diff --git a/appointment-booking/app/api/timeslots.ts b/appointment-booking/app/api/timeslots.ts new file mode 100644 index 000000000..deb9d3012 --- /dev/null +++ b/appointment-booking/app/api/timeslots.ts @@ -0,0 +1,77 @@ +import { getApiBaseUrl } from '../runtime-config' + +// Start and end time shown in the booking UI. +export type TimeSlot = { + startTime: string + endTime: string +} + +// Available times grouped by date (YYYY-MM-DD). +export type AvailableTimeSlots = Record + +// One slot as returned by the office slots API. +type ApiTimeSlot = { + start_time: string + end_time: string + no_of_slots: number +} + +// The API groups slots by MM/DD/YYYY dates. +type ApiAvailableTimeSlots = Record + +// Convert MM/DD/YYYY to YYYY-MM-DD. Skip bad dates instead of failing the whole load. +function toIsoDate(date: string): string | null { + const [month, day, year] = date.split('/') + if (!month || !day || !year) { + return null + } + return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}` +} + +// Load bookable times for the selected office and service. +// Throws when the request fails so the page can show an error, not "no times available". +export async function getAvailableTimeSlots( + officeId: number, + serviceId: number, +): Promise { + const url = `${await getApiBaseUrl()}/offices/${officeId}/slots/?service_id=${serviceId}` + + let body: ApiAvailableTimeSlots + try { + const response = await fetch(url) + if (!response.ok) { + throw new Error('Failed to load time slots') + } + body = (await response.json()) as ApiAvailableTimeSlots + } catch { + throw new Error('Failed to load time slots') + } + + if (!body || typeof body !== 'object') { + throw new Error('Failed to load time slots') + } + + const availableTimeSlots: AvailableTimeSlots = {} + + for (const [date, slots] of Object.entries(body)) { + const isoDate = toIsoDate(date) + if (!isoDate) { + continue + } + + // Skip slots with no openings left, in case the API includes them. + const availableSlots = slots + .filter((slot) => slot.no_of_slots > 0) + .map((slot) => ({ + startTime: slot.start_time, + endTime: slot.end_time, + })) + + // Leave empty days out so the calendar can mark them unavailable. + if (availableSlots.length > 0) { + availableTimeSlots[isoDate] = availableSlots + } + } + + return availableTimeSlots +} diff --git a/appointment-booking/app/app.css b/appointment-booking/app/app.css index 6fc769ef1..94ea99c3a 100644 --- a/appointment-booking/app/app.css +++ b/appointment-booking/app/app.css @@ -237,6 +237,10 @@ p { overflow-x: auto; width: 100%; max-width: 100%; +} + +.services-table-wrapper, +.datetime-selection-panel { box-sizing: border-box; background: var(--surface-color-background-white); border: var(--layout-border-width-small) solid var(--surface-color-border-default); @@ -566,6 +570,130 @@ p { margin-bottom: var(--layout-margin-large); } +.datetime-status { + margin-top: var(--layout-margin-large); +} + +/* Same gold top bar used on the services and locations tables. */ +.datetime-selection-panel { + margin-top: var(--layout-margin-large); + padding: var(--layout-padding-xlarge); +} + +.datetime-selection { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--layout-padding-xlarge); +} + +.datetime-selection h2 { + margin-top: 0; + font: var(--typography-bold-h3); +} + +.datetime-selected-day { + margin: 0 0 var(--layout-margin-medium); + padding: var(--layout-padding-small) var(--layout-padding-medium); + width: fit-content; + max-width: 100%; + box-sizing: border-box; + border-radius: var(--layout-border-radius-medium); + background: var(--theme-blue-10); + font: var(--typography-regular-small-body); + color: var(--typography-color-secondary); + text-align: center; +} + +.datetime-time-panel { + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; +} + +.datetime-calendar-legend { + margin: var(--layout-margin-small) 0 0; + padding: 0 0 0 1.25rem; + width: fit-content; + max-width: 100%; + box-sizing: border-box; + list-style-position: outside; + text-align: left; + font: var(--typography-regular-small-body); + color: var(--typography-color-secondary); +} + +.datetime-calendar-legend-unavailable { + color: var(--typography-color-danger); + text-decoration: line-through; +} + +.datetime-calendar-legend-disabled { + color: var(--typography-color-disabled); +} + +.datetime-time-slots { + width: 100%; + max-width: 28rem; +} + +/* Stretch the select so the full "9:00 a.m. – 9:15 a.m." label fits on one line. */ +.datetime-time-slots .bcds-react-aria-Select { + width: 100%; +} + +.datetime-time-slots .bcds-react-aria-Select--Button { + width: 100%; +} + +/* Make the calendar wider on desktop so it fills its column. */ +@media (min-width: 48rem) { + .datetime-selection .bcds-react-aria-Calendar { + display: flex; + width: 100%; + max-width: 26rem; + } + + .datetime-selection .bcds-react-aria-Calendar--GridContainer, + .datetime-selection .bcds-react-aria-Calendar--Header, + .datetime-selection .bcds-react-aria-Calendar--Grid { + width: 100%; + } + + .datetime-selection .bcds-react-aria-Calendar--GridContainer { + flex: 1 1 auto; + } + + .datetime-selection .bcds-react-aria-Calendar--Cell { + width: 2.75rem; + height: 2.75rem; + } +} + +@media (max-width: 48rem) { + .datetime-selection { + grid-template-columns: 1fr; + justify-items: center; + text-align: center; + } + + .datetime-selection section { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + } + + .datetime-time-panel { + align-items: center; + } + + .datetime-calendar-legend { + margin-left: auto; + margin-right: auto; + } +} + .header-account { display: flex; align-items: center; diff --git a/appointment-booking/app/auth/session-keys.ts b/appointment-booking/app/auth/session-keys.ts index af9afcad6..0b25f253f 100644 --- a/appointment-booking/app/auth/session-keys.ts +++ b/appointment-booking/app/auth/session-keys.ts @@ -12,6 +12,7 @@ export const SessionKeys = { // Booking selections survive the IdP redirect round-trip. BookingSelectedService: 'BOOKING_SELECTED_SERVICE', BookingSelectedLocation: 'BOOKING_SELECTED_LOCATION', + BookingSelectedSlot: 'BOOKING_SELECTED_SLOT', } as const // Cleared on logout and when discarding a bad/disallowed auth session. @@ -28,6 +29,7 @@ export const AUTH_SESSION_KEYS = [ export const BOOKING_SESSION_KEYS = [ SessionKeys.BookingSelectedService, SessionKeys.BookingSelectedLocation, + SessionKeys.BookingSelectedSlot, ] as const export const IdpHint = { diff --git a/appointment-booking/app/booking/booking-context.tsx b/appointment-booking/app/booking/booking-context.tsx index eb1b86db8..11bd27c46 100644 --- a/appointment-booking/app/booking/booking-context.tsx +++ b/appointment-booking/app/booking/booking-context.tsx @@ -1,44 +1,54 @@ -// Shared service + location selection across booking steps. -// Also saved to sessionStorage so selections survive the BCSC redirect. +// Shared service, location, and appointment time across booking steps. +// Also saved in the browser so choices survive the sign-in redirect. import { useContext, useEffect, useState, type ReactNode } from 'react' import type { Location } from '../api/locations' import type { Service } from '../api/services' import { addJsonToSession, getJsonFromSession, removeFromSession } from '../auth/session' import { SessionKeys } from '../auth/session-keys' -import { BookingContext } from './booking-store' +import { BookingContext, type BookingSlot } from './booking-store' + +function persistJson(key: string, value: unknown) { + if (value) { + addJsonToSession(key, value) + } else { + removeFromSession(key) + } +} export function BookingProvider({ children }: { children: ReactNode }) { const [isReady, setIsReady] = useState(false) const [selectedService, setSelectedServiceState] = useState(null) const [selectedLocation, setSelectedLocationState] = useState(null) + const [selectedSlot, setSelectedSlotState] = useState(null) useEffect(() => { - // sessionStorage is browser-only, so restore it after the initial render. + // Restore saved choices after the page first loads in the browser. const id = window.setTimeout(() => { setSelectedServiceState(getJsonFromSession(SessionKeys.BookingSelectedService)) setSelectedLocationState(getJsonFromSession(SessionKeys.BookingSelectedLocation)) + setSelectedSlotState(getJsonFromSession(SessionKeys.BookingSelectedSlot)) setIsReady(true) }, 0) return () => window.clearTimeout(id) }, []) + // Changing service or location clears any time already chosen. function setSelectedService(service: Service | null) { setSelectedServiceState(service) - if (service) { - addJsonToSession(SessionKeys.BookingSelectedService, service) - } else { - removeFromSession(SessionKeys.BookingSelectedService) - } + setSelectedSlot(null) + persistJson(SessionKeys.BookingSelectedService, service) } function setSelectedLocation(location: Location | null) { setSelectedLocationState(location) - if (location) { - addJsonToSession(SessionKeys.BookingSelectedLocation, location) - } else { - removeFromSession(SessionKeys.BookingSelectedLocation) - } + setSelectedSlot(null) + persistJson(SessionKeys.BookingSelectedLocation, location) + } + + function setSelectedSlot(slot: BookingSlot | null) { + setSelectedSlotState(slot) + persistJson(SessionKeys.BookingSelectedSlot, slot) } return ( @@ -49,6 +59,8 @@ export function BookingProvider({ children }: { children: ReactNode }) { setSelectedService, selectedLocation, setSelectedLocation, + selectedSlot, + setSelectedSlot, }} > {children} diff --git a/appointment-booking/app/booking/booking-store.ts b/appointment-booking/app/booking/booking-store.ts index bb76caee7..5bad7bc88 100644 --- a/appointment-booking/app/booking/booking-store.ts +++ b/appointment-booking/app/booking/booking-store.ts @@ -3,12 +3,21 @@ import { createContext } from 'react' import type { Location } from '../api/locations' import type { Service } from '../api/services' +// The chosen appointment date and time. Date is YYYY-MM-DD; times are HH:MM. +export type BookingSlot = { + date: string + startTime: string + endTime: string +} + export type BookingContextValue = { isReady: boolean selectedService: Service | null setSelectedService: (service: Service | null) => void selectedLocation: Location | null setSelectedLocation: (location: Location | null) => void + selectedSlot: BookingSlot | null + setSelectedSlot: (slot: BookingSlot | null) => void } // Isolated from component exports so Vite/Fast Refresh cannot duplicate this context. diff --git a/appointment-booking/app/routes.ts b/appointment-booking/app/routes.ts index 16e658296..8e606f4e4 100644 --- a/appointment-booking/app/routes.ts +++ b/appointment-booking/app/routes.ts @@ -6,5 +6,6 @@ export default [ route('service-locations', 'routes/service-locations.tsx'), route('signin/:idpHint', 'routes/signin.$idpHint.tsx'), route('login', 'routes/login.tsx'), + route('datetime', 'routes/datetime.tsx'), route('locations', 'routes/locations.tsx'), ] satisfies RouteConfig diff --git a/appointment-booking/app/routes/datetime.tsx b/appointment-booking/app/routes/datetime.tsx new file mode 100644 index 000000000..81b941269 --- /dev/null +++ b/appointment-booking/app/routes/datetime.tsx @@ -0,0 +1,286 @@ +import { useEffect, useState } from 'react' +import { + Button, + Calendar, + Callout, + InlineAlert, + Select, + Text, +} from '@bcgov/design-system-react-components' +import { parseDate } from '@internationalized/date' +import { useNavigate } from 'react-router' + +import { getAvailableTimeSlots, type AvailableTimeSlots } from '~/api/timeslots' +import { useAuth } from '~/auth/auth-context' +import { useBooking } from '~/booking/booking-context' +import { BookingBackRow } from '~/components/BookingBackRow' +import { BookingStepProgress } from '~/components/BookingStepProgress' + +const BOOKING_STEP = 4 +const BOOKING_STEP_COUNT = 5 +const BOOKING_STEP_HEADING = 'Select a date and time for your appointment.' + +function formatDate(date: string) { + const [year, month, day] = date.split('-').map(Number) + return new Intl.DateTimeFormat('en-CA', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric', + }).format(new Date(year, month - 1, day)) +} + +function formatTime(time: string) { + const [hour, minute] = time.split(':').map(Number) + return new Intl.DateTimeFormat('en-CA', { + hour: 'numeric', + minute: '2-digit', + }).format(new Date(2000, 0, 1, hour, minute)) +} + +function formatTimeRange(startTime: string, endTime: string) { + return `${formatTime(startTime)} – ${formatTime(endTime)}` +} + +function slotValue(startTime: string, endTime: string) { + return `${startTime}|${endTime}` +} + +export function meta() { + return [{ title: 'Select Date and Time' }] +} + +export default function DateTimePage() { + const navigate = useNavigate() + const { isReady: isAuthReady, isAuthenticated } = useAuth() + const { + isReady: isBookingReady, + selectedService, + selectedLocation, + selectedSlot, + setSelectedSlot, + } = useBooking() + const [timeSlots, setTimeSlots] = useState({}) + const [selectedDay, setSelectedDay] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [loadError, setLoadError] = useState(false) + + useEffect(() => { + if ( + !isAuthReady || + !isBookingReady || + !isAuthenticated || + !selectedService || + !selectedLocation + ) { + return + } + + let cancelled = false + + // Wait a moment before loading so we can clear any old error and show loading again. + const startId = window.setTimeout(() => { + if (cancelled) { + return + } + + setIsLoading(true) + setLoadError(false) + + getAvailableTimeSlots(selectedLocation.id, selectedService.id) + .then((loaded) => { + if (!cancelled) setTimeSlots(loaded) + }) + .catch(() => { + if (!cancelled) { + setTimeSlots({}) + setLoadError(true) + } + }) + .finally(() => { + if (!cancelled) setIsLoading(false) + }) + }, 0) + + return () => { + cancelled = true + window.clearTimeout(startId) + } + }, [isAuthReady, isBookingReady, isAuthenticated, selectedLocation, selectedService]) + + const availableDates = Object.keys(timeSlots).sort() + // Prefer the day the user clicked; if none, use the day from a previously saved time. + const activeDay = + selectedDay ?? (selectedSlot && timeSlots[selectedSlot.date] ? selectedSlot.date : null) + const activeDaySlots = activeDay ? (timeSlots[activeDay] ?? []) : [] + const selectedSlotValue = + selectedSlot?.date === activeDay ? slotValue(selectedSlot.startTime, selectedSlot.endTime) : '' + + const stepProgress = ( + + ) + + if (!isAuthReady || !isBookingReady) { + return ( +
+ Loading booking… +
+ ) + } + + if (!selectedService || !selectedLocation) { + return ( + <> + {stepProgress} + + Please go back to the services page and start by selecting a service, then a location. + +
+ +
+ + ) + } + + if (!isAuthenticated) { + return ( + <> + {stepProgress} + + Please sign in before selecting a date and time for your appointment. + +
+ +
+ + ) + } + + return ( + <> +

Select Date and Time

+ {stepProgress} + + +
+ + Selected Service - {selectedService.name} +
+ Appointment Location - {selectedLocation.name} + {selectedLocation.address ? ( + <> +
+ Address - {selectedLocation.address} + + ) : null} + {selectedSlot ? ( + <> +
+ Appointment Date - {formatDate(selectedSlot.date)} +
+ Appointment Time -{' '} + {formatTimeRange(selectedSlot.startTime, selectedSlot.endTime)} + + ) : null} +
+ {selectedLocation.appointmentMessage ? ( + + {selectedLocation.appointmentMessage} + + ) : null} +
+
+ + {isLoading ? ( +
+ Loading available dates and times… +
+ ) : loadError ? ( +
+ + Please try again. + +
+ ) : availableDates.length === 0 ? ( +
+ + There are no available dates or times for this service and location. + +
+ ) : ( +
+
+
+

Select Date

+ !timeSlots[date.toString()]} + onChange={(date) => { + const nextDay = date.toString() + if (nextDay !== activeDay) setSelectedSlot(null) + setSelectedDay(nextDay) + }} + /> +
    +
  • + Red line — no + appointments available +
  • +
  • + Greyed out — outside + the booking window or not selectable +
  • +
+
+ +
+

Select Time

+ {activeDay ? ( +
+

+ {formatDate(activeDay)} +

+
+