@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.
- 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 +66 -29
- package/composables/useCategoryImages.ts +45 -0
- package/composables/useCheckout.ts +302 -95
- 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 -81
- package/composables/useRecent.ts +30 -0
- package/composables/useSanitize.ts +76 -0
- package/composables/useSiteConfig.ts +51 -45
- 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/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
|
@@ -1,31 +1,178 @@
|
|
|
1
1
|
import { ref, computed } from 'vue';
|
|
2
|
+
import { tryUseNuxtApp } from '#imports';
|
|
2
3
|
import { getApiBaseUrl } from '../utils/api';
|
|
3
4
|
import { useSiteConfig } from './useSiteConfig';
|
|
4
5
|
|
|
5
|
-
interface
|
|
6
|
+
export interface ProductImage {
|
|
7
|
+
id?: string;
|
|
8
|
+
url: string;
|
|
9
|
+
isMain?: boolean;
|
|
10
|
+
alt?: string;
|
|
11
|
+
sortOrder?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ProductPrice {
|
|
15
|
+
id?: string;
|
|
16
|
+
value: string | number;
|
|
17
|
+
currency?: string;
|
|
18
|
+
isWholesale?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Акція, що діє на товар (публічне API магазину) */
|
|
22
|
+
export interface ProductDiscount {
|
|
6
23
|
id: string;
|
|
7
24
|
name: string;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
25
|
+
type: 'percentage' | 'fixed_amount';
|
|
26
|
+
value: number | string;
|
|
27
|
+
/** ISO-дата завершення акції; null — безстрокова */
|
|
28
|
+
endDate: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Товар у тому вигляді, в якому його віддає публічне API магазину. */
|
|
32
|
+
export interface Product {
|
|
33
|
+
id: string;
|
|
34
|
+
title: string;
|
|
35
|
+
/** Застарілий аліас назви (деякі компоненти шару читають name) */
|
|
36
|
+
name?: string;
|
|
11
37
|
description?: string;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
isActive
|
|
38
|
+
sku?: string;
|
|
39
|
+
unit?: string;
|
|
40
|
+
isActive?: boolean;
|
|
41
|
+
isFeatured?: boolean;
|
|
42
|
+
images?: ProductImage[];
|
|
43
|
+
prices?: ProductPrice[];
|
|
44
|
+
categories?: { id: string; name: string }[];
|
|
45
|
+
/** Ціна до знижки (бек віддає, коли на товар діє акція) */
|
|
46
|
+
oldPrice?: number | string | null;
|
|
47
|
+
/** Ціна зі знижкою (ефективна ціна товару, якщо є акція) */
|
|
48
|
+
salePrice?: number | string | null;
|
|
49
|
+
/** Активна акція товару */
|
|
50
|
+
discount?: ProductDiscount | null;
|
|
15
51
|
stock?: number;
|
|
16
52
|
rating?: number;
|
|
17
53
|
reviews?: number;
|
|
54
|
+
createdAt?: string;
|
|
55
|
+
[key: string]: any;
|
|
18
56
|
}
|
|
19
57
|
|
|
20
|
-
interface ProductFilters {
|
|
58
|
+
export interface ProductFilters {
|
|
21
59
|
categoryId?: string;
|
|
22
60
|
minPrice?: number;
|
|
23
61
|
maxPrice?: number;
|
|
24
62
|
search?: string;
|
|
25
|
-
|
|
63
|
+
/** Лише товари з активною акцією */
|
|
64
|
+
discounted?: boolean;
|
|
65
|
+
sortBy?: 'price' | 'name' | 'title' | 'rating' | 'newest' | 'bestselling';
|
|
26
66
|
sortOrder?: 'asc' | 'desc';
|
|
67
|
+
limit?: number;
|
|
68
|
+
offset?: number;
|
|
27
69
|
}
|
|
28
70
|
|
|
71
|
+
export interface ProductsPage {
|
|
72
|
+
items: Product[];
|
|
73
|
+
total: number;
|
|
74
|
+
/** Сортування, яке бек реально застосував (якщо він його повертає) */
|
|
75
|
+
sortBy?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Хелпери відображення (чисті функції, використовуються карточками) ──
|
|
79
|
+
|
|
80
|
+
/** Базова (не оптова) ціна з prices[] у гривнях — без урахування акції. */
|
|
81
|
+
export const getProductBasePrice = (p?: Product | null): number => {
|
|
82
|
+
if (!p?.prices?.length) return 0;
|
|
83
|
+
const main = p.prices.find((x) => !x.isWholesale) || p.prices[0];
|
|
84
|
+
return Number(main.value) || 0;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Ефективна ціна: salePrice (акція), інакше базова з prices[]. */
|
|
88
|
+
export const getProductPrice = (p?: Product | null): number => {
|
|
89
|
+
const sale = Number(p?.salePrice);
|
|
90
|
+
if (p?.salePrice !== null && p?.salePrice !== undefined && p?.salePrice !== '' && Number.isFinite(sale) && sale >= 0) {
|
|
91
|
+
return sale;
|
|
92
|
+
}
|
|
93
|
+
return getProductBasePrice(p);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Стара ціна (для перекресленої), лише якщо вона більша за поточну.
|
|
98
|
+
* Бек віддає oldPrice; якщо є лише salePrice — стара = базова з prices[].
|
|
99
|
+
*/
|
|
100
|
+
export const getProductOldPrice = (p?: Product | null): number | null => {
|
|
101
|
+
const price = getProductPrice(p);
|
|
102
|
+
const old = Number(p?.oldPrice);
|
|
103
|
+
if (old > 0 && old > price) return old;
|
|
104
|
+
const base = getProductBasePrice(p);
|
|
105
|
+
return base > price ? base : null;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** Бейдж знижки: «-20%» для відсоткової акції, «-50 ₴» для фіксованої; '' — знижки немає. */
|
|
109
|
+
export const getProductDiscountLabel = (p?: Product | null): string => {
|
|
110
|
+
const old = getProductOldPrice(p);
|
|
111
|
+
if (!old) return '';
|
|
112
|
+
const price = getProductPrice(p);
|
|
113
|
+
if (p?.discount?.type === 'fixed_amount') return `-${formatPrice(old - price)}`;
|
|
114
|
+
const pct = Math.round(((old - price) / old) * 100);
|
|
115
|
+
return pct > 0 ? `-${pct}%` : `-${formatPrice(old - price)}`;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Дата завершення акції «31.12.2026» (за Києвом); '' — безстрокова/невідома. */
|
|
119
|
+
export const getProductDiscountEnd = (p?: Product | null): string => {
|
|
120
|
+
const raw = p?.discount?.endDate;
|
|
121
|
+
if (!raw || !getProductOldPrice(p)) return '';
|
|
122
|
+
const ts = Date.parse(raw);
|
|
123
|
+
if (!Number.isFinite(ts)) return '';
|
|
124
|
+
// Вручну з частин (Intl у Node і браузері може відрізнятись форматом → гідрація)
|
|
125
|
+
const parts = new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Kyiv', day: '2-digit', month: '2-digit', year: 'numeric' })
|
|
126
|
+
.formatToParts(new Date(ts));
|
|
127
|
+
const get = (t: string) => parts.find((x) => x.type === t)?.value || '';
|
|
128
|
+
return `${get('day')}.${get('month')}.${get('year')}`;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** «1 234 ₴». Символ додаємо вручну — Intl дає різний результат у Node і браузері (гідрація). */
|
|
132
|
+
export const formatPrice = (n: number): string =>
|
|
133
|
+
`${new Intl.NumberFormat('uk-UA', { maximumFractionDigits: 0 }).format(n).replace(/ | /g, ' ')} ₴`;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Абсолютний URL медіа. Повні URL лишаємо, відносні ({orgId}/file або /media/..)
|
|
137
|
+
* доповнюємо публічним media-доменом (public.mediaBase), з фолбеком на прод-домен.
|
|
138
|
+
*/
|
|
139
|
+
export const resolveMediaUrl = (url?: string | null): string => {
|
|
140
|
+
if (!url) return '';
|
|
141
|
+
if (/^(https?:|data:|blob:)/.test(url)) return url;
|
|
142
|
+
let base = '';
|
|
143
|
+
try {
|
|
144
|
+
base = String((useRuntimeConfig().public as any).mediaBase || '');
|
|
145
|
+
} catch { /* поза контекстом Nuxt */ }
|
|
146
|
+
base = (base || 'https://media.crm-market.com.ua').replace(/\/$/, '');
|
|
147
|
+
return base + (url.startsWith('/') ? url : '/' + url);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** HTML → звичайний текст (для мета-тегів і SEO). */
|
|
151
|
+
export const htmlToText = (html?: string | null): string =>
|
|
152
|
+
String(html || '')
|
|
153
|
+
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ')
|
|
154
|
+
.replace(/<[^>]*>/g, ' ')
|
|
155
|
+
.replace(/ /g, ' ')
|
|
156
|
+
.replace(/&/g, '&')
|
|
157
|
+
.replace(/\s+/g, ' ')
|
|
158
|
+
.trim();
|
|
159
|
+
|
|
160
|
+
/** Головна картинка товару (isMain) або перша; порожній рядок, якщо картинок немає. */
|
|
161
|
+
export const getProductImage = (p?: Product | null): string => {
|
|
162
|
+
const imgs = p?.images;
|
|
163
|
+
if (!imgs?.length) return '';
|
|
164
|
+
const main = imgs.find((i) => i.isMain) || imgs[0];
|
|
165
|
+
return resolveMediaUrl(typeof main === 'string' ? (main as any) : main?.url);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/** Дістає масив товарів з відповіді: {products|items|data} або голий масив. */
|
|
169
|
+
const extractProducts = (response: any): Product[] => {
|
|
170
|
+
const list = Array.isArray(response)
|
|
171
|
+
? response
|
|
172
|
+
: response?.products || response?.items || response?.data || [];
|
|
173
|
+
return Array.isArray(list) ? list : [];
|
|
174
|
+
};
|
|
175
|
+
|
|
29
176
|
const products = ref<Product[]>([]);
|
|
30
177
|
const loading = ref(false);
|
|
31
178
|
const error = ref<string | null>(null);
|
|
@@ -34,31 +181,24 @@ const currentProduct = ref<Product | null>(null);
|
|
|
34
181
|
export const useProducts = () => {
|
|
35
182
|
const { publicApiToken } = useSiteConfig();
|
|
36
183
|
|
|
37
|
-
|
|
184
|
+
const request = async <T = any>(path: string, params: Record<string, any> = {}): Promise<T> => {
|
|
185
|
+
const token = publicApiToken.value;
|
|
186
|
+
if (!token) throw new Error('Store token not available');
|
|
187
|
+
// Прибираємо undefined, щоб не слати порожні параметри
|
|
188
|
+
const clean = Object.fromEntries(Object.entries(params).filter(([, v]) => v !== undefined && v !== ''));
|
|
189
|
+
return await $fetch<T>(path, { baseURL: getApiBaseUrl(), params: { token, ...clean } });
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// Список товарів з фільтрами (оновлює глобальний стан; повертає масив — сумісно зі сторінкою каталогу)
|
|
38
193
|
const fetchProducts = async (filters?: ProductFilters) => {
|
|
39
194
|
loading.value = true;
|
|
40
195
|
error.value = null;
|
|
41
|
-
|
|
42
196
|
try {
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const params: any = {
|
|
49
|
-
token,
|
|
50
|
-
...filters,
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
const response = await $fetch<{ products: Product[]; total: number }>('/site-template/public/store/products', {
|
|
54
|
-
baseURL: getApiBaseUrl(),
|
|
55
|
-
params,
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
products.value = response.products;
|
|
59
|
-
return response.products;
|
|
197
|
+
const response = await request('/site-template/public/store/products', filters as any);
|
|
198
|
+
products.value = extractProducts(response);
|
|
199
|
+
return products.value;
|
|
60
200
|
} catch (e: any) {
|
|
61
|
-
error.value =
|
|
201
|
+
error.value = 'Не вдалося завантажити товари. Спробуйте пізніше.';
|
|
62
202
|
console.error('Error loading products:', e);
|
|
63
203
|
throw e;
|
|
64
204
|
} finally {
|
|
@@ -66,26 +206,16 @@ export const useProducts = () => {
|
|
|
66
206
|
}
|
|
67
207
|
};
|
|
68
208
|
|
|
69
|
-
//
|
|
209
|
+
// Один товар за id
|
|
70
210
|
const fetchProduct = async (productId: string) => {
|
|
71
211
|
loading.value = true;
|
|
72
212
|
error.value = null;
|
|
73
|
-
|
|
74
213
|
try {
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const response = await $fetch<{ product: Product }>(`/site-template/public/store/products/${productId}`, {
|
|
81
|
-
baseURL: getApiBaseUrl(),
|
|
82
|
-
params: { token },
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
currentProduct.value = response.product;
|
|
86
|
-
return response.product;
|
|
214
|
+
const response = await request<any>(`/site-template/public/store/products/${productId}`);
|
|
215
|
+
currentProduct.value = response?.product || response;
|
|
216
|
+
return currentProduct.value;
|
|
87
217
|
} catch (e: any) {
|
|
88
|
-
error.value =
|
|
218
|
+
error.value = 'Не вдалося завантажити товар. Спробуйте пізніше.';
|
|
89
219
|
console.error('Error loading product:', e);
|
|
90
220
|
throw e;
|
|
91
221
|
} finally {
|
|
@@ -93,57 +223,84 @@ export const useProducts = () => {
|
|
|
93
223
|
}
|
|
94
224
|
};
|
|
95
225
|
|
|
96
|
-
//
|
|
97
|
-
const
|
|
226
|
+
// Один товар без зміни глобального стану; null — якщо товару нема (404/некоректний id)
|
|
227
|
+
const fetchProductById = async (productId: string): Promise<Product | null> => {
|
|
228
|
+
if (!productId) return null;
|
|
98
229
|
try {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
230
|
+
const response = await request<any>(`/site-template/public/store/products/${encodeURIComponent(productId)}`);
|
|
231
|
+
const product = response?.product || response;
|
|
232
|
+
return product?.id ? (product as Product) : null;
|
|
233
|
+
} catch (e: any) {
|
|
234
|
+
const status = e?.statusCode || e?.status || e?.response?.status;
|
|
235
|
+
if (status === 404 || status === 400) return null;
|
|
236
|
+
throw e;
|
|
237
|
+
}
|
|
238
|
+
};
|
|
106
239
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
240
|
+
// Товари за списком id (пропускає відсутні/недоступні), порядок зберігається
|
|
241
|
+
const fetchProductsByIds = async (ids: string[]): Promise<Product[]> => {
|
|
242
|
+
const results = await Promise.all(ids.map((id) => fetchProductById(id).catch(() => null)));
|
|
243
|
+
return results.filter((p): p is Product => !!p && p.isActive !== false);
|
|
244
|
+
};
|
|
111
245
|
|
|
112
|
-
|
|
246
|
+
// Сторінка товарів {items,total} без зміни глобального стану; при помилці — порожній результат
|
|
247
|
+
const fetchPage = async (filters?: ProductFilters): Promise<ProductsPage> => {
|
|
248
|
+
try {
|
|
249
|
+
const response = await request('/site-template/public/store/products', filters as any);
|
|
250
|
+
const items = extractProducts(response);
|
|
251
|
+
return {
|
|
252
|
+
items,
|
|
253
|
+
total: Number(response?.total) || items.length,
|
|
254
|
+
sortBy: typeof response?.sortBy === 'string' ? response.sortBy : undefined,
|
|
255
|
+
};
|
|
113
256
|
} catch (e: any) {
|
|
114
|
-
console.error('Error loading products
|
|
115
|
-
return [];
|
|
257
|
+
console.error('Error loading products page:', e);
|
|
258
|
+
return { items: [], total: 0 };
|
|
116
259
|
}
|
|
117
260
|
};
|
|
118
261
|
|
|
119
|
-
//
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
};
|
|
262
|
+
// Товари для секції головної (без глобального стану)
|
|
263
|
+
const fetchProductsForSection = async (filters?: ProductFilters): Promise<Product[]> =>
|
|
264
|
+
(await fetchPage(filters)).items;
|
|
123
265
|
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
266
|
+
// Акційні товари (?discounted=true). Бек, що ще не знає фільтра, віддає звичайний список —
|
|
267
|
+
// тому завжди дофільтровуємо локально за наявністю старої ціни.
|
|
268
|
+
const fetchDiscounted = async (limit = 10): Promise<Product[]> => {
|
|
269
|
+
// Після await контекст Nuxt губиться (getApiBaseUrl бере useRuntimeConfig) — фолбек у контексті
|
|
270
|
+
const nuxtApp = tryUseNuxtApp();
|
|
271
|
+
const onlyDiscounted = (items: Product[]) =>
|
|
272
|
+
items.filter((p) => p.isActive !== false && getProductOldPrice(p)).slice(0, limit);
|
|
273
|
+
const found = onlyDiscounted((await fetchPage({ discounted: true, limit })).items);
|
|
274
|
+
if (found.length) return found;
|
|
275
|
+
// Бек ще не знає ?discounted (400 або без знижок у відповіді) — шукаємо серед звичайного списку
|
|
276
|
+
const fallback = () => fetchPage({ limit: 50 });
|
|
277
|
+
const page = nuxtApp ? await nuxtApp.runWithContext(fallback) : await fallback();
|
|
278
|
+
return onlyDiscounted(page.items);
|
|
127
279
|
};
|
|
128
280
|
|
|
129
|
-
//
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
);
|
|
281
|
+
// Товари конкретної категорії
|
|
282
|
+
const fetchByCategory = async (categoryId: string, limit = 10): Promise<Product[]> =>
|
|
283
|
+
fetchProductsForSection({ categoryId, limit, sortBy: 'newest' });
|
|
133
284
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
285
|
+
// Рекомендовані товари магазину
|
|
286
|
+
const fetchFeatured = async (limit = 8): Promise<Product[]> => {
|
|
287
|
+
try {
|
|
288
|
+
return extractProducts(await request('/site-template/public/store/products/featured', { limit }));
|
|
289
|
+
} catch (e: any) {
|
|
290
|
+
console.error('Error loading featured products:', e);
|
|
291
|
+
return [];
|
|
292
|
+
}
|
|
293
|
+
};
|
|
137
294
|
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
.sort((a, b) => (b.rating || 0) - (a.rating || 0))
|
|
141
|
-
.slice(0, 10)
|
|
142
|
-
);
|
|
295
|
+
const getProductsByCategory = (categoryId: string) => fetchProducts({ categoryId });
|
|
296
|
+
const searchProducts = (query: string) => fetchProducts({ search: query });
|
|
143
297
|
|
|
144
|
-
const
|
|
145
|
-
|
|
298
|
+
const activeProducts = computed(() => products.value.filter((p) => p.isActive !== false));
|
|
299
|
+
const discountedProducts = computed(() => activeProducts.value.filter((p) => getProductOldPrice(p)));
|
|
300
|
+
const bestRatedProducts = computed(() =>
|
|
301
|
+
[...activeProducts.value].sort((a, b) => (b.rating || 0) - (a.rating || 0)).slice(0, 10),
|
|
146
302
|
);
|
|
303
|
+
const newestProducts = computed(() => [...activeProducts.value].slice(0, 10));
|
|
147
304
|
|
|
148
305
|
return {
|
|
149
306
|
products,
|
|
@@ -152,7 +309,13 @@ export const useProducts = () => {
|
|
|
152
309
|
error,
|
|
153
310
|
fetchProducts,
|
|
154
311
|
fetchProduct,
|
|
312
|
+
fetchProductById,
|
|
313
|
+
fetchProductsByIds,
|
|
314
|
+
fetchPage,
|
|
155
315
|
fetchProductsForSection,
|
|
316
|
+
fetchByCategory,
|
|
317
|
+
fetchFeatured,
|
|
318
|
+
fetchDiscounted,
|
|
156
319
|
getProductsByCategory,
|
|
157
320
|
searchProducts,
|
|
158
321
|
activeProducts,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useState } from '#imports';
|
|
2
|
+
|
|
3
|
+
const RECENT_KEY = 'recent_products';
|
|
4
|
+
const MAX_RECENT = 8;
|
|
5
|
+
|
|
6
|
+
/** Нещодавно переглянуті товари (лише id, найновіші першими) у localStorage. */
|
|
7
|
+
export const useRecent = () => {
|
|
8
|
+
const ids = useState<string[]>('recent-ids', () => []);
|
|
9
|
+
|
|
10
|
+
const load = () => {
|
|
11
|
+
try {
|
|
12
|
+
const arr = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]');
|
|
13
|
+
ids.value = Array.isArray(arr) ? arr.filter((i) => typeof i === 'string').slice(0, MAX_RECENT) : [];
|
|
14
|
+
} catch {
|
|
15
|
+
ids.value = [];
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Додає товар на початок списку (дублікати прибирає). Лише на клієнті. */
|
|
20
|
+
const track = (id: string) => {
|
|
21
|
+
if (!id || typeof window === 'undefined') return;
|
|
22
|
+
load();
|
|
23
|
+
ids.value = [id, ...ids.value.filter((i) => i !== id)].slice(0, MAX_RECENT);
|
|
24
|
+
try {
|
|
25
|
+
localStorage.setItem(RECENT_KEY, JSON.stringify(ids.value));
|
|
26
|
+
} catch { /* сховище недоступне */ }
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return { ids, load, track };
|
|
30
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Санітизація HTML продавця (SSR + клієнт, без DOM і зайвих залежностей).
|
|
3
|
+
* Білий список тегів/атрибутів: все інше викидається, небезпечні блоки
|
|
4
|
+
* (script, style, iframe...) видаляються разом із вмістом, URI — лише безпечні схеми.
|
|
5
|
+
*/
|
|
6
|
+
const ALLOWED_TAGS = new Set([
|
|
7
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'br', 'hr', 'strong', 'b', 'em', 'i', 'u', 's',
|
|
8
|
+
'ul', 'ol', 'li', 'a', 'blockquote', 'span', 'div', 'img', 'table', 'thead', 'tbody',
|
|
9
|
+
'tr', 'td', 'th', 'pre', 'code',
|
|
10
|
+
]);
|
|
11
|
+
const VOID_TAGS = new Set(['br', 'hr', 'img']);
|
|
12
|
+
const ALLOWED_ATTRS: Record<string, string[]> = {
|
|
13
|
+
a: ['href', 'title', 'target', 'rel'],
|
|
14
|
+
img: ['src', 'alt', 'title'],
|
|
15
|
+
};
|
|
16
|
+
const GLOBAL_ATTRS = ['class'];
|
|
17
|
+
|
|
18
|
+
const escapeAttr = (v: string) =>
|
|
19
|
+
v.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
20
|
+
|
|
21
|
+
// Перевірка URI: прибираємо пробіли/керуючі символи, які ламають детекцію схеми
|
|
22
|
+
const isSafeUri = (raw: string, allowMail: boolean): boolean => {
|
|
23
|
+
const v = raw.replace(/[\u0000- \u007f-\u009f]/g, '').toLowerCase();
|
|
24
|
+
if (!v) return false;
|
|
25
|
+
if (/^(https?:|\/(?!\/)|#)/.test(v)) return true;
|
|
26
|
+
if (allowMail && /^(mailto:|tel:)/.test(v)) return true;
|
|
27
|
+
return false;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const DANGEROUS_BLOCK =
|
|
31
|
+
/<(script|style|iframe|object|embed|svg|math|template|noscript|textarea|select|form)\b[\s\S]*?(?:<\/\1\s*>|$)/gi;
|
|
32
|
+
|
|
33
|
+
export const sanitizeHtml = (html?: string | null): string => {
|
|
34
|
+
if (!html) return '';
|
|
35
|
+
const src = String(html).replace(/<!--[\s\S]*?(?:-->|$)/g, '').replace(DANGEROUS_BLOCK, '');
|
|
36
|
+
const tagRe = /<(\/?)([a-zA-Z][a-zA-Z0-9]*)\b((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
|
|
37
|
+
const attrRe = /([a-zA-Z_:][-\w:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
|
38
|
+
const text = (s: string) => s.replace(/</g, '<');
|
|
39
|
+
|
|
40
|
+
let out = '';
|
|
41
|
+
let last = 0;
|
|
42
|
+
let m: RegExpExecArray | null;
|
|
43
|
+
while ((m = tagRe.exec(src))) {
|
|
44
|
+
out += text(src.slice(last, m.index));
|
|
45
|
+
last = tagRe.lastIndex;
|
|
46
|
+
const closing = m[1] === '/';
|
|
47
|
+
const tag = m[2].toLowerCase();
|
|
48
|
+
if (!ALLOWED_TAGS.has(tag)) continue;
|
|
49
|
+
if (closing) {
|
|
50
|
+
if (!VOID_TAGS.has(tag)) out += `</${tag}>`;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const allowed = [...(ALLOWED_ATTRS[tag] || []), ...GLOBAL_ATTRS];
|
|
54
|
+
let attrs = '';
|
|
55
|
+
let hasBlank = false;
|
|
56
|
+
let am: RegExpExecArray | null;
|
|
57
|
+
attrRe.lastIndex = 0;
|
|
58
|
+
while ((am = attrRe.exec(m[3]))) {
|
|
59
|
+
const name = am[1].toLowerCase();
|
|
60
|
+
const val = am[2] ?? am[3] ?? am[4] ?? '';
|
|
61
|
+
if (!allowed.includes(name)) continue;
|
|
62
|
+
if (name === 'href' && !isSafeUri(val, true)) continue;
|
|
63
|
+
if (name === 'src' && !isSafeUri(val, false)) continue;
|
|
64
|
+
if (name === 'target') {
|
|
65
|
+
if (val !== '_blank') continue;
|
|
66
|
+
hasBlank = true;
|
|
67
|
+
}
|
|
68
|
+
if (name === 'rel') continue; // rel виставляємо самі
|
|
69
|
+
attrs += ` ${name}="${escapeAttr(val)}"`;
|
|
70
|
+
}
|
|
71
|
+
if (tag === 'a' && hasBlank) attrs += ' rel="noopener noreferrer"';
|
|
72
|
+
out += `<${tag}${attrs}${VOID_TAGS.has(tag) ? ' /' : ''}>`;
|
|
73
|
+
}
|
|
74
|
+
out += text(src.slice(last));
|
|
75
|
+
return out;
|
|
76
|
+
};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { computed } from 'vue';
|
|
2
|
-
import { useState, useRequestHeaders } from '#imports';
|
|
2
|
+
import { useState, useRequestHeaders, useHead, useRuntimeConfig } from '#imports';
|
|
3
3
|
import { getApiBaseUrl } from '../utils/api';
|
|
4
|
+
import { resolveMediaUrl } from './useProducts';
|
|
5
|
+
import { resolveTheme, FONT_FALLBACK } from './useThemeVars';
|
|
4
6
|
|
|
5
7
|
interface ColorScheme {
|
|
6
8
|
primary?: string;
|
|
@@ -66,6 +68,9 @@ export const useSiteConfig = () => {
|
|
|
66
68
|
let hostname = '';
|
|
67
69
|
|
|
68
70
|
if (import.meta.server) {
|
|
71
|
+
// Фіксований магазин екземпляра (деплой «один контейнер — один клієнт»): STORE_SUBDOMAIN має пріоритет над хостом
|
|
72
|
+
const fixed = String((useRuntimeConfig() as any).storeSubdomain || '').trim();
|
|
73
|
+
if (fixed) return fixed;
|
|
69
74
|
// SSR: отримуємо host з request headers
|
|
70
75
|
const headers = useRequestHeaders(['host', 'x-forwarded-host']);
|
|
71
76
|
hostname = (headers['x-forwarded-host'] || headers['host'] || '').split(':')[0];
|
|
@@ -75,6 +80,10 @@ export const useSiteConfig = () => {
|
|
|
75
80
|
}
|
|
76
81
|
|
|
77
82
|
const parts = hostname.split('.');
|
|
83
|
+
// Локальна розробка: shop.localhost → "shop"
|
|
84
|
+
if (parts.length === 2 && parts[1] === 'localhost') {
|
|
85
|
+
return parts[0];
|
|
86
|
+
}
|
|
78
87
|
// Формат: subdomain.domain.com або subdomain.domain.com.ua
|
|
79
88
|
if (parts.length >= 3) {
|
|
80
89
|
return parts[0];
|
|
@@ -120,7 +129,21 @@ export const useSiteConfig = () => {
|
|
|
120
129
|
const applyCustomization = () => {
|
|
121
130
|
if (!siteConfig.value?.customization) return;
|
|
122
131
|
|
|
123
|
-
const {
|
|
132
|
+
const { customCSS } = siteConfig.value.customization;
|
|
133
|
+
// Кольори/шрифти: values.colors/values.fonts («Налаштування шаблону») мають пріоритет над старими полями
|
|
134
|
+
const theme = resolveTheme(siteConfig.value.customization);
|
|
135
|
+
const colors = {
|
|
136
|
+
primary: theme.colors.primary,
|
|
137
|
+
secondary: theme.colors.secondary,
|
|
138
|
+
accent: theme.colors.accent,
|
|
139
|
+
bodyColor: theme.colors.body,
|
|
140
|
+
};
|
|
141
|
+
const fonts = {
|
|
142
|
+
...(siteConfig.value.customization.fonts || {}),
|
|
143
|
+
// Той самий стек, що й у useThemeVars (з фолбеком для кирилиці)
|
|
144
|
+
headingFont: theme.fonts.heading ? `'${theme.fonts.heading}', ${FONT_FALLBACK}` : '',
|
|
145
|
+
bodyFont: theme.fonts.body ? `'${theme.fonts.body}', ${FONT_FALLBACK}` : '',
|
|
146
|
+
};
|
|
124
147
|
|
|
125
148
|
if (import.meta.client && colors) {
|
|
126
149
|
const root = document.documentElement;
|
|
@@ -170,52 +193,35 @@ export const useSiteConfig = () => {
|
|
|
170
193
|
}
|
|
171
194
|
};
|
|
172
195
|
|
|
173
|
-
//
|
|
196
|
+
// SEO: мета-теги з налаштувань сайту (SSR-сумісно через useHead)
|
|
174
197
|
const updateMetaTags = () => {
|
|
175
198
|
if (!siteConfig.value) return;
|
|
176
199
|
|
|
177
|
-
const { siteName, siteDescription, logoUrl } = siteConfig.value;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
if (seo?.metaKeywords) {
|
|
205
|
-
updateMetaTag('keywords', seo.metaKeywords);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// Open Graph tags
|
|
209
|
-
if (seo?.ogTitle) {
|
|
210
|
-
updateMetaTag('og:title', seo.ogTitle);
|
|
211
|
-
}
|
|
212
|
-
if (seo?.ogDescription) {
|
|
213
|
-
updateMetaTag('og:description', seo.ogDescription);
|
|
214
|
-
}
|
|
215
|
-
if (seo?.ogImage || logoUrl) {
|
|
216
|
-
updateMetaTag('og:image', seo?.ogImage || logoUrl);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
200
|
+
const { siteName, siteDescription, logoUrl, faviconUrl } = siteConfig.value;
|
|
201
|
+
// SEO приходить верхнім полем конфігу (seo), старіші конфіги тримали його в customization.content.seo
|
|
202
|
+
const seo = (siteConfig.value as any).seo || siteConfig.value.customization?.content?.seo || {};
|
|
203
|
+
|
|
204
|
+
const title = seo.metaTitle || siteName;
|
|
205
|
+
const description = seo.metaDescription || siteDescription;
|
|
206
|
+
const ogImage = resolveMediaUrl(seo.ogImage || logoUrl);
|
|
207
|
+
|
|
208
|
+
const meta: Array<Record<string, string>> = [];
|
|
209
|
+
const add = (key: 'name' | 'property', id: string, content?: string) => {
|
|
210
|
+
if (content) meta.push({ [key]: id, content });
|
|
211
|
+
};
|
|
212
|
+
add('name', 'description', description);
|
|
213
|
+
add('name', 'keywords', seo.metaKeywords);
|
|
214
|
+
add('property', 'og:title', seo.ogTitle || title);
|
|
215
|
+
add('property', 'og:description', seo.ogDescription || description);
|
|
216
|
+
add('property', 'og:image', ogImage);
|
|
217
|
+
add('property', 'og:site_name', siteName);
|
|
218
|
+
|
|
219
|
+
useHead({
|
|
220
|
+
...(title ? { title } : {}),
|
|
221
|
+
meta,
|
|
222
|
+
// favicon може бути відносним шляхом медіа-сервера — доповнюємо mediaBase
|
|
223
|
+
link: faviconUrl ? [{ rel: 'icon', href: resolveMediaUrl(faviconUrl) }] : [],
|
|
224
|
+
});
|
|
219
225
|
};
|
|
220
226
|
|
|
221
227
|
// Computed properties
|