@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.
- package/assets/scss/_variables.scss +3 -3
- package/assets/scss/components/_header.scss +1 -1
- package/assets/scss/style.css +4 -4
- package/components/AboutUs/StaticContent.vue +32 -0
- package/components/Cart/CartEmpty.vue +42 -0
- package/components/Cart/CartItem.vue +145 -0
- package/components/Cart/CartSummary.vue +57 -0
- package/components/Cart/ProductQuantity.vue +23 -13
- package/components/Cart/index.vue +68 -150
- package/components/Categories/index.vue +88 -291
- package/components/Checkout/AddressFields.vue +74 -0
- package/components/Checkout/CheckoutForm.vue +75 -0
- package/components/Checkout/CityAutocomplete.vue +68 -0
- package/components/Checkout/DeliveryMethodCard.vue +66 -0
- package/components/Checkout/DeliveryOptions.vue +148 -0
- package/components/Checkout/OrderSummary.vue +116 -0
- package/components/Checkout/PaymentOptions.vue +76 -0
- package/components/Checkout/PickupPoints.vue +46 -0
- package/components/Checkout/PromoCode.vue +166 -0
- package/components/Checkout/index.vue +235 -173
- package/components/Checkout/types.ts +45 -0
- package/components/Common/PageBanner.vue +22 -20
- package/components/Common/ProductCard.vue +139 -115
- package/components/Common/Services.vue +4 -4
- package/components/Contact/ContactForm.vue +44 -74
- package/components/Contact/ContactInfo.vue +35 -61
- package/components/FAQ/index.vue +6 -119
- package/components/Layout/Copyright.vue +11 -9
- package/components/Layout/Footer.vue +49 -136
- package/components/Layout/HeaderBar.vue +174 -0
- package/components/Layout/HeaderLogo.vue +27 -0
- package/components/Layout/MiddleHeader.vue +1 -228
- package/components/Layout/MiddleHeaderThree.vue +1 -203
- package/components/Layout/MiddleHeaderTwo.vue +1 -239
- package/components/Layout/NavLinks.vue +23 -0
- package/components/Layout/Navbar.vue +38 -151
- package/components/Layout/NavbarStyleFour.vue +1 -332
- package/components/Layout/NavbarStyleThree.vue +1 -188
- package/components/Layout/NavbarStyleTwo.vue +1 -108
- package/components/Layout/Preloader.vue +5 -12
- package/components/Layout/ResponsiveNavbar.vue +32 -418
- package/components/Layout/SitewideBanner.vue +285 -0
- package/components/Layout/TopHeader.vue +17 -83
- package/components/Layout/layoutData.ts +83 -0
- package/components/PrivacyPolicy/index.vue +6 -110
- package/components/ProductDetails/ProductDetailsTab.vue +31 -321
- package/components/ProductDetails/ProductGallery.vue +45 -0
- package/components/ProductDetails/ProductQuantity.vue +8 -14
- package/components/ProductDetails/RecentlyViewed.vue +53 -289
- package/components/ProductDetails/RelatedProducts.vue +64 -0
- package/components/ProductDetails/index.vue +90 -230
- package/components/Products/index.vue +78 -16
- package/components/TermsConditions/index.vue +6 -110
- package/components/Wishlist/index.vue +62 -88
- package/composables/useCart.ts +133 -81
- package/composables/useCategories.ts +67 -31
- package/composables/useCategoryImages.ts +45 -0
- package/composables/useCheckout.ts +302 -97
- package/composables/useContactForm.ts +57 -0
- package/composables/useHomeContent.ts +220 -0
- package/composables/useI18n.ts +12 -24
- package/composables/useJsonLd.ts +162 -0
- package/composables/useProducts.ts +244 -83
- package/composables/useRecent.ts +30 -0
- package/composables/useSanitize.ts +76 -0
- package/composables/useSiteConfig.ts +51 -47
- package/composables/useStaticPages.ts +48 -0
- package/composables/useStoreOrigin.ts +32 -0
- package/composables/useTemplateSections.ts +51 -3
- package/composables/useThemeVars.ts +113 -0
- package/composables/useWishlist.ts +72 -0
- package/layouts/default.vue +80 -19
- package/locales/en.json +255 -86
- package/locales/uk.json +251 -82
- package/modules/template-schema.ts +35 -0
- package/nuxt.config.ts +8 -11
- package/package.json +1 -1
- package/pages/about-us.vue +9 -9
- package/pages/cart.vue +0 -1
- package/pages/categories.vue +0 -1
- package/pages/category/[slug].vue +84 -0
- package/pages/checkout.vue +0 -1
- package/pages/contact.vue +6 -6
- package/pages/delivery.vue +12 -0
- package/pages/faq.vue +6 -6
- package/pages/order-success.vue +113 -0
- package/pages/privacy-policy.vue +6 -6
- package/pages/product/[slug].vue +82 -0
- package/pages/product-details.vue +14 -6
- package/pages/products.vue +12 -1
- package/pages/returns.vue +12 -0
- package/pages/terms-conditions.vue +6 -6
- package/pages/wishlist.vue +2 -2
- package/utils/image.ts +0 -1
- package/utils/seo-urls.ts +70 -0
- package/utils/store-origin.ts +36 -0
- package/components/AboutUs/AboutUsTuan.vue +0 -25
- package/components/AboutUs/Statistics.vue +0 -42
- package/components/AboutUs/SubscribeToTheNewsletter.vue +0 -57
- package/components/AddAddress/index.vue +0 -68
- package/components/BestSellers/Products.vue +0 -1562
- package/components/BestSellers/RecentlyViewed.vue +0 -304
- package/components/ChangePassword/index.vue +0 -69
- package/components/Common/DashboardNavigation.vue +0 -35
- package/components/Dashboard/RecentOrder.vue +0 -173
- package/components/Dashboard/index.vue +0 -77
- package/components/EditAddress/index.vue +0 -117
- package/components/EditProfile/index.vue +0 -95
- package/components/FeaturedProduct/FeaturedProducts.vue +0 -304
- package/components/FeaturedProduct/Products.vue +0 -1562
- package/components/ForgotPassword/index.vue +0 -49
- package/components/Layout/FooterTwo.vue +0 -159
- package/components/Layout/LocationOption.vue +0 -178
- package/components/Layout/RTLSwitchBtn.vue +0 -40
- package/components/Login/index.vue +0 -89
- package/components/MyAccount/index.vue +0 -154
- package/components/NewArrivals/Products.vue +0 -1969
- package/components/NewArrivals/RecentlyViewed.vue +0 -304
- package/components/OrderDetails/index.vue +0 -75
- package/components/OrderHistory/index.vue +0 -195
- package/components/Products/RecentlyViewed.vue +0 -302
- package/components/Register/index.vue +0 -83
- package/components/TrendingProducts/FeaturedProducts.vue +0 -304
- package/components/TrendingProducts/Products.vue +0 -1564
- package/components/Wishlist/ProductQuantity.vue +0 -29
- package/layouts/inner.vue +0 -70
- package/locales/ar.json +0 -234
- package/locales/ru.json +0 -234
- package/pages/address.vue +0 -11
- package/pages/change-password.vue +0 -11
- package/pages/dashboard.vue +0 -11
- package/pages/edit-address.vue +0 -11
- package/pages/edit-profile.vue +0 -11
- package/pages/forgot-password.vue +0 -11
- package/pages/login.vue +0 -11
- package/pages/my-account.vue +0 -11
- package/pages/order-details.vue +0 -11
- package/pages/order-history.vue +0 -11
- package/pages/register.vue +0 -11
|
@@ -1,115 +1,47 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="ptb-60">
|
|
3
3
|
<div class="container">
|
|
4
|
-
<div class="
|
|
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('
|
|
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="
|
|
63
|
-
|
|
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
|
-
<
|
|
32
|
+
<NuxtLink :to="productPath(item)" class="title">{{ item.title }}</NuxtLink>
|
|
68
33
|
</td>
|
|
69
34
|
<td>
|
|
70
|
-
<
|
|
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
|
-
<
|
|
74
|
-
|
|
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
|
-
<
|
|
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>
|
package/composables/useCart.ts
CHANGED
|
@@ -1,136 +1,181 @@
|
|
|
1
1
|
import { ref, computed } from 'vue';
|
|
2
|
+
import { useRuntimeConfig } from '#imports';
|
|
2
3
|
|
|
3
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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 (
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
//
|
|
52
|
-
const addToCart = (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const item
|
|
104
|
-
|
|
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
|
-
|
|
108
|
-
const
|
|
109
|
-
cart.value.items.
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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(
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
28
|
-
const fetchCategories = async () => {
|
|
29
|
-
//
|
|
30
|
-
if (
|
|
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<
|
|
83
|
+
const response = await $fetch<any>('/site-template/public/store/categories', {
|
|
42
84
|
baseURL: getApiBaseUrl(),
|
|
43
85
|
params: { token },
|
|
44
86
|
});
|
|
45
87
|
|
|
46
|
-
|
|
47
|
-
|
|
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 =
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
+
};
|