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 1/4] 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 2/4] 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 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 3/4] 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 1e40f0622e073bcd04d446b0f334b15e29008b38 Mon Sep 17 00:00:00 2001 From: Barkley Trimsworth Date: Mon, 30 Mar 2026 01:16:48 +0000 Subject: [PATCH 4/4] 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