@osimart/create-storefront 1.0.3 → 1.0.7

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.
@@ -1,76 +1,955 @@
1
- import CheckoutServices from "#osi/services/CheckoutServices";
2
- import UtilsServices from "#osi/services/UtilsServices";
3
- import UserServices from "#osi/services/UserServices";
4
- import { authBus } from "#osi/services/AuthBus";
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 isLoading = useState("checkout-loading", () => false);
10
- const cartData = useState("checkout-cart-data", () => null);
11
- const countries = useState("checkout-countries", () => []);
12
- const paymentMethods = useState("checkout-payment-methods", () => []);
13
- const promoCodeData = useState("checkout-promo-code", () => null);
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
- const isLoggedIn = computed(() => authBus.isLoggedIn);
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
- async function loadCheckoutData() {
18
- isLoading.value = true;
315
+ // /payments/methods/ only returns active, fully configured methods.
316
+ async function loadPaymentMethods() {
19
317
  try {
20
- const [cartRes, countriesRes, paymentsRes] = await Promise.allSettled([
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
- console.error("[useCheckout] Error loading checkout data:", error);
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
- isLoading.value = false;
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
- async function applyPromoCode(code) {
37
- const toast = useToast();
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 CheckoutServices.promoCode(code);
40
- promoCodeData.value = res;
41
- toast.success("Promo code applied successfully!");
42
- return res;
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
- toast.error(error?.data?.detail || "Invalid promo code");
45
- throw error;
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 submitOrder(orderPayload) {
50
- const toast = useToast();
51
- isLoading.value = true;
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
- const response = await CheckoutServices.placeOrder(orderPayload);
54
- toast.success("Order placed successfully!");
55
- router.push(`/ordersummary?order_id=${response.id || response.order_id}`);
56
- return response;
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
- toast.error(error?.data?.detail || "Failed to place order");
59
- throw error;
748
+ console.error("Error placing order:", error);
749
+ if (handleOrderPromoError(error)) return;
750
+ toast.error(extractErrorMessage(error));
60
751
  } finally {
61
- isLoading.value = false;
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
- cartData,
68
- countries,
69
- paymentMethods,
70
- promoCodeData,
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
- loadCheckoutData,
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
- submitOrder,
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
+ }