@ticketboothapp/booking 1.2.108 → 1.2.110

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.108",
3
+ "version": "1.2.110",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -4419,7 +4419,7 @@ export function AdminChangeBookingFlow({
4419
4419
  hasChangesFromInitial: hasEffectiveChangeSelection,
4420
4420
  selectionTotal:
4421
4421
  originalReceipt
4422
- ? suppressSelfServeCurrencyUi && !selfServePricingConfirmed
4422
+ ? suppressSelfServeCurrencyUi && !selfServePricingConfirmed && !useAdminFeAuthoritativeQuote
4423
4423
  ? null
4424
4424
  : displayChangeFlowProposedTotalWithEditableLines
4425
4425
  : totalPrice,
@@ -109,6 +109,19 @@
109
109
  margin-bottom: 0.375rem;
110
110
  }
111
111
 
112
+ .minimumPaxNotice {
113
+ margin-top: 0.625rem;
114
+ border: 1px solid #fed7aa;
115
+ border-radius: 0.5rem;
116
+ background: #fff7ed;
117
+ color: #9a3412;
118
+ font-size: 0.75rem;
119
+ font-weight: 600;
120
+ line-height: 1.35;
121
+ padding: 0.625rem 0.75rem;
122
+ text-align: left;
123
+ }
124
+
112
125
  .calendarNav {
113
126
  padding: 0.375rem;
114
127
  border-radius: 0.5rem;
@@ -586,12 +599,44 @@
586
599
  background-color: rgba(217, 119, 6, 0.8);
587
600
  }
588
601
 
602
+ .calendarTimePillMinimumReached {
603
+ background-color: var(--cal-time-available-bg);
604
+ box-shadow: 0 0 0 2px #a7f3d0, 0 4px 10px rgba(5, 150, 105, 0.14);
605
+ }
606
+ .calendarDayCellSelected .calendarTimePillMinimumReached {
607
+ background-color: var(--cal-time-available-selected);
608
+ box-shadow: 0 0 0 2px rgba(167, 243, 208, 0.85);
609
+ }
610
+
589
611
  .calendarTimePillSoldOut {
590
612
  background-color: var(--cal-time-soldout-bg);
591
613
  color: var(--cal-time-soldout-text);
592
614
  opacity: 0.6;
593
615
  }
594
616
 
617
+ .calendarPaxProgress {
618
+ font-size: 9px;
619
+ line-height: 1.05;
620
+ margin-top: 0.0625rem;
621
+ opacity: 0.95;
622
+ text-align: center;
623
+ white-space: normal;
624
+ }
625
+
626
+ .calendarPaxProgressReached {
627
+ font-size: 8px;
628
+ font-weight: 800;
629
+ line-height: 1.05;
630
+ max-width: 3.9rem;
631
+ }
632
+ .calendarPaxProgressReached::before {
633
+ content: "✓ ";
634
+ font-weight: 900;
635
+ }
636
+ .calendarDayCellSelected .calendarPaxProgressReached {
637
+ color: var(--cal-cell-selected-text);
638
+ }
639
+
595
640
  .calendarDiscountBadge {
596
641
  font-size: 9px;
597
642
  font-weight: 600;
@@ -629,6 +674,23 @@
629
674
  line-height: 1.2;
630
675
  }
631
676
 
677
+ .calendarMobilePaxProgress {
678
+ color: var(--booking-stone-600, #57534e);
679
+ font-size: 8px;
680
+ font-weight: 700;
681
+ white-space: nowrap;
682
+ }
683
+ .calendarMobilePaxProgressReached {
684
+ color: #047857;
685
+ }
686
+ .calendarMobilePaxProgressReached::before {
687
+ content: "✓ ";
688
+ font-weight: 900;
689
+ }
690
+ .calendarDayCellSelected .calendarMobilePaxProgress {
691
+ color: rgba(255, 255, 255, 0.95);
692
+ }
693
+
632
694
  .calendarMobileTimeDotBullet {
633
695
  width: 4px;
634
696
  height: 4px;
@@ -648,6 +710,13 @@
648
710
  background-color: var(--cal-time-low-bg);
649
711
  }
650
712
 
713
+ .calendarMobileTimeDotMinimumReached .calendarMobileTimeDotBullet {
714
+ width: 5px;
715
+ height: 5px;
716
+ background-color: var(--cal-time-available-bg);
717
+ box-shadow: 0 0 0 2px #a7f3d0;
718
+ }
719
+
651
720
  .calendarLowAvailabilityBadge {
652
721
  font-size: 10px;
653
722
  font-weight: 600;
@@ -30,6 +30,8 @@ export interface DateAvailability {
30
30
  totalDiscountPercent?: number; // Total discount percentage from all negative adjustments (deals + dynamic pricing)
31
31
  /** Per-time capacity (admin): vacancies by default; booked/total only when the slot is sold out */
32
32
  timeCapacityMap?: Record<string, { booked: number; total: number; vacancies: number }>;
33
+ /** Public minimum-pax progress. Only populated for departures with at least one booked pax. */
34
+ timeBookingProgress?: Record<string, { booked: number; minimum: number }>;
33
35
  }
34
36
 
35
37
  interface CalendarProps {
@@ -48,6 +50,8 @@ interface CalendarProps {
48
50
  displayMode?: 'times' | 'status';
49
51
  /** Optional extra discount percent added to the day-level discount badge. */
50
52
  extraDiscountPercent?: number;
53
+ /** Public notice/progress display: minimum passengers required to run this product. */
54
+ minimumPaxToRun?: number | null;
51
55
  /**
52
56
  * Change-booking: yyyy-MM-dd of the **existing** booking's tour date. When that date exists in
53
57
  * [availabilitiesByDate], each day's badge is `min(that day's %, the booking date's catalog %)` — including
@@ -308,17 +312,37 @@ const DateCell = memo(function DateCell({
308
312
  const isLowAvailability =
309
313
  !isTimeSoldOut && timeVacancies !== undefined && timeVacancies < 5;
310
314
  const isDailyAvailability = time === '00:00';
315
+ const paxProgress = availability.timeBookingProgress?.[time];
316
+ const hasMinimumReached =
317
+ paxProgress != null && paxProgress.booked >= paxProgress.minimum;
318
+ const showMinimumReached = hasMinimumReached && !isLowAvailability;
311
319
  return (
312
320
  <div
313
321
  key={time}
314
322
  className={cn(
315
323
  styles.calendarMobileTimeDot,
316
324
  isTimeSoldOut && styles.calendarMobileTimeDotSoldOut,
317
- isLowAvailability && !isTimeSoldOut && styles.calendarMobileTimeDotLow
325
+ isLowAvailability && !isTimeSoldOut && styles.calendarMobileTimeDotLow,
326
+ showMinimumReached && !isTimeSoldOut && styles.calendarMobileTimeDotMinimumReached
318
327
  )}
319
328
  >
320
329
  <span className={styles.calendarMobileTimeDotBullet} aria-hidden />
321
330
  <span>{isDailyAvailability ? t('calendar.available') : formatTime(time)}</span>
331
+ {paxProgress && !isTimeSoldOut && !isLowAvailability && (
332
+ <span
333
+ className={cn(
334
+ styles.calendarMobilePaxProgress,
335
+ showMinimumReached && styles.calendarMobilePaxProgressReached
336
+ )}
337
+ >
338
+ {showMinimumReached
339
+ ? t('calendar.minimumReached')
340
+ : t('calendar.paxProgress', {
341
+ booked: paxProgress.booked,
342
+ minimum: paxProgress.minimum,
343
+ })}
344
+ </span>
345
+ )}
322
346
  </div>
323
347
  );
324
348
  })}
@@ -339,13 +363,18 @@ const DateCell = memo(function DateCell({
339
363
  const isLowAvailability =
340
364
  !isTimeSoldOut && timeVacancies !== undefined && timeVacancies < 5;
341
365
  const isDailyAvailability = time === '00:00';
366
+ const paxProgress = availability.timeBookingProgress?.[time];
367
+ const hasMinimumReached =
368
+ paxProgress != null && paxProgress.booked >= paxProgress.minimum;
369
+ const showMinimumReached = hasMinimumReached && !isLowAvailability;
342
370
  return (
343
371
  <div
344
372
  key={time}
345
373
  className={cn(
346
374
  styles.calendarTimePill,
347
375
  isTimeSoldOut && styles.calendarTimePillSoldOut,
348
- isLowAvailability && !isTimeSoldOut && styles.calendarTimePillLow
376
+ isLowAvailability && !isTimeSoldOut && styles.calendarTimePillLow,
377
+ showMinimumReached && !isTimeSoldOut && styles.calendarTimePillMinimumReached
349
378
  )}
350
379
  >
351
380
  <div>{isDailyAvailability ? t('calendar.available') : formatTime(time)}</div>
@@ -360,6 +389,21 @@ const DateCell = memo(function DateCell({
360
389
  </div>
361
390
  );
362
391
  })()}
392
+ {!showCapacity && paxProgress && !isTimeSoldOut && !isLowAvailability && (
393
+ <div
394
+ className={cn(
395
+ styles.calendarPaxProgress,
396
+ showMinimumReached && styles.calendarPaxProgressReached
397
+ )}
398
+ >
399
+ {showMinimumReached
400
+ ? t('calendar.minimumReached')
401
+ : t('calendar.paxProgress', {
402
+ booked: paxProgress.booked,
403
+ minimum: paxProgress.minimum,
404
+ })}
405
+ </div>
406
+ )}
363
407
  {isTimeSoldOut ? (
364
408
  <div className="text-[9px] -mt-0.5">
365
409
  {t('calendar.soldOut')}
@@ -416,6 +460,7 @@ export function Calendar({
416
460
  isLoading = false,
417
461
  displayMode = 'times',
418
462
  extraDiscountPercent = 0,
463
+ minimumPaxToRun = null,
419
464
  capDiscountBadgesToBookingDate = null,
420
465
  syncVisibleWeekToSelectedDate = false,
421
466
  selectableSoldOutDate = null,
@@ -766,6 +811,10 @@ export function Calendar({
766
811
  // Use plain object instead of Map so React can detect changes properly
767
812
  const dateAvailabilityMap = useMemo(() => {
768
813
  const map: Record<string, DateAvailability> = {};
814
+ const minimumPax =
815
+ minimumPaxToRun != null && minimumPaxToRun > 0
816
+ ? Math.floor(minimumPaxToRun)
817
+ : null;
769
818
  const partyReq =
770
819
  !showCapacity &&
771
820
  partySizeRequiredForCalendarSelection != null &&
@@ -825,6 +874,7 @@ export function Calendar({
825
874
  // Extract start times from availabilities and track which are sold out
826
875
  const timeAvailabilityMap = new Map<string, { isSoldOut: boolean; vacancies: number }>();
827
876
  const timeCapacityMap: Record<string, { booked: number; total: number }> = {};
877
+ const timeBookingProgressMap: Record<string, { booked: number; minimum: number }> = {};
828
878
 
829
879
  availabilities.forEach(avail => {
830
880
  let timeStr: string | null = null;
@@ -856,6 +906,16 @@ export function Calendar({
856
906
  const booked = avail.bookedCapacity ?? (totalCapacity - avail.vacancies);
857
907
  timeCapacityMap[timeStr] = { booked, total: totalCapacity };
858
908
  }
909
+ if (minimumPax != null && totalCapacity > 0) {
910
+ const booked = Math.max(0, avail.bookedCapacity ?? (totalCapacity - (avail.vacancies ?? 0)));
911
+ if (booked > 0) {
912
+ const existing = timeBookingProgressMap[timeStr];
913
+ timeBookingProgressMap[timeStr] = {
914
+ booked: (existing?.booked ?? 0) + booked,
915
+ minimum: minimumPax,
916
+ };
917
+ }
918
+ }
859
919
  }
860
920
  });
861
921
 
@@ -898,6 +958,8 @@ export function Calendar({
898
958
  totalDiscountPercent: totalDiscountPercent > 0 ? totalDiscountPercent : undefined,
899
959
  timeCapacityMap:
900
960
  Object.keys(timeCapacityMapWithVacancies).length > 0 ? timeCapacityMapWithVacancies : undefined,
961
+ timeBookingProgress:
962
+ Object.keys(timeBookingProgressMap).length > 0 ? timeBookingProgressMap : undefined,
901
963
  };
902
964
  }
903
965
  });
@@ -926,6 +988,7 @@ export function Calendar({
926
988
  timezone,
927
989
  currency,
928
990
  extraDiscountPercent,
991
+ minimumPaxToRun,
929
992
  capDiscountBadgesToBookingDate,
930
993
  showCapacity,
931
994
  partySizeRequiredForCalendarSelection,
@@ -1237,6 +1300,11 @@ export function Calendar({
1237
1300
  </div>
1238
1301
  </div>
1239
1302
  </div>
1303
+ {minimumPaxToRun != null && minimumPaxToRun > 0 && (
1304
+ <div className={styles.minimumPaxNotice}>
1305
+ {t('calendar.minimumPaxNotice', { count: minimumPaxToRun })}
1306
+ </div>
1307
+ )}
1240
1308
  </div>
1241
1309
  );
1242
1310
  }
@@ -464,6 +464,12 @@ export function NewBookingFlow({
464
464
  } = useBookingApp();
465
465
  const availabilitiesCache = useAvailabilitiesCache();
466
466
  const isAdmin = permissions.viewerRole === 'admin';
467
+ const minimumPaxToRun = useMemo(() => {
468
+ const productConfigKey = productId || product.productId;
469
+ if (!productConfigKey) return null;
470
+ const config = catalog.getProductByIdOrSlug(productConfigKey) as { minimumPaxToRun?: number | null } | null;
471
+ return config?.minimumPaxToRun ?? null;
472
+ }, [catalog, product.productId, productId]);
467
473
  const [availabilities, setAvailabilities] = useState<Availability[]>([]);
468
474
  const [bookingCutoffNowMs, setBookingCutoffNowMs] = useState(() => Date.now());
469
475
  const [selectedAvailability, setSelectedAvailability] = useState<Availability | null>(null);
@@ -3615,6 +3621,7 @@ export function NewBookingFlow({
3615
3621
  currency={currency}
3616
3622
  showCapacity={isAdmin}
3617
3623
  extraDiscountPercent={calendarDiscountPercent}
3624
+ minimumPaxToRun={minimumPaxToRun}
3618
3625
  capDiscountBadgesToBookingDate={null}
3619
3626
  />
3620
3627
  </div>
@@ -111,10 +111,41 @@
111
111
  margin: 0;
112
112
  }
113
113
 
114
+ .pickupDialogRoot .parkingNotice {
115
+ margin: 0.75rem 0 0;
116
+ padding: 0.625rem 0.75rem;
117
+ border: 1px solid #f4c27a;
118
+ border-radius: 8px;
119
+ background: #fff7ed;
120
+ color: #8a4b05;
121
+ font-size: 0.8125rem;
122
+ font-weight: 700;
123
+ line-height: 1.4;
124
+ }
125
+
114
126
  .pickupDialogRoot .selectorWrapper {
115
127
  margin-bottom: 1.5rem;
116
128
  }
117
129
 
130
+ .pickupDialogRoot .selectedParkingNotice {
131
+ display: flex;
132
+ flex-direction: column;
133
+ gap: 0.25rem;
134
+ margin: -0.5rem 0 1rem;
135
+ padding: 0.75rem 0.875rem;
136
+ border: 1px solid #f4c27a;
137
+ border-radius: 10px;
138
+ background: #fff7ed;
139
+ color: #8a4b05;
140
+ font-size: 0.875rem;
141
+ line-height: 1.4;
142
+ }
143
+
144
+ .pickupDialogRoot .selectedParkingNotice strong {
145
+ color: #713f12;
146
+ font-weight: 800;
147
+ }
148
+
118
149
  /* Prevent input overflow - constrain width in flex/grid layouts */
119
150
  .pickupDialogRoot .selectorWrapperConstrained {
120
151
  min-width: 0;
@@ -12,6 +12,10 @@ import { PickupLocationSelector } from './PickupLocationSelector';
12
12
  import { useTranslations } from '../../lib/booking/i18n';
13
13
  import styles from './PickupLocationDialog.module.css';
14
14
 
15
+ const SAMSON_MALL_PICKUP_LOCATION_ID = 'loc_afkvmlNRwRqZ';
16
+ const SAMSON_MALL_PARKING_DISCLAIMER =
17
+ 'No public parking at this pickup location. You must be staying at a nearby Lake Louise hotel and walk to Samson Mall for pickup.';
18
+
15
19
  function findProductWithPickupLocations(
16
20
  products: Product[],
17
21
  bookingProductId: string
@@ -27,6 +31,13 @@ function findProductWithPickupLocations(
27
31
  return null;
28
32
  }
29
33
 
34
+ function hasSamsonMallParkingDisclaimer(location: { id?: string | null; name?: string | null }): boolean {
35
+ return (
36
+ location.id === SAMSON_MALL_PICKUP_LOCATION_ID ||
37
+ location.name?.trim().toLowerCase() === 'samson mall'
38
+ );
39
+ }
40
+
30
41
  /** Itinerary rows shown on manage-booking (may include optional label/time from API). */
31
42
  export interface PickupDialogItineraryStep {
32
43
  label?: string | null;
@@ -222,6 +233,15 @@ export function PickupLocationDialog({
222
233
  (booking.pickupLocationId
223
234
  ? pickupLocations.find((l) => l.id === booking.pickupLocationId)?.name ?? booking.pickupLocationId
224
235
  : null);
236
+ const currentPickupRequiresParkingDisclaimer = hasSamsonMallParkingDisclaimer({
237
+ id: booking.pickupLocationId,
238
+ name: currentPickupName,
239
+ });
240
+ const selectedPickupLocation = selectedLocationId
241
+ ? pickupLocations.find((l) => l.id === selectedLocationId) ?? null
242
+ : null;
243
+ const selectedPickupRequiresParkingDisclaimer =
244
+ selectedPickupLocation ? hasSamsonMallParkingDisclaimer(selectedPickupLocation) : false;
225
245
 
226
246
  const itineraryDisplay = booking.itineraryDisplay ?? [];
227
247
  const pickupOrDropOffSteps = itineraryDisplay.filter(
@@ -312,6 +332,11 @@ export function PickupLocationDialog({
312
332
  {getDropoffTimeFromItinerary(itineraryDisplay)}
313
333
  </p>
314
334
  )}
335
+ {currentPickupRequiresParkingDisclaimer && (
336
+ <p className={styles.parkingNotice} role="note">
337
+ {SAMSON_MALL_PARKING_DISCLAIMER}
338
+ </p>
339
+ )}
315
340
  </div>
316
341
  <div
317
342
  className={`${styles.selectorWrapper} ${styles.selectorWrapperConstrained}`}
@@ -341,6 +366,13 @@ export function PickupLocationDialog({
341
366
  />
342
367
  </div>
343
368
 
369
+ {selectedPickupRequiresParkingDisclaimer && (
370
+ <div className={styles.selectedParkingNotice} role="note">
371
+ <strong>Samson Mall pickup note</strong>
372
+ <span>{SAMSON_MALL_PARKING_DISCLAIMER}</span>
373
+ </div>
374
+ )}
375
+
344
376
  {hasValidSelection && hasChanged && pickupOrDropOffSteps.length > 0 && (
345
377
  <div className={styles.itineraryPreview}>
346
378
  <h3 className={styles.previewTitle}>Itinerary changes</h3>
@@ -77,6 +77,31 @@
77
77
  height: 14rem; /* matches mapWrapper mobile */
78
78
  }
79
79
 
80
+ .parkingDisclaimer {
81
+ margin: 0.45rem 0 0;
82
+ padding: 0.5rem 0.6rem;
83
+ border: 1px solid #f4c27a;
84
+ border-radius: 0.45rem;
85
+ background: #fff7ed;
86
+ color: #8a4b05;
87
+ font-size: 0.72rem;
88
+ font-weight: 700;
89
+ line-height: 1.35;
90
+ white-space: normal;
91
+ }
92
+
93
+ .mapParkingDisclaimer {
94
+ margin: 0 0 0.5rem;
95
+ padding: 0.5rem;
96
+ border: 1px solid #f4c27a;
97
+ border-radius: 0.375rem;
98
+ background: #fff7ed;
99
+ color: #8a4b05;
100
+ font-size: 0.75rem;
101
+ font-weight: 700;
102
+ line-height: 1.35;
103
+ }
104
+
80
105
  @media (min-width: 768px) {
81
106
  .locationList {
82
107
  height: 18rem; /* matches mapWrapper desktop */
@@ -77,6 +77,9 @@ const SUGGESTION_HIDE_DELAY = 200; // ms
77
77
  const DEFAULT_MAP_ZOOM = 10;
78
78
  const SEARCHED_LOCATION_ZOOM = 14;
79
79
  const MAX_ZOOM = 15;
80
+ const SAMSON_MALL_PICKUP_LOCATION_ID = 'loc_afkvmlNRwRqZ';
81
+ const SAMSON_MALL_PARKING_DISCLAIMER =
82
+ 'No public parking at this pickup location. You must be staying at a nearby Lake Louise hotel and walk to Samson Mall for pickup.';
80
83
 
81
84
  // Filter definitions
82
85
  const FILTERS = [
@@ -111,6 +114,10 @@ function hasFreeParking(location: PickupLocation): boolean {
111
114
  return location.name.toLowerCase().includes('free parking');
112
115
  }
113
116
 
117
+ function hasSamsonMallParkingDisclaimer(location: Pick<PickupLocation, 'id' | 'name'>): boolean {
118
+ return location.id === SAMSON_MALL_PICKUP_LOCATION_ID || location.name.trim().toLowerCase() === 'samson mall';
119
+ }
120
+
114
121
  // Filter locations based on selected filters (AND logic)
115
122
  function filterLocations(
116
123
  locations: PickupLocation[],
@@ -1210,6 +1217,11 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
1210
1217
  )}
1211
1218
  </div>
1212
1219
  <p className="text-xs text-stone-600 truncate">{location.address}</p>
1220
+ {hasSamsonMallParkingDisclaimer(location) && (
1221
+ <p className={styles.parkingDisclaimer} role="note">
1222
+ {SAMSON_MALL_PARKING_DISCLAIMER}
1223
+ </p>
1224
+ )}
1213
1225
  {isNearby && searchedLocation && !exactMatchLocationId && !cityFilter && (
1214
1226
  <div className="flex items-center gap-2 mt-1 text-xs text-stone-500">
1215
1227
  <span>📍 {formatDistance((location as NearbyLocation).distance, useImperial)}</span>
@@ -1310,6 +1322,11 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
1310
1322
  <p className="text-sm text-stone-600 mb-2">
1311
1323
  {location.address}
1312
1324
  </p>
1325
+ {hasSamsonMallParkingDisclaimer(location) && (
1326
+ <p className={styles.mapParkingDisclaimer}>
1327
+ {SAMSON_MALL_PARKING_DISCLAIMER}
1328
+ </p>
1329
+ )}
1313
1330
  <div className="text-xs text-stone-500 space-y-1">
1314
1331
  <p>
1315
1332
  📍 {formatDistance(location.distance, useImperial)} {t('pickup.away')}
@@ -1363,6 +1380,11 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
1363
1380
  <p className="text-sm text-stone-600 mb-2">
1364
1381
  {location.address}
1365
1382
  </p>
1383
+ {hasSamsonMallParkingDisclaimer(location) && (
1384
+ <p className={styles.mapParkingDisclaimer}>
1385
+ {SAMSON_MALL_PARKING_DISCLAIMER}
1386
+ </p>
1387
+ )}
1366
1388
  {location.notes && (
1367
1389
  <p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded p-2 mb-2">
1368
1390
  {location.notes}
@@ -1420,6 +1442,11 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
1420
1442
  <p className="text-sm text-stone-600 mb-2">
1421
1443
  {location.address}
1422
1444
  </p>
1445
+ {hasSamsonMallParkingDisclaimer(location) && (
1446
+ <p className={styles.mapParkingDisclaimer}>
1447
+ {SAMSON_MALL_PARKING_DISCLAIMER}
1448
+ </p>
1449
+ )}
1423
1450
  {location.notes && (
1424
1451
  <p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded p-2 mb-2">
1425
1452
  {location.notes}
@@ -98,6 +98,7 @@
98
98
  {
99
99
  "productId": "p_C5jHohBUCwrd",
100
100
  "optionIds": ["po_cVr23UWDaC9M"],
101
+ "minimumPaxToRun": 6,
101
102
  "display": {
102
103
  "path": "/emerald-lake-shuttle",
103
104
  "slug": "emerald-lake-escape",
@@ -193,6 +193,10 @@
193
193
  "left": "{count} left",
194
194
  "timesAvailable": "{count} times",
195
195
  "spotsAvailable": "{count} available",
196
+ "minimumPaxNotice": "This tour needs a minimum of {count} guests to run. We will reach out 1 week before departure if the minimum has not been met to discuss a potential date change.",
197
+ "paxBooked": "{count} pax booked",
198
+ "paxProgress": "{booked}/{minimum} pax",
199
+ "minimumReached": "minimum reached!",
196
200
  "available": "Available",
197
201
  "days": {
198
202
  "sun": "SUN",
@@ -239,4 +243,3 @@
239
243
  "content": "By completing this booking you agree to the following terms and conditions.\n\n1. Booking and payment\nYour reservation is confirmed once payment has been processed. You will receive a confirmation by email or the contact method you selected.\n\n2. Cancellation and changes\nCancellation and change policies depend on the option you selected at checkout. Please refer to your confirmation for the policy that applies to your booking.\n\n3. Participation\nYou are responsible for arriving at the designated time and location. Late arrival may result in forfeiting the experience without refund.\n\n4. Liability\nThe operator is not liable for loss or damage beyond what is required by applicable law. Participation is at your own risk where activities involve inherent risk.\n\n5. Contact\nFor questions or changes to your booking, use the contact details provided in your confirmation."
240
244
  }
241
245
  }
242
-
@@ -193,6 +193,10 @@
193
193
  "left": "{count} restant",
194
194
  "timesAvailable": "{count} départs",
195
195
  "spotsAvailable": "{count} places",
196
+ "minimumPaxNotice": "Ce circuit nécessite un minimum de {count} participants pour avoir lieu. Nous confirmerons le départ une fois ce minimum atteint.",
197
+ "paxBooked": "{count} pax inscrits",
198
+ "paxProgress": "{booked}/{minimum} pax",
199
+ "minimumReached": "minimum atteint!",
196
200
  "available": "Disponible",
197
201
  "days": {
198
202
  "sun": "DIM",
@@ -239,4 +243,3 @@
239
243
  "content": "En finalisant cette réservation, vous acceptez les conditions générales suivantes.\n\n1. Réservation et paiement\nVotre réservation est confirmée une fois le paiement traité. Vous recevrez une confirmation par courriel ou par le mode de contact choisi.\n\n2. Annulation et modifications\nLes politiques d'annulation et de modification dépendent de l'option choisie au paiement. Veuillez consulter votre confirmation pour la politique applicable.\n\n3. Participation\nVous êtes responsable d'arriver à l'heure et au lieu indiqués. Un retard peut entraîner la perte de l'expérience sans remboursement.\n\n4. Responsabilité\nL'opérateur n'est pas responsable des pertes ou dommages au-delà de ce qu'exige la loi applicable. La participation est à vos risques lorsque les activités comportent des risques inhérents.\n\n5. Contact\nPour toute question ou modification de réservation, utilisez les coordonnées indiquées dans votre confirmation."
240
244
  }
241
245
  }
242
-
@@ -18,6 +18,8 @@ export interface ProductDisplayConfig {
18
18
  export interface ProductConfig {
19
19
  productId: string;
20
20
  optionIds: string[];
21
+ /** Optional public notice/progress display: minimum passengers required to run this product. */
22
+ minimumPaxToRun?: number | null;
21
23
  /** Optional public booking cutoff override in minutes. Defaults to 60 when omitted. */
22
24
  bookingCutoffMinutes?: number | null;
23
25
  /** STANDARD or PRIVATE_SHUTTLE - affects availability date format. Default STANDARD. */