Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 48 additions & 6 deletions src/components/Appointments/BookingModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ export default function BookingModal({ onClose, initialAppointmentType }: Bookin
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [conflictSlots, setConflictSlots] = useState<string[]>([]);

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<string, string> = {};
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -232,7 +249,28 @@ export default function BookingModal({ onClose, initialAppointmentType }: Bookin
>
{submitError && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{submitError}
<p>{submitError}</p>
{conflictSlots.length > 0 && (
<div className="mt-3">
<p className="text-xs font-semibold mb-2">Available alternate slots:</p>
<div className="flex flex-wrap gap-2">
{conflictSlots.map((slot) => (
<button
key={slot}
type="button"
onClick={() => {
setFormData((f) => ({ ...f, time: slot }));
setConflictSlots([]);
setSubmitError(null);
}}
className="px-3 py-1.5 bg-white border border-red-200 rounded-md text-red-700 text-xs font-medium hover:bg-red-50 transition-colors"
>
{slot}
</button>
))}
</div>
</div>
)}
</div>
)}
<TouchSelect
Expand Down Expand Up @@ -265,9 +303,13 @@ export default function BookingModal({ onClose, initialAppointmentType }: Bookin
/>
<TouchSelect
label="Time"
options={TIME_OPTIONS}
options={dynamicTimeOptions}
value={formData.time}
onChange={(e) => setFormData((f) => ({ ...f, time: e.target.value }))}
onChange={(e) => {
setFormData((f) => ({ ...f, time: e.target.value }));
setConflictSlots([]);
setSubmitError(null);
}}
/>
</div>

Expand Down
106 changes: 106 additions & 0 deletions src/components/Appointments/__tests__/BookingModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<BookingModal onClose={mockOnClose} />);

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(<BookingModal onClose={mockOnClose} />);

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();
});
});
});