@wix/himalaya-cli 0.1243.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/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/iap.mjs +192 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/screens.mjs +3 -0
- package/dist/cli.mjs +2 -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/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
|
@@ -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 };
|
|
@@ -6,11 +6,19 @@
|
|
|
6
6
|
// self-contained (name, cover, description snippet, cheap meta) — no detail
|
|
7
7
|
// screen; enrolling stays a hosted-site flow.
|
|
8
8
|
// Four-state (loading / error / rows / empty) like the sibling branded workers.
|
|
9
|
-
|
|
9
|
+
//
|
|
10
|
+
// App Store prices come from the shared IAP catalog layer (@himalaya/iap). Until a mapping backend
|
|
11
|
+
// is connected it is disabled, and this worker runs exactly its pre-pricing path.
|
|
12
|
+
import { defineService as register } from "@himalaya/state";
|
|
13
|
+
import { decorateCollection } from "../_shared/collection-service.js";
|
|
10
14
|
import { queryPrograms, type WixProgram } from "@himalaya/wix-online-programs";
|
|
15
|
+
import { createIapCatalogPricing, iapPriceFields, onlineProgramItem, type IapPollDirective } from "@himalaya/iap";
|
|
11
16
|
import { wixMediaUrl } from "@himalaya/wix-media";
|
|
12
17
|
import { cleanTitle, initials } from "../_shared/himi-text.js";
|
|
13
18
|
import { copyParam, stashCopy } from "../_shared/himi-copy.js";
|
|
19
|
+
import { hostIapScope, hostScopeComplete, iapPriceCopy, resetUnhydratedKeysOnOlderRuntime } from "../_shared/iap-worker.js";
|
|
20
|
+
|
|
21
|
+
const SERVICE = "himiBrandedPrograms";
|
|
14
22
|
|
|
15
23
|
interface ProgramRow {
|
|
16
24
|
id: string;
|
|
@@ -27,6 +35,15 @@ interface ProgramRow {
|
|
|
27
35
|
noImage: boolean;
|
|
28
36
|
/** Templated in-app program detail — rows tap through (no more dead wall). */
|
|
29
37
|
detailScreenId: string;
|
|
38
|
+
/** This run's App Store price or its loading/unavailable label; "" when pricing is off. The
|
|
39
|
+
* collection card shows it in the caption ahead of `meta`. */
|
|
40
|
+
priceLine: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A row as loaded, before pricing — the half that may be restored from a cold-boot snapshot. */
|
|
44
|
+
interface ProgramContent extends Omit<ProgramRow, "priceLine"> {
|
|
45
|
+
/** The Wix ID, or "" when the row fell back to its title and so cannot be priced. */
|
|
46
|
+
wixId: string;
|
|
30
47
|
}
|
|
31
48
|
|
|
32
49
|
interface State {
|
|
@@ -40,6 +57,13 @@ interface State {
|
|
|
40
57
|
hasRows: boolean;
|
|
41
58
|
noRows: boolean;
|
|
42
59
|
noEmpty: boolean;
|
|
60
|
+
hidePriceRetry: boolean;
|
|
61
|
+
/** Beside the list-level Retry, so it says what failed. */
|
|
62
|
+
priceRetryText: string;
|
|
63
|
+
programs?: ProgramContent[];
|
|
64
|
+
/** Derived from `rows` by the collection decorator. */
|
|
65
|
+
collection?: unknown;
|
|
66
|
+
_poll?: IapPollDirective;
|
|
43
67
|
}
|
|
44
68
|
|
|
45
69
|
/** v3 nests display fields under `description`/`settings`; older shapes are
|
|
@@ -86,7 +110,7 @@ function isDraft(p: WixProgram): boolean {
|
|
|
86
110
|
return state === "DRAFT";
|
|
87
111
|
}
|
|
88
112
|
|
|
89
|
-
function
|
|
113
|
+
function programFields(p: WixProgram): Omit<ProgramRow, "priceLine"> {
|
|
90
114
|
const name = programTitle(p);
|
|
91
115
|
const image = programImage(p);
|
|
92
116
|
const id = p.id || name;
|
|
@@ -109,6 +133,14 @@ function programRow(p: WixProgram): ProgramRow {
|
|
|
109
133
|
};
|
|
110
134
|
}
|
|
111
135
|
|
|
136
|
+
function programRow(p: WixProgram): ProgramRow {
|
|
137
|
+
return { ...programFields(p), priceLine: "" };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function programContent(p: WixProgram): ProgramContent {
|
|
141
|
+
return { ...programFields(p), wixId: p.id ?? "" };
|
|
142
|
+
}
|
|
143
|
+
|
|
112
144
|
function ready(rows: ProgramRow[]): State {
|
|
113
145
|
return {
|
|
114
146
|
status: "ready",
|
|
@@ -121,46 +153,134 @@ function ready(rows: ProgramRow[]): State {
|
|
|
121
153
|
hasRows: rows.length > 0,
|
|
122
154
|
noRows: rows.length === 0,
|
|
123
155
|
noEmpty: rows.length > 0,
|
|
156
|
+
hidePriceRetry: true,
|
|
157
|
+
priceRetryText: "",
|
|
124
158
|
};
|
|
125
159
|
}
|
|
126
160
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
notErrored: true,
|
|
138
|
-
hasRows: false,
|
|
139
|
-
noRows: true,
|
|
161
|
+
// Bridge errors land here — a graceful error card with retry, never a dead
|
|
162
|
+
// screen (the branded four-state contract).
|
|
163
|
+
function errored(state: State): State {
|
|
164
|
+
return {
|
|
165
|
+
...state,
|
|
166
|
+
status: "error",
|
|
167
|
+
isLoading: false,
|
|
168
|
+
notLoading: true,
|
|
169
|
+
isErrored: true,
|
|
170
|
+
notErrored: false,
|
|
140
171
|
noEmpty: true,
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function fetchPrograms(): Promise<WixProgram[]> {
|
|
176
|
+
const response = await queryPrograms({ cursorPaging: { limit: 50 } });
|
|
177
|
+
return (response.programs ?? []).filter((p) => !isDraft(p));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function load(state: State, arg?: unknown): Promise<State> {
|
|
181
|
+
stashCopy(arg);
|
|
182
|
+
try {
|
|
183
|
+
return ready((await fetchPrograms()).map(programRow));
|
|
184
|
+
} catch {
|
|
185
|
+
return errored(state);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── App Store pricing (enabled only when a mapping backend is connected) ─────
|
|
190
|
+
//
|
|
191
|
+
// One writer: `loadPriced` keeps its content here and every state change goes through the
|
|
192
|
+
// synchronous `pricingPublish`, which re-reads the latest content and prices. An async action
|
|
193
|
+
// that returned its own state could land after a newer publication and repaint stale prices.
|
|
194
|
+
|
|
195
|
+
const pricing = createIapCatalogPricing({ onChange: () => { void svc.pricingPublish(); } });
|
|
196
|
+
const PRICING_NOT_HYDRATED = ["rows", "collection", "hidePriceRetry"] as const;
|
|
197
|
+
let content: { status: State["status"]; programs: ProgramContent[] } = { status: "loading", programs: [] };
|
|
198
|
+
let scopeIncomplete = false;
|
|
199
|
+
let loadSeq = 0;
|
|
200
|
+
|
|
201
|
+
async function loadPriced(_state: State, arg?: unknown): Promise<undefined> {
|
|
202
|
+
stashCopy(arg);
|
|
203
|
+
const seq = ++loadSeq;
|
|
204
|
+
const programs = await fetchPrograms().catch(() => undefined);
|
|
205
|
+
if (seq !== loadSeq) return undefined;
|
|
206
|
+
content = programs ? { status: "ready", programs: programs.map(programContent) } : { ...content, status: "error" };
|
|
207
|
+
void svc.pricingPublish();
|
|
208
|
+
if (programs) void priceContent();
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Prices the programs on screen now. Content never waits on this, and the host scope is re-read
|
|
213
|
+
* each time, so a failed app-info read recovers on the next refresh. */
|
|
214
|
+
async function priceContent(): Promise<void> {
|
|
215
|
+
// The Retry action is declared in both modes; with no backend it must not reach the host.
|
|
216
|
+
if (!pricing.enabled) return;
|
|
217
|
+
const host = await hostIapScope();
|
|
218
|
+
scopeIncomplete = !hostScopeComplete(host);
|
|
219
|
+
if (content.status !== "ready") return;
|
|
220
|
+
await pricing.load({ ...host, items: content.programs.flatMap((program) => onlineProgramItem({ id: program.wixId }) ?? []) });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function pricedState(state: State): State {
|
|
224
|
+
const copy = iapPriceCopy("programs");
|
|
225
|
+
const views = content.programs.map((program) => {
|
|
226
|
+
const item = onlineProgramItem({ id: program.wixId });
|
|
227
|
+
return item ? pricing.view(item.reference) : undefined;
|
|
228
|
+
});
|
|
229
|
+
const rows = content.programs.map(({ wixId: _wixId, ...row }, i) =>
|
|
230
|
+
({ ...row, priceLine: views[i] ? iapPriceFields(views[i]!, copy).priceText : "" }));
|
|
231
|
+
const shown = content.status === "ready" ? ready(rows) : content.status === "error" ? errored({ ...state, rows }) : INITIAL_STATE;
|
|
232
|
+
return {
|
|
233
|
+
...state, ...shown, programs: content.programs,
|
|
234
|
+
// Retry re-prices the programs on screen, which only a ready list has.
|
|
235
|
+
hidePriceRetry: content.status !== "ready" || !views.some((view) => view && (scopeIncomplete || view.retryable)),
|
|
236
|
+
priceRetryText: copyParam("programs.priceRetryText", "Some prices could not be loaded."),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const INITIAL_STATE: State = {
|
|
241
|
+
status: "loading",
|
|
242
|
+
rows: [],
|
|
243
|
+
emptyText: "",
|
|
244
|
+
isLoading: true,
|
|
245
|
+
notLoading: false,
|
|
246
|
+
isErrored: false,
|
|
247
|
+
notErrored: true,
|
|
248
|
+
hasRows: false,
|
|
249
|
+
noRows: true,
|
|
250
|
+
noEmpty: true,
|
|
251
|
+
hidePriceRetry: true,
|
|
252
|
+
priceRetryText: "",
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
type Actions = {
|
|
256
|
+
load: (state: State, arg?: unknown) => Promise<State | undefined>;
|
|
257
|
+
pricingPublish: (state: State) => State;
|
|
258
|
+
pricingStart: (state: State) => State;
|
|
259
|
+
pricingRefresh: (state: State) => Promise<undefined>;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const actions: Actions = {
|
|
263
|
+
load: pricing.enabled ? loadPriced : load,
|
|
264
|
+
pricingPublish: pricedState,
|
|
265
|
+
pricingStart: (state) => ({ ...pricedState(state), _poll: pricing.pollDirective(`${SERVICE}.pricingRefresh`) }),
|
|
266
|
+
pricingRefresh: async () => { await priceContent(); return undefined; },
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// The collection decorator's own `defineService` drops `notHydrated`, so the decorated definition
|
|
270
|
+
// is registered directly: this run's prices must never be restored from a cold-boot snapshot.
|
|
271
|
+
const decorated = decorateCollection(SERVICE, { state: INITIAL_STATE, actions } as unknown as Parameters<typeof decorateCollection>[1]);
|
|
272
|
+
const svc = register<State, Actions>(SERVICE, (pricing.enabled
|
|
273
|
+
? { ...decorated, notHydrated: [...PRICING_NOT_HYDRATED] }
|
|
274
|
+
: decorated) as unknown as Parameters<typeof register<State, Actions>>[1]);
|
|
275
|
+
|
|
276
|
+
if (pricing.enabled) {
|
|
277
|
+
const booted = svc.getState();
|
|
278
|
+
if ((booted.status === "ready" || booted.status === "error") && Array.isArray(booted.programs)) {
|
|
279
|
+
content = { status: booted.status, programs: booted.programs };
|
|
280
|
+
}
|
|
281
|
+
resetUnhydratedKeysOnOlderRuntime(svc, decorated.state as unknown as State, PRICING_NOT_HYDRATED);
|
|
282
|
+
void svc.pricingStart();
|
|
283
|
+
}
|
|
164
284
|
|
|
165
285
|
// Kick the first load at module scope, like every other list bundle
|
|
166
286
|
// (himi-store-shop, himi-booking-services, himi-events-list, himi-blog-feed …).
|