{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "review-summary",
  "title": "Review Summary",
  "author": "ecomcn <hello@ecomcn.dev>",
  "description": "The reviews section: the average set large, a star histogram whose rows are buttons that filter the list (and read out as \"5 stars, 226 reviews, 72%\"), a fit scale with its reading in words, and reviews with a Verified buyer badge. The active row's rule slides between rows, bars grow to their share, and the list grows to its measured height as it filters — Motion, a fade under reduced motion. Takes a page of reviews plus overall counts, and reports the filter so you can fetch server-side. Compound, through useReviewSummary().",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "badge"
  ],
  "files": [
    {
      "path": "src/registry/ecomcn/review-summary/review-summary.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Check, Star } from \"lucide-react\"\nimport { AnimatePresence, MotionConfig, motion, useReducedMotion } from \"motion/react\"\n\nimport { Badge } from \"@/components/ui/badge\"\nimport { Button } from \"@/components/ui/button\"\nimport { cn } from \"@/lib/utils\"\n\n/*\n * The reviews section of a product page: the average, a star histogram that\n * filters the list, a fit scale, and the reviews themselves.\n *\n *   <ReviewSummary reviews={reviews} />\n *\n *   <ReviewSummary reviews={reviews} distribution={[4, 3, 18, 61, 226]}>   or composed\n *     <ReviewSummaryScore />\n *     <ReviewSummaryHistogram />\n *     <ReviewSummaryFit />\n *     <ReviewSummaryList />\n *   </ReviewSummary>\n *\n * Every histogram row is a button: it filters the list to that rating, and\n * says so in words. The fit scale has a text equivalent (\"Runs a little\n * small\") on screen, not only a marker. Motion does the moving: the active\n * row's rule slides between rows, the list grows and shrinks to its measured\n * height as it filters, and reviews fade in and out — a fade alone under\n * reduced motion. `useReviewSummary()` reads the shared state.\n */\n\nexport interface Review {\n  id: string\n  /** 1 to 5. */\n  rating: number\n  title?: string\n  body: React.ReactNode\n  author: string\n  /** ISO date, \"2026-09-14\". Shown as a calendar date, the same in every time zone. */\n  date: string\n  /** Bought it: shown as a \"Verified buyer\" badge. */\n  verified?: boolean\n  /** -1 runs small, 0 true to size, 1 runs large. */\n  fit?: number\n  /** What they bought: \"Tan · EU 41\". */\n  variant?: string\n}\n\n/** Review counts per rating, 1 star first: `[4, 3, 18, 61, 226]`. */\nexport type ReviewDistribution = [number, number, number, number, number]\n\n/** The same settle curve as the other ecomcn morphs: no bounce, an editorial ease. */\nconst MORPH = { duration: 0.45, ease: [0.2, 0, 0, 1] } as const\n\n/** The words for a fit score, from -1 (small) to 1 (large). */\nfunction defaultFitLabel(fit: number) {\n  if (fit <= -0.6) return \"Runs small\"\n  if (fit <= -0.2) return \"Runs a little small\"\n  if (fit < 0.2) return \"True to size\"\n  if (fit < 0.6) return \"Runs a little large\"\n  return \"Runs large\"\n}\n\nconst subscribeNothing = () => () => {}\n\n/**\n * The locale to format with. Yours when you pass one. Otherwise the\n * visitor's — but only once hydrated: the server cannot know it, and its HTML\n * (\"Sep 21, 2026\") must match the first client render, not the browser's\n * \"21 Sept 2026\". Until then, en-US.\n */\nfunction useFormatLocale(locale?: string) {\n  const hydrated = React.useSyncExternalStore(subscribeNothing, () => true, () => false)\n  return locale ?? (hydrated ? undefined : \"en-US\")\n}\n\nfunction useControllableState<T>(\n  value: T | undefined,\n  defaultValue: T,\n  onChange?: (next: T) => void\n) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultValue)\n  const controlled = value !== undefined\n  const set = React.useCallback(\n    (next: T) => {\n      if (!controlled) setUncontrolled(next)\n      onChange?.(next)\n    },\n    [controlled, onChange]\n  )\n  return [controlled ? value : uncontrolled, set] as const\n}\n\n/* ─── context ──────────────────────────────────────────────────────────── */\n\nexport interface ReviewSummaryContextValue {\n  reviews: Review[]\n  /** The reviews under the current filter. */\n  filtered: Review[]\n  distribution: ReviewDistribution\n  total: number\n  /** 0 when there are no reviews. */\n  average: number\n  /** Mean fit, -1 to 1; undefined when no review rated the fit. */\n  fit?: number\n  fitCount: number\n  fitLabel: (fit: number) => string\n  /** The rating the list is filtered to, or null for all. */\n  rating: number | null\n  setRating: (rating: number | null) => void\n  /** How many of `filtered` are on show; \"Show more\" raises it. */\n  visible: number\n  showMore: () => void\n  /** The locale formatting uses: yours, or the visitor's once hydrated. */\n  locale?: string\n  /** Formats a 0–5 score for the locale: \"4.6\". */\n  formatScore: (score: number) => string\n  formatDate: (iso: string) => string\n  loading: boolean\n}\n\nconst ReviewSummaryContext = React.createContext<ReviewSummaryContextValue | null>(null)\n\nexport function useReviewSummary() {\n  const context = React.useContext(ReviewSummaryContext)\n  if (!context) {\n    throw new Error(\"useReviewSummary must be used within <ReviewSummary>.\")\n  }\n  return context\n}\n\n/* ─── root ─────────────────────────────────────────────────────────────── */\n\nexport interface ReviewSummaryProps\n  extends Omit<React.HTMLAttributes<HTMLElement>, \"title\"> {\n  reviews: Review[]\n  /**\n   * Counts per rating across every review, when `reviews` is one page of\n   * many. Defaults to counting `reviews`.\n   */\n  distribution?: ReviewDistribution\n  /** Defaults to the mean of `distribution`. */\n  average?: number\n  /** Overall fit, -1 to 1. Defaults to the mean of the reviews' `fit`. */\n  fit?: number\n  /** \"Runs a little small\" for a score — your words, or another language. */\n  fitLabel?: (fit: number) => string\n  /** The rating to filter to, or null for all. */\n  rating?: number | null\n  defaultRating?: number | null\n  /** Fetch that rating's reviews here when they are paginated server-side. */\n  onRatingChange?: (rating: number | null) => void\n  /** Reviews shown before \"Show more\". Defaults to 4. */\n  pageSize?: number\n  title?: React.ReactNode\n  /** Under the score: a \"Write a review\" link, say. */\n  action?: React.ReactNode\n  locale?: string\n  loading?: boolean\n}\n\nexport function ReviewSummary({\n  reviews,\n  distribution: distributionProp,\n  average: averageProp,\n  fit: fitProp,\n  fitLabel = defaultFitLabel,\n  rating: ratingProp,\n  defaultRating = null,\n  onRatingChange,\n  pageSize = 4,\n  title = \"Reviews\",\n  action,\n  locale: localeProp,\n  loading = false,\n  className,\n  children,\n  ...props\n}: ReviewSummaryProps) {\n  const locale = useFormatLocale(localeProp)\n  const [rating, setRatingState] = useControllableState(ratingProp, defaultRating, onRatingChange)\n  const [visible, setVisible] = React.useState(pageSize)\n  const headingId = React.useId()\n\n  // A new filter starts from the first page again.\n  const setRating = React.useCallback(\n    (next: number | null) => {\n      setVisible(pageSize)\n      setRatingState(next)\n    },\n    [pageSize, setRatingState]\n  )\n\n  const context = React.useMemo<ReviewSummaryContextValue>(() => {\n    const distribution =\n      distributionProp ??\n      reviews.reduce<ReviewDistribution>(\n        (counts, review) => {\n          const star = Math.min(5, Math.max(1, Math.round(review.rating)))\n          counts[star - 1] += 1\n          return counts\n        },\n        [0, 0, 0, 0, 0]\n      )\n    const total = distribution.reduce((sum, n) => sum + n, 0)\n    const average =\n      averageProp ??\n      (total ? distribution.reduce((sum, n, i) => sum + n * (i + 1), 0) / total : 0)\n    const fits = reviews.map((r) => r.fit).filter((f): f is number => typeof f === \"number\")\n    const fit = fitProp ?? (fits.length ? fits.reduce((a, b) => a + b, 0) / fits.length : undefined)\n    const score = new Intl.NumberFormat(locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 })\n    // UTC: \"2026-09-14\" is a calendar date, and must not become the 13th\n    // west of Greenwich — or differ between the server and the browser.\n    const date = new Intl.DateTimeFormat(locale, { dateStyle: \"medium\", timeZone: \"UTC\" })\n\n    return {\n      reviews,\n      filtered: rating === null ? reviews : reviews.filter((r) => Math.round(r.rating) === rating),\n      distribution,\n      total,\n      average,\n      fit,\n      fitCount: fits.length,\n      fitLabel,\n      rating,\n      setRating,\n      visible,\n      showMore: () => setVisible((v) => v + pageSize),\n      locale,\n      formatScore: (n) => score.format(n),\n      formatDate: (iso) => {\n        const parsed = new Date(iso)\n        return Number.isNaN(parsed.getTime()) ? iso : date.format(parsed)\n      },\n      loading,\n    }\n  }, [reviews, distributionProp, averageProp, fitProp, fitLabel, rating, setRating, visible, pageSize, locale, loading])\n\n  return (\n    <ReviewSummaryContext.Provider value={context}>\n      <MotionConfig reducedMotion=\"user\">\n        <section\n          aria-labelledby={headingId}\n          aria-busy={loading || undefined}\n          data-slot=\"review-summary\"\n          className={cn(\"w-full\", className)}\n          {...props}\n        >\n          <h2 id={headingId} className=\"border-b pb-4 text-2xl\">\n            {title}\n          </h2>\n          {children ?? (\n            <div className=\"grid gap-10 pt-8 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] md:gap-14\">\n              <div className=\"flex flex-col gap-8\">\n                <ReviewSummaryScore />\n                <ReviewSummaryHistogram />\n                <ReviewSummaryFit />\n                {action}\n              </div>\n              <ReviewSummaryList />\n            </div>\n          )}\n        </section>\n      </MotionConfig>\n    </ReviewSummaryContext.Provider>\n  )\n}\n\n/* ─── parts ────────────────────────────────────────────────────────────── */\n\n/** Five stars, filled to the score; partial stars are clipped, not rounded. */\nfunction Stars({ score, className }: { score: number; className?: string }) {\n  return (\n    <span aria-hidden data-slot=\"review-summary-stars\" className={cn(\"relative inline-flex\", className)}>\n      <span className=\"flex gap-0.5 text-border\">\n        {Array.from({ length: 5 }, (_, i) => (\n          <Star key={i} className=\"size-[1em] fill-current\" />\n        ))}\n      </span>\n      <span\n        className=\"absolute inset-y-0 left-0 flex gap-0.5 overflow-hidden text-foreground\"\n        style={{ width: `${(Math.min(5, Math.max(0, score)) / 5) * 100}%` }}\n      >\n        {Array.from({ length: 5 }, (_, i) => (\n          <Star key={i} className=\"size-[1em] shrink-0 fill-current\" />\n        ))}\n      </span>\n    </span>\n  )\n}\n\n/** The average, large — the number is the headline — and what it is based on. */\nexport function ReviewSummaryScore({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {\n  const { average, total, formatScore, locale, loading } = useReviewSummary()\n  if (loading) {\n    return (\n      <div aria-hidden className={cn(\"flex flex-col gap-3\", className)}>\n        <span className=\"h-14 w-24 animate-pulse bg-muted\" />\n        <span className=\"h-3 w-32 animate-pulse bg-muted\" />\n      </div>\n    )\n  }\n  const count = new Intl.NumberFormat(locale).format(total)\n  return (\n    <div data-slot=\"review-summary-score\" className={cn(\"flex flex-col gap-3\", className)} {...props}>\n      {total ? (\n        <>\n          <p className=\"flex items-baseline gap-3\">\n            <span className=\"text-[60px] leading-none tracking-tight tabular-nums\">{formatScore(average)}</span>\n            <span className=\"text-sm text-muted-foreground\">out of 5</span>\n          </p>\n          <div className=\"flex items-center gap-3 text-sm text-muted-foreground\">\n            <Stars score={average} className=\"text-base\" />\n            <span className=\"tabular-nums\">\n              {count} {total === 1 ? \"review\" : \"reviews\"}\n            </span>\n          </div>\n        </>\n      ) : (\n        <>\n          <p className=\"text-2xl\">No reviews yet.</p>\n          <p className=\"max-w-xs text-sm leading-relaxed text-muted-foreground\">\n            Bought one? Yours would be the first — and the one the next buyer reads.\n          </p>\n        </>\n      )}\n    </div>\n  )\n}\n\n/**\n * One row per rating, 5 to 1. Each row is a button that filters the list\n * and reads out as \"5 stars, 226 reviews, 72%\". The active row carries a\n * rule that slides from row to row.\n */\nexport function ReviewSummaryHistogram({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {\n  const { distribution, total, rating, setRating, locale, loading } = useReviewSummary()\n  const reduce = useReducedMotion()\n  const scope = React.useId()\n  if (!total && !loading) return null\n  const count = new Intl.NumberFormat(locale)\n  const percent = new Intl.NumberFormat(locale, { style: \"percent\" })\n\n  return (\n    <div\n      role=\"group\"\n      aria-label=\"Filter reviews by rating\"\n      data-slot=\"review-summary-histogram\"\n      className={cn(\"flex flex-col\", className)}\n      {...props}\n    >\n      {[5, 4, 3, 2, 1].map((star) => {\n        const n = distribution[star - 1]\n        const share = total ? n / total : 0\n        const active = rating === star\n        return (\n          <Button\n            key={star}\n            type=\"button\"\n            variant=\"ghost\"\n            aria-pressed={active}\n            disabled={loading || n === 0}\n            onClick={() => setRating(active ? null : star)}\n            className=\"relative h-9 w-full justify-start gap-3 rounded-none px-2 font-normal text-foreground hover:bg-secondary disabled:opacity-40\"\n          >\n            {active ? (\n              <motion.span\n                aria-hidden\n                layoutId={reduce ? undefined : `${scope}-active`}\n                initial={reduce ? { opacity: 0 } : false}\n                animate={{ opacity: 1 }}\n                transition={reduce ? { duration: 0.15 } : MORPH}\n                className=\"absolute inset-y-1 left-0 w-0.5 bg-foreground\"\n              />\n            ) : null}\n            <span className=\"flex w-8 shrink-0 items-center gap-1 tabular-nums\">\n              {star}\n              <Star aria-hidden className=\"size-3 fill-current\" />\n            </span>\n            <span aria-hidden className=\"relative h-1.5 min-w-0 flex-1 overflow-hidden bg-secondary\">\n              <motion.span\n                className=\"absolute inset-0 origin-left bg-foreground\"\n                initial={false}\n                animate={{ scaleX: loading ? 0 : share }}\n                transition={reduce ? { duration: 0 } : MORPH}\n              />\n            </span>\n            <span aria-hidden className=\"w-12 shrink-0 text-right text-xs text-muted-foreground tabular-nums\">\n              {loading ? \"\" : count.format(n)}\n            </span>\n            <span className=\"sr-only\">\n              {`${star} ${star === 1 ? \"star\" : \"stars\"}, ${count.format(n)} ${n === 1 ? \"review\" : \"reviews\"}, ${percent.format(share)}`}\n            </span>\n          </Button>\n        )\n      })}\n    </div>\n  )\n}\n\n/** Which of the five steps, small to large, a fit score falls in — as the words do. */\nfunction fitStep(fit: number) {\n  if (fit <= -0.6) return 0\n  if (fit <= -0.2) return 1\n  if (fit < 0.2) return 2\n  if (fit < 0.6) return 3\n  return 4\n}\n\n/**\n * How the fit runs, as reviewers reported it: five fixed steps from small to\n * large with the one it lands on filled, and the reading in words above.\n * A gauge, not a control — no handle to grab, nothing to drag. Screen\n * readers get it as a meter with the words as its value.\n */\nexport function ReviewSummaryFit({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {\n  const { fit, fitCount, fitLabel, locale, loading } = useReviewSummary()\n  const reduce = useReducedMotion()\n  const scope = React.useId()\n  if (fit === undefined || loading) return null\n  const clamped = Math.min(1, Math.max(-1, fit))\n  const label = fitLabel(clamped)\n  const step = fitStep(clamped)\n  const ratings = `${new Intl.NumberFormat(locale).format(fitCount)} ${fitCount === 1 ? \"rating\" : \"ratings\"}`\n\n  return (\n    <div data-slot=\"review-summary-fit\" className={cn(\"flex flex-col gap-3\", className)} {...props}>\n      <p className=\"flex items-baseline justify-between gap-4 text-sm\">\n        <span className=\"text-xs font-medium tracking-[0.14em] uppercase\">Fit</span>\n        <span>\n          {label}\n          <span className=\"text-muted-foreground\"> · {ratings}</span>\n        </span>\n      </p>\n      <div\n        role=\"meter\"\n        aria-label=\"Fit\"\n        aria-valuemin={-1}\n        aria-valuemax={1}\n        aria-valuenow={Number(clamped.toFixed(2))}\n        aria-valuetext={`${label}, from ${ratings}`}\n        className=\"grid grid-cols-5 gap-0.5\"\n      >\n        {[0, 1, 2, 3, 4].map((i) => (\n          <span key={i} className=\"relative h-1.5 bg-secondary\">\n            {i === step ? (\n              <motion.span\n                layoutId={reduce ? undefined : `${scope}-fit`}\n                initial={reduce ? { opacity: 0 } : false}\n                animate={{ opacity: 1 }}\n                transition={reduce ? { duration: 0.15 } : MORPH}\n                className=\"absolute inset-0 bg-foreground\"\n              />\n            ) : null}\n          </span>\n        ))}\n      </div>\n      <p aria-hidden className=\"grid grid-cols-5 text-[11px] tracking-wide text-muted-foreground\">\n        <span>Small</span>\n        <span className=\"col-start-3 text-center\">True to size</span>\n        <span className=\"col-start-5 text-right\">Large</span>\n      </p>\n    </div>\n  )\n}\n\n/** Follows its content's height, so the panel grows and shrinks rather than jumps. */\nfunction useMeasuredHeight() {\n  const ref = React.useRef<HTMLDivElement>(null)\n  const [height, setHeight] = React.useState<number | \"auto\">(\"auto\")\n  React.useLayoutEffect(() => {\n    const node = ref.current\n    if (!node) return\n    const observer = new ResizeObserver(([entry]) => setHeight(entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight))\n    observer.observe(node)\n    return () => observer.disconnect()\n  }, [])\n  return [ref, height] as const\n}\n\n/**\n * The reviews, filtered by the histogram. Filtering says what is showing and\n * offers the way back; the list grows to its new height with Motion.\n */\nexport function ReviewSummaryList({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {\n  const { filtered, total, rating, setRating, visible, showMore, loading } = useReviewSummary()\n  const reduce = useReducedMotion()\n  const [inner, height] = useMeasuredHeight()\n  const shown = filtered.slice(0, visible)\n\n  if (!total && !loading) return null\n\n  return (\n    <div data-slot=\"review-summary-list\" className={cn(\"min-w-0\", className)} {...props}>\n      <div className=\"flex min-h-9 flex-wrap items-center justify-between gap-3 border-b pb-3\">\n        <p aria-live=\"polite\" className=\"text-sm text-muted-foreground\">\n          {loading\n            ? \"Loading reviews…\"\n            : rating === null\n              ? `Showing ${shown.length} of ${filtered.length}`\n              : `${filtered.length} ${filtered.length === 1 ? \"review\" : \"reviews\"} with ${rating} ${rating === 1 ? \"star\" : \"stars\"}`}\n        </p>\n        {rating !== null ? (\n          <Button\n            type=\"button\"\n            variant=\"link\"\n            onClick={() => setRating(null)}\n            className=\"h-auto rounded-none p-0 text-sm text-foreground\"\n          >\n            Show all reviews\n          </Button>\n        ) : null}\n      </div>\n\n      <motion.div\n        initial={false}\n        animate={{ height }}\n        transition={reduce ? { duration: 0 } : MORPH}\n        className=\"overflow-hidden\"\n      >\n        <div ref={inner}>\n          {loading ? (\n            <ul aria-hidden>\n              {Array.from({ length: 3 }, (_, i) => (\n                <li key={i} className=\"flex flex-col gap-3 border-b py-6\">\n                  <span className=\"h-3 w-20 animate-pulse bg-muted\" />\n                  <span className=\"h-4 w-1/2 animate-pulse bg-muted\" />\n                  <span className=\"h-3 w-full animate-pulse bg-muted\" />\n                  <span className=\"h-3 w-4/5 animate-pulse bg-muted\" />\n                </li>\n              ))}\n            </ul>\n          ) : (\n            <ul>\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                {shown.map((review) => (\n                  <motion.li\n                    key={review.id}\n                    layout={reduce ? false : \"position\"}\n                    initial={{ opacity: 0, y: reduce ? 0 : 8 }}\n                    animate={{ opacity: 1, y: 0 }}\n                    exit={{ opacity: 0 }}\n                    transition={reduce ? { duration: 0.15 } : MORPH}\n                    className=\"border-b\"\n                  >\n                    <ReviewSummaryItem review={review} />\n                  </motion.li>\n                ))}\n              </AnimatePresence>\n            </ul>\n          )}\n        </div>\n      </motion.div>\n\n      {!loading && shown.length < filtered.length ? (\n        <Button\n          type=\"button\"\n          variant=\"outline\"\n          onClick={showMore}\n          className=\"mt-6 h-11 w-full rounded-none text-xs font-medium tracking-[0.14em] uppercase shadow-none\"\n        >\n          Show more reviews\n        </Button>\n      ) : null}\n    </div>\n  )\n}\n\n/** One review: rating, title, body, then who and when — and whether they bought it. */\nexport function ReviewSummaryItem({\n  review,\n  className,\n  ...props\n}: { review: Review } & React.HTMLAttributes<HTMLElement>) {\n  const { formatDate, formatScore } = useReviewSummary()\n  return (\n    <article data-slot=\"review-summary-item\" className={cn(\"flex flex-col gap-3 py-6\", className)} {...props}>\n      <div className=\"flex items-center gap-3\">\n        <Stars score={review.rating} className=\"text-sm\" />\n        <span className=\"sr-only\">Rated {formatScore(review.rating)} out of 5</span>\n        {review.variant ? <span className=\"text-xs text-muted-foreground\">{review.variant}</span> : null}\n      </div>\n      {review.title ? <h3 className=\"text-base font-medium\">{review.title}</h3> : null}\n      <div className=\"max-w-prose text-sm leading-[1.7] text-muted-foreground [&_p+p]:mt-3\">{review.body}</div>\n      <p className=\"flex flex-wrap items-center gap-x-3 gap-y-2 text-xs text-muted-foreground\">\n        <span className=\"text-foreground\">{review.author}</span>\n        <time dateTime={review.date}>{formatDate(review.date)}</time>\n        {review.verified ? (\n          <Badge variant=\"outline\" className=\"rounded-none font-normal tracking-wide\">\n            <Check aria-hidden />\n            Verified buyer\n          </Badge>\n        ) : null}\n      </p>\n    </article>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/ecomcn/review-summary.tsx"
    }
  ],
  "categories": [
    "ecommerce",
    "product",
    "pdp",
    "reviews"
  ],
  "type": "registry:block"
}