@crm-market/template-shared 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/assets/scss/_variables.scss +3 -3
  2. package/assets/scss/components/_header.scss +1 -1
  3. package/assets/scss/style.css +4 -4
  4. package/components/AboutUs/StaticContent.vue +32 -0
  5. package/components/Cart/CartEmpty.vue +42 -0
  6. package/components/Cart/CartItem.vue +145 -0
  7. package/components/Cart/CartSummary.vue +57 -0
  8. package/components/Cart/ProductQuantity.vue +23 -13
  9. package/components/Cart/index.vue +68 -150
  10. package/components/Categories/index.vue +88 -291
  11. package/components/Checkout/AddressFields.vue +74 -0
  12. package/components/Checkout/CheckoutForm.vue +75 -0
  13. package/components/Checkout/CityAutocomplete.vue +68 -0
  14. package/components/Checkout/DeliveryMethodCard.vue +66 -0
  15. package/components/Checkout/DeliveryOptions.vue +148 -0
  16. package/components/Checkout/OrderSummary.vue +116 -0
  17. package/components/Checkout/PaymentOptions.vue +76 -0
  18. package/components/Checkout/PickupPoints.vue +46 -0
  19. package/components/Checkout/PromoCode.vue +166 -0
  20. package/components/Checkout/index.vue +235 -173
  21. package/components/Checkout/types.ts +45 -0
  22. package/components/Common/PageBanner.vue +22 -20
  23. package/components/Common/ProductCard.vue +139 -115
  24. package/components/Common/Services.vue +4 -4
  25. package/components/Contact/ContactForm.vue +44 -74
  26. package/components/Contact/ContactInfo.vue +35 -61
  27. package/components/FAQ/index.vue +6 -119
  28. package/components/Layout/Copyright.vue +11 -9
  29. package/components/Layout/Footer.vue +49 -136
  30. package/components/Layout/HeaderBar.vue +174 -0
  31. package/components/Layout/HeaderLogo.vue +27 -0
  32. package/components/Layout/MiddleHeader.vue +1 -228
  33. package/components/Layout/MiddleHeaderThree.vue +1 -203
  34. package/components/Layout/MiddleHeaderTwo.vue +1 -239
  35. package/components/Layout/NavLinks.vue +23 -0
  36. package/components/Layout/Navbar.vue +38 -151
  37. package/components/Layout/NavbarStyleFour.vue +1 -332
  38. package/components/Layout/NavbarStyleThree.vue +1 -188
  39. package/components/Layout/NavbarStyleTwo.vue +1 -108
  40. package/components/Layout/Preloader.vue +5 -12
  41. package/components/Layout/ResponsiveNavbar.vue +32 -418
  42. package/components/Layout/SitewideBanner.vue +285 -0
  43. package/components/Layout/TopHeader.vue +17 -83
  44. package/components/Layout/layoutData.ts +83 -0
  45. package/components/PrivacyPolicy/index.vue +6 -110
  46. package/components/ProductDetails/ProductDetailsTab.vue +31 -321
  47. package/components/ProductDetails/ProductGallery.vue +45 -0
  48. package/components/ProductDetails/ProductQuantity.vue +8 -14
  49. package/components/ProductDetails/RecentlyViewed.vue +53 -289
  50. package/components/ProductDetails/RelatedProducts.vue +64 -0
  51. package/components/ProductDetails/index.vue +90 -230
  52. package/components/Products/index.vue +78 -16
  53. package/components/TermsConditions/index.vue +6 -110
  54. package/components/Wishlist/index.vue +62 -88
  55. package/composables/useCart.ts +133 -81
  56. package/composables/useCategories.ts +67 -31
  57. package/composables/useCategoryImages.ts +45 -0
  58. package/composables/useCheckout.ts +302 -97
  59. package/composables/useContactForm.ts +57 -0
  60. package/composables/useHomeContent.ts +220 -0
  61. package/composables/useI18n.ts +12 -24
  62. package/composables/useJsonLd.ts +162 -0
  63. package/composables/useProducts.ts +244 -83
  64. package/composables/useRecent.ts +30 -0
  65. package/composables/useSanitize.ts +76 -0
  66. package/composables/useSiteConfig.ts +51 -47
  67. package/composables/useStaticPages.ts +48 -0
  68. package/composables/useStoreOrigin.ts +32 -0
  69. package/composables/useTemplateSections.ts +51 -3
  70. package/composables/useThemeVars.ts +113 -0
  71. package/composables/useWishlist.ts +72 -0
  72. package/layouts/default.vue +80 -19
  73. package/locales/en.json +255 -86
  74. package/locales/uk.json +251 -82
  75. package/modules/template-schema.ts +35 -0
  76. package/nuxt.config.ts +8 -11
  77. package/package.json +1 -1
  78. package/pages/about-us.vue +9 -9
  79. package/pages/cart.vue +0 -1
  80. package/pages/categories.vue +0 -1
  81. package/pages/category/[slug].vue +84 -0
  82. package/pages/checkout.vue +0 -1
  83. package/pages/contact.vue +6 -6
  84. package/pages/delivery.vue +12 -0
  85. package/pages/faq.vue +6 -6
  86. package/pages/order-success.vue +113 -0
  87. package/pages/privacy-policy.vue +6 -6
  88. package/pages/product/[slug].vue +82 -0
  89. package/pages/product-details.vue +14 -6
  90. package/pages/products.vue +12 -1
  91. package/pages/returns.vue +12 -0
  92. package/pages/terms-conditions.vue +6 -6
  93. package/pages/wishlist.vue +2 -2
  94. package/utils/image.ts +0 -1
  95. package/utils/seo-urls.ts +70 -0
  96. package/utils/store-origin.ts +36 -0
  97. package/components/AboutUs/AboutUsTuan.vue +0 -25
  98. package/components/AboutUs/Statistics.vue +0 -42
  99. package/components/AboutUs/SubscribeToTheNewsletter.vue +0 -57
  100. package/components/AddAddress/index.vue +0 -68
  101. package/components/BestSellers/Products.vue +0 -1562
  102. package/components/BestSellers/RecentlyViewed.vue +0 -304
  103. package/components/ChangePassword/index.vue +0 -69
  104. package/components/Common/DashboardNavigation.vue +0 -35
  105. package/components/Dashboard/RecentOrder.vue +0 -173
  106. package/components/Dashboard/index.vue +0 -77
  107. package/components/EditAddress/index.vue +0 -117
  108. package/components/EditProfile/index.vue +0 -95
  109. package/components/FeaturedProduct/FeaturedProducts.vue +0 -304
  110. package/components/FeaturedProduct/Products.vue +0 -1562
  111. package/components/ForgotPassword/index.vue +0 -49
  112. package/components/Layout/FooterTwo.vue +0 -159
  113. package/components/Layout/LocationOption.vue +0 -178
  114. package/components/Layout/RTLSwitchBtn.vue +0 -40
  115. package/components/Login/index.vue +0 -89
  116. package/components/MyAccount/index.vue +0 -154
  117. package/components/NewArrivals/Products.vue +0 -1969
  118. package/components/NewArrivals/RecentlyViewed.vue +0 -304
  119. package/components/OrderDetails/index.vue +0 -75
  120. package/components/OrderHistory/index.vue +0 -195
  121. package/components/Products/RecentlyViewed.vue +0 -302
  122. package/components/Register/index.vue +0 -83
  123. package/components/TrendingProducts/FeaturedProducts.vue +0 -304
  124. package/components/TrendingProducts/Products.vue +0 -1564
  125. package/components/Wishlist/ProductQuantity.vue +0 -29
  126. package/layouts/inner.vue +0 -70
  127. package/locales/ar.json +0 -234
  128. package/locales/ru.json +0 -234
  129. package/pages/address.vue +0 -11
  130. package/pages/change-password.vue +0 -11
  131. package/pages/dashboard.vue +0 -11
  132. package/pages/edit-address.vue +0 -11
  133. package/pages/edit-profile.vue +0 -11
  134. package/pages/forgot-password.vue +0 -11
  135. package/pages/login.vue +0 -11
  136. package/pages/my-account.vue +0 -11
  137. package/pages/order-details.vue +0 -11
  138. package/pages/order-history.vue +0 -11
  139. package/pages/register.vue +0 -11
@@ -1,190 +1,252 @@
1
1
  <template>
2
2
  <div class="ptb-60">
3
3
  <div class="container">
4
- <div class="row">
5
- <div class="col-lg-5">
6
- <div class="checkout-content">
7
- <h3>{{ t('checkout.billingDetails') }}</h3>
8
- <div class="form-group mb-4">
9
- <label class="label">{{ t('checkout.firstName') }}</label>
10
- <input
11
- type="text"
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
- <div class="col-lg-7">
99
- <div class="your-order">
100
- <h3>{{ t('checkout.yourOrder') }}</h3>
101
-
102
- <div class="your-order-form table-responsive bg-fffbf5">
103
- <table class="table table-bordered">
104
- <thead>
105
- <tr>
106
- <th scope="col">{{ t('product.product') }}</th>
107
- <th scope="col">{{ t('product.quantity') }}</th>
108
- <th scope="col">{{ t('product.subtotal') }}</th>
109
- </tr>
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
- <div class="mt-5">
178
- <button class="btn btn-warning text-white">{{ t('checkout.placeOrder') }}</button>
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
- </div>
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
- <div class="d-flex justify-content-between align-items-center">
5
- <ul class="d-flex align-content-center list-unstyled mb-0 page-content">
6
- <li>
7
- <NuxtLink to="/" class="text-decoration-none">{{ t('nav.home') }}</NuxtLink>
8
- </li>
9
- <li>
10
- <span>{{ pageTitle }}</span>
11
- </li>
12
- </ul>
13
- <div class="d-flex align-items-center">
14
- <i class="ri-arrow-left-s-line"></i>
15
- <i class="ri-layout-grid-line"></i>
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>