@wix/himalaya-cli 0.1589.0 → 0.1591.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 (178) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/wix-platform/src/index.d.ts +9 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/members-v1/src/index.d.ts +9 -5
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-members.mjs +9 -8
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-platform.mjs +2 -0
  5. package/dist/cli.mjs +90 -48
  6. package/dist/preview-assets.generated.bin +0 -0
  7. package/guides/one-app-to-himi-app.md +18 -4
  8. package/guides/sdk-reference.json +14 -2
  9. package/guides/tier-5-add-a-screen.md +48 -0
  10. package/package.json +2 -2
  11. package/templates/beauty-salon/tier5-src/_shared/app-locale.ts +61 -0
  12. package/templates/beauty-salon/tier5-src/_shared/engine-intl.ts +5 -3
  13. package/templates/beauty-salon/tier5-src/_shared/group-member-count.ts +15 -0
  14. package/templates/beauty-salon/tier5-src/_shared/himi-bookings.ts +54 -13
  15. package/templates/beauty-salon/tier5-src/_shared/himi-copy.ts +10 -2
  16. package/templates/beauty-salon/tier5-src/_shared/himi-restaurants.ts +2 -2
  17. package/templates/beauty-salon/tier5-src/_shared/restaurant-ordering.ts +1 -1
  18. package/templates/beauty-salon/tier5-src/_shared/wix-service-price.ts +2 -2
  19. package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +8 -3
  20. package/templates/beauty-salon/tier5-src/himi-branded-lite-cart/index.ts +1 -1
  21. package/templates/beauty-salon/tier5-src/himi-branded-lite-home/index.ts +2 -1
  22. package/templates/beauty-salon/tier5-src/himi-branded-lite-plans/index.ts +1 -1
  23. package/templates/beauty-salon-owner/tier5-src/_shared/app-locale.ts +61 -0
  24. package/templates/beauty-salon-owner/tier5-src/_shared/engine-intl.ts +5 -3
  25. package/templates/beauty-salon-owner/tier5-src/_shared/himi-copy.ts +10 -2
  26. package/templates/branded-lite/tier5-src/_shared/app-locale.ts +61 -0
  27. package/templates/branded-lite/tier5-src/_shared/engine-intl.ts +5 -3
  28. package/templates/branded-lite/tier5-src/_shared/group-member-count.ts +15 -0
  29. package/templates/branded-lite/tier5-src/_shared/himi-bookings.ts +54 -13
  30. package/templates/branded-lite/tier5-src/_shared/himi-copy.ts +10 -2
  31. package/templates/branded-lite/tier5-src/_shared/himi-restaurants.ts +2 -2
  32. package/templates/branded-lite/tier5-src/_shared/restaurant-ordering.ts +1 -1
  33. package/templates/branded-lite/tier5-src/_shared/wix-service-price.ts +2 -2
  34. package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +8 -3
  35. package/templates/branded-lite/tier5-src/himi-branded-lite-cart/index.ts +1 -1
  36. package/templates/branded-lite/tier5-src/himi-branded-lite-group/index.ts +9 -3
  37. package/templates/branded-lite/tier5-src/himi-branded-lite-group-about/index.ts +2 -1
  38. package/templates/branded-lite/tier5-src/himi-branded-lite-groups/index.ts +6 -2
  39. package/templates/branded-lite/tier5-src/himi-branded-lite-home/index.ts +2 -1
  40. package/templates/branded-lite/tier5-src/himi-branded-lite-loyalty/index.ts +3 -2
  41. package/templates/branded-lite/tier5-src/himi-branded-lite-my-groups/index.ts +2 -1
  42. package/templates/branded-lite/tier5-src/himi-branded-lite-plans/index.ts +1 -1
  43. package/templates/branded-lite/tier5-src/himi-branded-lite-program/index.ts +1 -1
  44. package/templates/branded-lite/tier5-src/himi-events-detail/index.ts +5 -5
  45. package/templates/branded-lite/tier5-src/wix-spaces-member-detail/index.ts +5 -1
  46. package/templates/brandy/tier5-src/_shared/app-locale.ts +61 -0
  47. package/templates/brandy/tier5-src/_shared/himi-copy.ts +10 -2
  48. package/templates/community-club/tier5-src/_shared/app-locale.ts +61 -0
  49. package/templates/community-club/tier5-src/_shared/engine-intl.ts +5 -3
  50. package/templates/community-club/tier5-src/_shared/group-member-count.ts +15 -0
  51. package/templates/community-club/tier5-src/_shared/himi-copy.ts +10 -2
  52. package/templates/community-club/tier5-src/_shared/himi-restaurants.ts +2 -2
  53. package/templates/community-club/tier5-src/_shared/wix-service-price.ts +2 -2
  54. package/templates/community-club/tier5-src/himi-branded-lite-group/index.ts +9 -3
  55. package/templates/community-club/tier5-src/himi-branded-lite-group-about/index.ts +2 -1
  56. package/templates/community-club/tier5-src/himi-branded-lite-groups/index.ts +6 -2
  57. package/templates/community-club/tier5-src/himi-branded-lite-home/index.ts +2 -1
  58. package/templates/community-club/tier5-src/himi-branded-lite-my-groups/index.ts +2 -1
  59. package/templates/community-club/tier5-src/himi-events-detail/index.ts +5 -5
  60. package/templates/community-club-owner/tier5-src/_shared/app-locale.ts +61 -0
  61. package/templates/community-club-owner/tier5-src/_shared/engine-intl.ts +5 -3
  62. package/templates/community-club-owner/tier5-src/_shared/himi-copy.ts +10 -2
  63. package/templates/events-meetups/tier5-src/_shared/app-locale.ts +61 -0
  64. package/templates/events-meetups/tier5-src/_shared/engine-intl.ts +5 -3
  65. package/templates/events-meetups/tier5-src/_shared/group-member-count.ts +15 -0
  66. package/templates/events-meetups/tier5-src/_shared/himi-copy.ts +10 -2
  67. package/templates/events-meetups/tier5-src/_shared/himi-restaurants.ts +2 -2
  68. package/templates/events-meetups/tier5-src/_shared/wix-service-price.ts +2 -2
  69. package/templates/events-meetups/tier5-src/himi-branded-lite-group/index.ts +9 -3
  70. package/templates/events-meetups/tier5-src/himi-branded-lite-group-about/index.ts +2 -1
  71. package/templates/events-meetups/tier5-src/himi-branded-lite-groups/index.ts +6 -2
  72. package/templates/events-meetups/tier5-src/himi-branded-lite-home/index.ts +2 -1
  73. package/templates/events-meetups/tier5-src/himi-branded-lite-my-groups/index.ts +2 -1
  74. package/templates/events-meetups/tier5-src/himi-events-detail/index.ts +5 -5
  75. package/templates/events-meetups-owner/tier5-src/_shared/app-locale.ts +61 -0
  76. package/templates/events-meetups-owner/tier5-src/_shared/engine-intl.ts +5 -3
  77. package/templates/events-meetups-owner/tier5-src/_shared/himi-copy.ts +10 -2
  78. package/templates/gym-fitness/tier5-src/_shared/app-locale.ts +61 -0
  79. package/templates/gym-fitness/tier5-src/_shared/engine-intl.ts +5 -3
  80. package/templates/gym-fitness/tier5-src/_shared/group-member-count.ts +15 -0
  81. package/templates/gym-fitness/tier5-src/_shared/himi-bookings.ts +54 -13
  82. package/templates/gym-fitness/tier5-src/_shared/himi-copy.ts +10 -2
  83. package/templates/gym-fitness/tier5-src/_shared/himi-restaurants.ts +2 -2
  84. package/templates/gym-fitness/tier5-src/_shared/restaurant-ordering.ts +1 -1
  85. package/templates/gym-fitness/tier5-src/_shared/wix-service-price.ts +2 -2
  86. package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +8 -3
  87. package/templates/gym-fitness/tier5-src/himi-branded-lite-cart/index.ts +1 -1
  88. package/templates/gym-fitness/tier5-src/himi-branded-lite-home/index.ts +2 -1
  89. package/templates/gym-fitness/tier5-src/himi-branded-lite-plans/index.ts +1 -1
  90. package/templates/gym-fitness/tier5-src/himi-events-detail/index.ts +5 -5
  91. package/templates/gym-fitness-owner/tier5-src/_shared/app-locale.ts +61 -0
  92. package/templates/gym-fitness-owner/tier5-src/_shared/engine-intl.ts +5 -3
  93. package/templates/gym-fitness-owner/tier5-src/_shared/himi-copy.ts +10 -2
  94. package/templates/himi-chat/tier5-src/_shared/app-locale.ts +61 -0
  95. package/templates/himi-chat/tier5-src/_shared/himi-copy.ts +10 -2
  96. package/templates/nonprofit-cause/tier5-src/_shared/app-locale.ts +61 -0
  97. package/templates/nonprofit-cause/tier5-src/_shared/engine-intl.ts +5 -3
  98. package/templates/nonprofit-cause/tier5-src/_shared/group-member-count.ts +15 -0
  99. package/templates/nonprofit-cause/tier5-src/_shared/himi-copy.ts +10 -2
  100. package/templates/nonprofit-cause/tier5-src/_shared/himi-restaurants.ts +2 -2
  101. package/templates/nonprofit-cause/tier5-src/_shared/wix-service-price.ts +2 -2
  102. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-group/index.ts +9 -3
  103. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-group-about/index.ts +2 -1
  104. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-groups/index.ts +6 -2
  105. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-home/index.ts +2 -1
  106. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-my-groups/index.ts +2 -1
  107. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-plans/index.ts +1 -1
  108. package/templates/nonprofit-cause/tier5-src/himi-events-detail/index.ts +5 -5
  109. package/templates/nonprofit-cause-owner/tier5-src/_shared/app-locale.ts +61 -0
  110. package/templates/nonprofit-cause-owner/tier5-src/_shared/engine-intl.ts +5 -3
  111. package/templates/nonprofit-cause-owner/tier5-src/_shared/himi-copy.ts +10 -2
  112. package/templates/owner-lite/tier5-src/_shared/app-locale.ts +61 -0
  113. package/templates/owner-lite/tier5-src/_shared/engine-intl.ts +5 -3
  114. package/templates/owner-lite/tier5-src/_shared/himi-copy.ts +10 -2
  115. package/templates/podcast-creator/tier5-src/_shared/app-locale.ts +61 -0
  116. package/templates/podcast-creator/tier5-src/_shared/engine-intl.ts +5 -3
  117. package/templates/podcast-creator/tier5-src/_shared/group-member-count.ts +15 -0
  118. package/templates/podcast-creator/tier5-src/_shared/himi-copy.ts +10 -2
  119. package/templates/podcast-creator/tier5-src/_shared/himi-restaurants.ts +2 -2
  120. package/templates/podcast-creator/tier5-src/_shared/wix-service-price.ts +2 -2
  121. package/templates/podcast-creator/tier5-src/himi-branded-lite-home/index.ts +2 -1
  122. package/templates/podcast-creator/tier5-src/himi-branded-lite-plans/index.ts +1 -1
  123. package/templates/podcast-creator/tier5-src/himi-events-detail/index.ts +5 -5
  124. package/templates/podcast-creator-owner/tier5-src/_shared/app-locale.ts +61 -0
  125. package/templates/podcast-creator-owner/tier5-src/_shared/engine-intl.ts +5 -3
  126. package/templates/podcast-creator-owner/tier5-src/_shared/himi-copy.ts +10 -2
  127. package/templates/portfolio-creative/tier5-src/_shared/app-locale.ts +61 -0
  128. package/templates/portfolio-creative/tier5-src/_shared/engine-intl.ts +5 -3
  129. package/templates/portfolio-creative/tier5-src/_shared/group-member-count.ts +15 -0
  130. package/templates/portfolio-creative/tier5-src/_shared/himi-copy.ts +10 -2
  131. package/templates/portfolio-creative/tier5-src/_shared/himi-restaurants.ts +2 -2
  132. package/templates/portfolio-creative/tier5-src/_shared/restaurant-ordering.ts +1 -1
  133. package/templates/portfolio-creative/tier5-src/_shared/wix-service-price.ts +2 -2
  134. package/templates/portfolio-creative/tier5-src/himi-branded-lite-cart/index.ts +1 -1
  135. package/templates/portfolio-creative/tier5-src/himi-branded-lite-home/index.ts +2 -1
  136. package/templates/portfolio-creative/tier5-src/himi-events-detail/index.ts +5 -5
  137. package/templates/portfolio-creative-owner/tier5-src/_shared/app-locale.ts +61 -0
  138. package/templates/portfolio-creative-owner/tier5-src/_shared/engine-intl.ts +5 -3
  139. package/templates/portfolio-creative-owner/tier5-src/_shared/himi-copy.ts +10 -2
  140. package/templates/restaurant/tier5-src/_shared/app-locale.ts +61 -0
  141. package/templates/restaurant/tier5-src/_shared/engine-intl.ts +5 -3
  142. package/templates/restaurant/tier5-src/_shared/group-member-count.ts +15 -0
  143. package/templates/restaurant/tier5-src/_shared/himi-copy.ts +10 -2
  144. package/templates/restaurant/tier5-src/_shared/himi-restaurants.ts +2 -2
  145. package/templates/restaurant/tier5-src/_shared/restaurant-ordering.ts +1 -1
  146. package/templates/restaurant/tier5-src/_shared/wix-service-price.ts +2 -2
  147. package/templates/restaurant/tier5-src/himi-branded-lite-home/index.ts +2 -1
  148. package/templates/restaurant/tier5-src/himi-events-detail/index.ts +5 -5
  149. package/templates/restaurant-owner/tier5-src/_shared/app-locale.ts +61 -0
  150. package/templates/restaurant-owner/tier5-src/_shared/engine-intl.ts +5 -3
  151. package/templates/restaurant-owner/tier5-src/_shared/himi-copy.ts +10 -2
  152. package/templates/retail-store/tier5-src/_shared/app-locale.ts +61 -0
  153. package/templates/retail-store/tier5-src/_shared/engine-intl.ts +5 -3
  154. package/templates/retail-store/tier5-src/_shared/group-member-count.ts +15 -0
  155. package/templates/retail-store/tier5-src/_shared/himi-copy.ts +10 -2
  156. package/templates/retail-store/tier5-src/_shared/himi-restaurants.ts +2 -2
  157. package/templates/retail-store/tier5-src/_shared/restaurant-ordering.ts +1 -1
  158. package/templates/retail-store/tier5-src/_shared/wix-service-price.ts +2 -2
  159. package/templates/retail-store/tier5-src/himi-branded-lite-cart/index.ts +1 -1
  160. package/templates/retail-store/tier5-src/himi-branded-lite-home/index.ts +2 -1
  161. package/templates/retail-store-owner/tier5-src/_shared/app-locale.ts +61 -0
  162. package/templates/retail-store-owner/tier5-src/_shared/engine-intl.ts +5 -3
  163. package/templates/retail-store-owner/tier5-src/_shared/himi-copy.ts +10 -2
  164. package/templates/yoga-studio/tier5-src/_shared/app-locale.ts +61 -0
  165. package/templates/yoga-studio/tier5-src/_shared/engine-intl.ts +5 -3
  166. package/templates/yoga-studio/tier5-src/_shared/group-member-count.ts +15 -0
  167. package/templates/yoga-studio/tier5-src/_shared/himi-bookings.ts +54 -13
  168. package/templates/yoga-studio/tier5-src/_shared/himi-copy.ts +10 -2
  169. package/templates/yoga-studio/tier5-src/_shared/himi-restaurants.ts +2 -2
  170. package/templates/yoga-studio/tier5-src/_shared/wix-service-price.ts +2 -2
  171. package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +8 -3
  172. package/templates/yoga-studio/tier5-src/himi-branded-lite-home/index.ts +2 -1
  173. package/templates/yoga-studio/tier5-src/himi-branded-lite-plans/index.ts +1 -1
  174. package/templates/yoga-studio/tier5-src/himi-branded-lite-program/index.ts +1 -1
  175. package/templates/yoga-studio/tier5-src/himi-events-detail/index.ts +5 -5
  176. package/templates/yoga-studio-owner/tier5-src/_shared/app-locale.ts +61 -0
  177. package/templates/yoga-studio-owner/tier5-src/_shared/engine-intl.ts +5 -3
  178. package/templates/yoga-studio-owner/tier5-src/_shared/himi-copy.ts +10 -2
@@ -12,10 +12,12 @@
12
12
  // and every read returns `fallback` verbatim — routing a worker string through
13
13
  // copyParam is byte-for-byte behavior-preserving.
14
14
  //
15
- // Pure + Hermes-safe (no host globals, no Node APIs, no imports): bundled into
15
+ // Pure + Hermes-safe (no host globals, no Node APIs; its only import is the pure `app-locale` sibling): bundled into
16
16
  // every worker that emits user-facing copy, and copied verbatim into the
17
17
  // generated templates' tier5-src/_shared/.
18
18
 
19
+ import { isSingular, setAppLocale, setBusinessTimeZone } from "./app-locale.js";
20
+
19
21
  /** Customer strings override every read; translated stock is valid only for its
20
22
  * original English fallback. Some existing keys serve more than one message. */
21
23
  export type CopyPackValue = string | { source: string; translation: string };
@@ -29,6 +31,12 @@ let copyReceived = false;
29
31
  * stock fallbacks keep rendering. */
30
32
  export function stashCopy(arg: unknown): void {
31
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
+ }
32
40
  const raw = (arg as Record<string, unknown>).copy;
33
41
  if (typeof raw !== "string" || !raw) return;
34
42
  try {
@@ -77,5 +85,5 @@ export function copyPlural(key: string, fallback: string, count: number): string
77
85
  const pattern = copyParam(key, fallback);
78
86
  const match = /^\{n, plural, one \{([^{}]*)\} other \{([^{}]*)\}\}$/.exec(pattern);
79
87
  if (!match) return copyTemplate(key, fallback, { n: count });
80
- return (count === 1 ? match[1] : match[2])!.replace(/#/g, String(count));
88
+ return (isSingular(count) ? match[1] : match[2])!.replace(/#/g, String(count));
81
89
  }
@@ -0,0 +1,61 @@
1
+ // The language a worker formats numbers, money and plurals in, and the business's time zone. A worker has no host locale to read,
2
+ // so the screen's `onAppear` hands it over: `appear()` adds `locale` to the load params when the app
3
+ // pins its language, and `stashCopy` stores it here. Absent, every formatter stays on "en" — the
4
+ // house pattern every English app was approved with — so an unpinned app is unchanged.
5
+ //
6
+ // The business time zone comes the same way, from the app's provisioning (`businessTimeZone`, collected
7
+ // at migration time while the site's properties are readable): a worker cannot read it itself, because
8
+ // the site-properties route is owner-scoped. Absent, it is "" and no worker guesses one.
9
+ //
10
+ // Pure, no host globals: bundled into every worker that formats, copied into generated templates.
11
+
12
+ const TAG = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
13
+ let tag = "en";
14
+
15
+ /** Set from a load argument's `locale`; anything that is not a language tag resets to "en". */
16
+ export function setAppLocale(value: unknown): void {
17
+ tag = typeof value === "string" && TAG.test(value) ? value : "en";
18
+ }
19
+
20
+ export function appLocale(): string {
21
+ return tag;
22
+ }
23
+
24
+ /** An IANA zone such as "Asia/Jerusalem" (or "UTC") that this engine knows. A bare offset, a lowercase
25
+ * alias or an engine without ECMA-402 is not one: a zone is never guessed. */
26
+ export function isIanaTimeZone(value: unknown): value is string {
27
+ if (typeof value !== "string" || !/^(?:UTC|[A-Za-z]+(?:\/[A-Za-z0-9_+-]+)+)$/.test(value)) return false;
28
+ // An engine without ECMA-402 has no zone database: nothing it holds is a zone it knows.
29
+ if (typeof Intl === "object" && typeof Intl.DateTimeFormat === "function") {
30
+ try {
31
+ new Intl.DateTimeFormat("en-US", { timeZone: value });
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+ return false;
38
+ }
39
+
40
+ let zone = "";
41
+
42
+ /** Set from a load argument's `businessTimeZone`; anything that is not an IANA zone clears it. */
43
+ export function setBusinessTimeZone(value: unknown): void {
44
+ zone = isIanaTimeZone(value) ? value : "";
45
+ }
46
+
47
+ /** The business's IANA zone, or "" when the app has none provisioned. */
48
+ export function businessTimeZone(): string {
49
+ return zone;
50
+ }
51
+
52
+ /** Does `count` take the singular form in the app language? CLDR plural rules, so French "0 point"
53
+ * and "1,5 point" are singular and Hebrew "2" is not. An engine without PluralRules keeps the
54
+ * English rule (exactly one). */
55
+ export function isSingular(count: number, locale = appLocale()): boolean {
56
+ try {
57
+ // An engine without ECMA-402 has no `Intl` at all: it keeps the English rule.
58
+ if (typeof Intl === "object" && typeof Intl.PluralRules === "function") return new Intl.PluralRules(locale).select(count) === "one";
59
+ } catch { /* malformed tag — use the English rule */ }
60
+ return count === 1;
61
+ }
@@ -20,6 +20,8 @@
20
20
  // These helpers are the sanctioned way to format across the whole fleet: real ECMA-402 where it
21
21
  // exists, a deterministic en-US-shaped fallback where it does not, and never a throw.
22
22
 
23
+ import { appLocale } from "./app-locale.js";
24
+
23
25
  /** Is ECMA-402 actually present in THIS engine? Cheap, and safe in a realm without `Intl`. */
24
26
  export function hasIntl(): boolean {
25
27
  return typeof Intl !== "undefined";
@@ -189,7 +191,7 @@ const CURRENCY_DIGITS: Record<string, number> = {
189
191
  export function formatCurrency(
190
192
  amount: number,
191
193
  currency = "USD",
192
- locale = "en",
194
+ locale = appLocale(),
193
195
  /** Omit for the currency's natural digits. Pass 0 to render a whole amount as "$25" rather
194
196
  * than "$25.00" — Intl clamps the minimum down to match, so max<min does not throw here. */
195
197
  maximumFractionDigits?: number,
@@ -258,7 +260,7 @@ function groupThousands(fixed: string): string {
258
260
  * inserted commas into the exponent — `1e21` came out as "1e,+21" and `1e-7` as "1,e-7".
259
261
  * Garbage, and silently so, because nothing in the corpus's tidy test numbers reached it.
260
262
  */
261
- export function formatNumber(value: number, locale = "en"): string {
263
+ export function formatNumber(value: number, locale = appLocale()): string {
262
264
  if (!Number.isFinite(value)) return "";
263
265
  if (hasIntl()) return new Intl.NumberFormat(locale).format(value);
264
266
  // `toFixed` itself returns exponential notation at >=1e21, so anything that big keeps the
@@ -409,7 +411,7 @@ export function compareDateDesc(a: string, b: string): number {
409
411
  // Customers read that as sloppiness, and a whole "₪70" beside "₪69.50" reads as a different
410
412
  // kind of number. So: one money pattern, one clock, one range.
411
413
 
412
- // Money uses `formatCurrency(amount, currency, "en")` — the currency's NATURAL decimals
414
+ // Money uses `formatCurrency(amount, currency)` — English unless the app pins another language, see app-locale — the currency's NATURAL decimals
413
415
  // ("₪70.00", "¥500") — for Shop, Menu and every other price. The one exception is a Bookings
414
416
  // SERVICE price (`_shared/wix-service-price.ts`): it drops the cents of a whole amount ("₪70",
415
417
  // "₪69.50"), the format the redesigned service screen and services list were approved with. It
@@ -0,0 +1,15 @@
1
+ import { copyPlural } from "./himi-copy.js";
2
+
3
+ /** "840 members" / "1 member" for a group, through the copy pack so a translated app reads its own
4
+ * language. One key for every worker that shows the count: a screen that renders it must list the
5
+ * `groups.` prefix in its `appear()` so the pack carries the key. */
6
+ export function groupMemberCount(count: number): string {
7
+ return copyPlural("groups.memberCount", "{n, plural, one {# member} other {# members}}", count);
8
+ }
9
+
10
+ /** The label for a group read back from the offline cache. A cached row keeps the raw `count`, never
11
+ * the formatted label, so a pinned app reads it in its own language. A row written before that (label
12
+ * only) has no count and shows none, instead of a label in the wrong language. */
13
+ export function cachedMemberCount(count: unknown): string {
14
+ return typeof count === "number" && Number.isFinite(count) ? groupMemberCount(count) : "";
15
+ }
@@ -17,6 +17,7 @@ export function categoryName(value: unknown): string {
17
17
  return typeof name === "string" ? name.trim() : "";
18
18
  }
19
19
 
20
+ import { businessTimeZone } from "./app-locale.js";
20
21
  import { formatDateMedium, formatMonthDay, zoneOffsetAt } from "./engine-intl.js";
21
22
  import { formatServiceMoney, formatServicePrice, serviceCurrency } from "./wix-service-price.js";
22
23
 
@@ -72,7 +73,8 @@ export function slotIdAt(slots: Iterable<[string, { startDate?: string; timezone
72
73
  // the button never offers a course that the booking step then refuses.
73
74
 
74
75
  type CoursePolicyShape = {
75
- schedule?: { id?: string; firstSessionStart?: string; lastSessionEnd?: string };
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 };
76
78
  bookingPolicy?: {
77
79
  bookAfterStartPolicy?: { enabled?: boolean };
78
80
  limitEarlyBookingPolicy?: { enabled?: boolean; earliestBookingInMinutes?: number };
@@ -80,25 +82,63 @@ type CoursePolicyShape = {
80
82
  };
81
83
  };
82
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
+
83
119
  /** A completed course, independent of whether bookings are currently allowed. Missing,
84
- * invalid or reversed dates are unknown, never evidence that a course has ended. */
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`. */
85
122
  export function courseHasEnded(service: CoursePolicyShape, now: number): boolean {
86
- const start = service.schedule?.firstSessionStart ?? "";
87
- const end = service.schedule?.lastSessionEnd ?? "";
88
- // The schedule has no zone beside these fields. A local wall time must not be interpreted
89
- // in the device's zone and presented as a confirmed business end instant.
90
- if (!HAS_OFFSET.test(start) || !HAS_OFFSET.test(end)) return false;
91
- const first = validDate(start)?.getTime() ?? NaN;
92
- const last = validDate(end)?.getTime() ?? NaN;
123
+ const zone = courseZone(service.schedule);
124
+ const first = sessionInstant(service.schedule?.firstSessionStart ?? "", zone);
125
+ const last = sessionInstant(service.schedule?.lastSessionEnd ?? "", zone);
93
126
  return Number.isFinite(now) && Number.isFinite(first) && Number.isFinite(last) && first <= last && last <= now;
94
127
  }
95
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
+
96
135
  /** Whether the business takes course bookings at `now` (ms): the course has not ended, has not
97
136
  * started (unless the policy lets people join late), and `now` is inside its booking window —
98
137
  * which the early and late limits count from the first session. */
99
138
  export function courseWindowOpen(service: CoursePolicyShape, now: number): boolean {
100
- const first = Date.parse(service.schedule?.firstSessionStart ?? "");
101
- const last = Date.parse(service.schedule?.lastSessionEnd ?? "");
139
+ const zone = courseZone(service.schedule);
140
+ const first = courseInstant(service.schedule?.firstSessionStart, zone);
141
+ const last = courseInstant(service.schedule?.lastSessionEnd, zone);
102
142
  if (!Number.isFinite(first) || !Number.isFinite(last) || last <= now) return false;
103
143
  const policy = service.bookingPolicy;
104
144
  if (first < now && policy?.bookAfterStartPolicy?.enabled !== true) return false;
@@ -119,8 +159,9 @@ const DAY = 86_400_000;
119
159
  * its last session (`complete`) and start at its first (`fromFirst`, so a count is the whole
120
160
  * course). Empty when the course has no usable dates or has ended. */
121
161
  export function courseReadWindows(service: CoursePolicyShape, now: number, chunkDays = 14): { windows: Array<[number, number]>; complete: boolean; fromFirst: boolean } {
122
- const first = Date.parse(service.schedule?.firstSessionStart ?? "");
123
- const last = Date.parse(service.schedule?.lastSessionEnd ?? "");
162
+ const zone = courseZone(service.schedule);
163
+ const first = courseInstant(service.schedule?.firstSessionStart, zone);
164
+ const last = courseInstant(service.schedule?.lastSessionEnd, zone);
124
165
  if (!Number.isFinite(first) || !Number.isFinite(last)) return { windows: [], complete: false, fromFirst: false };
125
166
  const from = Math.max(first, now);
126
167
  const courseEnd = last + DAY;
@@ -12,10 +12,12 @@
12
12
  // and every read returns `fallback` verbatim — routing a worker string through
13
13
  // copyParam is byte-for-byte behavior-preserving.
14
14
  //
15
- // Pure + Hermes-safe (no host globals, no Node APIs, no imports): bundled into
15
+ // Pure + Hermes-safe (no host globals, no Node APIs; its only import is the pure `app-locale` sibling): bundled into
16
16
  // every worker that emits user-facing copy, and copied verbatim into the
17
17
  // generated templates' tier5-src/_shared/.
18
18
 
19
+ import { isSingular, setAppLocale, setBusinessTimeZone } from "./app-locale.js";
20
+
19
21
  /** Customer strings override every read; translated stock is valid only for its
20
22
  * original English fallback. Some existing keys serve more than one message. */
21
23
  export type CopyPackValue = string | { source: string; translation: string };
@@ -29,6 +31,12 @@ let copyReceived = false;
29
31
  * stock fallbacks keep rendering. */
30
32
  export function stashCopy(arg: unknown): void {
31
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
+ }
32
40
  const raw = (arg as Record<string, unknown>).copy;
33
41
  if (typeof raw !== "string" || !raw) return;
34
42
  try {
@@ -77,5 +85,5 @@ export function copyPlural(key: string, fallback: string, count: number): string
77
85
  const pattern = copyParam(key, fallback);
78
86
  const match = /^\{n, plural, one \{([^{}]*)\} other \{([^{}]*)\}\}$/.exec(pattern);
79
87
  if (!match) return copyTemplate(key, fallback, { n: count });
80
- return (count === 1 ? match[1] : match[2])!.replace(/#/g, String(count));
88
+ return (isSingular(count) ? match[1] : match[2])!.replace(/#/g, String(count));
81
89
  }
@@ -104,7 +104,7 @@ export function itemPriceLine(item: WixItem): string {
104
104
  const currency = typeof info?.currency === "string" ? info.currency : "";
105
105
  if (!currency) return `${price}`;
106
106
  try {
107
- return formatCurrency(Number(price), currency, "en");
107
+ return formatCurrency(Number(price), currency);
108
108
  } catch {
109
109
  return `${price}`;
110
110
  }
@@ -153,7 +153,7 @@ export function itemPriceVariants(item: WixItem): Array<{ id: string; variantId:
153
153
  let priceLine = `${price}`;
154
154
  if (currency) {
155
155
  try {
156
- priceLine = formatCurrency(Number(price), currency, "en");
156
+ priceLine = formatCurrency(Number(price), currency);
157
157
  } catch { /* keep the bare amount */ }
158
158
  }
159
159
  const formatted = typeof node.priceInfo?.formattedPrice === "string" ? node.priceInfo.formattedPrice.trim() : "";
@@ -74,7 +74,7 @@ export function decimal(value: unknown): number {
74
74
 
75
75
  /** Cart V2 money carries no formatted string: format the decimal with the cart's currency. */
76
76
  export function money(amount: unknown, currency: string | undefined): string {
77
- return formatCurrency(decimal(amount), (currency ?? "").trim() || "USD", "en");
77
+ return formatCurrency(decimal(amount), (currency ?? "").trim() || "USD");
78
78
  }
79
79
 
80
80
  export function cartCurrency(cart: WixCartV2 | undefined): string {
@@ -111,7 +111,7 @@ export function formatServiceMoney(money: unknown, fallbackCurrency = "USD"): st
111
111
  if (amount == null) return "";
112
112
  const currency = stringOf(m.currency) || fallbackCurrency;
113
113
  try {
114
- return formatCurrency(amount, currency, "en", Number.isInteger(amount) ? 0 : undefined);
114
+ return formatCurrency(amount, currency, undefined, Number.isInteger(amount) ? 0 : undefined);
115
115
  } catch {
116
116
  return `${amount}`;
117
117
  }
@@ -139,7 +139,7 @@ export function formatServicePrice(service: unknown): string {
139
139
  try {
140
140
  // A whole amount drops its cents ("₪70", not "₪70.00"), as formatServiceMoney and the service
141
141
  // screen show it, so the same service reads the same price on every screen.
142
- return formatCurrency(amount, currency, "en", Number.isInteger(amount) ? 0 : undefined);
142
+ return formatCurrency(amount, currency, undefined, Number.isInteger(amount) ? 0 : undefined);
143
143
  } catch {
144
144
  return `${amount}`;
145
145
  }
@@ -14,7 +14,8 @@ import { defineService } from "@himalaya/state";
14
14
  import { defineBackendCollection } from "../_shared/collection-service.js";
15
15
  import { COLLECTION_CHROME_MIN_ITEMS } from "../_shared/collection-contract.js";
16
16
  import { queryServiceCategories, queryServices, type WixService } from "@himalaya/wix-bookings";
17
- import { categoryName, courseDatesText, courseHasEnded, serviceDurationText, servicePriceLine, type BookingLabel } from "../_shared/himi-bookings.js";
17
+ import { businessTimeZone } from "../_shared/app-locale.js";
18
+ import { categoryName, courseDatesText, courseHasEnded, courseNeedsZone, serviceDurationText, servicePriceLine, type BookingLabel } from "../_shared/himi-bookings.js";
18
19
  import { formatServicePrice } from "../_shared/wix-service-price.js";
19
20
  import { getAll, upsert, type DbEntity } from "@himalaya/local-db";
20
21
  import { get as getAppInfo } from "@himalaya/app-info";
@@ -74,7 +75,7 @@ interface Row {
74
75
  noTypeTag: boolean;
75
76
  endedText: string;
76
77
  /** Retain course dates so a cached catalog can refresh its status after the course ends. */
77
- courseSchedule?: { firstSessionStart?: string; lastSessionEnd?: string };
78
+ courseSchedule?: { firstSessionStart?: string; lastSessionEnd?: string; timeZone?: string };
78
79
  /** "1 hr · ₪70"; a course: "Nov 3 – Dec 15, 2026 · ₪1,200". "" when the record has neither. */
79
80
  meta: string;
80
81
  /** Online booking by time is possible: online booking is on and the service is not a course. */
@@ -269,11 +270,15 @@ function norm(service: WixService): Row {
269
270
  ].filter(Boolean);
270
271
  const image = imageUrl(service);
271
272
  const isCourse = service.type === "COURSE";
273
+ // A session time Wix lists without an offset is the business's wall time: the schedule carries the
274
+ // provisioned zone that places it (none provisioned: not Ended, never a guess), and the cached row
275
+ // keeps it so an offline list can still age out.
272
276
  const courseSchedule = isCourse ? {
273
277
  firstSessionStart: service.schedule?.firstSessionStart,
274
278
  lastSessionEnd: service.schedule?.lastSessionEnd,
279
+ ...(businessTimeZone() && courseNeedsZone(service.schedule) ? { timeZone: businessTimeZone() } : {}),
275
280
  } : undefined;
276
- const endedText = isCourse && courseHasEnded(service, Date.now()) ? copyParam("services.ended", "Ended") : "";
281
+ const endedText = courseSchedule && courseHasEnded({ schedule: courseSchedule }, Date.now()) ? copyParam("services.ended", "Ended") : "";
277
282
  const price = servicePriceLine(service, listLabel).text;
278
283
  const meta = [isCourse ? courseDatesText(service) : serviceDurationText(service, listLabel), price].filter(Boolean).join(" · ");
279
284
  const typeTag = typeTagOf(service.type);
@@ -364,7 +364,7 @@ function calculatedDiscount(result: unknown, currency: string): string {
364
364
  const discount = (result as { summary?: { priceSummary?: { discount?: { amount?: string; convertedAmount?: string } } } } | null)?.summary?.priceSummary?.discount;
365
365
  const amount = Number(discount?.amount);
366
366
  if (!Number.isFinite(amount) || amount === 0 || !currency) return "";
367
- return formattedDiscount(formatCurrency(Math.abs(amount), currency, "en"));
367
+ return formattedDiscount(formatCurrency(Math.abs(amount), currency));
368
368
  }
369
369
 
370
370
  function invalidCouponFailure(error: unknown): boolean {
@@ -7,6 +7,7 @@ import { initials } from "../_shared/himi-text.js";
7
7
  import { groupImage } from "../_shared/himi-media.js";
8
8
  import { WIX_DETAIL_IMAGE } from "@himalaya/wix-media";
9
9
  import { copyParam, stashCopy } from "../_shared/himi-copy.js";
10
+ import { cachedMemberCount, groupMemberCount } from "../_shared/group-member-count.js";
10
11
  import { get as dbGet, upsert as dbUpsert, type DbEntity } from "@himalaya/local-db";
11
12
  import { isOfflineFailure, writeMayHaveLanded } from "../_shared/owner-load-error";
12
13
  import { descriptionBody } from "../_shared/rich-body.js";
@@ -37,7 +38,7 @@ async function cachePutMembership(state: State, joined: boolean): Promise<void>
37
38
  id: state.id,
38
39
  title: state.name,
39
40
  subtitle: state.description,
40
- meta: state.memberCount,
41
+ count: state.memberTotal,
41
42
  image: state.image,
42
43
  initial: state.initial,
43
44
  hasImage: state.hasImage,
@@ -58,6 +59,8 @@ interface State {
58
59
  hasDescriptionJson: boolean;
59
60
  privacy: string;
60
61
  memberCount: string;
62
+ /** The raw count behind `memberCount`, written to the offline cache instead of the label. */
63
+ memberTotal: number | null;
61
64
  /** Member avatar strip (photo or initials per member) — best-effort: the
62
65
  * per-group members query + member profile resolution both degrade to an
63
66
  * empty (collapsed) strip, never a failed screen. */
@@ -141,6 +144,7 @@ const initial: State = {
141
144
  hasDescriptionJson: false,
142
145
  privacy: "",
143
146
  memberCount: "",
147
+ memberTotal: null,
144
148
  memberAvatars: [],
145
149
  hasMemberAvatars: false,
146
150
  noMemberAvatars: true,
@@ -198,7 +202,8 @@ const svc = defineService<State, {
198
202
  descriptionJson: body.bodyJson,
199
203
  hasDescriptionJson: body.bodyJson !== "",
200
204
  privacy: privacyLabel,
201
- memberCount: count == null ? "" : `${count} ${count === 1 ? "member" : "members"}`,
205
+ memberCount: count == null ? "" : groupMemberCount(count),
206
+ memberTotal: count ?? null,
202
207
  memberAvatars,
203
208
  hasMemberAvatars: memberAvatars.length > 0,
204
209
  noMemberAvatars: memberAvatars.length === 0,
@@ -232,7 +237,8 @@ const svc = defineService<State, {
232
237
  descriptionJson: body.bodyJson,
233
238
  hasDescriptionJson: body.bodyJson !== "",
234
239
  privacy: "Unknown",
235
- memberCount: String(cached.meta ?? ""),
240
+ memberCount: cachedMemberCount(cached.count),
241
+ memberTotal: typeof cached.count === "number" ? cached.count : null,
236
242
  image,
237
243
  initial: (name[0] || "G").toUpperCase(),
238
244
  message: "",
@@ -2,6 +2,7 @@ import { defineService } from "@himalaya/state";
2
2
  import { getGroup, listRules, type WixGroup, type WixGroupRule } from "@himalaya/wix-groups";
3
3
  import { WIX_DETAIL_IMAGE } from "@himalaya/wix-media";
4
4
  import { copyParam, stashCopy } from "../_shared/himi-copy.js";
5
+ import { groupMemberCount } from "../_shared/group-member-count.js";
5
6
  import { groupImage } from "../_shared/himi-media.js";
6
7
  import { descriptionBody } from "../_shared/rich-body.js";
7
8
  import { openRichLink } from "../_shared/rich-content-link.js";
@@ -171,7 +172,7 @@ function groupState(state: State, id: string, group: WixGroup): State {
171
172
  hasDescriptionJson: body.bodyJson !== "",
172
173
  privacy,
173
174
  createdDate: created,
174
- memberCount: typeof count === "number" ? `${count} ${count === 1 ? "member" : "members"}` : "",
175
+ memberCount: typeof count === "number" ? groupMemberCount(count) : "",
175
176
  image,
176
177
  initial: (name[0] || "G").toUpperCase(),
177
178
  errorText: "",
@@ -2,6 +2,7 @@ import { defineService } from "../_shared/collection-service.js";
2
2
  import { queryGroups, type WixGroup } from "@himalaya/wix-groups";
3
3
  import { groupImage } from "../_shared/himi-media.js";
4
4
  import { copyParam, stashCopy } from "../_shared/himi-copy.js";
5
+ import { cachedMemberCount, groupMemberCount } from "../_shared/group-member-count.js";
5
6
  import { getAll, upsert, type DbEntity } from "@himalaya/local-db";
6
7
  import { descriptionBody, ROW_IMAGE_WIDTH } from "../_shared/rich-body.js";
7
8
 
@@ -16,7 +17,7 @@ const ENTITY = "groups";
16
17
  async function readCached(): Promise<GroupRow[]> {
17
18
  try {
18
19
  const rows = (await getAll(ENTITY)) as unknown as GroupRow[];
19
- return rows.map((row) => ({ ...row, subtitle: snippet(descriptionBody(row.subtitle).bodyText
20
+ return rows.map((row) => ({ ...row, meta: cachedMemberCount(row.count), subtitle: snippet(descriptionBody(row.subtitle).bodyText
20
21
  || descriptionBody(row.descriptionJson || row.description).bodyText) }));
21
22
  } catch { return []; }
22
23
  }
@@ -31,6 +32,8 @@ interface GroupRow {
31
32
  description: string;
32
33
  descriptionJson: string;
33
34
  meta: string;
35
+ /** The raw member count, kept so the cached label is rebuilt in the current language. */
36
+ count: number | null;
34
37
  image: string;
35
38
  initial: string;
36
39
  hasImage: boolean;
@@ -69,7 +72,8 @@ function groupRow(group: WixGroup): GroupRow {
69
72
  subtitle: snippet(teaser) || snippet(body.bodyText) || copyParam("groups.subtitleFallback", "Join the conversation."),
70
73
  description: body.bodyText,
71
74
  descriptionJson: body.bodyJson,
72
- meta: count == null ? "" : `${count} ${count === 1 ? "member" : "members"}`,
75
+ meta: count == null ? "" : groupMemberCount(count),
76
+ count: count ?? null,
73
77
  image,
74
78
  initial: (title[0] || "G").toUpperCase(),
75
79
  hasImage: Boolean(image),
@@ -19,6 +19,7 @@ import { formatServicePrice } from "../_shared/wix-service-price.js";
19
19
  import { cleanTitle, initials } from "../_shared/himi-text.js";
20
20
  import { LAYOUT_KEY, type LayoutId, recommendLayout, resolveLayout } from "../_shared/himi-layouts.js";
21
21
  import { copyParam, copyTemplate, stashCopy } from "../_shared/himi-copy.js";
22
+ import { groupMemberCount } from "../_shared/group-member-count.js";
22
23
  import { parseWeeklyGoal, weeklyGoalProgress, weeklySessionsFromPages } from "../_shared/weekly-goal.js";
23
24
  import { isBridgeUnavailable } from "../_shared/owner-load-error.js";
24
25
  import { formatDateMedium } from "../_shared/engine-intl.js";
@@ -736,7 +737,7 @@ function groupCard(group: WixGroup): GroupCard {
736
737
  return {
737
738
  id: group.id || title,
738
739
  title,
739
- meta: count == null ? "" : `${count} ${count === 1 ? "member" : "members"}`,
740
+ meta: count == null ? "" : groupMemberCount(count),
740
741
  image,
741
742
  initial: (title[0] || "G").toUpperCase(),
742
743
  hasImage: !!image,
@@ -3,6 +3,7 @@ import { defineService } from "../_shared/collection-service.js";
3
3
  import { LOADING, loaded, loadFailed, type LoadCopy, type LoadFlags } from "../_shared/branded-load-state.js";
4
4
  import { copyParam, copyTemplate } from "../_shared/himi-copy.js";
5
5
  import { formatCurrency, formatNumber } from "../_shared/engine-intl.js";
6
+ import { isSingular } from "../_shared/app-locale.js";
6
7
  import { memberAccess, assertMemberAccess } from "../_shared/member-access.js";
7
8
  import { getLoyaltyProgram, listLoyaltyRewards, listLoyaltyEarningRules, getCurrentMemberLoyaltyAccount, type WixLoyaltyReward, type WixLoyaltyEarningRule } from "@himalaya/wix-loyalty";
8
9
 
@@ -24,7 +25,7 @@ function baseConfig<T extends { tierId?: string }>(configs: T[] | undefined): T
24
25
  return base?.length === 1 ? base[0] : undefined;
25
26
  }
26
27
  function points(value: number, customUnit: string): string {
27
- const unit = customUnit || copyParam(value === 1 ? "loyalty.pointOne" : "loyalty.pointMany", value === 1 ? "point" : "points");
28
+ const unit = customUnit || copyParam(isSingular(value) ? "loyalty.pointOne" : "loyalty.pointMany", isSingular(value) ? "point" : "points");
28
29
  return copyTemplate("loyalty.pointsLabel", "{points} {unit}", { points: formatNumber(value), unit });
29
30
  }
30
31
  function rewardLabel(reward: WixLoyaltyReward, customUnit: string): string {
@@ -44,7 +45,7 @@ function earningLabel(rule: WixLoyaltyEarningRule, customUnit: string, currency:
44
45
  return copyParam("loyalty.earningUnavailable", "Points details unavailable");
45
46
  }
46
47
  return copyTemplate("loyalty.pointsPerSpend", "{points} per {amount}", {
47
- points: points(rate.points, customUnit), amount: formatCurrency(rate.moneyAmount, currency, "en", Number.isInteger(rate.moneyAmount) ? 0 : undefined),
48
+ points: points(rate.points, customUnit), amount: formatCurrency(rate.moneyAmount, currency, undefined, Number.isInteger(rate.moneyAmount) ? 0 : undefined),
48
49
  });
49
50
  }
50
51
  function businessStamp(): unknown {
@@ -2,6 +2,7 @@ import { defineService } from "@himalaya/state";
2
2
  import { getGroup, listMemberships, type WixGroup, type WixGroupMembership } from "@himalaya/wix-groups";
3
3
  import { getMyMember } from "@himalaya/wix-members";
4
4
  import { copyParam, stashCopy } from "../_shared/himi-copy.js";
5
+ import { groupMemberCount } from "../_shared/group-member-count.js";
5
6
  import { groupImage } from "../_shared/himi-media.js";
6
7
  import { memberAccess, memberAccessDenied, type MemberAccess } from "../_shared/member-access.js";
7
8
 
@@ -103,7 +104,7 @@ function rowOf(group: WixGroup, membership: WixGroupMembership): MyGroupRow {
103
104
  id: String(membership.groupId),
104
105
  title,
105
106
  statusLabel: statusLabelOf(membership),
106
- meta: typeof count === "number" ? `${count} ${count === 1 ? "member" : "members"}` : "",
107
+ meta: typeof count === "number" ? groupMemberCount(count) : "",
107
108
  image,
108
109
  initial: (title[0] || "G").toUpperCase(),
109
110
  hasImage: Boolean(image),
@@ -82,7 +82,7 @@ function money(plan: WixPlan): string {
82
82
  const raw = price?.value;
83
83
  if (raw == null || raw === "" || Number.isNaN(Number(raw))) return "";
84
84
  try {
85
- return formatCurrency(Number(raw), price?.currency || "USD", "en");
85
+ return formatCurrency(Number(raw), price?.currency || "USD");
86
86
  } catch {
87
87
  return `${raw}`;
88
88
  }
@@ -147,7 +147,7 @@ function priceOf(p: WixProgram): string {
147
147
  if (raw == null || raw === "" || Number.isNaN(Number(raw))) return "";
148
148
  if (Number(raw) === 0) return "";
149
149
  try {
150
- return formatCurrency(Number(raw), price?.currency || "USD", "en");
150
+ return formatCurrency(Number(raw), price?.currency || "USD");
151
151
  } catch {
152
152
  return `${raw}`;
153
153
  }
@@ -243,7 +243,7 @@ function tierPrice(t: WixAvailableTicket): string {
243
243
  // as "25" — a price that reads as a quantity, and nothing downstream could tell it was a
244
244
  // formatting failure. `formatCurrency` uses real Intl where it exists and a symbol-carrying
245
245
  // fallback where it does not, diffed against ECMA-402 in engine-intl-fallback.test.ts.
246
- return formatCurrency(Number(raw), t.price?.currency || "USD", "en");
246
+ return formatCurrency(Number(raw), t.price?.currency || "USD");
247
247
  }
248
248
 
249
249
  function tierAvailable(t: WixAvailableTicket): boolean {
@@ -286,7 +286,7 @@ function tierRows(defs: WixAvailableTicket[], selectedId: string): TicketTierRow
286
286
  priceAmount: Number(t.price?.amount ?? 0),
287
287
  ...(isGuestPrice ? { priceLine: detailCopy("event.payWhatYouWant", "Pay what you want") } : {}),
288
288
  ...(isGuestPrice ? { priceAmount: minPrice, isGuestPrice: true } : {}),
289
- ...(isGuestPrice ? { minimumPriceLabel: detailCopy("event.minPrice", "Minimum {price}").replace("{price}", formatCurrency(minPrice, t.price?.currency || "USD", "en")) } : {}),
289
+ ...(isGuestPrice ? { minimumPriceLabel: detailCopy("event.minPrice", "Minimum {price}").replace("{price}", formatCurrency(minPrice, t.price?.currency || "USD")) } : {}),
290
290
  };
291
291
  });
292
292
  }
@@ -399,7 +399,7 @@ function derive(s: S): S {
399
399
  noFooterCaption: !s.footerCaption,
400
400
  hideFooter: s.status !== "ready" || s.errored || !s.footerLabel || s.rsvpState !== "none",
401
401
  checkoutDisabled: count === 0 || count > s.ticketLimitPerOrder || ticketCheckoutInFlight || !ticketCheckoutAllowed || s.ticketTiers.some((row) => row.isGuestPrice && (row.quantity ?? 0) > 0 && (!String(row.guestPrice ?? "").trim() || Number(row.guestPrice) < (row.minPrice ?? 0) || !Number.isFinite(Number(row.guestPrice)))) || s.ticketTiers.some((row) => (row.quantity ?? 0) > (row.maxQuantity ?? 99)),
402
- ticketTotal: formatCurrency(total, s.ticketCurrency || "USD", "en"),
402
+ ticketTotal: formatCurrency(total, s.ticketCurrency || "USD"),
403
403
  ticketCountLabel: copyParam("event.ticketCount", "{n} tickets, plus fees").replace("{n}", String(count)),
404
404
  noSchedule: s.schedule.length === 0,
405
405
  noFullSchedule: s.fullSchedule.length === 0,
@@ -494,7 +494,7 @@ function detailMoney(money?: EventDetailMoney): string {
494
494
  if (money?.formattedValue) return money.formattedValue;
495
495
  const value = money?.value;
496
496
  if (value == null || String(value).trim() === "" || !money?.currency || !Number.isFinite(Number(value))) return "";
497
- return formatCurrency(Number(value), money.currency, "en");
497
+ return formatCurrency(Number(value), money.currency);
498
498
  }
499
499
 
500
500
  function detailPlace(e: WixEvent): { value: string; mapUrl: string; hideMapTap: boolean; icon: string } {
@@ -617,7 +617,7 @@ const svc: ReturnType<typeof defineService<S, DetailActions>> = defineService<S,
617
617
  ticketSheetOpen: false,
618
618
  rsvpActionsOpen: false,
619
619
  cancelConfirmOpen: false,
620
- ticketTotal: formatCurrency(0, "USD", "en"),
620
+ ticketTotal: formatCurrency(0, "USD"),
621
621
  ticketCountLabel: copyParam("event.ticketCount", "{n} tickets, plus fees").replace("{n}", "0"),
622
622
  checkoutDisabled: true,
623
623
  schedule: [],
@@ -42,8 +42,12 @@ function name(m: WixMember): string {
42
42
  function followLabelFor(isFollowing: boolean): string {
43
43
  return isFollowing ? "Following ✓" : "Follow";
44
44
  }
45
+ /** Each counter on its own: a missing one is hidden, never shown as 0, and an explicit 0 stays visible. */
45
46
  function followCountFor(followers: number | null, following: number | null): string {
46
- return followers === null || following === null ? "" : followers + " Followers · " + following + " Following";
47
+ return [
48
+ followers === null ? "" : followers + (followers === 1 ? " Follower" : " Followers"),
49
+ following === null ? "" : following + " Following",
50
+ ].filter(Boolean).join(" · ");
47
51
  }
48
52
 
49
53
  const INITIAL: S = {