@wix/himalaya-cli 0.1324.0 → 0.1326.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 (177) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/checkout/src/index.d.ts +6 -10
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/order-refs.d.ts +1 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/purchase-fields.d.ts +1 -0
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/screens/src/index.d.ts +9 -35
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/stores-v1/src/display.d.ts +1 -0
  6. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/checkout.mjs +30 -13
  7. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap-checkout.mjs +20 -2
  8. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/screens.mjs +2890 -54
  9. package/dist/cli.mjs +4 -5
  10. package/fixtures/wix-canned.json +61 -6
  11. package/guides/iap-pricing.md +9 -0
  12. package/guides/iap-screen-adoption.md +77 -0
  13. package/guides/importable-sdks.md +2 -0
  14. package/guides/index.json +6 -0
  15. package/guides/sdk-reference.json +339 -1
  16. package/package.json +2 -2
  17. package/templates/beauty-salon/config.ts +1 -1
  18. package/templates/beauty-salon/dev/index.ts +46 -18
  19. package/templates/beauty-salon/tier5-src/_shared/collection-contract.ts +1 -1
  20. package/templates/beauty-salon/tier5-src/_shared/collection-service.ts +3 -2
  21. package/templates/beauty-salon/tier5-src/_shared/himi-store-collection.ts +1 -1
  22. package/templates/beauty-salon/tier5-src/_shared/home-service.ts +2 -1
  23. package/templates/beauty-salon/tier5-src/_shared/iap-presentation.ts +53 -0
  24. package/templates/beauty-salon/tier5-src/_shared/iap-worker.ts +42 -0
  25. package/templates/beauty-salon/tier5-src/himi-branded-lite-cart/index.ts +48 -1
  26. package/templates/beauty-salon/tier5-src/himi-branded-lite-home/index.ts +16 -2
  27. package/templates/beauty-salon/tier5-src/himi-branded-lite-order/index.ts +24 -3
  28. package/templates/beauty-salon/tier5-src/himi-branded-lite-plans/index.ts +153 -64
  29. package/templates/beauty-salon/tier5-src/himi-store-product/index.ts +24 -3
  30. package/templates/beauty-salon/tier5-src/himi-store-saved/index.ts +8 -3
  31. package/templates/beauty-salon/tier5-src/himi-store-shop/index.ts +10 -0
  32. package/templates/beauty-salon/tier5-src/wix-spaces-plan-detail/index.ts +8 -3
  33. package/templates/beauty-salon-owner/tier5-src/_shared/collection-contract.ts +1 -1
  34. package/templates/beauty-salon-owner/tier5-src/_shared/collection-service.ts +3 -2
  35. package/templates/beauty-salon-owner/tier5-src/_shared/home-service.ts +2 -1
  36. package/templates/branded-lite/config.ts +1 -1
  37. package/templates/branded-lite/dev/index.ts +46 -18
  38. package/templates/branded-lite/tier5-src/_shared/collection-contract.ts +1 -1
  39. package/templates/branded-lite/tier5-src/_shared/collection-service.ts +3 -2
  40. package/templates/branded-lite/tier5-src/_shared/himi-store-collection.ts +1 -1
  41. package/templates/branded-lite/tier5-src/_shared/home-service.ts +2 -1
  42. package/templates/branded-lite/tier5-src/_shared/iap-presentation.ts +53 -0
  43. package/templates/branded-lite/tier5-src/himi-branded-lite-cart/index.ts +48 -1
  44. package/templates/branded-lite/tier5-src/himi-branded-lite-home/index.ts +16 -2
  45. package/templates/branded-lite/tier5-src/himi-branded-lite-order/index.ts +24 -3
  46. package/templates/branded-lite/tier5-src/himi-branded-lite-plans/index.ts +153 -64
  47. package/templates/branded-lite/tier5-src/himi-store-product/index.ts +24 -3
  48. package/templates/branded-lite/tier5-src/himi-store-saved/index.ts +8 -3
  49. package/templates/branded-lite/tier5-src/himi-store-shop/index.ts +10 -0
  50. package/templates/branded-lite/tier5-src/wix-spaces-plan-detail/index.ts +8 -3
  51. package/templates/community-club/config.ts +1 -1
  52. package/templates/community-club/dev/index.ts +3 -1
  53. package/templates/community-club/tier5-src/_shared/collection-contract.ts +1 -1
  54. package/templates/community-club/tier5-src/_shared/collection-service.ts +3 -2
  55. package/templates/community-club/tier5-src/_shared/home-service.ts +2 -1
  56. package/templates/community-club/tier5-src/_shared/iap-presentation.ts +53 -0
  57. package/templates/community-club/tier5-src/_shared/iap-worker.ts +42 -0
  58. package/templates/community-club/tier5-src/himi-branded-lite-home/index.ts +16 -2
  59. package/templates/community-club-owner/tier5-src/_shared/collection-contract.ts +1 -1
  60. package/templates/community-club-owner/tier5-src/_shared/collection-service.ts +3 -2
  61. package/templates/community-club-owner/tier5-src/_shared/home-service.ts +2 -1
  62. package/templates/events-meetups/config.ts +1 -1
  63. package/templates/events-meetups/dev/index.ts +3 -1
  64. package/templates/events-meetups/tier5-src/_shared/collection-contract.ts +1 -1
  65. package/templates/events-meetups/tier5-src/_shared/collection-service.ts +3 -2
  66. package/templates/events-meetups/tier5-src/_shared/home-service.ts +2 -1
  67. package/templates/events-meetups/tier5-src/_shared/iap-presentation.ts +53 -0
  68. package/templates/events-meetups/tier5-src/_shared/iap-worker.ts +42 -0
  69. package/templates/events-meetups/tier5-src/himi-branded-lite-home/index.ts +16 -2
  70. package/templates/events-meetups-owner/tier5-src/_shared/collection-contract.ts +1 -1
  71. package/templates/events-meetups-owner/tier5-src/_shared/collection-service.ts +3 -2
  72. package/templates/events-meetups-owner/tier5-src/_shared/home-service.ts +2 -1
  73. package/templates/gym-fitness/config.ts +1 -1
  74. package/templates/gym-fitness/dev/index.ts +46 -18
  75. package/templates/gym-fitness/tier5-src/_shared/collection-contract.ts +1 -1
  76. package/templates/gym-fitness/tier5-src/_shared/collection-service.ts +3 -2
  77. package/templates/gym-fitness/tier5-src/_shared/himi-store-collection.ts +1 -1
  78. package/templates/gym-fitness/tier5-src/_shared/home-service.ts +2 -1
  79. package/templates/gym-fitness/tier5-src/_shared/iap-presentation.ts +53 -0
  80. package/templates/gym-fitness/tier5-src/_shared/iap-worker.ts +42 -0
  81. package/templates/gym-fitness/tier5-src/himi-branded-lite-cart/index.ts +48 -1
  82. package/templates/gym-fitness/tier5-src/himi-branded-lite-home/index.ts +16 -2
  83. package/templates/gym-fitness/tier5-src/himi-branded-lite-order/index.ts +24 -3
  84. package/templates/gym-fitness/tier5-src/himi-branded-lite-plans/index.ts +153 -64
  85. package/templates/gym-fitness/tier5-src/himi-store-product/index.ts +24 -3
  86. package/templates/gym-fitness/tier5-src/himi-store-saved/index.ts +8 -3
  87. package/templates/gym-fitness/tier5-src/himi-store-shop/index.ts +10 -0
  88. package/templates/gym-fitness/tier5-src/wix-spaces-plan-detail/index.ts +8 -3
  89. package/templates/gym-fitness-owner/tier5-src/_shared/collection-contract.ts +1 -1
  90. package/templates/gym-fitness-owner/tier5-src/_shared/collection-service.ts +3 -2
  91. package/templates/gym-fitness-owner/tier5-src/_shared/home-service.ts +2 -1
  92. package/templates/nonprofit-cause/config.ts +1 -1
  93. package/templates/nonprofit-cause/dev/index.ts +29 -13
  94. package/templates/nonprofit-cause/tier5-src/_shared/collection-contract.ts +1 -1
  95. package/templates/nonprofit-cause/tier5-src/_shared/collection-service.ts +3 -2
  96. package/templates/nonprofit-cause/tier5-src/_shared/home-service.ts +2 -1
  97. package/templates/nonprofit-cause/tier5-src/_shared/iap-presentation.ts +53 -0
  98. package/templates/nonprofit-cause/tier5-src/_shared/iap-worker.ts +42 -0
  99. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-home/index.ts +16 -2
  100. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-plans/index.ts +153 -64
  101. package/templates/nonprofit-cause/tier5-src/wix-spaces-plan-detail/index.ts +8 -3
  102. package/templates/nonprofit-cause-owner/tier5-src/_shared/collection-contract.ts +1 -1
  103. package/templates/nonprofit-cause-owner/tier5-src/_shared/collection-service.ts +3 -2
  104. package/templates/nonprofit-cause-owner/tier5-src/_shared/home-service.ts +2 -1
  105. package/templates/owner-lite/tier5-src/_shared/collection-contract.ts +1 -1
  106. package/templates/owner-lite/tier5-src/_shared/collection-service.ts +3 -2
  107. package/templates/owner-lite/tier5-src/_shared/home-service.ts +2 -1
  108. package/templates/podcast-creator/config.ts +1 -1
  109. package/templates/podcast-creator/dev/index.ts +29 -13
  110. package/templates/podcast-creator/tier5-src/_shared/collection-contract.ts +1 -1
  111. package/templates/podcast-creator/tier5-src/_shared/collection-service.ts +3 -2
  112. package/templates/podcast-creator/tier5-src/_shared/home-service.ts +2 -1
  113. package/templates/podcast-creator/tier5-src/_shared/iap-presentation.ts +53 -0
  114. package/templates/podcast-creator/tier5-src/_shared/iap-worker.ts +42 -0
  115. package/templates/podcast-creator/tier5-src/himi-branded-lite-home/index.ts +16 -2
  116. package/templates/podcast-creator/tier5-src/himi-branded-lite-plans/index.ts +153 -64
  117. package/templates/podcast-creator/tier5-src/wix-spaces-plan-detail/index.ts +8 -3
  118. package/templates/podcast-creator-owner/tier5-src/_shared/collection-contract.ts +1 -1
  119. package/templates/podcast-creator-owner/tier5-src/_shared/collection-service.ts +3 -2
  120. package/templates/podcast-creator-owner/tier5-src/_shared/home-service.ts +2 -1
  121. package/templates/portfolio-creative/config.ts +1 -1
  122. package/templates/portfolio-creative/dev/index.ts +20 -6
  123. package/templates/portfolio-creative/tier5-src/_shared/collection-contract.ts +1 -1
  124. package/templates/portfolio-creative/tier5-src/_shared/collection-service.ts +3 -2
  125. package/templates/portfolio-creative/tier5-src/_shared/himi-store-collection.ts +1 -1
  126. package/templates/portfolio-creative/tier5-src/_shared/home-service.ts +2 -1
  127. package/templates/portfolio-creative/tier5-src/_shared/iap-presentation.ts +53 -0
  128. package/templates/portfolio-creative/tier5-src/_shared/iap-worker.ts +42 -0
  129. package/templates/portfolio-creative/tier5-src/himi-branded-lite-cart/index.ts +48 -1
  130. package/templates/portfolio-creative/tier5-src/himi-branded-lite-home/index.ts +16 -2
  131. package/templates/portfolio-creative/tier5-src/himi-branded-lite-order/index.ts +24 -3
  132. package/templates/portfolio-creative/tier5-src/himi-store-product/index.ts +24 -3
  133. package/templates/portfolio-creative/tier5-src/himi-store-saved/index.ts +8 -3
  134. package/templates/portfolio-creative/tier5-src/himi-store-shop/index.ts +10 -0
  135. package/templates/portfolio-creative-owner/tier5-src/_shared/collection-contract.ts +1 -1
  136. package/templates/portfolio-creative-owner/tier5-src/_shared/collection-service.ts +3 -2
  137. package/templates/portfolio-creative-owner/tier5-src/_shared/home-service.ts +2 -1
  138. package/templates/restaurant/config.ts +1 -1
  139. package/templates/restaurant/dev/index.ts +3 -1
  140. package/templates/restaurant/tier5-src/_shared/collection-contract.ts +1 -1
  141. package/templates/restaurant/tier5-src/_shared/collection-service.ts +3 -2
  142. package/templates/restaurant/tier5-src/_shared/home-service.ts +2 -1
  143. package/templates/restaurant/tier5-src/_shared/iap-presentation.ts +53 -0
  144. package/templates/restaurant/tier5-src/_shared/iap-worker.ts +42 -0
  145. package/templates/restaurant/tier5-src/himi-branded-lite-home/index.ts +16 -2
  146. package/templates/restaurant-owner/tier5-src/_shared/collection-contract.ts +1 -1
  147. package/templates/restaurant-owner/tier5-src/_shared/collection-service.ts +3 -2
  148. package/templates/restaurant-owner/tier5-src/_shared/home-service.ts +2 -1
  149. package/templates/retail-store/config.ts +1 -1
  150. package/templates/retail-store/dev/index.ts +20 -6
  151. package/templates/retail-store/tier5-src/_shared/collection-contract.ts +1 -1
  152. package/templates/retail-store/tier5-src/_shared/collection-service.ts +3 -2
  153. package/templates/retail-store/tier5-src/_shared/himi-store-collection.ts +1 -1
  154. package/templates/retail-store/tier5-src/_shared/home-service.ts +2 -1
  155. package/templates/retail-store/tier5-src/_shared/iap-presentation.ts +53 -0
  156. package/templates/retail-store/tier5-src/_shared/iap-worker.ts +42 -0
  157. package/templates/retail-store/tier5-src/himi-branded-lite-cart/index.ts +48 -1
  158. package/templates/retail-store/tier5-src/himi-branded-lite-home/index.ts +16 -2
  159. package/templates/retail-store/tier5-src/himi-branded-lite-order/index.ts +24 -3
  160. package/templates/retail-store/tier5-src/himi-store-product/index.ts +24 -3
  161. package/templates/retail-store/tier5-src/himi-store-saved/index.ts +8 -3
  162. package/templates/retail-store/tier5-src/himi-store-shop/index.ts +10 -0
  163. package/templates/retail-store-owner/tier5-src/_shared/collection-contract.ts +1 -1
  164. package/templates/retail-store-owner/tier5-src/_shared/collection-service.ts +3 -2
  165. package/templates/retail-store-owner/tier5-src/_shared/home-service.ts +2 -1
  166. package/templates/yoga-studio/config.ts +1 -1
  167. package/templates/yoga-studio/dev/index.ts +29 -13
  168. package/templates/yoga-studio/tier5-src/_shared/collection-contract.ts +1 -1
  169. package/templates/yoga-studio/tier5-src/_shared/collection-service.ts +3 -2
  170. package/templates/yoga-studio/tier5-src/_shared/home-service.ts +2 -1
  171. package/templates/yoga-studio/tier5-src/_shared/iap-presentation.ts +53 -0
  172. package/templates/yoga-studio/tier5-src/himi-branded-lite-home/index.ts +16 -2
  173. package/templates/yoga-studio/tier5-src/himi-branded-lite-plans/index.ts +153 -64
  174. package/templates/yoga-studio/tier5-src/wix-spaces-plan-detail/index.ts +8 -3
  175. package/templates/yoga-studio-owner/tier5-src/_shared/collection-contract.ts +1 -1
  176. package/templates/yoga-studio-owner/tier5-src/_shared/collection-service.ts +3 -2
  177. package/templates/yoga-studio-owner/tier5-src/_shared/home-service.ts +2 -1
@@ -1,4 +1,7 @@
1
- import { defineService } from "@himalaya/state";
1
+ import { getStoreProduct } from "../_shared/himi-store.js";
2
+ import { iapPriceCopy } from "../_shared/iap-worker.js";
3
+ import { withIapPresentation } from "../_shared/iap-presentation.js";
4
+ import { type IapCommerce, classifyCart } from "@himalaya/iap-checkout";
2
5
  import { createCheckoutFromCurrentCart, createOrder, getCurrentCart, removeLineItemsFromCurrentCart, updateLineItemsQuantity, type WixCartLineItemDetail } from "@himalaya/wix-ecom";
3
6
  import { createRedirectSession } from "@himalaya/wix-redirects";
4
7
  import { session as wixPlatformSession } from "@himalaya/wix-platform";
@@ -6,6 +9,7 @@ import { payCashierOrder } from "@himalaya/checkout";
6
9
 
7
10
  /** Wix Stores appDefId — the vertical that owns a cart/ecom order, and therefore the
8
11
  * `appId` its cashier order is paid under. (Same constant Wix's own tooling uses.) */
12
+ const WIX_STORES_CATALOG_ID = "215238eb-22a5-4c36-9e7b-e7c08025e04e";
9
13
  const WIX_STORES_APP_ID = "1380b703-ce81-ff05-f115-39571d94dfcd";
10
14
 
11
15
  /** The site's metaSiteId for the cashier payment config. Best-effort — the native
@@ -40,6 +44,7 @@ async function cacheDrop(id: string): Promise<void> { try { await dbRemove(ENTIT
40
44
  async function queueWrite(actionId: string, params: Record<string, string>): Promise<void> { try { await enqueue(actionId, params); } catch { /* capability not granted */ } }
41
45
 
42
46
  interface Row {
47
+ catalogReference?: { appId: string; catalogItemId: string; options?: Record<string, unknown> };
43
48
  id: string;
44
49
  name: string;
45
50
  title: string;
@@ -50,6 +55,7 @@ interface Row {
50
55
  /** The "−" control at qty 1 removes the line, so it never dead-ends. */
51
56
  atMinQuantity: boolean;
52
57
  total: string;
58
+ commerceTotal: string;
53
59
  image: string;
54
60
  initial: string;
55
61
  hasImage: boolean;
@@ -59,6 +65,7 @@ interface State {
59
65
  status: "loading" | "ready" | "error";
60
66
  items: Row[];
61
67
  subtotal: string;
68
+ commerceSubtotal?: string;
62
69
  emptyText: string;
63
70
  checkoutHint: string;
64
71
  isLoading: boolean;
@@ -118,6 +125,7 @@ function row(item: WixCartLineItemDetail, index: number): Row {
118
125
  const name = item.productName?.original || copyParam("cart.itemFallbackName", "Cart item");
119
126
  const image = mediaUrl(item);
120
127
  return {
128
+ catalogReference: (item as WixCartLineItemDetail & { catalogReference?: Row["catalogReference"] }).catalogReference,
121
129
  id: item.id || `line-${index}`,
122
130
  name,
123
131
  title: name,
@@ -126,6 +134,7 @@ function row(item: WixCartLineItemDetail, index: number): Row {
126
134
  quantityLabel: `${quantity}`,
127
135
  atMinQuantity: quantity <= 1,
128
136
  total: item.price?.formattedAmount ?? "",
137
+ commerceTotal: item.price?.formattedAmount ?? "",
129
138
  image,
130
139
  initial: (name.trim()[0] || "C").toUpperCase(),
131
140
  hasImage: Boolean(image),
@@ -163,6 +172,7 @@ async function refresh(state: State, checkoutHint = ""): Promise<State> {
163
172
  status: "ready",
164
173
  items,
165
174
  subtotal: cart.subtotal?.formattedAmount ?? "",
175
+ commerceSubtotal: cart.subtotal?.formattedAmount ?? "",
166
176
  emptyText: items.length ? "" : copyParam("cart.emptyText", "Your cart is empty."),
167
177
  checkoutHint,
168
178
  });
@@ -190,6 +200,16 @@ function cartConsumedByOrder(): Pick<State, "items" | "subtotal" | "emptyText" |
190
200
  };
191
201
  }
192
202
 
203
+ let cartPricing: IapCommerce;
204
+ const defineService = withIapPresentation<State>({
205
+ onCreate: commerce => { cartPricing = commerce; },
206
+ prefix: "cart", transientKeys: ["items", "subtotal"], contentActions: ["load", "remove", "increment", "decrement"],
207
+ items: state => state.items.flatMap(row => row.catalogReference?.catalogItemId ? [{ reference: { kind: "storesProduct", id: row.catalogReference.catalogItemId }, commerceDisplayPrice: row.commerceTotal ?? row.total }] : []),
208
+ present: (state, price, allowed) => ({ ...state,
209
+ items: state.items.map(row => ({ ...row, total: row.catalogReference?.catalogItemId ? price(row.catalogReference.catalogItemId, row.commerceTotal ?? row.total) : row.total })),
210
+ subtotal: state.items.length > 0 && state.items.every(row => row.catalogReference?.catalogItemId && allowed(row.catalogReference.catalogItemId)) ? state.commerceSubtotal ?? state.subtotal : "" }),
211
+ });
212
+
193
213
  const svc = defineService<State, {
194
214
  load: (state: State, arg?: unknown) => Promise<State>;
195
215
  remove: (state: State, arg?: unknown) => Promise<State>;
@@ -272,6 +292,25 @@ const svc = defineService<State, {
272
292
  // `createOrder` returning no paymentGatewayOrderId means there is nothing to
273
293
  // charge (fully covered / zero total) — the order already stands.
274
294
  checkout: async (state) => {
295
+ let validatedLines: WixCartLineItemDetail[] = [];
296
+ const signature = (lines: WixCartLineItemDetail[]) => JSON.stringify(lines.map(line => [line.id, line.quantity, (line as WixCartLineItemDetail & { catalogReference?: Row["catalogReference"] }).catalogReference]));
297
+ try {
298
+ const cart = await getCurrentCart();
299
+ const lines = cart.lineItems ?? [];
300
+ if (!lines.length) return derive({ ...state, checkoutHint: copyParam("cart.checkoutEmptyMessage", "Add an item before checkout.") });
301
+ const commerce = cartPricing;
302
+ const items = lines.map(line => (line as WixCartLineItemDetail & { catalogReference?: Row["catalogReference"] }).catalogReference);
303
+ if (items.some(item => !item?.catalogItemId || !item.appId)) return derive({ ...state, checkoutHint: iapPriceCopy("cart").purchaseUnavailable });
304
+ await commerce.loadPrices(items.map((item, index) => ({ reference: { kind: "storesProduct", id: item!.catalogItemId }, commerceDisplayPrice: lines[index].price?.formattedAmount })));
305
+ const classification = classifyCart(items.map((item, index) => ({ route: commerce.pricing.view({ kind: "storesProduct", id: item!.catalogItemId }).route, quantity: lines[index].quantity ?? 1 })));
306
+ if (classification !== "regular") return derive({ ...state, checkoutHint: classification === "mixedRefused" ? copyParam("cart.iapSingleItemOnly", "Buy digital items individually, with quantity 1 and no options.") : iapPriceCopy("cart").purchaseUnavailable });
307
+ const products = await Promise.all(items.map(async item => item!.appId === WIX_STORES_CATALOG_ID ? (await getStoreProduct(item!.catalogItemId)).product : undefined));
308
+ if (products.some(product => product?.productType?.toLowerCase() !== "physical")) return derive({ ...state, checkoutHint: iapPriceCopy("cart").purchaseUnavailable });
309
+ const latest = await getCurrentCart();
310
+ if (signature(latest.lineItems ?? []) !== signature(lines)) return derive({ ...state, checkoutHint: copyParam("cart.changedBeforeCheckout", "Your cart changed. Review it before checking out.") });
311
+ validatedLines = lines;
312
+ } catch { return derive({ ...state, checkoutHint: iapPriceCopy("cart").purchaseUnavailable }); }
313
+
275
314
  if (!state.items.length) return derive({ ...state, checkoutHint: copyParam("cart.checkoutEmptyMessage", "Add an item before checkout.") });
276
315
  try {
277
316
  const checkout = await createCheckoutFromCurrentCart();
@@ -288,6 +327,14 @@ const svc = defineService<State, {
288
327
  appId: WIX_STORES_APP_ID,
289
328
  snapshotId,
290
329
  msid: await resolveMsid(),
330
+ mayCharge: async () => {
331
+ const latest = await getCurrentCart();
332
+ if (signature(latest.lineItems ?? []) !== signature(validatedLines)) return false;
333
+ return validatedLines.every(line => {
334
+ const reference = (line as WixCartLineItemDetail & { catalogReference?: Row["catalogReference"] }).catalogReference;
335
+ return !!reference && cartPricing.pricing.continueCheckoutGuard({ kind: "storesProduct", id: reference.catalogItemId }).allowed;
336
+ });
337
+ },
291
338
  cashierUrl: async () => {
292
339
  const redirect = await createRedirectSession({
293
340
  ecomCheckout: { checkoutId },
@@ -1,6 +1,7 @@
1
1
  import { fetchMemberBookings, type MemberBookingRow } from "../_shared/member-bookings.js";
2
2
  import { memberAccess, assertMemberAccess } from "../_shared/member-access.js";
3
- import { defineService } from "../_shared/home-service.js";
3
+ import { defineService as registerHome } from "../_shared/home-service.js";
4
+ import { withIapPresentation } from "../_shared/iap-presentation.js";
4
5
  import { queryServices, type WixService } from "@himalaya/wix-bookings";
5
6
  import { getPost, queryPosts, type WixPost } from "@himalaya/wix-blog";
6
7
  import { queryGroups, type WixGroup } from "@himalaya/wix-groups";
@@ -68,6 +69,7 @@ interface ProductFeature {
68
69
  /** Device-local catalog saves are useful to visitors too; they are never
69
70
  * presented as account-synced data or mixed with member-only bookings. */
70
71
  interface SavedProductCard {
72
+ commercePrice: string;
71
73
  id: string;
72
74
  name: string;
73
75
  price: string;
@@ -573,7 +575,7 @@ function postCard(post: WixPost): PostCard {
573
575
  function savedProductCard(product: SavedProduct): SavedProductCard {
574
576
  const name = cleanTitle(product.name) || copyParam("home.savedProductFallbackName", "Saved item");
575
577
  const image = product.image ? wixMediaUrl(product.image) : "";
576
- return { id: product.id, name, price: product.price, image, initial: initials(name), hasImage: !!image, noImage: !image, detailScreenId: "himi_product" };
578
+ return { id: product.id, name, price: product.price, commercePrice: product.price, image, initial: initials(name), hasImage: !!image, noImage: !image, detailScreenId: "himi_product" };
577
579
  }
578
580
 
579
581
  function savedPostCard(post: WixPost): SavedPostCard {
@@ -1111,6 +1113,18 @@ async function refresh(state: State, arg?: unknown): Promise<State | undefined>
1111
1113
  weeklyClassCount: current.weeklyClassCount, weeklyClassGoalFraction: current.weeklyClassGoalFraction, weeklyGoalLabel: current.weeklyGoalLabel });
1112
1114
  }
1113
1115
 
1116
+ const defineService = withIapPresentation<State>({
1117
+ register: registerHome, prefix: "home", transientKeys: ["featuredProducts", "savedProducts", "heroPrice"], contentActions: ["refresh"],
1118
+ items: state => [
1119
+ ...state.featuredProducts.map(row => ({ reference: { kind: "storesProduct", id: row.id }, commerceDisplayPrice: row.price })),
1120
+ ...state.savedProducts.map(row => ({ reference: { kind: "storesProduct", id: row.id }, commerceDisplayPrice: row.commercePrice })),
1121
+ ],
1122
+ present: (state, price) => derive({ ...state,
1123
+ featuredProducts: state.featuredProducts.map(row => ({ ...row, price: price(row.id) })),
1124
+ savedProducts: state.savedProducts.map(row => ({ ...row, price: price(row.id, row.commercePrice) })),
1125
+ }),
1126
+ });
1127
+
1114
1128
  const svc = defineService<State, {
1115
1129
  // `State | undefined`, matching the implementation: `refresh` returns undefined on a
1116
1130
  // STALE generation (`if (gen !== refreshGen) return undefined`) so the runtime keeps
@@ -1,3 +1,6 @@
1
+ import { createIapCommerce, classifyCart } from "@himalaya/iap-checkout";
2
+ import { getStoreProduct } from "../_shared/himi-store.js";
3
+ import { iapPriceCopy } from "../_shared/iap-worker.js";
1
4
  import { defineService } from "@himalaya/state";
2
5
  import { orderMember, assertOrderMember, ownedOrderCache, orderAccessDenied, orderAccessChanged, type OrderMember } from "../_shared/member-orders.js";
3
6
  import { addToCurrentCart, getOrder, searchOrders, type WixOrder } from "@himalaya/wix-ecom";
@@ -38,6 +41,8 @@ interface LineRow {
38
41
 
39
42
  /** What reorder re-adds: catalog item id + quantity per line (internal). */
40
43
  interface ReorderRef {
44
+ appId?: string;
45
+ options?: Record<string, unknown>;
41
46
  catalogItemId: string;
42
47
  quantity: number;
43
48
  }
@@ -164,10 +169,10 @@ function lineRows(order: WixOrder): LineRow[] {
164
169
  /** Catalog references for the reorder CTA — only store-catalog lines re-add. */
165
170
  function reorderRefs(order: WixOrder): ReorderRef[] {
166
171
  return (order.lineItems ?? []).flatMap((item) => {
167
- const ref = (item as { catalogReference?: { catalogItemId?: string } }).catalogReference;
172
+ const ref = (item as { catalogReference?: { catalogItemId?: string; appId?: string; options?: Record<string, unknown> } }).catalogReference;
168
173
  const catalogItemId = ref?.catalogItemId;
169
174
  return typeof catalogItemId === "string" && catalogItemId
170
- ? [{ catalogItemId, quantity: item.quantity || 1 }]
175
+ ? [{ catalogItemId, appId: ref.appId, ...(ref.options ? { options: ref.options } : {}), quantity: item.quantity || 1 }]
171
176
  : [];
172
177
  });
173
178
  }
@@ -307,6 +312,7 @@ async function load(state: State, arg?: unknown): Promise<State | undefined> {
307
312
  */
308
313
  const FOLLOW_TTL_SECONDS = 24 * 60 * 60;
309
314
 
315
+ const reorderPricing = createIapCommerce();
310
316
  const svc = defineService<State, {
311
317
  load: (state: State, arg?: unknown) => Promise<State | undefined>;
312
318
  retry: (state: State) => Promise<State | undefined>;
@@ -401,8 +407,23 @@ const svc = defineService<State, {
401
407
  catch { return version === loadVersion ? derive({ ...clearOrder(state), status: "error", emptyText: copyParam("order.signInEmptyText", "Sign in to view this order.") }) : undefined; }
402
408
  if (version !== loadVersion) return undefined;
403
409
  if (!state.reorderRefs.length) return derive({ ...state, reorderMessage: copyParam("order.reorderNoneMessage", "Nothing on this order can be re-added.") });
410
+ try {
411
+ const refs = state.reorderRefs;
412
+ if (refs.some(ref => ref.appId !== "215238eb-22a5-4c36-9e7b-e7c08025e04e")) return derive({ ...state, reorderMessage: iapPriceCopy("order").purchaseUnavailable });
413
+ await reorderPricing.loadPrices(refs.map(ref => ({ reference: { kind: "storesProduct", id: ref.catalogItemId } })));
414
+ const route = classifyCart(refs.map(ref => ({ route: reorderPricing.pricing.view({ kind: "storesProduct", id: ref.catalogItemId }).route, quantity: ref.quantity })));
415
+ const products = await Promise.all(refs.map(async ref => (await getStoreProduct(ref.catalogItemId)).product));
416
+ if (route !== "regular" || products.some(product => product?.productType?.toLowerCase() !== "physical")) return derive({ ...state, reorderMessage: iapPriceCopy("order").purchaseUnavailable });
417
+ await assertOrderMember(member);
418
+ if (version !== loadVersion) return undefined;
419
+ } catch {
420
+ if (version !== loadVersion) return undefined;
421
+ try { await assertOrderMember(member); }
422
+ catch { return derive({ ...clearOrder(state), status: "error", emptyText: copyParam("order.signInEmptyText", "Sign in to view this order.") }); }
423
+ return derive({ ...state, reorderMessage: iapPriceCopy("order").purchaseUnavailable });
424
+ }
404
425
  const results = await Promise.allSettled(
405
- state.reorderRefs.map((ref) => addToCurrentCart(ref.catalogItemId, ref.quantity)),
426
+ state.reorderRefs.map((ref) => addToCurrentCart(ref.catalogItemId, ref.quantity, ref.options)),
406
427
  );
407
428
  // A pending mutation must never restore an older order after navigation or sign-out.
408
429
  if (version !== loadVersion) return undefined;
@@ -1,21 +1,13 @@
1
- // Tier-5 bundle: himi-branded-lite — public pricing plans (memberships).
2
- //
3
- // Visitors can browse the site's public pricing plans (design §3.5 `plans`
4
- // vertical). The public GA route works on the anonymous visitor rung, so the
5
- // list paints with no login. Purchasing is NATIVE (Phase 2b): "Join" creates the
6
- // member's plan order via createOnlineOrder, then settles any balance on the shared
7
- // in-app pay rail (@wix/himalaya/checkout) — wallet where the app has one, in-app Wix
8
- // cashier otherwise. A FREE / fully-discounted plan activates with no payment step at
9
- // all. A plan-carried purchasable URL still wins when present, and any failure degrades
10
- // gracefully to the site-directing message rather than a dead button.
11
- // Four-state (loading / error / rows / empty) like the sibling branded workers.
12
1
  import { defineService } from "../_shared/collection-service.js";
13
2
  import { createOnlineOrder, listPublicPlans, type WixPlan } from "@himalaya/wix-pricing-plans";
14
3
  import { createRedirectSession } from "@himalaya/wix-redirects";
15
4
  import { open as openUrl } from "@himalaya/open-url";
16
5
  import { session as wixPlatformSession } from "@himalaya/wix-platform";
17
6
  import { copyParam, copyTemplate, stashCopy } from "../_shared/himi-copy.js";
18
- import { payCashierOrder } from "@himalaya/checkout";
7
+ import { activePlanIds, entitlement, payCashierOrder } from "@himalaya/checkout";
8
+ import { hostSessionFromGlobal, iapPriceFields, type IapCatalogItem } from "@himalaya/iap";
9
+ import { createIapCommerce, pricingPlanOrders } from "@himalaya/iap-checkout";
10
+ import { iapPriceCopy, iapPurchaseCopy, resetUnhydratedKeysOnOlderRuntime } from "../_shared/iap-worker.js";
19
11
  import { formatCurrency } from "../_shared/engine-intl.js";
20
12
 
21
13
  /** Wix Pricing Plans appDefId — the vertical that owns a plan order, so the `appId` its
@@ -57,6 +49,11 @@ interface PlanRow {
57
49
  hasBadge: boolean;
58
50
  /** Purchasable URL when the plan data carries one; "" otherwise. */
59
51
  joinUrl: string;
52
+ joined: boolean;
53
+ appleSupported: boolean;
54
+ joinLabel: string;
55
+ joinDisabled: boolean;
56
+ joinNote: string;
60
57
  }
61
58
 
62
59
  interface State {
@@ -130,7 +127,7 @@ function planRow(plan: WixPlan): PlanRow {
130
127
  const trialDays = plan.pricing?.freeTrialDays ?? 0;
131
128
  const trialBadge = trialDays > 0 ? copyTemplate("plans.trialBadge", "{days}-day free trial", { days: trialDays }) : "";
132
129
  return {
133
- id: plan.id || name,
130
+ id: plan.id || "",
134
131
  name,
135
132
  description: (plan.description ?? "").trim(),
136
133
  trialBadge,
@@ -145,6 +142,8 @@ function planRow(plan: WixPlan): PlanRow {
145
142
  noBadge: !badge,
146
143
  hasBadge: !!badge,
147
144
  joinUrl: joinUrl(plan),
145
+ appleSupported: plan.pricing?.singlePaymentUnlimited === true,
146
+ joined: false, joinLabel: copyParam("plans.joinCta", "Join"), joinDisabled: true, joinNote: "",
148
147
  };
149
148
  }
150
149
 
@@ -170,9 +169,97 @@ function idOf(arg: unknown): string {
170
169
  return typeof value === "string" ? value : "";
171
170
  }
172
171
 
172
+ const commerce = createIapCommerce({ adapters: [pricingPlanOrders], onChange: () => { void svc.pricingPublish(); } });
173
+ let rows: PlanRow[] = [];
174
+ let items: IapCatalogItem[] = [];
175
+ let message = "";
176
+ let started = false;
177
+ let loading = 0;
178
+ let contentReady = false;
179
+ const joining = new Set<string>();
180
+ const memberSession = hostSessionFromGlobal();
181
+ let regularGeneration = -1;
182
+ let regularAccess = new Set<string>();
183
+
184
+
185
+ function pricedState(state: State): State {
186
+ if (!contentReady) return state;
187
+ const priced = rows.map(row => {
188
+ const reference = { kind: "pricingPlan", id: row.id };
189
+ const view = commerce.pricing.view(reference);
190
+ const price = iapPriceFields(view, iapPriceCopy("plans"));
191
+ const purchase = commerce.purchaseFields(reference, iapPurchaseCopy("plans"));
192
+ const apple = purchase.appleRoute;
193
+ const regularReady = regularGeneration === memberSession.current().generation && (!memberSession.identityBound || memberSession.current().kind === "member");
194
+ const joined = apple ? purchase.accessReady : regularReady && regularAccess.has(row.id);
195
+ return { ...row, priceLine: view.route === "disabled" ? row.priceLine : price.priceText,
196
+ periodLine: apple ? price.priceNote : view.route === "regular" || view.route === "disabled" ? row.periodLine : "",
197
+ trialBadge: apple || price.actionDisabled ? "" : row.trialBadge,
198
+ noTrialBadge: apple || price.actionDisabled || row.noTrialBadge,
199
+ joined,
200
+ joinLabel: joined ? copyParam("plans.joinedLabel", "Current plan") : apple ? purchase.buyLabel : row.joinLabel,
201
+ joinDisabled: joined || (apple ? purchase.buyDisabled || !row.appleSupported : price.actionDisabled || !regularReady) || joining.has(row.id),
202
+ joinNote: apple ? row.appleSupported ? purchase.buyNote : iapPriceCopy("plans").purchaseUnavailable : price.actionNote };
203
+ });
204
+ const owned = priced.find(row => row.joined);
205
+ const poll = commerce.pollDirective("himiBrandedPlans.iapPoll");
206
+ return { ...state, ...ready(priced, owned ? copyTemplate("plans.joinedMessage", "You've joined {name}.", { name: owned.name }) : message), ...(poll ? { _poll: poll } : {}) };
207
+ }
208
+
209
+ async function regularJoinMessage(row: PlanRow, reference: { kind: string; id: string }): Promise<string> {
210
+ const generation = memberSession.current().generation;
211
+ if (row.joinUrl) {
212
+ try {
213
+ const result = await openUrl(row.joinUrl, "in_app_browser");
214
+ if (result.opened) { return ""; }
215
+ } catch {
216
+ }
217
+ }
218
+ try {
219
+ const created = await createOnlineOrder(row.id);
220
+ const snapshotId = created.purchaseFlowId;
221
+ if (generation !== memberSession.current().generation) return "";
222
+ if (created.order?.status === "ACTIVE") {
223
+ regularAccess.add(row.id);
224
+ return copyTemplate("plans.joinedMessage", "You've joined {name}.", { name: row.name });
225
+ }
226
+ const pay = await payCashierOrder({
227
+ appId: WIX_PRICING_PLANS_APP_ID,
228
+ snapshotId: snapshotId ?? "",
229
+ msid: await resolveMsid(),
230
+ mayCharge: () => commerce.pricing.continueCheckoutGuard(reference).allowed,
231
+ cashierUrl: async () => {
232
+ if (!commerce.pricing.continueCheckoutGuard(reference).allowed) return undefined;
233
+ const redirect = await createRedirectSession({
234
+ paidPlansCheckout: { planId: row.id },
235
+ callbacks: { postFlowUrl: "himalaya-himi-branded-lite://screen/himi_plans" },
236
+ });
237
+ return redirect.redirectSession?.fullUrl;
238
+ },
239
+ });
240
+ if (pay.kind === "paid") {
241
+ const access = await entitlement(row.id);
242
+ if (generation !== memberSession.current().generation) return "";
243
+ if (access.entitled) regularAccess.add(row.id);
244
+ return access.entitled
245
+ ? copyTemplate("plans.joinedMessage", "You've joined {name}.", { name: row.name })
246
+ : copyParam("plans.purchaseProcessingText", "Processing your purchase…");
247
+ }
248
+ if (pay.kind === "handoff") { return ""; }
249
+ if (pay.kind === "cancelled") {
250
+ return copyParam("plans.joinCancelledMessage", "Payment cancelled — you haven't joined.");
251
+ }
252
+ } catch {
253
+ }
254
+ return copyTemplate("plans.joinOnSiteMessage", "Memberships are purchased on the website — visit the site to join {name}.", { name: row.name });
255
+ }
256
+
173
257
  const svc = defineService<State, {
174
- load: (state: State, arg?: unknown) => Promise<State>;
175
- join: (state: State, arg?: unknown) => Promise<State>;
258
+ load: (state: State, arg?: unknown) => Promise<State | undefined>;
259
+ pricingPublish: (state: State) => State;
260
+ pricingRefresh: (state: State) => Promise<undefined>;
261
+ iapPoll: (state: State) => Promise<undefined>;
262
+ join: (state: State, arg?: unknown) => Promise<State | undefined>;
176
263
  }>("himiBrandedPlans", {
177
264
  state: {
178
265
  status: "loading",
@@ -187,13 +274,38 @@ const svc = defineService<State, {
187
274
  noRows: true,
188
275
  noEmpty: true,
189
276
  },
277
+ notHydrated: ["rows", "actionMessage"],
190
278
  actions: {
279
+ pricingPublish: pricedState,
280
+ pricingRefresh: async () => { await commerce.loadPrices(items); return undefined; },
281
+ iapPoll: async () => { await commerce.poll(); void svc.pricingPublish(); return undefined; },
191
282
  load: async (state, arg) => {
192
283
  stashCopy(arg);
284
+ const seq = ++loading;
285
+ const generation = memberSession.current().generation;
286
+ regularGeneration = -1;
287
+ regularAccess = new Set();
193
288
  try {
194
289
  const response = await listPublicPlans();
195
- return ready((response.plans ?? []).filter((plan) => !plan.archived).map(planRow));
290
+ if (seq !== loading) return undefined;
291
+ message = "";
292
+ contentReady = true;
293
+ rows = (response.plans ?? []).filter(plan => !plan.archived).map(planRow);
294
+ items = rows.map(row => ({ reference: { kind: "pricingPlan", id: row.id }, commerceDisplayPrice: row.priceLine }));
295
+ void svc.pricingPublish();
296
+ const access = rows.length > 0 && memberSession.current().kind === "member" ? await activePlanIds() : [];
297
+ if (seq !== loading || generation !== memberSession.current().generation) return undefined;
298
+ regularAccess = new Set(access);
299
+ regularGeneration = generation;
300
+ await commerce.loadPrices(items);
301
+ await (started ? commerce.reconcile() : commerce.start());
302
+ if (seq !== loading || generation !== memberSession.current().generation) return undefined;
303
+ started = true;
304
+ void svc.pricingPublish();
305
+ return undefined;
196
306
  } catch {
307
+ if (seq !== loading || generation !== memberSession.current().generation) return undefined;
308
+ contentReady = false;
197
309
  // Bridge errors (e.g. a 501 from a serve bridge without the route) land
198
310
  // here — a graceful error card with retry, never a dead screen.
199
311
  return {
@@ -209,57 +321,26 @@ const svc = defineService<State, {
209
321
  },
210
322
  join: async (state, arg) => {
211
323
  const row = state.rows.find((candidate) => candidate.id === idOf(arg));
212
- if (!row) return state;
213
- // A plan-carried purchasable URL still takes precedence (defensive: no
214
- // documented field emits one today, but if a future payload does we honor
215
- // it before minting a redirect session).
216
- if (row.joinUrl) {
217
- try {
218
- const result = await openUrl(row.joinUrl, "in_app_browser");
219
- if (result.opened) return { ...state, actionMessage: "" };
220
- } catch {
221
- // fall through to the redirect-session rail below
222
- }
324
+ if (!row || joining.has(row.id)) return undefined;
325
+ const reference = { kind: "pricingPlan", id: row.id };
326
+ const purchase = commerce.purchaseFields(reference, iapPurchaseCopy("plans"));
327
+ if (purchase.accessReady) return undefined;
328
+ if (purchase.appleRoute) {
329
+ if (!row.appleSupported) return undefined;
330
+ await commerce.buy({ reference });
331
+ void svc.pricingPublish();
332
+ return undefined;
223
333
  }
224
- // Phase 2b — NATIVE join: create the member's plan order, then settle any
225
- // balance on the shared in-app pay rail. A FREE (or fully-discounted) plan comes
226
- // back active with no `purchaseFlowId` and needs no payment step at all, which is
227
- // the case the hosted redirect used to make needlessly heavy.
334
+ if (regularGeneration !== memberSession.current().generation || (memberSession.identityBound && memberSession.current().kind !== "member") || regularAccess.has(row.id) || !commerce.pricing.checkoutGuard(reference).allowed) return undefined;
335
+ joining.add(row.id);
336
+ void svc.pricingPublish();
228
337
  try {
229
- const created = await createOnlineOrder(row.id);
230
- const snapshotId = created.purchaseFlowId;
231
- if (!snapshotId) {
232
- // Nothing to charge ⇒ the membership is already active.
233
- return { ...state, actionMessage: copyTemplate("plans.joinedMessage", "You've joined {name}.", { name: row.name }) };
234
- }
235
- const pay = await payCashierOrder({
236
- appId: WIX_PRICING_PLANS_APP_ID,
237
- snapshotId,
238
- msid: await resolveMsid(),
239
- // Wallet-less apps keep the hosted plans checkout, IN-APP — so a site with no
240
- // provisioned wallet can still sell memberships (see @wix/himalaya/checkout).
241
- cashierUrl: async () => {
242
- const redirect = await createRedirectSession({
243
- paidPlansCheckout: { planId: row.id },
244
- callbacks: { postFlowUrl: "himalaya-himi-branded-lite://screen/himi_plans" },
245
- });
246
- return redirect.redirectSession?.fullUrl;
247
- },
248
- });
249
- if (pay.kind === "paid") {
250
- return { ...state, actionMessage: copyTemplate("plans.joinedMessage", "You've joined {name}.", { name: row.name }) };
251
- }
252
- if (pay.kind === "handoff") return { ...state, actionMessage: "" };
253
- if (pay.kind === "cancelled") {
254
- return { ...state, actionMessage: copyParam("plans.joinCancelledMessage", "Payment cancelled — you haven't joined.") };
255
- }
256
- } catch {
257
- // Order/pay failure → graceful site-directing fallback below.
338
+ message = await regularJoinMessage(row, reference);
339
+ return undefined;
340
+ } finally {
341
+ joining.delete(row.id);
342
+ void svc.pricingPublish();
258
343
  }
259
- return {
260
- ...state,
261
- actionMessage: copyTemplate("plans.joinOnSiteMessage", "Memberships are purchased on the website — visit the site to join {name}.", { name: row.name }),
262
- };
263
344
  },
264
345
  },
265
346
  });
@@ -270,5 +351,13 @@ const svc = defineService<State, {
270
351
  // loading skeleton indefinitely on iOS. onAppear still fires afterwards and
271
352
  // re-loads with the screen's copy pack; it passes no required params here
272
353
  // (`appear(..., undefined, "<prefix>.")`), so a param-less first load is safe.
354
+ resetUnhydratedKeysOnOlderRuntime(svc, { ...svc.getState(), rows: [], actionMessage: "" }, ["rows", "actionMessage"]);
355
+ memberSession.subscribe(() => {
356
+ message = "";
357
+ regularGeneration = -1;
358
+ regularAccess = new Set();
359
+ void svc.pricingPublish();
360
+ void svc.load();
361
+ });
273
362
  svc.load();
274
363
  export { svc };
@@ -1,5 +1,7 @@
1
1
  // Tier-5 bundle: himi-store — product detail.
2
- import { defineService } from "@himalaya/state";
2
+ import { withIapPresentation } from "../_shared/iap-presentation.js";
3
+ import { type IapCommerce } from "@himalaya/iap-checkout";
4
+ import { iapPriceCopy } from "../_shared/iap-worker.js";
3
5
  import { share as shareSheet } from "@himalaya/share";
4
6
  import { addToCurrentCart, getCurrentCart } from "@himalaya/wix-ecom";
5
7
  import { get, upsert, enqueue, type DbEntity } from "@himalaya/local-db";
@@ -74,6 +76,7 @@ let lastProduct: StoreProduct | undefined;
74
76
  * `refreshServices()` → `load()`, so this is what tells a genuine navigation to a
75
77
  * DIFFERENT product (drop the picks) from the same product coming back (keep them). */
76
78
  let lastLoadedId: string | undefined;
79
+ let productLoadSequence = 0;
77
80
 
78
81
  interface OptionSlots {
79
82
  opt1Label: string; opt1Chips: OptionChip[]; opt1Selected: string[]; noOpt1: boolean;
@@ -136,6 +139,7 @@ interface S {
136
139
  status: "loading" | "ready" | "error";
137
140
  name: string;
138
141
  price: string;
142
+ commercePrice?: string;
139
143
  /** The plain-text description: RichContent's fallback, and what shows with no document. */
140
144
  description: string;
141
145
  /** [rico] #3372 — a V3 product's Ricos `description` (read with `fields=DESCRIPTION`) as ONE document string
@@ -305,6 +309,14 @@ function derive(s: S): S {
305
309
  // read answering late — must not replace the badge the add set (#3468 review).
306
310
  let cartBadgeEpoch = 0;
307
311
 
312
+ let productPricing: IapCommerce;
313
+ const defineService = withIapPresentation<S>({
314
+ onCreate: commerce => { productPricing = commerce; }, prefix: "product", transientKeys: ["price", "optionsIncomplete"], contentActions: ["load", "paintCached"],
315
+ items: state => inputId() ? [{ reference: { kind: "storesProduct", id: inputId() }, commerceDisplayPrice: state.price }] : [],
316
+ present: (state, price, allowed) => ({ ...state, price: price(inputId()), optionsIncomplete: derive(state).optionsIncomplete || !allowed(inputId()) || lastProduct?.productType?.toLowerCase() === "digital",
317
+ cartHint: lastProduct?.productType?.toLowerCase() === "digital" || (!state.optionsUnknown && !allowed(inputId())) ? iapPriceCopy("product").purchaseUnavailable : state.cartHint === iapPriceCopy("product").purchaseUnavailable ? "" : state.cartHint }),
318
+ });
319
+
308
320
  const svc = defineService<S, {
309
321
  load: (s: S, arg?: unknown) => Promise<S>;
310
322
  setQuantity: (s: S, arg?: unknown) => S;
@@ -365,8 +377,10 @@ const svc = defineService<S, {
365
377
  opt3Label: "", opt3Chips: [], opt3Selected: [], noOpt3: true,
366
378
  }),
367
379
  actions: {
368
- load: async (s, arg) => {
380
+ load: async (s, arg): Promise<S> => {
369
381
  stashCopy(arg);
382
+ const sequence = ++productLoadSequence;
383
+ lastProduct = undefined;
370
384
  const id = inputId();
371
385
  // Storage problems must not prevent browsing or buying a product.
372
386
  let saved = false;
@@ -403,6 +417,7 @@ const svc = defineService<S, {
403
417
  painted = true;
404
418
  }
405
419
  const outcome = await live;
420
+ if (sequence !== productLoadSequence || id !== inputId()) return svc.getState() as S;
406
421
  // Once painted the page is INTERACTIVE while the live read is on the wire: the visitor may
407
422
  // save, change the quantity or add to cart. Build on the state as it is NOW, and keep what
408
423
  // they did — overwriting it would untick a stored save, reset the quantity, and drop the
@@ -448,6 +463,7 @@ const svc = defineService<S, {
448
463
  optionsUnknown: false,
449
464
  name,
450
465
  price: productPrice(p),
466
+ commercePrice: productPrice(p),
451
467
  ...productBody(p),
452
468
  image,
453
469
  gallery: productGallery(p),
@@ -543,6 +559,7 @@ const svc = defineService<S, {
543
559
  status: "ready",
544
560
  name,
545
561
  price: String(row.subtitle ?? ""),
562
+ commercePrice: String(row.subtitle ?? ""),
546
563
  description: "",
547
564
  descriptionJson: "",
548
565
  image: String(row.image ?? ""),
@@ -559,6 +576,7 @@ const svc = defineService<S, {
559
576
  },
560
577
  addToCart: async (s) => {
561
578
  const id = inputId();
579
+ if (!productPricing.pricing.checkoutGuard({ kind: "storesProduct", id }).allowed || lastProduct?.productType?.toLowerCase() === "digital") return derive({ ...s, cartHint: iapPriceCopy("product").purchaseUnavailable });
562
580
  if (!id) return derive({ ...s, cartHint: copyParam("product.cartHintNoProduct", "No product to add.") });
563
581
  // A second tap while the first write is in flight must not add the line twice.
564
582
  if (s.adding) return s;
@@ -571,6 +589,9 @@ const svc = defineService<S, {
571
589
  ? copyParam("product.cartHintOffline", "Reconnect to choose options and add this to your cart.")
572
590
  : optionsHint(s.optionGroups, s.selection) || copyParam("product.cartHintNoVariant", "That combination is not available. Try another.") });
573
591
  }
592
+ if (lastLoadedId !== id || lastProduct?.productType?.toLowerCase() !== "physical") {
593
+ return derive({ ...s, cartHint: copyParam("product.cartHintOffline", "Reconnect to choose options and add this to your cart.") });
594
+ }
574
595
  // Which variant the choice resolves to is the SDK's job — V3 addresses the
575
596
  // choice by variantId, V1 by a name→choice map, and it throws rather than
576
597
  // hand back a payload that would add a line with no variant.
@@ -613,7 +634,7 @@ const svc = defineService<S, {
613
634
  toggleSave: (s) => {
614
635
  if (s.status !== "ready" || !s.name || !inputId()) return s;
615
636
  try {
616
- const saved = toggleSavedProduct({ id: inputId(), name: s.name, price: s.price, image: s.image });
637
+ const saved = toggleSavedProduct({ id: inputId(), name: s.name, price: lastProduct ? productPrice(lastProduct) : s.commercePrice ?? "", image: s.image });
617
638
  return derive({ ...s, saved, saveHint: saved
618
639
  ? copyParam("product.saveSuccess", "Saved on this device.")
619
640
  : copyParam("product.saveRemoved", "Removed from saved products.") });
@@ -1,9 +1,9 @@
1
- import { defineService } from "@himalaya/state";
1
+ import { withIapPresentation } from "../_shared/iap-presentation.js";
2
2
  import { withEmptyFlags } from "../_shared/empty-flags.js";
3
3
  import { readSavedProducts, removeSavedProduct, type SavedProduct } from "../_shared/himi-store-saved.js";
4
4
  import { stashCopy, copyParam } from "../_shared/himi-copy.js";
5
5
 
6
- interface Row extends SavedProduct { initial: string; noImage: boolean; hasImage: boolean; detailScreenId: string }
6
+ interface Row extends SavedProduct { commercePrice?: string; initial: string; noImage: boolean; hasImage: boolean; detailScreenId: string }
7
7
  interface S {
8
8
  status: "loading" | "ready" | "error";
9
9
  rows: Row[];
@@ -18,11 +18,16 @@ function shape(s: Pick<S, "status" | "rows" | "error">): S {
18
18
  return { ...s, isLoading: s.status === "loading", notLoading: s.status !== "loading", notErrored: s.status !== "error", noEmpty: s.status !== "ready" || s.rows.length > 0, emptyText: copyParam("savedProducts.empty", "Save a product to find it here later.") };
19
19
  }
20
20
  function loadRows(): Row[] {
21
- return readSavedProducts().map(row => ({ ...row, initial: row.name.slice(0, 1).toUpperCase(), hasImage: !!row.image, noImage: !row.image, detailScreenId: "himi_product" }));
21
+ return readSavedProducts().map(row => ({ ...row, commercePrice: row.price, initial: row.name.slice(0, 1).toUpperCase(), hasImage: !!row.image, noImage: !row.image, detailScreenId: "himi_product" }));
22
22
  }
23
23
  function failed(s: S): S {
24
24
  return shape({ ...s, status: "error", error: copyParam("savedProducts.error", "Could not update saved products. Try again.") });
25
25
  }
26
+ const defineService = withIapPresentation<S>({
27
+ prefix: "savedProducts", transientKeys: ["rows"], contentActions: ["load", "remove"],
28
+ items: state => state.rows.map(row => ({ reference: { kind: "storesProduct", id: row.id }, commerceDisplayPrice: row.commercePrice ?? row.price })),
29
+ present: (state, price) => ({ ...state, rows: state.rows.map(row => ({ ...row, price: price(row.id, row.commercePrice ?? row.price) })) }),
30
+ });
26
31
  const svc = defineService<S, {
27
32
  load: (s: S, arg?: unknown) => S;
28
33
  remove: (s: S, arg?: unknown) => S;