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 (
- Continue
+ {label}
)
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.
+
+
+ navigate('/services')}>
+ Go to services
+
+
+ >
+ )
+ }
+
+ if (!isAuthenticated) {
+ return (
+ <>
+ {stepProgress}
+
+ Please sign in before reviewing your appointment.
+
+
+ navigate('/login')}>
+ Go to login
+
+
+ >
+ )
+ }
+
+ return (
+ <>
+ Review Appointment
+ {stepProgress}
+
+
+
+
+
+
Appointment details will be sent to this email.
+
+
+
+ navigate('/datetime')} />
+
+ >
+ )
+}