@osimart/create-storefront 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -2
- package/THEME_DEV_API.md +80 -0
- package/app/app.vue +154 -0
- package/app/assets/css/osi-controls.css +824 -0
- package/app/components/SectionRenderer.vue +46 -0
- package/app/components/builder/BuilderOverlay.vue +582 -0
- package/app/components/checkout/ExtraFees.vue +329 -0
- package/app/components/checkout/PaymentHandoff.vue +388 -0
- package/app/components/common/Banner.vue +35 -0
- package/app/components/common/ConfirmDeleteModal.vue +117 -0
- package/app/components/common/DropdownSelect.vue +432 -0
- package/app/components/common/Loader.vue +32 -0
- package/app/components/common/OsiField.vue +218 -0
- package/app/components/common/PhoneNumInput.vue +434 -0
- package/app/composables/UseBodyScrollLock.js +122 -0
- package/app/composables/UseThemeGroup.js +575 -0
- package/app/composables/useAddToCart.js +87 -0
- package/app/composables/useAnalytics.js +15 -0
- package/app/composables/useAnnouncements.js +29 -0
- package/app/composables/useApiErrorToast.js +104 -0
- package/app/composables/useAuth.js +99 -0
- package/app/composables/useBanners.js +25 -0
- package/app/composables/useBuilderPreview.js +320 -0
- package/app/composables/useButtonStyle.js +107 -0
- package/app/composables/useCarousel.js +160 -0
- package/app/composables/useCart.js +109 -0
- package/app/composables/useCheckout.js +76 -0
- package/app/composables/useClassificationItems.js +269 -0
- package/app/composables/useClassificationPage.js +545 -0
- package/app/composables/useClassificationUrl.js +63 -0
- package/app/composables/useColorScheme.js +44 -0
- package/app/composables/useContactForm.js +53 -0
- package/app/composables/useContentTree.js +770 -0
- package/app/composables/useDragScroll.js +73 -0
- package/app/composables/useFooterSocials.js +107 -0
- package/app/composables/useInputStyle.js +45 -0
- package/app/composables/useLayoutShell.js +114 -0
- package/app/composables/useLazyVideo.js +36 -0
- package/app/composables/useMediaTypes.js +66 -0
- package/app/composables/useMediaUrl.js +71 -0
- package/app/composables/useMenu.js +100 -0
- package/app/composables/useNavStyle.js +219 -0
- package/app/composables/usePlaceholders.js +294 -0
- package/app/composables/usePolicies.js +26 -0
- package/app/composables/useProductCardMedia.js +135 -0
- package/app/composables/useProductCardStyle.js +132 -0
- package/app/composables/useProductDetail.js +982 -0
- package/app/composables/useProductGallery.js +166 -0
- package/app/composables/useProductItems.js +270 -0
- package/app/composables/useProductUrl.js +60 -0
- package/app/composables/useProductVariants.js +210 -0
- package/app/composables/useQuickShop.js +115 -0
- package/app/composables/useRelatedProducts.js +186 -0
- package/app/composables/useRenderContext.js +45 -0
- package/app/composables/useScopedCss.js +106 -0
- package/app/composables/useSearch.js +35 -0
- package/app/composables/useSectionData.js +462 -0
- package/app/composables/useSectionShell.js +113 -0
- package/app/composables/useSidebar.js +69 -0
- package/app/composables/useStoreData.js +57 -0
- package/app/composables/useStoreInit.js +52 -0
- package/app/composables/useStorefront.js +239 -0
- package/app/composables/useT.js +21 -0
- package/app/composables/useTextStyles.js +84 -0
- package/app/composables/useThemeControls.js +34 -0
- package/app/composables/useThemeSeo.js +91 -0
- package/app/composables/useThemeTokens.js +171 -0
- package/app/composables/useWishlist.js +66 -0
- package/app/logic/components/AnnouncementBar.js +118 -0
- package/app/logic/components/CardGrid.js +125 -0
- package/app/logic/components/CardList.js +98 -0
- package/app/logic/components/CartDrawer.js +273 -0
- package/app/logic/components/CartItem.js +31 -0
- package/app/logic/components/ClassificationBento.js +6 -0
- package/app/logic/components/ClassificationCard.js +6 -0
- package/app/logic/components/ClassificationCarousel.js +115 -0
- package/app/logic/components/ClassificationEditorial.js +41 -0
- package/app/logic/components/ClassificationFeatured.js +57 -0
- package/app/logic/components/ClassificationGrid.js +6 -0
- package/app/logic/components/ClassificationNav.js +61 -0
- package/app/logic/components/ClassificationPart.js +72 -0
- package/app/logic/components/FooterBlock.js +91 -0
- package/app/logic/components/FooterSocials.js +60 -0
- package/app/logic/components/HeaderDropdownList.js +29 -0
- package/app/logic/components/HeaderIcon.js +68 -0
- package/app/logic/components/HeaderLogo.js +39 -0
- package/app/logic/components/HeaderNav.js +268 -0
- package/app/logic/components/MobileMenu.js +280 -0
- package/app/logic/components/ProductBuyButtons.js +66 -0
- package/app/logic/components/ProductCard.js +160 -0
- package/app/logic/components/ProductFilters.js +37 -0
- package/app/logic/components/ProductGallery.js +19 -0
- package/app/logic/components/ProductInfoBlock.js +411 -0
- package/app/logic/components/ProductLightbox.js +12 -0
- package/app/logic/components/ProductQuantity.js +31 -0
- package/app/logic/components/ProductVariantPicker.js +125 -0
- package/app/logic/components/QuickBuy.js +194 -0
- package/app/logic/components/SearchOverlay.js +41 -0
- package/app/logic/components/SidebarMenuItem.js +39 -0
- package/app/logic/components/SiteFooter.js +476 -0
- package/app/logic/components/SiteHeader.js +266 -0
- package/app/logic/components/WhatsAppButton.js +75 -0
- package/app/logic/layouts/default.js +66 -0
- package/app/logic/sections/AnnouncementBarSection.js +6 -0
- package/app/logic/sections/BlockCarousel.js +103 -0
- package/app/logic/sections/ClassificationListSection.js +40 -0
- package/app/logic/sections/ContactFormSection.js +294 -0
- package/app/logic/sections/ContactInfoSection.js +419 -0
- package/app/logic/sections/ContentNode.js +189 -0
- package/app/logic/sections/CountdownSection.js +254 -0
- package/app/logic/sections/CustomHtmlSection.js +51 -0
- package/app/logic/sections/DividerSection.js +31 -0
- package/app/logic/sections/FaqSection.js +53 -0
- package/app/logic/sections/FeatureListSection.js +6 -0
- package/app/logic/sections/FeaturedProductSection.js +40 -0
- package/app/logic/sections/FooterSection.js +16 -0
- package/app/logic/sections/FooterUtilitiesSection.js +24 -0
- package/app/logic/sections/GallerySection.js +232 -0
- package/app/logic/sections/HeaderSection.js +24 -0
- package/app/logic/sections/HeroSection.js +24 -0
- package/app/logic/sections/LogoListSection.js +6 -0
- package/app/logic/sections/MainClassificationSection.js +473 -0
- package/app/logic/sections/MainProductSection.js +211 -0
- package/app/logic/sections/MainSearchSection.js +6 -0
- package/app/logic/sections/MapSection.js +92 -0
- package/app/logic/sections/MediaContentSection.js +38 -0
- package/app/logic/sections/MediaWithTextSection.js +238 -0
- package/app/logic/sections/NewsletterSection.js +126 -0
- package/app/logic/sections/ProductHighlightSection.js +201 -0
- package/app/logic/sections/ProductListSection.js +45 -0
- package/app/logic/sections/ProductRecommendationsSection.js +113 -0
- package/app/logic/sections/RichTextSection.js +6 -0
- package/app/logic/sections/ScrollingTextSection.js +69 -0
- package/app/logic/sections/SearchHeaderSection.js +22 -0
- package/app/logic/sections/SlideshowSection.js +118 -0
- package/app/logic/sections/SpacerSection.js +37 -0
- package/app/logic/sections/SplitShowcaseSection.js +6 -0
- package/app/logic/sections/TestimonialListSection.js +6 -0
- package/app/logic/sections/VideoSection.js +235 -0
- package/app/pages/[category]/[product].vue +196 -0
- package/app/pages/__builder-preview.vue +38 -0
- package/app/pages/categories/[slug].vue +208 -0
- package/app/pages/checkout.vue +1972 -0
- package/app/pages/forgot-password.vue +115 -0
- package/app/pages/index.vue +64 -0
- package/app/pages/login.vue +585 -0
- package/app/pages/order-detail.vue +442 -0
- package/app/pages/pages/[handle].vue +58 -0
- package/app/pages/privacy-policy.vue +46 -0
- package/app/pages/resetpassword.vue +138 -0
- package/app/pages/resetverificationcode.vue +163 -0
- package/app/pages/result.vue +203 -0
- package/app/pages/return-policy.vue +50 -0
- package/app/pages/search.vue +100 -0
- package/app/pages/shipping-policy.vue +46 -0
- package/app/pages/user-profile/components/AddAddressModal.vue +452 -0
- package/app/pages/user-profile/components/Whishlist.vue +137 -0
- package/app/pages/user-profile/components/left-bar.vue +233 -0
- package/app/pages/user-profile/components/orders.vue +162 -0
- package/app/pages/user-profile/components/profile-details.vue +566 -0
- package/app/pages/user-profile/components/settings.vue +194 -0
- package/app/pages/user-profile/index.vue +252 -0
- package/app/pages/verificationcode.vue +188 -0
- package/app/plugins/analytics.client.js +99 -0
- package/app/plugins/geo.js +24 -0
- package/app/plugins/reveal.client.js +53 -0
- package/app/plugins/toast.js +10 -0
- package/app/plugins/vue-query.js +5 -0
- package/app/services/ApiServices.js +74 -0
- package/app/services/AuthBus.js +162 -0
- package/app/services/Authentication/AuthenticationService.js +133 -0
- package/app/services/Authentication/TokenService.js +62 -0
- package/app/services/CartServices.js +91 -0
- package/app/services/CartToken.js +26 -0
- package/app/services/CategoryServices.js +54 -0
- package/app/services/CheckoutServices.js +113 -0
- package/app/services/HomeServices.js +133 -0
- package/app/services/NewsletterServices.js +29 -0
- package/app/services/PaymentServices.js +71 -0
- package/app/services/PoliciesServices.js +16 -0
- package/app/services/ProductServices.js +79 -0
- package/app/services/SearchServices.js +18 -0
- package/app/services/UserServices.js +105 -0
- package/app/services/UtilsServices.js +46 -0
- package/app/services/WishlistServices.js +51 -0
- package/app/services/storefront.service.js +57 -0
- package/app/stores/userStore.js +13 -0
- package/app/theme/categoryTree.js +68 -0
- package/app/theme/controls.js +88 -0
- package/app/theme/registry.js +56 -0
- package/app/utils/analytics.js +864 -0
- package/app/utils/device.js +50 -0
- package/app/utils/eventBus.js +24 -0
- package/app/utils/icon-presets.js +24 -0
- package/app/utils/productOffers.js +118 -0
- package/app/utils/requestCache.js +36 -0
- package/app/utils/scrollToError.js +59 -0
- package/app/utils/toPlainError.js +30 -0
- package/bin/osimart-theme.mjs +282 -0
- package/nuxt/osi-sections.ts +56 -0
- package/nuxt.config.ts +60 -0
- package/package.json +44 -4
- package/public/css/input.css +29 -0
- package/public/css/output.css +2716 -0
- package/public/favicon-32x32.png +0 -0
- 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>
|