{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "reviews-1",
  "title": "Reviews 1: customer reviews",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Customer reviews: an average with a bar for each star count that also filters, sorting, a verified-only switch, review cards with Read more, helpful votes that count at once and business replies. All figures are worked out from the reviews.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/cover-art.json",
    "https://ui.ballmac.com/r/select.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/reviews-1/reviews-1.tsx",
      "content": "// Ballmac UI: Reviews 1. https://ui.ballmac.com/blocks/reviews-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { BadgeCheck, ChevronDown, MessageSquareText, PenLine, Star, ThumbsUp, X } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { CoverArt } from \"@/components/ballmac/cover-art\"\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/ballmac/select\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Reviews1Review = {\n  id: string\n  author: string\n  /** 1 to 5. */\n  rating: number\n  title?: string\n  body: string\n  /** \"14 October 2026\", already written for people. */\n  date: string\n  /** ISO date, used to sort by newest. */\n  iso: string\n  verified?: boolean\n  helpful?: number\n  /** A short fact about them or their use, such as \"Team of 12\" or \"Runs a bakery\". */\n  context?: string\n  /** A reply from the business. */\n  reply?: { from: string; body: string; date: string }\n}\n\ntype Reviews1Props = Omit<React.ComponentProps<\"section\">, \"title\" | \"onSelect\"> &\n  StateProps & {\n    reviews?: Reviews1Review[]\n    title?: string\n    /** Called by Write a review. Leave out to hide the button. */\n    onWrite?: () => void\n    /** Called when someone marks a review helpful (true) or takes it back (false). */\n    onHelpful?: (review: Reviews1Review, helpful: boolean) => void\n  }\n\nconst sample: Reviews1Review[] = [\n  { id: \"r1\", author: \"Hannah Brooks\", rating: 5, title: \"Replaced three tools with one\", body: \"We moved our whole studio over in a weekend. The part I did not expect is how calm everything feels now: one place to look, and nothing falls through the cracks. Support answered a question I had on a Sunday evening, which sold me.\", date: \"14 October 2026\", iso: \"2026-10-14\", verified: true, helpful: 42, context: \"Design studio, team of 9\", reply: { from: \"Acme team\", body: \"Thank you Hannah. That Sunday answer was Sam, who will be very pleased to read this.\", date: \"15 October 2026\" } },\n  { id: \"r2\", author: \"Marcus Webb\", rating: 4, title: \"Great, with one wish\", body: \"Fast, clear and hardly any learning curve. I would love to see recurring tasks with more control over the schedule. Everything else is just what we needed.\", date: \"9 October 2026\", iso: \"2026-10-09\", verified: true, helpful: 18, context: \"Agency, team of 24\" },\n  { id: \"r3\", author: \"Elif Demir\", rating: 5, title: \"Our clients noticed\", body: \"Clients now see progress without asking for an update. It has changed how our weekly calls go: shorter and much friendlier.\", date: \"2 October 2026\", iso: \"2026-10-02\", verified: true, helpful: 31, context: \"Consultancy, team of 6\" },\n  { id: \"r4\", author: \"Tom Alvarez\", rating: 3, title: \"Good, but slow on big projects\", body: \"The basics are lovely. Once a project passed a few thousand tasks it started to lag, and the mobile app is behind the web one. Hopeful, since the team replies to everything.\", date: \"28 September 2026\", iso: \"2026-09-28\", verified: false, helpful: 12, context: \"Operations, team of 40\", reply: { from: \"Acme team\", body: \"Fair points. Large projects are our focus this quarter, and the mobile update ships next month.\", date: \"29 September 2026\" } },\n  { id: \"r5\", author: \"Aisha Rahman\", rating: 5, body: \"Easily the nicest tool our team uses. We stopped dreading Mondays.\", date: \"21 September 2026\", iso: \"2026-09-21\", verified: true, helpful: 9, context: \"Startup, team of 14\" },\n  { id: \"r6\", author: \"Jonas Keller\", rating: 2, title: \"Import lost our history\", body: \"Moving from our old tool dropped comments and attachments. Support helped fix most of it, but it cost us two days.\", date: \"15 September 2026\", iso: \"2026-09-15\", verified: true, helpful: 25, context: \"Software, team of 31\", reply: { from: \"Acme team\", body: \"We are sorry, Jonas. Comment import is fixed as of last week, and we have written a checklist so it does not happen again.\", date: \"16 September 2026\" } },\n  { id: \"r7\", author: \"Priya Nair\", rating: 4, title: \"Worth it\", body: \"Pays for itself in meetings we no longer need. Wish the free plan allowed a few more projects.\", date: \"8 September 2026\", iso: \"2026-09-08\", verified: false, helpful: 6, context: \"Freelancer\" },\n  { id: \"r8\", author: \"Lucas Moreau\", rating: 5, title: \"Simple and sturdy\", body: \"I have used a lot of these. This one is the first that my less technical colleagues picked up without a walkthrough.\", date: \"1 September 2026\", iso: \"2026-09-01\", verified: true, helpful: 14, context: \"Marketing, team of 11\" },\n]\n\nfunction Stars({ value, size = \"size-4\", className }: { value: number; size?: string; className?: string }) {\n  return (\n    <span aria-hidden=\"true\" className={cn(\"relative inline-flex\", className)}>\n      <span className=\"text-muted-foreground/35 inline-flex\">\n        {[0, 1, 2, 3, 4].map((i) => (\n          <Star key={i} className={size} />\n        ))}\n      </span>\n      <span className=\"absolute inset-0 inline-flex overflow-hidden text-amber-500\" style={{ width: `${Math.max(0, Math.min(5, value)) * 20}%` }}>\n        {[0, 1, 2, 3, 4].map((i) => (\n          <Star key={i} className={cn(size, \"shrink-0 fill-current\")} />\n        ))}\n      </span>\n    </span>\n  )\n}\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-8 lg:grid-cols-[18rem_minmax(0,1fr)]\">\n      <Skeleton className=\"h-72 rounded-2xl\" />\n      <div className=\"grid gap-4\">\n        {[0, 1, 2].map((i) => (\n          <div key={i} className=\"rounded-2xl border p-6\">\n            <Skeleton className=\"h-4 w-40\" />\n            <Skeleton className=\"mt-4 h-3 w-full\" />\n            <Skeleton className=\"mt-2 h-3 w-4/5\" />\n          </div>\n        ))}\n      </div>\n    </div>\n  )\n}\n\n/**\n * Customer reviews: a summary with the average and a bar for each star count that also filters, sorting, a verified-only switch, review cards with\n * Read more, helpful votes that count straight away and a business reply. The average and every bar are worked out from the reviews you pass.\n */\nfunction Reviews1({ reviews = sample, title = \"What customers say\", onWrite, onHelpful, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Reviews1Props) {\n  const id = React.useId()\n  const list = state === \"empty\" ? [] : reviews\n  const [star, setStar] = React.useState<number | null>(null)\n  const [verified, setVerified] = React.useState(false)\n  const [sort, setSort] = React.useState(\"helpful\")\n  const [open, setOpen] = React.useState<Set<string>>(new Set())\n  const [voted, setVoted] = React.useState<Set<string>>(new Set())\n  const [limit, setLimit] = React.useState(4)\n\n  const total = list.length\n  const avg = total ? list.reduce((n, r) => n + r.rating, 0) / total : 0\n  const dist = [5, 4, 3, 2, 1].map((s) => ({ s, n: list.filter((r) => r.rating === s).length }))\n  const recommend = total ? Math.round((100 * list.filter((r) => r.rating >= 4).length) / total) : 0\n  const shown = list\n    .filter((r) => (!star || r.rating === star) && (!verified || r.verified))\n    .sort((a, b) => (sort === \"newest\" ? b.iso.localeCompare(a.iso) : sort === \"high\" ? b.rating - a.rating || (b.helpful ?? 0) - (a.helpful ?? 0) : sort === \"low\" ? a.rating - b.rating || (b.helpful ?? 0) - (a.helpful ?? 0) : (b.helpful ?? 0) - (a.helpful ?? 0)))\n  const visible = shown.slice(0, limit)\n\n  const toggle = (set: Set<string>, k: string) => {\n    const n = new Set(set)\n    if (n.has(k)) n.delete(k)\n    else n.add(k)\n    return n\n  }\n\n  const content = !total ? (\n    <div className=\"bg-card rounded-2xl border px-6 py-16 text-center\">\n      <span aria-hidden=\"true\" className=\"bg-muted mx-auto flex size-12 items-center justify-center rounded-full\">\n        <MessageSquareText className=\"size-6\" />\n      </span>\n      <h3 className=\"mt-5 text-lg font-semibold tracking-tight\">{emptyState?.title ?? \"No reviews yet\"}</h3>\n      <p className=\"text-muted-foreground mx-auto mt-1.5 max-w-xs text-sm text-pretty\">{emptyState?.description ?? \"Be the first to share what it is like.\"}</p>\n      {emptyState?.action ?? (onWrite ? (\n        <Button className=\"mt-5\" onClick={onWrite}>\n          <PenLine aria-hidden=\"true\" />\n          Write a review\n        </Button>\n      ) : null)}\n    </div>\n  ) : (\n    <div className=\"grid items-start gap-8 lg:grid-cols-[18rem_minmax(0,1fr)] lg:gap-12\">\n      <aside aria-labelledby={`${id}-sum`} className=\"bg-card rounded-2xl border p-6 lg:sticky lg:top-6\">\n        <h3 id={`${id}-sum`} className=\"sr-only\">\n          Rating summary\n        </h3>\n        <p className=\"flex items-end gap-3\">\n          <span className=\"text-6xl leading-none font-semibold tracking-[-0.05em] tabular-nums\">{avg.toFixed(1)}</span>\n          <span className=\"pb-1\">\n            <Stars value={avg} size=\"size-5\" />\n            <span className=\"text-muted-foreground mt-1 block text-sm\">\n              {new Intl.NumberFormat(\"en\").format(total)} {total === 1 ? \"review\" : \"reviews\"}\n            </span>\n          </span>\n        </p>\n        <span className=\"sr-only\">\n          {avg.toFixed(1)} out of 5 from {total} reviews\n        </span>\n        <ul className=\"mt-6 grid gap-1.5\">\n          {dist.map(({ s, n }) => (\n            <li key={s}>\n              <button\n                type=\"button\"\n                aria-pressed={star === s}\n                aria-label={`${s} ${s === 1 ? \"star\" : \"stars\"}, ${n} ${n === 1 ? \"review\" : \"reviews\"}`}\n                disabled={n === 0}\n                onClick={() => setStar(star === s ? null : s)}\n                className={cn(\"focus-visible:ring-ring/50 hover:bg-accent group flex w-full items-center gap-3 rounded-md px-2 py-1 text-sm outline-none focus-visible:ring-[3px] disabled:opacity-50 disabled:hover:bg-transparent\", star === s && \"bg-accent\")}\n              >\n                <span aria-hidden=\"true\" className=\"flex w-6 items-center gap-0.5 tabular-nums\">\n                  {s}\n                  <Star className=\"size-3 fill-current text-amber-500\" />\n                </span>\n                <span aria-hidden=\"true\" className=\"bg-muted h-2 flex-1 overflow-hidden rounded-full\">\n                  <span className=\"block h-full rounded-full bg-amber-500 transition-[width] duration-500 motion-reduce:transition-none\" style={{ width: `${(n / total) * 100}%` }} />\n                </span>\n                <span aria-hidden=\"true\" className=\"text-muted-foreground w-6 text-end tabular-nums\">\n                  {n}\n                </span>\n              </button>\n            </li>\n          ))}\n        </ul>\n        <p className=\"mt-6 border-t pt-5 text-sm\">\n          <span className=\"font-semibold tabular-nums\">{recommend}%</span> <span className=\"text-muted-foreground\">of customers gave 4 or 5 stars</span>\n        </p>\n        {onWrite ? (\n          <Button variant=\"outline\" className=\"mt-5 w-full\" onClick={onWrite}>\n            <PenLine aria-hidden=\"true\" />\n            Write a review\n          </Button>\n        ) : null}\n      </aside>\n\n      <div className=\"min-w-0\">\n        <div className=\"flex flex-wrap items-center gap-3\">\n          <label className=\"flex cursor-pointer items-center gap-2 text-sm\">\n            <input type=\"checkbox\" checked={verified} onChange={(e) => setVerified(e.target.checked)} className=\"accent-foreground size-4\" />\n            Verified customers only\n          </label>\n          {star ? (\n            <button type=\"button\" onClick={() => setStar(null)} className=\"bg-muted hover:bg-accent focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium outline-none focus-visible:ring-[3px]\">\n              {star} {star === 1 ? \"star\" : \"stars\"}\n              <X aria-hidden=\"true\" className=\"size-3\" />\n              <span className=\"sr-only\">Clear the star filter</span>\n            </button>\n          ) : null}\n          <div className=\"ms-auto\">\n            <Select value={sort} onValueChange={setSort}>\n              <SelectTrigger aria-label=\"Sort reviews\" className=\"w-44\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"helpful\">Most helpful</SelectItem>\n                <SelectItem value=\"newest\">Newest</SelectItem>\n                <SelectItem value=\"high\">Highest rated</SelectItem>\n                <SelectItem value=\"low\">Lowest rated</SelectItem>\n              </SelectContent>\n            </Select>\n          </div>\n        </div>\n        <p aria-live=\"polite\" className=\"text-muted-foreground mt-4 text-sm\">\n          Showing {visible.length} of {shown.length} {shown.length === 1 ? \"review\" : \"reviews\"}\n        </p>\n\n        {shown.length ? (\n          <ul className=\"mt-4 grid gap-4\">\n            {visible.map((r) => {\n              const long = r.body.length > 220\n              const expanded = open.has(r.id)\n              const mine = voted.has(r.id)\n              return (\n                <li key={r.id} className=\"bg-card rounded-2xl border p-5 sm:p-6\">\n                  <article aria-labelledby={`${id}-${r.id}`}>\n                    <div className=\"flex items-start gap-3.5\">\n                      <span aria-hidden=\"true\" className=\"relative flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-full border text-sm font-semibold\">\n                        <CoverArt seed={r.author} variant=\"mesh\" className=\"absolute inset-0\" />\n                        <span className=\"relative\">{r.author.split(\" \").map((w) => w[0]).join(\"\").slice(0, 2)}</span>\n                      </span>\n                      <div className=\"min-w-0 flex-1\">\n                        <p className=\"flex flex-wrap items-center gap-x-2 gap-y-0.5\">\n                          <span id={`${id}-${r.id}`} className=\"font-semibold\">\n                            {r.author}\n                          </span>\n                          {r.verified ? (\n                            <span className=\"text-muted-foreground inline-flex items-center gap-1 text-xs\">\n                              <BadgeCheck aria-hidden=\"true\" className=\"size-3.5 text-sky-600 dark:text-sky-400\" />\n                              Verified customer\n                            </span>\n                          ) : null}\n                        </p>\n                        {r.context ? <p className=\"text-muted-foreground text-xs\">{r.context}</p> : null}\n                      </div>\n                      <p className=\"text-muted-foreground shrink-0 text-xs\">{r.date}</p>\n                    </div>\n                    <p className=\"mt-4 flex items-center gap-2.5\">\n                      <Stars value={r.rating} />\n                      <span className=\"sr-only\">{r.rating} out of 5 stars</span>\n                      {r.title ? <span className=\"font-semibold tracking-tight\">{r.title}</span> : null}\n                    </p>\n                    <p className={cn(\"text-muted-foreground mt-2 text-sm leading-relaxed text-pretty\", long && !expanded && \"line-clamp-3\")}>{r.body}</p>\n                    {long ? (\n                      <button type=\"button\" aria-expanded={expanded} onClick={() => setOpen(toggle(open, r.id))} className=\"focus-visible:ring-ring/50 mt-1.5 inline-flex items-center gap-1 rounded-sm text-sm font-medium outline-none focus-visible:ring-[3px]\">\n                        {expanded ? \"Show less\" : \"Read more\"}\n                        <ChevronDown aria-hidden=\"true\" className={cn(\"size-4 transition-transform motion-reduce:transition-none\", expanded && \"rotate-180\")} />\n                      </button>\n                    ) : null}\n                    {r.reply ? (\n                      <div className=\"bg-muted/50 mt-4 rounded-xl border-s-2 p-4 ps-5 text-sm\">\n                        <p className=\"font-medium\">\n                          {r.reply.from} <span className=\"text-muted-foreground font-normal\">replied · {r.reply.date}</span>\n                        </p>\n                        <p className=\"text-muted-foreground mt-1 text-pretty\">{r.reply.body}</p>\n                      </div>\n                    ) : null}\n                    <div className=\"mt-4 flex items-center justify-between gap-3\">\n                      <Button\n                        variant={mine ? \"secondary\" : \"outline\"}\n                        size=\"sm\"\n                        aria-pressed={mine}\n                        onClick={() => {\n                          setVoted(toggle(voted, r.id))\n                          onHelpful?.(r, !mine)\n                        }}\n                      >\n                        <ThumbsUp aria-hidden=\"true\" className={cn(mine && \"fill-current\")} />\n                        Helpful <span className=\"tabular-nums\">· {(r.helpful ?? 0) + (mine ? 1 : 0)}</span>\n                      </Button>\n                    </div>\n                  </article>\n                </li>\n              )\n            })}\n          </ul>\n        ) : (\n          <div className=\"mt-4 rounded-2xl border border-dashed px-6 py-14 text-center\">\n            <p className=\"font-medium\">No reviews match</p>\n            <p className=\"text-muted-foreground mt-1 text-sm\">Try another star count, or include customers who are not verified.</p>\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              className=\"mt-4\"\n              onClick={() => {\n                setStar(null)\n                setVerified(false)\n              }}\n            >\n              Clear filters\n            </Button>\n          </div>\n        )}\n        {shown.length > limit ? (\n          <div className=\"mt-6 text-center\">\n            <Button variant=\"outline\" onClick={() => setLimit((l) => l + 4)}>\n              Show more reviews\n            </Button>\n          </div>\n        ) : null}\n      </div>\n    </div>\n  )\n\n  return (\n    <section data-slot=\"reviews-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-6xl px-4 py-14 sm:px-6 sm:py-20\", className)} {...frameProps(props)}>\n      <h2 id={`${id}-title`} className=\"mb-10 text-4xl font-semibold tracking-[-0.04em] text-balance\">\n        {title}\n      </h2>\n      <StateView state={state === \"loading\" || state === \"error\" ? state : \"ready\"} onRetry={onRetry} label=\"Loading reviews\" loading={<Skel />} error={{ title: errorState?.title ?? \"Reviews could not be loaded\", description: errorState?.description ?? \"Check your connection and try again.\", ...errorState }}>\n        {content}\n      </StateView>\n    </section>\n  )\n}\n\nexport { Reviews1, type Reviews1Props, type Reviews1Review }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/reviews-1/reviews-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "reviews",
      "ratings",
      "stars",
      "customer feedback",
      "testimonials",
      "social proof",
      "verified",
      "product reviews"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "Customer reviews. <Reviews1 reviews={[{ id, author, rating: 1..5, title, body, date: '14 October 2026', iso: '2026-10-14', verified, helpful, context, reply: { from, body, date } }]} onWrite onHelpful={(review, helpful) => ...} />. The average, the bar for each star count and the percentage of 4 and 5 stars are worked out from the reviews. Clicking a bar filters by that star count.",
      "whenToUse": [
        "A product or service page with customer reviews",
        "A reviews section under pricing or a store listing"
      ],
      "whenNotToUse": [
        "A few hand-picked quotes (use testimonials-1 or testimonials-2)",
        "A customer story with results (use casestudy-1)"
      ],
      "composesWith": [
        "testimonials-1",
        "pricing-1",
        "faq-1",
        "cta-1"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Star-count buttons (each says its count and whether it is on), the verified switch, sort menu, then per review Read more and Helpful"
        },
        {
          "keys": "Stars",
          "action": "Drawn stars are hidden from assistive technology and replaced by '4 out of 5 stars' in text"
        },
        {
          "keys": "Count",
          "action": "'Showing 4 of 8 reviews' is announced politely as filters change"
        }
      ],
      "customization": [
        "Sort options: most helpful, newest, highest and lowest rated",
        "Read more appears for bodies over about 220 characters",
        "A business reply sits under its review with a rule on the reading edge, so it mirrors in right-to-left"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "reviews-1-loading",
        "trigger": "state=\"loading\"",
        "note": "A skeleton summary and review cards under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No reviews yet",
        "example": "reviews-1-empty",
        "trigger": "state=\"empty\" (or reviews={[]})",
        "note": "Invites the first review, with a Write a review button when onWrite is given. Filters with no match say so in place."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "reviews-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert with Try again (onRetry)."
      }
    ],
    "examples": [
      "reviews-1-demo",
      "reviews-1-loading",
      "reviews-1-empty",
      "reviews-1-error"
    ],
    "url": "https://ui.ballmac.com/components/reviews-1"
  },
  "categories": [
    "blocks",
    "reviews"
  ],
  "type": "registry:block"
}