Reviews
What buyers said: the average, the spread of stars and the reviews themselves.
The shop's score, star by star
reviews-01The shop's mean set large over its stars, how many reviews it is of, who would recommend and how many are verified; the spread of stars as bars that link to their reviews; and every department's own score.
import * as React from "react"
import Link from "next/link"
import { PenLineIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { buttonVariants } from "@/components/ui/button"
import { Rating } from "@/components/ui/rating"
import { RECENT_DAYS, storeScore } from "./reviews-01.data"
/** A mean as a score: one decimal, always. */
const score = (value: number) => formatNumber(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
const plural = (count: number, word: string) => `${formatNumber(count)} ${word}${count === 1 ? "" : "s"}`
/**
* The shop's score: the mean set large over its stars, how many reviews it is
* of, who would recommend what they bought and how many are verified; the
* spread of stars as bars, each with its count and its share (the shares add
* up to 100) and each a link to those reviews; and every department's own
* score beside it. On a phone the three stack.
*
* A server component: every figure comes from `./reviews-01.data`, which
* counts the reviews buyers wrote.
*/
export function Reviews01() {
const titleId = React.useId()
const { summary, departments, verified, recent } = storeScore()
return (
<section
data-section="reviews-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="flex flex-wrap items-end justify-between gap-x-6 gap-y-4">
<div className="flex max-w-xl flex-col gap-2">
<p className="type-eyebrow">Reviews</p>
<h2 id={titleId} className="type-display text-2xl md:text-3xl">
What buyers say
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
Written by the people who received what they ordered, low scores included. {plural(recent, "review")} came in over
the last {RECENT_DAYS} days.
</p>
</div>
<Link href="/reviews/new" className={cn(buttonVariants({ variant: "outline" }))}>
<PenLineIcon data-icon="inline-start" aria-hidden="true" />
Write a review
</Link>
</div>
<div className="mt-8 grid gap-4 md:mt-10 lg:grid-cols-[16rem_minmax(0,1fr)_minmax(0,1fr)]">
<div className="flex flex-col gap-3 frame p-5">
<p className="type-numeral text-5xl">{score(summary.average)}</p>
<Rating value={Math.round(summary.average)} aria-hidden="true" />
<p className="text-sm text-muted-foreground tabular-nums">
<span className="sr-only">Rated {score(summary.average)} out of 5, </span>from {plural(summary.count, "review")}
</p>
<dl className="mt-auto grid grid-cols-2 gap-3 border-t border-rule pt-4 text-sm">
<div className="flex flex-col-reverse gap-0.5">
<dt className="text-xs text-muted-foreground">would recommend</dt>
<dd className="text-lg font-semibold tabular-nums">{summary.recommend}%</dd>
</div>
<div className="flex flex-col-reverse gap-0.5">
<dt className="text-xs text-muted-foreground">verified buyers</dt>
<dd className="text-lg font-semibold tabular-nums">{verified}%</dd>
</div>
</dl>
</div>
<div className="panel p-5">
<h3 className="text-sm font-semibold">The spread of stars</h3>
<ul className="mt-4 flex flex-col gap-1">
{summary.byStars.map((count, index) => {
const stars = 5 - index
return (
<li key={stars}>
<Link
href={`/reviews?stars=${stars}`}
aria-label={`${stars} ${stars === 1 ? "star" : "stars"}: ${plural(count, "review")}, ${summary.shares[index]}%`}
className="grid grid-cols-[3.5rem_minmax(0,1fr)_4.5rem] items-center gap-3 rounded-md px-2 py-1.5 text-sm focus-ring hover:bg-muted"
>
<span className="tabular-nums">
{stars} {stars === 1 ? "star" : "stars"}
</span>
<span aria-hidden="true" className="h-2 overflow-hidden rounded-full bg-muted">
<span className="block h-full rounded-full bg-foreground" style={{ width: `${summary.shares[index]}%` }} />
</span>
<span className="text-right text-muted-foreground tabular-nums">
{formatNumber(count)} <span className="text-faint-foreground">· {summary.shares[index]}%</span>
</span>
</Link>
</li>
)
})}
</ul>
</div>
<div className="panel p-5">
<h3 className="text-sm font-semibold">By department</h3>
<ul className="mt-4 flex flex-col divide-y divide-border">
{departments.map((department) => (
<li key={department.name}>
<Link
href={department.href}
aria-label={`${department.name}: ${score(department.average)} from ${plural(department.count, "review")}`}
className="flex items-center justify-between gap-3 rounded-md px-2 py-2 text-sm focus-ring hover:bg-muted"
>
<span className="font-medium">{department.name}</span>
<span className="flex items-center gap-2 text-muted-foreground tabular-nums">
<span className="font-semibold text-foreground">{score(department.average)}</span>
from {department.count}
</span>
</Link>
</li>
))}
</ul>
</div>
</div>
</div>
</section>
)
}
export default Reviews01/**
* What `reviews-01` reads: every review buyers wrote (`storeReviews`, from
* reviews-ecommerce), summed up for the whole shop — the mean, the spread of
* stars as counts and as shares that add up to 100, who would recommend what
* they bought — and for each department, and how many came in over the last
* 30 days before `REFERENCE_DATE`. Nothing is typed in: change the reviews
* and every figure follows.
*
* Selectors only, reading the store inside functions and run on the server.
*/
import { storeReviews, summarizeReviews, type ReviewSummary } from "@/lib/dashboards/ecommerce/reviews"
import { departmentHref } from "@/lib/dashboards/ecommerce/storefront"
import { daysAgo } from "@/lib/sample-data"
/** How far back "lately" reaches, in days. */
export const RECENT_DAYS = 30
export type DepartmentScore = { name: string; href: string; average: number; count: number }
/** The shop's summary, its departments' scores, and how many reviews are verified and recent. */
export function storeScore(): { summary: ReviewSummary; departments: DepartmentScore[]; verified: number; recent: number } {
const rows = storeReviews()
const names = [...new Set(rows.map((row) => row.product.category))].sort((a, b) => a.localeCompare(b))
const departments = names.map((name) => {
const own = summarizeReviews(rows.filter((row) => row.product.category === name))
return { name, href: departmentHref(name), average: own.average, count: own.count }
})
const since = daysAgo(RECENT_DAYS).getTime()
return {
summary: summarizeReviews(rows),
departments,
verified: rows.length ? Math.round((rows.filter((row) => row.verified).length * 100) / rows.length) : 0,
recent: rows.filter((row) => Date.parse(row.at) > since).length,
}
}npx shadcn@latest add @vibra/reviews-01Review feed with filters and helpful votes
reviews-02The newest reviews across the shop: filter by stars with a count on each score, keep to verified buyers, sort four ways, vote a review helpful once, and page through six at a time.
import * as React from "react"
import { ReviewFeed } from "./reviews-02.client"
import { reviewFeed } from "./reviews-02.data"
/**
* The newest reviews across the shop as a feed: filter by stars (each score
* with its count), keep to verified buyers, sort four ways, vote a review
* helpful once and take the vote back, and page through six at a time. Every
* review carries its author's face, its date, its stars in words, whether the
* purchase is verified and the product it is about.
*
* A server component: the rows come from `./reviews-02.data`, and the island
* in `./reviews-02.client` does the narrowing.
*/
export function Reviews02() {
const titleId = React.useId()
const reviews = reviewFeed()
return (
<section
data-section="reviews-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="mx-auto max-w-4xl">
<div className="flex max-w-2xl flex-col gap-2">
<p className="type-eyebrow">Reviews</p>
<h2 id={titleId} className="type-display text-2xl md:text-3xl">
Latest reviews
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
The last {reviews.length} reviews buyers wrote, whatever they gave. Narrow them to one score, or to buyers whose order we
could match.
</p>
</div>
<ReviewFeed reviews={reviews} />
</div>
</div>
</section>
)
}
export default Reviews02"use client"
import * as React from "react"
import Link from "next/link"
import { BadgeCheckIcon, ThumbsUpIcon } from "lucide-react"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { Rating } from "@/components/ui/rating"
import { Switch } from "@/components/ui/switch"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import type { ReviewRow } from "./reviews-02.data"
type Sort = "newest" | "helpful" | "highest" | "lowest"
const SORTS: { value: Sort; label: string; spoken: string }[] = [
{ value: "newest", label: "Newest", spoken: "newest first" },
{ value: "helpful", label: "Most helpful", spoken: "most helpful first" },
{ value: "highest", label: "Highest score", spoken: "highest score first" },
{ value: "lowest", label: "Lowest score", spoken: "lowest score first" },
]
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
const starsWord = (stars: number) => `${stars} ${stars === 1 ? "star" : "stars"}`
const newer = (a: ReviewRow, b: ReviewRow) => Date.parse(b.at) - Date.parse(a.at)
const ORDER: Record<Sort, (a: ReviewRow, b: ReviewRow) => number> = {
newest: newer,
helpful: (a, b) => b.helpful - a.helpful || newer(a, b),
highest: (a, b) => b.rating - a.rating || newer(a, b),
lowest: (a, b) => a.rating - b.rating || newer(a, b),
}
/** The chosen score wears the kit's selected fill and the 500 weight; focus is the kit Toggle's own outline. */
const TOGGLE = "aria-pressed:bg-brand-muted aria-pressed:font-medium"
/**
* The shop's newest reviews as a feed a reader can narrow: one star score at a
* time (each with how many it holds), verified buyers only, and four orders.
* Each review shows its author's face and name, whether the purchase is
* verified, its stars (said in words), its title and text, the product it is
* of (a link, or its name and "no longer sold" once it is off the shelf), and
* a Helpful button — one vote per reader, pressed again to take it
* back. It pages `pageSize` at a time, focus moving to the first new review;
* one status line says what is shown.
*/
export function ReviewFeed({ reviews, pageSize = 6 }: { reviews: ReviewRow[]; pageSize?: number }) {
const base = React.useId()
const [stars, setStars] = React.useState<number | null>(null)
const [verifiedOnly, setVerifiedOnly] = React.useState(false)
const [sort, setSort] = React.useState<Sort>("newest")
const [shown, setShown] = React.useState(pageSize)
const [voted, setVoted] = React.useState<ReadonlySet<string>>(new Set())
const firstNew = React.useRef<string | null>(null)
const listRef = React.useRef<HTMLOListElement>(null)
const pool = verifiedOnly ? reviews.filter((review) => review.verified) : reviews
const matching = pool.filter((review) => stars === null || review.rating === stars).sort(ORDER[sort])
const visible = matching.slice(0, shown)
const count = (value: number) => pool.filter((review) => review.rating === value).length
React.useEffect(() => {
if (!firstNew.current) return
listRef.current?.querySelector<HTMLElement>(`[data-review="${firstNew.current}"]`)?.focus()
firstNew.current = null
}, [shown])
const narrow = (apply: () => void) => {
apply()
setShown(pageSize)
}
function more() {
firstNew.current = matching[shown]?.id ?? null
setShown(shown + pageSize)
}
function vote(id: string) {
setVoted((current) => {
const next = new Set(current)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const kind = `${verifiedOnly ? "verified " : ""}reviews${stars === null ? "" : ` of ${starsWord(stars)}`}`
const spokenSort = SORTS.find((entry) => entry.value === sort)!.spoken
return (
<div className="mt-8 flex flex-col gap-5 md:mt-10">
<div className="flex flex-col gap-4 frame p-3 md:flex-row md:items-center md:justify-between">
<ToggleGroup
value={[stars === null ? "all" : String(stars)]}
onValueChange={(values) => {
const [value] = values as string[]
if (value) narrow(() => setStars(value === "all" ? null : Number(value)))
}}
aria-label="Filter by stars"
spacing={1}
className="flex-wrap"
>
<ToggleGroupItem value="all" size="sm" className={TOGGLE}>
All <span className="text-muted-foreground tabular-nums">{pool.length}</span>
</ToggleGroupItem>
{[5, 4, 3, 2, 1].map((value) => (
<ToggleGroupItem key={value} value={String(value)} size="sm" className={TOGGLE}>
{starsWord(value)} <span className="text-muted-foreground tabular-nums">{count(value)}</span>
</ToggleGroupItem>
))}
</ToggleGroup>
<div className="flex flex-wrap items-center gap-x-5 gap-y-3">
<div className="flex items-center gap-2">
<Switch id={`${base}verified`} checked={verifiedOnly} onCheckedChange={(on) => narrow(() => setVerifiedOnly(on))} />
<Label htmlFor={`${base}verified`} className="font-normal">
Verified buyers only
</Label>
</div>
<div className="flex items-center gap-2">
<Label htmlFor={`${base}sort`} className="font-normal text-muted-foreground">
Sort by
</Label>
<NativeSelect id={`${base}sort`} value={sort} onChange={(event) => narrow(() => setSort(event.target.value as Sort))} size="sm" className="[&_select]:bg-card">
{SORTS.map((entry) => (
<NativeSelectOption key={entry.value} value={entry.value}>
{entry.label}
</NativeSelectOption>
))}
</NativeSelect>
</div>
</div>
</div>
<p role="status" aria-live="polite" className="text-sm text-muted-foreground tabular-nums">
{matching.length === 0 ? `No ${kind} yet.` : `Showing ${visible.length} of ${matching.length} ${kind}, ${spokenSort}.`}
</p>
<ol ref={listRef} aria-label="Reviews" className="flex flex-col divide-y divide-border border-y border-border">
{visible.map((review) => {
const mine = voted.has(review.id)
return (
<li key={review.id}>
<article
data-review={review.id}
tabIndex={-1}
aria-labelledby={`${base}${review.id}`}
className="grid gap-3 py-6 outline-none focus-visible:bg-muted/40 sm:grid-cols-[11rem_minmax(0,1fr)] sm:gap-6"
>
<div className="flex items-start gap-3">
<Avatar>
<AvatarImage src={review.author.avatarUrl} alt="" />
<AvatarFallback>{getInitials(review.author.name)}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col gap-0.5 text-sm">
<span className="font-medium">{review.author.shortName}</span>
<time dateTime={review.at} className="text-xs text-muted-foreground">
{DAY.format(new Date(review.at))}
</time>
{review.verified ? (
<span className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground">
<BadgeCheckIcon aria-hidden="true" className="size-3.5 text-success" />
Verified purchase
</span>
) : null}
</div>
</div>
<div className="flex min-w-0 flex-col gap-2">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Rating value={review.rating} size="sm" aria-hidden="true" />
<span className="sr-only">Rated {review.rating} out of 5.</span>
<h3 id={`${base}${review.id}`} className="text-sm font-semibold">
{review.title}
</h3>
</div>
<p className="text-sm text-pretty text-muted-foreground">{review.body}</p>
<div className="mt-1 flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
About the{" "}
{review.product.active ? (
<Link href={review.product.href} className="rounded-sm font-medium text-foreground focus-ring hover:underline">
{review.product.name}
</Link>
) : (
<>
<span className="font-medium text-foreground">{review.product.name}</span>, no longer sold
</>
)}
</p>
<Button variant="outline" size="sm" aria-pressed={mine} onClick={() => vote(review.id)} className="aria-pressed:bg-brand-muted">
<ThumbsUpIcon data-icon="inline-start" aria-hidden="true" />
Helpful <span className="tabular-nums">{review.helpful + (mine ? 1 : 0)}</span>
</Button>
</div>
</div>
</article>
</li>
)
})}
</ol>
{visible.length < matching.length ? (
<Button variant="outline" className="self-center" onClick={more}>
Show {Math.min(pageSize, matching.length - visible.length)} more
</Button>
) : null}
</div>
)
}/**
* What `reviews-02` reads: the newest reviews across the shop
* (`storeReviews`, from reviews-ecommerce), each joined to the product it is
* of, with its author's face, whether it is verified and how many readers
* found it helpful. The band filters, sorts and pages through these; change
* `FEED_SIZE` to hand it more.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes these rows as props; it never imports this module.
*/
import { storeReviews, type ReviewRow } from "@/lib/dashboards/ecommerce/reviews"
export type { ReviewRow }
/** How many of the newest reviews the feed holds. */
export const FEED_SIZE = 60
/** The newest `FEED_SIZE` reviews, the newest first. */
export function reviewFeed(size = FEED_SIZE): ReviewRow[] {
return storeReviews().slice(0, size)
}npx shadcn@latest add @vibra/reviews-02Write a review with a live preview
reviews-03The products that reached a buyer and still wait for their word; the stars, a title, the review and how it is signed; and a preview of it beside the form, as buyers will see it.
import * as React from "react"
import { ProductArt } from "@/components/ui/product-art"
import { WriteReview } from "./reviews-03.client"
import { BODY_MAX, BODY_MIN, TITLE_MAX, WAITING_DAYS, reviewDesk } from "./reviews-03.data"
/**
* Write a review: the products that reached a buyer and are still waiting
* for their word, each with its drawing and the day it arrived; the stars, a
* title, the review and how it is signed; and a preview of it as buyers will
* see it, beside the form. Post checks every field and says, in a status
* line, that the review is in.
*
* A server component: the buyer and their products come from
* `./reviews-03.data`; it draws each product here and hands the island the
* drawings.
*/
export function Reviews03() {
const titleId = React.useId()
const { reviewer, waiting } = reviewDesk()
const arts = Object.fromEntries(
waiting.map((product) => [product.productId, <ProductArt key={product.productId} name={product.name} category={product.category} className="w-11 rounded-md" />])
)
return (
<section
data-section="reviews-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="flex max-w-2xl flex-col gap-2">
<p className="type-eyebrow">Your orders</p>
<h2 id={titleId} className="type-display text-2xl md:text-3xl">
Review what you bought
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
{waiting.length} {waiting.length === 1 ? "product" : "products"} that reached you over the last {WAITING_DAYS} days{" "}
{waiting.length === 1 ? "is" : "are"} still waiting for your word. A few honest lines help the next buyer choose, and tell us what
to fix.
</p>
</div>
<WriteReview reviewer={reviewer} waiting={waiting} arts={arts} limits={{ bodyMin: BODY_MIN, bodyMax: BODY_MAX, titleMax: TITLE_MAX }} />
</div>
</section>
)
}
export default Reviews03"use client"
import * as React from "react"
import { BadgeCheckIcon, CircleCheckIcon, UserRoundIcon } from "lucide-react"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Rating } from "@/components/ui/rating"
import { Textarea } from "@/components/ui/textarea"
import type { Reviewer, WaitingProduct } from "./reviews-03.data"
type Name = "product" | "rating" | "title" | "body"
type Draft = { product: string; rating: number; title: string; body: string; signAs: "name" | "buyer" }
type Limits = { bodyMin: number; bodyMax: number; titleMax: number }
/** The form's own order, which focus looks for the first refusal in. */
const ORDER: Name[] = ["product", "rating", "title", "body"]
const WORDS = ["No stars yet", "Poor", "Fair", "Good", "Very good", "Excellent"]
const ANONYMOUS = "A verified buyer"
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
const EMPTY = { product: "", rating: 0, title: "", body: "" }
const starsWord = (stars: number) => `${stars} ${stars === 1 ? "star" : "stars"}`
/** Why the draft cannot be posted yet, field by field, in words that do not change as the reader types. */
function refusals(draft: Draft, { bodyMin }: Limits): Partial<Record<Name, string>> {
const out: Partial<Record<Name, string>> = {}
if (!draft.product) out.product = "Choose the product you are reviewing."
if (!draft.rating) out.rating = "Choose from one to five stars."
if (!draft.title.trim()) out.title = "Give it a title: a few words that sum it up."
if (!draft.body.trim()) out.body = `Write your review: at least ${bodyMin} characters.`
else if (draft.body.trim().length < bodyMin) out.body = `Say a little more: at least ${bodyMin} characters.`
return out
}
/**
* Write a review: pick one of the products that reached you, give it one to
* five stars (the kit's Rating, from the keyboard or the pointer), a title
* and a few lines, and choose how it is signed. The preview beside the form
* shows it as buyers will see it, as you type. Post checks every field,
* refuses each in words tied to it with focus on the first, and — once it
* passes — says so in the status line, takes the product off the list and
* clears the form for the next one. It sends nothing.
*/
export function WriteReview({ reviewer, waiting, arts, limits }: { reviewer: Reviewer; waiting: WaitingProduct[]; arts: Record<string, React.ReactNode>; limits: Limits }) {
const base = React.useId()
const idOf = (name: string) => `${base}${name}`
const [left, setLeft] = React.useState(waiting)
const [draft, setDraft] = React.useState<Draft>({ ...EMPTY, signAs: "name" })
const [tried, setTried] = React.useState(false)
const [posted, setPosted] = React.useState("")
const doneRef = React.useRef<HTMLParagraphElement>(null)
const errors = tried ? refusals(draft, limits) : {}
const product = left.find((entry) => entry.productId === draft.product)
const signature = draft.signAs === "name" ? reviewer.shortName : ANONYMOUS
const length = draft.body.trim().length
React.useEffect(() => {
if (left.length === 0) doneRef.current?.focus()
}, [left.length])
const set = <K extends keyof Draft>(key: K, value: Draft[K]) => setDraft((current) => ({ ...current, [key]: value }))
function focus(name: Name) {
if (name === "product") document.getElementById(idOf(`product-${left[0]?.productId}`))?.focus()
else if (name === "rating") document.getElementById(idOf("rating"))?.querySelector<HTMLElement>("[tabindex='0']")?.focus()
else document.getElementById(idOf(name))?.focus()
}
function post(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const found = refusals(draft, limits)
const first = ORDER.find((name) => found[name])
setTried(true)
if (first || !product) {
setPosted("")
focus(first ?? "product")
return
}
setPosted(
`Thanks, ${reviewer.name.split(" ")[0]}. Your review of the ${product.name} is in: ${starsWord(draft.rating)}, “${draft.title.trim()}”. It shows on the product's page, signed ${signature}, once we have read it — usually within a day.`
)
setLeft((current) => current.filter((entry) => entry.productId !== product.productId))
setDraft((current) => ({ ...EMPTY, signAs: current.signAs }))
setTried(false)
}
const invalid = (name: Name) => (errors[name] ? { "aria-invalid": true as const, "aria-describedby": idOf(`${name}-error`) } : {})
const said = (name: Name) => (errors[name] ? <FieldError id={idOf(`${name}-error`)}>{errors[name]}</FieldError> : null)
return (
<div className="mt-8 grid gap-8 md:mt-10 lg:grid-cols-[minmax(0,1fr)_22rem] lg:gap-12">
<div className="flex min-w-0 flex-col gap-6">
<div role="status" aria-live="polite">
{posted ? (
<p className="flex gap-2.5 rounded-lg bg-surface p-4 text-sm ring-1 ring-border">
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
<span>{posted}</span>
</p>
) : null}
</div>
{left.length === 0 ? (
<p ref={doneRef} tabIndex={-1} className="rounded-lg bg-card p-6 text-sm text-muted-foreground ring-1 ring-border outline-none">
Nothing else is waiting for your review. Thank you for every word — the next buyer reads them.
</p>
) : (
<form noValidate onSubmit={post} aria-label="Write a review" className="flex flex-col gap-6">
<fieldset className="flex flex-col gap-3" {...(errors.product ? { "aria-describedby": idOf("product-error") } : {})}>
<legend className="mb-3 text-sm font-medium">What are you reviewing?</legend>
<RadioGroup value={draft.product} onValueChange={(value) => set("product", value as string)} aria-invalid={errors.product ? true : undefined} className="grid gap-2 sm:grid-cols-2">
{left.map((entry) => (
<Label
key={entry.productId}
htmlFor={idOf(`product-${entry.productId}`)}
className="flex cursor-pointer items-center gap-3 rounded-lg bg-card p-3 leading-snug font-normal ring-1 ring-border has-[[data-checked]]:bg-brand-muted"
>
<RadioGroupItem id={idOf(`product-${entry.productId}`)} value={entry.productId} />
<span className="w-11 shrink-0">{arts[entry.productId]}</span>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="font-medium">
{entry.name}
<span className="sr-only">,</span>
</span>{" "}
<span className="text-xs text-muted-foreground">
{entry.category} · delivered {DAY.format(new Date(entry.deliveredAt))}
</span>
</span>
</Label>
))}
</RadioGroup>
{said("product")}
</fieldset>
<div className="flex flex-col gap-2">
<span id={idOf("rating-label")} className="text-sm font-medium">
Your rating
</span>
<div className="flex flex-wrap items-center gap-3">
<Rating
id={idOf("rating")}
value={draft.rating}
onValueChange={(value) => set("rating", value)}
label="Your rating"
aria-describedby={[idOf("rating-word"), errors.rating ? idOf("rating-error") : ""].filter(Boolean).join(" ")}
aria-invalid={errors.rating ? true : undefined}
/>
<span id={idOf("rating-word")} className="text-sm text-muted-foreground">
{WORDS[draft.rating]}
</span>
</div>
{said("rating")}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={idOf("title")}>Title</Label>
<Input id={idOf("title")} value={draft.title} maxLength={limits.titleMax} onChange={(event) => set("title", event.target.value)} className="h-9" {...invalid("title")} />
{said("title")}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={idOf("body")}>Your review</Label>
<Textarea
id={idOf("body")}
value={draft.body}
maxLength={limits.bodyMax}
onChange={(event) => set("body", event.target.value)}
className="min-h-32"
aria-invalid={errors.body ? true : undefined}
aria-describedby={[idOf("body-hint"), errors.body ? idOf("body-error") : ""].filter(Boolean).join(" ")}
/>
<p id={idOf("body-hint")} className="flex justify-between gap-3 text-xs text-muted-foreground">
<span>How did you use it, and how has it held up? At least {limits.bodyMin} characters.</span>
<span className="shrink-0 tabular-nums">
{length} / {limits.bodyMax}
</span>
</p>
{said("body")}
</div>
<fieldset className="flex flex-col gap-2">
<legend className="mb-2 text-sm font-medium">Sign it as</legend>
<RadioGroup value={draft.signAs} onValueChange={(value) => set("signAs", value as Draft["signAs"])} className="flex flex-wrap gap-x-6 gap-y-2">
{(["name", "buyer"] as const).map((way) => (
<Label key={way} htmlFor={idOf(`sign-${way}`)} className="flex cursor-pointer items-center gap-2 font-normal">
<RadioGroupItem id={idOf(`sign-${way}`)} value={way} />
{way === "name" ? reviewer.shortName : ANONYMOUS}
</Label>
))}
</RadioGroup>
</fieldset>
<Button type="submit" size="lg" className="h-10 self-start px-5">
Post review
</Button>
</form>
)}
</div>
<aside aria-label="Preview of your review" className="flex flex-col gap-3 lg:sticky lg:top-6 lg:self-start">
<p className="type-eyebrow">Preview</p>
<div className="flex flex-col gap-3 panel p-5">
<div className="flex items-center gap-3">
{draft.signAs === "name" ? (
<Avatar size="lg">
<AvatarImage src={reviewer.avatarUrl} alt="" />
<AvatarFallback>{getInitials(reviewer.name)}</AvatarFallback>
</Avatar>
) : (
<span className="flex size-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
<UserRoundIcon aria-hidden="true" className="size-5" />
</span>
)}
<div className="flex flex-col gap-0.5 text-sm">
<span className="font-medium">{signature}</span>
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<BadgeCheckIcon aria-hidden="true" className="size-3.5 text-success" />
Verified purchase
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Rating value={draft.rating} size="sm" aria-hidden="true" />
<span className="text-xs text-muted-foreground">{WORDS[draft.rating]}</span>
</div>
<p className="font-semibold break-words">{draft.title.trim() || <span className="font-normal text-faint-foreground">Your title</span>}</p>
<p className="text-sm break-words whitespace-pre-line text-muted-foreground">{draft.body.trim() || <span className="text-faint-foreground">What you write shows here.</span>}</p>
<p className="border-t border-rule pt-3 text-xs text-muted-foreground">About the {product ? <span className="font-medium text-foreground">{product.name}</span> : "product you pick"}</p>
</div>
<p className="text-xs text-pretty text-muted-foreground">
A problem with a delivery is better sent to our support team, who can put it right; a review is for the product itself.
</p>
</aside>
</div>
)
}/**
* What `reviews-03` reads: a buyer with products still waiting for their
* review. A product waits when it reached the buyer in a kept order (fulfilled,
* not refunded) over the last `WAITING_DAYS` before `REFERENCE_DATE`, is
* still sold, and has no review from that order yet. The buyer is the
* customer with the most of them, a tie going to the latest delivery; their
* newest `MAX_WAITING` are offered, one line per product.
*
* Selectors only, reading the store inside functions and run on the server.
*/
import { avatarFor } from "@/lib/avatars"
import { shortName } from "@/lib/dashboards/ecommerce/reviews"
import { productHref } from "@/lib/dashboards/ecommerce/storefront"
import { REFERENCE_DATE, db } from "@/lib/sample-data"
/** How long after a delivery the shop still asks for a review, in days. */
export const WAITING_DAYS = 60
/** The most products the form offers at once. */
export const MAX_WAITING = 5
/** The shortest review the form takes, in characters, and the longest. */
export const BODY_MIN = 30
export const BODY_MAX = 1000
export const TITLE_MAX = 80
export type Reviewer = { name: string; shortName: string; avatarUrl: string }
export type WaitingProduct = {
productId: string
orderId: string
name: string
category: string
href: string
/** When the order reached the buyer, as an ISO string. */
deliveredAt: string
}
/** The buyer, and the products they have received and not reviewed yet, the newest delivery first. */
export function reviewDesk(): { reviewer: Reviewer; waiting: WaitingProduct[] } {
const reviewed = new Set(db.reviews.all().map((review) => `${review.orderId}/${review.productId}`))
const products = new Map(db.products.all().map((product) => [product.id, product]))
const since = REFERENCE_DATE.getTime() - WAITING_DAYS * 86_400_000
const byCustomer = new Map<string, Map<string, WaitingProduct>>()
const kept = db.orders
.all()
.filter((order) => order.status === "fulfilled" && order.customerId && order.fulfilledAt)
.filter((order) => order.fulfilledAt!.getTime() > since && order.fulfilledAt!.getTime() <= REFERENCE_DATE.getTime())
.sort((a, b) => b.fulfilledAt!.getTime() - a.fulfilledAt!.getTime() || a.id.localeCompare(b.id))
for (const order of kept) {
const own = byCustomer.get(order.customerId) ?? new Map<string, WaitingProduct>()
for (const line of order.items) {
const product = products.get(line.productId)
if (!product || product.status !== "active" || reviewed.has(`${order.id}/${product.id}`) || own.has(product.id)) continue
own.set(product.id, {
productId: product.id,
orderId: order.id,
name: product.name,
category: product.category,
href: productHref(product.id),
deliveredAt: order.fulfilledAt!.toISOString(),
})
}
if (own.size) byCustomer.set(order.customerId, own)
}
// Map order is the latest delivery first, so the first with the most wins a tie.
const [customerId, waiting] = [...byCustomer].reduce((best, entry) => (entry[1].size > best[1].size ? entry : best))
const customer = db.customers.all().find((entry) => entry.id === customerId)!
return {
reviewer: { name: customer.name, shortName: shortName(customer.name), avatarUrl: customer.avatarUrl ?? avatarFor(customer.name) },
waiting: [...waiting.values()].slice(0, MAX_WAITING),
}
}npx shadcn@latest add @vibra/reviews-03Review cards with faces, one per department
reviews-04In every department, the four- or five-star review readers marked helpful most often: the stars, the review in full, the buyer's face and name, and the product it is about with its own score.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon, BadgeCheckIcon, ThumbsUpIcon } from "lucide-react"
import { formatNumber, getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { ProductArt } from "@/components/ui/product-art"
import { Rating } from "@/components/ui/rating"
import { LEAST_STARS, departmentFavourites } from "./reviews-04.data"
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
/** A mean as a score: one decimal, always. */
const score = (value: number) => formatNumber(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
/**
* Review cards with faces: one per department, the review readers marked
* helpful most often among those giving a product four or five stars. Each
* card carries its department, how many found it helpful, the stars (said in
* words), the title and the review in full, the buyer's face and name with
* the date and whether the purchase is verified, and the product it is about
* — drawn, with its own score — as a link to it. Three across from `lg`, two
* from `sm`.
*
* A server component: every card comes from `./reviews-04.data`.
*/
export function Reviews04() {
const titleId = React.useId()
const favourites = departmentFavourites()
return (
<section
data-section="reviews-04"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="flex max-w-2xl flex-col gap-2">
<p className="type-eyebrow">Reviews</p>
<h2 id={titleId} className="type-display text-2xl md:text-3xl">
The most helpful review in every department
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
Chosen by readers, not by us: in each department, the {LEAST_STARS}- or 5-star review the most people marked helpful.
</p>
</div>
<ul className="mt-8 grid gap-4 sm:grid-cols-2 md:mt-10 lg:grid-cols-3">
{favourites.map(({ department, review, score: own }) => (
<li key={review.id} className="flex">
<article aria-labelledby={`${titleId}${review.id}`} className="flex w-full flex-col gap-4 panel p-5 md:p-6">
<div className="flex items-center justify-between gap-3">
<Link href={department.href} className="type-eyebrow rounded-sm focus-ring hover:text-foreground hover:underline">
{department.name}
</Link>
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground tabular-nums">
<ThumbsUpIcon aria-hidden="true" className="size-3.5" />
{review.helpful} found it helpful
</span>
</div>
<div className="flex flex-col gap-2">
<Rating value={review.rating} size="sm" aria-hidden="true" />
<span className="sr-only">Rated {review.rating} out of 5.</span>
<h3 id={`${titleId}${review.id}`} className="text-base font-semibold text-balance">
{review.title}
</h3>
<p className="text-sm text-pretty text-muted-foreground">{review.body}</p>
</div>
<div className="mt-auto flex items-center gap-3">
<Avatar size="lg">
<AvatarImage src={review.author.avatarUrl} alt="" />
<AvatarFallback>{getInitials(review.author.name)}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col gap-0.5 text-sm">
<span className="font-medium">{review.author.shortName}</span>
<span className="inline-flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
{review.verified ? (
<span className="inline-flex items-center gap-1">
<BadgeCheckIcon aria-hidden="true" className="size-3.5 text-success" />
Verified purchase
<span aria-hidden="true">·</span>
</span>
) : null}
<time dateTime={review.at}>{DAY.format(new Date(review.at))}</time>
</span>
</div>
</div>
<Link
href={review.product.href}
className="flex items-center gap-3 rounded-lg bg-surface p-2 pr-3 ring-1 ring-border focus-ring hover:bg-muted"
>
<ProductArt name={review.product.name} category={review.product.category} className="w-12 shrink-0 rounded-md" />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium">
{review.product.name}
<span className="sr-only">, rated</span>
</span>{" "}
<span className="text-xs text-muted-foreground tabular-nums">
{score(own.average)} from {own.count} {own.count === 1 ? "review" : "reviews"}
</span>
</span>
<ArrowRightIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
</Link>
</article>
</li>
))}
</ul>
</div>
</section>
)
}
export default Reviews04/**
* What `reviews-04` reads: in every department, the review readers found
* most helpful among those that gave a product still sold four stars or five
* (a tie goes to the newer review), with the product's own score beside it —
* its mean and how many reviews that is of, counted from the same reviews
* (`summarizeReviews`, from reviews-ecommerce). Departments run A to Z.
*
* Selectors only, reading the store inside functions and run on the server.
*/
import { storeReviews, summarizeReviews, type ReviewRow } from "@/lib/dashboards/ecommerce/reviews"
import { departmentHref } from "@/lib/dashboards/ecommerce/storefront"
export type Favourite = {
department: { name: string; href: string }
review: ReviewRow
/** The reviewed product's own score, over all of its reviews. */
score: { average: number; count: number }
}
/** The lowest score a favourite may carry. */
export const LEAST_STARS = 4
/** One favourite per department, A to Z. */
export function departmentFavourites(): Favourite[] {
const rows = storeReviews()
const departments = [...new Set(rows.map((row) => row.product.category))].sort((a, b) => a.localeCompare(b))
return departments.flatMap((name) => {
// storeReviews runs newest first, so the first of the most helpful is the newer.
const [best] = rows
.filter((row) => row.product.category === name && row.product.active && row.rating >= LEAST_STARS)
.sort((a, b) => b.helpful - a.helpful)
if (!best) return []
const { average, count } = summarizeReviews(rows.filter((row) => row.product.id === best.product.id))
return [{ department: { name, href: departmentHref(name) }, review: best, score: { average, count } }]
})
}npx shadcn@latest add @vibra/reviews-04Product reviews with star filters
reviews-05One product's reviews on its page: the score set large with who would recommend it, a bar per star that narrows the list when pressed (several at once), and every review, sorted four ways, with a Helpful vote.
import * as React from "react"
import { ProductArt } from "@/components/ui/product-art"
import { ProductReviews } from "./reviews-05.client"
import { productReviewBlock } from "./reviews-05.data"
/**
* A product page's reviews: the product, its score set large with who would
* recommend it, a bar per star that narrows the list when pressed (more than
* one at a time), and every review beside it — sorted four ways, each with
* the buyer's face, a verified mark and a Helpful vote. The summary stays in
* view beside the list from `lg`; on a phone it sits above.
*
* A server component: the product and its reviews come from
* `./reviews-05.data`; it draws the product here and hands the island the
* drawing.
*/
export function Reviews05() {
const titleId = React.useId()
const { product, reviews, summary } = productReviewBlock()
return (
<section
data-section="reviews-05"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="flex max-w-2xl flex-col gap-2">
<p className="type-eyebrow">Reviews</p>
<h2 id={titleId} className="type-display text-2xl md:text-3xl">
What buyers say about the {product.name}
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
Every review from a buyer it reached, the low scores too. Press a star's bar to read only those, or several to compare.
</p>
</div>
<ProductReviews
product={product}
art={<ProductArt name={product.name} category={product.category} className="w-14 rounded-md" />}
reviews={reviews}
summary={summary}
/>
</div>
</section>
)
}
export default Reviews05"use client"
import * as React from "react"
import Link from "next/link"
import { BadgeCheckIcon, PenLineIcon, ThumbsUpIcon, XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatCurrency, formatNumber, getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button, buttonVariants } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { Rating } from "@/components/ui/rating"
import type { ReviewedProduct, ReviewRow, ReviewSummary } from "./reviews-05.data"
type Sort = "helpful" | "newest" | "highest" | "lowest"
const SORTS: { value: Sort; label: string; spoken: string }[] = [
{ value: "helpful", label: "Most helpful", spoken: "most helpful first" },
{ value: "newest", label: "Newest", spoken: "newest first" },
{ value: "highest", label: "Highest score", spoken: "highest score first" },
{ value: "lowest", label: "Lowest score", spoken: "lowest score first" },
]
const DAY = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" })
const newer = (a: ReviewRow, b: ReviewRow) => Date.parse(b.at) - Date.parse(a.at)
const ORDER: Record<Sort, (a: ReviewRow, b: ReviewRow) => number> = {
helpful: (a, b) => b.helpful - a.helpful || newer(a, b),
newest: newer,
highest: (a, b) => b.rating - a.rating || newer(a, b),
lowest: (a, b) => a.rating - b.rating || newer(a, b),
}
const score = (value: number) => formatNumber(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
const reviewsWord = (count: number) => `${count} ${count === 1 ? "review" : "reviews"}`
/** [2, 1] → "1 or 2 stars"; [1] → "1 star". */
function starsSaid(stars: number[]): string {
const sorted = [...stars].sort((a, b) => a - b)
const words = sorted.length > 1 ? `${sorted.slice(0, -1).join(", ")} or ${sorted.at(-1)}` : String(sorted[0])
return `${words} ${sorted.length === 1 && sorted[0] === 1 ? "star" : "stars"}`
}
/**
* One product's reviews on its page: the score set large with the share who
* would recommend it, a bar per star that is also a toggle — press any to
* narrow the list to those scores, press again to widen it; a score nobody
* gave is unavailable — and the reviews beside it, sorted four ways, each
* with the buyer's face, a verified mark and a Helpful vote (one per reader).
* The first `pageSize` show until Show all; a status line says what is shown.
*/
export function ProductReviews({ product, art, reviews, summary, pageSize = 5 }: { product: ReviewedProduct; art: React.ReactNode; reviews: ReviewRow[]; summary: ReviewSummary; pageSize?: number }) {
const base = React.useId()
const [stars, setStars] = React.useState<ReadonlySet<number>>(new Set())
const [sort, setSort] = React.useState<Sort>("helpful")
const [all, setAll] = React.useState(false)
const [voted, setVoted] = React.useState<ReadonlySet<string>>(new Set())
const listRef = React.useRef<HTMLOListElement>(null)
const barsRef = React.useRef<HTMLDivElement>(null)
const firstNew = React.useRef<string | null>(null)
const matching = reviews.filter((review) => stars.size === 0 || stars.has(review.rating)).sort(ORDER[sort])
const visible = all ? matching : matching.slice(0, pageSize)
const spokenSort = SORTS.find((entry) => entry.value === sort)!.spoken
React.useEffect(() => {
if (!firstNew.current) return
listRef.current?.querySelector<HTMLElement>(`[data-review="${firstNew.current}"]`)?.focus()
firstNew.current = null
}, [all])
const toggle = <T,>(set: ReadonlySet<T>, value: T) => {
const next = new Set(set)
if (next.has(value)) next.delete(value)
else next.add(value)
return next
}
function clear() {
setStars(new Set())
setAll(false)
// The button goes with the filter, so focus goes back to the bars.
barsRef.current?.querySelector<HTMLElement>("button:enabled")?.focus()
}
function showAll() {
firstNew.current = matching[pageSize]?.id ?? null
setAll(true)
}
return (
<div className="mt-8 grid gap-10 md:mt-10 lg:grid-cols-[20rem_minmax(0,1fr)] lg:gap-14">
<div className="flex flex-col gap-6 lg:sticky lg:top-6 lg:self-start">
<Link href={product.href} className="flex items-center gap-3 rounded-lg p-1 focus-ring hover:bg-muted">
<span className="w-14 shrink-0">{art}</span>
<span className="flex min-w-0 flex-col">
<span className="font-medium">
{product.name}
<span className="sr-only">,</span>
</span>{" "}
<span className="text-sm text-muted-foreground tabular-nums">{formatCurrency(product.priceCents / 100, "USD")}</span>
</span>
</Link>
<div className="flex flex-col gap-2 border-t border-rule pt-6">
<div className="flex items-end gap-3">
<p className="type-numeral text-5xl">{score(summary.average)}</p>
<div className="flex flex-col gap-1 pb-1">
<Rating value={Math.round(summary.average)} size="sm" aria-hidden="true" />
<p className="text-sm text-muted-foreground tabular-nums">
<span className="sr-only">Rated {score(summary.average)} out of 5, </span>from {reviewsWord(summary.count)}
</p>
</div>
</div>
<p className="text-sm tabular-nums">
<span className="font-semibold">{summary.recommend}%</span> <span className="text-muted-foreground">would recommend it</span>
</p>
</div>
<div ref={barsRef} role="group" aria-label="Filter by stars" className="flex flex-col gap-1">
{summary.byStars.map((count, index) => {
const value = 5 - index
const on = stars.has(value)
return (
<button
key={value}
type="button"
aria-pressed={on}
disabled={count === 0}
aria-label={`${value} ${value === 1 ? "star" : "stars"}: ${reviewsWord(count)}, ${summary.shares[index]}%`}
onClick={() => {
setStars(toggle(stars, value))
setAll(false)
}}
className="grid grid-cols-[3.25rem_minmax(0,1fr)_3.25rem] items-center gap-3 rounded-md px-2 py-1.5 text-left text-sm focus-ring enabled:hover:bg-muted disabled:text-faint-foreground aria-pressed:bg-brand-muted aria-pressed:font-medium"
>
<span className="tabular-nums">
{value} {value === 1 ? "star" : "stars"}
</span>
<span aria-hidden="true" className="h-2 overflow-hidden rounded-full bg-muted">
<span className="block h-full rounded-full bg-foreground" style={{ width: `${summary.shares[index]}%` }} />
</span>
<span className="text-right tabular-nums">{count}</span>
</button>
)
})}
{stars.size ? (
<Button variant="ghost" size="sm" className="self-start" onClick={clear}>
<XIcon data-icon="inline-start" aria-hidden="true" />
Show every score
</Button>
) : null}
</div>
<Link href={`/reviews/new?product=${product.id}`} className={cn(buttonVariants({ variant: "outline" }), "self-start")}>
<PenLineIcon data-icon="inline-start" aria-hidden="true" />
Write a review
</Link>
</div>
<div className="flex min-w-0 flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
<p role="status" aria-live="polite" className="text-sm text-muted-foreground tabular-nums">
Showing {visible.length} of {stars.size ? `${reviewsWord(matching.length)} with ${starsSaid([...stars])}` : reviewsWord(matching.length)}, {spokenSort}.
</p>
<div className="flex items-center gap-2">
<Label htmlFor={`${base}sort`} className="font-normal text-muted-foreground">
Sort by
</Label>
<NativeSelect id={`${base}sort`} value={sort} size="sm" onChange={(event) => setSort(event.target.value as Sort)}>
{SORTS.map((entry) => (
<NativeSelectOption key={entry.value} value={entry.value}>
{entry.label}
</NativeSelectOption>
))}
</NativeSelect>
</div>
</div>
<ol ref={listRef} aria-label={`Reviews of the ${product.name}`} className="flex flex-col gap-3">
{visible.map((review) => {
const mine = voted.has(review.id)
return (
<li key={review.id}>
<article
data-review={review.id}
tabIndex={-1}
aria-labelledby={`${base}${review.id}`}
className="flex flex-col gap-3 panel p-5 focus-ring"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Avatar>
<AvatarImage src={review.author.avatarUrl} alt="" />
<AvatarFallback>{getInitials(review.author.name)}</AvatarFallback>
</Avatar>
<div className="flex flex-col text-sm">
<span className="font-medium">{review.author.shortName}</span>
<time dateTime={review.at} className="text-xs text-muted-foreground">
{DAY.format(new Date(review.at))}
</time>
</div>
</div>
{review.verified ? (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<BadgeCheckIcon aria-hidden="true" className="size-3.5 text-success" />
Verified purchase
</span>
) : null}
</div>
<div className="flex flex-col gap-1.5">
<Rating value={review.rating} size="sm" aria-hidden="true" />
<span className="sr-only">Rated {review.rating} out of 5.</span>
<h3 id={`${base}${review.id}`} className="font-semibold">
{review.title}
</h3>
<p className="text-sm text-pretty text-muted-foreground">{review.body}</p>
</div>
<Button variant="ghost" size="sm" aria-pressed={mine} onClick={() => setVoted(toggle(voted, review.id))} className="-ml-2 self-start aria-pressed:bg-brand-muted">
<ThumbsUpIcon data-icon="inline-start" aria-hidden="true" />
Helpful <span className="text-muted-foreground tabular-nums">{review.helpful + (mine ? 1 : 0)}</span>
</Button>
</article>
</li>
)
})}
</ol>
{visible.length < matching.length ? (
<Button variant="outline" className="self-start" onClick={showAll}>
Show all {reviewsWord(matching.length)}
</Button>
) : null}
</div>
</div>
)
}/**
* What `reviews-05` reads: the product on the shelf with the most reviews (a
* tie goes to the name, A to Z), every review of it, the newest first, and
* their summary — the mean, the spread of stars as counts and shares, and who
* would recommend it (`summarizeReviews`, from reviews-ecommerce). The mean is
* the one the product's card prints (`storeShelf`), since both count the
* same reviews.
*
* Selectors only, reading the store inside functions and run on the server.
*/
import { reviewsOf, summarizeReviews, type ReviewRow, type ReviewSummary } from "@/lib/dashboards/ecommerce/reviews"
import { storeShelf } from "@/lib/dashboards/ecommerce/storefront"
export type { ReviewRow, ReviewSummary }
export type ReviewedProduct = { id: string; name: string; category: string; href: string; priceCents: number }
/** The most reviewed product still sold, its reviews and their summary. */
export function productReviewBlock(): { product: ReviewedProduct; reviews: ReviewRow[]; summary: ReviewSummary } {
const [top] = storeShelf().sort((a, b) => b.ratings - a.ratings || a.name.localeCompare(b.name))
const reviews = reviewsOf(top.id)
return {
product: { id: top.id, name: top.name, category: top.category, href: top.href, priceCents: top.priceCents },
reviews,
summary: summarizeReviews(reviews),
}
}npx shadcn@latest add @vibra/reviews-05What buyers mention, counted from their words
reviews-06The topics buyers bring up — build quality, value, delivery, fit and more — each a tab with how many reviews mention it and the stars they gave, and the sentences that say it from five-star reviews and from one- and two-star ones.
import * as React from "react"
import Link from "next/link"
import { StarIcon } from "lucide-react"
import { formatNumber, getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Rating } from "@/components/ui/rating"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { topicsOf, type Excerpt } from "./reviews-06.data"
/** A mean as a score: one decimal, always. */
const score = (value: number) => formatNumber(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
const reviewsWord = (count: number) => `${formatNumber(count)} ${count === 1 ? "review" : "reviews"}`
function Quote({ excerpt }: { excerpt: Excerpt }) {
return (
<li className="flex flex-col gap-2.5 border-t border-rule pt-4 first:border-t-0 first:pt-0">
<blockquote className="text-sm text-pretty">
“{excerpt.before}
<mark className="rounded-sm bg-brand-muted px-0.5 text-foreground">{excerpt.match}</mark>
{excerpt.after}”
</blockquote>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<Avatar size="sm">
<AvatarImage src={excerpt.author.avatarUrl} alt="" />
<AvatarFallback>{getInitials(excerpt.author.name)}</AvatarFallback>
</Avatar>
<span className="font-medium text-foreground">{excerpt.author.shortName}</span>
<Rating value={excerpt.rating} size="sm" aria-hidden="true" className="[&_svg]:size-3.5" />
<span className="sr-only">gave it {excerpt.rating} out of 5,</span>
<span>
on the{" "}
{excerpt.product.active ? (
<Link href={excerpt.product.href} className="rounded-sm font-medium text-foreground focus-ring hover:underline">
{excerpt.product.name}
</Link>
) : (
<>
<span className="font-medium text-foreground">{excerpt.product.name}</span>, no longer sold
</>
)}
</span>
</div>
</li>
)
}
function Side({ title, quotes, none }: { title: string; quotes: Excerpt[]; none: string }) {
return (
<div className="flex flex-col gap-4">
<h4 className="type-eyebrow">{title}</h4>
{quotes.length ? (
<ul className="flex flex-col gap-4">
{quotes.map((excerpt) => (
<Quote key={excerpt.reviewId} excerpt={excerpt} />
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">{none}</p>
)}
</div>
)
}
/**
* What buyers mention, counted from what they wrote: each topic a tab —
* named with how many reviews bring it up and the stars those reviews gave on
* average — and each tab's panel what was said about it, quoted from both
* ends: five-star reviews, and reviews of one or two stars, the words that
* matched marked in each sentence. The tabs stand in a column beside the
* panel from `lg`, two across above it on a phone; the arrow keys move
* between them.
*
* A server component: every figure and quote comes from `./reviews-06.data`,
* and the kit's Tabs switch the panels.
*/
export function Reviews06() {
const titleId = React.useId()
const { total, topics } = topicsOf()
return (
<section
data-section="reviews-06"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="flex max-w-2xl flex-col gap-2">
<p className="type-eyebrow">Reviews</p>
<h2 id={titleId} className="type-display text-2xl md:text-3xl">
What buyers mention
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
Counted from the words in {reviewsWord(total)}, not from a form: what people bring up unprompted, and how they felt about it.
</p>
</div>
<Tabs defaultValue={topics[0]?.key} orientation="vertical" className="mt-8 flex-col gap-6 md:mt-10 lg:flex-row lg:gap-10">
<TabsList variant="line" aria-label="Topics" className="grid h-fit w-full grid-cols-2 gap-1 p-0 sm:grid-cols-3 lg:flex lg:w-80 lg:shrink-0 lg:flex-col lg:items-stretch">
{topics.map((topic) => (
<TabsTrigger
key={topic.key}
value={topic.key}
aria-label={`${topic.label}: ${reviewsWord(topic.count)}, ${score(topic.average)} stars on average`}
className="h-auto justify-between gap-3 rounded-lg px-3 py-2.5 text-left whitespace-normal hover:bg-muted data-active:bg-brand-muted data-active:hover:bg-brand-muted"
>
<span className="flex min-w-0 flex-col items-start gap-0.5">
<span className="font-medium text-foreground">{topic.label}</span>
<span className="text-xs font-normal text-muted-foreground tabular-nums">
{reviewsWord(topic.count)} · {topic.share}%
</span>
</span>
<span className="inline-flex shrink-0 items-center gap-1 text-sm text-foreground tabular-nums">
{score(topic.average)}
<StarIcon aria-hidden="true" className="size-3.5 fill-current text-warning" />
</span>
</TabsTrigger>
))}
</TabsList>
{topics.map((topic) => (
<TabsContent key={topic.key} value={topic.key} className="min-w-0 panel p-5 md:p-7">
<div className="flex flex-col gap-2 border-b border-rule pb-5">
<h3 className="text-lg font-semibold">{topic.label}</h3>
<p className="text-sm text-pretty text-muted-foreground tabular-nums">
Brought up in {reviewsWord(topic.count)} of {formatNumber(total)} ({topic.share}%), which gave {score(topic.average)} stars on
average: {topic.pleased} four or five, {topic.letDown} three or fewer.
</p>
</div>
<div className="mt-5 grid gap-8 md:grid-cols-2">
<Side title="In five-star reviews" quotes={topic.praise} none="No five-star review brings it up." />
<Side title="In one- and two-star reviews" quotes={topic.complaints} none="No one- or two-star review brings it up." />
</div>
</TabsContent>
))}
</Tabs>
</div>
</section>
)
}
export default Reviews06/**
* What `reviews-06` reads: what buyers bring up, counted from the words they
* wrote. A topic is a pattern over a review's sentences (`TOPICS`); a review
* mentions it when one of its sentences matches. For each topic: how many
* reviews mention it and their share of all reviews, the mean of their stars,
* how many of them gave four or five and how many three or fewer, and the
* sentences that say it — the most helpful first, a sentence said twice kept
* once — quoted from the two ends, five-star reviews and those of one or two
* stars, where a sentence cannot be a gripe in a happy review or a kind word
* in a sore one; the words that matched are marked out. The busiest topic
* comes first.
*
* Selectors only, reading the store inside functions and run on the server.
*/
import { storeReviews, type ReviewRow } from "@/lib/dashboards/ecommerce/reviews"
export type TopicRule = { key: string; label: string; pattern: RegExp }
/** What buyers bring up, and the words that give it away. */
export const TOPICS: readonly TopicRule[] = [
{ key: "quality", label: "Build quality", pattern: /\b(well made|better made|build quality|built to last|sturdy|solid|stitching|flimsy|seams?|fell apart|came apart|quality)\b/i },
{ key: "value", label: "Value for money", pattern: /\b(value|price|worth|cents?|charged|paying)\b/i },
{ key: "looks", label: "Looks and colour", pattern: /\b(colou?rs?|finish|looks|photos|pictures|grain)\b/i },
{ key: "setup", label: "Set-up and assembly", pattern: /\b(assembl\w*|set up|set-up|screws|bolts?|holes|instructions|manual|drivers|pairing|paired|firmware|built it)\b/i },
{ key: "delivery", label: "Delivery and packaging", pattern: /\b(arriv\w*|deliver\w*|packag\w*|packed|boxed|wrapped|plastic|folded in paper|came in (?:one|a recycled) box)\b/i },
{ key: "fit", label: "Size and fit", pattern: /\b(true to size|sizes?|fits?|shrank|half size|smaller than|measure\w*)\b/i },
{ key: "comfort", label: "Comfort", pattern: /\b(comfortable|comfort|soft|scratchy|uncomfortable)\b/i },
{ key: "returns", label: "Returns and refunds", pattern: /\b(return\w*|refund\w*|sent (?:it|them) back)\b/i },
]
/** How many sentences each side of a topic quotes. */
export const QUOTES = 3
/** The stars a quote of praise comes from, and the most a complaint's review gives. */
export const PRAISE_STARS = 5
export const COMPLAINT_STARS = 2
/** One sentence of a review, cut around the words that matched. */
export type Excerpt = {
reviewId: string
before: string
match: string
after: string
rating: number
verified: boolean
author: ReviewRow["author"]
/** A product no longer sold is named, not linked. */
product: { name: string; href: string; active: boolean }
}
export type Topic = {
key: string
label: string
count: number
/** Of every review, as a whole percentage. */
share: number
average: number
pleased: number
letDown: number
praise: Excerpt[]
complaints: Excerpt[]
}
const sentencesOf = (body: string) => body.split(/(?<=[.!?])\s+/)
/** The first sentence of a review that mentions the topic, cut around the match; none if it never does. */
export function excerptOf(row: ReviewRow, pattern: RegExp): Excerpt | null {
for (const sentence of sentencesOf(row.body)) {
const found = sentence.match(pattern)
if (!found || found.index === undefined) continue
return {
reviewId: row.id,
before: sentence.slice(0, found.index),
match: found[0],
after: sentence.slice(found.index + found[0].length),
rating: row.rating,
verified: row.verified,
author: row.author,
product: { name: row.product.name, href: row.product.href, active: row.product.active },
}
}
return null
}
/** The most helpful sentences of a run of reviews, a newer review first on a tie, each sentence once. */
function quotes(rows: { row: ReviewRow; excerpt: Excerpt }[]): Excerpt[] {
const seen = new Set<string>()
const out: Excerpt[] = []
// storeReviews runs newest first, and a stable sort keeps that on a tie.
for (const { excerpt } of [...rows].sort((a, b) => b.row.helpful - a.row.helpful)) {
const sentence = excerpt.before + excerpt.match + excerpt.after
if (seen.has(sentence)) continue
seen.add(sentence)
out.push(excerpt)
if (out.length === QUOTES) break
}
return out
}
/** Every topic buyers mention, the busiest first, with how many reviews there are in all. */
export function topicsOf(): { total: number; topics: Topic[] } {
const rows = storeReviews()
const topics = TOPICS.map(({ key, label, pattern }) => {
const hits = rows.flatMap((row) => {
const excerpt = excerptOf(row, pattern)
return excerpt ? [{ row, excerpt }] : []
})
const sum = hits.reduce((total, hit) => total + hit.row.rating, 0)
return {
key,
label,
count: hits.length,
share: rows.length ? Math.round((hits.length * 100) / rows.length) : 0,
average: hits.length ? sum / hits.length : 0,
pleased: hits.filter((hit) => hit.row.rating >= 4).length,
letDown: hits.filter((hit) => hit.row.rating <= 3).length,
praise: quotes(hits.filter((hit) => hit.row.rating === PRAISE_STARS)),
complaints: quotes(hits.filter((hit) => hit.row.rating <= COMPLAINT_STARS)),
}
})
return { total: rows.length, topics: topics.filter((topic) => topic.count > 0).sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)) }
}npx shadcn@latest add @vibra/reviews-06Carousel of the newest four- and five-star reviews
reviews-07The latest reviews to give four or five stars as a carousel: the stars, the day, the review, the buyer's face and the product, with the controls in the header saying which reviews are in view.
import * as React from "react"
import Link from "next/link"
import { BadgeCheckIcon } from "lucide-react"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Carousel, CarouselContent, CarouselItem } from "@/components/ui/carousel"
import { ProductArt } from "@/components/ui/product-art"
import { Rating } from "@/components/ui/rating"
import { ReviewCarouselControls } from "./reviews-07.client"
import { LEAST_STARS, freshPraise } from "./reviews-07.data"
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
/**
* The newest four- and five-star reviews as a carousel: one card and the
* edge of the next on a phone, two from `sm`, three from `lg`. It is the
* kit's Carousel — a region described as a carousel, each slide a group named
* by its place ("4 of 12"), the arrow keys moving the track — with its
* controls in the header: which reviews are in view, and Newer and Older.
* Each card carries the stars (said in words), the day, the title and the
* review, the buyer's face and name with a verified mark, and the product,
* drawn, as a link.
*
* A server component: the reviews come from `./reviews-07.data`; the island
* is only the controls, which read the carousel's own state.
*/
export function Reviews07() {
const titleId = React.useId()
const reviews = freshPraise()
return (
<section
data-section="reviews-07"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<Carousel opts={{ align: "start", slidesToScroll: "auto", inViewThreshold: 0.6 }} aria-label={`The newest ${LEAST_STARS}- and 5-star reviews`}>
<div className="flex flex-wrap items-end justify-between gap-x-6 gap-y-4">
<div className="flex max-w-xl flex-col gap-2">
<p className="type-eyebrow">Reviews</p>
<h2 id={titleId} className="type-display text-2xl md:text-3xl">
Fresh from buyers
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
The latest {reviews.length} reviews to give {LEAST_STARS} or 5 stars, newest first, from people whose orders reached them.
</p>
</div>
<ReviewCarouselControls />
</div>
<CarouselContent className="mt-8 md:mt-10">
{reviews.map((review, index) => (
<CarouselItem key={review.id} aria-label={`${index + 1} of ${reviews.length}`} className="flex basis-[88%] sm:basis-1/2 lg:basis-1/3">
{/* The track clips whatever hangs outside it, so the card's
ring is drawn inside its edge. */}
<article aria-labelledby={`${titleId}${review.id}`} className="flex w-full flex-col gap-4 panel p-5 ring-inset md:p-6">
<div className="flex items-center justify-between gap-3">
<Rating value={review.rating} size="sm" aria-hidden="true" />
<span className="sr-only">Rated {review.rating} out of 5.</span>
<time dateTime={review.at} className="text-xs text-muted-foreground">
{DAY.format(new Date(review.at))}
</time>
</div>
<div className="flex flex-col gap-2">
<h3 id={`${titleId}${review.id}`} className="font-semibold text-balance">
{review.title}
</h3>
<p className="text-sm text-pretty text-muted-foreground">{review.body}</p>
</div>
<div className="mt-auto flex items-center gap-3">
<Avatar>
<AvatarImage src={review.author.avatarUrl} alt="" />
<AvatarFallback>{getInitials(review.author.name)}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col text-sm">
<span className="font-medium">{review.author.shortName}</span>
{review.verified ? (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<BadgeCheckIcon aria-hidden="true" className="size-3.5 text-success" />
Verified purchase
</span>
) : (
<span className="text-xs text-muted-foreground">Left on the product page</span>
)}
</div>
</div>
<div className="flex items-center gap-3 border-t border-rule pt-4">
<ProductArt name={review.product.name} category={review.product.category} className="w-10 shrink-0 rounded-md" />
<p className="min-w-0 text-xs text-muted-foreground">
On the{" "}
<Link href={review.product.href} className="rounded-sm font-medium text-foreground focus-ring hover:underline">
{review.product.name}
</Link>
</p>
</div>
</article>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
</div>
</section>
)
}
export default Reviews07"use client"
import * as React from "react"
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
import { prefersReducedMotion } from "@/hooks/use-reduced-motion"
import { Button } from "@/components/ui/button"
import { useCarousel } from "@/components/ui/carousel"
/**
* The carousel's own controls, set in the band's header: the reviews in view
* ("Reviews 4 to 6 of 12", said politely once the track comes to rest) and
* two buttons named for where they go — the reviews run newest first, so
* back is Newer and on is Older. At either end a button stays focusable, marked
* unavailable rather than disabled, so a keyboard keeps its place; under
* reduced motion the track jumps instead of sliding.
*/
export function ReviewCarouselControls() {
const { api, canScrollPrev, canScrollNext } = useCarousel()
const [shown, setShown] = React.useState<{ first: number; last: number; total: number } | null>(null)
React.useEffect(() => {
if (!api) return
// Slides cross into view one by one while the track moves; the line is
// said once it comes to rest, not at every slide it passes.
let moving = false
const sync = () => {
const inView = api.slidesInView()
if (moving || !inView.length) return
setShown({ first: Math.min(...inView) + 1, last: Math.max(...inView) + 1, total: api.slideNodes().length })
}
const move = () => {
moving = true
}
const rest = () => {
moving = false
sync()
}
sync()
api.on("scroll", move)
api.on("settle", rest)
api.on("reInit", sync)
api.on("slidesInView", sync)
return () => {
api.off("scroll", move)
api.off("settle", rest)
api.off("reInit", sync)
api.off("slidesInView", sync)
}
}, [api])
return (
<div className="flex items-center gap-3">
<p aria-live="polite" className="text-sm text-muted-foreground tabular-nums">
{shown ? (shown.first === shown.last ? `Review ${shown.first} of ${shown.total}` : `Reviews ${shown.first} to ${shown.last} of ${shown.total}`) : null}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
aria-label="Newer reviews"
disabled={!canScrollPrev}
focusableWhenDisabled
onClick={(event) => api?.scrollPrev(prefersReducedMotion(event.currentTarget))}
>
<ArrowLeftIcon aria-hidden="true" className="rtl:rotate-180" />
</Button>
<Button
variant="outline"
size="icon"
aria-label="Older reviews"
disabled={!canScrollNext}
focusableWhenDisabled
onClick={(event) => api?.scrollNext(prefersReducedMotion(event.currentTarget))}
>
<ArrowRightIcon aria-hidden="true" className="rtl:rotate-180" />
</Button>
</div>
</div>
)
}/**
* What `reviews-07` reads: the newest `FRESH_COUNT` reviews that gave a
* product still sold four stars or five (`storeReviews`, from
* reviews-ecommerce), the newest first — each with its author's face, its
* date, whether it is verified and the product it is about.
*
* Selectors only, reading the store inside functions and run on the server.
*/
import { storeReviews, type ReviewRow } from "@/lib/dashboards/ecommerce/reviews"
/** How many reviews the carousel holds. */
export const FRESH_COUNT = 12
/** The lowest score it shows. */
export const LEAST_STARS = 4
/** The newest pleased reviews of products still sold. */
export function freshPraise(): ReviewRow[] {
return storeReviews()
.filter((row) => row.rating >= LEAST_STARS && row.product.active)
.slice(0, FRESH_COUNT)
}npx shadcn@latest add @vibra/reviews-07Top positive and critical reviews with votes and reporting
reviews-08One product's most helpful pleased review beside its most helpful critical one, each with a Yes or No vote and a way to report it that asks why.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon } from "lucide-react"
import { formatNumber } from "@/lib/format"
import { ProductArt } from "@/components/ui/product-art"
import { Rating } from "@/components/ui/rating"
import { TopReviews } from "./reviews-08.client"
import { CRITICAL_TO, PLEASED_FROM, topReviews } from "./reviews-08.data"
/** A mean as a score: one decimal, always. */
const score = (value: number) => formatNumber(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
/**
* Both sides of one product: its most helpful pleased review and its most
* helpful critical one, side by side under the product and its score, each
* with a Yes and No vote and a way to report it. For a product people are
* weighing up, the two reviews other readers leaned on most.
*
* A server component: the product and its two reviews come from
* `./reviews-08.data`; the island in `./reviews-08.client` holds the votes
* and the report.
*/
export function Reviews08() {
const titleId = React.useId()
const { product, positive, critical } = topReviews()
return (
<section
data-section="reviews-08"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="flex flex-wrap items-end justify-between gap-x-10 gap-y-6">
<div className="flex max-w-2xl flex-col gap-2">
<p className="type-eyebrow">Top reviews</p>
<h2 id={titleId} className="type-display text-2xl md:text-3xl">
Both sides of the {product.name}
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
The {PLEASED_FROM}- or 5-star review and the review of {CRITICAL_TO} stars or fewer that readers found most helpful.
</p>
</div>
<Link href={product.href} className="flex items-center gap-3 rounded-lg p-1.5 pr-3 ring-1 ring-border focus-ring hover:bg-muted">
<ProductArt name={product.name} category={product.category} className="w-12 shrink-0 rounded-md" />
<span className="flex flex-col gap-0.5 text-sm">
<span className="font-medium">
{product.name}
<span className="sr-only">,</span>
</span>{" "}
<span className="flex items-center gap-1.5 text-xs text-muted-foreground tabular-nums">
<Rating value={Math.round(product.average)} size="sm" aria-hidden="true" className="[&_svg]:size-3.5" />
<span className="sr-only">rated</span> {score(product.average)} from {product.count} reviews
</span>
</span>
<ArrowRightIcon aria-hidden="true" className="size-4 text-muted-foreground" />
</Link>
</div>
<TopReviews
product={product.name}
reviews={[
{ label: "The most helpful positive review", review: positive },
{ label: "The most helpful critical review", review: critical },
]}
/>
</div>
</section>
)
}
export default Reviews08"use client"
import * as React from "react"
import { BadgeCheckIcon, FlagIcon } from "lucide-react"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { FieldError } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Rating } from "@/components/ui/rating"
import { Textarea } from "@/components/ui/textarea"
import type { ReviewRow } from "./reviews-08.data"
type Vote = "yes" | "no" | null
const REASONS = [
{ value: "off-topic", label: "It is not about this product" },
{ value: "personal", label: "It shares someone's personal details" },
{ value: "abusive", label: "It is rude or abusive" },
{ value: "fake", label: "It looks fake or paid for" },
{ value: "other", label: "Something else" },
] as const
const DAY = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" })
const people = (count: number) => `${count} ${count === 1 ? "person" : "people"}`
/** Report a review: why, in one choice, and a line more when the reason is "something else". It sends nothing. */
function ReportReview({ review, product, reported, onReport }: { review: ReviewRow; product: string; reported: boolean; onReport: (reason: string) => void }) {
const base = React.useId()
const [open, setOpen] = React.useState(false)
const [reason, setReason] = React.useState("")
const [details, setDetails] = React.useState("")
const [tried, setTried] = React.useState(false)
const errors = {
reason: tried && !reason ? "Choose why you are reporting it." : "",
details: tried && reason === "other" && !details.trim() ? "Say what is wrong with it, in a few words." : "",
}
function send(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setTried(true)
if (!reason) return document.getElementById(`${base}${REASONS[0].value}`)?.focus()
if (reason === "other" && !details.trim()) return document.getElementById(`${base}details`)?.focus()
onReport(REASONS.find((entry) => entry.value === reason)!.label)
setOpen(false)
}
return (
<Dialog
open={open}
// Once reported, the button stays where it is, marked unavailable, and opens nothing.
onOpenChange={(next) => {
setOpen(next && !reported)
if (!next) setTried(false)
}}
>
<DialogTrigger
render={
<Button variant="ghost" size="sm" aria-disabled={reported || undefined} className="text-muted-foreground">
<FlagIcon data-icon="inline-start" aria-hidden="true" />
{reported ? "Reported" : "Report"}{" "}
<span className="sr-only">{review.author.shortName}'s review</span>
</Button>
}
/>
<DialogContent className="gap-5 p-5 sm:max-w-md">
<DialogHeader className="pr-8">
<DialogTitle>Report this review</DialogTitle>
<DialogDescription>
{review.author.shortName}'s review of the {product}. We read every report within a day; the review stays up until then.
</DialogDescription>
</DialogHeader>
<form noValidate onSubmit={send} className="flex flex-col gap-5">
<fieldset className="flex flex-col gap-2" aria-describedby={errors.reason ? `${base}reason-error` : undefined}>
<legend className="mb-2 text-sm font-medium">Why are you reporting it?</legend>
<RadioGroup value={reason} onValueChange={(value) => setReason(value as string)} aria-invalid={errors.reason ? true : undefined} className="gap-2">
{REASONS.map((entry) => (
<Label key={entry.value} htmlFor={`${base}${entry.value}`} className="flex cursor-pointer items-center gap-2.5 font-normal">
<RadioGroupItem id={`${base}${entry.value}`} value={entry.value} />
{entry.label}
</Label>
))}
</RadioGroup>
{errors.reason ? <FieldError id={`${base}reason-error`}>{errors.reason}</FieldError> : null}
</fieldset>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`${base}details`}>{reason === "other" ? "What is wrong with it?" : "Anything we should know? (optional)"}</Label>
<Textarea
id={`${base}details`}
value={details}
maxLength={500}
onChange={(event) => setDetails(event.target.value)}
aria-invalid={errors.details ? true : undefined}
aria-describedby={errors.details ? `${base}details-error` : undefined}
/>
{errors.details ? <FieldError id={`${base}details-error`}>{errors.details}</FieldError> : null}
</div>
<DialogFooter className="-mx-5 -mb-5">
<DialogClose render={<Button variant="outline">Cancel</Button>} />
<Button type="submit">Send report</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
/**
* The product's two most helpful reviews side by side, the pleased and the
* critical: each with the buyer's face, name, day and verified mark, the
* stars (said in words), the title and the review; how many found it
* helpful; Yes and No — one vote per reader, which can be changed or taken
* back by pressing it again; and Report, which asks why in the kit's Dialog
* and, once sent, stays marked Reported. One status line says what each
* press did.
*/
export function TopReviews({ product, reviews }: { product: string; reviews: { label: string; review: ReviewRow }[] }) {
const base = React.useId()
const [votes, setVotes] = React.useState<Record<string, Vote>>({})
const [reported, setReported] = React.useState<ReadonlySet<string>>(new Set())
const [said, setSaid] = React.useState("")
function vote(review: ReviewRow, choice: "yes" | "no") {
const next = votes[review.id] === choice ? null : choice
setVotes({ ...votes, [review.id]: next })
setSaid(
next === null
? `Your vote on ${review.author.shortName}'s review is taken back.`
: `Thanks: you ${next === "yes" ? "found" : "did not find"} ${review.author.shortName}'s review helpful.`
)
}
return (
<div className="mt-8 flex flex-col gap-4 md:mt-10">
<div className="grid gap-4 lg:grid-cols-2">
{reviews.map(({ label, review }) => {
const mine = votes[review.id] ?? null
return (
<article key={review.id} aria-labelledby={`${base}${review.id}`} className="flex flex-col gap-5 panel p-5 md:p-7">
<p className="type-eyebrow">{label}</p>
<div className="flex items-center gap-3">
<Avatar size="lg">
<AvatarImage src={review.author.avatarUrl} alt="" />
<AvatarFallback>{getInitials(review.author.name)}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col gap-0.5 text-sm">
<span className="font-medium">{review.author.shortName}</span>
<span className="inline-flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
<time dateTime={review.at}>{DAY.format(new Date(review.at))}</time>
{review.verified ? (
<span className="inline-flex items-center gap-1">
<span aria-hidden="true">·</span>
<BadgeCheckIcon aria-hidden="true" className="size-3.5 text-success" />
Verified purchase
</span>
) : null}
</span>
</div>
</div>
<div className="flex flex-col gap-2">
<Rating value={review.rating} aria-hidden="true" />
<span className="sr-only">Rated {review.rating} out of 5.</span>
<h3 id={`${base}${review.id}`} className="text-lg font-semibold text-balance">
{review.title}
</h3>
<p className="text-pretty text-muted-foreground">{review.body}</p>
</div>
<p className="text-xs text-muted-foreground tabular-nums">{people(review.helpful + (mine === "yes" ? 1 : 0))} found this helpful</p>
<div className="mt-auto flex flex-wrap items-center justify-between gap-3 border-t border-rule pt-4">
<div role="group" aria-labelledby={`${base}${review.id}-ask`} className="flex items-center gap-2">
<span id={`${base}${review.id}-ask`} className="text-sm text-muted-foreground">
Helpful?
</span>
{(["yes", "no"] as const).map((choice) => (
<Button key={choice} variant="outline" size="sm" aria-pressed={mine === choice} onClick={() => vote(review, choice)} className="aria-pressed:bg-brand-muted">
{choice === "yes" ? "Yes" : "No"}
</Button>
))}
</div>
<ReportReview
review={review}
product={product}
reported={reported.has(review.id)}
onReport={(reason) => {
setReported(new Set(reported).add(review.id))
setSaid(`Reported as “${reason}”. Thank you: we read every report within a day, and ${review.author.shortName}'s review stays up until then.`)
}}
/>
</div>
</article>
)
})}
</div>
<p role="status" aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
{said}
</p>
</div>
)
}/**
* What `reviews-08` reads: the product whose reviews readers weigh up most —
* the one on the shelf whose reviews were marked helpful the most times in
* all, among those with at least one pleased review (four or five stars) and
* one critical (three or fewer); a tie goes to the name — with its score, and
* its most helpful review on each side, a tie going to the newer.
*
* Selectors only, reading the store inside functions and run on the server.
*/
import { reviewsOf, summarizeReviews, type ReviewRow } from "@/lib/dashboards/ecommerce/reviews"
import { storeShelf } from "@/lib/dashboards/ecommerce/storefront"
export type { ReviewRow }
/** The least a pleased review gives, and the most a critical one does. */
export const PLEASED_FROM = 4
export const CRITICAL_TO = 3
export type WeighedProduct = { id: string; name: string; category: string; href: string; average: number; count: number }
const mostHelpful = (rows: ReviewRow[]) => [...rows].sort((a, b) => b.helpful - a.helpful)[0]
/** The product, and its most helpful pleased and critical reviews. */
export function topReviews(): { product: WeighedProduct; positive: ReviewRow; critical: ReviewRow } {
const candidates = storeShelf()
.map((row) => ({ row, reviews: reviewsOf(row.id) }))
.filter(({ reviews }) => reviews.some((review) => review.rating >= PLEASED_FROM) && reviews.some((review) => review.rating <= CRITICAL_TO))
.map((entry) => ({ ...entry, votes: entry.reviews.reduce((sum, review) => sum + review.helpful, 0) }))
.sort((a, b) => b.votes - a.votes || a.row.name.localeCompare(b.row.name))
const { row, reviews } = candidates[0]
const { average, count } = summarizeReviews(reviews)
// reviewsOf runs newest first, and a stable sort keeps that on a tie.
return {
product: { id: row.id, name: row.name, category: row.category, href: row.href, average, count },
positive: mostHelpful(reviews.filter((review) => review.rating >= PLEASED_FROM)),
critical: mostHelpful(reviews.filter((review) => review.rating <= CRITICAL_TO)),
}
}npx shadcn@latest add @vibra/reviews-08