@code-collective/booking-widget 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +266 -0
- package/dist/booking-widget.css +2 -0
- package/dist/booking-widget.js +4359 -0
- package/dist/booking-widget.umd.cjs +2 -0
- package/package.json +48 -0
- package/src/lib/AvailabilityCalendar.svelte +222 -0
- package/src/lib/CartBar.svelte +32 -0
- package/src/lib/CartBarView.svelte +60 -0
- package/src/lib/CartOverview.svelte +20 -0
- package/src/lib/CartOverviewButton.svelte +120 -0
- package/src/lib/Checkout.svelte +55 -0
- package/src/lib/CheckoutModal.svelte +510 -0
- package/src/lib/ConsentSection.svelte +45 -0
- package/src/lib/ContactForm.svelte +67 -0
- package/src/lib/CountdownTimer.svelte +30 -0
- package/src/lib/EditBookingView.svelte +73 -0
- package/src/lib/OptionCard.svelte +41 -0
- package/src/lib/PaymentPage.svelte +155 -0
- package/src/lib/PickupPointPicker.svelte +76 -0
- package/src/lib/ResultView.svelte +84 -0
- package/src/lib/SelectableCard.svelte +71 -0
- package/src/lib/TicketConfigurator.svelte +78 -0
- package/src/lib/TimeSlotPicker.svelte +45 -0
- package/src/lib/UnitCounter.svelte +91 -0
- package/src/lib/WizardPage.svelte +561 -0
- package/src/lib/api.ts +204 -0
- package/src/lib/app.css +246 -0
- package/src/lib/cart-manager.ts +76 -0
- package/src/lib/client-types.ts +52 -0
- package/src/lib/config.ts +92 -0
- package/src/lib/currency.ts +18 -0
- package/src/lib/elements/bw-cart.svelte +44 -0
- package/src/lib/elements/bw-checkout.svelte +99 -0
- package/src/lib/elements/bw-configurator.svelte +66 -0
- package/src/lib/elements/env.ts +11 -0
- package/src/lib/elements/register.ts +149 -0
- package/src/lib/elements/shared.ts +14 -0
- package/src/lib/elements/theme.css +193 -0
- package/src/lib/fake-api.ts +282 -0
- package/src/lib/generated-types.ts +828 -0
- package/src/lib/index.ts +189 -0
- package/src/lib/messages.ts +3 -0
- package/src/lib/mock-data.ts +241 -0
- package/src/lib/session-manager.ts +75 -0
- package/src/lib/utils.ts +25 -0
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { CartItem, CartUnitItem, CheckoutProductDto, CheckoutCartItemDetailDto, CheckoutOptionDto, CheckoutAvailabilityDto, CheckoutPickupLocationDto, CheckoutAvailabilityCalendarDto } from './client-types';
|
|
3
|
+
import type { BookingApi } from './api';
|
|
4
|
+
import type { WizardPages, WizardWidgetType } from './config';
|
|
5
|
+
import { formatCurrency } from './currency';
|
|
6
|
+
import { dateKey, formatTime, parseDateFromAvailabilityId } from './utils';
|
|
7
|
+
import OptionCard from './OptionCard.svelte';
|
|
8
|
+
import UnitCounter from './UnitCounter.svelte';
|
|
9
|
+
import AvailabilityCalendar from './AvailabilityCalendar.svelte';
|
|
10
|
+
import TimeSlotPicker from './TimeSlotPicker.svelte';
|
|
11
|
+
import SelectableCard from './SelectableCard.svelte';
|
|
12
|
+
import { onMount } from 'svelte';
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
product: CheckoutProductDto;
|
|
16
|
+
api: BookingApi;
|
|
17
|
+
wizardPages: WizardPages;
|
|
18
|
+
editItem?: CheckoutCartItemDetailDto;
|
|
19
|
+
autoSelectSingleTimeSlot?: boolean;
|
|
20
|
+
onComplete: (item: CartItem) => void;
|
|
21
|
+
onCancel?: () => void;
|
|
22
|
+
}
|
|
23
|
+
let {
|
|
24
|
+
product, api, wizardPages,
|
|
25
|
+
editItem, autoSelectSingleTimeSlot = false, onComplete, onCancel,
|
|
26
|
+
}: Props = $props();
|
|
27
|
+
|
|
28
|
+
let editMode = $derived(editItem != null);
|
|
29
|
+
// In age-first flow the option is selected after date/time, so it should
|
|
30
|
+
// stay changeable even during an edit. Lock it only in option-first flow.
|
|
31
|
+
let lockedOptionId = $derived(editItem && !isAgeFirstFlow() ? editItem.optionId : undefined);
|
|
32
|
+
|
|
33
|
+
let pageIndex = $state(0);
|
|
34
|
+
let selectedOption = $state<CheckoutOptionDto | null>(null);
|
|
35
|
+
let unitQuantities = $state<Record<string, number>>({});
|
|
36
|
+
let selectedDate = $state<Date | null>(null);
|
|
37
|
+
let selectedTime = $state<string | null>(null);
|
|
38
|
+
let selectedTimeSlot = $state<CheckoutAvailabilityDto | null>(null);
|
|
39
|
+
let selectedPickupPoint = $state<CheckoutPickupLocationDto | null>(null);
|
|
40
|
+
let availabilityByDate = $state<Record<string, CheckoutAvailabilityCalendarDto>>({});
|
|
41
|
+
let timeSlots = $state<CheckoutAvailabilityDto[]>([]);
|
|
42
|
+
let loadingAvailability = $state(false);
|
|
43
|
+
let loadingTimeSlots = $state(false);
|
|
44
|
+
let dirty = $state(false);
|
|
45
|
+
let showConfirmDialog = $state(false);
|
|
46
|
+
|
|
47
|
+
let hasQuantities = $derived(Object.values(unitQuantities).some(q => q > 0));
|
|
48
|
+
let hasAllDaySlot = $derived(timeSlots.length === 1 && timeSlots[0].allDay);
|
|
49
|
+
let hasSingleSlot = $derived(timeSlots.length === 1);
|
|
50
|
+
let hideTimePicker = $derived(hasAllDaySlot || (autoSelectSingleTimeSlot && hasSingleSlot));
|
|
51
|
+
|
|
52
|
+
// -- Flow detection --
|
|
53
|
+
// When age-category appears on an earlier page than option, the user picks
|
|
54
|
+
// quantities first, then date/time, then option last. All options share the
|
|
55
|
+
// same unit types, so we can show units and fetch availability before an
|
|
56
|
+
// option is selected by using the first option as a stand-in.
|
|
57
|
+
let isAgeFirstFlow = $derived(() => {
|
|
58
|
+
const agePage = wizardPages.findIndex(p => p.includes('age-category'));
|
|
59
|
+
const optPage = wizardPages.findIndex(p => p.includes('option'));
|
|
60
|
+
return agePage >= 0 && (optPage < 0 || agePage < optPage);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
let availableUnits = $derived(() => {
|
|
64
|
+
if (selectedOption) return selectedOption.units;
|
|
65
|
+
if (isAgeFirstFlow()) return product.options[0]?.units ?? [];
|
|
66
|
+
return [];
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
let effectiveOptionId = $derived(() => {
|
|
70
|
+
if (selectedOption) return selectedOption.id;
|
|
71
|
+
if (isAgeFirstFlow()) return product.options[0]?.id ?? null;
|
|
72
|
+
return null;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
onMount(() => {
|
|
76
|
+
if (!editItem) return;
|
|
77
|
+
restoreFromCartItem(editItem);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
function restoreFromCartItem(item: CheckoutCartItemDetailDto) {
|
|
81
|
+
const opt = product.options.find(o => o.id === item.optionId);
|
|
82
|
+
if (opt) selectedOption = opt;
|
|
83
|
+
|
|
84
|
+
const counts: Record<string, number> = {};
|
|
85
|
+
for (const u of item.unitItems) {
|
|
86
|
+
counts[u.unitId] = (counts[u.unitId] ?? 0) + 1;
|
|
87
|
+
}
|
|
88
|
+
unitQuantities = counts;
|
|
89
|
+
|
|
90
|
+
if (hasQuantities) restoreAvailabilityState(item.availabilityId, item.pickupPointId);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function restoreAvailabilityState(availabilityId?: string, pickupPointId?: string) {
|
|
94
|
+
await fetchAvailability();
|
|
95
|
+
|
|
96
|
+
if (!availabilityId) return;
|
|
97
|
+
|
|
98
|
+
const localDate = parseDateFromAvailabilityId(availabilityId);
|
|
99
|
+
if (!localDate) return;
|
|
100
|
+
|
|
101
|
+
const optionId = effectiveOptionId();
|
|
102
|
+
if (!optionId) return;
|
|
103
|
+
|
|
104
|
+
const parts = localDate.split('-').map(Number);
|
|
105
|
+
selectedDate = new Date(parts[0], parts[1] - 1, parts[2]);
|
|
106
|
+
|
|
107
|
+
loadingTimeSlots = true;
|
|
108
|
+
const slots = await api.getAvailability(product.id, optionId, localDate);
|
|
109
|
+
timeSlots = slots;
|
|
110
|
+
loadingTimeSlots = false;
|
|
111
|
+
|
|
112
|
+
const match = slots.find(s => s.id === availabilityId);
|
|
113
|
+
if (match) {
|
|
114
|
+
selectedTimeSlot = match;
|
|
115
|
+
if (!match.allDay) {
|
|
116
|
+
selectedTime = formatTime(match.localDateTimeStart);
|
|
117
|
+
}
|
|
118
|
+
if (pickupPointId && selectedOption?.pickupAvailable) {
|
|
119
|
+
selectedPickupPoint = selectedOption.pickupLocations?.find(p => p.id === pickupPointId) ?? null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isWidgetVisible(type: WizardWidgetType): boolean {
|
|
125
|
+
if (isAgeFirstFlow()) return isWidgetVisibleAgeFirst(type);
|
|
126
|
+
return isWidgetVisibleOptionFirst(type);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Default flow: option -> age-category -> date -> time -> pickup
|
|
130
|
+
function isWidgetVisibleOptionFirst(type: WizardWidgetType): boolean {
|
|
131
|
+
switch (type) {
|
|
132
|
+
case 'option': return !lockedOptionId;
|
|
133
|
+
case 'age-category': return selectedOption != null;
|
|
134
|
+
case 'date': return hasQuantities;
|
|
135
|
+
case 'time': return selectedDate != null && !hideTimePicker;
|
|
136
|
+
case 'pickup': return selectedTimeSlot != null && selectedOption?.pickupAvailable === true;
|
|
137
|
+
case 'addon': return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Alternate flow: age-category -> date -> time -> option -> pickup
|
|
142
|
+
function isWidgetVisibleAgeFirst(type: WizardWidgetType): boolean {
|
|
143
|
+
switch (type) {
|
|
144
|
+
case 'age-category': return availableUnits().length > 0;
|
|
145
|
+
case 'date': return hasQuantities;
|
|
146
|
+
case 'time': return selectedDate != null && !hideTimePicker;
|
|
147
|
+
case 'option': return !lockedOptionId && (selectedTimeSlot != null || (selectedDate != null && hideTimePicker));
|
|
148
|
+
case 'pickup': return selectedOption != null && selectedOption.pickupAvailable === true && selectedTimeSlot != null;
|
|
149
|
+
case 'addon': return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let visiblePages = $derived(() => {
|
|
154
|
+
const result: { index: number; widgets: WizardWidgetType[] }[] = [];
|
|
155
|
+
for (let i = 0; i < wizardPages.length; i++) {
|
|
156
|
+
const visible = wizardPages[i].filter(isWidgetVisible);
|
|
157
|
+
if (visible.length > 0) result.push({ index: i, widgets: visible });
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
let allVisibleWidgets = $derived(() => {
|
|
163
|
+
const result: WizardWidgetType[] = [];
|
|
164
|
+
for (const page of wizardPages) {
|
|
165
|
+
for (const w of page) {
|
|
166
|
+
if (isWidgetVisible(w) && !result.includes(w)) result.push(w);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return result;
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
let currentPage = $derived(() => {
|
|
173
|
+
const pages = visiblePages();
|
|
174
|
+
if (pageIndex >= pages.length) return pages[pages.length - 1] ?? null;
|
|
175
|
+
return pages[pageIndex] ?? null;
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
let isLastPage = $derived(() => pageIndex >= visiblePages().length - 1);
|
|
179
|
+
|
|
180
|
+
let pageComplete = $derived(() => {
|
|
181
|
+
const widgets = editMode ? allVisibleWidgets() : (currentPage()?.widgets ?? []);
|
|
182
|
+
if (widgets.length === 0) return false;
|
|
183
|
+
for (const w of widgets) {
|
|
184
|
+
switch (w) {
|
|
185
|
+
case 'option': if (!selectedOption) return false; break;
|
|
186
|
+
case 'age-category': if (!hasQuantities) return false; break;
|
|
187
|
+
case 'date': if (!selectedDate) return false; break;
|
|
188
|
+
case 'time': if (!selectedTime && !hideTimePicker) return false; break;
|
|
189
|
+
case 'pickup':
|
|
190
|
+
if (selectedOption?.pickupRequired && !selectedPickupPoint) return false;
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return true;
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
let formattedTotal = $derived(() => {
|
|
198
|
+
if (!selectedTimeSlot?.unitPricing || selectedTimeSlot.unitPricing.length === 0) return '';
|
|
199
|
+
let total = 0;
|
|
200
|
+
for (const up of selectedTimeSlot.unitPricing) {
|
|
201
|
+
const qty = unitQuantities[up.unitId] ?? 0;
|
|
202
|
+
if (qty > 0) total += up.retail * qty;
|
|
203
|
+
}
|
|
204
|
+
const first = selectedTimeSlot.unitPricing[0];
|
|
205
|
+
return formatCurrency(total, first.currency, first.currencyPrecision);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
function selectOption(option: CheckoutOptionDto) {
|
|
209
|
+
// In age-first flow the user already picked quantities, date, and time
|
|
210
|
+
// before reaching the option step — keep those selections intact.
|
|
211
|
+
if (isAgeFirstFlow()) {
|
|
212
|
+
selectedOption = option;
|
|
213
|
+
selectedPickupPoint = null;
|
|
214
|
+
dirty = true;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
selectedOption = option;
|
|
219
|
+
unitQuantities = Object.fromEntries(option.units.map(u => [u.id, 0]));
|
|
220
|
+
selectedDate = null;
|
|
221
|
+
selectedTime = null;
|
|
222
|
+
selectedTimeSlot = null;
|
|
223
|
+
selectedPickupPoint = null;
|
|
224
|
+
availabilityByDate = {};
|
|
225
|
+
timeSlots = [];
|
|
226
|
+
dirty = true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function onUnitChanged(unitId: string, qty: number) {
|
|
230
|
+
unitQuantities = { ...unitQuantities, [unitId]: qty };
|
|
231
|
+
dirty = true;
|
|
232
|
+
if (hasQuantities && Object.keys(availabilityByDate).length === 0) {
|
|
233
|
+
fetchAvailability();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function fetchAvailability() {
|
|
238
|
+
const optionId = effectiveOptionId();
|
|
239
|
+
if (!optionId) return;
|
|
240
|
+
loadingAvailability = true;
|
|
241
|
+
availabilityByDate = await api.getAvailabilityCalendar(product.id, optionId);
|
|
242
|
+
loadingAvailability = false;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function onDateSelected(date: Date) {
|
|
246
|
+
const optionId = effectiveOptionId();
|
|
247
|
+
if (!optionId) return;
|
|
248
|
+
selectedDate = date;
|
|
249
|
+
selectedTime = null;
|
|
250
|
+
selectedTimeSlot = null;
|
|
251
|
+
selectedPickupPoint = null;
|
|
252
|
+
timeSlots = [];
|
|
253
|
+
dirty = true;
|
|
254
|
+
loadingTimeSlots = true;
|
|
255
|
+
|
|
256
|
+
const slots = await api.getAvailability(product.id, optionId, dateKey(date));
|
|
257
|
+
timeSlots = slots;
|
|
258
|
+
loadingTimeSlots = false;
|
|
259
|
+
|
|
260
|
+
if (slots.length === 1 && (slots[0].allDay || autoSelectSingleTimeSlot)) {
|
|
261
|
+
selectedTimeSlot = slots[0];
|
|
262
|
+
if (!slots[0].allDay) {
|
|
263
|
+
selectedTime = formatTime(slots[0].localDateTimeStart);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function onTimeSelected(time: string) {
|
|
269
|
+
const slot = timeSlots.find(s => formatTime(s.localDateTimeStart) === time && s.available);
|
|
270
|
+
if (!slot) return;
|
|
271
|
+
selectedTime = time;
|
|
272
|
+
selectedTimeSlot = slot;
|
|
273
|
+
selectedPickupPoint = null;
|
|
274
|
+
dirty = true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function onPickupSelected(point: CheckoutPickupLocationDto) {
|
|
278
|
+
selectedPickupPoint = point;
|
|
279
|
+
dirty = true;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function goNext() {
|
|
283
|
+
if (isLastPage()) completeWizard();
|
|
284
|
+
else pageIndex++;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function goBack() {
|
|
288
|
+
if (pageIndex > 0) {
|
|
289
|
+
pageIndex--;
|
|
290
|
+
} else if (onCancel) {
|
|
291
|
+
handleCancel();
|
|
292
|
+
} else if (!lockedOptionId && selectedOption) {
|
|
293
|
+
selectedOption = null;
|
|
294
|
+
unitQuantities = {};
|
|
295
|
+
selectedDate = null;
|
|
296
|
+
selectedTime = null;
|
|
297
|
+
selectedTimeSlot = null;
|
|
298
|
+
selectedPickupPoint = null;
|
|
299
|
+
availabilityByDate = {};
|
|
300
|
+
timeSlots = [];
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function handleCancel() {
|
|
305
|
+
if (dirty) {
|
|
306
|
+
showConfirmDialog = true;
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
onCancel?.();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function confirmDiscard() {
|
|
313
|
+
showConfirmDialog = false;
|
|
314
|
+
onCancel?.();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function completeWizard() {
|
|
318
|
+
if (!selectedTimeSlot || !selectedOption) return;
|
|
319
|
+
|
|
320
|
+
const cartUnits: CartUnitItem[] = [];
|
|
321
|
+
for (const up of (selectedTimeSlot.unitPricing ?? [])) {
|
|
322
|
+
const qty = unitQuantities[up.unitId] ?? 0;
|
|
323
|
+
if (qty <= 0) continue;
|
|
324
|
+
const unit = availableUnits().find(u => u.id === up.unitId);
|
|
325
|
+
cartUnits.push({
|
|
326
|
+
unitId: up.unitId,
|
|
327
|
+
quantity: qty,
|
|
328
|
+
title: unit?.title ?? up.unitId,
|
|
329
|
+
pricePerUnit: up.retail,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
let totalPrice = 0;
|
|
334
|
+
for (const u of cartUnits) totalPrice += u.pricePerUnit * u.quantity;
|
|
335
|
+
|
|
336
|
+
const firstPricing = selectedTimeSlot.unitPricing?.[0];
|
|
337
|
+
const currency = firstPricing?.currency ?? product.defaultCurrency ?? 'ZAR';
|
|
338
|
+
const currencyPrecision = firstPricing?.currencyPrecision ?? 2;
|
|
339
|
+
|
|
340
|
+
onComplete({
|
|
341
|
+
productId: product.id,
|
|
342
|
+
optionId: selectedOption.id,
|
|
343
|
+
optionTitle: selectedOption.title,
|
|
344
|
+
availabilityId: selectedTimeSlot.id,
|
|
345
|
+
localDate: selectedDate ? dateKey(selectedDate) : undefined,
|
|
346
|
+
pickupPointId: selectedPickupPoint?.id,
|
|
347
|
+
units: cartUnits,
|
|
348
|
+
totalPrice,
|
|
349
|
+
currency,
|
|
350
|
+
currencyPrecision,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
</script>
|
|
354
|
+
|
|
355
|
+
<div class="wizard">
|
|
356
|
+
{#if !editMode}
|
|
357
|
+
<div class="wizard-header">
|
|
358
|
+
<h2>Buy Tickets</h2>
|
|
359
|
+
</div>
|
|
360
|
+
{/if}
|
|
361
|
+
|
|
362
|
+
<div class="wizard-body">
|
|
363
|
+
{#each editMode ? allVisibleWidgets() : (currentPage()?.widgets ?? []) as widgetType}
|
|
364
|
+
{#if widgetType === 'option' && !lockedOptionId}
|
|
365
|
+
<div class="section">
|
|
366
|
+
<h4 class="section-title">Options</h4>
|
|
367
|
+
<div class="option-list">
|
|
368
|
+
{#each product.options as option}
|
|
369
|
+
<OptionCard
|
|
370
|
+
{option}
|
|
371
|
+
selected={selectedOption?.id === option.id}
|
|
372
|
+
onSelect={() => selectOption(option)}
|
|
373
|
+
/>
|
|
374
|
+
{/each}
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
{/if}
|
|
378
|
+
|
|
379
|
+
{#if widgetType === 'age-category' && availableUnits().length > 0}
|
|
380
|
+
<div class="section">
|
|
381
|
+
<h4 class="section-title">How many tickets?</h4>
|
|
382
|
+
<div class="unit-list">
|
|
383
|
+
{#each availableUnits() as unit}
|
|
384
|
+
<UnitCounter
|
|
385
|
+
{unit}
|
|
386
|
+
quantity={unitQuantities[unit.id] ?? 0}
|
|
387
|
+
onChange={(qty) => onUnitChanged(unit.id, qty)}
|
|
388
|
+
/>
|
|
389
|
+
{/each}
|
|
390
|
+
</div>
|
|
391
|
+
</div>
|
|
392
|
+
{/if}
|
|
393
|
+
|
|
394
|
+
{#if widgetType === 'date' && hasQuantities}
|
|
395
|
+
<div class="section">
|
|
396
|
+
<h4 class="section-title">Choose a date</h4>
|
|
397
|
+
<AvailabilityCalendar
|
|
398
|
+
{availabilityByDate}
|
|
399
|
+
{selectedDate}
|
|
400
|
+
onDateSelected={onDateSelected}
|
|
401
|
+
loading={loadingAvailability}
|
|
402
|
+
/>
|
|
403
|
+
</div>
|
|
404
|
+
{/if}
|
|
405
|
+
|
|
406
|
+
{#if widgetType === 'time' && selectedDate && !hideTimePicker}
|
|
407
|
+
<div class="section">
|
|
408
|
+
<h4 class="section-title">Select a time</h4>
|
|
409
|
+
<TimeSlotPicker
|
|
410
|
+
{timeSlots}
|
|
411
|
+
{selectedTime}
|
|
412
|
+
loading={loadingTimeSlots}
|
|
413
|
+
onTimeSelected={onTimeSelected}
|
|
414
|
+
/>
|
|
415
|
+
</div>
|
|
416
|
+
{/if}
|
|
417
|
+
|
|
418
|
+
{#if widgetType === 'pickup' && selectedOption?.pickupAvailable && selectedOption.pickupLocations}
|
|
419
|
+
<div class="section">
|
|
420
|
+
<h4 class="section-title">Select pickup point</h4>
|
|
421
|
+
<div class="pickup-list">
|
|
422
|
+
{#each selectedOption.pickupLocations as point}
|
|
423
|
+
<SelectableCard
|
|
424
|
+
selected={selectedPickupPoint?.id === point.id}
|
|
425
|
+
onSelect={() => onPickupSelected(point)}
|
|
426
|
+
heading={point.title ?? point.id}
|
|
427
|
+
body={point.address}
|
|
428
|
+
/>
|
|
429
|
+
{/each}
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
{/if}
|
|
433
|
+
{/each}
|
|
434
|
+
|
|
435
|
+
<div class="action-bar">
|
|
436
|
+
{#if editMode}
|
|
437
|
+
<button class="btn btn-secondary" onclick={handleCancel}>
|
|
438
|
+
Return to Cart
|
|
439
|
+
</button>
|
|
440
|
+
<button class="btn btn-primary" onclick={completeWizard} disabled={!pageComplete()}>
|
|
441
|
+
Save & Update
|
|
442
|
+
</button>
|
|
443
|
+
{:else}
|
|
444
|
+
{#if pageIndex > 0 || onCancel}
|
|
445
|
+
<button class="btn btn-secondary" onclick={goBack}>
|
|
446
|
+
{pageIndex > 0 ? 'Back' : 'Cancel'}
|
|
447
|
+
</button>
|
|
448
|
+
{/if}
|
|
449
|
+
<button class="btn btn-primary" onclick={goNext} disabled={!pageComplete()}>
|
|
450
|
+
{isLastPage() ? 'Add to Cart' : 'Next'}
|
|
451
|
+
<span class="arrow">→</span>
|
|
452
|
+
</button>
|
|
453
|
+
{/if}
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
</div>
|
|
457
|
+
|
|
458
|
+
{#if showConfirmDialog}
|
|
459
|
+
<div class="dialog-backdrop" onclick={() => { showConfirmDialog = false; }}>
|
|
460
|
+
<div class="dialog" onclick={(e) => e.stopPropagation()}>
|
|
461
|
+
<h3 class="dialog-title">You have unsaved changes</h3>
|
|
462
|
+
<p class="dialog-body">Would you like to proceed?</p>
|
|
463
|
+
<div class="dialog-actions">
|
|
464
|
+
<button class="dialog-btn-text" onclick={confirmDiscard}>Discard changes</button>
|
|
465
|
+
<button class="dialog-btn-fill" onclick={() => { showConfirmDialog = false; }}>Return to editing</button>
|
|
466
|
+
</div>
|
|
467
|
+
</div>
|
|
468
|
+
</div>
|
|
469
|
+
{/if}
|
|
470
|
+
|
|
471
|
+
<style>
|
|
472
|
+
.wizard {
|
|
473
|
+
display: flex;
|
|
474
|
+
flex-direction: column;
|
|
475
|
+
height: 100%;
|
|
476
|
+
max-height: 100%;
|
|
477
|
+
overflow: hidden;
|
|
478
|
+
}
|
|
479
|
+
.wizard-header {
|
|
480
|
+
padding: 20px 16px 16px;
|
|
481
|
+
border-bottom: 1px solid var(--bw-color-border);
|
|
482
|
+
}
|
|
483
|
+
.wizard-header h2 {
|
|
484
|
+
font-size: 20px;
|
|
485
|
+
font-weight: 800;
|
|
486
|
+
}
|
|
487
|
+
.wizard-body {
|
|
488
|
+
flex: 1;
|
|
489
|
+
overflow-y: auto;
|
|
490
|
+
padding: 16px;
|
|
491
|
+
}
|
|
492
|
+
.section {
|
|
493
|
+
margin-bottom: 24px;
|
|
494
|
+
}
|
|
495
|
+
.option-list, .pickup-list, .unit-list {
|
|
496
|
+
display: flex;
|
|
497
|
+
flex-direction: column;
|
|
498
|
+
gap: 10px;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/* ── Confirm dialog ────────────────────── */
|
|
502
|
+
.dialog-backdrop {
|
|
503
|
+
position: fixed;
|
|
504
|
+
top: 0;
|
|
505
|
+
left: 0;
|
|
506
|
+
width: 100%;
|
|
507
|
+
height: 100%;
|
|
508
|
+
background: rgba(0, 0, 0, 0.35);
|
|
509
|
+
display: flex;
|
|
510
|
+
align-items: center;
|
|
511
|
+
justify-content: center;
|
|
512
|
+
z-index: 100;
|
|
513
|
+
}
|
|
514
|
+
.dialog {
|
|
515
|
+
background: white;
|
|
516
|
+
border-radius: var(--bw-radius-lg);
|
|
517
|
+
padding: 24px;
|
|
518
|
+
width: 90%;
|
|
519
|
+
max-width: 360px;
|
|
520
|
+
box-shadow: var(--bw-shadow-3);
|
|
521
|
+
}
|
|
522
|
+
.dialog-title {
|
|
523
|
+
font-size: 17px;
|
|
524
|
+
font-weight: 700;
|
|
525
|
+
margin-bottom: 8px;
|
|
526
|
+
}
|
|
527
|
+
.dialog-body {
|
|
528
|
+
font-size: 14px;
|
|
529
|
+
color: var(--bw-color-text-secondary);
|
|
530
|
+
margin-bottom: 24px;
|
|
531
|
+
}
|
|
532
|
+
.dialog-actions {
|
|
533
|
+
display: flex;
|
|
534
|
+
justify-content: flex-end;
|
|
535
|
+
gap: 8px;
|
|
536
|
+
}
|
|
537
|
+
.dialog-btn-text {
|
|
538
|
+
background: none;
|
|
539
|
+
border: none;
|
|
540
|
+
color: var(--bw-color-primary);
|
|
541
|
+
font-size: 14px;
|
|
542
|
+
font-weight: 600;
|
|
543
|
+
padding: 10px 16px;
|
|
544
|
+
border-radius: var(--bw-radius-md);
|
|
545
|
+
}
|
|
546
|
+
.dialog-btn-text:hover {
|
|
547
|
+
background: var(--bw-color-primary-light);
|
|
548
|
+
}
|
|
549
|
+
.dialog-btn-fill {
|
|
550
|
+
background: var(--bw-color-primary);
|
|
551
|
+
color: white;
|
|
552
|
+
border: none;
|
|
553
|
+
font-size: 14px;
|
|
554
|
+
font-weight: 600;
|
|
555
|
+
padding: 10px 20px;
|
|
556
|
+
border-radius: var(--bw-radius-md);
|
|
557
|
+
}
|
|
558
|
+
.dialog-btn-fill:hover {
|
|
559
|
+
background: var(--bw-color-primary-dark);
|
|
560
|
+
}
|
|
561
|
+
</style>
|