@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,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The product page's media column.
|
|
3
|
+
*
|
|
4
|
+
* Five desktop layouts, one mobile carousel, one lightbox — all driven by
|
|
5
|
+
* MAIN_PRODUCT settings, so a merchant gets Shopify's media choices without a
|
|
6
|
+
* theme fork:
|
|
7
|
+
*
|
|
8
|
+
* STACKED one image per row, full width (Maqui's layout)
|
|
9
|
+
* GRID_2 two-up mosaic, first image spanning both columns
|
|
10
|
+
* THUMBNAILS_START large image with a thumbnail rail beside it
|
|
11
|
+
* THUMBNAILS_BELOW large image with the rail underneath
|
|
12
|
+
* CAROUSEL one image, swipeable, arrows + counter
|
|
13
|
+
*
|
|
14
|
+
* The carousel is only wired for CAROUSEL / thumbnail layouts and for mobile;
|
|
15
|
+
* the stacked layouts don't need Swiper at all, so they don't pay for it.
|
|
16
|
+
*
|
|
17
|
+
* Ratios go out as CSS variables rather than classes for the same reason the
|
|
18
|
+
* card does it: adding a ratio shouldn't mean another branch in the template.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const RATIOS = {
|
|
22
|
+
NATURAL: "auto",
|
|
23
|
+
PORTRAIT: "3 / 4",
|
|
24
|
+
SQUARE: "1 / 1",
|
|
25
|
+
LANDSCAPE: "4 / 3",
|
|
26
|
+
SCREEN: "auto", // paired with a height below
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {object} options
|
|
31
|
+
* @param {Array|Ref|(() => Array)} options.images media rows: { id, path, alt }
|
|
32
|
+
* @param {object|Ref|(() => object)} options.settings the section's settings
|
|
33
|
+
*/
|
|
34
|
+
export function useProductGallery({ images, settings = {} } = {}) {
|
|
35
|
+
const list = computed(() => toValue(images) ?? []);
|
|
36
|
+
const s = computed(() => toValue(settings) ?? {});
|
|
37
|
+
|
|
38
|
+
const { mediaUrl, posterUrl, mediaUrlAs } = useMediaUrl();
|
|
39
|
+
const { isVideo } = useMediaTypes();
|
|
40
|
+
|
|
41
|
+
const layout = computed(() => s.value.MEDIA_LAYOUT || "STACKED");
|
|
42
|
+
const mobileLayout = computed(() => s.value.MOBILE_MEDIA_LAYOUT || "CAROUSEL");
|
|
43
|
+
const zoom = computed(() => s.value.MEDIA_ZOOM || "LIGHTBOX");
|
|
44
|
+
|
|
45
|
+
const usesThumbnails = computed(() =>
|
|
46
|
+
["THUMBNAILS_START", "THUMBNAILS_BELOW"].includes(layout.value)
|
|
47
|
+
);
|
|
48
|
+
const usesCarousel = computed(
|
|
49
|
+
() => usesThumbnails.value || layout.value === "CAROUSEL"
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const showCounter = computed(() => s.value.MEDIA_SHOW_COUNTER !== false);
|
|
53
|
+
const showArrows = computed(() => s.value.MEDIA_SHOW_ARROWS !== false);
|
|
54
|
+
const showDots = computed(() => !!s.value.MEDIA_SHOW_DOTS);
|
|
55
|
+
|
|
56
|
+
const galleryStyle = computed(() => {
|
|
57
|
+
const ratio = s.value.MEDIA_RATIO || "PORTRAIT";
|
|
58
|
+
const style = {
|
|
59
|
+
"--osi-pdp-media-ratio": RATIOS[ratio] ?? RATIOS.PORTRAIT,
|
|
60
|
+
// Phones: a portrait crop takes the whole screen, so the merchant can
|
|
61
|
+
// pick a shorter one there.
|
|
62
|
+
"--osi-pdp-media-ratio-mobile":
|
|
63
|
+
RATIOS[s.value.MEDIA_RATIO_MOBILE] ?? RATIOS[ratio] ?? RATIOS.PORTRAIT,
|
|
64
|
+
"--osi-pdp-media-fit": s.value.MEDIA_FIT === "CONTAIN" ? "contain" : "cover",
|
|
65
|
+
"--osi-pdp-media-gap": `${Number(s.value.MEDIA_GAP ?? 4)}px`,
|
|
66
|
+
"--osi-pdp-media-radius": `${Number(s.value.MEDIA_RADIUS ?? 0)}px`,
|
|
67
|
+
"--osi-pdp-thumb-size": `${Number(s.value.MEDIA_THUMBNAIL_SIZE ?? 84)}px`,
|
|
68
|
+
// SCREEN is the one ratio that's a height, not an aspect — a full-bleed
|
|
69
|
+
// editorial gallery can't be expressed as a ratio because it has to match
|
|
70
|
+
// the viewport whatever the photo is.
|
|
71
|
+
"--osi-pdp-media-height": ratio === "SCREEN" ? "100vh" : "auto",
|
|
72
|
+
};
|
|
73
|
+
if (s.value.MEDIA_BACKGROUND) style["--osi-pdp-media-bg"] = s.value.MEDIA_BACKGROUND;
|
|
74
|
+
return style;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/* ------------------------------------------------------------- carousel */
|
|
78
|
+
|
|
79
|
+
const carousel = useCarousel({
|
|
80
|
+
items: () => list.value,
|
|
81
|
+
loop: () => list.value.length > 1,
|
|
82
|
+
minLoopSlides: 3,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const current = computed(() => carousel.activeIndex.value ?? 0);
|
|
86
|
+
|
|
87
|
+
// A variant switch replaces the image set; leaving the carousel on slide 4 of
|
|
88
|
+
// the old set shows an unrelated photo for one frame and then jumps.
|
|
89
|
+
watch(
|
|
90
|
+
() => list.value.map((image) => image.path).join("|"),
|
|
91
|
+
() => {
|
|
92
|
+
nextTick(() => {
|
|
93
|
+
carousel.update();
|
|
94
|
+
carousel.goTo(0, 0);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
/* ------------------------------------------------------------- lightbox */
|
|
100
|
+
|
|
101
|
+
const lightboxOpen = ref(false);
|
|
102
|
+
const lightboxIndex = ref(0);
|
|
103
|
+
|
|
104
|
+
useBodyScrollLock(() => lightboxOpen.value);
|
|
105
|
+
|
|
106
|
+
function openLightbox(index = 0) {
|
|
107
|
+
if (zoom.value === "NONE") return;
|
|
108
|
+
lightboxIndex.value = index;
|
|
109
|
+
lightboxOpen.value = true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function closeLightbox() {
|
|
113
|
+
lightboxOpen.value = false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function onKeydown(event) {
|
|
117
|
+
if (!lightboxOpen.value) return;
|
|
118
|
+
if (event.key === "Escape") closeLightbox();
|
|
119
|
+
if (event.key === "ArrowRight") {
|
|
120
|
+
lightboxIndex.value = Math.min(lightboxIndex.value + 1, list.value.length - 1);
|
|
121
|
+
}
|
|
122
|
+
if (event.key === "ArrowLeft") {
|
|
123
|
+
lightboxIndex.value = Math.max(lightboxIndex.value - 1, 0);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (import.meta.client) {
|
|
128
|
+
window.addEventListener("keydown", onKeydown);
|
|
129
|
+
onScopeDispose(() => window.removeEventListener("keydown", onKeydown));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* ----------------------------------------------------------------- media */
|
|
133
|
+
|
|
134
|
+
const src = (image) => mediaUrl(image);
|
|
135
|
+
const poster = (image) => posterUrl(image, image?.poster);
|
|
136
|
+
const sourceAs = (image, extension) => mediaUrlAs(image, extension);
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
images: list,
|
|
140
|
+
layout,
|
|
141
|
+
mobileLayout,
|
|
142
|
+
zoom,
|
|
143
|
+
usesThumbnails,
|
|
144
|
+
usesCarousel,
|
|
145
|
+
showCounter,
|
|
146
|
+
showArrows,
|
|
147
|
+
showDots,
|
|
148
|
+
galleryStyle,
|
|
149
|
+
|
|
150
|
+
// carousel
|
|
151
|
+
...carousel,
|
|
152
|
+
current,
|
|
153
|
+
|
|
154
|
+
// lightbox
|
|
155
|
+
lightboxOpen,
|
|
156
|
+
lightboxIndex,
|
|
157
|
+
openLightbox,
|
|
158
|
+
closeLightbox,
|
|
159
|
+
|
|
160
|
+
// media
|
|
161
|
+
isVideo,
|
|
162
|
+
src,
|
|
163
|
+
poster,
|
|
164
|
+
sourceAs,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRODUCT_LIST's items — products in the one shape the card template reads.
|
|
3
|
+
*
|
|
4
|
+
* The card parts (image, title, price, swatches, description, button) are
|
|
5
|
+
* drawn by ContentNode from an `item`, the same way CLASSIFICATION_LIST's are.
|
|
6
|
+
* Everything product-specific — price ranges, sale / sold out, the second
|
|
7
|
+
* image, option swatches, quick buy, add to cart, wishlist — is worked out here once per
|
|
8
|
+
* product, so ContentNode stays a layout component and the per-card cost is a
|
|
9
|
+
* plain object, not a stack of composables per card.
|
|
10
|
+
*
|
|
11
|
+
* Which products, in which order:
|
|
12
|
+
* SOURCE = MANUAL settings.PRODUCTS (ids, pick order); older sections kept
|
|
13
|
+
* their picks on PRODUCT blocks, read after those
|
|
14
|
+
* anything else the rows the backend resolved (or the preview fetched),
|
|
15
|
+
* cut to LIMIT
|
|
16
|
+
* PRODUCT blocks (older sections) still override TITLE / MEDIA / BADGE.
|
|
17
|
+
*
|
|
18
|
+
* @param {object|Ref|(() => object)} section pass a getter
|
|
19
|
+
* @param {Ref<Array>} products from useSectionData
|
|
20
|
+
* @param {{ all?: boolean }} options all: every row as given, no
|
|
21
|
+
* SOURCE / LIMIT — the classification
|
|
22
|
+
* grid, which pages itself
|
|
23
|
+
*/
|
|
24
|
+
import { cardOffers, tierText, rangeString } from "#osi/utils/productOffers";
|
|
25
|
+
|
|
26
|
+
export function useProductItems(section, products, options = {}) {
|
|
27
|
+
const t = useT();
|
|
28
|
+
const { mediaUrl } = useMediaUrl();
|
|
29
|
+
const { productUrl } = useProductUrl();
|
|
30
|
+
const { store } = useStorefront();
|
|
31
|
+
const { isPreview } = useBuilderPreview();
|
|
32
|
+
const { handleAddToCart } = useAddToCart();
|
|
33
|
+
const { addToWishlist, wishlistStatus } = useWishlist();
|
|
34
|
+
const analytics = useAnalytics();
|
|
35
|
+
const route = useRoute();
|
|
36
|
+
const { resource } = useRenderContext();
|
|
37
|
+
|
|
38
|
+
const settings = computed(() => toValue(section)?.settings ?? {});
|
|
39
|
+
const isManual = computed(() => String(settings.value.SOURCE ?? "").toUpperCase() === "MANUAL");
|
|
40
|
+
|
|
41
|
+
const currency = computed(
|
|
42
|
+
() => store.value?.default_currency?.symbol || store.value?.default_currency?.code || "$"
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/* ------------------------------------------------------------------ price */
|
|
46
|
+
|
|
47
|
+
// price_range / discounted_price_range arrive as a number, a numeric string,
|
|
48
|
+
// or "min - max" when variants differ in price.
|
|
49
|
+
function priceParts(value) {
|
|
50
|
+
if (value === null || value === undefined || value === "") return [];
|
|
51
|
+
if (typeof value === "number") return Number.isFinite(value) ? [value] : [];
|
|
52
|
+
return String(value)
|
|
53
|
+
.split("-")
|
|
54
|
+
.map((part) => parseFloat(part))
|
|
55
|
+
.filter((n) => Number.isFinite(n));
|
|
56
|
+
}
|
|
57
|
+
function formatPrice(value) {
|
|
58
|
+
const parts = priceParts(value);
|
|
59
|
+
if (!parts.some((n) => n > 0)) return "";
|
|
60
|
+
return parts.map((n) => `${currency.value}${n}`).join(" - ");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* ------------------------------------------------------------------ picks */
|
|
64
|
+
|
|
65
|
+
const idOf = (value) => (value && typeof value === "object" ? value.id : value);
|
|
66
|
+
|
|
67
|
+
/** Legacy PRODUCT blocks: { productId -> block settings }, in block order. */
|
|
68
|
+
const blockPicks = computed(() =>
|
|
69
|
+
(toValue(section)?.blocks ?? [])
|
|
70
|
+
.filter((b) => b.type === "PRODUCT" && !b.disabled)
|
|
71
|
+
.map((b) => ({ id: idOf(b.relations?.product ?? b.settings?.PRODUCT), overrides: b.settings ?? {} }))
|
|
72
|
+
.filter((pick) => pick.id != null)
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const pickedIds = computed(() => {
|
|
76
|
+
const ids = (settings.value.PRODUCTS ?? []).map(idOf).filter((id) => id != null);
|
|
77
|
+
for (const pick of blockPicks.value) if (!ids.some((id) => String(id) === String(pick.id))) ids.push(pick.id);
|
|
78
|
+
return ids;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const rows = computed(() => {
|
|
82
|
+
const all = products.value ?? [];
|
|
83
|
+
if (options.all) return all;
|
|
84
|
+
if (!isManual.value || !pickedIds.value.length) {
|
|
85
|
+
const limit = Number(settings.value.LIMIT) || 8;
|
|
86
|
+
return isManual.value ? all : all.slice(0, limit);
|
|
87
|
+
}
|
|
88
|
+
// Manual: pick order. Placeholders (an empty store in the editor) have no
|
|
89
|
+
// matching ids — show them as they are rather than nothing.
|
|
90
|
+
const byId = new Map(all.map((row) => [String(row.id), row]));
|
|
91
|
+
const ordered = pickedIds.value.map((id) => byId.get(String(id))).filter(Boolean);
|
|
92
|
+
return ordered.length ? ordered : all.filter((row) => row.is_placeholder);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/* -------------------------------------------------------------- analytics */
|
|
96
|
+
|
|
97
|
+
// Every product list the storefront draws goes through here (PRODUCT_LIST,
|
|
98
|
+
// the category / collection grid, product highlight), so this is the one
|
|
99
|
+
// place that reports them: view_item_list when the products render, and the
|
|
100
|
+
// list remembered so a card click can say where it came from.
|
|
101
|
+
if (import.meta.client) {
|
|
102
|
+
watch(
|
|
103
|
+
rows,
|
|
104
|
+
(list) => {
|
|
105
|
+
if (isPreview.value || !list?.length) return;
|
|
106
|
+
const sec = toValue(section) ?? {};
|
|
107
|
+
const heading = String(t(sec.settings?.HEADING) ?? "").trim();
|
|
108
|
+
const res = resource.value;
|
|
109
|
+
const isClassification = !!options.all;
|
|
110
|
+
analytics.viewItemList({
|
|
111
|
+
id: isClassification
|
|
112
|
+
? `${String(res?.type ?? "list").toLowerCase()}:${res?.slug ?? route.path}`
|
|
113
|
+
: `${route.path}#${sec.key ?? "products"}`,
|
|
114
|
+
name: isClassification ? res?.name || route.path : heading || sec.key || "Products",
|
|
115
|
+
products: list,
|
|
116
|
+
category: isClassification && ["CATEGORY", "COLLECTION"].includes(res?.type),
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
{ immediate: true, flush: "post" }
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* ------------------------------------------------------------------ items */
|
|
124
|
+
|
|
125
|
+
function imagesOf(product) {
|
|
126
|
+
const out = [];
|
|
127
|
+
if (product.main_image) out.push(product.main_image);
|
|
128
|
+
for (const g of product.gallery ?? []) if (g?.media) out.push(g.media);
|
|
129
|
+
const seen = new Set();
|
|
130
|
+
return out.filter((m) => {
|
|
131
|
+
const k = m?.id ?? m?.path ?? m;
|
|
132
|
+
if (!k || seen.has(k)) return false;
|
|
133
|
+
seen.add(k);
|
|
134
|
+
return true;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Sold out only when the product says so: "Out of Stock", or a stock count
|
|
140
|
+
* of 0. A missing count (null) isn't 0, and a product with no price isn't
|
|
141
|
+
* sold out — it just can't be bought yet (its button is disabled).
|
|
142
|
+
*/
|
|
143
|
+
function soldOutOf(product) {
|
|
144
|
+
if (product.status === "Out of Stock") return true;
|
|
145
|
+
if (product.status === "In Stock" || product.status === "On Demand") return false;
|
|
146
|
+
if (product.remaining_stock === null || product.remaining_stock === undefined) return false;
|
|
147
|
+
return Number(product.remaining_stock) <= 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const isColorName = (name) => ["color", "colour"].includes(String(name).toLowerCase());
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Every option (Color, Size, …) with the values that have a variant behind
|
|
154
|
+
* them. A colour value with a hex / colour draws as a dot; anything else —
|
|
155
|
+
* sizes, a colour without one — as text.
|
|
156
|
+
*/
|
|
157
|
+
function optionsOf(product) {
|
|
158
|
+
return (product.vary_by ?? [])
|
|
159
|
+
.map((type) => {
|
|
160
|
+
const seen = new Set();
|
|
161
|
+
const values = [];
|
|
162
|
+
for (const variant of product.product_variants ?? []) {
|
|
163
|
+
const value = (variant.values ?? []).find((v) => v.type === type.id);
|
|
164
|
+
if (!value || seen.has(value.id)) continue;
|
|
165
|
+
seen.add(value.id);
|
|
166
|
+
const color = isColorName(type.name) ? value.hex_code || "" : "";
|
|
167
|
+
values.push({ key: value.id, name: value.name || value.value || "", color });
|
|
168
|
+
}
|
|
169
|
+
return { key: type.id, name: type.name, isColor: isColorName(type.name), values };
|
|
170
|
+
})
|
|
171
|
+
.filter((option) => option.values.length);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// The one quick buy open in this list: { key, mode } — SHEET | POPUP.
|
|
175
|
+
const quickBuy = ref(null);
|
|
176
|
+
|
|
177
|
+
const plain = (html) =>
|
|
178
|
+
String(html || "").replace(/<[^>]*>/g, " ").replace(/ /g, " ").replace(/\s+/g, " ").trim();
|
|
179
|
+
|
|
180
|
+
const items = computed(() => {
|
|
181
|
+
const overrides = new Map(blockPicks.value.map((pick) => [String(pick.id), pick.overrides]));
|
|
182
|
+
return rows.value.map((product, i) => {
|
|
183
|
+
const o = overrides.get(String(product.id)) ?? {};
|
|
184
|
+
const images = imagesOf(product);
|
|
185
|
+
const title = t(o.TITLE) || product.name || product.page_title || "";
|
|
186
|
+
// Shopify's rules (utils/productOffers): the price for one item — only a
|
|
187
|
+
// 1-item sale lowers it — and the quantity tiers as boxes. Older rows
|
|
188
|
+
// without the variants' sales keep the API's ranges.
|
|
189
|
+
const offers = cardOffers(product);
|
|
190
|
+
let now, was;
|
|
191
|
+
if (offers) {
|
|
192
|
+
now = formatPrice(rangeString(offers.now));
|
|
193
|
+
was = offers.was ? formatPrice(rangeString(offers.was)) : "";
|
|
194
|
+
} else {
|
|
195
|
+
const base = product.price_range;
|
|
196
|
+
const sale = product.discounted_price_range;
|
|
197
|
+
now = formatPrice(sale ?? base);
|
|
198
|
+
was = sale && base && String(sale) !== String(base) ? formatPrice(base) : "";
|
|
199
|
+
}
|
|
200
|
+
const soldOut = soldOutOf(product);
|
|
201
|
+
const placeholder = product.is_placeholder === true;
|
|
202
|
+
const hasOptions = (product.product_variants?.length ?? 0) > 1;
|
|
203
|
+
const item = {
|
|
204
|
+
key: `${product.id ?? "p"}-${i}`,
|
|
205
|
+
id: product.id,
|
|
206
|
+
kind: "PRODUCT",
|
|
207
|
+
title,
|
|
208
|
+
description: plain(product.description),
|
|
209
|
+
url: placeholder ? null : productUrl(product),
|
|
210
|
+
image: mediaUrl(o.MEDIA ?? images[0] ?? null),
|
|
211
|
+
imageAlt: title,
|
|
212
|
+
hoverImage: o.MEDIA ? "" : images[1] ? mediaUrl(images[1]) : "",
|
|
213
|
+
price: now,
|
|
214
|
+
compareAt: was,
|
|
215
|
+
onSale: offers ? offers.sale : !!was,
|
|
216
|
+
// For the image's brand / category badges (BADGE_BRAND / BADGE_CATEGORIES).
|
|
217
|
+
brand: typeof product.brand === "object" ? product.brand?.name || "" : "",
|
|
218
|
+
categories: (product.categories ?? [])
|
|
219
|
+
.map((c) => c?.category?.name ?? c?.name ?? "")
|
|
220
|
+
.filter(Boolean),
|
|
221
|
+
// Quantity-tier sales (2+ items): badges on the image when there's no
|
|
222
|
+
// 1-item sale (that one shows "Sale").
|
|
223
|
+
offers: (offers?.tiers ?? []).map((tier) => ({ id: tier.id, text: tierText(tier, formatPrice, { short: true }) })),
|
|
224
|
+
soldOut,
|
|
225
|
+
comingSoon: !!product.coming_soon,
|
|
226
|
+
hasOptions,
|
|
227
|
+
badge: t(o.BADGE) || "",
|
|
228
|
+
options: optionsOf(product),
|
|
229
|
+
quickBuy: quickBuy.value?.key === `${product.id ?? "p"}-${i}` ? quickBuy.value.mode : null,
|
|
230
|
+
count: null,
|
|
231
|
+
wishlisted: wishlistStatus.value[product.id] ?? product.wishlisted ?? false,
|
|
232
|
+
isPlaceholder: placeholder,
|
|
233
|
+
raw: product,
|
|
234
|
+
};
|
|
235
|
+
// Card parts call these (ContentNode has no product code of its own).
|
|
236
|
+
item.addToCart = () => addToCart(item);
|
|
237
|
+
item.toggleWishlist = () => toggleWishlist(item);
|
|
238
|
+
item.openQuickBuy = (mode) => {
|
|
239
|
+
if (!inert(item) && !item.soldOut) quickBuy.value = { key: item.key, mode };
|
|
240
|
+
};
|
|
241
|
+
item.closeQuickBuy = () => {
|
|
242
|
+
if (quickBuy.value?.key === item.key) quickBuy.value = null;
|
|
243
|
+
};
|
|
244
|
+
return item;
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
/* ---------------------------------------------------------------- actions */
|
|
249
|
+
|
|
250
|
+
// In the editor a click selects the block — it must never add to a real
|
|
251
|
+
// cart or wishlist. Placeholders aren't real products either.
|
|
252
|
+
const inert = (item) => isPreview.value || !item || item.isPlaceholder;
|
|
253
|
+
|
|
254
|
+
async function addToCart(item) {
|
|
255
|
+
if (inert(item) || item.soldOut || !item.price) return;
|
|
256
|
+
try {
|
|
257
|
+
// Opens the cart itself; a product with options goes to its page.
|
|
258
|
+
await handleAddToCart(item.raw);
|
|
259
|
+
} catch {
|
|
260
|
+
// handleAddToCart shows its own error.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function toggleWishlist(item) {
|
|
265
|
+
if (inert(item)) return;
|
|
266
|
+
addToWishlist(item.raw);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return { items, addToCart, toggleWishlist };
|
|
270
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The href for a product.
|
|
3
|
+
*
|
|
4
|
+
* One place, because getting it wrong is invisible: a path the backend doesn't
|
|
5
|
+
* resolve falls through to NOT_FOUND and renders the wrong template while
|
|
6
|
+
* looking perfectly fine in the grid.
|
|
7
|
+
*
|
|
8
|
+
* ── Route shape ────────────────────────────────────────────────────────────
|
|
9
|
+
*
|
|
10
|
+
* `/<category-slug>/<product-slug>` — Terazar's nested form, and what the
|
|
11
|
+
* merchant-facing URLs are expected to look like.
|
|
12
|
+
*
|
|
13
|
+
* ⚠ The builder backend's `resolve_path()` currently maps `/products/<slug>`
|
|
14
|
+
* to the PRODUCT template and nothing else. Until it also matches a two-segment
|
|
15
|
+
* `/<category>/<product>` path, every link this produces 404s. That change has
|
|
16
|
+
* to land before this ships:
|
|
17
|
+
*
|
|
18
|
+
* /categories/<slug> CLASSIFICATION (already matched)
|
|
19
|
+
* /collections/<slug> CLASSIFICATION (already matched)
|
|
20
|
+
* /pages/<handle> PAGE (already matched)
|
|
21
|
+
* /<category>/<slug> PRODUCT ← needs adding, matched LAST so it
|
|
22
|
+
* can't shadow the prefixes above
|
|
23
|
+
*
|
|
24
|
+
* That ordering matters: a bare two-segment matcher placed first would swallow
|
|
25
|
+
* `/categories/shoes` and try to resolve a product called "shoes" in a category
|
|
26
|
+
* called "categories".
|
|
27
|
+
*
|
|
28
|
+
* ── Uncategorised products ─────────────────────────────────────────────────
|
|
29
|
+
*
|
|
30
|
+
* A product with no categories has no first segment. Terazar read
|
|
31
|
+
* `categories[0].category.slugified_name` unguarded, which is why an
|
|
32
|
+
* uncategorised product threw in search results rather than rendering. Here it
|
|
33
|
+
* falls back to `/products/<slug>`, which the backend already resolves — so the
|
|
34
|
+
* link works instead of crashing the page. Keep that route matched.
|
|
35
|
+
*/
|
|
36
|
+
export function useProductUrl() {
|
|
37
|
+
/** Handles both `[{ category: { slugified_name } }]` and a flat `[{ slugified_name }]`. */
|
|
38
|
+
function categorySlug(product) {
|
|
39
|
+
const first = product?.categories?.[0];
|
|
40
|
+
if (!first) return null;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
first.category?.slugified_name ||
|
|
44
|
+
first.category?.slug ||
|
|
45
|
+
first.slugified_name ||
|
|
46
|
+
first.slug ||
|
|
47
|
+
null
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function productUrl(product) {
|
|
52
|
+
const slug = product?.slugified_name || product?.slug;
|
|
53
|
+
if (!slug) return "/";
|
|
54
|
+
|
|
55
|
+
const category = categorySlug(product);
|
|
56
|
+
return category ? `/${category}/${slug}` : `/products/${slug}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { productUrl, categorySlug };
|
|
60
|
+
}
|