"use client";
import * as React from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "motion/react";
import { BadgeCheck, Camera, ChevronDown, Flag, PenLine, Star, ThumbsDown, ThumbsUp, X, Check } from "lucide-react";
import { cn } from "@/lib/utils";
import { ProductArt, type ArtKind } from "./product-art";
export type ReviewPhoto = { kind: ArtKind; hex: string; accent?: string; tilt?: number };
export type Review = {
id: string;
author: string;
rating: number;
title: string;
body: string;
date: string;
/** Sort key — larger is newer. */
order: number;
verified?: boolean;
variant?: string;
photos?: ReviewPhoto[];
helpful: number;
unhelpful?: number;
reply?: string;
pending?: boolean;
};
export type ReviewDraft = { rating: number; title: string; body: string; name: string; email: string; recommend: boolean };
type Sort = "helpful" | "newest" | "highest" | "lowest";
export interface ReviewsSectionProps {
title?: string;
productName?: string;
reviews?: Review[];
/** Totals for the histogram (index 0 = 1 star). Defaults to counting `reviews`. */
distribution?: [number, number, number, number, number];
pageSize?: number;
onSubmitReview?: (draft: ReviewDraft) => void;
onVote?: (id: string, vote: "up" | "down") => void;
className?: string;
}
const PHOTO = (kind: ArtKind, hex: string, accent?: string, tilt = 0): ReviewPhoto => ({ kind, hex, accent, tilt });
const DEFAULT_REVIEWS: Review[] = [
{ id: "r1", author: "Maya K.", rating: 5, title: "Smooth, rich and dangerously easy", body: "Toffee and baked apple on the nose, a little spice on the finish. I bought the 70 cl for my dad and ended up ordering one for myself the week after. The gift box is gorgeous too.", date: "12 Sep 2026", order: 12, verified: true, variant: "Sherry cask · 70 cl", photos: [PHOTO("bottle", "#b45309", "#f5efe0"), PHOTO("box", "#b45309", "#f5efe0")], helpful: 48, reply: "Thank you Maya — we're so glad it became a family favourite!" },
{ id: "r2", author: "Tomasz W.", rating: 4, title: "Great value for a 12 year old", body: "Really well balanced. Slightly lighter than I expected from the colour, but that's because there's no caramel added — which I appreciate. Delivery was next day.", date: "8 Sep 2026", order: 11, verified: true, variant: "Peated · 70 cl", helpful: 31 },
{ id: "r3", author: "Priya S.", rating: 5, title: "The tasting set is perfect for gifting", body: "Four mini bottles, lovely packaging and a little booklet with tasting notes. We did a mini tasting night with friends and everyone loved the port finish most.", date: "2 Sep 2026", order: 10, verified: true, variant: "Tasting set", photos: [PHOTO("box", "#9f1239", "#fdf2f8"), PHOTO("tumbler", "#9f1239", undefined, -8), PHOTO("bottle", "#9f1239", "#fdf2f8", 6)], helpful: 27 },
{ id: "r4", author: "Jonas B.", rating: 3, title: "Good, not great", body: "Nice dram, but the peated version was a bit too smoky for me. Customer service helped me swap to the sherry cask without any hassle.", date: "29 Aug 2026", order: 9, variant: "Peated · 20 cl", helpful: 12, unhelpful: 3 },
{ id: "r5", author: "Elena R.", rating: 5, title: "Beautiful bottle, better whisky", body: "It sits on my bar cart like a piece of art. The rye blend is sweet and spicy, fantastic in an old fashioned.", date: "21 Aug 2026", order: 8, verified: true, variant: "Rye blend · 70 cl", photos: [PHOTO("tumbler", "#ca8a04")], helpful: 19 },
{ id: "r6", author: "Chris D.", rating: 2, title: "Box arrived damaged", body: "The bottle was fine but the gift tube was dented, which was annoying as it was a present. Refund for the packaging was offered quickly though.", date: "15 Aug 2026", order: 7, verified: true, variant: "Sherry cask · 70 cl", helpful: 9, unhelpful: 1, reply: "Sorry Chris — we've since switched to a sturdier outer carton." },
{ id: "r7", author: "Aiko T.", rating: 5, title: "My new house whisky", body: "Consistent, flavourful and at this price I can keep a bottle open all year. Highly recommended.", date: "3 Aug 2026", order: 6, helpful: 7 },
{ id: "r8", author: "Sam L.", rating: 4, title: "Lovely with a drop of water", body: "Opens up beautifully with a few drops of water — much more fruit comes through.", date: "28 Jul 2026", order: 5, verified: true, variant: "Sherry cask · 5 cl", helpful: 5 },
];
const ring = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background";
const avatarHues = [262, 20, 160, 200, 330, 45, 110, 290];
function Stars({ value, size = "size-4" }: { value: number; size?: string }) {
return (
<span className="flex items-center gap-0.5" aria-label={`${value} out of 5 stars`} role="img">
{[1, 2, 3, 4, 5].map((i) => {
const fill = Math.max(0, Math.min(1, value - i + 1));
return (
<span key={i} className={cn("relative", size)}>
<Star className={cn("absolute inset-0 text-muted-foreground/30", size)} aria-hidden />
<span className="absolute inset-0 overflow-hidden" style={{ width: `${fill * 100}%` }}>
<Star className={cn("fill-amber-400 text-amber-400", size)} aria-hidden />
</span>
</span>
);
})}
</span>
);
}
export function ReviewsSection({
title = "Customer reviews",
productName = "Highland Single Malt 12",
reviews: initialReviews = DEFAULT_REVIEWS,
distribution,
pageSize = 4,
onSubmitReview,
onVote,
className,
}: ReviewsSectionProps) {
const reduce = useReducedMotion();
const [reviews, setReviews] = React.useState(initialReviews);
const [star, setStar] = React.useState<number | null>(null);
const [photos, setPhotos] = React.useState(false);
const [verified, setVerified] = React.useState(false);
const [sort, setSort] = React.useState<Sort>("helpful");
const [shown, setShown] = React.useState(pageSize);
const [votes, setVotes] = React.useState<Record<string, "up" | "down">>({});
const [writing, setWriting] = React.useState(false);
const [thanks, setThanks] = React.useState(false);
const [lightbox, setLightbox] = React.useState<ReviewPhoto | null>(null);
const histRef = React.useRef<HTMLDivElement>(null);
const inView = useInView(histRef, { once: true, amount: 0.4 });
const dist = distribution ?? ([1, 2, 3, 4, 5].map((s) => reviews.filter((r) => r.rating === s).length) as [number, number, number, number, number]);
const total = dist.reduce((a, b) => a + b, 0);
const avg = total ? dist.reduce((a, n, i) => a + n * (i + 1), 0) / total : 0;
const recommend = total ? Math.round(((dist[3] + dist[4]) / total) * 100) : 0;
const list = React.useMemo(() => {
const l = reviews.filter((r) => (star ? r.rating === star : true) && (!photos || r.photos?.length) && (!verified || r.verified));
const s = [...l];
if (sort === "helpful") s.sort((a, b) => b.helpful - a.helpful);
if (sort === "newest") s.sort((a, b) => b.order - a.order);
if (sort === "highest") s.sort((a, b) => b.rating - a.rating || b.order - a.order);
if (sort === "lowest") s.sort((a, b) => a.rating - b.rating || b.order - a.order);
return s;
}, [reviews, star, photos, verified, sort]);
const vote = (id: string, v: "up" | "down") => {
if (votes[id]) return;
setVotes((s) => ({ ...s, [id]: v }));
setReviews((rs) => rs.map((r) => (r.id === id ? (v === "up" ? { ...r, helpful: r.helpful + 1 } : { ...r, unhelpful: (r.unhelpful ?? 0) + 1 }) : r)));
onVote?.(id, v);
};
const allPhotos = reviews.flatMap((r) => r.photos ?? []);
return (
<section id="reviews" className={cn("w-full bg-background py-10 text-foreground sm:py-14", className)}>
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<div className="grid gap-10 lg:grid-cols-[320px_1fr] lg:gap-14">
{/* Summary */}
<aside className="lg:sticky lg:top-6 lg:self-start">
<h2 className="text-2xl font-semibold tracking-tight">{title}</h2>
<div className="mt-4 flex items-end gap-3">
<span className="text-5xl font-semibold tracking-tight tabular-nums">{avg.toFixed(1)}</span>
<div className="pb-1.5">
<Stars value={avg} size="size-5" />
<p className="mt-1 text-xs text-muted-foreground">Based on {total.toLocaleString()} reviews</p>
</div>
</div>
<div ref={histRef} className="mt-6 space-y-1.5" role="group" aria-label="Filter by star rating">
{[5, 4, 3, 2, 1].map((s) => {
const n = dist[s - 1];
const pct = total ? (n / total) * 100 : 0;
const on = star === s;
return (
<button
key={s}
type="button"
aria-pressed={on}
onClick={() => {
setStar(on ? null : s);
setShown(pageSize);
}}
className={cn("group flex w-full items-center gap-3 rounded-lg px-2 py-1.5 text-sm transition", on ? "bg-accent" : "hover:bg-muted", star && !on && "opacity-50", ring)}
>
<span className="flex w-8 items-center gap-1 tabular-nums">
{s} <Star className="size-3 fill-amber-400 text-amber-400" aria-hidden />
</span>
<span className="relative h-2 flex-1 overflow-hidden rounded-full bg-muted">
<motion.span
className="absolute inset-y-0 left-0 rounded-full bg-amber-400"
initial={{ width: reduce ? `${pct}%` : 0 }}
animate={{ width: inView ? `${pct}%` : 0 }}
transition={{ duration: 0.8, delay: (5 - s) * 0.08, ease: [0.22, 1, 0.36, 1] }}
/>
</span>
<span className="w-8 text-right text-xs tabular-nums text-muted-foreground">{n}</span>
</button>
);
})}
</div>
<p className="mt-4 rounded-xl bg-muted/60 px-4 py-3 text-sm">
<span className="font-semibold">{recommend}%</span> <span className="text-muted-foreground">of reviewers recommend this product</span>
</p>
{allPhotos.length > 0 && (
<div className="mt-6">
<p className="text-sm font-medium">Customer photos</p>
<div className="mt-2 grid grid-cols-4 gap-2">
{allPhotos.slice(0, 4).map((ph, i) => (
<button key={i} type="button" onClick={() => setLightbox(ph)} aria-label={`Open customer photo ${i + 1}`} className={cn("aspect-square overflow-hidden rounded-lg bg-muted p-1 transition hover:scale-[1.04]", ring)}>
<ProductArt kind={ph.kind} color={ph.hex} accent={ph.accent} tilt={ph.tilt} />
</button>
))}
</div>
</div>
)}
<button
type="button"
onClick={() => {
setWriting((w) => !w);
setThanks(false);
}}
aria-expanded={writing}
className={cn("mt-6 flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-foreground text-sm font-semibold text-background transition hover:bg-foreground/85", ring)}
>
<PenLine className="size-4" aria-hidden /> Write a review
</button>
</aside>
{/* List */}
<div className="min-w-0">
<AnimatePresence initial={false}>
{writing && (
<motion.div key="form" initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: reduce ? 0 : 0.35, ease: [0.4, 0, 0.2, 1] }} className="overflow-hidden">
<ReviewForm
productName={productName}
onCancel={() => setWriting(false)}
onSubmit={(d) => {
onSubmitReview?.(d);
setReviews((rs) => [
{ id: `new-${rs.length}`, author: d.name, rating: d.rating, title: d.title, body: d.body, date: "Just now", order: 999, helpful: 0, pending: true },
...rs,
]);
setSort("newest");
setStar(null);
setWriting(false);
setThanks(true);
}}
/>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{thanks && (
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} role="status" className="mb-5 flex items-center gap-3 rounded-2xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm">
<span className="grid size-7 place-items-center rounded-full bg-emerald-500 text-white">
<Check className="size-4" aria-hidden />
</span>
<span className="flex-1">Thanks! Your review will appear publicly after a quick check.</span>
<button type="button" onClick={() => setThanks(false)} aria-label="Dismiss" className={cn("rounded p-1 hover:bg-emerald-500/10", ring)}>
<X className="size-4" />
</button>
</motion.div>
)}
</AnimatePresence>
<div className="flex flex-wrap items-center gap-2 border-b pb-4">
<Chip on={photos} onClick={() => setPhotos((v) => !v)}>
<Camera className="size-3.5" aria-hidden /> With photos
</Chip>
<Chip on={verified} onClick={() => setVerified((v) => !v)}>
<BadgeCheck className="size-3.5" aria-hidden /> Verified buyers
</Chip>
{star && (
<Chip on onClick={() => setStar(null)}>
{star} stars <X className="size-3.5" aria-hidden />
</Chip>
)}
<label className="relative ml-auto flex items-center text-sm">
<span className="sr-only">Sort reviews</span>
<select value={sort} onChange={(e) => setSort(e.target.value as Sort)} className={cn("h-9 appearance-none rounded-full border bg-background pl-3.5 pr-8 text-sm font-medium", ring)}>
<option value="helpful">Most helpful</option>
<option value="newest">Newest</option>
<option value="highest">Highest rated</option>
<option value="lowest">Lowest rated</option>
</select>
<ChevronDown className="pointer-events-none absolute right-2.5 size-4 text-muted-foreground" aria-hidden />
</label>
</div>
<p className="mt-3 text-xs text-muted-foreground" aria-live="polite">
{list.length} {list.length === 1 ? "review" : "reviews"}
</p>
<ul className="divide-y">
<AnimatePresence mode="popLayout" initial={false}>
{list.slice(0, shown).map((r) => (
<motion.li key={r.id} layout={!reduce} initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, scale: 0.98 }} transition={{ type: "spring", stiffness: 380, damping: 36 }} className="py-6">
<ReviewCard r={r} vote={votes[r.id]} onVote={(v) => vote(r.id, v)} onPhoto={setLightbox} />
</motion.li>
))}
</AnimatePresence>
</ul>
{list.length === 0 && <p className="py-12 text-center text-sm text-muted-foreground">No reviews match these filters yet.</p>}
{shown < list.length && (
<div className="mt-2 flex justify-center">
<button type="button" onClick={() => setShown((s) => s + pageSize)} className={cn("h-11 rounded-full border px-6 text-sm font-medium transition hover:bg-muted", ring)}>
Show more reviews ({list.length - shown})
</button>
</div>
)}
</div>
</div>
</div>
<AnimatePresence>
{lightbox && <Lightbox photo={lightbox} onClose={() => setLightbox(null)} />}
</AnimatePresence>
</section>
);
}
function Chip({ on, onClick, children }: { on: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button
type="button"
aria-pressed={on}
onClick={onClick}
className={cn("flex h-9 items-center gap-1.5 rounded-full border px-3.5 text-sm font-medium transition", on ? "border-foreground bg-foreground text-background" : "hover:bg-muted", ring)}
>
{children}
</button>
);
}
function ReviewCard({ r, vote, onVote, onPhoto }: { r: Review; vote?: "up" | "down"; onVote: (v: "up" | "down") => void; onPhoto: (p: ReviewPhoto) => void }) {
const [reported, setReported] = React.useState(false);
const hue = avatarHues[r.author.charCodeAt(0) % avatarHues.length];
const initials = r.author
.split(" ")
.map((w) => w[0])
.join("")
.slice(0, 2);
return (
<article className="grid gap-4 sm:grid-cols-[160px_1fr]">
<div className="flex items-center gap-3 sm:flex-col sm:items-start">
<span className="grid size-10 place-items-center rounded-full text-sm font-semibold text-white" style={{ background: `linear-gradient(135deg, oklch(0.65 0.15 ${hue}), oklch(0.5 0.17 ${hue + 40}))` }} aria-hidden>
{initials}
</span>
<div>
<p className="text-sm font-medium">{r.author}</p>
{r.verified && (
<p className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
<BadgeCheck className="size-3.5" aria-hidden /> Verified buyer
</p>
)}
{r.pending && <p className="text-xs text-amber-600 dark:text-amber-400">Pending moderation</p>}
</div>
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<Stars value={r.rating} />
<time className="text-xs text-muted-foreground">{r.date}</time>
</div>
<h3 className="mt-2 font-semibold">{r.title}</h3>
<p className="mt-1.5 text-sm leading-relaxed text-muted-foreground">{r.body}</p>
{r.variant && <p className="mt-2 text-xs text-muted-foreground">Purchased: <span className="text-foreground">{r.variant}</span></p>}
{r.photos && r.photos.length > 0 && (
<div className="mt-3 flex gap-2">
{r.photos.map((ph, i) => (
<button key={i} type="button" onClick={() => onPhoto(ph)} aria-label={`View photo ${i + 1} from ${r.author}`} className={cn("size-16 overflow-hidden rounded-lg bg-muted p-1 transition hover:scale-105", ring)}>
<ProductArt kind={ph.kind} color={ph.hex} accent={ph.accent} tilt={ph.tilt} />
</button>
))}
</div>
)}
{r.reply && (
<div className="mt-3 rounded-xl border-l-2 border-primary bg-muted/50 px-3 py-2 text-sm">
<p className="text-xs font-semibold">Response from the store</p>
<p className="mt-0.5 text-muted-foreground">{r.reply}</p>
</div>
)}
<div className="mt-4 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>Was this helpful?</span>
<VoteButton active={vote === "up"} disabled={!!vote} onClick={() => onVote("up")} label="Yes" count={r.helpful}>
<ThumbsUp className="size-3.5" aria-hidden />
</VoteButton>
<VoteButton active={vote === "down"} disabled={!!vote} onClick={() => onVote("down")} label="No" count={r.unhelpful ?? 0}>
<ThumbsDown className="size-3.5" aria-hidden />
</VoteButton>
<button type="button" disabled={reported} onClick={() => setReported(true)} className={cn("ml-auto flex items-center gap-1 rounded px-1 py-1 hover:text-foreground disabled:opacity-70", ring)}>
<Flag className="size-3.5" aria-hidden /> {reported ? "Reported" : "Report"}
</button>
</div>
</div>
</article>
);
}
function VoteButton({ active, disabled, onClick, label, count, children }: { active: boolean; disabled: boolean; onClick: () => void; label: string; count: number; children: React.ReactNode }) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-pressed={active}
aria-label={`${label}, ${count} people`}
className={cn("flex h-7 items-center gap-1.5 rounded-full border px-2.5 font-medium transition enabled:hover:bg-muted", active && "border-primary bg-primary/10 text-primary", disabled && !active && "opacity-50", ring)}
>
<motion.span key={String(active)} initial={active ? { scale: 0.4, rotate: -20 } : false} animate={{ scale: 1, rotate: 0 }} transition={{ type: "spring", stiffness: 500, damping: 14 }}>
{children}
</motion.span>
{label}
<span className="tabular-nums">{count}</span>
</button>
);
}
function StarInput({ value, onChange, invalid }: { value: number; onChange: (v: number) => void; invalid?: boolean }) {
const [hover, setHover] = React.useState(0);
const labels = ["", "Poor", "Fair", "Good", "Very good", "Excellent"];
const shown = hover || value;
const refs = React.useRef<(HTMLButtonElement | null)[]>([]);
const onKey = (e: React.KeyboardEvent) => {
const k = e.key;
if (!["ArrowRight", "ArrowUp", "ArrowLeft", "ArrowDown", "Home", "End"].includes(k)) return;
e.preventDefault();
let next = value || 0;
if (k === "ArrowRight" || k === "ArrowUp") next = Math.min(5, next + 1);
if (k === "ArrowLeft" || k === "ArrowDown") next = Math.max(1, next - 1);
if (k === "Home") next = 1;
if (k === "End") next = 5;
onChange(next);
refs.current[next - 1]?.focus();
};
return (
<div className="flex items-center gap-3">
<div role="radiogroup" aria-label="Your rating" aria-invalid={invalid} aria-describedby="rv-rating-err" className="flex" onMouseLeave={() => setHover(0)} onKeyDown={onKey}>
{[1, 2, 3, 4, 5].map((i) => (
<button
key={i}
ref={(el) => {
refs.current[i - 1] = el;
}}
type="button"
role="radio"
aria-checked={value === i}
aria-label={`${i} star${i > 1 ? "s" : ""}`}
tabIndex={(value || 1) === i ? 0 : -1}
onClick={() => onChange(i)}
onMouseEnter={() => setHover(i)}
className={cn("rounded p-0.5", ring)}
>
<motion.span animate={{ scale: shown >= i ? 1.12 : 1 }} transition={{ type: "spring", stiffness: 500, damping: 18 }} className="block">
<Star className={cn("size-7 transition-colors", shown >= i ? "fill-amber-400 text-amber-400" : invalid ? "text-destructive/60" : "text-muted-foreground/40")} />
</motion.span>
</button>
))}
</div>
<span className="text-sm font-medium text-muted-foreground" aria-live="polite">
{labels[shown]}
</span>
</div>
);
}
function ReviewForm({ productName, onSubmit, onCancel }: { productName: string; onSubmit: (d: ReviewDraft) => void; onCancel: () => void }) {
const [d, setD] = React.useState<ReviewDraft>({ rating: 0, title: "", body: "", name: "", email: "", recommend: true });
const [tried, setTried] = React.useState(false);
const errors = {
rating: d.rating ? "" : "Please choose a star rating.",
title: d.title.trim().length >= 3 ? "" : "Add a short headline.",
body: d.body.trim().length >= 20 ? "" : `Tell us a bit more (${Math.max(0, 20 - d.body.trim().length)} more characters).`,
name: d.name.trim() ? "" : "Enter a display name.",
email: /^\S+@\S+\.\S+$/.test(d.email) ? "" : "Enter a valid email (never shown publicly).",
};
const valid = Object.values(errors).every((e) => !e);
const field = "mt-1.5 w-full rounded-lg border bg-background px-3 text-sm aria-[invalid=true]:border-destructive " + ring;
const err = (k: keyof typeof errors) =>
tried && errors[k] ? (
<p id={`rv-${k}-err`} className="mt-1 text-xs text-destructive">
{errors[k]}
</p>
) : null;
return (
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
setTried(true);
if (!valid) {
const first = (Object.keys(errors) as (keyof typeof errors)[]).find((k) => errors[k]);
e.currentTarget.querySelector<HTMLElement>(first === "rating" ? "[role=radio]" : `#rv-${first}`)?.focus();
return;
}
onSubmit(d);
}}
className="mb-8 rounded-3xl border bg-card p-5 sm:p-6"
>
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-lg font-semibold">Write a review</h3>
<p className="text-sm text-muted-foreground">{productName}</p>
</div>
<button type="button" onClick={onCancel} aria-label="Close review form" className={cn("grid size-8 place-items-center rounded-full hover:bg-muted", ring)}>
<X className="size-4" />
</button>
</div>
<div className="mt-5">
<p className="text-sm font-medium">Overall rating</p>
<div className="mt-1.5">
<StarInput value={d.rating} onChange={(rating) => setD({ ...d, rating })} invalid={tried && !!errors.rating} />
</div>
{err("rating")}
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label htmlFor="rv-title" className="text-sm font-medium">
Headline
</label>
<input id="rv-title" value={d.title} onChange={(e) => setD({ ...d, title: e.target.value })} aria-invalid={tried && !!errors.title} aria-describedby="rv-title-err" className={cn(field, "h-10")} placeholder="What stood out?" maxLength={80} />
{err("title")}
</div>
<div className="sm:col-span-2">
<div className="flex items-center justify-between">
<label htmlFor="rv-body" className="text-sm font-medium">
Your review
</label>
<span className="text-xs tabular-nums text-muted-foreground">{d.body.length}/1000</span>
</div>
<textarea id="rv-body" value={d.body} onChange={(e) => setD({ ...d, body: e.target.value })} aria-invalid={tried && !!errors.body} aria-describedby="rv-body-err" rows={4} maxLength={1000} className={cn(field, "py-2")} placeholder="Taste, value, packaging, delivery…" />
{err("body")}
</div>
<div>
<label htmlFor="rv-name" className="text-sm font-medium">
Display name
</label>
<input id="rv-name" value={d.name} onChange={(e) => setD({ ...d, name: e.target.value })} aria-invalid={tried && !!errors.name} aria-describedby="rv-name-err" className={cn(field, "h-10")} autoComplete="nickname" />
{err("name")}
</div>
<div>
<label htmlFor="rv-email" className="text-sm font-medium">
Email
</label>
<input id="rv-email" type="email" value={d.email} onChange={(e) => setD({ ...d, email: e.target.value })} aria-invalid={tried && !!errors.email} aria-describedby="rv-email-err" className={cn(field, "h-10")} autoComplete="email" />
{err("email")}
</div>
</div>
<fieldset className="mt-4">
<legend className="text-sm font-medium">Would you recommend it?</legend>
<div className="mt-2 flex gap-2">
{[true, false].map((v) => (
<label key={String(v)} className="cursor-pointer">
<input type="radio" name="rv-rec" className="peer sr-only" checked={d.recommend === v} onChange={() => setD({ ...d, recommend: v })} />
<span className="flex h-9 items-center gap-1.5 rounded-full border px-4 text-sm transition peer-checked:border-foreground peer-checked:bg-foreground peer-checked:text-background peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background">
{v ? <ThumbsUp className="size-3.5" aria-hidden /> : <ThumbsDown className="size-3.5" aria-hidden />}
{v ? "Yes" : "No"}
</span>
</label>
))}
</div>
</fieldset>
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button type="button" onClick={onCancel} className={cn("h-11 rounded-xl px-5 text-sm font-medium hover:bg-muted", ring)}>
Cancel
</button>
<button type="submit" className={cn("h-11 rounded-xl bg-primary px-6 text-sm font-semibold text-primary-foreground transition hover:bg-primary/90", ring)}>
Submit review
</button>
</div>
</form>
);
}
function Lightbox({ photo, onClose }: { photo: ReviewPhoto; onClose: () => void }) {
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
const prev = document.activeElement as HTMLElement | null;
ref.current?.focus();
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("keydown", onKey);
prev?.focus();
};
}, [onClose]);
return (
<motion.div role="dialog" aria-modal="true" aria-label="Customer photo" className="fixed inset-0 z-50 grid place-items-center bg-black/70 p-6 backdrop-blur-sm" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={onClose}>
<motion.div initial={{ scale: 0.9 }} animate={{ scale: 1 }} exit={{ scale: 0.9 }} className="relative aspect-square w-full max-w-md rounded-3xl bg-muted p-8" onClick={(e) => e.stopPropagation()}>
<ProductArt kind={photo.kind} color={photo.hex} accent={photo.accent} tilt={photo.tilt} />
<button ref={ref} type="button" onClick={onClose} aria-label="Close photo" className={cn("absolute right-3 top-3 grid size-9 place-items-center rounded-full bg-background/90 hover:bg-background", ring)}>
<X className="size-4" />
</button>
</motion.div>
</motion.div>
);
}