@wix/himalaya-cli 0.1524.0 → 0.1525.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.
@@ -93,6 +93,25 @@ function productPolicyBody(product: StoreProduct, kind: "returns" | "shipping"):
93
93
  * DIFFERENT product (drop the picks) from the same product coming back (keep them). */
94
94
  let lastLoadedId: string | undefined;
95
95
  let productLoadSequence = 0;
96
+ let booting = true;
97
+ let bootLoad: { id: string; state: Promise<S> } | undefined;
98
+
99
+ /** The host replays the screen's onAppear load right after the module-scope one: that one replay
100
+ * joins the boot load instead of reading the product, cart and App Store prices again. Any later
101
+ * entry loads afresh, since the cart, the saved flag or a purchase may have changed meanwhile. */
102
+ function joinedLoad(id: string, load: () => Promise<S>): Promise<S> {
103
+ if (booting) {
104
+ booting = false;
105
+ const current = { id, state: load() };
106
+ bootLoad = current;
107
+ const settle = () => { if (bootLoad === current) bootLoad = undefined; };
108
+ current.state.then(settle, settle);
109
+ return current.state;
110
+ }
111
+ const replayed = bootLoad;
112
+ bootLoad = undefined;
113
+ return replayed?.id === id ? replayed.state : load();
114
+ }
96
115
 
97
116
  interface OptionSlots {
98
117
  opt1Label: string; opt1Chips: OptionChip[]; opt1Selected: string[]; opt1SelectedId: string; opt1Placeholder: string; noOpt1: boolean;
@@ -576,144 +595,146 @@ const svc = defineService<S, {
576
595
  purchasedNote: "",
577
596
  }),
578
597
  actions: {
579
- load: async (s, arg): Promise<S> => {
598
+ load: (s, arg): Promise<S> => {
580
599
  stashCopy(arg);
581
- enterCheckout();
582
- const sequence = ++productLoadSequence;
583
- lastProduct = undefined;
584
- const id = inputId();
585
- // Storage problems must not prevent browsing or buying a product.
586
- let saved = false;
587
- try { saved = isProductSaved(id); } catch { /* save action reports a retryable error */ }
588
- s = { ...s, saved, saveHint: "", shareHint: "", adding: false, added: false, addedSheetPresented: false, quantity: 1, showOptionErrors: false, returnPolicyExpanded: false, shippingInfoExpanded: false };
589
- // A fresh navigation into the product resets any quantity left over
590
- // from a previous visit to the same worker instance.
591
- if (!id) {
600
+ return joinedLoad(inputId(), async (): Promise<S> => {
601
+ enterCheckout();
602
+ const sequence = ++productLoadSequence;
592
603
  lastProduct = undefined;
593
- lastLoadedId = undefined;
594
- return derive({ ...s, status: "ready", quantity: 1, optionGroups: [], selection: {}, optionsUnknown: false, descriptionJson: "", emptyText: copyParam("product.notFoundText", "Product not found.") });
595
- }
596
- // The cart count only feeds the top-bar badge: ask for it NOW, beside the product, instead
597
- // of after it — it used to add a whole cart round trip before the page could paint.
598
- const cartCount = cartItemCount();
599
- // The badge must never hold the page: remember the count if it is already in, and
600
- // publish it separately if it lands after the product (#3468 review).
601
- let cartKnown: number | undefined;
602
- void cartCount.then((n) => { cartKnown = n; }, () => {});
603
- // The live product read goes out FIRST too — the local-db read below must never delay it.
604
- // Settled into a value so a failure is handled where it is awaited, never unhandled.
605
- // DESCRIPTION widens the default fieldset with the V3 Ricos description (a V1 catalog ignores it).
606
- const live = getStoreProduct(id, ["DESCRIPTION"]).then(
607
- (r) => ({ ok: true as const, r }),
608
- () => ({ ok: false as const }),
609
- );
610
- // Paint at once from the row the list already synced (name, price, image) — the page used
611
- // to sit on its skeleton for the product round trip even when the list had just shown it.
612
- // The live read then fills in the description and gallery.
613
- const synced = await readCachedProduct(id);
614
- let painted = false;
615
- if (synced && s.status === "loading") {
616
- await svc.paintCached(synced);
617
- painted = true;
618
- }
619
- const outcome = await live;
620
- if (sequence !== productLoadSequence || id !== inputId()) return svc.getState() as S;
621
- // Once painted the page is INTERACTIVE while the live read is on the wire: the visitor may
622
- // save, change the quantity or add to cart. Build on the state as it is NOW, and keep what
623
- // they did — overwriting it would untick a stored save, reset the quantity, and drop the
624
- // in-flight add's `adding` guard (a second tap would add the line twice).
625
- const base: S = painted ? (svc.getState() as S) : s;
626
- const keep = painted
627
- ? { saved: base.saved, saveHint: base.saveHint, quantity: base.quantity, adding: base.adding, added: base.added, cartHint: base.cartHint }
628
- : { cartHint: "", quantity: 1 };
629
- const badge = (): number => {
630
- if (painted && (base.adding || base.added)) return base.cartCount;
631
- if (cartKnown !== undefined) return cartKnown;
632
- const epoch = cartBadgeEpoch;
633
- void cartCount.then((n) => { void svc.setCartCount({ count: n, epoch }); }, () => {});
634
- return base.cartCount;
635
- };
636
- if (outcome.ok) {
637
- const p = (outcome.r.product ?? (outcome.r as unknown as StoreProduct)) as StoreProduct;
638
- const name = cleanTitle(p.name) || "Product";
639
- const image = productImage(p, WIX_DETAIL_IMAGE);
640
- lastProduct = p;
641
- // A group with exactly one choice is chosen for the shopper; a real choice
642
- // is left blank so the pick is deliberate.
643
- const optionGroups = productOptions(p);
644
- const sale = productSale(p);
645
- // A pooled screen re-appearing re-runs `load`. Wiping `selection` there means a
646
- // trip to the cart and back silently clears the size the shopper picked and
647
- // re-disables the CTA with no explanation. Carry the picks when this is the SAME
648
- // product, and only for a choice the product still offers — a stale id would
649
- // resolve to no variant and block the cart just as silently.
650
- const carried: StoreOptionSelection = {};
651
- if (id === lastLoadedId) {
652
- for (const g of optionGroups) {
653
- const chosen = s.selection[g.id];
654
- if (chosen && g.choices.some((c) => c.id === chosen)) carried[g.id] = chosen;
604
+ const id = inputId();
605
+ // Storage problems must not prevent browsing or buying a product.
606
+ let saved = false;
607
+ try { saved = isProductSaved(id); } catch { /* save action reports a retryable error */ }
608
+ s = { ...s, saved, saveHint: "", shareHint: "", adding: false, added: false, addedSheetPresented: false, quantity: 1, showOptionErrors: false, returnPolicyExpanded: false, shippingInfoExpanded: false };
609
+ // A fresh navigation into the product resets any quantity left over
610
+ // from a previous visit to the same worker instance.
611
+ if (!id) {
612
+ lastProduct = undefined;
613
+ lastLoadedId = undefined;
614
+ return derive({ ...s, status: "ready", quantity: 1, optionGroups: [], selection: {}, optionsUnknown: false, descriptionJson: "", emptyText: copyParam("product.notFoundText", "Product not found.") });
615
+ }
616
+ // The cart count only feeds the top-bar badge: ask for it NOW, beside the product, instead
617
+ // of after it — it used to add a whole cart round trip before the page could paint.
618
+ const cartCount = cartItemCount();
619
+ // The badge must never hold the page: remember the count if it is already in, and
620
+ // publish it separately if it lands after the product (#3468 review).
621
+ let cartKnown: number | undefined;
622
+ void cartCount.then((n) => { cartKnown = n; }, () => {});
623
+ // The live product read goes out FIRST too — the local-db read below must never delay it.
624
+ // Settled into a value so a failure is handled where it is awaited, never unhandled.
625
+ // DESCRIPTION widens the default fieldset with the V3 Ricos description (a V1 catalog ignores it).
626
+ const live = getStoreProduct(id, ["DESCRIPTION"]).then(
627
+ (r) => ({ ok: true as const, r }),
628
+ () => ({ ok: false as const }),
629
+ );
630
+ // Paint at once from the row the list already synced (name, price, image) — the page used
631
+ // to sit on its skeleton for the product round trip even when the list had just shown it.
632
+ // The live read then fills in the description and gallery.
633
+ const synced = await readCachedProduct(id);
634
+ let painted = false;
635
+ if (synced && s.status === "loading") {
636
+ await svc.paintCached(synced);
637
+ painted = true;
638
+ }
639
+ const outcome = await live;
640
+ if (sequence !== productLoadSequence || id !== inputId()) return svc.getState() as S;
641
+ // Once painted the page is INTERACTIVE while the live read is on the wire: the visitor may
642
+ // save, change the quantity or add to cart. Build on the state as it is NOW, and keep what
643
+ // they did — overwriting it would untick a stored save, reset the quantity, and drop the
644
+ // in-flight add's `adding` guard (a second tap would add the line twice).
645
+ const base: S = painted ? (svc.getState() as S) : s;
646
+ const keep = painted
647
+ ? { saved: base.saved, saveHint: base.saveHint, quantity: base.quantity, adding: base.adding, added: base.added, cartHint: base.cartHint }
648
+ : { cartHint: "", quantity: 1 };
649
+ const badge = (): number => {
650
+ if (painted && (base.adding || base.added)) return base.cartCount;
651
+ if (cartKnown !== undefined) return cartKnown;
652
+ const epoch = cartBadgeEpoch;
653
+ void cartCount.then((n) => { void svc.setCartCount({ count: n, epoch }); }, () => {});
654
+ return base.cartCount;
655
+ };
656
+ if (outcome.ok) {
657
+ const p = (outcome.r.product ?? (outcome.r as unknown as StoreProduct)) as StoreProduct;
658
+ const name = cleanTitle(p.name) || "Product";
659
+ const image = productImage(p, WIX_DETAIL_IMAGE);
660
+ lastProduct = p;
661
+ // A group with exactly one choice is chosen for the shopper; a real choice
662
+ // is left blank so the pick is deliberate.
663
+ const optionGroups = productOptions(p);
664
+ const sale = productSale(p);
665
+ // A pooled screen re-appearing re-runs `load`. Wiping `selection` there means a
666
+ // trip to the cart and back silently clears the size the shopper picked and
667
+ // re-disables the CTA with no explanation. Carry the picks when this is the SAME
668
+ // product, and only for a choice the product still offers — a stale id would
669
+ // resolve to no variant and block the cart just as silently.
670
+ const carried: StoreOptionSelection = {};
671
+ if (id === lastLoadedId) {
672
+ for (const g of optionGroups) {
673
+ const chosen = s.selection[g.id];
674
+ if (chosen && g.choices.some((c) => c.id === chosen)) carried[g.id] = chosen;
675
+ }
655
676
  }
677
+ lastLoadedId = id;
678
+ return derive({
679
+ ...base,
680
+ ...keep,
681
+ status: "ready",
682
+ optionGroups,
683
+ selection: { ...defaultSelection(optionGroups), ...carried },
684
+ optionsUnknown: false,
685
+ name,
686
+ ...sale,
687
+ commercePrice: sale.price,
688
+ skuLabel: skuOf(p) ? `SKU: ${skuOf(p)}` : "",
689
+ returnPolicyBody: productPolicyBody(p, "returns") || copyParam("product.returnPolicyBody", "") || copyParam("product.policyUnavailable", "Policy details have not been provided by this store."),
690
+ shippingInfoBody: productPolicyBody(p, "shipping") || copyParam("product.shippingInfoBody", "") || copyParam("product.policyUnavailable", "Policy details have not been provided by this store."),
691
+ ...productBody(p),
692
+ image,
693
+ gallery: productGallery(p),
694
+ initial: (name[0] || "#").toUpperCase(),
695
+ shareUrl: productShareUrl(p),
696
+ cartCount: badge(),
697
+ emptyText: "",
698
+ });
699
+ }
700
+ // Offline: fall back to the last-synced cached product row before the error state.
701
+ if (synced) {
702
+ const name = String(synced.name ?? "") || "Product";
703
+ const image = String(synced.image ?? "");
704
+ // The cached list row carries no options, and guessing "none" would offer
705
+ // a one-tap Add to cart for a product that needs a colour and a size. So
706
+ // the offline detail shows no picker AND marks the options UNKNOWN, which
707
+ // keeps `optionsIncomplete` true: the CTA stays disabled and `addToCart`
708
+ // refuses. `cartHint` carries the reason, because the options card is hidden
709
+ // on `noOptions` and would otherwise leave a dead button unexplained.
710
+ lastProduct = undefined;
711
+ lastLoadedId = undefined;
712
+ return derive({
713
+ ...base,
714
+ ...keep,
715
+ status: "ready",
716
+ optionGroups: [],
717
+ selection: {},
718
+ optionsUnknown: true,
719
+ name,
720
+ price: String(synced.subtitle ?? ""),
721
+ wasPrice: "",
722
+ skuLabel: "",
723
+ returnPolicyBody: "",
724
+ shippingInfoBody: "",
725
+ description: "",
726
+ descriptionJson: "",
727
+ image,
728
+ initial: String(synced.initial ?? "") || (name[0] || "#").toUpperCase(),
729
+ cartHint: copyParam("product.cartHintOffline", "Reconnect to choose options and add this to your cart."),
730
+ cartCount: badge(),
731
+ emptyText: "",
732
+ });
656
733
  }
657
- lastLoadedId = id;
658
- return derive({
659
- ...base,
660
- ...keep,
661
- status: "ready",
662
- optionGroups,
663
- selection: { ...defaultSelection(optionGroups), ...carried },
664
- optionsUnknown: false,
665
- name,
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."),
671
- ...productBody(p),
672
- image,
673
- gallery: productGallery(p),
674
- initial: (name[0] || "#").toUpperCase(),
675
- shareUrl: productShareUrl(p),
676
- cartCount: badge(),
677
- emptyText: "",
678
- });
679
- }
680
- // Offline: fall back to the last-synced cached product row before the error state.
681
- if (synced) {
682
- const name = String(synced.name ?? "") || "Product";
683
- const image = String(synced.image ?? "");
684
- // The cached list row carries no options, and guessing "none" would offer
685
- // a one-tap Add to cart for a product that needs a colour and a size. So
686
- // the offline detail shows no picker AND marks the options UNKNOWN, which
687
- // keeps `optionsIncomplete` true: the CTA stays disabled and `addToCart`
688
- // refuses. `cartHint` carries the reason, because the options card is hidden
689
- // on `noOptions` and would otherwise leave a dead button unexplained.
690
734
  lastProduct = undefined;
691
735
  lastLoadedId = undefined;
692
- return derive({
693
- ...base,
694
- ...keep,
695
- status: "ready",
696
- optionGroups: [],
697
- selection: {},
698
- optionsUnknown: true,
699
- name,
700
- price: String(synced.subtitle ?? ""),
701
- wasPrice: "",
702
- skuLabel: "",
703
- returnPolicyBody: "",
704
- shippingInfoBody: "",
705
- description: "",
706
- descriptionJson: "",
707
- image,
708
- initial: String(synced.initial ?? "") || (name[0] || "#").toUpperCase(),
709
- cartHint: copyParam("product.cartHintOffline", "Reconnect to choose options and add this to your cart."),
710
- cartCount: badge(),
711
- emptyText: "",
712
- });
713
- }
714
- lastProduct = undefined;
715
- lastLoadedId = undefined;
716
- return derive({ ...s, status: "error", optionGroups: [], selection: {}, optionsUnknown: false, descriptionJson: "", emptyText: copyParam("product.errorText", "Product could not load.") });
736
+ return derive({ ...s, status: "error", optionGroups: [], selection: {}, optionsUnknown: false, descriptionJson: "", emptyText: copyParam("product.errorText", "Product could not load.") });
737
+ });
717
738
  },
718
739
  // The stepper fires onChange with { value } on every tap; clamp 1–99 and
719
740
  // ignore unparsable/garbage args instead of silently zeroing the quantity.
@@ -93,6 +93,25 @@ function productPolicyBody(product: StoreProduct, kind: "returns" | "shipping"):
93
93
  * DIFFERENT product (drop the picks) from the same product coming back (keep them). */
94
94
  let lastLoadedId: string | undefined;
95
95
  let productLoadSequence = 0;
96
+ let booting = true;
97
+ let bootLoad: { id: string; state: Promise<S> } | undefined;
98
+
99
+ /** The host replays the screen's onAppear load right after the module-scope one: that one replay
100
+ * joins the boot load instead of reading the product, cart and App Store prices again. Any later
101
+ * entry loads afresh, since the cart, the saved flag or a purchase may have changed meanwhile. */
102
+ function joinedLoad(id: string, load: () => Promise<S>): Promise<S> {
103
+ if (booting) {
104
+ booting = false;
105
+ const current = { id, state: load() };
106
+ bootLoad = current;
107
+ const settle = () => { if (bootLoad === current) bootLoad = undefined; };
108
+ current.state.then(settle, settle);
109
+ return current.state;
110
+ }
111
+ const replayed = bootLoad;
112
+ bootLoad = undefined;
113
+ return replayed?.id === id ? replayed.state : load();
114
+ }
96
115
 
97
116
  interface OptionSlots {
98
117
  opt1Label: string; opt1Chips: OptionChip[]; opt1Selected: string[]; opt1SelectedId: string; opt1Placeholder: string; noOpt1: boolean;
@@ -576,144 +595,146 @@ const svc = defineService<S, {
576
595
  purchasedNote: "",
577
596
  }),
578
597
  actions: {
579
- load: async (s, arg): Promise<S> => {
598
+ load: (s, arg): Promise<S> => {
580
599
  stashCopy(arg);
581
- enterCheckout();
582
- const sequence = ++productLoadSequence;
583
- lastProduct = undefined;
584
- const id = inputId();
585
- // Storage problems must not prevent browsing or buying a product.
586
- let saved = false;
587
- try { saved = isProductSaved(id); } catch { /* save action reports a retryable error */ }
588
- s = { ...s, saved, saveHint: "", shareHint: "", adding: false, added: false, addedSheetPresented: false, quantity: 1, showOptionErrors: false, returnPolicyExpanded: false, shippingInfoExpanded: false };
589
- // A fresh navigation into the product resets any quantity left over
590
- // from a previous visit to the same worker instance.
591
- if (!id) {
600
+ return joinedLoad(inputId(), async (): Promise<S> => {
601
+ enterCheckout();
602
+ const sequence = ++productLoadSequence;
592
603
  lastProduct = undefined;
593
- lastLoadedId = undefined;
594
- return derive({ ...s, status: "ready", quantity: 1, optionGroups: [], selection: {}, optionsUnknown: false, descriptionJson: "", emptyText: copyParam("product.notFoundText", "Product not found.") });
595
- }
596
- // The cart count only feeds the top-bar badge: ask for it NOW, beside the product, instead
597
- // of after it — it used to add a whole cart round trip before the page could paint.
598
- const cartCount = cartItemCount();
599
- // The badge must never hold the page: remember the count if it is already in, and
600
- // publish it separately if it lands after the product (#3468 review).
601
- let cartKnown: number | undefined;
602
- void cartCount.then((n) => { cartKnown = n; }, () => {});
603
- // The live product read goes out FIRST too — the local-db read below must never delay it.
604
- // Settled into a value so a failure is handled where it is awaited, never unhandled.
605
- // DESCRIPTION widens the default fieldset with the V3 Ricos description (a V1 catalog ignores it).
606
- const live = getStoreProduct(id, ["DESCRIPTION"]).then(
607
- (r) => ({ ok: true as const, r }),
608
- () => ({ ok: false as const }),
609
- );
610
- // Paint at once from the row the list already synced (name, price, image) — the page used
611
- // to sit on its skeleton for the product round trip even when the list had just shown it.
612
- // The live read then fills in the description and gallery.
613
- const synced = await readCachedProduct(id);
614
- let painted = false;
615
- if (synced && s.status === "loading") {
616
- await svc.paintCached(synced);
617
- painted = true;
618
- }
619
- const outcome = await live;
620
- if (sequence !== productLoadSequence || id !== inputId()) return svc.getState() as S;
621
- // Once painted the page is INTERACTIVE while the live read is on the wire: the visitor may
622
- // save, change the quantity or add to cart. Build on the state as it is NOW, and keep what
623
- // they did — overwriting it would untick a stored save, reset the quantity, and drop the
624
- // in-flight add's `adding` guard (a second tap would add the line twice).
625
- const base: S = painted ? (svc.getState() as S) : s;
626
- const keep = painted
627
- ? { saved: base.saved, saveHint: base.saveHint, quantity: base.quantity, adding: base.adding, added: base.added, cartHint: base.cartHint }
628
- : { cartHint: "", quantity: 1 };
629
- const badge = (): number => {
630
- if (painted && (base.adding || base.added)) return base.cartCount;
631
- if (cartKnown !== undefined) return cartKnown;
632
- const epoch = cartBadgeEpoch;
633
- void cartCount.then((n) => { void svc.setCartCount({ count: n, epoch }); }, () => {});
634
- return base.cartCount;
635
- };
636
- if (outcome.ok) {
637
- const p = (outcome.r.product ?? (outcome.r as unknown as StoreProduct)) as StoreProduct;
638
- const name = cleanTitle(p.name) || "Product";
639
- const image = productImage(p, WIX_DETAIL_IMAGE);
640
- lastProduct = p;
641
- // A group with exactly one choice is chosen for the shopper; a real choice
642
- // is left blank so the pick is deliberate.
643
- const optionGroups = productOptions(p);
644
- const sale = productSale(p);
645
- // A pooled screen re-appearing re-runs `load`. Wiping `selection` there means a
646
- // trip to the cart and back silently clears the size the shopper picked and
647
- // re-disables the CTA with no explanation. Carry the picks when this is the SAME
648
- // product, and only for a choice the product still offers — a stale id would
649
- // resolve to no variant and block the cart just as silently.
650
- const carried: StoreOptionSelection = {};
651
- if (id === lastLoadedId) {
652
- for (const g of optionGroups) {
653
- const chosen = s.selection[g.id];
654
- if (chosen && g.choices.some((c) => c.id === chosen)) carried[g.id] = chosen;
604
+ const id = inputId();
605
+ // Storage problems must not prevent browsing or buying a product.
606
+ let saved = false;
607
+ try { saved = isProductSaved(id); } catch { /* save action reports a retryable error */ }
608
+ s = { ...s, saved, saveHint: "", shareHint: "", adding: false, added: false, addedSheetPresented: false, quantity: 1, showOptionErrors: false, returnPolicyExpanded: false, shippingInfoExpanded: false };
609
+ // A fresh navigation into the product resets any quantity left over
610
+ // from a previous visit to the same worker instance.
611
+ if (!id) {
612
+ lastProduct = undefined;
613
+ lastLoadedId = undefined;
614
+ return derive({ ...s, status: "ready", quantity: 1, optionGroups: [], selection: {}, optionsUnknown: false, descriptionJson: "", emptyText: copyParam("product.notFoundText", "Product not found.") });
615
+ }
616
+ // The cart count only feeds the top-bar badge: ask for it NOW, beside the product, instead
617
+ // of after it — it used to add a whole cart round trip before the page could paint.
618
+ const cartCount = cartItemCount();
619
+ // The badge must never hold the page: remember the count if it is already in, and
620
+ // publish it separately if it lands after the product (#3468 review).
621
+ let cartKnown: number | undefined;
622
+ void cartCount.then((n) => { cartKnown = n; }, () => {});
623
+ // The live product read goes out FIRST too — the local-db read below must never delay it.
624
+ // Settled into a value so a failure is handled where it is awaited, never unhandled.
625
+ // DESCRIPTION widens the default fieldset with the V3 Ricos description (a V1 catalog ignores it).
626
+ const live = getStoreProduct(id, ["DESCRIPTION"]).then(
627
+ (r) => ({ ok: true as const, r }),
628
+ () => ({ ok: false as const }),
629
+ );
630
+ // Paint at once from the row the list already synced (name, price, image) — the page used
631
+ // to sit on its skeleton for the product round trip even when the list had just shown it.
632
+ // The live read then fills in the description and gallery.
633
+ const synced = await readCachedProduct(id);
634
+ let painted = false;
635
+ if (synced && s.status === "loading") {
636
+ await svc.paintCached(synced);
637
+ painted = true;
638
+ }
639
+ const outcome = await live;
640
+ if (sequence !== productLoadSequence || id !== inputId()) return svc.getState() as S;
641
+ // Once painted the page is INTERACTIVE while the live read is on the wire: the visitor may
642
+ // save, change the quantity or add to cart. Build on the state as it is NOW, and keep what
643
+ // they did — overwriting it would untick a stored save, reset the quantity, and drop the
644
+ // in-flight add's `adding` guard (a second tap would add the line twice).
645
+ const base: S = painted ? (svc.getState() as S) : s;
646
+ const keep = painted
647
+ ? { saved: base.saved, saveHint: base.saveHint, quantity: base.quantity, adding: base.adding, added: base.added, cartHint: base.cartHint }
648
+ : { cartHint: "", quantity: 1 };
649
+ const badge = (): number => {
650
+ if (painted && (base.adding || base.added)) return base.cartCount;
651
+ if (cartKnown !== undefined) return cartKnown;
652
+ const epoch = cartBadgeEpoch;
653
+ void cartCount.then((n) => { void svc.setCartCount({ count: n, epoch }); }, () => {});
654
+ return base.cartCount;
655
+ };
656
+ if (outcome.ok) {
657
+ const p = (outcome.r.product ?? (outcome.r as unknown as StoreProduct)) as StoreProduct;
658
+ const name = cleanTitle(p.name) || "Product";
659
+ const image = productImage(p, WIX_DETAIL_IMAGE);
660
+ lastProduct = p;
661
+ // A group with exactly one choice is chosen for the shopper; a real choice
662
+ // is left blank so the pick is deliberate.
663
+ const optionGroups = productOptions(p);
664
+ const sale = productSale(p);
665
+ // A pooled screen re-appearing re-runs `load`. Wiping `selection` there means a
666
+ // trip to the cart and back silently clears the size the shopper picked and
667
+ // re-disables the CTA with no explanation. Carry the picks when this is the SAME
668
+ // product, and only for a choice the product still offers — a stale id would
669
+ // resolve to no variant and block the cart just as silently.
670
+ const carried: StoreOptionSelection = {};
671
+ if (id === lastLoadedId) {
672
+ for (const g of optionGroups) {
673
+ const chosen = s.selection[g.id];
674
+ if (chosen && g.choices.some((c) => c.id === chosen)) carried[g.id] = chosen;
675
+ }
655
676
  }
677
+ lastLoadedId = id;
678
+ return derive({
679
+ ...base,
680
+ ...keep,
681
+ status: "ready",
682
+ optionGroups,
683
+ selection: { ...defaultSelection(optionGroups), ...carried },
684
+ optionsUnknown: false,
685
+ name,
686
+ ...sale,
687
+ commercePrice: sale.price,
688
+ skuLabel: skuOf(p) ? `SKU: ${skuOf(p)}` : "",
689
+ returnPolicyBody: productPolicyBody(p, "returns") || copyParam("product.returnPolicyBody", "") || copyParam("product.policyUnavailable", "Policy details have not been provided by this store."),
690
+ shippingInfoBody: productPolicyBody(p, "shipping") || copyParam("product.shippingInfoBody", "") || copyParam("product.policyUnavailable", "Policy details have not been provided by this store."),
691
+ ...productBody(p),
692
+ image,
693
+ gallery: productGallery(p),
694
+ initial: (name[0] || "#").toUpperCase(),
695
+ shareUrl: productShareUrl(p),
696
+ cartCount: badge(),
697
+ emptyText: "",
698
+ });
699
+ }
700
+ // Offline: fall back to the last-synced cached product row before the error state.
701
+ if (synced) {
702
+ const name = String(synced.name ?? "") || "Product";
703
+ const image = String(synced.image ?? "");
704
+ // The cached list row carries no options, and guessing "none" would offer
705
+ // a one-tap Add to cart for a product that needs a colour and a size. So
706
+ // the offline detail shows no picker AND marks the options UNKNOWN, which
707
+ // keeps `optionsIncomplete` true: the CTA stays disabled and `addToCart`
708
+ // refuses. `cartHint` carries the reason, because the options card is hidden
709
+ // on `noOptions` and would otherwise leave a dead button unexplained.
710
+ lastProduct = undefined;
711
+ lastLoadedId = undefined;
712
+ return derive({
713
+ ...base,
714
+ ...keep,
715
+ status: "ready",
716
+ optionGroups: [],
717
+ selection: {},
718
+ optionsUnknown: true,
719
+ name,
720
+ price: String(synced.subtitle ?? ""),
721
+ wasPrice: "",
722
+ skuLabel: "",
723
+ returnPolicyBody: "",
724
+ shippingInfoBody: "",
725
+ description: "",
726
+ descriptionJson: "",
727
+ image,
728
+ initial: String(synced.initial ?? "") || (name[0] || "#").toUpperCase(),
729
+ cartHint: copyParam("product.cartHintOffline", "Reconnect to choose options and add this to your cart."),
730
+ cartCount: badge(),
731
+ emptyText: "",
732
+ });
656
733
  }
657
- lastLoadedId = id;
658
- return derive({
659
- ...base,
660
- ...keep,
661
- status: "ready",
662
- optionGroups,
663
- selection: { ...defaultSelection(optionGroups), ...carried },
664
- optionsUnknown: false,
665
- name,
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."),
671
- ...productBody(p),
672
- image,
673
- gallery: productGallery(p),
674
- initial: (name[0] || "#").toUpperCase(),
675
- shareUrl: productShareUrl(p),
676
- cartCount: badge(),
677
- emptyText: "",
678
- });
679
- }
680
- // Offline: fall back to the last-synced cached product row before the error state.
681
- if (synced) {
682
- const name = String(synced.name ?? "") || "Product";
683
- const image = String(synced.image ?? "");
684
- // The cached list row carries no options, and guessing "none" would offer
685
- // a one-tap Add to cart for a product that needs a colour and a size. So
686
- // the offline detail shows no picker AND marks the options UNKNOWN, which
687
- // keeps `optionsIncomplete` true: the CTA stays disabled and `addToCart`
688
- // refuses. `cartHint` carries the reason, because the options card is hidden
689
- // on `noOptions` and would otherwise leave a dead button unexplained.
690
734
  lastProduct = undefined;
691
735
  lastLoadedId = undefined;
692
- return derive({
693
- ...base,
694
- ...keep,
695
- status: "ready",
696
- optionGroups: [],
697
- selection: {},
698
- optionsUnknown: true,
699
- name,
700
- price: String(synced.subtitle ?? ""),
701
- wasPrice: "",
702
- skuLabel: "",
703
- returnPolicyBody: "",
704
- shippingInfoBody: "",
705
- description: "",
706
- descriptionJson: "",
707
- image,
708
- initial: String(synced.initial ?? "") || (name[0] || "#").toUpperCase(),
709
- cartHint: copyParam("product.cartHintOffline", "Reconnect to choose options and add this to your cart."),
710
- cartCount: badge(),
711
- emptyText: "",
712
- });
713
- }
714
- lastProduct = undefined;
715
- lastLoadedId = undefined;
716
- return derive({ ...s, status: "error", optionGroups: [], selection: {}, optionsUnknown: false, descriptionJson: "", emptyText: copyParam("product.errorText", "Product could not load.") });
736
+ return derive({ ...s, status: "error", optionGroups: [], selection: {}, optionsUnknown: false, descriptionJson: "", emptyText: copyParam("product.errorText", "Product could not load.") });
737
+ });
717
738
  },
718
739
  // The stepper fires onChange with { value } on every tap; clamp 1–99 and
719
740
  // ignore unparsable/garbage args instead of silently zeroing the quantity.