{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hero-countdown",
  "type": "registry:block",
  "title": "Countdown hero",
  "description": "Live timer tied to a required real deadline, flip digits, minute-level polite announcements and an expired state.",
  "author": "StoreKit <hello@storekit.dev>",
  "categories": [
    "hero",
    "any"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://storekit-eight.vercel.app/r/cn.json",
    "https://storekit-eight.vercel.app/r/sk-analytics.json"
  ],
  "files": [
    {
      "path": "hero/hero-countdown.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useMemo, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { cn } from \"@/components/storekit/lib/cn\"\nimport { useSkBlock } from \"@/components/storekit/lib/sk-analytics\"\n\n/* Countdown hero tied to a real deadline. `deadline` is required and must be\n   the actual end of the sale or the real shipping cutoff; there is no\n   evergreen or resetting mode, because fake urgency breaks trust and FTC\n   guidance. The visual timer ticks every second and is hidden from screen\n   readers; a polite live region announces the remaining time at most once\n   a minute. When the deadline passes the timer is replaced by `expired`\n   copy (or the block renders nothing with `hideWhenExpired`). The timer\n   renders on the client only so server and client markup agree. */\n\nconst EASE = [0.23, 1, 0.32, 1] as const\n\ntype Cta = { label: string; href: string }\n\nconst PRIMARY =\n  \"sk-btn inline-flex min-h-12 items-center justify-center py-2.5 text-center leading-tight rounded-sk-btn bg-sk-accent px-7 text-[15px] font-medium text-sk-accent-ink outline-none transition-[filter,transform] duration-200 ease-sk-out hover:brightness-[1.06] focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper active:scale-[0.97]\"\nconst SECONDARY =\n  \"sk-btn inline-flex min-h-12 items-center justify-center py-2.5 text-center leading-tight rounded-sk-btn border border-sk-line px-7 text-[15px] font-medium text-sk-ink outline-none transition-[background-color,transform] duration-200 ease-sk-out hover:bg-sk-ink/[0.04] focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper active:scale-[0.97]\"\n\nfunction split(ms: number) {\n  const s = Math.max(0, Math.floor(ms / 1000))\n  return { d: Math.floor(s / 86400), h: Math.floor((s % 86400) / 3600), m: Math.floor((s % 3600) / 60), s: s % 60 }\n}\n\nfunction spoken(ms: number) {\n  const { d, h, m } = split(ms)\n  const parts = [d && `${d} day${d === 1 ? \"\" : \"s\"}`, h && `${h} hour${h === 1 ? \"\" : \"s\"}`, `${m} minute${m === 1 ? \"\" : \"s\"}`].filter(Boolean)\n  return parts.join(\", \")\n}\n\nfunction Digit({ value, reduce }: { value: string; reduce: boolean | null }) {\n  return (\n    <span className=\"relative inline-block overflow-hidden tabular-nums\">\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          key={value}\n          initial={{ y: reduce ? 0 : \"-60%\", opacity: 0 }}\n          animate={{ y: 0, opacity: 1 }}\n          exit={{ y: reduce ? 0 : \"60%\", opacity: 0 }}\n          transition={reduce ? { duration: 0.15 } : { type: \"spring\", stiffness: 420, damping: 34 }}\n          className=\"inline-block\"\n        >\n          {value}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  )\n}\n\nexport function HeroCountdown({\n  deadline,\n  eyebrow = \"Final weekend\",\n  label = \"Sale ends in\",\n  headline = \"20% off every routine, ends Sunday.\",\n  subcopy = \"Applied automatically at checkout. Subscriptions lock in the sale price for your first three deliveries.\",\n  expired = \"This sale has ended. Sign up to hear about the next one first.\",\n  hideWhenExpired = false,\n  primaryCta = { label: \"Shop the sale\", href: \"#\" },\n  secondaryCta = { label: \"See what's included\", href: \"#\" },\n  image = \"/placeholder.webp\",\n  imageAlt = \"\",\n  className,\n}: {\n  /** The real end of the sale or the real shipping cutoff. ISO string, epoch ms or Date. */\n  deadline: string | number | Date\n  eyebrow?: string\n  /** Sits above the timer, e.g. \"Sale ends in\" or \"Order within\". */\n  label?: string\n  headline?: string\n  subcopy?: string\n  expired?: string\n  hideWhenExpired?: boolean\n  primaryCta?: Cta\n  secondaryCta?: Cta\n  image?: string\n  imageAlt?: string\n  className?: string\n}) {\n  const reduce = useReducedMotion()\n  const sk = useSkBlock(\"hero\", \"hero-countdown\")\n  const end = useMemo(() => new Date(deadline).getTime(), [deadline])\n  const valid = Number.isFinite(end)\n  const [now, setNow] = useState<number | null>(null)\n\n  useEffect(() => {\n    if (!valid) return\n    setNow(Date.now())\n    const id = setInterval(() => setNow(Date.now()), 1000)\n    return () => clearInterval(id)\n  }, [valid])\n\n  const left = now === null ? null : end - now\n  const done = !valid || (left !== null && left <= 0)\n  // Changes once a minute, so the live region speaks at most once a minute.\n  const minuteText = left !== null && left > 0 ? `${spoken(left)} left` : \"\"\n  const endsAt = useMemo(\n    () => (now !== null && valid ? new Intl.DateTimeFormat(undefined, { weekday: \"short\", month: \"short\", day: \"numeric\", hour: \"numeric\", minute: \"2-digit\" }).format(end) : \"\"),\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [end, valid, now === null]\n  )\n\n  if (done && hideWhenExpired && now !== null) return null\n\n  const t = split(left ?? 0)\n  const cells: [string, string][] = [\n    [String(t.d).padStart(2, \"0\"), \"Days\"],\n    [String(t.h).padStart(2, \"0\"), \"Hours\"],\n    [String(t.m).padStart(2, \"0\"), \"Min\"],\n    [String(t.s).padStart(2, \"0\"), \"Sec\"],\n  ]\n\n  return (\n    <section ref={sk.ref} {...sk.attrs} className={cn(\"bg-sk-paper text-sk-ink\", className)}>\n      <div className=\"mx-auto grid w-full max-w-6xl items-center gap-10 px-4 py-10 sm:px-8 sm:py-14 lg:grid-cols-[1.1fr_0.9fr] lg:gap-14\">\n        <motion.div initial={{ opacity: 0, y: reduce ? 0 : 8 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, ease: EASE }} className=\"order-2 lg:order-1\">\n          <p className=\"font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted\">{eyebrow}</p>\n          <h1 className=\"mt-4 max-w-xl text-balance font-sk-display font-semibold leading-[1.08] tracking-[-0.02em]\" style={{ fontSize: \"clamp(2rem, 4.2vw, 3.25rem)\" }}>\n            {headline}\n          </h1>\n\n          <div className=\"mt-8 min-h-[104px]\">\n            {done && now !== null ? (\n              <p className=\"max-w-md rounded-sk-card border border-sk-line bg-sk-surface p-4 text-[15px] text-sk-ink\">{expired}</p>\n            ) : (\n              <>\n                <p className=\"flex flex-wrap items-baseline gap-x-2 font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted\">\n                  {label}\n                  {endsAt ? (\n                    <time dateTime={new Date(end).toISOString()} className=\"normal-case tracking-normal\">\n                      ({endsAt})\n                    </time>\n                  ) : null}\n                </p>\n                <div aria-hidden className=\"mt-3 grid max-w-md grid-cols-4 gap-2 sm:gap-3\">\n                  {cells.map(([v, l]) => (\n                    <div key={l} className=\"flex flex-col items-center rounded-sk-card border border-sk-line bg-sk-card px-1 py-3 sm:py-4\">\n                      <span className=\"font-sk-mono text-[28px] font-medium leading-none text-sk-ink sm:text-[40px]\">\n                        {now === null ? \"--\" : (\n                          <>\n                            <Digit value={v[0]} reduce={reduce} />\n                            <Digit value={v.slice(1)} reduce={reduce} />\n                          </>\n                        )}\n                      </span>\n                      <span className=\"mt-2 font-sk-mono text-[10px] uppercase tracking-[0.14em] text-sk-muted\">{l}</span>\n                    </div>\n                  ))}\n                </div>\n              </>\n            )}\n            <p className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n              {minuteText}\n            </p>\n          </div>\n\n          <p className=\"mt-6 max-w-md text-[15px] leading-[1.55] text-sk-muted\">{subcopy}</p>\n          <div className=\"mt-7 flex flex-col gap-3 sm:flex-row\">\n            <a href={primaryCta.href} onClick={() => sk.emit(\"interact\", \"cta\")} className={PRIMARY}>\n              {primaryCta.label}\n            </a>\n            <a href={secondaryCta.href} onClick={() => sk.emit(\"interact\", \"cta_secondary\")} className={SECONDARY}>\n              {secondaryCta.label}\n            </a>\n          </div>\n        </motion.div>\n\n        <motion.div\n          initial={{ opacity: 0, scale: reduce ? 1 : 0.96 }}\n          animate={{ opacity: 1, scale: 1 }}\n          transition={{ duration: 0.6, ease: EASE, delay: reduce ? 0 : 0.1 }}\n          className=\"relative order-1 aspect-[4/3] w-full overflow-hidden rounded-sk-card border border-sk-line bg-sk-surface lg:order-2 lg:aspect-[4/5]\"\n        >\n          <img src={image} alt={imageAlt} className=\"absolute inset-0 h-full w-full object-cover\" />\n        </motion.div>\n      </div>\n    </section>\n  )\n}\n",
      "type": "registry:block",
      "target": "components/storekit/hero/hero-countdown.tsx"
    }
  ],
  "docs": "`deadline` is required and must be the real sale end or shipping cutoff. There is no evergreen mode. Use `hideWhenExpired` to remove the block after it passes.",
  "meta": {
    "section": "hero",
    "variant": "countdown",
    "niche": [
      "any"
    ],
    "commerce": [
      "cta",
      "urgency"
    ],
    "previewHeight": 620
  }
}