@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 +1 -1
- package/src/components/booking/AdminChangeBookingFlow.tsx +1 -1
- package/src/components/booking/Calendar.module.css +69 -0
- package/src/components/booking/Calendar.tsx +70 -2
- package/src/components/booking/NewBookingFlow.tsx +7 -0
- package/src/components/booking/PickupLocationDialog.module.css +31 -0
- package/src/components/booking/PickupLocationDialog.tsx +32 -0
- package/src/components/booking/PickupLocationSelector.module.css +25 -0
- package/src/components/booking/PickupLocationSelector.tsx +27 -0
- package/src/data/products-config.json +1 -0
- package/src/lib/booking/i18n/messages/en.json +4 -1
- package/src/lib/booking/i18n/messages/fr.json +4 -1
- package/src/lib/booking-types.ts +2 -0
package/package.json
CHANGED
|
@@ -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}
|
|
@@ -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
|
-
|
package/src/lib/booking-types.ts
CHANGED
|
@@ -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. */
|