A chargeback dispute row that expands into an evidence checklist with a progress bar and a deadline countdown that escalates to warning and destructive.
npx shadcn@latest add @paragon/dispute-center"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronDown, Clock, Scale } from "lucide-react";
import { cn } from "@/lib/utils";
export interface EvidenceItem {
id: string;
label: string;
done?: boolean;
}
export interface DisputeCenterProps extends React.ComponentProps<"div"> {
merchant: string;
amount: number;
currency?: string;
/** Reason code / category, e.g. "Product not received". */
reason: string;
/** Whole days until the response deadline. */
daysLeft: number;
evidence: EvidenceItem[];
defaultOpen?: boolean;
caseId?: string;
}
/**
* A chargeback dispute row that expands into an evidence checklist. A
* countdown chip turns warning under 5 days and destructive under 2, so the
* deadline reads at a glance. Checking evidence advances a progress bar that
* animates its width via scaleX (never layout), and the submit button unlocks
* only when every required item is gathered. Accordion opens via
* grid-template-rows so height never janks.
*/
export function DisputeCenter({
merchant,
amount,
currency = "USD",
reason,
daysLeft,
evidence,
defaultOpen = false,
caseId,
className,
...props
}: DisputeCenterProps) {
const reduced = useReducedMotion();
const baseId = React.useId();
const [open, setOpen] = React.useState(defaultOpen);
const [checked, setChecked] = React.useState<Record<string, boolean>>(() =>
Object.fromEntries(evidence.map((e) => [e.id, !!e.done])),
);
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount);
const doneCount = evidence.filter((e) => checked[e.id]).length;
const ready = doneCount === evidence.length;
const pct = evidence.length ? doneCount / evidence.length : 0;
const urgency =
daysLeft <= 2
? "bg-destructive/10 text-destructive"
: daysLeft <= 5
? "bg-warning/15 text-warning"
: "bg-muted text-muted-foreground";
return (
<div
className={cn(
"w-full max-w-md overflow-hidden rounded-xl bg-card shadow-border",
className,
)}
{...props}
>
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-controls={`${baseId}-panel`}
className="flex w-full items-center gap-3 p-4 text-left transition-colors duration-150 ease-out hover:bg-accent/40"
>
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<Scale className="size-5 text-muted-foreground" aria-hidden />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{merchant}</p>
<p className="truncate text-[13px] text-muted-foreground">{reason}</p>
</div>
<div className="flex flex-col items-end gap-1">
<span className="text-sm font-semibold tabular-nums">{money}</span>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium tabular-nums",
urgency,
)}
>
<Clock className="size-3" aria-hidden />
{daysLeft}d left
</span>
</div>
<ChevronDown
aria-hidden
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform duration-200 ease-out motion-reduce:transition-none",
open && "rotate-180",
)}
/>
</button>
<div
id={`${baseId}-panel`}
className={cn(
"grid transition-[grid-template-rows] duration-250 ease-out motion-reduce:transition-none",
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">
<div className="border-t px-4 pb-4 pt-3">
<div className="mb-3 flex items-center justify-between">
<span className="text-[13px] font-medium">Evidence</span>
<span className="text-[13px] text-muted-foreground tabular-nums">
{doneCount}/{evidence.length}
</span>
</div>
<div className="mb-3 h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full origin-left rounded-full bg-success transition-transform duration-300 ease-out motion-reduce:transition-none"
style={{ transform: `scaleX(${pct})` }}
aria-hidden
/>
</div>
<ul className="flex flex-col gap-1">
{evidence.map((item) => {
const isDone = checked[item.id];
return (
<li key={item.id}>
<label className="flex cursor-pointer items-center gap-2.5 rounded-md px-1 py-1.5 transition-colors duration-150 ease-out hover:bg-accent/40">
<button
type="button"
role="checkbox"
aria-checked={isDone}
onClick={() =>
setChecked((c) => ({ ...c, [item.id]: !c[item.id] }))
}
className={cn(
"flex size-5 shrink-0 items-center justify-center rounded-md border transition-colors duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
isDone
? "border-success bg-success text-success-foreground"
: "border-input",
)}
>
<AnimatePresence initial={false}>
{isDone && (
<motion.span
initial={reduced ? { opacity: 0 } : { scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={reduced ? { opacity: 0 } : { scale: 0.5, opacity: 0 }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
>
<Check className="size-3.5" aria-hidden />
</motion.span>
)}
</AnimatePresence>
</button>
<span
className={cn(
"text-[13px]",
isDone && "text-muted-foreground line-through",
)}
>
{item.label}
</span>
</label>
</li>
);
})}
</ul>
<div className="mt-4 flex items-center justify-between gap-3">
{caseId && (
<span className="text-[12px] text-muted-foreground tabular-nums">
Case {caseId}
</span>
)}
<button
type="button"
disabled={!ready}
className="pressable ml-auto flex h-9 items-center justify-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors duration-150 ease-out hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50"
>
{ready ? "Submit evidence" : `${evidence.length - doneCount} left`}
</button>
</div>
</div>
</div>
</div>
</div>
);
}