@osimart/create-storefront 0.0.0-stage → 1.0.0

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.
Files changed (206) hide show
  1. package/README.md +80 -2
  2. package/THEME_DEV_API.md +80 -0
  3. package/app/app.vue +154 -0
  4. package/app/assets/css/osi-controls.css +824 -0
  5. package/app/components/SectionRenderer.vue +46 -0
  6. package/app/components/builder/BuilderOverlay.vue +582 -0
  7. package/app/components/checkout/ExtraFees.vue +329 -0
  8. package/app/components/checkout/PaymentHandoff.vue +388 -0
  9. package/app/components/common/Banner.vue +35 -0
  10. package/app/components/common/ConfirmDeleteModal.vue +117 -0
  11. package/app/components/common/DropdownSelect.vue +432 -0
  12. package/app/components/common/Loader.vue +32 -0
  13. package/app/components/common/OsiField.vue +218 -0
  14. package/app/components/common/PhoneNumInput.vue +434 -0
  15. package/app/composables/UseBodyScrollLock.js +122 -0
  16. package/app/composables/UseThemeGroup.js +575 -0
  17. package/app/composables/useAddToCart.js +87 -0
  18. package/app/composables/useAnalytics.js +15 -0
  19. package/app/composables/useAnnouncements.js +29 -0
  20. package/app/composables/useApiErrorToast.js +104 -0
  21. package/app/composables/useAuth.js +99 -0
  22. package/app/composables/useBanners.js +25 -0
  23. package/app/composables/useBuilderPreview.js +320 -0
  24. package/app/composables/useButtonStyle.js +107 -0
  25. package/app/composables/useCarousel.js +160 -0
  26. package/app/composables/useCart.js +109 -0
  27. package/app/composables/useCheckout.js +76 -0
  28. package/app/composables/useClassificationItems.js +269 -0
  29. package/app/composables/useClassificationPage.js +545 -0
  30. package/app/composables/useClassificationUrl.js +63 -0
  31. package/app/composables/useColorScheme.js +44 -0
  32. package/app/composables/useContactForm.js +53 -0
  33. package/app/composables/useContentTree.js +770 -0
  34. package/app/composables/useDragScroll.js +73 -0
  35. package/app/composables/useFooterSocials.js +107 -0
  36. package/app/composables/useInputStyle.js +45 -0
  37. package/app/composables/useLayoutShell.js +114 -0
  38. package/app/composables/useLazyVideo.js +36 -0
  39. package/app/composables/useMediaTypes.js +66 -0
  40. package/app/composables/useMediaUrl.js +71 -0
  41. package/app/composables/useMenu.js +100 -0
  42. package/app/composables/useNavStyle.js +219 -0
  43. package/app/composables/usePlaceholders.js +294 -0
  44. package/app/composables/usePolicies.js +26 -0
  45. package/app/composables/useProductCardMedia.js +135 -0
  46. package/app/composables/useProductCardStyle.js +132 -0
  47. package/app/composables/useProductDetail.js +982 -0
  48. package/app/composables/useProductGallery.js +166 -0
  49. package/app/composables/useProductItems.js +270 -0
  50. package/app/composables/useProductUrl.js +60 -0
  51. package/app/composables/useProductVariants.js +210 -0
  52. package/app/composables/useQuickShop.js +115 -0
  53. package/app/composables/useRelatedProducts.js +186 -0
  54. package/app/composables/useRenderContext.js +45 -0
  55. package/app/composables/useScopedCss.js +106 -0
  56. package/app/composables/useSearch.js +35 -0
  57. package/app/composables/useSectionData.js +462 -0
  58. package/app/composables/useSectionShell.js +113 -0
  59. package/app/composables/useSidebar.js +69 -0
  60. package/app/composables/useStoreData.js +57 -0
  61. package/app/composables/useStoreInit.js +52 -0
  62. package/app/composables/useStorefront.js +239 -0
  63. package/app/composables/useT.js +21 -0
  64. package/app/composables/useTextStyles.js +84 -0
  65. package/app/composables/useThemeControls.js +34 -0
  66. package/app/composables/useThemeSeo.js +91 -0
  67. package/app/composables/useThemeTokens.js +171 -0
  68. package/app/composables/useWishlist.js +66 -0
  69. package/app/logic/components/AnnouncementBar.js +118 -0
  70. package/app/logic/components/CardGrid.js +125 -0
  71. package/app/logic/components/CardList.js +98 -0
  72. package/app/logic/components/CartDrawer.js +273 -0
  73. package/app/logic/components/CartItem.js +31 -0
  74. package/app/logic/components/ClassificationBento.js +6 -0
  75. package/app/logic/components/ClassificationCard.js +6 -0
  76. package/app/logic/components/ClassificationCarousel.js +115 -0
  77. package/app/logic/components/ClassificationEditorial.js +41 -0
  78. package/app/logic/components/ClassificationFeatured.js +57 -0
  79. package/app/logic/components/ClassificationGrid.js +6 -0
  80. package/app/logic/components/ClassificationNav.js +61 -0
  81. package/app/logic/components/ClassificationPart.js +72 -0
  82. package/app/logic/components/FooterBlock.js +91 -0
  83. package/app/logic/components/FooterSocials.js +60 -0
  84. package/app/logic/components/HeaderDropdownList.js +29 -0
  85. package/app/logic/components/HeaderIcon.js +68 -0
  86. package/app/logic/components/HeaderLogo.js +39 -0
  87. package/app/logic/components/HeaderNav.js +268 -0
  88. package/app/logic/components/MobileMenu.js +280 -0
  89. package/app/logic/components/ProductBuyButtons.js +66 -0
  90. package/app/logic/components/ProductCard.js +160 -0
  91. package/app/logic/components/ProductFilters.js +37 -0
  92. package/app/logic/components/ProductGallery.js +19 -0
  93. package/app/logic/components/ProductInfoBlock.js +411 -0
  94. package/app/logic/components/ProductLightbox.js +12 -0
  95. package/app/logic/components/ProductQuantity.js +31 -0
  96. package/app/logic/components/ProductVariantPicker.js +125 -0
  97. package/app/logic/components/QuickBuy.js +194 -0
  98. package/app/logic/components/SearchOverlay.js +41 -0
  99. package/app/logic/components/SidebarMenuItem.js +39 -0
  100. package/app/logic/components/SiteFooter.js +476 -0
  101. package/app/logic/components/SiteHeader.js +266 -0
  102. package/app/logic/components/WhatsAppButton.js +75 -0
  103. package/app/logic/layouts/default.js +66 -0
  104. package/app/logic/sections/AnnouncementBarSection.js +6 -0
  105. package/app/logic/sections/BlockCarousel.js +103 -0
  106. package/app/logic/sections/ClassificationListSection.js +40 -0
  107. package/app/logic/sections/ContactFormSection.js +294 -0
  108. package/app/logic/sections/ContactInfoSection.js +419 -0
  109. package/app/logic/sections/ContentNode.js +189 -0
  110. package/app/logic/sections/CountdownSection.js +254 -0
  111. package/app/logic/sections/CustomHtmlSection.js +51 -0
  112. package/app/logic/sections/DividerSection.js +31 -0
  113. package/app/logic/sections/FaqSection.js +53 -0
  114. package/app/logic/sections/FeatureListSection.js +6 -0
  115. package/app/logic/sections/FeaturedProductSection.js +40 -0
  116. package/app/logic/sections/FooterSection.js +16 -0
  117. package/app/logic/sections/FooterUtilitiesSection.js +24 -0
  118. package/app/logic/sections/GallerySection.js +232 -0
  119. package/app/logic/sections/HeaderSection.js +24 -0
  120. package/app/logic/sections/HeroSection.js +24 -0
  121. package/app/logic/sections/LogoListSection.js +6 -0
  122. package/app/logic/sections/MainClassificationSection.js +473 -0
  123. package/app/logic/sections/MainProductSection.js +211 -0
  124. package/app/logic/sections/MainSearchSection.js +6 -0
  125. package/app/logic/sections/MapSection.js +92 -0
  126. package/app/logic/sections/MediaContentSection.js +38 -0
  127. package/app/logic/sections/MediaWithTextSection.js +238 -0
  128. package/app/logic/sections/NewsletterSection.js +126 -0
  129. package/app/logic/sections/ProductHighlightSection.js +201 -0
  130. package/app/logic/sections/ProductListSection.js +45 -0
  131. package/app/logic/sections/ProductRecommendationsSection.js +113 -0
  132. package/app/logic/sections/RichTextSection.js +6 -0
  133. package/app/logic/sections/ScrollingTextSection.js +69 -0
  134. package/app/logic/sections/SearchHeaderSection.js +22 -0
  135. package/app/logic/sections/SlideshowSection.js +118 -0
  136. package/app/logic/sections/SpacerSection.js +37 -0
  137. package/app/logic/sections/SplitShowcaseSection.js +6 -0
  138. package/app/logic/sections/TestimonialListSection.js +6 -0
  139. package/app/logic/sections/VideoSection.js +235 -0
  140. package/app/pages/[category]/[product].vue +196 -0
  141. package/app/pages/__builder-preview.vue +38 -0
  142. package/app/pages/categories/[slug].vue +208 -0
  143. package/app/pages/checkout.vue +1972 -0
  144. package/app/pages/forgot-password.vue +115 -0
  145. package/app/pages/index.vue +64 -0
  146. package/app/pages/login.vue +585 -0
  147. package/app/pages/order-detail.vue +442 -0
  148. package/app/pages/pages/[handle].vue +58 -0
  149. package/app/pages/privacy-policy.vue +46 -0
  150. package/app/pages/resetpassword.vue +138 -0
  151. package/app/pages/resetverificationcode.vue +163 -0
  152. package/app/pages/result.vue +203 -0
  153. package/app/pages/return-policy.vue +50 -0
  154. package/app/pages/search.vue +100 -0
  155. package/app/pages/shipping-policy.vue +46 -0
  156. package/app/pages/user-profile/components/AddAddressModal.vue +452 -0
  157. package/app/pages/user-profile/components/Whishlist.vue +137 -0
  158. package/app/pages/user-profile/components/left-bar.vue +233 -0
  159. package/app/pages/user-profile/components/orders.vue +162 -0
  160. package/app/pages/user-profile/components/profile-details.vue +566 -0
  161. package/app/pages/user-profile/components/settings.vue +194 -0
  162. package/app/pages/user-profile/index.vue +252 -0
  163. package/app/pages/verificationcode.vue +188 -0
  164. package/app/plugins/analytics.client.js +99 -0
  165. package/app/plugins/geo.ts +24 -0
  166. package/app/plugins/reveal.client.js +53 -0
  167. package/app/plugins/toast.js +10 -0
  168. package/app/plugins/vue-query.ts +5 -0
  169. package/app/services/ApiServices.js +74 -0
  170. package/app/services/AuthBus.js +162 -0
  171. package/app/services/Authentication/AuthenticationService.js +133 -0
  172. package/app/services/Authentication/TokenService.js +62 -0
  173. package/app/services/CartServices.js +91 -0
  174. package/app/services/CartToken.js +26 -0
  175. package/app/services/CategoryServices.js +54 -0
  176. package/app/services/CheckoutServices.js +113 -0
  177. package/app/services/HomeServices.js +133 -0
  178. package/app/services/NewsletterServices.js +29 -0
  179. package/app/services/PaymentServices.js +71 -0
  180. package/app/services/PoliciesServices.js +16 -0
  181. package/app/services/ProductServices.js +79 -0
  182. package/app/services/SearchServices.js +18 -0
  183. package/app/services/UserServices.js +105 -0
  184. package/app/services/UtilsServices.js +46 -0
  185. package/app/services/WishlistServices.js +51 -0
  186. package/app/services/storefront.service.js +57 -0
  187. package/app/stores/userStore.js +13 -0
  188. package/app/theme/categoryTree.js +68 -0
  189. package/app/theme/controls.js +88 -0
  190. package/app/theme/registry.js +56 -0
  191. package/app/utils/analytics.js +864 -0
  192. package/app/utils/device.js +50 -0
  193. package/app/utils/eventBus.js +24 -0
  194. package/app/utils/icon-presets.js +24 -0
  195. package/app/utils/productOffers.js +118 -0
  196. package/app/utils/requestCache.js +36 -0
  197. package/app/utils/scrollToError.js +59 -0
  198. package/app/utils/toPlainError.js +30 -0
  199. package/bin/osimart-theme.mjs +259 -0
  200. package/nuxt/osi-sections.ts +56 -0
  201. package/nuxt.config.ts +60 -0
  202. package/package.json +43 -3
  203. package/public/css/input.css +29 -0
  204. package/public/css/output.css +2716 -0
  205. package/public/favicon-32x32.png +0 -0
  206. package/public/robots.txt +2 -0
@@ -0,0 +1,1972 @@
1
+ <template>
2
+ <div class="bg-gray-50 font-body text-primaryColor">
3
+
4
+ <!-- ============ MOBILE ORDER STRIP ============ -->
5
+ <div class="border-b border-gray-200 bg-white lg:hidden">
6
+ <button
7
+ type="button"
8
+ class="flex w-full items-center gap-3 px-4 py-3 text-left"
9
+ @click="mobileSummaryOpen = !mobileSummaryOpen"
10
+ >
11
+ <template v-if="isLoading">
12
+ <skeletor width="100%" height="44px" />
13
+ </template>
14
+ <template v-else>
15
+ <div class="flex flex-shrink-0 gap-1.5">
16
+ <div
17
+ v-for="item in CartItems.slice(0, 2)"
18
+ :key="`strip-${item.id}`"
19
+ class="h-11 w-9 overflow-hidden rounded-md border border-gray-200 bg-white"
20
+ >
21
+ <img :src="imageUrl(item.image)" :alt="item.name" class="h-full w-full object-cover" />
22
+ </div>
23
+ <div
24
+ v-if="CartItems.length > 2"
25
+ class="flex h-11 w-9 items-center justify-center rounded-md border border-dashed border-gray-300 text-xs text-gray-500"
26
+ >
27
+ +{{ CartItems.length - 2 }}
28
+ </div>
29
+ </div>
30
+ <div class="min-w-0 flex-1">
31
+ <p class="text-[13px] text-gray-500">
32
+ Order Summary
33
+ </p>
34
+ <p class="text-base font-semibold">{{ currency }}{{ totalPrice }}</p>
35
+ </div>
36
+ <svg
37
+ xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"
38
+ class="w-4 flex-shrink-0 text-gray-500 transition-transform duration-300"
39
+ :class="mobileSummaryOpen ? 'rotate-180' : ''"
40
+ >
41
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
42
+ </svg>
43
+ </template>
44
+ </button>
45
+
46
+ <transition
47
+ :css="false"
48
+ @enter="accordionEnter"
49
+ @after-enter="accordionAfterEnter"
50
+ @leave="accordionLeave"
51
+ @after-leave="accordionAfterEnter"
52
+ >
53
+ <div v-if="mobileSummaryOpen && !isLoading">
54
+ <div class="border-t border-gray-200 px-4 pb-5 pt-4">
55
+ <!-- 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>
108
+
109
+ </button>
110
+ </div>
111
+ </div>
112
+ <span class="whitespace-nowrap text-[13px]">{{ currency }}{{ item.total_price }}</span>
113
+ </div>
114
+
115
+ <!-- promo -->
116
+ <div class="pt-3">
117
+ <div v-if="!discount" class="flex gap-2">
118
+ <input
119
+ type="text"
120
+ id="m-promocode"
121
+ v-model="promoCode"
122
+ placeholder="Promo code"
123
+ 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"
124
+ />
125
+ <button
126
+ type="button"
127
+ class="h-10 flex-shrink-0 rounded-xl border border-gray-300 bg-white px-4 text-sm font-medium"
128
+ :disabled="isCodeLoading"
129
+ @click="applyPromoCode"
130
+ >
131
+ <span v-if="isCodeLoading" class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-gray-200 border-t-primaryColor"></span>
132
+ <span v-else>Apply</span>
133
+ </button>
134
+ </div>
135
+ <div v-else class="flex items-center justify-between">
136
+ <p class="text-sm text-green-600">Promo applied: {{ promoCode }}</p>
137
+ <button type="button" class="rounded-lg border border-red-600 bg-red-600 px-2 py-1 text-xs text-white" @click="removePromo">
138
+ Remove
139
+ </button>
140
+ </div>
141
+ </div>
142
+
143
+ <!-- totals -->
144
+ <dl class="mt-4 space-y-2 border-t border-gray-200 pt-3 text-[13px]">
145
+ <div class="flex justify-between text-gray-500"><dt>Subtotal</dt><dd>{{ currency }}{{ total_price }}</dd></div>
146
+ <div class="flex justify-between text-gray-500" v-if="selectedCountry || selectedAddress || selectedZone">
147
+ <dt>Shipping</dt>
148
+ <dd v-if="isShippingLoading" class="w-[80px]"><skeletor width="100%" height="18px" /></dd>
149
+ <dd v-else-if="isFreeDelivery" class="flex gap-2">
150
+ <span class="text-gray-400 line-through">{{ currency }}{{ shippingCost }}</span>
151
+ <span class="text-green-600">{{ currency }}0</span>
152
+ </dd>
153
+ <dd v-else>{{ currency }}{{ shippingCost }}</dd>
154
+ </div>
155
+ <div class="flex justify-between text-gray-500" v-else>
156
+ <dt>Shipping</dt><dd class="text-xs">Enter address to calculate</dd>
157
+ </div>
158
+ <div v-if="discount" class="flex justify-between text-green-600">
159
+ <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>
164
+ </div>
165
+ <div v-for="line in extraFeeLines" :key="`m-fee-${line.name}`" class="flex justify-between text-gray-500">
166
+ <dt>{{ line.name }}</dt><dd>{{ currency }}{{ line.amount.toFixed(2) }}</dd>
167
+ </div>
168
+ </dl>
169
+ </div>
170
+ </div>
171
+ </transition>
172
+ </div>
173
+
174
+ <!-- ============ BODY ============ -->
175
+ <div
176
+ class="mx-auto w-full px-4 pb-5 pt-5 lg:grid lg:max-w-6xl lg:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)] lg:gap-10 lg:px-9 lg:pb-8 lg:pt-8"
177
+ >
178
+ <!-- ---------------- LEFT COLUMN ---------------- -->
179
+ <div class="lg:order-1">
180
+ <!-- skeleton -->
181
+ <template v-if="isLoading">
182
+ <div class="space-y-8">
183
+ <div class="space-y-3">
184
+ <skeletor width="40%" height="24px" />
185
+ <skeletor width="100%" height="150px" />
186
+ </div>
187
+ <div class="space-y-3">
188
+ <skeletor width="40%" height="24px" />
189
+ <skeletor width="100%" height="180px" />
190
+ </div>
191
+ <div class="space-y-3">
192
+ <skeletor width="40%" height="24px" />
193
+ <skeletor width="100%" height="110px" />
194
+ </div>
195
+ </div>
196
+ </template>
197
+
198
+ <template v-else>
199
+ <!-- ===== 1. DETAILS ===== -->
200
+ <section>
201
+ <div class="mb-3 flex items-center gap-2">
202
+ <span class="flex h-[22px] w-[22px] items-center justify-center rounded-full bg-primaryColor text-xs text-white">1</span>
203
+ <h2 class="text-[15px] font-semibold">Your details</h2>
204
+ <span class="h-px flex-1 bg-gray-200"></span>
205
+ <router-link
206
+ v-if="!isLoggedIn"
207
+ :to="{ path: '/login', query: { redirect: $route.fullPath } }"
208
+ class="text-xs text-green-500 underline"
209
+ >
210
+ Sign in
211
+ </router-link>
212
+ </div>
213
+
214
+ <div class="divide-y divide-gray-200 overflow-hidden rounded-xl border border-gray-300 bg-white">
215
+ <div data-field="email" class="floating-field" :class="emailHasError ? 'bg-red-50' : ''">
216
+ <input
217
+ type="text"
218
+ id="email"
219
+ v-model="formData.email"
220
+ placeholder=" "
221
+ class="floating-input disabled:text-gray-500"
222
+ :disabled="isLoggedIn && formData.email !== ''"
223
+ @input="validateEmailField"
224
+ />
225
+ <label for="email" class="floating-label">Email</label>
226
+ </div>
227
+
228
+ <div class="flex divide-x divide-gray-200">
229
+ <div data-field="first_name" class="floating-field flex-1" :class="formErrors.first_name ? 'bg-red-50' : ''">
230
+ <input
231
+ type="text"
232
+ id="firstName"
233
+ v-model="formData.first_name"
234
+ placeholder=" "
235
+ class="floating-input disabled:text-gray-500"
236
+ :disabled="isLoggedIn && formData.first_name !== ''"
237
+ @input="clearFieldError('first_name')"
238
+ />
239
+ <label for="firstName" class="floating-label">First name</label>
240
+ </div>
241
+ <div data-field="last_name" class="floating-field flex-1" :class="formErrors.last_name ? 'bg-red-50' : ''">
242
+ <input
243
+ type="text"
244
+ id="lastName"
245
+ v-model="formData.last_name"
246
+ placeholder=" "
247
+ class="floating-input disabled:text-gray-500"
248
+ :disabled="isLoggedIn && formData.last_name !== ''"
249
+ @input="clearFieldError('last_name')"
250
+ />
251
+ <label for="lastName" class="floating-label">Last name</label>
252
+ </div>
253
+ </div>
254
+
255
+ <div
256
+ v-if="!isLoggedIn"
257
+ data-field="mobile_number"
258
+ class="px-3"
259
+ :class="phoneHasError ? 'bg-red-50' : ''"
260
+ >
261
+ <PhoneNumInput
262
+ v-model="formData.mobile_number"
263
+ variant="bare"
264
+ :error="phoneHasError"
265
+ @update="onPhoneUpdate"
266
+ />
267
+ </div>
268
+ <div v-else class="floating-field">
269
+ <input
270
+ type="text"
271
+ id="mobile_number"
272
+ v-model="formData.mobile_number"
273
+ placeholder=" "
274
+ class="floating-input disabled:text-gray-500"
275
+ :disabled="isLoggedIn && formData.mobile_number !== ''"
276
+ />
277
+ <label for="mobile_number" class="floating-label">Phone</label>
278
+ </div>
279
+ </div>
280
+
281
+ <p v-if="emailErrorMessage" class="mt-2 text-xs text-red-500">{{ emailErrorMessage }}</p>
282
+ <p v-if="phoneErrorMessage" class="mt-2 text-xs text-red-500">{{ phoneErrorMessage }}</p>
283
+ <p v-if="detailsErrorFields.length" class="mt-2 text-xs text-red-500">Please fill all required fields: {{ detailsErrorFields.join(', ') }}</p>
284
+
285
+ <!-- newsletter toggle -->
286
+ <label for="toggle" class="mt-3 flex cursor-pointer items-center gap-3">
287
+ <span class="relative inline-block h-[26px] w-[50px] flex-shrink-0">
288
+ <input
289
+ type="checkbox"
290
+ id="toggle"
291
+ v-model="formData.signedUpForExclusiveContent"
292
+ class="peer h-0 w-0 appearance-none opacity-0"
293
+ />
294
+ <span
295
+ class="absolute inset-0 rounded-full bg-gray-300 transition-colors duration-300 peer-checked:bg-primaryColor"
296
+ ></span>
297
+ <span
298
+ class="pointer-events-none absolute left-1 top-[3px] h-5 w-5 rounded-full bg-white transition-transform duration-300 peer-checked:translate-x-6"
299
+ ></span>
300
+ </span>
301
+ <span class="text-[13px] text-gray-500">
302
+ Sign up for exclusive discounts and updates via email. Unsubscribe anytime.
303
+ </span>
304
+ </label>
305
+ </section>
306
+
307
+ <!-- ===== 2. DELIVERY ===== -->
308
+ <section class="pt-7">
309
+ <div class="mb-3 flex items-center gap-2">
310
+ <span class="flex h-[22px] w-[22px] items-center justify-center rounded-full bg-primaryColor text-xs text-white">2</span>
311
+ <h2 class="text-[15px] font-semibold">Delivery address</h2>
312
+ <span class="h-px flex-1 bg-gray-200"></span>
313
+ <router-link
314
+ v-if="!isLoggedIn"
315
+ :to="{ path: '/login', query: { redirect: $route.fullPath } }"
316
+ class="text-xs text-green-500 underline"
317
+ >
318
+ Sign in
319
+ </router-link>
320
+ </div>
321
+
322
+ <!-- saved addresses -->
323
+ <div v-if="userAddresses && userAddresses.length > 0" class="mb-2.5">
324
+ <div class="divide-y divide-gray-200 overflow-hidden rounded-xl border border-gray-300 bg-white">
325
+ <label
326
+ v-for="(address, index) in userAddresses"
327
+ :key="index"
328
+ class="flex cursor-pointer items-center gap-3 px-3 py-2.5"
329
+ :class="selectedAddressIndex === index && !showAddressForm ? 'bg-primaryColor/5' : ''"
330
+ >
331
+ <input
332
+ type="checkbox"
333
+ :checked="selectedAddressIndex === index"
334
+ :disabled="showAddressForm"
335
+ class="h-4 w-4 flex-shrink-0 cursor-pointer appearance-none rounded-full border-2 border-gray-300 transition-colors checked:border-primaryColor checked:bg-primaryColor checked:ring-2 checked:ring-white disabled:opacity-40"
336
+ @change="selectAddress(index)"
337
+ />
338
+ <span class="min-w-0 flex-1 truncate text-sm">{{ address.address }}</span>
339
+ </label>
340
+
341
+ <button
342
+ type="button"
343
+ class="flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm text-primaryColor"
344
+ @click="toggleAddressForm"
345
+ >
346
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4">
347
+ <path stroke-linecap="round" d="M12 5v14M5 12h14" />
348
+ </svg>
349
+ {{ showAddressForm ? "Use a saved address" : "Add new address" }}
350
+ </button>
351
+ </div>
352
+ </div>
353
+
354
+ <!-- address form -->
355
+ <div
356
+ v-if="showAddressForm || (isLoggedIn && userAddresses.length === 0) || !isLoggedIn"
357
+ class="divide-y divide-gray-200 overflow-hidden rounded-xl border border-gray-300 bg-white"
358
+ >
359
+ <!-- country -->
360
+ <div data-field="country" class="floating-field" :class="formErrors.country ? 'bg-red-50' : ''">
361
+ <DropdownSelect
362
+ :options="countryOptions"
363
+ :model-value="selectedCountry"
364
+ @update:model-value="selectCountry($event); clearFieldError('country')"
365
+ placeholder="Country"
366
+ searchable
367
+ local-search
368
+ full-width
369
+ field="country"
370
+ :error="!!formErrors.country"
371
+ >
372
+ <template #trigger="{ toggle, open }">
373
+ <button
374
+ type="button"
375
+ class="flex h-5 w-full items-center justify-between border-0 bg-transparent p-0 text-left text-sm"
376
+ @click="toggle"
377
+ >
378
+ <span class="truncate">{{ selectedCountry?.country_name || "" }}</span>
379
+ <svg
380
+ xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
381
+ class="ml-2 w-4 flex-shrink-0 text-gray-400 transition-transform duration-200"
382
+ :class="open ? 'rotate-180' : ''"
383
+ >
384
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
385
+ </svg>
386
+ </button>
387
+ </template>
388
+ </DropdownSelect>
389
+ <label class="floating-label" :class="selectedCountry ? 'is-floated' : ''">Country</label>
390
+ </div>
391
+
392
+ <!-- zone -->
393
+ <div v-if="zoneRequired" data-field="zone" class="floating-field" :class="formErrors.zone ? 'bg-red-50' : ''">
394
+ <DropdownSelect
395
+ :options="zoneOptions"
396
+ :model-value="selectedZone"
397
+ @update:model-value="selectZone($event); clearFieldError('zone')"
398
+ placeholder="Zone"
399
+ searchable
400
+ local-search
401
+ full-width
402
+ field="zone"
403
+ :error="!!formErrors.zone"
404
+ >
405
+ <template #trigger="{ toggle, open }">
406
+ <button
407
+ type="button"
408
+ class="flex h-5 w-full items-center justify-between border-0 bg-transparent p-0 text-left text-sm"
409
+ @click="toggle"
410
+ >
411
+ <span class="truncate">{{ selectedZone?.name || "" }}</span>
412
+ <svg
413
+ xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
414
+ class="ml-2 w-4 flex-shrink-0 text-gray-400 transition-transform duration-200"
415
+ :class="open ? 'rotate-180' : ''"
416
+ >
417
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
418
+ </svg>
419
+ </button>
420
+ </template>
421
+ </DropdownSelect>
422
+ <label class="floating-label" :class="selectedZone ? 'is-floated' : ''">Zone</label>
423
+ </div>
424
+
425
+ <!-- city + postcode -->
426
+ <div class="flex divide-x divide-gray-200">
427
+ <div data-field="city" class="floating-field flex-1" :class="formErrors.city ? 'bg-red-50' : ''">
428
+ <input
429
+ type="text"
430
+ id="city"
431
+ v-model="formData.address.city"
432
+ placeholder=" "
433
+ class="floating-input"
434
+ @input="clearFieldError('city')"
435
+ />
436
+ <label for="city" class="floating-label">City</label>
437
+ </div>
438
+ <div class="floating-field flex-1">
439
+ <input
440
+ type="number"
441
+ id="postcode"
442
+ v-model="formData.address.postal_code"
443
+ placeholder=" "
444
+ class="floating-input"
445
+ />
446
+ <label for="postcode" class="floating-label">Postal code (optional)</label>
447
+ </div>
448
+ </div>
449
+
450
+ <!-- street -->
451
+ <div data-field="address" class="floating-field" :class="formErrors.address ? 'bg-red-50' : ''">
452
+ <input
453
+ id="street"
454
+ v-model="formData.address.address"
455
+ placeholder=" "
456
+ class="floating-input"
457
+ @input="clearFieldError('address')"
458
+ />
459
+ <label for="street" class="floating-label">Street and area</label>
460
+ </div>
461
+
462
+ <!-- building -->
463
+ <div class="floating-field">
464
+ <input
465
+ id="building"
466
+ v-model="formData.address.address_line2"
467
+ placeholder=" "
468
+ class="floating-input"
469
+ />
470
+ <label for="building" class="floating-label">Building, floor, apartment (optional)</label>
471
+ </div>
472
+ </div>
473
+
474
+ <p v-if="deliveryErrorFields.length" class="mt-2 text-xs text-red-500">Please fill all required fields: {{ deliveryErrorFields.join(', ') }}</p>
475
+ </section>
476
+
477
+ <!-- ===== 3. PAYMENT ===== -->
478
+ <section class="pt-7">
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>
483
+ <h2 class="text-[15px] font-semibold">Payment</h2>
484
+ <span class="h-px flex-1 bg-gray-200"></span>
485
+ </div>
486
+
487
+ <div
488
+ data-field="payment_method_id"
489
+ class="divide-y divide-gray-200 overflow-hidden rounded-xl border"
490
+ :class="formErrors.payment_method_id ? 'bg-red-50' : 'border-gray-300 bg-white'"
491
+ >
492
+ <label
493
+ v-for="(option, index) in displayedPaymentMethods"
494
+ :key="index"
495
+ class="flex cursor-pointer items-center gap-3 px-3 py-3"
496
+ :class="isSelected(option) ? 'bg-primaryColor/5' : ''"
497
+ @click="handleLabelClick(option)"
498
+ >
499
+ <input
500
+ type="checkbox"
501
+ :checked="isSelected(option)"
502
+ class="h-4 w-4 flex-shrink-0 cursor-pointer appearance-none rounded-full border-2 border-gray-300 transition-colors checked:border-primaryColor checked:bg-primaryColor checked:ring-2 checked:ring-white"
503
+ @change="setPaymentMethod(option.id); clearFieldError('payment_method_id')"
504
+ />
505
+ <img v-if="option.icon" :src="imageUrl(option.icon)" width="22" height="22" :alt="option.name" />
506
+ <span class="flex-1 text-sm">{{ option.name }}</span>
507
+ </label>
508
+ </div>
509
+ <p v-if="formErrors.payment_method_id" class="mt-2 text-xs text-red-500">{{ formErrors.payment_method_id }}</p>
510
+ </section>
511
+
512
+ <!-- ===== DONATE ===== -->
513
+ <section v-if="extraFees.length" class="pt-7">
514
+ <div class="mb-3 flex items-center gap-2">
515
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
516
+ <path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" />
517
+ </svg>
518
+ <h2 class="text-[15px] font-semibold">Would you like to donate?</h2>
519
+ <span class="h-px flex-1 bg-gray-200"></span>
520
+ </div>
521
+ <ExtraFees :fees="extraFees" :subtotal="total_price" @change="onExtraFeesChange" />
522
+ </section>
523
+ </template>
524
+ </div>
525
+
526
+ <!-- ---------------- DESKTOP SUMMARY RAIL ---------------- -->
527
+ <aside class="hidden lg:order-2 lg:block">
528
+ <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>
544
+
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>
550
+
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>
562
+
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>
568
+
569
+ <div class="mt-4"><skeletor width="100%" height="44px" /></div>
570
+ </template>
571
+ <template v-else>
572
+ <p class="mb-3 text-[13px] text-gray-500">
573
+ Order · {{ totalItemsCount }} {{ totalItemsCount > 1 ? "items" : "item" }}
574
+ </p>
575
+
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>
628
+
629
+ </button>
630
+ </div>
631
+ </div>
632
+ <span class="whitespace-nowrap text-[13px]">{{ currency }}{{ item.total_price }}</span>
633
+ </div>
634
+
635
+ <!-- promo -->
636
+ <div class="mt-3">
637
+ <div v-if="!discount" class="flex gap-2">
638
+ <input
639
+ type="text"
640
+ id="promocode"
641
+ v-model="promoCode"
642
+ placeholder="Promo code"
643
+ 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"
644
+ />
645
+ <button
646
+ type="button"
647
+ class="h-9 flex-shrink-0 flex justify-center items-center rounded-lg border border-gray-300 px-4 text-[13px]"
648
+ :disabled="isCodeLoading"
649
+ @click="applyPromoCode"
650
+ >
651
+ <span v-if="isCodeLoading" class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-gray-200 border-t-primaryColor"></span>
652
+ <span v-else>Apply</span>
653
+ </button>
654
+ </div>
655
+ <div v-else class="flex items-center justify-between">
656
+ <p class="text-[13px] text-green-600">Promo applied: {{ promoCode }}</p>
657
+ <button type="button" class="rounded-lg border border-red-600 bg-red-600 px-2 py-1 text-xs text-white" @click="removePromo">
658
+ Remove
659
+ </button>
660
+ </div>
661
+ </div>
662
+
663
+ <!-- totals -->
664
+ <dl class="mt-4 space-y-2 border-t border-gray-200 pt-3 text-[13px]">
665
+ <div class="flex justify-between text-gray-500"><dt>Subtotal</dt><dd>{{ currency }}{{ total_price }}</dd></div>
666
+ <div class="flex justify-between text-gray-500" v-if="selectedCountry || selectedAddress || selectedZone">
667
+ <dt>Shipping</dt>
668
+ <dd v-if="isShippingLoading" class="w-[80px]"><skeletor width="100%" height="18px" /></dd>
669
+ <dd v-else-if="isFreeDelivery" class="flex gap-2">
670
+ <span class="text-gray-400 line-through">{{ currency }}{{ shippingCost }}</span>
671
+ <span class="text-green-600">{{ currency }}0</span>
672
+ </dd>
673
+ <dd v-else>{{ currency }}{{ shippingCost }}</dd>
674
+ </div>
675
+ <div class="flex justify-between text-gray-500" v-else>
676
+ <dt>Shipping</dt><dd class="text-xs">Enter address to calculate</dd>
677
+ </div>
678
+ <div v-if="discount" class="flex justify-between text-green-600">
679
+ <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>
684
+ </div>
685
+ <div v-for="line in extraFeeLines" :key="`rail-fee-${line.name}`" class="flex justify-between text-gray-500">
686
+ <dt>{{ line.name }}</dt><dd>{{ currency }}{{ line.amount.toFixed(2) }}</dd>
687
+ </div>
688
+ </dl>
689
+
690
+ <div class="mt-3 flex items-baseline justify-between border-t border-gray-200 pt-3">
691
+ <span class="text-[13px] text-gray-500">Total</span>
692
+ <div class="flex items-baseline gap-2">
693
+ <span v-if="discount" class="text-[13px] text-gray-400 line-through">{{ currency }}{{ originalTotal }}</span>
694
+ <span class="text-xl font-semibold">{{ currency }}{{ totalPrice }}</span>
695
+ </div>
696
+ </div>
697
+
698
+ <div v-if="isCheckingOut" class="mt-4 flex w-full justify-center py-8">
699
+ <FadeLoader color="black" height="10px" />
700
+ </div>
701
+ <button
702
+ v-else
703
+ type="button"
704
+ class="osi-btn osi-btn--block mt-4"
705
+ @click="submitForm"
706
+ >
707
+ Complete order
708
+ </button>
709
+
710
+ <p class="mt-3 text-center text-[11px] text-gray-400">
711
+ By placing an order you're consenting to our privacy policy.
712
+ </p>
713
+ </template>
714
+ </div>
715
+ </aside>
716
+ </div>
717
+
718
+ <!-- ============ MOBILE PAY BAR ============ -->
719
+ <div
720
+ class="border-t border-gray-200 bg-white px-4 pb-[max(12px,env(safe-area-inset-bottom))] pt-3 lg:hidden"
721
+ >
722
+ <div class="mx-auto">
723
+ <!-- skeleton while the cart/user is still loading -->
724
+ <template v-if="isLoading">
725
+ <skeletor width="50%" height="18px" />
726
+ <div class="mt-3 space-y-3">
727
+ <skeletor v-for="i in 2" :key="`sk-bottom-item-${i}`" width="100%" height="56px" />
728
+ </div>
729
+ <div class="mt-3"><skeletor width="100%" height="40px" /></div>
730
+ <div class="mt-4 space-y-2">
731
+ <skeletor v-for="i in 3" :key="`sk-bottom-line-${i}`" width="100%" height="16px" />
732
+ </div>
733
+ <div class="mb-2.5 mt-3 border-t border-gray-200 pt-3">
734
+ <skeletor width="45%" height="22px" />
735
+ </div>
736
+ <skeletor width="100%" height="48px" />
737
+ </template>
738
+
739
+ <template v-else>
740
+ <!-- toggle: reveals line items + promo code input, Shopify-style -->
741
+ <button
742
+ type="button"
743
+ class="flex w-full items-center justify-between py-1 text-[13px] font-medium text-gray-700"
744
+ @click="bottomDiscountOpen = !bottomDiscountOpen"
745
+ >
746
+ <span class="flex items-center gap-1.5">
747
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.6" stroke="currentColor" class="w-4">
748
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.169.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" />
749
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6Z" />
750
+ </svg>
751
+ Add Discount
752
+ </span>
753
+ <svg
754
+ xmlns="http://www.w3.org/2000/svg"
755
+ fill="none"
756
+ viewBox="0 0 24 24"
757
+ stroke-width="2"
758
+ stroke="currentColor"
759
+ class="w-4 flex-shrink-0 transition-transform duration-200"
760
+ :class="bottomDiscountOpen ? 'rotate-180' : ''"
761
+ >
762
+ <path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
763
+ </svg>
764
+ </button>
765
+
766
+ <transition
767
+ :css="false"
768
+ @enter="accordionEnter"
769
+ @after-enter="accordionAfterEnter"
770
+ @leave="accordionLeave"
771
+ @after-leave="accordionAfterEnter"
772
+ >
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>
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
+
816
+ <button
817
+ 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)"
822
+ >
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>
827
+ </button>
828
+ </div>
829
+ </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
+ </div>
861
+ </transition>
862
+
863
+ <!-- order breakdown: always visible, even while items/promo are collapsed -->
864
+ <dl class="mt-3 space-y-2 border-t border-gray-200 pb-1 pt-3 text-[13px]">
865
+ <div class="flex justify-between text-gray-500">
866
+ <dt>Subtotal ({{ totalItemsCount }} {{ totalItemsCount > 1 ? "items" : "item" }})</dt>
867
+ <dd>{{ currency }}{{ total_price }}</dd>
868
+ </div>
869
+ <div class="flex justify-between text-gray-500" v-if="selectedCountry || selectedAddress || selectedZone">
870
+ <dt>Shipping</dt>
871
+ <dd v-if="isShippingLoading" class="w-[80px]"><skeletor width="100%" height="18px" /></dd>
872
+ <dd v-else-if="isFreeDelivery" class="flex gap-2">
873
+ <span class="text-gray-400 line-through">{{ currency }}{{ shippingCost }}</span>
874
+ <span class="text-green-600">{{ currency }}0</span>
875
+ </dd>
876
+ <dd v-else>{{ currency }}{{ shippingCost }}</dd>
877
+ </div>
878
+ <div class="flex justify-between text-gray-500" v-else>
879
+ <dt>Shipping</dt><dd class="text-xs">Enter address to calculate</dd>
880
+ </div>
881
+ <div v-if="discount" class="flex justify-between text-green-600">
882
+ <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>
887
+ </div>
888
+ <div v-for="line in extraFeeLines" :key="`bottom-fee-${line.name}`" class="flex justify-between text-gray-500">
889
+ <dt>{{ line.name }}</dt><dd>{{ currency }}{{ line.amount.toFixed(2) }}</dd>
890
+ </div>
891
+ </dl>
892
+
893
+ <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
+ </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
+ </div>
903
+
904
+ <div v-if="isCheckingOut" class="flex w-full justify-center py-8">
905
+ <FadeLoader color="black" height="10px" />
906
+ </div>
907
+ <button
908
+ v-else
909
+ type="button"
910
+ class="osi-btn osi-btn--block"
911
+ @click="submitForm"
912
+ >
913
+ Complete order
914
+ </button>
915
+ </template>
916
+ </div>
917
+ </div>
918
+
919
+ <!-- ============ GATEWAY HANDOFF ============ -->
920
+ <PaymentHandoff
921
+ v-if="activePayment"
922
+ :payment="activePayment"
923
+ :amount="totalPrice"
924
+ @error="onPaymentError"
925
+ @settled="onPaymentSettled"
926
+ @declined="onPaymentDeclined"
927
+ @close="closePayment"
928
+ />
929
+ </div>
930
+ </template>
931
+ <script>
932
+ import checkoutServices from "#osi/services/CheckoutServices";
933
+ import { authBus } from "#osi/services/AuthBus";
934
+ import utilsServices from "#osi/services/UtilsServices";
935
+ import { Skeletor } from "vue-skeletor";
936
+ 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
+ import FadeLoader from "vue-spinner/src/FadeLoader.vue";
944
+ import { DropdownSelect } from "#components";
945
+ import { scrollToFirstError } from "#osi/utils/scrollToError";
946
+ import { useHead } from "#imports";
947
+ import { useUserStore } from "#osi/stores/userStore";
948
+ import { analytics } from "#osi/utils/analytics";
949
+
950
+ definePageMeta({
951
+ name: "checkout",
952
+ });
953
+
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",
1003
+ ),
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
+ },
1127
+
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
+ };
1919
+ </script>
1920
+ <style scoped>
1921
+ /* Floating label fields: label sits inside the input and lifts to the
1922
+ top of the row on focus or once the field has a value. */
1923
+ .floating-field {
1924
+ position: relative;
1925
+ padding: 22px 12px 9px;
1926
+ }
1927
+
1928
+ .floating-input {
1929
+ width: 100%;
1930
+ border: 0;
1931
+ background: transparent;
1932
+ padding: 0;
1933
+ font-size: 16px;
1934
+ line-height: 20px;
1935
+ outline: none;
1936
+ color: inherit;
1937
+ }
1938
+
1939
+ .floating-label {
1940
+ position: absolute;
1941
+ left: 12px;
1942
+ right: 12px;
1943
+ top: 50%;
1944
+ transform: translateY(-50%);
1945
+ font-size: 14px;
1946
+ line-height: 20px;
1947
+ color: #6b7280;
1948
+ pointer-events: none;
1949
+ white-space: nowrap;
1950
+ overflow: hidden;
1951
+ text-overflow: ellipsis;
1952
+ transition: top 0.18s ease, font-size 0.18s ease, transform 0.18s ease, color 0.18s ease;
1953
+ }
1954
+
1955
+ .floating-input:focus ~ .floating-label,
1956
+ .floating-input:not(:placeholder-shown) ~ .floating-label,
1957
+ .floating-input:-webkit-autofill ~ .floating-label,
1958
+ .floating-label.is-floated {
1959
+ top: 7px;
1960
+ transform: none;
1961
+ font-size: 11px;
1962
+ line-height: 15px;
1963
+ color: #9ca3af;
1964
+ }
1965
+
1966
+
1967
+ @media (prefers-reduced-motion: reduce) {
1968
+ .floating-label {
1969
+ transition: none;
1970
+ }
1971
+ }
1972
+ </style>