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
2 changes: 1 addition & 1 deletion .github/workflows/appointment-booking-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
node-version: '24'
cache: 'npm'
cache-dependency-path: appointment-booking/package-lock.json

Expand Down
66 changes: 0 additions & 66 deletions appointment-booking/app/api/locations.ts

This file was deleted.

96 changes: 96 additions & 0 deletions appointment-booking/app/api/service-locations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { getApiBaseUrl } from '../runtime-config'

// Mapped office for the booking locations step.
// appointmentsDisabled / isBookable are derived — the API does not send them.
export type ServiceLocation = {
id: number
name: string
address: string
latitude: number | null
longitude: number | null
appointmentMessage: string
nextAppointmentDate: string | null
appointmentsDisabled: boolean
isBookable: boolean
}

type ApiOffice = {
office_id: number
office_name: string
civic_address: string | null
latitude: number | null
longitude: number | null
appointments_enabled_ind: number
online_status: string | null
deleted: string | null
office_appointment_message: string | null
next_appointment_date: string | null
}

type ApiOfficesResponse = {
offices: ApiOffice[]
}

// API sends "Status.SHOW" or "SHOW". HIDE (and unknown) are omitted from the list.
function readOnlineStatus(value: string | null): 'SHOW' | 'DISABLE' | null {
if (!value) {
return null
}
if (value === 'HIDE' || value.endsWith('.HIDE')) {
return null
}
if (value === 'SHOW' || value.endsWith('.SHOW')) {
return 'SHOW'
}
if (value === 'DISABLE' || value.endsWith('.DISABLE')) {
return 'DISABLE'
}
return null
}

// Fetch offices for a selected service. Keeps SHOW and DISABLE; omits HIDE/deleted.
// Throws on fetch/HTTP/parse failure so the page can tell error apart from empty [].
export async function getServiceLocations(serviceId: number): Promise<ServiceLocation[]> {
const url = `${await getApiBaseUrl()}/offices?service_id=${serviceId}`

let body: ApiOfficesResponse
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error('Failed to load locations')
}
body = (await response.json()) as ApiOfficesResponse
} catch {
throw new Error('Failed to load locations')
}

const locations: ServiceLocation[] = []

for (const row of body.offices ?? []) {
if (row.deleted) {
continue
}

const status = readOnlineStatus(row.online_status)
if (!status) {
continue
}

const nextAppointmentDate = row.next_appointment_date ?? null
const appointmentsDisabled = status === 'DISABLE' || !row.appointments_enabled_ind

locations.push({
id: row.office_id,
name: row.office_name.trim(),
address: row.civic_address?.trim() || '',
latitude: row.latitude,
longitude: row.longitude,
appointmentMessage: row.office_appointment_message?.trim() || '',
nextAppointmentDate,
appointmentsDisabled,
isBookable: !appointmentsDisabled && nextAppointmentDate !== null,
})
}

return locations
}
4 changes: 4 additions & 0 deletions appointment-booking/app/api/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export type Service = {
name: string
onlineAvailability: OnlineAvailability
isOnlineBookable: boolean
/** Knowledge-test (DLKT) service; used to hide offices with no DLKT capacity. */
isDlkt: boolean
}

// API response row from GET /api/v1/services/.
Expand All @@ -19,6 +21,7 @@ type ApiService = {
display_dashboard_ind: number
online_availability: string | null
deleted: string | null
is_dlkt?: boolean | null
}

type ApiServicesResponse = {
Expand Down Expand Up @@ -73,6 +76,7 @@ function mapRow(row: ApiService, availability: OnlineAvailability): Service {
name,
onlineAvailability: availability,
isOnlineBookable: availability === 'SHOW',
isDlkt: row.is_dlkt === true,
}
}

Expand Down
10 changes: 6 additions & 4 deletions appointment-booking/app/booking/booking-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// 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 { ServiceLocation } from '../api/service-locations'
import type { Service } from '../api/services'
import { addJsonToSession, getJsonFromSession, removeFromSession } from '../auth/session'
import { SessionKeys } from '../auth/session-keys'
Expand All @@ -19,14 +19,16 @@ function persistJson(key: string, value: unknown) {
export function BookingProvider({ children }: { children: ReactNode }) {
const [isReady, setIsReady] = useState(false)
const [selectedService, setSelectedServiceState] = useState<Service | null>(null)
const [selectedLocation, setSelectedLocationState] = useState<Location | null>(null)
const [selectedLocation, setSelectedLocationState] = useState<ServiceLocation | null>(null)
const [selectedSlot, setSelectedSlotState] = useState<BookingSlot | null>(null)

useEffect(() => {
// Restore saved choices after the page first loads in the browser.
const id = window.setTimeout(() => {
setSelectedServiceState(getJsonFromSession<Service>(SessionKeys.BookingSelectedService))
setSelectedLocationState(getJsonFromSession<Location>(SessionKeys.BookingSelectedLocation))
setSelectedLocationState(
getJsonFromSession<ServiceLocation>(SessionKeys.BookingSelectedLocation),
)
setSelectedSlotState(getJsonFromSession<BookingSlot>(SessionKeys.BookingSelectedSlot))
setIsReady(true)
}, 0)
Expand All @@ -40,7 +42,7 @@ export function BookingProvider({ children }: { children: ReactNode }) {
persistJson(SessionKeys.BookingSelectedService, service)
}

function setSelectedLocation(location: Location | null) {
function setSelectedLocation(location: ServiceLocation | null) {
setSelectedLocationState(location)
setSelectedSlot(null)
persistJson(SessionKeys.BookingSelectedLocation, location)
Expand Down
6 changes: 3 additions & 3 deletions appointment-booking/app/booking/booking-store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createContext } from 'react'

import type { Location } from '../api/locations'
import type { ServiceLocation } from '../api/service-locations'
import type { Service } from '../api/services'

// The chosen appointment date and time. Date is YYYY-MM-DD; times are HH:MM.
Expand All @@ -14,8 +14,8 @@ export type BookingContextValue = {
isReady: boolean
selectedService: Service | null
setSelectedService: (service: Service | null) => void
selectedLocation: Location | null
setSelectedLocation: (location: Location | null) => void
selectedLocation: ServiceLocation | null
setSelectedLocation: (location: ServiceLocation | null) => void
selectedSlot: BookingSlot | null
setSelectedSlot: (slot: BookingSlot | null) => void
}
Expand Down
76 changes: 76 additions & 0 deletions appointment-booking/app/components/BookingDetailCallout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Callout, InlineAlert, Text } from '@bcgov/design-system-react-components'

import type { ServiceLocation } from '~/api/service-locations'
import type { Service } from '~/api/services'
import type { BookingSlot } from '~/booking/booking-store'

type BookingDetailCalloutProps = {
selectedService: Service | null
selectedLocation: ServiceLocation | null
/** When set (datetime step), 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">
<div className="booking-detail-callout-content">
<Text>
{selectedService ? (
<>
Selected service - <strong>{selectedService.name}</strong>
<br />
</>
) : (
<>Please go back to choose a service before selecting a location.</>
)}
{selectedLocation ? (
<>
{!selectedService ? <br /> : null}
Appointment location - <strong>{selectedLocation.name}</strong>
{selectedLocation.address ? (
<>
<br />
Address - <strong>{selectedLocation.address}</strong>
</>
) : null}
</>
) : selectedService ? (
<>Select a location from the list to view details.</>
) : null}
{selectedSlot && formatDate && formatTimeRange ? (
<>
<br />
Appointment date - <strong>{formatDate(selectedSlot.date)}</strong>
<br />
Appointment time -{' '}
<strong>{formatTimeRange(selectedSlot.startTime, selectedSlot.endTime)}</strong>
</>
) : null}
</Text>
{selectedLocation?.appointmentsDisabled === true ? (
<InlineAlert variant="info" title="Availability">
Appointments are not available at this location. Please select another location.
</InlineAlert>
) : selectedLocation?.isBookable === false ? (
<InlineAlert variant="info" title="Availability">
No appointments available. Select another location, or visit for walk-in service.
</InlineAlert>
) : null}
{selectedLocation?.appointmentMessage ? (
<InlineAlert variant="info" title="Location notice">
{selectedLocation.appointmentMessage}
</InlineAlert>
) : null}
</div>
</Callout>
)
}
Loading
Loading