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>
115 lines
3.3 KiB
TypeScript
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>
|
|
)
|
|
}
|