@code-collective/booking-widget 1.0.12 → 1.0.13
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/CHANGELOG.md +48 -46
- package/README.md +500 -500
- package/dist/booking-widget.css +1 -1
- package/dist/booking-widget.js +581 -538
- package/dist/booking-widget.min.js +24 -22
- package/dist/booking-widget.umd.cjs +4 -4
- package/package.json +58 -58
- package/src/lib/BookingProvider.svelte +21 -21
- package/src/lib/CartBar.svelte +26 -26
- package/src/lib/CartBarView.svelte +78 -78
- package/src/lib/CartExpiryGuard.svelte +416 -416
- package/src/lib/CartOverview.svelte +30 -30
- package/src/lib/CartOverviewButton.svelte +104 -104
- package/src/lib/Checkout.svelte +141 -141
- package/src/lib/CheckoutModal.svelte +179 -71
- package/src/lib/CheckoutPanel.svelte +121 -121
- package/src/lib/PaymentPage.svelte +191 -191
- package/src/lib/ResultView.svelte +24 -4
- package/src/lib/TicketConfigurator.svelte +166 -166
- package/src/lib/booking-context.ts +33 -33
- package/src/lib/cart-overview.svelte.ts +97 -97
- package/src/lib/client-types.ts +10 -6
- package/src/lib/config.ts +162 -162
- package/src/lib/elements/bw-cart.svelte +49 -49
- package/src/lib/elements/bw-checkout.svelte +97 -97
- package/src/lib/elements/bw-configurator.svelte +72 -72
- package/src/lib/elements/register.ts +171 -171
- package/src/lib/elements/shared.ts +18 -18
- package/src/lib/host.svelte.ts +336 -336
- package/src/lib/index.ts +242 -242
- package/src/lib/messages.ts +157 -157
- package/src/lib/peach-sdk.ts +86 -86
- package/src/lib/portal.ts +23 -23
|
@@ -1,166 +1,166 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import type { BookingApi } from './api';
|
|
3
|
-
import { ApiError, isPriceMismatch } from './api';
|
|
4
|
-
import type { WizardPages } from './config';
|
|
5
|
-
import { DEFAULT_WIZARD_PAGES } from './config';
|
|
6
|
-
import type { CartItem, CheckoutProductDto } from './client-types';
|
|
7
|
-
import { CartManager } from './cart-manager';
|
|
8
|
-
import { postMessage } from './messages';
|
|
9
|
-
import { expandUnitItems } from './utils';
|
|
10
|
-
import { formatCurrency } from './currency';
|
|
11
|
-
import WizardPage from './WizardPage.svelte';
|
|
12
|
-
import { getBookingHostContext, requireBookingService } from './booking-context';
|
|
13
|
-
|
|
14
|
-
interface Props {
|
|
15
|
-
// Optional when a BookingProvider is above this component - it supplies both from its host.
|
|
16
|
-
api?: BookingApi;
|
|
17
|
-
cartManager?: CartManager;
|
|
18
|
-
productId: string;
|
|
19
|
-
wizardPages?: WizardPages;
|
|
20
|
-
autoSelectSingleTimeSlot?: boolean;
|
|
21
|
-
// Whether an add from this configurator asks to be carried on into checkout. Stated on the cart:change
|
|
22
|
-
// message rather than acted on here, so the host decides how to honour it - and so bw-configurator's
|
|
23
|
-
// no-auto-checkout attribute and a host's own autoOpenCheckout: false meet in the same branch (see
|
|
24
|
-
// host.svelte.ts) instead of each build having its own way to suppress it.
|
|
25
|
-
autoOpenCheckout?: boolean;
|
|
26
|
-
onCancel?: () => void;
|
|
27
|
-
}
|
|
28
|
-
let { api: apiProp, cartManager: cartManagerProp, productId, wizardPages = DEFAULT_WIZARD_PAGES,
|
|
29
|
-
autoSelectSingleTimeSlot = false, autoOpenCheckout = true, onCancel }: Props = $props();
|
|
30
|
-
|
|
31
|
-
const bookingHost = getBookingHostContext();
|
|
32
|
-
let api = $derived(requireBookingService(apiProp ?? bookingHost?.api, 'TicketConfigurator', 'api'));
|
|
33
|
-
let cartManager = $derived(
|
|
34
|
-
requireBookingService(cartManagerProp ?? bookingHost?.cartManager, 'TicketConfigurator', 'cartManager'),
|
|
35
|
-
);
|
|
36
|
-
|
|
37
|
-
let product = $state<CheckoutProductDto | null>(null);
|
|
38
|
-
let isLoading = $state(true);
|
|
39
|
-
let isAddingToCart = $state(false);
|
|
40
|
-
let addToCartError = $state<string | null>(null);
|
|
41
|
-
|
|
42
|
-
async function load() {
|
|
43
|
-
product = await api.getProduct(productId);
|
|
44
|
-
isLoading = false;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
load();
|
|
48
|
-
|
|
49
|
-
async function addItemToCart(item: CartItem): Promise<void> {
|
|
50
|
-
await cartManager.ensureCart();
|
|
51
|
-
|
|
52
|
-
const unitItems = expandUnitItems(item.units);
|
|
53
|
-
const added = await api.addCartItem({
|
|
54
|
-
productId: item.productId,
|
|
55
|
-
optionId: item.optionId,
|
|
56
|
-
unitItems,
|
|
57
|
-
availabilityId: item.availabilityId,
|
|
58
|
-
localDate: item.localDate,
|
|
59
|
-
pickupPointId: item.pickupPointId,
|
|
60
|
-
// Currency and precision are the supplier's to state, not ours - the API resolves both server-side and
|
|
61
|
-
// ignores anything sent here. Amount stays, but only as a claim about the price the customer was shown:
|
|
62
|
-
// the server accepts it solely when it matches what the supplier is asking or a price the server itself
|
|
63
|
-
// published, and returns PRICE_MISMATCH otherwise.
|
|
64
|
-
amount: item.totalPrice,
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
const total = formatCurrency(item.totalPrice, item.currency, item.currencyPrecision, 2);
|
|
68
|
-
postMessage({
|
|
69
|
-
type: 'cart:change',
|
|
70
|
-
itemCount: unitItems.length,
|
|
71
|
-
cartItemId: added.id ?? '',
|
|
72
|
-
totalFormatted: total,
|
|
73
|
-
openCheckout: autoOpenCheckout,
|
|
74
|
-
});
|
|
75
|
-
// Separate from cart:change above - that one is per-item-added detail forwarded to consumers as the
|
|
76
|
-
// public bw:cart-change event. This one carries the fresh cart itself: CartBar/CartOverviewButton/Checkout
|
|
77
|
-
// use it to update their own state without each independently re-fetching, and it's forwarded to
|
|
78
|
-
// consumers as the public bw:cart-updated event/onCartUpdated callback - see this event's own doc comment
|
|
79
|
-
// in CheckoutModal (the other place it's posted from, after an edit/remove).
|
|
80
|
-
const cart = await api.getCart().catch(() => null);
|
|
81
|
-
postMessage({ type: 'cart:updated', cart });
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async function onAddToCart(item: CartItem) {
|
|
85
|
-
isAddingToCart = true;
|
|
86
|
-
addToCartError = null;
|
|
87
|
-
|
|
88
|
-
try {
|
|
89
|
-
await addItemToCart(item);
|
|
90
|
-
} catch (e) {
|
|
91
|
-
// The supplier moved the price while the customer was configuring, and the amount they agreed to is no
|
|
92
|
-
// longer one the server accepts. Retrying is useless - the wizard still holds the old prices, so it
|
|
93
|
-
// would resubmit exactly the same amount and fail identically. The only way out is to re-fetch and let
|
|
94
|
-
// the customer see the new price, which is what the message asks them to do.
|
|
95
|
-
if (isPriceMismatch(e)) {
|
|
96
|
-
await reloadAfterPriceChange();
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// CartManager only knows a cart is expired from timestamps it saw at creation - it can't see the
|
|
101
|
-
// server's idle window sliding forward, so a cart that looks valid locally can still be rejected
|
|
102
|
-
// server-side. A 401/404 here means exactly that: drop the stale cart and retry once with a fresh one
|
|
103
|
-
// before giving up, rather than leaving the user stuck with no feedback and no way to proceed.
|
|
104
|
-
const isStaleCart = e instanceof ApiError && (e.status === 401 || e.status === 404);
|
|
105
|
-
if (isStaleCart) {
|
|
106
|
-
cartManager.reset();
|
|
107
|
-
try {
|
|
108
|
-
await addItemToCart(item);
|
|
109
|
-
} catch {
|
|
110
|
-
addToCartError = 'Something went wrong adding this to your cart. Please try again.';
|
|
111
|
-
}
|
|
112
|
-
} else {
|
|
113
|
-
addToCartError = 'Something went wrong adding this to your cart. Please try again.';
|
|
114
|
-
}
|
|
115
|
-
} finally {
|
|
116
|
-
isAddingToCart = false;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
async function reloadAfterPriceChange() {
|
|
121
|
-
addToCartError = "This ticket's price has changed since you started. Please check the updated price and try again.";
|
|
122
|
-
try {
|
|
123
|
-
await load();
|
|
124
|
-
} catch {
|
|
125
|
-
// The reload is what makes the message actionable; if even that fails the customer needs to start over
|
|
126
|
-
// rather than be left looking at prices we already know are wrong.
|
|
127
|
-
addToCartError = "This ticket's price has changed and we could not load the new one. Please reload the page.";
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
</script>
|
|
131
|
-
|
|
132
|
-
<div class="bw-widget">
|
|
133
|
-
{#if isLoading || isAddingToCart}
|
|
134
|
-
<div class="loading-center" style="height:100vh">
|
|
135
|
-
<div class="spinner"></div>
|
|
136
|
-
</div>
|
|
137
|
-
{:else if product}
|
|
138
|
-
{#if addToCartError}
|
|
139
|
-
<p class="add-to-cart-error">{addToCartError}</p>
|
|
140
|
-
{/if}
|
|
141
|
-
<WizardPage
|
|
142
|
-
{product}
|
|
143
|
-
{api}
|
|
144
|
-
{wizardPages}
|
|
145
|
-
{autoSelectSingleTimeSlot}
|
|
146
|
-
{onCancel}
|
|
147
|
-
onComplete={onAddToCart}
|
|
148
|
-
/>
|
|
149
|
-
{/if}
|
|
150
|
-
</div>
|
|
151
|
-
|
|
152
|
-
<style>
|
|
153
|
-
/* display: contents - a plain box here would break WizardPage's own .wizard{height:100%}, which needs
|
|
154
|
-
to resolve against this component's real parent, not an unsized wrapper inserted in between. */
|
|
155
|
-
.bw-widget {
|
|
156
|
-
display: contents;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
.add-to-cart-error {
|
|
160
|
-
margin: 0;
|
|
161
|
-
padding: 12px 16px;
|
|
162
|
-
background: #fdecea;
|
|
163
|
-
color: #b3261e;
|
|
164
|
-
font-size: 14px;
|
|
165
|
-
}
|
|
166
|
-
</style>
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { BookingApi } from './api';
|
|
3
|
+
import { ApiError, isPriceMismatch } from './api';
|
|
4
|
+
import type { WizardPages } from './config';
|
|
5
|
+
import { DEFAULT_WIZARD_PAGES } from './config';
|
|
6
|
+
import type { CartItem, CheckoutProductDto } from './client-types';
|
|
7
|
+
import { CartManager } from './cart-manager';
|
|
8
|
+
import { postMessage } from './messages';
|
|
9
|
+
import { expandUnitItems } from './utils';
|
|
10
|
+
import { formatCurrency } from './currency';
|
|
11
|
+
import WizardPage from './WizardPage.svelte';
|
|
12
|
+
import { getBookingHostContext, requireBookingService } from './booking-context';
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
// Optional when a BookingProvider is above this component - it supplies both from its host.
|
|
16
|
+
api?: BookingApi;
|
|
17
|
+
cartManager?: CartManager;
|
|
18
|
+
productId: string;
|
|
19
|
+
wizardPages?: WizardPages;
|
|
20
|
+
autoSelectSingleTimeSlot?: boolean;
|
|
21
|
+
// Whether an add from this configurator asks to be carried on into checkout. Stated on the cart:change
|
|
22
|
+
// message rather than acted on here, so the host decides how to honour it - and so bw-configurator's
|
|
23
|
+
// no-auto-checkout attribute and a host's own autoOpenCheckout: false meet in the same branch (see
|
|
24
|
+
// host.svelte.ts) instead of each build having its own way to suppress it.
|
|
25
|
+
autoOpenCheckout?: boolean;
|
|
26
|
+
onCancel?: () => void;
|
|
27
|
+
}
|
|
28
|
+
let { api: apiProp, cartManager: cartManagerProp, productId, wizardPages = DEFAULT_WIZARD_PAGES,
|
|
29
|
+
autoSelectSingleTimeSlot = false, autoOpenCheckout = true, onCancel }: Props = $props();
|
|
30
|
+
|
|
31
|
+
const bookingHost = getBookingHostContext();
|
|
32
|
+
let api = $derived(requireBookingService(apiProp ?? bookingHost?.api, 'TicketConfigurator', 'api'));
|
|
33
|
+
let cartManager = $derived(
|
|
34
|
+
requireBookingService(cartManagerProp ?? bookingHost?.cartManager, 'TicketConfigurator', 'cartManager'),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
let product = $state<CheckoutProductDto | null>(null);
|
|
38
|
+
let isLoading = $state(true);
|
|
39
|
+
let isAddingToCart = $state(false);
|
|
40
|
+
let addToCartError = $state<string | null>(null);
|
|
41
|
+
|
|
42
|
+
async function load() {
|
|
43
|
+
product = await api.getProduct(productId);
|
|
44
|
+
isLoading = false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
load();
|
|
48
|
+
|
|
49
|
+
async function addItemToCart(item: CartItem): Promise<void> {
|
|
50
|
+
await cartManager.ensureCart();
|
|
51
|
+
|
|
52
|
+
const unitItems = expandUnitItems(item.units);
|
|
53
|
+
const added = await api.addCartItem({
|
|
54
|
+
productId: item.productId,
|
|
55
|
+
optionId: item.optionId,
|
|
56
|
+
unitItems,
|
|
57
|
+
availabilityId: item.availabilityId,
|
|
58
|
+
localDate: item.localDate,
|
|
59
|
+
pickupPointId: item.pickupPointId,
|
|
60
|
+
// Currency and precision are the supplier's to state, not ours - the API resolves both server-side and
|
|
61
|
+
// ignores anything sent here. Amount stays, but only as a claim about the price the customer was shown:
|
|
62
|
+
// the server accepts it solely when it matches what the supplier is asking or a price the server itself
|
|
63
|
+
// published, and returns PRICE_MISMATCH otherwise.
|
|
64
|
+
amount: item.totalPrice,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const total = formatCurrency(item.totalPrice, item.currency, item.currencyPrecision, 2);
|
|
68
|
+
postMessage({
|
|
69
|
+
type: 'cart:change',
|
|
70
|
+
itemCount: unitItems.length,
|
|
71
|
+
cartItemId: added.id ?? '',
|
|
72
|
+
totalFormatted: total,
|
|
73
|
+
openCheckout: autoOpenCheckout,
|
|
74
|
+
});
|
|
75
|
+
// Separate from cart:change above - that one is per-item-added detail forwarded to consumers as the
|
|
76
|
+
// public bw:cart-change event. This one carries the fresh cart itself: CartBar/CartOverviewButton/Checkout
|
|
77
|
+
// use it to update their own state without each independently re-fetching, and it's forwarded to
|
|
78
|
+
// consumers as the public bw:cart-updated event/onCartUpdated callback - see this event's own doc comment
|
|
79
|
+
// in CheckoutModal (the other place it's posted from, after an edit/remove).
|
|
80
|
+
const cart = await api.getCart().catch(() => null);
|
|
81
|
+
postMessage({ type: 'cart:updated', cart });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function onAddToCart(item: CartItem) {
|
|
85
|
+
isAddingToCart = true;
|
|
86
|
+
addToCartError = null;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
await addItemToCart(item);
|
|
90
|
+
} catch (e) {
|
|
91
|
+
// The supplier moved the price while the customer was configuring, and the amount they agreed to is no
|
|
92
|
+
// longer one the server accepts. Retrying is useless - the wizard still holds the old prices, so it
|
|
93
|
+
// would resubmit exactly the same amount and fail identically. The only way out is to re-fetch and let
|
|
94
|
+
// the customer see the new price, which is what the message asks them to do.
|
|
95
|
+
if (isPriceMismatch(e)) {
|
|
96
|
+
await reloadAfterPriceChange();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// CartManager only knows a cart is expired from timestamps it saw at creation - it can't see the
|
|
101
|
+
// server's idle window sliding forward, so a cart that looks valid locally can still be rejected
|
|
102
|
+
// server-side. A 401/404 here means exactly that: drop the stale cart and retry once with a fresh one
|
|
103
|
+
// before giving up, rather than leaving the user stuck with no feedback and no way to proceed.
|
|
104
|
+
const isStaleCart = e instanceof ApiError && (e.status === 401 || e.status === 404);
|
|
105
|
+
if (isStaleCart) {
|
|
106
|
+
cartManager.reset();
|
|
107
|
+
try {
|
|
108
|
+
await addItemToCart(item);
|
|
109
|
+
} catch {
|
|
110
|
+
addToCartError = 'Something went wrong adding this to your cart. Please try again.';
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
addToCartError = 'Something went wrong adding this to your cart. Please try again.';
|
|
114
|
+
}
|
|
115
|
+
} finally {
|
|
116
|
+
isAddingToCart = false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function reloadAfterPriceChange() {
|
|
121
|
+
addToCartError = "This ticket's price has changed since you started. Please check the updated price and try again.";
|
|
122
|
+
try {
|
|
123
|
+
await load();
|
|
124
|
+
} catch {
|
|
125
|
+
// The reload is what makes the message actionable; if even that fails the customer needs to start over
|
|
126
|
+
// rather than be left looking at prices we already know are wrong.
|
|
127
|
+
addToCartError = "This ticket's price has changed and we could not load the new one. Please reload the page.";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
</script>
|
|
131
|
+
|
|
132
|
+
<div class="bw-widget">
|
|
133
|
+
{#if isLoading || isAddingToCart}
|
|
134
|
+
<div class="loading-center" style="height:100vh">
|
|
135
|
+
<div class="spinner"></div>
|
|
136
|
+
</div>
|
|
137
|
+
{:else if product}
|
|
138
|
+
{#if addToCartError}
|
|
139
|
+
<p class="add-to-cart-error">{addToCartError}</p>
|
|
140
|
+
{/if}
|
|
141
|
+
<WizardPage
|
|
142
|
+
{product}
|
|
143
|
+
{api}
|
|
144
|
+
{wizardPages}
|
|
145
|
+
{autoSelectSingleTimeSlot}
|
|
146
|
+
{onCancel}
|
|
147
|
+
onComplete={onAddToCart}
|
|
148
|
+
/>
|
|
149
|
+
{/if}
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<style>
|
|
153
|
+
/* display: contents - a plain box here would break WizardPage's own .wizard{height:100%}, which needs
|
|
154
|
+
to resolve against this component's real parent, not an unsized wrapper inserted in between. */
|
|
155
|
+
.bw-widget {
|
|
156
|
+
display: contents;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.add-to-cart-error {
|
|
160
|
+
margin: 0;
|
|
161
|
+
padding: 12px 16px;
|
|
162
|
+
background: #fdecea;
|
|
163
|
+
color: #b3261e;
|
|
164
|
+
font-size: 14px;
|
|
165
|
+
}
|
|
166
|
+
</style>
|
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
// How a BookingProvider hands the host down to the components beneath it, so a consumer writes
|
|
2
|
-
// <TicketConfigurator {productId} /> rather than restating host.api and host.cartManager on every tag. The
|
|
3
|
-
// props are still there and still win - the custom-element wrappers pass them explicitly, because each
|
|
4
|
-
// bw-* element is the root of its own Svelte tree and context does not cross that boundary.
|
|
5
|
-
|
|
6
|
-
import { getContext, setContext } from 'svelte';
|
|
7
|
-
import type { BookingHost } from './host.svelte';
|
|
8
|
-
|
|
9
|
-
const BOOKING_HOST = Symbol('booking-host');
|
|
10
|
-
|
|
11
|
-
/** Call during component initialisation - BookingProvider is the only thing that should need this. */
|
|
12
|
-
export function setBookingHostContext(host: BookingHost): void {
|
|
13
|
-
setContext(BOOKING_HOST, host);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** The host from the nearest BookingProvider, or undefined when there is none. */
|
|
17
|
-
export function getBookingHostContext(): BookingHost | undefined {
|
|
18
|
-
return getContext<BookingHost | undefined>(BOOKING_HOST);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Resolves a service a component cannot render without, and says plainly what to do when it is missing.
|
|
23
|
-
* Without this the failure is a `Cannot read properties of undefined` from somewhere deep in a load().
|
|
24
|
-
*/
|
|
25
|
-
export function requireBookingService<T>(value: T | undefined, component: string, prop: string): T {
|
|
26
|
-
if (value === undefined || value === null) {
|
|
27
|
-
throw new Error(
|
|
28
|
-
`<${component}> has no ${prop}. Pass ${prop}={...}, or wrap it in ` +
|
|
29
|
-
`<BookingProvider host={createBookingHost(...)}>.`,
|
|
30
|
-
);
|
|
31
|
-
}
|
|
32
|
-
return value;
|
|
33
|
-
}
|
|
1
|
+
// How a BookingProvider hands the host down to the components beneath it, so a consumer writes
|
|
2
|
+
// <TicketConfigurator {productId} /> rather than restating host.api and host.cartManager on every tag. The
|
|
3
|
+
// props are still there and still win - the custom-element wrappers pass them explicitly, because each
|
|
4
|
+
// bw-* element is the root of its own Svelte tree and context does not cross that boundary.
|
|
5
|
+
|
|
6
|
+
import { getContext, setContext } from 'svelte';
|
|
7
|
+
import type { BookingHost } from './host.svelte';
|
|
8
|
+
|
|
9
|
+
const BOOKING_HOST = Symbol('booking-host');
|
|
10
|
+
|
|
11
|
+
/** Call during component initialisation - BookingProvider is the only thing that should need this. */
|
|
12
|
+
export function setBookingHostContext(host: BookingHost): void {
|
|
13
|
+
setContext(BOOKING_HOST, host);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The host from the nearest BookingProvider, or undefined when there is none. */
|
|
17
|
+
export function getBookingHostContext(): BookingHost | undefined {
|
|
18
|
+
return getContext<BookingHost | undefined>(BOOKING_HOST);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolves a service a component cannot render without, and says plainly what to do when it is missing.
|
|
23
|
+
* Without this the failure is a `Cannot read properties of undefined` from somewhere deep in a load().
|
|
24
|
+
*/
|
|
25
|
+
export function requireBookingService<T>(value: T | undefined, component: string, prop: string): T {
|
|
26
|
+
if (value === undefined || value === null) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`<${component}> has no ${prop}. Pass ${prop}={...}, or wrap it in ` +
|
|
29
|
+
`<BookingProvider host={createBookingHost(...)}>.`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
@@ -1,97 +1,97 @@
|
|
|
1
|
-
// The cart state behind both CartOverview presentations. Extracted because the bar and the button had the
|
|
2
|
-
// same load-then-follow-cart:updated logic written twice, and both answered a failed fetch the same wrong
|
|
3
|
-
// way: `cart = null`, rendering exactly what an empty cart renders - nothing at all. On a narrow layout the
|
|
4
|
-
// bar is the only route to checkout, so "no DOM" has to mean something a consumer can act on.
|
|
5
|
-
|
|
6
|
-
import type { BookingApi } from './api';
|
|
7
|
-
import type { CartManager } from './cart-manager';
|
|
8
|
-
import type { CartOverviewState } from './config';
|
|
9
|
-
import type { CheckoutCartDetailDto } from './client-types';
|
|
10
|
-
import { onWidgetMessage } from './messages';
|
|
11
|
-
|
|
12
|
-
export interface CartOverview {
|
|
13
|
-
readonly cart: CheckoutCartDetailDto | null;
|
|
14
|
-
readonly state: CartOverviewState;
|
|
15
|
-
/** Re-reads the cart. What the error presentation's retry is wired to. */
|
|
16
|
-
readonly reload: () => void;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Call during component initialisation - it registers an $effect that follows cart:updated for the
|
|
21
|
-
* component's lifetime.
|
|
22
|
-
*/
|
|
23
|
-
export function createCartOverview(
|
|
24
|
-
// Accessors rather than values: these arrive as props, and reading them through a closure keeps this
|
|
25
|
-
// following the current ones rather than pinning whichever pair happened to be passed on first render.
|
|
26
|
-
getApi: () => BookingApi,
|
|
27
|
-
getCartManager: () => CartManager,
|
|
28
|
-
onStateChange?: (state: CartOverviewState) => void,
|
|
29
|
-
): CartOverview {
|
|
30
|
-
let cart = $state<CheckoutCartDetailDto | null>(null);
|
|
31
|
-
let failed = $state(false);
|
|
32
|
-
let isLoading = $state(true);
|
|
33
|
-
// Only the newest load may write. A read still in flight when a fresher cart arrives - from a retry, or
|
|
34
|
-
// from cart:updated after an add elsewhere on the page - would otherwise land afterwards and overwrite it,
|
|
35
|
-
// and a transient failure landing late would replace a perfectly good cart summary with the error view.
|
|
36
|
-
let currentLoad = 0;
|
|
37
|
-
|
|
38
|
-
async function load() {
|
|
39
|
-
const thisLoad = ++currentLoad;
|
|
40
|
-
isLoading = true;
|
|
41
|
-
failed = false;
|
|
42
|
-
// No token held means there is genuinely nothing to fetch - an empty cart, not a failure.
|
|
43
|
-
if (!getCartManager().hasCart) {
|
|
44
|
-
cart = null;
|
|
45
|
-
isLoading = false;
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
try {
|
|
49
|
-
const loaded = await getApi().getCart();
|
|
50
|
-
if (thisLoad !== currentLoad) return;
|
|
51
|
-
cart = loaded;
|
|
52
|
-
} catch {
|
|
53
|
-
if (thisLoad !== currentLoad) return;
|
|
54
|
-
cart = null;
|
|
55
|
-
failed = true;
|
|
56
|
-
}
|
|
57
|
-
isLoading = false;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
void load();
|
|
61
|
-
|
|
62
|
-
// load() only runs once - without this, adding/editing/removing an item elsewhere on the page (the
|
|
63
|
-
// configurator, or the checkout modal's own edit/remove) leaves this showing a stale total. The event
|
|
64
|
-
// already carries the fresh cart (see TicketConfigurator/CheckoutModal's own posting sites), so it is
|
|
65
|
-
// applied directly rather than triggering a second, redundant getCart() call.
|
|
66
|
-
$effect(() =>
|
|
67
|
-
onWidgetMessage((d) => {
|
|
68
|
-
if (d.type === 'cart:updated') {
|
|
69
|
-
// Supersedes any read still in flight - this cart is newer than whatever it will return.
|
|
70
|
-
currentLoad += 1;
|
|
71
|
-
cart = d.cart;
|
|
72
|
-
// A cart arriving by message is a cart successfully read somewhere else on the page, so whatever
|
|
73
|
-
// this one's own fetch ran into is no longer the current answer.
|
|
74
|
-
failed = false;
|
|
75
|
-
isLoading = false;
|
|
76
|
-
}
|
|
77
|
-
}),
|
|
78
|
-
);
|
|
79
|
-
|
|
80
|
-
const state = $derived<CartOverviewState>(
|
|
81
|
-
isLoading ? 'loading' : failed ? 'error' : (cart?.items?.length ?? 0) > 0 ? 'ready' : 'empty',
|
|
82
|
-
);
|
|
83
|
-
|
|
84
|
-
$effect(() => {
|
|
85
|
-
onStateChange?.(state);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
return {
|
|
89
|
-
get cart() {
|
|
90
|
-
return cart;
|
|
91
|
-
},
|
|
92
|
-
get state() {
|
|
93
|
-
return state;
|
|
94
|
-
},
|
|
95
|
-
reload: () => void load(),
|
|
96
|
-
};
|
|
97
|
-
}
|
|
1
|
+
// The cart state behind both CartOverview presentations. Extracted because the bar and the button had the
|
|
2
|
+
// same load-then-follow-cart:updated logic written twice, and both answered a failed fetch the same wrong
|
|
3
|
+
// way: `cart = null`, rendering exactly what an empty cart renders - nothing at all. On a narrow layout the
|
|
4
|
+
// bar is the only route to checkout, so "no DOM" has to mean something a consumer can act on.
|
|
5
|
+
|
|
6
|
+
import type { BookingApi } from './api';
|
|
7
|
+
import type { CartManager } from './cart-manager';
|
|
8
|
+
import type { CartOverviewState } from './config';
|
|
9
|
+
import type { CheckoutCartDetailDto } from './client-types';
|
|
10
|
+
import { onWidgetMessage } from './messages';
|
|
11
|
+
|
|
12
|
+
export interface CartOverview {
|
|
13
|
+
readonly cart: CheckoutCartDetailDto | null;
|
|
14
|
+
readonly state: CartOverviewState;
|
|
15
|
+
/** Re-reads the cart. What the error presentation's retry is wired to. */
|
|
16
|
+
readonly reload: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Call during component initialisation - it registers an $effect that follows cart:updated for the
|
|
21
|
+
* component's lifetime.
|
|
22
|
+
*/
|
|
23
|
+
export function createCartOverview(
|
|
24
|
+
// Accessors rather than values: these arrive as props, and reading them through a closure keeps this
|
|
25
|
+
// following the current ones rather than pinning whichever pair happened to be passed on first render.
|
|
26
|
+
getApi: () => BookingApi,
|
|
27
|
+
getCartManager: () => CartManager,
|
|
28
|
+
onStateChange?: (state: CartOverviewState) => void,
|
|
29
|
+
): CartOverview {
|
|
30
|
+
let cart = $state<CheckoutCartDetailDto | null>(null);
|
|
31
|
+
let failed = $state(false);
|
|
32
|
+
let isLoading = $state(true);
|
|
33
|
+
// Only the newest load may write. A read still in flight when a fresher cart arrives - from a retry, or
|
|
34
|
+
// from cart:updated after an add elsewhere on the page - would otherwise land afterwards and overwrite it,
|
|
35
|
+
// and a transient failure landing late would replace a perfectly good cart summary with the error view.
|
|
36
|
+
let currentLoad = 0;
|
|
37
|
+
|
|
38
|
+
async function load() {
|
|
39
|
+
const thisLoad = ++currentLoad;
|
|
40
|
+
isLoading = true;
|
|
41
|
+
failed = false;
|
|
42
|
+
// No token held means there is genuinely nothing to fetch - an empty cart, not a failure.
|
|
43
|
+
if (!getCartManager().hasCart) {
|
|
44
|
+
cart = null;
|
|
45
|
+
isLoading = false;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const loaded = await getApi().getCart();
|
|
50
|
+
if (thisLoad !== currentLoad) return;
|
|
51
|
+
cart = loaded;
|
|
52
|
+
} catch {
|
|
53
|
+
if (thisLoad !== currentLoad) return;
|
|
54
|
+
cart = null;
|
|
55
|
+
failed = true;
|
|
56
|
+
}
|
|
57
|
+
isLoading = false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
void load();
|
|
61
|
+
|
|
62
|
+
// load() only runs once - without this, adding/editing/removing an item elsewhere on the page (the
|
|
63
|
+
// configurator, or the checkout modal's own edit/remove) leaves this showing a stale total. The event
|
|
64
|
+
// already carries the fresh cart (see TicketConfigurator/CheckoutModal's own posting sites), so it is
|
|
65
|
+
// applied directly rather than triggering a second, redundant getCart() call.
|
|
66
|
+
$effect(() =>
|
|
67
|
+
onWidgetMessage((d) => {
|
|
68
|
+
if (d.type === 'cart:updated') {
|
|
69
|
+
// Supersedes any read still in flight - this cart is newer than whatever it will return.
|
|
70
|
+
currentLoad += 1;
|
|
71
|
+
cart = d.cart;
|
|
72
|
+
// A cart arriving by message is a cart successfully read somewhere else on the page, so whatever
|
|
73
|
+
// this one's own fetch ran into is no longer the current answer.
|
|
74
|
+
failed = false;
|
|
75
|
+
isLoading = false;
|
|
76
|
+
}
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const state = $derived<CartOverviewState>(
|
|
81
|
+
isLoading ? 'loading' : failed ? 'error' : (cart?.items?.length ?? 0) > 0 ? 'ready' : 'empty',
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
$effect(() => {
|
|
85
|
+
onStateChange?.(state);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
get cart() {
|
|
90
|
+
return cart;
|
|
91
|
+
},
|
|
92
|
+
get state() {
|
|
93
|
+
return state;
|
|
94
|
+
},
|
|
95
|
+
reload: () => void load(),
|
|
96
|
+
};
|
|
97
|
+
}
|
package/src/lib/client-types.ts
CHANGED
|
@@ -23,17 +23,21 @@ export type CheckoutCartConfirmItemResultDto = S['CheckoutCartConfirmItemResultD
|
|
|
23
23
|
export type OctoContact = S['OctoContact'];
|
|
24
24
|
|
|
25
25
|
// Client-side types
|
|
26
|
-
|
|
26
|
+
// 'partial' is its own outcome rather than a kind of 'failed' because the shopper's card WAS charged: the
|
|
27
|
+
// payment succeeded and only some of the booking's items could be confirmed afterwards. It gets a heading of
|
|
28
|
+
// its own in ResultView, since "Payment Failed" printed over the words "Your payment succeeded" invites
|
|
29
|
+
// exactly the wrong reaction - paying again elsewhere, or charging back a payment that did go through.
|
|
30
|
+
export type PaymentOutcome = 'successful' | 'pending' | 'partial' | 'failed' | 'cancelled';
|
|
27
31
|
|
|
28
32
|
export interface PaymentStatus {
|
|
29
33
|
outcome: PaymentOutcome;
|
|
30
34
|
resultCode?: string;
|
|
31
35
|
resultDescription?: string;
|
|
32
|
-
// Only meaningful for outcome 'failed'
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
36
|
+
// Only meaningful for outcome 'failed', and only for a 'failed' that nonetheless took the shopper's money:
|
|
37
|
+
// it must never be offered a "try again" that re-opens the card form, since they would be charged a second
|
|
38
|
+
// time for a booking that already has their money against it. Defaults true (the ordinary declined/expired/
|
|
39
|
+
// errored case, where nothing was charged and retrying is exactly correct) so existing callers need not set
|
|
40
|
+
// it. The confirm-partial case this was first introduced for now carries its own 'partial' outcome.
|
|
37
41
|
retryPayment?: boolean;
|
|
38
42
|
}
|
|
39
43
|
|