@wix/himalaya-cli 0.1613.0 → 0.1615.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 (130) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/ui/src/index.d.ts +53 -13
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +3 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/ui.mjs +31 -5
  4. package/dist/cli.mjs +1638 -1330
  5. package/dist/preview-assets.generated.bin +0 -0
  6. package/fixtures/wix-canned.json +4 -0
  7. package/guides/component-reference.json +1 -1
  8. package/guides/component-reference.md +3 -0
  9. package/guides/designing-himi-screens.md +4 -1
  10. package/guides/hhp.md +2 -2
  11. package/guides/sdk-reference.json +28 -4
  12. package/package.json +2 -2
  13. package/template-icons/salon-spa.png +0 -0
  14. package/templates/beauty-salon/tier5-src/_shared/branded-load-state.ts +5 -2
  15. package/templates/beauty-salon/tier5-src/_shared/himi-account.ts +31 -0
  16. package/templates/beauty-salon/tier5-src/_shared/himi-booking-saved.ts +88 -0
  17. package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +50 -3
  18. package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +70 -3
  19. package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +40 -8
  20. package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  21. package/templates/beauty-salon/tier5-src/himi-branded-lite-home/index.ts +178 -12
  22. package/templates/branded-lite/dev/index.ts +192 -0
  23. package/templates/branded-lite/l10n/en.json +1 -1
  24. package/templates/branded-lite/tier5-src/_shared/branded-load-state.ts +5 -2
  25. package/templates/branded-lite/tier5-src/_shared/himi-account.ts +31 -0
  26. package/templates/branded-lite/tier5-src/_shared/himi-booking-saved.ts +88 -0
  27. package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +50 -3
  28. package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +70 -3
  29. package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +40 -8
  30. package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  31. package/templates/branded-lite/tier5-src/himi-branded-lite-galleries/index.ts +104 -12
  32. package/templates/branded-lite/tier5-src/himi-branded-lite-home/index.ts +178 -12
  33. package/templates/community-club/tier5-src/_shared/branded-load-state.ts +5 -2
  34. package/templates/community-club/tier5-src/_shared/himi-account.ts +31 -0
  35. package/templates/community-club/tier5-src/himi-branded-lite-home/index.ts +178 -12
  36. package/templates/events-meetups/tier5-src/_shared/branded-load-state.ts +5 -2
  37. package/templates/events-meetups/tier5-src/_shared/himi-account.ts +31 -0
  38. package/templates/events-meetups/tier5-src/himi-branded-lite-home/index.ts +178 -12
  39. package/templates/gym-fitness/tier5-src/_shared/branded-load-state.ts +5 -2
  40. package/templates/gym-fitness/tier5-src/_shared/himi-account.ts +31 -0
  41. package/templates/gym-fitness/tier5-src/_shared/himi-booking-saved.ts +88 -0
  42. package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +50 -3
  43. package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +70 -3
  44. package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +40 -8
  45. package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  46. package/templates/gym-fitness/tier5-src/himi-branded-lite-home/index.ts +178 -12
  47. package/templates/nonprofit-cause/tier5-src/_shared/branded-load-state.ts +5 -2
  48. package/templates/nonprofit-cause/tier5-src/_shared/himi-account.ts +31 -0
  49. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-home/index.ts +178 -12
  50. package/templates/podcast-creator/tier5-src/_shared/branded-load-state.ts +5 -2
  51. package/templates/podcast-creator/tier5-src/_shared/himi-account.ts +31 -0
  52. package/templates/podcast-creator/tier5-src/himi-branded-lite-home/index.ts +178 -12
  53. package/templates/portfolio-creative/tier5-src/_shared/branded-load-state.ts +5 -2
  54. package/templates/portfolio-creative/tier5-src/_shared/himi-account.ts +31 -0
  55. package/templates/portfolio-creative/tier5-src/himi-branded-lite-home/index.ts +178 -12
  56. package/templates/restaurant/tier5-src/_shared/branded-load-state.ts +5 -2
  57. package/templates/restaurant/tier5-src/_shared/himi-account.ts +31 -0
  58. package/templates/restaurant/tier5-src/himi-branded-lite-home/index.ts +178 -12
  59. package/templates/retail-store/tier5-src/_shared/branded-load-state.ts +5 -2
  60. package/templates/retail-store/tier5-src/_shared/himi-account.ts +31 -0
  61. package/templates/retail-store/tier5-src/himi-branded-lite-home/index.ts +178 -12
  62. package/templates/salon-spa/README.md +53 -0
  63. package/templates/salon-spa/config.ts +272 -0
  64. package/templates/salon-spa/dev/index.ts +19893 -0
  65. package/templates/salon-spa/himalaya.content.json +49 -0
  66. package/templates/salon-spa/himalaya.template.json +14 -0
  67. package/templates/salon-spa/icon.png +0 -0
  68. package/templates/salon-spa/platforms.json +1 -0
  69. package/templates/salon-spa/tier5-src/_shared/app-locale.ts +61 -0
  70. package/templates/salon-spa/tier5-src/_shared/booking-location.ts +147 -0
  71. package/templates/salon-spa/tier5-src/_shared/booking-session.ts +222 -0
  72. package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +278 -0
  73. package/templates/salon-spa/tier5-src/_shared/branded-load-state.ts +109 -0
  74. package/templates/salon-spa/tier5-src/_shared/business-chat-copy.ts +40 -0
  75. package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +48 -0
  76. package/templates/salon-spa/tier5-src/_shared/business-chat-message-model.ts +177 -0
  77. package/templates/salon-spa/tier5-src/_shared/business-chat-model.ts +56 -0
  78. package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +581 -0
  79. package/templates/salon-spa/tier5-src/_shared/business-chat-transport.ts +148 -0
  80. package/templates/salon-spa/tier5-src/_shared/collection-contract.ts +230 -0
  81. package/templates/salon-spa/tier5-src/_shared/collection-query.ts +193 -0
  82. package/templates/salon-spa/tier5-src/_shared/collection-service.ts +400 -0
  83. package/templates/salon-spa/tier5-src/_shared/detail-presence.ts +51 -0
  84. package/templates/salon-spa/tier5-src/_shared/empty-flags.ts +33 -0
  85. package/templates/salon-spa/tier5-src/_shared/engine-intl.ts +508 -0
  86. package/templates/salon-spa/tier5-src/_shared/himi-account.ts +164 -0
  87. package/templates/salon-spa/tier5-src/_shared/himi-booking-saved.ts +88 -0
  88. package/templates/salon-spa/tier5-src/_shared/himi-bookings.ts +318 -0
  89. package/templates/salon-spa/tier5-src/_shared/himi-copy.ts +89 -0
  90. package/templates/salon-spa/tier5-src/_shared/himi-store.ts +7 -0
  91. package/templates/salon-spa/tier5-src/_shared/himi-text.ts +24 -0
  92. package/templates/salon-spa/tier5-src/_shared/host-events.ts +66 -0
  93. package/templates/salon-spa/tier5-src/_shared/hosted-checkout.ts +70 -0
  94. package/templates/salon-spa/tier5-src/_shared/iap-attendance-form.ts +106 -0
  95. package/templates/salon-spa/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
  96. package/templates/salon-spa/tier5-src/_shared/iap-attendance.ts +38 -0
  97. package/templates/salon-spa/tier5-src/_shared/iap-worker.ts +125 -0
  98. package/templates/salon-spa/tier5-src/_shared/local-preferences.ts +38 -0
  99. package/templates/salon-spa/tier5-src/_shared/member-access.ts +52 -0
  100. package/templates/salon-spa/tier5-src/_shared/member-account-routes.ts +52 -0
  101. package/templates/salon-spa/tier5-src/_shared/member-bookings.ts +94 -0
  102. package/templates/salon-spa/tier5-src/_shared/offset-page.ts +10 -0
  103. package/templates/salon-spa/tier5-src/_shared/online-actions.ts +76 -0
  104. package/templates/salon-spa/tier5-src/_shared/owner-load-error.ts +348 -0
  105. package/templates/salon-spa/tier5-src/_shared/payment-setup.ts +6 -0
  106. package/templates/salon-spa/tier5-src/_shared/rich-body.ts +78 -0
  107. package/templates/salon-spa/tier5-src/_shared/rich-content-link.ts +34 -0
  108. package/templates/salon-spa/tier5-src/_shared/weekly-goal.ts +107 -0
  109. package/templates/salon-spa/tier5-src/_shared/wix-service-price.ts +146 -0
  110. package/templates/salon-spa/tier5-src/business-chat/index.ts +669 -0
  111. package/templates/salon-spa/tier5-src/himi-booking-account/index.ts +241 -0
  112. package/templates/salon-spa/tier5-src/himi-booking-bookings/index.ts +466 -0
  113. package/templates/salon-spa/tier5-src/himi-booking-saved/index.ts +103 -0
  114. package/templates/salon-spa/tier5-src/himi-booking-service/index.ts +1170 -0
  115. package/templates/salon-spa/tier5-src/himi-booking-services/index.ts +791 -0
  116. package/templates/salon-spa/tier5-src/himi-booking-time/index.ts +1712 -0
  117. package/templates/salon-spa/tier5-src/himi-branded-lite-agenda/index.ts +950 -0
  118. package/templates/salon-spa/tier5-src/himi-branded-lite-galleries/index.ts +282 -0
  119. package/templates/salon-spa/tier5-src/himi-salon-home/index.ts +544 -0
  120. package/templates/salon-spa/tier5-src/himi-store-account/index.ts +925 -0
  121. package/templates/salon-spa/tokens.json +341 -0
  122. package/templates/yoga-studio/dev/index.ts +192 -0
  123. package/templates/yoga-studio/tier5-src/_shared/branded-load-state.ts +5 -2
  124. package/templates/yoga-studio/tier5-src/_shared/himi-account.ts +31 -0
  125. package/templates/yoga-studio/tier5-src/_shared/himi-booking-saved.ts +88 -0
  126. package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +50 -3
  127. package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +70 -3
  128. package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +40 -8
  129. package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  130. package/templates/yoga-studio/tier5-src/himi-branded-lite-home/index.ts +178 -12
@@ -0,0 +1,1712 @@
1
+ import { paymentSetupMessage } from "../_shared/payment-setup.js";
2
+ import { withOnlineWrites, offlineWritesEnabled } from "../_shared/online-actions.js";
3
+ // Tier-5 bundle: himi-booking — availability and member booking.
4
+ import { defineService as registerService, navigate } from "@himalaya/state";
5
+ // Local-day keying + shared day/time labelling. `addDays` below stays local and
6
+ // UTC-based on purpose: it bounds the availability QUERY window, which is an
7
+ // instant range, not a calendar day.
8
+ import { addDays as addLocalDays, dayKey, dayLabel, timeLabel, toDate } from "@himalaya/time";
9
+ import { dispatch } from "@himalaya/dispatch";
10
+ import { bookSchedule, bookSlot, getService, queryServiceAvailability, registerToWaitlist, type WixBookingSlotFull, type WixSelectedPaymentOption, type WixService } from "@himalaya/wix-bookings";
11
+ import { createCheckout, createOrder } from "@himalaya/wix-ecom";
12
+ import { createRedirectSession, type WixSlot } from "@himalaya/wix-redirects";
13
+ import { isPaymentSetupFailure, payCashierOrder } from "@himalaya/checkout";
14
+ import { hostedCheckoutOpener, isHostedWorker } from "../_shared/hosted-checkout.js";
15
+ import { session as wixPlatformSession } from "@himalaya/wix-platform";
16
+ import { displayName, getMyData, type WixMyData } from "@himalaya/wix-users";
17
+ import { formatServicePrice, serviceIsChargeable } from "../_shared/wix-service-price.js";
18
+ import { stashCopy, copyParam, copyPlural, copyTemplate } from "../_shared/himi-copy.js";
19
+ import { upsert as dbUpsert, remove as dbRemove, enqueue as dbEnqueue, type DbEntity } from "@himalaya/local-db";
20
+ import { locationChoice, readSharedLocationResult, writeSharedLocation } from "../_shared/booking-location.js";
21
+ import { isBridgeUnavailable } from "../_shared/owner-load-error.js";
22
+ import { emitHostEvent, hostEventInstant } from "../_shared/host-events.js";
23
+ import { availabilityBeyond, courseReadWindows, courseSessionsAvailability, courseWindowOpen, slotIdAt } from "../_shared/himi-bookings.js";
24
+ import { wixMediaUrl, WIX_DETAIL_IMAGE, WIX_LIST_IMAGE } from "@himalaya/wix-media";
25
+ import { mayUseRegularBookingCheckout } from "../_shared/iap-attendance.js";
26
+ import { ATTENDANCE_INITIAL, ATTENDANCE_NOT_HYDRATED, createAttendancePurchase, type AttendancePurchaseFields } from "../_shared/iap-attendance-purchase.js";
27
+ import { resetUnhydratedKeysOnOlderRuntime } from "../_shared/iap-worker.js";
28
+
29
+ // Offline-first (data-offline): booking a slot is WRITE-BEHIND against the
30
+ // `member_bookings` entity (the same rows the My Bookings worker reads). The
31
+ // booking is written to the local store OPTIMISTICALLY so My Bookings repaints
32
+ // from cache immediately, then the Wix write is attempted; if it can't reach the
33
+ // bridge/network the mutation is ENQUEUED for replay on reconnect. Every local-db
34
+ // call is wrapped (try/catch) so a build that ships without the `local_database`
35
+ // grant behaves exactly as before (online-only, no cache/queue).
36
+ const ENTITY = "member_bookings";
37
+ async function cachePut(row: DbEntity): Promise<void> { try { await dbUpsert(ENTITY, row); } catch { /* capability not granted */ } }
38
+ async function cacheDrop(id: string): Promise<void> { try { await dbRemove(ENTITY, id); } catch { /* capability not granted */ } }
39
+ async function queueWrite(actionId: string, params: Record<string, string>): Promise<void> { try { await dbEnqueue(actionId, params); } catch { /* capability not granted */ } }
40
+
41
+ /** One bookable slot row. Every row is a real slot.
42
+ *
43
+ * #3276 — the screen used to render the WHOLE 14-day window as one flat agenda
44
+ * (the day heading riding on each day's first row), capped at 24 rows, with the
45
+ * sign-in card under all of it. It now shows ONE day at a time: `days` feeds a
46
+ * day strip, `slots` holds only the selected day's rows, and the strip is the
47
+ * heading — so rows no longer carry a per-row one. */
48
+ interface SlotRow {
49
+ id: string;
50
+ /** Local calendar-day key (`YYYY-MM-DD`) the slot starts on. */
51
+ dayKey: string;
52
+ /** Day label carried for the selected-line copy, on every row. */
53
+ day: string;
54
+ /** Compact day label for the strip chip ("Sun 27"). */
55
+ dayChip: string;
56
+ title: string;
57
+ subtitle: string;
58
+ badge: string;
59
+ noBadge: boolean;
60
+ badgeWarning: boolean;
61
+ badgeNeutral: boolean;
62
+ selectable: boolean;
63
+ notSelectable: boolean;
64
+ selected: boolean;
65
+ notSelected: boolean;
66
+ /** The slot's start (ISO), for ordering and the pill label. */
67
+ start: string;
68
+ /** The staff member (`slot.resource`): id, else name; "" when the slot names none. */
69
+ staffKey: string;
70
+ staffName: string;
71
+ /** The slot's location key (id, else name + address) and its labels; "" when it has none. */
72
+ locKey: string;
73
+ locLabel: string;
74
+ locSub: string;
75
+ /** The location as every bookings screen keys it (`booking-location.ts`): what the shared
76
+ * choice of Services and the Agenda names. "" when it has none. */
77
+ locPlace: string;
78
+ }
79
+
80
+ /** One time in a staff group: a pill with three looks. */
81
+ interface TimePill {
82
+ id: string;
83
+ label: string;
84
+ badge: string;
85
+ hasBadge: boolean;
86
+ noBadge: boolean;
87
+ a11y: string;
88
+ available: boolean;
89
+ notAvailable: boolean;
90
+ picked: boolean;
91
+ notPicked: boolean;
92
+ unavailable: boolean;
93
+ notUnavailable: boolean;
94
+ }
95
+
96
+ /** The selected day's times for one staff member (or the times that name no one). */
97
+ interface StaffGroup {
98
+ id: string;
99
+ name: string;
100
+ image: string;
101
+ hasImage: boolean;
102
+ noImage: boolean;
103
+ noStaffHeader: boolean;
104
+ hasStaffHeader: boolean;
105
+ times: TimePill[];
106
+ }
107
+
108
+ /** One row of the location sheet. `id` "" is "All locations". */
109
+ interface LocationOption {
110
+ id: string;
111
+ label: string;
112
+ sub: string;
113
+ hasSub: boolean;
114
+ noSub: boolean;
115
+ selected: boolean;
116
+ notSelected: boolean;
117
+ a11y: string;
118
+ }
119
+
120
+ interface S extends AttendancePurchaseFields {
121
+ status: "loading" | "ready";
122
+ id: string;
123
+ serviceName: string;
124
+ serviceLine: string;
125
+ /** Service header — image + price + duration from getService (previously
126
+ * discarded); each collapses when the catalog carries none. */
127
+ serviceImage: string;
128
+ hasServiceImage: boolean;
129
+ noServiceImage: boolean;
130
+ servicePrice: string;
131
+ serviceDuration: string;
132
+ /** True when the service carries a positive price — a PAID service routes the
133
+ * booking through the hosted Wix bookings checkout (GPV) instead of the free
134
+ * bookSlot rung. Set from the service payload during load. */
135
+ serviceIsPaid: boolean;
136
+ coursePaymentOption: WixSelectedPaymentOption | "";
137
+ courseAvailable: boolean;
138
+ isCourse: boolean;
139
+ notCourse: boolean;
140
+ scheduleId: string;
141
+ courseStart: string;
142
+ courseEnd: string;
143
+ /** Day-strip chips: every day of the window (a day with no times shows a zero
144
+ * count rather than being silently skipped), in the FilterChips option shape. */
145
+ days: DayOption[];
146
+ /** The day whose slots are shown — defaults to the first day that has any. */
147
+ selectedDay: string;
148
+ /** Full heading for the selected day ("Today", "Mon, Sep 28"). */
149
+ selectedDayLabel: string;
150
+ /** Only the selected day's slots. */
151
+ slots: SlotRow[];
152
+ /** Copy for a selected day with no times; "" when the day has slots. */
153
+ dayEmptyText: string;
154
+ hasDaySlots: boolean;
155
+ noDaySlots: boolean;
156
+ /** No times anywhere in the window — the day strip and heading step aside
157
+ * for the screen-level empty state. */
158
+ noWindowSlots: boolean;
159
+ selectedSlotId: string;
160
+ selectedLine: string;
161
+ emptyText: string;
162
+ message: string;
163
+ successMessage: string;
164
+ hasSuccess: boolean;
165
+ noSuccess: boolean;
166
+ errored: boolean;
167
+ notErrored: boolean;
168
+ isLoading: boolean;
169
+ notLoading: boolean;
170
+ hasSlots: boolean;
171
+ noEmpty: boolean;
172
+ hasSelected: boolean;
173
+ noSelected: boolean;
174
+ /** #3276 — the sign-in/register card belongs to the moment of booking: hidden
175
+ * for a member, and for a visitor until they have picked a time. */
176
+ hideSignIn: boolean;
177
+ hideTimeSignIn: boolean;
178
+ hideCourseSignIn: boolean;
179
+ hideWaitlistSignIn: boolean;
180
+ canBook: boolean;
181
+ cannotBook: boolean;
182
+ hideTimeBook: boolean;
183
+ hideCourseBook: boolean;
184
+ hideWaitlistBook: boolean;
185
+ hideDayPicker: boolean;
186
+ hideDaySlots: boolean;
187
+ hideDayEmpty: boolean;
188
+ showChoosePrompt: boolean;
189
+ hideChoosePrompt: boolean;
190
+ isMember: boolean;
191
+ notMember: boolean;
192
+ identityLine: string;
193
+ authTitle: string;
194
+ authBody: string;
195
+ email: string;
196
+ password: string;
197
+ firstName: string;
198
+ lastName: string;
199
+ isRegistering: boolean;
200
+ notRegistering: boolean;
201
+ bookingInFlight: boolean;
202
+ notBooking: boolean;
203
+ bookingsScreenId: string;
204
+ /** First day of the day strip (`YYYY-MM-DD`): today, or the first two weeks with times. */
205
+ windowStart: string;
206
+ /** The location filter: "" is every location. */
207
+ locationId: string;
208
+ locationLabel: string;
209
+ locationA11y: string;
210
+ locationOptions: LocationOption[];
211
+ locationSheetOpen: boolean;
212
+ /** Two or more locations: the trailing side of the day row opens the sheet. */
213
+ hasLocationMenu: boolean;
214
+ noLocationMenu: boolean;
215
+ /** Exactly one location with a name or an address: its name, not tappable. */
216
+ singleLocation: string;
217
+ hasSingleLocation: boolean;
218
+ noSingleLocation: boolean;
219
+ /** The selected day's times, grouped by staff member. */
220
+ groups: StaffGroup[];
221
+ /** The selected day has no time to book (the window has some). */
222
+ dayEmpty: boolean;
223
+ hasDayTimes: boolean;
224
+ /** Nothing to book in the next 90 days: one message under the header. */
225
+ noUpcoming: boolean;
226
+ hasUpcoming: boolean;
227
+ /** The result of a tap, in a sheet above the tab bar: a booking stays until closed, other
228
+ * results close by themselves. */
229
+ resultText: string;
230
+ resultSticky: boolean;
231
+ resultAuto: boolean;
232
+ resultIsBooking: boolean;
233
+ resultNotBooking: boolean;
234
+ /** A course has no times: its messages stay on the screen, under its button. */
235
+ courseMessage: string;
236
+ }
237
+
238
+ /** One day-strip chip — the `FilterChips` option shape `dateStrip` binds. */
239
+ interface DayOption {
240
+ id: string;
241
+ label: string;
242
+ count: number;
243
+ hasSlots: boolean;
244
+ }
245
+
246
+ let slotsById = new Map<string, WixBookingSlotFull>();
247
+ let waitlistIds = new Set<string>();
248
+ let regularCheckoutAllowed = false;
249
+ let regularServicePrice = "";
250
+ let lastReady: S | undefined;
251
+ const applePurchase = createAttendancePurchase({ kind: "bookingsService", prefix: "time", pollAction: "himiBookingTime.iapPoll", onChange: () => { void svc.iapPublish(); } });
252
+ /** Every slot row in the window, across all days. Kept out of worker state so
253
+ * the state the host receives holds one day's rows, not the whole window. */
254
+ let allRows: SlotRow[] = [];
255
+
256
+ /** Days the strip offers, from today. */
257
+ // No row cap. The old flat list capped the WHOLE window at 24 rows, which
258
+ // silently dropped every day after the first one or two — times a customer
259
+ // could neither see nor book. Worker state now carries one day's rows, so the
260
+ // bound that list needed is gone, and the availability query has no paging.
261
+ const WINDOW_DAYS = 14;
262
+ /** How far ahead the screen looks for a first time, and for "Check Next Availability". */
263
+ const HORIZON_DAYS = 90;
264
+ const DAY_MS = 86_400_000;
265
+
266
+ /** The service record of the last load (its type and waitlist policy shape new rows). */
267
+ let lastService: WixService | undefined;
268
+ /** The selected day's times that cannot be booked (full, too early, too late), by day. They show
269
+ * greyed. Read with `bookable: false` for one day at a time, because that response is large. */
270
+ let greyByDay = new Map<string, SlotRow[]>();
271
+ /** Staff photos from the service's `staffMemberDetails`, by staff id and by name. */
272
+ let staffPhotos = { byId: new Map<string, string>(), byName: new Map<string, string>() };
273
+ /** True while a tap books. A second tap, or a tap during a booking, sends nothing. */
274
+ let bookingLock = false;
275
+
276
+ /** A course booking created by an earlier tap whose checkout or payment did not finish. It stays
277
+ * on Wix, so the next tap pays for THIS booking instead of creating a second one for the same
278
+ * customer. `ordered` once its order exists: the order is what holds the place. Cleared once a
279
+ * booking goes through. */
280
+ let pendingCourseBooking: { scheduleId: string; email: string; ordered?: boolean; booking: { id?: string; startDate?: string; endDate?: string } } | null = null;
281
+
282
+ /** A course occupies one place for its whole schedule, so it can be booked only while its
283
+ * booking window is open and EVERY upcoming session still has room — not just the next one
284
+ * (_shared/himi-bookings.ts, the same rule the service screen's button uses). */
285
+ /** Whether this customer may book the course now. Once an order of theirs holds a place (`held`),
286
+ * counting places would refuse them their own, so only the booking window is checked again. */
287
+ async function courseOpenTo(service: WixService, now: Date, held: boolean): Promise<boolean> {
288
+ return held ? !!service.schedule?.id && courseWindowOpen(service, now.getTime()) : courseIsBookable(service, now);
289
+ }
290
+
291
+ async function courseIsBookable(service: WixService, now: Date): Promise<boolean> {
292
+ const current = now.getTime();
293
+ if (!service.schedule?.id || !courseWindowOpen(service, current)) return false;
294
+ const read = courseReadWindows(service, current);
295
+ const reads = read.windows.map(([from, to]) =>
296
+ queryServiceAvailability(service.id, isoDate(new Date(from)), isoDate(new Date(to)), { bookable: false }));
297
+ const entries = (await Promise.all(reads)).flatMap((res) => res.availabilityEntries ?? []);
298
+ const course = courseSessionsAvailability(entries, current, read.complete);
299
+ return course.found && course.bookable;
300
+ }
301
+
302
+ /** The service screen opens this screen with `course: "true"` for a course. When the service
303
+ * record then fails to load, the screen errors instead of listing the course's sessions as
304
+ * single times (where a booking would take one session, not the whole course). */
305
+ const courseInput = (() => {
306
+ const course = (globalThis as { himalaya?: { input?: { course?: unknown } } }).himalaya?.input?.course;
307
+ return course === true || course === "true";
308
+ })();
309
+
310
+ function inputId(): string {
311
+ // WS-C — `himalaya.input.id` is the PRIMARY contract: hosts thread the row's
312
+ // `id` nav param (himi.open { screenId: "himi_time", id }) into worker input.
313
+ // The screenId-slice below is #570 back-compat for pre-params releases with
314
+ // baked `himi_time_<id>` screenIds — do not author against this.
315
+ // `serviceId` is the same thing under the name a HOST uses to open this screen (the deep-entry
316
+ // contract: Branded Lite README, "Opening at a booking").
317
+ const input = (globalThis as { himalaya?: { input?: { id?: unknown; serviceId?: unknown; screenId?: unknown } } }).himalaya?.input;
318
+ if (typeof input?.id === "string" && input.id) return input.id;
319
+ if (typeof input?.serviceId === "string" && input.serviceId) return input.serviceId;
320
+ const screenId = typeof input?.screenId === "string" ? input.screenId : "";
321
+ return screenId.startsWith("himi_time_") ? screenId.slice("himi_time_".length) : "";
322
+ }
323
+
324
+ /** The time a host opened this screen at (`himalaya.input.start`, ISO 8601), or "". Every load
325
+ * preselects that slot (or opens on its day) until the member picks a day or a time themselves —
326
+ * not just the first, because the boot load and the screen's own `load` can overlap. */
327
+ /** Counts the customer's picks. A load that sees it move ran while they picked a time. */
328
+ let selectionRevision = 0;
329
+ // Each guest tap that asks the session first: only the newest answers, so two quick taps whose
330
+ // reads come back out of order never pick the older time or open a second sign-in.
331
+ let sessionTaps = 0;
332
+
333
+ let pendingStart = (() => {
334
+ const start = (globalThis as { himalaya?: { input?: { start?: unknown } } }).himalaya?.input?.start;
335
+ return typeof start === "string" && Number.isFinite(Date.parse(start)) ? start : "";
336
+ })();
337
+ /** The exact session a host opened this screen at (`himalaya.input.sessionId`), when it names one:
338
+ * two sessions of a class can start at the same instant, and `start` alone picks the first. */
339
+ /** A host's `start` past the two-week window, kept for the life of this worker: a pick on that day
340
+ * clears `pendingStart`, yet the reload after the guest sign-in step must still read it. */
341
+ const farStart = pendingStart;
342
+ let pendingSession = (() => {
343
+ const id = (globalThis as { himalaya?: { input?: { sessionId?: unknown } } }).himalaya?.input?.sessionId;
344
+ return typeof id === "string" ? id.trim() : "";
345
+ })();
346
+
347
+ function strOf(arg: unknown): string {
348
+ if (typeof arg === "string") return arg;
349
+ const a = arg as { value?: string; text?: string } | undefined;
350
+ return a?.value ?? a?.text ?? "";
351
+ }
352
+
353
+ /** Bound an identity probe to a real budget: reject after `ms` so the callers'
354
+ * catch-arms fall back (guest identity) instead of pinning the skeleton behind
355
+ * a hung dispatch. Load awaits probeMember BEFORE availability, so an unbounded
356
+ * probe used to stall the whole screen. Tier-5 JSC workers are timer-less
357
+ * (no setTimeout is installed there); where no timer exists we degrade to the
358
+ * raw promise — the WebView/web-worker hosts (where the hang was observed)
359
+ * do expose one. */
360
+ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
361
+ const host = globalThis as {
362
+ setTimeout?: (fn: () => void, ms: number) => unknown;
363
+ clearTimeout?: (timer: unknown) => void;
364
+ };
365
+ if (typeof host.setTimeout !== "function") return promise;
366
+ return new Promise<T>((resolve, reject) => {
367
+ const timer = host.setTimeout!(() => reject(new Error(`timed out after ${ms}ms`)), ms);
368
+ const settle = () => { try { host.clearTimeout?.(timer); } catch { /* best-effort */ } };
369
+ promise.then(
370
+ (value) => { settle(); resolve(value); },
371
+ (error) => { settle(); reject(error instanceof Error ? error : new Error(String(error))); },
372
+ );
373
+ });
374
+ }
375
+
376
+ function isoDate(date: Date): string {
377
+ return date.toISOString();
378
+ }
379
+
380
+ function fmtRange(start?: string, end?: string): { day: string; title: string; subtitle: string } {
381
+ const s = toDate(start);
382
+ if (!s) return { day: "", title: "Available time", subtitle: "" };
383
+ const endTime = timeLabel(end);
384
+ return {
385
+ // "Today" / "Tomorrow" / "Mon, Jun 9" — the shared relative window, so this
386
+ // screen's headings read the same as every other agenda in the app.
387
+ day: dayLabel(s),
388
+ title: timeLabel(s),
389
+ subtitle: endTime ? `Until ${endTime}` : "Bookable slot",
390
+ };
391
+ }
392
+
393
+ /** Service price/duration/image display fields (getService previously discarded
394
+ * them).
395
+ *
396
+ * Price and currency come from `_shared/wix-service-price.ts`, the one door #816
397
+ * introduced so these four workers could not drift. This one was left behind: it kept
398
+ * its own probe of `payment.price` / `payment.currency`, which the live Bookings v2
399
+ * payload does not have — the fixed price is nested at
400
+ * `payment.fixed.price.{value,currency}`. The amount still resolved via the top-level
401
+ * fallback, but the currency fell through to the hardcoded `"USD"`, so a real ILS site
402
+ * rendered `$120` for a ₪120 service — on the CONFIRM screen, the last thing a customer
403
+ * reads before committing. Measured on a live site, where the list screen (already
404
+ * migrated) said ₪120 and this one said $120 for the same service. */
405
+ function serviceHeaderFields(service: unknown): { image: string; price: string; duration: string; paid: boolean } {
406
+ const raw = (service ?? {}) as { duration?: unknown };
407
+ const price = formatServicePrice(service);
408
+ // `paid` routes the PAYMENT branch (see bookSelected), so it is a money decision, not a
409
+ // display one. `serviceAmount() > 0` is the wrong predicate for it: that returns
410
+ // undefined for VARIED/CUSTOM, which are chargeable services with no single figure — so
411
+ // a booking for one went down the free branch, booked and never charged. #816 shipped
412
+ // that shape and review caught it here.
413
+ const paid = serviceIsChargeable(service);
414
+ const minutes = typeof raw.duration === "number" ? raw.duration : (typeof raw.duration === "string" && raw.duration.trim() ? Number(raw.duration) : 0);
415
+ let duration = "";
416
+ if (minutes && !Number.isNaN(minutes)) {
417
+ if (minutes < 60) duration = `${minutes} min`;
418
+ else {
419
+ const hours = Math.floor(minutes / 60);
420
+ const rest = minutes % 60;
421
+ duration = rest ? `${hours} hr ${rest} min` : `${hours} hr`;
422
+ }
423
+ }
424
+ return { image: serviceImageUrl(service), price, duration, paid };
425
+ }
426
+
427
+ function serviceImageUrl(value: unknown): string {
428
+ if (typeof value === "string") return wixMediaUrl(value, WIX_DETAIL_IMAGE);
429
+ if (!value || typeof value !== "object") return "";
430
+ if (Array.isArray(value)) {
431
+ for (const item of value) {
432
+ const url = serviceImageUrl(item);
433
+ if (url) return url;
434
+ }
435
+ return "";
436
+ }
437
+ const node = value as Record<string, unknown>;
438
+ for (const direct of [node.url, node.src, node.imageUrl, node.imageSrc]) {
439
+ const url = wixMediaUrl(direct, WIX_DETAIL_IMAGE);
440
+ if (url) return url;
441
+ }
442
+ for (const key of ["image", "thumbnail", "mediaItem", "mainMedia", "main", "media", "items", "photos", "gallery", "coverImage"]) {
443
+ const url = serviceImageUrl(node[key]);
444
+ if (url) return url;
445
+ }
446
+ return "";
447
+ }
448
+
449
+ /** The day id out of a chip-selection payload: FilterChips' onChange contract is
450
+ * `{ selectedIds }` on every renderer; the string / `{ dayId }` / `{ id }` arms
451
+ * keep direct dispatches working. A re-tap of the active chip emits `[]`. */
452
+ function chosenDay(arg: unknown): string {
453
+ if (typeof arg === "string") return arg;
454
+ const a = arg as { selectedIds?: unknown; dayId?: unknown; id?: unknown } | undefined;
455
+ const ids = a?.selectedIds;
456
+ if (Array.isArray(ids)) return typeof ids[0] === "string" ? ids[0] : "";
457
+ if (typeof ids === "string") return ids;
458
+ if (typeof a?.dayId === "string") return a.dayId;
459
+ return typeof a?.id === "string" ? a.id : "";
460
+ }
461
+
462
+ /** A slot's location: a key to compare by (id, else name + address) and its labels. A location
463
+ * with neither a name nor an address has no key: it is not offered and not counted. */
464
+ function locationOf(slot: WixBookingSlotFull): { key: string; label: string; sub: string } {
465
+ const loc = (slot.location ?? {}) as { id?: unknown; name?: unknown; formattedAddress?: unknown; locationType?: unknown };
466
+ const name = typeof loc.name === "string" ? loc.name.trim() : "";
467
+ const address = typeof loc.formattedAddress === "string" ? loc.formattedAddress.trim() : "";
468
+ const id = typeof loc.id === "string" ? loc.id : "";
469
+ if (loc.locationType === "CUSTOMER") return { key: id || "customer", label: copyParam("bookingCommon.clientPlace", "Client's place"), sub: "" };
470
+ if (!name && !address) return { key: "", label: "", sub: "" };
471
+ return { key: id || `${name}|${address}`, label: name || address, sub: name && address ? address : "" };
472
+ }
473
+
474
+ function staffOf(slot: WixBookingSlotFull): { key: string; name: string } {
475
+ const resource = (slot.resource ?? {}) as { id?: unknown; name?: unknown };
476
+ const name = typeof resource.name === "string" ? resource.name.trim() : "";
477
+ const id = typeof resource.id === "string" ? resource.id : "";
478
+ return { key: id || name, name };
479
+ }
480
+
481
+ /** The rows for one availability response. A class lists its full sessions too (greyed, or a
482
+ * waitlist). Two slots at the same instant with different staff get distinct ids. */
483
+ function slotRows(entries: ReadonlyArray<{ slot?: WixBookingSlotFull; bookable?: boolean; openSpots?: number; waitingList?: { openSpots?: number } }>, service: WixService | undefined): { rows: SlotRow[]; slots: Map<string, WixBookingSlotFull>; waitlist: Set<string> } {
484
+ const slots = new Map<string, WixBookingSlotFull>();
485
+ const waitlist = new Set<string>();
486
+ const rows: SlotRow[] = [];
487
+ const used = new Set<string>();
488
+ // Group by LOCAL calendar day, not by the formatted heading: two
489
+ // distinct days can render the same text, and a UTC-derived key can
490
+ // name a different day than the heading beside it.
491
+ const classService = service?.type === "CLASS";
492
+ for (const [index, entry] of entries
493
+ .filter((candidate) => candidate.slot?.startDate && (candidate.bookable !== false || (classService && candidate.openSpots === 0)))
494
+ .entries()) {
495
+ const slot = entry.slot as WixBookingSlotFull;
496
+ const key = dayKey(slot.startDate);
497
+ if (!key) continue;
498
+ const staff = staffOf(slot);
499
+ const base = String(slot.sessionId ?? slot.startDate ?? `slot-${index}`);
500
+ const slotId = used.has(base) ? `${base}#${staff.key || index}` : base;
501
+ used.add(slotId);
502
+ const openSpots = classService && typeof entry.openSpots === "number" && entry.openSpots >= 0 ? entry.openSpots : undefined;
503
+ const isWaitlist = openSpots === 0 && service?.bookingPolicy?.waitlistPolicy?.enabled === true &&
504
+ typeof entry.waitingList?.openSpots === "number" && entry.waitingList.openSpots > 0;
505
+ const selectable = (entry.bookable !== false && openSpots !== 0) || isWaitlist;
506
+ if (isWaitlist) waitlist.add(slotId);
507
+ else if (selectable) slots.set(slotId, slot);
508
+ const label = fmtRange(slot.startDate, slot.endDate);
509
+ const loc = locationOf(slot);
510
+ rows.push({
511
+ id: slotId,
512
+ dayKey: key,
513
+ day: label.day,
514
+ dayChip: dayLabel(slot.startDate, { format: "compact" }),
515
+ title: label.title,
516
+ subtitle: label.subtitle,
517
+ badge: openSpots == null ? "" : openSpots > 0
518
+ ? copyPlural("time.spotsLeft", "{n, plural, one {# spot left} other {# spots left}}", openSpots)
519
+ : isWaitlist ? copyParam("time.waitlistBadge", "Waitlist") : copyParam("time.fullBadge", "Full"),
520
+ noBadge: openSpots == null,
521
+ badgeWarning: openSpots != null && openSpots > 0 && openSpots <= 3,
522
+ badgeNeutral: openSpots == null || openSpots === 0 || openSpots > 3,
523
+ selectable,
524
+ notSelectable: !selectable,
525
+ selected: false,
526
+ notSelected: true,
527
+ start: slot.startDate ?? "",
528
+ staffKey: staff.key,
529
+ staffName: staff.name,
530
+ locKey: loc.key,
531
+ locLabel: loc.label,
532
+ locSub: loc.sub,
533
+ locPlace: locationChoice(slot.location as Parameters<typeof locationChoice>[0]).key,
534
+ });
535
+ }
536
+ return { rows, slots, waitlist };
537
+ }
538
+
539
+ /** The unbookable times of one day's `bookable: false` response that are still ahead (full,
540
+ * too early or too late to book). Never a past time, and never one the window already lists. */
541
+ function greyRows(entries: ReadonlyArray<{ slot?: WixBookingSlotFull; bookable?: boolean }>, day: string, known: SlotRow[], now: number): SlotRow[] {
542
+ const seen = new Set(known.map((row) => `${row.start}|${row.staffKey}`));
543
+ const unbookable = entries.filter((entry) => entry.bookable === false && entry.slot?.startDate
544
+ && Date.parse(entry.slot.startDate) > now && dayKey(entry.slot.startDate) === day)
545
+ .map((entry) => ({ ...entry, bookable: true }));
546
+ return slotRows(unbookable, undefined).rows
547
+ .filter((row) => !seen.has(`${row.start}|${row.staffKey}`))
548
+ .map((row) => ({ ...row, id: `grey:${row.id}`, selectable: false, notSelectable: true }));
549
+ }
550
+
551
+ /** Earliest day first. A plain string compare: `YYYY-MM-DD` keys sort as text, and a worker on
552
+ * a watch engine has no locale-aware string compare. */
553
+ function byDay(a: SlotRow, b: SlotRow): number {
554
+ return a.dayKey < b.dayKey ? -1 : a.dayKey > b.dayKey ? 1 : 0;
555
+ }
556
+
557
+ function locationMatches(row: SlotRow, locationId: string): boolean {
558
+ return !locationId || row.locKey === locationId;
559
+ }
560
+
561
+ /** Every distinct location of the window's times (the greyed ones read so far included), in the
562
+ * order they first appear. */
563
+ function windowLocations(): Array<{ key: string; label: string; sub: string; place: string }> {
564
+ const seen = new Map<string, { key: string; label: string; sub: string; place: string }>();
565
+ for (const row of [...allRows, ...[...greyByDay.values()].flat()]) if (row.locKey && !seen.has(row.locKey)) seen.set(row.locKey, { key: row.locKey, label: row.locLabel, sub: row.locSub, place: row.locPlace });
566
+ return [...seen.values()];
567
+ }
568
+
569
+ function staffPhoto(key: string, name: string): string {
570
+ return staffPhotos.byId.get(key) ?? (name ? staffPhotos.byName.get(name.toLowerCase()) : undefined) ?? "";
571
+ }
572
+
573
+ function readStaffPhotos(service: WixService | undefined): void {
574
+ const byId = new Map<string, string>();
575
+ const byName = new Map<string, string>();
576
+ for (const member of service?.staffMemberDetails?.staffMembers ?? []) {
577
+ const url = wixMediaUrl(member?.mainMedia?.image?.url, WIX_LIST_IMAGE);
578
+ if (!url) continue;
579
+ const m = member as { staffMemberId?: string; resourceId?: string; name?: string };
580
+ if (m.staffMemberId) byId.set(m.staffMemberId, url);
581
+ if (m.resourceId) byId.set(m.resourceId, url);
582
+ if (m.name) byName.set(m.name.trim().toLowerCase(), url);
583
+ }
584
+ staffPhotos = { byId, byName };
585
+ }
586
+
587
+ /** The selected day's times after the location filter, grouped by staff member. Groups are ordered
588
+ * by their first time (ties by name); a group's times by start. Times that name no staff member
589
+ * share one group with no header. */
590
+ function groupsOf(s: S): StaffGroup[] {
591
+ const day = [...allRows, ...(greyByDay.get(s.selectedDay) ?? [])]
592
+ .filter((row) => row.dayKey === s.selectedDay && locationMatches(row, s.locationId))
593
+ .sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
594
+ const anyStaff = day.some((row) => row.staffKey);
595
+ const groups = new Map<string, { name: string; rows: SlotRow[] }>();
596
+ for (const row of day) {
597
+ const key = anyStaff ? row.staffKey : "";
598
+ const group = groups.get(key) ?? { name: row.staffName, rows: [] };
599
+ group.rows.push(row);
600
+ groups.set(key, group);
601
+ }
602
+ return [...groups.entries()]
603
+ .sort(([ka, a], [kb, b]) => (!ka ? 1 : !kb ? -1 : 0) || Date.parse(a.rows[0]!.start) - Date.parse(b.rows[0]!.start) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
604
+ .map(([key, group]) => {
605
+ const image = key ? staffPhoto(key, group.name) : "";
606
+ return {
607
+ id: key || "none",
608
+ name: group.name,
609
+ image,
610
+ hasImage: !!image,
611
+ noImage: !image,
612
+ noStaffHeader: !key,
613
+ hasStaffHeader: !!key,
614
+ times: group.rows.map((row) => {
615
+ const picked = row.selectable && row.id === s.selectedSlotId;
616
+ const available = row.selectable && !picked;
617
+ const label = row.title;
618
+ const named = group.name
619
+ ? copyTemplate("time.slotLabel", "{time} with {staff}", { time: label, staff: group.name })
620
+ : label;
621
+ const a11y = row.selectable
622
+ ? [named, row.badge, picked ? copyParam("bookingCommon.selected", "Selected") : ""].filter(Boolean).join(", ")
623
+ : copyTemplate("time.slotUnavailable", "{time}, unavailable", { time: label });
624
+ return {
625
+ id: row.id,
626
+ label,
627
+ badge: row.badge,
628
+ hasBadge: !!row.badge,
629
+ noBadge: !row.badge,
630
+ a11y,
631
+ available,
632
+ notAvailable: !available,
633
+ picked,
634
+ notPicked: !picked,
635
+ unavailable: !row.selectable,
636
+ notUnavailable: row.selectable,
637
+ };
638
+ }),
639
+ };
640
+ });
641
+ }
642
+
643
+ /** Every day of the window from `now` (local), plus any day a slot lands on
644
+ * outside it, sorted — each with its slot count. */
645
+ function daysOf(rows: SlotRow[], now: Date = new Date()): DayOption[] {
646
+ const counts = new Map<string, number>();
647
+ const labels = new Map<string, string>();
648
+ for (const row of rows) {
649
+ counts.set(row.dayKey, (counts.get(row.dayKey) ?? 0) + 1);
650
+ if (!labels.has(row.dayKey)) labels.set(row.dayKey, row.dayChip);
651
+ }
652
+ for (let offset = 0; offset < WINDOW_DAYS; offset++) {
653
+ const at = addLocalDays(now, offset);
654
+ const key = at ? dayKey(at) : "";
655
+ // Compact ("Sun 27"), not the full heading: these are pills in a strip.
656
+ if (key && !labels.has(key)) labels.set(key, dayLabel(at, { format: "compact" }));
657
+ }
658
+ return [...labels.keys()].sort().map((key) => {
659
+ const count = counts.get(key) ?? 0;
660
+ return { id: key, label: labels.get(key) ?? key, count, hasSlots: count > 0 };
661
+ });
662
+ }
663
+
664
+ /** Show `day`: its heading. `derive` fills its rows from the current selection and location. */
665
+ function withDay(s: S, day: string): S {
666
+ const rows = allRows.filter((row) => row.dayKey === day);
667
+ const noon = day ? new Date(`${day}T12:00:00`) : null;
668
+ return {
669
+ ...s,
670
+ selectedDay: day,
671
+ selectedDayLabel: rows[0]?.day ?? (noon && !Number.isNaN(noon.getTime()) ? dayLabel(noon) : ""),
672
+ slots: rows.map((row) => ({ ...row, selected: row.id === s.selectedSlotId, notSelected: row.id !== s.selectedSlotId })),
673
+ };
674
+ }
675
+
676
+ /** The day strip, the day's rows, the staff groups and the location row, from the window's rows,
677
+ * the selected day and the location filter. Only for a loaded list of times (not a course). */
678
+ function timesView(s: S): Pick<S, "days" | "slots" | "groups" | "locationOptions" | "locationLabel" | "locationA11y" | "hasLocationMenu" | "noLocationMenu" | "singleLocation" | "hasSingleLocation" | "noSingleLocation"> {
679
+ const all = copyParam("time.allLocations", "All locations");
680
+ const shown = s.status === "ready" && !s.isCourse && !s.errored && !!s.windowStart;
681
+ const locations = shown ? windowLocations() : [];
682
+ const visible = allRows.filter((row) => locationMatches(row, s.locationId));
683
+ const label = s.locationId ? (locations.find((l) => l.key === s.locationId)?.label ?? all) : all;
684
+ const single = locations.length === 1 ? locations[0]!.label : "";
685
+ return {
686
+ days: shown ? daysOf(visible, new Date(`${s.windowStart}T12:00:00`)) : s.days,
687
+ slots: shown
688
+ ? visible.filter((row) => row.dayKey === s.selectedDay).map((row) => ({ ...row, selected: row.id === s.selectedSlotId, notSelected: row.id !== s.selectedSlotId }))
689
+ : s.slots,
690
+ groups: shown ? groupsOf(s) : [],
691
+ locationOptions: locations.length >= 2 ? [{ key: "", label: all, sub: "" }, ...locations].map((l) => {
692
+ const selected = l.key === s.locationId;
693
+ return { id: l.key, label: l.label, sub: l.sub, hasSub: !!l.sub, noSub: !l.sub, selected, notSelected: !selected,
694
+ a11y: [l.label, l.sub, selected ? copyParam("bookingCommon.selected", "Selected") : ""].filter(Boolean).join(", ") };
695
+ }) : [],
696
+ locationLabel: label,
697
+ locationA11y: copyTemplate("time.locationLabel", "Location, {label}", { label }),
698
+ hasLocationMenu: locations.length >= 2,
699
+ noLocationMenu: locations.length < 2,
700
+ singleLocation: single,
701
+ hasSingleLocation: !!single,
702
+ noSingleLocation: !single,
703
+ };
704
+ }
705
+
706
+ function derive(input: S): S {
707
+ const s: S = { ...input, ...timesView(input) };
708
+ const regular = regularCheckoutAllowed || applePurchase.usesRegularPlatform();
709
+ // `hasSlots` is the WHOLE window (it drives the screen-level empty state); the
710
+ // selected day having none is a per-day line, not an empty screen.
711
+ const hasSlots = s.days.some((d) => d.hasSlots) || s.slots.length > 0;
712
+ const hasDaySlots = s.slots.length > 0;
713
+ const timesShown = s.status === "ready" && !s.isCourse && !s.errored;
714
+ // Nothing to BOOK in the 90 days: a class's full sessions alone (grey rows) are no upcoming session.
715
+ const noUpcoming = timesShown && !allRows.some((row) => row.selectable);
716
+ const dayEmpty = timesShown && !noUpcoming && !s.slots.some((row) => row.selectable);
717
+ const resultText = s.isCourse ? "" : (s.successMessage || s.message);
718
+ const hasSelected = s.isCourse ? !!s.scheduleId && s.courseAvailable : !!s.selectedSlotId;
719
+ const selectedWaitlist = !s.isCourse && waitlistIds.has(s.selectedSlotId);
720
+ const hasSuccess = !!s.successMessage;
721
+ const canBook = regular && s.isMember && hasSelected && !s.bookingInFlight && !hasSuccess;
722
+ const showChoosePrompt = regular && !s.isCourse && s.isMember && hasSlots && !hasSelected && !s.bookingInFlight && !hasSuccess;
723
+ const isRegistering = !!s.isRegistering;
724
+ const next: S = {
725
+ ...s,
726
+ _poll: undefined,
727
+ servicePrice: regular ? regularServicePrice : "",
728
+ // A course is booked whole, not from the 14-day window of times, so it has no window line.
729
+ serviceLine: s.status === "ready" ? [s.serviceDuration, regular ? regularServicePrice : ""].filter(Boolean).join(" · ") : s.serviceLine,
730
+ message: regular && !regularCheckoutAllowed && s.message === copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") ? "" : s.message,
731
+ hasSlots,
732
+ // A waitlist pick or a course is not an in-app purchase: neither enables the Apple button.
733
+ ...applePurchase.fields(hasSelected && !selectedWaitlist && !s.isCourse),
734
+ hasSelected,
735
+ noSelected: !hasSelected,
736
+ // The shared sign-in card: no sign-in for a course this screen cannot sell (as below).
737
+ hideSignIn: s.isMember || !hasSelected || (s.isCourse && !regular),
738
+ hideTimeSignIn: s.isMember || !hasSelected || s.isCourse || selectedWaitlist,
739
+ // A course has no in-app purchase path: where it cannot use the regular checkout, a guest is
740
+ // not sent to sign in for a button that will not be there.
741
+ hideCourseSignIn: s.isMember || !hasSelected || !s.isCourse || !regular,
742
+ hideWaitlistSignIn: s.isMember || !hasSelected || s.isCourse || !selectedWaitlist,
743
+ notCourse: !s.isCourse,
744
+ hasDaySlots,
745
+ noDaySlots: !hasDaySlots,
746
+ noWindowSlots: !hasSlots && !s.isCourse,
747
+ // Only a quiet DAY in a window that has times gets the per-day line; a
748
+ // window with no times at all is the screen-level empty state's job.
749
+ // The window (not the location filter) decides: a location with only greyed times keeps the
750
+ // strip, the location row and "Check Next Availability".
751
+ dayEmptyText: !dayEmpty || !s.selectedDay
752
+ ? ""
753
+ : copyParam("time.dayEmptyText", "Nothing available for this date."),
754
+ dayEmpty: dayEmpty && !!s.selectedDay,
755
+ hasDayTimes: !(dayEmpty && !!s.selectedDay),
756
+ noUpcoming,
757
+ hasUpcoming: !noUpcoming,
758
+ resultText,
759
+ resultSticky: !!resultText && !!s.successMessage,
760
+ resultAuto: !!resultText && !s.successMessage,
761
+ resultIsBooking: !!s.successMessage,
762
+ resultNotBooking: !s.successMessage,
763
+ courseMessage: s.isCourse ? (s.successMessage || s.message) : "",
764
+ hasSuccess,
765
+ noSuccess: !hasSuccess,
766
+ canBook,
767
+ cannotBook: !canBook,
768
+ hideTimeBook: !canBook || s.isCourse || selectedWaitlist,
769
+ hideCourseBook: !canBook || !s.isCourse,
770
+ // Joining a waitlist buys nothing, so it does not wait on the purchase route.
771
+ hideWaitlistBook: !s.isMember || !hasSelected || s.isCourse || !selectedWaitlist || s.bookingInFlight || hasSuccess,
772
+ hideDayPicker: s.isCourse || !timesShown || noUpcoming,
773
+ hideDaySlots: s.isCourse || s.groups.length === 0,
774
+ hideDayEmpty: s.isCourse || hasDaySlots,
775
+ showChoosePrompt,
776
+ hideChoosePrompt: !showChoosePrompt,
777
+ isLoading: s.status === "loading",
778
+ notLoading: s.status !== "loading",
779
+ noEmpty: s.status === "loading" || s.errored || hasSlots || s.isCourse,
780
+ notErrored: !s.errored,
781
+ notMember: !s.isMember,
782
+ notBooking: !s.bookingInFlight,
783
+ emptyText: hasSlots
784
+ ? ""
785
+ : (s.errored
786
+ ? copyParam("time.errorText", "Times could not be loaded.")
787
+ : copyParam("time.emptyText", "No bookable times in the next two weeks.")),
788
+ isRegistering,
789
+ notRegistering: !isRegistering,
790
+ authTitle: isRegistering
791
+ ? copyParam("time.authTitleRegister", "Create account to book")
792
+ : copyParam("time.authTitle", "Sign in to book"),
793
+ authBody: s.isCourse
794
+ ? copyParam("time.courseAuthBody", "Sign in to book the full course. Booking is saved to your member account.")
795
+ : isRegistering
796
+ ? copyParam("time.authBodyRegister", "Create a member account, then book the selected time on this screen.")
797
+ : copyParam("time.authBody", "You can browse times as a visitor. Booking is saved to a member account."),
798
+ };
799
+ if (next.status === "ready") lastReady = next;
800
+ return next;
801
+ }
802
+
803
+ async function probeMember(s: S): Promise<S> {
804
+ const storedEmail = await signedInEmail();
805
+ if (storedEmail) {
806
+ return derive({
807
+ ...s,
808
+ isMember: true,
809
+ identityLine: storedEmail,
810
+ email: storedEmail,
811
+ message: "",
812
+ });
813
+ }
814
+ try {
815
+ const data = await withTimeout(getMyData(), 2500);
816
+ const email = data.email || data.contactEmail || s.email;
817
+ return derive({
818
+ ...s,
819
+ isMember: true,
820
+ identityLine: displayName(data) || email || copyParam("time.signedIn", "Signed in"),
821
+ email,
822
+ message: "",
823
+ });
824
+ } catch {
825
+ return derive({
826
+ ...s,
827
+ isMember: false,
828
+ identityLine: copyParam("time.identityGuest", "Sign in to book this time."),
829
+ });
830
+ }
831
+ }
832
+
833
+ function contactName(dataLine: string): { first: string; last: string } {
834
+ const parts = dataLine.trim().split(/\s+/).filter(Boolean);
835
+ if (!parts.length) return { first: "Himi", last: "Member" };
836
+ if (parts.length === 1) return { first: parts[0], last: "Member" };
837
+ return { first: parts[0], last: parts.slice(1).join(" ") };
838
+ }
839
+
840
+ async function signedInEmail(): Promise<string> {
841
+ try {
842
+ const identity = await withTimeout(dispatch("himi.wix.memberIdentity") as Promise<unknown>, 1500);
843
+ const direct = identity as { email?: unknown } | null;
844
+ const wrapped = identity as { payload?: { email?: unknown } } | null;
845
+ const email = direct?.email ?? wrapped?.payload?.email;
846
+ return typeof email === "string" ? email : "";
847
+ } catch {
848
+ return "";
849
+ }
850
+ }
851
+
852
+ // A guest's picked time, kept for the sign-in it needs. Hosts key workers by account, so
853
+ // signing in can start a new worker for this screen, with no selection. `himalaya.storage`
854
+ // is shared by every worker of the app on every host, so a member's load of the same
855
+ // service with nothing selected takes the pick back. Reading never clears it: a new worker's
856
+ // boot load and appear load can run at once, and both must restore the same time. It goes
857
+ // when the member picks a time or books, or after 30 minutes.
858
+ const PENDING_PICK_KEY = "himi.booking.pendingPick.v1";
859
+ const PENDING_PICK_TTL_MS = 30 * 60 * 1000;
860
+ type StorageLike = { getItem(key: string): string | null; setItem(key: string, value: string): void };
861
+
862
+ function appStorage(): StorageLike | undefined {
863
+ return (globalThis as { himalaya?: { storage?: StorageLike } }).himalaya?.storage;
864
+ }
865
+
866
+ function writePick(value: string): void {
867
+ try {
868
+ appStorage()?.setItem(PENDING_PICK_KEY, value);
869
+ } catch {
870
+ // No storage on this host: the pick lives only as long as this worker.
871
+ }
872
+ }
873
+
874
+ function rememberPick(serviceId: string, slotId: string): void {
875
+ writePick(JSON.stringify({ serviceId, slotId, at: Date.now() }));
876
+ }
877
+
878
+ function clearPick(): void {
879
+ writePick("");
880
+ }
881
+
882
+ /** The slot a guest picked for `serviceId` in the last 30 minutes, or "". */
883
+ function readPick(serviceId: string): string {
884
+ let pick: { serviceId?: unknown; slotId?: unknown; at?: unknown } | null = null;
885
+ try {
886
+ pick = JSON.parse(appStorage()?.getItem(PENDING_PICK_KEY) || "null");
887
+ } catch {
888
+ // No storage, or a damaged entry: there is no pick to restore.
889
+ }
890
+ const fresh = typeof pick?.at === "number" && Date.now() - pick.at < PENDING_PICK_TTL_MS;
891
+ return fresh && pick?.serviceId === serviceId && typeof pick?.slotId === "string" ? pick.slotId : "";
892
+ }
893
+
894
+ // Wix Bookings appDefId — the catalogReference.appId that wraps a booking as an
895
+ // ecom checkout line item, and the payment appId the native cashier settles under.
896
+ const WIX_BOOKINGS_APP_ID = "13d21c63-b5ec-5912-8397-c3a5ddb27a97";
897
+
898
+ // The site's metaSiteId, for the cashier payment config. Best-effort: the native
899
+ // cashier can also resolve it from the app's signed Wix session, so an empty string
900
+ // (no wix-platform arm / not yet minted) is tolerated rather than blocking the pay.
901
+ async function resolveMsid(): Promise<string> {
902
+ try {
903
+ const st = await wixPlatformSession.signedInstanceStatus();
904
+ return st.msid ?? "";
905
+ } catch {
906
+ return "";
907
+ }
908
+ }
909
+
910
+ /** Read `day`'s unbookable times once (cached). A failure shows the bookable times only. */
911
+ async function loadGreyDay(serviceId: string, day: string): Promise<void> {
912
+ if (!day || greyByDay.has(day)) return;
913
+ const from = new Date(`${day}T00:00:00`);
914
+ const until = new Date(from.getFullYear(), from.getMonth(), from.getDate() + 1);
915
+ if (Number.isNaN(from.getTime())) return;
916
+ try {
917
+ const res = await queryServiceAvailability(serviceId, isoDate(from), isoDate(until), { bookable: false });
918
+ greyByDay.set(day, greyRows(res.availabilityEntries ?? [], day, allRows, Date.now()));
919
+ } catch {
920
+ greyByDay.set(day, []);
921
+ }
922
+ }
923
+
924
+ /** The first two-week window from `from` with a time to book (at `locationId`, "" for any), up to
925
+ * 90 days after `now`: at most seven reads. `undefined` when there is none. */
926
+ async function nextWindowWithTimes(serviceId: string, from: Date, now: Date, includeFull: boolean, service: WixService | undefined, locationId: string): Promise<{ built: ReturnType<typeof slotRows>; windowStart: string } | undefined> {
927
+ const horizon = now.getTime() + HORIZON_DAYS * DAY_MS;
928
+ for (let start = new Date(from.getFullYear(), from.getMonth(), from.getDate()); start.getTime() < horizon;
929
+ start = new Date(start.getFullYear(), start.getMonth(), start.getDate() + WINDOW_DAYS)) {
930
+ // Calendar days, as the step above: across a DST change 14 × 24 h would end the window an hour
931
+ // short of where the next one starts.
932
+ const until = new Date(Math.min(new Date(start.getFullYear(), start.getMonth(), start.getDate() + WINDOW_DAYS).getTime(), horizon));
933
+ const res = await queryServiceAvailability(serviceId, isoDate(new Date(Math.max(start.getTime(), now.getTime()))), isoDate(until), { bookable: !includeFull });
934
+ const built = slotRows(res.availabilityEntries ?? [], service);
935
+ if (built.rows.some((row) => row.selectable && locationMatches(row, locationId))) return { built, windowStart: dayKey(start) };
936
+ }
937
+ return undefined;
938
+ }
939
+
940
+ /** After a booking went through (or its checkout opened, which already holds a booking), the time
941
+ * is no longer bookable here: it shows greyed and is no longer selected, so another tap on it cannot
942
+ * send a second booking or charge. */
943
+ function afterBooking(result: S, slotId: string): S {
944
+ const booked = !!result.successMessage || result.message === copyParam("time.checkoutOpened", "Complete your payment in the checkout to confirm this booking.");
945
+ if (!booked || !slotId || result.isCourse) return result;
946
+ allRows = allRows.map((row) => row.id === slotId ? { ...row, selectable: false, notSelectable: true } : row);
947
+ slotsById.delete(slotId);
948
+ waitlistIds.delete(slotId);
949
+ return derive({ ...result, selectedSlotId: "", selectedLine: "" });
950
+ }
951
+
952
+ /** The selection, kept only while its time is still in the window and at the chosen location: a
953
+ * hidden pick would leave the in-app purchase button buying a time the screen no longer shows. */
954
+ function visibleSelection(s: S): S {
955
+ const row = allRows.find((r) => r.id === s.selectedSlotId);
956
+ return row && row.selectable && locationMatches(row, s.locationId) ? s : { ...s, selectedSlotId: "", selectedLine: "" };
957
+ }
958
+
959
+ /** Counts day changes, so a slow read for an older day choice cannot move the screen back to it. */
960
+ let dayRevision = 0;
961
+ /** True while "Check Next Availability" searches; a second tap waits for the first. */
962
+ let checkingNextLock = false;
963
+
964
+ /** `{ slotId, signInScreenId, signInPresentation }` from a pill tap (or a bare slot id). */
965
+ function tapped(arg: unknown): { slotId: string; signInScreenId: string; signInPresentation: "" | "sheet" | "full_screen" } {
966
+ if (typeof arg === "string") return { slotId: arg, signInScreenId: "", signInPresentation: "" };
967
+ const a = (arg ?? {}) as { slotId?: unknown; id?: unknown; signInScreenId?: unknown; signInPresentation?: unknown };
968
+ const slotId = typeof a.slotId === "string" ? a.slotId : typeof a.id === "string" ? a.id : "";
969
+ const presentation = a.signInPresentation === "sheet" || a.signInPresentation === "full_screen" ? a.signInPresentation : "";
970
+ return { slotId, signInScreenId: typeof a.signInScreenId === "string" ? a.signInScreenId : "", signInPresentation: presentation };
971
+ }
972
+
973
+ /** Select a bookable time, as a tap does: the pick-storage rules and the selection revision. */
974
+ function selectSlotIn(s: S, slotId: string): S {
975
+ const selected = allRows.find((slot) => slot.id === slotId && slot.selectable);
976
+ if (selected) {
977
+ selectionRevision++;
978
+ pendingStart = "";
979
+ pendingSession = "";
980
+ if (s.isMember) clearPick();
981
+ else rememberPick(s.id, slotId);
982
+ }
983
+ return derive({
984
+ ...s,
985
+ selectedSlotId: selected ? slotId : s.selectedSlotId,
986
+ selectedLine: selected ? [selected.day, selected.title, selected.subtitle].filter(Boolean).join(" · ") : s.selectedLine,
987
+ message: "",
988
+ successMessage: "",
989
+ slots: s.slots.map((slot) => ({ ...slot, selected: slot.id === slotId, notSelected: slot.id !== slotId })),
990
+ });
991
+ }
992
+
993
+ const id = inputId();
994
+ // A member's tap on a time books it, so pickSlot needs a connection too; a guest's tap only opens
995
+ // the sign-in screen (or the sign-in card), which works offline.
996
+ const defineService = withOnlineWrites(registerService, ["bookSelected", "buyApple", "signIn", "register", "pickSlot"], {
997
+ derive,
998
+ secretFields: ["password"],
999
+ requiresConnectivity: (action, state, arg) => action !== "pickSlot" || (state.isMember
1000
+ && (regularCheckoutAllowed || applePurchase.usesRegularPlatform() || waitlistIds.has(tapped(arg).slotId))),
1001
+ });
1002
+ const svc = defineService<S, {
1003
+ load: (s: S, arg?: unknown) => Promise<S>;
1004
+ refreshMember: (s: S, arg?: unknown) => Promise<S>;
1005
+ selectDay: (s: S, arg?: unknown) => Promise<S>;
1006
+ selectSlot: (s: S, arg?: unknown) => S;
1007
+ pickSlot: (s: S, arg?: unknown) => Promise<S | undefined>;
1008
+ openLocations: (s: S) => S;
1009
+ closeLocations: (s: S) => S;
1010
+ selectLocation: (s: S, arg?: unknown) => Promise<S>;
1011
+ checkNext: (s: S) => Promise<S>;
1012
+ closeResult: (s: S) => S;
1013
+ setEmail: (s: S, arg?: unknown) => S;
1014
+ setPassword: (s: S, arg?: unknown) => S;
1015
+ setFirstName: (s: S, arg?: unknown) => S;
1016
+ setLastName: (s: S, arg?: unknown) => S;
1017
+ showRegister: (s: S) => S;
1018
+ showSignIn: (s: S) => S;
1019
+ signIn: (s: S) => Promise<S>;
1020
+ register: (s: S) => Promise<S>;
1021
+ bookSelected: (s: S) => Promise<S>;
1022
+ buyApple: (s: S) => Promise<S>;
1023
+ setIapField: (s: S, arg?: unknown) => S;
1024
+ iapPublish: (s: S) => S;
1025
+ iapRefresh: (s: S) => Promise<S>;
1026
+ iapPoll: (s: S) => Promise<S>;
1027
+ }>("himiBookingTime", {
1028
+ notHydrated: [...ATTENDANCE_NOT_HYDRATED, "email", "password", "firstName", "lastName", "isMember", "canBook", "cannotBook"],
1029
+ state: derive({
1030
+ ...ATTENDANCE_INITIAL,
1031
+ status: "loading",
1032
+ id,
1033
+ serviceName: "Choose a time",
1034
+ serviceLine: "Next 14 days",
1035
+ serviceImage: "",
1036
+ hasServiceImage: false,
1037
+ noServiceImage: true,
1038
+ servicePrice: "",
1039
+ serviceDuration: "",
1040
+ serviceIsPaid: false,
1041
+ coursePaymentOption: "",
1042
+ courseAvailable: false,
1043
+ isCourse: false,
1044
+ notCourse: true,
1045
+ scheduleId: "",
1046
+ courseStart: "",
1047
+ courseEnd: "",
1048
+ days: [],
1049
+ selectedDay: "",
1050
+ selectedDayLabel: "",
1051
+ slots: [],
1052
+ dayEmptyText: "",
1053
+ hasDaySlots: false,
1054
+ noDaySlots: true,
1055
+ noWindowSlots: true,
1056
+ selectedSlotId: "",
1057
+ selectedLine: "",
1058
+ emptyText: "",
1059
+ message: "",
1060
+ successMessage: "",
1061
+ hasSuccess: false,
1062
+ noSuccess: true,
1063
+ errored: false,
1064
+ notErrored: true,
1065
+ isLoading: true,
1066
+ notLoading: false,
1067
+ hasSlots: false,
1068
+ noEmpty: true,
1069
+ hasSelected: false,
1070
+ noSelected: true,
1071
+ hideSignIn: true,
1072
+ hideTimeSignIn: true,
1073
+ hideCourseSignIn: true,
1074
+ hideWaitlistSignIn: true,
1075
+ canBook: false,
1076
+ cannotBook: true,
1077
+ hideTimeBook: true,
1078
+ hideCourseBook: true,
1079
+ hideWaitlistBook: true,
1080
+ hideDayPicker: true,
1081
+ hideDaySlots: true,
1082
+ hideDayEmpty: true,
1083
+ showChoosePrompt: false,
1084
+ hideChoosePrompt: true,
1085
+ isMember: false,
1086
+ notMember: true,
1087
+ identityLine: "Sign in to book this time.",
1088
+ authTitle: "Sign in to book",
1089
+ authBody: "You can browse times as a visitor. Booking is saved to a member account.",
1090
+ email: "",
1091
+ password: "",
1092
+ firstName: "",
1093
+ lastName: "",
1094
+ isRegistering: false,
1095
+ notRegistering: true,
1096
+ bookingInFlight: false,
1097
+ notBooking: true,
1098
+ bookingsScreenId: "himi_bookings",
1099
+ windowStart: "",
1100
+ locationId: "",
1101
+ locationLabel: "All locations",
1102
+ locationA11y: "",
1103
+ locationOptions: [],
1104
+ locationSheetOpen: false,
1105
+ hasLocationMenu: false,
1106
+ noLocationMenu: true,
1107
+ singleLocation: "",
1108
+ hasSingleLocation: false,
1109
+ noSingleLocation: true,
1110
+ groups: [],
1111
+ dayEmpty: false,
1112
+ hasDayTimes: true,
1113
+ noUpcoming: false,
1114
+ hasUpcoming: true,
1115
+ resultText: "",
1116
+ resultSticky: false,
1117
+ resultAuto: false,
1118
+ resultIsBooking: false,
1119
+ resultNotBooking: true,
1120
+ courseMessage: "",
1121
+ }),
1122
+ actions: {
1123
+ iapPublish: (s) => derive(lastReady ?? s),
1124
+ iapRefresh: async (): Promise<S> => { await applePurchase.refresh(); return derive(svc.getState()); },
1125
+ iapPoll: async (): Promise<S> => { await applePurchase.poll(); return derive(svc.getState()); },
1126
+ setIapField: (s, arg) => { applePurchase.change(arg); return derive(s); },
1127
+ buyApple: async (s: S): Promise<S> => {
1128
+ const slot = slotsById.get(s.selectedSlotId);
1129
+ if (!slot || !s.isMember) return derive(s);
1130
+ await applePurchase.buy({ key: s.selectedSlotId, slot: slot as unknown as Record<string, unknown> });
1131
+ return derive(svc.getState());
1132
+ },
1133
+ load: async (s, arg): Promise<S> => {
1134
+ const revisionAtStart = selectionRevision;
1135
+ stashCopy(arg);
1136
+ if (!s.id) return derive({ ...s, status: "ready", errored: true });
1137
+ let next = derive({ ...s, status: "loading", errored: false, message: "", successMessage: "" });
1138
+ next = await probeMember(next);
1139
+ try {
1140
+ const now = new Date();
1141
+ const beyond = availabilityBeyond(now, farStart);
1142
+ // Staff photos need STAFF_MEMBER_DETAILS; a token that may not read them still gets the service.
1143
+ const serviceRes = await getService(s.id, { conditionalFields: ["STAFF_MEMBER_DETAILS"] })
1144
+ .catch(() => getService(s.id))
1145
+ .catch(() => ({ service: undefined }));
1146
+ lastService = serviceRes.service;
1147
+ readStaffPhotos(serviceRes.service);
1148
+ if (courseInput && !serviceRes.service) {
1149
+ slotsById = new Map();
1150
+ allRows = [];
1151
+ return derive({ ...next, status: "ready", errored: true, days: [], selectedDay: "", selectedDayLabel: "", slots: [] });
1152
+ }
1153
+ if (serviceRes.service?.type === "COURSE") {
1154
+ slotsById = new Map();
1155
+ waitlistIds = new Set();
1156
+ allRows = [];
1157
+ const header = serviceHeaderFields(serviceRes.service);
1158
+ const schedule = serviceRes.service.schedule;
1159
+ const scheduleId = schedule?.id ?? "";
1160
+ // A reload after a cancelled payment: this customer's order may hold the last place.
1161
+ const held = pendingCourseBooking?.ordered === true && pendingCourseBooking.scheduleId === scheduleId
1162
+ && pendingCourseBooking.email === ((await signedInEmail()) || next.email);
1163
+ const courseAvailable = await courseOpenTo(serviceRes.service, now, held);
1164
+ // A course has no in-app purchase path: where a paid booking may not use the regular
1165
+ // checkout (mayUseRegularBookingCheckout), it cannot be bought here.
1166
+ regularServicePrice = header.price;
1167
+ regularCheckoutAllowed = await mayUseRegularBookingCheckout(serviceRes.service);
1168
+ return derive({
1169
+ ...next,
1170
+ status: "ready",
1171
+ errored: false,
1172
+ isCourse: true,
1173
+ courseAvailable,
1174
+ scheduleId,
1175
+ courseStart: schedule?.firstSessionStart ?? "",
1176
+ courseEnd: schedule?.lastSessionEnd ?? "",
1177
+ days: [],
1178
+ selectedDay: "",
1179
+ slots: [],
1180
+ selectedSlotId: "",
1181
+ serviceName: serviceRes.service.name || copyParam("time.courseNameFallback", "Course"),
1182
+ serviceLine: [header.duration, regularCheckoutAllowed ? header.price : ""].filter(Boolean).join(" · "),
1183
+ serviceImage: header.image,
1184
+ hasServiceImage: !!header.image,
1185
+ noServiceImage: !header.image,
1186
+ servicePrice: regularCheckoutAllowed ? header.price : "",
1187
+ serviceDuration: header.duration,
1188
+ serviceIsPaid: header.paid,
1189
+ coursePaymentOption: serviceRes.service.payment?.options?.online ? "ONLINE"
1190
+ : serviceRes.service.payment?.options?.inPerson ? "OFFLINE" : "",
1191
+ selectedLine: schedule?.firstSessionStart && schedule?.lastSessionEnd
1192
+ ? `${dayLabel(schedule.firstSessionStart)} – ${dayLabel(schedule.lastSessionEnd)}` : "",
1193
+ message: !courseAvailable ? copyParam("time.courseUnavailable", "Course booking is unavailable right now.")
1194
+ : regularCheckoutAllowed ? "" : copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable."),
1195
+ });
1196
+ }
1197
+ const includeFull = serviceRes.service?.type === "CLASS";
1198
+ const [first, target] = await Promise.all([
1199
+ queryServiceAvailability(s.id, isoDate(now), isoDate(new Date(now.getTime() + WINDOW_DAYS * DAY_MS)), { bookable: !includeFull }),
1200
+ // A host's start past the window (an Agenda session weeks out): its day alone, so it can
1201
+ // be preselected. Its failure costs only that preselection.
1202
+ beyond ? queryServiceAvailability(s.id, isoDate(beyond.from), isoDate(beyond.until), { bookable: !includeFull }).catch(() => ({ availabilityEntries: [] })) : undefined,
1203
+ ]);
1204
+ let built = slotRows([...(first.availabilityEntries ?? []), ...(target?.availabilityEntries ?? [])], serviceRes.service);
1205
+ let windowStart = dayKey(now);
1206
+ // Nothing to book in the first two weeks (a class's full sessions do not count), and nothing
1207
+ // at a host's deep-linked start either: look further, two weeks at a time, up to 90 days. The
1208
+ // strip then starts at the first two weeks with a time. Nothing in 90 days → "no upcoming
1209
+ // sessions".
1210
+ if (!built.rows.some((row) => row.selectable)) {
1211
+ const later = await nextWindowWithTimes(s.id, new Date(now.getTime() + WINDOW_DAYS * DAY_MS), now, includeFull, serviceRes.service, "");
1212
+ if (later) { built = later.built; windowStart = later.windowStart; }
1213
+ }
1214
+ slotsById = built.slots;
1215
+ waitlistIds = built.waitlist;
1216
+ const rows = built.rows;
1217
+ allRows = rows;
1218
+ greyByDay = new Map();
1219
+ const days = daysOf(rows, new Date(`${windowStart}T12:00:00`));
1220
+ const header = serviceHeaderFields(serviceRes.service);
1221
+ regularServicePrice = header.price;
1222
+ regularCheckoutAllowed = await mayUseRegularBookingCheckout(serviceRes.service);
1223
+ await applePurchase.booking(s.id, serviceRes.service);
1224
+ // A reload runs in the background when the screen shows again. A time the customer
1225
+ // picked while it ran wins over the selection this load started from.
1226
+ const chosen: S = selectionRevision !== revisionAtStart ? (svc.getState?.() ?? next) : next;
1227
+ // A host's `start` (deep entry): that slot preselected, or — taken since — its day.
1228
+ const wantedStart = pendingStart;
1229
+ // The named session (a slot row's id IS its session id), else the slot at that instant. A
1230
+ // named session that is gone is "taken since" — never another session at the same time.
1231
+ const startRow = !wantedStart ? undefined
1232
+ : pendingSession ? rows.find((r) => r.id === pendingSession && r.selectable)
1233
+ : rows.find((r) => r.id === slotIdAt(slotsById, wantedStart) && r.selectable);
1234
+ // The requested day even with no time left on it: "taken since" opens there, nothing selected.
1235
+ const startDay = wantedStart ? days.find((d) => d.id === dayKey(wantedStart)) : undefined;
1236
+ const selectionAlive = !!startRow || slotsById.has(chosen.selectedSlotId) || waitlistIds.has(chosen.selectedSlotId);
1237
+ // A member with no live selection, and no host start, gets back the time they picked as a guest.
1238
+ const picked = selectionAlive || wantedStart || !next.isMember ? undefined : rows.find((row) => row.id === readPick(s.id) && row.selectable);
1239
+ // Keep the customer's day across a reload when it still has times;
1240
+ // otherwise open on the first day that has any.
1241
+ const keep = days.find((d) => d.id === chosen.selectedDay && d.hasSlots);
1242
+ const initialDay = startRow?.dayKey ?? startDay?.id ?? picked?.dayKey ?? keep?.id ?? days.find((d) => d.hasSlots)?.id ?? days[0]?.id ?? "";
1243
+ if (!includeFull && initialDay) await loadGreyDay(s.id, initialDay);
1244
+ // A location with no times left in the window goes back to "All locations".
1245
+ // A location with no times left (greyed ones included) goes back to "All locations", and so
1246
+ // does a filter that would hide the time this load restores (a host's start, a guest's pick).
1247
+ const restored = startRow ?? picked;
1248
+ // The location chosen on any bookings screen (this one writes its choices there too), matched
1249
+ // by the key they all share; this screen's own choice only when the store cannot answer. A
1250
+ // pooled worker re-shown later otherwise kept an older choice than Services' newer one.
1251
+ // A place the other screens cannot name (the client's own place) is never written there, so
1252
+ // this screen keeps it rather than lose it to the shared value.
1253
+ const shared = await readSharedLocationResult();
1254
+ const own = windowLocations().find((l) => l.key === chosen.locationId);
1255
+ const preferred = own && !own.place ? own.key
1256
+ : shared ? (shared.id ? windowLocations().find((l) => l.place === shared.id)?.key ?? "" : "") : chosen.locationId;
1257
+ const locationId = windowLocations().some((l) => l.key === preferred) && (!restored || locationMatches(restored, preferred))
1258
+ ? preferred : "";
1259
+ return derive(withDay({
1260
+ ...next,
1261
+ days,
1262
+ windowStart,
1263
+ locationId,
1264
+ selectedSlotId: startRow ? startRow.id : selectionAlive ? chosen.selectedSlotId : picked?.id ?? "",
1265
+ selectedLine: startRow ? [startRow.day, startRow.title, startRow.subtitle].filter(Boolean).join(" · ")
1266
+ : selectionAlive ? chosen.selectedLine
1267
+ : picked ? [picked.day, picked.title, picked.subtitle].filter(Boolean).join(" · ") : "",
1268
+ status: "ready",
1269
+ errored: false,
1270
+ serviceName: serviceRes.service?.name || copyParam("time.serviceNameFallback", "Choose a time"),
1271
+ serviceLine: [header.duration, regularCheckoutAllowed ? header.price : ""].filter(Boolean).join(" · "),
1272
+ serviceImage: header.image,
1273
+ hasServiceImage: !!header.image,
1274
+ noServiceImage: !header.image,
1275
+ servicePrice: regularCheckoutAllowed ? header.price : "",
1276
+ serviceDuration: header.duration,
1277
+ serviceIsPaid: header.paid,
1278
+ // A guest's pick restored after sign-in stays picked: the member taps it to book. The
1279
+ // screen never books from a load (two loads can run at once right after a sign-in).
1280
+ // Where nothing can be bought here, a tap says so (pickSlot), not the screen's opening.
1281
+ message: picked ? copyParam("time.signedInTapAgain", "Signed in. Tap the time again to book it.") : "",
1282
+ }, initialDay));
1283
+ } catch {
1284
+ slotsById = new Map();
1285
+ waitlistIds = new Set();
1286
+ allRows = [];
1287
+ return derive({ ...next, status: "ready", errored: true, days: [], selectedDay: "", selectedDayLabel: "", slots: [] });
1288
+ }
1289
+ },
1290
+ // #3276 — show one day's times. Re-tapping the active chip emits an empty
1291
+ // selection; the strip is single-select with no "none", so the day stays.
1292
+ // A time picked on another day stays selected (the selected line and the
1293
+ // Book button keep naming it): switching days to compare is not a reset.
1294
+ selectDay: async (s, arg): Promise<S> => {
1295
+ const day = chosenDay(arg);
1296
+ if (!day || !s.days.some((d) => d.id === day)) return derive(s);
1297
+ pendingStart = "";
1298
+ pendingSession = "";
1299
+ const revision = ++dayRevision;
1300
+ if (lastService?.type !== "CLASS") await loadGreyDay(s.id, day);
1301
+ const current = svc.getState?.() ?? s;
1302
+ return derive(revision === dayRevision ? withDay(current, day) : current);
1303
+ },
1304
+ refreshMember: (s, arg) => { stashCopy(arg); return probeMember(s); },
1305
+ selectSlot: (s, arg) => selectSlotIn(s, tapped(arg).slotId),
1306
+ // One tap books: the tap does what "Book selected time" did (select, then book). A guest keeps
1307
+ // the pick and signs in first; after the sign-in the time comes back picked, and the member
1308
+ // taps it again. Where the regular checkout is not allowed, the tap selects the time for the
1309
+ // in-app purchase button.
1310
+ pickSlot: async (s, arg): Promise<S | undefined> => {
1311
+ const { slotId, signInScreenId, signInPresentation } = tapped(arg);
1312
+ if (bookingLock || s.bookingInFlight || !allRows.some((row) => row.id === slotId && row.selectable)) return derive(s);
1313
+ const selected = selectSlotIn(s, slotId);
1314
+ // A modal sign-in leaves this screen in place: a host need not run its onAppear when the
1315
+ // sheet closes, and the reload that refreshes `isMember` can still be running. So ask the
1316
+ // session before opening the sheet again, or a member's tap would only reopen it. Only the
1317
+ // session's own identity counts here, never getMyData: that can still answer with a stale
1318
+ // site or owner profile for a visitor. A member found here gets the pick and the "tap again"
1319
+ // note, as after a reload, and books on the next tap: that tap passes the booking lock and
1320
+ // the offline guard with the member state, which this await would let a second tap skip.
1321
+ if (!selected.isMember && signInScreenId && signInPresentation) {
1322
+ const mine = ++sessionTaps;
1323
+ const email = await signedInEmail();
1324
+ if (mine !== sessionTaps) return undefined;
1325
+ // From the state as it is NOW: a reload may have landed during the await. The pick
1326
+ // stays only while that time is still bookable.
1327
+ const now = svc.getState?.() ?? s;
1328
+ const open = allRows.some((row) => row.id === slotId && row.selectable);
1329
+ if (email) {
1330
+ return derive({ ...(open ? selectSlotIn(now, slotId) : now), isMember: true, identityLine: email, email,
1331
+ message: open ? copyParam("time.signedInTapAgain", "Signed in. Tap the time again to book it.") : "" });
1332
+ }
1333
+ // Still a guest: the sign-in opens over the newest state; a time gone meanwhile opens nothing.
1334
+ if (!open) return derive(now);
1335
+ return navigate(selectSlotIn(now, slotId), { screenId: signInScreenId, presentation: signInPresentation, args: { mode: "signin", intent: "book", modal: "1" } });
1336
+ }
1337
+ if (!selected.isMember) {
1338
+ // Branded Lite opens its sign-in screen; other apps show the sign-in card on this screen.
1339
+ // With a presentation it opens as a modal (`modal`: the sign-in worker closes it after a
1340
+ // sign-in), and this screen loads again under it with the guest's pick kept.
1341
+ if (!signInScreenId) return selected;
1342
+ return navigate(selected, signInPresentation
1343
+ ? { screenId: signInScreenId, presentation: signInPresentation, args: { mode: "signin", intent: "book", modal: "1" } }
1344
+ : { screenId: signInScreenId, args: { mode: "signin", intent: "book" } });
1345
+ }
1346
+ const regular = regularCheckoutAllowed || applePurchase.usesRegularPlatform();
1347
+ if (!regular && !waitlistIds.has(slotId)) {
1348
+ // The in-app purchase button takes over for the picked time; with none, say why not.
1349
+ return applePurchase.fields(true, false).iapBuyHidden
1350
+ ? derive({ ...selected, message: copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") })
1351
+ : selected;
1352
+ }
1353
+ bookingLock = true;
1354
+ try {
1355
+ return afterBooking(await bookSelectedIn(selected), slotId);
1356
+ } finally {
1357
+ bookingLock = false;
1358
+ }
1359
+ },
1360
+ openLocations: (s) => derive({ ...s, locationSheetOpen: true }),
1361
+ closeLocations: (s) => derive({ ...s, locationSheetOpen: false }),
1362
+ selectLocation: async (s, arg): Promise<S> => {
1363
+ const raw = (arg ?? {}) as { locationId?: unknown; id?: unknown };
1364
+ const wanted = typeof arg === "string" ? arg : typeof raw.locationId === "string" ? raw.locationId : typeof raw.id === "string" ? raw.id : "";
1365
+ const locationId = windowLocations().some((l) => l.key === wanted) ? wanted : "";
1366
+ const next = visibleSelection({ ...s, locationId, locationSheetOpen: false });
1367
+ // Stay on the day when it has a time at this location; else go to the first day that has one.
1368
+ const keep = allRows.some((row) => row.dayKey === s.selectedDay && row.selectable && locationMatches(row, locationId));
1369
+ const first = allRows.filter((row) => row.selectable && locationMatches(row, locationId)).sort(byDay)[0];
1370
+ const revision = ++dayRevision;
1371
+ // Kept for Services and the Agenda, which filter by the same location. Not awaited (it never
1372
+ // throws): the choice shows at once, and a search still running settles as it always has.
1373
+ // A place with no shared key (the client's place) is this screen's alone: Services and the
1374
+ // Agenda keep their own choice instead of falling back to "All locations".
1375
+ const picked = windowLocations().find((l) => l.key === locationId);
1376
+ if (!locationId || picked?.place) void writeSharedLocation({ id: picked?.place ?? "", label: picked?.label ?? "" });
1377
+ if (keep || !first) return derive(next);
1378
+ if (lastService?.type !== "CLASS") await loadGreyDay(s.id, first.dayKey);
1379
+ // A newer day or location choice made during the read wins.
1380
+ if (revision !== dayRevision) return derive(svc.getState?.() ?? next);
1381
+ const current = svc.getState?.() ?? next;
1382
+ return derive(withDay(visibleSelection({ ...current, locationId, locationSheetOpen: false }), first.dayKey));
1383
+ },
1384
+ // The next day after the selected one with a time (at the chosen location): in the loaded
1385
+ // window first, then forward up to 90 days, moving the strip.
1386
+ checkNext: async (s): Promise<S> => {
1387
+ if (checkingNextLock) return derive(s);
1388
+ checkingNextLock = true;
1389
+ try {
1390
+ return await checkNextIn(s);
1391
+ } finally {
1392
+ checkingNextLock = false;
1393
+ }
1394
+ },
1395
+ closeResult: (s) => derive({ ...s, message: "", successMessage: "" }),
1396
+ setEmail: (s, arg) => derive({ ...s, email: strOf(arg), message: "" }),
1397
+ setPassword: (s, arg) => derive({ ...s, password: strOf(arg), message: "" }),
1398
+ setFirstName: (s, arg) => derive({ ...s, firstName: strOf(arg), message: "" }),
1399
+ setLastName: (s, arg) => derive({ ...s, lastName: strOf(arg), message: "" }),
1400
+ showRegister: (s) => derive({ ...s, isRegistering: true, message: "" }),
1401
+ showSignIn: (s) => derive({ ...s, isRegistering: false, message: "" }),
1402
+ signIn: async (s) => {
1403
+ if (!s.email || !s.password) return derive({ ...s, message: copyParam("time.validationEmailPassword", "Enter your email and password.") });
1404
+ try {
1405
+ await dispatch("himi.wix.signInDirect", { email: s.email, password: s.password });
1406
+ return derive({
1407
+ ...s,
1408
+ isMember: true,
1409
+ identityLine: s.email,
1410
+ password: "",
1411
+ firstName: "",
1412
+ lastName: "",
1413
+ isRegistering: false,
1414
+ message: s.isCourse
1415
+ ? copyParam("time.courseSignedInReady", "Signed in. You can book this course now.")
1416
+ : copyParam("time.signedInTapAgain", "Signed in. Tap the time again to book it."),
1417
+ });
1418
+ } catch {
1419
+ return derive({ ...s, password: "", message: copyParam("time.signInFailed", "Sign in failed. Try again.") });
1420
+ }
1421
+ },
1422
+ register: async (s) => {
1423
+ if (!s.email || !s.password) return derive({ ...s, message: copyParam("time.validationEmailPassword", "Enter your email and password.") });
1424
+ try {
1425
+ await dispatch("himi.wix.registerDirect", {
1426
+ email: s.email,
1427
+ password: s.password,
1428
+ firstName: s.firstName,
1429
+ lastName: s.lastName,
1430
+ });
1431
+ return derive({
1432
+ ...s,
1433
+ isMember: true,
1434
+ identityLine: s.email,
1435
+ password: "",
1436
+ firstName: "",
1437
+ lastName: "",
1438
+ isRegistering: false,
1439
+ message: s.isCourse
1440
+ ? copyParam("time.courseRegisterReady", "Account created. You can book this course now.")
1441
+ : copyParam("time.signedInTapAgain", "Signed in. Tap the time again to book it."),
1442
+ });
1443
+ } catch {
1444
+ return derive({ ...s, password: "", message: copyParam("time.registerFailed", "Account creation failed. Try another email.") });
1445
+ }
1446
+ },
1447
+ // Kept for the course button and the offline replay (WriteQueue replays this action).
1448
+ bookSelected: async (s) => {
1449
+ if (bookingLock) return derive(s);
1450
+ bookingLock = true;
1451
+ try {
1452
+ return afterBooking(await bookSelectedIn(s), s.selectedSlotId);
1453
+ } finally {
1454
+ bookingLock = false;
1455
+ }
1456
+ },
1457
+ },
1458
+ });
1459
+
1460
+ /** "Check Next Availability": the next day after the selected one with a time (at the chosen
1461
+ * location), in the loaded window first, then forward up to 90 days, moving the strip. */
1462
+ async function checkNextIn(s: S): Promise<S> {
1463
+ const next = allRows.filter((row) => row.selectable && row.dayKey > s.selectedDay && locationMatches(row, s.locationId))
1464
+ .sort(byDay)[0];
1465
+ if (next) {
1466
+ const revision = ++dayRevision;
1467
+ if (lastService?.type !== "CLASS") await loadGreyDay(s.id, next.dayKey);
1468
+ const current = svc.getState?.() ?? s;
1469
+ return derive(revision === dayRevision ? withDay(current, next.dayKey) : current);
1470
+ }
1471
+ const revision = ++dayRevision;
1472
+ const after = new Date(`${s.selectedDay || s.windowStart}T12:00:00`);
1473
+ const from = new Date(after.getFullYear(), after.getMonth(), after.getDate() + 1);
1474
+ const includeFull = lastService?.type === "CLASS";
1475
+ let later: Awaited<ReturnType<typeof nextWindowWithTimes>>;
1476
+ try {
1477
+ later = Number.isNaN(from.getTime()) ? undefined : await nextWindowWithTimes(s.id, from, new Date(), includeFull, lastService, s.locationId);
1478
+ } catch {
1479
+ return derive({ ...(svc.getState?.() ?? s), message: copyParam("time.errorText", "Times could not be loaded.") });
1480
+ }
1481
+ const current = svc.getState?.() ?? s;
1482
+ // A day or location chosen while the search ran wins: its result is dropped.
1483
+ if (revision !== dayRevision) return derive(current);
1484
+ if (!later) return derive({ ...current, message: copyParam("time.noLaterTimes", "There are no later times in the next 90 days.") });
1485
+ allRows = later.built.rows;
1486
+ slotsById = later.built.slots;
1487
+ waitlistIds = later.built.waitlist;
1488
+ greyByDay = new Map();
1489
+ const day = later.built.rows.filter((row) => row.selectable && locationMatches(row, s.locationId))
1490
+ .sort(byDay)[0]!.dayKey;
1491
+ if (!includeFull) await loadGreyDay(s.id, day);
1492
+ return derive(withDay(visibleSelection({ ...current, windowStart: later.windowStart }), day));
1493
+ }
1494
+
1495
+ /** The booking itself: a waitlist, a paid or course checkout, or a free booking. */
1496
+ async function bookSelectedIn(s: S): Promise<S> {
1497
+ if (!s.isMember) return derive({ ...s, message: copyParam("time.bookNeedsSignIn", "Sign in before booking.") });
1498
+ if (s.isCourse && !s.scheduleId) return derive({ ...s, message: copyParam("time.courseUnavailable", "Course booking is unavailable right now.") });
1499
+ if (!s.isCourse && !s.selectedSlotId) return derive({ ...s, message: copyParam("time.bookNeedsSlot", "Choose one time first.") });
1500
+ const joiningWaitlist = !s.isCourse && waitlistIds.has(s.selectedSlotId);
1501
+ const slot = s.isCourse ? undefined : slotsById.get(s.selectedSlotId);
1502
+ if (!s.isCourse && !slot && !joiningWaitlist) return derive({ ...s, message: copyParam("time.bookSlotGone", "That time is no longer available. Try again.") });
1503
+ // A booking is paid through the regular checkout only where that is allowed; joining a
1504
+ // waitlist buys nothing, so it skips this check.
1505
+ if (!joiningWaitlist) {
1506
+ try {
1507
+ const current = await getService(s.id);
1508
+ // A course is booked whole, never one of its sessions as a time: a screen opened with no
1509
+ // `course` input whose first service read failed lists the sessions as times.
1510
+ if (!s.isCourse && current.service?.type === "COURSE") return derive({ ...s, message: copyParam("time.courseUnavailable", "Course booking is unavailable right now.") });
1511
+ regularCheckoutAllowed = await mayUseRegularBookingCheckout(current.service);
1512
+ if (serviceHeaderFields(current.service).paid !== s.serviceIsPaid) return derive({ ...s, message: copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
1513
+ } catch {
1514
+ if (s.serviceIsPaid) return derive({ ...s, message: copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
1515
+ }
1516
+ if (!regularCheckoutAllowed) return derive({ ...s, message: copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
1517
+ }
1518
+ // A confirmed booking is reported to the app hosting this screen (an embed's `onAction`), so a
1519
+ // host chat can say so and refresh what it shows. Dropped where nothing hosts the screen.
1520
+ // The booking's own instants carry an offset; a slot's may be LOCAL to its timezone, which
1521
+ // `emitHostEvent` leaves out rather than let the host read it in its own zone.
1522
+ const reportBooked = (booking: { id?: string; startDate?: string; endDate?: string } | undefined) => emitHostEvent("booking.created", {
1523
+ bookingId: booking?.id,
1524
+ serviceId: s.id,
1525
+ serviceName: s.serviceName,
1526
+ start: hostEventInstant(booking?.startDate) || slot?.startDate || s.courseStart,
1527
+ end: hostEventInstant(booking?.endDate) || slot?.endDate || s.courseEnd,
1528
+ });
1529
+ // The member acts on the pick now; a later visit must not restore it.
1530
+ clearPick();
1531
+ if (joiningWaitlist) {
1532
+ const pending = derive({ ...s, bookingInFlight: true, message: "", successMessage: "" });
1533
+ const my = await getMyData().catch(() => ({ email: s.email } as WixMyData));
1534
+ const email = (await signedInEmail()) || my.email || my.contactEmail || s.email;
1535
+ const name = contactName(displayName(my) || email);
1536
+ try {
1537
+ await registerToWaitlist(s.selectedSlotId, { email, firstName: name.first, lastName: name.last });
1538
+ return derive({ ...pending, bookingInFlight: false,
1539
+ successMessage: copyParam("time.waitlistJoined", "You're on the waitlist. Check My Bookings for updates."), message: "" });
1540
+ } catch {
1541
+ return derive({ ...s, bookingInFlight: false,
1542
+ message: copyParam("time.waitlistFailed", "Couldn't join the waitlist. Try again.") });
1543
+ }
1544
+ }
1545
+ // Paid service or any course — create a Wix eCommerce order so a free
1546
+ // course does not remain in CREATED status with no reserved capacity.
1547
+ // FULLY NATIVE in-app payment (mirrors Wix's RN apps): NO
1548
+ // hosted-checkout WebView / browser redirect. The chain is
1549
+ // createBooking → ecom.createCheckout(booking line item) → ecom.createOrder
1550
+ // → @himalaya/wix-cashier.proceedToPayment(snapshotId)
1551
+ // where `paymentGatewayOrderId` from createOrder is the cashier order id
1552
+ // (snapshotId) settled on-device via Apple/Google Pay. We do NOT optimistically
1553
+ // write a local booking row (My Bookings revalidates from Wix on success). Any
1554
+ // failure degrades to an inline retry message, never a dead button.
1555
+ if (s.serviceIsPaid || s.isCourse) {
1556
+ const pending = derive({ ...s, bookingInFlight: true, message: "", successMessage: "" });
1557
+ // Booker identity — same resolution as the free path (never throws).
1558
+ const payerData = await getMyData().catch(() => ({ email: s.email } as WixMyData));
1559
+ const payerEmail = (await signedInEmail()) || payerData.email || payerData.contactEmail || s.email;
1560
+ const payerName = contactName(displayName(payerData) || payerEmail);
1561
+ // Only the same customer's own unfinished booking for this course is reused.
1562
+ const retried = s.isCourse && pendingCourseBooking?.scheduleId === s.scheduleId && pendingCourseBooking.email === payerEmail
1563
+ ? pendingCourseBooking : undefined;
1564
+ if (s.isCourse) {
1565
+ try {
1566
+ const latest = (await getService(s.id)).service;
1567
+ const open = latest != null && await courseOpenTo(latest, new Date(), retried?.ordered === true);
1568
+ if (!open) {
1569
+ return derive({ ...pending, bookingInFlight: false, courseAvailable: false,
1570
+ message: copyParam("time.courseUnavailable", "Course booking is unavailable right now.") });
1571
+ }
1572
+ } catch {
1573
+ return derive({ ...pending, bookingInFlight: false,
1574
+ message: copyParam("time.courseUnavailable", "Course booking is unavailable right now.") });
1575
+ }
1576
+ }
1577
+ try {
1578
+ // 1. Create the booking (CREATED status — payment pending).
1579
+ // A course retried after a failed checkout reuses the booking it already made.
1580
+ const created = retried ? { booking: retried.booking }
1581
+ : s.isCourse
1582
+ ? await bookSchedule(s.scheduleId, payerName.first, payerName.last, payerEmail,
1583
+ s.coursePaymentOption ? { selectedPaymentOption: s.coursePaymentOption } : undefined)
1584
+ : await bookSlot(slot!, payerName.first, payerName.last, payerEmail);
1585
+ const bookingId = created.booking?.id;
1586
+ if (!bookingId) throw new Error("booking not created");
1587
+ if (s.isCourse && !retried) pendingCourseBooking = { scheduleId: s.scheduleId, email: payerEmail, booking: created.booking! };
1588
+ // 2. Wrap the booking as a checkout line item; create the checkout.
1589
+ const checkoutRes = await createCheckout([
1590
+ { quantity: 1, catalogReference: { appId: WIX_BOOKINGS_APP_ID, catalogItemId: bookingId } },
1591
+ ]);
1592
+ const checkoutId = checkoutRes.checkout?.id;
1593
+ if (!checkoutId) throw new Error("checkout not created");
1594
+ // 3. Create the Wix-managed order — mints the cashier snapshot when a
1595
+ // balance is due. No paymentGatewayOrderId ⇒ nothing to charge
1596
+ // (covered / zero total) ⇒ the booking is already confirmed.
1597
+ // Hosted (Spaces): the site's own checkout page creates the order (as
1598
+ // RN does) — creating it here first fails on a site with no payment method set up.
1599
+ const hosted = isHostedWorker();
1600
+ const orderRes = hosted ? { paymentGatewayOrderId: "" } : await createOrder(checkoutId);
1601
+ if (s.isCourse && !hosted && pendingCourseBooking) pendingCourseBooking.ordered = true;
1602
+ const snapshotId = orderRes.paymentGatewayOrderId ?? "";
1603
+ if ((!snapshotId || (s.isCourse && s.coursePaymentOption === "OFFLINE")) && !hosted) {
1604
+ pendingCourseBooking = null;
1605
+ await reportBooked(created.booking);
1606
+ return derive({ ...pending, bookingInFlight: false, successMessage: s.isCourse
1607
+ ? copyParam("time.courseSubmitted", "Course booking submitted. Check My Bookings for confirmation.")
1608
+ : copyParam("time.successMessage", "Booked. Your confirmation is ready in My Bookings."), message: "" });
1609
+ }
1610
+ // 4. Collect payment IN-APP — wallet when this app has one wired, otherwise
1611
+ // the in-app Wix cashier. Routed through the shared helper so a
1612
+ // wallet-less app can still actually pay: calling proceedToPayment
1613
+ // unconditionally created the booking + order and THEN failed, leaving an
1614
+ // unpaid booking behind (see @wix/himalaya/checkout).
1615
+ const pay = await payCashierOrder({
1616
+ appId: WIX_BOOKINGS_APP_ID,
1617
+ snapshotId,
1618
+ msid: await resolveMsid(),
1619
+ // Hosted (Spaces): the host opens this booking's checkout as the lent member.
1620
+ ...(hosted ? { openCashier: hostedCheckoutOpener(checkoutId) } : {}),
1621
+ cashierUrl: async () => {
1622
+ const redirect = await createRedirectSession(s.isCourse
1623
+ ? { ecomCheckout: { checkoutId } }
1624
+ : { bookingsCheckout: { slotAvailability: { slot: slot as WixSlot } } });
1625
+ return redirect.redirectSession?.fullUrl;
1626
+ },
1627
+ });
1628
+ if (pay.kind === "paid") {
1629
+ pendingCourseBooking = null;
1630
+ await reportBooked(created.booking);
1631
+ return derive({ ...pending, bookingInFlight: false, successMessage: copyParam("time.successMessage", "Booked. Your confirmation is ready in My Bookings."), message: "" });
1632
+ }
1633
+ if (pay.kind === "handoff") {
1634
+ // The cashier is open in-app; the booking confirms once payment lands, so
1635
+ // promise nothing here — My Bookings revalidates from Wix.
1636
+ return derive({ ...pending, bookingInFlight: false, message: copyParam("time.checkoutOpened", "Complete your payment in the checkout to confirm this booking.") });
1637
+ }
1638
+ if (pay.kind === "cancelled") {
1639
+ return derive({ ...s, bookingInFlight: false, message: s.isCourse
1640
+ ? copyParam("time.coursePaymentCancelled", "Payment cancelled — your course booking wasn't confirmed. Try again.")
1641
+ : copyParam("time.paymentCancelled", "Payment cancelled — your booking wasn't confirmed. Pick a time to try again.") });
1642
+ }
1643
+ if (isPaymentSetupFailure(pay)) return derive({ ...s, bookingInFlight: false, message: paymentSetupMessage("time") });
1644
+ return derive({ ...s, bookingInFlight: false, message: s.isCourse
1645
+ ? copyParam("time.coursePaymentFailed", "Payment couldn't be completed. Try again.")
1646
+ : copyParam("time.paymentFailed", "Payment couldn't be completed. Pick another time or try again.") });
1647
+ } catch (error) {
1648
+ if (isPaymentSetupFailure(error)) return derive({ ...s, bookingInFlight: false, message: paymentSetupMessage("time") });
1649
+ return derive({ ...s, bookingInFlight: false, message: s.isCourse
1650
+ ? copyParam("time.courseCheckoutFailed", "Checkout could not start. Try again.")
1651
+ : copyParam("time.checkoutFailed", "Checkout could not start. Pick another time or try again.") });
1652
+ }
1653
+ }
1654
+ // FREE appointment/class — the original member bookSlot rung (offline-safe below).
1655
+ // getMyData/signedInEmail carry their own fallbacks (never throw), so the
1656
+ // booker identity is resolved up front — needed for both the optimistic
1657
+ // cache row and the offline replay params.
1658
+ const my = await getMyData().catch(() => ({ email: s.email } as WixMyData));
1659
+ const email = (await signedInEmail()) || my.email || my.contactEmail || s.email;
1660
+ const name = contactName(displayName(my) || email);
1661
+ // Optimistic local write — My Bookings repaints from cache immediately (offline or not).
1662
+ const selectedSlot = allRows.find((r) => r.id === s.selectedSlotId);
1663
+ const localId = `local-${Date.now()}`;
1664
+ const localRow = (id: string): DbEntity => ({
1665
+ id,
1666
+ title: s.serviceName || "Booking",
1667
+ subtitle: s.isCourse ? (s.selectedLine || "Full course") : (selectedSlot?.title || "Booked time"),
1668
+ status: "Booked",
1669
+ });
1670
+ if (!offlineWritesEnabled) await cachePut(localRow(localId));
1671
+ const pending = derive({ ...s, bookingInFlight: true, message: "", successMessage: "" });
1672
+ try {
1673
+ const created = await bookSlot(slot!, name.first, name.last, email);
1674
+ // Reconcile the optimistic local id with the server-assigned booking id.
1675
+ const realId = created.booking?.id;
1676
+ if (offlineWritesEnabled) await cachePut(localRow(realId || localId));
1677
+ else if (realId && realId !== localId) { await cacheDrop(localId); await cachePut(localRow(realId)); }
1678
+ await reportBooked(created.booking);
1679
+ return derive({
1680
+ ...pending,
1681
+ bookingInFlight: false,
1682
+ successMessage: copyParam("time.successMessage", "Booked. Your confirmation is ready in My Bookings."),
1683
+ message: "",
1684
+ });
1685
+ } catch (e) {
1686
+ if (!offlineWritesEnabled && isBridgeUnavailable(e)) {
1687
+ // Offline / no bridge: the optimistic cache row already shows in My
1688
+ // Bookings; queue the booking to replay (WriteQueue, idempotent) when
1689
+ // connectivity returns.
1690
+ await queueWrite("tier5.himiBookingTime.bookSelected", {
1691
+ serviceId: s.id,
1692
+ ...(s.isCourse ? { scheduleId: s.scheduleId } : { slotId: s.selectedSlotId }),
1693
+ email,
1694
+ firstName: name.first,
1695
+ lastName: name.last,
1696
+ ...(slot ? { slot: JSON.stringify(slot) } : {}),
1697
+ });
1698
+ return derive({
1699
+ ...pending,
1700
+ bookingInFlight: false,
1701
+ successMessage: "Booked offline — will sync when you reconnect.",
1702
+ message: "",
1703
+ });
1704
+ }
1705
+ return derive({ ...s, bookingInFlight: false, message: s.isCourse
1706
+ ? copyParam("time.courseBookFailed", "Course booking failed. Try again.")
1707
+ : copyParam("time.bookFailed", "Booking failed. Pick another time or try again.") });
1708
+ }
1709
+ }
1710
+ resetUnhydratedKeysOnOlderRuntime(svc, { ...svc.getState(), ...ATTENDANCE_INITIAL, email: "", password: "", firstName: "", lastName: "", isMember: false, canBook: false, cannotBook: true }, [...ATTENDANCE_NOT_HYDRATED, "email", "password", "firstName", "lastName", "isMember", "canBook", "cannotBook"]);
1711
+ svc.load();
1712
+ export { svc };