{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "error-page",
  "title": "Error Page",
  "description": "A full-viewport error page for 404 / 403 / 500 / offline / maintenance — per-type copy and advice, a back button that degrades when the history stack is empty, live online status, and a copyable error ID behind collapsed diagnostics.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.zyeon.ai/r/use-copy-to-clipboard.json",
    "https://ui.zyeon.ai/r/use-online.json"
  ],
  "files": [
    {
      "path": "src/registry/blocks/error-page.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  ArrowLeft,\n  Check,\n  ChevronRight,\n  Copy,\n  House,\n  Lock,\n  RefreshCw,\n  Search,\n  SearchX,\n  ServerCrash,\n  Wifi,\n  WifiOff,\n  Wrench,\n} from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { useCopyToClipboard } from \"@/hooks/use-copy-to-clipboard\"\nimport { useOnline } from \"@/hooks/use-online\"\n\n/** 五种终端用户真的会撞上的整页失败,每一种的解释和恢复路径都不同。 */\nexport type ErrorPageKind = \"404\" | \"403\" | \"500\" | \"offline\" | \"maintenance\"\n\nexport interface ErrorPageSuggestion {\n  label: string\n  href: string\n  /** 一行补充说明(可选),窄屏会换行而不是截断。 */\n  description?: string\n}\n\nexport interface ErrorPageDetail {\n  label: string\n  value: string\n}\n\nexport interface ErrorPageProps extends Omit<React.ComponentPropsWithoutRef<\"main\">, \"title\"> {\n  /** 错误类型,默认 \"404\"。它决定标题/解释/建议/主动作,不只是换个数字。 */\n  kind?: ErrorPageKind\n  /** 覆盖预设标题(渲染成 h1)。 */\n  title?: React.ReactNode\n  /** 覆盖预设解释(发生了什么)。 */\n  description?: React.ReactNode\n  /** 覆盖预设建议(该做什么)。 */\n  hint?: React.ReactNode\n  /** 覆盖预设状态码;传 `null` 则不画大数字,改画该类型的图标。 */\n  statusCode?: string | null\n  /** 「回首页」的目标,默认 \"/\"。 */\n  homeHref?: string\n  /** 覆盖「返回上页」的默认 `history.back()`(如 Next.js 的 `router.back()`)。 */\n  onBack?: () => void\n  /** 给了才渲染重试按钮。500 / offline / maintenance 下它是主动作。 */\n  onRetry?: () => void\n  /** 消费者驱动的重试 pending 态:转圈 + aria-busy + 期间点击被忽略。 */\n  retrying?: boolean\n  /** 求助入口的真实地址(支持页 / mailto / 状态页)。 */\n  supportHref?: string\n  /** 覆盖预设的求助文案(403 是「申请权限」、maintenance 是「看状态页」)。 */\n  supportLabel?: string\n  /** 建议去处,通常配 404。空数组不渲染。 */\n  suggestions?: ErrorPageSuggestion[]\n  /** 给了才渲染搜索框;提交时收到 trim 过的查询串。 */\n  onSearch?: (query: string) => void\n  /** 报障用的错误 ID —— 折叠区收起时也可见,且一键可复制。 */\n  errorId?: string\n  /** 已格式化好的时间串。组件自己永不调 `new Date()`(渲染期不读时钟)。 */\n  timestamp?: string\n  /** 追加的技术细节行(请求路径、trace id、构建号…)。 */\n  details?: ErrorPageDetail[]\n}\n\ninterface ErrorPagePreset {\n  statusCode: string | null\n  icon: React.ComponentType<{ className?: string }>\n  title: string\n  /** 发生了什么。 */\n  description: string\n  /** 该做什么 —— 每种类型的建议真的不一样。 */\n  hint: string\n  retryLabel: string\n  homeLabel: string\n  supportLabel: string\n  /** 哪个动作拿主色填充。 */\n  primary: \"retry\" | \"home\"\n}\n\n/**\n * 所有预设文案只有这一处 —— 换语言 / 换语气只改这张表,渲染树不动。\n * 五种类型的建议是分开写的:404 让人核对网址或搜索、403 让人换账号或找管理员、\n * 500 明说不是用户的错、offline 让人查网络、maintenance 说它自己会好。\n */\nconst PRESETS: Record<ErrorPageKind, ErrorPagePreset> = {\n  \"403\": {\n    description: \"You're signed in, but this account isn't allowed to open this page.\",\n    hint: \"Switch to an account that has access, or ask a workspace admin to grant your account permission.\",\n    homeLabel: \"Go to homepage\",\n    icon: Lock,\n    primary: \"home\",\n    retryLabel: \"Try again\",\n    statusCode: \"403\",\n    supportLabel: \"Request access\",\n    title: \"You don't have access to this page\",\n  },\n  \"404\": {\n    description: \"The address may be mistyped, or the page may have been moved or deleted.\",\n    hint: \"Check the URL for typos, or search for the page instead — it may live somewhere else now.\",\n    homeLabel: \"Go to homepage\",\n    icon: SearchX,\n    primary: \"home\",\n    retryLabel: \"Reload page\",\n    statusCode: \"404\",\n    supportLabel: \"Contact support\",\n    title: \"We can't find that page\",\n  },\n  \"500\": {\n    description: \"This one is on us — the server failed while handling your request.\",\n    hint: \"Nothing you did caused this. Wait a few seconds and try again; if it keeps failing, send support the error ID below.\",\n    homeLabel: \"Go to homepage\",\n    icon: ServerCrash,\n    primary: \"retry\",\n    retryLabel: \"Try again\",\n    statusCode: \"500\",\n    supportLabel: \"Contact support\",\n    title: \"Something went wrong on our end\",\n  },\n  maintenance: {\n    description: \"The service is briefly offline while an update is deployed. Nothing is lost.\",\n    hint: \"This page starts working again on its own — check back in a few minutes, or follow the status page for updates.\",\n    homeLabel: \"Go to homepage\",\n    icon: Wrench,\n    primary: \"retry\",\n    retryLabel: \"Check again\",\n    statusCode: \"503\",\n    supportLabel: \"View status page\",\n    title: \"We're down for scheduled maintenance\",\n  },\n  offline: {\n    description: \"Your device lost its network connection, so this page couldn't load.\",\n    hint: \"Check Wi-Fi, mobile data or airplane mode, then retry. Pages you already opened still work.\",\n    homeLabel: \"Go to homepage\",\n    icon: WifiOff,\n    primary: \"retry\",\n    retryLabel: \"Retry connection\",\n    statusCode: null,\n    supportLabel: \"Contact support\",\n    title: \"You're offline\",\n  },\n}\n\nconst ACTION_BASE =\n  \"inline-flex w-full items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium \" +\n  \"transition-colors motion-reduce:transition-none sm:w-auto \" +\n  \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 \" +\n  \"focus-visible:ring-offset-background\"\n\nconst ACTION_PRIMARY = \"bg-primary text-primary-foreground hover:bg-primary/90\"\n\n// 次动作的 hover 用 foreground/10:亮色主题里 muted/accent/secondary 是同一个值,\n// hover:bg-accent 压在浅底上是零变化(等于没有 hover 反馈)。\nconst ACTION_SECONDARY = \"border hover:bg-foreground/10\"\n\ntype NavigationLike = {\n  canGoBack?: boolean\n  addEventListener?: (type: string, callback: () => void) => void\n  removeEventListener?: (type: string, callback: () => void) => void\n}\n\nfunction getNavigation(): NavigationLike | undefined {\n  return (window as unknown as { navigation?: NavigationLike }).navigation\n}\n\nfunction subscribeHistory(callback: () => void) {\n  if (typeof window === \"undefined\") return () => {}\n  const navigation = getNavigation()\n  window.addEventListener(\"popstate\", callback)\n  navigation?.addEventListener?.(\"currententrychange\", callback)\n  return () => {\n    window.removeEventListener(\"popstate\", callback)\n    navigation?.removeEventListener?.(\"currententrychange\", callback)\n  }\n}\n\n/**\n * 「后退真的有地方可去吗」。优先问 Navigation API(它知道确切答案),\n * 没有就退回 `history.length > 1`。返回 false 时不画后退按钮 —— 一个按下去\n * 什么都不发生的「返回上页」比没有按钮更糟。\n */\nfunction getCanGoBack(): boolean {\n  const navigation = getNavigation()\n  if (navigation && typeof navigation.canGoBack === \"boolean\") return navigation.canGoBack\n  return window.history.length > 1\n}\n\n// 服务端 / hydration 前一律当作「没有历史」:宁可少画一个按钮,也不画一个死按钮。\nfunction getCanGoBackServerSnapshot(): boolean {\n  return false\n}\n\nfunction ErrorIdRow({ errorId }: { errorId: string }) {\n  const { copied, copy, error } = useCopyToClipboard()\n\n  return (\n    // code 和按钮是一组:分开当 flex item 时,长 ID 会吃掉整行把按钮挤到下一行,\n    // 复制键于是孤零零地掉在左下角\n    <span className=\"inline-flex min-w-0 items-center gap-1\">\n      <code className=\"min-w-0 break-all font-mono text-xs text-muted-foreground\">{errorId}</code>\n      <button\n        aria-label={copied ? \"Error ID copied\" : \"Copy error ID\"}\n        className={cn(\n          \"inline-flex shrink-0 cursor-pointer items-center gap-1 rounded-md px-1.5 py-1 text-xs font-medium\",\n          \"transition-colors hover:bg-foreground/10 motion-reduce:transition-none\",\n          \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n          error && !copied && \"text-destructive\",\n        )}\n        onClick={() => {\n          void copy(errorId)\n        }}\n        type=\"button\"\n      >\n        {copied ? (\n          <Check aria-hidden=\"true\" className=\"size-3.5\" />\n        ) : (\n          <Copy aria-hidden=\"true\" className=\"size-3.5\" />\n        )}\n        {/* 成功和失败都给可见反馈 —— 剪贴板被拒(非安全上下文 / 无权限)不能是个沉默的空动作 */}\n        {copied ? \"Copied\" : error ? \"Copy failed\" : null}\n      </button>\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {copied ? \"Error ID copied\" : error ? \"Copy failed\" : \"\"}\n      </span>\n    </span>\n  )\n}\n\nfunction TechnicalDetails({ errorId, rows }: { errorId?: string; rows: ErrorPageDetail[] }) {\n  const [open, setOpen] = React.useState(false)\n  const panelId = React.useId()\n\n  return (\n    <div className=\"w-full rounded-lg border bg-muted/40 text-left\">\n      <div className=\"flex flex-wrap items-center gap-x-2 gap-y-1 px-3 py-2\">\n        <button\n          aria-controls={panelId}\n          aria-expanded={open}\n          className={cn(\n            \"inline-flex cursor-pointer items-center gap-1.5 rounded-sm text-xs font-medium text-muted-foreground\",\n            \"transition-colors hover:text-foreground motion-reduce:transition-none\",\n            \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n          )}\n          onClick={() => setOpen(value => !value)}\n          type=\"button\"\n        >\n          <ChevronRight\n            aria-hidden=\"true\"\n            className={cn(\n              \"size-3.5 shrink-0 transition-transform motion-reduce:transition-none\",\n              open && \"rotate-90\",\n            )}\n          />\n          Technical details\n        </button>\n        {/* 错误 ID 在折叠态就可见并可一键复制:报障时它是客服唯一要的东西 */}\n        {errorId && <ErrorIdRow errorId={errorId} />}\n      </div>\n      {/* 折叠时真的卸载,而不是留一堆 Tab 得到却看不见的元素 */}\n      {open && (\n        <dl className=\"grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 border-t px-3 py-2.5\" id={panelId}>\n          {rows.map(row => (\n            <React.Fragment key={row.label}>\n              <dt className=\"text-xs whitespace-nowrap text-muted-foreground\">{row.label}</dt>\n              <dd className=\"min-w-0 break-words font-mono text-xs text-foreground\">{row.value}</dd>\n            </React.Fragment>\n          ))}\n        </dl>\n      )}\n    </div>\n  )\n}\n\n/**\n * 整页错误页:占满视口、有 `<main>` 地标和 `<h1>`、按错误类型给不同的解释和\n * 恢复路径。它是「这条路走不通了」的着陆点,不是嵌在某个区块里的失败提示 ——\n * 后者用 ErrorState。\n */\nexport function ErrorPage({\n  kind = \"404\",\n  title,\n  description,\n  hint,\n  statusCode,\n  homeHref = \"/\",\n  onBack,\n  onRetry,\n  retrying = false,\n  supportHref,\n  supportLabel,\n  suggestions,\n  onSearch,\n  errorId,\n  timestamp,\n  details,\n  className,\n  ...rest\n}: ErrorPageProps) {\n  // 未知 kind 兜底,避免消费者传进来一个野字符串就整页空白\n  const preset = PRESETS[kind] ?? PRESETS[\"404\"]\n  const [query, setQuery] = React.useState(\"\")\n  const searchId = React.useId()\n  const suggestionsId = React.useId()\n\n  const online = useOnline()\n  const canGoBack = React.useSyncExternalStore(\n    subscribeHistory,\n    getCanGoBack,\n    getCanGoBackServerSnapshot,\n  )\n\n  const code = statusCode === undefined ? preset.statusCode : statusCode\n  const GlyphIcon = preset.icon\n  const retryIsPrimary = preset.primary === \"retry\" && onRetry !== undefined\n\n  // 网络回来之后,「去查 Wi-Fi」这条建议就过期了 —— 标题保留「你为什么会看到这一页」,\n  // 建议行改成「现在该做什么」,两者不打架。\n  const resolvedHint =\n    hint ??\n    (kind === \"offline\" && online\n      ? \"Your connection is back. Retry to load the page — nothing was lost.\"\n      : preset.hint)\n\n  // 只有真拿到诊断信息才开这个折叠区。状态码是随行上下文,单靠它开一个\n  // 「技术细节」抽屉,给终端用户看到的只是一行「Status 404」的噪音。\n  const hasDiagnostics = Boolean(errorId || timestamp || (details && details.length > 0))\n  const detailRows: ErrorPageDetail[] = [\n    ...(errorId ? [{ label: \"Error ID\", value: errorId }] : []),\n    ...(timestamp ? [{ label: \"Time\", value: timestamp }] : []),\n    ...(code ? [{ label: \"Status\", value: code }] : []),\n    ...(details ?? []),\n  ]\n\n  const handleBack = () => {\n    if (onBack) {\n      onBack()\n      return\n    }\n    window.history.back()\n  }\n\n  const retryButton = onRetry ? (\n    <button\n      aria-busy={retrying || undefined}\n      // 原生 disabled 会立刻把焦点踢回 body,「点完还在这个按钮上」就没了;\n      // 用 aria-disabled + handler 里拦截,键盘用户不会丢失位置。\n      aria-disabled={retrying || undefined}\n      className={cn(\n        ACTION_BASE,\n        retryIsPrimary ? ACTION_PRIMARY : ACTION_SECONDARY,\n        \"cursor-pointer\",\n        retrying && \"opacity-70\",\n      )}\n      onClick={() => {\n        if (retrying) return\n        onRetry()\n      }}\n      type=\"button\"\n    >\n      <RefreshCw\n        aria-hidden=\"true\"\n        className={cn(\"size-4 shrink-0\", retrying && \"animate-spin motion-reduce:animate-none\")}\n      />\n      {retrying ? \"Retrying…\" : preset.retryLabel}\n    </button>\n  ) : null\n\n  const homeButton = (\n    <a\n      className={cn(ACTION_BASE, retryIsPrimary ? ACTION_SECONDARY : ACTION_PRIMARY)}\n      href={homeHref}\n    >\n      <House aria-hidden=\"true\" className=\"size-4 shrink-0\" />\n      {preset.homeLabel}\n    </a>\n  )\n\n  // 历史栈里没有可回的条目就不画这个按钮,回首页顶上主动作的位置\n  const backButton = canGoBack ? (\n    <button\n      className={cn(ACTION_BASE, ACTION_SECONDARY, \"cursor-pointer\")}\n      onClick={handleBack}\n      type=\"button\"\n    >\n      <ArrowLeft aria-hidden=\"true\" className=\"size-4 shrink-0\" />\n      Go back\n    </button>\n  ) : null\n\n  return (\n    <main\n      className={cn(\n        \"flex min-h-dvh w-full flex-col items-center justify-center bg-background px-6 py-16\",\n        className,\n      )}\n      {...rest}\n    >\n      <div className=\"flex w-full max-w-md flex-col items-center gap-8 text-center\">\n        <div className=\"flex flex-col items-center gap-3\">\n          {/* 大数字对读屏是装饰:真正的信息在下面的 h1 里,状态码另有一行在技术细节中 */}\n          {code === null ? (\n            <GlyphIcon aria-hidden=\"true\" className=\"size-14 text-muted-foreground sm:size-16\" />\n          ) : (\n            <span\n              aria-hidden=\"true\"\n              className=\"text-6xl font-semibold tracking-tighter text-muted-foreground tabular-nums sm:text-7xl\"\n            >\n              {code}\n            </span>\n          )}\n          <h1 className=\"text-2xl font-semibold tracking-tight text-balance sm:text-3xl\">\n            {title ?? preset.title}\n          </h1>\n          <p className=\"text-base text-pretty text-muted-foreground\">\n            {description ?? preset.description}\n          </p>\n          <p className=\"text-sm text-pretty text-muted-foreground\">{resolvedHint}</p>\n        </div>\n\n        {/* 离线页订阅真实的在线状态:网络回来时这行文字自己变,并被 role=status 播报 */}\n        {kind === \"offline\" && (\n          <p\n            className={cn(\n              \"inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium\",\n              online ? \"border-foreground/30 text-foreground\" : \"text-muted-foreground\",\n            )}\n            role=\"status\"\n          >\n            {online ? (\n              <Wifi aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n            ) : (\n              <WifiOff aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n            )}\n            {online ? \"Back online\" : \"No connection detected\"}\n          </p>\n        )}\n\n        <div className=\"flex w-full flex-col items-center gap-3\">\n          <div className=\"flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:items-center sm:justify-center\">\n            {retryIsPrimary ? (\n              <>\n                {retryButton}\n                {homeButton}\n                {backButton}\n              </>\n            ) : (\n              <>\n                {homeButton}\n                {backButton}\n                {retryButton}\n              </>\n            )}\n          </div>\n          {supportHref && (\n            <a\n              className={cn(\n                \"rounded-sm text-sm text-muted-foreground underline-offset-4\",\n                \"transition-colors hover:text-foreground hover:underline motion-reduce:transition-none\",\n                \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n              )}\n              href={supportHref}\n            >\n              {supportLabel ?? preset.supportLabel}\n            </a>\n          )}\n        </div>\n\n        {onSearch && (\n          <form\n            className=\"flex w-full items-center gap-2\"\n            onSubmit={event => {\n              event.preventDefault()\n              const trimmed = query.trim()\n              if (!trimmed) return\n              onSearch(trimmed)\n            }}\n            role=\"search\"\n          >\n            <label className=\"sr-only\" htmlFor={searchId}>\n              Search this site\n            </label>\n            <div className=\"relative min-w-0 flex-1\">\n              <Search\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground\"\n              />\n              <input\n                className={cn(\n                  \"h-10 w-full rounded-lg border bg-background pr-3 pl-9 text-sm\",\n                  \"placeholder:text-muted-foreground\",\n                  \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n                )}\n                id={searchId}\n                onChange={event => setQuery(event.target.value)}\n                placeholder=\"Search for a page…\"\n                type=\"search\"\n                value={query}\n              />\n            </div>\n            <button\n              className={cn(\n                ACTION_BASE,\n                ACTION_SECONDARY,\n                \"h-10 w-auto shrink-0 cursor-pointer py-0 disabled:cursor-default disabled:opacity-50\",\n              )}\n              disabled={!query.trim()}\n              type=\"submit\"\n            >\n              Search\n            </button>\n          </form>\n        )}\n\n        {suggestions && suggestions.length > 0 && (\n          <nav aria-labelledby={suggestionsId} className=\"w-full text-left\">\n            <h2\n              className=\"text-xs font-medium tracking-wide text-muted-foreground uppercase\"\n              id={suggestionsId}\n            >\n              Try one of these instead\n            </h2>\n            <ul className=\"mt-2 flex flex-col divide-y rounded-lg border\">\n              {suggestions.map(suggestion => (\n                <li key={suggestion.href}>\n                  <a\n                    className={cn(\n                      \"flex items-center justify-between gap-3 px-3 py-2.5 text-sm\",\n                      \"transition-colors hover:bg-foreground/10 motion-reduce:transition-none\",\n                      \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n                    )}\n                    href={suggestion.href}\n                  >\n                    <span className=\"min-w-0\">\n                      <span className=\"block font-medium\">{suggestion.label}</span>\n                      {suggestion.description && (\n                        <span className=\"block text-xs text-muted-foreground\">\n                          {suggestion.description}\n                        </span>\n                      )}\n                    </span>\n                    <ChevronRight aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n                  </a>\n                </li>\n              ))}\n            </ul>\n          </nav>\n        )}\n\n        {hasDiagnostics && <TechnicalDetails errorId={errorId} rows={detailRows} />}\n      </div>\n    </main>\n  )\n}\n\nexport default ErrorPage\n",
      "type": "registry:block"
    }
  ],
  "type": "registry:block"
}
