diff --git a/CLAUDE.md b/CLAUDE.md index 0b3eb16..c95a2c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,4 +108,30 @@ Databases can be optionally encrypted. The `Encryption` class handles AES encryp - `VITE_SYNCDB_ENDPOINT`: Remote database sync endpoint Check `.env.local` for local configuration. -- document the toolId and material naming conventions. \ No newline at end of file + +## Naming Conventions + +### Tool and Material Naming + +**Material Names:** Materials follow the pattern `material-{color}` where `{color}` is the hex color string (e.g., `material-#ff0000` for red). + +**Tool Mesh Names:** Tools use the pattern `tool-{toolType}-{color}`: +- Example: `tool-BOX-#ff0000` (red box tool) +- ToolTypes: `BOX`, `SPHERE`, `CYLINDER`, `CONE`, `PLANE`, `PERSON` + +**Tool Instance Names:** `tool-instance-{toolType}-{color}` (e.g., `tool-instance-BOX-#ff0000`) + +**Implementation details:** +- 16 predefined toolbox colors (see docs/NAMING_CONVENTIONS.md) +- Materials created in `src/toolbox/functions/buildColor.ts` +- Tool meshes created in `src/toolbox/functions/buildTool.ts` +- When extracting colors from materials, use: `emissiveColor || diffuseColor` (priority order) + +### Rendering Modes + +Three rendering modes affect material properties: +1. **Lightmap with Lighting**: Uses `diffuseColor` + `lightmapTexture` (expensive) +2. **Unlit with Emissive Texture** (default): Uses `emissiveColor` + `emissiveTexture` (lightmap) +3. **Flat Emissive**: Uses only `emissiveColor` (fastest) + +See `src/util/renderingMode.ts` and `src/util/lightmapGenerator.ts` for implementation. \ No newline at end of file diff --git a/package.json b/package.json index 7cef81b..bf3df75 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "immersive", "private": true, - "version": "0.0.8-26", + "version": "0.0.8-27", "type": "module", "license": "MIT", "engines": { diff --git a/public/api/user/README.md b/public/api/user/README.md new file mode 100644 index 0000000..bee9d43 --- /dev/null +++ b/public/api/user/README.md @@ -0,0 +1,89 @@ +# Feature Configuration Testing + +This directory contains static JSON files for testing different user tiers locally. + +## Available Configurations + +### Default: `features` (none tier) +- **What you see**: Only the home page +- **All pages and features**: Disabled +- **Use case**: Unauthenticated users or when API is unavailable + +### Free Tier: `features-free.json` +- **Pages**: All marketing pages + VR Experience +- **Features**: Basic diagram creation, management, immersive mode +- **Limits**: 6 diagrams max, 100MB storage +- **No access to**: Templates, private/encrypted designs, collaboration + +### Basic Tier: `features-basic.json` +- **Pages**: All pages available +- **Features**: Free features + templates + private designs +- **Limits**: 25 diagrams max, 500MB storage +- **No access to**: Encrypted designs, collaboration + +### Pro Tier: `features-pro.json` +- **Pages**: All pages available +- **Features**: Everything unlocked +- **Limits**: Unlimited (indicated by -1) + +## How to Test Locally + +### Method 1: Copy the file you want to test +```bash +# Test free tier +cp public/api/user/features-free.json public/api/user/features + +# Test basic tier +cp public/api/user/features-basic.json public/api/user/features + +# Test pro tier +cp public/api/user/features-pro.json public/api/user/features + +# Test none/default (locked down) +cp public/api/user/features-none.json public/api/user/features +``` + +### Method 2: Symlink (easier for switching) +```bash +# Remove the default file +rm public/api/user/features + +# Create a symlink to the tier you want to test +ln -s features-free.json public/api/user/features +# or +ln -s features-basic.json public/api/user/features +# or +ln -s features-pro.json public/api/user/features +``` + +## What Changes Between Tiers + +| Feature | None | Free | Basic | Pro | +|---------|------|------|-------|-----| +| Pages (Examples, Docs, Pricing) | ❌ | ✅ | ✅ | ✅ | +| VR Experience | ❌ | ✅ | ✅ | ✅ | +| Create Diagram | ❌ | ✅ | ✅ | ✅ | +| Create From Template | ❌ | ❌ | ✅ | ✅ | +| Private Designs | ❌ | ❌ | ✅ | ✅ | +| Encrypted Designs | ❌ | ❌ | ❌ | ✅ | +| Share/Collaborate | ❌ | ❌ | ❌ | ✅ | +| Max Diagrams | 0 | 6 | 25 | ∞ | +| Storage | 0 | 100MB | 500MB | ∞ | + +## Backend Implementation (Future) + +When you're ready to implement the backend, create an endpoint at: +``` +GET https://www.deepdiagram.com/api/user/features +``` + +The endpoint should: +1. Validate the Auth0 JWT token from `Authorization: Bearer ` header +2. Query the user's subscription tier from your database +3. Return JSON matching one of these structures based on their tier +4. Handle errors gracefully (401 for invalid token, 403 for unauthorized) + +The frontend will automatically fall back to the static `features` file if: +- User is not authenticated +- API returns an error +- Network request fails diff --git a/public/api/user/features b/public/api/user/features new file mode 100644 index 0000000..a7f36d0 --- /dev/null +++ b/public/api/user/features @@ -0,0 +1,26 @@ +{ + "tier": "none", + "pages": { + "examples": false, + "documentation": false, + "pricing": false, + "vrExperience": true + }, + "features": { + "createDiagram": false, + "createFromTemplate": false, + "manageDiagrams": false, + "shareCollaborate": false, + "privateDesigns": false, + "encryptedDesigns": false, + "editData": false, + "config": false, + "enterImmersive": true, + "launchMetaQuest": true + }, + "limits": { + "maxDiagrams": 0, + "maxCollaborators": 0, + "storageQuotaMB": 0 + } +} diff --git a/public/api/user/features-basic.json b/public/api/user/features-basic.json new file mode 100644 index 0000000..7227ad6 --- /dev/null +++ b/public/api/user/features-basic.json @@ -0,0 +1,26 @@ +{ + "tier": "basic", + "pages": { + "examples": true, + "documentation": true, + "pricing": true, + "vrExperience": true + }, + "features": { + "createDiagram": true, + "createFromTemplate": true, + "manageDiagrams": true, + "shareCollaborate": false, + "privateDesigns": true, + "encryptedDesigns": false, + "editData": true, + "config": true, + "enterImmersive": true, + "launchMetaQuest": true + }, + "limits": { + "maxDiagrams": 25, + "maxCollaborators": 0, + "storageQuotaMB": 500 + } +} diff --git a/public/api/user/features-free.json b/public/api/user/features-free.json new file mode 100644 index 0000000..9b88239 --- /dev/null +++ b/public/api/user/features-free.json @@ -0,0 +1,26 @@ +{ + "tier": "free", + "pages": { + "examples": true, + "documentation": true, + "pricing": true, + "vrExperience": true + }, + "features": { + "createDiagram": true, + "createFromTemplate": false, + "manageDiagrams": true, + "shareCollaborate": false, + "privateDesigns": false, + "encryptedDesigns": false, + "editData": true, + "config": true, + "enterImmersive": true, + "launchMetaQuest": true + }, + "limits": { + "maxDiagrams": 6, + "maxCollaborators": 0, + "storageQuotaMB": 100 + } +} diff --git a/public/api/user/features-none.json b/public/api/user/features-none.json new file mode 100644 index 0000000..5cbcc46 --- /dev/null +++ b/public/api/user/features-none.json @@ -0,0 +1,26 @@ +{ + "tier": "none", + "pages": { + "examples": false, + "documentation": false, + "pricing": false, + "vrExperience": false + }, + "features": { + "createDiagram": false, + "createFromTemplate": false, + "manageDiagrams": false, + "shareCollaborate": false, + "privateDesigns": false, + "encryptedDesigns": false, + "editData": false, + "config": false, + "enterImmersive": false, + "launchMetaQuest": false + }, + "limits": { + "maxDiagrams": 0, + "maxCollaborators": 0, + "storageQuotaMB": 0 + } +} diff --git a/public/api/user/features-pro.json b/public/api/user/features-pro.json new file mode 100644 index 0000000..8069625 --- /dev/null +++ b/public/api/user/features-pro.json @@ -0,0 +1,26 @@ +{ + "tier": "pro", + "pages": { + "examples": true, + "documentation": true, + "pricing": true, + "vrExperience": true + }, + "features": { + "createDiagram": true, + "createFromTemplate": true, + "manageDiagrams": true, + "shareCollaborate": true, + "privateDesigns": true, + "encryptedDesigns": true, + "editData": true, + "config": true, + "enterImmersive": true, + "launchMetaQuest": true + }, + "limits": { + "maxDiagrams": -1, + "maxCollaborators": -1, + "storageQuotaMB": -1 + } +} diff --git a/src/react/components/ProtectedRoute.tsx b/src/react/components/ProtectedRoute.tsx new file mode 100644 index 0000000..3dfd1c3 --- /dev/null +++ b/src/react/components/ProtectedRoute.tsx @@ -0,0 +1,23 @@ +import React, { ReactElement } from 'react'; +import { Navigate } from 'react-router-dom'; +import { useIsPageEnabled } from '../hooks/useFeatures'; +import { PageFlags } from '../../util/featureConfig'; + +interface ProtectedRouteProps { + page: keyof PageFlags; + children: ReactElement; +} + +/** + * Route guard component that redirects to home if the page is not enabled + */ +export function ProtectedRoute({ page, children }: ProtectedRouteProps) { + const isEnabled = useIsPageEnabled(page); + + if (!isEnabled) { + // Redirect to home page if feature is not enabled + return ; + } + + return children; +} diff --git a/src/react/contexts/FeatureContext.tsx b/src/react/contexts/FeatureContext.tsx new file mode 100644 index 0000000..c4a50ef --- /dev/null +++ b/src/react/contexts/FeatureContext.tsx @@ -0,0 +1,16 @@ +import { createContext } from 'react'; +import { FeatureConfig, DEFAULT_FEATURE_CONFIG } from '../../util/featureConfig'; + +export interface FeatureContextValue { + config: FeatureConfig; + isLoading: boolean; + error: Error | null; + refetch: () => Promise; +} + +export const FeatureContext = createContext({ + config: DEFAULT_FEATURE_CONFIG, + isLoading: false, + error: null, + refetch: async () => {}, +}); diff --git a/src/react/contexts/FeatureProvider.tsx b/src/react/contexts/FeatureProvider.tsx new file mode 100644 index 0000000..04a7570 --- /dev/null +++ b/src/react/contexts/FeatureProvider.tsx @@ -0,0 +1,104 @@ +import { ReactNode, useCallback, useEffect, useState } from 'react'; +import { useAuth0 } from '@auth0/auth0-react'; +import { FeatureContext } from './FeatureContext'; +import { FeatureConfig, DEFAULT_FEATURE_CONFIG } from '../../util/featureConfig'; +import log from 'loglevel'; + +const logger = log.getLogger('FeatureProvider'); + +interface FeatureProviderProps { + children: ReactNode; +} + +/** + * Fetches feature configuration from the API endpoint + */ +async function fetchFeatureConfig(accessToken: string | undefined): Promise { + try { + const headers: HeadersInit = { + 'Content-Type': 'application/json', + }; + + // Include auth token if available + if (accessToken) { + headers['Authorization'] = `Bearer ${accessToken}`; + } + + const response = await fetch('/api/user/features', { + method: 'GET', + headers, + }); + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + logger.info('User not authenticated or not authorized, using default config'); + return DEFAULT_FEATURE_CONFIG; + } + throw new Error(`Failed to fetch feature config: ${response.status} ${response.statusText}`); + } + + const config: FeatureConfig = await response.json(); + logger.info('Feature config loaded:', config); + return config; + } catch (error) { + logger.error('Error fetching feature config:', error); + throw error; + } +} + +export function FeatureProvider({ children }: FeatureProviderProps) { + const { isAuthenticated, isLoading: authLoading, getAccessTokenSilently } = useAuth0(); + const [config, setConfig] = useState(DEFAULT_FEATURE_CONFIG); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const loadFeatures = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + let accessToken: string | undefined; + + if (isAuthenticated) { + try { + accessToken = await getAccessTokenSilently(); + } catch (err) { + logger.warn('Failed to get access token:', err); + } + } + + const fetchedConfig = await fetchFeatureConfig(accessToken); + setConfig(fetchedConfig); + } catch (err) { + const error = err instanceof Error ? err : new Error('Unknown error fetching features'); + setError(error); + // On error, use default config (everything disabled) + setConfig(DEFAULT_FEATURE_CONFIG); + } finally { + setIsLoading(false); + } + }, [isAuthenticated, getAccessTokenSilently]); + + // Load features when auth state changes + useEffect(() => { + // Wait for auth to finish loading + if (authLoading) { + return; + } + + loadFeatures(); + }, [authLoading, loadFeatures]); + + const contextValue = { + config, + isLoading, + error, + refetch: loadFeatures, + }; + + return ( + + {children} + + ); +} diff --git a/src/react/hooks/useFeatures.ts b/src/react/hooks/useFeatures.ts new file mode 100644 index 0000000..438bc08 --- /dev/null +++ b/src/react/hooks/useFeatures.ts @@ -0,0 +1,48 @@ +import { useContext } from 'react'; +import { FeatureContext } from '../contexts/FeatureContext'; +import { FeatureFlags, PageFlags, LimitFlags } from '../../util/featureConfig'; + +/** + * Hook to access the full feature configuration context + */ +export function useFeatures() { + const context = useContext(FeatureContext); + + if (!context) { + throw new Error('useFeatures must be used within a FeatureProvider'); + } + + return context; +} + +/** + * Hook to check if a specific page is enabled + */ +export function useIsPageEnabled(page: keyof PageFlags): boolean { + const { config } = useFeatures(); + return config.pages[page]; +} + +/** + * Hook to check if a specific feature is enabled + */ +export function useIsFeatureEnabled(feature: keyof FeatureFlags): boolean { + const { config } = useFeatures(); + return config.features[feature]; +} + +/** + * Hook to get a specific limit value + */ +export function useFeatureLimit(limit: keyof LimitFlags): number { + const { config } = useFeatures(); + return config.limits[limit]; +} + +/** + * Hook to get the current user tier + */ +export function useUserTier() { + const { config } = useFeatures(); + return config.tier; +} diff --git a/src/react/pageHeader.tsx b/src/react/pageHeader.tsx index ac6b845..f2e7681 100644 --- a/src/react/pageHeader.tsx +++ b/src/react/pageHeader.tsx @@ -2,9 +2,15 @@ import {Anchor, AppShell, Box, Burger, Button, Group, Image, Menu, Stack} from " import React from "react"; import {Link} from "react-router-dom"; import {useAuth0} from "@auth0/auth0-react"; +import {useIsPageEnabled} from "./hooks/useFeatures"; export default function PageHeader() { const {user, isAuthenticated, loginWithRedirect, logout} = useAuth0(); + const examplesEnabled = useIsPageEnabled('examples'); + const documentationEnabled = useIsPageEnabled('documentation'); + const pricingEnabled = useIsPageEnabled('pricing'); + const vrExperienceEnabled = useIsPageEnabled('vrExperience'); + const picture = () => { if (user.picture) { return user @@ -22,11 +28,18 @@ export default function PageHeader() { return } } - const items = [{name: 'Examples', href: '/examples', key: 'examples'}, - {name: 'About', href: '/', key: 'about'}, - {name: 'Documentation', href: '/documentation', key: 'documentation'}, - {name: 'Pricing', href: '/pricing', key: 'pricing'}, - {name: 'VR Experience', href: '/db/public/local', key: 'vrexperience'}] + + // Define all possible menu items + const allItems = [ + {name: 'Examples', href: '/examples', key: 'examples', enabled: examplesEnabled}, + {name: 'About', href: '/', key: 'about', enabled: true}, // About (home) is always visible + {name: 'Documentation', href: '/documentation', key: 'documentation', enabled: documentationEnabled}, + {name: 'Pricing', href: '/pricing', key: 'pricing', enabled: pricingEnabled}, + {name: 'VR Experience', href: '/db/public/local', key: 'vrexperience', enabled: vrExperienceEnabled} + ]; + + // Filter to only enabled items + const items = allItems.filter(item => item.enabled) const mainMenu = function () { return items.map((item) => { return ( diff --git a/src/react/pages/createDiagramModal.tsx b/src/react/pages/createDiagramModal.tsx index 7e4a5fb..cabc885 100644 --- a/src/react/pages/createDiagramModal.tsx +++ b/src/react/pages/createDiagramModal.tsx @@ -3,10 +3,17 @@ import {usePouch} from "use-pouchdb"; import {useState} from "react"; import {v4} from "uuid"; import log from "loglevel"; +import {useIsFeatureEnabled} from "../hooks/useFeatures"; export default function CreateDiagramModal({createOpened, closeCreate}) { const logger = log.getLogger('createDiagramModal'); const db = usePouch(); + + // Feature flags + const privateDesignsEnabled = useIsFeatureEnabled('privateDesigns'); + const encryptedDesignsEnabled = useIsFeatureEnabled('encryptedDesigns'); + const shareCollaborateEnabled = useIsFeatureEnabled('shareCollaborate'); + const [diagram, setDiagram] = useState({ name: '', description: '', @@ -63,8 +70,8 @@ export default function CreateDiagramModal({createOpened, closeCreate}) { onChange={(e) => { setDiagram({...diagram, private: e.currentTarget.checked}) }} - disabled={true}/> - Basic + disabled={!privateDesignsEnabled}/> + {!privateDesignsEnabled && Basic} { setDiagram({...diagram, encrypted: e.currentTarget.checked}) }} - disabled={true}/> - Pro + disabled={!encryptedDesignsEnabled}/> + {!encryptedDesignsEnabled && Pro} { setDiagram({...diagram, invite: e.currentTarget.checked}) }} - disabled={true}/> - Pro + disabled={!shareCollaborateEnabled}/> + {!shareCollaborateEnabled && Pro} diff --git a/src/react/pages/manageDiagramsModal.tsx b/src/react/pages/manageDiagramsModal.tsx index 7c50151..a42cfe1 100644 --- a/src/react/pages/manageDiagramsModal.tsx +++ b/src/react/pages/manageDiagramsModal.tsx @@ -4,11 +4,13 @@ import {useDoc, usePouch} from "use-pouchdb"; import {IconTrash} from "@tabler/icons-react"; import {Link} from "react-router-dom"; import log from "loglevel"; +import {useFeatureLimit} from "../hooks/useFeatures"; export default function ManageDiagramsModal({openCreate, manageOpened, closeManage}) { const logger = log.getLogger('manageDiagramsModal'); const {doc: diagram, error} = useDoc('directory', {}, {_id: 'directory', diagrams: []}); const db = usePouch(); + const maxDiagrams = useFeatureLimit('maxDiagrams'); if (error) { if (error.status === 404) { @@ -45,12 +47,15 @@ export default function ManageDiagramsModal({openCreate, manageOpened, closeMana }); const buildCreateButton = () => { - if (diagrams.length < 6) { + // Check against the configured maxDiagrams limit + const hasReachedLimit = maxDiagrams > 0 && diagrams.length >= maxDiagrams; + + if (!hasReachedLimit) { return } else { return ( - You've reached the max number of diagrams for this Tier. + You've reached the max number of diagrams ({maxDiagrams}) for your current tier. ) } diff --git a/src/react/pages/vrExperience.tsx b/src/react/pages/vrExperience.tsx index b72265e..ceeb9f1 100644 --- a/src/react/pages/vrExperience.tsx +++ b/src/react/pages/vrExperience.tsx @@ -11,6 +11,7 @@ import {useDisclosure} from "@mantine/hooks"; import ConfigModal from "./configModal"; import FirstVisitVr from "../instructions/firstVisitVr"; import log from "loglevel"; +import {useIsFeatureEnabled, useUserTier} from "../hooks/useFeatures"; let vrApp: VrApp = null; @@ -20,6 +21,18 @@ const defaultManage = window.localStorage.getItem('manageOpened') === 'true'; export default function VrExperience() { const logger = log.getLogger('vrExperience'); const params = useParams(); + + // Feature flags + const createDiagramEnabled = useIsFeatureEnabled('createDiagram'); + const createFromTemplateEnabled = useIsFeatureEnabled('createFromTemplate'); + const manageDiagramsEnabled = useIsFeatureEnabled('manageDiagrams'); + const shareCollaborateEnabled = useIsFeatureEnabled('shareCollaborate'); + const editDataEnabled = useIsFeatureEnabled('editData'); + const configEnabled = useIsFeatureEnabled('config'); + const enterImmersiveEnabled = useIsFeatureEnabled('enterImmersive'); + const launchMetaQuestEnabled = useIsFeatureEnabled('launchMetaQuest'); + const userTier = useUserTier(); + const saveState = (key, value) => { logger.debug('saving', key, value) window.localStorage.setItem(key, value ? 'true' : 'false'); @@ -72,15 +85,23 @@ export default function VrExperience() { const [immersiveDisabled, setImmersiveDisabled] = useState(true); const navigate = useNavigate(); - const availableInFree = () => { + // Tier indicator functions - now using the actual user tier + const getTierIndicator = (requiredTier: 'free' | 'basic' | 'pro') => { + if (requiredTier === 'free' || userTier === requiredTier || + (userTier === 'pro' && (requiredTier === 'basic' || requiredTier === 'free')) || + (userTier === 'basic' && requiredTier === 'free')) { + return null; // User has access, no indicator needed + } + + // Show tier requirement + if (requiredTier === 'basic') { + return Basic; + } + if (requiredTier === 'pro') { + return Pro!; + } return null; } - const availableInBasic = () => { - return Basic - } - const availableInPro = () => { - return Pro! - } const enterImmersive = (e) => { logger.info('entering immersive mode'); @@ -119,58 +140,87 @@ export default function VrExperience() { + {/* Home is always visible */} { navigate("/") }} label="Home" - availableIcon={availableInFree()}/> - - { - window.open('https://www.oculus.com/open_url/?url=' + window.location.href, 'launchQuest', 'popup') - }} - label="Launch On Meta Quest" - availableIcon={availableInFree()}/> - - - - - - - - - + availableIcon={null}/> + + {enterImmersiveEnabled && ( + + )} + + {launchMetaQuestEnabled && ( + { + window.open('https://www.oculus.com/open_url/?url=' + window.location.href, 'launchQuest', 'popup') + }} + label="Launch On Meta Quest" + availableIcon={getTierIndicator('free')}/> + )} + + {editDataEnabled && ( + <> + + + + )} + + {(createDiagramEnabled || createFromTemplateEnabled || manageDiagramsEnabled) && } + + {createDiagramEnabled && ( + + )} + + {createFromTemplateEnabled && ( + + )} + + {manageDiagramsEnabled && ( + + )} + + {(shareCollaborateEnabled || configEnabled) && } + + {shareCollaborateEnabled && ( + + )} + + {configEnabled && ( + + )} diff --git a/src/react/webApp.tsx b/src/react/webApp.tsx index c5b941c..8e28c3f 100644 --- a/src/react/webApp.tsx +++ b/src/react/webApp.tsx @@ -4,6 +4,7 @@ import React from "react"; import {RouterProvider} from "react-router-dom"; import {webRouter} from "./webRouter"; import {Auth0Provider} from "@auth0/auth0-react"; +import {FeatureProvider} from "./contexts/FeatureProvider"; export default function WebApp() { document.addEventListener('promptpassword', () => { @@ -20,7 +21,9 @@ export default function WebApp() { authorizationParams={{ redirect_uri: window.location.origin }}> - + + + ) } diff --git a/src/react/webRouter.tsx b/src/react/webRouter.tsx index a256ba0..18b53b3 100644 --- a/src/react/webRouter.tsx +++ b/src/react/webRouter.tsx @@ -6,6 +6,7 @@ import Examples from "./marketing/examples"; import Pricing from "./marketing/pricing"; import VrExperience from "./pages/vrExperience"; import NotFound from "./pages/notFound"; +import {ProtectedRoute} from "./components/ProtectedRoute"; export const webRouter = createBrowserRouter([ { @@ -16,19 +17,39 @@ export const webRouter = createBrowserRouter([ }, { path: "/documentation", - element: () + element: ( + + + + ) }, { path: "/examples", - element: () + element: ( + + + + ) }, { path: "/Pricing", - element: () + element: ( + + + + ) }, { path: "/db/public/:db", - element: () + element: ( + + + + ) }, { path: "/db/private/:db", - element: () + element: ( + + + + ) }, { path: "*", element: () diff --git a/src/util/featureConfig.ts b/src/util/featureConfig.ts new file mode 100644 index 0000000..0db1862 --- /dev/null +++ b/src/util/featureConfig.ts @@ -0,0 +1,112 @@ +/** + * Feature configuration system for controlling access to pages, features, and limits + * based on user tier/subscription level. + */ + +export type UserTier = 'none' | 'free' | 'basic' | 'pro'; + +export interface PageFlags { + examples: boolean; + documentation: boolean; + pricing: boolean; + vrExperience: boolean; +} + +export interface FeatureFlags { + createDiagram: boolean; + createFromTemplate: boolean; + manageDiagrams: boolean; + shareCollaborate: boolean; + privateDesigns: boolean; + encryptedDesigns: boolean; + editData: boolean; + config: boolean; + enterImmersive: boolean; + launchMetaQuest: boolean; +} + +export interface LimitFlags { + maxDiagrams: number; + maxCollaborators: number; + storageQuotaMB: number; +} + +export interface FeatureConfig { + tier: UserTier; + pages: PageFlags; + features: FeatureFlags; + limits: LimitFlags; +} + +/** + * Default configuration for unauthenticated users or when API fetch fails. + * Everything is disabled except the home page. + */ +export const DEFAULT_FEATURE_CONFIG: FeatureConfig = { + tier: 'none', + pages: { + examples: false, + documentation: false, + pricing: false, + vrExperience: false, + }, + features: { + createDiagram: false, + createFromTemplate: false, + manageDiagrams: false, + shareCollaborate: false, + privateDesigns: false, + encryptedDesigns: false, + editData: false, + config: false, + enterImmersive: false, + launchMetaQuest: false, + }, + limits: { + maxDiagrams: 0, + maxCollaborators: 0, + storageQuotaMB: 0, + }, +}; + +/** + * Type guard to check if a page name is valid + */ +export function isValidPage(page: string): page is keyof PageFlags { + return page in DEFAULT_FEATURE_CONFIG.pages; +} + +/** + * Type guard to check if a feature name is valid + */ +export function isValidFeature(feature: string): feature is keyof FeatureFlags { + return feature in DEFAULT_FEATURE_CONFIG.features; +} + +/** + * Type guard to check if a limit name is valid + */ +export function isValidLimit(limit: string): limit is keyof LimitFlags { + return limit in DEFAULT_FEATURE_CONFIG.limits; +} + +/** + * Helper to check if a page is enabled in the config + */ +export function isPageEnabled(config: FeatureConfig, page: keyof PageFlags): boolean { + return config.pages[page]; +} + +/** + * Helper to check if a feature is enabled in the config + */ +export function isFeatureEnabled(config: FeatureConfig, feature: keyof FeatureFlags): boolean { + return config.features[feature]; +} + +/** + * Helper to get a limit value from the config + */ +export function getFeatureLimit(config: FeatureConfig, limit: keyof LimitFlags): number { + return config.limits[limit]; +} diff --git a/vite.config.ts b/vite.config.ts index 21e2074..1508757 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -36,7 +36,7 @@ export default defineConfig({ '^/api/images': { target: 'https://www.deepdiagram.com/', changeOrigin: true, - }, + } } }, @@ -54,7 +54,7 @@ export default defineConfig({ '^/api/images': { target: 'https://www.deepdiagram.com/', changeOrigin: true, - }, + } } }, base: "/"