E-commerce

Return Request

An RMA card to select items, pick a reason and refund method, with a refund total that tweens as items change.

Install

npx shadcn@latest add @paragon/return-request

return-request.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, RotateCcw, CreditCard, Wallet } from "lucide-react";
import { cn } from "@/lib/utils";

export interface ReturnItem {
  id: string;
  name: string;
  variant?: string;
  price: number;
  hue?: number;
}

export interface ReturnRequestProps
  extends Omit<React.ComponentProps<"div">, "onSubmit"> {
  items: ReturnItem[];
  reasons?: string[];
  currency?: string;
  locale?: Intl.LocalesArgument;
  onSubmit?: (payload: {
    items: string[];
    reason: string;
    refund: "original" | "credit";
  }) => void;
}

function placeholder(hue: number) {
  return `linear-gradient(135deg, hsl(${hue} 55% 82%), hsl(${(hue + 40) % 360} 60% 66%))`;
}

const DEFAULT_REASONS = [
  "Wrong size or fit",
  "Item defective or damaged",
  "Not as described",
  "Changed my mind",
];

/**
 * A return (RMA) card: check the items to send back, pick a reason, choose a
 * refund method, and see the running refund total tween. The submit button
 * enables only when the form is valid and flips to a confirmed state. Money is
 * tabular. Reduced motion drops the total tween and check pop.
 */
export function ReturnRequest({
  items,
  reasons = DEFAULT_REASONS,
  currency = "USD",
  locale = "en-US",
  onSubmit,
  className,
  ...props
}: ReturnRequestProps) {
  const reduced = useReducedMotion();
  const [selected, setSelected] = React.useState<Set<string>>(new Set());
  const [reason, setReason] = React.useState("");
  const [refund, setRefund] = React.useState<"original" | "credit">("credit");
  const [done, setDone] = React.useState(false);

  const format = React.useMemo(
    () => new Intl.NumberFormat(locale, { style: "currency", currency }),
    [locale, currency],
  );

  const toggle = (id: string) =>
    setSelected((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });

  const total = items
    .filter((i) => selected.has(i.id))
    .reduce((sum, i) => sum + i.price, 0);
  const bonus = refund === "credit" ? total * 0.1 : 0;

  const valid = selected.size > 0 && reason !== "" && !done;

  return (
    <div
      className={cn(
        "w-full max-w-md rounded-xl bg-card p-4 shadow-border",
        className,
      )}
      {...props}
    >
      <div className="mb-3 flex items-center gap-2">
        <RotateCcw className="size-4 text-muted-foreground" aria-hidden />
        <h3 className="text-sm font-medium text-card-foreground">
          Start a return
        </h3>
      </div>

      {/* Items */}
      <fieldset className="flex flex-col gap-2">
        <legend className="sr-only">Select items to return</legend>
        {items.map((item) => {
          const checked = selected.has(item.id);
          return (
            <label
              key={item.id}
              className={cn(
                "flex cursor-pointer items-center gap-3 rounded-lg p-2 transition-colors duration-150",
                checked ? "bg-accent/50" : "hover:bg-accent/30",
              )}
            >
              <span
                className={cn(
                  "flex size-5 shrink-0 items-center justify-center rounded-md transition-[background-color,box-shadow] duration-150 ease-out",
                  checked
                    ? "bg-foreground text-background"
                    : "shadow-[inset_0_0_0_1.5px_var(--color-border)]",
                )}
              >
                <Check
                  className={cn(
                    "size-3.5 transition-[scale,opacity] duration-150 ease-out",
                    checked ? "scale-100 opacity-100" : "scale-50 opacity-0",
                  )}
                  aria-hidden
                />
              </span>
              <input
                type="checkbox"
                className="sr-only"
                checked={checked}
                onChange={() => toggle(item.id)}
              />
              <span
                aria-hidden
                className="size-9 shrink-0 rounded-md"
                style={{ background: placeholder(item.hue ?? 40) }}
              />
              <span className="min-w-0 flex-1">
                <span className="block truncate text-sm text-card-foreground">
                  {item.name}
                </span>
                {item.variant && (
                  <span className="block text-xs text-muted-foreground">
                    {item.variant}
                  </span>
                )}
              </span>
              <span className="text-sm tabular-nums text-card-foreground">
                {format.format(item.price)}
              </span>
            </label>
          );
        })}
      </fieldset>

      {/* Reason */}
      <label className="mt-4 block">
        <span className="mb-1.5 block text-xs font-medium text-muted-foreground">
          Reason
        </span>
        <select
          value={reason}
          onChange={(e) => setReason(e.target.value)}
          className="h-9 w-full rounded-lg border border-input bg-transparent px-3 text-sm text-foreground outline-none transition-[border-color,box-shadow] duration-150 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25"
        >
          <option value="" disabled>
            Select a reason…
          </option>
          {reasons.map((r) => (
            <option key={r} value={r}>
              {r}
            </option>
          ))}
        </select>
      </label>

      {/* Refund method */}
      <div className="mt-4">
        <span className="mb-1.5 block text-xs font-medium text-muted-foreground">
          Refund to
        </span>
        <div role="radiogroup" aria-label="Refund method" className="grid grid-cols-2 gap-2">
          {(
            [
              {
                id: "credit",
                label: "Store credit",
                Icon: Wallet,
                hint: "+10% bonus",
              },
              {
                id: "original",
                label: "Original payment",
                Icon: CreditCard,
                hint: undefined,
              },
            ] as const
          ).map(({ id, label, Icon, hint }) => {
            const on = refund === id;
            return (
              <button
                key={id}
                type="button"
                role="radio"
                aria-checked={on}
                onClick={() => setRefund(id)}
                className={cn(
                  "flex flex-col items-start gap-1 rounded-lg p-2.5 text-left outline-none transition-[box-shadow] duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring",
                  on ? "shadow-[0_0_0_2px_var(--color-foreground)]" : "shadow-border hover:shadow-border-hover",
                )}
              >
                <Icon className="size-4 text-muted-foreground" aria-hidden />
                <span className="text-xs font-medium text-card-foreground">
                  {label}
                </span>
                {hint && (
                  <span className="text-[11px] font-medium text-success">
                    {hint}
                  </span>
                )}
              </button>
            );
          })}
        </div>
      </div>

      {/* Total + submit */}
      <div className="mt-4 flex items-center justify-between border-t border-border pt-3">
        <span className="text-sm text-muted-foreground">Estimated refund</span>
        <TotalTween
          value={total + bonus}
          format={format}
          reduced={!!reduced}
        />
      </div>

      <button
        type="button"
        disabled={!valid}
        onClick={() => {
          setDone(true);
          onSubmit?.({
            items: Array.from(selected),
            reason,
            refund,
          });
        }}
        className={cn(
          "pressable mt-3 flex h-9 w-full items-center justify-center gap-2 rounded-lg text-sm font-medium transition-[background-color,color] duration-150 ease-out disabled:pointer-events-none disabled:opacity-50",
          done
            ? "bg-success text-success-foreground"
            : "bg-primary text-primary-foreground hover:bg-primary/90",
        )}
      >
        <AnimatePresence mode="popLayout" initial={false}>
          <motion.span
            key={done ? "done" : "submit"}
            initial={{ opacity: 0, y: 6 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: -6 }}
            transition={{ type: "spring", duration: 0.3, bounce: 0 }}
            className="flex items-center gap-2"
          >
            {done ? (
              <>
                <Check className="size-4" aria-hidden />
                Return requested
              </>
            ) : (
              "Request return label"
            )}
          </motion.span>
        </AnimatePresence>
      </button>
    </div>
  );
}

function TotalTween({
  value,
  format,
  reduced,
}: {
  value: number;
  format: Intl.NumberFormat;
  reduced: boolean;
}) {
  const [display, setDisplay] = React.useState(value);
  const from = React.useRef(value);
  React.useEffect(() => {
    if (reduced) {
      setDisplay(value);
      from.current = value;
      return;
    }
    const start = performance.now();
    const a = from.current;
    const b = value;
    let raf = 0;
    const tick = (t: number) => {
      const k = Math.min(1, (t - start) / 300);
      const eased = 1 - Math.pow(1 - k, 3);
      setDisplay(a + (b - a) * eased);
      if (k < 1) raf = requestAnimationFrame(tick);
      else from.current = b;
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [value, reduced]);

  return (
    <span className="text-lg font-semibold tabular-nums text-card-foreground">
      {format.format(display)}
    </span>
  );
}