@wix/himalaya-cli 0.1494.0 → 0.1495.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 (79) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/builder.d.ts +3 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/live-activity/src/index.d.ts +4 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +2 -0
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/ecom/src/extensions.d.ts +16 -0
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/stores-v1/src/display.d.ts +24 -0
  6. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/live-activity.mjs +3 -1
  7. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/sdui.mjs +1 -1
  8. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-ecom.mjs +14 -0
  9. package/dist/cli.mjs +3584 -2540
  10. package/dist/preview-assets.generated.bin +0 -0
  11. package/guides/himi-json-output.md +46 -4
  12. package/guides/iap-products.md +68 -9
  13. package/guides/one-app-to-himi-app.md +80 -5
  14. package/guides/sdk-reference.json +11 -256
  15. package/guides/widgets.md +5 -3
  16. package/package.json +2 -2
  17. package/templates/beauty-salon/dev/index.ts +2590 -1034
  18. package/templates/beauty-salon/himalaya.content.json +1 -1
  19. package/templates/beauty-salon/tier5-src/_shared/himi-store-collection.ts +6 -3
  20. package/templates/beauty-salon/tier5-src/_shared/himi-store-saved.ts +7 -1
  21. package/templates/beauty-salon/tier5-src/himi-branded-lite-cart/index.ts +256 -19
  22. package/templates/beauty-salon/tier5-src/himi-store-product/index.ts +196 -34
  23. package/templates/beauty-salon/tier5-src/himi-store-saved/index.ts +21 -5
  24. package/templates/beauty-salon/tier5-src/himi-store-shop/index.ts +14 -2
  25. package/templates/beauty-salon/tokens.json +15 -0
  26. package/templates/branded-lite/dev/index.ts +2696 -1140
  27. package/templates/branded-lite/himalaya.content.json +1 -1
  28. package/templates/branded-lite/tier5-src/_shared/himi-store-collection.ts +6 -3
  29. package/templates/branded-lite/tier5-src/_shared/himi-store-saved.ts +7 -1
  30. package/templates/branded-lite/tier5-src/himi-branded-lite-cart/index.ts +256 -19
  31. package/templates/branded-lite/tier5-src/himi-store-product/index.ts +196 -34
  32. package/templates/branded-lite/tier5-src/himi-store-saved/index.ts +21 -5
  33. package/templates/branded-lite/tier5-src/himi-store-shop/index.ts +14 -2
  34. package/templates/branded-lite/tokens.json +8 -0
  35. package/templates/community-club/dev/index.ts +1 -1
  36. package/templates/community-club/tier5-src/_shared/himi-store-saved.ts +7 -1
  37. package/templates/community-club/tokens.json +15 -0
  38. package/templates/events-meetups/dev/index.ts +1 -1
  39. package/templates/events-meetups/tier5-src/_shared/himi-store-saved.ts +7 -1
  40. package/templates/events-meetups/tokens.json +15 -0
  41. package/templates/gym-fitness/dev/index.ts +2695 -1139
  42. package/templates/gym-fitness/himalaya.content.json +1 -1
  43. package/templates/gym-fitness/tier5-src/_shared/himi-store-collection.ts +6 -3
  44. package/templates/gym-fitness/tier5-src/_shared/himi-store-saved.ts +7 -1
  45. package/templates/gym-fitness/tier5-src/himi-branded-lite-cart/index.ts +256 -19
  46. package/templates/gym-fitness/tier5-src/himi-store-product/index.ts +196 -34
  47. package/templates/gym-fitness/tier5-src/himi-store-saved/index.ts +21 -5
  48. package/templates/gym-fitness/tier5-src/himi-store-shop/index.ts +14 -2
  49. package/templates/gym-fitness/tokens.json +15 -0
  50. package/templates/nonprofit-cause/dev/index.ts +1 -1
  51. package/templates/nonprofit-cause/tier5-src/_shared/himi-store-saved.ts +7 -1
  52. package/templates/nonprofit-cause/tokens.json +15 -0
  53. package/templates/podcast-creator/dev/index.ts +1 -1
  54. package/templates/podcast-creator/tier5-src/_shared/himi-store-saved.ts +7 -1
  55. package/templates/podcast-creator/tokens.json +15 -0
  56. package/templates/portfolio-creative/dev/index.ts +2569 -1013
  57. package/templates/portfolio-creative/himalaya.content.json +1 -1
  58. package/templates/portfolio-creative/tier5-src/_shared/himi-store-collection.ts +6 -3
  59. package/templates/portfolio-creative/tier5-src/_shared/himi-store-saved.ts +7 -1
  60. package/templates/portfolio-creative/tier5-src/himi-branded-lite-cart/index.ts +256 -19
  61. package/templates/portfolio-creative/tier5-src/himi-store-product/index.ts +196 -34
  62. package/templates/portfolio-creative/tier5-src/himi-store-saved/index.ts +21 -5
  63. package/templates/portfolio-creative/tier5-src/himi-store-shop/index.ts +14 -2
  64. package/templates/portfolio-creative/tokens.json +15 -0
  65. package/templates/restaurant/dev/index.ts +1 -1
  66. package/templates/restaurant/tier5-src/_shared/himi-store-saved.ts +7 -1
  67. package/templates/restaurant/tokens.json +15 -0
  68. package/templates/retail-store/dev/index.ts +2541 -985
  69. package/templates/retail-store/himalaya.content.json +1 -1
  70. package/templates/retail-store/tier5-src/_shared/himi-store-collection.ts +6 -3
  71. package/templates/retail-store/tier5-src/_shared/himi-store-saved.ts +7 -1
  72. package/templates/retail-store/tier5-src/himi-branded-lite-cart/index.ts +256 -19
  73. package/templates/retail-store/tier5-src/himi-store-product/index.ts +196 -34
  74. package/templates/retail-store/tier5-src/himi-store-saved/index.ts +21 -5
  75. package/templates/retail-store/tier5-src/himi-store-shop/index.ts +14 -2
  76. package/templates/retail-store/tokens.json +15 -0
  77. package/templates/yoga-studio/dev/index.ts +1 -1
  78. package/templates/yoga-studio/tier5-src/_shared/himi-store-saved.ts +7 -1
  79. package/templates/yoga-studio/tokens.json +15 -0
@@ -48,6 +48,8 @@ interface GalleryItem {
48
48
  interface OptionChip {
49
49
  id: string;
50
50
  label: string;
51
+ swatch?: string;
52
+ disabled?: boolean;
51
53
  }
52
54
 
53
55
  // ---------------------------------------------------------------------------
@@ -72,6 +74,20 @@ const OPTION_SLOTS = 3;
72
74
  * payload is resolved from `variantsInfo`, which no renderer needs to see. */
73
75
  let lastProduct: StoreProduct | undefined;
74
76
 
77
+ /** Match only merchant-authored product sections with a clear policy title. */
78
+ function productPolicyBody(product: StoreProduct, kind: "returns" | "shipping"): string {
79
+ const sections = [...(product.infoSections ?? []), ...(product.additionalInfoSections ?? [])];
80
+ const section = sections.find((item) => {
81
+ const name = `${item.title ?? ""} ${"uniqueName" in item ? item.uniqueName ?? "" : ""}`;
82
+ return kind === "returns" ? /return|refund/i.test(name) : /shipping|delivery/i.test(name);
83
+ });
84
+ if (!section) return "";
85
+ const html = "plainDescription" in section ? section.plainDescription : undefined;
86
+ if (html) return plainText(html.replace(/ /gi, " "));
87
+ if (typeof section.description === "string") return plainText(section.description.replace(/ /gi, " "));
88
+ return richBody(section.description).bodyText;
89
+ }
90
+
75
91
  /** The product id `lastProduct` was read for. A pooled screen re-appearing calls
76
92
  * `refreshServices()` → `load()`, so this is what tells a genuine navigation to a
77
93
  * DIFFERENT product (drop the picks) from the same product coming back (keep them). */
@@ -79,23 +95,31 @@ let lastLoadedId: string | undefined;
79
95
  let productLoadSequence = 0;
80
96
 
81
97
  interface OptionSlots {
82
- opt1Label: string; opt1Chips: OptionChip[]; opt1Selected: string[]; noOpt1: boolean;
83
- opt2Label: string; opt2Chips: OptionChip[]; opt2Selected: string[]; noOpt2: boolean;
84
- opt3Label: string; opt3Chips: OptionChip[]; opt3Selected: string[]; noOpt3: boolean;
98
+ opt1Label: string; opt1Chips: OptionChip[]; opt1Selected: string[]; opt1SelectedId: string; opt1Placeholder: string; noOpt1: boolean;
99
+ opt2Label: string; opt2Chips: OptionChip[]; opt2Selected: string[]; opt2SelectedId: string; opt2Placeholder: string; noOpt2: boolean;
100
+ opt3Label: string; opt3Chips: OptionChip[]; opt3Selected: string[]; opt3SelectedId: string; opt3Placeholder: string; noOpt3: boolean;
101
+ opt1Error: string; noOpt1Error: boolean; hasOpt1Error: boolean; opt2Error: string; noOpt2Error: boolean; hasOpt2Error: boolean; opt3Error: string; noOpt3Error: boolean; hasOpt3Error: boolean;
85
102
  }
86
103
 
87
- function slotsOf(groups: StoreOption[], selection: StoreOptionSelection): OptionSlots {
104
+ function slotsOf(groups: StoreOption[], selection: StoreOptionSelection, showErrors: boolean): OptionSlots {
88
105
  const out = {} as Record<string, unknown>;
89
106
  for (let i = 0; i < OPTION_SLOTS; i++) {
90
107
  const g = groups[i];
91
108
  const n = i + 1;
92
109
  const chosen = g ? selection[g.id] ?? "" : "";
93
- out[`opt${n}Label`] = g?.name ?? "";
110
+ const displayName = g?.name ? g.name.charAt(0).toUpperCase() + g.name.slice(1).toLowerCase() : "";
111
+ out[`opt${n}Label`] = displayName ? copyTemplate("product.optionLabel", "{option}:", { option: displayName }) : "";
94
112
  // Out-of-stock choices stay selectable but say so — hiding them makes a product
95
113
  // look like it has fewer colours than it does, and the stock flag is per-choice
96
114
  // across variants, not a promise about the combination.
97
- out[`opt${n}Chips`] = (g?.choices ?? []).map((c) => ({ id: c.id, label: c.inStock ? c.label : `${c.label} · ${copyParam("product.optionOutOfStock", "Out of stock")}` }));
115
+ out[`opt${n}Chips`] = (g?.choices ?? []).map((c) => ({ id: c.id, label: c.label, ...(g.kind === "color" && c.colorCode ? { swatch: c.colorCode } : {}), disabled: c.inStock === false }));
98
116
  out[`opt${n}Selected`] = chosen ? [chosen] : [];
117
+ out[`opt${n}SelectedId`] = chosen;
118
+ out[`opt${n}Placeholder`] = g?.choices.find(c => c.id === chosen)?.label ?? copyTemplate("product.optionPlaceholder", "Choose {option}", { option: displayName || "option" });
119
+ const error = showErrors && g && !chosen ? copyTemplate("product.optionRequired", "Choose a {option}", { option: g.name.toLowerCase() }) : "";
120
+ out[`opt${n}Error`] = error;
121
+ out[`noOpt${n}Error`] = !error;
122
+ out[`hasOpt${n}Error`] = !!error;
99
123
  out[`noOpt${n}`] = !g;
100
124
  }
101
125
  return out as unknown as OptionSlots;
@@ -135,10 +159,76 @@ function productBody(p: StoreProduct): { description: string; descriptionJson: s
135
159
  return { description: html || bodyText, descriptionJson: bodyJson };
136
160
  }
137
161
 
162
+ function productSale(p: StoreProduct): { price: string; wasPrice: string } {
163
+ const raw = p as unknown as { priceData?: Record<string, unknown> };
164
+ const pd = raw.priceData ?? {};
165
+ const formatted = (pd.formatted ?? {}) as Record<string, unknown>;
166
+ const regular = productPrice(p);
167
+ if (p.compareAtPriceRange?.minValue) {
168
+ const original = productPrice({ ...p, price: undefined, priceData: undefined, actualPriceRange: p.compareAtPriceRange });
169
+ if (original && original !== regular) return { price: regular, wasPrice: original };
170
+ }
171
+ const rawRegular = typeof formatted.price === "string" ? formatted.price : regular;
172
+ const saleRaw = formatted.discountedPrice ?? pd.discountedPrice ?? pd.salePrice;
173
+ const sale = typeof saleRaw === "string" ? saleRaw : "";
174
+ const saleDisplay = sale ? productPrice({ ...p, priceData: { ...pd, formatted: { ...formatted, price: sale } } } as StoreProduct) : "";
175
+ return sale && sale !== rawRegular ? { price: saleDisplay, wasPrice: regular } : { price: regular, wasPrice: "" };
176
+ }
177
+
178
+ /** The optimistic cart has no Wix-calculated line price yet. Multiply the formatted
179
+ * unit amount while preserving its symbol, separators and decimal precision. */
180
+ function optimisticLineTotal(unitPrice: string, quantity: number): string {
181
+ if (quantity <= 1) return unitPrice;
182
+ const match = /\d(?:[\d.,]|[ \u00a0\u202f](?=\d))*/u.exec(unitPrice);
183
+ if (!match) return "";
184
+ const raw = match[0];
185
+ const compact = raw.replace(/[ \u00a0\u202f]/g, "");
186
+ const last = Math.max(compact.lastIndexOf("."), compact.lastIndexOf(","));
187
+ const fractionalDigits = last >= 0 ? compact.length - last - 1 : 0;
188
+ const decimal = fractionalDigits > 0 && fractionalDigits <= 2 ? compact[last] : "";
189
+ const punctuationGrouping = decimal === "." ? "," : decimal === "," ? "." : compact.includes(",") ? "," : compact.includes(".") ? "." : "";
190
+ const grouping = raw.match(/[ \u00a0\u202f](?=\d)/u)?.[0] ?? punctuationGrouping;
191
+ const amount = Number(compact.replaceAll(punctuationGrouping || "\u0000", "").replace(decimal || "\u0000", "."));
192
+ if (!Number.isFinite(amount)) return "";
193
+ const fixed = (amount * quantity).toFixed(decimal ? fractionalDigits : 0);
194
+ const [whole, fraction] = fixed.split(".");
195
+ const grouped = grouping ? whole.replace(/\B(?=(\d{3})+(?!\d))/g, grouping) : whole;
196
+ return unitPrice.replace(raw, `${grouped}${decimal ? decimal + fraction : ""}`);
197
+ }
198
+
199
+ function skuOf(p: StoreProduct): string {
200
+ const raw = p as unknown as { sku?: unknown; variantsInfo?: { variants?: Array<{ sku?: unknown }> } };
201
+ if (typeof raw.sku === "string" && raw.sku.trim()) return raw.sku.trim();
202
+ const sku = raw.variantsInfo?.variants?.find(v => typeof v.sku === "string" && v.sku.trim())?.sku;
203
+ return typeof sku === "string" ? sku.trim() : "";
204
+ }
205
+
138
206
  interface S {
139
207
  status: "loading" | "ready" | "error";
140
208
  name: string;
141
209
  price: string;
210
+ wasPrice: string;
211
+ notOnSale: boolean;
212
+ wasPriceEmpty: boolean;
213
+ noSku: boolean;
214
+ skuLabel: string;
215
+ returnPolicyBody: string;
216
+ shippingInfoBody: string;
217
+ noReturnPolicyBody: boolean;
218
+ noShippingInfoBody: boolean;
219
+ returnPolicyCollapsed: boolean;
220
+ shippingInfoCollapsed: boolean;
221
+ noSaveHint: boolean;
222
+ saveIcon: string;
223
+ optionsSummary: string;
224
+ quantityLabel: string;
225
+ quantityIsOne: boolean;
226
+ addedSheetPresented: boolean;
227
+ shareHint: string;
228
+ noShareHint: boolean;
229
+ noCartHint: boolean;
230
+ hideBar: boolean;
231
+ hideProduct: boolean;
142
232
  commercePrice?: string;
143
233
  /** The plain-text description: RichContent's fallback, and what shows with no document. */
144
234
  description: string;
@@ -147,6 +237,8 @@ interface S {
147
237
  descriptionJson: string;
148
238
  /** descriptionJson !== "" — gates the plain text, because RichContent draws nothing for "". */
149
239
  hasDescriptionJson: boolean;
240
+ returnPolicyExpanded: boolean;
241
+ shippingInfoExpanded: boolean;
150
242
  image: string;
151
243
  /** FULL media gallery (media.items[] with the main image first) — the detail
152
244
  * screen renders a Carousel over it when 2+ images exist. */
@@ -194,14 +286,17 @@ interface S {
194
286
  noOptionsOverflow: boolean;
195
287
  /** Add to cart is blocked: this product has options and they are not all chosen. */
196
288
  optionsIncomplete: boolean;
289
+ showOptionErrors: boolean;
290
+ addDisabled: boolean;
197
291
  /** The product was served from the offline cache, which carries no option groups.
198
292
  * "No groups" here means UNKNOWN, not "none" — so the CTA must stay blocked rather
199
293
  * than offer a one-tap add for a product that may need a colour and a size. */
200
294
  optionsUnknown: boolean;
201
295
  optionsHint: string;
202
- opt1Label: string; opt1Chips: OptionChip[]; opt1Selected: string[]; noOpt1: boolean;
203
- opt2Label: string; opt2Chips: OptionChip[]; opt2Selected: string[]; noOpt2: boolean;
204
- opt3Label: string; opt3Chips: OptionChip[]; opt3Selected: string[]; noOpt3: boolean;
296
+ opt1Label: string; opt1Chips: OptionChip[]; opt1Selected: string[]; opt1SelectedId: string; opt1Placeholder: string; noOpt1: boolean;
297
+ opt2Label: string; opt2Chips: OptionChip[]; opt2Selected: string[]; opt2SelectedId: string; opt2Placeholder: string; noOpt2: boolean;
298
+ opt3Label: string; opt3Chips: OptionChip[]; opt3Selected: string[]; opt3SelectedId: string; opt3Placeholder: string; noOpt3: boolean;
299
+ opt1Error: string; noOpt1Error: boolean; hasOpt1Error: boolean; opt2Error: string; noOpt2Error: boolean; hasOpt2Error: boolean; opt3Error: string; noOpt3Error: boolean; hasOpt3Error: boolean;
205
300
  /** The App Store action (`commerce.purchaseFields`), in place of Add to cart while it shows. */
206
301
  cartHidden: boolean;
207
302
  buyHidden: boolean;
@@ -281,10 +376,27 @@ function derive(s: S): S {
281
376
  const hasSingleImage = !!s.image && !hasGallery;
282
377
  const groups = s.optionGroups;
283
378
  const overflow = groups.length > OPTION_SLOTS;
379
+ const optionsIncomplete = s.optionsUnknown || !!outOfStockPick(groups, s.selection) || (groups.length > 0 && (overflow || !isSelectionComplete(groups, s.selection)));
284
380
  return {
285
381
  ...s,
382
+ notOnSale: !s.wasPrice,
383
+ wasPriceEmpty: !!s.wasPrice,
384
+ noSku: !s.skuLabel,
385
+ noReturnPolicyBody: !s.returnPolicyBody,
386
+ noShippingInfoBody: !s.shippingInfoBody,
387
+ returnPolicyCollapsed: !s.returnPolicyExpanded,
388
+ shippingInfoCollapsed: !s.shippingInfoExpanded,
389
+ noSaveHint: !s.saveHint,
390
+ noShareHint: !s.shareHint,
391
+ noCartHint: !s.cartHint || s.added,
392
+ hideBar: s.status !== "ready" || !s.name || s.addedSheetPresented,
393
+ hideProduct: s.status !== "ready" || !s.name,
394
+ saveIcon: s.saved ? "heart_fill" : "heart",
395
+ optionsSummary: selectionSummary(s.optionGroups, s.selection),
396
+ quantityLabel: `Qty: ${s.quantity}`,
397
+ quantityIsOne: s.quantity <= 1,
286
398
  hasDescriptionJson: s.descriptionJson !== "",
287
- ...slotsOf(groups, s.selection),
399
+ ...slotsOf(groups, s.selection, s.showOptionErrors),
288
400
  hasOptions: groups.length > 0,
289
401
  noOptions: groups.length === 0,
290
402
  optionsOverflow: overflow,
@@ -294,9 +406,10 @@ function derive(s: S): S {
294
406
  // `optionsUnknown` blocks too: a cached product whose options we never read is
295
407
  // indistinguishable from one with none, and adding it would post the variant-less
296
408
  // line this whole feature exists to prevent.
297
- optionsIncomplete: s.optionsUnknown || !!outOfStockPick(groups, s.selection) || (groups.length > 0 && (overflow || !isSelectionComplete(groups, s.selection))),
409
+ optionsIncomplete,
410
+ addDisabled: optionsIncomplete,
298
411
  optionsHint: groups.length ? optionsHint(groups, s.selection) : "",
299
- saveLabel: s.saved ? copyParam("product.saved", "Saved") : copyParam("product.save", "Save product"),
412
+ saveLabel: s.saved ? copyParam("savedProducts.removeLabel", "Remove from wishlist") : copyParam("savedProducts.addLabel", "Add to wishlist"),
300
413
  notAdding: !s.adding,
301
414
  notAdded: !s.added,
302
415
  isLoading: s.status === "loading",
@@ -329,15 +442,16 @@ let checkoutEntered = false;
329
442
  const defineService = withIapPresentation<S>({
330
443
  adapters: SELLS_THROUGH_APPLE ? [storesProductOrders] : [],
331
444
  onCreate: commerce => { productPricing = commerce; }, prefix: "product",
332
- transientKeys: ["price", "optionsIncomplete", "cartHint", "cartHidden", "buyHidden", "buyLabel", "buyDisabled", "buyNote", "purchasedNote"], contentActions: ["load", "paintCached"],
445
+ transientKeys: ["price", "optionsIncomplete", "cartHint", "cartHidden", "buyHidden", "buyLabel", "buyDisabled", "buyNote", "purchasedNote", "addedSheetPresented", "shareHint"], contentActions: ["load", "paintCached"],
333
446
  items: state => inputId() ? [{ reference: { kind: "storesProduct", id: inputId() }, commerceDisplayPrice: state.price }] : [],
334
447
  present: (state, price, allowed) => {
335
448
  const purchase = applePurchase(state);
336
449
  const unavailable = iapPriceCopy("product").purchaseUnavailable;
337
450
  const blocked = lastProduct?.productType?.toLowerCase() === "digital" || (!state.optionsUnknown && !allowed(inputId()));
451
+ const cartHint = !purchase.appleRoute && blocked ? unavailable : state.cartHint === unavailable ? "" : state.cartHint;
338
452
  return {
339
- ...state, price: price(inputId()), optionsIncomplete: derive(state).optionsIncomplete || !allowed(inputId()) || lastProduct?.productType?.toLowerCase() === "digital",
340
- cartHint: !purchase.appleRoute && blocked ? unavailable : state.cartHint === unavailable ? "" : state.cartHint,
453
+ ...state, price: price(inputId()), ...(purchase.appleRoute ? { wasPrice: "", wasPriceEmpty: false, notOnSale: true } : {}), optionsIncomplete: derive(state).optionsIncomplete || !allowed(inputId()) || lastProduct?.productType?.toLowerCase() === "digital",
454
+ cartHint, noCartHint: !cartHint || state.added, addDisabled: state.addDisabled || blocked,
341
455
  cartHidden: purchase.appleRoute,
342
456
  buyHidden: !purchase.appleRoute || purchase.accessReady, buyLabel: purchase.buyLabel, buyDisabled: purchase.buyDisabled, buyNote: purchase.buyNote,
343
457
  purchasedNote: purchase.accessReady ? copyParam("product.purchasedText", "Purchased") : "",
@@ -367,6 +481,8 @@ function enterCheckout(): void {
367
481
  const svc = defineService<S, {
368
482
  load: (s: S, arg?: unknown) => Promise<S>;
369
483
  setQuantity: (s: S, arg?: unknown) => S;
484
+ toggleReturnPolicy: (s: S) => S;
485
+ toggleShippingInfo: (s: S) => S;
370
486
  selectOption: (s: S, arg?: unknown) => S;
371
487
  addToCart: (s: S) => Promise<S>;
372
488
  beginAdd: (s: S, arg?: unknown) => S;
@@ -374,6 +490,7 @@ const svc = defineService<S, {
374
490
  setCartCount: (s: S, arg?: unknown) => S;
375
491
  share: (s: S) => Promise<S>;
376
492
  toggleSave: (s: S) => S;
493
+ closeAddedSheet: (s: S) => S;
377
494
  /** [rico] #3372 — RichContent's on_link: `{ url }` of the tapped link. */
378
495
  openLink: (s: S, arg?: unknown) => Promise<S>;
379
496
  /** The App Store Buy: through the shared checkout, for the product on screen. */
@@ -383,9 +500,33 @@ const svc = defineService<S, {
383
500
  status: "loading",
384
501
  name: "",
385
502
  price: "",
503
+ wasPrice: "",
504
+ notOnSale: true,
505
+ wasPriceEmpty: false,
506
+ skuLabel: "",
507
+ noSku: true,
508
+ returnPolicyBody: "",
509
+ shippingInfoBody: "",
510
+ noReturnPolicyBody: true,
511
+ noShippingInfoBody: true,
512
+ returnPolicyCollapsed: true,
513
+ shippingInfoCollapsed: true,
514
+ noSaveHint: true,
515
+ saveIcon: "heart",
516
+ optionsSummary: "",
517
+ quantityLabel: "Qty: 1",
518
+ quantityIsOne: true,
519
+ addedSheetPresented: false,
520
+ shareHint: "",
521
+ noShareHint: true,
522
+ noCartHint: true,
523
+ hideBar: true,
524
+ hideProduct: true,
386
525
  description: "",
387
526
  descriptionJson: "",
388
527
  hasDescriptionJson: false,
528
+ returnPolicyExpanded: false,
529
+ shippingInfoExpanded: false,
389
530
  image: "",
390
531
  gallery: [],
391
532
  hasGallery: false,
@@ -419,11 +560,14 @@ const svc = defineService<S, {
419
560
  optionsOverflow: false,
420
561
  noOptionsOverflow: true,
421
562
  optionsIncomplete: false,
563
+ showOptionErrors: false,
564
+ addDisabled: false,
422
565
  optionsUnknown: false,
423
566
  optionsHint: "",
424
- opt1Label: "", opt1Chips: [], opt1Selected: [], noOpt1: true,
425
- opt2Label: "", opt2Chips: [], opt2Selected: [], noOpt2: true,
426
- opt3Label: "", opt3Chips: [], opt3Selected: [], noOpt3: true,
567
+ opt1Label: "", opt1Chips: [], opt1Selected: [], opt1SelectedId: "", opt1Placeholder: "", noOpt1: true,
568
+ opt2Label: "", opt2Chips: [], opt2Selected: [], opt2SelectedId: "", opt2Placeholder: "", noOpt2: true,
569
+ opt3Label: "", opt3Chips: [], opt3Selected: [], opt3SelectedId: "", opt3Placeholder: "", noOpt3: true,
570
+ opt1Error: "", noOpt1Error: true, hasOpt1Error: false, opt2Error: "", noOpt2Error: true, hasOpt2Error: false, opt3Error: "", noOpt3Error: true, hasOpt3Error: false,
427
571
  cartHidden: false,
428
572
  buyHidden: true,
429
573
  buyLabel: "",
@@ -441,7 +585,7 @@ const svc = defineService<S, {
441
585
  // Storage problems must not prevent browsing or buying a product.
442
586
  let saved = false;
443
587
  try { saved = isProductSaved(id); } catch { /* save action reports a retryable error */ }
444
- s = { ...s, saved, saveHint: "", adding: false, added: false };
588
+ s = { ...s, saved, saveHint: "", shareHint: "", adding: false, added: false, addedSheetPresented: false, quantity: 1, showOptionErrors: false, returnPolicyExpanded: false, shippingInfoExpanded: false };
445
589
  // A fresh navigation into the product resets any quantity left over
446
590
  // from a previous visit to the same worker instance.
447
591
  if (!id) {
@@ -497,6 +641,7 @@ const svc = defineService<S, {
497
641
  // A group with exactly one choice is chosen for the shopper; a real choice
498
642
  // is left blank so the pick is deliberate.
499
643
  const optionGroups = productOptions(p);
644
+ const sale = productSale(p);
500
645
  // A pooled screen re-appearing re-runs `load`. Wiping `selection` there means a
501
646
  // trip to the cart and back silently clears the size the shopper picked and
502
647
  // re-disables the CTA with no explanation. Carry the picks when this is the SAME
@@ -518,8 +663,11 @@ const svc = defineService<S, {
518
663
  selection: { ...defaultSelection(optionGroups), ...carried },
519
664
  optionsUnknown: false,
520
665
  name,
521
- price: productPrice(p),
522
- commercePrice: productPrice(p),
666
+ ...sale,
667
+ commercePrice: sale.price,
668
+ skuLabel: skuOf(p) ? `SKU: ${skuOf(p)}` : "",
669
+ returnPolicyBody: productPolicyBody(p, "returns") || copyParam("product.returnPolicyBody", "") || copyParam("product.policyUnavailable", "Policy details have not been provided by this store."),
670
+ shippingInfoBody: productPolicyBody(p, "shipping") || copyParam("product.shippingInfoBody", "") || copyParam("product.policyUnavailable", "Policy details have not been provided by this store."),
523
671
  ...productBody(p),
524
672
  image,
525
673
  gallery: productGallery(p),
@@ -550,6 +698,10 @@ const svc = defineService<S, {
550
698
  optionsUnknown: true,
551
699
  name,
552
700
  price: String(synced.subtitle ?? ""),
701
+ wasPrice: "",
702
+ skuLabel: "",
703
+ returnPolicyBody: "",
704
+ shippingInfoBody: "",
553
705
  description: "",
554
706
  descriptionJson: "",
555
707
  image,
@@ -569,6 +721,8 @@ const svc = defineService<S, {
569
721
  const quantity = parseQuantity(argValue(arg, "value"));
570
722
  return quantity ? derive({ ...s, quantity }) : s;
571
723
  },
724
+ toggleReturnPolicy: s => derive({ ...s, returnPolicyExpanded: !s.returnPolicyExpanded }),
725
+ toggleShippingInfo: s => derive({ ...s, shippingInfoExpanded: !s.shippingInfoExpanded }),
572
726
  // One chip row per option group. `group` is the descriptor slot ("1".."3");
573
727
  // FilterChips sends back the WHOLE selected set, which for a single-select row
574
728
  // is either one id or none (re-tapping the active chip clears it).
@@ -576,16 +730,17 @@ const svc = defineService<S, {
576
730
  const slot = Math.floor(Number(argValue(arg, "group")));
577
731
  const group = s.optionGroups[slot - 1];
578
732
  if (!group) return s;
579
- const raw = (arg as { selectedIds?: unknown } | undefined)?.selectedIds;
580
- const picked = Array.isArray(raw) ? String(raw[0] ?? "") : String(raw ?? "");
733
+ const raw = (arg as { selectedIds?: unknown; selectedId?: unknown; id?: unknown; value?: unknown } | undefined);
734
+ const selected = raw?.selectedIds;
735
+ const picked = Array.isArray(selected) ? String(selected[0] ?? "") : String(raw?.selectedId ?? raw?.id ?? raw?.value ?? selected ?? "");
581
736
  // Only a choice this group actually offers — a stale id from a previous
582
737
  // product would otherwise resolve to no variant and block the cart silently.
583
- const choice = group.choices.some((c) => c.id === picked) ? picked : "";
738
+ const choice = group.choices.some((c) => c.id === picked && c.inStock !== false) ? picked : "";
584
739
  const selection = { ...s.selection };
585
740
  if (choice) selection[group.id] = choice; else delete selection[group.id];
586
741
  // A new choice invalidates the last add's confirmation — it was for the
587
742
  // previous variant.
588
- return derive({ ...s, selection, added: false, cartHint: "" });
743
+ return derive({ ...s, selection, added: false, addedSheetPresented: false, cartHint: "" });
589
744
  },
590
745
  // The pending half of addToCart, as its own reducer so it PUBLISHES before the
591
746
  // network write starts: the pending line shows and the top-bar badge ticks up in the same
@@ -594,7 +749,7 @@ const svc = defineService<S, {
594
749
  beginAdd: (s, arg) => {
595
750
  const quantity = parseQuantity(argValue(arg, "quantity")) ?? 1;
596
751
  cartBadgeEpoch += 1;
597
- return derive({ ...s, adding: true, added: false, cartHint: "", cartCount: s.cartCount + quantity });
752
+ return derive({ ...s, adding: true, added: false, addedSheetPresented: false, cartHint: "", cartCount: s.cartCount + quantity });
598
753
  },
599
754
  // The first paint from the list's synced row (called from load, not from the descriptor):
600
755
  // name, price and image are on screen while the live product — description, gallery — loads.
@@ -641,7 +796,7 @@ const svc = defineService<S, {
641
796
  if (s.optionsIncomplete) {
642
797
  // With no groups in hand there is nothing for `optionsHint` to name, so the
643
798
  // offline case needs its own reason rather than an empty string.
644
- return derive({ ...s, cartHint: s.optionsUnknown
799
+ return derive({ ...s, showOptionErrors: true, cartHint: s.optionsUnknown
645
800
  ? copyParam("product.cartHintOffline", "Reconnect to choose options and add this to your cart.")
646
801
  : optionsHint(s.optionGroups, s.selection) || copyParam("product.cartHintNoVariant", "That combination is not available. Try another.") });
647
802
  }
@@ -668,7 +823,10 @@ const svc = defineService<S, {
668
823
  // then shows one line for two adds. Sorted so the key is stable whatever order the
669
824
  // groups were picked in, and reduced to id-safe characters.
670
825
  const variantKey = Object.keys(s.selection).sort().map((k) => `${k}:${s.selection[k]}`).join("|").replace(/[^A-Za-z0-9]+/g, "_");
671
- const line: DbEntity = { id: variantKey ? `line-${id}-${variantKey}` : `line-${id}`, productId: id, name: s.name || "product", title: s.name || "product", subtitle: chosen ? `${chosen} · Qty ${quantity}` : `Qty ${quantity}`, quantity: String(quantity) };
826
+ const unitPrice = s.commercePrice || s.price;
827
+ const sale = lastProduct ? productSale(lastProduct) : { wasPrice: "" };
828
+ const swatches = s.optionGroups.flatMap(g => g.kind === "color" ? g.choices.map(c => c.colorCode).filter(Boolean) : []).slice(0, 5);
829
+ const line: DbEntity = { id: variantKey ? `line-${id}-${variantKey}` : `line-${id}`, productId: id, name: s.name || "product", title: s.name || "product", subtitle: chosen ? `${chosen} · Qty ${quantity}` : `Qty ${quantity}`, quantity: String(quantity), image: s.image, unitPrice, wasPrice: sale.wasPrice, swatches, total: optimisticLineTotal(unitPrice, quantity), hasImage: !!s.image, noImage: !s.image };
672
830
  await cartPut(line);
673
831
  try {
674
832
  await addToCurrentCart(id, quantity, cartOptions);
@@ -685,15 +843,18 @@ const svc = defineService<S, {
685
843
  // The write LANDED. The badge re-read is outside the write's try on purpose: a failed
686
844
  // count must never be mistaken for a failed add. Server count wins; else keep optimistic.
687
845
  const count = await cartItemCount().catch(() => 0);
688
- return derive({ ...s, adding: false, added: true, cartHint: copyTemplate("product.cartHintAdded", "Added {name} to cart.", { name: s.name || "product" }), cartCount: count || s.cartCount });
846
+ const confirmation = copyTemplate("product.cartHintAdded", "Added {name} to cart.", { name: s.name || "product" });
847
+ return derive({ ...s, adding: false, added: true, addedSheetPresented: true, cartHint: `${confirmation}${quantity > 1 ? ` Qty: ${quantity}` : ""}`, cartCount: count || s.cartCount });
689
848
  },
690
849
  toggleSave: (s) => {
691
850
  if (s.status !== "ready" || !s.name || !inputId()) return s;
692
851
  try {
693
- const saved = toggleSavedProduct({ id: inputId(), name: s.name, price: lastProduct ? productPrice(lastProduct) : s.commercePrice ?? "", image: s.image });
852
+ const sale = lastProduct ? productSale(lastProduct) : { price: s.commercePrice ?? "", wasPrice: "" };
853
+ const saved = toggleSavedProduct({ id: inputId(), name: s.name, price: sale.price, wasPrice: sale.wasPrice, image: s.image,
854
+ swatches: s.optionGroups.flatMap(g => g.kind === "color" ? g.choices.map(c => c.colorCode).filter(Boolean) : []).slice(0, 5) });
694
855
  return derive({ ...s, saved, saveHint: saved
695
- ? copyParam("product.saveSuccess", "Saved on this device.")
696
- : copyParam("product.saveRemoved", "Removed from saved products.") });
856
+ ? copyParam("product.saveSuccess", "Added to wishlist.")
857
+ : copyParam("product.saveRemoved", "Removed from wishlist.") });
697
858
  } catch {
698
859
  return derive({ ...s, saveHint: copyParam("product.saveError", "Could not save this change. Try again.") });
699
860
  }
@@ -702,12 +863,13 @@ const svc = defineService<S, {
702
863
  if (!s.name) return s;
703
864
  try {
704
865
  await shareSheet({ subject: s.name, text: s.name, ...(s.shareUrl ? { url: s.shareUrl } : {}) });
705
- return s;
866
+ return derive({ ...s, shareHint: "" });
706
867
  } catch {
707
868
  // `added: false` — this hint must not render inside the add-to-cart confirmation.
708
- return derive({ ...s, added: false, cartHint: copyParam("product.shareUnavailable", "Sharing is not available on this device.") });
869
+ return derive({ ...s, shareHint: copyParam("product.shareUnavailable", "Sharing is not available on this device.") });
709
870
  }
710
871
  },
872
+ closeAddedSheet: s => derive({ ...s, addedSheetPresented: false }),
711
873
  // Only http(s), mailto and tel links reach open_url; a refused link, or one nothing opened, says so in the
712
874
  // hint line (`added: false`, as for share, so it never renders inside the add-to-cart confirmation).
713
875
  openLink: async (s, arg) => ((await openRichLink(arg))
@@ -3,7 +3,12 @@ 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 { commercePrice?: string; initial: string; noImage: boolean; hasImage: boolean; detailScreenId: string }
6
+ interface Row extends SavedProduct {
7
+ commercePrice?: string; initial: string; noImage: boolean; hasImage: boolean; detailScreenId: string;
8
+ notOnSale: boolean; wasPriceEmpty: boolean; noSwatches: boolean; noTagRow: boolean;
9
+ swatch1: string; swatch2: string; swatch3: string; swatch4: string; swatch5: string;
10
+ noSwatch1: boolean; noSwatch2: boolean; noSwatch3: boolean; noSwatch4: boolean; noSwatch5: boolean;
11
+ }
7
12
  interface S {
8
13
  status: "loading" | "ready" | "error";
9
14
  rows: Row[];
@@ -13,20 +18,31 @@ interface S {
13
18
  notLoading: boolean;
14
19
  notErrored: boolean;
15
20
  noEmpty: boolean;
21
+ hasRows: boolean;
16
22
  }
17
23
  function shape(s: Pick<S, "status" | "rows" | "error">): S {
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.") };
24
+ return { ...s, isLoading: s.status === "loading", notLoading: s.status !== "loading", notErrored: s.status !== "error", noEmpty: s.status !== "ready" || s.rows.length > 0, hasRows: s.rows.length > 0, emptyText: copyParam("savedProducts.empty", "Tap the heart on a product to add it to your wishlist.") };
19
25
  }
20
26
  function loadRows(): Row[] {
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" }));
27
+ return readSavedProducts().map(row => {
28
+ const swatches = (row.swatches ?? []).filter(value => /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(value)).slice(0, 5);
29
+ return { ...row, commercePrice: row.price, initial: row.name.slice(0, 1).toUpperCase(), hasImage: !!row.image, noImage: !row.image,
30
+ detailScreenId: "himi_product", notOnSale: !row.wasPrice, wasPriceEmpty: !!row.wasPrice, noSwatches: swatches.length === 0, noTagRow: !row.wasPrice && swatches.length === 0,
31
+ swatch1: swatches[0] ?? "", swatch2: swatches[1] ?? "", swatch3: swatches[2] ?? "", swatch4: swatches[3] ?? "", swatch5: swatches[4] ?? "",
32
+ noSwatch1: !swatches[0], noSwatch2: !swatches[1], noSwatch3: !swatches[2], noSwatch4: !swatches[3], noSwatch5: !swatches[4] };
33
+ });
22
34
  }
23
35
  function failed(s: S): S {
24
- return shape({ ...s, status: "error", error: copyParam("savedProducts.error", "Could not update saved products. Try again.") });
36
+ return shape({ ...s, status: "error", error: copyParam("savedProducts.error", "Could not update your wishlist. Try again.") });
25
37
  }
26
38
  const defineService = withIapPresentation<S>({
27
39
  prefix: "savedProducts", transientKeys: ["rows"], contentActions: ["load", "remove"],
28
40
  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) })) }),
41
+ present: (state, price) => ({ ...state, rows: state.rows.map(row => {
42
+ const next = price(row.id, row.commercePrice ?? row.price);
43
+ const iapAdjusted = next !== (row.commercePrice ?? row.price);
44
+ return { ...row, price: next, wasPriceEmpty: !!row.wasPrice && !iapAdjusted, notOnSale: !row.wasPrice || iapAdjusted, noTagRow: (!row.wasPrice || iapAdjusted) && !row.swatches?.length };
45
+ }) }),
30
46
  });
31
47
  const svc = defineService<S, {
32
48
  load: (s: S, arg?: unknown) => S;
@@ -39,6 +39,7 @@ interface Row {
39
39
  name: string;
40
40
  subtitle: string;
41
41
  commercePrice: string;
42
+ wasPrice: string;
42
43
  image: string;
43
44
  initial: string;
44
45
  hasImage: boolean;
@@ -73,11 +74,22 @@ function row(p: StoreProduct): Row {
73
74
  const name = cleanTitle(p.name) || "Product";
74
75
  const image = productImage(p);
75
76
  const id = p.id ?? "";
77
+ const priceData = p.priceData as { formatted?: { discountedPrice?: string; price?: string }; discountedPrice?: string; salePrice?: string } | undefined;
78
+ const regular = productPrice(p);
79
+ const discounted = priceData?.formatted?.discountedPrice ?? priceData?.discountedPrice ?? priceData?.salePrice;
80
+ const salePrice = discounted && discounted !== (priceData?.formatted?.price ?? regular)
81
+ ? productPrice({ ...p, priceData: { ...priceData, formatted: { ...priceData?.formatted, price: discounted } } } as StoreProduct)
82
+ : "";
83
+ const originalV3 = p.compareAtPriceRange?.minValue
84
+ ? productPrice({ ...p, price: undefined, priceData: undefined, actualPriceRange: p.compareAtPriceRange })
85
+ : "";
86
+ const wasPrice = salePrice ? regular : originalV3 && originalV3 !== regular ? originalV3 : "";
76
87
  return {
77
88
  id,
78
89
  name,
79
- subtitle: productPrice(p),
80
- commercePrice: productPrice(p),
90
+ subtitle: salePrice || regular,
91
+ commercePrice: salePrice || regular,
92
+ wasPrice,
81
93
  image,
82
94
  initial: initials(name),
83
95
  hasImage: !!image,
@@ -151,6 +151,12 @@
151
151
  "line": 1.4,
152
152
  "letterSpacing": 0.25
153
153
  },
154
+ "footnoteRegular": {
155
+ "size": 14,
156
+ "weight": 400,
157
+ "line": 1.4,
158
+ "letterSpacing": 1
159
+ },
154
160
  "caption": {
155
161
  "size": 12,
156
162
  "weight": 500,
@@ -170,6 +176,15 @@
170
176
  "letterSpacing": 0
171
177
  }
172
178
  },
179
+ "controls": {
180
+ "button": {
181
+ "variants": {
182
+ "tertiary": {
183
+ "label": "textPrimary"
184
+ }
185
+ }
186
+ }
187
+ },
173
188
  "radius": {
174
189
  "sm": 8,
175
190
  "md": 12,
@@ -751,7 +751,7 @@ const SCREENS = runtimeSetting({
751
751
  "package": "himalaya",
752
752
  "type": "Text",
753
753
  "props": {
754
- "text": "Products you saved",
754
+ "text": "Wishlist",
755
755
  "style": "bodyEmphasis"
756
756
  }
757
757
  }
@@ -5,6 +5,8 @@ export interface SavedProduct {
5
5
  name: string;
6
6
  price: string;
7
7
  image: string;
8
+ wasPrice?: string;
9
+ swatches?: string[];
8
10
  }
9
11
  type StorageLike = { getItem(key: string): string | null; setItem(key: string, value: string): void };
10
12
  const KEY = "himi-store:saved-products:v1";
@@ -24,7 +26,11 @@ export function readSavedProducts(): SavedProduct[] {
24
26
  if (!value || typeof value !== "object") continue;
25
27
  const row = value as Record<string, unknown>;
26
28
  if (typeof row.id !== "string" || !row.id.trim() || typeof row.name !== "string") continue;
27
- rows.set(row.id, { id: row.id, name: row.name, price: typeof row.price === "string" ? row.price : "", image: typeof row.image === "string" ? row.image : "" });
29
+ const swatches = Array.isArray(row.swatches)
30
+ ? row.swatches.filter((color): color is string => typeof color === "string" && /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(color)).slice(0, 5)
31
+ : [];
32
+ rows.set(row.id, { id: row.id, name: row.name, price: typeof row.price === "string" ? row.price : "", image: typeof row.image === "string" ? row.image : "",
33
+ ...(typeof row.wasPrice === "string" && row.wasPrice ? { wasPrice: row.wasPrice } : {}), ...(swatches.length ? { swatches } : {}) });
28
34
  }
29
35
  return [...rows.values()];
30
36
  }
@@ -165,6 +165,12 @@
165
165
  "letterSpacing": 0.1,
166
166
  "family": "Wix Madefor Text"
167
167
  },
168
+ "footnoteRegular": {
169
+ "size": 14,
170
+ "weight": 400,
171
+ "line": 1.4,
172
+ "letterSpacing": 1
173
+ },
168
174
  "caption": {
169
175
  "size": 13,
170
176
  "weight": 500,
@@ -187,6 +193,15 @@
187
193
  "family": "Wix Madefor Text"
188
194
  }
189
195
  },
196
+ "controls": {
197
+ "button": {
198
+ "variants": {
199
+ "tertiary": {
200
+ "label": "textPrimary"
201
+ }
202
+ }
203
+ }
204
+ },
190
205
  "radius": {
191
206
  "sm": 12,
192
207
  "md": 16,