@code-collective/booking-widget 1.0.8 → 1.0.10
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 +19 -1
- package/dist/booking-widget.css +1 -1
- package/dist/booking-widget.js +897 -681
- package/dist/booking-widget.min.js +16 -11
- package/dist/booking-widget.umd.cjs +3 -2
- package/package.json +6 -2
- 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 +2 -5
- package/src/lib/Checkout.svelte +43 -1
- 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 -652
- package/src/lib/CountdownTimer.svelte +2 -4
- package/src/lib/PaymentPage.svelte +20 -1
- package/src/lib/StillTherePrompt.svelte +72 -0
- package/src/lib/UnitCounter.svelte +84 -8
- package/src/lib/WizardPage.svelte +11 -0
- package/src/lib/api.ts +69 -0
- package/src/lib/cart-expiry.ts +46 -0
- package/src/lib/cart-manager.ts +4 -0
- package/src/lib/elements/register.ts +30 -5
- package/src/lib/generated-types.ts +132 -3
- package/src/lib/index.ts +7 -0
- package/src/lib/messages.ts +77 -63
- 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,13 +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
|
entityId: string;
|
|
8
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;
|
|
9
15
|
}
|
|
10
|
-
let { checkoutId, entityId, onPaymentComplete }: Props = $props();
|
|
16
|
+
let { checkoutId, entityId, onPaymentComplete, expiresAt }: Props = $props();
|
|
11
17
|
|
|
12
18
|
let failed = $state(false);
|
|
13
19
|
let errorMessage = $state('');
|
|
@@ -76,6 +82,10 @@
|
|
|
76
82
|
<div class="payment-dialog" onclick={(e) => e.stopPropagation()}>
|
|
77
83
|
<div class="page-header">
|
|
78
84
|
<h2>Payment</h2>
|
|
85
|
+
{#if expiresAt}
|
|
86
|
+
<span class="spacer"></span>
|
|
87
|
+
<CountdownTimer {expiresAt} />
|
|
88
|
+
{/if}
|
|
79
89
|
</div>
|
|
80
90
|
|
|
81
91
|
<div class="body">
|
|
@@ -84,6 +94,12 @@
|
|
|
84
94
|
<div class="error-icon">!</div>
|
|
85
95
|
<p class="error-title">Unable to load payment form</p>
|
|
86
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>
|
|
87
103
|
</div>
|
|
88
104
|
{:else}
|
|
89
105
|
<div id="peach-container"></div>
|
|
@@ -155,4 +171,7 @@
|
|
|
155
171
|
font-size: 14px;
|
|
156
172
|
color: var(--bw-color-text-secondary);
|
|
157
173
|
}
|
|
174
|
+
.error-state .btn {
|
|
175
|
+
margin-top: 16px;
|
|
176
|
+
}
|
|
158
177
|
</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>
|
|
@@ -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}
|
package/src/lib/api.ts
CHANGED
|
@@ -41,6 +41,49 @@ export function isPriceMismatch(e: unknown): e is ApiError {
|
|
|
41
41
|
return e instanceof ApiError && e.status === 400 && e.code === 'PRICE_MISMATCH';
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// The cart token the widget holds no longer names a live cart - it expired (idle or absolute) or was never
|
|
45
|
+
// valid. Every cart route answers this the same way, and the only recovery is a fresh cart.
|
|
46
|
+
export function isInvalidCart(e: unknown): e is ApiError {
|
|
47
|
+
return e instanceof ApiError && e.status === 401;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The cart is past the point of being modified or extended - a payment is in flight for it or already done
|
|
51
|
+
// (CheckoutCartEndpoints.CartLockedResult). Unlike the other 409s the API sends, this one carries no code.
|
|
52
|
+
export function isCartLocked(e: unknown): e is ApiError {
|
|
53
|
+
return e instanceof ApiError && e.status === 409 && e.code === undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// extendCart was refused because the cart's one clock has already run out. Only ever answered while a payment
|
|
57
|
+
// is in flight - anywhere else an expired cart is a plain 401 - because such a cart still validates so its
|
|
58
|
+
// charge can be confirmed, yet its time is up. Not a 401 on purpose: the widget must not drop the cart while
|
|
59
|
+
// a charge may be landing, but tear the attempt down through abandonPayment (Peach's real status permitting).
|
|
60
|
+
export function isCartExpired(e: unknown): e is ApiError {
|
|
61
|
+
return e instanceof ApiError && e.status === 409 && e.code === 'CART_EXPIRED';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// abandonPayment was refused because Peach's webhook already settled this attempt - most likely as paid. The
|
|
65
|
+
// shopper's money is against the cart, so the right move is to confirm it, not to report it expired.
|
|
66
|
+
export function isPaymentSettled(e: unknown): e is ApiError {
|
|
67
|
+
return e instanceof ApiError && e.status === 409 && e.code === 'PAYMENT_SETTLED';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// abandonPayment was refused because Peach still has this attempt in flight (a 3-D Secure check or similar has
|
|
71
|
+
// not reached a final outcome). Not paid yet, but a charge may still land, so the cart must not be reopened -
|
|
72
|
+
// wait for the outcome instead. Peach times a pending attempt out within about 30 minutes, after which
|
|
73
|
+
// abandoning may be accepted again.
|
|
74
|
+
export function isPaymentPending(e: unknown): e is ApiError {
|
|
75
|
+
return e instanceof ApiError && e.status === 409 && e.code === 'PAYMENT_PENDING';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// confirmCart was refused because Peach's webhook already landed with a decline (or other non-success
|
|
79
|
+
// outcome) for this attempt - there is no charge to confirm and waiting longer will not change that. Distinct
|
|
80
|
+
// from the plain 402 confirmCart otherwise answers with (not paid yet, keep retrying while the webhook is
|
|
81
|
+
// still in flight): without this, a declined card retries the same way a slow-but-live one does, and only
|
|
82
|
+
// ever surfaces as "still confirming", never as the decline it actually was.
|
|
83
|
+
export function isPaymentFailed(e: unknown): e is ApiError {
|
|
84
|
+
return e instanceof ApiError && e.status === 409 && e.code === 'PAYMENT_FAILED';
|
|
85
|
+
}
|
|
86
|
+
|
|
44
87
|
export interface BookingApi {
|
|
45
88
|
sessionToken: string;
|
|
46
89
|
cartToken: string;
|
|
@@ -69,6 +112,15 @@ export interface BookingApi {
|
|
|
69
112
|
removeCartItem(itemId: string): Promise<void>;
|
|
70
113
|
payCart(contact: OctoContact): Promise<CheckoutCartPaymentInitiationDto>;
|
|
71
114
|
confirmCart(): Promise<CheckoutCartConfirmResultDto>;
|
|
115
|
+
// Slides the cart's idle window out to a full window from now, clamped at its absolute ceiling, and
|
|
116
|
+
// re-extends the OCTO holds behind it. There is no refusal to handle: a cart already at its ceiling comes
|
|
117
|
+
// back with the deadline it already had (see isAtExpiryCeiling). Returns the cart with its new expiry so the
|
|
118
|
+
// countdown resumes from the server's numbers rather than a local guess.
|
|
119
|
+
extendCart(): Promise<CheckoutCartDetailDto>;
|
|
120
|
+
// Reports a specific Peach attempt dead without an outcome so the server reopens the cart for a new one -
|
|
121
|
+
// used by CheckoutModal itself, which knows the checkoutId Peach's own callback fired for. See
|
|
122
|
+
// isPaymentSettled for the one refusal that changes what the caller does next.
|
|
123
|
+
abandonPayment(checkoutId: string): Promise<void>;
|
|
72
124
|
}
|
|
73
125
|
|
|
74
126
|
export class ApiClient implements BookingApi {
|
|
@@ -228,4 +280,21 @@ export class ApiClient implements BookingApi {
|
|
|
228
280
|
await this.client.POST('/v1/checkout/cart/confirm'),
|
|
229
281
|
);
|
|
230
282
|
}
|
|
283
|
+
|
|
284
|
+
async extendCart(): Promise<CheckoutCartDetailDto> {
|
|
285
|
+
return this.unwrap(
|
|
286
|
+
await this.client.POST('/v1/checkout/cart/extend'),
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async abandonPayment(checkoutId: string): Promise<void> {
|
|
291
|
+
const result = await this.client.POST('/v1/checkout/cart/abandon-payment', {
|
|
292
|
+
body: { checkoutId },
|
|
293
|
+
});
|
|
294
|
+
// 204 on success, so there is no data to unwrap - same shape as removeCartItem. The error body is kept:
|
|
295
|
+
// the 409s here carry a code the caller branches on (see isPaymentSettled).
|
|
296
|
+
if (result.response.status >= 400) {
|
|
297
|
+
throw new ApiError(result.response.status, result.error);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
231
300
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { CheckoutCartDetailDto } from './client-types';
|
|
2
|
+
|
|
3
|
+
// Whether the "are you still there?" prompt - and with it the shopper's ability to extend the cart - is
|
|
4
|
+
// offered at all. Switched off for now: the cart gets one flat window (CheckoutCartService.IdleDuration,
|
|
5
|
+
// 20 minutes), the countdown shows on every step, and at 0:00 the shopper sees "Your cart has expired" and
|
|
6
|
+
// starts again. The prompt, the extend call and everything the server does for them stay in place behind
|
|
7
|
+
// this flag so a later change can turn them back on without rebuilding any of it. CartExpiryGuard takes it
|
|
8
|
+
// as a prop defaulting to this, so tests can exercise the prompt with it on.
|
|
9
|
+
export const EXTENSION_ENABLED = false;
|
|
10
|
+
|
|
11
|
+
// How long before the deadline the page-wide "are you still shopping?" prompt appears, when it is enabled at
|
|
12
|
+
// all (EXTENSION_ENABLED above). The one source of truth for the lead time - the guard's prompt reads it from
|
|
13
|
+
// here. Three minutes against the server's 20-minute idle window (CheckoutCartService.IdleDuration) would put
|
|
14
|
+
// the prompt at 17 minutes on a fresh cart, with the cart expiring at 20 if it went unanswered.
|
|
15
|
+
export const EXPIRY_WARNING_MS = 3 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
export function formatRemaining(ms: number): string {
|
|
18
|
+
const clamped = Math.max(0, ms);
|
|
19
|
+
const mins = Math.floor(clamped / 60000);
|
|
20
|
+
const secs = Math.floor((clamped % 60000) / 1000);
|
|
21
|
+
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The cart's own clock: one standard idle window that only answering the "still there?" prompt slides forward
|
|
25
|
+
// (adding or editing an item deliberately do not, and paying only holds it to a floor so the card form always
|
|
26
|
+
// has time for card entry - CheckoutCartService.PaymentWindowFloor), clamped at the absolute ceiling from
|
|
27
|
+
// creation (see CheckoutCartService's own remarks). idleExpiresAt is therefore always the sooner of the two.
|
|
28
|
+
export function cartDeadline(cart: CheckoutCartDetailDto): Date {
|
|
29
|
+
return new Date(instant(cart.idleExpiresAt));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Whether the idle window has been slid as far as the absolute ceiling allows, so answering the prompt again
|
|
33
|
+
// cannot buy any more time. Derived from the two instants the cart already carries rather than from a failed
|
|
34
|
+
// request: the server has no refusal to report, it simply hands back the same deadline.
|
|
35
|
+
export function isAtExpiryCeiling(cart: CheckoutCartDetailDto): boolean {
|
|
36
|
+
return instant(cart.idleExpiresAt) >= instant(cart.absoluteExpiresAt);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The latest instant a Date can hold - "no deadline". The API always sends idleExpiresAt; the generated type
|
|
40
|
+
// only marks it optional because of its nullability defaults, so this is a type-level fallback, not a case the
|
|
41
|
+
// server produces.
|
|
42
|
+
const NoDeadline = 8.64e15;
|
|
43
|
+
|
|
44
|
+
function instant(iso: string | null | undefined): number {
|
|
45
|
+
return iso ? new Date(iso).getTime() : NoDeadline;
|
|
46
|
+
}
|
package/src/lib/cart-manager.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CheckoutCartResult } from './client-types';
|
|
2
2
|
import type { BookingApi } from './api';
|
|
3
|
+
import { forgetPaymentAttempt } from './payment-attempt';
|
|
3
4
|
|
|
4
5
|
const STORAGE_KEY = 'morii-checkout-cart';
|
|
5
6
|
|
|
@@ -58,6 +59,8 @@ export class CartManager {
|
|
|
58
59
|
} catch {
|
|
59
60
|
// storage unavailable (e.g. iframe sandbox, or a browser blocking site data)
|
|
60
61
|
}
|
|
62
|
+
// A Peach attempt only ever belongs to the cart it was started for.
|
|
63
|
+
forgetPaymentAttempt();
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
private isExpired(): boolean {
|
|
@@ -86,6 +89,7 @@ export class CartManager {
|
|
|
86
89
|
const stored: StoredCart = JSON.parse(raw);
|
|
87
90
|
if (new Date(stored.absoluteExpiresAt).getTime() <= Date.now()) {
|
|
88
91
|
storage?.removeItem(STORAGE_KEY);
|
|
92
|
+
forgetPaymentAttempt();
|
|
89
93
|
return;
|
|
90
94
|
}
|
|
91
95
|
|
|
@@ -5,6 +5,8 @@ import { SessionManager } from '../session-manager';
|
|
|
5
5
|
import { CartManager } from '../cart-manager';
|
|
6
6
|
import { defaultApiBaseUrl } from './env';
|
|
7
7
|
import { onWidgetMessage } from '../messages';
|
|
8
|
+
import { mount } from 'svelte';
|
|
9
|
+
import CartExpiryGuard from '../CartExpiryGuard.svelte';
|
|
8
10
|
|
|
9
11
|
// Bootstrap shared services BEFORE element imports trigger connectedCallback.
|
|
10
12
|
// Elements read from window.__bwServices instead of importing shared.ts,
|
|
@@ -18,6 +20,30 @@ sessionManager.startBackgroundRefresh();
|
|
|
18
20
|
const ready = sessionManager.ensureSession(checkoutKey).then(() => {});
|
|
19
21
|
(window as any).__bwServices = { api, cartManager, ready };
|
|
20
22
|
|
|
23
|
+
function whenDomReady(fn: () => void) {
|
|
24
|
+
if (document.readyState === 'loading') {
|
|
25
|
+
document.addEventListener('DOMContentLoaded', fn, { once: true });
|
|
26
|
+
} else {
|
|
27
|
+
fn();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Mounted once, directly on <body> - not inside any <bw-*> element's own tree, so the "still shopping?"
|
|
32
|
+
// warning and the expiry it can lead to work regardless of which combination of elements (if any include
|
|
33
|
+
// <bw-checkout> at all) the merchant actually placed on this page. Session readiness is irrelevant to it -
|
|
34
|
+
// cart operations only need the cart token, never the session token - so it does not wait on `ready`.
|
|
35
|
+
//
|
|
36
|
+
// Deferred to DOMContentLoaded rather than run inline: a <script> placed in <head> without defer/type=module
|
|
37
|
+
// runs before <body> exists at all, and document.body would be null here. An exception at module level would
|
|
38
|
+
// also skip the cart:updated forwarding and autoWire below, not just the expiry guard.
|
|
39
|
+
function mountExpiryGuard() {
|
|
40
|
+
const guardHost = document.createElement('div');
|
|
41
|
+
guardHost.dataset.bwExpiryGuard = '';
|
|
42
|
+
document.body.appendChild(guardHost);
|
|
43
|
+
mount(CartExpiryGuard, { target: guardHost, props: { api, cartManager } });
|
|
44
|
+
}
|
|
45
|
+
whenDomReady(mountExpiryGuard);
|
|
46
|
+
|
|
21
47
|
import './bw-configurator.svelte';
|
|
22
48
|
import './bw-cart.svelte';
|
|
23
49
|
import './bw-checkout.svelte';
|
|
@@ -31,6 +57,9 @@ onWidgetMessage((d) => {
|
|
|
31
57
|
if (d.type === 'cart:updated' && 'cart' in d) {
|
|
32
58
|
window.dispatchEvent(new CustomEvent('bw:cart-updated', { detail: { cart: d.cart } }));
|
|
33
59
|
}
|
|
60
|
+
if (d.type === 'cart:expired') {
|
|
61
|
+
window.dispatchEvent(new CustomEvent('bw:cart-expired'));
|
|
62
|
+
}
|
|
34
63
|
});
|
|
35
64
|
|
|
36
65
|
interface BwOptions {
|
|
@@ -164,8 +193,4 @@ function autoWire() {
|
|
|
164
193
|
});
|
|
165
194
|
}
|
|
166
195
|
|
|
167
|
-
|
|
168
|
-
document.addEventListener('DOMContentLoaded', autoWire);
|
|
169
|
-
} else {
|
|
170
|
-
autoWire();
|
|
171
|
-
}
|
|
196
|
+
whenDomReady(autoWire);
|