{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "reviews-list",
  "type": "registry:block",
  "title": "Review list",
  "description": "Filter chips, sort, review cards with verified badge, photo thumb and a springy helpful count.",
  "author": "StoreKit <hello@storekit.dev>",
  "categories": [
    "reviews",
    "any"
  ],
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "https://storekit-eight.vercel.app/r/cn.json",
    "https://storekit-eight.vercel.app/r/stars.json",
    "https://storekit-eight.vercel.app/r/sk-analytics.json"
  ],
  "files": [
    {
      "path": "reviews/reviews-list.tsx",
      "content": "\"use client\"\n\nimport { useMemo, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { BadgeCheck, ChevronDown, ThumbsUp } from \"lucide-react\"\nimport { cn } from \"@/components/storekit/lib/cn\"\nimport { Stars } from \"@/components/storekit/ui/stars\"\nimport { useSkBlock } from \"@/components/storekit/lib/sk-analytics\"\n\n/* Review list with single-select filter chips and a sort. Cards re-flow with\n   motion layout when the filter changes; \"Helpful\" counts tick up with a\n   small spring. */\n\ntype Review = {\n  id: string\n  rating: number\n  title: string\n  body: string\n  author: string\n  verified?: boolean\n  date: string\n  photo?: string\n  helpful: number\n}\n\nconst EASE = [0.23, 1, 0.32, 1] as const\nconst RING = \"outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper\"\n\nconst DEFAULT_REVIEWS: Review[] = [\n  { id: \"r1\", rating: 5, title: \"Finally a greens I like\", body: \"Tastes bright, not grassy, and mixes clean in cold water with no clumps. I have more steady energy by mid-morning.\", author: \"Dana R.\", verified: true, date: \"2026-09-21\", photo: \"/brands/morrow/greens.webp\", helpful: 24 },\n  { id: \"r2\", rating: 5, title: \"Sleeping through the night\", body: \"Two Magnesium Night gummies after dinner and I wake up less. They taste like real berries, not candy.\", author: \"Marcus T.\", verified: true, date: \"2026-09-12\", helpful: 11 },\n  { id: \"r3\", rating: 4, title: \"Great taste, scoop could be bigger\", body: \"The vanilla protein is smooth and not chalky. The scoop is a little small, so I use one and a half.\", author: \"Priya K.\", date: \"2026-08-30\", photo: \"/brands/morrow/protein.webp\", helpful: 6 },\n  { id: \"r4\", rating: 5, title: \"Subscription is set and forget\", body: \"Third month on subscribe and save. It shows up before I run out, and skipping a month takes two taps.\", author: \"Ellis W.\", verified: true, date: \"2026-08-14\", helpful: 17 },\n]\n\nconst FILTERS = [\"All\", \"5 star\", \"With photos\", \"Verified\"] as const\ntype Filter = (typeof FILTERS)[number]\ntype Sort = \"recent\" | \"helpful\" | \"rating\"\n\nfunction fmtDate(iso: string) {\n  return new Date(iso + \"T00:00:00\").toLocaleDateString(\"en-US\", { month: \"short\", day: \"numeric\", year: \"numeric\" })\n}\n\nfunction Helpful({ count, onVote }: { count: number; onVote: () => void }) {\n  const reduce = useReducedMotion()\n  const [voted, setVoted] = useState(false)\n  return (\n    <button\n      type=\"button\"\n      aria-pressed={voted}\n      onClick={() => {\n        if (voted) return\n        setVoted(true)\n        onVote()\n      }}\n      className={cn(\n        \"inline-flex h-11 items-center gap-2 rounded-sk-btn px-3 text-[13px] transition-[background-color,color,transform] duration-200 active:scale-[0.97]\",\n        voted ? \"text-sk-accent\" : \"text-sk-muted hover:bg-sk-ink/[0.04] hover:text-sk-ink\",\n        RING\n      )}\n    >\n      <ThumbsUp size={14} aria-hidden className={voted ? \"fill-current\" : undefined} />\n      Helpful\n      <span className=\"relative inline-grid min-w-[2ch] overflow-hidden tabular-nums\">\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          <motion.span\n            key={count}\n            initial={{ y: reduce ? 0 : 10, opacity: 0, scale: reduce ? 1 : 0.8 }}\n            animate={{ y: 0, opacity: 1, scale: 1 }}\n            exit={{ y: reduce ? 0 : -10, opacity: 0 }}\n            transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 500, damping: 26 }}\n          >\n            {count}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n    </button>\n  )\n}\n\nexport function ReviewsList({ reviews = DEFAULT_REVIEWS, className }: { reviews?: Review[]; className?: string }) {\n  const reduce = useReducedMotion()\n  const sk = useSkBlock(\"reviews\", \"reviews-list\")\n  const [filter, setFilter] = useState<Filter>(\"All\")\n  const [sort, setSort] = useState<Sort>(\"recent\")\n  const [votes, setVotes] = useState<Record<string, number>>({})\n  const chipRefs = useRef<(HTMLButtonElement | null)[]>([])\n\n  const shown = useMemo(() => {\n    const list = reviews.filter((r) =>\n      filter === \"5 star\" ? r.rating === 5 : filter === \"With photos\" ? !!r.photo : filter === \"Verified\" ? !!r.verified : true\n    )\n    const helpful = (r: Review) => r.helpful + (votes[r.id] ?? 0)\n    return [...list].sort((a, b) =>\n      sort === \"helpful\" ? helpful(b) - helpful(a) : sort === \"rating\" ? b.rating - a.rating : b.date.localeCompare(a.date)\n    )\n  }, [reviews, filter, sort, votes])\n\n  const current = FILTERS.indexOf(filter)\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    const n = FILTERS.length\n    let next = -1\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") next = (current + 1) % n\n    else if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") next = (current - 1 + n) % n\n    if (next < 0) return\n    e.preventDefault()\n    setFilter(FILTERS[next])\n    chipRefs.current[next]?.focus()\n  }\n\n  return (\n    <section {...sk.attrs} ref={sk.ref as React.RefObject<HTMLElement>} className={cn(\"w-full\", className)} aria-label=\"Reviews\">\n      <div className=\"flex flex-wrap items-center justify-between gap-x-4 gap-y-3 border-b border-sk-line pb-4\">\n        <div role=\"radiogroup\" aria-label=\"Filter reviews\" className=\"-mx-1 flex min-w-0 max-w-[calc(100%+0.5rem)] gap-2 overflow-x-auto px-1 py-1 [scrollbar-width:none]\">\n          {FILTERS.map((f, i) => {\n            const on = f === filter\n            return (\n              <button\n                key={f}\n                ref={(el) => {\n                  chipRefs.current[i] = el\n                }}\n                type=\"button\"\n                role=\"radio\"\n                aria-checked={on}\n                tabIndex={on ? 0 : -1}\n                onClick={() => setFilter(f)}\n                onKeyDown={onKeyDown}\n                className={cn(\n                  \"h-11 shrink-0 rounded-sk-chip border px-4 text-sm font-medium transition-[background-color,border-color,color,transform] duration-200 active:scale-[0.97]\",\n                  on ? \"border-sk-ink bg-sk-ink text-sk-paper\" : \"border-sk-line text-sk-ink hover:bg-sk-ink/[0.04]\",\n                  RING\n                )}\n              >\n                {f}\n              </button>\n            )\n          })}\n        </div>\n        <label className=\"ml-auto flex shrink-0 items-center gap-2 text-[13px] text-sk-muted\">\n          Sort\n          <span className=\"relative\">\n            <select\n              value={sort}\n              onChange={(e) => setSort(e.target.value as Sort)}\n              className={cn(\"h-11 appearance-none rounded-sk-btn border border-sk-line bg-sk-card pl-3 pr-8 text-sm text-sk-ink\", RING)}\n            >\n              <option value=\"recent\">Most recent</option>\n              <option value=\"helpful\">Most helpful</option>\n              <option value=\"rating\">Highest rated</option>\n            </select>\n            <ChevronDown size={14} className=\"pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2\" aria-hidden />\n          </span>\n        </label>\n      </div>\n\n      <motion.ul layout={!reduce} className=\"flex flex-col\" aria-live=\"polite\">\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {shown.map((r) => (\n            <motion.li\n              key={r.id}\n              layout={!reduce}\n              initial={{ opacity: 0, y: reduce ? 0 : 8 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, scale: reduce ? 1 : 0.98 }}\n              transition={{ duration: reduce ? 0 : 0.35, ease: EASE }}\n              className=\"flex flex-col gap-2 border-b border-sk-line py-5\"\n            >\n              <div className=\"flex flex-wrap items-center justify-between gap-2\">\n                <Stars value={r.rating} size={13} />\n                <time dateTime={r.date} className=\"font-sk-mono text-[11px] uppercase tracking-[0.08em] text-sk-muted\">\n                  {fmtDate(r.date)}\n                </time>\n              </div>\n              <div className=\"flex gap-4\">\n                <div className=\"flex min-w-0 flex-1 flex-col gap-2\">\n                  <h4 className=\"text-[15px] font-semibold text-sk-ink\">{r.title}</h4>\n                  <p className=\"text-sm leading-[1.5] text-sk-muted\">{r.body}</p>\n                </div>\n                {r.photo ? (\n                  <span className=\"relative h-16 w-16 shrink-0 overflow-hidden rounded-sk-btn border border-sk-line bg-sk-surface sm:h-20 sm:w-20\">\n                    <img src={r.photo} alt={`Photo from ${r.author}`} loading=\"lazy\" className=\"absolute inset-0 h-full w-full object-cover\" />\n                  </span>\n                ) : null}\n              </div>\n              <div className=\"-mb-2 flex flex-wrap items-center justify-between gap-x-2\">\n                <span className=\"flex flex-wrap items-center gap-2 text-[13px] text-sk-ink\">\n                  {r.author}\n                  {r.verified ? (\n                    <span className=\"inline-flex items-center gap-1 text-[12px] text-sk-savings\">\n                      <BadgeCheck size={14} aria-hidden /> Verified buyer\n                    </span>\n                  ) : null}\n                </span>\n                <Helpful\n                  count={r.helpful + (votes[r.id] ?? 0)}\n                  onVote={() => {\n                    sk.emit(\"interact\", \"helpful\", undefined)\n                    setVotes((v) => ({ ...v, [r.id]: (v[r.id] ?? 0) + 1 }))\n                  }}\n                />\n              </div>\n            </motion.li>\n          ))}\n        </AnimatePresence>\n        {shown.length === 0 ? <li className=\"py-10 text-center text-sm text-sk-muted\">No reviews match this filter yet.</li> : null}\n      </motion.ul>\n    </section>\n  )\n}\n",
      "type": "registry:block",
      "target": "components/storekit/reviews/reviews-list.tsx"
    }
  ],
  "meta": {
    "section": "reviews",
    "variant": "list",
    "niche": [
      "any"
    ],
    "commerce": [
      "reviews"
    ],
    "previewHeight": 720
  }
}