@wix/himalaya-cli 0.1243.0 → 0.1245.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 (41) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/catalog-adapters/online-programs.d.ts +6 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/catalog-price-fields.d.ts +4 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/catalog-pricing-contract.d.ts +87 -0
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/catalog-pricing-core.d.ts +10 -0
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/catalog-pricing.d.ts +6 -0
  6. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/index.d.ts +4 -0
  7. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/mapping-source.d.ts +5 -0
  8. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/checkout/src/index.d.ts +3 -0
  9. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/checkout.mjs +3 -0
  10. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap.mjs +192 -0
  11. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/screens.mjs +3 -0
  12. package/dist/cli.mjs +18 -18
  13. package/dist/preview-assets.generated.bin +0 -0
  14. package/guides/getting-started.md +6 -0
  15. package/guides/himi-skill.md +6 -0
  16. package/guides/iap-pricing.md +162 -211
  17. package/guides/index.json +7 -6
  18. package/guides/sdk-reference.json +96 -0
  19. package/guides/tier-5-add-a-screen.md +9 -0
  20. package/guides/wix-api-usage.md +11 -0
  21. package/guides/wix-site-to-himi-app.md +1 -1
  22. package/package.json +2 -2
  23. package/templates/beauty-salon-owner/dev/index.ts +1 -1
  24. package/templates/branded-lite/dev/index.ts +110 -3
  25. package/templates/branded-lite/tier5-src/_shared/iap-worker.ts +65 -0
  26. package/templates/branded-lite/tier5-src/himi-branded-lite-program/index.ts +179 -52
  27. package/templates/branded-lite/tier5-src/himi-branded-lite-programs/index.ts +158 -38
  28. package/templates/community-club-owner/dev/index.ts +1 -1
  29. package/templates/events-meetups-owner/dev/index.ts +1 -1
  30. package/templates/gym-fitness-owner/dev/index.ts +1 -1
  31. package/templates/nonprofit-cause-owner/dev/index.ts +1 -1
  32. package/templates/owner-lite/dev/index.ts +1 -1
  33. package/templates/podcast-creator-owner/dev/index.ts +1 -1
  34. package/templates/portfolio-creative-owner/dev/index.ts +1 -1
  35. package/templates/restaurant-owner/dev/index.ts +1 -1
  36. package/templates/retail-store-owner/dev/index.ts +1 -1
  37. package/templates/yoga-studio/dev/index.ts +110 -3
  38. package/templates/yoga-studio/tier5-src/_shared/iap-worker.ts +65 -0
  39. package/templates/yoga-studio/tier5-src/himi-branded-lite-program/index.ts +179 -52
  40. package/templates/yoga-studio/tier5-src/himi-branded-lite-programs/index.ts +158 -38
  41. package/templates/yoga-studio-owner/dev/index.ts +1 -1
@@ -13,6 +13,9 @@
13
13
  // PAYMENT_PENDING and settles on the shared in-app pay rail, and a private one comes
14
14
  // back APPROVAL_PENDING (the owner approves; there is nothing to charge). Any failure
15
15
  // degrades to the site-directing message rather than a dead button.
16
+ //
17
+ // App Store prices and the payment route come from the shared IAP catalog layer (@himalaya/iap).
18
+ // Until a mapping backend is connected it is disabled, and this worker runs its pre-pricing path.
16
19
  import { defineService } from "@himalaya/state";
17
20
  import {
18
21
  getProgram,
@@ -28,6 +31,8 @@ import { cleanTitle, initials } from "../_shared/himi-text.js";
28
31
  import { copyParam, copyTemplate, stashCopy } from "../_shared/himi-copy.js";
29
32
  import { payCashierOrder } from "@himalaya/checkout";
30
33
  import { formatCurrency } from "../_shared/engine-intl.js";
34
+ import { createIapCatalogPricing, iapPriceFields, onlineProgramItem, type IapCatalogItem, type IapPollDirective, type IapPriceFields } from "@himalaya/iap";
35
+ import { hostIapScope, hostScopeComplete, iapPriceCopy, resetUnhydratedKeysOnOlderRuntime } from "../_shared/iap-worker.js";
31
36
 
32
37
  /** Wix Online Programs appDefId — the vertical a participant's cashier order is
33
38
  * paid under (mirrors AppDefId.CHALLENGES in Wix's RN apps). */
@@ -55,6 +60,13 @@ interface State {
55
60
  hasImage: boolean;
56
61
  noImage: boolean;
57
62
  hideContent: boolean;
63
+ /** "One-time purchase" beside an App Store price. */
64
+ priceNote: string;
65
+ hidePriceRetry: boolean;
66
+ /** Bound to the Enrol CTA's `disabledPath`; `enroll` re-checks the route itself regardless. */
67
+ enrollDisabled: boolean;
68
+ enrollNote: string;
69
+ _poll?: IapPollDirective;
58
70
  }
59
71
 
60
72
  function inputId(): string {
@@ -149,45 +161,65 @@ function ready(state: State, p: WixProgram | undefined): State {
149
161
  });
150
162
  }
151
163
 
164
+ /** §8.10 drift discipline: getProgram is the primary read; when the GET path 404s/errors the
165
+ * programs QUERY is the fallback (client-side find keeps it correct even when the gateway ignores
166
+ * the filter shape). Throws only when both fail. */
167
+ async function fetchProgram(id: string): Promise<WixProgram | undefined> {
168
+ if (!id) return undefined;
169
+ try {
170
+ return (await getProgram(id)).program;
171
+ } catch {
172
+ const response = await queryPrograms({ cursorPaging: { limit: 50 } });
173
+ return (response.programs ?? []).find((candidate) => candidate.id === id);
174
+ }
175
+ }
176
+
152
177
  async function load(state: State, arg?: unknown): Promise<State> {
153
178
  stashCopy(arg);
154
- const id = inputId();
155
- if (!id) return ready(state, undefined);
156
179
  try {
157
- const response = await getProgram(id);
158
- return ready(state, response.program);
180
+ return ready(state, await fetchProgram(inputId()));
159
181
  } catch {
160
- // Drifted GET path / transient failure → the query fallback. Client-side
161
- // find keeps this correct even when the gateway ignores the filter shape.
162
- try {
163
- const response = await queryPrograms({ cursorPaging: { limit: 50 } });
164
- return ready(state, (response.programs ?? []).find((candidate) => candidate.id === id));
165
- } catch {
166
- return derive({ ...state, status: "error" });
167
- }
182
+ return derive({ ...state, status: "error" });
168
183
  }
169
184
  }
170
185
 
171
- async function enroll(state: State): Promise<State> {
172
- const id = inputId();
173
- if (!id) return state;
186
+ /** Whether the existing commerce flow may start (before the join) and continue (before the Wix
187
+ * payment): a price refresh can move the program onto a route this flow must not charge. */
188
+ interface CommerceGuards {
189
+ mayStart(): boolean;
190
+ mayContinue(): boolean;
191
+ }
192
+
193
+ const ALWAYS_ALLOWED: CommerceGuards = { mayStart: () => true, mayContinue: () => true };
194
+
195
+ async function enrollmentMessage(name: string, guards: CommerceGuards): Promise<string> {
196
+ const blocked = () => iapPriceCopy("program").purchaseUnavailable;
197
+ if (!guards.mayStart()) return blocked();
174
198
  try {
175
- const joined = await joinProgram(id);
199
+ const joined = await joinProgram(inputId());
176
200
  const participant = joined.participant;
177
201
  const programState = (participant?.state ?? "").toUpperCase();
178
202
  if (programState === "APPROVAL_PENDING") {
179
- return derive({ ...state, actionMessage: copyParam("program.approvalPendingMessage", "Request sent — the host will approve your place.") });
203
+ return copyParam("program.approvalPendingMessage", "Request sent — the host will approve your place.");
180
204
  }
181
205
  const payable = payableIdOf(participant);
182
206
  if (!payable) {
183
207
  // Free / open program (or one already settled) ⇒ nothing to charge.
184
- return derive({ ...state, actionMessage: copyTemplate("program.joinedMessage", "You're enrolled in {name}.", { name: state.name }) });
208
+ return copyTemplate("program.joinedMessage", "You're enrolled in {name}.", { name });
185
209
  }
186
210
  const msid = await wixPlatformSession.signedInstanceStatus().then((s) => s.msid ?? "").catch(() => "");
211
+ // Re-read before each step that commits money: a route that settles meanwhile wins.
212
+ let withdrawn = false;
213
+ const stillAllowed = () => {
214
+ if (!guards.mayContinue()) withdrawn = true;
215
+ return !withdrawn;
216
+ };
217
+ if (!stillAllowed()) return blocked();
187
218
  const pay = await payCashierOrder({
188
219
  appId: WIX_PROGRAMS_APP_ID,
189
220
  snapshotId: payable,
190
221
  msid,
222
+ mayCharge: stillAllowed,
191
223
  // Wallet-less apps need a rail too. Without this thunk a paid program was
192
224
  // unpayable on any app with no provisioned wallet — `payCashierOrder`
193
225
  // returned `failed` and the CTA fell through to the site-directing message,
@@ -201,6 +233,7 @@ async function enroll(state: State): Promise<State> {
201
233
  // message as before, and nothing regresses — but when it is right, a
202
234
  // wallet-less site can actually sell the program IN-APP.
203
235
  cashierUrl: async () => {
236
+ if (!stillAllowed()) return undefined;
204
237
  const redirect = await createRedirectSession({
205
238
  ecomOrderPaymentRequest: { paymentRequestId: payable },
206
239
  callbacks: { postFlowUrl: "himalaya-himi-branded-lite://screen/himi_programs" },
@@ -208,43 +241,137 @@ async function enroll(state: State): Promise<State> {
208
241
  return redirect.redirectSession?.fullUrl;
209
242
  },
210
243
  });
211
- if (pay.kind === "paid") {
212
- return derive({ ...state, actionMessage: copyTemplate("program.joinedMessage", "You're enrolled in {name}.", { name: state.name }) });
213
- }
214
- if (pay.kind === "handoff") return derive({ ...state, actionMessage: "" });
215
- if (pay.kind === "cancelled") {
216
- return derive({ ...state, actionMessage: copyParam("program.joinCancelledMessage", "Payment cancelled — you haven't enrolled.") });
217
- }
244
+ if (withdrawn) return blocked();
245
+ if (pay.kind === "paid") return copyTemplate("program.joinedMessage", "You're enrolled in {name}.", { name });
246
+ if (pay.kind === "handoff") return "";
247
+ if (pay.kind === "cancelled") return copyParam("program.joinCancelledMessage", "Payment cancelled — you haven't enrolled.");
218
248
  } catch {
219
249
  // fall through to the site-directing message
220
250
  }
221
- return derive({ ...state, actionMessage: copyTemplate("program.joinOnSiteMessage", "Enrolment happens on the website — visit the site to join {name}.", { name: state.name }) });
222
- }
223
-
224
- const svc = defineService<State, {
225
- load: (state: State, arg?: unknown) => Promise<State>;
226
- enroll: (state: State) => Promise<State>;
227
- }>("himiBrandedProgram", {
228
- state: derive({
229
- status: "loading",
230
- actionMessage: "",
231
- name: "",
232
- description: "",
233
- meta: "",
234
- priceLine: "",
235
- image: "",
236
- initial: "",
237
- emptyText: copyParam("program.emptyText", "This program is no longer available."),
238
- isLoading: true,
239
- notLoading: false,
240
- isErrored: false,
241
- notErrored: true,
242
- noEmpty: true,
243
- hasImage: false,
244
- noImage: true,
245
- hideContent: true,
246
- }),
247
- actions: { load, enroll },
251
+ return copyTemplate("program.joinOnSiteMessage", "Enrolment happens on the website — visit the site to join {name}.", { name });
252
+ }
253
+
254
+ async function enroll(state: State): Promise<State> {
255
+ if (!inputId()) return state;
256
+ return derive({ ...state, actionMessage: await enrollmentMessage(state.name, ALWAYS_ALLOWED) });
257
+ }
258
+
259
+ // ── App Store pricing (enabled only when a mapping backend is connected) ─────
260
+ //
261
+ // One writer: `loadPriced` and `enrollPriced` keep their results here and every state change goes
262
+ // through the synchronous `pricingPublish`, which re-reads the latest content and route. An async
263
+ // action returning its own state could land after a refresh and revive a stale price or route.
264
+
265
+ const pricing = createIapCatalogPricing({ onChange: () => { void svc.pricingPublish(); } });
266
+ const PRICING_NOT_HYDRATED = ["priceLine", "priceNote", "hidePriceRetry", "enrollDisabled", "enrollNote"] as const;
267
+ const CONTENT_KEYS = ["status", "name", "description", "meta", "image", "initial", "emptyText"] as const;
268
+ type Content = Pick<State, (typeof CONTENT_KEYS)[number]>;
269
+ const NO_PRICE: IapPriceFields = { priceText: "", priceNote: "", priceLoading: false, hidePriceRetry: true, actionDisabled: true, actionNote: "" };
270
+ let content: Content;
271
+ let pricedItem: IapCatalogItem | undefined;
272
+ let scopeIncomplete = false;
273
+ let actionMessage = "";
274
+ let loadSeq = 0;
275
+
276
+ function contentOf(state: State): Content {
277
+ return Object.fromEntries(CONTENT_KEYS.map((key) => [key, state[key]])) as Content;
278
+ }
279
+
280
+ const programReference = () => onlineProgramItem({ id: inputId() })?.reference;
281
+
282
+ async function loadPriced(state: State, arg?: unknown): Promise<undefined> {
283
+ stashCopy(arg);
284
+ const seq = ++loadSeq;
285
+ const found = await fetchProgram(inputId()).then((program) => ({ program }), () => undefined);
286
+ if (seq !== loadSeq) return undefined;
287
+ content = found ? contentOf(ready(state, found.program)) : { ...content, status: "error" };
288
+ pricedItem = found?.program ? onlineProgramItem({ id: inputId() }, priceOf(found.program)) : pricedItem;
289
+ void svc.pricingPublish();
290
+ if (found?.program) void priceProgram();
291
+ return undefined;
292
+ }
293
+
294
+ /** Content never waits on this, and the host scope is re-read each time, so a failed app-info
295
+ * read recovers on the next refresh. */
296
+ async function priceProgram(): Promise<void> {
297
+ // The Retry action is declared in both modes; with no backend it must not reach the host.
298
+ if (!pricing.enabled) return;
299
+ const host = await hostIapScope();
300
+ scopeIncomplete = !hostScopeComplete(host);
301
+ if (pricedItem) await pricing.load({ ...host, items: [pricedItem] });
302
+ }
303
+
304
+ async function enrollPriced(): Promise<undefined> {
305
+ const reference = programReference();
306
+ if (!reference) return undefined;
307
+ // A tap that lands as a refresh starts is refused without a verdict: the route is unknown, not
308
+ // App Store, and the screen already reads "Loading price…" with Enrol disabled.
309
+ actionMessage = pricing.checkoutGuard(reference).route === "resolving" ? "" : await enrollmentMessage(content.name, {
310
+ mayStart: () => pricing.checkoutGuard(reference).allowed,
311
+ mayContinue: () => pricing.continueCheckoutGuard(reference).allowed,
312
+ });
313
+ void svc.pricingPublish();
314
+ return undefined;
315
+ }
316
+
317
+ function pricedState(state: State): State {
318
+ const reference = programReference();
319
+ const price = reference ? iapPriceFields(pricing.view(reference), iapPriceCopy("program")) : NO_PRICE;
320
+ return derive({
321
+ ...state, ...content, actionMessage,
322
+ priceLine: price.priceText, priceNote: price.priceNote, hidePriceRetry: price.hidePriceRetry && !scopeIncomplete,
323
+ enrollDisabled: price.actionDisabled, enrollNote: price.actionNote,
324
+ });
325
+ }
326
+
327
+ const INITIAL_STATE: State = derive({
328
+ status: "loading",
329
+ actionMessage: "",
330
+ name: "",
331
+ description: "",
332
+ meta: "",
333
+ priceLine: "",
334
+ image: "",
335
+ initial: "",
336
+ emptyText: copyParam("program.emptyText", "This program is no longer available."),
337
+ isLoading: true,
338
+ notLoading: false,
339
+ isErrored: false,
340
+ notErrored: true,
341
+ noEmpty: true,
342
+ hasImage: false,
343
+ noImage: true,
344
+ hideContent: true,
345
+ priceNote: "",
346
+ hidePriceRetry: true,
347
+ enrollDisabled: pricing.enabled,
348
+ enrollNote: "",
248
349
  });
249
350
 
351
+ type Actions = {
352
+ load: (state: State, arg?: unknown) => Promise<State | undefined>;
353
+ enroll: (state: State) => Promise<State | undefined>;
354
+ pricingPublish: (state: State) => State;
355
+ pricingStart: (state: State) => State;
356
+ pricingRefresh: (state: State) => Promise<undefined>;
357
+ };
358
+
359
+ const svc = defineService<State, Actions>("himiBrandedProgram", {
360
+ state: INITIAL_STATE,
361
+ ...(pricing.enabled ? { notHydrated: [...PRICING_NOT_HYDRATED] } : {}),
362
+ actions: {
363
+ load: pricing.enabled ? loadPriced : load,
364
+ enroll: pricing.enabled ? enrollPriced : enroll,
365
+ pricingPublish: pricedState,
366
+ pricingStart: (state) => ({ ...pricedState(state), _poll: pricing.pollDirective("himiBrandedProgram.pricingRefresh") }),
367
+ pricingRefresh: async () => { await priceProgram(); return undefined; },
368
+ },
369
+ });
370
+
371
+ content = contentOf(svc.getState());
372
+ if (pricing.enabled) {
373
+ resetUnhydratedKeysOnOlderRuntime(svc, INITIAL_STATE, PRICING_NOT_HYDRATED);
374
+ void svc.pricingStart();
375
+ }
376
+
250
377
  export { svc };
@@ -6,11 +6,19 @@
6
6
  // self-contained (name, cover, description snippet, cheap meta) — no detail
7
7
  // screen; enrolling stays a hosted-site flow.
8
8
  // Four-state (loading / error / rows / empty) like the sibling branded workers.
9
- import { defineService } from "../_shared/collection-service.js";
9
+ //
10
+ // App Store prices come from the shared IAP catalog layer (@himalaya/iap). Until a mapping backend
11
+ // is connected it is disabled, and this worker runs exactly its pre-pricing path.
12
+ import { defineService as register } from "@himalaya/state";
13
+ import { decorateCollection } from "../_shared/collection-service.js";
10
14
  import { queryPrograms, type WixProgram } from "@himalaya/wix-online-programs";
15
+ import { createIapCatalogPricing, iapPriceFields, onlineProgramItem, type IapPollDirective } from "@himalaya/iap";
11
16
  import { wixMediaUrl } from "@himalaya/wix-media";
12
17
  import { cleanTitle, initials } from "../_shared/himi-text.js";
13
18
  import { copyParam, stashCopy } from "../_shared/himi-copy.js";
19
+ import { hostIapScope, hostScopeComplete, iapPriceCopy, resetUnhydratedKeysOnOlderRuntime } from "../_shared/iap-worker.js";
20
+
21
+ const SERVICE = "himiBrandedPrograms";
14
22
 
15
23
  interface ProgramRow {
16
24
  id: string;
@@ -27,6 +35,15 @@ interface ProgramRow {
27
35
  noImage: boolean;
28
36
  /** Templated in-app program detail — rows tap through (no more dead wall). */
29
37
  detailScreenId: string;
38
+ /** This run's App Store price or its loading/unavailable label; "" when pricing is off. The
39
+ * collection card shows it in the caption ahead of `meta`. */
40
+ priceLine: string;
41
+ }
42
+
43
+ /** A row as loaded, before pricing — the half that may be restored from a cold-boot snapshot. */
44
+ interface ProgramContent extends Omit<ProgramRow, "priceLine"> {
45
+ /** The Wix ID, or "" when the row fell back to its title and so cannot be priced. */
46
+ wixId: string;
30
47
  }
31
48
 
32
49
  interface State {
@@ -40,6 +57,13 @@ interface State {
40
57
  hasRows: boolean;
41
58
  noRows: boolean;
42
59
  noEmpty: boolean;
60
+ hidePriceRetry: boolean;
61
+ /** Beside the list-level Retry, so it says what failed. */
62
+ priceRetryText: string;
63
+ programs?: ProgramContent[];
64
+ /** Derived from `rows` by the collection decorator. */
65
+ collection?: unknown;
66
+ _poll?: IapPollDirective;
43
67
  }
44
68
 
45
69
  /** v3 nests display fields under `description`/`settings`; older shapes are
@@ -86,7 +110,7 @@ function isDraft(p: WixProgram): boolean {
86
110
  return state === "DRAFT";
87
111
  }
88
112
 
89
- function programRow(p: WixProgram): ProgramRow {
113
+ function programFields(p: WixProgram): Omit<ProgramRow, "priceLine"> {
90
114
  const name = programTitle(p);
91
115
  const image = programImage(p);
92
116
  const id = p.id || name;
@@ -109,6 +133,14 @@ function programRow(p: WixProgram): ProgramRow {
109
133
  };
110
134
  }
111
135
 
136
+ function programRow(p: WixProgram): ProgramRow {
137
+ return { ...programFields(p), priceLine: "" };
138
+ }
139
+
140
+ function programContent(p: WixProgram): ProgramContent {
141
+ return { ...programFields(p), wixId: p.id ?? "" };
142
+ }
143
+
112
144
  function ready(rows: ProgramRow[]): State {
113
145
  return {
114
146
  status: "ready",
@@ -121,46 +153,134 @@ function ready(rows: ProgramRow[]): State {
121
153
  hasRows: rows.length > 0,
122
154
  noRows: rows.length === 0,
123
155
  noEmpty: rows.length > 0,
156
+ hidePriceRetry: true,
157
+ priceRetryText: "",
124
158
  };
125
159
  }
126
160
 
127
- const svc = defineService<State, {
128
- load: (state: State, arg?: unknown) => Promise<State>;
129
- }>("himiBrandedPrograms", {
130
- state: {
131
- status: "loading",
132
- rows: [],
133
- emptyText: "",
134
- isLoading: true,
135
- notLoading: false,
136
- isErrored: false,
137
- notErrored: true,
138
- hasRows: false,
139
- noRows: true,
161
+ // Bridge errors land here — a graceful error card with retry, never a dead
162
+ // screen (the branded four-state contract).
163
+ function errored(state: State): State {
164
+ return {
165
+ ...state,
166
+ status: "error",
167
+ isLoading: false,
168
+ notLoading: true,
169
+ isErrored: true,
170
+ notErrored: false,
140
171
  noEmpty: true,
141
- },
142
- actions: {
143
- load: async (state, arg) => {
144
- stashCopy(arg);
145
- try {
146
- const response = await queryPrograms({ cursorPaging: { limit: 50 } });
147
- return ready((response.programs ?? []).filter((p) => !isDraft(p)).map(programRow));
148
- } catch {
149
- // Bridge errors land here — a graceful error card with retry, never a
150
- // dead screen (the branded four-state contract).
151
- return {
152
- ...state,
153
- status: "error",
154
- isLoading: false,
155
- notLoading: true,
156
- isErrored: true,
157
- notErrored: false,
158
- noEmpty: true,
159
- };
160
- }
161
- },
162
- },
163
- });
172
+ };
173
+ }
174
+
175
+ async function fetchPrograms(): Promise<WixProgram[]> {
176
+ const response = await queryPrograms({ cursorPaging: { limit: 50 } });
177
+ return (response.programs ?? []).filter((p) => !isDraft(p));
178
+ }
179
+
180
+ async function load(state: State, arg?: unknown): Promise<State> {
181
+ stashCopy(arg);
182
+ try {
183
+ return ready((await fetchPrograms()).map(programRow));
184
+ } catch {
185
+ return errored(state);
186
+ }
187
+ }
188
+
189
+ // ── App Store pricing (enabled only when a mapping backend is connected) ─────
190
+ //
191
+ // One writer: `loadPriced` keeps its content here and every state change goes through the
192
+ // synchronous `pricingPublish`, which re-reads the latest content and prices. An async action
193
+ // that returned its own state could land after a newer publication and repaint stale prices.
194
+
195
+ const pricing = createIapCatalogPricing({ onChange: () => { void svc.pricingPublish(); } });
196
+ const PRICING_NOT_HYDRATED = ["rows", "collection", "hidePriceRetry"] as const;
197
+ let content: { status: State["status"]; programs: ProgramContent[] } = { status: "loading", programs: [] };
198
+ let scopeIncomplete = false;
199
+ let loadSeq = 0;
200
+
201
+ async function loadPriced(_state: State, arg?: unknown): Promise<undefined> {
202
+ stashCopy(arg);
203
+ const seq = ++loadSeq;
204
+ const programs = await fetchPrograms().catch(() => undefined);
205
+ if (seq !== loadSeq) return undefined;
206
+ content = programs ? { status: "ready", programs: programs.map(programContent) } : { ...content, status: "error" };
207
+ void svc.pricingPublish();
208
+ if (programs) void priceContent();
209
+ return undefined;
210
+ }
211
+
212
+ /** Prices the programs on screen now. Content never waits on this, and the host scope is re-read
213
+ * each time, so a failed app-info read recovers on the next refresh. */
214
+ async function priceContent(): Promise<void> {
215
+ // The Retry action is declared in both modes; with no backend it must not reach the host.
216
+ if (!pricing.enabled) return;
217
+ const host = await hostIapScope();
218
+ scopeIncomplete = !hostScopeComplete(host);
219
+ if (content.status !== "ready") return;
220
+ await pricing.load({ ...host, items: content.programs.flatMap((program) => onlineProgramItem({ id: program.wixId }) ?? []) });
221
+ }
222
+
223
+ function pricedState(state: State): State {
224
+ const copy = iapPriceCopy("programs");
225
+ const views = content.programs.map((program) => {
226
+ const item = onlineProgramItem({ id: program.wixId });
227
+ return item ? pricing.view(item.reference) : undefined;
228
+ });
229
+ const rows = content.programs.map(({ wixId: _wixId, ...row }, i) =>
230
+ ({ ...row, priceLine: views[i] ? iapPriceFields(views[i]!, copy).priceText : "" }));
231
+ const shown = content.status === "ready" ? ready(rows) : content.status === "error" ? errored({ ...state, rows }) : INITIAL_STATE;
232
+ return {
233
+ ...state, ...shown, programs: content.programs,
234
+ // Retry re-prices the programs on screen, which only a ready list has.
235
+ hidePriceRetry: content.status !== "ready" || !views.some((view) => view && (scopeIncomplete || view.retryable)),
236
+ priceRetryText: copyParam("programs.priceRetryText", "Some prices could not be loaded."),
237
+ };
238
+ }
239
+
240
+ const INITIAL_STATE: State = {
241
+ status: "loading",
242
+ rows: [],
243
+ emptyText: "",
244
+ isLoading: true,
245
+ notLoading: false,
246
+ isErrored: false,
247
+ notErrored: true,
248
+ hasRows: false,
249
+ noRows: true,
250
+ noEmpty: true,
251
+ hidePriceRetry: true,
252
+ priceRetryText: "",
253
+ };
254
+
255
+ type Actions = {
256
+ load: (state: State, arg?: unknown) => Promise<State | undefined>;
257
+ pricingPublish: (state: State) => State;
258
+ pricingStart: (state: State) => State;
259
+ pricingRefresh: (state: State) => Promise<undefined>;
260
+ };
261
+
262
+ const actions: Actions = {
263
+ load: pricing.enabled ? loadPriced : load,
264
+ pricingPublish: pricedState,
265
+ pricingStart: (state) => ({ ...pricedState(state), _poll: pricing.pollDirective(`${SERVICE}.pricingRefresh`) }),
266
+ pricingRefresh: async () => { await priceContent(); return undefined; },
267
+ };
268
+
269
+ // The collection decorator's own `defineService` drops `notHydrated`, so the decorated definition
270
+ // is registered directly: this run's prices must never be restored from a cold-boot snapshot.
271
+ const decorated = decorateCollection(SERVICE, { state: INITIAL_STATE, actions } as unknown as Parameters<typeof decorateCollection>[1]);
272
+ const svc = register<State, Actions>(SERVICE, (pricing.enabled
273
+ ? { ...decorated, notHydrated: [...PRICING_NOT_HYDRATED] }
274
+ : decorated) as unknown as Parameters<typeof register<State, Actions>>[1]);
275
+
276
+ if (pricing.enabled) {
277
+ const booted = svc.getState();
278
+ if ((booted.status === "ready" || booted.status === "error") && Array.isArray(booted.programs)) {
279
+ content = { status: booted.status, programs: booted.programs };
280
+ }
281
+ resetUnhydratedKeysOnOlderRuntime(svc, decorated.state as unknown as State, PRICING_NOT_HYDRATED);
282
+ void svc.pricingStart();
283
+ }
164
284
 
165
285
  // Kick the first load at module scope, like every other list bundle
166
286
  // (himi-store-shop, himi-booking-services, himi-events-list, himi-blog-feed …).
@@ -51996,7 +51996,7 @@ const SCREENS = runtimeSetting({
51996
51996
  "package": "himalaya",
51997
51997
  "type": "Button",
51998
51998
  "props": {
51999
- "title": "Sign out",
51999
+ "title": "Sign out of Wix",
52000
52000
  "style": "secondary",
52001
52001
  "fullWidth": true,
52002
52002
  "action": {