Billing Payment Schedule
Billing Payment Schedule is a React and Tailwind CSS component from HextaUI, licensed MIT. Copy it and paste it into your project.
HextaUI
MIT
other
What it is
Billing Payment Schedule is a React and Tailwind CSS component from HextaUI, licensed MIT. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Five free copies a month. No card.
No packages beyond the project baseline
Licence
This component comes from HextaUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.
Billing Payment Schedule
"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>
);
}