Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, animate, motion, useInView, useMotionValue, useReducedMotion, useTransform } from "motion/react";
import { Check, Copy, Crown, Gift, Lock, Medal, Minus, Plus, Share2, ShoppingBag, Sparkles, Star, Ticket, Truck, Users, Wine, X, Cake, Award } from "lucide-react";
import { cn } from "@/lib/utils";
import { ProductArt, type ArtKind } from "./product-art";

export type Tier = { id: string; name: string; min: number; color: string; perks: string[] };
export type Reward = { id: string; name: string; cost: number; description: string; kind?: ArtKind; hex?: string; accent?: string; icon?: "truck" | "ticket" | "gift" | "wine"; stock?: number };
export type Activity = { id: string; label: string; detail?: string; points: number; date: string };

export interface LoyaltyRewardsProps {
  memberName?: string;
  programName?: string;
  initialPoints?: number;
  /** Lifetime points used for tier status (redeeming doesn't lower it). */
  lifetimePoints?: number;
  tiers?: Tier[];
  rewards?: Reward[];
  activity?: Activity[];
  referralCode?: string;
  referrals?: { joined: number; goal: number; bonus: number };
  onRedeem?: (reward: Reward) => string | void;
  onCopyReferral?: (code: string) => void;
  className?: string;
}

const TIERS: Tier[] = [
  { id: "bronze", name: "Bronze", min: 0, color: "#b45309", perks: ["1 point per €1", "Birthday gift", "Member-only prices"] },
  { id: "silver", name: "Silver", min: 1500, color: "#94a3b8", perks: ["1.25 points per €1", "Free standard shipping", "Early access to drops"] },
  { id: "gold", name: "Gold", min: 4000, color: "#eab308", perks: ["1.5 points per €1", "Free express shipping", "Private tasting invites", "Dedicated concierge"] },
];
const REWARDS: Reward[] = [
  { id: "ship", name: "Free express delivery", cost: 300, description: "On your next order, any size.", icon: "truck" },
  { id: "v5", name: "€5 voucher", cost: 500, description: "Min. spend €30. Valid 60 days.", icon: "ticket" },
  { id: "glass", name: "Crystal nosing glass", cost: 900, description: "Hand-blown, engraved with your initials.", kind: "tumbler", hex: "#d97706", stock: 12 },
  { id: "v20", name: "€20 voucher", cost: 1800, description: "No minimum spend. Valid 90 days.", icon: "gift" },
  { id: "mini", name: "Rare cask miniature", cost: 1200, description: "5 cl sample from our private casks.", kind: "bottle", hex: "#9f1239", accent: "#fdf2f8", stock: 4 },
  { id: "class", name: "Tasting masterclass", cost: 3000, description: "Two seats, six drams, one expert host.", icon: "wine" },
];
const ACTIVITY: Activity[] = [
  { id: "a1", label: "Order LC-204981", detail: "€63.90 spent", points: 80, date: "22 Sep" },
  { id: "a2", label: "Review bonus", detail: "Highland Single Malt 12", points: 50, date: "18 Sep" },
  { id: "a3", label: "Redeemed: Free express delivery", points: -300, date: "9 Sep" },
  { id: "a4", label: "Order LC-203377", detail: "€89.59 spent", points: 112, date: "9 Sep" },
  { id: "a5", label: "Friend joined", detail: "Referral bonus — Tom W.", points: 250, date: "30 Aug" },
  { id: "a6", label: "Birthday gift", points: 200, date: "12 Aug" },
];

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 TierIcon = ({ i, className }: { i: number; className?: string }) => {
  const I = i === 0 ? Medal : i === 1 ? Award : Crown;
  return <I className={className} aria-hidden />;
};

function useCount(value: number) {
  const reduce = useReducedMotion();
  const mv = useMotionValue(reduce ? value : 0);
  const txt = useTransform(mv, (v) => Math.round(v).toLocaleString("en-US"));
  React.useEffect(() => {
    if (reduce) {
      mv.set(value);
      return;
    }
    const c = animate(mv, value, { duration: 1.2, ease: [0.22, 1, 0.36, 1] });
    return () => c.stop();
  }, [value, mv, reduce]);
  return txt;
}

export function LoyaltyRewards({
  memberName = "Alex",
  programName = "Lumen Circle",
  initialPoints = 1240,
  lifetimePoints = 2860,
  tiers = TIERS,
  rewards = REWARDS,
  activity: initialActivity = ACTIVITY,
  referralCode = "ALEX-CIRCLE-25",
  referrals = { joined: 2, goal: 5, bonus: 250 },
  onRedeem,
  onCopyReferral,
  className,
}: LoyaltyRewardsProps) {
  const reduce = useReducedMotion();
  const [points, setPoints] = React.useState(initialPoints);
  const [activity, setActivity] = React.useState(initialActivity);
  const [confirm, setConfirm] = React.useState<Reward | null>(null);
  const [redeemed, setRedeemed] = React.useState<{ reward: Reward; code: string } | null>(null);
  const [filter, setFilter] = React.useState<"all" | "earned" | "spent">("all");
  const [copied, setCopied] = React.useState(false);
  const ringRef = React.useRef<HTMLDivElement>(null);
  const gradId = `lr-ring-${React.useId().replace(/:/g, "")}`;
  const inView = useInView(ringRef, { once: true, amount: 0.5 });
  const shownPoints = useCount(inView ? points : 0);

  const tierIdx = Math.max(0, tiers.map((t) => lifetimePoints >= t.min).lastIndexOf(true));
  const tier = tiers[tierIdx];
  const next = tiers[tierIdx + 1];
  const toNext = next ? next.min - lifetimePoints : 0;
  const tierPct = next ? (lifetimePoints - tier.min) / (next.min - tier.min) : 1;
  const nextReward = [...rewards].sort((a, b) => a.cost - b.cost).find((r) => r.cost > points);
  const ringPct = nextReward ? points / nextReward.cost : 1;
  const R = 70;
  const C = 2 * Math.PI * R;

  const doRedeem = (r: Reward) => {
    const code = onRedeem?.(r) || `${r.id.toUpperCase()}-${(points % 9000) + 1000}`;
    setPoints((p) => p - r.cost);
    setActivity((a) => [{ id: `n${a.length}`, label: `Redeemed: ${r.name}`, points: -r.cost, date: "Today" }, ...a]);
    setConfirm(null);
    setRedeemed({ reward: r, code });
  };

  const acts = activity.filter((a) => filter === "all" || (filter === "earned" ? a.points > 0 : a.points < 0));

  return (
    <section className={cn("w-full bg-background py-8 text-foreground sm:py-12", className)}>
      <div className="mx-auto max-w-6xl space-y-6 px-4 sm:px-6">
        {/* Header card */}
        <div className="relative overflow-hidden rounded-3xl border bg-gradient-to-br from-zinc-950 via-zinc-900 to-indigo-950 p-6 text-white sm:p-8">
          <div aria-hidden className="absolute -right-20 -top-24 size-72 rounded-full opacity-40 blur-3xl" style={{ background: tier.color }} />
          <div aria-hidden className="absolute -bottom-24 left-1/3 size-64 rounded-full bg-indigo-500/30 blur-3xl" />
          <div className="relative grid items-center gap-8 md:grid-cols-[auto_1fr]">
            <div ref={ringRef} className="relative mx-auto size-44 shrink-0 sm:size-48">
              <svg viewBox="0 0 160 160" className="size-full -rotate-90" aria-hidden>
                <defs>
                  <linearGradient id={gradId} x1="0" y1="0" x2="1" y2="1">
                    <stop offset="0" stopColor="#a5b4fc" />
                    <stop offset="1" stopColor={tier.color} />
                  </linearGradient>
                </defs>
                <circle cx="80" cy="80" r={R} fill="none" stroke="white" strokeOpacity="0.1" strokeWidth="12" />
                <motion.circle
                  cx="80"
                  cy="80"
                  r={R}
                  fill="none"
                  stroke={`url(#${gradId})`}
                  strokeWidth="12"
                  strokeLinecap="round"
                  strokeDasharray={C}
                  initial={{ strokeDashoffset: C }}
                  animate={{ strokeDashoffset: inView ? C * (1 - Math.min(1, ringPct)) : C }}
                  transition={{ duration: reduce ? 0 : 1.4, ease: [0.22, 1, 0.36, 1] }}
                />
              </svg>
              <div className="absolute inset-0 flex flex-col items-center justify-center text-center">
                <motion.span className="text-4xl font-semibold tabular-nums tracking-tight" aria-hidden>
                  {shownPoints}
                </motion.span>
                <span className="sr-only" aria-live="polite">
                  {points} points
                </span>
                <span className="text-xs text-white/60">points</span>
                {nextReward && <span className="mt-1 max-w-[7.5rem] text-[10px] leading-tight text-white/50">{nextReward.cost - points} to {nextReward.name.toLowerCase()}</span>}
              </div>
            </div>

            <div className="min-w-0">
              <p className="text-sm text-white/60">{programName}</p>
              <h2 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">Welcome back, {memberName}</h2>
              <p className="mt-2 flex items-center gap-2 text-sm">
                <span className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold text-zinc-950" style={{ background: tier.color }}>
                  <TierIcon i={tierIdx} className="size-3.5" /> {tier.name} member
                </span>
                {next && <span className="text-white/70">{toNext.toLocaleString("en-US")} pts to {next.name}</span>}
              </p>

              {/* Tier track */}
              <div className="mt-6">
                <div className="relative h-2 rounded-full bg-white/10">
                  <motion.div
                    className="absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-amber-600 via-slate-300 to-yellow-400"
                    initial={{ width: 0 }}
                    animate={{ width: inView ? `${((tierIdx + Math.min(1, tierPct)) / (tiers.length - 1)) * 100}%` : 0 }}
                    transition={{ duration: reduce ? 0 : 1.2, delay: 0.2, ease: [0.22, 1, 0.36, 1] }}
                  />
                  {tiers.map((t, i) => {
                    const reached = i <= tierIdx;
                    return (
                      <span
                        key={t.id}
                        className={cn("absolute top-1/2 grid size-7 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border-2 transition", reached ? "border-transparent text-zinc-950" : "border-white/20 bg-zinc-900 text-white/40")}
                        style={{ left: `${(i / (tiers.length - 1)) * 100}%`, background: reached ? t.color : undefined }}
                      >
                        <TierIcon i={i} className="size-3.5" />
                      </span>
                    );
                  })}
                </div>
                <div className="mt-4 flex justify-between text-[11px] text-white/60">
                  {tiers.map((t, i) => (
                    <span key={t.id} className={cn(i === 0 ? "text-left" : i === tiers.length - 1 ? "text-right" : "text-center", i === tierIdx && "font-semibold text-white")}>
                      {t.name}
                      <span className="block text-white/40">{t.min.toLocaleString("en-US")}+</span>
                    </span>
                  ))}
                </div>
              </div>
            </div>
          </div>

          {/* Perks */}
          <div className="relative mt-8 grid gap-3 sm:grid-cols-2">
            <div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
              <p className="text-xs font-semibold uppercase tracking-[0.14em] text-white/60">Your {tier.name} perks</p>
              <ul className="mt-3 space-y-2 text-sm">
                {tier.perks.map((p) => (
                  <li key={p} className="flex items-center gap-2">
                    <span className="grid size-5 place-items-center rounded-full bg-emerald-400/20 text-emerald-300">
                      <Check className="size-3" strokeWidth={3} aria-hidden />
                    </span>
                    {p}
                  </li>
                ))}
              </ul>
            </div>
            {next && (
              <div className="rounded-2xl border border-dashed border-white/15 p-4">
                <p className="text-xs font-semibold uppercase tracking-[0.14em] text-white/60">Unlock at {next.name}</p>
                <ul className="mt-3 space-y-2 text-sm text-white/70">
                  {next.perks.map((p) => (
                    <li key={p} className="flex items-center gap-2">
                      <span className="grid size-5 place-items-center rounded-full bg-white/10">
                        <Lock className="size-3" aria-hidden />
                      </span>
                      {p}
                    </li>
                  ))}
                </ul>
              </div>
            )}
          </div>
        </div>

        {/* Rewards */}
        <div>
          <div className="flex items-end justify-between gap-4">
            <div>
              <h3 className="text-xl font-semibold tracking-tight">Redeem rewards</h3>
              <p className="text-sm text-muted-foreground">Spend your points on treats, vouchers and experiences.</p>
            </div>
          </div>
          <ul className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
            {rewards.map((r, i) => {
              const can = points >= r.cost;
              const pct = Math.min(1, points / r.cost);
              const Icon = r.icon === "truck" ? Truck : r.icon === "ticket" ? Ticket : r.icon === "wine" ? Wine : Gift;
              return (
                <motion.li
                  key={r.id}
                  initial={reduce ? false : { opacity: 0, y: 12 }}
                  animate={{ opacity: 1, y: 0 }}
                  transition={{ delay: 0.2 + i * 0.05 }}
                  className={cn("group flex gap-4 rounded-2xl border bg-card p-4 transition", can ? "hover:-translate-y-0.5 hover:shadow-lg" : "")}
                >
                  <span className={cn("relative grid size-16 shrink-0 place-items-center overflow-hidden rounded-xl", r.kind ? "bg-muted" : "bg-gradient-to-br from-primary/15 to-primary/5 text-primary")}>
                    {r.kind ? <ProductArt kind={r.kind} color={r.hex} accent={r.accent} className="p-1" /> : <Icon className="size-6" aria-hidden />}
                  </span>
                  <div className="flex min-w-0 flex-1 flex-col">
                    <p className="text-sm font-semibold">{r.name}</p>
                    <p className="text-xs text-muted-foreground">{r.description}</p>
                    {r.stock !== undefined && r.stock <= 5 && <p className="mt-0.5 text-[11px] font-medium text-amber-600 dark:text-amber-400">Only {r.stock} left</p>}
                    <div className="mt-auto flex items-center justify-between gap-2 pt-3">
                      <span className="flex items-center gap-1 text-sm font-semibold tabular-nums">
                        <Sparkles className="size-3.5 text-amber-500" aria-hidden />
                        {r.cost.toLocaleString("en-US")}
                      </span>
                      {can ? (
                        <button type="button" onClick={() => setConfirm(r)} className={cn("h-8 rounded-lg bg-foreground px-3 text-xs font-semibold text-background transition hover:bg-foreground/85", ring)}>
                          Redeem
                        </button>
                      ) : (
                        <span className="flex items-center gap-2 text-[11px] text-muted-foreground">
                          <span className="h-1.5 w-14 overflow-hidden rounded-full bg-muted">
                            <span className="block h-full rounded-full bg-primary/60" style={{ width: `${pct * 100}%` }} />
                          </span>
                          {(r.cost - points).toLocaleString("en-US")} more
                        </span>
                      )}
                    </div>
                  </div>
                </motion.li>
              );
            })}
          </ul>
        </div>

        <div className="grid gap-6 lg:grid-cols-[1.4fr_1fr]">
          {/* Activity */}
          <div className="rounded-3xl border bg-card p-5 sm:p-6">
            <div className="flex flex-wrap items-center justify-between gap-3">
              <h3 className="text-lg font-semibold">Points activity</h3>
              <div role="tablist" aria-label="Filter activity" className="flex rounded-full bg-muted p-1 text-xs font-medium">
                {(["all", "earned", "spent"] as const).map((f) => (
                  <button key={f} role="tab" aria-selected={filter === f} onClick={() => setFilter(f)} className={cn("relative rounded-full px-3 py-1.5 capitalize", ring)}>
                    {filter === f && <motion.span layoutId="lr-filter" className="absolute inset-0 rounded-full bg-background shadow-sm" transition={{ type: "spring", stiffness: 500, damping: 35 }} />}
                    <span className="relative">{f}</span>
                  </button>
                ))}
              </div>
            </div>
            <ul className="mt-4 divide-y">
              <AnimatePresence initial={false} mode="popLayout">
                {acts.map((a) => {
                  const earn = a.points > 0;
                  const Icon = a.label.startsWith("Redeemed") ? Gift : a.label.startsWith("Review") ? Star : a.label.startsWith("Friend") ? Users : a.label.startsWith("Birthday") ? Cake : ShoppingBag;
                  return (
                    <motion.li key={a.id} layout={!reduce} initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 10 }} className="flex items-center gap-3 py-3">
                      <span className={cn("grid size-9 shrink-0 place-items-center rounded-xl", earn ? "bg-emerald-500/12 text-emerald-600 dark:text-emerald-400" : "bg-rose-500/12 text-rose-600 dark:text-rose-400")}>
                        <Icon className="size-4" aria-hidden />
                      </span>
                      <span className="min-w-0 flex-1">
                        <span className="block truncate text-sm font-medium">{a.label}</span>
                        <span className="block truncate text-xs text-muted-foreground">
                          {a.date}
                          {a.detail ? ` · ${a.detail}` : ""}
                        </span>
                      </span>
                      <span className={cn("flex items-center gap-0.5 text-sm font-semibold tabular-nums", earn ? "text-emerald-600 dark:text-emerald-400" : "text-rose-600 dark:text-rose-400")}>
                        {earn ? <Plus className="size-3" aria-hidden /> : <Minus className="size-3" aria-hidden />}
                        {Math.abs(a.points).toLocaleString("en-US")}
                      </span>
                    </motion.li>
                  );
                })}
              </AnimatePresence>
            </ul>
          </div>

          {/* Referral */}
          <div className="relative overflow-hidden rounded-3xl border bg-card p-5 sm:p-6">
            <div aria-hidden className="absolute -right-10 -top-10 size-40 rounded-full bg-primary/15 blur-2xl" />
            <span className="relative grid size-11 place-items-center rounded-2xl bg-primary text-primary-foreground">
              <Share2 className="size-5" aria-hidden />
            </span>
            <h3 className="relative mt-4 text-lg font-semibold">Give €10, get {referrals.bonus} points</h3>
            <p className="relative mt-1 text-sm text-muted-foreground">Friends get €10 off their first order. You earn {referrals.bonus} points when they buy.</p>
            <div className="relative mt-5 flex items-center gap-2 rounded-xl border-2 border-dashed bg-muted/40 p-1.5 pl-4">
              <span className="min-w-0 flex-1 truncate font-mono text-sm font-semibold tracking-wider" id="lr-code">
                {referralCode}
              </span>
              <button
                type="button"
                aria-describedby="lr-code"
                onClick={() => {
                  void navigator.clipboard?.writeText(referralCode).catch(() => undefined);
                  onCopyReferral?.(referralCode);
                  setCopied(true);
                  window.setTimeout(() => setCopied(false), 1800);
                }}
                className={cn("relative flex h-9 shrink-0 items-center gap-1.5 overflow-hidden rounded-lg px-3 text-xs font-semibold transition-colors", copied ? "bg-emerald-600 text-white" : "bg-foreground text-background", ring)}
              >
                <AnimatePresence mode="wait" initial={false}>
                  <motion.span key={String(copied)} initial={{ y: 10, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: -10, opacity: 0 }} className="flex items-center gap-1.5">
                    {copied ? <Check className="size-3.5" aria-hidden /> : <Copy className="size-3.5" aria-hidden />}
                    {copied ? "Copied!" : "Copy"}
                  </motion.span>
                </AnimatePresence>
              </button>
            </div>
            <span className="sr-only" aria-live="polite">
              {copied ? "Referral code copied" : ""}
            </span>
            <div className="relative mt-5">
              <div className="flex items-center justify-between text-xs">
                <span className="font-medium">
                  {referrals.joined} of {referrals.goal} friends joined
                </span>
                <span className="text-muted-foreground">Bonus at {referrals.goal}</span>
              </div>
              <div className="mt-2 flex gap-1.5">
                {Array.from({ length: referrals.goal }, (_, i) => (
                  <motion.span
                    key={i}
                    initial={{ scaleX: 0 }}
                    animate={{ scaleX: 1 }}
                    transition={{ delay: i * 0.08 }}
                    className={cn("h-2 flex-1 origin-left rounded-full", i < referrals.joined ? "bg-primary" : "bg-muted")}
                  />
                ))}
              </div>
            </div>
          </div>
        </div>
      </div>

      <AnimatePresence>
        {(confirm || redeemed) && (
          <RedeemDialog
            reward={confirm ?? redeemed!.reward}
            code={redeemed?.code}
            points={points}
            onCancel={() => {
              setConfirm(null);
              setRedeemed(null);
            }}
            onConfirm={() => confirm && doRedeem(confirm)}
          />
        )}
      </AnimatePresence>
    </section>
  );
}

function RedeemDialog({ reward, code, points, onCancel, onConfirm }: { reward: Reward; code?: string; points: number; onCancel: () => void; onConfirm: () => void }) {
  const reduce = useReducedMotion();
  const ref = React.useRef<HTMLDivElement>(null);
  const [copied, setCopied] = React.useState(false);
  React.useEffect(() => {
    const prev = document.activeElement as HTMLElement | null;
    ref.current?.querySelector<HTMLElement>("[data-autofocus]")?.focus();
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") onCancel();
      if (e.key === "Tab" && ref.current) {
        const f = [...ref.current.querySelectorAll<HTMLElement>("button")];
        if (e.shiftKey && document.activeElement === f[0]) {
          e.preventDefault();
          f[f.length - 1]?.focus();
        } else if (!e.shiftKey && document.activeElement === f[f.length - 1]) {
          e.preventDefault();
          f[0]?.focus();
        }
      }
    };
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("keydown", onKey);
      prev?.focus();
    };
  }, [onCancel]);
  React.useEffect(() => {
    ref.current?.querySelector<HTMLElement>("[data-autofocus]")?.focus();
  }, [code]);

  return (
    <div className="fixed inset-0 z-50 grid place-items-center p-4">
      <motion.div className="absolute inset-0 bg-black/55 backdrop-blur-sm" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={onCancel} aria-hidden />
      <motion.div
        ref={ref}
        role={code ? "dialog" : "alertdialog"}
        aria-modal="true"
        aria-labelledby="lr-dlg-title"
        aria-describedby="lr-dlg-desc"
        initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.92, y: 16 }}
        animate={{ opacity: 1, scale: 1, y: 0 }}
        exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.95 }}
        transition={{ type: "spring", stiffness: 380, damping: 30 }}
        className="relative w-full max-w-sm rounded-3xl border bg-background p-6 text-center shadow-2xl"
      >
        <button type="button" onClick={onCancel} aria-label="Close" className={cn("absolute right-3 top-3 grid size-8 place-items-center rounded-full hover:bg-muted", ring)}>
          <X className="size-4" />
        </button>
        <AnimatePresence mode="wait" initial={false}>
          {!code ? (
            <motion.div key="confirm" exit={{ opacity: 0, y: -8 }}>
              <span className="mx-auto grid size-14 place-items-center rounded-2xl bg-primary/10 text-primary">
                <Gift className="size-6" aria-hidden />
              </span>
              <h3 id="lr-dlg-title" className="mt-4 text-lg font-semibold">
                Redeem {reward.name}?
              </h3>
              <p id="lr-dlg-desc" className="mt-1 text-sm text-muted-foreground">
                This uses <span className="font-semibold text-foreground">{reward.cost.toLocaleString("en-US")} points</span>. You&apos;ll have {(points - reward.cost).toLocaleString("en-US")} left.
              </p>
              <div className="mt-6 grid grid-cols-2 gap-2">
                <button type="button" onClick={onCancel} className={cn("h-11 rounded-xl border text-sm font-medium hover:bg-muted", ring)}>
                  Cancel
                </button>
                <button type="button" data-autofocus onClick={onConfirm} className={cn("h-11 rounded-xl bg-primary text-sm font-semibold text-primary-foreground hover:bg-primary/90", ring)}>
                  Confirm
                </button>
              </div>
            </motion.div>
          ) : (
            <motion.div key="done" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }}>
              <motion.span initial={reduce ? false : { scale: 0, rotate: -30 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: "spring", stiffness: 400, damping: 14 }} className="mx-auto grid size-14 place-items-center rounded-full bg-emerald-500 text-white">
                <Check className="size-7" strokeWidth={3} aria-hidden />
              </motion.span>
              <h3 id="lr-dlg-title" className="mt-4 text-lg font-semibold">
                Reward unlocked!
              </h3>
              <p id="lr-dlg-desc" className="mt-1 text-sm text-muted-foreground">
                Use this code at checkout for your {reward.name.toLowerCase()}.
              </p>
              <div className="mt-5 flex items-center gap-2 rounded-xl border-2 border-dashed border-emerald-500/40 bg-emerald-500/5 p-1.5 pl-4">
                <span className="flex-1 text-left font-mono font-semibold tracking-wider">{code}</span>
                <button
                  type="button"
                  data-autofocus
                  onClick={() => {
                    void navigator.clipboard?.writeText(code).catch(() => undefined);
                    setCopied(true);
                  }}
                  className={cn("flex h-9 items-center gap-1.5 rounded-lg bg-foreground px-3 text-xs font-semibold text-background", ring)}
                >
                  {copied ? <Check className="size-3.5" aria-hidden /> : <Copy className="size-3.5" aria-hidden />}
                  {copied ? "Copied" : "Copy"}
                </button>
              </div>
              <button type="button" onClick={onCancel} className={cn("mt-4 h-11 w-full rounded-xl border text-sm font-medium hover:bg-muted", ring)}>
                Done
              </button>
            </motion.div>
          )}
        </AnimatePresence>
      </motion.div>
    </div>
  );
}

More in E-commerce

View all →