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:
parent
6ea6eaaac7
commit
c1503d959e
28
CLAUDE.md
28
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.
|
||||
|
||||
## 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.
|
||||
@ -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
89
public/api/user/README.md
Normal 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
26
public/api/user/features
Normal 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
|
||||
}
|
||||
}
|
||||
26
public/api/user/features-basic.json
Normal file
26
public/api/user/features-basic.json
Normal 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
|
||||
}
|
||||
}
|
||||
26
public/api/user/features-free.json
Normal file
26
public/api/user/features-free.json
Normal 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
|
||||
}
|
||||
}
|
||||
26
public/api/user/features-none.json
Normal file
26
public/api/user/features-none.json
Normal 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
|
||||
}
|
||||
}
|
||||
26
public/api/user/features-pro.json
Normal file
26
public/api/user/features-pro.json
Normal 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
|
||||
}
|
||||
}
|
||||
23
src/react/components/ProtectedRoute.tsx
Normal file
23
src/react/components/ProtectedRoute.tsx
Normal 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;
|
||||
}
|
||||
16
src/react/contexts/FeatureContext.tsx
Normal file
16
src/react/contexts/FeatureContext.tsx
Normal 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 () => {},
|
||||
});
|
||||
104
src/react/contexts/FeatureProvider.tsx
Normal file
104
src/react/contexts/FeatureProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/react/hooks/useFeatures.ts
Normal file
48
src/react/hooks/useFeatures.ts
Normal 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;
|
||||
}
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
112
src/util/featureConfig.ts
Normal 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];
|
||||
}
|
||||
@ -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: "/"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user