diff --git a/appointment-booking/app/app.css b/appointment-booking/app/app.css index 1f17e0e4c..4ac1275c8 100644 --- a/appointment-booking/app/app.css +++ b/appointment-booking/app/app.css @@ -720,3 +720,17 @@ p { font: var(--typography-regular-small-body); text-align: right; } + +.review-contact { + display: flex; + flex-direction: column; + gap: var(--layout-padding-xsmall); + margin-top: var(--layout-padding-large); + max-width: 28rem; +} + +.review-contact-hint { + margin: 0; + font: var(--typography-regular-small-body); + color: var(--typography-color-secondary); +} diff --git a/appointment-booking/app/auth/keycloak.ts b/appointment-booking/app/auth/keycloak.ts index 83c05506c..52a7c04e5 100644 --- a/appointment-booking/app/auth/keycloak.ts +++ b/appointment-booking/app/auth/keycloak.ts @@ -15,6 +15,8 @@ export type AuthSession = { userFullName: string kcGuid: string loginSource: string + /** From token claims when present (e.g. email OTP). Not persisted separately. */ + email?: string } type TokenClaims = { @@ -74,6 +76,15 @@ function resolveFullName(claims: TokenClaims): string { return claims.display_name?.trim() || claims.email?.trim() || 'Appointment User' } +// Email claim on an access token, if the IdP provided one (OTP usually does; BCSC may not). +export function emailFromAccessToken(token: string): string | undefined { + try { + return decodeTokenClaims(token).email?.trim() || undefined + } catch { + return undefined + } +} + // Rebuilds the auth session after a refresh/redirect. Drops disallowed IdP sessions. export function readAuthSessionFromStorage(): AuthSession | null { const token = getFromSession(SessionKeys.KeyCloakToken) @@ -82,6 +93,7 @@ export function readAuthSessionFromStorage(): AuthSession | null { let identityProvider = getFromSession(SessionKeys.UserAccountType) || '' let userFullName = getFromSession(SessionKeys.UserFullName) || '' let kcGuid = getFromSession(SessionKeys.UserKcId) || '' + let email: string | undefined try { const claims = decodeTokenClaims(token) @@ -94,6 +106,7 @@ export function readAuthSessionFromStorage(): AuthSession | null { if (!kcGuid) { kcGuid = claims.sub || '' } + email = emailFromAccessToken(token) } catch { // Keep stored values if the token cannot be decoded. } @@ -110,6 +123,7 @@ export function readAuthSessionFromStorage(): AuthSession | null { userFullName, kcGuid, loginSource: identityProvider, + email, } } @@ -137,6 +151,7 @@ function buildSessionFromKeycloak(kc: Keycloak, requestedIdpHint: string): AuthS userFullName: resolveFullName(claims), kcGuid: claims.sub || '', loginSource, + email: emailFromAccessToken(token), } } diff --git a/appointment-booking/app/auth/token-refresh.ts b/appointment-booking/app/auth/token-refresh.ts index a01e1e179..c05f0bb41 100644 --- a/appointment-booking/app/auth/token-refresh.ts +++ b/appointment-booking/app/auth/token-refresh.ts @@ -8,7 +8,12 @@ import Keycloak from 'keycloak-js' import { getKeycloakConfigUrl } from '../runtime-config' import { getFromSession } from './session' import { SessionKeys } from './session-keys' -import { type AuthSession, clearStoredAuthSession, writeAuthSession } from './keycloak' +import { + type AuthSession, + clearStoredAuthSession, + emailFromAccessToken, + writeAuthSession, +} from './keycloak' // How early before access-token expiry we refresh (realm lifespan is 5 minutes). const REFRESH_EARLY_SECONDS = 30 @@ -48,6 +53,7 @@ function sessionFromKeycloakTokens(kc: Keycloak): AuthSession { userFullName: getFromSession(SessionKeys.UserFullName) || '', kcGuid: getFromSession(SessionKeys.UserKcId) || '', loginSource: getFromSession(SessionKeys.UserAccountType) || '', + email: emailFromAccessToken(kc.token || ''), } } diff --git a/appointment-booking/app/booking/format-slot.ts b/appointment-booking/app/booking/format-slot.ts new file mode 100644 index 000000000..f4603028f --- /dev/null +++ b/appointment-booking/app/booking/format-slot.ts @@ -0,0 +1,23 @@ +// Shared appointment date/time labels (callout summary and datetime picker). + +export 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)) +} + +export function formatTimeRange(startTime: string, endTime: string) { + return `${formatTime(startTime)} – ${formatTime(endTime)}` +} diff --git a/appointment-booking/app/components/BookingContinueRow.tsx b/appointment-booking/app/components/BookingContinueRow.tsx index b500bd310..c659909a8 100644 --- a/appointment-booking/app/components/BookingContinueRow.tsx +++ b/appointment-booking/app/components/BookingContinueRow.tsx @@ -1,16 +1,21 @@ import { Button } from '@bcgov/design-system-react-components' -// Shared Continue button for booking steps. Enablement and navigation are decided by the calling page. +// Shared primary nav button for booking steps. Label, enablement, and navigation are decided by the calling page. type BookingContinueRowProps = { isDisabled?: boolean + label?: string onContinue?: () => void } -export function BookingContinueRow({ isDisabled = false, onContinue }: BookingContinueRowProps) { +export function BookingContinueRow({ + isDisabled = false, + label = 'Continue', + onContinue, +}: BookingContinueRowProps) { return (
) diff --git a/appointment-booking/app/components/BookingDetailCallout.tsx b/appointment-booking/app/components/BookingDetailCallout.tsx index 5b291f4bb..b5b9163c9 100644 --- a/appointment-booking/app/components/BookingDetailCallout.tsx +++ b/appointment-booking/app/components/BookingDetailCallout.tsx @@ -3,22 +3,19 @@ import { Callout, InlineAlert, Text } from '@bcgov/design-system-react-component import type { ServiceLocation } from '~/api/service-locations' import type { Service } from '~/api/services' import type { BookingSlot } from '~/booking/booking-store' +import { formatDate, formatTimeRange } from '~/booking/format-slot' type BookingDetailCalloutProps = { selectedService: Service | null selectedLocation: ServiceLocation | null - /** When set (datetime step), show chosen date/time under location details. */ + /** When set, show chosen date/time under location details. */ selectedSlot?: BookingSlot | null - formatDate?: (date: string) => string - formatTimeRange?: (startTime: string, endTime: string) => string } export function BookingDetailCallout({ selectedService, selectedLocation, selectedSlot = null, - formatDate, - formatTimeRange, }: BookingDetailCalloutProps) { return ( @@ -46,7 +43,7 @@ export function BookingDetailCallout({ ) : selectedService ? ( <>Select a location from the list to view details. ) : null} - {selectedSlot && formatDate && formatTimeRange ? ( + {selectedSlot ? ( <>
Appointment date - {formatDate(selectedSlot.date)} diff --git a/appointment-booking/app/routes.ts b/appointment-booking/app/routes.ts index 8e606f4e4..2493141e7 100644 --- a/appointment-booking/app/routes.ts +++ b/appointment-booking/app/routes.ts @@ -7,5 +7,6 @@ export default [ route('signin/:idpHint', 'routes/signin.$idpHint.tsx'), route('login', 'routes/login.tsx'), route('datetime', 'routes/datetime.tsx'), + route('review', 'routes/review.tsx'), route('locations', 'routes/locations.tsx'), ] satisfies RouteConfig diff --git a/appointment-booking/app/routes/datetime.tsx b/appointment-booking/app/routes/datetime.tsx index bbe71e209..db3b2dfb0 100644 --- a/appointment-booking/app/routes/datetime.tsx +++ b/appointment-booking/app/routes/datetime.tsx @@ -6,7 +6,9 @@ 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 { formatDate, formatTimeRange } from '~/booking/format-slot' import { BookingBackRow } from '~/components/BookingBackRow' +import { BookingContinueRow } from '~/components/BookingContinueRow' import { BookingDetailCallout } from '~/components/BookingDetailCallout' import { BookingStepProgress } from '~/components/BookingStepProgress' @@ -14,28 +16,6 @@ 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}` } @@ -173,8 +153,6 @@ export default function DateTimePage() { selectedService={selectedService} selectedLocation={selectedLocation} selectedSlot={selectedSlot} - formatDate={formatDate} - formatTimeRange={formatTimeRange} /> {isLoading ? ( @@ -287,6 +265,11 @@ export default function DateTimePage() {
navigate('/login')} /> + navigate('/review')} + />
) diff --git a/appointment-booking/app/routes/review.tsx b/appointment-booking/app/routes/review.tsx new file mode 100644 index 000000000..eb9e4ced3 --- /dev/null +++ b/appointment-booking/app/routes/review.tsx @@ -0,0 +1,106 @@ +// Booking step 5: review service, location, date/time, and confirmation email. No confirm yet. +import { useState } from 'react' +import { Button, InlineAlert, Text, TextField } from '@bcgov/design-system-react-components' +import { useNavigate } from 'react-router' + +import { useAuth } from '~/auth/auth-context' +import { useBooking } from '~/booking/booking-context' +import { BookingBackRow } from '~/components/BookingBackRow' +import { BookingDetailCallout } from '~/components/BookingDetailCallout' +import { BookingStepProgress } from '~/components/BookingStepProgress' + +const BOOKING_STEP = 5 +const BOOKING_STEP_COUNT = 5 +const BOOKING_STEP_HEADING = 'Review your appointment details before confirming.' + +export function meta() { + return [{ title: 'Review Appointment' }] +} + +export default function ReviewPage() { + const navigate = useNavigate() + const { isReady: isAuthReady, isAuthenticated, session } = useAuth() + const { isReady: isBookingReady, selectedService, selectedLocation, selectedSlot } = useBooking() + // null = still using signed-in email; string = user edited (including cleared). + const [contactEmail, setContactEmail] = useState(null) + const confirmationEmail = contactEmail ?? session?.email?.trim() ?? '' + + const stepProgress = ( + + ) + + // Wait until sessionStorage restore finishes so we do not flash the wrong screen. + if (!isAuthReady || !isBookingReady) { + return ( +
+ Loading booking… +
+ ) + } + + if (!selectedService || !selectedLocation || !selectedSlot) { + 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 reviewing your appointment. + +
+ +
+ + ) + } + + return ( + <> +

Review Appointment

+ {stepProgress} + + + +
+ +

Appointment details will be sent to this email.

+
+ +
+ navigate('/datetime')} /> +
+ + ) +}