@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,280 @@
1
+
2
+ export default function useLogic() {
3
+ /**
4
+ * Sidebar / mobile navigation.
5
+ *
6
+ * Everything visual is merchant-controlled through the SIDEBAR theme group.
7
+ * The menu itself is HEADER_MENU — the desktop nav and the drawer render the
8
+ * same tree, deliberately, so they cannot disagree.
9
+ *
10
+ * Two structures, one setting:
11
+ *
12
+ * ACCORDION submenus expand in place (default; SIDEBAR_SUBMENU_* apply)
13
+ * DRILLDOWN each submenu slides in as a new panel
14
+ *
15
+ * Accessibility is not settings-driven and is not optional: the panel is a
16
+ * modal dialog, focus is trapped inside it while open, Escape closes it, and
17
+ * focus returns to the trigger. A merchant can restyle all of that; they
18
+ * cannot switch it off.
19
+ */
20
+ const { settings, scheme, style, overlayStyle, enabled, position, animation, submenuMode } =
21
+ useSidebarStyle();
22
+
23
+ const header = useThemeGroup("HEADER");
24
+ const brand = useThemeGroup("BRAND");
25
+ const { isMobileSidebarVisible, toggleMobileSidebar, toggleCartSidebar } = useSidebar();
26
+ const { useMenuByHandle } = useMenu();
27
+ const { isActive } = useNavActive();
28
+ const { store, storeLogo, dir } = useStorefront();
29
+ const { mediaUrl } = useMediaUrl();
30
+ const { isLoggedIn } = useAuth();
31
+ const { count } = useCart();
32
+ const t = useT();
33
+ const router = useRouter();
34
+
35
+ const menu = useMenuByHandle(computed(() => header.value.HEADER_MENU));
36
+
37
+ const logoSrc = computed(() => mediaUrl(brand.value.LOGO || storeLogo.value));
38
+ const socials = computed(() => store.value?.social_medias ?? []);
39
+ const footerText = computed(() => t(settings.value.SIDEBAR_FOOTER_TEXT));
40
+
41
+ const submenuAnimation = computed(() => settings.value.SIDEBAR_SUBMENU_ANIMATION || "SLIDE");
42
+
43
+ const transitionName = computed(() => {
44
+ if (animation.value === "NONE") return "osi-sb-none";
45
+ if (animation.value === "FADE") return "osi-sb-fade";
46
+ return position.value === "END" ? "osi-sb-slide-end" : "osi-sb-slide-start";
47
+ });
48
+
49
+ function close() {
50
+ toggleMobileSidebar(false);
51
+ }
52
+
53
+ // ---------------------------------------------------------------- accordion
54
+
55
+ const openIds = ref(new Set());
56
+
57
+ function toggle(id, siblings = []) {
58
+ const next = new Set(openIds.value);
59
+ if (next.has(id)) {
60
+ next.delete(id);
61
+ } else {
62
+ // "Close the others" is the inverse of "allow multiple", so one setting
63
+ // drives both and they can't contradict each other.
64
+ if (!settings.value.SIDEBAR_SUBMENU_MULTIPLE_OPEN) {
65
+ siblings.forEach((sibling) => next.delete(sibling));
66
+ }
67
+ next.add(id);
68
+ }
69
+ openIds.value = next;
70
+ }
71
+
72
+ provide("osi-sidebar-nav", {
73
+ isOpen: (id) => openIds.value.has(id),
74
+ toggle,
75
+ close,
76
+ isActive,
77
+ maxDepth: computed(() => Number(settings.value.SIDEBAR_MAX_DEPTH) || 3),
78
+ showArrows: computed(() => settings.value.SIDEBAR_SHOW_ARROWS !== false),
79
+ arrowStyle: computed(() => settings.value.SIDEBAR_ARROW_STYLE || "CHEVRON"),
80
+ submenuAnimation,
81
+ });
82
+
83
+ // --------------------------------------------------------------- drilldown
84
+
85
+ const stack = ref([]);
86
+ const current = computed(() => stack.value[stack.value.length - 1] ?? null);
87
+ const panelItems = computed(() => current.value?.children ?? menu.value.items);
88
+
89
+ /**
90
+ * Which way the panels travel.
91
+ *
92
+ * A single transition name can't express both directions — going deeper has to
93
+ * slide in from the end and going back from the start, or the drawer reads as
94
+ * if it's always moving forward and the user loses their place in the tree.
95
+ */
96
+ const direction = ref("forward");
97
+
98
+ const bodyEl = ref(null);
99
+
100
+ function movePanel(fn, way) {
101
+ direction.value = way;
102
+ fn();
103
+ // A deep panel scrolled halfway down leaves the next one scrolled to the
104
+ // same offset, which looks like content is missing from the top.
105
+ nextTick(() => {
106
+ if (bodyEl.value) bodyEl.value.scrollTop = 0;
107
+ });
108
+ }
109
+
110
+ function push(item) {
111
+ if (item.children?.length) {
112
+ movePanel(() => {
113
+ stack.value = [...stack.value, item];
114
+ }, "forward");
115
+ }
116
+ }
117
+
118
+ function pop() {
119
+ movePanel(() => {
120
+ stack.value = stack.value.slice(0, -1);
121
+ }, "back");
122
+ }
123
+
124
+ /**
125
+ * Vue's <Transition> only auto-generates classes off `name` (e.g.
126
+ * osi-panel-forward-leave-active); it doesn't apply the custom
127
+ * `osi-panel-leaving` class from the CSS below on its own. Without this
128
+ * hook the outgoing panel stays in normal document flow for the whole
129
+ * transition, so it stacks on top of the incoming panel instead of
130
+ * overlapping it — which is what reads as "items appear under the first
131
+ * page, then the new panel jumps into place" once the old one unmounts.
132
+ */
133
+ function onPanelLeave(el) {
134
+ el.classList.add("osi-panel-leaving");
135
+ }
136
+
137
+ /**
138
+ * Reuses SIDEBAR_SUBMENU_ANIMATION rather than adding a drilldown-only twin —
139
+ * "how a submenu appears" is one merchant decision whichever structure is in
140
+ * use. Note the schema currently hides that field behind
141
+ * `visible_if={'SIDEBAR_SUBMENU_MODE': ACCORDION}`, so on DRILLDOWN it falls
142
+ * to its default of SLIDE and can't be changed; widen the visible_if to
143
+ * [ACCORDION, DRILLDOWN] to expose it.
144
+ */
145
+ const panelTransition = computed(() => {
146
+ if (submenuAnimation.value === "NONE") return "osi-panel-none";
147
+ if (submenuAnimation.value === "FADE") return "osi-panel-fade";
148
+ return direction.value === "back" ? "osi-panel-back" : "osi-panel-forward";
149
+ });
150
+
151
+ // ------------------------------------------------------------ appearance
152
+
153
+ /**
154
+ * Links appearing (SIDEBAR_LINK_ANIMATION), Shopify's drawer: CASCADE — each
155
+ * link fades in and rises, one after another; FADE — together; NONE. Runs
156
+ * whenever a list mounts: the drawer opening, a panel coming in.
157
+ */
158
+ const linkAnimation = computed(() => String(settings.value.SIDEBAR_LINK_ANIMATION || "CASCADE").toLowerCase());
159
+ const animVars = computed(() => ({
160
+ "--sb-stagger": `${Number(settings.value.SIDEBAR_LINK_STAGGER) || 50}ms`,
161
+ // Wait for the drawer to be mostly open before the first link comes in.
162
+ "--sb-anim-base": `${Math.round((Number(settings.value.SIDEBAR_ANIMATION_DURATION) || 300) * 0.35)}ms`,
163
+ }));
164
+
165
+ /** A submenu panel's own colours (drilldown, SIDEBAR_PANEL_*). */
166
+ const panelStyle = computed(() => {
167
+ if (!stack.value.length) return null;
168
+ const s = settings.value;
169
+ const out = {};
170
+ if (s.SIDEBAR_PANEL_BACKGROUND) out.background = s.SIDEBAR_PANEL_BACKGROUND;
171
+ if (s.SIDEBAR_PANEL_TEXT_COLOR) {
172
+ out.color = s.SIDEBAR_PANEL_TEXT_COLOR;
173
+ out["--osi-sb-link"] = s.SIDEBAR_PANEL_TEXT_COLOR;
174
+ out["--osi-sb-sub-color"] = s.SIDEBAR_PANEL_TEXT_COLOR;
175
+ }
176
+ return out;
177
+ });
178
+
179
+ /**
180
+ * The panel's image row: the categories / collections it lists that have an
181
+ * image, in menu order. Only in submenu panels, and only when switched on.
182
+ */
183
+ const RATIOS = { SQUARE: "1 / 1", PORTRAIT: "3 / 4", LANDSCAPE: "4 / 3" };
184
+ const panelImages = computed(() => {
185
+ if (!stack.value.length || !settings.value.SIDEBAR_PANEL_IMAGES) return [];
186
+ return panelItems.value.filter((item) => item.image);
187
+ });
188
+ const imageVars = computed(() => {
189
+ const s = settings.value;
190
+ const n = (v, d) => (Number.isFinite(Number(v)) && v !== "" && v != null ? Number(v) : d);
191
+ return {
192
+ "--sb-img-w": `${n(s.SIDEBAR_PANEL_IMAGE_WIDTH, 130)}px`,
193
+ "--sb-img-ratio": RATIOS[s.SIDEBAR_PANEL_IMAGE_RATIO] ?? RATIOS.PORTRAIT,
194
+ "--sb-img-r": `${n(s.SIDEBAR_PANEL_IMAGE_RADIUS, 6)}px`,
195
+ "--sb-img-gap": `${n(s.SIDEBAR_PANEL_IMAGE_GAP, 10)}px`,
196
+ };
197
+ });
198
+
199
+ // Panels translate on the physical X axis, so the sign has to flip in Arabic
200
+ // or "deeper" animates backwards.
201
+ const panelDirection = computed(() => (dir.value === "rtl" ? -1 : 1));
202
+
203
+ // --------------------------------------------------------- focus & keyboard
204
+
205
+ const panel = ref(null);
206
+ let restoreFocusTo = null;
207
+
208
+ const FOCUSABLE =
209
+ 'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])';
210
+
211
+ function onKeydown(e) {
212
+ if (!isMobileSidebarVisible.value) return;
213
+
214
+ if (e.key === "Escape") {
215
+ e.preventDefault();
216
+ close();
217
+ return;
218
+ }
219
+
220
+ if (e.key !== "Tab" || !panel.value) return;
221
+
222
+ // Querying on every Tab rather than caching: the tree expands and collapses
223
+ // while the drawer is open, so a cached list traps focus on rows that are no
224
+ // longer visible. During a panel transition both the old and new panel are
225
+ // in the DOM, so the outgoing one is excluded explicitly.
226
+ const items = [...panel.value.querySelectorAll(FOCUSABLE)].filter(
227
+ (el) => el.offsetParent !== null && !el.closest(".osi-panel-leaving")
228
+ );
229
+ if (!items.length) return;
230
+
231
+ const first = items[0];
232
+ const last = items[items.length - 1];
233
+
234
+ if (e.shiftKey && document.activeElement === first) {
235
+ e.preventDefault();
236
+ last.focus();
237
+ } else if (!e.shiftKey && document.activeElement === last) {
238
+ e.preventDefault();
239
+ first.focus();
240
+ }
241
+ }
242
+
243
+ watch(isMobileSidebarVisible, async (open) => {
244
+ if (open) {
245
+ restoreFocusTo = document.activeElement;
246
+ await nextTick();
247
+ panel.value?.querySelector(FOCUSABLE)?.focus();
248
+ return;
249
+ }
250
+
251
+ // Returning focus to the burger matters for keyboard and screen reader
252
+ // users: without it focus falls back to <body> and the next Tab restarts
253
+ // from the top of the page.
254
+ restoreFocusTo?.focus?.();
255
+ restoreFocusTo = null;
256
+
257
+ // Reset only after the close transition, or the panel visibly snaps back to
258
+ // the root while it's still sliding out.
259
+ const delay = Number(settings.value.SIDEBAR_ANIMATION_DURATION) || 0;
260
+ setTimeout(() => {
261
+ stack.value = [];
262
+ openIds.value = new Set();
263
+ direction.value = "forward";
264
+ }, delay);
265
+ });
266
+
267
+ onMounted(() => document.addEventListener("keydown", onKeydown));
268
+ onBeforeUnmount(() => document.removeEventListener("keydown", onKeydown));
269
+
270
+ function goSearch() {
271
+ close();
272
+ router.push("/search");
273
+ }
274
+ function openCart() {
275
+ close();
276
+ toggleCartSidebar(true);
277
+ }
278
+
279
+ return { settings, scheme, style, overlayStyle, enabled, position, animation, submenuMode, header, brand, isMobileSidebarVisible, toggleMobileSidebar, toggleCartSidebar, useMenuByHandle, isActive, store, storeLogo, dir, mediaUrl, isLoggedIn, count, t, router, menu, logoSrc, socials, footerText, submenuAnimation, transitionName, close, openIds, toggle, stack, current, panelItems, direction, bodyEl, movePanel, push, pop, onPanelLeave, panelTransition, linkAnimation, animVars, panelStyle, RATIOS, panelImages, imageVars, panelDirection, panel, restoreFocusTo, FOCUSABLE, onKeydown, goSearch, openCart };
280
+ }
@@ -0,0 +1,66 @@
1
+ import { useProductDetail } from "#osi/composables/useProductDetail";
2
+ import { buttonClasses, buttonVars } from "#osi/composables/useButtonStyle";
3
+
4
+ export default function useLogic({ props }) {
5
+ const detail = useProductDetail();
6
+ const t = useT();
7
+ const { addToWishlist, wishlistStatus } = useWishlist();
8
+
9
+ const s = computed(() => props.block.settings ?? {});
10
+ const has = (v) => v !== undefined && v !== null && v !== "";
11
+ const px = (v) => (has(v) && Number.isFinite(Number(v)) ? `${Number(v)}px` : undefined);
12
+
13
+ // The button's height is fixed, so no vertical padding applies — neither the
14
+ // theme's nor this block's (BUTTON_PADDING_Y is dropped on purpose).
15
+ function heightFixed(vars, height) {
16
+ const { "--btn-pad-y": _drop, ...rest } = vars;
17
+ return { ...rest, "--btn-pad-y": "0px", height: height ?? "48px" };
18
+ }
19
+
20
+ const height = computed(() => px(s.value.BUTTON_HEIGHT) ?? "48px");
21
+
22
+ const addStyle = computed(() => heightFixed(buttonVars(s.value, "BUTTON"), height.value));
23
+ const addClasses = computed(() => buttonClasses(s.value, "BUTTON", ["osi-pdp-buy-button"]));
24
+
25
+ const buyNowStyle = computed(() =>
26
+ heightFixed(buttonVars(s.value, "BUY_NOW_BUTTON"), px(s.value.BUY_NOW_BUTTON_HEIGHT) ?? height.value)
27
+ );
28
+ const buyNowClasses = computed(() => {
29
+ const own = buttonClasses(s.value, "BUY_NOW_BUTTON", ["osi-btn--block", "osi-pdp-buy-now"]);
30
+ // No style chosen: the theme's secondary button.
31
+ return s.value.BUY_NOW_BUTTON_STYLE ? own : [...own, "osi-btn--secondary"];
32
+ });
33
+
34
+ const layout = computed(() => (s.value.BUY_LAYOUT === "STACKED" ? "stacked" : "row"));
35
+ const showQuantity = computed(() => !!s.value.BUY_SHOW_QUANTITY);
36
+ const showBuyNow = computed(() => !!s.value.BUY_SHOW_BUY_NOW);
37
+ const showWishlist = computed(() => !!s.value.BUY_SHOW_WISHLIST);
38
+ const showStock = computed(() => s.value.BUY_SHOW_STOCK_NOTICE !== false);
39
+
40
+ const label = computed(() => {
41
+ if (detail.isComingSoon.value) return t(s.value.BUY_COMING_SOON_LABEL) || "Coming soon";
42
+ if (detail.isOutOfStock.value) return t(s.value.BUY_SOLD_OUT_LABEL) || "Sold out";
43
+ if (detail.isAddingToCart.value) return t(s.value.BUY_ADDING_LABEL) || "Adding…";
44
+ return t(s.value.BUY_LABEL) || "Add to bag";
45
+ });
46
+
47
+ const wishlisted = computed(
48
+ () => wishlistStatus.value[detail.product.value?.id] ?? detail.product.value?.wishlisted ?? false
49
+ );
50
+
51
+ const stockNotice = computed(() => {
52
+ if (detail.isComingSoon.value || detail.isOutOfStock.value) return "";
53
+ if (detail.isLowStock.value) {
54
+ return (t(s.value.BUY_LOW_STOCK_LABEL) || "Only {n} left").replace("{n}", detail.remainingStock.value);
55
+ }
56
+ return t(s.value.BUY_IN_STOCK_LABEL) || "In stock, ready to ship";
57
+ });
58
+
59
+ const stockStyle = computed(() => ({
60
+ "--stock-dot": detail.isLowStock.value
61
+ ? s.value.BUY_LOW_STOCK_COLOR || "#d97706"
62
+ : s.value.BUY_STOCK_COLOR || "#16a34a",
63
+ }));
64
+
65
+ return { detail, t, addToWishlist, wishlistStatus, s, has, px, heightFixed, height, addStyle, addClasses, buyNowStyle, buyNowClasses, layout, showQuantity, showBuyNow, showWishlist, showStock, label, wishlisted, stockNotice, stockStyle };
66
+ }
@@ -0,0 +1,160 @@
1
+ import { cardOffers, variantPrices, tierText, rangeString } from "#osi/utils/productOffers";
2
+
3
+ export default function useLogic({ props, emit }) {
4
+ const { addToWishlist, wishlistStatus } = useWishlist();
5
+ const analytics = useAnalytics();
6
+ const { handleAddToCart } = useAddToCart();
7
+ const { toggleCartSidebar } = useSidebar();
8
+ const { store } = useStorefront();
9
+
10
+ // Store-wide currency symbol, falling back to "$" only if the store hasn't
11
+ // set one.
12
+ const currency = computed(
13
+ () => store.value?.default_currency?.symbol || store.value?.default_currency?.code || "$"
14
+ );
15
+
16
+ // Matches the storefront's existing trailing-zero-strip convention
17
+ // (e.g. "50.00" -> "$50", "49.99" -> "$49.99"), using the store's currency
18
+ // instead of a hardcoded "$".
19
+ //
20
+ // price_range/discounted_price_range come from the API as either a single
21
+ // number, a numeric string, or a "min - max" range string (e.g.
22
+ // "42.79 - 47.0") when a product's variants aren't all the same price — so
23
+ // this has to split on "-" rather than Number()'ing the whole thing, or
24
+ // every ranged product would parse as NaN and look priceless.
25
+ function parsePriceParts(value) {
26
+ if (value === null || value === undefined || value === "") return [];
27
+ if (typeof value === "number") return Number.isFinite(value) ? [value] : [];
28
+ return String(value)
29
+ .split("-")
30
+ .map((part) => parseFloat(part))
31
+ .filter((n) => Number.isFinite(n));
32
+ }
33
+
34
+ // No real price set (missing, empty, or every part of the range is 0) —
35
+ // never show "0" and never let the shopper add it to cart.
36
+ function hasPriceValue(value) {
37
+ return parsePriceParts(value).some((n) => n > 0);
38
+ }
39
+
40
+ function formatPrice(value) {
41
+ const parts = parsePriceParts(value);
42
+ if (!parts.length || !parts.some((n) => n > 0)) return "";
43
+ return parts.map((n) => `${currency.value}${n}`).join(" - ");
44
+ }
45
+
46
+ const card = useProductCardStyle(() => props.cardSettings);
47
+
48
+ const variants = useProductVariants(() => props.product);
49
+ const quickShop = useQuickShop({
50
+ product: () => props.product,
51
+ variants,
52
+ onAdded: (variant) => {
53
+ emit("added-to-cart", { product: props.product, variant });
54
+ toggleCartSidebar(true);
55
+ },
56
+ });
57
+
58
+ const media = useProductCardMedia({
59
+ product: () => props.product,
60
+ quickShopOpen: () => quickShop.isOpen.value,
61
+ selectedVariant: () => variants.selectedVariant.value,
62
+ selectedOptions: () => variants.selectedOptions.value,
63
+ selectedColor: () => variants.selectedColor.value,
64
+ });
65
+
66
+ const swatches = useDragScroll();
67
+
68
+ // See useProductUrl for the route shape and the backend change it needs.
69
+ const { productUrl } = useProductUrl();
70
+ const url = computed(() => productUrl(props.product));
71
+
72
+ const wishlisted = computed(
73
+ () => wishlistStatus.value[props.product.id] ?? props.product.wishlisted ?? false
74
+ );
75
+
76
+ // Stock is asked with the quick-shop selection while the panel is open and
77
+ // without it when closed — one sold-out size shouldn't grey out the whole card.
78
+ const outOfStock = computed(() =>
79
+ variants.stockFor(quickShop.isOpen.value ? variants.selectedVariant.value : null)
80
+ );
81
+
82
+ // Shopify's rules (utils/productOffers): the price for one item — only a
83
+ // 1-item sale lowers it; quantity tiers (2+ items) come off the total and show
84
+ // as boxes. Older rows without the variants' sales keep the API's ranges.
85
+ const offers = computed(() => cardOffers(props.product));
86
+ const price = computed(() => {
87
+ const variant = quickShop.isOpen.value ? variants.selectedVariant.value : null;
88
+ if (variant) {
89
+ return variantPrices(variant);
90
+ }
91
+ if (offers.value) {
92
+ return {
93
+ now: rangeString(offers.value.now),
94
+ was: offers.value.was ? rangeString(offers.value.was) : null,
95
+ sale: offers.value.sale,
96
+ };
97
+ }
98
+ const base = props.product.price_range;
99
+ const now = props.product.discounted_price_range;
100
+ const was = now && base && now !== base ? base : null;
101
+ return { now: now ?? base, was, sale: !!was };
102
+ });
103
+ const tierBoxes = computed(() =>
104
+ (offers.value?.tiers ?? []).map((tier) => ({ id: tier.id, text: tierText(tier, formatPrice, { short: true }) }))
105
+ );
106
+
107
+ // No real price set on the current selection — never show "0" and never
108
+ // let the shopper add it to cart.
109
+ const hasPrice = computed(() => hasPriceValue(price.value.now));
110
+
111
+ const showHoverImage = computed(
112
+ () =>
113
+ card.showSecondImage.value &&
114
+ !quickShop.isOpen.value &&
115
+ media.isHovering.value &&
116
+ !!media.hoverImage.value
117
+ );
118
+
119
+ const quickShopAvailable = computed(
120
+ () =>
121
+ card.showQuickShop.value &&
122
+ variants.hasVariants.value &&
123
+ !variants.isComingSoon.value
124
+ );
125
+
126
+ // The CTA appears in one of two places, so its label and state are computed
127
+ // once rather than written twice in the template.
128
+ const ctaVisible = computed(
129
+ () => card.showCta.value && !quickShop.isOpen.value && !variants.isComingSoon.value
130
+ );
131
+ const ctaLabel = computed(() => {
132
+ if (outOfStock.value) return "Sold out";
133
+ // No price: the normal label, disabled (see :disabled below).
134
+ return quickShopAvailable.value ? "Quick shop" : "Add to cart";
135
+ });
136
+
137
+ /** No variants to choose: straight to cart. Otherwise open the panel. */
138
+ async function primaryAction() {
139
+ if (!hasPrice.value) return;
140
+ if (quickShopAvailable.value) {
141
+ quickShop.open(window.innerWidth < 1024 ? "MOBILE" : "DESKTOP");
142
+ return;
143
+ }
144
+ try {
145
+ await handleAddToCart(props.product);
146
+ toggleCartSidebar(true);
147
+ } catch {
148
+ // handleAddToCart is responsible for surfacing its own error (toast/etc);
149
+ // just don't open the drawer on a failed add.
150
+ }
151
+ }
152
+
153
+ function pickSwatch(value) {
154
+ // A swatch click that was really the end of a drag shouldn't select.
155
+ if (swatches.wasDragged()) return;
156
+ variants.selectVariant(value);
157
+ }
158
+
159
+ return { addToWishlist, wishlistStatus, analytics, handleAddToCart, toggleCartSidebar, store, currency, parsePriceParts, hasPriceValue, formatPrice, card, variants, quickShop, media, swatches, productUrl, url, wishlisted, outOfStock, offers, price, tierBoxes, hasPrice, showHoverImage, quickShopAvailable, ctaVisible, ctaLabel, primaryAction, pickSwatch };
160
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Sort + variant filters as accordion groups.
3
+ *
4
+ * Stateless: the parent owns the selection. In the sidebar every click is
5
+ * applied straight to the URL; in the drawer the parent holds a draft and
6
+ * applies it on "Apply". Same component either way.
7
+ */
8
+
9
+
10
+ export default function useLogic({ props, emit }) {
11
+ const selectedSet = computed(() => new Set(props.selected.map(String)));
12
+
13
+ const initialOpen = () => {
14
+ const open = new Set(["sort"]);
15
+ for (const f of props.filters) {
16
+ if (props.expanded || f.values.some((v) => selectedSet.value.has(v.id))) open.add(f.id);
17
+ }
18
+ return open;
19
+ };
20
+ const open = ref(initialOpen());
21
+ watch(
22
+ () => props.filters.length,
23
+ () => {
24
+ open.value = new Set([...open.value, ...initialOpen()]);
25
+ }
26
+ );
27
+
28
+ function toggleGroup(key) {
29
+ const next = new Set(open.value);
30
+ next.has(key) ? next.delete(key) : next.add(key);
31
+ open.value = next;
32
+ }
33
+
34
+ const countIn = (f) => f.values.filter((v) => selectedSet.value.has(v.id)).length;
35
+
36
+ return { selectedSet, initialOpen, open, toggleGroup, countIn };
37
+ }
@@ -0,0 +1,19 @@
1
+ import { Pagination } from "swiper/modules";
2
+ import { useProductDetail } from "#osi/composables/useProductDetail";
3
+ import { useProductGallery } from "#osi/composables/useProductGallery";
4
+
5
+ export default function useLogic({ props }) {
6
+ const detail = useProductDetail();
7
+
8
+ const gallery = useProductGallery({
9
+ images: () => detail?.activeImages.value ?? [],
10
+ settings: () => props.settings,
11
+ });
12
+
13
+ const images = gallery.images;
14
+ const zoomable = computed(() => gallery.zoom.value !== "NONE");
15
+
16
+ const modules = [Pagination];
17
+
18
+ return { detail, gallery, images, zoomable, modules };
19
+ }