@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,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
  };
@@ -1,33 +1,75 @@
1
1
  import { computed } from 'vue';
2
- import { useState, useRuntimeConfig } from '#imports';
2
+ 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
- const config = useRuntimeConfig();
25
67
  const { publicApiToken } = useSiteConfig();
26
68
 
27
- // Fetch all categories
28
- const fetchCategories = async () => {
29
- // Якщо вже завантажено — не перезавантажуємо
30
- if (categories.value.length > 0) return categories.value;
69
+ // Завантаження дерева категорій магазину
70
+ const fetchCategories = async (force = false) => {
71
+ // Вже завантажено — не перезавантажуємо
72
+ if (loaded.value && !force) return categories.value;
31
73
 
32
74
  loading.value = true;
33
75
  error.value = null;
@@ -38,15 +80,17 @@ export const useCategories = () => {
38
80
  throw new Error('Store token not available');
39
81
  }
40
82
 
41
- const response = await $fetch<{ categories: Category[] }>('/site-template/public/store/categories', {
83
+ const response = await $fetch<any>('/site-template/public/store/categories', {
42
84
  baseURL: getApiBaseUrl(),
43
85
  params: { token },
44
86
  });
45
87
 
46
- categories.value = response.categories;
47
- 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;
48
92
  } catch (e: any) {
49
- error.value = e.message || 'Failed to load categories';
93
+ error.value = 'Не вдалося завантажити категорії. Спробуйте оновити сторінку.';
50
94
  console.error('Error loading categories:', e);
51
95
  throw e;
52
96
  } finally {
@@ -54,31 +98,23 @@ export const useCategories = () => {
54
98
  }
55
99
  };
56
100
 
57
- // Get category by ID
58
- const getCategoryById = (categoryId: string) => {
59
- return categories.value.find(c => c.id === categoryId);
60
- };
101
+ const getCategoryById = (categoryId: string) =>
102
+ categories.value.find((c) => c.id === categoryId);
61
103
 
62
- // Get category by slug
63
- const getCategoryBySlug = (slug: string) => {
64
- return categories.value.find(c => c.slug === slug);
65
- };
104
+ const getCategoryBySlug = (slug: string) =>
105
+ categories.value.find((c) => c.slug === slug);
66
106
 
67
- // Computed properties
68
- const activeCategories = computed(() =>
69
- categories.value.filter(c => c.isActive)
70
- );
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));
71
111
 
72
112
  const topLevelCategories = computed(() =>
73
- activeCategories.value.filter(c => !c.parentId)
74
- .sort((a, b) => (a.order || 0) - (b.order || 0))
113
+ sortByOrder(activeCategories.value.filter((c) => !c.parentId)),
75
114
  );
76
115
 
77
- const getSubcategories = (parentId: string) => {
78
- return activeCategories.value
79
- .filter(c => c.parentId === parentId)
80
- .sort((a, b) => (a.order || 0) - (b.order || 0));
81
- };
116
+ const getSubcategories = (parentId: string) =>
117
+ sortByOrder(activeCategories.value.filter((c) => c.parentId === parentId));
82
118
 
83
119
  return {
84
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
+ };