Billing Payment Schedule

PreviousNext

View scheduled payments with next payment preview and retry options.

Docs
hextauiui

Preview

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

import {
  Calendar,
  Check,
  Clock,
  CreditCard,
  Loader2,
  TrendingUp,
  X,
} from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/utils";
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 { Progress } from "@/registry/new-york/ui/progress";
import { Separator } from "@/registry/new-york/ui/separator";

export interface ScheduledPayment {
  id: string;
  date: Date;
  amount: number;
  currency?: string;
  status: "upcoming" | "processing" | "completed" | "failed" | "canceled";
  description: string;
  paymentMethod?: {
    type: string;
    last4?: string;
    brand?: string;
  };
  invoiceId?: string;
  invoiceNumber?: string;
  retryAttempts?: number;
  maxRetryAttempts?: number;
}

export interface BillingPaymentScheduleProps {
  payments: ScheduledPayment[];
  onViewInvoice?: (invoiceId: string) => void;
  onRetry?: (paymentId: string) => Promise<void>;
  onCancel?: (paymentId: string) => Promise<void>;
  className?: string;
  currency?: string;
  showUpcomingOnly?: boolean;
}

function formatDate(date: Date): string {
  const year = date.getFullYear();
  const month = date.toLocaleString("en-US", { month: "short" });
  const day = date.getDate();
  return `${month} ${day}, ${year}`;
}

function formatPrice(amount: number, currency = "USD"): string {
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency,
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  }).format(amount);
}

function getStatusConfig(status: ScheduledPayment["status"]) {
  switch (status) {
    case "upcoming":
      return {
        label: "Upcoming",
        variant: "secondary" as const,
        className: "bg-blue-500/10 text-blue-600 border-blue-500/20",
        icon: Clock,
      };
    case "processing":
      return {
        label: "Processing",
        variant: "secondary" as const,
        className: "bg-yellow-500/10 text-yellow-600 border-yellow-500/20",
        icon: Loader2,
      };
    case "completed":
      return {
        label: "Completed",
        variant: "default" as const,
        className: "bg-green-500/10 text-green-600 border-green-500/20",
        icon: Check,
      };
    case "failed":
      return {
        label: "Failed",
        variant: "destructive" as const,
        className: "bg-destructive/10 text-destructive border-destructive/20",
        icon: TrendingUp,
      };
    case "canceled":
      return {
        label: "Canceled",
        variant: "secondary" as const,
        className: "",
        icon: X,
      };
    default:
      return {
        label: "Unknown",
        variant: "secondary" as const,
        className: "",
        icon: Clock,
      };
  }
}

function getDaysUntil(date: Date): number {
  const today = new Date();
  today.setHours(0, 0, 0, 0);
  const targetDate = new Date(date);
  targetDate.setHours(0, 0, 0, 0);
  const diffTime = targetDate.getTime() - today.getTime();
  const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
  return diffDays;
}

export default function BillingPaymentSchedule({
  payments,
  onViewInvoice,
  onRetry,
  onCancel,
  className,
  currency = "USD",
  showUpcomingOnly = false,
}: BillingPaymentScheduleProps) {
  const [isRetrying, setIsRetrying] = useState<string | null>(null);

  const filteredPayments = showUpcomingOnly
    ? payments.filter((p) => p.status === "upcoming")
    : payments;

  const sortedPayments = [...filteredPayments].sort(
    (a, b) => a.date.getTime() - b.date.getTime()
  );

  const upcomingPayments = payments.filter((p) => p.status === "upcoming");
  const nextPayment =
    upcomingPayments.length > 0
      ? upcomingPayments.sort((a, b) => a.date.getTime() - b.date.getTime())[0]
      : null;

  const handleRetry = async (paymentId: string) => {
    if (!onRetry) return;

    setIsRetrying(paymentId);
    try {
      await onRetry(paymentId);
    } finally {
      setIsRetrying(null);
    }
  };

  if (payments.length === 0) {
    return (
      <Card className={cn("w-full shadow-xs", className)}>
        <CardHeader>
          <div className="flex flex-col gap-1">
            <CardTitle>Payment Schedule</CardTitle>
            <CardDescription>
              View your upcoming and past scheduled payments
            </CardDescription>
          </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">
              <Calendar className="size-6 text-muted-foreground" />
            </div>
            <p className="text-muted-foreground text-sm">
              No scheduled payments
            </p>
          </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 Schedule</CardTitle>
            <CardDescription>
              View your upcoming and past scheduled payments
            </CardDescription>
          </div>
          {nextPayment && (
            <div className="flex flex-col gap-1 text-right">
              <div className="flex items-center justify-end gap-2 text-muted-foreground text-sm">
                <Calendar className="size-4" />
                <span>Next payment</span>
              </div>
              <div className="font-medium text-sm">
                {formatDate(nextPayment.date)}
              </div>
            </div>
          )}
        </div>
      </CardHeader>
      <CardContent>
        <div className="flex flex-col gap-6">
          {nextPayment && (
            <div className="flex flex-col gap-4 rounded-lg border bg-muted/50 p-4">
              <div className="flex items-center gap-2">
                <Clock className="size-5 text-blue-600" />
                <h3 className="font-medium text-sm">Next Payment</h3>
              </div>
              <div className="flex flex-col gap-3">
                <div className="flex items-center justify-between">
                  <span className="text-muted-foreground text-sm">Amount</span>
                  <span className="font-medium text-sm">
                    {formatPrice(
                      nextPayment.amount,
                      nextPayment.currency || currency
                    )}
                  </span>
                </div>
                <div className="flex items-center justify-between">
                  <span className="text-muted-foreground text-sm">Date</span>
                  <span className="text-sm">
                    {formatDate(nextPayment.date)}
                  </span>
                </div>
                {nextPayment.paymentMethod && (
                  <div className="flex items-center justify-between">
                    <span className="text-muted-foreground text-sm">
                      Payment Method
                    </span>
                    <div className="flex flex-wrap items-center gap-2 text-sm">
                      <span className="capitalize">
                        {nextPayment.paymentMethod.type}
                      </span>
                      {nextPayment.paymentMethod.brand && (
                        <>
                          <span aria-hidden="true">•</span>
                          <span className="capitalize">
                            {nextPayment.paymentMethod.brand}
                          </span>
                        </>
                      )}
                      {nextPayment.paymentMethod.last4 && (
                        <>
                          <span aria-hidden="true">•</span>
                          <span>•••• {nextPayment.paymentMethod.last4}</span>
                        </>
                      )}
                    </div>
                  </div>
                )}
                {nextPayment.description && (
                  <div className="flex items-center justify-between">
                    <span className="text-muted-foreground text-sm">
                      Description
                    </span>
                    <span className="wrap-break-word text-right text-sm">
                      {nextPayment.description}
                    </span>
                  </div>
                )}
                <div className="flex items-center gap-2 pt-2">
                  <div className="flex-1">
                    <Progress
                      aria-label={`Days until payment: ${getDaysUntil(nextPayment.date)}`}
                      className="h-2"
                      value={Math.max(
                        0,
                        Math.min(
                          100,
                          ((30 - getDaysUntil(nextPayment.date)) / 30) * 100
                        )
                      )}
                    />
                  </div>
                  <span className="shrink-0 text-muted-foreground text-xs">
                    {getDaysUntil(nextPayment.date)} day
                    {getDaysUntil(nextPayment.date) !== 1 ? "s" : ""} until
                  </span>
                </div>
              </div>
            </div>
          )}

          {sortedPayments.length > 0 && (
            <>
              {nextPayment && <Separator />}
              <div className="flex flex-col gap-4">
                <h3 className="font-medium text-sm">
                  {showUpcomingOnly ? "Upcoming Payments" : "All Payments"}
                </h3>
                <div className="flex flex-col gap-2">
                  {sortedPayments.map((payment) => {
                    const statusConfig = getStatusConfig(payment.status);
                    const StatusIcon = statusConfig.icon;
                    const isUpcoming = payment.status === "upcoming";
                    const daysUntil = getDaysUntil(payment.date);

                    return (
                      <div
                        className="flex flex-col gap-3 rounded-lg border bg-card p-4"
                        key={payment.id}
                      >
                        <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
                          <div className="flex min-w-0 flex-1 flex-col gap-2">
                            <div className="flex flex-wrap items-center gap-2">
                              <div className="flex items-center gap-2">
                                <StatusIcon
                                  className={cn(
                                    "size-4 shrink-0",
                                    payment.status === "processing" &&
                                      "animate-spin",
                                    isUpcoming && "text-blue-600",
                                    payment.status === "completed" &&
                                      "text-green-600",
                                    payment.status === "failed" &&
                                      "text-destructive"
                                  )}
                                />
                                <span className="wrap-break-word font-medium text-sm">
                                  {payment.description}
                                </span>
                              </div>
                              <Badge
                                className={cn(
                                  "shrink-0 text-xs",
                                  statusConfig.className
                                )}
                                variant={statusConfig.variant}
                              >
                                {statusConfig.label}
                              </Badge>
                            </div>
                            <div className="flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
                              <div className="flex items-center gap-1">
                                <Calendar className="size-3.5" />
                                <span>{formatDate(payment.date)}</span>
                              </div>
                              {isUpcoming && daysUntil >= 0 && (
                                <>
                                  <span aria-hidden="true">•</span>
                                  <span>
                                    {daysUntil === 0
                                      ? "Today"
                                      : daysUntil === 1
                                        ? "Tomorrow"
                                        : `${daysUntil} days`}
                                  </span>
                                </>
                              )}
                              {payment.invoiceNumber && (
                                <>
                                  <span aria-hidden="true">•</span>
                                  <span>{payment.invoiceNumber}</span>
                                </>
                              )}
                              {payment.paymentMethod && (
                                <>
                                  <span aria-hidden="true">•</span>
                                  <div className="flex items-center gap-1">
                                    <CreditCard className="size-3.5" />
                                    <span className="capitalize">
                                      {payment.paymentMethod.type}
                                    </span>
                                    {payment.paymentMethod.last4 && (
                                      <span>
                                        {" "}
                                        •••• {payment.paymentMethod.last4}
                                      </span>
                                    )}
                                  </div>
                                </>
                              )}
                              {payment.status === "failed" &&
                                payment.retryAttempts !== undefined &&
                                payment.maxRetryAttempts !== undefined && (
                                  <>
                                    <span aria-hidden="true">•</span>
                                    <span>
                                      Retry {payment.retryAttempts}/
                                      {payment.maxRetryAttempts}
                                    </span>
                                  </>
                                )}
                            </div>
                          </div>
                          <div className="flex shrink-0 items-center gap-3">
                            <span className="font-medium text-sm">
                              {formatPrice(
                                payment.amount,
                                payment.currency || currency
                              )}
                            </span>
                            <div className="flex gap-2">
                              {payment.status === "failed" && onRetry && (
                                <Button
                                  aria-busy={isRetrying === payment.id}
                                  aria-label={`Retry payment ${payment.id}`}
                                  data-loading={isRetrying === payment.id}
                                  onClick={() => handleRetry(payment.id)}
                                  size="icon"
                                  type="button"
                                  variant="ghost"
                                >
                                  {isRetrying === payment.id ? (
                                    <Loader2 className="size-4 animate-spin" />
                                  ) : (
                                    <TrendingUp className="size-4" />
                                  )}
                                </Button>
                              )}
                              {isUpcoming && onCancel && (
                                <Button
                                  aria-label={`Cancel payment ${payment.id}`}
                                  onClick={() => onCancel?.(payment.id)}
                                  size="icon"
                                  type="button"
                                  variant="ghost"
                                >
                                  <X className="size-4" />
                                </Button>
                              )}
                              {payment.invoiceId && onViewInvoice && (
                                <Button
                                  aria-label={`View invoice ${payment.invoiceNumber}`}
                                  onClick={() =>
                                    onViewInvoice(payment.invoiceId!)
                                  }
                                  size="icon"
                                  type="button"
                                  variant="ghost"
                                >
                                  <Calendar className="size-4" />
                                </Button>
                              )}
                            </div>
                          </div>
                        </div>
                      </div>
                    );
                  })}
                </div>
              </div>
            </>
          )}
        </div>
      </CardContent>
    </Card>
  );
}

Installation

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

Usage

import { BillingPaymentSchedule } from "@/components/ui/billing-payment-schedule"
<BillingPaymentSchedule />