@wix/himalaya-cli 0.1095.0 → 0.1097.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.
@@ -80654,6 +80654,219 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
80654
80654
  }
80655
80655
  }
80656
80656
  },
80657
+ {
80658
+ "id": "himi_product.options_card",
80659
+ "body": {
80660
+ "package": "himalaya",
80661
+ "type": "VStack",
80662
+ "props": {
80663
+ "spacing": 12,
80664
+ "alignment": "leading",
80665
+ "children": [
80666
+ {
80667
+ "id": "himi_product.opt1_row",
80668
+ "body": {
80669
+ "package": "himalaya",
80670
+ "type": "VStack",
80671
+ "props": {
80672
+ "spacing": 8,
80673
+ "alignment": "leading",
80674
+ "children": [
80675
+ {
80676
+ "id": "",
80677
+ "body": {
80678
+ "package": "himalaya",
80679
+ "type": "Text",
80680
+ "props": {
80681
+ "text": "$service:himiStoreProduct.opt1Label",
80682
+ "style": "bodyEmphasis",
80683
+ "collapseWhenEmpty": true
80684
+ }
80685
+ }
80686
+ },
80687
+ {
80688
+ "id": "himi_product.opt1",
80689
+ "body": {
80690
+ "package": "himalaya",
80691
+ "type": "FilterChips",
80692
+ "props": {
80693
+ "chips": [],
80694
+ "optionsPath": "$service:himiStoreProduct.opt1Chips",
80695
+ "selectedIds": [
80696
+ "$service:himiStoreProduct.opt1Selected"
80697
+ ],
80698
+ "onChange": {
80699
+ "actionId": "tier5.himiStoreProduct.selectOption",
80700
+ "params": {
80701
+ "group": "1"
80702
+ }
80703
+ }
80704
+ }
80705
+ },
80706
+ "modifiers": {
80707
+ "accessibilityLabel": "$service:himiStoreProduct.opt1Label"
80708
+ }
80709
+ }
80710
+ ]
80711
+ }
80712
+ },
80713
+ "modifiers": {
80714
+ "hiddenPath": "$service:himiStoreProduct.noOpt1"
80715
+ }
80716
+ },
80717
+ {
80718
+ "id": "himi_product.opt2_row",
80719
+ "body": {
80720
+ "package": "himalaya",
80721
+ "type": "VStack",
80722
+ "props": {
80723
+ "spacing": 8,
80724
+ "alignment": "leading",
80725
+ "children": [
80726
+ {
80727
+ "id": "",
80728
+ "body": {
80729
+ "package": "himalaya",
80730
+ "type": "Text",
80731
+ "props": {
80732
+ "text": "$service:himiStoreProduct.opt2Label",
80733
+ "style": "bodyEmphasis",
80734
+ "collapseWhenEmpty": true
80735
+ }
80736
+ }
80737
+ },
80738
+ {
80739
+ "id": "himi_product.opt2",
80740
+ "body": {
80741
+ "package": "himalaya",
80742
+ "type": "FilterChips",
80743
+ "props": {
80744
+ "chips": [],
80745
+ "optionsPath": "$service:himiStoreProduct.opt2Chips",
80746
+ "selectedIds": [
80747
+ "$service:himiStoreProduct.opt2Selected"
80748
+ ],
80749
+ "onChange": {
80750
+ "actionId": "tier5.himiStoreProduct.selectOption",
80751
+ "params": {
80752
+ "group": "2"
80753
+ }
80754
+ }
80755
+ }
80756
+ },
80757
+ "modifiers": {
80758
+ "accessibilityLabel": "$service:himiStoreProduct.opt2Label"
80759
+ }
80760
+ }
80761
+ ]
80762
+ }
80763
+ },
80764
+ "modifiers": {
80765
+ "hiddenPath": "$service:himiStoreProduct.noOpt2"
80766
+ }
80767
+ },
80768
+ {
80769
+ "id": "himi_product.opt3_row",
80770
+ "body": {
80771
+ "package": "himalaya",
80772
+ "type": "VStack",
80773
+ "props": {
80774
+ "spacing": 8,
80775
+ "alignment": "leading",
80776
+ "children": [
80777
+ {
80778
+ "id": "",
80779
+ "body": {
80780
+ "package": "himalaya",
80781
+ "type": "Text",
80782
+ "props": {
80783
+ "text": "$service:himiStoreProduct.opt3Label",
80784
+ "style": "bodyEmphasis",
80785
+ "collapseWhenEmpty": true
80786
+ }
80787
+ }
80788
+ },
80789
+ {
80790
+ "id": "himi_product.opt3",
80791
+ "body": {
80792
+ "package": "himalaya",
80793
+ "type": "FilterChips",
80794
+ "props": {
80795
+ "chips": [],
80796
+ "optionsPath": "$service:himiStoreProduct.opt3Chips",
80797
+ "selectedIds": [
80798
+ "$service:himiStoreProduct.opt3Selected"
80799
+ ],
80800
+ "onChange": {
80801
+ "actionId": "tier5.himiStoreProduct.selectOption",
80802
+ "params": {
80803
+ "group": "3"
80804
+ }
80805
+ }
80806
+ }
80807
+ },
80808
+ "modifiers": {
80809
+ "accessibilityLabel": "$service:himiStoreProduct.opt3Label"
80810
+ }
80811
+ }
80812
+ ]
80813
+ }
80814
+ },
80815
+ "modifiers": {
80816
+ "hiddenPath": "$service:himiStoreProduct.noOpt3"
80817
+ }
80818
+ },
80819
+ {
80820
+ "id": "himi_product.options_hint",
80821
+ "body": {
80822
+ "package": "himalaya",
80823
+ "type": "Text",
80824
+ "props": {
80825
+ "text": "$service:himiStoreProduct.optionsHint",
80826
+ "style": "footnote",
80827
+ "color": "textSecondary",
80828
+ "collapseWhenEmpty": true
80829
+ }
80830
+ }
80831
+ },
80832
+ {
80833
+ "id": "",
80834
+ "body": {
80835
+ "package": "himalaya",
80836
+ "type": "Text",
80837
+ "props": {
80838
+ "text": "This product has more options than this screen can show. Open it on the website to order.",
80839
+ "style": "footnote",
80840
+ "color": "textSecondary"
80841
+ }
80842
+ },
80843
+ "modifiers": {
80844
+ "hiddenPath": "$service:himiStoreProduct.noOptionsOverflow"
80845
+ }
80846
+ }
80847
+ ]
80848
+ }
80849
+ },
80850
+ "modifiers": {
80851
+ "background": {
80852
+ "token": "surfaceElevated"
80853
+ },
80854
+ "border": {
80855
+ "width": 1,
80856
+ "color": {
80857
+ "token": "border"
80858
+ }
80859
+ },
80860
+ "cornerRadius": 8,
80861
+ "padding": {
80862
+ "leading": 14,
80863
+ "trailing": 14,
80864
+ "top": 14,
80865
+ "bottom": 14
80866
+ },
80867
+ "hiddenPath": "$service:himiStoreProduct.noOptions"
80868
+ }
80869
+ },
80657
80870
  {
80658
80871
  "id": "himi_product.qty_card",
80659
80872
  "body": {
@@ -80938,7 +81151,8 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
80938
81151
  "fullWidth": true,
80939
81152
  "action": {
80940
81153
  "actionId": "tier5.himiStoreProduct.addToCart"
80941
- }
81154
+ },
81155
+ "disabledPath": "$service:himiStoreProduct.optionsIncomplete"
80942
81156
  }
80943
81157
  },
80944
81158
  "modifiers": {
@@ -3,7 +3,9 @@ import { defineService } from "@himalaya/state";
3
3
  import { share as shareSheet } from "@himalaya/share";
4
4
  import { addToCurrentCart, getCurrentCart } from "@himalaya/wix-ecom";
5
5
  import { get, upsert, enqueue, type DbEntity } from "@himalaya/local-db";
6
- import { getStoreProduct, plainText, productImage, productPrice, wixMediaUrl, productGallery as sdkProductGallery, WIX_DETAIL_IMAGE, type StoreProduct } from "../_shared/himi-store.js";
6
+ import { getStoreProduct, plainText, productImage, productPrice, wixMediaUrl, productGallery as sdkProductGallery, WIX_DETAIL_IMAGE, type StoreProduct,
7
+ productOptions, defaultSelection, isSelectionComplete, selectionSummary, cartOptionsPayload,
8
+ type StoreOption, type StoreOptionChoice, type StoreOptionSelection } from "../_shared/himi-store.js";
7
9
  import { cleanTitle } from "../_shared/himi-text.js";
8
10
  import { stashCopy, copyParam, copyTemplate } from "../_shared/himi-copy.js";
9
11
  import { isBridgeUnavailable } from "../_shared/owner-load-error.js";
@@ -39,6 +41,85 @@ interface GalleryItem {
39
41
  url: string;
40
42
  }
41
43
 
44
+ interface OptionChip {
45
+ id: string;
46
+ label: string;
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Product options (Colour, Size, …)
51
+ //
52
+ // A product with options is not buyable without them: the cart line goes out with
53
+ // no colour and no size, and the merchant cannot fulfil the order. The reader and
54
+ // the cart payload live in @himalaya/wix-stores (one reader, both catalogs, and a
55
+ // cart payload that is catalog-specific in a way a per-app copy gets wrong) — this
56
+ // worker only holds the shopper's choice and hides the slots it does not need.
57
+ //
58
+ // THREE fixed slots, because a screen descriptor is static: it cannot loop over N
59
+ // groups when N is unknown at authoring time. Catalog V3 allows up to 6. A product
60
+ // with more groups than slots is NOT silently half-configured — the overflow hint
61
+ // renders and Add to cart stays disabled, because a line item for the wrong variant
62
+ // is worse than one the shopper cannot place yet. Give the descriptor a bounded
63
+ // repeater and this cap goes away.
64
+ // ---------------------------------------------------------------------------
65
+ const OPTION_SLOTS = 3;
66
+
67
+ /** The raw product behind the current screen. NOT in published state: the V3 cart
68
+ * payload is resolved from `variantsInfo`, which no renderer needs to see. */
69
+ let lastProduct: StoreProduct | undefined;
70
+
71
+ /** The product id `lastProduct` was read for. A pooled screen re-appearing calls
72
+ * `refreshServices()` → `load()`, so this is what tells a genuine navigation to a
73
+ * DIFFERENT product (drop the picks) from the same product coming back (keep them). */
74
+ let lastLoadedId: string | undefined;
75
+
76
+ interface OptionSlots {
77
+ opt1Label: string; opt1Chips: OptionChip[]; opt1Selected: string[]; noOpt1: boolean;
78
+ opt2Label: string; opt2Chips: OptionChip[]; opt2Selected: string[]; noOpt2: boolean;
79
+ opt3Label: string; opt3Chips: OptionChip[]; opt3Selected: string[]; noOpt3: boolean;
80
+ }
81
+
82
+ function slotsOf(groups: StoreOption[], selection: StoreOptionSelection): OptionSlots {
83
+ const out = {} as Record<string, unknown>;
84
+ for (let i = 0; i < OPTION_SLOTS; i++) {
85
+ const g = groups[i];
86
+ const n = i + 1;
87
+ const chosen = g ? selection[g.id] ?? "" : "";
88
+ out[`opt${n}Label`] = g?.name ?? "";
89
+ // Out-of-stock choices stay selectable but say so — hiding them makes a product
90
+ // look like it has fewer colours than it does, and the stock flag is per-choice
91
+ // across variants, not a promise about the combination.
92
+ out[`opt${n}Chips`] = (g?.choices ?? []).map((c) => ({ id: c.id, label: c.inStock ? c.label : `${c.label} · ${copyParam("product.optionOutOfStock", "Out of stock")}` }));
93
+ out[`opt${n}Selected`] = chosen ? [chosen] : [];
94
+ out[`noOpt${n}`] = !g;
95
+ }
96
+ return out as unknown as OptionSlots;
97
+ }
98
+
99
+ /** The chosen choice that cannot be bought, if any. A chip stays SELECTABLE when it
100
+ * is out of stock — hiding it makes a product look like it has fewer colours than it
101
+ * does — but picking it must not reach the cart: the add was accepted and only failed
102
+ * at purchase, which is the worst place to learn it. */
103
+ function outOfStockPick(groups: StoreOption[], selection: StoreOptionSelection): StoreOptionChoice | undefined {
104
+ for (const g of groups) {
105
+ const chosen = g.choices.find((c) => c.id === selection[g.id]);
106
+ if (chosen && chosen.inStock === false) return chosen;
107
+ }
108
+ return undefined;
109
+ }
110
+
111
+ /** What to say under the picker: the groups still unchosen, else the choice made. */
112
+ function optionsHint(groups: StoreOption[], selection: StoreOptionSelection): string {
113
+ const missing = groups.filter((g) => !selection[g.id]).map((g) => g.name);
114
+ if (!missing.length) {
115
+ const blocked = outOfStockPick(groups, selection);
116
+ return blocked
117
+ ? copyTemplate("product.optionOutOfStockHint", "{choice} is out of stock. Choose another.", { choice: blocked.label })
118
+ : selectionSummary(groups, selection);
119
+ }
120
+ return copyTemplate("product.optionsChoose", "Choose {options}", { options: missing.join(", ") });
121
+ }
122
+
42
123
  interface S {
43
124
  status: "loading" | "ready" | "error";
44
125
  name: string;
@@ -79,6 +160,26 @@ interface S {
79
160
  saved: boolean;
80
161
  saveLabel: string;
81
162
  saveHint: string;
163
+ /** The product's option groups, in site order — the source the slots below and
164
+ * the cart payload are derived from. */
165
+ optionGroups: StoreOption[];
166
+ /** option id → chosen choice id. A group the shopper has not picked is absent. */
167
+ selection: StoreOptionSelection;
168
+ hasOptions: boolean;
169
+ noOptions: boolean;
170
+ /** More option groups than the descriptor has slots — see OPTION_SLOTS. */
171
+ optionsOverflow: boolean;
172
+ noOptionsOverflow: boolean;
173
+ /** Add to cart is blocked: this product has options and they are not all chosen. */
174
+ optionsIncomplete: boolean;
175
+ /** The product was served from the offline cache, which carries no option groups.
176
+ * "No groups" here means UNKNOWN, not "none" — so the CTA must stay blocked rather
177
+ * than offer a one-tap add for a product that may need a colour and a size. */
178
+ optionsUnknown: boolean;
179
+ optionsHint: string;
180
+ opt1Label: string; opt1Chips: OptionChip[]; opt1Selected: string[]; noOpt1: boolean;
181
+ opt2Label: string; opt2Chips: OptionChip[]; opt2Selected: string[]; noOpt2: boolean;
182
+ opt3Label: string; opt3Chips: OptionChip[]; opt3Selected: string[]; noOpt3: boolean;
82
183
  }
83
184
 
84
185
  function inputId(): string {
@@ -148,8 +249,22 @@ function derive(s: S): S {
148
249
  const empty = s.status === "ready" && !s.name;
149
250
  const hasGallery = s.gallery.length > 1;
150
251
  const hasSingleImage = !!s.image && !hasGallery;
252
+ const groups = s.optionGroups;
253
+ const overflow = groups.length > OPTION_SLOTS;
151
254
  return {
152
255
  ...s,
256
+ ...slotsOf(groups, s.selection),
257
+ hasOptions: groups.length > 0,
258
+ noOptions: groups.length === 0,
259
+ optionsOverflow: overflow,
260
+ noOptionsOverflow: !overflow,
261
+ // Overflow counts as incomplete on purpose: the shopper cannot choose a group
262
+ // the screen never rendered, so the only variant we could send would be a guess.
263
+ // `optionsUnknown` blocks too: a cached product whose options we never read is
264
+ // indistinguishable from one with none, and adding it would post the variant-less
265
+ // line this whole feature exists to prevent.
266
+ optionsIncomplete: s.optionsUnknown || !!outOfStockPick(groups, s.selection) || (groups.length > 0 && (overflow || !isSelectionComplete(groups, s.selection))),
267
+ optionsHint: groups.length ? optionsHint(groups, s.selection) : "",
153
268
  saveLabel: s.saved ? copyParam("product.saved", "Saved") : copyParam("product.save", "Save product"),
154
269
  notAdding: !s.adding,
155
270
  notAdded: !s.added,
@@ -170,6 +285,7 @@ function derive(s: S): S {
170
285
  const svc = defineService<S, {
171
286
  load: (s: S, arg?: unknown) => Promise<S>;
172
287
  setQuantity: (s: S, arg?: unknown) => S;
288
+ selectOption: (s: S, arg?: unknown) => S;
173
289
  addToCart: (s: S) => Promise<S>;
174
290
  beginAdd: (s: S, arg?: unknown) => S;
175
291
  share: (s: S) => Promise<S>;
@@ -206,6 +322,18 @@ const svc = defineService<S, {
206
322
  saved: false,
207
323
  saveLabel: "",
208
324
  saveHint: "",
325
+ optionGroups: [],
326
+ selection: {},
327
+ hasOptions: false,
328
+ noOptions: true,
329
+ optionsOverflow: false,
330
+ noOptionsOverflow: true,
331
+ optionsIncomplete: false,
332
+ optionsUnknown: false,
333
+ optionsHint: "",
334
+ opt1Label: "", opt1Chips: [], opt1Selected: [], noOpt1: true,
335
+ opt2Label: "", opt2Chips: [], opt2Selected: [], noOpt2: true,
336
+ opt3Label: "", opt3Chips: [], opt3Selected: [], noOpt3: true,
209
337
  }),
210
338
  actions: {
211
339
  load: async (s, arg) => {
@@ -217,15 +345,39 @@ const svc = defineService<S, {
217
345
  s = { ...s, saved, saveHint: "", adding: false, added: false };
218
346
  // A fresh navigation into the product resets any quantity left over
219
347
  // from a previous visit to the same worker instance.
220
- if (!id) return derive({ ...s, status: "ready", quantity: 1, emptyText: copyParam("product.notFoundText", "Product not found.") });
348
+ if (!id) {
349
+ lastProduct = undefined;
350
+ lastLoadedId = undefined;
351
+ return derive({ ...s, status: "ready", quantity: 1, optionGroups: [], selection: {}, optionsUnknown: false, emptyText: copyParam("product.notFoundText", "Product not found.") });
352
+ }
221
353
  try {
222
354
  const r = await getStoreProduct(id);
223
355
  const p = (r.product ?? (r as unknown as StoreProduct)) as StoreProduct;
224
356
  const name = cleanTitle(p.name) || "Product";
225
357
  const image = productImage(p, WIX_DETAIL_IMAGE);
358
+ lastProduct = p;
359
+ // A group with exactly one choice is chosen for the shopper; a real choice
360
+ // is left blank so the pick is deliberate.
361
+ const optionGroups = productOptions(p);
362
+ // A pooled screen re-appearing re-runs `load`. Wiping `selection` there means a
363
+ // trip to the cart and back silently clears the size the shopper picked and
364
+ // re-disables the CTA with no explanation. Carry the picks when this is the SAME
365
+ // product, and only for a choice the product still offers — a stale id would
366
+ // resolve to no variant and block the cart just as silently.
367
+ const carried: StoreOptionSelection = {};
368
+ if (id === lastLoadedId) {
369
+ for (const g of optionGroups) {
370
+ const chosen = s.selection[g.id];
371
+ if (chosen && g.choices.some((c) => c.id === chosen)) carried[g.id] = chosen;
372
+ }
373
+ }
374
+ lastLoadedId = id;
226
375
  return derive({
227
376
  ...s,
228
377
  status: "ready",
378
+ optionGroups,
379
+ selection: { ...defaultSelection(optionGroups), ...carried },
380
+ optionsUnknown: false,
229
381
  name,
230
382
  price: productPrice(p),
231
383
  description: plainText(p.plainDescription || p.description),
@@ -244,21 +396,34 @@ const svc = defineService<S, {
244
396
  if (cached) {
245
397
  const name = String(cached.name ?? "") || "Product";
246
398
  const image = String(cached.image ?? "");
399
+ // The cached list row carries no options, and guessing "none" would offer
400
+ // a one-tap Add to cart for a product that needs a colour and a size. So
401
+ // the offline detail shows no picker AND marks the options UNKNOWN, which
402
+ // keeps `optionsIncomplete` true: the CTA stays disabled and `addToCart`
403
+ // refuses. `cartHint` carries the reason, because the options card is hidden
404
+ // on `noOptions` and would otherwise leave a dead button unexplained.
405
+ lastProduct = undefined;
406
+ lastLoadedId = undefined;
247
407
  return derive({
248
408
  ...s,
249
409
  status: "ready",
410
+ optionGroups: [],
411
+ selection: {},
412
+ optionsUnknown: true,
250
413
  name,
251
414
  price: String(cached.subtitle ?? ""),
252
415
  description: "",
253
416
  image,
254
417
  initial: String(cached.initial ?? "") || (name[0] || "#").toUpperCase(),
255
- cartHint: "",
418
+ cartHint: copyParam("product.cartHintOffline", "Reconnect to choose options and add this to your cart."),
256
419
  cartCount: await cartItemCount(),
257
420
  emptyText: "",
258
421
  quantity: 1,
259
422
  });
260
423
  }
261
- return derive({ ...s, status: "error", emptyText: copyParam("product.errorText", "Product could not load.") });
424
+ lastProduct = undefined;
425
+ lastLoadedId = undefined;
426
+ return derive({ ...s, status: "error", optionGroups: [], selection: {}, optionsUnknown: false, emptyText: copyParam("product.errorText", "Product could not load.") });
262
427
  }
263
428
  },
264
429
  // The stepper fires onChange with { value } on every tap; clamp 1–99 and
@@ -267,6 +432,24 @@ const svc = defineService<S, {
267
432
  const quantity = parseQuantity(argValue(arg, "value"));
268
433
  return quantity ? derive({ ...s, quantity }) : s;
269
434
  },
435
+ // One chip row per option group. `group` is the descriptor slot ("1".."3");
436
+ // FilterChips sends back the WHOLE selected set, which for a single-select row
437
+ // is either one id or none (re-tapping the active chip clears it).
438
+ selectOption: (s, arg) => {
439
+ const slot = Math.floor(Number(argValue(arg, "group")));
440
+ const group = s.optionGroups[slot - 1];
441
+ if (!group) return s;
442
+ const raw = (arg as { selectedIds?: unknown } | undefined)?.selectedIds;
443
+ const picked = Array.isArray(raw) ? String(raw[0] ?? "") : String(raw ?? "");
444
+ // Only a choice this group actually offers — a stale id from a previous
445
+ // product would otherwise resolve to no variant and block the cart silently.
446
+ const choice = group.choices.some((c) => c.id === picked) ? picked : "";
447
+ const selection = { ...s.selection };
448
+ if (choice) selection[group.id] = choice; else delete selection[group.id];
449
+ // A new choice invalidates the last add's confirmation — it was for the
450
+ // previous variant.
451
+ return derive({ ...s, selection, added: false, cartHint: "" });
452
+ },
270
453
  // The pending half of addToCart, as its own reducer so it PUBLISHES before the
271
454
  // network write starts: the pending line shows and the top-bar badge ticks up in the same
272
455
  // interaction, instead of the tap looking dead until the round-trip (and the badge re-read)
@@ -280,20 +463,44 @@ const svc = defineService<S, {
280
463
  if (!id) return derive({ ...s, cartHint: copyParam("product.cartHintNoProduct", "No product to add.") });
281
464
  // A second tap while the first write is in flight must not add the line twice.
282
465
  if (s.adding) return s;
466
+ // The CTA is already disabled on this state; this is the second gate, because
467
+ // a voice-control/deep-link dispatch reaches the action without the button.
468
+ if (s.optionsIncomplete) {
469
+ // With no groups in hand there is nothing for `optionsHint` to name, so the
470
+ // offline case needs its own reason rather than an empty string.
471
+ return derive({ ...s, cartHint: s.optionsUnknown
472
+ ? copyParam("product.cartHintOffline", "Reconnect to choose options and add this to your cart.")
473
+ : optionsHint(s.optionGroups, s.selection) || copyParam("product.cartHintNoVariant", "That combination is not available. Try another.") });
474
+ }
475
+ // Which variant the choice resolves to is the SDK's job — V3 addresses the
476
+ // choice by variantId, V1 by a name→choice map, and it throws rather than
477
+ // hand back a payload that would add a line with no variant.
478
+ let cartOptions: Record<string, unknown> | undefined;
479
+ try {
480
+ cartOptions = cartOptionsPayload(lastProduct, s.optionGroups, s.selection);
481
+ } catch {
482
+ return derive({ ...s, cartHint: copyParam("product.cartHintNoVariant", "That combination is not available. Try another.") });
483
+ }
283
484
  const quantity = s.quantity > 0 ? s.quantity : 1;
284
485
  const before = s.cartCount;
285
486
  await svc.beginAdd({ quantity: String(quantity) });
286
487
  s = svc.getState() as S;
287
488
  // Optimistic local cart line — the cart repaints from cache immediately (offline or not).
288
- const line: DbEntity = { id: `line-${id}`, productId: id, name: s.name || "product", title: s.name || "product", subtitle: `Qty ${quantity}`, quantity: String(quantity) };
489
+ const chosen = selectionSummary(s.optionGroups, s.selection);
490
+ // Keyed by product AND variant. `line-${id}` alone collapses two variants of the
491
+ // same product into one cached row while the badge counts both — the offline cart
492
+ // then shows one line for two adds. Sorted so the key is stable whatever order the
493
+ // groups were picked in, and reduced to id-safe characters.
494
+ const variantKey = Object.keys(s.selection).sort().map((k) => `${k}:${s.selection[k]}`).join("|").replace(/[^A-Za-z0-9]+/g, "_");
495
+ 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) };
289
496
  await cartPut(line);
290
497
  try {
291
- await addToCurrentCart(id, quantity);
498
+ await addToCurrentCart(id, quantity, cartOptions);
292
499
  } catch (e) {
293
500
  if (isBridgeUnavailable(e)) {
294
501
  // Offline / no bridge: the optimistic cache line already shows; queue the
295
502
  // add to replay (WriteQueue, idempotent) when connectivity returns.
296
- await queueWrite("tier5.himiStoreProduct.addToCart", { id });
503
+ await queueWrite("tier5.himiStoreProduct.addToCart", { id, ...(cartOptions ? { options: JSON.stringify(cartOptions) } : {}) });
297
504
  return derive({ ...s, adding: false, added: false, cartHint: copyTemplate("product.cartHintQueued", "Added {name} to cart. Will sync when you reconnect.", { name: s.name || "product" }) });
298
505
  }
299
506
  // Roll the optimistic badge back — nothing was added.