@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,190 +1,252 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="ptb-60">
|
|
3
3
|
<div class="container">
|
|
4
|
-
<
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class="form-control"
|
|
13
|
-
:placeholder="t('checkout.firstName')"
|
|
14
|
-
/>
|
|
15
|
-
</div>
|
|
16
|
-
<div class="form-group mb-4">
|
|
17
|
-
<label class="label">{{ t('checkout.lastName') }}</label>
|
|
18
|
-
<input type="text" class="form-control" :placeholder="t('checkout.lastName')" />
|
|
19
|
-
</div>
|
|
20
|
-
<div class="form-group mb-4">
|
|
21
|
-
<label class="label">{{ t('checkout.emailAddress') }}</label>
|
|
22
|
-
<input
|
|
23
|
-
type="email"
|
|
24
|
-
class="form-control"
|
|
25
|
-
:placeholder="t('checkout.emailAddress')"
|
|
26
|
-
/>
|
|
27
|
-
</div>
|
|
28
|
-
<div class="form-group mb-4">
|
|
29
|
-
<label class="label">{{ t('checkout.companyName') }}</label>
|
|
30
|
-
<input
|
|
31
|
-
type="text"
|
|
32
|
-
class="form-control"
|
|
33
|
-
:placeholder="t('checkout.companyName')"
|
|
34
|
-
/>
|
|
35
|
-
</div>
|
|
36
|
-
<div class="form-group mb-4">
|
|
37
|
-
<label class="label">{{ t('checkout.countryRegion') }}</label>
|
|
38
|
-
<input
|
|
39
|
-
type="text"
|
|
40
|
-
class="form-control"
|
|
41
|
-
:placeholder="t('checkout.countryRegion')"
|
|
42
|
-
/>
|
|
43
|
-
</div>
|
|
44
|
-
<div class="form-group mb-4">
|
|
45
|
-
<label class="label">{{ t('checkout.countryRegion') }}</label>
|
|
46
|
-
<select
|
|
47
|
-
class="form-select form-control"
|
|
48
|
-
aria-label="Default select example"
|
|
49
|
-
>
|
|
50
|
-
<option selected>Canada</option>
|
|
51
|
-
<option value="1">United States</option>
|
|
52
|
-
<option value="2">United Kingdom</option>
|
|
53
|
-
</select>
|
|
54
|
-
</div>
|
|
55
|
-
<div class="form-group mb-4">
|
|
56
|
-
<label class="label">{{ t('checkout.streetAddress') }}</label>
|
|
57
|
-
<input
|
|
58
|
-
type="text"
|
|
59
|
-
class="form-control"
|
|
60
|
-
:placeholder="t('checkout.addressLine1')"
|
|
61
|
-
/>
|
|
62
|
-
</div>
|
|
63
|
-
<div class="form-group mb-4">
|
|
64
|
-
<input
|
|
65
|
-
type="text"
|
|
66
|
-
class="form-control"
|
|
67
|
-
:placeholder="t('checkout.addressLine2')"
|
|
68
|
-
/>
|
|
69
|
-
</div>
|
|
70
|
-
<div class="form-group mb-4">
|
|
71
|
-
<label class="label">{{ t('checkout.townCity') }}</label>
|
|
72
|
-
<input type="text" class="form-control" :placeholder="t('checkout.townCity')" />
|
|
73
|
-
</div>
|
|
74
|
-
<div class="form-group mb-4">
|
|
75
|
-
<label class="label">{{ t('checkout.postalCode') }}</label>
|
|
76
|
-
<input
|
|
77
|
-
type="text"
|
|
78
|
-
class="form-control"
|
|
79
|
-
:placeholder="t('checkout.postalCode')"
|
|
80
|
-
/>
|
|
81
|
-
</div>
|
|
82
|
-
<div class="form-group mb-4">
|
|
83
|
-
<label class="label">{{ t('checkout.phone') }}</label>
|
|
84
|
-
<input type="text" class="form-control" :placeholder="t('checkout.phone')" />
|
|
85
|
-
</div>
|
|
86
|
-
<div class="form-group mb-4">
|
|
87
|
-
<label class="label">{{ t('checkout.additionalInfo') }}</label>
|
|
88
|
-
<textarea
|
|
89
|
-
class="form-control"
|
|
90
|
-
:placeholder="t('checkout.notes')"
|
|
91
|
-
cols="30"
|
|
92
|
-
rows="5"
|
|
93
|
-
></textarea>
|
|
94
|
-
</div>
|
|
95
|
-
</div>
|
|
4
|
+
<ClientOnly>
|
|
5
|
+
<!-- Порожній кошик -->
|
|
6
|
+
<div v-if="isEmpty" class="text-center py-5">
|
|
7
|
+
<h3 class="mb-3">{{ t('cart.empty') }}</h3>
|
|
8
|
+
<NuxtLink to="/products" class="btn btn-warning text-white">
|
|
9
|
+
{{ t('cart.toCatalog') }}
|
|
10
|
+
<i class="flaticon-up-right-arrow"></i>
|
|
11
|
+
</NuxtLink>
|
|
96
12
|
</div>
|
|
97
13
|
|
|
98
|
-
<
|
|
99
|
-
<div class="
|
|
100
|
-
<
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
<
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
</
|
|
110
|
-
</thead>
|
|
111
|
-
<tbody>
|
|
112
|
-
<tr>
|
|
113
|
-
<td class="title">
|
|
114
|
-
<span class="text-truncate">
|
|
115
|
-
Multiplatform Wireless Noise-Cancelling Gaming Headset
|
|
116
|
-
</span>
|
|
117
|
-
</td>
|
|
118
|
-
<td class="price">$79 <sub class="text-body">x1</sub></td>
|
|
119
|
-
<td class="price">$79</td>
|
|
120
|
-
</tr>
|
|
121
|
-
<tr>
|
|
122
|
-
<td class="title">
|
|
123
|
-
<span class="text-truncate">
|
|
124
|
-
GPS Smartwatch with Bright Touchscreen Display
|
|
125
|
-
</span>
|
|
126
|
-
</td>
|
|
127
|
-
<td class="price">$59 <sub class="text-body">x2</sub></td>
|
|
128
|
-
<td class="price">$119</td>
|
|
129
|
-
</tr>
|
|
130
|
-
<tr>
|
|
131
|
-
<td class="title">
|
|
132
|
-
<span class="text-truncate">
|
|
133
|
-
SAMSUNG 32-Inch Class QLED 4K Q60C Series Quantum HDR
|
|
134
|
-
</span>
|
|
135
|
-
</td>
|
|
136
|
-
<td class="price">$398 <sub class="text-body">x1</sub></td>
|
|
137
|
-
<td class="price">$398</td>
|
|
138
|
-
</tr>
|
|
139
|
-
<tr>
|
|
140
|
-
<td class="sub-title">{{ t('cart.subtotal') }}</td>
|
|
141
|
-
<td class="sub-price"></td>
|
|
142
|
-
<td class="sub-price">$595</td>
|
|
143
|
-
</tr>
|
|
144
|
-
<tr class="total">
|
|
145
|
-
<td class="sub-title">{{ t('cart.total') }}</td>
|
|
146
|
-
<td class="sub-price"></td>
|
|
147
|
-
<td class="sub-price">$595</td>
|
|
148
|
-
</tr>
|
|
149
|
-
</tbody>
|
|
150
|
-
</table>
|
|
151
|
-
|
|
152
|
-
<h3>{{ t('checkout.paymentOption') }}</h3>
|
|
153
|
-
<div class="form-check">
|
|
154
|
-
<input
|
|
155
|
-
class="form-check-input"
|
|
156
|
-
type="checkbox"
|
|
157
|
-
value=""
|
|
158
|
-
id="flexCheckDefault"
|
|
159
|
-
/>
|
|
160
|
-
<label class="form-check-label" for="flexCheckDefault">
|
|
161
|
-
{{ t('checkout.cardPayment') }}
|
|
162
|
-
</label>
|
|
163
|
-
</div>
|
|
164
|
-
<div class="form-check mb-0">
|
|
165
|
-
<input
|
|
166
|
-
class="form-check-input"
|
|
167
|
-
type="checkbox"
|
|
168
|
-
value=""
|
|
169
|
-
id="flexCheckChecked"
|
|
170
|
-
checked
|
|
171
|
-
/>
|
|
172
|
-
<label class="form-check-label" for="flexCheckChecked">
|
|
173
|
-
{{ t('checkout.cashOnDelivery') }}
|
|
174
|
-
</label>
|
|
175
|
-
</div>
|
|
14
|
+
<form v-else novalidate @submit.prevent="submit">
|
|
15
|
+
<div class="row g-4">
|
|
16
|
+
<div class="col-lg-7">
|
|
17
|
+
<CheckoutForm v-model="contact" :errors="errors" />
|
|
18
|
+
<CheckoutDeliveryOptions v-model="delivery" :errors="errors" class="mt-4" />
|
|
19
|
+
<CheckoutPaymentOptions v-model:payment="payment" v-model:provider="payProvider" class="mt-4" />
|
|
20
|
+
</div>
|
|
21
|
+
<div class="col-lg-5">
|
|
22
|
+
<div class="summary-sticky">
|
|
23
|
+
<CheckoutOrderSummary :delivery="delivery" :buyer="promoBuyer">
|
|
24
|
+
<div v-if="errorText" class="alert alert-danger mb-3">{{ errorText }}</div>
|
|
25
|
+
<div v-if="errors.cart" class="alert alert-danger mb-3">{{ errors.cart }}</div>
|
|
176
26
|
|
|
177
|
-
|
|
178
|
-
|
|
27
|
+
<button type="submit" class="btn btn-warning text-white w-100 submit-btn" :disabled="submitting">
|
|
28
|
+
<span v-if="submitting" class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span>
|
|
29
|
+
{{ submitting ? t('checkout.placing') : t('checkout.placeOrder') }}
|
|
30
|
+
</button>
|
|
31
|
+
<NuxtLink to="/cart" class="btn btn-link w-100 mt-2">{{ t('checkout.backToCart') }}</NuxtLink>
|
|
32
|
+
</CheckoutOrderSummary>
|
|
179
33
|
</div>
|
|
180
34
|
</div>
|
|
181
35
|
</div>
|
|
36
|
+
</form>
|
|
37
|
+
|
|
38
|
+
<!-- Платежі вимкнено глобально (403 PAYMENTS_DISABLED) -->
|
|
39
|
+
<div v-if="paymentsDisabled" class="pay-modal" role="dialog" aria-modal="true" @click.self="paymentsDisabled = false">
|
|
40
|
+
<div class="pay-modal-box">
|
|
41
|
+
<h4>{{ t('checkout.paymentsDisabledTitle') }}</h4>
|
|
42
|
+
<p class="text-muted">{{ t('checkout.paymentsDisabledText') }}</p>
|
|
43
|
+
<button type="button" class="btn btn-warning text-white" @click="paymentsDisabled = false">
|
|
44
|
+
{{ t('checkout.close') }}
|
|
45
|
+
</button>
|
|
46
|
+
</div>
|
|
182
47
|
</div>
|
|
183
|
-
|
|
48
|
+
|
|
49
|
+
<template #fallback>
|
|
50
|
+
<div class="text-center py-5 text-muted">{{ t('cart.loading') }}</div>
|
|
51
|
+
</template>
|
|
52
|
+
</ClientOnly>
|
|
184
53
|
</div>
|
|
185
54
|
</div>
|
|
186
55
|
</template>
|
|
187
56
|
|
|
188
57
|
<script setup lang="ts">
|
|
58
|
+
import { reactive, ref, computed, nextTick, onMounted, watch } from 'vue';
|
|
59
|
+
import { emptyDelivery, normalizePhone } from './types';
|
|
60
|
+
import type { ContactState, DeliveryState, FieldErrors } from './types';
|
|
61
|
+
|
|
189
62
|
const { t } = useI18n();
|
|
63
|
+
const router = useRouter();
|
|
64
|
+
const { cart, isEmpty, priceListId, clearCart, syncPrices } = useCart();
|
|
65
|
+
const { fetchProductsByIds } = useProducts();
|
|
66
|
+
const {
|
|
67
|
+
options, loadOptions, hasWarehouseFlow, needsPostcode, normalizeDelivery,
|
|
68
|
+
submitting, errorMsg, errorCode, submitOrder, payOrder, promo, clearPromo, promoFromOrder,
|
|
69
|
+
} = useCheckout();
|
|
70
|
+
|
|
71
|
+
const contact = reactive<ContactState>({ name: '', phone: '', email: '', comment: '' });
|
|
72
|
+
const delivery = reactive<DeliveryState>(emptyDelivery());
|
|
73
|
+
const payment = ref<'cod' | 'online'>('cod');
|
|
74
|
+
const payProvider = ref('');
|
|
75
|
+
const errors = ref<FieldErrors>({});
|
|
76
|
+
const paymentsDisabled = ref(false);
|
|
77
|
+
const generalError = ref('');
|
|
78
|
+
const errorText = computed(() => generalError.value || errorMsg.value);
|
|
79
|
+
// Контакти для перевірки персональних промокодів (телефон — лише коли формат валідний)
|
|
80
|
+
const promoBuyer = computed(() => ({
|
|
81
|
+
email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(contact.email.trim()) ? contact.email.trim() : '',
|
|
82
|
+
phone: normalizePhone(contact.phone),
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
onMounted(async () => {
|
|
86
|
+
// Свіжі ціни/знижки в кошику (паралельно з опціями доставки)
|
|
87
|
+
const ids = cart.value.items.map((i) => i.productId);
|
|
88
|
+
if (ids.length && !priceListId.value) fetchProductsByIds(ids).then(syncPrices).catch(() => {});
|
|
89
|
+
const opts = await loadOptions();
|
|
90
|
+
const first = opts.delivery[0];
|
|
91
|
+
delivery.provider = first ? (first as any).value || first.provider || '' : '';
|
|
92
|
+
// Якщо післяплата вимкнена, а онлайн доступний — стартуємо з онлайн
|
|
93
|
+
if (opts.payment.cashOnDelivery === false && opts.payment.online) payment.value = 'online';
|
|
94
|
+
payProvider.value = opts.payment.onlineProviders?.[0]?.provider || '';
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Онлайн вимкнули/недоступний — повертаємось на післяплату
|
|
98
|
+
watch(options, (o) => {
|
|
99
|
+
if (payment.value === 'online' && !o?.payment?.online) payment.value = 'cod';
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Скрол до першого поля з помилкою (порядок: контакти -> доставка)
|
|
103
|
+
const scrollToFirstError = async (e: FieldErrors) => {
|
|
104
|
+
await nextTick();
|
|
105
|
+
const order = ['name', 'phone', 'email', 'delivery', 'city', 'warehouse', 'pickup', 'address', 'postcode'];
|
|
106
|
+
const key = order.find((k) => e[k]);
|
|
107
|
+
if (!key) return;
|
|
108
|
+
const el = document.querySelector<HTMLElement>(`[data-field="${key}"]`);
|
|
109
|
+
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
110
|
+
el?.querySelector<HTMLElement>('input, textarea')?.focus({ preventScroll: true });
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Валідація з повідомленнями українською
|
|
114
|
+
const validate = (): boolean => {
|
|
115
|
+
const e: FieldErrors = {};
|
|
116
|
+
if (!cart.value.items.length) e.cart = t('checkout.errCart');
|
|
117
|
+
if (contact.name.trim().length < 2) e.name = t('checkout.errName');
|
|
118
|
+
if (!normalizePhone(contact.phone)) e.phone = t('checkout.errPhone');
|
|
119
|
+
if (!contact.email.trim()) e.email = t('checkout.errEmailRequired');
|
|
120
|
+
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(contact.email.trim())) e.email = t('checkout.errEmail');
|
|
121
|
+
const p = delivery.provider;
|
|
122
|
+
if (!p) {
|
|
123
|
+
e.delivery = t('checkout.errDelivery');
|
|
124
|
+
} else if (hasWarehouseFlow(p)) {
|
|
125
|
+
if (!delivery.city) e.city = t('checkout.errCity');
|
|
126
|
+
else if (!delivery.warehouse) e.warehouse = t('checkout.errWarehouse');
|
|
127
|
+
} else if (p === 'pickup') {
|
|
128
|
+
const points = (options.value?.delivery.find((d: any) => (d.value || d.provider) === 'pickup') as any)?.points || [];
|
|
129
|
+
if (points.length && !delivery.pickupPoint) e.pickup = t('checkout.errPickup');
|
|
130
|
+
} else {
|
|
131
|
+
if (!delivery.cityText.trim()) e.city = t('checkout.errCity');
|
|
132
|
+
if (!delivery.address.trim()) e.address = t('checkout.errAddress');
|
|
133
|
+
if (needsPostcode(p) && !/^\d{5}$/.test(delivery.postcode.trim())) e.postcode = t('checkout.errPostcode');
|
|
134
|
+
}
|
|
135
|
+
errors.value = e;
|
|
136
|
+
const ok = Object.keys(e).length === 0;
|
|
137
|
+
if (!ok) scrollToFirstError(e);
|
|
138
|
+
return ok;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Місто/адреса для buyerInfo залежно від способу доставки
|
|
142
|
+
const buildAddress = () => {
|
|
143
|
+
const p = delivery.provider;
|
|
144
|
+
if (p === 'pickup') {
|
|
145
|
+
const pt = delivery.pickupPoint;
|
|
146
|
+
return pt
|
|
147
|
+
? { city: pt.city || 'Самовивіз', address: `Самовивіз: ${pt.name}${pt.address ? ', ' + pt.address : ''}` }
|
|
148
|
+
: { city: 'Самовивіз', address: 'Самовивіз зі складу продавця' };
|
|
149
|
+
}
|
|
150
|
+
if (hasWarehouseFlow(p)) return { city: delivery.city!.name, address: delivery.warehouse!.description };
|
|
151
|
+
return {
|
|
152
|
+
city: delivery.cityText.trim(),
|
|
153
|
+
address: delivery.address.trim(),
|
|
154
|
+
...(delivery.postcode.trim() ? { postalCode: delivery.postcode.trim() } : {}),
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const submit = async () => {
|
|
159
|
+
if (submitting.value) return; // захист від дублікатів замовлень
|
|
160
|
+
generalError.value = '';
|
|
161
|
+
if (!validate()) return;
|
|
162
|
+
|
|
163
|
+
const name = contact.name.trim();
|
|
164
|
+
const sp = name.indexOf(' ');
|
|
165
|
+
const firstName = sp === -1 ? name : name.slice(0, sp);
|
|
166
|
+
const lastName = sp === -1 ? name : name.slice(sp + 1).trim() || name;
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const res = await submitOrder({
|
|
170
|
+
items: cart.value.items.map((i) => ({ productId: i.productId, quantity: i.quantity })),
|
|
171
|
+
...(priceListId.value ? { priceListId: priceListId.value } : {}),
|
|
172
|
+
buyerInfo: {
|
|
173
|
+
firstName,
|
|
174
|
+
lastName,
|
|
175
|
+
phoneNumber: normalizePhone(contact.phone),
|
|
176
|
+
email: contact.email.trim(),
|
|
177
|
+
...buildAddress(),
|
|
178
|
+
},
|
|
179
|
+
// Бек (CreateStorefrontOrderDto) очікує коментар у полі notes
|
|
180
|
+
...(contact.comment.trim() ? { notes: contact.comment.trim() } : {}),
|
|
181
|
+
deliveryMethod: normalizeDelivery(delivery.provider),
|
|
182
|
+
paymentMethod: payment.value === 'online' ? 'online_payment' : 'cash_on_delivery',
|
|
183
|
+
// Промокод: сервер сам перевіряє його й рахує знижку (невалідний → 400 з повідомленням)
|
|
184
|
+
...(promo.value?.code ? { promoCode: promo.value.code } : {}),
|
|
185
|
+
});
|
|
186
|
+
const orderId = res?.orderId || res?.id;
|
|
187
|
+
const orderNumber = res?.orderNumber || '';
|
|
188
|
+
clearCart();
|
|
189
|
+
clearPromo();
|
|
190
|
+
|
|
191
|
+
// Онлайн-оплата: ініціюємо платіж і йдемо на платіжну сторінку
|
|
192
|
+
if (payment.value === 'online' && orderId) {
|
|
193
|
+
try {
|
|
194
|
+
const pay = await payOrder(orderId, payProvider.value || undefined);
|
|
195
|
+
if (pay?.paymentUrl) {
|
|
196
|
+
try { localStorage.setItem('lastOrderId', orderId); } catch {}
|
|
197
|
+
window.location.href = pay.paymentUrl;
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
/* платіж не ініційовано — замовлення створене, покажемо сторінку статусу */
|
|
202
|
+
}
|
|
203
|
+
await router.push({ path: '/order-success', query: { order: orderId, n: orderNumber, paid: '1' } });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
await router.push({ path: '/order-success', query: { order: orderId, n: orderNumber } });
|
|
207
|
+
} catch (e: any) {
|
|
208
|
+
if (errorCode.value === 'PAYMENTS_DISABLED' || (e?.statusCode === 403 && payment.value === 'online')) {
|
|
209
|
+
paymentsDisabled.value = true;
|
|
210
|
+
payment.value = 'cod';
|
|
211
|
+
} else if (promoFromOrder.value) {
|
|
212
|
+
// Сервер відхилив промокод — помилка вже біля поля промокоду
|
|
213
|
+
document.querySelector<HTMLElement>('[data-field="promo"]')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
214
|
+
} else if (!errorMsg.value) {
|
|
215
|
+
generalError.value = t('checkout.errGeneric');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
};
|
|
190
219
|
</script>
|
|
220
|
+
|
|
221
|
+
<style scoped lang="scss">
|
|
222
|
+
.summary-sticky { position: sticky; top: 20px; }
|
|
223
|
+
.submit-btn { padding: 14px 20px; font-size: 18px; font-weight: 600; }
|
|
224
|
+
:deep(.checkout-card) {
|
|
225
|
+
padding: 24px;
|
|
226
|
+
border: 1px solid #e7e7e7;
|
|
227
|
+
border-radius: 12px;
|
|
228
|
+
background: #fff;
|
|
229
|
+
h3 { margin-bottom: 16px; }
|
|
230
|
+
.form-control:focus { border-color: var(--primary-color); box-shadow: 0 0 0 3px rgba(74, 141, 22, 0.15); }
|
|
231
|
+
}
|
|
232
|
+
@media (max-width: 575px) {
|
|
233
|
+
:deep(.checkout-card), :deep(.order-summary) { padding: 16px; }
|
|
234
|
+
}
|
|
235
|
+
.pay-modal {
|
|
236
|
+
position: fixed;
|
|
237
|
+
inset: 0;
|
|
238
|
+
z-index: 2000;
|
|
239
|
+
display: flex;
|
|
240
|
+
align-items: center;
|
|
241
|
+
justify-content: center;
|
|
242
|
+
background: rgba(0, 0, 0, 0.5);
|
|
243
|
+
padding: 16px;
|
|
244
|
+
}
|
|
245
|
+
.pay-modal-box {
|
|
246
|
+
background: #fff;
|
|
247
|
+
border-radius: 12px;
|
|
248
|
+
padding: 32px;
|
|
249
|
+
max-width: 420px;
|
|
250
|
+
text-align: center;
|
|
251
|
+
}
|
|
252
|
+
</style>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Стан блоку доставки на сторінці оформлення
|
|
2
|
+
export interface DeliveryState {
|
|
3
|
+
provider: string;
|
|
4
|
+
// Нова Пошта: місто -> відділення
|
|
5
|
+
cityQuery: string;
|
|
6
|
+
city: { ref: string; name: string } | null;
|
|
7
|
+
warehouseQuery: string;
|
|
8
|
+
warehouse: { ref: string; description: string } | null;
|
|
9
|
+
// Кур'єр / Укрпошта / інше
|
|
10
|
+
cityText: string;
|
|
11
|
+
address: string;
|
|
12
|
+
postcode: string;
|
|
13
|
+
// Самовивіз
|
|
14
|
+
pickupPoint: { name: string; city?: string; address?: string; workingHours?: string } | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ContactState {
|
|
18
|
+
name: string;
|
|
19
|
+
phone: string;
|
|
20
|
+
email: string;
|
|
21
|
+
comment: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type FieldErrors = Record<string, string>;
|
|
25
|
+
|
|
26
|
+
export const emptyDelivery = (): DeliveryState => ({
|
|
27
|
+
provider: '',
|
|
28
|
+
cityQuery: '',
|
|
29
|
+
city: null,
|
|
30
|
+
warehouseQuery: '',
|
|
31
|
+
warehouse: null,
|
|
32
|
+
cityText: '',
|
|
33
|
+
address: '',
|
|
34
|
+
postcode: '',
|
|
35
|
+
pickupPoint: null,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Нормалізує телефон до +380XXXXXXXXX; повертає '' якщо формат невірний
|
|
39
|
+
export const normalizePhone = (raw: string): string => {
|
|
40
|
+
let d = raw.replace(/[^\d+]/g, '');
|
|
41
|
+
if (d.startsWith('+')) d = d.slice(1);
|
|
42
|
+
if (d.startsWith('0') && d.length === 10) d = '38' + d;
|
|
43
|
+
if (d.length === 9) d = '380' + d;
|
|
44
|
+
return /^380\d{9}$/.test(d) ? '+' + d : '';
|
|
45
|
+
};
|
|
@@ -1,26 +1,28 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Заголовок сторінки та хлібні крихти
|
|
3
|
+
// crumbs — проміжні кроки між «Головна» і назвою сторінки (з посиланнями)
|
|
4
|
+
defineProps<{
|
|
5
|
+
pageTitle: string;
|
|
6
|
+
crumbs?: { label: string; to: string }[];
|
|
7
|
+
}>();
|
|
8
|
+
const { t } = useI18n();
|
|
9
|
+
</script>
|
|
10
|
+
|
|
1
11
|
<template>
|
|
2
12
|
<div class="page-banner bg-gray4 py-3">
|
|
3
13
|
<div class="container">
|
|
4
|
-
<
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
<i class="ri-arrow-right-s-line"></i>
|
|
17
|
-
</div>
|
|
18
|
-
</div>
|
|
14
|
+
<h1 class="h3 mb-2">{{ pageTitle }}</h1>
|
|
15
|
+
<ul class="d-flex align-content-center list-unstyled mb-0 page-content">
|
|
16
|
+
<li>
|
|
17
|
+
<NuxtLink to="/" class="text-decoration-none">{{ t('nav.home') }}</NuxtLink>
|
|
18
|
+
</li>
|
|
19
|
+
<li v-for="crumb in crumbs" :key="crumb.to">
|
|
20
|
+
<NuxtLink :to="crumb.to" class="text-decoration-none">{{ crumb.label }}</NuxtLink>
|
|
21
|
+
</li>
|
|
22
|
+
<li>
|
|
23
|
+
<span>{{ pageTitle }}</span>
|
|
24
|
+
</li>
|
|
25
|
+
</ul>
|
|
19
26
|
</div>
|
|
20
27
|
</div>
|
|
21
28
|
</template>
|
|
22
|
-
|
|
23
|
-
<script setup lang="ts">
|
|
24
|
-
defineProps<{ pageTitle: string }>();
|
|
25
|
-
const { t } = useI18n();
|
|
26
|
-
</script>
|