@wix/himalaya-cli 0.1589.0 → 0.1590.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 (174) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/members-v1/src/index.d.ts +9 -5
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-members.mjs +9 -8
  3. package/dist/cli.mjs +54 -12
  4. package/guides/one-app-to-himi-app.md +18 -4
  5. package/guides/sdk-reference.json +2 -2
  6. package/package.json +2 -2
  7. package/templates/beauty-salon/tier5-src/_shared/app-locale.ts +61 -0
  8. package/templates/beauty-salon/tier5-src/_shared/engine-intl.ts +5 -3
  9. package/templates/beauty-salon/tier5-src/_shared/group-member-count.ts +15 -0
  10. package/templates/beauty-salon/tier5-src/_shared/himi-bookings.ts +54 -13
  11. package/templates/beauty-salon/tier5-src/_shared/himi-copy.ts +10 -2
  12. package/templates/beauty-salon/tier5-src/_shared/himi-restaurants.ts +2 -2
  13. package/templates/beauty-salon/tier5-src/_shared/restaurant-ordering.ts +1 -1
  14. package/templates/beauty-salon/tier5-src/_shared/wix-service-price.ts +2 -2
  15. package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +8 -3
  16. package/templates/beauty-salon/tier5-src/himi-branded-lite-cart/index.ts +1 -1
  17. package/templates/beauty-salon/tier5-src/himi-branded-lite-home/index.ts +2 -1
  18. package/templates/beauty-salon/tier5-src/himi-branded-lite-plans/index.ts +1 -1
  19. package/templates/beauty-salon-owner/tier5-src/_shared/app-locale.ts +61 -0
  20. package/templates/beauty-salon-owner/tier5-src/_shared/engine-intl.ts +5 -3
  21. package/templates/beauty-salon-owner/tier5-src/_shared/himi-copy.ts +10 -2
  22. package/templates/branded-lite/tier5-src/_shared/app-locale.ts +61 -0
  23. package/templates/branded-lite/tier5-src/_shared/engine-intl.ts +5 -3
  24. package/templates/branded-lite/tier5-src/_shared/group-member-count.ts +15 -0
  25. package/templates/branded-lite/tier5-src/_shared/himi-bookings.ts +54 -13
  26. package/templates/branded-lite/tier5-src/_shared/himi-copy.ts +10 -2
  27. package/templates/branded-lite/tier5-src/_shared/himi-restaurants.ts +2 -2
  28. package/templates/branded-lite/tier5-src/_shared/restaurant-ordering.ts +1 -1
  29. package/templates/branded-lite/tier5-src/_shared/wix-service-price.ts +2 -2
  30. package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +8 -3
  31. package/templates/branded-lite/tier5-src/himi-branded-lite-cart/index.ts +1 -1
  32. package/templates/branded-lite/tier5-src/himi-branded-lite-group/index.ts +9 -3
  33. package/templates/branded-lite/tier5-src/himi-branded-lite-group-about/index.ts +2 -1
  34. package/templates/branded-lite/tier5-src/himi-branded-lite-groups/index.ts +6 -2
  35. package/templates/branded-lite/tier5-src/himi-branded-lite-home/index.ts +2 -1
  36. package/templates/branded-lite/tier5-src/himi-branded-lite-loyalty/index.ts +3 -2
  37. package/templates/branded-lite/tier5-src/himi-branded-lite-my-groups/index.ts +2 -1
  38. package/templates/branded-lite/tier5-src/himi-branded-lite-plans/index.ts +1 -1
  39. package/templates/branded-lite/tier5-src/himi-branded-lite-program/index.ts +1 -1
  40. package/templates/branded-lite/tier5-src/himi-events-detail/index.ts +5 -5
  41. package/templates/branded-lite/tier5-src/wix-spaces-member-detail/index.ts +5 -1
  42. package/templates/brandy/tier5-src/_shared/app-locale.ts +61 -0
  43. package/templates/brandy/tier5-src/_shared/himi-copy.ts +10 -2
  44. package/templates/community-club/tier5-src/_shared/app-locale.ts +61 -0
  45. package/templates/community-club/tier5-src/_shared/engine-intl.ts +5 -3
  46. package/templates/community-club/tier5-src/_shared/group-member-count.ts +15 -0
  47. package/templates/community-club/tier5-src/_shared/himi-copy.ts +10 -2
  48. package/templates/community-club/tier5-src/_shared/himi-restaurants.ts +2 -2
  49. package/templates/community-club/tier5-src/_shared/wix-service-price.ts +2 -2
  50. package/templates/community-club/tier5-src/himi-branded-lite-group/index.ts +9 -3
  51. package/templates/community-club/tier5-src/himi-branded-lite-group-about/index.ts +2 -1
  52. package/templates/community-club/tier5-src/himi-branded-lite-groups/index.ts +6 -2
  53. package/templates/community-club/tier5-src/himi-branded-lite-home/index.ts +2 -1
  54. package/templates/community-club/tier5-src/himi-branded-lite-my-groups/index.ts +2 -1
  55. package/templates/community-club/tier5-src/himi-events-detail/index.ts +5 -5
  56. package/templates/community-club-owner/tier5-src/_shared/app-locale.ts +61 -0
  57. package/templates/community-club-owner/tier5-src/_shared/engine-intl.ts +5 -3
  58. package/templates/community-club-owner/tier5-src/_shared/himi-copy.ts +10 -2
  59. package/templates/events-meetups/tier5-src/_shared/app-locale.ts +61 -0
  60. package/templates/events-meetups/tier5-src/_shared/engine-intl.ts +5 -3
  61. package/templates/events-meetups/tier5-src/_shared/group-member-count.ts +15 -0
  62. package/templates/events-meetups/tier5-src/_shared/himi-copy.ts +10 -2
  63. package/templates/events-meetups/tier5-src/_shared/himi-restaurants.ts +2 -2
  64. package/templates/events-meetups/tier5-src/_shared/wix-service-price.ts +2 -2
  65. package/templates/events-meetups/tier5-src/himi-branded-lite-group/index.ts +9 -3
  66. package/templates/events-meetups/tier5-src/himi-branded-lite-group-about/index.ts +2 -1
  67. package/templates/events-meetups/tier5-src/himi-branded-lite-groups/index.ts +6 -2
  68. package/templates/events-meetups/tier5-src/himi-branded-lite-home/index.ts +2 -1
  69. package/templates/events-meetups/tier5-src/himi-branded-lite-my-groups/index.ts +2 -1
  70. package/templates/events-meetups/tier5-src/himi-events-detail/index.ts +5 -5
  71. package/templates/events-meetups-owner/tier5-src/_shared/app-locale.ts +61 -0
  72. package/templates/events-meetups-owner/tier5-src/_shared/engine-intl.ts +5 -3
  73. package/templates/events-meetups-owner/tier5-src/_shared/himi-copy.ts +10 -2
  74. package/templates/gym-fitness/tier5-src/_shared/app-locale.ts +61 -0
  75. package/templates/gym-fitness/tier5-src/_shared/engine-intl.ts +5 -3
  76. package/templates/gym-fitness/tier5-src/_shared/group-member-count.ts +15 -0
  77. package/templates/gym-fitness/tier5-src/_shared/himi-bookings.ts +54 -13
  78. package/templates/gym-fitness/tier5-src/_shared/himi-copy.ts +10 -2
  79. package/templates/gym-fitness/tier5-src/_shared/himi-restaurants.ts +2 -2
  80. package/templates/gym-fitness/tier5-src/_shared/restaurant-ordering.ts +1 -1
  81. package/templates/gym-fitness/tier5-src/_shared/wix-service-price.ts +2 -2
  82. package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +8 -3
  83. package/templates/gym-fitness/tier5-src/himi-branded-lite-cart/index.ts +1 -1
  84. package/templates/gym-fitness/tier5-src/himi-branded-lite-home/index.ts +2 -1
  85. package/templates/gym-fitness/tier5-src/himi-branded-lite-plans/index.ts +1 -1
  86. package/templates/gym-fitness/tier5-src/himi-events-detail/index.ts +5 -5
  87. package/templates/gym-fitness-owner/tier5-src/_shared/app-locale.ts +61 -0
  88. package/templates/gym-fitness-owner/tier5-src/_shared/engine-intl.ts +5 -3
  89. package/templates/gym-fitness-owner/tier5-src/_shared/himi-copy.ts +10 -2
  90. package/templates/himi-chat/tier5-src/_shared/app-locale.ts +61 -0
  91. package/templates/himi-chat/tier5-src/_shared/himi-copy.ts +10 -2
  92. package/templates/nonprofit-cause/tier5-src/_shared/app-locale.ts +61 -0
  93. package/templates/nonprofit-cause/tier5-src/_shared/engine-intl.ts +5 -3
  94. package/templates/nonprofit-cause/tier5-src/_shared/group-member-count.ts +15 -0
  95. package/templates/nonprofit-cause/tier5-src/_shared/himi-copy.ts +10 -2
  96. package/templates/nonprofit-cause/tier5-src/_shared/himi-restaurants.ts +2 -2
  97. package/templates/nonprofit-cause/tier5-src/_shared/wix-service-price.ts +2 -2
  98. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-group/index.ts +9 -3
  99. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-group-about/index.ts +2 -1
  100. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-groups/index.ts +6 -2
  101. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-home/index.ts +2 -1
  102. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-my-groups/index.ts +2 -1
  103. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-plans/index.ts +1 -1
  104. package/templates/nonprofit-cause/tier5-src/himi-events-detail/index.ts +5 -5
  105. package/templates/nonprofit-cause-owner/tier5-src/_shared/app-locale.ts +61 -0
  106. package/templates/nonprofit-cause-owner/tier5-src/_shared/engine-intl.ts +5 -3
  107. package/templates/nonprofit-cause-owner/tier5-src/_shared/himi-copy.ts +10 -2
  108. package/templates/owner-lite/tier5-src/_shared/app-locale.ts +61 -0
  109. package/templates/owner-lite/tier5-src/_shared/engine-intl.ts +5 -3
  110. package/templates/owner-lite/tier5-src/_shared/himi-copy.ts +10 -2
  111. package/templates/podcast-creator/tier5-src/_shared/app-locale.ts +61 -0
  112. package/templates/podcast-creator/tier5-src/_shared/engine-intl.ts +5 -3
  113. package/templates/podcast-creator/tier5-src/_shared/group-member-count.ts +15 -0
  114. package/templates/podcast-creator/tier5-src/_shared/himi-copy.ts +10 -2
  115. package/templates/podcast-creator/tier5-src/_shared/himi-restaurants.ts +2 -2
  116. package/templates/podcast-creator/tier5-src/_shared/wix-service-price.ts +2 -2
  117. package/templates/podcast-creator/tier5-src/himi-branded-lite-home/index.ts +2 -1
  118. package/templates/podcast-creator/tier5-src/himi-branded-lite-plans/index.ts +1 -1
  119. package/templates/podcast-creator/tier5-src/himi-events-detail/index.ts +5 -5
  120. package/templates/podcast-creator-owner/tier5-src/_shared/app-locale.ts +61 -0
  121. package/templates/podcast-creator-owner/tier5-src/_shared/engine-intl.ts +5 -3
  122. package/templates/podcast-creator-owner/tier5-src/_shared/himi-copy.ts +10 -2
  123. package/templates/portfolio-creative/tier5-src/_shared/app-locale.ts +61 -0
  124. package/templates/portfolio-creative/tier5-src/_shared/engine-intl.ts +5 -3
  125. package/templates/portfolio-creative/tier5-src/_shared/group-member-count.ts +15 -0
  126. package/templates/portfolio-creative/tier5-src/_shared/himi-copy.ts +10 -2
  127. package/templates/portfolio-creative/tier5-src/_shared/himi-restaurants.ts +2 -2
  128. package/templates/portfolio-creative/tier5-src/_shared/restaurant-ordering.ts +1 -1
  129. package/templates/portfolio-creative/tier5-src/_shared/wix-service-price.ts +2 -2
  130. package/templates/portfolio-creative/tier5-src/himi-branded-lite-cart/index.ts +1 -1
  131. package/templates/portfolio-creative/tier5-src/himi-branded-lite-home/index.ts +2 -1
  132. package/templates/portfolio-creative/tier5-src/himi-events-detail/index.ts +5 -5
  133. package/templates/portfolio-creative-owner/tier5-src/_shared/app-locale.ts +61 -0
  134. package/templates/portfolio-creative-owner/tier5-src/_shared/engine-intl.ts +5 -3
  135. package/templates/portfolio-creative-owner/tier5-src/_shared/himi-copy.ts +10 -2
  136. package/templates/restaurant/tier5-src/_shared/app-locale.ts +61 -0
  137. package/templates/restaurant/tier5-src/_shared/engine-intl.ts +5 -3
  138. package/templates/restaurant/tier5-src/_shared/group-member-count.ts +15 -0
  139. package/templates/restaurant/tier5-src/_shared/himi-copy.ts +10 -2
  140. package/templates/restaurant/tier5-src/_shared/himi-restaurants.ts +2 -2
  141. package/templates/restaurant/tier5-src/_shared/restaurant-ordering.ts +1 -1
  142. package/templates/restaurant/tier5-src/_shared/wix-service-price.ts +2 -2
  143. package/templates/restaurant/tier5-src/himi-branded-lite-home/index.ts +2 -1
  144. package/templates/restaurant/tier5-src/himi-events-detail/index.ts +5 -5
  145. package/templates/restaurant-owner/tier5-src/_shared/app-locale.ts +61 -0
  146. package/templates/restaurant-owner/tier5-src/_shared/engine-intl.ts +5 -3
  147. package/templates/restaurant-owner/tier5-src/_shared/himi-copy.ts +10 -2
  148. package/templates/retail-store/tier5-src/_shared/app-locale.ts +61 -0
  149. package/templates/retail-store/tier5-src/_shared/engine-intl.ts +5 -3
  150. package/templates/retail-store/tier5-src/_shared/group-member-count.ts +15 -0
  151. package/templates/retail-store/tier5-src/_shared/himi-copy.ts +10 -2
  152. package/templates/retail-store/tier5-src/_shared/himi-restaurants.ts +2 -2
  153. package/templates/retail-store/tier5-src/_shared/restaurant-ordering.ts +1 -1
  154. package/templates/retail-store/tier5-src/_shared/wix-service-price.ts +2 -2
  155. package/templates/retail-store/tier5-src/himi-branded-lite-cart/index.ts +1 -1
  156. package/templates/retail-store/tier5-src/himi-branded-lite-home/index.ts +2 -1
  157. package/templates/retail-store-owner/tier5-src/_shared/app-locale.ts +61 -0
  158. package/templates/retail-store-owner/tier5-src/_shared/engine-intl.ts +5 -3
  159. package/templates/retail-store-owner/tier5-src/_shared/himi-copy.ts +10 -2
  160. package/templates/yoga-studio/tier5-src/_shared/app-locale.ts +61 -0
  161. package/templates/yoga-studio/tier5-src/_shared/engine-intl.ts +5 -3
  162. package/templates/yoga-studio/tier5-src/_shared/group-member-count.ts +15 -0
  163. package/templates/yoga-studio/tier5-src/_shared/himi-bookings.ts +54 -13
  164. package/templates/yoga-studio/tier5-src/_shared/himi-copy.ts +10 -2
  165. package/templates/yoga-studio/tier5-src/_shared/himi-restaurants.ts +2 -2
  166. package/templates/yoga-studio/tier5-src/_shared/wix-service-price.ts +2 -2
  167. package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +8 -3
  168. package/templates/yoga-studio/tier5-src/himi-branded-lite-home/index.ts +2 -1
  169. package/templates/yoga-studio/tier5-src/himi-branded-lite-plans/index.ts +1 -1
  170. package/templates/yoga-studio/tier5-src/himi-branded-lite-program/index.ts +1 -1
  171. package/templates/yoga-studio/tier5-src/himi-events-detail/index.ts +5 -5
  172. package/templates/yoga-studio-owner/tier5-src/_shared/app-locale.ts +61 -0
  173. package/templates/yoga-studio-owner/tier5-src/_shared/engine-intl.ts +5 -3
  174. package/templates/yoga-studio-owner/tier5-src/_shared/himi-copy.ts +10 -2
@@ -200,8 +200,9 @@ export interface WixActivityCountersQueryResponse {
200
200
  activityCounters?: WixActivityCounter[];
201
201
  }
202
202
  export interface WixFollowCounts {
203
- followers: number;
204
- following: number;
203
+ /** `null` when Wix returned no total for this direction: unavailable, never an assumed 0. */
204
+ followers: number | null;
205
+ following: number | null;
205
206
  }
206
207
  export interface WixMemberConnection {
207
208
  memberId?: string;
@@ -212,9 +213,12 @@ export interface WixMyConnectionsResponse {
212
213
  connectedMembers?: WixMemberConnection[];
213
214
  }
214
215
  /** Followers / following totals for a member — POST
215
- * /members/v1/activity-counters/query. Requires explicit nonnegative safe integer
216
- * totals for this member's Followers-app entry. Missing/invalid totals reject,
217
- * as do permission failures; callers should hide unavailable counts, never invent zero. */
216
+ * /members/v1/activity-counters/query. Wix keeps ONE counter per direction and creates it on the
217
+ * first change (live, amit1: a followed member's entry holds only `followerCount`, the follower's
218
+ * only `followingCount`), so each direction is reported on its own: an explicit nonnegative safe
219
+ * integer, or `null` when Wix returned none. A counter that is present but malformed makes the
220
+ * whole answer untrusted and rejects, as does an entry with neither counter or a permission
221
+ * failure. Callers hide an unavailable counter; a missing one is never shown as zero. */
218
222
  export declare function getFollowCounts(memberId: string, options?: WixCallOptions): Promise<WixFollowCounts>;
219
223
  /** Whether the signed-in member follows `targetId` — POST
220
224
  * /members/v3/followers/my/connections. Returns false on any miss. May 403 →
@@ -87,15 +87,16 @@ async function getFollowCounts(memberId, options) {
87
87
  const entry = (res.activityCounters ?? []).find((c) => c.appId === MEMBERS_FOLLOWERS_APP_ID && c.memberId === memberId);
88
88
  const counters = entry?.counters;
89
89
  const value = (key) => Array.isArray(counters) ? counters.find((counter) => counter?.key === key)?.count : counters?.[key]?.count;
90
- const followers = value("followerCount");
91
- const following = value("followingCount");
92
- if (typeof followers !== "number" || !Number.isSafeInteger(followers) || followers < 0 || typeof following !== "number" || !Number.isSafeInteger(following) || following < 0) {
93
- throw new Error("getFollowCounts: explicit member totals are unavailable");
94
- }
95
- return {
96
- followers,
97
- following
90
+ const total = (key) => {
91
+ const count = value(key);
92
+ if (count === void 0 || count === null) return null;
93
+ if (typeof count !== "number" || !Number.isSafeInteger(count) || count < 0) throw new Error("getFollowCounts: a member total is malformed");
94
+ return count;
98
95
  };
96
+ const followers = total("followerCount");
97
+ const following = total("followingCount");
98
+ if (followers === null && following === null) throw new Error("getFollowCounts: explicit member totals are unavailable");
99
+ return { followers, following };
99
100
  }
100
101
  async function isFollowingMember(targetId) {
101
102
  if (!targetId) throw new Error("isFollowingMember: targetId is required");
package/dist/cli.mjs CHANGED
@@ -27515,7 +27515,6 @@ function resolveIdiom(tokens2, ctx = {}) {
27515
27515
  var IDIOM_CONTEXTS, UNSAFE_KEYS;
27516
27516
  var init_idiom = __esm({
27517
27517
  "../../core/ts/src/idiom.ts"() {
27518
- "use strict";
27519
27518
  init_define_HHP_CATALOG();
27520
27519
  IDIOM_CONTEXTS = {
27521
27520
  ios: { native: ["ios"] },
@@ -142292,6 +142291,7 @@ async function buildFromLoaded(preloaded, dest, opts = {}) {
142292
142291
  const renders = [];
142293
142292
  const baked = [];
142294
142293
  const linkable = /* @__PURE__ */ new Set();
142294
+ let bakedAppConfig;
142295
142295
  const lintTokens = collect2?.lint || collect2?.render ? readTokensForLint(preloaded) : void 0;
142296
142296
  const renderTokens = lintTokens && withServedFontFaces(lintTokens, preloaded.dir);
142297
142297
  const allowedComponents = await loadAllowedComponents(preloaded.dir);
@@ -143021,7 +143021,11 @@ async function buildFromLoaded(preloaded, dest, opts = {}) {
143021
143021
  await store.putAppConfig(app, text13);
143022
143022
  appConfigHash = sha2563(text13);
143023
143023
  minBinaryVersion = maximumBinaryVersion(minBinaryVersion, navBundleMinBinaryVersion(text13));
143024
- if (collect2?.descriptors) for (const id of linkableScreenIdsFrom(text13)) linkable.add(id);
143024
+ if (collect2?.descriptors) {
143025
+ for (const id of linkableScreenIdsFrom(text13)) linkable.add(id);
143026
+ const parsed = JSON.parse(text13);
143027
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) bakedAppConfig = parsed;
143028
+ }
143025
143029
  }
143026
143030
  const ahRes = await devFetch(`${base}/v1/action-handlers/${encodeURIComponent(app)}`, { headers: { "X-Himalaya-App": app } });
143027
143031
  if (ahRes.ok) {
@@ -143163,7 +143167,7 @@ async function buildFromLoaded(preloaded, dest, opts = {}) {
143163
143167
  capabilityUsage,
143164
143168
  ...collect2?.lint ? { lint, workers } : {},
143165
143169
  ...collect2?.render ? { renders } : {},
143166
- ...collect2?.descriptors ? { descriptors: baked, linkableScreenIds: [...linkable] } : {},
143170
+ ...collect2?.descriptors ? { descriptors: baked, linkableScreenIds: [...linkable], ...bakedAppConfig ? { appConfig: bakedAppConfig } : {} } : {},
143167
143171
  ...typeof preloaded.meta.homeScreenId === "string" && preloaded.meta.homeScreenId ? { homeScreenId: preloaded.meta.homeScreenId } : {},
143168
143172
  ...renderTokens !== void 0 ? { tokens: renderTokens } : {},
143169
143173
  ...preloaded.meta.surfaces ? { surfaceNav: preloaded.meta.surfaces } : {},
@@ -194790,6 +194794,25 @@ var init_business_address = __esm({
194790
194794
  }
194791
194795
  });
194792
194796
 
194797
+ // ../../core/dev-server/tier5-bundles-src/_shared/app-locale.ts
194798
+ function isIanaTimeZone(value2) {
194799
+ if (typeof value2 !== "string" || !/^(?:UTC|[A-Za-z]+(?:\/[A-Za-z0-9_+-]+)+)$/.test(value2)) return false;
194800
+ if (typeof Intl === "object" && typeof Intl.DateTimeFormat === "function") {
194801
+ try {
194802
+ new Intl.DateTimeFormat("en-US", { timeZone: value2 });
194803
+ return true;
194804
+ } catch {
194805
+ return false;
194806
+ }
194807
+ }
194808
+ return false;
194809
+ }
194810
+ var init_app_locale = __esm({
194811
+ "../../core/dev-server/tier5-bundles-src/_shared/app-locale.ts"() {
194812
+ init_define_HHP_CATALOG();
194813
+ }
194814
+ });
194815
+
194793
194816
  // ../../stdlib/wix-sdk/rich-content/src/display.ts
194794
194817
  function trimAscii(s2) {
194795
194818
  return s2.replace(/^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/g, "");
@@ -195570,10 +195593,12 @@ async function collectOneApp(input, deps = {}) {
195570
195593
  });
195571
195594
  const wantsContact = definitions2.some((id) => CONTACT_SECTIONS.has(id));
195572
195595
  const wantsLoyalty = definitions2.some((id) => LOYALTY_SECTIONS.has(id));
195573
- if (wantsContact || wantsLoyalty) {
195574
- const requiredVisitor = internalBusinessRead ? await visitorAuthorization("enrichment_session") : void 0;
195596
+ const wantsTimeZone = definitions2.some((id) => SERVICES_SECTIONS.has(id));
195597
+ if (wantsContact || wantsLoyalty || wantsTimeZone) {
195598
+ const optionalOnly = !wantsContact && !wantsLoyalty;
195599
+ const requiredVisitor = internalBusinessRead && !optionalOnly ? await visitorAuthorization("enrichment_session") : void 0;
195575
195600
  try {
195576
- const authorization2 = requiredVisitor ?? await visitorAuthorization("business_contact_session");
195601
+ const authorization2 = requiredVisitor ?? await visitorAuthorization(internalBusinessRead ? "enrichment_session" : "business_contact_session");
195577
195602
  const properties = await request(PUBLIC_CONTACT, "business_contact", void 0, { authorization: authorization2, siteScoped: false });
195578
195603
  if (!isObject2(properties) || typeof properties.metaSiteId !== "string" || !isObject2(properties.properties)) throw invalidResponse("business_contact");
195579
195604
  if (properties.metaSiteId.toLowerCase() !== input.metaSiteId.toLowerCase()) {
@@ -195585,8 +195610,14 @@ async function collectOneApp(input, deps = {}) {
195585
195610
  if (typeof currency === "string" && /^[A-Z]{3}$/.test(currency)) businessContext.paymentCurrency = currency;
195586
195611
  else contextWarnings.push({ code: "loyalty_currency_unavailable", message: "Payment currency is unverified. Keep currency-based Loyalty earning details unresolved." });
195587
195612
  }
195613
+ if (wantsTimeZone) {
195614
+ const zone = properties.properties.timeZone;
195615
+ if (isIanaTimeZone(zone)) businessContext.timeZone = zone;
195616
+ else contextWarnings.push({ code: "business_time_zone_unavailable", message: TIME_ZONE_UNAVAILABLE });
195617
+ }
195588
195618
  } catch (error) {
195589
- if (!(error instanceof OneAppError) || ![
195619
+ if (!(error instanceof OneAppError)) throw error;
195620
+ if (!optionalOnly && ![
195590
195621
  "source_not_found",
195591
195622
  "source_request_failed",
195592
195623
  "source_timeout",
@@ -195602,6 +195633,7 @@ async function collectOneApp(input, deps = {}) {
195602
195633
  failureCode: error.code,
195603
195634
  message: "Payment currency is unavailable. Keep currency-based Loyalty earning details unresolved."
195604
195635
  });
195636
+ if (wantsTimeZone) contextWarnings.push({ code: "business_time_zone_unavailable", failureCode: error.code, message: TIME_ZONE_UNAVAILABLE });
195605
195637
  }
195606
195638
  }
195607
195639
  if (Object.values(buildSourceGraph(rawStructure).sections).some((section3) => {
@@ -195721,12 +195753,13 @@ async function collectOneApp(input, deps = {}) {
195721
195753
  ]
195722
195754
  };
195723
195755
  }
195724
- var MIDDLEWARE, BUSINESS, TOKEN_INFO, IS_PERMITTED, INTERNAL_PERMISSION, VISITOR_SESSION, PUBLIC_CONTACT, PUBLIC_MEMBERS, MEMBER_WIDGETS, LOYALTY_SECTIONS, CONTACT_SECTIONS, MAX_RESPONSE_BYTES, TIMEOUT_MS3, UUID, LIFECYCLE_FIELDS;
195756
+ var MIDDLEWARE, BUSINESS, TOKEN_INFO, IS_PERMITTED, INTERNAL_PERMISSION, VISITOR_SESSION, PUBLIC_CONTACT, PUBLIC_MEMBERS, MEMBER_WIDGETS, LOYALTY_SECTIONS, CONTACT_SECTIONS, SERVICES_SECTIONS, TIME_ZONE_UNAVAILABLE, MAX_RESPONSE_BYTES, TIMEOUT_MS3, UUID, LIFECYCLE_FIELDS;
195725
195757
  var init_collect = __esm({
195726
195758
  "src/one-app/collect.ts"() {
195727
195759
  init_define_HHP_CATALOG();
195728
195760
  init_locale();
195729
195761
  init_business_address();
195762
+ init_app_locale();
195730
195763
  init_errors2();
195731
195764
  init_graph();
195732
195765
  init_pages();
@@ -195741,6 +195774,8 @@ var init_collect = __esm({
195741
195774
  MEMBER_WIDGETS = /* @__PURE__ */ new Set(["platform_general_members_section", "platform_general_members_section_bp"]);
195742
195775
  LOYALTY_SECTIONS = /* @__PURE__ */ new Set(["loyalty_offering_section", "loyalty_status_section"]);
195743
195776
  CONTACT_SECTIONS = /* @__PURE__ */ new Set(["platform_general_contact_section", "platform_general_contact_section_bp"]);
195777
+ SERVICES_SECTIONS = /* @__PURE__ */ new Set(["bookings_services_list_section"]);
195778
+ TIME_ZONE_UNAVAILABLE = "Business time zone is unverified. Leave `businessTimeZone` out of provisioning: a course whose session times carry no offset then shows no Ended label, and no zone is guessed.";
195744
195779
  MAX_RESPONSE_BYTES = 8e6;
195745
195780
  TIMEOUT_MS3 = 3e4;
195746
195781
  UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -198660,7 +198695,8 @@ function mergeCustomerOwned(committed, overlay) {
198660
198695
  "site",
198661
198696
  "goals",
198662
198697
  "businessContact",
198663
- "loyalty"
198698
+ "loyalty",
198699
+ "businessTimeZone"
198664
198700
  ];
198665
198701
  const customer = {};
198666
198702
  for (const key3 of CUSTOMER_OWNED) {
@@ -198683,13 +198719,16 @@ function mergeCustomerOwned(committed, overlay) {
198683
198719
  if (typeof value2 === "string" && value2.trim() === "") delete overlayIdentity[key3];
198684
198720
  }
198685
198721
  if (customer.identity !== void 0) customer.identity = overlayIdentity;
198686
- const { goals: _shellGoals, businessContact: previousContact, loyalty: previousLoyalty, ...shell } = committed;
198722
+ const { goals: _shellGoals, businessContact: previousContact, loyalty: previousLoyalty, businessTimeZone: previousZone, ...shell } = committed;
198687
198723
  if (customer.businessContact === void 0 && sameKnownSite(committed, overlay)) {
198688
198724
  customer.businessContact = previousContact;
198689
198725
  }
198690
198726
  if (customer.loyalty === void 0 && sameKnownSite(committed, overlay)) {
198691
198727
  customer.loyalty = previousLoyalty;
198692
198728
  }
198729
+ if (customer.businessTimeZone === void 0 && sameKnownSite(committed, overlay)) {
198730
+ customer.businessTimeZone = previousZone;
198731
+ }
198693
198732
  return deepMerge4(shell, customer);
198694
198733
  }
198695
198734
  function carryOverGoals(previousOverlayJson, overlay) {
@@ -198720,14 +198759,15 @@ function buildMergedContract(options) {
198720
198759
  const committed = JSON.parse(readFileSync134(committedPath, "utf8"));
198721
198760
  const previousPath = join127(appDir3, LOCAL_CONTRACT_REL);
198722
198761
  let evidenceOverlay = overlay;
198723
- if ((overlay.businessContact === void 0 || overlay.loyalty === void 0) && existsSync115(previousPath)) {
198762
+ if ((overlay.businessContact === void 0 || overlay.loyalty === void 0 || overlay.businessTimeZone === void 0) && existsSync115(previousPath)) {
198724
198763
  try {
198725
198764
  const previous = JSON.parse(readFileSync134(previousPath, "utf8"));
198726
198765
  if (previous && typeof previous === "object" && !Array.isArray(previous) && sameKnownSite(previous, overlay)) {
198727
198766
  evidenceOverlay = {
198728
198767
  ...overlay,
198729
198768
  ...overlay.businessContact === void 0 ? { businessContact: previous.businessContact } : {},
198730
- ...overlay.loyalty === void 0 ? { loyalty: previous.loyalty } : {}
198769
+ ...overlay.loyalty === void 0 ? { loyalty: previous.loyalty } : {},
198770
+ ...overlay.businessTimeZone === void 0 ? { businessTimeZone: previous.businessTimeZone } : {}
198731
198771
  };
198732
198772
  }
198733
198773
  } catch {
@@ -198930,6 +198970,8 @@ async function applyOverlays(input) {
198930
198970
  else if (!sameKnownSite(cur, overlay)) delete merged.businessContact;
198931
198971
  if (overlay.loyalty !== void 0) merged.loyalty = overlay.loyalty;
198932
198972
  else if (!sameKnownSite(cur, overlay)) delete merged.loyalty;
198973
+ if (overlay.businessTimeZone !== void 0) merged.businessTimeZone = overlay.businessTimeZone;
198974
+ else if (!sameKnownSite(cur, overlay)) delete merged.businessTimeZone;
198933
198975
  packageFaq = merged.faq;
198934
198976
  packageGalleries = merged.galleries;
198935
198977
  syncViewNames(cur.site, merged.site);
@@ -232,10 +232,14 @@ private/all-members total.
232
232
  Member-profile follower/following totals are separate runtime reads, not the
233
233
  directory total or this collected snapshot. The shared member-detail worker keeps
234
234
  the public profile readable when its activity counters are forbidden, missing or
235
- invalid, and hides the count line. `getFollowCounts` accepts only explicit
236
- nonnegative safe integers from the requested member's Followers-app entry,
237
- reading the source's keyed counter array (and compatible older map responses);
238
- unavailable totals reject rather than becoming zero. A real zero remains visible.
235
+ invalid, and hides the count line. Wix keeps one counter per direction and creates
236
+ it on the first change, so a member can have only `followerCount` or only
237
+ `followingCount`: `getFollowCounts` reports each direction on its own, as an
238
+ explicit nonnegative safe integer from the requested member's Followers-app entry
239
+ or `null` when Wix returned none, reading the source's keyed counter array (and
240
+ compatible older map responses). A missing counter is hidden and never becomes
241
+ zero; a real zero remains visible; a malformed counter, an entry with neither
242
+ counter or a permission failure rejects and hides the line.
239
243
  Following a member does not turn an unknown aggregate into a fabricated total.
240
244
  Late Follow results cannot restore counters from an earlier load, identity or site.
241
245
  Directory search metadata, activity and viewed-member program progress require
@@ -394,6 +398,16 @@ Apply these interpretation rules:
394
398
  conversion details; locale, device settings and permission failures are not currency
395
399
  evidence. Require recorded live Visitor catalog and signed-in own-account responses
396
400
  for acceptance; mocked member balances prove implementation behavior only.
401
+ - **Bookings: bake the business time zone.** A course's session times (`firstSessionStart`,
402
+ `lastSessionEnd`) may arrive without an offset, and only the business's own zone places them
403
+ (the "Ended" label). A `bookings_services_list_section` collection reads the same public,
404
+ site-bound Visitor properties and allowlists `businessContext.timeZone`, kept only when it is an
405
+ IANA zone ("Asia/Jerusalem"). When verified, bake it into Branded provisioning
406
+ `businessTimeZone` (the source value, never an example, a locale guess or the device zone). The
407
+ worker cannot read it itself: `site-properties/v4` is owner-scoped. Omit an unverified zone: the
408
+ list then shows no Ended label for a time without an offset, which is correct, and a
409
+ `business_time_zone_unavailable` warning says so. A re-apply keeps the zone for the same site and
410
+ drops it for another.
397
411
  - **Native navigation is the migration target.** Use the shell's native top bar
398
412
  and, for bottom-tab navigation, `navigation.tabBar.presentation: "native"`.
399
413
  Do not recreate the source Detailed header, call `detailedHeader`, or insert a
@@ -23911,7 +23911,7 @@
23911
23911
  "name": "WixFollowCounts",
23912
23912
  "kind": "interface",
23913
23913
  "jsdoc": "",
23914
- "decl": "export interface WixFollowCounts {\n followers: number;\n following: number;\n}"
23914
+ "decl": "export interface WixFollowCounts {\n /** `null` when Wix returned no total for this direction: unavailable, never an assumed 0. */\n followers: number | null;\n following: number | null;\n}"
23915
23915
  },
23916
23916
  {
23917
23917
  "name": "WixMemberConnection",
@@ -23928,7 +23928,7 @@
23928
23928
  {
23929
23929
  "name": "getFollowCounts",
23930
23930
  "kind": "function",
23931
- "jsdoc": "/** Followers / following totals for a member — POST\n * /members/v1/activity-counters/query. Requires explicit nonnegative safe integer\n * totals for this member's Followers-app entry. Missing/invalid totals reject,\n * as do permission failures; callers should hide unavailable counts, never invent zero. */",
23931
+ "jsdoc": "/** Followers / following totals for a member — POST\n * /members/v1/activity-counters/query. Wix keeps ONE counter per direction and creates it on the\n * first change (live, amit1: a followed member's entry holds only `followerCount`, the follower's\n * only `followingCount`), so each direction is reported on its own: an explicit nonnegative safe\n * integer, or `null` when Wix returned none. A counter that is present but malformed makes the\n * whole answer untrusted and rejects, as does an entry with neither counter or a permission\n * failure. Callers hide an unavailable counter; a missing one is never shown as zero. */",
23932
23932
  "decl": "export async function getFollowCounts(memberId: string, options?: WixCallOptions): Promise<WixFollowCounts>;",
23933
23933
  "http": "POST /members/v1/activity-counters/query"
23934
23934
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.1589.0",
3
+ "version": "0.1590.0",
4
4
  "type": "module",
5
5
  "description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
6
6
  "keywords": [
@@ -72,5 +72,5 @@
72
72
  "artifactId": "himalaya-cli"
73
73
  }
74
74
  },
75
- "falconPackageHash": "90984a6109a8a7bcdc2b514a13a5abda6d5b9967765e6606d2254b68"
75
+ "falconPackageHash": "0c09c9f148ea94e2a39a974ed0254bee503aa232427deadb20e27979"
76
76
  }
@@ -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
  }