Implement three-state feature flag system with upgrade badges

Feature States:
- 'on': Feature fully accessible
- 'off': Feature hidden from menus
- 'coming-soon': Visible with "Coming Soon!" badge, not clickable
- 'basic': Visible with "Sign Up for Free" badge, triggers Auth0 login
- 'pro': Visible with "Upgrade to Pro" badge (for future upgrade flow)

Changes:
- Update FeatureState type to support 5 states (on/off/coming-soon/basic/pro)
- Consolidate GUEST_FEATURE_CONFIG as DEFAULT_FEATURE_CONFIG
- Create ComingSoonBadge component for coming-soon features
- Create UpgradeBadge component for basic/pro tier requirements
- Update VR Experience hamburger menu to maintain open/closed state
- Make menu default to open, persist state in localStorage
- Make 'basic' features clickable to trigger Auth0 sign-in
- Update createDiagramModal to show appropriate badges
- Fix camera initial position to match VR rig (prevent flip on load)

🤖 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-21 14:33:11 -06:00
parent 31dd8a89da
commit 1c50dd5c84
9 changed files with 355 additions and 161 deletions

View File

@ -0,0 +1,22 @@
import React from 'react';
import { Badge } from '@mantine/core';
interface ComingSoonBadgeProps {
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
}
/**
* Small pill badge to indicate a feature or page is coming soon
*/
export default function ComingSoonBadge({ size = 'sm' }: ComingSoonBadgeProps) {
return (
<Badge
size={size}
variant="gradient"
gradient={{ from: 'blue', to: 'cyan', deg: 90 }}
style={{ marginLeft: '8px' }}
>
Coming Soon!
</Badge>
);
}

View File

@ -0,0 +1,35 @@
import React from 'react';
import { Badge } from '@mantine/core';
import { IconStar } from '@tabler/icons-react';
interface UpgradeBadgeProps {
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
tier?: 'basic' | 'pro';
onClick?: () => void;
}
/**
* Small pill badge to indicate a feature requires sign up or upgrade
*/
export default function UpgradeBadge({ size = 'sm', tier, onClick }: UpgradeBadgeProps) {
const tierLabel = tier === 'basic' ? 'Sign Up for Free' : tier === 'pro' ? 'Upgrade to Pro' : 'Upgrade';
const gradient = tier === 'pro'
? { from: 'yellow', to: 'orange', deg: 90 }
: { from: 'indigo', to: 'grape', deg: 90 };
return (
<Badge
size={size}
variant="gradient"
gradient={gradient}
style={{
marginLeft: '8px',
cursor: onClick ? 'pointer' : 'default'
}}
rightSection={tier === 'pro' ? <IconStar size={12} /> : undefined}
onClick={onClick}
>
{tierLabel}
</Badge>
);
}

View File

@ -1,7 +1,7 @@
import { ReactNode, useCallback, useEffect, useState } from 'react'; import { ReactNode, useCallback, useEffect, useState } from 'react';
import { useAuth0 } from '@auth0/auth0-react'; import { useAuth0 } from '@auth0/auth0-react';
import { FeatureContext } from './FeatureContext'; import { FeatureContext } from './FeatureContext';
import { FeatureConfig, DEFAULT_FEATURE_CONFIG, GUEST_FEATURE_CONFIG } from '../../util/featureConfig'; import { FeatureConfig, DEFAULT_FEATURE_CONFIG } from '../../util/featureConfig';
import log from 'loglevel'; import log from 'loglevel';
const logger = log.getLogger('FeatureProvider'); const logger = log.getLogger('FeatureProvider');
@ -31,7 +31,7 @@ async function fetchFeatureConfig(accessToken: string | undefined): Promise<Feat
if (!response.ok) { if (!response.ok) {
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
logger.info('User not authenticated or not authorized, using default config'); logger.info('User not authenticated or not authorized, using default (guest) config');
return DEFAULT_FEATURE_CONFIG; return DEFAULT_FEATURE_CONFIG;
} }
throw new Error(`Failed to fetch feature config: ${response.status} ${response.statusText}`); throw new Error(`Failed to fetch feature config: ${response.status} ${response.statusText}`);
@ -48,7 +48,7 @@ async function fetchFeatureConfig(accessToken: string | undefined): Promise<Feat
export function FeatureProvider({ children }: FeatureProviderProps) { export function FeatureProvider({ children }: FeatureProviderProps) {
const { isAuthenticated, isLoading: authLoading, getAccessTokenSilently } = useAuth0(); const { isAuthenticated, isLoading: authLoading, getAccessTokenSilently } = useAuth0();
const [config, setConfig] = useState<FeatureConfig>(GUEST_FEATURE_CONFIG); // Start with guest config const [config, setConfig] = useState<FeatureConfig>(DEFAULT_FEATURE_CONFIG); // Start with default (guest) config
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
@ -67,10 +67,10 @@ export function FeatureProvider({ children }: FeatureProviderProps) {
} }
} }
// If not authenticated, use guest config // If not authenticated, use default (guest) config
if (!isAuthenticated) { if (!isAuthenticated) {
logger.info('User not authenticated, using guest config'); logger.info('User not authenticated, using default (guest) config');
setConfig(GUEST_FEATURE_CONFIG); setConfig(DEFAULT_FEATURE_CONFIG);
setIsLoading(false); setIsLoading(false);
return; return;
} }
@ -80,9 +80,9 @@ export function FeatureProvider({ children }: FeatureProviderProps) {
} catch (err) { } catch (err) {
const error = err instanceof Error ? err : new Error('Unknown error fetching features'); const error = err instanceof Error ? err : new Error('Unknown error fetching features');
setError(error); setError(error);
// On error, fallback to guest config for better UX // On error, fallback to default (guest) config for better UX
logger.warn('Error loading features, falling back to guest config'); logger.warn('Error loading features, falling back to default (guest) config');
setConfig(GUEST_FEATURE_CONFIG); setConfig(DEFAULT_FEATURE_CONFIG);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }

View File

@ -1,6 +1,6 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { FeatureContext } from '../contexts/FeatureContext'; import { FeatureContext } from '../contexts/FeatureContext';
import { FeatureFlags, PageFlags, LimitFlags } from '../../util/featureConfig'; import { FeatureFlags, FeatureState, LimitFlags, PageFlags } from '../../util/featureConfig';
/** /**
* Hook to access the full feature configuration context * Hook to access the full feature configuration context
@ -16,21 +16,29 @@ export function useFeatures() {
} }
/** /**
* Hook to check if a specific page is enabled * Hook to get the state of a specific page
*/ */
export function useIsPageEnabled(page: keyof PageFlags): boolean { export function usePageState(page: keyof PageFlags): FeatureState {
const { config } = useFeatures(); const { config } = useFeatures();
return config.pages[page]; return config.pages[page];
} }
/** /**
* Hook to check if a specific feature is enabled * Hook to get the state of a specific feature
*/ */
export function useIsFeatureEnabled(feature: keyof FeatureFlags): boolean { export function useFeatureState(feature: keyof FeatureFlags): FeatureState {
const { config } = useFeatures(); const { config } = useFeatures();
return config.features[feature]; return config.features[feature];
} }
/**
* Hook to check if a specific page is enabled (on)
*/
export function useIsPageEnabled(page: keyof PageFlags): boolean {
const { config } = useFeatures();
return config.pages[page] === 'on';
}
/** /**
* Hook to get a specific limit value * Hook to get a specific limit value
*/ */

View File

@ -2,14 +2,15 @@ import {Anchor, AppShell, Box, Burger, Button, Group, Image, Menu, Stack} from "
import React from "react"; import React from "react";
import {Link} from "react-router-dom"; import {Link} from "react-router-dom";
import {useAuth0} from "@auth0/auth0-react"; import {useAuth0} from "@auth0/auth0-react";
import {useIsPageEnabled} from "./hooks/useFeatures"; import {usePageState} from "./hooks/useFeatures";
import ComingSoonBadge from "./components/ComingSoonBadge";
export default function PageHeader() { export default function PageHeader() {
const {user, isAuthenticated, loginWithRedirect, logout} = useAuth0(); const {user, isAuthenticated, loginWithRedirect, logout} = useAuth0();
const examplesEnabled = useIsPageEnabled('examples'); const examplesState = usePageState('examples');
const documentationEnabled = useIsPageEnabled('documentation'); const documentationState = usePageState('documentation');
const pricingEnabled = useIsPageEnabled('pricing'); const pricingState = usePageState('pricing');
const vrExperienceEnabled = useIsPageEnabled('vrExperience'); const vrExperienceState = usePageState('vrExperience');
const picture = () => { const picture = () => {
if (user.picture) { if (user.picture) {
@ -29,32 +30,69 @@ export default function PageHeader() {
} }
} }
// Define all possible menu items // Define all possible menu items with their states
const allItems = [ const allItems = [
{name: 'Examples', href: '/examples', key: 'examples', enabled: examplesEnabled}, {name: 'Examples', href: '/examples', key: 'examples', state: examplesState},
{name: 'About', href: '/', key: 'about', enabled: true}, // About (home) is always visible {name: 'About', href: '/', key: 'about', state: 'on' as const}, // About (home) is always visible
{name: 'Documentation', href: '/documentation', key: 'documentation', enabled: documentationEnabled}, {name: 'Documentation', href: '/documentation', key: 'documentation', state: documentationState},
{name: 'Pricing', href: '/pricing', key: 'pricing', enabled: pricingEnabled}, {name: 'Pricing', href: '/pricing', key: 'pricing', state: pricingState},
{name: 'VR Experience', href: '/db/public/local', key: 'vrexperience', enabled: vrExperienceEnabled} {name: 'VR Experience', href: '/db/public/local', key: 'vrexperience', state: vrExperienceState}
]; ];
// Filter to only enabled items // Filter to only 'on' and 'coming-soon' items (hide 'off' items)
const items = allItems.filter(item => item.enabled) const items = allItems.filter(item => item.state !== 'off')
const mainMenu = function () { const mainMenu = function () {
return items.map((item) => { return items.map((item) => {
const isComingSoon = item.state === 'coming-soon';
return ( return (
<Anchor component={Link} key={item.key} to={item.href} p={5} c="myColor" bg="none" <Group key={item.key} gap="xs">
underline="hover">{item.name}</Anchor> <Anchor
component={isComingSoon ? 'span' : Link}
to={isComingSoon ? undefined : item.href}
p={5}
c={isComingSoon ? 'dimmed' : 'myColor'}
bg="none"
underline="hover"
style={{
cursor: isComingSoon ? 'not-allowed' : 'pointer',
pointerEvents: isComingSoon ? 'none' : 'auto'
}}
>
{item.name}
</Anchor>
{isComingSoon && <ComingSoonBadge size="xs" />}
</Group>
) )
}) })
} }
const miniMenu = function () { const miniMenu = function () {
return items.map((item) => { return items.map((item) => {
const isComingSoon = item.state === 'coming-soon';
return ( return (
<Menu.Item><Anchor size="xl" component={Link} key={item.key} <Menu.Item
to={item.href} p={5} key={item.key}
c="myColor" bg="none" disabled={isComingSoon}
underline="hover">{item.name}</Anchor></Menu.Item> >
<Group gap="xs">
<Anchor
size="xl"
component={isComingSoon ? 'span' : Link}
to={isComingSoon ? undefined : item.href}
p={5}
c={isComingSoon ? 'dimmed' : 'myColor'}
bg="none"
underline="hover"
style={{
cursor: isComingSoon ? 'not-allowed' : 'pointer',
pointerEvents: isComingSoon ? 'none' : 'auto'
}}
>
{item.name}
</Anchor>
{isComingSoon && <ComingSoonBadge size="xs" />}
</Group>
</Menu.Item>
) )
}) })
} }

View File

@ -3,16 +3,30 @@ import {usePouch} from "use-pouchdb";
import {useState} from "react"; import {useState} from "react";
import {v4} from "uuid"; import {v4} from "uuid";
import log from "loglevel"; import log from "loglevel";
import {useIsFeatureEnabled} from "../hooks/useFeatures"; import {useFeatureState} from "../hooks/useFeatures";
import ComingSoonBadge from "../components/ComingSoonBadge";
import UpgradeBadge from "../components/UpgradeBadge";
import {useAuth0} from "@auth0/auth0-react";
export default function CreateDiagramModal({createOpened, closeCreate}) { export default function CreateDiagramModal({createOpened, closeCreate}) {
const logger = log.getLogger('createDiagramModal'); const logger = log.getLogger('createDiagramModal');
const db = usePouch(); const db = usePouch();
const { loginWithRedirect } = useAuth0();
// Feature flags // Feature flags
const privateDesignsEnabled = useIsFeatureEnabled('privateDesigns'); const privateDesignsState = useFeatureState('privateDesigns');
const encryptedDesignsEnabled = useIsFeatureEnabled('encryptedDesigns'); const encryptedDesignsState = useFeatureState('encryptedDesigns');
const shareCollaborateEnabled = useIsFeatureEnabled('shareCollaborate'); const shareCollaborateState = useFeatureState('shareCollaborate');
const privateDesignsEnabled = privateDesignsState === 'on';
const encryptedDesignsEnabled = encryptedDesignsState === 'on';
const shareCollaborateEnabled = shareCollaborateState === 'on';
const handleSignUp = () => {
loginWithRedirect({
appState: { returnTo: window.location.pathname }
});
};
const [diagram, setDiagram] = useState({ const [diagram, setDiagram] = useState({
name: '', name: '',
@ -68,10 +82,16 @@ export default function CreateDiagramModal({createOpened, closeCreate}) {
label="Private" label="Private"
checked={diagram.private} checked={diagram.private}
onChange={(e) => { onChange={(e) => {
if (privateDesignsState === 'basic') {
handleSignUp();
} else {
setDiagram({...diagram, private: e.currentTarget.checked}) setDiagram({...diagram, private: e.currentTarget.checked})
}
}} }}
disabled={!privateDesignsEnabled}/> disabled={!privateDesignsEnabled && privateDesignsState !== 'basic'}/>
{!privateDesignsEnabled && <Pill>Basic</Pill>} {privateDesignsState === 'coming-soon' && <ComingSoonBadge />}
{privateDesignsState === 'basic' && <UpgradeBadge tier="basic" onClick={handleSignUp} />}
{privateDesignsState === 'pro' && <UpgradeBadge tier="pro" />}
</Group> </Group>
<Group> <Group>
<Checkbox w={250} <Checkbox w={250}
@ -79,10 +99,16 @@ export default function CreateDiagramModal({createOpened, closeCreate}) {
label="Encrypted" label="Encrypted"
checked={diagram.encrypted} checked={diagram.encrypted}
onChange={(e) => { onChange={(e) => {
if (encryptedDesignsState === 'basic') {
handleSignUp();
} else {
setDiagram({...diagram, encrypted: e.currentTarget.checked}) setDiagram({...diagram, encrypted: e.currentTarget.checked})
}
}} }}
disabled={!encryptedDesignsEnabled}/> disabled={!encryptedDesignsEnabled && encryptedDesignsState !== 'basic'}/>
{!encryptedDesignsEnabled && <Pill>Pro</Pill>} {encryptedDesignsState === 'coming-soon' && <ComingSoonBadge />}
{encryptedDesignsState === 'basic' && <UpgradeBadge tier="basic" onClick={handleSignUp} />}
{encryptedDesignsState === 'pro' && <UpgradeBadge tier="pro" />}
</Group> </Group>
<Group> <Group>
<Checkbox w={250} <Checkbox w={250}
@ -90,10 +116,16 @@ export default function CreateDiagramModal({createOpened, closeCreate}) {
label="Invite Collaborators" label="Invite Collaborators"
checked={diagram.invite} checked={diagram.invite}
onChange={(e) => { onChange={(e) => {
if (shareCollaborateState === 'basic') {
handleSignUp();
} else {
setDiagram({...diagram, invite: e.currentTarget.checked}) setDiagram({...diagram, invite: e.currentTarget.checked})
}
}} }}
disabled={!shareCollaborateEnabled}/> disabled={!shareCollaborateEnabled && shareCollaborateState !== 'basic'}/>
{!shareCollaborateEnabled && <Pill>Pro</Pill>} {shareCollaborateState === 'coming-soon' && <ComingSoonBadge />}
{shareCollaborateState === 'basic' && <UpgradeBadge tier="basic" onClick={handleSignUp} />}
{shareCollaborateState === 'pro' && <UpgradeBadge tier="pro" />}
</Group> </Group>
<Group> <Group>
<Button key="create" onClick={createDiagram}>Create</Button> <Button key="create" onClick={createDiagram}>Create</Button>

View File

@ -10,36 +10,57 @@ import {useNavigate, useParams} from "react-router-dom";
import {useDisclosure} from "@mantine/hooks"; import {useDisclosure} from "@mantine/hooks";
import ConfigModal from "./configModal"; import ConfigModal from "./configModal";
import log from "loglevel"; import log from "loglevel";
import {useIsFeatureEnabled, useUserTier} from "../hooks/useFeatures"; import {useFeatureState, useUserTier} from "../hooks/useFeatures";
import {useAuth0} from "@auth0/auth0-react"; import {useAuth0} from "@auth0/auth0-react";
import {GUEST_MODE_BANNER} from "../../content/upgradeCopy"; import {GUEST_MODE_BANNER} from "../../content/upgradeCopy";
import {exportDiagramAsJSON} from "../../util/functions/exportDiagramAsJSON"; import {exportDiagramAsJSON} from "../../util/functions/exportDiagramAsJSON";
import {isMobileVRDevice} from "../../util/deviceDetection"; import {isMobileVRDevice} from "../../util/deviceDetection";
import {DefaultScene} from "../../defaultScene"; import {DefaultScene} from "../../defaultScene";
import VREntryPrompt from "../components/VREntryPrompt"; import VREntryPrompt from "../components/VREntryPrompt";
import ComingSoonBadge from "../components/ComingSoonBadge";
import UpgradeBadge from "../components/UpgradeBadge";
let vrApp: VrApp = null; let vrApp: VrApp = null;
const defaultCreate = window.localStorage.getItem('createOpened') === 'true'; const defaultCreate = window.localStorage.getItem('createOpened') === 'true';
const defaultConfig = window.localStorage.getItem('configOpened') === 'true'; const defaultConfig = window.localStorage.getItem('configOpened') === 'true';
const defaultManage = window.localStorage.getItem('manageOpened') === 'true'; const defaultManage = window.localStorage.getItem('manageOpened') === 'true';
const defaultMenuOpened = window.localStorage.getItem('menuOpened') !== 'false'; // Default to true (open)
export default function VrExperience() { export default function VrExperience() {
const logger = log.getLogger('vrExperience'); const logger = log.getLogger('vrExperience');
const params = useParams(); const params = useParams();
const { isAuthenticated, loginWithRedirect } = useAuth0(); const { isAuthenticated, loginWithRedirect } = useAuth0();
const [guestBannerDismissed, setGuestBannerDismissed] = useState(false); const [guestBannerDismissed, setGuestBannerDismissed] = useState(false);
const [menuOpened, setMenuOpened] = useState(defaultMenuOpened);
// Feature flags // Feature flags - get states instead of just enabled boolean
const createDiagramEnabled = useIsFeatureEnabled('createDiagram'); const createDiagramState = useFeatureState('createDiagram');
const createFromTemplateEnabled = useIsFeatureEnabled('createFromTemplate'); const createFromTemplateState = useFeatureState('createFromTemplate');
const manageDiagramsEnabled = useIsFeatureEnabled('manageDiagrams'); const manageDiagramsState = useFeatureState('manageDiagrams');
const shareCollaborateEnabled = useIsFeatureEnabled('shareCollaborate'); const shareCollaborateState = useFeatureState('shareCollaborate');
const editDataEnabled = useIsFeatureEnabled('editData'); const editDataState = useFeatureState('editData');
const configEnabled = useIsFeatureEnabled('config'); const configState = useFeatureState('config');
const enterImmersiveEnabled = useIsFeatureEnabled('enterImmersive'); const enterImmersiveState = useFeatureState('enterImmersive');
const launchMetaQuestEnabled = useIsFeatureEnabled('launchMetaQuest'); const launchMetaQuestState = useFeatureState('launchMetaQuest');
const userTier = useUserTier(); const userTier = useUserTier();
// Helper to check if feature should be shown (not 'off')
const shouldShow = (state) => state !== 'off';
const isEnabled = (state) => state === 'on';
// Get the appropriate click handler based on feature state
const getClickHandler = (state, enabledHandler) => {
if (state === 'on') {
return enabledHandler; // Feature is enabled, use the normal handler
}
if (state === 'basic') {
return handleSignUp; // Feature requires sign up, trigger auth
}
// For 'coming-soon', 'pro', or other states, no click handler
return null;
};
const handleSignUp = () => { const handleSignUp = () => {
loginWithRedirect({ loginWithRedirect({
appState: { returnTo: window.location.pathname } appState: { returnTo: window.location.pathname }
@ -59,6 +80,12 @@ export default function VrExperience() {
logger.debug('saving', key, value) logger.debug('saving', key, value)
window.localStorage.setItem(key, value ? 'true' : 'false'); window.localStorage.setItem(key, value ? 'true' : 'false');
} }
const toggleMenu = () => {
const newState = !menuOpened;
setMenuOpened(newState);
window.localStorage.setItem('menuOpened', newState ? 'true' : 'false');
};
const [createOpened, {open: openCreate, close: closeCreate}] = const [createOpened, {open: openCreate, close: closeCreate}] =
useDisclosure(defaultCreate, useDisclosure(defaultCreate,
{ {
@ -140,21 +167,27 @@ export default function VrExperience() {
const [immersiveDisabled, setImmersiveDisabled] = useState(true); const [immersiveDisabled, setImmersiveDisabled] = useState(true);
const navigate = useNavigate(); const navigate = useNavigate();
// Tier indicator functions - now using the actual user tier // Get the appropriate indicator for a feature based on its state
const getTierIndicator = (requiredTier: 'free' | 'basic' | 'pro') => { const getFeatureIndicator = (featureState) => {
if (requiredTier === 'free' || userTier === requiredTier || // 'off' - don't show at all (handled by shouldShow)
(userTier === 'pro' && (requiredTier === 'basic' || requiredTier === 'free')) || // 'on' - no badge needed, feature is fully accessible
(userTier === 'basic' && requiredTier === 'free')) { // 'coming-soon' - show Coming Soon badge (visible to all)
return null; // User has access, no indicator needed // 'basic' - show Sign Up badge (requires basic tier) - clickable to sign up
// 'pro' - show Upgrade to Pro badge (requires pro tier)
if (featureState === 'coming-soon') {
return <ComingSoonBadge size="xs" />;
} }
// Show tier requirement if (featureState === 'basic') {
if (requiredTier === 'basic') { return <UpgradeBadge size="xs" tier="basic" onClick={handleSignUp} />;
return <Group w={50}>Basic</Group>;
} }
if (requiredTier === 'pro') {
return <Group w={50}>Pro!<IconStar size={11}/></Group>; if (featureState === 'pro') {
return <UpgradeBadge size="xs" tier="pro" />;
} }
// 'on' state - no indicator needed
return null; return null;
} }
@ -214,9 +247,9 @@ export default function VrExperience() {
{createModal()} {createModal()}
{manageModal()} {manageModal()}
<Affix position={{top: 30, left: 60}}> <Affix position={{top: 30, left: 60}}>
<Menu trigger="hover" openDelay={50} closeDelay={400}> <Menu opened={menuOpened} onChange={setMenuOpened}>
<Menu.Target> <Menu.Target>
<Burger size="xl"/> <Burger opened={menuOpened} onClick={toggleMenu} size="xl"/>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
{/* Home is always visible */} {/* Home is always visible */}
@ -228,59 +261,59 @@ export default function VrExperience() {
label="Home" label="Home"
availableIcon={null}/> availableIcon={null}/>
{enterImmersiveEnabled && ( {shouldShow(enterImmersiveState) && (
<VrMenuItem <VrMenuItem
tip={immersiveDisabled ? "Browser does not support WebXR. Immersive experience best viewed with Meta Quest headset" : "Enter Immersive Mode"} tip={immersiveDisabled ? "Browser does not support WebXR. Immersive experience best viewed with Meta Quest headset" : "Enter Immersive Mode"}
onClick={enterImmersive} onClick={getClickHandler(enterImmersiveState, enterImmersive)}
label="Enter Immersive Mode" label="Enter Immersive Mode"
availableIcon={getTierIndicator('free')}/> availableIcon={getFeatureIndicator(enterImmersiveState)}/>
)} )}
{launchMetaQuestEnabled && ( {shouldShow(launchMetaQuestState) && (
<VrMenuItem <VrMenuItem
tip="Open a new window and automatically send experience to your Meta Quest headset" tip="Open a new window and automatically send experience to your Meta Quest headset"
onClick={() => { onClick={getClickHandler(launchMetaQuestState, () => {
window.open('https://www.oculus.com/open_url/?url=' + window.location.href, 'launchQuest', 'popup') window.open('https://www.oculus.com/open_url/?url=' + window.location.href, 'launchQuest', 'popup')
}} })}
label="Launch On Meta Quest" label="Launch On Meta Quest"
availableIcon={getTierIndicator('free')}/> availableIcon={getFeatureIndicator(launchMetaQuestState)}/>
)} )}
{editDataEnabled && ( {shouldShow(editDataState) && (
<> <>
<Menu.Divider/> <Menu.Divider/>
<VrMenuItem <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." 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" label="Edit Data"
onClick={null} onClick={getClickHandler(editDataState, null)}
availableIcon={getTierIndicator('free')}/> availableIcon={getFeatureIndicator(editDataState)}/>
</> </>
)} )}
{(createDiagramEnabled || createFromTemplateEnabled || manageDiagramsEnabled) && <Menu.Divider/>} {(shouldShow(createDiagramState) || shouldShow(createFromTemplateState) || shouldShow(manageDiagramsState)) && <Menu.Divider/>}
{createDiagramEnabled && ( {shouldShow(createDiagramState) && (
<VrMenuItem <VrMenuItem
tip="Create a new diagram from scratch" tip="Create a new diagram from scratch"
label="Create" label="Create"
onClick={openCreate} onClick={getClickHandler(createDiagramState, openCreate)}
availableIcon={getTierIndicator('free')}/> availableIcon={getFeatureIndicator(createDiagramState)}/>
)} )}
{createFromTemplateEnabled && ( {shouldShow(createFromTemplateState) && (
<VrMenuItem <VrMenuItem
tip="Create a new diagram from predefined template" tip="Create a new diagram from predefined template"
label="Create From Template" label="Create From Template"
onClick={null} onClick={getClickHandler(createFromTemplateState, null)}
availableIcon={getTierIndicator('basic')}/> availableIcon={getFeatureIndicator(createFromTemplateState)}/>
)} )}
{manageDiagramsEnabled && ( {shouldShow(manageDiagramsState) && (
<VrMenuItem <VrMenuItem
tip="Manage Diagrams" tip="Manage Diagrams"
label="Manage" label="Manage"
onClick={openManage} onClick={getClickHandler(manageDiagramsState, openManage)}
availableIcon={getTierIndicator('free')}/> availableIcon={getFeatureIndicator(manageDiagramsState)}/>
)} )}
{/* Export JSON - Always available for creating templates */} {/* Export JSON - Always available for creating templates */}
@ -290,22 +323,22 @@ export default function VrExperience() {
onClick={handleExportJSON} onClick={handleExportJSON}
availableIcon={null}/> availableIcon={null}/>
{(shareCollaborateEnabled || configEnabled) && <Menu.Divider/>} {(shouldShow(shareCollaborateState) || shouldShow(configState)) && <Menu.Divider/>}
{shareCollaborateEnabled && ( {shouldShow(shareCollaborateState) && (
<VrMenuItem <VrMenuItem
tip="Share your model with others and collaborate in real time with others. This is a paid feature." tip="Share your model with others and collaborate in real time with others. This is a paid feature."
label="Share" label="Share"
onClick={null} onClick={getClickHandler(shareCollaborateState, null)}
availableIcon={getTierIndicator('pro')}/> availableIcon={getFeatureIndicator(shareCollaborateState)}/>
)} )}
{configEnabled && ( {shouldShow(configState) && (
<VrMenuItem <VrMenuItem
tip="Configure settings for your VR experience" tip="Configure settings for your VR experience"
label="Config" label="Config"
onClick={openConfig} onClick={getClickHandler(configState, openConfig)}
availableIcon={getTierIndicator('free')}/> availableIcon={getFeatureIndicator(configState)}/>
)} )}
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>

View File

@ -5,24 +5,26 @@
export type UserTier = 'none' | 'free' | 'basic' | 'pro'; export type UserTier = 'none' | 'free' | 'basic' | 'pro';
export type FeatureState = 'on' | 'coming-soon' | 'basic' | 'pro' | 'off';
export interface PageFlags { export interface PageFlags {
examples: boolean; examples: FeatureState;
documentation: boolean; documentation: FeatureState;
pricing: boolean; pricing: FeatureState;
vrExperience: boolean; vrExperience: FeatureState;
} }
export interface FeatureFlags { export interface FeatureFlags {
createDiagram: boolean; createDiagram: FeatureState;
createFromTemplate: boolean; createFromTemplate: FeatureState;
manageDiagrams: boolean; manageDiagrams: FeatureState;
shareCollaborate: boolean; shareCollaborate: FeatureState;
privateDesigns: boolean; privateDesigns: FeatureState;
encryptedDesigns: boolean; encryptedDesigns: FeatureState;
editData: boolean; editData: FeatureState;
config: boolean; config: FeatureState;
enterImmersive: boolean; enterImmersive: FeatureState;
launchMetaQuest: boolean; launchMetaQuest: FeatureState;
} }
export interface LimitFlags { export interface LimitFlags {
@ -39,59 +41,28 @@ export interface FeatureConfig {
} }
/** /**
* Default configuration for unauthenticated users or when API fetch fails. * Default configuration for unauthenticated users (guest mode).
* Everything is disabled except the home page. * Allows limited access with local storage only (no sync/collaboration).
*/ */
export const DEFAULT_FEATURE_CONFIG: FeatureConfig = { export const DEFAULT_FEATURE_CONFIG: FeatureConfig = {
tier: 'none', tier: 'none',
pages: { pages: {
examples: false, examples: 'coming-soon',
documentation: false, documentation: 'coming-soon',
pricing: false, pricing: 'coming-soon',
vrExperience: false, vrExperience: 'on', // Allow VR experience for guests
}, },
features: { features: {
createDiagram: false, createDiagram: 'basic', // Guests can create diagrams
createFromTemplate: false, createFromTemplate: 'coming-soon', // Coming soon for guests
manageDiagrams: false, manageDiagrams: 'basic', // Guests can manage their local diagrams
shareCollaborate: false, shareCollaborate: 'coming-soon', // Coming soon for guests
privateDesigns: false, privateDesigns: 'coming-soon', // Coming soon for guests
encryptedDesigns: false, encryptedDesigns: 'pro', // No encryption for guests
editData: false, editData: 'coming-soon', // Guests can edit data
config: false, config: 'on', // Guests can access settings
enterImmersive: false, enterImmersive: 'on', // Guests can enter immersive mode
launchMetaQuest: false, launchMetaQuest: 'on', // Guests can launch on Meta Quest
},
limits: {
maxDiagrams: 0,
maxCollaborators: 0,
storageQuotaMB: 0,
},
};
/**
* Guest mode configuration for unauthenticated users.
* Allows limited access with local storage only (no sync/collaboration).
*/
export const GUEST_FEATURE_CONFIG: FeatureConfig = {
tier: 'none',
pages: {
examples: false,
documentation: false,
pricing: false,
vrExperience: true, // Allow VR experience for guests
},
features: {
createDiagram: true, // Guests can create diagrams
createFromTemplate: false, // No templates for guests
manageDiagrams: true, // Guests can manage their local diagrams
shareCollaborate: false, // No sharing/collaboration for guests
privateDesigns: false, // No private designs (local only anyway)
encryptedDesigns: false, // No encryption for guests
editData: true, // Guests can edit data
config: true, // Guests can access settings
enterImmersive: true, // Guests can enter immersive mode
launchMetaQuest: true, // Guests can launch on Meta Quest
}, },
limits: { limits: {
maxDiagrams: 3, // Guests limited to 3 diagrams maxDiagrams: 3, // Guests limited to 3 diagrams
@ -100,6 +71,33 @@ export const GUEST_FEATURE_CONFIG: FeatureConfig = {
}, },
}; };
export const BASIC_FEATURE_CONFIG: FeatureConfig = {
tier: 'basic',
pages: {
examples: 'off',
documentation: 'off',
pricing: 'coming-soon',
vrExperience: 'on',
},
features: {
createDiagram: 'on',
createFromTemplate: 'off',
manageDiagrams: 'off',
shareCollaborate: 'off',
privateDesigns: 'off',
encryptedDesigns: 'off',
editData: 'off',
config: 'off',
enterImmersive: 'off',
launchMetaQuest: 'off',
},
limits: {
maxDiagrams: 0,
maxCollaborators: 0,
storageQuotaMB: 0,
},
};
/** /**
* Type guard to check if a page name is valid * Type guard to check if a page name is valid
*/ */
@ -122,16 +120,44 @@ export function isValidLimit(limit: string): limit is keyof LimitFlags {
} }
/** /**
* Helper to check if a page is enabled in the config * Helper to check if a page is enabled (on) in the config
*/ */
export function isPageEnabled(config: FeatureConfig, page: keyof PageFlags): boolean { export function isPageEnabled(config: FeatureConfig, page: keyof PageFlags): boolean {
return config.pages[page] === 'on';
}
/**
* Helper to check if a feature is enabled (on) in the config
*/
export function isFeatureEnabled(config: FeatureConfig, feature: keyof FeatureFlags): boolean {
return config.features[feature] === 'on';
}
/**
* Helper to check if a page or feature should be visible (not 'off')
*/
export function shouldShowPage(config: FeatureConfig, page: keyof PageFlags): boolean {
return config.pages[page] !== 'off';
}
/**
* Helper to check if a feature should be visible (not 'off')
*/
export function shouldShowFeature(config: FeatureConfig, feature: keyof FeatureFlags): boolean {
return config.features[feature] !== 'off';
}
/**
* Helper to get the state of a page
*/
export function getPageState(config: FeatureConfig, page: keyof PageFlags): FeatureState {
return config.pages[page]; return config.pages[page];
} }
/** /**
* Helper to check if a feature is enabled in the config * Helper to get the state of a feature
*/ */
export function isFeatureEnabled(config: FeatureConfig, feature: keyof FeatureFlags): boolean { export function getFeatureState(config: FeatureConfig, feature: keyof FeatureFlags): FeatureState {
return config.features[feature]; return config.features[feature];
} }

View File

@ -108,7 +108,7 @@ export default class VrApp {
private setMainCamera(scene: Scene) { private setMainCamera(scene: Scene) {
const CAMERA_NAME = 'Main Camera'; const CAMERA_NAME = 'Main Camera';
const camera: FreeCamera = new FreeCamera(CAMERA_NAME, const camera: FreeCamera = new FreeCamera(CAMERA_NAME,
new Vector3(0, 1.6, 0), scene); new Vector3(0, 1.6, -5), scene); // Match VR rig Z position to prevent flip
scene.setActiveCameraByName(CAMERA_NAME); scene.setActiveCameraByName(CAMERA_NAME);
/* if (!this._mobileCamera) { /* if (!this._mobileCamera) {