Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions appointment-booking/app/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
15 changes: 15 additions & 0 deletions appointment-booking/app/auth/keycloak.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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.
}
Expand All @@ -110,6 +123,7 @@ export function readAuthSessionFromStorage(): AuthSession | null {
userFullName,
kcGuid,
loginSource: identityProvider,
email,
}
}

Expand Down Expand Up @@ -137,6 +151,7 @@ function buildSessionFromKeycloak(kc: Keycloak, requestedIdpHint: string): AuthS
userFullName: resolveFullName(claims),
kcGuid: claims.sub || '',
loginSource,
email: emailFromAccessToken(token),
}
}

Expand Down
8 changes: 7 additions & 1 deletion appointment-booking/app/auth/token-refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 || ''),
}
}

Expand Down
23 changes: 23 additions & 0 deletions appointment-booking/app/booking/format-slot.ts
Original file line number Diff line number Diff line change
@@ -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)}`
}
11 changes: 8 additions & 3 deletions appointment-booking/app/components/BookingContinueRow.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="booking-continue-row">
<Button variant="primary" size="medium" isDisabled={isDisabled} onPress={onContinue}>
Continue
{label}
</Button>
</div>
)
Expand Down
9 changes: 3 additions & 6 deletions appointment-booking/app/components/BookingDetailCallout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Callout variant="lightBlue">
Expand Down Expand Up @@ -46,7 +43,7 @@ export function BookingDetailCallout({
) : selectedService ? (
<>Select a location from the list to view details.</>
) : null}
{selectedSlot && formatDate && formatTimeRange ? (
{selectedSlot ? (
<>
<br />
Appointment date - <strong>{formatDate(selectedSlot.date)}</strong>
Expand Down
1 change: 1 addition & 0 deletions appointment-booking/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
31 changes: 7 additions & 24 deletions appointment-booking/app/routes/datetime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,16 @@ 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'

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}`
}
Expand Down Expand Up @@ -173,8 +153,6 @@ export default function DateTimePage() {
selectedService={selectedService}
selectedLocation={selectedLocation}
selectedSlot={selectedSlot}
formatDate={formatDate}
formatTimeRange={formatTimeRange}
/>

{isLoading ? (
Expand Down Expand Up @@ -287,6 +265,11 @@ export default function DateTimePage() {

<div className="booking-nav-row">
<BookingBackRow onBack={() => navigate('/login')} />
<BookingContinueRow
label="Review"
isDisabled={!selectedSlot}
onContinue={() => navigate('/review')}
/>
</div>
</>
)
Expand Down
106 changes: 106 additions & 0 deletions appointment-booking/app/routes/review.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null)
const confirmationEmail = contactEmail ?? session?.email?.trim() ?? ''

const stepProgress = (
<BookingStepProgress
step={BOOKING_STEP}
stepCount={BOOKING_STEP_COUNT}
heading={BOOKING_STEP_HEADING}
/>
)

// Wait until sessionStorage restore finishes so we do not flash the wrong screen.
if (!isAuthReady || !isBookingReady) {
return (
<div className="sign-in-panel" role="status" aria-live="polite">
<Text>Loading booking…</Text>
</div>
)
}

if (!selectedService || !selectedLocation || !selectedSlot) {
return (
<>
{stepProgress}
<InlineAlert variant="warning" title="Start your booking">
Please go back to the services page and start by selecting a service, then a location.
</InlineAlert>
<div className="booking-nav-row">
<Button type="button" onPress={() => navigate('/services')}>
Go to services
</Button>
</div>
</>
)
}

if (!isAuthenticated) {
return (
<>
{stepProgress}
<InlineAlert variant="warning" title="Sign in to continue">
Please sign in before reviewing your appointment.
</InlineAlert>
<div className="booking-nav-row">
<Button type="button" onPress={() => navigate('/login')}>
Go to login
</Button>
</div>
</>
)
}

return (
<>
<h1 className="sr-only">Review Appointment</h1>
{stepProgress}

<BookingDetailCallout
selectedService={selectedService}
selectedLocation={selectedLocation}
selectedSlot={selectedSlot}
/>

<div className="review-contact">
<TextField
label="Confirmation email"
type="email"
name="confirmationEmail"
value={confirmationEmail}
onChange={setContactEmail}
// @ts-expect-error placeholder is supported by underlying react-aria TextField
placeholder="name@example.com"
/>
<p className="review-contact-hint">Appointment details will be sent to this email.</p>
</div>

<div className="booking-nav-row">
<BookingBackRow onBack={() => navigate('/datetime')} />
</div>
</>
)
}
Loading