@ticketboothapp/booking 1.2.61 → 1.2.62
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/CHANGE_BOOKING_BE_HANDOFF.md +86 -0
- package/package.json +1 -1
- package/src/components/booking/AddOnsSection.tsx +6 -3
- package/src/components/booking/BookingFlow.tsx +19 -5344
- package/src/components/booking/Calendar.tsx +79 -35
- package/src/components/booking/CancellationPolicySelector.tsx +9 -2
- package/src/components/booking/ChangeBookingDialog.tsx +20 -10
- package/src/components/booking/ChangeBookingFlow.tsx +5381 -0
- package/src/components/booking/ChangeBookingPricingDriftPanel.tsx +268 -0
- package/src/components/booking/CheckoutForm.tsx +29 -19
- package/src/components/booking/CurrencySwitcher.tsx +1 -1
- package/src/components/booking/MealDrinkAddOnSelector.tsx +4 -2
- package/src/components/booking/NewBookingFlow.tsx +3256 -0
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +8 -6
- package/src/components/booking/PromoCodeInput.tsx +4 -1
- package/src/components/booking/ReturnTimeSelector.tsx +12 -5
- package/src/components/booking/TicketSelector.tsx +6 -1
- package/src/components/booking/booking-flow-types.ts +143 -0
- package/src/index.ts +9 -1
- package/src/lib/booking/change-booking-pricing-drift.ts +331 -0
- package/src/lib/booking/change-booking-server-preview.ts +139 -0
- package/src/lib/booking/change-flow-pricing.ts +157 -23
- package/src/lib/booking-api.ts +72 -0
|
@@ -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
|
|
55
|
-
* the
|
|
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
|
-
|
|
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?.
|
|
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
|
-
//
|
|
780
|
-
|
|
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(
|
|
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 &&
|
|
804
|
-
vacancies: existing.vacancies +
|
|
835
|
+
isSoldOut: existing.isSoldOut && vac === 0,
|
|
836
|
+
vacancies: existing.vacancies + vac
|
|
805
837
|
});
|
|
806
838
|
} else {
|
|
807
839
|
timeAvailabilityMap.set(timeStr, {
|
|
808
|
-
isSoldOut:
|
|
809
|
-
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]) =>
|
|
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
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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
|
-
{
|
|
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 {
|
|
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
|
-
?
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
<
|
|
415
|
+
<ChangeBookingFlow
|
|
405
416
|
product={product}
|
|
406
417
|
productId={productSlug}
|
|
407
418
|
onBack={onClose}
|
|
408
|
-
currency=
|
|
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,
|