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

73
app/lib/errors.ts Normal file
View File

@@ -0,0 +1,73 @@
// Human error copy. Maps error codes / shapes / thrown values to a single
// plain sentence a person can act on. Used by the root ErrorBoundary and
// available to any route that catches a failure (e.g. a fetch reject).
//
// Ported/generalised from arcadia-personal-cloud-web's `acceptErrorMessage`:
// switch on a known machine `code`, else flatten field-validation errors,
// else fall back to the raw message or a friendly default.
import { isRouteErrorResponse } from "react-router"
/** A known machine error code → the human sentence we show for it. Extend
* this map as the app grows real backend error codes. */
const CODE_MESSAGES: Record<string, string> = {
network_error: "Couldn't reach the server. Check your connection and try again.",
timeout: "That took too long to respond. Try again in a moment.",
unauthorized: "Your session has expired. Sign in again to continue.",
forbidden: "You don't have access to that.",
not_found: "We couldn't find what you were looking for.",
rate_limited: "Too many requests just now. Give it a few seconds.",
server_error: "Something went wrong on our end. Try again shortly.",
}
type ErrorShape = {
error?: string
code?: string
message?: string
errors?: Record<string, string[] | string>
}
/** Turn any caught value into one human sentence. */
export function humanErrorMessage(err: unknown): string {
// React Router route-error responses (thrown Responses / 404s etc.).
if (isRouteErrorResponse(err)) {
if (err.status === 404) return CODE_MESSAGES.not_found
if (err.status === 401) return CODE_MESSAGES.unauthorized
if (err.status === 403) return CODE_MESSAGES.forbidden
if (err.status === 429) return CODE_MESSAGES.rate_limited
if (err.status >= 500) return CODE_MESSAGES.server_error
return err.statusText || "That request couldn't be completed."
}
const d = (
err && typeof err === "object" ? err : {}
) as ErrorShape
const code = d.code ?? d.error
if (code && CODE_MESSAGES[code]) return CODE_MESSAGES[code]
// arcadia-style field validation: { errors: { field: [msg...] } } —
// flatten into a single readable line so the user knows what to fix.
if (d.errors && typeof d.errors === "object") {
const lines: string[] = []
for (const [field, msgs] of Object.entries(d.errors)) {
const label = field.replace(/_/g, " ")
const arr = Array.isArray(msgs) ? msgs : [String(msgs)]
for (const m of arr) lines.push(`${label} ${m}`)
}
if (lines.length) return lines.join("; ")
}
if (err instanceof Error && err.message) return err.message
if (typeof d.message === "string" && d.message) return d.message
return "An unexpected error occurred. Please try again."
}
/** Short heading to pair with {@link humanErrorMessage} in a boundary/card. */
export function humanErrorTitle(err: unknown): string {
if (isRouteErrorResponse(err)) {
if (err.status === 404) return "Page not found"
return "Something went wrong"
}
return "Something went wrong"
}

View File

@@ -127,29 +127,3 @@ export function useNotifications(): AppNotification[] {
export function unreadCount(items: AppNotification[]): number {
return items.filter((n) => !n.readAt).length
}
/** Seed a few demo notifications on first load so the bell isn't empty. */
export function seedIfEmpty() {
if (typeof window === "undefined") return
if (localStorage.getItem(STORAGE_KEY)) return
const now = Date.now()
const seed: AppNotification[] = [
{
id: newId(),
kind: "info",
title: "Welcome",
body: "Tag elements with data-action and the assistant can drive them.",
href: "/assistant",
createdAt: now - 60_000,
},
{
id: newId(),
kind: "success",
title: "Profile saved",
body: "Your display name and avatar are live across the app.",
href: "/profile",
createdAt: now - 5 * 60_000,
},
]
writeToStorage(seed)
}