Product reviews
Reviews as their own sections. A full overview with filters, search and shop replies, a customer photo wall, highlights by topic, the form to write one and a review row for home pages.
One of 2 components in Reviews and questions
Variants
Details
What Product reviews brings along, read from the registry and the source.
- Category
- Reviews and questions
- Area
- E-Commerce
- Variants
- 5
- Version
- 1.0.1 · since 1.1.0
- File
- components/ui/product-reviews.tsx
- npm packages
- None
- Languages
- English and German, 74 texts of its own
- Studio
- All 5 variants editable
- In collections
- Online shop
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ProductReviewsProps interface from components/ui/product-reviews.tsx, with the comments from the code.
variantoptionalProductReviewsVariantoverviewphoto-wallhighlightswrite-reviewcarousel
eyebrowoptionalstringtitleoptionalstringdescriptionoptionalstringsummaryoptionalReviewSummaryDatareviewsoptionalProductReview[]topicsoptionalReviewTopic[]pageSizeoptionalnumberoverview: reviews shown before "Show more"
productoptional{ name: string; variant?: string; image?: React.ReactNode }write-review: the product that gets reviewed
defaultValuesoptionalPartial<Pick<ReviewDraft, 'rating' | 'title' | 'body' | 'fit' | 'name' | 'email'>>write-review: start values, e.g. a rating that was picked in a mail
minLengthoptionalnumberwrite-review: shortest text that counts as a review
writeHrefoptionalstringWhere "Write a review" leads (overview, carousel)
actionoptional{ label: string; href: string }carousel: link to all reviews
onLoadMoreoptional() => voidoverview: called on "Show more" once every review passed in is shown, to load the next page
onSubmitoptional(draft: ReviewDraft) => voidwrite-review: called with the review on submit
defaultPhotooptionalnumberStudio and demos: start the lightbox on this photo
Built-in texts
Product reviews writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 74 texts
| Key | English | German |
|---|---|---|
| reviews | English reviews | German Bewertungen |
| review | English review | German Bewertung |
| writeReview | English Write a review | German Bewertung schreiben |
| all | English All | German Alle |
| withPhotos | English With photos | German Mit Fotos |
| verifiedOnly | English Verified | German Verifiziert |
| stars | English stars | German Sterne |
| star | English star | German Stern |
| clearStars | English Show all ratings | German Alle Bewertungen zeigen |
| search | English Search reviews | German Bewertungen durchsuchen |
| searchPlaceholder | English Search reviews | German Bewertungen durchsuchen |
| sort | English Sort by | German Sortieren nach |
| sortNewest | English Newest | German Neueste |
| sortHelpful | English Most helpful | German Hilfreichste |
| sortHighest | English Highest rating | German Beste Bewertung |
| sortLowest | English Lowest rating | German Schlechteste Bewertung |
| match | English reviews match | German Bewertungen passen |
| matchOne | English review matches | German Bewertung passt |
| noMatch | English No review matches yet. Try another word or show all ratings. | German Dazu gibt es noch keine Bewertung. Versuch ein anderes Wort oder zeig alle Sterne. |
| resetFilters | English Show all reviews | German Alle Bewertungen zeigen |
| verified | English Verified purchase | German Verifizierter Kauf |
| fit | English Fit | German Passform |
| wouldRecommend | English would recommend it | German würden es weiterempfehlen |
| helpfulQuestion | English Helpful? | German Hilfreich? |
| yes | English Yes | German Ja |
| no | English No | German Nein |
| replyFrom | English Reply from | German Antwort von |
| showMore | English Show more reviews | German Weitere Bewertungen zeigen |
| showing | English Showing | German Zu sehen sind |
| of | English of | German von |
| photos | English photos | German Fotos |
| photo | English Photo | German Foto |
| openPhoto | English Open photo by | German Foto öffnen von |
| morePhotos | English more photos | German weitere Fotos |
| previous | English Previous photo | German Vorheriges Foto |
| next | English Next photo | German Nächstes Foto |
| close | English Close | German Schließen |
| photosBy | English Customer photos | German Fotos von Kundinnen und Kunden |
| mentions | English What people mention | German Was oft erwähnt wird |
| mentioned | English mentions | German Erwähnungen |
| summaryFrom | English Summary of | German Zusammenfassung aus |
| allTopics | English All topics | German Alle Themen |
| yourRating | English Your rating | German Gesamteindruck |
| ratingNeeded | English Choose a rating from one to five stars. | German Wähle eine Bewertung von einem bis fünf Sternen. |
| titleLabel | English Title | German Überschrift |
| titlePlaceholder | English The short version | German Die Kurzfassung |
| bodyLabel | English Your review | German Dein Erfahrungsbericht |
| bodyPlaceholder | English What do you like, what would you change, how do you use it? | German Was gefällt dir, was würdest du ändern, wofür nutzt du es? |
| bodyShort | English A few more words help others decide. | German Ein paar Worte mehr helfen anderen bei der Entscheidung. |
| charsLeft | English more characters | German Zeichen fehlen noch |
| charsDone | English Long enough, thank you | German Lang genug, danke |
| fitLabel | English How does it turn out? | German Wie fällt es aus? |
| photosLabel | English Photos | German Fotos |
| photosHint | English Up to four, JPG or PNG | German Bis zu vier, JPG oder PNG |
| addPhoto | English Add a photo | German Foto hinzufügen |
| removePhoto | English Remove photo | German Foto entfernen |
| recommend | English I would recommend it | German Ich würde es weiterempfehlen |
| nameLabel | English Name shown with the review | German Name unter der Bewertung |
| emailLabel | English Email | German E-Mail |
| emailHint | English Only to confirm your review, never shown | German Nur zur Bestätigung, wird nie gezeigt |
| emailError | English Enter your email so we can confirm the review. | German Gib deine E-Mail ein, damit wir die Bewertung bestätigen können. |
| submit | English Publish review | German Bewertung veröffentlichen |
| sending | English Publishing | German Wird veröffentlicht |
| preview | English Preview | German Vorschau |
| previewHint | English This is how your review appears | German So erscheint deine Bewertung |
| previewEmpty | English Your words appear here as you type. | German Deine Worte erscheinen hier, während du schreibst. |
| reviewing | English You review | German Du bewertest |
| thanks | English Thank you | German Danke dir |
| thanksText | English Your review is in. It appears on the product page after a short check, usually within a day. | German Deine Bewertung ist angekommen. Nach einer kurzen Prüfung erscheint sie auf der Produktseite, meist innerhalb eines Tages. |
| you | English You | German Du |
| today | English Today | German Heute |
| scrollBack | English Scroll back | German Zurückblättern |
| scrollOn | English Scroll on | German Weiterblättern |
| basedOn | English from | German aus |
Accessibility
Read from the source of the component.
- Product reviews handles the keys Arrow left, Arrow right and Space itself.
- Built on the native dialog element. The browser keeps focus inside and closes it with Escape.
- Sets aria-current, aria-describedby, aria-invalid, aria-label, aria-labelledby, aria-live and aria-pressed, so screen readers announce purpose and state.
- Uses the roles alert, email and group.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 9 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 1,561 lines
'use client';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger, useScrollProgress } from '@/components/ui/motion';
import { Eyebrow, Section } from '@/components/ui/section';
import { DialogMotionStyle, scrim, useModalMotion } from '@/components/ui/dialog';
import { Rating as Stars, RatingSummary } from '@/components/ui/rating';
import { NativeSelect } from '@/components/ui/select';
import { ShopMotion, Swap, formatNumber, formatPercent } from '@/components/ui/product-image';
/*
* Product reviews as their own sections, for product pages and the shop.
*
* overview summary with filters, search, sorting, photos, shop replies and "show more"
* photo-wall customer photos as a wall, each one opens with its review in a lightbox
* highlights what people mention: scores per aspect, topics that filter quotes
* write-review the form with stars, fit, photos and a live preview of the review
* carousel review cards with the reviewed product in a row that scrolls, for home and category pages
*
* Photos come in as nodes (`photos`), so any <img> works.
*/
export type ProductReviewsVariant = 'overview' | 'photo-wall' | 'highlights' | 'write-review' | 'carousel';
export interface ProductReview {
author: string;
/** City or short note, e.g. "Hamburg" */
location?: string;
/** Already formatted, e.g. "Oct 12, 2026" */
date: string;
rating: number;
title?: string;
body: string;
verified?: boolean;
/** The bought option, e.g. "Clay · 350 ml" */
variant?: string;
/** How it fits or turns out, 0 small, 0.5 as expected, 1 large */
fit?: number;
helpful?: number;
unhelpful?: number;
photos?: React.ReactNode[];
/** An answer from the shop */
reply?: { author: string; body: string; date?: string };
/** carousel: the reviewed product */
product?: { name: string; href: string; image?: React.ReactNode };
/** highlights: ids of the topics this review is about */
topics?: string[];
}
export interface ReviewSummaryData {
average: number;
count: number;
/** Count per star, highest first: [5★, 4★, 3★, 2★, 1★] */
distribution?: number[];
/** Share that would recommend it, 0 to 100 */
recommend?: number;
/** Where the fit lands, 0 small, 0.5 as expected, 1 large */
fit?: number;
/** Three words for the fit scale, e.g. ["Runs small", "True to size", "Runs large"] */
fitLabels?: [string, string, string];
/** Score per aspect, 1 to 5 */
aspects?: { label: string; value: number }[];
/** Number of customer photos, shown on the photo wall */
photos?: number;
/** highlights: one short summary of what people say */
text?: string;
}
export interface ReviewTopic {
id: string;
label: string;
/** How many reviews mention it */
count: number;
/** positive or mixed, shown as a calm dot */
tone?: 'positive' | 'mixed';
/** Words to highlight in the quotes, default the label */
words?: string[];
}
export interface ReviewDraft {
rating: number;
title: string;
body: string;
fit: number | null;
recommend: boolean;
name: string;
email: string;
photos: File[];
}
export interface ProductReviewsProps {
variant?: ProductReviewsVariant;
eyebrow?: string;
title?: string;
description?: string;
summary?: ReviewSummaryData;
reviews?: ProductReview[];
topics?: ReviewTopic[];
/** overview: reviews shown before "Show more" */
pageSize?: number;
/** write-review: the product that gets reviewed */
product?: { name: string; variant?: string; image?: React.ReactNode };
/** write-review: start values, e.g. a rating that was picked in a mail */
defaultValues?: Partial<Pick<ReviewDraft, 'rating' | 'title' | 'body' | 'fit' | 'name' | 'email'>>;
/** write-review: shortest text that counts as a review */
minLength?: number;
/** Where "Write a review" leads (overview, carousel) */
writeHref?: string;
/** carousel: link to all reviews */
action?: { label: string; href: string };
/** overview: called on "Show more" once every review passed in is shown, to load the next page */
onLoadMore?: () => void;
/** write-review: called with the review on submit */
onSubmit?: (draft: ReviewDraft) => void;
/** Studio and demos: start the lightbox on this photo */
defaultPhoto?: number;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
reviews: 'reviews',
review: 'review',
writeReview: 'Write a review',
all: 'All',
withPhotos: 'With photos',
verifiedOnly: 'Verified',
stars: 'stars',
star: 'star',
clearStars: 'Show all ratings',
search: 'Search reviews',
searchPlaceholder: 'Search reviews',
sort: 'Sort by',
sortNewest: 'Newest',
sortHelpful: 'Most helpful',
sortHighest: 'Highest rating',
sortLowest: 'Lowest rating',
match: 'reviews match',
matchOne: 'review matches',
noMatch: 'No review matches yet. Try another word or show all ratings.',
resetFilters: 'Show all reviews',
verified: 'Verified purchase',
fit: 'Fit',
wouldRecommend: 'would recommend it',
helpfulQuestion: 'Helpful?',
yes: 'Yes',
no: 'No',
replyFrom: 'Reply from',
showMore: 'Show more reviews',
showing: 'Showing',
of: 'of',
photos: 'photos',
photo: 'Photo',
openPhoto: 'Open photo by',
morePhotos: 'more photos',
previous: 'Previous photo',
next: 'Next photo',
close: 'Close',
photosBy: 'Customer photos',
mentions: 'What people mention',
mentioned: 'mentions',
summaryFrom: 'Summary of',
allTopics: 'All topics',
yourRating: 'Your rating',
ratingNeeded: 'Choose a rating from one to five stars.',
titleLabel: 'Title',
titlePlaceholder: 'The short version',
bodyLabel: 'Your review',
bodyPlaceholder: 'What do you like, what would you change, how do you use it?',
bodyShort: 'A few more words help others decide.',
charsLeft: 'more characters',
charsDone: 'Long enough, thank you',
fitLabel: 'How does it turn out?',
photosLabel: 'Photos',
photosHint: 'Up to four, JPG or PNG',
addPhoto: 'Add a photo',
removePhoto: 'Remove photo',
recommend: 'I would recommend it',
nameLabel: 'Name shown with the review',
emailLabel: 'Email',
emailHint: 'Only to confirm your review, never shown',
emailError: 'Enter your email so we can confirm the review.',
submit: 'Publish review',
sending: 'Publishing',
preview: 'Preview',
previewHint: 'This is how your review appears',
previewEmpty: 'Your words appear here as you type.',
reviewing: 'You review',
thanks: 'Thank you',
thanksText: 'Your review is in. It appears on the product page after a short check, usually within a day.',
you: 'You',
today: 'Today',
scrollBack: 'Scroll back',
scrollOn: 'Scroll on',
basedOn: 'from',
},
de: {
reviews: 'Bewertungen',
review: 'Bewertung',
writeReview: 'Bewertung schreiben',
all: 'Alle',
withPhotos: 'Mit Fotos',
verifiedOnly: 'Verifiziert',
stars: 'Sterne',
star: 'Stern',
clearStars: 'Alle Bewertungen zeigen',
search: 'Bewertungen durchsuchen',
searchPlaceholder: 'Bewertungen durchsuchen',
sort: 'Sortieren nach',
sortNewest: 'Neueste',
sortHelpful: 'Hilfreichste',
sortHighest: 'Beste Bewertung',
sortLowest: 'Schlechteste Bewertung',
match: 'Bewertungen passen',
matchOne: 'Bewertung passt',
noMatch: 'Dazu gibt es noch keine Bewertung. Versuch ein anderes Wort oder zeig alle Sterne.',
resetFilters: 'Alle Bewertungen zeigen',
verified: 'Verifizierter Kauf',
fit: 'Passform',
wouldRecommend: 'würden es weiterempfehlen',
helpfulQuestion: 'Hilfreich?',
yes: 'Ja',
no: 'Nein',
replyFrom: 'Antwort von',
showMore: 'Weitere Bewertungen zeigen',
showing: 'Zu sehen sind',
of: 'von',
photos: 'Fotos',
photo: 'Foto',
openPhoto: 'Foto öffnen von',
morePhotos: 'weitere Fotos',
previous: 'Vorheriges Foto',
next: 'Nächstes Foto',
close: 'Schließen',
photosBy: 'Fotos von Kundinnen und Kunden',
mentions: 'Was oft erwähnt wird',
mentioned: 'Erwähnungen',
summaryFrom: 'Zusammenfassung aus',
allTopics: 'Alle Themen',
yourRating: 'Gesamteindruck',
ratingNeeded: 'Wähle eine Bewertung von einem bis fünf Sternen.',
titleLabel: 'Überschrift',
titlePlaceholder: 'Die Kurzfassung',
bodyLabel: 'Dein Erfahrungsbericht',
bodyPlaceholder: 'Was gefällt dir, was würdest du ändern, wofür nutzt du es?',
bodyShort: 'Ein paar Worte mehr helfen anderen bei der Entscheidung.',
charsLeft: 'Zeichen fehlen noch',
charsDone: 'Lang genug, danke',
fitLabel: 'Wie fällt es aus?',
photosLabel: 'Fotos',
photosHint: 'Bis zu vier, JPG oder PNG',
addPhoto: 'Foto hinzufügen',
removePhoto: 'Foto entfernen',
recommend: 'Ich würde es weiterempfehlen',
nameLabel: 'Name unter der Bewertung',
emailLabel: 'E-Mail',
emailHint: 'Nur zur Bestätigung, wird nie gezeigt',
emailError: 'Gib deine E-Mail ein, damit wir die Bewertung bestätigen können.',
submit: 'Bewertung veröffentlichen',
sending: 'Wird veröffentlicht',
preview: 'Vorschau',
previewHint: 'So erscheint deine Bewertung',
previewEmpty: 'Deine Worte erscheinen hier, während du schreibst.',
reviewing: 'Du bewertest',
thanks: 'Danke dir',
thanksText: 'Deine Bewertung ist angekommen. Nach einer kurzen Prüfung erscheint sie auf der Produktseite, meist innerhalb eines Tages.',
you: 'Du',
today: 'Heute',
scrollBack: 'Zurückblättern',
scrollOn: 'Weiterblättern',
basedOn: 'aus',
},
};
type Texts = (typeof TEXTS)['en'];
const FOCUS = 'outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ring)]';
const ACCENT_TEXT = 'text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]';
const SUCCESS_TEXT = 'text-success-foreground';
const MARK = 'rounded-[3px] bg-[color-mix(in_oklab,var(--primary)_24%,transparent)] text-foreground [box-shadow:0_0_0_2px_color-mix(in_oklab,var(--primary)_24%,transparent)]';
/* ------------------------------------------------------------------ */
/* Shared parts */
/* ------------------------------------------------------------------ */
function Head({ eyebrow, title, description, aside }: { eyebrow?: string; title?: string; description?: string; aside?: React.ReactNode }) {
if (!title && !aside) return null;
return (
<div className="mb-10 flex flex-col gap-6 sm:mb-12 sm:flex-row sm:items-end sm:justify-between sm:gap-10">
<div className="max-w-[40rem]">
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
{title && <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>}
{description && (
<p className="kit-rise mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
{description}
</p>
)}
</div>
{aside && <div className="flex shrink-0 flex-wrap items-center gap-3">{aside}</div>}
</div>
);
}
/** Initials in a calm circle, the hue follows the name so a list does not look like one person */
function Initials({ name, size = 'md' }: { name: string; size?: 'sm' | 'md' }) {
const letters = name
.split(/\s+/)
.map((w) => w.charAt(0))
.join('')
.slice(0, 2)
.toUpperCase();
let h = 0;
for (const ch of name) h = (h * 31 + ch.charCodeAt(0)) % 997;
const mix = 10 + (h % 4) * 4;
return (
<span
aria-hidden
className={cn('grid shrink-0 place-items-center rounded-full font-semibold text-foreground', size === 'sm' ? 'size-8 text-[12px]' : 'size-10 text-[13.5px]')}
style={{ background: `color-mix(in oklab, var(--primary) ${mix}%, var(--surface-2))` }}
>
{letters}
</span>
);
}
function Verified({ t, className }: { t: Texts; className?: string }) {
return (
<span className={cn('inline-flex items-center gap-1 text-[12.5px]', SUCCESS_TEXT, className)}>
<Icon name="check-circle" size={13} />
{t.verified}
</span>
);
}
/** Wraps every match of the words in <mark> */
function highlight(text: string, words: string[]): React.ReactNode {
const list = words.map((w) => w.trim()).filter((w) => w.length > 1);
if (!list.length) return text;
const re = new RegExp(`(${list.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'gi');
const parts = text.split(re);
return parts.map((p, i) =>
i % 2 ? (
<mark key={i} className={MARK}>
{p}
</mark>
) : (
p
),
);
}
/** A scale from small to large with a marker that slides in to its place */
function FitScale({ value, labels, className }: { value: number; labels: [string, string, string]; className?: string }) {
const [ref, inView] = useInView<HTMLDivElement>();
const pos = Math.max(0, Math.min(1, value));
return (
<div ref={ref} className={className}>
<div className="relative h-2">
<div className="absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" />
{[0, 0.5, 1].map((x) => (
<span key={x} aria-hidden className="absolute top-1/2 h-2 w-px -translate-y-1/2 bg-border-strong" style={{ left: `${x * 100}%` }} />
))}
<span
aria-hidden
className="absolute top-1/2 size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-background bg-primary shadow-[var(--shadow-sm)] transition-[left] duration-1000 ease-[var(--ease-kit)] motion-reduce:transition-none"
style={{ left: `${round((inView ? pos : 0.5) * 100, 1)}%` }}
/>
</div>
<div className="mt-2.5 grid grid-cols-3 text-[12px] text-muted-foreground">
<span>{labels[0]}</span>
<span className="text-center">{labels[1]}</span>
<span className="text-right">{labels[2]}</span>
</div>
<p className="sr-only">{labels[pos < 0.34 ? 0 : pos > 0.66 ? 2 : 1]}</p>
</div>
);
}
/** A ring that draws to the share when it scrolls in */
function Ring({ value, size = 76 }: { value: number; size?: number }) {
const [ref, inView] = useInView<HTMLSpanElement>();
const r = size / 2 - 5;
const len = round(2 * Math.PI * r);
return (
<span ref={ref} className="relative grid shrink-0 place-items-center" style={{ width: size, height: size }} aria-hidden>
<svg viewBox={`0 0 ${size} ${size}`} className="absolute inset-0 -rotate-90">
<circle cx={size / 2} cy={size / 2} r={r} fill="none" strokeWidth="5" className="stroke-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" />
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
strokeWidth="5"
strokeLinecap="round"
className="stroke-primary transition-[stroke-dashoffset] duration-[1400ms] ease-[var(--ease-kit)] motion-reduce:transition-none"
strokeDasharray={len}
strokeDashoffset={round(len * (1 - (inView ? value / 100 : 0)))}
/>
</svg>
</span>
);
}
/** Yes and No with counts, one choice per visitor */
function Helpful({ review, t }: { review: ProductReview; t: Texts }) {
const locale = useLocale();
const [vote, setVote] = useState<'yes' | 'no' | null>(null);
const btn = (kind: 'yes' | 'no') => {
const on = vote === kind;
const base = (kind === 'yes' ? review.helpful : review.unhelpful) ?? 0;
return (
<button
type="button"
aria-pressed={on}
onClick={() => setVote(on ? null : kind)}
className={cn(
'group/vote relative inline-flex h-9 items-center gap-1.5 rounded-md border px-2.5 text-[12.5px] font-medium tabular-nums transition-[background-color,border-color,color] duration-200 after:absolute after:-inset-1 after:content-[""]',
on ? 'border-foreground bg-foreground text-background' : 'border-border-strong text-muted-foreground hover:bg-accent hover:text-foreground',
FOCUS,
)}
>
<Icon name={kind === 'yes' ? 'thumbs-up' : 'thumbs-down'} size={14} className={cn('transition-transform duration-300 ease-[var(--ease-kit)] group-active/vote:scale-75', on && 'animate-[kitshop-pop_0.4s_var(--ease-kit)]')} />
{kind === 'yes' ? t.yes : t.no}
<Swap value={base + (on ? 1 : 0)}>
<span className="opacity-70">{formatNumber(locale, base + (on ? 1 : 0))}</span>
</Swap>
</button>
);
};
return (
<div className="flex items-center gap-2">
<span className="mr-1 text-[12.5px] text-muted-foreground">{t.helpfulQuestion}</span>
{btn('yes')}
{btn('no')}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Lightbox, shared by the photo wall and the overview */
/* ------------------------------------------------------------------ */
interface PhotoRef {
photo: React.ReactNode;
review: ProductReview;
/** Index of the photo inside its review */
n: number;
}
function Lightbox({ photos, index, onIndex, onClose, t }: { photos: PhotoRef[]; index: number | null; onIndex: (i: number) => void; onClose: () => void; t: Texts }) {
const open = index !== null;
const [modal, present] = useModalMotion(open, (v) => !v && onClose(), { motion: 'pop' });
const [dir, setDir] = useState<1 | -1>(1);
const id = useId();
const last = useRef(0);
if (index !== null) last.current = index;
const i = index ?? last.current;
const item = photos[i];
const go = (d: 1 | -1) => {
setDir(d);
onIndex((i + d + photos.length) % photos.length);
};
const thumbs = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = thumbs.current?.children[i] as HTMLElement | undefined;
el?.scrollIntoView({ block: 'nearest', inline: 'center', behavior: 'smooth' });
}, [i]);
return (
<>
<DialogMotionStyle />
<dialog
{...modal}
aria-labelledby={`${id}-t`}
onKeyDown={(e) => {
if (e.key === 'ArrowRight') go(1);
if (e.key === 'ArrowLeft') go(-1);
}}
className={cn('kitfx-dialog m-auto w-full max-w-none overflow-visible bg-transparent p-3 text-left outline-none data-[closing]:pointer-events-none sm:w-[calc(100%-2rem)] sm:max-w-[60rem] sm:p-4', scrim)}
>
{present && item && (
<div className="relative grid max-h-[calc(100dvh-1.5rem)] grid-cols-1 overflow-hidden rounded-2xl border border-border bg-popover shadow-[var(--highlight),var(--shadow-lg)] md:grid-cols-[minmax(0,1.35fr)_minmax(0,1fr)]">
<div className="relative aspect-[4/3] overflow-hidden bg-surface-2 md:aspect-auto md:min-h-[30rem]">
<div key={i} className={cn('absolute inset-0 [&>*]:size-full [&>*]:object-cover', dir === 1 ? 'animate-[kitshop-next_0.45s_var(--ease-kit)]' : 'animate-[kitshop-prev_0.45s_var(--ease-kit)]')}>
{item.photo}
</div>
<div className="absolute inset-x-3 bottom-3 flex items-center justify-between">
<button type="button" onClick={() => go(-1)} aria-label={t.previous} className={cn('grid size-11 place-items-center rounded-full border border-border bg-background/90 text-foreground shadow-[var(--shadow-md)] backdrop-blur transition-transform duration-200 hover:-translate-x-0.5 active:scale-95', FOCUS)}>
<Icon name="arrow-left" size={17} />
</button>
<span className="rounded-full border border-border bg-background/90 px-3 py-1 text-[12.5px] font-medium tabular-nums text-foreground backdrop-blur" aria-live="polite">
{i + 1} / {photos.length}
</span>
<button type="button" onClick={() => go(1)} aria-label={t.next} className={cn('grid size-11 place-items-center rounded-full border border-border bg-background/90 text-foreground shadow-[var(--shadow-md)] backdrop-blur transition-transform duration-200 hover:translate-x-0.5 active:scale-95', FOCUS)}>
<Icon name="arrow-right" size={17} />
</button>
</div>
</div>
<div className="flex min-h-0 flex-col overflow-y-auto">
<div key={i} className="flex-1 animate-[kitshop-fade_0.4s_var(--ease-kit)] p-6 pe-14 sm:p-7 sm:pe-14">
<div className="flex items-center gap-3">
<Initials name={item.review.author} />
<div className="min-w-0">
<p id={`${id}-t`} className="text-[14.5px] font-semibold text-foreground">
{item.review.author}
</p>
<p className="text-[12.5px] text-muted-foreground">{[item.review.location, item.review.date].filter(Boolean).join(' · ')}</p>
</div>
</div>
<Stars value={item.review.rating} size="sm" tone="neutral" className="mt-5" />
{item.review.title && <h3 className="mt-2 text-[16px] font-semibold tracking-[-0.01em] text-foreground">{item.review.title}</h3>}
<p className="mt-2 text-pretty text-[14px] leading-relaxed text-muted-foreground">{item.review.body}</p>
<div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2">
{item.review.verified && <Verified t={t} />}
{item.review.variant && <span className="rounded-md bg-muted px-2 py-1 text-[12px] text-muted-foreground">{item.review.variant}</span>}
</div>
</div>
<div ref={thumbs} className="kit-no-scrollbar kit-fade-end flex gap-2 overflow-x-auto border-t border-border p-3" aria-label={t.photosBy}>
{photos.map((p, k) => (
<button
key={k}
type="button"
aria-label={`${t.photo} ${k + 1}, ${p.review.author}`}
aria-current={k === i || undefined}
onClick={() => {
setDir(k > i ? 1 : -1);
onIndex(k);
}}
className={cn('relative size-14 shrink-0 overflow-hidden rounded-lg bg-surface-2 transition-[opacity,box-shadow] duration-300 [&>*]:size-full [&>*]:object-cover', k === i ? 'opacity-100 shadow-[0_0_0_2px_var(--foreground)]' : 'opacity-55 hover:opacity-100', FOCUS)}
>
{p.photo}
</button>
))}
</div>
</div>
<button type="button" onClick={onClose} aria-label={t.close} className={cn('group/close absolute end-3 top-3 z-10 grid size-10 place-items-center rounded-full border border-border bg-background/90 text-foreground backdrop-blur hover:bg-accent', FOCUS)}>
<Icon name="x" size={17} className="transition-transform duration-300 ease-[var(--ease-kit)] group-hover/close:rotate-90 motion-reduce:transition-none" />
</button>
</div>
)}
</dialog>
</>
);
}
function photoRefs(reviews: ProductReview[]): PhotoRef[] {
return reviews.flatMap((review) => (review.photos ?? []).map((photo, n) => ({ photo, review, n })));
}
/* ------------------------------------------------------------------ */
/* overview */
/* ------------------------------------------------------------------ */
type Sort = 'newest' | 'helpful' | 'highest' | 'lowest';
function ReviewRow({ review, t, query, onPhoto, delay = 0, fitLabels }: { review: ProductReview; t: Texts; query: string; onPhoto: (n: number) => void; delay?: number; fitLabels?: [string, string, string] }) {
const words = query ? [query] : [];
return (
<li className="grid grid-cols-1 gap-5 border-b border-border py-8 animate-[kitshop-in_0.5s_var(--ease-kit)_both] lg:grid-cols-[13.5rem_minmax(0,1fr)] lg:gap-12" style={{ animationDelay: `${delay}ms` }}>
<div className="flex items-start gap-3 lg:flex-col lg:gap-4">
<Initials name={review.author} />
<div className="min-w-0">
<p className="text-[14.5px] font-semibold text-foreground">{review.author}</p>
{review.location && <p className="text-[12.5px] text-muted-foreground">{review.location}</p>}
{review.verified && <Verified t={t} className="mt-1.5" />}
{(review.variant || (review.fit !== undefined && fitLabels)) && (
<dl className="mt-3 hidden space-y-1 text-[12.5px] lg:block">
{review.variant && (
<div className="flex gap-1.5">
<dt className="sr-only">Variant</dt>
<dd className="text-muted-foreground">{review.variant}</dd>
</div>
)}
{review.fit !== undefined && fitLabels && (
<div className="flex gap-1.5">
<dt className="text-subtle-foreground">{t.fit}</dt>
<dd className="text-foreground">{fitLabels[review.fit < 0.34 ? 0 : review.fit > 0.66 ? 2 : 1]}</dd>
</div>
)}
</dl>
)}
</div>
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<Stars value={review.rating} size="sm" tone="neutral" />
<span aria-hidden className="size-0.5 rounded-full bg-subtle-foreground" />
<p className="text-[12.5px] tabular-nums text-muted-foreground">{review.date}</p>
</div>
{review.title && <h3 className="mt-3 text-[16px] font-semibold tracking-[-0.01em] text-foreground">{highlight(review.title, words)}</h3>}
<p className="mt-1.5 max-w-[44rem] text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{highlight(review.body, words)}</p>
{(review.variant || review.fit !== undefined) && (
<p className="mt-3 flex flex-wrap gap-2 text-[12px] text-muted-foreground lg:hidden">
{review.variant && <span className="rounded-md bg-muted px-2 py-1">{review.variant}</span>}
{review.fit !== undefined && fitLabels && (
<span className="rounded-md bg-muted px-2 py-1">
{t.fit} · {fitLabels[review.fit < 0.34 ? 0 : review.fit > 0.66 ? 2 : 1]}
</span>
)}
</p>
)}
{review.photos?.length ? (
<ul className="mt-4 flex flex-wrap gap-2">
{review.photos.map((p, n) => (
<li key={n}>
<button
type="button"
onClick={() => onPhoto(n)}
aria-label={`${t.openPhoto} ${review.author}`}
className={cn('group/ph relative block size-20 overflow-hidden rounded-lg bg-surface-2 sm:size-24 [&>*]:size-full [&>*]:object-cover', FOCUS)}
>
<span className="block size-full transition-transform duration-500 ease-[var(--ease-kit)] group-hover/ph:scale-110 motion-reduce:transition-none [&>*]:size-full">{p}</span>
<span aria-hidden className="absolute inset-0 grid place-items-center bg-[color-mix(in_oklab,var(--background)_40%,transparent)] opacity-0 transition-opacity duration-300 group-hover/ph:opacity-100">
<Icon name="zoom-in" size={18} className="text-foreground" />
</span>
</button>
</li>
))}
</ul>
) : null}
{review.reply && (
<div className="mt-5 max-w-[44rem] rounded-xl border border-border bg-surface px-4 py-3.5">
<p className="flex flex-wrap items-center gap-x-2 text-[12.5px] font-medium text-foreground">
<Icon name="reply" size={14} className="text-muted-foreground" />
{t.replyFrom} {review.reply.author}
{review.reply.date && <span className="font-normal text-muted-foreground">· {review.reply.date}</span>}
</p>
<p className="mt-1.5 text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{review.reply.body}</p>
</div>
)}
<div className="mt-5">
<Helpful review={review} t={t} />
</div>
</div>
</li>
);
}
function Chip({ on, onClick, children, count }: { on: boolean; onClick: () => void; children: React.ReactNode; count?: number }) {
const locale = useLocale();
return (
<button
type="button"
aria-pressed={on}
onClick={onClick}
className={cn(
'inline-flex h-11 shrink-0 items-center gap-1.5 rounded-full border px-4 sm:h-10 text-[13.5px] font-medium transition-[background-color,border-color,color] duration-200',
on ? 'border-foreground bg-foreground text-background' : 'border-border-strong text-muted-foreground hover:bg-accent hover:text-foreground',
FOCUS,
)}
>
{children}
{count !== undefined && <span className={cn('tabular-nums', on ? 'opacity-70' : 'text-subtle-foreground')}>{formatNumber(locale, count)}</span>}
</button>
);
}
function Overview(p: ProductReviewsProps & { t: Texts }) {
const { t, summary, reviews = [] } = p;
const locale = useLocale();
const page = p.pageSize ?? 4;
const [stars, setStars] = useState<number | null>(null);
const [photosOnly, setPhotosOnly] = useState(false);
const [verifiedOnly, setVerifiedOnly] = useState(false);
const [query, setQuery] = useState('');
const [sort, setSort] = useState<Sort>('newest');
const [shown, setShown] = useState(page);
const [lightbox, setLightbox] = useState<number | null>(null);
const searchId = useId();
const list = useMemo(() => {
const q = query.trim().toLowerCase();
const out = reviews
.map((r, i) => ({ r, i }))
.filter(({ r }) => (stars ? Math.round(r.rating) === stars : true))
.filter(({ r }) => (photosOnly ? !!r.photos?.length : true))
.filter(({ r }) => (verifiedOnly ? !!r.verified : true))
.filter(({ r }) => (q ? `${r.title ?? ''} ${r.body}`.toLowerCase().includes(q) : true));
if (sort === 'helpful') out.sort((a, b) => (b.r.helpful ?? 0) - (a.r.helpful ?? 0) || a.i - b.i);
if (sort === 'highest') out.sort((a, b) => b.r.rating - a.r.rating || a.i - b.i);
if (sort === 'lowest') out.sort((a, b) => a.r.rating - b.r.rating || a.i - b.i);
return out.map((x) => x.r);
}, [reviews, stars, photosOnly, verifiedOnly, query, sort]);
const key = `${stars}-${photosOnly}-${verifiedOnly}-${query}-${sort}`;
const visible = list.slice(0, shown);
const photos = useMemo(() => photoRefs(list), [list]);
const withPhotos = reviews.filter((r) => r.photos?.length).length;
const verifiedCount = reviews.filter((r) => r.verified).length;
const fitLabels = summary?.fitLabels;
const reset = () => {
setStars(null);
setPhotosOnly(false);
setVerifiedOnly(false);
setQuery('');
setShown(page);
};
const filtered = stars !== null || photosOnly || verifiedOnly || query.trim() !== '';
// Without filters the total is the whole count from the summary, the list holds the first pages
const total = filtered ? list.length : Math.max(list.length, summary?.count ?? 0);
const progress = total ? Math.min(1, visible.length / total) : 0;
return (
<Section className={p.className}>
<ShopMotion />
<Head
eyebrow={p.eyebrow}
title={p.title}
description={p.description}
aside={
<Button href={p.writeHref ?? '#write-review'} variant="outline" iconLeft="edit">
{t.writeReview}
</Button>
}
/>
{summary && (
<Reveal effect="rise" className="grid grid-cols-1 gap-px overflow-hidden rounded-2xl border border-border bg-border shadow-[var(--highlight),var(--shadow-sm)] lg:grid-cols-[minmax(0,1.55fr)_minmax(0,1fr)]">
<div className="bg-card p-6 sm:p-8">
{summary.distribution ? (
<RatingSummary
average={summary.average}
distribution={summary.distribution}
tone="neutral"
selected={stars ?? undefined}
onSelect={(s) => {
setStars(stars === s ? null : s);
setShown(page);
}}
/>
) : (
<Stars value={summary.average} showValue count={summary.count} tone="neutral" />
)}
</div>
<div className="grid grid-cols-1 gap-px bg-border sm:grid-cols-2 lg:grid-cols-1">
{summary.recommend !== undefined && (
<div className="flex items-center gap-5 bg-card p-6 sm:p-8">
<Ring value={summary.recommend} />
<p className="text-[14px] leading-snug text-muted-foreground">
<span className="block text-[28px] font-semibold leading-none tracking-[-0.03em] text-foreground tabular-nums">{formatPercent(locale, summary.recommend)}</span>
<span className="mt-1.5 block">{t.wouldRecommend}</span>
</p>
</div>
)}
{summary.fit !== undefined && fitLabels && (
<div className="bg-card p-6 sm:p-8">
<p className="mb-4 flex items-baseline justify-between gap-3 text-[14px] text-muted-foreground">
{t.fit}
<span className="font-semibold text-foreground">{fitLabels[summary.fit < 0.34 ? 0 : summary.fit > 0.66 ? 2 : 1]}</span>
</p>
<FitScale value={summary.fit} labels={fitLabels} />
</div>
)}
</div>
</Reveal>
)}
<div className="mt-10 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="kit-no-scrollbar kit-fade-end -mx-6 flex gap-2 overflow-x-auto px-6 py-1 sm:mx-0 sm:flex-wrap sm:overflow-visible sm:px-0 sm:[mask-image:none]" role="group" aria-label={t.search}>
<Chip on={!filtered} onClick={reset}>
{t.all}
</Chip>
<Chip
on={photosOnly}
onClick={() => {
setPhotosOnly(!photosOnly);
setShown(page);
}}
count={withPhotos}
>
<Icon name="camera" size={15} />
{t.withPhotos}
</Chip>
<Chip
on={verifiedOnly}
onClick={() => {
setVerifiedOnly(!verifiedOnly);
setShown(page);
}}
count={verifiedCount}
>
<Icon name="check-circle" size={15} />
{t.verifiedOnly}
</Chip>
{stars !== null && (
<button
type="button"
onClick={() => setStars(null)}
aria-label={t.clearStars}
className={cn('inline-flex h-11 shrink-0 animate-[kitshop-pop_0.35s_var(--ease-kit)] items-center sm:h-10 gap-1.5 rounded-full border border-foreground bg-foreground px-4 text-[13.5px] font-medium text-background', FOCUS)}
>
{stars} {stars === 1 ? t.star : t.stars}
<Icon name="x" size={14} />
</button>
)}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_12rem] lg:w-[27rem] lg:shrink-0">
<label htmlFor={searchId} className="relative block">
<span className="sr-only">{t.search}</span>
<Icon name="search" size={16} className="pointer-events-none absolute start-3.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
<input
id={searchId}
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setShown(page);
}}
placeholder={t.searchPlaceholder}
className="h-11 w-full rounded-lg border border-input bg-background ps-10 pe-3 text-[15px] text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] placeholder:text-subtle-foreground hover:border-border-strong focus:border-border-strong focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--ring)_22%,transparent)]"
/>
</label>
<NativeSelect
aria-label={t.sort}
value={sort}
onChange={(e) => setSort(e.target.value as Sort)}
options={[
{ value: 'newest', label: t.sortNewest },
{ value: 'helpful', label: t.sortHelpful },
{ value: 'highest', label: t.sortHighest },
{ value: 'lowest', label: t.sortLowest },
]}
/>
</div>
</div>
<p className="mt-6 border-b border-border pb-4 text-[13px] text-muted-foreground" aria-live="polite">
<span className="font-medium tabular-nums text-foreground">{formatNumber(locale, total)}</span> {filtered ? (total === 1 ? t.matchOne : t.match) : total === 1 ? t.review : t.reviews}
</p>
{list.length ? (
<ul key={key}>
{visible.map((r, k) => (
<ReviewRow
key={r.author + r.date}
review={r}
t={t}
query={query.trim()}
fitLabels={fitLabels}
delay={k >= shown - page && k >= page ? (k - (shown - page)) * 70 : Math.min(k, 4) * 60}
onPhoto={(n) => setLightbox(photos.findIndex((x) => x.review === r && x.n === n))}
/>
))}
</ul>
) : (
<div className="flex flex-col items-center gap-4 py-16 text-center">
<span className="grid size-12 place-items-center rounded-full bg-muted text-muted-foreground">
<Icon name="search" size={20} />
</span>
<p className="max-w-[24rem] text-pretty text-[14px] text-muted-foreground">{t.noMatch}</p>
<Button variant="outline" onClick={reset}>
{t.resetFilters}
</Button>
</div>
)}
{list.length > 0 && (
<div className="flex flex-col items-center gap-4 pt-10">
<p className="text-[13px] tabular-nums text-muted-foreground">
{t.showing} {formatNumber(locale, visible.length)} {t.of} {formatNumber(locale, total)}
</p>
<span aria-hidden className="relative h-0.5 w-48 overflow-hidden rounded-full bg-border">
<span className="absolute inset-0 origin-left rounded-full bg-foreground transition-transform duration-700 ease-[var(--ease-kit)]" style={{ transform: `scaleX(${round(progress, 3)})` }} />
</span>
{(visible.length < list.length || (p.onLoadMore && visible.length < total)) && (
<Button variant="outline" icon="arrow-down" onClick={() => (visible.length < list.length ? setShown((s) => s + page) : p.onLoadMore?.())}>
{t.showMore}
</Button>
)}
</div>
)}
<Lightbox photos={photos} index={lightbox} onIndex={setLightbox} onClose={() => setLightbox(null)} t={t} />
</Section>
);
}
/* ------------------------------------------------------------------ */
/* photo-wall */
/* ------------------------------------------------------------------ */
/* Tile shapes on desktop, repeating: one large square, then smaller ones */
const WALL = ['lg:col-span-2 lg:row-span-2', '', '', 'lg:row-span-2', '', '', 'lg:col-span-2', 'lg:col-span-2', ''];
function PhotoWall(p: ProductReviewsProps & { t: Texts }) {
const { t, summary, reviews = [] } = p;
const locale = useLocale();
const photos = useMemo(() => photoRefs(reviews), [reviews]);
const [lightbox, setLightbox] = useState<number | null>(p.defaultPhoto ?? null);
const max = 9;
const shown = photos.slice(0, max);
const more = Math.max(0, (summary?.photos ?? photos.length) - shown.length);
const parallax = useScrollProgress<HTMLDivElement>();
return (
<Section className={p.className}>
<ShopMotion />
<Head
eyebrow={p.eyebrow}
title={p.title}
description={p.description}
aside={
summary && (
<div className="flex items-center gap-4 rounded-xl border border-border bg-card px-4 py-3 shadow-[var(--highlight),var(--shadow-sm)]">
<span className="text-[28px] font-semibold leading-none tracking-[-0.03em] text-foreground tabular-nums">{formatNumber(locale, summary.average, 1)}</span>
<span>
<Stars value={summary.average} size="sm" tone="neutral" />
<span className="mt-1 block text-[12.5px] text-muted-foreground">
{formatNumber(locale, summary.count)} {t.reviews}
{summary.photos !== undefined && ` · ${formatNumber(locale, summary.photos)} ${t.photos}`}
</span>
</span>
</div>
)
}
/>
<div ref={parallax}>
<Stagger step={60} as="ul" className="grid grid-flow-dense auto-rows-[9.5rem] grid-cols-2 gap-2 sm:auto-rows-[11rem] sm:grid-cols-3 sm:gap-3 lg:auto-rows-[10.5rem] lg:grid-cols-5">
{shown.map((ph, i) => {
const last = i === shown.length - 1 && more > 0;
return (
<li key={i} className={cn(WALL[i % WALL.length], i === 0 && 'col-span-2 row-span-2 sm:col-span-2 sm:row-span-2')}>
<button
type="button"
onClick={() => setLightbox(i)}
aria-label={`${t.openPhoto} ${ph.review.author}, ${ph.review.rating} ${t.stars}`}
className={cn('group/tile relative block size-full overflow-hidden rounded-xl bg-surface-2 text-left', FOCUS)}
>
<span
className="absolute inset-x-0 -inset-y-[5%] block transition-transform duration-[900ms] ease-[var(--ease-kit)] group-hover/tile:scale-[1.06] motion-reduce:transition-none [&>*]:size-full [&>*]:object-cover"
style={i === 0 ? { translate: '0 calc((0.5 - var(--kit-progress, 0.5)) * 6%)' } : undefined}
>
{ph.photo}
</span>
{last ? (
<span className="absolute inset-0 grid place-items-center bg-[color-mix(in_oklab,var(--background)_62%,transparent)] backdrop-blur-[2px]">
<span className="text-center">
<span className="block text-[26px] font-semibold tracking-[-0.03em] text-foreground tabular-nums">+{formatNumber(locale, more)}</span>
<span className="text-[12.5px] font-medium text-muted-foreground">{t.morePhotos}</span>
</span>
</span>
) : (
<span className="absolute inset-x-2 bottom-2 flex translate-y-1 flex-wrap items-center justify-between gap-x-2 gap-y-0.5 rounded-lg border border-border bg-background/88 px-2.5 py-1.5 opacity-0 backdrop-blur transition-[opacity,transform] duration-300 ease-[var(--ease-kit)] group-hover/tile:translate-y-0 group-hover/tile:opacity-100 group-focus-visible/tile:translate-y-0 group-focus-visible/tile:opacity-100 max-lg:hidden">
<span className="text-[12.5px] font-medium text-foreground">{ph.review.author}</span>
<Stars value={ph.review.rating} size="sm" tone="neutral" />
</span>
)}
</button>
</li>
);
})}
</Stagger>
</div>
<Lightbox photos={photos} index={lightbox} onIndex={setLightbox} onClose={() => setLightbox(null)} t={t} />
</Section>
);
}
/* ------------------------------------------------------------------ */
/* highlights */
/* ------------------------------------------------------------------ */
function AspectBar({ label, value, delay }: { label: string; value: number; delay: number }) {
const locale = useLocale();
const [ref, inView] = useInView<HTMLLIElement>();
return (
<li ref={ref}>
<div className="flex items-baseline justify-between gap-3 text-[14px]">
<span className="text-muted-foreground">{label}</span>
<span className="font-semibold tabular-nums text-foreground">{formatNumber(locale, value, 1)}</span>
</div>
<span className="relative mt-2 block h-1.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" aria-hidden>
<span
className="absolute inset-0 origin-left rounded-full bg-foreground transition-transform duration-1000 ease-[var(--ease-kit)] motion-reduce:transition-none"
style={{ transform: `scaleX(${inView ? round(value / 5, 3) : 0})`, transitionDelay: `${delay}ms` }}
/>
</span>
</li>
);
}
function Highlights(p: ProductReviewsProps & { t: Texts }) {
const { t, summary, reviews = [], topics = [] } = p;
const locale = useLocale();
const [topic, setTopic] = useState<string | null>(null);
const active = topics.find((x) => x.id === topic);
const quotes = (topic ? reviews.filter((r) => r.topics?.includes(topic)) : reviews).slice(0, 3);
// Only the picked topic lights up, with all topics the quotes stay calm
const words = active ? (active.words?.length ? active.words : [active.label]) : [];
return (
<Section className={p.className}>
<ShopMotion />
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:gap-16">
<Reveal effect="rise" className="lg:sticky lg:top-8 lg:self-start">
{summary && (
<div className="flex items-end gap-4">
<span className="text-[56px] font-semibold leading-none tracking-[-0.04em] text-foreground tabular-nums">{formatNumber(locale, summary.average, 1)}</span>
<span className="pb-1.5">
<Stars value={summary.average} size="sm" tone="neutral" />
<span className="mt-1 block text-[13px] text-muted-foreground">
{t.basedOn} {formatNumber(locale, summary.count)} {t.reviews}
</span>
</span>
</div>
)}
{summary?.aspects?.length ? (
<ul className="mt-8 space-y-5">
{summary.aspects.map((a, i) => (
<AspectBar key={a.label} label={a.label} value={a.value} delay={i * 110} />
))}
</ul>
) : null}
{summary?.fit !== undefined && summary.fitLabels && (
<div className="mt-8 border-t border-border pt-6">
<p className="mb-4 text-[14px] text-muted-foreground">{t.fit}</p>
<FitScale value={summary.fit} labels={summary.fitLabels} />
</div>
)}
</Reveal>
<div className="min-w-0">
{summary?.text && (
<Reveal effect="rise" className="rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-sm)] sm:p-7">
<p className="flex items-center gap-2 text-[12.5px] font-medium text-muted-foreground">
<Icon name="quote" size={15} className={ACCENT_TEXT} />
{t.summaryFrom} {formatNumber(locale, summary.count)} {t.reviews}
</p>
<p className="mt-3 text-pretty text-[17px] leading-relaxed tracking-[-0.01em] text-foreground sm:text-[19px]">{summary.text}</p>
</Reveal>
)}
<h3 className="mt-10 text-[15px] font-semibold text-foreground">{t.mentions}</h3>
<div className="mt-4 flex flex-wrap gap-2" role="group" aria-label={t.mentions}>
<Chip on={topic === null} onClick={() => setTopic(null)}>
{t.allTopics}
</Chip>
{topics.map((x) => (
<button
key={x.id}
type="button"
aria-pressed={topic === x.id}
onClick={() => setTopic(topic === x.id ? null : x.id)}
className={cn(
'inline-flex h-11 items-center gap-2 rounded-full border px-4 sm:h-10 text-[13.5px] font-medium transition-[background-color,border-color,color] duration-200',
topic === x.id ? 'border-foreground bg-foreground text-background' : 'border-border-strong text-muted-foreground hover:bg-accent hover:text-foreground',
FOCUS,
)}
>
<span aria-hidden className={cn('size-1.5 rounded-full', x.tone === 'mixed' ? 'bg-warning' : 'bg-success')} />
{x.label}
<span className={cn('tabular-nums', topic === x.id ? 'opacity-70' : 'text-subtle-foreground')}>
<span className="sr-only">, </span>
{formatNumber(locale, x.count)}
<span className="sr-only"> {t.mentioned}</span>
</span>
</button>
))}
</div>
<ul key={topic ?? 'all'} className="mt-6 grid grid-cols-1 gap-3" aria-live="polite">
{quotes.map((r, i) => (
<li
key={r.author + i}
className="rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)] animate-[kitshop-in_0.5s_var(--ease-kit)_both] sm:p-6"
style={{ animationDelay: `${i * 80}ms` }}
>
<div className="flex items-center justify-between gap-3">
<Stars value={r.rating} size="sm" tone="neutral" />
<span className="text-[12.5px] text-muted-foreground">{r.date}</span>
</div>
<p className="mt-3 text-pretty text-[15px] leading-relaxed text-foreground">{highlight(r.body, words)}</p>
<p className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-1 text-[13px]">
<span className="font-medium text-foreground">{r.author}</span>
{r.verified && <Verified t={t} />}
</p>
</li>
))}
</ul>
</div>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* write-review */
/* ------------------------------------------------------------------ */
function WriteReview(p: ProductReviewsProps & { t: Texts }) {
const { t } = p;
const min = p.minLength ?? 60;
const fitLabels = p.summary?.fitLabels;
const id = useId();
const d = p.defaultValues ?? {};
const [rating, setRating] = useState(d.rating ?? 0);
const [title, setTitle] = useState(d.title ?? '');
const [body, setBody] = useState(d.body ?? '');
const [fit, setFit] = useState<number | null>(d.fit ?? null);
const [recommend, setRecommend] = useState(true);
const [name, setName] = useState(d.name ?? '');
const [email, setEmail] = useState(d.email ?? '');
const [files, setFiles] = useState<{ file: File; url: string }[]>([]);
const [tried, setTried] = useState(false);
const [state, setState] = useState<'idle' | 'sending' | 'done'>('idle');
const [shake, setShake] = useState(0);
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => () => files.forEach((f) => URL.revokeObjectURL(f.url)), [files]);
const left = Math.max(0, min - body.trim().length);
const ratingError = tried && !rating;
const bodyError = tried && left > 0;
const emailError = tried && !/^\S+@\S+\.\S+$/.test(email);
const submit = (e: React.FormEvent) => {
e.preventDefault();
setTried(true);
if (!rating || left > 0 || !/^\S+@\S+\.\S+$/.test(email)) {
setShake((s) => s + 1);
const first = !rating ? `${id}-rating` : left > 0 ? `${id}-body` : `${id}-email`;
document.getElementById(first)?.focus?.();
return;
}
setState('sending');
p.onSubmit?.({ rating, title, body, fit, recommend, name, email, photos: files.map((f) => f.file) });
window.setTimeout(() => setState('done'), 900);
};
const ringLen = round(2 * Math.PI * 9);
const field = 'w-full rounded-lg border border-input bg-background px-3.5 text-[14.5px] text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] placeholder:text-subtle-foreground hover:border-border-strong focus:border-border-strong focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--ring)_22%,transparent)]';
const invalidField = 'border-destructive focus:border-destructive focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--destructive)_18%,transparent)]';
const preview = (
<div className="rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)] sm:p-6">
<div className="flex items-center gap-3">
{name.trim() ? (
<Initials name={name.trim()} />
) : (
<span aria-hidden className="grid size-10 shrink-0 place-items-center rounded-full bg-muted text-muted-foreground">
<Icon name="user" size={18} />
</span>
)}
<div className="min-w-0">
<p className="truncate text-[14.5px] font-semibold text-foreground">{name.trim() || t.you}</p>
<p className="text-[12.5px] text-muted-foreground">{t.today}</p>
</div>
</div>
<div className="mt-4 min-h-[18px]">
<Stars key={rating} value={rating} size="sm" tone="neutral" />
</div>
{title.trim() && <h3 className="mt-2.5 break-words text-[16px] font-semibold tracking-[-0.01em] text-foreground">{title}</h3>}
<p className={cn('mt-1.5 whitespace-pre-line break-words text-pretty text-[14px] leading-relaxed', body.trim() ? 'text-muted-foreground' : 'italic text-subtle-foreground')}>{body.trim() || t.previewEmpty}</p>
{files.length > 0 && (
<div className="mt-4 flex gap-2">
{files.map((f) => (
<img key={f.url} src={f.url} alt="" className="size-14 rounded-lg object-cover" />
))}
</div>
)}
{(fit !== null || p.product?.variant) && (
<p className="mt-4 flex flex-wrap gap-2 text-[12px] text-muted-foreground">
{p.product?.variant && <span className="rounded-md bg-muted px-2 py-1">{p.product.variant}</span>}
{fit !== null && fitLabels && (
<span className="rounded-md bg-muted px-2 py-1">
{t.fit} · {fitLabels[fit === 0 ? 0 : fit === 1 ? 2 : 1]}
</span>
)}
</p>
)}
</div>
);
if (state === 'done') {
return (
<Section className={p.className}>
<ShopMotion />
<div id="write-review" className="mx-auto max-w-[34rem] text-center">
<span className="mx-auto grid size-16 animate-[kitshop-pop_0.6s_var(--ease-kit)] place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-success-foreground">
<svg viewBox="0 0 24 24" className="size-8" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M5 12.5 10 17.5 19 7" strokeDasharray="24" className="animate-[kitshop-draw_0.6s_var(--ease-kit)_0.2s_both]" />
</svg>
</span>
<h2 className="mt-6 text-h3 font-semibold text-foreground" tabIndex={-1} ref={(el) => el?.focus()}>
{t.thanks}
{name.trim() ? `, ${name.trim().split(/\s+/)[0]}` : ''}
</h2>
<p className="mx-auto mt-3 max-w-[28rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{t.thanksText}</p>
<div className="mx-auto mt-8 max-w-[26rem] text-left animate-[kitshop-in_0.6s_var(--ease-kit)_0.25s_both]">{preview}</div>
</div>
</Section>
);
}
return (
<Section className={p.className}>
<ShopMotion />
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<div id="write-review" className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,22rem)] lg:gap-16">
<form noValidate onSubmit={submit} className="min-w-0 space-y-8">
{p.product && (
<div className="flex w-fit max-w-full items-center gap-4 rounded-xl border border-border bg-card p-3 pe-6 shadow-[var(--highlight),var(--shadow-sm)]">
{p.product.image && <span className="size-14 shrink-0 overflow-hidden rounded-lg bg-surface-2 [&>*]:size-full [&>*]:object-cover">{p.product.image}</span>}
<span className="min-w-0">
<span className="block text-[12.5px] text-muted-foreground">{t.reviewing}</span>
<span className="block truncate text-[15px] font-semibold text-foreground">{p.product.name}</span>
{p.product.variant && <span className="block truncate text-[12.5px] text-muted-foreground">{p.product.variant}</span>}
</span>
</div>
)}
<fieldset>
<legend className="mb-2 text-[14px] font-medium text-foreground">
{t.yourRating} <span className="text-destructive" aria-hidden>*</span>
</legend>
<div id={`${id}-rating`} tabIndex={-1} key={ratingError ? `e${shake}` : 'ok'} className={cn('outline-none', ratingError && 'animate-[kitshop-shake_0.4s_var(--ease-kit)]')}>
<Stars value={rating} onChange={setRating} size="lg" tone="neutral" label={t.yourRating} name={`${id}-stars`} />
</div>
{ratingError && (
<p className="mt-1.5 flex items-center gap-1.5 text-[13px] text-destructive" role="alert">
<Icon name="alert-circle" size={14} />
{t.ratingNeeded}
</p>
)}
</fieldset>
<div>
<label htmlFor={`${id}-title`} className="mb-2 block text-[14px] font-medium text-foreground">
{t.titleLabel}
</label>
<input id={`${id}-title`} value={title} onChange={(e) => setTitle(e.target.value)} maxLength={80} placeholder={t.titlePlaceholder} className={cn(field, 'h-11')} />
</div>
<div>
<label htmlFor={`${id}-body`} className="mb-2 block text-[14px] font-medium text-foreground">
{t.bodyLabel} <span className="text-destructive" aria-hidden>*</span>
</label>
<textarea
id={`${id}-body`}
value={body}
onChange={(e) => setBody(e.target.value)}
rows={5}
placeholder={t.bodyPlaceholder}
aria-invalid={bodyError || undefined}
aria-describedby={`${id}-count`}
className={cn(field, 'min-h-32 resize-y py-3 leading-relaxed', bodyError && invalidField)}
/>
<p id={`${id}-count`} className={cn('mt-2 flex items-center gap-2 text-[13px]', bodyError ? 'text-destructive' : left ? 'text-muted-foreground' : SUCCESS_TEXT)} aria-live="polite">
<svg viewBox="0 0 22 22" className="size-[18px] -rotate-90" aria-hidden>
<circle cx="11" cy="11" r="9" fill="none" strokeWidth="2.5" className="stroke-[color-mix(in_oklab,var(--foreground)_12%,transparent)]" />
<circle
cx="11"
cy="11"
r="9"
fill="none"
strokeWidth="2.5"
strokeLinecap="round"
className={cn('transition-[stroke-dashoffset] duration-300', left ? 'stroke-foreground' : 'stroke-success')}
strokeDasharray={ringLen}
strokeDashoffset={round(ringLen * (left / min))}
/>
</svg>
{left ? (bodyError ? `${t.bodyShort} ` : '') + `${left} ${t.charsLeft}` : t.charsDone}
</p>
</div>
{fitLabels && (
<fieldset>
<legend className="mb-2 text-[14px] font-medium text-foreground">{t.fitLabel}</legend>
<div className="relative grid grid-cols-3 rounded-lg border border-border-strong bg-surface p-1">
{fit !== null && (
<span aria-hidden className="absolute inset-y-1 left-1 w-[calc((100%-0.5rem)/3)] rounded-md bg-background shadow-[var(--highlight),var(--shadow-sm)] transition-transform duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `translateX(${fit * 200}%)` }} />
)}
{fitLabels.map((label, i) => {
const v = i / 2;
return (
<label key={label} className={cn('relative z-10 grid min-h-11 cursor-pointer place-items-center rounded-md px-2 text-center text-[13px] font-medium transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-[var(--ring)]', fit === v ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}>
<input type="radio" name={`${id}-fit`} value={v} checked={fit === v} onChange={() => setFit(v)} className="sr-only" />
{label}
</label>
);
})}
</div>
</fieldset>
)}
<div>
<p className="mb-2 flex items-baseline justify-between gap-3 text-[14px] font-medium text-foreground">
{t.photosLabel}
<span className="text-[12.5px] font-normal text-muted-foreground">{t.photosHint}</span>
</p>
<div className="grid grid-cols-4 gap-2 sm:max-w-[26rem]">
{files.map((f, i) => (
<div key={f.url} className="group/ph relative aspect-square animate-[kitshop-pop_0.4s_var(--ease-kit)] overflow-hidden rounded-lg bg-surface-2">
<img src={f.url} alt={`${t.photo} ${i + 1}`} className="size-full object-cover" />
<button
type="button"
aria-label={`${t.removePhoto} ${i + 1}`}
onClick={() => setFiles((list) => list.filter((x) => x !== f))}
className={cn('absolute right-1 top-1 grid size-8 place-items-center rounded-full bg-background/90 text-foreground shadow-[var(--shadow-sm)] backdrop-blur', FOCUS)}
>
<Icon name="x" size={14} />
</button>
</div>
))}
{files.length < 4 && (
<button
type="button"
onClick={() => fileRef.current?.click()}
className={cn('group/add grid aspect-square place-items-center rounded-lg border border-dashed border-border-strong bg-surface text-muted-foreground transition-[border-color,color,background-color] hover:border-foreground hover:bg-accent hover:text-foreground', FOCUS)}
>
<span className="grid justify-items-center gap-1 text-[11.5px] font-medium">
<Icon name="camera" size={18} className="transition-transform duration-300 group-hover/add:scale-110" />
<span className="max-sm:sr-only">{t.addPhoto}</span>
</span>
</button>
)}
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png"
multiple
className="sr-only"
tabIndex={-1}
aria-label={t.addPhoto}
onChange={(e) => {
const picked = Array.from(e.target.files ?? []).slice(0, 4 - files.length);
setFiles((list) => [...list, ...picked.map((file) => ({ file, url: URL.createObjectURL(file) }))]);
e.target.value = '';
}}
/>
</div>
</div>
<label className="flex min-h-11 cursor-pointer items-center justify-between gap-4 rounded-xl border border-border bg-card px-4 py-2 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-[var(--ring)]">
<span className="flex items-center gap-2.5 text-[14px] font-medium text-foreground">
<Icon name="thumbs-up" size={16} className="text-muted-foreground" />
{t.recommend}
</span>
<input type="checkbox" checked={recommend} onChange={(e) => setRecommend(e.target.checked)} className="peer sr-only" />
<span aria-hidden className={cn('relative h-6 w-11 shrink-0 rounded-full transition-colors duration-300', recommend ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--foreground)_18%,transparent)]')}>
<span className={cn('absolute left-0.5 top-0.5 size-5 rounded-full bg-background shadow-[var(--shadow-sm)] transition-transform duration-300 ease-[var(--ease-kit)]', recommend && 'translate-x-5')} />
</span>
</label>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div>
<label htmlFor={`${id}-name`} className="mb-2 block text-[14px] font-medium text-foreground">
{t.nameLabel}
</label>
<input id={`${id}-name`} value={name} onChange={(e) => setName(e.target.value)} autoComplete="given-name" className={cn(field, 'h-11')} />
</div>
<div>
<label htmlFor={`${id}-email`} className="mb-2 block text-[14px] font-medium text-foreground">
{t.emailLabel} <span className="text-destructive" aria-hidden>*</span>
</label>
<input
id={`${id}-email`}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
aria-invalid={emailError || undefined}
aria-describedby={`${id}-email-hint`}
className={cn(field, 'h-11', emailError && invalidField)}
/>
<p id={`${id}-email-hint`} className={cn('mt-1.5 flex items-start gap-1.5 text-[12.5px]', emailError ? 'text-destructive' : 'text-muted-foreground')} role={emailError ? 'alert' : undefined}>
{emailError && <Icon name="alert-circle" size={14} className="mt-px" />}
{emailError ? t.emailError : t.emailHint}
</p>
</div>
</div>
<div className="lg:hidden">
<p className="mb-3 text-[13px] font-medium text-muted-foreground">{t.previewHint}</p>
{preview}
</div>
<Button type="submit" size="lg" className="w-full sm:w-auto" iconLeft={state === 'sending' ? 'loader' : 'send'} disabled={state === 'sending'}>
<Swap value={state}>{state === 'sending' ? t.sending : t.submit}</Swap>
</Button>
</form>
<aside className="hidden lg:sticky lg:top-8 lg:block lg:self-start" aria-label={t.preview}>
<p className="mb-3 flex items-center gap-2 text-[13px] font-medium text-muted-foreground">
<span className="relative flex size-1.5">
<span className="absolute inset-0 animate-kit-pulse rounded-full bg-success" />
<span className="relative size-1.5 rounded-full bg-success" />
</span>
{t.previewHint}
</p>
{preview}
</aside>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* carousel */
/* ------------------------------------------------------------------ */
function useRail() {
const ref = useRef<HTMLUListElement>(null);
const [pos, setPos] = useState({ start: true, end: true, progress: 0, view: 1 });
const drag = useRef<{ x: number; left: number; moved: boolean } | null>(null);
const [dragging, setDragging] = useState(false);
const measure = () => {
const el = ref.current;
if (!el) return;
const max = el.scrollWidth - el.clientWidth;
setPos({ start: el.scrollLeft <= 2, end: el.scrollLeft >= max - 2, progress: max > 0 ? el.scrollLeft / max : 0, view: el.scrollWidth ? el.clientWidth / el.scrollWidth : 1 });
};
useEffect(() => {
measure();
const ro = new ResizeObserver(measure);
if (ref.current) ro.observe(ref.current);
return () => ro.disconnect();
}, []);
const go = (dir: 1 | -1) => {
const el = ref.current;
const card = el?.querySelector('li');
if (!el || !card) return;
el.scrollBy({ left: dir * (card.getBoundingClientRect().width + 16), behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
};
const handlers = {
onScroll: measure,
onPointerDown: (e: React.PointerEvent<HTMLUListElement>) => {
if (e.pointerType !== 'mouse' || e.button !== 0 || !ref.current) return;
drag.current = { x: e.clientX, left: ref.current.scrollLeft, moved: false };
},
onPointerMove: (e: React.PointerEvent<HTMLUListElement>) => {
const d = drag.current;
const el = ref.current;
if (!d || !el) return;
const dx = e.clientX - d.x;
if (!d.moved && Math.abs(dx) > 5) {
d.moved = true;
setDragging(true);
el.setPointerCapture(e.pointerId);
}
if (d.moved) el.scrollLeft = d.left - dx;
},
onPointerUp: () => {
const moved = drag.current?.moved;
drag.current = null;
if (moved) requestAnimationFrame(() => setDragging(false));
},
onClickCapture: (e: React.MouseEvent) => {
if (dragging) e.preventDefault();
},
onKeyDown: (e: React.KeyboardEvent) => {
if (e.key === 'ArrowRight') go(1);
if (e.key === 'ArrowLeft') go(-1);
},
};
const overflow = !(pos.start && pos.end);
const fade = !overflow ? '' : pos.start ? 'kit-fade-end' : pos.end ? 'kit-fade-start' : 'kit-fade-x';
return { ref, pos, go, handlers, dragging, overflow, fade };
}
function Carousel(p: ProductReviewsProps & { t: Texts }) {
const { t, summary, reviews = [] } = p;
const locale = useLocale();
const rail = useRail();
const thumb = Math.max(0.14, Math.min(1, rail.pos.view));
const arrow = (dir: 1 | -1) => (
<button
type="button"
onClick={() => rail.go(dir)}
disabled={dir < 0 ? rail.pos.start : rail.pos.end}
aria-label={dir < 0 ? t.scrollBack : t.scrollOn}
className={cn('grid size-11 place-items-center rounded-full border border-border-strong bg-background text-foreground shadow-[var(--shadow-sm)] transition-[background-color,opacity,transform] duration-300 hover:bg-accent active:scale-95 disabled:pointer-events-none disabled:opacity-35', FOCUS)}
>
<Icon name={dir < 0 ? 'arrow-left' : 'arrow-right'} size={17} />
</button>
);
return (
<Section className={cn('overflow-x-clip', p.className)}>
<div className="mb-10 grid grid-cols-1 gap-6 sm:mb-12 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end lg:gap-10">
<div className="max-w-[40rem]">
{p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
{p.title && <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{p.title}</h2>}
{summary && (
<div className="kit-rise mt-5 flex flex-wrap items-center gap-x-4 gap-y-2" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
<span className="text-[34px] font-semibold leading-none tracking-[-0.03em] text-foreground tabular-nums">{formatNumber(locale, summary.average, 1)}</span>
<Stars value={summary.average} tone="neutral" />
<span className="text-[14px] text-muted-foreground">
{t.basedOn} {formatNumber(locale, summary.count)} {t.reviews}
</span>
</div>
)}
</div>
<div className="flex items-center justify-between gap-4 lg:justify-end">
{p.action && (
<a href={p.action.href} className={cn('group/all inline-flex min-h-11 items-center gap-2 rounded-md text-[14.5px] font-medium text-foreground', FOCUS)}>
{p.action.label}
<Icon name="arrow-right" size={15} className="transition-transform duration-300 group-hover/all:translate-x-1" />
</a>
)}
<div className={cn('flex gap-2 transition-opacity duration-300 max-sm:hidden', !rail.overflow && 'pointer-events-none opacity-0')} aria-hidden={!rail.overflow}>
{arrow(-1)}
{arrow(1)}
</div>
</div>
</div>
<ul
ref={rail.ref}
{...rail.handlers}
tabIndex={0}
aria-label={p.title ?? t.reviews}
className={cn('kit-no-scrollbar -mx-6 flex snap-x snap-mandatory gap-4 overflow-x-auto scroll-px-6 px-6 pb-3 pt-1 lg:-mx-2 lg:scroll-px-2 lg:px-2', FOCUS, rail.dragging && 'cursor-grabbing snap-none', rail.fade)}
>
{reviews.map((r, i) => (
<Reveal as="li" key={r.author + i} effect="rise" delay={Math.min(i, 4) * 80} className="w-[84%] shrink-0 snap-start sm:w-[calc((100%-1rem)/2.2)] lg:w-[calc((100%-3rem)/3.3)]">
<article className="group/card flex h-full flex-col rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,box-shadow] duration-500 ease-[var(--ease-kit)] hover:-translate-y-1 hover:shadow-[var(--highlight),var(--shadow-lg)] motion-reduce:transition-none motion-reduce:hover:translate-y-0">
<div className="flex items-center justify-between gap-3">
<Stars value={r.rating} size="sm" tone="neutral" />
<span className="text-[12.5px] text-muted-foreground">{r.date}</span>
</div>
{r.title && <h3 className="mt-4 text-balance text-[17px] font-semibold tracking-[-0.015em] text-foreground">{r.title}</h3>}
<p className="mt-2 line-clamp-5 text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{r.body}</p>
<p className="mb-5 mt-5 flex flex-1 flex-wrap content-end items-end gap-x-3 gap-y-1 text-[13px]">
<span className="font-medium text-foreground">{r.author}</span>
{r.verified && <Verified t={t} />}
</p>
{r.product && (
<a
href={r.product.href}
draggable={false}
className={cn('group/prod flex items-center gap-3 border-t border-border pt-4 text-[13.5px] font-medium text-foreground', FOCUS)}
>
{r.product.image && <span className="size-11 shrink-0 overflow-hidden rounded-lg bg-surface-2 [&>*]:size-full [&>*]:object-cover">{r.product.image}</span>}
<span className="min-w-0 flex-1 truncate">{r.product.name}</span>
<Icon name="arrow-right" size={15} className="shrink-0 text-muted-foreground transition-transform duration-300 group-hover/prod:translate-x-1 group-hover/prod:text-foreground" />
</a>
)}
</article>
</Reveal>
))}
</ul>
<div aria-hidden className={cn('relative mt-6 h-0.5 overflow-hidden rounded-full bg-border transition-opacity duration-300', !rail.overflow && 'opacity-0')}>
<span className="absolute inset-y-0 left-0 rounded-full bg-foreground transition-transform duration-150" style={{ width: `${round(thumb * 100, 1)}%`, transform: `translateX(${round(((rail.pos.progress * (1 - thumb)) / thumb) * 100, 1)}%)` }} />
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* The block */
/* ------------------------------------------------------------------ */
/**
* Product reviews in five layouts. overview: the full section with filters,
* search and sorting. photo-wall: customer photos with a lightbox.
* highlights: aspects and topics. write-review: the form. carousel: a row of
* review cards with the product.
*/
export function ProductReviews(props: ProductReviewsProps) {
const { variant = 'overview' } = props;
const t = useTexts(TEXTS, props.labels);
if (variant === 'photo-wall') return <PhotoWall {...props} t={t} />;
if (variant === 'highlights') return <Highlights {...props} t={t} />;
if (variant === 'write-review') return <WriteReview {...props} t={t} />;
if (variant === 'carousel') return <Carousel {...props} t={t} />;
return <Overview {...props} t={t} />;
}Frequent questions
How do I install Product reviews?
With aiw add @ai-website/product-reviews. The command puts components/ui/product-reviews.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, motion, section, dialog, rating, select and product-image. It needs no further npm packages.
Which variants does Product reviews have?
5 variants, Overview, Photo wall, Highlights, Write a review and Review row. You pick one with the variant prop, for example variant="photo-wall".
Can I translate the texts of Product reviews?
Yes. Product reviews carries 74 texts of its own in English and German, such as “reviews”, “review” and “Write a review”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Product reviews?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Product reviews with aiw_components and install it with aiw_add @ai-website/product-reviews. Name the variant in your prompt, for example variant="overview", and the assistant writes your content straight into the props.
Can I adjust Product reviews without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching ProductReviews code to copy.