@crm-market/template-shared 1.0.7 → 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 (138) 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 +66 -29
  57. package/composables/useCategoryImages.ts +45 -0
  58. package/composables/useCheckout.ts +302 -95
  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 -81
  64. package/composables/useRecent.ts +30 -0
  65. package/composables/useSanitize.ts +76 -0
  66. package/composables/useSiteConfig.ts +51 -45
  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/seo-urls.ts +70 -0
  95. package/utils/store-origin.ts +36 -0
  96. package/components/AboutUs/AboutUsTuan.vue +0 -25
  97. package/components/AboutUs/Statistics.vue +0 -42
  98. package/components/AboutUs/SubscribeToTheNewsletter.vue +0 -57
  99. package/components/AddAddress/index.vue +0 -68
  100. package/components/BestSellers/Products.vue +0 -1562
  101. package/components/BestSellers/RecentlyViewed.vue +0 -304
  102. package/components/ChangePassword/index.vue +0 -69
  103. package/components/Common/DashboardNavigation.vue +0 -35
  104. package/components/Dashboard/RecentOrder.vue +0 -173
  105. package/components/Dashboard/index.vue +0 -77
  106. package/components/EditAddress/index.vue +0 -117
  107. package/components/EditProfile/index.vue +0 -95
  108. package/components/FeaturedProduct/FeaturedProducts.vue +0 -304
  109. package/components/FeaturedProduct/Products.vue +0 -1562
  110. package/components/ForgotPassword/index.vue +0 -49
  111. package/components/Layout/FooterTwo.vue +0 -159
  112. package/components/Layout/LocationOption.vue +0 -178
  113. package/components/Layout/RTLSwitchBtn.vue +0 -40
  114. package/components/Login/index.vue +0 -89
  115. package/components/MyAccount/index.vue +0 -154
  116. package/components/NewArrivals/Products.vue +0 -1969
  117. package/components/NewArrivals/RecentlyViewed.vue +0 -304
  118. package/components/OrderDetails/index.vue +0 -75
  119. package/components/OrderHistory/index.vue +0 -195
  120. package/components/Products/RecentlyViewed.vue +0 -302
  121. package/components/Register/index.vue +0 -83
  122. package/components/TrendingProducts/FeaturedProducts.vue +0 -304
  123. package/components/TrendingProducts/Products.vue +0 -1564
  124. package/components/Wishlist/ProductQuantity.vue +0 -29
  125. package/layouts/inner.vue +0 -70
  126. package/locales/ar.json +0 -234
  127. package/locales/ru.json +0 -234
  128. package/pages/address.vue +0 -11
  129. package/pages/change-password.vue +0 -11
  130. package/pages/dashboard.vue +0 -11
  131. package/pages/edit-address.vue +0 -11
  132. package/pages/edit-profile.vue +0 -11
  133. package/pages/forgot-password.vue +0 -11
  134. package/pages/login.vue +0 -11
  135. package/pages/my-account.vue +0 -11
  136. package/pages/order-details.vue +0 -11
  137. package/pages/order-history.vue +0 -11
  138. package/pages/register.vue +0 -11
@@ -0,0 +1,48 @@
1
+ import { computed } from 'vue';
2
+ import { sanitizeHtml } from './useSanitize';
3
+
4
+ export type StaticPageKey = 'about' | 'delivery' | 'returns' | 'contacts' | 'faq' | 'privacy' | 'terms';
5
+
6
+ // Альтернативні ключі, під якими CRM може віддавати розділ
7
+ const ALIASES: Record<StaticPageKey, string[]> = {
8
+ about: ['about'],
9
+ delivery: ['delivery'],
10
+ returns: ['returns'],
11
+ contacts: ['contacts'],
12
+ faq: ['faq'],
13
+ privacy: ['privacy', 'privacyPolicy'],
14
+ terms: ['terms', 'termsOfService'],
15
+ };
16
+
17
+ // Дефолтні тексти, якщо продавець нічого не заповнив
18
+ const DEFAULTS: Record<StaticPageKey, string> = {
19
+ about: '<p>Інформація про магазин незабаром зʼявиться на цій сторінці.</p>',
20
+ delivery: '<p>Інформацію про доставку уточнюйте у менеджера.</p>',
21
+ returns: '<p>Умови повернення та обміну уточнюйте у менеджера.</p>',
22
+ contacts: '',
23
+ faq: '<p>Розділ наповнюється.</p>',
24
+ privacy: '<p>Розділ наповнюється.</p>',
25
+ terms: '<p>Розділ наповнюється.</p>',
26
+ };
27
+
28
+ /**
29
+ * Статичні сторінки магазину (HTML від продавця) з дефолтами.
30
+ * pageHtml повертає лише санітизований HTML.
31
+ */
32
+ export const useStaticPages = () => {
33
+ const { siteConfig } = useSiteConfig();
34
+ const pages = computed<Record<string, string>>(() => (siteConfig.value as any)?.staticPages || {});
35
+
36
+ const raw = (key: StaticPageKey): string => {
37
+ for (const k of ALIASES[key]) {
38
+ const v = pages.value[k];
39
+ if (typeof v === 'string' && v.replace(/<[^>]*>|&nbsp;|\s/g, '').length > 0) return v;
40
+ }
41
+ return '';
42
+ };
43
+
44
+ const hasPage = (key: StaticPageKey) => !!raw(key);
45
+ const pageHtml = (key: StaticPageKey): string => sanitizeHtml(raw(key) || DEFAULTS[key]);
46
+
47
+ return { pageHtml, hasPage };
48
+ };
@@ -0,0 +1,32 @@
1
+ import { useRequestURL, useRoute, useRuntimeConfig } from '#imports';
2
+ import { useSiteConfig } from './useSiteConfig';
3
+ import { computeStoreOrigin, buildCanonicalUrl } from '../utils/store-origin';
4
+
5
+ /** Основний домен магазину та canonical-адреси сторінок. */
6
+ export const useStoreOrigin = () => {
7
+ const { siteConfig } = useSiteConfig();
8
+ const config = useRuntimeConfig();
9
+ const reqUrl = useRequestURL();
10
+
11
+ const storeOrigin = (): string =>
12
+ computeStoreOrigin(
13
+ siteConfig.value,
14
+ String((config.public as any).storeBaseDomain || ''),
15
+ reqUrl.hostname,
16
+ reqUrl.origin,
17
+ );
18
+
19
+ /** page додається лише для каталогу/категорії і лише якщо > 1. */
20
+ const canonicalUrl = (path: string, page?: number | string | null): string =>
21
+ buildCanonicalUrl(storeOrigin(), path, page);
22
+
23
+ return { storeOrigin, canonicalUrl };
24
+ };
25
+
26
+ /** Canonical поточного маршруту (для каталогу/категорії враховує ?page=N). */
27
+ export const useRouteCanonical = () => {
28
+ const route = useRoute();
29
+ const { canonicalUrl } = useStoreOrigin();
30
+ const PAGED = /^\/(products|categories|category\/)/;
31
+ return () => canonicalUrl(route.path, PAGED.test(route.path) ? route.query.page as string : null);
32
+ };
@@ -19,7 +19,21 @@ export const useTemplateSections = () => {
19
19
  registeredSections.value = sections;
20
20
  };
21
21
 
22
- // Секції з contentBlocks API (кастомізовано організацією)
22
+ // Секції з «Налаштувань шаблону»: values.contentBlocks.blocks (схема шаблону, repeater key/enabled/order)
23
+ const settingsBlocks = computed<ContentBlock[]>(() => {
24
+ const blocks = (siteConfig.value?.customization as any)?.values?.contentBlocks?.blocks;
25
+ if (!Array.isArray(blocks)) return [];
26
+ const seen = new Set<string>();
27
+ return blocks
28
+ .filter((b: any) => b && typeof b.key === 'string' && b.key && !seen.has(b.key) && seen.add(b.key))
29
+ .map((b: any, i: number) => ({
30
+ key: b.key,
31
+ order: Number.isFinite(Number(b.order)) && b.order !== '' && b.order !== null ? Number(b.order) : i + 1,
32
+ enabled: b.enabled !== false,
33
+ }));
34
+ });
35
+
36
+ // Старе місце: customization.contentBlocks (кастомізовано організацією через API)
23
37
  const contentBlocks = computed<ContentBlock[]>(() => {
24
38
  const blocks = siteConfig.value?.customization?.contentBlocks;
25
39
  if (!blocks || !Array.isArray(blocks) || blocks.length === 0) {
@@ -28,11 +42,44 @@ export const useTemplateSections = () => {
28
42
  return blocks;
29
43
  });
30
44
 
31
- // Фінальний порядок секцій: API contentBlocks мають пріоритет, інакше — дефолтний порядок
32
- const orderedSections = computed(() => {
45
+ // Фінальний порядок секцій:
46
+ // 1) values.contentBlocks.blocks — перелічені секції за order (вимкнені ховаються),
47
+ // неперелічені секції шаблону йдуть після них у дефолтному порядку;
48
+ // 2) customization.contentBlocks — лише перелічені (стара поведінка);
49
+ // 3) дефолтний порядок з реєстру шаблону.
50
+ // Вимикач у самій секції: values[<ключ секції>].enabled === false ховає її (діє разом зі списком — AND)
51
+ const enabledInSection = (key: string) =>
52
+ (siteConfig.value?.customization as any)?.values?.[key]?.enabled !== false;
53
+
54
+ const orderedSections = computed(() => listedSections.value.filter((s) => enabledInSection(s.key)));
55
+
56
+ const listedSections = computed(() => {
33
57
  const sections = registeredSections.value;
34
58
  if (sections.length === 0) return [];
35
59
 
60
+ if (settingsBlocks.value.length > 0) {
61
+ const listed = new Set(settingsBlocks.value.map(b => b.key));
62
+ const fromSettings = [...settingsBlocks.value]
63
+ .sort((a, b) => a.order - b.order)
64
+ .filter(b => b.enabled)
65
+ .map(b => {
66
+ const registered = sections.find(s => s.key === b.key);
67
+ return {
68
+ key: b.key,
69
+ component: registered?.component || b.key,
70
+ label: registered?.label || b.key,
71
+ order: b.order,
72
+ enabled: true,
73
+ data: null,
74
+ };
75
+ });
76
+ const rest = sections
77
+ .filter(s => s.defaultEnabled && !listed.has(s.key))
78
+ .sort((a, b) => a.defaultOrder - b.defaultOrder)
79
+ .map(s => ({ key: s.key, component: s.component, label: s.label, order: s.defaultOrder, enabled: true, data: null }));
80
+ return [...fromSettings, ...rest];
81
+ }
82
+
36
83
  // Якщо є contentBlocks з API — використовуємо їх
37
84
  if (contentBlocks.value.length > 0) {
38
85
  return contentBlocks.value
@@ -71,5 +118,6 @@ export const useTemplateSections = () => {
71
118
  registerSections,
72
119
  orderedSections,
73
120
  contentBlocks,
121
+ settingsBlocks,
74
122
  };
75
123
  };
@@ -0,0 +1,113 @@
1
+ import { computed } from 'vue';
2
+ import { useHead } from '#imports';
3
+ import { useSiteConfig } from './useSiteConfig';
4
+
5
+ // Шрифти, які не вшиті в шар (Satoshi є локально) — підтягуємо з Google Fonts
6
+ // (з кириличним сабсетом; 500 — для елементів теми на --fontFamilyMedium)
7
+ const GOOGLE_FONTS: Record<string, string> = {
8
+ Roboto: 'Roboto:wght@400;500;700',
9
+ 'Open Sans': 'Open+Sans:wght@400;500;600;700',
10
+ Montserrat: 'Montserrat:wght@400;500;600;700',
11
+ Inter: 'Inter:wght@400;500;600;700',
12
+ };
13
+
14
+ // Фолбек для будь-якого шрифту: Satoshi не має кирилиці — без фолбеку браузер малював її serif'ом
15
+ export const FONT_FALLBACK = 'system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif';
16
+ const fontStack = (name: string) => `'${name}', ${FONT_FALLBACK}`;
17
+
18
+ const HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
19
+ const color = (v: any) => (typeof v === 'string' && HEX.test(v.trim()) ? v.trim() : '');
20
+ // Лише безпечні назви шрифтів (літери, цифри, пробіл, дефіс) — значення йде в CSS
21
+ const font = (v: any) => (typeof v === 'string' && /^[\w \-]{1,40}$/.test(v.trim()) ? v.trim() : '');
22
+
23
+ const hexToRgb = (hex: string) => {
24
+ let h = hex.slice(1);
25
+ if (h.length === 3) h = h.split('').map((c) => c + c).join('');
26
+ const n = parseInt(h, 16);
27
+ return `${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}`;
28
+ };
29
+
30
+ /**
31
+ * Тема з «Налаштувань шаблону»: values.colors / values.fonts (схема шаблону)
32
+ * з фолбеком на старі customization.colors / customization.fonts.
33
+ */
34
+ export const resolveTheme = (customization: any) => {
35
+ const v = customization?.values || {};
36
+ const c = v.colors || {};
37
+ const legacyC = customization?.colors || {};
38
+ const f = v.fonts || {};
39
+ const legacyF = customization?.fonts || {};
40
+ return {
41
+ colors: {
42
+ primary: color(c.primaryColor) || color(legacyC.primary),
43
+ secondary: color(c.secondaryColor) || color(legacyC.secondary),
44
+ accent: color(c.accentColor) || color(legacyC.accent),
45
+ body: color(c.textColor) || color(legacyC.bodyColor),
46
+ },
47
+ fonts: {
48
+ heading: font(f.headingFont) || font(legacyF.headingFont),
49
+ body: font(f.bodyFont) || font(legacyF.bodyFont),
50
+ },
51
+ };
52
+ };
53
+
54
+ /**
55
+ * Інжектить CSS-змінні теми в <head> (працює і на SSR — без «миготіння» кольорів).
56
+ * :root:root — вища специфічність за :root зі стилів шаблону, які йдуть пізніше.
57
+ */
58
+ export const useThemeVars = () => {
59
+ const { siteConfig } = useSiteConfig();
60
+ const theme = computed(() => resolveTheme(siteConfig.value?.customization));
61
+
62
+ const css = computed(() => {
63
+ const { colors, fonts } = theme.value;
64
+ const rules: string[] = [];
65
+ if (colors.primary) {
66
+ const rgb = hexToRgb(colors.primary);
67
+ rules.push(`--primary-color:${colors.primary}`, `--primary:${colors.primary}`, `--bs-primary:${colors.primary}`, `--bs-primary-rgb:${rgb}`,
68
+ `--bs-link-color:${colors.primary}`, `--bs-link-color-rgb:${rgb}`,
69
+ // Тема Tuan будує головні CTA (.btn-success, .read-more.success, «У кошик») на --success
70
+ `--success:${colors.primary}`, `--bs-success:${colors.primary}`, `--bs-success-rgb:${rgb}`);
71
+ }
72
+ if (colors.secondary) {
73
+ rules.push(`--secondary-color:${colors.secondary}`, `--bs-secondary:${colors.secondary}`, `--bs-link-hover-color:${colors.secondary}`,
74
+ `--successdiv:${colors.secondary}`);
75
+ }
76
+ if (colors.accent) {
77
+ // Помаранчеві елементи теми («Категорії товарів», «Пошук», «Оформити») — на --warning
78
+ rules.push(`--accent-color:${colors.accent}`, `--warning:${colors.accent}`, `--warningdiv:${colors.accent}`,
79
+ `--bs-warning:${colors.accent}`, `--bs-warning-rgb:${hexToRgb(colors.accent)}`);
80
+ }
81
+ if (colors.body) rules.push(`--body-color:${colors.body}`, `--bodyColor:${colors.body}`);
82
+ // Заголовки → Bold-шрифт теми; текст, кнопки, навігація → Regular/Medium
83
+ if (fonts.heading) rules.push(`--font-family-heading:${fontStack(fonts.heading)}`, `--fontFamilyBold:${fontStack(fonts.heading)}`);
84
+ if (fonts.body) {
85
+ rules.push(`--font-family-body:${fontStack(fonts.body)}`, `--fontFamilyRegular:${fontStack(fonts.body)}`,
86
+ `--fontFamilyMedium:${fontStack(fonts.body)}`);
87
+ }
88
+ return rules.length ? `:root:root{${rules.join(';')}}` : '';
89
+ });
90
+
91
+ // Google Fonts — лише для обраних шрифтів: один <link> + preconnect
92
+ const fontLinks = computed(() => {
93
+ const { heading, body } = theme.value.fonts;
94
+ const families = [...new Set([heading, body].filter((x) => GOOGLE_FONTS[x]).map((x) => GOOGLE_FONTS[x]))];
95
+ if (!families.length) return [];
96
+ return [
97
+ { key: 'theme-fonts-pre1', rel: 'preconnect', href: 'https://fonts.googleapis.com' },
98
+ { key: 'theme-fonts-pre2', rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
99
+ {
100
+ key: 'theme-fonts',
101
+ rel: 'stylesheet',
102
+ href: `https://fonts.googleapis.com/css2?${families.map((x) => `family=${x}`).join('&')}&display=swap`,
103
+ },
104
+ ];
105
+ });
106
+
107
+ useHead({
108
+ style: () => (css.value ? [{ key: 'theme-vars', innerHTML: css.value }] : []),
109
+ link: () => fontLinks.value,
110
+ });
111
+
112
+ return { theme, css };
113
+ };
@@ -0,0 +1,72 @@
1
+ import { useState } from '#imports';
2
+ import { getProductImage, getProductPrice, type Product } from './useProducts';
3
+
4
+ // Той самий ключ і формат, що пише CommonProductCard та читає лічильник у шапці:
5
+ // масив { id, title, image, price }
6
+ const WISHLIST_KEY = 'wishlist';
7
+
8
+ interface WishItem {
9
+ id: string;
10
+ title?: string;
11
+ image?: string;
12
+ price?: number;
13
+ }
14
+
15
+ const read = (): WishItem[] => {
16
+ if (typeof window === 'undefined') return [];
17
+ try {
18
+ const arr = JSON.parse(localStorage.getItem(WISHLIST_KEY) || '[]');
19
+ if (!Array.isArray(arr)) return [];
20
+ return arr
21
+ .map((i) => (typeof i === 'string' ? { id: i } : i))
22
+ .filter((i) => i && i.id);
23
+ } catch {
24
+ return [];
25
+ }
26
+ };
27
+
28
+ const write = (items: WishItem[]) => {
29
+ try {
30
+ localStorage.setItem(WISHLIST_KEY, JSON.stringify(items));
31
+ } catch { /* сховище недоступне — лишається лише стан у пам'яті */ }
32
+ };
33
+
34
+ /** Список бажань у localStorage; стан id спільний із картками товарів. */
35
+ export const useWishlist = () => {
36
+ const ids = useState<string[]>('wishlist-ids', () => []);
37
+
38
+ /** Синхронізує стан із localStorage (викликати в onMounted). */
39
+ const load = () => {
40
+ ids.value = read().map((i) => i.id);
41
+ };
42
+
43
+ const has = (id: string) => ids.value.includes(id);
44
+ const count = computed(() => ids.value.length);
45
+
46
+ const remove = (id: string) => {
47
+ const next = read().filter((i) => i.id !== id);
48
+ write(next);
49
+ ids.value = next.map((i) => i.id);
50
+ };
51
+
52
+ const toggle = (product: Product) => {
53
+ const list = read();
54
+ if (list.some((i) => i.id === product.id)) {
55
+ remove(product.id);
56
+ return;
57
+ }
58
+ const next = [
59
+ ...list,
60
+ {
61
+ id: product.id,
62
+ title: product.title || product.name || '',
63
+ image: getProductImage(product),
64
+ price: getProductPrice(product),
65
+ },
66
+ ];
67
+ write(next);
68
+ ids.value = next.map((i) => i.id);
69
+ };
70
+
71
+ return { ids, count, load, has, toggle, remove };
72
+ };
@@ -1,27 +1,63 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
- * Головний layout шаблону магазину.
4
- * Динамічно обирає варіант header/navbar/footer
5
- * на основі templateType або componentSettings з API.
3
+ * Єдиний layout шаблону магазину (головна і внутрішні сторінки).
4
+ * Динамічно обирає варіант header/navbar/footer на основі templateType
5
+ * або componentSettings з API; каркас — flex-колонка, футер завжди внизу.
6
6
  */
7
7
  const config = useRuntimeConfig();
8
- const { siteConfig, componentSettings } = useSiteConfig();
8
+ const { siteConfig, componentSettings, updateMetaTags } = useSiteConfig();
9
+ // SEO-теги з конфігу магазину реєструємо тут: у плагіні useHead не потрапляє в SSR
10
+ updateMetaTags();
11
+
12
+ // Canonical для кожної сторінки (сторінки товару/категорії повторюють той самий хелпер) + JSON-LD організації
13
+ const route = useRoute();
14
+ const canonicalFor = useRouteCanonical();
15
+ const NOINDEX = ['/cart', '/checkout', '/order-success', '/wishlist', '/product-details'];
16
+ useHead({ link: () => [{ key: 'canonical', rel: 'canonical', href: canonicalFor() }] });
17
+ useSeoMeta({ robots: () => (NOINDEX.includes(route.path) ? 'noindex, nofollow' : undefined) });
18
+ useSiteJsonLd();
19
+
20
+ // Осмислені title/description для каталогу й статичних сторінок (сторінки товару/категорії перекривають своїми)
21
+ const { t } = useI18n();
22
+ const siteLabel = computed(() => siteConfig.value?.siteName || '');
23
+ const ROUTE_SEO: Record<string, { title?: string; description: string }> = {
24
+ '/products': { title: 'seo.productsTitle', description: 'seo.productsDescription' },
25
+ '/categories': { title: 'seo.categoriesTitle', description: 'seo.categoriesDescription' },
26
+ '/about-us': { description: 'seo.aboutDescription' },
27
+ '/contact': { description: 'seo.contactDescription' },
28
+ '/delivery': { description: 'seo.deliveryDescription' },
29
+ '/returns': { description: 'seo.returnsDescription' },
30
+ };
31
+ const routeSeo = computed(() => ROUTE_SEO[route.path]);
32
+ useHead({
33
+ titleTemplate: (title) => (title && siteLabel.value && !title.includes(siteLabel.value) ? `${title} — ${siteLabel.value}` : title),
34
+ title: () => (routeSeo.value?.title ? t(routeSeo.value.title) : undefined) as any,
35
+ });
36
+ useSeoMeta({
37
+ description: () => (routeSeo.value ? t(routeSeo.value.description).replace('{site}', siteLabel.value) : undefined),
38
+ ogDescription: () => (routeSeo.value ? t(routeSeo.value.description).replace('{site}', siteLabel.value) : undefined),
39
+ });
9
40
 
10
41
  const templateType = computed(() => {
11
42
  return siteConfig.value?.template?.type || config.public.templateType || 'electronics';
12
43
  });
13
44
 
14
- // Визначаємо варіант header на основі шаблону або componentSettings
45
+ // CSS-змінні кольорів/шрифтів з «Налаштувань шаблону» (SSR-сумісно)
46
+ useThemeVars();
47
+
48
+ // Варіант header/navbar/footer: values.layout («Налаштування шаблону») → componentSettings → дефолт шаблону
49
+ const layoutValues = computed<Record<string, any>>(() => (siteConfig.value?.customization as any)?.values?.layout || {});
50
+
15
51
  const headerStyle = computed(() => {
16
- return componentSettings.value?.header?.style || getDefaultHeaderStyle(templateType.value);
52
+ return layoutValues.value.headerStyle || componentSettings.value?.header?.style || getDefaultHeaderStyle(templateType.value);
17
53
  });
18
54
 
19
55
  const navbarStyle = computed(() => {
20
- return componentSettings.value?.navbar?.style || getDefaultNavbarStyle(templateType.value);
56
+ return layoutValues.value.navbarStyle || componentSettings.value?.navbar?.style || getDefaultNavbarStyle(templateType.value);
21
57
  });
22
58
 
23
59
  const footerStyle = computed(() => {
24
- return componentSettings.value?.footer?.style || getDefaultFooterStyle(templateType.value);
60
+ return layoutValues.value.footerStyle || componentSettings.value?.footer?.style || getDefaultFooterStyle(templateType.value);
25
61
  });
26
62
 
27
63
  function getDefaultHeaderStyle(type: string): string {
@@ -65,9 +101,9 @@ const navbarComponents: Record<string, any> = {
65
101
  four: resolveComponent('LayoutNavbarStyleFour'),
66
102
  };
67
103
 
104
+ // Один футер: старе значення 'two' (якщо збережене) показує той самий
68
105
  const footerComponents: Record<string, any> = {
69
106
  default: resolveComponent('LayoutFooter'),
70
- two: resolveComponent('LayoutFooterTwo'),
71
107
  };
72
108
 
73
109
  const currentHeader = computed(() => headerComponents[headerStyle.value] || headerComponents.default);
@@ -76,19 +112,44 @@ const currentFooter = computed(() => footerComponents[footerStyle.value] || foot
76
112
  </script>
77
113
 
78
114
  <template>
79
- <div>
80
- <LayoutTopHeader />
81
- <component :is="currentHeader" />
82
- <component :is="currentNavbar" />
115
+ <div class="site-wrapper">
116
+ <header class="site-header">
117
+ <LayoutTopHeader />
118
+ <component :is="currentHeader" />
119
+ <component :is="currentNavbar" />
120
+ </header>
83
121
 
84
- <slot />
122
+ <!-- Наскрізний банер (sitewideBanner) — під шапкою на всіх сторінках -->
123
+ <LayoutSitewideBanner />
124
+
125
+ <main class="site-main">
126
+ <slot />
127
+ </main>
128
+
129
+ <footer class="site-footer">
130
+ <component :is="currentFooter" />
131
+ <LayoutCopyright />
132
+ </footer>
85
133
 
86
- <CommonServices class="bg-gray1" />
87
- <component :is="currentFooter" />
88
134
  <LayoutBackToUp />
89
135
  <LayoutResponsiveNavbar />
90
- <LayoutLocationOption />
91
- <LayoutCopyright />
92
- <LayoutRTLSwitchBtn />
93
136
  </div>
94
137
  </template>
138
+
139
+ <style scoped>
140
+ /* Каркас сторінки: шапка → контент (росте) → футер завжди знизу, жодних перекриттів */
141
+ .site-wrapper {
142
+ display: flex;
143
+ flex-direction: column;
144
+ min-height: 100vh;
145
+ }
146
+ .site-header,
147
+ .site-footer {
148
+ flex: 0 0 auto;
149
+ }
150
+ .site-main {
151
+ flex: 1 0 auto;
152
+ display: block;
153
+ width: 100%;
154
+ }
155
+ </style>