@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
package/app/pages/checkout.vue
CHANGED
|
@@ -28,9 +28,7 @@
|
|
|
28
28
|
</div>
|
|
29
29
|
</div>
|
|
30
30
|
<div class="min-w-0 flex-1">
|
|
31
|
-
<p class="text-[13px] text-gray-500">
|
|
32
|
-
Order Summary
|
|
33
|
-
</p>
|
|
31
|
+
<p class="text-[13px] text-gray-500">Order Summary</p>
|
|
34
32
|
<p class="text-base font-semibold">{{ currency }}{{ totalPrice }}</p>
|
|
35
33
|
</div>
|
|
36
34
|
<svg
|
|
@@ -53,78 +51,79 @@
|
|
|
53
51
|
<div v-if="mobileSummaryOpen && !isLoading">
|
|
54
52
|
<div class="border-t border-gray-200 px-4 pb-5 pt-4">
|
|
55
53
|
<!-- line items with steppers -->
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
>
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
<button
|
|
98
|
-
type="button"
|
|
99
|
-
class="flex items-center gap-1 text-[12px] justify-center text-gray-400 transition-opacity duration-200 hover:text-red-500 disabled:opacity-40 opacity-100 md:opacity-0 md:group-hover:opacity-100 md:focus-visible:opacity-100"
|
|
100
|
-
:disabled="isItemUpdating(item)"
|
|
101
|
-
aria-label="Remove item"
|
|
102
|
-
@click="deleteItem(item)"
|
|
103
|
-
>
|
|
104
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.6" stroke="currentColor" class="w-3.5">
|
|
105
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.35 9m-4.78 0L9.26 9m9.97-3.21c.34.05.67.1 1.01.16m-1.01-.16-1.06 14.09a2.25 2.25 0 0 1-2.24 2.08H8.08a2.25 2.25 0 0 1-2.25-2.08L4.77 5.79m14.46 0a48.1 48.1 0 0 0-3.48-.4m-12 .56c.35-.06.68-.11 1.02-.16m0 0a48.1 48.1 0 0 1 3.48-.4m7.5 0v-.92c0-1.18-.91-2.16-2.09-2.2a51.96 51.96 0 0 0-3.32 0c-1.18.04-2.09 1.02-2.09 2.2v.92m7.5 0a48.67 48.67 0 0 0-7.5 0" />
|
|
106
|
-
</svg>
|
|
107
|
-
<span class="mt-0.5">Remove</span>
|
|
54
|
+
<div
|
|
55
|
+
v-for="item in CartItems"
|
|
56
|
+
:key="`strip-item-${item.id}`"
|
|
57
|
+
class="group flex items-center gap-3 py-2"
|
|
58
|
+
>
|
|
59
|
+
<div class="h-14 w-12 flex-shrink-0 overflow-hidden rounded-md border border-gray-200 bg-white">
|
|
60
|
+
<img :src="imageUrl(item.image)" :alt="item.name" class="h-full w-full object-cover" />
|
|
61
|
+
</div>
|
|
62
|
+
<div class="min-w-0 flex-1">
|
|
63
|
+
<p class="truncate text-[13px]">{{ item.name }}</p>
|
|
64
|
+
<p v-if="Array.isArray(item.values) ? item.values.length : item.values" class="truncate text-xs text-gray-400">{{ Array.isArray(item.values) && item.values.length ? item.values.join(' | ') : item.values || '' }}</p>
|
|
65
|
+
<div class="mt-1.5 flex items-center gap-3">
|
|
66
|
+
<div class="flex h-7 w-fit items-center rounded-full border border-gray-300">
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
class="flex h-7 w-7 items-center justify-center text-gray-500 disabled:opacity-40"
|
|
70
|
+
:disabled="isItemUpdating(item)"
|
|
71
|
+
aria-label="Decrease quantity"
|
|
72
|
+
@click="decreaseQuantity(item)"
|
|
73
|
+
>
|
|
74
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-3.5">
|
|
75
|
+
<path stroke-linecap="round" d="M5 12h14" />
|
|
76
|
+
</svg>
|
|
77
|
+
</button>
|
|
78
|
+
<span class="min-w-[24px] text-center text-[13px]">
|
|
79
|
+
<span v-if="isItemUpdating(item)" class="inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-200 border-t-primaryColor"></span>
|
|
80
|
+
<template v-else>{{ item.quantity }}</template>
|
|
81
|
+
</span>
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
class="flex h-7 w-7 items-center justify-center text-gray-500 disabled:opacity-40"
|
|
85
|
+
:disabled="isItemUpdating(item)"
|
|
86
|
+
aria-label="Increase quantity"
|
|
87
|
+
@click="increaseQuantity(item)"
|
|
88
|
+
>
|
|
89
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-3.5">
|
|
90
|
+
<path stroke-linecap="round" d="M12 5v14M5 12h14" />
|
|
91
|
+
</svg>
|
|
92
|
+
</button>
|
|
93
|
+
</div>
|
|
108
94
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
class="flex items-center justify-center gap-1 text-[12px] text-gray-400 opacity-100 transition-opacity duration-200 hover:text-red-500 disabled:opacity-40 md:opacity-0 md:focus-visible:opacity-100 md:group-hover:opacity-100"
|
|
98
|
+
:disabled="isItemUpdating(item)"
|
|
99
|
+
aria-label="Remove item"
|
|
100
|
+
@click="deleteItem(item)"
|
|
101
|
+
>
|
|
102
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.6" stroke="currentColor" class="w-3.5">
|
|
103
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.35 9m-4.78 0L9.26 9m9.97-3.21c.34.05.67.1 1.01.16m-1.01-.16-1.06 14.09a2.25 2.25 0 0 1-2.24 2.08H8.08a2.25 2.25 0 0 1-2.25-2.08L4.77 5.79m14.46 0a48.1 48.1 0 0 0-3.48-.4m-12 .56c.35-.06.68-.11 1.02-.16m0 0a48.1 48.1 0 0 1 3.48-.4m7.5 0v-.92c0-1.18-.91-2.16-2.09-2.2a51.96 51.96 0 0 0-3.32 0c-1.18.04-2.09 1.02-2.09 2.2v.92m7.5 0a48.67 48.67 0 0 0-7.5 0" />
|
|
104
|
+
</svg>
|
|
105
|
+
<span class="mt-0.5">Remove</span>
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
<span class="whitespace-nowrap text-[13px]">{{ currency }}{{ item.total_price }}</span>
|
|
110
|
+
</div>
|
|
114
111
|
|
|
115
112
|
<!-- promo -->
|
|
116
113
|
<div class="pt-3">
|
|
117
|
-
<div v-if="!
|
|
114
|
+
<div v-if="!promo_id" class="flex gap-2">
|
|
118
115
|
<input
|
|
119
116
|
type="text"
|
|
120
117
|
id="m-promocode"
|
|
121
118
|
v-model="promoCode"
|
|
122
119
|
placeholder="Promo code"
|
|
123
120
|
class="h-10 min-w-0 flex-1 rounded-xl border border-gray-300 bg-white px-3 text-[16px] outline-none placeholder:text-gray-300"
|
|
121
|
+
@input="promoError = ''"
|
|
122
|
+
@keyup.enter="applyPromoCode"
|
|
124
123
|
/>
|
|
125
124
|
<button
|
|
126
125
|
type="button"
|
|
127
|
-
class="h-10 flex-shrink-0 rounded-xl border border-gray-300 bg-white px-4 text-sm font-medium"
|
|
126
|
+
class="flex h-10 flex-shrink-0 items-center justify-center rounded-xl border border-gray-300 bg-white px-4 text-sm font-medium"
|
|
128
127
|
:disabled="isCodeLoading"
|
|
129
128
|
@click="applyPromoCode"
|
|
130
129
|
>
|
|
@@ -133,11 +132,15 @@
|
|
|
133
132
|
</button>
|
|
134
133
|
</div>
|
|
135
134
|
<div v-else class="flex items-center justify-between">
|
|
136
|
-
<p class="text-sm text-green-600">
|
|
135
|
+
<p class="text-sm text-green-600">
|
|
136
|
+
Promo applied: {{ appliedPromoCode }}
|
|
137
|
+
<span v-if="isCodeLoading" class="ml-1 inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-200 border-t-primaryColor align-middle"></span>
|
|
138
|
+
</p>
|
|
137
139
|
<button type="button" class="rounded-lg border border-red-600 bg-red-600 px-2 py-1 text-xs text-white" @click="removePromo">
|
|
138
140
|
Remove
|
|
139
141
|
</button>
|
|
140
142
|
</div>
|
|
143
|
+
<p v-if="promoError" class="mt-1.5 text-xs text-red-500">{{ promoError }}</p>
|
|
141
144
|
</div>
|
|
142
145
|
|
|
143
146
|
<!-- totals -->
|
|
@@ -155,12 +158,9 @@
|
|
|
155
158
|
<div class="flex justify-between text-gray-500" v-else>
|
|
156
159
|
<dt>Shipping</dt><dd class="text-xs">Enter address to calculate</dd>
|
|
157
160
|
</div>
|
|
158
|
-
<div v-if="discount" class="flex justify-between text-green-600">
|
|
161
|
+
<div v-if="discount > 0" class="flex justify-between text-green-600">
|
|
159
162
|
<dt>Discount</dt>
|
|
160
|
-
<dd>
|
|
161
|
-
<template v-if="discountType === 'Percentage'">{{ Number(discount) }}%</template>
|
|
162
|
-
<template v-else-if="discountType === 'Fixed Amount'">{{ currency }}{{ Number(discount) }}</template>
|
|
163
|
-
</dd>
|
|
163
|
+
<dd>-{{ currency }}{{ discount.toFixed(2) }}</dd>
|
|
164
164
|
</div>
|
|
165
165
|
<div v-for="line in extraFeeLines" :key="`m-fee-${line.name}`" class="flex justify-between text-gray-500">
|
|
166
166
|
<dt>{{ line.name }}</dt><dd>{{ currency }}{{ line.amount.toFixed(2) }}</dd>
|
|
@@ -310,7 +310,7 @@
|
|
|
310
310
|
<span class="flex h-[22px] w-[22px] items-center justify-center rounded-full bg-primaryColor text-xs text-white">2</span>
|
|
311
311
|
<h2 class="text-[15px] font-semibold">Delivery address</h2>
|
|
312
312
|
<span class="h-px flex-1 bg-gray-200"></span>
|
|
313
|
-
|
|
313
|
+
<router-link
|
|
314
314
|
v-if="!isLoggedIn"
|
|
315
315
|
:to="{ path: '/login', query: { redirect: $route.fullPath } }"
|
|
316
316
|
class="text-xs text-green-500 underline"
|
|
@@ -477,9 +477,7 @@
|
|
|
477
477
|
<!-- ===== 3. PAYMENT ===== -->
|
|
478
478
|
<section class="pt-7">
|
|
479
479
|
<div class="mb-3 flex items-center gap-2">
|
|
480
|
-
<span
|
|
481
|
-
class="flex h-[22px] w-[22px] items-center justify-center rounded-full bg-primaryColor text-xs text-white"
|
|
482
|
-
>3</span>
|
|
480
|
+
<span class="flex h-[22px] w-[22px] items-center justify-center rounded-full bg-primaryColor text-xs text-white">3</span>
|
|
483
481
|
<h2 class="text-[15px] font-semibold">Payment</h2>
|
|
484
482
|
<span class="h-px flex-1 bg-gray-200"></span>
|
|
485
483
|
</div>
|
|
@@ -490,11 +488,10 @@
|
|
|
490
488
|
:class="formErrors.payment_method_id ? 'bg-red-50' : 'border-gray-300 bg-white'"
|
|
491
489
|
>
|
|
492
490
|
<label
|
|
493
|
-
v-for="(option, index) in
|
|
491
|
+
v-for="(option, index) in paymentMethods"
|
|
494
492
|
:key="index"
|
|
495
493
|
class="flex cursor-pointer items-center gap-3 px-3 py-3"
|
|
496
494
|
:class="isSelected(option) ? 'bg-primaryColor/5' : ''"
|
|
497
|
-
@click="handleLabelClick(option)"
|
|
498
495
|
>
|
|
499
496
|
<input
|
|
500
497
|
type="checkbox"
|
|
@@ -518,7 +515,7 @@
|
|
|
518
515
|
<h2 class="text-[15px] font-semibold">Would you like to donate?</h2>
|
|
519
516
|
<span class="h-px flex-1 bg-gray-200"></span>
|
|
520
517
|
</div>
|
|
521
|
-
|
|
518
|
+
<ExtraFees :fees="extraFees" :subtotal="total_price" @change="onExtraFeesChange" />
|
|
522
519
|
</section>
|
|
523
520
|
</template>
|
|
524
521
|
</div>
|
|
@@ -526,125 +523,126 @@
|
|
|
526
523
|
<!-- ---------------- DESKTOP SUMMARY RAIL ---------------- -->
|
|
527
524
|
<aside class="hidden lg:order-2 lg:block">
|
|
528
525
|
<div class="sticky top-[90px] rounded-xl border border-gray-300 bg-white p-5">
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
526
|
+
<template v-if="isLoading">
|
|
527
|
+
<skeletor width="50%" height="20px" />
|
|
528
|
+
|
|
529
|
+
<!-- line items: thumbnail + name/variant/qty-pill + price, matching the real rows -->
|
|
530
|
+
<div class="mt-4 space-y-4">
|
|
531
|
+
<div v-for="i in 4" :key="`sk-rail-item-${i}`" class="flex items-center gap-3">
|
|
532
|
+
<skeletor width="48px" height="56px" />
|
|
533
|
+
<div class="min-w-0 flex-1 space-y-1.5">
|
|
534
|
+
<skeletor width="75%" height="13px" />
|
|
535
|
+
<skeletor width="40%" height="11px" />
|
|
536
|
+
<skeletor width="70px" height="22px" />
|
|
537
|
+
</div>
|
|
538
|
+
<skeletor width="32px" height="13px" />
|
|
539
|
+
</div>
|
|
540
|
+
</div>
|
|
544
541
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
542
|
+
<!-- promo code row -->
|
|
543
|
+
<div class="mt-4 flex gap-2">
|
|
544
|
+
<skeletor width="100%" height="36px" />
|
|
545
|
+
<skeletor width="64px" height="36px" />
|
|
546
|
+
</div>
|
|
550
547
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
548
|
+
<!-- totals -->
|
|
549
|
+
<div class="mt-4 space-y-2.5 border-t border-gray-200 pt-3">
|
|
550
|
+
<div class="flex justify-between">
|
|
551
|
+
<skeletor width="35%" height="13px" />
|
|
552
|
+
<skeletor width="18%" height="13px" />
|
|
553
|
+
</div>
|
|
554
|
+
<div class="flex justify-between">
|
|
555
|
+
<skeletor width="28%" height="13px" />
|
|
556
|
+
<skeletor width="18%" height="13px" />
|
|
557
|
+
</div>
|
|
558
|
+
</div>
|
|
562
559
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
560
|
+
<!-- total -->
|
|
561
|
+
<div class="mt-3 flex items-baseline justify-between border-t border-gray-200 pt-3">
|
|
562
|
+
<skeletor width="25%" height="13px" />
|
|
563
|
+
<skeletor width="30%" height="22px" />
|
|
564
|
+
</div>
|
|
568
565
|
|
|
569
|
-
|
|
570
|
-
</template>
|
|
566
|
+
<div class="mt-4"><skeletor width="100%" height="44px" /></div>
|
|
567
|
+
</template>
|
|
571
568
|
<template v-else>
|
|
572
569
|
<p class="mb-3 text-[13px] text-gray-500">
|
|
573
570
|
Order · {{ totalItemsCount }} {{ totalItemsCount > 1 ? "items" : "item" }}
|
|
574
571
|
</p>
|
|
575
572
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
>
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
<button
|
|
618
|
-
type="button"
|
|
619
|
-
class="flex items-center gap-1 text-[12px] font-body text-gray-400 transition-opacity duration-200 hover:text-red-500 opacity-100 md:opacity-0 md:group-hover:opacity-100 md:focus-visible:opacity-100"
|
|
620
|
-
:disabled="isItemUpdating(item)"
|
|
621
|
-
aria-label="Remove item"
|
|
622
|
-
@click="deleteItem(item)"
|
|
623
|
-
>
|
|
624
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.6" stroke="currentColor" class="w-3.5">
|
|
625
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.35 9m-4.78 0L9.26 9m9.97-3.21c.34.05.67.1 1.01.16m-1.01-.16-1.06 14.09a2.25 2.25 0 0 1-2.24 2.08H8.08a2.25 2.25 0 0 1-2.25-2.08L4.77 5.79m14.46 0a48.1 48.1 0 0 0-3.48-.4m-12 .56c.35-.06.68-.11 1.02-.16m0 0a48.1 48.1 0 0 1 3.48-.4m7.5 0v-.92c0-1.18-.91-2.16-2.09-2.2a51.96 51.96 0 0 0-3.32 0c-1.18.04-2.09 1.02-2.09 2.2v.92m7.5 0a48.67 48.67 0 0 0-7.5 0" />
|
|
626
|
-
</svg>
|
|
627
|
-
<span class="mt-0.5">Remove</span>
|
|
573
|
+
<div
|
|
574
|
+
v-for="item in CartItems"
|
|
575
|
+
:key="`rail-${item.id}`"
|
|
576
|
+
class="group flex items-center gap-3 py-2"
|
|
577
|
+
>
|
|
578
|
+
<div class="h-14 w-12 flex-shrink-0 overflow-hidden rounded-md border border-gray-200 bg-white">
|
|
579
|
+
<img :src="imageUrl(item.image)" :alt="item.name" class="h-full w-full object-cover" />
|
|
580
|
+
</div>
|
|
581
|
+
<div class="min-w-0 flex-1">
|
|
582
|
+
<p class="truncate text-[13px]">{{ item.name }}</p>
|
|
583
|
+
<p v-if="Array.isArray(item.values) ? item.values.length : item.values" class="truncate text-xs text-gray-400">{{ Array.isArray(item.values) && item.values.length ? item.values.join(' | ') : item.values || '' }}</p>
|
|
584
|
+
<div class="mt-1.5 flex items-center gap-3">
|
|
585
|
+
<div class="flex h-7 w-fit items-center rounded-full border border-gray-300">
|
|
586
|
+
<button
|
|
587
|
+
type="button"
|
|
588
|
+
class="flex h-7 w-7 items-center justify-center text-gray-500 disabled:opacity-40"
|
|
589
|
+
:disabled="isItemUpdating(item)"
|
|
590
|
+
aria-label="Decrease quantity"
|
|
591
|
+
@click="decreaseQuantity(item)"
|
|
592
|
+
>
|
|
593
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-3.5">
|
|
594
|
+
<path stroke-linecap="round" d="M5 12h14" />
|
|
595
|
+
</svg>
|
|
596
|
+
</button>
|
|
597
|
+
<span class="min-w-[24px] text-center text-[13px]">
|
|
598
|
+
<span v-if="isItemUpdating(item)" class="inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-200 border-t-primaryColor"></span>
|
|
599
|
+
<template v-else>{{ item.quantity }}</template>
|
|
600
|
+
</span>
|
|
601
|
+
<button
|
|
602
|
+
type="button"
|
|
603
|
+
class="flex h-7 w-7 items-center justify-center text-gray-500 disabled:opacity-40"
|
|
604
|
+
:disabled="isItemUpdating(item)"
|
|
605
|
+
aria-label="Increase quantity"
|
|
606
|
+
@click="increaseQuantity(item)"
|
|
607
|
+
>
|
|
608
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-3.5">
|
|
609
|
+
<path stroke-linecap="round" d="M12 5v14M5 12h14" />
|
|
610
|
+
</svg>
|
|
611
|
+
</button>
|
|
612
|
+
</div>
|
|
628
613
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
614
|
+
<button
|
|
615
|
+
type="button"
|
|
616
|
+
class="flex items-center gap-1 font-body text-[12px] text-gray-400 opacity-100 transition-opacity duration-200 hover:text-red-500 disabled:opacity-40 md:opacity-0 md:focus-visible:opacity-100 md:group-hover:opacity-100"
|
|
617
|
+
:disabled="isItemUpdating(item)"
|
|
618
|
+
aria-label="Remove item"
|
|
619
|
+
@click="deleteItem(item)"
|
|
620
|
+
>
|
|
621
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.6" stroke="currentColor" class="w-3.5">
|
|
622
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.35 9m-4.78 0L9.26 9m9.97-3.21c.34.05.67.1 1.01.16m-1.01-.16-1.06 14.09a2.25 2.25 0 0 1-2.24 2.08H8.08a2.25 2.25 0 0 1-2.25-2.08L4.77 5.79m14.46 0a48.1 48.1 0 0 0-3.48-.4m-12 .56c.35-.06.68-.11 1.02-.16m0 0a48.1 48.1 0 0 1 3.48-.4m7.5 0v-.92c0-1.18-.91-2.16-2.09-2.2a51.96 51.96 0 0 0-3.32 0c-1.18.04-2.09 1.02-2.09 2.2v.92m7.5 0a48.67 48.67 0 0 0-7.5 0" />
|
|
623
|
+
</svg>
|
|
624
|
+
<span class="mt-0.5">Remove</span>
|
|
625
|
+
</button>
|
|
626
|
+
</div>
|
|
627
|
+
</div>
|
|
628
|
+
<span class="whitespace-nowrap text-[13px]">{{ currency }}{{ item.total_price }}</span>
|
|
629
|
+
</div>
|
|
634
630
|
|
|
635
631
|
<!-- promo -->
|
|
636
632
|
<div class="mt-3">
|
|
637
|
-
<div v-if="!
|
|
633
|
+
<div v-if="!promo_id" class="flex gap-2">
|
|
638
634
|
<input
|
|
639
635
|
type="text"
|
|
640
636
|
id="promocode"
|
|
641
637
|
v-model="promoCode"
|
|
642
638
|
placeholder="Promo code"
|
|
643
639
|
class="h-9 min-w-0 flex-1 rounded-lg border border-gray-300 px-3 text-[16px] outline-none placeholder:text-gray-300 focus:border-primaryColor"
|
|
640
|
+
@input="promoError = ''"
|
|
641
|
+
@keyup.enter="applyPromoCode"
|
|
644
642
|
/>
|
|
645
643
|
<button
|
|
646
644
|
type="button"
|
|
647
|
-
class="h-9 flex-shrink-0
|
|
645
|
+
class="flex h-9 flex-shrink-0 items-center justify-center rounded-lg border border-gray-300 px-4 text-[13px]"
|
|
648
646
|
:disabled="isCodeLoading"
|
|
649
647
|
@click="applyPromoCode"
|
|
650
648
|
>
|
|
@@ -653,11 +651,15 @@
|
|
|
653
651
|
</button>
|
|
654
652
|
</div>
|
|
655
653
|
<div v-else class="flex items-center justify-between">
|
|
656
|
-
<p class="text-[13px] text-green-600">
|
|
654
|
+
<p class="text-[13px] text-green-600">
|
|
655
|
+
Promo applied: {{ appliedPromoCode }}
|
|
656
|
+
<span v-if="isCodeLoading" class="ml-1 inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-200 border-t-primaryColor align-middle"></span>
|
|
657
|
+
</p>
|
|
657
658
|
<button type="button" class="rounded-lg border border-red-600 bg-red-600 px-2 py-1 text-xs text-white" @click="removePromo">
|
|
658
659
|
Remove
|
|
659
660
|
</button>
|
|
660
661
|
</div>
|
|
662
|
+
<p v-if="promoError" class="mt-1.5 text-xs text-red-500">{{ promoError }}</p>
|
|
661
663
|
</div>
|
|
662
664
|
|
|
663
665
|
<!-- totals -->
|
|
@@ -675,12 +677,9 @@
|
|
|
675
677
|
<div class="flex justify-between text-gray-500" v-else>
|
|
676
678
|
<dt>Shipping</dt><dd class="text-xs">Enter address to calculate</dd>
|
|
677
679
|
</div>
|
|
678
|
-
<div v-if="discount" class="flex justify-between text-green-600">
|
|
680
|
+
<div v-if="discount > 0" class="flex justify-between text-green-600">
|
|
679
681
|
<dt>Discount</dt>
|
|
680
|
-
<dd>
|
|
681
|
-
<template v-if="discountType === 'Percentage'">{{ Number(discount) }}%</template>
|
|
682
|
-
<template v-else-if="discountType === 'Fixed Amount'">{{ currency }}{{ Number(discount) }}</template>
|
|
683
|
-
</dd>
|
|
682
|
+
<dd>-{{ currency }}{{ discount.toFixed(2) }}</dd>
|
|
684
683
|
</div>
|
|
685
684
|
<div v-for="line in extraFeeLines" :key="`rail-fee-${line.name}`" class="flex justify-between text-gray-500">
|
|
686
685
|
<dt>{{ line.name }}</dt><dd>{{ currency }}{{ line.amount.toFixed(2) }}</dd>
|
|
@@ -690,7 +689,7 @@
|
|
|
690
689
|
<div class="mt-3 flex items-baseline justify-between border-t border-gray-200 pt-3">
|
|
691
690
|
<span class="text-[13px] text-gray-500">Total</span>
|
|
692
691
|
<div class="flex items-baseline gap-2">
|
|
693
|
-
<span v-if="discount" class="text-[13px] text-gray-400 line-through">{{ currency }}{{ originalTotal }}</span>
|
|
692
|
+
<span v-if="discount > 0" class="text-[13px] text-gray-400 line-through">{{ currency }}{{ originalTotal }}</span>
|
|
694
693
|
<span class="text-xl font-semibold">{{ currency }}{{ totalPrice }}</span>
|
|
695
694
|
</div>
|
|
696
695
|
</div>
|
|
@@ -701,7 +700,8 @@
|
|
|
701
700
|
<button
|
|
702
701
|
v-else
|
|
703
702
|
type="button"
|
|
704
|
-
class="osi-btn osi-btn--block mt-4"
|
|
703
|
+
class="osi-btn osi-btn--block mt-4 disabled:cursor-not-allowed disabled:opacity-50"
|
|
704
|
+
:disabled="!canPay"
|
|
705
705
|
@click="submitForm"
|
|
706
706
|
>
|
|
707
707
|
Complete order
|
|
@@ -771,92 +771,98 @@
|
|
|
771
771
|
@after-leave="accordionAfterEnter"
|
|
772
772
|
>
|
|
773
773
|
<div v-if="bottomDiscountOpen">
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
774
|
+
<!-- line items -->
|
|
775
|
+
<div
|
|
776
|
+
v-for="item in CartItems"
|
|
777
|
+
:key="`bottom-${item.id}`"
|
|
778
|
+
class="group flex items-center gap-3 py-2"
|
|
779
|
+
>
|
|
780
|
+
<div class="h-14 w-12 flex-shrink-0 overflow-hidden rounded-md border border-gray-200 bg-white">
|
|
781
|
+
<img :src="imageUrl(item.image)" :alt="item.name" class="h-full w-full object-cover" />
|
|
782
|
+
</div>
|
|
783
|
+
<div class="min-w-0 flex-1">
|
|
784
|
+
<p class="truncate text-[13px]">{{ item.name }}</p>
|
|
785
|
+
<p v-if="Array.isArray(item.values) ? item.values.length : item.values" class="truncate text-xs text-gray-400">{{ Array.isArray(item.values) && item.values.length ? item.values.join(' | ') : item.values || '' }}</p>
|
|
786
|
+
<div class="mt-1.5 flex items-center gap-3">
|
|
787
|
+
<div class="flex h-7 w-fit items-center rounded-full border border-gray-300">
|
|
788
|
+
<button
|
|
789
|
+
type="button"
|
|
790
|
+
class="flex h-7 w-7 items-center justify-center text-gray-500 disabled:opacity-40"
|
|
791
|
+
:disabled="isItemUpdating(item)"
|
|
792
|
+
aria-label="Decrease quantity"
|
|
793
|
+
@click="decreaseQuantity(item)"
|
|
794
|
+
>
|
|
795
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-3.5">
|
|
796
|
+
<path stroke-linecap="round" d="M5 12h14" />
|
|
797
|
+
</svg>
|
|
798
|
+
</button>
|
|
799
|
+
<span class="min-w-[24px] text-center text-[13px]">
|
|
800
|
+
<span v-if="isItemUpdating(item)" class="inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-200 border-t-primaryColor"></span>
|
|
801
|
+
<template v-else>{{ item.quantity }}</template>
|
|
802
|
+
</span>
|
|
803
|
+
<button
|
|
804
|
+
type="button"
|
|
805
|
+
class="flex h-7 w-7 items-center justify-center text-gray-500 disabled:opacity-40"
|
|
806
|
+
:disabled="isItemUpdating(item)"
|
|
807
|
+
aria-label="Increase quantity"
|
|
808
|
+
@click="increaseQuantity(item)"
|
|
809
|
+
>
|
|
810
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-3.5">
|
|
811
|
+
<path stroke-linecap="round" d="M12 5v14M5 12h14" />
|
|
812
|
+
</svg>
|
|
813
|
+
</button>
|
|
814
|
+
</div>
|
|
815
|
+
|
|
803
816
|
<button
|
|
804
817
|
type="button"
|
|
805
|
-
class="flex
|
|
818
|
+
class="flex items-center justify-center gap-1 text-[12px] text-gray-400 transition-colors duration-200 hover:text-red-500 disabled:opacity-40"
|
|
806
819
|
:disabled="isItemUpdating(item)"
|
|
807
|
-
aria-label="
|
|
808
|
-
@click="
|
|
820
|
+
aria-label="Remove item"
|
|
821
|
+
@click="deleteItem(item)"
|
|
809
822
|
>
|
|
810
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="
|
|
811
|
-
<path stroke-linecap="round" d="
|
|
823
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.6" stroke="currentColor" class="w-3.5">
|
|
824
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.35 9m-4.78 0L9.26 9m9.97-3.21c.34.05.67.1 1.01.16m-1.01-.16-1.06 14.09a2.25 2.25 0 0 1-2.24 2.08H8.08a2.25 2.25 0 0 1-2.25-2.08L4.77 5.79m14.46 0a48.1 48.1 0 0 0-3.48-.4m-12 .56c.35-.06.68-.11 1.02-.16m0 0a48.1 48.1 0 0 1 3.48-.4m7.5 0v-.92c0-1.18-.91-2.16-2.09-2.2a51.96 51.96 0 0 0-3.32 0c-1.18.04-2.09 1.02-2.09 2.2v.92m7.5 0a48.67 48.67 0 0 0-7.5 0" />
|
|
812
825
|
</svg>
|
|
826
|
+
<span class="mt-0.5">Remove</span>
|
|
813
827
|
</button>
|
|
814
828
|
</div>
|
|
829
|
+
</div>
|
|
830
|
+
<span class="whitespace-nowrap text-[13px]">{{ currency }}{{ item.total_price }}</span>
|
|
831
|
+
</div>
|
|
815
832
|
|
|
833
|
+
<!-- promo -->
|
|
834
|
+
<div class="pt-3">
|
|
835
|
+
<div v-if="!promo_id" class="flex gap-2">
|
|
836
|
+
<input
|
|
837
|
+
type="text"
|
|
838
|
+
id="b-promocode"
|
|
839
|
+
v-model="promoCode"
|
|
840
|
+
placeholder="Promo code"
|
|
841
|
+
class="h-10 min-w-0 flex-1 rounded-xl border border-gray-300 bg-white px-3 text-[16px] outline-none placeholder:text-gray-300"
|
|
842
|
+
@input="promoError = ''"
|
|
843
|
+
@keyup.enter="applyPromoCode"
|
|
844
|
+
/>
|
|
816
845
|
<button
|
|
817
846
|
type="button"
|
|
818
|
-
class="flex items-center justify-center
|
|
819
|
-
:disabled="
|
|
820
|
-
|
|
821
|
-
@click="deleteItem(item)"
|
|
847
|
+
class="flex h-10 flex-shrink-0 items-center justify-center rounded-xl border border-gray-300 bg-white px-4 text-sm font-medium"
|
|
848
|
+
:disabled="isCodeLoading"
|
|
849
|
+
@click="applyPromoCode"
|
|
822
850
|
>
|
|
823
|
-
<
|
|
824
|
-
|
|
825
|
-
</svg>
|
|
826
|
-
<span class="mt-0.5">Remove</span>
|
|
851
|
+
<span v-if="isCodeLoading" class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-gray-200 border-t-primaryColor"></span>
|
|
852
|
+
<span v-else>Apply</span>
|
|
827
853
|
</button>
|
|
828
854
|
</div>
|
|
855
|
+
<div v-else class="flex items-center justify-between">
|
|
856
|
+
<p class="text-sm text-green-600">
|
|
857
|
+
Promo applied: {{ appliedPromoCode }}
|
|
858
|
+
<span v-if="isCodeLoading" class="ml-1 inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-200 border-t-primaryColor align-middle"></span>
|
|
859
|
+
</p>
|
|
860
|
+
<button type="button" class="rounded-lg border border-red-600 bg-red-600 px-2 py-1 text-xs text-white" @click="removePromo">
|
|
861
|
+
Remove
|
|
862
|
+
</button>
|
|
863
|
+
</div>
|
|
864
|
+
<p v-if="promoError" class="mt-1.5 text-xs text-red-500">{{ promoError }}</p>
|
|
829
865
|
</div>
|
|
830
|
-
<span class="whitespace-nowrap text-[13px]">{{ currency }}{{ item.total_price }}</span>
|
|
831
|
-
</div>
|
|
832
|
-
|
|
833
|
-
<!-- promo -->
|
|
834
|
-
<div class="pt-3">
|
|
835
|
-
<div v-if="!discount" class="flex gap-2">
|
|
836
|
-
<input
|
|
837
|
-
type="text"
|
|
838
|
-
id="b-promocode"
|
|
839
|
-
v-model="promoCode"
|
|
840
|
-
placeholder="Promo code"
|
|
841
|
-
class="h-10 min-w-0 flex-1 rounded-xl border border-gray-300 bg-white px-3 text-[16px] outline-none placeholder:text-gray-300"
|
|
842
|
-
/>
|
|
843
|
-
<button
|
|
844
|
-
type="button"
|
|
845
|
-
class="flex h-10 flex-shrink-0 items-center justify-center rounded-xl border border-gray-300 bg-white px-4 text-sm font-medium"
|
|
846
|
-
:disabled="isCodeLoading"
|
|
847
|
-
@click="applyPromoCode"
|
|
848
|
-
>
|
|
849
|
-
<span v-if="isCodeLoading" class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-gray-200 border-t-primaryColor"></span>
|
|
850
|
-
<span v-else>Apply</span>
|
|
851
|
-
</button>
|
|
852
|
-
</div>
|
|
853
|
-
<div v-else class="flex items-center justify-between">
|
|
854
|
-
<p class="text-sm text-green-600">Promo applied: {{ promoCode }}</p>
|
|
855
|
-
<button type="button" class="rounded-lg border border-red-600 bg-red-600 px-2 py-1 text-xs text-white" @click="removePromo">
|
|
856
|
-
Remove
|
|
857
|
-
</button>
|
|
858
|
-
</div>
|
|
859
|
-
</div>
|
|
860
866
|
</div>
|
|
861
867
|
</transition>
|
|
862
868
|
|
|
@@ -878,12 +884,9 @@
|
|
|
878
884
|
<div class="flex justify-between text-gray-500" v-else>
|
|
879
885
|
<dt>Shipping</dt><dd class="text-xs">Enter address to calculate</dd>
|
|
880
886
|
</div>
|
|
881
|
-
<div v-if="discount" class="flex justify-between text-green-600">
|
|
887
|
+
<div v-if="discount > 0" class="flex justify-between text-green-600">
|
|
882
888
|
<dt>Discount</dt>
|
|
883
|
-
<dd>
|
|
884
|
-
<template v-if="discountType === 'Percentage'">{{ Number(discount) }}%</template>
|
|
885
|
-
<template v-else-if="discountType === 'Fixed Amount'">{{ currency }}{{ Number(discount) }}</template>
|
|
886
|
-
</dd>
|
|
889
|
+
<dd>-{{ currency }}{{ discount.toFixed(2) }}</dd>
|
|
887
890
|
</div>
|
|
888
891
|
<div v-for="line in extraFeeLines" :key="`bottom-fee-${line.name}`" class="flex justify-between text-gray-500">
|
|
889
892
|
<dt>{{ line.name }}</dt><dd>{{ currency }}{{ line.amount.toFixed(2) }}</dd>
|
|
@@ -891,14 +894,11 @@
|
|
|
891
894
|
</dl>
|
|
892
895
|
|
|
893
896
|
<div class="mb-2.5 mt-3 flex items-end justify-between border-t border-gray-200 pt-3">
|
|
894
|
-
<
|
|
895
|
-
|
|
896
|
-
|
|
897
|
+
<p>Total</p>
|
|
898
|
+
<div class="flex items-baseline gap-2">
|
|
899
|
+
<span v-if="discount > 0" class="text-[13px] text-gray-400 line-through">{{ currency }}{{ originalTotal }}</span>
|
|
900
|
+
<span class="text-lg font-semibold">{{ currency }}{{ totalPrice }}</span>
|
|
897
901
|
</div>
|
|
898
|
-
<div class="flex items-baseline gap-2">
|
|
899
|
-
<span v-if="discount" class="text-[13px] text-gray-400 line-through">{{ currency }}{{ originalTotal }}</span>
|
|
900
|
-
<span class="text-lg font-semibold">{{ currency }}{{ totalPrice }}</span>
|
|
901
|
-
</div>
|
|
902
902
|
</div>
|
|
903
903
|
|
|
904
904
|
<div v-if="isCheckingOut" class="flex w-full justify-center py-8">
|
|
@@ -907,7 +907,8 @@
|
|
|
907
907
|
<button
|
|
908
908
|
v-else
|
|
909
909
|
type="button"
|
|
910
|
-
class="osi-btn osi-btn--block"
|
|
910
|
+
class="osi-btn osi-btn--block disabled:cursor-not-allowed disabled:opacity-50"
|
|
911
|
+
:disabled="!canPay"
|
|
911
912
|
@click="submitForm"
|
|
912
913
|
>
|
|
913
914
|
Complete order
|
|
@@ -928,994 +929,120 @@
|
|
|
928
929
|
/>
|
|
929
930
|
</div>
|
|
930
931
|
</template>
|
|
931
|
-
<script>
|
|
932
|
-
import checkoutServices from "#osi/services/CheckoutServices";
|
|
933
|
-
import { authBus } from "#osi/services/AuthBus";
|
|
934
|
-
import utilsServices from "#osi/services/UtilsServices";
|
|
932
|
+
<script setup>
|
|
935
933
|
import { Skeletor } from "vue-skeletor";
|
|
936
934
|
import "vue-skeletor/dist/vue-skeletor.css";
|
|
937
|
-
import { ExtraFees } from "#components";
|
|
938
|
-
import paymentServices from "#osi/services/PaymentServices";
|
|
939
|
-
import { PaymentHandoff } from "#components";
|
|
940
|
-
import { useToast } from "vue-toastification";
|
|
941
|
-
import { eventBus } from "#osi/utils/eventBus";
|
|
942
|
-
import { PhoneNumInput } from "#components";
|
|
943
935
|
import FadeLoader from "vue-spinner/src/FadeLoader.vue";
|
|
944
|
-
import
|
|
945
|
-
import
|
|
936
|
+
import ExtraFees from "~/components/checkout/ExtraFees.vue";
|
|
937
|
+
import PaymentHandoff from "~/components/checkout/PaymentHandoff.vue";
|
|
938
|
+
import PhoneNumInput from "~/components/common/PhoneNumInput.vue";
|
|
939
|
+
import DropdownSelect from "~/components/common/DropdownSelect.vue";
|
|
946
940
|
import { useHead } from "#imports";
|
|
947
|
-
import {
|
|
948
|
-
import { analytics } from "#osi/utils/analytics";
|
|
941
|
+
import { useCheckout } from "~/composables/useCheckout";
|
|
949
942
|
|
|
950
943
|
definePageMeta({
|
|
951
944
|
name: "checkout",
|
|
952
945
|
});
|
|
953
946
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
}
|
|
968
|
-
if (typeof data === "string" && data) return data;
|
|
969
|
-
|
|
970
|
-
return error?.message || "An error occurred";
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
export default {
|
|
974
|
-
name: "checkout",
|
|
975
|
-
setup() {
|
|
976
|
-
const toast = useToast();
|
|
977
|
-
const userStore = useUserStore();
|
|
978
|
-
// Multi-tenant: the page title and media host come from the resolved store,
|
|
979
|
-
// never from a build-time constant.
|
|
980
|
-
const { store } = useStorefront();
|
|
981
|
-
const { mediaUrl } = useMediaUrl();
|
|
982
|
-
// The one cart. Every mutation below writes through this composable, so
|
|
983
|
-
// the drawer and the header badge move with the checkout page.
|
|
984
|
-
const {
|
|
985
|
-
cart: rawCart,
|
|
986
|
-
cartItems,
|
|
987
|
-
cartTotal,
|
|
988
|
-
loading: cartLoading,
|
|
989
|
-
isUpdating,
|
|
990
|
-
fetchCart,
|
|
991
|
-
editItem,
|
|
992
|
-
removeItem,
|
|
993
|
-
clear: clearCart,
|
|
994
|
-
} = useCart();
|
|
995
|
-
const storeName = computed(() => store.value?.name || "");
|
|
996
|
-
const currency = computed(
|
|
997
|
-
() => store.value?.default_currency?.symbol || store.value?.default_currency?.code || "$"
|
|
998
|
-
);
|
|
999
|
-
|
|
1000
|
-
useHead({
|
|
1001
|
-
title: computed(() =>
|
|
1002
|
-
"Checkout",
|
|
947
|
+
// Multi-tenant: the description comes from the resolved store.
|
|
948
|
+
const { store } = useStorefront();
|
|
949
|
+
const storeName = computed(() => store.value?.name || "");
|
|
950
|
+
|
|
951
|
+
useHead({
|
|
952
|
+
title: "Checkout",
|
|
953
|
+
meta: [
|
|
954
|
+
{
|
|
955
|
+
name: "description",
|
|
956
|
+
content: computed(() =>
|
|
957
|
+
storeName.value
|
|
958
|
+
? `Complete your ${storeName.value} purchase securely.`
|
|
959
|
+
: "Complete your purchase securely.",
|
|
1003
960
|
),
|
|
1004
|
-
meta: [
|
|
1005
|
-
{
|
|
1006
|
-
name: "description",
|
|
1007
|
-
content: computed(() =>
|
|
1008
|
-
storeName.value
|
|
1009
|
-
? `Complete your ${storeName.value} purchase securely.`
|
|
1010
|
-
: "Complete your purchase securely.",
|
|
1011
|
-
),
|
|
1012
|
-
},
|
|
1013
|
-
{ name: "robots", content: "noindex, nofollow" },
|
|
1014
|
-
{ property: "og:type", content: "website" },
|
|
1015
|
-
{ name: "twitter:card", content: "summary" },
|
|
1016
|
-
],
|
|
1017
|
-
});
|
|
1018
|
-
|
|
1019
|
-
return {
|
|
1020
|
-
toast,
|
|
1021
|
-
userStore,
|
|
1022
|
-
mediaUrl,
|
|
1023
|
-
currency,
|
|
1024
|
-
rawCart,
|
|
1025
|
-
cartItems,
|
|
1026
|
-
cartTotal,
|
|
1027
|
-
cartLoading,
|
|
1028
|
-
isUpdating,
|
|
1029
|
-
fetchCart,
|
|
1030
|
-
editItem,
|
|
1031
|
-
removeItem,
|
|
1032
|
-
clearCart,
|
|
1033
|
-
};
|
|
1034
|
-
},
|
|
1035
|
-
components: {
|
|
1036
|
-
Skeletor,
|
|
1037
|
-
PaymentHandoff,
|
|
1038
|
-
PhoneNumInput,
|
|
1039
|
-
FadeLoader,
|
|
1040
|
-
ExtraFees,
|
|
1041
|
-
DropdownSelect,
|
|
1042
|
-
},
|
|
1043
|
-
data() {
|
|
1044
|
-
return {
|
|
1045
|
-
countryAutoSet: false,
|
|
1046
|
-
// Set by submitForm(), read by onPaymentSettled() after the gateway modal
|
|
1047
|
-
// closes — the response object is long out of scope by then.
|
|
1048
|
-
placedOrderId: null,
|
|
1049
|
-
// --- mobile UI state (accordion open/closed; layout itself is CSS-driven) ---
|
|
1050
|
-
mobileSummaryOpen: false,
|
|
1051
|
-
// Bottom pay bar: collapses the line items + promo code input behind
|
|
1052
|
-
// an "Add a discount code" toggle, Shopify-style. Totals stay visible.
|
|
1053
|
-
bottomDiscountOpen: false,
|
|
1054
|
-
|
|
1055
|
-
originalTotal: null,
|
|
1056
|
-
subscribeNewsletter: true,
|
|
1057
|
-
isLoggedIn: false,
|
|
1058
|
-
needsUserInfo: true,
|
|
1059
|
-
toast: useToast(),
|
|
1060
|
-
selectedCountry: "",
|
|
1061
|
-
selectedZone: "",
|
|
1062
|
-
isCheckingOut: false,
|
|
1063
|
-
promoCode: "",
|
|
1064
|
-
promo_id: "",
|
|
1065
|
-
discount: "",
|
|
1066
|
-
discountType: "",
|
|
1067
|
-
isCodeLoading: false,
|
|
1068
|
-
paymentMethods: [], //array to store all payment methods
|
|
1069
|
-
selectedPaymentMethod: null, //store the selected payment methods ID
|
|
1070
|
-
selectedOption: null, //store the selected option
|
|
1071
|
-
activePayment: null, //set once an order is placed and a gateway takes over
|
|
1072
|
-
isShippingLoading: false,
|
|
1073
|
-
isFreeDelivery: false,
|
|
1074
|
-
freeDeliveryId: null,
|
|
1075
|
-
extraFees: [],
|
|
1076
|
-
extraFeesPayload: [],
|
|
1077
|
-
extraFeesTotal: 0,
|
|
1078
|
-
extraFeesValid: true,
|
|
1079
|
-
extraFeeLines: [],
|
|
1080
|
-
countries: [],
|
|
1081
|
-
userAddresses: [],
|
|
1082
|
-
selectedPaymentMethodIndex: null,
|
|
1083
|
-
selectedAddressIndex: null, //track which address is selected
|
|
1084
|
-
showAddressForm: false, //controls whether to show the address form
|
|
1085
|
-
formErrors: {}, //field-level validation errors, keyed by data-field name
|
|
1086
|
-
phoneNumberResults: null, //validity/e164 payload emitted by PhoneNumInput's @update
|
|
1087
|
-
phoneTouched: false, //true once the phone field has been left, so we don't flag it mid-typing
|
|
1088
|
-
formData: {
|
|
1089
|
-
countryCode: "LB",
|
|
1090
|
-
first_name: "",
|
|
1091
|
-
last_name: "",
|
|
1092
|
-
email: "",
|
|
1093
|
-
mobile_number: "",
|
|
1094
|
-
signedUpForExclusiveContent: false,
|
|
1095
|
-
payment_method_id: "",
|
|
1096
|
-
address: {
|
|
1097
|
-
country: "",
|
|
1098
|
-
city: "",
|
|
1099
|
-
address: "",
|
|
1100
|
-
address_line2: "",
|
|
1101
|
-
zone: "",
|
|
1102
|
-
postal_code: "",
|
|
1103
|
-
},
|
|
1104
|
-
cart: {},
|
|
1105
|
-
},
|
|
1106
|
-
};
|
|
1107
|
-
},
|
|
1108
|
-
methods: {
|
|
1109
|
-
// --- absolute media url for cart thumbnails / payment icons ---
|
|
1110
|
-
// useMediaUrl() resolves against the `base-url` state the tenant was
|
|
1111
|
-
// resolved into, so one Nuxt process serves every store correctly.
|
|
1112
|
-
imageUrl(path) {
|
|
1113
|
-
return this.mediaUrl(path);
|
|
1114
|
-
},
|
|
1115
|
-
isItemUpdating(item) {
|
|
1116
|
-
return this.isUpdating(item);
|
|
1117
|
-
},
|
|
1118
|
-
// /payments/methods/ only returns methods that are active and fully
|
|
1119
|
-
// configured, so anything rendered here is selectable.
|
|
1120
|
-
isAvailable() {
|
|
1121
|
-
return true;
|
|
1122
|
-
},
|
|
1123
|
-
|
|
1124
|
-
isSelected(option) {
|
|
1125
|
-
return this.selectedPaymentMethod === option.id;
|
|
1126
961
|
},
|
|
962
|
+
{ name: "robots", content: "noindex, nofollow" },
|
|
963
|
+
{ property: "og:type", content: "website" },
|
|
964
|
+
{ name: "twitter:card", content: "summary" },
|
|
965
|
+
],
|
|
966
|
+
});
|
|
1127
967
|
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
// If the form is open, close it and allow selection again
|
|
1207
|
-
this.showAddressForm = false;
|
|
1208
|
-
this.selectedCountry = null;
|
|
1209
|
-
this.selectedZone = null;
|
|
1210
|
-
this.selectedAddressIndex = null;
|
|
1211
|
-
} else {
|
|
1212
|
-
// If the form is closed, open it and clear address selection
|
|
1213
|
-
this.formData.address = {
|
|
1214
|
-
country: "",
|
|
1215
|
-
city: "",
|
|
1216
|
-
address: "",
|
|
1217
|
-
address_line2: "",
|
|
1218
|
-
zone: "",
|
|
1219
|
-
postal_code: "",
|
|
1220
|
-
};
|
|
1221
|
-
this.selectedCountry = null;
|
|
1222
|
-
this.selectedZone = null;
|
|
1223
|
-
this.showAddressForm = true;
|
|
1224
|
-
this.selectedAddressIndex = null;
|
|
1225
|
-
this.countryAutoSet = false;
|
|
1226
|
-
this.syncCountry();
|
|
1227
|
-
}
|
|
1228
|
-
},
|
|
1229
|
-
syncCountry() {
|
|
1230
|
-
const ipCountryName =
|
|
1231
|
-
this.userStore?.clientIpInfo?.country_name ||
|
|
1232
|
-
this.userStore?.clientIpInfo?.country;
|
|
1233
|
-
if (!ipCountryName || !this.countries || !this.countries.length) return;
|
|
1234
|
-
if (!this.countryAutoSet && !this.selectedCountry) {
|
|
1235
|
-
const target = String(ipCountryName).trim().toLowerCase();
|
|
1236
|
-
const matchedCountry = this.countries.find(
|
|
1237
|
-
(c) =>
|
|
1238
|
-
(c.country_name && c.country_name.trim().toLowerCase() === target) ||
|
|
1239
|
-
(c.country_code && c.country_code.trim().toLowerCase() === target) ||
|
|
1240
|
-
(c.code && c.code.trim().toLowerCase() === target)
|
|
1241
|
-
);
|
|
1242
|
-
if (matchedCountry) {
|
|
1243
|
-
this.selectCountry(matchedCountry);
|
|
1244
|
-
this.countryAutoSet = true;
|
|
1245
|
-
}
|
|
1246
|
-
}
|
|
1247
|
-
},
|
|
1248
|
-
async selectCountry(country) {
|
|
1249
|
-
this.countryAutoSet = true;
|
|
1250
|
-
// If user clicks the same country again, deselect it
|
|
1251
|
-
if (this.selectedCountry === country) {
|
|
1252
|
-
this.selectedCountry = "";
|
|
1253
|
-
this.formData.address.country = "";
|
|
1254
|
-
this.isFreeDelivery = false;
|
|
1255
|
-
this.applyDefaultZone(null);
|
|
1256
|
-
} else {
|
|
1257
|
-
this.selectedCountry = country;
|
|
1258
|
-
this.formData.address.country = country.id;
|
|
1259
|
-
// countries with has_zones = false have a single hidden "Default Zone":
|
|
1260
|
-
// pick it silently so it still goes out in the payload
|
|
1261
|
-
this.applyDefaultZone(country);
|
|
1262
|
-
await this.checkFreeDelivery(country.id);
|
|
1263
|
-
}
|
|
1264
|
-
},
|
|
1265
|
-
// silently select the default zone for countries that don't expose zones
|
|
1266
|
-
applyDefaultZone(country) {
|
|
1267
|
-
const zones = country?.shipping_zones || [];
|
|
1268
|
-
const isNewAddress =
|
|
1269
|
-
this.formData.address && typeof this.formData.address === "object";
|
|
1270
|
-
|
|
1271
|
-
if (country && !country.has_zones && zones.length) {
|
|
1272
|
-
this.selectedZone = zones[0];
|
|
1273
|
-
if (isNewAddress) this.formData.address.zone = zones[0].id;
|
|
1274
|
-
} else {
|
|
1275
|
-
this.selectedZone = "";
|
|
1276
|
-
if (isNewAddress) this.formData.address.zone = "";
|
|
1277
|
-
}
|
|
1278
|
-
},
|
|
1279
|
-
async checkFreeDelivery(countryId) {
|
|
1280
|
-
this.isShippingLoading = true;
|
|
1281
|
-
this.isFreeDelivery = false;
|
|
1282
|
-
this.freeDeliveryId = null;
|
|
1283
|
-
try {
|
|
1284
|
-
const response = await checkoutServices.freeDeliveries(countryId);
|
|
1285
|
-
const results = response.results || (Array.isArray(response) ? response : []);
|
|
1286
|
-
|
|
1287
|
-
if (results.length > 0) {
|
|
1288
|
-
const applicableRule = results.find(rule => this.total_price >= rule.minimum_order_amount);
|
|
1289
|
-
if (applicableRule) {
|
|
1290
|
-
this.isFreeDelivery = true;
|
|
1291
|
-
this.freeDeliveryId = applicableRule.id;
|
|
1292
|
-
} else {
|
|
1293
|
-
this.isFreeDelivery = false;
|
|
1294
|
-
this.freeDeliveryId = null;
|
|
1295
|
-
}
|
|
1296
|
-
} else {
|
|
1297
|
-
this.isFreeDelivery = false;
|
|
1298
|
-
this.freeDeliveryId = null;
|
|
1299
|
-
}
|
|
1300
|
-
} catch (error) {
|
|
1301
|
-
console.error(error);
|
|
1302
|
-
this.isFreeDelivery = false;
|
|
1303
|
-
this.freeDeliveryId = null;
|
|
1304
|
-
} finally {
|
|
1305
|
-
this.isShippingLoading = false;
|
|
1306
|
-
}
|
|
1307
|
-
},
|
|
1308
|
-
// --- height-based accordion animation (bottom order summary) ---
|
|
1309
|
-
accordionEnter(el, done) {
|
|
1310
|
-
el.style.overflow = "hidden";
|
|
1311
|
-
el.style.height = "0px";
|
|
1312
|
-
el.style.opacity = "0";
|
|
1313
|
-
void el.offsetHeight;
|
|
1314
|
-
el.style.transition = "height 0.3s ease, opacity 0.25s ease";
|
|
1315
|
-
el.style.height = `${el.scrollHeight}px`;
|
|
1316
|
-
el.style.opacity = "1";
|
|
1317
|
-
this.onAccordionEnd(el, done);
|
|
1318
|
-
},
|
|
1319
|
-
accordionLeave(el, done) {
|
|
1320
|
-
el.style.overflow = "hidden";
|
|
1321
|
-
el.style.height = `${el.scrollHeight}px`;
|
|
1322
|
-
el.style.opacity = "1";
|
|
1323
|
-
void el.offsetHeight;
|
|
1324
|
-
el.style.transition = "height 0.3s ease, opacity 0.25s ease";
|
|
1325
|
-
el.style.height = "0px";
|
|
1326
|
-
el.style.opacity = "0";
|
|
1327
|
-
this.onAccordionEnd(el, done);
|
|
1328
|
-
},
|
|
1329
|
-
accordionAfterEnter(el) {
|
|
1330
|
-
el.style.transition = "";
|
|
1331
|
-
el.style.height = "";
|
|
1332
|
-
el.style.opacity = "";
|
|
1333
|
-
el.style.overflow = "";
|
|
1334
|
-
},
|
|
1335
|
-
onAccordionEnd(el, done) {
|
|
1336
|
-
let finished = false;
|
|
1337
|
-
const finish = (event) => {
|
|
1338
|
-
if (event && (event.target !== el || event.propertyName !== "height")) {
|
|
1339
|
-
return;
|
|
1340
|
-
}
|
|
1341
|
-
if (finished) return;
|
|
1342
|
-
finished = true;
|
|
1343
|
-
clearTimeout(timer);
|
|
1344
|
-
el.removeEventListener("transitionend", finish);
|
|
1345
|
-
done();
|
|
1346
|
-
};
|
|
1347
|
-
const timer = setTimeout(finish, 400);
|
|
1348
|
-
el.addEventListener("transitionend", finish);
|
|
1349
|
-
},
|
|
1350
|
-
selectZone(zone) {
|
|
1351
|
-
if (this.selectedZone === zone) {
|
|
1352
|
-
this.selectedZone = "";
|
|
1353
|
-
this.formData.address.zone = "";
|
|
1354
|
-
} else {
|
|
1355
|
-
this.selectedZone = zone;
|
|
1356
|
-
this.formData.address.zone = zone.id;
|
|
1357
|
-
}
|
|
1358
|
-
},
|
|
1359
|
-
//get cart items — shared state, so this refreshes the drawer too
|
|
1360
|
-
async getCartItems() {
|
|
1361
|
-
await this.fetchCart();
|
|
1362
|
-
},
|
|
1363
|
-
handleUserInfo() {
|
|
1364
|
-
if (!authBus || !authBus.isLoggedIn) {
|
|
1365
|
-
this.needsUserInfo = false;
|
|
1366
|
-
this.isLoggedIn = false;
|
|
1367
|
-
return;
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
if (authBus.userInfo) {
|
|
1371
|
-
this.isUserLoading = false;
|
|
1372
|
-
this.isLoggedIn = authBus.isLoggedIn;
|
|
1373
|
-
this.userAddresses = authBus.userInfo.customer_addresses;
|
|
1374
|
-
this.formData.first_name = authBus.userInfo.first_name || "";
|
|
1375
|
-
this.formData.last_name = authBus.userInfo.last_name || "";
|
|
1376
|
-
this.formData.email = authBus.userInfo.email || "";
|
|
1377
|
-
this.formData.mobile_number = authBus.userInfo.mobile_number || "";
|
|
1378
|
-
}
|
|
1379
|
-
},
|
|
1380
|
-
// Quantity changes go through useCart(): it applies the server's response
|
|
1381
|
-
// to the shared state, so the drawer and header badge follow along and
|
|
1382
|
-
// there's no second fetch.
|
|
1383
|
-
async increaseQuantity(item) {
|
|
1384
|
-
try {
|
|
1385
|
-
await this.editItem(item, "add");
|
|
1386
|
-
} catch (error) {
|
|
1387
|
-
console.error("Error adding to cart:", error);
|
|
1388
|
-
this.toast.error(extractErrorMessage(error));
|
|
1389
|
-
}
|
|
1390
|
-
},
|
|
1391
|
-
async decreaseQuantity(item) {
|
|
1392
|
-
try {
|
|
1393
|
-
await this.editItem(item, "remove");
|
|
1394
|
-
} catch (error) {
|
|
1395
|
-
console.error("Error decreasing quantity:", error);
|
|
1396
|
-
this.toast.error(extractErrorMessage(error));
|
|
1397
|
-
}
|
|
1398
|
-
},
|
|
1399
|
-
async deleteItem(item) {
|
|
1400
|
-
try {
|
|
1401
|
-
await this.removeItem(item);
|
|
1402
|
-
} catch (error) {
|
|
1403
|
-
console.error("Error removing from cart:", error);
|
|
1404
|
-
this.toast.error(extractErrorMessage(error));
|
|
1405
|
-
}
|
|
1406
|
-
},
|
|
1407
|
-
async waitForUserInfoIfNeeded() {
|
|
1408
|
-
if (!authBus.isLoggedIn) return;
|
|
1409
|
-
|
|
1410
|
-
// Wait for user info with timeout as fallback
|
|
1411
|
-
await new Promise((resolve) => {
|
|
1412
|
-
const checkInterval = setInterval(() => {
|
|
1413
|
-
if (authBus.userInfo) {
|
|
1414
|
-
clearInterval(checkInterval);
|
|
1415
|
-
this.handleUserInfo();
|
|
1416
|
-
resolve();
|
|
1417
|
-
}
|
|
1418
|
-
}, 0);
|
|
1419
|
-
|
|
1420
|
-
// Fallback in case user info never arrives
|
|
1421
|
-
setTimeout(() => {
|
|
1422
|
-
clearInterval(checkInterval);
|
|
1423
|
-
this.needsUserInfo = false;
|
|
1424
|
-
resolve();
|
|
1425
|
-
}, 0);
|
|
1426
|
-
});
|
|
1427
|
-
},
|
|
1428
|
-
// Validates required checkout fields and populates `formErrors` (keyed by
|
|
1429
|
-
// the same names used in each field's `data-field` attribute) so inputs
|
|
1430
|
-
// can show a red border + inline message instead of a toast.
|
|
1431
|
-
validateForm() {
|
|
1432
|
-
const errors = {};
|
|
1433
|
-
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
1434
|
-
|
|
1435
|
-
if (!this.formData.first_name)
|
|
1436
|
-
errors.first_name = "First name is required.";
|
|
1437
|
-
if (!this.formData.last_name)
|
|
1438
|
-
errors.last_name = "Last name is required.";
|
|
1439
|
-
if (!this.formData.email) {
|
|
1440
|
-
errors.email_required = true;
|
|
1441
|
-
} else if (!emailPattern.test(this.formData.email)) {
|
|
1442
|
-
errors.email = "Enter a valid email address.";
|
|
1443
|
-
}
|
|
1444
|
-
if (!this.formData.payment_method_id)
|
|
1445
|
-
errors.payment_method_id = "Payment method is required.";
|
|
1446
|
-
if (!this.isLoggedIn) {
|
|
1447
|
-
this.phoneTouched = true;
|
|
1448
|
-
if (!this.formData.mobile_number) {
|
|
1449
|
-
// Empty phone joins the combined "fill all required fields" line
|
|
1450
|
-
// instead of showing its own message (see detailsErrorFields).
|
|
1451
|
-
errors.mobile_number_required = true;
|
|
1452
|
-
} else if (!this.phoneNumberResults?.isValid) {
|
|
1453
|
-
// Wrong-format phone still gets its own standalone message.
|
|
1454
|
-
errors.mobile_number = "This phone number is not valid.";
|
|
1455
|
-
}
|
|
1456
|
-
}
|
|
1457
|
-
|
|
1458
|
-
// Validate address only if it's a new address (object). An existing
|
|
1459
|
-
// address selected by id (string) is already complete.
|
|
1460
|
-
if (!this.formData.address) {
|
|
1461
|
-
errors.country = "Country is required.";
|
|
1462
|
-
errors.city = "City is required.";
|
|
1463
|
-
errors.address = "Street & area is required.";
|
|
1464
|
-
} else if (typeof this.formData.address === "object") {
|
|
1465
|
-
if (!this.formData.address.country)
|
|
1466
|
-
errors.country = "Country is required.";
|
|
1467
|
-
if (!this.formData.address.city) errors.city = "City is required.";
|
|
1468
|
-
if (!this.formData.address.address)
|
|
1469
|
-
errors.address = "Street & area is required.";
|
|
1470
|
-
if (this.zoneRequired && !this.selectedZone?.id) {
|
|
1471
|
-
errors.zone = "Please select a zone.";
|
|
1472
|
-
}
|
|
1473
|
-
}
|
|
1474
|
-
|
|
1475
|
-
this.formErrors = errors;
|
|
1476
|
-
return Object.keys(errors).length === 0;
|
|
1477
|
-
},
|
|
1478
|
-
// PhoneNumInput emits its parsed result on every keystroke. We just store
|
|
1479
|
-
// the latest result; we don't touch phoneTouched here so no error shows
|
|
1480
|
-
// while the user is still typing — only on submit (see validateForm).
|
|
1481
|
-
onPhoneUpdate(results) {
|
|
1482
|
-
this.phoneNumberResults = { ...results };
|
|
1483
|
-
if (this.formData.mobile_number) this.clearFieldError("mobile_number_required");
|
|
1484
|
-
},
|
|
1485
|
-
// Clears a single field's error as the user fixes it, instead of waiting
|
|
1486
|
-
// for the next full submit attempt to re-validate.
|
|
1487
|
-
clearFieldError(field) {
|
|
1488
|
-
if (this.formErrors[field]) {
|
|
1489
|
-
this.formErrors = { ...this.formErrors, [field]: "" };
|
|
1490
|
-
}
|
|
1491
|
-
},
|
|
1492
|
-
// Like clearFieldError, but for email: only lifts the red state once the
|
|
1493
|
-
// typed value actually matches a valid email format, instead of clearing
|
|
1494
|
-
// on the first keystroke.
|
|
1495
|
-
validateEmailField() {
|
|
1496
|
-
if (this.formData.email) this.clearFieldError("email_required");
|
|
1497
|
-
if (!this.formErrors.email) return;
|
|
1498
|
-
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
1499
|
-
if (this.formData.email && emailPattern.test(this.formData.email)) {
|
|
1500
|
-
this.clearFieldError("email");
|
|
1501
|
-
}
|
|
1502
|
-
},
|
|
1503
|
-
async submitForm() {
|
|
1504
|
-
const toast = useToast();
|
|
1505
|
-
const cleanedCart = Object.fromEntries(
|
|
1506
|
-
Object.entries(this.rawCart?.cart ?? {}).map(([id, item]) => [
|
|
1507
|
-
id,
|
|
1508
|
-
{
|
|
1509
|
-
name: item.name,
|
|
1510
|
-
quantity: item.quantity,
|
|
1511
|
-
price: item.price,
|
|
1512
|
-
image: item.image,
|
|
1513
|
-
},
|
|
1514
|
-
]),
|
|
1515
|
-
);
|
|
1516
|
-
|
|
1517
|
-
// Prepare the payload
|
|
1518
|
-
const payload = {
|
|
1519
|
-
payment_method_id: this.formData.payment_method_id,
|
|
1520
|
-
cart: cleanedCart,
|
|
1521
|
-
};
|
|
1522
|
-
|
|
1523
|
-
// Where the gateway callback sends the browser back. Validated server-side
|
|
1524
|
-
// against the store's own origins, so an unknown host can't be injected -
|
|
1525
|
-
// sending it explicitly just keeps the buyer on the domain they came from
|
|
1526
|
-
// instead of the store's first known origin.
|
|
1527
|
-
if (typeof window !== "undefined") {
|
|
1528
|
-
payload.return_url = `${window.location.origin}/checkout/result`;
|
|
1529
|
-
}
|
|
1530
|
-
if (this.promo_id && this.promo_id !== "") {
|
|
1531
|
-
payload.promo_id = this.promo_id;
|
|
1532
|
-
}
|
|
1533
|
-
|
|
1534
|
-
if (this.freeDeliveryId && this.freeDeliveryId !== "") {
|
|
1535
|
-
payload.freedelivery_id = this.freeDeliveryId;
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
if (this.extraFeesPayload.length > 0) {
|
|
1539
|
-
if (!this.extraFeesValid) {
|
|
1540
|
-
toast.error("Check the donation amount before completing your order.");
|
|
1541
|
-
return;
|
|
1542
|
-
}
|
|
1543
|
-
payload.extra_fees = this.extraFeesPayload;
|
|
1544
|
-
}
|
|
1545
|
-
if (typeof this.formData.address === "string") {
|
|
1546
|
-
payload.address_id = this.formData.address; // Using address_id instead of address
|
|
1547
|
-
} else {
|
|
1548
|
-
payload.address = { ...this.formData.address };
|
|
1549
|
-
if (this.selectedZone && this.selectedZone.id) {
|
|
1550
|
-
payload.address.zone = this.selectedZone.id;
|
|
1551
|
-
} else {
|
|
1552
|
-
delete payload.address.zone;
|
|
1553
|
-
}
|
|
1554
|
-
if (!payload.address.address_line2) {
|
|
1555
|
-
delete payload.address.address_line2;
|
|
1556
|
-
}
|
|
1557
|
-
}
|
|
1558
|
-
// Validate the form; on failure, inline field errors are set and the
|
|
1559
|
-
// page scrolls to the first invalid field instead of showing a toast.
|
|
1560
|
-
if (!this.validateForm()) {
|
|
1561
|
-
await this.$nextTick();
|
|
1562
|
-
scrollToFirstError(this.formErrors);
|
|
1563
|
-
return;
|
|
1564
|
-
}
|
|
1565
|
-
|
|
1566
|
-
// Add guest data if the user is not logged in
|
|
1567
|
-
if (!this.isLoggedIn) {
|
|
1568
|
-
payload.guest = {
|
|
1569
|
-
first_name: this.formData.first_name,
|
|
1570
|
-
last_name: this.formData.last_name,
|
|
1571
|
-
email: this.formData.email,
|
|
1572
|
-
mobile_number: this.formData.mobile_number,
|
|
1573
|
-
};
|
|
1574
|
-
}
|
|
1575
|
-
|
|
1576
|
-
// The shopper has committed to a destination and a payment method.
|
|
1577
|
-
// Reported here, once the form is valid, in funnel order.
|
|
1578
|
-
const coupon = this.promo_id ? this.promoCode || undefined : undefined;
|
|
1579
|
-
const chosenMethod = this.paymentMethods.find(
|
|
1580
|
-
(method) => method.id === this.formData.payment_method_id,
|
|
1581
|
-
);
|
|
1582
|
-
analytics.addShippingInfo(this.CartItems, {
|
|
1583
|
-
tier:
|
|
1584
|
-
this.selectedZone?.name ||
|
|
1585
|
-
this.selectedCountry?.country_name ||
|
|
1586
|
-
this.selectedAddress?.country_name ||
|
|
1587
|
-
undefined,
|
|
1588
|
-
coupon,
|
|
1589
|
-
});
|
|
1590
|
-
analytics.addPaymentInfo(this.CartItems, {
|
|
1591
|
-
paymentType: chosenMethod?.name,
|
|
1592
|
-
coupon,
|
|
1593
|
-
});
|
|
1594
|
-
|
|
1595
|
-
try {
|
|
1596
|
-
this.isCheckingOut = true;
|
|
1597
|
-
// Place the order. The server decides what happens next via payment.flow.
|
|
1598
|
-
const response = await checkoutServices.placeOrder(payload);
|
|
1599
|
-
this.placedOrderId = response.order_id;
|
|
1600
|
-
// Fallback for anything still reading the id out of session storage.
|
|
1601
|
-
sessionStorage.setItem("orderId", response.order_id);
|
|
1602
|
-
|
|
1603
|
-
// No fallback to OFFLINE. Defaulting an unrecognised response to
|
|
1604
|
-
// "already paid" would clear the bag and show the order summary for an
|
|
1605
|
-
// order nobody has paid for - the worst available default.
|
|
1606
|
-
const payment = response.payment;
|
|
1607
|
-
if (!payment?.flow) {
|
|
1608
|
-
console.error("checkout returned no payment object", response);
|
|
1609
|
-
this.toast.error("Payment could not be started. Please try again.");
|
|
1610
|
-
this.isCheckingOut = false;
|
|
1611
|
-
return;
|
|
1612
|
-
}
|
|
1613
|
-
if (payment.reference) paymentServices.setReference(payment.reference);
|
|
1614
|
-
|
|
1615
|
-
if (payment.flow === "OFFLINE") {
|
|
1616
|
-
// Already placed (COD, bank transfer) - safe to clear the bag here.
|
|
1617
|
-
this.toast.success("Order added successfully!");
|
|
1618
|
-
paymentServices.clearReference();
|
|
1619
|
-
// this.clearCart();
|
|
1620
|
-
eventBus.emit("cartUpdated");
|
|
1621
|
-
this.$router.replace({
|
|
1622
|
-
path: "/order-detail",
|
|
1623
|
-
query: { placed: "1" },
|
|
1624
|
-
|
|
1625
|
-
});
|
|
1626
|
-
return;
|
|
1627
|
-
}
|
|
1628
|
-
|
|
1629
|
-
// Card / redirect: hand off to the gateway. The bag stays until
|
|
1630
|
-
// /payments/verify/ says paid on the result page.
|
|
1631
|
-
this.activePayment = payment;
|
|
1632
|
-
} catch (error) {
|
|
1633
|
-
this.isCheckingOut = false;
|
|
1634
|
-
console.error("Error placing order:", error);
|
|
1635
|
-
this.toast.error(extractErrorMessage(error));
|
|
1636
|
-
} finally {
|
|
1637
|
-
this.isCheckingOut = false;
|
|
1638
|
-
}
|
|
1639
|
-
},
|
|
1640
|
-
async applyPromoCode() {
|
|
1641
|
-
if (this.promoCode) {
|
|
1642
|
-
try {
|
|
1643
|
-
this.isCodeLoading = true;
|
|
1644
|
-
const response = await checkoutServices.promoCode(this.promoCode);
|
|
1645
|
-
if (response.results && response.results.length > 0) {
|
|
1646
|
-
this.discount = response.results[0].discount_value;
|
|
1647
|
-
this.discountType = response.results[0].discount_type;
|
|
1648
|
-
this.promo_id = response.results[0].id;
|
|
1649
|
-
this.toast.success("Promo code applied");
|
|
1650
|
-
} else {
|
|
1651
|
-
this.toast.error("Invalid promo code");
|
|
1652
|
-
}
|
|
1653
|
-
} catch (error) {
|
|
1654
|
-
this.toast.error(extractErrorMessage(error));
|
|
1655
|
-
} finally {
|
|
1656
|
-
this.isCodeLoading = false;
|
|
1657
|
-
}
|
|
1658
|
-
}
|
|
1659
|
-
},
|
|
1660
|
-
removePromo() {
|
|
1661
|
-
this.promoCode = "";
|
|
1662
|
-
this.discount = null;
|
|
1663
|
-
this.discountType = null;
|
|
1664
|
-
this.promo_id = null;
|
|
1665
|
-
},
|
|
1666
|
-
onExtraFeesChange({ payload, total, valid, lines }) {
|
|
1667
|
-
this.extraFeesPayload = payload;
|
|
1668
|
-
this.extraFeesTotal = total;
|
|
1669
|
-
this.extraFeesValid = valid;
|
|
1670
|
-
this.extraFeeLines = lines;
|
|
1671
|
-
},
|
|
1672
|
-
async getExtraFees() {
|
|
1673
|
-
try {
|
|
1674
|
-
const response = await checkoutServices.extraFees();
|
|
1675
|
-
this.extraFees = response.results || (Array.isArray(response) ? response : []);
|
|
1676
|
-
} catch (error) {
|
|
1677
|
-
this.extraFees = [];
|
|
1678
|
-
}
|
|
1679
|
-
},
|
|
1680
|
-
// Fetches payment methods and unwraps whatever shape the API returns
|
|
1681
|
-
// (paginated { results: [...] } or a bare array) - same pattern used for
|
|
1682
|
-
// extraFees / freeDeliveries above. This was previously assigned directly,
|
|
1683
|
-
// which silently broke rendering whenever the endpoint returned a
|
|
1684
|
-
// paginated object instead of a bare array.
|
|
1685
|
-
async getPaymentMethods() {
|
|
1686
|
-
try {
|
|
1687
|
-
const response = await paymentServices.getMethods();
|
|
1688
|
-
this.paymentMethods =
|
|
1689
|
-
response?.results || (Array.isArray(response) ? response : []);
|
|
1690
|
-
} catch (e) {
|
|
1691
|
-
this.paymentMethods = [];
|
|
1692
|
-
this.toast.error("Payment methods could not be loaded.");
|
|
1693
|
-
}
|
|
1694
|
-
},
|
|
1695
|
-
},
|
|
1696
|
-
async mounted() {
|
|
1697
|
-
await this.getPaymentMethods();
|
|
1698
|
-
const result = await utilsServices.getCountries();
|
|
1699
|
-
this.countries = result;
|
|
1700
|
-
this.syncCountry();
|
|
1701
|
-
this.handleUserInfo();
|
|
1702
|
-
await Promise.all([this.getCartItems(), this.waitForUserInfoIfNeeded(), this.getExtraFees()]);
|
|
1703
|
-
// begin_checkout / InitiateCheckout: the page is open and the bag is loaded.
|
|
1704
|
-
analytics.beginCheckout(this.CartItems);
|
|
1705
|
-
},
|
|
1706
|
-
updated() {
|
|
1707
|
-
if (authBus.userInfo && this.isUserLoading) {
|
|
1708
|
-
this.handleUserInfo();
|
|
1709
|
-
}
|
|
1710
|
-
},
|
|
1711
|
-
computed: {
|
|
1712
|
-
// Read-only views onto the shared cart. Keeping the template's original
|
|
1713
|
-
// names means nothing in the 900-line markup had to change.
|
|
1714
|
-
CartItems() {
|
|
1715
|
-
return this.cartItems;
|
|
1716
|
-
},
|
|
1717
|
-
total_price() {
|
|
1718
|
-
return this.cartTotal;
|
|
1719
|
-
},
|
|
1720
|
-
totalItemsCount() {
|
|
1721
|
-
return this.CartItems.reduce(
|
|
1722
|
-
(sum, item) => sum + Number(item.quantity || 0),
|
|
1723
|
-
0,
|
|
1724
|
-
);
|
|
1725
|
-
},
|
|
1726
|
-
displayedPaymentMethods() {
|
|
1727
|
-
return this.paymentMethods;
|
|
1728
|
-
},
|
|
1729
|
-
totalPrice() {
|
|
1730
|
-
let total = this.total_price;
|
|
1731
|
-
this.originalTotal = total + this.shippingCost + this.extraFeesTotal;
|
|
1732
|
-
if (this.discount) {
|
|
1733
|
-
if (this.discountType === "Percentage") {
|
|
1734
|
-
total = total - (total * this.discount) / 100;
|
|
1735
|
-
} else if (this.discountType === "Fixed Amount") {
|
|
1736
|
-
total = total - Number(this.discount);
|
|
1737
|
-
}
|
|
1738
|
-
}
|
|
1739
|
-
|
|
1740
|
-
const shipping = this.isFreeDelivery ? 0 : this.shippingCost;
|
|
1741
|
-
return total + shipping + this.extraFeesTotal;
|
|
1742
|
-
},
|
|
1743
|
-
isLoading() {
|
|
1744
|
-
return this.cartLoading || this.needsUserInfo;
|
|
1745
|
-
},
|
|
1746
|
-
// only ask the user to pick a zone when the country actually exposes them
|
|
1747
|
-
zoneRequired() {
|
|
1748
|
-
return !!(
|
|
1749
|
-
this.selectedCountry &&
|
|
1750
|
-
this.selectedCountry.has_zones &&
|
|
1751
|
-
this.selectedCountry.shipping_zones?.length
|
|
1752
|
-
);
|
|
1753
|
-
},
|
|
1754
|
-
// Options for the Country DropdownSelect - value is the country object
|
|
1755
|
-
// itself so selectCountry() keeps receiving what it always received.
|
|
1756
|
-
countryOptions() {
|
|
1757
|
-
return (this.countries || []).map((country) => ({
|
|
1758
|
-
label: country.country_name,
|
|
1759
|
-
value: country,
|
|
1760
|
-
}));
|
|
1761
|
-
},
|
|
1762
|
-
// Options for the Zone DropdownSelect - value is the zone object itself
|
|
1763
|
-
// so selectZone() keeps receiving what it always received.
|
|
1764
|
-
zoneOptions() {
|
|
1765
|
-
return (this.selectedCountry?.shipping_zones || []).map((zone) => ({
|
|
1766
|
-
label: zone.name,
|
|
1767
|
-
value: zone,
|
|
1768
|
-
}));
|
|
1769
|
-
},
|
|
1770
|
-
selectedAddress() {
|
|
1771
|
-
return this.selectedAddressIndex !== null
|
|
1772
|
-
? this.userAddresses[this.selectedAddressIndex]
|
|
1773
|
-
: null;
|
|
1774
|
-
},
|
|
1775
|
-
shippingCost() {
|
|
1776
|
-
// If an address is selected, return its shipping fee
|
|
1777
|
-
if (
|
|
1778
|
-
this.selectedAddressIndex !== null &&
|
|
1779
|
-
this.userAddresses[this.selectedAddressIndex]
|
|
1780
|
-
) {
|
|
1781
|
-
return this.userAddresses[this.selectedAddressIndex].shipping_fee || 0;
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
|
-
// If the user has selected a zone, return the zone's shipping fee
|
|
1785
|
-
if (this.selectedZone && this.selectedZone.shipment_price) {
|
|
1786
|
-
return this.selectedZone.shipment_price || 0;
|
|
1787
|
-
}
|
|
1788
|
-
|
|
1789
|
-
// If a zone is not selected, fall back to the country's shipping fee
|
|
1790
|
-
if (this.selectedCountry) {
|
|
1791
|
-
return this.selectedCountry.shipment_price || 0;
|
|
1792
|
-
}
|
|
1793
|
-
|
|
1794
|
-
return 0; // Default fallback if nothing is selected
|
|
1795
|
-
},
|
|
1796
|
-
// Missing/invalid field labels for the "Your details" section, used to
|
|
1797
|
-
// build a single combined message instead of showing one error at a time.
|
|
1798
|
-
// Shrinks automatically as formErrors clears each field.
|
|
1799
|
-
detailsErrorFields() {
|
|
1800
|
-
const labels = {
|
|
1801
|
-
email_required: "Email",
|
|
1802
|
-
first_name: "First name",
|
|
1803
|
-
last_name: "Last name",
|
|
1804
|
-
...(this.isLoggedIn ? {} : { mobile_number_required: "Phone number" }),
|
|
1805
|
-
};
|
|
1806
|
-
return Object.keys(labels)
|
|
1807
|
-
.filter((key) => this.formErrors[key])
|
|
1808
|
-
.map((key) => labels[key]);
|
|
1809
|
-
},
|
|
1810
|
-
// Standalone message shown only for a filled-but-wrong-format email.
|
|
1811
|
-
// An empty email instead joins the combined "fill all required fields"
|
|
1812
|
-
// line via detailsErrorFields, so it isn't duplicated here.
|
|
1813
|
-
emailErrorMessage() {
|
|
1814
|
-
return this.formErrors.email || "";
|
|
1815
|
-
},
|
|
1816
|
-
// Red border on the field for either error type (empty or invalid),
|
|
1817
|
-
// separate from emailErrorMessage which only covers the invalid case.
|
|
1818
|
-
emailHasError() {
|
|
1819
|
-
return !!(this.formErrors.email || this.formErrors.email_required);
|
|
1820
|
-
},
|
|
1821
|
-
// Standalone message shown only for a filled-but-wrong-format number.
|
|
1822
|
-
// An empty number instead joins the combined "fill all required fields"
|
|
1823
|
-
// line via detailsErrorFields, so it isn't duplicated here.
|
|
1824
|
-
phoneErrorMessage() {
|
|
1825
|
-
if (this.isLoggedIn) return "";
|
|
1826
|
-
return this.formErrors.mobile_number || "";
|
|
1827
|
-
},
|
|
1828
|
-
// Red border on the field for either error type (empty or invalid),
|
|
1829
|
-
// separate from phoneErrorMessage which only covers the invalid case.
|
|
1830
|
-
phoneHasError() {
|
|
1831
|
-
if (this.isLoggedIn) return false;
|
|
1832
|
-
return !!(this.formErrors.mobile_number || this.formErrors.mobile_number_required);
|
|
1833
|
-
},
|
|
1834
|
-
// Same idea for the "Delivery address" section.
|
|
1835
|
-
deliveryErrorFields() {
|
|
1836
|
-
const labels = {
|
|
1837
|
-
country: "Country",
|
|
1838
|
-
zone: "Zone",
|
|
1839
|
-
city: "City",
|
|
1840
|
-
address: "Street & area",
|
|
1841
|
-
};
|
|
1842
|
-
return Object.keys(labels)
|
|
1843
|
-
.filter((key) => this.formErrors[key])
|
|
1844
|
-
.map((key) => labels[key]);
|
|
1845
|
-
},
|
|
1846
|
-
isFormComplete() {
|
|
1847
|
-
const isExistingAddressSelected =
|
|
1848
|
-
typeof this.formData.address === "string"; // Ensure it's an ID
|
|
1849
|
-
|
|
1850
|
-
const isNewAddressValid =
|
|
1851
|
-
this.formData.address &&
|
|
1852
|
-
typeof this.formData.address === "object" &&
|
|
1853
|
-
this.formData.address.address &&
|
|
1854
|
-
this.formData.address.city &&
|
|
1855
|
-
this.formData.address.country &&
|
|
1856
|
-
this.formData.address.postal_code &&
|
|
1857
|
-
(!this.zoneRequired || this.formData.address.zone);
|
|
1858
|
-
|
|
1859
|
-
const isPhoneValid =
|
|
1860
|
-
this.phoneNumberResults && this.phoneNumberResults.isValid; //check if phone number is valid
|
|
1861
|
-
|
|
1862
|
-
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; //email validation
|
|
1863
|
-
const isEmailValid = emailPattern.test(this.formData.email);
|
|
1864
|
-
|
|
1865
|
-
return (
|
|
1866
|
-
this.formData.first_name &&
|
|
1867
|
-
this.formData.last_name &&
|
|
1868
|
-
isEmailValid &&
|
|
1869
|
-
this.formData.payment_method_id &&
|
|
1870
|
-
isPhoneValid &&
|
|
1871
|
-
(isExistingAddressSelected || isNewAddressValid) //accept either an address ID or a new address
|
|
1872
|
-
);
|
|
1873
|
-
},
|
|
1874
|
-
|
|
1875
|
-
syncedCart() {
|
|
1876
|
-
return this.CartItems;
|
|
1877
|
-
},
|
|
1878
|
-
},
|
|
1879
|
-
|
|
1880
|
-
watch: {
|
|
1881
|
-
// Only lifts the red state on the phone field once the number actually
|
|
1882
|
-
// validates, instead of clearing on the first keystroke.
|
|
1883
|
-
phoneNumberResults(results) {
|
|
1884
|
-
if (this.formErrors.mobile_number && results?.isValid) {
|
|
1885
|
-
this.clearFieldError("mobile_number");
|
|
1886
|
-
}
|
|
1887
|
-
},
|
|
1888
|
-
"userStore.clientIpInfo": {
|
|
1889
|
-
handler() {
|
|
1890
|
-
this.syncCountry();
|
|
1891
|
-
},
|
|
1892
|
-
deep: true,
|
|
1893
|
-
immediate: true,
|
|
1894
|
-
},
|
|
1895
|
-
selectedCountry(newCountry, oldCountry) {
|
|
1896
|
-
if (newCountry && oldCountry && newCountry.id !== oldCountry.id) {
|
|
1897
|
-
// If the country changes, reset the zone (and re-apply the hidden
|
|
1898
|
-
// default zone when the new country has has_zones = false)
|
|
1899
|
-
this.applyDefaultZone(newCountry);
|
|
1900
|
-
}
|
|
1901
|
-
},
|
|
1902
|
-
selectedZone(newZone, oldZone) {
|
|
1903
|
-
if (newZone && oldZone && newZone.id !== oldZone.id) {
|
|
1904
|
-
// If the zone changes, ensure that the country is set correctly
|
|
1905
|
-
if (!this.selectedCountry) {
|
|
1906
|
-
// Optionally, set a fallback country if zone is selected without a country
|
|
1907
|
-
this.selectedCountry = this.defaultCountry;
|
|
1908
|
-
}
|
|
1909
|
-
}
|
|
1910
|
-
},
|
|
1911
|
-
CartItems: {
|
|
1912
|
-
handler(newCart) {
|
|
1913
|
-
this.formData.cart = [...newCart]; // Update formData.cart whenever CartItems changes
|
|
1914
|
-
},
|
|
1915
|
-
deep: true, // Ensures nested changes are also detected
|
|
1916
|
-
},
|
|
1917
|
-
},
|
|
1918
|
-
};
|
|
968
|
+
const {
|
|
969
|
+
// display
|
|
970
|
+
currency,
|
|
971
|
+
imageUrl,
|
|
972
|
+
isLoading,
|
|
973
|
+
mobileSummaryOpen,
|
|
974
|
+
bottomDiscountOpen,
|
|
975
|
+
accordionEnter,
|
|
976
|
+
accordionLeave,
|
|
977
|
+
accordionAfterEnter,
|
|
978
|
+
// cart
|
|
979
|
+
CartItems,
|
|
980
|
+
total_price,
|
|
981
|
+
totalItemsCount,
|
|
982
|
+
isItemUpdating,
|
|
983
|
+
increaseQuantity,
|
|
984
|
+
decreaseQuantity,
|
|
985
|
+
deleteItem,
|
|
986
|
+
// user + form
|
|
987
|
+
isLoggedIn,
|
|
988
|
+
formData,
|
|
989
|
+
formErrors,
|
|
990
|
+
clearFieldError,
|
|
991
|
+
validateEmailField,
|
|
992
|
+
onPhoneUpdate,
|
|
993
|
+
detailsErrorFields,
|
|
994
|
+
deliveryErrorFields,
|
|
995
|
+
emailErrorMessage,
|
|
996
|
+
emailHasError,
|
|
997
|
+
phoneErrorMessage,
|
|
998
|
+
phoneHasError,
|
|
999
|
+
// address + shipping
|
|
1000
|
+
userAddresses,
|
|
1001
|
+
selectedAddressIndex,
|
|
1002
|
+
selectedAddress,
|
|
1003
|
+
showAddressForm,
|
|
1004
|
+
selectAddress,
|
|
1005
|
+
toggleAddressForm,
|
|
1006
|
+
countryOptions,
|
|
1007
|
+
zoneOptions,
|
|
1008
|
+
zoneRequired,
|
|
1009
|
+
selectedCountry,
|
|
1010
|
+
selectedZone,
|
|
1011
|
+
selectCountry,
|
|
1012
|
+
selectZone,
|
|
1013
|
+
shippingCost,
|
|
1014
|
+
isShippingLoading,
|
|
1015
|
+
isFreeDelivery,
|
|
1016
|
+
// payment method
|
|
1017
|
+
paymentMethods,
|
|
1018
|
+
isSelected,
|
|
1019
|
+
setPaymentMethod,
|
|
1020
|
+
// extra fees
|
|
1021
|
+
extraFees,
|
|
1022
|
+
extraFeeLines,
|
|
1023
|
+
onExtraFeesChange,
|
|
1024
|
+
// promo
|
|
1025
|
+
promoCode,
|
|
1026
|
+
appliedPromoCode,
|
|
1027
|
+
promo_id,
|
|
1028
|
+
discount,
|
|
1029
|
+
promoError,
|
|
1030
|
+
isCodeLoading,
|
|
1031
|
+
applyPromoCode,
|
|
1032
|
+
removePromo,
|
|
1033
|
+
// totals + order
|
|
1034
|
+
originalTotal,
|
|
1035
|
+
totalPrice,
|
|
1036
|
+
canPay,
|
|
1037
|
+
isCheckingOut,
|
|
1038
|
+
submitForm,
|
|
1039
|
+
// gateway
|
|
1040
|
+
activePayment,
|
|
1041
|
+
onPaymentError,
|
|
1042
|
+
onPaymentSettled,
|
|
1043
|
+
onPaymentDeclined,
|
|
1044
|
+
closePayment,
|
|
1045
|
+
} = useCheckout();
|
|
1919
1046
|
</script>
|
|
1920
1047
|
<style scoped>
|
|
1921
1048
|
/* Floating label fields: label sits inside the input and lifts to the
|
|
@@ -1952,6 +1079,8 @@ export default {
|
|
|
1952
1079
|
transition: top 0.18s ease, font-size 0.18s ease, transform 0.18s ease, color 0.18s ease;
|
|
1953
1080
|
}
|
|
1954
1081
|
|
|
1082
|
+
/* Text inputs float on focus or once filled; dropdowns float only once an
|
|
1083
|
+
option is actually selected (is-floated). */
|
|
1955
1084
|
.floating-input:focus ~ .floating-label,
|
|
1956
1085
|
.floating-input:not(:placeholder-shown) ~ .floating-label,
|
|
1957
1086
|
.floating-input:-webkit-autofill ~ .floating-label,
|
|
@@ -1963,7 +1092,6 @@ export default {
|
|
|
1963
1092
|
color: #9ca3af;
|
|
1964
1093
|
}
|
|
1965
1094
|
|
|
1966
|
-
|
|
1967
1095
|
@media (prefers-reduced-motion: reduce) {
|
|
1968
1096
|
.floating-label {
|
|
1969
1097
|
transition: none;
|