Billing Payment Method

PreviousNext

Manage payment methods with add, edit, delete, and set default options.

Docs
hextauiui

Preview

Loading preview…
registry/new-york/blocks/billing/billing-payment-method.tsx
"use client";

import { CreditCard, Loader2, MoreVertical, Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/utils";
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from "@/registry/new-york/ui/alert-dialog";
import { Badge } from "@/registry/new-york/ui/badge";
import { Button } from "@/registry/new-york/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/registry/new-york/ui/card";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/registry/new-york/ui/dropdown-menu";

export interface PaymentMethod {
  id: string;
  type: "card" | "bank_account" | "paypal" | "other";
  last4?: string;
  brand?: "visa" | "mastercard" | "amex" | "discover" | "other";
  expiryMonth?: number;
  expiryYear?: number;
  holderName?: string;
  isDefault?: boolean;
  billingAddress?: {
    line1?: string;
    city?: string;
    state?: string;
    zip?: string;
    country?: string;
  };
}

export interface BillingPaymentMethodProps {
  paymentMethods: PaymentMethod[];
  onAdd?: () => void;
  onEdit?: (methodId: string) => void;
  onDelete?: (methodId: string) => void;
  onSetDefault?: (methodId: string) => void;
  className?: string;
  allowMultiple?: boolean;
  isLoading?: boolean;
}

function getCardBrandIcon(brand?: PaymentMethod["brand"]) {
  switch (brand) {
    case "visa":
      return "💳";
    case "mastercard":
      return "💳";
    case "amex":
      return "💳";
    case "discover":
      return "💳";
    default:
      return "💳";
  }
}

function formatExpiry(month?: number, year?: number): string {
  if (!(month && year)) return "";
  const monthStr = month.toString().padStart(2, "0");
  const yearStr = year.toString().slice(-2);
  return `${monthStr}/${yearStr}`;
}

export default function BillingPaymentMethod({
  paymentMethods,
  onAdd,
  onEdit,
  onDelete,
  onSetDefault,
  className,
  allowMultiple = true,
  isLoading = false,
}: BillingPaymentMethodProps) {
  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
  const [deletingId, setDeletingId] = useState<string | null>(null);

  const handleDelete = async (methodId: string) => {
    setDeletingId(methodId);
    setDeleteDialogOpen(true);
  };

  const confirmDelete = async () => {
    if (deletingId) {
      await onDelete?.(deletingId);
      setDeletingId(null);
    }
    setDeleteDialogOpen(false);
  };

  if (paymentMethods.length === 0) {
    return (
      <Card className={cn("w-full shadow-xs", className)}>
        <CardHeader>
          <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
            <div className="flex flex-col gap-1">
              <CardTitle>Payment methods</CardTitle>
              <CardDescription>Manage your payment methods</CardDescription>
            </div>
            {onAdd && (
              <Button
                className="w-full sm:w-auto"
                onClick={onAdd}
                type="button"
              >
                <Plus className="size-4" />
                Add payment method
              </Button>
            )}
          </div>
        </CardHeader>
        <CardContent>
          <div className="flex flex-col items-center justify-center gap-4 py-12 text-center">
            <div className="flex size-12 items-center justify-center rounded-full bg-muted">
              <CreditCard className="size-6 text-muted-foreground" />
            </div>
            <p className="text-muted-foreground text-sm">
              No payment methods added yet
            </p>
            {onAdd && (
              <Button onClick={onAdd} type="button" variant="outline">
                <Plus className="size-4" />
                Add payment method
              </Button>
            )}
          </div>
        </CardContent>
      </Card>
    );
  }

  return (
    <>
      <Card className={cn("w-full shadow-xs", className)}>
        <CardHeader>
          <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
            <div className="flex flex-col gap-1">
              <CardTitle>Payment methods</CardTitle>
              <CardDescription>Manage your payment methods</CardDescription>
            </div>
            {onAdd && allowMultiple && (
              <Button
                className="w-full sm:w-auto"
                onClick={onAdd}
                type="button"
              >
                <Plus className="size-4" />
                Add payment method
              </Button>
            )}
          </div>
        </CardHeader>
        <CardContent>
          <div className="flex flex-col gap-3">
            {paymentMethods.map((method) => (
              <div
                className="flex flex-col gap-3 rounded-lg border bg-card p-4 sm:flex-row sm:items-center sm:justify-between"
                key={method.id}
              >
                <div className="flex min-w-0 flex-1 items-center gap-3">
                  <div className="flex size-10 shrink-0 items-center justify-center rounded-lg border bg-background text-xl">
                    {method.type === "card"
                      ? getCardBrandIcon(method.brand)
                      : method.type === "paypal"
                        ? "P"
                        : "🏦"}
                  </div>
                  <div className="flex min-w-0 flex-1 flex-col gap-2">
                    <div className="flex flex-wrap items-center gap-2">
                      <span className="font-medium text-sm">
                        {method.type === "card"
                          ? `${method.brand ? method.brand.charAt(0).toUpperCase() + method.brand.slice(1) : "Card"} •••• ${method.last4 || "0000"}`
                          : method.type === "paypal"
                            ? "PayPal"
                            : method.type === "bank_account"
                              ? `Bank Account •••• ${method.last4 || "0000"}`
                              : "Other"}
                      </span>
                    </div>
                    <div className="flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
                      {method.holderName && <span>{method.holderName}</span>}
                      {method.expiryMonth && method.expiryYear && (
                        <>
                          {method.holderName && (
                            <span aria-hidden="true">•</span>
                          )}
                          <span>
                            Expires{" "}
                            {formatExpiry(
                              method.expiryMonth,
                              method.expiryYear
                            )}
                          </span>
                        </>
                      )}
                    </div>
                  </div>
                </div>
                <div className="flex shrink-0 items-center gap-2">
                  {method.isDefault && (
                    <Badge className="text-xs">Default</Badge>
                  )}
                  {!method.isDefault && onSetDefault && (
                    <Button
                      onClick={() => onSetDefault(method.id)}
                      type="button"
                      variant="outline"
                    >
                      Set default
                    </Button>
                  )}
                  <DropdownMenu>
                    <DropdownMenuTrigger asChild>
                      <Button
                        aria-label="Payment method options"
                        size="icon"
                        type="button"
                        variant="ghost"
                      >
                        <MoreVertical className="size-4" />
                      </Button>
                    </DropdownMenuTrigger>
                    <DropdownMenuContent align="end">
                      {onEdit && (
                        <DropdownMenuItem onClick={() => onEdit(method.id)}>
                          Edit
                        </DropdownMenuItem>
                      )}
                      {!method.isDefault && onSetDefault && (
                        <DropdownMenuItem
                          onClick={() => onSetDefault(method.id)}
                        >
                          Set as default
                        </DropdownMenuItem>
                      )}
                      {onDelete && (
                        <>
                          <DropdownMenuSeparator />
                          <DropdownMenuItem
                            className="text-destructive"
                            onClick={() => handleDelete(method.id)}
                          >
                            <Trash2 className="size-4" />
                            Delete
                          </DropdownMenuItem>
                        </>
                      )}
                    </DropdownMenuContent>
                  </DropdownMenu>
                </div>
              </div>
            ))}
          </div>
        </CardContent>
      </Card>

      <AlertDialog onOpenChange={setDeleteDialogOpen} open={deleteDialogOpen}>
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>Delete payment method?</AlertDialogTitle>
            <AlertDialogDescription>
              This action cannot be undone. This payment method will be
              permanently removed from your account.
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel>Cancel</AlertDialogCancel>
            <AlertDialogAction
              className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
              onClick={confirmDelete}
            >
              {deletingId && isLoading ? (
                <>
                  <Loader2 className="size-4 animate-spin" />
                  Deleting…
                </>
              ) : (
                "Delete"
              )}
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
    </>
  );
}

Installation

npx shadcn@latest add @hextaui/billing-payment-method

Usage

import { BillingPaymentMethod } from "@/components/ui/billing-payment-method"
<BillingPaymentMethod />