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:
parent
31dd8a89da
commit
1c50dd5c84
22
src/react/components/ComingSoonBadge.tsx
Normal file
22
src/react/components/ComingSoonBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/react/components/UpgradeBadge.tsx
Normal file
35
src/react/components/UpgradeBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
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';
|
||||
|
||||
const logger = log.getLogger('FeatureProvider');
|
||||
@ -31,7 +31,7 @@ async function fetchFeatureConfig(accessToken: string | undefined): Promise<Feat
|
||||
|
||||
if (!response.ok) {
|
||||
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;
|
||||
}
|
||||
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) {
|
||||
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 [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) {
|
||||
logger.info('User not authenticated, using guest config');
|
||||
setConfig(GUEST_FEATURE_CONFIG);
|
||||
logger.info('User not authenticated, using default (guest) config');
|
||||
setConfig(DEFAULT_FEATURE_CONFIG);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
@ -80,9 +80,9 @@ export function FeatureProvider({ children }: FeatureProviderProps) {
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error('Unknown error fetching features');
|
||||
setError(error);
|
||||
// On error, fallback to guest config for better UX
|
||||
logger.warn('Error loading features, falling back to guest config');
|
||||
setConfig(GUEST_FEATURE_CONFIG);
|
||||
// On error, fallback to default (guest) config for better UX
|
||||
logger.warn('Error loading features, falling back to default (guest) config');
|
||||
setConfig(DEFAULT_FEATURE_CONFIG);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useContext } from 'react';
|
||||
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
|
||||
@ -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();
|
||||
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();
|
||||
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
|
||||
*/
|
||||
|
||||
@ -2,14 +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";
|
||||
import {usePageState} from "./hooks/useFeatures";
|
||||
import ComingSoonBadge from "./components/ComingSoonBadge";
|
||||
|
||||
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 examplesState = usePageState('examples');
|
||||
const documentationState = usePageState('documentation');
|
||||
const pricingState = usePageState('pricing');
|
||||
const vrExperienceState = usePageState('vrExperience');
|
||||
|
||||
const 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 = [
|
||||
{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}
|
||||
{name: 'Examples', href: '/examples', key: 'examples', state: examplesState},
|
||||
{name: 'About', href: '/', key: 'about', state: 'on' as const}, // About (home) is always visible
|
||||
{name: 'Documentation', href: '/documentation', key: 'documentation', state: documentationState},
|
||||
{name: 'Pricing', href: '/pricing', key: 'pricing', state: pricingState},
|
||||
{name: 'VR Experience', href: '/db/public/local', key: 'vrexperience', state: vrExperienceState}
|
||||
];
|
||||
|
||||
// Filter to only enabled items
|
||||
const items = allItems.filter(item => item.enabled)
|
||||
// Filter to only 'on' and 'coming-soon' items (hide 'off' items)
|
||||
const items = allItems.filter(item => item.state !== 'off')
|
||||
const mainMenu = function () {
|
||||
return items.map((item) => {
|
||||
const isComingSoon = item.state === 'coming-soon';
|
||||
return (
|
||||
<Anchor component={Link} key={item.key} to={item.href} p={5} c="myColor" bg="none"
|
||||
underline="hover">{item.name}</Anchor>
|
||||
<Group key={item.key} gap="xs">
|
||||
<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 () {
|
||||
return items.map((item) => {
|
||||
const isComingSoon = item.state === 'coming-soon';
|
||||
return (
|
||||
<Menu.Item><Anchor size="xl" component={Link} key={item.key}
|
||||
to={item.href} p={5}
|
||||
c="myColor" bg="none"
|
||||
underline="hover">{item.name}</Anchor></Menu.Item>
|
||||
<Menu.Item
|
||||
key={item.key}
|
||||
disabled={isComingSoon}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@ -3,16 +3,30 @@ import {usePouch} from "use-pouchdb";
|
||||
import {useState} from "react";
|
||||
import {v4} from "uuid";
|
||||
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}) {
|
||||
const logger = log.getLogger('createDiagramModal');
|
||||
const db = usePouch();
|
||||
const { loginWithRedirect } = useAuth0();
|
||||
|
||||
// Feature flags
|
||||
const privateDesignsEnabled = useIsFeatureEnabled('privateDesigns');
|
||||
const encryptedDesignsEnabled = useIsFeatureEnabled('encryptedDesigns');
|
||||
const shareCollaborateEnabled = useIsFeatureEnabled('shareCollaborate');
|
||||
const privateDesignsState = useFeatureState('privateDesigns');
|
||||
const encryptedDesignsState = useFeatureState('encryptedDesigns');
|
||||
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({
|
||||
name: '',
|
||||
@ -68,10 +82,16 @@ export default function CreateDiagramModal({createOpened, closeCreate}) {
|
||||
label="Private"
|
||||
checked={diagram.private}
|
||||
onChange={(e) => {
|
||||
setDiagram({...diagram, private: e.currentTarget.checked})
|
||||
if (privateDesignsState === 'basic') {
|
||||
handleSignUp();
|
||||
} else {
|
||||
setDiagram({...diagram, private: e.currentTarget.checked})
|
||||
}
|
||||
}}
|
||||
disabled={!privateDesignsEnabled}/>
|
||||
{!privateDesignsEnabled && <Pill>Basic</Pill>}
|
||||
disabled={!privateDesignsEnabled && privateDesignsState !== 'basic'}/>
|
||||
{privateDesignsState === 'coming-soon' && <ComingSoonBadge />}
|
||||
{privateDesignsState === 'basic' && <UpgradeBadge tier="basic" onClick={handleSignUp} />}
|
||||
{privateDesignsState === 'pro' && <UpgradeBadge tier="pro" />}
|
||||
</Group>
|
||||
<Group>
|
||||
<Checkbox w={250}
|
||||
@ -79,10 +99,16 @@ export default function CreateDiagramModal({createOpened, closeCreate}) {
|
||||
label="Encrypted"
|
||||
checked={diagram.encrypted}
|
||||
onChange={(e) => {
|
||||
setDiagram({...diagram, encrypted: e.currentTarget.checked})
|
||||
if (encryptedDesignsState === 'basic') {
|
||||
handleSignUp();
|
||||
} else {
|
||||
setDiagram({...diagram, encrypted: e.currentTarget.checked})
|
||||
}
|
||||
}}
|
||||
disabled={!encryptedDesignsEnabled}/>
|
||||
{!encryptedDesignsEnabled && <Pill>Pro</Pill>}
|
||||
disabled={!encryptedDesignsEnabled && encryptedDesignsState !== 'basic'}/>
|
||||
{encryptedDesignsState === 'coming-soon' && <ComingSoonBadge />}
|
||||
{encryptedDesignsState === 'basic' && <UpgradeBadge tier="basic" onClick={handleSignUp} />}
|
||||
{encryptedDesignsState === 'pro' && <UpgradeBadge tier="pro" />}
|
||||
</Group>
|
||||
<Group>
|
||||
<Checkbox w={250}
|
||||
@ -90,10 +116,16 @@ export default function CreateDiagramModal({createOpened, closeCreate}) {
|
||||
label="Invite Collaborators"
|
||||
checked={diagram.invite}
|
||||
onChange={(e) => {
|
||||
setDiagram({...diagram, invite: e.currentTarget.checked})
|
||||
if (shareCollaborateState === 'basic') {
|
||||
handleSignUp();
|
||||
} else {
|
||||
setDiagram({...diagram, invite: e.currentTarget.checked})
|
||||
}
|
||||
}}
|
||||
disabled={!shareCollaborateEnabled}/>
|
||||
{!shareCollaborateEnabled && <Pill>Pro</Pill>}
|
||||
disabled={!shareCollaborateEnabled && shareCollaborateState !== 'basic'}/>
|
||||
{shareCollaborateState === 'coming-soon' && <ComingSoonBadge />}
|
||||
{shareCollaborateState === 'basic' && <UpgradeBadge tier="basic" onClick={handleSignUp} />}
|
||||
{shareCollaborateState === 'pro' && <UpgradeBadge tier="pro" />}
|
||||
</Group>
|
||||
<Group>
|
||||
<Button key="create" onClick={createDiagram}>Create</Button>
|
||||
|
||||
@ -10,36 +10,57 @@ import {useNavigate, useParams} from "react-router-dom";
|
||||
import {useDisclosure} from "@mantine/hooks";
|
||||
import ConfigModal from "./configModal";
|
||||
import log from "loglevel";
|
||||
import {useIsFeatureEnabled, useUserTier} from "../hooks/useFeatures";
|
||||
import {useFeatureState, useUserTier} from "../hooks/useFeatures";
|
||||
import {useAuth0} from "@auth0/auth0-react";
|
||||
import {GUEST_MODE_BANNER} from "../../content/upgradeCopy";
|
||||
import {exportDiagramAsJSON} from "../../util/functions/exportDiagramAsJSON";
|
||||
import {isMobileVRDevice} from "../../util/deviceDetection";
|
||||
import {DefaultScene} from "../../defaultScene";
|
||||
import VREntryPrompt from "../components/VREntryPrompt";
|
||||
import ComingSoonBadge from "../components/ComingSoonBadge";
|
||||
import UpgradeBadge from "../components/UpgradeBadge";
|
||||
|
||||
let vrApp: VrApp = null;
|
||||
|
||||
const defaultCreate = window.localStorage.getItem('createOpened') === 'true';
|
||||
const defaultConfig = window.localStorage.getItem('configOpened') === 'true';
|
||||
const defaultManage = window.localStorage.getItem('manageOpened') === 'true';
|
||||
const defaultMenuOpened = window.localStorage.getItem('menuOpened') !== 'false'; // Default to true (open)
|
||||
|
||||
export default function VrExperience() {
|
||||
const logger = log.getLogger('vrExperience');
|
||||
const params = useParams();
|
||||
const { isAuthenticated, loginWithRedirect } = useAuth0();
|
||||
const [guestBannerDismissed, setGuestBannerDismissed] = useState(false);
|
||||
const [menuOpened, setMenuOpened] = useState(defaultMenuOpened);
|
||||
|
||||
// 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');
|
||||
// Feature flags - get states instead of just enabled boolean
|
||||
const createDiagramState = useFeatureState('createDiagram');
|
||||
const createFromTemplateState = useFeatureState('createFromTemplate');
|
||||
const manageDiagramsState = useFeatureState('manageDiagrams');
|
||||
const shareCollaborateState = useFeatureState('shareCollaborate');
|
||||
const editDataState = useFeatureState('editData');
|
||||
const configState = useFeatureState('config');
|
||||
const enterImmersiveState = useFeatureState('enterImmersive');
|
||||
const launchMetaQuestState = useFeatureState('launchMetaQuest');
|
||||
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 = () => {
|
||||
loginWithRedirect({
|
||||
appState: { returnTo: window.location.pathname }
|
||||
@ -59,6 +80,12 @@ export default function VrExperience() {
|
||||
logger.debug('saving', key, value)
|
||||
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}] =
|
||||
useDisclosure(defaultCreate,
|
||||
{
|
||||
@ -140,21 +167,27 @@ export default function VrExperience() {
|
||||
const [immersiveDisabled, setImmersiveDisabled] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 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
|
||||
// Get the appropriate indicator for a feature based on its state
|
||||
const getFeatureIndicator = (featureState) => {
|
||||
// 'off' - don't show at all (handled by shouldShow)
|
||||
// 'on' - no badge needed, feature is fully accessible
|
||||
// 'coming-soon' - show Coming Soon badge (visible to all)
|
||||
// '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 (requiredTier === 'basic') {
|
||||
return <Group w={50}>Basic</Group>;
|
||||
if (featureState === 'basic') {
|
||||
return <UpgradeBadge size="xs" tier="basic" onClick={handleSignUp} />;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@ -214,9 +247,9 @@ export default function VrExperience() {
|
||||
{createModal()}
|
||||
{manageModal()}
|
||||
<Affix position={{top: 30, left: 60}}>
|
||||
<Menu trigger="hover" openDelay={50} closeDelay={400}>
|
||||
<Menu opened={menuOpened} onChange={setMenuOpened}>
|
||||
<Menu.Target>
|
||||
<Burger size="xl"/>
|
||||
<Burger opened={menuOpened} onClick={toggleMenu} size="xl"/>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{/* Home is always visible */}
|
||||
@ -228,59 +261,59 @@ export default function VrExperience() {
|
||||
label="Home"
|
||||
availableIcon={null}/>
|
||||
|
||||
{enterImmersiveEnabled && (
|
||||
{shouldShow(enterImmersiveState) && (
|
||||
<VrMenuItem
|
||||
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"
|
||||
availableIcon={getTierIndicator('free')}/>
|
||||
availableIcon={getFeatureIndicator(enterImmersiveState)}/>
|
||||
)}
|
||||
|
||||
{launchMetaQuestEnabled && (
|
||||
{shouldShow(launchMetaQuestState) && (
|
||||
<VrMenuItem
|
||||
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')
|
||||
}}
|
||||
})}
|
||||
label="Launch On Meta Quest"
|
||||
availableIcon={getTierIndicator('free')}/>
|
||||
availableIcon={getFeatureIndicator(launchMetaQuestState)}/>
|
||||
)}
|
||||
|
||||
{editDataEnabled && (
|
||||
{shouldShow(editDataState) && (
|
||||
<>
|
||||
<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={getTierIndicator('free')}/>
|
||||
onClick={getClickHandler(editDataState, null)}
|
||||
availableIcon={getFeatureIndicator(editDataState)}/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(createDiagramEnabled || createFromTemplateEnabled || manageDiagramsEnabled) && <Menu.Divider/>}
|
||||
{(shouldShow(createDiagramState) || shouldShow(createFromTemplateState) || shouldShow(manageDiagramsState)) && <Menu.Divider/>}
|
||||
|
||||
{createDiagramEnabled && (
|
||||
{shouldShow(createDiagramState) && (
|
||||
<VrMenuItem
|
||||
tip="Create a new diagram from scratch"
|
||||
label="Create"
|
||||
onClick={openCreate}
|
||||
availableIcon={getTierIndicator('free')}/>
|
||||
onClick={getClickHandler(createDiagramState, openCreate)}
|
||||
availableIcon={getFeatureIndicator(createDiagramState)}/>
|
||||
)}
|
||||
|
||||
{createFromTemplateEnabled && (
|
||||
{shouldShow(createFromTemplateState) && (
|
||||
<VrMenuItem
|
||||
tip="Create a new diagram from predefined template"
|
||||
label="Create From Template"
|
||||
onClick={null}
|
||||
availableIcon={getTierIndicator('basic')}/>
|
||||
onClick={getClickHandler(createFromTemplateState, null)}
|
||||
availableIcon={getFeatureIndicator(createFromTemplateState)}/>
|
||||
)}
|
||||
|
||||
{manageDiagramsEnabled && (
|
||||
{shouldShow(manageDiagramsState) && (
|
||||
<VrMenuItem
|
||||
tip="Manage Diagrams"
|
||||
label="Manage"
|
||||
onClick={openManage}
|
||||
availableIcon={getTierIndicator('free')}/>
|
||||
onClick={getClickHandler(manageDiagramsState, openManage)}
|
||||
availableIcon={getFeatureIndicator(manageDiagramsState)}/>
|
||||
)}
|
||||
|
||||
{/* Export JSON - Always available for creating templates */}
|
||||
@ -290,22 +323,22 @@ export default function VrExperience() {
|
||||
onClick={handleExportJSON}
|
||||
availableIcon={null}/>
|
||||
|
||||
{(shareCollaborateEnabled || configEnabled) && <Menu.Divider/>}
|
||||
{(shouldShow(shareCollaborateState) || shouldShow(configState)) && <Menu.Divider/>}
|
||||
|
||||
{shareCollaborateEnabled && (
|
||||
{shouldShow(shareCollaborateState) && (
|
||||
<VrMenuItem
|
||||
tip="Share your model with others and collaborate in real time with others. This is a paid feature."
|
||||
label="Share"
|
||||
onClick={null}
|
||||
availableIcon={getTierIndicator('pro')}/>
|
||||
onClick={getClickHandler(shareCollaborateState, null)}
|
||||
availableIcon={getFeatureIndicator(shareCollaborateState)}/>
|
||||
)}
|
||||
|
||||
{configEnabled && (
|
||||
{shouldShow(configState) && (
|
||||
<VrMenuItem
|
||||
tip="Configure settings for your VR experience"
|
||||
label="Config"
|
||||
onClick={openConfig}
|
||||
availableIcon={getTierIndicator('free')}/>
|
||||
onClick={getClickHandler(configState, openConfig)}
|
||||
availableIcon={getFeatureIndicator(configState)}/>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
@ -5,24 +5,26 @@
|
||||
|
||||
export type UserTier = 'none' | 'free' | 'basic' | 'pro';
|
||||
|
||||
export type FeatureState = 'on' | 'coming-soon' | 'basic' | 'pro' | 'off';
|
||||
|
||||
export interface PageFlags {
|
||||
examples: boolean;
|
||||
documentation: boolean;
|
||||
pricing: boolean;
|
||||
vrExperience: boolean;
|
||||
examples: FeatureState;
|
||||
documentation: FeatureState;
|
||||
pricing: FeatureState;
|
||||
vrExperience: FeatureState;
|
||||
}
|
||||
|
||||
export interface FeatureFlags {
|
||||
createDiagram: boolean;
|
||||
createFromTemplate: boolean;
|
||||
manageDiagrams: boolean;
|
||||
shareCollaborate: boolean;
|
||||
privateDesigns: boolean;
|
||||
encryptedDesigns: boolean;
|
||||
editData: boolean;
|
||||
config: boolean;
|
||||
enterImmersive: boolean;
|
||||
launchMetaQuest: boolean;
|
||||
createDiagram: FeatureState;
|
||||
createFromTemplate: FeatureState;
|
||||
manageDiagrams: FeatureState;
|
||||
shareCollaborate: FeatureState;
|
||||
privateDesigns: FeatureState;
|
||||
encryptedDesigns: FeatureState;
|
||||
editData: FeatureState;
|
||||
config: FeatureState;
|
||||
enterImmersive: FeatureState;
|
||||
launchMetaQuest: FeatureState;
|
||||
}
|
||||
|
||||
export interface LimitFlags {
|
||||
@ -39,28 +41,55 @@ export interface FeatureConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration for unauthenticated users or when API fetch fails.
|
||||
* Everything is disabled except the home page.
|
||||
* Default configuration for unauthenticated users (guest mode).
|
||||
* Allows limited access with local storage only (no sync/collaboration).
|
||||
*/
|
||||
export const DEFAULT_FEATURE_CONFIG: FeatureConfig = {
|
||||
tier: 'none',
|
||||
pages: {
|
||||
examples: false,
|
||||
documentation: false,
|
||||
pricing: false,
|
||||
vrExperience: false,
|
||||
examples: 'coming-soon',
|
||||
documentation: 'coming-soon',
|
||||
pricing: 'coming-soon',
|
||||
vrExperience: 'on', // Allow VR experience for guests
|
||||
},
|
||||
features: {
|
||||
createDiagram: false,
|
||||
createFromTemplate: false,
|
||||
manageDiagrams: false,
|
||||
shareCollaborate: false,
|
||||
privateDesigns: false,
|
||||
encryptedDesigns: false,
|
||||
editData: false,
|
||||
config: false,
|
||||
enterImmersive: false,
|
||||
launchMetaQuest: false,
|
||||
createDiagram: 'basic', // Guests can create diagrams
|
||||
createFromTemplate: 'coming-soon', // Coming soon for guests
|
||||
manageDiagrams: 'basic', // Guests can manage their local diagrams
|
||||
shareCollaborate: 'coming-soon', // Coming soon for guests
|
||||
privateDesigns: 'coming-soon', // Coming soon for guests
|
||||
encryptedDesigns: 'pro', // No encryption for guests
|
||||
editData: 'coming-soon', // Guests can edit data
|
||||
config: 'on', // Guests can access settings
|
||||
enterImmersive: 'on', // Guests can enter immersive mode
|
||||
launchMetaQuest: 'on', // Guests can launch on Meta Quest
|
||||
},
|
||||
limits: {
|
||||
maxDiagrams: 3, // Guests limited to 3 diagrams
|
||||
maxCollaborators: 0, // No collaboration for guests
|
||||
storageQuotaMB: 50, // 50MB local storage for guests
|
||||
},
|
||||
};
|
||||
|
||||
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,
|
||||
@ -69,37 +98,6 @@ export const DEFAULT_FEATURE_CONFIG: FeatureConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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: {
|
||||
maxDiagrams: 3, // Guests limited to 3 diagrams
|
||||
maxCollaborators: 0, // No collaboration for guests
|
||||
storageQuotaMB: 50, // 50MB local storage for guests
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
|
||||
|
||||
@ -108,7 +108,7 @@ export default class VrApp {
|
||||
private setMainCamera(scene: Scene) {
|
||||
const CAMERA_NAME = 'Main Camera';
|
||||
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);
|
||||
|
||||
/* if (!this._mobileCamera) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user