Produktbewertungen
Bewertungen als eigene Sections. Eine volle Übersicht mit Filtern, Suche und Antworten des Shops, eine Wand mit Kundenfotos, Schwerpunkte nach Thema, das Formular zum Schreiben und eine Reihe für Startseiten.
Eine von 2 Components in Bewertungen und Fragen
Variants
Details
Was Produktbewertungen mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Bewertungen und Fragen
- Bereich
- E-Commerce
- Variants
- 5
- Version
- 1.0.1 · seit 1.1.0
- Datei
- components/ui/product-reviews.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 74 eigene Texte
- In Sammlungen
- Online-Shop
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ProductReviewsProps aus components/ui/product-reviews.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
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
Eingebaute Texte
Diese Texte schreibt Produktbewertungen selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 74 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| reviews | Englisch reviews | Deutsch Bewertungen |
| review | Englisch review | Deutsch Bewertung |
| writeReview | Englisch Write a review | Deutsch Bewertung schreiben |
| all | Englisch All | Deutsch Alle |
| withPhotos | Englisch With photos | Deutsch Mit Fotos |
| verifiedOnly | Englisch Verified | Deutsch Verifiziert |
| stars | Englisch stars | Deutsch Sterne |
| star | Englisch star | Deutsch Stern |
| clearStars | Englisch Show all ratings | Deutsch Alle Bewertungen zeigen |
| search | Englisch Search reviews | Deutsch Bewertungen durchsuchen |
| searchPlaceholder | Englisch Search reviews | Deutsch Bewertungen durchsuchen |
| sort | Englisch Sort by | Deutsch Sortieren nach |
| sortNewest | Englisch Newest | Deutsch Neueste |
| sortHelpful | Englisch Most helpful | Deutsch Hilfreichste |
| sortHighest | Englisch Highest rating | Deutsch Beste Bewertung |
| sortLowest | Englisch Lowest rating | Deutsch Schlechteste Bewertung |
| match | Englisch reviews match | Deutsch Bewertungen passen |
| matchOne | Englisch review matches | Deutsch Bewertung passt |
| noMatch | Englisch No review matches yet. Try another word or show all ratings. | Deutsch Dazu gibt es noch keine Bewertung. Versuch ein anderes Wort oder zeig alle Sterne. |
| resetFilters | Englisch Show all reviews | Deutsch Alle Bewertungen zeigen |
| verified | Englisch Verified purchase | Deutsch Verifizierter Kauf |
| fit | Englisch Fit | Deutsch Passform |
| wouldRecommend | Englisch would recommend it | Deutsch würden es weiterempfehlen |
| helpfulQuestion | Englisch Helpful? | Deutsch Hilfreich? |
| yes | Englisch Yes | Deutsch Ja |
| no | Englisch No | Deutsch Nein |
| replyFrom | Englisch Reply from | Deutsch Antwort von |
| showMore | Englisch Show more reviews | Deutsch Weitere Bewertungen zeigen |
| showing | Englisch Showing | Deutsch Zu sehen sind |
| of | Englisch of | Deutsch von |
| photos | Englisch photos | Deutsch Fotos |
| photo | Englisch Photo | Deutsch Foto |
| openPhoto | Englisch Open photo by | Deutsch Foto öffnen von |
| morePhotos | Englisch more photos | Deutsch weitere Fotos |
| previous | Englisch Previous photo | Deutsch Vorheriges Foto |
| next | Englisch Next photo | Deutsch Nächstes Foto |
| close | Englisch Close | Deutsch Schließen |
| photosBy | Englisch Customer photos | Deutsch Fotos von Kundinnen und Kunden |
| mentions | Englisch What people mention | Deutsch Was oft erwähnt wird |
| mentioned | Englisch mentions | Deutsch Erwähnungen |
| summaryFrom | Englisch Summary of | Deutsch Zusammenfassung aus |
| allTopics | Englisch All topics | Deutsch Alle Themen |
| yourRating | Englisch Your rating | Deutsch Gesamteindruck |
| ratingNeeded | Englisch Choose a rating from one to five stars. | Deutsch Wähle eine Bewertung von einem bis fünf Sternen. |
| titleLabel | Englisch Title | Deutsch Überschrift |
| titlePlaceholder | Englisch The short version | Deutsch Die Kurzfassung |
| bodyLabel | Englisch Your review | Deutsch Dein Erfahrungsbericht |
| bodyPlaceholder | Englisch What do you like, what would you change, how do you use it? | Deutsch Was gefällt dir, was würdest du ändern, wofür nutzt du es? |
| bodyShort | Englisch A few more words help others decide. | Deutsch Ein paar Worte mehr helfen anderen bei der Entscheidung. |
| charsLeft | Englisch more characters | Deutsch Zeichen fehlen noch |
| charsDone | Englisch Long enough, thank you | Deutsch Lang genug, danke |
| fitLabel | Englisch How does it turn out? | Deutsch Wie fällt es aus? |
| photosLabel | Englisch Photos | Deutsch Fotos |
| photosHint | Englisch Up to four, JPG or PNG | Deutsch Bis zu vier, JPG oder PNG |
| addPhoto | Englisch Add a photo | Deutsch Foto hinzufügen |
| removePhoto | Englisch Remove photo | Deutsch Foto entfernen |
| recommend | Englisch I would recommend it | Deutsch Ich würde es weiterempfehlen |
| nameLabel | Englisch Name shown with the review | Deutsch Name unter der Bewertung |
| emailLabel | Englisch Email | Deutsch E-Mail |
| emailHint | Englisch Only to confirm your review, never shown | Deutsch Nur zur Bestätigung, wird nie gezeigt |
| emailError | Englisch Enter your email so we can confirm the review. | Deutsch Gib deine E-Mail ein, damit wir die Bewertung bestätigen können. |
| submit | Englisch Publish review | Deutsch Bewertung veröffentlichen |
| sending | Englisch Publishing | Deutsch Wird veröffentlicht |
| preview | Englisch Preview | Deutsch Vorschau |
| previewHint | Englisch This is how your review appears | Deutsch So erscheint deine Bewertung |
| previewEmpty | Englisch Your words appear here as you type. | Deutsch Deine Worte erscheinen hier, während du schreibst. |
| reviewing | Englisch You review | Deutsch Du bewertest |
| thanks | Englisch Thank you | Deutsch Danke dir |
| thanksText | Englisch Your review is in. It appears on the product page after a short check, usually within a day. | Deutsch Deine Bewertung ist angekommen. Nach einer kurzen Prüfung erscheint sie auf der Produktseite, meist innerhalb eines Tages. |
| you | Englisch You | Deutsch Du |
| today | Englisch Today | Deutsch Heute |
| scrollBack | Englisch Scroll back | Deutsch Zurückblättern |
| scrollOn | Englisch Scroll on | Deutsch Weiterblättern |
| basedOn | Englisch from | Deutsch aus |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Produktbewertungen reagiert selbst auf die Tasten Pfeil links, Pfeil rechts und Leertaste.
- Baut auf dem nativen dialog-Element auf. Der Browser hält den Fokus im Fenster und schließt es mit Escape.
- Setzt aria-current, aria-describedby, aria-invalid, aria-label, aria-labelledby, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen alert, email und group.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 9 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 1.561 Zeilen
'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} />;
}Häufige Fragen
Wie installiere ich Produktbewertungen?
Mit aiw add @ai-website/product-reviews. Der Befehl legt die Datei components/ui/product-reviews.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, reveal, motion, section, dialog, rating, select und product-image. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Produktbewertungen?
5 Varianten, Übersicht, Fotowand, Schwerpunkte, Bewertung schreiben und Bewertungsreihe. Du wählst sie mit der Prop variant, zum Beispiel variant="photo-wall".
Kann ich die Texte von Produktbewertungen übersetzen?
Ja. Produktbewertungen bringt 74 eigene Texte auf Deutsch und Englisch mit, etwa „Bewertungen“, „Bewertung“ und „Bewertung schreiben“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Produktbewertungen einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Produktbewertungen mit aiw_components und installieren sie mit aiw_add @ai-website/product-reviews. Nenn im Prompt die Variante, etwa variant="overview", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Produktbewertungen ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden ProductReviews-Code zum Kopieren.