fix+upstream: hooks-order crash, ConfirmDialog/PageHeader/skeletons, styled error boundary

Hoists hooks above early return (render-time Navigate); ports finance's
ConfirmDialog/PageHeader/loading/states + APC error-copy mapper; removes
window.confirm, dead appbar search, and seeded fake notifications; wires toasts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jules
2026-07-04 13:46:12 +10:00
parent 3dbf2ac175
commit 675f6f8b35
13 changed files with 809 additions and 319 deletions

View File

@@ -0,0 +1,99 @@
import { useState, type ReactElement, type ReactNode } from "react"
import { Loader2 } from "lucide-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog"
type Props = {
/** The element that opens the dialog — a Button or bare button. */
trigger: ReactElement
title: string
description?: ReactNode
confirmLabel?: string
destructive?: boolean
onConfirm: () => Promise<void> | void
}
/** Branded confirm dialog. Replaces `window.confirm` — branded buttons,
* keyboard-trapped, dismisses on Esc, awaits async confirm handlers
* so we can show a spinner while the operation runs. */
export function ConfirmDialog({
trigger,
title,
description,
confirmLabel = "Confirm",
destructive,
onConfirm,
}: Props) {
const [open, setOpen] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleConfirm() {
setBusy(true)
setError(null)
try {
await onConfirm()
setOpen(false)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
return (
<AlertDialog
open={open}
onOpenChange={(v) => {
if (!busy) setOpen(v)
}}
>
<AlertDialogTrigger render={trigger} />
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
{description ? (
<AlertDialogDescription>{description}</AlertDialogDescription>
) : null}
</AlertDialogHeader>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<AlertDialogFooter>
<AlertDialogCancel data-action="confirm-cancel" disabled={busy}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
data-action="confirm-accept"
onClick={(e) => {
e.preventDefault()
void handleConfirm()
}}
disabled={busy}
className={
destructive
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
: undefined
}
>
{busy ? (
<>
<Loader2 className="animate-spin" /> Working
</>
) : (
confirmLabel
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react"
const SIDEBAR_KEY = "crema.shell.sidebar"
import { NavLink, useNavigate } from "react-router"
import { NavLink, Navigate, useNavigate } from "react-router"
import {
Bell,
LayoutDashboard,
@@ -52,7 +52,6 @@ import {
dismissAll,
markAllRead,
markRead,
seedIfEmpty,
unreadCount,
useNotifications,
} from "~/lib/notifications"
@@ -66,7 +65,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"
import { Input } from "~/components/ui/input"
import {
Sheet,
SheetContent,
@@ -129,17 +127,10 @@ export function AppShell({
),
}
// Protected shell: bounce to /login when there's no session.
useEffect(() => {
if (typeof window === "undefined") return
if (!session) {
const next = encodeURIComponent(
window.location.pathname + window.location.search,
)
navigate(`/login?next=${next}`, { replace: true })
}
}, [session, navigate])
if (!session) return null
// All hooks must be called unconditionally — declare them BEFORE any
// early return so React's render-time hook count is stable. (Previously
// `if (!session) return null` sat above these hooks, so the hook count
// changed the moment the session flipped → hook-order crash.)
const [expanded, setExpanded] = useState<boolean>(() => {
if (typeof window === "undefined") return false
return localStorage.getItem(SIDEBAR_KEY) === "1"
@@ -149,10 +140,20 @@ export function AppShell({
}, [expanded])
const [mobileOpen, setMobileOpen] = useState(false)
const [scriptsOpen, setScriptsOpen] = useState(false)
const BrandIcon = brand.icon
useScriptsHotkey(() => setScriptsOpen(true))
// Protected shell: redirect to /login when there's no session. Done at
// render time (not in an effect) so we don't briefly render a blank
// shell during the redirect window.
if (!session) {
const next =
typeof window !== "undefined"
? encodeURIComponent(window.location.pathname + window.location.search)
: ""
return <Navigate to={`/login?next=${next}`} replace />
}
const BrandIcon = brand.icon
return (
<div
data-theme={theme}
@@ -289,14 +290,20 @@ export function AppShell({
</SheetContent>
</Sheet>
<AppbarTitle>{title}</AppbarTitle>
<div className="relative ml-6 hidden md:block">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
data-action="appbar-search"
placeholder="Search…"
className="h-9 w-80 pl-8"
/>
</div>
{/* Honest search affordance: the template has no search index, so
this routes to the Assistant — the app's real natural-language
surface — rather than being a dead <input>. Forks with a search
route should point this at it (and add a ⌘K palette hint). */}
<button
type="button"
data-action="appbar-search"
onClick={() => navigate("/assistant")}
className="ml-6 hidden h-9 w-80 items-center gap-2 rounded-md border bg-background px-2.5 text-left text-sm text-muted-foreground transition hover:bg-accent/40 md:flex"
title="Ask the assistant — it can answer questions and drive the app."
>
<Search className="size-4" />
<span className="flex-1 truncate">Ask the assistant</span>
</button>
<AppbarSpacer />
<AppbarActions>
<Button
@@ -459,10 +466,6 @@ function NotificationsBell() {
const unread = unreadCount(items)
const navigate = useNavigate()
useEffect(() => {
seedIfEmpty()
}, [])
return (
<Popover>
<PopoverTrigger

View File

@@ -0,0 +1,36 @@
import { type ReactNode } from "react"
interface PageHeaderProps {
title: ReactNode
description?: ReactNode
/** Inline indicators after the title (badges, status pills). */
badges?: ReactNode
/** Toolbar rendered below the title row — primary actions go here. */
actions?: ReactNode
}
// Shared page header for the app's main surfaces. Keeps the title/description
// pattern consistent across routes so forks don't reinvent it per page.
export function PageHeader({
title,
description,
badges,
actions,
}: PageHeaderProps) {
return (
<header className="flex flex-col gap-3">
<div className="flex flex-wrap items-end gap-4">
<h1 className="text-headline font-semibold tracking-tight">{title}</h1>
{badges}
</div>
{description ? (
<p className="max-w-2xl text-[15px] leading-relaxed text-muted-foreground">
{description}
</p>
) : null}
{actions ? (
<div className="mt-2 flex flex-wrap items-center gap-2">{actions}</div>
) : null}
</header>
)
}

View File

@@ -0,0 +1,98 @@
import { Skeleton } from "~/components/ui/skeleton"
import { Card, CardContent } from "~/components/ui/card"
/** Layout-preserving placeholder for a list/table surface. Renders a
* header bar + `rows` row skeletons. Looks far less alarming than a
* single spinner during slow loads and keeps the layout from jumping
* when data lands. */
export function ListSkeleton({ rows = 8 }: { rows?: number }) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Skeleton className="h-9 w-72" />
<Skeleton className="h-8 w-24" />
</div>
<div className="rounded-md border">
<div className="flex items-center gap-4 border-b bg-muted/30 px-4 py-2">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-24" />
<Skeleton className="h-3 w-20" />
<Skeleton className="ml-auto h-3 w-16" />
</div>
{Array.from({ length: rows }).map((_, i) => (
<div
key={i}
className="flex items-center gap-4 border-b px-4 py-3 last:border-b-0"
>
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-16" />
<Skeleton className="ml-auto h-4 w-24" />
</div>
))}
</div>
</div>
)
}
/** Layout-preserving placeholder for a dashboard/overview surface. Mirrors
* a hero KPI + secondary KPIs, a trend + categories row, a forecast strip
* and a list card so there's no visible reshuffle when data lands. Shape
* it to match whatever your overview renders. */
export function DashboardSkeleton() {
return (
<div className="space-y-6">
{/* Hero KPI row */}
<section className="grid gap-6 lg:grid-cols-3 lg:items-end">
<div className="lg:col-span-2 space-y-3">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-14 w-72" />
<Skeleton className="h-8 w-full max-w-md" />
</div>
<div className="space-y-4">
<Skeleton className="h-7 w-full" />
<Skeleton className="h-7 w-full" />
<Skeleton className="h-3 w-32" />
</div>
</section>
{/* Trend + categories */}
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardContent className="space-y-3 p-5 md:p-7">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-64" />
<Skeleton className="h-56 w-full" />
</CardContent>
</Card>
<Card>
<CardContent className="space-y-4 p-5 md:p-7">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-8 w-40" />
<Skeleton className="h-2.5 w-full rounded-full" />
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
{/* Forecast */}
<Card>
<CardContent className="space-y-4 p-5 md:p-7">
<Skeleton className="h-4 w-32" />
<div className="grid gap-4 sm:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-7 w-32" />
</div>
))}
</div>
</CardContent>
</Card>
</div>
)
}

114
app/components/states.tsx Normal file
View File

@@ -0,0 +1,114 @@
import type { ReactNode } from "react"
import { AlertTriangle } from "lucide-react"
import { Card, CardContent, CardHeader } from "~/components/ui/card"
import { Skeleton } from "~/components/ui/skeleton"
type EmptyStateProps = {
icon?: ReactNode
title: string
description?: ReactNode
action?: ReactNode
secondaryAction?: ReactNode
/** When true, wrap the body in a bordered card frame. Default false —
* a bare dashed surface that reads as "nothing here yet". */
framed?: boolean
}
/** Shared empty state. Centred icon disc + title + description + optional
* actions, using theme tokens only. Pair with EmptyState → ErrorState →
* content branching so a route never renders a bare blank surface. */
export function EmptyState({
icon,
title,
description,
action,
secondaryAction,
framed = false,
}: EmptyStateProps) {
const body = (
<div className="flex flex-col items-center gap-5 px-8 py-16 text-center">
{icon ? (
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
{icon}
</div>
) : null}
<div className="space-y-2">
<p className="text-title font-semibold tracking-tight">{title}</p>
{description ? (
<p className="mx-auto max-w-md text-[15px] leading-relaxed text-muted-foreground">
{description}
</p>
) : null}
</div>
{action || secondaryAction ? (
<div className="mt-1 flex flex-wrap items-center justify-center gap-2">
{action}
{secondaryAction}
</div>
) : null}
</div>
)
if (framed) {
return (
<Card>
<CardContent className="p-0">{body}</CardContent>
</Card>
)
}
return (
<div className="overflow-hidden rounded-2xl border-2 border-dashed border-muted-foreground/20 bg-muted/30">
{body}
</div>
)
}
/** Shared error state — a soft destructive-tinted banner. Used both by
* routes (data-fetch failures) and as the middle rung of the
* skeleton → error → empty → content grammar. */
export function ErrorState({
title = "Something went wrong",
message,
action,
}: {
title?: string
message: string
action?: ReactNode
}) {
return (
<div className="flex items-start gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-4">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-destructive" />
<div className="flex-1 space-y-1">
<p className="text-sm font-medium text-destructive">{title}</p>
<p className="text-sm text-muted-foreground">{message}</p>
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
)
}
/** Card-shaped loading placeholder for card lists/grids. */
export function LoadingCards({
count = 3,
variant = "row",
}: {
count?: number
variant?: "row" | "grid"
}) {
const items = Array.from({ length: count })
return (
<div className={variant === "grid" ? "grid gap-5 sm:grid-cols-2" : "space-y-3"}>
{items.map((_, i) => (
<Card key={i}>
<CardHeader className="space-y-2 pb-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-64" />
</CardHeader>
<CardContent>
<Skeleton className="h-3 w-3/4" />
</CardContent>
</Card>
))}
</div>
)
}