@code-collective/booking-widget 1.0.7 → 1.0.9
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 +33 -2
- package/dist/booking-widget.css +1 -1
- package/dist/booking-widget.js +1038 -795
- package/dist/booking-widget.min.js +23 -9
- package/dist/booking-widget.umd.cjs +3 -2
- package/package.json +6 -2
- package/src/lib/CartBar.svelte +4 -11
- package/src/lib/CartBarView.svelte +2 -1
- package/src/lib/CartExpiredView.svelte +63 -0
- package/src/lib/CartExpiryGuard.svelte +410 -0
- package/src/lib/CartExpiryGuard.test.ts +331 -0
- package/src/lib/CartExpiryWatcher.svelte +46 -0
- package/src/lib/CartOverviewButton.svelte +6 -17
- package/src/lib/Checkout.svelte +55 -14
- package/src/lib/CheckoutModal.confirm-outcome.test.ts +91 -0
- package/src/lib/CheckoutModal.payment-timeout.test.ts +140 -0
- package/src/lib/CheckoutModal.svelte +805 -560
- package/src/lib/CountdownTimer.svelte +2 -4
- package/src/lib/EditBookingView.svelte +15 -4
- package/src/lib/PaymentPage.svelte +78 -61
- package/src/lib/ResultView.svelte +43 -3
- package/src/lib/StillTherePrompt.svelte +72 -0
- package/src/lib/TicketConfigurator.svelte +25 -3
- package/src/lib/UnitCounter.svelte +84 -8
- package/src/lib/WizardPage.svelte +11 -0
- package/src/lib/api.ts +89 -2
- package/src/lib/cart-expiry.ts +46 -0
- package/src/lib/cart-manager.ts +23 -6
- package/src/lib/client-types.ts +6 -0
- package/src/lib/elements/bw-cart.svelte +3 -12
- package/src/lib/elements/bw-checkout.svelte +9 -16
- package/src/lib/elements/bw-configurator.svelte +3 -13
- package/src/lib/elements/register.ts +33 -10
- package/src/lib/generated-types.ts +133 -3
- package/src/lib/index.ts +19 -28
- package/src/lib/messages.ts +75 -1
- package/src/lib/payment-attempt.ts +56 -0
- package/src/lib/test/fixtures.ts +107 -0
- package/src/lib/test/messages-mock.ts +34 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onDestroy } from 'svelte';
|
|
3
|
+
import { formatRemaining } from './cart-expiry';
|
|
3
4
|
|
|
4
5
|
interface Props { expiresAt: Date }
|
|
5
6
|
let { expiresAt }: Props = $props();
|
|
@@ -7,10 +8,7 @@
|
|
|
7
8
|
let remaining = $state('');
|
|
8
9
|
|
|
9
10
|
function update() {
|
|
10
|
-
|
|
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')}`;
|
|
11
|
+
remaining = formatRemaining(expiresAt.getTime() - Date.now());
|
|
14
12
|
}
|
|
15
13
|
|
|
16
14
|
update();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { CartItem, CheckoutCartItemDetailDto, CheckoutProductDto } from './client-types';
|
|
3
3
|
import type { BookingApi } from './api';
|
|
4
|
-
import { ApiError } from './api';
|
|
4
|
+
import { ApiError, isPriceMismatch } from './api';
|
|
5
5
|
import type { WizardPages } from './config';
|
|
6
6
|
import { DEFAULT_WIZARD_PAGES } from './config';
|
|
7
7
|
import { expandUnitItems } from './utils';
|
|
@@ -17,8 +17,11 @@
|
|
|
17
17
|
onBack: () => void;
|
|
18
18
|
onClose: () => void;
|
|
19
19
|
onUpdated: () => void;
|
|
20
|
+
/// Re-fetches the products this view is handed, so a price that moved under the customer can be shown to
|
|
21
|
+
/// them. Owned by the parent because `product` is a prop here - this view cannot refresh it itself.
|
|
22
|
+
onPriceChanged?: () => Promise<void>;
|
|
20
23
|
}
|
|
21
|
-
let { cartItem, product, api, wizardPages, editPages, autoSelectSingleTimeSlot = false, onBack, onClose, onUpdated }: Props = $props();
|
|
24
|
+
let { cartItem, product, api, wizardPages, editPages, autoSelectSingleTimeSlot = false, onBack, onClose, onUpdated, onPriceChanged }: Props = $props();
|
|
22
25
|
|
|
23
26
|
let isSaving = $state(false);
|
|
24
27
|
let errorMessage = $state<string | null>(null);
|
|
@@ -34,12 +37,20 @@
|
|
|
34
37
|
availabilityId: item.availabilityId,
|
|
35
38
|
localDate: item.localDate,
|
|
36
39
|
pickupPointId: item.pickupPointId,
|
|
40
|
+
// Currency and precision are resolved server-side - see the same call in TicketConfigurator.
|
|
37
41
|
amount: item.totalPrice,
|
|
38
|
-
currencyCode: item.currency,
|
|
39
|
-
currencyPrecision: item.currencyPrecision,
|
|
40
42
|
});
|
|
41
43
|
onUpdated();
|
|
42
44
|
} catch (e) {
|
|
45
|
+
// The supplier moved the price mid-edit, so the amount being submitted is no longer one the server will
|
|
46
|
+
// accept. Retrying resubmits the same stale figure and fails identically, so the prices this view is
|
|
47
|
+
// showing have to be refreshed before the customer can do anything useful.
|
|
48
|
+
if (isPriceMismatch(e)) {
|
|
49
|
+
errorMessage = "This item's price has changed. Please check the updated price and save again.";
|
|
50
|
+
await onPriceChanged?.();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
43
54
|
// 424 means the cart item itself was rolled back server-side (see CheckoutCartEndpoints.UpdateItemAsync) -
|
|
44
55
|
// the supplier rejected the change, so nothing was saved. Surface that instead of letting it vanish
|
|
45
56
|
// silently: WizardPage stays mounted (not swapped for a spinner) specifically so the user's in-progress
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount, onDestroy } from 'svelte';
|
|
3
3
|
import { loadPeachSdk } from './peach-sdk';
|
|
4
|
+
import CountdownTimer from './CountdownTimer.svelte';
|
|
4
5
|
|
|
5
6
|
interface Props {
|
|
6
7
|
checkoutId: string;
|
|
7
|
-
|
|
8
|
-
onBack: () => void;
|
|
9
|
-
onClose: () => void;
|
|
8
|
+
entityId: string;
|
|
10
9
|
onPaymentComplete: (result: { status: string }) => void;
|
|
10
|
+
// The cart's remaining life, shown in this dialog's own header so the shopper can see it over Peach's
|
|
11
|
+
// form - CheckoutModal's own cart timer is behind this overlay. Optional so callers that predate it need
|
|
12
|
+
// not pass one. CartExpiryGuard.svelte is what actually warns and acts as this counts down to zero - it
|
|
13
|
+
// renders above this overlay too, so there is nothing else for this dialog to do near the deadline.
|
|
14
|
+
expiresAt?: Date;
|
|
11
15
|
}
|
|
12
|
-
let { checkoutId,
|
|
16
|
+
let { checkoutId, entityId, onPaymentComplete, expiresAt }: Props = $props();
|
|
13
17
|
|
|
14
18
|
let failed = $state(false);
|
|
15
19
|
let errorMessage = $state('');
|
|
@@ -30,9 +34,16 @@
|
|
|
30
34
|
|
|
31
35
|
peachInstance = Checkout.initiate({
|
|
32
36
|
checkoutId,
|
|
37
|
+
// Peach's own "key" field, not our checkoutKey/entityId naming - without it Checkout.initiate()
|
|
38
|
+
// throws (Cannot read properties of undefined (reading 'trim')) before ever rendering anything,
|
|
39
|
+
// since the embedded widget authenticates to Peach directly with this rather than through us.
|
|
40
|
+
key: entityId,
|
|
33
41
|
customisations: {
|
|
34
42
|
card: { submitButtonText: 'Pay Now' },
|
|
35
43
|
theme: { brand: { primary: '#E30613' } },
|
|
44
|
+
// Peach's own Cancel (method-selection screen) and Back (card-entry screen) are the only
|
|
45
|
+
// back/exit controls in this dialog - we don't render one of our own alongside them, so there's
|
|
46
|
+
// exactly one way out per screen instead of ours and Peach's competing for the same job.
|
|
36
47
|
},
|
|
37
48
|
eventHandlers: {
|
|
38
49
|
onCompleted: () => { cleanup(); onPaymentComplete({ status: 'completed' }); },
|
|
@@ -60,77 +71,80 @@
|
|
|
60
71
|
onDestroy(cleanup);
|
|
61
72
|
</script>
|
|
62
73
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
<div class="
|
|
72
|
-
<div class="
|
|
73
|
-
<
|
|
74
|
-
|
|
74
|
+
<!-- Peach's own card form renders inside a cross-origin iframe with its own Cancel/Back controls we have no
|
|
75
|
+
way to hide or reach (no SDK option, no CSS/JS access across origins) - see Checkout.initiate()'s
|
|
76
|
+
customisations doc comment above. Presenting it as its own nested dialog over the checkout modal, rather
|
|
77
|
+
than as just another view swapped into that modal's own body, means Peach's own Cancel/Back read as
|
|
78
|
+
belonging to Peach's own layer instead of visually competing with a second, separate Back of ours - so
|
|
79
|
+
this dialog has no back/exit control of its own at all; onCancelled below routes back to contact. -->
|
|
80
|
+
<div class="payment-overlay">
|
|
81
|
+
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
82
|
+
<div class="payment-dialog" onclick={(e) => e.stopPropagation()}>
|
|
83
|
+
<div class="page-header">
|
|
84
|
+
<h2>Payment</h2>
|
|
85
|
+
{#if expiresAt}
|
|
86
|
+
<span class="spacer"></span>
|
|
87
|
+
<CountdownTimer {expiresAt} />
|
|
88
|
+
{/if}
|
|
75
89
|
</div>
|
|
76
90
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
<div class="error-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
+
<div class="body">
|
|
92
|
+
{#if failed}
|
|
93
|
+
<div class="error-state">
|
|
94
|
+
<div class="error-icon">!</div>
|
|
95
|
+
<p class="error-title">Unable to load payment form</p>
|
|
96
|
+
<p class="error-msg">{errorMessage || 'The checkout session may have expired.'}</p>
|
|
97
|
+
<!-- Peach's own Cancel/Back never rendered, so this dialog needs one exit of its own here. Reported
|
|
98
|
+
as an error rather than a cancel: nothing was charged, and CheckoutModal's error path abandons
|
|
99
|
+
the attempt so a retry mints a fresh Peach checkout instead of reopening this one - which is the
|
|
100
|
+
only sensible outcome for a checkout that would not even render (e.g. one resumed after a reload
|
|
101
|
+
that Peach has since expired). -->
|
|
102
|
+
<button class="btn btn-primary" onclick={() => onPaymentComplete({ status: 'error' })}>Start over</button>
|
|
103
|
+
</div>
|
|
104
|
+
{:else}
|
|
105
|
+
<div id="peach-container"></div>
|
|
106
|
+
{/if}
|
|
107
|
+
</div>
|
|
91
108
|
</div>
|
|
92
109
|
</div>
|
|
93
110
|
|
|
94
111
|
<style>
|
|
95
|
-
.payment-
|
|
112
|
+
.payment-overlay {
|
|
113
|
+
position: fixed;
|
|
114
|
+
inset: 0;
|
|
115
|
+
/* Above bw-checkout's own .bw-modal-overlay (z-index 10000) so this reads as a second dialog opening
|
|
116
|
+
on top of the checkout modal, not content living inside it. */
|
|
117
|
+
z-index: 10001;
|
|
118
|
+
background: rgba(0, 0, 0, 0.5);
|
|
119
|
+
display: flex;
|
|
120
|
+
align-items: center;
|
|
121
|
+
justify-content: center;
|
|
122
|
+
padding: 16px;
|
|
123
|
+
}
|
|
124
|
+
.payment-dialog {
|
|
96
125
|
display: flex;
|
|
97
126
|
flex-direction: column;
|
|
98
|
-
|
|
127
|
+
width: 100%;
|
|
128
|
+
max-width: 480px;
|
|
129
|
+
height: min(700px, 90vh);
|
|
130
|
+
background: var(--bw-color-bg);
|
|
131
|
+
border-radius: var(--bw-radius-lg);
|
|
132
|
+
box-shadow: var(--bw-shadow-3);
|
|
133
|
+
overflow: hidden;
|
|
99
134
|
}
|
|
100
135
|
.body {
|
|
101
136
|
flex: 1;
|
|
137
|
+
/* A definite height (not just flex:1 alone) so #peach-container's own height:100% below has something
|
|
138
|
+
real to resolve against, and so this scrolls as a single region if content overflows - Peach's own
|
|
139
|
+
root is `height: inherit`, so without that it collapses to its 360px fallback regardless of how tall
|
|
140
|
+
the card form actually gets (e.g. once billing address fields are showing), and its submit button
|
|
141
|
+
ends up overlapping the fields above it. */
|
|
142
|
+
min-height: 0;
|
|
102
143
|
overflow-y: auto;
|
|
103
144
|
padding: 16px;
|
|
104
145
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
justify-content: space-between;
|
|
108
|
-
align-items: center;
|
|
109
|
-
padding: 16px;
|
|
110
|
-
background: var(--bw-color-surface);
|
|
111
|
-
border-radius: var(--bw-radius-lg);
|
|
112
|
-
margin-bottom: 20px;
|
|
113
|
-
font-weight: 600;
|
|
114
|
-
font-size: 15px;
|
|
115
|
-
}
|
|
116
|
-
.amount {
|
|
117
|
-
color: var(--bw-color-primary);
|
|
118
|
-
font-size: 18px;
|
|
119
|
-
font-weight: 700;
|
|
120
|
-
}
|
|
121
|
-
.brands {
|
|
122
|
-
display: flex;
|
|
123
|
-
gap: 8px;
|
|
124
|
-
margin-bottom: 16px;
|
|
125
|
-
}
|
|
126
|
-
.brand {
|
|
127
|
-
padding: 4px 10px;
|
|
128
|
-
border: 1px solid var(--bw-color-border);
|
|
129
|
-
border-radius: var(--bw-radius-sm);
|
|
130
|
-
font-size: 11px;
|
|
131
|
-
font-weight: 700;
|
|
132
|
-
color: var(--bw-color-text-secondary);
|
|
133
|
-
letter-spacing: 0.02em;
|
|
146
|
+
#peach-container {
|
|
147
|
+
height: 100%;
|
|
134
148
|
}
|
|
135
149
|
.error-state {
|
|
136
150
|
text-align: center;
|
|
@@ -157,4 +171,7 @@
|
|
|
157
171
|
font-size: 14px;
|
|
158
172
|
color: var(--bw-color-text-secondary);
|
|
159
173
|
}
|
|
174
|
+
.error-state .btn {
|
|
175
|
+
margin-top: 16px;
|
|
176
|
+
}
|
|
160
177
|
</style>
|
|
@@ -6,19 +6,43 @@
|
|
|
6
6
|
verifying: boolean;
|
|
7
7
|
onDone: () => void;
|
|
8
8
|
onRetry: () => void;
|
|
9
|
+
// Re-checks confirmation on demand without touching payment - only meaningful (and only rendered) for
|
|
10
|
+
// a 'pending' outcome. Optional so callers that predate this state need not pass it.
|
|
11
|
+
onCheckAgain?: () => void;
|
|
9
12
|
}
|
|
10
|
-
let { status, verifying, onDone, onRetry }: Props = $props();
|
|
13
|
+
let { status, verifying, onDone, onRetry, onCheckAgain }: Props = $props();
|
|
11
14
|
|
|
12
15
|
let success = $derived(status?.outcome === 'successful');
|
|
16
|
+
let pending = $derived(status?.outcome === 'pending');
|
|
17
|
+
// A 'failed' outcome covers two genuinely different situations - see CheckoutModal's own remarks on
|
|
18
|
+
// confirmOutcome. Only one of them is safe to offer "Try Again" for: the payment itself never went
|
|
19
|
+
// through (declined, expired, SDK error), so re-opening the card form charges the shopper once, not
|
|
20
|
+
// twice. The other - payment succeeded but this gateway could not confirm every item - already has the
|
|
21
|
+
// shopper's money against a real charge; offering to pay again there would risk a second charge for
|
|
22
|
+
// nothing this screen can fix, so status.retryPayment gates the button rather than the outcome alone.
|
|
23
|
+
let retryable = $derived(status?.outcome === 'failed' && status.retryPayment !== false);
|
|
13
24
|
</script>
|
|
14
25
|
|
|
15
26
|
<div class="result">
|
|
16
27
|
{#if verifying}
|
|
17
28
|
<div class="spinner"></div>
|
|
18
29
|
<p class="hint">Verifying payment...</p>
|
|
30
|
+
{:else if pending}
|
|
31
|
+
<div class="icon pending">…</div>
|
|
32
|
+
<h2>Payment Received</h2>
|
|
33
|
+
<p class="desc">
|
|
34
|
+
{status?.resultDescription ??
|
|
35
|
+
"We're still confirming your booking - this can take a minute. Please don't try to pay again; we'll email your confirmation as soon as it's ready."}
|
|
36
|
+
</p>
|
|
37
|
+
<div class="actions">
|
|
38
|
+
{#if onCheckAgain}
|
|
39
|
+
<button class="btn btn-primary" onclick={onCheckAgain}>Check Again</button>
|
|
40
|
+
{/if}
|
|
41
|
+
<button class="btn btn-outline" onclick={onDone}>Close</button>
|
|
42
|
+
</div>
|
|
19
43
|
{:else}
|
|
20
44
|
<div class="icon" class:success class:failure={!success}>
|
|
21
|
-
{success ? '
|
|
45
|
+
{success ? '✓' : '!'}
|
|
22
46
|
</div>
|
|
23
47
|
<h2>{success ? 'Payment Successful' : 'Payment Failed'}</h2>
|
|
24
48
|
<p class="desc">
|
|
@@ -28,8 +52,10 @@
|
|
|
28
52
|
</p>
|
|
29
53
|
{#if success}
|
|
30
54
|
<button class="btn btn-primary" onclick={onDone}>Done</button>
|
|
31
|
-
{:else}
|
|
55
|
+
{:else if retryable}
|
|
32
56
|
<button class="btn btn-outline" onclick={onRetry}>Try Again</button>
|
|
57
|
+
{:else}
|
|
58
|
+
<button class="btn btn-outline" onclick={onDone}>Close</button>
|
|
33
59
|
{/if}
|
|
34
60
|
{/if}
|
|
35
61
|
</div>
|
|
@@ -69,6 +95,13 @@
|
|
|
69
95
|
color: var(--bw-color-error);
|
|
70
96
|
background: var(--bw-color-primary-light);
|
|
71
97
|
}
|
|
98
|
+
/* Deliberately its own amber, not success green or failure red - payment succeeded (so red reads as an
|
|
99
|
+
alarming false alarm) but nothing is confirmed yet (so green would be premature). */
|
|
100
|
+
.icon.pending {
|
|
101
|
+
border: 2px solid #B8860B;
|
|
102
|
+
color: #B8860B;
|
|
103
|
+
background: rgba(184, 134, 11, 0.08);
|
|
104
|
+
}
|
|
72
105
|
h2 {
|
|
73
106
|
font-size: 20px;
|
|
74
107
|
font-weight: 700;
|
|
@@ -81,4 +114,11 @@
|
|
|
81
114
|
max-width: 280px;
|
|
82
115
|
line-height: 1.5;
|
|
83
116
|
}
|
|
117
|
+
.actions {
|
|
118
|
+
display: flex;
|
|
119
|
+
flex-direction: column;
|
|
120
|
+
gap: 8px;
|
|
121
|
+
width: 100%;
|
|
122
|
+
max-width: 220px;
|
|
123
|
+
}
|
|
84
124
|
</style>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
interface Props {
|
|
3
|
+
remaining: string;
|
|
4
|
+
// False once the cart can be given no more time - its deadline is at its absolute ceiling (read off the
|
|
5
|
+
// cart's own instants, see CartExpiryGuard.svelte) - the prompt then only warns, it does not offer what it
|
|
6
|
+
// cannot deliver. A hold extension the supplier refuses is not this case: the guard closes the prompt
|
|
7
|
+
// for that deadline instead of showing a second message.
|
|
8
|
+
canExtend: boolean;
|
|
9
|
+
extending: boolean;
|
|
10
|
+
onExtend: () => void;
|
|
11
|
+
onDismiss: () => void;
|
|
12
|
+
}
|
|
13
|
+
let { remaining, canExtend, extending, onExtend, onDismiss }: Props = $props();
|
|
14
|
+
|
|
15
|
+
// Focused on mount so a keyboard or screen-reader user lands inside the dialog they have just been shown
|
|
16
|
+
// rather than wherever they happened to be on the merchant's page behind it.
|
|
17
|
+
let primaryButton = $state<HTMLButtonElement | null>(null);
|
|
18
|
+
$effect(() => {
|
|
19
|
+
primaryButton?.focus();
|
|
20
|
+
});
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<!-- No overlay/backdrop of its own - CartExpiryGuard.svelte owns the one shared page-wide overlay this and
|
|
24
|
+
CartExpiredView both render inside, so there is exactly one dimmed backdrop, never two stacking. -->
|
|
25
|
+
<div class="prompt-card" role="dialog" aria-modal="true" aria-labelledby="bw-still-there-title">
|
|
26
|
+
<h3 id="bw-still-there-title">Are you still there?</h3>
|
|
27
|
+
{#if canExtend}
|
|
28
|
+
<p>
|
|
29
|
+
Your cart will expire in <strong class="remaining">{remaining}</strong>. Let us know you're still here
|
|
30
|
+
and we'll hold your tickets a little longer.
|
|
31
|
+
</p>
|
|
32
|
+
<button class="btn btn-primary" bind:this={primaryButton} onclick={onExtend} disabled={extending}>
|
|
33
|
+
{extending ? 'One moment...' : "Yes, I'm still here"}
|
|
34
|
+
</button>
|
|
35
|
+
{:else}
|
|
36
|
+
<p>
|
|
37
|
+
Your cart will expire in <strong class="remaining">{remaining}</strong> and can't be held any longer.
|
|
38
|
+
Please complete your booking before then.
|
|
39
|
+
</p>
|
|
40
|
+
<button class="btn btn-primary" bind:this={primaryButton} onclick={onDismiss}>OK</button>
|
|
41
|
+
{/if}
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<style>
|
|
45
|
+
.prompt-card {
|
|
46
|
+
width: 100%;
|
|
47
|
+
max-width: 360px;
|
|
48
|
+
padding: 28px 24px;
|
|
49
|
+
background: var(--bw-color-bg);
|
|
50
|
+
border-radius: var(--bw-radius-lg);
|
|
51
|
+
box-shadow: var(--bw-shadow-3);
|
|
52
|
+
text-align: center;
|
|
53
|
+
}
|
|
54
|
+
h3 {
|
|
55
|
+
font-size: 18px;
|
|
56
|
+
font-weight: 700;
|
|
57
|
+
margin-bottom: 10px;
|
|
58
|
+
}
|
|
59
|
+
p {
|
|
60
|
+
font-size: 14px;
|
|
61
|
+
line-height: 1.5;
|
|
62
|
+
color: var(--bw-color-text-secondary);
|
|
63
|
+
margin-bottom: 20px;
|
|
64
|
+
}
|
|
65
|
+
.remaining {
|
|
66
|
+
color: var(--bw-color-text);
|
|
67
|
+
font-variant-numeric: tabular-nums;
|
|
68
|
+
}
|
|
69
|
+
.btn {
|
|
70
|
+
width: 100%;
|
|
71
|
+
}
|
|
72
|
+
</style>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { BookingApi } from './api';
|
|
3
|
-
import { ApiError } from './api';
|
|
3
|
+
import { ApiError, isPriceMismatch } from './api';
|
|
4
4
|
import type { WizardPages } from './config';
|
|
5
5
|
import { DEFAULT_WIZARD_PAGES } from './config';
|
|
6
6
|
import type { CartItem, CheckoutProductDto } from './client-types';
|
|
@@ -43,9 +43,11 @@
|
|
|
43
43
|
availabilityId: item.availabilityId,
|
|
44
44
|
localDate: item.localDate,
|
|
45
45
|
pickupPointId: item.pickupPointId,
|
|
46
|
+
// Currency and precision are the supplier's to state, not ours - the API resolves both server-side and
|
|
47
|
+
// ignores anything sent here. Amount stays, but only as a claim about the price the customer was shown:
|
|
48
|
+
// the server accepts it solely when it matches what the supplier is asking or a price the server itself
|
|
49
|
+
// published, and returns PRICE_MISMATCH otherwise.
|
|
46
50
|
amount: item.totalPrice,
|
|
47
|
-
currencyCode: item.currency,
|
|
48
|
-
currencyPrecision: item.currencyPrecision,
|
|
49
51
|
});
|
|
50
52
|
|
|
51
53
|
const total = formatCurrency(item.totalPrice, item.currency, item.currencyPrecision, 2);
|
|
@@ -72,6 +74,15 @@
|
|
|
72
74
|
try {
|
|
73
75
|
await addItemToCart(item);
|
|
74
76
|
} catch (e) {
|
|
77
|
+
// The supplier moved the price while the customer was configuring, and the amount they agreed to is no
|
|
78
|
+
// longer one the server accepts. Retrying is useless - the wizard still holds the old prices, so it
|
|
79
|
+
// would resubmit exactly the same amount and fail identically. The only way out is to re-fetch and let
|
|
80
|
+
// the customer see the new price, which is what the message asks them to do.
|
|
81
|
+
if (isPriceMismatch(e)) {
|
|
82
|
+
await reloadAfterPriceChange();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
75
86
|
// CartManager only knows a cart is expired from timestamps it saw at creation - it can't see the
|
|
76
87
|
// server's idle window sliding forward, so a cart that looks valid locally can still be rejected
|
|
77
88
|
// server-side. A 401/404 here means exactly that: drop the stale cart and retry once with a fresh one
|
|
@@ -91,6 +102,17 @@
|
|
|
91
102
|
isAddingToCart = false;
|
|
92
103
|
}
|
|
93
104
|
}
|
|
105
|
+
|
|
106
|
+
async function reloadAfterPriceChange() {
|
|
107
|
+
addToCartError = "This ticket's price has changed since you started. Please check the updated price and try again.";
|
|
108
|
+
try {
|
|
109
|
+
await load();
|
|
110
|
+
} catch {
|
|
111
|
+
// The reload is what makes the message actionable; if even that fails the customer needs to start over
|
|
112
|
+
// rather than be left looking at prices we already know are wrong.
|
|
113
|
+
addToCartError = "This ticket's price has changed and we could not load the new one. Please reload the page.";
|
|
114
|
+
}
|
|
115
|
+
}
|
|
94
116
|
</script>
|
|
95
117
|
|
|
96
118
|
<div class="bw-widget">
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
interface Props {
|
|
6
6
|
unit: CheckoutUnitDto;
|
|
7
7
|
quantity: number;
|
|
8
|
+
max?: number;
|
|
8
9
|
onChange: (qty: number) => void;
|
|
9
10
|
}
|
|
10
|
-
let { unit, quantity, onChange }: Props = $props();
|
|
11
|
+
let { unit, quantity, max, onChange }: Props = $props();
|
|
11
12
|
|
|
12
13
|
let priceLabel = $derived(() => {
|
|
13
14
|
if (!unit.pricing || unit.pricing.length === 0) return '';
|
|
@@ -15,11 +16,53 @@
|
|
|
15
16
|
return `from ${formatCurrency(p.retail, p.currency, p.currencyPrecision)}`;
|
|
16
17
|
});
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
let inputValue = $state(String(quantity));
|
|
20
|
+
let limitMessage = $state('');
|
|
21
|
+
|
|
22
|
+
$effect(() => {
|
|
23
|
+
inputValue = String(quantity);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function decrement() {
|
|
27
|
+
if (quantity > 0) onChange(quantity - 1);
|
|
28
|
+
}
|
|
19
29
|
|
|
20
30
|
function increment() {
|
|
31
|
+
if (max != null && quantity >= max) {
|
|
32
|
+
showLimitMessage();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
limitMessage = '';
|
|
21
36
|
onChange(quantity + 1);
|
|
22
37
|
}
|
|
38
|
+
|
|
39
|
+
function showLimitMessage() {
|
|
40
|
+
const label = unit.title ?? unit.id;
|
|
41
|
+
limitMessage = max === 0
|
|
42
|
+
? `No ${label} tickets left for this time slot.`
|
|
43
|
+
: `Only ${max} ${label} ticket${max === 1 ? '' : 's'} left for this time slot.`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function onInput() {
|
|
47
|
+
const digitsOnly = inputValue.replace(/[^0-9]/g, '');
|
|
48
|
+
if (digitsOnly !== inputValue) inputValue = digitsOnly;
|
|
49
|
+
limitMessage = '';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function onBlur() {
|
|
53
|
+
let parsed = inputValue === '' ? 0 : parseInt(inputValue, 10);
|
|
54
|
+
if (Number.isNaN(parsed)) parsed = 0;
|
|
55
|
+
if (max != null && parsed > max) {
|
|
56
|
+
parsed = max;
|
|
57
|
+
showLimitMessage();
|
|
58
|
+
}
|
|
59
|
+
inputValue = String(parsed);
|
|
60
|
+
if (parsed !== quantity) onChange(parsed);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function onFocus(event: Event) {
|
|
64
|
+
(event.currentTarget as HTMLInputElement).select();
|
|
65
|
+
}
|
|
23
66
|
</script>
|
|
24
67
|
|
|
25
68
|
<div class="card row">
|
|
@@ -31,10 +74,28 @@
|
|
|
31
74
|
</div>
|
|
32
75
|
<div class="stepper">
|
|
33
76
|
<button class="step-btn" onclick={decrement} disabled={quantity === 0} aria-label="Decrease">−</button>
|
|
34
|
-
<
|
|
35
|
-
|
|
77
|
+
<input
|
|
78
|
+
class="qty"
|
|
79
|
+
type="text"
|
|
80
|
+
inputmode="numeric"
|
|
81
|
+
pattern="[0-9]*"
|
|
82
|
+
bind:value={inputValue}
|
|
83
|
+
oninput={onInput}
|
|
84
|
+
onblur={onBlur}
|
|
85
|
+
onfocus={onFocus}
|
|
86
|
+
aria-label="{unit.title ?? unit.id} quantity"
|
|
87
|
+
/>
|
|
88
|
+
<button
|
|
89
|
+
class="step-btn"
|
|
90
|
+
onclick={increment}
|
|
91
|
+
disabled={max != null && quantity >= max}
|
|
92
|
+
aria-label="Increase"
|
|
93
|
+
>+</button>
|
|
36
94
|
</div>
|
|
37
95
|
</div>
|
|
96
|
+
{#if limitMessage}
|
|
97
|
+
<p class="limit-message">{limitMessage}</p>
|
|
98
|
+
{/if}
|
|
38
99
|
|
|
39
100
|
<style>
|
|
40
101
|
.row {
|
|
@@ -61,8 +122,8 @@
|
|
|
61
122
|
gap: 4px;
|
|
62
123
|
}
|
|
63
124
|
.step-btn {
|
|
64
|
-
width:
|
|
65
|
-
height:
|
|
125
|
+
width: 44px;
|
|
126
|
+
height: 44px;
|
|
66
127
|
border-radius: 50%;
|
|
67
128
|
background: transparent;
|
|
68
129
|
border: 1.5px solid var(--bw-color-border);
|
|
@@ -82,10 +143,25 @@
|
|
|
82
143
|
cursor: default;
|
|
83
144
|
}
|
|
84
145
|
.qty {
|
|
146
|
+
width: 64px;
|
|
147
|
+
height: 44px;
|
|
148
|
+
border-radius: 12px;
|
|
149
|
+
border: 1.5px solid var(--bw-color-border);
|
|
150
|
+
background: transparent;
|
|
151
|
+
color: var(--bw-color-text);
|
|
85
152
|
font-size: 17px;
|
|
86
|
-
font-weight:
|
|
87
|
-
min-width: 32px;
|
|
153
|
+
font-weight: 800;
|
|
88
154
|
text-align: center;
|
|
89
155
|
font-variant-numeric: tabular-nums;
|
|
156
|
+
font-family: inherit;
|
|
157
|
+
}
|
|
158
|
+
.qty:focus {
|
|
159
|
+
outline: none;
|
|
160
|
+
border-color: var(--bw-color-primary);
|
|
161
|
+
}
|
|
162
|
+
.limit-message {
|
|
163
|
+
margin: 4px 0 0;
|
|
164
|
+
font-size: 13px;
|
|
165
|
+
color: var(--bw-color-error);
|
|
90
166
|
}
|
|
91
167
|
</style>
|
|
@@ -318,6 +318,15 @@
|
|
|
318
318
|
markInvalidated('date', 'time');
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
+
// Once a slot is selected, its vacancy count is the live ceiling on how many of a given
|
|
322
|
+
// unit can be added - shared across all unit types, so it shrinks as other units are added.
|
|
323
|
+
function maxForUnit(unitId: string): number | undefined {
|
|
324
|
+
if (!selectedTimeSlot || selectedTimeSlot.vacancies == null) return undefined;
|
|
325
|
+
const totalQty = Object.values(unitQuantities).reduce((sum, q) => sum + q, 0);
|
|
326
|
+
const thisQty = unitQuantities[unitId] ?? 0;
|
|
327
|
+
return selectedTimeSlot.vacancies - totalQty + thisQty;
|
|
328
|
+
}
|
|
329
|
+
|
|
321
330
|
async function fetchAvailability() {
|
|
322
331
|
const optionId = effectiveOptionId();
|
|
323
332
|
if (!optionId) return;
|
|
@@ -499,6 +508,7 @@
|
|
|
499
508
|
<UnitCounter
|
|
500
509
|
{unit}
|
|
501
510
|
quantity={unitQuantities[unit.id] ?? 0}
|
|
511
|
+
max={maxForUnit(unit.id)}
|
|
502
512
|
onChange={(qty) => onUnitChanged(unit.id, qty)}
|
|
503
513
|
/>
|
|
504
514
|
{/each}
|
|
@@ -584,6 +594,7 @@
|
|
|
584
594
|
<UnitCounter
|
|
585
595
|
{unit}
|
|
586
596
|
quantity={unitQuantities[unit.id] ?? 0}
|
|
597
|
+
max={maxForUnit(unit.id)}
|
|
587
598
|
onChange={(qty) => onUnitChanged(unit.id, qty)}
|
|
588
599
|
/>
|
|
589
600
|
{/each}
|