"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowLeft, ArrowRight, Bell, Check, Copy, FileText, Home, LifeBuoy, Loader2, RefreshCw, Search, Wifi, WifiOff } from "lucide-react";
import { cn } from "@/lib/utils";
const focusRing = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background";
const btn = (primary?: boolean) =>
cn(
"inline-flex h-10 items-center justify-center gap-2 rounded-lg px-4 text-sm font-medium transition disabled:opacity-60",
focusRing,
primary ? "bg-foreground text-background shadow-sm hover:opacity-90" : "border bg-background shadow-xs hover:bg-accent",
);
type Link = { label: string; href: string };
export interface ErrorPageBaseProps {
homeHref?: string;
statusHref?: string;
supportHref?: string;
className?: string;
}
function Shell({ code, title, children, illustration, className }: { code: string; title: string; children: React.ReactNode; illustration: React.ReactNode; className?: string }) {
const reduce = useReducedMotion();
return (
<div className={cn("mx-auto flex w-full max-w-xl flex-col items-center px-4 text-center", className)}>
<div className="relative flex h-52 w-full items-center justify-center sm:h-60">{illustration}</div>
<motion.p initial={reduce ? false : { opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }} className="mt-4 font-mono text-xs font-semibold uppercase tracking-[0.2em] text-primary">
{code}
</motion.p>
<motion.h1 initial={reduce ? false : { opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.16 }} className="mt-2 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
{title}
</motion.h1>
<motion.div initial={reduce ? false : { opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.22 }} className="w-full">
{children}
</motion.div>
</div>
);
}
function FooterLinks({ statusHref = "#status", supportHref = "#support", status = "All systems operational", tone = "ok" }: { statusHref?: string; supportHref?: string; status?: string; tone?: "ok" | "warn" | "down" }) {
return (
<div className="mt-8 flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-sm text-muted-foreground">
<a href={statusHref} className={cn("inline-flex items-center gap-2 rounded hover:text-foreground", focusRing)}>
<span className="relative flex size-2">
<span className={cn("absolute inline-flex size-full animate-ping rounded-full opacity-60", tone === "ok" ? "bg-emerald-500" : tone === "warn" ? "bg-amber-500" : "bg-rose-500")} />
<span className={cn("relative inline-flex size-2 rounded-full", tone === "ok" ? "bg-emerald-500" : tone === "warn" ? "bg-amber-500" : "bg-rose-500")} />
</span>
{status}
</a>
<a href={supportHref} className={cn("inline-flex items-center gap-1.5 rounded hover:text-foreground", focusRing)}>
<LifeBuoy className="size-4" /> Contact support
</a>
</div>
);
}
/* ------------------------------------------------------------------ 404 */
export interface NotFoundPageProps extends ErrorPageBaseProps {
suggestions?: (Link & { description?: string })[];
onSearch?: (q: string) => void;
}
const DEFAULT_SUGGESTIONS: (Link & { description: string })[] = [
{ label: "Documentation", href: "#docs", description: "Guides, API reference and examples" },
{ label: "Pricing", href: "#pricing", description: "Plans for every team size" },
{ label: "Changelog", href: "#changelog", description: "What we shipped recently" },
{ label: "Integrations", href: "#integrations", description: "Connect the tools you use" },
{ label: "Account settings", href: "#settings", description: "Profile, billing and security" },
];
function LostPlanet() {
const reduce = useReducedMotion();
const stars = [
[30, 40, 0],
[290, 30, 0.6],
[60, 170, 1.2],
[270, 160, 0.3],
[160, 18, 0.9],
[210, 190, 1.5],
[100, 90, 1.8],
[240, 90, 2.1],
];
return (
<svg viewBox="0 0 320 210" className="h-full w-auto max-w-full overflow-visible" aria-hidden>
<defs>
<radialGradient id="ep-planet" cx="35%" cy="30%" r="75%">
<stop offset="0" stopColor="color-mix(in oklab, var(--primary) 55%, white)" />
<stop offset="1" stopColor="var(--primary)" />
</radialGradient>
</defs>
{stars.map(([x, y, d], i) => (
<motion.circle key={i} cx={x} cy={y} r={i % 3 === 0 ? 2 : 1.3} fill="var(--foreground)" initial={{ opacity: 0.2 }} animate={reduce ? { opacity: 0.5 } : { opacity: [0.15, 0.8, 0.15] }} transition={{ duration: 2.4, repeat: Infinity, delay: d }} />
))}
<text x="58" y="138" textAnchor="middle" fontSize="104" fontWeight="700" fill="var(--foreground)" style={{ fontFamily: "inherit", letterSpacing: "-0.05em" }}>
4
</text>
<text x="262" y="138" textAnchor="middle" fontSize="104" fontWeight="700" fill="var(--foreground)" style={{ fontFamily: "inherit", letterSpacing: "-0.05em" }}>
4
</text>
<motion.g animate={reduce ? undefined : { y: [0, -6, 0] }} transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}>
{/* back half of ring */}
<path d="M96 110 A66 16 -14 0 1 224 94" fill="none" stroke="var(--foreground)" strokeOpacity="0.3" strokeWidth="5" />
<circle cx="160" cy="102" r="42" fill="url(#ep-planet)" />
<circle cx="146" cy="88" r="7" fill="white" opacity="0.18" />
<circle cx="176" cy="116" r="10" fill="black" opacity="0.1" />
<circle cx="170" cy="84" r="4" fill="black" opacity="0.1" />
{/* front half of ring */}
<path d="M96 110 A66 16 -14 0 0 224 94" fill="none" stroke="var(--foreground)" strokeOpacity="0.75" strokeWidth="5" strokeLinecap="round" />
</motion.g>
{/* orbiting moon */}
<motion.g style={{ transformOrigin: "50% 50%", transformBox: "fill-box" }} animate={reduce ? undefined : { rotate: 360 }} transition={{ duration: 9, repeat: Infinity, ease: "linear" }}>
<circle cx="160" cy="102" r="73" fill="none" stroke="none" />
<circle cx="160" cy="29" r="7" fill="var(--muted-foreground)" />
<circle cx="158" cy="27" r="2" fill="var(--background)" opacity="0.5" />
</motion.g>
{/* drifting satellite */}
<motion.g initial={{ x: -30, y: 10 }} animate={reduce ? undefined : { x: [-30, 20, -30], y: [10, -6, 10], rotate: [0, 18, 0] }} transition={{ duration: 7, repeat: Infinity, ease: "easeInOut" }}>
<g transform="translate(250 30)">
<rect x="-4" y="-4" width="8" height="8" rx="1.5" fill="var(--foreground)" />
<rect x="-18" y="-2.5" width="11" height="5" rx="1" fill="var(--primary)" opacity="0.8" />
<rect x="7" y="-2.5" width="11" height="5" rx="1" fill="var(--primary)" opacity="0.8" />
<line x1="0" y1="-4" x2="0" y2="-10" stroke="var(--foreground)" strokeWidth="1.5" />
<circle cx="0" cy="-11" r="1.8" fill="#f43f5e" />
</g>
</motion.g>
</svg>
);
}
export function NotFoundPage({ suggestions = DEFAULT_SUGGESTIONS, onSearch, homeHref = "#", statusHref, supportHref, className }: NotFoundPageProps) {
const uid = React.useId();
const [q, setQ] = React.useState("");
const [submitted, setSubmitted] = React.useState("");
const results = q.trim() ? suggestions.filter((s) => (s.label + " " + (s.description ?? "")).toLowerCase().includes(q.trim().toLowerCase())) : suggestions.slice(0, 3);
return (
<Shell code="Error 404" title="This page drifted out of orbit" illustration={<LostPlanet />} className={className}>
<p className="mx-auto mt-3 max-w-md text-muted-foreground">The link may be broken or the page may have moved. Try searching, or head back to safety.</p>
<form
role="search"
onSubmit={(e) => {
e.preventDefault();
setSubmitted(q.trim());
onSearch?.(q.trim());
}}
className="relative mx-auto mt-6 max-w-md text-left"
>
<label htmlFor={`${uid}-q`} className="sr-only">
Search the site
</label>
<Search className="pointer-events-none absolute left-3.5 top-[22px] size-4 -translate-y-1/2 text-muted-foreground" />
<input
id={`${uid}-q`}
value={q}
onChange={(e) => {
setQ(e.target.value);
setSubmitted("");
}}
placeholder="Search docs, pages, settings…"
aria-controls={`${uid}-results`}
className="h-11 w-full rounded-xl border bg-background pl-10 pr-20 text-sm shadow-xs outline-none transition placeholder:text-muted-foreground/70 focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/15"
/>
<kbd className="pointer-events-none absolute right-3 top-[22px] -translate-y-1/2 rounded border bg-muted px-1.5 font-sans text-[10px] text-muted-foreground">Enter</kbd>
<ul id={`${uid}-results`} aria-live="polite" className="mt-2 overflow-hidden rounded-xl border bg-card text-card-foreground shadow-xs">
<AnimatePresence initial={false}>
{results.map((r) => (
<motion.li key={r.href} layout initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} className="border-b last:border-b-0">
<a href={r.href} className={cn("group flex items-center gap-3 px-3.5 py-2.5 text-sm transition hover:bg-accent", "focus-visible:bg-accent focus-visible:outline-none")}>
<FileText className="size-4 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block font-medium">{r.label}</span>
{r.description && <span className="block truncate text-xs text-muted-foreground">{r.description}</span>}
</span>
<ArrowRight className="size-4 -translate-x-1 text-muted-foreground opacity-0 transition group-hover:translate-x-0 group-hover:opacity-100" />
</a>
</motion.li>
))}
</AnimatePresence>
{!results.length && <li className="px-3.5 py-3 text-sm text-muted-foreground">{submitted ? `No results for “${submitted}”.` : `Nothing matches “${q.trim()}”.`} Try a broader term.</li>}
</ul>
</form>
<div className="mt-6 flex flex-wrap justify-center gap-2">
<a href={homeHref} className={btn(true)}>
<Home className="size-4" /> Go home
</a>
<button type="button" onClick={() => history.back()} className={btn()}>
<ArrowLeft className="size-4" /> Go back
</button>
</div>
<FooterLinks statusHref={statusHref} supportHref={supportHref} />
</Shell>
);
}
/* ------------------------------------------------------------------ 500 */
export interface ServerErrorPageProps extends ErrorPageBaseProps {
errorId?: string;
/** Resolve true if the retry succeeded. */
onRetry?: () => Promise<boolean>;
}
function BrokenServer() {
const reduce = useReducedMotion();
const units = [0, 1, 2];
return (
<svg viewBox="0 0 320 210" className="h-full w-auto max-w-full overflow-visible" aria-hidden>
{/* smoke */}
{!reduce &&
[0, 1, 2, 3].map((i) => (
<motion.circle
key={i}
cx={176 + (i % 2) * 10}
cy={52}
r={9}
fill="var(--muted-foreground)"
initial={{ opacity: 0, scale: 0.4, y: 0 }}
animate={{ opacity: [0, 0.35, 0], scale: [0.4, 1.6, 2.2], y: [0, -34, -58], x: [0, i % 2 ? 8 : -6, i % 2 ? 14 : -10] }}
transition={{ duration: 3, repeat: Infinity, delay: i * 0.75, ease: "easeOut" }}
style={{ transformBox: "fill-box", transformOrigin: "center" }}
/>
))}
<ellipse cx="160" cy="196" rx="90" ry="7" fill="var(--foreground)" opacity="0.08" />
<motion.g animate={reduce ? undefined : { x: [0, -1.2, 1.2, -0.8, 0] }} transition={{ duration: 0.5, repeat: Infinity, repeatDelay: 2.6 }}>
{units.map((u) => {
const y = 58 + u * 46;
const broken = u === 1;
return (
<g key={u}>
<rect x="92" y={y} width="136" height="38" rx="8" fill="var(--card)" stroke="var(--border)" strokeWidth="2" />
<rect x="92" y={y} width="136" height="38" rx="8" fill="var(--foreground)" opacity="0.04" />
{[0, 1, 2, 3, 4].map((s) => (
<rect key={s} x={104 + s * 9} y={y + 13} width="5" height="12" rx="1.5" fill="var(--muted-foreground)" opacity="0.35" />
))}
<rect x="160" y={y + 16} width="34" height="6" rx="3" fill="var(--muted-foreground)" opacity="0.2" />
<motion.circle
cx="206"
cy={y + 19}
r="3.5"
fill={broken ? "#f43f5e" : "#10b981"}
animate={reduce ? undefined : broken ? { opacity: [1, 0.15, 1] } : { opacity: [1, 0.55, 1] }}
transition={{ duration: broken ? 0.45 : 1.8 + u * 0.4, repeat: Infinity }}
/>
<motion.circle cx="216" cy={y + 19} r="3.5" fill={broken ? "#f59e0b" : "#10b981"} animate={reduce ? undefined : { opacity: [0.4, 1, 0.4] }} transition={{ duration: broken ? 0.7 : 2.4, repeat: Infinity, delay: 0.3 }} />
</g>
);
})}
{/* crack */}
<path d="M150 104 l8 10 -6 6 10 12" fill="none" stroke="var(--foreground)" strokeOpacity="0.5" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</motion.g>
{/* spark */}
<motion.path
d="M238 92 l-10 16 h9 l-7 16 16 -21 h-9 l7 -11z"
fill="#fbbf24"
stroke="#f59e0b"
strokeWidth="1"
strokeLinejoin="round"
animate={reduce ? undefined : { opacity: [0, 1, 0, 1, 0], scale: [0.8, 1.1, 0.9, 1.1, 0.8] }}
transition={{ duration: 1.2, repeat: Infinity, repeatDelay: 1.6 }}
style={{ transformBox: "fill-box", transformOrigin: "center" }}
/>
{/* unplugged cable */}
<path d="M92 150 C60 150 70 186 40 186" fill="none" stroke="var(--muted-foreground)" strokeOpacity="0.5" strokeWidth="3" strokeLinecap="round" />
</svg>
);
}
export function ServerErrorPage({ errorId = "req_7f3a9c21e04b", onRetry, homeHref = "#", statusHref, supportHref, className }: ServerErrorPageProps) {
const [state, setState] = React.useState<"idle" | "retrying" | "failed" | "ok">("idle");
const [attempts, setAttempts] = React.useState(0);
const [copied, setCopied] = React.useState(false);
const retry = async () => {
setState("retrying");
const ok = onRetry ? await onRetry() : (await new Promise((r) => setTimeout(r, 1200)), attempts >= 1);
setAttempts((a) => a + 1);
setState(ok ? "ok" : "failed");
};
return (
<Shell code="Error 500" title="Something broke on our end" illustration={<BrokenServer />} className={className}>
<p className="mx-auto mt-3 max-w-md text-muted-foreground">Our servers hit an unexpected error. The team has been alerted automatically — it's usually fixed within minutes.</p>
<div className="mt-6 flex flex-wrap justify-center gap-2">
<button type="button" onClick={retry} disabled={state === "retrying"} className={btn(true)}>
{state === "retrying" ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
{state === "retrying" ? "Retrying…" : "Try again"}
</button>
<a href={homeHref} className={btn()}>
<Home className="size-4" /> Go home
</a>
</div>
<p className="mt-3 min-h-5 text-sm" aria-live="polite">
{state === "failed" && <span className="text-destructive">Still unavailable (attempt {attempts}). Try once more — the demo recovers on the second retry.</span>}
{state === "ok" && (
<span className="inline-flex items-center gap-1.5 text-emerald-600 dark:text-emerald-400">
<Check className="size-4" /> Back up! Reloading your page…
</span>
)}
</p>
<div className="mx-auto mt-4 flex max-w-sm items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2 text-left">
<span className="text-xs text-muted-foreground">Error ID</span>
<code className="min-w-0 flex-1 truncate font-mono text-xs">{errorId}</code>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(errorId);
} catch {
/* ignore */
}
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}}
aria-label={copied ? "Copied error ID" : "Copy error ID"}
className={cn("grid size-7 place-items-center rounded-md text-muted-foreground transition hover:bg-accent hover:text-foreground", focusRing)}
>
{copied ? <Check className="size-3.5 text-emerald-500" /> : <Copy className="size-3.5" />}
</button>
</div>
<FooterLinks statusHref={statusHref} supportHref={supportHref} status="Investigating elevated errors" tone="down" />
</Shell>
);
}
/* ------------------------------------------------------------------ Maintenance */
export interface MaintenancePageProps extends ErrorPageBaseProps {
/** Minutes until the window ends. */
etaMinutes?: number;
progress?: number;
onSubscribe?: (email: string) => void;
}
function Gear({ cx, cy, r, teeth, dur, reverse, fill }: { cx: number; cy: number; r: number; teeth: number; dur: number; reverse?: boolean; fill: string }) {
const reduce = useReducedMotion();
const d = React.useMemo(() => {
const pts: string[] = [];
const inner = r * 0.8;
for (let i = 0; i < teeth * 2; i++) {
const a0 = (i / (teeth * 2)) * Math.PI * 2;
const a1 = ((i + 1) / (teeth * 2)) * Math.PI * 2;
const rr = i % 2 === 0 ? r : inner;
pts.push(`${(cx + Math.cos(a0) * rr).toFixed(2)} ${(cy + Math.sin(a0) * rr).toFixed(2)}`, `${(cx + Math.cos(a1) * rr).toFixed(2)} ${(cy + Math.sin(a1) * rr).toFixed(2)}`);
}
return `M${pts.join(" L")}Z`;
}, [cx, cy, r, teeth]);
return (
<motion.g style={{ transformOrigin: "50% 50%", transformBox: "fill-box" }} animate={reduce ? undefined : { rotate: reverse ? -360 : 360 }} transition={{ duration: dur, repeat: Infinity, ease: "linear" }}>
<path d={d} fill={fill} strokeLinejoin="round" />
<circle cx={cx} cy={cy} r={r * 0.42} fill="var(--background)" />
<circle cx={cx} cy={cy} r={r * 0.18} fill={fill} />
</motion.g>
);
}
function Workshop() {
const reduce = useReducedMotion();
return (
<svg viewBox="0 0 320 210" className="h-full w-auto max-w-full overflow-visible" aria-hidden>
<ellipse cx="160" cy="196" rx="100" ry="7" fill="var(--foreground)" opacity="0.07" />
<Gear cx={138} cy={108} r={52} teeth={12} dur={14} fill="var(--primary)" />
<Gear cx={206} cy={66} r={30} teeth={8} dur={14 * (30 / 52)} reverse fill="color-mix(in oklab, var(--foreground) 65%, transparent)" />
<Gear cx={202} cy={150} r={22} teeth={7} dur={14 * (22 / 52)} reverse fill="var(--muted-foreground)" />
{/* traffic cone */}
<motion.g animate={reduce ? undefined : { rotate: [-3, 3, -3] }} transition={{ duration: 2.4, repeat: Infinity, ease: "easeInOut" }} style={{ transformOrigin: "50% 100%", transformBox: "fill-box" }}>
<path d="M52 110 h20 l18 76 h-56z" fill="#f97316" />
<path d="M47 134 h30 l4 16 h-38z M40 164 h44 l3 12 h-50z" fill="white" opacity="0.9" />
<rect x="26" y="184" width="72" height="8" rx="3" fill="#ea580c" />
</motion.g>
{/* status light */}
<motion.circle cx="262" cy="118" r="6" fill="#f59e0b" animate={reduce ? undefined : { opacity: [1, 0.25, 1] }} transition={{ duration: 1.1, repeat: Infinity }} />
<circle cx="262" cy="118" r="12" fill="none" stroke="#f59e0b" strokeOpacity="0.35" strokeWidth="2" />
</svg>
);
}
export function MaintenancePage({ etaMinutes = 42, progress = 64, onSubscribe, statusHref, supportHref, className }: MaintenancePageProps) {
const reduce = useReducedMotion();
const uid = React.useId();
const [left, setLeft] = React.useState(etaMinutes * 60);
const [email, setEmail] = React.useState("");
const [err, setErr] = React.useState("");
const [done, setDone] = React.useState(false);
React.useEffect(() => {
const t = setInterval(() => setLeft((s) => Math.max(0, s - 1)), 1000);
return () => clearInterval(t);
}, []);
const mm = String(Math.floor(left / 60)).padStart(2, "0");
const ss = String(left % 60).padStart(2, "0");
return (
<Shell code="Scheduled maintenance" title="We're tuning things up" illustration={<Workshop />} className={className}>
<p className="mx-auto mt-3 max-w-md text-muted-foreground">We're upgrading our database cluster to make everything faster. Your data is safe and nothing is lost.</p>
<div className="mx-auto mt-6 max-w-sm rounded-xl border bg-card p-4 text-left text-card-foreground shadow-xs">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">Upgrade progress</span>
<span className="font-mono tabular-nums text-muted-foreground" aria-label={`About ${Math.ceil(left / 60)} minutes remaining`}>
~{mm}:{ss} left
</span>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-muted" role="progressbar" aria-valuenow={progress} aria-valuemin={0} aria-valuemax={100} aria-label="Upgrade progress">
<motion.div className="relative h-full overflow-hidden rounded-full bg-primary" initial={reduce ? false : { width: 0 }} animate={{ width: `${progress}%` }} transition={{ duration: 1.2, ease: [0.22, 1, 0.36, 1] }}>
{!reduce && <motion.span className="absolute inset-y-0 w-1/2 bg-gradient-to-r from-transparent via-white/40 to-transparent" initial={{ x: "-100%" }} animate={{ x: "250%" }} transition={{ duration: 1.8, repeat: Infinity, ease: "linear" }} />}
</motion.div>
</div>
<ol className="mt-4 space-y-1.5 text-xs">
{[
["Snapshot created", true],
["Migrating data", true],
["Warming caches", false],
].map(([l, ok], i) => (
<li key={String(l)} className={cn("flex items-center gap-2", ok ? "text-foreground" : "text-muted-foreground")}>
{ok ? <Check className="size-3.5 text-emerald-500" /> : <Loader2 className={cn("size-3.5", !reduce && "animate-spin")} />}
{l}
{i === 1 && <span className="ml-auto text-muted-foreground">{progress}%</span>}
</li>
))}
</ol>
</div>
<AnimatePresence mode="wait" initial={false}>
{done ? (
<motion.p key="done" initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} className="mt-5 inline-flex items-center gap-2 text-sm text-emerald-600 dark:text-emerald-400" role="status">
<Check className="size-4" /> We'll email {email} the moment we're back.
</motion.p>
) : (
<motion.form
key="form"
exit={{ opacity: 0 }}
noValidate
onSubmit={(e) => {
e.preventDefault();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email.trim())) return setErr("Enter a valid email.");
setErr("");
onSubscribe?.(email.trim());
setDone(true);
}}
className="mx-auto mt-5 max-w-sm text-left"
>
<label htmlFor={`${uid}-notify`} className="sr-only">
Email for updates
</label>
<div className="flex gap-2">
<input
id={`${uid}-notify`}
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setErr("");
}}
placeholder="Email me when it's back"
aria-invalid={!!err}
aria-describedby={err ? `${uid}-notify-err` : undefined}
className={cn("h-10 min-w-0 flex-1 rounded-lg border bg-background px-3 text-sm shadow-xs outline-none transition focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/15", err && "border-destructive")}
/>
<button className={btn(true)}>
<Bell className="size-4" /> Notify me
</button>
</div>
{err && (
<p id={`${uid}-notify-err`} role="alert" className="mt-1.5 text-xs font-medium text-destructive">
{err}
</p>
)}
</motion.form>
)}
</AnimatePresence>
<FooterLinks statusHref={statusHref} supportHref={supportHref} status="Maintenance in progress" tone="warn" />
</Shell>
);
}
/* ------------------------------------------------------------------ Offline */
export interface OfflinePageProps extends ErrorPageBaseProps {
cachedPages?: Link[];
}
function Unplugged() {
const reduce = useReducedMotion();
return (
<svg viewBox="0 0 320 210" className="h-full w-auto max-w-full overflow-visible" aria-hidden>
{/* cloud */}
<motion.g animate={reduce ? undefined : { y: [0, -4, 0] }} transition={{ duration: 5, repeat: Infinity, ease: "easeInOut" }}>
<path d="M96 84 a26 26 0 0 1 8 -50 a36 36 0 0 1 68 -6 a28 28 0 0 1 44 30 a22 22 0 0 1 -6 44 H104 a19 19 0 0 1 -8 -18z" fill="var(--muted)" stroke="var(--border)" strokeWidth="2" transform="translate(0 6)" />
<g transform="translate(160 64)" stroke="var(--muted-foreground)" strokeWidth="5" fill="none" strokeLinecap="round">
<path d="M-26 -8 a38 38 0 0 1 52 0" opacity="0.35" />
<path d="M-16 2 a24 24 0 0 1 32 0" opacity="0.35" />
<circle cx="0" cy="12" r="3" fill="var(--muted-foreground)" stroke="none" />
<line x1="-24" y1="-18" x2="24" y2="22" stroke="#f43f5e" />
</g>
</motion.g>
{/* cables */}
<path d="M0 170 C40 170 60 160 96 160" fill="none" stroke="var(--foreground)" strokeOpacity="0.5" strokeWidth="4" strokeLinecap="round" />
<path d="M320 170 C280 170 262 160 228 160" fill="none" stroke="var(--foreground)" strokeOpacity="0.5" strokeWidth="4" strokeLinecap="round" />
{/* plug */}
<motion.g animate={reduce ? undefined : { x: [0, 14, 0] }} transition={{ duration: 2.2, repeat: Infinity, ease: "easeInOut" }}>
<rect x="94" y="146" width="34" height="28" rx="6" fill="var(--primary)" />
<rect x="128" y="151" width="14" height="4" rx="2" fill="var(--foreground)" opacity="0.7" />
<rect x="128" y="165" width="14" height="4" rx="2" fill="var(--foreground)" opacity="0.7" />
</motion.g>
{/* socket */}
<motion.g animate={reduce ? undefined : { x: [0, -14, 0] }} transition={{ duration: 2.2, repeat: Infinity, ease: "easeInOut" }}>
<rect x="196" y="146" width="34" height="28" rx="6" fill="var(--card)" stroke="var(--foreground)" strokeOpacity="0.4" strokeWidth="2" />
<rect x="199" y="152" width="8" height="3" rx="1.5" fill="var(--foreground)" opacity="0.5" />
<rect x="199" y="166" width="8" height="3" rx="1.5" fill="var(--foreground)" opacity="0.5" />
</motion.g>
{/* spark gap */}
<motion.g animate={reduce ? undefined : { opacity: [0, 0, 1, 0] }} transition={{ duration: 2.2, repeat: Infinity, times: [0, 0.4, 0.5, 0.6] }}>
<path d="M168 140 l-4 8 M176 138 v10 M184 140 l4 8" stroke="#f59e0b" strokeWidth="2.5" strokeLinecap="round" />
</motion.g>
</svg>
);
}
export function OfflinePage({ cachedPages = [{ label: "Dashboard", href: "#dashboard" }, { label: "Recent documents", href: "#docs" }, { label: "Saved drafts", href: "#drafts" }], statusHref, supportHref, className }: OfflinePageProps) {
const [online, setOnline] = React.useState<boolean | null>(null);
const [checking, setChecking] = React.useState(false);
const [result, setResult] = React.useState<"" | "online" | "offline">("");
React.useEffect(() => {
const update = () => setOnline(navigator.onLine);
update();
window.addEventListener("online", update);
window.addEventListener("offline", update);
return () => {
window.removeEventListener("online", update);
window.removeEventListener("offline", update);
};
}, []);
const check = () => {
setChecking(true);
setResult("");
setTimeout(() => {
setChecking(false);
setResult(navigator.onLine ? "online" : "offline");
}, 1100);
};
return (
<Shell code="No connection" title="You're offline" illustration={<Unplugged />} className={className}>
<p className="mx-auto mt-3 max-w-md text-muted-foreground">We can't reach the network right now. Changes you make are saved locally and sync once you're back.</p>
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
<button type="button" onClick={check} disabled={checking} className={btn(true)}>
{checking ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
{checking ? "Checking connection…" : "Retry"}
</button>
<span className={cn("inline-flex h-10 items-center gap-2 rounded-lg border px-3 text-sm", online ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground")} aria-live="polite">
{online ? <Wifi className="size-4" /> : <WifiOff className="size-4" />}
{online === null ? "Detecting…" : online ? "Network detected" : "No network"}
</span>
</div>
<p className="mt-3 min-h-5 text-sm" aria-live="polite">
{result === "online" && (
<span className="inline-flex items-center gap-1.5 text-emerald-600 dark:text-emerald-400">
<Check className="size-4" /> Connection restored — syncing 3 pending changes.
</span>
)}
{result === "offline" && <span className="text-destructive">Still offline. Check your Wi-Fi or VPN and try again.</span>}
</p>
<div className="mx-auto mt-4 max-w-sm rounded-xl border bg-card p-4 text-left text-card-foreground shadow-xs">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Available offline</p>
<ul className="mt-2 space-y-0.5">
{cachedPages.map((p) => (
<li key={p.href}>
<a href={p.href} className={cn("flex items-center justify-between rounded-md px-2 py-1.5 text-sm transition hover:bg-accent", focusRing)}>
{p.label} <ArrowRight className="size-3.5 text-muted-foreground" />
</a>
</li>
))}
</ul>
</div>
<FooterLinks statusHref={statusHref} supportHref={supportHref} />
</Shell>
);
}
/* ------------------------------------------------------------------ Switcher */
export type ErrorPageKind = "404" | "500" | "maintenance" | "offline";
export function ErrorPages({ initial = "404", className }: { initial?: ErrorPageKind; className?: string }) {
const reduce = useReducedMotion();
const uid = React.useId();
const [kind, setKind] = React.useState<ErrorPageKind>(initial);
const tabs: { id: ErrorPageKind; label: string }[] = [
{ id: "404", label: "404" },
{ id: "500", label: "500" },
{ id: "maintenance", label: "Maintenance" },
{ id: "offline", label: "Offline" },
];
return (
<section className={cn("relative flex min-h-[780px] w-full flex-col overflow-hidden bg-background text-foreground", className)}>
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-70 dark:opacity-40"
style={{
backgroundImage: "radial-gradient(color-mix(in oklab, var(--foreground) 12%, transparent) 1px, transparent 1px)",
backgroundSize: "24px 24px",
maskImage: "radial-gradient(ellipse 70% 60% at 50% 40%, black, transparent)",
WebkitMaskImage: "radial-gradient(ellipse 70% 60% at 50% 40%, black, transparent)",
}}
/>
<div className="relative flex justify-center px-4 pt-5">
<div role="tablist" aria-label="Error page variant" className="inline-flex rounded-full border bg-card/80 p-1 shadow-xs backdrop-blur">
{tabs.map((t) => (
<button
key={t.id}
role="tab"
id={`${uid}-tab-${t.id}`}
aria-selected={kind === t.id}
aria-controls={`${uid}-panel`}
onClick={() => setKind(t.id)}
className={cn("relative rounded-full px-3 py-1.5 text-xs font-medium transition-colors sm:px-4 sm:text-sm", focusRing, kind === t.id ? "text-background" : "text-muted-foreground hover:text-foreground")}
>
{kind === t.id && <motion.span layoutId={`${uid}-pill`} className="absolute inset-0 rounded-full bg-foreground" transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 36 }} />}
<span className="relative">{t.label}</span>
</button>
))}
</div>
</div>
<div id={`${uid}-panel`} role="tabpanel" aria-labelledby={`${uid}-tab-${kind}`} className="relative flex flex-1 items-center justify-center py-8">
<AnimatePresence mode="wait" initial={false}>
<motion.div key={kind} className="w-full" initial={{ opacity: 0, scale: 0.98 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.98 }} transition={{ duration: 0.2 }}>
{kind === "404" && <NotFoundPage />}
{kind === "500" && <ServerErrorPage />}
{kind === "maintenance" && <MaintenancePage />}
{kind === "offline" && <OfflinePage />}
</motion.div>
</AnimatePresence>
</div>
</section>
);
}