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:
parent
0b81605bdf
commit
0e053bf69c
224
ROADMAP.md
Normal file
224
ROADMAP.md
Normal 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*
|
||||||
@ -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
245
public/templates/demo.json
Normal 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
144
src/content/upgradeCopy.ts
Normal 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',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,7 +60,22 @@ 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) => {
|
||||||
|
// Check if this is the demo database and it's empty
|
||||||
|
if (this._dbName === 'demo' && docs.rows.length === 0) {
|
||||||
|
this._logger.info('Demo database is empty, loading template...');
|
||||||
|
await this.loadDemoTemplate();
|
||||||
|
// Re-fetch docs after loading template
|
||||||
|
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) => {
|
docs.rows.forEach((row) => {
|
||||||
if (row.doc.id != 'metadata') {
|
if (row.doc.id != 'metadata') {
|
||||||
diagramManager.onDiagramEventObservable.notifyObservers({
|
diagramManager.onDiagramEventObservable.notifyObservers({
|
||||||
@ -66,9 +84,28 @@ export class PouchData {
|
|||||||
}, DiagramEventObserverMask.FROM_DB);
|
}, 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;
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
135
src/react/components/UpgradePrompt.tsx
Normal file
135
src/react/components/UpgradePrompt.tsx
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
71
src/react/components/VREntryPrompt.tsx
Normal file
71
src/react/components/VREntryPrompt.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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 */}
|
||||||
|
{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>
|
</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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
69
src/util/deviceDetection.ts
Normal file
69
src/util/deviceDetection.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
148
src/util/functions/exportDiagramAsJSON.ts
Normal file
148
src/util/functions/exportDiagramAsJSON.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user