diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 089dde6..0cb6db1 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -183,6 +183,18 @@ export const { signIn, signOut, useSession, changePassword } = authClient; | TC-WEB-5.12.3 | Confirm appointment | Click confirm on pending appointment | Appointment status updated to confirmed | | TC-WEB-5.12.4 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled | +#### 5.12b Dynamic Portal Time Slots (GRO-1793) + +| # | Scenario | Steps | Expected | +|---|----------|-------|----------| +| TC-WEB-5.12.5 | BookingFlow dynamic slots | Open Book New, select pet and service, pick a date | Time slots fetched from API; "Checking availability…" shown while loading | +| TC-WEB-5.12.6 | BookingFlow slots match wizard | Compare BookingFlow slot times with public booking wizard for same date | Same slots displayed | +| TC-WEB-5.12.7 | BookingFlow error state | Mock API failure on availability fetch | "Failed to load time slots" error shown | +| TC-WEB-5.12.8 | BookingFlow no slots | Select date with no availability | "No available slots on this date" shown | +| TC-WEB-5.12.9 | RescheduleFlow dynamic slots | Open reschedule, pick a new date | Time slots fetched from API; loading state shown | +| TC-WEB-5.12.10 | RescheduleFlow error state | Mock API failure on availability fetch | "Failed to load time slots" error shown | +| TC-WEB-5.12.11 | RescheduleFlow no slots | Select date with no availability | "No available slots on this date" shown | + ### 5.13 Reports UI | # | Scenario | Steps | Expected | diff --git a/src/__tests__/Appointments.test.tsx b/src/__tests__/Appointments.test.tsx index bc42a07..9ba746b 100644 --- a/src/__tests__/Appointments.test.tsx +++ b/src/__tests__/Appointments.test.tsx @@ -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..13038c5 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; @@ -654,6 +664,7 @@ export function RescheduleFlow({

Pick a New Date & Time

setSelectedDate(e.target.value)} min={new Date().toISOString().split('T')[0]} @@ -661,7 +672,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) => (