Files
resolutionflow/frontend/src/pages/SharedSessionPage.tsx
chihlasm aef40078d0 fix: UX deep dive — 28 fixes across authoring, navigation, consistency, and cleanup (#86)
* fix: tree editor authoring blockers - scroll trap, form density, branching hint

- Replace fixed viewport height with flex layout in NodeEditorPanel
- Make footer sticky so Save/Cancel always reachable
- Compact root node banner to single-line with InfoTip tooltip
- Reduce resolution note from callout box to inline text
- Add answer-first branching hint below options label

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: broken functionality - auth errors, toast logic, role update, routing, step library

- Extract backend error detail in auth store login/register
- Fix inverted 4xx toast logic and add 429 rate limit handling
- Send account_role field to match backend schema in role update
- Use type-aware routing for Repeat Last Session button
- Add step library placeholder page and route, remove dot badge

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: navigation correctness - back buttons, exit dialog, dedup nav, redirects

- Standardize all procedural back/exit paths to /trees (not /my-trees)
- Add exit button with ConfirmDialog to procedural session top bar
- Consolidate duplicate account links in sidebar and topbar
- Auto-redirect non-owners to personal analytics
- Add toast feedback before silent permission redirects in tree editor
- Delete orphaned AdminCategoriesPage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: shared components, ConfirmDialog migration, pinned flow fixes

- Create shared Spinner component with sm/md/lg sizes
- Migrate 13 page-level spinners to shared Spinner
- Promote EmptyState to shared component, adopt in MyShares and SessionHistory
- Replace window.confirm with ConfirmDialog in 3 files
- Fix PinnedFlow.tree_type to include maintenance, update emoji display
- Verify sidebar unpin handler already correct (no-op)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: visual consistency - toasts, typography, focus rings, container padding

- Remove richColors from Sonner toasts, limit stacking to 3
- Add font-heading to all page H1s (7 files)
- Add font-label (Outfit) to TagBadges component
- Fix focus ring tokens on analytics pages
- Replace deprecated glass-stat with design system tokens
- Standardize container padding on analytics pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: backend alignment - remove drafts toggle, clean dead code, truncation indicator

- Remove non-functional drafts toggle and clean TreeFilters type
- Fix AccountInvite type to match backend schema
- Remove dead API methods: pinnedFlows.pin/reorder, trees.getSharedTree
- Remove unused types: SessionListResponse, RatingCreate.is_verified_use
- Add session list truncation indicator with size=51 lookahead

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove bg-black from PageLoader and RouteError, fix PageLoader height

PageLoader used h-screen inside a grid cell, causing it to overflow.
Changed to h-full so it fits within the main-content area. Removed
bg-black from both PageLoader and RouteError in favor of theme-aware
bg-background to prevent black flash during lazy loading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: guard against Pydantic validation error objects in toast/error messages

FastAPI returns `detail` as an array of objects for 422 validation errors,
not a string. Passing these objects to toast.error() or rendering them in
JSX crashes React with Error #31 ("Objects are not valid as a React child").
Now checks typeof detail === 'string' before using it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: toast styling, node editor first-click, action node placeholder pattern

1. Toast fixes: Add theme="dark" to Sonner, use !important CSS overrides
   instead of zero-specificity :where() selectors, suppress noisy 4xx
   global toasts (pages handle their own errors)

2. Node editor first-click: Add node.type to draft initialization
   useEffect deps so draft resets when answer stub converts to real type

3. Action node redesign: Remove NodePicker dropdown, auto-create answer
   placeholder on save (matching decision node pattern). Users click the
   placeholder on canvas to choose type and fill in details.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: auto-seed test users when release command fails on PR envs

The background seeder now creates users directly via DB if login fails,
instead of silently aborting. This handles Railway PR environments where
the releaseCommand may not execute properly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove categories/tags from sidebar to prevent footer clipping

Categories and Tags sections were pushing Feedback, Account, and
Collapse off-screen when All Flows expanded its children. These
filters already exist on the TreeLibraryPage, so the sidebar
duplicates were removed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 22:10:47 -05:00

265 lines
8.5 KiB
TypeScript

import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { Globe, Users, ShieldAlert, FileX, Clock } from 'lucide-react'
import { isAxiosError } from 'axios'
import { sessionsApi } from '@/api/sessions'
import { Spinner } from '@/components/common/Spinner'
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 (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="bg-card border border-border w-full max-w-md rounded-xl p-8 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-accent">
<Icon className="h-8 w-8 text-muted-foreground" />
</div>
<h1 className="mb-2 text-xl font-heading font-semibold text-foreground">{titleMap[error.type]}</h1>
<p className="mb-6 text-sm text-muted-foreground">{error.message}</p>
<Link
to="/"
className="inline-block rounded-lg bg-gradient-brand px-6 py-2.5 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
>
Go to ResolutionFlow
</Link>
</div>
</div>
)
}
export function SharedSessionPage() {
const { shareToken } = useParams<{ shareToken: string }>()
const navigate = useNavigate()
const [data, setData] = useState<SharedSessionView | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<ErrorState | null>(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: { pathname: `/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 (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<Spinner />
<p className="text-sm text-muted-foreground">Loading shared session...</p>
</div>
</div>
)
}
if (error) {
return <ErrorCard error={error} />
}
if (!data) {
return null
}
return (
<div className="min-h-screen bg-background">
{/* Minimal header */}
<header className="border-b border-border px-6 py-4">
<div className="mx-auto flex max-w-7xl items-center justify-between">
<Link to="/" className="flex items-center gap-2">
<BrandLogo size="sm" />
<span className="text-lg font-heading font-semibold text-foreground">ResolutionFlow</span>
</Link>
<Link
to="/login"
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
Sign In
</Link>
</div>
</header>
{/* Content */}
<main className="container mx-auto max-w-7xl px-4 py-8">
{/* Metadata section */}
<div className="mb-8">
{data.share_name && (
<h1 className="mb-2 text-2xl font-heading font-bold text-foreground">{data.share_name}</h1>
)}
<p className="text-lg text-muted-foreground">
<span className="text-muted-foreground">Tree:</span> {data.tree_name}
</p>
{(data.ticket_number || data.client_name) && (
<p className="mt-1 text-sm text-muted-foreground">
{data.ticket_number && (
<span>Ticket: #{data.ticket_number}</span>
)}
{data.ticket_number && data.client_name && (
<span className="mx-1.5">&middot;</span>
)}
{data.client_name && (
<span>Client: {data.client_name}</span>
)}
</p>
)}
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
<span>Started: {formatDate(data.started_at)}</span>
{data.completed_at && (
<>
<span>Completed: {formatDate(data.completed_at)}</span>
<span>Duration: {formatDuration(data.started_at, data.completed_at)}</span>
</>
)}
<span className="inline-flex items-center gap-1">
{data.visibility === 'public' ? (
<>
<Globe className="h-3.5 w-3.5" />
Public
</>
) : (
<>
<Users className="h-3.5 w-3.5" />
Account
</>
)}
</span>
</div>
</div>
{/* Two-column layout */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Decision Timeline (2 cols) */}
<div className="lg:col-span-2">
<SessionTimeline
decisions={data.decisions}
treeType={data.tree_structure?.tree_type as string | undefined}
startedAt={data.started_at}
completedAt={data.completed_at}
showCopyButtons={false}
/>
</div>
{/* Tree Preview (1 col) */}
<div className="lg:col-span-1">
<SharedSessionTreePreview
treeStructure={data.tree_structure}
pathTaken={data.path_taken}
/>
</div>
</div>
</main>
{/* Footer */}
<footer className="py-8 text-center text-sm text-muted-foreground">
Powered by{' '}
<Link to="/" className="underline hover:text-foreground">
ResolutionFlow
</Link>
</footer>
</div>
)
}
export default SharedSessionPage