@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.
@@ -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
- <div
57
- v-for="item in CartItems"
58
- :key="`rail-${item.id}`"
59
- class="group flex items-center gap-3 py-2"
60
- >
61
- <div class="h-14 w-12 flex-shrink-0 overflow-hidden rounded-md border border-gray-200 bg-white">
62
- <img :src="imageUrl(item.image)" :alt="item.name" class="h-full w-full object-cover" />
63
- </div>
64
- <div class="min-w-0 flex-1">
65
- <p class="truncate text-[13px]">{{ item.name }}</p>
66
- <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>
67
- <div class="mt-1.5 flex items-center gap-3">
68
- <div class="flex h-7 w-fit items-center rounded-full border border-gray-300">
69
- <button
70
- type="button"
71
- class="flex h-7 w-7 items-center justify-center text-gray-500 disabled:opacity-40"
72
- :disabled="isItemUpdating(item)"
73
- aria-label="Decrease quantity"
74
- @click="decreaseQuantity(item)"
75
- >
76
- <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-3.5">
77
- <path stroke-linecap="round" d="M5 12h14" />
78
- </svg>
79
- </button>
80
- <span class="min-w-[24px] text-center text-[13px]">
81
- <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>
82
- <template v-else>{{ item.quantity }}</template>
83
- </span>
84
- <button
85
- type="button"
86
- class="flex h-7 w-7 items-center justify-center text-gray-500 disabled:opacity-40"
87
- :disabled="isItemUpdating(item)"
88
- aria-label="Increase quantity"
89
- @click="increaseQuantity(item)"
90
- >
91
- <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-3.5">
92
- <path stroke-linecap="round" d="M12 5v14M5 12h14" />
93
- </svg>
94
- </button>
95
- </div>
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
- </button>
110
- </div>
111
- </div>
112
- <span class="whitespace-nowrap text-[13px]">{{ currency }}{{ item.total_price }}</span>
113
- </div>
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="!discount" class="flex gap-2">
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">Promo applied: {{ promoCode }}</p>
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
- <router-link
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 displayedPaymentMethods"
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
- <ExtraFees :fees="extraFees" :subtotal="total_price" @change="onExtraFeesChange" />
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
- <template v-if="isLoading">
530
- <skeletor width="50%" height="20px" />
531
-
532
- <!-- line items: thumbnail + name/variant/qty-pill + price, matching the real rows -->
533
- <div class="mt-4 space-y-4">
534
- <div v-for="i in 4" :key="`sk-rail-item-${i}`" class="flex items-center gap-3">
535
- <skeletor width="48px" height="56px" />
536
- <div class="min-w-0 flex-1 space-y-1.5">
537
- <skeletor width="75%" height="13px" />
538
- <skeletor width="40%" height="11px" />
539
- <skeletor width="70px" height="22px" />
540
- </div>
541
- <skeletor width="32px" height="13px" />
542
- </div>
543
- </div>
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
- <!-- promo code row -->
546
- <div class="mt-4 flex gap-2">
547
- <skeletor width="100%" height="36px" />
548
- <skeletor width="64px" height="36px" />
549
- </div>
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
- <!-- totals -->
552
- <div class="mt-4 space-y-2.5 border-t border-gray-200 pt-3">
553
- <div class="flex justify-between">
554
- <skeletor width="35%" height="13px" />
555
- <skeletor width="18%" height="13px" />
556
- </div>
557
- <div class="flex justify-between">
558
- <skeletor width="28%" height="13px" />
559
- <skeletor width="18%" height="13px" />
560
- </div>
561
- </div>
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
- <!-- total -->
564
- <div class="mt-3 flex items-baseline justify-between border-t border-gray-200 pt-3">
565
- <skeletor width="25%" height="13px" />
566
- <skeletor width="30%" height="22px" />
567
- </div>
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
- <div class="mt-4"><skeletor width="100%" height="44px" /></div>
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
- <div
577
- v-for="item in CartItems"
578
- :key="`rail-${item.id}`"
579
- class="group flex items-center gap-3 py-2"
580
- >
581
- <div class="h-14 w-12 flex-shrink-0 overflow-hidden rounded-md border border-gray-200 bg-white">
582
- <img :src="imageUrl(item.image)" :alt="item.name" class="h-full w-full object-cover" />
583
- </div>
584
- <div class="min-w-0 flex-1">
585
- <p class="truncate text-[13px]">{{ item.name }}</p>
586
- <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>
587
- <div class="mt-1.5 flex items-center gap-3">
588
- <div class="flex h-7 w-fit items-center rounded-full border border-gray-300">
589
- <button
590
- type="button"
591
- class="flex h-7 w-7 items-center justify-center text-gray-500 disabled:opacity-40"
592
- :disabled="isItemUpdating(item)"
593
- aria-label="Decrease quantity"
594
- @click="decreaseQuantity(item)"
595
- >
596
- <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-3.5">
597
- <path stroke-linecap="round" d="M5 12h14" />
598
- </svg>
599
- </button>
600
- <span class="min-w-[24px] text-center text-[13px]">
601
- <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>
602
- <template v-else>{{ item.quantity }}</template>
603
- </span>
604
- <button
605
- type="button"
606
- class="flex h-7 w-7 items-center justify-center text-gray-500 disabled:opacity-40"
607
- :disabled="isItemUpdating(item)"
608
- aria-label="Increase quantity"
609
- @click="increaseQuantity(item)"
610
- >
611
- <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-3.5">
612
- <path stroke-linecap="round" d="M12 5v14M5 12h14" />
613
- </svg>
614
- </button>
615
- </div>
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
- </button>
630
- </div>
631
- </div>
632
- <span class="whitespace-nowrap text-[13px]">{{ currency }}{{ item.total_price }}</span>
633
- </div>
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="!discount" class="flex gap-2">
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 flex justify-center items-center rounded-lg border border-gray-300 px-4 text-[13px]"
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">Promo applied: {{ promoCode }}</p>
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
- <!-- 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>
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 h-7 w-7 items-center justify-center text-gray-500 disabled:opacity-40"
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="Increase quantity"
808
- @click="increaseQuantity(item)"
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="2" stroke="currentColor" class="w-3.5">
811
- <path stroke-linecap="round" d="M12 5v14M5 12h14" />
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 gap-1 text-[12px] text-gray-400 transition-colors duration-200 hover:text-red-500 disabled:opacity-40"
819
- :disabled="isItemUpdating(item)"
820
- aria-label="Remove item"
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
- <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" />
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
- <div>
895
- <p>Total</p>
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 { DropdownSelect } from "#components";
945
- import { scrollToFirstError } from "#osi/utils/scrollToError";
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 { useUserStore } from "#osi/stores/userStore";
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
- function extractErrorMessage(error) {
955
- const data = error?.data ?? error?.response?.data ?? error;
956
-
957
- if (Array.isArray(data) && data.length > 0) {
958
- return data.length === 1 ? data[0] : data.map((e) => `• ${e}`).join("\n");
959
- }
960
- if (data && typeof data === "object") {
961
- const messages = Object.values(data).flat().filter(Boolean);
962
- if (messages.length > 0) {
963
- return messages.length === 1
964
- ? messages[0]
965
- : messages.map((m) => `• ${m}`).join("\n");
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
- handleLabelClick() {},
1129
-
1130
- setPaymentMethod(optionId) {
1131
- if (this.selectedPaymentMethod === optionId) {
1132
- this.selectedPaymentMethod = null;
1133
- this.formData.payment_method_id = null;
1134
- } else {
1135
- this.selectedPaymentMethod = optionId;
1136
- this.formData.payment_method_id = optionId;
1137
- }
1138
- },
1139
-
1140
- onPaymentError(message) {
1141
- this.toast.error(message);
1142
- },
1143
-
1144
- // Fired ONLY when the server confirmed the charge - PaymentHandoff now
1145
- // reads `paid` off the verify response, so this no longer runs on a
1146
- // decline. That makes it the one safe place to empty the bag.
1147
- onPaymentSettled(result) {
1148
- paymentServices.clearReference();
1149
- // this.clearCart();
1150
- eventBus.emit("cartUpdated");
1151
- this.activePayment = null;
1152
- // Straight to the order, not to /checkout/result: this flow already
1153
- // verified in the modal, and clearReference() above leaves the result
1154
- // page with no reference to check — it would report a failure on a
1155
- // payment that just succeeded.
1156
- this.$router.replace({
1157
- path: "/order-detail",
1158
- query: { id: this.placedOrderId, placed: "1" },
1159
- });
1160
- },
1161
-
1162
- // The bank refused. The order exists and is unpaid, which is correct - so
1163
- // the bag stays and the buyer can pay again. A retry needs a fresh capture
1164
- // context (the transient token is single-use), which is why this closes
1165
- // the modal and returns them to the form rather than leaving it open.
1166
- onPaymentDeclined(reason) {
1167
- this.activePayment = null;
1168
- this.isCheckingOut = false;
1169
- this.toast.error(
1170
- reason || "Your payment was declined. Please try another card.",
1171
- );
1172
- },
1173
-
1174
- closePayment() {
1175
- this.activePayment = null;
1176
- // submitForm leaves this true while the gateway is up - release it so
1177
- // the Complete Order button is usable again after a dismissed payment.
1178
- this.isCheckingOut = false;
1179
- this.toast.info("Your order is saved but unpaid. You can pay again.");
1180
- },
1181
-
1182
- async selectAddress(index) {
1183
- // Prevent selection if the address form is open
1184
- if (this.showAddressForm) return;
1185
-
1186
- if (this.selectedAddressIndex === index) {
1187
- this.selectedAddressIndex = null;
1188
- this.formData.address = null;
1189
- this.isFreeDelivery = false;
1190
- this.freeDeliveryId = null;
1191
- } else {
1192
- // Otherwise, select the address
1193
- const selectedAddress = this.userAddresses[index];
1194
- this.formData.address = selectedAddress.id;
1195
- this.selectedAddressIndex = index;
1196
-
1197
- // Check for free delivery using the country ID from the selected address
1198
- if (selectedAddress.country) {
1199
- await this.checkFreeDelivery(selectedAddress.country);
1200
- }
1201
- }
1202
- },
1203
-
1204
- toggleAddressForm() {
1205
- if (this.showAddressForm) {
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;