@osimart/create-storefront 1.0.22 → 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.
@@ -1,967 +1,28 @@
1
1
  <template>
2
- <div class="bg-gray-50 font-body text-primaryColor">
3
-
4
- <!-- Customization changed since the item was added: placeOrder said so. -->
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
- import { Skeletor } from "vue-skeletor";
950
- import "vue-skeletor/dist/vue-skeletor.css";
951
- import FadeLoader from "vue-spinner/src/FadeLoader.vue";
952
- import ExtraFees from "~/components/checkout/ExtraFees.vue";
953
- import PaymentHandoff from "~/components/checkout/PaymentHandoff.vue";
954
- import PhoneNumInput from "~/components/common/PhoneNumInput.vue";
955
- import DropdownSelect from "~/components/common/DropdownSelect.vue";
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 { useCheckout } from "~/composables/useCheckout";
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
- // display
986
- currency,
987
- imageUrl,
988
- isLoading,
989
- mobileSummaryOpen,
990
- bottomDiscountOpen,
991
- accordionEnter,
992
- accordionLeave,
993
- accordionAfterEnter,
994
- // cart
995
- CartItems,
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
- </style>
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>