From b647561f6baee7623e63041acb175634ff5b8c2d Mon Sep 17 00:00:00 2001 From: Barkley Trimsworth Date: Sun, 29 Mar 2026 20:02:35 +0000 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 && ( Date: Mon, 30 Mar 2026 10:56:21 +0000 Subject: [PATCH 07/10] fix: show Pay Now button during impersonation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove readOnly guard from Pay Now button and PaymentModal in BillingPayments. The readOnly guard was too broad — it hid the Pay Now button during staff impersonation sessions, making it impossible for staff to collect payments. Other readOnly guards (Remove payment method, Autopay toggle) remain intact. Co-authored-by: groombook-engineer[bot] <269742240+groombook-engineer[bot]@users.noreply.github.com> Co-authored-by: Paperclip --- apps/web/src/portal/sections/BillingPayments.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/portal/sections/BillingPayments.tsx b/apps/web/src/portal/sections/BillingPayments.tsx index 015da3c..e0b3b97 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 && ( Date: Mon, 30 Mar 2026 11:08:23 +0000 Subject: [PATCH 08/10] fix(portal): add password validation and fix Report Cards retry - PasswordChange: add useState hooks for all 3 password fields, password- match validation with inline error, disabled submit when fields are empty/mismatched, wired onClick handler with TODO for API integration - ReportCards: extract fetch into loadReportCards(), replace window.location.reload() with loadReportCards() so SPA state (activeSection) is preserved on retry Refs: GRO-287 Co-Authored-By: Paperclip --- .../src/portal/sections/AccountSettings.tsx | 77 +++++++++++++++++-- apps/web/src/portal/sections/ReportCards.tsx | 44 ++++++----- 2 files changed, 95 insertions(+), 26 deletions(-) diff --git a/apps/web/src/portal/sections/AccountSettings.tsx b/apps/web/src/portal/sections/AccountSettings.tsx index 2fba3a6..973e7b5 100644 --- a/apps/web/src/portal/sections/AccountSettings.tsx +++ b/apps/web/src/portal/sections/AccountSettings.tsx @@ -142,6 +142,47 @@ function PersonalInfo({ sessionId, readOnly }: { sessionId: string | null; readO } function PasswordChange({ readOnly }: { readOnly: boolean }) { + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [validationError, setValidationError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const passwordsMatch = newPassword === confirmPassword; + const canSubmit = + !readOnly && + currentPassword.length > 0 && + newPassword.length > 0 && + confirmPassword.length > 0 && + passwordsMatch; + + const handleSubmit = async () => { + setValidationError(null); + + if (!passwordsMatch) { + setValidationError("Passwords do not match."); + return; + } + + if (newPassword.length === 0 || confirmPassword.length === 0) { + setValidationError("All fields are required."); + return; + } + + setSubmitting(true); + + // TODO: wire up to actual password-change API endpoint (e.g., POST /api/portal/password) + // const response = await fetch("/api/portal/password", { ... }); + + setTimeout(() => { + setSubmitting(false); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + // On success, clear error and show confirmation (API integration needed first) + }, 500); + }; + if (readOnly) { return (
@@ -156,18 +197,44 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
- + setCurrentPassword(e.target.value)} + className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm" + />
- + { setNewPassword(e.target.value); setValidationError(null); }} + className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm" + />
- + { setConfirmPassword(e.target.value); setValidationError(null); }} + className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm" + />
-
diff --git a/apps/web/src/portal/sections/ReportCards.tsx b/apps/web/src/portal/sections/ReportCards.tsx index a8d471b..5c8a509 100644 --- a/apps/web/src/portal/sections/ReportCards.tsx +++ b/apps/web/src/portal/sections/ReportCards.tsx @@ -30,29 +30,31 @@ export function ReportCards() { const [error, setError] = useState(null); const [selectedCard, setSelectedCard] = useState(null); - useEffect(() => { - const fetchReportCards = async () => { - try { - const response = await fetch("/api/portal/appointments"); + const loadReportCards = async () => { + try { + setError(null); + setIsLoading(true); + const response = await fetch("/api/portal/appointments"); - if (response.ok) { - const data = await response.json(); - const allAppointments: Appointment[] = data.appointments || data || []; - const reportCardAppointments = allAppointments.filter( - (appt) => appt.reportCardId - ); - setAppointments(reportCardAppointments); - } else { - setError("Failed to load report cards."); - } - } catch { - setError("Failed to load report cards. Please try again."); - } finally { - setIsLoading(false); + if (response.ok) { + const data = await response.json(); + const allAppointments: Appointment[] = data.appointments || data || []; + const reportCardAppointments = allAppointments.filter( + (appt) => appt.reportCardId + ); + setAppointments(reportCardAppointments); + } else { + setError("Failed to load report cards."); } - }; + } catch { + setError("Failed to load report cards. Please try again."); + } finally { + setIsLoading(false); + } + }; - fetchReportCards(); + useEffect(() => { + loadReportCards(); }, []); if (isLoading) { @@ -69,7 +71,7 @@ export function ReportCards() {

{error}