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 { 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) => {
|
||||||
setDiagram({...diagram, private: e.currentTarget.checked})
|
if (privateDesignsState === 'basic') {
|
||||||
|
handleSignUp();
|
||||||
|
} else {
|
||||||
|
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) => {
|
||||||
setDiagram({...diagram, encrypted: e.currentTarget.checked})
|
if (encryptedDesignsState === 'basic') {
|
||||||
|
handleSignUp();
|
||||||
|
} else {
|
||||||
|
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) => {
|
||||||
setDiagram({...diagram, invite: e.currentTarget.checked})
|
if (shareCollaborateState === 'basic') {
|
||||||
|
handleSignUp();
|
||||||
|
} else {
|
||||||
|
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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,28 +41,55 @@ 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: 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: {
|
limits: {
|
||||||
maxDiagrams: 0,
|
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
|
* 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];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user