import React, { useState, useEffect } from 'react'; import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react'; import { ANALYTICS_EVENTS, fireAnalyticsEvent } from '../../lib/analytics'; // ─── Availability fetch helper ─────────────────────────────────────────────── // Returns ISO startTime strings for the given service/date, or an error message. // Validates HTTP status and that the body is actually an array — the API // responds with `{error: "..."}` on 4xx, and we must not treat that as slots. const AVAILABILITY_ERROR_MESSAGE = 'Failed to load time slots'; /** * Re-mint an SSO-bridge portal session from the active Better Auth session. * Defense-in-depth for GRO-2234: if a portal call returns 401 mid-flow (the * impersonation session lapsed during a slow wizard), the customer's Better * Auth cookie is still valid, so we can transparently obtain a fresh portal * session id and retry once. Returns the new session id, or null if no Better * Auth session is available (e.g. staff/dev impersonation paths). */ async function remintPortalSession(): Promise { try { const res = await fetch('/api/portal/session-from-auth', { method: 'POST', credentials: 'include', }); if (!res.ok) return null; const data = (await res.json().catch(() => ({}))) as { sessionId?: string }; return data.sessionId ?? null; } catch { return null; } } async function fetchAvailability( params: { serviceId: string; date: string }, sessionId: string | null, ): Promise<{ times: string[]; error: string | null }> { const url = `/api/book/availability?${new URLSearchParams(params).toString()}`; const headers: Record = {}; if (sessionId) headers['X-Impersonation-Session-Id'] = sessionId; try { const res = await fetch(url, { headers }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string }; return { times: [], error: body.error ?? `${AVAILABILITY_ERROR_MESSAGE} (HTTP ${res.status})` }; } const data: unknown = await res.json(); if (!Array.isArray(data)) { return { times: [], error: AVAILABILITY_ERROR_MESSAGE }; } return { times: data as string[], error: null }; } catch { return { times: [], error: AVAILABILITY_ERROR_MESSAGE }; } } export interface Appointment { id: string; petId: string; serviceId: string; groomerId: string | null; // Absolute ISO instants as returned by `/api/portal/appointments`. `date`/`time` // below are the locally-formatted display strings derived from `startTime`. startTime?: string; endTime?: string; date: string; time: string; status: 'scheduled' | 'confirmed' | 'pending' | 'waitlisted' | 'completed' | 'cancelled' | 'no-show'; petName?: string; serviceName?: string; groomerName?: string; duration?: number; price?: number; notes?: string; customerNotes?: string; addOns?: string[]; confirmationStatus?: 'confirmed' | 'pending' | 'cancelled'; } interface Pet { id: string; name: string; breed: string; weight?: number; photo?: string; imageUrl?: string; } interface Service { id: string; name: string; description?: string; duration?: number; price?: number; priceRange?: string; isAddOn?: boolean; } interface AppointmentsSectionProps { sessionId: string | null; readOnly: boolean; } interface RescheduleFlowProps { appointment: Appointment; onClose: () => void; sessionId: string | null; } const MAX_CUSTOMER_NOTES = 500; export function formatDate(dateStr: string): string { return new Date(dateStr).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', }); } export function parseTimeTo24Hour(time: string | null | undefined): string { const parts = (time ?? '').split(' '); const hoursMinutes = parts[0] ?? ''; const period = parts[1] ?? ''; const [hoursStr, minutesStr] = hoursMinutes.split(':'); // `|| '0'` (not `?? '0'`) so empty strings from blank/undefined input // fall back to 0 rather than parsing to NaN. const hours = parseInt(hoursStr || '0', 10); const minutes = parseInt(minutesStr || '0', 10); let hours24 = hours; if (period === 'PM' && hours !== 12) hours24 += 12; if (period === 'AM' && hours === 12) hours24 = 0; return `${hours24.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`; } // A booking slot is the canonical UTC ISO instant returned by // `/api/book/availability` (e.g. "2026-06-09T10:00:00.000Z" is the 10:00 UTC // business slot — see api `src/lib/slots.ts`, which builds them with // `setUTCHours`). Display label and submit payload both derive from the slot via // these helpers so they never desync. Always format/extract in UTC: slots are // generated as UTC business hours, so a browser-local conversion would mislabel // the slot and diverge from the stored Postgres `time` column. export function formatSlotLabel(slot: string): string { const d = new Date(slot); // Non-ISO input (e.g. an already-formatted "10:00 AM" label) — show as-is. if (Number.isNaN(d.getTime())) return slot; return new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit', hour12: true, timeZone: 'UTC', }).format(d); } // Extracts the UTC `HH:MM:SS` time component the api stores in the Postgres // `time` column. The api inserts this verbatim, so a full ISO datetime here is // an `invalid input syntax for type time` 500 (GRO-2211). export function slotToTime(slot: string): string { if (/^\d{2}:\d{2}:\d{2}$/.test(slot)) return slot; // already HH:MM:SS const d = new Date(slot); if (!Number.isNaN(d.getTime())) { const hh = String(d.getUTCHours()).padStart(2, '0'); const mm = String(d.getUTCMinutes()).padStart(2, '0'); const ss = String(d.getUTCSeconds()).padStart(2, '0'); return `${hh}:${mm}:${ss}`; } // "10:00 AM"-style label fallback. return parseTimeTo24Hour(slot); } export function isUpcoming(appt: Appointment): boolean { const now = new Date(); // Prefer the absolute ISO `startTime` from the API; fall back to the // locally-formatted date/time pair for already-normalized/legacy shapes. const apptDate = appt.startTime ? new Date(appt.startTime) : new Date(`${appt.date}T${parseTimeTo24Hour(appt.time)}`); return apptDate > now && appt.status !== 'cancelled' && appt.status !== 'completed'; } // ─── API → UI shape normalization ──────────────────────────────────────────── // `/api/portal/appointments` returns ISO `startTime`/`endTime` plus nested // pet/service/staff objects, NOT the flat `date`/`time`/`petName` shape the // portal UI renders. Every field below is optional so the legacy flat shape // (used by tests/fixtures) is tolerated unchanged. export interface RawApiAppointment { id: string; startTime?: string | null; endTime?: string | null; status: Appointment['status']; confirmationStatus?: Appointment['confirmationStatus']; customerNotes?: string | null; notes?: string | null; pet?: { id?: string | null; name?: string | null; photo?: string | null } | null; service?: { id?: string | null; name?: string | null } | null; staff?: { id?: string | null; name?: string | null } | null; // Legacy / already-flat fields petId?: string; serviceId?: string; groomerId?: string | null; date?: string; time?: string; petName?: string; serviceName?: string; groomerName?: string; duration?: number; price?: number; addOns?: string[]; } function toLocalDateString(d: Date): string { const year = d.getFullYear(); const month = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } function toLocalTimeString(d: Date): string { return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); } // Maps a raw API appointment into the flat `Appointment` shape the portal // renders. Derives display `date`/`time` from the absolute `startTime` and // `duration` from the start/end delta. Tolerates the legacy flat shape. export function normalizeAppointment(raw: RawApiAppointment): Appointment { const start = raw.startTime ? new Date(raw.startTime) : null; const end = raw.endTime ? new Date(raw.endTime) : null; const derivedDuration = start && end ? Math.round((end.getTime() - start.getTime()) / 60000) : undefined; return { id: raw.id, petId: raw.pet?.id ?? raw.petId ?? '', serviceId: raw.service?.id ?? raw.serviceId ?? '', groomerId: raw.staff?.id ?? raw.groomerId ?? null, startTime: raw.startTime ?? undefined, endTime: raw.endTime ?? undefined, date: start ? toLocalDateString(start) : raw.date ?? '', time: start ? toLocalTimeString(start) : raw.time ?? '', status: raw.status, petName: raw.pet?.name ?? raw.petName, serviceName: raw.service?.name ?? raw.serviceName, groomerName: raw.staff?.name ?? raw.groomerName, duration: raw.duration ?? derivedDuration, price: raw.price, notes: raw.notes ?? undefined, customerNotes: raw.customerNotes ?? undefined, addOns: raw.addOns, confirmationStatus: raw.confirmationStatus, }; } // Raw service shape from `GET /api/portal/services`, which projects the // canonical DB columns (`basePriceCents`, `durationMinutes`). Also tolerates an // already-normalized payload so either shape renders correctly. interface RawApiService { id: string; name: string; description?: string | null; basePriceCents?: number | null; durationMinutes?: number | null; price?: number | null; duration?: number | null; priceRange?: string | null; isAddOn?: boolean | null; } // Normalizes a raw API service into the flat `Service` shape the cards render: // price as dollars (from `basePriceCents`) and duration in minutes (from // `durationMinutes`). Leaves fields undefined when genuinely absent so the card // can hide them rather than print `$undefined` / empty `min`. export function normalizeService(raw: RawApiService): Service { const price = raw.price ?? (typeof raw.basePriceCents === 'number' ? raw.basePriceCents / 100 : undefined); const duration = raw.duration ?? raw.durationMinutes ?? undefined; return { id: raw.id, name: raw.name, description: raw.description ?? undefined, duration: duration ?? undefined, price: price ?? undefined, priceRange: raw.priceRange ?? undefined, isAddOn: raw.isAddOn ?? undefined, }; } // Renders a service price for display, preferring an explicit `priceRange` // string, then a numeric dollar `price` (integers without trailing zeros, e.g. // `$45`; fractional values to cents, e.g. `$45.50`). Returns null when neither // is available so the caller can omit the price line entirely. export function formatServicePrice(svc: Pick): string | null { if (svc.priceRange) return svc.priceRange; if (typeof svc.price === 'number' && Number.isFinite(svc.price)) { return `$${Number.isInteger(svc.price) ? svc.price : svc.price.toFixed(2)}`; } return null; } const STATUS_COLORS: Record = { confirmed: 'bg-green-100 text-green-700', pending: 'bg-amber-100 text-amber-600', waitlisted: 'bg-blue-100 text-blue-600', completed: 'bg-stone-100 text-stone-600', cancelled: 'bg-red-100 text-red-600', 'no-show': 'bg-yellow-100 text-yellow-700', scheduled: 'bg-blue-100 text-blue-600', }; const STATUS_LABELS: Record = { confirmed: 'Confirmed', pending: 'Pending', waitlisted: 'Waitlisted', completed: 'Completed', cancelled: 'Cancelled', 'no-show': 'No-show', scheduled: 'Scheduled', }; // The DB `appointment_status` enum stores `no_show` (underscore), but the badge // palette is keyed on `no-show` (hyphen). Without normalization a no-show // appointment renders as a raw gray `no_show` label instead of the styled // "No-show" badge (GRO-2319 item 1). Map underscore status keys to the hyphen // palette key so DB-sourced statuses resolve to their intended badge style. export function normalizeStatusKey(status: string): string { return status.replace(/_/g, '-'); } export function StatusBadge({ status }: { status: string }) { const key = normalizeStatusKey(status); const label = STATUS_LABELS[key] ?? status; const colorClass = STATUS_COLORS[key] ?? 'bg-stone-100 text-stone-600'; return ( {label} ); } // Derives the badge state shown on an Upcoming/Past card from the appointment's // raw status plus its confirmationStatus (GRO-2319 item 2, CMPO-approved): // - a synthetic waitlist entry (status `waitlisted`) always shows Waitlisted // - an upcoming appointment the groomer has not yet confirmed // (`confirmationStatus === 'pending'`) shows Pending — semantically honest // and reduces anxiety-driven follow-up messages // - otherwise the raw status drives the badge export function deriveDisplayStatus(appt: Appointment): string { if (appt.status === 'waitlisted') return 'waitlisted'; if (isUpcoming(appt) && appt.confirmationStatus === 'pending') return 'pending'; return appt.status; } const CONFIRMATION_STATUS_COLORS: Record = { confirmed: 'bg-green-100 text-green-700', pending: 'bg-amber-100 text-amber-700', cancelled: 'bg-red-100 text-red-600', }; export const AppointmentsSection: React.FC = ({ sessionId, readOnly }) => { const [upcomingAppointments, setUpcomingAppointments] = useState([]); const [pastAppointments, setPastAppointments] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [showBooking, setShowBooking] = useState(false); const [showReschedule, setShowReschedule] = useState(false); const [rescheduleAppointment, setRescheduleAppointment] = useState(null); const [expandedId, setExpandedId] = useState(null); const [tab, setTab] = useState<'upcoming' | 'past'>('upcoming'); useEffect(() => { const fetchAppointments = async () => { if (!sessionId) { setUpcomingAppointments([]); setPastAppointments([]); setIsLoading(false); return; } try { const response = await fetch('/api/portal/appointments', { headers: { "X-Impersonation-Session-Id": sessionId ?? "" }, }); if (response.ok) { const data = await response.json(); const rawAppointments: RawApiAppointment[] = data.appointments || data || []; const fetchedAppointments: Appointment[] = rawAppointments.map(normalizeAppointment); const upcoming = fetchedAppointments.filter((appt) => isUpcoming(appt)); const past = fetchedAppointments.filter((appt) => !isUpcoming(appt)); setUpcomingAppointments(upcoming); setPastAppointments(past); } else { setError('Failed to load appointments.'); } } catch { setError('Failed to load appointments. Please try again.'); } finally { setIsLoading(false); } }; fetchAppointments(); }, [sessionId]); const handleReschedule = (appointment: Appointment) => { setRescheduleAppointment(appointment); setShowReschedule(true); }; if (isLoading) { return (
Loading appointments...
); } if (error) { return (

{error}

); } return (
{!readOnly && ( )}
{tab === 'upcoming' && (
{upcomingAppointments.map((appt) => ( setExpandedId(expandedId === appt.id ? null : appt.id)} readOnly={readOnly} sessionId={sessionId} onReschedule={handleReschedule} /> ))} {upcomingAppointments.length === 0 && (

No upcoming appointments

)}
)} {tab === 'past' && (
{pastAppointments.map((appt) => ( setExpandedId(expandedId === appt.id ? null : appt.id)} readOnly={readOnly} sessionId={sessionId} onReschedule={handleReschedule} /> ))}
)} {showBooking && ( setShowBooking(false)} sessionId={sessionId} /> )} {showReschedule && rescheduleAppointment && ( { setShowReschedule(false); setRescheduleAppointment(null); }} sessionId={sessionId} /> )}
); }; function AppointmentCard({ appointment: appt, expanded, onToggle, readOnly, sessionId, onReschedule, }: { appointment: Appointment; expanded: boolean; onToggle: () => void; readOnly: boolean; sessionId: string | null; onReschedule: (appt: Appointment) => void; }) { // A waitlist-backed entry (GRO-2319 item 2, CMPO UX spec GRO-2328) is not a // confirmed appointment: it gets a muted, dashed-border card and a subtext // line so the customer can tell it apart from booked appointments, and the // appointment-only actions (confirm / notes / reschedule / cancel) are hidden. const isWaitlist = appt.status === 'waitlisted'; return (
{expanded && (
{appt.duration && (

Duration

{appt.duration} min

)} {appt.price && (

Estimated Price

${appt.price}

)} {appt.addOns && appt.addOns.length > 0 && (

Add-ons

{appt.addOns.join(', ')}

)}
{appt.notes && (

{appt.notes}

)} {!isWaitlist && isUpcoming(appt) && !readOnly && ( )} {!isWaitlist && isUpcoming(appt) && ( )} {!isWaitlist && appt.status !== 'completed' && appt.status !== 'cancelled' && !readOnly && (
)}
)}
); } export function ConfirmationSection({ appointment: appt, sessionId, }: { appointment: Appointment; sessionId: string | null; }) { const [confirming, setConfirming] = useState(false); const [confirmError, setConfirmError] = useState(null); const [confirmSuccess, setConfirmSuccess] = useState(false); const [localStatus, setLocalStatus] = useState(appt.confirmationStatus); async function handleConfirm() { if (!window.confirm('Confirm this appointment?')) return; setConfirming(true); setConfirmError(null); try { const headers: Record = {}; if (sessionId) { headers['X-Impersonation-Session-Id'] = sessionId ?? ''; } const res = await fetch(`/api/portal/appointments/${appt.id}/confirm`, { method: 'POST', headers, }); if (!res.ok) { const err = await res.json().catch(() => ({ error: 'Failed to confirm' })); throw new Error(err.error || `HTTP ${res.status}`); } setLocalStatus('confirmed'); setConfirmSuccess(true); setTimeout(() => setConfirmSuccess(false), 2000); } catch (e) { setConfirmError(e instanceof Error ? e.message : 'Failed to confirm'); } finally { setConfirming(false); } } const currentStatus = localStatus ?? appt.confirmationStatus; const statusLabel = currentStatus === 'confirmed' ? 'Confirmed' : currentStatus === 'pending' ? 'Pending confirmation' : 'Cancelled'; return (
{statusLabel}
{!confirmSuccess && currentStatus === 'pending' && ( )} {confirmSuccess && ( Confirmed! )}
{confirmError &&

{confirmError}

}
); } function CancelAppointmentButton({ appointment: appt, sessionId, }: { appointment: Appointment; sessionId: string | null; }) { const [cancelling, setCancelling] = useState(false); const [cancelError, setCancelError] = useState(null); async function handleCancel() { if (!window.confirm('Cancel this appointment? This cannot be undone.')) return; setCancelling(true); setCancelError(null); try { const headers: Record = {}; if (sessionId) { headers['X-Impersonation-Session-Id'] = sessionId ?? ''; } const res = await fetch(`/api/portal/appointments/${appt.id}/cancel`, { method: 'POST', headers, }); if (!res.ok) { const err = await res.json().catch(() => ({ error: 'Failed to cancel' })); throw new Error(err.error || `HTTP ${res.status}`); } window.location.reload(); } catch (e) { setCancelError(e instanceof Error ? e.message : 'Failed to cancel'); setCancelling(false); } } return ( <> {cancelError &&

{cancelError}

} ); } export function CustomerNotesSection({ appointment: appt, sessionId, }: { appointment: Appointment; sessionId: string | null; }) { const [notes, setNotes] = useState(appt.customerNotes || ''); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); const [error, setError] = useState(null); const isDisabled = appt.status === 'completed' || appt.status === 'cancelled'; async function handleSave() { setSaving(true); setError(null); setSaved(false); try { const headers: Record = { 'Content-Type': 'application/json' }; if (sessionId) { headers['X-Impersonation-Session-Id'] = sessionId ?? ''; } const res = await fetch(`/api/portal/appointments/${appt.id}/notes`, { method: 'PATCH', headers, body: JSON.stringify({ customerNotes: notes }), }); if (!res.ok) { const err = await res.json().catch(() => ({ error: 'Failed to save' })); throw new Error(err.error || `HTTP ${res.status}`); } setSaved(true); setTimeout(() => setSaved(false), 2000); } catch (e) { setError(e instanceof Error ? e.message : 'Failed to save'); } finally { setSaving(false); } } return (
MAX_CUSTOMER_NOTES ? 'text-red-500' : 'text-stone-400' }`} > {notes.length}/{MAX_CUSTOMER_NOTES}