@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.
Files changed (139) hide show
  1. package/assets/scss/_variables.scss +3 -3
  2. package/assets/scss/components/_header.scss +1 -1
  3. package/assets/scss/style.css +4 -4
  4. package/components/AboutUs/StaticContent.vue +32 -0
  5. package/components/Cart/CartEmpty.vue +42 -0
  6. package/components/Cart/CartItem.vue +145 -0
  7. package/components/Cart/CartSummary.vue +57 -0
  8. package/components/Cart/ProductQuantity.vue +23 -13
  9. package/components/Cart/index.vue +68 -150
  10. package/components/Categories/index.vue +88 -291
  11. package/components/Checkout/AddressFields.vue +74 -0
  12. package/components/Checkout/CheckoutForm.vue +75 -0
  13. package/components/Checkout/CityAutocomplete.vue +68 -0
  14. package/components/Checkout/DeliveryMethodCard.vue +66 -0
  15. package/components/Checkout/DeliveryOptions.vue +148 -0
  16. package/components/Checkout/OrderSummary.vue +116 -0
  17. package/components/Checkout/PaymentOptions.vue +76 -0
  18. package/components/Checkout/PickupPoints.vue +46 -0
  19. package/components/Checkout/PromoCode.vue +166 -0
  20. package/components/Checkout/index.vue +235 -173
  21. package/components/Checkout/types.ts +45 -0
  22. package/components/Common/PageBanner.vue +22 -20
  23. package/components/Common/ProductCard.vue +139 -115
  24. package/components/Common/Services.vue +4 -4
  25. package/components/Contact/ContactForm.vue +44 -74
  26. package/components/Contact/ContactInfo.vue +35 -61
  27. package/components/FAQ/index.vue +6 -119
  28. package/components/Layout/Copyright.vue +11 -9
  29. package/components/Layout/Footer.vue +49 -136
  30. package/components/Layout/HeaderBar.vue +174 -0
  31. package/components/Layout/HeaderLogo.vue +27 -0
  32. package/components/Layout/MiddleHeader.vue +1 -228
  33. package/components/Layout/MiddleHeaderThree.vue +1 -203
  34. package/components/Layout/MiddleHeaderTwo.vue +1 -239
  35. package/components/Layout/NavLinks.vue +23 -0
  36. package/components/Layout/Navbar.vue +38 -151
  37. package/components/Layout/NavbarStyleFour.vue +1 -332
  38. package/components/Layout/NavbarStyleThree.vue +1 -188
  39. package/components/Layout/NavbarStyleTwo.vue +1 -108
  40. package/components/Layout/Preloader.vue +5 -12
  41. package/components/Layout/ResponsiveNavbar.vue +32 -418
  42. package/components/Layout/SitewideBanner.vue +285 -0
  43. package/components/Layout/TopHeader.vue +17 -83
  44. package/components/Layout/layoutData.ts +83 -0
  45. package/components/PrivacyPolicy/index.vue +6 -110
  46. package/components/ProductDetails/ProductDetailsTab.vue +31 -321
  47. package/components/ProductDetails/ProductGallery.vue +45 -0
  48. package/components/ProductDetails/ProductQuantity.vue +8 -14
  49. package/components/ProductDetails/RecentlyViewed.vue +53 -289
  50. package/components/ProductDetails/RelatedProducts.vue +64 -0
  51. package/components/ProductDetails/index.vue +90 -230
  52. package/components/Products/index.vue +78 -16
  53. package/components/TermsConditions/index.vue +6 -110
  54. package/components/Wishlist/index.vue +62 -88
  55. package/composables/useCart.ts +133 -81
  56. package/composables/useCategories.ts +67 -31
  57. package/composables/useCategoryImages.ts +45 -0
  58. package/composables/useCheckout.ts +302 -97
  59. package/composables/useContactForm.ts +57 -0
  60. package/composables/useHomeContent.ts +220 -0
  61. package/composables/useI18n.ts +12 -24
  62. package/composables/useJsonLd.ts +162 -0
  63. package/composables/useProducts.ts +244 -83
  64. package/composables/useRecent.ts +30 -0
  65. package/composables/useSanitize.ts +76 -0
  66. package/composables/useSiteConfig.ts +51 -47
  67. package/composables/useStaticPages.ts +48 -0
  68. package/composables/useStoreOrigin.ts +32 -0
  69. package/composables/useTemplateSections.ts +51 -3
  70. package/composables/useThemeVars.ts +113 -0
  71. package/composables/useWishlist.ts +72 -0
  72. package/layouts/default.vue +80 -19
  73. package/locales/en.json +255 -86
  74. package/locales/uk.json +251 -82
  75. package/modules/template-schema.ts +35 -0
  76. package/nuxt.config.ts +8 -11
  77. package/package.json +1 -1
  78. package/pages/about-us.vue +9 -9
  79. package/pages/cart.vue +0 -1
  80. package/pages/categories.vue +0 -1
  81. package/pages/category/[slug].vue +84 -0
  82. package/pages/checkout.vue +0 -1
  83. package/pages/contact.vue +6 -6
  84. package/pages/delivery.vue +12 -0
  85. package/pages/faq.vue +6 -6
  86. package/pages/order-success.vue +113 -0
  87. package/pages/privacy-policy.vue +6 -6
  88. package/pages/product/[slug].vue +82 -0
  89. package/pages/product-details.vue +14 -6
  90. package/pages/products.vue +12 -1
  91. package/pages/returns.vue +12 -0
  92. package/pages/terms-conditions.vue +6 -6
  93. package/pages/wishlist.vue +2 -2
  94. package/utils/image.ts +0 -1
  95. package/utils/seo-urls.ts +70 -0
  96. package/utils/store-origin.ts +36 -0
  97. package/components/AboutUs/AboutUsTuan.vue +0 -25
  98. package/components/AboutUs/Statistics.vue +0 -42
  99. package/components/AboutUs/SubscribeToTheNewsletter.vue +0 -57
  100. package/components/AddAddress/index.vue +0 -68
  101. package/components/BestSellers/Products.vue +0 -1562
  102. package/components/BestSellers/RecentlyViewed.vue +0 -304
  103. package/components/ChangePassword/index.vue +0 -69
  104. package/components/Common/DashboardNavigation.vue +0 -35
  105. package/components/Dashboard/RecentOrder.vue +0 -173
  106. package/components/Dashboard/index.vue +0 -77
  107. package/components/EditAddress/index.vue +0 -117
  108. package/components/EditProfile/index.vue +0 -95
  109. package/components/FeaturedProduct/FeaturedProducts.vue +0 -304
  110. package/components/FeaturedProduct/Products.vue +0 -1562
  111. package/components/ForgotPassword/index.vue +0 -49
  112. package/components/Layout/FooterTwo.vue +0 -159
  113. package/components/Layout/LocationOption.vue +0 -178
  114. package/components/Layout/RTLSwitchBtn.vue +0 -40
  115. package/components/Login/index.vue +0 -89
  116. package/components/MyAccount/index.vue +0 -154
  117. package/components/NewArrivals/Products.vue +0 -1969
  118. package/components/NewArrivals/RecentlyViewed.vue +0 -304
  119. package/components/OrderDetails/index.vue +0 -75
  120. package/components/OrderHistory/index.vue +0 -195
  121. package/components/Products/RecentlyViewed.vue +0 -302
  122. package/components/Register/index.vue +0 -83
  123. package/components/TrendingProducts/FeaturedProducts.vue +0 -304
  124. package/components/TrendingProducts/Products.vue +0 -1564
  125. package/components/Wishlist/ProductQuantity.vue +0 -29
  126. package/layouts/inner.vue +0 -70
  127. package/locales/ar.json +0 -234
  128. package/locales/ru.json +0 -234
  129. package/pages/address.vue +0 -11
  130. package/pages/change-password.vue +0 -11
  131. package/pages/dashboard.vue +0 -11
  132. package/pages/edit-address.vue +0 -11
  133. package/pages/edit-profile.vue +0 -11
  134. package/pages/forgot-password.vue +0 -11
  135. package/pages/login.vue +0 -11
  136. package/pages/my-account.vue +0 -11
  137. package/pages/order-details.vue +0 -11
  138. package/pages/order-history.vue +0 -11
  139. 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
+ };
@@ -1,6 +1,6 @@
1
1
  import { ref, computed } from 'vue';
2
2
 
3
- type LocaleCode = 'en' | 'uk' | 'ru' | 'ar';
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
- // Lazy-load locale files
20
- const localeModules: Record<LocaleCode, () => Promise<any>> = {
21
- en: () => import('../locales/en.json'),
22
- uk: () => import('../locales/uk.json'),
23
- ru: () => import('../locales/ru.json'),
24
- ar: () => import('../locales/ar.json'),
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(false);
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
- const mod = await localeModules[code]();
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
- // Встановити RTL напрямок для арабської
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
+ };