@code-collective/booking-widget 1.0.13 → 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.
@@ -2,11 +2,12 @@
2
2
  import { onMount, onDestroy } from 'svelte';
3
3
  import { loadPeachSdk, loadedPeachSdkUrl } from './peach-sdk';
4
4
  import CountdownTimer from './CountdownTimer.svelte';
5
+ import type { PaymentSdkResult } from './client-types';
5
6
 
6
7
  interface Props {
7
8
  checkoutId: string;
8
9
  entityId: string;
9
- onPaymentComplete: (result: { status: string }) => void;
10
+ onPaymentComplete: (result: PaymentSdkResult) => void;
10
11
  // The cart's remaining life, shown in this dialog's own header so the shopper can see it over Peach's
11
12
  // form - CheckoutModal's own cart timer is behind this overlay. Optional so callers that predate it need
12
13
  // not pass one. CartExpiryGuard.svelte is what actually warns and acts as this counts down to zero - it
@@ -15,6 +16,18 @@
15
16
  }
16
17
  let { checkoutId, entityId, onPaymentComplete, expiresAt }: Props = $props();
17
18
 
19
+ // Peach does not document a stable shape for what its eventHandlers receive, and it differs between the
20
+ // callbacks. Anything we cannot find is simply omitted - the failure is still worth reporting without a
21
+ // code, and guessing one would be worse than sending none.
22
+ function resultOf(status: PaymentSdkResult['status'], payload: any): PaymentSdkResult {
23
+ const result = payload?.result ?? payload;
24
+ return {
25
+ status,
26
+ resultCode: result?.code ?? result?.resultCode ?? payload?.errorCode ?? undefined,
27
+ description: result?.description ?? payload?.message ?? undefined,
28
+ };
29
+ }
30
+
18
31
  let failed = $state(false);
19
32
  let errorMessage = $state('');
20
33
 
@@ -46,10 +59,14 @@
46
59
  // exactly one way out per screen instead of ours and Peach's competing for the same job.
47
60
  },
48
61
  eventHandlers: {
49
- onCompleted: () => { cleanup(); onPaymentComplete({ status: 'completed' }); },
50
- onCancelled: () => { cleanup(); onPaymentComplete({ status: 'cancelled' }); },
51
- onExpired: () => { cleanup(); onPaymentComplete({ status: 'expired' }); },
52
- onError: () => { reportPeachFailure('Peach reported an error rendering the card form'); cleanup(); onPaymentComplete({ status: 'error' }); },
62
+ onCompleted: (e: any) => { cleanup(); onPaymentComplete(resultOf('completed', e)); },
63
+ onCancelled: (e: any) => { cleanup(); onPaymentComplete(resultOf('cancelled', e)); },
64
+ onExpired: (e: any) => { cleanup(); onPaymentComplete(resultOf('expired', e)); },
65
+ onError: (e: any) => {
66
+ reportPeachFailure('Peach reported an error rendering the card form');
67
+ cleanup();
68
+ onPaymentComplete(resultOf('error', e));
69
+ },
53
70
  },
54
71
  });
55
72
 
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 },
@@ -0,0 +1,63 @@
1
+ // Pure view-model helpers for rendering a CheckoutCartDetailDto's items - split out of CheckoutModal.svelte
2
+ // because none of these hold or need reactive state of their own; they are plain functions of the item (and,
3
+ // for the ones that need titles/units, the already-loaded product catalog), called straight from the
4
+ // template on every render.
5
+
6
+ import type { CheckoutCartDetailDto, CheckoutCartItemDetailDto, CheckoutProductDto } from './client-types';
7
+
8
+ export function cartTotal(cart: CheckoutCartDetailDto): number {
9
+ return cart.items.reduce((s, i) => s + i.amount, 0);
10
+ }
11
+
12
+ // Every item in one cart shares a currency (PayCheckoutCartCommandHandler refuses to mix them), so the first
13
+ // item's is the cart's - defaulting to ZAR only for the moment before the first item exists.
14
+ export function cartCurrency(cart: CheckoutCartDetailDto): string {
15
+ return cart.items[0]?.currencyCode ?? 'ZAR';
16
+ }
17
+
18
+ export function productTitle(item: CheckoutCartItemDetailDto, productsById: Map<string, CheckoutProductDto>): string {
19
+ return productsById.get(item.productId)?.title ?? item.productId;
20
+ }
21
+
22
+ export function optionTitle(item: CheckoutCartItemDetailDto, productsById: Map<string, CheckoutProductDto>): string {
23
+ const product = productsById.get(item.productId);
24
+ return product?.options.find((o) => o.id === item.optionId)?.title ?? item.optionId;
25
+ }
26
+
27
+ export interface GroupedUnit {
28
+ unitId: string;
29
+ title: string;
30
+ quantity: number;
31
+ linePrice: number;
32
+ }
33
+
34
+ export function groupUnits(
35
+ item: CheckoutCartItemDetailDto,
36
+ productsById: Map<string, CheckoutProductDto>,
37
+ ): GroupedUnit[] {
38
+ const product = productsById.get(item.productId);
39
+ const option = product?.options.find((o) => o.id === item.optionId);
40
+ const counts = new Map<string, number>();
41
+ for (const u of item.unitItems) {
42
+ counts.set(u.unitId, (counts.get(u.unitId) ?? 0) + 1);
43
+ }
44
+ return [...counts.entries()].map(([unitId, quantity]) => {
45
+ const unit = option?.units.find((u) => u.id === unitId);
46
+ const unitPrice = unit?.pricing?.[0]?.retail ?? 0;
47
+ return {
48
+ unitId,
49
+ title: unit?.title ?? unitId,
50
+ quantity,
51
+ linePrice: unitPrice * quantity,
52
+ };
53
+ });
54
+ }
55
+
56
+ export function itemDateLabel(item: CheckoutCartItemDetailDto): string | null {
57
+ if (!item.availabilityId) return null;
58
+ const match = item.availabilityId.match(/(\d{4})-(\d{2})-(\d{2})/);
59
+ if (!match) return null;
60
+ const d = new Date(+match[1], +match[2] - 1, +match[3]);
61
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
62
+ return `${d.getDate()} ${months[d.getMonth()]} ${d.getFullYear()}`;
63
+ }