A promo-code list with status pills, copy-to-clipboard, and usage bars that grow to the redemption count.
npx shadcn@latest add @paragon/discount-manager"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>
);
}