@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.
- 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 +66 -29
- package/composables/useCategoryImages.ts +45 -0
- package/composables/useCheckout.ts +302 -95
- 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 -81
- package/composables/useRecent.ts +30 -0
- package/composables/useSanitize.ts +76 -0
- package/composables/useSiteConfig.ts +51 -45
- 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/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
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<label class="dm-card" :class="{ active: selected }" :for="id">
|
|
3
|
+
<input :id="id" class="dm-radio" type="radio" :checked="selected" @change="$emit('select')" />
|
|
4
|
+
<span class="dm-icon"><i :class="icon"></i></span>
|
|
5
|
+
<span class="dm-body">
|
|
6
|
+
<span class="dm-title">{{ title }}</span>
|
|
7
|
+
<span v-if="time" class="dm-time">{{ time }}</span>
|
|
8
|
+
</span>
|
|
9
|
+
<span class="dm-price" :class="{ free: !price }">{{ price ? formatUah(price) : t('checkout.free') }}</span>
|
|
10
|
+
</label>
|
|
11
|
+
</template>
|
|
12
|
+
|
|
13
|
+
<script setup lang="ts">
|
|
14
|
+
// Картка способу доставки (вся картка клікабельна)
|
|
15
|
+
const props = defineProps<{ id: string; provider: string; title: string; time?: string; price?: number; selected: boolean }>();
|
|
16
|
+
defineEmits<{ select: [] }>();
|
|
17
|
+
const { t } = useI18n();
|
|
18
|
+
|
|
19
|
+
const ICONS: Record<string, string> = {
|
|
20
|
+
nova_poshta: 'ri-send-plane-line',
|
|
21
|
+
ukrposhta: 'ri-mail-send-line',
|
|
22
|
+
meest: 'ri-map-pin-line',
|
|
23
|
+
courier: 'ri-truck-line',
|
|
24
|
+
pickup: 'ri-store-2-line',
|
|
25
|
+
};
|
|
26
|
+
const icon = computed(() => ICONS[props.provider] || 'ri-truck-line');
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<style scoped lang="scss">
|
|
30
|
+
.dm-card {
|
|
31
|
+
position: relative;
|
|
32
|
+
display: flex;
|
|
33
|
+
align-items: center;
|
|
34
|
+
gap: 12px;
|
|
35
|
+
height: 100%;
|
|
36
|
+
margin: 0;
|
|
37
|
+
padding: 14px 16px;
|
|
38
|
+
border: 2px solid #e7e7e7;
|
|
39
|
+
border-radius: 10px;
|
|
40
|
+
background: #fff;
|
|
41
|
+
cursor: pointer;
|
|
42
|
+
transition: border-color 0.15s, box-shadow 0.15s;
|
|
43
|
+
&:hover { border-color: var(--secondary-color); }
|
|
44
|
+
&.active {
|
|
45
|
+
border-color: var(--primary-color);
|
|
46
|
+
box-shadow: 0 0 0 3px rgba(74, 141, 22, 0.12);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
.dm-radio { position: absolute; opacity: 0; pointer-events: none; }
|
|
50
|
+
.dm-icon {
|
|
51
|
+
flex: 0 0 40px;
|
|
52
|
+
height: 40px;
|
|
53
|
+
display: flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
justify-content: center;
|
|
56
|
+
border-radius: 50%;
|
|
57
|
+
font-size: 20px;
|
|
58
|
+
color: var(--primary-color);
|
|
59
|
+
background: rgba(117, 179, 68, 0.15);
|
|
60
|
+
}
|
|
61
|
+
.active .dm-icon { color: #fff; background: var(--primary-color); }
|
|
62
|
+
.dm-body { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
|
63
|
+
.dm-title { font-weight: 600; line-height: 1.2; }
|
|
64
|
+
.dm-time { font-size: 13px; color: #777; }
|
|
65
|
+
.dm-price { font-weight: 600; white-space: nowrap; &.free { color: var(--primary-color); } }
|
|
66
|
+
</style>
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="delivery-options checkout-card" data-field="delivery">
|
|
3
|
+
<h3>{{ t('checkout.deliveryTitle') }}</h3>
|
|
4
|
+
|
|
5
|
+
<div class="row g-2">
|
|
6
|
+
<div v-for="m in methods" :key="methodKey(m)" class="col-12 col-md-6">
|
|
7
|
+
<CheckoutDeliveryMethodCard
|
|
8
|
+
:id="`dl-${methodKey(m)}`"
|
|
9
|
+
:provider="methodKey(m)"
|
|
10
|
+
:title="m.label || providerLabel(methodKey(m))"
|
|
11
|
+
:time="m.time"
|
|
12
|
+
:price="methodPrice(m)"
|
|
13
|
+
:selected="model.provider === methodKey(m)"
|
|
14
|
+
@select="model.provider = methodKey(m)"
|
|
15
|
+
/>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
<!-- Поки опції не завантажені (SSR/перший рендер) — не лякаємо «не налаштовано» -->
|
|
19
|
+
<p v-if="!options" class="text-muted mt-2 mb-0"><span class="spinner-border spinner-border-sm me-2" role="status"></span>{{ t('checkout.loadingOptions') }}</p>
|
|
20
|
+
<p v-else-if="!methods.length" class="text-muted mt-2 mb-0">{{ t('checkout.noDelivery') }}</p>
|
|
21
|
+
<div v-if="errors.delivery" class="invalid-feedback d-block">{{ errors.delivery }}</div>
|
|
22
|
+
|
|
23
|
+
<!-- Панель полів вибраного способу -->
|
|
24
|
+
<div v-if="model.provider" class="dl-panel mt-3">
|
|
25
|
+
<!-- Нова Пошта: місто -> відділення -->
|
|
26
|
+
<template v-if="isWarehouse">
|
|
27
|
+
<div class="form-group mb-3" data-field="city">
|
|
28
|
+
<label class="label">{{ t('checkout.city') }} *</label>
|
|
29
|
+
<CheckoutCityAutocomplete
|
|
30
|
+
v-model="model.cityQuery"
|
|
31
|
+
:items="cityItems"
|
|
32
|
+
:loading="searchingCities"
|
|
33
|
+
:error="citiesError"
|
|
34
|
+
:invalid="!!errors.city"
|
|
35
|
+
:show-empty="cityQueryReady"
|
|
36
|
+
:placeholder="t('checkout.cityPlaceholder')"
|
|
37
|
+
@input="onCityInput"
|
|
38
|
+
@pick="pickCity"
|
|
39
|
+
/>
|
|
40
|
+
<div v-if="errors.city" class="invalid-feedback d-block">{{ errors.city }}</div>
|
|
41
|
+
</div>
|
|
42
|
+
<div class="form-group mb-0" data-field="warehouse">
|
|
43
|
+
<label class="label">{{ t('checkout.warehouse') }} *</label>
|
|
44
|
+
<CheckoutCityAutocomplete
|
|
45
|
+
v-model="model.warehouseQuery"
|
|
46
|
+
:items="warehouseItems"
|
|
47
|
+
:loading="loadingWarehouses"
|
|
48
|
+
:invalid="!!errors.warehouse"
|
|
49
|
+
:show-empty="!!model.city"
|
|
50
|
+
:placeholder="model.city ? t('checkout.warehousePlaceholder') : t('checkout.pickCityFirst')"
|
|
51
|
+
@input="model.warehouse = null"
|
|
52
|
+
@pick="pickWarehouse"
|
|
53
|
+
/>
|
|
54
|
+
<div v-if="errors.warehouse" class="invalid-feedback d-block">{{ errors.warehouse }}</div>
|
|
55
|
+
</div>
|
|
56
|
+
</template>
|
|
57
|
+
|
|
58
|
+
<!-- Самовивіз -->
|
|
59
|
+
<div v-else-if="model.provider === 'pickup'" data-field="pickup">
|
|
60
|
+
<CheckoutPickupPoints v-model="model.pickupPoint" :points="pickupPoints" :error="errors.pickup" />
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<!-- Кур'єр / Укрпошта / інше -->
|
|
64
|
+
<template v-else>
|
|
65
|
+
<CheckoutAddressFields v-model="model" :errors="errors" :postcode="needsPostcode(model.provider)" :street-mode="model.provider === 'courier'" />
|
|
66
|
+
</template>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</template>
|
|
70
|
+
|
|
71
|
+
<script setup lang="ts">
|
|
72
|
+
import { computed, watch } from 'vue';
|
|
73
|
+
import type { DeliveryState, FieldErrors } from './types';
|
|
74
|
+
|
|
75
|
+
// Спосіб доставки (картки) + панель полів саме для нього
|
|
76
|
+
defineProps<{ errors: FieldErrors }>();
|
|
77
|
+
const model = defineModel<DeliveryState>({ required: true });
|
|
78
|
+
const { t } = useI18n();
|
|
79
|
+
const {
|
|
80
|
+
options, providerLabel, hasWarehouseFlow, needsPostcode,
|
|
81
|
+
cities, searchCities, searchingCities, citiesError, warehouses, loadWarehouses, loadingWarehouses,
|
|
82
|
+
} = useCheckout();
|
|
83
|
+
|
|
84
|
+
const methods = computed(() => options.value?.delivery || []);
|
|
85
|
+
const methodKey = (m: any): string => m.value || m.provider;
|
|
86
|
+
// Для кур'єра ціна — courierDeliveryPrice, якщо задана
|
|
87
|
+
const methodPrice = (m: any): number =>
|
|
88
|
+
methodKey(m) === 'courier' && m.courierDeliveryPrice != null ? m.courierDeliveryPrice : m.price || 0;
|
|
89
|
+
const isWarehouse = computed(() => hasWarehouseFlow(model.value.provider));
|
|
90
|
+
const pickupPoints = computed(
|
|
91
|
+
() => (methods.value.find((m) => methodKey(m) === 'pickup')?.points as any[]) || [],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const cityItems = computed(() =>
|
|
95
|
+
(cities.value || []).map((c: any) => ({ key: c.ref, title: c.name, sub: c.region, raw: c })),
|
|
96
|
+
);
|
|
97
|
+
const cityQueryReady = computed(
|
|
98
|
+
() => !model.value.city && model.value.cityQuery.trim().length >= 2 && !searchingCities.value && !citiesError.value,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// Відділень може бути тисячі — фільтруємо на клієнті, показуємо до 60
|
|
102
|
+
const warehouseItems = computed(() => {
|
|
103
|
+
const q = model.value.warehouseQuery.trim().toLowerCase();
|
|
104
|
+
const list = warehouses.value || [];
|
|
105
|
+
const res = q && !model.value.warehouse ? list.filter((w: any) => (w.description || '').toLowerCase().includes(q)) : list;
|
|
106
|
+
return res.slice(0, 60).map((w: any) => ({ key: w.ref, title: w.description, raw: w }));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Зміна способу — скидаємо залежні поля
|
|
110
|
+
watch(
|
|
111
|
+
() => model.value.provider,
|
|
112
|
+
() => {
|
|
113
|
+
const m = model.value;
|
|
114
|
+
m.cityQuery = ''; m.city = null; m.warehouseQuery = ''; m.warehouse = null;
|
|
115
|
+
m.pickupPoint = null; cities.value = []; warehouses.value = [];
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
120
|
+
const onCityInput = () => {
|
|
121
|
+
const m = model.value;
|
|
122
|
+
m.city = null; m.warehouse = null; m.warehouseQuery = ''; warehouses.value = [];
|
|
123
|
+
if (timer) clearTimeout(timer);
|
|
124
|
+
timer = setTimeout(() => searchCities(m.provider, m.cityQuery), 350);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const pickCity = async (c: any) => {
|
|
128
|
+
const m = model.value;
|
|
129
|
+
m.city = { ref: c.ref, name: c.name };
|
|
130
|
+
m.cityQuery = c.name + (c.region ? `, ${c.region}` : '');
|
|
131
|
+
m.warehouse = null; m.warehouseQuery = '';
|
|
132
|
+
await loadWarehouses(m.provider, c.ref);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const pickWarehouse = (w: any) => {
|
|
136
|
+
model.value.warehouse = { ref: w.ref, description: w.description };
|
|
137
|
+
model.value.warehouseQuery = w.description;
|
|
138
|
+
};
|
|
139
|
+
</script>
|
|
140
|
+
|
|
141
|
+
<style scoped lang="scss">
|
|
142
|
+
.dl-panel {
|
|
143
|
+
padding: 16px;
|
|
144
|
+
border-radius: 10px;
|
|
145
|
+
background: #f8faf5;
|
|
146
|
+
border: 1px solid rgba(117, 179, 68, 0.3);
|
|
147
|
+
}
|
|
148
|
+
</style>
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<aside class="order-summary">
|
|
3
|
+
<h3>{{ t('checkout.yourOrder') }}</h3>
|
|
4
|
+
<ul class="os-items list-unstyled">
|
|
5
|
+
<li v-for="item in cart.items" :key="item.productId" class="os-item">
|
|
6
|
+
<div class="os-thumb">
|
|
7
|
+
<img v-if="item.image" :src="item.image" :alt="item.name" loading="lazy" />
|
|
8
|
+
<i v-else class="ri-image-line"></i>
|
|
9
|
+
<span class="os-qty">{{ item.quantity }}</span>
|
|
10
|
+
</div>
|
|
11
|
+
<div class="os-name">
|
|
12
|
+
<span>{{ item.name }}</span>
|
|
13
|
+
<small class="text-muted d-block">
|
|
14
|
+
{{ formatUah(unitPrice(item)) }}
|
|
15
|
+
<del v-if="unitPrice(item) < item.price" class="ms-1">{{ formatUah(item.price) }}</del>
|
|
16
|
+
× {{ item.quantity }}{{ item.unit ? ' ' + item.unit : '' }}
|
|
17
|
+
</small>
|
|
18
|
+
</div>
|
|
19
|
+
<div class="os-sum">{{ formatUah(unitPrice(item) * item.quantity) }}</div>
|
|
20
|
+
</li>
|
|
21
|
+
</ul>
|
|
22
|
+
|
|
23
|
+
<CheckoutPromoCode :buyer="buyer" />
|
|
24
|
+
|
|
25
|
+
<dl class="os-totals">
|
|
26
|
+
<div class="os-row"><dt>{{ t('checkout.sumGoods') }}</dt><dd>{{ formatUah(subtotal + totalDiscount) }}</dd></div>
|
|
27
|
+
<div v-if="totalDiscount > 0" class="os-row os-discount">
|
|
28
|
+
<dt>{{ t('checkout.itemsDiscount') }}</dt><dd>−{{ formatUah(totalDiscount) }}</dd>
|
|
29
|
+
</div>
|
|
30
|
+
<div v-if="promoDiscount > 0" class="os-row os-discount">
|
|
31
|
+
<dt>{{ t('checkout.promoApplied', { code: promo!.code }) }}</dt><dd>−{{ formatUah(promoDiscount) }}</dd>
|
|
32
|
+
</div>
|
|
33
|
+
<div class="os-row">
|
|
34
|
+
<dt>{{ t('checkout.sumDelivery') }}</dt>
|
|
35
|
+
<dd v-if="freeShipping" class="os-discount">{{ t('checkout.freeShipping') }}</dd>
|
|
36
|
+
<dd v-else-if="deliveryCost != null">{{ deliveryCost ? formatUah(deliveryCost) : t('checkout.free') }}</dd>
|
|
37
|
+
<dd v-else class="text-muted small">{{ delivery?.provider ? t('checkout.byCarrierTariffs') : '—' }}</dd>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="os-row os-total"><dt>{{ t('checkout.sumTotal') }}</dt><dd>{{ formatUah(total) }}</dd></div>
|
|
40
|
+
</dl>
|
|
41
|
+
<slot />
|
|
42
|
+
</aside>
|
|
43
|
+
</template>
|
|
44
|
+
|
|
45
|
+
<script setup lang="ts">
|
|
46
|
+
import { computed } from 'vue';
|
|
47
|
+
import type { DeliveryState } from './types';
|
|
48
|
+
import type { PromoBuyer } from '../../composables/useCheckout';
|
|
49
|
+
|
|
50
|
+
// Підсумок: товари, знижки (акції + промокод), доставка (відома лише для кур'єра/фіксованої ціни), разом; слот — помилки та кнопка
|
|
51
|
+
const props = defineProps<{ delivery?: DeliveryState; buyer?: PromoBuyer }>();
|
|
52
|
+
const { t } = useI18n();
|
|
53
|
+
const { cart, subtotal, totalDiscount, unitPrice } = useCart();
|
|
54
|
+
const { options, promo } = useCheckout();
|
|
55
|
+
|
|
56
|
+
// Промокод: знижка не більша за суму товарів; freeShipping обнуляє доставку
|
|
57
|
+
const promoDiscount = computed(() => Math.min(promo.value?.discountAmount || 0, subtotal.value));
|
|
58
|
+
const freeShipping = computed(() => !!promo.value?.freeShipping);
|
|
59
|
+
|
|
60
|
+
const deliveryCost = computed<number | null>(() => {
|
|
61
|
+
const p = props.delivery?.provider;
|
|
62
|
+
if (!p) return null;
|
|
63
|
+
const m: any = options.value?.delivery.find((d: any) => (d.value || d.provider) === p);
|
|
64
|
+
if (!m) return null;
|
|
65
|
+
if (p === 'pickup') return 0;
|
|
66
|
+
if (p === 'courier') return m.courierDeliveryPrice ?? m.price ?? 0;
|
|
67
|
+
return null; // НП/Укрпошта/Meest — за тарифами перевізника
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const total = computed(() =>
|
|
71
|
+
Math.max(0, subtotal.value - promoDiscount.value) + (freeShipping.value ? 0 : deliveryCost.value || 0),
|
|
72
|
+
);
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<style scoped lang="scss">
|
|
76
|
+
.order-summary {
|
|
77
|
+
padding: 24px;
|
|
78
|
+
border: 1px solid #e7e7e7;
|
|
79
|
+
border-radius: 12px;
|
|
80
|
+
background: #fff;
|
|
81
|
+
}
|
|
82
|
+
.os-items { max-height: 340px; overflow: auto; margin-bottom: 16px; }
|
|
83
|
+
.os-item { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid #f0f0f0; }
|
|
84
|
+
.os-thumb {
|
|
85
|
+
position: relative;
|
|
86
|
+
flex: 0 0 56px;
|
|
87
|
+
height: 56px;
|
|
88
|
+
border: 1px solid #eee;
|
|
89
|
+
border-radius: 8px;
|
|
90
|
+
display: flex;
|
|
91
|
+
align-items: center;
|
|
92
|
+
justify-content: center;
|
|
93
|
+
color: #bbb;
|
|
94
|
+
img { width: 100%; height: 100%; object-fit: cover; border-radius: 7px; }
|
|
95
|
+
}
|
|
96
|
+
.os-qty {
|
|
97
|
+
position: absolute;
|
|
98
|
+
top: -8px;
|
|
99
|
+
right: -8px;
|
|
100
|
+
min-width: 20px;
|
|
101
|
+
height: 20px;
|
|
102
|
+
padding: 0 5px;
|
|
103
|
+
border-radius: 10px;
|
|
104
|
+
background: var(--primary-color);
|
|
105
|
+
color: #fff;
|
|
106
|
+
font-size: 12px;
|
|
107
|
+
line-height: 20px;
|
|
108
|
+
text-align: center;
|
|
109
|
+
}
|
|
110
|
+
.os-name { flex: 1; min-width: 0; line-height: 1.3; }
|
|
111
|
+
.os-sum { font-weight: 600; white-space: nowrap; }
|
|
112
|
+
.os-totals { margin: 0 0 20px; }
|
|
113
|
+
.os-row { display: flex; justify-content: space-between; padding: 6px 0; dt, dd { margin: 0; font-weight: 400; } }
|
|
114
|
+
.os-discount, .os-discount dd { color: var(--primary-color); }
|
|
115
|
+
.os-total { margin-top: 6px; padding-top: 12px; border-top: 2px solid #eee; font-size: 20px; dt, dd { font-weight: 700; } dd { color: var(--primary-color); } }
|
|
116
|
+
</style>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="payment-options checkout-card">
|
|
3
|
+
<h3>{{ t('checkout.paymentOption') }}</h3>
|
|
4
|
+
<div class="row g-2">
|
|
5
|
+
<div v-if="!options" class="col-12 text-muted"><span class="spinner-border spinner-border-sm me-2" role="status"></span>{{ t('checkout.loadingOptions') }}</div>
|
|
6
|
+
<div v-if="options && options.payment?.cashOnDelivery !== false" class="col-12 col-md-6">
|
|
7
|
+
<label class="pay-card" :class="{ active: payment === 'cod' }" for="pay-cod">
|
|
8
|
+
<input id="pay-cod" v-model="payment" class="pay-radio" type="radio" value="cod" />
|
|
9
|
+
<i class="ri-wallet-3-line pay-ico"></i>
|
|
10
|
+
<span><b class="d-block">{{ t('checkout.cashOnDelivery') }}</b><small class="text-muted">{{ t('checkout.codDesc') }}</small></span>
|
|
11
|
+
</label>
|
|
12
|
+
</div>
|
|
13
|
+
<div v-if="options?.payment?.online" class="col-12 col-md-6">
|
|
14
|
+
<label class="pay-card" :class="{ active: payment === 'online' }" for="pay-online">
|
|
15
|
+
<input id="pay-online" v-model="payment" class="pay-radio" type="radio" value="online" />
|
|
16
|
+
<i class="ri-bank-card-line pay-ico"></i>
|
|
17
|
+
<span>
|
|
18
|
+
<b class="d-block">{{ t('checkout.onlinePayment') }}</b>
|
|
19
|
+
<small class="text-muted">{{ providerNames || t('checkout.onlineDesc') }}</small>
|
|
20
|
+
</span>
|
|
21
|
+
</label>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<!-- Вибір платіжного провайдера, якщо увімкнено кілька -->
|
|
26
|
+
<div v-if="payment === 'online' && providers.length > 1" class="mt-3">
|
|
27
|
+
<div class="label mb-2">{{ t('checkout.chooseProvider') }}</div>
|
|
28
|
+
<div class="d-flex flex-wrap gap-2">
|
|
29
|
+
<label v-for="pp in providers" :key="pp.provider" class="prov-chip" :class="{ active: provider === pp.provider }" :for="`pp-${pp.provider}`">
|
|
30
|
+
<input :id="`pp-${pp.provider}`" v-model="provider" class="pay-radio" type="radio" :value="pp.provider" />
|
|
31
|
+
{{ pp.label }}
|
|
32
|
+
</label>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</template>
|
|
37
|
+
|
|
38
|
+
<script setup lang="ts">
|
|
39
|
+
import { computed } from 'vue';
|
|
40
|
+
|
|
41
|
+
const payment = defineModel<'cod' | 'online'>('payment', { required: true });
|
|
42
|
+
const provider = defineModel<string>('provider', { default: '' });
|
|
43
|
+
const { t } = useI18n();
|
|
44
|
+
const { options } = useCheckout();
|
|
45
|
+
|
|
46
|
+
const providers = computed(() => options.value?.payment?.onlineProviders || []);
|
|
47
|
+
const providerNames = computed(() => providers.value.map((p) => p.label).join(', '));
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<style scoped lang="scss">
|
|
51
|
+
.pay-card {
|
|
52
|
+
display: flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
gap: 12px;
|
|
55
|
+
height: 100%;
|
|
56
|
+
margin: 0;
|
|
57
|
+
padding: 14px 16px;
|
|
58
|
+
border: 2px solid #e7e7e7;
|
|
59
|
+
border-radius: 10px;
|
|
60
|
+
background: #fff;
|
|
61
|
+
cursor: pointer;
|
|
62
|
+
transition: border-color 0.15s, box-shadow 0.15s;
|
|
63
|
+
&:hover { border-color: var(--secondary-color); }
|
|
64
|
+
&.active { border-color: var(--primary-color); box-shadow: 0 0 0 3px rgba(74, 141, 22, 0.12); }
|
|
65
|
+
}
|
|
66
|
+
.pay-radio { position: absolute; opacity: 0; pointer-events: none; }
|
|
67
|
+
.pay-ico { font-size: 26px; color: var(--primary-color); }
|
|
68
|
+
.prov-chip {
|
|
69
|
+
margin: 0;
|
|
70
|
+
padding: 6px 16px;
|
|
71
|
+
border: 2px solid #e7e7e7;
|
|
72
|
+
border-radius: 20px;
|
|
73
|
+
cursor: pointer;
|
|
74
|
+
&.active { border-color: var(--primary-color); color: var(--primary-color); font-weight: 600; }
|
|
75
|
+
}
|
|
76
|
+
</style>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<div v-if="points.length" class="row g-2">
|
|
4
|
+
<div v-for="(p, i) in points" :key="i" class="col-12">
|
|
5
|
+
<label class="pp-card" :class="{ active: model === p }" :for="`pp-${i}`">
|
|
6
|
+
<input :id="`pp-${i}`" v-model="model" class="pp-radio" type="radio" :value="p" />
|
|
7
|
+
<i class="ri-store-2-line pp-ico"></i>
|
|
8
|
+
<span class="d-block">
|
|
9
|
+
<b>{{ p.name }}</b>
|
|
10
|
+
<span v-if="p.city || p.address" class="d-block text-muted small">{{ [p.city, p.address].filter(Boolean).join(', ') }}</span>
|
|
11
|
+
<span v-if="p.workingHours" class="d-block small"><i class="ri-time-line me-1"></i>{{ p.workingHours }}</span>
|
|
12
|
+
</span>
|
|
13
|
+
</label>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
<p v-else class="text-muted mb-0">{{ t('checkout.pickupDefault') }}</p>
|
|
17
|
+
<div v-if="error" class="invalid-feedback d-block">{{ error }}</div>
|
|
18
|
+
</div>
|
|
19
|
+
</template>
|
|
20
|
+
|
|
21
|
+
<script setup lang="ts">
|
|
22
|
+
import type { DeliveryState } from './types';
|
|
23
|
+
|
|
24
|
+
// Пункти самовивозу як radio-картки
|
|
25
|
+
defineProps<{ points: any[]; error?: string }>();
|
|
26
|
+
const model = defineModel<DeliveryState['pickupPoint']>({ required: true });
|
|
27
|
+
const { t } = useI18n();
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<style scoped lang="scss">
|
|
31
|
+
.pp-card {
|
|
32
|
+
display: flex;
|
|
33
|
+
gap: 12px;
|
|
34
|
+
align-items: flex-start;
|
|
35
|
+
margin: 0;
|
|
36
|
+
padding: 12px 14px;
|
|
37
|
+
border: 2px solid #e7e7e7;
|
|
38
|
+
border-radius: 10px;
|
|
39
|
+
background: #fff;
|
|
40
|
+
cursor: pointer;
|
|
41
|
+
&:hover { border-color: var(--secondary-color); }
|
|
42
|
+
&.active { border-color: var(--primary-color); box-shadow: 0 0 0 3px rgba(74, 141, 22, 0.12); }
|
|
43
|
+
}
|
|
44
|
+
.pp-radio { position: absolute; opacity: 0; pointer-events: none; }
|
|
45
|
+
.pp-ico { font-size: 22px; color: var(--primary-color); }
|
|
46
|
+
</style>
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, watch, onBeforeUnmount } from 'vue';
|
|
3
|
+
import type { PromoBuyer } from '../../composables/useCheckout';
|
|
4
|
+
|
|
5
|
+
// Поле «Промокод» на оформленні: перевірка на сервері (POST promo-code/validate) з товарами
|
|
6
|
+
// й уже введеними email/телефоном (персональні коди). Зміна кошика або контактів → повторна
|
|
7
|
+
// перевірка (debounce): покупець бачить «код не для вас» ще до «Підтвердити замовлення».
|
|
8
|
+
const props = defineProps<{ buyer?: PromoBuyer }>();
|
|
9
|
+
const { t } = useI18n();
|
|
10
|
+
const { cart } = useCart();
|
|
11
|
+
const { promo, promoChecking, promoError, promoFromOrder, validatePromo, clearPromo } = useCheckout();
|
|
12
|
+
|
|
13
|
+
const code = ref(promo.value?.code || '');
|
|
14
|
+
const notice = ref('');
|
|
15
|
+
const touched = ref(false);
|
|
16
|
+
|
|
17
|
+
const items = () => cart.value.items.map((i) => ({ productId: i.productId, quantity: i.quantity }));
|
|
18
|
+
|
|
19
|
+
const buyer = (): PromoBuyer => ({
|
|
20
|
+
...(props.buyer?.email?.trim() ? { email: props.buyer.email.trim() } : {}),
|
|
21
|
+
...(props.buyer?.phone ? { phone: props.buyer.phone } : {}),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Помилка з бека (у т.ч. відмова при створенні замовлення); без тексту — загальна
|
|
25
|
+
const errorText = computed(() => {
|
|
26
|
+
if (promo.value || promoChecking.value) return '';
|
|
27
|
+
if (promoError.value) return promoError.value;
|
|
28
|
+
return touched.value ? t('checkout.promoInvalid') : '';
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Сервер відхилив код при оформленні — повертаємо його в поле поруч із помилкою
|
|
32
|
+
watch(promoFromOrder, (c) => {
|
|
33
|
+
if (c) code.value = c;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const apply = async () => {
|
|
37
|
+
if (!code.value.trim() || promoChecking.value) return;
|
|
38
|
+
notice.value = '';
|
|
39
|
+
touched.value = true;
|
|
40
|
+
await validatePromo(code.value, items(), buyer());
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const onInput = () => {
|
|
44
|
+
touched.value = false;
|
|
45
|
+
promoError.value = '';
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const remove = () => {
|
|
49
|
+
clearPromo();
|
|
50
|
+
code.value = '';
|
|
51
|
+
touched.value = false;
|
|
52
|
+
notice.value = '';
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Кошик або контакти змінились — перевіряємо застосований (чи щойно відхилений) код ще раз
|
|
56
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
57
|
+
watch(
|
|
58
|
+
() =>
|
|
59
|
+
[
|
|
60
|
+
cart.value.items.map((i) => `${i.productId}:${i.quantity}`).join(','),
|
|
61
|
+
props.buyer?.email?.trim() || '',
|
|
62
|
+
props.buyer?.phone || '',
|
|
63
|
+
].join('|'),
|
|
64
|
+
() => {
|
|
65
|
+
const pending = promo.value?.code || (touched.value && promoError.value ? code.value.trim() : '');
|
|
66
|
+
if (!pending) return;
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
const wasApplied = !!promo.value;
|
|
69
|
+
timer = setTimeout(async () => {
|
|
70
|
+
const res = await validatePromo(pending, items(), buyer());
|
|
71
|
+
if (!res.valid && wasApplied) {
|
|
72
|
+
touched.value = true;
|
|
73
|
+
if (!res.message) notice.value = t('checkout.promoRemoved');
|
|
74
|
+
}
|
|
75
|
+
}, 600);
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
onBeforeUnmount(() => clearTimeout(timer));
|
|
79
|
+
</script>
|
|
80
|
+
|
|
81
|
+
<template>
|
|
82
|
+
<div class="promo-code" data-field="promo">
|
|
83
|
+
<label for="checkout-promo" class="form-label promo-code__label">{{ t('checkout.promoCode') }}</label>
|
|
84
|
+
|
|
85
|
+
<div v-if="promo" class="promo-code__applied">
|
|
86
|
+
<span>
|
|
87
|
+
<i class="ri-coupon-3-line"></i>
|
|
88
|
+
<b>{{ promo.code }}</b>
|
|
89
|
+
<template v-if="promo.discountAmount"> · −{{ formatUah(promo.discountAmount) }}</template>
|
|
90
|
+
<template v-if="promo.freeShipping"> · {{ t('checkout.freeShipping') }}</template>
|
|
91
|
+
</span>
|
|
92
|
+
<button type="button" class="promo-code__remove" :aria-label="t('checkout.promoRemove')" @click="remove">
|
|
93
|
+
<i class="ri-close-line"></i>
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<div v-else class="promo-code__row">
|
|
98
|
+
<input
|
|
99
|
+
id="checkout-promo"
|
|
100
|
+
v-model="code"
|
|
101
|
+
type="text"
|
|
102
|
+
class="form-control"
|
|
103
|
+
autocomplete="off"
|
|
104
|
+
autocapitalize="characters"
|
|
105
|
+
:placeholder="t('checkout.promoPlaceholder')"
|
|
106
|
+
:aria-invalid="!!errorText"
|
|
107
|
+
aria-describedby="checkout-promo-msg"
|
|
108
|
+
@input="onInput"
|
|
109
|
+
@keydown.enter.prevent="apply"
|
|
110
|
+
/>
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
class="btn btn-outline-success promo-code__btn"
|
|
114
|
+
:disabled="!code.trim() || promoChecking"
|
|
115
|
+
@click="apply"
|
|
116
|
+
>
|
|
117
|
+
<span v-if="promoChecking" class="spinner-border spinner-border-sm" aria-hidden="true"></span>
|
|
118
|
+
<span v-else>{{ t('checkout.promoApply') }}</span>
|
|
119
|
+
</button>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<div id="checkout-promo-msg" aria-live="polite">
|
|
123
|
+
<small v-if="errorText" class="text-danger d-block mt-1">{{ errorText }}</small>
|
|
124
|
+
<small v-else-if="notice" class="text-muted d-block mt-1">{{ notice }}</small>
|
|
125
|
+
<small v-if="promo?.requiresBuyer" class="text-muted d-block mt-1">
|
|
126
|
+
<i class="ri-user-line"></i> {{ t('checkout.promoRequiresBuyer') }}
|
|
127
|
+
</small>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
</template>
|
|
131
|
+
|
|
132
|
+
<style scoped lang="scss">
|
|
133
|
+
.promo-code { margin-bottom: 16px; }
|
|
134
|
+
.promo-code__label { font-weight: 600; margin-bottom: 6px; }
|
|
135
|
+
.promo-code__row {
|
|
136
|
+
display: flex;
|
|
137
|
+
gap: 8px;
|
|
138
|
+
.form-control { min-height: 44px; text-transform: uppercase; }
|
|
139
|
+
.form-control::placeholder { text-transform: none; }
|
|
140
|
+
.form-control:focus { border-color: var(--primary-color); box-shadow: 0 0 0 3px rgba(74, 141, 22, 0.15); }
|
|
141
|
+
}
|
|
142
|
+
.promo-code__btn { min-height: 44px; min-width: 120px; white-space: nowrap; }
|
|
143
|
+
.promo-code__applied {
|
|
144
|
+
display: flex;
|
|
145
|
+
align-items: center;
|
|
146
|
+
justify-content: space-between;
|
|
147
|
+
gap: 8px;
|
|
148
|
+
padding: 8px 8px 8px 12px;
|
|
149
|
+
border: 1px dashed var(--primary-color);
|
|
150
|
+
border-radius: 10px;
|
|
151
|
+
background: rgba(74, 141, 22, 0.06);
|
|
152
|
+
color: var(--primary-color);
|
|
153
|
+
i { margin-right: 4px; }
|
|
154
|
+
}
|
|
155
|
+
.promo-code__remove {
|
|
156
|
+
width: 36px;
|
|
157
|
+
height: 36px;
|
|
158
|
+
border: 0;
|
|
159
|
+
border-radius: 8px;
|
|
160
|
+
background: transparent;
|
|
161
|
+
color: #57636c;
|
|
162
|
+
font-size: 18px;
|
|
163
|
+
&:hover { background: #eef0f2; }
|
|
164
|
+
&:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 1px; }
|
|
165
|
+
}
|
|
166
|
+
</style>
|