diff --git a/packages/backend/src/booking/services/public-booking.service.ts b/packages/backend/src/booking/services/public-booking.service.ts index 0231e0d11..9a4b8312c 100644 --- a/packages/backend/src/booking/services/public-booking.service.ts +++ b/packages/backend/src/booking/services/public-booking.service.ts @@ -138,6 +138,11 @@ const buildGuestActionUrl = ( CONFIG.FRONTEND_URL, ).href; +const guestActionUrls = (reservationId: string, token: string) => ({ + cancelUrl: buildGuestActionUrl("cancel", reservationId, token), + rescheduleUrl: buildGuestActionUrl("reschedule", reservationId, token), +}); + const assertGuestEmail = (email: string): void => { if (!isGuestEmail(email)) { throw bookingError("INVALID_INPUT", "Invalid guest email"); @@ -567,13 +572,7 @@ export class PublicBookingService { const hostDisplayName = await getHostDisplayName(page.userId); const cancelToken = generateCancelToken(); const reservationId = mongoService.objectId(); - const cancelUrl = buildGuestActionUrl( - "cancel", - reservationId.toString(), - cancelToken, - ); - const rescheduleUrl = buildGuestActionUrl( - "reschedule", + const { cancelUrl, rescheduleUrl } = guestActionUrls( reservationId.toString(), cancelToken, ); @@ -713,13 +712,7 @@ export class PublicBookingService { const guestName = input.name ?? reservation.guestName; const notes = nextGuestNotes(input.notes, reservation.notes); const hostDisplayName = await getHostDisplayName(page.userId); - const cancelUrl = buildGuestActionUrl( - "cancel", - reservationId.toString(), - input.token, - ); - const rescheduleUrl = buildGuestActionUrl( - "reschedule", + const { cancelUrl, rescheduleUrl } = guestActionUrls( reservationId.toString(), input.token, ); @@ -772,13 +765,7 @@ export class PublicBookingService { const slotStart = new Date(input.slotStart); const slotEnd = slotEndForStart(slotStart, input.durationMinutes); const hostDisplayName = await getHostDisplayName(page.userId); - const cancelUrl = buildGuestActionUrl( - "cancel", - reservationId.toString(), - input.token, - ); - const rescheduleUrl = buildGuestActionUrl( - "reschedule", + const { cancelUrl, rescheduleUrl } = guestActionUrls( reservationId.toString(), input.token, ); diff --git a/packages/sync/src/providers/apple/apple-calendar.adapter.ts b/packages/sync/src/providers/apple/apple-calendar.adapter.ts index 446cb20b4..238230619 100644 --- a/packages/sync/src/providers/apple/apple-calendar.adapter.ts +++ b/packages/sync/src/providers/apple/apple-calendar.adapter.ts @@ -1,15 +1,12 @@ -import { - type CalendarAccessRole, - type SyncCalendarCapabilities, -} from "@core/types/sync/connection.contracts"; +import { type CalendarAccessRole } from "@core/types/sync/connection.contracts"; import { type CaldavClient, CaldavClientError, - type CaldavClientErrorReason, createCaldavClient, type DiscoveredCaldavCalendar, discoverCalendars as discoverCaldavCalendars, } from "@sync/providers/apple/caldav-client"; +import { capabilitiesForAccessRole } from "@sync/providers/calendar-role-capabilities"; import { type CalendarDiscovery, type DiscoveredCalendar, @@ -25,36 +22,6 @@ export type AppleCalendarClientFactory = ( const defaultClientFactory: AppleCalendarClientFactory = (username, password) => createCaldavClient({ username, password }); -const CAPABILITIES_BY_ROLE: Record< - CalendarAccessRole, - SyncCalendarCapabilities -> = { - owner: { - canReadEvents: true, - canWriteEvents: true, - canReadBusy: true, - canInviteAttendees: true, - }, - editor: { - canReadEvents: true, - canWriteEvents: true, - canReadBusy: true, - canInviteAttendees: true, - }, - viewer: { - canReadEvents: true, - canWriteEvents: false, - canReadBusy: true, - canInviteAttendees: false, - }, - busyOnly: { - canReadEvents: false, - canWriteEvents: false, - canReadBusy: true, - canInviteAttendees: false, - }, -}; - // Apple iCloud implementation of the calendar-discovery port. The access token // custody hands in is the app-specific password; the account email is bound // when the adapter is constructed for a connection. @@ -87,11 +54,9 @@ export class AppleCalendarAdapter implements ProviderCalendarAdapter { return { calendars, cursor: null }; } catch (error) { if (error instanceof CaldavClientError) { - throw new ProviderCalendarError( - mapDiscoveryReason(error.reason), - error.message, - { cause: error }, - ); + throw new ProviderCalendarError(error.reason, error.message, { + cause: error, + }); } throw error; } @@ -134,7 +99,7 @@ function mapCalendar(calendar: DiscoveredCaldavCalendar): DiscoveredCalendar { primary: false, active: true, accessRole, - capabilities: CAPABILITIES_BY_ROLE[accessRole], + capabilities: capabilitiesForAccessRole(accessRole), createsGoogleMeet: false, }; } @@ -144,9 +109,3 @@ function accountDefaultName(username: string): string { if (at <= 0) return username; return username.slice(0, at); } - -function mapDiscoveryReason( - reason: CaldavClientErrorReason, -): "authExpired" | "transient" | "discoveryFailed" { - return reason; -} diff --git a/packages/sync/src/providers/apple/apple-event-writer.adapter.test.ts b/packages/sync/src/providers/apple/apple-event-writer.adapter.test.ts index 06211baa8..83fab6364 100644 --- a/packages/sync/src/providers/apple/apple-event-writer.adapter.test.ts +++ b/packages/sync/src/providers/apple/apple-event-writer.adapter.test.ts @@ -5,8 +5,8 @@ import { type AppleEventWriterApi, appendExdateToMaster, eventResourceHref, - parseAppleInstanceId, } from "@sync/providers/apple/apple-event-writer.adapter"; +import { parseAppleInstanceId } from "@sync/providers/apple/apple-instance-id"; import { type CaldavResponse } from "@sync/providers/apple/caldav-client"; const CALENDAR = "https://caldav.icloud.com/123/calendars/home/"; diff --git a/packages/sync/src/providers/apple/apple-event-writer.adapter.ts b/packages/sync/src/providers/apple/apple-event-writer.adapter.ts index b9c2ad0d5..e2171518e 100644 --- a/packages/sync/src/providers/apple/apple-event-writer.adapter.ts +++ b/packages/sync/src/providers/apple/apple-event-writer.adapter.ts @@ -10,7 +10,11 @@ import { serializeAppleEventInstance, serializeAppleEventPatch, } from "@sync/providers/apple/apple-event.serializer"; -import { appleInstanceEventId } from "@sync/providers/apple/apple-instance-id"; +import { + appleInstanceEventId, + type ParsedAppleInstanceId, + parseAppleInstanceId, +} from "@sync/providers/apple/apple-instance-id"; import { type CaldavClient, type CaldavResponse, @@ -447,46 +451,6 @@ class CaldavAppleEventWriterApi implements AppleEventWriterApi { } } -interface ParsedAppleInstanceId { - readonly seriesUid: string; - readonly originalStartAt: string; - readonly scheduleKind: "timed" | "allDay"; -} - -export function parseAppleInstanceId( - providerEventId: string, -): ParsedAppleInstanceId | null { - const timedMatch = /^(.+)_(\d{8}T\d{6}Z)$/.exec(providerEventId); - if (timedMatch) { - const suffix = timedMatch[2]!; - const instant = dayjs.utc(suffix, RFC5545, true); - if (!instant.isValid()) return null; - return { - seriesUid: timedMatch[1]!, - originalStartAt: instant.toDate().toISOString(), - scheduleKind: "timed", - }; - } - - const allDayMatch = /^(.+)_(\d{8})$/.exec(providerEventId); - if (allDayMatch) { - const suffix = allDayMatch[2]!; - const instant = dayjs.utc( - suffix, - dayjs.DateFormat.YEAR_MONTH_DAY_COMPACT_FORMAT, - true, - ); - if (!instant.isValid()) return null; - return { - seriesUid: allDayMatch[1]!, - originalStartAt: instant.toDate().toISOString(), - scheduleKind: "allDay", - }; - } - - return null; -} - export function eventResourceHref(calendarUrl: string, uid: string): string { const base = calendarUrl.endsWith("/") ? calendarUrl : `${calendarUrl}/`; return `${base}${uid}.ics`; diff --git a/packages/sync/src/providers/apple/apple-instance-id.ts b/packages/sync/src/providers/apple/apple-instance-id.ts index 6b10dc3bf..f9c9de0bb 100644 --- a/packages/sync/src/providers/apple/apple-instance-id.ts +++ b/packages/sync/src/providers/apple/apple-instance-id.ts @@ -1,19 +1,29 @@ -import dayjs from "@core/util/date/dayjs"; +import { + parseRecurringInstanceEventId, + recurringInstanceEventId, +} from "@sync/providers/recurring-instance-id"; // Apple recurring-instance ids mirror Google's `{seriesId}_{originalStart}` // suffix so sparse cancellations and reader href mappings stay aligned with // Compass projection recurrenceIds. iCloud shares one UID across master and // exception VEVENTs in a resource; the suffix disambiguates instances. -export function appleInstanceEventId( - seriesProviderEventId: string, - originalStartAt: string, - scheduleKind: "timed" | "allDay", -): string { - const instant = dayjs.utc(originalStartAt); - const suffix = - scheduleKind === "allDay" - ? instant.format(dayjs.DateFormat.YEAR_MONTH_DAY_COMPACT_FORMAT) - : instant.format(dayjs.DateFormat.RFC5545); - return `${seriesProviderEventId}_${suffix}`; +export const appleInstanceEventId = recurringInstanceEventId; + +export interface ParsedAppleInstanceId { + readonly seriesUid: string; + readonly originalStartAt: string; + readonly scheduleKind: "timed" | "allDay"; +} + +export function parseAppleInstanceId( + providerEventId: string, +): ParsedAppleInstanceId | null { + const parsed = parseRecurringInstanceEventId(providerEventId); + if (!parsed) return null; + return { + seriesUid: parsed.seriesProviderId, + originalStartAt: parsed.recurrenceId, + scheduleKind: parsed.scheduleKind, + }; } diff --git a/packages/sync/src/providers/calendar-role-capabilities.ts b/packages/sync/src/providers/calendar-role-capabilities.ts new file mode 100644 index 000000000..8c8c066f3 --- /dev/null +++ b/packages/sync/src/providers/calendar-role-capabilities.ts @@ -0,0 +1,43 @@ +import { + type CalendarAccessRole, + type SyncCalendarCapabilities, +} from "@core/types/sync/connection.contracts"; + +// Operational capabilities implied by each access role. Invite ability follows +// write access — providers expose no separate per-calendar attendee-invite flag. + +const CAPABILITIES_BY_ROLE: Record< + CalendarAccessRole, + SyncCalendarCapabilities +> = { + owner: { + canReadEvents: true, + canWriteEvents: true, + canReadBusy: true, + canInviteAttendees: true, + }, + editor: { + canReadEvents: true, + canWriteEvents: true, + canReadBusy: true, + canInviteAttendees: true, + }, + viewer: { + canReadEvents: true, + canWriteEvents: false, + canReadBusy: true, + canInviteAttendees: false, + }, + busyOnly: { + canReadEvents: false, + canWriteEvents: false, + canReadBusy: true, + canInviteAttendees: false, + }, +}; + +export function capabilitiesForAccessRole( + role: CalendarAccessRole, +): SyncCalendarCapabilities { + return CAPABILITIES_BY_ROLE[role]; +} diff --git a/packages/sync/src/providers/google/google-calendar.adapter.ts b/packages/sync/src/providers/google/google-calendar.adapter.ts index edd995072..ca9f1fa1b 100644 --- a/packages/sync/src/providers/google/google-calendar.adapter.ts +++ b/packages/sync/src/providers/google/google-calendar.adapter.ts @@ -5,10 +5,8 @@ import { type gSchema$Calendar, type gSchema$CalendarListEntry, } from "@core/types/gcal"; -import { - type CalendarAccessRole, - type SyncCalendarCapabilities, -} from "@core/types/sync/connection.contracts"; +import { type CalendarAccessRole } from "@core/types/sync/connection.contracts"; +import { capabilitiesForAccessRole } from "@sync/providers/calendar-role-capabilities"; import { googleFailureCause, googleStatus, @@ -174,38 +172,6 @@ const ACCESS_ROLE_BY_GOOGLE: Record = { freeBusyReader: "busyOnly", }; -// Operational capabilities implied by each role. Invite ability follows write -// access — Google exposes no separate per-calendar attendee-invite flag. -const CAPABILITIES_BY_ROLE: Record< - CalendarAccessRole, - SyncCalendarCapabilities -> = { - owner: { - canReadEvents: true, - canWriteEvents: true, - canReadBusy: true, - canInviteAttendees: true, - }, - editor: { - canReadEvents: true, - canWriteEvents: true, - canReadBusy: true, - canInviteAttendees: true, - }, - viewer: { - canReadEvents: true, - canWriteEvents: false, - canReadBusy: true, - canInviteAttendees: false, - }, - busyOnly: { - canReadEvents: false, - canWriteEvents: false, - canReadBusy: true, - canInviteAttendees: false, - }, -}; - // Map one Google calendar-list entry to provider-neutral facts. An entry // without an id is unusable (it cannot be keyed or persisted), so it is dropped. async function mapCalendar( @@ -229,7 +195,7 @@ async function mapCalendar( // from before the hide, not a signal Google is still showing it. active: item.deleted !== true && item.hidden !== true, accessRole, - capabilities: CAPABILITIES_BY_ROLE[accessRole], + capabilities: capabilitiesForAccessRole(accessRole), createsGoogleMeet: calendarCreatesGoogleMeet( item.conferenceProperties?.allowedConferenceSolutionTypes, ), diff --git a/packages/sync/src/providers/google/google-instance-id.ts b/packages/sync/src/providers/google/google-instance-id.ts index c5a6fb4dc..84a447984 100644 --- a/packages/sync/src/providers/google/google-instance-id.ts +++ b/packages/sync/src/providers/google/google-instance-id.ts @@ -1,4 +1,8 @@ -import dayjs from "@core/util/date/dayjs"; +import { + type ParsedRecurringInstanceId, + parseRecurringInstanceEventId, + recurringInstanceEventId, +} from "@sync/providers/recurring-instance-id"; // Google instance ids are `{seriesId}_{originalStart}`: all-day YYYYMMDD, // timed YYYYMMDDTHHMMSSZ (always UTC). Compass mints these when addressing an @@ -7,60 +11,7 @@ import dayjs from "@core/util/date/dayjs"; // must stay byte-identical so a sparse cancellation reconstructs the same // recurrenceId the series projection uses. -export function googleInstanceEventId( - seriesProviderEventId: string, - originalStartAt: string, - scheduleKind: "timed" | "allDay", -): string { - const instant = dayjs.utc(originalStartAt); - const suffix = - scheduleKind === "allDay" - ? instant.format(dayjs.DateFormat.YEAR_MONTH_DAY_COMPACT_FORMAT) - : instant.format(dayjs.DateFormat.RFC5545); - return `${seriesProviderEventId}_${suffix}`; -} +export type ParsedGoogleInstanceId = ParsedRecurringInstanceId; -export interface ParsedGoogleInstanceId { - readonly seriesProviderId: string; - // Compass recurrence identity: UTC ISO with milliseconds (Date#toISOString). - readonly recurrenceId: string; - readonly scheduleKind: "timed" | "allDay"; -} - -const TIMED_INSTANCE_ID = /^(.*)_(\d{8}T\d{6}Z)$/; -const ALL_DAY_INSTANCE_ID = /^(.*)_(\d{8})$/; - -export function parseGoogleInstanceEventId( - providerEventId: string, -): ParsedGoogleInstanceId | null { - const timed = TIMED_INSTANCE_ID.exec(providerEventId); - if (timed) { - return parseSuffix(timed[1], timed[2], "timed", dayjs.DateFormat.RFC5545); - } - const allDay = ALL_DAY_INSTANCE_ID.exec(providerEventId); - if (allDay) { - return parseSuffix( - allDay[1], - allDay[2], - "allDay", - dayjs.DateFormat.YEAR_MONTH_DAY_COMPACT_FORMAT, - ); - } - return null; -} - -function parseSuffix( - seriesProviderId: string | undefined, - suffix: string | undefined, - scheduleKind: "timed" | "allDay", - format: string, -): ParsedGoogleInstanceId | null { - if (!seriesProviderId || !suffix) return null; - const instant = dayjs.utc(suffix, format, true); - if (!instant.isValid()) return null; - return { - seriesProviderId, - recurrenceId: instant.toDate().toISOString(), - scheduleKind, - }; -} +export const googleInstanceEventId = recurringInstanceEventId; +export const parseGoogleInstanceEventId = parseRecurringInstanceEventId; diff --git a/packages/sync/src/providers/recurring-instance-id.ts b/packages/sync/src/providers/recurring-instance-id.ts new file mode 100644 index 000000000..e83127288 --- /dev/null +++ b/packages/sync/src/providers/recurring-instance-id.ts @@ -0,0 +1,64 @@ +import dayjs from "@core/util/date/dayjs"; + +// Recurring-instance ids are `{seriesId}_{originalStart}`: all-day YYYYMMDD, +// timed YYYYMMDDTHHMMSSZ (always UTC). Google mints this form on the wire; +// Apple mirrors it so sparse cancellations and reader href mappings stay +// aligned with Compass projection recurrenceIds. + +export interface ParsedRecurringInstanceId { + readonly seriesProviderId: string; + // Compass recurrence identity: UTC ISO with milliseconds (Date#toISOString). + readonly recurrenceId: string; + readonly scheduleKind: "timed" | "allDay"; +} + +const TIMED_INSTANCE_ID = /^(.*)_(\d{8}T\d{6}Z)$/; +const ALL_DAY_INSTANCE_ID = /^(.*)_(\d{8})$/; + +export function recurringInstanceEventId( + seriesProviderEventId: string, + originalStartAt: string, + scheduleKind: "timed" | "allDay", +): string { + const instant = dayjs.utc(originalStartAt); + const suffix = + scheduleKind === "allDay" + ? instant.format(dayjs.DateFormat.YEAR_MONTH_DAY_COMPACT_FORMAT) + : instant.format(dayjs.DateFormat.RFC5545); + return `${seriesProviderEventId}_${suffix}`; +} + +export function parseRecurringInstanceEventId( + providerEventId: string, +): ParsedRecurringInstanceId | null { + const timed = TIMED_INSTANCE_ID.exec(providerEventId); + if (timed) { + return parseSuffix(timed[1], timed[2], "timed", dayjs.DateFormat.RFC5545); + } + const allDay = ALL_DAY_INSTANCE_ID.exec(providerEventId); + if (allDay) { + return parseSuffix( + allDay[1], + allDay[2], + "allDay", + dayjs.DateFormat.YEAR_MONTH_DAY_COMPACT_FORMAT, + ); + } + return null; +} + +function parseSuffix( + seriesProviderId: string | undefined, + suffix: string | undefined, + scheduleKind: "timed" | "allDay", + format: string, +): ParsedRecurringInstanceId | null { + if (!seriesProviderId || !suffix) return null; + const instant = dayjs.utc(suffix, format, true); + if (!instant.isValid()) return null; + return { + seriesProviderId, + recurrenceId: instant.toDate().toISOString(), + scheduleKind, + }; +} diff --git a/packages/sync/src/server/connection.routes.ts b/packages/sync/src/server/connection.routes.ts index e4cfe02f6..aed6be912 100644 --- a/packages/sync/src/server/connection.routes.ts +++ b/packages/sync/src/server/connection.routes.ts @@ -36,7 +36,6 @@ import { ProviderKindSchema, type TenantId, } from "@core/types/sync/identity.contracts"; -import dayjs from "@core/util/date/dayjs"; import { type SyncExecutionMode } from "@sync/config/sync.config"; import { CredentialCustody } from "@sync/credentials/credential-custody.service"; import { @@ -51,10 +50,7 @@ import { import { type DerivedConnectionState } from "@sync/domain/connection-state"; import { refreshConnectionState } from "@sync/domain/connection-state-refresh.service"; import { assembleEventInstances } from "@sync/domain/event-instance-assembly"; -import { - HORIZON_FUTURE_MONTHS, - HORIZON_PAST_MONTHS, -} from "@sync/domain/horizon"; +import { syncHorizon } from "@sync/domain/horizon"; import { signOAuthState, verifyOAuthState } from "@sync/oauth/oauth-state"; import { isMicrosoftConsentRequired } from "@sync/providers/microsoft/microsoft-consent"; import { @@ -275,14 +271,7 @@ export function registerConnectionRoutes( } const now = deps.now ? deps.now() : Date.now(); - const start = maxDate( - new Date(query.start), - dayjs(now).subtract(HORIZON_PAST_MONTHS, "month").toDate(), - ); - const end = minDate( - new Date(query.end), - dayjs(now).add(HORIZON_FUTURE_MONTHS, "month").toDate(), - ); + const { start, end } = clampQueryToHorizon(query.start, query.end, now); if (start >= end) { const empty: EventInstanceListResponse = { instances: [], @@ -393,14 +382,7 @@ export function registerConnectionRoutes( // Clamp the requested range to the horizon. A range that falls entirely // outside it collapses to empty rather than scanning anything. const now = deps.now ? deps.now() : Date.now(); - const start = maxDate( - new Date(query.start), - dayjs(now).subtract(HORIZON_PAST_MONTHS, "month").toDate(), - ); - const end = minDate( - new Date(query.end), - dayjs(now).add(HORIZON_FUTURE_MONTHS, "month").toDate(), - ); + const { start, end } = clampQueryToHorizon(query.start, query.end, now); if (start >= end) { // The window collapsed entirely outside the horizon: nothing to read, and // nothing verified, so fail closed. Build it through the mapper so the @@ -896,10 +878,6 @@ export function registerConnectionRoutes( // Redirect the browser to the server-configured post-connect URL with a coarse // status. The base is from config, never the request, so it can't be abused as // an open redirect; status is a fixed label, carrying no provider detail. -function resolveAuthForConnectionApi(deps: ConnectionApiDeps) { - return resolveAuthFrom(deps.registry); -} - function redirectAfterConnect( deps: ConnectionApiDeps, res: Response, @@ -974,7 +952,7 @@ async function linkConnection( try { const custody = new CredentialCustody( repos.credentials, - resolveAuthForConnectionApi(deps), + resolveAuthFrom(deps.registry), undefined, undefined, deps.credentialAtRestKey, @@ -1137,5 +1115,17 @@ function decodeOccurrenceCursor( } } +function clampQueryToHorizon( + start: string, + end: string, + now: number, +): { start: Date; end: Date } { + const horizon = syncHorizon(new Date(now)); + return { + start: maxDate(new Date(start), horizon.start), + end: minDate(new Date(end), horizon.end), + }; +} + const maxDate = (a: Date, b: Date): Date => (a > b ? a : b); const minDate = (a: Date, b: Date): Date => (a < b ? a : b); diff --git a/packages/web/src/booking/PublicBookingAlert.tsx b/packages/web/src/booking/PublicBookingAlert.tsx new file mode 100644 index 000000000..f872b6191 --- /dev/null +++ b/packages/web/src/booking/PublicBookingAlert.tsx @@ -0,0 +1,22 @@ +import { type Ref } from "react"; + +interface PublicBookingAlertProps { + message: string; + alertRef: Ref; +} + +export function PublicBookingAlert({ + message, + alertRef, +}: PublicBookingAlertProps) { + return ( +

+ {message} +

+ ); +} diff --git a/packages/web/src/booking/PublicBookingConfirmationView.tsx b/packages/web/src/booking/PublicBookingConfirmationView.tsx index 6623ad288..f66fd1591 100644 --- a/packages/web/src/booking/PublicBookingConfirmationView.tsx +++ b/packages/web/src/booking/PublicBookingConfirmationView.tsx @@ -1,6 +1,5 @@ import { CheckCircleIcon } from "@phosphor-icons/react"; -import { PublicBookingCopyCancelUrl } from "@web/booking/PublicBookingCopyCancelUrl"; -import { PublicBookingCopyRescheduleUrl } from "@web/booking/PublicBookingCopyRescheduleUrl"; +import { PublicBookingCopyGuestAction } from "@web/booking/PublicBookingCopyGuestAction"; import { PublicBookingLayout } from "@web/booking/PublicBookingLayout"; import { PublicBookingSlotSummary } from "@web/booking/PublicBookingSlotSummary"; import { PUBLIC_BOOKING_HEADING_CLASS } from "@web/booking/PublicBookingStatusMessage"; @@ -94,10 +93,18 @@ export function PublicBookingConfirmationView({ aria-label="Booking actions" > {cancelUrl ? ( - + ) : null} {rescheduleUrl ? ( - + ) : null} ) : null} diff --git a/packages/web/src/booking/PublicBookingCopyCancelUrl.tsx b/packages/web/src/booking/PublicBookingCopyCancelUrl.tsx deleted file mode 100644 index 15469cde9..000000000 --- a/packages/web/src/booking/PublicBookingCopyCancelUrl.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { PublicBookingCopyGuestAction } from "@web/booking/PublicBookingCopyGuestAction"; - -interface PublicBookingCopyCancelUrlProps { - cancelUrl: string; -} - -export function PublicBookingCopyCancelUrl({ - cancelUrl, -}: PublicBookingCopyCancelUrlProps) { - return ( - - ); -} diff --git a/packages/web/src/booking/PublicBookingCopyRescheduleUrl.tsx b/packages/web/src/booking/PublicBookingCopyRescheduleUrl.tsx deleted file mode 100644 index b842b2219..000000000 --- a/packages/web/src/booking/PublicBookingCopyRescheduleUrl.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { PublicBookingCopyGuestAction } from "@web/booking/PublicBookingCopyGuestAction"; - -interface PublicBookingCopyRescheduleUrlProps { - rescheduleUrl: string; -} - -export function PublicBookingCopyRescheduleUrl({ - rescheduleUrl, -}: PublicBookingCopyRescheduleUrlProps) { - return ( - - ); -} diff --git a/packages/web/src/booking/PublicBookingLayout.tsx b/packages/web/src/booking/PublicBookingLayout.tsx index 87a645c3e..c76744c90 100644 --- a/packages/web/src/booking/PublicBookingLayout.tsx +++ b/packages/web/src/booking/PublicBookingLayout.tsx @@ -1,5 +1,8 @@ import { type PropsWithChildren } from "react"; +export const PUBLIC_BOOKING_STICKY_STEP_CLASS = + "sticky bottom-0 z-10 -mx-4 border-border border-t bg-background px-4 py-3 sm:static sm:mx-0 sm:border-0 sm:px-0 sm:py-0"; + interface PublicBookingLayoutProps extends PropsWithChildren { wide?: boolean; } diff --git a/packages/web/src/booking/PublicBookingPage.tsx b/packages/web/src/booking/PublicBookingPage.tsx index 0526a9adc..2a438e98f 100644 --- a/packages/web/src/booking/PublicBookingPage.tsx +++ b/packages/web/src/booking/PublicBookingPage.tsx @@ -1,9 +1,13 @@ import { useParams } from "@tanstack/react-router"; import { useEffect, useRef } from "react"; import { PublicBookingNotFoundError } from "@web/api/public-booking.api"; +import { PublicBookingAlert } from "@web/booking/PublicBookingAlert"; import { PublicBookingDetailsStep } from "@web/booking/PublicBookingDetailsStep"; import { PublicBookingGuestForm } from "@web/booking/PublicBookingGuestForm"; -import { PublicBookingLayout } from "@web/booking/PublicBookingLayout"; +import { + PUBLIC_BOOKING_STICKY_STEP_CLASS, + PublicBookingLayout, +} from "@web/booking/PublicBookingLayout"; import { PublicBookingPicker } from "@web/booking/PublicBookingPicker"; import { PublicBookingSkipLink } from "@web/booking/PublicBookingSkipLink"; import { @@ -20,9 +24,6 @@ import { } from "@web/booking/use-booking-heading-focus"; import { usePublicBookingFlow } from "@web/booking/use-public-booking-flow"; -const STICKY_STEP_CLASS_NAME = - "sticky bottom-0 z-10 -mx-4 border-border border-t bg-background px-4 py-3 sm:static sm:mx-0 sm:border-0 sm:px-0 sm:py-0"; - export function PublicBookingPage() { const { username } = useParams({ from: "/book/$username" }); const flow = usePublicBookingFlow(); @@ -126,18 +127,14 @@ export function PublicBookingPage() { {flow.alertMessage ? ( -

- {flow.alertMessage} -

+ ) : null} {flow.showDetailsStep && flow.selectedSlotStart ? ( -
+
{flow.showConflictForm ? ( -
+
{flow.alertMessage ? ( -

- {flow.alertMessage} -

+ ) : null} {flow.selectedSlotStart ? ( -
+