@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.
@@ -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
+ }
@@ -23,17 +23,21 @@ export type CheckoutCartConfirmItemResultDto = S['CheckoutCartConfirmItemResultD
23
23
  export type OctoContact = S['OctoContact'];
24
24
 
25
25
  // Client-side types
26
- export type PaymentOutcome = 'successful' | 'pending' | 'failed' | 'cancelled';
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'. A card that was actually charged - which is every 'failed' this
33
- // gateway produces once confirmation itself is what failed, not the payment - must never be offered a
34
- // "try again" that re-opens the card form, since the shopper would be charged a second time for a booking
35
- // that already has their money against it. Defaults true (the ordinary declined/expired/errored case,
36
- // where nothing was charged and retrying is exactly correct) so existing callers need not set it.
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