@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,134 +1,339 @@
1
1
  import { ref } from 'vue';
2
- import { useRuntimeConfig } from '#imports';
3
- import { useCart } from './useCart';
2
+ import { getApiBaseUrl } from '../utils/api';
4
3
  import { useSiteConfig } from './useSiteConfig';
5
4
 
6
- interface ShippingAddress {
7
- firstName: string;
8
- lastName: string;
9
- email: string;
10
- phone: string;
11
- address: string;
12
- city: string;
13
- postalCode: string;
14
- country: string;
15
- notes?: string;
5
+ // Способи доставки та оплати, що приходять від бекенда (лише активні в продавця)
6
+ export interface PickupPoint {
7
+ name: string;
8
+ city?: string;
9
+ address?: string;
10
+ workingHours?: string;
16
11
  }
17
-
18
- interface OrderData {
19
- items: Array<{
20
- productId: string;
21
- quantity: number;
22
- price: number;
23
- }>;
24
- shippingAddress: ShippingAddress;
25
- paymentMethod: string;
26
- deliveryMethod?: string;
27
- totalAmount: number;
12
+ export interface DeliveryOption {
13
+ provider?: string;
14
+ value?: string;
15
+ label?: string;
16
+ time?: string;
17
+ price?: number;
18
+ courierDeliveryPrice?: number;
19
+ points?: PickupPoint[];
20
+ }
21
+ export interface OnlineProvider {
22
+ provider: string;
23
+ label: string;
24
+ }
25
+ export interface CheckoutOptions {
28
26
  organizationId: string;
27
+ delivery: DeliveryOption[];
28
+ payment: { cashOnDelivery: boolean; online: boolean; onlineProviders?: OnlineProvider[] };
29
+ }
30
+
31
+ // Застосований промокод (результат POST promo-code/validate)
32
+ export interface AppliedPromo {
33
+ code: string;
34
+ discountAmount: number;
35
+ freeShipping: boolean;
36
+ /** Знижка на замовлення в цілому чи на окремі товари */
37
+ appliesTo: 'order' | 'items';
38
+ itemDiscounts: { productId: string; amount: number }[];
39
+ /** Код персональний — сервер перевіряє його для email/телефону покупця */
40
+ requiresBuyer: boolean;
41
+ }
42
+ export interface PromoBuyer {
43
+ email?: string;
44
+ phone?: string;
29
45
  }
46
+ export interface PromoValidation {
47
+ valid: boolean;
48
+ message?: string;
49
+ discountAmount?: number;
50
+ freeShipping?: boolean;
51
+ code?: string;
52
+ appliesTo?: 'order' | 'items';
53
+ itemDiscounts?: { productId: string; amount: number }[];
54
+ requiresBuyer?: boolean;
55
+ }
56
+
57
+ // Підсумок створеного замовлення для сторінки order-success (sessionStorage, лише відображення)
58
+ export interface OrderResultSummary {
59
+ orderId: string;
60
+ discount: number;
61
+ promoCode: string;
62
+ total: number | null;
63
+ }
64
+ const LAST_ORDER_KEY = 'lastOrderSummary';
65
+
66
+ const PROVIDER_LABELS: Record<string, string> = {
67
+ nova_poshta: 'Нова Пошта',
68
+ ukrposhta: 'Укрпошта',
69
+ courier: "Кур'єр",
70
+ meest: 'Meest',
71
+ pickup: 'Самовивіз',
72
+ };
73
+ // Лише Нова Пошта має флоу «місто → відділення»; Укрпошта — адресна (з індексом)
74
+ const WAREHOUSE_PROVIDERS = ['nova_poshta'];
75
+ const NEEDS_POSTCODE = ['ukrposhta'];
76
+ // Валідні значення DeliveryMethodEnum на бекенді
77
+ const VALID_DELIVERY = ['pickup', 'courier', 'post', 'express', 'ukrposhta', 'nova_poshta'];
78
+
79
+ // Стан спільний між компонентами оформлення (форма, доставка, підсумок)
80
+ const options = ref<CheckoutOptions | null>(null);
81
+ const loadingOptions = ref(false);
82
+ const cities = ref<any[]>([]);
83
+ const searchingCities = ref(false);
84
+ const citiesError = ref(''); // помилка пошуку міст (API перевізника недоступне / ключ невалідний)
85
+ const warehouses = ref<any[]>([]);
86
+ const loadingWarehouses = ref(false);
87
+ const submitting = ref(false);
88
+ const errorMsg = ref('');
89
+ const errorCode = ref('');
90
+ const promo = ref<AppliedPromo | null>(null);
91
+ const promoChecking = ref(false);
92
+ const promoError = ref('');
93
+ // Код, який відхилив сервер при створенні замовлення (щоб повернути його в поле)
94
+ const promoFromOrder = ref('');
30
95
 
31
- const loading = ref(false);
32
- const error = ref<string | null>(null);
33
- const orderId = ref<string | null>(null);
96
+ // Помилка створення замовлення стосується промокоду?
97
+ const isPromoError = (code: string, text: string) =>
98
+ /promo|coupon|discount/i.test(code) || /промокод|promo|код|використан|мінімальн/i.test(text);
99
+
100
+ // Сума знижки з відповіді бека: число або об'єкт { amount | value }
101
+ const toAmount = (v: any): number => {
102
+ const n = Number(typeof v === 'object' && v !== null ? v.amount ?? v.value ?? v.total : v);
103
+ return Number.isFinite(n) && n > 0 ? n : 0;
104
+ };
34
105
 
35
106
  export const useCheckout = () => {
36
- const config = useRuntimeConfig();
37
- const { cart, clearCart } = useCart();
38
- const { organizationId } = useSiteConfig();
39
-
40
- // Submit order
41
- const submitOrder = async (
42
- shippingAddress: ShippingAddress,
43
- paymentMethod: string,
44
- deliveryMethod?: string
45
- ) => {
46
- loading.value = true;
47
- error.value = null;
107
+ const { publicApiToken } = useSiteConfig();
108
+ const baseURL = getApiBaseUrl();
109
+ const authHeaders = () => (publicApiToken.value ? { 'X-Store-Token': publicApiToken.value } : {});
110
+
48
111
 
112
+ const loadOptions = async () => {
113
+ if (options.value) return options.value;
114
+ loadingOptions.value = true;
49
115
  try {
50
- const orderData: OrderData = {
51
- items: cart.value.items.map(item => ({
52
- productId: item.productId,
53
- quantity: item.quantity,
54
- price: item.discountPrice || item.price,
55
- })),
56
- shippingAddress,
57
- paymentMethod,
58
- deliveryMethod,
59
- totalAmount: cart.value.items.reduce(
60
- (sum, item) => sum + (item.discountPrice || item.price) * item.quantity,
61
- 0
62
- ),
63
- organizationId: organizationId.value!,
116
+ options.value = await $fetch<CheckoutOptions>('/site-template/public/store/checkout-options', {
117
+ baseURL,
118
+ headers: authHeaders(),
119
+ });
120
+ } catch {
121
+ // Фолбек: лишаємо післяплату, щоб оформлення не блокувалось
122
+ options.value = {
123
+ organizationId: '',
124
+ delivery: [],
125
+ payment: { cashOnDelivery: true, online: false, onlineProviders: [] },
64
126
  };
127
+ } finally {
128
+ loadingOptions.value = false;
129
+ }
130
+ return options.value;
131
+ };
65
132
 
66
- const response = await $fetch<{ id: string }>('/order', {
67
- method: 'POST',
68
- baseURL: getApiBaseUrl(),
69
- body: orderData,
133
+ const providerLabel = (p: string) => PROVIDER_LABELS[p] || p;
134
+ const hasWarehouseFlow = (p: string) => WAREHOUSE_PROVIDERS.includes(p);
135
+ const needsPostcode = (p: string) => NEEDS_POSTCODE.includes(p);
136
+ const normalizeDelivery = (p: string) => (VALID_DELIVERY.includes(p) ? p : 'courier');
137
+
138
+ // ── Пошук міст ──
139
+ const searchCities = async (provider: string, q: string) => {
140
+ const orgId = options.value?.organizationId;
141
+ if (!orgId || !q || q.trim().length < 2) {
142
+ cities.value = [];
143
+ return;
144
+ }
145
+ searchingCities.value = true;
146
+ citiesError.value = '';
147
+ try {
148
+ const res = await $fetch<any>(`/delivery/cities/${orgId}/${provider}`, {
149
+ baseURL,
150
+ params: { search: q.trim() },
70
151
  });
152
+ cities.value = Array.isArray(res) ? res : res?.cities || [];
153
+ } catch {
154
+ cities.value = [];
155
+ // Показуємо покупцю зрозумілу причину замість мовчазного порожнього списку
156
+ citiesError.value = 'Не вдалося завантажити список міст. Спробуйте ще раз або оберіть інший спосіб доставки.';
157
+ } finally {
158
+ searchingCities.value = false;
159
+ }
160
+ };
71
161
 
72
- orderId.value = response.id;
73
- clearCart();
74
- return response;
75
- } catch (e: any) {
76
- error.value = e.message || 'Failed to submit order';
77
- console.error('Error submitting order:', e);
78
- throw e;
162
+ // ── Відділення обраного міста ──
163
+ const loadWarehouses = async (provider: string, cityRef: string) => {
164
+ const orgId = options.value?.organizationId;
165
+ if (!orgId || !cityRef) {
166
+ warehouses.value = [];
167
+ return;
168
+ }
169
+ loadingWarehouses.value = true;
170
+ try {
171
+ const res = await $fetch<any>(`/delivery/warehouses/${orgId}/${provider}/${cityRef}`, { baseURL });
172
+ warehouses.value = Array.isArray(res) ? res : res?.warehouses || [];
173
+ } catch {
174
+ warehouses.value = [];
79
175
  } finally {
80
- loading.value = false;
176
+ loadingWarehouses.value = false;
81
177
  }
82
178
  };
83
179
 
84
- // Get order by ID
85
- const getOrder = async (id: string) => {
86
- loading.value = true;
87
- error.value = null;
180
+ // ── Онлайн-оплата: повертає { paymentUrl } ──
181
+ const payOrder = async (orderId: string, provider?: string) => {
182
+ const origin = typeof window !== 'undefined' ? window.location.origin : '';
183
+ const resultUrl = origin ? `${origin}/order-success?paid=1&order=${orderId}` : undefined;
184
+ return await $fetch<any>(`/site-template/public/store/order/${orderId}/pay`, {
185
+ method: 'POST',
186
+ baseURL,
187
+ headers: authHeaders(),
188
+ body: { successUrl: resultUrl, failureUrl: resultUrl, provider },
189
+ });
190
+ };
191
+
192
+ const fetchOrderStatus = async (orderId: string) =>
193
+ await $fetch<any>(`/site-template/public/store/order/${orderId}/status`, {
194
+ baseURL,
195
+ headers: authHeaders(),
196
+ });
88
197
 
198
+ // ── Промокод: перевірка на сервері (сума знижки рахується там же) ──
199
+ const validatePromo = async (
200
+ code: string,
201
+ items: { productId: string; quantity: number }[],
202
+ buyer?: PromoBuyer,
203
+ ): Promise<PromoValidation> => {
204
+ const clean = code.trim();
205
+ if (!clean) return { valid: false };
206
+ promoChecking.value = true;
207
+ promoError.value = '';
89
208
  try {
90
- const response = await $fetch(`/order/${id}`, {
91
- baseURL: getApiBaseUrl(),
209
+ const res = await $fetch<PromoValidation>('/site-template/public/store/promo-code/validate', {
210
+ method: 'POST',
211
+ baseURL,
212
+ headers: authHeaders(),
213
+ body: { code: clean, items, ...(buyer && (buyer.email || buyer.phone) ? { buyer } : {}) },
92
214
  });
93
-
94
- return response;
215
+ if (res?.valid) {
216
+ promo.value = {
217
+ code: String(res.code || clean),
218
+ discountAmount: toAmount(res.discountAmount),
219
+ freeShipping: !!res.freeShipping,
220
+ appliesTo: res.appliesTo === 'items' ? 'items' : 'order',
221
+ itemDiscounts: Array.isArray(res.itemDiscounts)
222
+ ? res.itemDiscounts.map((d) => ({ productId: String(d.productId), amount: toAmount(d.amount) }))
223
+ : [],
224
+ requiresBuyer: !!res.requiresBuyer,
225
+ };
226
+ } else {
227
+ promo.value = null;
228
+ promoError.value = res?.message || '';
229
+ }
230
+ return res || { valid: false };
95
231
  } catch (e: any) {
96
- error.value = e.message || 'Failed to load order';
97
- console.error('Error loading order:', e);
98
- throw e;
232
+ promo.value = null;
233
+ const msg = e?.data?.message;
234
+ promoError.value = Array.isArray(msg) ? msg.join(', ') : typeof msg === 'string' ? msg : '';
235
+ return { valid: false, message: promoError.value };
99
236
  } finally {
100
- loading.value = false;
237
+ promoChecking.value = false;
101
238
  }
102
239
  };
103
240
 
104
- // Validate checkout data
105
- const validateCheckout = (shippingAddress: ShippingAddress) => {
106
- const errors: string[] = [];
241
+ const clearPromo = () => {
242
+ promo.value = null;
243
+ promoError.value = '';
244
+ promoFromOrder.value = '';
245
+ };
107
246
 
108
- if (!shippingAddress.firstName) errors.push('First name is required');
109
- if (!shippingAddress.lastName) errors.push('Last name is required');
110
- if (!shippingAddress.email) errors.push('Email is required');
111
- if (!shippingAddress.phone) errors.push('Phone is required');
112
- if (!shippingAddress.address) errors.push('Address is required');
113
- if (!shippingAddress.city) errors.push('City is required');
114
- if (!shippingAddress.postalCode) errors.push('Postal code is required');
247
+ // Підсумок замовлення для order-success (сервер — джерело правди: знижку беремо з відповіді)
248
+ const rememberOrderResult = (order: any) => {
249
+ const orderId = String(order?.orderId || order?.id || '');
250
+ if (!orderId) return;
251
+ const total = Number(order?.total ?? order?.totalAmount ?? order?.totalPrice);
252
+ const summary: OrderResultSummary = {
253
+ orderId,
254
+ discount: toAmount(order?.discount ?? order?.discountAmount),
255
+ promoCode: typeof order?.promoCode === 'string' ? order.promoCode : order?.promoCode?.code || '',
256
+ total: Number.isFinite(total) && total > 0 ? total : null,
257
+ };
258
+ try { sessionStorage.setItem(LAST_ORDER_KEY, JSON.stringify(summary)); } catch {}
259
+ };
115
260
 
116
- if (cart.value.items.length === 0) {
117
- errors.push('Cart is empty');
261
+ const readOrderResult = (orderId: string): OrderResultSummary | null => {
262
+ try {
263
+ const s = JSON.parse(sessionStorage.getItem(LAST_ORDER_KEY) || 'null');
264
+ return s && s.orderId === orderId ? s : null;
265
+ } catch {
266
+ return null;
118
267
  }
268
+ };
119
269
 
120
- return {
121
- valid: errors.length === 0,
122
- errors,
123
- };
270
+ // ── Створення замовлення (ціни рахує сервер) ──
271
+ const submitOrder = async (payload: Record<string, any>) => {
272
+ submitting.value = true;
273
+ errorMsg.value = '';
274
+ errorCode.value = '';
275
+ promoFromOrder.value = '';
276
+ try {
277
+ const order = await $fetch<any>('/site-template/public/store/order', {
278
+ method: 'POST',
279
+ baseURL,
280
+ headers: authHeaders(),
281
+ body: payload,
282
+ });
283
+ // Umami: конверсія «замовлення оформлено» (тихо ігноруємо, якщо трекер не завантажився)
284
+ try {
285
+ (window as any)?.umami?.track('order-created', { orderNumber: order?.orderNumber || '' });
286
+ } catch {}
287
+ rememberOrderResult(order);
288
+ return order;
289
+ } catch (e: any) {
290
+ const data = e?.data;
291
+ errorCode.value = data?.code || (data?.message && data.message.code) || '';
292
+ const msg = data?.message;
293
+ const text = Array.isArray(msg) ? msg.join(', ') : typeof msg === 'string' ? msg : '';
294
+ // 400 через промокод (прострочений, ліміт, не той покупець, мінімальна сума) —
295
+ // показуємо біля поля промокоду, а не загальною помилкою
296
+ const status = e?.statusCode || e?.status || e?.response?.status;
297
+ if (payload.promoCode && status === 400 && isPromoError(errorCode.value, text)) {
298
+ promo.value = null;
299
+ promoError.value = text || 'Промокод недійсний';
300
+ promoFromOrder.value = String(payload.promoCode);
301
+ } else {
302
+ errorMsg.value = text;
303
+ }
304
+ throw e;
305
+ } finally {
306
+ submitting.value = false;
307
+ }
124
308
  };
125
309
 
126
310
  return {
127
- loading,
128
- error,
129
- orderId,
311
+ options,
312
+ citiesError,
313
+ loadOptions,
314
+ loadingOptions,
315
+ providerLabel,
316
+ hasWarehouseFlow,
317
+ needsPostcode,
318
+ normalizeDelivery,
319
+ cities,
320
+ searchCities,
321
+ searchingCities,
322
+ warehouses,
323
+ loadWarehouses,
324
+ loadingWarehouses,
325
+ submitting,
326
+ errorMsg,
327
+ errorCode,
130
328
  submitOrder,
131
- getOrder,
132
- validateCheckout,
329
+ payOrder,
330
+ fetchOrderStatus,
331
+ promo,
332
+ promoChecking,
333
+ promoError,
334
+ promoFromOrder,
335
+ validatePromo,
336
+ clearPromo,
337
+ readOrderResult,
133
338
  };
134
339
  };
@@ -0,0 +1,57 @@
1
+ import { ref } from 'vue';
2
+ import { getApiBaseUrl } from '../utils/api';
3
+
4
+ /**
5
+ * Відправка звернення з форми «Напишіть нам»:
6
+ * POST /site-template/public/store/contact із store-токеном (X-Store-Token).
7
+ */
8
+ export const useContactForm = () => {
9
+ const { publicApiToken } = useSiteConfig();
10
+ const { t } = useI18n();
11
+ const form = ref({ name: '', email: '', phone: '', message: '' });
12
+ const loading = ref(false);
13
+ const sent = ref(false);
14
+ const error = ref('');
15
+
16
+ const isEmail = (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
17
+ const isPhone = (v: string) => /^\+?[\d\s()-]{9,18}$/.test(v);
18
+
19
+ const submit = async () => {
20
+ error.value = '';
21
+ const name = form.value.name.trim();
22
+ const email = form.value.email.trim();
23
+ const phone = form.value.phone.trim();
24
+ const message = form.value.message.trim();
25
+ // Контракт бека (StorefrontContactDto): name, email і message обов'язкові, phone опційний
26
+ if (!name || !email || !message) {
27
+ error.value = t('pages.contact.errRequired');
28
+ return;
29
+ }
30
+ if (!isEmail(email)) {
31
+ error.value = t('pages.contact.errEmail');
32
+ return;
33
+ }
34
+ if (phone && !isPhone(phone)) {
35
+ error.value = t('pages.contact.errPhone');
36
+ return;
37
+ }
38
+ loading.value = true;
39
+ try {
40
+ await $fetch('/site-template/public/store/contact', {
41
+ method: 'POST',
42
+ baseURL: getApiBaseUrl(),
43
+ headers: publicApiToken.value ? { 'X-Store-Token': publicApiToken.value } : {},
44
+ body: { name, email, phone: phone || undefined, message },
45
+ });
46
+ sent.value = true;
47
+ form.value = { name: '', email: '', phone: '', message: '' };
48
+ } catch (e: any) {
49
+ const msg = e?.data?.message;
50
+ error.value = Array.isArray(msg) ? msg.join(', ') : msg || t('pages.contact.errSend');
51
+ } finally {
52
+ loading.value = false;
53
+ }
54
+ };
55
+
56
+ return { form, loading, sent, error, submit };
57
+ };