Files
crema-app-aifirst-template/app/components/states.tsx
jules 675f6f8b35 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>
2026-07-04 13:46:12 +10:00

115 lines
3.3 KiB
TypeScript

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