@ticketboothapp/booking 1.2.151 → 1.2.152

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.151",
3
+ "version": "1.2.152",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -57,6 +57,7 @@ export function AdminChangeCheckoutDialogs({
57
57
  currency={currency}
58
58
  loading={loading}
59
59
  error={error}
60
+ reservationExpiration={adminChoiceData?.reservationExpiration}
60
61
  onPayNow={onPayNow}
61
62
  onConfirmWithoutPayment={onConfirmWithoutPayment}
62
63
  onCancel={onAdminPaymentChoiceCancel}
@@ -1,7 +1,13 @@
1
1
  'use client';
2
2
 
3
+ import { useEffect, useState } from 'react';
3
4
  import { formatCurrencyAmount } from '../../lib/currency';
4
5
  import type { Currency } from './CurrencySwitcher';
6
+ import {
7
+ formatReservationHoldTime,
8
+ reservationHoldSecondsRemaining,
9
+ RESERVATION_HOLD_EXPIRED_MESSAGE,
10
+ } from './reservation-hold';
5
11
 
6
12
  interface AdminPaymentChoiceModalProps {
7
13
  open: boolean;
@@ -9,6 +15,7 @@ interface AdminPaymentChoiceModalProps {
9
15
  currency: Currency;
10
16
  loading: boolean;
11
17
  error: string;
18
+ reservationExpiration?: string;
12
19
  onPayNow: () => void;
13
20
  onConfirmWithoutPayment: () => void;
14
21
  onCancel: () => void;
@@ -27,14 +34,27 @@ export function AdminPaymentChoiceModal({
27
34
  currency,
28
35
  loading,
29
36
  error,
37
+ reservationExpiration,
30
38
  onPayNow,
31
39
  onConfirmWithoutPayment,
32
40
  onCancel,
33
41
  description,
34
42
  payNowLabel,
35
43
  }: AdminPaymentChoiceModalProps) {
44
+ const [nowMs, setNowMs] = useState(() => Date.now());
45
+
46
+ useEffect(() => {
47
+ if (!open || !reservationExpiration) return;
48
+ setNowMs(Date.now());
49
+ const interval = window.setInterval(() => setNowMs(Date.now()), 1000);
50
+ return () => window.clearInterval(interval);
51
+ }, [open, reservationExpiration]);
52
+
36
53
  if (!open) return null;
37
54
 
55
+ const secondsRemaining = reservationHoldSecondsRemaining(reservationExpiration, nowMs);
56
+ const expired = secondsRemaining !== null && secondsRemaining <= 0;
57
+
38
58
  const modal = (
39
59
  <div
40
60
  className="booking-flow-root booking-flow-preflight fixed inset-0 z-[10050] flex items-center justify-center p-4 bg-black/50 pointer-events-auto"
@@ -68,6 +88,32 @@ export function AdminPaymentChoiceModal({
68
88
  </div>
69
89
 
70
90
  <div className="p-6 flex flex-col gap-3 flex-1 min-h-0">
91
+ {secondsRemaining !== null ? (
92
+ <div
93
+ className={`rounded-lg border p-3 text-sm ${
94
+ expired
95
+ ? 'border-red-200 bg-red-50 text-red-800'
96
+ : 'border-emerald-200 bg-emerald-50 text-emerald-800'
97
+ }`}
98
+ role={expired ? 'alert' : 'status'}
99
+ aria-live="polite"
100
+ >
101
+ {expired ? (
102
+ <>
103
+ <p className="font-semibold">Reservation hold expired</p>
104
+ <p className="mt-1">{RESERVATION_HOLD_EXPIRED_MESSAGE}</p>
105
+ </>
106
+ ) : (
107
+ <>
108
+ <p className="font-semibold">
109
+ Reservation held for {formatReservationHoldTime(secondsRemaining)}
110
+ </p>
111
+ <p className="mt-1">Complete payment or confirm the booking before the hold expires.</p>
112
+ </>
113
+ )}
114
+ </div>
115
+ ) : null}
116
+
71
117
  {error ? (
72
118
  <p className="text-sm text-red-600" role="alert">
73
119
  {error}
@@ -77,7 +123,7 @@ export function AdminPaymentChoiceModal({
77
123
  <button
78
124
  type="button"
79
125
  onClick={onPayNow}
80
- disabled={loading}
126
+ disabled={loading || expired}
81
127
  className="w-full py-3 px-4 bg-emerald-600 text-white font-semibold rounded-lg hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
82
128
  >
83
129
  {loading ? 'Loading...' : `${payNowLabel ?? 'Pay now'} (${formatCurrencyAmount(totalAmount, currency)})`}
@@ -85,7 +131,7 @@ export function AdminPaymentChoiceModal({
85
131
  <button
86
132
  type="button"
87
133
  onClick={onConfirmWithoutPayment}
88
- disabled={loading}
134
+ disabled={loading || expired}
89
135
  className="w-full py-3 px-4 border border-stone-300 text-stone-700 rounded-lg hover:bg-stone-50 font-medium disabled:opacity-50 disabled:cursor-not-allowed"
90
136
  >
91
137
  Confirm without payment
@@ -96,7 +142,7 @@ export function AdminPaymentChoiceModal({
96
142
  disabled={loading}
97
143
  className="w-full py-2 text-sm text-stone-500 hover:text-stone-700 font-medium disabled:opacity-50"
98
144
  >
99
- Cancel
145
+ {expired ? 'Close and restart checkout' : 'Cancel'}
100
146
  </button>
101
147
  </div>
102
148
  </div>
@@ -57,6 +57,7 @@ export function PrivateShuttleCheckoutDialogs({
57
57
  currency={currency}
58
58
  loading={loading}
59
59
  error={error}
60
+ reservationExpiration={adminChoiceData?.reservationExpiration}
60
61
  description={
61
62
  adminChoiceData?.isDepositPayment
62
63
  ? 'Pay the deposit now, or confirm without payment. The customer can pay the deposit or remaining balance from the Manage Booking page.'
@@ -57,6 +57,7 @@ export function StandardBookingCheckoutDialogs({
57
57
  currency={currency}
58
58
  loading={loading}
59
59
  error={error}
60
+ reservationExpiration={adminChoiceData?.reservationExpiration}
60
61
  onPayNow={onPayNow}
61
62
  onConfirmWithoutPayment={onConfirmWithoutPayment}
62
63
  onCancel={onAdminPaymentChoiceCancel}
@@ -47,6 +47,10 @@ import {
47
47
  type PrivateShuttleCheckoutModalData,
48
48
  } from './private-shuttle-payment-choice-runner';
49
49
  import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
50
+ import {
51
+ reservationHoldHasExpired,
52
+ RESERVATION_HOLD_EXPIRED_MESSAGE,
53
+ } from './reservation-hold';
50
54
 
51
55
  export interface UsePrivateShuttleCheckoutControllerParams {
52
56
  selectedOption: string;
@@ -676,6 +680,10 @@ export function usePrivateShuttleCheckoutController({
676
680
  const handleConfirmWithoutPayment = async () => {
677
681
  const choice = adminChoiceData;
678
682
  if (!choice) return;
683
+ if (reservationHoldHasExpired(choice.reservationExpiration)) {
684
+ setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
685
+ return;
686
+ }
679
687
  setLoading(true);
680
688
  setError('');
681
689
  try {
@@ -718,6 +726,10 @@ export function usePrivateShuttleCheckoutController({
718
726
  const handlePayNow = () => {
719
727
  const choice = adminChoiceData;
720
728
  if (!choice) return;
729
+ if (reservationHoldHasExpired(choice.reservationExpiration)) {
730
+ setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
731
+ return;
732
+ }
721
733
  setShowAdminPaymentChoice(false);
722
734
  setCheckoutClientSecret(choice.clientSecret);
723
735
  setCheckoutModalData(buildPrivateShuttlePayNowCheckoutModalData(choice, lastName.trim()));
@@ -726,6 +738,7 @@ export function usePrivateShuttleCheckoutController({
726
738
  };
727
739
 
728
740
  const handleAdminPaymentChoiceCancel = () => {
741
+ cancelPendingReservation();
729
742
  setShowAdminPaymentChoice(false);
730
743
  setAdminChoiceData(null);
731
744
  setError('');
@@ -0,0 +1,27 @@
1
+ export const RESERVATION_HOLD_EXPIRED_MESSAGE =
2
+ 'This reservation hold has expired. Close this window and restart checkout to get current availability and pricing.';
3
+
4
+ export function reservationHoldSecondsRemaining(
5
+ reservationExpiration: string | undefined,
6
+ nowMs: number = Date.now(),
7
+ ): number | null {
8
+ if (!reservationExpiration) return null;
9
+ const expirationMs = Date.parse(reservationExpiration);
10
+ if (!Number.isFinite(expirationMs)) return 0;
11
+ return Math.max(0, Math.ceil((expirationMs - nowMs) / 1000));
12
+ }
13
+
14
+ export function reservationHoldHasExpired(
15
+ reservationExpiration: string | undefined,
16
+ nowMs: number = Date.now(),
17
+ ): boolean {
18
+ const remaining = reservationHoldSecondsRemaining(reservationExpiration, nowMs);
19
+ return remaining !== null && remaining <= 0;
20
+ }
21
+
22
+ export function formatReservationHoldTime(seconds: number): string {
23
+ const safeSeconds = Math.max(0, Math.floor(seconds));
24
+ const minutes = Math.floor(safeSeconds / 60);
25
+ const remainder = safeSeconds % 60;
26
+ return `${minutes}:${String(remainder).padStart(2, '0')}`;
27
+ }
@@ -42,6 +42,10 @@ import {
42
42
  type StandardCheckoutModalData,
43
43
  } from './standard-booking-payment-choice-runner';
44
44
  import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
45
+ import {
46
+ reservationHoldHasExpired,
47
+ RESERVATION_HOLD_EXPIRED_MESSAGE,
48
+ } from './reservation-hold';
45
49
 
46
50
  export interface UseStandardBookingCheckoutControllerParams {
47
51
  selectedAvailability: Availability | null;
@@ -590,6 +594,10 @@ export function useStandardBookingCheckoutController({
590
594
  const handleConfirmWithoutPayment = async () => {
591
595
  const choice = adminChoiceData;
592
596
  if (!choice) return;
597
+ if (reservationHoldHasExpired(choice.reservationExpiration)) {
598
+ setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
599
+ return;
600
+ }
593
601
  setLoading(true);
594
602
  setError('');
595
603
  try {
@@ -634,6 +642,10 @@ export function useStandardBookingCheckoutController({
634
642
  const handlePayNow = () => {
635
643
  const choice = adminChoiceData;
636
644
  if (!choice) return;
645
+ if (reservationHoldHasExpired(choice.reservationExpiration)) {
646
+ setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
647
+ return;
648
+ }
637
649
  setShowAdminPaymentChoice(false);
638
650
  setCheckoutClientSecret(choice.clientSecret);
639
651
  setCheckoutModalData(buildStandardPayNowCheckoutModalData(choice, lastName.trim()));
@@ -642,6 +654,7 @@ export function useStandardBookingCheckoutController({
642
654
  };
643
655
 
644
656
  const handleAdminPaymentChoiceCancel = () => {
657
+ cancelPendingReservation();
645
658
  setShowAdminPaymentChoice(false);
646
659
  setAdminChoiceData(null);
647
660
  setError('');
@@ -62,6 +62,10 @@ import {
62
62
  confirmFreeAdminCustomerChange,
63
63
  quoteAdminCustomerChangeForCheckout,
64
64
  } from './admin-change-customer-quote-runner';
65
+ import {
66
+ reservationHoldHasExpired,
67
+ RESERVATION_HOLD_EXPIRED_MESSAGE,
68
+ } from './reservation-hold';
65
69
 
66
70
  type AdminCustomReceiptLine = { label: string; amountInput: string; amountSign?: number };
67
71
 
@@ -690,6 +694,10 @@ export function useAdminChangeCheckoutController({
690
694
 
691
695
  const handleConfirmWithoutPayment = async () => {
692
696
  if (!adminChoiceData) return;
697
+ if (reservationHoldHasExpired(adminChoiceData.reservationExpiration)) {
698
+ setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
699
+ return;
700
+ }
693
701
  setLoading(true);
694
702
  setError('');
695
703
  try {
@@ -771,6 +779,10 @@ export function useAdminChangeCheckoutController({
771
779
 
772
780
  const handlePayNow = () => {
773
781
  if (!adminChoiceData) return;
782
+ if (reservationHoldHasExpired(adminChoiceData.reservationExpiration)) {
783
+ setError(RESERVATION_HOLD_EXPIRED_MESSAGE);
784
+ return;
785
+ }
774
786
  setShowAdminPaymentChoice(false);
775
787
  setCheckoutClientSecret(adminChoiceData.clientSecret);
776
788
  setCheckoutModalData(buildAdminChangePayNowCheckoutModalData(adminChoiceData, lastName));
@@ -787,10 +799,11 @@ export function useAdminChangeCheckoutController({
787
799
  }, []);
788
800
 
789
801
  const handleAdminPaymentChoiceCancel = useCallback(() => {
802
+ cancelPendingReservation();
790
803
  setShowAdminPaymentChoice(false);
791
804
  setAdminChoiceData(null);
792
805
  setError('');
793
- }, [setError]);
806
+ }, [cancelPendingReservation, setError]);
794
807
 
795
808
  return {
796
809
  showCheckoutModal,
@@ -68,6 +68,9 @@ function getUserFacingMessage(endpoint: string): string {
68
68
  return 'Unable to hold your booking right now. Please try again.';
69
69
  case '/checkout/payment-intent':
70
70
  return 'Unable to continue to payment right now. Please try again.';
71
+ case '/checkout/confirm-booking-without-payment':
72
+ case '/1/partner/confirm-booking-without-payment':
73
+ return 'Unable to confirm this booking right now. Please try again or restart checkout.';
71
74
  default:
72
75
  return 'Something went wrong while loading booking details. Please try again.';
73
76
  }
@@ -186,7 +189,11 @@ function createUserError(
186
189
  bookingApiErrorCode?: string
187
190
  ): BookingClientError {
188
191
  const supportCode = buildSupportCode(endpoint, errorClass);
189
- const userMessage = `${getUserFacingMessage(endpoint)} (${supportCode})`;
192
+ const userMessage = bookingApiErrorCode === 'RESERVATION_EXPIRED'
193
+ ? 'This reservation hold has expired. Close this window and restart checkout to get current availability and pricing.'
194
+ : bookingApiErrorCode === 'RESERVATION_NOT_ACTIVE'
195
+ ? 'This reservation has already been completed or closed. Refresh before trying again.'
196
+ : `${getUserFacingMessage(endpoint)} (${supportCode})`;
190
197
  const error = new Error(userMessage) as BookingClientError;
191
198
  error.debugMessage = debugMessage;
192
199
  if (bookingApiErrorCode) error.bookingApiErrorCode = bookingApiErrorCode;
@@ -2580,7 +2587,12 @@ export async function confirmBookingWithoutPayment(
2580
2587
  httpStatus: res.status,
2581
2588
  errorCode: isApiErrorPayload(err) ? err.errorCode : undefined,
2582
2589
  });
2583
- throw createUserError(endpoint, 'HTTP', debugMessage);
2590
+ throw createUserError(
2591
+ endpoint,
2592
+ 'HTTP',
2593
+ debugMessage,
2594
+ isApiErrorPayload(err) ? err.errorCode : undefined,
2595
+ );
2584
2596
  }
2585
2597
  const data = await parseJsonSafely(res);
2586
2598
  const appError = toAppLevelErrorMessage(endpoint, data, 'Failed to confirm booking');
@@ -2591,7 +2603,12 @@ export async function confirmBookingWithoutPayment(
2591
2603
  message: appError,
2592
2604
  errorCode: isApiErrorPayload(data) ? data.errorCode : undefined,
2593
2605
  });
2594
- throw createUserError(endpoint, 'APP_ERROR_200', appError);
2606
+ throw createUserError(
2607
+ endpoint,
2608
+ 'APP_ERROR_200',
2609
+ appError,
2610
+ isApiErrorPayload(data) ? data.errorCode : undefined,
2611
+ );
2595
2612
  }
2596
2613
  logBookingSourceDebug('response', '/checkout/confirm-booking-without-payment', data);
2597
2614
  return ((data as { data?: ConfirmBookingWithoutPaymentResponse } | null)?.data ??
@@ -2636,7 +2653,12 @@ export async function confirmPartnerBookingWithoutPayment(
2636
2653
  httpStatus: res.status,
2637
2654
  errorCode: isApiErrorPayload(err) ? err.errorCode : undefined,
2638
2655
  });
2639
- throw createUserError(endpoint, 'HTTP', debugMessage);
2656
+ throw createUserError(
2657
+ endpoint,
2658
+ 'HTTP',
2659
+ debugMessage,
2660
+ isApiErrorPayload(err) ? err.errorCode : undefined,
2661
+ );
2640
2662
  }
2641
2663
  const data = await parseJsonSafely(res);
2642
2664
  const appError = toAppLevelErrorMessage(endpoint, data, 'Failed to confirm booking');
@@ -2647,7 +2669,12 @@ export async function confirmPartnerBookingWithoutPayment(
2647
2669
  message: appError,
2648
2670
  errorCode: isApiErrorPayload(data) ? data.errorCode : undefined,
2649
2671
  });
2650
- throw createUserError(endpoint, 'APP_ERROR_200', appError);
2672
+ throw createUserError(
2673
+ endpoint,
2674
+ 'APP_ERROR_200',
2675
+ appError,
2676
+ isApiErrorPayload(data) ? data.errorCode : undefined,
2677
+ );
2651
2678
  }
2652
2679
  logBookingSourceDebug('response', endpoint, data);
2653
2680
  return ((data as { data?: ConfirmBookingWithoutPaymentResponse } | null)?.data ??
@@ -14,6 +14,11 @@ import {
14
14
  resolveHydratedPrivateShuttleAvailability,
15
15
  } from '../src/components/booking/private-shuttle-availability';
16
16
  import { sanitizeBookingSourceUrl } from '../src/lib/booking/source-metadata';
17
+ import {
18
+ formatReservationHoldTime,
19
+ reservationHoldHasExpired,
20
+ reservationHoldSecondsRemaining,
21
+ } from '../src/components/booking/reservation-hold';
17
22
 
18
23
  function quote(overrides: Partial<ChangeBookingQuoteResponse>): ChangeBookingQuoteResponse {
19
24
  return {
@@ -42,6 +47,23 @@ function test(name: string, fn: () => void): void {
42
47
  }
43
48
  }
44
49
 
50
+ test('admin payment choice countdown expires exactly at the reservation deadline', () => {
51
+ const expiration = '2026-07-16T15:46:44+00:00';
52
+ assert.equal(
53
+ reservationHoldSecondsRemaining(expiration, Date.parse('2026-07-16T15:46:34Z')),
54
+ 10,
55
+ );
56
+ assert.equal(reservationHoldHasExpired(expiration, Date.parse('2026-07-16T15:46:43.999Z')), false);
57
+ assert.equal(reservationHoldHasExpired(expiration, Date.parse('2026-07-16T15:46:44Z')), true);
58
+ assert.equal(formatReservationHoldTime(604), '10:04');
59
+ });
60
+
61
+ test('malformed reservation expiration fails closed', () => {
62
+ assert.equal(reservationHoldSecondsRemaining('not-a-date', Date.now()), 0);
63
+ assert.equal(reservationHoldHasExpired('not-a-date', Date.now()), true);
64
+ assert.equal(reservationHoldHasExpired(undefined, Date.now()), false);
65
+ });
66
+
45
67
  test('source attribution strips Stripe credentials and transient partner checkout identity', () => {
46
68
  const sanitized = sanitizeBookingSourceUrl(
47
69
  'https://staging.booking.viaviamorainelake.com/?partnerId=par_safe&agentId=agent_safe&agentName=Isadora+Buttonmoss&embed_manage=1&reservationRef=resRef_previous&lastName=Tauro&bookingDate=2026-07-16&payment_intent=pi_previous&payment_intent_client_secret=do_not_store&redirect_status=succeeded&tab=bookings',