Search for pages, actions, and quick links.
Callouts, alerts and the states a page shows when it has nothing. 8 blocks.
A search that found nothing, distinguished from having no data at all.
src/components/blocks/banners/empty-no-results.tsx"use client";
import { SearchX } from "lucide-react";
import { Button } from "@dashboardpack/core/components/ui/button";
/**
* A search that found nothing.
*
* Distinct from having no data at all, and the distinction matters: this one echoes the term
* that failed and offers to clear it, because the useful next action is "undo my filter", not
* "create your first record". Showing a first-run empty state to someone who has mistyped a
* search is the most common version of this mistake.
*/
export function EmptyNoResults() {
const term = "quarterly forecast";
return (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-10 text-center">
<div className="flex size-11 items-center justify-center rounded-full bg-muted">
<SearchX className="size-5 text-muted-foreground" aria-hidden="true" />
</div>
<div>
<p className="text-sm font-medium">No results for “{term}”</p>
<p className="mt-1 text-sm text-muted-foreground">
Check the spelling, or widen the filters — there may be matches outside the current
date range.
</p>
</div>
<Button variant="outline" size="sm">
Clear search
</Button>
</div>
);
}Nothing here yet, and what to do about it.
src/components/blocks/banners/empty-first-run.tsx"use client";
import { FileText, Plus } from "lucide-react";
import { Button } from "@dashboardpack/core/components/ui/button";
/**
* Nothing here yet, and what to do about it.
*
* A first-run state should teach, so it names the thing that will appear and gives one primary
* action plus one way to learn more. It must never be shown for a search that found nothing —
* that is a different state with a different next step, and conflating them tells someone who
* mistyped a filter to create a record they already have.
*/
export function EmptyFirstRun() {
return (
<div className="flex flex-col items-center justify-center gap-4 px-6 py-12 text-center">
<div className="flex size-12 items-center justify-center rounded-xl bg-primary/10">
<FileText className="size-6 text-primary" aria-hidden="true" />
</div>
<div className="max-w-sm">
<p className="font-medium">No invoices yet</p>
<p className="mt-1 text-sm text-muted-foreground">
Invoices appear here once you bill a customer. You can create one by hand, or let them
be generated from a subscription.
</p>
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
<Button size="sm" className="gap-1.5">
<Plus className="size-3.5" />
New invoice
</Button>
<Button variant="ghost" size="sm">
How billing works
</Button>
</div>
</div>
);
}Shows the reader's own timezone without a hydration mismatch.
src/components/blocks/banners/maintenance-window.tsx"use client";
import { useCallback, useSyncExternalStore } from "react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Clock } from "lucide-react";
/**
* A scheduled-downtime notice, in the reader's own timezone — without a hydration mismatch.
*
* The mismatch is the whole lesson here. A banner giving only "02:00 UTC" makes every reader do
* timezone arithmetic and half will get it wrong, so it should show local time. But
* `toLocaleString()` resolves against the *machine's* timezone, and this app is statically exported:
* the string is baked at build time in the build machine's zone, then React re-renders it in the
* visitor's zone. Different text in the same node is a hydration error, and it is the kind that
* only appears for users in another country.
*
* So UTC — which is the same everywhere — is what gets prerendered, and the local rendering is
* gated on mount. The `useSyncExternalStore` idiom returns the server snapshot `false` and the
* client snapshot `true`, which flips on the first client render *after* hydration has matched.
* `suppressHydrationWarning` would silence the warning while leaving the wrong time on screen.
*
* The instants are fixed literals, not offsets from `Date.now()`: content derived from build time
* makes every rebuild produce a different static export, turning a no-op into a diff.
*
* `role="status"`, not `role="alert"` — this is scheduled and informational. `alert` interrupts a
* screen reader mid-sentence and is for things needing attention right now.
*/
export function MaintenanceWindow() {
const start = new Date("2026-08-14T02:00:00Z");
const end = new Date("2026-08-14T04:30:00Z");
const mounted = useSyncExternalStore(
useCallback(() => () => {}, []),
() => true,
() => false,
);
const format = (date: Date) =>
date.toLocaleString(undefined, {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
// Pinned to UTC until mounted, so server and first client render agree exactly.
timeZone: mounted ? undefined : "UTC",
});
return (
<div
role="status"
className="flex flex-wrap items-start gap-3 rounded-lg border border-warning/40 bg-warning/10 p-4"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-warning/20 text-warning">
<Clock className="h-4 w-4" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1 space-y-1">
<p className="text-sm font-medium">Scheduled maintenance</p>
<p className="text-sm text-muted-foreground">
Reporting will be read-only from{" "}
<time dateTime={start.toISOString()} className="font-medium text-foreground">
{format(start)}
</time>{" "}
to{" "}
<time dateTime={end.toISOString()} className="font-medium text-foreground">
{format(end)}
</time>{" "}
{mounted ? "your time" : "UTC"} (02:00–04:30 UTC). Dashboards stay available.
</p>
</div>
<Button variant="outline" size="sm">
Add to calendar
</Button>
</div>
);
}Forces the user to read what they are destroying, case-sensitively.
src/components/blocks/banners/destructive-confirm.tsx"use client";
import { useState } from "react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Input } from "@dashboardpack/core/components/ui/input";
import { Label } from "@dashboardpack/core/components/ui/label";
import { AlertTriangle } from "lucide-react";
/**
* A destructive action gated on typing the resource name.
*
* The type-to-confirm pattern exists because a second "Are you sure?" dialog trains people to click
* through it. Requiring the exact name forces the user to read what they are about to destroy, and it
* makes the dangerous action impossible to trigger by muscle memory or a stray Enter.
*
* The comparison is deliberately case-sensitive and untrimmed. Accepting "ACME-PROD " defeats the
* point: the check is not there to be passed conveniently.
*
* The confirm button is `disabled` rather than hidden, so its existence and its precondition are
* both visible. `aria-describedby` ties the button to the instruction, so a screen-reader user
* reaching a disabled button is told why it is disabled instead of being left to guess.
*/
export function DestructiveConfirm() {
const target = "acme-prod";
const [typed, setTyped] = useState("");
const armed = typed === target;
return (
<div className="space-y-4 rounded-lg border border-destructive/40 p-4">
<div className="flex items-start gap-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-destructive/10 text-destructive">
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1 space-y-1">
<p className="text-sm font-medium">Delete this environment</p>
<p className="text-sm text-muted-foreground">
This removes the database, all 1,284 stored records and every API key. It cannot be
undone and there is no backup older than 24 hours.
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-name" id="confirm-hint" className="text-xs font-normal">
Type <span className="font-mono font-semibold text-foreground">{target}</span> to confirm
</Label>
<Input
id="confirm-name"
value={typed}
onChange={(event) => setTyped(event.target.value)}
// Every one of these off: an autocorrected or capitalised value would never match, and the
// user would be left retyping a name that looks correct on screen.
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder={target}
className="font-mono"
/>
</div>
<Button
variant="destructive"
disabled={!armed}
aria-describedby="confirm-hint"
className="w-full"
>
Delete environment
</Button>
</div>
);
}Tone follows days left, and dismissal says what it does.
src/components/blocks/banners/trial-countdown.tsx"use client";
import { Button } from "@dashboardpack/core/components/ui/button";
import { cn } from "@dashboardpack/core/lib/utils";
import { Clock, X } from "lucide-react";
/**
* A trial banner whose urgency escalates, with a dismissal that is honest about being temporary.
*
* The tone is chosen from days remaining crossing named thresholds, so the same component covers
* "plenty of time" and "tomorrow" without a second banner. Escalating only at zero is too late to
* be useful.
*
* The dismiss button says "Remind me later", not "Close". A banner that reappears next session
* after a plain × reads as broken; naming the behaviour makes it a feature. This is a UI decision
* far more often gotten wrong than the styling.
*/
export function TrialCountdown() {
// Annotated, not inferred: without it TypeScript narrows this to the literal 3 and then
// reports the `=== 1` pluralisation check below as an impossible comparison.
const daysLeft: number = 3;
const level = daysLeft <= 3 ? "urgent" : daysLeft <= 7 ? "soon" : "calm";
const tones = {
calm: "border-border bg-muted/40",
soon: "border-warning/40 bg-warning/10",
urgent: "border-destructive/40",
};
const iconTones = {
calm: "bg-muted text-muted-foreground",
soon: "bg-warning/20 text-warning",
urgent: "bg-destructive/10 text-destructive",
};
return (
<div
role="status"
className={cn("flex flex-wrap items-start gap-3 rounded-lg border p-4", tones[level])}
>
<span
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md",
iconTones[level],
)}
>
<Clock className="h-4 w-4" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1 space-y-1">
<p className="text-sm font-medium">
{daysLeft} {daysLeft === 1 ? "day" : "days"} left in your trial
</p>
<p className="text-sm text-muted-foreground">
After that, dashboards stay readable but exports, saved views and API keys are disabled.
Nothing is deleted.
</p>
</div>
<div className="flex items-center gap-2">
<Button size="sm">Choose a plan</Button>
{/* Named for what it does. A bare × on a banner that returns next session reads as a bug. */}
<Button variant="ghost" size="sm" aria-label="Remind me later">
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
</div>
);
}Real online/offline events, with a matching server snapshot.
src/components/blocks/banners/offline-notice.tsx"use client";
import { useCallback, useSyncExternalStore } from "react";
import { cn } from "@dashboardpack/core/lib/utils";
import { Wifi, WifiOff } from "lucide-react";
/**
* A connection notice driven by the real `online` / `offline` events.
*
* `navigator.onLine` is read through `useSyncExternalStore` with a server snapshot of `true`, which
* matters under static export: reading it during render would make the prerendered HTML disagree
* with the client whenever a visitor loads the page offline, and that is a hydration mismatch.
*
* `navigator.onLine` only reports whether an interface is up, not whether the internet is reachable
* — a captive portal reports online. So the copy says "looks offline" rather than asserting it, and
* a real app should confirm with a request before showing anything stronger.
*/
function useOnline(): boolean {
return useSyncExternalStore(
useCallback((onChange: () => void) => {
window.addEventListener("online", onChange);
window.addEventListener("offline", onChange);
return () => {
window.removeEventListener("online", onChange);
window.removeEventListener("offline", onChange);
};
}, []),
() => navigator.onLine,
// Server snapshot: the export is prerendered as online, so hydration matches.
() => true,
);
}
export function OfflineNotice() {
const online = useOnline();
return (
<div className="space-y-3">
<div
role="status"
className={cn(
"flex items-start gap-3 rounded-lg border p-3",
online ? "border-border bg-muted/40" : "border-warning/40 bg-warning/10",
)}
>
<span
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md",
online ? "bg-success/10 text-success" : "bg-warning/20 text-warning",
)}
>
{online ? (
<Wifi className="h-4 w-4" aria-hidden="true" />
) : (
<WifiOff className="h-4 w-4" aria-hidden="true" />
)}
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{online ? "Connected" : "You look offline"}</p>
<p className="text-sm text-muted-foreground">
{online
? "Changes are saving normally."
: "Edits are kept locally and will sync when the connection returns."}
</p>
</div>
</div>
<p className="text-xs text-muted-foreground">
Toggle your network, or use the browser devtools’ offline mode, to see this switch.
</p>
</div>
);
}