@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.
- 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.ts +24 -0
- package/app/plugins/reveal.client.js +53 -0
- package/app/plugins/toast.js +10 -0
- package/app/plugins/vue-query.ts +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 +259 -0
- package/nuxt/osi-sections.ts +56 -0
- package/nuxt.config.ts +60 -0
- package/package.json +43 -3
- 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,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation settings -> CSS custom properties.
|
|
3
|
+
*
|
|
4
|
+
* Same contract as useThemeTokens: nothing in a template reads
|
|
5
|
+
* `settings.HEADER_MENU_GAP` directly, it reads `var(--osi-nav-gap)`. That's
|
|
6
|
+
* what lets the schema gain a field and the storefront follow without a
|
|
7
|
+
* component learning its name.
|
|
8
|
+
*
|
|
9
|
+
* Both functions read through useThemeGroup(), which falls back to its own
|
|
10
|
+
* DEFAULTS for any key the merchant's saved theme predates — so a theme
|
|
11
|
+
* installed before these settings existed renders the new defaults instead of
|
|
12
|
+
* `undefined` leaking into a style attribute.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const FONT_VAR = {
|
|
16
|
+
HEADING: "var(--osi-font-heading)",
|
|
17
|
+
BODY: "var(--osi-font-body)",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const WEIGHT = {
|
|
21
|
+
LIGHT: 300,
|
|
22
|
+
REGULAR: 400,
|
|
23
|
+
MEDIUM: 500,
|
|
24
|
+
SEMIBOLD: 600,
|
|
25
|
+
BOLD: 700,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Accepts 'MEDIUM' or a raw 500, because enums get refactored. */
|
|
29
|
+
function weightOf(value, fallback = 400) {
|
|
30
|
+
if (typeof value === "number") return value;
|
|
31
|
+
return WEIGHT[value] ?? (Number(value) || fallback);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fontOf(role) {
|
|
35
|
+
return FONT_VAR[role] ?? FONT_VAR.BODY;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function transformOf(value) {
|
|
39
|
+
return !value || value === "NONE" ? "none" : String(value).toLowerCase();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Letter spacing is stored in hundredths of an em, matching text_style(). */
|
|
43
|
+
function trackingOf(value) {
|
|
44
|
+
return `${(Number(value) || 0) / 100}em`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** #rrggbb + 0-100 -> rgba(). Hex only; the COLOR field never emits anything else. */
|
|
48
|
+
function withAlpha(hex, percent) {
|
|
49
|
+
const alpha = Math.max(0, Math.min(100, Number(percent) ?? 100)) / 100;
|
|
50
|
+
const match = /^#?([\da-f]{6})$/i.exec(String(hex ?? ""));
|
|
51
|
+
if (!match) return `rgba(0, 0, 0, ${alpha})`;
|
|
52
|
+
const int = parseInt(match[1], 16);
|
|
53
|
+
return `rgba(${(int >> 16) & 255}, ${(int >> 8) & 255}, ${int & 255}, ${alpha})`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Header inline navigation.
|
|
58
|
+
*
|
|
59
|
+
* Returns the variables plus the modifier classes for hover and active style,
|
|
60
|
+
* so SiteHeader binds one object and one array rather than assembling either.
|
|
61
|
+
*/
|
|
62
|
+
export function useHeaderNavStyle() {
|
|
63
|
+
const settings = useThemeGroup("HEADER");
|
|
64
|
+
|
|
65
|
+
const style = computed(() => {
|
|
66
|
+
const s = settings.value;
|
|
67
|
+
return {
|
|
68
|
+
// The gap applies to the nav element only. The header grid's own
|
|
69
|
+
// `gap: 1rem` between logo / nav / icons is untouched — a merchant
|
|
70
|
+
// setting link spacing to 0 must not glue the logo to the menu.
|
|
71
|
+
"--osi-nav-gap": `${s.HEADER_MENU_GAP}px`,
|
|
72
|
+
"--osi-nav-gap-row": `${s.HEADER_MENU_GAP_MOBILE}px`,
|
|
73
|
+
"--osi-nav-font": fontOf(s.HEADER_MENU_FONT),
|
|
74
|
+
"--osi-nav-size": `${s.HEADER_MENU_SIZE}px`,
|
|
75
|
+
"--osi-nav-weight": weightOf(s.HEADER_MENU_WEIGHT),
|
|
76
|
+
"--osi-nav-color": s.HEADER_MENU_COLOR || "var(--osi-text)",
|
|
77
|
+
"--osi-nav-hover-color": s.HEADER_MENU_HOVER_COLOR || "var(--osi-nav-color)",
|
|
78
|
+
"--osi-nav-tracking": trackingOf(s.HEADER_MENU_LETTER_SPACING),
|
|
79
|
+
"--osi-nav-transform": transformOf(s.HEADER_MENU_TRANSFORM),
|
|
80
|
+
"--osi-nav-active-color": s.HEADER_MENU_ACTIVE_COLOR || "var(--osi-nav-color)",
|
|
81
|
+
"--osi-nav-active-bg": s.HEADER_MENU_ACTIVE_BACKGROUND || "transparent",
|
|
82
|
+
"--osi-nav-underline-w": `${s.HEADER_MENU_ACTIVE_UNDERLINE_WIDTH}px`,
|
|
83
|
+
"--osi-nav-underline-offset": `${s.HEADER_MENU_ACTIVE_UNDERLINE_OFFSET}px`,
|
|
84
|
+
// Submenu (dropdown / mega menu) colours: the header's colour scheme
|
|
85
|
+
// unless the Menu block sets its own.
|
|
86
|
+
"--osi-dd-bg": s.HEADER_MENU_DROPDOWN_BG || "var(--osi-bg, #fff)",
|
|
87
|
+
"--osi-dd-text": s.HEADER_MENU_DROPDOWN_COLOR || "var(--osi-text, #111)",
|
|
88
|
+
...(s.HEADER_MENU_DROPDOWN_HOVER_BG ? { "--osi-dd-hover-bg": s.HEADER_MENU_DROPDOWN_HOVER_BG } : {}),
|
|
89
|
+
...(s.HEADER_MENU_DROPDOWN_HOVER_COLOR ? { "--osi-dd-hover-color": s.HEADER_MENU_DROPDOWN_HOVER_COLOR } : {}),
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const navClass = computed(() => [
|
|
94
|
+
`osi-nav--hover-${String(settings.value.HEADER_MENU_HOVER_EFFECT).toLowerCase()}`,
|
|
95
|
+
`osi-nav--active-${String(settings.value.HEADER_MENU_ACTIVE_STYLE).toLowerCase()}`,
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
const buttonStyle = computed(() => {
|
|
99
|
+
const s = settings.value;
|
|
100
|
+
return {
|
|
101
|
+
"--osi-menu-btn-size": `${s.HEADER_MENU_BUTTON_SIZE}px`,
|
|
102
|
+
"--osi-menu-btn-radius": `${s.HEADER_MENU_BUTTON_RADIUS}px`,
|
|
103
|
+
"--osi-menu-btn-bg": s.HEADER_MENU_BUTTON_BACKGROUND || "transparent",
|
|
104
|
+
"--osi-menu-btn-hover-bg":
|
|
105
|
+
s.HEADER_MENU_BUTTON_HOVER_BACKGROUND || "var(--osi-menu-btn-bg)",
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return { style, navClass, buttonStyle };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Which nav item represents the page we're on.
|
|
114
|
+
*
|
|
115
|
+
* Prefix match, not equality: /categories/shoes/boots should light up the
|
|
116
|
+
* "Shoes" link. Guarded against '/' , which prefixes everything and would
|
|
117
|
+
* otherwise mark the Home link active on every page of the store.
|
|
118
|
+
*/
|
|
119
|
+
export function useNavActive() {
|
|
120
|
+
const route = useRoute();
|
|
121
|
+
|
|
122
|
+
function isActive(item) {
|
|
123
|
+
if (!item?.url || item.external) return false;
|
|
124
|
+
const path = route.path.replace(/\/+$/, "") || "/";
|
|
125
|
+
const url = item.url.replace(/\/+$/, "") || "/";
|
|
126
|
+
if (url === "/") return path === "/";
|
|
127
|
+
if (path === url) return true;
|
|
128
|
+
if (path.startsWith(`${url}/`)) return true;
|
|
129
|
+
return (item.children ?? []).some(isActive);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { isActive };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Sidebar / mobile navigation.
|
|
137
|
+
*
|
|
138
|
+
* Scheme first, overrides on top — the rule the header already follows. An
|
|
139
|
+
* override left empty resolves to the scheme's variable rather than to a
|
|
140
|
+
* hardcoded colour, so editing one palette still restyles the drawer.
|
|
141
|
+
*/
|
|
142
|
+
export function useSidebarStyle() {
|
|
143
|
+
const settings = useThemeGroup("SIDEBAR");
|
|
144
|
+
const scheme = useColorScheme(computed(() => settings.value.SIDEBAR_COLOR_SCHEME));
|
|
145
|
+
|
|
146
|
+
const enabled = computed(() => settings.value.SIDEBAR_ENABLED !== false);
|
|
147
|
+
const position = computed(() => settings.value.SIDEBAR_POSITION || "START");
|
|
148
|
+
const animation = computed(() => settings.value.SIDEBAR_ANIMATION || "SLIDE");
|
|
149
|
+
const submenuMode = computed(() => settings.value.SIDEBAR_SUBMENU_MODE || "ACCORDION");
|
|
150
|
+
|
|
151
|
+
const duration = computed(() =>
|
|
152
|
+
animation.value === "NONE" ? 0 : Number(settings.value.SIDEBAR_ANIMATION_DURATION) || 0
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
console.log("animation",animation.value)
|
|
156
|
+
console.log("duration",duration.value);
|
|
157
|
+
const style = computed(() => {
|
|
158
|
+
const s = settings.value;
|
|
159
|
+
const base = { ...scheme.value.style };
|
|
160
|
+
|
|
161
|
+
if (s.SIDEBAR_BACKGROUND) base["--osi-bg"] = s.SIDEBAR_BACKGROUND;
|
|
162
|
+
if (s.SIDEBAR_TEXT_COLOR) base["--osi-text"] = s.SIDEBAR_TEXT_COLOR;
|
|
163
|
+
if (s.SIDEBAR_BORDER_COLOR) base["--osi-border"] = s.SIDEBAR_BORDER_COLOR;
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
...base,
|
|
167
|
+
|
|
168
|
+
"--osi-sb-width": `${s.SIDEBAR_WIDTH}px`,
|
|
169
|
+
"--osi-sb-width-mobile": `${s.SIDEBAR_WIDTH_MOBILE}vw`,
|
|
170
|
+
"--osi-sb-duration": `${duration.value}ms`,
|
|
171
|
+
"--osi-sb-radius": `${s.SIDEBAR_RADIUS}px`,
|
|
172
|
+
"--osi-sb-border-width": s.SIDEBAR_SHOW_BORDER ? `${s.SIDEBAR_BORDER_WIDTH}px` : "0px",
|
|
173
|
+
|
|
174
|
+
"--osi-sb-muted": s.SIDEBAR_MUTED_COLOR || "color-mix(in srgb, var(--osi-text) 60%, transparent)",
|
|
175
|
+
"--osi-sb-link": s.SIDEBAR_LINK_COLOR || "var(--osi-text)",
|
|
176
|
+
"--osi-sb-link-hover": s.SIDEBAR_LINK_HOVER_COLOR || "var(--osi-sb-link)",
|
|
177
|
+
"--osi-sb-active": s.SIDEBAR_ACTIVE_COLOR || "var(--osi-sb-link)",
|
|
178
|
+
"--osi-sb-active-bg": s.SIDEBAR_ACTIVE_BACKGROUND || "transparent",
|
|
179
|
+
"--osi-sb-icon": s.SIDEBAR_ICON_COLOR || "var(--osi-text)",
|
|
180
|
+
|
|
181
|
+
"--osi-sb-pad-top": `${s.SIDEBAR_PADDING_TOP}px`,
|
|
182
|
+
"--osi-sb-pad-bottom": `${s.SIDEBAR_PADDING_BOTTOM}px`,
|
|
183
|
+
"--osi-sb-pad-x": `${s.SIDEBAR_PADDING_X}px`,
|
|
184
|
+
"--osi-sb-link-gap": `${s.SIDEBAR_LINK_GAP}px`,
|
|
185
|
+
"--osi-sb-link-pad-y": `${s.SIDEBAR_LINK_PADDING_Y}px`,
|
|
186
|
+
"--osi-sb-group-gap": `${s.SIDEBAR_GROUP_GAP}px`,
|
|
187
|
+
"--osi-sb-indent": `${s.SIDEBAR_SUBMENU_INDENT}px`,
|
|
188
|
+
|
|
189
|
+
"--osi-sb-font": fontOf(s.SIDEBAR_LINK_FONT),
|
|
190
|
+
"--osi-sb-size": `${s.SIDEBAR_LINK_SIZE}px`,
|
|
191
|
+
"--osi-sb-size-mobile": `${s.SIDEBAR_LINK_SIZE_MOBILE}px`,
|
|
192
|
+
"--osi-sb-weight": weightOf(s.SIDEBAR_LINK_WEIGHT, 500),
|
|
193
|
+
"--osi-sb-line-height": (Number(s.SIDEBAR_LINK_LINE_HEIGHT) || 130) / 100,
|
|
194
|
+
"--osi-sb-tracking": trackingOf(s.SIDEBAR_LINK_LETTER_SPACING),
|
|
195
|
+
"--osi-sb-transform": transformOf(s.SIDEBAR_LINK_TRANSFORM),
|
|
196
|
+
|
|
197
|
+
"--osi-sb-sub-size": `${s.SIDEBAR_SUBMENU_SIZE}px`,
|
|
198
|
+
"--osi-sb-sub-weight": weightOf(s.SIDEBAR_SUBMENU_WEIGHT),
|
|
199
|
+
"--osi-sb-sub-color": s.SIDEBAR_SUBMENU_COLOR || "var(--osi-sb-link)",
|
|
200
|
+
|
|
201
|
+
"--osi-sb-arrow-size": `${s.SIDEBAR_ARROW_SIZE}px`,
|
|
202
|
+
"--osi-sb-header-height": `${s.SIDEBAR_HEADER_HEIGHT}px`,
|
|
203
|
+
"--osi-sb-logo-width": `${s.SIDEBAR_LOGO_WIDTH}px`,
|
|
204
|
+
};
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const overlayStyle = computed(() => ({
|
|
208
|
+
background: withAlpha(
|
|
209
|
+
settings.value.SIDEBAR_OVERLAY_COLOR || "#000000",
|
|
210
|
+
settings.value.SIDEBAR_OVERLAY_OPACITY
|
|
211
|
+
),
|
|
212
|
+
transitionDuration: `${duration.value}ms`,
|
|
213
|
+
}));
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
settings, scheme, style, overlayStyle,
|
|
217
|
+
enabled, position, animation, duration, submenuMode,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// app/composables/usePlaceholders.js
|
|
2
|
+
//
|
|
3
|
+
// Dummy products and classifications for a store whose catalog is still empty,
|
|
4
|
+
// so the builder shows a laid-out page instead of blank rows.
|
|
5
|
+
//
|
|
6
|
+
// Scope is exactly those two. Menus are real rows seeded at store creation, so
|
|
7
|
+
// nothing here touches them, and section media is left alone — a hero with no
|
|
8
|
+
// image is the merchant's own empty state to fill.
|
|
9
|
+
//
|
|
10
|
+
// Three rules hold this together:
|
|
11
|
+
//
|
|
12
|
+
// 1. Rendered, never stored. Nothing here is a Product, Category or Menu row.
|
|
13
|
+
// It cannot reach checkout, search, the sitemap, an export, or a save — the
|
|
14
|
+
// builder posts its own state back, and this never enters that state.
|
|
15
|
+
//
|
|
16
|
+
// 2. Builder iframe only. The gate is usePreviewOverlay().active, which
|
|
17
|
+
// useBuilderPreview.start() sets after confirming window.parent !== window.
|
|
18
|
+
// A live storefront with an empty catalog shows a real empty state, and an
|
|
19
|
+
// unpublished theme opened directly in a browser does too.
|
|
20
|
+
//
|
|
21
|
+
// 3. Raw shapes, not normalized ones. A placeholder category is shaped like a
|
|
22
|
+
// row out of the categories endpoint, and a placeholder product like one out
|
|
23
|
+
// of ProductListSerializer. They enter at useSectionData, upstream of
|
|
24
|
+
// useClassificationItems and ProductCard, so every consumer downstream —
|
|
25
|
+
// PARENTS_ONLY, LIMIT, HIDE_EMPTY, the URL table, the card styling — runs
|
|
26
|
+
// over them unchanged. Injecting normalized items instead would mean each of
|
|
27
|
+
// those had to learn about placeholders.
|
|
28
|
+
//
|
|
29
|
+
// Copy comes from the theme payload (default_theme.json -> `placeholders`) when
|
|
30
|
+
// it's there, so wording can improve server-side without a storefront deploy.
|
|
31
|
+
// The lists below are the fallback for a theme that doesn't carry the key.
|
|
32
|
+
|
|
33
|
+
import { computed } from "vue";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* When empty lists get filled.
|
|
37
|
+
*
|
|
38
|
+
* PREVIEW only when the storefront is framed by the dashboard
|
|
39
|
+
* ALWAYS any time a list comes back empty, including on the real URL
|
|
40
|
+
*
|
|
41
|
+
* PREVIEW is the point of this file: a shopper on a live store must never see
|
|
42
|
+
* invented merchandise. ALWAYS exists for debugging the placeholder rendering
|
|
43
|
+
* itself outside the builder — it is not a mode to ship.
|
|
44
|
+
*/
|
|
45
|
+
const WHEN_EMPTY = "PREVIEW";
|
|
46
|
+
|
|
47
|
+
const FALLBACK = {
|
|
48
|
+
products: [
|
|
49
|
+
{ title: "Product title 1", price: 2900, compare_at_price: null },
|
|
50
|
+
{ title: "Product title 2", price: 8900, compare_at_price: 11000 },
|
|
51
|
+
{ title: "Product title 3", price: 4500, compare_at_price: null },
|
|
52
|
+
{ title: "Product title 4", price: 12000, compare_at_price: null },
|
|
53
|
+
{ title: "Product title 5", price: 6500, compare_at_price: 8000 },
|
|
54
|
+
{ title: "Product title 6", price: 3800, compare_at_price: null },
|
|
55
|
+
{ title: "Product title 7", price: 15000, compare_at_price: null },
|
|
56
|
+
{ title: "Product title 8", price: 9500, compare_at_price: null },
|
|
57
|
+
],
|
|
58
|
+
classifications: [
|
|
59
|
+
{ title: "Category title 1" },
|
|
60
|
+
{ title: "Category title 2" },
|
|
61
|
+
{ title: "Category title 3" },
|
|
62
|
+
{ title: "Category title 4" },
|
|
63
|
+
{ title: "Category title 5" },
|
|
64
|
+
{ title: "Category title 6" },
|
|
65
|
+
],
|
|
66
|
+
currency: "USD",
|
|
67
|
+
price_divisor: 100,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The art on a placeholder card.
|
|
72
|
+
*
|
|
73
|
+
* An inline SVG rather than a remote URL: the preview runs in an iframe, and a
|
|
74
|
+
* placeholder that depends on the network is a placeholder that sometimes
|
|
75
|
+
* renders as a broken-image icon. useMediaUrl passes `data:` straight through,
|
|
76
|
+
* so this drops into any `:src` that expects a media path.
|
|
77
|
+
*
|
|
78
|
+
* The hue shifts per index so a grid of six doesn't read as one tile repeated.
|
|
79
|
+
*/
|
|
80
|
+
export function placeholderImage({ w = 1200, h = 900, index = 0, tone = "light" } = {}) {
|
|
81
|
+
const hue = (index * 47 + 210) % 360;
|
|
82
|
+
const bg = tone === "dark" ? `hsl(${hue} 14% 20%)` : `hsl(${hue} 18% 93%)`;
|
|
83
|
+
const fg = tone === "dark" ? `hsl(${hue} 12% 32%)` : `hsl(${hue} 16% 80%)`;
|
|
84
|
+
|
|
85
|
+
const svg =
|
|
86
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}">` +
|
|
87
|
+
`<rect width="${w}" height="${h}" fill="${bg}"/>` +
|
|
88
|
+
`<g fill="${fg}">` +
|
|
89
|
+
`<circle cx="${w * 0.34}" cy="${h * 0.32}" r="${Math.min(w, h) * 0.072}"/>` +
|
|
90
|
+
`<path d="M${w * 0.08} ${h * 0.82} L${w * 0.38} ${h * 0.42} L${w * 0.56} ${h * 0.64} ` +
|
|
91
|
+
`L${w * 0.7} ${h * 0.51} L${w * 0.92} ${h * 0.82} Z"/>` +
|
|
92
|
+
`</g></svg>`;
|
|
93
|
+
|
|
94
|
+
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function usePlaceholders() {
|
|
98
|
+
// Read the bootstrap state directly rather than through useStorefront(),
|
|
99
|
+
// which would pull in the preview overlay merge for a value we only need one
|
|
100
|
+
// key out of.
|
|
101
|
+
const data = useState("storefront", () => null);
|
|
102
|
+
|
|
103
|
+
const { active: isPreview } = usePreviewOverlay();
|
|
104
|
+
const route = useRoute();
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Is this page being rendered inside the dashboard?
|
|
108
|
+
*
|
|
109
|
+
* Three signals, because the obvious one alone isn't enough:
|
|
110
|
+
*
|
|
111
|
+
* isPreview usePreviewOverlay().active, set by useBuilderPreview
|
|
112
|
+
* .start(). Authoritative, but it only flips once the READY
|
|
113
|
+
* handshake has been set up in app.vue's onMounted — so it
|
|
114
|
+
* is false during SSR and for the first moments after
|
|
115
|
+
* hydration, and any section that resolved its rows before
|
|
116
|
+
* then saw an empty catalog with placeholders switched off.
|
|
117
|
+
*
|
|
118
|
+
* ?osi_preview a query param on the iframe src. The only signal that
|
|
119
|
+
* exists during SSR, so it's the one that makes the first
|
|
120
|
+
* paint correct instead of empty-then-filled. Optional —
|
|
121
|
+
* add it to the iframe URL in the dashboard when you want
|
|
122
|
+
* the flash gone.
|
|
123
|
+
*
|
|
124
|
+
* window.parent the same framing test start() makes, done directly so it
|
|
125
|
+
* doesn't wait on the handshake. Client-only by nature.
|
|
126
|
+
*/
|
|
127
|
+
const framed = computed(() => {
|
|
128
|
+
if (isPreview.value === true) return true;
|
|
129
|
+
if (
|
|
130
|
+
route?.query?.osi_preview !== undefined ||
|
|
131
|
+
route?.query?.preview !== undefined ||
|
|
132
|
+
route?.query?.builder !== undefined ||
|
|
133
|
+
route?.path?.includes("__builder-preview")
|
|
134
|
+
) {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
if (import.meta.client) {
|
|
138
|
+
try {
|
|
139
|
+
return window.self !== window.top || window.parent !== window;
|
|
140
|
+
} catch {
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const allowed = computed(() => WHEN_EMPTY === "ALWAYS" || framed.value);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Theme copy wins per key, and only when it actually has rows.
|
|
151
|
+
*
|
|
152
|
+
* A plain spread was wrong: a theme shipping `placeholders: { products: [] }`
|
|
153
|
+
* overrode the fallback with nothing, and the grid stayed empty with no error
|
|
154
|
+
* anywhere. An empty list in the payload means "nothing to say here", not
|
|
155
|
+
* "render nothing".
|
|
156
|
+
*/
|
|
157
|
+
const source = computed(() => {
|
|
158
|
+
const custom = data.value?.theme?.placeholders ?? {};
|
|
159
|
+
return {
|
|
160
|
+
...FALLBACK,
|
|
161
|
+
...custom,
|
|
162
|
+
products: custom.products?.length ? custom.products : FALLBACK.products,
|
|
163
|
+
classifications: custom.classifications?.length
|
|
164
|
+
? custom.classifications
|
|
165
|
+
: FALLBACK.classifications,
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const divisor = computed(() => Number(source.value.price_divisor) || 100);
|
|
170
|
+
|
|
171
|
+
/** Shaped like a row from the categories endpoint. */
|
|
172
|
+
function asClassificationRow(raw, index) {
|
|
173
|
+
return {
|
|
174
|
+
is_placeholder: true,
|
|
175
|
+
type: "CATEGORY",
|
|
176
|
+
id: `ph-cat-${index}`,
|
|
177
|
+
name: raw.title,
|
|
178
|
+
// No slug: classificationUrl falls through to "/" rather than building a
|
|
179
|
+
// path that would 404 the moment someone opened the preview in a tab.
|
|
180
|
+
slugified_name: null,
|
|
181
|
+
url: null,
|
|
182
|
+
featured: false,
|
|
183
|
+
image: { path: placeholderImage({ w: 900, h: 700, index }) },
|
|
184
|
+
order: index + 1,
|
|
185
|
+
parent_category: null,
|
|
186
|
+
// A real number, not 0: HIDE_EMPTY would filter the whole set away.
|
|
187
|
+
products_count: raw.products_count ?? 12,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Shaped like a row from ProductListSerializer. */
|
|
192
|
+
function asProductRow(raw, index) {
|
|
193
|
+
const media = { id: null, path: placeholderImage({ w: 800, h: 1000, index }) };
|
|
194
|
+
const price = Number(raw.price ?? 0) / divisor.value;
|
|
195
|
+
const compare = raw.compare_at_price
|
|
196
|
+
? Number(raw.compare_at_price) / divisor.value
|
|
197
|
+
: null;
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
is_placeholder: true,
|
|
201
|
+
id: `ph-prod-${index}`,
|
|
202
|
+
name: raw.title,
|
|
203
|
+
page_title: raw.title,
|
|
204
|
+
slugified_name: null,
|
|
205
|
+
url: null,
|
|
206
|
+
description: "",
|
|
207
|
+
status: "In Stock",
|
|
208
|
+
stock: null,
|
|
209
|
+
remaining_stock: 25,
|
|
210
|
+
quantity_unit: null,
|
|
211
|
+
quantity_step: 1,
|
|
212
|
+
sku: "",
|
|
213
|
+
price_range: String(compare ?? price),
|
|
214
|
+
discounted_price_range: compare ? String(price) : null,
|
|
215
|
+
main_image: media,
|
|
216
|
+
gallery: [{ id: null, media, order: 0, product: `ph-prod-${index}` }],
|
|
217
|
+
categories: [],
|
|
218
|
+
collections: [],
|
|
219
|
+
resources: [],
|
|
220
|
+
sections: [],
|
|
221
|
+
brand: null,
|
|
222
|
+
vary_by: [],
|
|
223
|
+
wishlisted: false,
|
|
224
|
+
product_variants: [
|
|
225
|
+
{
|
|
226
|
+
id: `ph-var-${index}`,
|
|
227
|
+
price,
|
|
228
|
+
compare_at_price: compare,
|
|
229
|
+
discounted_price_range: compare ? String(price) : null,
|
|
230
|
+
cost: null,
|
|
231
|
+
values: [],
|
|
232
|
+
gallery: [],
|
|
233
|
+
remaining_stock: 25,
|
|
234
|
+
discount: null,
|
|
235
|
+
sku: "",
|
|
236
|
+
max_quantity_per_user: null,
|
|
237
|
+
// False on purpose: a placeholder must never render as sold out, and
|
|
238
|
+
// the card reads stock off the variant.
|
|
239
|
+
track_inventory: false,
|
|
240
|
+
coming_soon: false,
|
|
241
|
+
restock_threshold: null,
|
|
242
|
+
allow_restock_notifications: false,
|
|
243
|
+
unlisted_at: null,
|
|
244
|
+
archived_at: null,
|
|
245
|
+
resources: [],
|
|
246
|
+
sales: [],
|
|
247
|
+
},
|
|
248
|
+
],
|
|
249
|
+
published_at: null,
|
|
250
|
+
unlisted_at: null,
|
|
251
|
+
archived_at: null,
|
|
252
|
+
page_views: null,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Repeats the sample set when a section asks for more than we ship, so
|
|
258
|
+
* LIMIT: 12 doesn't silently render 8 and make the grid look broken.
|
|
259
|
+
*/
|
|
260
|
+
function take(list, count, map) {
|
|
261
|
+
if (!list?.length || count <= 0) {
|
|
262
|
+
if (import.meta.dev && count > 0) {
|
|
263
|
+
console.warn(
|
|
264
|
+
"[usePlaceholders] nothing to render: the sample list is empty. " +
|
|
265
|
+
"Check theme.placeholders in the bootstrap payload."
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
return Array.from({ length: count }, (_, i) => map(list[i % list.length], i));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* @param {Array} real what the backend resolved
|
|
275
|
+
* @param {number} count how many the section wants
|
|
276
|
+
* @returns the real rows when there are any, placeholders when there aren't
|
|
277
|
+
* and we're in the editor, an empty array otherwise
|
|
278
|
+
*/
|
|
279
|
+
const products = (real, count) =>
|
|
280
|
+
real?.length
|
|
281
|
+
? real
|
|
282
|
+
: allowed.value
|
|
283
|
+
? take(source.value.products, count, asProductRow)
|
|
284
|
+
: [];
|
|
285
|
+
|
|
286
|
+
const classifications = (real, count) =>
|
|
287
|
+
real?.length
|
|
288
|
+
? real
|
|
289
|
+
: allowed.value
|
|
290
|
+
? take(source.value.classifications, count, asClassificationRow)
|
|
291
|
+
: [];
|
|
292
|
+
|
|
293
|
+
return { products, classifications, allowed };
|
|
294
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import PoliciesServices from "#osi/services/PoliciesServices";
|
|
2
|
+
|
|
3
|
+
export function usePolicies() {
|
|
4
|
+
const policies = useState("store-policies-data", () => ({}));
|
|
5
|
+
const loading = useState("store-policies-loading", () => false);
|
|
6
|
+
|
|
7
|
+
async function fetchPolicies() {
|
|
8
|
+
if (Object.keys(policies.value).length > 0) return policies.value;
|
|
9
|
+
loading.value = true;
|
|
10
|
+
try {
|
|
11
|
+
const data = await PoliciesServices.getPolicies();
|
|
12
|
+
policies.value = data || {};
|
|
13
|
+
} catch (error) {
|
|
14
|
+
console.error("[usePolicies] Error loading policies:", error);
|
|
15
|
+
} finally {
|
|
16
|
+
loading.value = false;
|
|
17
|
+
}
|
|
18
|
+
return policies.value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
policies,
|
|
23
|
+
loading,
|
|
24
|
+
fetchPolicies,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which images a card shows, and the carousel around them.
|
|
3
|
+
*
|
|
4
|
+
* The image set is derived, not stored. It changes when quick-shop opens, when
|
|
5
|
+
* a variant is picked, and when a colour swatch is clicked — Terazar's
|
|
6
|
+
* `currentImages` computed handled all three and it's lifted whole here,
|
|
7
|
+
* because getting it subtly wrong shows the wrong colourway to a shopper.
|
|
8
|
+
*
|
|
9
|
+
* The three branches, in the order they're checked:
|
|
10
|
+
*
|
|
11
|
+
* 1. quick-shop open -> the selected variant's gallery; failing that, the
|
|
12
|
+
* galleries of every variant matching any current selection; failing that,
|
|
13
|
+
* the product's own main image + gallery
|
|
14
|
+
* 2. a colour selected on the closed card -> that colour's variant gallery,
|
|
15
|
+
* or the main image alone
|
|
16
|
+
* 3. nothing selected -> main image + gallery
|
|
17
|
+
*
|
|
18
|
+
* `__mediaKey` exists because the same media object can arrive through both a
|
|
19
|
+
* variant gallery and the product gallery; without deduping, the carousel shows
|
|
20
|
+
* the same photo twice and the dots lie about how many images there are.
|
|
21
|
+
*/
|
|
22
|
+
export function useProductCardMedia({
|
|
23
|
+
product,
|
|
24
|
+
quickShopOpen,
|
|
25
|
+
selectedVariant,
|
|
26
|
+
selectedOptions,
|
|
27
|
+
selectedColor,
|
|
28
|
+
}) {
|
|
29
|
+
const p = computed(() => toValue(product) ?? {});
|
|
30
|
+
const open = computed(() => !!toValue(quickShopOpen));
|
|
31
|
+
|
|
32
|
+
const { mediaUrl, posterUrl, mediaUrlAs } = useMediaUrl();
|
|
33
|
+
const { isImage, isVideo } = useMediaTypes();
|
|
34
|
+
|
|
35
|
+
const isHovering = ref(false);
|
|
36
|
+
|
|
37
|
+
const key = (media) => media?.id || media?.path;
|
|
38
|
+
const wrap = (media) => ({ ...media, __mediaKey: key(media) });
|
|
39
|
+
|
|
40
|
+
function productOwnImages() {
|
|
41
|
+
const images = [];
|
|
42
|
+
if (p.value.main_image) images.push(wrap(p.value.main_image));
|
|
43
|
+
if (p.value.gallery?.length) {
|
|
44
|
+
images.push(...p.value.gallery.map((g) => wrap(g.media)));
|
|
45
|
+
}
|
|
46
|
+
return images;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const currentImages = computed(() => {
|
|
50
|
+
let images = [];
|
|
51
|
+
const variant = toValue(selectedVariant);
|
|
52
|
+
const options = toValue(selectedOptions) ?? {};
|
|
53
|
+
const colour = toValue(selectedColor);
|
|
54
|
+
|
|
55
|
+
if (open.value) {
|
|
56
|
+
if (variant?.gallery?.length) {
|
|
57
|
+
images = variant.gallery.map((g) => wrap(g.media));
|
|
58
|
+
} else {
|
|
59
|
+
const selectedValues = Object.values(options);
|
|
60
|
+
|
|
61
|
+
if (selectedValues.length) {
|
|
62
|
+
const matching =
|
|
63
|
+
p.value.product_variants?.filter((v) =>
|
|
64
|
+
v.values.some((val) => selectedValues.includes(val.id))
|
|
65
|
+
) ?? [];
|
|
66
|
+
|
|
67
|
+
matching.forEach((v) => {
|
|
68
|
+
v.gallery?.forEach((g) => images.push(wrap(g.media)));
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!images.length) images = productOwnImages();
|
|
73
|
+
}
|
|
74
|
+
} else if (colour) {
|
|
75
|
+
const variantForColour = p.value.product_variants?.find((v) =>
|
|
76
|
+
v.values.some((val) => val.id === colour.id)
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
images = variantForColour?.gallery?.length
|
|
80
|
+
? variantForColour.gallery.map((g) => wrap(g.media))
|
|
81
|
+
: p.value.main_image
|
|
82
|
+
? [wrap(p.value.main_image)]
|
|
83
|
+
: [];
|
|
84
|
+
} else {
|
|
85
|
+
images = productOwnImages();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
return images.filter((image) => {
|
|
90
|
+
if (!image.__mediaKey || seen.has(image.__mediaKey)) return false;
|
|
91
|
+
seen.add(image.__mediaKey);
|
|
92
|
+
return true;
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/** The second image, revealed on hover when the theme asks for it. */
|
|
97
|
+
const hoverImage = computed(() => currentImages.value[1] ?? null);
|
|
98
|
+
|
|
99
|
+
const carousel = useCarousel({
|
|
100
|
+
items: () => currentImages.value,
|
|
101
|
+
loop: () => currentImages.value.length > 1,
|
|
102
|
+
minLoopSlides: 3,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Advance to the first image only when a variant was actually picked — not
|
|
106
|
+
// every time currentImages recomputes. Opening quick-shop with nothing
|
|
107
|
+
// selected recomputes it too, and jumping the carousel then looks like a bug.
|
|
108
|
+
watch(
|
|
109
|
+
() => toValue(selectedVariant),
|
|
110
|
+
(variant) => {
|
|
111
|
+
nextTick(() => {
|
|
112
|
+
carousel.update();
|
|
113
|
+
if (variant && open.value && currentImages.value.length > 1) {
|
|
114
|
+
carousel.goTo(0);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const src = (media) => mediaUrl(media);
|
|
121
|
+
const poster = (media) => posterUrl(media, media?.poster);
|
|
122
|
+
const sourceAs = (media, extension) => mediaUrlAs(media, extension);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
currentImages,
|
|
126
|
+
hoverImage,
|
|
127
|
+
isHovering,
|
|
128
|
+
isImage,
|
|
129
|
+
isVideo,
|
|
130
|
+
src,
|
|
131
|
+
poster,
|
|
132
|
+
sourceAs,
|
|
133
|
+
...carousel,
|
|
134
|
+
};
|
|
135
|
+
}
|