@wix/himalaya-cli 0.1525.0 → 0.1527.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 (177) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/builder.d.ts +14 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/state/src/connectivity.d.ts +5 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/state/src/index.d.ts +1 -0
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +22 -0
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/worker-subtree-safe-types.generated.d.ts +4 -0
  6. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/core.mjs +7 -0
  7. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/sdui.mjs +13 -0
  8. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/state.mjs +14 -0
  9. package/dist/cli.mjs +157 -40
  10. package/dist/preview-assets.generated.bin +0 -0
  11. package/guides/designing-himi-screens.md +43 -18
  12. package/guides/himi-test.md +3 -3
  13. package/guides/recipe-dynamic-list.md +7 -7
  14. package/guides/recipe-four-state.md +47 -41
  15. package/guides/recipe-search.md +1 -1
  16. package/package.json +2 -2
  17. package/templates/beauty-salon/dev/index.ts +1943 -646
  18. package/templates/beauty-salon/tier5-src/_shared/collection-service.ts +5 -1
  19. package/templates/beauty-salon/tier5-src/_shared/online-actions.ts +76 -0
  20. package/templates/beauty-salon/tier5-src/himi-booking-bookings/index.ts +1 -0
  21. package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +803 -284
  22. package/templates/beauty-salon/tier5-src/himi-branded-lite-address/index.ts +3 -1
  23. package/templates/beauty-salon/tier5-src/himi-branded-lite-addresses/index.ts +3 -1
  24. package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +9 -1
  25. package/templates/beauty-salon/tier5-src/himi-branded-lite-cart/index.ts +3 -0
  26. package/templates/beauty-salon/tier5-src/himi-branded-lite-order/index.ts +3 -1
  27. package/templates/beauty-salon/tier5-src/himi-branded-lite-place-summary/index.ts +12 -1
  28. package/templates/beauty-salon/tier5-src/himi-branded-lite-plans/index.ts +13 -1
  29. package/templates/beauty-salon/tier5-src/himi-branded-lite-wallet/index.ts +3 -1
  30. package/templates/beauty-salon/tier5-src/himi-store-account/index.ts +69 -10
  31. package/templates/beauty-salon/tier5-src/himi-store-product/index.ts +10 -4
  32. package/templates/beauty-salon-owner/tier5-src/_shared/collection-service.ts +5 -1
  33. package/templates/beauty-salon-owner/tier5-src/_shared/online-actions.ts +76 -0
  34. package/templates/branded-lite/dev/index.ts +2404 -1020
  35. package/templates/branded-lite/tier5-src/_shared/collection-service.ts +5 -1
  36. package/templates/branded-lite/tier5-src/_shared/online-actions.ts +76 -0
  37. package/templates/branded-lite/tier5-src/business-chat/index.ts +4 -0
  38. package/templates/branded-lite/tier5-src/himi-booking-bookings/index.ts +1 -0
  39. package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +803 -284
  40. package/templates/branded-lite/tier5-src/himi-branded-lite-address/index.ts +3 -1
  41. package/templates/branded-lite/tier5-src/himi-branded-lite-addresses/index.ts +3 -1
  42. package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +9 -1
  43. package/templates/branded-lite/tier5-src/himi-branded-lite-cart/index.ts +3 -0
  44. package/templates/branded-lite/tier5-src/himi-branded-lite-feed/index.ts +3 -1
  45. package/templates/branded-lite/tier5-src/himi-branded-lite-group/index.ts +3 -1
  46. package/templates/branded-lite/tier5-src/himi-branded-lite-notifications/index.ts +6 -4
  47. package/templates/branded-lite/tier5-src/himi-branded-lite-notifsettings/index.ts +6 -4
  48. package/templates/branded-lite/tier5-src/himi-branded-lite-order/index.ts +3 -1
  49. package/templates/branded-lite/tier5-src/himi-branded-lite-place-summary/index.ts +12 -1
  50. package/templates/branded-lite/tier5-src/himi-branded-lite-plans/index.ts +13 -1
  51. package/templates/branded-lite/tier5-src/himi-branded-lite-program/index.ts +3 -1
  52. package/templates/branded-lite/tier5-src/himi-branded-lite-reservations/index.ts +6 -4
  53. package/templates/branded-lite/tier5-src/himi-branded-lite-reserve/index.ts +3 -1
  54. package/templates/branded-lite/tier5-src/himi-branded-lite-wallet/index.ts +3 -1
  55. package/templates/branded-lite/tier5-src/himi-events-detail/index.ts +8 -1
  56. package/templates/branded-lite/tier5-src/himi-events-rsvp/index.ts +7 -4
  57. package/templates/branded-lite/tier5-src/himi-store-account/index.ts +69 -10
  58. package/templates/branded-lite/tier5-src/himi-store-product/index.ts +10 -4
  59. package/templates/community-club/dev/index.ts +40 -21
  60. package/templates/community-club/tier5-src/_shared/collection-service.ts +5 -1
  61. package/templates/community-club/tier5-src/_shared/online-actions.ts +76 -0
  62. package/templates/community-club/tier5-src/himi-branded-lite-feed/index.ts +3 -1
  63. package/templates/community-club/tier5-src/himi-branded-lite-group/index.ts +3 -1
  64. package/templates/community-club/tier5-src/himi-events-detail/index.ts +8 -1
  65. package/templates/community-club/tier5-src/himi-events-rsvp/index.ts +7 -4
  66. package/templates/community-club/tier5-src/himi-store-account/index.ts +69 -10
  67. package/templates/community-club-owner/tier5-src/_shared/collection-service.ts +5 -1
  68. package/templates/community-club-owner/tier5-src/_shared/online-actions.ts +76 -0
  69. package/templates/events-meetups/dev/index.ts +36 -19
  70. package/templates/events-meetups/tier5-src/_shared/collection-service.ts +5 -1
  71. package/templates/events-meetups/tier5-src/_shared/online-actions.ts +76 -0
  72. package/templates/events-meetups/tier5-src/himi-branded-lite-feed/index.ts +3 -1
  73. package/templates/events-meetups/tier5-src/himi-branded-lite-group/index.ts +3 -1
  74. package/templates/events-meetups/tier5-src/himi-events-detail/index.ts +8 -1
  75. package/templates/events-meetups/tier5-src/himi-events-rsvp/index.ts +7 -4
  76. package/templates/events-meetups/tier5-src/himi-store-account/index.ts +69 -10
  77. package/templates/events-meetups-owner/tier5-src/_shared/collection-service.ts +5 -1
  78. package/templates/events-meetups-owner/tier5-src/_shared/online-actions.ts +76 -0
  79. package/templates/gym-fitness/dev/index.ts +1944 -645
  80. package/templates/gym-fitness/tier5-src/_shared/collection-service.ts +5 -1
  81. package/templates/gym-fitness/tier5-src/_shared/online-actions.ts +76 -0
  82. package/templates/gym-fitness/tier5-src/himi-booking-bookings/index.ts +1 -0
  83. package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +803 -284
  84. package/templates/gym-fitness/tier5-src/himi-branded-lite-address/index.ts +3 -1
  85. package/templates/gym-fitness/tier5-src/himi-branded-lite-addresses/index.ts +3 -1
  86. package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +9 -1
  87. package/templates/gym-fitness/tier5-src/himi-branded-lite-cart/index.ts +3 -0
  88. package/templates/gym-fitness/tier5-src/himi-branded-lite-order/index.ts +3 -1
  89. package/templates/gym-fitness/tier5-src/himi-branded-lite-place-summary/index.ts +12 -1
  90. package/templates/gym-fitness/tier5-src/himi-branded-lite-plans/index.ts +13 -1
  91. package/templates/gym-fitness/tier5-src/himi-branded-lite-wallet/index.ts +3 -1
  92. package/templates/gym-fitness/tier5-src/himi-events-detail/index.ts +8 -1
  93. package/templates/gym-fitness/tier5-src/himi-events-rsvp/index.ts +7 -4
  94. package/templates/gym-fitness/tier5-src/himi-store-account/index.ts +69 -10
  95. package/templates/gym-fitness/tier5-src/himi-store-product/index.ts +10 -4
  96. package/templates/gym-fitness-owner/tier5-src/_shared/collection-service.ts +5 -1
  97. package/templates/gym-fitness-owner/tier5-src/_shared/online-actions.ts +76 -0
  98. package/templates/nonprofit-cause/dev/index.ts +47 -29
  99. package/templates/nonprofit-cause/tier5-src/_shared/collection-service.ts +5 -1
  100. package/templates/nonprofit-cause/tier5-src/_shared/online-actions.ts +76 -0
  101. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-feed/index.ts +3 -1
  102. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-group/index.ts +3 -1
  103. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-place-summary/index.ts +12 -1
  104. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-plans/index.ts +13 -1
  105. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-wallet/index.ts +3 -1
  106. package/templates/nonprofit-cause/tier5-src/himi-events-detail/index.ts +8 -1
  107. package/templates/nonprofit-cause/tier5-src/himi-events-rsvp/index.ts +7 -4
  108. package/templates/nonprofit-cause/tier5-src/himi-store-account/index.ts +69 -10
  109. package/templates/nonprofit-cause-owner/tier5-src/_shared/collection-service.ts +5 -1
  110. package/templates/nonprofit-cause-owner/tier5-src/_shared/online-actions.ts +76 -0
  111. package/templates/owner-lite/tier5-src/_shared/collection-service.ts +5 -1
  112. package/templates/owner-lite/tier5-src/_shared/online-actions.ts +76 -0
  113. package/templates/podcast-creator/dev/index.ts +47 -29
  114. package/templates/podcast-creator/tier5-src/_shared/collection-service.ts +5 -1
  115. package/templates/podcast-creator/tier5-src/_shared/online-actions.ts +76 -0
  116. package/templates/podcast-creator/tier5-src/himi-branded-lite-feed/index.ts +3 -1
  117. package/templates/podcast-creator/tier5-src/himi-branded-lite-place-summary/index.ts +12 -1
  118. package/templates/podcast-creator/tier5-src/himi-branded-lite-plans/index.ts +13 -1
  119. package/templates/podcast-creator/tier5-src/himi-branded-lite-wallet/index.ts +3 -1
  120. package/templates/podcast-creator/tier5-src/himi-events-detail/index.ts +8 -1
  121. package/templates/podcast-creator/tier5-src/himi-events-rsvp/index.ts +7 -4
  122. package/templates/podcast-creator/tier5-src/himi-store-account/index.ts +69 -10
  123. package/templates/podcast-creator-owner/tier5-src/_shared/collection-service.ts +5 -1
  124. package/templates/podcast-creator-owner/tier5-src/_shared/online-actions.ts +76 -0
  125. package/templates/portfolio-creative/dev/index.ts +52 -27
  126. package/templates/portfolio-creative/tier5-src/_shared/collection-service.ts +5 -1
  127. package/templates/portfolio-creative/tier5-src/_shared/online-actions.ts +76 -0
  128. package/templates/portfolio-creative/tier5-src/himi-branded-lite-address/index.ts +3 -1
  129. package/templates/portfolio-creative/tier5-src/himi-branded-lite-addresses/index.ts +3 -1
  130. package/templates/portfolio-creative/tier5-src/himi-branded-lite-cart/index.ts +3 -0
  131. package/templates/portfolio-creative/tier5-src/himi-branded-lite-order/index.ts +3 -1
  132. package/templates/portfolio-creative/tier5-src/himi-branded-lite-wallet/index.ts +3 -1
  133. package/templates/portfolio-creative/tier5-src/himi-events-detail/index.ts +8 -1
  134. package/templates/portfolio-creative/tier5-src/himi-events-rsvp/index.ts +7 -4
  135. package/templates/portfolio-creative/tier5-src/himi-store-account/index.ts +69 -10
  136. package/templates/portfolio-creative/tier5-src/himi-store-product/index.ts +10 -4
  137. package/templates/portfolio-creative-owner/tier5-src/_shared/collection-service.ts +5 -1
  138. package/templates/portfolio-creative-owner/tier5-src/_shared/online-actions.ts +76 -0
  139. package/templates/restaurant/dev/index.ts +48 -25
  140. package/templates/restaurant/tier5-src/_shared/collection-service.ts +5 -1
  141. package/templates/restaurant/tier5-src/_shared/online-actions.ts +76 -0
  142. package/templates/restaurant/tier5-src/himi-branded-lite-address/index.ts +3 -1
  143. package/templates/restaurant/tier5-src/himi-branded-lite-addresses/index.ts +3 -1
  144. package/templates/restaurant/tier5-src/himi-branded-lite-reserve/index.ts +3 -1
  145. package/templates/restaurant/tier5-src/himi-branded-lite-wallet/index.ts +3 -1
  146. package/templates/restaurant/tier5-src/himi-events-detail/index.ts +8 -1
  147. package/templates/restaurant/tier5-src/himi-events-rsvp/index.ts +7 -4
  148. package/templates/restaurant/tier5-src/himi-store-account/index.ts +69 -10
  149. package/templates/restaurant-owner/tier5-src/_shared/collection-service.ts +5 -1
  150. package/templates/restaurant-owner/tier5-src/_shared/online-actions.ts +76 -0
  151. package/templates/retail-store/dev/index.ts +44 -23
  152. package/templates/retail-store/tier5-src/_shared/collection-service.ts +5 -1
  153. package/templates/retail-store/tier5-src/_shared/online-actions.ts +76 -0
  154. package/templates/retail-store/tier5-src/himi-branded-lite-address/index.ts +3 -1
  155. package/templates/retail-store/tier5-src/himi-branded-lite-addresses/index.ts +3 -1
  156. package/templates/retail-store/tier5-src/himi-branded-lite-cart/index.ts +3 -0
  157. package/templates/retail-store/tier5-src/himi-branded-lite-order/index.ts +3 -1
  158. package/templates/retail-store/tier5-src/himi-branded-lite-wallet/index.ts +3 -1
  159. package/templates/retail-store/tier5-src/himi-store-account/index.ts +69 -10
  160. package/templates/retail-store/tier5-src/himi-store-product/index.ts +10 -4
  161. package/templates/retail-store-owner/tier5-src/_shared/collection-service.ts +5 -1
  162. package/templates/retail-store-owner/tier5-src/_shared/online-actions.ts +76 -0
  163. package/templates/yoga-studio/dev/index.ts +2102 -811
  164. package/templates/yoga-studio/tier5-src/_shared/collection-service.ts +5 -1
  165. package/templates/yoga-studio/tier5-src/_shared/online-actions.ts +76 -0
  166. package/templates/yoga-studio/tier5-src/himi-booking-bookings/index.ts +1 -0
  167. package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +803 -284
  168. package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +9 -1
  169. package/templates/yoga-studio/tier5-src/himi-branded-lite-place-summary/index.ts +12 -1
  170. package/templates/yoga-studio/tier5-src/himi-branded-lite-plans/index.ts +13 -1
  171. package/templates/yoga-studio/tier5-src/himi-branded-lite-program/index.ts +3 -1
  172. package/templates/yoga-studio/tier5-src/himi-branded-lite-wallet/index.ts +3 -1
  173. package/templates/yoga-studio/tier5-src/himi-events-detail/index.ts +8 -1
  174. package/templates/yoga-studio/tier5-src/himi-events-rsvp/index.ts +7 -4
  175. package/templates/yoga-studio/tier5-src/himi-store-account/index.ts +69 -10
  176. package/templates/yoga-studio-owner/tier5-src/_shared/collection-service.ts +5 -1
  177. package/templates/yoga-studio-owner/tier5-src/_shared/online-actions.ts +76 -0
@@ -1,5 +1,6 @@
1
+ import { withOnlineWrites, offlineWritesEnabled } from "../_shared/online-actions.js";
1
2
  // Tier-5 bundle: himi-booking — availability and member booking.
2
- import { defineService } from "@himalaya/state";
3
+ import { defineService as registerService, navigate } from "@himalaya/state";
3
4
  // Local-day keying + shared day/time labelling. `addDays` below stays local and
4
5
  // UTC-based on purpose: it bounds the availability QUERY window, which is an
5
6
  // instant range, not a calendar day.
@@ -13,12 +14,12 @@ import { hostedCheckoutOpener, isHostedWorker } from "../_shared/hosted-checkout
13
14
  import { session as wixPlatformSession } from "@himalaya/wix-platform";
14
15
  import { displayName, getMyData, type WixMyData } from "@himalaya/wix-users";
15
16
  import { formatServicePrice, serviceIsChargeable } from "../_shared/wix-service-price.js";
16
- import { stashCopy, copyParam, copyPlural } from "../_shared/himi-copy.js";
17
+ import { stashCopy, copyParam, copyPlural, copyTemplate } from "../_shared/himi-copy.js";
17
18
  import { upsert as dbUpsert, remove as dbRemove, enqueue as dbEnqueue, type DbEntity } from "@himalaya/local-db";
18
19
  import { isBridgeUnavailable } from "../_shared/owner-load-error.js";
19
20
  import { emitHostEvent, hostEventInstant } from "../_shared/host-events.js";
20
21
  import { availabilityBeyond, courseReadWindows, courseSessionsAvailability, courseWindowOpen, slotIdAt } from "../_shared/himi-bookings.js";
21
- import { wixMediaUrl, WIX_DETAIL_IMAGE } from "@himalaya/wix-media";
22
+ import { wixMediaUrl, WIX_DETAIL_IMAGE, WIX_LIST_IMAGE } from "@himalaya/wix-media";
22
23
  import { mayUseRegularBookingCheckout } from "../_shared/iap-attendance.js";
23
24
  import { ATTENDANCE_INITIAL, ATTENDANCE_NOT_HYDRATED, createAttendancePurchase, type AttendancePurchaseFields } from "../_shared/iap-attendance-purchase.js";
24
25
  import { resetUnhydratedKeysOnOlderRuntime } from "../_shared/iap-worker.js";
@@ -60,6 +61,55 @@ interface SlotRow {
60
61
  notSelectable: boolean;
61
62
  selected: boolean;
62
63
  notSelected: boolean;
64
+ /** The slot's start (ISO), for ordering and the pill label. */
65
+ start: string;
66
+ /** The staff member (`slot.resource`): id, else name; "" when the slot names none. */
67
+ staffKey: string;
68
+ staffName: string;
69
+ /** The slot's location key (id, else name + address) and its labels; "" when it has none. */
70
+ locKey: string;
71
+ locLabel: string;
72
+ locSub: string;
73
+ }
74
+
75
+ /** One time in a staff group: a pill with three looks. */
76
+ interface TimePill {
77
+ id: string;
78
+ label: string;
79
+ badge: string;
80
+ hasBadge: boolean;
81
+ noBadge: boolean;
82
+ a11y: string;
83
+ available: boolean;
84
+ notAvailable: boolean;
85
+ picked: boolean;
86
+ notPicked: boolean;
87
+ unavailable: boolean;
88
+ notUnavailable: boolean;
89
+ }
90
+
91
+ /** The selected day's times for one staff member (or the times that name no one). */
92
+ interface StaffGroup {
93
+ id: string;
94
+ name: string;
95
+ image: string;
96
+ hasImage: boolean;
97
+ noImage: boolean;
98
+ noStaffHeader: boolean;
99
+ hasStaffHeader: boolean;
100
+ times: TimePill[];
101
+ }
102
+
103
+ /** One row of the location sheet. `id` "" is "All locations". */
104
+ interface LocationOption {
105
+ id: string;
106
+ label: string;
107
+ sub: string;
108
+ hasSub: boolean;
109
+ noSub: boolean;
110
+ selected: boolean;
111
+ notSelected: boolean;
112
+ a11y: string;
63
113
  }
64
114
 
65
115
  interface S extends AttendancePurchaseFields {
@@ -146,6 +196,38 @@ interface S extends AttendancePurchaseFields {
146
196
  bookingInFlight: boolean;
147
197
  notBooking: boolean;
148
198
  bookingsScreenId: string;
199
+ /** First day of the day strip (`YYYY-MM-DD`): today, or the first two weeks with times. */
200
+ windowStart: string;
201
+ /** The location filter: "" is every location. */
202
+ locationId: string;
203
+ locationLabel: string;
204
+ locationA11y: string;
205
+ locationOptions: LocationOption[];
206
+ locationSheetOpen: boolean;
207
+ /** Two or more locations: the trailing side of the day row opens the sheet. */
208
+ hasLocationMenu: boolean;
209
+ noLocationMenu: boolean;
210
+ /** Exactly one location with a name or an address: its name, not tappable. */
211
+ singleLocation: string;
212
+ hasSingleLocation: boolean;
213
+ noSingleLocation: boolean;
214
+ /** The selected day's times, grouped by staff member. */
215
+ groups: StaffGroup[];
216
+ /** The selected day has no time to book (the window has some). */
217
+ dayEmpty: boolean;
218
+ hasDayTimes: boolean;
219
+ /** Nothing to book in the next 90 days: one message under the header. */
220
+ noUpcoming: boolean;
221
+ hasUpcoming: boolean;
222
+ /** The result of a tap, in a sheet above the tab bar: a booking stays until closed, other
223
+ * results close by themselves. */
224
+ resultText: string;
225
+ resultSticky: boolean;
226
+ resultAuto: boolean;
227
+ resultIsBooking: boolean;
228
+ resultNotBooking: boolean;
229
+ /** A course has no times: its messages stay on the screen, under its button. */
230
+ courseMessage: string;
149
231
  }
150
232
 
151
233
  /** One day-strip chip — the `FilterChips` option shape `dateStrip` binds. */
@@ -172,6 +254,19 @@ let allRows: SlotRow[] = [];
172
254
  // could neither see nor book. Worker state now carries one day's rows, so the
173
255
  // bound that list needed is gone, and the availability query has no paging.
174
256
  const WINDOW_DAYS = 14;
257
+ /** How far ahead the screen looks for a first time, and for "Check Next Availability". */
258
+ const HORIZON_DAYS = 90;
259
+ const DAY_MS = 86_400_000;
260
+
261
+ /** The service record of the last load (its type and waitlist policy shape new rows). */
262
+ let lastService: WixService | undefined;
263
+ /** The selected day's times that cannot be booked (full, too early, too late), by day. They show
264
+ * greyed. Read with `bookable: false` for one day at a time, because that response is large. */
265
+ let greyByDay = new Map<string, SlotRow[]>();
266
+ /** Staff photos from the service's `staffMemberDetails`, by staff id and by name. */
267
+ let staffPhotos = { byId: new Map<string, string>(), byName: new Map<string, string>() };
268
+ /** True while a tap books. A second tap, or a tap during a booking, sends nothing. */
269
+ let bookingLock = false;
175
270
 
176
271
  /** A course booking created by an earlier tap whose checkout or payment did not finish. It stays
177
272
  * on Wix, so the next tap pays for THIS booking instead of creating a second one for the same
@@ -356,7 +451,187 @@ function chosenDay(arg: unknown): string {
356
451
  return typeof a?.id === "string" ? a.id : "";
357
452
  }
358
453
 
359
- /** Every day of the window from today (local), plus any day a slot lands on
454
+ /** A slot's location: a key to compare by (id, else name + address) and its labels. A location
455
+ * with neither a name nor an address has no key: it is not offered and not counted. */
456
+ function locationOf(slot: WixBookingSlotFull): { key: string; label: string; sub: string } {
457
+ const loc = (slot.location ?? {}) as { id?: unknown; name?: unknown; formattedAddress?: unknown; locationType?: unknown };
458
+ const name = typeof loc.name === "string" ? loc.name.trim() : "";
459
+ const address = typeof loc.formattedAddress === "string" ? loc.formattedAddress.trim() : "";
460
+ const id = typeof loc.id === "string" ? loc.id : "";
461
+ if (loc.locationType === "CUSTOMER") return { key: id || "customer", label: copyParam("bookingCommon.clientPlace", "Client's place"), sub: "" };
462
+ if (!name && !address) return { key: "", label: "", sub: "" };
463
+ return { key: id || `${name}|${address}`, label: name || address, sub: name && address ? address : "" };
464
+ }
465
+
466
+ function staffOf(slot: WixBookingSlotFull): { key: string; name: string } {
467
+ const resource = (slot.resource ?? {}) as { id?: unknown; name?: unknown };
468
+ const name = typeof resource.name === "string" ? resource.name.trim() : "";
469
+ const id = typeof resource.id === "string" ? resource.id : "";
470
+ return { key: id || name, name };
471
+ }
472
+
473
+ /** The rows for one availability response. A class lists its full sessions too (greyed, or a
474
+ * waitlist). Two slots at the same instant with different staff get distinct ids. */
475
+ 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> } {
476
+ const slots = new Map<string, WixBookingSlotFull>();
477
+ const waitlist = new Set<string>();
478
+ const rows: SlotRow[] = [];
479
+ const used = new Set<string>();
480
+ // Group by LOCAL calendar day, not by the formatted heading: two
481
+ // distinct days can render the same text, and a UTC-derived key can
482
+ // name a different day than the heading beside it.
483
+ const classService = service?.type === "CLASS";
484
+ for (const [index, entry] of entries
485
+ .filter((candidate) => candidate.slot?.startDate && (candidate.bookable !== false || (classService && candidate.openSpots === 0)))
486
+ .entries()) {
487
+ const slot = entry.slot as WixBookingSlotFull;
488
+ const key = dayKey(slot.startDate);
489
+ if (!key) continue;
490
+ const staff = staffOf(slot);
491
+ const base = String(slot.sessionId ?? slot.startDate ?? `slot-${index}`);
492
+ const slotId = used.has(base) ? `${base}#${staff.key || index}` : base;
493
+ used.add(slotId);
494
+ const openSpots = classService && typeof entry.openSpots === "number" && entry.openSpots >= 0 ? entry.openSpots : undefined;
495
+ const isWaitlist = openSpots === 0 && service?.bookingPolicy?.waitlistPolicy?.enabled === true &&
496
+ typeof entry.waitingList?.openSpots === "number" && entry.waitingList.openSpots > 0;
497
+ const selectable = (entry.bookable !== false && openSpots !== 0) || isWaitlist;
498
+ if (isWaitlist) waitlist.add(slotId);
499
+ else if (selectable) slots.set(slotId, slot);
500
+ const label = fmtRange(slot.startDate, slot.endDate);
501
+ const loc = locationOf(slot);
502
+ rows.push({
503
+ id: slotId,
504
+ dayKey: key,
505
+ day: label.day,
506
+ dayChip: dayLabel(slot.startDate, { format: "compact" }),
507
+ title: label.title,
508
+ subtitle: label.subtitle,
509
+ badge: openSpots == null ? "" : openSpots > 0
510
+ ? copyPlural("time.spotsLeft", "{n, plural, one {# spot left} other {# spots left}}", openSpots)
511
+ : isWaitlist ? copyParam("time.waitlistBadge", "Waitlist") : copyParam("time.fullBadge", "Full"),
512
+ noBadge: openSpots == null,
513
+ badgeWarning: openSpots != null && openSpots > 0 && openSpots <= 3,
514
+ badgeNeutral: openSpots == null || openSpots === 0 || openSpots > 3,
515
+ selectable,
516
+ notSelectable: !selectable,
517
+ selected: false,
518
+ notSelected: true,
519
+ start: slot.startDate ?? "",
520
+ staffKey: staff.key,
521
+ staffName: staff.name,
522
+ locKey: loc.key,
523
+ locLabel: loc.label,
524
+ locSub: loc.sub,
525
+ });
526
+ }
527
+ return { rows, slots, waitlist };
528
+ }
529
+
530
+ /** The unbookable times of one day's `bookable: false` response that are still ahead (full,
531
+ * too early or too late to book). Never a past time, and never one the window already lists. */
532
+ function greyRows(entries: ReadonlyArray<{ slot?: WixBookingSlotFull; bookable?: boolean }>, day: string, known: SlotRow[], now: number): SlotRow[] {
533
+ const seen = new Set(known.map((row) => `${row.start}|${row.staffKey}`));
534
+ const unbookable = entries.filter((entry) => entry.bookable === false && entry.slot?.startDate
535
+ && Date.parse(entry.slot.startDate) > now && dayKey(entry.slot.startDate) === day)
536
+ .map((entry) => ({ ...entry, bookable: true }));
537
+ return slotRows(unbookable, undefined).rows
538
+ .filter((row) => !seen.has(`${row.start}|${row.staffKey}`))
539
+ .map((row) => ({ ...row, id: `grey:${row.id}`, selectable: false, notSelectable: true }));
540
+ }
541
+
542
+ /** Earliest day first. A plain string compare: `YYYY-MM-DD` keys sort as text, and a worker on
543
+ * a watch engine has no locale-aware string compare. */
544
+ function byDay(a: SlotRow, b: SlotRow): number {
545
+ return a.dayKey < b.dayKey ? -1 : a.dayKey > b.dayKey ? 1 : 0;
546
+ }
547
+
548
+ function locationMatches(row: SlotRow, locationId: string): boolean {
549
+ return !locationId || row.locKey === locationId;
550
+ }
551
+
552
+ /** Every distinct location of the window's times (the greyed ones read so far included), in the
553
+ * order they first appear. */
554
+ function windowLocations(): Array<{ key: string; label: string; sub: string }> {
555
+ const seen = new Map<string, { key: string; label: string; sub: string }>();
556
+ 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 });
557
+ return [...seen.values()];
558
+ }
559
+
560
+ function staffPhoto(key: string, name: string): string {
561
+ return staffPhotos.byId.get(key) ?? (name ? staffPhotos.byName.get(name.toLowerCase()) : undefined) ?? "";
562
+ }
563
+
564
+ function readStaffPhotos(service: WixService | undefined): void {
565
+ const byId = new Map<string, string>();
566
+ const byName = new Map<string, string>();
567
+ for (const member of service?.staffMemberDetails?.staffMembers ?? []) {
568
+ const url = wixMediaUrl(member?.mainMedia?.image?.url, WIX_LIST_IMAGE);
569
+ if (!url) continue;
570
+ const m = member as { staffMemberId?: string; resourceId?: string; name?: string };
571
+ if (m.staffMemberId) byId.set(m.staffMemberId, url);
572
+ if (m.resourceId) byId.set(m.resourceId, url);
573
+ if (m.name) byName.set(m.name.trim().toLowerCase(), url);
574
+ }
575
+ staffPhotos = { byId, byName };
576
+ }
577
+
578
+ /** The selected day's times after the location filter, grouped by staff member. Groups are ordered
579
+ * by their first time (ties by name); a group's times by start. Times that name no staff member
580
+ * share one group with no header. */
581
+ function groupsOf(s: S): StaffGroup[] {
582
+ const day = [...allRows, ...(greyByDay.get(s.selectedDay) ?? [])]
583
+ .filter((row) => row.dayKey === s.selectedDay && locationMatches(row, s.locationId))
584
+ .sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
585
+ const anyStaff = day.some((row) => row.staffKey);
586
+ const groups = new Map<string, { name: string; rows: SlotRow[] }>();
587
+ for (const row of day) {
588
+ const key = anyStaff ? row.staffKey : "";
589
+ const group = groups.get(key) ?? { name: row.staffName, rows: [] };
590
+ group.rows.push(row);
591
+ groups.set(key, group);
592
+ }
593
+ return [...groups.entries()]
594
+ .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))
595
+ .map(([key, group]) => {
596
+ const image = key ? staffPhoto(key, group.name) : "";
597
+ return {
598
+ id: key || "none",
599
+ name: group.name,
600
+ image,
601
+ hasImage: !!image,
602
+ noImage: !image,
603
+ noStaffHeader: !key,
604
+ hasStaffHeader: !!key,
605
+ times: group.rows.map((row) => {
606
+ const picked = row.selectable && row.id === s.selectedSlotId;
607
+ const available = row.selectable && !picked;
608
+ const label = row.title;
609
+ const named = group.name
610
+ ? copyTemplate("time.slotLabel", "{time} with {staff}", { time: label, staff: group.name })
611
+ : label;
612
+ const a11y = row.selectable
613
+ ? [named, row.badge, picked ? copyParam("bookingCommon.selected", "Selected") : ""].filter(Boolean).join(", ")
614
+ : copyTemplate("time.slotUnavailable", "{time}, unavailable", { time: label });
615
+ return {
616
+ id: row.id,
617
+ label,
618
+ badge: row.badge,
619
+ hasBadge: !!row.badge,
620
+ noBadge: !row.badge,
621
+ a11y,
622
+ available,
623
+ notAvailable: !available,
624
+ picked,
625
+ notPicked: !picked,
626
+ unavailable: !row.selectable,
627
+ notUnavailable: row.selectable,
628
+ };
629
+ }),
630
+ };
631
+ });
632
+ }
633
+
634
+ /** Every day of the window from `now` (local), plus any day a slot lands on
360
635
  * outside it, sorted — each with its slot count. */
361
636
  function daysOf(rows: SlotRow[], now: Date = new Date()): DayOption[] {
362
637
  const counts = new Map<string, number>();
@@ -377,7 +652,7 @@ function daysOf(rows: SlotRow[], now: Date = new Date()): DayOption[] {
377
652
  });
378
653
  }
379
654
 
380
- /** Show `day`: its rows (selection flags from the current selection) + heading. */
655
+ /** Show `day`: its heading. `derive` fills its rows from the current selection and location. */
381
656
  function withDay(s: S, day: string): S {
382
657
  const rows = allRows.filter((row) => row.dayKey === day);
383
658
  const noon = day ? new Date(`${day}T12:00:00`) : null;
@@ -389,12 +664,48 @@ function withDay(s: S, day: string): S {
389
664
  };
390
665
  }
391
666
 
392
- function derive(s: S): S {
667
+ /** The day strip, the day's rows, the staff groups and the location row, from the window's rows,
668
+ * the selected day and the location filter. Only for a loaded list of times (not a course). */
669
+ function timesView(s: S): Pick<S, "days" | "slots" | "groups" | "locationOptions" | "locationLabel" | "locationA11y" | "hasLocationMenu" | "noLocationMenu" | "singleLocation" | "hasSingleLocation" | "noSingleLocation"> {
670
+ const all = copyParam("time.allLocations", "All locations");
671
+ const shown = s.status === "ready" && !s.isCourse && !s.errored && !!s.windowStart;
672
+ const locations = shown ? windowLocations() : [];
673
+ const visible = allRows.filter((row) => locationMatches(row, s.locationId));
674
+ const label = s.locationId ? (locations.find((l) => l.key === s.locationId)?.label ?? all) : all;
675
+ const single = locations.length === 1 ? locations[0]!.label : "";
676
+ return {
677
+ days: shown ? daysOf(visible, new Date(`${s.windowStart}T12:00:00`)) : s.days,
678
+ slots: shown
679
+ ? visible.filter((row) => row.dayKey === s.selectedDay).map((row) => ({ ...row, selected: row.id === s.selectedSlotId, notSelected: row.id !== s.selectedSlotId }))
680
+ : s.slots,
681
+ groups: shown ? groupsOf(s) : [],
682
+ locationOptions: locations.length >= 2 ? [{ key: "", label: all, sub: "" }, ...locations].map((l) => {
683
+ const selected = l.key === s.locationId;
684
+ return { id: l.key, label: l.label, sub: l.sub, hasSub: !!l.sub, noSub: !l.sub, selected, notSelected: !selected,
685
+ a11y: [l.label, l.sub, selected ? copyParam("bookingCommon.selected", "Selected") : ""].filter(Boolean).join(", ") };
686
+ }) : [],
687
+ locationLabel: label,
688
+ locationA11y: copyTemplate("time.locationLabel", "Location, {label}", { label }),
689
+ hasLocationMenu: locations.length >= 2,
690
+ noLocationMenu: locations.length < 2,
691
+ singleLocation: single,
692
+ hasSingleLocation: !!single,
693
+ noSingleLocation: !single,
694
+ };
695
+ }
696
+
697
+ function derive(input: S): S {
698
+ const s: S = { ...input, ...timesView(input) };
393
699
  const regular = regularCheckoutAllowed || applePurchase.usesRegularPlatform();
394
700
  // `hasSlots` is the WHOLE window (it drives the screen-level empty state); the
395
701
  // selected day having none is a per-day line, not an empty screen.
396
702
  const hasSlots = s.days.some((d) => d.hasSlots) || s.slots.length > 0;
397
703
  const hasDaySlots = s.slots.length > 0;
704
+ const timesShown = s.status === "ready" && !s.isCourse && !s.errored;
705
+ // Nothing to BOOK in the 90 days: a class's full sessions alone (grey rows) are no upcoming session.
706
+ const noUpcoming = timesShown && !allRows.some((row) => row.selectable);
707
+ const dayEmpty = timesShown && !noUpcoming && !s.slots.some((row) => row.selectable);
708
+ const resultText = s.isCourse ? "" : (s.successMessage || s.message);
398
709
  const hasSelected = s.isCourse ? !!s.scheduleId && s.courseAvailable : !!s.selectedSlotId;
399
710
  const selectedWaitlist = !s.isCourse && waitlistIds.has(s.selectedSlotId);
400
711
  const hasSuccess = !!s.successMessage;
@@ -406,7 +717,7 @@ function derive(s: S): S {
406
717
  _poll: undefined,
407
718
  servicePrice: regular ? regularServicePrice : "",
408
719
  // A course is booked whole, not from the 14-day window of times, so it has no window line.
409
- serviceLine: s.status === "ready" ? [s.serviceDuration, regular ? regularServicePrice : "", s.isCourse ? "" : copyParam("time.availabilityWindow", "Available in the next 14 days")].filter(Boolean).join(" · ") : s.serviceLine,
720
+ serviceLine: s.status === "ready" ? [s.serviceDuration, regular ? regularServicePrice : ""].filter(Boolean).join(" · ") : s.serviceLine,
410
721
  message: regular && !regularCheckoutAllowed && s.message === copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") ? "" : s.message,
411
722
  hasSlots,
412
723
  // A waitlist pick or a course is not an in-app purchase: neither enables the Apple button.
@@ -426,9 +737,21 @@ function derive(s: S): S {
426
737
  noWindowSlots: !hasSlots && !s.isCourse,
427
738
  // Only a quiet DAY in a window that has times gets the per-day line; a
428
739
  // window with no times at all is the screen-level empty state's job.
429
- dayEmptyText: hasDaySlots || !s.selectedDay || !hasSlots
740
+ // The window (not the location filter) decides: a location with only greyed times keeps the
741
+ // strip, the location row and "Check Next Availability".
742
+ dayEmptyText: !dayEmpty || !s.selectedDay
430
743
  ? ""
431
- : copyParam("time.dayEmptyText", "No times on this day. Try another day."),
744
+ : copyParam("time.dayEmptyText", "Nothing available for this date."),
745
+ dayEmpty: dayEmpty && !!s.selectedDay,
746
+ hasDayTimes: !(dayEmpty && !!s.selectedDay),
747
+ noUpcoming,
748
+ hasUpcoming: !noUpcoming,
749
+ resultText,
750
+ resultSticky: !!resultText && !!s.successMessage,
751
+ resultAuto: !!resultText && !s.successMessage,
752
+ resultIsBooking: !!s.successMessage,
753
+ resultNotBooking: !s.successMessage,
754
+ courseMessage: s.isCourse ? (s.successMessage || s.message) : "",
432
755
  hasSuccess,
433
756
  noSuccess: !hasSuccess,
434
757
  canBook,
@@ -437,8 +760,8 @@ function derive(s: S): S {
437
760
  hideCourseBook: !canBook || !s.isCourse,
438
761
  // Joining a waitlist buys nothing, so it does not wait on the purchase route.
439
762
  hideWaitlistBook: !s.isMember || !hasSelected || s.isCourse || !selectedWaitlist || s.bookingInFlight || hasSuccess,
440
- hideDayPicker: s.isCourse || !hasSlots,
441
- hideDaySlots: s.isCourse || !hasDaySlots,
763
+ hideDayPicker: s.isCourse || !timesShown || noUpcoming,
764
+ hideDaySlots: s.isCourse || s.groups.length === 0,
442
765
  hideDayEmpty: s.isCourse || hasDaySlots,
443
766
  showChoosePrompt,
444
767
  hideChoosePrompt: !showChoosePrompt,
@@ -575,12 +898,108 @@ async function resolveMsid(): Promise<string> {
575
898
  }
576
899
  }
577
900
 
901
+ /** Read `day`'s unbookable times once (cached). A failure shows the bookable times only. */
902
+ async function loadGreyDay(serviceId: string, day: string): Promise<void> {
903
+ if (!day || greyByDay.has(day)) return;
904
+ const from = new Date(`${day}T00:00:00`);
905
+ const until = new Date(from.getFullYear(), from.getMonth(), from.getDate() + 1);
906
+ if (Number.isNaN(from.getTime())) return;
907
+ try {
908
+ const res = await queryServiceAvailability(serviceId, isoDate(from), isoDate(until), { bookable: false });
909
+ greyByDay.set(day, greyRows(res.availabilityEntries ?? [], day, allRows, Date.now()));
910
+ } catch {
911
+ greyByDay.set(day, []);
912
+ }
913
+ }
914
+
915
+ /** The first two-week window from `from` with a time to book (at `locationId`, "" for any), up to
916
+ * 90 days after `now`: at most seven reads. `undefined` when there is none. */
917
+ async function nextWindowWithTimes(serviceId: string, from: Date, now: Date, includeFull: boolean, service: WixService | undefined, locationId: string): Promise<{ built: ReturnType<typeof slotRows>; windowStart: string } | undefined> {
918
+ const horizon = now.getTime() + HORIZON_DAYS * DAY_MS;
919
+ for (let start = new Date(from.getFullYear(), from.getMonth(), from.getDate()); start.getTime() < horizon;
920
+ start = new Date(start.getFullYear(), start.getMonth(), start.getDate() + WINDOW_DAYS)) {
921
+ // Calendar days, as the step above: across a DST change 14 × 24 h would end the window an hour
922
+ // short of where the next one starts.
923
+ const until = new Date(Math.min(new Date(start.getFullYear(), start.getMonth(), start.getDate() + WINDOW_DAYS).getTime(), horizon));
924
+ const res = await queryServiceAvailability(serviceId, isoDate(new Date(Math.max(start.getTime(), now.getTime()))), isoDate(until), { bookable: !includeFull });
925
+ const built = slotRows(res.availabilityEntries ?? [], service);
926
+ if (built.rows.some((row) => row.selectable && locationMatches(row, locationId))) return { built, windowStart: dayKey(start) };
927
+ }
928
+ return undefined;
929
+ }
930
+
931
+ /** After a booking went through (or its checkout opened, which already holds a booking), the time
932
+ * is no longer bookable here: it shows greyed and is no longer selected, so another tap on it cannot
933
+ * send a second booking or charge. */
934
+ function afterBooking(result: S, slotId: string): S {
935
+ const booked = !!result.successMessage || result.message === copyParam("time.checkoutOpened", "Complete your payment in the checkout to confirm this booking.");
936
+ if (!booked || !slotId || result.isCourse) return result;
937
+ allRows = allRows.map((row) => row.id === slotId ? { ...row, selectable: false, notSelectable: true } : row);
938
+ slotsById.delete(slotId);
939
+ waitlistIds.delete(slotId);
940
+ return derive({ ...result, selectedSlotId: "", selectedLine: "" });
941
+ }
942
+
943
+ /** The selection, kept only while its time is still in the window and at the chosen location: a
944
+ * hidden pick would leave the in-app purchase button buying a time the screen no longer shows. */
945
+ function visibleSelection(s: S): S {
946
+ const row = allRows.find((r) => r.id === s.selectedSlotId);
947
+ return row && row.selectable && locationMatches(row, s.locationId) ? s : { ...s, selectedSlotId: "", selectedLine: "" };
948
+ }
949
+
950
+ /** Counts day changes, so a slow read for an older day choice cannot move the screen back to it. */
951
+ let dayRevision = 0;
952
+ /** True while "Check Next Availability" searches; a second tap waits for the first. */
953
+ let checkingNextLock = false;
954
+
955
+ /** `{ slotId, signInScreenId }` from a pill tap (or a bare slot id). */
956
+ function tapped(arg: unknown): { slotId: string; signInScreenId: string } {
957
+ if (typeof arg === "string") return { slotId: arg, signInScreenId: "" };
958
+ const a = (arg ?? {}) as { slotId?: unknown; id?: unknown; signInScreenId?: unknown };
959
+ const slotId = typeof a.slotId === "string" ? a.slotId : typeof a.id === "string" ? a.id : "";
960
+ return { slotId, signInScreenId: typeof a.signInScreenId === "string" ? a.signInScreenId : "" };
961
+ }
962
+
963
+ /** Select a bookable time, as a tap does: the pick-storage rules and the selection revision. */
964
+ function selectSlotIn(s: S, slotId: string): S {
965
+ const selected = allRows.find((slot) => slot.id === slotId && slot.selectable);
966
+ if (selected) {
967
+ selectionRevision++;
968
+ pendingStart = "";
969
+ pendingSession = "";
970
+ if (s.isMember) clearPick();
971
+ else rememberPick(s.id, slotId);
972
+ }
973
+ return derive({
974
+ ...s,
975
+ selectedSlotId: selected ? slotId : s.selectedSlotId,
976
+ selectedLine: selected ? [selected.day, selected.title, selected.subtitle].filter(Boolean).join(" · ") : s.selectedLine,
977
+ message: "",
978
+ successMessage: "",
979
+ slots: s.slots.map((slot) => ({ ...slot, selected: slot.id === slotId, notSelected: slot.id !== slotId })),
980
+ });
981
+ }
982
+
578
983
  const id = inputId();
984
+ // A member's tap on a time books it, so pickSlot needs a connection too; a guest's tap only opens
985
+ // the sign-in screen (or the sign-in card), which works offline.
986
+ const defineService = withOnlineWrites(registerService, ["bookSelected", "buyApple", "signIn", "register", "pickSlot"], {
987
+ derive,
988
+ secretFields: ["password"],
989
+ requiresConnectivity: (action, state, arg) => action !== "pickSlot" || (state.isMember
990
+ && (regularCheckoutAllowed || applePurchase.usesRegularPlatform() || waitlistIds.has(tapped(arg).slotId))),
991
+ });
579
992
  const svc = defineService<S, {
580
993
  load: (s: S, arg?: unknown) => Promise<S>;
581
994
  refreshMember: (s: S, arg?: unknown) => Promise<S>;
582
- selectDay: (s: S, arg?: unknown) => S;
995
+ selectDay: (s: S, arg?: unknown) => Promise<S>;
583
996
  selectSlot: (s: S, arg?: unknown) => S;
997
+ pickSlot: (s: S, arg?: unknown) => Promise<S | undefined>;
998
+ openLocations: (s: S) => S;
999
+ closeLocations: (s: S) => S;
1000
+ selectLocation: (s: S, arg?: unknown) => Promise<S>;
1001
+ checkNext: (s: S) => Promise<S>;
1002
+ closeResult: (s: S) => S;
584
1003
  setEmail: (s: S, arg?: unknown) => S;
585
1004
  setPassword: (s: S, arg?: unknown) => S;
586
1005
  setFirstName: (s: S, arg?: unknown) => S;
@@ -667,6 +1086,28 @@ const svc = defineService<S, {
667
1086
  bookingInFlight: false,
668
1087
  notBooking: true,
669
1088
  bookingsScreenId: "himi_bookings",
1089
+ windowStart: "",
1090
+ locationId: "",
1091
+ locationLabel: "All locations",
1092
+ locationA11y: "",
1093
+ locationOptions: [],
1094
+ locationSheetOpen: false,
1095
+ hasLocationMenu: false,
1096
+ noLocationMenu: true,
1097
+ singleLocation: "",
1098
+ hasSingleLocation: false,
1099
+ noSingleLocation: true,
1100
+ groups: [],
1101
+ dayEmpty: false,
1102
+ hasDayTimes: true,
1103
+ noUpcoming: false,
1104
+ hasUpcoming: true,
1105
+ resultText: "",
1106
+ resultSticky: false,
1107
+ resultAuto: false,
1108
+ resultIsBooking: false,
1109
+ resultNotBooking: true,
1110
+ courseMessage: "",
670
1111
  }),
671
1112
  actions: {
672
1113
  iapPublish: (s) => derive(lastReady ?? s),
@@ -688,7 +1129,12 @@ const svc = defineService<S, {
688
1129
  try {
689
1130
  const now = new Date();
690
1131
  const beyond = availabilityBeyond(now, farStart);
691
- const serviceRes = await getService(s.id).catch(() => ({ service: undefined }));
1132
+ // Staff photos need STAFF_MEMBER_DETAILS; a token that may not read them still gets the service.
1133
+ const serviceRes = await getService(s.id, { conditionalFields: ["STAFF_MEMBER_DETAILS"] })
1134
+ .catch(() => getService(s.id))
1135
+ .catch(() => ({ service: undefined }));
1136
+ lastService = serviceRes.service;
1137
+ readStaffPhotos(serviceRes.service);
692
1138
  if (courseInput && !serviceRes.service) {
693
1139
  slotsById = new Map();
694
1140
  allRows = [];
@@ -739,54 +1185,28 @@ const svc = defineService<S, {
739
1185
  });
740
1186
  }
741
1187
  const includeFull = serviceRes.service?.type === "CLASS";
742
- const [availability, target] = await Promise.all([
743
- queryServiceAvailability(s.id, isoDate(now), isoDate(new Date(now.getTime() + WINDOW_DAYS * 86_400_000)), { bookable: !includeFull }),
1188
+ const [first, target] = await Promise.all([
1189
+ queryServiceAvailability(s.id, isoDate(now), isoDate(new Date(now.getTime() + WINDOW_DAYS * DAY_MS)), { bookable: !includeFull }),
744
1190
  // A host's start past the window (an Agenda session weeks out): its day alone, so it can
745
1191
  // be preselected. Its failure costs only that preselection.
746
1192
  beyond ? queryServiceAvailability(s.id, isoDate(beyond.from), isoDate(beyond.until), { bookable: !includeFull }).catch(() => ({ availabilityEntries: [] })) : undefined,
747
1193
  ]);
748
- slotsById = new Map();
749
- waitlistIds = new Set();
750
- const rows: SlotRow[] = [];
751
- // Group by LOCAL calendar day, not by the formatted heading: two
752
- // distinct days can render the same text, and a UTC-derived key can
753
- // name a different day than the heading beside it.
754
- const classService = serviceRes.service?.type === "CLASS";
755
- for (const [index, entry] of [...(availability.availabilityEntries ?? []), ...(target?.availabilityEntries ?? [])]
756
- .filter((candidate) => candidate.slot?.startDate && (candidate.bookable !== false || (classService && candidate.openSpots === 0)))
757
- .entries()) {
758
- const slot = entry.slot as WixBookingSlotFull;
759
- const key = dayKey(slot.startDate);
760
- if (!key) continue;
761
- const slotId = String(slot.sessionId ?? slot.startDate ?? `slot-${index}`);
762
- const openSpots = classService && typeof entry.openSpots === "number" && entry.openSpots >= 0 ? entry.openSpots : undefined;
763
- const waitlist = openSpots === 0 && serviceRes.service?.bookingPolicy?.waitlistPolicy?.enabled === true &&
764
- typeof entry.waitingList?.openSpots === "number" && entry.waitingList.openSpots > 0;
765
- const selectable = (entry.bookable !== false && openSpots !== 0) || waitlist;
766
- if (waitlist) waitlistIds.add(slotId);
767
- else if (selectable) slotsById.set(slotId, slot);
768
- const label = fmtRange(slot.startDate, slot.endDate);
769
- rows.push({
770
- id: slotId,
771
- dayKey: key,
772
- day: label.day,
773
- dayChip: dayLabel(slot.startDate, { format: "compact" }),
774
- title: label.title,
775
- subtitle: label.subtitle,
776
- badge: openSpots == null ? "" : openSpots > 0
777
- ? copyPlural("time.spotsLeft", "{n, plural, one {# spot left} other {# spots left}}", openSpots)
778
- : waitlist ? copyParam("time.waitlistBadge", "Waitlist") : copyParam("time.fullBadge", "Full"),
779
- noBadge: openSpots == null,
780
- badgeWarning: openSpots != null && openSpots > 0 && openSpots <= 3,
781
- badgeNeutral: openSpots == null || openSpots === 0 || openSpots > 3,
782
- selectable,
783
- notSelectable: !selectable,
784
- selected: false,
785
- notSelected: true,
786
- });
1194
+ let built = slotRows([...(first.availabilityEntries ?? []), ...(target?.availabilityEntries ?? [])], serviceRes.service);
1195
+ let windowStart = dayKey(now);
1196
+ // Nothing to book in the first two weeks (a class's full sessions do not count), and nothing
1197
+ // at a host's deep-linked start either: look further, two weeks at a time, up to 90 days. The
1198
+ // strip then starts at the first two weeks with a time. Nothing in 90 days → "no upcoming
1199
+ // sessions".
1200
+ if (!built.rows.some((row) => row.selectable)) {
1201
+ const later = await nextWindowWithTimes(s.id, new Date(now.getTime() + WINDOW_DAYS * DAY_MS), now, includeFull, serviceRes.service, "");
1202
+ if (later) { built = later.built; windowStart = later.windowStart; }
787
1203
  }
1204
+ slotsById = built.slots;
1205
+ waitlistIds = built.waitlist;
1206
+ const rows = built.rows;
788
1207
  allRows = rows;
789
- const days = daysOf(rows);
1208
+ greyByDay = new Map();
1209
+ const days = daysOf(rows, new Date(`${windowStart}T12:00:00`));
790
1210
  const header = serviceHeaderFields(serviceRes.service);
791
1211
  regularServicePrice = header.price;
792
1212
  regularCheckoutAllowed = await mayUseRegularBookingCheckout(serviceRes.service);
@@ -810,9 +1230,18 @@ const svc = defineService<S, {
810
1230
  // otherwise open on the first day that has any.
811
1231
  const keep = days.find((d) => d.id === chosen.selectedDay && d.hasSlots);
812
1232
  const initialDay = startRow?.dayKey ?? startDay?.id ?? picked?.dayKey ?? keep?.id ?? days.find((d) => d.hasSlots)?.id ?? days[0]?.id ?? "";
1233
+ if (!includeFull && initialDay) await loadGreyDay(s.id, initialDay);
1234
+ // A location with no times left in the window goes back to "All locations".
1235
+ // A location with no times left (greyed ones included) goes back to "All locations", and so
1236
+ // does a filter that would hide the time this load restores (a host's start, a guest's pick).
1237
+ const restored = startRow ?? picked;
1238
+ const locationId = windowLocations().some((l) => l.key === chosen.locationId) && (!restored || locationMatches(restored, chosen.locationId))
1239
+ ? chosen.locationId : "";
813
1240
  return derive(withDay({
814
1241
  ...next,
815
1242
  days,
1243
+ windowStart,
1244
+ locationId,
816
1245
  selectedSlotId: startRow ? startRow.id : selectionAlive ? chosen.selectedSlotId : picked?.id ?? "",
817
1246
  selectedLine: startRow ? [startRow.day, startRow.title, startRow.subtitle].filter(Boolean).join(" · ")
818
1247
  : selectionAlive ? chosen.selectedLine
@@ -820,14 +1249,17 @@ const svc = defineService<S, {
820
1249
  status: "ready",
821
1250
  errored: false,
822
1251
  serviceName: serviceRes.service?.name || copyParam("time.serviceNameFallback", "Choose a time"),
823
- serviceLine: [header.duration, regularCheckoutAllowed ? header.price : "", copyParam("time.availabilityWindow", "Available in the next 14 days")].filter(Boolean).join(" · "),
1252
+ serviceLine: [header.duration, regularCheckoutAllowed ? header.price : ""].filter(Boolean).join(" · "),
824
1253
  serviceImage: header.image,
825
1254
  hasServiceImage: !!header.image,
826
1255
  noServiceImage: !header.image,
827
1256
  servicePrice: regularCheckoutAllowed ? header.price : "",
828
1257
  serviceDuration: header.duration,
829
1258
  serviceIsPaid: header.paid,
830
- message: regularCheckoutAllowed || !applePurchase.fields(true, false).iapBuyHidden ? "" : copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable."),
1259
+ // A guest's pick restored after sign-in stays picked: the member taps it to book. The
1260
+ // screen never books from a load (two loads can run at once right after a sign-in).
1261
+ // Where nothing can be bought here, a tap says so (pickSlot), not the screen's opening.
1262
+ message: picked ? copyParam("time.signedInTapAgain", "Signed in. Tap the time again to book it.") : "",
831
1263
  }, initialDay));
832
1264
  } catch {
833
1265
  slotsById = new Map();
@@ -840,35 +1272,74 @@ const svc = defineService<S, {
840
1272
  // selection; the strip is single-select with no "none", so the day stays.
841
1273
  // A time picked on another day stays selected (the selected line and the
842
1274
  // Book button keep naming it): switching days to compare is not a reset.
843
- selectDay: (s, arg) => {
1275
+ selectDay: async (s, arg): Promise<S> => {
844
1276
  const day = chosenDay(arg);
845
1277
  if (!day || !s.days.some((d) => d.id === day)) return derive(s);
846
1278
  pendingStart = "";
847
1279
  pendingSession = "";
848
- return derive(withDay(s, day));
1280
+ const revision = ++dayRevision;
1281
+ if (lastService?.type !== "CLASS") await loadGreyDay(s.id, day);
1282
+ const current = svc.getState?.() ?? s;
1283
+ return derive(revision === dayRevision ? withDay(current, day) : current);
849
1284
  },
850
1285
  refreshMember: (s, arg) => { stashCopy(arg); return probeMember(s); },
851
- selectSlot: (s, arg) => {
852
- const slotId = typeof arg === "string"
853
- ? arg
854
- : ((arg as { id?: string; slotId?: string })?.slotId ?? (arg as { id?: string })?.id ?? "");
855
- const selected = allRows.find((slot) => slot.id === slotId && slot.selectable);
856
- if (selected) {
857
- selectionRevision++;
858
- pendingStart = "";
859
- pendingSession = "";
860
- if (s.isMember) clearPick();
861
- else rememberPick(s.id, slotId);
1286
+ selectSlot: (s, arg) => selectSlotIn(s, tapped(arg).slotId),
1287
+ // One tap books: the tap does what "Book selected time" did (select, then book). A guest keeps
1288
+ // the pick and signs in first; after the sign-in the time comes back picked, and the member
1289
+ // taps it again. Where the regular checkout is not allowed, the tap selects the time for the
1290
+ // in-app purchase button.
1291
+ pickSlot: async (s, arg): Promise<S | undefined> => {
1292
+ const { slotId, signInScreenId } = tapped(arg);
1293
+ if (bookingLock || s.bookingInFlight || !allRows.some((row) => row.id === slotId && row.selectable)) return derive(s);
1294
+ const selected = selectSlotIn(s, slotId);
1295
+ if (!s.isMember) {
1296
+ // Branded Lite opens its sign-in screen; other apps show the sign-in card on this screen.
1297
+ return signInScreenId ? navigate(selected, { screenId: signInScreenId, args: { mode: "signin", intent: "book" } }) : selected;
1298
+ }
1299
+ const regular = regularCheckoutAllowed || applePurchase.usesRegularPlatform();
1300
+ if (!regular && !waitlistIds.has(slotId)) {
1301
+ // The in-app purchase button takes over for the picked time; with none, say why not.
1302
+ return applePurchase.fields(true, false).iapBuyHidden
1303
+ ? derive({ ...selected, message: copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") })
1304
+ : selected;
1305
+ }
1306
+ bookingLock = true;
1307
+ try {
1308
+ return afterBooking(await bookSelectedIn(selected), slotId);
1309
+ } finally {
1310
+ bookingLock = false;
862
1311
  }
863
- return derive({
864
- ...s,
865
- selectedSlotId: selected ? slotId : s.selectedSlotId,
866
- selectedLine: selected ? [selected.day, selected.title, selected.subtitle].filter(Boolean).join(" · ") : s.selectedLine,
867
- message: "",
868
- successMessage: "",
869
- slots: s.slots.map((slot) => ({ ...slot, selected: slot.id === slotId, notSelected: slot.id !== slotId })),
870
- });
871
1312
  },
1313
+ openLocations: (s) => derive({ ...s, locationSheetOpen: true }),
1314
+ closeLocations: (s) => derive({ ...s, locationSheetOpen: false }),
1315
+ selectLocation: async (s, arg): Promise<S> => {
1316
+ const raw = (arg ?? {}) as { locationId?: unknown; id?: unknown };
1317
+ const wanted = typeof arg === "string" ? arg : typeof raw.locationId === "string" ? raw.locationId : typeof raw.id === "string" ? raw.id : "";
1318
+ const locationId = windowLocations().some((l) => l.key === wanted) ? wanted : "";
1319
+ const next = visibleSelection({ ...s, locationId, locationSheetOpen: false });
1320
+ // Stay on the day when it has a time at this location; else go to the first day that has one.
1321
+ const keep = allRows.some((row) => row.dayKey === s.selectedDay && row.selectable && locationMatches(row, locationId));
1322
+ const first = allRows.filter((row) => row.selectable && locationMatches(row, locationId)).sort(byDay)[0];
1323
+ const revision = ++dayRevision;
1324
+ if (keep || !first) return derive(next);
1325
+ if (lastService?.type !== "CLASS") await loadGreyDay(s.id, first.dayKey);
1326
+ // A newer day or location choice made during the read wins.
1327
+ if (revision !== dayRevision) return derive(svc.getState?.() ?? next);
1328
+ const current = svc.getState?.() ?? next;
1329
+ return derive(withDay(visibleSelection({ ...current, locationId, locationSheetOpen: false }), first.dayKey));
1330
+ },
1331
+ // The next day after the selected one with a time (at the chosen location): in the loaded
1332
+ // window first, then forward up to 90 days, moving the strip.
1333
+ checkNext: async (s): Promise<S> => {
1334
+ if (checkingNextLock) return derive(s);
1335
+ checkingNextLock = true;
1336
+ try {
1337
+ return await checkNextIn(s);
1338
+ } finally {
1339
+ checkingNextLock = false;
1340
+ }
1341
+ },
1342
+ closeResult: (s) => derive({ ...s, message: "", successMessage: "" }),
872
1343
  setEmail: (s, arg) => derive({ ...s, email: strOf(arg), message: "" }),
873
1344
  setPassword: (s, arg) => derive({ ...s, password: strOf(arg), message: "" }),
874
1345
  setFirstName: (s, arg) => derive({ ...s, firstName: strOf(arg), message: "" }),
@@ -889,7 +1360,7 @@ const svc = defineService<S, {
889
1360
  isRegistering: false,
890
1361
  message: s.isCourse
891
1362
  ? copyParam("time.courseSignedInReady", "Signed in. You can book this course now.")
892
- : copyParam("time.signedInReady", "Signed in. You can book this time now."),
1363
+ : copyParam("time.signedInTapAgain", "Signed in. Tap the time again to book it."),
893
1364
  });
894
1365
  } catch {
895
1366
  return derive({ ...s, password: "", message: copyParam("time.signInFailed", "Sign in failed. Try again.") });
@@ -914,225 +1385,273 @@ const svc = defineService<S, {
914
1385
  isRegistering: false,
915
1386
  message: s.isCourse
916
1387
  ? copyParam("time.courseRegisterReady", "Account created. You can book this course now.")
917
- : copyParam("time.registerReady", "Account created. You can book this time now."),
1388
+ : copyParam("time.signedInTapAgain", "Signed in. Tap the time again to book it."),
918
1389
  });
919
1390
  } catch {
920
1391
  return derive({ ...s, password: "", message: copyParam("time.registerFailed", "Account creation failed. Try another email.") });
921
1392
  }
922
1393
  },
1394
+ // Kept for the course button and the offline replay (WriteQueue replays this action).
923
1395
  bookSelected: async (s) => {
924
- if (!s.isMember) return derive({ ...s, message: copyParam("time.bookNeedsSignIn", "Sign in before booking.") });
925
- if (s.isCourse && !s.scheduleId) return derive({ ...s, message: copyParam("time.courseUnavailable", "Course booking is unavailable right now.") });
926
- if (!s.isCourse && !s.selectedSlotId) return derive({ ...s, message: copyParam("time.bookNeedsSlot", "Choose one time first.") });
927
- const joiningWaitlist = !s.isCourse && waitlistIds.has(s.selectedSlotId);
928
- const slot = s.isCourse ? undefined : slotsById.get(s.selectedSlotId);
929
- if (!s.isCourse && !slot && !joiningWaitlist) return derive({ ...s, message: copyParam("time.bookSlotGone", "That time is no longer available. Try again.") });
930
- // A booking is paid through the regular checkout only where that is allowed; joining a
931
- // waitlist buys nothing, so it skips this check.
932
- if (!joiningWaitlist) {
933
- try {
934
- const current = await getService(s.id);
935
- // A course is booked whole, never one of its sessions as a time: a screen opened with no
936
- // `course` input whose first service read failed lists the sessions as times.
937
- if (!s.isCourse && current.service?.type === "COURSE") return derive({ ...s, message: copyParam("time.courseUnavailable", "Course booking is unavailable right now.") });
938
- regularCheckoutAllowed = await mayUseRegularBookingCheckout(current.service);
939
- if (serviceHeaderFields(current.service).paid !== s.serviceIsPaid) return derive({ ...s, message: copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
940
- } catch {
941
- if (s.serviceIsPaid) return derive({ ...s, message: copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
942
- }
943
- if (!regularCheckoutAllowed) return derive({ ...s, message: copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
1396
+ if (bookingLock) return derive(s);
1397
+ bookingLock = true;
1398
+ try {
1399
+ return afterBooking(await bookSelectedIn(s), s.selectedSlotId);
1400
+ } finally {
1401
+ bookingLock = false;
944
1402
  }
945
- // A confirmed booking is reported to the app hosting this screen (an embed's `onAction`), so a
946
- // host chat can say so and refresh what it shows. Dropped where nothing hosts the screen.
947
- // The booking's own instants carry an offset; a slot's may be LOCAL to its timezone, which
948
- // `emitHostEvent` leaves out rather than let the host read it in its own zone.
949
- const reportBooked = (booking: { id?: string; startDate?: string; endDate?: string } | undefined) => emitHostEvent("booking.created", {
950
- bookingId: booking?.id,
951
- serviceId: s.id,
952
- serviceName: s.serviceName,
953
- start: hostEventInstant(booking?.startDate) || slot?.startDate || s.courseStart,
954
- end: hostEventInstant(booking?.endDate) || slot?.endDate || s.courseEnd,
955
- });
956
- // The member acts on the pick now; a later visit must not restore it.
957
- clearPick();
958
- if (joiningWaitlist) {
959
- const pending = derive({ ...s, bookingInFlight: true, message: "", successMessage: "" });
960
- const my = await getMyData().catch(() => ({ email: s.email } as WixMyData));
961
- const email = (await signedInEmail()) || my.email || my.contactEmail || s.email;
962
- const name = contactName(displayName(my) || email);
963
- try {
964
- await registerToWaitlist(s.selectedSlotId, { email, firstName: name.first, lastName: name.last });
965
- return derive({ ...pending, bookingInFlight: false,
966
- successMessage: copyParam("time.waitlistJoined", "You're on the waitlist. Check My Bookings for updates."), message: "" });
967
- } catch {
968
- return derive({ ...s, bookingInFlight: false,
969
- message: copyParam("time.waitlistFailed", "Couldn't join the waitlist. Try again.") });
1403
+ },
1404
+ },
1405
+ });
1406
+
1407
+ /** "Check Next Availability": the next day after the selected one with a time (at the chosen
1408
+ * location), in the loaded window first, then forward up to 90 days, moving the strip. */
1409
+ async function checkNextIn(s: S): Promise<S> {
1410
+ const next = allRows.filter((row) => row.selectable && row.dayKey > s.selectedDay && locationMatches(row, s.locationId))
1411
+ .sort(byDay)[0];
1412
+ if (next) {
1413
+ const revision = ++dayRevision;
1414
+ if (lastService?.type !== "CLASS") await loadGreyDay(s.id, next.dayKey);
1415
+ const current = svc.getState?.() ?? s;
1416
+ return derive(revision === dayRevision ? withDay(current, next.dayKey) : current);
1417
+ }
1418
+ const revision = ++dayRevision;
1419
+ const after = new Date(`${s.selectedDay || s.windowStart}T12:00:00`);
1420
+ const from = new Date(after.getFullYear(), after.getMonth(), after.getDate() + 1);
1421
+ const includeFull = lastService?.type === "CLASS";
1422
+ let later: Awaited<ReturnType<typeof nextWindowWithTimes>>;
1423
+ try {
1424
+ later = Number.isNaN(from.getTime()) ? undefined : await nextWindowWithTimes(s.id, from, new Date(), includeFull, lastService, s.locationId);
1425
+ } catch {
1426
+ return derive({ ...(svc.getState?.() ?? s), message: copyParam("time.errorText", "Times could not be loaded.") });
1427
+ }
1428
+ const current = svc.getState?.() ?? s;
1429
+ // A day or location chosen while the search ran wins: its result is dropped.
1430
+ if (revision !== dayRevision) return derive(current);
1431
+ if (!later) return derive({ ...current, message: copyParam("time.noLaterTimes", "There are no later times in the next 90 days.") });
1432
+ allRows = later.built.rows;
1433
+ slotsById = later.built.slots;
1434
+ waitlistIds = later.built.waitlist;
1435
+ greyByDay = new Map();
1436
+ const day = later.built.rows.filter((row) => row.selectable && locationMatches(row, s.locationId))
1437
+ .sort(byDay)[0]!.dayKey;
1438
+ if (!includeFull) await loadGreyDay(s.id, day);
1439
+ return derive(withDay(visibleSelection({ ...current, windowStart: later.windowStart }), day));
1440
+ }
1441
+
1442
+ /** The booking itself: a waitlist, a paid or course checkout, or a free booking. */
1443
+ async function bookSelectedIn(s: S): Promise<S> {
1444
+ if (!s.isMember) return derive({ ...s, message: copyParam("time.bookNeedsSignIn", "Sign in before booking.") });
1445
+ if (s.isCourse && !s.scheduleId) return derive({ ...s, message: copyParam("time.courseUnavailable", "Course booking is unavailable right now.") });
1446
+ if (!s.isCourse && !s.selectedSlotId) return derive({ ...s, message: copyParam("time.bookNeedsSlot", "Choose one time first.") });
1447
+ const joiningWaitlist = !s.isCourse && waitlistIds.has(s.selectedSlotId);
1448
+ const slot = s.isCourse ? undefined : slotsById.get(s.selectedSlotId);
1449
+ if (!s.isCourse && !slot && !joiningWaitlist) return derive({ ...s, message: copyParam("time.bookSlotGone", "That time is no longer available. Try again.") });
1450
+ // A booking is paid through the regular checkout only where that is allowed; joining a
1451
+ // waitlist buys nothing, so it skips this check.
1452
+ if (!joiningWaitlist) {
1453
+ try {
1454
+ const current = await getService(s.id);
1455
+ // A course is booked whole, never one of its sessions as a time: a screen opened with no
1456
+ // `course` input whose first service read failed lists the sessions as times.
1457
+ if (!s.isCourse && current.service?.type === "COURSE") return derive({ ...s, message: copyParam("time.courseUnavailable", "Course booking is unavailable right now.") });
1458
+ regularCheckoutAllowed = await mayUseRegularBookingCheckout(current.service);
1459
+ if (serviceHeaderFields(current.service).paid !== s.serviceIsPaid) return derive({ ...s, message: copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
1460
+ } catch {
1461
+ if (s.serviceIsPaid) return derive({ ...s, message: copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
1462
+ }
1463
+ if (!regularCheckoutAllowed) return derive({ ...s, message: copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
1464
+ }
1465
+ // A confirmed booking is reported to the app hosting this screen (an embed's `onAction`), so a
1466
+ // host chat can say so and refresh what it shows. Dropped where nothing hosts the screen.
1467
+ // The booking's own instants carry an offset; a slot's may be LOCAL to its timezone, which
1468
+ // `emitHostEvent` leaves out rather than let the host read it in its own zone.
1469
+ const reportBooked = (booking: { id?: string; startDate?: string; endDate?: string } | undefined) => emitHostEvent("booking.created", {
1470
+ bookingId: booking?.id,
1471
+ serviceId: s.id,
1472
+ serviceName: s.serviceName,
1473
+ start: hostEventInstant(booking?.startDate) || slot?.startDate || s.courseStart,
1474
+ end: hostEventInstant(booking?.endDate) || slot?.endDate || s.courseEnd,
1475
+ });
1476
+ // The member acts on the pick now; a later visit must not restore it.
1477
+ clearPick();
1478
+ if (joiningWaitlist) {
1479
+ const pending = derive({ ...s, bookingInFlight: true, message: "", successMessage: "" });
1480
+ const my = await getMyData().catch(() => ({ email: s.email } as WixMyData));
1481
+ const email = (await signedInEmail()) || my.email || my.contactEmail || s.email;
1482
+ const name = contactName(displayName(my) || email);
1483
+ try {
1484
+ await registerToWaitlist(s.selectedSlotId, { email, firstName: name.first, lastName: name.last });
1485
+ return derive({ ...pending, bookingInFlight: false,
1486
+ successMessage: copyParam("time.waitlistJoined", "You're on the waitlist. Check My Bookings for updates."), message: "" });
1487
+ } catch {
1488
+ return derive({ ...s, bookingInFlight: false,
1489
+ message: copyParam("time.waitlistFailed", "Couldn't join the waitlist. Try again.") });
1490
+ }
1491
+ }
1492
+ // Paid service or any course — create a Wix eCommerce order so a free
1493
+ // course does not remain in CREATED status with no reserved capacity.
1494
+ // FULLY NATIVE in-app payment (mirrors Wix's RN apps): NO
1495
+ // hosted-checkout WebView / browser redirect. The chain is
1496
+ // createBooking → ecom.createCheckout(booking line item) → ecom.createOrder
1497
+ // → @himalaya/wix-cashier.proceedToPayment(snapshotId)
1498
+ // where `paymentGatewayOrderId` from createOrder is the cashier order id
1499
+ // (snapshotId) settled on-device via Apple/Google Pay. We do NOT optimistically
1500
+ // write a local booking row (My Bookings revalidates from Wix on success). Any
1501
+ // failure degrades to an inline retry message, never a dead button.
1502
+ if (s.serviceIsPaid || s.isCourse) {
1503
+ const pending = derive({ ...s, bookingInFlight: true, message: "", successMessage: "" });
1504
+ // Booker identity — same resolution as the free path (never throws).
1505
+ const payerData = await getMyData().catch(() => ({ email: s.email } as WixMyData));
1506
+ const payerEmail = (await signedInEmail()) || payerData.email || payerData.contactEmail || s.email;
1507
+ const payerName = contactName(displayName(payerData) || payerEmail);
1508
+ // Only the same customer's own unfinished booking for this course is reused.
1509
+ const retried = s.isCourse && pendingCourseBooking?.scheduleId === s.scheduleId && pendingCourseBooking.email === payerEmail
1510
+ ? pendingCourseBooking : undefined;
1511
+ if (s.isCourse) {
1512
+ try {
1513
+ const latest = (await getService(s.id)).service;
1514
+ const open = latest != null && await courseOpenTo(latest, new Date(), retried?.ordered === true);
1515
+ if (!open) {
1516
+ return derive({ ...pending, bookingInFlight: false, courseAvailable: false,
1517
+ message: copyParam("time.courseUnavailable", "Course booking is unavailable right now.") });
970
1518
  }
1519
+ } catch {
1520
+ return derive({ ...pending, bookingInFlight: false,
1521
+ message: copyParam("time.courseUnavailable", "Course booking is unavailable right now.") });
971
1522
  }
972
- // Paid service or any course — create a Wix eCommerce order so a free
973
- // course does not remain in CREATED status with no reserved capacity.
974
- // FULLY NATIVE in-app payment (mirrors Wix's RN apps): NO
975
- // hosted-checkout WebView / browser redirect. The chain is
976
- // createBooking → ecom.createCheckout(booking line item) → ecom.createOrder
977
- // → @himalaya/wix-cashier.proceedToPayment(snapshotId)
978
- // where `paymentGatewayOrderId` from createOrder is the cashier order id
979
- // (snapshotId) settled on-device via Apple/Google Pay. We do NOT optimistically
980
- // write a local booking row (My Bookings revalidates from Wix on success). Any
981
- // failure degrades to an inline retry message, never a dead button.
982
- if (s.serviceIsPaid || s.isCourse) {
983
- const pending = derive({ ...s, bookingInFlight: true, message: "", successMessage: "" });
984
- // Booker identity — same resolution as the free path (never throws).
985
- const payerData = await getMyData().catch(() => ({ email: s.email } as WixMyData));
986
- const payerEmail = (await signedInEmail()) || payerData.email || payerData.contactEmail || s.email;
987
- const payerName = contactName(displayName(payerData) || payerEmail);
988
- // Only the same customer's own unfinished booking for this course is reused.
989
- const retried = s.isCourse && pendingCourseBooking?.scheduleId === s.scheduleId && pendingCourseBooking.email === payerEmail
990
- ? pendingCourseBooking : undefined;
991
- if (s.isCourse) {
992
- try {
993
- const latest = (await getService(s.id)).service;
994
- const open = latest != null && await courseOpenTo(latest, new Date(), retried?.ordered === true);
995
- if (!open) {
996
- return derive({ ...pending, bookingInFlight: false, courseAvailable: false,
997
- message: copyParam("time.courseUnavailable", "Course booking is unavailable right now.") });
998
- }
999
- } catch {
1000
- return derive({ ...pending, bookingInFlight: false,
1001
- message: copyParam("time.courseUnavailable", "Course booking is unavailable right now.") });
1002
- }
1003
- }
1004
- try {
1005
- // 1. Create the booking (CREATED status — payment pending).
1006
- // A course retried after a failed checkout reuses the booking it already made.
1007
- const created = retried ? { booking: retried.booking }
1008
- : s.isCourse
1009
- ? await bookSchedule(s.scheduleId, payerName.first, payerName.last, payerEmail,
1010
- s.coursePaymentOption ? { selectedPaymentOption: s.coursePaymentOption } : undefined)
1011
- : await bookSlot(slot!, payerName.first, payerName.last, payerEmail);
1012
- const bookingId = created.booking?.id;
1013
- if (!bookingId) throw new Error("booking not created");
1014
- if (s.isCourse && !retried) pendingCourseBooking = { scheduleId: s.scheduleId, email: payerEmail, booking: created.booking! };
1015
- // 2. Wrap the booking as a checkout line item; create the checkout.
1016
- const checkoutRes = await createCheckout([
1017
- { quantity: 1, catalogReference: { appId: WIX_BOOKINGS_APP_ID, catalogItemId: bookingId } },
1018
- ]);
1019
- const checkoutId = checkoutRes.checkout?.id;
1020
- if (!checkoutId) throw new Error("checkout not created");
1021
- // 3. Create the Wix-managed order — mints the cashier snapshot when a
1022
- // balance is due. No paymentGatewayOrderId ⇒ nothing to charge
1023
- // (covered / zero total) ⇒ the booking is already confirmed.
1024
- // Hosted (Spaces): the site's own checkout page creates the order (as
1025
- // RN does) — creating it here first fails on a site with no payment method set up.
1026
- const hosted = isHostedWorker();
1027
- const orderRes = hosted ? { paymentGatewayOrderId: "" } : await createOrder(checkoutId);
1028
- if (s.isCourse && !hosted && pendingCourseBooking) pendingCourseBooking.ordered = true;
1029
- const snapshotId = orderRes.paymentGatewayOrderId ?? "";
1030
- if ((!snapshotId || (s.isCourse && s.coursePaymentOption === "OFFLINE")) && !hosted) {
1031
- pendingCourseBooking = null;
1032
- await reportBooked(created.booking);
1033
- return derive({ ...pending, bookingInFlight: false, successMessage: s.isCourse
1034
- ? copyParam("time.courseSubmitted", "Course booking submitted. Check My Bookings for confirmation.")
1035
- : copyParam("time.successMessage", "Booked. Your confirmation is ready in My Bookings."), message: "" });
1036
- }
1037
- // 4. Collect payment IN-APP — wallet when this app has one wired, otherwise
1038
- // the in-app Wix cashier. Routed through the shared helper so a
1039
- // wallet-less app can still actually pay: calling proceedToPayment
1040
- // unconditionally created the booking + order and THEN failed, leaving an
1041
- // unpaid booking behind (see @wix/himalaya/checkout).
1042
- const pay = await payCashierOrder({
1043
- appId: WIX_BOOKINGS_APP_ID,
1044
- snapshotId,
1045
- msid: await resolveMsid(),
1046
- // Hosted (Spaces): the host opens this booking's checkout as the lent member.
1047
- ...(hosted ? { openCashier: hostedCheckoutOpener(checkoutId) } : {}),
1048
- cashierUrl: async () => {
1049
- const redirect = await createRedirectSession(s.isCourse
1050
- ? { ecomCheckout: { checkoutId } }
1051
- : { bookingsCheckout: { slotAvailability: { slot: slot as WixSlot } } });
1052
- return redirect.redirectSession?.fullUrl;
1053
- },
1054
- });
1055
- if (pay.kind === "paid") {
1056
- pendingCourseBooking = null;
1057
- await reportBooked(created.booking);
1058
- return derive({ ...pending, bookingInFlight: false, successMessage: copyParam("time.successMessage", "Booked. Your confirmation is ready in My Bookings."), message: "" });
1059
- }
1060
- if (pay.kind === "handoff") {
1061
- // The cashier is open in-app; the booking confirms once payment lands, so
1062
- // promise nothing here — My Bookings revalidates from Wix.
1063
- return derive({ ...pending, bookingInFlight: false, message: copyParam("time.checkoutOpened", "Complete your payment in the checkout to confirm this booking.") });
1064
- }
1065
- if (pay.kind === "cancelled") {
1066
- return derive({ ...s, bookingInFlight: false, message: s.isCourse
1067
- ? copyParam("time.coursePaymentCancelled", "Payment cancelled — your course booking wasn't confirmed. Try again.")
1068
- : copyParam("time.paymentCancelled", "Payment cancelled — your booking wasn't confirmed. Pick a time to try again.") });
1069
- }
1070
- return derive({ ...s, bookingInFlight: false, message: s.isCourse
1071
- ? copyParam("time.coursePaymentFailed", "Payment couldn't be completed. Try again.")
1072
- : copyParam("time.paymentFailed", "Payment couldn't be completed. Pick another time or try again.") });
1073
- } catch {
1074
- return derive({ ...s, bookingInFlight: false, message: s.isCourse
1075
- ? copyParam("time.courseCheckoutFailed", "Checkout could not start. Try again.")
1076
- : copyParam("time.checkoutFailed", "Checkout could not start. Pick another time or try again.") });
1077
- }
1523
+ }
1524
+ try {
1525
+ // 1. Create the booking (CREATED status — payment pending).
1526
+ // A course retried after a failed checkout reuses the booking it already made.
1527
+ const created = retried ? { booking: retried.booking }
1528
+ : s.isCourse
1529
+ ? await bookSchedule(s.scheduleId, payerName.first, payerName.last, payerEmail,
1530
+ s.coursePaymentOption ? { selectedPaymentOption: s.coursePaymentOption } : undefined)
1531
+ : await bookSlot(slot!, payerName.first, payerName.last, payerEmail);
1532
+ const bookingId = created.booking?.id;
1533
+ if (!bookingId) throw new Error("booking not created");
1534
+ if (s.isCourse && !retried) pendingCourseBooking = { scheduleId: s.scheduleId, email: payerEmail, booking: created.booking! };
1535
+ // 2. Wrap the booking as a checkout line item; create the checkout.
1536
+ const checkoutRes = await createCheckout([
1537
+ { quantity: 1, catalogReference: { appId: WIX_BOOKINGS_APP_ID, catalogItemId: bookingId } },
1538
+ ]);
1539
+ const checkoutId = checkoutRes.checkout?.id;
1540
+ if (!checkoutId) throw new Error("checkout not created");
1541
+ // 3. Create the Wix-managed order — mints the cashier snapshot when a
1542
+ // balance is due. No paymentGatewayOrderId ⇒ nothing to charge
1543
+ // (covered / zero total) ⇒ the booking is already confirmed.
1544
+ // Hosted (Spaces): the site's own checkout page creates the order (as
1545
+ // RN does) — creating it here first fails on a site with no payment method set up.
1546
+ const hosted = isHostedWorker();
1547
+ const orderRes = hosted ? { paymentGatewayOrderId: "" } : await createOrder(checkoutId);
1548
+ if (s.isCourse && !hosted && pendingCourseBooking) pendingCourseBooking.ordered = true;
1549
+ const snapshotId = orderRes.paymentGatewayOrderId ?? "";
1550
+ if ((!snapshotId || (s.isCourse && s.coursePaymentOption === "OFFLINE")) && !hosted) {
1551
+ pendingCourseBooking = null;
1552
+ await reportBooked(created.booking);
1553
+ return derive({ ...pending, bookingInFlight: false, successMessage: s.isCourse
1554
+ ? copyParam("time.courseSubmitted", "Course booking submitted. Check My Bookings for confirmation.")
1555
+ : copyParam("time.successMessage", "Booked. Your confirmation is ready in My Bookings."), message: "" });
1078
1556
  }
1079
- // FREE appointment/class — the original member bookSlot rung (offline-safe below).
1080
- // getMyData/signedInEmail carry their own fallbacks (never throw), so the
1081
- // booker identity is resolved up front — needed for both the optimistic
1082
- // cache row and the offline replay params.
1083
- const my = await getMyData().catch(() => ({ email: s.email } as WixMyData));
1084
- const email = (await signedInEmail()) || my.email || my.contactEmail || s.email;
1085
- const name = contactName(displayName(my) || email);
1086
- // Optimistic local write — My Bookings repaints from cache immediately (offline or not).
1087
- const selectedSlot = allRows.find((r) => r.id === s.selectedSlotId);
1088
- const localId = `local-${Date.now()}`;
1089
- const localRow = (id: string): DbEntity => ({
1090
- id,
1091
- title: s.serviceName || "Booking",
1092
- subtitle: s.isCourse ? (s.selectedLine || "Full course") : (selectedSlot?.title || "Booked time"),
1093
- status: "Booked",
1557
+ // 4. Collect payment IN-APP — wallet when this app has one wired, otherwise
1558
+ // the in-app Wix cashier. Routed through the shared helper so a
1559
+ // wallet-less app can still actually pay: calling proceedToPayment
1560
+ // unconditionally created the booking + order and THEN failed, leaving an
1561
+ // unpaid booking behind (see @wix/himalaya/checkout).
1562
+ const pay = await payCashierOrder({
1563
+ appId: WIX_BOOKINGS_APP_ID,
1564
+ snapshotId,
1565
+ msid: await resolveMsid(),
1566
+ // Hosted (Spaces): the host opens this booking's checkout as the lent member.
1567
+ ...(hosted ? { openCashier: hostedCheckoutOpener(checkoutId) } : {}),
1568
+ cashierUrl: async () => {
1569
+ const redirect = await createRedirectSession(s.isCourse
1570
+ ? { ecomCheckout: { checkoutId } }
1571
+ : { bookingsCheckout: { slotAvailability: { slot: slot as WixSlot } } });
1572
+ return redirect.redirectSession?.fullUrl;
1573
+ },
1094
1574
  });
1095
- await cachePut(localRow(localId));
1096
- const pending = derive({ ...s, bookingInFlight: true, message: "", successMessage: "" });
1097
- try {
1098
- const created = await bookSlot(slot!, name.first, name.last, email);
1099
- // Reconcile the optimistic local id with the server-assigned booking id.
1100
- const realId = created.booking?.id;
1101
- if (realId && realId !== localId) { await cacheDrop(localId); await cachePut(localRow(realId)); }
1575
+ if (pay.kind === "paid") {
1576
+ pendingCourseBooking = null;
1102
1577
  await reportBooked(created.booking);
1103
- return derive({
1104
- ...pending,
1105
- bookingInFlight: false,
1106
- successMessage: copyParam("time.successMessage", "Booked. Your confirmation is ready in My Bookings."),
1107
- message: "",
1108
- });
1109
- } catch (e) {
1110
- if (isBridgeUnavailable(e)) {
1111
- // Offline / no bridge: the optimistic cache row already shows in My
1112
- // Bookings; queue the booking to replay (WriteQueue, idempotent) when
1113
- // connectivity returns.
1114
- await queueWrite("tier5.himiBookingTime.bookSelected", {
1115
- serviceId: s.id,
1116
- ...(s.isCourse ? { scheduleId: s.scheduleId } : { slotId: s.selectedSlotId }),
1117
- email,
1118
- firstName: name.first,
1119
- lastName: name.last,
1120
- ...(slot ? { slot: JSON.stringify(slot) } : {}),
1121
- });
1122
- return derive({
1123
- ...pending,
1124
- bookingInFlight: false,
1125
- successMessage: "Booked offline — will sync when you reconnect.",
1126
- message: "",
1127
- });
1128
- }
1578
+ return derive({ ...pending, bookingInFlight: false, successMessage: copyParam("time.successMessage", "Booked. Your confirmation is ready in My Bookings."), message: "" });
1579
+ }
1580
+ if (pay.kind === "handoff") {
1581
+ // The cashier is open in-app; the booking confirms once payment lands, so
1582
+ // promise nothing here — My Bookings revalidates from Wix.
1583
+ return derive({ ...pending, bookingInFlight: false, message: copyParam("time.checkoutOpened", "Complete your payment in the checkout to confirm this booking.") });
1584
+ }
1585
+ if (pay.kind === "cancelled") {
1129
1586
  return derive({ ...s, bookingInFlight: false, message: s.isCourse
1130
- ? copyParam("time.courseBookFailed", "Course booking failed. Try again.")
1131
- : copyParam("time.bookFailed", "Booking failed. Pick another time or try again.") });
1587
+ ? copyParam("time.coursePaymentCancelled", "Payment cancelled — your course booking wasn't confirmed. Try again.")
1588
+ : copyParam("time.paymentCancelled", "Payment cancelled — your booking wasn't confirmed. Pick a time to try again.") });
1132
1589
  }
1133
- },
1134
- },
1135
- });
1590
+ return derive({ ...s, bookingInFlight: false, message: s.isCourse
1591
+ ? copyParam("time.coursePaymentFailed", "Payment couldn't be completed. Try again.")
1592
+ : copyParam("time.paymentFailed", "Payment couldn't be completed. Pick another time or try again.") });
1593
+ } catch {
1594
+ return derive({ ...s, bookingInFlight: false, message: s.isCourse
1595
+ ? copyParam("time.courseCheckoutFailed", "Checkout could not start. Try again.")
1596
+ : copyParam("time.checkoutFailed", "Checkout could not start. Pick another time or try again.") });
1597
+ }
1598
+ }
1599
+ // FREE appointment/class — the original member bookSlot rung (offline-safe below).
1600
+ // getMyData/signedInEmail carry their own fallbacks (never throw), so the
1601
+ // booker identity is resolved up front — needed for both the optimistic
1602
+ // cache row and the offline replay params.
1603
+ const my = await getMyData().catch(() => ({ email: s.email } as WixMyData));
1604
+ const email = (await signedInEmail()) || my.email || my.contactEmail || s.email;
1605
+ const name = contactName(displayName(my) || email);
1606
+ // Optimistic local write — My Bookings repaints from cache immediately (offline or not).
1607
+ const selectedSlot = allRows.find((r) => r.id === s.selectedSlotId);
1608
+ const localId = `local-${Date.now()}`;
1609
+ const localRow = (id: string): DbEntity => ({
1610
+ id,
1611
+ title: s.serviceName || "Booking",
1612
+ subtitle: s.isCourse ? (s.selectedLine || "Full course") : (selectedSlot?.title || "Booked time"),
1613
+ status: "Booked",
1614
+ });
1615
+ if (!offlineWritesEnabled) await cachePut(localRow(localId));
1616
+ const pending = derive({ ...s, bookingInFlight: true, message: "", successMessage: "" });
1617
+ try {
1618
+ const created = await bookSlot(slot!, name.first, name.last, email);
1619
+ // Reconcile the optimistic local id with the server-assigned booking id.
1620
+ const realId = created.booking?.id;
1621
+ if (offlineWritesEnabled) await cachePut(localRow(realId || localId));
1622
+ else if (realId && realId !== localId) { await cacheDrop(localId); await cachePut(localRow(realId)); }
1623
+ await reportBooked(created.booking);
1624
+ return derive({
1625
+ ...pending,
1626
+ bookingInFlight: false,
1627
+ successMessage: copyParam("time.successMessage", "Booked. Your confirmation is ready in My Bookings."),
1628
+ message: "",
1629
+ });
1630
+ } catch (e) {
1631
+ if (!offlineWritesEnabled && isBridgeUnavailable(e)) {
1632
+ // Offline / no bridge: the optimistic cache row already shows in My
1633
+ // Bookings; queue the booking to replay (WriteQueue, idempotent) when
1634
+ // connectivity returns.
1635
+ await queueWrite("tier5.himiBookingTime.bookSelected", {
1636
+ serviceId: s.id,
1637
+ ...(s.isCourse ? { scheduleId: s.scheduleId } : { slotId: s.selectedSlotId }),
1638
+ email,
1639
+ firstName: name.first,
1640
+ lastName: name.last,
1641
+ ...(slot ? { slot: JSON.stringify(slot) } : {}),
1642
+ });
1643
+ return derive({
1644
+ ...pending,
1645
+ bookingInFlight: false,
1646
+ successMessage: "Booked offline — will sync when you reconnect.",
1647
+ message: "",
1648
+ });
1649
+ }
1650
+ return derive({ ...s, bookingInFlight: false, message: s.isCourse
1651
+ ? copyParam("time.courseBookFailed", "Course booking failed. Try again.")
1652
+ : copyParam("time.bookFailed", "Booking failed. Pick another time or try again.") });
1653
+ }
1654
+ }
1136
1655
  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"]);
1137
1656
  svc.load();
1138
1657
  export { svc };