@wix/himalaya-cli 0.1497.0 → 0.1499.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 (146) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/stores-collections/src/display.d.ts +1 -1
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/stores-collections/src/index.d.ts +2 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/stores-v1/src/display.d.ts +4 -0
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-stores-collections.mjs +2 -2
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-stores.mjs +3 -2
  6. package/dist/cli.mjs +30 -30
  7. package/dist/preview-assets.generated.bin +0 -0
  8. package/guides/sdk-reference.json +8 -2
  9. package/guides/trim-a-template.md +1 -1
  10. package/package.json +2 -2
  11. package/templates/beauty-salon/dev/index.ts +2465 -2035
  12. package/templates/beauty-salon/tier5-src/_shared/himi-store-categories.ts +102 -0
  13. package/templates/beauty-salon/tier5-src/_shared/himi-store-facets.ts +478 -0
  14. package/templates/beauty-salon/tier5-src/_shared/iap-presentation.ts +158 -15
  15. package/templates/beauty-salon/tier5-src/himi-branded-lite-home/index.ts +42 -2
  16. package/templates/beauty-salon/tier5-src/himi-branded-lite-shop/index.ts +691 -0
  17. package/templates/beauty-salon/tier5-src/himi-store-product/index.ts +2 -1
  18. package/templates/beauty-salon/tier5-src/himi-store-saved/index.ts +1 -0
  19. package/templates/beauty-salon-owner/dev/index.ts +571 -0
  20. package/templates/beauty-salon-owner/himalaya.content.json +6 -0
  21. package/templates/beauty-salon-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  22. package/templates/beauty-salon-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  23. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  24. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  25. package/templates/branded-lite/dev/index.ts +2989 -2197
  26. package/templates/branded-lite/tier5-src/_shared/business-chat-presenter.ts +46 -8
  27. package/templates/branded-lite/tier5-src/_shared/himi-store-categories.ts +102 -0
  28. package/templates/branded-lite/tier5-src/_shared/himi-store-facets.ts +478 -0
  29. package/templates/branded-lite/tier5-src/_shared/iap-presentation.ts +158 -15
  30. package/templates/branded-lite/tier5-src/business-chat/index.ts +3 -2
  31. package/templates/branded-lite/tier5-src/himi-branded-lite-home/index.ts +42 -2
  32. package/templates/branded-lite/tier5-src/himi-branded-lite-shop/index.ts +691 -0
  33. package/templates/branded-lite/tier5-src/himi-store-product/index.ts +2 -1
  34. package/templates/branded-lite/tier5-src/himi-store-saved/index.ts +1 -0
  35. package/templates/community-club/tier5-src/_shared/himi-store-categories.ts +102 -0
  36. package/templates/community-club/tier5-src/_shared/iap-presentation.ts +158 -15
  37. package/templates/community-club/tier5-src/himi-branded-lite-home/index.ts +42 -2
  38. package/templates/community-club-owner/dev/index.ts +571 -0
  39. package/templates/community-club-owner/himalaya.content.json +6 -0
  40. package/templates/community-club-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  41. package/templates/community-club-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  42. package/templates/community-club-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  43. package/templates/community-club-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  44. package/templates/events-meetups/tier5-src/_shared/himi-store-categories.ts +102 -0
  45. package/templates/events-meetups/tier5-src/_shared/iap-presentation.ts +158 -15
  46. package/templates/events-meetups/tier5-src/himi-branded-lite-home/index.ts +42 -2
  47. package/templates/events-meetups-owner/dev/index.ts +571 -0
  48. package/templates/events-meetups-owner/himalaya.content.json +6 -0
  49. package/templates/events-meetups-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  50. package/templates/events-meetups-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  51. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  52. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  53. package/templates/gym-fitness/dev/index.ts +2600 -2170
  54. package/templates/gym-fitness/tier5-src/_shared/himi-store-categories.ts +102 -0
  55. package/templates/gym-fitness/tier5-src/_shared/himi-store-facets.ts +478 -0
  56. package/templates/gym-fitness/tier5-src/_shared/iap-presentation.ts +158 -15
  57. package/templates/gym-fitness/tier5-src/himi-branded-lite-home/index.ts +42 -2
  58. package/templates/gym-fitness/tier5-src/himi-branded-lite-shop/index.ts +691 -0
  59. package/templates/gym-fitness/tier5-src/himi-store-product/index.ts +2 -1
  60. package/templates/gym-fitness/tier5-src/himi-store-saved/index.ts +1 -0
  61. package/templates/gym-fitness-owner/dev/index.ts +571 -0
  62. package/templates/gym-fitness-owner/himalaya.content.json +6 -0
  63. package/templates/gym-fitness-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  64. package/templates/gym-fitness-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  65. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  66. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  67. package/templates/nonprofit-cause/tier5-src/_shared/himi-store-categories.ts +102 -0
  68. package/templates/nonprofit-cause/tier5-src/_shared/iap-presentation.ts +158 -15
  69. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-home/index.ts +42 -2
  70. package/templates/nonprofit-cause-owner/dev/index.ts +571 -0
  71. package/templates/nonprofit-cause-owner/himalaya.content.json +6 -0
  72. package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  73. package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  74. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  75. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  76. package/templates/owner-lite/dev/index.ts +571 -0
  77. package/templates/owner-lite/himalaya.content.json +6 -0
  78. package/templates/owner-lite/tier5-src/_shared/owner-today-rollup.ts +307 -0
  79. package/templates/owner-lite/tier5-src/_shared/owner-widget-reads.ts +23 -0
  80. package/templates/owner-lite/tier5-src/himi-owner-lite-today/index.ts +8 -20
  81. package/templates/owner-lite/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  82. package/templates/podcast-creator/tier5-src/_shared/himi-store-categories.ts +102 -0
  83. package/templates/podcast-creator/tier5-src/_shared/iap-presentation.ts +158 -15
  84. package/templates/podcast-creator/tier5-src/himi-branded-lite-home/index.ts +42 -2
  85. package/templates/podcast-creator-owner/dev/index.ts +571 -0
  86. package/templates/podcast-creator-owner/himalaya.content.json +6 -0
  87. package/templates/podcast-creator-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  88. package/templates/podcast-creator-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  89. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  90. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  91. package/templates/portfolio-creative/dev/index.ts +2470 -2040
  92. package/templates/portfolio-creative/tier5-src/_shared/himi-store-categories.ts +102 -0
  93. package/templates/portfolio-creative/tier5-src/_shared/himi-store-facets.ts +478 -0
  94. package/templates/portfolio-creative/tier5-src/_shared/iap-presentation.ts +158 -15
  95. package/templates/portfolio-creative/tier5-src/himi-branded-lite-home/index.ts +42 -2
  96. package/templates/portfolio-creative/tier5-src/himi-branded-lite-shop/index.ts +691 -0
  97. package/templates/portfolio-creative/tier5-src/himi-store-product/index.ts +2 -1
  98. package/templates/portfolio-creative/tier5-src/himi-store-saved/index.ts +1 -0
  99. package/templates/portfolio-creative-owner/dev/index.ts +571 -0
  100. package/templates/portfolio-creative-owner/himalaya.content.json +6 -0
  101. package/templates/portfolio-creative-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  102. package/templates/portfolio-creative-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  103. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  104. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  105. package/templates/restaurant/tier5-src/_shared/himi-store-categories.ts +102 -0
  106. package/templates/restaurant/tier5-src/_shared/iap-presentation.ts +158 -15
  107. package/templates/restaurant/tier5-src/himi-branded-lite-home/index.ts +42 -2
  108. package/templates/restaurant-owner/dev/index.ts +571 -0
  109. package/templates/restaurant-owner/himalaya.content.json +6 -0
  110. package/templates/restaurant-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  111. package/templates/restaurant-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  112. package/templates/restaurant-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  113. package/templates/restaurant-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  114. package/templates/retail-store/dev/index.ts +2583 -2137
  115. package/templates/retail-store/tier5-src/_shared/himi-store-categories.ts +102 -0
  116. package/templates/retail-store/tier5-src/_shared/himi-store-facets.ts +478 -0
  117. package/templates/retail-store/tier5-src/_shared/iap-presentation.ts +158 -15
  118. package/templates/retail-store/tier5-src/himi-branded-lite-home/index.ts +42 -2
  119. package/templates/retail-store/tier5-src/himi-branded-lite-shop/index.ts +691 -0
  120. package/templates/retail-store/tier5-src/himi-store-product/index.ts +2 -1
  121. package/templates/retail-store/tier5-src/himi-store-saved/index.ts +1 -0
  122. package/templates/retail-store-owner/dev/index.ts +571 -0
  123. package/templates/retail-store-owner/himalaya.content.json +6 -0
  124. package/templates/retail-store-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  125. package/templates/retail-store-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  126. package/templates/retail-store-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  127. package/templates/retail-store-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  128. package/templates/yoga-studio/tier5-src/_shared/himi-store-categories.ts +102 -0
  129. package/templates/yoga-studio/tier5-src/_shared/iap-presentation.ts +158 -15
  130. package/templates/yoga-studio/tier5-src/himi-branded-lite-home/index.ts +42 -2
  131. package/templates/yoga-studio-owner/dev/index.ts +571 -0
  132. package/templates/yoga-studio-owner/himalaya.content.json +6 -0
  133. package/templates/yoga-studio-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  134. package/templates/yoga-studio-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  135. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  136. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  137. package/templates/beauty-salon/tier5-src/_shared/himi-store-collection.ts +0 -22
  138. package/templates/beauty-salon/tier5-src/himi-store-shop/index.ts +0 -173
  139. package/templates/branded-lite/tier5-src/_shared/himi-store-collection.ts +0 -22
  140. package/templates/branded-lite/tier5-src/himi-store-shop/index.ts +0 -173
  141. package/templates/gym-fitness/tier5-src/_shared/himi-store-collection.ts +0 -22
  142. package/templates/gym-fitness/tier5-src/himi-store-shop/index.ts +0 -173
  143. package/templates/portfolio-creative/tier5-src/_shared/himi-store-collection.ts +0 -22
  144. package/templates/portfolio-creative/tier5-src/himi-store-shop/index.ts +0 -173
  145. package/templates/retail-store/tier5-src/_shared/himi-store-collection.ts +0 -22
  146. package/templates/retail-store/tier5-src/himi-store-shop/index.ts +0 -173
@@ -11,46 +11,189 @@ export function withIapPresentation<S extends object>(options: {
11
11
  onCreate?(commerce: IapCommerce): void;
12
12
  onContent?(state: S): void;
13
13
  items(state: S): IapCatalogItem[];
14
- present(state: S, price: (id: string, commercePrice?: string) => string, allowed: (id: string) => boolean): S;
14
+ present(state: S, price: (id: string, commercePrice?: string) => string, allowed: (id: string) => boolean, appleIapOrResolving: (id: string) => boolean): S;
15
15
  contentActions: string[];
16
+ /** Defaults to load/refresh/retry; mutations can explicitly request fresh mapping. */
17
+ forcePriceRefreshActions?: string[];
18
+ concurrentPublishActions?: string[];
19
+ /** Override the commerce factory in focused worker tests. */
20
+ commerceFactory?: typeof createIapCommerce;
16
21
  transientKeys: (keyof S & string)[];
17
22
  prefix: string;
18
23
  }): typeof defineService {
19
24
  return ((name: string, definition: ServiceDefinition<S>) => {
20
- const commerce = createIapCommerce({ adapters: options.adapters, onChange: () => { void service.iapPublish(); } });
25
+ let queuePublish = () => {};
26
+ const commerce = (options.commerceFactory ?? createIapCommerce)({ adapters: options.adapters, onChange: () => queuePublish() });
21
27
  options.onCreate?.(commerce);
22
28
  let items: IapCatalogItem[] = [];
29
+ let loadedItemsKey = "";
30
+ const settledViews = new Map<string, { commerceDisplayPrice?: string; view: ReturnType<IapCommerce["pricing"]["view"]> }>();
31
+ const forcePriceRefreshActions = new Set(options.forcePriceRefreshActions ?? options.contentActions.filter(action => ["load", "refresh", "retry"].includes(action)));
23
32
  let contentVersion = 0;
24
- const project = (state: S): S => {
33
+ let actionVersion = 0;
34
+ let activeActions = 0;
35
+ let activeConcurrentPublishActions = 0;
36
+ let actionWaiters: Array<() => void> = [];
37
+ let publishQueued = false;
38
+ // pollDirective() arms its interval as a side effect. Keep a directive from a
39
+ // projection that loses a commit race so the retry can still publish it.
40
+ let uncommittedPollDirective: ReturnType<IapCommerce["pollDirective"]>;
41
+ let uncommittedPollVersion = 0;
42
+ // The committed state, read when a publish runs. Assigned once the service exists, so the
43
+ // service's own initializer never refers to it.
44
+ let committed: () => S = () => definition.state;
45
+ queuePublish = () => {
46
+ if (publishQueued) return;
47
+ publishQueued = true;
48
+ // Tier-5 workers have no timer globals. A microtask is enough here: if the
49
+ // action that caused the price update is still in flight, iapPublish's
50
+ // action-version check queues another publish after that action commits.
51
+ const publish = () => {
52
+ publishQueued = false;
53
+ void service.iapPublish();
54
+ };
55
+ void Promise.resolve().then(publish);
56
+ };
57
+ const project = (state: S, retainedPoll?: ReturnType<IapCommerce["pollDirective"]>): S => {
58
+ const presentationView = (id: string, commercePrice?: string) => {
59
+ const item = items.find(item => item.reference.id === id);
60
+ const reference = item?.reference ?? { kind: "storesProduct", id };
61
+ const key = `${reference.kind}:${reference.id}`;
62
+ const current = commerce.pricing.view(reference);
63
+ const cached = settledViews.get(key);
64
+ const displayPrice = commercePrice ?? item?.commerceDisplayPrice;
65
+ if (current.route === "resolving" && cached && cached.commerceDisplayPrice === displayPrice) return cached.view;
66
+ if (current.route !== "resolving" && item) settledViews.set(key, { commerceDisplayPrice: item.commerceDisplayPrice, view: current });
67
+ return current;
68
+ };
25
69
  const price = (id: string, commercePrice?: string) => {
26
70
  const item = items.find(item => item.reference.id === id);
27
71
  if (!item) return "";
28
- const view = commerce.pricing.view(item.reference);
72
+ const view = presentationView(id, commercePrice);
73
+ if (view.route === "resolving") return iapPriceCopy(options.prefix).loading;
29
74
  return view.route === "disabled" || view.route === "regular" ? commercePrice ?? item.commerceDisplayPrice ?? "" : iapPriceFields(view, iapPriceCopy(options.prefix)).priceText;
30
75
  };
31
76
  const allowed = (id: string) => commerce.pricing.checkoutGuard({ kind: "storesProduct", id }).allowed;
32
- const poll = commerce.pollDirective(`${name}.iapPoll`);
33
- return { ...options.present(state, price, allowed), ...(poll ? { _poll: poll } : {}) };
77
+ const appleIapOrResolving = (id: string) => ["appleIap", "resolving", "unavailable"].includes(presentationView(id).route);
78
+ // Re-evaluate the cadence first. A checkout can enter/leave due work while a prior
79
+ // projection is waiting to retry; a fresh directive supersedes the retained one.
80
+ const poll = commerce.pollDirective(`${name}.iapPoll`) ?? retainedPoll;
81
+ return { ...options.present(state, price, allowed, appleIapOrResolving), ...(poll ? { _poll: poll } : {}) };
34
82
  };
35
- const actions = Object.fromEntries(Object.entries(definition.actions ?? {}).map(([action, reduce]) => [action, async (state: S, ...args: never[]) => {
83
+ const mergeConcurrent = (before: S, next: S, current: S): S => {
84
+ if (current === before) return next;
85
+ const merged = { ...current } as Record<string, unknown>;
86
+ const previous = before as Record<string, unknown>;
87
+ const changed = next as Record<string, unknown>;
88
+ for (const key of new Set([...Object.keys(previous), ...Object.keys(changed)])) {
89
+ if (JSON.stringify(previous[key]) === JSON.stringify(changed[key])) continue;
90
+ if (Object.prototype.hasOwnProperty.call(changed, key)) merged[key] = changed[key];
91
+ else delete merged[key];
92
+ }
93
+ return merged as S;
94
+ };
95
+ const actions = Object.fromEntries(Object.entries(definition.actions ?? {}).map(([action, reduce]) => [action, (state: S, ...args: never[]) => {
96
+ actionVersion++;
97
+ activeActions++;
98
+ const concurrentPublish = options.concurrentPublishActions?.includes(action) ?? false;
99
+ if (concurrentPublish) activeConcurrentPublishActions++;
36
100
  const content = options.contentActions.includes(action);
37
101
  const version = action === "load" ? ++contentVersion : contentVersion;
38
- const next = await reduce(state, ...args);
39
- if (!next || (content && version !== contentVersion)) return undefined;
40
- if (content) {
41
- options.onContent?.(next);
42
- items = options.items(next);
43
- void commerce.loadPrices(items);
102
+ const presentResult = (next: S | undefined) => {
103
+ // scopeLanded is the completion of the active load, not a stale load result. It
104
+ // must publish even if another overlapping onAppear load advanced contentVersion.
105
+ if (!next || (content && action !== "scopeLanded" && version !== contentVersion)) return undefined;
106
+ if (content) {
107
+ options.onContent?.(next);
108
+ const nextItems = options.items(next);
109
+ // This is an order-independent identity key, not display text. Default
110
+ // string ordering is deterministic on every worker engine, including QuickJS.
111
+ const nextItemsKey = JSON.stringify(nextItems.map(item => JSON.stringify([item.reference.kind, item.reference.id, item.commerceDisplayPrice]))
112
+ .sort());
113
+ items = nextItems;
114
+ const forcePriceRefresh = forcePriceRefreshActions.has(action);
115
+ if (forcePriceRefresh) settledViews.clear();
116
+ if (nextItemsKey !== loadedItemsKey || forcePriceRefresh) {
117
+ loadedItemsKey = nextItemsKey;
118
+ void commerce.loadPrices(items);
119
+ }
120
+ }
121
+ // Checkout preserves prices published while it ran.
122
+ return project(concurrentPublish ? mergeConcurrent(state, next, committed()) : next);
123
+ };
124
+ const finished = () => {
125
+ activeActions--;
126
+ if (concurrentPublish) activeConcurrentPublishActions--;
127
+ const waiters = actionWaiters;
128
+ actionWaiters = [];
129
+ for (const resolve of waiters) resolve();
130
+ };
131
+ try {
132
+ const next = reduce(state, ...args);
133
+ // Keep synchronous sidecars synchronous. Adding an await lets their old
134
+ // catalog snapshot commit after a newer asynchronous refresh.
135
+ if (content || (next && typeof (next as Promise<S | undefined>).then === "function")) {
136
+ return (async () => {
137
+ try { return presentResult(await next); }
138
+ finally { finished(); }
139
+ })();
140
+ }
141
+ const result = presentResult(next as S | undefined);
142
+ finished();
143
+ return result;
144
+ } catch (error) {
145
+ finished();
146
+ throw error;
44
147
  }
45
- return project(next);
46
148
  }]));
47
149
  const service = (options.register ?? defineService)(name, {
48
150
  // The collection's shown page goes with the rows and chrome it copies: kept out of the restore with them.
49
151
  ...definition, notHydrated: [...(definition.notHydrated ?? []), ...options.transientKeys,
50
152
  ...((options.transientKeys as string[]).includes('collection') ? ['collectionShown' as keyof S & string] : [])],
51
- actions: { ...actions, iapPublish: project, iapPoll: async () => { await commerce.poll(); return undefined; },
153
+ // Prices land on their own clock, and a commit replaces the whole state — so a publish
154
+ // can carry a snapshot that an in-flight commit (a page that just landed, a tap) has
155
+ // since replaced. Project the state as it is now, and publish only when the prices
156
+ // actually change it: with no App Store mapping, or prices already shown, there is
157
+ // nothing to publish and nothing to overwrite.
158
+ actions: { ...actions, iapPublish: async () => {
159
+ // Checkout actions publish their busy phase while running. Every other action
160
+ // finishes before we take a projection, so it cannot erase a reducer's result.
161
+ for (;;) {
162
+ if (activeActions > activeConcurrentPublishActions) await new Promise<void>(resolve => actionWaiters.push(resolve));
163
+ await Promise.resolve();
164
+ const version = actionVersion;
165
+ await Promise.resolve();
166
+ if (activeActions <= activeConcurrentPublishActions && version === actionVersion) break;
167
+ }
168
+ const current = committed();
169
+ // The runtime commits an action's result just after this wrapper returns. If a
170
+ // scope action lands while prices are being projected, publishing this snapshot
171
+ // would put the older scope back on top. Drop it and retry against the commit.
172
+ const retainedPollVersion = uncommittedPollVersion;
173
+ const retainedPoll = uncommittedPollDirective;
174
+ const next: S & { _poll?: unknown } = project(current, retainedPoll);
175
+ await Promise.resolve();
176
+ if (committed() !== current) {
177
+ // Overlapping retries can race too: a projection with no new directive must
178
+ // not erase one another already retained after pollDirective() armed itself.
179
+ if (next._poll) {
180
+ uncommittedPollDirective = next._poll as ReturnType<IapCommerce["pollDirective"]>;
181
+ uncommittedPollVersion++;
182
+ }
183
+ queuePublish();
184
+ return undefined;
185
+ }
186
+ // Another concurrent projection may have retained a newer directive since this
187
+ // snapshot. Clear only the exact retained generation this successful commit used.
188
+ if (next._poll && retainedPollVersion === uncommittedPollVersion && retainedPoll === uncommittedPollDirective) {
189
+ uncommittedPollDirective = undefined;
190
+ uncommittedPollVersion++;
191
+ }
192
+ return !next._poll && JSON.stringify(next) === JSON.stringify(current) ? undefined : next;
193
+ }, iapPoll: async () => { await commerce.poll(); return undefined; },
52
194
  iapRefresh: async () => { await commerce.loadPrices(items); return undefined; } },
53
195
  });
196
+ committed = () => (service as unknown as { getState(): S }).getState();
54
197
  resetUnhydratedKeysOnOlderRuntime(service, definition.state, options.transientKeys);
55
198
  return service;
56
199
  }) as unknown as typeof defineService;
@@ -2,6 +2,7 @@ import { fetchMemberBookings, type MemberBookingRow } from "../_shared/member-bo
2
2
  import { hostSiteFromInput, hostedExploreCards, hostedHiddenFlags, type HostedExploreCard } from "../_shared/branded-lite-hosted.js";
3
3
  import { memberAccess, assertMemberAccess } from "../_shared/member-access.js";
4
4
  import { defineService as registerHome } from "../_shared/home-service.js";
5
+ import { navigate } from "@himalaya/state";
5
6
  import { withIapPresentation } from "../_shared/iap-presentation.js";
6
7
  import { queryServices, type WixService } from "@himalaya/wix-bookings";
7
8
  import { getPost, queryPosts, type WixPost } from "@himalaya/wix-blog";
@@ -20,6 +21,7 @@ import { parseWeeklyGoal, weeklyGoalProgress, weeklySessionsFromPages } from "..
20
21
  import { isBridgeUnavailable } from "../_shared/owner-load-error.js";
21
22
  import { formatDateMedium } from "../_shared/engine-intl.js";
22
23
  import { readSavedProducts, type SavedProduct } from "../_shared/himi-store-saved.js";
24
+ import { loadStoreCategories, type StoreCategoryCard } from "../_shared/himi-store-categories.js";
23
25
  import { readSavedIds } from "../_shared/himi-blog-saved.js";
24
26
  import { wixMediaUrl } from "@himalaya/wix-media";
25
27
  import { isWixVideoWebRefusal, listLatestVideos, videoCard, type WixVideoCard } from "@himalaya/wix-video";
@@ -270,6 +272,9 @@ interface State {
270
272
  noHeroImage: boolean;
271
273
  hasFeaturedProducts: boolean;
272
274
  noFeaturedProducts: boolean;
275
+ /** The store's categories for the "Shop by category" rail; each opens Shop on that chip. */
276
+ homeCategories: StoreCategoryCard[];
277
+ noHomeCategories: boolean;
273
278
  /** Schedule-only trainingFirst fields (design §4.1). They remain empty until
274
279
  * a dated availability source can back them; collapse gates hide them. */
275
280
  todaysClasses: ClassEntry[];
@@ -857,6 +862,7 @@ function derive(state: State): State {
857
862
  noHeroImage: !hasHeroImage,
858
863
  hasFeaturedProducts: state.featuredProducts.length > 0,
859
864
  noFeaturedProducts: state.featuredProducts.length === 0,
865
+ noHomeCategories: (state.homeCategories ?? []).length === 0,
860
866
  noSavedProducts: state.savedProducts.length === 0,
861
867
  noSavedPosts: state.savedPosts.length === 0,
862
868
  noSavedContent: state.savedProducts.length === 0 && state.savedPosts.length === 0,
@@ -950,6 +956,8 @@ function baseState(): State {
950
956
  noHeroImage: true,
951
957
  hasFeaturedProducts: false,
952
958
  noFeaturedProducts: true,
959
+ homeCategories: [],
960
+ noHomeCategories: true,
953
961
  todaysClasses: [],
954
962
  noTodaysClasses: true,
955
963
  coachAvatars: [],
@@ -998,6 +1006,7 @@ let refreshGen = 0;
998
1006
  /** Last successful featured list — catch paths read this when the action's
999
1007
  * `state` snapshot was captured before a racing refresh painted products. */
1000
1008
  let lastFeaturedProducts: ProductFeature[] = [];
1009
+ let lastHomeCategories: StoreCategoryCard[] = [];
1001
1010
 
1002
1011
  async function refreshPublic(state: State, arg?: unknown): Promise<State | undefined> {
1003
1012
  // Absorb the descriptor's copy-pack overrides (if any) before any string is
@@ -1034,6 +1043,21 @@ async function refreshPublic(state: State, arg?: unknown): Promise<State | undef
1034
1043
  effectiveLayout: resolveEffectiveLayout(arg, 0),
1035
1044
  });
1036
1045
  }
1046
+ // Categories ride beside the products; a failed read only hides their rail. Started
1047
+ // outside the try, and merged as a separate action so a slow categories endpoint cannot
1048
+ // hold the primary products rail behind it. Offline on a cold boot the module copy is
1049
+ // still empty: fall back to the rail the state restored.
1050
+ let categoriesResolved = false;
1051
+ let resolvedCategories: StoreCategoryCard[] = [];
1052
+ const categoriesRead = loadStoreCategories().catch(() => (lastHomeCategories.length ? lastHomeCategories : state.homeCategories ?? []));
1053
+ void categoriesRead.then((homeCategories) => {
1054
+ categoriesResolved = true;
1055
+ resolvedCategories = homeCategories;
1056
+ if (gen === refreshGen) {
1057
+ lastHomeCategories = homeCategories;
1058
+ void svc.categoriesLoaded({ generation: gen, categories: homeCategories });
1059
+ }
1060
+ });
1037
1061
  try {
1038
1062
  const response = await queryStoreProducts(50);
1039
1063
  if (gen !== refreshGen) return undefined;
@@ -1051,6 +1075,7 @@ async function refreshPublic(state: State, arg?: unknown): Promise<State | undef
1051
1075
  welcome: welcome(),
1052
1076
  refreshedAt: new Date().toISOString(),
1053
1077
  featuredProducts,
1078
+ homeCategories: categoriesResolved ? resolvedCategories : state.homeCategories ?? [],
1054
1079
  ...bookings,
1055
1080
  ...extras,
1056
1081
  ...saved,
@@ -1075,6 +1100,7 @@ async function refreshPublic(state: State, arg?: unknown): Promise<State | undef
1075
1100
  // genuine store outage still raises the banner.
1076
1101
  isErrored: keptProducts.length === 0 && !isBridgeUnavailable(e),
1077
1102
  featuredProducts: keptProducts,
1103
+ homeCategories: categoriesResolved ? resolvedCategories : state.homeCategories ?? [],
1078
1104
  welcome: welcome(),
1079
1105
  refreshedAt: new Date().toISOString(),
1080
1106
  ...bookings,
@@ -1087,6 +1113,7 @@ async function refreshPublic(state: State, arg?: unknown): Promise<State | undef
1087
1113
  }
1088
1114
 
1089
1115
  let personalGeneration = 0;
1116
+ let shopCategoryActivation = 0;
1090
1117
  function memberSchedule(rows: MemberBookingRow[], now = new Date()): { label: string; fraction: number } {
1091
1118
  const weekStart = new Date(now);
1092
1119
  const day = (weekStart.getDay() + 6) % 7;
@@ -1184,20 +1211,33 @@ const svc = defineService<State, {
1184
1211
  refresh: (state: State, arg?: unknown) => Promise<State | undefined>;
1185
1212
  dismissOnboarding: (state: State) => State;
1186
1213
  clearPersonal: (state: State) => State;
1214
+ openShopCategory: (state: State, arg?: unknown) => State;
1187
1215
  personalLoaded: (state: State, arg: { generation: number; rows: MemberBookingRow[]; progress: { label: string; fraction: number }; error: boolean; weekly: { count: number; fraction: number; label: string } | null }) => State;
1216
+ categoriesLoaded: (state: State, arg: { generation: number; categories: StoreCategoryCard[] }) => State | undefined;
1188
1217
  }>("himiBrandedHome", {
1189
1218
  state: baseState(),
1190
1219
  // The host's site is read fresh at every boot: a snapshot of the last session's site must not
1191
1220
  // decide this one's verticals or name (an owner installs or removes one; another site opens).
1192
- notHydrated: ["hosted", "hostedName", "hostedCards"],
1221
+ notHydrated: ["hosted", "hostedName", "hostedCards", "homeCategories", "noHomeCategories"],
1193
1222
  actions: {
1194
1223
  clearPersonal: state => derive({ ...state, nextBooking: [], hideNextBooking: true, memberProgressLabel: "", memberProgressFraction: 0, hideMemberProgress: true, personalError: false, hidePersonalError: true,
1195
1224
  weeklyClassCount: 0, weeklyClassGoalFraction: 0, weeklyGoalLabel: "" }),
1225
+ openShopCategory: (state, arg) => {
1226
+ const category = String((arg as { category?: unknown } | undefined)?.category ?? "");
1227
+ if (!category) return state;
1228
+ shopCategoryActivation++;
1229
+ // Nav params are strings and the activation nonce is a transient route event,
1230
+ // not a distinct Shop record. ScreenHost forwards these live params to onAppear
1231
+ // when a pooled worker is reused.
1232
+ const activation = `${Date.now()}-${shopCategoryActivation}`;
1233
+ return navigate(state, { screenId: "himi_shop", args: { category, categoryId: category, categoryActivation: activation } });
1234
+ },
1196
1235
  personalLoaded: (state, arg) => arg.generation !== personalGeneration ? state : derive({ ...state,
1197
1236
  nextBooking: arg.rows, hideNextBooking: !arg.rows.length,
1198
1237
  memberProgressLabel: arg.progress.label, memberProgressFraction: arg.progress.fraction, hideMemberProgress: !arg.progress.label,
1199
1238
  personalError: arg.error, hidePersonalError: !arg.error,
1200
1239
  weeklyClassCount: arg.weekly?.count ?? 0, weeklyClassGoalFraction: arg.weekly?.fraction ?? 0, weeklyGoalLabel: arg.weekly?.label ?? "" }),
1240
+ categoriesLoaded: (state, arg) => arg.generation !== refreshGen ? undefined : derive({ ...state, homeCategories: arg.categories }),
1201
1241
  refresh,
1202
1242
  // "Got it" on the first-run card: persist + hide. Persistence is
1203
1243
  // best-effort — the UI hides either way for this session.
@@ -1213,7 +1253,7 @@ const svc = defineService<State, {
1213
1253
  // name. In place, because on those runtimes getState() IS the stored state (as himi-player-home does).
1214
1254
  if ((globalThis as { himalaya?: { hydrationOptOut?: unknown } }).himalaya?.hydrationOptOut !== true) {
1215
1255
  const live = (svc as { getState?: () => State | undefined }).getState?.();
1216
- if (live && typeof live === "object") Object.assign(live, hostedBoot);
1256
+ if (live && typeof live === "object") Object.assign(live, hostedBoot, { homeCategories: [], noHomeCategories: true });
1217
1257
  }
1218
1258
 
1219
1259
  svc.refresh();