feat: Flow Transfer, Procedural Assist & UI Design System (#97)

* feat: add flow export/import backend (migration, endpoints, schemas)

Add .rfflow file export/import support:
- Migration 050: import_metadata JSONB column on trees
- GET /trees/{id}/export?format=json|xml endpoint
- POST /trees/import endpoint (creates draft, resolves categories/tags)
- FlowExportEnvelope, FlowImportRequest/Response schemas
- import_metadata field on TreeResponse

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add flow export/import frontend + backend tests

Frontend:
- ExportFlowModal with JSON/XML format selection + download
- ImportFlowModal with drag-drop file picker + preview step
- rfflowParser for client-side JSON/XML .rfflow parsing
- Export buttons on editor toolbar and library action menus
- Import button on library page next to Create New
- Provenance display for imported flows in editor
- flowTransfer API client + types

Backend:
- Fix regex->pattern deprecation in export endpoint
- 12 integration tests covering export, import, round-trip,
  access control, tag/category creation, version validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: remove XML export, JSON-only for .rfflow files

- Remove XML builder, format query param, and XML tests
- Simplify ExportFlowModal (no format picker)
- Simplify rfflowParser (JSON-only)
- Remove format field from schemas and types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: match Flow Assist chat input to AI Assistant styling + strengthen one-question prompt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add procedural flow support to AI chat builder (Flow Assist)

- Add procedural-specific system prompts (schema, interview protocol, response format)
- Dispatch prompts by flow_type: procedural/maintenance use flat steps schema, troubleshooting uses decision tree schema
- Parse [STEPS_UPDATE] and [INTAKE_FORM] markers in AI responses
- Add validate_generated_procedural_steps() validator
- Handle intake form extraction in AI chat import endpoint
- Add StaticStepsPreview component for procedural flow preview
- Update store and page to render correct preview by flow type

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add flow type selection to Flow Assist entry points

- CreateFlowDropdown now shows "Build with Flow Assist" under each flow type
- Library page "Flow Assist" button respects current type filter
- Clean up unused AIFlowBuilderModal references

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: update CLAUDE.md with AI chat builder and intake form learnings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: refine assistant chat prompt for concise answers and focused questions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: switch AI provider to Claude Sonnet 4.6 + add shift+enter hint to chat inputs

- Default AI_PROVIDER changed from gemini to anthropic
- AI_MODEL and AI_MODEL_ANTHROPIC updated to claude-sonnet-4-6
- Added "Shift + Enter for a new line" hint below all chat textareas

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: update CLAUDE.md with AI provider and chat input learnings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add editor-embedded Flow Assist design document

Design for replacing the standalone /ai/chat page with context-aware
AI side panels embedded in each editor (Troubleshooting + Procedural).
Covers ghost node suggestion system, output-based thresholds,
config-driven model routing, knowledge integration, and per-flow
chat persistence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add editor-embedded Flow Assist implementation plan

25-task plan across 9 phases covering backend foundation, frontend
infrastructure, tree/procedural editor integration, AI-assisted create,
old code removal, action-type dispatch, suggestion audit trail, and
build verification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use actual root node ID in orphan validation check

AI-generated trees use descriptive IDs (e.g., "verify-account-exists")
instead of "root", causing the root node to be falsely flagged as orphaned.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add config-driven AI model tier routing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: extend AI chat session with tree_id and archived_at

Add tree_id FK (CASCADE) for editor-embedded sessions and archived_at
timestamp column to ai_chat_sessions table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add AI suggestion audit trail table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add action_type and focal_node_id to AI chat message API

- Add VALID_ACTION_TYPES literal and action_type/focal_node_id fields to
  AIChatMessageRequest schema
- Add tree_id field to AIChatStartRequest schema for editor-embedded sessions
- Update send_message() signature with action_type and focal_node_id params
- Update start_chat_session() signature with tree_id param
- Pass new params through endpoints to service functions
- All new params have defaults so existing behavior is unchanged

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: route AI model selection through action-type config

Update get_ai_provider() to accept an optional model override parameter
(applied only to AnthropicProvider; Gemini always uses its own model).
Thread action_type-based model resolution through send_message() and
generate_final_tree() in the AI chat service.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add TypeScript types for editor-embedded AI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add shared ContextMenu component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add useEditorAI hook and editorAI API client

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add EditorAIPanel component with Chat and Suggestions tabs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: integrate AI panel, context menu, and ghost nodes in tree editor

- Add AI Assist panel toggle button to tree editor toolbar
- Wire EditorAIPanel alongside TreeEditorLayout with single-panel rule
- Thread onNodeContextMenu through TreeEditorLayout → FlowCanvas → FlowCanvasNode
- Add right-click context menu with Generate Branch, Explain Node, Delete actions
- Add ghost node detection (_suggestion flag) with dashed border + opacity styling
- Add Accept/Dismiss overlay buttons on ghost nodes for future suggestion handling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: integrate AI panel, context menu, and ghost steps in procedural editor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add AI prompt dialog and wire into CreateFlowDropdown

Replace navigation to /ai/chat with an inline AIPromptDialog modal
that collects a single prompt, generates a flow via the editor AI API,
imports it, and navigates to the editor with the AI panel open.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add glassmorphism to AI prompt dialog + maintenance Flow Assist button

- Use .glass-card-static on AIPromptDialog card for consistent design system
- Add "Build with Flow Assist" button to maintenance section in CreateFlowDropdown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: remove standalone Flow Assist page and old AI chat components

Remove the old /ai/chat page, AI wizard modal, and all associated
components/stores/types now replaced by the editor-embedded AI panel.

Deleted:
- AIChatBuilderPage, ai-chat/ components, aiChatStore, aiChat API, ai-chat types
- AIFlowBuilderModal, ai-builder/ components, aiFlowBuilderStore

Cleaned up:
- Router (removed /ai/chat route)
- Sidebar (removed Flow Assist nav item)
- MyTreesPage (removed AI builder modal and button)
- TreeLibraryPage (removed Flow Assist button)
- API and type barrel exports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add delta response parsing and action-type prompt dispatch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add AI suggestion audit trail endpoints

Create/list/resolve endpoints for tracking AI-applied changes to flows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add APScheduler task to auto-archive stale AI chat sessions

Archives AI chat sessions with no activity for 30 days, runs daily at 3 AM.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: update project status for editor-embedded Flow Assist

- Add Editor-Embedded Flow Assist to CURRENT-STATE.md in-progress items
- Update CLAUDE.md: fix stale lessons (#41, #46), add new patterns (#47 editor AI architecture, #48 orphan validation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use correct model alias in AI_MODEL_TIERS standard tier

The dated model ID `claude-sonnet-4-6-20250514` was causing 502 errors.
Use the alias `claude-sonnet-4-6` which matches AI_MODEL_ANTHROPIC.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: send live flow context to AI Assist for full editor awareness

The AI panel now sends the current tree structure (troubleshooting) or
steps + intake form (procedural/maintenance) with each message. This
gives the AI full visibility into node details, questions, descriptions,
options, and intake form fields — not just the node ID.

- Backend: add flow_context param to schema, endpoint, and service
- Frontend: add getFlowContext callback to useEditorAI hook
- TreeEditorPage: passes treeStructure as flow context
- ProceduralEditorPage: passes steps + intakeForm as flow context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: include flow name and description in AI Assist context

Both editors now send name and description alongside the flow structure,
so the AI can reference what the flow is about when responding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: increase AI timeout to 120s and limit retries to 1

The 45s timeout was too short for generation tasks with full flow
context in the system prompt. The Anthropic SDK's default 2 retries
caused requests to hang for ~136s before failing. Now: 120s timeout
with max 1 retry = faster failure if it does timeout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: wire AI-generated flow structures into editor stores

The useEditorAI hook was ignoring result.working_tree from AI responses,
so generated steps/trees never appeared in the editor. Now:
- useEditorAI calls onFlowUpdate when working_tree is present in response
- ProceduralEditorPage handles steps + intake form updates via replaceSteps
- TreeEditorPage handles tree structure updates via replaceTreeStructure
- Both stores have new bulk-replace methods for AI integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add lessons learned for full-stack integration, Anthropic retries, model tiers

#49 Always verify frontend consumes backend response fields
#50 Anthropic SDK max_retries=1 to avoid 3× timeout
#51 AI model tier routing via settings.get_model_for_action()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: move AI Assist panel to full-height side layout in both editors

The AI panel was nested inside the content area, only spanning the
step list / canvas section. Now it sits at the outermost flex level,
spanning the full page height alongside all content (toolbar,
collapsible sections, steps/canvas). This prevents the panel from
overlapping content and lets the editor area properly shrink.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: AI Assist panel as fixed right drawer (matching Copilot/Scratchpad)

Convert EditorAIPanel from in-flow flex child to fixed right-side drawer
overlay, same pattern as CopilotPanel and ScratchpadSidebar. The panel
is fixed at right:0 spanning full viewport height, and editor pages add
pr-[380px] padding when open so content shifts left without overlap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: AI Assist panel sits below topbar with slide-in animation

- Panel now uses top:56px to sit below the app shell topbar instead of
  covering it (matches the main-content grid cell area)
- Added slideInRight CSS animation for smooth drawer entrance
- Editor pages use dynamic paddingRight via PANEL_WIDTH constant
- ChatTab upgraded: markdown rendering, CopilotPanel-style message
  bubbles, auto-focus input, Shift+Enter hint
- All borders use --glass-border for consistent glassmorphism

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: AI Assist panel as in-flow flex sibling (not fixed/overlay)

Replace fixed positioning with in-flow flex layout. The outermost div
is now a horizontal flex row: content column (flex-1 min-w-0) + panel
(w-[380px] shrink-0). When the panel opens, the content column
automatically shrinks — no padding hacks or z-index stacking needed.
This guarantees the content shifts left and stays fully visible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: AI Copilot panel as in-flow flex sibling in session navigation pages

Changed CopilotPanel from fixed overlay to flex layout sibling so it
pushes main content instead of covering it during active sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: remove duplicate CLAUDE.md lessons #47-48

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: UI design system primitives, accessibility, and performance improvements

- Add Button component with CVA variants (primary, secondary, destructive, ghost, link)
- Add Input, Textarea, FormField, and Skeleton UI primitives
- Add focus trapping to Modal for WCAG accessibility compliance
- Add prefers-reduced-motion media query for motion-sensitive users
- Add route-level ErrorBoundary wrapping via page() helper in router
- Add route prefetching on sidebar nav hover for instant navigation
- Add PageMeta component with OG/Twitter meta tags (react-helmet-async)
- Add PageMeta to SharedSessionPage and SurveyPage for social sharing
- Replace lodash with custom debounce utility (saves ~71KB bundle)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: mobile-responsive SurveyPage with touch-friendly targets

15+ responsive adjustments using sm: breakpoints for proper mobile
display: compact padding, flex-wrap metadata, stacked email input,
larger touch targets for drag-rank/range inputs, hidden brand text
on small screens, and tighter line heights.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: survey invite links use FRONTEND_URL config instead of hardcoded URL

PR environments were generating survey links pointing to production
(resolutionflow.com) because the URL was hardcoded. Now uses the
existing settings.FRONTEND_URL, falling back to localhost (debug)
or production (release).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: scroll to top after survey slide transition renders

Use requestAnimationFrame to defer scrollTo until after React
renders the new slide content, preventing the browser from
staying at the bottom of the page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: scroll to top on survey slide change via useEffect

requestAnimationFrame was still too early. Use a useEffect on
currentSlide so the scroll fires after React commits the new
slide to the DOM. Skips initial render to avoid scroll on load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: survey scroll-to-top on mobile using scrollIntoView

Mobile browsers (iOS Safari especially) ignore window.scrollTo.
Use scrollIntoView on a ref at the top of the page instead,
which works reliably across mobile and desktop browsers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add mobile scrollIntoView lesson (#52) to CLAUDE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #97.
This commit is contained in:
chihlasm
2026-03-07 18:44:14 -05:00
committed by GitHub
parent 0dc6123c0c
commit 96966c3b72
21 changed files with 613 additions and 412 deletions

View File

@@ -335,6 +335,8 @@ navigate(`/trees/${newTree.id}/edit`)
**51. AI model tier routing:** `config.py` has `AI_MODEL_TIERS` (fast/standard) and `ACTION_MODEL_MAP` mapping action types to tiers. Use `settings.get_model_for_action(action_type)` to resolve concrete model names. Model IDs must be valid — use alias form (`claude-sonnet-4-6`) not invented dated forms.
**52. Mobile scroll-to-top — use `scrollIntoView`, not `window.scrollTo`:** Mobile browsers (iOS Safari, Firefox Android) often ignore `window.scrollTo()`. Use a ref at the top of the page and call `ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' })` instead. Trigger via `useEffect` on the state change (not inline with `setState`) so the DOM has committed before scrolling.
---
## RBAC & Permissions

View File

@@ -29,11 +29,14 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin", tags=["admin-survey"])
FRONTEND_URL = "https://resolutionflow.com"
def _get_frontend_url() -> str:
if settings.FRONTEND_URL:
return settings.FRONTEND_URL
return "http://localhost:5173" if settings.DEBUG else "https://resolutionflow.com"
def _build_invite_response(invite: SurveyInvite) -> SurveyInviteResponse:
base_url = FRONTEND_URL if not settings.DEBUG else "http://localhost:5173"
base_url = _get_frontend_url()
return SurveyInviteResponse(
id=str(invite.id),
token=invite.token,
@@ -63,7 +66,7 @@ async def create_survey_invite(
if data.send_email and data.recipient_email:
try:
base_url = FRONTEND_URL if not settings.DEBUG else "http://localhost:5173"
base_url = _get_frontend_url()
survey_url = f"{base_url}/survey?t={invite.token}"
sent = await EmailService.send_survey_invite_email(
to_email=data.recipient_email,

View File

@@ -14,19 +14,18 @@
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.7.0",
"@stripe/stripe-js": "^8.7.0",
"@types/lodash": "^4.17.23",
"@xyflow/react": "^12.10.0",
"axios": "^1.13.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"immer": "^11.1.3",
"lodash": "^4.17.23",
"lucide-react": "^0.563.0",
"monaco-editor": "^0.55.1",
"react": "^19.2.0",
"react-day-picker": "^9.13.1",
"react-dom": "^19.2.0",
"react-helmet-async": "^3.0.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.0",
"recharts": "^3.7.0",
@@ -2121,12 +2120,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==",
"license": "MIT"
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -4643,6 +4636,15 @@
"node": ">=12"
}
},
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/is-alphabetical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@@ -4818,7 +4820,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -4981,12 +4982,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -5004,6 +4999,18 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -6335,6 +6342,26 @@
"react": "^19.2.4"
}
},
"node_modules/react-fast-compare": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==",
"license": "MIT"
},
"node_modules/react-helmet-async": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-3.0.0.tgz",
"integrity": "sha512-nA3IEZfXiclgrz4KLxAhqJqIfFDuvzQwlKwpdmzZIuC1KNSghDEIXmyU0TKtbM+NafnkICcwx8CECFrZ/sL/1w==",
"license": "Apache-2.0",
"dependencies": {
"invariant": "^2.2.4",
"react-fast-compare": "^3.2.2",
"shallowequal": "^1.1.0"
},
"peerDependencies": {
"react": "^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -6781,6 +6808,12 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shallowequal": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@@ -19,19 +19,18 @@
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.7.0",
"@stripe/stripe-js": "^8.7.0",
"@types/lodash": "^4.17.23",
"@xyflow/react": "^12.10.0",
"axios": "^1.13.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"immer": "^11.1.3",
"lodash": "^4.17.23",
"lucide-react": "^0.563.0",
"monaco-editor": "^0.55.1",
"react": "^19.2.0",
"react-day-picker": "^9.13.1",
"react-dom": "^19.2.0",
"react-helmet-async": "^3.0.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.0",
"recharts": "^3.7.0",

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { Search, X } from 'lucide-react'
import { debounce } from 'lodash'
import { debounce } from '@/lib/debounce'
import { cn } from '@/lib/utils'
interface SearchInputProps {

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, type ReactNode } from 'react'
import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
import { X, Maximize2, Minimize2 } from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -14,6 +14,9 @@ interface ModalProps {
allowFullScreen?: boolean
}
const FOCUSABLE_SELECTOR =
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled])'
export function Modal({ isOpen, onClose, title, children, footer, size = 'md', allowFullScreen = false }: ModalProps) {
const [isFullScreen, setIsFullScreen] = useState(() => {
if (!allowFullScreen) return false
@@ -24,6 +27,9 @@ export function Modal({ isOpen, onClose, title, children, footer, size = 'md', a
}
})
const modalRef = useRef<HTMLDivElement>(null)
const previousFocusRef = useRef<HTMLElement | null>(null)
const toggleFullScreen = () => {
const next = !isFullScreen
setIsFullScreen(next)
@@ -44,8 +50,10 @@ export function Modal({ isOpen, onClose, title, children, footer, size = 'md', a
[onClose]
)
// Body overflow lock + keyboard listener
useEffect(() => {
if (isOpen) {
previousFocusRef.current = document.activeElement as HTMLElement
document.addEventListener('keydown', handleKeyDown)
document.body.style.overflow = 'hidden'
}
@@ -55,6 +63,50 @@ export function Modal({ isOpen, onClose, title, children, footer, size = 'md', a
}
}, [isOpen, handleKeyDown])
// Focus trap: keep focus inside the modal
useEffect(() => {
if (!isOpen) {
// Restore focus when modal closes
previousFocusRef.current?.focus()
return
}
const modal = modalRef.current
if (!modal) return
// Auto-focus first focusable element
const timer = setTimeout(() => {
const focusable = modal.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
if (focusable.length > 0) {
focusable[0].focus()
}
}, 50)
const trapFocus = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return
const focusable = modal.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
modal.addEventListener('keydown', trapFocus)
return () => {
clearTimeout(timer)
modal.removeEventListener('keydown', trapFocus)
}
}, [isOpen])
if (!isOpen) return null
const sizeClasses = {
@@ -80,6 +132,7 @@ export function Modal({ isOpen, onClose, title, children, footer, size = 'md', a
{/* Modal Content */}
<div
ref={modalRef}
className={cn(
'relative flex w-full flex-col border border-border bg-card shadow-lg',
'animate-scale-in transition-all duration-200',

View File

@@ -0,0 +1,44 @@
import { Helmet } from 'react-helmet-async'
interface PageMetaProps {
title?: string
description?: string
ogImage?: string
ogType?: string
}
const SITE_NAME = 'ResolutionFlow'
const DEFAULT_DESCRIPTION = 'Transform troubleshooting into guided workflows with automatic documentation'
/**
* Sets page-level <title> and Open Graph meta tags.
* Wrap the app in <HelmetProvider> (see main.tsx).
*/
export function PageMeta({
title,
description = DEFAULT_DESCRIPTION,
ogImage,
ogType = 'website',
}: PageMetaProps) {
const fullTitle = title ? `${title} | ${SITE_NAME}` : `${SITE_NAME} - Decision Tree Platform`
return (
<Helmet>
<title>{fullTitle}</title>
<meta name="description" content={description} />
{/* Open Graph */}
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:type" content={ogType} />
<meta property="og:site_name" content={SITE_NAME} />
{ogImage && <meta property="og:image" content={ogImage} />}
{/* Twitter */}
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} />
{ogImage && <meta name="twitter:image" content={ogImage} />}
</Helmet>
)
}

View File

@@ -1,6 +1,7 @@
import { Link, useLocation } from 'react-router-dom'
import type { LucideIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { prefetchForRoute } from '@/lib/routePrefetch'
interface NavSubItem {
href: string
@@ -36,6 +37,7 @@ export function NavItem({ href, icon: Icon, label, badge, matchPaths, collapsed,
return (
<Link
to={href}
onMouseEnter={() => prefetchForRoute(href)}
className={cn(
'group relative flex items-center justify-center rounded-lg p-2 transition-all duration-120',
isActive
@@ -61,6 +63,7 @@ export function NavItem({ href, icon: Icon, label, badge, matchPaths, collapsed,
<div className="group/nav">
<Link
to={href}
onMouseEnter={() => prefetchForRoute(href)}
className={cn(
'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-[0.8125rem] font-medium transition-all duration-120',
isActive

View File

@@ -0,0 +1,63 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { Spinner } from '@/components/common/Spinner'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.97]',
{
variants: {
variant: {
primary:
'bg-gradient-brand text-[#101114] font-semibold shadow-lg shadow-primary/20 hover:opacity-90',
secondary:
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground hover:border-[rgba(255,255,255,0.12)] hover:bg-[rgba(255,255,255,0.06)]',
destructive:
'bg-red-400/10 text-red-400 border border-red-400/20 hover:bg-red-400/20',
ghost:
'text-muted-foreground hover:bg-accent hover:text-foreground',
link:
'text-primary underline-offset-4 hover:underline p-0 h-auto',
},
size: {
sm: 'h-8 px-3 text-xs rounded-lg',
md: 'h-9 px-4 text-sm rounded-[10px]',
lg: 'h-10 px-6 text-sm rounded-[10px]',
icon: 'size-9 rounded-lg',
'icon-sm': 'size-8 rounded-lg',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
loading?: boolean
}
export function Button({
className,
variant,
size,
loading,
children,
disabled,
...props
}: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
disabled={disabled || loading}
{...props}
>
{loading && <Spinner size="sm" />}
{children}
</button>
)
}
export { buttonVariants }

View File

@@ -0,0 +1,24 @@
import type { ReactNode } from 'react'
interface FormFieldProps {
label: string
htmlFor?: string
required?: boolean
hint?: string
children: ReactNode
}
export function FormField({ label, htmlFor, required, hint, children }: FormFieldProps) {
return (
<div className="space-y-1.5">
<label htmlFor={htmlFor} className="text-sm font-medium text-foreground">
{label}
{required && <span className="ml-0.5 text-red-400">*</span>}
</label>
{children}
{hint && (
<p className="text-xs text-muted-foreground">{hint}</p>
)}
</div>
)
}

View File

@@ -0,0 +1,35 @@
import { cn } from '@/lib/utils'
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
error?: string
}
export function Input({ className, error, id, ...props }: InputProps) {
return (
<div>
<input
id={id}
className={cn(
'flex h-9 w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
'placeholder:text-muted-foreground',
'focus:border-[rgba(6,182,212,0.3)] focus:outline-none focus:ring-1 focus:ring-primary/20',
'disabled:cursor-not-allowed disabled:opacity-50',
error && 'border-red-400/50 focus:border-red-400 focus:ring-red-400/20',
className
)}
aria-invalid={error ? true : undefined}
aria-describedby={error && id ? `${id}-error` : undefined}
{...props}
/>
{error && (
<p
id={id ? `${id}-error` : undefined}
className="mt-1.5 text-xs text-red-400"
role="alert"
>
{error}
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,58 @@
import { cn } from '@/lib/utils'
interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {}
export function Skeleton({ className, ...props }: SkeletonProps) {
return (
<div
className={cn(
'animate-pulse rounded-lg bg-[rgba(255,255,255,0.06)]',
className
)}
{...props}
/>
)
}
export function CardSkeleton({ className }: { className?: string }) {
return (
<div className={cn('glass-card-static p-5 space-y-3', className)}>
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<div className="flex gap-2 mt-4">
<Skeleton className="h-6 w-16 rounded-full" />
<Skeleton className="h-6 w-20 rounded-full" />
</div>
</div>
)
}
export function TableRowSkeleton({ cols = 4 }: { cols?: number }) {
return (
<div className="flex items-center gap-4 px-4 py-3">
{Array.from({ length: cols }).map((_, i) => (
<Skeleton
key={i}
className="h-4"
style={{ width: `${20 + Math.random() * 30}%` }}
/>
))}
</div>
)
}
export function ListSkeleton({ count = 5, className }: { count?: number; className?: string }) {
return (
<div className={cn('space-y-3', className)}>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3 rounded-lg bg-[rgba(255,255,255,0.02)]">
<Skeleton className="size-8 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-1/3" />
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,35 @@
import { cn } from '@/lib/utils'
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
error?: string
}
export function Textarea({ className, error, id, ...props }: TextareaProps) {
return (
<div>
<textarea
id={id}
className={cn(
'flex w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
'placeholder:text-muted-foreground',
'focus:border-[rgba(6,182,212,0.3)] focus:outline-none focus:ring-1 focus:ring-primary/20',
'disabled:cursor-not-allowed disabled:opacity-50',
error && 'border-red-400/50 focus:border-red-400 focus:ring-red-400/20',
className
)}
aria-invalid={error ? true : undefined}
aria-describedby={error && id ? `${id}-error` : undefined}
{...props}
/>
{error && (
<p
id={id ? `${id}-error` : undefined}
className="mt-1.5 text-xs text-red-400"
role="alert"
>
{error}
</p>
)}
</div>
)
}

View File

@@ -427,3 +427,15 @@
.react-flow__handle {
background-color: hsl(var(--border));
}
/* Accessibility: Reduce motion for users who prefer it */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -0,0 +1,27 @@
/**
* Simple debounce with cancel support. Replaces lodash.debounce.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function debounce<T extends (...args: any[]) => void>(
fn: T,
ms: number
): T & { cancel: () => void } {
let timeoutId: ReturnType<typeof setTimeout> | null = null
const debounced = (...args: Parameters<T>) => {
if (timeoutId !== null) clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
timeoutId = null
fn(...args)
}, ms)
}
debounced.cancel = () => {
if (timeoutId !== null) {
clearTimeout(timeoutId)
timeoutId = null
}
}
return debounced as T & { cancel: () => void }
}

View File

@@ -0,0 +1,12 @@
const prefetched = new Set<string>()
/**
* Prefetch a lazy-loaded route chunk on hover/focus for perceived instant navigation.
* Call with the same import function used in React.lazy().
*/
export function prefetchRoute(importFn: () => Promise<unknown>) {
const key = importFn.toString()
if (prefetched.has(key)) return
prefetched.add(key)
importFn()
}

View File

@@ -0,0 +1,27 @@
import { prefetchRoute } from '@/lib/prefetch'
/** Map of base route paths to their lazy import functions for hover-prefetching */
const PREFETCH_MAP: Record<string, () => Promise<unknown>> = {
'/': () => import('@/pages/QuickStartPage'),
'/trees': () => import('@/pages/TreeLibraryPage'),
'/my-trees': () => import('@/pages/MyTreesPage'),
'/sessions': () => import('@/pages/SessionHistoryPage'),
'/shares': () => import('@/pages/MySharesPage'),
'/analytics': () => import('@/pages/TeamAnalyticsPage'),
'/analytics/me': () => import('@/pages/MyAnalyticsPage'),
'/assistant': () => import('@/pages/AssistantChatPage'),
'/step-library': () => import('@/pages/StepLibraryPage'),
'/guides': () => import('@/pages/GuidesHubPage'),
'/feedback': () => import('@/pages/FeedbackPage'),
'/account': () => import('@/pages/AccountSettingsPage'),
}
/** Prefetch the chunk for a route on hover/focus */
export function prefetchForRoute(path: string) {
// Strip query params for lookup
const basePath = path.split('?')[0]
const importFn = PREFETCH_MAP[basePath]
if (importFn) {
prefetchRoute(importFn)
}
}

View File

@@ -1,23 +1,26 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { HelmetProvider } from 'react-helmet-async'
import { Toaster } from 'sonner'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
{/* Toast notification system - theme syncs automatically via CSS custom properties */}
<Toaster
position="top-right"
expand={false}
closeButton
visibleToasts={3}
gap={8}
theme="dark"
toastOptions={{
className: 'sonner-toast-custom',
}}
/>
<App />
<HelmetProvider>
{/* Toast notification system - theme syncs automatically via CSS custom properties */}
<Toaster
position="top-right"
expand={false}
closeButton
visibleToasts={3}
gap={8}
theme="dark"
toastOptions={{
className: 'sonner-toast-custom',
}}
/>
<App />
</HelmetProvider>
</StrictMode>,
)

View File

@@ -5,6 +5,7 @@ import { isAxiosError } from 'axios'
import { sessionsApi } from '@/api/sessions'
import { Spinner } from '@/components/common/Spinner'
import { BrandLogo } from '@/components/common/BrandLogo'
import { PageMeta } from '@/components/common/PageMeta'
import { SessionTimeline } from '@/components/session/SessionTimeline'
import { SharedSessionTreePreview } from '@/components/session/SharedSessionTreePreview'
import type { SharedSessionView } from '@/types'
@@ -162,6 +163,10 @@ export function SharedSessionPage() {
return (
<div className="min-h-screen bg-background">
<PageMeta
title={data ? `Shared Session - ${data.tree_name}` : 'Shared Session'}
description="View a shared troubleshooting session on ResolutionFlow"
/>
{/* Minimal header */}
<header className="border-b border-border px-6 py-4">
<div className="mx-auto flex max-w-7xl items-center justify-between">

View File

@@ -1,6 +1,7 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { BrandLogo } from '@/components/common/BrandLogo'
import { PageMeta } from '@/components/common/PageMeta'
// ── Survey Data Types ──
@@ -185,11 +186,28 @@ export default function SurveyPage() {
}, 0)
const progressPct = Math.round((answeredCount / TOTAL_QUESTIONS) * 100)
const topRef = useRef<HTMLDivElement>(null)
const goSlide = (idx: number) => {
setCurrentSlide(idx)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
// Scroll to top whenever the active slide changes — mobile browsers
// (especially iOS Safari) ignore window.scrollTo, so we use
// scrollIntoView on a ref at the top of the page as primary method
const isFirstRender = useRef(true)
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false
return
}
if (topRef.current) {
topRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' })
} else {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}, [currentSlide])
const handleSubmit = async () => {
setIsSubmitting(true)
setSubmitError('')
@@ -259,8 +277,8 @@ export default function SurveyPage() {
<div className="absolute -top-32 right-0 h-[500px] w-[500px] rounded-full opacity-[0.03]" style={{ background: 'radial-gradient(circle, #06b6d4, transparent 70%)' }} />
<div className="absolute -bottom-32 left-0 h-[400px] w-[400px] rounded-full opacity-[0.02]" style={{ background: 'radial-gradient(circle, #a855f7, transparent 70%)' }} />
</div>
<div className="relative z-10 mx-auto max-w-[680px] px-5">
<div className="text-center pt-32 animate-fade-in-up">
<div className="relative z-10 mx-auto max-w-[680px] px-4 sm:px-5">
<div className="text-center pt-20 sm:pt-32 animate-fade-in-up">
<div className="w-16 h-16 mx-auto mb-5 rounded-full flex items-center justify-center" style={{ background: 'rgba(6, 182, 212, 0.1)' }}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#06b6d4" strokeWidth="2.5"><path d="M20 6L9 17l-5-5"/></svg>
</div>
@@ -271,7 +289,7 @@ export default function SurveyPage() {
<p className="text-sm text-muted-foreground/70 max-w-[400px] mx-auto leading-relaxed mb-8">
You can safely close this browser window now.
</p>
<div className="glass-card-static p-5 max-w-[400px] mx-auto text-center">
<div className="glass-card-static p-4 sm:p-5 max-w-[400px] mx-auto text-center">
<p className="text-xs text-muted-foreground leading-relaxed">
Have feedback unrelated to the survey?{' '}
<a href="mailto:feedback@resolutionflow.com" className="text-primary hover:underline font-medium">
@@ -286,7 +304,11 @@ export default function SurveyPage() {
}
return (
<div className="min-h-screen bg-background text-foreground">
<div ref={topRef} className="min-h-screen bg-background text-foreground">
<PageMeta
title="Product Survey"
description="Help shape the future of ResolutionFlow by sharing your feedback"
/>
{/* Atmosphere orbs */}
<div className="pointer-events-none fixed inset-0 z-0 overflow-hidden">
<div
@@ -301,35 +323,35 @@ export default function SurveyPage() {
{/* Top bar */}
<div className="sticky top-0 z-50" style={{ backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)', background: 'rgba(16, 17, 20, 0.85)', borderBottom: '1px solid var(--glass-border)' }}>
<div className="mx-auto flex max-w-[680px] items-center justify-between gap-3 px-5 py-3.5">
<a href="https://resolutionflow.com" target="_blank" rel="noreferrer" className="flex items-center gap-2.5 text-sm font-heading font-bold text-muted-foreground no-underline">
<div className="mx-auto flex max-w-[680px] items-center justify-between gap-3 px-4 py-3 sm:px-5 sm:py-3.5">
<a href="https://resolutionflow.com" target="_blank" rel="noreferrer" className="flex items-center gap-2 sm:gap-2.5 text-sm font-heading font-bold text-muted-foreground no-underline shrink-0">
<BrandLogo size="sm" />
<span>Resolution<span className="text-gradient-brand">Flow</span></span>
<span className="hidden sm:inline">Resolution<span className="text-gradient-brand">Flow</span></span>
</a>
<div className="flex flex-1 items-center gap-2.5" style={{ maxWidth: '240px' }}>
<div className="flex flex-1 items-center gap-2 sm:gap-2.5" style={{ maxWidth: '280px' }}>
<div className="flex-1 h-[3px] rounded-full overflow-hidden" style={{ background: 'hsl(var(--border))' }}>
<div className="h-full rounded-full bg-gradient-brand transition-[width] duration-500" style={{ width: `${progressPct}%` }} />
</div>
<span className="text-[11px] font-label text-muted-foreground whitespace-nowrap tabular-nums">{answeredCount} of {TOTAL_QUESTIONS}</span>
<span className="text-[11px] font-label text-muted-foreground whitespace-nowrap tabular-nums">{answeredCount}/{TOTAL_QUESTIONS}</span>
</div>
</div>
</div>
<div className="relative z-10 mx-auto max-w-[680px] px-5 pb-24">
<div className="relative z-10 mx-auto max-w-[680px] px-4 pb-20 sm:px-5 sm:pb-24">
{/* Hero — visible only on first slide */}
{currentSlide === 0 && !isComplete && (
<div className="text-center pt-[72px] pb-10 animate-fade-in-up">
<div className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-full text-[11px] font-semibold font-label uppercase tracking-widest mb-5" style={{ background: 'rgba(6, 182, 212, 0.1)', border: '1px solid rgba(6, 182, 212, 0.15)', color: '#06b6d4' }}>
<div className="text-center pt-10 pb-8 sm:pt-[72px] sm:pb-10 animate-fade-in-up">
<div className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[10px] sm:text-[11px] font-semibold font-label uppercase tracking-widest mb-4 sm:mb-5" style={{ background: 'rgba(6, 182, 212, 0.1)', border: '1px solid rgba(6, 182, 212, 0.15)', color: '#06b6d4' }}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
FlowPilot Research
</div>
<h1 className="font-heading text-[clamp(26px,5vw,36px)] font-extrabold leading-tight mb-3">
<h1 className="font-heading text-[clamp(24px,5vw,36px)] font-extrabold leading-tight mb-3">
Help Build an AI That<br/>Thinks Like <span className="text-gradient-brand">You</span>
</h1>
<p className="text-[15px] text-muted-foreground max-w-[500px] mx-auto leading-relaxed">
<p className="text-[14px] sm:text-[15px] text-muted-foreground max-w-[500px] mx-auto leading-relaxed">
We're building an AI assistant for MSP engineers. Your expertise shapes how it thinks. Takes about 5 minutes.
</p>
<div className="flex justify-center gap-7 mt-5 text-[12px] text-muted-foreground">
<div className="flex flex-wrap justify-center gap-4 sm:gap-7 mt-4 sm:mt-5 text-[11px] sm:text-[12px] text-muted-foreground">
<span className="flex items-center gap-1.5">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#06b6d4" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
~5 minutes
@@ -348,11 +370,11 @@ export default function SurveyPage() {
{/* Step dots */}
{!isComplete && (
<div className="flex gap-1 mb-9">
<div className="flex gap-1 mb-6 sm:mb-9">
{SLIDES.map((_, i) => (
<div
key={i}
className="flex-1 h-[3px] rounded-full transition-colors duration-300"
className="flex-1 h-1 sm:h-[3px] rounded-full transition-colors duration-300"
style={{
background: i < currentSlide ? '#34d399' : i === currentSlide ? 'linear-gradient(90deg, #06b6d4, #22d3ee)' : 'hsl(var(--border))',
}}
@@ -369,20 +391,20 @@ export default function SurveyPage() {
{slide.questions.map(q => (
<QuestionCard key={q.id} question={q} answer={answers[q.id]} setAnswer={setAnswer} />
))}
<div className="flex justify-between mt-7 gap-3">
<div className="flex justify-between mt-6 sm:mt-7 gap-3">
{si > 0 ? (
<button onClick={() => goSlide(si - 1)} className="inline-flex items-center gap-2 px-6 py-3 rounded-[10px] text-sm font-semibold text-muted-foreground transition-all duration-150 hover:text-foreground" style={{ background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.06)' }}>
<button onClick={() => goSlide(si - 1)} className="inline-flex items-center gap-2 px-4 py-2.5 sm:px-6 sm:py-3 rounded-[10px] text-sm font-semibold text-muted-foreground transition-all duration-150 hover:text-foreground" style={{ background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.06)' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
Back
</button>
) : <div />}
{si < SLIDES.length - 1 ? (
<button onClick={() => goSlide(si + 1)} className="inline-flex items-center gap-2 px-6 py-3 rounded-[10px] text-sm font-semibold bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20 transition-all duration-150 hover:opacity-90 active:scale-[0.97]">
<button onClick={() => goSlide(si + 1)} className="inline-flex items-center gap-2 px-5 py-2.5 sm:px-6 sm:py-3 rounded-[10px] text-sm font-semibold bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20 transition-all duration-150 hover:opacity-90 active:scale-[0.97]">
Next
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
</button>
) : (
<button onClick={handleSubmit} disabled={isSubmitting} className="inline-flex items-center gap-2 px-6 py-3 rounded-[10px] text-sm font-semibold bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20 transition-all duration-150 hover:opacity-90 active:scale-[0.97] disabled:opacity-40 disabled:cursor-not-allowed">
<button onClick={handleSubmit} disabled={isSubmitting} className="inline-flex items-center gap-2 px-5 py-2.5 sm:px-6 sm:py-3 rounded-[10px] text-sm font-semibold bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20 transition-all duration-150 hover:opacity-90 active:scale-[0.97] disabled:opacity-40 disabled:cursor-not-allowed">
{isSubmitting ? 'Submitting...' : 'Submit'}
{!isSubmitting && <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 6L9 17l-5-5"/></svg>}
</button>
@@ -400,22 +422,22 @@ export default function SurveyPage() {
{/* Completion */}
{isComplete && (
<div className="text-center pt-16 animate-fade-in-up">
<div className="text-center pt-10 sm:pt-16 animate-fade-in-up">
<div className="w-16 h-16 mx-auto mb-5 rounded-full flex items-center justify-center" style={{ background: 'rgba(52, 211, 153, 0.1)' }}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#34d399" strokeWidth="2.5"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<h2 className="font-heading text-2xl font-bold mb-2.5">Response Submitted!</h2>
<p className="text-muted-foreground text-sm max-w-[440px] mx-auto mb-8 leading-relaxed">
<p className="text-muted-foreground text-sm max-w-[440px] mx-auto mb-6 sm:mb-8 leading-relaxed">
Your answers will directly shape how FlowPilot troubleshoots. Would you like a copy of your responses?
</p>
{/* Email a copy */}
<div className="glass-card-static p-6 max-w-[420px] mx-auto mb-5">
<div className="glass-card-static p-4 sm:p-6 max-w-[420px] mx-auto mb-5">
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-3">
Email a copy to yourself
</p>
{!emailSent ? (
<div className="flex gap-2">
<div className="flex flex-col sm:flex-row gap-2">
<input
type="email"
value={emailInput}
@@ -451,7 +473,7 @@ export default function SurveyPage() {
}
}}
disabled={!emailInput.trim() || emailSending}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-[9px] text-sm font-semibold bg-gradient-brand text-[#101114] transition-all duration-150 hover:opacity-90 active:scale-[0.97] disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap"
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-[9px] text-sm font-semibold bg-gradient-brand text-[#101114] transition-all duration-150 hover:opacity-90 active:scale-[0.97] disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap"
>
{emailSending ? (
<>
@@ -474,13 +496,13 @@ export default function SurveyPage() {
{/* Copy + Finish buttons */}
<div className="flex gap-2.5 justify-center flex-wrap">
<button onClick={copyAll} className="inline-flex items-center gap-2 px-5 py-2.5 rounded-[10px] text-sm font-semibold transition-all duration-150 text-muted-foreground hover:text-foreground" style={{ background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.06)' }}>
<button onClick={copyAll} className="inline-flex items-center gap-2 px-4 py-2.5 sm:px-5 rounded-[10px] text-sm font-semibold transition-all duration-150 text-muted-foreground hover:text-foreground" style={{ background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.06)' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Copy to Clipboard
</button>
<button
onClick={() => navigate('/survey/thank-you')}
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-[10px] text-sm font-semibold bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20 transition-all duration-150 hover:opacity-90 active:scale-[0.97]"
className="inline-flex items-center gap-2 px-5 py-2.5 sm:px-6 rounded-[10px] text-sm font-semibold bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20 transition-all duration-150 hover:opacity-90 active:scale-[0.97]"
>
Finish
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
@@ -497,13 +519,13 @@ export default function SurveyPage() {
function ScenarioBox({ scenario }: { scenario: { title: string; symptom: string; details: string } }) {
return (
<div className="rounded-[10px] p-4 px-5 mb-4 text-[13px]" style={{ background: 'linear-gradient(135deg, rgba(6, 182, 212, 0.06), rgba(139, 92, 246, 0.03))', border: '1px solid rgba(6, 182, 212, 0.12)' }}>
<div className="rounded-[10px] p-3.5 px-4 sm:p-4 sm:px-5 mb-4 text-[13px]" style={{ background: 'linear-gradient(135deg, rgba(6, 182, 212, 0.06), rgba(139, 92, 246, 0.03))', border: '1px solid rgba(6, 182, 212, 0.12)' }}>
<div className="font-label text-[10px] uppercase tracking-widest mb-2 font-semibold" style={{ color: '#06b6d4' }}>{scenario.title}</div>
<div className="flex gap-2 mb-1">
<div className="sm:flex gap-2 mb-1">
<span className="text-muted-foreground font-medium whitespace-nowrap">Symptom:</span>
<span className="text-muted-foreground/80">{scenario.symptom}</span>
</div>
<div className="flex gap-2">
<div className="sm:flex gap-2">
<span className="text-muted-foreground font-medium whitespace-nowrap">Details:</span>
<span className="text-muted-foreground/80">{scenario.details}</span>
</div>
@@ -513,11 +535,11 @@ function ScenarioBox({ scenario }: { scenario: { title: string; symptom: string;
function QuestionCard({ question: q, answer, setAnswer }: { question: SurveyQuestion; answer?: string | string[]; setAnswer: (id: string, val: string | string[]) => void }) {
return (
<div className="glass-card-static p-7 mb-4 transition-[border-color] duration-200 focus-within:!border-[rgba(6,182,212,0.25)]">
<div className="glass-card-static p-4 sm:p-7 mb-3 sm:mb-4 transition-[border-color] duration-200 focus-within:!border-[rgba(6,182,212,0.25)]">
<div className="font-label text-[11px] mb-1.5 font-medium" style={{ color: '#06b6d4' }}>Q{q.num}</div>
<div className="font-heading text-[15px] font-semibold text-foreground leading-snug mb-1">{q.text}</div>
{q.hint && <div className="text-[12px] text-muted-foreground mb-4 leading-snug">{q.hint}</div>}
{!q.hint && <div className="mb-4" />}
<div className="font-heading text-[14px] sm:text-[15px] font-semibold text-foreground leading-snug mb-1">{q.text}</div>
{q.hint && <div className="text-[12px] text-muted-foreground mb-3 sm:mb-4 leading-snug">{q.hint}</div>}
{!q.hint && <div className="mb-3 sm:mb-4" />}
{q.type === 'mc' && q.options && (
<div className="flex flex-col gap-2">
@@ -525,17 +547,17 @@ function QuestionCard({ question: q, answer, setAnswer }: { question: SurveyQues
<button
key={opt}
onClick={() => setAnswer(q.id, opt)}
className="flex items-center gap-3 px-4 py-3 rounded-[9px] text-left text-sm transition-all duration-150 select-none"
className="flex items-start gap-3 px-3.5 py-3 sm:px-4 rounded-[9px] text-left text-[13px] sm:text-sm transition-all duration-150 select-none"
style={{
background: answer === opt ? 'rgba(6, 182, 212, 0.1)' : 'rgba(16, 17, 20, 0.6)',
border: `1px solid ${answer === opt ? '#06b6d4' : 'var(--glass-border)'}`,
color: answer === opt ? 'hsl(var(--foreground))' : 'hsl(var(--muted-foreground))',
}}
>
<div className="w-[18px] h-[18px] rounded-full flex-shrink-0 flex items-center justify-center transition-all duration-150" style={{ border: `2px solid ${answer === opt ? '#06b6d4' : 'var(--glass-border)'}` }}>
<div className="w-[18px] h-[18px] rounded-full flex-shrink-0 flex items-center justify-center transition-all duration-150 mt-0.5" style={{ border: `2px solid ${answer === opt ? '#06b6d4' : 'var(--glass-border)'}` }}>
{answer === opt && <div className="w-2 h-2 rounded-full" style={{ background: '#06b6d4' }} />}
</div>
<span>{opt}</span>
<span className="leading-snug">{opt}</span>
</button>
))}
</div>
@@ -552,17 +574,17 @@ function QuestionCard({ question: q, answer, setAnswer }: { question: SurveyQues
const current = Array.isArray(answer) ? answer : []
setAnswer(q.id, selected ? current.filter(v => v !== opt) : [...current, opt])
}}
className="flex items-center gap-3 px-4 py-3 rounded-[9px] text-left text-sm transition-all duration-150 select-none"
className="flex items-start gap-3 px-3.5 py-3 sm:px-4 rounded-[9px] text-left text-[13px] sm:text-sm transition-all duration-150 select-none"
style={{
background: selected ? 'rgba(6, 182, 212, 0.1)' : 'rgba(16, 17, 20, 0.6)',
border: `1px solid ${selected ? '#06b6d4' : 'var(--glass-border)'}`,
color: selected ? 'hsl(var(--foreground))' : 'hsl(var(--muted-foreground))',
}}
>
<div className="w-[18px] h-[18px] rounded-[5px] flex-shrink-0 flex items-center justify-center text-[11px] transition-all duration-150" style={{ border: `2px solid ${selected ? '#06b6d4' : 'var(--glass-border)'}`, background: selected ? '#06b6d4' : 'transparent', color: selected ? 'white' : 'transparent' }}>
<div className="w-[18px] h-[18px] rounded-[5px] flex-shrink-0 flex items-center justify-center text-[11px] transition-all duration-150 mt-0.5" style={{ border: `2px solid ${selected ? '#06b6d4' : 'var(--glass-border)'}`, background: selected ? '#06b6d4' : 'transparent', color: selected ? 'white' : 'transparent' }}>
{'\u2713'}
</div>
<span>{opt}</span>
<span className="leading-snug">{opt}</span>
</button>
)
})}
@@ -578,7 +600,7 @@ function QuestionCard({ question: q, answer, setAnswer }: { question: SurveyQues
value={(answer as string) || ''}
onChange={e => setAnswer(q.id, e.target.value)}
placeholder="Type your answer here..."
className="w-full min-h-[100px] rounded-[9px] p-3.5 text-sm text-foreground leading-relaxed resize-y transition-all duration-200 placeholder:text-[#5a6170] focus:outline-none"
className="w-full min-h-[100px] rounded-[9px] p-3 sm:p-3.5 text-[13px] sm:text-sm text-foreground leading-relaxed resize-y transition-all duration-200 placeholder:text-[#5a6170] focus:outline-none"
style={{
background: 'rgba(16, 17, 20, 0.6)',
border: '1px solid var(--glass-border)',
@@ -609,7 +631,7 @@ function RangeInput({ question: q, value, onChange }: { question: SurveyQuestion
step={q.step}
value={numVal}
onChange={e => onChange(e.target.value + (q.suffix || ''))}
className="w-full h-1 rounded-full appearance-none cursor-pointer"
className="w-full h-2 sm:h-1 rounded-full appearance-none cursor-pointer touch-none"
style={{
background: `linear-gradient(to right, #06b6d4 0%, #06b6d4 ${((numVal - (q.min || 0)) / ((q.max || 10) - (q.min || 0))) * 100}%, hsl(var(--border)) ${((numVal - (q.min || 0)) / ((q.max || 10) - (q.min || 0))) * 100}%, hsl(var(--border)) 100%)`,
}}
@@ -705,7 +727,7 @@ function DragRank({ items, onChange }: { items: string[]; onChange: (items: stri
onDragEnd={handleDragEnd}
onDragLeave={() => setOverIdx(null)}
onTouchStart={() => handleTouchStart(idx)}
className="flex items-center gap-3 px-4 py-2.5 rounded-[9px] text-sm transition-all duration-150 select-none"
className="flex items-center gap-2.5 sm:gap-3 px-3 py-3 sm:px-4 sm:py-2.5 rounded-[9px] text-[13px] sm:text-sm transition-all duration-150 select-none"
style={{
background: overIdx === idx ? 'rgba(6, 182, 212, 0.1)' : 'rgba(16, 17, 20, 0.6)',
border: `1px solid ${overIdx === idx || draggingIdx === idx ? '#06b6d4' : 'var(--glass-border)'}`,
@@ -718,7 +740,7 @@ function DragRank({ items, onChange }: { items: string[]; onChange: (items: stri
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="9" cy="6" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="18" r="1"/></svg>
</div>
<div className="font-label text-[11px] font-semibold w-5 text-center flex-shrink-0" style={{ color: '#06b6d4' }}>{idx + 1}</div>
<div className="flex-1">{item}</div>
<div className="flex-1 leading-snug">{item}</div>
</div>
))}
</div>

View File

@@ -2,6 +2,7 @@ import { createBrowserRouter } from 'react-router-dom'
import { lazy, Suspense } from 'react'
import { AppLayout, ProtectedRoute } from '@/components/layout'
import { RouteError } from '@/components/common/RouteError'
import { ErrorBoundary } from '@/components/common/ErrorBoundary'
import { PageLoader } from '@/components/common/PageLoader'
import {
LoginPage,
@@ -61,6 +62,17 @@ const TeamCategoriesPage = lazy(() => import('@/pages/account/TeamCategoriesPage
const TargetListsPage = lazy(() => import('@/pages/account/TargetListsPage'))
const ChatRetentionSettingsPage = lazy(() => import('@/pages/account/ChatRetentionSettingsPage'))
/** Wraps a lazy-loaded page with Suspense + ErrorBoundary */
function page(Component: React.LazyExoticComponent<React.ComponentType>) {
return (
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<Component />
</Suspense>
</ErrorBoundary>
)
}
export const router = createBrowserRouter([
{
path: '/login',
@@ -74,65 +86,39 @@ export const router = createBrowserRouter([
},
{
path: '/forgot-password',
element: (
<Suspense fallback={<PageLoader />}>
<ForgotPasswordPage />
</Suspense>
),
element: page(ForgotPasswordPage),
errorElement: <RouteError />,
},
{
path: '/reset-password',
element: (
<Suspense fallback={<PageLoader />}>
<ResetPasswordPage />
</Suspense>
),
element: page(ResetPasswordPage),
errorElement: <RouteError />,
},
{
path: '/verify-email',
element: (
<Suspense fallback={<PageLoader />}>
<VerifyEmailPage />
</Suspense>
),
element: page(VerifyEmailPage),
errorElement: <RouteError />,
},
{
path: '/survey',
element: (
<Suspense fallback={<PageLoader />}>
<SurveyPage />
</Suspense>
),
element: page(SurveyPage),
errorElement: <RouteError />,
},
{
path: '/survey/thank-you',
element: (
<Suspense fallback={<PageLoader />}>
<SurveyThankYouPage />
</Suspense>
),
element: page(SurveyThankYouPage),
errorElement: <RouteError />,
},
{
path: '/share/:shareToken',
element: (
<Suspense fallback={<PageLoader />}>
<SharedSessionPage />
</Suspense>
),
element: page(SharedSessionPage),
errorElement: <RouteError />,
},
{
path: '/change-password',
element: (
<ProtectedRoute>
<Suspense fallback={<PageLoader />}>
<ChangePasswordPage />
</Suspense>
{page(ChangePasswordPage)}
</ProtectedRoute>
),
errorElement: <RouteError />,
@@ -146,328 +132,83 @@ export const router = createBrowserRouter([
),
errorElement: <RouteError />,
children: [
{
index: true,
element: (
<Suspense fallback={<PageLoader />}>
<QuickStartPage />
</Suspense>
),
},
{
path: 'trees',
element: (
<Suspense fallback={<PageLoader />}>
<TreeLibraryPage />
</Suspense>
),
},
{
path: 'my-trees',
element: (
<Suspense fallback={<PageLoader />}>
<MyTreesPage />
</Suspense>
),
},
{
path: 'trees/new',
element: (
<Suspense fallback={<PageLoader />}>
<TreeEditorPage />
</Suspense>
),
},
{
path: 'trees/:id/edit',
element: (
<Suspense fallback={<PageLoader />}>
<TreeEditorPage />
</Suspense>
),
},
{
path: 'flows/new',
element: (
<Suspense fallback={<PageLoader />}>
<ProceduralEditorPage />
</Suspense>
),
},
{
path: 'flows/:id/edit',
element: (
<Suspense fallback={<PageLoader />}>
<ProceduralEditorPage />
</Suspense>
),
},
{
path: 'flows/:id/navigate',
element: (
<Suspense fallback={<PageLoader />}>
<ProceduralNavigationPage />
</Suspense>
),
},
{
path: 'flows/:id/maintenance',
element: (
<Suspense fallback={<PageLoader />}>
<MaintenanceFlowDetailPage />
</Suspense>
),
},
{
path: 'flows/:id/batches/:batchId',
element: (
<Suspense fallback={<PageLoader />}>
<BatchStatusPage />
</Suspense>
),
},
{
path: 'trees/:id/navigate',
element: (
<Suspense fallback={<PageLoader />}>
<TreeNavigationPage />
</Suspense>
),
},
{
path: 'sessions',
element: (
<Suspense fallback={<PageLoader />}>
<SessionHistoryPage />
</Suspense>
),
},
{
path: 'sessions/:id',
element: (
<Suspense fallback={<PageLoader />}>
<SessionDetailPage />
</Suspense>
),
},
{
path: 'shares',
element: (
<Suspense fallback={<PageLoader />}>
<MySharesPage />
</Suspense>
),
},
{
path: 'analytics',
element: (
<Suspense fallback={<PageLoader />}>
<TeamAnalyticsPage />
</Suspense>
),
},
{
path: 'analytics/me',
element: (
<Suspense fallback={<PageLoader />}>
<MyAnalyticsPage />
</Suspense>
),
},
{
path: 'feedback',
element: (
<Suspense fallback={<PageLoader />}>
<FeedbackPage />
</Suspense>
),
},
{
path: 'step-library',
element: (
<Suspense fallback={<PageLoader />}>
<StepLibraryPage />
</Suspense>
),
},
{
path: 'assistant',
element: (
<Suspense fallback={<PageLoader />}>
<AssistantChatPage />
</Suspense>
),
},
{
path: 'guides',
element: (
<Suspense fallback={<PageLoader />}>
<GuidesHubPage />
</Suspense>
),
},
{
path: 'guides/:slug',
element: (
<Suspense fallback={<PageLoader />}>
<GuideDetailPage />
</Suspense>
),
},
{ index: true, element: page(QuickStartPage) },
{ path: 'trees', element: page(TreeLibraryPage) },
{ path: 'my-trees', element: page(MyTreesPage) },
{ path: 'trees/new', element: page(TreeEditorPage) },
{ path: 'trees/:id/edit', element: page(TreeEditorPage) },
{ path: 'flows/new', element: page(ProceduralEditorPage) },
{ path: 'flows/:id/edit', element: page(ProceduralEditorPage) },
{ path: 'flows/:id/navigate', element: page(ProceduralNavigationPage) },
{ path: 'flows/:id/maintenance', element: page(MaintenanceFlowDetailPage) },
{ path: 'flows/:id/batches/:batchId', element: page(BatchStatusPage) },
{ path: 'trees/:id/navigate', element: page(TreeNavigationPage) },
{ path: 'sessions', element: page(SessionHistoryPage) },
{ path: 'sessions/:id', element: page(SessionDetailPage) },
{ path: 'shares', element: page(MySharesPage) },
{ path: 'analytics', element: page(TeamAnalyticsPage) },
{ path: 'analytics/me', element: page(MyAnalyticsPage) },
{ path: 'feedback', element: page(FeedbackPage) },
{ path: 'step-library', element: page(StepLibraryPage) },
{ path: 'assistant', element: page(AssistantChatPage) },
{ path: 'guides', element: page(GuidesHubPage) },
{ path: 'guides/:slug', element: page(GuideDetailPage) },
// Admin routes
{
path: 'admin',
element: (
<Suspense fallback={<PageLoader />}>
<ProtectedRoute requiredRole="super_admin">
<AdminLayout />
</ProtectedRoute>
</Suspense>
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<ProtectedRoute requiredRole="super_admin">
<AdminLayout />
</ProtectedRoute>
</Suspense>
</ErrorBoundary>
),
children: [
{
index: true,
element: (
<Suspense fallback={<PageLoader />}>
<AdminDashboardPage />
</Suspense>
),
},
{
path: 'users',
element: (
<Suspense fallback={<PageLoader />}>
<AdminUsersPage />
</Suspense>
),
},
{
path: 'users/:userId',
element: (
<Suspense fallback={<PageLoader />}>
<AdminUserDetailPage />
</Suspense>
),
},
{
path: 'invite-codes',
element: (
<Suspense fallback={<PageLoader />}>
<AdminInviteCodesPage />
</Suspense>
),
},
{
path: 'audit-logs',
element: (
<Suspense fallback={<PageLoader />}>
<AdminAuditLogsPage />
</Suspense>
),
},
{
path: 'plan-limits',
element: (
<Suspense fallback={<PageLoader />}>
<AdminPlanLimitsPage />
</Suspense>
),
},
{
path: 'feature-flags',
element: (
<Suspense fallback={<PageLoader />}>
<AdminFeatureFlagsPage />
</Suspense>
),
},
{
path: 'settings',
element: (
<Suspense fallback={<PageLoader />}>
<AdminSettingsPage />
</Suspense>
),
},
{
path: 'categories',
element: (
<Suspense fallback={<PageLoader />}>
<AdminGlobalCategoriesPage />
</Suspense>
),
},
{
path: 'survey-invites',
element: (
<Suspense fallback={<PageLoader />}>
<AdminSurveyInvitesPage />
</Suspense>
),
},
{
path: 'survey-responses',
element: (
<Suspense fallback={<PageLoader />}>
<AdminSurveyResponsesPage />
</Suspense>
),
},
{ index: true, element: page(AdminDashboardPage) },
{ path: 'users', element: page(AdminUsersPage) },
{ path: 'users/:userId', element: page(AdminUserDetailPage) },
{ path: 'invite-codes', element: page(AdminInviteCodesPage) },
{ path: 'audit-logs', element: page(AdminAuditLogsPage) },
{ path: 'plan-limits', element: page(AdminPlanLimitsPage) },
{ path: 'feature-flags', element: page(AdminFeatureFlagsPage) },
{ path: 'settings', element: page(AdminSettingsPage) },
{ path: 'categories', element: page(AdminGlobalCategoriesPage) },
{ path: 'survey-invites', element: page(AdminSurveyInvitesPage) },
{ path: 'survey-responses', element: page(AdminSurveyResponsesPage) },
],
},
// Account routes
{
path: 'account',
element: (
<Suspense fallback={<PageLoader />}>
<AccountLayout />
</Suspense>
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<AccountLayout />
</Suspense>
</ErrorBoundary>
),
children: [
{
index: true,
element: (
<Suspense fallback={<PageLoader />}>
<AccountSettingsPage />
</Suspense>
),
},
{
path: 'profile',
element: (
<Suspense fallback={<PageLoader />}>
<ProfileSettingsPage />
</Suspense>
),
},
{ index: true, element: page(AccountSettingsPage) },
{ path: 'profile', element: page(ProfileSettingsPage) },
{
path: 'categories',
element: (
<Suspense fallback={<PageLoader />}>
<ProtectedRoute requiredRole="owner">
<TeamCategoriesPage />
</ProtectedRoute>
</Suspense>
<ProtectedRoute requiredRole="owner">
{page(TeamCategoriesPage)}
</ProtectedRoute>
),
},
{
path: 'chat-retention',
element: (
<Suspense fallback={<PageLoader />}>
<ProtectedRoute requiredRole="owner">
<ChatRetentionSettingsPage />
</ProtectedRoute>
</Suspense>
),
},
{
path: 'target-lists',
element: (
<Suspense fallback={<PageLoader />}>
<TargetListsPage />
</Suspense>
<ProtectedRoute requiredRole="owner">
{page(ChatRetentionSettingsPage)}
</ProtectedRoute>
),
},
{ path: 'target-lists', element: page(TargetListsPage) },
],
},
],