@wix/himalaya-cli 0.1497.0 → 0.1499.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 (146) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/stores-collections/src/display.d.ts +1 -1
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/stores-collections/src/index.d.ts +2 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/stores-v1/src/display.d.ts +4 -0
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-stores-collections.mjs +2 -2
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-stores.mjs +3 -2
  6. package/dist/cli.mjs +30 -30
  7. package/dist/preview-assets.generated.bin +0 -0
  8. package/guides/sdk-reference.json +8 -2
  9. package/guides/trim-a-template.md +1 -1
  10. package/package.json +2 -2
  11. package/templates/beauty-salon/dev/index.ts +2465 -2035
  12. package/templates/beauty-salon/tier5-src/_shared/himi-store-categories.ts +102 -0
  13. package/templates/beauty-salon/tier5-src/_shared/himi-store-facets.ts +478 -0
  14. package/templates/beauty-salon/tier5-src/_shared/iap-presentation.ts +158 -15
  15. package/templates/beauty-salon/tier5-src/himi-branded-lite-home/index.ts +42 -2
  16. package/templates/beauty-salon/tier5-src/himi-branded-lite-shop/index.ts +691 -0
  17. package/templates/beauty-salon/tier5-src/himi-store-product/index.ts +2 -1
  18. package/templates/beauty-salon/tier5-src/himi-store-saved/index.ts +1 -0
  19. package/templates/beauty-salon-owner/dev/index.ts +571 -0
  20. package/templates/beauty-salon-owner/himalaya.content.json +6 -0
  21. package/templates/beauty-salon-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  22. package/templates/beauty-salon-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  23. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  24. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  25. package/templates/branded-lite/dev/index.ts +2989 -2197
  26. package/templates/branded-lite/tier5-src/_shared/business-chat-presenter.ts +46 -8
  27. package/templates/branded-lite/tier5-src/_shared/himi-store-categories.ts +102 -0
  28. package/templates/branded-lite/tier5-src/_shared/himi-store-facets.ts +478 -0
  29. package/templates/branded-lite/tier5-src/_shared/iap-presentation.ts +158 -15
  30. package/templates/branded-lite/tier5-src/business-chat/index.ts +3 -2
  31. package/templates/branded-lite/tier5-src/himi-branded-lite-home/index.ts +42 -2
  32. package/templates/branded-lite/tier5-src/himi-branded-lite-shop/index.ts +691 -0
  33. package/templates/branded-lite/tier5-src/himi-store-product/index.ts +2 -1
  34. package/templates/branded-lite/tier5-src/himi-store-saved/index.ts +1 -0
  35. package/templates/community-club/tier5-src/_shared/himi-store-categories.ts +102 -0
  36. package/templates/community-club/tier5-src/_shared/iap-presentation.ts +158 -15
  37. package/templates/community-club/tier5-src/himi-branded-lite-home/index.ts +42 -2
  38. package/templates/community-club-owner/dev/index.ts +571 -0
  39. package/templates/community-club-owner/himalaya.content.json +6 -0
  40. package/templates/community-club-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  41. package/templates/community-club-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  42. package/templates/community-club-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  43. package/templates/community-club-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  44. package/templates/events-meetups/tier5-src/_shared/himi-store-categories.ts +102 -0
  45. package/templates/events-meetups/tier5-src/_shared/iap-presentation.ts +158 -15
  46. package/templates/events-meetups/tier5-src/himi-branded-lite-home/index.ts +42 -2
  47. package/templates/events-meetups-owner/dev/index.ts +571 -0
  48. package/templates/events-meetups-owner/himalaya.content.json +6 -0
  49. package/templates/events-meetups-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  50. package/templates/events-meetups-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  51. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  52. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  53. package/templates/gym-fitness/dev/index.ts +2600 -2170
  54. package/templates/gym-fitness/tier5-src/_shared/himi-store-categories.ts +102 -0
  55. package/templates/gym-fitness/tier5-src/_shared/himi-store-facets.ts +478 -0
  56. package/templates/gym-fitness/tier5-src/_shared/iap-presentation.ts +158 -15
  57. package/templates/gym-fitness/tier5-src/himi-branded-lite-home/index.ts +42 -2
  58. package/templates/gym-fitness/tier5-src/himi-branded-lite-shop/index.ts +691 -0
  59. package/templates/gym-fitness/tier5-src/himi-store-product/index.ts +2 -1
  60. package/templates/gym-fitness/tier5-src/himi-store-saved/index.ts +1 -0
  61. package/templates/gym-fitness-owner/dev/index.ts +571 -0
  62. package/templates/gym-fitness-owner/himalaya.content.json +6 -0
  63. package/templates/gym-fitness-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  64. package/templates/gym-fitness-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  65. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  66. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  67. package/templates/nonprofit-cause/tier5-src/_shared/himi-store-categories.ts +102 -0
  68. package/templates/nonprofit-cause/tier5-src/_shared/iap-presentation.ts +158 -15
  69. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-home/index.ts +42 -2
  70. package/templates/nonprofit-cause-owner/dev/index.ts +571 -0
  71. package/templates/nonprofit-cause-owner/himalaya.content.json +6 -0
  72. package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  73. package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  74. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  75. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  76. package/templates/owner-lite/dev/index.ts +571 -0
  77. package/templates/owner-lite/himalaya.content.json +6 -0
  78. package/templates/owner-lite/tier5-src/_shared/owner-today-rollup.ts +307 -0
  79. package/templates/owner-lite/tier5-src/_shared/owner-widget-reads.ts +23 -0
  80. package/templates/owner-lite/tier5-src/himi-owner-lite-today/index.ts +8 -20
  81. package/templates/owner-lite/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  82. package/templates/podcast-creator/tier5-src/_shared/himi-store-categories.ts +102 -0
  83. package/templates/podcast-creator/tier5-src/_shared/iap-presentation.ts +158 -15
  84. package/templates/podcast-creator/tier5-src/himi-branded-lite-home/index.ts +42 -2
  85. package/templates/podcast-creator-owner/dev/index.ts +571 -0
  86. package/templates/podcast-creator-owner/himalaya.content.json +6 -0
  87. package/templates/podcast-creator-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  88. package/templates/podcast-creator-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  89. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  90. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  91. package/templates/portfolio-creative/dev/index.ts +2470 -2040
  92. package/templates/portfolio-creative/tier5-src/_shared/himi-store-categories.ts +102 -0
  93. package/templates/portfolio-creative/tier5-src/_shared/himi-store-facets.ts +478 -0
  94. package/templates/portfolio-creative/tier5-src/_shared/iap-presentation.ts +158 -15
  95. package/templates/portfolio-creative/tier5-src/himi-branded-lite-home/index.ts +42 -2
  96. package/templates/portfolio-creative/tier5-src/himi-branded-lite-shop/index.ts +691 -0
  97. package/templates/portfolio-creative/tier5-src/himi-store-product/index.ts +2 -1
  98. package/templates/portfolio-creative/tier5-src/himi-store-saved/index.ts +1 -0
  99. package/templates/portfolio-creative-owner/dev/index.ts +571 -0
  100. package/templates/portfolio-creative-owner/himalaya.content.json +6 -0
  101. package/templates/portfolio-creative-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  102. package/templates/portfolio-creative-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  103. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  104. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  105. package/templates/restaurant/tier5-src/_shared/himi-store-categories.ts +102 -0
  106. package/templates/restaurant/tier5-src/_shared/iap-presentation.ts +158 -15
  107. package/templates/restaurant/tier5-src/himi-branded-lite-home/index.ts +42 -2
  108. package/templates/restaurant-owner/dev/index.ts +571 -0
  109. package/templates/restaurant-owner/himalaya.content.json +6 -0
  110. package/templates/restaurant-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  111. package/templates/restaurant-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  112. package/templates/restaurant-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  113. package/templates/restaurant-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  114. package/templates/retail-store/dev/index.ts +2583 -2137
  115. package/templates/retail-store/tier5-src/_shared/himi-store-categories.ts +102 -0
  116. package/templates/retail-store/tier5-src/_shared/himi-store-facets.ts +478 -0
  117. package/templates/retail-store/tier5-src/_shared/iap-presentation.ts +158 -15
  118. package/templates/retail-store/tier5-src/himi-branded-lite-home/index.ts +42 -2
  119. package/templates/retail-store/tier5-src/himi-branded-lite-shop/index.ts +691 -0
  120. package/templates/retail-store/tier5-src/himi-store-product/index.ts +2 -1
  121. package/templates/retail-store/tier5-src/himi-store-saved/index.ts +1 -0
  122. package/templates/retail-store-owner/dev/index.ts +571 -0
  123. package/templates/retail-store-owner/himalaya.content.json +6 -0
  124. package/templates/retail-store-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  125. package/templates/retail-store-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  126. package/templates/retail-store-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  127. package/templates/retail-store-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  128. package/templates/yoga-studio/tier5-src/_shared/himi-store-categories.ts +102 -0
  129. package/templates/yoga-studio/tier5-src/_shared/iap-presentation.ts +158 -15
  130. package/templates/yoga-studio/tier5-src/himi-branded-lite-home/index.ts +42 -2
  131. package/templates/yoga-studio-owner/dev/index.ts +571 -0
  132. package/templates/yoga-studio-owner/himalaya.content.json +6 -0
  133. package/templates/yoga-studio-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  134. package/templates/yoga-studio-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  135. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  136. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  137. package/templates/beauty-salon/tier5-src/_shared/himi-store-collection.ts +0 -22
  138. package/templates/beauty-salon/tier5-src/himi-store-shop/index.ts +0 -173
  139. package/templates/branded-lite/tier5-src/_shared/himi-store-collection.ts +0 -22
  140. package/templates/branded-lite/tier5-src/himi-store-shop/index.ts +0 -173
  141. package/templates/gym-fitness/tier5-src/_shared/himi-store-collection.ts +0 -22
  142. package/templates/gym-fitness/tier5-src/himi-store-shop/index.ts +0 -173
  143. package/templates/portfolio-creative/tier5-src/_shared/himi-store-collection.ts +0 -22
  144. package/templates/portfolio-creative/tier5-src/himi-store-shop/index.ts +0 -173
  145. package/templates/retail-store/tier5-src/_shared/himi-store-collection.ts +0 -22
  146. package/templates/retail-store/tier5-src/himi-store-shop/index.ts +0 -173
@@ -0,0 +1,691 @@
1
+ // Tier-5 bundle: Branded Lite — Shop (the store tab's product gallery).
2
+ // A category chip row on top, a sort picker and a Filter button that opens the filter
3
+ // sheet (price + the store's own product options, e.g. Color / Size), a grid or list
4
+ // of product cards with a saved-products heart, and a live cart count for the top bar.
5
+ //
6
+ // The scope is one category (or All), fetched from Wix; sort and filters then narrow
7
+ // it on the device (../_shared/himi-store-facets.ts). The search field narrows it on
8
+ // the device too, by product name. The first page paints as soon as it lands; later
9
+ // pages join it in the background.
10
+ //
11
+ // Opened with `category` = a category id or name; absent ⇒ All.
12
+ import { getCurrentCart } from "@himalaya/wix-ecom";
13
+ import { getAll, remove, upsert, type DbEntity } from "@himalaya/local-db";
14
+ import { searchStoreProducts, type StoreProduct } from "../_shared/himi-store.js";
15
+ import { withIapPresentation } from "../_shared/iap-presentation.js";
16
+ import { stashCopy, copyParam, copyTemplate } from "../_shared/himi-copy.js";
17
+ import { loadStoreCategories, type StoreCategoryCard } from "../_shared/himi-store-categories.js";
18
+ import {
19
+ FACETS_BOOT, PAGE_SIZE, applyFacets, countText, currencyOf, facetActions, idOf, option, readViewPrefs, selected, toRows, toggleSaved,
20
+ type FacetState, type Option, type ProductCard, type Row,
21
+ } from "../_shared/himi-store-facets.js";
22
+
23
+ const ALL = "all";
24
+ const PAGE = 100;
25
+ const MAX_PRODUCTS = 500;
26
+ const ENTITY = "products";
27
+
28
+ interface S extends FacetState {
29
+ status: "loading" | "ready" | "error";
30
+ isLoading: boolean;
31
+ notLoading: boolean;
32
+ isErrored: boolean;
33
+ notErrored: boolean;
34
+ /** The rows are the last synced catalog, read from the device while offline. */
35
+ fromCache: boolean;
36
+ noOffline: boolean;
37
+ categorySelected: string;
38
+ /** The scope generation this state was derived for — see `committed`. */
39
+ scopeGen: number;
40
+ /** The search field's text; matches product names within the category. */
41
+ query: string;
42
+ /** The chip row: All plus one chip per store category, with its selected state. */
43
+ categoryOptions: Option[];
44
+ hideCategories: boolean;
45
+ countLabel: string;
46
+ noEmpty: boolean;
47
+ emptyText: string;
48
+ cartCount: number;
49
+ }
50
+
51
+ // The fetched catalog stays out of bound state; only derived cards are rendered.
52
+ let rows: Row[] = [];
53
+ let currency: string | undefined;
54
+ let categories: StoreCategoryCard[] = [];
55
+ let categoriesAuthoritative = false;
56
+ let categoryReadVersion = 0;
57
+ let pendingCategoriesRead: Promise<StoreCategoryCard[]> | undefined;
58
+ /** Cart count can land while the first product scope is still pending. */
59
+ let loadedCartCount: number | undefined;
60
+ /** Each category's fetched rows, so switching back to a chip is instant. */
61
+ const scopes = new Map<string, { rows: Row[]; complete: boolean; loading: boolean; fromCache?: boolean; currency?: string; total?: number }>();
62
+ const pendingScopes = new Map<string, number>();
63
+ // A pull-to-refresh that loses the foreground generation still represents fresh data the
64
+ // shopper asked for. Keep that intent until a current request for the scope lands, so a
65
+ // quick trip away and back cannot present the old cached scope as the refresh result.
66
+ const pendingRefreshScopes = new Map<string, number>();
67
+ let cacheWriteGeneration = 0;
68
+ let cacheWriteQueue: Promise<void> = Promise.resolve();
69
+ /** Bumped on every scope change; a stale first page must never write over a newer scope. */
70
+ let generation = 0;
71
+ /** The category the screen shows. Module state, not action state: the boot load and the
72
+ * onAppear load run at once, and both must land on the same chip. */
73
+ let selectedCategory = ALL;
74
+ /** A route category waiting for the category list to load. */
75
+ let pendingLink: string | undefined;
76
+ let pendingLinkIntentVersion = 0;
77
+ let categoryIntentVersion = 0;
78
+ let pendingAuxiliary: { cartCount: number; version: number } | undefined;
79
+ let auxiliaryVersion = 0;
80
+ let lastRouteActivation: string | undefined;
81
+ let routeCategoryConsumed = false;
82
+ let legacyCacheFacetResetDone = false;
83
+ const LEGACY_CACHE_FACETS_KEY = "himi-store:legacy-cache-facets-reset:v1";
84
+
85
+ function shouldResetLegacyCacheFacets(): boolean {
86
+ if (legacyCacheFacetResetDone) return false;
87
+ legacyCacheFacetResetDone = true;
88
+ try {
89
+ const store = (globalThis as { himalaya?: { storage?: { getItem(key: string): string | null; setItem(key: string, value: string): void } } }).himalaya?.storage;
90
+ if (store?.getItem(LEGACY_CACHE_FACETS_KEY) === "1") return false;
91
+ store?.setItem(LEGACY_CACHE_FACETS_KEY, "1");
92
+ } catch { /* one reset this boot even when storage is unavailable */ }
93
+ return true;
94
+ }
95
+
96
+ function input(arg?: unknown): { category?: string; categoryId?: string; categoryActivation?: string } {
97
+ const fromArg = arg as { category?: unknown; categoryId?: unknown; categoryActivation?: unknown } | undefined;
98
+ const route = (fromArg && typeof fromArg === "object" && (
99
+ typeof fromArg.category === "string" || typeof fromArg.categoryId === "string"
100
+ ) ? fromArg : (globalThis as { himalaya?: { input?: { category?: unknown; categoryId?: unknown; categoryActivation?: unknown } } }).himalaya?.input);
101
+ return {
102
+ ...(typeof route?.category === "string" ? { category: route.category } : {}),
103
+ ...(typeof route?.categoryId === "string" ? { categoryId: route.categoryId } : {}),
104
+ ...(typeof route?.categoryActivation === "string" ? { categoryActivation: route.categoryActivation } : {}),
105
+ };
106
+ }
107
+
108
+ function matchCategory(want: string): string | undefined {
109
+ const w = want.trim().toLowerCase();
110
+ if (!w) return undefined;
111
+ if (w === ALL) return ALL;
112
+ return categories.find((c) => c.id === want.trim() || c.name.toLowerCase() === w)?.id;
113
+ }
114
+
115
+ async function cartItemCount(): Promise<number | undefined> {
116
+ try {
117
+ const cart = await getCurrentCart();
118
+ return (cart.lineItems ?? []).reduce((n, li) => n + (li.quantity ?? 1), 0);
119
+ } catch {
120
+ return undefined;
121
+ }
122
+ }
123
+
124
+ /** A cart/onAppear load may supersede the auxiliary count for a refresh, but it must not
125
+ * supersede that refresh's category discovery. Cache reads share an in-flight catalog read;
126
+ * only the newest network read can replace the worker's current categories. */
127
+ function readCategories(fresh: boolean): Promise<StoreCategoryCard[]> {
128
+ // A network read already in flight is fresh enough for a refresh that starts before it
129
+ // lands. Sharing it avoids discarding a good older response when a redundant newer read
130
+ // fails, and lets a route load consume the same authoritative list.
131
+ if (pendingCategoriesRead) return pendingCategoriesRead;
132
+ if (!fresh && categories.length) return Promise.resolve(categories);
133
+ const version = ++categoryReadVersion;
134
+ const read = loadStoreCategories().then((loaded) => {
135
+ if (version === categoryReadVersion) {
136
+ categories = loaded;
137
+ categoriesAuthoritative = true;
138
+ }
139
+ return categories;
140
+ }, () => {
141
+ if (version === categoryReadVersion) categoriesAuthoritative = false;
142
+ return categories;
143
+ });
144
+ pendingCategoriesRead = read;
145
+ void read.then(() => {
146
+ if (pendingCategoriesRead === read) pendingCategoriesRead = undefined;
147
+ });
148
+ return read;
149
+ }
150
+
151
+ function reconcileCategories(): { invalidatedSelection: boolean; intentVersion: number } {
152
+ // A failed discovery says nothing about whether a Home route still exists.
153
+ // Keep its stable id and product scope; only a successful read can remove it.
154
+ if (!categoriesAuthoritative) {
155
+ if (pendingLink === selectedCategory) pendingLink = undefined;
156
+ return { invalidatedSelection: false, intentVersion: categoryIntentVersion };
157
+ }
158
+ if (pendingLink && pendingLinkIntentVersion === categoryIntentVersion) {
159
+ // A Home category can be newer than Shop's cached catalog. Once discovery has
160
+ // finished, an unmatched route is obsolete; return to All instead of retaining
161
+ // the previously selected category.
162
+ selectedCategory = matchCategory(pendingLink) ?? ALL;
163
+ pendingLink = undefined;
164
+ } else if (pendingLink && pendingLinkIntentVersion !== categoryIntentVersion) {
165
+ pendingLink = undefined;
166
+ }
167
+ const invalidatedSelection = selectedCategory !== ALL && !categories.some((category) => category.id === selectedCategory);
168
+ if (invalidatedSelection) selectedCategory = ALL;
169
+ return { invalidatedSelection, intentVersion: categoryIntentVersion };
170
+ }
171
+
172
+ type CachedCard = ProductCard & {
173
+ cachePrice?: number; cacheCreated?: string; cacheName?: string;
174
+ cacheOptions?: Record<string, string[]>; cacheCurrency?: string; legacyCache?: boolean;
175
+ };
176
+
177
+ /** Migrate the former himi-store-shop row shape in memory: the entity name stayed the same. */
178
+ function cachedCard(value: unknown): CachedCard | undefined {
179
+ const raw = value as (ProductCard & { subtitle?: string; initial?: string }) | undefined;
180
+ if (!raw?.id || typeof raw.name !== "string") return undefined;
181
+ if (typeof raw.noTagRow === "boolean" && typeof raw.notOnSale === "boolean") return raw as CachedCard;
182
+ const image = typeof raw.image === "string" ? raw.image : "";
183
+ const price = typeof raw.commercePrice === "string" && raw.commercePrice ? raw.commercePrice : raw.subtitle ?? "";
184
+ return {
185
+ id: raw.id, name: raw.name, image, hasImage: typeof raw.hasImage === "boolean" ? raw.hasImage : !!image,
186
+ noImage: typeof raw.noImage === "boolean" ? raw.noImage : !image, initial: raw.initial ?? raw.name.slice(0, 1).toUpperCase(),
187
+ price, wasPrice: "", onSale: false, notOnSale: true, saved: false, notSaved: true,
188
+ swatch1: "", swatch2: "", swatch3: "", swatch4: "", swatch5: "",
189
+ swatch1Hidden: true, swatch2Hidden: true, swatch3Hidden: true, swatch4Hidden: true, swatch5Hidden: true,
190
+ noSwatches: true, noTagRow: true, commercePrice: raw.commercePrice ?? price,
191
+ detailScreenId: raw.detailScreenId || "himi_product", legacyCache: true,
192
+ };
193
+ }
194
+
195
+ async function readCachedCards(): Promise<CachedCard[]> {
196
+ try {
197
+ return ((await getAll(ENTITY)) as unknown[]).map(cachedCard).filter((c): c is CachedCard => !!c);
198
+ } catch { return []; }
199
+ }
200
+ function writeCachedCards(list: Row[], snapshotCurrency?: string): void {
201
+ const writeGeneration = ++cacheWriteGeneration;
202
+ const snapshot = list.map(r => ({
203
+ ...r.card, subtitle: r.card.price,
204
+ cachePrice: r.price, cacheCreated: r.created, cacheName: r.name, cacheOptions: r.options, cacheCurrency: snapshotCurrency,
205
+ } as unknown as DbEntity));
206
+ cacheWriteQueue = cacheWriteQueue.then(async () => {
207
+ if (writeGeneration !== cacheWriteGeneration) return;
208
+ const cached = await getAll(ENTITY);
209
+ if (writeGeneration !== cacheWriteGeneration) return;
210
+ // Once a snapshot starts writing, finish it. A later complete snapshot queues behind
211
+ // it; switching or refreshing categories cannot strand a half-replaced catalog.
212
+ const keep = new Set(snapshot.map(row => row.id));
213
+ for (const row of snapshot) await upsert(ENTITY, row);
214
+ for (const item of cached) if (!keep.has(item.id)) await remove(ENTITY, item.id);
215
+ }).catch(() => { /* capability not granted → online-only */ });
216
+ }
217
+
218
+ function derive(s: S): S {
219
+ // The field keeps exactly what the shopper typed (a trailing space included); the match trims.
220
+ const typed = s.query.trim();
221
+ const q = typed.toLowerCase();
222
+ const next = applyFacets(s, q ? rows.filter((r) => r.name.includes(q)) : rows, currency);
223
+ const n = next.matched;
224
+ const ready = s.status === "ready";
225
+ const choices = [{ id: ALL, label: copyParam("shop.allChip", "All") }, ...categories.map((c) => ({ id: c.id, label: c.name }))];
226
+ return {
227
+ ...next,
228
+ isLoading: s.status === "loading",
229
+ notLoading: s.status !== "loading",
230
+ isErrored: s.status === "error",
231
+ notErrored: s.status !== "error",
232
+ noOffline: !s.fromCache,
233
+ categoryOptions: choices.map((c) => option(c, c.id === s.categorySelected)),
234
+ hideCategories: categories.length === 0,
235
+ countLabel: ready ? countText(n) : "",
236
+ noEmpty: !ready || n > 0,
237
+ emptyText: typed
238
+ ? copyTemplate("shop.noQueryMatch", "No products match “{query}”.", { query: typed })
239
+ : next.activeFilters
240
+ ? copyParam("shop.noMatchText", "No products match these filters.")
241
+ : s.categorySelected !== ALL
242
+ ? copyParam("shop.emptyCategoryText", "No products in this category yet.")
243
+ : copyParam("shop.emptyText", "No products are published yet."),
244
+ };
245
+ }
246
+
247
+ async function fetchPage(category: string, cursor?: string) {
248
+ return searchStoreProducts({ limit: PAGE, ...(category !== ALL ? { categoryId: category } : {}), ...(cursor ? { cursor } : {}) });
249
+ }
250
+
251
+ /** Fetch the rest of a scope in the background, a page at a time. A page that lands while
252
+ * this scope is on screen re-derives — also after a round trip to another chip and back. */
253
+ async function fetchRest(category: string, cursor: string): Promise<void> {
254
+ let next: string | undefined = cursor;
255
+ let failed = false;
256
+ const scope = scopes.get(category);
257
+ if (!scope) return;
258
+ const seenCursors = new Set<string>();
259
+ const seenProducts = new Set(scope.rows.map(row => row.card.id));
260
+ try {
261
+ while (next && scope.rows.length < MAX_PRODUCTS) {
262
+ if (scopes.get(category) !== scope) return;
263
+ if (seenCursors.has(next) || seenCursors.size >= 100) throw new Error("Product pagination did not advance");
264
+ seenCursors.add(next);
265
+ const res = await fetchPage(category, next);
266
+ if (scopes.get(category) !== scope) return;
267
+ const added = toRows(res.products ?? []).filter(row => {
268
+ if (seenProducts.has(row.card.id)) return false;
269
+ seenProducts.add(row.card.id);
270
+ return true;
271
+ });
272
+ scope.total = Math.max(scope.total ?? 0, res.pagingMetadata?.total ?? 0);
273
+ next = res.pagingMetadata?.cursors?.next ?? undefined;
274
+ if (!added.length && next) throw new Error("Product pagination made no progress");
275
+ scope.rows.push(...added.slice(0, MAX_PRODUCTS - scope.rows.length));
276
+ if (category === selectedCategory) await svc.scopeGrew();
277
+ if (!next && seenProducts.size < Math.min(scope.total, MAX_PRODUCTS)) throw new Error("Product pagination is incomplete");
278
+ }
279
+ } catch { failed = true; /* keep what arrived; pull to refresh tries again */ }
280
+ if (scopes.get(category) !== scope) return;
281
+ scope.complete = !failed;
282
+ scope.loading = false;
283
+ if (!failed && category === ALL) writeCachedCards(scope.rows, scope.currency);
284
+ }
285
+
286
+ /** Wait, a bounded number of turns, until the action that started fetch `gen` has committed
287
+ * its state. Actions are not serialized and each commits when it resolves, so an answer that
288
+ * comes back at once (a cache, a local fixture) could otherwise land first and then be
289
+ * overwritten by the older action's skeleton. */
290
+ async function committed(gen: number): Promise<void> {
291
+ for (let i = 0; i < 50 && (svc as unknown as { getState(): S }).getState().scopeGen !== gen; i++) await Promise.resolve();
292
+ }
293
+
294
+ async function publishCategoryState(): Promise<void> {
295
+ // A cached category read can settle before the load that selected its scope commits.
296
+ // Dispatch the sidecar against that committed state, including on a warm Home route.
297
+ await committed(generation);
298
+ if ((svc as unknown as { getState(): S }).getState().scopeGen !== generation) return;
299
+ await svc.loaded({ auxiliaryOnly: true });
300
+ }
301
+
302
+ async function publishAuxiliaryIfReady(): Promise<void> {
303
+ const pending = pendingAuxiliary;
304
+ if (!pending || pending.version !== auxiliaryVersion) return;
305
+ const current = (svc as unknown as { getState(): S }).getState();
306
+ if (current.status === "loading" || current.scopeGen !== generation) return;
307
+ pendingAuxiliary = undefined;
308
+ await svc.loaded({ cartCount: pending.cartCount, fresh: false, auxiliaryOnly: true });
309
+ }
310
+
311
+ /** Fetch `category`'s first page and hand it to `scopeLanded`. The action that started the
312
+ * fetch has already returned: a search, a sort or another chip the shopper picks meanwhile
313
+ * is current state, and a state captured before this await must never overwrite it. */
314
+ async function fetchScope(category: string, gen: number, refresh = false): Promise<void> {
315
+ // A pull to refresh keeps what is on screen until the new page is in hand.
316
+ const shown = refresh ? scopes.get(category) : undefined;
317
+ try {
318
+ const res = await fetchPage(category);
319
+ if (gen !== generation) return;
320
+ if (pendingRefreshScopes.get(category) === gen) pendingRefreshScopes.delete(category);
321
+ const products: StoreProduct[] = res.products ?? [];
322
+ const cursor = res.pagingMetadata?.cursors?.next;
323
+ const loaded = toRows(products);
324
+ const total = res.pagingMetadata?.total;
325
+ // Keep partial live rows visible, but neither cache them nor suppress a later retry.
326
+ const complete = !cursor && new Set(loaded.map(row => row.card.id)).size >= Math.min(total ?? 0, MAX_PRODUCTS);
327
+ const scope = { rows: loaded, complete, loading: !!cursor, fromCache: false, currency: products.map(currencyOf).find(Boolean), total };
328
+ // Fresh data for this scope: every other scope refetches when the shopper next opens it.
329
+ if (refresh) scopes.clear();
330
+ scopes.set(category, scope);
331
+ rows = scope.rows;
332
+ currency = scope.currency ?? currency;
333
+ if (cursor) void fetchRest(category, cursor);
334
+ else if (scope.complete && category === ALL) writeCachedCards(scope.rows, scope.currency);
335
+ await committed(gen);
336
+ if (gen === generation) {
337
+ await svc.scopeLanded({ gen, category, status: "ready", fromCache: false });
338
+ await publishAuxiliaryIfReady();
339
+ }
340
+ } catch (error) {
341
+ if (gen !== generation) return;
342
+ if (pendingRefreshScopes.get(category) === gen) pendingRefreshScopes.delete(category);
343
+ if (shown) {
344
+ // A failed refresh is not a failed screen: the cards the shopper was looking at stay.
345
+ await committed(gen);
346
+ if (gen === generation) {
347
+ await svc.scopeLanded({ gen, category, status: "ready", fromCache: !!shown?.fromCache });
348
+ await publishAuxiliaryIfReady();
349
+ }
350
+ return;
351
+ }
352
+ // Offline: paint the last synced catalog — only for All, and never over an auth refusal.
353
+ const status = /HTTP (\d{3})/.exec(String(error))?.[1];
354
+ if (category === ALL && status !== "401" && status !== "403") {
355
+ const cached = await readCachedCards();
356
+ if (gen !== generation) return;
357
+ if (cached.length) {
358
+ const legacyCache = cached.some((card) => card.legacyCache || typeof card.cachePrice !== "number" || !card.cacheOptions);
359
+ rows = cached.map((card) => {
360
+ const stored = card;
361
+ currency ??= stored.cacheCurrency;
362
+ return {
363
+ card, price: Number.isFinite(stored.cachePrice) ? stored.cachePrice! : 0,
364
+ created: stored.cacheCreated ?? "", name: stored.cacheName ?? card.name.toLowerCase(), options: stored.cacheOptions ?? {},
365
+ };
366
+ });
367
+ scopes.set(category, { rows, complete: true, loading: false, fromCache: true, currency });
368
+ await committed(gen);
369
+ if (gen === generation) {
370
+ await svc.scopeLanded({ gen, category, status: "ready", fromCache: true, resetFacets: legacyCache && shouldResetLegacyCacheFacets() });
371
+ await publishAuxiliaryIfReady();
372
+ }
373
+ return;
374
+ }
375
+ }
376
+ rows = [];
377
+ await committed(gen);
378
+ if (gen === generation) {
379
+ await svc.scopeLanded({ gen, category, status: "error", fromCache: false });
380
+ await publishAuxiliaryIfReady();
381
+ }
382
+ } finally {
383
+ if (pendingScopes.get(category) === gen) pendingScopes.delete(category);
384
+ if (gen === generation && pendingRefreshScopes.get(category) === gen) pendingRefreshScopes.delete(category);
385
+ }
386
+ }
387
+
388
+ /** Make `category` the scope, committing the chip at once: from memory when it was fetched
389
+ * before, else its skeleton now and its rows when `fetchScope` lands. `refresh` (pull to
390
+ * refresh) fetches again but leaves the current cards up until the new page is in hand. */
391
+ function showScope(s: S, category: string, refresh = false): S {
392
+ const pending = pendingScopes.get(category);
393
+ if (!refresh && category === selectedCategory && pending === generation) {
394
+ return derive({ ...s, status: "loading", fromCache: false, categorySelected: category, scopeGen: generation });
395
+ }
396
+ const known = scopes.get(category);
397
+ const needsRefresh = refresh || pendingRefreshScopes.has(category) || (!!known && !known.complete && !known.loading);
398
+ selectedCategory = category;
399
+ const gen = ++generation;
400
+ if (known) {
401
+ rows = known.rows;
402
+ currency = known.currency ?? currency;
403
+ }
404
+ if (known && !needsRefresh) {
405
+ return derive({ ...s, status: "ready", fromCache: !!known.fromCache, categorySelected: category, scopeGen: gen,
406
+ visible: PAGE_SIZE, cursorEpoch: s.cursorEpoch + 1 });
407
+ }
408
+ pendingScopes.set(category, gen);
409
+ if (needsRefresh) pendingRefreshScopes.set(category, gen);
410
+ void fetchScope(category, gen, needsRefresh);
411
+ const keep = needsRefresh && !!known && s.status === "ready";
412
+ return derive({ ...s, status: keep ? "ready" : "loading", categorySelected: category, scopeGen: gen,
413
+ ...(keep ? {} : { visible: PAGE_SIZE, cursorEpoch: s.cursorEpoch + 1 }) });
414
+ }
415
+
416
+ /** The categories and the cart count, then `loaded` — which reads the state as it is when
417
+ * they land, not as it was when the load began. */
418
+ function loadedState(s: S, arg?: unknown): S | undefined {
419
+ const { cartCount, fresh, auxiliaryOnly } = (arg ?? {}) as { cartCount?: number; fresh?: boolean; auxiliaryOnly?: boolean };
420
+ if (typeof cartCount === "number" && cartCount >= 0) loadedCartCount = cartCount;
421
+ const base = { ...s, cartCount: typeof cartCount === "number" && cartCount >= 0 ? cartCount : s.cartCount };
422
+ if (auxiliaryOnly) return derive(base);
423
+ const category = selectedCategory;
424
+ if (!fresh && category === s.categorySelected && pendingScopes.get(category) === generation) {
425
+ // A first-page failure has already committed its error state. A late cart/category
426
+ // sidecar must not turn that completed failure back into an unowned loading skeleton.
427
+ if (s.status === "error") return derive(base);
428
+ const landed = scopes.get(category);
429
+ if (landed) {
430
+ // The rows can land just before scopeLanded commits. A later overlapping load may
431
+ // invalidate that action result, so it must be able to publish the landed scope too.
432
+ return derive({
433
+ ...base,
434
+ status: "ready",
435
+ fromCache: !!landed.fromCache,
436
+ categorySelected: category,
437
+ scopeGen: generation,
438
+ ...(s.scopeGen === generation ? {} : { visible: PAGE_SIZE, cursorEpoch: s.cursorEpoch + 1 }),
439
+ });
440
+ }
441
+ // Boot load and onAppear load can overlap. This action may commit after scopeLanded,
442
+ // so only publish the pending generation if this state has not seen it yet. scopeLanded
443
+ // invalidates older load results in withIapPresentation before it publishes ready data.
444
+ return s.scopeGen === generation
445
+ ? (base.cartCount === s.cartCount ? undefined : derive({ ...base, status: "loading", fromCache: false }))
446
+ : derive({
447
+ ...base,
448
+ status: "loading",
449
+ fromCache: false,
450
+ categorySelected: category,
451
+ scopeGen: generation,
452
+ visible: PAGE_SIZE,
453
+ cursorEpoch: s.cursorEpoch + 1,
454
+ });
455
+ }
456
+ const scope = scopes.get(category);
457
+ const needsRefresh = fresh || !!scope?.fromCache || (!!scope && !scope.complete && !scope.loading);
458
+ return !needsRefresh && category === s.categorySelected && !!scope && s.status === "ready"
459
+ ? derive(base)
460
+ : showScope(base, category, needsRefresh);
461
+ }
462
+
463
+ async function load(s: S, arg?: unknown, fresh = false): Promise<S | undefined> {
464
+ stashCopy(arg);
465
+ const route = input(arg);
466
+ const routeCategory = route.categoryId ?? route.category;
467
+ if (!fresh && routeCategory && (
468
+ route.categoryActivation
469
+ ? route.categoryActivation !== lastRouteActivation
470
+ : !routeCategoryConsumed
471
+ )) {
472
+ lastRouteActivation = route.categoryActivation;
473
+ routeCategoryConsumed = true;
474
+ pendingLink = routeCategory.trim();
475
+ pendingLinkIntentVersion = categoryIntentVersion;
476
+ }
477
+ const loadIntentVersion = categoryIntentVersion;
478
+ // The initial catalog is useful before the optional category and cart reads land.
479
+ // Start it now. Its completion action merges auxiliary data only after the active
480
+ // scope has settled, so it cannot invalidate the pending product transition.
481
+ if (!pendingLink && selectedCategory === ALL) {
482
+ const initial = loadedState(s, { fresh });
483
+ const version = ++auxiliaryVersion;
484
+ void readCategories(fresh).then(async () => {
485
+ const { invalidatedSelection, intentVersion: invalidatedIntentVersion } = reconcileCategories();
486
+ // Category discovery has its own lifetime from the cart read. Publish it even when
487
+ // that read's count already landed or a newer onAppear superseded the count request.
488
+ await publishCategoryState();
489
+ await publishAuxiliaryIfReady();
490
+ // A category can disappear while the fast All-scope load is refreshing in the
491
+ // background. Resetting the worker's selection alone leaves the chip and rows on
492
+ // the removed scope; dispatch the real scope transition after this sidecar lands.
493
+ if (invalidatedSelection && loadIntentVersion !== invalidatedIntentVersion) {
494
+ // The All request started with the refresh was canceled by the chip tap, so
495
+ // switch back with a fresh All request instead of reviving its stale scope cache.
496
+ // Recheck the intent after the auxiliary action yields: a newer chip tap wins.
497
+ if (selectedCategory === ALL && categoryIntentVersion === invalidatedIntentVersion) {
498
+ await svc.selectCategory({ selectedIds: [ALL], refresh: true });
499
+ }
500
+ }
501
+ });
502
+ void cartItemCount().then(async (cartCount) => {
503
+ if (version !== auxiliaryVersion) return;
504
+ if (typeof cartCount === "number" && cartCount >= 0) loadedCartCount = cartCount;
505
+ pendingAuxiliary = { cartCount: cartCount ?? -1, version };
506
+ await publishAuxiliaryIfReady();
507
+ });
508
+ return initial;
509
+ }
510
+ // Home sends categoryId because it already knows the stable Wix id. Start that
511
+ // primary product request immediately while category discovery runs alongside it;
512
+ // the category list still validates the route and supplies the chip labels.
513
+ if (!fresh && route.categoryId && pendingLink === route.categoryId.trim()) {
514
+ const category = route.categoryId.trim();
515
+ const initial = showScope(s, category);
516
+ const version = ++auxiliaryVersion;
517
+ void readCategories(!matchCategory(category)).then(async () => {
518
+ const { invalidatedSelection, intentVersion } = reconcileCategories();
519
+ await publishCategoryState();
520
+ await publishAuxiliaryIfReady();
521
+ const routeStillCurrent = categoryIntentVersion === loadIntentVersion && intentVersion === loadIntentVersion;
522
+ if ((invalidatedSelection || !matchCategory(category)) && selectedCategory === ALL && routeStillCurrent) {
523
+ await svc.selectCategory({ selectedIds: [ALL], refresh: true });
524
+ }
525
+ });
526
+ void cartItemCount().then(async (cartCount) => {
527
+ if (version !== auxiliaryVersion) return;
528
+ if (typeof cartCount === "number" && cartCount >= 0) loadedCartCount = cartCount;
529
+ pendingAuxiliary = { cartCount: cartCount ?? -1, version };
530
+ await publishAuxiliaryIfReady();
531
+ });
532
+ return initial;
533
+ }
534
+ // A refresh of an already-selected category also starts its product request before
535
+ // waiting for category discovery. If that discovery removes the category, the same
536
+ // sidecar transitions back to All after the current selection is invalidated.
537
+ if (!pendingLink && selectedCategory !== ALL) {
538
+ const cartRead = cartItemCount();
539
+ const categoryRead = readCategories(fresh);
540
+ const version = ++auxiliaryVersion;
541
+ const current = (svc as unknown as { getState(): S }).getState();
542
+ const next = loadedState(current, { fresh });
543
+ void categoryRead.then(async () => {
544
+ const { invalidatedSelection, intentVersion } = reconcileCategories();
545
+ await publishCategoryState();
546
+ await publishAuxiliaryIfReady();
547
+ if (invalidatedSelection && selectedCategory === ALL && categoryIntentVersion === intentVersion) {
548
+ await svc.selectCategory({ selectedIds: [ALL], refresh: true });
549
+ }
550
+ });
551
+ void cartRead.then(async (cartCount) => {
552
+ if (version !== auxiliaryVersion) return;
553
+ if (typeof cartCount === "number" && cartCount >= 0) loadedCartCount = cartCount;
554
+ pendingAuxiliary = { cartCount: cartCount ?? -1, version };
555
+ await publishAuxiliaryIfReady();
556
+ });
557
+ return next;
558
+ }
559
+ // A route category needs category discovery, but neither it nor an already-selected
560
+ // category needs the cart count to start the primary product request.
561
+ const cartRead = cartItemCount();
562
+ const missingRouteCategory = !!pendingLink && !matchCategory(pendingLink);
563
+ await readCategories(fresh || missingRouteCategory);
564
+ const version = ++auxiliaryVersion;
565
+ reconcileCategories();
566
+ // Not awaited: an action waiting on another action of its own service never sees it run.
567
+ // Return the reducer result directly. Dispatching `svc.loaded()` from this active
568
+ // action leaves the follow-up scope transition pending in headless worker runs, so
569
+ // Shop never leaves its loading skeleton there even though the Wix response arrived.
570
+ // Loads resolve asynchronously and can overlap taps or one another. Use the committed
571
+ // state at completion so a late boot/onAppear result keeps transient UI such as an open
572
+ // filter sheet instead of restoring the action's stale snapshot.
573
+ const current = (svc as unknown as { getState(): S }).getState();
574
+ const next = loadedState(current, { fresh });
575
+ void cartRead.then(async (cartCount) => {
576
+ if (version !== auxiliaryVersion) return;
577
+ if (typeof cartCount === "number" && cartCount >= 0) loadedCartCount = cartCount;
578
+ pendingAuxiliary = { cartCount: cartCount ?? -1, version };
579
+ await publishAuxiliaryIfReady();
580
+ });
581
+ return next;
582
+ }
583
+
584
+ const view = readViewPrefs();
585
+ const BOOT: S = derive({
586
+ ...FACETS_BOOT, ...view,
587
+ status: "loading", isLoading: true, notLoading: false, isErrored: false, notErrored: true, fromCache: false, noOffline: true,
588
+ categorySelected: ALL, scopeGen: 0, query: "", categoryOptions: [], hideCategories: true,
589
+ countLabel: "", noEmpty: true, emptyText: "", cartCount: 0,
590
+ });
591
+
592
+ const facets = facetActions<S>(derive);
593
+ const CONTENT = ["load", "refresh", "retry", "loaded", "scopeLanded", "scopeGrew", "selectCategory", "search", "toggleSave", "loadMore", ...Object.keys(facets)];
594
+ const register = withIapPresentation<S>({
595
+ // Restored from the last run, these painted a state this boot has not reached: a count over
596
+ // no cards, an open sheet, a stale scope generation that defeats `committed`, a search
597
+ // narrowing the list under an empty field. The shopper's view, sort and filters do persist.
598
+ prefix: "shop",
599
+ transientKeys: ["results", "query", "scopeGen", "status", "isLoading", "notLoading", "isErrored", "notErrored", "fromCache", "noOffline",
600
+ "countLabel", "noEmpty", "emptyText", "cartCount", "panel", "filtersOpen", "sortOpen", "categorySelected", "categoryOptions", "hideCategories",
601
+ "sortOptions", "sortLabel", "filterLabel", "activeFilters", "noActiveFilters", "priceChips", "priceSelected", "hidePrice",
602
+ "opt1Name", "opt1Chips", "opt1Selected", "hideOpt1", "opt2Name", "opt2Chips", "opt2Selected", "hideOpt2", "opt3Name", "opt3Chips", "opt3Selected", "hideOpt3",
603
+ "visible", "cursorEpoch", "matched", "nextCursor", "noMore", "showLabel", "hideGrid", "hideList", "hideComfortable", "hideCompact"],
604
+ contentActions: CONTENT,
605
+ forcePriceRefreshActions: ["load", "refresh", "retry"],
606
+ items: (state) => state.results.map((row) => ({ reference: { kind: "storesProduct", id: row.id }, commerceDisplayPrice: row.commercePrice })),
607
+ present: (state, price, _allowed, appleIapOrResolving) => ({ ...state, results: state.results.map((row) => {
608
+ const stored = row as typeof row & { commerceWasPrice?: string; commerceOnSale?: boolean };
609
+ const wasPrice = stored.commerceWasPrice ?? row.wasPrice;
610
+ const onSale = stored.commerceOnSale ?? row.onSale;
611
+ if (appleIapOrResolving(row.id)) return {
612
+ ...row,
613
+ commerceWasPrice: wasPrice,
614
+ commerceOnSale: onSale,
615
+ price: price(row.id, row.commercePrice) || row.price,
616
+ wasPrice: "",
617
+ onSale: false,
618
+ notOnSale: true,
619
+ noTagRow: row.noSwatches,
620
+ };
621
+ return {
622
+ ...row,
623
+ price: price(row.id, row.commercePrice) || row.price,
624
+ wasPrice,
625
+ onSale,
626
+ notOnSale: !onSale,
627
+ noTagRow: !onSale && row.noSwatches,
628
+ };
629
+ }) }),
630
+ });
631
+
632
+ type A = (s: S, arg?: unknown) => S | undefined | Promise<S | undefined>;
633
+ const svc = register<S, Record<string, A>>("himiBrandedShop", {
634
+ state: BOOT,
635
+ actions: {
636
+ load: (s, arg) => load(s, arg),
637
+ // Pull to refresh: the categories and the scope again, from Wix.
638
+ refresh: (s, arg) => load(s, arg, true),
639
+ retry: (s) => showScope(s, s.categorySelected),
640
+ // The categories and the cart count landed (load / refresh).
641
+ loaded: (s, arg) => loadedState(s, arg),
642
+ // The first page of the scope on screen landed, or failed.
643
+ scopeLanded: (s, arg) => {
644
+ const { gen, category, status, fromCache, resetFacets } = (arg ?? {}) as { gen?: number; category?: string; status?: S["status"]; fromCache?: boolean; resetFacets?: boolean };
645
+ if (gen !== generation) return undefined;
646
+ return derive({ ...s, ...(loadedCartCount === undefined ? {} : { cartCount: loadedCartCount }), ...(resetFacets ? { sort: "recommended", priceSelected: [], opt1Selected: [], opt2Selected: [], opt3Selected: [] } : {}), status: status ?? "ready", fromCache: !!fromCache, categorySelected: category ?? selectedCategory, scopeGen: gen });
647
+ },
648
+ // A background page landed for the current scope.
649
+ scopeGrew: (s) => derive(s),
650
+ // Single-select: one chip is always chosen.
651
+ selectCategory: (s, arg) => {
652
+ // The chip row sends { selectedIds }; a deselected chip falls back to All.
653
+ const selection = arg as { selectedIds?: unknown; refresh?: boolean } | undefined;
654
+ const id = selection?.selectedIds !== undefined ? selected(arg)[0] ?? ALL : idOf(arg);
655
+ const refresh = selection?.refresh === true;
656
+ const cancelsPendingRoute = !!pendingLink || selectedCategory !== id;
657
+ categoryIntentVersion++;
658
+ pendingLink = undefined;
659
+ if (id !== ALL && !categories.some((c) => c.id === id)) return s;
660
+ selectedCategory = id;
661
+ // categorySelected is committed with the tap (showScope), so a tap back to the chip
662
+ // on screen while another category loads is a no-op on the right scope.
663
+ if (id === s.categorySelected) return cancelsPendingRoute || refresh ? showScope(s, id, refresh) : s;
664
+ const next = showScope(s, id, refresh);
665
+ const pending = pendingAuxiliary;
666
+ if (pending && pending.version === auxiliaryVersion && pending.cartCount >= 0) {
667
+ pendingAuxiliary = undefined;
668
+ loadedCartCount = pending.cartCount;
669
+ return derive({ ...next, cartCount: pending.cartCount });
670
+ }
671
+ return next;
672
+ },
673
+ search: (s, arg) => {
674
+ // Stored as typed: it is bound back into the field, and a trimmed copy would eat the
675
+ // space the shopper just typed before the next word.
676
+ const query = String((arg as { value?: unknown } | undefined)?.value ?? "").slice(0, 256);
677
+ if (query === s.query) return s;
678
+ return derive({ ...s, query, ...(query.trim() === s.query.trim() ? {} : { visible: PAGE_SIZE, cursorEpoch: s.cursorEpoch + 1 }) });
679
+ },
680
+ toggleSave: (s, arg) => {
681
+ try { toggleSaved(s.results, idOf(arg)); } catch { return s; }
682
+ return derive(s);
683
+ },
684
+ ...facets,
685
+ // The list view's load-more.
686
+ loadMore: (s) => facets.showMore(s),
687
+ },
688
+ });
689
+
690
+ svc.load();
691
+ export { svc };