@code-collective/booking-widget 1.0.12 → 1.0.14

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +48 -46
  2. package/README.md +500 -500
  3. package/dist/booking-widget.css +1 -1
  4. package/dist/booking-widget.js +829 -707
  5. package/dist/booking-widget.min.js +24 -25
  6. package/dist/booking-widget.umd.cjs +5 -5
  7. package/package.json +58 -58
  8. package/src/lib/BookingProvider.svelte +21 -21
  9. package/src/lib/CartBar.svelte +26 -26
  10. package/src/lib/CartBarView.svelte +78 -78
  11. package/src/lib/CartExpiryGuard.svelte +416 -416
  12. package/src/lib/CartOverview.svelte +30 -30
  13. package/src/lib/CartOverviewButton.svelte +104 -104
  14. package/src/lib/Checkout.svelte +141 -141
  15. package/src/lib/CheckoutModal.css +179 -0
  16. package/src/lib/CheckoutModal.svelte +78 -573
  17. package/src/lib/CheckoutPanel.svelte +121 -121
  18. package/src/lib/PaymentPage.svelte +208 -191
  19. package/src/lib/ResultView.svelte +24 -4
  20. package/src/lib/TicketConfigurator.svelte +166 -166
  21. package/src/lib/api.ts +36 -5
  22. package/src/lib/booking-context.ts +33 -33
  23. package/src/lib/cart-overview.svelte.ts +97 -97
  24. package/src/lib/checkout-item-view.ts +63 -0
  25. package/src/lib/checkout-payment-flow.svelte.ts +523 -0
  26. package/src/lib/client-types.ts +22 -6
  27. package/src/lib/config.ts +162 -162
  28. package/src/lib/elements/bw-cart.svelte +49 -49
  29. package/src/lib/elements/bw-checkout.svelte +97 -97
  30. package/src/lib/elements/bw-configurator.svelte +72 -72
  31. package/src/lib/elements/register.ts +171 -171
  32. package/src/lib/elements/shared.ts +18 -18
  33. package/src/lib/generated-types.ts +94 -5
  34. package/src/lib/host.svelte.ts +336 -336
  35. package/src/lib/index.ts +242 -242
  36. package/src/lib/messages.ts +157 -157
  37. package/src/lib/peach-sdk.ts +86 -86
  38. 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>
package/src/lib/api.ts CHANGED
@@ -11,6 +11,7 @@ import type {
11
11
  CheckoutCartItemAddedDto,
12
12
  CheckoutCartPaymentInitiationDto,
13
13
  CheckoutCartConfirmResultDto,
14
+ CheckoutCartStatusDto,
14
15
  OctoContact,
15
16
  } from './client-types';
16
17
  import { dateKey } from './utils';
@@ -111,7 +112,16 @@ export interface BookingApi {
111
112
  updateCartItem(itemId: string, request: CheckoutAddCartItemDto): Promise<CheckoutCartItemAddedDto>;
112
113
  removeCartItem(itemId: string): Promise<void>;
113
114
  payCart(contact: OctoContact): Promise<CheckoutCartPaymentInitiationDto>;
114
- confirmCart(): Promise<CheckoutCartConfirmResultDto>;
115
+ // Where the payment actually stands, as decided server-side off Peach's webhook. There is deliberately no
116
+ // confirmCart here: confirmation that depends on this tab staying open strands every shopper who closes it
117
+ // after paying, so the widget only ever reads the outcome, it never produces one.
118
+ getCartStatus(): Promise<CheckoutCartStatusDto>;
119
+
120
+ // Asks the server to find out what actually happened to the current attempt. Called once when Peach's form
121
+ // reports the checkout complete - "complete" covers a declined card as much as a charged one, and Peach
122
+ // sends no webhook for a decline - and then getCartStatus is polled for the answer. Best effort: the server
123
+ // answers 204 whatever it found, and the sweep reconciles anything it could not.
124
+ resolvePayment(): Promise<void>;
115
125
  // Slides the cart's idle window out to a full window from now, clamped at its absolute ceiling, and
116
126
  // re-extends the OCTO holds behind it. There is no refusal to handle: a cart already at its ceiling comes
117
127
  // back with the deadline it already had (see isAtExpiryCeiling). Returns the cart with its new expiry so the
@@ -121,6 +131,11 @@ export interface BookingApi {
121
131
  // used by CheckoutModal itself, which knows the checkoutId Peach's own callback fired for. See
122
132
  // isPaymentSettled for the one refusal that changes what the caller does next.
123
133
  abandonPayment(checkoutId: string): Promise<void>;
134
+
135
+ // The SDK-failure counterpart to abandonPayment, carrying whatever reason Peach gave. Same guard and same
136
+ // refusals server-side - the difference is that the attempt is recorded as failed with its code rather than
137
+ // simply abandoned, which for a card declined inside the checkout is the only place that code ever exists.
138
+ reportPaymentFailure(checkoutId: string, resultCode?: string, description?: string): Promise<void>;
124
139
  }
125
140
 
126
141
  export class ApiClient implements BookingApi {
@@ -275,10 +290,15 @@ export class ApiClient implements BookingApi {
275
290
  );
276
291
  }
277
292
 
278
- async confirmCart(): Promise<CheckoutCartConfirmResultDto> {
279
- return this.unwrap(
280
- await this.client.POST('/v1/checkout/cart/confirm'),
281
- );
293
+ async getCartStatus(): Promise<CheckoutCartStatusDto> {
294
+ return this.unwrap(await this.client.GET('/v1/checkout/cart/status'));
295
+ }
296
+
297
+ async resolvePayment(): Promise<void> {
298
+ const result = await this.client.POST('/v1/checkout/cart/resolve-payment');
299
+ if (result.response.status >= 400) {
300
+ throw new ApiError(result.response.status, result.error);
301
+ }
282
302
  }
283
303
 
284
304
  async extendCart(): Promise<CheckoutCartDetailDto> {
@@ -287,6 +307,17 @@ export class ApiClient implements BookingApi {
287
307
  );
288
308
  }
289
309
 
310
+ async reportPaymentFailure(checkoutId: string, resultCode?: string, description?: string): Promise<void> {
311
+ const result = await this.client.POST('/v1/checkout/cart/payment-failure', {
312
+ body: { checkoutId, resultCode, description },
313
+ });
314
+ // Same shape as abandonPayment below, including keeping the error body: the 409s carry a code the caller
315
+ // branches on (see isPaymentSettled).
316
+ if (result.response.status >= 400) {
317
+ throw new ApiError(result.response.status, result.error);
318
+ }
319
+ }
320
+
290
321
  async abandonPayment(checkoutId: string): Promise<void> {
291
322
  const result = await this.client.POST('/v1/checkout/cart/abandon-payment', {
292
323
  body: { checkoutId },
@@ -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
+ }