@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,982 @@
|
|
|
1
|
+
import ProductServices from "#osi/services/ProductServices";
|
|
2
|
+
import CartServices from "#osi/services/CartServices";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Product detail state — the whole of Terazar/Maqui's product page logic, lifted
|
|
6
|
+
* out of the page and made shareable.
|
|
7
|
+
*
|
|
8
|
+
* Why a context rather than a page:
|
|
9
|
+
*
|
|
10
|
+
* The product page is theme-driven. MAIN_PRODUCT owns the layout, and the info
|
|
11
|
+
* column is an ordered list of merchant-arranged blocks (title, price, variant
|
|
12
|
+
* picker, quantity, buy buttons, collapsible rows, …) exactly like Shopify's
|
|
13
|
+
* main-product section. Those blocks are separate components, and every one of
|
|
14
|
+
* them needs the same selection: the price a block renders has to be the price
|
|
15
|
+
* the buy button adds. Prop-drilling that through a v-for over blocks means
|
|
16
|
+
* every block signature carries ten props it mostly ignores.
|
|
17
|
+
*
|
|
18
|
+
* So MainProductSection calls createProductDetail() once and provides it; every
|
|
19
|
+
* block calls useProductDetail() and reads the same refs.
|
|
20
|
+
*
|
|
21
|
+
* ── What was kept from the original page, deliberately ─────────────────────
|
|
22
|
+
*
|
|
23
|
+
* * exactMatchVariant has NO early return on an empty selection. For a product
|
|
24
|
+
* with no options, `variant.values` is [] and `.every()` on it is trivially
|
|
25
|
+
* true, so the single default variant matches immediately. Bailing out early
|
|
26
|
+
* is what used to break "Add to cart" on option-less products.
|
|
27
|
+
*
|
|
28
|
+
* * selectedVariant falls back to a dummy `{ gallery }` object while the
|
|
29
|
+
* selection is partial, so picking a colour swaps the gallery before a size
|
|
30
|
+
* is chosen.
|
|
31
|
+
*
|
|
32
|
+
* * Deselect-on-second-click, the partial-selection price range, the
|
|
33
|
+
* discounted_price_range parsing, "Buy N & Save X off", the coming-soon and
|
|
34
|
+
* per-variant track_inventory stock rules, quantity_step arithmetic, and the
|
|
35
|
+
* Web Share -> clipboard -> execCommand share chain are all as they were.
|
|
36
|
+
*
|
|
37
|
+
* ── What changed, and why ──────────────────────────────────────────────────
|
|
38
|
+
*
|
|
39
|
+
* * `${BASE_URL}${image.path}` is gone. One Nuxt process serves every tenant,
|
|
40
|
+
* so a build-time base URL is wrong here — media goes through useMediaUrl().
|
|
41
|
+
* * The hand-rolled productCache / inFlight Maps are gone. useAsyncData keyed
|
|
42
|
+
* by slug already dedupes, survives SSR->client, and cancels stale results.
|
|
43
|
+
* * Option state (selectedOptions, applyCombinationLogic, isSelected) is
|
|
44
|
+
* delegated to useProductVariants so the card's quick-shop and this page
|
|
45
|
+
* can't drift apart again.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
export const PRODUCT_DETAIL_KEY = Symbol("osi-product-detail");
|
|
49
|
+
|
|
50
|
+
/* ------------------------------------------------------------------ utils */
|
|
51
|
+
|
|
52
|
+
/** "10-20" -> { min: 10, max: 20 } ; "10" -> { min: 10, max: 10 } */
|
|
53
|
+
export function parsePriceRange(value) {
|
|
54
|
+
if (value === null || value === undefined || value === "") {
|
|
55
|
+
return { min: 0, max: 0 };
|
|
56
|
+
}
|
|
57
|
+
const [rawMin, rawMax] = String(value).split("-").map(Number);
|
|
58
|
+
const min = Number.isFinite(rawMin) ? rawMin : 0;
|
|
59
|
+
const max = Number.isFinite(rawMax) ? rawMax : min;
|
|
60
|
+
return { min, max };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* ------------------------------------------------------------- the source */
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The full product for the current route.
|
|
67
|
+
*
|
|
68
|
+
* Two calls, because the list endpoint is the only one that takes a slug and
|
|
69
|
+
* the detail endpoint is the only one that returns variants and the full
|
|
70
|
+
* gallery. Merged rather than replaced — the list row carries fields
|
|
71
|
+
* (categories, price_range) the detail response doesn't always repeat.
|
|
72
|
+
*/
|
|
73
|
+
async function loadProduct(slug) {
|
|
74
|
+
const list = await ProductServices.getProductId(slug);
|
|
75
|
+
const summary = list?.results?.[0];
|
|
76
|
+
|
|
77
|
+
if (!summary?.id) {
|
|
78
|
+
throw createError({
|
|
79
|
+
statusCode: 404,
|
|
80
|
+
statusMessage: "Product not found",
|
|
81
|
+
fatal: true,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A failure here must not 404 a product that exists — the summary alone is
|
|
86
|
+
// enough to render a card-level page.
|
|
87
|
+
const detail = await ProductServices.getProductDetail(summary.id).catch(() => null);
|
|
88
|
+
return detail?.id ? { ...summary, ...detail } : summary;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolve the route's product, SSR included.
|
|
93
|
+
*
|
|
94
|
+
* Call it from the page (awaited, so the payload is in the server render) and
|
|
95
|
+
* again from MainProductSection (not awaited — same key, so it reads the cached
|
|
96
|
+
* payload instead of firing a second request). In the builder there is no route
|
|
97
|
+
* product at all, so it falls back to a placeholder.
|
|
98
|
+
*/
|
|
99
|
+
/**
|
|
100
|
+
* @param {object} [options]
|
|
101
|
+
* @param {() => (number|string|object|null)} [options.productId]
|
|
102
|
+
* An explicit product (FEATURED_PRODUCT's picker): an id, or the picked
|
|
103
|
+
* object. When given, the route is ignored and the store's first product is
|
|
104
|
+
* only used while nothing is picked, in the editor.
|
|
105
|
+
*/
|
|
106
|
+
export function useProductResource(options = {}) {
|
|
107
|
+
const route = useRoute();
|
|
108
|
+
const explicit = computed(() => {
|
|
109
|
+
const raw = toValue(options.productId ?? null);
|
|
110
|
+
if (raw == null || raw === "") return null;
|
|
111
|
+
return typeof raw === "object" ? raw.id ?? null : raw;
|
|
112
|
+
});
|
|
113
|
+
const isFeatured = options.productId !== undefined;
|
|
114
|
+
const { resource } = useRenderContext();
|
|
115
|
+
const placeholders = usePlaceholders();
|
|
116
|
+
|
|
117
|
+
const slug = computed(
|
|
118
|
+
() =>
|
|
119
|
+
isFeatured ? null :
|
|
120
|
+
route.params.product ||
|
|
121
|
+
route.params.slug ||
|
|
122
|
+
resource.value?.slug ||
|
|
123
|
+
resource.value?.slugified_name ||
|
|
124
|
+
null
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const key = isFeatured
|
|
128
|
+
? `product:id:${explicit.value ?? "first"}`
|
|
129
|
+
: `product:${slug.value ?? "preview"}`;
|
|
130
|
+
|
|
131
|
+
// The raw AsyncData is returned as well as its refs: the page awaits it so
|
|
132
|
+
// the product is in the SSR payload, and MainProductSection calls this again
|
|
133
|
+
// without awaiting — same key, so it reads that payload instead of firing a
|
|
134
|
+
// second request.
|
|
135
|
+
/**
|
|
136
|
+
* The dashboard previews the PRODUCT template with no product routed. Rather
|
|
137
|
+
* than the stand-in, show the store's first real product: the merchant is
|
|
138
|
+
* designing against their own photos, title length and variants, which is
|
|
139
|
+
* where a layout actually breaks. The stand-in stays for a store whose
|
|
140
|
+
* catalog is still empty.
|
|
141
|
+
*/
|
|
142
|
+
async function loadFirstProduct() {
|
|
143
|
+
const list = await ProductServices.listProducts({ page_size: 1 }).catch(() => null);
|
|
144
|
+
const summary = list?.results?.[0];
|
|
145
|
+
if (!summary?.id) return null;
|
|
146
|
+
const detail = await ProductServices.getProductDetail(summary.id).catch(() => null);
|
|
147
|
+
return detail?.id ? { ...summary, ...detail } : summary;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function loadById(id) {
|
|
151
|
+
const detail = await ProductServices.getProductDetail(id).catch(() => null);
|
|
152
|
+
return detail?.id ? detail : null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const asyncData = useAsyncData(
|
|
156
|
+
key,
|
|
157
|
+
() =>
|
|
158
|
+
explicit.value != null
|
|
159
|
+
? loadById(explicit.value)
|
|
160
|
+
: slug.value
|
|
161
|
+
? loadProduct(slug.value)
|
|
162
|
+
: loadFirstProduct(),
|
|
163
|
+
{
|
|
164
|
+
watch: [slug, explicit],
|
|
165
|
+
default: () => null,
|
|
166
|
+
getCachedData: (cacheKey, nuxtApp) =>
|
|
167
|
+
nuxtApp.payload.data[cacheKey] ?? nuxtApp.static.data[cacheKey],
|
|
168
|
+
}
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const { data, pending, error, refresh } = asyncData;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A product-shaped stand-in for the editor.
|
|
175
|
+
*
|
|
176
|
+
* Three placeholder rows folded into one so the gallery has something to
|
|
177
|
+
* stack; a single-image product page reads as broken layout rather than as an
|
|
178
|
+
* empty catalog. Never reachable on a live storefront — usePlaceholders only
|
|
179
|
+
* fills when the page is framed by the dashboard.
|
|
180
|
+
*/
|
|
181
|
+
const placeholder = computed(() => {
|
|
182
|
+
const rows = placeholders.products([], 3);
|
|
183
|
+
if (!rows.length) return null;
|
|
184
|
+
|
|
185
|
+
const [first, ...rest] = rows;
|
|
186
|
+
return {
|
|
187
|
+
...first,
|
|
188
|
+
name: "Product title",
|
|
189
|
+
description:
|
|
190
|
+
"<p>This is where the product description will appear. Add one from the " +
|
|
191
|
+
"products area to replace this text.</p>",
|
|
192
|
+
gallery: [
|
|
193
|
+
...(first.gallery ?? []),
|
|
194
|
+
...rest.flatMap((row) => row.gallery ?? []),
|
|
195
|
+
],
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const product = computed(() => data.value ?? placeholder.value ?? null);
|
|
200
|
+
|
|
201
|
+
// pending is true on the very first client-side navigation before the fetch
|
|
202
|
+
// settles; a placeholder standing in for a real product isn't "loaded".
|
|
203
|
+
const loading = computed(() => pending.value && !data.value);
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
product,
|
|
207
|
+
loading,
|
|
208
|
+
error,
|
|
209
|
+
refresh,
|
|
210
|
+
slug,
|
|
211
|
+
asyncData,
|
|
212
|
+
isPlaceholder: computed(() => !data.value),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/* --------------------------------------------------------------- the state */
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* @param {object} options
|
|
220
|
+
* @param {object|Ref|(() => object)} options.product
|
|
221
|
+
* @param {object|Ref|(() => object)} [options.settings] MAIN_PRODUCT settings
|
|
222
|
+
* @param {boolean|Ref} [options.loading]
|
|
223
|
+
*/
|
|
224
|
+
export function createProductDetail({ product, settings = {}, loading = false } = {}) {
|
|
225
|
+
const p = computed(() => toValue(product) ?? null);
|
|
226
|
+
const s = computed(() => toValue(settings) ?? {});
|
|
227
|
+
const isLoading = computed(() => !!toValue(loading));
|
|
228
|
+
|
|
229
|
+
const route = useRoute();
|
|
230
|
+
const router = useRouter();
|
|
231
|
+
const { store, cartType } = useStorefront();
|
|
232
|
+
const { mediaUrl } = useMediaUrl();
|
|
233
|
+
const { fetchCart } = useCart();
|
|
234
|
+
const { toggleCartSidebar } = useSidebar();
|
|
235
|
+
const { showApiError } = useApiErrorToast();
|
|
236
|
+
const { policies, fetchPolicies } = usePolicies();
|
|
237
|
+
|
|
238
|
+
const variants = useProductVariants(product);
|
|
239
|
+
|
|
240
|
+
/* ---------------------------------------------------------------- money */
|
|
241
|
+
|
|
242
|
+
const currency = computed(
|
|
243
|
+
() => store.value?.currency_symbol || store.value?.currency_sign || "$"
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
/** Matches the card's convention: "50.00" -> "$50", "49.99" -> "$49.99". */
|
|
247
|
+
/** "50.00" -> "$50". No price (null, empty, 0) -> "" — never "$0". */
|
|
248
|
+
function formatMoney(value) {
|
|
249
|
+
if (value === null || value === undefined || value === "") return "";
|
|
250
|
+
const n = Number(value);
|
|
251
|
+
if (!Number.isFinite(n) || n <= 0) return "";
|
|
252
|
+
return `${currency.value}${Number(n.toFixed(2))}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function formatRange(range) {
|
|
256
|
+
if (!range) return "";
|
|
257
|
+
const { min, max } = range;
|
|
258
|
+
const low = formatMoney(min);
|
|
259
|
+
const high = formatMoney(max);
|
|
260
|
+
if (!low || !high || min === max) return low || high;
|
|
261
|
+
return `${low} – ${high}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/* ------------------------------------------------------------- variants */
|
|
265
|
+
|
|
266
|
+
const variantTypes = computed(() => p.value?.vary_by ?? []);
|
|
267
|
+
const allVariants = computed(() => p.value?.product_variants ?? []);
|
|
268
|
+
const selectedValueIds = computed(() => Object.values(variants.selectedOptions.value));
|
|
269
|
+
|
|
270
|
+
const hasOptions = computed(() => variantTypes.value.length > 0);
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Complete when every option type has a value. With no option types that's
|
|
274
|
+
* 0 === 0, i.e. true — which is what lets an option-less product be added.
|
|
275
|
+
*/
|
|
276
|
+
const isSelectionComplete = computed(
|
|
277
|
+
() => selectedValueIds.value.length === variantTypes.value.length
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const missingVariantName = computed(() => {
|
|
281
|
+
const missing = variantTypes.value.find(
|
|
282
|
+
(type) => !variants.selectedOptions.value[type.id]
|
|
283
|
+
);
|
|
284
|
+
return missing?.name?.toLowerCase() ?? "";
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
/** See the header note — no early return on an empty selection. */
|
|
288
|
+
const exactMatchVariant = computed(
|
|
289
|
+
() =>
|
|
290
|
+
allVariants.value.find((variant) =>
|
|
291
|
+
(variant.values ?? []).every((value) =>
|
|
292
|
+
selectedValueIds.value.includes(value.id)
|
|
293
|
+
)
|
|
294
|
+
) ?? null
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
/** Variants sharing at least one selected value — the partial-selection set. */
|
|
298
|
+
const partialVariants = computed(() => {
|
|
299
|
+
if (!selectedValueIds.value.length) return [];
|
|
300
|
+
return allVariants.value.filter((variant) =>
|
|
301
|
+
(variant.values ?? []).some((value) => selectedValueIds.value.includes(value.id))
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The real variant, or a dummy carrying only a merged gallery while the
|
|
307
|
+
* selection is partial. Image preview reads `.gallery`; anything that adds to
|
|
308
|
+
* a cart must read exactMatchVariant instead.
|
|
309
|
+
*/
|
|
310
|
+
const selectedVariant = computed(() => {
|
|
311
|
+
if (exactMatchVariant.value) return exactMatchVariant.value;
|
|
312
|
+
if (!selectedValueIds.value.length) return null;
|
|
313
|
+
|
|
314
|
+
const unique = [];
|
|
315
|
+
partialVariants.value.forEach((variant) => {
|
|
316
|
+
(variant.gallery ?? []).forEach((image) => {
|
|
317
|
+
const id = image?.media?.id ?? image?.id;
|
|
318
|
+
if (!id) return;
|
|
319
|
+
if (unique.some((existing) => (existing?.media?.id ?? existing?.id) === id)) return;
|
|
320
|
+
unique.push(image);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Keep the product's own gallery order — a variant gallery that reorders
|
|
325
|
+
// the same photos makes the column jump on every swatch click.
|
|
326
|
+
const order = (image) => {
|
|
327
|
+
const id = image?.media?.id ?? image?.id;
|
|
328
|
+
return (
|
|
329
|
+
p.value?.gallery?.findIndex(
|
|
330
|
+
(candidate) => (candidate?.media?.id ?? candidate?.id) === id
|
|
331
|
+
) ?? -1
|
|
332
|
+
);
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
return { gallery: [...unique].sort((a, b) => order(a) - order(b)) };
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
function selectVariant(value) {
|
|
339
|
+
if (value?.isAvailable === false) return;
|
|
340
|
+
variants.selectVariant(value);
|
|
341
|
+
showError.value = false;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const isSelected = (value) => variants.isSelected(value);
|
|
345
|
+
const applyCombinationLogic = (typeId) => variants.applyCombinationLogic(typeId);
|
|
346
|
+
|
|
347
|
+
/** Shopify preselects; the original page didn't. Off unless the theme asks. */
|
|
348
|
+
function preselectFirstAvailable() {
|
|
349
|
+
if (!s.value.PRESELECT_FIRST_VARIANT) return;
|
|
350
|
+
if (selectedValueIds.value.length) return;
|
|
351
|
+
|
|
352
|
+
const first = allVariants.value.find(
|
|
353
|
+
(variant) => !variant.track_inventory || variant.remaining_stock > 0
|
|
354
|
+
);
|
|
355
|
+
(first?.values ?? []).forEach((value) => variants.selectVariant(value));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/* -------------------------------------------------------------- pricing */
|
|
359
|
+
|
|
360
|
+
/* Quantity lives up here: the price depends on it (quantity-tier sales). */
|
|
361
|
+
const quantityStep = computed(() => Number(p.value?.quantity_step) || 1);
|
|
362
|
+
const quantityUnit = computed(() => p.value?.quantity_unit?.abbreviation ?? "");
|
|
363
|
+
const quantity = ref(quantityStep.value);
|
|
364
|
+
|
|
365
|
+
watch(quantityStep, (step) => {
|
|
366
|
+
quantity.value = step;
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Sales, the way Shopify applies discounts:
|
|
371
|
+
* - a 1-item sale (required_quantity 1) is a markdown on every item — the
|
|
372
|
+
* unit price itself drops ("save $4" → each item $4 less);
|
|
373
|
+
* - a quantity tier (required_quantity 2+) comes off the TOTAL once the
|
|
374
|
+
* quantity reaches it, once per order: "buy 12, save $4" is $4 off the
|
|
375
|
+
* whole line, not per item and not again at 24; a percentage tier is that
|
|
376
|
+
* percentage off the total;
|
|
377
|
+
* - discounts don't combine: the one saving the most at the quantity picked
|
|
378
|
+
* wins (Shopify's default, no combinations).
|
|
379
|
+
* So the unit price only changes for a 1-item sale; a tier shows as its box
|
|
380
|
+
* and in the total line. (The API's discounted_price_range treats tiers as
|
|
381
|
+
* per-item prices, so it isn't used.)
|
|
382
|
+
*/
|
|
383
|
+
function activeSales(variant) {
|
|
384
|
+
const now = Date.now();
|
|
385
|
+
return (variant?.sales ?? []).filter(
|
|
386
|
+
(sale) =>
|
|
387
|
+
Number(sale.discount_value) > 0 &&
|
|
388
|
+
(!sale.start_date || Date.parse(sale.start_date) <= now) &&
|
|
389
|
+
(!sale.end_date || Date.parse(sale.end_date) > now)
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
const requiredOf = (v) => Math.max(1, Number(Array.isArray(v) ? v[0] : v) || 1);
|
|
393
|
+
const round2 = (n) => Math.round(n * 100) / 100;
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* A variant's discounts, each with what it saves on `qty` items:
|
|
397
|
+
* { id, required, perItem, saving(qty), label, sale }.
|
|
398
|
+
*/
|
|
399
|
+
function offersOf(variant) {
|
|
400
|
+
const price = Number(variant?.price);
|
|
401
|
+
if (!variant || !(price > 0)) return [];
|
|
402
|
+
const offers = activeSales(variant).map((sale) => {
|
|
403
|
+
const required = requiredOf(sale.required_quantity);
|
|
404
|
+
const pct = sale.sale_type === "Percentage";
|
|
405
|
+
const value = Number(sale.discount_value);
|
|
406
|
+
const perItem = required <= 1;
|
|
407
|
+
const saving = (qty) => {
|
|
408
|
+
if (qty < required) return 0;
|
|
409
|
+
if (pct) return round2(qty * price * Math.min(value, 100) / 100);
|
|
410
|
+
return perItem ? round2(qty * Math.min(value, price)) : Math.min(value, round2(qty * price));
|
|
411
|
+
};
|
|
412
|
+
return {
|
|
413
|
+
id: sale.id, required, perItem, saving,
|
|
414
|
+
label: pct ? `${value}%` : formatMoney(value),
|
|
415
|
+
};
|
|
416
|
+
});
|
|
417
|
+
// The newer `discount` shape: a discounted unit price from a quantity.
|
|
418
|
+
const d = variant.discount;
|
|
419
|
+
if (d?.discounted_price !== undefined && d?.discounted_price !== null && Number(d.discounted_price) < price) {
|
|
420
|
+
const required = requiredOf(d.required_quantity);
|
|
421
|
+
const off = round2(price - Number(d.discounted_price));
|
|
422
|
+
offers.push({
|
|
423
|
+
id: `discount-${variant.id}`, required, perItem: required <= 1,
|
|
424
|
+
saving: (qty) => (qty < required ? 0 : required <= 1 ? round2(qty * off) : off),
|
|
425
|
+
label: d.discount ?? formatMoney(off),
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
return offers;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** The discount applied to `qty` of a variant: the biggest saving, or null. */
|
|
432
|
+
function bestOffer(variant, qty) {
|
|
433
|
+
let best = null;
|
|
434
|
+
for (const offer of offersOf(variant)) {
|
|
435
|
+
const saving = offer.saving(qty);
|
|
436
|
+
if (saving > 0 && (!best || saving > best.saving)) best = { ...offer, saving };
|
|
437
|
+
}
|
|
438
|
+
return best;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** A variant at a quantity: unit price shown, and the line total before / after. */
|
|
442
|
+
function lineOf(variant, qty) {
|
|
443
|
+
const price = Number(variant?.price) || 0;
|
|
444
|
+
const best = bestOffer(variant, qty);
|
|
445
|
+
const before = round2(price * qty);
|
|
446
|
+
const after = round2(before - (best?.saving ?? 0));
|
|
447
|
+
// Only a 1-item markdown changes the unit price.
|
|
448
|
+
const unit = best?.perItem ? round2(after / qty) : price;
|
|
449
|
+
// Display only: never a sale on its own.
|
|
450
|
+
const compare = Number(variant?.compare_at_price) || 0;
|
|
451
|
+
return { price, compare, unit, before, after, best };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The sale boxes under the price: one per quantity tier (2+ items) — a
|
|
456
|
+
* 1-item sale is already in the price, so it gets no box. Each once, fewest
|
|
457
|
+
* items first; `applied` when it's the discount the cart will apply.
|
|
458
|
+
*/
|
|
459
|
+
function tierBadges(list, qty, appliedIds) {
|
|
460
|
+
const seen = new Map();
|
|
461
|
+
for (const variant of list ?? []) {
|
|
462
|
+
for (const offer of offersOf(variant)) {
|
|
463
|
+
if (offer.perItem || seen.has(offer.id)) continue;
|
|
464
|
+
seen.set(offer.id, {
|
|
465
|
+
id: offer.id,
|
|
466
|
+
required: offer.required,
|
|
467
|
+
text: `Buy ${offer.required}, save ${offer.label} on the total`,
|
|
468
|
+
reached: qty >= offer.required,
|
|
469
|
+
applied: appliedIds.has(offer.id),
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return [...seen.values()].sort((a, b) => a.required - b.required);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Products whose discount only comes at product level (no variant sales). */
|
|
477
|
+
function productLevel(product, qty) {
|
|
478
|
+
const original = parsePriceRange(product.price_range);
|
|
479
|
+
const d = product.discounted_price_range;
|
|
480
|
+
if (d && typeof d === "object" && d.discounted_price_range && (d.discount ?? []).length) {
|
|
481
|
+
const required = Math.min(...(d.required_quantity ?? [1]).map((n) => requiredOf(n)));
|
|
482
|
+
const price = typeof d.discounted_price_range === "string" ? parsePriceRange(d.discounted_price_range) : original;
|
|
483
|
+
const perItem = required <= 1;
|
|
484
|
+
return {
|
|
485
|
+
original,
|
|
486
|
+
price: perItem ? price : original,
|
|
487
|
+
badges: perItem
|
|
488
|
+
? []
|
|
489
|
+
: [{ id: "product", required, text: `Buy ${required}, save ${d.discount[0]} on the total`, reached: qty >= required, applied: qty >= required }],
|
|
490
|
+
total: null,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
return { original, price: original, badges: [], total: null };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const pricing = computed(() => {
|
|
497
|
+
const product = p.value;
|
|
498
|
+
const empty = { original: { min: 0, max: 0 }, price: { min: 0, max: 0 }, badges: [], total: null };
|
|
499
|
+
if (!product) return empty;
|
|
500
|
+
const qty = Number(quantity.value) || 1;
|
|
501
|
+
|
|
502
|
+
const list = (exactMatchVariant.value
|
|
503
|
+
? [exactMatchVariant.value]
|
|
504
|
+
: selectedValueIds.value.length
|
|
505
|
+
? partialVariants.value
|
|
506
|
+
: allVariants.value
|
|
507
|
+
).filter((v) => Number(v?.price) > 0);
|
|
508
|
+
|
|
509
|
+
if (!list.length) return productLevel(product, qty);
|
|
510
|
+
if (!exactMatchVariant.value && !list.some((v) => offersOf(v).length || Number(v.compare_at_price) > Number(v.price))) {
|
|
511
|
+
const level = productLevel(product, qty);
|
|
512
|
+
if (level.badges.length || level.price.min !== level.original.min) return level;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const lines = list.map((v) => lineOf(v, qty));
|
|
516
|
+
const span = (pick) => ({ min: Math.min(...lines.map(pick)), max: Math.max(...lines.map(pick)) });
|
|
517
|
+
const applied = new Set(lines.map((l) => l.best?.id).filter(Boolean));
|
|
518
|
+
|
|
519
|
+
// The total line: for one price (a variant picked, or every variant the
|
|
520
|
+
// same price), from 2 items.
|
|
521
|
+
const one = lines.every((l) => l.price === lines[0].price && l.after === lines[0].after);
|
|
522
|
+
const first = lines[0];
|
|
523
|
+
const total = one && qty > 1
|
|
524
|
+
? { quantity: qty, before: first.before, after: first.after, saving: round2(first.before - first.after) }
|
|
525
|
+
: null;
|
|
526
|
+
|
|
527
|
+
return {
|
|
528
|
+
original: span((l) => l.price),
|
|
529
|
+
// What is struck through: compare_at_price when above the price, else the pre-sale price.
|
|
530
|
+
was: lines.some((l) => l.compare > l.unit || l.price > l.unit)
|
|
531
|
+
? span((l) => (l.compare > l.unit ? l.compare : l.price > l.unit ? l.price : l.unit))
|
|
532
|
+
: null,
|
|
533
|
+
price: span((l) => l.unit),
|
|
534
|
+
badges: tierBadges(list, qty, applied),
|
|
535
|
+
total,
|
|
536
|
+
};
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
const priceRange = computed(() => pricing.value.price);
|
|
540
|
+
const originalPriceRange = computed(() => pricing.value.original);
|
|
541
|
+
/** The sale boxes under the price (tiers of 2+ items), each { id, text, reached, applied }. */
|
|
542
|
+
const discountBadges = computed(() => pricing.value.badges ?? []);
|
|
543
|
+
/**
|
|
544
|
+
* The line total for the quantity picked — { quantity, before, after, saving,
|
|
545
|
+
* text: { before, after, saving } } — or null (1 item, or no single price).
|
|
546
|
+
*/
|
|
547
|
+
const lineTotal = computed(() => {
|
|
548
|
+
const t = pricing.value.total;
|
|
549
|
+
if (!t) return null;
|
|
550
|
+
return {
|
|
551
|
+
...t,
|
|
552
|
+
text: { before: formatMoney(t.before), after: formatMoney(t.after), saving: formatMoney(t.saving) },
|
|
553
|
+
};
|
|
554
|
+
});
|
|
555
|
+
/** The same, as one line (older callers). */
|
|
556
|
+
const discountMessage = computed(() => discountBadges.value.map((b) => b.text).join(" · ") || null);
|
|
557
|
+
|
|
558
|
+
const isOnSale = computed(
|
|
559
|
+
() =>
|
|
560
|
+
originalPriceRange.value.min > 0 &&
|
|
561
|
+
priceRange.value.min > 0 &&
|
|
562
|
+
originalPriceRange.value.min !== priceRange.value.min
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
const savingsPercent = computed(() => {
|
|
566
|
+
if (!isOnSale.value) return 0;
|
|
567
|
+
const was = originalPriceRange.value.min;
|
|
568
|
+
const now = priceRange.value.min;
|
|
569
|
+
return Math.round(((was - now) / was) * 100);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
const priceText = computed(() => formatRange(priceRange.value));
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Whether the selection has a real price. A product (or variant) with no
|
|
576
|
+
* price — null, empty, 0 — shows no price and can't be bought.
|
|
577
|
+
*/
|
|
578
|
+
const hasPrice = computed(() => {
|
|
579
|
+
const r = priceRange.value;
|
|
580
|
+
return Number(r?.max ?? 0) > 0 || Number(r?.min ?? 0) > 0;
|
|
581
|
+
});
|
|
582
|
+
const comparePriceText = computed(() =>
|
|
583
|
+
pricing.value.was
|
|
584
|
+
? formatRange(pricing.value.was)
|
|
585
|
+
: isOnSale.value
|
|
586
|
+
? formatRange(originalPriceRange.value)
|
|
587
|
+
: ""
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
/* ---------------------------------------------------------------- stock */
|
|
591
|
+
|
|
592
|
+
const isComingSoon = computed(() => !!p.value?.coming_soon);
|
|
593
|
+
|
|
594
|
+
const isOutOfStock = computed(() => {
|
|
595
|
+
const product = p.value;
|
|
596
|
+
if (!product) return false;
|
|
597
|
+
|
|
598
|
+
// A missing stock count (null) isn't 0 — not sold out.
|
|
599
|
+
const out = (n) => n !== null && n !== undefined && Number(n) <= 0;
|
|
600
|
+
const variant = exactMatchVariant.value;
|
|
601
|
+
if (variant?.id) {
|
|
602
|
+
if (!variant.track_inventory) return false;
|
|
603
|
+
return out(variant.remaining_stock);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (product.status === "In Stock") return false;
|
|
607
|
+
if (product.status === "Out of Stock") return true;
|
|
608
|
+
if (product.status === "On Demand") return false;
|
|
609
|
+
|
|
610
|
+
return out(product.remaining_stock);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
/** Units left on the current selection, or null when nothing tracks it. */
|
|
614
|
+
const remainingStock = computed(() => {
|
|
615
|
+
const variant = exactMatchVariant.value;
|
|
616
|
+
if (variant?.id) return variant.track_inventory ? variant.remaining_stock : null;
|
|
617
|
+
return p.value?.remaining_stock ?? null;
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
const isLowStock = computed(() => {
|
|
621
|
+
const threshold = Number(s.value.LOW_STOCK_THRESHOLD) || 0;
|
|
622
|
+
if (!threshold || remainingStock.value === null) return false;
|
|
623
|
+
return remainingStock.value > 0 && remainingStock.value <= threshold;
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
const sku = computed(() => exactMatchVariant.value?.sku || p.value?.sku || "");
|
|
627
|
+
|
|
628
|
+
/* --------------------------------------------------------------- images */
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* The gallery for the current selection: the variant's own images when there
|
|
632
|
+
* are any, otherwise the product's main image followed by its gallery.
|
|
633
|
+
* Deduped on path — the same photo reaches this list through both routes.
|
|
634
|
+
*/
|
|
635
|
+
const activeImages = computed(() => {
|
|
636
|
+
const product = p.value;
|
|
637
|
+
if (!product) return [];
|
|
638
|
+
|
|
639
|
+
let images = [];
|
|
640
|
+
const variantGallery = selectedVariant.value?.gallery;
|
|
641
|
+
|
|
642
|
+
if (variantGallery?.length) {
|
|
643
|
+
images = variantGallery.map((entry) => ({
|
|
644
|
+
id: entry.media?.id ?? entry.id,
|
|
645
|
+
path: entry.media?.path ?? entry.path,
|
|
646
|
+
alt: entry.media?.alt ?? entry.alt ?? product.name,
|
|
647
|
+
}));
|
|
648
|
+
} else {
|
|
649
|
+
if (product.main_image) {
|
|
650
|
+
images.push({
|
|
651
|
+
id: product.main_image.id,
|
|
652
|
+
path: product.main_image.path,
|
|
653
|
+
alt: product.main_image.alt ?? product.name,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
(product.gallery ?? []).forEach((entry) => {
|
|
657
|
+
images.push({
|
|
658
|
+
id: entry.media?.id ?? entry.id,
|
|
659
|
+
path: entry.media?.path ?? entry.path,
|
|
660
|
+
alt: entry.media?.alt ?? entry.alt ?? product.name,
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const seen = new Set();
|
|
666
|
+
return images.filter((image) => {
|
|
667
|
+
if (!image.path || seen.has(image.path)) return false;
|
|
668
|
+
seen.add(image.path);
|
|
669
|
+
return true;
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
const imageUrl = (image) => mediaUrl(image);
|
|
674
|
+
|
|
675
|
+
/* ------------------------------------------------------------- quantity */
|
|
676
|
+
|
|
677
|
+
// quantityStep / quantityUnit / quantity are declared with the pricing.
|
|
678
|
+
|
|
679
|
+
const maxQuantity = computed(() => {
|
|
680
|
+
const perUser = exactMatchVariant.value?.max_quantity_per_user;
|
|
681
|
+
const stock = remainingStock.value;
|
|
682
|
+
const limits = [perUser, stock].filter((value) => Number.isFinite(Number(value)) && value > 0);
|
|
683
|
+
return limits.length ? Math.min(...limits.map(Number)) : null;
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
function increment() {
|
|
687
|
+
const next = quantity.value + quantityStep.value;
|
|
688
|
+
if (maxQuantity.value && next > maxQuantity.value) return;
|
|
689
|
+
quantity.value = next;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function decrement() {
|
|
693
|
+
if (quantity.value > quantityStep.value) quantity.value -= quantityStep.value;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function setQuantity(value) {
|
|
697
|
+
const step = quantityStep.value;
|
|
698
|
+
const n = Number(value);
|
|
699
|
+
if (!Number.isFinite(n) || n < step) {
|
|
700
|
+
quantity.value = step;
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
// Snap to the step: a typed 7 on a step of 5 is rejected by the API anyway.
|
|
704
|
+
const snapped = Math.round(n / step) * step;
|
|
705
|
+
quantity.value = maxQuantity.value ? Math.min(snapped, maxQuantity.value) : snapped;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/* ----------------------------------------------------------------- cart */
|
|
709
|
+
|
|
710
|
+
const isAddingToCart = ref(false);
|
|
711
|
+
const showError = ref(false);
|
|
712
|
+
let errorTimer = null;
|
|
713
|
+
|
|
714
|
+
function flashSelectionError() {
|
|
715
|
+
showError.value = true;
|
|
716
|
+
clearTimeout(errorTimer);
|
|
717
|
+
errorTimer = setTimeout(() => {
|
|
718
|
+
showError.value = false;
|
|
719
|
+
}, 3000);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function openCartUi() {
|
|
723
|
+
if (cartType.value === "PAGE") router.push("/cart");
|
|
724
|
+
else toggleCartSidebar(true);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* @param {object} [options]
|
|
729
|
+
* @param {boolean} [options.buyNow] straight to checkout instead of the drawer
|
|
730
|
+
* @returns {Promise<boolean>} whether the line was added
|
|
731
|
+
*/
|
|
732
|
+
async function addToCart({ buyNow = false } = {}) {
|
|
733
|
+
if (isAddingToCart.value) return false;
|
|
734
|
+
if (isComingSoon.value || isOutOfStock.value || !hasPrice.value) return false;
|
|
735
|
+
|
|
736
|
+
if (!isSelectionComplete.value || !exactMatchVariant.value) {
|
|
737
|
+
flashSelectionError();
|
|
738
|
+
return false;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (quantity.value % quantityStep.value !== 0) {
|
|
742
|
+
showApiError(
|
|
743
|
+
{ message: `Enter a quantity in multiples of ${quantityStep.value}.` },
|
|
744
|
+
`Enter a quantity in multiples of ${quantityStep.value}.`
|
|
745
|
+
);
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
isAddingToCart.value = true;
|
|
750
|
+
try {
|
|
751
|
+
await CartServices.EditCart(exactMatchVariant.value.id, quantity.value, "add", {
|
|
752
|
+
product: p.value,
|
|
753
|
+
variant: exactMatchVariant.value,
|
|
754
|
+
});
|
|
755
|
+
await fetchCart();
|
|
756
|
+
|
|
757
|
+
if (buyNow) router.push("/checkout");
|
|
758
|
+
else openCartUi();
|
|
759
|
+
|
|
760
|
+
return true;
|
|
761
|
+
} catch (error) {
|
|
762
|
+
showApiError(error, "Couldn't add that to your cart.");
|
|
763
|
+
return false;
|
|
764
|
+
} finally {
|
|
765
|
+
isAddingToCart.value = false;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const buyDisabled = computed(
|
|
770
|
+
() =>
|
|
771
|
+
isComingSoon.value || isOutOfStock.value || !hasPrice.value || isAddingToCart.value || isLoading.value
|
|
772
|
+
);
|
|
773
|
+
|
|
774
|
+
const buyLabel = computed(() => {
|
|
775
|
+
if (isComingSoon.value) return "Coming soon";
|
|
776
|
+
if (isOutOfStock.value) return "Sold out";
|
|
777
|
+
if (isAddingToCart.value) return "Adding…";
|
|
778
|
+
return null; // the block supplies its own default label
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
/* ---------------------------------------------------------------- share */
|
|
782
|
+
|
|
783
|
+
const stripHtml = (html) => String(html ?? "").replace(/<[^>]*>?/gm, "").trim();
|
|
784
|
+
|
|
785
|
+
function legacyCopy(text) {
|
|
786
|
+
const input = document.createElement("textarea");
|
|
787
|
+
input.value = text;
|
|
788
|
+
input.style.position = "fixed";
|
|
789
|
+
input.style.opacity = "0";
|
|
790
|
+
document.body.appendChild(input);
|
|
791
|
+
input.focus();
|
|
792
|
+
input.select();
|
|
793
|
+
let copied = false;
|
|
794
|
+
try {
|
|
795
|
+
copied = document.execCommand("copy");
|
|
796
|
+
} catch {
|
|
797
|
+
copied = false;
|
|
798
|
+
}
|
|
799
|
+
document.body.removeChild(input);
|
|
800
|
+
return copied;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const shareState = ref("");
|
|
804
|
+
|
|
805
|
+
async function shareProduct() {
|
|
806
|
+
if (!import.meta.client) return;
|
|
807
|
+
|
|
808
|
+
const url = window.location.href;
|
|
809
|
+
const data = {
|
|
810
|
+
title: p.value?.name,
|
|
811
|
+
text: stripHtml(p.value?.description),
|
|
812
|
+
url,
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
try {
|
|
816
|
+
if (navigator.share) {
|
|
817
|
+
await navigator.share(data);
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
throw new Error("Web Share unavailable");
|
|
821
|
+
} catch (error) {
|
|
822
|
+
// The shopper closing the native sheet isn't a failure.
|
|
823
|
+
if (error?.name === "AbortError") return;
|
|
824
|
+
|
|
825
|
+
try {
|
|
826
|
+
if (navigator.clipboard?.writeText) {
|
|
827
|
+
await navigator.clipboard.writeText(url);
|
|
828
|
+
} else if (!legacyCopy(url)) {
|
|
829
|
+
throw new Error("Clipboard unavailable");
|
|
830
|
+
}
|
|
831
|
+
shareState.value = "Link copied";
|
|
832
|
+
setTimeout(() => {
|
|
833
|
+
shareState.value = "";
|
|
834
|
+
}, 2000);
|
|
835
|
+
} catch (copyError) {
|
|
836
|
+
showApiError(copyError, "Couldn't share this product.");
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/* ---------------------------------------------------------- breadcrumbs */
|
|
842
|
+
|
|
843
|
+
const breadcrumbs = computed(() => {
|
|
844
|
+
const categories = p.value?.categories ?? [];
|
|
845
|
+
if (!categories.length) return [];
|
|
846
|
+
|
|
847
|
+
// The route's category segment is the path the shopper actually came
|
|
848
|
+
// through; fall back to the product's first category on a flat URL.
|
|
849
|
+
const routeSlug = route.params.category;
|
|
850
|
+
const matched =
|
|
851
|
+
categories.find((entry) => entry.category?.slugified_name === routeSlug) ??
|
|
852
|
+
categories[0];
|
|
853
|
+
|
|
854
|
+
const category = matched?.category;
|
|
855
|
+
if (!category) return [];
|
|
856
|
+
|
|
857
|
+
const items = [{ name: "Home", path: "/" }];
|
|
858
|
+
|
|
859
|
+
if (category.parent_category) {
|
|
860
|
+
items.push({
|
|
861
|
+
name: category.parent_category.name,
|
|
862
|
+
path: `/categories/${category.parent_category.slugified_name}`,
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
items.push({
|
|
867
|
+
name: category.name,
|
|
868
|
+
path: `/categories/${category.slugified_name}`,
|
|
869
|
+
});
|
|
870
|
+
items.push({ name: p.value?.name });
|
|
871
|
+
|
|
872
|
+
return items;
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
/* -------------------------------------------------------------- lifecycle */
|
|
876
|
+
|
|
877
|
+
function resetSelections() {
|
|
878
|
+
variants.reset();
|
|
879
|
+
quantity.value = quantityStep.value;
|
|
880
|
+
showError.value = false;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
watch(
|
|
884
|
+
() => p.value?.id,
|
|
885
|
+
() => {
|
|
886
|
+
resetSelections();
|
|
887
|
+
preselectFirstAvailable();
|
|
888
|
+
}
|
|
889
|
+
);
|
|
890
|
+
|
|
891
|
+
onMounted(() => {
|
|
892
|
+
fetchPolicies();
|
|
893
|
+
preselectFirstAvailable();
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
onScopeDispose(() => clearTimeout(errorTimer));
|
|
897
|
+
|
|
898
|
+
return {
|
|
899
|
+
// source
|
|
900
|
+
product: p,
|
|
901
|
+
settings: s,
|
|
902
|
+
loading: isLoading,
|
|
903
|
+
|
|
904
|
+
// options
|
|
905
|
+
variants,
|
|
906
|
+
variantTypes,
|
|
907
|
+
hasOptions,
|
|
908
|
+
selectedOptions: variants.selectedOptions,
|
|
909
|
+
selectedVariant,
|
|
910
|
+
exactMatchVariant,
|
|
911
|
+
isSelected,
|
|
912
|
+
selectVariant,
|
|
913
|
+
applyCombinationLogic,
|
|
914
|
+
isSelectionComplete,
|
|
915
|
+
missingVariantName,
|
|
916
|
+
showError,
|
|
917
|
+
resetSelections,
|
|
918
|
+
|
|
919
|
+
// price
|
|
920
|
+
pricing,
|
|
921
|
+
priceRange,
|
|
922
|
+
originalPriceRange,
|
|
923
|
+
discountMessage,
|
|
924
|
+
discountBadges,
|
|
925
|
+
lineTotal,
|
|
926
|
+
isOnSale,
|
|
927
|
+
savingsPercent,
|
|
928
|
+
priceText,
|
|
929
|
+
hasPrice,
|
|
930
|
+
comparePriceText,
|
|
931
|
+
formatMoney,
|
|
932
|
+
formatRange,
|
|
933
|
+
currency,
|
|
934
|
+
|
|
935
|
+
// stock
|
|
936
|
+
isComingSoon,
|
|
937
|
+
isOutOfStock,
|
|
938
|
+
remainingStock,
|
|
939
|
+
isLowStock,
|
|
940
|
+
sku,
|
|
941
|
+
|
|
942
|
+
// media
|
|
943
|
+
activeImages,
|
|
944
|
+
imageUrl,
|
|
945
|
+
|
|
946
|
+
// quantity
|
|
947
|
+
quantity,
|
|
948
|
+
quantityStep,
|
|
949
|
+
quantityUnit,
|
|
950
|
+
maxQuantity,
|
|
951
|
+
increment,
|
|
952
|
+
decrement,
|
|
953
|
+
setQuantity,
|
|
954
|
+
|
|
955
|
+
// cart
|
|
956
|
+
addToCart,
|
|
957
|
+
isAddingToCart,
|
|
958
|
+
buyDisabled,
|
|
959
|
+
buyLabel,
|
|
960
|
+
|
|
961
|
+
// misc
|
|
962
|
+
policies,
|
|
963
|
+
shareProduct,
|
|
964
|
+
shareState,
|
|
965
|
+
breadcrumbs,
|
|
966
|
+
stripHtml,
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
export function provideProductDetail(context) {
|
|
971
|
+
provide(PRODUCT_DETAIL_KEY, context);
|
|
972
|
+
return context;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Read the context MainProductSection provided. Returns null outside a product
|
|
977
|
+
* section rather than throwing, so a block dropped somewhere odd renders
|
|
978
|
+
* nothing instead of taking the page down.
|
|
979
|
+
*/
|
|
980
|
+
export function useProductDetail() {
|
|
981
|
+
return inject(PRODUCT_DETAIL_KEY, null);
|
|
982
|
+
}
|