@wix/himalaya-cli 0.1522.0 → 0.1523.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 (44) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/bookings-v2/src/extensions.d.ts +18 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-bookings.mjs +13 -0
  3. package/dist/cli.mjs +2 -2
  4. package/package.json +2 -2
  5. package/templates/beauty-salon/dev/index.ts +862 -35
  6. package/templates/beauty-salon/tier5-src/_shared/himi-bookings.ts +69 -0
  7. package/templates/beauty-salon/tier5-src/_shared/himi-copy.ts +9 -0
  8. package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +237 -17
  9. package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +277 -55
  10. package/templates/beauty-salon-owner/tier5-src/_shared/himi-copy.ts +9 -0
  11. package/templates/branded-lite/dev/index.ts +862 -35
  12. package/templates/branded-lite/tier5-src/_shared/himi-bookings.ts +69 -0
  13. package/templates/branded-lite/tier5-src/_shared/himi-copy.ts +9 -0
  14. package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +237 -17
  15. package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +277 -55
  16. package/templates/brandy/tier5-src/_shared/himi-copy.ts +9 -0
  17. package/templates/community-club/tier5-src/_shared/himi-copy.ts +9 -0
  18. package/templates/community-club-owner/tier5-src/_shared/himi-copy.ts +9 -0
  19. package/templates/events-meetups/tier5-src/_shared/himi-copy.ts +9 -0
  20. package/templates/events-meetups-owner/tier5-src/_shared/himi-copy.ts +9 -0
  21. package/templates/gym-fitness/dev/index.ts +862 -35
  22. package/templates/gym-fitness/tier5-src/_shared/himi-bookings.ts +69 -0
  23. package/templates/gym-fitness/tier5-src/_shared/himi-copy.ts +9 -0
  24. package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +237 -17
  25. package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +277 -55
  26. package/templates/gym-fitness-owner/tier5-src/_shared/himi-copy.ts +9 -0
  27. package/templates/himi-chat/tier5-src/_shared/himi-copy.ts +9 -0
  28. package/templates/nonprofit-cause/tier5-src/_shared/himi-copy.ts +9 -0
  29. package/templates/nonprofit-cause-owner/tier5-src/_shared/himi-copy.ts +9 -0
  30. package/templates/owner-lite/tier5-src/_shared/himi-copy.ts +9 -0
  31. package/templates/podcast-creator/tier5-src/_shared/himi-copy.ts +9 -0
  32. package/templates/podcast-creator-owner/tier5-src/_shared/himi-copy.ts +9 -0
  33. package/templates/portfolio-creative/tier5-src/_shared/himi-copy.ts +9 -0
  34. package/templates/portfolio-creative-owner/tier5-src/_shared/himi-copy.ts +9 -0
  35. package/templates/restaurant/tier5-src/_shared/himi-copy.ts +9 -0
  36. package/templates/restaurant-owner/tier5-src/_shared/himi-copy.ts +9 -0
  37. package/templates/retail-store/tier5-src/_shared/himi-copy.ts +9 -0
  38. package/templates/retail-store-owner/tier5-src/_shared/himi-copy.ts +9 -0
  39. package/templates/yoga-studio/dev/index.ts +857 -30
  40. package/templates/yoga-studio/tier5-src/_shared/himi-bookings.ts +69 -0
  41. package/templates/yoga-studio/tier5-src/_shared/himi-copy.ts +9 -0
  42. package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +237 -17
  43. package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +277 -55
  44. package/templates/yoga-studio-owner/tier5-src/_shared/himi-copy.ts +9 -0
@@ -5,7 +5,7 @@ import { defineService } from "@himalaya/state";
5
5
  // instant range, not a calendar day.
6
6
  import { addDays as addLocalDays, dayKey, dayLabel, timeLabel, toDate } from "@himalaya/time";
7
7
  import { dispatch } from "@himalaya/dispatch";
8
- import { bookSlot, getService, queryServiceAvailability, type WixBookingSlotFull } from "@himalaya/wix-bookings";
8
+ import { bookSchedule, bookSlot, getService, queryServiceAvailability, registerToWaitlist, type WixBookingSlotFull, type WixSelectedPaymentOption, type WixService } from "@himalaya/wix-bookings";
9
9
  import { createCheckout, createOrder } from "@himalaya/wix-ecom";
10
10
  import { createRedirectSession, type WixSlot } from "@himalaya/wix-redirects";
11
11
  import { payCashierOrder } from "@himalaya/checkout";
@@ -13,11 +13,11 @@ import { hostedCheckoutOpener, isHostedWorker } from "../_shared/hosted-checkout
13
13
  import { session as wixPlatformSession } from "@himalaya/wix-platform";
14
14
  import { displayName, getMyData, type WixMyData } from "@himalaya/wix-users";
15
15
  import { formatServicePrice, serviceIsChargeable } from "../_shared/wix-service-price.js";
16
- import { stashCopy, copyParam } from "../_shared/himi-copy.js";
16
+ import { stashCopy, copyParam, copyPlural } from "../_shared/himi-copy.js";
17
17
  import { upsert as dbUpsert, remove as dbRemove, enqueue as dbEnqueue, type DbEntity } from "@himalaya/local-db";
18
18
  import { isBridgeUnavailable } from "../_shared/owner-load-error.js";
19
19
  import { emitHostEvent, hostEventInstant } from "../_shared/host-events.js";
20
- import { availabilityBeyond, slotIdAt } from "../_shared/himi-bookings.js";
20
+ import { availabilityBeyond, courseReadWindows, courseSessionsAvailability, courseWindowOpen, slotIdAt } from "../_shared/himi-bookings.js";
21
21
  import { wixMediaUrl, WIX_DETAIL_IMAGE } from "@himalaya/wix-media";
22
22
  import { mayUseRegularBookingCheckout } from "../_shared/iap-attendance.js";
23
23
  import { ATTENDANCE_INITIAL, ATTENDANCE_NOT_HYDRATED, createAttendancePurchase, type AttendancePurchaseFields } from "../_shared/iap-attendance-purchase.js";
@@ -52,6 +52,12 @@ interface SlotRow {
52
52
  dayChip: string;
53
53
  title: string;
54
54
  subtitle: string;
55
+ badge: string;
56
+ noBadge: boolean;
57
+ badgeWarning: boolean;
58
+ badgeNeutral: boolean;
59
+ selectable: boolean;
60
+ notSelectable: boolean;
55
61
  selected: boolean;
56
62
  notSelected: boolean;
57
63
  }
@@ -72,6 +78,13 @@ interface S extends AttendancePurchaseFields {
72
78
  * booking through the hosted Wix bookings checkout (GPV) instead of the free
73
79
  * bookSlot rung. Set from the service payload during load. */
74
80
  serviceIsPaid: boolean;
81
+ coursePaymentOption: WixSelectedPaymentOption | "";
82
+ courseAvailable: boolean;
83
+ isCourse: boolean;
84
+ notCourse: boolean;
85
+ scheduleId: string;
86
+ courseStart: string;
87
+ courseEnd: string;
75
88
  /** Day-strip chips: every day of the window (a day with no times shows a zero
76
89
  * count rather than being silently skipped), in the FilterChips option shape. */
77
90
  days: DayOption[];
@@ -106,8 +119,17 @@ interface S extends AttendancePurchaseFields {
106
119
  /** #3276 — the sign-in/register card belongs to the moment of booking: hidden
107
120
  * for a member, and for a visitor until they have picked a time. */
108
121
  hideSignIn: boolean;
122
+ hideTimeSignIn: boolean;
123
+ hideCourseSignIn: boolean;
124
+ hideWaitlistSignIn: boolean;
109
125
  canBook: boolean;
110
126
  cannotBook: boolean;
127
+ hideTimeBook: boolean;
128
+ hideCourseBook: boolean;
129
+ hideWaitlistBook: boolean;
130
+ hideDayPicker: boolean;
131
+ hideDaySlots: boolean;
132
+ hideDayEmpty: boolean;
111
133
  showChoosePrompt: boolean;
112
134
  hideChoosePrompt: boolean;
113
135
  isMember: boolean;
@@ -135,6 +157,7 @@ interface DayOption {
135
157
  }
136
158
 
137
159
  let slotsById = new Map<string, WixBookingSlotFull>();
160
+ let waitlistIds = new Set<string>();
138
161
  let regularCheckoutAllowed = false;
139
162
  let regularServicePrice = "";
140
163
  let lastReady: S | undefined;
@@ -150,6 +173,40 @@ let allRows: SlotRow[] = [];
150
173
  // bound that list needed is gone, and the availability query has no paging.
151
174
  const WINDOW_DAYS = 14;
152
175
 
176
+ /** A course booking created by an earlier tap whose checkout or payment did not finish. It stays
177
+ * on Wix, so the next tap pays for THIS booking instead of creating a second one for the same
178
+ * customer. `ordered` once its order exists: the order is what holds the place. Cleared once a
179
+ * booking goes through. */
180
+ let pendingCourseBooking: { scheduleId: string; email: string; ordered?: boolean; booking: { id?: string; startDate?: string; endDate?: string } } | null = null;
181
+
182
+ /** A course occupies one place for its whole schedule, so it can be booked only while its
183
+ * booking window is open and EVERY upcoming session still has room — not just the next one
184
+ * (_shared/himi-bookings.ts, the same rule the service screen's button uses). */
185
+ /** Whether this customer may book the course now. Once an order of theirs holds a place (`held`),
186
+ * counting places would refuse them their own, so only the booking window is checked again. */
187
+ async function courseOpenTo(service: WixService, now: Date, held: boolean): Promise<boolean> {
188
+ return held ? !!service.schedule?.id && courseWindowOpen(service, now.getTime()) : courseIsBookable(service, now);
189
+ }
190
+
191
+ async function courseIsBookable(service: WixService, now: Date): Promise<boolean> {
192
+ const current = now.getTime();
193
+ if (!service.schedule?.id || !courseWindowOpen(service, current)) return false;
194
+ const read = courseReadWindows(service, current);
195
+ const reads = read.windows.map(([from, to]) =>
196
+ queryServiceAvailability(service.id, isoDate(new Date(from)), isoDate(new Date(to)), { bookable: false }));
197
+ const entries = (await Promise.all(reads)).flatMap((res) => res.availabilityEntries ?? []);
198
+ const course = courseSessionsAvailability(entries, current, read.complete);
199
+ return course.found && course.bookable;
200
+ }
201
+
202
+ /** The service screen opens this screen with `course: "true"` for a course. When the service
203
+ * record then fails to load, the screen errors instead of listing the course's sessions as
204
+ * single times (where a booking would take one session, not the whole course). */
205
+ const courseInput = (() => {
206
+ const course = (globalThis as { himalaya?: { input?: { course?: unknown } } }).himalaya?.input?.course;
207
+ return course === true || course === "true";
208
+ })();
209
+
153
210
  function inputId(): string {
154
211
  // WS-C — `himalaya.input.id` is the PRIMARY contract: hosts thread the row's
155
212
  // `id` nav param (himi.open { screenId: "himi_time", id }) into worker input.
@@ -338,25 +395,35 @@ function derive(s: S): S {
338
395
  // selected day having none is a per-day line, not an empty screen.
339
396
  const hasSlots = s.days.some((d) => d.hasSlots) || s.slots.length > 0;
340
397
  const hasDaySlots = s.slots.length > 0;
341
- const hasSelected = !!s.selectedSlotId;
398
+ const hasSelected = s.isCourse ? !!s.scheduleId && s.courseAvailable : !!s.selectedSlotId;
399
+ const selectedWaitlist = !s.isCourse && waitlistIds.has(s.selectedSlotId);
342
400
  const hasSuccess = !!s.successMessage;
343
401
  const canBook = regular && s.isMember && hasSelected && !s.bookingInFlight && !hasSuccess;
344
- const showChoosePrompt = regular && s.isMember && hasSlots && !hasSelected && !s.bookingInFlight && !hasSuccess;
402
+ const showChoosePrompt = regular && !s.isCourse && s.isMember && hasSlots && !hasSelected && !s.bookingInFlight && !hasSuccess;
345
403
  const isRegistering = !!s.isRegistering;
346
404
  const next: S = {
347
405
  ...s,
348
406
  _poll: undefined,
349
407
  servicePrice: regular ? regularServicePrice : "",
350
- serviceLine: s.status === "ready" ? [s.serviceDuration, regular ? regularServicePrice : "", copyParam("time.availabilityWindow", "Available in the next 14 days")].filter(Boolean).join(" · ") : s.serviceLine,
408
+ // 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,
351
410
  message: regular && !regularCheckoutAllowed && s.message === copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") ? "" : s.message,
352
411
  hasSlots,
353
- ...applePurchase.fields(hasSelected),
412
+ // A waitlist pick or a course is not an in-app purchase: neither enables the Apple button.
413
+ ...applePurchase.fields(hasSelected && !selectedWaitlist && !s.isCourse),
354
414
  hasSelected,
355
415
  noSelected: !hasSelected,
356
- hideSignIn: s.isMember || !hasSelected,
416
+ // The shared sign-in card: no sign-in for a course this screen cannot sell (as below).
417
+ hideSignIn: s.isMember || !hasSelected || (s.isCourse && !regular),
418
+ hideTimeSignIn: s.isMember || !hasSelected || s.isCourse || selectedWaitlist,
419
+ // A course has no in-app purchase path: where it cannot use the regular checkout, a guest is
420
+ // not sent to sign in for a button that will not be there.
421
+ hideCourseSignIn: s.isMember || !hasSelected || !s.isCourse || !regular,
422
+ hideWaitlistSignIn: s.isMember || !hasSelected || s.isCourse || !selectedWaitlist,
423
+ notCourse: !s.isCourse,
357
424
  hasDaySlots,
358
425
  noDaySlots: !hasDaySlots,
359
- noWindowSlots: !hasSlots,
426
+ noWindowSlots: !hasSlots && !s.isCourse,
360
427
  // Only a quiet DAY in a window that has times gets the per-day line; a
361
428
  // window with no times at all is the screen-level empty state's job.
362
429
  dayEmptyText: hasDaySlots || !s.selectedDay || !hasSlots
@@ -366,11 +433,18 @@ function derive(s: S): S {
366
433
  noSuccess: !hasSuccess,
367
434
  canBook,
368
435
  cannotBook: !canBook,
436
+ hideTimeBook: !canBook || s.isCourse || selectedWaitlist,
437
+ hideCourseBook: !canBook || !s.isCourse,
438
+ // Joining a waitlist buys nothing, so it does not wait on the purchase route.
439
+ hideWaitlistBook: !s.isMember || !hasSelected || s.isCourse || !selectedWaitlist || s.bookingInFlight || hasSuccess,
440
+ hideDayPicker: s.isCourse || !hasSlots,
441
+ hideDaySlots: s.isCourse || !hasDaySlots,
442
+ hideDayEmpty: s.isCourse || hasDaySlots,
369
443
  showChoosePrompt,
370
444
  hideChoosePrompt: !showChoosePrompt,
371
445
  isLoading: s.status === "loading",
372
446
  notLoading: s.status !== "loading",
373
- noEmpty: s.status === "loading" || s.errored || hasSlots,
447
+ noEmpty: s.status === "loading" || s.errored || hasSlots || s.isCourse,
374
448
  notErrored: !s.errored,
375
449
  notMember: !s.isMember,
376
450
  notBooking: !s.bookingInFlight,
@@ -384,9 +458,11 @@ function derive(s: S): S {
384
458
  authTitle: isRegistering
385
459
  ? copyParam("time.authTitleRegister", "Create account to book")
386
460
  : copyParam("time.authTitle", "Sign in to book"),
387
- authBody: isRegistering
388
- ? copyParam("time.authBodyRegister", "Create a member account, then book the selected time on this screen.")
389
- : copyParam("time.authBody", "You can browse times as a visitor. Booking is saved to a member account."),
461
+ authBody: s.isCourse
462
+ ? copyParam("time.courseAuthBody", "Sign in to book the full course. Booking is saved to your member account.")
463
+ : isRegistering
464
+ ? copyParam("time.authBodyRegister", "Create a member account, then book the selected time on this screen.")
465
+ : copyParam("time.authBody", "You can browse times as a visitor. Booking is saved to a member account."),
390
466
  };
391
467
  if (next.status === "ready") lastReady = next;
392
468
  return next;
@@ -533,6 +609,13 @@ const svc = defineService<S, {
533
609
  servicePrice: "",
534
610
  serviceDuration: "",
535
611
  serviceIsPaid: false,
612
+ coursePaymentOption: "",
613
+ courseAvailable: false,
614
+ isCourse: false,
615
+ notCourse: true,
616
+ scheduleId: "",
617
+ courseStart: "",
618
+ courseEnd: "",
536
619
  days: [],
537
620
  selectedDay: "",
538
621
  selectedDayLabel: "",
@@ -557,8 +640,17 @@ const svc = defineService<S, {
557
640
  hasSelected: false,
558
641
  noSelected: true,
559
642
  hideSignIn: true,
643
+ hideTimeSignIn: true,
644
+ hideCourseSignIn: true,
645
+ hideWaitlistSignIn: true,
560
646
  canBook: false,
561
647
  cannotBook: true,
648
+ hideTimeBook: true,
649
+ hideCourseBook: true,
650
+ hideWaitlistBook: true,
651
+ hideDayPicker: true,
652
+ hideDaySlots: true,
653
+ hideDayEmpty: true,
562
654
  showChoosePrompt: false,
563
655
  hideChoosePrompt: true,
564
656
  isMember: false,
@@ -596,26 +688,83 @@ const svc = defineService<S, {
596
688
  try {
597
689
  const now = new Date();
598
690
  const beyond = availabilityBeyond(now, farStart);
599
- const [serviceRes, availability, target] = await Promise.all([
600
- getService(s.id).catch(() => ({ service: undefined })),
601
- queryServiceAvailability(s.id, isoDate(now), isoDate(new Date(now.getTime() + WINDOW_DAYS * 86_400_000))),
691
+ const serviceRes = await getService(s.id).catch(() => ({ service: undefined }));
692
+ if (courseInput && !serviceRes.service) {
693
+ slotsById = new Map();
694
+ allRows = [];
695
+ return derive({ ...next, status: "ready", errored: true, days: [], selectedDay: "", selectedDayLabel: "", slots: [] });
696
+ }
697
+ if (serviceRes.service?.type === "COURSE") {
698
+ slotsById = new Map();
699
+ waitlistIds = new Set();
700
+ allRows = [];
701
+ const header = serviceHeaderFields(serviceRes.service);
702
+ const schedule = serviceRes.service.schedule;
703
+ const scheduleId = schedule?.id ?? "";
704
+ // A reload after a cancelled payment: this customer's order may hold the last place.
705
+ const held = pendingCourseBooking?.ordered === true && pendingCourseBooking.scheduleId === scheduleId
706
+ && pendingCourseBooking.email === ((await signedInEmail()) || next.email);
707
+ const courseAvailable = await courseOpenTo(serviceRes.service, now, held);
708
+ // A course has no in-app purchase path: where a paid booking may not use the regular
709
+ // checkout (mayUseRegularBookingCheckout), it cannot be bought here.
710
+ regularServicePrice = header.price;
711
+ regularCheckoutAllowed = await mayUseRegularBookingCheckout(serviceRes.service);
712
+ return derive({
713
+ ...next,
714
+ status: "ready",
715
+ errored: false,
716
+ isCourse: true,
717
+ courseAvailable,
718
+ scheduleId,
719
+ courseStart: schedule?.firstSessionStart ?? "",
720
+ courseEnd: schedule?.lastSessionEnd ?? "",
721
+ days: [],
722
+ selectedDay: "",
723
+ slots: [],
724
+ selectedSlotId: "",
725
+ serviceName: serviceRes.service.name || copyParam("time.courseNameFallback", "Course"),
726
+ serviceLine: [header.duration, regularCheckoutAllowed ? header.price : ""].filter(Boolean).join(" · "),
727
+ serviceImage: header.image,
728
+ hasServiceImage: !!header.image,
729
+ noServiceImage: !header.image,
730
+ servicePrice: regularCheckoutAllowed ? header.price : "",
731
+ serviceDuration: header.duration,
732
+ serviceIsPaid: header.paid,
733
+ coursePaymentOption: serviceRes.service.payment?.options?.online ? "ONLINE"
734
+ : serviceRes.service.payment?.options?.inPerson ? "OFFLINE" : "",
735
+ selectedLine: schedule?.firstSessionStart && schedule?.lastSessionEnd
736
+ ? `${dayLabel(schedule.firstSessionStart)} – ${dayLabel(schedule.lastSessionEnd)}` : "",
737
+ message: !courseAvailable ? copyParam("time.courseUnavailable", "Course booking is unavailable right now.")
738
+ : regularCheckoutAllowed ? "" : copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable."),
739
+ });
740
+ }
741
+ 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 }),
602
744
  // A host's start past the window (an Agenda session weeks out): its day alone, so it can
603
745
  // be preselected. Its failure costs only that preselection.
604
- beyond ? queryServiceAvailability(s.id, isoDate(beyond.from), isoDate(beyond.until)).catch(() => ({ availabilityEntries: [] })) : undefined,
746
+ beyond ? queryServiceAvailability(s.id, isoDate(beyond.from), isoDate(beyond.until), { bookable: !includeFull }).catch(() => ({ availabilityEntries: [] })) : undefined,
605
747
  ]);
606
748
  slotsById = new Map();
749
+ waitlistIds = new Set();
607
750
  const rows: SlotRow[] = [];
608
751
  // Group by LOCAL calendar day, not by the formatted heading: two
609
752
  // distinct days can render the same text, and a UTC-derived key can
610
753
  // name a different day than the heading beside it.
754
+ const classService = serviceRes.service?.type === "CLASS";
611
755
  for (const [index, entry] of [...(availability.availabilityEntries ?? []), ...(target?.availabilityEntries ?? [])]
612
- .filter((candidate) => candidate.bookable !== false && candidate.slot?.startDate)
756
+ .filter((candidate) => candidate.slot?.startDate && (candidate.bookable !== false || (classService && candidate.openSpots === 0)))
613
757
  .entries()) {
614
758
  const slot = entry.slot as WixBookingSlotFull;
615
759
  const key = dayKey(slot.startDate);
616
760
  if (!key) continue;
617
761
  const slotId = String(slot.sessionId ?? slot.startDate ?? `slot-${index}`);
618
- slotsById.set(slotId, slot);
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);
619
768
  const label = fmtRange(slot.startDate, slot.endDate);
620
769
  rows.push({
621
770
  id: slotId,
@@ -624,6 +773,14 @@ const svc = defineService<S, {
624
773
  dayChip: dayLabel(slot.startDate, { format: "compact" }),
625
774
  title: label.title,
626
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,
627
784
  selected: false,
628
785
  notSelected: true,
629
786
  });
@@ -642,13 +799,13 @@ const svc = defineService<S, {
642
799
  // The named session (a slot row's id IS its session id), else the slot at that instant. A
643
800
  // named session that is gone is "taken since" — never another session at the same time.
644
801
  const startRow = !wantedStart ? undefined
645
- : pendingSession ? rows.find((r) => r.id === pendingSession)
646
- : rows.find((r) => r.id === slotIdAt(slotsById, wantedStart));
802
+ : pendingSession ? rows.find((r) => r.id === pendingSession && r.selectable)
803
+ : rows.find((r) => r.id === slotIdAt(slotsById, wantedStart) && r.selectable);
647
804
  // The requested day even with no time left on it: "taken since" opens there, nothing selected.
648
805
  const startDay = wantedStart ? days.find((d) => d.id === dayKey(wantedStart)) : undefined;
649
- const selectionAlive = !!startRow || slotsById.has(chosen.selectedSlotId);
806
+ const selectionAlive = !!startRow || slotsById.has(chosen.selectedSlotId) || waitlistIds.has(chosen.selectedSlotId);
650
807
  // A member with no live selection, and no host start, gets back the time they picked as a guest.
651
- const picked = selectionAlive || wantedStart || !next.isMember ? undefined : rows.find((row) => row.id === readPick(s.id));
808
+ const picked = selectionAlive || wantedStart || !next.isMember ? undefined : rows.find((row) => row.id === readPick(s.id) && row.selectable);
652
809
  // Keep the customer's day across a reload when it still has times;
653
810
  // otherwise open on the first day that has any.
654
811
  const keep = days.find((d) => d.id === chosen.selectedDay && d.hasSlots);
@@ -674,6 +831,7 @@ const svc = defineService<S, {
674
831
  }, initialDay));
675
832
  } catch {
676
833
  slotsById = new Map();
834
+ waitlistIds = new Set();
677
835
  allRows = [];
678
836
  return derive({ ...next, status: "ready", errored: true, days: [], selectedDay: "", selectedDayLabel: "", slots: [] });
679
837
  }
@@ -694,7 +852,7 @@ const svc = defineService<S, {
694
852
  const slotId = typeof arg === "string"
695
853
  ? arg
696
854
  : ((arg as { id?: string; slotId?: string })?.slotId ?? (arg as { id?: string })?.id ?? "");
697
- const selected = allRows.find((slot) => slot.id === slotId);
855
+ const selected = allRows.find((slot) => slot.id === slotId && slot.selectable);
698
856
  if (selected) {
699
857
  selectionRevision++;
700
858
  pendingStart = "";
@@ -729,7 +887,9 @@ const svc = defineService<S, {
729
887
  firstName: "",
730
888
  lastName: "",
731
889
  isRegistering: false,
732
- message: copyParam("time.signedInReady", "Signed in. You can book this time now."),
890
+ message: s.isCourse
891
+ ? copyParam("time.courseSignedInReady", "Signed in. You can book this course now.")
892
+ : copyParam("time.signedInReady", "Signed in. You can book this time now."),
733
893
  });
734
894
  } catch {
735
895
  return derive({ ...s, password: "", message: copyParam("time.signInFailed", "Sign in failed. Try again.") });
@@ -752,7 +912,9 @@ const svc = defineService<S, {
752
912
  firstName: "",
753
913
  lastName: "",
754
914
  isRegistering: false,
755
- message: copyParam("time.registerReady", "Account created. You can book this time now."),
915
+ message: s.isCourse
916
+ ? copyParam("time.courseRegisterReady", "Account created. You can book this course now.")
917
+ : copyParam("time.registerReady", "Account created. You can book this time now."),
756
918
  });
757
919
  } catch {
758
920
  return derive({ ...s, password: "", message: copyParam("time.registerFailed", "Account creation failed. Try another email.") });
@@ -760,17 +922,26 @@ const svc = defineService<S, {
760
922
  },
761
923
  bookSelected: async (s) => {
762
924
  if (!s.isMember) return derive({ ...s, message: copyParam("time.bookNeedsSignIn", "Sign in before booking.") });
763
- if (!s.selectedSlotId) return derive({ ...s, message: copyParam("time.bookNeedsSlot", "Choose one time first.") });
764
- const slot = slotsById.get(s.selectedSlotId);
765
- if (!slot) return derive({ ...s, message: copyParam("time.bookSlotGone", "That time is no longer available. Try again.") });
766
- try {
767
- const current = await getService(s.id);
768
- regularCheckoutAllowed = await mayUseRegularBookingCheckout(current.service);
769
- if (serviceHeaderFields(current.service).paid !== s.serviceIsPaid) return derive({ ...s, message: copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
770
- } catch {
771
- if (s.serviceIsPaid) return derive({ ...s, message: copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
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.") });
772
944
  }
773
- if (!regularCheckoutAllowed) return derive({ ...s, message: copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
774
945
  // A confirmed booking is reported to the app hosting this screen (an embed's `onAction`), so a
775
946
  // host chat can say so and refresh what it shows. Dropped where nothing hosts the screen.
776
947
  // The booking's own instants carry an offset; a slot's may be LOCAL to its timezone, which
@@ -779,12 +950,28 @@ const svc = defineService<S, {
779
950
  bookingId: booking?.id,
780
951
  serviceId: s.id,
781
952
  serviceName: s.serviceName,
782
- start: hostEventInstant(booking?.startDate) || slot.startDate,
783
- end: hostEventInstant(booking?.endDate) || slot.endDate,
953
+ start: hostEventInstant(booking?.startDate) || slot?.startDate || s.courseStart,
954
+ end: hostEventInstant(booking?.endDate) || slot?.endDate || s.courseEnd,
784
955
  });
785
956
  // The member acts on the pick now; a later visit must not restore it.
786
957
  clearPick();
787
- // PAID service — FULLY NATIVE in-app payment (mirrors Wix's RN apps): NO
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.") });
970
+ }
971
+ }
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
788
975
  // hosted-checkout WebView / browser redirect. The chain is
789
976
  // createBooking → ecom.createCheckout(booking line item) → ecom.createOrder
790
977
  // → @himalaya/wix-cashier.proceedToPayment(snapshotId)
@@ -792,17 +979,39 @@ const svc = defineService<S, {
792
979
  // (snapshotId) settled on-device via Apple/Google Pay. We do NOT optimistically
793
980
  // write a local booking row (My Bookings revalidates from Wix on success). Any
794
981
  // failure degrades to an inline retry message, never a dead button.
795
- if (s.serviceIsPaid) {
982
+ if (s.serviceIsPaid || s.isCourse) {
796
983
  const pending = derive({ ...s, bookingInFlight: true, message: "", successMessage: "" });
797
984
  // Booker identity — same resolution as the free path (never throws).
798
985
  const payerData = await getMyData().catch(() => ({ email: s.email } as WixMyData));
799
986
  const payerEmail = (await signedInEmail()) || payerData.email || payerData.contactEmail || s.email;
800
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
+ }
801
1004
  try {
802
1005
  // 1. Create the booking (CREATED status — payment pending).
803
- const created = await bookSlot(slot, payerName.first, payerName.last, payerEmail);
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);
804
1012
  const bookingId = created.booking?.id;
805
1013
  if (!bookingId) throw new Error("booking not created");
1014
+ if (s.isCourse && !retried) pendingCourseBooking = { scheduleId: s.scheduleId, email: payerEmail, booking: created.booking! };
806
1015
  // 2. Wrap the booking as a checkout line item; create the checkout.
807
1016
  const checkoutRes = await createCheckout([
808
1017
  { quantity: 1, catalogReference: { appId: WIX_BOOKINGS_APP_ID, catalogItemId: bookingId } },
@@ -816,10 +1025,14 @@ const svc = defineService<S, {
816
1025
  // RN does) — creating it here first fails on a site with no payment method set up.
817
1026
  const hosted = isHostedWorker();
818
1027
  const orderRes = hosted ? { paymentGatewayOrderId: "" } : await createOrder(checkoutId);
1028
+ if (s.isCourse && !hosted && pendingCourseBooking) pendingCourseBooking.ordered = true;
819
1029
  const snapshotId = orderRes.paymentGatewayOrderId ?? "";
820
- if (!snapshotId && !hosted) {
1030
+ if ((!snapshotId || (s.isCourse && s.coursePaymentOption === "OFFLINE")) && !hosted) {
1031
+ pendingCourseBooking = null;
821
1032
  await reportBooked(created.booking);
822
- return derive({ ...pending, bookingInFlight: false, successMessage: copyParam("time.successMessage", "Booked. Your confirmation is ready in My Bookings."), message: "" });
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: "" });
823
1036
  }
824
1037
  // 4. Collect payment IN-APP — wallet when this app has one wired, otherwise
825
1038
  // the in-app Wix cashier. Routed through the shared helper so a
@@ -833,13 +1046,14 @@ const svc = defineService<S, {
833
1046
  // Hosted (Spaces): the host opens this booking's checkout as the lent member.
834
1047
  ...(hosted ? { openCashier: hostedCheckoutOpener(checkoutId) } : {}),
835
1048
  cashierUrl: async () => {
836
- const redirect = await createRedirectSession({
837
- bookingsCheckout: { slotAvailability: { slot: slot as unknown as WixSlot } },
838
- });
1049
+ const redirect = await createRedirectSession(s.isCourse
1050
+ ? { ecomCheckout: { checkoutId } }
1051
+ : { bookingsCheckout: { slotAvailability: { slot: slot as WixSlot } } });
839
1052
  return redirect.redirectSession?.fullUrl;
840
1053
  },
841
1054
  });
842
1055
  if (pay.kind === "paid") {
1056
+ pendingCourseBooking = null;
843
1057
  await reportBooked(created.booking);
844
1058
  return derive({ ...pending, bookingInFlight: false, successMessage: copyParam("time.successMessage", "Booked. Your confirmation is ready in My Bookings."), message: "" });
845
1059
  }
@@ -849,14 +1063,20 @@ const svc = defineService<S, {
849
1063
  return derive({ ...pending, bookingInFlight: false, message: copyParam("time.checkoutOpened", "Complete your payment in the checkout to confirm this booking.") });
850
1064
  }
851
1065
  if (pay.kind === "cancelled") {
852
- return derive({ ...s, bookingInFlight: false, message: copyParam("time.paymentCancelled", "Payment cancelled — your booking wasn't confirmed. Pick a time to try again.") });
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.") });
853
1069
  }
854
- return derive({ ...s, bookingInFlight: false, message: copyParam("time.paymentFailed", "Payment couldn't be completed. Pick another time or try again.") });
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.") });
855
1073
  } catch {
856
- return derive({ ...s, bookingInFlight: false, message: copyParam("time.checkoutFailed", "Checkout could not start. Pick another time or try again.") });
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.") });
857
1077
  }
858
1078
  }
859
- // FREE service — the original member bookSlot rung (offline-safe below).
1079
+ // FREE appointment/class — the original member bookSlot rung (offline-safe below).
860
1080
  // getMyData/signedInEmail carry their own fallbacks (never throw), so the
861
1081
  // booker identity is resolved up front — needed for both the optimistic
862
1082
  // cache row and the offline replay params.
@@ -869,13 +1089,13 @@ const svc = defineService<S, {
869
1089
  const localRow = (id: string): DbEntity => ({
870
1090
  id,
871
1091
  title: s.serviceName || "Booking",
872
- subtitle: selectedSlot?.title || "Booked time",
1092
+ subtitle: s.isCourse ? (s.selectedLine || "Full course") : (selectedSlot?.title || "Booked time"),
873
1093
  status: "Booked",
874
1094
  });
875
1095
  await cachePut(localRow(localId));
876
1096
  const pending = derive({ ...s, bookingInFlight: true, message: "", successMessage: "" });
877
1097
  try {
878
- const created = await bookSlot(slot, name.first, name.last, email);
1098
+ const created = await bookSlot(slot!, name.first, name.last, email);
879
1099
  // Reconcile the optimistic local id with the server-assigned booking id.
880
1100
  const realId = created.booking?.id;
881
1101
  if (realId && realId !== localId) { await cacheDrop(localId); await cachePut(localRow(realId)); }
@@ -893,11 +1113,11 @@ const svc = defineService<S, {
893
1113
  // connectivity returns.
894
1114
  await queueWrite("tier5.himiBookingTime.bookSelected", {
895
1115
  serviceId: s.id,
896
- slotId: s.selectedSlotId,
1116
+ ...(s.isCourse ? { scheduleId: s.scheduleId } : { slotId: s.selectedSlotId }),
897
1117
  email,
898
1118
  firstName: name.first,
899
1119
  lastName: name.last,
900
- slot: JSON.stringify(slot),
1120
+ ...(slot ? { slot: JSON.stringify(slot) } : {}),
901
1121
  });
902
1122
  return derive({
903
1123
  ...pending,
@@ -906,7 +1126,9 @@ const svc = defineService<S, {
906
1126
  message: "",
907
1127
  });
908
1128
  }
909
- return derive({ ...s, bookingInFlight: false, message: copyParam("time.bookFailed", "Booking failed. Pick another time or try again.") });
1129
+ 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.") });
910
1132
  }
911
1133
  },
912
1134
  },
@@ -54,3 +54,12 @@ export function copyTemplate(key: string, fallback: string, values: Record<strin
54
54
  name in values ? String(values[name]) : "",
55
55
  );
56
56
  }
57
+
58
+ /** The small ICU plural form used by Bookings spot counts. The provisioned copy may
59
+ * translate both branches without adding a second key for the singular. */
60
+ export function copyPlural(key: string, fallback: string, count: number): string {
61
+ const pattern = copyParam(key, fallback);
62
+ const match = /^\{n, plural, one \{([^{}]*)\} other \{([^{}]*)\}\}$/.exec(pattern);
63
+ if (!match) return copyTemplate(key, fallback, { n: count });
64
+ return (count === 1 ? match[1] : match[2])!.replace(/#/g, String(count));
65
+ }
@@ -54,3 +54,12 @@ export function copyTemplate(key: string, fallback: string, values: Record<strin
54
54
  name in values ? String(values[name]) : "",
55
55
  );
56
56
  }
57
+
58
+ /** The small ICU plural form used by Bookings spot counts. The provisioned copy may
59
+ * translate both branches without adding a second key for the singular. */
60
+ export function copyPlural(key: string, fallback: string, count: number): string {
61
+ const pattern = copyParam(key, fallback);
62
+ const match = /^\{n, plural, one \{([^{}]*)\} other \{([^{}]*)\}\}$/.exec(pattern);
63
+ if (!match) return copyTemplate(key, fallback, { n: count });
64
+ return (count === 1 ? match[1] : match[2])!.replace(/#/g, String(count));
65
+ }
@@ -54,3 +54,12 @@ export function copyTemplate(key: string, fallback: string, values: Record<strin
54
54
  name in values ? String(values[name]) : "",
55
55
  );
56
56
  }
57
+
58
+ /** The small ICU plural form used by Bookings spot counts. The provisioned copy may
59
+ * translate both branches without adding a second key for the singular. */
60
+ export function copyPlural(key: string, fallback: string, count: number): string {
61
+ const pattern = copyParam(key, fallback);
62
+ const match = /^\{n, plural, one \{([^{}]*)\} other \{([^{}]*)\}\}$/.exec(pattern);
63
+ if (!match) return copyTemplate(key, fallback, { n: count });
64
+ return (count === 1 ? match[1] : match[2])!.replace(/#/g, String(count));
65
+ }