Fintech & Payments

Dispute Center

A chargeback dispute row that expands into an evidence checklist with a progress bar and a deadline countdown that escalates to warning and destructive.

Install

npx shadcn@latest add @paragon/dispute-center

dispute-center.tsx

"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>
  );
}