Add configurable feature management system with JSON-based feature flags

Implement comprehensive feature toggle system allowing menu options and features
to be controlled via JSON configuration fetched from API endpoint or static files.

Core System:
- Create FeatureConfig type system with page, feature, and limit-based flags
- Add React Context (FeatureProvider) that fetches from /api/user/features
- Implement custom hooks (useFeatures, useIsFeatureEnabled, useFeatureLimit, etc.)
- Default config disables everything except home page

Integration:
- Update PageHeader to filter menu items based on page flags
- Add ProtectedRoute component to guard routes
- Update VR menu to conditionally render items based on feature flags
- Update CreateDiagramModal to enable/disable options (private, encrypted, invite)
- Update ManageDiagramsModal to use configurable maxDiagrams limit

Configuration Files:
- Add static JSON files for local testing (none, free, basic, pro tiers)
- Add dev proxy for /api/user/features endpoint
- Include README with testing instructions

Updates:
- Complete CLAUDE.md naming conventions section
- Version bump to 0.0.8-27

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-11-18 06:52:39 -06:00
parent 6ea6eaaac7
commit c1503d959e
20 changed files with 723 additions and 76 deletions

View File

@ -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.
## 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.

View File

@ -1,7 +1,7 @@
{
"name": "immersive",
"private": true,
"version": "0.0.8-26",
"version": "0.0.8-27",
"type": "module",
"license": "MIT",
"engines": {

89
public/api/user/README.md Normal file
View File

@ -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 <token>` 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

26
public/api/user/features Normal file
View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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 <Navigate to="/" replace />;
}
return children;
}

View File

@ -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<void>;
}
export const FeatureContext = createContext<FeatureContextValue>({
config: DEFAULT_FEATURE_CONFIG,
isLoading: false,
error: null,
refetch: async () => {},
});

View File

@ -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<FeatureConfig> {
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<FeatureConfig>(DEFAULT_FEATURE_CONFIG);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(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 (
<FeatureContext.Provider value={contextValue}>
{children}
</FeatureContext.Provider>
);
}

View File

@ -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;
}

View File

@ -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 <Image w="32" h="32" src={user.picture} alt="user"/>
@ -22,11 +28,18 @@ export default function PageHeader() {
return <Button key="login" onClick={() => loginWithRedirect()}>Login</Button>
}
}
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 (

View File

@ -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}/>
<Pill>Basic</Pill>
disabled={!privateDesignsEnabled}/>
{!privateDesignsEnabled && <Pill>Basic</Pill>}
</Group>
<Group>
<Checkbox w={250}
@ -74,8 +81,8 @@ export default function CreateDiagramModal({createOpened, closeCreate}) {
onChange={(e) => {
setDiagram({...diagram, encrypted: e.currentTarget.checked})
}}
disabled={true}/>
<Pill>Pro</Pill>
disabled={!encryptedDesignsEnabled}/>
{!encryptedDesignsEnabled && <Pill>Pro</Pill>}
</Group>
<Group>
<Checkbox w={250}
@ -85,8 +92,8 @@ export default function CreateDiagramModal({createOpened, closeCreate}) {
onChange={(e) => {
setDiagram({...diagram, invite: e.currentTarget.checked})
}}
disabled={true}/>
<Pill>Pro</Pill>
disabled={!shareCollaborateEnabled}/>
{!shareCollaborateEnabled && <Pill>Pro</Pill>}
</Group>
<Group>
<Button key="create" onClick={createDiagram}>Create</Button>

View File

@ -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 <Button size="lg" onClick={openCreate} disabled={false}>Create</Button>
} else {
return (<Stack>
<Button key="create" size="lg" disabled={true}>Create</Button>
<Paper key="upgrademessage">You've reached the max number of diagrams for this Tier.</Paper>
<Paper key="upgrademessage">You've reached the max number of diagrams ({maxDiagrams}) for your current tier.</Paper>
<Button key="upgradebutton" size="xl">Upgrade To Pro</Button>
</Stack>)
}

View File

@ -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 <Group w={50}>Basic</Group>;
}
if (requiredTier === 'pro') {
return <Group w={50}>Pro!<IconStar size={11}/></Group>;
}
return null;
}
const availableInBasic = () => {
return <Group w={50}>Basic</Group>
}
const availableInPro = () => {
return <Group w={50}>Pro!<IconStar size={11}/></Group>
}
const enterImmersive = (e) => {
logger.info('entering immersive mode');
@ -119,58 +140,87 @@ export default function VrExperience() {
<Burger size="xl"/>
</Menu.Target>
<Menu.Dropdown>
{/* Home is always visible */}
<VrMenuItem
tip={"Exit modeling environment and go back to main site"}
onClick={() => {
navigate("/")
}}
label="Home"
availableIcon={availableInFree()}/>
availableIcon={null}/>
{enterImmersiveEnabled && (
<VrMenuItem
tip={immersiveDisabled ? "Browser does not support WebXR. Immersive experience best viewed with Meta Quest headset" : "Enter Immersive Mode"}
onClick={enterImmersive}
label="Enter Immersive Mode"
availableIcon={availableInFree()}/>
availableIcon={getTierIndicator('free')}/>
)}
{launchMetaQuestEnabled && (
<VrMenuItem
tip="Open a new window and automatically send experience to your Meta Quest headset"
onClick={() => {
window.open('https://www.oculus.com/open_url/?url=' + window.location.href, 'launchQuest', 'popup')
}}
label="Launch On Meta Quest"
availableIcon={availableInFree()}/>
availableIcon={getTierIndicator('free')}/>
)}
{editDataEnabled && (
<>
<Menu.Divider/>
<VrMenuItem
tip="Edit data on desktop (Best for large amounts of text or images). After adding data, you can enter immersive mode to further refine the model."
label="Edit Data"
onClick={null}
availableIcon={availableInFree()}/>
<Menu.Divider/>
availableIcon={getTierIndicator('free')}/>
</>
)}
{(createDiagramEnabled || createFromTemplateEnabled || manageDiagramsEnabled) && <Menu.Divider/>}
{createDiagramEnabled && (
<VrMenuItem
tip="Create a new diagram from scratch"
label="Create"
onClick={openCreate}
availableIcon={availableInFree()}/>
availableIcon={getTierIndicator('free')}/>
)}
{createFromTemplateEnabled && (
<VrMenuItem
tip="Create a new diagram from predefined template"
label="Create From Template"
onClick={null}
availableIcon={availableInBasic()}/>
availableIcon={getTierIndicator('basic')}/>
)}
{manageDiagramsEnabled && (
<VrMenuItem
tip="Manage Diagrams"
label="Manage"
onClick={openManage}
availableIcon={availableInFree()}/>
<Menu.Divider/>
availableIcon={getTierIndicator('free')}/>
)}
{(shareCollaborateEnabled || configEnabled) && <Menu.Divider/>}
{shareCollaborateEnabled && (
<VrMenuItem
tip="Share your model with others and collaborate in real time with others. This is a paid feature."
label="Share"
onClick={null}
availableIcon={availableInPro()}/>
availableIcon={getTierIndicator('pro')}/>
)}
{configEnabled && (
<VrMenuItem
tip="Configure settings for your VR experience"
label="Config"
onClick={openConfig}
availableIcon={availableInFree()}/>
availableIcon={getTierIndicator('free')}/>
)}
</Menu.Dropdown>
</Menu>
</Affix>

View File

@ -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
}}>
<FeatureProvider>
<RouterProvider router={webRouter}/>
</FeatureProvider>
</Auth0Provider>
)
}

View File

@ -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: (<Documentation/>)
element: (
<ProtectedRoute page="documentation">
<Documentation/>
</ProtectedRoute>
)
}, {
path: "/examples",
element: (<Examples/>)
element: (
<ProtectedRoute page="examples">
<Examples/>
</ProtectedRoute>
)
}, {
path: "/Pricing",
element: (<Pricing/>)
element: (
<ProtectedRoute page="pricing">
<Pricing/>
</ProtectedRoute>
)
}, {
path: "/db/public/:db",
element: (<VrExperience/>)
element: (
<ProtectedRoute page="vrExperience">
<VrExperience/>
</ProtectedRoute>
)
}, {
path: "/db/private/:db",
element: (<VrExperience/>)
element: (
<ProtectedRoute page="vrExperience">
<VrExperience/>
</ProtectedRoute>
)
}, {
path: "*",
element: (<NotFound/>)

112
src/util/featureConfig.ts Normal file
View File

@ -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];
}

View File

@ -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: "/"