Fazekit

Code

"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&apos;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&apos;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&apos;ll email {email} the moment we&apos;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&apos;t reach the network right now. Changes you make are saved locally and sync once you&apos;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>
  );
}

More in App Pages

View all →