@ticketboothapp/booking 1.2.59 → 1.2.61
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.
|
@@ -49,14 +49,23 @@ interface CalendarProps {
|
|
|
49
49
|
displayMode?: 'times' | 'status';
|
|
50
50
|
/** Optional extra discount percent added to the day-level discount badge. */
|
|
51
51
|
extraDiscountPercent?: number;
|
|
52
|
-
/**
|
|
53
|
-
|
|
52
|
+
/**
|
|
53
|
+
* 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.
|
|
56
|
+
*/
|
|
57
|
+
capDiscountBadgesToBookingDate?: string | null;
|
|
54
58
|
/**
|
|
55
59
|
* When true (e.g. change-booking flow), scroll the visible grid once to the week containing
|
|
56
60
|
* `selectedDate` after the parent sets it from the current booking. Otherwise the grid stays
|
|
57
61
|
* anchored to the earliest available date.
|
|
58
62
|
*/
|
|
59
63
|
syncVisibleWeekToSelectedDate?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Optional date string (yyyy-MM-dd) that should remain selectable even when sold out.
|
|
66
|
+
* Used by change-booking so the original booked date can always be selected.
|
|
67
|
+
*/
|
|
68
|
+
selectableSoldOutDate?: string | null;
|
|
60
69
|
}
|
|
61
70
|
|
|
62
71
|
// ============ Constants ============
|
|
@@ -152,18 +161,21 @@ interface DateCellProps {
|
|
|
152
161
|
showCapacity?: boolean;
|
|
153
162
|
timezone: string;
|
|
154
163
|
displayMode: 'times' | 'status';
|
|
164
|
+
selectableSoldOutDate?: string | null;
|
|
155
165
|
onClick: () => void;
|
|
156
166
|
isMobile?: boolean;
|
|
157
167
|
}
|
|
158
168
|
|
|
159
169
|
const DateCell = memo(function DateCell({
|
|
160
170
|
date,
|
|
171
|
+
dateStr,
|
|
161
172
|
availability,
|
|
162
173
|
isSelected,
|
|
163
174
|
isToday,
|
|
164
175
|
showCapacity,
|
|
165
176
|
timezone,
|
|
166
177
|
displayMode,
|
|
178
|
+
selectableSoldOutDate = null,
|
|
167
179
|
onClick,
|
|
168
180
|
isMobile = false,
|
|
169
181
|
}: DateCellProps) {
|
|
@@ -171,8 +183,12 @@ const DateCell = memo(function DateCell({
|
|
|
171
183
|
const { locale } = useLocale();
|
|
172
184
|
const dateFnsLocale = dateFnsLocales[locale] || enUS;
|
|
173
185
|
const dayNumber = formatInTimeZone(date, timezone, 'd');
|
|
174
|
-
|
|
175
|
-
const
|
|
186
|
+
const allowPinnedSoldOutDate = Boolean(selectableSoldOutDate && selectableSoldOutDate === dateStr);
|
|
187
|
+
const hasAvailabilityData = availability !== null;
|
|
188
|
+
// When showCapacity (admin), allow selecting sold-out slots for overbooking.
|
|
189
|
+
// In change flow, also allow the original booked sold-out date to stay selectable.
|
|
190
|
+
const hasAvailability =
|
|
191
|
+
hasAvailabilityData && (!availability.isSoldOut || showCapacity || allowPinnedSoldOutDate);
|
|
176
192
|
const isDisabled = !hasAvailability;
|
|
177
193
|
|
|
178
194
|
// Format time for display. timeStr (e.g. "09:00") is already in company timezone (MDT).
|
|
@@ -389,8 +405,9 @@ export function Calendar({
|
|
|
389
405
|
isLoading = false,
|
|
390
406
|
displayMode = 'times',
|
|
391
407
|
extraDiscountPercent = 0,
|
|
392
|
-
|
|
408
|
+
capDiscountBadgesToBookingDate = null,
|
|
393
409
|
syncVisibleWeekToSelectedDate = false,
|
|
410
|
+
selectableSoldOutDate = null,
|
|
394
411
|
}: CalendarProps) {
|
|
395
412
|
const { t } = useTranslations();
|
|
396
413
|
const { locale } = useLocale();
|
|
@@ -837,14 +854,15 @@ export function Calendar({
|
|
|
837
854
|
};
|
|
838
855
|
}
|
|
839
856
|
});
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
857
|
+
|
|
858
|
+
const anchor = capDiscountBadgesToBookingDate?.trim() || null;
|
|
859
|
+
if (anchor) {
|
|
860
|
+
const anchorDiscount = map[anchor]?.totalDiscountPercent;
|
|
861
|
+
if (anchorDiscount != null && anchorDiscount > 0) {
|
|
844
862
|
const capped: Record<string, DateAvailability> = {};
|
|
845
863
|
for (const [dateStr, availability] of Object.entries(map)) {
|
|
846
864
|
const raw = availability.totalDiscountPercent;
|
|
847
|
-
const adjusted = raw != null ? Math.min(raw,
|
|
865
|
+
const adjusted = raw != null ? Math.min(raw, anchorDiscount) : undefined;
|
|
848
866
|
capped[dateStr] = {
|
|
849
867
|
...availability,
|
|
850
868
|
totalDiscountPercent: adjusted,
|
|
@@ -855,7 +873,7 @@ export function Calendar({
|
|
|
855
873
|
}
|
|
856
874
|
|
|
857
875
|
return map;
|
|
858
|
-
}, [availabilitiesByDate, timezone, currency, extraDiscountPercent,
|
|
876
|
+
}, [availabilitiesByDate, timezone, currency, extraDiscountPercent, capDiscountBadgesToBookingDate]);
|
|
859
877
|
|
|
860
878
|
// Get display range for header
|
|
861
879
|
const displayRange = useMemo(() => {
|
|
@@ -884,9 +902,16 @@ export function Calendar({
|
|
|
884
902
|
const handleDateClick = (date: Date) => {
|
|
885
903
|
const dateStr = formatInTimeZone(date, timezone, 'yyyy-MM-dd');
|
|
886
904
|
const availability = dateAvailabilityMap[dateStr] || null;
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
905
|
+
|
|
906
|
+
const allowPinnedSoldOutDate = Boolean(
|
|
907
|
+
selectableSoldOutDate &&
|
|
908
|
+
selectableSoldOutDate === dateStr &&
|
|
909
|
+
availability?.isSoldOut,
|
|
910
|
+
);
|
|
911
|
+
|
|
912
|
+
// Allow selection when there are availabilities; when showCapacity (admin), allow sold-out for overbooking.
|
|
913
|
+
// In change flow, also allow re-selecting the original booked sold-out date.
|
|
914
|
+
if (availability && (!availability.isSoldOut || showCapacity || allowPinnedSoldOutDate)) {
|
|
890
915
|
onDateSelect(dateStr);
|
|
891
916
|
}
|
|
892
917
|
};
|
|
@@ -1026,7 +1051,13 @@ export function Calendar({
|
|
|
1026
1051
|
const hasAvailabilityData = availability !== null;
|
|
1027
1052
|
const isSoldOut = availability?.isSoldOut === true;
|
|
1028
1053
|
// When showCapacity (admin), sold-out days are still selectable for overbooking
|
|
1029
|
-
const
|
|
1054
|
+
const allowPinnedSoldOutDate = Boolean(
|
|
1055
|
+
selectableSoldOutDate && selectableSoldOutDate === dateStr && isSoldOut,
|
|
1056
|
+
);
|
|
1057
|
+
const isAvailable =
|
|
1058
|
+
hasAvailabilityData &&
|
|
1059
|
+
isInAllowedRange &&
|
|
1060
|
+
(!isSoldOut || showCapacity || allowPinnedSoldOutDate);
|
|
1030
1061
|
const isToday = isSameDay(day, new Date());
|
|
1031
1062
|
|
|
1032
1063
|
const dayClass = cn(
|
|
@@ -1036,7 +1067,7 @@ export function Calendar({
|
|
|
1036
1067
|
isSoldOut && showCapacity && styles.calendarDropdownDaySoldOutAdmin,
|
|
1037
1068
|
!isSoldOut && !isInAllowedRange && styles.calendarDropdownDayMuted,
|
|
1038
1069
|
!hasAvailabilityData && isInAllowedRange && styles.calendarDropdownDayMuted,
|
|
1039
|
-
|
|
1070
|
+
isSelected && isAvailable && styles.calendarDropdownDaySelected,
|
|
1040
1071
|
isAvailable && !isSelected && styles.calendarDropdownDayAvailable,
|
|
1041
1072
|
isToday && isAvailable && !isSelected && styles.calendarDropdownDayToday
|
|
1042
1073
|
);
|
|
@@ -1140,6 +1171,7 @@ export function Calendar({
|
|
|
1140
1171
|
showCapacity={showCapacity}
|
|
1141
1172
|
timezone={timezone}
|
|
1142
1173
|
displayMode={displayMode}
|
|
1174
|
+
selectableSoldOutDate={selectableSoldOutDate}
|
|
1143
1175
|
onClick={() => handleDateClick(date)}
|
|
1144
1176
|
isMobile={isMobile}
|
|
1145
1177
|
/>
|
|
@@ -193,6 +193,8 @@ export default function ChangeBookingDialog({
|
|
|
193
193
|
bookingReference: booking.bookingReference ?? null,
|
|
194
194
|
dateTime: booking.dateTime ?? null,
|
|
195
195
|
availabilityId: booking.availabilityId ?? null,
|
|
196
|
+
/** Parent id (`p_…`) when API did not persist option id; BookingFlow resolves option via availability row. */
|
|
197
|
+
productId: booking.productId ?? null,
|
|
196
198
|
productOptionId: effectiveProductOptionIdForChangeFlow(booking),
|
|
197
199
|
pickupLocationId: booking.pickupLocationId ?? null,
|
|
198
200
|
returnAvailabilityId: booking.returnAvailabilityId ?? null,
|
|
@@ -208,6 +210,8 @@ export default function ChangeBookingDialog({
|
|
|
208
210
|
},
|
|
209
211
|
promoCode: lockedPromoCode,
|
|
210
212
|
cancellationPolicyId: booking.cancellationPolicyId ?? null,
|
|
213
|
+
returnUnitFloorPerPerson:
|
|
214
|
+
(booking as { returnUnitFloorPerPerson?: number | null }).returnUnitFloorPerPerson ?? null,
|
|
211
215
|
};
|
|
212
216
|
const receiptCurrency = (booking.receipt.currency as Currency) || 'CAD';
|
|
213
217
|
// Use booking + static config only — not `product.name` (minimal → API product swaps labels).
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ## Change-booking pricing — product rules (frontend)
|
|
3
|
+
*
|
|
4
|
+
* Use this file as the **spec checklist** when debating behavior with product; BookingFlow adds **gates** (parent product,
|
|
5
|
+
* channel) on top of these formulas.
|
|
6
|
+
*
|
|
7
|
+
* ### 1. When receipt “paid floors” apply
|
|
8
|
+
* Only **customer self-serve** change flow, when the booking’s **parent catalog product** matches the **loaded product**
|
|
9
|
+
* (`changeFlowApplyReceiptPaidFloors` in BookingFlow). **Provider dashboard** change flow uses **live catalog only** (no
|
|
10
|
+
* floors) so a cheaper date/return yields a lower total / refund via signed balance.
|
|
11
|
+
*
|
|
12
|
+
* ### 2. Tickets (per category)
|
|
13
|
+
* For each category where we can infer an average **unit paid** from the stored receipt:
|
|
14
|
+
* - Among seats **up to** the **originally booked** count for that category, **unit price** =
|
|
15
|
+
* **`max(receipt average unit, live catalog unit)`** — not the minimum; the guest never pays **below** what they paid
|
|
16
|
+
* for those seats **and** never **below** today’s list price for those seats.
|
|
17
|
+
* - Any **extra** seats beyond the original count for that category: **live catalog only** (no receipt floor).
|
|
18
|
+
* If we cannot infer a receipt unit for a category, that line stays **live catalog** only.
|
|
19
|
+
*
|
|
20
|
+
* ### 3. Product fees (config fees, per line)
|
|
21
|
+
* Split the party into **protected** headcount (≤ original total ticket count) vs **incremental** passengers.
|
|
22
|
+
* For each fee line: **`max(receipt-derived fee per person, live fee per person)`** on protected passengers; incremental
|
|
23
|
+
* passengers pay each line’s **live** per-person amount only.
|
|
24
|
+
*
|
|
25
|
+
* ### 4. Return option — **order total / checkout**
|
|
26
|
+
* When §1 floors apply and a per-person return floor exists (API `returnUnitFloorPerPerson` or receipt RETURN lines):
|
|
27
|
+
* **per person** = **`max(live catalog return for the selected slot, floor)`**; **row total** = party size × that amount.
|
|
28
|
+
* Provider dashboard: **live catalog return only** (no floor).
|
|
29
|
+
*
|
|
30
|
+
* ### 5. Return option — **picker UI only** (BookingFlow)
|
|
31
|
+
* **Self-serve:** return cards use the floored per-person price when a floor exists (aligned with §4).
|
|
32
|
+
* **Provider dashboard:** cards show **catalog** prices only (no floor).
|
|
33
|
+
*
|
|
34
|
+
* ### 6. Quote “new booking” total & balance
|
|
35
|
+
* **FE proposed total** = full cart math (subtotal + tax − promo), cent-rounded; optional **1¢ reconcile** to old receipt
|
|
36
|
+
* total when the difference is only rounding noise (`resolveChangeFlowNewBookingTotal`).
|
|
37
|
+
* **Customer** owes **`max(0, newTotal − oldReceipt)`**; **provider** inline pricing may show a **signed** delta.
|
|
38
|
+
*/
|
|
39
|
+
import { reconcileChangeBookingProposedTotal } from '../currency';
|
|
40
|
+
|
|
41
|
+
/** Money in major units, rounded to cents (half-up). */
|
|
42
|
+
export function roundMoney(amount: number): number {
|
|
43
|
+
return Math.round(amount * 100) / 100;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One ticket row: protected seats use `max(receipt unit, live unit)`; extra qty uses live only.
|
|
48
|
+
*/
|
|
49
|
+
export function changeFlowTicketLineTotalWithReceiptFloor(input: {
|
|
50
|
+
qty: number;
|
|
51
|
+
/** Seats originally booked in this category (caps “protected” seats). */
|
|
52
|
+
baselineQtyForCategory: number;
|
|
53
|
+
/** Average unit paid on receipt for this category; omit if unknown. */
|
|
54
|
+
receiptUnitFloor: number | undefined;
|
|
55
|
+
/** Current catalog line total (before floors). */
|
|
56
|
+
liveLineTotal: number;
|
|
57
|
+
}): number {
|
|
58
|
+
const qty = Math.max(0, input.qty);
|
|
59
|
+
if (qty <= 0) return 0;
|
|
60
|
+
const liveUnit = Math.max(0, Number(input.liveLineTotal) || 0) / qty;
|
|
61
|
+
if (input.receiptUnitFloor == null || !Number.isFinite(input.receiptUnitFloor)) {
|
|
62
|
+
return Math.max(0, Number(input.liveLineTotal) || 0);
|
|
63
|
+
}
|
|
64
|
+
const baselineQty = Math.max(0, input.baselineQtyForCategory);
|
|
65
|
+
const protectedQty = Math.min(qty, baselineQty);
|
|
66
|
+
const incrementalQty = Math.max(0, qty - baselineQty);
|
|
67
|
+
const protectedUnit = Math.max(input.receiptUnitFloor, liveUnit);
|
|
68
|
+
return protectedQty * protectedUnit + incrementalQty * liveUnit;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One fee row: first `initialTicketCount` passengers use `max(receipt per-person, live per-person)`; rest live only.
|
|
73
|
+
*/
|
|
74
|
+
export function changeFlowFeeLineTotalWithReceiptFloor(input: {
|
|
75
|
+
totalQuantity: number;
|
|
76
|
+
initialTicketCount: number;
|
|
77
|
+
bookedFeePerPerson: number | undefined;
|
|
78
|
+
liveFeeLineTotal: number;
|
|
79
|
+
}): number {
|
|
80
|
+
const totalQuantity = Math.max(0, input.totalQuantity);
|
|
81
|
+
if (totalQuantity <= 0) return Math.max(0, Number(input.liveFeeLineTotal) || 0);
|
|
82
|
+
const liveTotal = Math.max(0, Number(input.liveFeeLineTotal) || 0);
|
|
83
|
+
const livePer = liveTotal / totalQuantity;
|
|
84
|
+
if (input.bookedFeePerPerson == null || !Number.isFinite(input.bookedFeePerPerson)) {
|
|
85
|
+
return liveTotal;
|
|
86
|
+
}
|
|
87
|
+
const protectedP = Math.min(Math.max(0, input.initialTicketCount), totalQuantity);
|
|
88
|
+
const incrementalP = Math.max(0, totalQuantity - protectedP);
|
|
89
|
+
const protectedPerPerson = Math.max(input.bookedFeePerPerson, livePer);
|
|
90
|
+
return protectedP * protectedPerPerson + incrementalP * livePer;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Return add-on: **max(live, receipt floor)** per person (then multiply by party size in BookingFlow).
|
|
95
|
+
*/
|
|
96
|
+
export function changeFlowReturnPerPersonWithReceiptFloor(input: {
|
|
97
|
+
livePerPerson: number;
|
|
98
|
+
receiptFloorPerPerson: number | null;
|
|
99
|
+
}): number {
|
|
100
|
+
const live = Number.isFinite(input.livePerPerson) ? input.livePerPerson : 0;
|
|
101
|
+
const floor = input.receiptFloorPerPerson;
|
|
102
|
+
if (floor == null || !Number.isFinite(floor)) return live;
|
|
103
|
+
return Math.max(live, floor);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Product: **New booking price** for a change is the same cart total as a fresh booking
|
|
108
|
+
* (subtotal + tax − discounts), in cents. We send that on quotes so it matches line items and the server breakdown.
|
|
109
|
+
*
|
|
110
|
+
* If the customer is only “off” from the old receipt by a penny of float/rounding, treat as no change to the rolled-up
|
|
111
|
+
* total (see `reconcileChangeBookingProposedTotal`).
|
|
112
|
+
*/
|
|
113
|
+
export function resolveChangeFlowNewBookingTotal(input: {
|
|
114
|
+
isChangeFlow: boolean;
|
|
115
|
+
/** `effectiveSubtotal + effectiveTax - promo` from the live cart. */
|
|
116
|
+
cartTotal: number;
|
|
117
|
+
originalReceiptTotal: number | null | undefined;
|
|
118
|
+
}): number {
|
|
119
|
+
if (!input.isChangeFlow) {
|
|
120
|
+
return input.cartTotal;
|
|
121
|
+
}
|
|
122
|
+
const rounded = roundMoney(input.cartTotal);
|
|
123
|
+
if (input.originalReceiptTotal == null) {
|
|
124
|
+
return input.cartTotal;
|
|
125
|
+
}
|
|
126
|
+
return reconcileChangeBookingProposedTotal(rounded, input.originalReceiptTotal);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Product: **What the customer owes** for the difference is `max(0, newTotal − oldReceipt)`.
|
|
131
|
+
* **Provider dashboard** may show a signed delta (credits/refunds as negative).
|
|
132
|
+
*/
|
|
133
|
+
export function changeFlowBalanceVsOriginal(input: {
|
|
134
|
+
newTotal: number;
|
|
135
|
+
originalReceiptTotal: number;
|
|
136
|
+
/** `customer` = self-serve & admin change; `provider` = provider inline editor (can owe the guest). */
|
|
137
|
+
audience: 'customer' | 'provider';
|
|
138
|
+
}): number {
|
|
139
|
+
const delta = input.newTotal - input.originalReceiptTotal;
|
|
140
|
+
return input.audience === 'provider' ? delta : Math.max(0, delta);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Product: **What we show** in the change-flow price row — either the provider’s manual preview totals, or the same
|
|
145
|
+
* cart numbers as everywhere else (no second shadow total).
|
|
146
|
+
*/
|
|
147
|
+
export function resolveChangeFlowDisplayedAmounts(input: {
|
|
148
|
+
providerPreview: {
|
|
149
|
+
totalAmount: number;
|
|
150
|
+
subtotalBeforeTax: number;
|
|
151
|
+
taxAmount: number;
|
|
152
|
+
} | null;
|
|
153
|
+
fromCart: { total: number; subtotal: number; tax: number };
|
|
154
|
+
}): { total: number; subtotal: number; tax: number } {
|
|
155
|
+
if (input.providerPreview) {
|
|
156
|
+
return {
|
|
157
|
+
total: input.providerPreview.totalAmount,
|
|
158
|
+
subtotal: input.providerPreview.subtotalBeforeTax,
|
|
159
|
+
tax: input.providerPreview.taxAmount,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return { ...input.fromCart };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Product: **Main “total owed” number** should not show -0.00 or noise when the balance is effectively zero.
|
|
167
|
+
*/
|
|
168
|
+
export function normalizeNearZeroOwed(amount: number): number {
|
|
169
|
+
const r = roundMoney(amount);
|
|
170
|
+
return Math.abs(r) < 0.01 ? 0 : r;
|
|
171
|
+
}
|