@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,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drag-to-scroll for a horizontal row (the variant swatch strips).
|
|
3
|
+
*
|
|
4
|
+
* Terazar kept `variantContainers` as a plain object of raw elements keyed by
|
|
5
|
+
* variant-type id, plus startDrag/doDrag/stopDrag. Three changes:
|
|
6
|
+
*
|
|
7
|
+
* - pointer events instead of mouse events, so a stylus and touch work
|
|
8
|
+
* - the move/up listeners live on the element, not the component, so a drag
|
|
9
|
+
* that ends outside the row still releases (the old one left
|
|
10
|
+
* isDragScrolling true and the row stayed "stuck" to the cursor)
|
|
11
|
+
* - a small movement threshold, so a click on a swatch isn't swallowed as a
|
|
12
|
+
* drag — the original fired preventDefault on every move, including the 1px
|
|
13
|
+
* jitter of a normal click
|
|
14
|
+
*/
|
|
15
|
+
const DRAG_THRESHOLD = 4;
|
|
16
|
+
|
|
17
|
+
export function useDragScroll() {
|
|
18
|
+
const containers = ref({});
|
|
19
|
+
const dragging = ref(null);
|
|
20
|
+
|
|
21
|
+
let startX = 0;
|
|
22
|
+
let startScroll = 0;
|
|
23
|
+
let moved = false;
|
|
24
|
+
|
|
25
|
+
/** :ref="(el) => setRef(el, type.id)" */
|
|
26
|
+
function setRef(el, id) {
|
|
27
|
+
if (el) containers.value[id] = el;
|
|
28
|
+
else delete containers.value[id];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function onPointerDown(event, id) {
|
|
32
|
+
const el = containers.value[id];
|
|
33
|
+
if (!el || el.scrollWidth <= el.clientWidth) return;
|
|
34
|
+
|
|
35
|
+
dragging.value = id;
|
|
36
|
+
moved = false;
|
|
37
|
+
startX = event.pageX;
|
|
38
|
+
startScroll = el.scrollLeft;
|
|
39
|
+
el.setPointerCapture?.(event.pointerId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function onPointerMove(event, id) {
|
|
43
|
+
if (dragging.value !== id) return;
|
|
44
|
+
const el = containers.value[id];
|
|
45
|
+
if (!el) return;
|
|
46
|
+
|
|
47
|
+
const walk = event.pageX - startX;
|
|
48
|
+
if (!moved && Math.abs(walk) < DRAG_THRESHOLD) return;
|
|
49
|
+
|
|
50
|
+
moved = true;
|
|
51
|
+
event.preventDefault();
|
|
52
|
+
el.scrollLeft = startScroll - walk * 2;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function onPointerUp(event, id) {
|
|
56
|
+
if (dragging.value !== id) return;
|
|
57
|
+
containers.value[id]?.releasePointerCapture?.(event.pointerId);
|
|
58
|
+
dragging.value = null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** True right after a drag — let the click handler bail instead of selecting. */
|
|
62
|
+
const wasDragged = () => moved;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
containers,
|
|
66
|
+
dragging,
|
|
67
|
+
setRef,
|
|
68
|
+
onPointerDown,
|
|
69
|
+
onPointerMove,
|
|
70
|
+
onPointerUp,
|
|
71
|
+
wasDragged,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Footer social links: the list, and how each item is displayed.
|
|
3
|
+
*
|
|
4
|
+
* Shared by SiteFooter (which needs the count to decide whether a column or a
|
|
5
|
+
* bottom-bar zone renders at all) and FooterSocials (which renders the list).
|
|
6
|
+
* Without this the two would each resolve the list and could disagree.
|
|
7
|
+
*
|
|
8
|
+
* The important rule here: CONTENT and STYLING are independent.
|
|
9
|
+
*
|
|
10
|
+
* showIcon / showText come from FOOTER_SOCIAL_DISPLAY only
|
|
11
|
+
* iconStyle comes from FOOTER_SOCIAL_ICON_STYLE only
|
|
12
|
+
*
|
|
13
|
+
* Picking a circle or a square must never remove the glyph. The previous
|
|
14
|
+
* template gated the <svg> on a computed that mixed the two, so a shape change
|
|
15
|
+
* could blank the icons.
|
|
16
|
+
*/
|
|
17
|
+
export function useFooterSocials() {
|
|
18
|
+
const settings = useThemeGroup("FOOTER");
|
|
19
|
+
const social = useThemeGroup("SOCIAL_MEDIA");
|
|
20
|
+
const { store } = useStorefront();
|
|
21
|
+
|
|
22
|
+
// Platform keys match SOCIAL_ICON_PATHS in FooterSocials.vue, so every
|
|
23
|
+
// filled slot renders a real glyph rather than the generic fallback mark.
|
|
24
|
+
const SOCIAL_SLOTS = [
|
|
25
|
+
"instagram",
|
|
26
|
+
"facebook",
|
|
27
|
+
"tiktok",
|
|
28
|
+
"twitter",
|
|
29
|
+
"youtube",
|
|
30
|
+
"pinterest",
|
|
31
|
+
"linkedin",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const themeSocials = computed(() =>
|
|
35
|
+
SOCIAL_SLOTS.map((platform) => ({
|
|
36
|
+
id: `theme-${platform}`,
|
|
37
|
+
platform,
|
|
38
|
+
url: String(settings.value[`FOOTER_SOCIAL_${platform.toUpperCase()}_URL`] ?? "").trim(),
|
|
39
|
+
})).filter((social) => social.url)
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// The theme's store-wide "Social media" accounts (Shopify-style), used when
|
|
43
|
+
// the footer has none of its own.
|
|
44
|
+
const accountSocials = computed(() =>
|
|
45
|
+
[...SOCIAL_SLOTS, "snapchat", "whatsapp"]
|
|
46
|
+
.map((platform) => ({
|
|
47
|
+
id: `account-${platform}`,
|
|
48
|
+
platform,
|
|
49
|
+
url: String(social.value[`SOCIAL_${platform.toUpperCase()}_URL`] ?? "").trim(),
|
|
50
|
+
}))
|
|
51
|
+
.filter((s) => s.url)
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// Each source wins as a set: footer slots, then the theme's accounts, then
|
|
55
|
+
// the store record. Merging per platform would mean a merchant who deletes a
|
|
56
|
+
// link silently gets an old one back.
|
|
57
|
+
const socials = computed(() =>
|
|
58
|
+
themeSocials.value.length
|
|
59
|
+
? themeSocials.value
|
|
60
|
+
: accountSocials.value.length
|
|
61
|
+
? accountSocials.value
|
|
62
|
+
: store.value?.social_medias ?? []
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const display = computed(() =>
|
|
66
|
+
String(settings.value.FOOTER_SOCIAL_DISPLAY ?? "TEXT").toUpperCase()
|
|
67
|
+
);
|
|
68
|
+
const showIcon = computed(() => display.value === "ICONS" || display.value === "ICON_TEXT");
|
|
69
|
+
const showText = computed(() => display.value === "TEXT" || display.value === "ICON_TEXT");
|
|
70
|
+
|
|
71
|
+
// Shape and fill are separate settings and resolved separately, the same way
|
|
72
|
+
// showIcon and iconStyle are. PLAIN has no box, so the background has nothing
|
|
73
|
+
// to draw on and the component ignores it.
|
|
74
|
+
const iconStyle = computed(() =>
|
|
75
|
+
String(settings.value.FOOTER_SOCIAL_ICON_STYLE ?? "PLAIN").toLowerCase()
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const iconBackground = computed(() =>
|
|
79
|
+
String(settings.value.FOOTER_SOCIAL_ICON_BACKGROUND ?? "FILLED").toLowerCase()
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const iconSize = computed(() => {
|
|
83
|
+
const n = Number(settings.value.FOOTER_SOCIAL_ICON_SIZE);
|
|
84
|
+
return Number.isFinite(n) && n > 0 ? n : 18;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const iconColor = computed(() => settings.value.FOOTER_SOCIAL_ICON_COLOR || null);
|
|
88
|
+
|
|
89
|
+
const enabled = computed(
|
|
90
|
+
() => settings.value.FOOTER_SHOW_SOCIAL !== false && socials.value.length > 0
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const position = computed(() => settings.value.FOOTER_SOCIAL_POSITION || "OWN_COLUMN");
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
socials,
|
|
97
|
+
enabled,
|
|
98
|
+
position,
|
|
99
|
+
display,
|
|
100
|
+
showIcon,
|
|
101
|
+
showText,
|
|
102
|
+
iconStyle,
|
|
103
|
+
iconBackground,
|
|
104
|
+
iconSize,
|
|
105
|
+
iconColor,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { INPUT_STYLES, color, compact, has, pick, px, shadow } from "#osi/theme/controls";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A section's INPUT_* settings -> one field's overrides (--in-*).
|
|
5
|
+
*
|
|
6
|
+
* The field must carry `osi-input` (and live inside an `osi-field` if it has
|
|
7
|
+
* a label). Anything left empty here falls through to the theme's Inputs
|
|
8
|
+
* group, exactly like useButtonStyle does for buttons.
|
|
9
|
+
*
|
|
10
|
+
* const inputStyle = useInputStyle(() => s.value)
|
|
11
|
+
* const inputClasses = useInputClasses(() => s.value)
|
|
12
|
+
*/
|
|
13
|
+
export function useInputStyle(settings, options = {}) {
|
|
14
|
+
const { prefix = "INPUT" } = options;
|
|
15
|
+
return computed(() => inputVars(toValue(settings) ?? {}, prefix));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function inputVars(s, prefix = "INPUT") {
|
|
19
|
+
const get = (key) => s?.[`${prefix}_${key}`];
|
|
20
|
+
const size = get("SIZE") ?? get("FONT_SIZE");
|
|
21
|
+
return compact({
|
|
22
|
+
"--in-bg": color(get("BG_COLOR")),
|
|
23
|
+
"--in-fg": color(get("TEXT_COLOR")),
|
|
24
|
+
"--in-border-color": color(get("BORDER_COLOR")),
|
|
25
|
+
"--in-focus": color(get("FOCUS_COLOR")),
|
|
26
|
+
"--in-placeholder": color(s?.PLACEHOLDER_COLOR ?? get("PLACEHOLDER_COLOR")),
|
|
27
|
+
"--in-error": color(s?.ERROR_COLOR ?? get("ERROR_COLOR")),
|
|
28
|
+
"--in-radius": px(get("RADIUS")),
|
|
29
|
+
"--in-border-w": px(get("BORDER_WIDTH")),
|
|
30
|
+
"--in-shadow": has(get("SHADOW")) ? shadow(get("SHADOW"), get("SHADOW_COLOR")) : undefined,
|
|
31
|
+
"--in-pad-y": px(get("PADDING_Y")),
|
|
32
|
+
"--in-pad-x": px(get("PADDING_X")),
|
|
33
|
+
"--in-pad-y-mobile": px(get("PADDING_Y_MOBILE")),
|
|
34
|
+
"--in-pad-x-mobile": px(get("PADDING_X_MOBILE")),
|
|
35
|
+
"--in-fs": px(size),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function useInputClasses(settings, options = {}) {
|
|
40
|
+
const { prefix = "INPUT" } = options;
|
|
41
|
+
return computed(() => {
|
|
42
|
+
const style = pick(toValue(settings)?.[`${prefix}_STYLE`], INPUT_STYLES);
|
|
43
|
+
return ["osi-input", style && `osi-input--${style.toLowerCase()}`].filter(Boolean);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scroll and viewport state for the layout chrome.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the `handleScroll` / `scrolled` / `isMobile` block that Terazar's
|
|
5
|
+
* two layouts each carried a copy of. Three behaviours, one listener:
|
|
6
|
+
*
|
|
7
|
+
* - `scrolled` past the fold, for the header's condensed state
|
|
8
|
+
* - `scrollingUp` drives HEADER_STICKY = ON_SCROLL_UP
|
|
9
|
+
* - `isMobile` matchMedia, not a resize handler — resize fires on every
|
|
10
|
+
* mobile URL-bar show/hide, and on iOS that means a reflow
|
|
11
|
+
* storm while the user is just scrolling
|
|
12
|
+
*
|
|
13
|
+
* The listener is passive and reads through rAF, so it never blocks scrolling.
|
|
14
|
+
*/
|
|
15
|
+
const MOBILE_QUERY = "(max-width: 1023px)";
|
|
16
|
+
const SCROLLED_AFTER = 100;
|
|
17
|
+
const UP_THRESHOLD = 8; // ignore sub-pixel jitter and rubber-banding
|
|
18
|
+
// ON_SCROLL_UP: how far the user must keep scrolling DOWN before the header
|
|
19
|
+
// slides away. Any upward scroll resets it. Raise to make it linger longer.
|
|
20
|
+
const HIDE_AFTER = 250;
|
|
21
|
+
|
|
22
|
+
export function useLayoutShell() {
|
|
23
|
+
const scrollY = ref(0);
|
|
24
|
+
const scrolled = ref(false);
|
|
25
|
+
const scrollingUp = ref(true);
|
|
26
|
+
const atTop = ref(true);
|
|
27
|
+
const isMobile = ref(false);
|
|
28
|
+
|
|
29
|
+
let lastY = 0;
|
|
30
|
+
let downDistance = 0;
|
|
31
|
+
let ticking = false;
|
|
32
|
+
let stopLockWatch = null;
|
|
33
|
+
|
|
34
|
+
function read() {
|
|
35
|
+
// A drawer / popup is holding the page (or just let go): not the visitor
|
|
36
|
+
// scrolling. Leave the header exactly as it was.
|
|
37
|
+
if (scrollLocked.value) {
|
|
38
|
+
ticking = false;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const y = window.scrollY || document.documentElement.scrollTop || 0;
|
|
42
|
+
|
|
43
|
+
scrollY.value = y;
|
|
44
|
+
atTop.value = y <= 0;
|
|
45
|
+
scrolled.value = y > SCROLLED_AFTER;
|
|
46
|
+
|
|
47
|
+
const delta = y - lastY;
|
|
48
|
+
if (Math.abs(delta) > UP_THRESHOLD) {
|
|
49
|
+
if (delta < 0) {
|
|
50
|
+
scrollingUp.value = true;
|
|
51
|
+
downDistance = 0;
|
|
52
|
+
} else {
|
|
53
|
+
downDistance += delta;
|
|
54
|
+
if (downDistance >= HIDE_AFTER) scrollingUp.value = false;
|
|
55
|
+
}
|
|
56
|
+
lastY = y;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
ticking = false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function onScroll() {
|
|
63
|
+
if (ticking) return;
|
|
64
|
+
ticking = true;
|
|
65
|
+
requestAnimationFrame(read);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (import.meta.client) {
|
|
69
|
+
const media = window.matchMedia(MOBILE_QUERY);
|
|
70
|
+
const onMediaChange = (event) => {
|
|
71
|
+
isMobile.value = event.matches;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
onMounted(() => {
|
|
75
|
+
isMobile.value = media.matches;
|
|
76
|
+
lastY = window.scrollY || 0;
|
|
77
|
+
downDistance = 0;
|
|
78
|
+
read();
|
|
79
|
+
|
|
80
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
81
|
+
// After a lock: measure from wherever the page is now, so the restore
|
|
82
|
+
// isn't taken for a scroll down that hides the header.
|
|
83
|
+
stopLockWatch = watch(scrollLocked, (locked) => {
|
|
84
|
+
if (!locked) {
|
|
85
|
+
lastY = window.scrollY || 0;
|
|
86
|
+
downDistance = 0;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
media.addEventListener("change", onMediaChange);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
onBeforeUnmount(() => {
|
|
93
|
+
window.removeEventListener("scroll", onScroll);
|
|
94
|
+
stopLockWatch?.();
|
|
95
|
+
media.removeEventListener("change", onMediaChange);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Whether the header should be pinned right now.
|
|
101
|
+
* @param {string|Ref<string>} mode NONE | ALWAYS | ON_SCROLL_UP
|
|
102
|
+
*/
|
|
103
|
+
function headerPinned(mode) {
|
|
104
|
+
return computed(() => {
|
|
105
|
+
const value = unref(mode) ?? "ON_SCROLL_UP";
|
|
106
|
+
if (value === "NONE") return false;
|
|
107
|
+
if (value === "ALWAYS") return true;
|
|
108
|
+
// ON_SCROLL_UP: pinned at the top of the page, then only while going up.
|
|
109
|
+
return atTop.value || scrollingUp.value;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { scrollY, scrolled, scrollingUp, atTop, isMobile, headerPinned };
|
|
114
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function useLazyVideo() {
|
|
2
|
+
const videoEl = ref(null);
|
|
3
|
+
let observer = null;
|
|
4
|
+
|
|
5
|
+
const bindVideoRef = (el) => {
|
|
6
|
+
if (!el) return;
|
|
7
|
+
videoEl.value = el;
|
|
8
|
+
|
|
9
|
+
el.muted = true;
|
|
10
|
+
el.volume = 0;
|
|
11
|
+
|
|
12
|
+
if (typeof window !== "undefined" && "IntersectionObserver" in window) {
|
|
13
|
+
observer = new IntersectionObserver(
|
|
14
|
+
(entries) => {
|
|
15
|
+
entries.forEach((entry) => {
|
|
16
|
+
if (entry.isIntersecting) {
|
|
17
|
+
if (!el.src && el.dataset.src) {
|
|
18
|
+
el.src = el.dataset.src;
|
|
19
|
+
el.load();
|
|
20
|
+
}
|
|
21
|
+
el.play().catch(() => {});
|
|
22
|
+
} else {
|
|
23
|
+
el.pause();
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
{ threshold: 0.25 }
|
|
28
|
+
);
|
|
29
|
+
observer.observe(el);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const unbind = () => observer?.disconnect();
|
|
34
|
+
|
|
35
|
+
return { bindVideoRef, unbind };
|
|
36
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export const useMediaTypes = () => {
|
|
2
|
+
const imageExtensions = [
|
|
3
|
+
"jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff", "tif",
|
|
4
|
+
"ico", "avif", "heic", "heif", "jfif", "pjpeg", "pjp", "apng",
|
|
5
|
+
];
|
|
6
|
+
|
|
7
|
+
const videoExtensions = [
|
|
8
|
+
"mp4", "webm", "ogg", "mov", "avi", "mkv", "flv", "wmv", "m4v",
|
|
9
|
+
"3gp", "3g2", "ogv", "ts", "m2ts", "mts", "vob", "rm", "rmvb",
|
|
10
|
+
"asf", "amv", "mpg", "mpeg", "mp2", "mpe", "mpv", "m2v",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const audioExtensions = [
|
|
14
|
+
"mp3", "wav", "ogg", "aac", "flac", "m4a", "wma", "aiff", "aif",
|
|
15
|
+
"opus", "oga", "mid", "midi", "ra", "rm", "amr",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const documentExtensions = [
|
|
19
|
+
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods",
|
|
20
|
+
"odp", "txt", "rtf", "csv", "tsv", "epub", "pages", "numbers", "key",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const archiveExtensions = [
|
|
24
|
+
"zip", "rar", "7z", "tar", "gz", "bz2", "xz", "tgz", "tar.gz",
|
|
25
|
+
"tar.bz2", "tar.xz", "cab", "iso", "dmg",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const codeExtensions = [
|
|
29
|
+
"js", "ts", "jsx", "tsx", "html", "css", "scss", "sass", "less",
|
|
30
|
+
"json", "xml", "yaml", "yml", "md", "py", "java", "php", "rb",
|
|
31
|
+
"go", "rs", "c", "cpp", "h", "cs", "swift", "kt", "sh", "bash",
|
|
32
|
+
"sql", "graphql", "vue", "svelte",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const getMediaType = (path) => {
|
|
36
|
+
if (!path || typeof path !== "string") return null;
|
|
37
|
+
|
|
38
|
+
const ext = path.split(".").pop()?.toLowerCase();
|
|
39
|
+
|
|
40
|
+
if (imageExtensions.includes(ext)) return "image";
|
|
41
|
+
if (videoExtensions.includes(ext)) return "video";
|
|
42
|
+
if (audioExtensions.includes(ext)) return "audio";
|
|
43
|
+
if (documentExtensions.includes(ext)) return "document";
|
|
44
|
+
if (archiveExtensions.includes(ext)) return "archive";
|
|
45
|
+
if (codeExtensions.includes(ext)) return "code";
|
|
46
|
+
|
|
47
|
+
return "unknown";
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const isImage = (path) => getMediaType(path) === "image";
|
|
51
|
+
const isVideo = (path) => getMediaType(path) === "video";
|
|
52
|
+
const isAudio = (path) => getMediaType(path) === "audio";
|
|
53
|
+
const isDocument = (path) => getMediaType(path) === "document";
|
|
54
|
+
const isArchive = (path) => getMediaType(path) === "archive";
|
|
55
|
+
const isCode = (path) => getMediaType(path) === "code";
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
getMediaType,
|
|
59
|
+
isImage,
|
|
60
|
+
isVideo,
|
|
61
|
+
isAudio,
|
|
62
|
+
isDocument,
|
|
63
|
+
isArchive,
|
|
64
|
+
isCode,
|
|
65
|
+
};
|
|
66
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Absolute URL for a media path returned by the API.
|
|
3
|
+
*
|
|
4
|
+
* Replaces `import.meta.env.VITE_BASE_URL` — that was a build-time constant, and
|
|
5
|
+
* one Nuxt process serves every tenant here, so it cannot be baked in. The value
|
|
6
|
+
* comes from the same `base-url` state that useStoreInit() populates and
|
|
7
|
+
* getBaseUrl() reads, so media and API calls can never disagree about the host.
|
|
8
|
+
*
|
|
9
|
+
* Read via useState rather than useStoreInit(): useStoreInit() calls
|
|
10
|
+
* useRequestHeaders() in its body, which needs the Nuxt instance. This one is
|
|
11
|
+
* safe to call from anywhere, including inside a computed.
|
|
12
|
+
*
|
|
13
|
+
* Accepts every shape the API hands back:
|
|
14
|
+
* "media/x.jpg" | "/media/x.jpg" | { path } | { media: { path } }
|
|
15
|
+
*/
|
|
16
|
+
export function useMediaUrl() {
|
|
17
|
+
const baseUrl = useState("base-url", () => "");
|
|
18
|
+
|
|
19
|
+
/** Pull the path out of a media object, a nested one, or a plain string. */
|
|
20
|
+
function toPath(input) {
|
|
21
|
+
if (!input) return "";
|
|
22
|
+
if (typeof input === "string") return input;
|
|
23
|
+
return (
|
|
24
|
+
input.logo_url ||
|
|
25
|
+
input.logo?.logo_url ||
|
|
26
|
+
input.logo ||
|
|
27
|
+
input.media?.path ||
|
|
28
|
+
input.path ||
|
|
29
|
+
""
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string|object} input path, media object, or { media: {...} }
|
|
35
|
+
* @param {string} [fallback] returned when there's nothing to resolve
|
|
36
|
+
*/
|
|
37
|
+
function mediaUrl(input, fallback = "") {
|
|
38
|
+
const path = toPath(input);
|
|
39
|
+
if (!path) return fallback;
|
|
40
|
+
|
|
41
|
+
// Already absolute, a data URI, or a blob — hand it back untouched.
|
|
42
|
+
if (/^(https?:)?\/\//i.test(path) || /^(data|blob):/i.test(path)) return path;
|
|
43
|
+
|
|
44
|
+
const base = baseUrl.value;
|
|
45
|
+
// No base yet (bootstrap still in flight): a root-relative path still
|
|
46
|
+
// resolves correctly against the current origin, so don't return "".
|
|
47
|
+
if (!base) return path.startsWith("/") ? path : `/${path}`;
|
|
48
|
+
|
|
49
|
+
return `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Swap a file's extension. The video sections need this to derive poster and
|
|
54
|
+
* per-codec source URLs from a single stored path.
|
|
55
|
+
*/
|
|
56
|
+
function mediaUrlAs(input, extension, fallback = "") {
|
|
57
|
+
const path = toPath(input);
|
|
58
|
+
if (!path) return fallback;
|
|
59
|
+
return mediaUrl(path.replace(/\.[^/.]+$/, `.${extension}`), fallback);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `-poster.jpg` sibling of a video path, unless an explicit poster exists. */
|
|
63
|
+
function posterUrl(input, explicitPoster = null, fallback = "") {
|
|
64
|
+
if (explicitPoster) return mediaUrl(explicitPoster, fallback);
|
|
65
|
+
const path = toPath(input);
|
|
66
|
+
if (!path) return fallback;
|
|
67
|
+
return mediaUrl(path.replace(/\.[^/.]+$/, "-poster.jpg"), fallback);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { baseUrl, mediaUrl, mediaUrlAs, posterUrl, toPath };
|
|
71
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Menus, normalized to something a template can render without guarding.
|
|
3
|
+
*
|
|
4
|
+
* Menu items reference a category, collection, page, or product by relation, or
|
|
5
|
+
* carry a raw URL. The href each one produces has to match the backend's
|
|
6
|
+
* `resolve_path()` exactly — a path the storefront links to but the backend
|
|
7
|
+
* doesn't recognise falls through to NOT_FOUND and renders the wrong template
|
|
8
|
+
* while looking otherwise fine.
|
|
9
|
+
*
|
|
10
|
+
* The route table, from builder's resolve_path():
|
|
11
|
+
*
|
|
12
|
+
* / INDEX
|
|
13
|
+
* /categories/<slug> CLASSIFICATION (resource.type = CATEGORY)
|
|
14
|
+
* /collections/<slug> CLASSIFICATION (resource.type = COLLECTION)
|
|
15
|
+
* /products/<slug> PRODUCT
|
|
16
|
+
* /pages/<handle> PAGE
|
|
17
|
+
* /search SEARCH
|
|
18
|
+
* /cart CART
|
|
19
|
+
* /account ACCOUNT
|
|
20
|
+
*
|
|
21
|
+
* Note `/products/<slug>` — Terazar used `/<category>/<product>`, which this
|
|
22
|
+
* backend does not resolve. Every product link in the storefront uses the flat
|
|
23
|
+
* form; the category a product was browsed through is not in the URL.
|
|
24
|
+
*/
|
|
25
|
+
export function useMenu() {
|
|
26
|
+
const { menu } = useStorefront();
|
|
27
|
+
const t = useT();
|
|
28
|
+
|
|
29
|
+
/** Href for one item, in the order the backend's resolver would match it. */
|
|
30
|
+
function itemUrl(item) {
|
|
31
|
+
if (!item) return "/";
|
|
32
|
+
|
|
33
|
+
// Backend may pre-resolve. Trust it when it does.
|
|
34
|
+
if (item.url) return item.url;
|
|
35
|
+
|
|
36
|
+
const relation = item.relations ?? item;
|
|
37
|
+
|
|
38
|
+
if (relation.category?.slugified_name) {
|
|
39
|
+
return `/categories/${relation.category.slugified_name}`;
|
|
40
|
+
}
|
|
41
|
+
if (relation.collection?.slugified_name) {
|
|
42
|
+
return `/collections/${relation.collection.slugified_name}`;
|
|
43
|
+
}
|
|
44
|
+
if (relation.product?.slugified_name) {
|
|
45
|
+
return `/products/${relation.product.slugified_name}`;
|
|
46
|
+
}
|
|
47
|
+
if (relation.page?.handle) {
|
|
48
|
+
return `/pages/${relation.page.handle}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return "/";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Recursively normalize to { id, title, url, external, children }.
|
|
56
|
+
*
|
|
57
|
+
* Ids without one from the payload come from the item's place in the tree
|
|
58
|
+
* ("m.2.0" = first child of the third item), unique across every level. They
|
|
59
|
+
* used to be `${url}-${index}`, so a parent and its first child linking to
|
|
60
|
+
* the same page (the backend's "View all" row) shared an id — and opening a
|
|
61
|
+
* sibling in the mobile menu then closed the parent, because "close the
|
|
62
|
+
* other submenus" closed that id.
|
|
63
|
+
*/
|
|
64
|
+
function normalizeItems(items = [], parentId = "m") {
|
|
65
|
+
return items.map((item, index) => {
|
|
66
|
+
const url = itemUrl(item);
|
|
67
|
+
const id = item.id ?? `${parentId}.${index}`;
|
|
68
|
+
return {
|
|
69
|
+
id,
|
|
70
|
+
title: t(item.title) || t(item.label) || "",
|
|
71
|
+
url,
|
|
72
|
+
external: /^https?:\/\//i.test(url),
|
|
73
|
+
// A category's / collection's image (menu payload), for the sidebar.
|
|
74
|
+
image: item.image ?? null,
|
|
75
|
+
children: normalizeItems(item.children ?? item.items ?? [], id),
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {string|Ref<string>} handle
|
|
82
|
+
* @returns {ComputedRef<{handle, title, items}>} always a valid shape,
|
|
83
|
+
* including for a handle the merchant deleted.
|
|
84
|
+
*/
|
|
85
|
+
function useMenuByHandle(handle) {
|
|
86
|
+
return computed(() => {
|
|
87
|
+
const key = unref(handle);
|
|
88
|
+
if (!key) return { handle: "", title: "", items: [] };
|
|
89
|
+
|
|
90
|
+
const found = menu(key);
|
|
91
|
+
return {
|
|
92
|
+
handle: found.handle,
|
|
93
|
+
title: t(found.title) || "",
|
|
94
|
+
items: normalizeItems(found.items),
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { useMenuByHandle, itemUrl, normalizeItems };
|
|
100
|
+
}
|