@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,120 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { CheckoutCartDetailDto } from './client-types';
|
|
3
|
+
import type { BookingApi } from './api';
|
|
4
|
+
import { CartManager } from './cart-manager';
|
|
5
|
+
import { formatCurrency } from './currency';
|
|
6
|
+
import { postMessage } from './messages';
|
|
7
|
+
import { onDestroy } from 'svelte';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
api: BookingApi;
|
|
11
|
+
cartManager: CartManager;
|
|
12
|
+
}
|
|
13
|
+
let { api, cartManager }: Props = $props();
|
|
14
|
+
|
|
15
|
+
let cart = $state<CheckoutCartDetailDto | null>(null);
|
|
16
|
+
let remaining = $state('');
|
|
17
|
+
|
|
18
|
+
async function load() {
|
|
19
|
+
if (!cartManager.hasCart) return;
|
|
20
|
+
try {
|
|
21
|
+
cart = await api.getCart();
|
|
22
|
+
} catch {
|
|
23
|
+
cart = null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
load();
|
|
28
|
+
|
|
29
|
+
function updateTimer() {
|
|
30
|
+
if (!cart) { remaining = '0:00'; return; }
|
|
31
|
+
const diff = Math.max(0, new Date(cart.absoluteExpiresAt).getTime() - Date.now());
|
|
32
|
+
const mins = Math.floor(diff / 60000);
|
|
33
|
+
const secs = Math.floor((diff % 60000) / 1000);
|
|
34
|
+
remaining = `${mins}:${String(secs).padStart(2, '0')}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
updateTimer();
|
|
38
|
+
const interval = setInterval(updateTimer, 1000);
|
|
39
|
+
onDestroy(() => clearInterval(interval));
|
|
40
|
+
|
|
41
|
+
let itemCount = $derived(cart?.items.length ?? 0);
|
|
42
|
+
|
|
43
|
+
let totalLabel = $derived(() => {
|
|
44
|
+
if (!cart || cart.items.length === 0) return '';
|
|
45
|
+
const currency = cart.items[0].currencyCode;
|
|
46
|
+
const total = cart.items.reduce((sum, i) => sum + i.amount, 0);
|
|
47
|
+
return formatCurrency(total, currency, 2, 0);
|
|
48
|
+
});
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
{#if cart && cart.items.length > 0}
|
|
52
|
+
<button class="cart-overview-btn" onclick={() => postMessage({ type: 'modal:open' })}>
|
|
53
|
+
<svg class="cart-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
54
|
+
<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/>
|
|
55
|
+
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>
|
|
56
|
+
</svg>
|
|
57
|
+
<span class="cart-badge">{itemCount}</span>
|
|
58
|
+
<span class="cart-total">{totalLabel()}</span>
|
|
59
|
+
<span class="cart-sep"></span>
|
|
60
|
+
<span class="cart-timer">{remaining}</span>
|
|
61
|
+
</button>
|
|
62
|
+
{/if}
|
|
63
|
+
|
|
64
|
+
<style>
|
|
65
|
+
.cart-overview-btn {
|
|
66
|
+
display: flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
gap: 10px;
|
|
69
|
+
width: 100%;
|
|
70
|
+
padding: 12px 20px;
|
|
71
|
+
background: var(--bw-color-primary);
|
|
72
|
+
color: white;
|
|
73
|
+
border: none;
|
|
74
|
+
border-radius: 999px;
|
|
75
|
+
font-size: 14px;
|
|
76
|
+
font-weight: 700;
|
|
77
|
+
cursor: pointer;
|
|
78
|
+
transition: background 0.15s;
|
|
79
|
+
}
|
|
80
|
+
.cart-overview-btn:hover {
|
|
81
|
+
background: var(--bw-color-primary-dark);
|
|
82
|
+
}
|
|
83
|
+
.cart-icon {
|
|
84
|
+
width: 18px;
|
|
85
|
+
height: 18px;
|
|
86
|
+
flex-shrink: 0;
|
|
87
|
+
}
|
|
88
|
+
.cart-badge {
|
|
89
|
+
display: flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
justify-content: center;
|
|
92
|
+
min-width: 22px;
|
|
93
|
+
height: 22px;
|
|
94
|
+
padding: 0 5px;
|
|
95
|
+
background: white;
|
|
96
|
+
color: var(--bw-color-primary);
|
|
97
|
+
border-radius: 5px;
|
|
98
|
+
font-size: 12px;
|
|
99
|
+
font-weight: 800;
|
|
100
|
+
flex-shrink: 0;
|
|
101
|
+
}
|
|
102
|
+
.cart-total {
|
|
103
|
+
flex: 1;
|
|
104
|
+
text-align: left;
|
|
105
|
+
font-size: 14px;
|
|
106
|
+
font-weight: 700;
|
|
107
|
+
}
|
|
108
|
+
.cart-sep {
|
|
109
|
+
width: 1px;
|
|
110
|
+
height: 16px;
|
|
111
|
+
background: rgba(255,255,255,0.35);
|
|
112
|
+
flex-shrink: 0;
|
|
113
|
+
}
|
|
114
|
+
.cart-timer {
|
|
115
|
+
font-variant-numeric: tabular-nums;
|
|
116
|
+
font-size: 13px;
|
|
117
|
+
opacity: 0.85;
|
|
118
|
+
flex-shrink: 0;
|
|
119
|
+
}
|
|
120
|
+
</style>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { BookingApi } from './api';
|
|
3
|
+
import type { WizardPages } from './config';
|
|
4
|
+
import { DEFAULT_WIZARD_PAGES } from './config';
|
|
5
|
+
import type { CheckoutCartDetailDto } from './client-types';
|
|
6
|
+
import { postMessage } from './messages';
|
|
7
|
+
import CheckoutModal from './CheckoutModal.svelte';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
api: BookingApi;
|
|
11
|
+
wizardPages?: WizardPages;
|
|
12
|
+
}
|
|
13
|
+
let { api, wizardPages = DEFAULT_WIZARD_PAGES }: Props = $props();
|
|
14
|
+
|
|
15
|
+
let cart = $state<CheckoutCartDetailDto | null>(null);
|
|
16
|
+
let isLoading = $state(true);
|
|
17
|
+
|
|
18
|
+
async function load() {
|
|
19
|
+
try {
|
|
20
|
+
cart = await api.getCart();
|
|
21
|
+
} catch {
|
|
22
|
+
cart = null;
|
|
23
|
+
}
|
|
24
|
+
isLoading = false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
load();
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
{#if isLoading}
|
|
31
|
+
<div class="loading-center" style="height:100vh">
|
|
32
|
+
<div class="spinner"></div>
|
|
33
|
+
</div>
|
|
34
|
+
{:else if cart && cart.items.length > 0}
|
|
35
|
+
<CheckoutModal
|
|
36
|
+
{cart}
|
|
37
|
+
{api}
|
|
38
|
+
{wizardPages}
|
|
39
|
+
onClose={() => postMessage({ type: 'modal:close' })}
|
|
40
|
+
onOrderConfirmed={() => {
|
|
41
|
+
const total = cart!.items.reduce((s, i) => s + i.amount, 0);
|
|
42
|
+
const currency = cart!.items[0]?.currencyCode ?? 'ZAR';
|
|
43
|
+
postMessage({
|
|
44
|
+
type: 'order:complete',
|
|
45
|
+
cartToken: cart!.cartToken,
|
|
46
|
+
value: total,
|
|
47
|
+
currency,
|
|
48
|
+
});
|
|
49
|
+
}}
|
|
50
|
+
/>
|
|
51
|
+
{:else}
|
|
52
|
+
<div class="loading-center" style="height:100vh;color:var(--bw-color-text-secondary)">
|
|
53
|
+
No items in cart.
|
|
54
|
+
</div>
|
|
55
|
+
{/if}
|
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { CheckoutCartDetailDto, CheckoutCartItemDetailDto, CheckoutCartPaymentInitiationDto, CheckoutCartConfirmResultDto, CheckoutProductDto } from './client-types';
|
|
3
|
+
import type { BookingApi } from './api';
|
|
4
|
+
import type { WizardPages } from './config';
|
|
5
|
+
import { formatCurrency } from './currency';
|
|
6
|
+
import { postMessage } from './messages';
|
|
7
|
+
import { onDestroy } from 'svelte';
|
|
8
|
+
import ContactForm from './ContactForm.svelte';
|
|
9
|
+
import ConsentSection from './ConsentSection.svelte';
|
|
10
|
+
import CountdownTimer from './CountdownTimer.svelte';
|
|
11
|
+
import PaymentPage from './PaymentPage.svelte';
|
|
12
|
+
import ResultView from './ResultView.svelte';
|
|
13
|
+
import EditBookingView from './EditBookingView.svelte';
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
cart: CheckoutCartDetailDto;
|
|
17
|
+
api: BookingApi;
|
|
18
|
+
wizardPages?: WizardPages;
|
|
19
|
+
onClose: () => void;
|
|
20
|
+
onOrderConfirmed: () => void;
|
|
21
|
+
}
|
|
22
|
+
let { cart, api, wizardPages, onClose, onOrderConfirmed }: Props = $props();
|
|
23
|
+
|
|
24
|
+
type View = 'cart' | 'contact' | 'edit' | 'payment' | 'result';
|
|
25
|
+
let currentView = $state<View>('cart');
|
|
26
|
+
let editingItem = $state<CheckoutCartItemDetailDto | null>(null);
|
|
27
|
+
let paymentResult = $state<CheckoutCartPaymentInitiationDto | null>(null);
|
|
28
|
+
let confirmResult = $state<CheckoutCartConfirmResultDto | null>(null);
|
|
29
|
+
let confirmFailed = $state(false);
|
|
30
|
+
let isLoading = $state(true);
|
|
31
|
+
let isConfirming = $state(false);
|
|
32
|
+
let privacyAccepted = $state(false);
|
|
33
|
+
let marketingOptIn = $state(false);
|
|
34
|
+
let hasAttemptedSubmit = $state(false);
|
|
35
|
+
|
|
36
|
+
let contactForm = $state<ContactForm | null>(null);
|
|
37
|
+
|
|
38
|
+
// Load product data so we can display titles and unit info
|
|
39
|
+
let productsById = $state<Map<string, CheckoutProductDto>>(new Map());
|
|
40
|
+
|
|
41
|
+
async function loadProducts() {
|
|
42
|
+
const productIds = [...new Set(cart.items.map((i) => i.productId))];
|
|
43
|
+
const results = await Promise.all(productIds.map((id) => api.getProduct(id)));
|
|
44
|
+
const map = new Map<string, CheckoutProductDto>();
|
|
45
|
+
for (const p of results) {
|
|
46
|
+
map.set(p.id, p);
|
|
47
|
+
}
|
|
48
|
+
productsById = map;
|
|
49
|
+
isLoading = false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
loadProducts();
|
|
53
|
+
|
|
54
|
+
let cartTotal = $derived(() => cart.items.reduce((s, i) => s + i.amount, 0));
|
|
55
|
+
let cartCurrency = $derived(() => cart.items[0]?.currencyCode ?? 'ZAR');
|
|
56
|
+
|
|
57
|
+
let formattedTotal = $derived(() =>
|
|
58
|
+
formatCurrency(cartTotal(), cartCurrency(), 2, 2));
|
|
59
|
+
|
|
60
|
+
let expiresAt = $derived(() => new Date(cart.absoluteExpiresAt));
|
|
61
|
+
|
|
62
|
+
function productTitle(item: CheckoutCartItemDetailDto): string {
|
|
63
|
+
return productsById.get(item.productId)?.title ?? item.productId;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function optionTitle(item: CheckoutCartItemDetailDto): string {
|
|
67
|
+
const product = productsById.get(item.productId);
|
|
68
|
+
return product?.options.find((o) => o.id === item.optionId)?.title ?? item.optionId;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface GroupedUnit {
|
|
72
|
+
unitId: string;
|
|
73
|
+
title: string;
|
|
74
|
+
quantity: number;
|
|
75
|
+
linePrice: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function groupUnits(item: CheckoutCartItemDetailDto): GroupedUnit[] {
|
|
79
|
+
const product = productsById.get(item.productId);
|
|
80
|
+
const option = product?.options.find((o) => o.id === item.optionId);
|
|
81
|
+
const counts = new Map<string, number>();
|
|
82
|
+
for (const u of item.unitItems) {
|
|
83
|
+
counts.set(u.unitId, (counts.get(u.unitId) ?? 0) + 1);
|
|
84
|
+
}
|
|
85
|
+
return [...counts.entries()].map(([unitId, quantity]) => {
|
|
86
|
+
const unit = option?.units.find((u) => u.id === unitId);
|
|
87
|
+
const unitPrice = unit?.pricing?.[0]?.retail ?? 0;
|
|
88
|
+
return {
|
|
89
|
+
unitId,
|
|
90
|
+
title: unit?.title ?? unitId,
|
|
91
|
+
quantity,
|
|
92
|
+
linePrice: unitPrice * quantity,
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function itemDateLabel(item: CheckoutCartItemDetailDto): string | null {
|
|
98
|
+
if (!item.availabilityId) return null;
|
|
99
|
+
const match = item.availabilityId.match(/(\d{4})-(\d{2})-(\d{2})/);
|
|
100
|
+
if (!match) return null;
|
|
101
|
+
const d = new Date(+match[1], +match[2] - 1, +match[3]);
|
|
102
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
103
|
+
return `${d.getDate()} ${months[d.getMonth()]} ${d.getFullYear()}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
onDestroy(() => postMessage({ type: 'modal:close' }));
|
|
107
|
+
|
|
108
|
+
function close() {
|
|
109
|
+
postMessage({ type: 'modal:close' });
|
|
110
|
+
onClose();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function continueShopping() {
|
|
114
|
+
close();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function editItem(item: CheckoutCartItemDetailDto) {
|
|
118
|
+
editingItem = item;
|
|
119
|
+
currentView = 'edit';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function onItemUpdated() {
|
|
123
|
+
// Reload cart after edit
|
|
124
|
+
const updated = await api.getCart();
|
|
125
|
+
cart = updated;
|
|
126
|
+
currentView = 'cart';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function removeItem(item: CheckoutCartItemDetailDto) {
|
|
130
|
+
await api.removeCartItem(item.id);
|
|
131
|
+
const updated = await api.getCart();
|
|
132
|
+
cart = updated;
|
|
133
|
+
if (cart.items.length === 0) close();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function onPayNow() {
|
|
137
|
+
hasAttemptedSubmit = true;
|
|
138
|
+
if (!contactForm?.isValid() || !privacyAccepted) return;
|
|
139
|
+
|
|
140
|
+
const contact = contactForm.getContact();
|
|
141
|
+
|
|
142
|
+
isLoading = true;
|
|
143
|
+
try {
|
|
144
|
+
paymentResult = await api.payCart(contact);
|
|
145
|
+
currentView = 'payment';
|
|
146
|
+
} finally {
|
|
147
|
+
isLoading = false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function onPaymentComplete(result: { status: string }) {
|
|
152
|
+
if (result.status === 'cancelled') {
|
|
153
|
+
currentView = 'contact';
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (result.status === 'expired') {
|
|
158
|
+
confirmFailed = true;
|
|
159
|
+
isConfirming = false;
|
|
160
|
+
currentView = 'result';
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
isConfirming = true;
|
|
165
|
+
currentView = 'result';
|
|
166
|
+
try {
|
|
167
|
+
confirmResult = await api.confirmCart();
|
|
168
|
+
const allOk = confirmResult.items.every((i) => i.statusCode >= 200 && i.statusCode < 300);
|
|
169
|
+
confirmFailed = !allOk;
|
|
170
|
+
isConfirming = false;
|
|
171
|
+
if (allOk) {
|
|
172
|
+
postMessage({ type: 'modal:close' });
|
|
173
|
+
onOrderConfirmed();
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
confirmFailed = true;
|
|
177
|
+
isConfirming = false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
</script>
|
|
181
|
+
|
|
182
|
+
<div class="modal">
|
|
183
|
+
{#if isLoading}
|
|
184
|
+
<div class="loading-center" style="height:100%">
|
|
185
|
+
<div class="spinner"></div>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
{:else if currentView === 'payment' && paymentResult}
|
|
189
|
+
<PaymentPage
|
|
190
|
+
checkoutId={paymentResult.checkoutId}
|
|
191
|
+
totalFormatted={formattedTotal()}
|
|
192
|
+
onBack={() => { currentView = 'contact'; }}
|
|
193
|
+
onClose={close}
|
|
194
|
+
{onPaymentComplete}
|
|
195
|
+
/>
|
|
196
|
+
|
|
197
|
+
{:else if currentView === 'result'}
|
|
198
|
+
<ResultView
|
|
199
|
+
status={isConfirming ? null : confirmFailed
|
|
200
|
+
? { outcome: 'failed', resultDescription: 'Something went wrong confirming your order. Please try again.' }
|
|
201
|
+
: { outcome: 'successful' }}
|
|
202
|
+
verifying={isConfirming}
|
|
203
|
+
onDone={() => { postMessage({ type: 'modal:close' }); onOrderConfirmed(); }}
|
|
204
|
+
onRetry={() => { currentView = 'payment'; }}
|
|
205
|
+
/>
|
|
206
|
+
|
|
207
|
+
{:else if currentView === 'edit' && editingItem && productsById.get(editingItem.productId)}
|
|
208
|
+
<EditBookingView
|
|
209
|
+
cartItem={editingItem}
|
|
210
|
+
product={productsById.get(editingItem.productId)!}
|
|
211
|
+
{api}
|
|
212
|
+
{wizardPages}
|
|
213
|
+
onBack={() => { currentView = 'cart'; }}
|
|
214
|
+
onClose={close}
|
|
215
|
+
onUpdated={onItemUpdated}
|
|
216
|
+
/>
|
|
217
|
+
|
|
218
|
+
{:else if currentView === 'contact'}
|
|
219
|
+
<!-- Contact Details view -->
|
|
220
|
+
<div class="page-header">
|
|
221
|
+
<button class="icon-btn" onclick={() => { currentView = 'cart'; }} aria-label="Back">←</button>
|
|
222
|
+
<h2>Checkout</h2>
|
|
223
|
+
<span class="spacer"></span>
|
|
224
|
+
<button class="icon-btn" onclick={close} aria-label="Close">×</button>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
<div class="body">
|
|
228
|
+
<div class="contact-header">
|
|
229
|
+
<span class="contact-title">Contact Details</span>
|
|
230
|
+
<span class="required-hint">* Required Fields</span>
|
|
231
|
+
</div>
|
|
232
|
+
<ContactForm bind:this={contactForm} {hasAttemptedSubmit} />
|
|
233
|
+
<div style="margin-top:24px">
|
|
234
|
+
<ConsentSection
|
|
235
|
+
{privacyAccepted}
|
|
236
|
+
{marketingOptIn}
|
|
237
|
+
{hasAttemptedSubmit}
|
|
238
|
+
onPrivacyChanged={(v) => { privacyAccepted = v; }}
|
|
239
|
+
onMarketingChanged={(v) => { marketingOptIn = v; }}
|
|
240
|
+
/>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
|
|
244
|
+
<div class="pay-bar">
|
|
245
|
+
<span class="pay-bar-total">{formattedTotal()}</span>
|
|
246
|
+
<button class="pay-bar-btn" onclick={onPayNow}>
|
|
247
|
+
Pay Now <span class="arrow">→</span>
|
|
248
|
+
</button>
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
{:else}
|
|
252
|
+
<!-- Cart view -->
|
|
253
|
+
<div class="body">
|
|
254
|
+
<div class="cart-header">
|
|
255
|
+
<h2 class="cart-title">Your cart</h2>
|
|
256
|
+
<div class="cart-header-right">
|
|
257
|
+
<CountdownTimer expiresAt={expiresAt()} />
|
|
258
|
+
<button class="keep-shopping-btn" onclick={continueShopping}>+ Keep shopping</button>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
{#each cart.items as item}
|
|
263
|
+
<div class="cart-card">
|
|
264
|
+
<div class="cart-card-top">
|
|
265
|
+
<h3 class="cart-product">{productTitle(item)}</h3>
|
|
266
|
+
<span class="cart-item-total">{formatCurrency(item.amount, item.currencyCode, 2, 0)}</span>
|
|
267
|
+
</div>
|
|
268
|
+
<div class="cart-detail-lines">
|
|
269
|
+
{#if itemDateLabel(item)}
|
|
270
|
+
<p class="cart-detail">{itemDateLabel(item)}</p>
|
|
271
|
+
{/if}
|
|
272
|
+
<p class="cart-detail">{optionTitle(item)}</p>
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
<div class="cart-divider"></div>
|
|
276
|
+
|
|
277
|
+
<div class="cart-units">
|
|
278
|
+
{#each groupUnits(item) as gu}
|
|
279
|
+
<div class="cart-unit-line">
|
|
280
|
+
<span class="cart-unit-label">{gu.title} × {gu.quantity}</span>
|
|
281
|
+
<span class="cart-unit-price">{formatCurrency(gu.linePrice, item.currencyCode, 2, 0)}</span>
|
|
282
|
+
</div>
|
|
283
|
+
{/each}
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
<div class="cart-actions">
|
|
287
|
+
<button class="action-edit" onclick={() => editItem(item)}>Edit</button>
|
|
288
|
+
<button class="action-remove" onclick={() => removeItem(item)}>Remove</button>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
{/each}
|
|
292
|
+
|
|
293
|
+
<div class="cart-total-divider"></div>
|
|
294
|
+
|
|
295
|
+
<div class="cart-total-row">
|
|
296
|
+
<span>Total payable today</span>
|
|
297
|
+
<span class="cart-total-amount">{formattedTotal()}</span>
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
<button class="checkout-btn" onclick={() => { currentView = 'contact'; }}>
|
|
301
|
+
Checkout →
|
|
302
|
+
</button>
|
|
303
|
+
</div>
|
|
304
|
+
{/if}
|
|
305
|
+
</div>
|
|
306
|
+
|
|
307
|
+
<style>
|
|
308
|
+
.modal {
|
|
309
|
+
display: flex;
|
|
310
|
+
flex-direction: column;
|
|
311
|
+
height: 100%;
|
|
312
|
+
max-height: 100%;
|
|
313
|
+
overflow: hidden;
|
|
314
|
+
background: var(--bw-color-surface);
|
|
315
|
+
}
|
|
316
|
+
.body {
|
|
317
|
+
flex: 1;
|
|
318
|
+
overflow-y: auto;
|
|
319
|
+
padding: 16px;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/* -- Cart view -- */
|
|
323
|
+
.cart-header {
|
|
324
|
+
display: flex;
|
|
325
|
+
justify-content: space-between;
|
|
326
|
+
align-items: center;
|
|
327
|
+
margin-bottom: 20px;
|
|
328
|
+
}
|
|
329
|
+
.cart-title {
|
|
330
|
+
font-size: 22px;
|
|
331
|
+
font-weight: 800;
|
|
332
|
+
}
|
|
333
|
+
.cart-header-right {
|
|
334
|
+
display: flex;
|
|
335
|
+
align-items: center;
|
|
336
|
+
gap: 12px;
|
|
337
|
+
}
|
|
338
|
+
.keep-shopping-btn {
|
|
339
|
+
padding: 8px 16px;
|
|
340
|
+
background: none;
|
|
341
|
+
border: 2px solid #222;
|
|
342
|
+
border-radius: var(--bw-radius-md);
|
|
343
|
+
font-size: 13px;
|
|
344
|
+
font-weight: 600;
|
|
345
|
+
color: #222;
|
|
346
|
+
cursor: pointer;
|
|
347
|
+
}
|
|
348
|
+
.keep-shopping-btn:hover {
|
|
349
|
+
background: #f5f5f5;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.cart-card {
|
|
353
|
+
border: 1px solid var(--bw-color-border);
|
|
354
|
+
border-radius: var(--bw-radius-lg);
|
|
355
|
+
padding: 20px;
|
|
356
|
+
margin-bottom: 16px;
|
|
357
|
+
}
|
|
358
|
+
.cart-card-top {
|
|
359
|
+
display: flex;
|
|
360
|
+
justify-content: space-between;
|
|
361
|
+
align-items: flex-start;
|
|
362
|
+
gap: 16px;
|
|
363
|
+
}
|
|
364
|
+
.cart-product {
|
|
365
|
+
font-size: 16px;
|
|
366
|
+
font-weight: 700;
|
|
367
|
+
flex: 1;
|
|
368
|
+
}
|
|
369
|
+
.cart-item-total {
|
|
370
|
+
font-size: 16px;
|
|
371
|
+
font-weight: 800;
|
|
372
|
+
white-space: nowrap;
|
|
373
|
+
}
|
|
374
|
+
.cart-detail-lines {
|
|
375
|
+
margin-top: 4px;
|
|
376
|
+
}
|
|
377
|
+
.cart-detail {
|
|
378
|
+
font-size: 13px;
|
|
379
|
+
color: var(--bw-color-text-secondary);
|
|
380
|
+
line-height: 1.5;
|
|
381
|
+
}
|
|
382
|
+
.cart-divider {
|
|
383
|
+
border-top: 1px dashed var(--bw-color-border);
|
|
384
|
+
margin: 14px 0;
|
|
385
|
+
}
|
|
386
|
+
.cart-units {
|
|
387
|
+
display: flex;
|
|
388
|
+
flex-direction: column;
|
|
389
|
+
gap: 6px;
|
|
390
|
+
margin-bottom: 14px;
|
|
391
|
+
}
|
|
392
|
+
.cart-unit-line {
|
|
393
|
+
display: flex;
|
|
394
|
+
justify-content: space-between;
|
|
395
|
+
align-items: center;
|
|
396
|
+
font-size: 14px;
|
|
397
|
+
}
|
|
398
|
+
.cart-unit-label {
|
|
399
|
+
color: #333;
|
|
400
|
+
}
|
|
401
|
+
.cart-unit-price {
|
|
402
|
+
font-weight: 600;
|
|
403
|
+
font-variant-numeric: tabular-nums;
|
|
404
|
+
}
|
|
405
|
+
.cart-actions {
|
|
406
|
+
display: flex;
|
|
407
|
+
gap: 16px;
|
|
408
|
+
}
|
|
409
|
+
.action-edit {
|
|
410
|
+
background: none;
|
|
411
|
+
border: none;
|
|
412
|
+
font-size: 13px;
|
|
413
|
+
font-weight: 700;
|
|
414
|
+
color: var(--bw-color-primary);
|
|
415
|
+
padding: 0;
|
|
416
|
+
cursor: pointer;
|
|
417
|
+
}
|
|
418
|
+
.action-edit:hover { text-decoration: underline; }
|
|
419
|
+
.action-remove {
|
|
420
|
+
background: none;
|
|
421
|
+
border: none;
|
|
422
|
+
font-size: 13px;
|
|
423
|
+
font-weight: 600;
|
|
424
|
+
color: #555;
|
|
425
|
+
padding: 0;
|
|
426
|
+
cursor: pointer;
|
|
427
|
+
}
|
|
428
|
+
.action-remove:hover { text-decoration: underline; }
|
|
429
|
+
|
|
430
|
+
.cart-total-divider {
|
|
431
|
+
border-top: 3px solid #111;
|
|
432
|
+
margin: 8px 0 20px;
|
|
433
|
+
}
|
|
434
|
+
.cart-total-row {
|
|
435
|
+
display: flex;
|
|
436
|
+
justify-content: space-between;
|
|
437
|
+
align-items: center;
|
|
438
|
+
font-size: 16px;
|
|
439
|
+
font-weight: 700;
|
|
440
|
+
margin-bottom: 20px;
|
|
441
|
+
}
|
|
442
|
+
.cart-total-amount {
|
|
443
|
+
font-size: 20px;
|
|
444
|
+
font-weight: 800;
|
|
445
|
+
}
|
|
446
|
+
.checkout-btn {
|
|
447
|
+
display: block;
|
|
448
|
+
width: 100%;
|
|
449
|
+
padding: 16px;
|
|
450
|
+
background: var(--bw-color-primary);
|
|
451
|
+
color: white;
|
|
452
|
+
border: none;
|
|
453
|
+
border-radius: var(--bw-radius-md);
|
|
454
|
+
font-size: 16px;
|
|
455
|
+
font-weight: 700;
|
|
456
|
+
cursor: pointer;
|
|
457
|
+
text-align: center;
|
|
458
|
+
}
|
|
459
|
+
.checkout-btn:hover {
|
|
460
|
+
background: var(--bw-color-primary-dark);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/* -- Contact view -- */
|
|
464
|
+
.contact-header {
|
|
465
|
+
display: flex;
|
|
466
|
+
justify-content: space-between;
|
|
467
|
+
align-items: center;
|
|
468
|
+
margin-bottom: 20px;
|
|
469
|
+
}
|
|
470
|
+
.contact-title {
|
|
471
|
+
font-size: 16px;
|
|
472
|
+
font-weight: 700;
|
|
473
|
+
}
|
|
474
|
+
.required-hint {
|
|
475
|
+
font-size: 12px;
|
|
476
|
+
color: var(--bw-color-primary);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
.pay-bar {
|
|
480
|
+
display: flex;
|
|
481
|
+
align-items: center;
|
|
482
|
+
flex-shrink: 0;
|
|
483
|
+
background: var(--bw-color-primary);
|
|
484
|
+
color: white;
|
|
485
|
+
position: sticky;
|
|
486
|
+
bottom: 0;
|
|
487
|
+
}
|
|
488
|
+
.pay-bar-total {
|
|
489
|
+
padding: 0 20px;
|
|
490
|
+
font-size: 16px;
|
|
491
|
+
font-weight: 700;
|
|
492
|
+
}
|
|
493
|
+
.pay-bar-btn {
|
|
494
|
+
flex: 1;
|
|
495
|
+
display: flex;
|
|
496
|
+
align-items: center;
|
|
497
|
+
justify-content: flex-end;
|
|
498
|
+
gap: 8px;
|
|
499
|
+
height: 52px;
|
|
500
|
+
padding: 0 20px;
|
|
501
|
+
background: none;
|
|
502
|
+
border: none;
|
|
503
|
+
color: white;
|
|
504
|
+
font-size: 16px;
|
|
505
|
+
font-weight: 700;
|
|
506
|
+
}
|
|
507
|
+
.pay-bar-btn .arrow {
|
|
508
|
+
font-size: 20px;
|
|
509
|
+
}
|
|
510
|
+
</style>
|