From 9c2629c8be0097abe357d71d54532c11eceb575f Mon Sep 17 00:00:00 2001 From: Veenu Punyani Date: Fri, 24 Jul 2026 13:28:10 -0700 Subject: [PATCH 1/2] Add date/time selection step for appointment booking. Added booking step 4 date and time selection. Protected /datetime page loads office slots, lets users pick a day and time with the BCDS calendar and radios, and saves the choice in booking context. --- appointment-booking/app/api/timeslots.ts | 77 +++++ appointment-booking/app/app.css | 156 ++++++++++ appointment-booking/app/auth/session-keys.ts | 2 + .../app/booking/booking-context.tsx | 40 ++- .../app/booking/booking-store.ts | 9 + appointment-booking/app/routes.ts | 1 + appointment-booking/app/routes/datetime.tsx | 291 ++++++++++++++++++ appointment-booking/app/routes/login.tsx | 8 +- appointment-booking/package-lock.json | 55 ++-- appointment-booking/package.json | 1 + 10 files changed, 597 insertions(+), 43 deletions(-) create mode 100644 appointment-booking/app/api/timeslots.ts create mode 100644 appointment-booking/app/routes/datetime.tsx 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..3b5bf4c88 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,158 @@ 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: var(--layout-margin-medium) 0 0; + 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; +} + +.datetime-time-slots .bcds-react-aria-RadioGroup--options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: stretch; + width: 100%; + gap: var(--layout-padding-medium); +} + +.datetime-time-slots .bcds-react-aria-Radio { + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 100%; + min-height: 2.75rem; + padding: var(--layout-padding-medium); + border: var(--layout-border-width-small) solid var(--surface-color-border-default); + border-radius: var(--layout-border-radius-medium); + background: var(--surface-color-background-white); + cursor: pointer; +} + +.datetime-time-slots .bcds-react-aria-Radio[data-selected] { + border-color: var(--surface-color-border-active); + background: var(--theme-blue-10); + box-shadow: inset var(--layout-border-width-medium) 0 0 0 var(--theme-blue-80); +} + +/* 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-selected-day { + order: -1; + margin: 0 0 var(--layout-margin-medium); + } + + .datetime-calendar-legend { + margin-left: auto; + margin-right: auto; + } + + .datetime-time-slots .bcds-react-aria-RadioGroup--options { + grid-template-columns: 1fr; + } +} + .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..6c4466106 --- /dev/null +++ b/appointment-booking/app/routes/datetime.tsx @@ -0,0 +1,291 @@ +import { useEffect, useState } from 'react' +import { + Button, + Calendar, + Callout, + InlineAlert, + Radio, + RadioGroup, + 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 ? ( +
+
+ { + const slot = activeDaySlots.find( + ({ startTime, endTime }) => slotValue(startTime, endTime) === value, + ) + if (slot) setSelectedSlot({ date: activeDay, ...slot }) + }} + > + {activeDaySlots.map((slot) => { + const value = slotValue(slot.startTime, slot.endTime) + return ( + + {formatTimeRange(slot.startTime, slot.endTime)} + + ) + })} + +
+

+ {formatDate(activeDay)} +

+
+ ) : ( + Select an available date to see its appointment times. + )} +
+
+
+ )} + +
+ navigate('/login')} /> +
+ + ) +} diff --git a/appointment-booking/app/routes/login.tsx b/appointment-booking/app/routes/login.tsx index 51df550bb..8315faa09 100644 --- a/appointment-booking/app/routes/login.tsx +++ b/appointment-booking/app/routes/login.tsx @@ -153,6 +153,11 @@ export default function LoginPage() { ) : null} + {selectedLocation.appointmentMessage ? ( + + {selectedLocation.appointmentMessage} + + ) : null} @@ -162,8 +167,7 @@ export default function LoginPage() {
navigate('/service-locations')} /> - {/* Date/time step is not built yet — Continue stays disabled. */} - + navigate('/datetime')} />
) diff --git a/appointment-booking/package-lock.json b/appointment-booking/package-lock.json index 7f66e23df..5778855b5 100644 --- a/appointment-booking/package-lock.json +++ b/appointment-booking/package-lock.json @@ -14,6 +14,7 @@ "@fortawesome/fontawesome-svg-core": "^7.3.0", "@fortawesome/free-solid-svg-icons": "^7.3.0", "@fortawesome/react-fontawesome": "^3.3.1", + "@internationalized/date": "^3.12.2", "@react-router/node": "8.0.0", "@react-router/serve": "8.0.0", "geolib": "^3.3.14", @@ -614,6 +615,31 @@ "integrity": "sha512-kcen18Q/snP6M6JQ6XUQTlWXM1Gnm6y8Zk0i29K+T/Y0m/ab4PufmGpl43l/EJKCnYgpOaZFSXDK7CCO6P0INw==", "license": "Apache-2.0" }, + "node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", @@ -621,10 +647,10 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" - }, - "peer": true + } }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", @@ -6107,31 +6133,6 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } - }, - "node_modules/@emnapi/core": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", - "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } } } } diff --git a/appointment-booking/package.json b/appointment-booking/package.json index fe3c5ba97..93f02e1a8 100644 --- a/appointment-booking/package.json +++ b/appointment-booking/package.json @@ -21,6 +21,7 @@ "@fortawesome/fontawesome-svg-core": "^7.3.0", "@fortawesome/free-solid-svg-icons": "^7.3.0", "@fortawesome/react-fontawesome": "^3.3.1", + "@internationalized/date": "^3.12.2", "@react-router/node": "8.0.0", "@react-router/serve": "8.0.0", "geolib": "^3.3.14", From 550222a07c2d75180886e32c7058d5435b5e359d Mon Sep 17 00:00:00 2001 From: Veenu Punyani Date: Fri, 24 Jul 2026 14:25:23 -0700 Subject: [PATCH 2/2] switch to dropdown, move date up --- appointment-booking/app/app.css | 36 +++------------------ appointment-booking/app/routes/datetime.tsx | 31 ++++++++---------- 2 files changed, 17 insertions(+), 50 deletions(-) diff --git a/appointment-booking/app/app.css b/appointment-booking/app/app.css index 3b5bf4c88..94ea99c3a 100644 --- a/appointment-booking/app/app.css +++ b/appointment-booking/app/app.css @@ -592,7 +592,7 @@ p { } .datetime-selected-day { - margin: var(--layout-margin-medium) 0 0; + margin: 0 0 var(--layout-margin-medium); padding: var(--layout-padding-small) var(--layout-padding-medium); width: fit-content; max-width: 100%; @@ -637,32 +637,13 @@ p { max-width: 28rem; } -.datetime-time-slots .bcds-react-aria-RadioGroup--options { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - align-items: stretch; +/* 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%; - gap: var(--layout-padding-medium); } -.datetime-time-slots .bcds-react-aria-Radio { - display: flex; - align-items: center; - justify-content: center; - box-sizing: border-box; +.datetime-time-slots .bcds-react-aria-Select--Button { width: 100%; - min-height: 2.75rem; - padding: var(--layout-padding-medium); - border: var(--layout-border-width-small) solid var(--surface-color-border-default); - border-radius: var(--layout-border-radius-medium); - background: var(--surface-color-background-white); - cursor: pointer; -} - -.datetime-time-slots .bcds-react-aria-Radio[data-selected] { - border-color: var(--surface-color-border-active); - background: var(--theme-blue-10); - box-shadow: inset var(--layout-border-width-medium) 0 0 0 var(--theme-blue-80); } /* Make the calendar wider on desktop so it fills its column. */ @@ -707,19 +688,10 @@ p { align-items: center; } - .datetime-selected-day { - order: -1; - margin: 0 0 var(--layout-margin-medium); - } - .datetime-calendar-legend { margin-left: auto; margin-right: auto; } - - .datetime-time-slots .bcds-react-aria-RadioGroup--options { - grid-template-columns: 1fr; - } } .header-account { diff --git a/appointment-booking/app/routes/datetime.tsx b/appointment-booking/app/routes/datetime.tsx index 6c4466106..81b941269 100644 --- a/appointment-booking/app/routes/datetime.tsx +++ b/appointment-booking/app/routes/datetime.tsx @@ -4,8 +4,7 @@ import { Calendar, Callout, InlineAlert, - Radio, - RadioGroup, + Select, Text, } from '@bcgov/design-system-react-components' import { parseDate } from '@internationalized/date' @@ -250,30 +249,26 @@ export default function DateTimePage() {

Select Time

{activeDay ? (
+

+ {formatDate(activeDay)} +

- { + placeholder="Select a Time Slot" + selectedKey={selectedSlotValue || null} + items={activeDaySlots.map((slot) => ({ + id: slotValue(slot.startTime, slot.endTime), + label: formatTimeRange(slot.startTime, slot.endTime), + }))} + onSelectionChange={(value) => { const slot = activeDaySlots.find( ({ startTime, endTime }) => slotValue(startTime, endTime) === value, ) if (slot) setSelectedSlot({ date: activeDay, ...slot }) }} - > - {activeDaySlots.map((slot) => { - const value = slotValue(slot.startTime, slot.endTime) - return ( - - {formatTimeRange(slot.startTime, slot.endTime)} - - ) - })} - + />
-

- {formatDate(activeDay)} -

) : ( Select an available date to see its appointment times.