@wix/himalaya-cli 0.1613.0 → 0.1615.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/ui/src/index.d.ts +53 -13
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +3 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/ui.mjs +31 -5
  4. package/dist/cli.mjs +1638 -1330
  5. package/dist/preview-assets.generated.bin +0 -0
  6. package/fixtures/wix-canned.json +4 -0
  7. package/guides/component-reference.json +1 -1
  8. package/guides/component-reference.md +3 -0
  9. package/guides/designing-himi-screens.md +4 -1
  10. package/guides/hhp.md +2 -2
  11. package/guides/sdk-reference.json +28 -4
  12. package/package.json +2 -2
  13. package/template-icons/salon-spa.png +0 -0
  14. package/templates/beauty-salon/tier5-src/_shared/branded-load-state.ts +5 -2
  15. package/templates/beauty-salon/tier5-src/_shared/himi-account.ts +31 -0
  16. package/templates/beauty-salon/tier5-src/_shared/himi-booking-saved.ts +88 -0
  17. package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +50 -3
  18. package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +70 -3
  19. package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +40 -8
  20. package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  21. package/templates/beauty-salon/tier5-src/himi-branded-lite-home/index.ts +178 -12
  22. package/templates/branded-lite/dev/index.ts +192 -0
  23. package/templates/branded-lite/l10n/en.json +1 -1
  24. package/templates/branded-lite/tier5-src/_shared/branded-load-state.ts +5 -2
  25. package/templates/branded-lite/tier5-src/_shared/himi-account.ts +31 -0
  26. package/templates/branded-lite/tier5-src/_shared/himi-booking-saved.ts +88 -0
  27. package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +50 -3
  28. package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +70 -3
  29. package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +40 -8
  30. package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  31. package/templates/branded-lite/tier5-src/himi-branded-lite-galleries/index.ts +104 -12
  32. package/templates/branded-lite/tier5-src/himi-branded-lite-home/index.ts +178 -12
  33. package/templates/community-club/tier5-src/_shared/branded-load-state.ts +5 -2
  34. package/templates/community-club/tier5-src/_shared/himi-account.ts +31 -0
  35. package/templates/community-club/tier5-src/himi-branded-lite-home/index.ts +178 -12
  36. package/templates/events-meetups/tier5-src/_shared/branded-load-state.ts +5 -2
  37. package/templates/events-meetups/tier5-src/_shared/himi-account.ts +31 -0
  38. package/templates/events-meetups/tier5-src/himi-branded-lite-home/index.ts +178 -12
  39. package/templates/gym-fitness/tier5-src/_shared/branded-load-state.ts +5 -2
  40. package/templates/gym-fitness/tier5-src/_shared/himi-account.ts +31 -0
  41. package/templates/gym-fitness/tier5-src/_shared/himi-booking-saved.ts +88 -0
  42. package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +50 -3
  43. package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +70 -3
  44. package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +40 -8
  45. package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  46. package/templates/gym-fitness/tier5-src/himi-branded-lite-home/index.ts +178 -12
  47. package/templates/nonprofit-cause/tier5-src/_shared/branded-load-state.ts +5 -2
  48. package/templates/nonprofit-cause/tier5-src/_shared/himi-account.ts +31 -0
  49. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-home/index.ts +178 -12
  50. package/templates/podcast-creator/tier5-src/_shared/branded-load-state.ts +5 -2
  51. package/templates/podcast-creator/tier5-src/_shared/himi-account.ts +31 -0
  52. package/templates/podcast-creator/tier5-src/himi-branded-lite-home/index.ts +178 -12
  53. package/templates/portfolio-creative/tier5-src/_shared/branded-load-state.ts +5 -2
  54. package/templates/portfolio-creative/tier5-src/_shared/himi-account.ts +31 -0
  55. package/templates/portfolio-creative/tier5-src/himi-branded-lite-home/index.ts +178 -12
  56. package/templates/restaurant/tier5-src/_shared/branded-load-state.ts +5 -2
  57. package/templates/restaurant/tier5-src/_shared/himi-account.ts +31 -0
  58. package/templates/restaurant/tier5-src/himi-branded-lite-home/index.ts +178 -12
  59. package/templates/retail-store/tier5-src/_shared/branded-load-state.ts +5 -2
  60. package/templates/retail-store/tier5-src/_shared/himi-account.ts +31 -0
  61. package/templates/retail-store/tier5-src/himi-branded-lite-home/index.ts +178 -12
  62. package/templates/salon-spa/README.md +53 -0
  63. package/templates/salon-spa/config.ts +272 -0
  64. package/templates/salon-spa/dev/index.ts +19893 -0
  65. package/templates/salon-spa/himalaya.content.json +49 -0
  66. package/templates/salon-spa/himalaya.template.json +14 -0
  67. package/templates/salon-spa/icon.png +0 -0
  68. package/templates/salon-spa/platforms.json +1 -0
  69. package/templates/salon-spa/tier5-src/_shared/app-locale.ts +61 -0
  70. package/templates/salon-spa/tier5-src/_shared/booking-location.ts +147 -0
  71. package/templates/salon-spa/tier5-src/_shared/booking-session.ts +222 -0
  72. package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +278 -0
  73. package/templates/salon-spa/tier5-src/_shared/branded-load-state.ts +109 -0
  74. package/templates/salon-spa/tier5-src/_shared/business-chat-copy.ts +40 -0
  75. package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +48 -0
  76. package/templates/salon-spa/tier5-src/_shared/business-chat-message-model.ts +177 -0
  77. package/templates/salon-spa/tier5-src/_shared/business-chat-model.ts +56 -0
  78. package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +581 -0
  79. package/templates/salon-spa/tier5-src/_shared/business-chat-transport.ts +148 -0
  80. package/templates/salon-spa/tier5-src/_shared/collection-contract.ts +230 -0
  81. package/templates/salon-spa/tier5-src/_shared/collection-query.ts +193 -0
  82. package/templates/salon-spa/tier5-src/_shared/collection-service.ts +400 -0
  83. package/templates/salon-spa/tier5-src/_shared/detail-presence.ts +51 -0
  84. package/templates/salon-spa/tier5-src/_shared/empty-flags.ts +33 -0
  85. package/templates/salon-spa/tier5-src/_shared/engine-intl.ts +508 -0
  86. package/templates/salon-spa/tier5-src/_shared/himi-account.ts +164 -0
  87. package/templates/salon-spa/tier5-src/_shared/himi-booking-saved.ts +88 -0
  88. package/templates/salon-spa/tier5-src/_shared/himi-bookings.ts +318 -0
  89. package/templates/salon-spa/tier5-src/_shared/himi-copy.ts +89 -0
  90. package/templates/salon-spa/tier5-src/_shared/himi-store.ts +7 -0
  91. package/templates/salon-spa/tier5-src/_shared/himi-text.ts +24 -0
  92. package/templates/salon-spa/tier5-src/_shared/host-events.ts +66 -0
  93. package/templates/salon-spa/tier5-src/_shared/hosted-checkout.ts +70 -0
  94. package/templates/salon-spa/tier5-src/_shared/iap-attendance-form.ts +106 -0
  95. package/templates/salon-spa/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
  96. package/templates/salon-spa/tier5-src/_shared/iap-attendance.ts +38 -0
  97. package/templates/salon-spa/tier5-src/_shared/iap-worker.ts +125 -0
  98. package/templates/salon-spa/tier5-src/_shared/local-preferences.ts +38 -0
  99. package/templates/salon-spa/tier5-src/_shared/member-access.ts +52 -0
  100. package/templates/salon-spa/tier5-src/_shared/member-account-routes.ts +52 -0
  101. package/templates/salon-spa/tier5-src/_shared/member-bookings.ts +94 -0
  102. package/templates/salon-spa/tier5-src/_shared/offset-page.ts +10 -0
  103. package/templates/salon-spa/tier5-src/_shared/online-actions.ts +76 -0
  104. package/templates/salon-spa/tier5-src/_shared/owner-load-error.ts +348 -0
  105. package/templates/salon-spa/tier5-src/_shared/payment-setup.ts +6 -0
  106. package/templates/salon-spa/tier5-src/_shared/rich-body.ts +78 -0
  107. package/templates/salon-spa/tier5-src/_shared/rich-content-link.ts +34 -0
  108. package/templates/salon-spa/tier5-src/_shared/weekly-goal.ts +107 -0
  109. package/templates/salon-spa/tier5-src/_shared/wix-service-price.ts +146 -0
  110. package/templates/salon-spa/tier5-src/business-chat/index.ts +669 -0
  111. package/templates/salon-spa/tier5-src/himi-booking-account/index.ts +241 -0
  112. package/templates/salon-spa/tier5-src/himi-booking-bookings/index.ts +466 -0
  113. package/templates/salon-spa/tier5-src/himi-booking-saved/index.ts +103 -0
  114. package/templates/salon-spa/tier5-src/himi-booking-service/index.ts +1170 -0
  115. package/templates/salon-spa/tier5-src/himi-booking-services/index.ts +791 -0
  116. package/templates/salon-spa/tier5-src/himi-booking-time/index.ts +1712 -0
  117. package/templates/salon-spa/tier5-src/himi-branded-lite-agenda/index.ts +950 -0
  118. package/templates/salon-spa/tier5-src/himi-branded-lite-galleries/index.ts +282 -0
  119. package/templates/salon-spa/tier5-src/himi-salon-home/index.ts +544 -0
  120. package/templates/salon-spa/tier5-src/himi-store-account/index.ts +925 -0
  121. package/templates/salon-spa/tokens.json +341 -0
  122. package/templates/yoga-studio/dev/index.ts +192 -0
  123. package/templates/yoga-studio/tier5-src/_shared/branded-load-state.ts +5 -2
  124. package/templates/yoga-studio/tier5-src/_shared/himi-account.ts +31 -0
  125. package/templates/yoga-studio/tier5-src/_shared/himi-booking-saved.ts +88 -0
  126. package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +50 -3
  127. package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +70 -3
  128. package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +40 -8
  129. package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  130. package/templates/yoga-studio/tier5-src/himi-branded-lite-home/index.ts +178 -12
@@ -0,0 +1,88 @@
1
+ // Saved (favorite) booking services use the host's durable, app-scoped KV, shared by every
2
+ // screen worker — the service detail toggles a row (himi-booking-service `toggleSaved`), the
3
+ // Favorites list reads them (himi-booking-saved). Same model as _shared/himi-store-saved.ts: a
4
+ // failed write must never claim that a save succeeded.
5
+ export interface SavedService {
6
+ id: string;
7
+ name: string;
8
+ /** The price line as the detail showed it ("$250"), kept so the list paints offline. */
9
+ price: string;
10
+ /** The duration line ("1 hr"). */
11
+ duration: string;
12
+ image: string;
13
+ category: string;
14
+ /** A course: the list's Book passes `course` to the time screen, which then refuses a course
15
+ * whose record failed to load instead of offering its sessions one by one. */
16
+ course?: boolean;
17
+ }
18
+ type StorageLike = { getItem(key: string): string | null; setItem(key: string, value: string): void };
19
+ type Host = { himalaya?: { storage?: StorageLike; input?: Record<string, unknown> }; localStorage?: StorageLike };
20
+ const BASE = "himi-booking:saved-services:v1";
21
+ function storage(): StorageLike {
22
+ const host = globalThis as Host;
23
+ const kv = host.himalaya?.storage ?? host.localStorage;
24
+ if (!kv) throw new Error("Saved-service storage is unavailable");
25
+ return kv;
26
+ }
27
+ const validPart = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_.:-]{1,128}$/.test(value) && value !== "default";
28
+ /** Saves belong to whoever made them. A signed-in member's live under the host's authenticated
29
+ * account + business — the partition collection preferences use, so a second member on the same
30
+ * device never sees them; a visitor's live on the device. A member's partition, read for the
31
+ * first time, takes the visitor's saves on this device (signing in carries them, like a cart),
32
+ * once — the device list is then emptied. */
33
+ function storeKey(kv: StorageLike): string {
34
+ const input = (globalThis as Host).himalaya?.input;
35
+ const account = input?.__himiPreferenceAccount, business = input?.__himiPreferenceBusiness;
36
+ if (!validPart(account) || !validPart(business)) return BASE;
37
+ const key = `${BASE}.${account}.${encodeURIComponent(business)}`;
38
+ if (kv.getItem(key) === null) {
39
+ const visitor = kv.getItem(BASE);
40
+ if (visitor !== null) {
41
+ kv.setItem(key, visitor);
42
+ // Empty the device list only once the member's copy is confirmed: a web host may swallow a
43
+ // failed write, and clearing then would lose the visitor's saves.
44
+ if (kv.getItem(key) === visitor) kv.setItem(BASE, "[]");
45
+ }
46
+ }
47
+ return key;
48
+ }
49
+ const str = (value: unknown): string => (typeof value === "string" ? value : "");
50
+ export function readSavedServices(): SavedService[] {
51
+ const kv = storage();
52
+ const raw = kv.getItem(storeKey(kv));
53
+ if (!raw) return [];
54
+ const parsed: unknown = JSON.parse(raw);
55
+ if (!Array.isArray(parsed)) throw new Error("Invalid saved-service data");
56
+ const rows = new Map<string, SavedService>();
57
+ for (const value of parsed) {
58
+ if (!value || typeof value !== "object") continue;
59
+ const row = value as Record<string, unknown>;
60
+ if (typeof row.id !== "string" || !row.id.trim() || typeof row.name !== "string") continue;
61
+ rows.set(row.id, { id: row.id, name: row.name, price: str(row.price), duration: str(row.duration), image: str(row.image), category: str(row.category), ...(row.course === true ? { course: true } : {}) });
62
+ }
63
+ return [...rows.values()];
64
+ }
65
+
66
+ export function isServiceSaved(id: string): boolean {
67
+ return readSavedServices().some(row => row.id === id);
68
+ }
69
+ export function writeSavedServices(rows: SavedService[]): void {
70
+ const kv = storage();
71
+ const key = storeKey(kv);
72
+ const value = JSON.stringify(rows);
73
+ kv.setItem(key, value);
74
+ // Web hosts may swallow quota/security errors. Confirm the synchronous write
75
+ // before displaying success; a missing/mismatched read is a failed save.
76
+ if (kv.getItem(key) !== value) throw new Error("Saved-service write did not persist");
77
+ }
78
+ /** Save or unsave; returns the new saved state. The newest save is listed first. */
79
+ export function toggleSavedService(service: SavedService): boolean {
80
+ if (!service.id.trim() || !service.name.trim()) throw new Error("No service to save");
81
+ const rows = readSavedServices();
82
+ const saved = !rows.some(row => row.id === service.id);
83
+ writeSavedServices(saved ? [service, ...rows] : rows.filter(row => row.id !== service.id));
84
+ return saved;
85
+ }
86
+ export function removeSavedService(id: string): void {
87
+ writeSavedServices(readSavedServices().filter(row => row.id !== id));
88
+ }
@@ -0,0 +1,318 @@
1
+ // Shared Wix Bookings shaping for himi workers (branded-lite `bookings`
2
+ // vertical): defensive extraction where the LIVE services payload drifts from
3
+ // the generated client's types. Used by BOTH the services-list worker
4
+ // (himi_services rows) and the service-detail worker so the two screens agree
5
+ // on one shape (the himi-restaurants.ts pattern).
6
+
7
+ /** Human-readable category name. The live services payload carries `category`
8
+ * as a STRUCTURED object (`{ id, name, sortOrder }`) while the generated
9
+ * client types it as a string — joined raw it rendered
10
+ * "[object Object] · 1 hr · $111" in the services rows (production sweep R3).
11
+ * Extract the name member; an id-only/unrecognizable shape yields "" so the
12
+ * caller omits the segment instead of stringifying the object. */
13
+ export function categoryName(value: unknown): string {
14
+ if (typeof value === "string") return value.trim();
15
+ if (!value || typeof value !== "object") return "";
16
+ const name = (value as { name?: unknown }).name;
17
+ return typeof name === "string" ? name.trim() : "";
18
+ }
19
+
20
+ import { businessTimeZone } from "./app-locale.js";
21
+ import { formatDateMedium, formatMonthDay, zoneOffsetAt } from "./engine-intl.js";
22
+ import { formatServiceMoney, formatServicePrice, serviceCurrency } from "./wix-service-price.js";
23
+
24
+ const HAS_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/i;
25
+ const LOCAL_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/;
26
+
27
+ /** The instant (ms) a slot starts. Wix may list a slot's `startDate` as a LOCAL wall time with its
28
+ * `timezone` beside it ("2026-11-02T18:30:00" in America/New_York is 23:30Z); a bare `Date.parse`
29
+ * would read that in the device's zone. So a local time is placed in the slot's own zone when this
30
+ * engine has Intl and knows the zone, and read in the device's zone only when it cannot be. */
31
+ export function slotStartInstant(slot: { startDate?: string; timezone?: unknown }): number {
32
+ const start = slot.startDate ?? "";
33
+ if (!start) return NaN;
34
+ const local = LOCAL_TIME.exec(start);
35
+ if (HAS_OFFSET.test(start) || !local || typeof slot.timezone !== "string" || !slot.timezone) return Date.parse(start);
36
+ const wall = Date.UTC(+local[1], +local[2] - 1, +local[3], +local[4], +local[5], +(local[6] ?? 0));
37
+ const offset = zoneOffsetAt(wall, slot.timezone);
38
+ if (!Number.isFinite(offset)) return Date.parse(start);
39
+ // Once more at the corrected instant, so a wall time beside a DST change lands on the right side.
40
+ const again = zoneOffsetAt(wall - offset, slot.timezone);
41
+ return wall - (Number.isFinite(again) ? again : offset);
42
+ }
43
+
44
+ /** `himi_time` reads the next two weeks. A host's `start` past them (an Agenda session weeks
45
+ * ahead) is read on its own — that session's local day only, never everything up to it — so it
46
+ * can be preselected while each read stays bounded. `undefined` when the start is inside the two
47
+ * weeks. */
48
+ export function availabilityBeyond(now: Date, start = ""): { from: Date; until: Date } | undefined {
49
+ const end = now.getTime() + 14 * 86_400_000;
50
+ const at = start ? Date.parse(start) : NaN;
51
+ if (!Number.isFinite(at) || at < end) return undefined;
52
+ const d = new Date(at);
53
+ const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate());
54
+ // Never back into the two weeks already read: a slot in both would be listed twice.
55
+ return { from: new Date(Math.max(dayStart.getTime(), end)), until: new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1) };
56
+ }
57
+
58
+ /** The slot a host asked to open at (`himi_time` input `start`, an ISO 8601 instant): the id of the
59
+ * slot that starts at that same instant, or "" when none does (taken since, or out of the window).
60
+ * Compared as instants, so `…T16:30:00Z` finds a slot listed as `…T18:30:00+02:00`, or as a local
61
+ * `…T18:30:00` beside `timezone: "Asia/Jerusalem"`. */
62
+ export function slotIdAt(slots: Iterable<[string, { startDate?: string; timezone?: unknown }]>, start: string): string {
63
+ const at = start ? Date.parse(start) : NaN;
64
+ if (!Number.isFinite(at)) return "";
65
+ for (const [id, slot] of slots) {
66
+ if (slotStartInstant(slot) === at) return id;
67
+ }
68
+ return "";
69
+ }
70
+
71
+ // ---- Courses ----------------------------------------------------------------------
72
+ // One answer for the service screen's course button and the time screen's course booking, so
73
+ // the button never offers a course that the booking step then refuses.
74
+
75
+ type CoursePolicyShape = {
76
+ /** `timeZone`: the business's IANA zone, for a session time Wix lists as a bare local wall time. */
77
+ schedule?: { id?: string; firstSessionStart?: string; lastSessionEnd?: string; timeZone?: string };
78
+ bookingPolicy?: {
79
+ bookAfterStartPolicy?: { enabled?: boolean };
80
+ limitEarlyBookingPolicy?: { enabled?: boolean; earliestBookingInMinutes?: number };
81
+ limitLateBookingPolicy?: { enabled?: boolean; latestBookingInMinutes?: number };
82
+ };
83
+ };
84
+
85
+ /** A bare local wall time placed in `timeZone`, as an instant (ms). NaN when the value is not a bare local
86
+ * time, has an impossible calendar day, or the engine does not know the zone. */
87
+ function placedInstant(value: string, timeZone: string | undefined): number {
88
+ const local = LOCAL_TIME.exec(value);
89
+ if (HAS_OFFSET.test(value) || !local || !timeZone || !validDate(value)) return NaN;
90
+ const wall = Date.UTC(+local[1], +local[2] - 1, +local[3], +local[4], +local[5], +(local[6] ?? 0));
91
+ const offset = zoneOffsetAt(wall, timeZone);
92
+ if (!Number.isFinite(offset)) return NaN;
93
+ // Once more at the corrected instant, so a wall time beside a DST change lands on the right side.
94
+ const again = zoneOffsetAt(wall - offset, timeZone);
95
+ return wall - (Number.isFinite(again) ? again : offset);
96
+ }
97
+
98
+ /** The zone that places a course's bare local session times: the schedule's own, else the app's
99
+ * provisioned business zone. "" when there is none: a zone is never guessed. */
100
+ const courseZone = (schedule: { timeZone?: string } | undefined): string => schedule?.timeZone || businessTimeZone();
101
+
102
+ /** The instant (ms) of a course session time, for the "Ended" label. A time with an offset is exact. A
103
+ * bare local wall time is the BUSINESS's wall time, so it is placed in its zone — never in the device's,
104
+ * which would present another zone's clock as a confirmed instant. No zone, a date-only value, an
105
+ * impossible calendar day or an engine that does not know the zone: unknown (NaN). */
106
+ function sessionInstant(value: string, timeZone: string | undefined): number {
107
+ return HAS_OFFSET.test(value) ? validDate(value)?.getTime() ?? NaN : placedInstant(value, timeZone);
108
+ }
109
+
110
+ /** The same instant for the booking rules (window, read range): a bare local time the business zone can
111
+ * place is placed there, so the list label, the service screen and the time screen agree. Everything else
112
+ * keeps the read they always had (`Date.parse`), so an app with no provisioned zone is unchanged. */
113
+ function courseInstant(value: string | undefined, timeZone: string | undefined): number {
114
+ const text = value ?? "";
115
+ const placed = placedInstant(text, timeZone);
116
+ return Number.isFinite(placed) ? placed : Date.parse(text);
117
+ }
118
+
119
+ /** A completed course, independent of whether bookings are currently allowed. Missing,
120
+ * invalid or reversed dates are unknown, never evidence that a course has ended. A session time
121
+ * without an offset counts only when the schedule names the business's `timeZone`. */
122
+ export function courseHasEnded(service: CoursePolicyShape, now: number): boolean {
123
+ const zone = courseZone(service.schedule);
124
+ const first = sessionInstant(service.schedule?.firstSessionStart ?? "", zone);
125
+ const last = sessionInstant(service.schedule?.lastSessionEnd ?? "", zone);
126
+ return Number.isFinite(now) && Number.isFinite(first) && Number.isFinite(last) && first <= last && last <= now;
127
+ }
128
+
129
+ /** Does this course schedule hold a session time that is a bare local wall time, which only the
130
+ * business's zone can place? A caller reads the zone only when this is true. */
131
+ export function courseNeedsZone(schedule: { firstSessionStart?: string; lastSessionEnd?: string } | undefined): boolean {
132
+ return [schedule?.firstSessionStart, schedule?.lastSessionEnd].some((value) => !!value && !HAS_OFFSET.test(value) && LOCAL_TIME.test(value));
133
+ }
134
+
135
+ /** Whether the business takes course bookings at `now` (ms): the course has not ended, has not
136
+ * started (unless the policy lets people join late), and `now` is inside its booking window —
137
+ * which the early and late limits count from the first session. */
138
+ export function courseWindowOpen(service: CoursePolicyShape, now: number): boolean {
139
+ const zone = courseZone(service.schedule);
140
+ const first = courseInstant(service.schedule?.firstSessionStart, zone);
141
+ const last = courseInstant(service.schedule?.lastSessionEnd, zone);
142
+ if (!Number.isFinite(first) || !Number.isFinite(last) || last <= now) return false;
143
+ const policy = service.bookingPolicy;
144
+ if (first < now && policy?.bookAfterStartPolicy?.enabled !== true) return false;
145
+ const early = policy?.limitEarlyBookingPolicy;
146
+ if (early?.enabled && typeof early.earliestBookingInMinutes === "number" && now < first - early.earliestBookingInMinutes * 60_000) return false;
147
+ const late = policy?.limitLateBookingPolicy;
148
+ if (late?.enabled && typeof late.latestBookingInMinutes === "number" && now > first - late.latestBookingInMinutes * 60_000) return false;
149
+ return true;
150
+ }
151
+
152
+ /** How far ahead a course is read: the whole remaining course, up to a year. Both screens read the
153
+ * same sessions — from the later of its first session and now — so the service screen never
154
+ * offers a course that the booking step then refuses on a session the first did not read. */
155
+ export const COURSE_READ_DAYS = 366;
156
+ const DAY = 86_400_000;
157
+
158
+ /** The 14-day availability windows covering a course's remaining sessions, and whether they reach
159
+ * its last session (`complete`) and start at its first (`fromFirst`, so a count is the whole
160
+ * course). Empty when the course has no usable dates or has ended. */
161
+ export function courseReadWindows(service: CoursePolicyShape, now: number, chunkDays = 14): { windows: Array<[number, number]>; complete: boolean; fromFirst: boolean } {
162
+ const zone = courseZone(service.schedule);
163
+ const first = courseInstant(service.schedule?.firstSessionStart, zone);
164
+ const last = courseInstant(service.schedule?.lastSessionEnd, zone);
165
+ if (!Number.isFinite(first) || !Number.isFinite(last)) return { windows: [], complete: false, fromFirst: false };
166
+ const from = Math.max(first, now);
167
+ const courseEnd = last + DAY;
168
+ const until = Math.min(courseEnd, from + COURSE_READ_DAYS * DAY);
169
+ const windows: Array<[number, number]> = [];
170
+ for (let at = from; at < until; at += chunkDays * DAY) windows.push([at, Math.min(until, at + chunkDays * DAY)]);
171
+ return { windows, complete: courseEnd <= until, fromFirst: from === first };
172
+ }
173
+
174
+ type CourseEntryShape = { slot?: { startDate?: string; timezone?: unknown }; bookable?: boolean; openSpots?: number };
175
+
176
+ /** A course across EVERY upcoming session read: a customer books all of them at once, so the
177
+ * smallest open count is the course's, and one session that cannot be booked closes it. A start
178
+ * is read in the session's own zone (`slotStartInstant`). `found` is false when no upcoming
179
+ * session came back: both screens then treat the course as not bookable. So is a course whose
180
+ * read did not reach its last session (`readComplete`, from `courseReadWindows`): a session
181
+ * past the year read may be full, and the course is booked whole. */
182
+ export function courseSessionsAvailability(entries: readonly CourseEntryShape[], now: number, readComplete = true): { found: boolean; spots: number | undefined; bookable: boolean } {
183
+ const upcoming = entries.filter((entry) => {
184
+ const start = entry.slot ? slotStartInstant(entry.slot as { startDate?: string; timezone?: unknown }) : NaN;
185
+ return Number.isFinite(start) && start >= now;
186
+ });
187
+ if (!upcoming.length) return { found: false, spots: undefined, bookable: false };
188
+ const counts = upcoming.map((entry) => entry.openSpots).filter((n): n is number => typeof n === "number" && Number.isFinite(n) && n >= 0);
189
+ const spots = counts.length ? Math.min(...counts) : undefined;
190
+ return { found: true, spots, bookable: readComplete && upcoming.every((entry) => entry.bookable !== false) && spots !== 0 };
191
+ }
192
+
193
+ // ---- Price, duration and course dates ------------------------------------------------
194
+ // One answer for the services list and the service screen, so a row and the screen it opens
195
+ // say the same price and the same length. Each screen reads its own copy keys: the caller
196
+ // passes a `BookingLabel` reader bound to its prefix ("service." on the service screen,
197
+ // "services." on the list).
198
+
199
+ /** Reads one copy line by its key suffix ("priceFree", "days"). `values` fills {placeholders}. */
200
+ export type BookingLabel = (key: string, fallback: string, values?: Record<string, string | number>) => string;
201
+
202
+ type PricedServiceShape = {
203
+ type?: string;
204
+ duration?: unknown;
205
+ payment?: {
206
+ rateType?: unknown;
207
+ fixed?: { price?: unknown };
208
+ varied?: { minPrice?: unknown; defaultPrice?: unknown };
209
+ custom?: { description?: unknown };
210
+ };
211
+ schedule?: {
212
+ firstSessionStart?: string;
213
+ lastSessionEnd?: string;
214
+ availabilityConstraints?: {
215
+ sessionDurations?: unknown[];
216
+ durations?: Array<{ minutes?: unknown } | undefined>;
217
+ durationRange?: {
218
+ hourOptions?: { minDurationInMinutes?: unknown; maxDurationInMinutes?: unknown };
219
+ dayOptions?: { minDurationInDays?: unknown; maxDurationInDays?: unknown };
220
+ };
221
+ };
222
+ };
223
+ };
224
+
225
+ function trimmed(value: unknown): string {
226
+ return typeof value === "string" ? value.trim() : "";
227
+ }
228
+
229
+ function finiteNumber(value: unknown): number | undefined {
230
+ if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
231
+ if (typeof value === "string" && value.trim()) {
232
+ const n = Number(value);
233
+ return Number.isNaN(n) ? undefined : n;
234
+ }
235
+ return undefined;
236
+ }
237
+
238
+ export interface ServicePriceLine { text: string; unit: string; isText: boolean }
239
+
240
+ /** The price line by rate type. FIXED → the price, no cents on a whole amount; VARIED →
241
+ * "From {price}"; CUSTOM → the owner's description, no unit; NO_FEE → "Free", no unit. */
242
+ export function servicePriceLine(service: PricedServiceShape, label: BookingLabel): ServicePriceLine {
243
+ const payment = service.payment ?? {};
244
+ const rate = trimmed(payment.rateType).toUpperCase();
245
+ const currency = serviceCurrency(service);
246
+ const unit = service.type === "COURSE"
247
+ ? label("unitParticipant", "per participant")
248
+ : label("unitSession", "per session");
249
+ if (rate === "NO_FEE") return { text: label("priceFree", "Free"), unit: "", isText: false };
250
+ if (rate === "CUSTOM") return { text: trimmed(payment.custom?.description), unit: "", isText: true };
251
+ if (rate === "VARIED") {
252
+ const from = formatServiceMoney(payment.varied?.minPrice, currency) || formatServiceMoney(payment.varied?.defaultPrice, currency);
253
+ return { text: from ? label("priceFrom", "From {price}", { price: from }) : "", unit: from ? unit : "", isText: false };
254
+ }
255
+ // FIXED, or a legacy payload with no rate type.
256
+ const text = formatServiceMoney(payment.fixed?.price, currency) || formatServicePrice(service);
257
+ return { text, unit: text ? unit : "", isText: false };
258
+ }
259
+
260
+ function minutesText(minutes: number): string {
261
+ if (minutes < 60) return `${minutes} min`;
262
+ const hours = Math.floor(minutes / 60);
263
+ const rest = minutes % 60;
264
+ return rest ? `${hours} hr ${rest} min` : `${hours} hr`;
265
+ }
266
+
267
+ /** "45 min", "1 hr 30 min"; a service with several lengths, or a flexible range the
268
+ * customer picks from: "30 min - 1 hr", "1 day - 3 days". "" when the record has none. */
269
+ export function serviceDurationText(service: PricedServiceShape, label: BookingLabel): string {
270
+ const constraints = service.schedule?.availabilityConstraints;
271
+ const positive = (values: unknown[]) => values.map(finiteNumber).filter((n): n is number => n != null && n > 0);
272
+ const listed = positive([
273
+ ...(constraints?.sessionDurations ?? []),
274
+ ...(constraints?.durations ?? []).map((d) => d?.minutes),
275
+ ]);
276
+ const hours = constraints?.durationRange?.hourOptions;
277
+ const range = positive([hours?.minDurationInMinutes, hours?.maxDurationInMinutes]);
278
+ const days = constraints?.durationRange?.dayOptions;
279
+ const dayRange = positive([days?.minDurationInDays, days?.maxDurationInDays]);
280
+ if (!listed.length && !range.length && dayRange.length) {
281
+ const dayText = (n: number) => (n === 1 ? label("day", "1 day") : label("days", "{n} days", { n }));
282
+ const first = Math.min(...dayRange);
283
+ const last = Math.max(...dayRange);
284
+ return first === last ? dayText(first) : `${dayText(first)} - ${dayText(last)}`;
285
+ }
286
+ const minutes = listed.length ? listed : range.length ? range : positive([service.duration]);
287
+ if (!minutes.length) return "";
288
+ const min = Math.min(...minutes);
289
+ const max = Math.max(...minutes);
290
+ return min === max ? minutesText(min) : `${minutesText(min)} - ${minutesText(max)}`;
291
+ }
292
+
293
+ function validDate(value: string | undefined): Date | undefined {
294
+ if (!value) return undefined;
295
+ const parts = /^(\d{4})-(\d{2})-(\d{2})(?:T|$)/i.exec(value);
296
+ if (!parts) return undefined;
297
+ const year = Number(parts[1]), month = Number(parts[2]) - 1, day = Number(parts[3]);
298
+ // Date accepts February 30 by rolling into March. Check the stated calendar day first;
299
+ // setUTCFullYear also preserves years 0–99, which Date.UTC would reinterpret as 1900–1999.
300
+ const calendar = new Date(0);
301
+ calendar.setUTCFullYear(year, month, day);
302
+ if (calendar.getUTCFullYear() !== year || calendar.getUTCMonth() !== month || calendar.getUTCDate() !== day) return undefined;
303
+ const date = new Date(value);
304
+ return Number.isFinite(date.getTime()) ? date : undefined;
305
+ }
306
+
307
+ function shortDate(date: Date, year: boolean): string {
308
+ return year ? formatDateMedium(date, "en") : formatMonthDay(date, "en");
309
+ }
310
+
311
+ /** A course's dates, "Nov 3 – Dec 15, 2026" (the year on the first date only when the two
312
+ * differ). "" when the record has no first or last session. */
313
+ export function courseDatesText(service: PricedServiceShape): string {
314
+ const first = validDate(service.schedule?.firstSessionStart);
315
+ const last = validDate(service.schedule?.lastSessionEnd);
316
+ if (!first || !last) return "";
317
+ return `${shortDate(first, first.getFullYear() !== last.getFullYear())} – ${shortDate(last, true)}`;
318
+ }
@@ -0,0 +1,89 @@
1
+ // Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
2
+ // §"Copy is configuration"): a worker never hardcodes user-facing copy — the
3
+ // descriptor bakes the provisioned copy-pack overrides relevant to this worker
4
+ // into its onAppear action params as a `copy` JSON object (customer strings or
5
+ // source-matched stock translations, computed at descriptor-build time), and the
6
+ // worker reads every user-facing string through `copyParam(key, fallback)`.
7
+ //
8
+ // STASHED (the stashed appear-params idiom, himi-branded-lite-home): the
9
+ // param-carrying onAppear dispatch updates the stash; later arg-less calls
10
+ // (self-kick refreshes, retry buttons, pull-to-refresh) fall back to it, so
11
+ // resolution order can never drop the pack. With no pack the param is absent
12
+ // and every read returns `fallback` verbatim — routing a worker string through
13
+ // copyParam is byte-for-byte behavior-preserving.
14
+ //
15
+ // Pure + Hermes-safe (no host globals, no Node APIs; its only import is the pure `app-locale` sibling): bundled into
16
+ // every worker that emits user-facing copy, and copied verbatim into the
17
+ // generated templates' tier5-src/_shared/.
18
+
19
+ import { isSingular, setAppLocale, setBusinessTimeZone } from "./app-locale.js";
20
+
21
+ /** Customer strings override every read; translated stock is valid only for its
22
+ * original English fallback. Some existing keys serve more than one message. */
23
+ export type CopyPackValue = string | { source: string; translation: string };
24
+
25
+ let stashedCopy: Record<string, CopyPackValue> = Object.create(null);
26
+ let copyReceived = false;
27
+
28
+ /** Absorb the `copy` onAppear param (customer strings or stock translation records)
29
+ * from an action arg. Call first in every action that can receive onAppear
30
+ * params (load/refresh). Non-copy args and malformed packs are ignored — the
31
+ * stock fallbacks keep rendering. */
32
+ export function stashCopy(arg: unknown): void {
33
+ if (!arg || typeof arg !== "object") return;
34
+ // A param-carrying dispatch names the app language and business zone too (absent: the English house
35
+ // pattern, and no zone).
36
+ if ("copy" in arg || "locale" in arg || "businessTimeZone" in arg) {
37
+ setAppLocale((arg as Record<string, unknown>).locale);
38
+ setBusinessTimeZone((arg as Record<string, unknown>).businessTimeZone);
39
+ }
40
+ const raw = (arg as Record<string, unknown>).copy;
41
+ if (typeof raw !== "string" || !raw) return;
42
+ try {
43
+ const parsed = JSON.parse(raw) as unknown;
44
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
45
+ const next: Record<string, CopyPackValue> = Object.create(null);
46
+ for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
47
+ if (typeof value === "string") next[key] = value;
48
+ else if (value && typeof value === "object" && !Array.isArray(value)) {
49
+ const row = value as Record<string, unknown>;
50
+ if (typeof row.source === "string" && typeof row.translation === "string") {
51
+ next[key] = { source: row.source, translation: row.translation };
52
+ }
53
+ }
54
+ }
55
+ stashedCopy = next;
56
+ copyReceived = true;
57
+ } catch {
58
+ // Malformed pack param — keep the stock copy rather than dying mid-action.
59
+ }
60
+ }
61
+
62
+ /** The provisioned override for `key`, or `fallback` (the stock copy). An optional
63
+ * restored label survives only until a current copy pack arrives. */
64
+ export function copyParam(key: string, fallback: string, restoredFallback = fallback): string {
65
+ const value = stashedCopy[key];
66
+ // A disk-restored row may paint before onAppear supplies its copy pack. Keep its
67
+ // last label only before a pack arrives; even an empty current pack selects stock copy.
68
+ if (value === undefined) return copyReceived ? fallback : restoredFallback;
69
+ if (typeof value === "string") return value !== "" ? value : fallback;
70
+ return value?.source === fallback && value.translation !== "" ? value.translation : fallback;
71
+ }
72
+
73
+ /** Interpolate `{name}` placeholders in a (possibly overridden) template:
74
+ * `copyTemplate("plans.joinOnSite", "…join {name}.", { name })`. Unknown
75
+ * placeholders resolve to "" so a typo'd override degrades, never crashes. */
76
+ export function copyTemplate(key: string, fallback: string, values: Record<string, string | number>): string {
77
+ return copyParam(key, fallback).replace(/\{([A-Za-z0-9_]+)\}/g, (_all, name: string) =>
78
+ name in values ? String(values[name]) : "",
79
+ );
80
+ }
81
+
82
+ /** The small ICU plural form used by Bookings spot counts. The provisioned copy may
83
+ * translate both branches without adding a second key for the singular. */
84
+ export function copyPlural(key: string, fallback: string, count: number): string {
85
+ const pattern = copyParam(key, fallback);
86
+ const match = /^\{n, plural, one \{([^{}]*)\} other \{([^{}]*)\}\}$/.exec(pattern);
87
+ if (!match) return copyTemplate(key, fallback, { n: count });
88
+ return (isSingular(count) ? match[1] : match[2])!.replace(/#/g, String(count));
89
+ }
@@ -0,0 +1,7 @@
1
+ // Graduated to the SDK (SP-3): the store display helpers + v3 product query now live in
2
+ // @himalaya/wix-stores. This thin re-export keeps existing bundle imports working; new
3
+ // code should import from "@himalaya/wix-stores" directly.
4
+ // Relative so node (tests importing worker sources) resolves it too — the bare
5
+ // "@himalaya/wix-stores" name exists only in the esbuild alias map / the off-repo
6
+ // umbrella. gen-templates.mts rewrites this back to @himalaya/wix-stores off-repo.
7
+ export * from "@himalaya/wix-stores";
@@ -0,0 +1,24 @@
1
+ // Shared display-text helpers for himi cards: trim catalog artifacts from titles and
2
+ // derive up-to-two-letter placeholder initials so same-first-letter lists still vary.
3
+
4
+ /** Trim trailing artifact markers (stray asterisks / footnote daggers) and whitespace
5
+ * that leak from test/seed catalogs, without touching interior punctuation. Falls back
6
+ * to the original when trimming would empty the string. */
7
+ export function cleanTitle(value?: string): string {
8
+ const raw = (value ?? "").trim();
9
+ const cleaned = raw.replace(/[\s*†‡]+$/u, "").trim();
10
+ return cleaned || raw;
11
+ }
12
+
13
+ /** Up to two significant initials for a placeholder avatar. Two-plus words → first
14
+ * letter of the first two; a single word → its first two alphanumerics. Keeps a list
15
+ * of similar names (e.g. many "Booking…") from collapsing to one identical glyph. */
16
+ export function initials(value?: string): string {
17
+ const words = (value ?? "").trim().split(/\s+/).filter(Boolean);
18
+ if (words.length === 0) return "#";
19
+ if (words.length === 1) {
20
+ const alnum = words[0].replace(/[^\p{L}\p{N}]/gu, "");
21
+ return (alnum.slice(0, 2) || "#").toUpperCase();
22
+ }
23
+ return ((words[0][0] ?? "") + (words[1][0] ?? "")).toUpperCase();
24
+ }
@@ -0,0 +1,66 @@
1
+ // Host events — what a member screen tells the app hosting it about something the member did
2
+ // (a booking confirmed or cancelled), so a host such as Symphony can say so in its chat and refresh
3
+ // what it shows.
4
+ //
5
+ // A worker dispatches `host.event` with `{ type, ...fields }` (string values only: a dispatch carries
6
+ // one string params map). An embed (`HimalayaEmbed`) hands it to the host's `onAction` as an action
7
+ // with that id; anywhere else nothing handles it and it is dropped. It is a notice, never a request:
8
+ // the host owns what happens next, and the worker never waits on it or depends on its answer.
9
+ //
10
+ // The contract (hosts parse exactly this — Symphony's `PlaceHostEvent`):
11
+ // type booking.created | booking.cancelled | plan.used
12
+ // serviceName the class / service title (bookings) planName the plan's name (plan.used)
13
+ // start, end ISO 8601 instants WITH an offset (`Z` or `+02:00`); a local time is left out
14
+ // remaining sessions left on the plan, when known (a whole number)
15
+ // bookingId, serviceId, planId when known
16
+
17
+ import { dispatch } from "@himalaya/dispatch";
18
+
19
+ export const HOST_EVENT_ACTION_ID = "host.event";
20
+
21
+ /** The events a member screen reports. */
22
+ export type HostEventType = "booking.created" | "booking.cancelled" | "plan.used";
23
+
24
+ export interface HostEventFields {
25
+ bookingId?: string;
26
+ serviceId?: string;
27
+ serviceName?: string;
28
+ planId?: string;
29
+ planName?: string;
30
+ start?: string;
31
+ end?: string;
32
+ remaining?: number | string;
33
+ }
34
+
35
+ const WITH_OFFSET = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})$/i;
36
+
37
+ /** [value] when it is an ISO 8601 instant that names its offset, else "". A slot's LOCAL wall time
38
+ * ("2026-11-02T18:30:00") would be read in the host's zone, i.e. a wrong hour, so it is left out. */
39
+ export function hostEventInstant(value: unknown): string {
40
+ return typeof value === "string" && WITH_OFFSET.test(value) && Number.isFinite(Date.parse(value)) ? value : "";
41
+ }
42
+
43
+ /** The `host.event` params for [type] and [fields]: unset and empty fields are left out, times
44
+ * without an offset are left out, and `remaining` is a whole number ≥ 0 or nothing. */
45
+ export function hostEventParams(type: HostEventType, fields: HostEventFields = {}): Record<string, string> {
46
+ const params: Record<string, string> = { type };
47
+ for (const [key, value] of Object.entries(fields)) {
48
+ let out = "";
49
+ if (key === "start" || key === "end") out = hostEventInstant(value);
50
+ else if (key === "remaining") {
51
+ const n = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
52
+ out = Number.isInteger(n) && n >= 0 ? String(n) : "";
53
+ } else if (typeof value === "string") out = value.trim();
54
+ if (out) params[key] = out;
55
+ }
56
+ return params;
57
+ }
58
+
59
+ /** Tell the host about [type]. Never throws: a host that does not listen must not break the screen. */
60
+ export async function emitHostEvent(type: HostEventType, fields: HostEventFields = {}): Promise<void> {
61
+ try {
62
+ await dispatch(HOST_EVENT_ACTION_ID, hostEventParams(type, fields));
63
+ } catch {
64
+ // Not embedded, or the host has no handler: nothing to tell.
65
+ }
66
+ }