E-commerce

Discount Manager

A promo-code list with status pills, copy-to-clipboard, and usage bars that grow to the redemption count.

Install

npx shadcn@latest add @paragon/discount-manager

discount-manager.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Tag, Copy, Check, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";

export interface Discount {
  id: string;
  code: string;
  /** Human summary, e.g. "20% off orders over $75". */
  summary: string;
  status: "active" | "scheduled" | "expired";
  /** Times redeemed. */
  used: number;
  /** Redemption cap; omit for unlimited. */
  limit?: number;
}

export interface DiscountManagerProps extends React.ComponentProps<"div"> {
  discounts: Discount[];
  locale?: Intl.LocalesArgument;
}

const STATUS: Record<
  Discount["status"],
  { label: string; className: string }
> = {
  active: { label: "Active", className: "bg-success/12 text-success" },
  scheduled: { label: "Scheduled", className: "bg-warning/15 text-warning" },
  expired: {
    label: "Expired",
    className: "bg-muted text-muted-foreground",
  },
};

/**
 * A promo-code management list. Each row shows the code, a plain-language rule,
 * a status pill, and a usage bar (redeemed / cap) that grows in on mount. The
 * copy button swaps its icon on click (copy → check). Numbers are tabular so
 * columns line up. Reduced motion renders bars at their final width.
 */
export function DiscountManager({
  discounts,
  locale = "en-US",
  className,
  ...props
}: DiscountManagerProps) {
  const nf = React.useMemo(() => new Intl.NumberFormat(locale), [locale]);
  return (
    <div
      className={cn(
        "w-full max-w-lg divide-y divide-border overflow-hidden rounded-xl bg-card shadow-border",
        className,
      )}
      {...props}
    >
      {discounts.map((d) => (
        <DiscountRow key={d.id} discount={d} nf={nf} />
      ))}
    </div>
  );
}

function DiscountRow({
  discount: d,
  nf,
}: {
  discount: Discount;
  nf: Intl.NumberFormat;
}) {
  const reduced = useReducedMotion();
  const [copied, setCopied] = React.useState(false);
  const timeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
  React.useEffect(() => () => {
    if (timeout.current) clearTimeout(timeout.current);
  }, []);

  const status = STATUS[d.status];
  const pct =
    d.limit && d.limit > 0
      ? Math.min(100, Math.round((d.used / d.limit) * 100))
      : null;

  const copy = () => {
    navigator.clipboard?.writeText(d.code);
    setCopied(true);
    if (timeout.current) clearTimeout(timeout.current);
    timeout.current = setTimeout(() => setCopied(false), 1500);
  };

  return (
    <div className="flex items-center gap-3 px-4 py-3">
      <span
        aria-hidden
        className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-secondary text-muted-foreground"
      >
        <Tag className="size-4" />
      </span>

      <div className="min-w-0 flex-1">
        <div className="flex items-center gap-2">
          <code className="rounded-md bg-secondary px-1.5 py-0.5 font-mono text-xs font-medium text-foreground">
            {d.code}
          </code>
          <span
            className={cn(
              "rounded-full px-1.5 py-0.5 text-[11px] font-medium",
              status.className,
            )}
          >
            {status.label}
          </span>
        </div>
        <p className="mt-1 truncate text-xs text-muted-foreground">
          {d.summary}
        </p>
        <div className="mt-2 flex items-center gap-2">
          <span className="text-[11px] tabular-nums text-muted-foreground">
            {nf.format(d.used)}
            {d.limit ? ` / ${nf.format(d.limit)}` : " redeemed"}
          </span>
          {pct !== null && (
            <span
              aria-hidden
              className="h-1 flex-1 overflow-hidden rounded-full bg-secondary"
            >
              <motion.span
                className={cn(
                  "block h-full rounded-full",
                  pct >= 100 ? "bg-warning" : "bg-foreground",
                )}
                initial={reduced ? false : { scaleX: 0 }}
                animate={{ scaleX: pct / 100 }}
                transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
                style={{ transformOrigin: "left", width: "100%" }}
              />
            </span>
          )}
        </div>
      </div>

      <button
        type="button"
        onClick={copy}
        aria-label={copied ? "Copied" : `Copy code ${d.code}`}
        className="pressable relative flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground"
      >
        <AnimatePresence mode="popLayout" initial={false}>
          <motion.span
            key={copied ? "check" : "copy"}
            initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
            animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
            exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
            transition={{ type: "spring", duration: 0.3, bounce: 0 }}
          >
            {copied ? (
              <Check className="size-4 text-success" />
            ) : (
              <Copy className="size-4" />
            )}
          </motion.span>
        </AnimatePresence>
      </button>
      <button
        type="button"
        aria-label={`More actions for ${d.code}`}
        className="pressable flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground"
      >
        <MoreHorizontal className="size-4" aria-hidden />
      </button>
    </div>
  );
}