@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
- /** When true, cap day discount badges to the currently selected date's discount percent. */
53
- capDiscountToSelectedDate?: boolean;
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
- // When showCapacity (admin), allow selecting sold-out slots for overbooking
175
- const hasAvailability = availability !== null && (!availability.isSoldOut || showCapacity);
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
- capDiscountToSelectedDate = false,
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
- if (capDiscountToSelectedDate && selectedDate) {
842
- const selectedDiscount = map[selectedDate]?.totalDiscountPercent;
843
- if (selectedDiscount != null && selectedDiscount > 0) {
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, selectedDiscount) : undefined;
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, capDiscountToSelectedDate, selectedDate]);
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
- // Allow selection when there are availabilities; when showCapacity (admin), allow sold-out for overbooking
889
- if (availability && (!availability.isSoldOut || showCapacity)) {
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 isAvailable = hasAvailabilityData && isInAllowedRange && (!isSoldOut || showCapacity);
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
- isAvailable && isSelected && styles.calendarDropdownDaySelected,
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
+ }