@wix/himalaya-cli 0.1242.0 → 0.1244.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 (33) 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/core/ts/src/tokens.d.ts +94 -0
  9. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/checkout/src/index.d.ts +3 -0
  10. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/checkout.mjs +3 -0
  11. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/core.mjs +40 -6
  12. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap.mjs +192 -0
  13. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/screens.mjs +3 -0
  14. package/dist/cli.mjs +365 -226
  15. package/dist/preview-assets.generated.bin +0 -0
  16. package/guides/getting-started.md +6 -0
  17. package/guides/himi-skill.md +6 -0
  18. package/guides/iap-pricing.md +162 -211
  19. package/guides/index.json +7 -6
  20. package/guides/sdk-reference.json +96 -0
  21. package/guides/tier-5-add-a-screen.md +9 -0
  22. package/guides/tokens.md +28 -0
  23. package/guides/wix-api-usage.md +11 -0
  24. package/guides/wix-site-to-himi-app.md +1 -1
  25. package/package.json +2 -2
  26. package/templates/branded-lite/dev/index.ts +110 -3
  27. package/templates/branded-lite/tier5-src/_shared/iap-worker.ts +65 -0
  28. package/templates/branded-lite/tier5-src/himi-branded-lite-program/index.ts +179 -52
  29. package/templates/branded-lite/tier5-src/himi-branded-lite-programs/index.ts +158 -38
  30. package/templates/yoga-studio/dev/index.ts +110 -3
  31. package/templates/yoga-studio/tier5-src/_shared/iap-worker.ts +65 -0
  32. package/templates/yoga-studio/tier5-src/himi-branded-lite-program/index.ts +179 -52
  33. package/templates/yoga-studio/tier5-src/himi-branded-lite-programs/index.ts +158 -38
Binary file
@@ -218,6 +218,12 @@ mapper writes the site's light colour into the dark arm too, so dark mode is whe
218
218
  To create/publish/invoke the site's Velo HTTP functions, `himi docs wix-site-http-functions`
219
219
  and `himi site function` (not analyze, and not `himi functions`).
220
220
 
221
+ **Selling digital items** (programs, products, plans the owner registered with `himi store iap`)?
222
+ Any screen that shows such an item's price — a template screen or one you write — gets its App
223
+ Store price and payment route from the shared `createIapCatalogPricing` in `@himalaya/iap`:
224
+ read `himi docs iap-pricing` before writing the worker. Starting from a template does not cover
225
+ your own screens; each screen opts in explicitly.
226
+
221
227
  After `himi init … --template branded-lite` (or a vertical overlay), **point the app at your
222
228
  Wix site** before expecting live catalog data:
223
229
 
@@ -247,6 +247,12 @@ mapper writes the site's light colour into the dark arm too, so dark mode is whe
247
247
  To create/publish/invoke the site's Velo HTTP functions, `himi docs wix-site-http-functions`
248
248
  and `himi site function` (not analyze, and not `himi functions`).
249
249
 
250
+ **Selling digital items** (programs, products, plans the owner registered with `himi store iap`)?
251
+ Any screen that shows such an item's price — a template screen or one you write — gets its App
252
+ Store price and payment route from the shared `createIapCatalogPricing` in `@himalaya/iap`:
253
+ read `himi docs iap-pricing` before writing the worker. Starting from a template does not cover
254
+ your own screens; each screen opts in explicitly.
255
+
250
256
  After `himi init … --template branded-lite` (or a vertical overlay), **point the app at your
251
257
  Wix site** before expecting live catalog data:
252
258
 
@@ -1,225 +1,176 @@
1
- # Shared Apple IAP pricing
2
-
3
- `@himalaya/iap` resolves read-only payment mappings and retrieves Apple metadata for
4
- cards, details, and checkout previews. It neither buys anything nor proves access.
5
- The mapping provider is an internal client port; a deployed production backend is
6
- still required. Do not call the purchase-delivery coordinator for price lookup.
7
-
8
- ## Inputs and states
9
-
10
- Create one controller per worker/service with `createPricingController({ mapping,
11
- getProducts, onState })`. Inject the existing SDK `getProducts` as the real store
12
- adapter; tests may inject another function. `load(input)` accepts:
13
-
14
- - `scope`: app ID, bundle ID, Wix site ID, and optional opaque `contextRevision`.
15
- Change the revision whenever a member/audience change affects mapping. Pass no
16
- credential or raw member identity.
17
- - `platform`: the actual trusted host platform. Only `ios` resolves Apple prices.
18
- - `items`: `{ reference: { kind, id }, commerceDisplayPrice? }`. References are
19
- unique by the pair; repeated references retain the first occurrence and its
20
- commerce input. Different entity kinds cannot collide.
21
-
22
- The injected batch `mapping.resolve({ scope, references })` returns one mapping
23
- per requested reference: `appleIap` with `productId` and `mappingRevision`,
24
- `regular`, or `unavailable`. Missing, duplicate, unknown, or malformed routes are
25
- unresolved. This is not an agreed Scala wire contract or a deployed endpoint.
26
-
27
- `getState()` returns a detached serializable snapshot with revision, captured
28
- scope, and per-reference items. Loading and unavailable items contain **no price**.
29
- Apple ready items carry `source: "appleIap"`, product ID, mapping revision, and
30
- exact StoreKit `displayPrice`; regular ready items carry `source: "regular"` and
31
- the caller's existing commerce price. Copy remains the screen's responsibility.
32
- When that commerce price is missing or blank, `commercePriceUnavailable` is
33
- non-retryable: the caller must supply a usable price in a new `load(input)`.
34
- Refreshing the same input cannot supply it.
35
-
36
- Apple strings are displayed unchanged, including spaces and currency placement;
37
- they are not reconstructed from micros or phone locale. See Apple's
38
- [displayPrice contract](https://developer.apple.com/documentation/storekit/product/displayprice).
39
- Only non-consumables are supported here. A lookup failure, empty response, missing
40
- product, or blank price is `priceUnavailable`, not proof of a network failure or
41
- nonexistent product: the current native adapter collapses store errors into `[]`.
42
- There is never a Wix price or external-checkout fallback for an Apple route.
43
-
44
- `load` batches unique Apple IDs and joins identical work already in flight within
45
- this controller. Input identity includes scope, platform, item order/references,
46
- and commerce display prices. Every call after completion performs a fresh lookup.
47
- `refresh()` reloads the last captured input, also joining identical in-flight work.
48
- Changing input invalidates prior work before its results can publish; obsolete
49
- mapping results do not start a store lookup. `dispose()` clears readable state,
50
- suppresses later publications/calls, and cannot cancel store/network I/O already
51
- sent. No persistent or app-wide cache exists; separate workers perform independent
52
- lookups.
53
-
54
- ## Complete Tier-5 service example
55
-
56
- This example is compiled and executed by `pricing-integration.test.ts` against the
57
- actual worker bootstrap and SDK dispatch seam. Register it separately in each
58
- screen worker, injecting the mapping provider and trusted initial input. It is a
59
- checkout **preview**: purchase readiness stays false independently of metadata.
1
+ # Showing App Store prices on any screen
60
2
 
61
- ```ts
62
- // pricing-worker.ts
63
- import { defineService } from "@himalaya/state";
64
- import {
65
- createPricingController, getProducts,
66
- type PricingInput, type PricingMappingProvider, type PricingState,
67
- } from "@himalaya/iap";
68
-
69
- export interface PriceCopy {
70
- loading: string;
71
- unavailable: string;
72
- apple: string;
73
- regular: string;
74
- }
3
+ `@himalaya/iap` gives every screen one shared way to show the App Store price of an item it
4
+ displays or sells: a program, a product, a plan, or anything else you registered with
5
+ `himi store iap`. Your worker passes its scope and the items on screen. The shared layer looks up
6
+ which items are sold through the App Store, fetches their prices from Apple, and returns display
7
+ state plus a payment route. Your screen binds the result to its own UI.
75
8
 
76
- interface PriceRow {
77
- id: string;
78
- status: "loading" | "ready" | "unavailable";
79
- displayPrice: string;
80
- message: string;
81
- retryable: boolean;
82
- loading: boolean;
83
- hideLoading: boolean;
84
- hideRetry: boolean;
85
- }
9
+ > **Not switched on yet.** Wix has not connected the service that maps your catalog to App Store
10
+ > products. Until it does, every screen keeps its existing price and checkout. Apple purchasing is
11
+ > not available in any case: this layer shows prices and blocks the wrong checkout, but never buys.
12
+ > Only one-time purchases (non-consumables) are supported. Subscriptions and consumables are not.
86
13
 
87
- interface PriceServiceState {
88
- pricing: PricingState;
89
- rows: PriceRow[];
90
- canPurchase: boolean;
91
- purchaseDisabled: boolean;
92
- }
14
+ ## What you get per item
93
15
 
94
- export function registerPricingService(
95
- mapping: PricingMappingProvider, initialInput: PricingInput, copy: PriceCopy,
96
- ) {
97
- let input = initialInput;
98
- let disposed = false;
99
- const controller = createPricingController({
100
- mapping, getProducts,
101
- onState: () => { void service.publish(); },
102
- });
16
+ `pricing.view(reference)` returns plain data you can put in state. Its `route` tells the screen
17
+ what to do:
18
+
19
+ | `route` | Show | Existing checkout |
20
+ |---|---|---|
21
+ | `disabled` | Your screen's own price, exactly as before | Allowed |
22
+ | `resolving` | A "Loading price…" label, never your own price | Blocked |
23
+ | `appleIap` | `displayPrice` exactly as Apple formats it, plus a "One-time purchase" note | Blocked; purchasing is not available yet |
24
+ | `regular` | `displayPrice` (the commerce price you passed), or nothing | Allowed |
25
+ | `unavailable` | "Price unavailable", plus Retry when `retryable` | Blocked |
26
+
27
+ Never fall back to your own price or checkout for an item on an App Store route. A missing price
28
+ does not mean the item is free. Only an explicit `regular` route keeps your existing flow.
29
+
30
+ `iapPriceFields(view, copy)` turns a view into bindable fields (`priceText`, `priceNote`,
31
+ `priceLoading`, `hidePriceRetry`, `actionDisabled`, `actionNote`) using labels from your own copy.
32
+
33
+ ## Catalog references
34
+
35
+ An item is identified by `{ kind, id }`. The kind is the same entity type `himi store iap` uses
36
+ when it registers the item with Apple:
37
+
38
+ | `kind` | Item | Helper |
39
+ |---|---|---|
40
+ | `onlineProgram` | An Online Programs program | `onlineProgramItem(program, commercePrice)` |
41
+ | `storesProduct` | A Wix Stores product | Build `{ kind: "storesProduct", id }` |
42
+ | `pricingPlan` | A one-time Pricing Plans plan | Build `{ kind: "pricingPlan", id }` |
43
+
44
+ ## Add it to a worker
45
+
46
+ This is a complete worker for one screen. Copy the pattern, not the course names.
103
47
 
104
- function project(pricing: PricingState): PriceRow[] {
105
- return pricing.items.map(item => ({
106
- id: JSON.stringify([item.reference.kind, item.reference.id]),
107
- status: item.status,
108
- displayPrice: item.status === "ready" ? item.displayPrice : "",
109
- message: item.status === "loading" ? copy.loading : item.status === "unavailable"
110
- ? copy.unavailable : item.source === "appleIap" ? copy.apple : copy.regular,
111
- retryable: item.status === "unavailable" && item.retryable,
112
- loading: item.status === "loading",
113
- hideLoading: item.status !== "loading",
114
- hideRetry: item.status !== "unavailable" || !item.retryable,
115
- }));
48
+ ```ts
49
+ // catalog-pricing-worker.ts
50
+ import { defineService } from "@himalaya/state";
51
+ import { get as appInfo } from "@himalaya/app-info";
52
+ import { createIapCatalogPricing, iapPriceFields, onlineProgramItem } from "@himalaya/iap";
53
+
54
+ const COPY = {
55
+ loading: "Loading price…",
56
+ unavailable: "Price unavailable",
57
+ oneTimePurchase: "One-time purchase",
58
+ purchaseUnavailable: "Purchasing is not available yet.",
59
+ };
60
+ // This run's prices and buy state must never be restored from a cold-boot snapshot.
61
+ const PRICE_KEYS = ["priceText", "priceNote", "hidePriceRetry", "buyDisabled", "buyNote"];
62
+
63
+ interface Course { id: string; title: string; commercePrice: string }
64
+ type CourseState = Record<string, unknown>;
65
+
66
+ /** `startCheckout` is the screen's existing commerce flow. */
67
+ export function registerCourseScreen(course: Course, startCheckout: () => Promise<string>) {
68
+ const pricing = createIapCatalogPricing({ onChange: () => { void service.publish(); } });
69
+ const item = onlineProgramItem({ id: course.id }, course.commercePrice)!;
70
+ let message = "";
71
+ let scopeIncomplete = false;
72
+
73
+ async function hostScope() {
74
+ const info = await appInfo().catch(() => undefined);
75
+ scopeIncomplete = !info?.appId || !info?.bundleId || !info?.platform;
76
+ return { scope: { appId: info?.appId ?? "", bundleId: info?.bundleId ?? "" }, platform: info?.platform ?? "" };
116
77
  }
117
78
 
118
- const actions = {
119
- start: (state: PriceServiceState) => ({ ...state, _poll: {
120
- id: "pricing", action: "pricing.reload", intervalMs: 60_000, immediate: true,
121
- } }),
122
- publish: (state: PriceServiceState) => {
123
- const pricing = controller.getState();
124
- return { ...state, pricing, rows: project(pricing), canPurchase: false, purchaseDisabled: true };
125
- },
126
- reload: async () => {
127
- if (!disposed) await controller.load(input);
128
- // Publication uses a separate reducer; returning captured state would race.
129
- return undefined;
130
- },
131
- retry: async () => {
132
- if (!disposed) await controller.refresh();
133
- return undefined;
134
- },
135
- setInput: async (_state: PriceServiceState, next: PricingInput) => {
136
- if (!disposed) { input = next; await controller.load(input); }
137
- return undefined;
138
- },
139
- dispose: (state: PriceServiceState) => {
140
- disposed = true;
141
- controller.dispose();
142
- return { ...state, pricing: controller.getState(), rows: [], canPurchase: false, purchaseDisabled: true };
79
+ async function loadPrices() {
80
+ if (pricing.enabled) await pricing.load({ ...(await hostScope()), items: [item] });
81
+ void service.publish();
82
+ }
83
+
84
+ function present(state: CourseState): CourseState {
85
+ if (!pricing.enabled) return { ...state, priceText: course.commercePrice, buyDisabled: false, message };
86
+ const price = iapPriceFields(pricing.view(item.reference), COPY);
87
+ return { ...state, priceText: price.priceText, priceNote: price.priceNote, hidePriceRetry: price.hidePriceRetry && !scopeIncomplete,
88
+ buyDisabled: price.actionDisabled, buyNote: price.actionNote, message };
89
+ }
90
+
91
+ const service = defineService("course", {
92
+ state: { title: course.title, priceText: "", priceNote: "", hidePriceRetry: true, buyDisabled: pricing.enabled, buyNote: "", message: "" } as CourseState,
93
+ notHydrated: pricing.enabled ? PRICE_KEYS : [],
94
+ actions: {
95
+ // The screen starts the first lookup; the host's poll and Retry only refresh one that exists.
96
+ start: (state: CourseState) => {
97
+ void loadPrices();
98
+ return { ...present(state), _poll: pricing.pollDirective("course.refreshPrice") };
99
+ },
100
+ publish: (state: CourseState) => present(state),
101
+ // Re-reads the host too: `pricing.refresh()` would replay a scope whose identity read failed.
102
+ refreshPrice: async () => { await loadPrices(); return undefined; },
103
+ buy: async () => {
104
+ // Re-check at the moment of the mutation: a price refresh can change the route after render.
105
+ // While a refresh resolves the route is unknown, so refuse without claiming anything.
106
+ const start = pricing.checkoutGuard(item.reference);
107
+ if (start.allowed) message = await startCheckout();
108
+ else message = start.route === "resolving" ? "" : COPY.purchaseUnavailable;
109
+ void service.publish();
110
+ return undefined;
111
+ },
143
112
  },
144
- };
145
- const initialPricing: PricingState = { ...controller.getState(), items: initialInput.items.map(item => ({
146
- reference: { kind: item.reference.kind, id: item.reference.id }, status: "loading",
147
- })) };
148
- const service = defineService<PriceServiceState, typeof actions>("pricing", {
149
- state: { pricing: initialPricing, rows: project(initialPricing), canPurchase: false, purchaseDisabled: true },
150
- notHydrated: ["pricing", "rows", "canPurchase", "purchaseDisabled"], actions,
151
113
  });
152
114
  void service.start();
153
115
  return service;
154
116
  }
155
117
  ```
156
118
 
157
- The bundle must declare `iap`. To derive platform/app identity from the host,
158
- declare `app_info` too and call `@himalaya/app-info.get()` before registering the
159
- service. Use its `platform`, `appId`, and `bundleId`, with the caller's site and
160
- opaque context revision. If the call rejects or identity is absent, keep the
161
- Apple route unavailable; never assume iOS. Its `environment: dev | prod` describes
162
- Himalaya serving, **not** Apple Sandbox/production. Do not introduce an app-info
163
- dependency into the pricing package itself.
164
-
165
- Bind price text to `$service:pricing.rows.0.displayPrice` for a single card/detail/
166
- checkout item, and use the existing bound list template for multiple rows. Bind
167
- loading visibility to `hideLoading`, Retry visibility to `hideRetry`, and the
168
- purchase button's `disabledPath` to `$service:pricing.purchaseDisabled`. The Retry
169
- action is `tier5.pricing.retry`. Existing Text, ActivityIndicator, and Button components
170
- are sufficient; keep labels in the consuming copy pack and use stable component
171
- IDs. Regular commerce route/checkout remains owned by the existing commerce flow.
172
-
173
- ## Lifecycle and purchase boundaries
174
-
175
- The service arms `_poll` at module evaluation with an immediate first load and a
176
- 60-second visible cadence. The **host** schedules it: worker JS has no ordinary
177
- timers. Current iOS pooling pauses off-screen polls, resumes immediately on return,
178
- and the lifecycle scheduler pauses in the background and fires on resume. Worker
179
- teardown cancels polling. `_poll` has no explicit stop directive; a disposed
180
- service ignores subsequent ticks until its worker is destroyed.
181
-
182
- Use the same module-evaluation registration on card, detail, and checkout entry;
183
- checkout opens with a fresh controller/lookup, or call `reload` if retaining its
184
- controller. Route supported caller scope/reference changes through `setInput`.
185
- Do not use descriptor `onAppear` as a reliable iOS return callback: retained
186
- screens do not necessarily remount. Prices and derived fields must be excluded
187
- from persisted worker hydration, as above, on hosts supporting `notHydrated`.
188
-
189
- Every new lookup clears the amount immediately. A failed periodic/background
190
- refresh shows unavailable with Retry rather than keeping a prior amount as freshly
191
- confirmed. Overlapping entry, poll, checkout, and Retry requests join matching
192
- in-flight work. No polling continues while the iOS host has pooled/backgrounded
193
- the worker.
194
-
195
- No Storefront update bridge is implemented in this slice. Supported invalidations
196
- refresh prices; a storefront change while visible may remain undetected until the
197
- next poll or explicit refresh. This does not establish immediate storefront
198
- notification. Native `Storefront.updates` remains a separate follow-up.
199
-
200
- Price metadata is not purchase authorization or entitlement. A future activated
201
- checkout must compare its purchase context's Apple product ID with the displayed
202
- ready state; if they differ, refresh mapping/price and show the new amount before
203
- allowing purchase. The existing purchase coordinator remains unchanged, and this
204
- preview never invokes it. Mapping fixtures and their failure scenarios belong only
205
- in the local demonstration. Production mapping, checkout activation, and template
206
- migration are still required before rollout.
207
-
208
- The dedicated iOS LiftGear QA binary opts into the existing managed transaction
209
- API before shared capability registration. Its native identity requires both
210
- `com.alexchudnovsky.liftgear` and baked app
211
- `alexanderch3.fit-gear-2-push-test`; the tenant is `liftgear-sandbox-lab`.
212
- This opt-in cannot be selected by worker input, member identity, or OTA content.
213
- Other Branded Lite identities retain their existing registration behavior.
214
- For this dedicated QA identity, a pinned baked config with the
215
- `liftgear-pricing` scheme and a pricing-card home supplies the navigation menu
216
- and initial screen. This keeps the embedded demonstration visible after an
217
- update over an existing LiftGear installation or a live config refresh, without
218
- changing stored member data or managed purchase policy. Ordinary or incomplete
219
- baked configs continue to use the cached navigation. This is a bounded preview
220
- host rule, not a general fix for distribution release routing.
221
- The separate, explicitly armed purchase demonstration calls
222
- `purchaseForVerification` with a labelled synthetic association, reports Apple's
223
- actual transaction environment, and leaves the transaction unfinished. It has
224
- no transaction-server integration or program-access delivery. Pricing alone
225
- does not arm the demonstration or start a purchase.
119
+ Bind the fields in the screen descriptor and declare both capabilities the worker calls. To show
120
+ real App Store prices, the app must also grant `iap` (and `app_info`) in its `config.ts`
121
+ `capabilities`, so its native shell registers the store bridge; until it does, the screen fails
122
+ closed to "Price unavailable" rather than showing a price it could not confirm.
123
+
124
+ ```json
125
+ {
126
+ "logicBundle": { "declaredCapabilities": ["iap", "app_info"] },
127
+ "price": { "type": "Text", "props": { "text": "$service:course.priceText", "collapseWhenEmpty": true } },
128
+ "note": { "type": "Text", "props": { "text": "$service:course.priceNote", "collapseWhenEmpty": true } },
129
+ "retry": { "type": "Button", "props": { "title": "Try again", "action": { "actionId": "tier5.course.refreshPrice" } },
130
+ "modifiers": { "hiddenPath": "$service:course.hidePriceRetry" } },
131
+ "buy": { "type": "Button", "props": { "title": "Enrol", "disabledPath": "$service:course.buyDisabled",
132
+ "action": { "actionId": "tier5.course.buy" } } },
133
+ "buyNote": { "type": "Text", "props": { "text": "$service:course.buyNote", "collapseWhenEmpty": true } }
134
+ }
135
+ ```
136
+
137
+ ## Rules
138
+
139
+ - **One pricing instance per worker, one `load` per set of visible items.** Pass every item the
140
+ screen shows in a single call; the layer batches the lookup and fetches each Apple product once.
141
+ - **Guard the action, not only the button.** Call `checkoutGuard` immediately before a commerce flow
142
+ starts; actions can run without a tap. Before each later step of a flow that already started (a
143
+ payment after a join or reservation), call `continueCheckoutGuard`: it lets the step through on the
144
+ route the last lookup settled, so a routine refresh cannot strand the flow halfway, and stops it
145
+ once a refresh settles on an App Store route. If the flow pays through `payOrder` from
146
+ `@wix/himalaya/checkout`, pass the same check as `mayCharge`; it runs again right before the wallet
147
+ charges and before the hosted cashier opens.
148
+ - **One writer.** Publish through a synchronous action that reads `pricing.view` at that moment.
149
+ An async action that returns its own state can land after a newer price and repaint a stale one.
150
+ - **No timers.** `pollDirective` asks the app to refresh every 60 seconds while the screen is
151
+ visible and on return to it. Point it and Retry at an action that reads the host and calls `load`
152
+ again, so a failed identity read recovers; `refresh` replays the last scope unchanged. Every
153
+ lookup clears the old price first.
154
+ - **Keep prices out of hydration** with `notHydrated`, so a cold start never shows an old amount.
155
+ - **Content first.** A pricing failure is per item. Never replace the screen with an error because a
156
+ price could not load.
157
+ - **iOS only.** On Android, web and macOS, App Store routes come back `unavailable`. Regular
158
+ routes are unaffected.
159
+
160
+ ## Adopting it
161
+
162
+ - **Starter templates with Online Programs** (`branded-lite`, `yoga-studio`) already include it on
163
+ their Programs list and program screens.
164
+ - **Existing screens, including apps created from a template before this existed:** an SDK update
165
+ never changes a screen that does not call this layer. Add the worker calls, the bindings and the
166
+ two capabilities above. After that, screens pick up layer fixes on their next build.
167
+ - **Custom screens:** follow the worker above for any item you sell, whatever its vertical.
168
+ - **New item types:** use the kind `himi store iap` registers for it, build `{ kind, id }`, and call
169
+ the same layer. Never write your own mapping lookup, App Store price request or payment guard.
170
+
171
+ ## What turns it on
172
+
173
+ Register your digital products with `himi store iap` (see `himi docs iap-products`). When Wix
174
+ connects the mapping service those registrations feed, screens that use this layer start showing
175
+ App Store prices on their next build — in an app that grants the `iap` capability in `config.ts`.
176
+ Screens that do not use the layer are unaffected.
package/guides/index.json CHANGED
@@ -20,6 +20,7 @@
20
20
  ],
21
21
  "guardrails": [
22
22
  "Prefer Tier-5 (SDUI descriptor + JS worker) over native screens — reach for native only when SDUI genuinely can't express it.",
23
+ "A screen that shows or sells an item that may be an App Store in-app purchase (a program, product or plan — template or custom screen alike) uses the shared layer `createIapCatalogPricing` from `@himalaya/iap` (`himi docs iap-pricing`): it batches the mapping lookup, fetches Apple prices and decides the payment route. Never hand-wire a mapping, an Apple price request or a payment guard in a worker. Call `checkoutGuard` immediately before a commerce flow starts, and `continueCheckoutGuard` before each later step of a flow already under way (a payment after a join), so a routine price refresh cannot strand it halfway. It is off until Wix connects the mapping service, and Apple purchasing is not available yet.",
23
24
  "Keep structure descriptor-authored: use FragmentSlot for enumerable variants and bound list/grid templates for repetition. Use WorkerSubtree only for runtime-unknowable structure, with an explicit screen allowlist, policy budgets, and shipped fallback.",
24
25
  "Bindings ($service:<svc>.<path>, $item.<field>, $flag:<key>) are plain strings in ANY prop — INCLUDING Modifiers token refs (background, color, tint), so a worker can drive per-item theming, not just text. They CANNOT target strict-enum fields (style, size, alignment, severity, presentation, …) — those fail at render, not validation.",
25
26
  "Workers are sandboxed: no DOM, no timers, no raw fetch. Produce state synchronously in actions; do not rely on an ambient refresh tick.",
@@ -178,12 +179,6 @@
178
179
  "category": "Workers & SDKs",
179
180
  "file": "capabilities.md"
180
181
  },
181
- {
182
- "id": "iap-pricing",
183
- "title": "Apple IAP pricing in a worker",
184
- "category": "Workers & SDKs",
185
- "file": "iap-pricing.md"
186
- },
187
182
  {
188
183
  "id": "wix-api-usage",
189
184
  "title": "Call Wix APIs from a worker",
@@ -327,6 +322,12 @@
327
322
  "title": "Registering digital products as App Store in-app purchases",
328
323
  "category": "CLI & Loop",
329
324
  "file": "iap-products.md"
325
+ },
326
+ {
327
+ "id": "iap-pricing",
328
+ "title": "Showing App Store prices on any screen",
329
+ "category": "Workers & SDKs",
330
+ "file": "iap-pricing.md"
330
331
  }
331
332
  ],
332
333
  "componentReferenceFile": "component-reference.json",
@@ -1777,6 +1777,102 @@
1777
1777
  "jsdoc": "/** Creates one worker's read-only pricing controller with injected mapping, store lookup, and state publication. */",
1778
1778
  "decl": "export function createPricingController(deps: PricingDependencies): PricingController;"
1779
1779
  },
1780
+ {
1781
+ "name": "IAP_CATALOG_KINDS",
1782
+ "kind": "const",
1783
+ "jsdoc": "/** The Wix entity types `himi store iap` writes into its Wix→Apple mapping, so a mapping\n * backend can serve that export without translating kinds. */",
1784
+ "decl": "export const IAP_CATALOG_KINDS = [\"onlineProgram\", \"storesProduct\", \"pricingPlan\"] as const;"
1785
+ },
1786
+ {
1787
+ "name": "IapCatalogKind",
1788
+ "kind": "type",
1789
+ "jsdoc": "",
1790
+ "decl": "export type IapCatalogKind = typeof IAP_CATALOG_KINDS[number];"
1791
+ },
1792
+ {
1793
+ "name": "IapCatalogItem",
1794
+ "kind": "interface",
1795
+ "jsdoc": "",
1796
+ "decl": "export interface IapCatalogItem {\n reference: CatalogReference;\n /** The screen's own formatted commerce price; shown only on an explicit regular route. */\n commerceDisplayPrice?: string;\n}"
1797
+ },
1798
+ {
1799
+ "name": "IapCatalogScope",
1800
+ "kind": "interface",
1801
+ "jsdoc": "",
1802
+ "decl": "export interface IapCatalogScope {\n appId: string;\n bundleId: string;\n /** Wix metaSiteId. Omit it to use the connected mapping source's bound site. */\n siteId?: string;\n contextRevision?: string;\n}"
1803
+ },
1804
+ {
1805
+ "name": "IapCatalogInput",
1806
+ "kind": "interface",
1807
+ "jsdoc": "",
1808
+ "decl": "export interface IapCatalogInput {\n scope: IapCatalogScope;\n /** The host's own platform (from `@himalaya/app-info`); only \"ios\" resolves Apple prices. */\n platform: string;\n items: IapCatalogItem[];\n}"
1809
+ },
1810
+ {
1811
+ "name": "IapPaymentRoute",
1812
+ "kind": "type",
1813
+ "jsdoc": "/** `disabled` means no backend is connected: the screen's existing commerce behavior applies. */",
1814
+ "decl": "export type IapPaymentRoute = \"disabled\" | \"regular\" | \"appleIap\" | \"resolving\" | \"unavailable\";"
1815
+ },
1816
+ {
1817
+ "name": "IapItemView",
1818
+ "kind": "interface",
1819
+ "jsdoc": "/** Serializable per-item state a screen binds. Carries no credential, transaction or entitlement. */",
1820
+ "decl": "export interface IapItemView {\n reference: CatalogReference;\n route: IapPaymentRoute;\n /** Apple: StoreKit's string verbatim. Regular: the caller's commerce price. Otherwise \"\". */\n displayPrice: string;\n reason?: PricingUnavailableReason;\n retryable: boolean;\n /** True only when the existing commerce checkout may run: disabled or an explicit regular route. */\n canStartRegularCheckout: boolean;\n /** Apple purchasing is not available until a backend verifies transactions. */\n purchasable: false;\n}"
1821
+ },
1822
+ {
1823
+ "name": "IapCheckoutDecision",
1824
+ "kind": "interface",
1825
+ "jsdoc": "",
1826
+ "decl": "export interface IapCheckoutDecision {\n allowed: boolean;\n route: IapPaymentRoute;\n}"
1827
+ },
1828
+ {
1829
+ "name": "IapMappingSource",
1830
+ "kind": "interface",
1831
+ "jsdoc": "/** A deployed Wix→Apple mapping backend. Never supplied by a screen. */",
1832
+ "decl": "export interface IapMappingSource {\n mapping: PricingMappingProvider;\n /** The site this backend serves, used when the caller passes no site. */\n siteId?: string;\n}"
1833
+ },
1834
+ {
1835
+ "name": "IapPollDirective",
1836
+ "kind": "interface",
1837
+ "jsdoc": "",
1838
+ "decl": "export interface IapPollDirective {\n id: string;\n action: string;\n intervalMs: number;\n immediate: boolean;\n}"
1839
+ },
1840
+ {
1841
+ "name": "IapCatalogPricing",
1842
+ "kind": "interface",
1843
+ "jsdoc": "",
1844
+ "decl": "export interface IapCatalogPricing {\n readonly enabled: boolean;\n /** Batched mapping + Apple lookup for the items a screen shows. */\n load(input: IapCatalogInput): Promise<void>;\n refresh(): Promise<void>;\n view(reference: CatalogReference): IapItemView;\n /** May a commerce flow START now? Re-read immediately before the first mutation: the route as it\n * is now, so a refresh in flight (`resolving`) blocks. */\n checkoutGuard(reference: CatalogReference): IapCheckoutDecision;\n /** May a flow that already STARTED take its next commerce step (e.g. pay after a join)? While no\n * new answer exists (a refresh in flight, or a mapping backend that did not answer) it uses the\n * route the last settled lookup gave for the current scope, so a routine refresh cannot strand a\n * regular checkout halfway; any new answer other than regular stops it. */\n continueCheckoutGuard(reference: CatalogReference): IapCheckoutDecision;\n /** The host-scheduled visible refresh; undefined while disabled. */\n pollDirective(action: string): IapPollDirective | undefined;\n dispose(): void;\n}"
1845
+ },
1846
+ {
1847
+ "name": "IapPriceCopy",
1848
+ "kind": "interface",
1849
+ "jsdoc": "/** Labels come from the screen's copy pack; the shared layer only chooses between them. */",
1850
+ "decl": "export interface IapPriceCopy {\n loading: string;\n unavailable: string;\n oneTimePurchase: string;\n purchaseUnavailable: string;\n}"
1851
+ },
1852
+ {
1853
+ "name": "IapPriceFields",
1854
+ "kind": "interface",
1855
+ "jsdoc": "",
1856
+ "decl": "export interface IapPriceFields {\n priceText: string;\n priceNote: string;\n priceLoading: boolean;\n hidePriceRetry: boolean;\n actionDisabled: boolean;\n actionNote: string;\n}"
1857
+ },
1858
+ {
1859
+ "name": "createIapCatalogPricing",
1860
+ "kind": "function",
1861
+ "jsdoc": "/** One screen worker's catalog pricing. Activation comes only from the connected mapping\n * source, so a screen cannot turn Apple prices on by supplying its own mapping. */",
1862
+ "decl": "export function createIapCatalogPricing(options: { onChange?(): void } = {}): IapCatalogPricing;"
1863
+ },
1864
+ {
1865
+ "name": "iapPriceFields",
1866
+ "kind": "function",
1867
+ "jsdoc": "/** Bindable display state for one item. A screen spreads these into its own state and binds\n * them; the labels stay in the screen's copy pack. */",
1868
+ "decl": "export function iapPriceFields(view: IapItemView, copy: IapPriceCopy): IapPriceFields;"
1869
+ },
1870
+ {
1871
+ "name": "onlineProgramItem",
1872
+ "kind": "function",
1873
+ "jsdoc": "/** An Online Programs program as a shared IAP catalog item. A program without a Wix ID cannot be\n * mapped (a title fallback is not an identity), so it has no item and its screen shows no price. */",
1874
+ "decl": "export function onlineProgramItem(program: { id?: string | null }, commerceDisplayPrice?: string): IapCatalogItem | undefined;"
1875
+ },
1780
1876
  {
1781
1877
  "name": "ProductType",
1782
1878
  "kind": "type",
@@ -479,6 +479,15 @@ This is what makes the fail-closed capability gates readable. `local_database: e
479
479
  "X" is not declared in the app's dataSchema` is a message written to be read — before
480
480
  #2288 an author only ever saw an eternal spinner.
481
481
 
482
+ ### Show App Store prices for catalog items
483
+
484
+ A screen that shows or sells a program, product or plan that may be an App Store in-app purchase
485
+ does not look up mappings or Apple prices itself. It uses the shared catalog layer
486
+ (`createIapCatalogPricing` in `@himalaya/iap`), which also hands it the `_poll` directive below and
487
+ the guards a commerce flow must call: `checkoutGuard` before it starts, `continueCheckoutGuard`
488
+ before each later step. The adoption guide, with a compiled and tested
489
+ worker example, is `himi docs iap-pricing` ([iap-pricing-for-screens.md](iap-pricing-for-screens.md)).
490
+
482
491
  ### Refresh on a cadence with `_poll` (the worker has no clock)
483
492
 
484
493
  A worker is a pure reducer with **no `setTimeout` and no `setInterval`** — its sandbox
package/guides/tokens.md CHANGED
@@ -21,6 +21,7 @@ one registry, mirrored byte-for-byte by the iOS + Android decoders.
21
21
  | `kit` | { material?, variant?, density?, motion? } | optional — the active design kit |
22
22
  | `contentMaxWidth` | { base, medium?, expanded? } | optional |
23
23
  | `elevations` | name → { level?, tint?, shadow?, border? } | optional — a Card wears `card` |
24
+ | `controls` | { button?: { sm?, md?, lg?, variants? } } | optional — every Button's size and paint |
24
25
 
25
26
  ## ⚠️ Typography requires `size`, `weight`, AND `line`
26
27
 
@@ -96,3 +97,30 @@ by copying its `tokens` into tokens.json; the active kit's app-wide axes
96
97
  is a hairline inside the edge (default 0.5). Declare none and Cards stay flat. To look native on
97
98
  each platform, declare `"card": {}` and let the idiom overlays fill it: `material` a `level`,
98
99
  `ios` a `border` + `shadow`.
100
+
101
+ ## Buttons (`controls.button`)
102
+
103
+ Restyle every `Button` of one size or one style from the design system — no per-screen props:
104
+
105
+ ```json
106
+ "controls": {
107
+ "button": {
108
+ "sm": { "height": 36, "paddingH": 13, "paddingV": 6, "text": "bodyEmphasis", "icon": 14 },
109
+ "variants": { "secondary": { "radius": 4, "borderWidth": 1 }, "primary": { "fill": "brand" } }
110
+ }
111
+ }
112
+ ```
113
+
114
+ - **Per size** (`sm` / `md` / `lg`): `height` (the VISIBLE minimum height), `paddingH`,
115
+ `paddingV`, `text` (a `typography` token), `icon` (glyph size), `radius`.
116
+ - **Per variant** (`variants.<style>`, e.g. `primary`, `secondary`, `tertiary`, `destructive`):
117
+ `fill`, `label`, `border` (colour tokens or `#hex`), `borderWidth` (0 = no outline), `radius`
118
+ (wins over the size's).
119
+ - Every field is optional. An absent field keeps the built-in Button, so a tokens.json without
120
+ `controls` renders exactly as before. `Button.cornerRadius` / `Button.minHeight` on a node
121
+ still win over the tokens.
122
+ - The **44pt tap target stays**: a `height` under 44 gets a transparent hit area above and below
123
+ (web: a hit band, React Native: `hitSlop`, iOS/macOS: a padded content shape, Android: Material
124
+ 3's 48dp touch target), so a 36px `sm` button still passes accessibility.
125
+ - `himi validate` errors when `text` names no `typography` token, or a colour names no
126
+ `colors` token.