@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.
- package/README.md +80 -2
- package/THEME_DEV_API.md +80 -0
- package/app/app.vue +154 -0
- package/app/assets/css/osi-controls.css +824 -0
- package/app/components/SectionRenderer.vue +46 -0
- package/app/components/builder/BuilderOverlay.vue +582 -0
- package/app/components/checkout/ExtraFees.vue +329 -0
- package/app/components/checkout/PaymentHandoff.vue +388 -0
- package/app/components/common/Banner.vue +35 -0
- package/app/components/common/ConfirmDeleteModal.vue +117 -0
- package/app/components/common/DropdownSelect.vue +432 -0
- package/app/components/common/Loader.vue +32 -0
- package/app/components/common/OsiField.vue +218 -0
- package/app/components/common/PhoneNumInput.vue +434 -0
- package/app/composables/UseBodyScrollLock.js +122 -0
- package/app/composables/UseThemeGroup.js +575 -0
- package/app/composables/useAddToCart.js +87 -0
- package/app/composables/useAnalytics.js +15 -0
- package/app/composables/useAnnouncements.js +29 -0
- package/app/composables/useApiErrorToast.js +104 -0
- package/app/composables/useAuth.js +99 -0
- package/app/composables/useBanners.js +25 -0
- package/app/composables/useBuilderPreview.js +320 -0
- package/app/composables/useButtonStyle.js +107 -0
- package/app/composables/useCarousel.js +160 -0
- package/app/composables/useCart.js +109 -0
- package/app/composables/useCheckout.js +76 -0
- package/app/composables/useClassificationItems.js +269 -0
- package/app/composables/useClassificationPage.js +545 -0
- package/app/composables/useClassificationUrl.js +63 -0
- package/app/composables/useColorScheme.js +44 -0
- package/app/composables/useContactForm.js +53 -0
- package/app/composables/useContentTree.js +770 -0
- package/app/composables/useDragScroll.js +73 -0
- package/app/composables/useFooterSocials.js +107 -0
- package/app/composables/useInputStyle.js +45 -0
- package/app/composables/useLayoutShell.js +114 -0
- package/app/composables/useLazyVideo.js +36 -0
- package/app/composables/useMediaTypes.js +66 -0
- package/app/composables/useMediaUrl.js +71 -0
- package/app/composables/useMenu.js +100 -0
- package/app/composables/useNavStyle.js +219 -0
- package/app/composables/usePlaceholders.js +294 -0
- package/app/composables/usePolicies.js +26 -0
- package/app/composables/useProductCardMedia.js +135 -0
- package/app/composables/useProductCardStyle.js +132 -0
- package/app/composables/useProductDetail.js +982 -0
- package/app/composables/useProductGallery.js +166 -0
- package/app/composables/useProductItems.js +270 -0
- package/app/composables/useProductUrl.js +60 -0
- package/app/composables/useProductVariants.js +210 -0
- package/app/composables/useQuickShop.js +115 -0
- package/app/composables/useRelatedProducts.js +186 -0
- package/app/composables/useRenderContext.js +45 -0
- package/app/composables/useScopedCss.js +106 -0
- package/app/composables/useSearch.js +35 -0
- package/app/composables/useSectionData.js +462 -0
- package/app/composables/useSectionShell.js +113 -0
- package/app/composables/useSidebar.js +69 -0
- package/app/composables/useStoreData.js +57 -0
- package/app/composables/useStoreInit.js +52 -0
- package/app/composables/useStorefront.js +239 -0
- package/app/composables/useT.js +21 -0
- package/app/composables/useTextStyles.js +84 -0
- package/app/composables/useThemeControls.js +34 -0
- package/app/composables/useThemeSeo.js +91 -0
- package/app/composables/useThemeTokens.js +171 -0
- package/app/composables/useWishlist.js +66 -0
- package/app/logic/components/AnnouncementBar.js +118 -0
- package/app/logic/components/CardGrid.js +125 -0
- package/app/logic/components/CardList.js +98 -0
- package/app/logic/components/CartDrawer.js +273 -0
- package/app/logic/components/CartItem.js +31 -0
- package/app/logic/components/ClassificationBento.js +6 -0
- package/app/logic/components/ClassificationCard.js +6 -0
- package/app/logic/components/ClassificationCarousel.js +115 -0
- package/app/logic/components/ClassificationEditorial.js +41 -0
- package/app/logic/components/ClassificationFeatured.js +57 -0
- package/app/logic/components/ClassificationGrid.js +6 -0
- package/app/logic/components/ClassificationNav.js +61 -0
- package/app/logic/components/ClassificationPart.js +72 -0
- package/app/logic/components/FooterBlock.js +91 -0
- package/app/logic/components/FooterSocials.js +60 -0
- package/app/logic/components/HeaderDropdownList.js +29 -0
- package/app/logic/components/HeaderIcon.js +68 -0
- package/app/logic/components/HeaderLogo.js +39 -0
- package/app/logic/components/HeaderNav.js +268 -0
- package/app/logic/components/MobileMenu.js +280 -0
- package/app/logic/components/ProductBuyButtons.js +66 -0
- package/app/logic/components/ProductCard.js +160 -0
- package/app/logic/components/ProductFilters.js +37 -0
- package/app/logic/components/ProductGallery.js +19 -0
- package/app/logic/components/ProductInfoBlock.js +411 -0
- package/app/logic/components/ProductLightbox.js +12 -0
- package/app/logic/components/ProductQuantity.js +31 -0
- package/app/logic/components/ProductVariantPicker.js +125 -0
- package/app/logic/components/QuickBuy.js +194 -0
- package/app/logic/components/SearchOverlay.js +41 -0
- package/app/logic/components/SidebarMenuItem.js +39 -0
- package/app/logic/components/SiteFooter.js +476 -0
- package/app/logic/components/SiteHeader.js +266 -0
- package/app/logic/components/WhatsAppButton.js +75 -0
- package/app/logic/layouts/default.js +66 -0
- package/app/logic/sections/AnnouncementBarSection.js +6 -0
- package/app/logic/sections/BlockCarousel.js +103 -0
- package/app/logic/sections/ClassificationListSection.js +40 -0
- package/app/logic/sections/ContactFormSection.js +294 -0
- package/app/logic/sections/ContactInfoSection.js +419 -0
- package/app/logic/sections/ContentNode.js +189 -0
- package/app/logic/sections/CountdownSection.js +254 -0
- package/app/logic/sections/CustomHtmlSection.js +51 -0
- package/app/logic/sections/DividerSection.js +31 -0
- package/app/logic/sections/FaqSection.js +53 -0
- package/app/logic/sections/FeatureListSection.js +6 -0
- package/app/logic/sections/FeaturedProductSection.js +40 -0
- package/app/logic/sections/FooterSection.js +16 -0
- package/app/logic/sections/FooterUtilitiesSection.js +24 -0
- package/app/logic/sections/GallerySection.js +232 -0
- package/app/logic/sections/HeaderSection.js +24 -0
- package/app/logic/sections/HeroSection.js +24 -0
- package/app/logic/sections/LogoListSection.js +6 -0
- package/app/logic/sections/MainClassificationSection.js +473 -0
- package/app/logic/sections/MainProductSection.js +211 -0
- package/app/logic/sections/MainSearchSection.js +6 -0
- package/app/logic/sections/MapSection.js +92 -0
- package/app/logic/sections/MediaContentSection.js +38 -0
- package/app/logic/sections/MediaWithTextSection.js +238 -0
- package/app/logic/sections/NewsletterSection.js +126 -0
- package/app/logic/sections/ProductHighlightSection.js +201 -0
- package/app/logic/sections/ProductListSection.js +45 -0
- package/app/logic/sections/ProductRecommendationsSection.js +113 -0
- package/app/logic/sections/RichTextSection.js +6 -0
- package/app/logic/sections/ScrollingTextSection.js +69 -0
- package/app/logic/sections/SearchHeaderSection.js +22 -0
- package/app/logic/sections/SlideshowSection.js +118 -0
- package/app/logic/sections/SpacerSection.js +37 -0
- package/app/logic/sections/SplitShowcaseSection.js +6 -0
- package/app/logic/sections/TestimonialListSection.js +6 -0
- package/app/logic/sections/VideoSection.js +235 -0
- package/app/pages/[category]/[product].vue +196 -0
- package/app/pages/__builder-preview.vue +38 -0
- package/app/pages/categories/[slug].vue +208 -0
- package/app/pages/checkout.vue +1972 -0
- package/app/pages/forgot-password.vue +115 -0
- package/app/pages/index.vue +64 -0
- package/app/pages/login.vue +585 -0
- package/app/pages/order-detail.vue +442 -0
- package/app/pages/pages/[handle].vue +58 -0
- package/app/pages/privacy-policy.vue +46 -0
- package/app/pages/resetpassword.vue +138 -0
- package/app/pages/resetverificationcode.vue +163 -0
- package/app/pages/result.vue +203 -0
- package/app/pages/return-policy.vue +50 -0
- package/app/pages/search.vue +100 -0
- package/app/pages/shipping-policy.vue +46 -0
- package/app/pages/user-profile/components/AddAddressModal.vue +452 -0
- package/app/pages/user-profile/components/Whishlist.vue +137 -0
- package/app/pages/user-profile/components/left-bar.vue +233 -0
- package/app/pages/user-profile/components/orders.vue +162 -0
- package/app/pages/user-profile/components/profile-details.vue +566 -0
- package/app/pages/user-profile/components/settings.vue +194 -0
- package/app/pages/user-profile/index.vue +252 -0
- package/app/pages/verificationcode.vue +188 -0
- package/app/plugins/analytics.client.js +99 -0
- package/app/plugins/geo.js +24 -0
- package/app/plugins/reveal.client.js +53 -0
- package/app/plugins/toast.js +10 -0
- package/app/plugins/vue-query.js +5 -0
- package/app/services/ApiServices.js +74 -0
- package/app/services/AuthBus.js +162 -0
- package/app/services/Authentication/AuthenticationService.js +133 -0
- package/app/services/Authentication/TokenService.js +62 -0
- package/app/services/CartServices.js +91 -0
- package/app/services/CartToken.js +26 -0
- package/app/services/CategoryServices.js +54 -0
- package/app/services/CheckoutServices.js +113 -0
- package/app/services/HomeServices.js +133 -0
- package/app/services/NewsletterServices.js +29 -0
- package/app/services/PaymentServices.js +71 -0
- package/app/services/PoliciesServices.js +16 -0
- package/app/services/ProductServices.js +79 -0
- package/app/services/SearchServices.js +18 -0
- package/app/services/UserServices.js +105 -0
- package/app/services/UtilsServices.js +46 -0
- package/app/services/WishlistServices.js +51 -0
- package/app/services/storefront.service.js +57 -0
- package/app/stores/userStore.js +13 -0
- package/app/theme/categoryTree.js +68 -0
- package/app/theme/controls.js +88 -0
- package/app/theme/registry.js +56 -0
- package/app/utils/analytics.js +864 -0
- package/app/utils/device.js +50 -0
- package/app/utils/eventBus.js +24 -0
- package/app/utils/icon-presets.js +24 -0
- package/app/utils/productOffers.js +118 -0
- package/app/utils/requestCache.js +36 -0
- package/app/utils/scrollToError.js +59 -0
- package/app/utils/toPlainError.js +30 -0
- package/bin/osimart-theme.mjs +282 -0
- package/nuxt/osi-sections.ts +56 -0
- package/nuxt.config.ts +60 -0
- package/package.json +44 -4
- package/public/css/input.css +29 -0
- package/public/css/output.css +2716 -0
- package/public/favicon-32x32.png +0 -0
- 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
|
+
}
|