From d78c859c2bf2e094ecc9783cf5b113a6d1b2745b Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Tue, 26 May 2026 12:23:29 +0000 Subject: [PATCH] Replace hardcoded time slots with dynamic API availability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both BookingFlow and RescheduleFlow in Appointments.tsx now fetch from /api/book/availability when a date is selected, matching the public booking wizard behavior. Loading and error states shown. - Removed hardcoded availableTimes arrays from both flows - Added useEffect that fetches availability on date change - Shows "Checking availability…" while loading - Shows error message on fetch failure - Shows "No available slots" when API returns empty Added tests for RescheduleFlow dynamic slot fetching covering: loading, fetched slots, error, empty, API params, and re-fetch on date change. Co-Authored-By: Paperclip --- src/__tests__/Appointments.test.tsx | 140 ++++++++++++++++++++++++++- src/portal/sections/Appointments.tsx | 70 ++++++++++---- 2 files changed, 189 insertions(+), 21 deletions(-) diff --git a/src/__tests__/Appointments.test.tsx b/src/__tests__/Appointments.test.tsx index bc42a07..d70f3d6 100644 --- a/src/__tests__/Appointments.test.tsx +++ b/src/__tests__/Appointments.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.tsx"; const UPCOMING_APPT = { @@ -379,4 +379,142 @@ describe("ConfirmationSection", () => { expect(screen.getByText(/Confirmed!/i)).toBeInTheDocument(); }); }); +}); + +describe("RescheduleFlow dynamic time slots", () => { + beforeEach(() => { + vi.clearAllMocks(); + global.fetch = vi.fn(); + }); + + const RESCHEDULE_APPT = { + id: "appt-r1", + petId: "pet-1", + petName: "Buddy", + groomerId: "groomer-1", + groomerName: "Sarah", + services: ["Bath & Brush"], + serviceId: "service-1", + addOns: [], + date: "2027-01-01", + time: "10:00 AM", + duration: 60, + price: 50, + status: "confirmed" as const, + notes: "", + customerNotes: "", + confirmationStatus: "confirmed" as const, + }; + + it("shows loading state while fetching availability", async () => { + vi.mocked(global.fetch).mockReturnValue(new Promise(() => {})); // Never resolves + + const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx"); + render( {}} sessionId="test-session-id" />); + + const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i }); + fireEvent.change(dateInput, { target: { value: "2027-01-15" } }); + + await waitFor(() => { + expect(screen.getByText(/Checking availability/i)).toBeInTheDocument(); + }); + }); + + it("displays fetched time slots from API", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ["9:00 AM", "10:00 AM", "2:00 PM"], + } as Response); + + const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx"); + render( {}} sessionId="test-session-id" />); + + const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i }); + fireEvent.change(dateInput, { target: { value: "2027-01-15" } }); + + await waitFor(() => { + expect(screen.getByText("9:00 AM")).toBeInTheDocument(); + expect(screen.getByText("10:00 AM")).toBeInTheDocument(); + expect(screen.getByText("2:00 PM")).toBeInTheDocument(); + }); + }); + + it("shows error state when availability fetch fails", async () => { + vi.mocked(global.fetch).mockRejectedValue(new Error("Network error")); + + const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx"); + render( {}} sessionId="test-session-id" />); + + const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i }); + fireEvent.change(dateInput, { target: { value: "2027-01-15" } }); + + await waitFor(() => { + expect(screen.getByText(/Failed to load time slots/i)).toBeInTheDocument(); + }); + }); + + it("shows no slots message when API returns empty array", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => [] as string[], + } as Response); + + const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx"); + render( {}} sessionId="test-session-id" />); + + const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i }); + fireEvent.change(dateInput, { target: { value: "2027-01-15" } }); + + await waitFor(() => { + expect(screen.getByText(/No available slots on this date/i)).toBeInTheDocument(); + }); + }); + + it("calls /api/book/availability with the selected date", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ["9:00 AM"] as string[], + } as Response); + + const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx"); + render( {}} sessionId="test-session-id" />); + + const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i }); + fireEvent.change(dateInput, { target: { value: "2027-02-20" } }); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "/api/book/availability?date=2027-02-20", + expect.objectContaining({ + headers: expect.objectContaining({ "X-Impersonation-Session-Id": "test-session-id" }), + }) + ); + }); + }); + + it("re-fetches slots when date changes", async () => { + vi.mocked(global.fetch) + .mockResolvedValueOnce({ + ok: true, + json: async () => ["9:00 AM"] as string[], + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ["11:00 AM", "1:00 PM"] as string[], + } as Response); + + const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx"); + render( {}} sessionId="test-session-id" />); + + const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i }); + + fireEvent.change(dateInput, { target: { value: "2027-01-10" } }); + await waitFor(() => expect(screen.getByText("9:00 AM")).toBeInTheDocument()); + + fireEvent.change(dateInput, { target: { value: "2027-01-15" } }); + await waitFor(() => { + expect(screen.getByText("11:00 AM")).toBeInTheDocument(); + expect(screen.getByText("1:00 PM")).toBeInTheDocument(); + }); + }); }); \ No newline at end of file diff --git a/src/portal/sections/Appointments.tsx b/src/portal/sections/Appointments.tsx index f5fad62..593ca8a 100644 --- a/src/portal/sections/Appointments.tsx +++ b/src/portal/sections/Appointments.tsx @@ -573,16 +573,26 @@ export function RescheduleFlow({ const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); + const [slotsLoading, setSlotsLoading] = useState(false); + const [slotsError, setSlotsError] = useState(null); + const [availableTimes, setAvailableTimes] = useState([]); - const availableTimes = [ - '9:00 AM', - '10:00 AM', - '11:00 AM', - '1:00 PM', - '2:00 PM', - '3:00 PM', - '4:00 PM', - ]; + useEffect(() => { + if (!selectedDate || !sessionId) { + setAvailableTimes([]); + return; + } + const params = new URLSearchParams({ date: selectedDate }); + setSlotsLoading(true); + setSlotsError(null); + fetch(`/api/book/availability?${params.toString()}`, { + headers: { "X-Impersonation-Session-Id": sessionId ?? "" }, + }) + .then((r) => r.json() as Promise) + .then(setAvailableTimes) + .catch(() => setSlotsError('Failed to load time slots')) + .finally(() => setSlotsLoading(false)); + }, [selectedDate, sessionId]); async function handleSubmit() { if (!selectedDate || !selectedTime) return; @@ -661,7 +671,12 @@ export function RescheduleFlow({ /> {selectedDate && (
- {availableTimes.map((time) => ( + {slotsLoading &&

Checking availability…

} + {!slotsLoading && slotsError &&

{slotsError}

} + {!slotsLoading && availableTimes.length === 0 && !slotsError && ( +

No available slots on this date.

+ )} + {!slotsLoading && availableTimes.map((time) => (