From 8ff03a8ec3a99ef789d6c4cc027b04a750d0c085 Mon Sep 17 00:00:00 2001 From: counterweight Date: Sun, 21 Dec 2025 00:15:29 +0100 Subject: [PATCH] Phase 4: Booking UI for regular users with date selection, slot booking, and e2e tests --- frontend/app/booking/page.tsx | 350 ++++++++++++++++++++++++++++ frontend/app/components/Header.tsx | 3 +- frontend/e2e/booking.spec.ts | 360 +++++++++++++++++++++++++++++ 3 files changed, 712 insertions(+), 1 deletion(-) create mode 100644 frontend/app/booking/page.tsx create mode 100644 frontend/e2e/booking.spec.ts diff --git a/frontend/app/booking/page.tsx b/frontend/app/booking/page.tsx new file mode 100644 index 0000000..765eafe --- /dev/null +++ b/frontend/app/booking/page.tsx @@ -0,0 +1,350 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { Permission } from "../auth-context"; +import { api } from "../api"; +import { sharedStyles } from "../styles/shared"; +import { Header } from "../components/Header"; +import { useRequireAuth } from "../hooks/useRequireAuth"; +import { components } from "../generated/api"; +import constants from "../../../shared/constants.json"; + +const { slotDurationMinutes, maxAdvanceDays, minAdvanceDays, noteMaxLength } = constants.booking; + +type BookableSlot = components["schemas"]["BookableSlot"]; +type AvailableSlotsResponse = components["schemas"]["AvailableSlotsResponse"]; +type AppointmentResponse = components["schemas"]["AppointmentResponse"]; + +// Helper to format date as YYYY-MM-DD in local timezone +function formatDate(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}`; +} + +// Helper to format time from ISO string +function formatTime(isoString: string): string { + const d = new Date(isoString); + return d.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +} + +// Get date range for booking (tomorrow to +30 days) +function getBookableDates(): Date[] { + const dates: Date[] = []; + const today = new Date(); + for (let i = minAdvanceDays; i <= maxAdvanceDays; i++) { + const d = new Date(today); + d.setDate(today.getDate() + i); + dates.push(d); + } + return dates; +} + +export default function BookingPage() { + const router = useRouter(); + const { user, isLoading, isAuthorized } = useRequireAuth({ + requiredPermission: Permission.BOOK_APPOINTMENT, + fallbackRedirect: "/", + }); + + const [selectedDate, setSelectedDate] = useState(null); + const [availableSlots, setAvailableSlots] = useState([]); + const [selectedSlot, setSelectedSlot] = useState(null); + const [note, setNote] = useState(""); + const [isLoadingSlots, setIsLoadingSlots] = useState(false); + const [isBooking, setIsBooking] = useState(false); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + const dates = getBookableDates(); + + const fetchSlots = useCallback(async (date: Date) => { + setIsLoadingSlots(true); + setError(null); + setAvailableSlots([]); + setSelectedSlot(null); + + try { + const dateStr = formatDate(date); + const data = await api.get(`/api/booking/slots?date=${dateStr}`); + setAvailableSlots(data.slots); + } catch (err) { + console.error("Failed to fetch slots:", err); + setError("Failed to load available slots"); + } finally { + setIsLoadingSlots(false); + } + }, []); + + useEffect(() => { + if (selectedDate && user && isAuthorized) { + fetchSlots(selectedDate); + } + }, [selectedDate, user, isAuthorized, fetchSlots]); + + const handleDateSelect = (date: Date) => { + setSelectedDate(date); + setSuccessMessage(null); + }; + + const handleSlotSelect = (slot: BookableSlot) => { + setSelectedSlot(slot); + setNote(""); + setError(null); + }; + + const handleBook = async () => { + if (!selectedSlot) return; + + setIsBooking(true); + setError(null); + + try { + const appointment = await api.post("/api/booking", { + slot_start: selectedSlot.start_time, + note: note || null, + }); + + setSuccessMessage(`Appointment booked for ${formatTime(appointment.slot_start)} - ${formatTime(appointment.slot_end)}`); + setSelectedSlot(null); + setNote(""); + + // Refresh slots to show the booked one is gone + if (selectedDate) { + await fetchSlots(selectedDate); + } + } catch (err) { + if (err instanceof Error) { + setError(err.message); + } else { + setError("Failed to book appointment"); + } + } finally { + setIsBooking(false); + } + }; + + const cancelSlotSelection = () => { + setSelectedSlot(null); + setNote(""); + setError(null); + }; + + if (isLoading) { + return ( +
+
+
+

Loading...

+
+
+ ); + } + + if (!isAuthorized) { + return null; + } + + return ( +
+
+
+

Book an Appointment

+

+ Select a date to see available {slotDurationMinutes}-minute slots +

+ + {successMessage && ( +
+ {successMessage} +
+ )} + + {error && ( +
+ {error} +
+ )} + + {/* Date Selection */} +
+

Select a Date

+
+ {dates.map((date) => { + const isSelected = selectedDate && formatDate(selectedDate) === formatDate(date); + return ( + + ); + })} +
+
+ + {/* Available Slots */} + {selectedDate && ( +
+

+ Available Slots for {selectedDate.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric" + })} +

+ + {isLoadingSlots ? ( +

Loading slots...

+ ) : availableSlots.length === 0 ? ( +

No available slots for this date

+ ) : ( +
+ {availableSlots.map((slot) => { + const isSelected = selectedSlot?.start_time === slot.start_time; + return ( + + ); + })} +
+ )} +
+ )} + + {/* Booking Form */} + {selectedSlot && ( +
+

+ Confirm Booking +

+

+ Time: {formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)} +

+ +
+ +