diff --git a/frontend/src/components/session/SharedSessionTreePreview.tsx b/frontend/src/components/session/SharedSessionTreePreview.tsx new file mode 100644 index 00000000..0f6a3123 --- /dev/null +++ b/frontend/src/components/session/SharedSessionTreePreview.tsx @@ -0,0 +1,88 @@ +import { cn } from '@/lib/utils' + +interface SharedSessionTreePreviewProps { + treeStructure: Record + pathTaken: string[] +} + +const nodeTypeColors: Record = { + root: 'bg-white', + decision: 'bg-blue-400', + action: 'bg-yellow-400', + solution: 'bg-emerald-400', + information: 'bg-white/50', +} + +function getNodeTitle(node: Record): string { + return ( + (node.question as string) || + (node.title as string) || + (node.node_type as string) || + 'Untitled' + ) +} + +function TreeNode({ + node, + depth, + pathTaken, +}: { + node: Record + depth: number + pathTaken: string[] +}) { + const nodeId = (node.id as string) || '' + const nodeType = (node.node_type as string) || 'decision' + const isInPath = pathTaken.includes(nodeId) + const children = (node.children as Record[]) || [] + const colorClass = nodeTypeColors[nodeType] || 'bg-white/50' + + return ( + <> +
+ + {getNodeTitle(node)} +
+ {children.map((child, index) => ( + + ))} + + ) +} + +export function SharedSessionTreePreview({ + treeStructure, + pathTaken, +}: SharedSessionTreePreviewProps) { + if (!treeStructure) { + return null + } + + return ( +
+
+

Tree Structure

+
+
+ +
+
+ ) +} + +export default SharedSessionTreePreview diff --git a/frontend/src/pages/SharedSessionPage.tsx b/frontend/src/pages/SharedSessionPage.tsx new file mode 100644 index 00000000..252d94e2 --- /dev/null +++ b/frontend/src/pages/SharedSessionPage.tsx @@ -0,0 +1,263 @@ +import { useState, useEffect } from 'react' +import { useParams, useNavigate, Link } from 'react-router-dom' +import { Globe, Users, ShieldAlert, FileX, Clock, Loader2 } from 'lucide-react' +import { isAxiosError } from 'axios' +import { sessionsApi } from '@/api/sessions' +import { BrandLogo } from '@/components/common/BrandLogo' +import { SessionTimeline } from '@/components/session/SessionTimeline' +import { SharedSessionTreePreview } from '@/components/session/SharedSessionTreePreview' +import type { SharedSessionView } from '@/types' + +function formatDate(dateString: string) { + return new Date(dateString).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }) +} + +function formatDuration(startedAt: string, completedAt: string): string { + const start = new Date(startedAt).getTime() + const end = new Date(completedAt).getTime() + const totalSeconds = Math.floor((end - start) / 1000) + if (totalSeconds < 0) return '0s' + if (totalSeconds < 60) return `${totalSeconds}s` + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + if (hours > 0) { + return seconds > 0 ? `${hours}h ${minutes}m ${seconds}s` : `${hours}h ${minutes}m` + } + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m` +} + +type ErrorState = { + type: 'access_denied' | 'not_found' | 'expired' | 'generic' + message: string +} + +function ErrorCard({ error }: { error: ErrorState }) { + const iconMap = { + access_denied: ShieldAlert, + not_found: FileX, + expired: Clock, + generic: FileX, + } + const titleMap = { + access_denied: 'Access Denied', + not_found: 'Not Found', + expired: 'Link Expired', + generic: 'Error', + } + + const Icon = iconMap[error.type] + + return ( +
+
+
+ +
+

{titleMap[error.type]}

+

{error.message}

+ + Go to ResolutionFlow + +
+
+ ) +} + +export function SharedSessionPage() { + const { shareToken } = useParams<{ shareToken: string }>() + const navigate = useNavigate() + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + if (!shareToken) return + + let cancelled = false + + async function fetchSharedSession() { + try { + const result = await sessionsApi.getSharedSession(shareToken!) + if (!cancelled) { + setData(result) + setLoading(false) + } + } catch (err) { + if (cancelled) return + + if (isAxiosError(err)) { + const status = err.response?.status + if (status === 401) { + navigate('/login', { + state: { from: `/share/${shareToken}` }, + replace: true, + }) + return + } + if (status === 403) { + setError({ + type: 'access_denied', + message: + 'This session is private to the account. You need to be a member of the account to view it.', + }) + } else if (status === 404) { + setError({ + type: 'not_found', + message: 'This share link was not found or has been revoked.', + }) + } else if (status === 410) { + setError({ + type: 'expired', + message: 'This share link has expired.', + }) + } else { + setError({ + type: 'generic', + message: 'Failed to load shared session. Please try again.', + }) + } + } else { + setError({ + type: 'generic', + message: 'Failed to load shared session. Please try again.', + }) + } + setLoading(false) + } + } + + fetchSharedSession() + return () => { + cancelled = true + } + }, [shareToken, navigate]) + + if (loading) { + return ( +
+
+ +

Loading shared session...

+
+
+ ) + } + + if (error) { + return + } + + if (!data) { + return null + } + + return ( +
+ {/* Minimal header */} +
+
+ + + ResolutionFlow + + + Sign In + +
+
+ + {/* Content */} +
+ {/* Metadata section */} +
+ {data.share_name && ( +

{data.share_name}

+ )} +

+ Tree: {data.tree_name} +

+ + {(data.ticket_number || data.client_name) && ( +

+ {data.ticket_number && ( + Ticket: #{data.ticket_number} + )} + {data.ticket_number && data.client_name && ( + · + )} + {data.client_name && ( + Client: {data.client_name} + )} +

+ )} + +
+ Started: {formatDate(data.started_at)} + {data.completed_at && ( + <> + Completed: {formatDate(data.completed_at)} + Duration: {formatDuration(data.started_at, data.completed_at)} + + )} + + {data.visibility === 'public' ? ( + <> + + Public + + ) : ( + <> + + Account + + )} + +
+
+ + {/* Two-column layout */} +
+ {/* Decision Timeline (2 cols) */} +
+ +
+ + {/* Tree Preview (1 col) */} +
+ +
+
+
+ + {/* Footer */} +
+ Powered by{' '} + + ResolutionFlow + +
+
+ ) +} + +export default SharedSessionPage diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 607bbec6..f8bbce1a 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -8,6 +8,9 @@ import { RegisterPage, } from '@/pages' +// Public pages +const SharedSessionPage = lazy(() => import('@/pages/SharedSessionPage')) + // Standalone auth pages const ChangePasswordPage = lazy(() => import('@/pages/ChangePasswordPage')) const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage')) @@ -69,6 +72,15 @@ export const router = createBrowserRouter([ ), errorElement: , }, + { + path: '/share/:shareToken', + element: ( + }> + + + ), + errorElement: , + }, { path: '/change-password', element: (