@wix/himalaya-cli 0.1614.0 → 0.1616.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 (106) hide show
  1. package/dist/cli.mjs +2 -0
  2. package/package.json +2 -2
  3. package/template-icons/salon-spa.png +0 -0
  4. package/templates/beauty-salon/tier5-src/_shared/branded-load-state.ts +5 -2
  5. package/templates/beauty-salon/tier5-src/_shared/himi-account.ts +31 -0
  6. package/templates/beauty-salon/tier5-src/_shared/himi-booking-saved.ts +88 -0
  7. package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +50 -3
  8. package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +70 -3
  9. package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +40 -8
  10. package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  11. package/templates/branded-lite/tier5-src/_shared/branded-load-state.ts +5 -2
  12. package/templates/branded-lite/tier5-src/_shared/himi-account.ts +31 -0
  13. package/templates/branded-lite/tier5-src/_shared/himi-booking-saved.ts +88 -0
  14. package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +50 -3
  15. package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +70 -3
  16. package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +40 -8
  17. package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  18. package/templates/branded-lite/tier5-src/himi-branded-lite-galleries/index.ts +104 -12
  19. package/templates/community-club/tier5-src/_shared/branded-load-state.ts +5 -2
  20. package/templates/community-club/tier5-src/_shared/himi-account.ts +31 -0
  21. package/templates/events-meetups/tier5-src/_shared/branded-load-state.ts +5 -2
  22. package/templates/events-meetups/tier5-src/_shared/himi-account.ts +31 -0
  23. package/templates/gym-fitness/tier5-src/_shared/branded-load-state.ts +5 -2
  24. package/templates/gym-fitness/tier5-src/_shared/himi-account.ts +31 -0
  25. package/templates/gym-fitness/tier5-src/_shared/himi-booking-saved.ts +88 -0
  26. package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +50 -3
  27. package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +70 -3
  28. package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +40 -8
  29. package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  30. package/templates/nonprofit-cause/tier5-src/_shared/branded-load-state.ts +5 -2
  31. package/templates/nonprofit-cause/tier5-src/_shared/himi-account.ts +31 -0
  32. package/templates/podcast-creator/tier5-src/_shared/branded-load-state.ts +5 -2
  33. package/templates/podcast-creator/tier5-src/_shared/himi-account.ts +31 -0
  34. package/templates/portfolio-creative/tier5-src/_shared/branded-load-state.ts +5 -2
  35. package/templates/portfolio-creative/tier5-src/_shared/himi-account.ts +31 -0
  36. package/templates/restaurant/tier5-src/_shared/branded-load-state.ts +5 -2
  37. package/templates/restaurant/tier5-src/_shared/himi-account.ts +31 -0
  38. package/templates/retail-store/tier5-src/_shared/branded-load-state.ts +5 -2
  39. package/templates/retail-store/tier5-src/_shared/himi-account.ts +31 -0
  40. package/templates/salon-spa/README.md +53 -0
  41. package/templates/salon-spa/config.ts +272 -0
  42. package/templates/salon-spa/dev/index.ts +19893 -0
  43. package/templates/salon-spa/himalaya.content.json +49 -0
  44. package/templates/salon-spa/himalaya.template.json +14 -0
  45. package/templates/salon-spa/icon.png +0 -0
  46. package/templates/salon-spa/platforms.json +1 -0
  47. package/templates/salon-spa/tier5-src/_shared/app-locale.ts +61 -0
  48. package/templates/salon-spa/tier5-src/_shared/booking-location.ts +147 -0
  49. package/templates/salon-spa/tier5-src/_shared/booking-session.ts +222 -0
  50. package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +278 -0
  51. package/templates/salon-spa/tier5-src/_shared/branded-load-state.ts +109 -0
  52. package/templates/salon-spa/tier5-src/_shared/business-chat-copy.ts +40 -0
  53. package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +48 -0
  54. package/templates/salon-spa/tier5-src/_shared/business-chat-message-model.ts +177 -0
  55. package/templates/salon-spa/tier5-src/_shared/business-chat-model.ts +56 -0
  56. package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +581 -0
  57. package/templates/salon-spa/tier5-src/_shared/business-chat-transport.ts +148 -0
  58. package/templates/salon-spa/tier5-src/_shared/collection-contract.ts +230 -0
  59. package/templates/salon-spa/tier5-src/_shared/collection-query.ts +193 -0
  60. package/templates/salon-spa/tier5-src/_shared/collection-service.ts +400 -0
  61. package/templates/salon-spa/tier5-src/_shared/detail-presence.ts +51 -0
  62. package/templates/salon-spa/tier5-src/_shared/empty-flags.ts +33 -0
  63. package/templates/salon-spa/tier5-src/_shared/engine-intl.ts +508 -0
  64. package/templates/salon-spa/tier5-src/_shared/himi-account.ts +164 -0
  65. package/templates/salon-spa/tier5-src/_shared/himi-booking-saved.ts +88 -0
  66. package/templates/salon-spa/tier5-src/_shared/himi-bookings.ts +318 -0
  67. package/templates/salon-spa/tier5-src/_shared/himi-copy.ts +89 -0
  68. package/templates/salon-spa/tier5-src/_shared/himi-store.ts +7 -0
  69. package/templates/salon-spa/tier5-src/_shared/himi-text.ts +24 -0
  70. package/templates/salon-spa/tier5-src/_shared/host-events.ts +66 -0
  71. package/templates/salon-spa/tier5-src/_shared/hosted-checkout.ts +70 -0
  72. package/templates/salon-spa/tier5-src/_shared/iap-attendance-form.ts +106 -0
  73. package/templates/salon-spa/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
  74. package/templates/salon-spa/tier5-src/_shared/iap-attendance.ts +38 -0
  75. package/templates/salon-spa/tier5-src/_shared/iap-worker.ts +125 -0
  76. package/templates/salon-spa/tier5-src/_shared/local-preferences.ts +38 -0
  77. package/templates/salon-spa/tier5-src/_shared/member-access.ts +52 -0
  78. package/templates/salon-spa/tier5-src/_shared/member-account-routes.ts +52 -0
  79. package/templates/salon-spa/tier5-src/_shared/member-bookings.ts +94 -0
  80. package/templates/salon-spa/tier5-src/_shared/offset-page.ts +10 -0
  81. package/templates/salon-spa/tier5-src/_shared/online-actions.ts +76 -0
  82. package/templates/salon-spa/tier5-src/_shared/owner-load-error.ts +348 -0
  83. package/templates/salon-spa/tier5-src/_shared/payment-setup.ts +6 -0
  84. package/templates/salon-spa/tier5-src/_shared/rich-body.ts +78 -0
  85. package/templates/salon-spa/tier5-src/_shared/rich-content-link.ts +34 -0
  86. package/templates/salon-spa/tier5-src/_shared/weekly-goal.ts +107 -0
  87. package/templates/salon-spa/tier5-src/_shared/wix-service-price.ts +146 -0
  88. package/templates/salon-spa/tier5-src/business-chat/index.ts +669 -0
  89. package/templates/salon-spa/tier5-src/himi-booking-account/index.ts +241 -0
  90. package/templates/salon-spa/tier5-src/himi-booking-bookings/index.ts +466 -0
  91. package/templates/salon-spa/tier5-src/himi-booking-saved/index.ts +103 -0
  92. package/templates/salon-spa/tier5-src/himi-booking-service/index.ts +1170 -0
  93. package/templates/salon-spa/tier5-src/himi-booking-services/index.ts +791 -0
  94. package/templates/salon-spa/tier5-src/himi-booking-time/index.ts +1712 -0
  95. package/templates/salon-spa/tier5-src/himi-branded-lite-agenda/index.ts +950 -0
  96. package/templates/salon-spa/tier5-src/himi-branded-lite-galleries/index.ts +282 -0
  97. package/templates/salon-spa/tier5-src/himi-salon-home/index.ts +544 -0
  98. package/templates/salon-spa/tier5-src/himi-store-account/index.ts +925 -0
  99. package/templates/salon-spa/tokens.json +341 -0
  100. package/templates/yoga-studio/tier5-src/_shared/branded-load-state.ts +5 -2
  101. package/templates/yoga-studio/tier5-src/_shared/himi-account.ts +31 -0
  102. package/templates/yoga-studio/tier5-src/_shared/himi-booking-saved.ts +88 -0
  103. package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +50 -3
  104. package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +70 -3
  105. package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +40 -8
  106. package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
@@ -0,0 +1,508 @@
1
+ // ECMA-402 is NOT part of the Tier-5 floor. Measured across the engines we actually ship
2
+ // (engine-parity runners, #1367):
3
+ //
4
+ // engine typeof Intl
5
+ // V8 (web, Android WebView) object
6
+ // JSC (iOS, macOS) object
7
+ // QuickJS-ng (watchOS, Wear OS) **undefined**
8
+ //
9
+ // The wrist engine has no ECMA-402 at all — `grep -c Intl quickjs.c` → 0, and its maintainer
10
+ // ruled ICU out as too large (quickjs-ng#467). RFC 0051's per-surface projection does NOT subset
11
+ // destinations: a watch keeps every screen the phone has and only REMAPS which one an entry
12
+ // opens. So any screen in a watch/Wear-enabled app can execute on QuickJS, and a bare `Intl.*`
13
+ // there is a `ReferenceError`.
14
+ //
15
+ // That is not hypothetical. `himi-branded-lite-order.formatDate` called `new Intl.DateTimeFormat`
16
+ // unguarded and with no try/catch, which KILLS the screen on the wrist rather than degrading it.
17
+ // `himi-events-detail` had the softer version — a guarded `Intl.NumberFormat` whose catch left a
18
+ // ticket price rendering "25" where the phone shows "$25", i.e. a price that reads as a quantity.
19
+ //
20
+ // These helpers are the sanctioned way to format across the whole fleet: real ECMA-402 where it
21
+ // exists, a deterministic en-US-shaped fallback where it does not, and never a throw.
22
+
23
+ import { appLocale } from "./app-locale.js";
24
+
25
+ /** Is ECMA-402 actually present in THIS engine? Cheap, and safe in a realm without `Intl`. */
26
+ export function hasIntl(): boolean {
27
+ return typeof Intl !== "undefined";
28
+ }
29
+
30
+ const MONTHS_SHORT = [
31
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
32
+ ] as const;
33
+
34
+ function pad2(n: number): string {
35
+ return n < 10 ? `0${n}` : `${n}`;
36
+ }
37
+
38
+ /** `h:mm AM/PM`, matching what `timeStyle: "short"` renders for en-US. */
39
+ function clock12(d: Date): string {
40
+ const h = d.getHours();
41
+ const suffix = h < 12 ? "AM" : "PM";
42
+ const hour = h % 12 === 0 ? 12 : h % 12;
43
+ return `${hour}:${pad2(d.getMinutes())} ${suffix}`;
44
+ }
45
+
46
+ const WEEKDAYS_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
47
+
48
+ const MONTHS_LONG = [
49
+ "January", "February", "March", "April", "May", "June",
50
+ "July", "August", "September", "October", "November", "December",
51
+ ] as const;
52
+
53
+ /**
54
+ * A DateTimeFormat for `timeZone` when one is given and this engine knows it. An unknown or
55
+ * malformed IANA id is a RangeError in ECMA-402; these helpers never throw, so it degrades to
56
+ * the device zone rather than killing the screen. Without Intl there is no zone database at all,
57
+ * and the fallbacks below render in the device zone for the same reason.
58
+ *
59
+ * Formatters are memoised per (locale, zone, options): a management page formats several dates
60
+ * per row, and building a DateTimeFormat (or throwing on a bad zone) each time is not free on
61
+ * JSC or QuickJS. Only call it where `hasIntl()` holds — every caller does.
62
+ */
63
+ const FORMATTERS = new Map<string, Intl.DateTimeFormat>();
64
+ function dateFormat(locale: string, options: Intl.DateTimeFormatOptions, timeZone?: string): Intl.DateTimeFormat {
65
+ const key = `${locale}|${timeZone ?? ""}|${JSON.stringify(options)}`;
66
+ let formatter = FORMATTERS.get(key);
67
+ if (!formatter) {
68
+ if (hasIntl()) {
69
+ if (timeZone) {
70
+ try {
71
+ formatter = new Intl.DateTimeFormat(locale, { ...options, timeZone });
72
+ } catch {
73
+ /* unknown zone: device zone below, remembered under this key */
74
+ }
75
+ }
76
+ formatter ??= new Intl.DateTimeFormat(locale, options);
77
+ FORMATTERS.set(key, formatter);
78
+ }
79
+ }
80
+ return formatter as Intl.DateTimeFormat;
81
+ }
82
+
83
+ // The four shapes below are not invented — they are the option bags this corpus actually uses,
84
+ // counted across every bundle: "Jan 5, 2026" (33 sites), "Jan 5" (11), "Mon, Jan 5, 3:07 PM" (8)
85
+ // and "Jan 5, 3:07 PM" (4). Naming them means a conversion is a one-line swap rather than a
86
+ // judgement call about which fallback is close enough, and every one is diffed against real
87
+ // ECMA-402 in engine-intl-fallback.test.ts.
88
+
89
+ /** `{ month: "short", day: "numeric", year: "numeric" }` — "Jan 5, 2026". */
90
+ export function formatDateMedium(date: Date, locale = "en-US", timeZone?: string): string {
91
+ if (hasIntl()) {
92
+ return dateFormat(locale, { month: "short", day: "numeric", year: "numeric" }, timeZone).format(date);
93
+ }
94
+ return `${MONTHS_SHORT[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;
95
+ }
96
+
97
+ /** `{ month: "short", day: "numeric" }` — "Jan 5". */
98
+ export function formatMonthDay(date: Date, locale = "en-US", timeZone?: string): string {
99
+ if (hasIntl()) {
100
+ return dateFormat(locale, { month: "short", day: "numeric" }, timeZone).format(date);
101
+ }
102
+ return `${MONTHS_SHORT[date.getMonth()]} ${date.getDate()}`;
103
+ }
104
+
105
+ /** `{ month: "long", day: "numeric" }` — "January 5". */
106
+ export function formatMonthDayLong(date: Date, locale = "en-US", timeZone?: string): string {
107
+ if (hasIntl()) {
108
+ return dateFormat(locale, { month: "long", day: "numeric" }, timeZone).format(date);
109
+ }
110
+ return `${MONTHS_LONG[date.getMonth()]} ${date.getDate()}`;
111
+ }
112
+
113
+ const WEEKDAYS_LONG = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] as const;
114
+
115
+ /** `{ weekday: "short", month: "short", day: "numeric" }` — "Mon, Jan 5". */
116
+ export function formatWeekdayMonthDay(date: Date, locale = "en-US", timeZone?: string): string {
117
+ if (hasIntl()) {
118
+ return dateFormat(locale, { weekday: "short", month: "short", day: "numeric" }, timeZone).format(date);
119
+ }
120
+ return `${WEEKDAYS_SHORT[date.getDay()]}, ${MONTHS_SHORT[date.getMonth()]} ${date.getDate()}`;
121
+ }
122
+
123
+ /** `{ weekday: "long", month: "long", day: "numeric" }` — "Monday, January 5". */
124
+ export function formatWeekdayMonthDayLong(date: Date, locale = "en-US", timeZone?: string): string {
125
+ if (hasIntl()) {
126
+ return dateFormat(locale, { weekday: "long", month: "long", day: "numeric" }, timeZone).format(date);
127
+ }
128
+ return `${WEEKDAYS_LONG[date.getDay()]}, ${MONTHS_LONG[date.getMonth()]} ${date.getDate()}`;
129
+ }
130
+
131
+ /** `{ weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }`
132
+ * — "Mon, Jan 5, 3:07 PM". */
133
+ export function formatWeekdayDateTime(date: Date, locale = "en-US", timeZone?: string): string {
134
+ if (hasIntl()) {
135
+ return dateFormat(locale, {
136
+ weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit",
137
+ }, timeZone).format(date);
138
+ }
139
+ return `${WEEKDAYS_SHORT[date.getDay()]}, ${MONTHS_SHORT[date.getMonth()]} ${date.getDate()}, ${clock12(date)}`;
140
+ }
141
+
142
+ /** `{ month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }` — "Jan 5, 3:07 PM". */
143
+ export function formatMonthDayTime(date: Date, locale = "en-US", timeZone?: string): string {
144
+ if (hasIntl()) {
145
+ return dateFormat(locale, {
146
+ month: "short", day: "numeric", hour: "numeric", minute: "2-digit",
147
+ }, timeZone).format(date);
148
+ }
149
+ return `${MONTHS_SHORT[date.getMonth()]} ${date.getDate()}, ${clock12(date)}`;
150
+ }
151
+
152
+ /**
153
+ * `dateStyle: "medium", timeStyle: "short"` — "Jan 5, 2026, 3:07 PM".
154
+ * Falls back to the same SHAPE (not a different one) so a screen does not visibly change
155
+ * character between a phone and a watch.
156
+ */
157
+ export function formatDateTimeMedium(date: Date, locale = "en"): string {
158
+ if (hasIntl()) {
159
+ return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(date);
160
+ }
161
+ return `${MONTHS_SHORT[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}, ${clock12(date)}`;
162
+ }
163
+
164
+ /**
165
+ * Currency, e.g. "$25.00". The fallback covers the symbol-prefix currencies these apps actually
166
+ * use and degrades to "CODE amount" for anything else — deliberately NOT a bare number, because
167
+ * that is the exact defect this exists to prevent (a price rendering as a quantity).
168
+ */
169
+ const CURRENCY_SYMBOL: Record<string, string> = {
170
+ USD: "$", EUR: "€", GBP: "£", ILS: "₪", JPY: "¥", CAD: "CA$", AUD: "A$",
171
+ };
172
+
173
+ /**
174
+ * ISO 4217 minor units, for the currencies that are NOT the default 2.
175
+ *
176
+ * Without this the fallback rendered every non-JPY currency with 2 decimals while Intl used the
177
+ * currency's natural precision — so a Bahraini price was "BHD 25.00" on the wrist and
178
+ * "BHD 25.000" on the phone. Formatting money differently per engine is the exact defect this
179
+ * helper exists to prevent, so the table is part of the contract, not a nicety.
180
+ */
181
+ const CURRENCY_DIGITS: Record<string, number> = {
182
+ // Zero-decimal
183
+ BIF: 0, CLP: 0, DJF: 0, GNF: 0, ISK: 0, JPY: 0, KMF: 0, KRW: 0, PYG: 0, RWF: 0,
184
+ UGX: 0, UYI: 0, VND: 0, VUV: 0, XAF: 0, XOF: 0, XPF: 0,
185
+ // Three-decimal
186
+ BHD: 3, IQD: 3, JOD: 3, KWD: 3, LYD: 3, OMR: 3, TND: 3,
187
+ // Four-decimal
188
+ CLF: 4, UYW: 4,
189
+ };
190
+
191
+ export function formatCurrency(
192
+ amount: number,
193
+ currency = "USD",
194
+ locale = appLocale(),
195
+ /** Omit for the currency's natural digits. Pass 0 to render a whole amount as "$25" rather
196
+ * than "$25.00" — Intl clamps the minimum down to match, so max<min does not throw here. */
197
+ maximumFractionDigits?: number,
198
+ ): string {
199
+ if (!Number.isFinite(amount)) return "";
200
+ if (hasIntl()) {
201
+ // The Intl branch CAN throw: `new Intl.NumberFormat(…, { currency })` raises a RangeError for
202
+ // any code that is not three letters ("US", "12", ""), and a currency arriving from payload
203
+ // data is exactly where that happens. This helper replaced call sites that had their own
204
+ // try/catch (himi-events-detail's tierPrice returned the raw amount), so without this the
205
+ // refactor would have REMOVED a safety net and turned a degraded price into a dead service on
206
+ // web, iOS and Android alike — worse than the wrist bug it was written to fix.
207
+ //
208
+ // Falling through to the fallback rather than rethrowing also degrades better than the code
209
+ // it replaced: that returned a bare number, this still carries the currency code.
210
+ try {
211
+ return new Intl.NumberFormat(locale, {
212
+ style: "currency",
213
+ currency,
214
+ ...(maximumFractionDigits != null ? { maximumFractionDigits } : {}),
215
+ }).format(amount);
216
+ } catch { /* malformed currency — use the engine-independent path below */ }
217
+ }
218
+ // A blank/whitespace currency would otherwise produce " 25.00" — a bare number, which is the
219
+ // very defect this helper exists to prevent. Fall back to the documented default rather than
220
+ // emitting an unlabelled amount. (Found by the "never a bare number" assertion, not by reading.)
221
+ const code = (currency || "").trim().toUpperCase() || "USD";
222
+ const symbol = CURRENCY_SYMBOL[code];
223
+ // The sign goes OUTSIDE the symbol: ECMA-402 renders -42.5 USD as "-$42.50", not "$-42.50".
224
+ // Caught by diffing this fallback against Intl rather than by reading it.
225
+ const sign = amount < 0 ? "-" : "";
226
+ const abs = Math.abs(amount);
227
+ const digits = maximumFractionDigits ?? CURRENCY_DIGITS[code] ?? 2;
228
+ const raw = abs.toFixed(digits);
229
+ // Same trap as formatNumber: `toFixed` goes EXPONENTIAL at >=1e21, and groupThousands would
230
+ // then comma up the exponent ("1e,+21"). No real price is that large, but emitting garbage is
231
+ // never the right answer to an unexpected input.
232
+ const fixed = raw.includes("e") || raw.includes("E") ? String(abs) : groupThousands(raw);
233
+ return symbol ? `${sign}${symbol}${fixed}` : `${sign}${code} ${fixed}`;
234
+ }
235
+
236
+ /** "1234567.89" → "1,234,567.89". `toLocaleString` cannot be used: on QuickJS it silently
237
+ * returns the UNGROUPED string rather than throwing, which is why the wrist showed "1234.5"
238
+ * where every other engine showed "1,234.5". */
239
+ function groupThousands(fixed: string): string {
240
+ const [whole, frac] = fixed.split(".");
241
+ const sign = whole.startsWith("-") ? "-" : "";
242
+ const digits = sign ? whole.slice(1) : whole;
243
+ let out = "";
244
+ for (let i = 0; i < digits.length; i++) {
245
+ if (i > 0 && (digits.length - i) % 3 === 0) out += ",";
246
+ out += digits[i];
247
+ }
248
+ return frac ? `${sign}${out}.${frac}` : `${sign}${out}`;
249
+ }
250
+
251
+ /**
252
+ * Plain grouped number, e.g. "1,234.5".
253
+ *
254
+ * Two things the first cut of this got wrong, both found by diffing against Intl over values
255
+ * chosen to be AWKWARD rather than tidy:
256
+ *
257
+ * • `Intl.NumberFormat` defaults to `maximumFractionDigits: 3`. Feeding it `2/3` gives
258
+ * "0.667"; a fallback built on `String(value)` gave "0.6666666666666666".
259
+ * • `String(value)` is EXPONENTIAL outside ~1e21 / 1e-7, and the grouping loop below happily
260
+ * inserted commas into the exponent — `1e21` came out as "1e,+21" and `1e-7` as "1,e-7".
261
+ * Garbage, and silently so, because nothing in the corpus's tidy test numbers reached it.
262
+ */
263
+ export function formatNumber(value: number, locale = appLocale()): string {
264
+ if (!Number.isFinite(value)) return "";
265
+ if (hasIntl()) return new Intl.NumberFormat(locale).format(value);
266
+ // `toFixed` itself returns exponential notation at >=1e21, so anything that big keeps the
267
+ // plain JS form rather than being mangled. Intl would expand it in full; a number that large
268
+ // is not a price or a count, and an honest "1e+21" beats a wrong "1e,+21".
269
+ const fixed = value.toFixed(3);
270
+ if (fixed.includes("e") || fixed.includes("E")) return String(value);
271
+ // Trailing zeros go, so an integer stays "1,000" instead of "1,000.000".
272
+ const trimmed = fixed.includes(".") ? fixed.replace(/0+$/, "").replace(/\.$/, "") : fixed;
273
+ return groupThousands(trimmed);
274
+ }
275
+
276
+ /** Strip combining marks so "Ändern" folds to "andern". `String.prototype.normalize` is
277
+ * ECMA-262, NOT ECMA-402 — which is exactly why it is usable here: it exists on the engine that
278
+ * has no `Intl`. The range is the Unicode combining-diacritical block, written as an explicit
279
+ * range rather than a `\p{M}` property escape so no engine needs RegExp property support. */
280
+ function foldDiacritics(s: string): string {
281
+ try {
282
+ return s.normalize("NFD").replace(/[̀-ͯ]/g, "");
283
+ } catch {
284
+ return s; // no normalize (should not happen) — degrade to the un-folded form
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Locale-aware string ordering for SORTS.
290
+ *
291
+ * `localeCompare` is the trap here, because it does not throw where ECMA-402 is missing — it
292
+ * silently falls back to codepoint order. Measured on the engines we ship, that is not a subtle
293
+ * difference: for a realistic product list the wrist produced
294
+ *
295
+ * Banana, Zoë, andern, apple, cherry, Ändern, Über, éclair ← raw codepoint (QuickJS-ng)
296
+ * andern, Ändern, apple, Banana, cherry, éclair, Über, Zoë ← ICU (V8/JSC)
297
+ *
298
+ * i.e. a list that looks shuffled rather than sorted, with no error anywhere to explain it.
299
+ *
300
+ * The phone path is UNCHANGED — it is still `Intl.Collator`, so nothing about existing ordering
301
+ * moves. Only the engine that lacked ECMA-402 changes, and it changes from codepoint order to a
302
+ * case-folded, diacritic-folded comparison that agreed with ICU on 576/576 pairs of the probe
303
+ * corpus (accents included). This is deliberately NOT "pick one algorithm for every engine":
304
+ * that framing would have traded a wrist bug for a phone regression, and it is unnecessary.
305
+ */
306
+ export function compareText(a: string, b: string, locale = "en"): number {
307
+ if (hasIntl()) return new Intl.Collator(locale).compare(a, b);
308
+ const fa = foldDiacritics(a.toLowerCase());
309
+ const fb = foldDiacritics(b.toLowerCase());
310
+ if (fa < fb) return -1;
311
+ if (fa > fb) return 1;
312
+ // Same letters ignoring case and accents — fall back through the finer distinctions so the
313
+ // order is TOTAL (a comparator that returns 0 for distinct strings makes a sort unstable).
314
+ const ca = a.toLowerCase(), cb = b.toLowerCase();
315
+ if (ca < cb) return -1;
316
+ if (ca > cb) return 1;
317
+ if (a === b) return 0;
318
+ // en-US collation puts lowercase before uppercase for the same letter, which is the OPPOSITE
319
+ // of codepoint order — hence the inversion rather than a plain comparison.
320
+ return a > b ? -1 : 1;
321
+ }
322
+
323
+ /**
324
+ * Ordering for machine timestamps (ISO-8601, RFC-3339) and other fixed-format ASCII keys.
325
+ *
326
+ * Exists to keep `localeCompare` away from them. Sorting `"2026-01-05T15:07:00Z"` through a
327
+ * COLLATOR is the wrong tool — collation carries locale rules a timestamp has no use for, and on
328
+ * an engine without ECMA-402 it silently becomes codepoint order anyway. Measured: for ISO
329
+ * strings, plain comparison and `localeCompare` agree on 36/36 pairs, so this is a no-op on
330
+ * every engine AND removes the engine-variance question entirely.
331
+ */
332
+ export function compareIso(a: string, b: string): number {
333
+ return a < b ? -1 : a > b ? 1 : 0;
334
+ }
335
+
336
+ const MONTH_INDEX: Record<string, number> = {
337
+ jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5,
338
+ jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11,
339
+ };
340
+
341
+ /**
342
+ * Epoch-ms for a date string that may be ISO-8601 **or** RFC-822/2822 ("Sun, 02 Aug 2026"), the
343
+ * shape RSS feeds use. `NaN` when it is neither.
344
+ *
345
+ * Hand-parsed rather than handed to `Date.parse` because `Date.parse` is only SPECIFIED for the
346
+ * ISO format — everything else is implementation-defined, which is precisely the kind of
347
+ * per-engine variance this module exists to remove.
348
+ */
349
+ export function parseDateish(value: string): number {
350
+ if (!value) return NaN;
351
+ // ISO / RFC-3339 — `Date` parses this by spec on every engine.
352
+ if (/^\d{4}-\d{2}-\d{2}/.test(value)) return new Date(value).getTime();
353
+ // RFC-822/2822: "[Ddd, ]DD Mon YYYY[ HH:MM[:SS]]"
354
+ const m = value.match(/(\d{1,2})\s+([A-Za-z]{3})[a-z]*\s+(\d{4})(?:\s+(\d{2}):(\d{2})(?::(\d{2}))?)?/);
355
+ if (!m) return NaN;
356
+ const month = MONTH_INDEX[m[2].toLowerCase()];
357
+ if (month === undefined) return NaN;
358
+ return Date.UTC(Number(m[3]), month, Number(m[1]), Number(m[4] ?? 0), Number(m[5] ?? 0), Number(m[6] ?? 0));
359
+ }
360
+
361
+ /**
362
+ * Chronological ordering for date strings — ASCENDING (oldest first); negate for newest-first.
363
+ *
364
+ * This exists because comparing RFC-822 dates as STRINGS sorts by weekday name. Measured on the
365
+ * podcast corpus, a "latest episodes" list built that way rendered
366
+ *
367
+ * Wed, 01 Jan 2025 ← shown FIRST
368
+ * Tue, 21 Jul 2026
369
+ * …
370
+ * Fri, 15 Aug 2026 ← the newest episode, shown LAST
371
+ *
372
+ * and it was wrong on EVERY engine, not just the one missing ECMA-402 — a plain product bug that
373
+ * the engine work happened to surface. Unparseable values sort last rather than throwing, so one
374
+ * malformed feed entry cannot take the screen down.
375
+ */
376
+ export function compareDate(a: string, b: string): number {
377
+ const ta = parseDateish(a);
378
+ const tb = parseDateish(b);
379
+ const aBad = Number.isNaN(ta);
380
+ const bBad = Number.isNaN(tb);
381
+ if (aBad && bBad) return compareIso(a, b); // both unparseable — arbitrary but TOTAL
382
+ if (aBad) return 1;
383
+ if (bBad) return -1;
384
+ return ta - tb;
385
+ }
386
+
387
+ /**
388
+ * Newest-first, which is what every call site here actually wants.
389
+ *
390
+ * A separate function rather than asking callers to write `-compareDate(a, b)`, because negating
391
+ * also flips the unparseable-values-last rule to unparseable-values-FIRST — a malformed feed entry
392
+ * would head the "latest episodes" list. My own test caught that on the negation idiom, which is
393
+ * exactly the footgun worth deleting rather than documenting.
394
+ */
395
+ export function compareDateDesc(a: string, b: string): number {
396
+ const ta = parseDateish(a);
397
+ const tb = parseDateish(b);
398
+ const aBad = Number.isNaN(ta);
399
+ const bBad = Number.isNaN(tb);
400
+ if (aBad && bBad) return compareIso(a, b);
401
+ if (aBad) return 1; // unparseable stays LAST in this direction too
402
+ if (bBad) return -1;
403
+ return tb - ta;
404
+ }
405
+
406
+ // --- [branded-lite-qa] #3079: one pattern per fact ---------------------------------------
407
+ //
408
+ // Before these existed, four screens formatted the same money four ways — Shop "69.00 ₪" (Wix's
409
+ // pre-formatted string), Services "₪70" (0 decimals), Menu "₪32.00", Plans "$24" — and two
410
+ // screens formatted the same clock two ways (Events "9:00 PM-11:00 PM", Reserve "20:00").
411
+ // Customers read that as sloppiness, and a whole "₪70" beside "₪69.50" reads as a different
412
+ // kind of number. So: one money pattern, one clock, one range.
413
+
414
+ // Money uses `formatCurrency(amount, currency)` — English unless the app pins another language, see app-locale — the currency's NATURAL decimals
415
+ // ("₪70.00", "¥500") — for Shop, Menu and every other price. The one exception is a Bookings
416
+ // SERVICE price (`_shared/wix-service-price.ts`): it drops the cents of a whole amount ("₪70",
417
+ // "₪69.50"), the format the redesigned service screen and services list were approved with. It
418
+ // must read the same on every screen that shows a service (the list, the service screen, the
419
+ // time screen, Home, the agenda); do not put those back on natural decimals one screen at a time.
420
+
421
+ const KNOWN_SYMBOLS = new Set(Object.values(CURRENCY_SYMBOL));
422
+
423
+ /** Wix hands some prices over PRE-formatted in the site's regional style — "69.00 ₪" with the
424
+ * symbol trailing, often wrapped in bidi marks. Reorder those to the house pattern ("₪69.00") so
425
+ * they match every price we format ourselves. Only an en-shaped number ("69.00", "1,234.50") with
426
+ * a known trailing symbol is reordered: a comma-decimal or space-grouped string ("69,00 €",
427
+ * "1 234,56 €") is kept as Wix wrote it, because half-converting it would be worse. Anything else
428
+ * ("Market price") is returned trimmed. The store SDK's product price applies the same rule. */
429
+ export function normalizeFormattedPrice(formatted: string): string {
430
+ const raw = (formatted || "").replace(/[‎‏]/g, "").trim();
431
+ const m = /^(-?)(\d{1,3}(?:,\d{3})*(?:\.\d+)?|\d+(?:\.\d+)?)[\s  ]*(\S+)$/.exec(raw);
432
+ if (!m || !KNOWN_SYMBOLS.has(m[3])) return raw;
433
+ return `${m[1]}${m[3]}${m[2]}`;
434
+ }
435
+
436
+ function clockParts(d: Date, locale: string, timeZone?: string): { time: string; meridiem: string } {
437
+ const full = hasIntl()
438
+ ? dateFormat(locale, { hour: "numeric", minute: "2-digit", hour12: true }, timeZone).format(d)
439
+ : clock12(d);
440
+ // ICU puts a narrow no-break space before the meridiem; normalize so callers can split on it.
441
+ const clean = full.replace(/[  ]/g, " ");
442
+ const m = /^(.*?)\s*([AP]\.?M\.?)$/i.exec(clean);
443
+ return m ? { time: m[1], meridiem: m[2] } : { time: clean, meridiem: "" };
444
+ }
445
+
446
+ /** "8:00 PM" — the one clock every customer screen uses (12-hour, en-US shape). */
447
+ export function formatClock(date: Date, locale = "en-US", timeZone?: string): string {
448
+ const { time, meridiem } = clockParts(date, locale, timeZone);
449
+ return meridiem ? `${time} ${meridiem}` : time;
450
+ }
451
+
452
+ /** Is this ISO instant before `now`? A missing or unparseable value is NOT past — "no expiry"
453
+ * must never classify an offer as expired. */
454
+ export function isPastDate(iso: string | undefined | null, now: number = Date.now()): boolean {
455
+ if (!iso) return false;
456
+ const t = Date.parse(iso);
457
+ return !Number.isNaN(t) && t < now;
458
+ }
459
+
460
+ /** The calendar day `date` falls on in `timeZone` (device zone when absent, unknown, or when
461
+ * the engine has no Intl), as `YYYY-MM-DD`. For "is this today / this year" comparisons. */
462
+ export function calendarDay(date: Date, timeZone?: string): string {
463
+ if (hasIntl()) {
464
+ const parts = dateFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit" }, timeZone).formatToParts(date);
465
+ const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
466
+ return `${get("year")}-${get("month")}-${get("day")}`;
467
+ }
468
+ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
469
+ }
470
+
471
+ /**
472
+ * [timeZone]'s offset from UTC in ms at instant [at] (Asia/Jerusalem in winter: 7_200_000), or NaN
473
+ * where it cannot be known — no ECMA-402 (the wrist engines) or a zone this engine does not know.
474
+ * Callers fall back to the device zone on NaN, as the formatters above do.
475
+ */
476
+ export function zoneOffsetAt(at: number, timeZone: string): number {
477
+ if (hasIntl() && Number.isFinite(at)) {
478
+ try {
479
+ const parts = new Intl.DateTimeFormat("en-US", {
480
+ timeZone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit",
481
+ hour: "2-digit", minute: "2-digit", second: "2-digit",
482
+ }).formatToParts(new Date(at));
483
+ const n = (type: string) => Number(parts.find((p) => p.type === type)?.value);
484
+ return Date.UTC(n("year"), n("month") - 1, n("day"), n("hour") % 24, n("minute"), n("second")) - at;
485
+ } catch {
486
+ /* a zone this engine does not know: NaN below */
487
+ }
488
+ }
489
+ return NaN;
490
+ }
491
+
492
+ /** Long zone label plus its short offset, e.g. "Israel Standard Time (GMT+2)". An engine
493
+ * without ECMA-402 has no zone database, so callers omit the supplementary label there. */
494
+ export function formatTimeZoneLabel(date: Date, timeZone: string, locale = "en"): string {
495
+ if (hasIntl()) {
496
+ if (!timeZone) return "";
497
+ try {
498
+ const parts = new Intl.DateTimeFormat(locale, { timeZone, timeZoneName: "long" }).formatToParts(date);
499
+ const name = parts.find((part) => part.type === "timeZoneName")?.value || timeZone;
500
+ const offset = new Intl.DateTimeFormat(locale, { timeZone, timeZoneName: "shortOffset" })
501
+ .formatToParts(date).find((part) => part.type === "timeZoneName")?.value;
502
+ return offset ? `${name} (${offset.replace(/:00$/, "")})` : name;
503
+ } catch {
504
+ return timeZone;
505
+ }
506
+ }
507
+ return "";
508
+ }
@@ -0,0 +1,164 @@
1
+ import { dispatch } from "@himalaya/dispatch";
2
+ // Relative for node resolution (see himi-store.ts note); rewritten off-repo.
3
+ import { displayName, getMyData, initials, type WixMyData } from "@himalaya/wix-users";
4
+ import { getMyMember } from "@himalaya/wix-members";
5
+ import { wixMediaUrl } from "@himalaya/wix-media";
6
+ import { formatDateMedium, formatWeekdayDateTime } from "../_shared/engine-intl.js";
7
+
8
+ export interface AccountProfile {
9
+ isMember: boolean;
10
+ identityLine: string;
11
+ detailLine: string;
12
+ memberName: string;
13
+ memberEmail: string;
14
+ memberImage: string;
15
+ memberInitials: string;
16
+ hasMemberImage: boolean;
17
+ noMemberImage: boolean;
18
+ }
19
+
20
+ export interface AccountActivityRow {
21
+ id: string;
22
+ title: string;
23
+ subtitle: string;
24
+ meta: string;
25
+ status: string;
26
+ }
27
+
28
+ export function strOf(arg: unknown): string {
29
+ if (typeof arg === "string") return arg;
30
+ const a = arg as { value?: string; text?: string } | undefined;
31
+ return a?.value ?? a?.text ?? "";
32
+ }
33
+
34
+ export function titleCase(value?: string): string {
35
+ return (value || "")
36
+ .toLowerCase()
37
+ .split(/[_\s-]+/)
38
+ .filter(Boolean)
39
+ .map((part) => part[0].toUpperCase() + part.slice(1))
40
+ .join(" ");
41
+ }
42
+
43
+ export function fmtDate(value?: string): string {
44
+ if (!value) return "";
45
+ const date = new Date(value);
46
+ if (Number.isNaN(date.getTime())) return "";
47
+ return formatDateMedium(date);
48
+ }
49
+
50
+ export function fmtDateTime(value?: string): string {
51
+ if (!value) return "";
52
+ const date = new Date(value);
53
+ if (Number.isNaN(date.getTime())) return "";
54
+ return formatWeekdayDateTime(date);
55
+ }
56
+
57
+ function initialsFromName(value: string): string {
58
+ const parts = value.trim().split(/\s+/).filter(Boolean);
59
+ if (!parts.length) return "";
60
+ const first = parts[0]?.[0] ?? "";
61
+ const last = parts.length > 1 ? parts[parts.length - 1]?.[0] ?? "" : "";
62
+ return (first + last).toUpperCase();
63
+ }
64
+
65
+ function profileImage(data: WixMyData): string {
66
+ const profile = data.profile as { profileImage?: string; photo?: { url?: string }; imageUrl?: string } | undefined;
67
+ return profile?.profileImage || profile?.photo?.url || profile?.imageUrl || "";
68
+ }
69
+
70
+ function profileFromData(data: WixMyData, fallbackEmail = ""): AccountProfile {
71
+ const email = fallbackEmail || data.email || data.contactEmail || "";
72
+ const name = displayName(data) || email || "Member";
73
+ const image = profileImage(data);
74
+ return {
75
+ isMember: true,
76
+ identityLine: "Signed in",
77
+ detailLine: email || name,
78
+ memberName: name,
79
+ memberEmail: email,
80
+ memberImage: image,
81
+ memberInitials: initials(data) || initialsFromName(name) || "M",
82
+ hasMemberImage: !!image,
83
+ noMemberImage: !image,
84
+ };
85
+ }
86
+
87
+ /** The signed-in site member's name and photo from their member record, or undefined when it
88
+ * cannot be read or names nobody (the caller then shows the email). */
89
+ async function siteMemberProfile(email: string): Promise<AccountProfile | undefined> {
90
+ try {
91
+ const member = (await getMyMember({ fieldsets: ["FULL"] }))?.member;
92
+ const name = [member?.contact?.firstName, member?.contact?.lastName].map((part) => (part ?? "").trim()).filter(Boolean).join(" ")
93
+ || (member?.profile?.nickname ?? "").trim();
94
+ if (!name) return undefined;
95
+ const image = wixMediaUrl(member?.profile?.photo?.url) || wixMediaUrl(member?.profile?.photo?.id) || "";
96
+ return {
97
+ isMember: true,
98
+ identityLine: "Signed in",
99
+ detailLine: email || name,
100
+ memberName: name,
101
+ memberEmail: email || member?.loginEmail || "",
102
+ memberImage: image,
103
+ memberInitials: initialsFromName(name) || "M",
104
+ hasMemberImage: !!image,
105
+ noMemberImage: !image,
106
+ };
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
112
+ export function guestProfile(detailLine: string): AccountProfile {
113
+ return {
114
+ isMember: false,
115
+ identityLine: "Browsing as a guest",
116
+ detailLine,
117
+ memberName: "",
118
+ memberEmail: "",
119
+ memberImage: "",
120
+ memberInitials: "M",
121
+ hasMemberImage: false,
122
+ noMemberImage: true,
123
+ };
124
+ }
125
+
126
+ export async function signedInEmail(): Promise<string> {
127
+ try {
128
+ const identity = await dispatch("himi.wix.memberIdentity") as unknown;
129
+ const direct = identity as { email?: unknown } | null;
130
+ const wrapped = identity as { payload?: { email?: unknown } } | null;
131
+ const email = direct?.email ?? wrapped?.payload?.email;
132
+ return typeof email === "string" ? email : "";
133
+ } catch {
134
+ return "";
135
+ }
136
+ }
137
+
138
+ export async function loadMemberProfile(guestDetailLine: string): Promise<AccountProfile> {
139
+ // `himi.wix.memberIdentity` is the session source of truth. Visitor-scoped
140
+ // getMyData can still return a site/account nickname (e.g. apptestercoowner)
141
+ // after sign-out or for anonymous — never treat that as a signed-in member.
142
+ const storedEmail = await signedInEmail();
143
+ if (!storedEmail) return guestProfile(guestDetailLine);
144
+ try {
145
+ const data = await getMyData();
146
+ return profileFromData(data, storedEmail);
147
+ } catch {
148
+ // The account profile is an owner endpoint: the web host refuses it, and a site member need
149
+ // not have one. The member's own record (the Members API) still names them.
150
+ const member = await siteMemberProfile(storedEmail);
151
+ if (member) return member;
152
+ return {
153
+ isMember: true,
154
+ identityLine: "Signed in",
155
+ detailLine: storedEmail,
156
+ memberName: storedEmail,
157
+ memberEmail: storedEmail,
158
+ memberImage: "",
159
+ memberInitials: initialsFromName(storedEmail) || "M",
160
+ hasMemberImage: false,
161
+ noMemberImage: true,
162
+ };
163
+ }
164
+ }