@ticketboothapp/booking 1.2.61 → 1.2.63

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.
@@ -23,8 +23,6 @@ export interface DateAvailability {
23
23
  availabilityCount: number;
24
24
  isSoldOut: boolean;
25
25
  minPrice?: number;
26
- hasDiscount?: boolean;
27
- discountPercent?: number;
28
26
  totalVacancies?: number; // Total available tickets across all availabilities for this date
29
27
  startTimes?: string[]; // Array of start times for this date (e.g., ["09:00", "10:00", "14:00"])
30
28
  soldOutTimes?: Set<string>; // Set of sold out time strings (e.g., ["09:00", "14:00"])
@@ -51,8 +49,8 @@ interface CalendarProps {
51
49
  extraDiscountPercent?: number;
52
50
  /**
53
51
  * Change-booking: yyyy-MM-dd of the **existing** booking's tour date. When that date exists in
54
- * [availabilitiesByDate], each day's badge uses `min(that day's catalog %, the booking date's catalog %)` so
55
- * the cap stays tied to the current booking, not which day is selected in the UI.
52
+ * [availabilitiesByDate], each day's badge is `min(that day's %, the booking date's catalog %)` — including
53
+ * when the booking date's discount is 0% (no day shows a higher % than the booked day).
56
54
  */
57
55
  capDiscountBadgesToBookingDate?: string | null;
58
56
  /**
@@ -66,6 +64,16 @@ interface CalendarProps {
66
64
  * Used by change-booking so the original booked date can always be selected.
67
65
  */
68
66
  selectableSoldOutDate?: string | null;
67
+ /**
68
+ * Self-serve change booking: treat a day as sold out when no single departure slot has at least
69
+ * this many effective seats (after optional {@link getEffectiveVacancies}). New booking flow omits this.
70
+ */
71
+ partySizeRequiredForCalendarSelection?: number;
72
+ /**
73
+ * When {@link partySizeRequiredForCalendarSelection} is set, per-row seat counts for calendar logic
74
+ * (e.g. change flow seat release on the original slot). If omitted, raw `vacancies` are used.
75
+ */
76
+ getEffectiveVacancies?: (availability: Availability) => number;
69
77
  }
70
78
 
71
79
  // ============ Constants ============
@@ -237,9 +245,10 @@ const DateCell = memo(function DateCell({
237
245
  const ariaLabel = useMemo(() => {
238
246
  const parts = [dayNumber];
239
247
  if (availability?.isSoldOut) parts.push(' - Sold out');
240
- if (availability?.hasDiscount) parts.push(` - ${availability.discountPercent}% off`);
248
+ const pct = availability?.totalDiscountPercent;
249
+ if (pct != null && pct > 0) parts.push(` - ${pct}% off`);
241
250
  return parts.join('');
242
- }, [dayNumber, availability?.isSoldOut, availability?.hasDiscount, availability?.discountPercent]);
251
+ }, [dayNumber, availability?.isSoldOut, availability?.totalDiscountPercent]);
243
252
 
244
253
  return (
245
254
  <button
@@ -375,13 +384,6 @@ const DateCell = memo(function DateCell({
375
384
  </div>
376
385
  )
377
386
  )}
378
-
379
- {/* Discount Badge - desktop only (mobile uses corner tag) */}
380
- {!isMobile && availability.hasDiscount && availability.discountPercent && (availability.totalVacancies === undefined || availability.totalVacancies >= 5) && (
381
- <div className={styles.calendarDiscountBadge}>
382
- -{availability.discountPercent}%
383
- </div>
384
- )}
385
387
  </>
386
388
  ) : null}
387
389
  </div>
@@ -408,6 +410,8 @@ export function Calendar({
408
410
  capDiscountBadgesToBookingDate = null,
409
411
  syncVisibleWeekToSelectedDate = false,
410
412
  selectableSoldOutDate = null,
413
+ partySizeRequiredForCalendarSelection,
414
+ getEffectiveVacancies,
411
415
  }: CalendarProps) {
412
416
  const { t } = useTranslations();
413
417
  const { locale } = useLocale();
@@ -753,6 +757,20 @@ export function Calendar({
753
757
  // Use plain object instead of Map so React can detect changes properly
754
758
  const dateAvailabilityMap = useMemo(() => {
755
759
  const map: Record<string, DateAvailability> = {};
760
+ const partyReq =
761
+ !showCapacity &&
762
+ partySizeRequiredForCalendarSelection != null &&
763
+ partySizeRequiredForCalendarSelection > 0
764
+ ? partySizeRequiredForCalendarSelection
765
+ : 0;
766
+
767
+ const seatCountForAvailability = (avail: Availability): number => {
768
+ const raw =
769
+ getEffectiveVacancies != null
770
+ ? getEffectiveVacancies(avail)
771
+ : avail.vacancies ?? 0;
772
+ return Math.max(0, raw);
773
+ };
756
774
 
757
775
  Object.entries(availabilitiesByDate).forEach(([dateStr, availabilities]) => {
758
776
  if (availabilities && availabilities.length > 0) {
@@ -776,11 +794,24 @@ export function Calendar({
776
794
 
777
795
  const minPrice = adultPrices.length > 0 ? Math.min(...adultPrices) : undefined;
778
796
 
779
- // Check if sold out (all availabilities have 0 vacancies)
780
- const isSoldOut = availabilities.every(avail => avail.vacancies === 0);
797
+ // Sold out: no seats on any row (using effective vacancies when provided), and/or (change flow)
798
+ // no single departure row can fit the fixed party size.
799
+ const isEverySlotZero = availabilities.every((avail) => seatCountForAvailability(avail) === 0);
800
+ let isSoldOut = isEverySlotZero;
801
+ if (!isSoldOut && partyReq > 0) {
802
+ const hasSlotForParty = availabilities.some(
803
+ (avail) => seatCountForAvailability(avail) >= partyReq,
804
+ );
805
+ if (!hasSlotForParty) {
806
+ isSoldOut = true;
807
+ }
808
+ }
781
809
 
782
810
  // Calculate total vacancies across all availabilities for this date
783
- const totalVacancies = availabilities.reduce((sum, avail) => sum + avail.vacancies, 0);
811
+ const totalVacancies = availabilities.reduce(
812
+ (sum, avail) => sum + seatCountForAvailability(avail),
813
+ 0,
814
+ );
784
815
 
785
816
  // Extract start times from availabilities and track which are sold out
786
817
  const timeAvailabilityMap = new Map<string, { isSoldOut: boolean; vacancies: number }>();
@@ -797,16 +828,17 @@ export function Calendar({
797
828
  if (timeMatch) timeStr = timeMatch[1];
798
829
  }
799
830
  if (timeStr) {
831
+ const vac = seatCountForAvailability(avail);
800
832
  const existing = timeAvailabilityMap.get(timeStr);
801
833
  if (existing) {
802
834
  timeAvailabilityMap.set(timeStr, {
803
- isSoldOut: existing.isSoldOut && avail.vacancies === 0,
804
- vacancies: existing.vacancies + avail.vacancies
835
+ isSoldOut: existing.isSoldOut && vac === 0,
836
+ vacancies: existing.vacancies + vac
805
837
  });
806
838
  } else {
807
839
  timeAvailabilityMap.set(timeStr, {
808
- isSoldOut: avail.vacancies === 0,
809
- vacancies: avail.vacancies
840
+ isSoldOut: vac === 0,
841
+ vacancies: vac
810
842
  });
811
843
  }
812
844
  // Per-time capacity for admin: first availability for this time wins
@@ -831,7 +863,9 @@ export function Calendar({
831
863
  const startTimes = Array.from(timeAvailabilityMap.keys()).sort();
832
864
  const soldOutTimes = new Set(
833
865
  Array.from(timeAvailabilityMap.entries())
834
- .filter(([, info]) => info.isSoldOut)
866
+ .filter(([, info]) =>
867
+ partyReq > 0 ? info.vacancies < partyReq : info.isSoldOut,
868
+ )
835
869
  .map(([time]) => time)
836
870
  );
837
871
 
@@ -856,24 +890,34 @@ export function Calendar({
856
890
  });
857
891
 
858
892
  const anchor = capDiscountBadgesToBookingDate?.trim() || null;
859
- if (anchor) {
860
- const anchorDiscount = map[anchor]?.totalDiscountPercent;
861
- if (anchorDiscount != null && anchorDiscount > 0) {
862
- const capped: Record<string, DateAvailability> = {};
863
- for (const [dateStr, availability] of Object.entries(map)) {
864
- const raw = availability.totalDiscountPercent;
865
- const adjusted = raw != null ? Math.min(raw, anchorDiscount) : undefined;
866
- capped[dateStr] = {
867
- ...availability,
868
- totalDiscountPercent: adjusted,
869
- };
870
- }
871
- return capped;
893
+ /** Change-booking: never show a day’s badge higher than the booking date’s catalog % (y); includes y === 0. */
894
+ if (anchor && map[anchor]) {
895
+ const anchorDiscount = map[anchor].totalDiscountPercent ?? 0;
896
+ const capped: Record<string, DateAvailability> = {};
897
+ for (const [dateStr, availability] of Object.entries(map)) {
898
+ const raw = availability.totalDiscountPercent;
899
+ const adjusted =
900
+ raw != null ? Math.min(raw, anchorDiscount) : undefined;
901
+ capped[dateStr] = {
902
+ ...availability,
903
+ totalDiscountPercent:
904
+ adjusted !== undefined && adjusted > 0 ? adjusted : undefined,
905
+ };
872
906
  }
907
+ return capped;
873
908
  }
874
909
 
875
910
  return map;
876
- }, [availabilitiesByDate, timezone, currency, extraDiscountPercent, capDiscountBadgesToBookingDate]);
911
+ }, [
912
+ availabilitiesByDate,
913
+ timezone,
914
+ currency,
915
+ extraDiscountPercent,
916
+ capDiscountBadgesToBookingDate,
917
+ showCapacity,
918
+ partySizeRequiredForCalendarSelection,
919
+ getEffectiveVacancies,
920
+ ]);
877
921
 
878
922
  // Get display range for header
879
923
  const displayRange = useMemo(() => {
@@ -48,6 +48,8 @@ interface CancellationPolicySelectorProps {
48
48
  } | null;
49
49
  /** Optional content rendered inside each policy row (e.g. deposit refundability for private shuttles) */
50
50
  rowSubtitle?: React.ReactNode;
51
+ /** Hide +fee amounts (customer change flow until server-backed pricing). */
52
+ suppressFees?: boolean;
51
53
  }
52
54
 
53
55
  export function CancellationPolicySelector({
@@ -59,6 +61,7 @@ export function CancellationPolicySelector({
59
61
  onPolicySelect,
60
62
  forcedPolicy,
61
63
  rowSubtitle,
64
+ suppressFees = false,
62
65
  }: CancellationPolicySelectorProps) {
63
66
  return (
64
67
  <div className={styles.wrapper}>
@@ -92,7 +95,7 @@ export function CancellationPolicySelector({
92
95
  )}
93
96
  </div>
94
97
  </div>
95
- <span className={`${styles.fee} ${styles.feeFree}`}>
98
+ <span className={`${styles.fee} ${styles.feeFree}`}>
96
99
  {t('booking.included') ?? 'Included'}
97
100
  </span>
98
101
  </div>
@@ -131,7 +134,11 @@ export function CancellationPolicySelector({
131
134
  </div>
132
135
  </div>
133
136
  <span className={`${styles.fee} ${isFree ? styles.feeFree : styles.feePaid}`}>
134
- {isFree ? (t('booking.included') ?? 'Included') : `+${formatCurrencyAmount(fee, currency, locale as 'en' | 'fr')}`}
137
+ {suppressFees
138
+ ? '—'
139
+ : isFree
140
+ ? (t('booking.included') ?? 'Included')
141
+ : `+${formatCurrencyAmount(fee, currency, locale as 'en' | 'fr')}`}
135
142
  </span>
136
143
  </button>
137
144
  );
@@ -6,7 +6,9 @@ import { AnimatePresence, motion } from 'framer-motion';
6
6
  import { getProduct, type Product } from '../../lib/booking-api';
7
7
  import { useBookingHost } from '../../runtime';
8
8
  import { formatCurrencyAmount, type Currency } from '../../lib/currency';
9
- import { BookingFlow, type ChangeFlowSelectionPreview } from './BookingFlow';
9
+ import { CURRENCIES, DEFAULT_CURRENCY } from './CurrencySwitcher';
10
+ import { ChangeBookingFlow } from './ChangeBookingFlow';
11
+ import type { ChangeFlowSelectionPreview } from './booking-flow-types';
10
12
  import { useBookingSourceMetadataFromLocation } from '../../hooks/useBookingSourceMetadataFromLocation';
11
13
  import styles from './BookingDialog.module.css';
12
14
  import './booking-flow.css';
@@ -189,6 +191,13 @@ export default function ChangeBookingDialog({
189
191
 
190
192
  const originalPromo = getOriginalPromoFromReceipt(booking.receipt);
191
193
  const lockedPromoCode = booking.receipt.promoCode?.trim() || originalPromo?.code || null;
194
+ const receiptCurrency = (() => {
195
+ const raw =
196
+ (booking.receipt?.currency && String(booking.receipt.currency).trim()) ||
197
+ (booking.currency && String(booking.currency).trim()) ||
198
+ '';
199
+ return raw && (CURRENCIES as readonly string[]).includes(raw) ? (raw as Currency) : DEFAULT_CURRENCY;
200
+ })();
192
201
  const initialValues = {
193
202
  bookingReference: booking.bookingReference ?? null,
194
203
  dateTime: booking.dateTime ?? null,
@@ -212,8 +221,8 @@ export default function ChangeBookingDialog({
212
221
  cancellationPolicyId: booking.cancellationPolicyId ?? null,
213
222
  returnUnitFloorPerPerson:
214
223
  (booking as { returnUnitFloorPerPerson?: number | null }).returnUnitFloorPerPerson ?? null,
224
+ currency: receiptCurrency,
215
225
  };
216
- const receiptCurrency = (booking.receipt.currency as Currency) || 'CAD';
217
226
  // Use booking + static config only — not `product.name` (minimal → API product swaps labels).
218
227
  const tourName =
219
228
  booking.productName?.trim() ||
@@ -249,11 +258,13 @@ export default function ChangeBookingDialog({
249
258
  );
250
259
  const newTotalFormatted =
251
260
  newBookingPreview != null
252
- ? formatCurrencyAmount(
253
- newBookingPreview.selectionTotal,
254
- newBookingPreview.selectionCurrency,
255
- 'en',
256
- )
261
+ ? newBookingPreview.selectionTotal == null
262
+ ? '—'
263
+ : formatCurrencyAmount(
264
+ newBookingPreview.selectionTotal,
265
+ newBookingPreview.selectionCurrency,
266
+ 'en',
267
+ )
257
268
  : '—';
258
269
 
259
270
  const visuallyMatches =
@@ -401,18 +412,17 @@ export default function ChangeBookingDialog({
401
412
  )}
402
413
  </AnimatePresence>
403
414
  </div>
404
- <BookingFlow
415
+ <ChangeBookingFlow
405
416
  product={product}
406
417
  productId={productSlug}
407
418
  onBack={onClose}
408
- currency="CAD"
419
+ currency={receiptCurrency}
409
420
  contentRef={contentRef}
410
421
  bookingSourceAttribution={bookingSourceAttribution}
411
422
  onSuccess={() => {
412
423
  onChangeCompleted?.(newBookingPreview);
413
424
  onClose();
414
425
  }}
415
- mode="change"
416
426
  hideItineraryBox
417
427
  originalReceipt={{
418
428
  subtotal: booking.receipt.subtotalBeforeTax,