@osimart/create-storefront 1.0.23 → 1.0.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/pages/checkout.vue
CHANGED
|
@@ -1,967 +1,28 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
<div v-if="cartError" class="mx-auto max-w-6xl px-4 pt-4 lg:px-9" role="alert">
|
|
6
|
-
<div class="flex items-center justify-between gap-4 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
|
7
|
-
<span>{{ cartError }}</span>
|
|
8
|
-
<button type="button" class="shrink-0 underline" @click="reviewCart">Review cart</button>
|
|
9
|
-
</div>
|
|
10
|
-
</div>
|
|
11
|
-
|
|
12
|
-
<!-- ============ MOBILE ORDER STRIP ============ -->
|
|
13
|
-
<div class="border-b border-gray-200 bg-white lg:hidden">
|
|
14
|
-
<button
|
|
15
|
-
type="button"
|
|
16
|
-
class="flex w-full items-center gap-3 px-4 py-3 text-left"
|
|
17
|
-
@click="mobileSummaryOpen = !mobileSummaryOpen"
|
|
18
|
-
>
|
|
19
|
-
<template v-if="isLoading">
|
|
20
|
-
<skeletor width="100%" height="44px" />
|
|
21
|
-
</template>
|
|
22
|
-
<template v-else>
|
|
23
|
-
<div class="flex flex-shrink-0 gap-1.5">
|
|
24
|
-
<div
|
|
25
|
-
v-for="item in TopLevelItems.slice(0, 2)"
|
|
26
|
-
:key="`strip-${item.order_item_id}`"
|
|
27
|
-
class="h-11 w-9 overflow-hidden rounded-md border border-gray-200 bg-white"
|
|
28
|
-
>
|
|
29
|
-
<img :src="imageUrl(item.image)" :alt="item.name" class="h-full w-full object-cover" />
|
|
30
|
-
</div>
|
|
31
|
-
<div
|
|
32
|
-
v-if="TopLevelItems.length > 2"
|
|
33
|
-
class="flex h-11 w-9 items-center justify-center rounded-md border border-dashed border-gray-300 text-xs text-gray-500"
|
|
34
|
-
>
|
|
35
|
-
+{{ TopLevelItems.length - 2 }}
|
|
36
|
-
</div>
|
|
37
|
-
</div>
|
|
38
|
-
<div class="min-w-0 flex-1">
|
|
39
|
-
<p class="text-[13px] text-gray-500">Order Summary</p>
|
|
40
|
-
<p class="text-base font-semibold">{{ currency }}{{ totalPrice }}</p>
|
|
41
|
-
</div>
|
|
42
|
-
<svg
|
|
43
|
-
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"
|
|
44
|
-
class="w-4 flex-shrink-0 text-gray-500 transition-transform duration-300"
|
|
45
|
-
:class="mobileSummaryOpen ? 'rotate-180' : ''"
|
|
46
|
-
>
|
|
47
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
48
|
-
</svg>
|
|
49
|
-
</template>
|
|
50
|
-
</button>
|
|
51
|
-
|
|
52
|
-
<transition
|
|
53
|
-
:css="false"
|
|
54
|
-
@enter="accordionEnter"
|
|
55
|
-
@after-enter="accordionAfterEnter"
|
|
56
|
-
@leave="accordionLeave"
|
|
57
|
-
@after-leave="accordionAfterEnter"
|
|
58
|
-
>
|
|
59
|
-
<div v-if="mobileSummaryOpen && !isLoading">
|
|
60
|
-
<div class="border-t border-gray-200 px-4 pb-5 pt-4">
|
|
61
|
-
<!-- line items with steppers -->
|
|
62
|
-
<div
|
|
63
|
-
v-for="item in CartItems"
|
|
64
|
-
:key="`strip-item-${item.order_item_id}`"
|
|
65
|
-
:class="item.parent_item ? 'ms-5 opacity-80' : ''"
|
|
66
|
-
:data-checkout-lines="item === CartItems[0] ? 'mobile' : undefined"
|
|
67
|
-
class="group flex items-center gap-3 py-2"
|
|
68
|
-
>
|
|
69
|
-
<div class="h-14 w-12 flex-shrink-0 overflow-hidden rounded-md border border-gray-200 bg-white">
|
|
70
|
-
<img :src="imageUrl(item.image)" :alt="item.name" class="h-full w-full object-cover" />
|
|
71
|
-
</div>
|
|
72
|
-
<div class="min-w-0 flex-1">
|
|
73
|
-
<p class="truncate text-[13px]">{{ item.name }}</p>
|
|
74
|
-
<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>
|
|
75
|
-
<p v-for="prop in item.properties || []" :key="prop.key" class="text-xs text-gray-500">{{ prop.label }}: {{ prop.value === true ? 'Yes' : prop.value }}</p>
|
|
76
|
-
<div v-if="!item.parent_item" class="mt-1.5 flex items-center gap-3">
|
|
77
|
-
<div class="flex h-7 w-fit items-center rounded-full border border-gray-300">
|
|
78
|
-
<button
|
|
79
|
-
type="button"
|
|
80
|
-
class="flex h-7 w-7 items-center justify-center text-gray-500 disabled:opacity-40"
|
|
81
|
-
:disabled="isItemUpdating(item)"
|
|
82
|
-
aria-label="Decrease quantity"
|
|
83
|
-
@click="decreaseQuantity(item)"
|
|
84
|
-
>
|
|
85
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-3.5">
|
|
86
|
-
<path stroke-linecap="round" d="M5 12h14" />
|
|
87
|
-
</svg>
|
|
88
|
-
</button>
|
|
89
|
-
<span class="min-w-[24px] text-center text-[13px]">
|
|
90
|
-
<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>
|
|
91
|
-
<template v-else>{{ item.quantity }}</template>
|
|
92
|
-
</span>
|
|
93
|
-
<button
|
|
94
|
-
type="button"
|
|
95
|
-
class="flex h-7 w-7 items-center justify-center text-gray-500 disabled:opacity-40"
|
|
96
|
-
:disabled="isItemUpdating(item)"
|
|
97
|
-
aria-label="Increase quantity"
|
|
98
|
-
@click="increaseQuantity(item)"
|
|
99
|
-
>
|
|
100
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-3.5">
|
|
101
|
-
<path stroke-linecap="round" d="M12 5v14M5 12h14" />
|
|
102
|
-
</svg>
|
|
103
|
-
</button>
|
|
104
|
-
</div>
|
|
105
|
-
|
|
106
|
-
<button
|
|
107
|
-
type="button"
|
|
108
|
-
class="flex items-center justify-center gap-1 text-[12px] text-gray-400 opacity-100 transition-opacity duration-200 hover:text-red-500 disabled:opacity-40 md:opacity-0 md:focus-visible:opacity-100 md:group-hover:opacity-100"
|
|
109
|
-
:disabled="isItemUpdating(item)"
|
|
110
|
-
aria-label="Remove item"
|
|
111
|
-
@click="deleteItem(item)"
|
|
112
|
-
>
|
|
113
|
-
<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">
|
|
114
|
-
<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" />
|
|
115
|
-
</svg>
|
|
116
|
-
<span class="mt-0.5">Remove</span>
|
|
117
|
-
</button>
|
|
118
|
-
</div>
|
|
119
|
-
</div>
|
|
120
|
-
<span class="whitespace-nowrap text-[13px]">{{ currency }}{{ item.total_price }}</span>
|
|
121
|
-
</div>
|
|
122
|
-
|
|
123
|
-
<!-- promo -->
|
|
124
|
-
<div class="pt-3">
|
|
125
|
-
<div v-if="!promo_id" class="flex gap-2">
|
|
126
|
-
<input
|
|
127
|
-
type="text"
|
|
128
|
-
id="m-promocode"
|
|
129
|
-
v-model="promoCode"
|
|
130
|
-
placeholder="Promo code"
|
|
131
|
-
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"
|
|
132
|
-
@input="promoError = ''"
|
|
133
|
-
@keyup.enter="applyPromoCode"
|
|
134
|
-
/>
|
|
135
|
-
<button
|
|
136
|
-
type="button"
|
|
137
|
-
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"
|
|
138
|
-
:disabled="isCodeLoading"
|
|
139
|
-
@click="applyPromoCode"
|
|
140
|
-
>
|
|
141
|
-
<span v-if="isCodeLoading" class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-gray-200 border-t-primaryColor"></span>
|
|
142
|
-
<span v-else>Apply</span>
|
|
143
|
-
</button>
|
|
144
|
-
</div>
|
|
145
|
-
<div v-else class="flex items-center justify-between">
|
|
146
|
-
<p class="text-sm text-green-600">
|
|
147
|
-
Promo applied: {{ appliedPromoCode }}
|
|
148
|
-
<span v-if="isCodeLoading" class="ml-1 inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-200 border-t-primaryColor align-middle"></span>
|
|
149
|
-
</p>
|
|
150
|
-
<button type="button" class="rounded-lg border border-red-600 bg-red-600 px-2 py-1 text-xs text-white" @click="removePromo">
|
|
151
|
-
Remove
|
|
152
|
-
</button>
|
|
153
|
-
</div>
|
|
154
|
-
<p v-if="promoError" class="mt-1.5 text-xs text-red-500">{{ promoError }}</p>
|
|
155
|
-
</div>
|
|
156
|
-
|
|
157
|
-
<!-- totals -->
|
|
158
|
-
<dl class="mt-4 space-y-2 border-t border-gray-200 pt-3 text-[13px]">
|
|
159
|
-
<div class="flex justify-between text-gray-500"><dt>Subtotal</dt><dd>{{ currency }}{{ total_price }}</dd></div>
|
|
160
|
-
<div class="flex justify-between text-gray-500" v-if="selectedCountry || selectedAddress || selectedZone">
|
|
161
|
-
<dt>Shipping</dt>
|
|
162
|
-
<dd v-if="isShippingLoading" class="w-[80px]"><skeletor width="100%" height="18px" /></dd>
|
|
163
|
-
<dd v-else-if="isFreeDelivery" class="flex gap-2">
|
|
164
|
-
<span class="text-gray-400 line-through">{{ currency }}{{ shippingCost }}</span>
|
|
165
|
-
<span class="text-green-600">{{ currency }}0</span>
|
|
166
|
-
</dd>
|
|
167
|
-
<dd v-else>{{ currency }}{{ shippingCost }}</dd>
|
|
168
|
-
</div>
|
|
169
|
-
<div class="flex justify-between text-gray-500" v-else>
|
|
170
|
-
<dt>Shipping</dt><dd class="text-xs">Enter address to calculate</dd>
|
|
171
|
-
</div>
|
|
172
|
-
<div v-if="discount > 0" class="flex justify-between text-green-600">
|
|
173
|
-
<dt>Discount</dt>
|
|
174
|
-
<dd>-{{ currency }}{{ discount.toFixed(2) }}</dd>
|
|
175
|
-
</div>
|
|
176
|
-
<div v-for="line in extraFeeLines" :key="`m-fee-${line.name}`" class="flex justify-between text-gray-500">
|
|
177
|
-
<dt>{{ line.name }}</dt><dd>{{ currency }}{{ line.amount.toFixed(2) }}</dd>
|
|
178
|
-
</div>
|
|
179
|
-
</dl>
|
|
180
|
-
</div>
|
|
181
|
-
</div>
|
|
182
|
-
</transition>
|
|
183
|
-
</div>
|
|
184
|
-
|
|
185
|
-
<!-- ============ BODY ============ -->
|
|
186
|
-
<div
|
|
187
|
-
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"
|
|
188
|
-
>
|
|
189
|
-
<!-- ---------------- LEFT COLUMN ---------------- -->
|
|
190
|
-
<div class="lg:order-1">
|
|
191
|
-
<!-- skeleton -->
|
|
192
|
-
<template v-if="isLoading">
|
|
193
|
-
<div class="space-y-8">
|
|
194
|
-
<div class="space-y-3">
|
|
195
|
-
<skeletor width="40%" height="24px" />
|
|
196
|
-
<skeletor width="100%" height="150px" />
|
|
197
|
-
</div>
|
|
198
|
-
<div class="space-y-3">
|
|
199
|
-
<skeletor width="40%" height="24px" />
|
|
200
|
-
<skeletor width="100%" height="180px" />
|
|
201
|
-
</div>
|
|
202
|
-
<div class="space-y-3">
|
|
203
|
-
<skeletor width="40%" height="24px" />
|
|
204
|
-
<skeletor width="100%" height="110px" />
|
|
205
|
-
</div>
|
|
206
|
-
</div>
|
|
207
|
-
</template>
|
|
208
|
-
|
|
209
|
-
<template v-else>
|
|
210
|
-
<!-- ===== 1. DETAILS ===== -->
|
|
211
|
-
<section>
|
|
212
|
-
<div class="mb-3 flex items-center gap-2">
|
|
213
|
-
<span class="flex h-[22px] w-[22px] items-center justify-center rounded-full bg-primaryColor text-xs text-white">1</span>
|
|
214
|
-
<h2 class="text-[15px] font-semibold">Your details</h2>
|
|
215
|
-
<span class="h-px flex-1 bg-gray-200"></span>
|
|
216
|
-
<router-link
|
|
217
|
-
v-if="!isLoggedIn"
|
|
218
|
-
:to="{ path: '/login', query: { redirect: $route.fullPath } }"
|
|
219
|
-
class="text-xs text-green-500 underline"
|
|
220
|
-
>
|
|
221
|
-
Sign in
|
|
222
|
-
</router-link>
|
|
223
|
-
</div>
|
|
224
|
-
|
|
225
|
-
<div class="divide-y divide-gray-200 overflow-hidden rounded-xl border border-gray-300 bg-white">
|
|
226
|
-
<div data-field="email" class="floating-field" :class="emailHasError ? 'bg-red-50' : ''">
|
|
227
|
-
<input
|
|
228
|
-
type="text"
|
|
229
|
-
id="email"
|
|
230
|
-
v-model="formData.email"
|
|
231
|
-
placeholder=" "
|
|
232
|
-
class="floating-input disabled:text-gray-500"
|
|
233
|
-
:disabled="isLoggedIn && formData.email !== ''"
|
|
234
|
-
@input="validateEmailField"
|
|
235
|
-
/>
|
|
236
|
-
<label for="email" class="floating-label">Email</label>
|
|
237
|
-
</div>
|
|
238
|
-
|
|
239
|
-
<div class="flex divide-x divide-gray-200">
|
|
240
|
-
<div data-field="first_name" class="floating-field flex-1" :class="formErrors.first_name ? 'bg-red-50' : ''">
|
|
241
|
-
<input
|
|
242
|
-
type="text"
|
|
243
|
-
id="firstName"
|
|
244
|
-
v-model="formData.first_name"
|
|
245
|
-
placeholder=" "
|
|
246
|
-
class="floating-input disabled:text-gray-500"
|
|
247
|
-
:disabled="isLoggedIn && formData.first_name !== ''"
|
|
248
|
-
@input="clearFieldError('first_name')"
|
|
249
|
-
/>
|
|
250
|
-
<label for="firstName" class="floating-label">First name</label>
|
|
251
|
-
</div>
|
|
252
|
-
<div data-field="last_name" class="floating-field flex-1" :class="formErrors.last_name ? 'bg-red-50' : ''">
|
|
253
|
-
<input
|
|
254
|
-
type="text"
|
|
255
|
-
id="lastName"
|
|
256
|
-
v-model="formData.last_name"
|
|
257
|
-
placeholder=" "
|
|
258
|
-
class="floating-input disabled:text-gray-500"
|
|
259
|
-
:disabled="isLoggedIn && formData.last_name !== ''"
|
|
260
|
-
@input="clearFieldError('last_name')"
|
|
261
|
-
/>
|
|
262
|
-
<label for="lastName" class="floating-label">Last name</label>
|
|
263
|
-
</div>
|
|
264
|
-
</div>
|
|
265
|
-
|
|
266
|
-
<div
|
|
267
|
-
v-if="needsPhoneInput"
|
|
268
|
-
data-field="mobile_number"
|
|
269
|
-
class="px-3"
|
|
270
|
-
:class="phoneHasError ? 'bg-red-50' : ''"
|
|
271
|
-
>
|
|
272
|
-
<PhoneNumInput
|
|
273
|
-
v-model="formData.mobile_number"
|
|
274
|
-
variant="bare"
|
|
275
|
-
:error="phoneHasError"
|
|
276
|
-
@update="onPhoneUpdate"
|
|
277
|
-
/>
|
|
278
|
-
</div>
|
|
279
|
-
<div v-else class="floating-field">
|
|
280
|
-
<input
|
|
281
|
-
type="text"
|
|
282
|
-
id="mobile_number"
|
|
283
|
-
v-model="formData.mobile_number"
|
|
284
|
-
placeholder=" "
|
|
285
|
-
class="floating-input disabled:text-gray-500"
|
|
286
|
-
disabled
|
|
287
|
-
/>
|
|
288
|
-
<label for="mobile_number" class="floating-label">Phone</label>
|
|
289
|
-
</div>
|
|
290
|
-
</div>
|
|
291
|
-
|
|
292
|
-
<p v-if="emailErrorMessage" class="mt-2 text-xs text-red-500">{{ emailErrorMessage }}</p>
|
|
293
|
-
<p v-if="phoneErrorMessage" class="mt-2 text-xs text-red-500">{{ phoneErrorMessage }}</p>
|
|
294
|
-
<p v-if="detailsErrorFields.length" class="mt-2 text-xs text-red-500">Please fill all required fields: {{ detailsErrorFields.join(', ') }}</p>
|
|
295
|
-
|
|
296
|
-
<!-- newsletter toggle -->
|
|
297
|
-
<label for="toggle" class="mt-3 flex cursor-pointer items-center gap-3">
|
|
298
|
-
<span class="relative inline-block h-[26px] w-[50px] flex-shrink-0">
|
|
299
|
-
<input
|
|
300
|
-
type="checkbox"
|
|
301
|
-
id="toggle"
|
|
302
|
-
v-model="formData.signedUpForExclusiveContent"
|
|
303
|
-
class="peer h-0 w-0 appearance-none opacity-0"
|
|
304
|
-
/>
|
|
305
|
-
<span
|
|
306
|
-
class="absolute inset-0 rounded-full bg-gray-300 transition-colors duration-300 peer-checked:bg-primaryColor"
|
|
307
|
-
></span>
|
|
308
|
-
<span
|
|
309
|
-
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"
|
|
310
|
-
></span>
|
|
311
|
-
</span>
|
|
312
|
-
<span class="text-[13px] text-gray-500">
|
|
313
|
-
Sign up for exclusive discounts and updates via email. Unsubscribe anytime.
|
|
314
|
-
</span>
|
|
315
|
-
</label>
|
|
316
|
-
</section>
|
|
317
|
-
|
|
318
|
-
<!-- ===== 2. DELIVERY ===== -->
|
|
319
|
-
<section class="pt-7">
|
|
320
|
-
<div class="mb-3 flex items-center gap-2">
|
|
321
|
-
<span class="flex h-[22px] w-[22px] items-center justify-center rounded-full bg-primaryColor text-xs text-white">2</span>
|
|
322
|
-
<h2 class="text-[15px] font-semibold">Delivery address</h2>
|
|
323
|
-
<span class="h-px flex-1 bg-gray-200"></span>
|
|
324
|
-
<router-link
|
|
325
|
-
v-if="!isLoggedIn"
|
|
326
|
-
:to="{ path: '/login', query: { redirect: $route.fullPath } }"
|
|
327
|
-
class="text-xs text-green-500 underline"
|
|
328
|
-
>
|
|
329
|
-
Sign in
|
|
330
|
-
</router-link>
|
|
331
|
-
</div>
|
|
332
|
-
|
|
333
|
-
<!-- saved addresses -->
|
|
334
|
-
<div v-if="userAddresses && userAddresses.length > 0" class="mb-2.5">
|
|
335
|
-
<div class="divide-y divide-gray-200 overflow-hidden rounded-xl border border-gray-300 bg-white">
|
|
336
|
-
<label
|
|
337
|
-
v-for="(address, index) in userAddresses"
|
|
338
|
-
:key="index"
|
|
339
|
-
class="flex cursor-pointer items-center gap-3 px-3 py-2.5"
|
|
340
|
-
:class="selectedAddressIndex === index && !showAddressForm ? 'bg-primaryColor/5' : ''"
|
|
341
|
-
>
|
|
342
|
-
<input
|
|
343
|
-
type="checkbox"
|
|
344
|
-
:checked="selectedAddressIndex === index"
|
|
345
|
-
:disabled="showAddressForm"
|
|
346
|
-
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"
|
|
347
|
-
@change="selectAddress(index)"
|
|
348
|
-
/>
|
|
349
|
-
<span class="min-w-0 flex-1 truncate text-sm">{{ address.address }}</span>
|
|
350
|
-
</label>
|
|
351
|
-
|
|
352
|
-
<button
|
|
353
|
-
type="button"
|
|
354
|
-
class="flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm text-primaryColor"
|
|
355
|
-
@click="toggleAddressForm"
|
|
356
|
-
>
|
|
357
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4">
|
|
358
|
-
<path stroke-linecap="round" d="M12 5v14M5 12h14" />
|
|
359
|
-
</svg>
|
|
360
|
-
{{ showAddressForm ? "Use a saved address" : "Add new address" }}
|
|
361
|
-
</button>
|
|
362
|
-
</div>
|
|
363
|
-
</div>
|
|
364
|
-
|
|
365
|
-
<!-- address form -->
|
|
366
|
-
<div
|
|
367
|
-
v-if="showAddressForm || (isLoggedIn && userAddresses.length === 0) || !isLoggedIn"
|
|
368
|
-
class="divide-y divide-gray-200 overflow-hidden rounded-xl border border-gray-300 bg-white"
|
|
369
|
-
>
|
|
370
|
-
<!-- country -->
|
|
371
|
-
<div data-field="country" class="floating-field" :class="formErrors.country ? 'bg-red-50' : ''">
|
|
372
|
-
<DropdownSelect
|
|
373
|
-
:options="countryOptions"
|
|
374
|
-
:model-value="selectedCountry"
|
|
375
|
-
@update:model-value="selectCountry($event); clearFieldError('country')"
|
|
376
|
-
placeholder="Country"
|
|
377
|
-
searchable
|
|
378
|
-
local-search
|
|
379
|
-
full-width
|
|
380
|
-
field="country"
|
|
381
|
-
:error="!!formErrors.country"
|
|
382
|
-
>
|
|
383
|
-
<template #trigger="{ toggle, open }">
|
|
384
|
-
<button
|
|
385
|
-
type="button"
|
|
386
|
-
class="flex h-5 w-full items-center justify-between border-0 bg-transparent p-0 text-left text-sm"
|
|
387
|
-
@click="toggle"
|
|
388
|
-
>
|
|
389
|
-
<span class="truncate">{{ selectedCountry?.country_name || "" }}</span>
|
|
390
|
-
<svg
|
|
391
|
-
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
|
392
|
-
class="ml-2 w-4 flex-shrink-0 text-gray-400 transition-transform duration-200"
|
|
393
|
-
:class="open ? 'rotate-180' : ''"
|
|
394
|
-
>
|
|
395
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
396
|
-
</svg>
|
|
397
|
-
</button>
|
|
398
|
-
</template>
|
|
399
|
-
</DropdownSelect>
|
|
400
|
-
<label class="floating-label" :class="selectedCountry ? 'is-floated' : ''">Country</label>
|
|
401
|
-
</div>
|
|
402
|
-
|
|
403
|
-
<!-- zone -->
|
|
404
|
-
<div v-if="zoneRequired" data-field="zone" class="floating-field" :class="formErrors.zone ? 'bg-red-50' : ''">
|
|
405
|
-
<DropdownSelect
|
|
406
|
-
:options="zoneOptions"
|
|
407
|
-
:model-value="selectedZone"
|
|
408
|
-
@update:model-value="selectZone($event); clearFieldError('zone')"
|
|
409
|
-
placeholder="Zone"
|
|
410
|
-
searchable
|
|
411
|
-
local-search
|
|
412
|
-
full-width
|
|
413
|
-
field="zone"
|
|
414
|
-
:error="!!formErrors.zone"
|
|
415
|
-
>
|
|
416
|
-
<template #trigger="{ toggle, open }">
|
|
417
|
-
<button
|
|
418
|
-
type="button"
|
|
419
|
-
class="flex h-5 w-full items-center justify-between border-0 bg-transparent p-0 text-left text-sm"
|
|
420
|
-
@click="toggle"
|
|
421
|
-
>
|
|
422
|
-
<span class="truncate">{{ selectedZone?.name || "" }}</span>
|
|
423
|
-
<svg
|
|
424
|
-
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
|
425
|
-
class="ml-2 w-4 flex-shrink-0 text-gray-400 transition-transform duration-200"
|
|
426
|
-
:class="open ? 'rotate-180' : ''"
|
|
427
|
-
>
|
|
428
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
429
|
-
</svg>
|
|
430
|
-
</button>
|
|
431
|
-
</template>
|
|
432
|
-
</DropdownSelect>
|
|
433
|
-
<label class="floating-label" :class="selectedZone ? 'is-floated' : ''">Zone</label>
|
|
434
|
-
</div>
|
|
435
|
-
|
|
436
|
-
<!-- city + postcode -->
|
|
437
|
-
<div class="flex divide-x divide-gray-200">
|
|
438
|
-
<div data-field="city" class="floating-field flex-1" :class="formErrors.city ? 'bg-red-50' : ''">
|
|
439
|
-
<input
|
|
440
|
-
type="text"
|
|
441
|
-
id="city"
|
|
442
|
-
v-model="formData.address.city"
|
|
443
|
-
placeholder=" "
|
|
444
|
-
class="floating-input"
|
|
445
|
-
@input="clearFieldError('city')"
|
|
446
|
-
/>
|
|
447
|
-
<label for="city" class="floating-label">City</label>
|
|
448
|
-
</div>
|
|
449
|
-
<div class="floating-field flex-1">
|
|
450
|
-
<input
|
|
451
|
-
type="number"
|
|
452
|
-
id="postcode"
|
|
453
|
-
v-model="formData.address.postal_code"
|
|
454
|
-
placeholder=" "
|
|
455
|
-
class="floating-input"
|
|
456
|
-
/>
|
|
457
|
-
<label for="postcode" class="floating-label">Postal code (optional)</label>
|
|
458
|
-
</div>
|
|
459
|
-
</div>
|
|
460
|
-
|
|
461
|
-
<!-- street -->
|
|
462
|
-
<div data-field="address" class="floating-field" :class="formErrors.address ? 'bg-red-50' : ''">
|
|
463
|
-
<input
|
|
464
|
-
id="street"
|
|
465
|
-
v-model="formData.address.address"
|
|
466
|
-
placeholder=" "
|
|
467
|
-
class="floating-input"
|
|
468
|
-
@input="clearFieldError('address')"
|
|
469
|
-
/>
|
|
470
|
-
<label for="street" class="floating-label">Street and area</label>
|
|
471
|
-
</div>
|
|
472
|
-
|
|
473
|
-
<!-- building -->
|
|
474
|
-
<div class="floating-field">
|
|
475
|
-
<input
|
|
476
|
-
id="building"
|
|
477
|
-
v-model="formData.address.address_line2"
|
|
478
|
-
placeholder=" "
|
|
479
|
-
class="floating-input"
|
|
480
|
-
/>
|
|
481
|
-
<label for="building" class="floating-label">Building, floor, apartment (optional)</label>
|
|
482
|
-
</div>
|
|
483
|
-
</div>
|
|
484
|
-
|
|
485
|
-
<p v-if="deliveryErrorFields.length" class="mt-2 text-xs text-red-500">Please fill all required fields: {{ deliveryErrorFields.join(', ') }}</p>
|
|
486
|
-
</section>
|
|
487
|
-
|
|
488
|
-
<!-- ===== 3. PAYMENT ===== -->
|
|
489
|
-
<section class="pt-7">
|
|
490
|
-
<div class="mb-3 flex items-center gap-2">
|
|
491
|
-
<span class="flex h-[22px] w-[22px] items-center justify-center rounded-full bg-primaryColor text-xs text-white">3</span>
|
|
492
|
-
<h2 class="text-[15px] font-semibold">Payment</h2>
|
|
493
|
-
<span class="h-px flex-1 bg-gray-200"></span>
|
|
494
|
-
</div>
|
|
495
|
-
|
|
496
|
-
<div
|
|
497
|
-
data-field="payment_method_id"
|
|
498
|
-
class="divide-y divide-gray-200 overflow-hidden rounded-xl border"
|
|
499
|
-
:class="formErrors.payment_method_id ? 'bg-red-50' : 'border-gray-300 bg-white'"
|
|
500
|
-
>
|
|
501
|
-
<label
|
|
502
|
-
v-for="(option, index) in paymentMethods"
|
|
503
|
-
:key="index"
|
|
504
|
-
class="flex cursor-pointer items-center gap-3 px-3 py-3"
|
|
505
|
-
:class="isSelected(option) ? 'bg-primaryColor/5' : ''"
|
|
506
|
-
>
|
|
507
|
-
<input
|
|
508
|
-
type="checkbox"
|
|
509
|
-
:checked="isSelected(option)"
|
|
510
|
-
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"
|
|
511
|
-
@change="setPaymentMethod(option.id); clearFieldError('payment_method_id')"
|
|
512
|
-
/>
|
|
513
|
-
<img v-if="option.icon" :src="imageUrl(option.icon)" width="22" height="22" :alt="option.name" />
|
|
514
|
-
<span class="flex-1 text-sm">{{ option.name }}</span>
|
|
515
|
-
</label>
|
|
516
|
-
</div>
|
|
517
|
-
<p v-if="formErrors.payment_method_id" class="mt-2 text-xs text-red-500">{{ formErrors.payment_method_id }}</p>
|
|
518
|
-
</section>
|
|
519
|
-
|
|
520
|
-
<!-- ===== DONATE ===== -->
|
|
521
|
-
<section v-if="extraFees.length" class="pt-7">
|
|
522
|
-
<div class="mb-3 flex items-center gap-2">
|
|
523
|
-
<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">
|
|
524
|
-
<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" />
|
|
525
|
-
</svg>
|
|
526
|
-
<h2 class="text-[15px] font-semibold">Would you like to donate?</h2>
|
|
527
|
-
<span class="h-px flex-1 bg-gray-200"></span>
|
|
528
|
-
</div>
|
|
529
|
-
<ExtraFees :fees="extraFees" :subtotal="total_price" @change="onExtraFeesChange" />
|
|
530
|
-
</section>
|
|
531
|
-
</template>
|
|
532
|
-
</div>
|
|
533
|
-
|
|
534
|
-
<!-- ---------------- DESKTOP SUMMARY RAIL ---------------- -->
|
|
535
|
-
<aside class="hidden lg:order-2 lg:block">
|
|
536
|
-
<div class="sticky top-[90px] rounded-xl border border-gray-300 bg-white p-5">
|
|
537
|
-
<template v-if="isLoading">
|
|
538
|
-
<skeletor width="50%" height="20px" />
|
|
539
|
-
|
|
540
|
-
<!-- line items: thumbnail + name/variant/qty-pill + price, matching the real rows -->
|
|
541
|
-
<div class="mt-4 space-y-4">
|
|
542
|
-
<div v-for="i in 4" :key="`sk-rail-item-${i}`" class="flex items-center gap-3">
|
|
543
|
-
<skeletor width="48px" height="56px" />
|
|
544
|
-
<div class="min-w-0 flex-1 space-y-1.5">
|
|
545
|
-
<skeletor width="75%" height="13px" />
|
|
546
|
-
<skeletor width="40%" height="11px" />
|
|
547
|
-
<skeletor width="70px" height="22px" />
|
|
548
|
-
</div>
|
|
549
|
-
<skeletor width="32px" height="13px" />
|
|
550
|
-
</div>
|
|
551
|
-
</div>
|
|
552
|
-
|
|
553
|
-
<!-- promo code row -->
|
|
554
|
-
<div class="mt-4 flex gap-2">
|
|
555
|
-
<skeletor width="100%" height="36px" />
|
|
556
|
-
<skeletor width="64px" height="36px" />
|
|
557
|
-
</div>
|
|
558
|
-
|
|
559
|
-
<!-- totals -->
|
|
560
|
-
<div class="mt-4 space-y-2.5 border-t border-gray-200 pt-3">
|
|
561
|
-
<div class="flex justify-between">
|
|
562
|
-
<skeletor width="35%" height="13px" />
|
|
563
|
-
<skeletor width="18%" height="13px" />
|
|
564
|
-
</div>
|
|
565
|
-
<div class="flex justify-between">
|
|
566
|
-
<skeletor width="28%" height="13px" />
|
|
567
|
-
<skeletor width="18%" height="13px" />
|
|
568
|
-
</div>
|
|
569
|
-
</div>
|
|
570
|
-
|
|
571
|
-
<!-- total -->
|
|
572
|
-
<div class="mt-3 flex items-baseline justify-between border-t border-gray-200 pt-3">
|
|
573
|
-
<skeletor width="25%" height="13px" />
|
|
574
|
-
<skeletor width="30%" height="22px" />
|
|
575
|
-
</div>
|
|
576
|
-
|
|
577
|
-
<div class="mt-4"><skeletor width="100%" height="44px" /></div>
|
|
578
|
-
</template>
|
|
579
|
-
<template v-else>
|
|
580
|
-
<p class="mb-3 text-[13px] text-gray-500">
|
|
581
|
-
Order · {{ totalItemsCount }} {{ totalItemsCount > 1 ? "items" : "item" }}
|
|
582
|
-
</p>
|
|
583
|
-
|
|
584
|
-
<div
|
|
585
|
-
v-for="item in CartItems"
|
|
586
|
-
:key="`rail-${item.order_item_id}`"
|
|
587
|
-
:class="item.parent_item ? 'ms-5 opacity-80' : ''"
|
|
588
|
-
:data-checkout-lines="item === CartItems[0] ? 'rail' : undefined"
|
|
589
|
-
class="group flex items-center gap-3 py-2"
|
|
590
|
-
>
|
|
591
|
-
<div class="h-14 w-12 flex-shrink-0 overflow-hidden rounded-md border border-gray-200 bg-white">
|
|
592
|
-
<img :src="imageUrl(item.image)" :alt="item.name" class="h-full w-full object-cover" />
|
|
593
|
-
</div>
|
|
594
|
-
<div class="min-w-0 flex-1">
|
|
595
|
-
<p class="truncate text-[13px]">{{ item.name }}</p>
|
|
596
|
-
<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>
|
|
597
|
-
<p v-for="prop in item.properties || []" :key="prop.key" class="text-xs text-gray-500">{{ prop.label }}: {{ prop.value === true ? 'Yes' : prop.value }}</p>
|
|
598
|
-
<div v-if="!item.parent_item" class="mt-1.5 flex items-center gap-3">
|
|
599
|
-
<div class="flex h-7 w-fit items-center rounded-full border border-gray-300">
|
|
600
|
-
<button
|
|
601
|
-
type="button"
|
|
602
|
-
class="flex h-7 w-7 items-center justify-center text-gray-500 disabled:opacity-40"
|
|
603
|
-
:disabled="isItemUpdating(item)"
|
|
604
|
-
aria-label="Decrease quantity"
|
|
605
|
-
@click="decreaseQuantity(item)"
|
|
606
|
-
>
|
|
607
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-3.5">
|
|
608
|
-
<path stroke-linecap="round" d="M5 12h14" />
|
|
609
|
-
</svg>
|
|
610
|
-
</button>
|
|
611
|
-
<span class="min-w-[24px] text-center text-[13px]">
|
|
612
|
-
<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>
|
|
613
|
-
<template v-else>{{ item.quantity }}</template>
|
|
614
|
-
</span>
|
|
615
|
-
<button
|
|
616
|
-
type="button"
|
|
617
|
-
class="flex h-7 w-7 items-center justify-center text-gray-500 disabled:opacity-40"
|
|
618
|
-
:disabled="isItemUpdating(item)"
|
|
619
|
-
aria-label="Increase quantity"
|
|
620
|
-
@click="increaseQuantity(item)"
|
|
621
|
-
>
|
|
622
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-3.5">
|
|
623
|
-
<path stroke-linecap="round" d="M12 5v14M5 12h14" />
|
|
624
|
-
</svg>
|
|
625
|
-
</button>
|
|
626
|
-
</div>
|
|
627
|
-
|
|
628
|
-
<button
|
|
629
|
-
type="button"
|
|
630
|
-
class="flex items-center gap-1 font-body text-[12px] text-gray-400 opacity-100 transition-opacity duration-200 hover:text-red-500 disabled:opacity-40 md:opacity-0 md:focus-visible:opacity-100 md:group-hover:opacity-100"
|
|
631
|
-
:disabled="isItemUpdating(item)"
|
|
632
|
-
aria-label="Remove item"
|
|
633
|
-
@click="deleteItem(item)"
|
|
634
|
-
>
|
|
635
|
-
<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">
|
|
636
|
-
<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" />
|
|
637
|
-
</svg>
|
|
638
|
-
<span class="mt-0.5">Remove</span>
|
|
639
|
-
</button>
|
|
640
|
-
</div>
|
|
641
|
-
</div>
|
|
642
|
-
<span class="whitespace-nowrap text-[13px]">{{ currency }}{{ item.total_price }}</span>
|
|
643
|
-
</div>
|
|
644
|
-
|
|
645
|
-
<!-- promo -->
|
|
646
|
-
<div class="mt-3">
|
|
647
|
-
<div v-if="!promo_id" class="flex gap-2">
|
|
648
|
-
<input
|
|
649
|
-
type="text"
|
|
650
|
-
id="promocode"
|
|
651
|
-
v-model="promoCode"
|
|
652
|
-
placeholder="Promo code"
|
|
653
|
-
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"
|
|
654
|
-
@input="promoError = ''"
|
|
655
|
-
@keyup.enter="applyPromoCode"
|
|
656
|
-
/>
|
|
657
|
-
<button
|
|
658
|
-
type="button"
|
|
659
|
-
class="flex h-9 flex-shrink-0 items-center justify-center rounded-lg border border-gray-300 px-4 text-[13px]"
|
|
660
|
-
:disabled="isCodeLoading"
|
|
661
|
-
@click="applyPromoCode"
|
|
662
|
-
>
|
|
663
|
-
<span v-if="isCodeLoading" class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-gray-200 border-t-primaryColor"></span>
|
|
664
|
-
<span v-else>Apply</span>
|
|
665
|
-
</button>
|
|
666
|
-
</div>
|
|
667
|
-
<div v-else class="flex items-center justify-between">
|
|
668
|
-
<p class="text-[13px] text-green-600">
|
|
669
|
-
Promo applied: {{ appliedPromoCode }}
|
|
670
|
-
<span v-if="isCodeLoading" class="ml-1 inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-200 border-t-primaryColor align-middle"></span>
|
|
671
|
-
</p>
|
|
672
|
-
<button type="button" class="rounded-lg border border-red-600 bg-red-600 px-2 py-1 text-xs text-white" @click="removePromo">
|
|
673
|
-
Remove
|
|
674
|
-
</button>
|
|
675
|
-
</div>
|
|
676
|
-
<p v-if="promoError" class="mt-1.5 text-xs text-red-500">{{ promoError }}</p>
|
|
677
|
-
</div>
|
|
678
|
-
|
|
679
|
-
<!-- totals -->
|
|
680
|
-
<dl class="mt-4 space-y-2 border-t border-gray-200 pt-3 text-[13px]">
|
|
681
|
-
<div class="flex justify-between text-gray-500"><dt>Subtotal</dt><dd>{{ currency }}{{ total_price }}</dd></div>
|
|
682
|
-
<div class="flex justify-between text-gray-500" v-if="selectedCountry || selectedAddress || selectedZone">
|
|
683
|
-
<dt>Shipping</dt>
|
|
684
|
-
<dd v-if="isShippingLoading" class="w-[80px]"><skeletor width="100%" height="18px" /></dd>
|
|
685
|
-
<dd v-else-if="isFreeDelivery" class="flex gap-2">
|
|
686
|
-
<span class="text-gray-400 line-through">{{ currency }}{{ shippingCost }}</span>
|
|
687
|
-
<span class="text-green-600">{{ currency }}0</span>
|
|
688
|
-
</dd>
|
|
689
|
-
<dd v-else>{{ currency }}{{ shippingCost }}</dd>
|
|
690
|
-
</div>
|
|
691
|
-
<div class="flex justify-between text-gray-500" v-else>
|
|
692
|
-
<dt>Shipping</dt><dd class="text-xs">Enter address to calculate</dd>
|
|
693
|
-
</div>
|
|
694
|
-
<div v-if="discount > 0" class="flex justify-between text-green-600">
|
|
695
|
-
<dt>Discount</dt>
|
|
696
|
-
<dd>-{{ currency }}{{ discount.toFixed(2) }}</dd>
|
|
697
|
-
</div>
|
|
698
|
-
<div v-for="line in extraFeeLines" :key="`rail-fee-${line.name}`" class="flex justify-between text-gray-500">
|
|
699
|
-
<dt>{{ line.name }}</dt><dd>{{ currency }}{{ line.amount.toFixed(2) }}</dd>
|
|
700
|
-
</div>
|
|
701
|
-
</dl>
|
|
702
|
-
|
|
703
|
-
<div class="mt-3 flex items-baseline justify-between border-t border-gray-200 pt-3">
|
|
704
|
-
<span class="text-[13px] text-gray-500">Total</span>
|
|
705
|
-
<div class="flex items-baseline gap-2">
|
|
706
|
-
<span v-if="discount > 0" class="text-[13px] text-gray-400 line-through">{{ currency }}{{ originalTotal }}</span>
|
|
707
|
-
<span class="text-xl font-semibold">{{ currency }}{{ totalPrice }}</span>
|
|
708
|
-
</div>
|
|
709
|
-
</div>
|
|
710
|
-
|
|
711
|
-
<div v-if="isCheckingOut" class="mt-4 flex w-full justify-center py-8">
|
|
712
|
-
<FadeLoader color="black" height="10px" />
|
|
713
|
-
</div>
|
|
714
|
-
<button
|
|
715
|
-
v-else
|
|
716
|
-
type="button"
|
|
717
|
-
class="osi-btn osi-btn--block mt-4 disabled:cursor-not-allowed disabled:opacity-50"
|
|
718
|
-
:disabled="!canPay"
|
|
719
|
-
@click="submitForm"
|
|
720
|
-
>
|
|
721
|
-
Complete order
|
|
722
|
-
</button>
|
|
723
|
-
|
|
724
|
-
<p class="mt-3 text-center text-[11px] text-gray-400">
|
|
725
|
-
By placing an order you're consenting to our privacy policy.
|
|
726
|
-
</p>
|
|
727
|
-
</template>
|
|
728
|
-
</div>
|
|
729
|
-
</aside>
|
|
730
|
-
</div>
|
|
731
|
-
|
|
732
|
-
<!-- ============ MOBILE PAY BAR ============ -->
|
|
733
|
-
<div
|
|
734
|
-
class="border-t border-gray-200 bg-white px-4 pb-[max(12px,env(safe-area-inset-bottom))] pt-3 lg:hidden"
|
|
735
|
-
>
|
|
736
|
-
<div class="mx-auto">
|
|
737
|
-
<!-- skeleton while the cart/user is still loading -->
|
|
738
|
-
<template v-if="isLoading">
|
|
739
|
-
<skeletor width="50%" height="18px" />
|
|
740
|
-
<div class="mt-3 space-y-3">
|
|
741
|
-
<skeletor v-for="i in 2" :key="`sk-bottom-item-${i}`" width="100%" height="56px" />
|
|
742
|
-
</div>
|
|
743
|
-
<div class="mt-3"><skeletor width="100%" height="40px" /></div>
|
|
744
|
-
<div class="mt-4 space-y-2">
|
|
745
|
-
<skeletor v-for="i in 3" :key="`sk-bottom-line-${i}`" width="100%" height="16px" />
|
|
746
|
-
</div>
|
|
747
|
-
<div class="mb-2.5 mt-3 border-t border-gray-200 pt-3">
|
|
748
|
-
<skeletor width="45%" height="22px" />
|
|
749
|
-
</div>
|
|
750
|
-
<skeletor width="100%" height="48px" />
|
|
751
|
-
</template>
|
|
752
|
-
|
|
753
|
-
<template v-else>
|
|
754
|
-
<!-- toggle: reveals line items + promo code input, Shopify-style -->
|
|
755
|
-
<button
|
|
756
|
-
type="button"
|
|
757
|
-
class="flex w-full items-center justify-between py-1 text-[13px] font-medium text-gray-700"
|
|
758
|
-
@click="bottomDiscountOpen = !bottomDiscountOpen"
|
|
759
|
-
>
|
|
760
|
-
<span class="flex items-center gap-1.5">
|
|
761
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.6" stroke="currentColor" class="w-4">
|
|
762
|
-
<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" />
|
|
763
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6Z" />
|
|
764
|
-
</svg>
|
|
765
|
-
Add Discount
|
|
766
|
-
</span>
|
|
767
|
-
<svg
|
|
768
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
769
|
-
fill="none"
|
|
770
|
-
viewBox="0 0 24 24"
|
|
771
|
-
stroke-width="2"
|
|
772
|
-
stroke="currentColor"
|
|
773
|
-
class="w-4 flex-shrink-0 transition-transform duration-200"
|
|
774
|
-
:class="bottomDiscountOpen ? 'rotate-180' : ''"
|
|
775
|
-
>
|
|
776
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
|
777
|
-
</svg>
|
|
778
|
-
</button>
|
|
779
|
-
|
|
780
|
-
<transition
|
|
781
|
-
:css="false"
|
|
782
|
-
@enter="accordionEnter"
|
|
783
|
-
@after-enter="accordionAfterEnter"
|
|
784
|
-
@leave="accordionLeave"
|
|
785
|
-
@after-leave="accordionAfterEnter"
|
|
786
|
-
>
|
|
787
|
-
<div v-if="bottomDiscountOpen">
|
|
788
|
-
<!-- line items -->
|
|
789
|
-
<div
|
|
790
|
-
v-for="item in CartItems"
|
|
791
|
-
:key="`bottom-${item.order_item_id}`"
|
|
792
|
-
:class="item.parent_item ? 'ms-5 opacity-80' : ''"
|
|
793
|
-
class="group flex items-center gap-3 py-2"
|
|
794
|
-
>
|
|
795
|
-
<div class="h-14 w-12 flex-shrink-0 overflow-hidden rounded-md border border-gray-200 bg-white">
|
|
796
|
-
<img :src="imageUrl(item.image)" :alt="item.name" class="h-full w-full object-cover" />
|
|
797
|
-
</div>
|
|
798
|
-
<div class="min-w-0 flex-1">
|
|
799
|
-
<p class="truncate text-[13px]">{{ item.name }}</p>
|
|
800
|
-
<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>
|
|
801
|
-
<p v-for="prop in item.properties || []" :key="prop.key" class="text-xs text-gray-500">{{ prop.label }}: {{ prop.value === true ? 'Yes' : prop.value }}</p>
|
|
802
|
-
<div v-if="!item.parent_item" class="mt-1.5 flex items-center gap-3">
|
|
803
|
-
<div class="flex h-7 w-fit items-center rounded-full border border-gray-300">
|
|
804
|
-
<button
|
|
805
|
-
type="button"
|
|
806
|
-
class="flex h-7 w-7 items-center justify-center text-gray-500 disabled:opacity-40"
|
|
807
|
-
:disabled="isItemUpdating(item)"
|
|
808
|
-
aria-label="Decrease quantity"
|
|
809
|
-
@click="decreaseQuantity(item)"
|
|
810
|
-
>
|
|
811
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-3.5">
|
|
812
|
-
<path stroke-linecap="round" d="M5 12h14" />
|
|
813
|
-
</svg>
|
|
814
|
-
</button>
|
|
815
|
-
<span class="min-w-[24px] text-center text-[13px]">
|
|
816
|
-
<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>
|
|
817
|
-
<template v-else>{{ item.quantity }}</template>
|
|
818
|
-
</span>
|
|
819
|
-
<button
|
|
820
|
-
type="button"
|
|
821
|
-
class="flex h-7 w-7 items-center justify-center text-gray-500 disabled:opacity-40"
|
|
822
|
-
:disabled="isItemUpdating(item)"
|
|
823
|
-
aria-label="Increase quantity"
|
|
824
|
-
@click="increaseQuantity(item)"
|
|
825
|
-
>
|
|
826
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-3.5">
|
|
827
|
-
<path stroke-linecap="round" d="M12 5v14M5 12h14" />
|
|
828
|
-
</svg>
|
|
829
|
-
</button>
|
|
830
|
-
</div>
|
|
831
|
-
|
|
832
|
-
<button
|
|
833
|
-
type="button"
|
|
834
|
-
class="flex items-center justify-center gap-1 text-[12px] text-gray-400 transition-colors duration-200 hover:text-red-500 disabled:opacity-40"
|
|
835
|
-
:disabled="isItemUpdating(item)"
|
|
836
|
-
aria-label="Remove item"
|
|
837
|
-
@click="deleteItem(item)"
|
|
838
|
-
>
|
|
839
|
-
<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">
|
|
840
|
-
<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" />
|
|
841
|
-
</svg>
|
|
842
|
-
<span class="mt-0.5">Remove</span>
|
|
843
|
-
</button>
|
|
844
|
-
</div>
|
|
845
|
-
</div>
|
|
846
|
-
<span class="whitespace-nowrap text-[13px]">{{ currency }}{{ item.total_price }}</span>
|
|
847
|
-
</div>
|
|
848
|
-
|
|
849
|
-
<!-- promo -->
|
|
850
|
-
<div class="pt-3">
|
|
851
|
-
<div v-if="!promo_id" class="flex gap-2">
|
|
852
|
-
<input
|
|
853
|
-
type="text"
|
|
854
|
-
id="b-promocode"
|
|
855
|
-
v-model="promoCode"
|
|
856
|
-
placeholder="Promo code"
|
|
857
|
-
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"
|
|
858
|
-
@input="promoError = ''"
|
|
859
|
-
@keyup.enter="applyPromoCode"
|
|
860
|
-
/>
|
|
861
|
-
<button
|
|
862
|
-
type="button"
|
|
863
|
-
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"
|
|
864
|
-
:disabled="isCodeLoading"
|
|
865
|
-
@click="applyPromoCode"
|
|
866
|
-
>
|
|
867
|
-
<span v-if="isCodeLoading" class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-gray-200 border-t-primaryColor"></span>
|
|
868
|
-
<span v-else>Apply</span>
|
|
869
|
-
</button>
|
|
870
|
-
</div>
|
|
871
|
-
<div v-else class="flex items-center justify-between">
|
|
872
|
-
<p class="text-sm text-green-600">
|
|
873
|
-
Promo applied: {{ appliedPromoCode }}
|
|
874
|
-
<span v-if="isCodeLoading" class="ml-1 inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-200 border-t-primaryColor align-middle"></span>
|
|
875
|
-
</p>
|
|
876
|
-
<button type="button" class="rounded-lg border border-red-600 bg-red-600 px-2 py-1 text-xs text-white" @click="removePromo">
|
|
877
|
-
Remove
|
|
878
|
-
</button>
|
|
879
|
-
</div>
|
|
880
|
-
<p v-if="promoError" class="mt-1.5 text-xs text-red-500">{{ promoError }}</p>
|
|
881
|
-
</div>
|
|
882
|
-
</div>
|
|
883
|
-
</transition>
|
|
884
|
-
|
|
885
|
-
<!-- order breakdown: always visible, even while items/promo are collapsed -->
|
|
886
|
-
<dl class="mt-3 space-y-2 border-t border-gray-200 pb-1 pt-3 text-[13px]">
|
|
887
|
-
<div class="flex justify-between text-gray-500">
|
|
888
|
-
<dt>Subtotal ({{ totalItemsCount }} {{ totalItemsCount > 1 ? "items" : "item" }})</dt>
|
|
889
|
-
<dd>{{ currency }}{{ total_price }}</dd>
|
|
890
|
-
</div>
|
|
891
|
-
<div class="flex justify-between text-gray-500" v-if="selectedCountry || selectedAddress || selectedZone">
|
|
892
|
-
<dt>Shipping</dt>
|
|
893
|
-
<dd v-if="isShippingLoading" class="w-[80px]"><skeletor width="100%" height="18px" /></dd>
|
|
894
|
-
<dd v-else-if="isFreeDelivery" class="flex gap-2">
|
|
895
|
-
<span class="text-gray-400 line-through">{{ currency }}{{ shippingCost }}</span>
|
|
896
|
-
<span class="text-green-600">{{ currency }}0</span>
|
|
897
|
-
</dd>
|
|
898
|
-
<dd v-else>{{ currency }}{{ shippingCost }}</dd>
|
|
899
|
-
</div>
|
|
900
|
-
<div class="flex justify-between text-gray-500" v-else>
|
|
901
|
-
<dt>Shipping</dt><dd class="text-xs">Enter address to calculate</dd>
|
|
902
|
-
</div>
|
|
903
|
-
<div v-if="discount > 0" class="flex justify-between text-green-600">
|
|
904
|
-
<dt>Discount</dt>
|
|
905
|
-
<dd>-{{ currency }}{{ discount.toFixed(2) }}</dd>
|
|
906
|
-
</div>
|
|
907
|
-
<div v-for="line in extraFeeLines" :key="`bottom-fee-${line.name}`" class="flex justify-between text-gray-500">
|
|
908
|
-
<dt>{{ line.name }}</dt><dd>{{ currency }}{{ line.amount.toFixed(2) }}</dd>
|
|
909
|
-
</div>
|
|
910
|
-
</dl>
|
|
911
|
-
|
|
912
|
-
<div class="mb-2.5 mt-3 flex items-end justify-between border-t border-gray-200 pt-3">
|
|
913
|
-
<p>Total</p>
|
|
914
|
-
<div class="flex items-baseline gap-2">
|
|
915
|
-
<span v-if="discount > 0" class="text-[13px] text-gray-400 line-through">{{ currency }}{{ originalTotal }}</span>
|
|
916
|
-
<span class="text-lg font-semibold">{{ currency }}{{ totalPrice }}</span>
|
|
917
|
-
</div>
|
|
918
|
-
</div>
|
|
919
|
-
|
|
920
|
-
<div v-if="isCheckingOut" class="flex w-full justify-center py-8">
|
|
921
|
-
<FadeLoader color="black" height="10px" />
|
|
922
|
-
</div>
|
|
923
|
-
<button
|
|
924
|
-
v-else
|
|
925
|
-
type="button"
|
|
926
|
-
class="osi-btn osi-btn--block disabled:cursor-not-allowed disabled:opacity-50"
|
|
927
|
-
:disabled="!canPay"
|
|
928
|
-
@click="submitForm"
|
|
929
|
-
>
|
|
930
|
-
Complete order
|
|
931
|
-
</button>
|
|
932
|
-
</template>
|
|
933
|
-
</div>
|
|
934
|
-
</div>
|
|
935
|
-
|
|
936
|
-
<!-- ============ GATEWAY HANDOFF ============ -->
|
|
937
|
-
<PaymentHandoff
|
|
938
|
-
v-if="activePayment"
|
|
939
|
-
:payment="activePayment"
|
|
940
|
-
:amount="totalPrice"
|
|
941
|
-
@error="onPaymentError"
|
|
942
|
-
@settled="onPaymentSettled"
|
|
943
|
-
@declined="onPaymentDeclined"
|
|
944
|
-
@close="closePayment"
|
|
945
|
-
/>
|
|
946
|
-
</div>
|
|
2
|
+
<!-- In the editor's preview the section key goes on the root, so a click
|
|
3
|
+
selects the checkout section. -->
|
|
4
|
+
<CheckoutView :settings="mainSection?.settings ?? {}" :data-osi-section="mainSection?.key" />
|
|
947
5
|
</template>
|
|
948
6
|
<script setup>
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
7
|
+
/**
|
|
8
|
+
* /checkout — Shopify-style: the CHECKOUT template, edited in the theme
|
|
9
|
+
* editor, holds one section (MAIN_CHECKOUT) whose settings colour the page
|
|
10
|
+
* and rename a few labels. The checkout itself (CheckoutView) stays in this
|
|
11
|
+
* package — the theme can't replace or rearrange it.
|
|
12
|
+
*
|
|
13
|
+
* The template comes from render("/checkout"). No template (an older theme,
|
|
14
|
+
* a backend without the route): the built-in look. In the editor, the live
|
|
15
|
+
* state replaces it on every change.
|
|
16
|
+
*/
|
|
956
17
|
import { useHead } from "#imports";
|
|
957
|
-
import
|
|
18
|
+
import CheckoutView from "~/components/checkout/CheckoutView.vue";
|
|
958
19
|
|
|
959
20
|
definePageMeta({
|
|
960
21
|
name: "checkout",
|
|
961
22
|
});
|
|
962
23
|
|
|
963
24
|
// Multi-tenant: the description comes from the resolved store.
|
|
964
|
-
const { store } = useStorefront();
|
|
25
|
+
const { store, render } = useStorefront();
|
|
965
26
|
const storeName = computed(() => store.value?.name || "");
|
|
966
27
|
|
|
967
28
|
useHead({
|
|
@@ -981,140 +42,24 @@ useHead({
|
|
|
981
42
|
],
|
|
982
43
|
});
|
|
983
44
|
|
|
984
|
-
const {
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
TopLevelItems,
|
|
997
|
-
total_price,
|
|
998
|
-
totalItemsCount,
|
|
999
|
-
isItemUpdating,
|
|
1000
|
-
increaseQuantity,
|
|
1001
|
-
decreaseQuantity,
|
|
1002
|
-
deleteItem,
|
|
1003
|
-
cartError,
|
|
1004
|
-
reviewCart,
|
|
1005
|
-
// user + form
|
|
1006
|
-
isLoggedIn,
|
|
1007
|
-
needsPhoneInput,
|
|
1008
|
-
formData,
|
|
1009
|
-
formErrors,
|
|
1010
|
-
clearFieldError,
|
|
1011
|
-
validateEmailField,
|
|
1012
|
-
onPhoneUpdate,
|
|
1013
|
-
detailsErrorFields,
|
|
1014
|
-
deliveryErrorFields,
|
|
1015
|
-
emailErrorMessage,
|
|
1016
|
-
emailHasError,
|
|
1017
|
-
phoneErrorMessage,
|
|
1018
|
-
phoneHasError,
|
|
1019
|
-
// address + shipping
|
|
1020
|
-
userAddresses,
|
|
1021
|
-
selectedAddressIndex,
|
|
1022
|
-
selectedAddress,
|
|
1023
|
-
showAddressForm,
|
|
1024
|
-
selectAddress,
|
|
1025
|
-
toggleAddressForm,
|
|
1026
|
-
countryOptions,
|
|
1027
|
-
zoneOptions,
|
|
1028
|
-
zoneRequired,
|
|
1029
|
-
selectedCountry,
|
|
1030
|
-
selectedZone,
|
|
1031
|
-
selectCountry,
|
|
1032
|
-
selectZone,
|
|
1033
|
-
shippingCost,
|
|
1034
|
-
isShippingLoading,
|
|
1035
|
-
isFreeDelivery,
|
|
1036
|
-
// payment method
|
|
1037
|
-
paymentMethods,
|
|
1038
|
-
isSelected,
|
|
1039
|
-
setPaymentMethod,
|
|
1040
|
-
// extra fees
|
|
1041
|
-
extraFees,
|
|
1042
|
-
extraFeeLines,
|
|
1043
|
-
onExtraFeesChange,
|
|
1044
|
-
// promo
|
|
1045
|
-
promoCode,
|
|
1046
|
-
appliedPromoCode,
|
|
1047
|
-
promo_id,
|
|
1048
|
-
discount,
|
|
1049
|
-
promoError,
|
|
1050
|
-
isCodeLoading,
|
|
1051
|
-
applyPromoCode,
|
|
1052
|
-
removePromo,
|
|
1053
|
-
// totals + order
|
|
1054
|
-
originalTotal,
|
|
1055
|
-
totalPrice,
|
|
1056
|
-
canPay,
|
|
1057
|
-
isCheckingOut,
|
|
1058
|
-
submitForm,
|
|
1059
|
-
// gateway
|
|
1060
|
-
activePayment,
|
|
1061
|
-
onPaymentError,
|
|
1062
|
-
onPaymentSettled,
|
|
1063
|
-
onPaymentDeclined,
|
|
1064
|
-
closePayment,
|
|
1065
|
-
} = useCheckout();
|
|
1066
|
-
</script>
|
|
1067
|
-
<style scoped>
|
|
1068
|
-
/* Floating label fields: label sits inside the input and lifts to the
|
|
1069
|
-
top of the row on focus or once the field has a value. */
|
|
1070
|
-
.floating-field {
|
|
1071
|
-
position: relative;
|
|
1072
|
-
padding: 22px 12px 9px;
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
.floating-input {
|
|
1076
|
-
width: 100%;
|
|
1077
|
-
border: 0;
|
|
1078
|
-
background: transparent;
|
|
1079
|
-
padding: 0;
|
|
1080
|
-
font-size: 16px;
|
|
1081
|
-
line-height: 20px;
|
|
1082
|
-
outline: none;
|
|
1083
|
-
color: inherit;
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
.floating-label {
|
|
1087
|
-
position: absolute;
|
|
1088
|
-
left: 12px;
|
|
1089
|
-
right: 12px;
|
|
1090
|
-
top: 50%;
|
|
1091
|
-
transform: translateY(-50%);
|
|
1092
|
-
font-size: 14px;
|
|
1093
|
-
line-height: 20px;
|
|
1094
|
-
color: #6b7280;
|
|
1095
|
-
pointer-events: none;
|
|
1096
|
-
white-space: nowrap;
|
|
1097
|
-
overflow: hidden;
|
|
1098
|
-
text-overflow: ellipsis;
|
|
1099
|
-
transition: top 0.18s ease, font-size 0.18s ease, transform 0.18s ease, color 0.18s ease;
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
/* Text inputs float on focus or once filled; dropdowns float only once an
|
|
1103
|
-
option is actually selected (is-floated). */
|
|
1104
|
-
.floating-input:focus ~ .floating-label,
|
|
1105
|
-
.floating-input:not(:placeholder-shown) ~ .floating-label,
|
|
1106
|
-
.floating-input:-webkit-autofill ~ .floating-label,
|
|
1107
|
-
.floating-label.is-floated {
|
|
1108
|
-
top: 7px;
|
|
1109
|
-
transform: none;
|
|
1110
|
-
font-size: 11px;
|
|
1111
|
-
line-height: 15px;
|
|
1112
|
-
color: #9ca3af;
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
@media (prefers-reduced-motion: reduce) {
|
|
1116
|
-
.floating-label {
|
|
1117
|
-
transition: none;
|
|
45
|
+
const templateName = (payload) => {
|
|
46
|
+
const t = payload?.template;
|
|
47
|
+
return String((t && typeof t === "object" ? t.name : t) ?? "").toUpperCase();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Never blocks the checkout: any failure just means the built-in look.
|
|
51
|
+
const { data } = await useAsyncData("render:/checkout", async () => {
|
|
52
|
+
try {
|
|
53
|
+
const payload = await render("/checkout");
|
|
54
|
+
return templateName(payload) === "CHECKOUT" ? payload : null;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
1118
57
|
}
|
|
1119
|
-
}
|
|
1120
|
-
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const { sectionsFrom } = useBuilderPreview();
|
|
61
|
+
const sections = sectionsFrom(computed(() => data.value?.sections ?? []));
|
|
62
|
+
const mainSection = computed(
|
|
63
|
+
() => sections.value.find((s) => s?.type === "MAIN_CHECKOUT" && !s.disabled) ?? null
|
|
64
|
+
);
|
|
65
|
+
</script>
|