Skip to contentVibraUI
Dashboards

Reviews

What buyers wrote, read one way for every band that shows reviews: each review joined to its product, signed with a first name and an initial beside the author's face, and the summary a band prints — the mean, the spread of stars as counts and as shares that add up to 100, and who would recommend it.

Server-only, since it reads db.reviews: a section reads the rows in its own data file and hands them to its island, each a plain object with its date as an ISO string. A review is one buyer's stars and words for one order line that reached them (sample-data's reviews entity), so every row carries a real product, a real customer's face and a real delivery before it. summarizeReviews is storefront-ecommerce's own summarize over the same stars — the mean and the counts a product card prints — with the shares rounded by largest remainder so they always add up to 100, and recommend counting the fours and fives. Swap db.reviews for your reviews table and every band that lists or scores reviews follows.

Install

npx shadcn@latest add @vibra/reviews-ecommerce

Needs the @vibra registry in your components.json — set it up once.

Props

PropTypeDefaultDescription
storeReviews() => ReviewRow[]—Every review, the newest first: { id, product: { id, name, category, href, active }, author: { name, shortName, avatarUrl }, rating, title, body, at (ISO), verified, helpful }.
reviewsOf(productId: string) => ReviewRow[]—One product's reviews, the newest first.
summarizeReviews(rows: { rating }[]) => ReviewSummary—{ average, count, byStars (five to one), shares (whole percentages adding up to 100), recommend (the share of fours and fives) }; all zeros for no reviews.
sharesOf(counts: number[]) => number[]—Whole percentages that add up to exactly 100, the points left over going to the largest remainders.
shortName(name: string) => string—"Ada Lovelace" → "Ada L.": how a review is signed.

Dependencies

Source

lib/dashboards/ecommerce/reviews.ts
/**
 * What buyers wrote, read one way for every band that shows reviews: each
 * review of `db.reviews` joined to the product it is of, its author as a band
 * signs them (a first name and an initial) with their face, and its date as
 * an ISO string an island can take; and the summary a band prints — the mean,
 * the count, how many gave each star, those as shares that add up to 100, and
 * how many would recommend it (four stars or five). The mean and the counts
 * are the storefront's own `summarize` over the same reviews its scores are
 * read from (`storefront-ratings`), so a reviews band and a product card
 * never disagree.
 *
 * Server-only, because it reads `db`: a section reads it in its own data
 * file and hands the rows to its island as props.
 */
import { productHref } from "@/lib/dashboards/ecommerce/storefront"
import { summarize, type RatingSummary } from "@/lib/dashboards/ecommerce/storefront-ratings"
import { db, type Review } from "@/lib/sample-data"

/** A review as a band shows it. */
export type ReviewRow = {
  id: string
  product: { id: string; name: string; category: string; href: string; active: boolean }
  author: { name: string; shortName: string; avatarUrl: string }
  rating: number
  title: string
  body: string
  /** When it was written, as an ISO string. */
  at: string
  verified: boolean
  helpful: number
}

/** "Ada Lovelace" → "Ada L.": how a review is signed on the page. */
export function shortName(name: string): string {
  const [first, ...rest] = name.trim().split(/\s+/)
  const last = rest.at(-1)
  return last ? `${first} ${last.charAt(0)}.` : first
}

type Shelf = Map<string, { name: string; category: string; status: string }>

function toRow(review: Review, products: Shelf): ReviewRow {
  const product = products.get(review.productId)
  return {
    id: review.id,
    product: {
      id: review.productId,
      name: product?.name ?? "A product",
      category: product?.category ?? "",
      href: productHref(review.productId),
      active: product?.status === "active",
    },
    author: { name: review.author.name, shortName: shortName(review.author.name), avatarUrl: review.author.avatarUrl },
    rating: review.rating,
    title: review.title,
    body: review.body,
    at: review.at.toISOString(),
    verified: review.verified,
    helpful: review.helpful,
  }
}

/** Every review, the newest first; a tie goes to the id. */
export function storeReviews(): ReviewRow[] {
  const products: Shelf = new Map(db.products.all().map((product) => [product.id, product]))
  return db.reviews
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime() || a.id.localeCompare(b.id))
    .map((review) => toRow(review, products))
}

/** One product's reviews, the newest first. */
export function reviewsOf(productId: string): ReviewRow[] {
  return storeReviews().filter((row) => row.product.id === productId)
}

/** A run of reviews as a band sums it up: the storefront's summary, with shares and who would recommend. */
export type ReviewSummary = RatingSummary & {
  /** `byStars` as whole percentages that add up to 100 (all 0 with no reviews). */
  shares: [number, number, number, number, number]
  /** The share, as a whole percentage, who gave four stars or five. */
  recommend: number
}

/**
 * Whole percentages that add up to exactly 100: each count's share rounded
 * down, then the points left over given to the largest remainders, a tie to
 * the earlier count (the higher star).
 */
export function sharesOf(counts: readonly number[]): number[] {
  const total = counts.reduce((sum, count) => sum + count, 0)
  if (total === 0) return counts.map(() => 0)
  const exact = counts.map((count) => (count * 100) / total)
  const shares = exact.map(Math.floor)
  let left = 100 - shares.reduce((sum, share) => sum + share, 0)
  const order = exact
    .map((share, index) => ({ index, rest: share - Math.floor(share) }))
    .sort((a, b) => b.rest - a.rest || a.index - b.index)
  for (const { index } of order) {
    if (left === 0) break
    shares[index] += 1
    left -= 1
  }
  return shares
}

/** The summary of a run of reviews. */
export function summarizeReviews(rows: readonly Pick<ReviewRow, "rating">[]): ReviewSummary {
  const summary = summarize(rows.map((row) => row.rating))
  const [five, four] = summary.byStars
  return {
    ...summary,
    shares: sharesOf(summary.byStars) as ReviewSummary["shares"],
    recommend: summary.count ? Math.round(((five + four) * 100) / summary.count) : 0,
  }
}