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

@@ -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>
)
}