import { useState, useCallback, useEffect, useRef } from "react"; import { useSearchParams, Navigate } from "react-router-dom"; import { Home, Calendar, PawPrint, FileText, CreditCard, MessageSquare, Settings, LogOut, Shield, } from "lucide-react"; import { Dashboard } from "./sections/Dashboard.js"; import { AppointmentsSection, RescheduleFlow } from "./sections/Appointments.js"; import { PetProfiles } from "./sections/PetProfiles.js"; import { ReportCards } from "./sections/ReportCards.js"; import { BillingPayments } from "./sections/BillingPayments.js"; import { Communication } from "./sections/Communication.js"; import { AccountSettings } from "./sections/AccountSettings.js"; import { ImpersonationBanner } from "./ImpersonationBanner.js"; import { AuditLogViewer } from "./AuditLogViewer.js"; import { useBranding } from "../BrandingContext.js"; import { getDevUser } from "../pages/DevLoginSelector.js"; import type { ImpersonationSession } from "@groombook/types"; type Section = "dashboard" | "appointments" | "pets" | "reports" | "billing" | "messages" | "settings"; const NAV_ITEMS: { id: Section; label: string; icon: typeof Home }[] = [ { id: "dashboard", label: "Home", icon: Home }, { id: "appointments", label: "Appointments", icon: Calendar }, { id: "pets", label: "My Pets", icon: PawPrint }, { id: "reports", label: "Report Cards", icon: FileText }, { id: "billing", label: "Billing", icon: CreditCard }, { id: "messages", label: "Messages", icon: MessageSquare }, { id: "settings", label: "Settings", icon: Settings }, ]; export function CustomerPortal() { const [activeSection, setActiveSection] = useState
("dashboard"); const [mobileNavOpen, setMobileNavOpen] = useState(false); const [showAuditLog, setShowAuditLog] = useState(false); const [showReschedule, setShowReschedule] = useState(false); const [rescheduleAppointment, setRescheduleAppointment] = useState | null>(null); const [session, setSession] = useState(null); const [sessionExtended, setSessionExtended] = useState(false); const [clientName, setClientName] = useState(""); const [initComplete, setInitComplete] = useState(false); // Track whether an impersonation session fetch from URL param is in-flight // Dashboard will not redirect while this is true, allowing the session to load const [isImpersonating, setIsImpersonating] = useState(false); const { branding } = useBranding(); const [searchParams, setSearchParams] = useSearchParams(); // On mount: load session from ?sessionId= URL param OR from dev user in localStorage const initDone = useRef(false); useEffect(() => { if (initDone.current) return; initDone.current = true; const sessionId = searchParams.get("sessionId"); if (sessionId) { setIsImpersonating(true); // Real impersonation session from URL param fetch(`/api/impersonation/sessions/${sessionId}`) .then((r) => { if (!r.ok) return null; return r.json() as Promise; }) .then((s) => { if (s && s.status === "active") { setSession(s); fetch(`/api/portal/me`, { headers: { "X-Impersonation-Session-Id": s.id } }) .then(r => r.ok ? r.json() : null) .then(data => { if (data?.name) setClientName(data.name); }) .catch(() => {}); } setSearchParams({}, { replace: true }); }) .catch(() => { setSearchParams({}, { replace: true }); }) .finally(() => { setInitComplete(true); setIsImpersonating(false); }); return; } // Dev mode: check for dev user in localStorage and create a dev session const devUser = getDevUser(); if (devUser && devUser.type === "client") { fetch("/api/portal/dev-session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clientId: devUser.id }), }) .then((r) => { if (!r.ok) return null; return r.json() as Promise; }) .then((s) => { if (s && s.id) { setSession(s); setClientName(devUser.name); } }) .finally(() => setInitComplete(true)); } else { // No valid session: staff dev users and unauthenticated users fall through here setInitComplete(true); } }, []); const handleEnd = useCallback(async () => { if (!session) return; try { await fetch(`/api/impersonation/sessions/${session.id}/end`, { method: "POST" }); } catch { // Ignore — session ends on the client regardless } setSession(null); setSessionExtended(false); window.location.href = "/admin/clients"; }, [session]); const handleExtend = useCallback(async () => { if (!session) return; try { const r = await fetch(`/api/impersonation/sessions/${session.id}/extend`, { method: "POST" }); if (r.ok) { const updated = await r.json() as ImpersonationSession; setSession(updated); setSessionExtended(true); } } catch { // Best-effort } }, [session]); const logPageView = useCallback((page: string) => { if (!session) return; void fetch(`/api/impersonation/sessions/${session.id}/log`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "page_view", pageVisited: page }), }); }, [session]); const handleNavClick = (section: Section) => { setActiveSection(section); setMobileNavOpen(false); if (session?.status === "active") { logPageView(section); } }; const handleReschedule = useCallback((appointmentId: string) => { // Look up the full appointment from Dashboard's displayed data // The appointment was already fetched by Dashboard, so we use the ID to find it setRescheduleAppointment({ id: appointmentId } as Record); setShowReschedule(true); }, []); const isReadOnly = session?.status === "active"; const renderSection = () => { const sessionId = session?.id ?? null; switch (activeSection) { case "dashboard": return ; case "appointments": return ; case "pets": return ; case "reports": return ; case "billing": return ; case "messages": return ; case "settings": return ; } }; const avatarInitials = (clientName.split(" ")[0] || "G").charAt(0).toUpperCase(); // After init completes, redirect unauthenticated users to /login and staff to /admin. // The portal chrome must NEVER be visible to users without a valid client session. // For client dev users, we stay on the portal even if session is null — the dev-session // response may not have id set immediately, or there may be timing issues with the // session state. Dev users are verified via localStorage and the dev-session flow. if (initComplete && !session) { const devUser = getDevUser(); if (devUser && devUser.type === "staff") { return ; } if (devUser && devUser.type === "client") { // Don't redirect — dev session creation may have failed or session.id may be null // The portal should still render for client dev users } else { return ; } } return (
{session?.status === "active" && ( <> { void handleEnd(); }} onExtend={() => { void handleExtend(); }} onShowAudit={() => setShowAuditLog(true)} /> {/* Watermark */}
STAFF VIEW
)} {showAuditLog && session && ( setShowAuditLog(false)} /> )} {showReschedule && rescheduleAppointment && ( { setShowReschedule(false); setRescheduleAppointment(null); }} sessionId={session?.id ?? null} /> )} {/* Mobile Header */}
{branding.businessName}
{avatarInitials}
{/* Sidebar Navigation */} {/* Mobile nav overlay */} {mobileNavOpen && (
setMobileNavOpen(false)} /> )} {/* Main Content */}

{NAV_ITEMS.find(n => n.id === activeSection)?.label}

Hi, {clientName.split(" ")[0] || "Guest"}
{avatarInitials}
{renderSection()}
); }