E-commerce

Order Summary

A checkout breakdown with rolling totals and a promo-code field that draws a check and slides a discount row in on apply.

Install

npx shadcn@latest add @paragon/order-summary

Also installs: digit-roll

order-summary.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { DigitRoll } from "@/registry/paragon/ui/digit-roll";

export interface OrderSummaryProps
  extends Omit<React.ComponentProps<"div">, "onChange"> {
  subtotal: number;
  shipping?: number;
  /** Tax rate applied to (subtotal − discount), e.g. 0.0875. */
  taxRate?: number;
  currency?: string;
  locale?: Intl.LocalesArgument;
  /** Enable the promo-code field. */
  promo?: boolean;
  /**
   * Resolve a code to a discount amount (in currency units). Return 0/undefined
   * to reject the code. Defaults to a demo table.
   */
  onApplyPromo?: (code: string) => number | Promise<number>;
  static?: boolean;
}

const DEMO_CODES: Record<string, number> = {
  WELCOME10: 10,
  FREESHIP: 8,
  TAKE20: 20,
};

/**
 * A checkout summary: subtotal, shipping, tax, and total, each total rolling
 * as inputs change. An optional promo field validates on apply — success draws
 * a check inside the field and slides a discount row into the breakdown; a bad
 * code shakes the field. Rows enter/exit via grid-rows so height stays cheap.
 */
export function OrderSummary({
  subtotal,
  shipping = 0,
  taxRate = 0,
  currency = "USD",
  locale = "en-US",
  promo = true,
  onApplyPromo,
  static: isStatic = false,
  className,
  ...props
}: OrderSummaryProps) {
  const reduced = useReducedMotion() ?? false;
  const [code, setCode] = React.useState("");
  const [applied, setApplied] = React.useState<{
    code: string;
    amount: number;
  } | null>(null);
  const [status, setStatus] = React.useState<"idle" | "loading" | "error">(
    "idle",
  );
  const shake = status === "error";

  const format = (n: number) =>
    new Intl.NumberFormat(locale, { style: "currency", currency }).format(n);

  const discount = applied?.amount ?? 0;
  const taxable = Math.max(0, subtotal - discount);
  const tax = taxable * taxRate;
  const total = taxable + shipping + tax;

  const apply = async () => {
    const trimmed = code.trim().toUpperCase();
    if (!trimmed || status === "loading") return;
    setStatus("loading");
    try {
      const resolver = onApplyPromo ?? ((c: string) => DEMO_CODES[c] ?? 0);
      const amount = await resolver(trimmed);
      if (amount > 0) {
        setApplied({ code: trimmed, amount });
        setStatus("idle");
        setCode("");
      } else {
        setStatus("error");
      }
    } catch {
      setStatus("error");
    }
  };

  const Row = ({
    label,
    value,
    muted,
    negative,
  }: {
    label: string;
    value: number;
    muted?: boolean;
    negative?: boolean;
  }) => (
    <div className="flex items-center justify-between py-1.5 text-sm">
      <span className={muted ? "text-muted-foreground" : undefined}>
        {label}
      </span>
      <span className={cn("tabular-nums", negative && "text-success")}>
        {negative && "−"}
        <DigitRoll
          value={negative ? Math.abs(value) : value}
          locale={locale}
          formatOptions={{ style: "currency", currency }}
          static={isStatic}
        />
      </span>
    </div>
  );

  return (
    <>
      <style href="paragon-order-summary" precedence="paragon">{`
        @keyframes pg-summary-shake {
          10%, 90% { translate: -1px 0; }
          30%, 70% { translate: 3px 0; }
          50% { translate: -4px 0; }
        }
        @media (prefers-reduced-motion: reduce) {
          .pg-summary-field { animation: none !important; }
        }
      `}</style>
      <div
        className={cn(
          "flex w-full flex-col rounded-xl bg-card p-5 shadow-border",
          className,
        )}
        {...props}
      >
        <h3 className="mb-1 text-sm font-semibold">Order summary</h3>
        <div className="divide-y divide-border/60">
          <div className="py-1">
            <Row label="Subtotal" value={subtotal} muted />
            <div
              className={cn(
                "grid transition-[grid-template-rows] duration-250 ease-out",
                applied ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
              )}
            >
              <div className="overflow-hidden">
                {applied && (
                  <div className="flex items-center justify-between py-1.5 text-sm">
                    <span className="inline-flex items-center gap-1.5 text-muted-foreground">
                      Discount
                      <span className="rounded bg-success/12 px-1.5 py-0.5 text-xs font-medium text-success">
                        {applied.code}
                      </span>
                    </span>
                    <span className="text-success tabular-nums">
{format(applied.amount)}
                    </span>
                  </div>
                )}
              </div>
            </div>
            <Row label="Shipping" value={shipping} muted />
            {taxRate > 0 && <Row label="Tax" value={tax} muted />}
          </div>

          <div className="flex items-center justify-between py-3 text-base font-semibold">
            <span>Total</span>
            <span className="tabular-nums">
              <DigitRoll
                value={total}
                locale={locale}
                formatOptions={{ style: "currency", currency }}
                static={isStatic}
              />
            </span>
          </div>
        </div>

        {promo && !applied && (
          <div
            className={cn(
              "pg-summary-field mt-1 flex items-center gap-2",
              shake && "[animation:pg-summary-shake_280ms_var(--ease-out)]",
            )}
            onAnimationEnd={() => shake && setStatus("idle")}
          >
            <div className="relative flex-1">
              <input
                value={code}
                onChange={(e) => {
                  setCode(e.target.value);
                  if (status === "error") setStatus("idle");
                }}
                onKeyDown={(e) => {
                  if (e.key === "Enter") {
                    e.preventDefault();
                    apply();
                  }
                }}
                placeholder="Promo code"
                aria-label="Promo code"
                aria-invalid={status === "error" || undefined}
                className={cn(
                  "h-9 w-full rounded-lg border bg-transparent px-3 text-sm uppercase outline-none transition-[border-color,box-shadow] duration-150 placeholder:normal-case placeholder:text-muted-foreground focus:ring-2 focus:ring-ring/30",
                  status === "error"
                    ? "border-destructive focus:border-destructive focus:ring-destructive/25"
                    : "border-input focus:border-ring",
                )}
              />
            </div>
            <button
              type="button"
              onClick={apply}
              disabled={!code.trim() || status === "loading"}
              className={cn(
                "inline-flex h-9 items-center justify-center gap-1.5 rounded-lg bg-secondary px-3.5 text-sm font-medium text-secondary-foreground outline-none transition-[background-color,scale] duration-150 hover:bg-secondary/80 focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
                !isStatic && "active:not-disabled:scale-[0.97]",
              )}
            >
              {status === "loading" ? (
                <Loader2 className="size-4 animate-spin" aria-hidden />
              ) : null}
              Apply
            </button>
          </div>
        )}

        {promo && applied && (
          <AnimatePresence>
            <motion.div
              key="applied"
              initial={
                isStatic || reduced
                  ? false
                  : { opacity: 0, y: 4, filter: "blur(4px)" }
              }
              animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
              transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
              className="mt-1 flex items-center gap-2 text-sm text-success"
            >
              <span className="flex size-4 items-center justify-center rounded-full bg-success text-success-foreground">
                <Check className="size-3" strokeWidth={3} aria-hidden />
              </span>
              Code {applied.code} applied
              <button
                type="button"
                onClick={() => setApplied(null)}
                className="ml-auto text-xs text-muted-foreground underline-offset-2 hover:underline"
              >
                Remove
              </button>
            </motion.div>
          </AnimatePresence>
        )}
      </div>
    </>
  );
}