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

Merged
chihlasm merged 51 commits from feat/flow-transfer-and-procedural-assist into main 2026-03-07 23:44:14 +00:00
21 changed files with 613 additions and 412 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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