@ticketboothapp/booking 1.2.112 → 1.2.113

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.112",
3
+ "version": "1.2.113",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -76,8 +76,10 @@
76
76
  --cal-discount-tag-bg: rgba(239, 68, 68, 0.9);
77
77
  --cal-discount-tag-text: #fff;
78
78
 
79
- /* Today ring */
80
- --cal-today-ring: var(--booking-emerald-600, #059669);
79
+ /* Today marker */
80
+ --cal-today-ring: var(--booking-orange-600, #ff4d00);
81
+ --cal-today-text: var(--booking-orange-600, #ff4d00);
82
+ --cal-today-dot: var(--booking-orange-600, #ff4d00);
81
83
 
82
84
  /* Grid borders */
83
85
  --cal-grid-border: var(--booking-stone-300, #d6d3d1);
@@ -201,7 +203,8 @@
201
203
  --cal-dropdown-day-selected-bg-hover: var(--booking-emerald-700, #047857);
202
204
  --cal-dropdown-day-soldout-bg: #f87171;
203
205
  --cal-dropdown-day-soldout-bg-hover: #ef4444;
204
- --cal-dropdown-day-today-ring: var(--booking-emerald-500, #10b981);
206
+ --cal-dropdown-day-today-ring: var(--booking-orange-600, #ff4d00);
207
+ --cal-dropdown-day-today-text: var(--booking-orange-600, #ff4d00);
205
208
 
206
209
  position: fixed;
207
210
  background-color: var(--cal-dropdown-bg);
@@ -305,6 +308,7 @@
305
308
  transition: background-color 0.2s, color 0.2s;
306
309
  border: none;
307
310
  cursor: pointer;
311
+ position: relative;
308
312
  }
309
313
 
310
314
  .calendarDropdownDayMuted {
@@ -312,6 +316,12 @@
312
316
  cursor: not-allowed;
313
317
  }
314
318
 
319
+ .calendarDropdownDayPast {
320
+ background-color: var(--booking-stone-100, #f5f5f4);
321
+ color: var(--booking-stone-400, #a8a29e);
322
+ cursor: not-allowed;
323
+ }
324
+
315
325
  .calendarDropdownDayAvailable {
316
326
  color: var(--cal-dropdown-day-text);
317
327
  background-color: var(--cal-dropdown-day-available-bg);
@@ -348,6 +358,20 @@
348
358
 
349
359
  .calendarDropdownDayToday {
350
360
  box-shadow: inset 0 0 0 1px var(--cal-dropdown-day-today-ring);
361
+ color: var(--cal-dropdown-day-today-text);
362
+ font-weight: 700;
363
+ }
364
+
365
+ .calendarDropdownDayToday::after {
366
+ content: "";
367
+ position: absolute;
368
+ left: 50%;
369
+ bottom: 0.1875rem;
370
+ width: 0.25rem;
371
+ height: 0.25rem;
372
+ border-radius: 999px;
373
+ background-color: var(--cal-dropdown-day-today-ring);
374
+ transform: translateX(-50%);
351
375
  }
352
376
 
353
377
  .calendarGrid {
@@ -402,10 +426,10 @@
402
426
  }
403
427
  }
404
428
 
405
- /* Desktop: don't stretch every cell to the tallest day in the row (was huge empty space). */
429
+ /* Desktop: stretch each day to the row height so grid borders stay aligned across empty and available days. */
406
430
  @media (min-width: 640px) {
407
431
  .calendarDaysGrid {
408
- align-items: start;
432
+ align-items: stretch;
409
433
  }
410
434
 
411
435
  /* Admin capacity mode: keep all days in a row equal height (including empty days like leading/trailing month dates). */
@@ -456,15 +480,9 @@
456
480
  width: 100%;
457
481
  }
458
482
 
459
- /* Desktop: pin time pills to bottom of cell; centering left a tall empty band under the date. */
483
+ /* Desktop: center availability content while row stretching keeps grid borders aligned. */
460
484
  @media (min-width: 640px) {
461
485
  .calendarDayCellInner:not(.calendarDayCellInnerMobile) {
462
- justify-content: flex-end;
463
- padding-bottom: 0.125rem;
464
- }
465
-
466
- /* Admin capacity mode: with stretched row heights, bottom-pinning creates excessive top whitespace. */
467
- .calendar.calendarAdminMode .calendarDayCellInner:not(.calendarDayCellInnerMobile) {
468
486
  justify-content: center;
469
487
  padding-bottom: 0;
470
488
  }
@@ -517,8 +535,19 @@
517
535
  cursor: not-allowed;
518
536
  }
519
537
 
538
+ .calendar button.calendarDayCellPast,
539
+ .calendar .calendarDayCellPast {
540
+ background-color: var(--booking-stone-100, #f5f5f4);
541
+ color: var(--booking-stone-400, #a8a29e);
542
+ cursor: not-allowed;
543
+ }
544
+
545
+ .calendarDayCellPast .calendarDayNumber {
546
+ color: var(--booking-stone-400, #a8a29e);
547
+ }
548
+
520
549
  .calendarDayCellToday {
521
- box-shadow: inset 0 0 0 1px var(--cal-today-ring);
550
+ box-shadow: none;
522
551
  }
523
552
 
524
553
  /* Day number */
@@ -534,6 +563,22 @@
534
563
  color: var(--cal-day-number-selected);
535
564
  }
536
565
 
566
+ .calendarDayCellToday .calendarDayNumber {
567
+ color: var(--cal-today-text);
568
+ font-weight: 700;
569
+ }
570
+
571
+ .calendarDayCellToday .calendarDayNumber::after {
572
+ content: "";
573
+ display: inline-block;
574
+ width: 0.25rem;
575
+ height: 0.25rem;
576
+ margin-left: 0.1875rem;
577
+ border-radius: 999px;
578
+ background-color: var(--cal-today-dot);
579
+ vertical-align: 0.125rem;
580
+ }
581
+
537
582
  /* ============ BADGES & PILLS ============ */
538
583
  .calendarDiscountTag {
539
584
  position: absolute;
@@ -178,6 +178,7 @@ interface DateCellProps {
178
178
  availability: DateAvailability | null;
179
179
  isSelected: boolean;
180
180
  isToday: boolean;
181
+ isPast: boolean;
181
182
  showCapacity?: boolean;
182
183
  timezone: string;
183
184
  displayMode: 'times' | 'status';
@@ -192,6 +193,7 @@ const DateCell = memo(function DateCell({
192
193
  availability,
193
194
  isSelected,
194
195
  isToday,
196
+ isPast,
195
197
  showCapacity,
196
198
  timezone,
197
199
  displayMode,
@@ -209,7 +211,7 @@ const DateCell = memo(function DateCell({
209
211
  // In change flow, also allow the original booked sold-out date to stay selectable.
210
212
  const hasAvailability =
211
213
  hasAvailabilityData && (!availability.isSoldOut || showCapacity || allowPinnedSoldOutDate);
212
- const isDisabled = !hasAvailability;
214
+ const isDisabled = isPast || !hasAvailability;
213
215
 
214
216
  // Format time for display. timeStr (e.g. "09:00") is already in company timezone (MDT).
215
217
  // Create a Date with those components, interpret as company timezone, then format.
@@ -246,21 +248,24 @@ const DateCell = memo(function DateCell({
246
248
  ? 'min-w-0 min-h-[5.75rem]'
247
249
  : 'min-h-[5rem]'
248
250
  ),
249
- isDisabled
251
+ isPast
252
+ ? styles.calendarDayCellPast
253
+ : isDisabled
250
254
  ? styles.calendarDayCellDisabled
251
255
  : isSelected
252
256
  ? styles.calendarDayCellSelected
253
257
  : styles.calendarDayCellAvailable,
254
- isToday && hasAvailability && !isSelected && styles.calendarDayCellToday
255
- ), [isDisabled, isSelected, isToday, hasAvailability, needsTallerCell, isMobile, mobileNeedsTaller, adminCapacityTallCells]);
258
+ isToday && !isPast && !isSelected && styles.calendarDayCellToday
259
+ ), [isPast, isDisabled, isSelected, isToday, needsTallerCell, isMobile, mobileNeedsTaller, adminCapacityTallCells]);
256
260
 
257
261
  const ariaLabel = useMemo(() => {
258
262
  const parts = [dayNumber];
263
+ if (isPast) parts.push(' - Past date');
259
264
  if (availability?.isSoldOut) parts.push(' - Sold out');
260
265
  const pct = availability?.totalDiscountPercent;
261
266
  if (pct != null && pct > 0) parts.push(` - ${pct}% off`);
262
267
  return parts.join('');
263
- }, [dayNumber, availability?.isSoldOut, availability?.totalDiscountPercent]);
268
+ }, [dayNumber, isPast, availability?.isSoldOut, availability?.totalDiscountPercent]);
264
269
 
265
270
  return (
266
271
  <button
@@ -996,6 +1001,8 @@ export function Calendar({
996
1001
  getEffectiveVacancies,
997
1002
  ]);
998
1003
 
1004
+ const todayDateStr = formatInTimeZone(new Date(), timezone, 'yyyy-MM-dd');
1005
+
999
1006
  // Get display range for header
1000
1007
  const displayRange = useMemo(() => {
1001
1008
  const firstDay = calendarDays[0];
@@ -1022,6 +1029,8 @@ export function Calendar({
1022
1029
 
1023
1030
  const handleDateClick = (date: Date) => {
1024
1031
  const dateStr = formatInTimeZone(date, timezone, 'yyyy-MM-dd');
1032
+ if (dateStr < todayDateStr) return;
1033
+
1025
1034
  const availability = dateAvailabilityMap[dateStr] || null;
1026
1035
 
1027
1036
  const allowPinnedSoldOutDate = Boolean(
@@ -1165,6 +1174,7 @@ export function Calendar({
1165
1174
  const availability = dateAvailabilityMap[dateStr] || null;
1166
1175
  const isSelected = selectedDate === dateStr;
1167
1176
  const isCurrentMonth = day >= miniCalendarData.monthStart && day <= miniCalendarData.monthEnd;
1177
+ const isPast = dateStr < todayDateStr;
1168
1178
  // Check if day is within allowed range (June 1 - October 12, 2026) in company timezone
1169
1179
  const isInAllowedRange = day >= miniCalendarBoundsInTz.start && day <= miniCalendarBoundsInTz.end;
1170
1180
 
@@ -1178,19 +1188,21 @@ export function Calendar({
1178
1188
  const isAvailable =
1179
1189
  hasAvailabilityData &&
1180
1190
  isInAllowedRange &&
1191
+ !isPast &&
1181
1192
  (!isSoldOut || showCapacity || allowPinnedSoldOutDate);
1182
1193
  const isToday = isSameDay(day, new Date());
1183
1194
 
1184
1195
  const dayClass = cn(
1185
1196
  styles.calendarDropdownDay,
1186
1197
  !isCurrentMonth && styles.calendarDropdownDayMuted,
1198
+ isPast && styles.calendarDropdownDayPast,
1187
1199
  isSoldOut && !showCapacity && styles.calendarDropdownDaySoldOut,
1188
1200
  isSoldOut && showCapacity && styles.calendarDropdownDaySoldOutAdmin,
1189
1201
  !isSoldOut && !isInAllowedRange && styles.calendarDropdownDayMuted,
1190
1202
  !hasAvailabilityData && isInAllowedRange && styles.calendarDropdownDayMuted,
1191
1203
  isSelected && isAvailable && styles.calendarDropdownDaySelected,
1192
1204
  isAvailable && !isSelected && styles.calendarDropdownDayAvailable,
1193
- isToday && isAvailable && !isSelected && styles.calendarDropdownDayToday
1205
+ isToday && !isPast && !isSelected && styles.calendarDropdownDayToday
1194
1206
  );
1195
1207
 
1196
1208
  return (
@@ -1276,6 +1288,7 @@ export function Calendar({
1276
1288
  const availability = dateAvailabilityMap[dateStr] || null;
1277
1289
  const isSelected = selectedDate === dateStr;
1278
1290
  const isToday = isSameDay(date, new Date());
1291
+ const isPast = dateStr < todayDateStr;
1279
1292
 
1280
1293
  // Include availability data in key to force re-render when availability changes
1281
1294
  const availabilityKey = availability
@@ -1289,6 +1302,7 @@ export function Calendar({
1289
1302
  availability={availability}
1290
1303
  isSelected={isSelected}
1291
1304
  isToday={isToday}
1305
+ isPast={isPast}
1292
1306
  showCapacity={showCapacity}
1293
1307
  timezone={timezone}
1294
1308
  displayMode={displayMode}
@@ -33,6 +33,10 @@ import {
33
33
  import { formatCurrencyAmount } from '../../lib/currency';
34
34
  import { formatBookingRefForDisplay } from '../../lib/booking-ref';
35
35
  import { buildCheckoutBreakdown } from '../../lib/booking/checkout-breakdown';
36
+ import {
37
+ isDateAfterPublicBookingCutoff,
38
+ resolvePublicBookingCutoffMinutes,
39
+ } from '../../lib/booking/booking-cutoffs';
36
40
  import type { PricingConfig, PrecomputedPricesByCategory } from '../../lib/booking-api';
37
41
  import { Calendar } from './Calendar';
38
42
  import { PickupLocationSelector } from './PickupLocationSelector';
@@ -122,6 +126,32 @@ function parseAvailabilityDateTime(value: string): Date {
122
126
  return parseISO(hasExplicitOffset ? value : `${value}Z`);
123
127
  }
124
128
 
129
+ function privateShuttleStartDateTime(dateStr: string, timeStr: string, timezone: string): Date {
130
+ return fromZonedTime(parseISO(`${dateStr}T${timeStr}:00`), timezone);
131
+ }
132
+
133
+ function privateShuttleStartTimeAllowed(
134
+ dateStr: string,
135
+ timeStr: string,
136
+ timezone: string,
137
+ now: Date,
138
+ cutoffMinutes?: number | null,
139
+ ): boolean {
140
+ return isDateAfterPublicBookingCutoff(
141
+ privateShuttleStartDateTime(dateStr, timeStr, timezone),
142
+ now,
143
+ cutoffMinutes,
144
+ );
145
+ }
146
+
147
+ function privateShuttleBookingCutoffMessage(cutoffMinutes?: number | null): string {
148
+ const resolvedCutoffMinutes = resolvePublicBookingCutoffMinutes(cutoffMinutes);
149
+ if (resolvedCutoffMinutes % 60 === 0) {
150
+ return `Private shuttles must be booked at least ${resolvedCutoffMinutes / 60} hours before departure.`;
151
+ }
152
+ return `Private shuttles must be booked at least ${resolvedCutoffMinutes} minutes before departure.`;
153
+ }
154
+
125
155
  function findMergedPrivateShuttleAvailability(
126
156
  merged: Availability[],
127
157
  sel: Availability | null,
@@ -257,6 +287,7 @@ export function PrivateShuttleBookingFlow({
257
287
  const [loading, setLoading] = useState(false);
258
288
  const [loadingAvailabilities, setLoadingAvailabilities] = useState(true);
259
289
  const [isFetchingMoreAvailabilities, setIsFetchingMoreAvailabilities] = useState(false);
290
+ const [bookingCutoffNowMs, setBookingCutoffNowMs] = useState(() => Date.now());
260
291
  const [error, setError] = useState('');
261
292
  const [showCheckoutModal, setShowCheckoutModal] = useState(false);
262
293
  /** Pending reservation while user is in checkout (RESERVED until confirmed/cancelled). */
@@ -355,6 +386,14 @@ export function PrivateShuttleBookingFlow({
355
386
  () => product.options?.filter((opt) => opt.status === 'ACTIVE') || [],
356
387
  [product.options]
357
388
  );
389
+ const bookingCutoffNow = useMemo(() => new Date(bookingCutoffNowMs), [bookingCutoffNowMs]);
390
+ const bookingCutoffMinutes = product.bookingCutoffMinutes ?? null;
391
+
392
+ useEffect(() => {
393
+ if (isAdmin) return;
394
+ const interval = window.setInterval(() => setBookingCutoffNowMs(Date.now()), 60 * 1000);
395
+ return () => window.clearInterval(interval);
396
+ }, [isAdmin]);
358
397
 
359
398
  // Fire view_item when product is first displayed
360
399
  const hasFiredViewItem = useRef(false);
@@ -780,9 +819,43 @@ export function PrivateShuttleBookingFlow({
780
819
  availabilitiesCache,
781
820
  ]);
782
821
 
822
+ const isPrivateShuttleAvailabilityBookable = useCallback(
823
+ (availability: Availability): boolean => {
824
+ if (isAdmin) return true;
825
+ const dateInCompanyTz = DATE_ONLY_REGEX.test(availability.dateTime)
826
+ ? availability.dateTime
827
+ : formatInTimeZone(parseAvailabilityDateTime(availability.dateTime), companyTimezone, 'yyyy-MM-dd');
828
+ const optionId = getPrivateShuttleAvailabilityOptionId(availability);
829
+ const option = activeOptions.find((opt) => opt.optionId === optionId);
830
+ const startTimes = availability.suggestedStartTimes || option?.privateShuttleConfig?.suggestedStartTimes || [];
831
+ if (startTimes.length === 0) {
832
+ return isDateAfterPublicBookingCutoff(
833
+ fromZonedTime(parseISO(`${dateInCompanyTz}T23:59:59`), companyTimezone),
834
+ bookingCutoffNow,
835
+ bookingCutoffMinutes,
836
+ );
837
+ }
838
+ return startTimes.some((time) =>
839
+ privateShuttleStartTimeAllowed(
840
+ dateInCompanyTz,
841
+ time,
842
+ companyTimezone,
843
+ bookingCutoffNow,
844
+ bookingCutoffMinutes,
845
+ ),
846
+ );
847
+ },
848
+ [activeOptions, bookingCutoffMinutes, bookingCutoffNow, companyTimezone, isAdmin],
849
+ );
850
+
851
+ const publicVisibleAvailabilities = useMemo(
852
+ () => (isAdmin ? availabilities : availabilities.filter(isPrivateShuttleAvailabilityBookable)),
853
+ [availabilities, isAdmin, isPrivateShuttleAvailabilityBookable],
854
+ );
855
+
783
856
  // Group availabilities by date (in company timezone - MDT). Availabilities from API are in UTC.
784
857
  const availabilitiesByDate = useMemo(() => {
785
- const grouped = availabilities.reduce(
858
+ const grouped = publicVisibleAvailabilities.reduce(
786
859
  (acc, avail) => {
787
860
  const dateInCompanyTz = DATE_ONLY_REGEX.test(avail.dateTime)
788
861
  ? avail.dateTime
@@ -803,7 +876,7 @@ export function PrivateShuttleBookingFlow({
803
876
  });
804
877
  });
805
878
  return sorted;
806
- }, [availabilities, companyTimezone]);
879
+ }, [publicVisibleAvailabilities, companyTimezone]);
807
880
 
808
881
  const optionsAvailableForSelectedDate = useMemo(() => {
809
882
  if (!selectedDate) return [];
@@ -914,8 +987,27 @@ export function PrivateShuttleBookingFlow({
914
987
  return fromZonedTime(parseISO(`${dates[0]}T12:00:00`), companyTimezone);
915
988
  }, [dates, companyTimezone]);
916
989
 
917
- const suggestedStartTimes =
918
- selectedAvailability?.suggestedStartTimes || privateShuttleConfig?.suggestedStartTimes || [];
990
+ const suggestedStartTimes = useMemo(() => {
991
+ const times = selectedAvailability?.suggestedStartTimes || privateShuttleConfig?.suggestedStartTimes || [];
992
+ if (isAdmin || !selectedDate) return times;
993
+ return times.filter((time) =>
994
+ privateShuttleStartTimeAllowed(
995
+ selectedDate,
996
+ time,
997
+ companyTimezone,
998
+ bookingCutoffNow,
999
+ bookingCutoffMinutes,
1000
+ ),
1001
+ );
1002
+ }, [
1003
+ bookingCutoffMinutes,
1004
+ bookingCutoffNow,
1005
+ companyTimezone,
1006
+ isAdmin,
1007
+ privateShuttleConfig?.suggestedStartTimes,
1008
+ selectedAvailability?.suggestedStartTimes,
1009
+ selectedDate,
1010
+ ]);
919
1011
  const resourceCount = Math.ceil(passengerCount / RESOURCE_CAPACITY);
920
1012
  const maxVacancies = selectedAvailability?.vacancies || 0;
921
1013
  const isSpecialRequest = passengerCount > maxVacancies;
@@ -1255,6 +1347,13 @@ export function PrivateShuttleBookingFlow({
1255
1347
  }
1256
1348
  }, [suggestedStartTimes, isCustomTimeMode, selectedStartTime]);
1257
1349
 
1350
+ useEffect(() => {
1351
+ if (isAdmin || isCustomTimeMode || !selectedStartTime) return;
1352
+ if (!suggestedStartTimes.includes(selectedStartTime)) {
1353
+ setSelectedStartTime('');
1354
+ }
1355
+ }, [isAdmin, isCustomTimeMode, selectedStartTime, suggestedStartTimes]);
1356
+
1258
1357
  useEffect(() => {
1259
1358
  if (
1260
1359
  pricingConfig?.cancellationPolicies &&
@@ -1282,6 +1381,14 @@ export function PrivateShuttleBookingFlow({
1282
1381
  };
1283
1382
 
1284
1383
  const handleStartTimeSelect = (time: string) => {
1384
+ if (
1385
+ !isAdmin &&
1386
+ selectedDate &&
1387
+ !privateShuttleStartTimeAllowed(selectedDate, time, companyTimezone, bookingCutoffNow, bookingCutoffMinutes)
1388
+ ) {
1389
+ setError(privateShuttleBookingCutoffMessage(bookingCutoffMinutes));
1390
+ return;
1391
+ }
1285
1392
  setSelectedStartTime(time);
1286
1393
  setIsCustomTimeMode(false);
1287
1394
  setError('');
@@ -1465,6 +1572,19 @@ export function PrivateShuttleBookingFlow({
1465
1572
  setError('Please select a date, start time, and passenger count');
1466
1573
  return;
1467
1574
  }
1575
+ if (
1576
+ !isAdmin &&
1577
+ !privateShuttleStartTimeAllowed(
1578
+ selectedDate,
1579
+ selectedStartTime,
1580
+ companyTimezone,
1581
+ bookingCutoffNow,
1582
+ bookingCutoffMinutes,
1583
+ )
1584
+ ) {
1585
+ setError(privateShuttleBookingCutoffMessage(bookingCutoffMinutes));
1586
+ return;
1587
+ }
1468
1588
  if (
1469
1589
  product.pickupLocations &&
1470
1590
  product.pickupLocations.length > 0 &&
@@ -113,6 +113,7 @@
113
113
  "productId": "p_urmbvZepRegd",
114
114
  "optionIds": ["po_uO9Ra6NHzwof", "po_44EQlZn24gXg", "po_vuzqpvGk6wVS", "po_nIToR7Qwau3E"],
115
115
  "productType": "PRIVATE_SHUTTLE",
116
+ "bookingCutoffMinutes": 2880,
116
117
  "display": {
117
118
  "path": "/private-shuttle",
118
119
  "slug": "private-tour",