@osimart/create-storefront 0.0.0-stage → 1.0.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 (206) hide show
  1. package/README.md +80 -2
  2. package/THEME_DEV_API.md +80 -0
  3. package/app/app.vue +154 -0
  4. package/app/assets/css/osi-controls.css +824 -0
  5. package/app/components/SectionRenderer.vue +46 -0
  6. package/app/components/builder/BuilderOverlay.vue +582 -0
  7. package/app/components/checkout/ExtraFees.vue +329 -0
  8. package/app/components/checkout/PaymentHandoff.vue +388 -0
  9. package/app/components/common/Banner.vue +35 -0
  10. package/app/components/common/ConfirmDeleteModal.vue +117 -0
  11. package/app/components/common/DropdownSelect.vue +432 -0
  12. package/app/components/common/Loader.vue +32 -0
  13. package/app/components/common/OsiField.vue +218 -0
  14. package/app/components/common/PhoneNumInput.vue +434 -0
  15. package/app/composables/UseBodyScrollLock.js +122 -0
  16. package/app/composables/UseThemeGroup.js +575 -0
  17. package/app/composables/useAddToCart.js +87 -0
  18. package/app/composables/useAnalytics.js +15 -0
  19. package/app/composables/useAnnouncements.js +29 -0
  20. package/app/composables/useApiErrorToast.js +104 -0
  21. package/app/composables/useAuth.js +99 -0
  22. package/app/composables/useBanners.js +25 -0
  23. package/app/composables/useBuilderPreview.js +320 -0
  24. package/app/composables/useButtonStyle.js +107 -0
  25. package/app/composables/useCarousel.js +160 -0
  26. package/app/composables/useCart.js +109 -0
  27. package/app/composables/useCheckout.js +76 -0
  28. package/app/composables/useClassificationItems.js +269 -0
  29. package/app/composables/useClassificationPage.js +545 -0
  30. package/app/composables/useClassificationUrl.js +63 -0
  31. package/app/composables/useColorScheme.js +44 -0
  32. package/app/composables/useContactForm.js +53 -0
  33. package/app/composables/useContentTree.js +770 -0
  34. package/app/composables/useDragScroll.js +73 -0
  35. package/app/composables/useFooterSocials.js +107 -0
  36. package/app/composables/useInputStyle.js +45 -0
  37. package/app/composables/useLayoutShell.js +114 -0
  38. package/app/composables/useLazyVideo.js +36 -0
  39. package/app/composables/useMediaTypes.js +66 -0
  40. package/app/composables/useMediaUrl.js +71 -0
  41. package/app/composables/useMenu.js +100 -0
  42. package/app/composables/useNavStyle.js +219 -0
  43. package/app/composables/usePlaceholders.js +294 -0
  44. package/app/composables/usePolicies.js +26 -0
  45. package/app/composables/useProductCardMedia.js +135 -0
  46. package/app/composables/useProductCardStyle.js +132 -0
  47. package/app/composables/useProductDetail.js +982 -0
  48. package/app/composables/useProductGallery.js +166 -0
  49. package/app/composables/useProductItems.js +270 -0
  50. package/app/composables/useProductUrl.js +60 -0
  51. package/app/composables/useProductVariants.js +210 -0
  52. package/app/composables/useQuickShop.js +115 -0
  53. package/app/composables/useRelatedProducts.js +186 -0
  54. package/app/composables/useRenderContext.js +45 -0
  55. package/app/composables/useScopedCss.js +106 -0
  56. package/app/composables/useSearch.js +35 -0
  57. package/app/composables/useSectionData.js +462 -0
  58. package/app/composables/useSectionShell.js +113 -0
  59. package/app/composables/useSidebar.js +69 -0
  60. package/app/composables/useStoreData.js +57 -0
  61. package/app/composables/useStoreInit.js +52 -0
  62. package/app/composables/useStorefront.js +239 -0
  63. package/app/composables/useT.js +21 -0
  64. package/app/composables/useTextStyles.js +84 -0
  65. package/app/composables/useThemeControls.js +34 -0
  66. package/app/composables/useThemeSeo.js +91 -0
  67. package/app/composables/useThemeTokens.js +171 -0
  68. package/app/composables/useWishlist.js +66 -0
  69. package/app/logic/components/AnnouncementBar.js +118 -0
  70. package/app/logic/components/CardGrid.js +125 -0
  71. package/app/logic/components/CardList.js +98 -0
  72. package/app/logic/components/CartDrawer.js +273 -0
  73. package/app/logic/components/CartItem.js +31 -0
  74. package/app/logic/components/ClassificationBento.js +6 -0
  75. package/app/logic/components/ClassificationCard.js +6 -0
  76. package/app/logic/components/ClassificationCarousel.js +115 -0
  77. package/app/logic/components/ClassificationEditorial.js +41 -0
  78. package/app/logic/components/ClassificationFeatured.js +57 -0
  79. package/app/logic/components/ClassificationGrid.js +6 -0
  80. package/app/logic/components/ClassificationNav.js +61 -0
  81. package/app/logic/components/ClassificationPart.js +72 -0
  82. package/app/logic/components/FooterBlock.js +91 -0
  83. package/app/logic/components/FooterSocials.js +60 -0
  84. package/app/logic/components/HeaderDropdownList.js +29 -0
  85. package/app/logic/components/HeaderIcon.js +68 -0
  86. package/app/logic/components/HeaderLogo.js +39 -0
  87. package/app/logic/components/HeaderNav.js +268 -0
  88. package/app/logic/components/MobileMenu.js +280 -0
  89. package/app/logic/components/ProductBuyButtons.js +66 -0
  90. package/app/logic/components/ProductCard.js +160 -0
  91. package/app/logic/components/ProductFilters.js +37 -0
  92. package/app/logic/components/ProductGallery.js +19 -0
  93. package/app/logic/components/ProductInfoBlock.js +411 -0
  94. package/app/logic/components/ProductLightbox.js +12 -0
  95. package/app/logic/components/ProductQuantity.js +31 -0
  96. package/app/logic/components/ProductVariantPicker.js +125 -0
  97. package/app/logic/components/QuickBuy.js +194 -0
  98. package/app/logic/components/SearchOverlay.js +41 -0
  99. package/app/logic/components/SidebarMenuItem.js +39 -0
  100. package/app/logic/components/SiteFooter.js +476 -0
  101. package/app/logic/components/SiteHeader.js +266 -0
  102. package/app/logic/components/WhatsAppButton.js +75 -0
  103. package/app/logic/layouts/default.js +66 -0
  104. package/app/logic/sections/AnnouncementBarSection.js +6 -0
  105. package/app/logic/sections/BlockCarousel.js +103 -0
  106. package/app/logic/sections/ClassificationListSection.js +40 -0
  107. package/app/logic/sections/ContactFormSection.js +294 -0
  108. package/app/logic/sections/ContactInfoSection.js +419 -0
  109. package/app/logic/sections/ContentNode.js +189 -0
  110. package/app/logic/sections/CountdownSection.js +254 -0
  111. package/app/logic/sections/CustomHtmlSection.js +51 -0
  112. package/app/logic/sections/DividerSection.js +31 -0
  113. package/app/logic/sections/FaqSection.js +53 -0
  114. package/app/logic/sections/FeatureListSection.js +6 -0
  115. package/app/logic/sections/FeaturedProductSection.js +40 -0
  116. package/app/logic/sections/FooterSection.js +16 -0
  117. package/app/logic/sections/FooterUtilitiesSection.js +24 -0
  118. package/app/logic/sections/GallerySection.js +232 -0
  119. package/app/logic/sections/HeaderSection.js +24 -0
  120. package/app/logic/sections/HeroSection.js +24 -0
  121. package/app/logic/sections/LogoListSection.js +6 -0
  122. package/app/logic/sections/MainClassificationSection.js +473 -0
  123. package/app/logic/sections/MainProductSection.js +211 -0
  124. package/app/logic/sections/MainSearchSection.js +6 -0
  125. package/app/logic/sections/MapSection.js +92 -0
  126. package/app/logic/sections/MediaContentSection.js +38 -0
  127. package/app/logic/sections/MediaWithTextSection.js +238 -0
  128. package/app/logic/sections/NewsletterSection.js +126 -0
  129. package/app/logic/sections/ProductHighlightSection.js +201 -0
  130. package/app/logic/sections/ProductListSection.js +45 -0
  131. package/app/logic/sections/ProductRecommendationsSection.js +113 -0
  132. package/app/logic/sections/RichTextSection.js +6 -0
  133. package/app/logic/sections/ScrollingTextSection.js +69 -0
  134. package/app/logic/sections/SearchHeaderSection.js +22 -0
  135. package/app/logic/sections/SlideshowSection.js +118 -0
  136. package/app/logic/sections/SpacerSection.js +37 -0
  137. package/app/logic/sections/SplitShowcaseSection.js +6 -0
  138. package/app/logic/sections/TestimonialListSection.js +6 -0
  139. package/app/logic/sections/VideoSection.js +235 -0
  140. package/app/pages/[category]/[product].vue +196 -0
  141. package/app/pages/__builder-preview.vue +38 -0
  142. package/app/pages/categories/[slug].vue +208 -0
  143. package/app/pages/checkout.vue +1972 -0
  144. package/app/pages/forgot-password.vue +115 -0
  145. package/app/pages/index.vue +64 -0
  146. package/app/pages/login.vue +585 -0
  147. package/app/pages/order-detail.vue +442 -0
  148. package/app/pages/pages/[handle].vue +58 -0
  149. package/app/pages/privacy-policy.vue +46 -0
  150. package/app/pages/resetpassword.vue +138 -0
  151. package/app/pages/resetverificationcode.vue +163 -0
  152. package/app/pages/result.vue +203 -0
  153. package/app/pages/return-policy.vue +50 -0
  154. package/app/pages/search.vue +100 -0
  155. package/app/pages/shipping-policy.vue +46 -0
  156. package/app/pages/user-profile/components/AddAddressModal.vue +452 -0
  157. package/app/pages/user-profile/components/Whishlist.vue +137 -0
  158. package/app/pages/user-profile/components/left-bar.vue +233 -0
  159. package/app/pages/user-profile/components/orders.vue +162 -0
  160. package/app/pages/user-profile/components/profile-details.vue +566 -0
  161. package/app/pages/user-profile/components/settings.vue +194 -0
  162. package/app/pages/user-profile/index.vue +252 -0
  163. package/app/pages/verificationcode.vue +188 -0
  164. package/app/plugins/analytics.client.js +99 -0
  165. package/app/plugins/geo.ts +24 -0
  166. package/app/plugins/reveal.client.js +53 -0
  167. package/app/plugins/toast.js +10 -0
  168. package/app/plugins/vue-query.ts +5 -0
  169. package/app/services/ApiServices.js +74 -0
  170. package/app/services/AuthBus.js +162 -0
  171. package/app/services/Authentication/AuthenticationService.js +133 -0
  172. package/app/services/Authentication/TokenService.js +62 -0
  173. package/app/services/CartServices.js +91 -0
  174. package/app/services/CartToken.js +26 -0
  175. package/app/services/CategoryServices.js +54 -0
  176. package/app/services/CheckoutServices.js +113 -0
  177. package/app/services/HomeServices.js +133 -0
  178. package/app/services/NewsletterServices.js +29 -0
  179. package/app/services/PaymentServices.js +71 -0
  180. package/app/services/PoliciesServices.js +16 -0
  181. package/app/services/ProductServices.js +79 -0
  182. package/app/services/SearchServices.js +18 -0
  183. package/app/services/UserServices.js +105 -0
  184. package/app/services/UtilsServices.js +46 -0
  185. package/app/services/WishlistServices.js +51 -0
  186. package/app/services/storefront.service.js +57 -0
  187. package/app/stores/userStore.js +13 -0
  188. package/app/theme/categoryTree.js +68 -0
  189. package/app/theme/controls.js +88 -0
  190. package/app/theme/registry.js +56 -0
  191. package/app/utils/analytics.js +864 -0
  192. package/app/utils/device.js +50 -0
  193. package/app/utils/eventBus.js +24 -0
  194. package/app/utils/icon-presets.js +24 -0
  195. package/app/utils/productOffers.js +118 -0
  196. package/app/utils/requestCache.js +36 -0
  197. package/app/utils/scrollToError.js +59 -0
  198. package/app/utils/toPlainError.js +30 -0
  199. package/bin/osimart-theme.mjs +259 -0
  200. package/nuxt/osi-sections.ts +56 -0
  201. package/nuxt.config.ts +60 -0
  202. package/package.json +43 -3
  203. package/public/css/input.css +29 -0
  204. package/public/css/output.css +2716 -0
  205. package/public/favicon-32x32.png +0 -0
  206. package/public/robots.txt +2 -0
@@ -0,0 +1,462 @@
1
+ import ProductServices from "#osi/services/ProductServices";
2
+ import HomeServices from "#osi/services/HomeServices";
3
+ import { flattenCategories } from "#osi/theme/categoryTree";
4
+ import { cachedRequest } from "#osi/utils/requestCache";
5
+
6
+ /**
7
+ * Access the rows a section needs.
8
+ *
9
+ * The backend resolves SOURCE into `section.data` before the payload leaves the
10
+ * server. That's deliberate: a six-section homepage is one request instead of
11
+ * seven, and SOURCE=MANUAL would otherwise be one request per block.
12
+ *
13
+ * So this is mostly an accessor. The fetch path exists for two cases:
14
+ *
15
+ * 1. Builder preview — STATE arrives over postMessage with no `data`, because
16
+ * the editor never resolves products or classifications.
17
+ * 2. A CLASSIFICATION_LIST set to MANUAL whose blocks arrived without their
18
+ * category/collection expanded (see resolveManualClassifications). On a
19
+ * backend that resolves MANUAL server-side this never fires; until then it
20
+ * is what makes manual selection render at all.
21
+ *
22
+ * CHANGED in this revision:
23
+ * * isCollectionSource()'s eight-key heuristic is gone. SOURCE is now an
24
+ * explicit MANUAL | ALL_CATEGORIES | ALL_COLLECTIONS, so guessing from
25
+ * TYPE / TARGET / SELECTION / MODE / RESOURCE_TYPE is no longer needed. Two
26
+ * legacy keys are still read for sections created before that landed.
27
+ * * MANUAL is handled: picks are read off the blocks, and any that the
28
+ * backend didn't expand are matched against the catalog lists, which are
29
+ * fetched once per page and shared by every section on it.
30
+ * * The `current.data` early return is now conditional — a live section can
31
+ * carry `data` and still have unresolved manual picks.
32
+ *
33
+ * @param {object|Ref|(() => object)} section pass a ref/computed/getter (e.g.
34
+ * `() => props.section`) — a plain object is captured once and won't see the
35
+ * new section pushed over postMessage on the next builder edit.
36
+ */
37
+ export function useSectionData(section) {
38
+ const fetched = ref(null);
39
+ const loading = ref(false);
40
+
41
+ /**
42
+ * Catalog rows, shared by every section on the page.
43
+ *
44
+ * useState, not a module-level ref: one Nuxt process serves every tenant, and
45
+ * a module ref would leak one store's categories into another store's SSR
46
+ * response. Only ever written on the client, where that can't happen anyway —
47
+ * but the rule holds regardless of where the write happens today.
48
+ */
49
+ const catalog = useState("osi-classification-catalog", () => ({
50
+ CATEGORY: null,
51
+ COLLECTION: null,
52
+ }));
53
+
54
+ /**
55
+ * Placeholders enter here and nowhere else.
56
+ *
57
+ * This is the one point every list section reads its rows from, and the rows
58
+ * are still in raw API shape at this stage — so useClassificationItems,
59
+ * ProductCard, PARENTS_ONLY, LIMIT and HIDE_EMPTY all treat a placeholder
60
+ * exactly like a real row and need no branch of their own. Filling further
61
+ * downstream would mean teaching each of them separately.
62
+ *
63
+ * usePlaceholders returns the real rows untouched whenever there are any, and
64
+ * returns nothing at all outside the builder iframe.
65
+ */
66
+ const placeholders = usePlaceholders();
67
+
68
+ const rawProducts = computed(
69
+ () => fetched.value?.products ?? toValue(section)?.data?.products ?? []
70
+ );
71
+
72
+ /**
73
+ * Categories are flattened whichever way they arrived. The client fetch
74
+ * always flattened; the backend's resolved `data.classifications` didn't, so
75
+ * on a first load (server data) subcategories nested under their parents
76
+ * were invisible, and only appeared once an edit in the dashboard triggered
77
+ * the client fetch — "Top-level only" off seemed to need clicking twice.
78
+ */
79
+ const rawClassifications = computed(() =>
80
+ flattenCategories(fetched.value?.classifications ?? toValue(section)?.data?.classifications ?? [])
81
+ );
82
+
83
+ const products = computed(() => {
84
+ const current = toValue(section);
85
+ if (current?.type !== "PRODUCT_LIST") return rawProducts.value;
86
+ return placeholders.products(
87
+ rawProducts.value,
88
+ Number(current.settings?.LIMIT) || 8
89
+ );
90
+ });
91
+
92
+ const classifications = computed(() => {
93
+ const current = toValue(section);
94
+ if (current?.type !== "CLASSIFICATION_LIST") return rawClassifications.value;
95
+
96
+ return placeholders.classifications(
97
+ rawClassifications.value,
98
+ Number(current.settings?.LIMIT) || 6
99
+ );
100
+ });
101
+
102
+ /**
103
+ * Manually picked product ids.
104
+ *
105
+ * Reads the relation and the setting. Which one a block carries depends on
106
+ * how the editor persisted the pick, and a preview that only understands one
107
+ * of them renders an empty grid while the panel shows products selected.
108
+ */
109
+ function manualProductIds(current) {
110
+ const idOf = (value) => (value && typeof value === "object" ? value.id : value);
111
+ // The panel's multi-select first (pick order), then older sections'
112
+ // PRODUCT blocks. Header and card blocks carry no product.
113
+ const ids = (current?.settings?.PRODUCTS ?? []).map(idOf).filter(Boolean);
114
+ for (const b of current?.blocks ?? []) {
115
+ if (b.type !== "PRODUCT") continue;
116
+ const id = idOf(b.relations?.product ?? b.settings?.PRODUCT ?? null);
117
+ if (id && !ids.some((x) => String(x) === String(id))) ids.push(id);
118
+ }
119
+ return ids;
120
+ }
121
+
122
+ /**
123
+ * A MANUAL product list whose resolved rows don't hold every pick — the
124
+ * backend resolved picks from PRODUCT blocks only, or the payload predates
125
+ * the pick. Fetched on the client, like manual classifications.
126
+ */
127
+ function needsManualProducts(current) {
128
+ if (current?.type !== "PRODUCT_LIST") return false;
129
+ if (String(current.settings?.SOURCE ?? "").toUpperCase() !== "MANUAL") return false;
130
+ const ids = manualProductIds(current);
131
+ if (!ids.length) return false;
132
+ const have = new Set((current.data?.products ?? []).map((p) => String(p.id)));
133
+ return ids.some((id) => !have.has(String(id)));
134
+ }
135
+
136
+ // ---------------------------------------------------------------- classification
137
+
138
+ function kindOf(value, fallback = "CATEGORY") {
139
+ return String(value ?? fallback)
140
+ .toUpperCase()
141
+ .includes("COLLECTION")
142
+ ? "COLLECTION"
143
+ : "CATEGORY";
144
+ }
145
+
146
+ function classificationSource(current) {
147
+ const s = current?.settings ?? {};
148
+ // CLASSIFICATION_TYPE and TYPE: sections created before SOURCE gained its
149
+ // MANUAL member. Everything else the old heuristic sniffed was speculative.
150
+ const raw = String(s.SOURCE ?? s.CLASSIFICATION_TYPE ?? s.TYPE ?? "").toUpperCase();
151
+
152
+ // FEATURED has no SOURCE field — it always resolves one explicit pick.
153
+ if (String(s.LAYOUT ?? "").toUpperCase() === "FEATURED") return "MANUAL";
154
+ // Picked in the panel: resolve exactly those (either kind), not a list.
155
+ if ((s.CLASSIFICATIONS ?? []).length) return "MANUAL";
156
+
157
+ if (raw === "MANUAL") return "MANUAL";
158
+ if (raw.includes("COLLECTION")) return "ALL_COLLECTIONS";
159
+ if (raw.includes("CATEGOR")) return "ALL_CATEGORIES";
160
+
161
+ // No source at all + blocks that point at a category / collection is the
162
+ // old manual behaviour. The header and card are blocks too — they don't.
163
+ const pointsAtOne = (b) =>
164
+ !!(b?.settings?.CLASSIFICATION || b?.relations?.category || b?.relations?.collection);
165
+ if ((current?.blocks ?? []).some(pointsAtOne)) return "MANUAL";
166
+ return "ALL_CATEGORIES";
167
+ }
168
+
169
+ /**
170
+ * Everything a merchant picked by hand, in render order.
171
+ *
172
+ * Three places, because there are three ways to pick:
173
+ * FEATURED_CLASSIFICATION the single pick for the featured layout
174
+ * CLASSIFICATIONS the panel's multi-select (the normal path)
175
+ * blocks legacy, and where per-item overrides live
176
+ *
177
+ * Blocks go last and win nothing on their own — they're merged, not replaced,
178
+ * so a section created before the CLASSIFICATIONS field existed still renders.
179
+ */
180
+ function manualPicks(current) {
181
+ const settings = current?.settings ?? {};
182
+ const picks = [];
183
+
184
+ const single = settings.FEATURED_CLASSIFICATION;
185
+ if (single && typeof single === "object" && single.id != null) {
186
+ picks.push({ kind: kindOf(single.kind ?? single.type), id: single.id, resolved: !!single.name });
187
+ }
188
+
189
+ for (const entry of settings.CLASSIFICATIONS ?? []) {
190
+ if (!entry) continue;
191
+ if (typeof entry === "object") {
192
+ if (entry.id == null) continue;
193
+ picks.push({ kind: kindOf(entry.kind ?? entry.type), id: entry.id, resolved: !!entry.name });
194
+ } else {
195
+ // "CATEGORY:12" — the encoded form, in case a value was persisted
196
+ // straight off the select before decode() ran.
197
+ const [kind, id] = String(entry).split(":");
198
+ if (id) picks.push({ kind: kindOf(kind), id: /^\d+$/.test(id) ? Number(id) : id, resolved: false });
199
+ }
200
+ }
201
+
202
+ return [...picks, ...blockPicks(current)];
203
+ }
204
+
205
+ /** The { kind, id } a merchant picked on a block, in block order. */
206
+ function blockPicks(current) {
207
+ return (current?.blocks ?? [])
208
+ .map((block) => {
209
+ const relations = block.relations ?? {};
210
+ if (relations.category) {
211
+ return { kind: "CATEGORY", id: relations.category.id, resolved: true };
212
+ }
213
+ if (relations.collection) {
214
+ return { kind: "COLLECTION", id: relations.collection.id, resolved: true };
215
+ }
216
+
217
+ const raw = block.settings?.CLASSIFICATION ?? relations.classification ?? null;
218
+ if (!raw) return null;
219
+
220
+ if (typeof raw === "object") {
221
+ // The picker may have stored a snapshot rather than a bare reference.
222
+ // A row that already carries a name needs no lookup.
223
+ return { kind: kindOf(raw.kind ?? raw.type), id: raw.id, resolved: !!raw.name };
224
+ }
225
+ return { kind: "CATEGORY", id: raw, resolved: false };
226
+ })
227
+ .filter((pick) => pick && pick.id != null);
228
+ }
229
+
230
+ /**
231
+ * Categories come back as a tree; a merchant can pick a child.
232
+ * Flattens defensively — the key name for children differs between endpoints.
233
+ */
234
+ function flatten(rows) {
235
+ return flattenCategories(rows);
236
+ }
237
+
238
+ async function loadCatalog(kind) {
239
+ if (catalog.value[kind]) return catalog.value[kind];
240
+
241
+ try {
242
+ const response =
243
+ kind === "COLLECTION"
244
+ ? await HomeServices.getCollections()
245
+ : await HomeServices.getCategories();
246
+
247
+ const rows = flatten(response?.results ?? response ?? []).map((row) => ({
248
+ ...row,
249
+ // Stamp the kind: nothing downstream can tell a category row from a
250
+ // collection row by shape, and the URL prefix depends on it.
251
+ kind: row.kind ?? kind,
252
+ }));
253
+
254
+ catalog.value = { ...catalog.value, [kind]: rows };
255
+ return rows;
256
+ } catch (e) {
257
+ console.warn(`[useSectionData] loadCatalog(${kind}) failed (e.g. 404), using empty fallback:`, e);
258
+ catalog.value = { ...catalog.value, [kind]: [] };
259
+ return [];
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Fill in manual picks the backend didn't expand.
265
+ *
266
+ * One request per kind, cached for the page — a homepage with three manual
267
+ * category rows costs one call, not one per row and certainly not one per
268
+ * picked category.
269
+ */
270
+ async function resolveManualClassifications(current) {
271
+ const picks = manualPicks(current);
272
+ if (!picks.length) return [];
273
+
274
+ const kinds = [...new Set(picks.filter((p) => !p.resolved).map((p) => p.kind))];
275
+ if (!kinds.length) return [];
276
+
277
+ const loaded = await Promise.all(kinds.map((kind) => loadCatalog(kind)));
278
+ const index = new Map();
279
+ loaded.flat().forEach((row) => index.set(`${row.kind}:${row.id}`, row));
280
+
281
+ // Block order is the merchant's order. Keep it.
282
+ return picks.map((pick) => index.get(`${pick.kind}:${pick.id}`)).filter(Boolean);
283
+ }
284
+
285
+ /**
286
+ * True when a section that already has `data` still needs a client-side
287
+ * lookup — a MANUAL list whose blocks arrived as bare references.
288
+ */
289
+ function needsManualResolve(current) {
290
+ if (current?.type !== "CLASSIFICATION_LIST") return false;
291
+ if (classificationSource(current) !== "MANUAL") return false;
292
+
293
+ const picks = manualPicks(current);
294
+ if (!picks.length) return false;
295
+ if (picks.every((pick) => pick.resolved)) return false;
296
+
297
+ // Already matched by an earlier run.
298
+ const known = new Set(
299
+ (current.data?.classifications ?? []).map((row) => `${kindOf(row.kind ?? row.type)}:${row.id}`)
300
+ );
301
+ return picks.some((pick) => !known.has(`${pick.kind}:${pick.id}`));
302
+ }
303
+
304
+ // ---------------------------------------------------------------- resolve
305
+
306
+ /**
307
+ * Everything that changes what should be on screen, as one string.
308
+ *
309
+ * Watching the section object itself would refetch on every keystroke in the
310
+ * heading field; watching SOURCE alone would miss a category being added to a
311
+ * MANUAL list.
312
+ */
313
+ const signature = computed(() => {
314
+ const current = toValue(section);
315
+ if (!current) return "";
316
+
317
+ if (current.type === "PRODUCT_LIST") {
318
+ if (current.data && !needsManualProducts(current)) return "";
319
+ const s = current.settings ?? {};
320
+ return JSON.stringify([
321
+ "PRODUCT_LIST",
322
+ s.SOURCE ?? "LATEST",
323
+ s.LIMIT ?? 8,
324
+ manualProductIds(current),
325
+ ]);
326
+ }
327
+
328
+ if (current.type === "CLASSIFICATION_LIST") {
329
+ if (current.data && !needsManualResolve(current)) return "";
330
+ const s = current.settings ?? {};
331
+ return JSON.stringify([
332
+ "CLASSIFICATION_LIST",
333
+ classificationSource(current),
334
+ s.LIMIT ?? null,
335
+ manualPicks(current).map((pick) => `${pick.kind}:${pick.id}`),
336
+ ]);
337
+ }
338
+
339
+ return "";
340
+ });
341
+
342
+ // Guards against an out-of-order response overwriting a newer one: the
343
+ // merchant drags the LIMIT slider, six requests go out, and without this the
344
+ // slowest one wins.
345
+ let requestToken = 0;
346
+ let debounceTimer = null;
347
+
348
+ async function resolve() {
349
+ const current = toValue(section);
350
+ if (!current) return;
351
+ if (current.type !== "PRODUCT_LIST" && current.type !== "CLASSIFICATION_LIST") return;
352
+ if (current.data && !needsManualResolve(current) && !needsManualProducts(current)) return;
353
+
354
+ const settings = current.settings ?? {};
355
+ const token = ++requestToken;
356
+
357
+ loading.value = true;
358
+
359
+ try {
360
+ if (current.type === "PRODUCT_LIST") {
361
+ const limit = Number(settings.LIMIT) || 8;
362
+ let response;
363
+
364
+ if (settings.SOURCE === "MANUAL") {
365
+ const ids = manualProductIds(current);
366
+ // Not a no-op: clearing the last block has to empty the grid, not
367
+ // leave the previous fetch's products sitting there.
368
+ if (!ids.length) {
369
+ if (token === requestToken) fetched.value = { products: [] };
370
+ return;
371
+ }
372
+ // Cached per tab (utils/requestCache): coming back to a page reuses it.
373
+ response = await cachedRequest(`products:ids:${ids.join(",")}`, () => ProductServices.getProductsByIds(ids));
374
+ } else {
375
+ const params = { page_size: limit };
376
+ // Best-effort mapping of the other sources. An unknown value falls
377
+ // through to the default ordering rather than erroring — the preview's
378
+ // job is to show layout, and being approximately right beats blank.
379
+ if (settings.SOURCE === "ON_SALE") params.on_sale = true;
380
+ if (settings.SOURCE === "BEST_SELLERS") params.ordering = "-sales_count";
381
+ if (settings.SOURCE === "LATEST") params.ordering = "-created_at";
382
+ response = await cachedRequest(`products:list:${JSON.stringify(params)}`, () => ProductServices.listProducts(params));
383
+ }
384
+
385
+ if (token !== requestToken) return;
386
+
387
+ const rows = response?.results ?? response ?? [];
388
+ fetched.value = {
389
+ products: settings.SOURCE === "MANUAL" ? rows : rows.slice(0, limit),
390
+ };
391
+ return;
392
+ }
393
+
394
+ // ---- CLASSIFICATION_LIST ----
395
+ const source = classificationSource(current);
396
+
397
+ if (source === "MANUAL") {
398
+ const rows = await resolveManualClassifications(current);
399
+ if (token !== requestToken) return;
400
+
401
+ // Merge with whatever the backend did expand, so a half-resolved
402
+ // section doesn't lose the half that worked.
403
+ const existing = current.data?.classifications ?? [];
404
+ const seen = new Set();
405
+ fetched.value = {
406
+ classifications: [...existing, ...rows].filter((row) => {
407
+ const key = `${kindOf(row.kind ?? row.type)}:${row.id}`;
408
+ if (seen.has(key)) return false;
409
+ seen.add(key);
410
+ return true;
411
+ }),
412
+ };
413
+ return;
414
+ }
415
+
416
+ const kind = source === "ALL_COLLECTIONS" ? "COLLECTION" : "CATEGORY";
417
+ const rows = await loadCatalog(kind);
418
+ if (token !== requestToken) return;
419
+
420
+ // LIMIT and PARENTS_ONLY are applied in useClassificationItems, so the
421
+ // preview and the live page filter identically. Don't slice here.
422
+ fetched.value = { classifications: rows };
423
+ } catch (e) {
424
+ // A preview that can't reach the catalog should still show layout with dummy data.
425
+ if (token === requestToken) {
426
+ console.warn("[useSectionData] resolve failed:", e);
427
+ if (current.type === "PRODUCT_LIST") {
428
+ fetched.value = { products: [] };
429
+ } else if (current.type === "CLASSIFICATION_LIST") {
430
+ fetched.value = { classifications: [] };
431
+ }
432
+ }
433
+ } finally {
434
+ if (token === requestToken) loading.value = false;
435
+ }
436
+ }
437
+
438
+ if (import.meta.client) {
439
+ watch(
440
+ signature,
441
+ (value) => {
442
+ if (!value) return;
443
+ clearTimeout(debounceTimer);
444
+ // The editor pushes STATE on every slider frame; without this a drag
445
+ // across the LIMIT range fires a request per pixel.
446
+ debounceTimer = setTimeout(resolve, 200);
447
+ },
448
+ { immediate: true }
449
+ );
450
+
451
+ onScopeDispose(() => clearTimeout(debounceTimer));
452
+ }
453
+
454
+ return {
455
+ products,
456
+ classifications,
457
+ loading,
458
+ /** Kept for the existing onMounted calls in the section components. Now a
459
+ * no-op in practice — the watcher above has already run. */
460
+ resolveForPreview: resolve,
461
+ };
462
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * SectionWidth -> max-width.
3
+ *
4
+ * Exported because the footer takes the same enum (FOOTER_CONTAINER) but isn't
5
+ * a section, so it can't go through useSectionShell. It previously carried its
6
+ * own copy with different values, which made a NARROW footer a different width
7
+ * from a NARROW section directly above it.
8
+ */
9
+ export const SECTION_WIDTHS = {
10
+ NARROW: "720px",
11
+ NORMAL: "var(--osi-page-width)",
12
+ WIDE: "1600px",
13
+ FULL: "100%",
14
+ };
15
+
16
+ const WIDTHS = SECTION_WIDTHS;
17
+
18
+ /**
19
+ * The settings every section shares.
20
+ *
21
+ * COMMON_STYLE_SETTINGS is spliced into nearly every schema on the backend —
22
+ * COLOR_SCHEME, CONTAINER, HEADING_ALIGNMENT, PADDING_*, BORDER_RADIUS,
23
+ * CUSTOM_CLASS. Handling them once here means a new section type only has to
24
+ * implement what makes it different.
25
+ *
26
+ * @param {object|Ref|(() => object)} section the section passed to every
27
+ * section component. Pass a ref/computed/getter (e.g. `() => props.section`),
28
+ * not the raw object — a plain object is captured once at call time and
29
+ * never sees the new section pushed over postMessage on the next builder edit.
30
+ */
31
+ export function useSectionShell(section) {
32
+ const t = useT();
33
+ const s = computed(() => toValue(section)?.settings ?? {});
34
+ const resolved = useColorScheme(computed(() => s.value.COLOR_SCHEME));
35
+
36
+ /**
37
+ * Padding, one value per side, with optional phone values.
38
+ *
39
+ * PADDING_TOP / PADDING_BOTTOM desktop
40
+ * PADDING_LEFT / PADDING_RIGHT desktop; empty = PADDING_X
41
+ * PADDING_X both sides (older sections)
42
+ * PADDING_*_MOBILE phones; empty = the desktop value
43
+ *
44
+ * Written as variables plus `padding: var(--osi-sec-p*-now, <desktop>)`. The
45
+ * -now variables are switched by a media query in app.vue, which is the only
46
+ * way an inline style can have a phone value. A section root without the
47
+ * osi-section class still gets its desktop padding from the fallback.
48
+ *
49
+ * LEFT/RIGHT are physical on purpose: the merchant sees "left" and "right"
50
+ * in the panel and expects exactly that side, in RTL too.
51
+ */
52
+ const px = (value) => {
53
+ if (value === undefined || value === null || value === "") return undefined;
54
+ const n = Number(value);
55
+ return Number.isFinite(n) ? `${n}px` : undefined;
56
+ };
57
+
58
+ const padding = computed(() => {
59
+ const v = s.value;
60
+ const x = px(v.PADDING_X) ?? "16px";
61
+ const top = px(v.PADDING_TOP) ?? "48px";
62
+ const bottom = px(v.PADDING_BOTTOM) ?? "48px";
63
+ const left = px(v.PADDING_LEFT) ?? x;
64
+ const right = px(v.PADDING_RIGHT) ?? x;
65
+ return {
66
+ "--osi-sec-pt": top,
67
+ "--osi-sec-pb": bottom,
68
+ "--osi-sec-pl": left,
69
+ "--osi-sec-pr": right,
70
+ "--osi-sec-pt-m": px(v.PADDING_TOP_MOBILE),
71
+ "--osi-sec-pb-m": px(v.PADDING_BOTTOM_MOBILE),
72
+ "--osi-sec-pl-m": px(v.PADDING_LEFT_MOBILE),
73
+ "--osi-sec-pr-m": px(v.PADDING_RIGHT_MOBILE),
74
+ paddingTop: `var(--osi-sec-pt-now, ${top})`,
75
+ paddingBottom: `var(--osi-sec-pb-now, ${bottom})`,
76
+ paddingLeft: `var(--osi-sec-pl-now, ${left})`,
77
+ paddingRight: `var(--osi-sec-pr-now, ${right})`,
78
+ };
79
+ });
80
+
81
+ const shellStyle = computed(() => {
82
+ const style = {
83
+ ...resolved.value.style,
84
+ // BACKGROUND_COLOR overrides the scheme's background for this section
85
+ // only; text and button colours still come from the scheme.
86
+ ...(s.value.BACKGROUND_COLOR ? { "--osi-bg": s.value.BACKGROUND_COLOR } : {}),
87
+ background: "var(--osi-bg)",
88
+ color: "var(--osi-text)",
89
+ ...padding.value,
90
+ borderRadius: `${s.value.BORDER_RADIUS ?? 0}px`,
91
+ };
92
+ for (const key of Object.keys(style)) if (style[key] === undefined) delete style[key];
93
+ return style;
94
+ });
95
+
96
+ const innerStyle = computed(() => ({
97
+ maxWidth: WIDTHS[s.value.CONTAINER ?? "NORMAL"] ?? WIDTHS.NORMAL,
98
+ marginInline: "auto",
99
+ }));
100
+
101
+ // 'LEFT' | 'CENTER' | 'RIGHT' -> a CSS text-align value. Uses start/end so it
102
+ // flips correctly in Arabic instead of pinning to the physical left.
103
+ const align = computed(() => {
104
+ const value = s.value.HEADING_ALIGNMENT ?? "LEFT";
105
+ if (value === "CENTER") return "center";
106
+ return value === "RIGHT" ? "end" : "start";
107
+ });
108
+
109
+ const customClass = computed(() => s.value.CUSTOM_CLASS ?? "");
110
+ const heading = computed(() => t(s.value.HEADING));
111
+
112
+ return { s, t, shellStyle, innerStyle, align, customClass, heading, scheme: resolved };
113
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Cart drawer and mobile menu visibility.
3
+ *
4
+ * Two changes from the version this replaces:
5
+ *
6
+ * - `process.client` -> `import.meta.client`. `process` isn't defined in the
7
+ * browser bundle under Nuxt 3's default, so the old guard threw on the
8
+ * client and the body was never locked at all.
9
+ * - Scroll locking goes through useBodyScrollLock() instead of writing
10
+ * `document.body.style.overflow` directly. Opening the cart over an open
11
+ * mobile menu and closing one used to unlock the page under the other.
12
+ *
13
+ * State is useState, so the drawer survives a page component swapping out
14
+ * during navigation — closing it is a deliberate act (see the route watcher in
15
+ * the layout), not a side effect of the page unmounting.
16
+ */
17
+ export function useSidebar() {
18
+ const isCartSidebarVisible = useState("cart-sidebar-visible", () => false);
19
+ const isMobileSidebarVisible = useState("mobile-sidebar-visible", () => false);
20
+
21
+ const anyOpen = computed(
22
+ () => isCartSidebarVisible.value || isMobileSidebarVisible.value
23
+ );
24
+
25
+ // SIDEBAR_LOCK_SCROLL is honoured for the mobile menu only. The cart drawer
26
+ // always locks: it has its own scrolling line-item list, and a page moving
27
+ // underneath it makes that list impossible to use on touch. Reading the
28
+ // setting through useThemeGroup means an older theme without the key falls
29
+ // back to true, which is the behaviour this replaces.
30
+ const sidebarSettings = useThemeGroup("SIDEBAR");
31
+ const shouldLock = computed(
32
+ () =>
33
+ isCartSidebarVisible.value ||
34
+ (isMobileSidebarVisible.value && sidebarSettings.value.SIDEBAR_LOCK_SCROLL !== false)
35
+ );
36
+
37
+ // One lock for both drawers: the page needs to stay locked while either is
38
+ // open, and unlock exactly once when the last one closes.
39
+ useBodyScrollLock(shouldLock);
40
+
41
+ function toggleCartSidebar(visible) {
42
+ isCartSidebarVisible.value =
43
+ typeof visible === "boolean" ? visible : !isCartSidebarVisible.value;
44
+
45
+ // Never both at once — they'd stack and the backdrop would trap focus.
46
+ if (isCartSidebarVisible.value) isMobileSidebarVisible.value = false;
47
+ }
48
+
49
+ function toggleMobileSidebar(visible) {
50
+ isMobileSidebarVisible.value =
51
+ typeof visible === "boolean" ? visible : !isMobileSidebarVisible.value;
52
+
53
+ if (isMobileSidebarVisible.value) isCartSidebarVisible.value = false;
54
+ }
55
+
56
+ function closeAll() {
57
+ isCartSidebarVisible.value = false;
58
+ isMobileSidebarVisible.value = false;
59
+ }
60
+
61
+ return {
62
+ isCartSidebarVisible,
63
+ isMobileSidebarVisible,
64
+ anyOpen,
65
+ toggleCartSidebar,
66
+ toggleMobileSidebar,
67
+ closeAll,
68
+ };
69
+ }