@osimart/create-storefront 1.0.2 → 1.0.4
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/app/app.vue +0 -1
- package/app/composables/useCheckout.js +927 -48
- package/app/pages/checkout.vue +370 -1242
- package/app/services/CheckoutServices.js +7 -5
- package/nuxt.config.ts +7 -1
- package/package.json +1 -1
|
@@ -1,76 +1,955 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import { authBus } from "
|
|
1
|
+
import checkoutServices from "~/services/CheckoutServices";
|
|
2
|
+
import utilsServices from "~/services/UtilsServices";
|
|
3
|
+
import paymentServices from "~/services/PaymentServices";
|
|
4
|
+
import { authBus } from "~/services/AuthBus";
|
|
5
5
|
import { useToast } from "vue-toastification";
|
|
6
|
+
import { eventBus } from "~/utils/eventBus";
|
|
7
|
+
import { scrollToFirstError } from "~/utils/scrollToError";
|
|
8
|
+
import { useUserStore } from "~/stores/userStore";
|
|
9
|
+
import { analytics } from "~/utils/analytics";
|
|
6
10
|
|
|
11
|
+
// ------------------------------------------------------------------
|
|
12
|
+
// Helpers
|
|
13
|
+
// ------------------------------------------------------------------
|
|
14
|
+
export function extractErrorMessage(error) {
|
|
15
|
+
const data = error?.data ?? error?.response?.data ?? error;
|
|
16
|
+
|
|
17
|
+
if (Array.isArray(data) && data.length > 0) {
|
|
18
|
+
return data.length === 1 ? data[0] : data.map((e) => `• ${e}`).join("\n");
|
|
19
|
+
}
|
|
20
|
+
if (data && typeof data === "object") {
|
|
21
|
+
const messages = Object.values(data).flat().filter(Boolean);
|
|
22
|
+
if (messages.length > 0) {
|
|
23
|
+
return messages.length === 1
|
|
24
|
+
? messages[0]
|
|
25
|
+
: messages.map((m) => `• ${m}`).join("\n");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (typeof data === "string" && data) return data;
|
|
29
|
+
|
|
30
|
+
return error?.message || "An error occurred";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function errorData(error) {
|
|
34
|
+
return error?.data ?? error?.response?.data ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Paginated { results: [...] } or a bare array -> array.
|
|
38
|
+
function unwrapList(response) {
|
|
39
|
+
return response?.results || (Array.isArray(response) ? response : []);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
43
|
+
|
|
44
|
+
function emptyAddress() {
|
|
45
|
+
return {
|
|
46
|
+
country: "",
|
|
47
|
+
city: "",
|
|
48
|
+
address: "",
|
|
49
|
+
address_line2: "",
|
|
50
|
+
zone: "",
|
|
51
|
+
postal_code: "",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Everything the checkout page does: cart view, details/address form,
|
|
57
|
+
* shipping + free delivery, server-computed promo, extra fees, payment
|
|
58
|
+
* method, order placement and gateway handoff.
|
|
59
|
+
*
|
|
60
|
+
* State is local refs (not useState) on purpose: a promo, address or
|
|
61
|
+
* payment choice must not survive navigating away and back.
|
|
62
|
+
*
|
|
63
|
+
* Call once, from pages/checkout.vue. Registers its own onMounted.
|
|
64
|
+
*/
|
|
7
65
|
export function useCheckout() {
|
|
66
|
+
const toast = useToast();
|
|
8
67
|
const router = useRouter();
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
68
|
+
const userStore = useUserStore();
|
|
69
|
+
const { store } = useStorefront();
|
|
70
|
+
const { mediaUrl } = useMediaUrl();
|
|
71
|
+
|
|
72
|
+
// The one cart. Every mutation writes through useCart(), so the drawer
|
|
73
|
+
// and header badge move with the checkout page.
|
|
74
|
+
const {
|
|
75
|
+
cart: rawCart,
|
|
76
|
+
cartItems,
|
|
77
|
+
cartTotal,
|
|
78
|
+
loading: cartLoading,
|
|
79
|
+
isUpdating,
|
|
80
|
+
fetchCart,
|
|
81
|
+
editItem,
|
|
82
|
+
removeItem,
|
|
83
|
+
} = useCart();
|
|
84
|
+
|
|
85
|
+
const currency = computed(
|
|
86
|
+
() => store.value?.default_currency?.symbol || store.value?.default_currency?.code || "$",
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// ================================================================
|
|
90
|
+
// STATE
|
|
91
|
+
// ================================================================
|
|
92
|
+
|
|
93
|
+
// --- UI ---
|
|
94
|
+
const mobileSummaryOpen = ref(false);
|
|
95
|
+
const bottomDiscountOpen = ref(false); // bottom pay bar: line items + promo, Shopify-style
|
|
96
|
+
|
|
97
|
+
// --- user ---
|
|
98
|
+
const isLoggedIn = ref(false);
|
|
99
|
+
const needsUserInfo = ref(true);
|
|
100
|
+
const userAddresses = ref([]);
|
|
101
|
+
|
|
102
|
+
// --- reference data ---
|
|
103
|
+
const countries = ref([]);
|
|
104
|
+
const paymentMethods = ref([]);
|
|
105
|
+
const extraFees = ref([]);
|
|
106
|
+
|
|
107
|
+
// --- form ---
|
|
108
|
+
const formData = reactive({
|
|
109
|
+
first_name: "",
|
|
110
|
+
last_name: "",
|
|
111
|
+
email: "",
|
|
112
|
+
mobile_number: "",
|
|
113
|
+
signedUpForExclusiveContent: false,
|
|
114
|
+
payment_method_id: "",
|
|
115
|
+
// object = new address, string = saved address id, null = nothing selected
|
|
116
|
+
address: emptyAddress(),
|
|
117
|
+
});
|
|
118
|
+
const formErrors = ref({}); // keyed by each field's data-field name
|
|
119
|
+
const phoneNumberResults = ref(null); // validity payload from PhoneNumInput @update
|
|
120
|
+
|
|
121
|
+
// --- address / shipping ---
|
|
122
|
+
const selectedCountry = ref("");
|
|
123
|
+
const selectedZone = ref("");
|
|
124
|
+
const countryAutoSet = ref(false);
|
|
125
|
+
const selectedAddressIndex = ref(null);
|
|
126
|
+
const showAddressForm = ref(false);
|
|
127
|
+
const isShippingLoading = ref(false);
|
|
128
|
+
const isFreeDelivery = ref(false);
|
|
129
|
+
const freeDeliveryId = ref(null);
|
|
130
|
+
|
|
131
|
+
// --- extra fees (donations) ---
|
|
132
|
+
const extraFeesPayload = ref([]);
|
|
133
|
+
const extraFeesTotal = ref(0);
|
|
134
|
+
const extraFeesValid = ref(true);
|
|
135
|
+
const extraFeeLines = ref([]);
|
|
136
|
+
|
|
137
|
+
// --- payment / order ---
|
|
138
|
+
const selectedPaymentMethod = ref(null);
|
|
139
|
+
const isCheckingOut = ref(false);
|
|
140
|
+
const activePayment = ref(null); // set once an order is placed and a gateway takes over
|
|
141
|
+
const placedOrderId = ref(null); // read by onPaymentSettled after the modal closes
|
|
142
|
+
|
|
143
|
+
// --- promo (server-computed; the frontend never calculates the discount) ---
|
|
144
|
+
const promoCode = ref(""); // what the user typed
|
|
145
|
+
const appliedPromoCode = ref(""); // code the server accepted
|
|
146
|
+
const promoId = ref(null); // sent as promo_id at checkout
|
|
147
|
+
const discount = ref(0); // discount_total from the server, in currency
|
|
148
|
+
const totalAfterDiscount = ref(null); // subtotal after discount, from the server
|
|
149
|
+
const promoError = ref("");
|
|
150
|
+
const isCodeLoading = ref(false);
|
|
151
|
+
let promoSeq = 0; // drops stale responses when requests overlap
|
|
152
|
+
|
|
153
|
+
const isNewAddress = () =>
|
|
154
|
+
!!formData.address && typeof formData.address === "object";
|
|
155
|
+
|
|
156
|
+
// ================================================================
|
|
157
|
+
// COMPUTED
|
|
158
|
+
// ================================================================
|
|
159
|
+
// Template keeps its original names.
|
|
160
|
+
const CartItems = computed(() => cartItems.value);
|
|
161
|
+
const total_price = computed(() => cartTotal.value);
|
|
162
|
+
|
|
163
|
+
const totalItemsCount = computed(() =>
|
|
164
|
+
CartItems.value.reduce((sum, item) => sum + Number(item.quantity || 0), 0),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const isLoading = computed(() => cartLoading.value || needsUserInfo.value);
|
|
168
|
+
|
|
169
|
+
const selectedAddress = computed(() =>
|
|
170
|
+
selectedAddressIndex.value !== null
|
|
171
|
+
? userAddresses.value[selectedAddressIndex.value]
|
|
172
|
+
: null,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const zoneRequired = computed(
|
|
176
|
+
() =>
|
|
177
|
+
!!(
|
|
178
|
+
selectedCountry.value &&
|
|
179
|
+
selectedCountry.value.has_zones &&
|
|
180
|
+
selectedCountry.value.shipping_zones?.length
|
|
181
|
+
),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const countryOptions = computed(() =>
|
|
185
|
+
(countries.value || []).map((country) => ({
|
|
186
|
+
label: country.country_name,
|
|
187
|
+
value: country,
|
|
188
|
+
})),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const zoneOptions = computed(() =>
|
|
192
|
+
(selectedCountry.value?.shipping_zones || []).map((zone) => ({
|
|
193
|
+
label: zone.name,
|
|
194
|
+
value: zone,
|
|
195
|
+
})),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const shippingCost = computed(() => {
|
|
199
|
+
if (selectedAddress.value) return selectedAddress.value.shipping_fee || 0;
|
|
200
|
+
if (selectedZone.value?.shipment_price) return selectedZone.value.shipment_price;
|
|
201
|
+
if (selectedCountry.value) return selectedCountry.value.shipment_price || 0;
|
|
202
|
+
return 0;
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const effectiveShipping = computed(() =>
|
|
206
|
+
isFreeDelivery.value ? 0 : Number(shippingCost.value || 0),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// Total before the promo, for the strike-through.
|
|
210
|
+
const originalTotal = computed(() =>
|
|
211
|
+
Number(
|
|
212
|
+
(
|
|
213
|
+
Number(total_price.value || 0) +
|
|
214
|
+
effectiveShipping.value +
|
|
215
|
+
Number(extraFeesTotal.value || 0)
|
|
216
|
+
).toFixed(2),
|
|
217
|
+
),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
// Discount comes from the server (total_after_discount); never computed here.
|
|
221
|
+
const totalPrice = computed(() => {
|
|
222
|
+
const base =
|
|
223
|
+
promoId.value && totalAfterDiscount.value !== null
|
|
224
|
+
? totalAfterDiscount.value
|
|
225
|
+
: Number(total_price.value || 0);
|
|
226
|
+
return Number(
|
|
227
|
+
(base + effectiveShipping.value + Number(extraFeesTotal.value || 0)).toFixed(2),
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const anyItemUpdating = computed(() =>
|
|
232
|
+
CartItems.value.some((item) => isUpdating(item)),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// Block paying while the cart or promo is mid-update (stale total).
|
|
236
|
+
const canPay = computed(
|
|
237
|
+
() => !isCheckingOut.value && !isCodeLoading.value && !anyItemUpdating.value,
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// --- validation messages ---
|
|
241
|
+
const detailsErrorFields = computed(() => {
|
|
242
|
+
const labels = {
|
|
243
|
+
email_required: "Email",
|
|
244
|
+
first_name: "First name",
|
|
245
|
+
last_name: "Last name",
|
|
246
|
+
...(isLoggedIn.value ? {} : { mobile_number_required: "Phone number" }),
|
|
247
|
+
};
|
|
248
|
+
return Object.keys(labels)
|
|
249
|
+
.filter((key) => formErrors.value[key])
|
|
250
|
+
.map((key) => labels[key]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const deliveryErrorFields = computed(() => {
|
|
254
|
+
const labels = {
|
|
255
|
+
country: "Country",
|
|
256
|
+
zone: "Zone",
|
|
257
|
+
city: "City",
|
|
258
|
+
address: "Street & area",
|
|
259
|
+
};
|
|
260
|
+
return Object.keys(labels)
|
|
261
|
+
.filter((key) => formErrors.value[key])
|
|
262
|
+
.map((key) => labels[key]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Standalone message only for a filled-but-wrong email; an empty one joins
|
|
266
|
+
// the combined "fill all required fields" line instead.
|
|
267
|
+
const emailErrorMessage = computed(() => formErrors.value.email || "");
|
|
268
|
+
const emailHasError = computed(
|
|
269
|
+
() => !!(formErrors.value.email || formErrors.value.email_required),
|
|
270
|
+
);
|
|
271
|
+
const phoneErrorMessage = computed(() =>
|
|
272
|
+
isLoggedIn.value ? "" : formErrors.value.mobile_number || "",
|
|
273
|
+
);
|
|
274
|
+
const phoneHasError = computed(
|
|
275
|
+
() =>
|
|
276
|
+
!isLoggedIn.value &&
|
|
277
|
+
!!(formErrors.value.mobile_number || formErrors.value.mobile_number_required),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
// ================================================================
|
|
281
|
+
// MEDIA / CART
|
|
282
|
+
// ================================================================
|
|
283
|
+
const imageUrl = (path) => mediaUrl(path);
|
|
284
|
+
const isItemUpdating = (item) => isUpdating(item);
|
|
14
285
|
|
|
15
|
-
|
|
286
|
+
// Quantity changes go through useCart(). The resulting subtotal change
|
|
287
|
+
// triggers the promo re-check and free-delivery refresh (watch below).
|
|
288
|
+
async function mutateCart(fn, label) {
|
|
289
|
+
try {
|
|
290
|
+
await fn();
|
|
291
|
+
} catch (error) {
|
|
292
|
+
console.error(label, error);
|
|
293
|
+
toast.error(extractErrorMessage(error));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const increaseQuantity = (item) =>
|
|
297
|
+
mutateCart(() => editItem(item, "add"), "Error adding to cart:");
|
|
298
|
+
const decreaseQuantity = (item) =>
|
|
299
|
+
mutateCart(() => editItem(item, "remove"), "Error decreasing quantity:");
|
|
300
|
+
const deleteItem = (item) =>
|
|
301
|
+
mutateCart(() => removeItem(item), "Error removing from cart:");
|
|
302
|
+
|
|
303
|
+
// ================================================================
|
|
304
|
+
// REFERENCE DATA
|
|
305
|
+
// ================================================================
|
|
306
|
+
async function loadCountries() {
|
|
307
|
+
try {
|
|
308
|
+
countries.value = unwrapList(await utilsServices.getCountries());
|
|
309
|
+
} catch (error) {
|
|
310
|
+
console.error("[useCheckout] countries:", error);
|
|
311
|
+
countries.value = [];
|
|
312
|
+
}
|
|
313
|
+
}
|
|
16
314
|
|
|
17
|
-
|
|
18
|
-
|
|
315
|
+
// /payments/methods/ only returns active, fully configured methods.
|
|
316
|
+
async function loadPaymentMethods() {
|
|
19
317
|
try {
|
|
20
|
-
|
|
21
|
-
CheckoutServices.fetchCart(),
|
|
22
|
-
UtilsServices.getCountries(),
|
|
23
|
-
UtilsServices.getAvailablePaymentMethods(),
|
|
24
|
-
]);
|
|
25
|
-
|
|
26
|
-
if (cartRes.status === "fulfilled") cartData.value = cartRes.value;
|
|
27
|
-
if (countriesRes.status === "fulfilled") countries.value = countriesRes.value?.results || countriesRes.value || [];
|
|
28
|
-
if (paymentsRes.status === "fulfilled") paymentMethods.value = paymentsRes.value?.results || paymentsRes.value || [];
|
|
318
|
+
paymentMethods.value = unwrapList(await paymentServices.getMethods());
|
|
29
319
|
} catch (error) {
|
|
30
|
-
|
|
320
|
+
paymentMethods.value = [];
|
|
321
|
+
toast.error("Payment methods could not be loaded.");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function loadExtraFees() {
|
|
326
|
+
try {
|
|
327
|
+
extraFees.value = unwrapList(await checkoutServices.extraFees());
|
|
328
|
+
} catch (error) {
|
|
329
|
+
extraFees.value = [];
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ================================================================
|
|
334
|
+
// USER
|
|
335
|
+
// ================================================================
|
|
336
|
+
function handleUserInfo() {
|
|
337
|
+
if (!authBus || !authBus.isLoggedIn) {
|
|
338
|
+
needsUserInfo.value = false;
|
|
339
|
+
isLoggedIn.value = false;
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (authBus.userInfo) {
|
|
343
|
+
const info = authBus.userInfo;
|
|
344
|
+
isLoggedIn.value = authBus.isLoggedIn;
|
|
345
|
+
userAddresses.value = info.customer_addresses || [];
|
|
346
|
+
formData.first_name = info.first_name || "";
|
|
347
|
+
formData.last_name = info.last_name || "";
|
|
348
|
+
formData.email = info.email || "";
|
|
349
|
+
formData.mobile_number = info.mobile_number || "";
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function waitForUserInfoIfNeeded() {
|
|
354
|
+
if (!authBus.isLoggedIn) return;
|
|
355
|
+
|
|
356
|
+
await new Promise((resolve) => {
|
|
357
|
+
const checkInterval = setInterval(() => {
|
|
358
|
+
if (authBus.userInfo) {
|
|
359
|
+
clearInterval(checkInterval);
|
|
360
|
+
handleUserInfo();
|
|
361
|
+
resolve();
|
|
362
|
+
}
|
|
363
|
+
}, 0);
|
|
364
|
+
|
|
365
|
+
// Fallback in case user info never arrives
|
|
366
|
+
setTimeout(() => {
|
|
367
|
+
clearInterval(checkInterval);
|
|
368
|
+
needsUserInfo.value = false;
|
|
369
|
+
resolve();
|
|
370
|
+
}, 0);
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ================================================================
|
|
375
|
+
// ADDRESS / SHIPPING
|
|
376
|
+
// ================================================================
|
|
377
|
+
async function checkFreeDelivery(countryId) {
|
|
378
|
+
isShippingLoading.value = true;
|
|
379
|
+
isFreeDelivery.value = false;
|
|
380
|
+
freeDeliveryId.value = null;
|
|
381
|
+
try {
|
|
382
|
+
const results = unwrapList(await checkoutServices.freeDeliveries(countryId));
|
|
383
|
+
const rule = results.find((r) => total_price.value >= r.minimum_order_amount);
|
|
384
|
+
if (rule) {
|
|
385
|
+
isFreeDelivery.value = true;
|
|
386
|
+
freeDeliveryId.value = rule.id;
|
|
387
|
+
}
|
|
388
|
+
} catch (error) {
|
|
389
|
+
console.error(error);
|
|
390
|
+
isFreeDelivery.value = false;
|
|
391
|
+
freeDeliveryId.value = null;
|
|
31
392
|
} finally {
|
|
32
|
-
|
|
393
|
+
isShippingLoading.value = false;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Free delivery depends on the subtotal, so re-evaluate when the cart changes.
|
|
398
|
+
async function refreshFreeDelivery() {
|
|
399
|
+
const countryId = selectedAddress.value?.country || selectedCountry.value?.id;
|
|
400
|
+
if (countryId) await checkFreeDelivery(countryId);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function selectAddress(index) {
|
|
404
|
+
if (showAddressForm.value) return;
|
|
405
|
+
|
|
406
|
+
if (selectedAddressIndex.value === index) {
|
|
407
|
+
selectedAddressIndex.value = null;
|
|
408
|
+
formData.address = null;
|
|
409
|
+
isFreeDelivery.value = false;
|
|
410
|
+
freeDeliveryId.value = null;
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const address = userAddresses.value[index];
|
|
415
|
+
formData.address = address.id;
|
|
416
|
+
selectedAddressIndex.value = index;
|
|
417
|
+
if (address.country) await checkFreeDelivery(address.country);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function toggleAddressForm() {
|
|
421
|
+
selectedCountry.value = null;
|
|
422
|
+
selectedZone.value = null;
|
|
423
|
+
selectedAddressIndex.value = null;
|
|
424
|
+
|
|
425
|
+
if (showAddressForm.value) {
|
|
426
|
+
showAddressForm.value = false;
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
formData.address = emptyAddress();
|
|
430
|
+
showAddressForm.value = true;
|
|
431
|
+
countryAutoSet.value = false;
|
|
432
|
+
syncCountry();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Silently select the hidden "Default Zone" for countries that don't
|
|
436
|
+
// expose zones, so it still goes out in the payload.
|
|
437
|
+
function applyDefaultZone(country) {
|
|
438
|
+
const zones = country?.shipping_zones || [];
|
|
439
|
+
if (country && !country.has_zones && zones.length) {
|
|
440
|
+
selectedZone.value = zones[0];
|
|
441
|
+
if (isNewAddress()) formData.address.zone = zones[0].id;
|
|
442
|
+
} else {
|
|
443
|
+
selectedZone.value = "";
|
|
444
|
+
if (isNewAddress()) formData.address.zone = "";
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function selectCountry(country) {
|
|
449
|
+
countryAutoSet.value = true;
|
|
450
|
+
if (selectedCountry.value === country) {
|
|
451
|
+
selectedCountry.value = "";
|
|
452
|
+
if (isNewAddress()) formData.address.country = "";
|
|
453
|
+
isFreeDelivery.value = false;
|
|
454
|
+
applyDefaultZone(null);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
selectedCountry.value = country;
|
|
458
|
+
if (isNewAddress()) formData.address.country = country.id;
|
|
459
|
+
applyDefaultZone(country);
|
|
460
|
+
await checkFreeDelivery(country.id);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function selectZone(zone) {
|
|
464
|
+
if (selectedZone.value === zone) {
|
|
465
|
+
selectedZone.value = "";
|
|
466
|
+
if (isNewAddress()) formData.address.zone = "";
|
|
467
|
+
} else {
|
|
468
|
+
selectedZone.value = zone;
|
|
469
|
+
if (isNewAddress()) formData.address.zone = zone.id;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Pre-select the country from the visitor's IP, once.
|
|
474
|
+
function syncCountry() {
|
|
475
|
+
const ipCountryName =
|
|
476
|
+
userStore?.clientIpInfo?.country_name || userStore?.clientIpInfo?.country;
|
|
477
|
+
if (!ipCountryName || !countries.value?.length) return;
|
|
478
|
+
if (countryAutoSet.value || selectedCountry.value) return;
|
|
479
|
+
|
|
480
|
+
const target = String(ipCountryName).trim().toLowerCase();
|
|
481
|
+
const match = countries.value.find(
|
|
482
|
+
(c) =>
|
|
483
|
+
c.country_name?.trim().toLowerCase() === target ||
|
|
484
|
+
c.country_code?.trim().toLowerCase() === target ||
|
|
485
|
+
c.code?.trim().toLowerCase() === target,
|
|
486
|
+
);
|
|
487
|
+
if (match) {
|
|
488
|
+
selectCountry(match);
|
|
489
|
+
countryAutoSet.value = true;
|
|
33
490
|
}
|
|
34
491
|
}
|
|
35
492
|
|
|
36
|
-
|
|
37
|
-
|
|
493
|
+
// ================================================================
|
|
494
|
+
// PAYMENT METHOD
|
|
495
|
+
// ================================================================
|
|
496
|
+
const isSelected = (option) => selectedPaymentMethod.value === option.id;
|
|
497
|
+
|
|
498
|
+
function setPaymentMethod(optionId) {
|
|
499
|
+
const next = selectedPaymentMethod.value === optionId ? null : optionId;
|
|
500
|
+
selectedPaymentMethod.value = next;
|
|
501
|
+
formData.payment_method_id = next;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ================================================================
|
|
505
|
+
// EXTRA FEES
|
|
506
|
+
// ================================================================
|
|
507
|
+
function onExtraFeesChange({ payload, total, valid, lines }) {
|
|
508
|
+
extraFeesPayload.value = payload;
|
|
509
|
+
extraFeesTotal.value = total;
|
|
510
|
+
extraFeesValid.value = valid;
|
|
511
|
+
extraFeeLines.value = lines;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ================================================================
|
|
515
|
+
// PROMO
|
|
516
|
+
// ================================================================
|
|
517
|
+
async function checkPromo(code, { silent = false } = {}) {
|
|
518
|
+
const seq = ++promoSeq;
|
|
519
|
+
const wasApplied = !!promoId.value;
|
|
520
|
+
isCodeLoading.value = true;
|
|
521
|
+
promoError.value = "";
|
|
38
522
|
try {
|
|
39
|
-
const res = await
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
523
|
+
const res = await checkoutServices.applyPromoCode(code);
|
|
524
|
+
if (seq !== promoSeq) return;
|
|
525
|
+
promoId.value = res.id;
|
|
526
|
+
appliedPromoCode.value = res.code;
|
|
527
|
+
discount.value = Number(res.discount_total) || 0;
|
|
528
|
+
totalAfterDiscount.value =
|
|
529
|
+
res.total_after_discount != null ? Number(res.total_after_discount) : null;
|
|
530
|
+
if (!silent) toast.success("Promo code applied");
|
|
43
531
|
} catch (error) {
|
|
44
|
-
|
|
45
|
-
|
|
532
|
+
if (seq !== promoSeq) return;
|
|
533
|
+
const message = extractErrorMessage(error);
|
|
534
|
+
clearPromo();
|
|
535
|
+
promoCode.value = code; // keep it visible so the user sees what was removed
|
|
536
|
+
promoError.value = message;
|
|
537
|
+
if (silent && wasApplied) toast.warning(`Promo removed: ${message}`);
|
|
538
|
+
} finally {
|
|
539
|
+
if (seq === promoSeq) isCodeLoading.value = false;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function applyPromoCode() {
|
|
544
|
+
const code = (promoCode.value || "").trim();
|
|
545
|
+
if (!code || isCodeLoading.value) return;
|
|
546
|
+
return checkPromo(code);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Re-validate the applied code against the current cart.
|
|
550
|
+
function recheckPromo() {
|
|
551
|
+
if (!promoId.value || !appliedPromoCode.value) return;
|
|
552
|
+
return checkPromo(appliedPromoCode.value, { silent: true });
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function clearPromo() {
|
|
556
|
+
promoSeq++; // ignore any in-flight response
|
|
557
|
+
promoId.value = null;
|
|
558
|
+
appliedPromoCode.value = "";
|
|
559
|
+
discount.value = 0;
|
|
560
|
+
totalAfterDiscount.value = null;
|
|
561
|
+
isCodeLoading.value = false;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function removePromo() {
|
|
565
|
+
clearPromo();
|
|
566
|
+
promoCode.value = "";
|
|
567
|
+
promoError.value = "";
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// placeOrder rejected the promo (expired, limit reached, no longer applies):
|
|
571
|
+
// drop it so the totals match what the server will charge.
|
|
572
|
+
function handleOrderPromoError(error) {
|
|
573
|
+
const data = errorData(error);
|
|
574
|
+
if (!data?.promo_id) return false;
|
|
575
|
+
const message = [].concat(data.promo_id)[0];
|
|
576
|
+
clearPromo();
|
|
577
|
+
promoError.value = message;
|
|
578
|
+
toast.error(`${message} The promo was removed, please review your total.`);
|
|
579
|
+
return true;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ================================================================
|
|
583
|
+
// VALIDATION
|
|
584
|
+
// ================================================================
|
|
585
|
+
function validateForm() {
|
|
586
|
+
const errors = {};
|
|
587
|
+
|
|
588
|
+
if (!formData.first_name) errors.first_name = "First name is required.";
|
|
589
|
+
if (!formData.last_name) errors.last_name = "Last name is required.";
|
|
590
|
+
if (!formData.email) {
|
|
591
|
+
errors.email_required = true;
|
|
592
|
+
} else if (!EMAIL_PATTERN.test(formData.email)) {
|
|
593
|
+
errors.email = "Enter a valid email address.";
|
|
594
|
+
}
|
|
595
|
+
if (!formData.payment_method_id)
|
|
596
|
+
errors.payment_method_id = "Payment method is required.";
|
|
597
|
+
if (!isLoggedIn.value) {
|
|
598
|
+
if (!formData.mobile_number) {
|
|
599
|
+
errors.mobile_number_required = true;
|
|
600
|
+
} else if (!phoneNumberResults.value?.isValid) {
|
|
601
|
+
errors.mobile_number = "This phone number is not valid.";
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// New address (object) needs validating; a saved address id is complete.
|
|
606
|
+
if (!formData.address) {
|
|
607
|
+
errors.country = "Country is required.";
|
|
608
|
+
errors.city = "City is required.";
|
|
609
|
+
errors.address = "Street & area is required.";
|
|
610
|
+
} else if (isNewAddress()) {
|
|
611
|
+
if (!formData.address.country) errors.country = "Country is required.";
|
|
612
|
+
if (!formData.address.city) errors.city = "City is required.";
|
|
613
|
+
if (!formData.address.address) errors.address = "Street & area is required.";
|
|
614
|
+
if (zoneRequired.value && !selectedZone.value?.id)
|
|
615
|
+
errors.zone = "Please select a zone.";
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
formErrors.value = errors;
|
|
619
|
+
return Object.keys(errors).length === 0;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function clearFieldError(field) {
|
|
623
|
+
if (formErrors.value[field]) {
|
|
624
|
+
formErrors.value = { ...formErrors.value, [field]: "" };
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Only lifts the red state once the email is actually valid.
|
|
629
|
+
function validateEmailField() {
|
|
630
|
+
if (formData.email) clearFieldError("email_required");
|
|
631
|
+
if (formErrors.value.email && EMAIL_PATTERN.test(formData.email || "")) {
|
|
632
|
+
clearFieldError("email");
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// PhoneNumInput emits on every keystroke; errors only show on submit.
|
|
637
|
+
function onPhoneUpdate(results) {
|
|
638
|
+
phoneNumberResults.value = { ...results };
|
|
639
|
+
if (formData.mobile_number) clearFieldError("mobile_number_required");
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ================================================================
|
|
643
|
+
// ORDER
|
|
644
|
+
// ================================================================
|
|
645
|
+
function buildPayload() {
|
|
646
|
+
const cart = Object.fromEntries(
|
|
647
|
+
Object.entries(rawCart.value?.cart ?? {}).map(([id, item]) => [
|
|
648
|
+
id,
|
|
649
|
+
{ name: item.name, quantity: item.quantity, price: item.price, image: item.image },
|
|
650
|
+
]),
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
const payload = { payment_method_id: formData.payment_method_id, cart };
|
|
654
|
+
|
|
655
|
+
// Validated server-side against the store's own origins.
|
|
656
|
+
if (typeof window !== "undefined") {
|
|
657
|
+
payload.return_url = `${window.location.origin}/checkout/result`;
|
|
658
|
+
}
|
|
659
|
+
if (promoId.value) payload.promo_id = promoId.value;
|
|
660
|
+
if (freeDeliveryId.value) payload.freedelivery_id = freeDeliveryId.value;
|
|
661
|
+
if (extraFeesPayload.value.length > 0) payload.extra_fees = extraFeesPayload.value;
|
|
662
|
+
|
|
663
|
+
if (typeof formData.address === "string") {
|
|
664
|
+
payload.address_id = formData.address;
|
|
665
|
+
} else {
|
|
666
|
+
payload.address = { ...formData.address };
|
|
667
|
+
if (selectedZone.value?.id) payload.address.zone = selectedZone.value.id;
|
|
668
|
+
else delete payload.address.zone;
|
|
669
|
+
if (!payload.address.address_line2) delete payload.address.address_line2;
|
|
46
670
|
}
|
|
671
|
+
|
|
672
|
+
if (!isLoggedIn.value) {
|
|
673
|
+
payload.guest = {
|
|
674
|
+
first_name: formData.first_name,
|
|
675
|
+
last_name: formData.last_name,
|
|
676
|
+
email: formData.email,
|
|
677
|
+
mobile_number: formData.mobile_number,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
return payload;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function trackFunnel() {
|
|
684
|
+
const coupon = promoId.value ? appliedPromoCode.value || undefined : undefined;
|
|
685
|
+
const chosenMethod = paymentMethods.value.find(
|
|
686
|
+
(m) => m.id === formData.payment_method_id,
|
|
687
|
+
);
|
|
688
|
+
analytics.addShippingInfo(CartItems.value, {
|
|
689
|
+
tier:
|
|
690
|
+
selectedZone.value?.name ||
|
|
691
|
+
selectedCountry.value?.country_name ||
|
|
692
|
+
selectedAddress.value?.country_name ||
|
|
693
|
+
undefined,
|
|
694
|
+
coupon,
|
|
695
|
+
});
|
|
696
|
+
analytics.addPaymentInfo(CartItems.value, {
|
|
697
|
+
paymentType: chosenMethod?.name,
|
|
698
|
+
coupon,
|
|
699
|
+
});
|
|
47
700
|
}
|
|
48
701
|
|
|
49
|
-
async function
|
|
50
|
-
|
|
51
|
-
|
|
702
|
+
async function submitForm() {
|
|
703
|
+
if (!canPay.value) return;
|
|
704
|
+
|
|
705
|
+
if (!validateForm()) {
|
|
706
|
+
await nextTick();
|
|
707
|
+
scrollToFirstError(formErrors.value);
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
if (extraFeesPayload.value.length > 0 && !extraFeesValid.value) {
|
|
711
|
+
toast.error("Check the donation amount before completing your order.");
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const payload = buildPayload();
|
|
716
|
+
trackFunnel();
|
|
717
|
+
|
|
52
718
|
try {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
719
|
+
isCheckingOut.value = true;
|
|
720
|
+
const response = await checkoutServices.placeOrder(payload);
|
|
721
|
+
placedOrderId.value = response.order_id;
|
|
722
|
+
sessionStorage.setItem("orderId", response.order_id);
|
|
723
|
+
|
|
724
|
+
// No fallback to OFFLINE: treating an unknown response as paid would
|
|
725
|
+
// clear the bag for an order nobody has paid for.
|
|
726
|
+
const payment = response.payment;
|
|
727
|
+
if (!payment?.flow) {
|
|
728
|
+
console.error("checkout returned no payment object", response);
|
|
729
|
+
toast.error("Payment could not be started. Please try again.");
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (payment.reference) paymentServices.setReference(payment.reference);
|
|
733
|
+
|
|
734
|
+
if (payment.flow === "OFFLINE") {
|
|
735
|
+
toast.success("Order added successfully!");
|
|
736
|
+
paymentServices.clearReference();
|
|
737
|
+
eventBus.emit("cartUpdated");
|
|
738
|
+
router.replace({
|
|
739
|
+
path: "/order-detail",
|
|
740
|
+
query: { id: placedOrderId.value, placed: "1" },
|
|
741
|
+
});
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Card / redirect: hand off to the gateway.
|
|
746
|
+
activePayment.value = payment;
|
|
57
747
|
} catch (error) {
|
|
58
|
-
|
|
59
|
-
|
|
748
|
+
console.error("Error placing order:", error);
|
|
749
|
+
if (handleOrderPromoError(error)) return;
|
|
750
|
+
toast.error(extractErrorMessage(error));
|
|
60
751
|
} finally {
|
|
61
|
-
|
|
752
|
+
isCheckingOut.value = false;
|
|
62
753
|
}
|
|
63
754
|
}
|
|
64
755
|
|
|
756
|
+
// ================================================================
|
|
757
|
+
// GATEWAY HANDOFF
|
|
758
|
+
// ================================================================
|
|
759
|
+
function onPaymentError(message) {
|
|
760
|
+
toast.error(message);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Fired ONLY when the server confirmed the charge. Goes straight to the
|
|
764
|
+
// order: this flow already verified in the modal, and clearReference()
|
|
765
|
+
// leaves /checkout/result with nothing to check.
|
|
766
|
+
function onPaymentSettled() {
|
|
767
|
+
paymentServices.clearReference();
|
|
768
|
+
eventBus.emit("cartUpdated");
|
|
769
|
+
activePayment.value = null;
|
|
770
|
+
router.replace({
|
|
771
|
+
path: "/order-detail",
|
|
772
|
+
query: { id: placedOrderId.value, placed: "1" },
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Bank refused: the order exists unpaid, the bag stays. A retry needs a
|
|
777
|
+
// fresh capture context, so return to the form.
|
|
778
|
+
function onPaymentDeclined(reason) {
|
|
779
|
+
activePayment.value = null;
|
|
780
|
+
isCheckingOut.value = false;
|
|
781
|
+
toast.error(reason || "Your payment was declined. Please try another card.");
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function closePayment() {
|
|
785
|
+
activePayment.value = null;
|
|
786
|
+
isCheckingOut.value = false;
|
|
787
|
+
toast.info("Your order is saved but unpaid. You can pay again.");
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ================================================================
|
|
791
|
+
// ACCORDION (height-based enter/leave for the mobile summaries)
|
|
792
|
+
// ================================================================
|
|
793
|
+
function onAccordionEnd(el, done) {
|
|
794
|
+
let finished = false;
|
|
795
|
+
const finish = (event) => {
|
|
796
|
+
if (event && (event.target !== el || event.propertyName !== "height")) return;
|
|
797
|
+
if (finished) return;
|
|
798
|
+
finished = true;
|
|
799
|
+
clearTimeout(timer);
|
|
800
|
+
el.removeEventListener("transitionend", finish);
|
|
801
|
+
done();
|
|
802
|
+
};
|
|
803
|
+
const timer = setTimeout(finish, 400);
|
|
804
|
+
el.addEventListener("transitionend", finish);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function accordionEnter(el, done) {
|
|
808
|
+
el.style.overflow = "hidden";
|
|
809
|
+
el.style.height = "0px";
|
|
810
|
+
el.style.opacity = "0";
|
|
811
|
+
void el.offsetHeight;
|
|
812
|
+
el.style.transition = "height 0.3s ease, opacity 0.25s ease";
|
|
813
|
+
el.style.height = `${el.scrollHeight}px`;
|
|
814
|
+
el.style.opacity = "1";
|
|
815
|
+
onAccordionEnd(el, done);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function accordionLeave(el, done) {
|
|
819
|
+
el.style.overflow = "hidden";
|
|
820
|
+
el.style.height = `${el.scrollHeight}px`;
|
|
821
|
+
el.style.opacity = "1";
|
|
822
|
+
void el.offsetHeight;
|
|
823
|
+
el.style.transition = "height 0.3s ease, opacity 0.25s ease";
|
|
824
|
+
el.style.height = "0px";
|
|
825
|
+
el.style.opacity = "0";
|
|
826
|
+
onAccordionEnd(el, done);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function accordionAfterEnter(el) {
|
|
830
|
+
el.style.transition = "";
|
|
831
|
+
el.style.height = "";
|
|
832
|
+
el.style.opacity = "";
|
|
833
|
+
el.style.overflow = "";
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// ================================================================
|
|
837
|
+
// WATCHERS + LIFECYCLE
|
|
838
|
+
// ================================================================
|
|
839
|
+
// Subtotal changed (checkout steppers or the drawer): re-validate the promo
|
|
840
|
+
// and free-delivery threshold against the server.
|
|
841
|
+
watch(total_price, (next, prev) => {
|
|
842
|
+
if (next === prev) return;
|
|
843
|
+
recheckPromo();
|
|
844
|
+
refreshFreeDelivery();
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
watch(phoneNumberResults, (results) => {
|
|
848
|
+
if (formErrors.value.mobile_number && results?.isValid) {
|
|
849
|
+
clearFieldError("mobile_number");
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
watch(() => userStore.clientIpInfo, syncCountry, { deep: true, immediate: true });
|
|
854
|
+
|
|
855
|
+
watch(selectedCountry, (next, prev) => {
|
|
856
|
+
if (next && prev && next.id !== prev.id) applyDefaultZone(next);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
onMounted(async () => {
|
|
860
|
+
await Promise.all([loadPaymentMethods(), loadCountries()]);
|
|
861
|
+
syncCountry();
|
|
862
|
+
handleUserInfo();
|
|
863
|
+
await Promise.all([fetchCart(), waitForUserInfoIfNeeded(), loadExtraFees()]);
|
|
864
|
+
// begin_checkout / InitiateCheckout: the page is open and the bag is loaded.
|
|
865
|
+
analytics.beginCheckout(CartItems.value);
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
// ================================================================
|
|
65
869
|
return {
|
|
870
|
+
// display
|
|
871
|
+
currency,
|
|
872
|
+
imageUrl,
|
|
66
873
|
isLoading,
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
874
|
+
mobileSummaryOpen,
|
|
875
|
+
bottomDiscountOpen,
|
|
876
|
+
accordionEnter,
|
|
877
|
+
accordionLeave,
|
|
878
|
+
accordionAfterEnter,
|
|
879
|
+
|
|
880
|
+
// cart
|
|
881
|
+
CartItems,
|
|
882
|
+
total_price,
|
|
883
|
+
totalItemsCount,
|
|
884
|
+
isItemUpdating,
|
|
885
|
+
increaseQuantity,
|
|
886
|
+
decreaseQuantity,
|
|
887
|
+
deleteItem,
|
|
888
|
+
|
|
889
|
+
// user + form
|
|
71
890
|
isLoggedIn,
|
|
72
|
-
|
|
891
|
+
formData,
|
|
892
|
+
formErrors,
|
|
893
|
+
clearFieldError,
|
|
894
|
+
validateEmailField,
|
|
895
|
+
onPhoneUpdate,
|
|
896
|
+
detailsErrorFields,
|
|
897
|
+
deliveryErrorFields,
|
|
898
|
+
emailErrorMessage,
|
|
899
|
+
emailHasError,
|
|
900
|
+
phoneErrorMessage,
|
|
901
|
+
phoneHasError,
|
|
902
|
+
|
|
903
|
+
// address + shipping
|
|
904
|
+
userAddresses,
|
|
905
|
+
selectedAddressIndex,
|
|
906
|
+
selectedAddress,
|
|
907
|
+
showAddressForm,
|
|
908
|
+
selectAddress,
|
|
909
|
+
toggleAddressForm,
|
|
910
|
+
countryOptions,
|
|
911
|
+
zoneOptions,
|
|
912
|
+
zoneRequired,
|
|
913
|
+
selectedCountry,
|
|
914
|
+
selectedZone,
|
|
915
|
+
selectCountry,
|
|
916
|
+
selectZone,
|
|
917
|
+
shippingCost,
|
|
918
|
+
isShippingLoading,
|
|
919
|
+
isFreeDelivery,
|
|
920
|
+
|
|
921
|
+
// payment method
|
|
922
|
+
paymentMethods,
|
|
923
|
+
isSelected,
|
|
924
|
+
setPaymentMethod,
|
|
925
|
+
|
|
926
|
+
// extra fees
|
|
927
|
+
extraFees,
|
|
928
|
+
extraFeeLines,
|
|
929
|
+
onExtraFeesChange,
|
|
930
|
+
|
|
931
|
+
// promo
|
|
932
|
+
promoCode,
|
|
933
|
+
appliedPromoCode,
|
|
934
|
+
promo_id: promoId,
|
|
935
|
+
discount,
|
|
936
|
+
promoError,
|
|
937
|
+
isCodeLoading,
|
|
73
938
|
applyPromoCode,
|
|
74
|
-
|
|
939
|
+
removePromo,
|
|
940
|
+
|
|
941
|
+
// totals + order
|
|
942
|
+
originalTotal,
|
|
943
|
+
totalPrice,
|
|
944
|
+
canPay,
|
|
945
|
+
isCheckingOut,
|
|
946
|
+
submitForm,
|
|
947
|
+
|
|
948
|
+
// gateway
|
|
949
|
+
activePayment,
|
|
950
|
+
onPaymentError,
|
|
951
|
+
onPaymentSettled,
|
|
952
|
+
onPaymentDeclined,
|
|
953
|
+
closePayment,
|
|
75
954
|
};
|
|
76
|
-
}
|
|
955
|
+
}
|