/**
* 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,
}
}