@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,134 +1,339 @@
|
|
|
1
1
|
import { ref } from 'vue';
|
|
2
|
-
import {
|
|
3
|
-
import { useCart } from './useCart';
|
|
2
|
+
import { getApiBaseUrl } from '../utils/api';
|
|
4
3
|
import { useSiteConfig } from './useSiteConfig';
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
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
|
|
37
|
-
const
|
|
38
|
-
const {
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
176
|
+
loadingWarehouses.value = false;
|
|
81
177
|
}
|
|
82
178
|
};
|
|
83
179
|
|
|
84
|
-
//
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
237
|
+
promoChecking.value = false;
|
|
101
238
|
}
|
|
102
239
|
};
|
|
103
240
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
241
|
+
const clearPromo = () => {
|
|
242
|
+
promo.value = null;
|
|
243
|
+
promoError.value = '';
|
|
244
|
+
promoFromOrder.value = '';
|
|
245
|
+
};
|
|
107
246
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (!
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
+
};
|