Fazekit

Code

"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>
  );
}

More in E-commerce

View all →