From b647561f6baee7623e63041acb175634ff5b8c2d Mon Sep 17 00:00:00 2001 From: Barkley Trimsworth Date: Sun, 29 Mar 2026 20:02:35 +0000 Subject: [PATCH] fix(portal): wire Pay Now button with payment modal (GRO-261) Rebase onto current main which has API-driven BillingPayments. Adds Outstanding Balance banner with Pay Now button that opens a payment modal for selecting and paying pending invoices. Co-Authored-By: Paperclip --- .../src/portal/sections/BillingPayments.tsx | 426 ++++++++++++++---- 1 file changed, 342 insertions(+), 84 deletions(-) diff --git a/apps/web/src/portal/sections/BillingPayments.tsx b/apps/web/src/portal/sections/BillingPayments.tsx index 5ae9f81..bc110e3 100644 --- a/apps/web/src/portal/sections/BillingPayments.tsx +++ b/apps/web/src/portal/sections/BillingPayments.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { CreditCard, DollarSign, Package, Zap } from "lucide-react"; interface Invoice { id: string; @@ -31,6 +32,9 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) { const [packages, setPackages] = useState([]); 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); useEffect(() => { async function fetchData() { @@ -71,6 +75,9 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) { }).format(cents / 100); }; + const pending = invoices.filter((i) => i.status === "pending"); + const totalPending = pending.reduce((sum, i) => sum + i.totalCents, 0); + if (loading) { return (
@@ -92,98 +99,349 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) { } return ( -
-

Billing & Payments

- - {/* Payment Methods */} -
-

Payment Methods

- {paymentMethods.length === 0 ? ( -

No payment methods on file

- ) : ( -
- {paymentMethods.map((method) => ( -
-
-
- {method.brand.toUpperCase()} -
- **** {method.last4} - - {method.expiryMonth}/{method.expiryYear} - -
- {!readOnly && ( - - )} -
- ))} +
+ {/* Outstanding Balance Banner */} + {totalPending > 0 && ( +
+
+

Outstanding Balance

+

{formatCents(totalPending)}

+

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

- )} -
+ {!readOnly && ( + + )} +
+ )} - {/* Packages */} -
-

Packages

- {packages.length === 0 ? ( -

No packages purchased

- ) : ( -
- {packages.map((pkg, index) => ( -
- {pkg.name} - {pkg.remaining} remaining -
- ))} -
- )} -
+ {/* Tabs */} +
+ {([ + { 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 }) => ( + + ))} +
{/* Invoices */} -
-

Invoice History

- {invoices.length === 0 ? ( -

No invoices yet

- ) : ( -
- {invoices.map((invoice) => ( -
-
- - {invoice.description || `Invoice ${invoice.id.slice(0, 8)}`} - - {invoice.date} + {tab === "invoices" && ( +
+
+ + + + + + + + + + + + {invoices.map((inv) => ( + + + + + + + + ))} + +
DateDescriptionAmountStatus
+ {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)} + + + +
+
+
+ )} + + {/* Payment Methods */} + {tab === "payment" && ( +
+ {paymentMethods.length === 0 ? ( +

No payment methods on file

+ ) : ( +
+ {paymentMethods.map((method) => ( +
+
+
+ {method.brand.toUpperCase()} +
+ **** {method.last4} + + {method.expiryMonth}/{method.expiryYear} + +
+ {!readOnly && ( + + )}
-
- - {formatCents(invoice.totalCents)} - - - {invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)} - + ))} +
+ )} + + {/* Autopay */} +
+
+
+
+ +
+
+

Autopay

+

+ Automatically charge after each appointment +

- ))} + {!readOnly ? ( + + ) : ( + + {autopay ? "Enabled" : "Disabled"} + + )} +
- )} -
+
+ )} + + {/* Packages */} + {tab === "packages" && ( +
+ {packages.length === 0 ? ( +

No packages purchased

+ ) : ( + packages.map((pkg, index) => ( +
+
+ {pkg.name} + {pkg.remaining} remaining +
+
+ )) + )} +
+ )} + + {/* Payment Modal */} + {showPaymentModal && !readOnly && ( + setShowPaymentModal(false)} + /> + )} + + ); +} + +function PaymentModal({ + pending, + totalPending: _totalPending, + onClose, +}: { + pending: Invoice[]; + totalPending: number; + onClose: () => void; +}) { + const [selectedInvoices, setSelectedInvoices] = useState>( + new Set(pending.map((i) => i.id)) + ); + const [isProcessing, setIsProcessing] = useState(false); + const [isComplete, setIsComplete] = useState(false); + + 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 handlePay = async () => { + setIsProcessing(true); + await new Promise((resolve) => setTimeout(resolve, 1500)); + setIsProcessing(false); + setIsComplete(true); + }; + + const selectedTotal = pending + .filter((i) => selectedInvoices.has(i.id)) + .reduce((sum, i) => sum + i.totalCents, 0); + + 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)} + +
+
+ +
+ + +
+
); } -- 2.52.0