@wix/himalaya-cli 0.1522.0 → 0.1524.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 (49) 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 +3 -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/tier5-src/himi-branded-lite-cart/index.ts +4 -63
  11. package/templates/beauty-salon-owner/tier5-src/_shared/himi-copy.ts +9 -0
  12. package/templates/branded-lite/dev/index.ts +862 -35
  13. package/templates/branded-lite/tier5-src/_shared/himi-bookings.ts +69 -0
  14. package/templates/branded-lite/tier5-src/_shared/himi-copy.ts +9 -0
  15. package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +237 -17
  16. package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +277 -55
  17. package/templates/branded-lite/tier5-src/himi-branded-lite-cart/index.ts +4 -63
  18. package/templates/brandy/tier5-src/_shared/himi-copy.ts +9 -0
  19. package/templates/community-club/tier5-src/_shared/himi-copy.ts +9 -0
  20. package/templates/community-club-owner/tier5-src/_shared/himi-copy.ts +9 -0
  21. package/templates/events-meetups/tier5-src/_shared/himi-copy.ts +9 -0
  22. package/templates/events-meetups-owner/tier5-src/_shared/himi-copy.ts +9 -0
  23. package/templates/gym-fitness/dev/index.ts +862 -35
  24. package/templates/gym-fitness/tier5-src/_shared/himi-bookings.ts +69 -0
  25. package/templates/gym-fitness/tier5-src/_shared/himi-copy.ts +9 -0
  26. package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +237 -17
  27. package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +277 -55
  28. package/templates/gym-fitness/tier5-src/himi-branded-lite-cart/index.ts +4 -63
  29. package/templates/gym-fitness-owner/tier5-src/_shared/himi-copy.ts +9 -0
  30. package/templates/himi-chat/tier5-src/_shared/himi-copy.ts +9 -0
  31. package/templates/nonprofit-cause/tier5-src/_shared/himi-copy.ts +9 -0
  32. package/templates/nonprofit-cause-owner/tier5-src/_shared/himi-copy.ts +9 -0
  33. package/templates/owner-lite/tier5-src/_shared/himi-copy.ts +9 -0
  34. package/templates/podcast-creator/tier5-src/_shared/himi-copy.ts +9 -0
  35. package/templates/podcast-creator-owner/tier5-src/_shared/himi-copy.ts +9 -0
  36. package/templates/portfolio-creative/tier5-src/_shared/himi-copy.ts +9 -0
  37. package/templates/portfolio-creative/tier5-src/himi-branded-lite-cart/index.ts +4 -63
  38. package/templates/portfolio-creative-owner/tier5-src/_shared/himi-copy.ts +9 -0
  39. package/templates/restaurant/tier5-src/_shared/himi-copy.ts +9 -0
  40. package/templates/restaurant-owner/tier5-src/_shared/himi-copy.ts +9 -0
  41. package/templates/retail-store/tier5-src/_shared/himi-copy.ts +9 -0
  42. package/templates/retail-store/tier5-src/himi-branded-lite-cart/index.ts +4 -63
  43. package/templates/retail-store-owner/tier5-src/_shared/himi-copy.ts +9 -0
  44. package/templates/yoga-studio/dev/index.ts +857 -30
  45. package/templates/yoga-studio/tier5-src/_shared/himi-bookings.ts +69 -0
  46. package/templates/yoga-studio/tier5-src/_shared/himi-copy.ts +9 -0
  47. package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +237 -17
  48. package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +277 -55
  49. package/templates/yoga-studio-owner/tier5-src/_shared/himi-copy.ts +9 -0
@@ -65,3 +65,72 @@ export function slotIdAt(slots: Iterable<[string, { startDate?: string; timezone
65
65
  }
66
66
  return "";
67
67
  }
68
+
69
+ // ---- Courses ----------------------------------------------------------------------
70
+ // One answer for the service screen's course button and the time screen's course booking, so
71
+ // the button never offers a course that the booking step then refuses.
72
+
73
+ type CoursePolicyShape = {
74
+ schedule?: { id?: string; firstSessionStart?: string; lastSessionEnd?: string };
75
+ bookingPolicy?: {
76
+ bookAfterStartPolicy?: { enabled?: boolean };
77
+ limitEarlyBookingPolicy?: { enabled?: boolean; earliestBookingInMinutes?: number };
78
+ limitLateBookingPolicy?: { enabled?: boolean; latestBookingInMinutes?: number };
79
+ };
80
+ };
81
+
82
+ /** Whether the business takes course bookings at `now` (ms): the course has not ended, has not
83
+ * started (unless the policy lets people join late), and `now` is inside its booking window —
84
+ * which the early and late limits count from the first session. */
85
+ export function courseWindowOpen(service: CoursePolicyShape, now: number): boolean {
86
+ const first = Date.parse(service.schedule?.firstSessionStart ?? "");
87
+ const last = Date.parse(service.schedule?.lastSessionEnd ?? "");
88
+ if (!Number.isFinite(first) || !Number.isFinite(last) || last <= now) return false;
89
+ const policy = service.bookingPolicy;
90
+ if (first < now && policy?.bookAfterStartPolicy?.enabled !== true) return false;
91
+ const early = policy?.limitEarlyBookingPolicy;
92
+ if (early?.enabled && typeof early.earliestBookingInMinutes === "number" && now < first - early.earliestBookingInMinutes * 60_000) return false;
93
+ const late = policy?.limitLateBookingPolicy;
94
+ if (late?.enabled && typeof late.latestBookingInMinutes === "number" && now > first - late.latestBookingInMinutes * 60_000) return false;
95
+ return true;
96
+ }
97
+
98
+ /** How far ahead a course is read: the whole remaining course, up to a year. Both screens read the
99
+ * same sessions — from the later of its first session and now — so the service screen never
100
+ * offers a course that the booking step then refuses on a session the first did not read. */
101
+ export const COURSE_READ_DAYS = 366;
102
+ const DAY = 86_400_000;
103
+
104
+ /** The 14-day availability windows covering a course's remaining sessions, and whether they reach
105
+ * its last session (`complete`) and start at its first (`fromFirst`, so a count is the whole
106
+ * course). Empty when the course has no usable dates or has ended. */
107
+ export function courseReadWindows(service: CoursePolicyShape, now: number, chunkDays = 14): { windows: Array<[number, number]>; complete: boolean; fromFirst: boolean } {
108
+ const first = Date.parse(service.schedule?.firstSessionStart ?? "");
109
+ const last = Date.parse(service.schedule?.lastSessionEnd ?? "");
110
+ if (!Number.isFinite(first) || !Number.isFinite(last)) return { windows: [], complete: false, fromFirst: false };
111
+ const from = Math.max(first, now);
112
+ const courseEnd = last + DAY;
113
+ const until = Math.min(courseEnd, from + COURSE_READ_DAYS * DAY);
114
+ const windows: Array<[number, number]> = [];
115
+ for (let at = from; at < until; at += chunkDays * DAY) windows.push([at, Math.min(until, at + chunkDays * DAY)]);
116
+ return { windows, complete: courseEnd <= until, fromFirst: from === first };
117
+ }
118
+
119
+ type CourseEntryShape = { slot?: { startDate?: string; timezone?: unknown }; bookable?: boolean; openSpots?: number };
120
+
121
+ /** A course across EVERY upcoming session read: a customer books all of them at once, so the
122
+ * smallest open count is the course's, and one session that cannot be booked closes it. A start
123
+ * is read in the session's own zone (`slotStartInstant`). `found` is false when no upcoming
124
+ * session came back: both screens then treat the course as not bookable. So is a course whose
125
+ * read did not reach its last session (`readComplete`, from `courseReadWindows`): a session
126
+ * past the year read may be full, and the course is booked whole. */
127
+ export function courseSessionsAvailability(entries: readonly CourseEntryShape[], now: number, readComplete = true): { found: boolean; spots: number | undefined; bookable: boolean } {
128
+ const upcoming = entries.filter((entry) => {
129
+ const start = entry.slot ? slotStartInstant(entry.slot as { startDate?: string; timezone?: unknown }) : NaN;
130
+ return Number.isFinite(start) && start >= now;
131
+ });
132
+ if (!upcoming.length) return { found: false, spots: undefined, bookable: false };
133
+ const counts = upcoming.map((entry) => entry.openSpots).filter((n): n is number => typeof n === "number" && Number.isFinite(n) && n >= 0);
134
+ const spots = counts.length ? Math.min(...counts) : undefined;
135
+ return { found: true, spots, bookable: readComplete && upcoming.every((entry) => entry.bookable !== false) && spots !== 0 };
136
+ }
@@ -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
+ }
@@ -2,12 +2,13 @@
2
2
  import { defineService, navigate } from "@himalaya/state";
3
3
  import { share as shareSheet } from "@himalaya/share";
4
4
  import { open, defaultMode } from "@himalaya/open-url";
5
- import { getService, type WixService, type WixServiceAddress, type WixServiceBookingPolicy } from "@himalaya/wix-bookings";
6
- import { categoryName } from "../_shared/himi-bookings.js";
5
+ import { getService, queryServiceAvailability, type WixAvailabilityEntry, type WixService, type WixServiceAddress, type WixServiceBookingPolicy } from "@himalaya/wix-bookings";
6
+ import { categoryName, courseReadWindows, courseSessionsAvailability, courseWindowOpen, slotStartInstant } from "../_shared/himi-bookings.js";
7
7
  import { formatServicePrice, formatServiceMoney, serviceCurrency } from "../_shared/wix-service-price.js";
8
- import { stashCopy, copyParam, copyTemplate } from "../_shared/himi-copy.js";
8
+ import { stashCopy, copyParam, copyTemplate, copyPlural } from "../_shared/himi-copy.js";
9
9
  import { get, type DbEntity } from "@himalaya/local-db";
10
10
  import { wixMediaUrl, WIX_DETAIL_IMAGE, WIX_LIST_IMAGE } from "@himalaya/wix-media";
11
+ import { formatClock, formatDateMedium, formatMonthDay } from "../_shared/engine-intl.js";
11
12
 
12
13
  // Offline-first (data-offline): the service detail is a public read-through. On a
13
14
  // fetch failure we fall back to the last-synced row for this service written by
@@ -35,6 +36,7 @@ interface LocationRow {
35
36
  a11y: string;
36
37
  }
37
38
  interface PolicyLine { text: string; owner: boolean; notOwner: boolean }
39
+ interface CourseSession { date: string; time: string }
38
40
 
39
41
  interface S {
40
42
  status: "loading" | "ready";
@@ -43,6 +45,11 @@ interface S {
43
45
  /** The category name, shown as the small label above the name. */
44
46
  category: string;
45
47
  noCategory: boolean;
48
+ typeTag: string;
49
+ noTypeTag: boolean;
50
+ isClass: boolean;
51
+ isCourse: boolean;
52
+ noCourse: boolean;
46
53
  tagline: string;
47
54
  noTagline: boolean;
48
55
  /** The price line: "₪90" + "per session", "From ₪60", "Free", or a CUSTOM description. */
@@ -68,6 +75,30 @@ interface S {
68
75
  noImage: boolean;
69
76
  durationText: string;
70
77
  noDuration: boolean;
78
+ courseDates: string;
79
+ courseSessionCount: string;
80
+ noCourseDates: boolean;
81
+ spotsText: string;
82
+ noSpots: boolean;
83
+ spotsWarning: boolean;
84
+ spotsNeutral: boolean;
85
+ hideSpotsNeutral: boolean;
86
+ courseSessions: CourseSession[];
87
+ noCourseSessions: boolean;
88
+ courseMoreSessions: CourseSession[];
89
+ noCourseMoreSessions: boolean;
90
+ scheduleExpanded: boolean;
91
+ scheduleCollapsed: boolean;
92
+ courseCtaBook: boolean;
93
+ courseCtaRequest: boolean;
94
+ courseCtaFull: boolean;
95
+ courseCtaClosed: boolean;
96
+ hideChooseTime: boolean;
97
+ hideRequestTime: boolean;
98
+ noCourseCtaBook: boolean;
99
+ noCourseCtaRequest: boolean;
100
+ noCourseCtaFull: boolean;
101
+ noCourseCtaClosed: boolean;
71
102
  locations: LocationRow[];
72
103
  noLocations: boolean;
73
104
  online: boolean;
@@ -146,7 +177,9 @@ function priceLine(service: WixService): PriceLine {
146
177
  const payment = service.payment ?? {};
147
178
  const rate = stringOf(payment.rateType).toUpperCase();
148
179
  const currency = serviceCurrency(service);
149
- const unit = copyParam("service.unitSession", "per session");
180
+ const unit = service.type === "COURSE"
181
+ ? copyParam("service.unitParticipant", "per participant")
182
+ : copyParam("service.unitSession", "per session");
150
183
  if (rate === "NO_FEE") return { text: copyParam("service.priceFree", "Free"), unit: "", isText: false };
151
184
  if (rate === "CUSTOM") return { text: stringOf(payment.custom?.description), unit: "", isText: true };
152
185
  if (rate === "VARIED") {
@@ -363,10 +396,14 @@ function policyText(text: string): string {
363
396
 
364
397
  /** The lines of "Our policy", from the business's own booking policy. The app never
365
398
  * invents policy text: each line exists only when the policy states it. */
366
- function policyLines(policy: WixServiceBookingPolicy | undefined): PolicyLine[] {
399
+ function policyLines(policy: WixServiceBookingPolicy | undefined, type: string): PolicyLine[] {
367
400
  if (!policy) return [];
368
401
  const lines: PolicyLine[] = [];
369
402
  const add = (text: string, owner = false) => { if (text) lines.push({ text, owner, notOwner: !owner }); };
403
+ const templated = (key: string, fallback: string, values: Record<string, string | number>) =>
404
+ type === "COURSE" && fallback.includes("session starts")
405
+ ? copyTemplate(`${key}Course`, fallback.replace("session starts", "course starts"), values)
406
+ : copyTemplate(key, fallback, values);
370
407
 
371
408
  const custom = policy.customPolicyDescription;
372
409
  if (custom?.enabled) add(policyText(stringOf(custom.description)), true);
@@ -374,14 +411,14 @@ function policyLines(policy: WixServiceBookingPolicy | undefined): PolicyLine[]
374
411
  const cancel = changeRule(policy.cancellationPolicy?.enabled, policy.cancellationPolicy?.limitLatestCancellation, numberOf(policy.cancellationPolicy?.latestCancellationInMinutes));
375
412
  const reschedule = changeRule(policy.reschedulePolicy?.enabled, policy.reschedulePolicy?.limitLatestReschedule, numberOf(policy.reschedulePolicy?.latestRescheduleInMinutes));
376
413
  if (cancel.kind === "until" && reschedule.kind === "until" && cancel.minutes === reschedule.minutes) {
377
- add(copyTemplate("service.changeUntil", "Cancel or reschedule online up to {window} before the session starts.", { window: windowText(cancel.minutes) }));
414
+ add(templated("service.changeUntil", "Cancel or reschedule online up to {window} before the session starts.", { window: windowText(cancel.minutes) }));
378
415
  } else if (cancel.kind === "anytime" && reschedule.kind === "anytime") {
379
416
  add(copyParam("service.changeAnytime", "You can cancel or reschedule online at any time."));
380
417
  } else {
381
- if (cancel.kind === "until") add(copyTemplate("service.cancelUntil", "Cancel online up to {window} before the session starts.", { window: windowText(cancel.minutes) }));
418
+ if (cancel.kind === "until") add(templated("service.cancelUntil", "Cancel online up to {window} before the session starts.", { window: windowText(cancel.minutes) }));
382
419
  if (cancel.kind === "anytime") add(copyParam("service.cancelAnytime", "You can cancel online at any time."));
383
420
  if (cancel.kind === "off") add(copyParam("service.cancelNotOnline", "You can't cancel online. Contact us to cancel."));
384
- if (reschedule.kind === "until") add(copyTemplate("service.rescheduleUntil", "Reschedule online up to {window} before the session starts.", { window: windowText(reschedule.minutes) }));
421
+ if (reschedule.kind === "until") add(templated("service.rescheduleUntil", "Reschedule online up to {window} before the session starts.", { window: windowText(reschedule.minutes) }));
385
422
  if (reschedule.kind === "anytime") add(copyParam("service.rescheduleAnytime", "You can reschedule online at any time."));
386
423
  if (reschedule.kind === "off") add(copyParam("service.rescheduleNotOnline", "You can't reschedule online."));
387
424
  }
@@ -395,22 +432,24 @@ function policyLines(policy: WixServiceBookingPolicy | undefined): PolicyLine[]
395
432
  const start = numberOf(window?.startInMinutes);
396
433
  if (start == null || start <= 0) add(copyTemplate("service.feeLate", "Late cancellations have a {fee} fee.", { fee }));
397
434
  else if (cancel.kind === "until" && cancel.minutes === start) add(copyTemplate("service.feeAfterThat", "Cancellations after that have a {fee} fee.", { fee }));
398
- else add(copyTemplate("service.feeWindow", "Cancellations less than {window} before the session starts have a {fee} fee.", { window: windowText(start), fee }));
435
+ else add(templated("service.feeWindow", "Cancellations less than {window} before the session starts have a {fee} fee.", { window: windowText(start), fee }));
399
436
  }
400
437
  }
401
438
 
402
439
  const early = policy.limitEarlyBookingPolicy;
403
440
  const earliest = numberOf(early?.earliestBookingInMinutes);
404
- if (early?.enabled && earliest != null && earliest > 0) add(copyTemplate("service.bookingsOpen", "Bookings open {window} before the session starts.", { window: windowText(earliest) }));
441
+ if (early?.enabled && earliest != null && earliest > 0) add(templated("service.bookingsOpen", "Bookings open {window} before the session starts.", { window: windowText(earliest) }));
405
442
  const late = policy.limitLateBookingPolicy;
406
443
  const latest = numberOf(late?.latestBookingInMinutes);
407
- if (late?.enabled && latest != null && latest > 0) add(copyTemplate("service.bookingsClose", "Bookings close {window} before the session starts.", { window: windowText(latest) }));
444
+ if (late?.enabled && latest != null && latest > 0) add(templated("service.bookingsClose", "Bookings close {window} before the session starts.", { window: windowText(latest) }));
408
445
 
409
446
  // Live payloads also carry `participantsPolicy.enabled` (not in the V2 types); a disabled
410
447
  // policy keeps its configured maximum, which is not a rule the customer is held to.
411
448
  const participants = policy.participantsPolicy as { enabled?: boolean; maxParticipantsPerBooking?: number } | undefined;
412
449
  const max = numberOf(participants?.maxParticipantsPerBooking);
413
450
  if (participants?.enabled !== false && max != null && max > 1) add(copyTemplate("service.maxParticipants", "Book for up to {n} participants.", { n: max }));
451
+ if (type === "CLASS" && policy.waitlistPolicy?.enabled) add(copyParam("service.waitlistRule", "When a class is full, you can join the waitlist."));
452
+ if (type === "COURSE" && policy.bookAfterStartPolicy?.enabled) add(copyParam("service.joinAfterStart", "You can join after the course starts."));
414
453
  return lines;
415
454
  }
416
455
 
@@ -442,6 +481,120 @@ function plain(text: string | undefined): string {
442
481
  .trim();
443
482
  }
444
483
 
484
+ // ---- Class and course ------------------------------------------------------------
485
+
486
+ const DAY_MS = 86_400_000;
487
+ const GROUP_WINDOW_DAYS = 14;
488
+
489
+ function validDate(value: string | undefined): Date | undefined {
490
+ if (!value) return undefined;
491
+ const date = new Date(value);
492
+ return Number.isFinite(date.getTime()) ? date : undefined;
493
+ }
494
+
495
+ function shortDate(date: Date, year: boolean): string {
496
+ return year ? formatDateMedium(date, "en") : formatMonthDay(date, "en");
497
+ }
498
+
499
+ function courseDates(service: WixService): string {
500
+ const first = validDate(service.schedule?.firstSessionStart);
501
+ const last = validDate(service.schedule?.lastSessionEnd);
502
+ if (!first || !last) return "";
503
+ return `${shortDate(first, first.getFullYear() !== last.getFullYear())} – ${shortDate(last, true)}`;
504
+ }
505
+
506
+ function spotCount(entry: WixAvailabilityEntry | undefined): number | undefined {
507
+ const count = numberOf(entry?.openSpots);
508
+ return count != null && count >= 0 ? count : undefined;
509
+ }
510
+
511
+ function spotText(count: number): string {
512
+ return copyPlural("service.spotsLeft", "{n, plural, one {# spot left} other {# spots left}}", count);
513
+ }
514
+
515
+ function sessionTime(start: string, end: string | undefined): string {
516
+ const from = validDate(start);
517
+ const to = validDate(end);
518
+ if (!from) return "";
519
+ return to ? `${formatClock(from, "en")} – ${formatClock(to, "en")}` : formatClock(from, "en");
520
+ }
521
+
522
+ /** V1 availability is the same visitor-readable call used by the time screen. Ask
523
+ * for full entries too, since `bookable: true` hides the full sessions whose
524
+ * zero capacity and waitlist state the design needs. Bound every request to 14
525
+ * days; a course reads at most 84 days and never calls its partial count exact. */
526
+ async function groupAvailability(service: WixService, now: Date): Promise<{ entries: WixAvailabilityEntry[]; complete: boolean; readToEnd: boolean }> {
527
+ if (service.type === "CLASS") {
528
+ const res = await queryServiceAvailability(service.id, now.toISOString(), new Date(now.getTime() + GROUP_WINDOW_DAYS * DAY_MS).toISOString(), { bookable: false });
529
+ return { entries: res.availabilityEntries ?? [], complete: true, readToEnd: true };
530
+ }
531
+ if (service.type !== "COURSE") return { entries: [], complete: true, readToEnd: true };
532
+ // The same sessions the time screen checks before it books (_shared/himi-bookings.ts). The
533
+ // session count is the whole course only when the read starts at its first session.
534
+ const read = courseReadWindows(service, now.getTime(), GROUP_WINDOW_DAYS);
535
+ const replies = await Promise.all(read.windows.map(([from, to]) =>
536
+ queryServiceAvailability(service.id, new Date(from).toISOString(), new Date(to).toISOString(), { bookable: false })));
537
+ return { entries: replies.flatMap((res) => res.availabilityEntries ?? []), complete: read.complete && read.fromFirst, readToEnd: read.complete };
538
+ }
539
+
540
+ /** When a session starts, read in its own zone (a local wall time beside `timezone`). */
541
+ function sessionStart(entry: WixAvailabilityEntry): number {
542
+ return entry.slot ? slotStartInstant(entry.slot as { startDate?: string; timezone?: unknown }) : NaN;
543
+ }
544
+
545
+ async function withGroupAvailability(s: S, service: WixService): Promise<S> {
546
+ if (service.type !== "CLASS" && service.type !== "COURSE") return s;
547
+ const now = new Date();
548
+ try {
549
+ const result = await groupAvailability(service, now);
550
+ const seen = new Set<string>();
551
+ const entries = result.entries.filter((entry) => {
552
+ const slot = entry.slot;
553
+ if (!slot?.startDate) return false;
554
+ const key = String(slot.sessionId ?? slot.startDate);
555
+ if (seen.has(key)) return false;
556
+ seen.add(key);
557
+ return true;
558
+ }).sort((a, b) => sessionStart(a) - sessionStart(b));
559
+ const next = entries.find((entry) => sessionStart(entry) >= now.getTime());
560
+ // A course is booked whole, so its count is the smallest across every upcoming session, and
561
+ // one session that cannot be booked closes it (the rule the time screen books by).
562
+ const course = service.type === "COURSE" ? courseSessionsAvailability(entries, now.getTime(), result.readToEnd) : undefined;
563
+ const count = course ? course.spots : spotCount(next);
564
+ // No upcoming session read back: the time screen refuses it, so the button is closed too.
565
+ const courseClosed = s.courseCtaClosed || (course != null && (!course.found || (!course.bookable && count !== 0)));
566
+ const waitlist = service.type === "CLASS" && service.bookingPolicy?.waitlistPolicy?.enabled === true &&
567
+ (numberOf(next?.waitingList?.openSpots) ?? 0) > 0;
568
+ const spotsText = count == null ? "" : count > 0 ? spotText(count)
569
+ : service.type === "CLASS"
570
+ ? waitlist
571
+ ? copyParam("service.classFullWaitlist", "The class is fully booked. You can join the waitlist.")
572
+ : copyParam("service.classFull", "The class is fully booked.")
573
+ : "";
574
+ const courseSessions = service.type === "COURSE" ? entries.map((entry) => ({
575
+ date: shortDate(validDate(entry.slot!.startDate)!, true),
576
+ time: sessionTime(entry.slot!.startDate!, entry.slot!.endDate),
577
+ })) : [];
578
+ return derive({
579
+ ...s,
580
+ spotsText,
581
+ spotsWarning: count != null && count > 0 && count <= 3,
582
+ courseCtaClosed: courseClosed,
583
+ courseCtaFull: service.type === "COURSE" && count === 0 && !courseClosed,
584
+ courseCtaBook: service.type === "COURSE" && count !== 0 && !s.requestToBook && !courseClosed,
585
+ courseCtaRequest: service.type === "COURSE" && count !== 0 && s.requestToBook && !courseClosed,
586
+ courseSessionCount: service.type === "COURSE" && result.complete && courseSessions.length > 0
587
+ ? copyPlural("service.sessionsCount", "{n, plural, one {# session} other {# sessions}}", courseSessions.length) : "",
588
+ courseSessions: courseSessions.slice(0, 3),
589
+ courseMoreSessions: courseSessions.slice(3),
590
+ });
591
+ } catch {
592
+ // The record still loads if the availability endpoint is unavailable. Unknown
593
+ // capacity stays unknown; the customer can inspect availability at booking.
594
+ return s;
595
+ }
596
+ }
597
+
445
598
  // ---- State -----------------------------------------------------------------------
446
599
 
447
600
  function derive(s: S): S {
@@ -456,6 +609,8 @@ function derive(s: S): S {
456
609
  hasService,
457
610
  noEmpty: isLoading || s.errored || hasService,
458
611
  noCategory: !s.category,
612
+ noTypeTag: !s.typeTag,
613
+ noCourse: !s.isCourse,
459
614
  noTagline: !s.tagline,
460
615
  noPrice: !s.priceText,
461
616
  noPriceUnit: !s.priceUnit,
@@ -472,6 +627,13 @@ function derive(s: S): S {
472
627
  hasImage,
473
628
  noImage: !hasImage,
474
629
  noDuration: !s.durationText,
630
+ noCourseDates: !s.courseDates,
631
+ noSpots: !s.spotsText,
632
+ spotsNeutral: !s.spotsWarning,
633
+ hideSpotsNeutral: !s.spotsText || s.spotsWarning,
634
+ noCourseMoreSessions: s.courseMoreSessions.length === 0,
635
+ noCourseSessions: s.courseSessions.length === 0,
636
+ scheduleCollapsed: !s.scheduleExpanded,
475
637
  noLocations: s.locations.length === 0,
476
638
  noOnline: !s.online,
477
639
  noStaff: !s.staffText,
@@ -480,11 +642,17 @@ function derive(s: S): S {
480
642
  noPay: !s.payText,
481
643
  noPaySub: !s.paySub,
482
644
  notRequestToBook: !s.requestToBook,
645
+ hideChooseTime: s.isCourse || s.requestToBook,
646
+ hideRequestTime: s.isCourse || !s.requestToBook,
647
+ noCourseCtaBook: !s.courseCtaBook,
648
+ noCourseCtaRequest: !s.courseCtaRequest,
649
+ noCourseCtaFull: !s.courseCtaFull,
650
+ noCourseCtaClosed: !s.courseCtaClosed,
483
651
  notBookableOnline: !s.bookableOnline,
484
652
  noAbout: !s.about,
485
653
  noPolicy: s.policyLines.length === 0,
486
654
  policyCollapsed: !s.policyExpanded,
487
- noFacts: !s.durationText && s.locations.length === 0 && !s.online && !s.staffText && !s.payText,
655
+ noFacts: !s.courseDates && !s.durationText && !s.spotsText && s.locations.length === 0 && !s.online && !s.staffText && !s.payText,
488
656
  noKeyFacts: !s.priceText && !s.durationText && !s.category,
489
657
  hideContent: isLoading || s.errored || !hasService,
490
658
  hideFooter: isLoading || s.errored || !hasService || !s.bookableOnline,
@@ -500,6 +668,11 @@ function withService(s: S, service: WixService): S {
500
668
  const price = priceLine(service);
501
669
  const staff = staffLine(service);
502
670
  const pay = payLine(service);
671
+ const isCourse = service.type === "COURSE";
672
+ const isClass = service.type === "CLASS";
673
+ // Ended, started without late joining, or outside the booking window: the same rule the time
674
+ // screen applies before it books (_shared/himi-bookings.ts).
675
+ const courseCtaClosed = isCourse && !courseWindowOpen(service, Date.now());
503
676
  return derive({
504
677
  ...s,
505
678
  status: "ready",
@@ -509,12 +682,28 @@ function withService(s: S, service: WixService): S {
509
682
  // Live payloads carry `category` as a structured object — extract the name
510
683
  // (the list worker joins the same field; see _shared/himi-bookings.ts).
511
684
  category: categoryName(service.category),
685
+ typeTag: isClass ? copyParam("service.tagClass", "Class") : isCourse ? copyParam("service.tagCourse", "Course") : "",
686
+ isClass,
687
+ isCourse,
512
688
  tagline: stringOf(service.tagLine),
513
689
  priceText: price.text,
514
690
  priceUnit: price.unit,
515
691
  priceIsText: price.isText,
516
692
  images: serviceImages(service, name),
517
- durationText: durationText(service),
693
+ durationText: isCourse && durationText(service)
694
+ ? copyTemplate("service.durationEach", "{duration} each", { duration: durationText(service) })
695
+ : durationText(service),
696
+ courseDates: isCourse ? courseDates(service) : "",
697
+ courseSessionCount: "",
698
+ courseSessions: [],
699
+ courseMoreSessions: [],
700
+ spotsText: "",
701
+ spotsWarning: false,
702
+ scheduleExpanded: false,
703
+ courseCtaClosed,
704
+ courseCtaFull: false,
705
+ courseCtaBook: isCourse && !courseCtaClosed && service.onlineBooking?.requireManualApproval !== true,
706
+ courseCtaRequest: isCourse && !courseCtaClosed && service.onlineBooking?.requireManualApproval === true,
518
707
  locations: locationRows(service),
519
708
  online: service.conferencing?.enabled === true,
520
709
  staffText: staff.text,
@@ -525,7 +714,7 @@ function withService(s: S, service: WixService): S {
525
714
  requestToBook: service.onlineBooking?.requireManualApproval === true,
526
715
  bookableOnline: service.onlineBooking?.enabled !== false,
527
716
  about: plain(service.description),
528
- policyLines: policyLines(service.bookingPolicy),
717
+ policyLines: policyLines(service.bookingPolicy, stringOf(service.type).toUpperCase()),
529
718
  shareUrl: serviceShareUrl(service),
530
719
  // WS-C — canonical param-less target; the descriptor's choose-time CTA
531
720
  // binds the separate `id` state field as a first-class nav param.
@@ -554,6 +743,7 @@ const svc = defineService<S, {
554
743
  load: (s: S, arg?: unknown) => Promise<S>;
555
744
  share: (s: S) => Promise<S>;
556
745
  togglePolicy: (s: S) => S;
746
+ toggleSchedule: (s: S) => S;
557
747
  openDirections: (s: S, arg?: unknown) => Promise<S>;
558
748
  chooseTime: (s: S) => S;
559
749
  }>("himiBookingService", {
@@ -563,6 +753,11 @@ const svc = defineService<S, {
563
753
  name: "",
564
754
  category: "",
565
755
  noCategory: true,
756
+ typeTag: "",
757
+ noTypeTag: true,
758
+ isClass: false,
759
+ isCourse: false,
760
+ noCourse: true,
566
761
  tagline: "",
567
762
  noTagline: true,
568
763
  priceText: "",
@@ -583,6 +778,30 @@ const svc = defineService<S, {
583
778
  noImage: true,
584
779
  durationText: "",
585
780
  noDuration: true,
781
+ courseDates: "",
782
+ courseSessionCount: "",
783
+ noCourseDates: true,
784
+ spotsText: "",
785
+ noSpots: true,
786
+ spotsWarning: false,
787
+ spotsNeutral: true,
788
+ hideSpotsNeutral: true,
789
+ courseSessions: [],
790
+ noCourseSessions: true,
791
+ courseMoreSessions: [],
792
+ noCourseMoreSessions: true,
793
+ scheduleExpanded: false,
794
+ scheduleCollapsed: true,
795
+ courseCtaBook: false,
796
+ courseCtaRequest: false,
797
+ courseCtaFull: false,
798
+ courseCtaClosed: false,
799
+ hideChooseTime: false,
800
+ hideRequestTime: true,
801
+ noCourseCtaBook: true,
802
+ noCourseCtaRequest: true,
803
+ noCourseCtaFull: true,
804
+ noCourseCtaClosed: true,
586
805
  locations: [],
587
806
  noLocations: true,
588
807
  online: false,
@@ -625,10 +844,10 @@ const svc = defineService<S, {
625
844
  load: async (s, arg) => {
626
845
  stashCopy(arg);
627
846
  if (!s.id) return derive({ ...s, status: "ready", errored: true });
628
- const start = { ...s, policyExpanded: false };
847
+ const start = { ...s, policyExpanded: false, scheduleExpanded: false };
629
848
  try {
630
849
  const service = await fetchService(s.id);
631
- return service ? withService(start, service) : derive({ ...start, status: "ready" });
850
+ return service ? withGroupAvailability(withService(start, service), service) : derive({ ...start, status: "ready" });
632
851
  } catch {
633
852
  // Offline: paint from the last-synced services cache (written by the list
634
853
  // worker) before falling back to the error state. The cache holds the name,
@@ -662,10 +881,11 @@ const svc = defineService<S, {
662
881
  }
663
882
  },
664
883
  togglePolicy: (s) => derive({ ...s, policyExpanded: !s.policyExpanded }),
884
+ toggleSchedule: (s) => derive({ ...s, scheduleExpanded: !s.scheduleExpanded }),
665
885
  // The footer button opens the time screen for the loaded service. A worker directive,
666
886
  // not a `himi.open` with a `$service:` id param: the iOS and Android sticky footers
667
887
  // dispatch their taps without resolving `$service:` params, so the id never arrived.
668
- chooseTime: (s) => (s.id && s.hasService ? navigate(s, { screenId: "himi_time", args: { id: s.id } }) : s),
888
+ chooseTime: (s) => (s.id && s.hasService ? navigate(s, { screenId: "himi_time", args: { id: s.id, ...(s.isCourse ? { course: "true" } : {}) } }) : s),
669
889
  openDirections: async (s, arg) => {
670
890
  const url = s.locations[tappedIndex(arg)]?.directionsUrl ?? "";
671
891
  if (!url) return s;