@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
@@ -68,7 +68,7 @@
68
68
  "displayName": "Portfolio Project"
69
69
  },
70
70
  "himi_product": {
71
- "displayName": "Product"
71
+ "displayName": "Product Details"
72
72
  },
73
73
  "himi_rsvp": {
74
74
  "displayName": "RSVP"
@@ -7,9 +7,12 @@ export const storeCollectionSaving: CollectionSaving = {
7
7
  const products = new Map(readSavedProducts().map(row => [row.id, row]));
8
8
  for (const row of rows) {
9
9
  if (typeof row.id !== 'string' || typeof row.name !== 'string') continue;
10
- products.set(row.id, { id: row.id, name: row.name,
11
- price: typeof row.commercePrice === 'string' ? row.commercePrice : typeof row.subtitle === 'string' ? row.subtitle : '',
12
- image: typeof row.image === 'string' ? row.image : '' });
10
+ const previous = products.get(row.id);
11
+ const freshPrice = typeof row.commercePrice === 'string' ? row.commercePrice : typeof row.subtitle === 'string' ? row.subtitle : '';
12
+ const wasPrice = typeof row.wasPrice === 'string' ? row.wasPrice || undefined : previous?.price === freshPrice ? previous?.wasPrice : undefined;
13
+ products.set(row.id, { ...previous, wasPrice, id: row.id, name: row.name,
14
+ price: freshPrice || previous?.price || '',
15
+ image: typeof row.image === 'string' && row.image ? row.image : previous?.image ?? '' });
13
16
  }
14
17
  const selected = ids.map(id => products.get(id));
15
18
  if (selected.some(row => !row)) throw new Error('Saved product metadata is unavailable');
@@ -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
  }
@@ -2,11 +2,12 @@ import { getStoreProduct } from "../_shared/himi-store.js";
2
2
  import { iapPriceCopy } from "../_shared/iap-worker.js";
3
3
  import { withIapPresentation } from "../_shared/iap-presentation.js";
4
4
  import { type IapCommerce, classifyCart } from "@himalaya/iap-checkout";
5
- import { createCheckoutFromCurrentCart, createOrder, getCurrentCart, removeLineItemsFromCurrentCart, updateLineItemsQuantity, type WixCartLineItemDetail } from "@himalaya/wix-ecom";
5
+ import { addCouponToCurrentCart, calculateCurrentCart, createCheckoutFromCurrentCart, createOrder, getCurrentCart, removeCouponFromCurrentCart, removeLineItemsFromCurrentCart, updateLineItemsQuantity, type WixCartDetail, type WixCartLineItemDetail } from "@himalaya/wix-ecom";
6
6
  import { createRedirectSession } from "@himalaya/wix-redirects";
7
7
  import { session as wixPlatformSession } from "@himalaya/wix-platform";
8
8
  import { payCashierOrder } from "@himalaya/checkout";
9
9
  import { hostedCheckoutOpener, isHostedWorker } from "../_shared/hosted-checkout.js";
10
+ import { formatCurrency } from "../_shared/engine-intl.js";
10
11
 
11
12
  /** Wix Stores appDefId — the vertical that owns a cart/ecom order, and therefore the
12
13
  * `appId` its cashier order is paid under. (Same constant Wix's own tooling uses.) */
@@ -26,6 +27,7 @@ async function resolveMsid(): Promise<string> {
26
27
  import { copyParam, copyTemplate, stashCopy } from "../_shared/himi-copy.js";
27
28
  import { getAll, upsert, remove as dbRemove, type DbEntity } from "@himalaya/local-db";
28
29
  import { isOfflineFailure, writeMayHaveLanded } from "../_shared/owner-load-error.js";
30
+ import { isProductSaved, readSavedProducts, toggleSavedProduct } from "../_shared/himi-store-saved.js";
29
31
 
30
32
  // Offline-first (data-offline): the cart is READ-THROUGH — paint the last-synced line
31
33
  // items from the on-device `cart` entity first (instant, works offline), then
@@ -36,8 +38,37 @@ import { isOfflineFailure, writeMayHaveLanded } from "../_shared/owner-load-erro
36
38
  // The checkout redirect path is unchanged. Every local-db call is wrapped so a build
37
39
  // without the `local_database` grant behaves exactly as before (online-only).
38
40
  const ENTITY = "cart";
41
+ function cachedUnitPrice(total: string, quantity: number): string {
42
+ if (quantity <= 1) return total;
43
+ const match = /\d(?:[\d.,]|[ \u00a0\u202f](?=\d))*/u.exec(total);
44
+ if (!match) return total;
45
+ const raw = match[0];
46
+ const compact = raw.replace(/[ \u00a0\u202f]/g, "");
47
+ const last = Math.max(compact.lastIndexOf("."), compact.lastIndexOf(","));
48
+ const digits = last >= 0 && compact.length - last - 1 <= 2 ? compact.length - last - 1 : 0;
49
+ const decimal = digits ? compact[last] : ".";
50
+ const numeric = digits ? `${compact.slice(0, last).replace(/[,.]/g, "")}.${compact.slice(last + 1)}` : compact.replace(/[,.]/g, "");
51
+ const amount = Number(numeric);
52
+ if (!Number.isFinite(amount)) return total;
53
+ const [whole, fraction] = (amount / quantity).toFixed(digits).split(".");
54
+ const spaceGrouping = raw.match(/[ \u00a0\u202f](?=\d)/u)?.[0];
55
+ const grouped = spaceGrouping ? whole.replace(/\B(?=(\d{3})+(?!\d))/g, spaceGrouping) : whole;
56
+ return total.replace(raw, `${grouped}${digits ? decimal + fraction : ""}`);
57
+ }
39
58
  async function readCachedItems(): Promise<Row[]> {
40
- try { return (await getAll(ENTITY)) as unknown as Row[]; } catch { return []; }
59
+ try {
60
+ return ((await getAll(ENTITY)) as unknown as Row[]).map(item => {
61
+ const quantity = Math.max(1, Math.floor(Number(item.quantity) || 1));
62
+ let saved = false;
63
+ try { saved = isProductSaved(item.catalogReference?.catalogItemId ?? item.productId ?? ""); } catch { /* keep the cached line usable */ }
64
+ const unitPrice = item.unitPrice || cachedUnitPrice(item.total || item.commerceTotal || "", quantity);
65
+ return { ...item, catalogItemId: item.catalogReference?.catalogItemId ?? item.productId ?? "", quantity, quantityLabel: String(quantity), atMinQuantity: quantity <= 1, unitPrice,
66
+ wasPrice: item.wasPrice ?? "", swatches: item.swatches ?? [], option1: item.option1 ?? "", option2: item.option2 ?? "", option3: item.option3 ?? "",
67
+ noOption1: !item.option1, noOption2: !item.option2, noOption3: !item.option3,
68
+ stockMessage: item.stockMessage ?? "", noStockMessage: !item.stockMessage,
69
+ inStockMessage: item.inStockMessage ?? "", noInStockMessage: !item.inStockMessage, saved, notSaved: !saved };
70
+ });
71
+ } catch { return []; }
41
72
  }
42
73
  /** Mirror the cart Wix returned: upsert its lines and drop any cached line it no longer has
43
74
  * (one removed elsewhere, or a removal whose answer was lost), so the offline paint never
@@ -54,6 +85,8 @@ async function cacheDrop(id: string): Promise<void> { try { await dbRemove(ENTIT
54
85
 
55
86
  interface Row {
56
87
  catalogReference?: { appId: string; catalogItemId: string; options?: Record<string, unknown> };
88
+ productId?: string;
89
+ catalogItemId?: string;
57
90
  id: string;
58
91
  name: string;
59
92
  title: string;
@@ -64,19 +97,45 @@ interface Row {
64
97
  /** The "−" control at qty 1 removes the line, so it never dead-ends. */
65
98
  atMinQuantity: boolean;
66
99
  total: string;
100
+ unitPrice: string;
101
+ wasPrice?: string;
102
+ swatches?: string[];
67
103
  commerceTotal: string;
68
104
  image: string;
69
105
  initial: string;
70
106
  hasImage: boolean;
71
107
  noImage: boolean;
108
+ option1: string; option2: string; option3: string;
109
+ noOption1: boolean; noOption2: boolean; noOption3: boolean;
110
+ stockMessage: string; noStockMessage: boolean;
111
+ inStockMessage: string; noInStockMessage: boolean;
112
+ saved: boolean;
113
+ notSaved: boolean;
72
114
  }
73
115
  interface State {
74
116
  status: "loading" | "ready" | "error";
117
+ errorKind: "offline" | "server" | "";
75
118
  items: Row[];
76
119
  subtotal: string;
77
120
  commerceSubtotal?: string;
78
121
  emptyText: string;
79
122
  checkoutHint: string;
123
+ itemCount: number;
124
+ promoCode: string;
125
+ appliedCode: string;
126
+ appliedLabel: string;
127
+ couponId: string;
128
+ promoError: string;
129
+ promoBusy: boolean;
130
+ noPromoError: boolean;
131
+ hasCoupon: boolean;
132
+ discount: string;
133
+ noDiscount: boolean;
134
+ noCheckoutHint: boolean;
135
+ showPaidToast: boolean;
136
+ noPaidToast: boolean;
137
+ noCoupon: boolean;
138
+ subtotalLabel: string;
80
139
  isLoading: boolean;
81
140
  notLoading: boolean;
82
141
  /** Standard error quad (the himi-store retryBlock contract): the descriptor
@@ -84,6 +143,8 @@ interface State {
84
143
  * failed load shows the retry card instead of a blank list + blank subtotal. */
85
144
  isErrored: boolean;
86
145
  notErrored: boolean;
146
+ noOfflineError: boolean;
147
+ noServerError: boolean;
87
148
  hideContent: boolean;
88
149
  noEmpty: boolean;
89
150
  noItems: boolean;
@@ -91,7 +152,14 @@ interface State {
91
152
 
92
153
  function itemId(arg: unknown): string {
93
154
  if (typeof arg === "string") return arg;
94
- return (arg as { id?: string } | undefined)?.id ?? "";
155
+ if (!arg || typeof arg !== "object") return "";
156
+ const payload = arg as {
157
+ id?: string;
158
+ item?: { id?: string };
159
+ args?: { id?: string };
160
+ params?: { id?: string };
161
+ };
162
+ return payload.id ?? payload.item?.id ?? payload.args?.id ?? payload.params?.id ?? "";
95
163
  }
96
164
 
97
165
  function mediaUrl(value: unknown): string {
@@ -130,11 +198,37 @@ function mediaUrl(value: unknown): string {
130
198
  }
131
199
 
132
200
  function row(item: WixCartLineItemDetail, index: number): Row {
201
+ const raw = item as WixCartLineItemDetail & Record<string, unknown>;
133
202
  const quantity = item.quantity ?? 1;
134
203
  const name = item.productName?.original || copyParam("cart.itemFallbackName", "Cart item");
135
204
  const image = mediaUrl(item);
205
+ const lines = Array.isArray(raw.descriptionLines) ? raw.descriptionLines as Array<{ name?: { original?: string }; plainText?: { original?: string }; colorInfo?: { original?: string; code?: string } }> : [];
206
+ const opts = lines.slice(0, 3).map(line => {
207
+ const label = line.name?.original?.trim() ?? "";
208
+ const name = label && label === label.toUpperCase() ? label.charAt(0) + label.slice(1).toLowerCase() : label;
209
+ const value = line.plainText?.original || line.colorInfo?.original || "";
210
+ return [name, value].filter(Boolean).join(": ");
211
+ });
212
+ const inventory = raw.inventory as { quantity?: number } | undefined;
213
+ const availabilityData = raw.availability as { status?: string; quantityAvailable?: number } | undefined;
214
+ const stockCount = Number(availabilityData?.quantityAvailable ?? raw.inventoryQuantity ?? inventory?.quantity);
215
+ const availability = String(availabilityData?.status ?? raw.availabilityStatus ?? "").toUpperCase();
216
+ const stockMessage = availability === "NOT_AVAILABLE" || availability === "NOT_FOUND" || availability.includes("OUT")
217
+ ? copyParam("cart.outOfStock", "Out of stock")
218
+ : availability === "PARTIALLY_AVAILABLE" && Number.isFinite(stockCount) && stockCount > 0
219
+ ? copyTemplate("cart.onlyLeft", "Only {count} left", { count: stockCount }) : "";
220
+ const inStock = !stockMessage && (availability === "AVAILABLE" || availability === "IN_STOCK" || availability === "PARTIALLY_AVAILABLE");
221
+ const total = (raw.lineItemPrice as { formattedAmount?: string } | undefined)?.formattedAmount
222
+ ?? (raw.totalPriceAfterTax as { formattedAmount?: string } | undefined)?.formattedAmount
223
+ ?? (raw.totalPriceBeforeTax as { formattedAmount?: string } | undefined)?.formattedAmount
224
+ ?? item.price?.formattedAmount ?? "";
225
+ const unitPrice = item.price?.formattedAmount ?? "";
226
+ const fullPrice = (raw.fullPrice as { formattedAmount?: string } | undefined)?.formattedAmount ?? "";
227
+ const swatches = lines.map(line => line.colorInfo?.code ?? "").filter(code => /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(code)).slice(0, 5);
228
+ const saved = (() => { try { return isProductSaved(String((item as WixCartLineItemDetail & { catalogReference?: { catalogItemId?: string } }).catalogReference?.catalogItemId ?? "")); } catch { return false; } })();
136
229
  return {
137
230
  catalogReference: (item as WixCartLineItemDetail & { catalogReference?: Row["catalogReference"] }).catalogReference,
231
+ catalogItemId: (item as WixCartLineItemDetail & { catalogReference?: Row["catalogReference"] }).catalogReference?.catalogItemId ?? "",
138
232
  id: item.id || `line-${index}`,
139
233
  name,
140
234
  title: name,
@@ -142,33 +236,66 @@ function row(item: WixCartLineItemDetail, index: number): Row {
142
236
  quantity,
143
237
  quantityLabel: `${quantity}`,
144
238
  atMinQuantity: quantity <= 1,
145
- total: item.price?.formattedAmount ?? "",
146
- commerceTotal: item.price?.formattedAmount ?? "",
239
+ total,
240
+ unitPrice,
241
+ wasPrice: fullPrice && fullPrice !== unitPrice ? fullPrice : "",
242
+ swatches,
243
+ commerceTotal: total,
147
244
  image,
148
245
  initial: (name.trim()[0] || "C").toUpperCase(),
149
246
  hasImage: Boolean(image),
150
247
  noImage: !image,
248
+ option1: opts[0] ?? "", option2: opts[1] ?? "", option3: opts[2] ?? "",
249
+ noOption1: !opts[0], noOption2: !opts[1], noOption3: !opts[2],
250
+ stockMessage, noStockMessage: !stockMessage,
251
+ inStockMessage: inStock ? copyParam("cart.inStock", "In Stock") : "",
252
+ noInStockMessage: !inStock,
253
+ saved,
254
+ notSaved: !saved,
151
255
  };
152
256
  }
153
257
 
154
258
  /** POST /ecom/v1/carts/{cartId}/update-line-items-quantity via @himalaya/wix-ecom. */
155
- function updateLineItemQuantity(cartId: string, lineItemId: string, quantity: number): Promise<unknown> {
259
+ function updateLineItemQuantity(cartId: string, lineItemId: string, quantity: number): Promise<{ cart?: WixCartDetail }> {
156
260
  return updateLineItemsQuantity(cartId, [{ id: lineItemId, quantity: Math.max(1, quantity) }]);
157
261
  }
158
262
 
159
263
  function derive(state: State): State {
264
+ let savedIds: Set<string> | null = null;
265
+ try { savedIds = new Set(readSavedProducts().map(row => row.id)); }
266
+ catch { /* saved storage unavailable: retain the saved flags already shown on each row */ }
267
+ const items = savedIds ? state.items.map(item => {
268
+ const productId = item.catalogReference?.catalogItemId ?? item.productId;
269
+ if (!productId) return item;
270
+ const saved = savedIds.has(productId);
271
+ return { ...item, saved, notSaved: !saved };
272
+ }) : state.items;
273
+ const itemCount = items.reduce((n, item) => n + item.quantity, 0);
160
274
  return {
161
275
  ...state,
276
+ items,
277
+ promoBusy: promoInFlight && state.promoBusy,
162
278
  isLoading: state.status === "loading",
163
279
  notLoading: state.status !== "loading",
164
280
  isErrored: state.status === "error",
165
281
  notErrored: state.status !== "error",
282
+ noOfflineError: state.status !== "error" || state.errorKind !== "offline",
283
+ noServerError: state.status !== "error" || state.errorKind !== "server",
166
284
  // Exactly one of {content, error card, empty card} renders: content only on
167
285
  // "ready" (an errored cart must not paint a blank subtotal), the error card
168
286
  // on "error", the empty card on ready + no items.
169
287
  hideContent: state.status !== "ready",
170
- noEmpty: state.status !== "ready" || state.items.length > 0,
171
- noItems: state.items.length === 0,
288
+ noEmpty: state.status !== "ready" || items.length > 0,
289
+ noItems: items.length === 0,
290
+ itemCount,
291
+ hasCoupon: Boolean(state.appliedCode),
292
+ appliedLabel: state.appliedCode ? copyTemplate("cart.promoApplied", "Promo code {code}", { code: state.appliedCode }) : "",
293
+ noPromoError: !state.promoError,
294
+ noDiscount: !state.discount,
295
+ noCheckoutHint: !state.checkoutHint || state.showPaidToast,
296
+ noPaidToast: !state.showPaidToast,
297
+ noCoupon: !state.appliedCode,
298
+ subtotalLabel: copyTemplate("cart.subtotalCount", "Subtotal ({count})", { count: itemCount }),
172
299
  };
173
300
  }
174
301
 
@@ -205,21 +332,61 @@ async function removeLine(state: State, id: string): Promise<State> {
205
332
  }
206
333
  }
207
334
 
208
- async function refresh(state: State, checkoutHint = ""): Promise<State> {
209
- const cart = await getCurrentCart();
335
+ async function refresh(state: State, checkoutHint = "", updatedCart?: WixCartDetail, calculatedCart?: unknown): Promise<State> {
336
+ const cart = updatedCart ?? await getCurrentCart();
210
337
  const items = (cart.lineItems ?? []).map(row);
211
338
  await writeCachedItems(items);
339
+ const rawCart = cart as unknown as Record<string, unknown>;
340
+ const summary = (rawCart.priceSummary ?? rawCart.totals ?? {}) as Record<string, unknown>;
341
+ const discountRaw = (summary.discount ?? summary.discountAmount) as { formattedAmount?: string } | string | undefined;
342
+ const applied = couponDetails(cart);
343
+ let discount = formattedDiscount(typeof discountRaw === "string" ? discountRaw : discountRaw?.formattedAmount ?? "");
344
+ if (applied.code && !discount) {
345
+ const calculated = calculatedCart ?? await calculateCurrentCart().catch(() => null);
346
+ discount = calculatedDiscount(calculated, String(rawCart.currency ?? rawCart.paymentCurrency ?? "")) || (state.appliedCode === applied.code ? state.discount : "");
347
+ }
212
348
  return derive({
213
349
  ...state,
214
350
  status: "ready",
215
351
  items,
216
352
  subtotal: cart.subtotal?.formattedAmount ?? "",
217
353
  commerceSubtotal: cart.subtotal?.formattedAmount ?? "",
354
+ discount,
355
+ appliedCode: applied.code,
356
+ couponId: applied.id,
218
357
  emptyText: items.length ? "" : copyParam("cart.emptyText", "Your cart is empty."),
358
+ ...(items.length ? {} : { promoCode: "", appliedCode: "", couponId: "", discount: "", promoError: "" }),
219
359
  checkoutHint,
360
+ showPaidToast: false,
220
361
  });
221
362
  }
222
363
 
364
+ function formattedDiscount(value: string): string {
365
+ if (!value || !/[1-9]/.test(value)) return "";
366
+ return /^[−-]/.test(value.trim()) ? value : `−${value}`;
367
+ }
368
+
369
+ function calculatedDiscount(result: unknown, currency: string): string {
370
+ const discount = (result as { summary?: { priceSummary?: { discount?: { amount?: string; convertedAmount?: string } } } } | null)?.summary?.priceSummary?.discount;
371
+ const amount = Number(discount?.amount);
372
+ if (!Number.isFinite(amount) || amount === 0 || !currency) return "";
373
+ return formattedDiscount(formatCurrency(Math.abs(amount), currency, "en"));
374
+ }
375
+
376
+ function invalidCouponFailure(error: unknown): boolean {
377
+ const status = Number((error as { status?: unknown; statusCode?: unknown } | null)?.status ?? (error as { statusCode?: unknown } | null)?.statusCode);
378
+ const message = String((error as Error | null)?.message ?? error ?? "");
379
+ return status === 400 || status === 422 || /\bHTTP (?:400|422)\b|INVALID_COUPON|COUPON_(?:NOT_FOUND|EXPIRED)/i.test(message);
380
+ }
381
+
382
+ function couponDetails(result: unknown): { id: string; code: string } {
383
+ const envelope = result as { cart?: Record<string, unknown> } | null;
384
+ const cart = envelope?.cart ?? result as Record<string, unknown> | null;
385
+ const discounts = cart?.appliedDiscounts as Array<{ coupon?: { id?: string; code?: string }; id?: string; code?: string }> | { discounts?: Array<{ coupon?: { id?: string; code?: string }; id?: string; code?: string }> } | undefined;
386
+ const coupon = (Array.isArray(discounts) ? discounts : discounts?.discounts)?.find(entry => entry.coupon?.code || entry.code);
387
+ return { id: coupon?.coupon?.id ?? coupon?.id ?? "", code: coupon?.coupon?.code ?? coupon?.code ?? "" };
388
+ }
389
+
223
390
  /** The state slice a SETTLED order leaves behind: the order consumed the cart, so the
224
391
  * lines AND the subtotal go with it (`refresh` and the error path both clear the pair —
225
392
  * leaving a stale subtotal in state is the class of defect #864 fixed on the render side).
@@ -233,33 +400,41 @@ async function refresh(state: State, checkoutHint = ""): Promise<State> {
233
400
  * the subtotal and emptyText resets were missing. It survived because nothing typechecks
234
401
  * Tier-5 workers: esbuild erases types, and no tsconfig includes tier5-bundles-src.
235
402
  */
236
- function cartConsumedByOrder(): Pick<State, "items" | "subtotal" | "emptyText" | "checkoutHint"> {
403
+ function cartConsumedByOrder(): Pick<State, "items" | "subtotal" | "emptyText" | "checkoutHint" | "showPaidToast" | "promoCode" | "appliedCode" | "couponId" | "discount"> {
237
404
  return {
238
405
  items: [],
239
406
  subtotal: "",
240
407
  emptyText: "",
241
408
  checkoutHint: copyParam("cart.checkoutPaidMessage", "Order placed. Thanks!"),
409
+ showPaidToast: true,
410
+ promoCode: "", appliedCode: "", couponId: "", discount: "",
242
411
  };
243
412
  }
244
413
 
245
414
  let cartPricing: IapCommerce;
246
415
  const defineService = withIapPresentation<State>({
247
416
  onCreate: commerce => { cartPricing = commerce; },
248
- prefix: "cart", transientKeys: ["items", "subtotal"], contentActions: ["load", "remove", "increment", "decrement"],
417
+ prefix: "cart", transientKeys: ["items", "subtotal", "promoBusy"], contentActions: ["load", "remove", "increment", "decrement"],
249
418
  items: state => state.items.flatMap(row => row.catalogReference?.catalogItemId ? [{ reference: { kind: "storesProduct", id: row.catalogReference.catalogItemId }, commerceDisplayPrice: row.commerceTotal ?? row.total }] : []),
250
419
  present: (state, price, allowed) => ({ ...state,
251
420
  items: state.items.map(row => ({ ...row, total: row.catalogReference?.catalogItemId ? price(row.catalogReference.catalogItemId, row.commerceTotal ?? row.total) : row.total })),
252
421
  subtotal: state.items.length > 0 && state.items.every(row => row.catalogReference?.catalogItemId && allowed(row.catalogReference.catalogItemId)) ? state.commerceSubtotal ?? state.subtotal : "" }),
253
422
  });
254
423
 
424
+ let promoInFlight = false;
255
425
  const svc = defineService<State, {
256
426
  load: (state: State, arg?: unknown) => Promise<State>;
427
+ setPromo: (state: State, arg?: unknown) => State;
428
+ beginPromo: (state: State) => State;
429
+ applyPromo: (state: State) => Promise<State>;
430
+ removePromo: (state: State) => Promise<State>;
257
431
  remove: (state: State, arg?: unknown) => Promise<State>;
432
+ save: (state: State, arg?: unknown) => State;
258
433
  increment: (state: State, arg?: unknown) => Promise<State>;
259
434
  decrement: (state: State, arg?: unknown) => Promise<State>;
260
435
  checkout: (state: State) => Promise<State>;
261
436
  }>("himiStoreCart", {
262
- state: derive({ status: "loading", items: [], subtotal: "", emptyText: "", checkoutHint: "", isLoading: true, notLoading: false, isErrored: false, notErrored: true, hideContent: true, noEmpty: true, noItems: true }),
437
+ state: derive({ status: "loading", errorKind: "", items: [], subtotal: "", subtotalLabel: "", emptyText: "", checkoutHint: "", showPaidToast: false, noPaidToast: true, itemCount: 0, promoCode: "", appliedCode: "", appliedLabel: "", couponId: "", promoError: "", promoBusy: false, noPromoError: true, hasCoupon: false, discount: "", noDiscount: true, noCheckoutHint: true, noCoupon: true, isLoading: true, notLoading: false, isErrored: false, notErrored: true, noOfflineError: true, noServerError: true, hideContent: true, noEmpty: true, noItems: true }),
263
438
  actions: {
264
439
  load: async (state, arg) => {
265
440
  stashCopy(arg);
@@ -271,11 +446,61 @@ const svc = defineService<State, {
271
446
  // 2) Revalidate from Wix and refresh the cache.
272
447
  try {
273
448
  return await refresh(state);
274
- } catch {
449
+ } catch (error) {
275
450
  // Offline / fetch failure: keep the cached items instead of a blank error state.
276
451
  return cached.length
277
452
  ? base
278
- : derive({ ...state, status: "error", items: [], subtotal: "", emptyText: copyParam("cart.loadErrorText", "Cart could not load."), checkoutHint: "" });
453
+ : derive({ ...state, status: "error", errorKind: isOfflineFailure(error) ? "offline" : "server", items: [], subtotal: "", emptyText: copyParam("cart.loadErrorText", "Cart could not load."), checkoutHint: "" });
454
+ }
455
+ },
456
+ setPromo: (state, arg) => derive({ ...state, promoCode: (typeof arg === "string" ? arg : String((arg as { value?: unknown } | undefined)?.value ?? "")).slice(0, 100), promoError: "" }),
457
+ beginPromo: state => derive({ ...state, promoBusy: true, promoError: "" }),
458
+ applyPromo: async (state): Promise<State> => {
459
+ const code = state.promoCode.trim();
460
+ if (promoInFlight) return svc.getState() as State;
461
+ if (!code) return derive({ ...state, promoError: copyParam("cart.promoEmpty", "Enter a promo code.") });
462
+ promoInFlight = true;
463
+ let added = false;
464
+ try {
465
+ await svc.beginPromo();
466
+ state = svc.getState() as State;
467
+ await addCouponToCurrentCart(code);
468
+ added = true;
469
+ const calculated = await calculateCurrentCart().catch(() => null);
470
+ const refreshed = await refresh(state, "", undefined, calculated);
471
+ return refreshed.appliedCode
472
+ ? derive({ ...refreshed, promoBusy: false, promoCode: "", promoError: "" })
473
+ : derive({ ...refreshed, promoBusy: false, promoCode: state.promoCode, promoError: copyParam("cart.promoInvalid", "This promo code is not valid.") });
474
+ } catch (error) {
475
+ const mayHaveLanded = added || writeMayHaveLanded(error);
476
+ const refreshed = mayHaveLanded ? await refresh(state).catch(() => null) : null;
477
+ if (refreshed?.appliedCode === code) return derive({ ...refreshed, promoBusy: false, promoCode: "", promoError: "" });
478
+ return derive({ ...state, promoBusy: false, promoError: mayHaveLanded
479
+ ? copyParam("cart.promoUnconfirmed", "Could not confirm the promo code. Refresh your cart.")
480
+ : isOfflineFailure(error)
481
+ ? copyParam("cart.promoOffline", "Connect to the internet to use a promo code.")
482
+ : invalidCouponFailure(error)
483
+ ? copyParam("cart.promoInvalid", "This promo code is not valid.")
484
+ : copyParam("cart.promoUnavailable", "Could not apply the promo code. Try again.") });
485
+ } finally {
486
+ promoInFlight = false;
487
+ }
488
+ },
489
+ removePromo: async state => {
490
+ if (!state.couponId) return derive({ ...state, appliedCode: "", discount: "" });
491
+ let removed = false;
492
+ try {
493
+ await removeCouponFromCurrentCart(state.couponId);
494
+ removed = true;
495
+ await calculateCurrentCart().catch(() => null);
496
+ return derive({ ...(await refresh(state)), appliedCode: "", couponId: "", promoError: "" });
497
+ } catch (error) {
498
+ const mayHaveLanded = removed || writeMayHaveLanded(error);
499
+ const refreshed = mayHaveLanded ? await refresh(state).catch(() => null) : null;
500
+ if (refreshed && !refreshed.appliedCode) return derive({ ...refreshed, promoError: "" });
501
+ return derive({ ...state, promoError: mayHaveLanded
502
+ ? copyParam("cart.promoRemoveUnconfirmed", "Could not confirm promo removal. Refresh your cart.")
503
+ : copyParam("cart.promoRemoveFailed", "Could not remove the promo code.") });
279
504
  }
280
505
  },
281
506
  remove: async (state, arg) => {
@@ -283,14 +508,24 @@ const svc = defineService<State, {
283
508
  if (!id) return state;
284
509
  return removeLine(state, id);
285
510
  },
511
+ save: (state, arg) => {
512
+ const id = itemId(arg);
513
+ const item = state.items.find(candidate => candidate.id === id);
514
+ const productId = item?.catalogReference?.catalogItemId ?? item?.productId;
515
+ if (!item || !productId) return state;
516
+ try {
517
+ const saved = toggleSavedProduct({ id: productId, name: item.name, price: item.unitPrice, wasPrice: item.wasPrice, image: item.image, swatches: item.swatches });
518
+ return derive({ ...state, items: state.items.map(candidate => (candidate.catalogReference?.catalogItemId ?? candidate.productId) === productId ? { ...candidate, saved, notSaved: !saved } : candidate) });
519
+ } catch { return derive({ ...state, checkoutHint: copyParam("cart.saveError", "Could not save this item.") }); }
520
+ },
286
521
  increment: async (state, arg) => {
287
522
  const id = itemId(arg);
288
523
  const line = state.items.find((candidate) => candidate.id === id);
289
524
  if (!line) return state;
290
525
  try {
291
526
  const cartId = (await getCurrentCart()).id || "";
292
- await updateLineItemQuantity(cartId, id, line.quantity + 1);
293
- return await refresh(state);
527
+ const updated = await updateLineItemQuantity(cartId, id, line.quantity + 1);
528
+ return await refresh(state, "", updated.cart);
294
529
  } catch {
295
530
  return derive({ ...state, checkoutHint: copyParam("cart.quantityFailedMessage", "Could not update the quantity.") });
296
531
  }
@@ -303,8 +538,8 @@ const svc = defineService<State, {
303
538
  if (line.quantity <= 1) return removeLine(state, id);
304
539
  try {
305
540
  const cartId = (await getCurrentCart()).id || "";
306
- await updateLineItemQuantity(cartId, id, line.quantity - 1);
307
- return await refresh(state);
541
+ const updated = await updateLineItemQuantity(cartId, id, line.quantity - 1);
542
+ return await refresh(state, "", updated.cart);
308
543
  } catch {
309
544
  return derive({ ...state, checkoutHint: copyParam("cart.quantityFailedMessage", "Could not update the quantity.") });
310
545
  }
@@ -349,6 +584,7 @@ const svc = defineService<State, {
349
584
  const order = hosted ? { paymentGatewayOrderId: "" } : await createOrder(checkoutId);
350
585
  const snapshotId = order.paymentGatewayOrderId ?? "";
351
586
  if (!snapshotId && !hosted) {
587
+ await writeCachedItems([]);
352
588
  return derive({ ...state, ...cartConsumedByOrder() });
353
589
  }
354
590
 
@@ -377,6 +613,7 @@ const svc = defineService<State, {
377
613
  });
378
614
  if (pay.kind === "paid") {
379
615
  // Settled in place — the cart is consumed by the order.
616
+ await writeCachedItems([]);
380
617
  return derive({ ...state, ...cartConsumedByOrder() });
381
618
  }
382
619
  if (pay.kind === "handoff") {