Web InnoventixFreeCode

Checkout Form

Original · free

compact checkout summary + fields

byWeb InnoventixReact + Tailwind
formcheckoutforms
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/form-checkout.json
form-checkout.tsx
"use client";

import { useMemo, useState } from "react";
import type { FormEvent, ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Brand = "visa" | "mastercard" | "amex" | "unknown";

type CartItem = {
  id: string;
  name: string;
  variant: string;
  unit: number; // cents
  qty: number;
  initials: string;
  tint: string;
};

type Promo = { type: "pct" | "ship"; value: number; label: string };

const PROMOS: Record<string, Promo> = {
  BREW15: { type: "pct", value: 0.15, label: "BREW15 · 15% off" },
  FREESHIP: { type: "ship", value: 0, label: "FREESHIP · free shipping" },
};

const INITIAL_ITEMS: CartItem[] = [
  {
    id: "grinder",
    name: "Nomad Hand Grinder",
    variant: "Matte black · 48mm conical burr",
    unit: 18900,
    qty: 1,
    initials: "NG",
    tint: "bg-indigo-600",
  },
  {
    id: "sampler",
    name: "Single-Origin Sampler",
    variant: "3 × 250g whole bean",
    unit: 4200,
    qty: 2,
    initials: "SS",
    tint: "bg-emerald-600",
  },
];

const TAX_RATE = 0.0825;
const SHIP_FLAT = 800;
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

const fmt = (c: number): string => `$${(c / 100).toFixed(2)}`;

function luhn(num: string): boolean {
  let sum = 0;
  let alt = false;
  for (let i = num.length - 1; i >= 0; i -= 1) {
    let n = Number(num[i]);
    if (Number.isNaN(n)) return false;
    if (alt) {
      n *= 2;
      if (n > 9) n -= 9;
    }
    sum += n;
    alt = !alt;
  }
  return sum % 10 === 0;
}

function detectBrand(digits: string): Brand {
  if (/^4/.test(digits)) return "visa";
  if (/^(5[1-5]|2[2-7])/.test(digits)) return "mastercard";
  if (/^3[47]/.test(digits)) return "amex";
  return "unknown";
}

function expiryValid(v: string): boolean {
  const m = /^(\d{2})\/(\d{2})$/.exec(v);
  if (!m) return false;
  const mm = Number(m[1]);
  const yy = Number(m[2]);
  if (mm < 1 || mm > 12) return false;
  const now = new Date();
  const curYY = now.getFullYear() % 100;
  const curMM = now.getMonth() + 1;
  if (yy < curYY) return false;
  if (yy === curYY && mm < curMM) return false;
  return true;
}

const BRAND_LABEL: Record<Brand, string> = {
  visa: "Visa",
  mastercard: "Mastercard",
  amex: "Amex",
  unknown: "",
};

const inputCls =
  "w-full rounded-xl border border-slate-300 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-sm outline-none transition-colors placeholder:text-slate-400 focus:border-indigo-500 focus-visible:ring-2 focus-visible:ring-indigo-500/40 aria-[invalid=true]:border-rose-400 aria-[invalid=true]:focus-visible:ring-rose-400/40 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:placeholder:text-slate-500 dark:aria-[invalid=true]:border-rose-500";

const keyframes = `
@keyframes fckt-pop { 0% { transform: scale(.85); opacity: 0 } 60% { transform: scale(1.04) } 100% { transform: scale(1); opacity: 1 } }
@keyframes fckt-ring { 0% { transform: scale(.55); opacity: .55 } 100% { transform: scale(1.5); opacity: 0 } }
@keyframes fckt-shimmer { 0% { background-position: 200% 0 } 100% { background-position: -200% 0 } }
@keyframes fckt-check { to { stroke-dashoffset: 0 } }
.fckt-pop { animation: fckt-pop .4s cubic-bezier(.2,.8,.2,1) both; }
.fckt-ring { animation: fckt-ring 1.1s ease-out forwards; }
.fckt-check { stroke-dasharray: 30; stroke-dashoffset: 30; animation: fckt-check .5s ease-out .12s forwards; }
.fckt-shimmer { background-size: 200% 100%; animation: fckt-shimmer 2.6s linear infinite; }
.fckt-cb ~ span > svg { opacity: 0; transition: opacity .15s ease; }
.fckt-cb:checked ~ span > svg { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
  .fckt-pop, .fckt-ring, .fckt-shimmer, .fckt-check { animation: none !important; }
  .fckt-check { stroke-dashoffset: 0 !important; }
}
`;

type TextFieldProps = {
  id: string;
  label: string;
  value: string;
  onChange: (v: string) => void;
  onBlur?: () => void;
  type?: string;
  placeholder?: string;
  autoComplete?: string;
  inputMode?: "text" | "numeric" | "email" | "tel";
  maxLength?: number;
  error?: string;
  hint?: string;
  trailing?: ReactNode;
};

function TextField({
  id,
  label,
  value,
  onChange,
  onBlur,
  type = "text",
  placeholder,
  autoComplete,
  inputMode,
  maxLength,
  error,
  hint,
  trailing,
}: TextFieldProps) {
  const describedBy = error ? `${id}-error` : hint ? `${id}-hint` : undefined;
  return (
    <div>
      <label
        htmlFor={id}
        className="mb-1.5 block text-xs font-medium text-slate-600 dark:text-slate-400"
      >
        {label}
      </label>
      <div className="relative">
        <input
          id={id}
          type={type}
          value={value}
          onChange={(e) => onChange(e.target.value)}
          onBlur={onBlur}
          placeholder={placeholder}
          autoComplete={autoComplete}
          inputMode={inputMode}
          maxLength={maxLength}
          aria-invalid={error ? true : undefined}
          aria-describedby={describedBy}
          className={`${inputCls}${trailing ? " pr-20" : ""}`}
        />
        {trailing ? (
          <div className="pointer-events-none absolute inset-y-0 right-2.5 flex items-center">
            {trailing}
          </div>
        ) : null}
      </div>
      {error ? (
        <p
          id={`${id}-error`}
          role="alert"
          className="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400"
        >
          {error}
        </p>
      ) : hint ? (
        <p id={`${id}-hint`} className="mt-1.5 text-xs text-slate-400 dark:text-slate-500">
          {hint}
        </p>
      ) : null}
    </div>
  );
}

type CheckProps = {
  id: string;
  checked: boolean;
  onChange: (v: boolean) => void;
  children: ReactNode;
};

function CheckRow({ id, checked, onChange, children }: CheckProps) {
  return (
    <label htmlFor={id} className="flex cursor-pointer items-start gap-3">
      <input
        id={id}
        type="checkbox"
        checked={checked}
        onChange={(e) => onChange(e.target.checked)}
        className="fckt-cb peer sr-only"
      />
      <span
        aria-hidden="true"
        className="mt-px flex h-5 w-5 flex-none items-center justify-center rounded-md border border-slate-300 bg-white transition-colors peer-checked:border-indigo-600 peer-checked:bg-indigo-600 peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500/50 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:border-slate-600 dark:bg-slate-900 dark:peer-focus-visible:ring-offset-slate-900"
      >
        <svg viewBox="0 0 20 20" className="h-3.5 w-3.5 text-white" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
          <path d="M4 10.5l4 4 8-9" />
        </svg>
      </span>
      <span className="text-sm text-slate-600 dark:text-slate-300">{children}</span>
    </label>
  );
}

function LockIcon() {
  return (
    <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <rect x="4.5" y="10.5" width="15" height="10" rx="2" />
      <path d="M8 10.5V7a4 4 0 0 1 8 0v3.5" />
    </svg>
  );
}

function CardGlyph() {
  return (
    <svg viewBox="0 0 24 24" className="h-5 w-7 text-slate-400 dark:text-slate-500" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true">
      <rect x="2.5" y="5.5" width="19" height="13" rx="2.5" />
      <path d="M2.5 9.5h19" strokeLinecap="round" />
    </svg>
  );
}

const legendCls =
  "mb-4 flex items-center gap-2.5 text-sm font-semibold text-slate-900 dark:text-slate-100";
const stepCls =
  "flex h-6 w-6 flex-none items-center justify-center rounded-full bg-indigo-600 text-xs font-bold text-white";
const cardCls =
  "rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900/60 sm:p-6";

export default function FormCheckout() {
  const reduce = useReducedMotion();

  const [items, setItems] = useState<CartItem[]>(INITIAL_ITEMS);
  const [email, setEmail] = useState("");
  const [optIn, setOptIn] = useState(true);
  const [fullName, setFullName] = useState("");
  const [address, setAddress] = useState("");
  const [city, setCity] = useState("");
  const [zip, setZip] = useState("");
  const [country, setCountry] = useState("United States");
  const [nameOnCard, setNameOnCard] = useState("");
  const [card, setCard] = useState("");
  const [expiry, setExpiry] = useState("");
  const [cvc, setCvc] = useState("");
  const [billingSame, setBillingSame] = useState(true);

  const [promoInput, setPromoInput] = useState("");
  const [appliedPromo, setAppliedPromo] = useState<string | null>(null);
  const [promoError, setPromoError] = useState("");

  const [touched, setTouched] = useState<Record<string, boolean>>({});
  const [submitted, setSubmitted] = useState(false);
  const [placed, setPlaced] = useState(false);
  const [orderNo] = useState(() => `NB-${Math.floor(100000 + Math.random() * 900000)}`);

  const digits = card.replace(/\s/g, "");
  const brand = detectBrand(digits);

  const totals = useMemo(() => {
    const subtotal = items.reduce((s, i) => s + i.unit * i.qty, 0);
    const promo = appliedPromo ? PROMOS[appliedPromo] : null;
    const discount = promo && promo.type === "pct" ? Math.round(subtotal * promo.value) : 0;
    const shipping = promo?.type === "ship" ? 0 : subtotal === 0 ? 0 : SHIP_FLAT;
    const taxable = Math.max(0, subtotal - discount);
    const tax = Math.round(taxable * TAX_RATE);
    const total = taxable + shipping + tax;
    return { subtotal, discount, shipping, tax, total };
  }, [items, appliedPromo]);

  const errors = useMemo(() => {
    const e: Record<string, string> = {};
    if (!emailRe.test(email)) e.email = "Enter a valid email address.";
    if (fullName.trim().length < 2) e.fullName = "Enter your full name.";
    if (address.trim().length < 4) e.address = "Enter your street address.";
    if (city.trim().length < 2) e.city = "Enter your city.";
    if (!/^\d{5}(-\d{4})?$/.test(zip.trim())) e.zip = "Enter a 5-digit ZIP.";
    if (nameOnCard.trim().length < 2) e.nameOnCard = "Enter the name on the card.";
    if (!(digits.length >= 13 && digits.length <= 19 && luhn(digits)))
      e.card = "Enter a valid card number.";
    if (!expiryValid(expiry)) e.expiry = "MM/YY, not expired.";
    if (!/^\d{3,4}$/.test(cvc)) e.cvc = "3–4 digits.";
    return e;
  }, [email, fullName, address, city, zip, nameOnCard, digits, expiry, cvc]);

  const show = (k: string): string | undefined =>
    (touched[k] || submitted) && errors[k] ? errors[k] : undefined;

  const mark = (k: string) => () => setTouched((t) => ({ ...t, [k]: true }));

  const setQty = (id: string, delta: number) =>
    setItems((prev) =>
      prev.map((i) =>
        i.id === id ? { ...i, qty: Math.min(9, Math.max(1, i.qty + delta)) } : i,
      ),
    );

  const onCardChange = (v: string) => {
    const only = v.replace(/\D/g, "").slice(0, 19);
    setCard(only.replace(/(.{4})/g, "$1 ").trim());
  };
  const onExpiryChange = (v: string) => {
    const only = v.replace(/\D/g, "").slice(0, 4);
    setExpiry(only.length >= 3 ? `${only.slice(0, 2)}/${only.slice(2)}` : only);
  };

  const applyPromo = () => {
    const code = promoInput.trim().toUpperCase();
    if (!code) return;
    if (PROMOS[code]) {
      setAppliedPromo(code);
      setPromoError("");
      setPromoInput("");
    } else {
      setAppliedPromo(null);
      setPromoError(`"${code}" isn’t a valid code.`);
    }
  };

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setSubmitted(true);
    if (Object.keys(errors).length === 0) setPlaced(true);
  };

  const firstName = fullName.trim().split(" ")[0] || "there";

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:px-6 sm:py-20 lg:px-8">
      <style>{keyframes}</style>

      <div className="mx-auto max-w-5xl">
        <header className="mb-8 sm:mb-10">
          <p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
            Northbeam Coffee · Secure checkout
          </p>
          <h2 className="mt-2 text-2xl font-bold tracking-tight sm:text-3xl">
            Review &amp; complete your order
          </h2>
          <p className="mt-2 max-w-xl text-sm text-slate-500 dark:text-slate-400">
            Your card is encrypted end-to-end. Orders can be edited or cancelled free for 60
            minutes after they’re placed.
          </p>
        </header>

        <AnimatePresence mode="wait">
          {placed ? (
            <motion.div
              key="confirmed"
              initial={reduce ? false : { opacity: 0, y: 10 }}
              animate={{ opacity: 1, y: 0 }}
              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -10 }}
              transition={{ duration: 0.3 }}
              className={`${cardCls} mx-auto max-w-lg text-center`}
            >
              <div className="relative mx-auto mb-5 flex h-16 w-16 items-center justify-center">
                <span className="fckt-ring absolute inset-0 rounded-full bg-emerald-400/50" />
                <span className="fckt-pop relative flex h-16 w-16 items-center justify-center rounded-full bg-emerald-500 text-white">
                  <svg viewBox="0 0 32 32" className="h-8 w-8" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                    <path className="fckt-check" d="M9 16.5l4.5 4.5L23 11" />
                  </svg>
                </span>
              </div>
              <h3 className="text-xl font-bold">Order confirmed</h3>
              <p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
                Thanks, {firstName} — we charged{" "}
                <span className="font-semibold text-slate-700 dark:text-slate-200">
                  {fmt(totals.total)}
                </span>{" "}
                and a receipt is on its way to{" "}
                <span className="font-semibold text-slate-700 dark:text-slate-200">
                  {email || "your inbox"}
                </span>
                .
              </p>
              <div className="mt-5 inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-4 py-1.5 text-sm font-medium dark:border-slate-700 dark:bg-slate-800/60">
                <span className="text-slate-500 dark:text-slate-400">Order</span>
                <span className="font-mono font-semibold tracking-wide">{orderNo}</span>
              </div>
              <div className="mt-6">
                <button
                  type="button"
                  onClick={() => {
                    setPlaced(false);
                    setSubmitted(false);
                  }}
                  className="rounded-xl px-4 py-2 text-sm font-semibold text-indigo-600 underline-offset-4 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-indigo-500/50 dark:text-indigo-400"
                >
                  Place another order
                </button>
              </div>
            </motion.div>
          ) : (
            <motion.form
              key="form"
              onSubmit={handleSubmit}
              noValidate
              initial={reduce ? false : { opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.2 }}
              className="grid grid-cols-1 gap-6 lg:grid-cols-5 lg:items-start"
            >
              {/* Fields */}
              <div className="space-y-6 lg:col-span-3">
                <fieldset className={cardCls}>
                  <legend className={legendCls}>
                    <span className={stepCls}>1</span> Contact
                  </legend>
                  <div className="space-y-4">
                    <TextField
                      id="co-email"
                      label="Email address"
                      type="email"
                      inputMode="email"
                      autoComplete="email"
                      placeholder="you@example.com"
                      value={email}
                      onChange={setEmail}
                      onBlur={mark("email")}
                      error={show("email")}
                    />
                    <CheckRow id="co-optin" checked={optIn} onChange={setOptIn}>
                      Email me order updates and the occasional brewing guide.
                    </CheckRow>
                  </div>
                </fieldset>

                <fieldset className={cardCls}>
                  <legend className={legendCls}>
                    <span className={stepCls}>2</span> Shipping address
                  </legend>
                  <div className="space-y-4">
                    <TextField
                      id="co-name"
                      label="Full name"
                      autoComplete="name"
                      placeholder="Ada Lovelace"
                      value={fullName}
                      onChange={setFullName}
                      onBlur={mark("fullName")}
                      error={show("fullName")}
                    />
                    <TextField
                      id="co-address"
                      label="Street address"
                      autoComplete="address-line1"
                      placeholder="118 Ferry Building"
                      value={address}
                      onChange={setAddress}
                      onBlur={mark("address")}
                      error={show("address")}
                    />
                    <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
                      <TextField
                        id="co-city"
                        label="City"
                        autoComplete="address-level2"
                        placeholder="San Francisco"
                        value={city}
                        onChange={setCity}
                        onBlur={mark("city")}
                        error={show("city")}
                      />
                      <TextField
                        id="co-zip"
                        label="ZIP code"
                        inputMode="numeric"
                        autoComplete="postal-code"
                        maxLength={10}
                        placeholder="94111"
                        value={zip}
                        onChange={setZip}
                        onBlur={mark("zip")}
                        error={show("zip")}
                      />
                    </div>
                    <div>
                      <label
                        htmlFor="co-country"
                        className="mb-1.5 block text-xs font-medium text-slate-600 dark:text-slate-400"
                      >
                        Country / region
                      </label>
                      <select
                        id="co-country"
                        value={country}
                        onChange={(e) => setCountry(e.target.value)}
                        autoComplete="country-name"
                        className={inputCls}
                      >
                        <option>United States</option>
                        <option>Canada</option>
                        <option>United Kingdom</option>
                        <option>Australia</option>
                      </select>
                    </div>
                  </div>
                </fieldset>

                <fieldset className={cardCls}>
                  <legend className={legendCls}>
                    <span className={stepCls}>3</span> Payment
                  </legend>
                  <div className="space-y-4">
                    <TextField
                      id="co-cardname"
                      label="Name on card"
                      autoComplete="cc-name"
                      placeholder="Ada Lovelace"
                      value={nameOnCard}
                      onChange={setNameOnCard}
                      onBlur={mark("nameOnCard")}
                      error={show("nameOnCard")}
                    />
                    <TextField
                      id="co-card"
                      label="Card number"
                      inputMode="numeric"
                      autoComplete="cc-number"
                      placeholder="4242 4242 4242 4242"
                      value={card}
                      onChange={onCardChange}
                      onBlur={mark("card")}
                      error={show("card")}
                      trailing={
                        brand !== "unknown" && digits.length > 1 ? (
                          <span className="rounded-md bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-slate-600 dark:bg-slate-800 dark:text-slate-300">
                            {BRAND_LABEL[brand]}
                          </span>
                        ) : (
                          <CardGlyph />
                        )
                      }
                    />
                    <div className="grid grid-cols-2 gap-4">
                      <TextField
                        id="co-exp"
                        label="Expiry"
                        inputMode="numeric"
                        autoComplete="cc-exp"
                        placeholder="MM/YY"
                        maxLength={5}
                        value={expiry}
                        onChange={onExpiryChange}
                        onBlur={mark("expiry")}
                        error={show("expiry")}
                      />
                      <TextField
                        id="co-cvc"
                        label="Security code"
                        inputMode="numeric"
                        autoComplete="cc-csc"
                        placeholder={brand === "amex" ? "4 digits" : "3 digits"}
                        maxLength={4}
                        value={cvc}
                        onChange={(v) => setCvc(v.replace(/\D/g, "").slice(0, 4))}
                        onBlur={mark("cvc")}
                        error={show("cvc")}
                      />
                    </div>
                    <CheckRow id="co-billing" checked={billingSame} onChange={setBillingSame}>
                      Billing address is the same as shipping.
                    </CheckRow>
                  </div>
                </fieldset>
              </div>

              {/* Summary */}
              <aside className="lg:sticky lg:top-8 lg:col-span-2">
                <div className={`${cardCls} overflow-hidden p-0`}>
                  <div className="fckt-shimmer h-1 w-full bg-gradient-to-r from-transparent via-indigo-500 to-transparent" />
                  <div className="p-5 sm:p-6">
                    <h3 className="text-sm font-semibold">Order summary</h3>

                    <ul className="mt-4 space-y-4">
                      {items.map((item) => (
                        <li key={item.id} className="flex gap-3">
                          <div
                            className={`flex h-12 w-12 flex-none items-center justify-center rounded-xl text-sm font-bold text-white ${item.tint}`}
                            aria-hidden="true"
                          >
                            {item.initials}
                          </div>
                          <div className="min-w-0 flex-1">
                            <p className="truncate text-sm font-medium">{item.name}</p>
                            <p className="truncate text-xs text-slate-500 dark:text-slate-400">
                              {item.variant}
                            </p>
                            <div className="mt-2 flex items-center justify-between">
                              <div
                                className="inline-flex items-center rounded-lg border border-slate-200 dark:border-slate-700"
                                role="group"
                                aria-label={`Quantity for ${item.name}`}
                              >
                                <button
                                  type="button"
                                  onClick={() => setQty(item.id, -1)}
                                  disabled={item.qty <= 1}
                                  aria-label={`Decrease quantity of ${item.name}`}
                                  className="flex h-7 w-7 items-center justify-center rounded-l-lg text-slate-600 outline-none transition-colors hover:bg-slate-100 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500/50 disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800"
                                >
                                  <svg viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
                                    <path d="M3 8h10" />
                                  </svg>
                                </button>
                                <span className="w-7 text-center text-sm font-semibold tabular-nums" aria-live="polite">
                                  {item.qty}
                                </span>
                                <button
                                  type="button"
                                  onClick={() => setQty(item.id, 1)}
                                  disabled={item.qty >= 9}
                                  aria-label={`Increase quantity of ${item.name}`}
                                  className="flex h-7 w-7 items-center justify-center rounded-r-lg text-slate-600 outline-none transition-colors hover:bg-slate-100 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500/50 disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800"
                                >
                                  <svg viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
                                    <path d="M8 3v10M3 8h10" />
                                  </svg>
                                </button>
                              </div>
                              <span className="text-sm font-semibold tabular-nums">
                                {fmt(item.unit * item.qty)}
                              </span>
                            </div>
                          </div>
                        </li>
                      ))}
                    </ul>

                    {/* Promo */}
                    <div className="mt-5 border-t border-slate-200 pt-5 dark:border-slate-800">
                      <label
                        htmlFor="co-promo"
                        className="mb-1.5 block text-xs font-medium text-slate-600 dark:text-slate-400"
                      >
                        Promo code
                      </label>
                      <div className="flex gap-2">
                        <input
                          id="co-promo"
                          value={promoInput}
                          onChange={(e) => {
                            setPromoInput(e.target.value);
                            if (promoError) setPromoError("");
                          }}
                          onKeyDown={(e) => {
                            if (e.key === "Enter") {
                              e.preventDefault();
                              applyPromo();
                            }
                          }}
                          placeholder="Enter code"
                          aria-invalid={promoError ? true : undefined}
                          aria-describedby={promoError ? "co-promo-error" : "co-promo-hint"}
                          className={inputCls}
                        />
                        <button
                          type="button"
                          onClick={applyPromo}
                          className="flex-none rounded-xl border border-slate-300 px-4 py-2.5 text-sm font-semibold text-slate-700 outline-none transition-colors hover:bg-slate-100 focus-visible:ring-2 focus-visible:ring-indigo-500/50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
                        >
                          Apply
                        </button>
                      </div>
                      <div aria-live="polite">
                        {promoError ? (
                          <p
                            id="co-promo-error"
                            role="alert"
                            className="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400"
                          >
                            {promoError}
                          </p>
                        ) : (
                          <p
                            id="co-promo-hint"
                            className="mt-1.5 text-xs text-slate-400 dark:text-slate-500"
                          >
                            Try <span className="font-semibold">BREW15</span> or{" "}
                            <span className="font-semibold">FREESHIP</span>.
                          </p>
                        )}
                        <AnimatePresence>
                          {appliedPromo ? (
                            <motion.div
                              initial={reduce ? false : { opacity: 0, height: 0 }}
                              animate={{ opacity: 1, height: "auto" }}
                              exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}
                              transition={{ duration: 0.25 }}
                              className="overflow-hidden"
                            >
                              <div className="mt-2 flex items-center justify-between rounded-lg bg-emerald-50 px-3 py-2 text-xs font-medium text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300">
                                <span className="flex items-center gap-1.5">
                                  <svg viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                                    <path d="M3 8.5l3 3 7-7.5" />
                                  </svg>
                                  {PROMOS[appliedPromo].label}
                                </span>
                                <button
                                  type="button"
                                  onClick={() => setAppliedPromo(null)}
                                  className="rounded text-emerald-700 underline-offset-2 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-emerald-500/50 dark:text-emerald-300"
                                >
                                  Remove
                                </button>
                              </div>
                            </motion.div>
                          ) : null}
                        </AnimatePresence>
                      </div>
                    </div>

                    {/* Totals */}
                    <dl className="mt-5 space-y-2.5 border-t border-slate-200 pt-5 text-sm dark:border-slate-800">
                      <div className="flex justify-between">
                        <dt className="text-slate-500 dark:text-slate-400">Subtotal</dt>
                        <dd className="tabular-nums">{fmt(totals.subtotal)}</dd>
                      </div>
                      {totals.discount > 0 ? (
                        <div className="flex justify-between text-emerald-600 dark:text-emerald-400">
                          <dt>Discount</dt>
                          <dd className="tabular-nums">-{fmt(totals.discount)}</dd>
                        </div>
                      ) : null}
                      <div className="flex justify-between">
                        <dt className="text-slate-500 dark:text-slate-400">Shipping</dt>
                        <dd className="tabular-nums">
                          {totals.shipping === 0 ? "Free" : fmt(totals.shipping)}
                        </dd>
                      </div>
                      <div className="flex justify-between">
                        <dt className="text-slate-500 dark:text-slate-400">Tax (8.25%)</dt>
                        <dd className="tabular-nums">{fmt(totals.tax)}</dd>
                      </div>
                    </dl>

                    <div className="mt-4 flex items-baseline justify-between border-t border-slate-200 pt-4 dark:border-slate-800">
                      <span className="text-base font-semibold">Total</span>
                      <span className="text-xl font-bold tabular-nums">{fmt(totals.total)}</span>
                    </div>

                    <button
                      type="submit"
                      className="mt-5 flex w-full items-center justify-center gap-2 rounded-xl bg-indigo-600 px-5 py-3 text-sm font-semibold text-white shadow-sm outline-none transition-colors hover:bg-indigo-500 focus-visible:ring-2 focus-visible:ring-indigo-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white active:bg-indigo-700 dark:focus-visible:ring-offset-slate-900"
                    >
                      <LockIcon />
                      Pay {fmt(totals.total)}
                    </button>

                    {submitted && Object.keys(errors).length > 0 ? (
                      <p role="alert" className="mt-3 text-center text-xs font-medium text-rose-600 dark:text-rose-400">
                        Please fix the highlighted fields above.
                      </p>
                    ) : null}

                    <div className="mt-4 flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 text-xs text-slate-400 dark:text-slate-500">
                      <span className="flex items-center gap-1.5">
                        <LockIcon /> 256-bit TLS
                      </span>
                      <span>PCI-DSS compliant</span>
                      <span>30-day returns</span>
                    </div>
                  </div>
                </div>
              </aside>
            </motion.form>
          )}
        </AnimatePresence>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →