@ticketboothapp/booking 1.2.98 → 1.2.100

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.
@@ -27,6 +27,7 @@ import {
27
27
  EARLIEST_AVAILABILITY_DATE,
28
28
  LATEST_AVAILABILITY_DATE,
29
29
  INITIAL_FETCH_WEEKS,
30
+ VISIBLE_RANGE_BUFFER_WEEKS,
30
31
  } from '../../lib/booking-constants';
31
32
  import { Calendar } from './Calendar';
32
33
  import { AdminPaymentChoiceModal } from './AdminPaymentChoiceModal';
@@ -44,7 +45,7 @@ import { type Currency } from './CurrencySwitcher';
44
45
  import { formatBookingRefForDisplay } from '../../lib/booking-ref';
45
46
  import { formatCurrencyAmount } from '../../lib/currency';
46
47
  import { buildCheckoutBreakdown } from '../../lib/booking/checkout-breakdown';
47
- import type { PricingConfig, PrecomputedPricesByCategory, ItineraryDisplayStep } from '../../lib/booking-api';
48
+ import type { PricingConfig, PrecomputedPricesByCategory, ItineraryDisplayStep, GetAvailabilitiesResponse } from '../../lib/booking-api';
48
49
  import { ItineraryStepType as StepType } from '../../lib/booking-api';
49
50
  import {
50
51
  getDisplayPriceFromBaseInDisplayCurrency,
@@ -52,6 +53,7 @@ import {
52
53
  computeOrderSummary,
53
54
  type PriceBreakdown as PriceBreakdownData,
54
55
  type OrderSummary,
56
+ type OrderSummaryFeeLine,
55
57
  } from '../../lib/booking/pricing';
56
58
  import { useCompanyTimezone } from '../../contexts/CompanyContext';
57
59
  import { useBookingApp } from '../../contexts/BookingAppContext';
@@ -169,6 +171,26 @@ function findMergedAvailabilityForSelection(
169
171
  return merged.find((a) => a.dateTime === dt && a.productOptionId === optId);
170
172
  }
171
173
 
174
+ function availabilityWithOptionId(availability: Availability, fallbackOptionId?: string): Availability {
175
+ const productOptionId = availability.productOptionId || availability.productId || fallbackOptionId;
176
+ return productOptionId ? { ...availability, productOptionId } : availability;
177
+ }
178
+
179
+ function mergePrecomputedPricesFromAvailabilityResult(
180
+ next: Record<string, PrecomputedPricesByCategory>,
181
+ result: GetAvailabilitiesResponse,
182
+ fallbackOptionId?: string,
183
+ ) {
184
+ if (result.precomputedPricesByOption) {
185
+ Object.entries(result.precomputedPricesByOption).forEach(([optionId, prices]) => {
186
+ if (prices && Object.keys(prices).length > 0) next[optionId] = prices;
187
+ });
188
+ }
189
+ if (fallbackOptionId && result.precomputedPrices && Object.keys(result.precomputedPrices).length > 0) {
190
+ next[fallbackOptionId] = result.precomputedPrices;
191
+ }
192
+ }
193
+
172
194
  /** Ticket rates for one availability row — mirrors main cart [pricing] useMemo so baseline slots match BE. */
173
195
  function buildPricingFromAvailability(
174
196
  selectedAvailability: Availability | null,
@@ -320,6 +342,11 @@ export function NewBookingFlow({
320
342
  initialValues,
321
343
  hideItineraryBox = false,
322
344
  flowUi,
345
+ afterItinerary,
346
+ augmentItineraryItems,
347
+ augmentPriceSummary,
348
+ dependentAddOnSelection,
349
+ validateBeforeCheckout,
323
350
  bookingSourceAttribution,
324
351
  partnerPortalBooking = false,
325
352
  availabilityPricingProfileId,
@@ -563,45 +590,39 @@ export function NewBookingFlow({
563
590
  endDateStr = formatInTimeZone(endMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
564
591
  }
565
592
 
566
- const availabilityPromises = activeOptions.map(async (option) => {
567
- const result = await getAvailabilities(option.optionId, startDateStr, endDateStr, {
568
- promoCode: appliedPromoCode || undefined,
569
- ...(pricingProfileIdForAvailabilities
570
- ? { pricingProfileId: pricingProfileIdForAvailabilities }
571
- : {}),
572
- ...(cancellationPolicyProfileIdForAvailabilities
573
- ? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
574
- : {}),
575
- });
576
- if (result.pricingConfig && !pricingConfigSetRef.current) {
577
- setPricingConfig((prev) => {
578
- if (!prev) {
579
- pricingConfigSetRef.current = true;
580
- return result.pricingConfig!;
581
- }
582
- return prev;
583
- });
584
- }
585
- return {
586
- optionId: option.optionId,
587
- availabilities: result.availabilities.map((avail) => ({
588
- ...avail,
589
- productOptionId: option.optionId,
590
- })),
591
- precomputedPrices: result.precomputedPrices,
592
- pricingConfig: result.pricingConfig,
593
- };
593
+ const result = await getAvailabilities(product.productId, startDateStr, endDateStr, {
594
+ allOptions: true,
595
+ promoCode: appliedPromoCode || undefined,
596
+ ...(pricingProfileIdForAvailabilities
597
+ ? { pricingProfileId: pricingProfileIdForAvailabilities }
598
+ : {}),
599
+ ...(cancellationPolicyProfileIdForAvailabilities
600
+ ? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
601
+ : {}),
594
602
  });
603
+ if (result.pricingConfig && !pricingConfigSetRef.current) {
604
+ setPricingConfig((prev) => {
605
+ if (!prev) {
606
+ pricingConfigSetRef.current = true;
607
+ return result.pricingConfig!;
608
+ }
609
+ return prev;
610
+ });
611
+ }
612
+ const results = [{
613
+ optionId: product.productId,
614
+ availabilities: result.availabilities.map((avail) => availabilityWithOptionId(avail)),
615
+ precomputedPrices: result.precomputedPrices,
616
+ precomputedPricesByOption: result.precomputedPricesByOption,
617
+ pricingConfig: result.pricingConfig,
618
+ }];
595
619
 
596
- const results = await Promise.all(availabilityPromises);
597
620
  const allFetchedAvailabilities = results.flatMap((r) => r.availabilities);
598
621
 
599
622
  setPrecomputedPricesByOption((prev) => {
600
623
  const next = { ...(prev || {}) };
601
624
  results.forEach((r) => {
602
- if (r.precomputedPrices && Object.keys(r.precomputedPrices).length > 0) {
603
- next[r.optionId] = r.precomputedPrices;
604
- }
625
+ mergePrecomputedPricesFromAvailabilityResult(next, r);
605
626
  });
606
627
  return Object.keys(next).length > 0 ? next : prev;
607
628
  });
@@ -652,9 +673,7 @@ export function NewBookingFlow({
652
673
  });
653
674
  const mergedPrecomputed = { ...(existingCache?.precomputedPricesByOption ?? {}) };
654
675
  results.forEach((r) => {
655
- if (r.precomputedPrices && Object.keys(r.precomputedPrices).length > 0) {
656
- mergedPrecomputed[r.optionId] = r.precomputedPrices;
657
- }
676
+ mergePrecomputedPricesFromAvailabilityResult(mergedPrecomputed, r);
658
677
  });
659
678
  const firstPricingConfig =
660
679
  (results[0] as { pricingConfig?: PricingConfig } | undefined)?.pricingConfig ??
@@ -684,10 +703,110 @@ export function NewBookingFlow({
684
703
  availabilitiesCache,
685
704
  ]);
686
705
 
706
+ /** Fresh selected-day check before reserve so stale calendar vacancies are corrected before payment. */
707
+ const refreshSelectedDateDetailsForCheckout = useCallback(async (): Promise<Availability[]> => {
708
+ if (!selectedDate || activeOptions.length === 0) return [];
709
+
710
+ let startDateStr: string;
711
+ let endDateStr: string;
712
+ if (isPrivateShuttle) {
713
+ startDateStr = selectedDate;
714
+ endDateStr = selectedDate;
715
+ } else {
716
+ const startMoment = fromZonedTime(parseISO(`${selectedDate}T00:00:00.000`), companyTimezone);
717
+ const endMoment = fromZonedTime(parseISO(`${selectedDate}T23:59:59.999`), companyTimezone);
718
+ startDateStr = formatInTimeZone(startMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
719
+ endDateStr = formatInTimeZone(endMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
720
+ }
721
+
722
+ const result = await getAvailabilities(product.productId, startDateStr, endDateStr, {
723
+ allOptions: true,
724
+ promoCode: appliedPromoCode || undefined,
725
+ ...(pricingProfileIdForAvailabilities
726
+ ? { pricingProfileId: pricingProfileIdForAvailabilities }
727
+ : {}),
728
+ ...(cancellationPolicyProfileIdForAvailabilities
729
+ ? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
730
+ : {}),
731
+ });
732
+ const results = [{
733
+ optionId: product.productId,
734
+ availabilities: result.availabilities.map((availability) => availabilityWithOptionId(availability)),
735
+ precomputedPrices: result.precomputedPrices,
736
+ precomputedPricesByOption: result.precomputedPricesByOption,
737
+ pricingConfig: result.pricingConfig,
738
+ }];
739
+ const detailedAvailabilities = results.flatMap((r) => r.availabilities);
740
+ if (detailedAvailabilities.length === 0) return [];
741
+
742
+ if (result.pricingConfig && !pricingConfigSetRef.current) {
743
+ setPricingConfig(result.pricingConfig);
744
+ pricingConfigSetRef.current = true;
745
+ }
746
+
747
+ setPrecomputedPricesByOption((prev) => {
748
+ const next = { ...(prev || {}) };
749
+ results.forEach((r) => mergePrecomputedPricesFromAvailabilityResult(next, r));
750
+ return Object.keys(next).length > 0 ? next : prev;
751
+ });
752
+
753
+ let mergedOut: Availability[] = [];
754
+ setAvailabilities((prev) => {
755
+ const merged = new Map(prev.map((availability) => [`${availability.dateTime}-${availability.productOptionId}`, availability]));
756
+ detailedAvailabilities.forEach((availability) => {
757
+ merged.set(`${availability.dateTime}-${availability.productOptionId}`, availability);
758
+ });
759
+ mergedOut = Array.from(merged.values());
760
+ return mergedOut;
761
+ });
762
+
763
+ const cacheKey = availabilitiesCache
764
+ ? buildAvailabilitiesCacheKey(
765
+ product.productId,
766
+ activeOptionIdsKey,
767
+ appliedPromoCode,
768
+ pricingProfileIdForAvailabilities,
769
+ )
770
+ : null;
771
+ if (cacheKey && availabilitiesCache) {
772
+ const existingCache = availabilitiesCache.get(cacheKey);
773
+ const mergedAvailabilities = new Map(
774
+ (existingCache?.availabilities ?? []).map((availability) => [
775
+ `${availability.dateTime}-${availability.productOptionId}`,
776
+ availability,
777
+ ])
778
+ );
779
+ detailedAvailabilities.forEach((availability) => {
780
+ mergedAvailabilities.set(`${availability.dateTime}-${availability.productOptionId}`, availability);
781
+ });
782
+ const mergedPrecomputed = { ...(existingCache?.precomputedPricesByOption ?? {}) };
783
+ results.forEach((r) => mergePrecomputedPricesFromAvailabilityResult(mergedPrecomputed, r));
784
+ availabilitiesCache.merge(cacheKey, {
785
+ fetchedRanges: existingCache?.fetchedRanges ?? fetchedRangesRef.current,
786
+ availabilities: Array.from(mergedAvailabilities.values()),
787
+ pricingConfig: result.pricingConfig ?? existingCache?.pricingConfig ?? null,
788
+ precomputedPricesByOption: Object.keys(mergedPrecomputed).length > 0 ? mergedPrecomputed : null,
789
+ });
790
+ }
791
+
792
+ return detailedAvailabilities;
793
+ }, [
794
+ selectedDate,
795
+ activeOptions.length,
796
+ isPrivateShuttle,
797
+ companyTimezone,
798
+ product.productId,
799
+ appliedPromoCode,
800
+ pricingProfileIdForAvailabilities,
801
+ cancellationPolicyProfileIdForAvailabilities,
802
+ availabilitiesCache,
803
+ activeOptionIdsKey,
804
+ ]);
805
+
687
806
  // Initialize visible range when unset (anchor at season open for fast first paint).
688
807
  useEffect(() => {
689
808
  if (!visibleRange) {
690
- const initialEnd = addWeeks(EARLIEST_AVAILABILITY_DATE, INITIAL_FETCH_WEEKS);
809
+ const initialEnd = addWeeks(EARLIEST_AVAILABILITY_DATE, INITIAL_FETCH_WEEKS + VISIBLE_RANGE_BUFFER_WEEKS);
691
810
  setVisibleRange({ start: EARLIEST_AVAILABILITY_DATE, end: initialEnd });
692
811
  }
693
812
  }, [visibleRange]);
@@ -760,6 +879,7 @@ export function NewBookingFlow({
760
879
  )
761
880
  : null;
762
881
  const cached = cacheKey ? availabilitiesCache!.get(cacheKey) : undefined;
882
+ let shouldRevalidateCachedRange = false;
763
883
  if (cached && cached.availabilities.length > 0) {
764
884
  const cachedInitialQuantities = deriveDefaultQuantitiesFromAvailabilities(cached.availabilities);
765
885
  if (cachedInitialQuantities) {
@@ -770,6 +890,7 @@ export function NewBookingFlow({
770
890
  );
771
891
  const isStale = availabilitiesCache?.isStale(cached) ?? false;
772
892
  if (cacheCoversRange) {
893
+ shouldRevalidateCachedRange = true;
773
894
  setAvailabilities(cached.availabilities);
774
895
  if (cached.availabilities.length > 0) {
775
896
  hasLoadedAvailabilitiesRef.current = true;
@@ -785,10 +906,9 @@ export function NewBookingFlow({
785
906
  setIsFetchingMoreAvailabilities(false);
786
907
  if (!isStale) {
787
908
  fetchedRangesRef.current = [...cached.fetchedRanges];
788
- fetchingRef.current = false;
789
- return;
909
+ // Show cached availability instantly, then quietly revalidate the visible range.
790
910
  }
791
- // Stale-while-revalidate: show cached data, fetch in background (don't set fetchedRangesRef so we fall through)
911
+ // Stale-while-revalidate: show cached data, fetch in background.
792
912
  }
793
913
  // Partial cache: show cached data immediately, then fetch missing range below
794
914
  setAvailabilities(cached.availabilities);
@@ -808,7 +928,7 @@ export function NewBookingFlow({
808
928
 
809
929
  // Check if we need to fetch this range
810
930
  // Backend always returns CAD prices without fee/tax, so no need to refetch on currency change
811
- const shouldFetch = needsFetch(clampedStart, clampedEnd);
931
+ const shouldFetch = shouldRevalidateCachedRange || needsFetch(clampedStart, clampedEnd);
812
932
  if (!shouldFetch) {
813
933
  // Range already fetched - ensure loading state is cleared
814
934
  setLoadingAvailabilities(false);
@@ -826,7 +946,7 @@ export function NewBookingFlow({
826
946
  end: new Date(clampedEnd),
827
947
  };
828
948
  if (shouldUsePrimaryLoader) setLoadingAvailabilities(true);
829
- else setIsFetchingMoreAvailabilities(true);
949
+ else if (!shouldRevalidateCachedRange) setIsFetchingMoreAvailabilities(true);
830
950
 
831
951
  try {
832
952
  let startDateStr: string;
@@ -847,40 +967,33 @@ export function NewBookingFlow({
847
967
  endDateStr = formatInTimeZone(endMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
848
968
  }
849
969
 
850
- // Fetch availabilities for all product options in parallel (no currency param - we use precomputedPrices for display)
851
- const availabilityPromises = activeOptions.map(async (option) => {
852
- const result = await getAvailabilities(option.optionId, startDateStr, endDateStr, {
853
- promoCode: appliedPromoCode || undefined,
854
- ...(pricingProfileIdForAvailabilities
855
- ? { pricingProfileId: pricingProfileIdForAvailabilities }
856
- : {}),
857
- ...(cancellationPolicyProfileIdForAvailabilities
858
- ? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
859
- : {}),
860
- });
861
- // Store pricing config from first response only (all responses have same config)
862
- if (result.pricingConfig && !pricingConfigSetRef.current) {
863
- setPricingConfig(prev => {
864
- if (!prev) {
865
- pricingConfigSetRef.current = true;
866
- return result.pricingConfig!;
867
- }
868
- return prev;
869
- });
870
- }
871
- // Tag each availability with its productOptionId and carry precomputedPrices for this option
872
- return {
873
- optionId: option.optionId,
874
- availabilities: result.availabilities.map(avail => ({
875
- ...avail,
876
- productOptionId: option.optionId
877
- })),
878
- precomputedPrices: result.precomputedPrices,
879
- pricingConfig: result.pricingConfig,
880
- };
970
+ const result = await getAvailabilities(product.productId, startDateStr, endDateStr, {
971
+ allOptions: true,
972
+ summary: true,
973
+ promoCode: appliedPromoCode || undefined,
974
+ ...(pricingProfileIdForAvailabilities
975
+ ? { pricingProfileId: pricingProfileIdForAvailabilities }
976
+ : {}),
977
+ ...(cancellationPolicyProfileIdForAvailabilities
978
+ ? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
979
+ : {}),
881
980
  });
882
-
883
- const results = await Promise.all(availabilityPromises);
981
+ if (result.pricingConfig && !pricingConfigSetRef.current) {
982
+ setPricingConfig(prev => {
983
+ if (!prev) {
984
+ pricingConfigSetRef.current = true;
985
+ return result.pricingConfig!;
986
+ }
987
+ return prev;
988
+ });
989
+ }
990
+ const results = [{
991
+ optionId: product.productId,
992
+ availabilities: result.availabilities.map((avail) => availabilityWithOptionId(avail)),
993
+ precomputedPrices: result.precomputedPrices,
994
+ precomputedPricesByOption: result.precomputedPricesByOption,
995
+ pricingConfig: result.pricingConfig,
996
+ }];
884
997
  const allFetchedAvailabilities = results.flatMap(r => r.availabilities);
885
998
  if (allFetchedAvailabilities.length > 0) {
886
999
  hasLoadedAvailabilitiesRef.current = true;
@@ -888,9 +1001,7 @@ export function NewBookingFlow({
888
1001
  setPrecomputedPricesByOption(prev => {
889
1002
  const next = { ...(prev || {}) };
890
1003
  results.forEach(r => {
891
- if (r.precomputedPrices && Object.keys(r.precomputedPrices).length > 0) {
892
- next[r.optionId] = r.precomputedPrices;
893
- }
1004
+ mergePrecomputedPricesFromAvailabilityResult(next, r);
894
1005
  });
895
1006
  return Object.keys(next).length > 0 ? next : prev;
896
1007
  });
@@ -939,9 +1050,7 @@ export function NewBookingFlow({
939
1050
  });
940
1051
  const mergedPrecomputed = { ...(existingCache?.precomputedPricesByOption ?? {}) };
941
1052
  results.forEach((r) => {
942
- if (r.precomputedPrices && Object.keys(r.precomputedPrices).length > 0) {
943
- mergedPrecomputed[r.optionId] = r.precomputedPrices;
944
- }
1053
+ mergePrecomputedPricesFromAvailabilityResult(mergedPrecomputed, r);
945
1054
  });
946
1055
  const firstPricingConfig = (results[0] as { pricingConfig?: PricingConfig } | undefined)?.pricingConfig ?? existingCache?.pricingConfig ?? null;
947
1056
  availabilitiesCache.merge(cacheKey, {
@@ -959,8 +1068,12 @@ export function NewBookingFlow({
959
1068
  setQuantities((prev) => (Object.keys(prev).length > 0 ? prev : fetchedInitialQuantities));
960
1069
  }
961
1070
  } catch (err) {
962
- setError(err instanceof Error ? err.message : 'Failed to load availabilities');
963
- console.error('Error fetching availabilities:', err);
1071
+ if (shouldRevalidateCachedRange && cached?.availabilities.length) {
1072
+ console.warn('Background availability refresh failed; keeping cached availability visible.', err);
1073
+ } else {
1074
+ setError(err instanceof Error ? err.message : 'Failed to load availabilities');
1075
+ console.error('Error fetching availabilities:', err);
1076
+ }
964
1077
  } finally {
965
1078
  setLoadingAvailabilities(false);
966
1079
  setIsFetchingMoreAvailabilities(false);
@@ -1043,6 +1156,129 @@ export function NewBookingFlow({
1043
1156
 
1044
1157
  const dates = useMemo(() => Object.keys(availabilitiesByDate).sort(), [availabilitiesByDate]);
1045
1158
 
1159
+ useEffect(() => {
1160
+ if (!selectedDate || activeOptions.length === 0) return;
1161
+ const selectedDateAvailabilities = availabilitiesByDate[selectedDate] ?? [];
1162
+ if (!selectedDateAvailabilities.some((availability) => availability.isSummary)) return;
1163
+
1164
+ let cancelled = false;
1165
+ async function hydrateSelectedDateDetails() {
1166
+ setIsFetchingMoreAvailabilities(true);
1167
+ try {
1168
+ let startDateStr: string;
1169
+ let endDateStr: string;
1170
+
1171
+ if (isPrivateShuttle) {
1172
+ startDateStr = selectedDate;
1173
+ endDateStr = selectedDate;
1174
+ } else {
1175
+ const startMoment = fromZonedTime(parseISO(`${selectedDate}T00:00:00.000`), companyTimezone);
1176
+ const endMoment = fromZonedTime(parseISO(`${selectedDate}T23:59:59.999`), companyTimezone);
1177
+ startDateStr = formatInTimeZone(startMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
1178
+ endDateStr = formatInTimeZone(endMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
1179
+ }
1180
+
1181
+ const result = await getAvailabilities(product.productId, startDateStr, endDateStr, {
1182
+ allOptions: true,
1183
+ promoCode: appliedPromoCode || undefined,
1184
+ ...(pricingProfileIdForAvailabilities
1185
+ ? { pricingProfileId: pricingProfileIdForAvailabilities }
1186
+ : {}),
1187
+ ...(cancellationPolicyProfileIdForAvailabilities
1188
+ ? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
1189
+ : {}),
1190
+ });
1191
+ const results = [{
1192
+ optionId: product.productId,
1193
+ availabilities: result.availabilities.map((availability) => availabilityWithOptionId(availability)),
1194
+ precomputedPrices: result.precomputedPrices,
1195
+ precomputedPricesByOption: result.precomputedPricesByOption,
1196
+ pricingConfig: result.pricingConfig,
1197
+ }];
1198
+ if (cancelled) return;
1199
+
1200
+ const detailedAvailabilities = results.flatMap((result) => result.availabilities);
1201
+ if (detailedAvailabilities.length === 0) return;
1202
+
1203
+ const firstPricingConfig = results.find((result) => result.pricingConfig)?.pricingConfig;
1204
+ if (firstPricingConfig && !pricingConfigSetRef.current) {
1205
+ setPricingConfig(firstPricingConfig);
1206
+ pricingConfigSetRef.current = true;
1207
+ }
1208
+
1209
+ setPrecomputedPricesByOption((prev) => {
1210
+ const next = { ...(prev || {}) };
1211
+ results.forEach((result) => {
1212
+ mergePrecomputedPricesFromAvailabilityResult(next, result);
1213
+ });
1214
+ return Object.keys(next).length > 0 ? next : prev;
1215
+ });
1216
+
1217
+ setAvailabilities((prev) => {
1218
+ const merged = new Map(prev.map((availability) => [`${availability.dateTime}-${availability.productOptionId}`, availability]));
1219
+ detailedAvailabilities.forEach((availability) => {
1220
+ merged.set(`${availability.dateTime}-${availability.productOptionId}`, availability);
1221
+ });
1222
+ return Array.from(merged.values());
1223
+ });
1224
+
1225
+ const cacheKey = availabilitiesCache
1226
+ ? buildAvailabilitiesCacheKey(
1227
+ product.productId,
1228
+ activeOptionIdsKey,
1229
+ appliedPromoCode,
1230
+ pricingProfileIdForAvailabilities,
1231
+ )
1232
+ : null;
1233
+ if (cacheKey && availabilitiesCache) {
1234
+ const existingCache = availabilitiesCache.get(cacheKey);
1235
+ const mergedAvailabilities = new Map(
1236
+ (existingCache?.availabilities ?? []).map((availability) => [
1237
+ `${availability.dateTime}-${availability.productOptionId}`,
1238
+ availability,
1239
+ ])
1240
+ );
1241
+ detailedAvailabilities.forEach((availability) => {
1242
+ mergedAvailabilities.set(`${availability.dateTime}-${availability.productOptionId}`, availability);
1243
+ });
1244
+ const mergedPrecomputed = { ...(existingCache?.precomputedPricesByOption ?? {}) };
1245
+ results.forEach((result) => {
1246
+ mergePrecomputedPricesFromAvailabilityResult(mergedPrecomputed, result);
1247
+ });
1248
+ availabilitiesCache.merge(cacheKey, {
1249
+ fetchedRanges: existingCache?.fetchedRanges ?? fetchedRangesRef.current,
1250
+ availabilities: Array.from(mergedAvailabilities.values()),
1251
+ pricingConfig: firstPricingConfig ?? existingCache?.pricingConfig ?? null,
1252
+ precomputedPricesByOption: Object.keys(mergedPrecomputed).length > 0 ? mergedPrecomputed : null,
1253
+ });
1254
+ }
1255
+ } catch (err) {
1256
+ if (!cancelled) {
1257
+ console.error('Error hydrating availability details:', err);
1258
+ }
1259
+ } finally {
1260
+ if (!cancelled) setIsFetchingMoreAvailabilities(false);
1261
+ }
1262
+ }
1263
+
1264
+ hydrateSelectedDateDetails();
1265
+ return () => {
1266
+ cancelled = true;
1267
+ };
1268
+ }, [
1269
+ selectedDate,
1270
+ availabilitiesByDate,
1271
+ activeOptions,
1272
+ activeOptionIdsKey,
1273
+ isPrivateShuttle,
1274
+ companyTimezone,
1275
+ appliedPromoCode,
1276
+ pricingProfileIdForAvailabilities,
1277
+ cancellationPolicyProfileIdForAvailabilities,
1278
+ availabilitiesCache,
1279
+ product.productId,
1280
+ ]);
1281
+
1046
1282
  // Track mobile state
1047
1283
  useEffect(() => {
1048
1284
  const checkMobile = () => {
@@ -1930,6 +2166,56 @@ export function NewBookingFlow({
1930
2166
  ? (effectiveSubtotal - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
1931
2167
  : taxOnSubtotal;
1932
2168
  const totalPrice = effectiveSubtotal + effectiveTax - effectivePromoDiscountAmount;
2169
+ const priceSummaryAugmentation = useMemo(
2170
+ () =>
2171
+ augmentPriceSummary
2172
+ ? augmentPriceSummary({
2173
+ selectedDate: selectedDate || null,
2174
+ selectedAvailability,
2175
+ itineraryItems: computeItineraryDisplay(),
2176
+ priceSummaryLines: checkoutPriceSummaryLines,
2177
+ subtotal: effectiveSubtotal,
2178
+ tax: effectivePromoDiscountAmount > 0 ? effectiveTax : tax,
2179
+ total: totalPrice,
2180
+ currency,
2181
+ })
2182
+ : null,
2183
+ [
2184
+ augmentPriceSummary,
2185
+ selectedDate,
2186
+ selectedAvailability,
2187
+ checkoutPriceSummaryLines,
2188
+ effectiveSubtotal,
2189
+ effectivePromoDiscountAmount,
2190
+ effectiveTax,
2191
+ tax,
2192
+ totalPrice,
2193
+ currency,
2194
+ ]
2195
+ );
2196
+ const displayCheckoutPriceSummaryLines =
2197
+ priceSummaryAugmentation?.lines ?? checkoutPriceSummaryLines;
2198
+ const displaySubtotal =
2199
+ effectiveSubtotal + (priceSummaryAugmentation?.subtotalAdjustment ?? 0);
2200
+ const displayTotalPrice =
2201
+ totalPrice + (priceSummaryAugmentation?.totalAdjustment ?? 0);
2202
+ const augmentedModalFeeLineItems = useMemo((): OrderSummaryFeeLine[] => {
2203
+ const externalLines =
2204
+ priceSummaryAugmentation?.lines
2205
+ ?.slice(checkoutPriceSummaryLines.length)
2206
+ .flatMap((line) =>
2207
+ line.kind === 'line'
2208
+ ? [
2209
+ {
2210
+ name: line.label,
2211
+ totalAmount: line.amount,
2212
+ showQuantityLabel: false,
2213
+ },
2214
+ ]
2215
+ : []
2216
+ ) ?? [];
2217
+ return [...feeLineItemsWithAddOns, ...externalLines];
2218
+ }, [checkoutPriceSummaryLines.length, feeLineItemsWithAddOns, priceSummaryAugmentation?.lines]);
1933
2219
 
1934
2220
  const changeCheckoutButtonLabel = undefined;
1935
2221
 
@@ -2352,6 +2638,15 @@ export function NewBookingFlow({
2352
2638
  setError(t('booking.selectPickupLocation'));
2353
2639
  return;
2354
2640
  }
2641
+ const externalValidationError = validateBeforeCheckout?.({
2642
+ selectedDate: selectedDate || null,
2643
+ selectedAvailability,
2644
+ itineraryItems: computeItineraryDisplay(),
2645
+ });
2646
+ if (externalValidationError) {
2647
+ setError(externalValidationError);
2648
+ return;
2649
+ }
2355
2650
 
2356
2651
  setLoading(true);
2357
2652
  setError('');
@@ -2385,6 +2680,31 @@ export function NewBookingFlow({
2385
2680
  const selectedPickupLocation = pickupLocationId
2386
2681
  ? product.pickupLocations?.find(loc => loc.id === pickupLocationId)
2387
2682
  : null;
2683
+
2684
+ try {
2685
+ const refreshed = await refreshSelectedDateDetailsForCheckout();
2686
+ const outbound = findMergedAvailabilityForSelection(refreshed, selectedAvailability);
2687
+ const outboundVacancies = outbound?.vacancies ?? null;
2688
+ const returnVacancies = findMergedReturnVacancies(outbound, selectedReturnOption);
2689
+ if (
2690
+ (outboundVacancies != null && outboundVacancies < totalQuantity) ||
2691
+ (selectedReturnOption && returnVacancies != null && returnVacancies < totalQuantity)
2692
+ ) {
2693
+ setError(
2694
+ describeStandardTourCapacityConflictMessage({
2695
+ partySize: totalQuantity,
2696
+ outboundVacancies,
2697
+ returnVacancies,
2698
+ hasReturnSelection: !!selectedReturnOption,
2699
+ })
2700
+ );
2701
+ setLoading(false);
2702
+ return;
2703
+ }
2704
+ } catch (refreshErr) {
2705
+ console.warn('Fresh availability check failed before reserve; falling back to reserve validation.', refreshErr);
2706
+ }
2707
+
2388
2708
  const reservation = await createReservation({
2389
2709
  productId: availabilityProductOptionId, // GetYourGuide passes productOptionId values in productId field
2390
2710
  dateTime: selectedAvailability.dateTime,
@@ -2448,7 +2768,22 @@ export function NewBookingFlow({
2448
2768
  // Build checkout breakdown from the exact same values we show in the UI and Stripe modal.
2449
2769
  // Backend will charge totalAmount and store this as the receipt so /manage matches.
2450
2770
  const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
2451
- const amountDueForCheckout = totalPrice;
2771
+ const amountDueForCheckout = displayTotalPrice;
2772
+ const augmentedCheckoutLines =
2773
+ priceSummaryAugmentation?.lines
2774
+ ?.slice(checkoutPriceSummaryLines.length)
2775
+ .flatMap((line) =>
2776
+ line.kind === 'line'
2777
+ ? [
2778
+ {
2779
+ label: line.label,
2780
+ amount: line.amount,
2781
+ type: line.type ?? 'FEE',
2782
+ quantity: line.quantity ?? undefined,
2783
+ },
2784
+ ]
2785
+ : []
2786
+ ) ?? [];
2452
2787
  const lines = [
2453
2788
  ...ticketLineItems.map((line) => ({
2454
2789
  label: line.category,
@@ -2493,6 +2828,7 @@ export function NewBookingFlow({
2493
2828
  };
2494
2829
  })
2495
2830
  .filter((x): x is NonNullable<typeof x> => x != null),
2831
+ ...augmentedCheckoutLines,
2496
2832
  ...feeLineItems.map((fee) => ({
2497
2833
  label: `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? (t('booking.person') || 'person') : (t('booking.people') || 'people')})`,
2498
2834
  amount: fee.totalAmount,
@@ -2546,6 +2882,7 @@ export function NewBookingFlow({
2546
2882
  skipConfirmationCommunications: isAdmin && skipConfirmationCommunications ? true : undefined,
2547
2883
  disableAutoCommunications: isAdmin && disableAutoCommunications ? true : undefined,
2548
2884
  checkoutBreakdown,
2885
+ dependentAddOnSelection: dependentAddOnSelection || undefined,
2549
2886
  depositAmount: 0,
2550
2887
  balanceAmount: amountDueForCheckout,
2551
2888
  totalAmount: amountDueForCheckout,
@@ -2587,6 +2924,7 @@ export function NewBookingFlow({
2587
2924
  cancellationPolicyId: cancellationPolicyId || undefined,
2588
2925
  termsAcceptedAt: termsAcceptedAt ?? undefined,
2589
2926
  checkoutBreakdown,
2927
+ dependentAddOnSelection: dependentAddOnSelection || undefined,
2590
2928
  skipConfirmationCommunications: isAdmin && skipConfirmationCommunications ? true : undefined,
2591
2929
  disableAutoCommunications: isAdmin && disableAutoCommunications ? true : undefined,
2592
2930
  ...bookingSourceContext,
@@ -2614,6 +2952,7 @@ export function NewBookingFlow({
2614
2952
  termsAcceptedAt: termsAcceptedAt ?? undefined,
2615
2953
  skipConfirmationCommunications: isAdmin && skipConfirmationCommunications ? true : undefined,
2616
2954
  disableAutoCommunications: isAdmin && disableAutoCommunications ? true : undefined,
2955
+ dependentAddOnSelection: dependentAddOnSelection || undefined,
2617
2956
  ...bookingSourceContext,
2618
2957
  });
2619
2958
  pendingReservationRef.current = null;
@@ -2658,11 +2997,11 @@ export function NewBookingFlow({
2658
2997
  );
2659
2998
  return { line, breakdown };
2660
2999
  }),
2661
- feeLineItems: feeLineItemsWithAddOns,
3000
+ feeLineItems: augmentedModalFeeLineItems,
2662
3001
  returnPriceAdjustment: checkoutReturnLineAmount,
2663
3002
  cancellationPolicyFee,
2664
3003
  cancellationPolicyLabel: effectiveCancellationPolicyLabel,
2665
- subtotal: effectiveSubtotal,
3004
+ subtotal: displaySubtotal,
2666
3005
  tax: effectivePromoDiscountAmount > 0 ? effectiveTax : tax,
2667
3006
  totalQuantity,
2668
3007
  isTaxIncludedInPrice,
@@ -2694,11 +3033,11 @@ export function NewBookingFlow({
2694
3033
  bookingDate: datePart,
2695
3034
  successUrlOverride: undefined,
2696
3035
  ticketLines: ticketLinesForModal,
2697
- feeLineItems: feeLineItemsWithAddOns,
3036
+ feeLineItems: augmentedModalFeeLineItems,
2698
3037
  returnPriceAdjustment: checkoutReturnLineAmount,
2699
3038
  cancellationPolicyFee,
2700
3039
  cancellationPolicyLabel: effectiveCancellationPolicyLabel,
2701
- subtotal: effectiveSubtotal,
3040
+ subtotal: displaySubtotal,
2702
3041
  tax: effectivePromoDiscountAmount > 0 ? effectiveTax : tax,
2703
3042
  total: amountDueForCheckout,
2704
3043
  totalQuantity,
@@ -2959,48 +3298,50 @@ export function NewBookingFlow({
2959
3298
  </div>
2960
3299
  ) : (
2961
3300
  <>
2962
- {/* Date Selection */}
2963
- <div>
2964
- <div className="relative">
2965
- {loadingAvailabilities && (
2966
- <div className="absolute inset-0 bg-white/80 backdrop-blur-sm flex items-center justify-center z-10 rounded-lg">
2967
- <div className="flex flex-col items-center gap-3">
2968
- <div className="booking-loading-spinner" aria-hidden />
2969
- <div className="text-stone-600">{t('booking.loadingTimes')}</div>
3301
+ {flowUi?.showCalendar !== false && (
3302
+ /* Date Selection */
3303
+ <div>
3304
+ <div className="relative">
3305
+ {loadingAvailabilities && (
3306
+ <div className="absolute inset-0 bg-white/80 backdrop-blur-sm flex items-center justify-center z-10 rounded-lg">
3307
+ <div className="flex flex-col items-center gap-3">
3308
+ <div className="booking-loading-spinner" aria-hidden />
3309
+ <div className="text-stone-600">{t('booking.loadingTimes')}</div>
3310
+ </div>
2970
3311
  </div>
2971
- </div>
2972
- )}
2973
- <Calendar
2974
- availabilitiesByDate={availabilitiesByDate}
2975
- selectedDate={selectedDate}
2976
- syncVisibleWeekToSelectedDate={false}
2977
- selectableSoldOutDate={null}
2978
- isLoading={loadingAvailabilities || isFetchingMoreAvailabilities}
2979
- onDateSelect={(date) => {
2980
- setSelectedDate(date);
2981
- handleDateSelect(date);
2982
- if (suppressCalendarDateScroll) return;
2983
- // Scroll so calendar is almost at top and user sees the rest of the booking flow.
2984
- // Dialog: scroll inside contentRef. Full-page: fall back to window scroll.
2985
- setTimeout(() => {
2986
- const container = contentRef?.current;
2987
- if (!useWindowScroll && container && container.scrollHeight > container.clientHeight + 16) {
2988
- container.scrollBy({ top: 400, behavior: 'smooth' });
2989
- } else if (typeof window !== 'undefined') {
2990
- window.scrollBy({ top: 400, behavior: 'smooth' });
2991
- }
2992
- }, 100);
2993
- }}
2994
- timezone={companyTimezone}
2995
- earliestDate={earliestAvailabilityDate}
2996
- onVisibleRangeChange={handleVisibleRangeChange}
2997
- currency={currency}
2998
- showCapacity={isAdmin}
2999
- extraDiscountPercent={calendarDiscountPercent}
3000
- capDiscountBadgesToBookingDate={null}
3001
- />
3312
+ )}
3313
+ <Calendar
3314
+ availabilitiesByDate={availabilitiesByDate}
3315
+ selectedDate={selectedDate}
3316
+ syncVisibleWeekToSelectedDate={false}
3317
+ selectableSoldOutDate={null}
3318
+ isLoading={loadingAvailabilities || isFetchingMoreAvailabilities}
3319
+ onDateSelect={(date) => {
3320
+ setSelectedDate(date);
3321
+ handleDateSelect(date);
3322
+ if (suppressCalendarDateScroll) return;
3323
+ // Scroll so calendar is almost at top and user sees the rest of the booking flow.
3324
+ // Dialog: scroll inside contentRef. Full-page: fall back to window scroll.
3325
+ setTimeout(() => {
3326
+ const container = contentRef?.current;
3327
+ if (!useWindowScroll && container && container.scrollHeight > container.clientHeight + 16) {
3328
+ container.scrollBy({ top: 400, behavior: 'smooth' });
3329
+ } else if (typeof window !== 'undefined') {
3330
+ window.scrollBy({ top: 400, behavior: 'smooth' });
3331
+ }
3332
+ }, 100);
3333
+ }}
3334
+ timezone={companyTimezone}
3335
+ earliestDate={earliestAvailabilityDate}
3336
+ onVisibleRangeChange={handleVisibleRangeChange}
3337
+ currency={currency}
3338
+ showCapacity={isAdmin}
3339
+ extraDiscountPercent={calendarDiscountPercent}
3340
+ capDiscountBadgesToBookingDate={null}
3341
+ />
3342
+ </div>
3002
3343
  </div>
3003
- </div>
3344
+ )}
3004
3345
 
3005
3346
  {/* Form sections - equal spacing between each */}
3006
3347
  <div className="mt-6 space-y-6">
@@ -3012,7 +3353,14 @@ export function NewBookingFlow({
3012
3353
  if (!selectedAvailability) {
3013
3354
  return <ItineraryPlaceholder formattedDate={formattedDate} t={t} />;
3014
3355
  }
3015
- const itineraryItems = computeItineraryDisplay();
3356
+ const baseItineraryItems = computeItineraryDisplay();
3357
+ const itineraryItems = augmentItineraryItems
3358
+ ? augmentItineraryItems({
3359
+ selectedDate,
3360
+ selectedAvailability,
3361
+ itineraryItems: baseItineraryItems,
3362
+ })
3363
+ : baseItineraryItems;
3016
3364
  if (!itineraryItems || itineraryItems.length === 0) return null;
3017
3365
  const isBookingComplete = Boolean(selectedAvailability &&
3018
3366
  selectedReturnOption &&
@@ -3079,6 +3427,16 @@ export function NewBookingFlow({
3079
3427
  />
3080
3428
  )}
3081
3429
 
3430
+ {selectedDate && selectedAvailability && afterItinerary
3431
+ ? typeof afterItinerary === 'function'
3432
+ ? afterItinerary({
3433
+ selectedDate,
3434
+ selectedAvailability,
3435
+ itineraryItems: computeItineraryDisplay(),
3436
+ })
3437
+ : afterItinerary
3438
+ : null}
3439
+
3082
3440
  {/* Cancellation policy selection - all options from config, sorted by cheapest first. Also show when forced by promo. */}
3083
3441
  {selectedAvailability && ((pricingConfig?.cancellationPolicies?.length ?? 0) > 0 || forcedCancellationPolicy) && (() => {
3084
3442
  const sortedPolicies = [...(pricingConfig?.cancellationPolicies ?? [])].sort((a, b) => {
@@ -3141,12 +3499,15 @@ export function NewBookingFlow({
3141
3499
  {selectedAvailability && (
3142
3500
  <>
3143
3501
  <CheckoutForm
3144
- priceSummaryLines={checkoutPriceSummaryLines}
3145
- totalPrice={totalPrice}
3502
+ priceSummaryLines={displayCheckoutPriceSummaryLines}
3503
+ totalPrice={displayTotalPrice}
3146
3504
  totalSummaryLabel={undefined}
3147
3505
  subtotal={
3148
- subtotal !== totalFromSummary || effectivePromoDiscountAmount > 0 || addOnTotal > 0
3149
- ? effectiveSubtotal
3506
+ subtotal !== totalFromSummary ||
3507
+ effectivePromoDiscountAmount > 0 ||
3508
+ addOnTotal > 0 ||
3509
+ (priceSummaryAugmentation?.subtotalAdjustment ?? 0) !== 0
3510
+ ? displaySubtotal
3150
3511
  : undefined
3151
3512
  }
3152
3513
  taxAmount={
@@ -3253,4 +3614,3 @@ export function NewBookingFlow({
3253
3614
  </div>
3254
3615
  );
3255
3616
  }
3256
-