@ticketboothapp/booking 1.2.99 → 1.2.101

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.
@@ -28,6 +28,7 @@ import {
28
28
  EARLIEST_AVAILABILITY_DATE,
29
29
  LATEST_AVAILABILITY_DATE,
30
30
  INITIAL_FETCH_WEEKS,
31
+ VISIBLE_RANGE_BUFFER_WEEKS,
31
32
  } from '../../lib/booking-constants';
32
33
  import { formatCurrencyAmount } from '../../lib/currency';
33
34
  import { formatBookingRefForDisplay } from '../../lib/booking-ref';
@@ -447,11 +448,86 @@ export function PrivateShuttleBookingFlow({
447
448
  availabilitiesCache,
448
449
  ]);
449
450
 
451
+ /** Fresh selected-day check before reserve so stale private shuttle vacancies are corrected before payment. */
452
+ const refreshSelectedDateDetailsForCheckout = useCallback(async (): Promise<Availability[]> => {
453
+ if (!selectedDate || !product.productId) return [];
454
+
455
+ const result = await getAvailabilities(product.productId, selectedDate, selectedDate, {
456
+ allOptions: true,
457
+ promoCode: activePromoCode || undefined,
458
+ ...(pricingProfileIdForAvailabilities
459
+ ? { pricingProfileId: pricingProfileIdForAvailabilities }
460
+ : {}),
461
+ ...(cancellationPolicyProfileIdForAvailabilities
462
+ ? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
463
+ : {}),
464
+ });
465
+
466
+ if (result.pricingConfig && !pricingConfigSetRef.current) {
467
+ setPricingConfig(result.pricingConfig);
468
+ pricingConfigSetRef.current = true;
469
+ }
470
+ if (result.precomputedPrices) setPrecomputedPrices(result.precomputedPrices);
471
+ if (result.resourcePriceByCurrency) setResourcePriceByCurrency(result.resourcePriceByCurrency);
472
+ if (result.resourcePriceByOption) setResourcePriceByOption(result.resourcePriceByOption);
473
+
474
+ let mergedAvailabilities: Availability[] = [];
475
+ setAvailabilities((prev) => {
476
+ const existingMap = new Map(
477
+ prev.map((avail) => [`${avail.dateTime}-${avail.productId || avail.productOptionId}`, avail])
478
+ );
479
+ result.availabilities.forEach((avail) => {
480
+ existingMap.set(`${avail.dateTime}-${avail.productId || avail.productOptionId}`, avail);
481
+ });
482
+ mergedAvailabilities = Array.from(existingMap.values());
483
+ return mergedAvailabilities;
484
+ });
485
+
486
+ const cacheKey = availabilitiesCache
487
+ ? buildAvailabilitiesCacheKey(
488
+ product.productId,
489
+ activeOptionIdsKey,
490
+ activePromoCode,
491
+ pricingProfileIdForAvailabilities,
492
+ )
493
+ : null;
494
+ if (cacheKey && availabilitiesCache) {
495
+ const existingCache = availabilitiesCache.get(cacheKey);
496
+ const mergedAvailabilitiesMap = new Map(
497
+ (existingCache?.availabilities ?? []).map((availability) => [
498
+ `${availability.dateTime}-${availability.productId || availability.productOptionId}`,
499
+ availability,
500
+ ])
501
+ );
502
+ result.availabilities.forEach((availability) => {
503
+ mergedAvailabilitiesMap.set(`${availability.dateTime}-${availability.productId || availability.productOptionId}`, availability);
504
+ });
505
+ availabilitiesCache.merge(cacheKey, {
506
+ fetchedRanges: existingCache?.fetchedRanges ?? fetchedRangesRef.current,
507
+ availabilities: Array.from(mergedAvailabilitiesMap.values()),
508
+ pricingConfig: result.pricingConfig ?? existingCache?.pricingConfig ?? null,
509
+ precomputedPrices: result.precomputedPrices ?? existingCache?.precomputedPrices ?? null,
510
+ resourcePriceByCurrency: result.resourcePriceByCurrency ?? existingCache?.resourcePriceByCurrency ?? null,
511
+ resourcePriceByOption: result.resourcePriceByOption ?? existingCache?.resourcePriceByOption ?? null,
512
+ });
513
+ }
514
+
515
+ return result.availabilities;
516
+ }, [
517
+ selectedDate,
518
+ product.productId,
519
+ activePromoCode,
520
+ pricingProfileIdForAvailabilities,
521
+ cancellationPolicyProfileIdForAvailabilities,
522
+ availabilitiesCache,
523
+ activeOptionIdsKey,
524
+ ]);
525
+
450
526
  useEffect(() => {
451
527
  if (!visibleRange) {
452
528
  setVisibleRange({
453
529
  start: EARLIEST_AVAILABILITY_DATE,
454
- end: addWeeks(EARLIEST_AVAILABILITY_DATE, INITIAL_FETCH_WEEKS),
530
+ end: addWeeks(EARLIEST_AVAILABILITY_DATE, INITIAL_FETCH_WEEKS + VISIBLE_RANGE_BUFFER_WEEKS),
455
531
  });
456
532
  }
457
533
  }, [visibleRange]);
@@ -534,6 +610,7 @@ export function PrivateShuttleBookingFlow({
534
610
  )
535
611
  : null;
536
612
  const cached = cacheKey ? availabilitiesCache!.get(cacheKey) : undefined;
613
+ let shouldRevalidateCachedRange = false;
537
614
  if (cached && cached.availabilities.length > 0) {
538
615
  const cacheCoversRange = cached.fetchedRanges.some(
539
616
  (r) =>
@@ -541,6 +618,7 @@ export function PrivateShuttleBookingFlow({
541
618
  );
542
619
  const isStale = availabilitiesCache?.isStale(cached) ?? false;
543
620
  if (cacheCoversRange) {
621
+ shouldRevalidateCachedRange = true;
544
622
  setAvailabilities(cached.availabilities);
545
623
  if (cached.pricingConfig) {
546
624
  setPricingConfig(cached.pricingConfig);
@@ -553,8 +631,7 @@ export function PrivateShuttleBookingFlow({
553
631
  setIsFetchingMoreAvailabilities(false);
554
632
  if (!isStale) {
555
633
  fetchedRangesRef.current = [...cached.fetchedRanges];
556
- fetchingRef.current = false;
557
- return;
634
+ // Show cached availability instantly, then quietly revalidate the visible range.
558
635
  }
559
636
  }
560
637
  // Partial cache: show cached data immediately, then fetch missing range below
@@ -570,7 +647,7 @@ export function PrivateShuttleBookingFlow({
570
647
  setLoadingAvailabilities(false);
571
648
  }
572
649
 
573
- if (!needsFetch(clampedStart, clampedEnd)) {
650
+ if (!shouldRevalidateCachedRange && !needsFetch(clampedStart, clampedEnd)) {
574
651
  setLoadingAvailabilities(false);
575
652
  setIsFetchingMoreAvailabilities(false);
576
653
  fetchingRef.current = false;
@@ -579,12 +656,13 @@ export function PrivateShuttleBookingFlow({
579
656
  const hasPartialCache = cached && cached.availabilities.length > 0;
580
657
  fetchingRef.current = true;
581
658
  if (!hasPartialCache) setLoadingAvailabilities(true);
582
- else setIsFetchingMoreAvailabilities(true);
659
+ else if (!shouldRevalidateCachedRange) setIsFetchingMoreAvailabilities(true);
583
660
  try {
584
661
  const startDate = format(startOfDay(clampedStart), 'yyyy-MM-dd');
585
662
  const endDate = format(endOfDay(clampedEnd), 'yyyy-MM-dd');
586
663
  const result = await getAvailabilities(product.productId, startDate, endDate, {
587
664
  allOptions: true,
665
+ summary: true,
588
666
  promoCode: activePromoCode || undefined,
589
667
  ...(pricingProfileIdForAvailabilities
590
668
  ? { pricingProfileId: pricingProfileIdForAvailabilities }
@@ -643,7 +721,11 @@ export function PrivateShuttleBookingFlow({
643
721
  });
644
722
  }
645
723
  } catch (err) {
646
- setError(err instanceof Error ? err.message : 'Failed to load availabilities');
724
+ if (shouldRevalidateCachedRange && cached?.availabilities.length) {
725
+ console.warn('Background private shuttle availability refresh failed; keeping cached availability visible.', err);
726
+ } else {
727
+ setError(err instanceof Error ? err.message : 'Failed to load availabilities');
728
+ }
647
729
  } finally {
648
730
  setLoadingAvailabilities(false);
649
731
  setIsFetchingMoreAvailabilities(false);
@@ -702,6 +784,100 @@ export function PrivateShuttleBookingFlow({
702
784
 
703
785
  const dates = useMemo(() => Object.keys(availabilitiesByDate).sort(), [availabilitiesByDate]);
704
786
 
787
+ useEffect(() => {
788
+ if (!selectedDate || !product.productId) return;
789
+ const selectedDateAvailabilities = availabilitiesByDate[selectedDate] ?? [];
790
+ if (!selectedDateAvailabilities.some((availability) => availability.isSummary)) return;
791
+
792
+ let cancelled = false;
793
+ async function hydrateSelectedDateDetails() {
794
+ setIsFetchingMoreAvailabilities(true);
795
+ try {
796
+ const result = await getAvailabilities(product.productId, selectedDate, selectedDate, {
797
+ allOptions: true,
798
+ promoCode: activePromoCode || undefined,
799
+ ...(pricingProfileIdForAvailabilities
800
+ ? { pricingProfileId: pricingProfileIdForAvailabilities }
801
+ : {}),
802
+ ...(cancellationPolicyProfileIdForAvailabilities
803
+ ? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
804
+ : {}),
805
+ });
806
+ if (cancelled) return;
807
+
808
+ if (result.pricingConfig && !pricingConfigSetRef.current) {
809
+ setPricingConfig(result.pricingConfig);
810
+ pricingConfigSetRef.current = true;
811
+ }
812
+ if (result.precomputedPrices) setPrecomputedPrices(result.precomputedPrices);
813
+ if (result.resourcePriceByCurrency) setResourcePriceByCurrency(result.resourcePriceByCurrency);
814
+ if (result.resourcePriceByOption) setResourcePriceByOption(result.resourcePriceByOption);
815
+
816
+ setAvailabilities((prev) => {
817
+ const merged = new Map(
818
+ prev.map((availability) => [
819
+ `${availability.dateTime}-${availability.productId || availability.productOptionId}`,
820
+ availability,
821
+ ])
822
+ );
823
+ result.availabilities.forEach((availability) => {
824
+ merged.set(`${availability.dateTime}-${availability.productId || availability.productOptionId}`, availability);
825
+ });
826
+ return Array.from(merged.values());
827
+ });
828
+
829
+ const cacheKey = availabilitiesCache
830
+ ? buildAvailabilitiesCacheKey(
831
+ product.productId,
832
+ activeOptionIdsKey,
833
+ activePromoCode,
834
+ pricingProfileIdForAvailabilities,
835
+ )
836
+ : null;
837
+ if (cacheKey && availabilitiesCache) {
838
+ const existingCache = availabilitiesCache.get(cacheKey);
839
+ const mergedAvailabilities = new Map(
840
+ (existingCache?.availabilities ?? []).map((availability) => [
841
+ `${availability.dateTime}-${availability.productId || availability.productOptionId}`,
842
+ availability,
843
+ ])
844
+ );
845
+ result.availabilities.forEach((availability) => {
846
+ mergedAvailabilities.set(`${availability.dateTime}-${availability.productId || availability.productOptionId}`, availability);
847
+ });
848
+ availabilitiesCache.merge(cacheKey, {
849
+ fetchedRanges: existingCache?.fetchedRanges ?? fetchedRangesRef.current,
850
+ availabilities: Array.from(mergedAvailabilities.values()),
851
+ pricingConfig: result.pricingConfig ?? existingCache?.pricingConfig ?? null,
852
+ precomputedPrices: result.precomputedPrices ?? existingCache?.precomputedPrices ?? null,
853
+ resourcePriceByCurrency: result.resourcePriceByCurrency ?? existingCache?.resourcePriceByCurrency ?? null,
854
+ resourcePriceByOption: result.resourcePriceByOption ?? existingCache?.resourcePriceByOption ?? null,
855
+ });
856
+ }
857
+ } catch (err) {
858
+ if (!cancelled) {
859
+ console.error('Error hydrating private shuttle availability details:', err);
860
+ }
861
+ } finally {
862
+ if (!cancelled) setIsFetchingMoreAvailabilities(false);
863
+ }
864
+ }
865
+
866
+ hydrateSelectedDateDetails();
867
+ return () => {
868
+ cancelled = true;
869
+ };
870
+ }, [
871
+ selectedDate,
872
+ availabilitiesByDate,
873
+ product.productId,
874
+ activeOptionIdsKey,
875
+ activePromoCode,
876
+ pricingProfileIdForAvailabilities,
877
+ cancellationPolicyProfileIdForAvailabilities,
878
+ availabilitiesCache,
879
+ ]);
880
+
705
881
  const earliestAvailabilityDate = useMemo(() => {
706
882
  if (dates.length === 0) return EARLIEST_AVAILABILITY_DATE;
707
883
  // Build date in company timezone at noon to avoid cross-timezone day shifts.
@@ -1319,6 +1495,32 @@ export function PrivateShuttleBookingFlow({
1319
1495
  const [hours, minutes] = selectedStartTime.split(':').map(Number);
1320
1496
  const startTimeISO = `${selectedDate}T${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00-06:00`;
1321
1497
 
1498
+ try {
1499
+ const refreshed = await refreshSelectedDateDetailsForCheckout();
1500
+ const slot = findMergedPrivateShuttleAvailability(
1501
+ refreshed,
1502
+ selectedAvailability,
1503
+ selectedOption || null,
1504
+ selectedDate || null,
1505
+ companyTimezone
1506
+ );
1507
+ const passengersToReserve = isSpecialRequest
1508
+ ? Math.min(passengerCount, maxVacancies)
1509
+ : passengerCount;
1510
+ if (slot?.vacancies != null && (slot.vacancies === 0 || slot.vacancies < passengersToReserve)) {
1511
+ setError(
1512
+ describePrivateShuttleCapacityConflictMessage({
1513
+ passengersRequested: passengerCount,
1514
+ vacancies: slot.vacancies,
1515
+ })
1516
+ );
1517
+ setLoading(false);
1518
+ return;
1519
+ }
1520
+ } catch (refreshErr) {
1521
+ console.warn('Fresh private shuttle availability check failed before reserve; falling back to reserve validation.', refreshErr);
1522
+ }
1523
+
1322
1524
  const specialRequestNote = isSpecialRequest
1323
1525
  ? `Special request: Customer requested ${passengerCount} passengers (${resourceCount} shuttles needed). We reserved ${billableResourceCount} at standard capacity. Team to add ${resourceCount - billableResourceCount} more shuttle(s) and verify fleet availability.`
1324
1526
  : '';
@@ -1,7 +1,13 @@
1
- import type { RefObject } from 'react';
2
- import type { Product } from '../../lib/booking-api';
1
+ import type { ReactNode, RefObject } from 'react';
2
+ import type {
3
+ Availability,
4
+ CheckoutDependentAddOnSelection,
5
+ ItineraryDisplayStep,
6
+ Product,
7
+ } from '../../lib/booking-api';
3
8
  import type { BookingSourceMetadata } from '../../lib/booking/source-metadata';
4
9
  import type { Currency } from './CurrencySwitcher';
10
+ import type { PriceSummaryLine } from './PriceSummary';
5
11
  import type { BookingFlowUiOptions } from './booking-flow-ui';
6
12
  import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
7
13
 
@@ -96,6 +102,25 @@ export interface BookingFlowBaseProps {
96
102
  hideItineraryBox?: boolean;
97
103
  /** Partner / embed-only tweaks; omit for default website behavior. */
98
104
  flowUi?: BookingFlowUiOptions;
105
+ /**
106
+ * Optional inline extension rendered immediately below the Build Itinerary box once
107
+ * a date and shuttle time are selected. Used by photo-first dependent add-on flows.
108
+ */
109
+ afterItinerary?: ReactNode | ((context: BookingFlowAfterItineraryContext) => ReactNode);
110
+ /**
111
+ * Optional hook to insert add-on rows into the Build Itinerary box. Receives the
112
+ * base itinerary from the selected shuttle and returns the display list to render.
113
+ */
114
+ augmentItineraryItems?: (context: BookingFlowAfterItineraryContext) => ItineraryDisplayStep[] | null;
115
+ /** Optional hook for embed flows to show extra rows in the checkout receipt summary. */
116
+ augmentPriceSummary?: (context: BookingFlowPriceSummaryContext) => BookingFlowPriceSummaryAugmentation | null;
117
+ /**
118
+ * Optional dependent add-on to create with the primary booking after checkout.
119
+ * Used by photo-first flows where the add-on is selected before the shuttle booking exists.
120
+ */
121
+ dependentAddOnSelection?: CheckoutDependentAddOnSelection | null;
122
+ /** Optional external validation before checkout submit (e.g. required add-on questionnaire answers). */
123
+ validateBeforeCheckout?: (context: BookingFlowAfterItineraryContext) => string | null;
99
124
  /** Explicit reserve/checkout source metadata (browser URL layer + optional portal merge at call site). */
100
125
  bookingSourceAttribution: Partial<BookingSourceMetadata>;
101
126
  /** Dedicated partner portal app (`booking.*`): persist reserve `source` as PARTNER_PORTAL. */
@@ -108,6 +133,29 @@ export interface BookingFlowBaseProps {
108
133
  changeProductOptions?: Product[];
109
134
  }
110
135
 
136
+ export interface BookingFlowAfterItineraryContext {
137
+ selectedDate: string | null;
138
+ selectedAvailability: Availability | null;
139
+ itineraryItems: ItineraryDisplayStep[] | null;
140
+ }
141
+
142
+ export interface BookingFlowPriceSummaryContext {
143
+ selectedDate: string | null;
144
+ selectedAvailability: Availability | null;
145
+ itineraryItems?: ItineraryDisplayStep[] | null;
146
+ priceSummaryLines: PriceSummaryLine[];
147
+ subtotal: number;
148
+ tax: number;
149
+ total: number;
150
+ currency: Currency;
151
+ }
152
+
153
+ export interface BookingFlowPriceSummaryAugmentation {
154
+ lines?: PriceSummaryLine[];
155
+ subtotalAdjustment?: number;
156
+ totalAdjustment?: number;
157
+ }
158
+
111
159
  /** Standard (new) reservation flow — no change-booking receipt or callbacks. */
112
160
  export interface NewBookingFlowProps extends BookingFlowBaseProps {}
113
161
 
@@ -45,6 +45,8 @@ export type ProviderDashboardChangePricingUi = {
45
45
  export interface BookingFlowUiOptions {
46
46
  showCollage?: boolean;
47
47
  showTourDescription?: boolean;
48
+ /** When false, the flow still uses the selected/initial date but does not render the calendar picker. */
49
+ showCalendar?: boolean;
48
50
  autoSelectFirstAvailableDate?: boolean;
49
51
  autoSelectFirstHighlightedPickup?: boolean;
50
52
  /**
@@ -9,8 +9,8 @@ import {
9
9
  } from 'react';
10
10
  import type { Availability, PricingConfig, PrecomputedPricesByCategory } from '../lib/booking-api';
11
11
 
12
- /** Cache TTL in milliseconds (5 minutes). Entries older than this are considered stale. */
13
- export const AVAILABILITIES_CACHE_TTL_MS = 5 * 60 * 1000;
12
+ /** Cache TTL in milliseconds (1 minute). Entries older than this are considered stale. */
13
+ export const AVAILABILITIES_CACHE_TTL_MS = 60 * 1000;
14
14
 
15
15
  export interface CachedAvailabilitiesData {
16
16
  fetchedRanges: Array<{ start: Date; end: Date }>;
@@ -355,6 +355,7 @@ export interface OrderSummaryFeeLine {
355
355
  name: string;
356
356
  totalAmount: number;
357
357
  description?: string;
358
+ showQuantityLabel?: boolean;
358
359
  }
359
360
 
360
361
  /** One ticket line for order summary (category, qty, price per unit, item total in display currency). */