From 6d21cecfcd463da7cdff6707b127f06394ddeadc Mon Sep 17 00:00:00 2001 From: FairBid Date: Tue, 25 Aug 2026 16:04:08 +0100 Subject: [PATCH] feat(appointments): add double-slot conflict handling #873 --- src/components/Appointments/BookingModal.tsx | 54 ++++++++- .../__tests__/BookingModal.test.tsx | 106 ++++++++++++++++++ 2 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 src/components/Appointments/__tests__/BookingModal.test.tsx diff --git a/src/components/Appointments/BookingModal.tsx b/src/components/Appointments/BookingModal.tsx index 7700197f..09550197 100644 --- a/src/components/Appointments/BookingModal.tsx +++ b/src/components/Appointments/BookingModal.tsx @@ -77,6 +77,17 @@ export default function BookingModal({ onClose, initialAppointmentType }: Bookin const [errors, setErrors] = useState>({}); const [isSubmitting, setIsSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); + const [conflictSlots, setConflictSlots] = useState([]); + + const dynamicTimeOptions = React.useMemo(() => { + const options = [...TIME_OPTIONS]; + conflictSlots.forEach(slot => { + if (!options.find(opt => opt.value === slot)) { + options.push({ value: slot, label: slot }); + } + }); + return options.sort((a, b) => a.value.localeCompare(b.value)); + }, [conflictSlots]); const validate = () => { const next: Record = {}; @@ -109,9 +120,15 @@ export default function BookingModal({ onClose, initialAppointmentType }: Bookin trigger('success'); onClose(); } catch (err) { - const apiErr = err as { response?: { data?: { message?: string } }; message?: string }; - const errorMessage = apiErr.response?.data?.message || apiErr.message || 'Booking failed, please try again'; - setSubmitError(errorMessage); + const apiErr = err as { response?: { status?: number; data?: { message?: string; availableSlots?: string[] } }; message?: string }; + if (apiErr.response?.status === 409 && apiErr.response.data?.availableSlots) { + setConflictSlots(apiErr.response.data.availableSlots); + setSubmitError(apiErr.response.data.message || 'The selected time slot is no longer available.'); + } else { + const errorMessage = apiErr.response?.data?.message || apiErr.message || 'Booking failed, please try again'; + setSubmitError(errorMessage); + setConflictSlots([]); + } trigger('error'); } finally { setIsSubmitting(false); @@ -232,7 +249,28 @@ export default function BookingModal({ onClose, initialAppointmentType }: Bookin > {submitError && (
- {submitError} +

{submitError}

+ {conflictSlots.length > 0 && ( +
+

Available alternate slots:

+
+ {conflictSlots.map((slot) => ( + + ))} +
+
+ )}
)} setFormData((f) => ({ ...f, time: e.target.value }))} + onChange={(e) => { + setFormData((f) => ({ ...f, time: e.target.value })); + setConflictSlots([]); + setSubmitError(null); + }} /> diff --git a/src/components/Appointments/__tests__/BookingModal.test.tsx b/src/components/Appointments/__tests__/BookingModal.test.tsx new file mode 100644 index 00000000..7aefb252 --- /dev/null +++ b/src/components/Appointments/__tests__/BookingModal.test.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import BookingModal from '../BookingModal'; +import { appointmentsAPI } from '@/lib/api/appointmentsAPI'; +import '@testing-library/jest-dom'; + +jest.mock('@/lib/api/appointmentsAPI', () => ({ + appointmentsAPI: { + createAppointment: jest.fn(), + }, +})); + +jest.mock('@/hooks/useHaptic', () => ({ + useHaptic: () => ({ trigger: jest.fn() }), +})); + +describe('BookingModal Conflict Handling', () => { + const mockOnClose = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const fillForm = () => { + fireEvent.change(screen.getByLabelText(/select pet/i), { target: { value: 'pet1' } }); + fireEvent.change(screen.getByLabelText(/veterinarian/i), { target: { value: 'vet1' } }); + fireEvent.change(screen.getByLabelText(/date/i), { target: { value: '2026-10-10' } }); + }; + + it('displays alternate slots when a 409 conflict occurs', async () => { + const errorResponse = { + response: { + status: 409, + data: { + message: 'The selected time slot is no longer available.', + availableSlots: ['10:30', '11:00', '14:00'], + }, + }, + }; + (appointmentsAPI.createAppointment as jest.Mock).mockRejectedValueOnce(errorResponse); + + render(); + + fillForm(); + + const submitButton = screen.getByRole('button', { name: /confirm booking/i }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByText('The selected time slot is no longer available.')).toBeInTheDocument(); + expect(screen.getByText('10:30')).toBeInTheDocument(); + expect(screen.getByText('11:00')).toBeInTheDocument(); + expect(screen.getByText('14:00')).toBeInTheDocument(); + }); + + // Verify form fields are preserved + expect(screen.getByLabelText(/select pet/i)).toHaveValue('pet1'); + expect(screen.getByLabelText(/veterinarian/i)).toHaveValue('vet1'); + expect(screen.getByLabelText(/date/i)).toHaveValue('2026-10-10'); + }); + + it('updates time and clears conflict slots when an alternate slot is selected', async () => { + const errorResponse = { + response: { + status: 409, + data: { + message: 'The selected time slot is no longer available.', + availableSlots: ['10:30', '11:00', '14:00'], + }, + }, + }; + (appointmentsAPI.createAppointment as jest.Mock) + .mockRejectedValueOnce(errorResponse) + .mockResolvedValueOnce({}); + + render(); + + fillForm(); + + const submitButton = screen.getByRole('button', { name: /confirm booking/i }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByText('10:30')).toBeInTheDocument(); + }); + + // Click on the alternate slot + fireEvent.click(screen.getByText('10:30')); + + // Should update time and hide alternate slots + expect(screen.queryByText('10:30')).not.toBeInTheDocument(); + expect(screen.getByLabelText(/time/i)).toHaveValue('10:30'); + expect(screen.queryByText('The selected time slot is no longer available.')).not.toBeInTheDocument(); + + // Submit again, should succeed + fireEvent.click(submitButton); + + await waitFor(() => { + expect(appointmentsAPI.createAppointment).toHaveBeenCalledTimes(2); + expect(appointmentsAPI.createAppointment).toHaveBeenLastCalledWith(expect.objectContaining({ + time: '10:30', + })); + expect(mockOnClose).toHaveBeenCalled(); + }); + }); +});