@wix/himalaya-cli 0.1352.0 → 0.1353.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 (27) hide show
  1. package/package.json +2 -2
  2. package/templates/beauty-salon/tier5-src/himi-branded-lite-plans/index.ts +17 -0
  3. package/templates/beauty-salon/tier5-src/wix-spaces-plan-detail/index.ts +30 -4
  4. package/templates/branded-lite/tier5-src/himi-branded-lite-plans/index.ts +17 -0
  5. package/templates/branded-lite/tier5-src/himi-events-detail/index.ts +17 -3
  6. package/templates/branded-lite/tier5-src/wix-spaces-plan-detail/index.ts +30 -4
  7. package/templates/community-club/tier5-src/_shared/hosted-checkout.ts +42 -0
  8. package/templates/community-club/tier5-src/himi-events-detail/index.ts +17 -3
  9. package/templates/events-meetups/tier5-src/_shared/hosted-checkout.ts +42 -0
  10. package/templates/events-meetups/tier5-src/himi-events-detail/index.ts +17 -3
  11. package/templates/gym-fitness/tier5-src/himi-branded-lite-plans/index.ts +17 -0
  12. package/templates/gym-fitness/tier5-src/himi-events-detail/index.ts +17 -3
  13. package/templates/gym-fitness/tier5-src/wix-spaces-plan-detail/index.ts +30 -4
  14. package/templates/nonprofit-cause/tier5-src/_shared/hosted-checkout.ts +42 -0
  15. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-plans/index.ts +17 -0
  16. package/templates/nonprofit-cause/tier5-src/himi-events-detail/index.ts +17 -3
  17. package/templates/nonprofit-cause/tier5-src/wix-spaces-plan-detail/index.ts +30 -4
  18. package/templates/podcast-creator/tier5-src/_shared/hosted-checkout.ts +42 -0
  19. package/templates/podcast-creator/tier5-src/himi-branded-lite-plans/index.ts +17 -0
  20. package/templates/podcast-creator/tier5-src/himi-events-detail/index.ts +17 -3
  21. package/templates/podcast-creator/tier5-src/wix-spaces-plan-detail/index.ts +30 -4
  22. package/templates/portfolio-creative/tier5-src/himi-events-detail/index.ts +17 -3
  23. package/templates/restaurant/tier5-src/_shared/hosted-checkout.ts +42 -0
  24. package/templates/restaurant/tier5-src/himi-events-detail/index.ts +17 -3
  25. package/templates/yoga-studio/tier5-src/himi-branded-lite-plans/index.ts +17 -0
  26. package/templates/yoga-studio/tier5-src/himi-events-detail/index.ts +17 -3
  27. package/templates/yoga-studio/tier5-src/wix-spaces-plan-detail/index.ts +30 -4
@@ -0,0 +1,42 @@
1
+ // Hosted checkout (#4089 B3). A Branded Lite hosted in a host shell (Spaces)
2
+ // pays as the member the host lent it: Headless `createRedirectSession` does not take a Member
3
+ // instance, so the HOST opens the site's checkout signed in as that member. The worker names only
4
+ // its ecom checkout; the host reads Wix's checkout URL, mints a one-time member session and opens
5
+ // the site's `createSessionCookie` → checkout. The session token never reaches this worker.
6
+
7
+ import { dispatch } from "@himalaya/dispatch";
8
+ import { hostSiteFromInput } from "./branded-lite-hosted.js";
9
+
10
+ /** The host verbs (registered by the host shell, never by the app itself). The first prepares the
11
+ * checkout and answers a one-time ticket; the second opens it. Two steps so the caller's charge guard
12
+ * is re-read after the host's network round trips and right before the checkout opens — the prepared
13
+ * URL (which carries the member's session token) stays in the host; only the ticket crosses here. */
14
+ export const HOSTED_CHECKOUT_ACTION = "himi.host.memberCheckout";
15
+ export const HOSTED_CHECKOUT_OPEN_ACTION = "himi.host.memberCheckout.open";
16
+
17
+ /** Whether this worker runs inside a host that opened a site for it. */
18
+ export function isHostedWorker(): boolean {
19
+ return hostSiteFromInput((globalThis as { himalaya?: { input?: unknown } }).himalaya?.input) !== null;
20
+ }
21
+
22
+ type HostAnswer = { opened?: unknown; ticket?: unknown; payload?: { opened?: unknown; ticket?: unknown } } | null;
23
+
24
+ /** `payCashierOrder`'s `openCashier` for a hosted app: the host prepares [checkoutId]'s checkout as the
25
+ * lent member, the caller's guard is re-read, and only then does the host open it. Resolves whether it
26
+ * opened ("withdrawn" when the guard said no); a host without the verbs (or that refused) → false. */
27
+ export function hostedCheckoutOpener(checkoutId: string): (stillMayCharge: () => Promise<boolean>) => Promise<boolean | "withdrawn"> {
28
+ return async (stillMayCharge) => {
29
+ try {
30
+ const prepared = await dispatch(HOSTED_CHECKOUT_ACTION, { checkoutId }) as HostAnswer;
31
+ const ticket = prepared?.ticket ?? prepared?.payload?.ticket;
32
+ if (typeof ticket !== "string" || !ticket) return false;
33
+ // An unopened ticket simply expires in the host: nothing to cancel.
34
+ if (!(await stillMayCharge())) return "withdrawn";
35
+ const result = await dispatch(HOSTED_CHECKOUT_OPEN_ACTION, { ticket }) as HostAnswer;
36
+ const opened = result?.opened ?? result?.payload?.opened;
37
+ return opened === true || opened === "true";
38
+ } catch {
39
+ return false;
40
+ }
41
+ };
42
+ }
@@ -9,6 +9,7 @@ import { hostSessionFromGlobal, iapPriceFields, type IapCatalogItem } from "@him
9
9
  import { createIapCommerce, pricingPlanOrders } from "@himalaya/iap-checkout";
10
10
  import { iapPriceCopy, iapPurchaseCopy, resetUnhydratedKeysOnOlderRuntime } from "../_shared/iap-worker.js";
11
11
  import { formatCurrency } from "../_shared/engine-intl.js";
12
+ import { isHostedWorker } from "../_shared/hosted-checkout.js";
12
13
 
13
14
  /** Wix Pricing Plans appDefId — the vertical that owns a plan order, so the `appId` its
14
15
  * cashier order is paid under (mirrors AppDefId.PRICING_PLANS in Wix's RN apps). */
@@ -49,6 +50,8 @@ interface PlanRow {
49
50
  hasBadge: boolean;
50
51
  /** Purchasable URL when the plan data carries one; "" otherwise. */
51
52
  joinUrl: string;
53
+ /** No price (or a zero price): an online order activates it at once, with nothing to pay. */
54
+ free: boolean;
52
55
  joined: boolean;
53
56
  appleSupported: boolean;
54
57
  joinLabel: string;
@@ -117,6 +120,11 @@ function joinUrl(plan: WixPlan): string {
117
120
  return "";
118
121
  }
119
122
 
123
+ function isFreePlan(plan: WixPlan): boolean {
124
+ const raw = plan.pricing?.price?.value;
125
+ return raw == null || raw === "" || Number(raw) === 0;
126
+ }
127
+
120
128
  function planRow(plan: WixPlan): PlanRow {
121
129
  const name = (plan.name || copyParam("plans.planFallbackName", "Plan")).trim();
122
130
  const perks = (plan.perks?.values ?? [])
@@ -142,6 +150,7 @@ function planRow(plan: WixPlan): PlanRow {
142
150
  noBadge: !badge,
143
151
  hasBadge: !!badge,
144
152
  joinUrl: joinUrl(plan),
153
+ free: isFreePlan(plan),
145
154
  appleSupported: plan.pricing?.singlePaymentUnlimited === true,
146
155
  joined: false, joinLabel: copyParam("plans.joinCta", "Join"), joinDisabled: true, joinNote: "",
147
156
  };
@@ -215,6 +224,14 @@ async function regularJoinMessage(row: PlanRow, reference: { kind: string; id: s
215
224
  } catch {
216
225
  }
217
226
  }
227
+ // Hosted inside a host shell (Spaces) a plan cannot be paid in-app: classic Pricing Plans is not on
228
+ // ecom, so the host's member checkout has no checkout to open, and the redirect session needs a
229
+ // headless OAuth client a hosted app does not have. An online order created here would only be
230
+ // left behind as a DRAFT, so none is — the member joins on the site. A FREE plan has nothing to
231
+ // pay: its online order activates at once, so it still joins in-app.
232
+ if (isHostedWorker() && !row.free) {
233
+ return copyTemplate("plans.joinOnSiteMessage", "Memberships are purchased on the website — visit the site to join {name}.", { name: row.name });
234
+ }
218
235
  try {
219
236
  const created = await createOnlineOrder(row.id);
220
237
  const snapshotId = created.purchaseFlowId;
@@ -13,6 +13,7 @@ import {
13
13
  queryAvailableTickets,
14
14
  queryEvents,
15
15
  type WixAvailableTicket,
16
+ type WixEventFieldset,
16
17
  type WixEvent,
17
18
  type WixGuestRsvp,
18
19
  type WixGuestTicket,
@@ -34,6 +35,7 @@ import {
34
35
  type EventRow,
35
36
  } from "../_shared/himi-events";
36
37
  import { initials } from "../_shared/himi-text.js";
38
+ import { isHostedWorker } from "../_shared/hosted-checkout.js";
37
39
  import { stashCopy, copyParam, copyTemplate } from "../_shared/himi-copy.js";
38
40
  import { openRichLink } from "../_shared/rich-content-link.js";
39
41
  import { richBody } from "../_shared/rich-body.js";
@@ -281,6 +283,10 @@ function calendarFallbackUrl(s: S, end: number): string {
281
283
  return `https://calendar.google.com/calendar/render?${parts.join("&")}`;
282
284
  }
283
285
 
286
+ /** The Events v3 fieldsets the detail reads (see `WixEventFieldset`). DETAILS carries mainImage and
287
+ * shortDescription: REGISTRATION fills eventType, which skips the query enrichment that used to supply them. */
288
+ export const EVENT_DETAIL_FIELDSETS: WixEventFieldset[] = ["DETAILS", "TEXTS", "REGISTRATION", "URLS"];
289
+
284
290
  /** Map the decoded registration/eventType onto the CTA variant. The decoded
285
291
  * `eventType` already folds registration.type / registration.initialType /
286
292
  * the legacy flat `type` (decodeEvent, #478). Unknown ⇒ RSVP (historical). */
@@ -458,8 +464,11 @@ const svc = defineService<S, {
458
464
  const id = s.id || inputId();
459
465
  if (!id) return derive({ ...s, status: "ready", errored: true });
460
466
  try {
461
- // TEXTS adds the Ricos `description` to the base fieldset (a bare read returns none).
462
- const res = await getEvent(id, { fields: ["TEXTS"] });
467
+ // TEXTS adds the Ricos `description` to the base fieldset (a bare read returns none);
468
+ // REGISTRATION adds `registration` (the CTA's type, and an external event's own URL);
469
+ // URLS adds `eventPageUrl` — the registration fallback and the share link. Without URLS a
470
+ // ticket flow that cannot complete in-app had no page to send the buyer to.
471
+ const res = await getEvent(id, { fields: EVENT_DETAIL_FIELDSETS });
463
472
  let e = ((res as unknown as { event?: WixEvent }).event ?? res) as WixEvent;
464
473
  e = await enrichRegistration(id, e);
465
474
  const attendees = await attendeeSummary(id);
@@ -566,7 +575,12 @@ const svc = defineService<S, {
566
575
  // Every failure degrades one rung at a time, never to a dead button:
567
576
  // native chain → hosted eventsCheckout (pinned to the SAME reservation
568
577
  // when we got one, else the slug) → the event page / registration URL.
569
- if (s.registrationKind === "tickets" && s.id) {
578
+ // Hosted inside a host shell (Spaces) neither in-app rung can finish: the classic
579
+ // `checkoutReservation` needs a buyer + guest form this screen does not collect, and the
580
+ // redirect session needs a headless OAuth client a hosted app does not have. Skip both —
581
+ // the native chain would also leave a ticket reservation held for nothing — and open the
582
+ // event's own page, where the member registers on the site.
583
+ if (s.registrationKind === "tickets" && s.id && !isHostedWorker()) {
570
584
  let reservationId = "";
571
585
  try {
572
586
  // Buy what the buyer PICKED. `load` seeds the pick with the cheapest
@@ -1,7 +1,13 @@
1
1
  // Tier-5 bundle: Wix Spaces clone — Pricing plan detail (ON-DEVICE, site-scoped).
2
- // himalaya.input.id carries the plan id; fetched via getPlan.
2
+ // himalaya.input.id carries the plan id.
3
+ //
4
+ // Read from the PUBLIC plans (GET /pricing-plans/v2/plans/public — what the plans list
5
+ // shows), not from getPlan: GET /pricing-plans/v2/plans/{id} is the management read, and a
6
+ // hosted site's member or visitor gets a 403 from it, which rendered "couldn't be loaded"
7
+ // for every plan the list had just shown. getPlan stays as the fallback for a plan that is
8
+ // not public, for a caller that may read it (an owner-scope host).
3
9
  import { withIapPresentation } from "../_shared/iap-presentation.js";
4
- import { getPlan, type WixPlan } from "@himalaya/wix-pricing-plans";
10
+ import { getPlan, listPublicPlans, type WixPlan } from "@himalaya/wix-pricing-plans";
5
11
  import { presence, presenceAfterError, detailInputId, type PresenceFlags } from "../_shared/detail-presence.js";
6
12
 
7
13
  interface S extends PresenceFlags { status: "loading" | "ready"; name: string; price: string; description: string; perks: string }
@@ -12,6 +18,26 @@ function priceOf(p: WixPlan): string {
12
18
  return m.formattedAmount || [m.amount ?? m.value, m.currency].filter(Boolean).join(" ");
13
19
  }
14
20
 
21
+ /** The plan, or null when it is not public and the caller may not read it otherwise. */
22
+ async function readPlan(id: string): Promise<WixPlan | null> {
23
+ let publicRead = false;
24
+ try {
25
+ const listed = await listPublicPlans();
26
+ publicRead = true;
27
+ const hit = (listed?.plans ?? []).find((plan) => plan?.id === id);
28
+ if (hit) return hit;
29
+ } catch (e) { void e; }
30
+ try {
31
+ const res = await getPlan(id);
32
+ return ((res as unknown as { plan?: WixPlan }).plan) ?? (res as unknown as WixPlan);
33
+ } catch (e) {
34
+ // The public read worked, so the device is online: a refused or empty management
35
+ // read means this plan is not one the viewer can see — not-found, not offline.
36
+ if (publicRead) return null;
37
+ throw e;
38
+ }
39
+ }
40
+
15
41
  const defineService = withIapPresentation<S>({
16
42
  prefix: "plans", transientKeys: ["price"], contentActions: ["load"],
17
43
  items: state => detailInputId() ? [{ reference: { kind: "pricingPlan", id: detailInputId() }, commerceDisplayPrice: state.price }] : [],
@@ -24,8 +50,8 @@ const svc = defineService<S, { load: (s: S) => Promise<S> }>("spacesPlanDetail",
24
50
  const id = detailInputId();
25
51
  if (!id) return { ...s, status: "ready", ...presence("missing") };
26
52
  try {
27
- const res = await getPlan(id);
28
- const p = ((res as unknown as { plan?: WixPlan }).plan) ?? (res as unknown as WixPlan);
53
+ const p = await readPlan(id);
54
+ if (!p) return { ...s, status: "ready", ...presence("missing") };
29
55
  const perks = (p.perks?.values ?? []).map((v) => `• ${v}`).join("\n");
30
56
  return { status: "ready", name: p.name || "Plan", price: priceOf(p), description: p.description || "", perks, ...presence("found") };
31
57
  } catch (e) { return { ...s, status: "ready", ...presenceAfterError(e) }; }
@@ -0,0 +1,42 @@
1
+ // Hosted checkout (#4089 B3). A Branded Lite hosted in a host shell (Spaces)
2
+ // pays as the member the host lent it: Headless `createRedirectSession` does not take a Member
3
+ // instance, so the HOST opens the site's checkout signed in as that member. The worker names only
4
+ // its ecom checkout; the host reads Wix's checkout URL, mints a one-time member session and opens
5
+ // the site's `createSessionCookie` → checkout. The session token never reaches this worker.
6
+
7
+ import { dispatch } from "@himalaya/dispatch";
8
+ import { hostSiteFromInput } from "./branded-lite-hosted.js";
9
+
10
+ /** The host verbs (registered by the host shell, never by the app itself). The first prepares the
11
+ * checkout and answers a one-time ticket; the second opens it. Two steps so the caller's charge guard
12
+ * is re-read after the host's network round trips and right before the checkout opens — the prepared
13
+ * URL (which carries the member's session token) stays in the host; only the ticket crosses here. */
14
+ export const HOSTED_CHECKOUT_ACTION = "himi.host.memberCheckout";
15
+ export const HOSTED_CHECKOUT_OPEN_ACTION = "himi.host.memberCheckout.open";
16
+
17
+ /** Whether this worker runs inside a host that opened a site for it. */
18
+ export function isHostedWorker(): boolean {
19
+ return hostSiteFromInput((globalThis as { himalaya?: { input?: unknown } }).himalaya?.input) !== null;
20
+ }
21
+
22
+ type HostAnswer = { opened?: unknown; ticket?: unknown; payload?: { opened?: unknown; ticket?: unknown } } | null;
23
+
24
+ /** `payCashierOrder`'s `openCashier` for a hosted app: the host prepares [checkoutId]'s checkout as the
25
+ * lent member, the caller's guard is re-read, and only then does the host open it. Resolves whether it
26
+ * opened ("withdrawn" when the guard said no); a host without the verbs (or that refused) → false. */
27
+ export function hostedCheckoutOpener(checkoutId: string): (stillMayCharge: () => Promise<boolean>) => Promise<boolean | "withdrawn"> {
28
+ return async (stillMayCharge) => {
29
+ try {
30
+ const prepared = await dispatch(HOSTED_CHECKOUT_ACTION, { checkoutId }) as HostAnswer;
31
+ const ticket = prepared?.ticket ?? prepared?.payload?.ticket;
32
+ if (typeof ticket !== "string" || !ticket) return false;
33
+ // An unopened ticket simply expires in the host: nothing to cancel.
34
+ if (!(await stillMayCharge())) return "withdrawn";
35
+ const result = await dispatch(HOSTED_CHECKOUT_OPEN_ACTION, { ticket }) as HostAnswer;
36
+ const opened = result?.opened ?? result?.payload?.opened;
37
+ return opened === true || opened === "true";
38
+ } catch {
39
+ return false;
40
+ }
41
+ };
42
+ }
@@ -9,6 +9,7 @@ import { hostSessionFromGlobal, iapPriceFields, type IapCatalogItem } from "@him
9
9
  import { createIapCommerce, pricingPlanOrders } from "@himalaya/iap-checkout";
10
10
  import { iapPriceCopy, iapPurchaseCopy, resetUnhydratedKeysOnOlderRuntime } from "../_shared/iap-worker.js";
11
11
  import { formatCurrency } from "../_shared/engine-intl.js";
12
+ import { isHostedWorker } from "../_shared/hosted-checkout.js";
12
13
 
13
14
  /** Wix Pricing Plans appDefId — the vertical that owns a plan order, so the `appId` its
14
15
  * cashier order is paid under (mirrors AppDefId.PRICING_PLANS in Wix's RN apps). */
@@ -49,6 +50,8 @@ interface PlanRow {
49
50
  hasBadge: boolean;
50
51
  /** Purchasable URL when the plan data carries one; "" otherwise. */
51
52
  joinUrl: string;
53
+ /** No price (or a zero price): an online order activates it at once, with nothing to pay. */
54
+ free: boolean;
52
55
  joined: boolean;
53
56
  appleSupported: boolean;
54
57
  joinLabel: string;
@@ -117,6 +120,11 @@ function joinUrl(plan: WixPlan): string {
117
120
  return "";
118
121
  }
119
122
 
123
+ function isFreePlan(plan: WixPlan): boolean {
124
+ const raw = plan.pricing?.price?.value;
125
+ return raw == null || raw === "" || Number(raw) === 0;
126
+ }
127
+
120
128
  function planRow(plan: WixPlan): PlanRow {
121
129
  const name = (plan.name || copyParam("plans.planFallbackName", "Plan")).trim();
122
130
  const perks = (plan.perks?.values ?? [])
@@ -142,6 +150,7 @@ function planRow(plan: WixPlan): PlanRow {
142
150
  noBadge: !badge,
143
151
  hasBadge: !!badge,
144
152
  joinUrl: joinUrl(plan),
153
+ free: isFreePlan(plan),
145
154
  appleSupported: plan.pricing?.singlePaymentUnlimited === true,
146
155
  joined: false, joinLabel: copyParam("plans.joinCta", "Join"), joinDisabled: true, joinNote: "",
147
156
  };
@@ -215,6 +224,14 @@ async function regularJoinMessage(row: PlanRow, reference: { kind: string; id: s
215
224
  } catch {
216
225
  }
217
226
  }
227
+ // Hosted inside a host shell (Spaces) a plan cannot be paid in-app: classic Pricing Plans is not on
228
+ // ecom, so the host's member checkout has no checkout to open, and the redirect session needs a
229
+ // headless OAuth client a hosted app does not have. An online order created here would only be
230
+ // left behind as a DRAFT, so none is — the member joins on the site. A FREE plan has nothing to
231
+ // pay: its online order activates at once, so it still joins in-app.
232
+ if (isHostedWorker() && !row.free) {
233
+ return copyTemplate("plans.joinOnSiteMessage", "Memberships are purchased on the website — visit the site to join {name}.", { name: row.name });
234
+ }
218
235
  try {
219
236
  const created = await createOnlineOrder(row.id);
220
237
  const snapshotId = created.purchaseFlowId;
@@ -13,6 +13,7 @@ import {
13
13
  queryAvailableTickets,
14
14
  queryEvents,
15
15
  type WixAvailableTicket,
16
+ type WixEventFieldset,
16
17
  type WixEvent,
17
18
  type WixGuestRsvp,
18
19
  type WixGuestTicket,
@@ -34,6 +35,7 @@ import {
34
35
  type EventRow,
35
36
  } from "../_shared/himi-events";
36
37
  import { initials } from "../_shared/himi-text.js";
38
+ import { isHostedWorker } from "../_shared/hosted-checkout.js";
37
39
  import { stashCopy, copyParam, copyTemplate } from "../_shared/himi-copy.js";
38
40
  import { openRichLink } from "../_shared/rich-content-link.js";
39
41
  import { richBody } from "../_shared/rich-body.js";
@@ -281,6 +283,10 @@ function calendarFallbackUrl(s: S, end: number): string {
281
283
  return `https://calendar.google.com/calendar/render?${parts.join("&")}`;
282
284
  }
283
285
 
286
+ /** The Events v3 fieldsets the detail reads (see `WixEventFieldset`). DETAILS carries mainImage and
287
+ * shortDescription: REGISTRATION fills eventType, which skips the query enrichment that used to supply them. */
288
+ export const EVENT_DETAIL_FIELDSETS: WixEventFieldset[] = ["DETAILS", "TEXTS", "REGISTRATION", "URLS"];
289
+
284
290
  /** Map the decoded registration/eventType onto the CTA variant. The decoded
285
291
  * `eventType` already folds registration.type / registration.initialType /
286
292
  * the legacy flat `type` (decodeEvent, #478). Unknown ⇒ RSVP (historical). */
@@ -458,8 +464,11 @@ const svc = defineService<S, {
458
464
  const id = s.id || inputId();
459
465
  if (!id) return derive({ ...s, status: "ready", errored: true });
460
466
  try {
461
- // TEXTS adds the Ricos `description` to the base fieldset (a bare read returns none).
462
- const res = await getEvent(id, { fields: ["TEXTS"] });
467
+ // TEXTS adds the Ricos `description` to the base fieldset (a bare read returns none);
468
+ // REGISTRATION adds `registration` (the CTA's type, and an external event's own URL);
469
+ // URLS adds `eventPageUrl` — the registration fallback and the share link. Without URLS a
470
+ // ticket flow that cannot complete in-app had no page to send the buyer to.
471
+ const res = await getEvent(id, { fields: EVENT_DETAIL_FIELDSETS });
463
472
  let e = ((res as unknown as { event?: WixEvent }).event ?? res) as WixEvent;
464
473
  e = await enrichRegistration(id, e);
465
474
  const attendees = await attendeeSummary(id);
@@ -566,7 +575,12 @@ const svc = defineService<S, {
566
575
  // Every failure degrades one rung at a time, never to a dead button:
567
576
  // native chain → hosted eventsCheckout (pinned to the SAME reservation
568
577
  // when we got one, else the slug) → the event page / registration URL.
569
- if (s.registrationKind === "tickets" && s.id) {
578
+ // Hosted inside a host shell (Spaces) neither in-app rung can finish: the classic
579
+ // `checkoutReservation` needs a buyer + guest form this screen does not collect, and the
580
+ // redirect session needs a headless OAuth client a hosted app does not have. Skip both —
581
+ // the native chain would also leave a ticket reservation held for nothing — and open the
582
+ // event's own page, where the member registers on the site.
583
+ if (s.registrationKind === "tickets" && s.id && !isHostedWorker()) {
570
584
  let reservationId = "";
571
585
  try {
572
586
  // Buy what the buyer PICKED. `load` seeds the pick with the cheapest
@@ -1,7 +1,13 @@
1
1
  // Tier-5 bundle: Wix Spaces clone — Pricing plan detail (ON-DEVICE, site-scoped).
2
- // himalaya.input.id carries the plan id; fetched via getPlan.
2
+ // himalaya.input.id carries the plan id.
3
+ //
4
+ // Read from the PUBLIC plans (GET /pricing-plans/v2/plans/public — what the plans list
5
+ // shows), not from getPlan: GET /pricing-plans/v2/plans/{id} is the management read, and a
6
+ // hosted site's member or visitor gets a 403 from it, which rendered "couldn't be loaded"
7
+ // for every plan the list had just shown. getPlan stays as the fallback for a plan that is
8
+ // not public, for a caller that may read it (an owner-scope host).
3
9
  import { withIapPresentation } from "../_shared/iap-presentation.js";
4
- import { getPlan, type WixPlan } from "@himalaya/wix-pricing-plans";
10
+ import { getPlan, listPublicPlans, type WixPlan } from "@himalaya/wix-pricing-plans";
5
11
  import { presence, presenceAfterError, detailInputId, type PresenceFlags } from "../_shared/detail-presence.js";
6
12
 
7
13
  interface S extends PresenceFlags { status: "loading" | "ready"; name: string; price: string; description: string; perks: string }
@@ -12,6 +18,26 @@ function priceOf(p: WixPlan): string {
12
18
  return m.formattedAmount || [m.amount ?? m.value, m.currency].filter(Boolean).join(" ");
13
19
  }
14
20
 
21
+ /** The plan, or null when it is not public and the caller may not read it otherwise. */
22
+ async function readPlan(id: string): Promise<WixPlan | null> {
23
+ let publicRead = false;
24
+ try {
25
+ const listed = await listPublicPlans();
26
+ publicRead = true;
27
+ const hit = (listed?.plans ?? []).find((plan) => plan?.id === id);
28
+ if (hit) return hit;
29
+ } catch (e) { void e; }
30
+ try {
31
+ const res = await getPlan(id);
32
+ return ((res as unknown as { plan?: WixPlan }).plan) ?? (res as unknown as WixPlan);
33
+ } catch (e) {
34
+ // The public read worked, so the device is online: a refused or empty management
35
+ // read means this plan is not one the viewer can see — not-found, not offline.
36
+ if (publicRead) return null;
37
+ throw e;
38
+ }
39
+ }
40
+
15
41
  const defineService = withIapPresentation<S>({
16
42
  prefix: "plans", transientKeys: ["price"], contentActions: ["load"],
17
43
  items: state => detailInputId() ? [{ reference: { kind: "pricingPlan", id: detailInputId() }, commerceDisplayPrice: state.price }] : [],
@@ -24,8 +50,8 @@ const svc = defineService<S, { load: (s: S) => Promise<S> }>("spacesPlanDetail",
24
50
  const id = detailInputId();
25
51
  if (!id) return { ...s, status: "ready", ...presence("missing") };
26
52
  try {
27
- const res = await getPlan(id);
28
- const p = ((res as unknown as { plan?: WixPlan }).plan) ?? (res as unknown as WixPlan);
53
+ const p = await readPlan(id);
54
+ if (!p) return { ...s, status: "ready", ...presence("missing") };
29
55
  const perks = (p.perks?.values ?? []).map((v) => `• ${v}`).join("\n");
30
56
  return { status: "ready", name: p.name || "Plan", price: priceOf(p), description: p.description || "", perks, ...presence("found") };
31
57
  } catch (e) { return { ...s, status: "ready", ...presenceAfterError(e) }; }
@@ -13,6 +13,7 @@ import {
13
13
  queryAvailableTickets,
14
14
  queryEvents,
15
15
  type WixAvailableTicket,
16
+ type WixEventFieldset,
16
17
  type WixEvent,
17
18
  type WixGuestRsvp,
18
19
  type WixGuestTicket,
@@ -34,6 +35,7 @@ import {
34
35
  type EventRow,
35
36
  } from "../_shared/himi-events";
36
37
  import { initials } from "../_shared/himi-text.js";
38
+ import { isHostedWorker } from "../_shared/hosted-checkout.js";
37
39
  import { stashCopy, copyParam, copyTemplate } from "../_shared/himi-copy.js";
38
40
  import { openRichLink } from "../_shared/rich-content-link.js";
39
41
  import { richBody } from "../_shared/rich-body.js";
@@ -281,6 +283,10 @@ function calendarFallbackUrl(s: S, end: number): string {
281
283
  return `https://calendar.google.com/calendar/render?${parts.join("&")}`;
282
284
  }
283
285
 
286
+ /** The Events v3 fieldsets the detail reads (see `WixEventFieldset`). DETAILS carries mainImage and
287
+ * shortDescription: REGISTRATION fills eventType, which skips the query enrichment that used to supply them. */
288
+ export const EVENT_DETAIL_FIELDSETS: WixEventFieldset[] = ["DETAILS", "TEXTS", "REGISTRATION", "URLS"];
289
+
284
290
  /** Map the decoded registration/eventType onto the CTA variant. The decoded
285
291
  * `eventType` already folds registration.type / registration.initialType /
286
292
  * the legacy flat `type` (decodeEvent, #478). Unknown ⇒ RSVP (historical). */
@@ -458,8 +464,11 @@ const svc = defineService<S, {
458
464
  const id = s.id || inputId();
459
465
  if (!id) return derive({ ...s, status: "ready", errored: true });
460
466
  try {
461
- // TEXTS adds the Ricos `description` to the base fieldset (a bare read returns none).
462
- const res = await getEvent(id, { fields: ["TEXTS"] });
467
+ // TEXTS adds the Ricos `description` to the base fieldset (a bare read returns none);
468
+ // REGISTRATION adds `registration` (the CTA's type, and an external event's own URL);
469
+ // URLS adds `eventPageUrl` — the registration fallback and the share link. Without URLS a
470
+ // ticket flow that cannot complete in-app had no page to send the buyer to.
471
+ const res = await getEvent(id, { fields: EVENT_DETAIL_FIELDSETS });
463
472
  let e = ((res as unknown as { event?: WixEvent }).event ?? res) as WixEvent;
464
473
  e = await enrichRegistration(id, e);
465
474
  const attendees = await attendeeSummary(id);
@@ -566,7 +575,12 @@ const svc = defineService<S, {
566
575
  // Every failure degrades one rung at a time, never to a dead button:
567
576
  // native chain → hosted eventsCheckout (pinned to the SAME reservation
568
577
  // when we got one, else the slug) → the event page / registration URL.
569
- if (s.registrationKind === "tickets" && s.id) {
578
+ // Hosted inside a host shell (Spaces) neither in-app rung can finish: the classic
579
+ // `checkoutReservation` needs a buyer + guest form this screen does not collect, and the
580
+ // redirect session needs a headless OAuth client a hosted app does not have. Skip both —
581
+ // the native chain would also leave a ticket reservation held for nothing — and open the
582
+ // event's own page, where the member registers on the site.
583
+ if (s.registrationKind === "tickets" && s.id && !isHostedWorker()) {
570
584
  let reservationId = "";
571
585
  try {
572
586
  // Buy what the buyer PICKED. `load` seeds the pick with the cheapest
@@ -0,0 +1,42 @@
1
+ // Hosted checkout (#4089 B3). A Branded Lite hosted in a host shell (Spaces)
2
+ // pays as the member the host lent it: Headless `createRedirectSession` does not take a Member
3
+ // instance, so the HOST opens the site's checkout signed in as that member. The worker names only
4
+ // its ecom checkout; the host reads Wix's checkout URL, mints a one-time member session and opens
5
+ // the site's `createSessionCookie` → checkout. The session token never reaches this worker.
6
+
7
+ import { dispatch } from "@himalaya/dispatch";
8
+ import { hostSiteFromInput } from "./branded-lite-hosted.js";
9
+
10
+ /** The host verbs (registered by the host shell, never by the app itself). The first prepares the
11
+ * checkout and answers a one-time ticket; the second opens it. Two steps so the caller's charge guard
12
+ * is re-read after the host's network round trips and right before the checkout opens — the prepared
13
+ * URL (which carries the member's session token) stays in the host; only the ticket crosses here. */
14
+ export const HOSTED_CHECKOUT_ACTION = "himi.host.memberCheckout";
15
+ export const HOSTED_CHECKOUT_OPEN_ACTION = "himi.host.memberCheckout.open";
16
+
17
+ /** Whether this worker runs inside a host that opened a site for it. */
18
+ export function isHostedWorker(): boolean {
19
+ return hostSiteFromInput((globalThis as { himalaya?: { input?: unknown } }).himalaya?.input) !== null;
20
+ }
21
+
22
+ type HostAnswer = { opened?: unknown; ticket?: unknown; payload?: { opened?: unknown; ticket?: unknown } } | null;
23
+
24
+ /** `payCashierOrder`'s `openCashier` for a hosted app: the host prepares [checkoutId]'s checkout as the
25
+ * lent member, the caller's guard is re-read, and only then does the host open it. Resolves whether it
26
+ * opened ("withdrawn" when the guard said no); a host without the verbs (or that refused) → false. */
27
+ export function hostedCheckoutOpener(checkoutId: string): (stillMayCharge: () => Promise<boolean>) => Promise<boolean | "withdrawn"> {
28
+ return async (stillMayCharge) => {
29
+ try {
30
+ const prepared = await dispatch(HOSTED_CHECKOUT_ACTION, { checkoutId }) as HostAnswer;
31
+ const ticket = prepared?.ticket ?? prepared?.payload?.ticket;
32
+ if (typeof ticket !== "string" || !ticket) return false;
33
+ // An unopened ticket simply expires in the host: nothing to cancel.
34
+ if (!(await stillMayCharge())) return "withdrawn";
35
+ const result = await dispatch(HOSTED_CHECKOUT_OPEN_ACTION, { ticket }) as HostAnswer;
36
+ const opened = result?.opened ?? result?.payload?.opened;
37
+ return opened === true || opened === "true";
38
+ } catch {
39
+ return false;
40
+ }
41
+ };
42
+ }
@@ -13,6 +13,7 @@ import {
13
13
  queryAvailableTickets,
14
14
  queryEvents,
15
15
  type WixAvailableTicket,
16
+ type WixEventFieldset,
16
17
  type WixEvent,
17
18
  type WixGuestRsvp,
18
19
  type WixGuestTicket,
@@ -34,6 +35,7 @@ import {
34
35
  type EventRow,
35
36
  } from "../_shared/himi-events";
36
37
  import { initials } from "../_shared/himi-text.js";
38
+ import { isHostedWorker } from "../_shared/hosted-checkout.js";
37
39
  import { stashCopy, copyParam, copyTemplate } from "../_shared/himi-copy.js";
38
40
  import { openRichLink } from "../_shared/rich-content-link.js";
39
41
  import { richBody } from "../_shared/rich-body.js";
@@ -281,6 +283,10 @@ function calendarFallbackUrl(s: S, end: number): string {
281
283
  return `https://calendar.google.com/calendar/render?${parts.join("&")}`;
282
284
  }
283
285
 
286
+ /** The Events v3 fieldsets the detail reads (see `WixEventFieldset`). DETAILS carries mainImage and
287
+ * shortDescription: REGISTRATION fills eventType, which skips the query enrichment that used to supply them. */
288
+ export const EVENT_DETAIL_FIELDSETS: WixEventFieldset[] = ["DETAILS", "TEXTS", "REGISTRATION", "URLS"];
289
+
284
290
  /** Map the decoded registration/eventType onto the CTA variant. The decoded
285
291
  * `eventType` already folds registration.type / registration.initialType /
286
292
  * the legacy flat `type` (decodeEvent, #478). Unknown ⇒ RSVP (historical). */
@@ -458,8 +464,11 @@ const svc = defineService<S, {
458
464
  const id = s.id || inputId();
459
465
  if (!id) return derive({ ...s, status: "ready", errored: true });
460
466
  try {
461
- // TEXTS adds the Ricos `description` to the base fieldset (a bare read returns none).
462
- const res = await getEvent(id, { fields: ["TEXTS"] });
467
+ // TEXTS adds the Ricos `description` to the base fieldset (a bare read returns none);
468
+ // REGISTRATION adds `registration` (the CTA's type, and an external event's own URL);
469
+ // URLS adds `eventPageUrl` — the registration fallback and the share link. Without URLS a
470
+ // ticket flow that cannot complete in-app had no page to send the buyer to.
471
+ const res = await getEvent(id, { fields: EVENT_DETAIL_FIELDSETS });
463
472
  let e = ((res as unknown as { event?: WixEvent }).event ?? res) as WixEvent;
464
473
  e = await enrichRegistration(id, e);
465
474
  const attendees = await attendeeSummary(id);
@@ -566,7 +575,12 @@ const svc = defineService<S, {
566
575
  // Every failure degrades one rung at a time, never to a dead button:
567
576
  // native chain → hosted eventsCheckout (pinned to the SAME reservation
568
577
  // when we got one, else the slug) → the event page / registration URL.
569
- if (s.registrationKind === "tickets" && s.id) {
578
+ // Hosted inside a host shell (Spaces) neither in-app rung can finish: the classic
579
+ // `checkoutReservation` needs a buyer + guest form this screen does not collect, and the
580
+ // redirect session needs a headless OAuth client a hosted app does not have. Skip both —
581
+ // the native chain would also leave a ticket reservation held for nothing — and open the
582
+ // event's own page, where the member registers on the site.
583
+ if (s.registrationKind === "tickets" && s.id && !isHostedWorker()) {
570
584
  let reservationId = "";
571
585
  try {
572
586
  // Buy what the buyer PICKED. `load` seeds the pick with the cheapest
@@ -9,6 +9,7 @@ import { hostSessionFromGlobal, iapPriceFields, type IapCatalogItem } from "@him
9
9
  import { createIapCommerce, pricingPlanOrders } from "@himalaya/iap-checkout";
10
10
  import { iapPriceCopy, iapPurchaseCopy, resetUnhydratedKeysOnOlderRuntime } from "../_shared/iap-worker.js";
11
11
  import { formatCurrency } from "../_shared/engine-intl.js";
12
+ import { isHostedWorker } from "../_shared/hosted-checkout.js";
12
13
 
13
14
  /** Wix Pricing Plans appDefId — the vertical that owns a plan order, so the `appId` its
14
15
  * cashier order is paid under (mirrors AppDefId.PRICING_PLANS in Wix's RN apps). */
@@ -49,6 +50,8 @@ interface PlanRow {
49
50
  hasBadge: boolean;
50
51
  /** Purchasable URL when the plan data carries one; "" otherwise. */
51
52
  joinUrl: string;
53
+ /** No price (or a zero price): an online order activates it at once, with nothing to pay. */
54
+ free: boolean;
52
55
  joined: boolean;
53
56
  appleSupported: boolean;
54
57
  joinLabel: string;
@@ -117,6 +120,11 @@ function joinUrl(plan: WixPlan): string {
117
120
  return "";
118
121
  }
119
122
 
123
+ function isFreePlan(plan: WixPlan): boolean {
124
+ const raw = plan.pricing?.price?.value;
125
+ return raw == null || raw === "" || Number(raw) === 0;
126
+ }
127
+
120
128
  function planRow(plan: WixPlan): PlanRow {
121
129
  const name = (plan.name || copyParam("plans.planFallbackName", "Plan")).trim();
122
130
  const perks = (plan.perks?.values ?? [])
@@ -142,6 +150,7 @@ function planRow(plan: WixPlan): PlanRow {
142
150
  noBadge: !badge,
143
151
  hasBadge: !!badge,
144
152
  joinUrl: joinUrl(plan),
153
+ free: isFreePlan(plan),
145
154
  appleSupported: plan.pricing?.singlePaymentUnlimited === true,
146
155
  joined: false, joinLabel: copyParam("plans.joinCta", "Join"), joinDisabled: true, joinNote: "",
147
156
  };
@@ -215,6 +224,14 @@ async function regularJoinMessage(row: PlanRow, reference: { kind: string; id: s
215
224
  } catch {
216
225
  }
217
226
  }
227
+ // Hosted inside a host shell (Spaces) a plan cannot be paid in-app: classic Pricing Plans is not on
228
+ // ecom, so the host's member checkout has no checkout to open, and the redirect session needs a
229
+ // headless OAuth client a hosted app does not have. An online order created here would only be
230
+ // left behind as a DRAFT, so none is — the member joins on the site. A FREE plan has nothing to
231
+ // pay: its online order activates at once, so it still joins in-app.
232
+ if (isHostedWorker() && !row.free) {
233
+ return copyTemplate("plans.joinOnSiteMessage", "Memberships are purchased on the website — visit the site to join {name}.", { name: row.name });
234
+ }
218
235
  try {
219
236
  const created = await createOnlineOrder(row.id);
220
237
  const snapshotId = created.purchaseFlowId;