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:
63
frontend/package-lock.json
generated
63
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
44
frontend/src/components/common/PageMeta.tsx
Normal file
44
frontend/src/components/common/PageMeta.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
63
frontend/src/components/ui/Button.tsx
Normal file
63
frontend/src/components/ui/Button.tsx
Normal 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 }
|
||||
24
frontend/src/components/ui/FormField.tsx
Normal file
24
frontend/src/components/ui/FormField.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
frontend/src/components/ui/Input.tsx
Normal file
35
frontend/src/components/ui/Input.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
frontend/src/components/ui/Skeleton.tsx
Normal file
58
frontend/src/components/ui/Skeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
frontend/src/components/ui/Textarea.tsx
Normal file
35
frontend/src/components/ui/Textarea.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
27
frontend/src/lib/debounce.ts
Normal file
27
frontend/src/lib/debounce.ts
Normal 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 }
|
||||
}
|
||||
12
frontend/src/lib/prefetch.ts
Normal file
12
frontend/src/lib/prefetch.ts
Normal 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()
|
||||
}
|
||||
27
frontend/src/lib/routePrefetch.ts
Normal file
27
frontend/src/lib/routePrefetch.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user