import { useState, useEffect, useRef } from "react"; import { loadStripe } from "@stripe/stripe-js"; import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js"; import { CreditCard, DollarSign, Package, Zap } from "lucide-react"; interface Invoice { id: string; status: "pending" | "paid" | "failed" | "refunded"; totalCents: number; date: string; description?: string; } interface PaymentMethod { id: string; brand: string; last4: string; expiryMonth: number; expiryYear: number; } interface BillingPaymentsProps { sessionId: string | null; readOnly: boolean; } function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) { const [invoices, setInvoices] = useState([]); const [paymentMethods, setPaymentMethods] = useState([]); const [packages] = useState<{ name: string; remaining: number }[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices"); const [autopay, setAutopay] = useState(false); const [showPaymentModal, setShowPaymentModal] = useState(false); const [publishableKey, setPublishableKey] = useState(""); useEffect(() => { async function fetchData() { if (!sessionId) { setLoading(false); return; } try { const [configRes, invoicesRes, methodsRes] = await Promise.all([ fetch("/api/portal/config", { headers: { "X-Impersonation-Session-Id": sessionId }, }), fetch("/api/portal/invoices", { headers: { "X-Impersonation-Session-Id": sessionId }, }), fetch("/api/portal/payment-methods", { headers: { "X-Impersonation-Session-Id": sessionId }, }), ]); if (!configRes.ok) throw new Error("Failed to fetch config"); const configData = await configRes.json(); setPublishableKey(configData.stripePublishableKey ?? ""); const invoicesData = await invoicesRes.json(); setInvoices(Array.isArray(invoicesData) ? invoicesData : invoicesData.invoices || []); if (methodsRes.ok) { const methodsData = await methodsRes.json(); setPaymentMethods( (methodsData ?? []).map((m: { id: string; card: { brand: string; last4: string; exp_month: number; exp_year: number } }) => ({ id: m.id, brand: m.card?.brand ?? "unknown", last4: m.card?.last4 ?? "****", expiryMonth: m.card?.exp_month ?? 0, expiryYear: m.card?.exp_year ?? 0, })) ); } } catch (err) { setError(err instanceof Error ? err.message : "An error occurred"); } finally { setLoading(false); } } fetchData(); }, [sessionId]); const formatCents = (cents: number) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100); const pending = invoices.filter((i) => i.status === "pending"); const totalPending = pending.reduce((sum, i) => sum + i.totalCents, 0); if (loading) { return (
); } if (error) { return (
Error: {error}
); } return (
{totalPending > 0 && (

Outstanding Balance

{formatCents(totalPending)}

{pending.length} unpaid invoice{pending.length > 1 ? "s" : ""}

)}
{([ { id: "invoices" as const, label: "Invoices", icon: DollarSign }, { id: "payment" as const, label: "Payment Methods", icon: CreditCard }, { id: "packages" as const, label: "Packages", icon: Package }, ]).map(({ id, label, icon: Icon }) => ( ))}
{tab === "invoices" && (
{invoices.map((inv) => ( ))}
Date Description Amount Status
{new Date(inv.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", })} {inv.description || `Invoice ${inv.id.slice(0, 8)}`} {formatCents(inv.totalCents)} {inv.status.charAt(0).toUpperCase() + inv.status.slice(1)}
)} {tab === "payment" && (
{paymentMethods.length === 0 ? (

No payment methods on file

) : (
{paymentMethods.map((method) => (
{method.brand.toUpperCase()}
**** {method.last4} {method.expiryMonth}/{method.expiryYear}
{!readOnly && ( )}
))}
)}

Autopay

Automatically charge after each appointment

{!readOnly ? ( ) : ( {autopay ? "Enabled" : "Disabled"} )}
)} {tab === "packages" && (
{packages.length === 0 ? (

No packages purchased

) : ( packages.map((pkg, index) => (
{pkg.name} {pkg.remaining} remaining
)) )}
)} {showPaymentModal && publishableKey && ( setShowPaymentModal(false)} onSuccess={() => { setInvoices((prev) => prev.map((inv) => pending.some((p) => p.id === inv.id) ? { ...inv, status: "paid" as const } : inv ) ); setShowPaymentModal(false); }} /> )}
); } interface PaymentModalWrapperProps { sessionId: string; publishableKey: string; pending: Invoice[]; onClose: () => void; onSuccess: () => void; } function PaymentModalWrapper({ sessionId, publishableKey, pending, onClose, onSuccess }: PaymentModalWrapperProps) { const [stripePromise] = useState(() => publishableKey ? loadStripe(publishableKey) : Promise.resolve(null) ); return ( s + i.totalCents, 0), currency: "usd" }}> ); } interface PaymentModalProps { sessionId: string; pending: Invoice[]; onClose: () => void; onSuccess: () => void; } function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalProps) { const stripe = useStripe(); const elements = useElements(); const [selectedInvoices, setSelectedInvoices] = useState>(new Set(pending.map((i) => i.id))); const [saveCard, setSaveCard] = useState(false); const [isProcessing, setIsProcessing] = useState(false); const [isComplete, setIsComplete] = useState(false); const [error, setError] = useState(null); const completeModalRef = useRef(null); const paymentModalRef = useRef(null); // Focus trap + Escape-to-close for both inline modals useEffect(() => { const modalRef = isComplete ? completeModalRef.current : paymentModalRef.current; if (!modalRef) return; const previouslyFocused = document.activeElement as HTMLElement; const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; const focusableElements = modalRef.querySelectorAll(focusableSelectors); const firstFocusable = focusableElements[0]; firstFocusable?.focus(); function handleKeyDown(e: KeyboardEvent) { if (e.key === "Escape") { onClose(); return; } if (e.key !== "Tab" || !modalRef) return; const focusables = modalRef.querySelectorAll(focusableSelectors); const first = focusables[0]; const last = focusables[focusables.length - 1]; if (e.shiftKey) { if (document.activeElement === first) { e.preventDefault(); last?.focus(); } } else { if (document.activeElement === last) { e.preventDefault(); first?.focus(); } } } document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("keydown", handleKeyDown); previouslyFocused?.focus(); }; }, [isComplete, onClose]); const formatCents = (cents: number) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100); const toggleInvoice = (id: string) => { const next = new Set(selectedInvoices); if (next.has(id)) next.delete(id); else next.add(id); setSelectedInvoices(next); }; const selectedTotal = pending.filter((i) => selectedInvoices.has(i.id)).reduce((sum, i) => sum + i.totalCents, 0); const handlePay = async () => { if (!stripe || !elements) return; setIsProcessing(true); setError(null); try { const isMulti = selectedInvoices.size > 1; const endpoint = isMulti ? "/api/portal/invoices/pay-multiple" : `/api/portal/invoices/${[...selectedInvoices][0]}/pay`; const body = isMulti ? { invoiceIds: [...selectedInvoices] } : {}; const res = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json", "X-Impersonation-Session-Id": sessionId, }, body: JSON.stringify(body), }); if (!res.ok) { const data = await res.json(); throw new Error(data.error ?? "Failed to initialize payment"); } const { clientSecret } = await res.json(); const { error: stripeError } = await stripe.confirmPayment({ elements, clientSecret, confirmParams: saveCard ? { setup_future_usage: "off_session" } : undefined, redirect: "if_required", }); if (stripeError) { setError(stripeError.message ?? "Payment failed"); setIsProcessing(false); return; } setIsComplete(true); onSuccess(); } catch (err) { setError(err instanceof Error ? err.message : "An unexpected error occurred"); setIsProcessing(false); } }; if (isComplete) { return (

Payment Successful

Your payment of {formatCents(selectedTotal)} has been processed. A receipt has been sent to your email.

); } return (

Pay Outstanding Balance

Select invoices to pay:

{pending.map((inv) => ( ))}
Total {formatCents(selectedTotal)}
{error && (
{error}
)}
); } export function BillingPayments(props: BillingPaymentsProps) { return ; } export default BillingPayments;