@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
@@ -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 Product {
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
- price: number;
9
- discountPrice?: number;
10
- images: string[];
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
- categoryId: string;
13
- organizationId: string;
14
- isActive: boolean;
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
- sortBy?: 'price' | 'name' | 'rating' | 'newest';
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(/&nbsp;/g, ' ')
156
+ .replace(/&amp;/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
- // Fetch all products with optional filters
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 token = publicApiToken.value;
44
- if (!token) {
45
- throw new Error('Store token not available');
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 = e.message || 'Failed to load products';
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
- // Fetch single product by ID
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 token = publicApiToken.value;
76
- if (!token) {
77
- throw new Error('Store token not available');
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 = e.message || 'Failed to load product';
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
- // Fetch products for a specific section without mutating global state
97
- const fetchProductsForSection = async (filters?: ProductFilters & { limit?: number; offset?: number }): Promise<Product[]> => {
226
+ // Один товар без зміни глобального стану; null — якщо товару нема (404/некоректний id)
227
+ const fetchProductById = async (productId: string): Promise<Product | null> => {
228
+ if (!productId) return null;
98
229
  try {
99
- const token = publicApiToken.value;
100
- if (!token) return [];
101
-
102
- const params: any = {
103
- token,
104
- ...filters,
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
- const response = await $fetch<{ products: Product[]; total: number }>('/site-template/public/store/products', {
108
- baseURL: getApiBaseUrl(),
109
- params,
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
- return response.products;
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 for section:', e);
115
- return [];
257
+ console.error('Error loading products page:', e);
258
+ return { items: [], total: 0 };
116
259
  }
117
260
  };
118
261
 
119
- // Get products by category
120
- const getProductsByCategory = async (categoryId: string) => {
121
- return fetchProducts({ categoryId });
122
- };
262
+ // Товари для секції головної (без глобального стану)
263
+ const fetchProductsForSection = async (filters?: ProductFilters): Promise<Product[]> =>
264
+ (await fetchPage(filters)).items;
123
265
 
124
- // Search products
125
- const searchProducts = async (query: string) => {
126
- return fetchProducts({ search: query });
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
- // Computed properties
130
- const activeProducts = computed(() =>
131
- products.value.filter(p => p.isActive)
132
- );
281
+ // Товари конкретної категорії
282
+ const fetchByCategory = async (categoryId: string, limit = 10): Promise<Product[]> =>
283
+ fetchProductsForSection({ categoryId, limit, sortBy: 'newest' });
133
284
 
134
- const discountedProducts = computed(() =>
135
- activeProducts.value.filter(p => p.discountPrice && p.discountPrice < p.price)
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 bestRatedProducts = computed(() =>
139
- [...activeProducts.value]
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 newestProducts = computed(() =>
145
- [...activeProducts.value].slice(0, 10)
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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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, '&lt;');
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 { colors, fonts, customCSS } = siteConfig.value.customization;
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
- // Update meta tags for SEO
196
+ // SEO: мета-теги з налаштувань сайту (SSR-сумісно через useHead)
174
197
  const updateMetaTags = () => {
175
198
  if (!siteConfig.value) return;
176
199
 
177
- const { siteName, siteDescription, logoUrl } = siteConfig.value;
178
- const seo = siteConfig.value.customization?.content?.seo;
179
-
180
- if (import.meta.client) {
181
- if (siteName) {
182
- document.title = siteName;
183
- }
184
-
185
- // Update or create meta tags
186
- const updateMetaTag = (name: string, content: string) => {
187
- let meta = document.querySelector(`meta[name="${name}"]`);
188
- if (!meta) {
189
- meta = document.createElement('meta');
190
- meta.setAttribute('name', name);
191
- document.head.appendChild(meta);
192
- }
193
- meta.setAttribute('content', content);
194
- };
195
-
196
- if (siteDescription) {
197
- updateMetaTag('description', siteDescription);
198
- }
199
-
200
- if (seo?.metaTitle) {
201
- updateMetaTag('title', seo.metaTitle);
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