@wix/himalaya-cli 0.1320.0 → 0.1321.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 (47) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/app-config.d.ts +1 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/app-module.d.ts +3 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/biometrics/src/index.d.ts +11 -1
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/catalog-pricing-contract.d.ts +4 -0
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/host-session.d.ts +4 -0
  6. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/index.d.ts +13 -0
  7. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/mapping-source.d.ts +8 -3
  8. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/wix-payments.d.ts +5 -4
  9. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/sdk-types/src/capabilities.generated.d.ts +1 -1
  10. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/tokens.d.ts +9 -1
  11. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/apple-iap-contract.d.ts +5 -0
  12. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/apple-iap-core.d.ts +3 -1
  13. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/checkout-source.d.ts +4 -0
  14. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/commerce-contract.d.ts +83 -0
  15. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/commerce-core.d.ts +36 -0
  16. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/commerce.d.ts +8 -0
  17. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/index.d.ts +3 -0
  18. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/purchase-fields.d.ts +4 -0
  19. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/audio-record.mjs +1 -1
  20. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/calendar.mjs +1 -1
  21. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/calls.mjs +1 -1
  22. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/contacts.mjs +1 -1
  23. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap-checkout.mjs +1205 -71
  24. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap.mjs +125 -13
  25. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/location.mjs +1 -1
  26. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/motion.mjs +1 -1
  27. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/photos.mjs +1 -1
  28. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/sdk-types.mjs +1 -1
  29. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/sdui.mjs +1 -0
  30. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/tasks.mjs +1 -1
  31. package/dist/cli.mjs +62 -23
  32. package/dist/preview-assets.generated.bin +0 -0
  33. package/guides/getting-started.md +4 -2
  34. package/guides/himi-skill.md +4 -2
  35. package/guides/iap-pricing.md +33 -26
  36. package/guides/index.json +2 -1
  37. package/guides/sdk-reference.json +17 -5
  38. package/guides/tokens.md +22 -1
  39. package/package.json +2 -2
  40. package/templates/branded-lite/dev/index.ts +41 -0
  41. package/templates/branded-lite/tier5-src/_shared/iap-worker.ts +21 -44
  42. package/templates/branded-lite/tier5-src/himi-branded-lite-program/index.ts +77 -47
  43. package/templates/branded-lite/tier5-src/himi-branded-lite-programs/index.ts +24 -38
  44. package/templates/yoga-studio/dev/index.ts +41 -0
  45. package/templates/yoga-studio/tier5-src/_shared/iap-worker.ts +21 -44
  46. package/templates/yoga-studio/tier5-src/himi-branded-lite-program/index.ts +77 -47
  47. package/templates/yoga-studio/tier5-src/himi-branded-lite-programs/index.ts +24 -38
@@ -7,16 +7,17 @@
7
7
  // screen; enrolling stays a hosted-site flow.
8
8
  // Four-state (loading / error / rows / empty) like the sibling branded workers.
9
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.
10
+ // App Store prices come from the shared IAP catalog layer (@himalaya/iap). An app that sells nothing
11
+ // through the App Store reads every program as `disabled` and shows no price line.
12
12
  import { defineService as register } from "@himalaya/state";
13
13
  import { decorateCollection } from "../_shared/collection-service.js";
14
14
  import { queryPrograms, type WixProgram } from "@himalaya/wix-online-programs";
15
- import { createIapCatalogPricing, iapPriceFields, onlineProgramItem, type IapPollDirective } from "@himalaya/iap";
15
+ import { iapPriceFields, onlineProgramItem, type IapPollDirective } from "@himalaya/iap";
16
+ import { createIapCommerce } from "@himalaya/iap-checkout";
16
17
  import { wixMediaUrl } from "@himalaya/wix-media";
17
18
  import { cleanTitle, initials } from "../_shared/himi-text.js";
18
19
  import { copyParam, stashCopy } from "../_shared/himi-copy.js";
19
- import { hostIapScope, hostScopeComplete, iapPriceCopy, resetUnhydratedKeysOnOlderRuntime } from "../_shared/iap-worker.js";
20
+ import { iapPriceCopy, resetUnhydratedKeysOnOlderRuntime } from "../_shared/iap-worker.js";
20
21
 
21
22
  const SERVICE = "himiBrandedPrograms";
22
23
 
@@ -133,10 +134,6 @@ function programFields(p: WixProgram): Omit<ProgramRow, "priceLine"> {
133
134
  };
134
135
  }
135
136
 
136
- function programRow(p: WixProgram): ProgramRow {
137
- return { ...programFields(p), priceLine: "" };
138
- }
139
-
140
137
  function programContent(p: WixProgram): ProgramContent {
141
138
  return { ...programFields(p), wixId: p.id ?? "" };
142
139
  }
@@ -177,28 +174,22 @@ async function fetchPrograms(): Promise<WixProgram[]> {
177
174
  return (response.programs ?? []).filter((p) => !isDraft(p));
178
175
  }
179
176
 
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) ─────
177
+ // ── Content and App Store prices ─────
190
178
  //
191
- // One writer: `loadPriced` keeps its content here and every state change goes through the
179
+ // One writer: `load` keeps its content here and every state change goes through the
192
180
  // synchronous `pricingPublish`, which re-reads the latest content and prices. An async action
193
181
  // that returned its own state could land after a newer publication and repaint stale prices.
194
182
 
195
- const pricing = createIapCatalogPricing({ onChange: () => { void svc.pricingPublish(); } });
183
+ // Prices only: the list sells nothing (each card opens the program, whose screen owns the sale),
184
+ // so its composition has no adapters and never starts a checkout.
185
+ const commerce = createIapCommerce({ onChange: () => { void svc.pricingPublish(); } });
186
+ const pricing = commerce.pricing;
196
187
  const PRICING_NOT_HYDRATED = ["rows", "collection", "hidePriceRetry"] as const;
197
188
  let content: { status: State["status"]; programs: ProgramContent[] } = { status: "loading", programs: [] };
198
189
  let scopeIncomplete = false;
199
190
  let loadSeq = 0;
200
191
 
201
- async function loadPriced(_state: State, arg?: unknown): Promise<undefined> {
192
+ async function load(_state: State, arg?: unknown): Promise<undefined> {
202
193
  stashCopy(arg);
203
194
  const seq = ++loadSeq;
204
195
  const programs = await fetchPrograms().catch(() => undefined);
@@ -212,12 +203,10 @@ async function loadPriced(_state: State, arg?: unknown): Promise<undefined> {
212
203
  /** Prices the programs on screen now. Content never waits on this, and the host scope is re-read
213
204
  * each time, so a failed app-info read recovers on the next refresh. */
214
205
  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
206
  if (content.status !== "ready") return;
220
- await pricing.load({ ...host, items: content.programs.flatMap((program) => onlineProgramItem({ id: program.wixId }) ?? []) });
207
+ const items = content.programs.flatMap((program) => onlineProgramItem({ id: program.wixId }) ?? []);
208
+ scopeIncomplete = !(await commerce.loadPrices(items)).scopeComplete;
209
+ void svc.pricingPublish();
221
210
  }
222
211
 
223
212
  function pricedState(state: State): State {
@@ -232,7 +221,8 @@ function pricedState(state: State): State {
232
221
  return {
233
222
  ...state, ...shown, programs: content.programs,
234
223
  // 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)),
224
+ // `disabled`: this app sells nothing through the App Store, so there is no price lookup to retry.
225
+ hidePriceRetry: content.status !== "ready" || !views.some((view) => view && view.route !== "disabled" && (scopeIncomplete || view.retryable)),
236
226
  priceRetryText: copyParam("programs.priceRetryText", "Some prices could not be loaded."),
237
227
  };
238
228
  }
@@ -260,7 +250,7 @@ type Actions = {
260
250
  };
261
251
 
262
252
  const actions: Actions = {
263
- load: pricing.enabled ? loadPriced : load,
253
+ load,
264
254
  pricingPublish: pricedState,
265
255
  pricingStart: (state) => ({ ...pricedState(state), _poll: pricing.pollDirective(`${SERVICE}.pricingRefresh`) }),
266
256
  pricingRefresh: async () => { await priceContent(); return undefined; },
@@ -269,18 +259,14 @@ const actions: Actions = {
269
259
  // The collection decorator's own `defineService` drops `notHydrated`, so the decorated definition
270
260
  // is registered directly: this run's prices must never be restored from a cold-boot snapshot.
271
261
  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]);
262
+ const svc = register<State, Actions>(SERVICE, { ...decorated, notHydrated: [...PRICING_NOT_HYDRATED] } as unknown as Parameters<typeof register<State, Actions>>[1]);
275
263
 
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();
264
+ const booted = svc.getState();
265
+ if ((booted.status === "ready" || booted.status === "error") && Array.isArray(booted.programs)) {
266
+ content = { status: booted.status, programs: booted.programs };
283
267
  }
268
+ resetUnhydratedKeysOnOlderRuntime(svc, decorated.state as unknown as State, PRICING_NOT_HYDRATED);
269
+ void svc.pricingStart();
284
270
 
285
271
  // Kick the first load at module scope, like every other list bundle
286
272
  // (himi-store-shop, himi-booking-services, himi-events-list, himi-blog-feed …).
@@ -25845,6 +25845,9 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
25845
25845
  "actionId": "tier5.himiBrandedProgram.enroll"
25846
25846
  }
25847
25847
  }
25848
+ },
25849
+ "modifiers": {
25850
+ "hiddenPath": "$service:himiBrandedProgram.enrollHidden"
25848
25851
  }
25849
25852
  },
25850
25853
  {
@@ -25858,6 +25861,44 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
25858
25861
  "color": "textSecondary",
25859
25862
  "collapseWhenEmpty": true
25860
25863
  }
25864
+ },
25865
+ "modifiers": {
25866
+ "hiddenPath": "$service:himiBrandedProgram.enrollHidden"
25867
+ }
25868
+ },
25869
+ {
25870
+ "id": "himi_program.buy",
25871
+ "body": {
25872
+ "package": "himalaya",
25873
+ "type": "Button",
25874
+ "props": {
25875
+ "title": "$service:himiBrandedProgram.buyLabel",
25876
+ "style": "primary",
25877
+ "fullWidth": true,
25878
+ "disabledPath": "$service:himiBrandedProgram.buyDisabled",
25879
+ "action": {
25880
+ "actionId": "tier5.himiBrandedProgram.buy"
25881
+ }
25882
+ }
25883
+ },
25884
+ "modifiers": {
25885
+ "hiddenPath": "$service:himiBrandedProgram.buyHidden"
25886
+ }
25887
+ },
25888
+ {
25889
+ "id": "himi_program.buy_note",
25890
+ "body": {
25891
+ "package": "himalaya",
25892
+ "type": "Text",
25893
+ "props": {
25894
+ "text": "$service:himiBrandedProgram.buyNote",
25895
+ "style": "footnote",
25896
+ "color": "textSecondary",
25897
+ "collapseWhenEmpty": true
25898
+ }
25899
+ },
25900
+ "modifiers": {
25901
+ "hiddenPath": "$service:himiBrandedProgram.buyHidden"
25861
25902
  }
25862
25903
  },
25863
25904
  {
@@ -1,49 +1,11 @@
1
- // Worker-side glue for the shared IAP catalog layer (`@himalaya/iap` → createIapCatalogPricing),
2
- // shared by every screen that shows or sells an IAP-backed item. Mapping, Apple prices and payment
3
- // guards live in the SDK; this only supplies the host scope, the copy, and the hydration reset.
4
- import { get as appInfo } from "@himalaya/app-info";
5
- import type { IapCatalogScope, IapPriceCopy } from "@himalaya/iap";
1
+ // Worker-side glue for the shared IAP layer (`@himalaya/iap` catalog pricing and
2
+ // `@himalaya/iap-checkout` → createIapCommerce), shared by every screen that shows or sells an
3
+ // IAP-backed item. Mapping, Apple prices, payment guards, the host scope and the sale live in the
4
+ // SDK; this only supplies the copy and the hydration reset.
5
+ import type { IapPriceCopy } from "@himalaya/iap";
6
+ import type { IapPurchaseCopy } from "@himalaya/iap-checkout";
6
7
  import { copyParam } from "./himi-copy.js";
7
8
 
8
- export interface HostIapScope {
9
- scope: IapCatalogScope;
10
- platform: string;
11
- }
12
-
13
- let hostScope: Promise<HostIapScope> | undefined;
14
-
15
- /** Read once per worker, and only by an enabled screen: a disabled one makes no app-info call.
16
- * App identity and platform come from the host, never a screen param. The site is the host-owned
17
- * native preference business (the web partition uses an OAuth client ID); without one, the mapping source's
18
- * bound site applies. A failed read leaves identity empty, which fails every item closed — and is
19
- * not kept, so the next lookup asks the host again instead of staying invalid for good. */
20
- export function hostIapScope(): Promise<HostIapScope> {
21
- return hostScope ??= readHostScope().then((read) => {
22
- if (!hostScopeComplete(read)) hostScope = undefined;
23
- return read;
24
- });
25
- }
26
-
27
- /** An incomplete scope prices every item `invalidInput`, which the shared layer never offers to
28
- * retry — so a screen offers Retry itself while its own host read is what failed. */
29
- export function hostScopeComplete(read: HostIapScope): boolean {
30
- return !!read.scope.appId && !!read.scope.bundleId && !!read.platform;
31
- }
32
-
33
- async function readHostScope(): Promise<HostIapScope> {
34
- const identity = await appInfo().catch(() => undefined);
35
- const siteId = identity?.platform === "web" ? "" : hostSiteId();
36
- return {
37
- scope: { appId: identity?.appId ?? "", bundleId: identity?.bundleId ?? "", ...(siteId ? { siteId } : {}) },
38
- platform: identity?.platform ?? "",
39
- };
40
- }
41
-
42
- function hostSiteId(): string {
43
- const value = (globalThis as { himalaya?: { input?: Record<string, unknown> } }).himalaya?.input?.__himiPreferenceBusiness;
44
- return typeof value === "string" ? value.trim() : "";
45
- }
46
-
47
9
  /** Price labels from the screen's copy pack (`<prefix>.priceLoadingText`, …). */
48
10
  export function iapPriceCopy(prefix: string): IapPriceCopy {
49
11
  return {
@@ -54,6 +16,21 @@ export function iapPriceCopy(prefix: string): IapPriceCopy {
54
16
  };
55
17
  }
56
18
 
19
+ /** App Store purchase labels from the screen's copy pack (`<prefix>.buyCta`, …), for
20
+ * `iapPurchaseFields`. Customer copy only: no backend names, ids or diagnostics. */
21
+ export function iapPurchaseCopy(prefix: string): IapPurchaseCopy {
22
+ return {
23
+ buy: copyParam(`${prefix}.buyCta`, "Get access"),
24
+ processing: copyParam(`${prefix}.purchaseProcessingText`, "Processing your purchase…"),
25
+ awaitingApproval: copyParam(`${prefix}.purchaseAwaitingApprovalText`, "Waiting for approval"),
26
+ unavailable: copyParam(`${prefix}.purchaseTemporarilyUnavailableText`, "Purchasing is temporarily unavailable."),
27
+ signInRequired: copyParam(`${prefix}.purchaseSignInText`, "Sign in to buy."),
28
+ retry: copyParam(`${prefix}.purchaseRetryText`, "Something went wrong. Try again."),
29
+ priceChanged: copyParam(`${prefix}.purchasePriceChangedText`, "The price changed. Check it and try again."),
30
+ support: copyParam(`${prefix}.purchaseSupportText`, "We couldn't finish this purchase. Contact support."),
31
+ };
32
+ }
33
+
57
34
  /** What `notHydrated` does, for a runtime that ignores the option (a binary older than it, still
58
35
  * served new bundles): put the listed keys back to their declared values before any action runs,
59
36
  * so a persisted price or an enabled purchase action never paints as this run's. */
@@ -14,8 +14,11 @@
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
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.
17
+ // App Store prices, the payment route and the App Store sale come from the shared IAP layer
18
+ // (`createIapCommerce` in @himalaya/iap-checkout). An app that sells nothing through the App Store
19
+ // reads every program as `disabled` and keeps Enrol on its own commerce. An Apple-routed program
20
+ // offers Get access in place of Enrol and sells only through the shared checkout, never through the
21
+ // Wix payment rail.
19
22
  import { defineService } from "@himalaya/state";
20
23
  import {
21
24
  getProgram,
@@ -31,8 +34,9 @@ import { cleanTitle, initials } from "../_shared/himi-text.js";
31
34
  import { copyParam, copyTemplate, stashCopy } from "../_shared/himi-copy.js";
32
35
  import { payCashierOrder } from "@himalaya/checkout";
33
36
  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";
37
+ import { iapPriceFields, onlineProgramItem, type IapCatalogItem, type IapPollDirective, type IapPriceFields } from "@himalaya/iap";
38
+ import { createIapCommerce, onlineProgramOrders, type IapPurchaseFields } from "@himalaya/iap-checkout";
39
+ import { iapPriceCopy, iapPurchaseCopy, resetUnhydratedKeysOnOlderRuntime } from "../_shared/iap-worker.js";
36
40
 
37
41
  /** Wix Online Programs appDefId — the vertical a participant's cashier order is
38
42
  * paid under (mirrors AppDefId.CHALLENGES in Wix's RN apps). */
@@ -66,6 +70,13 @@ interface State {
66
70
  /** Bound to the Enrol CTA's `disabledPath`; `enroll` re-checks the route itself regardless. */
67
71
  enrollDisabled: boolean;
68
72
  enrollNote: string;
73
+ /** An App Store program sells through Get access instead. */
74
+ enrollHidden: boolean;
75
+ /** The shared checkout's action (`commerce.purchaseFields`): shown only on the App Store route. */
76
+ buyHidden: boolean;
77
+ buyLabel: string;
78
+ buyDisabled: boolean;
79
+ buyNote: string;
69
80
  _poll?: IapPollDirective;
70
81
  }
71
82
 
@@ -174,15 +185,6 @@ async function fetchProgram(id: string): Promise<WixProgram | undefined> {
174
185
  }
175
186
  }
176
187
 
177
- async function load(state: State, arg?: unknown): Promise<State> {
178
- stashCopy(arg);
179
- try {
180
- return ready(state, await fetchProgram(inputId()));
181
- } catch {
182
- return derive({ ...state, status: "error" });
183
- }
184
- }
185
-
186
188
  /** Whether the existing commerce flow may start (before the join) and continue (before the Wix
187
189
  * payment): a price refresh can move the program onto a route this flow must not charge. */
188
190
  interface CommerceGuards {
@@ -190,8 +192,6 @@ interface CommerceGuards {
190
192
  mayContinue(): boolean;
191
193
  }
192
194
 
193
- const ALWAYS_ALLOWED: CommerceGuards = { mayStart: () => true, mayContinue: () => true };
194
-
195
195
  async function enrollmentMessage(name: string, guards: CommerceGuards): Promise<string> {
196
196
  const blocked = () => iapPriceCopy("program").purchaseUnavailable;
197
197
  if (!guards.mayStart()) return blocked();
@@ -251,27 +251,27 @@ async function enrollmentMessage(name: string, guards: CommerceGuards): Promise<
251
251
  return copyTemplate("program.joinOnSiteMessage", "Enrolment happens on the website — visit the site to join {name}.", { name });
252
252
  }
253
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) ─────
254
+ // ── Content, App Store pricing and sale ─────
260
255
  //
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.
256
+ // One writer: `load`, `enroll` and `buy` keep their results here and every state
257
+ // change goes through the synchronous `pricingPublish`, which re-reads the latest content, route
258
+ // and checkout state. An async action returning its own state could land after a refresh and
259
+ // revive a stale price, route or purchase.
264
260
 
265
- const pricing = createIapCatalogPricing({ onChange: () => { void svc.pricingPublish(); } });
266
- const PRICING_NOT_HYDRATED = ["priceLine", "priceNote", "hidePriceRetry", "enrollDisabled", "enrollNote"] as const;
261
+ const commerce = createIapCommerce({ adapters: [onlineProgramOrders], onChange: () => { void svc.pricingPublish(); } });
262
+ const pricing = commerce.pricing;
263
+ const PRICING_NOT_HYDRATED = [
264
+ "priceLine", "priceNote", "hidePriceRetry", "enrollDisabled", "enrollNote", "enrollHidden", "buyHidden", "buyLabel", "buyDisabled", "buyNote",
265
+ ] as const;
267
266
  const CONTENT_KEYS = ["status", "name", "description", "meta", "image", "initial", "emptyText"] as const;
268
267
  type Content = Pick<State, (typeof CONTENT_KEYS)[number]>;
269
268
  const NO_PRICE: IapPriceFields = { priceText: "", priceNote: "", priceLoading: false, hidePriceRetry: true, actionDisabled: true, actionNote: "" };
269
+ const NOT_APPLE: IapPurchaseFields = { appleRoute: false, accessReady: false, buyLabel: "", buyDisabled: true, buyBusy: false, buyNote: "" };
270
270
  let content: Content;
271
271
  let pricedItem: IapCatalogItem | undefined;
272
- let scopeIncomplete = false;
273
272
  let actionMessage = "";
274
273
  let loadSeq = 0;
274
+ let commerceStarted = false;
275
275
 
276
276
  function contentOf(state: State): Content {
277
277
  return Object.fromEntries(CONTENT_KEYS.map((key) => [key, state[key]])) as Content;
@@ -279,7 +279,7 @@ function contentOf(state: State): Content {
279
279
 
280
280
  const programReference = () => onlineProgramItem({ id: inputId() })?.reference;
281
281
 
282
- async function loadPriced(state: State, arg?: unknown): Promise<undefined> {
282
+ async function load(state: State, arg?: unknown): Promise<undefined> {
283
283
  stashCopy(arg);
284
284
  const seq = ++loadSeq;
285
285
  const found = await fetchProgram(inputId()).then((program) => ({ program }), () => undefined);
@@ -288,20 +288,27 @@ async function loadPriced(state: State, arg?: unknown): Promise<undefined> {
288
288
  pricedItem = found?.program ? onlineProgramItem({ id: inputId() }, priceOf(found.program)) : pricedItem;
289
289
  void svc.pricingPublish();
290
290
  if (found?.program) void priceProgram();
291
+ void resumeCommerce();
291
292
  return undefined;
292
293
  }
293
294
 
295
+ /** First entry recovers held purchases; every later one (a return to the screen) reconciles. */
296
+ async function resumeCommerce(): Promise<void> {
297
+ const first = !commerceStarted;
298
+ commerceStarted = true;
299
+ await (first ? commerce.start() : commerce.reconcile());
300
+ void svc.pricingPublish();
301
+ }
302
+
294
303
  /** Content never waits on this, and the host scope is re-read each time, so a failed app-info
295
304
  * read recovers on the next refresh. */
296
305
  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] });
306
+ if (!pricedItem) return;
307
+ await commerce.loadPrices([pricedItem]);
308
+ void svc.pricingPublish();
302
309
  }
303
310
 
304
- async function enrollPriced(): Promise<undefined> {
311
+ async function enroll(): Promise<undefined> {
305
312
  const reference = programReference();
306
313
  if (!reference) return undefined;
307
314
  // A tap that lands as a refresh starts is refused without a verdict: the route is unknown, not
@@ -314,13 +321,29 @@ async function enrollPriced(): Promise<undefined> {
314
321
  return undefined;
315
322
  }
316
323
 
324
+ /** The App Store sale: only through the shared checkout, and only for the route the price shows. */
325
+ async function buy(): Promise<undefined> {
326
+ const reference = programReference();
327
+ if (!reference) return undefined;
328
+ await commerce.buy({ reference });
329
+ void svc.pricingPublish();
330
+ return undefined;
331
+ }
332
+
317
333
  function pricedState(state: State): State {
318
334
  const reference = programReference();
319
- const price = reference ? iapPriceFields(pricing.view(reference), iapPriceCopy("program")) : NO_PRICE;
335
+ const view = reference && pricing.view(reference);
336
+ const price = view ? iapPriceFields(view, iapPriceCopy("program")) : NO_PRICE;
337
+ const purchase = reference ? commerce.purchaseFields(reference, iapPurchaseCopy("program")) : NOT_APPLE;
338
+ const poll = commerce.pollDirective("himiBrandedProgram.iapPoll");
320
339
  return derive({
321
- ...state, ...content, actionMessage,
322
- priceLine: price.priceText, priceNote: price.priceNote, hidePriceRetry: price.hidePriceRetry && !scopeIncomplete,
323
- enrollDisabled: price.actionDisabled, enrollNote: price.actionNote,
340
+ ...state, ...content,
341
+ actionMessage: purchase.accessReady ? copyTemplate("program.joinedMessage", "You're enrolled in {name}.", { name: content.name }) : actionMessage,
342
+ // `disabled`: this app sells nothing through the App Store, so the program shows its own price.
343
+ priceLine: view?.route === "disabled" ? pricedItem?.commerceDisplayPrice ?? "" : price.priceText, priceNote: price.priceNote, hidePriceRetry: view?.route === "disabled" || (price.hidePriceRetry && commerce.scopeComplete()),
344
+ enrollDisabled: price.actionDisabled, enrollNote: price.actionNote, enrollHidden: purchase.appleRoute,
345
+ buyHidden: !purchase.appleRoute || purchase.accessReady, buyLabel: purchase.buyLabel, buyDisabled: purchase.buyDisabled, buyNote: purchase.buyNote,
346
+ ...(poll ? { _poll: poll } : {}),
324
347
  });
325
348
  }
326
349
 
@@ -344,34 +367,41 @@ const INITIAL_STATE: State = derive({
344
367
  hideContent: true,
345
368
  priceNote: "",
346
369
  hidePriceRetry: true,
347
- enrollDisabled: pricing.enabled,
370
+ enrollDisabled: true,
348
371
  enrollNote: "",
372
+ enrollHidden: false,
373
+ buyHidden: true,
374
+ buyLabel: "",
375
+ buyDisabled: true,
376
+ buyNote: "",
349
377
  });
350
378
 
351
379
  type Actions = {
352
380
  load: (state: State, arg?: unknown) => Promise<State | undefined>;
353
381
  enroll: (state: State) => Promise<State | undefined>;
382
+ buy: (state: State) => Promise<State | undefined>;
354
383
  pricingPublish: (state: State) => State;
355
384
  pricingStart: (state: State) => State;
356
385
  pricingRefresh: (state: State) => Promise<undefined>;
386
+ iapPoll: (state: State) => Promise<undefined>;
357
387
  };
358
388
 
359
389
  const svc = defineService<State, Actions>("himiBrandedProgram", {
360
390
  state: INITIAL_STATE,
361
- ...(pricing.enabled ? { notHydrated: [...PRICING_NOT_HYDRATED] } : {}),
391
+ notHydrated: [...PRICING_NOT_HYDRATED],
362
392
  actions: {
363
- load: pricing.enabled ? loadPriced : load,
364
- enroll: pricing.enabled ? enrollPriced : enroll,
393
+ load,
394
+ enroll,
395
+ buy,
365
396
  pricingPublish: pricedState,
366
- pricingStart: (state) => ({ ...pricedState(state), _poll: pricing.pollDirective("himiBrandedProgram.pricingRefresh") }),
397
+ pricingStart: pricedState,
367
398
  pricingRefresh: async () => { await priceProgram(); return undefined; },
399
+ iapPoll: async () => { await commerce.poll(); void svc.pricingPublish(); return undefined; },
368
400
  },
369
401
  });
370
402
 
371
403
  content = contentOf(svc.getState());
372
- if (pricing.enabled) {
373
- resetUnhydratedKeysOnOlderRuntime(svc, INITIAL_STATE, PRICING_NOT_HYDRATED);
374
- void svc.pricingStart();
375
- }
404
+ resetUnhydratedKeysOnOlderRuntime(svc, INITIAL_STATE, PRICING_NOT_HYDRATED);
405
+ void svc.pricingStart();
376
406
 
377
407
  export { svc };
@@ -7,16 +7,17 @@
7
7
  // screen; enrolling stays a hosted-site flow.
8
8
  // Four-state (loading / error / rows / empty) like the sibling branded workers.
9
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.
10
+ // App Store prices come from the shared IAP catalog layer (@himalaya/iap). An app that sells nothing
11
+ // through the App Store reads every program as `disabled` and shows no price line.
12
12
  import { defineService as register } from "@himalaya/state";
13
13
  import { decorateCollection } from "../_shared/collection-service.js";
14
14
  import { queryPrograms, type WixProgram } from "@himalaya/wix-online-programs";
15
- import { createIapCatalogPricing, iapPriceFields, onlineProgramItem, type IapPollDirective } from "@himalaya/iap";
15
+ import { iapPriceFields, onlineProgramItem, type IapPollDirective } from "@himalaya/iap";
16
+ import { createIapCommerce } from "@himalaya/iap-checkout";
16
17
  import { wixMediaUrl } from "@himalaya/wix-media";
17
18
  import { cleanTitle, initials } from "../_shared/himi-text.js";
18
19
  import { copyParam, stashCopy } from "../_shared/himi-copy.js";
19
- import { hostIapScope, hostScopeComplete, iapPriceCopy, resetUnhydratedKeysOnOlderRuntime } from "../_shared/iap-worker.js";
20
+ import { iapPriceCopy, resetUnhydratedKeysOnOlderRuntime } from "../_shared/iap-worker.js";
20
21
 
21
22
  const SERVICE = "himiBrandedPrograms";
22
23
 
@@ -133,10 +134,6 @@ function programFields(p: WixProgram): Omit<ProgramRow, "priceLine"> {
133
134
  };
134
135
  }
135
136
 
136
- function programRow(p: WixProgram): ProgramRow {
137
- return { ...programFields(p), priceLine: "" };
138
- }
139
-
140
137
  function programContent(p: WixProgram): ProgramContent {
141
138
  return { ...programFields(p), wixId: p.id ?? "" };
142
139
  }
@@ -177,28 +174,22 @@ async function fetchPrograms(): Promise<WixProgram[]> {
177
174
  return (response.programs ?? []).filter((p) => !isDraft(p));
178
175
  }
179
176
 
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) ─────
177
+ // ── Content and App Store prices ─────
190
178
  //
191
- // One writer: `loadPriced` keeps its content here and every state change goes through the
179
+ // One writer: `load` keeps its content here and every state change goes through the
192
180
  // synchronous `pricingPublish`, which re-reads the latest content and prices. An async action
193
181
  // that returned its own state could land after a newer publication and repaint stale prices.
194
182
 
195
- const pricing = createIapCatalogPricing({ onChange: () => { void svc.pricingPublish(); } });
183
+ // Prices only: the list sells nothing (each card opens the program, whose screen owns the sale),
184
+ // so its composition has no adapters and never starts a checkout.
185
+ const commerce = createIapCommerce({ onChange: () => { void svc.pricingPublish(); } });
186
+ const pricing = commerce.pricing;
196
187
  const PRICING_NOT_HYDRATED = ["rows", "collection", "hidePriceRetry"] as const;
197
188
  let content: { status: State["status"]; programs: ProgramContent[] } = { status: "loading", programs: [] };
198
189
  let scopeIncomplete = false;
199
190
  let loadSeq = 0;
200
191
 
201
- async function loadPriced(_state: State, arg?: unknown): Promise<undefined> {
192
+ async function load(_state: State, arg?: unknown): Promise<undefined> {
202
193
  stashCopy(arg);
203
194
  const seq = ++loadSeq;
204
195
  const programs = await fetchPrograms().catch(() => undefined);
@@ -212,12 +203,10 @@ async function loadPriced(_state: State, arg?: unknown): Promise<undefined> {
212
203
  /** Prices the programs on screen now. Content never waits on this, and the host scope is re-read
213
204
  * each time, so a failed app-info read recovers on the next refresh. */
214
205
  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
206
  if (content.status !== "ready") return;
220
- await pricing.load({ ...host, items: content.programs.flatMap((program) => onlineProgramItem({ id: program.wixId }) ?? []) });
207
+ const items = content.programs.flatMap((program) => onlineProgramItem({ id: program.wixId }) ?? []);
208
+ scopeIncomplete = !(await commerce.loadPrices(items)).scopeComplete;
209
+ void svc.pricingPublish();
221
210
  }
222
211
 
223
212
  function pricedState(state: State): State {
@@ -232,7 +221,8 @@ function pricedState(state: State): State {
232
221
  return {
233
222
  ...state, ...shown, programs: content.programs,
234
223
  // 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)),
224
+ // `disabled`: this app sells nothing through the App Store, so there is no price lookup to retry.
225
+ hidePriceRetry: content.status !== "ready" || !views.some((view) => view && view.route !== "disabled" && (scopeIncomplete || view.retryable)),
236
226
  priceRetryText: copyParam("programs.priceRetryText", "Some prices could not be loaded."),
237
227
  };
238
228
  }
@@ -260,7 +250,7 @@ type Actions = {
260
250
  };
261
251
 
262
252
  const actions: Actions = {
263
- load: pricing.enabled ? loadPriced : load,
253
+ load,
264
254
  pricingPublish: pricedState,
265
255
  pricingStart: (state) => ({ ...pricedState(state), _poll: pricing.pollDirective(`${SERVICE}.pricingRefresh`) }),
266
256
  pricingRefresh: async () => { await priceContent(); return undefined; },
@@ -269,18 +259,14 @@ const actions: Actions = {
269
259
  // The collection decorator's own `defineService` drops `notHydrated`, so the decorated definition
270
260
  // is registered directly: this run's prices must never be restored from a cold-boot snapshot.
271
261
  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]);
262
+ const svc = register<State, Actions>(SERVICE, { ...decorated, notHydrated: [...PRICING_NOT_HYDRATED] } as unknown as Parameters<typeof register<State, Actions>>[1]);
275
263
 
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();
264
+ const booted = svc.getState();
265
+ if ((booted.status === "ready" || booted.status === "error") && Array.isArray(booted.programs)) {
266
+ content = { status: booted.status, programs: booted.programs };
283
267
  }
268
+ resetUnhydratedKeysOnOlderRuntime(svc, decorated.state as unknown as State, PRICING_NOT_HYDRATED);
269
+ void svc.pricingStart();
284
270
 
285
271
  // Kick the first load at module scope, like every other list bundle
286
272
  // (himi-store-shop, himi-booking-services, himi-events-list, himi-blog-feed …).