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"); } diff --git a/apps/web/src/portal/CustomerPortal.tsx b/apps/web/src/portal/CustomerPortal.tsx index d8ba8bc..2a5e8e1 100644 --- a/apps/web/src/portal/CustomerPortal.tsx +++ b/apps/web/src/portal/CustomerPortal.tsx @@ -133,7 +133,7 @@ export function CustomerPortal() { case "pets": return ; case "reports": - return ; + return ; case "billing": return ; case "messages": diff --git a/apps/web/src/portal/sections/AccountSettings.tsx b/apps/web/src/portal/sections/AccountSettings.tsx index 2fba3a6..d6708b8 100644 --- a/apps/web/src/portal/sections/AccountSettings.tsx +++ b/apps/web/src/portal/sections/AccountSettings.tsx @@ -72,7 +72,9 @@ function PersonalInfo({ sessionId, readOnly }: { sessionId: string | null; readO const fetchPersonalInfo = async () => { try { setLoading(true); - const response = await fetch("/api/portal/me"); + const response = await fetch("/api/portal/me", { + headers: { "X-Impersonation-Session-Id": sessionId }, + }); if (response.ok) { const data: PersonalInfoData = await response.json(); setForm({ @@ -142,6 +144,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 +199,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" + />
-
@@ -185,7 +254,9 @@ function ManagePets({ sessionId, readOnly }: { sessionId: string | null; readOnl const fetchPets = async () => { try { setLoading(true); - const response = await fetch("/api/portal/pets"); + const response = await fetch("/api/portal/pets", { + headers: { "X-Impersonation-Session-Id": sessionId }, + }); if (response.ok) { const data = await response.json(); setPets(Array.isArray(data) ? data : []); @@ -225,7 +296,6 @@ function ManagePets({ sessionId, readOnly }: { sessionId: string | null; readOnl { setEditingPetId(null); setShowAddForm(false); }} onCancel={() => { setEditingPetId(null); setShowAddForm(false); }} /> diff --git a/apps/web/src/portal/sections/Appointments.tsx b/apps/web/src/portal/sections/Appointments.tsx index 03fcad1..0530c9d 100644 --- a/apps/web/src/portal/sections/Appointments.tsx +++ b/apps/web/src/portal/sections/Appointments.tsx @@ -118,7 +118,7 @@ export const AppointmentsSection: React.FC = ({ sessio try { const response = await fetch('/api/portal/appointments', { - headers: { Authorization: `Bearer ${sessionId}` }, + headers: { "X-Impersonation-Session-Id": sessionId ?? "" }, }); if (response.ok) { @@ -379,7 +379,7 @@ export function ConfirmationSection({ try { const headers: Record = {}; if (sessionId) { - headers['Authorization'] = `Bearer ${sessionId}`; + headers['X-Impersonation-Session-Id'] = sessionId; } const res = await fetch(`/api/portal/appointments/${appt.id}/confirm`, { method: 'POST', @@ -455,7 +455,7 @@ function CancelAppointmentButton({ try { const headers: Record = {}; if (sessionId) { - headers['Authorization'] = `Bearer ${sessionId}`; + headers['X-Impersonation-Session-Id'] = sessionId; } const res = await fetch(`/api/portal/appointments/${appt.id}/cancel`, { method: 'POST', @@ -507,7 +507,7 @@ export function CustomerNotesSection({ try { const headers: Record = { 'Content-Type': 'application/json' }; if (sessionId) { - headers['Authorization'] = `Bearer ${sessionId}`; + headers['X-Impersonation-Session-Id'] = sessionId; } const res = await fetch(`/api/portal/appointments/${appt.id}/notes`, { method: 'PATCH', @@ -600,7 +600,7 @@ export function RescheduleFlow({ setError(null); try { const headers: Record = { 'Content-Type': 'application/json' }; - if (sessionId) headers['Authorization'] = `Bearer ${sessionId}`; + if (sessionId) headers['X-Impersonation-Session-Id'] = sessionId; const res = await fetch(`/api/portal/appointments/${appt.id}/reschedule`, { method: 'POST', headers, @@ -744,10 +744,10 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) { try { const [petsRes, servicesRes] = await Promise.all([ fetch('/api/portal/pets', { - headers: { Authorization: `Bearer ${sessionId}` }, + headers: { "X-Impersonation-Session-Id": sessionId ?? "" }, }), fetch('/api/portal/services', { - headers: { Authorization: `Bearer ${sessionId}` }, + headers: { "X-Impersonation-Session-Id": sessionId ?? "" }, }), ]); @@ -784,7 +784,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${sessionId}`, + "X-Impersonation-Session-Id": sessionId ?? "", }, body: JSON.stringify({ petId: selectedPet.id, diff --git a/apps/web/src/portal/sections/BillingPayments.tsx b/apps/web/src/portal/sections/BillingPayments.tsx index 5ae9f81..81d14e2 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,347 @@ 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" : ""} +

- )} -
+ +
+ )} - {/* 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 && ( + 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)} + +
+
+ +
+ + +
+
); } diff --git a/apps/web/src/portal/sections/ReportCards.tsx b/apps/web/src/portal/sections/ReportCards.tsx index a8d471b..9e74e77 100644 --- a/apps/web/src/portal/sections/ReportCards.tsx +++ b/apps/web/src/portal/sections/ReportCards.tsx @@ -24,36 +24,49 @@ interface Appointment { reportCardId?: string; } -export function ReportCards() { +interface ReportCardsProps { + sessionId: string | null; +} + +export function ReportCards({ sessionId }: ReportCardsProps) { const [appointments, setAppointments] = useState([]); const [isLoading, setIsLoading] = useState(true); 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 () => { + if (!sessionId) { + setAppointments([]); + setIsLoading(false); + return; + } + try { + setError(null); + setIsLoading(true); + const response = await fetch("/api/portal/appointments", { + headers: { "X-Impersonation-Session-Id": sessionId }, + }); - 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(); + }, [sessionId]); if (isLoading) { return ( @@ -69,7 +82,7 @@ export function ReportCards() {

{error}