@riverbankcms/sdk 0.72.0 → 0.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/README.md +1 -1
  2. package/dist/{PreviewEditorSidebar-44T64SAR.mjs → PreviewEditorSidebar-U2IHCBYN.mjs} +3 -3
  3. package/dist/{PreviewEditorUI-4WKHZTXW.mjs → PreviewEditorUI-ZGH2RQNP.mjs} +3 -3
  4. package/dist/SdkPreviewModeRuntime-MTUGBTLQ.mjs +8 -0
  5. package/dist/_dts/ai/src/contracts/proposals.d.ts +10 -10
  6. package/dist/_dts/api/src/cache-tags.d.ts +22 -0
  7. package/dist/_dts/api/src/contentRuntime.d.ts +1 -0
  8. package/dist/_dts/api/src/endpoints.d.ts +24 -24
  9. package/dist/_dts/api/src/publicExperienceEndpoints.d.ts +2 -1
  10. package/dist/_dts/block-form/src/blockDraftSaveability.d.ts +4 -1
  11. package/dist/_dts/blocks/src/client/booking-form.d.ts +2 -0
  12. package/dist/_dts/blocks/src/client/carousel.d.ts +1 -0
  13. package/dist/_dts/blocks/src/client/course-registration.d.ts +2 -0
  14. package/dist/_dts/blocks/src/client/events.d.ts +4 -0
  15. package/dist/_dts/blocks/src/client/form.d.ts +2 -0
  16. package/dist/_dts/blocks/src/client/header.d.ts +1 -0
  17. package/dist/_dts/blocks/src/client/location-map.d.ts +1 -0
  18. package/dist/_dts/blocks/src/client/newsletter-form.d.ts +2 -0
  19. package/dist/_dts/blocks/src/client/shop.d.ts +6 -0
  20. package/dist/_dts/blocks/src/lib/video.d.ts +2 -0
  21. package/dist/_dts/blocks/src/system/manifest/fieldValidation/index.d.ts +21 -1
  22. package/dist/_dts/blocks/src/system/manifest/schema.d.ts +6 -6
  23. package/dist/_dts/blocks/src/system/node/fragments/backgroundLayer.d.ts +15 -1
  24. package/dist/_dts/blocks/src/system/node/fragments/styledSection.d.ts +11 -0
  25. package/dist/_dts/blocks/src/system/runtime/components/registry.client.d.ts +1 -0
  26. package/dist/_dts/blocks/src/system/runtime/header/enhanceHeaderSection.d.ts +13 -0
  27. package/dist/_dts/blocks/src/system/runtime/nodes/booking-form.headless.state.d.ts +3 -2
  28. package/dist/_dts/blocks/src/system/runtime/nodes/events/index.d.ts +3 -0
  29. package/dist/_dts/blocks/src/system/runtime/nodes/header-section.client.d.ts +1 -0
  30. package/dist/_dts/blocks/src/system/runtime/nodes/header-section.server.d.ts +1 -0
  31. package/dist/_dts/blocks/src/system/runtime/nodes/media.d.ts +6 -0
  32. package/dist/_dts/content-editor/src/hooks/validateBlocksForPublish.d.ts +5 -0
  33. package/dist/_dts/preview-next/src/client/preview/SiteChromeEditableSurface.d.ts +12 -2
  34. package/dist/_dts/sdk/src/public-api/contracts.d.ts +1 -0
  35. package/dist/_dts/sdk/src/rendering/components/Layout.d.ts +2 -2
  36. package/dist/_dts/sdk/src/rendering/islands/deferredVideoEmbeds.d.ts +2 -0
  37. package/dist/_dts/sdk/src/rendering/islands/enhancers/accordion.d.ts +1 -0
  38. package/dist/_dts/sdk/src/rendering/islands/enhancers/bookingForm.d.ts +1 -0
  39. package/dist/_dts/sdk/src/rendering/islands/enhancers/carousel.d.ts +1 -0
  40. package/dist/_dts/sdk/src/rendering/islands/enhancers/cart.d.ts +1 -0
  41. package/dist/_dts/sdk/src/rendering/islands/enhancers/checkout.d.ts +1 -0
  42. package/dist/_dts/sdk/src/rendering/islands/enhancers/courseRegistration.d.ts +1 -0
  43. package/dist/_dts/sdk/src/rendering/islands/enhancers/eventCalendar.d.ts +1 -0
  44. package/dist/_dts/sdk/src/rendering/islands/enhancers/eventCombined.d.ts +1 -0
  45. package/dist/_dts/sdk/src/rendering/islands/enhancers/eventDetails.d.ts +1 -0
  46. package/dist/_dts/sdk/src/rendering/islands/enhancers/eventListing.d.ts +1 -0
  47. package/dist/_dts/sdk/src/rendering/islands/enhancers/eventRegistration.d.ts +1 -0
  48. package/dist/_dts/sdk/src/rendering/islands/enhancers/form.d.ts +1 -0
  49. package/dist/_dts/sdk/src/rendering/islands/enhancers/headerSection.d.ts +1 -0
  50. package/dist/_dts/sdk/src/rendering/islands/enhancers/locationMap.d.ts +1 -0
  51. package/dist/_dts/sdk/src/rendering/islands/enhancers/newsletterForm.d.ts +1 -0
  52. package/dist/_dts/sdk/src/rendering/islands/enhancers/productDetail.d.ts +1 -0
  53. package/dist/_dts/sdk/src/rendering/islands/enhancers/productList.d.ts +1 -0
  54. package/dist/_dts/sdk/src/rendering/islands/enhancers/shop.d.ts +1 -0
  55. package/dist/_dts/sdk/src/rendering/islands/hydrateBlocksIslands.d.ts +2 -2
  56. package/dist/_dts/sdk/src/rendering/islands/propsDecoder.d.ts +7 -0
  57. package/dist/_dts/sdk/src/rendering/islands/types.d.ts +9 -0
  58. package/dist/_dts/sdk/src/rendering/server.d.ts +3 -0
  59. package/dist/_dts/sdk/src/version.d.ts +1 -1
  60. package/dist/_dts/theme-core/src/accordions/types.d.ts +3 -3
  61. package/dist/_dts/theme-core/src/buttons/personalities/types.d.ts +7 -7
  62. package/dist/_dts/theme-core/src/buttons/types.d.ts +20 -20
  63. package/dist/_dts/theme-core/src/cards/types.d.ts +2 -2
  64. package/dist/_dts/theme-core/src/generated/containerResponsiveThemeCss.d.ts +1 -1
  65. package/dist/_dts/theme-core/src/header/navDropdownSizing.d.ts +7 -0
  66. package/dist/_dts/theme-core/src/inputs/types.d.ts +3 -3
  67. package/dist/_dts/theme-core/src/interactive/baseSchema.d.ts +1 -1
  68. package/dist/_dts/theme-core/src/mock-themes/all.d.ts +156 -156
  69. package/dist/_dts/theme-core/src/navigation/types.d.ts +1 -1
  70. package/dist/_dts/theme-core/src/palette/variants/types.d.ts +4 -4
  71. package/dist/_dts/theme-core/src/schema.d.ts +178 -178
  72. package/dist/_dts/theme-core/src/shared/componentStyles.d.ts +2 -2
  73. package/dist/_dts/theme-core/src/site-styles/designState.d.ts +8 -8
  74. package/dist/_dts/theme-core/src/site-styles/generatedSchemas.d.ts +5 -5
  75. package/dist/_dts/theme-core/src/tokens/themeColorTokens.d.ts +2 -2
  76. package/dist/{chunk-G4NLITC2.mjs → chunk-BGMNDF6T.mjs} +7 -7
  77. package/dist/{chunk-3YNCQG5N.mjs → chunk-GFE74WRD.mjs} +457 -98
  78. package/dist/{chunk-HV745JYE.mjs → chunk-RWXQBRQZ.mjs} +1 -1
  79. package/dist/{chunk-7RX72SK2.mjs → chunk-VX7B5VAC.mjs} +36 -25
  80. package/dist/{chunk-YRJKZEQ3.mjs → chunk-Z7JLNTJR.mjs} +1 -1
  81. package/dist/{chunk-KWQPAJYS.mjs → chunk-ZSHFGFUG.mjs} +2 -2
  82. package/dist/cli/index.mjs +3498 -3826
  83. package/dist/client/accordion-T2UD2ZTH.mjs +120 -0
  84. package/dist/client/altcha-LIGLPZS5.mjs +2755 -0
  85. package/dist/client/bookingForm-GUP75FKN.mjs +3022 -0
  86. package/dist/client/bookings.mjs +32 -10
  87. package/dist/client/carousel-2NQA6V57.mjs +47 -0
  88. package/dist/client/cart-5TFIAFDS.mjs +18 -0
  89. package/dist/client/checkout-TP4WAHZE.mjs +18 -0
  90. package/dist/client/chunk-2PHQZ666.mjs +383 -0
  91. package/dist/client/chunk-3DCJ45Z2.mjs +36 -0
  92. package/dist/client/chunk-3PEDD5KJ.mjs +146 -0
  93. package/dist/client/chunk-CY2ZTSE5.mjs +204 -0
  94. package/dist/client/chunk-F7LHOKEH.mjs +146 -0
  95. package/dist/client/chunk-FU2QRZMX.mjs +7617 -0
  96. package/dist/client/chunk-I2MMZO53.mjs +6 -0
  97. package/dist/client/chunk-II7O4XN3.mjs +2206 -0
  98. package/dist/client/chunk-M3L3KYYR.mjs +1181 -0
  99. package/dist/client/chunk-N7PBWBWQ.mjs +26 -0
  100. package/dist/client/chunk-NWOUL6JY.mjs +1378 -0
  101. package/dist/client/chunk-OJPZEZU2.mjs +31 -0
  102. package/dist/client/chunk-PMAIXMAB.mjs +1960 -0
  103. package/dist/client/chunk-RTZ3JKKX.mjs +178 -0
  104. package/dist/client/chunk-TQ5MSPDX.mjs +74 -0
  105. package/dist/client/chunk-VUAOE3AG.mjs +5592 -0
  106. package/dist/client/chunk-WJXXNDSE.mjs +245 -0
  107. package/dist/client/chunk-YLMKMCSJ.mjs +86 -0
  108. package/dist/client/chunk-ZLMS67RR.mjs +106 -0
  109. package/dist/client/client.mjs +51865 -52071
  110. package/dist/client/courseRegistration-5DPU6PDY.mjs +1737 -0
  111. package/dist/client/eventCalendar-F3GZGTEF.mjs +40 -0
  112. package/dist/client/eventCombined-BQOB6QMY.mjs +37 -0
  113. package/dist/client/eventDetails-PEM2EJWE.mjs +25 -0
  114. package/dist/client/eventListing-YMIQPVME.mjs +40 -0
  115. package/dist/client/eventRegistration-4I6ZNDUF.mjs +40 -0
  116. package/dist/client/form-ZJVAYZ6G.mjs +300 -0
  117. package/dist/client/headerSection-TOT5MGTF.mjs +729 -0
  118. package/dist/client/hooks.mjs +91 -42
  119. package/dist/client/locationMap-J66NZREU.mjs +227 -0
  120. package/dist/client/newsletterForm-ET2XUMRO.mjs +208 -0
  121. package/dist/client/productDetail-C2XIH7C5.mjs +18 -0
  122. package/dist/client/productList-T2AZLFTP.mjs +18 -0
  123. package/dist/client/rendering/client.mjs +12796 -18295
  124. package/dist/client/rendering/islands.mjs +108 -44022
  125. package/dist/client/rendering.mjs +45141 -45346
  126. package/dist/client/shop-UZ6IUZXD.mjs +20 -0
  127. package/dist/preview-next/before-render.mjs +37 -25
  128. package/dist/preview-next/client/runtime.mjs +5 -5
  129. package/dist/preview-next/middleware.mjs +37 -25
  130. package/dist/{sdk-runtime-NGDWSWOS.mjs → sdk-runtime-JCUQM63Y.mjs} +6 -6
  131. package/dist/server/components.mjs +472 -276
  132. package/dist/server/config-validation.mjs +91 -42
  133. package/dist/server/config.mjs +91 -42
  134. package/dist/server/data.mjs +91 -42
  135. package/dist/server/index.mjs +45 -33
  136. package/dist/server/next.mjs +468 -272
  137. package/dist/server/page-converter.mjs +41 -17
  138. package/dist/server/prebuild.mjs +1 -1
  139. package/dist/server/rendering/server.mjs +556 -275
  140. package/dist/server/rendering.mjs +474 -278
  141. package/dist/server/routing.mjs +99 -43
  142. package/dist/server/server.mjs +92 -43
  143. package/dist/server/theme-bridge.mjs +29 -0
  144. package/dist/styles/index.css +16 -0
  145. package/package.json +2 -1
  146. package/dist/SdkPreviewModeRuntime-FO6V7YVY.mjs +0 -8
@@ -0,0 +1,3022 @@
1
+ "use client";
2
+ import { DiscountCodeSection } from './chunk-OJPZEZU2.mjs';
3
+ import { useDiscountCodeUi, usePolling } from './chunk-WJXXNDSE.mjs';
4
+ import { SpinnerNode } from './chunk-TQ5MSPDX.mjs';
5
+ import { scrollToBlock, buildCheckoutReturnUrls, formatPrice } from './chunk-2PHQZ666.mjs';
6
+ import { FieldValidationMessage, getFieldValidationControlProps } from './chunk-3DCJ45Z2.mjs';
7
+ import './chunk-3PEDD5KJ.mjs';
8
+ import { isSpamProtectionEnabled, getChallengeUrl, AltchaWidget } from './chunk-YLMKMCSJ.mjs';
9
+ import { BlockApiProvider, useBlockApi, FeedbackMessage } from './chunk-M3L3KYYR.mjs';
10
+ import { mountReactIsland } from './chunk-N7PBWBWQ.mjs';
11
+ import { defineIslandPropsDecoder, resolveBookingSiteId, resolveDefaultBookingServiceId, getBookingRedirectStateFromLocation, deriveBookingFormViewState, getBookingSuccessMessage, resolveAppointmentCheckoutVerificationState, themeButtonClassName, toSelectedCollectionOptionKind, assertNever, prepareBookingSubmission, formatBookingSubmissionPreparationError, resolveBookingPaymentStep, BOOKING_FORM_DATA_RESERVED_KEYS, okWith, err } from './chunk-FU2QRZMX.mjs';
12
+ import { resolveBookingTimeZoneId, DEFAULT_BOOKING_TIME_ZONE, asFormId, asAppointmentResourceId, asCustomerPassId, asAppointmentServiceId, formatLocalScheduleDate, addLocalScheduleDays, parseBookingLocalDate, resolveFlexibleBalanceInitialPaymentCents, resolveFlexibleBalanceDueAt, getBookingInstalmentDepositRefundability, asPassId, splitAmountWithRemainderInFirst, addIntervalPreservingMonthAnchor, clampConfiguredAmount, getBookingInstalmentPlanIntervalCount, omitZeroAmountFutureRows, formatBookingInstant } from './chunk-PMAIXMAB.mjs';
13
+ import React2, { useRef, useState, useMemo, useReducer, useEffect, useCallback, useLayoutEffect } from 'react';
14
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
15
+
16
+ // ../logger/src/noop.ts
17
+ var noop = () => {
18
+ };
19
+ var logger = {
20
+ debug: noop,
21
+ info: noop,
22
+ warn: noop,
23
+ error: noop,
24
+ withFields: (_) => logger
25
+ };
26
+ var SuccessMessage = ({
27
+ message,
28
+ resourceName,
29
+ className
30
+ }) => {
31
+ return /* @__PURE__ */ jsx(
32
+ FeedbackMessage,
33
+ {
34
+ tone: "success",
35
+ className: `ab-alert ab-alert--success ${className ?? ""}`,
36
+ role: "alert",
37
+ live: "polite",
38
+ children: /* @__PURE__ */ jsxs(Fragment, { children: [
39
+ /* @__PURE__ */ jsx("p", { className: "ab-text", children: message }),
40
+ resourceName ? /* @__PURE__ */ jsxs("p", { className: "ab-text ab-text--muted", children: [
41
+ "Practitioner: ",
42
+ resourceName
43
+ ] }) : null
44
+ ] })
45
+ }
46
+ );
47
+ };
48
+
49
+ // ../blocks/src/utils/date-formatting.ts
50
+ var BOOKING_FETCH_CHUNK_DAYS = 14;
51
+ var hasWarnedAboutRuntimeTimeZoneFallback = false;
52
+ function warnRuntimeTimeZoneFallback() {
53
+ if (hasWarnedAboutRuntimeTimeZoneFallback) {
54
+ return;
55
+ }
56
+ hasWarnedAboutRuntimeTimeZoneFallback = true;
57
+ console.warn(
58
+ "[booking] Falling back to UTC because the browser timezone could not be resolved safely."
59
+ );
60
+ }
61
+ function getRuntimeTimeZone() {
62
+ try {
63
+ const resolvedTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
64
+ const timeZone = resolveBookingTimeZoneId(resolvedTimeZone);
65
+ if (!resolvedTimeZone || timeZone !== resolvedTimeZone.trim()) {
66
+ warnRuntimeTimeZoneFallback();
67
+ }
68
+ return timeZone;
69
+ } catch {
70
+ warnRuntimeTimeZoneFallback();
71
+ return DEFAULT_BOOKING_TIME_ZONE;
72
+ }
73
+ }
74
+ function formatDate(dateStr) {
75
+ return formatBookingInstant(`${dateStr}T00:00:00.000Z`, {
76
+ timeZone: DEFAULT_BOOKING_TIME_ZONE,
77
+ format: {
78
+ weekday: "long",
79
+ year: "numeric",
80
+ month: "long",
81
+ day: "numeric"
82
+ }
83
+ });
84
+ }
85
+ function formatTime(isoString) {
86
+ return formatBookingInstant(isoString, {
87
+ timeZone: DEFAULT_BOOKING_TIME_ZONE,
88
+ format: {
89
+ hour: "numeric",
90
+ minute: "2-digit",
91
+ timeZoneName: "short"
92
+ }
93
+ });
94
+ }
95
+ var MIN_AVAILABILITY_REFRESH_INTERVAL_MS = 15e3;
96
+ function useVisibleTabPolling({
97
+ enabled,
98
+ intervalMs,
99
+ jitterMs,
100
+ minVisibleRefreshIntervalMs = MIN_AVAILABILITY_REFRESH_INTERVAL_MS,
101
+ onPoll
102
+ }) {
103
+ const onPollRef = useRef(onPoll);
104
+ const inFlightRef = useRef(false);
105
+ const lastPollStartedAtRef = useRef(0);
106
+ onPollRef.current = onPoll;
107
+ useEffect(() => {
108
+ if (!enabled) return;
109
+ if (typeof window === "undefined" || typeof document === "undefined")
110
+ return;
111
+ let timeoutId = null;
112
+ let cancelled = false;
113
+ const runPoll = async (reason) => {
114
+ if (cancelled || document.visibilityState === "hidden" || inFlightRef.current) {
115
+ return;
116
+ }
117
+ const now = Date.now();
118
+ if (reason === "visible" && now - lastPollStartedAtRef.current < minVisibleRefreshIntervalMs) {
119
+ return;
120
+ }
121
+ lastPollStartedAtRef.current = now;
122
+ inFlightRef.current = true;
123
+ try {
124
+ await onPollRef.current();
125
+ } finally {
126
+ inFlightRef.current = false;
127
+ }
128
+ };
129
+ const schedule = () => {
130
+ const jitter = Math.floor(Math.random() * jitterMs);
131
+ timeoutId = window.setTimeout(() => {
132
+ void runPoll("timer").finally(() => {
133
+ if (!cancelled) schedule();
134
+ });
135
+ }, intervalMs + jitter);
136
+ };
137
+ const handleVisibilityChange = () => {
138
+ if (document.visibilityState === "visible") {
139
+ void runPoll("visible");
140
+ }
141
+ };
142
+ lastPollStartedAtRef.current = Date.now();
143
+ schedule();
144
+ document.addEventListener("visibilitychange", handleVisibilityChange);
145
+ return () => {
146
+ cancelled = true;
147
+ if (timeoutId) window.clearTimeout(timeoutId);
148
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
149
+ };
150
+ }, [enabled, intervalMs, jitterMs, minVisibleRefreshIntervalMs]);
151
+ }
152
+
153
+ // ../blocks/src/system/runtime/hooks/useAvailableDates.ts
154
+ var AVAILABILITY_REFRESH_INTERVAL_MS = 3e4;
155
+ var AVAILABILITY_REFRESH_JITTER_MS = 5e3;
156
+ function useAvailableDates({
157
+ siteId: _siteId,
158
+ // siteId now comes from BlockApiProvider context
159
+ serviceId,
160
+ resourceId,
161
+ timezone,
162
+ initialDays = 30
163
+ }) {
164
+ const api = useBlockApi();
165
+ const [availableDates, setAvailableDates] = useState(/* @__PURE__ */ new Set());
166
+ const [isLoading, setIsLoading] = useState(false);
167
+ const [isRefreshing, setIsRefreshing] = useState(false);
168
+ const [error, setError] = useState(null);
169
+ const [hasMore, setHasMore] = useState(false);
170
+ const [loadedRange, setLoadedRange] = useState(null);
171
+ const nextStartDateRef = useRef(null);
172
+ const requestIdRef = useRef(0);
173
+ const fetchDates = useCallback(
174
+ async (startDate, endDate, append = false, mode = "load") => {
175
+ if (!serviceId) return;
176
+ const requestId = ++requestIdRef.current;
177
+ if (mode === "refresh") {
178
+ setIsRefreshing(true);
179
+ } else {
180
+ setIsLoading(true);
181
+ setIsRefreshing(false);
182
+ }
183
+ setError(null);
184
+ try {
185
+ const data = await api.getAvailableDates({
186
+ serviceId,
187
+ startDate,
188
+ endDate,
189
+ timezone,
190
+ resourceId
191
+ });
192
+ if (requestId !== requestIdRef.current) return;
193
+ setAvailableDates((prev) => {
194
+ const newSet = append ? new Set(prev) : /* @__PURE__ */ new Set();
195
+ data.dates.forEach((d) => newSet.add(d));
196
+ return newSet;
197
+ });
198
+ setHasMore(data.hasMore);
199
+ nextStartDateRef.current = data.nextStartDate || null;
200
+ setLoadedRange((prev) => ({
201
+ start: append && prev ? prev.start : data.startDate,
202
+ end: data.endDate
203
+ }));
204
+ } catch (err2) {
205
+ if (requestId !== requestIdRef.current) return;
206
+ setError(
207
+ err2 instanceof Error ? err2.message : "Failed to load available dates"
208
+ );
209
+ } finally {
210
+ if (mode === "refresh" && requestId === requestIdRef.current) {
211
+ setIsRefreshing(false);
212
+ } else if (requestId === requestIdRef.current) {
213
+ setIsLoading(false);
214
+ }
215
+ }
216
+ },
217
+ [api, serviceId, resourceId, timezone]
218
+ );
219
+ useEffect(() => {
220
+ if (!serviceId) {
221
+ setAvailableDates(/* @__PURE__ */ new Set());
222
+ setLoadedRange(null);
223
+ setHasMore(false);
224
+ setIsRefreshing(false);
225
+ return;
226
+ }
227
+ const now = /* @__PURE__ */ new Date();
228
+ const today = formatLocalScheduleDate({
229
+ year: now.getFullYear(),
230
+ month: now.getMonth() + 1,
231
+ day: now.getDate()
232
+ });
233
+ const endDate = formatLocalScheduleDate(
234
+ addLocalScheduleDays(parseBookingLocalDate(today), initialDays)
235
+ );
236
+ fetchDates(today, endDate, false);
237
+ }, [serviceId, fetchDates, initialDays]);
238
+ useVisibleTabPolling({
239
+ enabled: Boolean(serviceId && loadedRange),
240
+ intervalMs: AVAILABILITY_REFRESH_INTERVAL_MS,
241
+ jitterMs: AVAILABILITY_REFRESH_JITTER_MS,
242
+ onPoll: () => {
243
+ if (!loadedRange) return;
244
+ return fetchDates(loadedRange.start, loadedRange.end, false, "refresh");
245
+ }
246
+ });
247
+ const loadMore = useCallback(() => {
248
+ if (!nextStartDateRef.current || isLoading) return;
249
+ const start = nextStartDateRef.current;
250
+ const end = formatLocalScheduleDate(
251
+ addLocalScheduleDays(
252
+ parseBookingLocalDate(start),
253
+ BOOKING_FETCH_CHUNK_DAYS
254
+ )
255
+ );
256
+ fetchDates(start, end, true);
257
+ }, [fetchDates, isLoading]);
258
+ return {
259
+ availableDates,
260
+ isLoading,
261
+ isRefreshing,
262
+ error,
263
+ hasMore,
264
+ loadMore,
265
+ loadedRange
266
+ };
267
+ }
268
+ var AVAILABILITY_REFRESH_INTERVAL_MS2 = 3e4;
269
+ var AVAILABILITY_REFRESH_JITTER_MS2 = 5e3;
270
+ function useAvailableSlots({
271
+ siteId: _siteId,
272
+ // siteId now comes from BlockApiProvider context
273
+ serviceId,
274
+ resourceId,
275
+ selectedDate,
276
+ timezone
277
+ }) {
278
+ const api = useBlockApi();
279
+ const [slots, setSlots] = useState([]);
280
+ const [isLoading, setIsLoading] = useState(false);
281
+ const [isRefreshing, setIsRefreshing] = useState(false);
282
+ const [hasLoaded, setHasLoaded] = useState(false);
283
+ const [error, setError] = useState(null);
284
+ const requestIdRef = useRef(0);
285
+ const fetchSlots = useCallback(
286
+ async (mode) => {
287
+ const requestId = ++requestIdRef.current;
288
+ if (!selectedDate || !serviceId) {
289
+ setSlots([]);
290
+ setIsLoading(false);
291
+ setIsRefreshing(false);
292
+ setHasLoaded(false);
293
+ setError(null);
294
+ return;
295
+ }
296
+ if (mode === "refresh") {
297
+ setIsRefreshing(true);
298
+ } else {
299
+ setIsLoading(true);
300
+ setIsRefreshing(false);
301
+ setSlots([]);
302
+ }
303
+ setError(null);
304
+ try {
305
+ const result = await api.getAvailableSlots({
306
+ serviceId,
307
+ startDate: selectedDate,
308
+ endDate: selectedDate,
309
+ timezone,
310
+ resourceId
311
+ });
312
+ if (requestId !== requestIdRef.current) return;
313
+ setSlots(
314
+ result.slots.map((slot) => ({
315
+ ...slot,
316
+ resourceId: asAppointmentResourceId(slot.resourceId)
317
+ }))
318
+ );
319
+ setHasLoaded(true);
320
+ } catch (err2) {
321
+ if (requestId !== requestIdRef.current) return;
322
+ setError(
323
+ err2 instanceof Error ? err2.message : "Failed to load available times"
324
+ );
325
+ } finally {
326
+ if (mode === "refresh" && requestId === requestIdRef.current) {
327
+ setIsRefreshing(false);
328
+ } else if (requestId === requestIdRef.current) {
329
+ setIsLoading(false);
330
+ }
331
+ }
332
+ },
333
+ [selectedDate, serviceId, resourceId, timezone, api]
334
+ );
335
+ useEffect(() => {
336
+ void fetchSlots("load");
337
+ return () => {
338
+ requestIdRef.current += 1;
339
+ };
340
+ }, [fetchSlots]);
341
+ useVisibleTabPolling({
342
+ enabled: Boolean(selectedDate && serviceId),
343
+ intervalMs: AVAILABILITY_REFRESH_INTERVAL_MS2,
344
+ jitterMs: AVAILABILITY_REFRESH_JITTER_MS2,
345
+ onPoll: () => fetchSlots("refresh")
346
+ });
347
+ return { slots, isLoading, isRefreshing, hasLoaded, error };
348
+ }
349
+
350
+ // ../blocks/src/system/runtime/hooks/useBookingSubmission.state.ts
351
+ var initialBookingSubmissionState = {
352
+ status: { tag: "idle" },
353
+ feedback: { tag: "idle" }
354
+ };
355
+ function bookingSubmissionReducer(state, action) {
356
+ switch (action.type) {
357
+ case "submission-started":
358
+ return {
359
+ ...state,
360
+ status: { tag: "submitting" },
361
+ feedback: { tag: "idle" }
362
+ };
363
+ case "submission-succeeded":
364
+ return {
365
+ status: { tag: "idle" },
366
+ feedback: { tag: "success", message: action.message }
367
+ };
368
+ case "submission-failed":
369
+ case "issue-reported":
370
+ return {
371
+ status: { tag: "idle" },
372
+ feedback: { tag: "error", message: action.message }
373
+ };
374
+ case "feedback-cleared":
375
+ return {
376
+ ...state,
377
+ feedback: { tag: "idle" }
378
+ };
379
+ default:
380
+ return assertNever(action);
381
+ }
382
+ }
383
+ function selectIsSubmitting(state) {
384
+ return state.status.tag === "submitting";
385
+ }
386
+
387
+ // ../blocks/src/system/runtime/hooks/useBookingSubmission.ts
388
+ function useBookingSubmission(_siteId) {
389
+ const api = useBlockApi();
390
+ const [state, dispatch] = useReducer(
391
+ bookingSubmissionReducer,
392
+ initialBookingSubmissionState
393
+ );
394
+ const submit = async (data) => {
395
+ dispatch({ type: "submission-started" });
396
+ try {
397
+ const response = data.kind === "appointment_package" ? await api.submitBookingWithPackage({
398
+ formId: data.formId,
399
+ serviceId: data.serviceId,
400
+ resourceId: data.resourceId || null,
401
+ startAt: data.startAt,
402
+ endAt: data.endAt,
403
+ customFields: data.customFields,
404
+ timezone: data.timezone,
405
+ customerPassId: data.customerPassId
406
+ }) : await api.submitBooking({
407
+ formId: data.formId,
408
+ serviceId: data.serviceId,
409
+ resourceId: data.resourceId || null,
410
+ startAt: data.startAt,
411
+ endAt: data.endAt,
412
+ customFields: data.customFields,
413
+ timezone: data.timezone,
414
+ discountCode: data.discountCode,
415
+ selectedCollectionOptionKind: data.selectedCollectionOptionKind,
416
+ altchaSolution: data.altchaSolution ?? null,
417
+ successUrl: data.successUrl,
418
+ cancelUrl: data.cancelUrl
419
+ });
420
+ dispatch({
421
+ type: "submission-succeeded",
422
+ message: response.type === "checkout_redirect" ? "Redirecting to secure checkout..." : "Booking submitted successfully."
423
+ });
424
+ return okWith(response);
425
+ } catch (error) {
426
+ console.error("Booking submission error:", error);
427
+ const message = error instanceof Error ? error.message : "Failed to book appointment";
428
+ dispatch({
429
+ type: "submission-failed",
430
+ message
431
+ });
432
+ return err(message);
433
+ }
434
+ };
435
+ return {
436
+ submit,
437
+ reportIssue: (message) => dispatch({ type: "issue-reported", message }),
438
+ clearFeedback: () => dispatch({ type: "feedback-cleared" }),
439
+ feedback: state.feedback,
440
+ isSubmitting: selectIsSubmitting(state)
441
+ };
442
+ }
443
+
444
+ // ../blocks/src/system/runtime/shared/bookingPaymentOptionCopy.ts
445
+ function buildUpfrontBookingPaymentOptionCopy(formattedTotal) {
446
+ return {
447
+ title: "Pay in full",
448
+ description: "Pay everything today and your booking is confirmed.",
449
+ ctaLabel: `Pay ${formattedTotal} now`
450
+ };
451
+ }
452
+ function buildDeferredManualBookingPaymentOptionCopy() {
453
+ return {
454
+ title: "Pay offline",
455
+ description: "Book now and pay the business later, outside the platform.",
456
+ helperText: "Nothing is due today.",
457
+ ctaLabel: "Book now"
458
+ };
459
+ }
460
+ function buildInstalmentBookingPaymentOptionCopy(input) {
461
+ return {
462
+ title: "Payment plan",
463
+ description: describeInstalmentPlanCopy(input),
464
+ helperText: "You can pay the rest early any time in your customer portal.",
465
+ ctaLabel: `Pay ${input.formattedDueToday} now`
466
+ };
467
+ }
468
+ function describeInstalmentPlanCopy(input) {
469
+ if (input.depositRefundability) {
470
+ return `Pay ${input.formattedDueToday} today, then continue with scheduled payments. Your deposit is ${formatDepositRefundabilityLabel(input.depositRefundability)}.`;
471
+ }
472
+ if (input.futurePaymentCount === 0) {
473
+ return `Pay ${input.formattedDueToday} today.`;
474
+ }
475
+ if (input.futurePaymentCount === 1) {
476
+ return `Pay ${input.formattedDueToday} today, then one more payment later.`;
477
+ }
478
+ const formattedFirstFuture = input.formattedFirstFuture ?? input.formattedDueToday;
479
+ return `Pay ${input.formattedDueToday} today, then ${input.futurePaymentCount} more ${input.cadenceLabel} payments from ${formattedFirstFuture}.`;
480
+ }
481
+ function buildFlexibleBalanceBookingPaymentOptionCopy(input) {
482
+ return {
483
+ title: "Deposit now, pay the rest later",
484
+ description: `Pay ${input.formattedDueToday} today, then pay the remaining balance any time before ${input.dueDateLabel}. Your deposit is ${formatDepositRefundabilityLabel(input.depositRefundability)}.`,
485
+ helperText: "You can return in your customer portal to pay the rest before the due date.",
486
+ ctaLabel: `Pay ${input.formattedDueToday} now`
487
+ };
488
+ }
489
+ function formatDepositRefundabilityLabel(depositRefundability) {
490
+ return depositRefundability === "non_refundable" ? "non-refundable" : "refundable";
491
+ }
492
+
493
+ // ../blocks/src/system/runtime/shared/bookingPaymentOptions.ts
494
+ function buildBookingPaymentSelectionViewModel(input) {
495
+ const currency = input.priceCurrency ?? "USD";
496
+ const totalCents = Math.max(0, input.priceAmount ?? 0);
497
+ const allowedOptions = input.paymentTerms?.allowedOptions ?? [{ kind: "upfront" }];
498
+ const mode = allowedOptions.length > 1 ? "choice_required" : "single_option";
499
+ return {
500
+ mode,
501
+ sectionTitle: mode === "choice_required" ? "Choose how you'd like to pay" : "Payment",
502
+ serviceTitle: input.serviceTitle,
503
+ options: allowedOptions.map((option) => buildBookingPaymentOptionViewModel({
504
+ option,
505
+ totalCents,
506
+ currency,
507
+ bookingStartAt: input.bookingStartAt,
508
+ locale: input.locale
509
+ }))
510
+ };
511
+ }
512
+ function buildBookingPaymentOptionViewModel(input) {
513
+ switch (input.option.kind) {
514
+ case "upfront": {
515
+ const formattedTotal = formatPrice(input.totalCents, input.currency, input.locale);
516
+ const copy = buildUpfrontBookingPaymentOptionCopy(formattedTotal);
517
+ return {
518
+ kind: "upfront",
519
+ title: copy.title,
520
+ description: copy.description,
521
+ summaryRows: [
522
+ { label: "Total", value: formattedTotal },
523
+ { label: "Due today", value: formattedTotal }
524
+ ],
525
+ ctaLabel: copy.ctaLabel
526
+ };
527
+ }
528
+ case "deferred_manual": {
529
+ const formattedTotal = formatPrice(input.totalCents, input.currency, input.locale);
530
+ const copy = buildDeferredManualBookingPaymentOptionCopy();
531
+ return {
532
+ kind: "deferred_manual",
533
+ title: copy.title,
534
+ description: copy.description,
535
+ summaryRows: [
536
+ { label: "Total", value: formattedTotal },
537
+ { label: "Due today", value: formatPrice(0, input.currency, input.locale) }
538
+ ],
539
+ helperText: copy.helperText,
540
+ ctaLabel: copy.ctaLabel
541
+ };
542
+ }
543
+ case "instalment_plan": {
544
+ const schedulePreview = buildInstalmentSchedulePreview({
545
+ totalCents: input.totalCents,
546
+ option: input.option,
547
+ bookingStartAt: input.bookingStartAt
548
+ });
549
+ const dueToday = schedulePreview[0]?.amountCents ?? input.totalCents;
550
+ const futureRows = schedulePreview.slice(1);
551
+ const formattedTotal = formatPrice(input.totalCents, input.currency, input.locale);
552
+ const formattedDueToday = formatPrice(dueToday, input.currency, input.locale);
553
+ const depositRefundability = getBookingInstalmentDepositRefundability(input.option.plan);
554
+ const copy = buildInstalmentBookingPaymentOptionCopy({
555
+ formattedDueToday,
556
+ futurePaymentCount: futureRows.length,
557
+ formattedFirstFuture: futureRows[0] ? formatPrice(futureRows[0].amountCents, input.currency, input.locale) : null,
558
+ cadenceLabel: getPaymentCadenceLabel(input.option.plan),
559
+ depositRefundability
560
+ });
561
+ return {
562
+ kind: "instalment_plan",
563
+ title: copy.title,
564
+ description: copy.description,
565
+ summaryRows: [
566
+ { label: "Total", value: formattedTotal },
567
+ { label: "Due today", value: formattedDueToday },
568
+ ...depositRefundability ? [{
569
+ label: "Deposit policy",
570
+ value: depositRefundability === "non_refundable" ? "Non-refundable" : "Refundable"
571
+ }] : [],
572
+ ...futureRows.map((row) => ({
573
+ label: formatScheduleDate(row.dueAt, input.locale),
574
+ value: formatPrice(row.amountCents, input.currency, input.locale)
575
+ }))
576
+ ],
577
+ helperText: copy.helperText,
578
+ ctaLabel: copy.ctaLabel
579
+ };
580
+ }
581
+ case "flexible_balance": {
582
+ const dueToday = resolveFlexibleBalanceInitialPaymentCents({
583
+ totalAmountCents: input.totalCents,
584
+ config: input.option.config
585
+ });
586
+ const formattedTotal = formatPrice(input.totalCents, input.currency, input.locale);
587
+ const formattedDueToday = formatPrice(dueToday, input.currency, input.locale);
588
+ const dueAt = resolveFlexibleBalanceDueAt({
589
+ bookingStartAt: input.bookingStartAt ?? (/* @__PURE__ */ new Date()).toISOString(),
590
+ dueDateRule: input.option.config.dueDateRule
591
+ });
592
+ const copy = buildFlexibleBalanceBookingPaymentOptionCopy({
593
+ formattedDueToday,
594
+ dueDateLabel: formatScheduleDate(dueAt, input.locale),
595
+ depositRefundability: input.option.config.depositRefundability
596
+ });
597
+ return {
598
+ kind: "flexible_balance",
599
+ title: copy.title,
600
+ description: copy.description,
601
+ summaryRows: [
602
+ { label: "Total", value: formattedTotal },
603
+ { label: "Due today", value: formattedDueToday },
604
+ { label: "Balance due by", value: formatScheduleDate(dueAt, input.locale) },
605
+ {
606
+ label: "Deposit policy",
607
+ value: input.option.config.depositRefundability === "non_refundable" ? "Non-refundable" : "Refundable"
608
+ }
609
+ ],
610
+ helperText: copy.helperText,
611
+ ctaLabel: copy.ctaLabel
612
+ };
613
+ }
614
+ default:
615
+ return assertNever(input.option);
616
+ }
617
+ }
618
+ function buildInstalmentSchedulePreview(input) {
619
+ const startAt = input.bookingStartAt ?? (/* @__PURE__ */ new Date()).toISOString();
620
+ switch (input.option.plan.kind) {
621
+ case "deposit_plus_one_remainder": {
622
+ const deposit = clampConfiguredAmount(input.option.plan.depositAmountCents, input.totalCents);
623
+ const remainder = Math.max(0, input.totalCents - deposit);
624
+ return compactSchedulePreview([
625
+ { dueAt: startAt, amountCents: deposit },
626
+ {
627
+ dueAt: addIntervalPreservingMonthAnchor(
628
+ startAt,
629
+ input.option.plan.remainderDueCount,
630
+ input.option.plan.intervalUnit
631
+ ),
632
+ amountCents: remainder
633
+ }
634
+ ]);
635
+ }
636
+ case "deposit_plus_scheduled_instalments": {
637
+ const plan = input.option.plan;
638
+ const deposit = clampConfiguredAmount(plan.depositAmountCents, input.totalCents);
639
+ const remaining = Math.max(0, input.totalCents - deposit);
640
+ const instalments = splitAmountWithRemainderInFirst(
641
+ remaining,
642
+ plan.instalmentCount
643
+ );
644
+ return compactSchedulePreview([
645
+ { dueAt: startAt, amountCents: deposit },
646
+ ...instalments.map((amountCents, index) => ({
647
+ dueAt: addIntervalPreservingMonthAnchor(
648
+ startAt,
649
+ plan.intervalCount * (index + 1),
650
+ plan.intervalUnit
651
+ ),
652
+ amountCents
653
+ }))
654
+ ]);
655
+ }
656
+ case "equal_scheduled_instalments": {
657
+ const plan = input.option.plan;
658
+ const instalments = splitAmountWithRemainderInFirst(
659
+ input.totalCents,
660
+ plan.instalmentCount
661
+ );
662
+ return compactSchedulePreview(instalments.map((amountCents, index) => ({
663
+ dueAt: index === 0 ? startAt : addIntervalPreservingMonthAnchor(
664
+ startAt,
665
+ plan.intervalCount * index,
666
+ plan.intervalUnit
667
+ ),
668
+ amountCents
669
+ })));
670
+ }
671
+ default:
672
+ return assertNever(input.option.plan);
673
+ }
674
+ }
675
+ function getPaymentCadenceLabel(plan) {
676
+ const intervalCount = getBookingInstalmentPlanIntervalCount(plan);
677
+ const unitLabel = intervalCount === 1 ? plan.intervalUnit : `${plan.intervalUnit}s`;
678
+ return intervalCount === 1 ? unitLabel : `every ${intervalCount} ${unitLabel}`;
679
+ }
680
+ function compactSchedulePreview(schedule) {
681
+ return omitZeroAmountFutureRows(schedule);
682
+ }
683
+ function formatScheduleDate(isoString, locale) {
684
+ const date = new Date(isoString);
685
+ if (!Number.isFinite(date.getTime())) {
686
+ return isoString;
687
+ }
688
+ return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(date);
689
+ }
690
+
691
+ // ../blocks/src/system/runtime/nodes/booking-form.headless.state.ts
692
+ var asBookingFieldId = (id) => id;
693
+ function createEmptyBookingValidationErrors() {
694
+ return { formError: null, fieldErrors: {} };
695
+ }
696
+ function createFormBookingValidationError(formError) {
697
+ return { formError, fieldErrors: {} };
698
+ }
699
+ function createFieldBookingValidationErrors(fieldErrors) {
700
+ return { formError: null, fieldErrors };
701
+ }
702
+ function hasBookingValidationErrors(errors) {
703
+ return Boolean(errors.formError) || Object.keys(errors.fieldErrors).length > 0;
704
+ }
705
+ function clearBookingFormError(errors) {
706
+ return errors.formError ? { ...errors, formError: null } : errors;
707
+ }
708
+ function clearBookingFieldError(errors, fieldId) {
709
+ if (!(fieldId in errors.fieldErrors)) return errors;
710
+ return {
711
+ ...errors,
712
+ fieldErrors: omitRecordKey(errors.fieldErrors, fieldId)
713
+ };
714
+ }
715
+ function createInitialBookingFlowReducerState(input) {
716
+ const initialData = input.initialData ?? {};
717
+ return {
718
+ phase: "editing",
719
+ currentStepId: "service-selection",
720
+ selectedServiceId: toOptionalAppointmentServiceId(
721
+ toOptionalString(initialData.serviceId) ?? input.defaultServiceId
722
+ ),
723
+ selectedResourceId: toOptionalAppointmentResourceId(
724
+ toOptionalString(initialData.resourceId) ?? input.defaultResourceId
725
+ ),
726
+ selectedDate: toOptionalString(initialData.selectedDate),
727
+ selectedSlot: null,
728
+ fieldValues: collectInitialFieldValues(initialData),
729
+ touchedFields: {},
730
+ discountCode: toOptionalString(initialData.discountCode) ?? "",
731
+ selectedCollectionOptionKind: toSelectedCollectionOptionKind(
732
+ initialData.selectedCollectionOptionKind
733
+ ),
734
+ selectedAppointmentPackageId: toOptionalCustomerPassId(
735
+ toOptionalString(initialData.selectedAppointmentPackageId)
736
+ ),
737
+ altchaSolution: null,
738
+ validationErrors: createEmptyBookingValidationErrors(),
739
+ feedback: { kind: "none" }
740
+ };
741
+ }
742
+ function collectInitialFieldValues(initialData) {
743
+ const values = {};
744
+ for (const [key, value] of Object.entries(initialData)) {
745
+ if (BOOKING_FORM_DATA_RESERVED_KEYS.has(key) || !isBookingFieldValue(value)) {
746
+ continue;
747
+ }
748
+ values[key] = value;
749
+ }
750
+ return values;
751
+ }
752
+ function toOptionalString(value) {
753
+ if (typeof value !== "string") return void 0;
754
+ const trimmed = value.trim();
755
+ return trimmed.length > 0 ? trimmed : void 0;
756
+ }
757
+ function toOptionalBrandedId(value, brand) {
758
+ const trimmed = toOptionalString(value);
759
+ return trimmed ? brand(trimmed) : void 0;
760
+ }
761
+ function toOptionalAppointmentServiceId(value) {
762
+ return toOptionalBrandedId(value, asAppointmentServiceId);
763
+ }
764
+ function toOptionalAppointmentResourceId(value) {
765
+ return toOptionalBrandedId(value, asAppointmentResourceId);
766
+ }
767
+ function toOptionalCustomerPassId(value) {
768
+ return toOptionalBrandedId(value, asCustomerPassId);
769
+ }
770
+ function isBookingFieldValue(value) {
771
+ return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" || Array.isArray(value) && value.every((item) => typeof item === "string");
772
+ }
773
+ function bookingFlowReducer(state, event) {
774
+ switch (event.type) {
775
+ case "service-selected":
776
+ return {
777
+ ...state,
778
+ selectedServiceId: event.serviceId,
779
+ selectedResourceId: void 0,
780
+ selectedDate: void 0,
781
+ selectedSlot: null,
782
+ selectedAppointmentPackageId: void 0,
783
+ selectedCollectionOptionKind: void 0,
784
+ validationErrors: clearBookingFormError(state.validationErrors)
785
+ };
786
+ case "resource-selected":
787
+ return {
788
+ ...state,
789
+ selectedResourceId: event.resourceId ?? void 0,
790
+ selectedDate: void 0,
791
+ selectedSlot: null,
792
+ validationErrors: clearBookingFormError(state.validationErrors)
793
+ };
794
+ case "date-selected":
795
+ return {
796
+ ...state,
797
+ selectedDate: event.date ?? void 0,
798
+ selectedSlot: null,
799
+ feedback: clearAvailabilityFeedback(state.feedback),
800
+ validationErrors: clearBookingFormError(state.validationErrors)
801
+ };
802
+ case "slot-selected":
803
+ return {
804
+ ...state,
805
+ selectedSlot: event.slot,
806
+ feedback: clearAvailabilityFeedback(state.feedback),
807
+ validationErrors: clearBookingFormError(state.validationErrors)
808
+ };
809
+ case "field-updated":
810
+ return {
811
+ ...state,
812
+ fieldValues: {
813
+ ...state.fieldValues,
814
+ [event.fieldId]: event.value
815
+ },
816
+ validationErrors: clearBookingFieldError(
817
+ clearBookingFormError(state.validationErrors),
818
+ event.fieldId
819
+ )
820
+ };
821
+ case "field-touched":
822
+ return {
823
+ ...state,
824
+ touchedFields: {
825
+ ...state.touchedFields,
826
+ [event.fieldId]: true
827
+ }
828
+ };
829
+ case "payment-option-selected":
830
+ return {
831
+ ...state,
832
+ selectedCollectionOptionKind: event.kind ?? void 0,
833
+ selectedAppointmentPackageId: void 0,
834
+ validationErrors: clearBookingFormError(state.validationErrors)
835
+ };
836
+ case "appointment-package-selected":
837
+ return {
838
+ ...state,
839
+ selectedAppointmentPackageId: event.packageId ?? void 0,
840
+ selectedCollectionOptionKind: void 0,
841
+ validationErrors: clearBookingFormError(state.validationErrors)
842
+ };
843
+ case "discount-code-changed":
844
+ return {
845
+ ...state,
846
+ discountCode: event.code,
847
+ validationErrors: clearBookingFormError(state.validationErrors)
848
+ };
849
+ case "altcha-solution-set":
850
+ return {
851
+ ...state,
852
+ altchaSolution: event.solution,
853
+ validationErrors: clearBookingFormError(state.validationErrors)
854
+ };
855
+ case "altcha-solution-invalidated":
856
+ return { ...state, altchaSolution: null };
857
+ case "step-changed":
858
+ return {
859
+ ...state,
860
+ currentStepId: event.stepId,
861
+ validationErrors: clearBookingFormError(state.validationErrors)
862
+ };
863
+ case "validation-errors-set":
864
+ return { ...state, validationErrors: event.errors };
865
+ case "feedback-set":
866
+ return {
867
+ ...state,
868
+ phase: event.feedback.kind === "success" ? "completed" : "editing",
869
+ feedback: event.feedback
870
+ };
871
+ case "availability-selection-invalidated":
872
+ if (state.phase !== "editing") {
873
+ return state;
874
+ }
875
+ return {
876
+ ...state,
877
+ currentStepId: "datetime-selection",
878
+ selectedDate: event.invalidation.kind === "date_unavailable" ? void 0 : state.selectedDate,
879
+ selectedSlot: null,
880
+ feedback: {
881
+ kind: "error",
882
+ scope: "availability",
883
+ message: event.invalidation.message
884
+ },
885
+ validationErrors: clearBookingFormError(state.validationErrors)
886
+ };
887
+ case "feedback-cleared":
888
+ return { ...state, phase: "editing", feedback: { kind: "none" } };
889
+ case "submission-started":
890
+ return { ...state, phase: "submitting" };
891
+ case "submission-finished":
892
+ return state.phase === "submitting" ? { ...state, phase: "editing" } : state;
893
+ case "reset":
894
+ return event.initial;
895
+ default:
896
+ return assertNever(event);
897
+ }
898
+ }
899
+ function clearAvailabilityFeedback(feedback) {
900
+ return feedback.kind === "error" && feedback.scope === "availability" ? { kind: "none" } : feedback;
901
+ }
902
+ function resolveAvailabilitySelectionInvalidation(input) {
903
+ if (input.selectedDate && !input.availableDates.includes(input.selectedDate)) {
904
+ return {
905
+ kind: "date_unavailable",
906
+ message: "That date is no longer available. Please choose another date."
907
+ };
908
+ }
909
+ if (input.selectedSlot && !input.slots.some((slot) => slot.key === input.selectedSlot?.key)) {
910
+ return {
911
+ kind: "slot_unavailable",
912
+ message: "That time is no longer available. Please choose another time."
913
+ };
914
+ }
915
+ return { kind: "preserve" };
916
+ }
917
+ function toBookingSlotView(slot) {
918
+ return {
919
+ key: `${slot.startAt}|${slot.endAt}|${slot.resourceId}`,
920
+ startAt: slot.startAt,
921
+ endAt: slot.endAt,
922
+ resourceId: asAppointmentResourceId(slot.resourceId),
923
+ ...slot.resourceName ? { resourceName: slot.resourceName } : {},
924
+ ...slot.label ? { label: slot.label } : {}
925
+ };
926
+ }
927
+ function toBookingSlotViews(slots) {
928
+ return slots.map(toBookingSlotView);
929
+ }
930
+ function toBookingFormData(state) {
931
+ return {
932
+ ...state.fieldValues,
933
+ serviceId: state.selectedServiceId,
934
+ resourceId: state.selectedResourceId ?? state.selectedSlot?.resourceId,
935
+ selectedDate: state.selectedDate,
936
+ selectedSlot: state.selectedSlot?.startAt,
937
+ discountCode: state.discountCode || void 0,
938
+ selectedCollectionOptionKind: state.selectedCollectionOptionKind,
939
+ selectedAppointmentPackageId: state.selectedAppointmentPackageId
940
+ };
941
+ }
942
+ function buildBookingStepResolution(input) {
943
+ const defaultServiceId = input.defaultServiceId ?? resolveDefaultBookingServiceId(input.form, input.services);
944
+ const paymentResolution = resolveBookingPaymentStep({
945
+ data: input.data,
946
+ services: input.services,
947
+ defaultServiceId
948
+ });
949
+ if (!input.form) return { steps: [], paymentResolution };
950
+ const hasValidConfiguredService = Boolean(defaultServiceId) && input.services.some((service) => service.id === defaultServiceId);
951
+ const needsServiceSelection = Boolean(input.requiresServiceSelection) || !hasValidConfiguredService && input.services.length > 1 || Boolean(input.requiresResourceSelection) || Boolean(
952
+ input.form.settings?.serviceIds && input.form.settings.serviceIds.length > 1
953
+ );
954
+ const selectedService = paymentResolution.service;
955
+ const needsPayment = paymentResolution.mode !== "hidden" || Boolean(
956
+ input.enableAppointmentPackages && selectedService && (selectedService.priceAmount ?? 0) > 0
957
+ );
958
+ const hasFields = Boolean(input.form.schema?.fields.length);
959
+ return {
960
+ steps: [
961
+ {
962
+ id: "service-selection",
963
+ label: "Select Service",
964
+ visible: needsServiceSelection
965
+ },
966
+ { id: "datetime-selection", label: "Select Date & Time", visible: true },
967
+ { id: "payment-selection", label: "Payment", visible: needsPayment },
968
+ { id: "custom-fields", label: "Your Information", visible: hasFields }
969
+ ],
970
+ paymentResolution
971
+ };
972
+ }
973
+ function validateBookingStep(input) {
974
+ switch (input.stepId) {
975
+ case "service-selection": {
976
+ let formError = null;
977
+ if (!input.data.serviceId) {
978
+ formError = "Please select a service";
979
+ }
980
+ if (input.requiresResourceSelection && !(input.data.resourceId ?? input.defaultResourceId)) {
981
+ formError = "Please select a practitioner";
982
+ }
983
+ return formError ? { ok: false, errors: createFormBookingValidationError(formError) } : { ok: true };
984
+ }
985
+ case "datetime-selection":
986
+ return input.data.selectedSlot ? { ok: true } : {
987
+ ok: false,
988
+ errors: createFormBookingValidationError(
989
+ "Please select a date and time"
990
+ )
991
+ };
992
+ case "payment-selection": {
993
+ if (input.data.selectedAppointmentPackageId) return { ok: true };
994
+ const resolution = input.paymentResolution ?? resolveBookingPaymentStep({
995
+ data: input.data,
996
+ services: input.services,
997
+ defaultServiceId: input.defaultServiceId
998
+ });
999
+ if (resolution.mode === "hidden" || resolution.mode === "disclosure_only") {
1000
+ return { ok: true };
1001
+ }
1002
+ if (resolution.mode === "unsupported") {
1003
+ return {
1004
+ ok: false,
1005
+ errors: createFormBookingValidationError(
1006
+ resolution.errorMessage ?? "This service cannot be booked right now."
1007
+ )
1008
+ };
1009
+ }
1010
+ return input.data.selectedCollectionOptionKind ? { ok: true } : {
1011
+ ok: false,
1012
+ errors: createFormBookingValidationError(
1013
+ "Please choose a payment option"
1014
+ )
1015
+ };
1016
+ }
1017
+ case "custom-fields": {
1018
+ const errors = validateBookingFields({
1019
+ fields: input.form?.schema?.fields ?? [],
1020
+ values: input.data
1021
+ });
1022
+ return Object.keys(errors).length > 0 ? { ok: false, errors: createFieldBookingValidationErrors(errors) } : { ok: true };
1023
+ }
1024
+ default:
1025
+ return assertNever(input.stepId);
1026
+ }
1027
+ }
1028
+ function validateBookingFields(input) {
1029
+ const errors = {};
1030
+ for (const field of input.fields) {
1031
+ const error = validateBookingField(field, input.values[field.id]);
1032
+ if (error) {
1033
+ errors[field.id] = error;
1034
+ }
1035
+ }
1036
+ return errors;
1037
+ }
1038
+ function validateBookingField(field, value) {
1039
+ if (field.required && isBlankFieldValue(value)) {
1040
+ return `${field.label} is required`;
1041
+ }
1042
+ if (isBlankFieldValue(value)) return null;
1043
+ const strValue = typeof value === "string" ? value : String(value);
1044
+ switch (field.type) {
1045
+ case "email":
1046
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(strValue) ? null : "Please enter a valid email address";
1047
+ case "url":
1048
+ try {
1049
+ new URL(strValue);
1050
+ return null;
1051
+ } catch {
1052
+ return "Please enter a valid URL";
1053
+ }
1054
+ case "tel":
1055
+ return /^[+\d\s\-()]+$/.test(strValue) ? null : "Please enter a valid phone number";
1056
+ case "number": {
1057
+ const num = Number(value);
1058
+ if (Number.isNaN(num)) return "Please enter a valid number";
1059
+ if (field.min !== void 0 && num < field.min) {
1060
+ return `Value must be at least ${field.min}`;
1061
+ }
1062
+ if (field.max !== void 0 && num > field.max) {
1063
+ return `Value must be at most ${field.max}`;
1064
+ }
1065
+ return null;
1066
+ }
1067
+ }
1068
+ if (typeof value === "string") {
1069
+ if (field.minLength && value.length < field.minLength) {
1070
+ return `Must be at least ${field.minLength} characters`;
1071
+ }
1072
+ if (field.maxLength && value.length > field.maxLength) {
1073
+ return `Must be at most ${field.maxLength} characters`;
1074
+ }
1075
+ if (field.pattern) {
1076
+ try {
1077
+ const regex = new RegExp(field.pattern);
1078
+ if (!regex.test(value)) return "Please match the required format";
1079
+ } catch {
1080
+ return "Please match the required format";
1081
+ }
1082
+ }
1083
+ }
1084
+ return null;
1085
+ }
1086
+ function deriveBookingFlowSteps(input) {
1087
+ const stepResolution = buildBookingStepResolution({
1088
+ form: input.form,
1089
+ services: input.services,
1090
+ data: input.data,
1091
+ defaultServiceId: input.defaultServiceId,
1092
+ requiresServiceSelection: input.requiresServiceSelection,
1093
+ requiresResourceSelection: input.requiresResourceSelection,
1094
+ enableAppointmentPackages: input.enableAppointmentPackages
1095
+ });
1096
+ const stepPlan = stepResolution.steps.filter((step) => step.visible);
1097
+ const currentStepIndex = Math.max(
1098
+ 0,
1099
+ stepPlan.findIndex((step) => step.id === input.currentStepId)
1100
+ );
1101
+ const validations = /* @__PURE__ */ new Map();
1102
+ for (const step of stepPlan) {
1103
+ validations.set(
1104
+ step.id,
1105
+ validateBookingStep({
1106
+ stepId: step.id,
1107
+ form: input.form,
1108
+ services: input.services,
1109
+ data: input.data,
1110
+ defaultServiceId: input.defaultServiceId,
1111
+ defaultResourceId: input.defaultResourceId,
1112
+ requiresResourceSelection: input.requiresResourceSelection,
1113
+ paymentResolution: stepResolution.paymentResolution
1114
+ })
1115
+ );
1116
+ }
1117
+ const steps = stepPlan.map((step, index) => {
1118
+ const validation = validations.get(step.id) ?? { ok: true };
1119
+ const state = index < currentStepIndex ? "complete" : index === currentStepIndex ? "active" : "available";
1120
+ return {
1121
+ id: step.id,
1122
+ label: step.label,
1123
+ state,
1124
+ index,
1125
+ visibleIndex: index,
1126
+ errors: validation.ok ? createEmptyBookingValidationErrors() : validation.errors,
1127
+ canEnter: index <= currentStepIndex,
1128
+ canLeave: validation.ok
1129
+ };
1130
+ });
1131
+ return {
1132
+ steps,
1133
+ currentStepIndex,
1134
+ currentStep: steps[currentStepIndex] ?? null,
1135
+ stepIds: steps.map((step) => step.id),
1136
+ validations
1137
+ };
1138
+ }
1139
+ function buildBookingAvailabilityQuery(input) {
1140
+ if (!input.serviceId) {
1141
+ return {
1142
+ ok: false,
1143
+ reason: "missing-service",
1144
+ key: `missing-service|${input.timezone}`
1145
+ };
1146
+ }
1147
+ if (input.requireDate && !input.selectedDate) {
1148
+ return {
1149
+ ok: false,
1150
+ reason: "missing-date",
1151
+ key: `${input.serviceId}|${input.resourceId ?? "any"}|missing-date|${input.timezone}`
1152
+ };
1153
+ }
1154
+ return {
1155
+ ok: true,
1156
+ key: `${input.serviceId}|${input.resourceId ?? "any"}|${input.selectedDate ?? "all-dates"}|${input.timezone}`,
1157
+ serviceId: input.serviceId,
1158
+ resourceId: input.resourceId,
1159
+ selectedDate: input.selectedDate,
1160
+ timezone: input.timezone
1161
+ };
1162
+ }
1163
+ function deriveBookingFieldViews(input) {
1164
+ return (input.form?.schema?.fields ?? []).map((field) => {
1165
+ const fieldId = asBookingFieldId(field.id);
1166
+ const inputId = `${input.idPrefix}-${fieldId}`;
1167
+ const helpTextId = field.helpText ? `${inputId}-help` : null;
1168
+ const fieldValue = input.values[field.id] ?? null;
1169
+ const localError = input.touched?.[field.id] ? validateBookingField(field, fieldValue) : null;
1170
+ const error = input.errors[field.id] ?? localError;
1171
+ const errorId = error ? `${inputId}-error` : null;
1172
+ const describedBy = [helpTextId, errorId].filter(Boolean).join(" ") || void 0;
1173
+ return {
1174
+ field,
1175
+ id: fieldId,
1176
+ label: field.label,
1177
+ type: field.type,
1178
+ value: fieldValue,
1179
+ required: Boolean(field.required),
1180
+ error,
1181
+ helpText: field.helpText ?? null,
1182
+ inputId,
1183
+ labelId: `${inputId}-label`,
1184
+ helpTextId,
1185
+ errorId,
1186
+ describedBy,
1187
+ ariaInvalid: Boolean(error)
1188
+ };
1189
+ });
1190
+ }
1191
+ function buildBookingSubmitPlan(input) {
1192
+ if (input.requiresResourceSelection && !(input.state.selectedResourceId ?? input.defaultResourceId)) {
1193
+ const message = "Please select a practitioner";
1194
+ return {
1195
+ ok: false,
1196
+ errors: createFormBookingValidationError(message),
1197
+ message
1198
+ };
1199
+ }
1200
+ const fieldErrors = validateBookingFields({
1201
+ fields: input.form?.schema?.fields ?? [],
1202
+ values: input.state.fieldValues
1203
+ });
1204
+ if (Object.keys(fieldErrors).length > 0) {
1205
+ return {
1206
+ ok: false,
1207
+ errors: createFieldBookingValidationErrors(fieldErrors),
1208
+ message: "Please check the highlighted fields."
1209
+ };
1210
+ }
1211
+ const prepared = prepareBookingSubmission({
1212
+ formId: input.formId,
1213
+ data: toBookingFormData({
1214
+ ...input.state,
1215
+ altchaSolution: input.altchaSolution ?? input.state.altchaSolution
1216
+ }),
1217
+ services: input.services,
1218
+ defaultServiceId: input.defaultServiceId,
1219
+ defaultResourceId: input.defaultResourceId,
1220
+ altchaSolution: input.altchaSolution ?? input.state.altchaSolution,
1221
+ viewerTimeZone: input.viewerTimeZone
1222
+ });
1223
+ if (!prepared.ok) {
1224
+ const message = formatBookingSubmissionPreparationError(prepared.error);
1225
+ return {
1226
+ ok: false,
1227
+ errors: createFormBookingValidationError(message),
1228
+ message
1229
+ };
1230
+ }
1231
+ return {
1232
+ ok: true,
1233
+ redirect: input.redirect,
1234
+ value: prepared.value.kind === "appointment_package" ? {
1235
+ ...prepared.value,
1236
+ endAt: input.state.selectedSlot?.endAt ?? prepared.value.endAt,
1237
+ resourceId: prepared.value.resourceId ?? input.state.selectedSlot?.resourceId
1238
+ } : {
1239
+ ...prepared.value,
1240
+ endAt: input.state.selectedSlot?.endAt ?? prepared.value.endAt,
1241
+ resourceId: prepared.value.resourceId ?? input.state.selectedSlot?.resourceId,
1242
+ successUrl: input.successUrl,
1243
+ cancelUrl: input.cancelUrl
1244
+ }
1245
+ };
1246
+ }
1247
+ function resolveAppointmentPackagesModel(input) {
1248
+ if (!input.enabled) {
1249
+ return {
1250
+ kind: "disabled",
1251
+ enabled: false,
1252
+ canLoad: false,
1253
+ unavailableReason: "disabled",
1254
+ state: { tag: "idle" },
1255
+ items: [],
1256
+ selected: null
1257
+ };
1258
+ }
1259
+ if (!input.canLoad) {
1260
+ const unavailableReason = input.unavailableReason === "missing-portal-auth" ? "missing-portal-auth" : "missing-priced-service";
1261
+ return {
1262
+ kind: "unavailable",
1263
+ enabled: true,
1264
+ canLoad: false,
1265
+ unavailableReason,
1266
+ state: { tag: "idle" },
1267
+ items: [],
1268
+ selected: null
1269
+ };
1270
+ }
1271
+ switch (input.state.tag) {
1272
+ case "idle":
1273
+ return {
1274
+ kind: "idle",
1275
+ enabled: true,
1276
+ canLoad: true,
1277
+ unavailableReason: null,
1278
+ state: input.state,
1279
+ items: [],
1280
+ selected: null
1281
+ };
1282
+ case "loading":
1283
+ return {
1284
+ kind: "loading",
1285
+ enabled: true,
1286
+ canLoad: true,
1287
+ unavailableReason: null,
1288
+ state: input.state,
1289
+ items: [],
1290
+ selected: null
1291
+ };
1292
+ case "error":
1293
+ return {
1294
+ kind: "error",
1295
+ enabled: true,
1296
+ canLoad: true,
1297
+ unavailableReason: null,
1298
+ state: input.state,
1299
+ items: [],
1300
+ selected: null
1301
+ };
1302
+ case "loaded": {
1303
+ const items = input.items.map(toHeadlessEligibleAppointmentPackage);
1304
+ const selected = input.selected ? toHeadlessEligibleAppointmentPackage(input.selected) : null;
1305
+ return {
1306
+ kind: "ready",
1307
+ enabled: true,
1308
+ canLoad: true,
1309
+ unavailableReason: null,
1310
+ state: { tag: "loaded", value: items },
1311
+ items,
1312
+ selected
1313
+ };
1314
+ }
1315
+ default:
1316
+ return assertNever(input.state);
1317
+ }
1318
+ }
1319
+ function toHeadlessEligibleAppointmentPackage(appointmentPackage) {
1320
+ return {
1321
+ ...appointmentPackage,
1322
+ customerPassId: asCustomerPassId(appointmentPackage.customerPassId),
1323
+ passId: asPassId(appointmentPackage.passId)
1324
+ };
1325
+ }
1326
+ function resolveHeadlessBookingStatus(input) {
1327
+ if (input.phase === "submitting") return { tag: "submitting" };
1328
+ if (input.phase === "completed" && input.feedback.kind === "success") {
1329
+ return {
1330
+ tag: "success",
1331
+ resourceName: input.feedback.resourceName
1332
+ };
1333
+ }
1334
+ if (input.feedback.kind === "error") {
1335
+ return {
1336
+ tag: "error",
1337
+ message: input.feedback.message
1338
+ };
1339
+ }
1340
+ return { tag: "ready" };
1341
+ }
1342
+ function resolveHeadlessBookingView(input) {
1343
+ if (!input.form) {
1344
+ return {
1345
+ kind: "missing",
1346
+ message: "Booking form not found."
1347
+ };
1348
+ }
1349
+ if (input.feedback.kind === "success") {
1350
+ return {
1351
+ kind: "success",
1352
+ message: input.feedback.message,
1353
+ resourceName: input.feedback.resourceName
1354
+ };
1355
+ }
1356
+ if (input.feedback.kind === "error") {
1357
+ return {
1358
+ kind: "error",
1359
+ message: input.feedback.message
1360
+ };
1361
+ }
1362
+ return { kind: "form" };
1363
+ }
1364
+ function describeUnknownError(error, fallback) {
1365
+ return error instanceof Error ? error.message : fallback;
1366
+ }
1367
+ function isBlankFieldValue(value) {
1368
+ if (value == null) return true;
1369
+ if (typeof value === "string") return value.trim().length === 0;
1370
+ if (value === false) return true;
1371
+ if (Array.isArray(value)) return value.length === 0;
1372
+ return false;
1373
+ }
1374
+ function omitRecordKey(record, key) {
1375
+ if (!(key in record)) return record;
1376
+ const { [key]: _removed, ...rest } = record;
1377
+ return rest;
1378
+ }
1379
+
1380
+ // ../blocks/src/system/runtime/hooks/useHeadlessBookingFlow.ts
1381
+ function useHeadlessBookingFlow(options) {
1382
+ const viewerTimeZone = options.viewerTimeZone ?? getRuntimeTimeZone();
1383
+ const formKey = asFormId(options.formId);
1384
+ const defaultServiceId = toOptionalAppointmentServiceId(
1385
+ options.initialServiceId ?? resolveDefaultBookingServiceId(options.form, options.services) ?? void 0
1386
+ );
1387
+ const defaultResourceId = toOptionalAppointmentResourceId(
1388
+ options.initialResourceId
1389
+ );
1390
+ const initialState = useMemo(
1391
+ () => createInitialBookingFlowReducerState({
1392
+ defaultServiceId,
1393
+ defaultResourceId,
1394
+ initialData: options.initialData
1395
+ }),
1396
+ [defaultResourceId, defaultServiceId, options.initialData]
1397
+ );
1398
+ const [state, dispatch] = useReducer(bookingFlowReducer, initialState);
1399
+ const api = useBlockApi();
1400
+ const [resourcesState, setResourcesState] = useState({ tag: "idle" });
1401
+ const resourcesRequestIdRef = useRef(0);
1402
+ const [appointmentPackages, setAppointmentPackages] = useState({ tag: "idle" });
1403
+ const appointmentPackagesRequestIdRef = useRef(0);
1404
+ const selectedServiceId = state.selectedServiceId ?? defaultServiceId;
1405
+ const selectedResourceId = state.selectedResourceId ?? defaultResourceId;
1406
+ const selectedService = options.services.find((service) => service.id === selectedServiceId) ?? null;
1407
+ const hasPortalBearerAuth = (api.config.authOverrides?.portal ?? api.config.auth).type === "bearer";
1408
+ const canLoadAppointmentPackages = Boolean(
1409
+ options.enableAppointmentPackages && hasPortalBearerAuth && selectedService && (selectedService.priceAmount ?? 0) > 0
1410
+ );
1411
+ const appointmentPackagesUnavailableReason = !options.enableAppointmentPackages ? "disabled" : !selectedService || (selectedService.priceAmount ?? 0) <= 0 ? "missing-priced-service" : !hasPortalBearerAuth ? "missing-portal-auth" : null;
1412
+ const data = useMemo(() => toBookingFormData(state), [state]);
1413
+ const availabilityDatesQuery = useMemo(
1414
+ () => buildBookingAvailabilityQuery({
1415
+ serviceId: selectedServiceId,
1416
+ resourceId: selectedResourceId,
1417
+ timezone: viewerTimeZone
1418
+ }),
1419
+ [selectedResourceId, selectedServiceId, viewerTimeZone]
1420
+ );
1421
+ const availabilitySlotsQuery = useMemo(
1422
+ () => buildBookingAvailabilityQuery({
1423
+ serviceId: selectedServiceId,
1424
+ resourceId: selectedResourceId,
1425
+ selectedDate: state.selectedDate,
1426
+ timezone: viewerTimeZone,
1427
+ requireDate: true
1428
+ }),
1429
+ [selectedResourceId, selectedServiceId, state.selectedDate, viewerTimeZone]
1430
+ );
1431
+ const stepState = useMemo(
1432
+ () => deriveBookingFlowSteps({
1433
+ form: options.form,
1434
+ services: options.services,
1435
+ data,
1436
+ defaultServiceId,
1437
+ defaultResourceId,
1438
+ currentStepId: state.currentStepId,
1439
+ requiresServiceSelection: options.requiresServiceSelection,
1440
+ requiresResourceSelection: options.requiresResourceSelection,
1441
+ enableAppointmentPackages: options.enableAppointmentPackages
1442
+ }),
1443
+ [
1444
+ data,
1445
+ defaultServiceId,
1446
+ defaultResourceId,
1447
+ options.enableAppointmentPackages,
1448
+ options.form,
1449
+ options.requiresResourceSelection,
1450
+ options.requiresServiceSelection,
1451
+ options.services,
1452
+ state.currentStepId
1453
+ ]
1454
+ );
1455
+ const {
1456
+ availableDates,
1457
+ isLoading: isLoadingDates,
1458
+ error: datesError,
1459
+ hasMore,
1460
+ loadMore,
1461
+ loadedRange: availableDatesLoadedRange
1462
+ } = useAvailableDates({
1463
+ siteId: options.siteId,
1464
+ serviceId: availabilityDatesQuery.ok ? availabilityDatesQuery.serviceId : void 0,
1465
+ resourceId: availabilityDatesQuery.ok ? availabilityDatesQuery.resourceId : void 0,
1466
+ timezone: viewerTimeZone,
1467
+ initialDays: 14
1468
+ });
1469
+ const {
1470
+ slots,
1471
+ isLoading: isLoadingSlots,
1472
+ hasLoaded: hasLoadedSlots,
1473
+ error: slotsError
1474
+ } = useAvailableSlots({
1475
+ siteId: options.siteId,
1476
+ serviceId: availabilitySlotsQuery.ok ? availabilitySlotsQuery.serviceId : void 0,
1477
+ resourceId: availabilitySlotsQuery.ok ? availabilitySlotsQuery.resourceId : void 0,
1478
+ selectedDate: availabilitySlotsQuery.ok ? availabilitySlotsQuery.selectedDate ?? "" : "",
1479
+ timezone: viewerTimeZone
1480
+ });
1481
+ const slotViews = useMemo(() => toBookingSlotViews(slots), [slots]);
1482
+ const dateValues = useMemo(
1483
+ () => [...availableDates].sort(),
1484
+ [availableDates]
1485
+ );
1486
+ useEffect(() => {
1487
+ if (state.phase !== "editing") return;
1488
+ const canEvaluateDate = availabilityDatesQuery.ok && Boolean(availableDatesLoadedRange) && !isLoadingDates && !datesError;
1489
+ const canEvaluateSlot = !state.selectedSlot || availabilitySlotsQuery.ok && hasLoadedSlots && !isLoadingSlots && !slotsError;
1490
+ if (!canEvaluateDate || !canEvaluateSlot) return;
1491
+ const invalidation = resolveAvailabilitySelectionInvalidation({
1492
+ selectedDate: state.selectedDate,
1493
+ selectedSlot: state.selectedSlot,
1494
+ availableDates: dateValues,
1495
+ slots: slotViews
1496
+ });
1497
+ if (invalidation.kind === "preserve") return;
1498
+ dispatch({
1499
+ type: "availability-selection-invalidated",
1500
+ invalidation
1501
+ });
1502
+ }, [
1503
+ availabilityDatesQuery.ok,
1504
+ availabilitySlotsQuery.ok,
1505
+ availableDatesLoadedRange,
1506
+ dateValues,
1507
+ datesError,
1508
+ hasLoadedSlots,
1509
+ isLoadingDates,
1510
+ isLoadingSlots,
1511
+ slotViews,
1512
+ slotsError,
1513
+ state.phase,
1514
+ state.selectedDate,
1515
+ state.selectedSlot
1516
+ ]);
1517
+ const fields = useMemo(
1518
+ () => deriveBookingFieldViews({
1519
+ form: options.form,
1520
+ values: state.fieldValues,
1521
+ errors: state.validationErrors.fieldErrors,
1522
+ touched: state.touchedFields,
1523
+ idPrefix: `booking-${options.formId}`
1524
+ }),
1525
+ [
1526
+ options.form,
1527
+ options.formId,
1528
+ state.fieldValues,
1529
+ state.touchedFields,
1530
+ state.validationErrors.fieldErrors
1531
+ ]
1532
+ );
1533
+ useEffect(() => {
1534
+ const requestId = ++resourcesRequestIdRef.current;
1535
+ let isCancelled = false;
1536
+ if (!selectedServiceId) {
1537
+ setResourcesState({ tag: "idle" });
1538
+ return () => {
1539
+ isCancelled = true;
1540
+ resourcesRequestIdRef.current += 1;
1541
+ };
1542
+ }
1543
+ setResourcesState({ tag: "loading" });
1544
+ api.getBookingResources({ serviceId: selectedServiceId }).then((result) => {
1545
+ if (isCancelled || requestId !== resourcesRequestIdRef.current) return;
1546
+ setResourcesState({
1547
+ tag: "loaded",
1548
+ value: (result.options ?? []).map((option) => ({
1549
+ id: asAppointmentResourceId(option.id),
1550
+ label: option.label
1551
+ }))
1552
+ });
1553
+ }).catch((error) => {
1554
+ if (isCancelled || requestId !== resourcesRequestIdRef.current) return;
1555
+ setResourcesState({
1556
+ tag: "error",
1557
+ message: describeUnknownError(
1558
+ error,
1559
+ "Failed to load booking resources."
1560
+ )
1561
+ });
1562
+ });
1563
+ return () => {
1564
+ isCancelled = true;
1565
+ resourcesRequestIdRef.current += 1;
1566
+ };
1567
+ }, [api, selectedServiceId]);
1568
+ useEffect(() => {
1569
+ const requestId = ++appointmentPackagesRequestIdRef.current;
1570
+ let isCancelled = false;
1571
+ if (!canLoadAppointmentPackages || !selectedServiceId) {
1572
+ setAppointmentPackages({ tag: "idle" });
1573
+ return () => {
1574
+ isCancelled = true;
1575
+ appointmentPackagesRequestIdRef.current += 1;
1576
+ };
1577
+ }
1578
+ setAppointmentPackages({ tag: "loading" });
1579
+ api.getEligibleAppointmentPackages({ serviceId: selectedServiceId }).then((result) => {
1580
+ if (isCancelled || requestId !== appointmentPackagesRequestIdRef.current) {
1581
+ return;
1582
+ }
1583
+ setAppointmentPackages({ tag: "loaded", value: result.packages });
1584
+ }).catch((error) => {
1585
+ if (isCancelled || requestId !== appointmentPackagesRequestIdRef.current) {
1586
+ return;
1587
+ }
1588
+ setAppointmentPackages({
1589
+ tag: "error",
1590
+ message: describeUnknownError(
1591
+ error,
1592
+ "Could not check package credits."
1593
+ )
1594
+ });
1595
+ });
1596
+ return () => {
1597
+ isCancelled = true;
1598
+ appointmentPackagesRequestIdRef.current += 1;
1599
+ };
1600
+ }, [api, canLoadAppointmentPackages, selectedServiceId]);
1601
+ useEffect(() => {
1602
+ if (!state.selectedAppointmentPackageId) return;
1603
+ if (appointmentPackages.tag !== "loaded") {
1604
+ if (!canLoadAppointmentPackages) {
1605
+ dispatch({ type: "appointment-package-selected", packageId: null });
1606
+ }
1607
+ return;
1608
+ }
1609
+ const stillEligible = appointmentPackages.value.some(
1610
+ (appointmentPackage) => appointmentPackage.customerPassId === state.selectedAppointmentPackageId
1611
+ );
1612
+ if (!stillEligible) {
1613
+ dispatch({ type: "appointment-package-selected", packageId: null });
1614
+ }
1615
+ }, [
1616
+ appointmentPackages,
1617
+ canLoadAppointmentPackages,
1618
+ state.selectedAppointmentPackageId
1619
+ ]);
1620
+ const resources = useMemo(
1621
+ () => resourcesState.tag === "loaded" ? resourcesState.value : [],
1622
+ [resourcesState]
1623
+ );
1624
+ useEffect(() => {
1625
+ if (options.shouldAutoSelectSingleResource === false) return;
1626
+ if (selectedResourceId) return;
1627
+ if (state.selectedDate || state.selectedSlot) return;
1628
+ if (resources.length !== 1) return;
1629
+ const onlyResource = resources[0];
1630
+ if (onlyResource) {
1631
+ dispatch({ type: "resource-selected", resourceId: onlyResource.id });
1632
+ }
1633
+ }, [
1634
+ options.shouldAutoSelectSingleResource,
1635
+ resources,
1636
+ selectedResourceId,
1637
+ state.selectedDate,
1638
+ state.selectedSlot
1639
+ ]);
1640
+ const appointmentDiscountContext = selectedService && (selectedService.priceAmount ?? 0) > 0 ? { type: "appointment", serviceId: selectedService.id } : null;
1641
+ const discountUi = useDiscountCodeUi({
1642
+ api,
1643
+ discountContext: appointmentDiscountContext,
1644
+ subtotalCents: selectedService?.priceAmount ?? 0
1645
+ });
1646
+ const { submit, clearFeedback } = useBookingSubmission(options.siteId);
1647
+ const submittingRef = useRef(false);
1648
+ const latestRef = useLatestRef({
1649
+ state,
1650
+ data,
1651
+ form: options.form,
1652
+ formId: formKey,
1653
+ services: options.services,
1654
+ defaultServiceId,
1655
+ defaultResourceId,
1656
+ requiresResourceSelection: options.requiresResourceSelection,
1657
+ successMessage: options.successMessage,
1658
+ viewerTimeZone,
1659
+ discountCode: discountUi.discountCode,
1660
+ submit,
1661
+ clearFeedback,
1662
+ setDiscountCode: discountUi.setDiscountCode,
1663
+ navigateToCheckout: options.navigateToCheckout ?? navigateToCheckout,
1664
+ steps: stepState.steps,
1665
+ currentStep: stepState.currentStep,
1666
+ currentStepIndex: stepState.currentStepIndex,
1667
+ stepIds: stepState.stepIds,
1668
+ validations: stepState.validations,
1669
+ initialState
1670
+ });
1671
+ const submitBooking = useCallback(
1672
+ async (submitOptions) => {
1673
+ const latest = latestRef.current;
1674
+ if (submittingRef.current || latest.state.phase === "submitting") {
1675
+ return {
1676
+ ok: false,
1677
+ reason: "blocked",
1678
+ message: "submission-in-flight"
1679
+ };
1680
+ }
1681
+ const plan = buildBookingSubmitPlan({
1682
+ formId: latest.formId,
1683
+ form: latest.form,
1684
+ state: {
1685
+ ...latest.state,
1686
+ discountCode: latest.discountCode
1687
+ },
1688
+ services: latest.services,
1689
+ defaultServiceId: latest.defaultServiceId,
1690
+ defaultResourceId: latest.defaultResourceId,
1691
+ requiresResourceSelection: latest.requiresResourceSelection,
1692
+ altchaSolution: latest.state.altchaSolution,
1693
+ viewerTimeZone: latest.viewerTimeZone,
1694
+ redirect: submitOptions?.redirect ?? "manual",
1695
+ successUrl: submitOptions?.successUrl,
1696
+ cancelUrl: submitOptions?.cancelUrl
1697
+ });
1698
+ if (!plan.ok) {
1699
+ dispatch({ type: "validation-errors-set", errors: plan.errors });
1700
+ return {
1701
+ ok: false,
1702
+ reason: "validation-failed",
1703
+ errors: plan.errors
1704
+ };
1705
+ }
1706
+ submittingRef.current = true;
1707
+ dispatch({ type: "submission-started" });
1708
+ try {
1709
+ const result = await latest.submit(plan.value);
1710
+ if (!result.ok) {
1711
+ dispatch({ type: "altcha-solution-invalidated" });
1712
+ dispatch({
1713
+ type: "feedback-set",
1714
+ feedback: {
1715
+ kind: "error",
1716
+ scope: "submission",
1717
+ message: result.error
1718
+ }
1719
+ });
1720
+ return {
1721
+ ok: false,
1722
+ reason: "submission-failed",
1723
+ message: result.error
1724
+ };
1725
+ }
1726
+ if (result.data.type === "checkout_redirect") {
1727
+ if (plan.redirect === "auto" && typeof window !== "undefined") {
1728
+ latest.navigateToCheckout(result.data.checkoutUrl);
1729
+ }
1730
+ return {
1731
+ ok: true,
1732
+ outcome: {
1733
+ kind: "checkout_redirect",
1734
+ checkoutUrl: result.data.checkoutUrl
1735
+ }
1736
+ };
1737
+ }
1738
+ dispatch({
1739
+ type: "feedback-set",
1740
+ feedback: {
1741
+ kind: "success",
1742
+ message: getBookingSuccessMessage(latest.successMessage),
1743
+ resourceName: result.data.appointment.resourceName
1744
+ }
1745
+ });
1746
+ return {
1747
+ ok: true,
1748
+ outcome: {
1749
+ kind: "booked",
1750
+ appointmentId: result.data.appointment.id,
1751
+ resourceName: result.data.appointment.resourceName
1752
+ }
1753
+ };
1754
+ } finally {
1755
+ submittingRef.current = false;
1756
+ dispatch({ type: "submission-finished" });
1757
+ }
1758
+ },
1759
+ [latestRef]
1760
+ );
1761
+ const actions = useMemo(
1762
+ () => ({
1763
+ selectService: (serviceId) => dispatch({
1764
+ type: "service-selected",
1765
+ serviceId: asAppointmentServiceId(serviceId)
1766
+ }),
1767
+ selectResource: (resourceId) => dispatch({
1768
+ type: "resource-selected",
1769
+ resourceId: resourceId ? asAppointmentResourceId(resourceId) : null
1770
+ }),
1771
+ selectDate: (date) => dispatch({ type: "date-selected", date }),
1772
+ selectSlot: (slot) => dispatch({
1773
+ type: "slot-selected",
1774
+ slot: slot ? toBookingSlotView(slot) : null
1775
+ }),
1776
+ updateField: (fieldId, value) => dispatch({
1777
+ type: "field-updated",
1778
+ fieldId: asBookingFieldId(fieldId),
1779
+ value
1780
+ }),
1781
+ markFieldTouched: (fieldId) => dispatch({ type: "field-touched", fieldId: asBookingFieldId(fieldId) }),
1782
+ selectPaymentOption: (kind) => dispatch({ type: "payment-option-selected", kind }),
1783
+ selectAppointmentPackage: (packageId) => dispatch({
1784
+ type: "appointment-package-selected",
1785
+ packageId: packageId ? asCustomerPassId(packageId) : null
1786
+ }),
1787
+ setDiscountCode: (code) => {
1788
+ dispatch({ type: "discount-code-changed", code });
1789
+ latestRef.current.setDiscountCode(code);
1790
+ },
1791
+ clearDiscountCode: () => {
1792
+ dispatch({ type: "discount-code-changed", code: "" });
1793
+ latestRef.current.setDiscountCode("");
1794
+ },
1795
+ setAltchaSolution: (solution) => dispatch({ type: "altcha-solution-set", solution }),
1796
+ invalidateSpamProtection: () => dispatch({ type: "altcha-solution-invalidated" }),
1797
+ next: async () => {
1798
+ const latest = latestRef.current;
1799
+ if (!latest.currentStep) return { ok: true };
1800
+ const validation = latest.validations.get(latest.currentStep.id) ?? {
1801
+ ok: true
1802
+ };
1803
+ if (!validation.ok) {
1804
+ dispatch({
1805
+ type: "validation-errors-set",
1806
+ errors: validation.errors
1807
+ });
1808
+ return {
1809
+ ok: false,
1810
+ reason: "validation-failed",
1811
+ errors: validation.errors
1812
+ };
1813
+ }
1814
+ dispatch({
1815
+ type: "validation-errors-set",
1816
+ errors: createEmptyBookingValidationErrors()
1817
+ });
1818
+ const nextStepId = latest.stepIds[latest.currentStepIndex + 1];
1819
+ if (nextStepId) dispatch({ type: "step-changed", stepId: nextStepId });
1820
+ return { ok: true };
1821
+ },
1822
+ back: () => {
1823
+ const latest = latestRef.current;
1824
+ const previousStepId = latest.stepIds[latest.currentStepIndex - 1];
1825
+ if (previousStepId) {
1826
+ dispatch({ type: "step-changed", stepId: previousStepId });
1827
+ }
1828
+ },
1829
+ goToStep: async (stepId) => {
1830
+ if (!latestRef.current.stepIds.includes(stepId)) {
1831
+ return { ok: false, reason: "blocked", message: "step-hidden" };
1832
+ }
1833
+ dispatch({ type: "step-changed", stepId });
1834
+ return { ok: true };
1835
+ },
1836
+ submit: submitBooking,
1837
+ reset: () => dispatch({ type: "reset", initial: latestRef.current.initialState }),
1838
+ clearFeedback: () => {
1839
+ dispatch({ type: "feedback-cleared" });
1840
+ latestRef.current.clearFeedback();
1841
+ }
1842
+ }),
1843
+ [latestRef, submitBooking]
1844
+ );
1845
+ const paymentResolution = useMemo(
1846
+ () => resolvePaymentMode({
1847
+ data,
1848
+ defaultServiceId,
1849
+ services: options.services
1850
+ }),
1851
+ [data, defaultServiceId, options.services]
1852
+ );
1853
+ const paymentSelection = useMemo(
1854
+ () => selectedService && paymentResolution.mode !== "unsupported" ? buildBookingPaymentSelectionViewModel({
1855
+ serviceTitle: selectedService.title,
1856
+ paymentTerms: selectedService.paymentTerms,
1857
+ priceAmount: selectedService.priceAmount,
1858
+ priceCurrency: selectedService.priceCurrency,
1859
+ bookingStartAt: state.selectedSlot?.startAt
1860
+ }) : null,
1861
+ [paymentResolution.mode, selectedService, state.selectedSlot?.startAt]
1862
+ );
1863
+ const appointmentPackageItems = appointmentPackages.tag === "loaded" ? appointmentPackages.value : [];
1864
+ const selectedAppointmentPackage = appointmentPackageItems.find(
1865
+ (appointmentPackage) => appointmentPackage.customerPassId === state.selectedAppointmentPackageId
1866
+ ) ?? null;
1867
+ const spamProtectionEnabled = options.spamProtectionEnabled ?? isSpamProtectionEnabled();
1868
+ const status = resolveHeadlessBookingStatus({
1869
+ feedback: state.feedback,
1870
+ phase: state.phase
1871
+ });
1872
+ const view = resolveHeadlessBookingView({
1873
+ form: options.form,
1874
+ feedback: state.feedback
1875
+ });
1876
+ const appointmentPackagesModel = resolveAppointmentPackagesModel({
1877
+ enabled: Boolean(options.enableAppointmentPackages),
1878
+ canLoad: canLoadAppointmentPackages,
1879
+ unavailableReason: appointmentPackagesUnavailableReason,
1880
+ state: appointmentPackages,
1881
+ items: appointmentPackageItems,
1882
+ selected: selectedAppointmentPackage
1883
+ });
1884
+ const payment = paymentResolution.mode === "unsupported" ? {
1885
+ kind: "unsupported",
1886
+ mode: "unsupported",
1887
+ selection: null,
1888
+ selectedCollectionOptionKind: state.selectedCollectionOptionKind,
1889
+ selectedAppointmentPackageId: state.selectedAppointmentPackageId,
1890
+ appointmentPackages: appointmentPackagesModel,
1891
+ errorMessage: paymentResolution.errorMessage ?? "This service cannot be booked right now."
1892
+ } : {
1893
+ kind: "ok",
1894
+ mode: paymentResolution.mode,
1895
+ selection: paymentSelection,
1896
+ selectedCollectionOptionKind: state.selectedCollectionOptionKind,
1897
+ selectedAppointmentPackageId: state.selectedAppointmentPackageId,
1898
+ appointmentPackages: appointmentPackagesModel
1899
+ };
1900
+ return {
1901
+ status,
1902
+ view,
1903
+ steps: stepState.steps,
1904
+ currentStep: stepState.currentStep,
1905
+ data,
1906
+ services: {
1907
+ items: options.services,
1908
+ selected: selectedService,
1909
+ defaultServiceId,
1910
+ requiresSelection: Boolean(options.requiresServiceSelection)
1911
+ },
1912
+ resources: {
1913
+ items: resources,
1914
+ selected: resources.find((resource) => resource.id === selectedResourceId) ?? null,
1915
+ selectedResourceId,
1916
+ state: resourcesState,
1917
+ requiresSelection: Boolean(options.requiresResourceSelection)
1918
+ },
1919
+ availability: {
1920
+ selectedDate: state.selectedDate,
1921
+ selectedSlot: state.selectedSlot,
1922
+ availableDates: dateValues,
1923
+ slots: slotViews,
1924
+ datesState: datesError ? { tag: "error", message: datesError } : isLoadingDates ? { tag: "loading" } : { tag: "loaded", value: dateValues },
1925
+ slotsState: slotsError ? { tag: "error", message: slotsError } : isLoadingSlots ? { tag: "loading" } : { tag: "loaded", value: slotViews },
1926
+ timezone: viewerTimeZone,
1927
+ hasMoreDates: hasMore,
1928
+ loadMoreDates: loadMore
1929
+ },
1930
+ fields: {
1931
+ items: fields,
1932
+ values: state.fieldValues,
1933
+ errors: state.validationErrors.fieldErrors,
1934
+ touched: state.touchedFields
1935
+ },
1936
+ payment,
1937
+ discount: {
1938
+ code: discountUi.discountCode,
1939
+ isOpen: discountUi.discountUiOpen,
1940
+ manualCodeEntryAllowed: discountUi.manualCodeEntryAllowed,
1941
+ loading: discountUi.discountsAvailableLoading,
1942
+ autoAppliedDiscount: discountUi.autoAppliedDiscount,
1943
+ setCode: actions.setDiscountCode,
1944
+ setOpen: discountUi.setDiscountUiOpen,
1945
+ clear: actions.clearDiscountCode
1946
+ },
1947
+ spamProtection: spamProtectionEnabled ? {
1948
+ enabled: true,
1949
+ provider: "altcha",
1950
+ challenge: { url: getChallengeUrl(), auto: "onfocus" },
1951
+ solution: state.altchaSolution
1952
+ } : { enabled: false },
1953
+ feedback: state.feedback,
1954
+ actions
1955
+ };
1956
+ }
1957
+ function navigateToCheckout(checkoutUrl) {
1958
+ window.location.href = checkoutUrl;
1959
+ }
1960
+ function resolvePaymentMode(input) {
1961
+ return resolveBookingPaymentStep(input);
1962
+ }
1963
+ function useLatestRef(value) {
1964
+ const ref = useRef(value);
1965
+ useLayoutEffect(() => {
1966
+ ref.current = value;
1967
+ }, [value]);
1968
+ return ref;
1969
+ }
1970
+ function useAppointmentCheckoutVerification(config) {
1971
+ const api = useBlockApi();
1972
+ const pollingConfig = useMemo(() => {
1973
+ if (!config) return null;
1974
+ return {
1975
+ pollFn: () => api.getAppointmentBookingStatus({ sessionId: config.sessionId }),
1976
+ isComplete: (result) => (result.paymentStatus === "completed" || result.paymentStatus === "not_required") && result.appointmentStatus === "confirmed",
1977
+ isFailed: (result) => result.paymentStatus === "failed" || result.paymentStatus === "cancelled" || result.paymentStatus === "refunded" || result.appointmentStatus === "cancelled",
1978
+ maxAttempts: config.maxAttempts,
1979
+ interval: config.pollInterval
1980
+ };
1981
+ }, [api, config]);
1982
+ const polling = usePolling(pollingConfig);
1983
+ const state = resolveAppointmentCheckoutVerificationState({
1984
+ config,
1985
+ pollingStatus: polling.status,
1986
+ result: polling.result,
1987
+ error: polling.error
1988
+ });
1989
+ return {
1990
+ state,
1991
+ retry: polling.retry
1992
+ };
1993
+ }
1994
+ var DatePicker = ({
1995
+ value,
1996
+ onChange,
1997
+ dateOptions,
1998
+ required = true,
1999
+ isLoading = false,
2000
+ isEmpty = false,
2001
+ hasMore = false,
2002
+ onLoadMore
2003
+ }) => {
2004
+ if (isLoading) {
2005
+ return /* @__PURE__ */ jsxs("div", { className: "ab-stack--sm", children: [
2006
+ /* @__PURE__ */ jsxs("label", { className: "ab-label", children: [
2007
+ "Select Date",
2008
+ required && /* @__PURE__ */ jsx("span", { className: "ab-required", children: "*" })
2009
+ ] }),
2010
+ /* @__PURE__ */ jsx("div", { className: "ab-skeleton" }),
2011
+ /* @__PURE__ */ jsx("p", { className: "ab-text ab-text--muted ab-text--sm", children: "Loading available dates..." })
2012
+ ] });
2013
+ }
2014
+ if (isEmpty) {
2015
+ return /* @__PURE__ */ jsxs("div", { className: "ab-stack--sm", children: [
2016
+ /* @__PURE__ */ jsxs("label", { className: "ab-label", children: [
2017
+ "Select Date",
2018
+ required && /* @__PURE__ */ jsx("span", { className: "ab-required", children: "*" })
2019
+ ] }),
2020
+ /* @__PURE__ */ jsx(FeedbackMessage, { tone: "warning", className: "ab-alert ab-alert--warning", role: "status", children: /* @__PURE__ */ jsxs("p", { className: "ab-text", children: [
2021
+ "No available dates in this period.",
2022
+ hasMore && onLoadMore && /* @__PURE__ */ jsx(
2023
+ "button",
2024
+ {
2025
+ type: "button",
2026
+ onClick: onLoadMore,
2027
+ className: "ab-link",
2028
+ children: "Check later dates"
2029
+ }
2030
+ )
2031
+ ] }) })
2032
+ ] });
2033
+ }
2034
+ return /* @__PURE__ */ jsxs("div", { className: "ab-stack--sm", children: [
2035
+ /* @__PURE__ */ jsxs("label", { htmlFor: "booking-date", className: "ab-label", children: [
2036
+ "Select Date",
2037
+ required && /* @__PURE__ */ jsx("span", { className: "ab-required", children: "*" })
2038
+ ] }),
2039
+ /* @__PURE__ */ jsxs(
2040
+ "select",
2041
+ {
2042
+ id: "booking-date",
2043
+ value,
2044
+ onChange: (e) => onChange(e.target.value),
2045
+ required,
2046
+ className: "ab-select",
2047
+ children: [
2048
+ /* @__PURE__ */ jsx("option", { value: "", children: "Choose a date..." }),
2049
+ dateOptions.map((date) => /* @__PURE__ */ jsx("option", { value: date, children: formatDate(date) }, date))
2050
+ ]
2051
+ }
2052
+ ),
2053
+ hasMore && onLoadMore && /* @__PURE__ */ jsx(
2054
+ "button",
2055
+ {
2056
+ type: "button",
2057
+ onClick: onLoadMore,
2058
+ className: "ab-link",
2059
+ children: "Load more dates \u2192"
2060
+ }
2061
+ )
2062
+ ] });
2063
+ };
2064
+ var TimeSlotSelector = ({
2065
+ value,
2066
+ onChange,
2067
+ slots,
2068
+ isLoading,
2069
+ required = true
2070
+ }) => {
2071
+ return /* @__PURE__ */ jsxs("div", { className: "ab-stack--sm", children: [
2072
+ /* @__PURE__ */ jsxs("label", { htmlFor: "booking-slot", className: "ab-label", children: [
2073
+ "Select Time",
2074
+ required && /* @__PURE__ */ jsx("span", { className: "ab-required", children: "*" })
2075
+ ] }),
2076
+ isLoading ? /* @__PURE__ */ jsx(FeedbackMessage, { tone: "info", className: "ab-alert ab-alert--info ab-center", role: "status", children: "Loading available times..." }) : slots.length === 0 ? /* @__PURE__ */ jsx(FeedbackMessage, { tone: "info", className: "ab-alert ab-alert--info ab-center", role: "status", children: "No available times for this date" }) : /* @__PURE__ */ jsxs(
2077
+ "select",
2078
+ {
2079
+ id: "booking-slot",
2080
+ value,
2081
+ onChange: (e) => onChange(e.target.value),
2082
+ required,
2083
+ className: "ab-select",
2084
+ children: [
2085
+ /* @__PURE__ */ jsx("option", { value: "", children: "Choose a time..." }),
2086
+ slots.map((slot) => /* @__PURE__ */ jsx("option", { value: slot.startAt, children: formatTime(slot.startAt) }, slot.startAt))
2087
+ ]
2088
+ }
2089
+ )
2090
+ ] });
2091
+ };
2092
+ var paymentOptionLayoutStyle = {
2093
+ alignItems: "flex-start",
2094
+ display: "flex",
2095
+ flexDirection: "column",
2096
+ gap: "0.25rem",
2097
+ justifyContent: "flex-start",
2098
+ width: "100%"
2099
+ };
2100
+ var summaryStackStyle = {
2101
+ gap: "0.125rem"
2102
+ };
2103
+ function HeadlessBookingDefaultForm({
2104
+ booking,
2105
+ onSubmit
2106
+ }) {
2107
+ const currentStep = booking.currentStep;
2108
+ const currentStepIndex = currentStep?.visibleIndex ?? 0;
2109
+ const isFirstStep = currentStepIndex === 0;
2110
+ const isLastStep = currentStepIndex === booking.steps.length - 1;
2111
+ const isSubmitting = booking.status.tag === "submitting";
2112
+ const handlePrimaryAction = async () => {
2113
+ const validation = await booking.actions.next();
2114
+ if (!validation.ok) return;
2115
+ if (isLastStep) {
2116
+ await onSubmit();
2117
+ }
2118
+ };
2119
+ if (!currentStep) {
2120
+ return /* @__PURE__ */ jsx("div", { className: "rb-text-center rb-py-12", children: /* @__PURE__ */ jsx("p", { className: "status-muted", children: "No steps available" }) });
2121
+ }
2122
+ return /* @__PURE__ */ jsxs("div", { className: "multi-step-form", children: [
2123
+ /* @__PURE__ */ jsx("div", { className: "rb-mb-8", children: /* @__PURE__ */ jsx(
2124
+ DefaultBookingProgress,
2125
+ {
2126
+ steps: booking.steps,
2127
+ currentIndex: currentStepIndex
2128
+ }
2129
+ ) }),
2130
+ /* @__PURE__ */ jsx("div", { className: "step-content", children: /* @__PURE__ */ jsx(DefaultBookingStepContent, { booking }) }),
2131
+ /* @__PURE__ */ jsx(DefaultBookingErrorSummary, { errors: currentStep.errors }),
2132
+ /* @__PURE__ */ jsxs("div", { className: "rb-flex rb-gap-3 rb-mt-8", children: [
2133
+ !isFirstStep ? /* @__PURE__ */ jsx(
2134
+ "button",
2135
+ {
2136
+ type: "button",
2137
+ onClick: booking.actions.back,
2138
+ className: themeButtonClassName({ variant: "outline", size: "md" }),
2139
+ children: "Back"
2140
+ }
2141
+ ) : null,
2142
+ /* @__PURE__ */ jsx(
2143
+ "button",
2144
+ {
2145
+ type: "button",
2146
+ onClick: () => {
2147
+ void handlePrimaryAction();
2148
+ },
2149
+ disabled: isSubmitting,
2150
+ className: themeButtonClassName({
2151
+ variant: "primary",
2152
+ size: "md",
2153
+ extraClassName: "rb-flex-1"
2154
+ }),
2155
+ children: isSubmitting ? /* @__PURE__ */ jsxs(Fragment, { children: [
2156
+ /* @__PURE__ */ jsx(
2157
+ SpinnerNode,
2158
+ {
2159
+ size: "sm",
2160
+ color: "inherit",
2161
+ className: "rb-mr-2",
2162
+ srLabel: "Submitting"
2163
+ }
2164
+ ),
2165
+ "Submitting..."
2166
+ ] }) : isLastStep ? "Complete" : "Continue"
2167
+ }
2168
+ )
2169
+ ] }),
2170
+ /* @__PURE__ */ jsx(DefaultBookingDiscount, { booking }),
2171
+ /* @__PURE__ */ jsx(DefaultBookingFeedback, { booking }),
2172
+ /* @__PURE__ */ jsx(DefaultBookingAltcha, { booking })
2173
+ ] });
2174
+ }
2175
+ function DefaultBookingProgress({
2176
+ steps,
2177
+ currentIndex
2178
+ }) {
2179
+ return /* @__PURE__ */ jsx("div", { className: "rb-flex rb-items-center rb-justify-between", children: steps.map((step, index) => {
2180
+ const isActive = index === currentIndex;
2181
+ const isCompleted = index < currentIndex;
2182
+ const isLast = index === steps.length - 1;
2183
+ return /* @__PURE__ */ jsxs("div", { className: "rb-flex rb-items-center rb-flex-1", children: [
2184
+ /* @__PURE__ */ jsxs("div", { className: "rb-flex rb-items-center rb-gap-2 rb-cursor-default", children: [
2185
+ /* @__PURE__ */ jsx(
2186
+ "div",
2187
+ {
2188
+ className: `
2189
+ step-circle transition-theme-standard
2190
+ ${isActive ? "step-circle-active" : ""}
2191
+ ${isCompleted ? "step-circle-complete" : ""}
2192
+ `,
2193
+ children: isCompleted ? "\u2713" : index + 1
2194
+ }
2195
+ ),
2196
+ /* @__PURE__ */ jsx(
2197
+ "span",
2198
+ {
2199
+ className: `
2200
+ rb-caption rb-hidden rb-sm-inline
2201
+ ${isCompleted || !isActive ? "status-muted" : ""}
2202
+ `,
2203
+ children: step.label
2204
+ }
2205
+ )
2206
+ ] }),
2207
+ !isLast ? /* @__PURE__ */ jsx("div", { className: "step-connector rb-ml-2 rb-mr-2 rb-hidden rb-sm-block" }) : null
2208
+ ] }, step.id);
2209
+ }) });
2210
+ }
2211
+ function DefaultBookingStepContent({
2212
+ booking
2213
+ }) {
2214
+ const stepId = booking.currentStep?.id;
2215
+ if (!stepId) return null;
2216
+ switch (stepId) {
2217
+ case "service-selection":
2218
+ return /* @__PURE__ */ jsx(DefaultServiceSelectionStep, { booking });
2219
+ case "datetime-selection":
2220
+ return /* @__PURE__ */ jsx(DefaultDateTimeSelectionStep, { booking });
2221
+ case "payment-selection":
2222
+ return /* @__PURE__ */ jsx(HeadlessBookingPaymentSelectionStep, { booking });
2223
+ case "custom-fields":
2224
+ return /* @__PURE__ */ jsx(DefaultDynamicFields, { fields: booking.fields.items, booking });
2225
+ default:
2226
+ return assertNever(stepId);
2227
+ }
2228
+ }
2229
+ function DefaultServiceSelectionStep({
2230
+ booking
2231
+ }) {
2232
+ const services = booking.services.items;
2233
+ const resources = booking.resources.items;
2234
+ const selectedServiceId = booking.services.selected?.id;
2235
+ const selectedResourceId = booking.resources.selectedResourceId;
2236
+ const showServiceSelect = booking.services.requiresSelection || services.length > 1;
2237
+ const showResourceSelect = booking.resources.requiresSelection || resources.length > 1;
2238
+ const allowAnyResource = !booking.resources.requiresSelection;
2239
+ return /* @__PURE__ */ jsxs("div", { className: "ab-stack", children: [
2240
+ /* @__PURE__ */ jsxs("div", { children: [
2241
+ /* @__PURE__ */ jsx("h2", { className: "ab-title", children: "Select Service" }),
2242
+ /* @__PURE__ */ jsx("p", { className: "ab-text ab-text--muted", children: "Choose the service you'd like to book" })
2243
+ ] }),
2244
+ showServiceSelect ? /* @__PURE__ */ jsxs("div", { className: "ab-stack--sm", children: [
2245
+ /* @__PURE__ */ jsx("label", { className: "ab-subtitle", children: "Service" }),
2246
+ /* @__PURE__ */ jsx("div", { className: "ab-grid", children: services.map((service) => {
2247
+ const isSelected = selectedServiceId === service.id;
2248
+ return /* @__PURE__ */ jsx(
2249
+ "button",
2250
+ {
2251
+ type: "button",
2252
+ onClick: () => booking.actions.selectService(service.id),
2253
+ className: `ab-card ${isSelected ? "ab-card--selected" : ""}`,
2254
+ children: /* @__PURE__ */ jsxs("div", { className: "ab-row--between", children: [
2255
+ /* @__PURE__ */ jsxs("div", { children: [
2256
+ /* @__PURE__ */ jsx("div", { className: "ab-subtitle", children: service.title }),
2257
+ service.description ? /* @__PURE__ */ jsx("div", { className: "ab-text ab-text--muted", children: service.description }) : null,
2258
+ service.durationMinutes ? /* @__PURE__ */ jsxs("div", { className: "ab-text ab-text--muted", children: [
2259
+ service.durationMinutes,
2260
+ " minutes"
2261
+ ] }) : null
2262
+ ] }),
2263
+ isSelected ? /* @__PURE__ */ jsx(SelectedCheck, {}) : null
2264
+ ] })
2265
+ },
2266
+ service.id
2267
+ );
2268
+ }) })
2269
+ ] }) : null,
2270
+ booking.resources.state.tag === "loading" ? /* @__PURE__ */ jsxs("div", { className: "ab-center", children: [
2271
+ /* @__PURE__ */ jsx(SpinnerNode, { size: "md", srLabel: "Loading practitioners" }),
2272
+ /* @__PURE__ */ jsx("p", { className: "ab-text ab-text--muted", children: "Loading practitioners..." })
2273
+ ] }) : null,
2274
+ booking.resources.state.tag === "error" ? /* @__PURE__ */ jsx(FeedbackMessage, { tone: "error", className: "ab-alert ab-alert--error", role: "alert", children: booking.resources.state.message }) : null,
2275
+ showResourceSelect && booking.resources.state.tag === "loaded" && resources.length === 0 ? /* @__PURE__ */ jsx(FeedbackMessage, { tone: "error", className: "ab-alert ab-alert--error", role: "alert", children: "No practitioners are available for this service right now." }) : null,
2276
+ showResourceSelect && booking.resources.state.tag !== "loading" && (allowAnyResource || resources.length > 0) ? /* @__PURE__ */ jsxs("div", { className: "ab-stack--sm", children: [
2277
+ /* @__PURE__ */ jsxs("label", { className: "ab-subtitle", children: [
2278
+ "Practitioner",
2279
+ booking.resources.requiresSelection ? "" : " (Optional)"
2280
+ ] }),
2281
+ /* @__PURE__ */ jsx("p", { className: "ab-text ab-text--muted", children: allowAnyResource ? 'Choose a specific practitioner or select "Any Available"' : "Choose the practitioner you'd like to book" }),
2282
+ /* @__PURE__ */ jsxs("div", { className: "ab-grid", children: [
2283
+ allowAnyResource ? /* @__PURE__ */ jsx(
2284
+ "button",
2285
+ {
2286
+ type: "button",
2287
+ onClick: () => booking.actions.selectResource(null),
2288
+ className: `ab-card ${!selectedResourceId ? "ab-card--selected" : ""}`,
2289
+ children: /* @__PURE__ */ jsxs("div", { className: "ab-row--between", children: [
2290
+ /* @__PURE__ */ jsx("div", { className: "ab-subtitle", children: "Any Available" }),
2291
+ !selectedResourceId ? /* @__PURE__ */ jsx(SelectedCheck, {}) : null
2292
+ ] })
2293
+ }
2294
+ ) : null,
2295
+ resources.map((resource) => {
2296
+ const isSelected = selectedResourceId === resource.id;
2297
+ return /* @__PURE__ */ jsx(
2298
+ "button",
2299
+ {
2300
+ type: "button",
2301
+ onClick: () => booking.actions.selectResource(resource.id),
2302
+ className: `ab-card ${isSelected ? "ab-card--selected" : ""}`,
2303
+ children: /* @__PURE__ */ jsxs("div", { className: "ab-row--between", children: [
2304
+ /* @__PURE__ */ jsx("div", { className: "ab-subtitle", children: resource.label }),
2305
+ isSelected ? /* @__PURE__ */ jsx(SelectedCheck, {}) : null
2306
+ ] })
2307
+ },
2308
+ resource.id
2309
+ );
2310
+ })
2311
+ ] })
2312
+ ] }) : null
2313
+ ] });
2314
+ }
2315
+ function DefaultDateTimeSelectionStep({
2316
+ booking
2317
+ }) {
2318
+ const selectedDate = booking.availability.selectedDate ?? "";
2319
+ const selectedSlotStart = booking.availability.selectedSlot?.startAt ?? "";
2320
+ const singleService = booking.services.items.length === 1 ? booking.services.items[0] : void 0;
2321
+ const slots = collapseHeadlessSlotsForDisplay({
2322
+ slots: booking.availability.slots,
2323
+ hasSelectedResource: Boolean(booking.resources.selectedResourceId)
2324
+ });
2325
+ const error = booking.availability.datesState.tag === "error" ? booking.availability.datesState.message : booking.availability.slotsState.tag === "error" ? booking.availability.slotsState.message : null;
2326
+ return /* @__PURE__ */ jsxs("div", { className: "ab-stack", children: [
2327
+ singleService ? /* @__PURE__ */ jsxs("div", { className: "ab-info-box", children: [
2328
+ /* @__PURE__ */ jsx("h3", { className: "ab-subtitle", children: singleService.title }),
2329
+ singleService.description ? /* @__PURE__ */ jsx("p", { className: "ab-text ab-text--muted", children: singleService.description }) : null,
2330
+ singleService.durationMinutes ? /* @__PURE__ */ jsxs("p", { className: "ab-text ab-text--muted", children: [
2331
+ "Duration: ",
2332
+ singleService.durationMinutes,
2333
+ " minutes"
2334
+ ] }) : null
2335
+ ] }) : null,
2336
+ error ? /* @__PURE__ */ jsx(FeedbackMessage, { tone: "error", className: "ab-alert ab-alert--error", role: "alert", children: error }) : null,
2337
+ /* @__PURE__ */ jsx(
2338
+ DatePicker,
2339
+ {
2340
+ value: selectedDate,
2341
+ onChange: (date) => booking.actions.selectDate(date || null),
2342
+ dateOptions: [...booking.availability.availableDates],
2343
+ isLoading: booking.availability.datesState.tag === "loading",
2344
+ isEmpty: booking.availability.datesState.tag !== "loading" && booking.availability.availableDates.length === 0,
2345
+ hasMore: booking.availability.hasMoreDates,
2346
+ onLoadMore: booking.availability.loadMoreDates
2347
+ }
2348
+ ),
2349
+ selectedDate ? /* @__PURE__ */ jsx(
2350
+ TimeSlotSelector,
2351
+ {
2352
+ value: selectedSlotStart,
2353
+ onChange: (slotStartTime) => {
2354
+ booking.actions.selectSlot(
2355
+ booking.availability.slots.find(
2356
+ (slot) => slot.startAt === slotStartTime
2357
+ ) ?? null
2358
+ );
2359
+ },
2360
+ slots,
2361
+ isLoading: booking.availability.slotsState.tag === "loading"
2362
+ }
2363
+ ) : null
2364
+ ] });
2365
+ }
2366
+ function HeadlessBookingPaymentSelectionStep({
2367
+ booking
2368
+ }) {
2369
+ const service = booking.services.selected;
2370
+ const viewModel = booking.payment.selection;
2371
+ if (booking.payment.kind === "unsupported") {
2372
+ return /* @__PURE__ */ jsx(FeedbackMessage, { tone: "error", className: "ab-alert ab-alert--error", role: "alert", children: booking.payment.errorMessage });
2373
+ }
2374
+ if (!service || !viewModel) {
2375
+ return /* @__PURE__ */ jsx(FeedbackMessage, { tone: "error", className: "ab-alert ab-alert--error", role: "alert", children: "Payment options are unavailable for this service right now." });
2376
+ }
2377
+ const packageOptions = booking.payment.appointmentPackages.items;
2378
+ const hasPackageOptions = packageOptions.length > 0;
2379
+ return /* @__PURE__ */ jsxs("div", { className: "ab-stack", children: [
2380
+ /* @__PURE__ */ jsxs("div", { className: "ab-info-box", children: [
2381
+ /* @__PURE__ */ jsx("h3", { className: "ab-subtitle", children: viewModel.sectionTitle }),
2382
+ /* @__PURE__ */ jsx("p", { className: "ab-text ab-text--muted", children: service.title })
2383
+ ] }),
2384
+ booking.payment.appointmentPackages.state.tag === "loading" ? /* @__PURE__ */ jsx(FeedbackMessage, { tone: "info", className: "ab-alert ab-alert--info", role: "status", children: "Checking package credits..." }) : null,
2385
+ booking.payment.appointmentPackages.state.tag === "error" ? /* @__PURE__ */ jsxs(FeedbackMessage, { tone: "info", className: "ab-alert ab-alert--info", role: "status", children: [
2386
+ booking.payment.appointmentPackages.state.message,
2387
+ " You can continue with the normal payment option."
2388
+ ] }) : null,
2389
+ /* @__PURE__ */ jsxs("div", { className: "ab-stack", role: "list", "aria-label": "Payment options", children: [
2390
+ packageOptions.map((appointmentPackage) => {
2391
+ const isSelected = booking.payment.selectedAppointmentPackageId === appointmentPackage.customerPassId;
2392
+ return /* @__PURE__ */ jsx(
2393
+ "button",
2394
+ {
2395
+ type: "button",
2396
+ className: themeButtonClassName({
2397
+ variant: isSelected ? "primary" : "outline",
2398
+ size: "md"
2399
+ }),
2400
+ style: paymentOptionLayoutStyle,
2401
+ onClick: () => booking.actions.selectAppointmentPackage(
2402
+ appointmentPackage.customerPassId
2403
+ ),
2404
+ "aria-pressed": isSelected,
2405
+ children: /* @__PURE__ */ jsx(
2406
+ PaymentOptionContent,
2407
+ {
2408
+ id: appointmentPackage.customerPassId,
2409
+ title: appointmentPackage.passName,
2410
+ description: "Use 1 package credit",
2411
+ summaryRows: [{
2412
+ label: "Balance",
2413
+ value: `${appointmentPackage.creditsRemaining} of ${appointmentPackage.creditsTotal} credits remaining`
2414
+ }]
2415
+ }
2416
+ )
2417
+ },
2418
+ appointmentPackage.customerPassId
2419
+ );
2420
+ }),
2421
+ viewModel.options.map((option) => {
2422
+ const isSelected = !booking.payment.selectedAppointmentPackageId && (booking.payment.selectedCollectionOptionKind === option.kind || viewModel.mode === "single_option");
2423
+ if (viewModel.mode === "single_option" && !hasPackageOptions) {
2424
+ return /* @__PURE__ */ jsx(
2425
+ "div",
2426
+ {
2427
+ className: "ab-card",
2428
+ style: paymentOptionLayoutStyle,
2429
+ children: /* @__PURE__ */ jsx(
2430
+ PaymentOptionContent,
2431
+ {
2432
+ id: option.kind,
2433
+ title: option.title,
2434
+ description: option.description,
2435
+ summaryRows: option.summaryRows,
2436
+ helperText: option.helperText
2437
+ }
2438
+ )
2439
+ },
2440
+ option.kind
2441
+ );
2442
+ }
2443
+ return /* @__PURE__ */ jsx(
2444
+ "button",
2445
+ {
2446
+ type: "button",
2447
+ className: themeButtonClassName({
2448
+ variant: isSelected ? "primary" : "outline",
2449
+ size: "md"
2450
+ }),
2451
+ style: paymentOptionLayoutStyle,
2452
+ onClick: () => booking.actions.selectPaymentOption(option.kind),
2453
+ "aria-pressed": isSelected,
2454
+ children: /* @__PURE__ */ jsx(
2455
+ PaymentOptionContent,
2456
+ {
2457
+ id: option.kind,
2458
+ title: option.title,
2459
+ description: option.description,
2460
+ summaryRows: option.summaryRows,
2461
+ helperText: option.helperText
2462
+ }
2463
+ )
2464
+ },
2465
+ option.kind
2466
+ );
2467
+ })
2468
+ ] })
2469
+ ] });
2470
+ }
2471
+ function PaymentOptionContent({
2472
+ description,
2473
+ helperText,
2474
+ id,
2475
+ summaryRows,
2476
+ title
2477
+ }) {
2478
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
2479
+ /* @__PURE__ */ jsx("span", { children: title }),
2480
+ /* @__PURE__ */ jsx("span", { className: "ab-text ab-text--muted", children: description }),
2481
+ /* @__PURE__ */ jsx("div", { className: "ab-stack", style: summaryStackStyle, children: summaryRows.map((row) => /* @__PURE__ */ jsxs("span", { className: "ab-text ab-text--muted", children: [
2482
+ row.label,
2483
+ ": ",
2484
+ row.value
2485
+ ] }, `${id}-${row.label}`)) }),
2486
+ helperText ? /* @__PURE__ */ jsx("span", { className: "ab-text ab-text--muted", children: helperText }) : null
2487
+ ] });
2488
+ }
2489
+ function DefaultDynamicFields({
2490
+ booking,
2491
+ fields
2492
+ }) {
2493
+ return /* @__PURE__ */ jsx("div", { className: "rb-space-y-6", children: fields.map((field) => /* @__PURE__ */ jsx(DefaultDynamicField, { booking, field }, field.id)) });
2494
+ }
2495
+ function DefaultDynamicField({
2496
+ booking,
2497
+ field
2498
+ }) {
2499
+ const value = field.value ?? "";
2500
+ const error = field.error;
2501
+ const errorId = error ? field.errorId ?? `${field.inputId}-error` : null;
2502
+ const describedBy = [field.helpTextId, errorId].filter(Boolean).join(" ") || void 0;
2503
+ return /* @__PURE__ */ jsxs("div", { className: "rb-space-y-2", children: [
2504
+ /* @__PURE__ */ jsxs("label", { id: field.labelId, htmlFor: field.inputId, className: "form-label", children: [
2505
+ field.label,
2506
+ field.required ? /* @__PURE__ */ jsx("span", { className: "required-marker", children: "*" }) : null
2507
+ ] }),
2508
+ field.helpText ? /* @__PURE__ */ jsx("p", { id: field.helpTextId ?? void 0, className: "rb-caption status-muted", children: field.helpText }) : null,
2509
+ /* @__PURE__ */ jsx(
2510
+ DefaultFieldInput,
2511
+ {
2512
+ field,
2513
+ value,
2514
+ onChange: (nextValue) => booking.actions.updateField(field.id, nextValue),
2515
+ onBlur: () => booking.actions.markFieldTouched(field.id),
2516
+ error,
2517
+ errorId: errorId ?? `${field.inputId}-error`,
2518
+ describedBy
2519
+ }
2520
+ ),
2521
+ error ? /* @__PURE__ */ jsx(
2522
+ FieldValidationMessage,
2523
+ {
2524
+ id: errorId ?? `${field.inputId}-error`,
2525
+ className: "rb-caption status-error",
2526
+ children: error
2527
+ }
2528
+ ) : null
2529
+ ] });
2530
+ }
2531
+ function DefaultFieldInput({
2532
+ describedBy,
2533
+ error,
2534
+ errorId,
2535
+ field,
2536
+ onBlur,
2537
+ onChange,
2538
+ value
2539
+ }) {
2540
+ const strValue = typeof value === "string" ? value : String(value ?? "");
2541
+ const validationProps = getFieldValidationControlProps({
2542
+ message: error,
2543
+ messageId: errorId,
2544
+ describedBy
2545
+ });
2546
+ const commonProps = {
2547
+ id: field.inputId,
2548
+ name: field.id,
2549
+ onBlur,
2550
+ required: field.required,
2551
+ ...validationProps
2552
+ };
2553
+ switch (field.type) {
2554
+ case "textarea":
2555
+ return /* @__PURE__ */ jsx(
2556
+ "textarea",
2557
+ {
2558
+ ...commonProps,
2559
+ value: strValue,
2560
+ onChange: (event) => onChange(event.target.value),
2561
+ placeholder: field.field.placeholder,
2562
+ minLength: field.field.minLength,
2563
+ maxLength: field.field.maxLength,
2564
+ rows: 4,
2565
+ className: "form-textarea"
2566
+ }
2567
+ );
2568
+ case "select":
2569
+ if (field.field.multiple) {
2570
+ return /* @__PURE__ */ jsx(
2571
+ "select",
2572
+ {
2573
+ ...commonProps,
2574
+ value: Array.isArray(value) ? value : [],
2575
+ onChange: (event) => {
2576
+ onChange(Array.from(
2577
+ event.target.selectedOptions,
2578
+ (option) => option.value
2579
+ ));
2580
+ },
2581
+ multiple: true,
2582
+ className: "form-select rb-h-32",
2583
+ children: field.field.options?.map((option) => /* @__PURE__ */ jsx("option", { value: option.value, children: option.label }, option.value))
2584
+ }
2585
+ );
2586
+ }
2587
+ return /* @__PURE__ */ jsxs(
2588
+ "select",
2589
+ {
2590
+ ...commonProps,
2591
+ value: strValue,
2592
+ onChange: (event) => onChange(event.target.value),
2593
+ className: "form-select",
2594
+ children: [
2595
+ /* @__PURE__ */ jsx("option", { value: "", children: "Select an option..." }),
2596
+ field.field.options?.map((option) => /* @__PURE__ */ jsx("option", { value: option.value, children: option.label }, option.value))
2597
+ ]
2598
+ }
2599
+ );
2600
+ case "radio":
2601
+ return /* @__PURE__ */ jsx("div", { className: "rb-space-y-2", children: field.field.options?.map((option) => /* @__PURE__ */ jsxs("label", { className: "rb-flex rb-items-center rb-gap-2 rb-cursor-pointer", children: [
2602
+ /* @__PURE__ */ jsx(
2603
+ "input",
2604
+ {
2605
+ type: "radio",
2606
+ name: field.id,
2607
+ value: option.value,
2608
+ checked: value === option.value,
2609
+ onChange: (event) => onChange(event.target.value),
2610
+ onBlur,
2611
+ required: field.required,
2612
+ ...validationProps,
2613
+ className: "form-radio"
2614
+ }
2615
+ ),
2616
+ /* @__PURE__ */ jsx("span", { className: "rb-caption", children: option.label })
2617
+ ] }, option.value)) });
2618
+ case "checkbox":
2619
+ if (field.field.options && field.field.options.length > 1) {
2620
+ const checkedValues = Array.isArray(value) ? value : [];
2621
+ return /* @__PURE__ */ jsx("div", { className: "rb-space-y-2", children: field.field.options.map((option) => /* @__PURE__ */ jsxs("label", { className: "rb-flex rb-items-center rb-gap-2 rb-cursor-pointer", children: [
2622
+ /* @__PURE__ */ jsx(
2623
+ "input",
2624
+ {
2625
+ type: "checkbox",
2626
+ name: field.id,
2627
+ value: option.value,
2628
+ checked: checkedValues.includes(option.value),
2629
+ onChange: (event) => {
2630
+ onChange(
2631
+ event.target.checked ? [...checkedValues, option.value] : checkedValues.filter((item) => item !== option.value)
2632
+ );
2633
+ },
2634
+ onBlur,
2635
+ ...validationProps,
2636
+ className: "form-checkbox"
2637
+ }
2638
+ ),
2639
+ /* @__PURE__ */ jsx("span", { className: "rb-caption", children: option.label })
2640
+ ] }, option.value)) });
2641
+ }
2642
+ return /* @__PURE__ */ jsxs("label", { className: "rb-flex rb-items-center rb-gap-2 rb-cursor-pointer", children: [
2643
+ /* @__PURE__ */ jsx(
2644
+ "input",
2645
+ {
2646
+ type: "checkbox",
2647
+ id: field.inputId,
2648
+ name: field.id,
2649
+ checked: Boolean(value),
2650
+ onChange: (event) => onChange(event.target.checked),
2651
+ onBlur,
2652
+ required: field.required,
2653
+ ...validationProps,
2654
+ className: "form-checkbox"
2655
+ }
2656
+ ),
2657
+ /* @__PURE__ */ jsx("span", { className: "rb-caption", children: field.field.placeholder || field.label })
2658
+ ] });
2659
+ case "booking_requirement_consent":
2660
+ return /* @__PURE__ */ jsxs("label", { className: "rb-flex rb-items-start rb-gap-2 rb-cursor-pointer", children: [
2661
+ /* @__PURE__ */ jsx(
2662
+ "input",
2663
+ {
2664
+ type: "checkbox",
2665
+ id: field.inputId,
2666
+ name: field.id,
2667
+ checked: Boolean(value),
2668
+ onChange: (event) => onChange(event.target.checked),
2669
+ onBlur,
2670
+ required: field.required,
2671
+ ...validationProps,
2672
+ className: "form-checkbox rb-mt-0.5"
2673
+ }
2674
+ ),
2675
+ /* @__PURE__ */ jsx("span", { className: "rb-caption", children: field.field.placeholder || field.label })
2676
+ ] });
2677
+ case "number":
2678
+ return /* @__PURE__ */ jsx(
2679
+ "input",
2680
+ {
2681
+ ...commonProps,
2682
+ type: "number",
2683
+ value: strValue,
2684
+ onChange: (event) => onChange(event.target.value),
2685
+ placeholder: field.field.placeholder,
2686
+ min: field.field.min,
2687
+ max: field.field.max,
2688
+ className: "form-input"
2689
+ }
2690
+ );
2691
+ case "date":
2692
+ return /* @__PURE__ */ jsx(
2693
+ "input",
2694
+ {
2695
+ ...commonProps,
2696
+ type: "date",
2697
+ value: strValue,
2698
+ onChange: (event) => onChange(event.target.value),
2699
+ min: field.field.min ? String(field.field.min) : void 0,
2700
+ max: field.field.max ? String(field.field.max) : void 0,
2701
+ className: "form-input"
2702
+ }
2703
+ );
2704
+ case "time":
2705
+ return /* @__PURE__ */ jsx(
2706
+ "input",
2707
+ {
2708
+ ...commonProps,
2709
+ type: "time",
2710
+ value: strValue,
2711
+ onChange: (event) => onChange(event.target.value),
2712
+ className: "form-input"
2713
+ }
2714
+ );
2715
+ default:
2716
+ return /* @__PURE__ */ jsx(
2717
+ "input",
2718
+ {
2719
+ ...commonProps,
2720
+ type: field.type,
2721
+ value: strValue,
2722
+ onChange: (event) => onChange(event.target.value),
2723
+ placeholder: field.field.placeholder,
2724
+ minLength: field.field.minLength,
2725
+ maxLength: field.field.maxLength,
2726
+ pattern: field.field.pattern,
2727
+ className: "form-input"
2728
+ }
2729
+ );
2730
+ }
2731
+ }
2732
+ function DefaultBookingDiscount({
2733
+ booking
2734
+ }) {
2735
+ const shouldShowDiscountSection = !booking.payment.selectedAppointmentPackageId && !booking.discount.loading && (booking.discount.manualCodeEntryAllowed || Boolean(booking.discount.autoAppliedDiscount));
2736
+ if (!shouldShowDiscountSection) return null;
2737
+ return /* @__PURE__ */ jsxs("div", { className: "rb-mt-6", children: [
2738
+ booking.discount.manualCodeEntryAllowed ? /* @__PURE__ */ jsx(
2739
+ DiscountCodeSection,
2740
+ {
2741
+ isOpen: booking.discount.isOpen,
2742
+ code: booking.discount.code,
2743
+ onOpen: () => booking.discount.setOpen(true),
2744
+ onChange: booking.discount.setCode,
2745
+ onRemove: () => {
2746
+ booking.discount.clear();
2747
+ booking.discount.setOpen(false);
2748
+ }
2749
+ }
2750
+ ) : null,
2751
+ booking.discount.autoAppliedDiscount && booking.discount.code.trim().length === 0 ? /* @__PURE__ */ jsx(FeedbackMessage, { tone: "info", className: "ab-alert ab-alert--info rb-mt-4", role: "status", children: /* @__PURE__ */ jsxs("p", { className: "ab-text", children: [
2752
+ "Discount available: ",
2753
+ booking.discount.autoAppliedDiscount.publicLabel,
2754
+ " ",
2755
+ "will apply automatically at checkout."
2756
+ ] }) }) : null
2757
+ ] });
2758
+ }
2759
+ function DefaultBookingFeedback({
2760
+ booking
2761
+ }) {
2762
+ if (booking.feedback.kind !== "error") return null;
2763
+ return /* @__PURE__ */ jsxs(FeedbackMessage, { tone: "error", className: "ab-alert ab-alert--error", role: "alert", children: [
2764
+ /* @__PURE__ */ jsx("p", { className: "ab-text", children: booking.feedback.message }),
2765
+ /* @__PURE__ */ jsx(
2766
+ "button",
2767
+ {
2768
+ type: "button",
2769
+ className: themeButtonClassName({ variant: "secondary", size: "sm" }),
2770
+ onClick: booking.actions.clearFeedback,
2771
+ children: "Dismiss"
2772
+ }
2773
+ )
2774
+ ] });
2775
+ }
2776
+ function DefaultBookingAltcha({
2777
+ booking
2778
+ }) {
2779
+ if (!booking.spamProtection.enabled) return null;
2780
+ return /* @__PURE__ */ jsx("div", { className: "ab-altcha", children: /* @__PURE__ */ jsx(
2781
+ AltchaWidget,
2782
+ {
2783
+ challengeUrl: booking.spamProtection.challenge.url,
2784
+ onSolutionChange: booking.actions.setAltchaSolution
2785
+ }
2786
+ ) });
2787
+ }
2788
+ function DefaultBookingErrorSummary({
2789
+ errors
2790
+ }) {
2791
+ if (!hasBookingValidationErrors(errors)) return null;
2792
+ return /* @__PURE__ */ jsxs(FeedbackMessage, { tone: "error", className: "rb-mt-6 alert alert-error", role: "alert", children: [
2793
+ errors.formError ? /* @__PURE__ */ jsx("p", { className: "rb-caption", children: /* @__PURE__ */ jsx("strong", { children: errors.formError }) }) : null,
2794
+ Object.entries(errors.fieldErrors).map(([key, message]) => /* @__PURE__ */ jsx("p", { className: "rb-caption", children: message }, key))
2795
+ ] });
2796
+ }
2797
+ function SelectedCheck() {
2798
+ return /* @__PURE__ */ jsx("div", { className: "ab-check", children: /* @__PURE__ */ jsx(
2799
+ "svg",
2800
+ {
2801
+ fill: "none",
2802
+ strokeLinecap: "round",
2803
+ strokeLinejoin: "round",
2804
+ strokeWidth: "2",
2805
+ viewBox: "0 0 24 24",
2806
+ stroke: "currentColor",
2807
+ children: /* @__PURE__ */ jsx("path", { d: "M5 13l4 4L19 7" })
2808
+ }
2809
+ ) });
2810
+ }
2811
+ function collapseHeadlessSlotsForDisplay(input) {
2812
+ const seenStartTimes = /* @__PURE__ */ new Set();
2813
+ const visibleSlots = [];
2814
+ for (const slot of input.slots) {
2815
+ if (!input.hasSelectedResource) {
2816
+ if (seenStartTimes.has(slot.startAt)) continue;
2817
+ seenStartTimes.add(slot.startAt);
2818
+ }
2819
+ visibleSlots.push({
2820
+ startAt: slot.startAt,
2821
+ endAt: slot.endAt,
2822
+ resourceId: asAppointmentResourceId(slot.resourceId)
2823
+ });
2824
+ }
2825
+ return visibleSlots;
2826
+ }
2827
+ var PORTAL_REBOOK_QUERY_PARAM = "rebook";
2828
+ var PORTAL_REBOOK_QUERY_VALUE = "1";
2829
+ var REBOOK_COMPLETION_MODE_VERIFIED_CHECKOUT = "verified_checkout";
2830
+ var REBOOK_COMPLETION_MODE_BYPASSED = "bypassed";
2831
+ function getBookingRedirectLocationSnapshot() {
2832
+ if (typeof window === "undefined") {
2833
+ return { search: "", hash: "" };
2834
+ }
2835
+ return {
2836
+ search: window.location.search,
2837
+ hash: window.location.hash
2838
+ };
2839
+ }
2840
+ function appendRebookFlag(url, enabled) {
2841
+ if (!enabled) {
2842
+ return url;
2843
+ }
2844
+ const nextUrl = new URL(url);
2845
+ nextUrl.searchParams.set(
2846
+ PORTAL_REBOOK_QUERY_PARAM,
2847
+ PORTAL_REBOOK_QUERY_VALUE
2848
+ );
2849
+ return nextUrl.toString();
2850
+ }
2851
+ var BookingFormClient = ({
2852
+ siteId: siteIdProp,
2853
+ render
2854
+ }) => {
2855
+ const siteId = resolveBookingSiteId(siteIdProp, null);
2856
+ const viewerTimeZone = getRuntimeTimeZone();
2857
+ const form = render.hydration.form;
2858
+ const services = render.hydration.services;
2859
+ const defaultServiceId = render.hydration.serviceId ?? resolveDefaultBookingServiceId(form, services);
2860
+ const initialServiceId = render.hydration.serviceId ?? defaultServiceId ?? void 0;
2861
+ const initialResourceId = render.hydration.resourceId ?? void 0;
2862
+ const isPortalRebook = render.hydration.rebookContext?.kind === "portal_rebook";
2863
+ const hasLoggedRebookCompletionRef = useRef(false);
2864
+ const [redirectState] = useState(
2865
+ () => getBookingRedirectStateFromLocation(getBookingRedirectLocationSnapshot())
2866
+ );
2867
+ const showSpamProtection = render.hydration.spamProtectionEnabled ?? isSpamProtectionEnabled();
2868
+ const api = useBlockApi();
2869
+ const hasPortalAuth = api.config.auth.type === "bearer" || api.config.authOverrides?.portal?.type === "bearer";
2870
+ const formId = render.hydration.formId ?? form?.id ?? "booking-form";
2871
+ const booking = useHeadlessBookingFlow({
2872
+ siteId,
2873
+ formId,
2874
+ form,
2875
+ services,
2876
+ initialServiceId,
2877
+ initialResourceId,
2878
+ initialData: render.hydration.initialData ?? void 0,
2879
+ requiresServiceSelection: render.hydration.requiresServiceSelection,
2880
+ requiresResourceSelection: render.hydration.requiresResourceSelection,
2881
+ shouldAutoSelectSingleResource: render.hydration.shouldAutoSelectSingleResource,
2882
+ enableAppointmentPackages: hasPortalAuth,
2883
+ spamProtectionEnabled: showSpamProtection,
2884
+ successMessage: render.display.successMessage ?? void 0,
2885
+ viewerTimeZone
2886
+ });
2887
+ React2.useEffect(() => {
2888
+ if (!siteId) {
2889
+ console.error("[BookingFormClient] ERROR: siteId is missing!");
2890
+ }
2891
+ if (!form) {
2892
+ console.warn("[BookingFormClient] Form data not loaded");
2893
+ }
2894
+ }, [siteId, form]);
2895
+ const bookingAnchorId = form ? `booking-form-${form.id}` : "booking-form";
2896
+ const verification = useAppointmentCheckoutVerification(
2897
+ redirectState.kind === "success" ? { sessionId: redirectState.sessionId } : null
2898
+ );
2899
+ const viewState = deriveBookingFormViewState({
2900
+ localSuccessResourceName: booking.view.kind === "success" ? booking.view.resourceName : void 0,
2901
+ redirectState,
2902
+ verificationState: verification.state
2903
+ });
2904
+ React2.useEffect(() => {
2905
+ if (viewState.kind === "error") {
2906
+ scrollToBlock(bookingAnchorId);
2907
+ }
2908
+ }, [
2909
+ bookingAnchorId,
2910
+ viewState.kind,
2911
+ viewState.kind === "error" ? viewState.message : null
2912
+ ]);
2913
+ React2.useEffect(() => {
2914
+ if (viewState.kind === "success" && redirectState.kind === "success") {
2915
+ scrollToBlock(bookingAnchorId);
2916
+ }
2917
+ }, [bookingAnchorId, redirectState.kind, viewState.kind]);
2918
+ React2.useEffect(() => {
2919
+ if (!isPortalRebook || viewState.kind !== "success") {
2920
+ return;
2921
+ }
2922
+ if (hasLoggedRebookCompletionRef.current) {
2923
+ return;
2924
+ }
2925
+ hasLoggedRebookCompletionRef.current = true;
2926
+ logger.info("public:appointments:rebook:completed", {
2927
+ siteId,
2928
+ formId: render.hydration.formId,
2929
+ serviceId: booking.services.selected?.id ?? initialServiceId ?? null,
2930
+ resourceId: initialResourceId ?? null,
2931
+ completionMode: redirectState.kind === "success" ? REBOOK_COMPLETION_MODE_VERIFIED_CHECKOUT : REBOOK_COMPLETION_MODE_BYPASSED
2932
+ });
2933
+ }, [
2934
+ initialResourceId,
2935
+ initialServiceId,
2936
+ isPortalRebook,
2937
+ redirectState.kind,
2938
+ render.hydration.formId,
2939
+ booking.services.selected?.id,
2940
+ siteId,
2941
+ viewState.kind
2942
+ ]);
2943
+ if (render.display.state === "missing" || !form || !render.hydration.formId) {
2944
+ return /* @__PURE__ */ jsx(
2945
+ FeedbackMessage,
2946
+ {
2947
+ tone: "error",
2948
+ className: `ab-alert ab-alert--error ${render.display.className ?? ""}`.trim(),
2949
+ role: "alert",
2950
+ children: /* @__PURE__ */ jsx("p", { className: "ab-text", children: render.display.message ?? "Booking form not found. Please check your configuration." })
2951
+ }
2952
+ );
2953
+ }
2954
+ const handleComplete = async () => {
2955
+ const { successUrl, cancelUrl } = buildCheckoutReturnUrls(
2956
+ "booking",
2957
+ bookingAnchorId
2958
+ );
2959
+ const result = await booking.actions.submit({
2960
+ redirect: "manual",
2961
+ successUrl: appendRebookFlag(successUrl, isPortalRebook),
2962
+ cancelUrl: appendRebookFlag(cancelUrl, isPortalRebook)
2963
+ });
2964
+ if (!result.ok) return;
2965
+ if (result.outcome.kind === "checkout_redirect") {
2966
+ window.location.href = result.outcome.checkoutUrl;
2967
+ }
2968
+ };
2969
+ if (viewState.kind === "success") {
2970
+ return /* @__PURE__ */ jsx(
2971
+ SuccessMessage,
2972
+ {
2973
+ message: getBookingSuccessMessage(render.display.successMessage),
2974
+ resourceName: viewState.resourceName,
2975
+ className: render.display.className ?? void 0
2976
+ }
2977
+ );
2978
+ }
2979
+ if (viewState.kind === "verifying") {
2980
+ return /* @__PURE__ */ jsx(
2981
+ "div",
2982
+ {
2983
+ id: bookingAnchorId,
2984
+ className: render.display.className ?? void 0,
2985
+ children: /* @__PURE__ */ jsx(FeedbackMessage, { tone: "info", className: "ab-alert ab-alert--info", role: "status", children: /* @__PURE__ */ jsx("p", { className: "ab-text", children: viewState.message }) })
2986
+ }
2987
+ );
2988
+ }
2989
+ return /* @__PURE__ */ jsxs("div", { id: bookingAnchorId, className: render.display.className ?? void 0, children: [
2990
+ render.hydration.noticeMessage ? /* @__PURE__ */ jsx(FeedbackMessage, { tone: "info", className: "ab-alert ab-alert--info rb-mb-4", role: "status", children: /* @__PURE__ */ jsx("p", { className: "ab-text", children: render.hydration.noticeMessage }) }) : null,
2991
+ viewState.kind === "error" ? /* @__PURE__ */ jsx(FeedbackMessage, { tone: "error", className: "ab-alert ab-alert--error", role: "alert", children: /* @__PURE__ */ jsx("p", { className: "ab-text", children: viewState.message }) }) : null,
2992
+ /* @__PURE__ */ jsx(HeadlessBookingDefaultForm, { booking, onSubmit: handleComplete })
2993
+ ] });
2994
+ };
2995
+ var decode = defineIslandPropsDecoder("booking-form");
2996
+ var enhance = (root, props) => {
2997
+ const { apiBaseUrl, portalToken } = props;
2998
+ const formId = props.render.hydration.formId;
2999
+ if (!formId) {
3000
+ throw new Error("[sdk:islands] booking-form requires formId");
3001
+ }
3002
+ const siteId = props.siteId;
3003
+ if (!siteId) {
3004
+ throw new Error("[sdk:islands] booking-form requires siteId");
3005
+ }
3006
+ if (!apiBaseUrl) {
3007
+ throw new Error("[sdk:islands] booking-form requires apiBaseUrl");
3008
+ }
3009
+ const apiConfig = {
3010
+ baseUrl: apiBaseUrl,
3011
+ auth: { type: "none" },
3012
+ authOverrides: portalToken ? { portal: { type: "bearer", token: portalToken } } : void 0,
3013
+ siteId
3014
+ };
3015
+ return mountReactIsland(root, ({ root: reactRoot }) => {
3016
+ reactRoot.render(
3017
+ /* @__PURE__ */ jsx(BlockApiProvider, { config: apiConfig, children: /* @__PURE__ */ jsx(BookingFormClient, { ...props }) })
3018
+ );
3019
+ });
3020
+ };
3021
+
3022
+ export { decode, enhance };