@crm-market/template-shared 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/assets/scss/_variables.scss +3 -3
  2. package/assets/scss/components/_header.scss +1 -1
  3. package/assets/scss/style.css +4 -4
  4. package/components/AboutUs/StaticContent.vue +32 -0
  5. package/components/Cart/CartEmpty.vue +42 -0
  6. package/components/Cart/CartItem.vue +145 -0
  7. package/components/Cart/CartSummary.vue +57 -0
  8. package/components/Cart/ProductQuantity.vue +23 -13
  9. package/components/Cart/index.vue +68 -150
  10. package/components/Categories/index.vue +88 -291
  11. package/components/Checkout/AddressFields.vue +74 -0
  12. package/components/Checkout/CheckoutForm.vue +75 -0
  13. package/components/Checkout/CityAutocomplete.vue +68 -0
  14. package/components/Checkout/DeliveryMethodCard.vue +66 -0
  15. package/components/Checkout/DeliveryOptions.vue +148 -0
  16. package/components/Checkout/OrderSummary.vue +116 -0
  17. package/components/Checkout/PaymentOptions.vue +76 -0
  18. package/components/Checkout/PickupPoints.vue +46 -0
  19. package/components/Checkout/PromoCode.vue +166 -0
  20. package/components/Checkout/index.vue +235 -173
  21. package/components/Checkout/types.ts +45 -0
  22. package/components/Common/PageBanner.vue +22 -20
  23. package/components/Common/ProductCard.vue +139 -115
  24. package/components/Common/Services.vue +4 -4
  25. package/components/Contact/ContactForm.vue +44 -74
  26. package/components/Contact/ContactInfo.vue +35 -61
  27. package/components/FAQ/index.vue +6 -119
  28. package/components/Layout/Copyright.vue +11 -9
  29. package/components/Layout/Footer.vue +49 -136
  30. package/components/Layout/HeaderBar.vue +174 -0
  31. package/components/Layout/HeaderLogo.vue +27 -0
  32. package/components/Layout/MiddleHeader.vue +1 -228
  33. package/components/Layout/MiddleHeaderThree.vue +1 -203
  34. package/components/Layout/MiddleHeaderTwo.vue +1 -239
  35. package/components/Layout/NavLinks.vue +23 -0
  36. package/components/Layout/Navbar.vue +38 -151
  37. package/components/Layout/NavbarStyleFour.vue +1 -332
  38. package/components/Layout/NavbarStyleThree.vue +1 -188
  39. package/components/Layout/NavbarStyleTwo.vue +1 -108
  40. package/components/Layout/Preloader.vue +5 -12
  41. package/components/Layout/ResponsiveNavbar.vue +32 -418
  42. package/components/Layout/SitewideBanner.vue +285 -0
  43. package/components/Layout/TopHeader.vue +17 -83
  44. package/components/Layout/layoutData.ts +83 -0
  45. package/components/PrivacyPolicy/index.vue +6 -110
  46. package/components/ProductDetails/ProductDetailsTab.vue +31 -321
  47. package/components/ProductDetails/ProductGallery.vue +45 -0
  48. package/components/ProductDetails/ProductQuantity.vue +8 -14
  49. package/components/ProductDetails/RecentlyViewed.vue +53 -289
  50. package/components/ProductDetails/RelatedProducts.vue +64 -0
  51. package/components/ProductDetails/index.vue +90 -230
  52. package/components/Products/index.vue +78 -16
  53. package/components/TermsConditions/index.vue +6 -110
  54. package/components/Wishlist/index.vue +62 -88
  55. package/composables/useCart.ts +133 -81
  56. package/composables/useCategories.ts +67 -31
  57. package/composables/useCategoryImages.ts +45 -0
  58. package/composables/useCheckout.ts +302 -97
  59. package/composables/useContactForm.ts +57 -0
  60. package/composables/useHomeContent.ts +220 -0
  61. package/composables/useI18n.ts +12 -24
  62. package/composables/useJsonLd.ts +162 -0
  63. package/composables/useProducts.ts +244 -83
  64. package/composables/useRecent.ts +30 -0
  65. package/composables/useSanitize.ts +76 -0
  66. package/composables/useSiteConfig.ts +51 -47
  67. package/composables/useStaticPages.ts +48 -0
  68. package/composables/useStoreOrigin.ts +32 -0
  69. package/composables/useTemplateSections.ts +51 -3
  70. package/composables/useThemeVars.ts +113 -0
  71. package/composables/useWishlist.ts +72 -0
  72. package/layouts/default.vue +80 -19
  73. package/locales/en.json +255 -86
  74. package/locales/uk.json +251 -82
  75. package/modules/template-schema.ts +35 -0
  76. package/nuxt.config.ts +8 -11
  77. package/package.json +1 -1
  78. package/pages/about-us.vue +9 -9
  79. package/pages/cart.vue +0 -1
  80. package/pages/categories.vue +0 -1
  81. package/pages/category/[slug].vue +84 -0
  82. package/pages/checkout.vue +0 -1
  83. package/pages/contact.vue +6 -6
  84. package/pages/delivery.vue +12 -0
  85. package/pages/faq.vue +6 -6
  86. package/pages/order-success.vue +113 -0
  87. package/pages/privacy-policy.vue +6 -6
  88. package/pages/product/[slug].vue +82 -0
  89. package/pages/product-details.vue +14 -6
  90. package/pages/products.vue +12 -1
  91. package/pages/returns.vue +12 -0
  92. package/pages/terms-conditions.vue +6 -6
  93. package/pages/wishlist.vue +2 -2
  94. package/utils/image.ts +0 -1
  95. package/utils/seo-urls.ts +70 -0
  96. package/utils/store-origin.ts +36 -0
  97. package/components/AboutUs/AboutUsTuan.vue +0 -25
  98. package/components/AboutUs/Statistics.vue +0 -42
  99. package/components/AboutUs/SubscribeToTheNewsletter.vue +0 -57
  100. package/components/AddAddress/index.vue +0 -68
  101. package/components/BestSellers/Products.vue +0 -1562
  102. package/components/BestSellers/RecentlyViewed.vue +0 -304
  103. package/components/ChangePassword/index.vue +0 -69
  104. package/components/Common/DashboardNavigation.vue +0 -35
  105. package/components/Dashboard/RecentOrder.vue +0 -173
  106. package/components/Dashboard/index.vue +0 -77
  107. package/components/EditAddress/index.vue +0 -117
  108. package/components/EditProfile/index.vue +0 -95
  109. package/components/FeaturedProduct/FeaturedProducts.vue +0 -304
  110. package/components/FeaturedProduct/Products.vue +0 -1562
  111. package/components/ForgotPassword/index.vue +0 -49
  112. package/components/Layout/FooterTwo.vue +0 -159
  113. package/components/Layout/LocationOption.vue +0 -178
  114. package/components/Layout/RTLSwitchBtn.vue +0 -40
  115. package/components/Login/index.vue +0 -89
  116. package/components/MyAccount/index.vue +0 -154
  117. package/components/NewArrivals/Products.vue +0 -1969
  118. package/components/NewArrivals/RecentlyViewed.vue +0 -304
  119. package/components/OrderDetails/index.vue +0 -75
  120. package/components/OrderHistory/index.vue +0 -195
  121. package/components/Products/RecentlyViewed.vue +0 -302
  122. package/components/Register/index.vue +0 -83
  123. package/components/TrendingProducts/FeaturedProducts.vue +0 -304
  124. package/components/TrendingProducts/Products.vue +0 -1564
  125. package/components/Wishlist/ProductQuantity.vue +0 -29
  126. package/layouts/inner.vue +0 -70
  127. package/locales/ar.json +0 -234
  128. package/locales/ru.json +0 -234
  129. package/pages/address.vue +0 -11
  130. package/pages/change-password.vue +0 -11
  131. package/pages/dashboard.vue +0 -11
  132. package/pages/edit-address.vue +0 -11
  133. package/pages/edit-profile.vue +0 -11
  134. package/pages/forgot-password.vue +0 -11
  135. package/pages/login.vue +0 -11
  136. package/pages/my-account.vue +0 -11
  137. package/pages/order-details.vue +0 -11
  138. package/pages/order-history.vue +0 -11
  139. package/pages/register.vue +0 -11
@@ -1,66 +1,204 @@
1
1
  import { ref, computed } from 'vue';
2
- import { useRuntimeConfig } from '#imports';
2
+ import { tryUseNuxtApp } from '#imports';
3
3
  import { getApiBaseUrl } from '../utils/api';
4
4
  import { useSiteConfig } from './useSiteConfig';
5
5
 
6
- 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 {
7
23
  id: string;
8
24
  name: string;
9
- price: number;
10
- discountPrice?: number;
11
- 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;
12
37
  description?: string;
13
- categoryId: string;
14
- organizationId: string;
15
- 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;
16
51
  stock?: number;
17
52
  rating?: number;
18
53
  reviews?: number;
54
+ createdAt?: string;
55
+ [key: string]: any;
19
56
  }
20
57
 
21
- interface ProductFilters {
58
+ export interface ProductFilters {
22
59
  categoryId?: string;
23
60
  minPrice?: number;
24
61
  maxPrice?: number;
25
62
  search?: string;
26
- sortBy?: 'price' | 'name' | 'rating' | 'newest';
63
+ /** Лише товари з активною акцією */
64
+ discounted?: boolean;
65
+ sortBy?: 'price' | 'name' | 'title' | 'rating' | 'newest' | 'bestselling';
27
66
  sortOrder?: 'asc' | 'desc';
67
+ limit?: number;
68
+ offset?: number;
28
69
  }
29
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
+
30
176
  const products = ref<Product[]>([]);
31
177
  const loading = ref(false);
32
178
  const error = ref<string | null>(null);
33
179
  const currentProduct = ref<Product | null>(null);
34
180
 
35
181
  export const useProducts = () => {
36
- const config = useRuntimeConfig();
37
182
  const { publicApiToken } = useSiteConfig();
38
183
 
39
- // 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
+ // Список товарів з фільтрами (оновлює глобальний стан; повертає масив — сумісно зі сторінкою каталогу)
40
193
  const fetchProducts = async (filters?: ProductFilters) => {
41
194
  loading.value = true;
42
195
  error.value = null;
43
-
44
196
  try {
45
- const token = publicApiToken.value;
46
- if (!token) {
47
- throw new Error('Store token not available');
48
- }
49
-
50
- const params: any = {
51
- token,
52
- ...filters,
53
- };
54
-
55
- const response = await $fetch<{ products: Product[]; total: number }>('/site-template/public/store/products', {
56
- baseURL: getApiBaseUrl(),
57
- params,
58
- });
59
-
60
- products.value = response.products;
61
- 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;
62
200
  } catch (e: any) {
63
- error.value = e.message || 'Failed to load products';
201
+ error.value = 'Не вдалося завантажити товари. Спробуйте пізніше.';
64
202
  console.error('Error loading products:', e);
65
203
  throw e;
66
204
  } finally {
@@ -68,26 +206,16 @@ export const useProducts = () => {
68
206
  }
69
207
  };
70
208
 
71
- // Fetch single product by ID
209
+ // Один товар за id
72
210
  const fetchProduct = async (productId: string) => {
73
211
  loading.value = true;
74
212
  error.value = null;
75
-
76
213
  try {
77
- const token = publicApiToken.value;
78
- if (!token) {
79
- throw new Error('Store token not available');
80
- }
81
-
82
- const response = await $fetch<{ product: Product }>(`/site-template/public/store/products/${productId}`, {
83
- baseURL: getApiBaseUrl(),
84
- params: { token },
85
- });
86
-
87
- currentProduct.value = response.product;
88
- 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;
89
217
  } catch (e: any) {
90
- error.value = e.message || 'Failed to load product';
218
+ error.value = 'Не вдалося завантажити товар. Спробуйте пізніше.';
91
219
  console.error('Error loading product:', e);
92
220
  throw e;
93
221
  } finally {
@@ -95,57 +223,84 @@ export const useProducts = () => {
95
223
  }
96
224
  };
97
225
 
98
- // Fetch products for a specific section without mutating global state
99
- 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;
100
229
  try {
101
- const token = publicApiToken.value;
102
- if (!token) return [];
103
-
104
- const params: any = {
105
- token,
106
- ...filters,
107
- };
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
+ };
108
239
 
109
- const response = await $fetch<{ products: Product[]; total: number }>('/site-template/public/store/products', {
110
- baseURL: getApiBaseUrl(),
111
- params,
112
- });
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
+ };
113
245
 
114
- 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
+ };
115
256
  } catch (e: any) {
116
- console.error('Error loading products for section:', e);
117
- return [];
257
+ console.error('Error loading products page:', e);
258
+ return { items: [], total: 0 };
118
259
  }
119
260
  };
120
261
 
121
- // Get products by category
122
- const getProductsByCategory = async (categoryId: string) => {
123
- return fetchProducts({ categoryId });
124
- };
262
+ // Товари для секції головної (без глобального стану)
263
+ const fetchProductsForSection = async (filters?: ProductFilters): Promise<Product[]> =>
264
+ (await fetchPage(filters)).items;
125
265
 
126
- // Search products
127
- const searchProducts = async (query: string) => {
128
- 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);
129
279
  };
130
280
 
131
- // Computed properties
132
- const activeProducts = computed(() =>
133
- products.value.filter(p => p.isActive)
134
- );
281
+ // Товари конкретної категорії
282
+ const fetchByCategory = async (categoryId: string, limit = 10): Promise<Product[]> =>
283
+ fetchProductsForSection({ categoryId, limit, sortBy: 'newest' });
135
284
 
136
- const discountedProducts = computed(() =>
137
- activeProducts.value.filter(p => p.discountPrice && p.discountPrice < p.price)
138
- );
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
+ };
139
294
 
140
- const bestRatedProducts = computed(() =>
141
- [...activeProducts.value]
142
- .sort((a, b) => (b.rating || 0) - (a.rating || 0))
143
- .slice(0, 10)
144
- );
295
+ const getProductsByCategory = (categoryId: string) => fetchProducts({ categoryId });
296
+ const searchProducts = (query: string) => fetchProducts({ search: query });
145
297
 
146
- const newestProducts = computed(() =>
147
- [...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),
148
302
  );
303
+ const newestProducts = computed(() => [...activeProducts.value].slice(0, 10));
149
304
 
150
305
  return {
151
306
  products,
@@ -154,7 +309,13 @@ export const useProducts = () => {
154
309
  error,
155
310
  fetchProducts,
156
311
  fetchProduct,
312
+ fetchProductById,
313
+ fetchProductsByIds,
314
+ fetchPage,
157
315
  fetchProductsForSection,
316
+ fetchByCategory,
317
+ fetchFeatured,
318
+ fetchDiscounted,
158
319
  getProductsByCategory,
159
320
  searchProducts,
160
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, useRuntimeConfig, useRoute, 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;
@@ -61,13 +63,14 @@ export const useSiteConfig = () => {
61
63
  const loading = useState<boolean>('site-config-loading', () => false);
62
64
  const error = useState<string | null>('site-config-error', () => null);
63
65
 
64
- const config = useRuntimeConfig();
65
-
66
66
  // Отримати subdomain з host — працює і на SSR, і на клієнті
67
67
  const getSubdomain = () => {
68
68
  let hostname = '';
69
69
 
70
70
  if (import.meta.server) {
71
+ // Фіксований магазин екземпляра (деплой «один контейнер — один клієнт»): STORE_SUBDOMAIN має пріоритет над хостом
72
+ const fixed = String((useRuntimeConfig() as any).storeSubdomain || '').trim();
73
+ if (fixed) return fixed;
71
74
  // SSR: отримуємо host з request headers
72
75
  const headers = useRequestHeaders(['host', 'x-forwarded-host']);
73
76
  hostname = (headers['x-forwarded-host'] || headers['host'] || '').split(':')[0];
@@ -77,6 +80,10 @@ export const useSiteConfig = () => {
77
80
  }
78
81
 
79
82
  const parts = hostname.split('.');
83
+ // Локальна розробка: shop.localhost → "shop"
84
+ if (parts.length === 2 && parts[1] === 'localhost') {
85
+ return parts[0];
86
+ }
80
87
  // Формат: subdomain.domain.com або subdomain.domain.com.ua
81
88
  if (parts.length >= 3) {
82
89
  return parts[0];
@@ -122,7 +129,21 @@ export const useSiteConfig = () => {
122
129
  const applyCustomization = () => {
123
130
  if (!siteConfig.value?.customization) return;
124
131
 
125
- 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
+ };
126
147
 
127
148
  if (import.meta.client && colors) {
128
149
  const root = document.documentElement;
@@ -172,52 +193,35 @@ export const useSiteConfig = () => {
172
193
  }
173
194
  };
174
195
 
175
- // Update meta tags for SEO
196
+ // SEO: мета-теги з налаштувань сайту (SSR-сумісно через useHead)
176
197
  const updateMetaTags = () => {
177
198
  if (!siteConfig.value) return;
178
199
 
179
- const { siteName, siteDescription, logoUrl } = siteConfig.value;
180
- const seo = siteConfig.value.customization?.content?.seo;
181
-
182
- if (import.meta.client) {
183
- if (siteName) {
184
- document.title = siteName;
185
- }
186
-
187
- // Update or create meta tags
188
- const updateMetaTag = (name: string, content: string) => {
189
- let meta = document.querySelector(`meta[name="${name}"]`);
190
- if (!meta) {
191
- meta = document.createElement('meta');
192
- meta.setAttribute('name', name);
193
- document.head.appendChild(meta);
194
- }
195
- meta.setAttribute('content', content);
196
- };
197
-
198
- if (siteDescription) {
199
- updateMetaTag('description', siteDescription);
200
- }
201
-
202
- if (seo?.metaTitle) {
203
- updateMetaTag('title', seo.metaTitle);
204
- }
205
-
206
- if (seo?.metaKeywords) {
207
- updateMetaTag('keywords', seo.metaKeywords);
208
- }
209
-
210
- // Open Graph tags
211
- if (seo?.ogTitle) {
212
- updateMetaTag('og:title', seo.ogTitle);
213
- }
214
- if (seo?.ogDescription) {
215
- updateMetaTag('og:description', seo.ogDescription);
216
- }
217
- if (seo?.ogImage || logoUrl) {
218
- updateMetaTag('og:image', seo?.ogImage || logoUrl);
219
- }
220
- }
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
+ });
221
225
  };
222
226
 
223
227
  // Computed properties