Add Quest VR onboarding flow with auto-entry prompt

Implemented comprehensive VR onboarding experience for Quest users:

- Add demo database template with pre-built architecture diagram
- Implement automatic demo template loading on first visit
- Create VREntryPrompt component for seamless VR mode entry
- Add device detection utilities for Quest/VR headset identification
- Integrate export/import functionality for diagram templates
- Add About page with device-aware CTA and VR benefits
- Remove legacy tutorial and FirstVisitVr modal for demo flow
- Add upgrade prompts and tiered feature configuration

Quest users now see prominent VR entry prompt when navigating to /db/** paths,
providing one-tap entry into immersive mode after scene initialization.

🤖 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-20 10:47:28 -06:00
parent 0b81605bdf
commit 0e053bf69c
17 changed files with 1463 additions and 86 deletions

224
ROADMAP.md Normal file
View File

@ -0,0 +1,224 @@
# Immersive - Product Roadmap
## Vision
Transform immersive into an accessible, intuitive WebXR diagramming platform that delivers a frictionless onboarding experience and sustainable growth path.
---
## Phase 1: Onboarding & User Experience (Q1 2025)
### 1.1 Frictionless Entry
**Goal:** Reduce barriers to entry for new users
- [ ] Redesign landing page to clearly guide users to immersive experience
- [ ] Create one-click "Enter VR" / "Try Demo" workflow
- [ ] Optimize initial load time and progressive loading
- [ ] Add clear device compatibility messaging (desktop/VR)
- [ ] Implement guest mode with no sign-in required for basic exploration
### 1.2 Marketing Content
**Goal:** Communicate value proposition effectively
- [ ] Create 3-5 demo videos showcasing key features (30-60 seconds each)
- Creating a basic diagram
- VR interaction showcase
- Collaboration features
- Template usage
- [ ] Develop tutorial video (2-3 minutes) explaining core workflows
- [ ] Autoplay video carousel on landing page
- [ ] Write marketing copy for landing page
- Hero section with clear value proposition
- Feature highlights
- Use case examples
- Call-to-action
### 1.3 In-Experience Tutorial
**Goal:** Replace external tutorial with immersive learning
- [ ] Remove existing external tutorial system
- [ ] Design in-VR tutorial experience with interactive steps
- [ ] Implement progressive disclosure (teach as users interact)
- [ ] Add contextual tooltips and hints in 3D space
- [ ] Create "first-time user" detection and guided walkthrough
- [ ] Add skip/replay tutorial options
### 1.4 Template System
**Goal:** Provide starting points for new users
- [ ] Design template/example diagram system
- [ ] Create 5-10 starter templates:
- Simple organizational chart
- Project workflow diagram
- Concept mapping example
- Architecture diagram
- Spatial layout example
- [ ] Build template browser UI (2D and VR)
- [ ] Implement "New from Template" workflow
- [ ] Add template preview/thumbnail generation
---
## Phase 2: Collaboration & Sync (Q2 2025)
### 2.1 Cross-Device Sharing
**Goal:** Enable seamless content sharing between desktop and Quest
- [ ] Research device-to-device sync options (WebRTC, local network)
- [ ] Design sync architecture without backend dependency
- [ ] Implement user content sync for signed-in users
- [ ] Add fallback to server-based sync when needed
- [ ] Create device pairing UI/workflow
- [ ] Test sync reliability across desktop ↔ Quest
- [ ] Add conflict resolution for simultaneous edits
---
## Phase 3: Immersion & Environment (Q2-Q3 2025)
### 3.1 Audio Integration
**Goal:** Enhance presence with ambient soundscapes
- [ ] Source/create ambient audio assets
- Nature sounds (birds, wind, water)
- Office ambience
- Abstract/focus music
- [ ] Implement spatial audio system
- [ ] Add audio settings (volume, on/off, environment selection)
- [ ] Create audio manager for seamless transitions
- [ ] Add positional audio for collaboration (optional user voices)
### 3.2 Environment System
**Goal:** Provide varied immersive environments
- [ ] Design environment switching architecture
- [ ] Create environment presets:
- Outdoor/nature scene
- Modern office
- Abstract/minimal space
- Workshop/studio
- [ ] Implement skybox and lighting variations
- [ ] Build environment selector UI (2D and VR)
- [ ] Optimize environment assets for performance
- [ ] Add environment-specific audio pairing
---
## Phase 4: User Feedback & Polish (Q3 2025)
### 4.1 In-VR Feedback Mechanism
**Goal:** Enable users to provide feedback without leaving VR
- [ ] Design in-VR feedback form/interface
- [ ] Implement voice-to-text option (VR accessibility)
- [ ] Add screenshot/recording attachment capability
- [ ] Create feedback submission backend
- [ ] Build feedback review dashboard
- [ ] Add "Report Bug" quick action in VR menu
### 4.2 Keyboard Improvements
**Goal:** Improve text input experience
- [ ] Test system keyboard integration (Quest/desktop)
- [ ] Evaluate custom keyboard vs. native keyboard UX
- [ ] Implement system keyboard fallback where supported
- [ ] Optimize keyboard positioning in VR space
- [ ] Add keyboard shortcuts for power users (desktop)
---
## Phase 5: Growth & Monetization (Q4 2025)
### 5.1 Marketing Roadmap
**Goal:** Build sustainable user acquisition
- [ ] Define target audience segments
- Educators
- Remote teams
- Designers/architects
- Knowledge workers
- [ ] Create content marketing strategy
- Blog posts on use cases
- Social media showcase
- Community building (Discord/Reddit)
- [ ] Develop SEO optimization plan
- [ ] Plan partnership outreach (VR communities, productivity tools)
- [ ] Create referral/sharing incentives
- [ ] Build analytics dashboard for user metrics
### 5.2 Monetization Strategy
**Goal:** Establish path to sustainability
**Potential Revenue Streams:**
- [ ] Freemium model research
- Free tier: Limited diagrams, basic features
- Pro tier: Unlimited diagrams, advanced features, collaboration
- [ ] Team/Enterprise pricing
- Private deployment options
- Admin controls
- Priority support
- [ ] Template marketplace
- Premium templates
- Community submissions (revenue share)
- [ ] Educational licensing
- Institutional pricing
- Classroom management features
**Implementation:**
- [ ] Define pricing tiers and feature gates
- [ ] Integrate payment processing (Stripe)
- [ ] Build subscription management UI
- [ ] Implement feature flags for tier differentiation
- [ ] Create upgrade prompts and conversion flow
- [ ] Add usage analytics for pricing optimization
---
## Success Metrics
### Phase 1-2 (Onboarding)
- Time to first diagram creation < 2 minutes
- Tutorial completion rate > 60%
- Return user rate (7-day) > 30%
### Phase 3-4 (Engagement)
- Average session duration > 15 minutes
- User satisfaction score > 4/5
- Feedback submission rate (active users) > 10%
### Phase 5 (Growth)
- Monthly active users growth > 20% MoM
- Free-to-paid conversion rate > 5%
- Customer acquisition cost < lifetime value
---
## Technical Debt & Infrastructure
### Ongoing Priorities
- [ ] Migration from legacy ConfigType to AppConfig
- [ ] Performance optimization (target 90fps in VR)
- [ ] Accessibility improvements (WCAG compliance)
- [ ] Testing coverage > 70%
- [ ] Documentation for contributors
- [ ] CI/CD pipeline enhancements
---
## Notes
**Dependencies:**
- Auth0 for user authentication
- PouchDB/CouchDB for data persistence
- BabylonJS 8.x for rendering
- Vite for build tooling
**Platform Support:**
- Desktop browsers (Chrome, Firefox, Edge)
- Meta Quest 2/3/Pro
- Future: PSVR2, Vision Pro (evaluate demand)
**Review Cadence:** Quarterly roadmap review and adjustment based on user feedback and metrics.
---
*Last Updated: 2025-11-19*

View File

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

245
public/templates/demo.json Normal file
View File

@ -0,0 +1,245 @@
{
"name": "demo",
"dbName": "demo",
"exportDate": "2025-11-20T14:32:58.031Z",
"version": "1.0",
"entities": [
{
"id": "id0c8fd8ad-7dc8-41fa-b61f-b22c2d2b3eb8",
"position": {
"x": 0.4000000059604645,
"y": 1,
"z": 2.9000000953674316
},
"rotation": {
"x": 0,
"y": 3.141592653589793,
"z": 0
},
"last_seen": "2025-11-20T14:30:54.865Z",
"template": "#cylinder-template",
"scale": {
"x": 0.1,
"y": 0.1,
"z": 0.1
},
"color": "#FF00FF",
"text": "db",
"_id": "id0c8fd8ad-7dc8-41fa-b61f-b22c2d2b3eb8"
},
{
"from": "ide476ec05-9aac-42c9-87f1-ba7f18141767",
"to": "idb75dff6c-e056-4a15-b179-adfb2bec793a",
"type": "entity",
"template": "#connection-template",
"color": "#000000",
"id": "id18bf9938-a0b7-4e65-bf91-38697064698a",
"_id": "id18bf9938-a0b7-4e65-bf91-38697064698a"
},
{
"from": "id5c0dab44-2ef8-406e-b0ca-3aeea5b820b9",
"to": "idb75dff6c-e056-4a15-b179-adfb2bec793a",
"type": "entity",
"template": "#connection-template",
"color": "#000000",
"id": "id47bf19b7-6263-44fa-b289-1f9f63aa6aff",
"_id": "id47bf19b7-6263-44fa-b289-1f9f63aa6aff"
},
{
"from": "id5c0dab44-2ef8-406e-b0ca-3aeea5b820b9",
"to": "idac543949-c285-4f4b-ab07-d6fd2bbf7bb5",
"type": "entity",
"template": "#connection-template",
"color": "#000000",
"id": "id50594820-ace6-44c7-be5c-2b0747549c75",
"_id": "id50594820-ace6-44c7-be5c-2b0747549c75"
},
{
"from": "id5c0dab44-2ef8-406e-b0ca-3aeea5b820b9",
"to": "id5c0dab44-2ef8-406e-b0ca-3aeea5b820b9",
"type": "entity",
"template": "#connection-template",
"color": "#000000",
"id": "id562bf787-0d11-413c-bc2d-194bf05275fd",
"_id": "id562bf787-0d11-413c-bc2d-194bf05275fd"
},
{
"from": "idb75dff6c-e056-4a15-b179-adfb2bec793a",
"to": "id0c8fd8ad-7dc8-41fa-b61f-b22c2d2b3eb8",
"type": "entity",
"template": "#connection-template",
"color": "#000000",
"id": "id5b622c06-95f1-4d04-b023-b1a6851d2107",
"_id": "id5b622c06-95f1-4d04-b023-b1a6851d2107"
},
{
"id": "id5c0dab44-2ef8-406e-b0ca-3aeea5b820b9",
"position": {
"x": 0.4000000059604645,
"y": 1.7000000476837158,
"z": 2.9000000953674316
},
"rotation": {
"x": 0,
"y": 3.141592653589793,
"z": 0
},
"last_seen": "2025-11-20T14:28:52.061Z",
"template": "#sphere-template",
"text": "browser",
"scale": {
"x": 0.1,
"y": 0.1,
"z": 0.1
},
"color": "#8B4513",
"_id": "id5c0dab44-2ef8-406e-b0ca-3aeea5b820b9"
},
{
"from": "idac543949-c285-4f4b-ab07-d6fd2bbf7bb5",
"to": "id5c0dab44-2ef8-406e-b0ca-3aeea5b820b9",
"type": "entity",
"template": "#connection-template",
"color": "#000000",
"id": "id6f4208a8-9b17-45a8-b030-83a80d7e09bf",
"_id": "id6f4208a8-9b17-45a8-b030-83a80d7e09bf"
},
{
"from": "idf1cf90c7-cc2f-4ccc-9bd6-274752f5b66f",
"to": "id5c0dab44-2ef8-406e-b0ca-3aeea5b820b9",
"type": "entity",
"template": "#connection-template",
"color": "#000000",
"id": "id7205d022-db34-4705-8e3b-930ae0351376",
"_id": "id7205d022-db34-4705-8e3b-930ae0351376"
},
{
"from": "idb75dff6c-e056-4a15-b179-adfb2bec793a",
"to": "ide476ec05-9aac-42c9-87f1-ba7f18141767",
"type": "entity",
"template": "#connection-template",
"color": "#000000",
"id": "id74b9b638-8148-4d98-a715-981f7aebd3bb",
"_id": "id74b9b638-8148-4d98-a715-981f7aebd3bb"
},
{
"from": "id0c8fd8ad-7dc8-41fa-b61f-b22c2d2b3eb8",
"to": "idb75dff6c-e056-4a15-b179-adfb2bec793a",
"type": "entity",
"template": "#connection-template",
"color": "#000000",
"id": "id7af34b2d-f790-45c9-9fce-6c627de1410e",
"_id": "id7af34b2d-f790-45c9-9fce-6c627de1410e"
},
{
"from": "id5c0dab44-2ef8-406e-b0ca-3aeea5b820b9",
"to": "idf1cf90c7-cc2f-4ccc-9bd6-274752f5b66f",
"type": "entity",
"template": "#connection-template",
"color": "#000000",
"id": "id8bdd38de-6ac9-44c1-95e9-015ed85c0b7a",
"_id": "id8bdd38de-6ac9-44c1-95e9-015ed85c0b7a"
},
{
"id": "idac543949-c285-4f4b-ab07-d6fd2bbf7bb5",
"position": {
"x": -0.6000000238418579,
"y": 1.7000000476837158,
"z": 2.799999952316284
},
"rotation": {
"x": -2.4492937051703357e-16,
"y": 3.141592653589793,
"z": -2.4492937051703357e-16
},
"last_seen": "2025-11-20T14:32:19.261Z",
"template": "#box-template",
"text": "api",
"scale": {
"x": 0.1,
"y": 0.1,
"z": 0.1
},
"color": "#0000FF",
"_id": "idac543949-c285-4f4b-ab07-d6fd2bbf7bb5"
},
{
"id": "idb75dff6c-e056-4a15-b179-adfb2bec793a",
"position": {
"x": 0.4000000059604645,
"y": 1.2999999523162842,
"z": 2.9000000953674316
},
"rotation": {
"x": 0,
"y": 3.141592653589793,
"z": 0
},
"last_seen": "2025-11-20T14:28:36.027Z",
"template": "#box-template",
"text": "server",
"scale": {
"x": 0.1,
"y": 0.1,
"z": 0.1
},
"color": "#006400",
"_id": "idb75dff6c-e056-4a15-b179-adfb2bec793a"
},
{
"from": "idb75dff6c-e056-4a15-b179-adfb2bec793a",
"to": "id5c0dab44-2ef8-406e-b0ca-3aeea5b820b9",
"type": "entity",
"template": "#connection-template",
"color": "#000000",
"id": "idd6098a95-f534-4126-9aad-9948fdc724c6",
"_id": "idd6098a95-f534-4126-9aad-9948fdc724c6"
},
{
"id": "ide476ec05-9aac-42c9-87f1-ba7f18141767",
"position": {
"x": -0.6000000238418579,
"y": 1.2999999523162842,
"z": 2.799999952316284
},
"rotation": {
"x": -2.4492931757747437e-16,
"y": 3.141592653589793,
"z": -6.429647808784774e-40
},
"last_seen": "2025-11-20T14:30:59.486Z",
"template": "#box-template",
"scale": {
"x": 0.1,
"y": 0.1,
"z": 0.1
},
"color": "#0000FF",
"text": "api",
"_id": "ide476ec05-9aac-42c9-87f1-ba7f18141767"
},
{
"id": "idf1cf90c7-cc2f-4ccc-9bd6-274752f5b66f",
"position": {
"x": 0.4000000059604645,
"y": 2.200000047683716,
"z": 3
},
"rotation": {
"x": -2.4492937051703357e-16,
"y": 3.141592653589793,
"z": -2.4492937051703357e-16
},
"last_seen": "2025-11-20T14:28:58.876Z",
"template": "#person-template",
"text": "user",
"scale": {
"x": 0.1,
"y": 0.1,
"z": 0.1
},
"color": "#FFE4B5",
"_id": "idf1cf90c7-cc2f-4ccc-9bd6-274752f5b66f"
}
]
}

144
src/content/upgradeCopy.ts Normal file
View File

@ -0,0 +1,144 @@
/**
* Copy and messaging for upgrade paths and guest limitations
*/
export interface UpgradeBenefit {
title: string;
description: string;
icon?: string;
}
export const GUEST_LIMITATIONS = {
diagrams: {
limit: 3,
message: 'Guest mode is limited to 3 diagrams',
upgradeMessage: 'Sign up for unlimited diagrams',
},
storage: {
limit: 50, // MB
message: 'Guest mode uses 50MB local storage',
upgradeMessage: 'Get cloud storage with sync',
},
collaboration: {
message: 'Collaboration features are disabled in guest mode',
upgradeMessage: 'Sign up to collaborate with your team',
},
sync: {
message: 'Changes are stored locally only',
upgradeMessage: 'Sign up to sync across all your devices',
},
templates: {
message: 'Templates are not available in guest mode',
upgradeMessage: 'Sign up to access our template library',
},
};
export const UPGRADE_BENEFITS: UpgradeBenefit[] = [
{
title: 'Unlimited Diagrams',
description: 'Create as many diagrams as you need without limits',
},
{
title: 'Cloud Sync',
description: 'Access your work from desktop, VR headset, and any device',
},
{
title: 'Real-Time Collaboration',
description: 'Work together with your team in the same 3D space',
},
{
title: 'Template Library',
description: 'Jump-start your projects with pre-built templates',
},
{
title: 'Secure Cloud Storage',
description: 'Your diagrams safely backed up and encrypted',
},
{
title: 'Priority Support',
description: 'Get help when you need it from our support team',
},
];
export const GUEST_MODE_BANNER = {
title: 'You\'re in Guest Mode',
message: 'Your diagrams are saved locally. Sign up to sync across devices and collaborate with teams.',
ctaText: 'Sign Up Free',
};
export const UPGRADE_CTA = {
hero: {
title: 'Ready to unlock the full experience?',
subtitle: 'Sign up free to sync across devices, collaborate with teams, and create unlimited diagrams.',
primaryCta: 'Sign Up Free',
secondaryCta: 'Learn More',
},
inline: {
title: 'Want more?',
message: 'Sign up to unlock unlimited diagrams, cloud sync, and collaboration.',
ctaText: 'Sign Up',
},
limit: {
diagrams: {
title: 'Diagram Limit Reached',
message: 'You\'ve created 3 diagrams (guest limit). Sign up to create unlimited diagrams.',
ctaText: 'Upgrade Now',
},
},
};
/**
* Get the appropriate upgrade message based on context
*/
export function getUpgradeMessage(context: 'diagram-limit' | 'collaboration' | 'sync' | 'template'): {
title: string;
message: string;
benefits: string[];
} {
switch (context) {
case 'diagram-limit':
return {
title: 'Unlock Unlimited Diagrams',
message: 'Guest mode is limited to 3 diagrams. Sign up to create as many as you need.',
benefits: [
'Create unlimited diagrams',
'Cloud storage and backup',
'Access from any device',
'Real-time collaboration',
],
};
case 'collaboration':
return {
title: 'Collaborate in Real-Time',
message: 'Work together with your team in shared 3D space.',
benefits: [
'Invite unlimited collaborators',
'See changes in real-time',
'Meet as avatars in VR',
'Audit trail of all changes',
],
};
case 'sync':
return {
title: 'Sync Across All Devices',
message: 'Access your diagrams from desktop, VR, and mobile.',
benefits: [
'Cloud sync across devices',
'Work on Quest and desktop',
'Automatic backups',
'Secure cloud storage',
],
};
case 'template':
return {
title: 'Get Started Faster',
message: 'Access pre-built templates for common use cases.',
benefits: [
'Professional templates',
'Org charts and workflows',
'Architecture diagrams',
'Customizable examples',
],
};
}
}

View File

@ -4,6 +4,7 @@ import {DiagramManager} from "../../diagram/diagramManager";
import {DiagramEventObserverMask} from "../../diagram/types/diagramEventObserverMask"; import {DiagramEventObserverMask} from "../../diagram/types/diagramEventObserverMask";
import log, {Logger} from "loglevel"; import log, {Logger} from "loglevel";
import PouchDB from 'pouchdb'; import PouchDB from 'pouchdb';
import {importDiagramFromJSON, DiagramExport} from "../../util/functions/exportDiagramAsJSON";
export class PouchData { export class PouchData {
public readonly onDBEntityUpdateObservable: Observable<DiagramEntity> = new Observable<DiagramEntity>(); public readonly onDBEntityUpdateObservable: Observable<DiagramEntity> = new Observable<DiagramEntity>();
@ -11,9 +12,11 @@ export class PouchData {
private _db: PouchDB; private _db: PouchDB;
private _diagramManager: DiagramManager; private _diagramManager: DiagramManager;
private _logger: Logger = log.getLogger('PouchData'); private _logger: Logger = log.getLogger('PouchData');
private _dbName: string;
constructor(dbname: string) { constructor(dbname: string) {
this._db = new PouchDB(dbname); this._db = new PouchDB(dbname);
this._dbName = dbname;
} }
public setDiagramManager(diagramManager: DiagramManager) { public setDiagramManager(diagramManager: DiagramManager) {
this._diagramManager = diagramManager; this._diagramManager = diagramManager;
@ -57,18 +60,52 @@ export class PouchData {
diagramManager.onDiagramEventObservable.notifyObservers( diagramManager.onDiagramEventObservable.notifyObservers(
{type: DiagramEventType.REMOVE, entity: entity}, DiagramEventObserverMask.FROM_DB); {type: DiagramEventType.REMOVE, entity: entity}, DiagramEventObserverMask.FROM_DB);
}); });
this._db.allDocs({include_docs: true}).then((docs) => { this._db.allDocs({include_docs: true}).then(async (docs) => {
docs.rows.forEach((row) => { // Check if this is the demo database and it's empty
if (row.doc.id != 'metadata') { if (this._dbName === 'demo' && docs.rows.length === 0) {
diagramManager.onDiagramEventObservable.notifyObservers({ this._logger.info('Demo database is empty, loading template...');
type: DiagramEventType.ADD, await this.loadDemoTemplate();
entity: row.doc // Re-fetch docs after loading template
}, DiagramEventObserverMask.FROM_DB); const updatedDocs = await this._db.allDocs({include_docs: true});
} updatedDocs.rows.forEach((row) => {
}); if (row.doc.id != 'metadata') {
diagramManager.onDiagramEventObservable.notifyObservers({
type: DiagramEventType.ADD,
entity: row.doc
}, DiagramEventObserverMask.FROM_DB);
}
});
} else {
docs.rows.forEach((row) => {
if (row.doc.id != 'metadata') {
diagramManager.onDiagramEventObservable.notifyObservers({
type: DiagramEventType.ADD,
entity: row.doc
}, DiagramEventObserverMask.FROM_DB);
}
});
}
}); });
} }
private async loadDemoTemplate(): Promise<void> {
try {
// Fetch the demo template from public/templates/demo.json
const response = await fetch('/templates/demo.json');
if (!response.ok) {
this._logger.error('Failed to fetch demo template:', response.statusText);
return;
}
const templateData: DiagramExport = await response.json();
// Import the template into the current database
await importDiagramFromJSON(templateData, this._dbName);
this._logger.info('Demo template loaded successfully');
} catch (error) {
this._logger.error('Error loading demo template:', error);
}
}
public async remove(id: string) { public async remove(id: string) {
if (!id) { if (!id) {
return; return;

View File

@ -92,7 +92,7 @@ export class RenderModeButton {
LightmapGenerator.updateAllMaterials(this._scene, nextMode); LightmapGenerator.updateAllMaterials(this._scene, nextMode);
// Recreate button with new label // Recreate button with new label
this.updateButton(nextMode); this.updateButton();
} }
/** /**
@ -100,7 +100,7 @@ export class RenderModeButton {
* @param mode New rendering mode * @param mode New rendering mode
* @private * @private
*/ */
private updateButton(mode: RenderingMode): void { private updateButton(): void {
// Dispose old button // Dispose old button
if (this._button) { if (this._button) {
this._button.dispose(); this._button.dispose();

View File

@ -0,0 +1,135 @@
import {Alert, Button, Group, Text} from "@mantine/core";
import {IconSparkles, IconX} from "@tabler/icons-react";
import {useState, useEffect} from "react";
import {useAuth0} from "@auth0/auth0-react";
export interface UpgradePromptProps {
reason: 'diagram-limit' | 'share-feature' | 'sync-feature' | 'template-feature';
onDismiss?: () => void;
}
const PROMPT_MESSAGES = {
'diagram-limit': {
title: 'Diagram Limit Reached',
message: 'You\'ve reached the 3 diagram limit for guest mode. Sign up to create unlimited diagrams and sync across devices!',
},
'share-feature': {
title: 'Collaboration Requires Sign Up',
message: 'Share and collaborate with your team in real-time. Sign up to unlock collaboration features!',
},
'sync-feature': {
title: 'Sync Across Devices',
message: 'Access your diagrams from any device. Sign up to enable cloud sync between desktop and VR!',
},
'template-feature': {
title: 'Templates Available',
message: 'Get started faster with pre-built templates. Sign up to access our template library!',
},
};
const SESSION_STORAGE_KEY = 'upgrade-prompts-dismissed';
export function UpgradePrompt({ reason, onDismiss }: UpgradePromptProps) {
const { loginWithRedirect, isAuthenticated } = useAuth0();
const [visible, setVisible] = useState(true);
const message = PROMPT_MESSAGES[reason];
useEffect(() => {
// Check if this prompt was already dismissed in this session
const dismissed = sessionStorage.getItem(SESSION_STORAGE_KEY);
if (dismissed) {
try {
const dismissedReasons = JSON.parse(dismissed);
if (dismissedReasons.includes(reason)) {
setVisible(false);
}
} catch {
// Ignore parse errors
}
}
}, [reason]);
const handleDismiss = () => {
// Mark as dismissed for this session
const dismissed = sessionStorage.getItem(SESSION_STORAGE_KEY);
let dismissedReasons: string[] = [];
if (dismissed) {
try {
dismissedReasons = JSON.parse(dismissed);
} catch {
// Ignore parse errors
}
}
if (!dismissedReasons.includes(reason)) {
dismissedReasons.push(reason);
}
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(dismissedReasons));
setVisible(false);
onDismiss?.();
};
const handleSignUp = () => {
loginWithRedirect({
appState: { returnTo: window.location.pathname }
});
};
// Don't show if already authenticated or dismissed
if (!visible || isAuthenticated) {
return null;
}
return (
<Alert
variant="light"
color="blue"
title={message.title}
icon={<IconSparkles size={20} />}
withCloseButton
onClose={handleDismiss}
mb="md"
>
<Text size="sm" mb="sm">
{message.message}
</Text>
<Group gap="xs">
<Button
size="xs"
onClick={handleSignUp}
leftSection={<IconSparkles size={16} />}
>
Sign Up Free
</Button>
<Button
size="xs"
variant="subtle"
onClick={handleDismiss}
leftSection={<IconX size={16} />}
>
Maybe Later
</Button>
</Group>
</Alert>
);
}
/**
* Hook to trigger upgrade prompts based on conditions
*/
export function useUpgradePrompt() {
const [promptReason, setPromptReason] = useState<UpgradePromptProps['reason'] | null>(null);
const showPrompt = (reason: UpgradePromptProps['reason']) => {
setPromptReason(reason);
};
const hidePrompt = () => {
setPromptReason(null);
};
return {
promptReason,
showPrompt,
hidePrompt,
};
}

View File

@ -0,0 +1,71 @@
import React from "react";
import {Button, Text, Title} from "@mantine/core";
import {IconBadgeVr, IconBrandMeta, IconHeadset} from "@tabler/icons-react";
import log from "loglevel";
const logger = log.getLogger('VREntryPrompt');
interface VREntryPromptProps {
isVisible: boolean;
onEnterVR: () => void;
onSkip: () => void;
}
export default function VREntryPrompt({ isVisible, onEnterVR, onSkip }: VREntryPromptProps) {
if (!isVisible) {
return null;
}
logger.info('VR entry prompt is rendering');
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
backgroundColor: 'rgba(0,0,0, 0.6)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
gap: '2rem',
zIndex: 10000,
pointerEvents: 'auto'
}}>
<IconBrandMeta size={80} color="white" />
<Title order={1} c="white" ta="center">Ready to Enter VR</Title>
<Text
size="xl"
c="white"
ta="center"
style={{maxWidth: '600px', padding: '0 2rem', color: 'white'}}
>
Tap the button below to enter immersive mode and explore the diagram in VR
</Text>
<Button
size="xl"
onClick={(e) => {
e.preventDefault();
logger.info('User tapped VR entry button');
onEnterVR();
}}
>
Enter <IconBadgeVr size={80} color="black" /> Now
</Button>
<Button
variant="subtle"
onClick={(e) => {
e.preventDefault();
logger.info('User skipped VR entry');
onSkip();
}}
style={{color: 'white', fontSize: '1rem'}}
>
Skip - Stay in Desktop Mode
</Button>
</div>
);
}

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 } from '../../util/featureConfig'; import { FeatureConfig, DEFAULT_FEATURE_CONFIG, GUEST_FEATURE_CONFIG } from '../../util/featureConfig';
import log from 'loglevel'; import log from 'loglevel';
const logger = log.getLogger('FeatureProvider'); const logger = log.getLogger('FeatureProvider');
@ -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>(DEFAULT_FEATURE_CONFIG); const [config, setConfig] = useState<FeatureConfig>(GUEST_FEATURE_CONFIG); // Start with 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,13 +67,22 @@ export function FeatureProvider({ children }: FeatureProviderProps) {
} }
} }
// If not authenticated, use guest config
if (!isAuthenticated) {
logger.info('User not authenticated, using guest config');
setConfig(GUEST_FEATURE_CONFIG);
setIsLoading(false);
return;
}
const fetchedConfig = await fetchFeatureConfig(accessToken); const fetchedConfig = await fetchFeatureConfig(accessToken);
setConfig(fetchedConfig); setConfig(fetchedConfig);
} 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, use default config (everything disabled) // On error, fallback to guest config for better UX
setConfig(DEFAULT_FEATURE_CONFIG); logger.warn('Error loading features, falling back to guest config');
setConfig(GUEST_FEATURE_CONFIG);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }

View File

@ -1,19 +0,0 @@
import React from "react";
import {Modal} from "@mantine/core";
import {useDisclosure} from "@mantine/hooks";
const firstVisit = !(window.localStorage.getItem('firstVisit') === 'false');
export default function FirstVisitVr() {
const [opened, {close}] =
useDisclosure(firstVisit,
{
onClose: () => {
window.localStorage.setItem('firstVisit', 'false')
}
});
return (
<Modal opened={opened} onClose={close}>
Welcome
</Modal>
)
}

View File

@ -1,51 +1,222 @@
import {Card} from "@mantine/core"; import {Button, Card, Container, Group, Stack, Text, Title, Alert, Box, List, ThemeIcon} from "@mantine/core";
import PageTemplate from "../pageTemplate"; import PageTemplate from "../pageTemplate";
import {useEffect, useState} from "react";
import {getDeviceCapabilities, DeviceCapabilities} from "../../util/deviceDetection";
import {IconHeadset, IconCheck, IconRocket} from "@tabler/icons-react";
import {useNavigate} from "react-router-dom";
export default function About() { export default function About() {
const [deviceCaps, setDeviceCaps] = useState<DeviceCapabilities | null>(null);
const navigate = useNavigate();
useEffect(() => {
getDeviceCapabilities().then(setDeviceCaps);
}, []);
const handleTryItOut = () => {
// For Quest users, set a flag indicating intent to enter VR
// This allows the demo page to show immediate VR entry UI
if (deviceCaps?.isMobileVR) {
sessionStorage.setItem('autoEnterVR', 'true');
}
// Use React Router navigation instead of window.location to try preserving gesture context
navigate('/db/public/demo');
};
const getCtaText = () => {
if (!deviceCaps) return "Try It Now";
if (deviceCaps.isMobileVR) return "Launch VR Experience";
if (deviceCaps.isVRCapable) return "Try It Now (VR Ready)";
return "Try It Now (Desktop Mode)";
};
return ( return (
<PageTemplate> <PageTemplate>
<Card m={64}> <Container size="lg" py={60}>
<p> {/* Hero Section */}
Introducing Das Fad: Your Creative Hub for Digital Architecture and Collaboration <Stack gap="xl" mb={60}>
</p> <Title order={1} ta="center" size="3rem">
<p> 3D Diagramming in Virtual Reality
Das Fad is a groundbreaking platform designed to enable collaboration between software architects, </Title>
security professionals, software development teams, and risk management teams. <Text size="xl" ta="center" c="dimmed" maw={800} mx="auto">
</p> Das Fad is a groundbreaking WebXR platform for creating, collaborating, and exploring
<p> system architecture diagrams in immersive 3D space.
Combining cutting-edge technology with a user-friendly interface, </Text>
Das Fad empowers professionals to push the boundaries of creativity
while ensuring robust security and seamless collaboration.
</p>
<p>
With our intuitive tools, you can quickly build and share designs for new
software platforms and instantly get feedback from different
stakeholders. Whether you are working on a simple layout or an intricate global network,
you can quickly vet, change, and share your system designs with your team.
</p>
<p>
Our solution is secure by design, ensuring that your
intellectual property and sensitive data are protected at all times.
Das Fad employs advanced encryption and secure access protocols
to ensure that your information remains confidential and secure.
</p>
<p>
Dive deep into your projects with our forensic analysis tools.
Identify potential design flaws, assess structural integrity,
and integrate with your incident tracking tools to quickly
assess and mitigate risks.
</p>
<p>
Our model is collaboration first and all aspects are designed to be used
with multiple users while retaining all audit history of changes.
</p> <Group justify="center" mt="md">
<p> <Button
Designed by software professionals for software professionals, size="xl"
the experience is tailored to your needs. onClick={handleTryItOut}
leftSection={<IconRocket size={24} />}
>
{getCtaText()}
</Button>
</Group>
</p> {/* VR Headset Recommendation */}
</Card> {deviceCaps && !deviceCaps.isMobileVR && (
<Alert
variant="light"
color="blue"
title="Best Experienced in VR"
icon={<IconHeadset />}
maw={700}
mx="auto"
>
While you can use Das Fad on desktop, the experience truly shines with a VR headset like
Meta Quest 2/3. In VR, you can naturally walk around your diagrams, manipulate objects
with your hands, and experience spatial relationships at full scale.
</Alert>
)}
{deviceCaps && deviceCaps.isMobileVR && (
<Alert
variant="light"
color="green"
title="VR Headset Detected!"
icon={<IconHeadset />}
maw={700}
mx="auto"
>
Perfect! You're using a VR headset - you'll get the full immersive experience.
Tap the button above to launch directly into VR.
</Alert>
)}
</Stack>
{/* Feature Highlights */}
<Stack gap="lg" mb={60}>
<Title order={2} ta="center" mb="md">
Why Das Fad?
</Title>
<Box>
<Card shadow="sm" padding="lg" radius="md" withBorder>
<Group mb="xs">
<ThemeIcon size="lg" radius="md" variant="light">
<IconCheck size={20} />
</ThemeIcon>
<Title order={3}>Immersive 3D Diagramming</Title>
</Group>
<Text c="dimmed">
Create and manipulate diagrams in true 3D space. Walk around your architecture,
see spatial relationships, and design at room scale.
</Text>
</Card>
</Box>
<Box>
<Card shadow="sm" padding="lg" radius="md" withBorder>
<Group mb="xs">
<ThemeIcon size="lg" radius="md" variant="light">
<IconCheck size={20} />
</ThemeIcon>
<Title order={3}>Real-Time Collaboration</Title>
</Group>
<Text c="dimmed">
Meet your team in VR. See their avatars, watch changes happen in real-time,
and collaborate naturally in shared 3D space.
</Text>
</Card>
</Box>
<Box>
<Card shadow="sm" padding="lg" radius="md" withBorder>
<Group mb="xs">
<ThemeIcon size="lg" radius="md" variant="light">
<IconCheck size={20} />
</ThemeIcon>
<Title order={3}>Built for Professionals</Title>
</Group>
<Text c="dimmed">
Designed by software professionals for architects, security teams, developers,
and risk management. Audit trails, encryption, and forensic analysis built-in.
</Text>
</Card>
</Box>
<Box>
<Card shadow="sm" padding="lg" radius="md" withBorder>
<Group mb="xs">
<ThemeIcon size="lg" radius="md" variant="light">
<IconCheck size={20} />
</ThemeIcon>
<Title order={3}>Works Everywhere</Title>
</Group>
<Text c="dimmed">
Desktop browser or VR headset - your choice. Start on desktop, continue in VR.
Your diagrams sync seamlessly across devices.
</Text>
</Card>
</Box>
</Stack>
{/* VR Benefits Section */}
<Card shadow="md" padding="xl" radius="md" bg="dark.7" mb={60}>
<Title order={2} mb="md" c="white">
Why VR Makes a Difference
</Title>
<List
spacing="md"
size="md"
icon={
<ThemeIcon color="blue" size={24} radius="xl">
<IconCheck size={16} />
</ThemeIcon>
}
>
<List.Item>
<Text c="white">
<strong>Spatial Understanding:</strong> Grasp complex system relationships by
physically walking around your architecture
</Text>
</List.Item>
<List.Item>
<Text c="white">
<strong>Natural Interaction:</strong> Use your hands to create, move, and
connect components - no learning curve
</Text>
</List.Item>
<List.Item>
<Text c="white">
<strong>True Scale:</strong> See your systems at real-world scale, from
microservices to global networks
</Text>
</List.Item>
<List.Item>
<Text c="white">
<strong>Immersive Focus:</strong> No distractions, no screen switching -
just you and your architecture
</Text>
</List.Item>
<List.Item>
<Text c="white">
<strong>Better Collaboration:</strong> Meet teammates as avatars, point at
things naturally, and communicate intuitively
</Text>
</List.Item>
</List>
</Card>
{/* CTA Section */}
<Stack gap="md" align="center">
<Title order={2}>Ready to Try It?</Title>
<Text size="lg" c="dimmed" ta="center" maw={600}>
No signup required to start exploring. Create your first diagram in seconds.
</Text>
<Button
size="xl"
onClick={handleTryItOut}
leftSection={<IconRocket size={24} />}
>
{getCtaText()}
</Button>
<Text size="sm" c="dimmed" ta="center">
Guest mode uses local storage only. Sign up to sync across devices and collaborate with teams.
</Text>
</Stack>
</Container>
</PageTemplate> </PageTemplate>
) );
} }

View File

@ -1,10 +1,11 @@
import {Button, Card, Container, Group, Modal, Paper, SimpleGrid, Stack} from "@mantine/core"; import {Button, Card, Container, Group, Modal, Paper, SimpleGrid, Stack} from "@mantine/core";
import React from "react"; import React from "react";
import {useDoc, usePouch} from "use-pouchdb"; import {useDoc, usePouch} from "use-pouchdb";
import {IconTrash} from "@tabler/icons-react"; import {IconTrash, IconDownload} from "@tabler/icons-react";
import {Link} from "react-router-dom"; import {Link} from "react-router-dom";
import log from "loglevel"; import log from "loglevel";
import {useFeatureLimit} from "../hooks/useFeatures"; import {useFeatureLimit} from "../hooks/useFeatures";
import {exportDiagramAsJSON} from "../../util/functions/exportDiagramAsJSON";
export default function ManageDiagramsModal({openCreate, manageOpened, closeManage}) { export default function ManageDiagramsModal({openCreate, manageOpened, closeManage}) {
const logger = log.getLogger('manageDiagramsModal'); const logger = log.getLogger('manageDiagramsModal');
@ -23,6 +24,15 @@ export default function ManageDiagramsModal({openCreate, manageOpened, closeMana
} }
const diagrams = diagram.diagrams || []; const diagrams = diagram.diagrams || [];
const handleExportDiagram = async (diagramId: string) => {
try {
await exportDiagramAsJSON(diagramId);
logger.info(`Diagram ${diagramId} exported successfully`);
} catch (error) {
logger.error('Failed to export diagram:', error);
}
};
const cards = diagrams.map((diagram) => { const cards = diagrams.map((diagram) => {
return ( return (
<Card key={diagram._id}> <Card key={diagram._id}>
@ -39,6 +49,14 @@ export default function ManageDiagramsModal({openCreate, manageOpened, closeMana
<Button component={Link} key="examples" to={"/db/public/" + diagram._id} p={5} c="myColor" <Button component={Link} key="examples" to={"/db/public/" + diagram._id} p={5} c="myColor"
bg="none">Select</Button> bg="none">Select</Button>
<Button
onClick={() => handleExportDiagram(diagram._id)}
variant="light"
size="xs"
title="Export as JSON">
<IconDownload size={16}/>
</Button>
<Button bg="red" size="xs"><IconTrash size={16}/></Button> <Button bg="red" size="xs"><IconTrash size={16}/></Button>
</Group> </Group>
</Card.Section> </Card.Section>

View File

@ -1,17 +1,22 @@
import VrApp from '../../vrApp'; import VrApp from '../../vrApp';
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import {Affix, Burger, Group, Menu} from "@mantine/core"; import {Affix, Burger, Group, Menu, Alert, Button, Text} from "@mantine/core";
import VrTemplate from "../vrTemplate"; import VrTemplate from "../vrTemplate";
import {IconStar} from "@tabler/icons-react"; import {IconStar, IconInfoCircle} from "@tabler/icons-react";
import VrMenuItem from "../components/vrMenuItem"; import VrMenuItem from "../components/vrMenuItem";
import CreateDiagramModal from "./createDiagramModal"; import CreateDiagramModal from "./createDiagramModal";
import ManageDiagramsModal from "./manageDiagramsModal"; import ManageDiagramsModal from "./manageDiagramsModal";
import {useNavigate, useParams} from "react-router-dom"; 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 FirstVisitVr from "../instructions/firstVisitVr";
import log from "loglevel"; import log from "loglevel";
import {useIsFeatureEnabled, useUserTier} from "../hooks/useFeatures"; import {useIsFeatureEnabled, 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";
let vrApp: VrApp = null; let vrApp: VrApp = null;
@ -21,6 +26,8 @@ const defaultManage = window.localStorage.getItem('manageOpened') === 'true';
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 [guestBannerDismissed, setGuestBannerDismissed] = useState(false);
// Feature flags // Feature flags
const createDiagramEnabled = useIsFeatureEnabled('createDiagram'); const createDiagramEnabled = useIsFeatureEnabled('createDiagram');
@ -33,6 +40,21 @@ export default function VrExperience() {
const launchMetaQuestEnabled = useIsFeatureEnabled('launchMetaQuest'); const launchMetaQuestEnabled = useIsFeatureEnabled('launchMetaQuest');
const userTier = useUserTier(); const userTier = useUserTier();
const handleSignUp = () => {
loginWithRedirect({
appState: { returnTo: window.location.pathname }
});
};
const handleExportJSON = async () => {
try {
await exportDiagramAsJSON(dbName);
logger.info('Diagram exported successfully');
} catch (error) {
logger.error('Failed to export diagram:', error);
}
};
const saveState = (key, value) => { const saveState = (key, value) => {
logger.debug('saving', key, value) logger.debug('saving', key, value)
window.localStorage.setItem(key, value ? 'true' : 'false'); window.localStorage.setItem(key, value ? 'true' : 'false');
@ -68,6 +90,8 @@ export default function VrExperience() {
const [rerender, setRerender] = useState(0); const [rerender, setRerender] = useState(0);
const [dbName, setDbName] = useState(params.db); const [dbName, setDbName] = useState(params.db);
const [showVRPrompt, setShowVRPrompt] = useState(false);
useEffect(() => { useEffect(() => {
const canvas = document.getElementById('vrCanvas'); const canvas = document.getElementById('vrCanvas');
if (!canvas) { if (!canvas) {
@ -80,6 +104,37 @@ export default function VrExperience() {
} }
vrApp = new VrApp(canvas as HTMLCanvasElement, dbName); vrApp = new VrApp(canvas as HTMLCanvasElement, dbName);
closeManage(); closeManage();
// Show VR entry prompt for all Quest users navigating to any /db/** path
const isQuest = isMobileVRDevice();
logger.info(`Device check: isMobileVRDevice=${isQuest}, userAgent=${navigator.userAgent}`);
if (isQuest) {
logger.info('Quest device detected, will show VR prompt when ready');
// Wait for XR to be ready, then show the prompt
let attempts = 0;
const maxAttempts = 50;
const waitForXRReady = setInterval(() => {
attempts++;
const scene = DefaultScene.Scene;
const groundMesh = scene?.getMeshByName('ground');
logger.debug(`XR readiness check attempt ${attempts}: scene=${!!scene}, groundMesh=${!!groundMesh}`);
if (groundMesh || attempts >= maxAttempts) {
clearInterval(waitForXRReady);
if (groundMesh) {
logger.info('XR ready, showing VR entry prompt');
setShowVRPrompt(true);
logger.info(`showVRPrompt state set to true`);
} else {
logger.warn('XR setup timeout, cannot show VR prompt');
}
}
}, 500);
}
}, [dbName]); }, [dbName]);
const [immersiveDisabled, setImmersiveDisabled] = useState(true); const [immersiveDisabled, setImmersiveDisabled] = useState(true);
@ -130,7 +185,31 @@ export default function VrExperience() {
return ( return (
<React.StrictMode> <React.StrictMode>
<VrTemplate> <VrTemplate>
<FirstVisitVr/> {/* Guest Mode Banner - Non-aggressive, dismissible (hidden for demo) */}
{!isAuthenticated && !guestBannerDismissed && dbName !== 'demo' && (
<Affix position={{top: 20, right: 20}} style={{maxWidth: 400}}>
<Alert
variant="light"
color="blue"
title={GUEST_MODE_BANNER.title}
icon={<IconInfoCircle size={20} />}
withCloseButton
onClose={() => setGuestBannerDismissed(true)}
>
<Text size="sm" mb="xs">
{GUEST_MODE_BANNER.message}
</Text>
<Button
size="xs"
onClick={handleSignUp}
variant="light"
>
{GUEST_MODE_BANNER.ctaText}
</Button>
</Alert>
</Affix>
)}
<ConfigModal closeConfig={closeConfig} configOpened={configOpened}/> <ConfigModal closeConfig={closeConfig} configOpened={configOpened}/>
{createModal()} {createModal()}
{manageModal()} {manageModal()}
@ -204,6 +283,13 @@ export default function VrExperience() {
availableIcon={getTierIndicator('free')}/> availableIcon={getTierIndicator('free')}/>
)} )}
{/* Export JSON - Always available for creating templates */}
<VrMenuItem
tip="Export current diagram as JSON file (useful for creating templates)"
label="Export JSON"
onClick={handleExportJSON}
availableIcon={null}/>
{(shareCollaborateEnabled || configEnabled) && <Menu.Divider/>} {(shareCollaborateEnabled || configEnabled) && <Menu.Divider/>}
{shareCollaborateEnabled && ( {shareCollaborateEnabled && (
@ -225,6 +311,17 @@ export default function VrExperience() {
</Menu> </Menu>
</Affix> </Affix>
<canvas id="vrCanvas" style={{zIndex: 1000, width: '100%', height: '100vh'}}/> <canvas id="vrCanvas" style={{zIndex: 1000, width: '100%', height: '100vh'}}/>
{/* VR Entry Prompt - Rendered AFTER canvas to ensure it's on top in DOM order */}
<VREntryPrompt
isVisible={showVRPrompt}
onEnterVR={() => {
setShowVRPrompt(false);
const event = new CustomEvent('enterXr', {bubbles: true});
window.dispatchEvent(event);
}}
onSkip={() => setShowVRPrompt(false)}
/>
</VrTemplate> </VrTemplate>
</React.StrictMode> </React.StrictMode>
) )

View File

@ -0,0 +1,69 @@
/**
* Device detection utilities for VR capability and device type
*/
import {Scene, WebXRDefaultExperience} from "@babylonjs/core";
export interface DeviceCapabilities {
isVRCapable: boolean;
isMobileVR: boolean;
isDesktop: boolean;
deviceType: 'vr-headset' | 'desktop' | 'mobile';
}
/**
* Checks if the browser supports WebXR immersive VR sessions
*/
export async function checkVRCapability(): Promise<boolean> {
if (!('xr' in navigator)) {
return false;
}
try {
return await navigator.xr!.isSessionSupported('immersive-vr');
} catch {
return false;
}
}
/**
* Detects if the user is on a VR headset device (Quest, Pico, etc.)
*/
export function isMobileVRDevice(): boolean {
const ua = navigator.userAgent;
return true;
//return /Quest|Oculus|Pico|VR/i.test(ua);
}
/**
* Detects if the user is on a desktop device
*/
export function isDesktopDevice(): boolean {
const ua = navigator.userAgent;
return !/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Quest|Oculus|Pico/i.test(ua);
}
/**
* Gets comprehensive device capabilities
*/
export async function getDeviceCapabilities(): Promise<DeviceCapabilities> {
const isVRCapable = await checkVRCapability();
const isMobileVR = isMobileVRDevice();
const isDesktop = isDesktopDevice();
let deviceType: 'vr-headset' | 'desktop' | 'mobile';
if (isMobileVR) {
deviceType = 'vr-headset';
} else if (isDesktop) {
deviceType = 'desktop';
} else {
deviceType = 'mobile';
}
return {
isVRCapable,
isMobileVR,
isDesktop,
deviceType
};
}

View File

@ -69,6 +69,37 @@ 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
*/ */

View File

@ -0,0 +1,148 @@
import PouchDB from 'pouchdb';
import log from 'loglevel';
const logger = log.getLogger('exportDiagramAsJSON');
export interface DiagramExport {
name: string;
dbName: string;
exportDate: string;
version: string;
entities: any[];
metadata?: any;
}
/**
* Exports the current diagram as a JSON file
* @param dbName - The name of the PouchDB database to export
* @param includeMetadata - Whether to include the metadata document in the export
*/
export async function exportDiagramAsJSON(dbName: string, includeMetadata: boolean = false): Promise<void> {
try {
logger.info(`Exporting diagram: ${dbName}`);
// Access the PouchDB database
const db = new PouchDB(dbName);
// Get all documents
const allDocs = await db.allDocs({ include_docs: true });
// Filter out metadata (unless requested) and extract entities
const entities = allDocs.rows
.filter(row => includeMetadata || row.doc._id !== 'metadata')
.map(row => {
// Remove PouchDB internal fields for cleaner export
const { _rev, ...cleanDoc } = row.doc;
return cleanDoc;
});
// Get metadata if it exists
let metadata = null;
if (includeMetadata) {
try {
metadata = await db.get('metadata');
const { _rev, ...cleanMetadata } = metadata;
metadata = cleanMetadata;
} catch (err) {
logger.warn('No metadata found');
}
}
// Get friendly name from localStorage if available
const friendlyName = localStorage.getItem(dbName) || dbName;
// Create export object
const exportData: DiagramExport = {
name: friendlyName,
dbName: dbName,
exportDate: new Date().toISOString(),
version: '1.0',
entities: entities.filter(e => e._id !== 'metadata'),
};
if (includeMetadata && metadata) {
exportData.metadata = metadata;
}
// Convert to formatted JSON
const json = JSON.stringify(exportData, null, 2);
// Create and trigger download
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
// Create filename with sanitized diagram name
const sanitizedName = friendlyName.replace(/[^a-z0-9]/gi, '-').toLowerCase();
const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
link.download = `diagram-${sanitizedName}-${timestamp}.json`;
// Trigger download
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up blob URL
URL.revokeObjectURL(url);
logger.info(`Export complete: ${link.download}`);
} catch (error) {
logger.error('Error exporting diagram:', error);
throw new Error(`Failed to export diagram: ${error.message}`);
}
}
/**
* Imports a diagram from a JSON file
* This can be used to load template diagrams from static hosting
* @param jsonData - The parsed JSON data from the export
* @param targetDbName - Optional: Override the database name from the export
*/
export async function importDiagramFromJSON(jsonData: DiagramExport, targetDbName?: string): Promise<string> {
try {
const dbName = targetDbName || jsonData.dbName;
logger.info(`Importing diagram to: ${dbName}`);
// Create/open database
const db = new PouchDB(dbName);
// Import metadata if present
if (jsonData.metadata) {
try {
await db.put(jsonData.metadata);
} catch (err) {
logger.warn('Could not import metadata:', err);
}
}
// Import all entities
for (const entity of jsonData.entities) {
try {
await db.put(entity);
} catch (err) {
// If document exists, update it
if (err.status === 409) {
const existing = await db.get(entity._id);
await db.put({ ...entity, _rev: existing._rev });
} else {
logger.error('Error importing entity:', entity._id, err);
}
}
}
// Store friendly name in localStorage
if (jsonData.name) {
localStorage.setItem(dbName, jsonData.name);
}
logger.info(`Import complete: ${jsonData.entities.length} entities imported`);
return dbName;
} catch (error) {
logger.error('Error importing diagram:', error);
throw new Error(`Failed to import diagram: ${error.message}`);
}
}

View File

@ -74,10 +74,7 @@ export default class VrApp {
} else { } else {
this.logger.error('Download button not found'); this.logger.error('Download button not found');
}*/ }*/
if (!localStorage.getItem('tutorialCompleted')) {
this.logger.info('Starting tutorial');
const intro = new Introduction();
}
this.logger.info('Render loop started'); this.logger.info('Render loop started');
} }