@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,45 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
interface Props {
|
|
3
|
+
privacyAccepted: boolean;
|
|
4
|
+
marketingOptIn: boolean;
|
|
5
|
+
hasAttemptedSubmit: boolean;
|
|
6
|
+
onPrivacyChanged: (v: boolean) => void;
|
|
7
|
+
onMarketingChanged: (v: boolean) => void;
|
|
8
|
+
}
|
|
9
|
+
let { privacyAccepted, marketingOptIn, hasAttemptedSubmit, onPrivacyChanged, onMarketingChanged }: Props = $props();
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<div class="consent">
|
|
13
|
+
<label class="check" class:error={hasAttemptedSubmit && !privacyAccepted}>
|
|
14
|
+
<input type="checkbox" checked={privacyAccepted} onchange={(e) => onPrivacyChanged((e.target as HTMLInputElement).checked)} />
|
|
15
|
+
<span>I accept the privacy policy *</span>
|
|
16
|
+
</label>
|
|
17
|
+
<label class="check">
|
|
18
|
+
<input type="checkbox" checked={marketingOptIn} onchange={(e) => onMarketingChanged((e.target as HTMLInputElement).checked)} />
|
|
19
|
+
<span>I would like to receive marketing communications</span>
|
|
20
|
+
</label>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<style>
|
|
24
|
+
.consent {
|
|
25
|
+
display: flex;
|
|
26
|
+
flex-direction: column;
|
|
27
|
+
gap: 12px;
|
|
28
|
+
}
|
|
29
|
+
.check {
|
|
30
|
+
display: flex;
|
|
31
|
+
align-items: flex-start;
|
|
32
|
+
gap: 10px;
|
|
33
|
+
font-size: 13px;
|
|
34
|
+
cursor: pointer;
|
|
35
|
+
line-height: 1.4;
|
|
36
|
+
}
|
|
37
|
+
.check.error span { color: var(--bw-color-error); }
|
|
38
|
+
input[type="checkbox"] {
|
|
39
|
+
margin-top: 2px;
|
|
40
|
+
accent-color: var(--bw-color-primary);
|
|
41
|
+
width: 16px;
|
|
42
|
+
height: 16px;
|
|
43
|
+
flex-shrink: 0;
|
|
44
|
+
}
|
|
45
|
+
</style>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { OctoContact } from './client-types';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
hasAttemptedSubmit: boolean;
|
|
6
|
+
}
|
|
7
|
+
let { hasAttemptedSubmit }: Props = $props();
|
|
8
|
+
|
|
9
|
+
let firstName = $state('');
|
|
10
|
+
let lastName = $state('');
|
|
11
|
+
let emailAddress = $state('');
|
|
12
|
+
let phoneNumber = $state('');
|
|
13
|
+
|
|
14
|
+
export function isValid(): boolean {
|
|
15
|
+
return firstName.trim() !== '' && emailAddress.trim() !== '' && phoneNumber.trim() !== '';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getContact(): OctoContact {
|
|
19
|
+
return {
|
|
20
|
+
firstName: firstName.trim(),
|
|
21
|
+
lastName: lastName.trim(),
|
|
22
|
+
emailAddress: emailAddress.trim(),
|
|
23
|
+
phoneNumber: phoneNumber.trim(),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function hasError(value: string): boolean {
|
|
28
|
+
return hasAttemptedSubmit && value.trim() === '';
|
|
29
|
+
}
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<div class="form">
|
|
33
|
+
<div class="field">
|
|
34
|
+
<label for="firstName">First name *</label>
|
|
35
|
+
<input id="firstName" class="input" class:input-error={hasError(firstName)} bind:value={firstName} />
|
|
36
|
+
</div>
|
|
37
|
+
<div class="field">
|
|
38
|
+
<label for="lastName">Last name</label>
|
|
39
|
+
<input id="lastName" class="input" bind:value={lastName} />
|
|
40
|
+
</div>
|
|
41
|
+
<div class="field">
|
|
42
|
+
<label for="email">Email address *</label>
|
|
43
|
+
<input id="email" type="email" class="input" class:input-error={hasError(emailAddress)} bind:value={emailAddress} />
|
|
44
|
+
</div>
|
|
45
|
+
<div class="field">
|
|
46
|
+
<label for="phone">Phone number *</label>
|
|
47
|
+
<input id="phone" type="tel" class="input" class:input-error={hasError(phoneNumber)} bind:value={phoneNumber} />
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<style>
|
|
52
|
+
.form {
|
|
53
|
+
display: flex;
|
|
54
|
+
flex-direction: column;
|
|
55
|
+
gap: 14px;
|
|
56
|
+
}
|
|
57
|
+
.field {
|
|
58
|
+
display: flex;
|
|
59
|
+
flex-direction: column;
|
|
60
|
+
gap: 4px;
|
|
61
|
+
}
|
|
62
|
+
label {
|
|
63
|
+
font-size: 13px;
|
|
64
|
+
font-weight: 600;
|
|
65
|
+
color: var(--bw-color-text-secondary);
|
|
66
|
+
}
|
|
67
|
+
</style>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onDestroy } from 'svelte';
|
|
3
|
+
|
|
4
|
+
interface Props { expiresAt: Date }
|
|
5
|
+
let { expiresAt }: Props = $props();
|
|
6
|
+
|
|
7
|
+
let remaining = $state('');
|
|
8
|
+
|
|
9
|
+
function update() {
|
|
10
|
+
const diff = Math.max(0, expiresAt.getTime() - Date.now());
|
|
11
|
+
const mins = Math.floor(diff / 60000);
|
|
12
|
+
const secs = Math.floor((diff % 60000) / 1000);
|
|
13
|
+
remaining = `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
update();
|
|
17
|
+
const interval = setInterval(update, 1000);
|
|
18
|
+
onDestroy(() => clearInterval(interval));
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<span class="timer">{remaining}</span>
|
|
22
|
+
|
|
23
|
+
<style>
|
|
24
|
+
.timer {
|
|
25
|
+
font-size: 13px;
|
|
26
|
+
font-weight: 600;
|
|
27
|
+
color: var(--bw-color-text-secondary);
|
|
28
|
+
font-variant-numeric: tabular-nums;
|
|
29
|
+
}
|
|
30
|
+
</style>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { CartItem, CheckoutCartItemDetailDto, CheckoutProductDto } from './client-types';
|
|
3
|
+
import type { BookingApi } from './api';
|
|
4
|
+
import type { WizardPages } from './config';
|
|
5
|
+
import { DEFAULT_WIZARD_PAGES } from './config';
|
|
6
|
+
import { expandUnitItems } from './utils';
|
|
7
|
+
import WizardPage from './WizardPage.svelte';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
cartItem: CheckoutCartItemDetailDto;
|
|
11
|
+
product: CheckoutProductDto;
|
|
12
|
+
api: BookingApi;
|
|
13
|
+
wizardPages?: WizardPages;
|
|
14
|
+
onBack: () => void;
|
|
15
|
+
onClose: () => void;
|
|
16
|
+
onUpdated: () => void;
|
|
17
|
+
}
|
|
18
|
+
let { cartItem, product, api, wizardPages, onBack, onClose, onUpdated }: Props = $props();
|
|
19
|
+
|
|
20
|
+
let isSaving = $state(false);
|
|
21
|
+
|
|
22
|
+
async function onComplete(item: CartItem) {
|
|
23
|
+
isSaving = true;
|
|
24
|
+
try {
|
|
25
|
+
await api.updateCartItem(cartItem.id, {
|
|
26
|
+
productId: item.productId,
|
|
27
|
+
optionId: item.optionId,
|
|
28
|
+
unitItems: expandUnitItems(item.units),
|
|
29
|
+
availabilityId: item.availabilityId,
|
|
30
|
+
localDate: item.localDate,
|
|
31
|
+
pickupPointId: item.pickupPointId,
|
|
32
|
+
amount: item.totalPrice,
|
|
33
|
+
currencyCode: item.currency,
|
|
34
|
+
currencyPrecision: item.currencyPrecision,
|
|
35
|
+
});
|
|
36
|
+
onUpdated();
|
|
37
|
+
} finally {
|
|
38
|
+
isSaving = false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<div class="edit-view">
|
|
44
|
+
<div class="page-header">
|
|
45
|
+
<button class="icon-btn" onclick={onBack} aria-label="Back">←</button>
|
|
46
|
+
<h2>Edit Item</h2>
|
|
47
|
+
<span class="spacer"></span>
|
|
48
|
+
<button class="icon-btn" onclick={onClose} aria-label="Close">×</button>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
{#if isSaving}
|
|
52
|
+
<div class="loading-center"><div class="spinner"></div></div>
|
|
53
|
+
{:else}
|
|
54
|
+
<WizardPage
|
|
55
|
+
{product}
|
|
56
|
+
{api}
|
|
57
|
+
wizardPages={wizardPages ?? DEFAULT_WIZARD_PAGES}
|
|
58
|
+
editItem={cartItem}
|
|
59
|
+
{onComplete}
|
|
60
|
+
onCancel={onBack}
|
|
61
|
+
/>
|
|
62
|
+
{/if}
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<style>
|
|
66
|
+
.edit-view {
|
|
67
|
+
display: flex;
|
|
68
|
+
flex-direction: column;
|
|
69
|
+
height: 100%;
|
|
70
|
+
max-height: 100%;
|
|
71
|
+
overflow: hidden;
|
|
72
|
+
}
|
|
73
|
+
</style>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { CheckoutOptionDto } from './client-types';
|
|
3
|
+
import { formatCurrency } from './currency';
|
|
4
|
+
import SelectableCard from './SelectableCard.svelte';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
option: CheckoutOptionDto;
|
|
8
|
+
selected: boolean;
|
|
9
|
+
onSelect: () => void;
|
|
10
|
+
}
|
|
11
|
+
let { option, selected, onSelect }: Props = $props();
|
|
12
|
+
|
|
13
|
+
let pricingLine = $derived(() => {
|
|
14
|
+
const parts: string[] = [];
|
|
15
|
+
for (const unit of option.units) {
|
|
16
|
+
if (!unit.pricing || unit.pricing.length === 0) continue;
|
|
17
|
+
const p = unit.pricing[0];
|
|
18
|
+
parts.push(`${unit.title ?? unit.id} from ${formatCurrency(p.retail, p.currency, p.currencyPrecision)}`);
|
|
19
|
+
}
|
|
20
|
+
return parts.join(', ');
|
|
21
|
+
});
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<SelectableCard
|
|
25
|
+
{selected}
|
|
26
|
+
{onSelect}
|
|
27
|
+
heading={option.title}
|
|
28
|
+
body={option.description}
|
|
29
|
+
>
|
|
30
|
+
{#if pricingLine()}
|
|
31
|
+
<p class="pricing">{pricingLine()}</p>
|
|
32
|
+
{/if}
|
|
33
|
+
</SelectableCard>
|
|
34
|
+
|
|
35
|
+
<style>
|
|
36
|
+
.pricing {
|
|
37
|
+
font-size: 13px;
|
|
38
|
+
font-weight: 700;
|
|
39
|
+
margin-top: 8px;
|
|
40
|
+
}
|
|
41
|
+
</style>
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, onDestroy } from 'svelte';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
checkoutId: string;
|
|
6
|
+
totalFormatted: string;
|
|
7
|
+
onBack: () => void;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
onPaymentComplete: (result: { status: string }) => void;
|
|
10
|
+
}
|
|
11
|
+
let { checkoutId, totalFormatted, onBack, onClose, onPaymentComplete }: Props = $props();
|
|
12
|
+
|
|
13
|
+
let failed = $state(false);
|
|
14
|
+
let errorMessage = $state('');
|
|
15
|
+
|
|
16
|
+
let peachInstance: any = null;
|
|
17
|
+
|
|
18
|
+
onMount(() => {
|
|
19
|
+
try {
|
|
20
|
+
const Checkout = (window as any).Checkout;
|
|
21
|
+
if (!Checkout) {
|
|
22
|
+
failed = true;
|
|
23
|
+
errorMessage = 'Payment SDK not loaded';
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
peachInstance = Checkout.initiate({
|
|
28
|
+
checkoutId,
|
|
29
|
+
customisations: {
|
|
30
|
+
card: { submitButtonText: 'Pay Now' },
|
|
31
|
+
theme: { brand: { primary: '#E30613' } },
|
|
32
|
+
},
|
|
33
|
+
eventHandlers: {
|
|
34
|
+
onCompleted: () => { cleanup(); onPaymentComplete({ status: 'completed' }); },
|
|
35
|
+
onCancelled: () => { cleanup(); onPaymentComplete({ status: 'cancelled' }); },
|
|
36
|
+
onExpired: () => { cleanup(); onPaymentComplete({ status: 'expired' }); },
|
|
37
|
+
onError: () => { cleanup(); onPaymentComplete({ status: 'error' }); },
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
peachInstance.render('#peach-container');
|
|
42
|
+
} catch (e) {
|
|
43
|
+
failed = true;
|
|
44
|
+
errorMessage = String(e);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
function cleanup() {
|
|
49
|
+
if (peachInstance) {
|
|
50
|
+
peachInstance.unmount();
|
|
51
|
+
peachInstance = null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
onDestroy(cleanup);
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<div class="payment-page">
|
|
59
|
+
<div class="page-header">
|
|
60
|
+
<button class="icon-btn" onclick={onBack} aria-label="Back">←</button>
|
|
61
|
+
<h2>Payment</h2>
|
|
62
|
+
<span class="spacer"></span>
|
|
63
|
+
<button class="icon-btn" onclick={onClose} aria-label="Close">×</button>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div class="body">
|
|
67
|
+
<div class="amount-bar">
|
|
68
|
+
<span>Amount to pay</span>
|
|
69
|
+
<span class="amount">{totalFormatted}</span>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
{#if failed}
|
|
73
|
+
<div class="error-state">
|
|
74
|
+
<div class="error-icon">!</div>
|
|
75
|
+
<p class="error-title">Unable to load payment form</p>
|
|
76
|
+
<p class="error-msg">{errorMessage || 'The checkout session may have expired.'}</p>
|
|
77
|
+
</div>
|
|
78
|
+
{:else}
|
|
79
|
+
<div class="brands">
|
|
80
|
+
<span class="brand">VISA</span>
|
|
81
|
+
<span class="brand">MC</span>
|
|
82
|
+
<span class="brand">AMEX</span>
|
|
83
|
+
</div>
|
|
84
|
+
<div id="peach-container"></div>
|
|
85
|
+
{/if}
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<style>
|
|
90
|
+
.payment-page {
|
|
91
|
+
display: flex;
|
|
92
|
+
flex-direction: column;
|
|
93
|
+
height: 100%;
|
|
94
|
+
}
|
|
95
|
+
.body {
|
|
96
|
+
flex: 1;
|
|
97
|
+
overflow-y: auto;
|
|
98
|
+
padding: 16px;
|
|
99
|
+
}
|
|
100
|
+
.amount-bar {
|
|
101
|
+
display: flex;
|
|
102
|
+
justify-content: space-between;
|
|
103
|
+
align-items: center;
|
|
104
|
+
padding: 16px;
|
|
105
|
+
background: var(--bw-color-surface);
|
|
106
|
+
border-radius: var(--bw-radius-lg);
|
|
107
|
+
margin-bottom: 20px;
|
|
108
|
+
font-weight: 600;
|
|
109
|
+
font-size: 15px;
|
|
110
|
+
}
|
|
111
|
+
.amount {
|
|
112
|
+
color: var(--bw-color-primary);
|
|
113
|
+
font-size: 18px;
|
|
114
|
+
font-weight: 700;
|
|
115
|
+
}
|
|
116
|
+
.brands {
|
|
117
|
+
display: flex;
|
|
118
|
+
gap: 8px;
|
|
119
|
+
margin-bottom: 16px;
|
|
120
|
+
}
|
|
121
|
+
.brand {
|
|
122
|
+
padding: 4px 10px;
|
|
123
|
+
border: 1px solid var(--bw-color-border);
|
|
124
|
+
border-radius: var(--bw-radius-sm);
|
|
125
|
+
font-size: 11px;
|
|
126
|
+
font-weight: 700;
|
|
127
|
+
color: var(--bw-color-text-secondary);
|
|
128
|
+
letter-spacing: 0.02em;
|
|
129
|
+
}
|
|
130
|
+
.error-state {
|
|
131
|
+
text-align: center;
|
|
132
|
+
padding: 32px 16px;
|
|
133
|
+
}
|
|
134
|
+
.error-icon {
|
|
135
|
+
width: 48px;
|
|
136
|
+
height: 48px;
|
|
137
|
+
border-radius: 50%;
|
|
138
|
+
border: 2px solid var(--bw-color-border);
|
|
139
|
+
display: flex;
|
|
140
|
+
align-items: center;
|
|
141
|
+
justify-content: center;
|
|
142
|
+
font-size: 24px;
|
|
143
|
+
color: var(--bw-color-text-secondary);
|
|
144
|
+
margin: 0 auto 16px;
|
|
145
|
+
}
|
|
146
|
+
.error-title {
|
|
147
|
+
font-size: 16px;
|
|
148
|
+
font-weight: 600;
|
|
149
|
+
margin-bottom: 8px;
|
|
150
|
+
}
|
|
151
|
+
.error-msg {
|
|
152
|
+
font-size: 14px;
|
|
153
|
+
color: var(--bw-color-text-secondary);
|
|
154
|
+
}
|
|
155
|
+
</style>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { CheckoutPickupLocationDto } from './client-types';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
pickupPoints: CheckoutPickupLocationDto[];
|
|
6
|
+
isRequired: boolean;
|
|
7
|
+
formattedTotal: string;
|
|
8
|
+
onBack: () => void;
|
|
9
|
+
onContinue: (point: CheckoutPickupLocationDto | null) => void;
|
|
10
|
+
}
|
|
11
|
+
let { pickupPoints, isRequired, formattedTotal, onBack, onContinue }: Props = $props();
|
|
12
|
+
|
|
13
|
+
let selectedId = $state<string | null>(null);
|
|
14
|
+
|
|
15
|
+
let selectedPoint = $derived(
|
|
16
|
+
pickupPoints.find(p => p.id === selectedId) ?? null,
|
|
17
|
+
);
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<div class="pickup-page">
|
|
21
|
+
<div class="page-header">
|
|
22
|
+
<button class="icon-btn" onclick={onBack} aria-label="Back">←</button>
|
|
23
|
+
<h2>Select pickup point</h2>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<div class="list">
|
|
27
|
+
{#each pickupPoints as point}
|
|
28
|
+
<button
|
|
29
|
+
class="card card-selectable"
|
|
30
|
+
class:card-selected={selectedId === point.id}
|
|
31
|
+
onclick={() => { selectedId = point.id; }}
|
|
32
|
+
>
|
|
33
|
+
<div class="name">{point.title ?? point.id}</div>
|
|
34
|
+
{#if point.address}
|
|
35
|
+
<div class="address">{point.address}</div>
|
|
36
|
+
{/if}
|
|
37
|
+
</button>
|
|
38
|
+
{/each}
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div class="action-bar">
|
|
42
|
+
<button
|
|
43
|
+
class="btn btn-primary btn-split"
|
|
44
|
+
disabled={isRequired && !selectedPoint}
|
|
45
|
+
onclick={() => onContinue(selectedPoint)}
|
|
46
|
+
>
|
|
47
|
+
<span>{formattedTotal}</span>
|
|
48
|
+
<span>Continue →</span>
|
|
49
|
+
</button>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<style>
|
|
54
|
+
.pickup-page {
|
|
55
|
+
display: flex;
|
|
56
|
+
flex-direction: column;
|
|
57
|
+
height: 100%;
|
|
58
|
+
}
|
|
59
|
+
.list {
|
|
60
|
+
flex: 1;
|
|
61
|
+
overflow-y: auto;
|
|
62
|
+
padding: 12px 16px;
|
|
63
|
+
display: flex;
|
|
64
|
+
flex-direction: column;
|
|
65
|
+
gap: 8px;
|
|
66
|
+
}
|
|
67
|
+
.name {
|
|
68
|
+
font-size: 14px;
|
|
69
|
+
font-weight: 600;
|
|
70
|
+
}
|
|
71
|
+
.address {
|
|
72
|
+
font-size: 12px;
|
|
73
|
+
color: var(--bw-color-text-secondary);
|
|
74
|
+
margin-top: 4px;
|
|
75
|
+
}
|
|
76
|
+
</style>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { PaymentStatus } from './client-types';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
status: PaymentStatus | null;
|
|
6
|
+
verifying: boolean;
|
|
7
|
+
onDone: () => void;
|
|
8
|
+
onRetry: () => void;
|
|
9
|
+
}
|
|
10
|
+
let { status, verifying, onDone, onRetry }: Props = $props();
|
|
11
|
+
|
|
12
|
+
let success = $derived(status?.outcome === 'successful');
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<div class="result">
|
|
16
|
+
{#if verifying}
|
|
17
|
+
<div class="spinner"></div>
|
|
18
|
+
<p class="hint">Verifying payment...</p>
|
|
19
|
+
{:else}
|
|
20
|
+
<div class="icon" class:success class:failure={!success}>
|
|
21
|
+
{success ? '\u2713' : '!'}
|
|
22
|
+
</div>
|
|
23
|
+
<h2>{success ? 'Payment Successful' : 'Payment Failed'}</h2>
|
|
24
|
+
<p class="desc">
|
|
25
|
+
{success
|
|
26
|
+
? 'Your booking has been confirmed.'
|
|
27
|
+
: status?.resultDescription ?? 'Something went wrong. Please try again.'}
|
|
28
|
+
</p>
|
|
29
|
+
{#if success}
|
|
30
|
+
<button class="btn btn-primary" onclick={onDone}>Done</button>
|
|
31
|
+
{:else}
|
|
32
|
+
<button class="btn btn-outline" onclick={onRetry}>Try Again</button>
|
|
33
|
+
{/if}
|
|
34
|
+
{/if}
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<style>
|
|
38
|
+
.result {
|
|
39
|
+
display: flex;
|
|
40
|
+
flex-direction: column;
|
|
41
|
+
align-items: center;
|
|
42
|
+
justify-content: center;
|
|
43
|
+
padding: 40px 24px;
|
|
44
|
+
height: 100%;
|
|
45
|
+
text-align: center;
|
|
46
|
+
}
|
|
47
|
+
.hint {
|
|
48
|
+
color: var(--bw-color-text-secondary);
|
|
49
|
+
font-size: 14px;
|
|
50
|
+
margin-top: 16px;
|
|
51
|
+
}
|
|
52
|
+
.icon {
|
|
53
|
+
width: 64px;
|
|
54
|
+
height: 64px;
|
|
55
|
+
border-radius: 50%;
|
|
56
|
+
display: flex;
|
|
57
|
+
align-items: center;
|
|
58
|
+
justify-content: center;
|
|
59
|
+
font-size: 32px;
|
|
60
|
+
margin-bottom: 20px;
|
|
61
|
+
}
|
|
62
|
+
.icon.success {
|
|
63
|
+
border: 2px solid var(--bw-color-success);
|
|
64
|
+
color: var(--bw-color-success);
|
|
65
|
+
background: rgba(76, 175, 80, 0.08);
|
|
66
|
+
}
|
|
67
|
+
.icon.failure {
|
|
68
|
+
border: 2px solid var(--bw-color-error);
|
|
69
|
+
color: var(--bw-color-error);
|
|
70
|
+
background: var(--bw-color-primary-light);
|
|
71
|
+
}
|
|
72
|
+
h2 {
|
|
73
|
+
font-size: 20px;
|
|
74
|
+
font-weight: 700;
|
|
75
|
+
margin-bottom: 8px;
|
|
76
|
+
}
|
|
77
|
+
.desc {
|
|
78
|
+
color: var(--bw-color-text-secondary);
|
|
79
|
+
font-size: 14px;
|
|
80
|
+
margin-bottom: 28px;
|
|
81
|
+
max-width: 280px;
|
|
82
|
+
line-height: 1.5;
|
|
83
|
+
}
|
|
84
|
+
</style>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
selected: boolean;
|
|
6
|
+
onSelect: () => void;
|
|
7
|
+
heading?: string;
|
|
8
|
+
body?: string;
|
|
9
|
+
leading?: Snippet;
|
|
10
|
+
children?: Snippet;
|
|
11
|
+
}
|
|
12
|
+
let { selected, onSelect, heading, body, leading, children }: Props = $props();
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<button
|
|
16
|
+
class="card card-selectable"
|
|
17
|
+
class:card-selected={selected}
|
|
18
|
+
onclick={onSelect}
|
|
19
|
+
>
|
|
20
|
+
<div class="layout">
|
|
21
|
+
{#if leading}
|
|
22
|
+
<div class="leading">
|
|
23
|
+
{@render leading()}
|
|
24
|
+
</div>
|
|
25
|
+
{/if}
|
|
26
|
+
<div class="text">
|
|
27
|
+
{#if heading}
|
|
28
|
+
<div class="heading">{heading}</div>
|
|
29
|
+
{/if}
|
|
30
|
+
{#if body}
|
|
31
|
+
<div class="body">{body}</div>
|
|
32
|
+
{/if}
|
|
33
|
+
{#if children}
|
|
34
|
+
{@render children()}
|
|
35
|
+
{/if}
|
|
36
|
+
</div>
|
|
37
|
+
<span class="radio" class:radio-selected={selected}></span>
|
|
38
|
+
</div>
|
|
39
|
+
</button>
|
|
40
|
+
|
|
41
|
+
<style>
|
|
42
|
+
.layout {
|
|
43
|
+
display: flex;
|
|
44
|
+
align-items: center;
|
|
45
|
+
gap: 12px;
|
|
46
|
+
}
|
|
47
|
+
.text {
|
|
48
|
+
flex: 1;
|
|
49
|
+
}
|
|
50
|
+
.heading {
|
|
51
|
+
font-size: 15px;
|
|
52
|
+
font-weight: 700;
|
|
53
|
+
margin-bottom: 4px;
|
|
54
|
+
}
|
|
55
|
+
.body {
|
|
56
|
+
font-size: 13px;
|
|
57
|
+
color: var(--bw-color-text-secondary);
|
|
58
|
+
line-height: 1.5;
|
|
59
|
+
}
|
|
60
|
+
.radio {
|
|
61
|
+
width: 22px;
|
|
62
|
+
height: 22px;
|
|
63
|
+
flex-shrink: 0;
|
|
64
|
+
border: 2px solid var(--bw-color-border);
|
|
65
|
+
border-radius: 50%;
|
|
66
|
+
}
|
|
67
|
+
.radio-selected {
|
|
68
|
+
border-color: var(--bw-color-text);
|
|
69
|
+
background: var(--bw-color-text);
|
|
70
|
+
}
|
|
71
|
+
</style>
|