@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.
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/catalog-adapters/online-programs.d.ts +6 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/catalog-price-fields.d.ts +4 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/catalog-pricing-contract.d.ts +87 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/catalog-pricing-core.d.ts +10 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/catalog-pricing.d.ts +6 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/index.d.ts +4 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/mapping-source.d.ts +5 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/tokens.d.ts +94 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/checkout/src/index.d.ts +3 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/checkout.mjs +3 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/core.mjs +40 -6
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap.mjs +192 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/screens.mjs +3 -0
- package/dist/cli.mjs +365 -226
- package/dist/preview-assets.generated.bin +0 -0
- package/guides/getting-started.md +6 -0
- package/guides/himi-skill.md +6 -0
- package/guides/iap-pricing.md +162 -211
- package/guides/index.json +7 -6
- package/guides/sdk-reference.json +96 -0
- package/guides/tier-5-add-a-screen.md +9 -0
- package/guides/tokens.md +28 -0
- package/guides/wix-api-usage.md +11 -0
- package/guides/wix-site-to-himi-app.md +1 -1
- package/package.json +2 -2
- package/templates/branded-lite/dev/index.ts +110 -3
- package/templates/branded-lite/tier5-src/_shared/iap-worker.ts +65 -0
- package/templates/branded-lite/tier5-src/himi-branded-lite-program/index.ts +179 -52
- package/templates/branded-lite/tier5-src/himi-branded-lite-programs/index.ts +158 -38
- package/templates/yoga-studio/dev/index.ts +110 -3
- package/templates/yoga-studio/tier5-src/_shared/iap-worker.ts +65 -0
- package/templates/yoga-studio/tier5-src/himi-branded-lite-program/index.ts +179 -52
- package/templates/yoga-studio/tier5-src/himi-branded-lite-programs/index.ts +158 -38
package/guides/wix-api-usage.md
CHANGED
|
@@ -281,6 +281,17 @@ If a record genuinely carries no currency, fall back to a constant your app owns
|
|
|
281
281
|
site-properties read that will 403. An owner-scope app (a dashboard/owner surface holding a `User`
|
|
282
282
|
instance) *can* read the site currency; a consumer/visitor app cannot.
|
|
283
283
|
|
|
284
|
+
### Items that may be sold as App Store in-app purchases
|
|
285
|
+
|
|
286
|
+
A program, product or plan the owner registered with `himi store iap` may have to be sold through
|
|
287
|
+
the App Store instead of your commerce flow. Do not decide that in your worker. Pass the items on
|
|
288
|
+
screen to the shared `createIapCatalogPricing` from `@himalaya/iap` (`himi docs iap-pricing`): it
|
|
289
|
+
looks up each item's route in one batch, fetches Apple's localized price, and returns what to show
|
|
290
|
+
and whether your existing checkout may run. Bind its fields, call `checkoutGuard` right before a
|
|
291
|
+
commerce flow starts and `continueCheckoutGuard` before each later step of it, and declare `iap` and
|
|
292
|
+
`app_info`. While Wix has not connected the mapping service
|
|
293
|
+
it reports `disabled` and your screen behaves exactly as before.
|
|
294
|
+
|
|
284
295
|
## Testing
|
|
285
296
|
|
|
286
297
|
Fake the bridge — stub `globalThis.himalaya.wixApi.call` to return a canned parsed object per `(method, path)` — or use
|
|
@@ -124,7 +124,7 @@ for **behavior** is the website; design authority for **layout** is
|
|
|
124
124
|
|
|
125
125
|
| Website | Mobile |
|
|
126
126
|
|---|---|
|
|
127
|
-
| Vertical (book, shop, events, forum, members, blog) | Template/overlay screen; bind with `himi sdk` + live `@himalaya/wix
|
|
127
|
+
| Vertical (book, shop, events, forum, members, blog) | Template/overlay screen; bind with `himi sdk` + live `@himalaya/wix-*`. Any screen showing a digital item's price (program, product, plan) also uses the shared IAP pricing layer — `himi docs iap-pricing` — whether it came from a template or you wrote it |
|
|
128
128
|
| Custom content / CMS / forms | One T5 screen per *job*; data = adopted CMS (`wixId`) or a form worker (`himi docs recipe-form-submit`) |
|
|
129
129
|
| Velo page modules | Briefing only: field names, collection ids, submit flow. **Rewrite** as a worker. Never copy Velo JS. Site HTTP functions (`/_functions/`) are a separate API: `himi docs wix-site-http-functions` / `himi site function` — analyze never invokes them |
|
|
130
130
|
| Lightboxes, popups, multi-column editors | Push screen or sheet — not a ported lightbox |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wix/himalaya-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1244.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
|
|
6
6
|
"keywords": [
|
|
@@ -67,5 +67,5 @@
|
|
|
67
67
|
"artifactId": "himalaya-cli"
|
|
68
68
|
}
|
|
69
69
|
},
|
|
70
|
-
"falconPackageHash": "
|
|
70
|
+
"falconPackageHash": "724b558d0195cae181956a174fc05420e13df2e99fb9a25e2f69b016"
|
|
71
71
|
}
|
|
@@ -22173,6 +22173,65 @@ const SCREENS = runtimeSetting({
|
|
|
22173
22173
|
"hiddenPath": "$service:himiBrandedPrograms.notErrored"
|
|
22174
22174
|
}
|
|
22175
22175
|
},
|
|
22176
|
+
{
|
|
22177
|
+
"id": "himi_programs.price_retry_card",
|
|
22178
|
+
"body": {
|
|
22179
|
+
"package": "himalaya",
|
|
22180
|
+
"type": "VStack",
|
|
22181
|
+
"props": {
|
|
22182
|
+
"spacing": 8,
|
|
22183
|
+
"alignment": "leading",
|
|
22184
|
+
"children": [
|
|
22185
|
+
{
|
|
22186
|
+
"id": "himi_programs.price_retry_text",
|
|
22187
|
+
"body": {
|
|
22188
|
+
"package": "himalaya",
|
|
22189
|
+
"type": "Text",
|
|
22190
|
+
"props": {
|
|
22191
|
+
"text": "$service:himiBrandedPrograms.priceRetryText",
|
|
22192
|
+
"style": "footnote",
|
|
22193
|
+
"color": "textSecondary",
|
|
22194
|
+
"collapseWhenEmpty": true
|
|
22195
|
+
}
|
|
22196
|
+
}
|
|
22197
|
+
},
|
|
22198
|
+
{
|
|
22199
|
+
"id": "himi_programs.price_retry",
|
|
22200
|
+
"body": {
|
|
22201
|
+
"package": "himalaya",
|
|
22202
|
+
"type": "Button",
|
|
22203
|
+
"props": {
|
|
22204
|
+
"title": "Try again",
|
|
22205
|
+
"style": "secondary",
|
|
22206
|
+
"action": {
|
|
22207
|
+
"actionId": "tier5.himiBrandedPrograms.pricingRefresh"
|
|
22208
|
+
}
|
|
22209
|
+
}
|
|
22210
|
+
}
|
|
22211
|
+
}
|
|
22212
|
+
]
|
|
22213
|
+
}
|
|
22214
|
+
},
|
|
22215
|
+
"modifiers": {
|
|
22216
|
+
"background": {
|
|
22217
|
+
"token": "surfaceElevated"
|
|
22218
|
+
},
|
|
22219
|
+
"border": {
|
|
22220
|
+
"width": 1,
|
|
22221
|
+
"color": {
|
|
22222
|
+
"token": "border"
|
|
22223
|
+
}
|
|
22224
|
+
},
|
|
22225
|
+
"cornerRadius": 16,
|
|
22226
|
+
"padding": {
|
|
22227
|
+
"leading": 16,
|
|
22228
|
+
"trailing": 16,
|
|
22229
|
+
"top": 16,
|
|
22230
|
+
"bottom": 16
|
|
22231
|
+
},
|
|
22232
|
+
"hiddenPath": "$service:himiBrandedPrograms.hidePriceRetry"
|
|
22233
|
+
}
|
|
22234
|
+
},
|
|
22176
22235
|
{
|
|
22177
22236
|
"id": "himi_programs.rows.collection.toolbar",
|
|
22178
22237
|
"body": {
|
|
@@ -24525,7 +24584,9 @@ const SCREENS = runtimeSetting({
|
|
|
24525
24584
|
"integrity": "",
|
|
24526
24585
|
"contentType": "application/javascript+ts-bundle",
|
|
24527
24586
|
"declaredCapabilities": [
|
|
24528
|
-
"wix_api"
|
|
24587
|
+
"wix_api",
|
|
24588
|
+
"iap",
|
|
24589
|
+
"app_info"
|
|
24529
24590
|
],
|
|
24530
24591
|
"minBinaryVersion": "0.1.0",
|
|
24531
24592
|
"bundleVersion": "0.0.1"
|
|
@@ -84141,7 +84202,7 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
|
|
|
84141
84202
|
}
|
|
84142
84203
|
},
|
|
84143
84204
|
{
|
|
84144
|
-
"id": "",
|
|
84205
|
+
"id": "himi_program.price",
|
|
84145
84206
|
"body": {
|
|
84146
84207
|
"package": "himalaya",
|
|
84147
84208
|
"type": "Text",
|
|
@@ -84153,6 +84214,36 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
|
|
|
84153
84214
|
}
|
|
84154
84215
|
}
|
|
84155
84216
|
},
|
|
84217
|
+
{
|
|
84218
|
+
"id": "himi_program.price_note",
|
|
84219
|
+
"body": {
|
|
84220
|
+
"package": "himalaya",
|
|
84221
|
+
"type": "Text",
|
|
84222
|
+
"props": {
|
|
84223
|
+
"text": "$service:himiBrandedProgram.priceNote",
|
|
84224
|
+
"style": "caption",
|
|
84225
|
+
"color": "textSecondary",
|
|
84226
|
+
"collapseWhenEmpty": true
|
|
84227
|
+
}
|
|
84228
|
+
}
|
|
84229
|
+
},
|
|
84230
|
+
{
|
|
84231
|
+
"id": "himi_program.price_retry",
|
|
84232
|
+
"body": {
|
|
84233
|
+
"package": "himalaya",
|
|
84234
|
+
"type": "Button",
|
|
84235
|
+
"props": {
|
|
84236
|
+
"title": "Try again",
|
|
84237
|
+
"style": "secondary",
|
|
84238
|
+
"action": {
|
|
84239
|
+
"actionId": "tier5.himiBrandedProgram.pricingRefresh"
|
|
84240
|
+
}
|
|
84241
|
+
}
|
|
84242
|
+
},
|
|
84243
|
+
"modifiers": {
|
|
84244
|
+
"hiddenPath": "$service:himiBrandedProgram.hidePriceRetry"
|
|
84245
|
+
}
|
|
84246
|
+
},
|
|
84156
84247
|
{
|
|
84157
84248
|
"id": "",
|
|
84158
84249
|
"body": {
|
|
@@ -84207,12 +84298,26 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
|
|
|
84207
84298
|
"title": "Enrol",
|
|
84208
84299
|
"style": "primary",
|
|
84209
84300
|
"fullWidth": true,
|
|
84301
|
+
"disabledPath": "$service:himiBrandedProgram.enrollDisabled",
|
|
84210
84302
|
"action": {
|
|
84211
84303
|
"actionId": "tier5.himiBrandedProgram.enroll"
|
|
84212
84304
|
}
|
|
84213
84305
|
}
|
|
84214
84306
|
}
|
|
84215
84307
|
},
|
|
84308
|
+
{
|
|
84309
|
+
"id": "himi_program.enrol_note",
|
|
84310
|
+
"body": {
|
|
84311
|
+
"package": "himalaya",
|
|
84312
|
+
"type": "Text",
|
|
84313
|
+
"props": {
|
|
84314
|
+
"text": "$service:himiBrandedProgram.enrollNote",
|
|
84315
|
+
"style": "footnote",
|
|
84316
|
+
"color": "textSecondary",
|
|
84317
|
+
"collapseWhenEmpty": true
|
|
84318
|
+
}
|
|
84319
|
+
}
|
|
84320
|
+
},
|
|
84216
84321
|
{
|
|
84217
84322
|
"id": "himi_program.action_message",
|
|
84218
84323
|
"body": {
|
|
@@ -84352,7 +84457,9 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
|
|
|
84352
84457
|
"wix_api",
|
|
84353
84458
|
"wix_cashier",
|
|
84354
84459
|
"open_url",
|
|
84355
|
-
"wix_platform"
|
|
84460
|
+
"wix_platform",
|
|
84461
|
+
"iap",
|
|
84462
|
+
"app_info"
|
|
84356
84463
|
],
|
|
84357
84464
|
"minBinaryVersion": "0.1.0",
|
|
84358
84465
|
"bundleVersion": "0.0.1"
|
|
@@ -0,0 +1,65 @@
|
|
|
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";
|
|
6
|
+
import { copyParam } from "./himi-copy.js";
|
|
7
|
+
|
|
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
|
+
/** Price labels from the screen's copy pack (`<prefix>.priceLoadingText`, …). */
|
|
48
|
+
export function iapPriceCopy(prefix: string): IapPriceCopy {
|
|
49
|
+
return {
|
|
50
|
+
loading: copyParam(`${prefix}.priceLoadingText`, "Loading price…"),
|
|
51
|
+
unavailable: copyParam(`${prefix}.priceUnavailableText`, "Price unavailable"),
|
|
52
|
+
oneTimePurchase: copyParam(`${prefix}.oneTimePurchaseLabel`, "One-time purchase"),
|
|
53
|
+
purchaseUnavailable: copyParam(`${prefix}.purchaseUnavailableText`, "Purchasing is not available yet."),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What `notHydrated` does, for a runtime that ignores the option (a binary older than it, still
|
|
58
|
+
* served new bundles): put the listed keys back to their declared values before any action runs,
|
|
59
|
+
* so a persisted price or an enabled purchase action never paints as this run's. */
|
|
60
|
+
export function resetUnhydratedKeysOnOlderRuntime<S extends object>(svc: { getState(): S }, declared: S, keys: readonly (keyof S & string)[]): void {
|
|
61
|
+
if ((globalThis as { himalaya?: { hydrationOptOut?: unknown } }).himalaya?.hydrationOptOut === true) return;
|
|
62
|
+
const booted = svc.getState();
|
|
63
|
+
if (!booted || typeof booted !== "object") return;
|
|
64
|
+
for (const key of keys) booted[key] = declared[key];
|
|
65
|
+
}
|
|
@@ -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
|
-
|
|
158
|
-
return ready(state, response.program);
|
|
180
|
+
return ready(state, await fetchProgram(inputId()));
|
|
159
181
|
} catch {
|
|
160
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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(
|
|
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
|
|
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
|
|
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 (
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (pay.kind === "
|
|
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
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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 };
|