@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,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(/<[^>]*>| |\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
|
|
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
|
-
// Фінальний порядок секцій:
|
|
32
|
-
|
|
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
|
+
};
|
package/layouts/default.vue
CHANGED
|
@@ -1,27 +1,63 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
* Динамічно обирає варіант header/navbar/footer
|
|
5
|
-
*
|
|
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
|
-
//
|
|
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
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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>
|