@wix/himalaya-cli 0.1524.0 → 0.1526.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 (55) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/builder.d.ts +14 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +22 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/worker-subtree-safe-types.generated.d.ts +4 -0
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/apple-iap-contract.d.ts +5 -1
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/cashier-charges.d.ts +3 -0
  6. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/core.mjs +7 -0
  7. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap-checkout.mjs +35 -9
  8. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap.mjs +14 -1
  9. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/screens.mjs +34 -8
  10. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/sdui.mjs +13 -0
  11. package/dist/cli.mjs +136 -39
  12. package/dist/preview-assets.generated.bin +0 -0
  13. package/guides/designing-himi-screens.md +43 -18
  14. package/guides/recipe-dynamic-list.md +7 -7
  15. package/guides/recipe-four-state.md +47 -41
  16. package/guides/recipe-search.md +1 -1
  17. package/package.json +2 -2
  18. package/templates/beauty-salon/dev/index.ts +69 -40
  19. package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +9 -1
  20. package/templates/beauty-salon/tier5-src/himi-branded-lite-place-summary/index.ts +12 -1
  21. package/templates/beauty-salon/tier5-src/himi-store-account/index.ts +69 -10
  22. package/templates/beauty-salon/tier5-src/himi-store-product/index.ts +153 -132
  23. package/templates/branded-lite/dev/index.ts +165 -88
  24. package/templates/branded-lite/tier5-src/business-chat/index.ts +4 -0
  25. package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +9 -1
  26. package/templates/branded-lite/tier5-src/himi-branded-lite-place-summary/index.ts +12 -1
  27. package/templates/branded-lite/tier5-src/himi-store-account/index.ts +69 -10
  28. package/templates/branded-lite/tier5-src/himi-store-product/index.ts +153 -132
  29. package/templates/community-club/dev/index.ts +40 -21
  30. package/templates/community-club/tier5-src/himi-store-account/index.ts +69 -10
  31. package/templates/events-meetups/dev/index.ts +36 -19
  32. package/templates/events-meetups/tier5-src/himi-store-account/index.ts +69 -10
  33. package/templates/gym-fitness/dev/index.ts +73 -42
  34. package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +9 -1
  35. package/templates/gym-fitness/tier5-src/himi-branded-lite-place-summary/index.ts +12 -1
  36. package/templates/gym-fitness/tier5-src/himi-store-account/index.ts +69 -10
  37. package/templates/gym-fitness/tier5-src/himi-store-product/index.ts +153 -132
  38. package/templates/nonprofit-cause/dev/index.ts +47 -29
  39. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-place-summary/index.ts +12 -1
  40. package/templates/nonprofit-cause/tier5-src/himi-store-account/index.ts +69 -10
  41. package/templates/podcast-creator/dev/index.ts +47 -29
  42. package/templates/podcast-creator/tier5-src/himi-branded-lite-place-summary/index.ts +12 -1
  43. package/templates/podcast-creator/tier5-src/himi-store-account/index.ts +69 -10
  44. package/templates/portfolio-creative/dev/index.ts +52 -27
  45. package/templates/portfolio-creative/tier5-src/himi-store-account/index.ts +69 -10
  46. package/templates/portfolio-creative/tier5-src/himi-store-product/index.ts +153 -132
  47. package/templates/restaurant/dev/index.ts +48 -25
  48. package/templates/restaurant/tier5-src/himi-store-account/index.ts +69 -10
  49. package/templates/retail-store/dev/index.ts +44 -23
  50. package/templates/retail-store/tier5-src/himi-store-account/index.ts +69 -10
  51. package/templates/retail-store/tier5-src/himi-store-product/index.ts +153 -132
  52. package/templates/yoga-studio/dev/index.ts +57 -34
  53. package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +9 -1
  54. package/templates/yoga-studio/tier5-src/himi-branded-lite-place-summary/index.ts +12 -1
  55. package/templates/yoga-studio/tier5-src/himi-store-account/index.ts +69 -10
@@ -26,6 +26,10 @@ interface AuthRouteInput {
26
26
  returnScreenId?: string;
27
27
  returnId?: string;
28
28
  returnChannelId?: string;
29
+ /** Standalone sign-in only: "1" when it was opened as a modal (sheet / cover). */
30
+ modal?: string;
31
+ /** Modal only: the tab whose stack the return screen opens on once the modal closes. */
32
+ returnTab?: string;
29
33
  }
30
34
 
31
35
  // The screen's boot input survives refresh; consuming it must survive too.
@@ -61,6 +65,12 @@ function authIntentFromArg(arg: unknown, prev: Pick<S, "authIntent" | "authConte
61
65
  };
62
66
  }
63
67
 
68
+ /** How the standalone form leaves. A modal is closed: on web `back` pops the tab's stack,
69
+ * not the modal. A pushed form goes back. */
70
+ function leaveNav(): "dismiss" | "back" {
71
+ return routeInput().modal === "1" ? "dismiss" : "back";
72
+ }
73
+
64
74
  /** Where a successful sign-in returns to, or null. Never Account itself — that would
65
75
  * only push a second copy of the screen the member is already on. */
66
76
  function resumeTarget(s: Pick<S, "returnScreenId" | "returnId" | "returnChannelId">): { screenId: string; args?: Record<string, string> } | null {
@@ -78,11 +88,26 @@ function resumeTarget(s: Pick<S, "returnScreenId" | "returnId" | "returnChannelI
78
88
  * On the standalone sign-in screen the target is pushed, not a `replace`: on iOS and
79
89
  * Android `replace` resets the stack to its root, which would drop the screens the
80
90
  * visitor came through. Back from the target shows the form again, and the form sends a
81
- * member back on appear (settle). With no target the form goes back. */
91
+ * member back on appear (settle). With no target the form goes back.
92
+ *
93
+ * A modal form closes. A target pushed from here would open inside the modal (iOS, Android)
94
+ * or under it (web), so a modal that names a `returnTab` hands the target to that tab: the
95
+ * host closes the modal, then pushes it on that tab's stack (Account's member rows). A host
96
+ * showing no such tab (a drawer, a narrow Mac window) drops that request whole, modal and
97
+ * all, so the modal is also dismissed directly: it always closes, and the row opens where
98
+ * the tab exists. With no `returnTab` it only closes, and the screen under it loads again —
99
+ * the screen that opened the modal is the return target. */
82
100
  function resumeAfterAuth(next: S): S {
83
101
  const target = resumeTarget(next);
84
102
  if (!next.isMember) return next;
85
- if (!target) return next.standalone ? navigate(next, { presentation: "back" }) : next;
103
+ const tab = routeInput().modal === "1" ? routeInput().returnTab ?? "" : "";
104
+ if (target && tab && next.standalone) {
105
+ routeInputConsumed = true;
106
+ void dispatch("himi.open", { presentation: "dismiss" });
107
+ const cleared = derive({ ...next, authIntent: "", authContextName: "", returnScreenId: "", returnId: "", returnChannelId: "" });
108
+ return navigate(cleared, target.args ? { tab, screenId: target.screenId, args: target.args } : { tab, screenId: target.screenId });
109
+ }
110
+ if (!target || routeInput().modal === "1") return next.standalone ? navigate(next, { presentation: leaveNav() }) : next;
86
111
  routeInputConsumed = true;
87
112
  const cleared = derive({ ...next, authIntent: "", authContextName: "", returnScreenId: "", returnId: "", returnChannelId: "" });
88
113
  return navigate(cleared, target);
@@ -96,11 +121,31 @@ function forwardToSignIn(next: S): S {
96
121
  if (!next.authIntent && !next.returnScreenId) return next;
97
122
  routeInputConsumed = true;
98
123
  const args: Record<string, string> = { mode: "signin" };
99
- for (const key of ["authIntent", "authContextName", "returnScreenId", "returnId", "returnChannelId"] as const) {
100
- if (next[key]) args[key === "authIntent" ? "intent" : key === "authContextName" ? "contextName" : key] = next[key];
124
+ for (const key of ["authIntent", "authContextName"] as const) {
125
+ if (next[key]) args[key === "authIntent" ? "intent" : "contextName"] = next[key];
126
+ }
127
+ return openSignIn(derive({ ...next, authIntent: "", authContextName: "", returnScreenId: "", returnId: "", returnChannelId: "" }),
128
+ args, resumeTarget(next));
129
+ }
130
+
131
+ /** Account sends a guest to the standalone sign-in screen, with `after`, the member screen to
132
+ * open once signed in. With a `signInPresentation` appear param it opens as that modal, and
133
+ * `after` goes on Account's own tab (`signInReturnTab`) when the modal closes; pushed, the form
134
+ * pushes `after` itself (resumeAfterAuth). */
135
+ function openSignIn(next: S, args: Record<string, string>, after: { screenId: string; args?: Record<string, string> } | null): S {
136
+ const screenId = next.signInScreen || "himi_sign_in";
137
+ const presentation = signInPresentationRequested;
138
+ const back: Record<string, string> = {};
139
+ if (after) {
140
+ back.returnScreenId = after.screenId;
141
+ if (after.args?.id) back.returnId = after.args.id;
142
+ if (after.args?.channelId) back.returnChannelId = after.args.channelId;
101
143
  }
102
- return navigate(derive({ ...next, authIntent: "", authContextName: "", returnScreenId: "", returnId: "", returnChannelId: "" }),
103
- { screenId: next.signInScreen, args });
144
+ if (!presentation) return navigate(next, { screenId, args: { ...args, ...back } });
145
+ const modalArgs: Record<string, string> = { ...args, modal: "1" };
146
+ // With no tab to open it on, a modal has no way to the member screen: it only closes.
147
+ if (after && signInReturnTabRequested) Object.assign(modalArgs, back, { returnTab: signInReturnTabRequested });
148
+ return navigate(next, { screenId, presentation, args: modalArgs });
104
149
  }
105
150
 
106
151
  /** After a load: the standalone sign-in screen sends a member back (a stale link, or a
@@ -128,8 +173,11 @@ function settle(next: S, arg: unknown, latest = true): S {
128
173
  // The appear refresh returns `_nav`, which every host applies — binaries from before
129
174
  // worker nav dispatch included. Only an arg-less load, whose result iOS
130
175
  // discards, dispatches.
131
- if (strField(arg, "standalone") === "1") return navigate(cleared, { presentation: "back" });
132
- void dispatch("himi.open", { presentation: "back" });
176
+ if (strField(arg, "standalone") === "1") return navigate(cleared, { presentation: leaveNav() });
177
+ // Not from a modal: nothing pushed over it comes back to it, and on iOS the closing modal's
178
+ // own screen reloads here once, after it has asked to close — a late dismiss would close
179
+ // whatever modal is open by then.
180
+ if (routeInput().modal !== "1") void dispatch("himi.open", { presentation: leaveNav() });
133
181
  return cleared;
134
182
  }
135
183
  // Account forwards only on an appear refresh: the boot `load()` has no params and its
@@ -180,6 +228,10 @@ let appliedSeq = 0;
180
228
  // The appear params belong to the worker session too, like the two flags above: a probe
181
229
  // dropped because the visitor typed must not take them with it.
182
230
  let signInScreenRequested = "";
231
+ /** Account only: how it opens the standalone sign-in screen ("" = push). */
232
+ let signInPresentationRequested: "" | "sheet" | "full_screen" = "";
233
+ /** Account only: its tab, where a member screen opens after a modal sign-in. */
234
+ let signInReturnTabRequested = "";
183
235
  let standaloneRequested = false;
184
236
  let pollIdentityRequested = false;
185
237
  // Bumped by every mode switch, so a dropped probe never puts back a mode the visitor left.
@@ -210,6 +262,9 @@ function appearConfig(s: S, arg: unknown): AppearConfig {
210
262
  passwordDraft = "";
211
263
  }
212
264
  signInScreenRequested ||= strField(arg, "signInScreen") || s.signInScreen;
265
+ const presentation = strField(arg, "signInPresentation");
266
+ if (presentation === "sheet" || presentation === "full_screen") signInPresentationRequested = presentation;
267
+ signInReturnTabRequested ||= strField(arg, "signInReturnTab");
213
268
  // Only the sign-in screen's route carries `mode`, so it marks the form from the boot load on.
214
269
  standaloneRequested ||= s.standalone || appearing || (!routeInputConsumed && !!routeInput().mode);
215
270
  pollIdentityRequested ||= s.pollIdentity || strField(arg, "pollIdentity") === "1";
@@ -676,6 +731,7 @@ const svc = defineService<S, {
676
731
  signOut: (s: S) => Promise<S>;
677
732
  sync: (s: S) => Promise<S | undefined>;
678
733
  openMember: (s: S, arg?: unknown) => Promise<S>;
734
+ leave: (s: S) => S;
679
735
  restorePublish: (s: S) => S;
680
736
  iapRestore: (s: S) => Promise<undefined>;
681
737
  }>("himiStoreAccount", {
@@ -756,7 +812,8 @@ const svc = defineService<S, {
756
812
  return "next" in r ? r.next : undefined;
757
813
  },
758
814
  // A row to a member-only screen shows to a guest too. A member opens it; a guest signs
759
- // in first and the sign-in screen replaces itself with it, so Back returns here.
815
+ // in first, and the sign-in screen then opens the row's screen — as a modal, by closing
816
+ // and pushing it on this tab (openSignIn) — so Back returns here either way.
760
817
  openMember: async (s, arg): Promise<S> => {
761
818
  const screenId = strField(arg, "screenId");
762
819
  if (!screenId) return s;
@@ -764,8 +821,10 @@ const svc = defineService<S, {
764
821
  // From the state as it is now: a probe may have landed during the identity read.
765
822
  const now = svc.getState();
766
823
  if (member) return navigate(now, { screenId });
767
- return navigate(now, { screenId: now.signInScreen || "himi_sign_in", args: { mode: "signin", returnScreenId: screenId } });
824
+ return openSignIn(now, { mode: "signin" }, { screenId });
768
825
  },
826
+ // The standalone form's Continue: leaves the form the way it was opened.
827
+ leave: (s) => navigate(s, { presentation: leaveNav() }),
769
828
  // Typing means any focus request was delivered: clear the tokens (safe while mounted, since
770
829
  // bumpToken never repeats), so a visit rarely ends — and a pooled form rarely reopens —
771
830
  // holding one.
@@ -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.
@@ -2100,8 +2100,10 @@ const SCREENS = runtimeSetting({
2100
2100
  "actionId": "himi.open",
2101
2101
  "params": {
2102
2102
  "screenId": "himi_sign_in",
2103
- "mode": "signin"
2104
- }
2103
+ "mode": "signin",
2104
+ "modal": "1"
2105
+ },
2106
+ "presentation": "sheet"
2105
2107
  }
2106
2108
  }
2107
2109
  }
@@ -2119,8 +2121,10 @@ const SCREENS = runtimeSetting({
2119
2121
  "actionId": "himi.open",
2120
2122
  "params": {
2121
2123
  "screenId": "himi_sign_in",
2122
- "mode": "register"
2123
- }
2124
+ "mode": "register",
2125
+ "modal": "1"
2126
+ },
2127
+ "presentation": "sheet"
2124
2128
  }
2125
2129
  }
2126
2130
  }
@@ -3077,6 +3081,8 @@ const SCREENS = runtimeSetting({
3077
3081
  "memberAccountLinks": "1",
3078
3082
  "restorePurchases": "1",
3079
3083
  "signInScreen": "himi_sign_in",
3084
+ "signInPresentation": "sheet",
3085
+ "signInReturnTab": "profile",
3080
3086
  "pollIdentity": "1",
3081
3087
  "copy": "{\"account.subtitle\":\"RSVPs, groups and saved content in one place.\",\"storeAccount.authBody\":\"Sign in to continue — RSVPs and groups stay with you.\",\"storeAccount.authBodyRegister\":\"Create a member account to continue — RSVPs and groups stay with you.\"}"
3082
3088
  }
@@ -3332,8 +3338,10 @@ const SCREENS = runtimeSetting({
3332
3338
  "params": {
3333
3339
  "screenId": "himi_sign_in",
3334
3340
  "mode": "signin",
3335
- "returnScreenId": "himi_account_settings"
3336
- }
3341
+ "returnScreenId": "himi_account_settings",
3342
+ "modal": "1"
3343
+ },
3344
+ "presentation": "sheet"
3337
3345
  }
3338
3346
  }
3339
3347
  },
@@ -5243,8 +5251,7 @@ const SCREENS = runtimeSetting({
5243
5251
  "style": "primary",
5244
5252
  "fullWidth": true,
5245
5253
  "action": {
5246
- "actionId": "nav.back",
5247
- "presentation": "back"
5254
+ "actionId": "tier5.himiStoreAccount.leave"
5248
5255
  }
5249
5256
  }
5250
5257
  }
@@ -5285,7 +5292,7 @@ const SCREENS = runtimeSetting({
5285
5292
  "declaredCapabilities": [
5286
5293
  "wix_api"
5287
5294
  ],
5288
- "minBinaryVersion": "0.2.0",
5295
+ "minBinaryVersion": "1.7.0",
5289
5296
  "bundleVersion": "0.0.1"
5290
5297
  }
5291
5298
  },
@@ -6152,8 +6159,10 @@ const SCREENS = runtimeSetting({
6152
6159
  "screenId": "himi_sign_in",
6153
6160
  "mode": "signin",
6154
6161
  "intent": "profile",
6155
- "returnScreenId": "himi_view_profile"
6156
- }
6162
+ "returnScreenId": "himi_view_profile",
6163
+ "modal": "1"
6164
+ },
6165
+ "presentation": "sheet"
6157
6166
  }
6158
6167
  }
6159
6168
  }
@@ -6884,8 +6893,10 @@ const SCREENS = runtimeSetting({
6884
6893
  "screenId": "himi_sign_in",
6885
6894
  "mode": "signin",
6886
6895
  "intent": "post",
6887
- "returnScreenId": "himi_feed"
6888
- }
6896
+ "returnScreenId": "himi_feed",
6897
+ "modal": "1"
6898
+ },
6899
+ "presentation": "sheet"
6889
6900
  }
6890
6901
  }
6891
6902
  }
@@ -7502,8 +7513,10 @@ const SCREENS = runtimeSetting({
7502
7513
  "screenId": "himi_sign_in",
7503
7514
  "mode": "signin",
7504
7515
  "intent": "post",
7505
- "returnScreenId": "himi_create_post"
7506
- }
7516
+ "returnScreenId": "himi_create_post",
7517
+ "modal": "1"
7518
+ },
7519
+ "presentation": "sheet"
7507
7520
  }
7508
7521
  }
7509
7522
  }
@@ -17254,8 +17267,10 @@ const SCREENS = runtimeSetting({
17254
17267
  "action": {
17255
17268
  "actionId": "himi.open",
17256
17269
  "params": {
17257
- "screenId": "himi_sign_in"
17258
- }
17270
+ "screenId": "himi_sign_in",
17271
+ "modal": "1"
17272
+ },
17273
+ "presentation": "sheet"
17259
17274
  }
17260
17275
  }
17261
17276
  },
@@ -20851,8 +20866,10 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
20851
20866
  "params": {
20852
20867
  "screenId": "himi_sign_in",
20853
20868
  "mode": "signin",
20854
- "intent": "rsvp"
20855
- }
20869
+ "intent": "rsvp",
20870
+ "modal": "1"
20871
+ },
20872
+ "presentation": "sheet"
20856
20873
  }
20857
20874
  }
20858
20875
  },
@@ -22184,8 +22201,10 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
22184
22201
  "intent": "join_group",
22185
22202
  "contextName": "$service:himiBrandedGroup.name",
22186
22203
  "returnScreenId": "himi_group",
22187
- "returnId": "$service:himiBrandedGroup.id"
22188
- }
22204
+ "returnId": "$service:himiBrandedGroup.id",
22205
+ "modal": "1"
22206
+ },
22207
+ "presentation": "sheet"
22189
22208
  }
22190
22209
  }
22191
22210
  },