@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,473 @@
1
+ import { useClassificationPage } from "#osi/composables/useClassificationPage";
2
+ import { ClassificationPart } from "#components";
3
+
4
+ export default function useLogic({ props }) {
5
+ /**
6
+ * The settings the listing runs on. With blocks, the Toolbar, Categories and
7
+ * Product grid blocks hold their parts' settings (same IDs as the section
8
+ * had) — laid over the section's, so everything below reads them as before.
9
+ * Without blocks (older sections) the section's own.
10
+ */
11
+ const LISTING_BLOCKS = ["CLASSIFICATION_TOOLBAR", "CLASSIFICATION_NAV", "PRODUCT_GRID"];
12
+ const effectiveSection = computed(() => {
13
+ const blocks = (props.section.blocks ?? []).filter((b) => !b.disabled && LISTING_BLOCKS.includes(b.type));
14
+ if (!blocks.length) return props.section;
15
+ const settings = { ...(props.section.settings ?? {}) };
16
+ for (const b of blocks) Object.assign(settings, b.settings ?? {});
17
+ return { ...props.section, settings };
18
+ });
19
+
20
+ const { s, t, shellStyle, innerStyle, align, customClass, scheme } = useSectionShell(() => effectiveSection.value);
21
+ const page = useClassificationPage(() => effectiveSection.value);
22
+ const {
23
+ kind, terms, title, description, image, breadcrumbs, navItems, sortOptions, ordering,
24
+ selectedValues, filters, filtersPending, activeFilters, products, total,
25
+ pageCount, pagination, pending, hasMore, loadingMore,
26
+ } = page;
27
+
28
+ /* --------------------------------------------------------------- blocks
29
+ * Like Shopify's main collection section: with blocks, they decide what's on
30
+ * the page and where — Breadcrumbs, Title, Description, Image, Categories
31
+ * (anywhere, in Groups), the Toolbar and the Product grid (holding the Card
32
+ * template). The section's settings still run the listing (filters, sort,
33
+ * toolbar look, columns, pagination). No blocks — a store that hasn't updated
34
+ * — the old fixed layout below. */
35
+ const blockMode = computed(() => (props.section.blocks ?? []).length > 0);
36
+ /** The search page's results (toolbar defaults differ). */
37
+ const isResults = computed(() => props.section.type === "MAIN_SEARCH");
38
+ const flowMode = blockMode;
39
+ const PART = ClassificationPart;
40
+ const { tree } = useContentTree(() => props.section, {
41
+ custom: {
42
+ BREADCRUMBS: PART, CLASSIFICATION_TITLE: PART, CLASSIFICATION_DESCRIPTION: PART,
43
+ CLASSIFICATION_IMAGE: PART, CLASSIFICATION_NAV: PART,
44
+ },
45
+ customText: {
46
+ BREADCRUMBS: "CUSTOM", CLASSIFICATION_TITLE: "H2", CLASSIFICATION_DESCRIPTION: "PARAGRAPH",
47
+ },
48
+ raw: ["CLASSIFICATION_TOOLBAR", "PRODUCT_GRID"],
49
+ index: () => props.index,
50
+ });
51
+
52
+ /**
53
+ * The page top to bottom: runs of header blocks (in the page container), the
54
+ * toolbar band (full width) and the grid — each with its place (CSS order).
55
+ */
56
+ const flow = computed(() => {
57
+ const out = [];
58
+ for (const node of tree.value.children) {
59
+ if (node.type === "CLASSIFICATION_TOOLBAR" || node.type === "PRODUCT_GRID") {
60
+ out.push({ kind: node.type, key: node.key, node });
61
+ continue;
62
+ }
63
+ const last = out[out.length - 1];
64
+ if (last?.kind === "BLOCKS") last.nodes.push(node);
65
+ else out.push({ kind: "BLOCKS", key: node.key, nodes: [node] });
66
+ }
67
+ return out.map((part, order) => ({ ...part, order }));
68
+ });
69
+ const orderOf = (kind) => flow.value.find((p) => p.kind === kind)?.order ?? -1;
70
+ /**
71
+ * Search page before anything is searched: nothing — no toolbar, no "no
72
+ * results" (the section is hidden, paddings included). The editor still
73
+ * shows the grid.
74
+ */
75
+ const { isPreview } = useBuilderPreview();
76
+ const awaitingTerms = computed(() => kind.value === "SEARCH" && !terms.value && !isPreview.value);
77
+ const toolbarOrder = computed(() =>
78
+ awaitingTerms.value ? -1 : flowMode.value ? orderOf("CLASSIFICATION_TOOLBAR") : 1
79
+ );
80
+ const gridOrder = computed(() => (awaitingTerms.value ? -1 : flowMode.value ? orderOf("PRODUCT_GRID") : 2));
81
+ const blockChunks = computed(() => flow.value.filter((p) => p.kind === "BLOCKS"));
82
+
83
+ /** The Product grid's Card template; none: the standard product card. */
84
+ const cardTemplate = computed(() =>
85
+ flow.value.find((p) => p.kind === "PRODUCT_GRID")?.node.children?.find((n) => n.type === "CARD") ?? null
86
+ );
87
+ const { items: cardItems } = useProductItems(() => props.section, products, { all: true });
88
+ const itemsById = computed(() => Object.fromEntries(cardItems.value.map((i) => [i.id, i])));
89
+
90
+ /* ------------------------------------------------------------- settings */
91
+
92
+ const num = (v, d) => {
93
+ const n = Number(v);
94
+ return v === "" || v === null || v === undefined || !Number.isFinite(n) ? d : n;
95
+ };
96
+ const bool = (v, d) => (v === undefined || v === null || v === "" ? d : v === true || v === "true");
97
+ const pick = (v, allowed, d) => (allowed.includes(String(v ?? "").toUpperCase()) ? String(v).toUpperCase() : d);
98
+
99
+ const WEIGHTS = { LIGHT: 300, REGULAR: 400, MEDIUM: 500, SEMIBOLD: 600, BOLD: 700 };
100
+ const CASES = { NONE: "none", UPPERCASE: "uppercase", LOWERCASE: "lowercase", CAPITALIZE: "capitalize" };
101
+ const weight = (v, d) => WEIGHTS[v] ?? (Number.isFinite(Number(v)) && v !== "" && v != null ? Number(v) : d);
102
+ const JUSTIFY = { LEFT: "flex-start", CENTER: "center", RIGHT: "flex-end" };
103
+
104
+ // header
105
+ const showBreadcrumbs = computed(() => bool(s.value.SHOW_BREADCRUMBS, false));
106
+ const showTitle = computed(() => bool(s.value.SHOW_TITLE, true));
107
+ const showDescription = computed(() => bool(s.value.SHOW_DESCRIPTION, true) && !!description.value);
108
+ const bannerStyle = computed(() => {
109
+ const style = pick(s.value.BANNER_STYLE, ["NONE", "ABOVE", "BEHIND"], "NONE");
110
+ return style !== "NONE" && image.value ? style : "NONE";
111
+ });
112
+
113
+ // layout
114
+ /**
115
+ * Asking for the navigation or the filters "in the sidebar" IS asking for a
116
+ * sidebar. With the page layout still on Full width that choice used to be
117
+ * dropped silently, which read as the setting not working; a left sidebar is
118
+ * switched on for it instead. An explicit Left / Right choice still wins.
119
+ */
120
+ const wantsSidebar = computed(
121
+ () =>
122
+ String(s.value.NAV_POSITION ?? "").toUpperCase() === "SIDEBAR" ||
123
+ String(s.value.FILTER_POSITION ?? "").toUpperCase() === "SIDEBAR"
124
+ );
125
+ const pageLayout = computed(() => {
126
+ const chosen = pick(s.value.PAGE_LAYOUT, ["FULL", "SIDEBAR_LEFT", "SIDEBAR_RIGHT"], "FULL");
127
+ return chosen === "FULL" && wantsSidebar.value ? "SIDEBAR_LEFT" : chosen;
128
+ });
129
+ const hasSidebarLayout = computed(() => pageLayout.value !== "FULL");
130
+
131
+ // navigation
132
+ const navPosition = computed(() => {
133
+ const p = pick(s.value.NAV_POSITION, ["ABOVE_TITLE", "BELOW_TITLE", "SIDEBAR"], "BELOW_TITLE");
134
+ return p === "SIDEBAR" && !hasSidebarLayout.value ? "BELOW_TITLE" : p;
135
+ });
136
+ const navAlign = computed(() =>
137
+ pick(s.value.NAV_ALIGNMENT, ["LEFT", "CENTER", "RIGHT"], align.value === "center" ? "CENTER" : "LEFT")
138
+ );
139
+ const navLayout = computed(() => pick(s.value.NAV_LAYOUT, ["SCROLL", "WRAP", "GRID"], "SCROLL"));
140
+ const navVariant = computed(() => pick(s.value.NAV_STYLE, ["TEXT", "PILL", "IMAGE", "CARD"], "TEXT"));
141
+ const navProps = computed(() => ({
142
+ items: navItems.value,
143
+ variant: navVariant.value,
144
+ shape: pick(s.value.NAV_IMAGE_SHAPE, ["CIRCLE", "SQUARE", "ROUNDED", "PORTRAIT", "LANDSCAPE"], "CIRCLE"),
145
+ textPosition: pick(
146
+ s.value.NAV_TEXT_POSITION,
147
+ ["BELOW", "BESIDE", "OVERLAY_CENTER", "OVERLAY_BOTTOM", "HIDDEN"],
148
+ "BELOW"
149
+ ),
150
+ activeStyle: pick(s.value.NAV_ACTIVE_STYLE, ["UNDERLINE", "BOLD", "FILL", "BORDER", "NONE"], "UNDERLINE"),
151
+ hover: pick(s.value.NAV_HOVER, ["NONE", "ZOOM", "LIFT"], "ZOOM"),
152
+ imageFit: pick(s.value.NAV_IMAGE_FIT, ["COVER", "CONTAIN"], "COVER"),
153
+ showCount: bool(s.value.NAV_SHOW_COUNT, false),
154
+ label: kind.value === "COLLECTION" ? "Collections" : "Categories",
155
+ }));
156
+
157
+ // filters / sort
158
+ const showFilters = computed(() => bool(s.value.SHOW_FILTERS, true));
159
+ const showSort = computed(() => bool(s.value.SHOW_SORT, true));
160
+ const filterPosition = computed(() => {
161
+ const p = pick(s.value.FILTER_POSITION, ["DRAWER", "SIDEBAR"], hasSidebarLayout.value ? "SIDEBAR" : "DRAWER");
162
+ return p === "SIDEBAR" && !hasSidebarLayout.value ? "DRAWER" : p;
163
+ });
164
+ const sortInToolbar = computed(
165
+ () => showSort.value && pick(s.value.SORT_POSITION, ["TOOLBAR", "FILTERS"], "FILTERS") === "TOOLBAR"
166
+ );
167
+ const sortInFilters = computed(() => showSort.value && !sortInToolbar.value);
168
+ /** In the drawer: its own setting, plus the toolbar's sort on phones. */
169
+ const sortInDrawer = computed(() => sortInFilters.value || (sortInToolbar.value && isNarrow.value));
170
+ const swatchShape = computed(() => pick(s.value.SWATCH_SHAPE, ["CIRCLE", "SQUARE"], "CIRCLE"));
171
+ const showActiveFilters = computed(() => bool(s.value.SHOW_ACTIVE_FILTERS, true));
172
+ const drawerSide = computed(() => pick(s.value.DRAWER_SIDE, ["START", "END"], "END"));
173
+
174
+ // sidebar contents
175
+ const sidebarHasNav = computed(() => navPosition.value === "SIDEBAR" && navItems.value.length > 0);
176
+
177
+ // For the header blocks (ClassificationPart).
178
+ provide("osiPlp", { kind, terms, title, description, image, breadcrumbs, navItems, navProps, navLayout });
179
+ const sidebarHasFilters = computed(
180
+ () => filterPosition.value === "SIDEBAR" && showFilters.value && (filters.value.length > 0 || sortInFilters.value)
181
+ );
182
+ const showSidebar = computed(() => hasSidebarLayout.value && (sidebarHasNav.value || sidebarHasFilters.value));
183
+
184
+ // toolbar
185
+ const showToolbar = computed(() => bool(s.value.SHOW_TOOLBAR, true));
186
+ const toolbarFullWidth = computed(() => bool(s.value.TOOLBAR_FULL_WIDTH, true));
187
+
188
+ /**
189
+ * Phones: the toolbar has room for one control, so the sort dropdown moves into
190
+ * the filter panel there, next to the filters. Tracked with matchMedia rather
191
+ * than CSS alone because the panel's contents depend on it.
192
+ */
193
+ const isNarrow = ref(false);
194
+ let narrowQuery = null;
195
+ const onNarrowChange = (e) => {
196
+ isNarrow.value = e.matches;
197
+ };
198
+ onMounted(() => {
199
+ narrowQuery = window.matchMedia("(max-width: 989px)");
200
+ isNarrow.value = narrowQuery.matches;
201
+ narrowQuery.addEventListener?.("change", onNarrowChange);
202
+ });
203
+ onBeforeUnmount(() => narrowQuery?.removeEventListener?.("change", onNarrowChange));
204
+ const toolbarStyle = computed(() => pick(s.value.TOOLBAR_STYLE, ["BAR", "LINES", "PLAIN"], "BAR"));
205
+ const showCount = computed(() => bool(s.value.SHOW_PRODUCT_COUNT, true));
206
+ const showColumnSwitcher = computed(() => bool(s.value.SHOW_COLUMN_SWITCHER, false));
207
+ /** The drawer button exists on desktop only when filters live in the drawer. */
208
+ const desktopFilterButton = computed(
209
+ () => showFilters.value && filterPosition.value === "DRAWER" && (filters.value.length > 0 || sortInFilters.value)
210
+ );
211
+ /** On mobile the sidebar — and the toolbar's sort — collapse into the drawer. */
212
+ const mobileFilterButton = computed(
213
+ () => desktopFilterButton.value || showSidebar.value || sortInToolbar.value
214
+ );
215
+
216
+ /**
217
+ * What goes where in the toolbar. Each control can be put at the start, the
218
+ * middle or the end; the product count can also be hidden.
219
+ */
220
+ const pos = (value, allowed, fallback) =>
221
+ allowed.includes(String(value ?? "").toUpperCase()) ? String(value).toUpperCase() : fallback;
222
+
223
+ const toolbarSlots = computed(() => {
224
+ const slots = { START: [], CENTER: [], END: [] };
225
+ if (mobileFilterButton.value) {
226
+ slots[pos(s.value.TOOLBAR_FILTER_POSITION, ["START", "CENTER", "END"], "START")].push("filter");
227
+ }
228
+ const countAt = pos(s.value.TOOLBAR_COUNT_POSITION, ["START", "CENTER", "END", "HIDDEN"], "START");
229
+ if (showCount.value && countAt !== "HIDDEN") slots[countAt].push("count");
230
+ if (sortInToolbar.value) {
231
+ slots[pos(s.value.TOOLBAR_SORT_POSITION, ["START", "CENTER", "END"], "END")].push("sort");
232
+ }
233
+ if (showColumnSwitcher.value) slots.END.push("columns");
234
+ return slots;
235
+ });
236
+
237
+ // products
238
+ const columns = computed(() => Math.min(6, Math.max(1, num(s.value.COLUMNS, 4))));
239
+ const userColumns = ref(null);
240
+ const effectiveColumns = computed(() => userColumns.value ?? columns.value);
241
+ const columnChoices = computed(() => [...new Set([2, 3, 4, columns.value])].filter((c) => c <= 6).sort());
242
+ const animate = computed(() => bool(s.value.ANIMATE_PRODUCTS, true));
243
+ const showProgress = computed(() => bool(s.value.SHOW_PROGRESS, false));
244
+
245
+ const cardSettings = computed(() => {
246
+ const out = {};
247
+ for (const key of ["CARD_IMAGE_RATIO", "CARD_TEXT_ALIGN", "CARD_CTA_STYLE", "CARD_TEXT_POSITION"]) {
248
+ if (s.value[key]) out[key] = s.value[key];
249
+ }
250
+ if (s.value.CARD_SHOW_QUICK_SHOP !== undefined && s.value.CARD_SHOW_QUICK_SHOP !== null && s.value.CARD_SHOW_QUICK_SHOP !== "") {
251
+ out.CARD_SHOW_QUICK_SHOP = bool(s.value.CARD_SHOW_QUICK_SHOP, true);
252
+ }
253
+ return out;
254
+ });
255
+
256
+ /* ------------------------------------------------------------ css vars */
257
+
258
+ const px = (v) => (v === undefined || v === null || v === "" ? undefined : `${Number(v)}px`);
259
+ const compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== ""));
260
+
261
+ // The toolbar's space above (schema default): 48 under the category header,
262
+ // 0 at the top of the search results.
263
+ const tbTop = computed(() => (isResults.value ? 0 : 48));
264
+ const tbBottom = computed(() => 0);
265
+
266
+ const cssVars = computed(() => ({
267
+ ...compact({
268
+ // header
269
+ "--plp-align": align.value,
270
+ "--plp-title-fs": px(num(s.value.TITLE_SIZE, 40)),
271
+ "--plp-title-fs-mobile": px(num(s.value.TITLE_SIZE_MOBILE, 30)),
272
+ "--plp-title-fw": weight(s.value.TITLE_WEIGHT, 400),
273
+ "--plp-title-tt": CASES[s.value.TITLE_TRANSFORM],
274
+ "--plp-title-color": s.value.TITLE_COLOR,
275
+ "--plp-banner-h": px(num(s.value.BANNER_HEIGHT, 320)),
276
+ "--plp-banner-h-mobile": px(num(s.value.BANNER_HEIGHT_MOBILE, 220)),
277
+ "--plp-banner-overlay": num(s.value.BANNER_OVERLAY, 35) / 100,
278
+ "--plp-banner-color": s.value.BANNER_TEXT_COLOR,
279
+ "--plp-banner-radius": px(s.value.BANNER_RADIUS),
280
+ "--plp-header-gap": px(num(s.value.HEADER_SPACING, 24)),
281
+
282
+ // nav
283
+ "--plp-nav-space": px(num(s.value.NAV_SPACING, 24)),
284
+ // Separate top / bottom, each falling back to the single spacing value.
285
+ "--plp-nav-mt": px(s.value.NAV_MARGIN_TOP),
286
+ "--plp-nav-mb": px(s.value.NAV_MARGIN_BOTTOM),
287
+ "--cnav-img": px(num(s.value.NAV_IMAGE_SIZE, 96)),
288
+ "--cnav-img-mobile": px(s.value.NAV_IMAGE_SIZE_MOBILE),
289
+ "--cnav-gap": px(num(s.value.NAV_GAP, navVariant.value === "TEXT" ? 28 : 20)),
290
+ "--cnav-gap-mobile": px(s.value.NAV_GAP_MOBILE),
291
+ "--cnav-cols": num(s.value.NAV_COLUMNS, 6),
292
+ "--cnav-cols-mobile": num(s.value.NAV_COLUMNS_MOBILE, 3),
293
+ "--cnav-radius": px(num(s.value.NAV_CORNER_RADIUS, 12)),
294
+ "--cnav-overlay": num(s.value.NAV_OVERLAY_OPACITY, 30) / 100,
295
+ "--cnav-justify": JUSTIFY[navAlign.value],
296
+ // Scrolling rows centre with auto margins, which collapse when they overflow.
297
+ "--cnav-scroll-start": navAlign.value === "LEFT" ? "0" : "auto",
298
+ "--cnav-scroll-end": navAlign.value === "CENTER" ? "auto" : "0",
299
+ "--cnav-fs": px(num(s.value.NAV_TEXT_SIZE, 14)),
300
+ "--cnav-fw": weight(s.value.NAV_TEXT_WEIGHT, undefined),
301
+ "--cnav-tt": CASES[s.value.NAV_TEXT_TRANSFORM],
302
+ "--cnav-ls": s.value.NAV_LETTER_SPACING ? `${num(s.value.NAV_LETTER_SPACING, 0) / 100}em` : undefined,
303
+ "--cnav-color": s.value.NAV_TEXT_COLOR,
304
+ "--cnav-active": s.value.NAV_ACTIVE_COLOR,
305
+ "--cnav-card-bg": s.value.NAV_CARD_BG,
306
+ "--cnav-overlay-color": s.value.NAV_OVERLAY_TEXT_COLOR,
307
+
308
+ // toolbar
309
+ "--plp-tb-bg": s.value.TOOLBAR_BG_COLOR,
310
+ "--plp-tb-border": s.value.TOOLBAR_BORDER_COLOR,
311
+ "--plp-tb-color": s.value.TOOLBAR_TEXT_COLOR,
312
+ "--plp-tb-h": px(s.value.TOOLBAR_HEIGHT),
313
+ "--plp-tb-fs": px(s.value.TOOLBAR_TEXT_SIZE),
314
+ // Block layout: the toolbar's and the grid's own spacing (mobile: empty =
315
+ // desktop). The header blocks get theirs from Groups.
316
+ ...(flowMode.value
317
+ ? {
318
+ "--plp-tb-mt": px(num(s.value.TOOLBAR_MARGIN_TOP, tbTop.value)),
319
+ "--plp-tb-mb": px(num(s.value.TOOLBAR_MARGIN_BOTTOM, tbBottom.value)),
320
+ "--plp-tb-mt-m": px(num(s.value.TOOLBAR_MARGIN_TOP_MOBILE, num(s.value.TOOLBAR_MARGIN_TOP, tbTop.value))),
321
+ "--plp-tb-mb-m": px(num(s.value.TOOLBAR_MARGIN_BOTTOM_MOBILE, num(s.value.TOOLBAR_MARGIN_BOTTOM, tbBottom.value))),
322
+ "--plp-grid-pt": px(num(s.value.GRID_PADDING_TOP, 24)),
323
+ "--plp-grid-pb": px(num(s.value.GRID_PADDING_BOTTOM, 0)),
324
+ "--plp-grid-pt-m": px(num(s.value.GRID_PADDING_TOP_MOBILE, num(s.value.GRID_PADDING_TOP, 24))),
325
+ "--plp-grid-pb-m": px(num(s.value.GRID_PADDING_BOTTOM_MOBILE, num(s.value.GRID_PADDING_BOTTOM, 0))),
326
+ }
327
+ : {}),
328
+
329
+ // layout
330
+ "--plp-sidebar-w": px(num(s.value.SIDEBAR_WIDTH, 260)),
331
+ "--plp-sidebar-gap": px(num(s.value.SIDEBAR_GAP, 40)),
332
+
333
+ // filters
334
+ "--pf-swatch": px(num(s.value.SWATCH_SIZE, 28)),
335
+
336
+ // grid
337
+ "--plp-cols": effectiveColumns.value,
338
+ "--plp-cols-tablet": Math.min(effectiveColumns.value, num(s.value.COLUMNS_TABLET, 3)),
339
+ "--plp-cols-mobile": Math.min(2, Math.max(1, num(s.value.COLUMNS_MOBILE, 2))),
340
+ "--plp-gap-x": px(num(s.value.GRID_GAP_X, 16)),
341
+ "--plp-gap-y": px(num(s.value.GRID_GAP_Y, 32)),
342
+ "--plp-gap-mobile": px(num(s.value.GRID_GAP_MOBILE, 8)),
343
+ }),
344
+ }));
345
+
346
+ const rootStyle = computed(() => ({
347
+ ...shellStyle.value,
348
+ ...cssVars.value,
349
+ "--plp-inner-max": innerStyle.value.maxWidth,
350
+ }));
351
+
352
+ /** The teleported drawer needs the colours and vars, not the section's box. */
353
+ const drawerStyle = computed(() => ({ ...scheme.value.style, ...cssVars.value }));
354
+
355
+ const rootClasses = computed(() => [
356
+ customClass.value,
357
+ `osi-plp--layout-${pageLayout.value.toLowerCase().replace("_", "-")}`,
358
+ `osi-plp--toolbar-${toolbarStyle.value.toLowerCase()}`,
359
+ `osi-plp--align-${align.value}`,
360
+ {
361
+ "osi-plp--toolbar-bleed": toolbarFullWidth.value,
362
+ "osi-plp--toolbar-sticky": bool(s.value.TOOLBAR_STICKY, false),
363
+ "osi-plp--sidebar-sticky": bool(s.value.SIDEBAR_STICKY, true),
364
+ "osi-plp--animate": animate.value,
365
+ },
366
+ ]);
367
+
368
+ /* ------------------------------------------------------------ labels */
369
+
370
+ /** "{terms}" in a label: what was searched (search page). */
371
+ const withTerms = (text) => String(text ?? "").replaceAll("{terms}", terms.value);
372
+
373
+ const L = computed(() => ({
374
+ filter: t(s.value.FILTER_BUTTON_LABEL) || (showSort.value ? "Filter & sort" : "Filter"),
375
+ filters: t(s.value.FILTER_TITLE) || "Filters",
376
+ sortBy: t(s.value.SORT_LABEL) || "Sort by",
377
+ apply: t(s.value.APPLY_LABEL) || "Apply",
378
+ clear: t(s.value.CLEAR_LABEL) || "Clear all",
379
+ loadMore: t(s.value.LOAD_MORE_LABEL) || "Load more",
380
+ emptyTitle: withTerms(t(s.value.EMPTY_TITLE)) || "No products found",
381
+ emptyText: withTerms(t(s.value.EMPTY_TEXT)) || "",
382
+ product: t(s.value.COUNT_SINGULAR) || "product",
383
+ products: t(s.value.COUNT_PLURAL) || "products",
384
+ }));
385
+
386
+ const countLabel = computed(() => `${total.value} ${total.value === 1 ? L.value.product : L.value.products}`);
387
+
388
+ /* --------------------------------------------------------------- drawer */
389
+
390
+ const drawerOpen = ref(false);
391
+ const draftValues = ref([]);
392
+ const draftOrdering = ref("");
393
+ useBodyScrollLock(drawerOpen);
394
+
395
+ function openDrawer() {
396
+ draftValues.value = [...selectedValues.value];
397
+ draftOrdering.value = ordering.value;
398
+ drawerOpen.value = true;
399
+ }
400
+ function closeDrawer() {
401
+ drawerOpen.value = false;
402
+ }
403
+ function toggleDraft(id) {
404
+ const set = new Set(draftValues.value);
405
+ set.has(id) ? set.delete(id) : set.add(id);
406
+ draftValues.value = [...set];
407
+ }
408
+ async function applyDrawer() {
409
+ await page.setValues(draftValues.value);
410
+ if (draftOrdering.value !== ordering.value) await page.setOrdering(draftOrdering.value);
411
+ closeDrawer();
412
+ }
413
+ async function clearDrawer() {
414
+ draftValues.value = [];
415
+ draftOrdering.value = "";
416
+ await page.clearFilters();
417
+ closeDrawer();
418
+ }
419
+
420
+ function onKeydown(e) {
421
+ if (e.key === "Escape" && drawerOpen.value) closeDrawer();
422
+ }
423
+
424
+ /* ------------------------------------------------------------ pagination */
425
+
426
+ const root = ref(null);
427
+ const sentinel = ref(null);
428
+ let observer = null;
429
+
430
+ function setupObserver() {
431
+ observer?.disconnect();
432
+ observer = null;
433
+ if (pagination.value !== "INFINITE" || !sentinel.value || typeof IntersectionObserver === "undefined") return;
434
+ observer = new IntersectionObserver(
435
+ (entries) => {
436
+ if (entries.some((e) => e.isIntersecting)) page.loadMore();
437
+ },
438
+ { rootMargin: "800px 0px" }
439
+ );
440
+ observer.observe(sentinel.value);
441
+ }
442
+
443
+ watch([sentinel, pagination], setupObserver);
444
+
445
+ const pageWindow = computed(() => {
446
+ const current = page.page.value;
447
+ const last = pageCount.value;
448
+ const out = new Set([1, last, current, current - 1, current + 1]);
449
+ const list = [...out].filter((n) => n >= 1 && n <= last).sort((a, b) => a - b);
450
+ const withGaps = [];
451
+ list.forEach((n, i) => {
452
+ if (i && n - list[i - 1] > 1) withGaps.push(`gap-${n}`);
453
+ withGaps.push(n);
454
+ });
455
+ return withGaps;
456
+ });
457
+
458
+ async function goToPage(n) {
459
+ await page.goToPage(n);
460
+ root.value?.scrollIntoView({ behavior: "smooth", block: "start" });
461
+ }
462
+
463
+ onMounted(() => {
464
+ window.addEventListener("keydown", onKeydown);
465
+ setupObserver();
466
+ });
467
+ onBeforeUnmount(() => {
468
+ window.removeEventListener("keydown", onKeydown);
469
+ observer?.disconnect();
470
+ });
471
+
472
+ return { LISTING_BLOCKS, effectiveSection, s, t, shellStyle, innerStyle, align, customClass, scheme, page, kind, terms, title, description, image, breadcrumbs, navItems, sortOptions, ordering, selectedValues, filters, filtersPending, activeFilters, products, total, pageCount, pagination, pending, hasMore, loadingMore, blockMode, isResults, flowMode, PART, tree, flow, orderOf, isPreview, awaitingTerms, toolbarOrder, gridOrder, blockChunks, cardTemplate, cardItems, itemsById, num, bool, pick, WEIGHTS, CASES, weight, JUSTIFY, showBreadcrumbs, showTitle, showDescription, bannerStyle, wantsSidebar, pageLayout, hasSidebarLayout, navPosition, navAlign, navLayout, navVariant, navProps, showFilters, showSort, filterPosition, sortInToolbar, sortInFilters, sortInDrawer, swatchShape, showActiveFilters, drawerSide, sidebarHasNav, sidebarHasFilters, showSidebar, showToolbar, toolbarFullWidth, isNarrow, narrowQuery, onNarrowChange, toolbarStyle, showCount, showColumnSwitcher, desktopFilterButton, mobileFilterButton, pos, toolbarSlots, columns, userColumns, effectiveColumns, columnChoices, animate, showProgress, cardSettings, px, compact, tbTop, tbBottom, cssVars, rootStyle, drawerStyle, rootClasses, withTerms, L, countLabel, drawerOpen, draftValues, draftOrdering, openDrawer, closeDrawer, toggleDraft, applyDrawer, clearDrawer, onKeydown, root, sentinel, observer, setupObserver, pageWindow, goToPage };
473
+ }