{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "expiry-pill",
  "type": "registry:component",
  "title": "Expiry pill",
  "description": "Shows a preview lifetime as fine, expiring soon, or expired. Does not verify codes, persist state, or grant or revoke access. Contract: the consumer owns lifecycle timestamps and action callbacks.",
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/components/expiry-pill.tsx",
      "type": "registry:component",
      "target": "@components/knock/expiry-pill.tsx",
      "content": "/**\n * MIT License — Copyright (c) 2026 Knock contributors.\n * Shows a preview lifetime as fine, expiring soon, or expired.\n * Does not verify codes, persist state, or grant or revoke access.\n * Contract: the consumer owns lifecycle timestamps and action callbacks.\n * @version 0.2.0\n * @minimalExample <ExpiryPill expiresAt=\"2030-01-01T12:00:00Z\" />\n * @fullExample <ExpiryPill expiresAt=\"2030-01-01T12:00:00Z\" soonThreshold={3600} theme=\"light\" labels={{ fine: \"Available\", soon: \"Ending soon\", expired: \"Expired\", pending: \"Expiry\", unavailable: \"Ask the owner\", remaining: seconds => `${seconds}s` }} />\n * @lifecycle gate\n * @whenToUse A preview's remaining lifetime belongs next to the gate, or inside the bar that stays after unlock.\n * @notFor Enforcing the deadline. The pill reaches its expired state and changes nothing about what your application serves.\n * @pitfall Rendering the expired state hides nothing. Your own check decides whether the preview is still available.\n * @pitfall `soonThreshold` is in seconds, not milliseconds. The default window is one hour.\n * @a11y Native controls, visible focus, localized labels; lifecycle messages use polite announcements. No modal dialog is created.\n */\n'use client';\n\nimport { useEffect, useState } from 'react';\nexport interface ExpiryPillProps {\n  /** Expiry timestamp as ISO 8601 with timezone; required. */\n  expiresAt: string;\n  /** Duration considered expiring soon, in seconds. @default 86400 */\n  soonThreshold?: number;\n  /** Localized states and remaining-time formatter. */\n  labels?: {\n    /** Localized pending. */\n    pending?: string;\n\n    /** Localized fine. */\n    fine?: string;\n\n    /** Localized soon. */\n    soon?: string;\n\n    /** Localized expired. */\n    expired?: string;\n\n    /** Localized unavailable. */\n    unavailable?: string;\n\n    /** Localized remaining formatter. */\n    remaining?: (seconds: number) => string;\n  };\n\n  /** Explicit theme; omitted follows the system preference. */\n  theme?: 'light' | 'dark';\n  /** Additional wrapper classes. */\n  className?: string;\n}\nexport function getExpiryState(\n  expiresAt: string,\n  now: number,\n  soonThreshold = 86400,\n): 'fine' | 'soon' | 'expired' | 'invalid' {\n  const remaining = Date.parse(expiresAt) - now;\n  return !Number.isFinite(remaining)\n    ? 'invalid'\n    : remaining <= 0\n      ? 'expired'\n      : remaining <= Math.max(0, soonThreshold) * 1000\n        ? 'soon'\n        : 'fine';\n}\nexport function ExpiryPill({\n  expiresAt,\n  soonThreshold = 86400,\n  labels,\n  theme,\n  className = '',\n}: ExpiryPillProps) {\n  const [now, setNow] = useState<number | null>(null);\n  useEffect(() => {\n    setNow(Date.now());\n    const timer = setInterval(() => setNow(Date.now()), 1000);\n    return () => clearInterval(timer);\n  }, [expiresAt]);\n  const state = now === null ? 'pending' : getExpiryState(expiresAt, now, soonThreshold);\n  const seconds = now === null ? 0 : Math.max(0, Math.ceil((Date.parse(expiresAt) - now) / 1000));\n  const remaining =\n    state !== 'fine' && state !== 'soon'\n      ? ''\n      : (labels?.remaining?.(seconds) ??\n        (seconds >= 86400\n          ? `${Math.ceil(seconds / 86400)}d`\n          : seconds >= 3600\n            ? `${Math.ceil(seconds / 3600)}h`\n            : `${Math.max(1, Math.ceil(seconds / 60))}m`));\n  const text =\n    state === 'pending'\n      ? (labels?.pending ?? 'Preview expiry')\n      : state === 'invalid'\n        ? (labels?.unavailable ?? 'Expiry unavailable')\n        : state === 'expired'\n          ? (labels?.expired ?? 'Preview expired')\n          : `${state === 'soon' ? (labels?.soon ?? 'Expiring soon') : (labels?.fine ?? 'Preview available')} · ${remaining}`;\n  return (\n    <span\n      data-knock-item=\"expiry\"\n      data-theme={theme}\n      data-state={state}\n      className={`inline-flex max-w-full items-center gap-2 rounded-full border border-[var(--knock-border,var(--knock-line))] bg-[var(--knock-bg,var(--knock-surface))] px-3 py-1.5 text-xs ${className}`}\n    >\n      <style>{`[data-knock-item]{--knock-surface:#fffefa;--knock-text:#30382c;--knock-secondary:#65705b;--knock-line:#d8dece;--knock-highlight:#a3462d;color:var(--knock-ink,var(--knock-text));color-scheme:light}[data-knock-item][data-theme=dark]{--knock-surface:#293028;--knock-text:#f0f3e9;--knock-secondary:#b6c2ab;--knock-line:#59654f;--knock-highlight:#f1a187;color-scheme:dark}@media(prefers-color-scheme:dark){[data-knock-item]:not([data-theme=light]){--knock-surface:#293028;--knock-text:#f0f3e9;--knock-secondary:#b6c2ab;--knock-line:#59654f;--knock-highlight:#f1a187;color-scheme:dark}}[data-knock-item] a:focus-visible,[data-knock-item] button:focus-visible,[data-knock-item] [tabindex]:focus-visible{outline:2px solid var(--knock-accent,var(--knock-highlight));outline-offset:4px}[data-knock-item] button,[data-knock-item] a{overflow-wrap:anywhere}@media(prefers-reduced-motion:reduce){[data-knock-item] *{animation:none!important;transition:none!important}}`}</style>\n      <svg\n        aria-hidden=\"true\"\n        width=\"14\"\n        height=\"14\"\n        viewBox=\"0 0 24 24\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n      >\n        <circle cx=\"12\" cy=\"12\" r=\"9\" />\n        <path d={state === 'expired' ? 'm8 8 8 8m0-8-8 8' : 'M12 6v6l4 2'} />\n      </svg>\n      <span>{text}</span>\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {state === 'expired'\n          ? (labels?.expired ?? 'Preview expired')\n          : state === 'soon'\n            ? (labels?.soon ?? 'Expiring soon')\n            : ''}\n      </span>\n    </span>\n  );\n}\n"
    }
  ],
  "meta": {
    "version": "0.2.0",
    "lifecycle": "gate",
    "whenToUse": "A preview's remaining lifetime belongs next to the gate, or inside the bar that stays after unlock.",
    "notFor": "Enforcing the deadline. The pill reaches its expired state and changes nothing about what your application serves.",
    "pitfalls": [
      "Rendering the expired state hides nothing. Your own check decides whether the preview is still available.",
      "`soonThreshold` is in seconds, not milliseconds. The default window is one hour."
    ],
    "dependencies": [],
    "usedBy": [
      "client-preview-gate",
      "cooldown-screen",
      "expired-notice",
      "gated-section",
      "open-invitation",
      "quick-gate",
      "revoked-notice",
      "teaser-gate"
    ],
    "sourceHash": "8a16373e2fd0c885c522b596aac85837d85103b66107cc0bc431f15b38c3348a",
    "component": "ExpiryPill",
    "contract": "Shows a preview lifetime as fine, expiring soon, or expired. Does not verify codes, persist state, or grant or revoke access. Contract: the consumer owns lifecycle timestamps and action callbacks.",
    "props": [
      {
        "name": "expiresAt",
        "type": "string",
        "required": true,
        "default": null,
        "description": "Expiry timestamp as ISO 8601 with timezone; required."
      },
      {
        "name": "soonThreshold",
        "type": "number",
        "required": false,
        "default": "86400",
        "description": "Duration considered expiring soon, in seconds."
      },
      {
        "name": "labels",
        "type": "{ pending?: string; fine?: string; soon?: string; expired?: string; unavailable?: string; remaining?: (seconds: number) => string; }",
        "required": false,
        "default": null,
        "description": "Localized states and remaining-time formatter.",
        "properties": [
          {
            "name": "pending",
            "type": "string",
            "required": false,
            "default": "'Preview expiry'"
          },
          {
            "name": "fine",
            "type": "string",
            "required": false,
            "default": "'Preview available'"
          },
          {
            "name": "soon",
            "type": "string",
            "required": false,
            "default": "'Expiring soon'"
          },
          {
            "name": "expired",
            "type": "string",
            "required": false,
            "default": "'Preview expired'"
          },
          {
            "name": "unavailable",
            "type": "string",
            "required": false,
            "default": "'Expiry unavailable'"
          },
          {
            "name": "remaining",
            "type": "(seconds: number) => string",
            "required": false,
            "default": null
          }
        ]
      },
      {
        "name": "theme",
        "type": "'light' | 'dark'",
        "required": false,
        "default": null,
        "description": "Explicit theme; omitted follows the system preference."
      },
      {
        "name": "className",
        "type": "string",
        "required": false,
        "default": "''",
        "description": "Additional wrapper classes."
      }
    ],
    "defaults": {
      "soonThreshold": "86400",
      "className": "''",
      "labels.pending": "'Preview expiry'",
      "labels.unavailable": "'Expiry unavailable'",
      "labels.expired": "'Preview expired'",
      "labels.soon": "'Expiring soon'",
      "labels.fine": "'Preview available'"
    },
    "slots": [],
    "a11y": "Native controls, visible focus, localized labels; lifecycle messages use polite announcements. No modal dialog is created.",
    "unsupportedProps": [
      "onSuccess",
      "password",
      "correctCode",
      "attempts",
      "maxAttempts",
      "onUnlock"
    ],
    "examples": {
      "minimal": "'use client';\nimport { useState } from 'react';\nimport { ExpiryPill } from '@/components/knock/expiry-pill';\n\nexport default function Example() {\n  const [value, setValue] = useState('');\n  const [unlocked, setUnlocked] = useState(false);\n  const [message, setMessage] = useState('');\n  return <section aria-label=\"Expiry pill example\">\n    {/* Demo consumer state only. Integrate your authorization outside this UI. */}\n    <ExpiryPill expiresAt=\"2030-01-01T12:00:00Z\" />\n    <p role=\"status\">{message}</p>\n  </section>;\n}\n",
      "full": "'use client';\nimport { useState } from 'react';\nimport { ExpiryPill } from '@/components/knock/expiry-pill';\n\nexport default function Example() {\n  const [value, setValue] = useState('');\n  const [unlocked, setUnlocked] = useState(false);\n  const [message, setMessage] = useState('');\n  return <section aria-label=\"Expiry pill example\">\n    {/* Demo consumer state only. Integrate your authorization outside this UI. */}\n    <ExpiryPill expiresAt=\"2030-01-01T12:00:00Z\" soonThreshold={3600} theme=\"light\" labels={{ fine: \"Available\", soon: \"Ending soon\", expired: \"Expired\", pending: \"Expiry\", unavailable: \"Ask the owner\", remaining: seconds => `${seconds}s` }} />\n    <p role=\"status\">{message}</p>\n  </section>;\n}\n"
    }
  },
  "docs": "UI only. Read https://knock.codes/docs/expiry-pill.md. Consumers own authorization."
}
