@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.
Files changed (138) 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 +66 -29
  57. package/composables/useCategoryImages.ts +45 -0
  58. package/composables/useCheckout.ts +302 -95
  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 -81
  64. package/composables/useRecent.ts +30 -0
  65. package/composables/useSanitize.ts +76 -0
  66. package/composables/useSiteConfig.ts +51 -45
  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/seo-urls.ts +70 -0
  95. package/utils/store-origin.ts +36 -0
  96. package/components/AboutUs/AboutUsTuan.vue +0 -25
  97. package/components/AboutUs/Statistics.vue +0 -42
  98. package/components/AboutUs/SubscribeToTheNewsletter.vue +0 -57
  99. package/components/AddAddress/index.vue +0 -68
  100. package/components/BestSellers/Products.vue +0 -1562
  101. package/components/BestSellers/RecentlyViewed.vue +0 -304
  102. package/components/ChangePassword/index.vue +0 -69
  103. package/components/Common/DashboardNavigation.vue +0 -35
  104. package/components/Dashboard/RecentOrder.vue +0 -173
  105. package/components/Dashboard/index.vue +0 -77
  106. package/components/EditAddress/index.vue +0 -117
  107. package/components/EditProfile/index.vue +0 -95
  108. package/components/FeaturedProduct/FeaturedProducts.vue +0 -304
  109. package/components/FeaturedProduct/Products.vue +0 -1562
  110. package/components/ForgotPassword/index.vue +0 -49
  111. package/components/Layout/FooterTwo.vue +0 -159
  112. package/components/Layout/LocationOption.vue +0 -178
  113. package/components/Layout/RTLSwitchBtn.vue +0 -40
  114. package/components/Login/index.vue +0 -89
  115. package/components/MyAccount/index.vue +0 -154
  116. package/components/NewArrivals/Products.vue +0 -1969
  117. package/components/NewArrivals/RecentlyViewed.vue +0 -304
  118. package/components/OrderDetails/index.vue +0 -75
  119. package/components/OrderHistory/index.vue +0 -195
  120. package/components/Products/RecentlyViewed.vue +0 -302
  121. package/components/Register/index.vue +0 -83
  122. package/components/TrendingProducts/FeaturedProducts.vue +0 -304
  123. package/components/TrendingProducts/Products.vue +0 -1564
  124. package/components/Wishlist/ProductQuantity.vue +0 -29
  125. package/layouts/inner.vue +0 -70
  126. package/locales/ar.json +0 -234
  127. package/locales/ru.json +0 -234
  128. package/pages/address.vue +0 -11
  129. package/pages/change-password.vue +0 -11
  130. package/pages/dashboard.vue +0 -11
  131. package/pages/edit-address.vue +0 -11
  132. package/pages/edit-profile.vue +0 -11
  133. package/pages/forgot-password.vue +0 -11
  134. package/pages/login.vue +0 -11
  135. package/pages/my-account.vue +0 -11
  136. package/pages/order-details.vue +0 -11
  137. package/pages/order-history.vue +0 -11
  138. package/pages/register.vue +0 -11
@@ -1,132 +1,339 @@
1
1
  import { ref } from 'vue';
2
- import { useCart } from './useCart';
2
+ import { getApiBaseUrl } from '../utils/api';
3
3
  import { useSiteConfig } from './useSiteConfig';
4
4
 
5
- interface ShippingAddress {
6
- firstName: string;
7
- lastName: string;
8
- email: string;
9
- phone: string;
10
- address: string;
11
- city: string;
12
- postalCode: string;
13
- country: string;
14
- notes?: string;
5
+ // Способи доставки та оплати, що приходять від бекенда (лише активні в продавця)
6
+ export interface PickupPoint {
7
+ name: string;
8
+ city?: string;
9
+ address?: string;
10
+ workingHours?: string;
15
11
  }
16
-
17
- interface OrderData {
18
- items: Array<{
19
- productId: string;
20
- quantity: number;
21
- price: number;
22
- }>;
23
- shippingAddress: ShippingAddress;
24
- paymentMethod: string;
25
- deliveryMethod?: string;
26
- 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 {
27
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;
28
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('');
29
95
 
30
- const loading = ref(false);
31
- const error = ref<string | null>(null);
32
- 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
+ };
33
105
 
34
106
  export const useCheckout = () => {
35
- const { cart, clearCart } = useCart();
36
- const { organizationId } = useSiteConfig();
37
-
38
- // Submit order
39
- const submitOrder = async (
40
- shippingAddress: ShippingAddress,
41
- paymentMethod: string,
42
- deliveryMethod?: string
43
- ) => {
44
- loading.value = true;
45
- error.value = null;
107
+ const { publicApiToken } = useSiteConfig();
108
+ const baseURL = getApiBaseUrl();
109
+ const authHeaders = () => (publicApiToken.value ? { 'X-Store-Token': publicApiToken.value } : {});
110
+
46
111
 
112
+ const loadOptions = async () => {
113
+ if (options.value) return options.value;
114
+ loadingOptions.value = true;
47
115
  try {
48
- const orderData: OrderData = {
49
- items: cart.value.items.map(item => ({
50
- productId: item.productId,
51
- quantity: item.quantity,
52
- price: item.discountPrice || item.price,
53
- })),
54
- shippingAddress,
55
- paymentMethod,
56
- deliveryMethod,
57
- totalAmount: cart.value.items.reduce(
58
- (sum, item) => sum + (item.discountPrice || item.price) * item.quantity,
59
- 0
60
- ),
61
- 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: [] },
62
126
  };
127
+ } finally {
128
+ loadingOptions.value = false;
129
+ }
130
+ return options.value;
131
+ };
63
132
 
64
- const response = await $fetch<{ id: string }>('/order', {
65
- method: 'POST',
66
- baseURL: getApiBaseUrl(),
67
- 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() },
68
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
+ };
69
161
 
70
- orderId.value = response.id;
71
- clearCart();
72
- return response;
73
- } catch (e: any) {
74
- error.value = e.message || 'Failed to submit order';
75
- console.error('Error submitting order:', e);
76
- 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 = [];
77
175
  } finally {
78
- loading.value = false;
176
+ loadingWarehouses.value = false;
79
177
  }
80
178
  };
81
179
 
82
- // Get order by ID
83
- const getOrder = async (id: string) => {
84
- loading.value = true;
85
- 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
+ });
86
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 = '';
87
208
  try {
88
- const response = await $fetch(`/order/${id}`, {
89
- 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 } : {}) },
90
214
  });
91
-
92
- 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 };
93
231
  } catch (e: any) {
94
- error.value = e.message || 'Failed to load order';
95
- console.error('Error loading order:', e);
96
- 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 };
97
236
  } finally {
98
- loading.value = false;
237
+ promoChecking.value = false;
99
238
  }
100
239
  };
101
240
 
102
- // Validate checkout data
103
- const validateCheckout = (shippingAddress: ShippingAddress) => {
104
- const errors: string[] = [];
241
+ const clearPromo = () => {
242
+ promo.value = null;
243
+ promoError.value = '';
244
+ promoFromOrder.value = '';
245
+ };
105
246
 
106
- if (!shippingAddress.firstName) errors.push('First name is required');
107
- if (!shippingAddress.lastName) errors.push('Last name is required');
108
- if (!shippingAddress.email) errors.push('Email is required');
109
- if (!shippingAddress.phone) errors.push('Phone is required');
110
- if (!shippingAddress.address) errors.push('Address is required');
111
- if (!shippingAddress.city) errors.push('City is required');
112
- 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
+ };
113
260
 
114
- if (cart.value.items.length === 0) {
115
- 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;
116
267
  }
268
+ };
117
269
 
118
- return {
119
- valid: errors.length === 0,
120
- errors,
121
- };
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
+ }
122
308
  };
123
309
 
124
310
  return {
125
- loading,
126
- error,
127
- 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,
128
328
  submitOrder,
129
- getOrder,
130
- validateCheckout,
329
+ payOrder,
330
+ fetchOrderStatus,
331
+ promo,
332
+ promoChecking,
333
+ promoError,
334
+ promoFromOrder,
335
+ validatePromo,
336
+ clearPromo,
337
+ readOrderResult,
131
338
  };
132
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
+ };