@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,115 +1,47 @@
1
1
  <template>
2
2
  <div class="ptb-60">
3
3
  <div class="container">
4
- <div class="table-responsive cart-table">
4
+ <div v-if="loading" class="text-center py-5 text-muted">{{ t('wishlist.loading') }}</div>
5
+
6
+ <div v-else-if="!items.length" class="text-center py-5">
7
+ <i class="ri-heart-line text-muted" style="font-size: 4rem"></i>
8
+ <h3 class="mt-3">{{ t('wishlist.empty') }}</h3>
9
+ <p class="text-muted">{{ t('wishlist.emptyText') }}</p>
10
+ <NuxtLink to="/products" class="btn btn-success text-white rounded-pill">{{ t('wishlist.toCatalog') }}</NuxtLink>
11
+ </div>
12
+
13
+ <div v-else class="table-responsive cart-table">
5
14
  <table class="table align-middle">
6
15
  <thead>
7
16
  <tr>
8
- <th scope="col">{{ t('product.product') }}</th>
17
+ <th scope="col">{{ t('wishlist.product') }}</th>
9
18
  <th scope="col">{{ t('product.name') }}</th>
10
19
  <th scope="col">{{ t('product.unitPrice') }}</th>
11
- <th scope="col">{{ t('product.quantity') }}</th>
12
- <th scope="col">{{ t('product.subtotal') }}</th>
13
20
  <th scope="col">{{ t('product.addToCart') }}</th>
14
21
  <th scope="col">{{ t('product.remove') }}</th>
15
22
  </tr>
16
23
  </thead>
17
24
  <tbody>
18
- <tr>
19
- <td>
20
- <NuxtLink to="/product-details">
21
- <img
22
- src="~/assets/images/product-6.png"
23
- class="cart-product"
24
- alt="product"
25
- />
26
- </NuxtLink>
27
- </td>
28
- <td>
29
- <NuxtLink to="/product-details" class="title">
30
- Multiplatform Wireless Noise-Cancelling Gaming Headset
31
- </NuxtLink>
32
- </td>
33
- <td>
34
- <span class="price">$79</span>
35
- </td>
36
- <td>
37
- <WishlistProductQuantity />
38
- </td>
39
- <td>
40
- <span class="price">$79</span>
41
- </td>
42
- <td>
43
- <button class="btn btn-warning text-white">{{ t('product.addToCart') }}</button>
44
- </td>
45
- <td>
46
- <button class="border-0 p-0 bg-transparent remove">
47
- <i class="ri-delete-bin-line"></i>
48
- </button>
49
- </td>
50
- </tr>
51
- <tr>
52
- <td>
53
- <NuxtLink to="/product-details">
54
- <img
55
- src="~/assets/images/product-7.png"
56
- class="cart-product"
57
- alt="product"
58
- />
59
- </NuxtLink>
60
- </td>
25
+ <tr v-for="item in items" :key="item.id">
61
26
  <td>
62
- <NuxtLink to="/product-details" class="title">
63
- GPS Smartwatch with Bright Touchscreen Display
27
+ <NuxtLink :to="productPath(item)">
28
+ <img v-if="imageOf(item)" :src="imageOf(item)" class="cart-product" :alt="item.title" />
64
29
  </NuxtLink>
65
30
  </td>
66
31
  <td>
67
- <span class="price">$119</span>
32
+ <NuxtLink :to="productPath(item)" class="title">{{ item.title }}</NuxtLink>
68
33
  </td>
69
34
  <td>
70
- <WishlistProductQuantity />
35
+ <span class="price">{{ formatPrice(getProductPrice(item)) }}</span>
36
+ <del v-if="getProductOldPrice(item)" class="ms-2 text-muted">{{ formatPrice(getProductOldPrice(item) as number) }}</del>
71
37
  </td>
72
38
  <td>
73
- <span class="price">$119</span>
74
- </td>
75
- <td>
76
- <button class="btn btn-warning text-white">{{ t('product.addToCart') }}</button>
77
- </td>
78
- <td>
79
- <button class="border-0 p-0 bg-transparent remove">
80
- <i class="ri-delete-bin-line"></i>
39
+ <button type="button" class="btn btn-warning text-white" :disabled="isOut(item)" @click="addItem(item)">
40
+ {{ isInCart(item.id) ? t('product.inCart') : t('product.addToCart') }}
81
41
  </button>
82
42
  </td>
83
- </tr>
84
- <tr>
85
- <td>
86
- <NuxtLink to="/product-details">
87
- <img
88
- src="~/assets/images/product-8.png"
89
- class="cart-product"
90
- alt="product"
91
- />
92
- </NuxtLink>
93
- </td>
94
43
  <td>
95
- <NuxtLink to="/product-details" class="title">
96
- SAMSUNG 32-Inch Class QLED 4K Q60C Series Quantum HDR
97
- </NuxtLink>
98
- </td>
99
- <td>
100
- <span class="price">$397</span>
101
- </td>
102
- <td>
103
- <WishlistProductQuantity />
104
- </td>
105
- <td>
106
- <span class="price">$397</span>
107
- </td>
108
- <td>
109
- <button class="btn btn-warning text-white">{{ t('product.addToCart') }}</button>
110
- </td>
111
- <td>
112
- <button class="border-0 p-0 bg-transparent remove">
44
+ <button type="button" class="border-0 p-0 bg-transparent remove" :aria-label="t('wishlist.removeItem')" @click="removeItem(item.id)">
113
45
  <i class="ri-delete-bin-line"></i>
114
46
  </button>
115
47
  </td>
@@ -122,5 +54,47 @@
122
54
  </template>
123
55
 
124
56
  <script setup lang="ts">
57
+ import { onMounted, ref } from 'vue';
58
+ import {
59
+ formatPrice,
60
+ getProductImage,
61
+ getProductOldPrice,
62
+ getProductPrice,
63
+ type Product,
64
+ } from '../../composables/useProducts';
65
+
125
66
  const { t } = useI18n();
67
+ const { addToCart, isInCart } = useCart();
68
+ const { ids, load, remove } = useWishlist();
69
+ const { fetchProductsByIds } = useProducts();
70
+
71
+ const items = ref<Product[]>([]);
72
+ const loading = ref(true);
73
+
74
+ const imageOf = (p: Product) => getProductImage(p);
75
+ const isOut = (p: Product) => typeof p.stock === 'number' && p.stock <= 0;
76
+
77
+ const addItem = (p: Product) => {
78
+ if (isOut(p)) return;
79
+ const old = getProductOldPrice(p);
80
+ addToCart({
81
+ productId: p.id,
82
+ name: p.title,
83
+ price: old || getProductPrice(p),
84
+ discountPrice: old ? getProductPrice(p) : undefined,
85
+ image: getProductImage(p),
86
+ unit: p.unit,
87
+ });
88
+ };
89
+
90
+ const removeItem = (id: string) => {
91
+ remove(id);
92
+ items.value = items.value.filter((p) => p.id !== id);
93
+ };
94
+
95
+ onMounted(async () => {
96
+ load();
97
+ items.value = ids.value.length ? await fetchProductsByIds(ids.value) : [];
98
+ loading.value = false;
99
+ });
126
100
  </script>
@@ -1,136 +1,181 @@
1
1
  import { ref, computed } from 'vue';
2
+ import { useRuntimeConfig } from '#imports';
2
3
 
3
- interface CartItem {
4
+ // Позиція кошика. Поля name/price/discountPrice/image/productId залишені
5
+ // для сумісності з компонентами шапки та карток товарів.
6
+ export interface CartItem {
4
7
  productId: string;
5
8
  name: string;
6
9
  price: number;
7
10
  discountPrice?: number;
8
11
  quantity: number;
9
12
  image?: string;
13
+ unit?: string;
14
+ minQty?: number;
10
15
  }
11
16
 
12
17
  interface Cart {
13
18
  items: CartItem[];
14
19
  updatedAt: number;
20
+ // Кошик належить або звичайному каталогу, або конкретному прайс-листу
21
+ priceListId?: string | null;
15
22
  }
16
23
 
17
24
  const CART_STORAGE_KEY = 'shopping_cart';
18
25
 
19
- const cart = ref<Cart>({
20
- items: [],
21
- updatedAt: Date.now(),
22
- });
26
+ const cart = ref<Cart>({ items: [], updatedAt: 0, priceListId: null });
27
+
28
+ // Формат «1 234 ₴» (нерозривний пробіл; вручну, щоб SSR і браузер збігались)
29
+ export const formatUah = (n: number): string => {
30
+ const num = Math.round((Number(n) || 0) * 100) / 100;
31
+ const [int, frac] = num.toFixed(2).split('.');
32
+ const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
33
+ return `${grouped}${frac !== '00' ? ',' + frac : ''} ₴`;
34
+ };
35
+
36
+ // Повний URL зображення з медіа-домену (відносні шляхи дописуємо mediaBase)
37
+ export const cartImageUrl = (url?: string | null): string => {
38
+ if (!url) return '';
39
+ if (/^(https?:|data:|blob:)/.test(url)) return url;
40
+ const base = String(useRuntimeConfig().public.mediaBase || '').replace(/\/$/, '');
41
+ const path = url.startsWith('/') ? url : '/' + url;
42
+ return base ? base + path : url;
43
+ };
44
+
45
+ // Приводить товар з API (prices[], images[], title) або старий формат до позиції кошика
46
+ const normalize = (src: Record<string, any>): Omit<CartItem, 'quantity'> => {
47
+ const prices: any[] = Array.isArray(src.prices) ? src.prices : [];
48
+ const main = prices.find((p) => !p.isWholesale) || prices[0];
49
+ const imgs: any[] = Array.isArray(src.images) ? src.images : [];
50
+ const mainImg = imgs.find((i) => i?.isMain) || imgs[0];
51
+ const imgUrl = src.image || (typeof mainImg === 'string' ? mainImg : mainImg?.url) || '';
52
+ return {
53
+ productId: String(src.productId ?? src.id),
54
+ name: src.name ?? src.title ?? '',
55
+ price: Number(src.price ?? main?.value ?? 0) || 0,
56
+ discountPrice: src.discountPrice ? Number(src.discountPrice) : undefined,
57
+ image: imgUrl,
58
+ unit: src.unit || undefined,
59
+ };
60
+ };
23
61
 
24
62
  export const useCart = () => {
25
- // Load cart from localStorage
26
63
  const loadCart = () => {
27
- if (process.client) {
28
- try {
29
- const stored = localStorage.getItem(CART_STORAGE_KEY);
30
- if (stored) {
31
- cart.value = JSON.parse(stored);
32
- }
33
- } catch (e) {
34
- console.error('Error loading cart:', e);
64
+ if (!import.meta.client) return;
65
+ try {
66
+ const stored = localStorage.getItem(CART_STORAGE_KEY);
67
+ if (!stored) return;
68
+ const parsed = JSON.parse(stored);
69
+ if (parsed && Array.isArray(parsed.items)) {
70
+ cart.value = {
71
+ items: parsed.items.filter((i: any) => i?.productId && i.quantity > 0),
72
+ updatedAt: parsed.updatedAt || Date.now(),
73
+ priceListId: parsed.priceListId || null,
74
+ };
35
75
  }
76
+ } catch (e) {
77
+ console.error('Помилка читання кошика:', e);
36
78
  }
37
79
  };
38
80
 
39
- // Save cart to localStorage
40
81
  const saveCart = () => {
41
- if (process.client) {
42
- try {
43
- cart.value.updatedAt = Date.now();
44
- localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(cart.value));
45
- } catch (e) {
46
- console.error('Error saving cart:', e);
47
- }
82
+ if (!import.meta.client) return;
83
+ try {
84
+ cart.value.updatedAt = Date.now();
85
+ localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(cart.value));
86
+ } catch (e) {
87
+ console.error('Помилка збереження кошика:', e);
48
88
  }
49
89
  };
50
90
 
51
- // Add item to cart
52
- const addToCart = (item: Omit<CartItem, 'quantity'>, quantity: number = 1) => {
53
- const existingItem = cart.value.items.find(
54
- i => i.productId === item.productId
55
- );
56
-
57
- if (existingItem) {
58
- existingItem.quantity += quantity;
91
+ // Додати товар (повний об'єкт з API або спрощений); priceListId — контекст прайс-листа
92
+ const addToCart = (
93
+ product: Record<string, any>,
94
+ quantity: number = 1,
95
+ priceListId: string | null = null,
96
+ minQty: number = 1,
97
+ ) => {
98
+ // Зміна контексту (прайс-лист ↔ звичайний) — починаємо новий кошик
99
+ if ((priceListId || null) !== (cart.value.priceListId || null)) {
100
+ cart.value.items = [];
101
+ cart.value.priceListId = priceListId || null;
102
+ }
103
+ const item = normalize(product);
104
+ const floor = Math.max(1, minQty || 1);
105
+ const existing = cart.value.items.find((i) => i.productId === item.productId);
106
+ if (existing) {
107
+ existing.quantity = Math.max(existing.quantity + quantity, floor);
108
+ existing.minQty = Math.max(existing.minQty || 1, floor);
59
109
  } else {
60
- cart.value.items.push({
61
- ...item,
62
- quantity,
63
- });
110
+ cart.value.items.push({ ...item, quantity: Math.max(quantity, floor), minQty: floor });
64
111
  }
65
-
66
112
  saveCart();
67
113
  };
68
114
 
69
- // Remove item from cart
70
115
  const removeFromCart = (productId: string) => {
71
- cart.value.items = cart.value.items.filter(
72
- i => i.productId !== productId
73
- );
116
+ cart.value.items = cart.value.items.filter((i) => i.productId !== productId);
74
117
  saveCart();
75
118
  };
76
119
 
77
- // Update item quantity
78
120
  const updateQuantity = (productId: string, quantity: number) => {
79
- const item = cart.value.items.find(i => i.productId === productId);
80
- if (item) {
81
- if (quantity <= 0) {
82
- removeFromCart(productId);
83
- } else {
84
- item.quantity = quantity;
85
- saveCart();
86
- }
87
- }
121
+ const item = cart.value.items.find((i) => i.productId === productId);
122
+ if (!item) return;
123
+ if (quantity <= 0) return removeFromCart(productId);
124
+ item.quantity = Math.max(item.minQty || 1, Math.floor(quantity));
125
+ saveCart();
88
126
  };
89
127
 
90
- // Clear cart
91
128
  const clearCart = () => {
92
129
  cart.value.items = [];
130
+ cart.value.priceListId = null;
93
131
  saveCart();
94
132
  };
95
133
 
96
- // Check if product is in cart
97
- const isInCart = (productId: string) => {
98
- return cart.value.items.some(i => i.productId === productId);
99
- };
100
-
101
- // Get item quantity
102
- const getItemQuantity = (productId: string) => {
103
- const item = cart.value.items.find(i => i.productId === productId);
104
- return item?.quantity || 0;
134
+ // Актуалізує ціни позицій за свіжими даними товарів (акція могла початись/закінчитись,
135
+ // поки товар лежав у кошику). price — ціна без знижки, discountPrice — акційна.
136
+ // Кошик прайс-листа не чіпаємо: там ціни з прайс-листа.
137
+ const syncPrices = (products: Record<string, any>[]) => {
138
+ if (cart.value.priceListId || !products.length) return;
139
+ const byId = new Map(products.map((p) => [String(p.id), p]));
140
+ let changed = false;
141
+ for (const item of cart.value.items) {
142
+ const p = byId.get(item.productId);
143
+ if (!p) continue;
144
+ const prices: any[] = Array.isArray(p.prices) ? p.prices : [];
145
+ const base = Number((prices.find((x) => !x.isWholesale) || prices[0])?.value) || 0;
146
+ const sale = p.salePrice !== null && p.salePrice !== undefined && p.salePrice !== '' ? Number(p.salePrice) : NaN;
147
+ const effective = Number.isFinite(sale) && sale >= 0 ? sale : base;
148
+ const old = Math.max(Number(p.oldPrice) || 0, base);
149
+ if (!effective) continue;
150
+ const price = old > effective ? old : effective;
151
+ const discountPrice = old > effective ? effective : undefined;
152
+ if (item.price !== price || item.discountPrice !== discountPrice) {
153
+ item.price = price;
154
+ item.discountPrice = discountPrice;
155
+ changed = true;
156
+ }
157
+ }
158
+ if (changed) saveCart();
105
159
  };
106
160
 
107
- // Computed properties
108
- const itemCount = computed(() =>
109
- cart.value.items.reduce((sum, item) => sum + item.quantity, 0)
110
- );
161
+ const isInCart = (productId: string) => cart.value.items.some((i) => i.productId === productId);
162
+ const getItemQuantity = (productId: string) =>
163
+ cart.value.items.find((i) => i.productId === productId)?.quantity || 0;
111
164
 
112
- const subtotal = computed(() =>
113
- cart.value.items.reduce((sum, item) => {
114
- const price = item.discountPrice || item.price;
115
- return sum + (price * item.quantity);
116
- }, 0)
117
- );
165
+ // Ціна одиниці з урахуванням знижки
166
+ const unitPrice = (item: CartItem) =>
167
+ item.discountPrice && item.discountPrice < item.price ? item.discountPrice : item.price;
118
168
 
169
+ const itemCount = computed(() => cart.value.items.reduce((s, i) => s + i.quantity, 0));
170
+ const subtotal = computed(() => cart.value.items.reduce((s, i) => s + unitPrice(i) * i.quantity, 0));
119
171
  const totalDiscount = computed(() =>
120
- cart.value.items.reduce((sum, item) => {
121
- if (item.discountPrice && item.discountPrice < item.price) {
122
- return sum + ((item.price - item.discountPrice) * item.quantity);
123
- }
124
- return sum;
125
- }, 0)
172
+ cart.value.items.reduce(
173
+ (s, i) => (i.discountPrice && i.discountPrice < i.price ? s + (i.price - i.discountPrice) * i.quantity : s),
174
+ 0,
175
+ ),
126
176
  );
127
-
128
177
  const isEmpty = computed(() => cart.value.items.length === 0);
129
-
130
- // Initialize cart on first use
131
- if (process.client && cart.value.items.length === 0) {
132
- loadCart();
133
- }
178
+ const priceListId = computed(() => cart.value.priceListId || null);
134
179
 
135
180
  return {
136
181
  cart,
@@ -141,9 +186,16 @@ export const useCart = () => {
141
186
  isInCart,
142
187
  getItemQuantity,
143
188
  loadCart,
189
+ syncPrices,
190
+ unitPrice,
144
191
  itemCount,
145
192
  subtotal,
146
193
  totalDiscount,
147
194
  isEmpty,
195
+ priceListId,
196
+ // Аліаси
197
+ updateQty: updateQuantity,
198
+ cartTotal: subtotal,
199
+ cartCount: itemCount,
148
200
  };
149
201
  };
@@ -3,30 +3,73 @@ import { useState } from '#imports';
3
3
  import { getApiBaseUrl } from '../utils/api';
4
4
  import { useSiteConfig } from './useSiteConfig';
5
5
 
6
- interface Category {
6
+ export interface Category {
7
7
  id: string;
8
8
  name: string;
9
9
  slug?: string;
10
10
  description?: string;
11
+ metaTitle?: string;
12
+ metaDescription?: string;
13
+ /** Відносний або повний URL картинки категорії */
11
14
  image?: string;
12
15
  parentId?: string;
13
- organizationId: string;
16
+ organizationId?: string;
14
17
  isActive: boolean;
15
18
  order?: number;
19
+ /** Кількість товарів, якщо API її віддає */
20
+ productsCount?: number;
21
+ subcategories?: Category[];
16
22
  }
17
23
 
24
+ /** Приводить сирий вузол дерева категорій API до єдиної форми (рекурсивно). */
25
+ const normalizeCategory = (raw: any, parentId?: string, index = 0): Category => {
26
+ const id = String(raw?.id ?? '');
27
+ const count = raw?.productsCount ?? raw?.productCount ?? raw?.count;
28
+ const children: any[] = Array.isArray(raw?.subcategories)
29
+ ? raw.subcategories
30
+ : Array.isArray(raw?.children)
31
+ ? raw.children
32
+ : [];
33
+ return {
34
+ id,
35
+ name: String(raw?.name ?? raw?.title ?? ''),
36
+ slug: raw?.slug,
37
+ description: raw?.description,
38
+ metaTitle: raw?.metaTitle || undefined,
39
+ metaDescription: raw?.metaDescription || undefined,
40
+ image: raw?.imageUrl || raw?.image || undefined,
41
+ parentId: raw?.parentId ?? parentId ?? undefined,
42
+ organizationId: raw?.organizationId,
43
+ isActive: raw?.isActive !== false,
44
+ order: typeof raw?.order === 'number' ? raw.order : index,
45
+ productsCount: count != null && !Number.isNaN(Number(count)) ? Number(count) : undefined,
46
+ subcategories: children.map((c, i) => normalizeCategory(c, id, i)),
47
+ };
48
+ };
49
+
50
+ /** Розгортає дерево у плоский список (батько, потім діти) — формат, який чекають навбар/фільтри. */
51
+ const flatten = (nodes: Category[]): Category[] =>
52
+ nodes.flatMap((n) => [n, ...flatten(n.subcategories || [])]);
53
+
54
+ /** Дістає масив категорій з відповіді: {categories|items|data} або голий масив. */
55
+ const extractList = (response: any): any[] => {
56
+ if (Array.isArray(response)) return response;
57
+ return response?.categories || response?.items || response?.data || [];
58
+ };
59
+
18
60
  export const useCategories = () => {
19
61
  // useState — SSR-safe, дані передаються клієнту через гідрацію
20
62
  const categories = useState<Category[]>('categories', () => []);
21
63
  const loading = useState<boolean>('categories-loading', () => false);
22
64
  const error = useState<string | null>('categories-error', () => null);
65
+ const loaded = useState<boolean>('categories-loaded', () => false);
23
66
 
24
67
  const { publicApiToken } = useSiteConfig();
25
68
 
26
- // Fetch all categories
27
- const fetchCategories = async () => {
28
- // Якщо вже завантажено — не перезавантажуємо
29
- if (categories.value.length > 0) return categories.value;
69
+ // Завантаження дерева категорій магазину
70
+ const fetchCategories = async (force = false) => {
71
+ // Вже завантажено — не перезавантажуємо
72
+ if (loaded.value && !force) return categories.value;
30
73
 
31
74
  loading.value = true;
32
75
  error.value = null;
@@ -37,15 +80,17 @@ export const useCategories = () => {
37
80
  throw new Error('Store token not available');
38
81
  }
39
82
 
40
- const response = await $fetch<{ categories: Category[] }>('/site-template/public/store/categories', {
83
+ const response = await $fetch<any>('/site-template/public/store/categories', {
41
84
  baseURL: getApiBaseUrl(),
42
85
  params: { token },
43
86
  });
44
87
 
45
- categories.value = response.categories;
46
- return response.categories;
88
+ const tree = extractList(response).map((c, i) => normalizeCategory(c, undefined, i));
89
+ categories.value = flatten(tree);
90
+ loaded.value = true;
91
+ return categories.value;
47
92
  } catch (e: any) {
48
- error.value = e.message || 'Failed to load categories';
93
+ error.value = 'Не вдалося завантажити категорії. Спробуйте оновити сторінку.';
49
94
  console.error('Error loading categories:', e);
50
95
  throw e;
51
96
  } finally {
@@ -53,31 +98,23 @@ export const useCategories = () => {
53
98
  }
54
99
  };
55
100
 
56
- // Get category by ID
57
- const getCategoryById = (categoryId: string) => {
58
- return categories.value.find(c => c.id === categoryId);
59
- };
101
+ const getCategoryById = (categoryId: string) =>
102
+ categories.value.find((c) => c.id === categoryId);
60
103
 
61
- // Get category by slug
62
- const getCategoryBySlug = (slug: string) => {
63
- return categories.value.find(c => c.slug === slug);
64
- };
104
+ const getCategoryBySlug = (slug: string) =>
105
+ categories.value.find((c) => c.slug === slug);
65
106
 
66
- // Computed properties
67
- const activeCategories = computed(() =>
68
- categories.value.filter(c => c.isActive)
69
- );
107
+ const activeCategories = computed(() => categories.value.filter((c) => c.isActive));
108
+
109
+ const sortByOrder = (list: Category[]) =>
110
+ [...list].sort((a, b) => (a.order || 0) - (b.order || 0));
70
111
 
71
112
  const topLevelCategories = computed(() =>
72
- activeCategories.value.filter(c => !c.parentId)
73
- .sort((a, b) => (a.order || 0) - (b.order || 0))
113
+ sortByOrder(activeCategories.value.filter((c) => !c.parentId)),
74
114
  );
75
115
 
76
- const getSubcategories = (parentId: string) => {
77
- return activeCategories.value
78
- .filter(c => c.parentId === parentId)
79
- .sort((a, b) => (a.order || 0) - (b.order || 0));
80
- };
116
+ const getSubcategories = (parentId: string) =>
117
+ sortByOrder(activeCategories.value.filter((c) => c.parentId === parentId));
81
118
 
82
119
  return {
83
120
  categories,
@@ -0,0 +1,45 @@
1
+ import { computed } from 'vue';
2
+ import { useState } from '#imports';
3
+ import { getProductImage, resolveMediaUrl, useProducts } from './useProducts';
4
+ import type { Category } from './useCategories';
5
+
6
+ /**
7
+ * Картинки категорій для вітрини.
8
+ * Якщо продавець не завантажив картинку категорії — беремо головне фото першого товару з неї.
9
+ * Результат кешується у useState, щоб головна і сторінка категорій не робили повторних запитів.
10
+ */
11
+ export const useCategoryImages = () => {
12
+ const { fetchByCategory } = useProducts();
13
+ const fallback = useState<Record<string, string>>('category-image-fallback', () => ({}));
14
+ const pending = new Set<string>();
15
+
16
+ const imageFor = (category: Category): string => {
17
+ if (category.image) return resolveMediaUrl(category.image);
18
+ return fallback.value[category.id] || '';
19
+ };
20
+
21
+ // Довантажити фолбеки для категорій без картинки (паралельно, без падіння при помилці)
22
+ const ensureFallbacks = async (categories: Category[]) => {
23
+ const missing = categories.filter((c) => !c.image && !fallback.value[c.id] && !pending.has(c.id));
24
+ if (!missing.length) return;
25
+ missing.forEach((c) => pending.add(c.id));
26
+ await Promise.all(
27
+ missing.map(async (c) => {
28
+ try {
29
+ const [product] = await fetchByCategory(c.id, 1);
30
+ const img = product ? getProductImage(product) : '';
31
+ if (img) fallback.value = { ...fallback.value, [c.id]: img };
32
+ } catch {
33
+ /* без картинки — компонент покаже іконку */
34
+ } finally {
35
+ pending.delete(c.id);
36
+ }
37
+ }),
38
+ );
39
+ };
40
+
41
+ const withImages = (categories: Category[]) =>
42
+ computed(() => categories.map((c) => ({ ...c, imageSrc: imageFor(c) })));
43
+
44
+ return { imageFor, ensureFallbacks, withImages };
45
+ };