@crm-market/template-shared 1.0.6 → 1.0.8
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/assets/scss/_variables.scss +3 -3
- package/assets/scss/components/_header.scss +1 -1
- package/assets/scss/style.css +4 -4
- package/components/AboutUs/StaticContent.vue +32 -0
- package/components/Cart/CartEmpty.vue +42 -0
- package/components/Cart/CartItem.vue +145 -0
- package/components/Cart/CartSummary.vue +57 -0
- package/components/Cart/ProductQuantity.vue +23 -13
- package/components/Cart/index.vue +68 -150
- package/components/Categories/index.vue +88 -291
- package/components/Checkout/AddressFields.vue +74 -0
- package/components/Checkout/CheckoutForm.vue +75 -0
- package/components/Checkout/CityAutocomplete.vue +68 -0
- package/components/Checkout/DeliveryMethodCard.vue +66 -0
- package/components/Checkout/DeliveryOptions.vue +148 -0
- package/components/Checkout/OrderSummary.vue +116 -0
- package/components/Checkout/PaymentOptions.vue +76 -0
- package/components/Checkout/PickupPoints.vue +46 -0
- package/components/Checkout/PromoCode.vue +166 -0
- package/components/Checkout/index.vue +235 -173
- package/components/Checkout/types.ts +45 -0
- package/components/Common/PageBanner.vue +22 -20
- package/components/Common/ProductCard.vue +139 -115
- package/components/Common/Services.vue +4 -4
- package/components/Contact/ContactForm.vue +44 -74
- package/components/Contact/ContactInfo.vue +35 -61
- package/components/FAQ/index.vue +6 -119
- package/components/Layout/Copyright.vue +11 -9
- package/components/Layout/Footer.vue +49 -136
- package/components/Layout/HeaderBar.vue +174 -0
- package/components/Layout/HeaderLogo.vue +27 -0
- package/components/Layout/MiddleHeader.vue +1 -228
- package/components/Layout/MiddleHeaderThree.vue +1 -203
- package/components/Layout/MiddleHeaderTwo.vue +1 -239
- package/components/Layout/NavLinks.vue +23 -0
- package/components/Layout/Navbar.vue +38 -151
- package/components/Layout/NavbarStyleFour.vue +1 -332
- package/components/Layout/NavbarStyleThree.vue +1 -188
- package/components/Layout/NavbarStyleTwo.vue +1 -108
- package/components/Layout/Preloader.vue +5 -12
- package/components/Layout/ResponsiveNavbar.vue +32 -418
- package/components/Layout/SitewideBanner.vue +285 -0
- package/components/Layout/TopHeader.vue +17 -83
- package/components/Layout/layoutData.ts +83 -0
- package/components/PrivacyPolicy/index.vue +6 -110
- package/components/ProductDetails/ProductDetailsTab.vue +31 -321
- package/components/ProductDetails/ProductGallery.vue +45 -0
- package/components/ProductDetails/ProductQuantity.vue +8 -14
- package/components/ProductDetails/RecentlyViewed.vue +53 -289
- package/components/ProductDetails/RelatedProducts.vue +64 -0
- package/components/ProductDetails/index.vue +90 -230
- package/components/Products/index.vue +78 -16
- package/components/TermsConditions/index.vue +6 -110
- package/components/Wishlist/index.vue +62 -88
- package/composables/useCart.ts +133 -81
- package/composables/useCategories.ts +67 -31
- package/composables/useCategoryImages.ts +45 -0
- package/composables/useCheckout.ts +302 -97
- package/composables/useContactForm.ts +57 -0
- package/composables/useHomeContent.ts +220 -0
- package/composables/useI18n.ts +12 -24
- package/composables/useJsonLd.ts +162 -0
- package/composables/useProducts.ts +244 -83
- package/composables/useRecent.ts +30 -0
- package/composables/useSanitize.ts +76 -0
- package/composables/useSiteConfig.ts +51 -47
- package/composables/useStaticPages.ts +48 -0
- package/composables/useStoreOrigin.ts +32 -0
- package/composables/useTemplateSections.ts +51 -3
- package/composables/useThemeVars.ts +113 -0
- package/composables/useWishlist.ts +72 -0
- package/layouts/default.vue +80 -19
- package/locales/en.json +255 -86
- package/locales/uk.json +251 -82
- package/modules/template-schema.ts +35 -0
- package/nuxt.config.ts +8 -11
- package/package.json +1 -1
- package/pages/about-us.vue +9 -9
- package/pages/cart.vue +0 -1
- package/pages/categories.vue +0 -1
- package/pages/category/[slug].vue +84 -0
- package/pages/checkout.vue +0 -1
- package/pages/contact.vue +6 -6
- package/pages/delivery.vue +12 -0
- package/pages/faq.vue +6 -6
- package/pages/order-success.vue +113 -0
- package/pages/privacy-policy.vue +6 -6
- package/pages/product/[slug].vue +82 -0
- package/pages/product-details.vue +14 -6
- package/pages/products.vue +12 -1
- package/pages/returns.vue +12 -0
- package/pages/terms-conditions.vue +6 -6
- package/pages/wishlist.vue +2 -2
- package/utils/image.ts +0 -1
- package/utils/seo-urls.ts +70 -0
- package/utils/store-origin.ts +36 -0
- package/components/AboutUs/AboutUsTuan.vue +0 -25
- package/components/AboutUs/Statistics.vue +0 -42
- package/components/AboutUs/SubscribeToTheNewsletter.vue +0 -57
- package/components/AddAddress/index.vue +0 -68
- package/components/BestSellers/Products.vue +0 -1562
- package/components/BestSellers/RecentlyViewed.vue +0 -304
- package/components/ChangePassword/index.vue +0 -69
- package/components/Common/DashboardNavigation.vue +0 -35
- package/components/Dashboard/RecentOrder.vue +0 -173
- package/components/Dashboard/index.vue +0 -77
- package/components/EditAddress/index.vue +0 -117
- package/components/EditProfile/index.vue +0 -95
- package/components/FeaturedProduct/FeaturedProducts.vue +0 -304
- package/components/FeaturedProduct/Products.vue +0 -1562
- package/components/ForgotPassword/index.vue +0 -49
- package/components/Layout/FooterTwo.vue +0 -159
- package/components/Layout/LocationOption.vue +0 -178
- package/components/Layout/RTLSwitchBtn.vue +0 -40
- package/components/Login/index.vue +0 -89
- package/components/MyAccount/index.vue +0 -154
- package/components/NewArrivals/Products.vue +0 -1969
- package/components/NewArrivals/RecentlyViewed.vue +0 -304
- package/components/OrderDetails/index.vue +0 -75
- package/components/OrderHistory/index.vue +0 -195
- package/components/Products/RecentlyViewed.vue +0 -302
- package/components/Register/index.vue +0 -83
- package/components/TrendingProducts/FeaturedProducts.vue +0 -304
- package/components/TrendingProducts/Products.vue +0 -1564
- package/components/Wishlist/ProductQuantity.vue +0 -29
- package/layouts/inner.vue +0 -70
- package/locales/ar.json +0 -234
- package/locales/ru.json +0 -234
- package/pages/address.vue +0 -11
- package/pages/change-password.vue +0 -11
- package/pages/dashboard.vue +0 -11
- package/pages/edit-address.vue +0 -11
- package/pages/edit-profile.vue +0 -11
- package/pages/forgot-password.vue +0 -11
- package/pages/login.vue +0 -11
- package/pages/my-account.vue +0 -11
- package/pages/order-details.vue +0 -11
- package/pages/order-history.vue +0 -11
- package/pages/register.vue +0 -11
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { computed } from 'vue';
|
|
2
|
+
import { useSiteConfig } from './useSiteConfig';
|
|
3
|
+
import { resolveMediaUrl } from './useProducts';
|
|
4
|
+
|
|
5
|
+
export interface HeroSlide {
|
|
6
|
+
id: string;
|
|
7
|
+
image: string;
|
|
8
|
+
subTitle: string;
|
|
9
|
+
heading: string;
|
|
10
|
+
shortDesc: string;
|
|
11
|
+
btnText: string;
|
|
12
|
+
btnLink: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PromoItem {
|
|
16
|
+
id: string;
|
|
17
|
+
/** Верхній підпис картки (offers.cards[].topTitle) */
|
|
18
|
+
topTitle: string;
|
|
19
|
+
title: string;
|
|
20
|
+
text: string;
|
|
21
|
+
/** Текст знижки (offers.cards[].discount) */
|
|
22
|
+
discount: string;
|
|
23
|
+
buttonText: string;
|
|
24
|
+
link: string;
|
|
25
|
+
image: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PromoBanner {
|
|
29
|
+
topTitle: string;
|
|
30
|
+
heading: string;
|
|
31
|
+
description: string;
|
|
32
|
+
image: string;
|
|
33
|
+
buttonText: string;
|
|
34
|
+
buttonLink: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const str = (v: any): string => (typeof v === 'string' ? v.trim() : v == null ? '' : String(v));
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Контент головної з конфігу магазину.
|
|
41
|
+
*
|
|
42
|
+
* Пріоритет джерел (нове → старе):
|
|
43
|
+
* - hero: values.banner (bannerType/bannerImage/bannerSlides) → siteConfig.banners →
|
|
44
|
+
* customization.banner.slides → heroImageUrl;
|
|
45
|
+
* - карусель банерів («Банери» в адмінці): siteConfig.banners — окремим блоком під hero,
|
|
46
|
+
* якщо hero вже взято з values.banner (інакше ці банери самі стають hero);
|
|
47
|
+
* - промо-картки: values.offers.cards → values.promo.items (контракт v1);
|
|
48
|
+
* - промо-банери: values.weekendSale / values.weekendSaleTwo → sitewideBanner / promo.items[3].
|
|
49
|
+
* Порожні дані → порожні масиви/null (секція не рендериться).
|
|
50
|
+
*/
|
|
51
|
+
export const useHomeContent = () => {
|
|
52
|
+
const { siteConfig } = useSiteConfig();
|
|
53
|
+
const cfg = computed<any>(() => siteConfig.value || {});
|
|
54
|
+
const values = computed<Record<string, any>>(() => cfg.value.customization?.values || {});
|
|
55
|
+
|
|
56
|
+
// Слайди з «Налаштувань шаблону» → values.banner
|
|
57
|
+
const settingsSlides = computed<HeroSlide[]>(() => {
|
|
58
|
+
const b = values.value.banner || {};
|
|
59
|
+
const slides: any[] = Array.isArray(b.bannerSlides) ? b.bannerSlides : [];
|
|
60
|
+
const image = str(b.bannerImage);
|
|
61
|
+
// Тип не обрано явно (дефолт схеми — single), але слайди заповнені — показуємо слайди
|
|
62
|
+
const type = b.bannerType || (slides.length && !image ? 'slider' : 'single');
|
|
63
|
+
|
|
64
|
+
if (type === 'slider') {
|
|
65
|
+
return slides
|
|
66
|
+
.filter((s) => s && (str(s.image) || str(s.title)))
|
|
67
|
+
.map((s, i) => ({
|
|
68
|
+
id: `v${i}`,
|
|
69
|
+
image: resolveMediaUrl(str(s.image)),
|
|
70
|
+
subTitle: str(s.subtitle),
|
|
71
|
+
heading: str(s.title),
|
|
72
|
+
shortDesc: str(s.description),
|
|
73
|
+
btnText: str(s.buttonText),
|
|
74
|
+
btnLink: str(s.buttonLink) || '/products',
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
if (type === 'single' && image) {
|
|
78
|
+
return [{ id: 'single', image: resolveMediaUrl(image), subTitle: '', heading: '', shortDesc: '', btnText: '', btnLink: '' }];
|
|
79
|
+
}
|
|
80
|
+
// 'video' у схемі без поля відео — використовуємо фолбеки нижче
|
|
81
|
+
return [];
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Банери, завантажені через «Банери» (config/banners); вимикач showCarousel ховає їх
|
|
85
|
+
const uploadedBanners = computed<HeroSlide[]>(() => {
|
|
86
|
+
const c = cfg.value;
|
|
87
|
+
if (c.customization?.componentSettings?.homepage?.showCarousel === false) return [];
|
|
88
|
+
const banners: any[] = Array.isArray(c.banners) ? c.banners : [];
|
|
89
|
+
return [...banners]
|
|
90
|
+
.filter((b) => b && b.url)
|
|
91
|
+
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
|
92
|
+
.map((b, i) => ({
|
|
93
|
+
id: `b${i}`,
|
|
94
|
+
image: resolveMediaUrl(b.url),
|
|
95
|
+
subTitle: str(b.subtitle),
|
|
96
|
+
heading: str(b.title || b.alt),
|
|
97
|
+
shortDesc: '',
|
|
98
|
+
btnText: str(b.buttonLabel),
|
|
99
|
+
btnLink: str(b.buttonLink || b.link) || '/products',
|
|
100
|
+
}));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const heroSlides = computed<HeroSlide[]>(() => {
|
|
104
|
+
if (settingsSlides.value.length) return settingsSlides.value;
|
|
105
|
+
if (uploadedBanners.value.length) return uploadedBanners.value;
|
|
106
|
+
|
|
107
|
+
const c = cfg.value;
|
|
108
|
+
const banner = c.customization?.banner || {};
|
|
109
|
+
const slides: any[] = Array.isArray(banner.slides) ? banner.slides : [];
|
|
110
|
+
if (slides.length) {
|
|
111
|
+
return slides.map((s, i) => ({
|
|
112
|
+
id: `s${i}`,
|
|
113
|
+
image: resolveMediaUrl(s.image || s.imageUrl),
|
|
114
|
+
subTitle: s.subtitle || s.subTitle || '',
|
|
115
|
+
heading: s.title || s.heading || '',
|
|
116
|
+
shortDesc: s.description || '',
|
|
117
|
+
btnText: s.buttonText || '',
|
|
118
|
+
btnLink: s.buttonLink || '/products',
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (c.heroImageUrl || c.siteName) {
|
|
123
|
+
return [{
|
|
124
|
+
id: 'default',
|
|
125
|
+
image: resolveMediaUrl(c.heroImageUrl),
|
|
126
|
+
subTitle: '',
|
|
127
|
+
heading: c.siteName || '',
|
|
128
|
+
shortDesc: c.siteDescription || '',
|
|
129
|
+
btnText: '',
|
|
130
|
+
btnLink: '/products',
|
|
131
|
+
}];
|
|
132
|
+
}
|
|
133
|
+
return [];
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Карусель «Банери» окремим блоком — лише коли hero зайнятий слайдами з налаштувань шаблону
|
|
137
|
+
const carouselBanners = computed<HeroSlide[]>(() =>
|
|
138
|
+
settingsSlides.value.length ? uploadedBanners.value : [],
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// Промо-картки: values.offers.cards (схема grocery) → values.promo.items (контракт v1)
|
|
142
|
+
const promoItems = computed<PromoItem[]>(() => {
|
|
143
|
+
const cards = values.value.offers?.cards;
|
|
144
|
+
if (Array.isArray(cards) && cards.length) {
|
|
145
|
+
return cards
|
|
146
|
+
.filter((p: any) => p && (str(p.heading) || str(p.image)))
|
|
147
|
+
.map((p: any, i: number) => ({
|
|
148
|
+
id: `o${i}`,
|
|
149
|
+
topTitle: str(p.topTitle),
|
|
150
|
+
title: str(p.heading),
|
|
151
|
+
text: str(p.description),
|
|
152
|
+
discount: str(p.discount),
|
|
153
|
+
buttonText: str(p.buttonText),
|
|
154
|
+
link: str(p.buttonLink) || '/products',
|
|
155
|
+
image: resolveMediaUrl(str(p.image)),
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
const items = values.value.promo?.items;
|
|
159
|
+
if (!Array.isArray(items)) return [];
|
|
160
|
+
return items
|
|
161
|
+
.filter((p: any) => p && p.title)
|
|
162
|
+
.slice(0, 4)
|
|
163
|
+
.map((p: any, i: number) => ({
|
|
164
|
+
id: `p${i}`,
|
|
165
|
+
topTitle: '',
|
|
166
|
+
title: String(p.title),
|
|
167
|
+
text: p.text || '',
|
|
168
|
+
discount: '',
|
|
169
|
+
buttonText: p.buttonText || '',
|
|
170
|
+
link: p.link || '/products',
|
|
171
|
+
image: resolveMediaUrl(p.imageUrl || p.image),
|
|
172
|
+
}));
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Наскрізний банер (якщо увімкнений і має заголовок)
|
|
176
|
+
const sitewide = computed(() => {
|
|
177
|
+
const s = cfg.value.sitewideBanner;
|
|
178
|
+
if (!s || s.enabled !== true || !s.title) return null;
|
|
179
|
+
return {
|
|
180
|
+
title: String(s.title),
|
|
181
|
+
text: s.subtitle || '',
|
|
182
|
+
buttonText: s.buttonLabel || '',
|
|
183
|
+
link: s.buttonLink || '/products',
|
|
184
|
+
image: resolveMediaUrl(s.imageUrl),
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Промо-банер секції (weekendSale / weekendSaleTwo): потрібен заголовок або картинка
|
|
189
|
+
const promoBanner = (sectionKey: string): PromoBanner | null => {
|
|
190
|
+
const v = values.value[sectionKey] || {};
|
|
191
|
+
const heading = str(v.heading);
|
|
192
|
+
const image = str(v.image);
|
|
193
|
+
if (!heading && !image) return null;
|
|
194
|
+
return {
|
|
195
|
+
topTitle: str(v.topTitle),
|
|
196
|
+
heading,
|
|
197
|
+
description: str(v.description),
|
|
198
|
+
image: resolveMediaUrl(image),
|
|
199
|
+
buttonText: str(v.buttonText),
|
|
200
|
+
buttonLink: str(v.buttonLink) || '/products',
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// Оформлення hero з values.banner: колір/картинка фону, затемнення картинки, колір тексту, декор
|
|
205
|
+
const HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
206
|
+
const heroBackground = computed(() => {
|
|
207
|
+
const b = values.value.banner || {};
|
|
208
|
+
const overlay = Math.round(Number(b.backgroundOverlay));
|
|
209
|
+
return {
|
|
210
|
+
color: HEX.test(str(b.backgroundColor)) ? str(b.backgroundColor) : '',
|
|
211
|
+
image: resolveMediaUrl(str(b.backgroundImage)),
|
|
212
|
+
overlay: Number.isFinite(overlay) ? Math.min(80, Math.max(0, overlay)) : 0,
|
|
213
|
+
textColor: HEX.test(str(b.textColor)) ? str(b.textColor) : '',
|
|
214
|
+
// Декор показуємо, доки продавець явно не вимкнув
|
|
215
|
+
showDecorations: b.showDecorations !== false,
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
return { heroSlides, carouselBanners, promoItems, sitewide, promoBanner, heroBackground, values };
|
|
220
|
+
};
|
package/composables/useI18n.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ref, computed } from 'vue';
|
|
2
2
|
|
|
3
|
-
type LocaleCode = 'en' | 'uk'
|
|
3
|
+
type LocaleCode = 'en' | 'uk';
|
|
4
4
|
|
|
5
5
|
interface LocaleOption {
|
|
6
6
|
code: LocaleCode;
|
|
@@ -12,21 +12,20 @@ interface LocaleOption {
|
|
|
12
12
|
const LOCALES: LocaleOption[] = [
|
|
13
13
|
{ code: 'en', name: 'English', dir: 'ltr', flag: '\u{1F1EC}\u{1F1E7}' },
|
|
14
14
|
{ code: 'uk', name: '\u0423\u043A\u0440\u0430\u0457\u043D\u0441\u044C\u043A\u0430', dir: 'ltr', flag: '\u{1F1FA}\u{1F1E6}' },
|
|
15
|
-
{ code: 'ru', name: '\u0420\u0443\u0441\u0441\u043A\u0438\u0439', dir: 'ltr', flag: '\u{1F1F7}\u{1F1FA}' },
|
|
16
|
-
{ code: 'ar', name: '\u0627\u0644\u0639\u0631\u0628\u064A\u0629', dir: 'rtl', flag: '\u{1F1F8}\u{1F1E6}' },
|
|
17
15
|
];
|
|
18
16
|
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
// Локалі імпортуємо статично: переклади доступні синхронно на SSR і клієнті (без сирих ключів у HTML)
|
|
18
|
+
import ukMessages from '../locales/uk.json';
|
|
19
|
+
import enMessages from '../locales/en.json';
|
|
20
|
+
|
|
21
|
+
const localeMessages: Record<LocaleCode, Record<string, any>> = {
|
|
22
|
+
uk: ukMessages as Record<string, any>,
|
|
23
|
+
en: enMessages as Record<string, any>,
|
|
25
24
|
};
|
|
26
25
|
|
|
27
26
|
const currentLocale = ref<LocaleCode>('uk');
|
|
28
|
-
const messages = ref<Record<string, any>>(
|
|
29
|
-
const isLoaded = ref(
|
|
27
|
+
const messages = ref<Record<string, any>>(localeMessages.uk);
|
|
28
|
+
const isLoaded = ref(true);
|
|
30
29
|
|
|
31
30
|
// Отримати вкладене значення за шляхом через крапку: t('nav.home') -> messages.nav.home
|
|
32
31
|
function getNestedValue(obj: any, path: string): string | undefined {
|
|
@@ -37,8 +36,7 @@ export const useI18n = () => {
|
|
|
37
36
|
// Завантажити повідомлення для обраної локалі
|
|
38
37
|
const loadLocale = async (code: LocaleCode) => {
|
|
39
38
|
try {
|
|
40
|
-
|
|
41
|
-
messages.value = mod.default || mod;
|
|
39
|
+
messages.value = localeMessages[code] || localeMessages.uk;
|
|
42
40
|
currentLocale.value = code;
|
|
43
41
|
isLoaded.value = true;
|
|
44
42
|
|
|
@@ -46,17 +44,7 @@ export const useI18n = () => {
|
|
|
46
44
|
if (import.meta.client) {
|
|
47
45
|
localStorage.setItem('locale', code);
|
|
48
46
|
|
|
49
|
-
|
|
50
|
-
const locale = LOCALES.find(l => l.code === code);
|
|
51
|
-
if (locale) {
|
|
52
|
-
document.documentElement.dir = locale.dir;
|
|
53
|
-
document.documentElement.lang = code;
|
|
54
|
-
if (locale.dir === 'rtl') {
|
|
55
|
-
document.body.classList.add('rtl');
|
|
56
|
-
} else {
|
|
57
|
-
document.body.classList.remove('rtl');
|
|
58
|
-
}
|
|
59
|
-
}
|
|
47
|
+
document.documentElement.lang = code;
|
|
60
48
|
}
|
|
61
49
|
} catch (e) {
|
|
62
50
|
console.error(`Failed to load locale: ${code}`, e);
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
|
|
2
|
+
import { useHead } from '#imports';
|
|
3
|
+
import { useSiteConfig } from './useSiteConfig';
|
|
4
|
+
import { useStoreOrigin } from './useStoreOrigin';
|
|
5
|
+
import { getProductPrice, getProductOldPrice, getProductImage, htmlToText, resolveMediaUrl, type Product } from './useProducts';
|
|
6
|
+
import { productPath, categoryPath } from '../utils/seo-urls';
|
|
7
|
+
|
|
8
|
+
type Json = Record<string, any>;
|
|
9
|
+
|
|
10
|
+
/** Безпечна серіалізація для <script>: екрануємо < > &, щоб не закрити тег. */
|
|
11
|
+
const stringify = (data: unknown): string =>
|
|
12
|
+
JSON.stringify(data).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
|
|
13
|
+
|
|
14
|
+
/** Реєструє JSON-LD блоки (реактивно, SSR-сумісно). */
|
|
15
|
+
export const useJsonLd = (blocks: MaybeRefOrGetter<Array<Json | null | undefined>>, keyPrefix = 'ld') => {
|
|
16
|
+
useHead({
|
|
17
|
+
script: computed(() =>
|
|
18
|
+
(toValue(blocks) || [])
|
|
19
|
+
.filter((b): b is Json => !!b)
|
|
20
|
+
.map((b, i) => ({ key: `${keyPrefix}-${i}`, type: 'application/ld+json', innerHTML: stringify(b) })),
|
|
21
|
+
) as any,
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const compact = <T extends Json>(o: T): T =>
|
|
26
|
+
Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== '' && !(Array.isArray(v) && !v.length))) as T;
|
|
27
|
+
|
|
28
|
+
/** Organization/Store + WebSite з пошуком — для всіх сторінок. */
|
|
29
|
+
export const useSiteJsonLd = () => {
|
|
30
|
+
const { siteConfig } = useSiteConfig();
|
|
31
|
+
const { storeOrigin } = useStoreOrigin();
|
|
32
|
+
|
|
33
|
+
useJsonLd(() => {
|
|
34
|
+
const c = siteConfig.value as any;
|
|
35
|
+
if (!c) return [];
|
|
36
|
+
const origin = storeOrigin();
|
|
37
|
+
const ci = c.contactInfo || {};
|
|
38
|
+
const social = Object.values(ci.socialMedia || {}).filter((u): u is string => typeof u === 'string' && /^https?:/.test(u));
|
|
39
|
+
const store = compact({
|
|
40
|
+
'@context': 'https://schema.org',
|
|
41
|
+
'@type': 'Store',
|
|
42
|
+
'@id': `${origin}/#organization`,
|
|
43
|
+
name: c.siteName,
|
|
44
|
+
url: origin,
|
|
45
|
+
logo: resolveMediaUrl(c.logoUrl),
|
|
46
|
+
image: resolveMediaUrl(c.logoUrl),
|
|
47
|
+
description: c.siteDescription,
|
|
48
|
+
telephone: ci.phone,
|
|
49
|
+
email: ci.email,
|
|
50
|
+
address: ci.address ? { '@type': 'PostalAddress', streetAddress: ci.address, addressCountry: 'UA' } : undefined,
|
|
51
|
+
sameAs: social,
|
|
52
|
+
});
|
|
53
|
+
const website = compact({
|
|
54
|
+
'@context': 'https://schema.org',
|
|
55
|
+
'@type': 'WebSite',
|
|
56
|
+
'@id': `${origin}/#website`,
|
|
57
|
+
name: c.siteName,
|
|
58
|
+
url: origin,
|
|
59
|
+
potentialAction: {
|
|
60
|
+
'@type': 'SearchAction',
|
|
61
|
+
target: { '@type': 'EntryPoint', urlTemplate: `${origin}/products?search={search_term_string}` },
|
|
62
|
+
'query-input': 'required name=search_term_string',
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
return [store, website];
|
|
66
|
+
}, 'site');
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const breadcrumb = (origin: string, items: Array<{ name: string; path: string }>) => ({
|
|
70
|
+
'@context': 'https://schema.org',
|
|
71
|
+
'@type': 'BreadcrumbList',
|
|
72
|
+
itemListElement: items.map((it, i) => ({
|
|
73
|
+
'@type': 'ListItem',
|
|
74
|
+
position: i + 1,
|
|
75
|
+
name: it.name,
|
|
76
|
+
item: origin + it.path,
|
|
77
|
+
})),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/** Product + BreadcrumbList для сторінки товару. */
|
|
81
|
+
export const useProductJsonLd = (product: MaybeRefOrGetter<Product | null | undefined>) => {
|
|
82
|
+
const { storeOrigin } = useStoreOrigin();
|
|
83
|
+
const { siteConfig } = useSiteConfig();
|
|
84
|
+
|
|
85
|
+
useJsonLd(() => {
|
|
86
|
+
const p = toValue(product);
|
|
87
|
+
if (!p) return [];
|
|
88
|
+
const origin = storeOrigin();
|
|
89
|
+
const url = origin + productPath(p);
|
|
90
|
+
const images = (p.images || [])
|
|
91
|
+
.map((i: any) => resolveMediaUrl(typeof i === 'string' ? i : i?.url))
|
|
92
|
+
.filter(Boolean);
|
|
93
|
+
const desc = htmlToText(p.description).slice(0, 500);
|
|
94
|
+
const brandRaw: any = (p as any).brand;
|
|
95
|
+
const brand = typeof brandRaw === 'string' ? brandRaw : brandRaw?.name || (p as any).brands?.[0]?.name;
|
|
96
|
+
const inStock = !(p.isActive === false || (typeof p.stock === 'number' && p.stock <= 0));
|
|
97
|
+
const cat = p.categories?.[0];
|
|
98
|
+
const crumbs = [{ name: siteConfig.value?.siteName || 'Головна', path: '/' }];
|
|
99
|
+
if (cat) crumbs.push({ name: cat.name, path: categoryPath(cat) });
|
|
100
|
+
crumbs.push({ name: p.title, path: productPath(p) });
|
|
101
|
+
return [
|
|
102
|
+
compact({
|
|
103
|
+
'@context': 'https://schema.org',
|
|
104
|
+
'@type': 'Product',
|
|
105
|
+
name: p.title,
|
|
106
|
+
image: images.length ? images : undefined,
|
|
107
|
+
description: desc,
|
|
108
|
+
sku: p.sku,
|
|
109
|
+
brand: brand ? { '@type': 'Brand', name: brand } : undefined,
|
|
110
|
+
offers: {
|
|
111
|
+
'@type': 'Offer',
|
|
112
|
+
// Ефективна ціна (з урахуванням акції)
|
|
113
|
+
price: getProductPrice(p).toFixed(2),
|
|
114
|
+
priceCurrency: 'UAH',
|
|
115
|
+
// Акційна ціна діє до завершення акції
|
|
116
|
+
priceValidUntil: getProductOldPrice(p) && p.discount?.endDate ? String(p.discount.endDate).slice(0, 10) : undefined,
|
|
117
|
+
availability: `https://schema.org/${inStock ? 'InStock' : 'OutOfStock'}`,
|
|
118
|
+
itemCondition: 'https://schema.org/NewCondition',
|
|
119
|
+
url,
|
|
120
|
+
},
|
|
121
|
+
}),
|
|
122
|
+
breadcrumb(origin, crumbs),
|
|
123
|
+
];
|
|
124
|
+
}, 'product');
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** BreadcrumbList + ItemList (до 20 товарів) для сторінки категорії. */
|
|
128
|
+
export const useCategoryJsonLd = (
|
|
129
|
+
category: MaybeRefOrGetter<{ id: string; name: string; parentId?: string } | null | undefined>,
|
|
130
|
+
parent: MaybeRefOrGetter<{ id: string; name: string } | null | undefined>,
|
|
131
|
+
products: MaybeRefOrGetter<Product[] | null | undefined>,
|
|
132
|
+
) => {
|
|
133
|
+
const { storeOrigin } = useStoreOrigin();
|
|
134
|
+
const { siteConfig } = useSiteConfig();
|
|
135
|
+
|
|
136
|
+
useJsonLd(() => {
|
|
137
|
+
const c = toValue(category);
|
|
138
|
+
if (!c) return [];
|
|
139
|
+
const origin = storeOrigin();
|
|
140
|
+
const crumbs = [{ name: siteConfig.value?.siteName || 'Головна', path: '/' }];
|
|
141
|
+
const par = toValue(parent);
|
|
142
|
+
if (par) crumbs.push({ name: par.name, path: categoryPath(par) });
|
|
143
|
+
crumbs.push({ name: c.name, path: categoryPath(c) });
|
|
144
|
+
const items = (toValue(products) || []).slice(0, 20);
|
|
145
|
+
return [
|
|
146
|
+
breadcrumb(origin, crumbs),
|
|
147
|
+
items.length
|
|
148
|
+
? {
|
|
149
|
+
'@context': 'https://schema.org',
|
|
150
|
+
'@type': 'ItemList',
|
|
151
|
+
itemListElement: items.map((p, i) => ({
|
|
152
|
+
'@type': 'ListItem',
|
|
153
|
+
position: i + 1,
|
|
154
|
+
url: origin + productPath(p),
|
|
155
|
+
name: p.title,
|
|
156
|
+
image: getProductImage(p) || undefined,
|
|
157
|
+
})),
|
|
158
|
+
}
|
|
159
|
+
: null,
|
|
160
|
+
];
|
|
161
|
+
}, 'category');
|
|
162
|
+
};
|