@osimart/create-storefront 0.0.0-stage → 1.0.1

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.js +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.js +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 +282 -0
  200. package/nuxt/osi-sections.ts +56 -0
  201. package/nuxt.config.ts +60 -0
  202. package/package.json +44 -4
  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,545 @@
1
+ import HomeServices from "#osi/services/HomeServices";
2
+ import CategoryServices from "#osi/services/CategoryServices";
3
+ import { apiService } from "#osi/services/ApiServices";
4
+ import { childrenOf as treeChildren, isOn, parentIdOf } from "#osi/theme/categoryTree";
5
+
6
+ /**
7
+ * Everything the category / collection page needs, for both kinds.
8
+ *
9
+ * Categories and collections render the same page — a heading, a way to move
10
+ * between siblings, a filterable product grid — and differ only in which query
11
+ * parameter narrows the products (`categories` vs `collections`) and where the
12
+ * navigation comes from (a tree vs a flat list). So there is one page
13
+ * (pages/categories/[slug].vue, aliased to /collections/:slug) and one section
14
+ * (MAIN_CLASSIFICATION), and this is the only place that knows the difference.
15
+ *
16
+ * `/collections/all` (or `/categories/all`) is the "All products" page: kind ALL,
17
+ * no narrowing parameter.
18
+ *
19
+ * The search page (MAIN_SEARCH, /search?q=…) is the same listing: kind SEARCH,
20
+ * narrowed by `search`. No item, no category navigation; nothing is fetched
21
+ * until something is searched.
22
+ */
23
+
24
+ export const ALL_SLUG = "all";
25
+
26
+ /* ------------------------------------------------------------------ catalog */
27
+
28
+ function rowsOf(response) {
29
+ if (Array.isArray(response)) return response;
30
+ return response?.results ?? [];
31
+ }
32
+
33
+ function slugOf(row) {
34
+ return row?.slugified_name || row?.slug || row?.handle || null;
35
+ }
36
+
37
+ /**
38
+ * Walks the category tree once into lookups. `children` is what the categories
39
+ * endpoint nests; `parent` / `parent_category` are read too for flat payloads.
40
+ */
41
+ function indexTree(roots) {
42
+ const bySlug = new Map();
43
+ const byId = new Map();
44
+ const parentOf = new Map();
45
+
46
+ const walk = (list, parent) => {
47
+ for (const row of list ?? []) {
48
+ if (!row) continue;
49
+ const slug = slugOf(row);
50
+ if (slug) bySlug.set(slug, row);
51
+ if (row.id != null) byId.set(row.id, row);
52
+ if (parent) parentOf.set(row.id, parent);
53
+ walk(treeChildren(row), row);
54
+ }
55
+ };
56
+ walk(roots, null);
57
+
58
+ // Flat payloads: resolve parent ids after the fact.
59
+ for (const row of byId.values()) {
60
+ if (parentOf.has(row.id)) continue;
61
+ const pid = parentIdOf(row);
62
+ if (pid != null && byId.has(pid)) parentOf.set(row.id, byId.get(pid));
63
+ }
64
+
65
+ const childrenOf = (row) => {
66
+ if (!row) return [];
67
+ const nested = treeChildren(row);
68
+ if (nested.length) return nested;
69
+ return [...byId.values()].filter((r) => parentOf.get(r.id)?.id === row.id);
70
+ };
71
+
72
+ const roots_ = (roots ?? []).filter((r) => !parentOf.has(r.id));
73
+
74
+ return { bySlug, byId, parentOf, childrenOf, roots: roots_ };
75
+ }
76
+
77
+ /**
78
+ * Categories + collections, fetched once per page load and shared (same
79
+ * useAsyncData keys everywhere, so the page and the section don't double up).
80
+ */
81
+ export function useClassificationCatalog() {
82
+ const baseUrl = useState("base-url", () => "");
83
+
84
+ const categories = useAsyncData(
85
+ "osi-catalog:categories",
86
+ () => HomeServices.getCategories(baseUrl.value).then(rowsOf).catch(() => []),
87
+ { default: () => [] }
88
+ );
89
+ const collections = useAsyncData(
90
+ "osi-catalog:collections",
91
+ () => HomeServices.getCollections(baseUrl.value).then(rowsOf).catch(() => []),
92
+ { default: () => [] }
93
+ );
94
+
95
+ const tree = computed(() => indexTree(categories.data.value ?? []));
96
+ const collectionBySlug = computed(() => {
97
+ const map = new Map();
98
+ for (const row of collections.data.value ?? []) {
99
+ const slug = slugOf(row);
100
+ if (slug) map.set(slug, row);
101
+ }
102
+ return map;
103
+ });
104
+
105
+ function find(kind, slug) {
106
+ if (!slug) return null;
107
+ return kind === "COLLECTION"
108
+ ? collectionBySlug.value.get(slug) ?? null
109
+ : tree.value.bySlug.get(slug) ?? null;
110
+ }
111
+
112
+ const ready = Promise.all([categories, collections]);
113
+
114
+ return { categories, collections, tree, collectionBySlug, find, ready };
115
+ }
116
+
117
+ /** Depth-first: each parent followed by its children. */
118
+ function flattenTree(roots, childrenOf) {
119
+ const out = [];
120
+ const walk = (list) => {
121
+ for (const row of list ?? []) {
122
+ out.push(row);
123
+ walk(childrenOf(row));
124
+ }
125
+ };
126
+ walk(roots);
127
+ return out;
128
+ }
129
+
130
+ /* -------------------------------------------------------------- the page */
131
+
132
+ const SORTS = {
133
+ FEATURED: { label: "Featured", value: "" },
134
+ PRICE_ASC: { label: "Price, low to high", value: "min_price" },
135
+ PRICE_DESC: { label: "Price, high to low", value: "-max_price" },
136
+ NEWEST: { label: "Newest", value: "-created_at" },
137
+ NAME_ASC: { label: "Alphabetically, A-Z", value: "name" },
138
+ NAME_DESC: { label: "Alphabetically, Z-A", value: "-name" },
139
+ };
140
+
141
+ const asArray = (v) => (Array.isArray(v) ? v : v == null || v === "" ? [] : [v]);
142
+
143
+ /**
144
+ * @param {() => object} section the MAIN_CLASSIFICATION section (getter)
145
+ */
146
+ export function useClassificationPage(section) {
147
+ const route = useRoute();
148
+ const router = useRouter();
149
+ const t = useT();
150
+ const { mediaUrl } = useMediaUrl();
151
+ const { classificationUrl } = useClassificationUrl();
152
+ const { resource } = useRenderContext();
153
+ const placeholders = usePlaceholders();
154
+ const baseUrl = useState("base-url", () => "");
155
+ const catalog = useClassificationCatalog();
156
+ const { isPreview } = useBuilderPreview();
157
+
158
+ const s = computed(() => toValue(section)?.settings ?? {});
159
+ const sectionKey = computed(() => toValue(section)?.key ?? "main");
160
+ const isSearch = computed(() => toValue(section)?.type === "MAIN_SEARCH");
161
+ /** What was searched (/search?q=…). */
162
+ const terms = computed(() => (isSearch.value ? String(route.query.q ?? "").trim() : ""));
163
+
164
+ /* ---------------------------------------------------------- current item */
165
+
166
+ const kind = computed(() => {
167
+ if (isSearch.value) return "SEARCH";
168
+ const slug = route.params.slug ?? resource.value?.slug;
169
+ if (slug === ALL_SLUG || resource.value?.type === "ALL") return "ALL";
170
+ // Dashboard preview: no route, no resource. The CLASSIFICATION template is
171
+ // previewed as /categories/all — every product, the real catalog.
172
+ if (!slug && !resource.value?.id) return "ALL";
173
+ const rt = String(resource.value?.type ?? "").toUpperCase();
174
+ if (rt === "COLLECTION") return "COLLECTION";
175
+ if (rt === "CATEGORY") return "CATEGORY";
176
+ return route.path.startsWith("/collections") ? "COLLECTION" : "CATEGORY";
177
+ });
178
+
179
+ const slug = computed(
180
+ () =>
181
+ route.params.slug ||
182
+ resource.value?.slug ||
183
+ resource.value?.slugified_name ||
184
+ null
185
+ );
186
+
187
+ /**
188
+ * The full row (image, description, children) for the current page. The
189
+ * render payload's `resource` is used when the catalog doesn't have the row,
190
+ * and in the builder — which has no route — the first category stands in so
191
+ * the merchant sees a real page while designing.
192
+ */
193
+ const current = computed(() => {
194
+ if (kind.value === "SEARCH") return { id: null, name: terms.value, slugified_name: null };
195
+ if (kind.value === "ALL") {
196
+ return { id: null, name: t(s.value.ALL_TITLE) || "All products", slugified_name: ALL_SLUG };
197
+ }
198
+ const row = catalog.find(kind.value, slug.value);
199
+ if (row) return row;
200
+ return resource.value ?? null;
201
+ });
202
+
203
+ const title = computed(() => current.value?.name || current.value?.title || "");
204
+ const description = computed(() => current.value?.description || "");
205
+ const image = computed(() =>
206
+ mediaUrl(
207
+ s.value.BANNER_IMAGE ??
208
+ current.value?.banner ??
209
+ current.value?.main_image ??
210
+ current.value?.image ??
211
+ null
212
+ )
213
+ );
214
+
215
+ const parent = computed(() =>
216
+ kind.value === "CATEGORY" && current.value?.id != null
217
+ ? catalog.tree.value.parentOf.get(current.value.id) ?? null
218
+ : null
219
+ );
220
+
221
+ const breadcrumbs = computed(() => {
222
+ const trail = [];
223
+ let node = parent.value;
224
+ while (node) {
225
+ trail.unshift({ title: node.name, url: classificationUrl({ ...node, kind: "CATEGORY" }) });
226
+ node = catalog.tree.value.parentOf.get(node.id) ?? null;
227
+ }
228
+ return [{ title: t(s.value.BREADCRUMB_HOME_LABEL) || "Home", url: "/" }, ...trail, { title: title.value, url: null }];
229
+ });
230
+
231
+ /* -------------------------------------------------------------- nav items */
232
+
233
+ /**
234
+ * Which prefix the current URL uses. "All products" stays under it, so a
235
+ * shopper browsing /categories/... gets /categories/all, not /collections/all.
236
+ */
237
+ const onCollectionsPath = computed(() => route.path.startsWith("/collections"));
238
+
239
+ /** The "All products" link for a nav listing rows of `rowKind`. */
240
+ function allUrlFor(rowKind) {
241
+ if (rowKind === "COLLECTION") return `/collections/${ALL_SLUG}`;
242
+ if (rowKind === "CATEGORY") return `/categories/${ALL_SLUG}`;
243
+ return `/${onCollectionsPath.value ? "collections" : "categories"}/${ALL_SLUG}`;
244
+ }
245
+
246
+ function toItem(row, rowKind) {
247
+ const rowSlug = slugOf(row);
248
+ return {
249
+ key: `${rowKind}-${row.id ?? rowSlug}`,
250
+ id: row.id,
251
+ title: row.name || row.title || "",
252
+ image: mediaUrl(row.main_image ?? row.image ?? row.media ?? null),
253
+ url: classificationUrl({ ...row, kind: rowKind }, rowKind),
254
+ count: row.products_count ?? row.product_count ?? null,
255
+ active: rowKind === kind.value && rowSlug != null && rowSlug === slug.value,
256
+ isAll: false,
257
+ };
258
+ }
259
+
260
+ /** Which rows the navigation lists, per NAV_SOURCE. */
261
+ const navSource = computed(() => {
262
+ const mode = String(s.value.NAV_SOURCE || "AUTO").toUpperCase();
263
+ const tree = catalog.tree.value;
264
+ const cur = current.value;
265
+ const children = kind.value === "CATEGORY" ? tree.childrenOf(cur) : [];
266
+ const siblings = parent.value ? tree.childrenOf(parent.value) : tree.roots;
267
+ const collections = catalog.collections.data.value ?? [];
268
+
269
+ switch (mode) {
270
+ case "NONE":
271
+ return { rows: [], rowKind: "CATEGORY", allTarget: null };
272
+ case "CHILDREN":
273
+ return { rows: children, rowKind: "CATEGORY", allTarget: "CURRENT" };
274
+ case "SIBLINGS":
275
+ return { rows: siblings, rowKind: "CATEGORY", allTarget: parent.value ? "PARENT" : "ALL" };
276
+ case "TOP_LEVEL":
277
+ return { rows: tree.roots, rowKind: "CATEGORY", allTarget: "ALL" };
278
+ case "ALL_CATEGORIES":
279
+ // Same switch as the home page's category list: top level only, or
280
+ // every category at every level (parents followed by their children).
281
+ return {
282
+ rows: isOn(s.value.NAV_PARENTS_ONLY, true) ? tree.roots : flattenTree(tree.roots, tree.childrenOf),
283
+ rowKind: "CATEGORY",
284
+ allTarget: "ALL",
285
+ };
286
+ case "COLLECTIONS":
287
+ return { rows: collections, rowKind: "COLLECTION", allTarget: "ALL" };
288
+ default: {
289
+ // AUTO: a collection page lists collections; a category page lists
290
+ // its children, or its siblings when it has none — so a leaf category
291
+ // still lets the shopper step sideways.
292
+ // /collections/all lists collections; /categories/all lists categories.
293
+ if (kind.value === "COLLECTION" || (kind.value === "ALL" && onCollectionsPath.value))
294
+ return { rows: collections, rowKind: "COLLECTION", allTarget: "ALL" };
295
+ if (kind.value === "CATEGORY" && children.length)
296
+ return { rows: children, rowKind: "CATEGORY", allTarget: "CURRENT" };
297
+ return { rows: siblings, rowKind: "CATEGORY", allTarget: parent.value ? "PARENT" : "ALL" };
298
+ }
299
+ }
300
+ });
301
+
302
+ const navItems = computed(() => {
303
+ if (kind.value === "SEARCH") return [];
304
+ const { rows, rowKind, allTarget } = navSource.value;
305
+ const mode = String(s.value.NAV_SOURCE || "AUTO").toUpperCase();
306
+ // Editor with an empty catalog: sample rows so the nav is designable.
307
+ const source = rows?.length || mode === "NONE" ? rows : placeholders.classifications([], 6);
308
+ let items = (source ?? []).filter(Boolean).map((row) => toItem(row, rowKind));
309
+
310
+ if (s.value.NAV_HIDE_EMPTY) items = items.filter((i) => i.count == null || i.count > 0);
311
+ const limit = Number(s.value.NAV_LIMIT);
312
+ if (limit > 0) items = items.slice(0, limit);
313
+
314
+ if (items.length && s.value.NAV_SHOW_ALL !== false && allTarget) {
315
+ let url = allUrlFor(rowKind);
316
+ let active = kind.value === "ALL";
317
+ let image = mediaUrl(s.value.NAV_ALL_IMAGE ?? null);
318
+ if (allTarget === "CURRENT") {
319
+ url = classificationUrl({ ...current.value, kind: kind.value }, kind.value);
320
+ active = true;
321
+ image = image || mediaUrl(current.value?.main_image ?? current.value?.image ?? null);
322
+ } else if (allTarget === "PARENT") {
323
+ url = classificationUrl({ ...parent.value, kind: "CATEGORY" }, "CATEGORY");
324
+ active = false;
325
+ image = image || mediaUrl(parent.value?.main_image ?? parent.value?.image ?? null);
326
+ }
327
+ // "All" is only highlighted when nothing more specific is.
328
+ if (items.some((i) => i.active)) active = false;
329
+ items = [
330
+ { key: "nav-all", id: null, title: t(s.value.NAV_ALL_LABEL) || "All", image, url, count: null, active, isAll: true },
331
+ ...items,
332
+ ];
333
+ }
334
+ return items;
335
+ });
336
+
337
+ /* ------------------------------------------------------------ sort/filter */
338
+
339
+ const sortOptions = computed(() => {
340
+ const keys = ["FEATURED", "PRICE_ASC", "PRICE_DESC"];
341
+ if (s.value.SORT_SHOW_NEWEST) keys.push("NEWEST");
342
+ if (s.value.SORT_SHOW_NAME) keys.push("NAME_ASC", "NAME_DESC");
343
+ return keys.map((k) => ({
344
+ key: k,
345
+ ...SORTS[k],
346
+ // Unsorted search results come back by relevance.
347
+ ...(k === "FEATURED" && kind.value === "SEARCH" ? { label: "Relevance" } : {}),
348
+ }));
349
+ });
350
+
351
+ const defaultOrdering = computed(() => {
352
+ const configured = SORTS[s.value.DEFAULT_SORT]?.value;
353
+ if (configured) return configured;
354
+ // "Featured" = the merchant's manual order inside the category.
355
+ return kind.value === "CATEGORY" ? "product_category_order" : "";
356
+ });
357
+
358
+ const ordering = computed(() => String(route.query.ordering ?? ""));
359
+ const selectedValues = computed(() => asArray(route.query.value).map(String));
360
+ const perPage = computed(() => Math.max(1, Number(s.value.PRODUCTS_PER_PAGE) || 12));
361
+ const pagination = computed(() => String(s.value.PAGINATION || "INFINITE").toUpperCase());
362
+ const page = computed(() =>
363
+ pagination.value === "PAGES" ? Math.max(1, Number(route.query.page) || 1) : 1
364
+ );
365
+
366
+ const filtersData = useAsyncData(
367
+ "osi-catalog:variant-types",
368
+ () => CategoryServices.getFilters(baseUrl.value).then(rowsOf).catch(() => []),
369
+ { default: () => [], server: false, lazy: true }
370
+ );
371
+
372
+ const filters = computed(() => {
373
+ const hidden = String(s.value.FILTER_HIDE ?? "")
374
+ .split(",")
375
+ .map((x) => x.trim().toLowerCase())
376
+ .filter(Boolean);
377
+ return (filtersData.data.value ?? [])
378
+ .filter((f) => f?.values?.length)
379
+ .filter((f) => !hidden.includes(String(f.name ?? "").toLowerCase()))
380
+ .map((f) => ({
381
+ id: f.id,
382
+ name: f.name,
383
+ isColor: String(f.display_type ?? "").toLowerCase() === "color",
384
+ values: f.values.map((v) => ({
385
+ id: String(v.id),
386
+ name: v.name,
387
+ color: v.hex_code || null,
388
+ image: v.image?.path ? mediaUrl(v.image) : null,
389
+ })),
390
+ }));
391
+ });
392
+
393
+ const activeFilters = computed(() => {
394
+ const byId = new Map();
395
+ for (const f of filters.value) for (const v of f.values) byId.set(v.id, { ...v, group: f.name });
396
+ return selectedValues.value.map((id) => byId.get(id) ?? { id, name: id, group: "" });
397
+ });
398
+
399
+ function pushQuery(patch) {
400
+ const next = { ...route.query, ...patch };
401
+ for (const k of Object.keys(next)) {
402
+ const v = next[k];
403
+ if (v == null || v === "" || (Array.isArray(v) && !v.length)) delete next[k];
404
+ }
405
+ return router.push({ path: route.path, query: next });
406
+ }
407
+
408
+ function setOrdering(value) {
409
+ return pushQuery({ ordering: value || undefined, page: undefined });
410
+ }
411
+ function setValues(ids) {
412
+ return pushQuery({ value: [...new Set(ids.map(String))], page: undefined });
413
+ }
414
+ function toggleValue(id) {
415
+ const set = new Set(selectedValues.value);
416
+ const key = String(id);
417
+ set.has(key) ? set.delete(key) : set.add(key);
418
+ return setValues([...set]);
419
+ }
420
+ function clearFilters() {
421
+ return pushQuery({ value: undefined, ordering: undefined, page: undefined });
422
+ }
423
+ function goToPage(n) {
424
+ return pushQuery({ page: n > 1 ? n : undefined });
425
+ }
426
+
427
+ /* --------------------------------------------------------------- products */
428
+
429
+ function fetchPage(n) {
430
+ const sp = new URLSearchParams();
431
+ const id = current.value?.id;
432
+ if (kind.value === "CATEGORY" && id != null) sp.append("categories", id);
433
+ if (kind.value === "COLLECTION" && id != null) sp.append("collections", id);
434
+ if (kind.value === "SEARCH" && terms.value) sp.set("search", terms.value);
435
+ sp.set("page", n);
436
+ sp.set("page_size", perPage.value);
437
+ const order = ordering.value || defaultOrdering.value;
438
+ if (order) sp.set("ordering", order);
439
+ for (const v of selectedValues.value) sp.append("variant_values", v);
440
+ return apiService(`${baseUrl.value}store/apis/products/?${sp.toString()}`, { method: "GET" });
441
+ }
442
+
443
+ const queryKey = computed(() =>
444
+ [kind.value, current.value?.id, terms.value, ordering.value, selectedValues.value.join("."), page.value, perPage.value].join("|")
445
+ );
446
+
447
+ const canFetch = computed(() => {
448
+ // The editor (no ?q=) shows the catalog, so the grid and card can be
449
+ // designed with real products.
450
+ if (kind.value === "SEARCH") return !!terms.value || isPreview.value;
451
+ return kind.value === "ALL" || (current.value?.id != null && !current.value?.is_placeholder);
452
+ });
453
+
454
+ const first = useAsyncData(
455
+ `plp:${route.path}:${sectionKey.value}`,
456
+ () =>
457
+ canFetch.value
458
+ ? fetchPage(page.value).catch(() => ({ results: [], count: 0, next: null }))
459
+ : Promise.resolve({ results: [], count: 0, next: null }),
460
+ { watch: [queryKey], default: () => ({ results: [], count: 0, next: null }) }
461
+ );
462
+
463
+ const extra = ref([]);
464
+ const nextPage = ref(2);
465
+ const loadingMore = ref(false);
466
+ const lastHadNext = ref(null);
467
+
468
+ watch(queryKey, () => {
469
+ extra.value = [];
470
+ nextPage.value = 2;
471
+ lastHadNext.value = null;
472
+ });
473
+
474
+ const total = computed(() => Number(first.data.value?.count) || 0);
475
+
476
+ const products = computed(() => {
477
+ const rows = [...(first.data.value?.results ?? []), ...extra.value];
478
+ if (rows.length || !canFetch.value) {
479
+ // Builder / placeholder category: show dummy cards so the grid is designable.
480
+ return rows.length ? rows : placeholders.products([], perPage.value);
481
+ }
482
+ return rows;
483
+ });
484
+
485
+ const hasMore = computed(() => {
486
+ if (pagination.value === "PAGES") return false;
487
+ if (lastHadNext.value !== null) return lastHadNext.value;
488
+ if (first.data.value?.next != null) return !!first.data.value.next;
489
+ return products.value.length < total.value;
490
+ });
491
+
492
+ const pageCount = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)));
493
+
494
+ async function loadMore() {
495
+ if (loadingMore.value || !hasMore.value || !canFetch.value) return;
496
+ loadingMore.value = true;
497
+ try {
498
+ const res = await fetchPage(nextPage.value);
499
+ extra.value = [...extra.value, ...(res?.results ?? [])];
500
+ nextPage.value += 1;
501
+ lastHadNext.value = res?.next != null ? !!res.next : extra.value.length + (first.data.value?.results?.length ?? 0) < total.value;
502
+ } catch {
503
+ lastHadNext.value = false;
504
+ } finally {
505
+ loadingMore.value = false;
506
+ }
507
+ }
508
+
509
+ return {
510
+ s,
511
+ kind,
512
+ terms,
513
+ slug,
514
+ current,
515
+ title,
516
+ description,
517
+ image,
518
+ parent,
519
+ breadcrumbs,
520
+ navItems,
521
+ sortOptions,
522
+ ordering,
523
+ defaultOrdering,
524
+ selectedValues,
525
+ filters,
526
+ filtersPending: filtersData.pending,
527
+ activeFilters,
528
+ setOrdering,
529
+ setValues,
530
+ toggleValue,
531
+ clearFilters,
532
+ goToPage,
533
+ products,
534
+ total,
535
+ page,
536
+ pageCount,
537
+ perPage,
538
+ pagination,
539
+ pending: first.pending,
540
+ hasMore,
541
+ loadingMore,
542
+ loadMore,
543
+ catalog,
544
+ };
545
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The href for a category or a collection.
3
+ *
4
+ * There were three copies of this before: useMenu().itemUrl(), a private
5
+ * classificationUrl() inside ClassificationListSection, and viewAllUrl() inside
6
+ * ProductListSection. Three copies of a route table is three chances to disagree
7
+ * with the backend's resolve_path(), and the failure is silent — a path the
8
+ * backend doesn't recognise falls through to NOT_FOUND, which renders a real
9
+ * page with the wrong content.
10
+ *
11
+ * The table, unchanged, from builder's resolve_path():
12
+ *
13
+ * /categories/<slug> CLASSIFICATION (resource.type = CATEGORY)
14
+ * /collections/<slug> CLASSIFICATION (resource.type = COLLECTION)
15
+ *
16
+ * Deciding which prefix an item takes is the whole job, and it can't be guessed
17
+ * from the shape of the object — categories and collections serialize almost
18
+ * identically. So `kind` is carried explicitly on every normalized item (see
19
+ * useClassificationItems), and this only falls back to sniffing when it's
20
+ * missing on a raw API row.
21
+ */
22
+ export function useClassificationUrl() {
23
+ /**
24
+ * 'CATEGORY' | 'COLLECTION' for anything the API or the editor hands over.
25
+ *
26
+ * The editor stores a pick as { kind, id }; the storefront API returns rows
27
+ * that may carry `type`; a row read out of data.classifications may carry
28
+ * neither, in which case the caller already knew which endpoint it came from
29
+ * and should have stamped it. Defaults to CATEGORY because that's the older
30
+ * behaviour, and a wrong guess is at least a consistent wrong guess.
31
+ */
32
+ function classificationKind(item, fallback = "CATEGORY") {
33
+ const raw = String(
34
+ item?.kind ?? item?.type ?? item?.resource_type ?? fallback
35
+ ).toUpperCase();
36
+ return raw.includes("COLLECTION") ? "COLLECTION" : "CATEGORY";
37
+ }
38
+
39
+ function classificationSlug(item) {
40
+ return item?.slugified_name || item?.slug || item?.handle || null;
41
+ }
42
+
43
+ /**
44
+ * @param {object} item a normalized item, an API row, or an editor pick
45
+ * @param {string} [fallbackKind] used when the item carries no kind at all
46
+ */
47
+ function classificationUrl(item, fallbackKind = "CATEGORY") {
48
+ if (!item) return "/";
49
+
50
+ // A backend that pre-resolved the path wins — it knows about routing
51
+ // changes this file may not.
52
+ if (item.url) return item.url;
53
+
54
+ const slug = classificationSlug(item);
55
+ if (!slug) return "/";
56
+
57
+ return classificationKind(item, fallbackKind) === "COLLECTION"
58
+ ? `/collections/${slug}`
59
+ : `/categories/${slug}`;
60
+ }
61
+
62
+ return { classificationUrl, classificationKind, classificationSlug };
63
+ }
@@ -0,0 +1,44 @@
1
+ const FALLBACK = {
2
+ id: "SCHEME_1",
3
+ background: "#ffffff",
4
+ text: "#121212",
5
+ button: "#121212",
6
+ button_label: "#ffffff",
7
+ border: "#e5e5e5",
8
+ };
9
+
10
+ /**
11
+ * Resolve a section's COLOR_SCHEME id against the theme's palette list.
12
+ *
13
+ * A section stores "SCHEME_1", never a colour. That indirection is the whole
14
+ * reason a merchant can restyle every section at once by editing one palette.
15
+ *
16
+ * Falls back rather than throwing: a merchant can delete a scheme that sections
17
+ * still reference, and a missing palette must not blank the page.
18
+ *
19
+ * No preview handling here — useStorefront().theme already carries the
20
+ * builder's unsaved edits, so editing a palette repaints every section live.
21
+ *
22
+ * @param {string|Ref<string>} schemeId
23
+ */
24
+ export function useColorScheme(schemeId) {
25
+ const { theme } = useStorefront();
26
+
27
+ return computed(() => {
28
+ const active = theme.value ?? {};
29
+ const id = unref(schemeId) || active.DEFAULT_SCHEME || "SCHEME_1";
30
+ const list = active.COLOR_SCHEMES ?? [];
31
+ const scheme = list.find((s) => s.id === id) ?? list[0] ?? FALLBACK;
32
+
33
+ return {
34
+ scheme,
35
+ style: {
36
+ "--osi-bg": scheme.background,
37
+ "--osi-text": scheme.text,
38
+ "--osi-btn-bg": scheme.button,
39
+ "--osi-btn-text": scheme.button_label,
40
+ "--osi-border": scheme.border,
41
+ },
42
+ };
43
+ });
44
+ }