From b647561f6baee7623e63041acb175634ff5b8c2d Mon Sep 17 00:00:00 2001 From: Barkley Trimsworth Date: Sun, 29 Mar 2026 20:02:35 +0000 Subject: [PATCH 1/6] 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 From 6cd2ea6ca9853ee98d57d09c386572b96d877510 Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:24:56 +0000 Subject: [PATCH 2/6] fix(portal): wire Pay Now button with payment modal (GRO-261) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes GRO-261 — Pay Now button on Billing page now opens a payment modal with invoice selection and simulated payment flow. 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 From 753080ecc4df6582adfe6edfea2c95695b9605c7 Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:58:02 +0000 Subject: [PATCH 3/6] fix: show login page before needsSetup guard for unauthenticated users (#166) cc @cpfarhood Unauthenticated users saw a blank screen because the needsSetup null-guard fired before the LoginPage render check. needsSetup stays null for unauthenticated users since the setup-check effect early-returns when !session. Now the login check runs first so users see the login page. Co-authored-by: Flea Flicker Co-authored-by: Paperclip Co-authored-by: Scrubs McBarkley (CEO) --- apps/web/src/App.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0a5afa1..a8f7b2a 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -249,14 +249,14 @@ export function App() { return ; } - // Production: need setup check - if (needsSetup === null) return null; - - // Production mode: if no session, redirect to Authentik sign-in + // Show login BEFORE checking needsSetup (needsSetup is never set for unauthenticated users) if (!authDisabled && !session) { return ; } + // Production: need setup check + if (needsSetup === null) return null; + // Redirect to setup wizard if needed if (needsSetup) { return ; -- 2.52.0 From f4197c7993fb9f9b2f02cbc2d5ed83fae50e4cb5 Mon Sep 17 00:00:00 2001 From: Barkley Trimsworth Date: Mon, 30 Mar 2026 01:16:48 +0000 Subject: [PATCH 4/6] fix(web): prevent redirect loop on Continue as default dev user GRO-264: The "Continue as default dev user" button on /login clears dev-user from localStorage then navigates to /admin, but App.tsx's auth guard immediately redirects back to /login because getDevUser() is null. Fix by setting a dev-login-skipped flag that App.tsx checks to allow through-navigation after explicit skip. Co-Authored-By: Paperclip --- apps/web/src/App.tsx | 3 ++- apps/web/src/pages/DevLoginSelector.tsx | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0a5afa1..64a20c5 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -245,7 +245,8 @@ export function App() { } // Dev mode: use dev login selector (no setup check needed in dev mode) - if (authDisabled && !getDevUser()) { + // Skip redirect if user explicitly chose "continue as default dev user" (dev-login-skipped flag) + if (authDisabled && !getDevUser() && !localStorage.getItem("dev-login-skipped")) { return ; } diff --git a/apps/web/src/pages/DevLoginSelector.tsx b/apps/web/src/pages/DevLoginSelector.tsx index 6de753b..7b39557 100644 --- a/apps/web/src/pages/DevLoginSelector.tsx +++ b/apps/web/src/pages/DevLoginSelector.tsx @@ -39,6 +39,7 @@ export function DevLoginSelector() { function skipLogin() { localStorage.removeItem("dev-user"); + localStorage.setItem("dev-login-skipped", "1"); navigate("/admin"); } -- 2.52.0 From 73ce16ee74300fb2b38751dfb6f0389c5af14dd3 Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:54:11 +0000 Subject: [PATCH 5/6] fix: billing portal session header and response format mismatch (#168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes GRO-261 — billing portal session header mismatch and response format bug. - x-session-id → X-Impersonation-Session-Id in BillingPayments.tsx and Dashboard.tsx - Handle bare array response from /api/portal/invoices Co-Authored-By: Paperclip --- apps/web/src/portal/sections/BillingPayments.tsx | 4 ++-- apps/web/src/portal/sections/Dashboard.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/portal/sections/BillingPayments.tsx b/apps/web/src/portal/sections/BillingPayments.tsx index bc110e3..015da3c 100644 --- a/apps/web/src/portal/sections/BillingPayments.tsx +++ b/apps/web/src/portal/sections/BillingPayments.tsx @@ -46,7 +46,7 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) { try { const response = await fetch("/api/portal/invoices", { headers: { - "x-session-id": sessionId, + "X-Impersonation-Session-Id": sessionId, }, }); @@ -55,7 +55,7 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) { } const data = await response.json(); - setInvoices(data.invoices || []); + setInvoices(Array.isArray(data) ? data : data.invoices || []); setPaymentMethods(data.paymentMethods || []); setPackages(data.packages || []); } catch (err) { diff --git a/apps/web/src/portal/sections/Dashboard.tsx b/apps/web/src/portal/sections/Dashboard.tsx index 5820365..43abe5c 100644 --- a/apps/web/src/portal/sections/Dashboard.tsx +++ b/apps/web/src/portal/sections/Dashboard.tsx @@ -92,7 +92,7 @@ export function Dashboard({ try { const headers = { - "x-session-id": sessionId, + "X-Impersonation-Session-Id": sessionId, }; const [appointmentsRes, petsRes, invoicesRes, brandingRes] = await Promise.all([ -- 2.52.0 From 317915a10daae56f28c8d6569b0edc9ab90e9e35 Mon Sep 17 00:00:00 2001 From: Barkley Trimsworth Date: Mon, 30 Mar 2026 03:23:45 +0000 Subject: [PATCH 6/6] fix(gro-279): show Pay Now button during staff impersonation Remove !readOnly guard from Pay Now button and PaymentModal in BillingPayments.tsx. The readOnly guard was too broad, hiding the Pay Now button during all impersonation sessions even though impersonation is the only way staff can access a client's billing page. Destructive actions (remove payment method, autopay toggle) still respect the readOnly flag. Co-Authored-By: Paperclip --- apps/web/src/portal/sections/BillingPayments.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/web/src/portal/sections/BillingPayments.tsx b/apps/web/src/portal/sections/BillingPayments.tsx index bc110e3..81d14e2 100644 --- a/apps/web/src/portal/sections/BillingPayments.tsx +++ b/apps/web/src/portal/sections/BillingPayments.tsx @@ -110,14 +110,12 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) { {pending.length} unpaid invoice{pending.length > 1 ? "s" : ""}

- {!readOnly && ( - - )} )} @@ -293,7 +291,7 @@ export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) { )} {/* Payment Modal */} - {showPaymentModal && !readOnly && ( + {showPaymentModal && (