@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,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Variant selection for a product.
|
|
3
|
+
*
|
|
4
|
+
* Terazar had two copies of this: one in ProductCard for quick-shop
|
|
5
|
+
* (selectLocalVariant / findMatchingVariant / applyCombinationLogic) and one in
|
|
6
|
+
* the product detail page. They drifted — the card's version cleared
|
|
7
|
+
* selectedColor on deselect, the page's didn't. One composable, both callers.
|
|
8
|
+
*
|
|
9
|
+
* The data shape, since none of it is obvious from the names:
|
|
10
|
+
* product.vary_by [{ id, name: 'Color', values: [{ id, name, ... }] }]
|
|
11
|
+
* product.product_variants [{ id, price, values: [{ id, type }], gallery, sales,
|
|
12
|
+
* track_inventory, remaining_stock }]
|
|
13
|
+
* A variant's `values[].type` is the vary_by id it belongs to. So a selection is
|
|
14
|
+
* a map of { [varyById]: valueId }, and a variant matches when every one of its
|
|
15
|
+
* values is selected.
|
|
16
|
+
*
|
|
17
|
+
* @param {object|Ref|(() => object)} product pass a getter — the card is
|
|
18
|
+
* rendered in a v-for and the product prop changes identity on re-fetch.
|
|
19
|
+
*/
|
|
20
|
+
export function useProductVariants(product) {
|
|
21
|
+
const p = computed(() => toValue(product) ?? {});
|
|
22
|
+
|
|
23
|
+
const selectedOptions = ref({});
|
|
24
|
+
const selectedVariant = ref(null);
|
|
25
|
+
const selectedColor = ref(null);
|
|
26
|
+
|
|
27
|
+
const variantTypes = computed(() => p.value.vary_by ?? []);
|
|
28
|
+
|
|
29
|
+
const hasVariants = computed(
|
|
30
|
+
() =>
|
|
31
|
+
Array.isArray(p.value.product_variants) && p.value.product_variants.length > 1
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const colorType = computed(
|
|
35
|
+
() => variantTypes.value.find((type) => type.name === "Color") ?? null
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
/** Distinct colour values that actually have a variant behind them. */
|
|
39
|
+
const availableColors = computed(() => {
|
|
40
|
+
if (!colorType.value) return [];
|
|
41
|
+
|
|
42
|
+
const fromVariants =
|
|
43
|
+
p.value.product_variants
|
|
44
|
+
?.map((variant) => variant.values.find((v) => v.type === colorType.value.id))
|
|
45
|
+
.filter(Boolean) ?? [];
|
|
46
|
+
|
|
47
|
+
return fromVariants.filter(
|
|
48
|
+
(color, index, self) => index === self.findIndex((c) => c.id === color.id)
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const isSelected = (value) => selectedOptions.value[value.type] === value.id;
|
|
53
|
+
|
|
54
|
+
/** Values of one type that exist on at least one variant. */
|
|
55
|
+
function getAvailableVariants(varyById) {
|
|
56
|
+
const type = variantTypes.value.find((v) => v.id === varyById);
|
|
57
|
+
if (!type) return [];
|
|
58
|
+
|
|
59
|
+
const present = new Set();
|
|
60
|
+
p.value.product_variants?.forEach((variant) => {
|
|
61
|
+
variant.values.forEach((value) => {
|
|
62
|
+
if (value.type === varyById) present.add(value.id);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return type.values.filter((value) => present.has(value.id));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Same list, each value flagged `isAvailable` against the *other* current
|
|
71
|
+
* selections — picking Red greys out the sizes Red doesn't come in. With
|
|
72
|
+
* nothing selected everything is available.
|
|
73
|
+
*/
|
|
74
|
+
function applyCombinationLogic(varyById) {
|
|
75
|
+
if (Object.keys(selectedOptions.value).length === 0) {
|
|
76
|
+
return getAvailableVariants(varyById).map((value) => ({
|
|
77
|
+
...value,
|
|
78
|
+
isAvailable: true,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const others = Object.entries(selectedOptions.value)
|
|
83
|
+
.filter(([key]) => key !== varyById)
|
|
84
|
+
.map(([, value]) => value);
|
|
85
|
+
|
|
86
|
+
const matching =
|
|
87
|
+
p.value.product_variants?.filter((variant) =>
|
|
88
|
+
others.every((id) => variant.values.map((v) => v.id).includes(id))
|
|
89
|
+
) ?? [];
|
|
90
|
+
|
|
91
|
+
return getAvailableVariants(varyById).map((value) => ({
|
|
92
|
+
...value,
|
|
93
|
+
isAvailable: matching.some((variant) =>
|
|
94
|
+
variant.values.map((v) => v.id).includes(value.id)
|
|
95
|
+
),
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Exact match only — a partial selection resolves to no variant. */
|
|
100
|
+
function findMatchingVariant() {
|
|
101
|
+
const selected = Object.values(selectedOptions.value);
|
|
102
|
+
|
|
103
|
+
const exact = p.value.product_variants?.find((variant) =>
|
|
104
|
+
variant.values.every((value) => selected.includes(value.id))
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
selectedVariant.value = exact ?? null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Clicking the selected value deselects it — that's deliberate, not a bug. */
|
|
111
|
+
function selectVariant(value) {
|
|
112
|
+
const next = { ...selectedOptions.value };
|
|
113
|
+
|
|
114
|
+
if (next[value.type] === value.id) {
|
|
115
|
+
delete next[value.type];
|
|
116
|
+
selectedOptions.value = next;
|
|
117
|
+
selectedVariant.value = null;
|
|
118
|
+
if (colorType.value && value.type === colorType.value.id) {
|
|
119
|
+
selectedColor.value = null;
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
next[value.type] = value.id;
|
|
123
|
+
selectedOptions.value = next;
|
|
124
|
+
if (colorType.value && value.type === colorType.value.id) {
|
|
125
|
+
selectedColor.value = value;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
findMatchingVariant();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const isSelectionComplete = computed(
|
|
133
|
+
() =>
|
|
134
|
+
variantTypes.value.length > 0 &&
|
|
135
|
+
Object.keys(selectedOptions.value).length === variantTypes.value.length
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
/** "size" — for the "Select a size" prompt. */
|
|
139
|
+
const missingVariantName = computed(() => {
|
|
140
|
+
const missing = variantTypes.value.find((type) => !selectedOptions.value[type.id]);
|
|
141
|
+
return missing ? missing.name.toLowerCase() : "";
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
function reset() {
|
|
145
|
+
selectedOptions.value = {};
|
|
146
|
+
selectedVariant.value = null;
|
|
147
|
+
selectedColor.value = null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// --- stock ---------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
const isComingSoon = computed(() => !!p.value.coming_soon);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Stock, optionally for a specific variant.
|
|
156
|
+
*
|
|
157
|
+
* A function rather than a computed because the answer depends on whether a
|
|
158
|
+
* variant is in play, which only the caller knows: the card asks with the
|
|
159
|
+
* quick-shop selection while the panel is open, and without it when closed.
|
|
160
|
+
*
|
|
161
|
+
* Order matters — an explicit status wins over stock counts, and a variant
|
|
162
|
+
* that doesn't track inventory is always available.
|
|
163
|
+
*/
|
|
164
|
+
function stockFor(variant = null) {
|
|
165
|
+
if (p.value.status === "Out of Stock") return true;
|
|
166
|
+
if (p.value.status === "In Stock") return false;
|
|
167
|
+
|
|
168
|
+
// A missing stock count (null) isn't 0 — not sold out.
|
|
169
|
+
const out = (n) => n !== null && n !== undefined && Number(n) <= 0;
|
|
170
|
+
if (variant) {
|
|
171
|
+
if (!variant.track_inventory) return false;
|
|
172
|
+
return out(variant.remaining_stock);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (p.value.status === "On Demand") return false;
|
|
176
|
+
return out(p.value.remaining_stock);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Sale price for a variant, or null when it isn't on sale. */
|
|
180
|
+
function variantDiscountedPrice(variant) {
|
|
181
|
+
if (!variant) return null;
|
|
182
|
+
const sale = variant.sales?.[0];
|
|
183
|
+
if (!sale) return null;
|
|
184
|
+
|
|
185
|
+
return sale.sale_type === "Percentage"
|
|
186
|
+
? +(variant.price * (1 - sale.discount_value / 100)).toFixed(2)
|
|
187
|
+
: +(variant.price - sale.discount_value).toFixed(2);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
selectedOptions,
|
|
192
|
+
selectedVariant,
|
|
193
|
+
selectedColor,
|
|
194
|
+
variantTypes,
|
|
195
|
+
availableColors,
|
|
196
|
+
colorType,
|
|
197
|
+
hasVariants,
|
|
198
|
+
isSelected,
|
|
199
|
+
getAvailableVariants,
|
|
200
|
+
applyCombinationLogic,
|
|
201
|
+
findMatchingVariant,
|
|
202
|
+
selectVariant,
|
|
203
|
+
isSelectionComplete,
|
|
204
|
+
missingVariantName,
|
|
205
|
+
reset,
|
|
206
|
+
isComingSoon,
|
|
207
|
+
stockFor,
|
|
208
|
+
variantDiscountedPrice,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import CartServices from "#osi/services/CartServices";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Quick-shop: pick a variant and add to cart without leaving the grid.
|
|
5
|
+
*
|
|
6
|
+
* Desktop shows an overlay on the card, mobile a bottom sheet. They're the same
|
|
7
|
+
* state — Terazar tracked them as two independent booleans with two copies of
|
|
8
|
+
* the reset logic, and the mobile one wrote body overflow directly in three
|
|
9
|
+
* places (and cleared it unconditionally on unmount, unlocking the page under
|
|
10
|
+
* an open cart drawer).
|
|
11
|
+
*
|
|
12
|
+
* `mode` is 'DESKTOP' | 'MOBILE' | null, so `isOpen` is one derived value and
|
|
13
|
+
* closing resets once.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* closeOnAdd false: the caller closes (after its own leave animation) —
|
|
17
|
+
* QuickBuy does; ProductCard keeps the default.
|
|
18
|
+
*/
|
|
19
|
+
export function useQuickShop({ product, variants, onAdded, closeOnAdd = true } = {}) {
|
|
20
|
+
const p = computed(() => toValue(product) ?? {});
|
|
21
|
+
|
|
22
|
+
const mode = ref(null);
|
|
23
|
+
const isOpen = computed(() => mode.value !== null);
|
|
24
|
+
const adding = ref(false);
|
|
25
|
+
const errorMessage = ref("");
|
|
26
|
+
|
|
27
|
+
const { fetchCart } = useCart();
|
|
28
|
+
const { showApiError } = useApiErrorToast();
|
|
29
|
+
|
|
30
|
+
// Mobile only: the sheet covers the page, the desktop overlay doesn't.
|
|
31
|
+
useBodyScrollLock(() => mode.value === "MOBILE");
|
|
32
|
+
|
|
33
|
+
function open(next = "DESKTOP") {
|
|
34
|
+
mode.value = next;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function close() {
|
|
38
|
+
mode.value = null;
|
|
39
|
+
errorMessage.value = "";
|
|
40
|
+
variants?.reset();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let errorTimer = null;
|
|
44
|
+
function flashError(message) {
|
|
45
|
+
errorMessage.value = message;
|
|
46
|
+
clearTimeout(errorTimer);
|
|
47
|
+
errorTimer = setTimeout(() => {
|
|
48
|
+
errorMessage.value = "";
|
|
49
|
+
}, 2000);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Add a specific variant. Separate from the guard below so the product page
|
|
54
|
+
* can reuse it once its own selection is complete.
|
|
55
|
+
*/
|
|
56
|
+
async function addVariant(variant, quantity) {
|
|
57
|
+
if (!variant?.id || adding.value) return false;
|
|
58
|
+
// An option with no price can't be bought.
|
|
59
|
+
if (!(Number(variant.price) > 0)) {
|
|
60
|
+
flashError("This option isn't available yet.");
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
adding.value = true;
|
|
65
|
+
try {
|
|
66
|
+
await CartServices.EditCart(
|
|
67
|
+
variant.id,
|
|
68
|
+
quantity ?? p.value.quantity_step ?? 1,
|
|
69
|
+
"add",
|
|
70
|
+
{ product: p.value, variant }
|
|
71
|
+
);
|
|
72
|
+
await fetchCart();
|
|
73
|
+
onAdded?.(variant);
|
|
74
|
+
if (closeOnAdd) close();
|
|
75
|
+
return true;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
// Stock and cart-limit errors come back as field errors, so they need the
|
|
78
|
+
// flattening — a bare error.message here would print "[object Object]".
|
|
79
|
+
flashError(showApiError(error, "Couldn't add that to your cart."));
|
|
80
|
+
return false;
|
|
81
|
+
} finally {
|
|
82
|
+
adding.value = false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The panel's own button: refuse politely if the selection is incomplete. */
|
|
87
|
+
async function addSelected() {
|
|
88
|
+
if (!variants) return false;
|
|
89
|
+
|
|
90
|
+
if (!variants.isSelectionComplete.value) {
|
|
91
|
+
flashError(`Select a ${variants.missingVariantName.value}`);
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
if (!variants.selectedVariant.value) {
|
|
95
|
+
flashError("That combination isn't available.");
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return addVariant(variants.selectedVariant.value);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
onScopeDispose(() => clearTimeout(errorTimer));
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
mode,
|
|
106
|
+
isOpen,
|
|
107
|
+
adding,
|
|
108
|
+
errorMessage,
|
|
109
|
+
open,
|
|
110
|
+
close,
|
|
111
|
+
addVariant,
|
|
112
|
+
addSelected,
|
|
113
|
+
flashError,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import CategoryServices from "#osi/services/CategoryServices";
|
|
2
|
+
import ProductServices from "#osi/services/ProductServices";
|
|
3
|
+
import { apiService } from "#osi/services/ApiServices";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* "You may also like".
|
|
7
|
+
*
|
|
8
|
+
* Client-side by design: it's below the fold and the least important thing on
|
|
9
|
+
* the page, so it stays out of the first render. If the backend resolved it
|
|
10
|
+
* (section.data.products), that wins and nothing is fetched.
|
|
11
|
+
*
|
|
12
|
+
* Where the products come from (SOURCE):
|
|
13
|
+
* SAME_CATEGORY the category the shopper browsed through, else the
|
|
14
|
+
* product's first category (the original behaviour)
|
|
15
|
+
* CLASSIFICATION one category or collection the merchant picked
|
|
16
|
+
* LATEST the newest products in the store
|
|
17
|
+
* NONE only the pinned ones
|
|
18
|
+
*
|
|
19
|
+
* Pinned products (PRODUCT blocks) always show, in the merchant's order,
|
|
20
|
+
* before whatever the source fills the rest of the row with. RANDOMIZE pulls a
|
|
21
|
+
* bigger pool and shuffles it, so the row differs between visits instead of
|
|
22
|
+
* showing the same four products forever.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const shuffle = (rows) => {
|
|
26
|
+
const out = [...rows];
|
|
27
|
+
for (let i = out.length - 1; i > 0; i -= 1) {
|
|
28
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
29
|
+
[out[i], out[j]] = [out[j], out[i]];
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const rowsOf = (response) => (Array.isArray(response) ? response : response?.results ?? []);
|
|
35
|
+
|
|
36
|
+
export function useRelatedProducts({
|
|
37
|
+
product,
|
|
38
|
+
limit = 4,
|
|
39
|
+
preloaded = null,
|
|
40
|
+
settings = null,
|
|
41
|
+
pinnedIds = null,
|
|
42
|
+
} = {}) {
|
|
43
|
+
const route = useRoute();
|
|
44
|
+
const baseUrl = useState("base-url", () => "");
|
|
45
|
+
|
|
46
|
+
const p = computed(() => toValue(product) ?? null);
|
|
47
|
+
const max = computed(() => Number(toValue(limit)) || 4);
|
|
48
|
+
const s = computed(() => toValue(settings) ?? {});
|
|
49
|
+
const pins = computed(() => (toValue(pinnedIds) ?? []).filter(Boolean));
|
|
50
|
+
|
|
51
|
+
const source = computed(() => String(s.value.SOURCE || "SAME_CATEGORY").toUpperCase());
|
|
52
|
+
const randomize = computed(() => !!s.value.RANDOMIZE);
|
|
53
|
+
const pool = computed(() => Math.min(60, Math.max(max.value, Number(s.value.POOL_SIZE) || 24)));
|
|
54
|
+
|
|
55
|
+
const fetched = ref([]);
|
|
56
|
+
const pinned = ref([]);
|
|
57
|
+
const loading = ref(false);
|
|
58
|
+
|
|
59
|
+
const products = computed(() => {
|
|
60
|
+
const given = toValue(preloaded) ?? [];
|
|
61
|
+
// Preloaded data only matches the default source, and can't know about pins.
|
|
62
|
+
const usePreloaded = given.length && source.value === "SAME_CATEGORY" && !pins.value.length;
|
|
63
|
+
const rows = usePreloaded ? given : [...pinned.value, ...fetched.value];
|
|
64
|
+
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
return rows
|
|
67
|
+
.filter((row) => {
|
|
68
|
+
if (!row?.id || row.id === p.value?.id || seen.has(row.id)) return false;
|
|
69
|
+
seen.add(row.id);
|
|
70
|
+
return true;
|
|
71
|
+
})
|
|
72
|
+
.slice(0, max.value);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/** The category the shopper came through, else the product's first. */
|
|
76
|
+
function categoryIdFor(current) {
|
|
77
|
+
const categories = current?.categories ?? [];
|
|
78
|
+
if (!categories.length) return null;
|
|
79
|
+
const slug = route.params.category;
|
|
80
|
+
const matched =
|
|
81
|
+
categories.find(
|
|
82
|
+
(entry) =>
|
|
83
|
+
entry.category?.slugified_name === slug ||
|
|
84
|
+
entry.category?.parent_category?.slugified_name === slug
|
|
85
|
+
) ?? categories[0];
|
|
86
|
+
return matched?.category?.id ?? null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The merchant's pick: { kind, id } or the encoded "CATEGORY:12". */
|
|
90
|
+
function pickedClassification() {
|
|
91
|
+
const raw = s.value.SOURCE_CLASSIFICATION;
|
|
92
|
+
if (!raw) return null;
|
|
93
|
+
if (typeof raw === "object") {
|
|
94
|
+
if (raw.id == null) return null;
|
|
95
|
+
const kind = String(raw.kind ?? raw.type ?? "CATEGORY").toUpperCase();
|
|
96
|
+
return { kind: kind.includes("COLLECTION") ? "COLLECTION" : "CATEGORY", id: raw.id };
|
|
97
|
+
}
|
|
98
|
+
const [kind, id] = String(raw).split(":");
|
|
99
|
+
if (!id) return null;
|
|
100
|
+
return {
|
|
101
|
+
kind: String(kind).toUpperCase().includes("COLLECTION") ? "COLLECTION" : "CATEGORY",
|
|
102
|
+
id: /^\d+$/.test(id) ? Number(id) : id,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function listProducts(params) {
|
|
107
|
+
const sp = new URLSearchParams(params);
|
|
108
|
+
return apiService(`${baseUrl.value}store/apis/products/?${sp.toString()}`, { method: "GET" });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let token = 0;
|
|
112
|
+
|
|
113
|
+
async function load() {
|
|
114
|
+
const current = p.value;
|
|
115
|
+
if (!current?.id || current.is_placeholder) return;
|
|
116
|
+
|
|
117
|
+
const mine = ++token;
|
|
118
|
+
loading.value = true;
|
|
119
|
+
const size = randomize.value ? pool.value : max.value + 1 + pins.value.length;
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
// Pinned products, in the merchant's order.
|
|
123
|
+
if (pins.value.length) {
|
|
124
|
+
const response = await ProductServices.getProductsByIds(pins.value, baseUrl.value);
|
|
125
|
+
if (mine !== token) return;
|
|
126
|
+
const byId = new Map(rowsOf(response).map((row) => [String(row.id), row]));
|
|
127
|
+
pinned.value = pins.value.map((id) => byId.get(String(id))).filter(Boolean);
|
|
128
|
+
} else {
|
|
129
|
+
pinned.value = [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (source.value === "NONE" || pinned.value.length >= max.value) {
|
|
133
|
+
if (mine === token) fetched.value = [];
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let rows = [];
|
|
138
|
+
if (source.value === "CLASSIFICATION") {
|
|
139
|
+
const pick = pickedClassification();
|
|
140
|
+
if (pick) {
|
|
141
|
+
rows = rowsOf(
|
|
142
|
+
await listProducts({
|
|
143
|
+
[pick.kind === "COLLECTION" ? "collections" : "categories"]: pick.id,
|
|
144
|
+
page_size: size,
|
|
145
|
+
})
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
} else if (source.value === "LATEST") {
|
|
149
|
+
rows = rowsOf(await listProducts({ page_size: size, ordering: "-created_at" }));
|
|
150
|
+
} else {
|
|
151
|
+
const categoryId = categoryIdFor(current);
|
|
152
|
+
rows = categoryId
|
|
153
|
+
? rowsOf(await CategoryServices.getCategoryProducts(categoryId, { pageSize: size }))
|
|
154
|
+
: [];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Out-of-order responses: navigating A -> B -> C must not end up showing
|
|
158
|
+
// A's siblings because its request finished last.
|
|
159
|
+
if (mine !== token) return;
|
|
160
|
+
fetched.value = randomize.value ? shuffle(rows) : rows;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (mine !== token) return;
|
|
163
|
+
console.warn("[useRelatedProducts] fetch failed:", error);
|
|
164
|
+
fetched.value = [];
|
|
165
|
+
} finally {
|
|
166
|
+
if (mine === token) loading.value = false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (import.meta.client) {
|
|
171
|
+
watch(
|
|
172
|
+
() =>
|
|
173
|
+
[
|
|
174
|
+
p.value?.id,
|
|
175
|
+
source.value,
|
|
176
|
+
pins.value.join(","),
|
|
177
|
+
JSON.stringify(s.value.SOURCE_CLASSIFICATION ?? null),
|
|
178
|
+
randomize.value,
|
|
179
|
+
].join("|"),
|
|
180
|
+
load,
|
|
181
|
+
{ immediate: true }
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { products, loading, reload: load };
|
|
186
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The current route's render() payload, shared page -> sections.
|
|
3
|
+
*
|
|
4
|
+
* SectionRenderer passes each component only `section` and `index`. That's
|
|
5
|
+
* right for the twenty content types — they're self-contained. It isn't enough
|
|
6
|
+
* for a MAIN_* section: MainClassificationSection has to know *which* category
|
|
7
|
+
* it's showing, and that lives in the payload's `resource`, not in the section.
|
|
8
|
+
*
|
|
9
|
+
* Prop-drilling it through SectionRenderer would mean every section signature
|
|
10
|
+
* grows a field only four of them use, and it wouldn't work in the builder
|
|
11
|
+
* anyway — there the payload arrives over postMessage with no navigation, so
|
|
12
|
+
* there's no page render to drill from. Shared state covers both.
|
|
13
|
+
*
|
|
14
|
+
* useState, not a module ref: one Nuxt process serves every tenant.
|
|
15
|
+
*
|
|
16
|
+
* Pages set it; MAIN_* sections read it:
|
|
17
|
+
*
|
|
18
|
+
* // pages/categories/[slug].vue
|
|
19
|
+
* const { data } = await useAsyncData(key, () => render(path));
|
|
20
|
+
* setRenderContext(data.value);
|
|
21
|
+
*
|
|
22
|
+
* // theme/sections/MainClassificationSection.vue
|
|
23
|
+
* const { resource } = useRenderContext();
|
|
24
|
+
*/
|
|
25
|
+
export function useRenderContext() {
|
|
26
|
+
const context = useState("render-context", () => null);
|
|
27
|
+
|
|
28
|
+
const template = computed(() => context.value?.template ?? null);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What this route resolved to, from the backend's resolve_path():
|
|
32
|
+
* { type: 'CATEGORY'|'COLLECTION'|'PRODUCT'|'PAGE', id, name, slug, ... }
|
|
33
|
+
* null on INDEX, SEARCH, CART and ACCOUNT — those routes resolve no object.
|
|
34
|
+
*/
|
|
35
|
+
const resource = computed(() => context.value?.resource ?? null);
|
|
36
|
+
|
|
37
|
+
const resourceType = computed(() => resource.value?.type ?? null);
|
|
38
|
+
const sections = computed(() => context.value?.sections ?? []);
|
|
39
|
+
|
|
40
|
+
function setRenderContext(payload) {
|
|
41
|
+
context.value = payload ?? null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { context, template, resource, resourceType, sections, setRenderContext };
|
|
45
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope merchant-authored CSS to one element, and strip what shouldn't be there.
|
|
3
|
+
*
|
|
4
|
+
* Custom CSS is far safer than custom HTML or SVG — no script execution — but
|
|
5
|
+
* it isn't free:
|
|
6
|
+
*
|
|
7
|
+
* - `</style>` breaks out of the tag and turns CSS into markup. Removed.
|
|
8
|
+
* - `@import` fetches a third-party stylesheet on every page load: a
|
|
9
|
+
* render-blocking request to a host the merchant doesn't control. Removed.
|
|
10
|
+
* - `expression(` and `javascript:` are legacy IE script vectors. Removed.
|
|
11
|
+
* - Unscoped, `.price { display: none }` would hit every price on the site.
|
|
12
|
+
* Every top-level selector is prefixed with the caller's scope selector.
|
|
13
|
+
*
|
|
14
|
+
* The prefixer tracks brace depth rather than splitting on `}`, so conditional
|
|
15
|
+
* at-rules (`@media`, `@supports`, `@container`, `@layer`) scope the rules
|
|
16
|
+
* inside them. `@keyframes` is deliberately excluded — its children are `from`,
|
|
17
|
+
* `to` and percentages, and prefixing those produces `[data-x] from`, which is
|
|
18
|
+
* not a valid keyframe selector and silently kills the animation.
|
|
19
|
+
*/
|
|
20
|
+
const SCOPING_AT_RULES = /^@(media|supports|container|layer|scope)\b/i;
|
|
21
|
+
|
|
22
|
+
export function scopeCss(css, scope) {
|
|
23
|
+
if (!css || typeof css !== "string" || !scope) return "";
|
|
24
|
+
|
|
25
|
+
const clean = css
|
|
26
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
27
|
+
.replace(/<\/\s*style/gi, "")
|
|
28
|
+
.replace(/@import[^;]*;?/gi, "")
|
|
29
|
+
.replace(/expression\s*\(/gi, "(")
|
|
30
|
+
.replace(/javascript\s*:/gi, "");
|
|
31
|
+
|
|
32
|
+
const out = [];
|
|
33
|
+
let buffer = "";
|
|
34
|
+
let depth = 0;
|
|
35
|
+
// At each depth: does a rule opened here get its selectors scoped?
|
|
36
|
+
const scopeAt = [true];
|
|
37
|
+
|
|
38
|
+
for (const char of clean) {
|
|
39
|
+
if (char === "{") {
|
|
40
|
+
const head = buffer.trim();
|
|
41
|
+
buffer = "";
|
|
42
|
+
|
|
43
|
+
if (head.startsWith("@")) {
|
|
44
|
+
scopeAt[depth + 1] = SCOPING_AT_RULES.test(head);
|
|
45
|
+
out.push(`${head}{`);
|
|
46
|
+
} else if (scopeAt[depth]) {
|
|
47
|
+
scopeAt[depth + 1] = false;
|
|
48
|
+
out.push(`${prefixSelectors(head, scope)}{`);
|
|
49
|
+
} else {
|
|
50
|
+
scopeAt[depth + 1] = false;
|
|
51
|
+
out.push(`${head}{`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
depth += 1;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (char === "}") {
|
|
59
|
+
out.push(buffer.trim(), "}");
|
|
60
|
+
buffer = "";
|
|
61
|
+
depth = Math.max(0, depth - 1);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
buffer += char;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return out.join("");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function prefixSelectors(selectorList, scope) {
|
|
72
|
+
return selectorList
|
|
73
|
+
.split(",")
|
|
74
|
+
.map((selector) => {
|
|
75
|
+
const trimmed = selector.trim();
|
|
76
|
+
if (!trimmed) return "";
|
|
77
|
+
// `&` lets a merchant target the scoped element itself: `& { border: … }`
|
|
78
|
+
if (trimmed.startsWith("&")) return trimmed.replace(/^&/, scope);
|
|
79
|
+
return `${scope} ${trimmed}`;
|
|
80
|
+
})
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.join(",");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Scope a reactive CSS string and inject it once.
|
|
87
|
+
*
|
|
88
|
+
* `key` dedupes in useHead, so twenty cards sharing one stylesheet emit one
|
|
89
|
+
* <style> rather than twenty. Give each *section instance* its own key (the
|
|
90
|
+
* section id) but share one key across repeated components.
|
|
91
|
+
*
|
|
92
|
+
* @param {string|Ref|(() => string)} css
|
|
93
|
+
* @param {string|Ref|(() => string)} scope e.g. '[data-osi-card]'
|
|
94
|
+
* @param {string} key
|
|
95
|
+
*/
|
|
96
|
+
export function useScopedCss(css, scope, key) {
|
|
97
|
+
const scoped = computed(() => scopeCss(toValue(css), toValue(scope)));
|
|
98
|
+
|
|
99
|
+
useHead({
|
|
100
|
+
style: computed(() =>
|
|
101
|
+
scoped.value ? [{ key, innerHTML: scoped.value }] : []
|
|
102
|
+
),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return scoped;
|
|
106
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import SearchServices from "#osi/services/SearchServices";
|
|
2
|
+
|
|
3
|
+
export function useSearch() {
|
|
4
|
+
const route = useRoute();
|
|
5
|
+
const searchQuery = computed(() => route.query.q || "");
|
|
6
|
+
const products = useState("search-products", () => []);
|
|
7
|
+
const loading = useState("search-loading", () => false);
|
|
8
|
+
|
|
9
|
+
async function fetchResults() {
|
|
10
|
+
if (!searchQuery.value) return;
|
|
11
|
+
loading.value = true;
|
|
12
|
+
try {
|
|
13
|
+
const response = await SearchServices.search(searchQuery.value);
|
|
14
|
+
products.value = response.results || response || [];
|
|
15
|
+
} catch (error) {
|
|
16
|
+
console.error("[useSearch] Error fetching search results:", error);
|
|
17
|
+
products.value = [];
|
|
18
|
+
} finally {
|
|
19
|
+
loading.value = false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
watch(() => route.query.q, (newQ) => {
|
|
24
|
+
if (route.path.startsWith("/search") && newQ) {
|
|
25
|
+
fetchResults();
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
searchQuery,
|
|
31
|
+
products,
|
|
32
|
+
loading,
|
|
33
|
+
fetchResults,
|
|
34
|
+
};
|
|
35
|
+
}
|