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>
This commit is contained in:
Michael Chihlas
2026-03-07 16:18:57 -05:00
parent 44e3bfe474
commit 5108d0bc38
19 changed files with 542 additions and 363 deletions

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 ──
@@ -287,6 +288,10 @@ export default function SurveyPage() {
return (
<div 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

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) },
],
},
],