feat: Flow Transfer, Procedural Assist & UI Design System #97
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
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 ──
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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