@ticketboothapp/booking 1.2.177 → 1.2.179
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/AddOnsSection.module.css +36 -5
- package/src/components/booking/AddOnsSection.tsx +250 -144
- package/src/components/booking/AdminChangeBookingFlow.tsx +3 -2
- package/src/components/booking/CollapsibleAddOnItem.module.css +72 -0
- package/src/components/booking/CollapsibleAddOnItem.tsx +38 -0
- package/src/components/booking/MealDrinkAddOnSelector.tsx +70 -29
- package/src/components/booking/PrivateShuttleAddOnsSection.tsx +124 -57
- package/src/components/booking/PrivateShuttleBookingFlow.module.css +26 -0
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +8 -5
- package/src/components/booking/PrivateShuttlePreferencesSection.tsx +29 -55
- package/src/components/booking/add-on-selection-helpers.ts +13 -0
- package/src/components/booking/change-booking-checkout-builders.ts +1 -1
- package/src/components/booking/private-shuttle-checkout-builders.ts +2 -2
- package/src/components/booking/private-shuttle-lunch-visibility.ts +20 -0
- package/src/components/booking/standard-booking-checkout-builders.ts +1 -1
- package/src/components/booking/useAdminChangeProtectedPricing.ts +1 -1
- package/src/components/booking/useBookingAvailabilityAddOns.ts +26 -4
- package/src/components/booking/useChangeBookingProtectedPricing.ts +1 -1
- package/src/components/booking/usePrivateShuttlePriceSummary.ts +13 -6
- package/src/components/booking/useStandardBookingPriceSummary.ts +1 -1
- package/src/lib/booking-api.ts +4 -1
- package/test/add-on-selection-helpers.test.ts +24 -0
- package/test/change-booking-helpers.test.ts +42 -1
- package/test/private-shuttle-lunch-visibility.test.ts +27 -0
|
@@ -24,6 +24,10 @@ export interface MealDrinkAddOnSelectorProps {
|
|
|
24
24
|
step2Label?: (total: number) => string;
|
|
25
25
|
/** Optional label for the add-on section (default: "Order lunch?") */
|
|
26
26
|
sectionLabel?: string;
|
|
27
|
+
headerClassName?: string;
|
|
28
|
+
titleClassName?: string;
|
|
29
|
+
descriptionClassName?: string;
|
|
30
|
+
hideTitle?: boolean;
|
|
27
31
|
/** Minimum total quantity locked by original booking in change flow. */
|
|
28
32
|
minimumTotal?: number;
|
|
29
33
|
suppressPrices?: boolean;
|
|
@@ -41,7 +45,7 @@ export function canUseMealDrinkSelector(addOn: AddOn): boolean {
|
|
|
41
45
|
* into step1 (meal types) and step2 (drink types) for a 2-step selection UI.
|
|
42
46
|
*/
|
|
43
47
|
function parseTwoStepVariants(variants: AddOnVariant[]): {
|
|
44
|
-
step1Options: { id: string; label: string }[];
|
|
48
|
+
step1Options: { id: string; label: string; description?: string }[];
|
|
45
49
|
step2Options: { id: string; label: string }[];
|
|
46
50
|
getVariantId: (step1Id: string, step2Id: string) => string | null;
|
|
47
51
|
} | null {
|
|
@@ -51,6 +55,7 @@ function parseTwoStepVariants(variants: AddOnVariant[]): {
|
|
|
51
55
|
const step1Options: { id: string; label: string }[] = [];
|
|
52
56
|
const step2Options: { id: string; label: string }[] = [];
|
|
53
57
|
const variantByKey = new Map<string, string>();
|
|
58
|
+
const step1Descriptions = new Map<string, string[]>();
|
|
54
59
|
|
|
55
60
|
for (const v of variants) {
|
|
56
61
|
const labelParts = v.label.split(/\s+\+\s+/);
|
|
@@ -72,6 +77,10 @@ function parseTwoStepVariants(variants: AddOnVariant[]): {
|
|
|
72
77
|
if (!step1Options.some((o) => o.id === step1Id)) {
|
|
73
78
|
step1Options.push({ id: step1Id, label: step1Label });
|
|
74
79
|
}
|
|
80
|
+
step1Descriptions.set(step1Id, [
|
|
81
|
+
...(step1Descriptions.get(step1Id) ?? []),
|
|
82
|
+
v.description?.trim() ?? '',
|
|
83
|
+
]);
|
|
75
84
|
if (!step2Options.some((o) => o.id === step2Id)) {
|
|
76
85
|
step2Options.push({ id: step2Id, label: step2Label });
|
|
77
86
|
}
|
|
@@ -81,9 +90,17 @@ function parseTwoStepVariants(variants: AddOnVariant[]): {
|
|
|
81
90
|
const getVariantId = (s1: string, s2: string) => variantByKey.get(`${s1}:${s2}`) ?? null;
|
|
82
91
|
|
|
83
92
|
if (step1Options.length < 2 || step2Options.length < 2) return null;
|
|
93
|
+
const describedStep1Options = step1Options.map((option) => {
|
|
94
|
+
const descriptions = step1Descriptions.get(option.id) ?? [];
|
|
95
|
+
const uniqueDescriptions = [...new Set(descriptions.filter(Boolean))];
|
|
96
|
+
const description = descriptions.length > 0 && descriptions.every(Boolean) && uniqueDescriptions.length === 1
|
|
97
|
+
? uniqueDescriptions[0]
|
|
98
|
+
: undefined;
|
|
99
|
+
return { ...option, description };
|
|
100
|
+
});
|
|
84
101
|
// Sort step2 so "water" comes first when reducing meals (cap water at total, rest to other)
|
|
85
102
|
const sortedStep2 = [...step2Options].sort((a, b) => (a.id === 'water' ? -1 : b.id === 'water' ? 1 : 0));
|
|
86
|
-
return { step1Options, step2Options: sortedStep2, getVariantId };
|
|
103
|
+
return { step1Options: describedStep1Options, step2Options: sortedStep2, getVariantId };
|
|
87
104
|
}
|
|
88
105
|
|
|
89
106
|
/**
|
|
@@ -94,7 +111,7 @@ function deriveCounts(
|
|
|
94
111
|
selections: AddOnSelection[],
|
|
95
112
|
getVariantId: (s1: string, s2: string) => string | null,
|
|
96
113
|
step1Ids: string[],
|
|
97
|
-
step2Ids: string[]
|
|
114
|
+
step2Ids: string[],
|
|
98
115
|
): {
|
|
99
116
|
step1Counts: Record<string, number>;
|
|
100
117
|
step2Counts: Record<string, number>;
|
|
@@ -128,7 +145,7 @@ function countsToSelections(
|
|
|
128
145
|
step2Counts: Record<string, number>,
|
|
129
146
|
getVariantId: (s1: string, s2: string) => string | null,
|
|
130
147
|
step1Ids: string[],
|
|
131
|
-
step2Ids: string[]
|
|
148
|
+
step2Ids: string[],
|
|
132
149
|
): AddOnSelection[] {
|
|
133
150
|
const total = Object.values(step1Counts).reduce((a, b) => a + b, 0);
|
|
134
151
|
const step2Total = Object.values(step2Counts).reduce((a, b) => a + b, 0);
|
|
@@ -160,25 +177,32 @@ export function MealDrinkAddOnSelector({
|
|
|
160
177
|
onSelectionsChange,
|
|
161
178
|
currency,
|
|
162
179
|
locale,
|
|
163
|
-
step2Label = (total) =>
|
|
164
|
-
|
|
165
|
-
|
|
180
|
+
step2Label = (total) => `Of your ${total} lunch${total !== 1 ? 'es' : ''}, how many with Water vs Gatorade?`,
|
|
181
|
+
sectionLabel = 'Order lunch?',
|
|
182
|
+
headerClassName,
|
|
183
|
+
titleClassName,
|
|
184
|
+
descriptionClassName,
|
|
185
|
+
hideTitle = false,
|
|
166
186
|
minimumTotal = 0,
|
|
167
187
|
suppressPrices = false,
|
|
168
188
|
}: MealDrinkAddOnSelectorProps) {
|
|
169
189
|
const parsed = useMemo(
|
|
170
190
|
() => (addOn.variantType === 'multi_quantity' && addOn.variants ? parseTwoStepVariants(addOn.variants) : null),
|
|
171
|
-
[addOn.variantType, addOn.variants]
|
|
191
|
+
[addOn.variantType, addOn.variants],
|
|
172
192
|
);
|
|
173
193
|
|
|
174
194
|
const { step1Counts, step2Counts } = useMemo(() => {
|
|
175
|
-
if (!parsed)
|
|
195
|
+
if (!parsed)
|
|
196
|
+
return {
|
|
197
|
+
step1Counts: {} as Record<string, number>,
|
|
198
|
+
step2Counts: {} as Record<string, number>,
|
|
199
|
+
};
|
|
176
200
|
return deriveCounts(
|
|
177
201
|
addOn.addOnId,
|
|
178
202
|
selections,
|
|
179
203
|
parsed.getVariantId,
|
|
180
204
|
parsed.step1Options.map((o) => o.id),
|
|
181
|
-
parsed.step2Options.map((o) => o.id)
|
|
205
|
+
parsed.step2Options.map((o) => o.id),
|
|
182
206
|
);
|
|
183
207
|
}, [addOn.addOnId, selections, parsed]);
|
|
184
208
|
|
|
@@ -193,11 +217,11 @@ export function MealDrinkAddOnSelector({
|
|
|
193
217
|
step2CountsNew,
|
|
194
218
|
parsed.getVariantId,
|
|
195
219
|
step1Ids,
|
|
196
|
-
step2Ids
|
|
220
|
+
step2Ids,
|
|
197
221
|
);
|
|
198
222
|
onSelectionsChange((prev) => [...prev.filter((s) => s.addOnId !== addOn.addOnId), ...newEntries]);
|
|
199
223
|
},
|
|
200
|
-
[addOn.addOnId, parsed, onSelectionsChange]
|
|
224
|
+
[addOn.addOnId, parsed, onSelectionsChange],
|
|
201
225
|
);
|
|
202
226
|
|
|
203
227
|
if (!parsed) return null;
|
|
@@ -210,10 +234,17 @@ export function MealDrinkAddOnSelector({
|
|
|
210
234
|
|
|
211
235
|
return (
|
|
212
236
|
<div>
|
|
213
|
-
<div className=
|
|
214
|
-
|
|
237
|
+
<div className={headerClassName || 'mb-2'}>
|
|
238
|
+
{!hideTitle ? (
|
|
239
|
+
<label className={titleClassName || 'block text-sm font-medium text-stone-700'}>
|
|
240
|
+
{addOn.emoji?.trim() ? `${addOn.emoji.trim()} ` : ''}
|
|
241
|
+
{sectionLabel}
|
|
242
|
+
</label>
|
|
243
|
+
) : null}
|
|
215
244
|
{addOn.description && (
|
|
216
|
-
<p className=
|
|
245
|
+
<p className={descriptionClassName || 'text-sm text-stone-500 mt-0.5'}>
|
|
246
|
+
{addOn.description} - lunch packages include sandwich, a drink, and a banana bread snack.
|
|
247
|
+
</p>
|
|
217
248
|
)}
|
|
218
249
|
</div>
|
|
219
250
|
<div className="space-y-4">
|
|
@@ -224,8 +255,15 @@ export function MealDrinkAddOnSelector({
|
|
|
224
255
|
const qty = step1Counts[opt.id] ?? 0;
|
|
225
256
|
const canDecrementTotal = totalStep1 > minimumTotal;
|
|
226
257
|
return (
|
|
227
|
-
<div key={opt.id} className="flex items-center gap-3">
|
|
228
|
-
<span className="
|
|
258
|
+
<div key={opt.id} className="flex flex-wrap items-center gap-3 p-3 bg-stone-50 rounded-lg">
|
|
259
|
+
<span className="min-w-0 flex-1">
|
|
260
|
+
<span className="block font-medium text-stone-800">{opt.label}</span>
|
|
261
|
+
{opt.description ? (
|
|
262
|
+
<span className="mt-0.5 block text-xs font-normal leading-snug text-stone-500">
|
|
263
|
+
{opt.description}
|
|
264
|
+
</span>
|
|
265
|
+
) : null}
|
|
266
|
+
</span>
|
|
229
267
|
<span className="text-sm text-stone-500 shrink-0">
|
|
230
268
|
{suppressPrices ? '—' : `${formatCurrencyAmount(price, currency, locale)} ea`}
|
|
231
269
|
</span>
|
|
@@ -234,7 +272,10 @@ export function MealDrinkAddOnSelector({
|
|
|
234
272
|
type="button"
|
|
235
273
|
onClick={() => {
|
|
236
274
|
if (!canDecrementTotal) return;
|
|
237
|
-
const next = {
|
|
275
|
+
const next = {
|
|
276
|
+
...step1Counts,
|
|
277
|
+
[opt.id]: Math.max(0, qty - 1),
|
|
278
|
+
};
|
|
238
279
|
const total = Object.values(next).reduce((a, b) => a + b, 0);
|
|
239
280
|
const drinks =
|
|
240
281
|
total > 0
|
|
@@ -276,14 +317,14 @@ export function MealDrinkAddOnSelector({
|
|
|
276
317
|
{totalStep1 > 0 && (
|
|
277
318
|
<div className="pt-3 border-t border-stone-200">
|
|
278
319
|
<p className="text-xs font-medium text-stone-600 mb-2">{step2Label(totalStep1)}</p>
|
|
279
|
-
<div className="
|
|
320
|
+
<div className="space-y-2">
|
|
280
321
|
{parsed.step2Options.map((opt) => {
|
|
281
322
|
const qty = step2Counts[opt.id] ?? 0;
|
|
282
323
|
const otherId = step2Ids.find((id) => id !== opt.id)!;
|
|
283
324
|
const otherQty = step2Counts[otherId] ?? 0;
|
|
284
325
|
return (
|
|
285
|
-
<div key={opt.id} className="flex items-center gap-
|
|
286
|
-
<span className="text-sm text-stone-700">{opt.label}</span>
|
|
326
|
+
<div key={opt.id} className="flex items-center gap-3 p-3 bg-stone-50 rounded-lg">
|
|
327
|
+
<span className="text-sm text-stone-700 flex-1">{opt.label}</span>
|
|
287
328
|
<div className="flex items-center gap-1">
|
|
288
329
|
<button
|
|
289
330
|
type="button"
|
|
@@ -293,7 +334,10 @@ export function MealDrinkAddOnSelector({
|
|
|
293
334
|
[opt.id]: qty - 1,
|
|
294
335
|
[otherId]: otherQty + 1,
|
|
295
336
|
};
|
|
296
|
-
updateSelections(step1Counts, {
|
|
337
|
+
updateSelections(step1Counts, {
|
|
338
|
+
...step2Counts,
|
|
339
|
+
...next,
|
|
340
|
+
});
|
|
297
341
|
}}
|
|
298
342
|
disabled={qty <= 0}
|
|
299
343
|
className="h-8 w-8 rounded-full border border-stone-300 bg-white text-stone-600 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
|
@@ -309,7 +353,10 @@ export function MealDrinkAddOnSelector({
|
|
|
309
353
|
[opt.id]: qty + 1,
|
|
310
354
|
[otherId]: otherQty - 1,
|
|
311
355
|
};
|
|
312
|
-
updateSelections(step1Counts, {
|
|
356
|
+
updateSelections(step1Counts, {
|
|
357
|
+
...step2Counts,
|
|
358
|
+
...next,
|
|
359
|
+
});
|
|
313
360
|
}}
|
|
314
361
|
disabled={otherQty <= 0}
|
|
315
362
|
className="h-8 w-8 rounded-full border border-stone-300 bg-white text-stone-600 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
|
@@ -328,12 +375,6 @@ export function MealDrinkAddOnSelector({
|
|
|
328
375
|
)}
|
|
329
376
|
</div>
|
|
330
377
|
)}
|
|
331
|
-
|
|
332
|
-
{totalStep1 > 0 && totalStep2 === totalStep1 && (
|
|
333
|
-
<p className="text-sm font-medium text-stone-700">
|
|
334
|
-
{suppressPrices ? 'Total: —' : `Total: ${formatCurrencyAmount(price * totalStep1, currency, locale)}`}
|
|
335
|
-
</p>
|
|
336
|
-
)}
|
|
337
378
|
</div>
|
|
338
379
|
</div>
|
|
339
380
|
);
|
|
@@ -2,7 +2,14 @@ import type { AddOn } from '../../lib/booking-api';
|
|
|
2
2
|
import { formatCurrencyAmount } from '../../lib/currency';
|
|
3
3
|
import type { Locale } from '../../lib/booking/i18n/config';
|
|
4
4
|
import type { Currency } from './CurrencySwitcher';
|
|
5
|
+
import { AddOnsSection } from './AddOnsSection';
|
|
6
|
+
import { CollapsibleAddOnItem } from './CollapsibleAddOnItem';
|
|
5
7
|
import { MealDrinkAddOnSelector, canUseMealDrinkSelector } from './MealDrinkAddOnSelector';
|
|
8
|
+
import { selectedAddOnQuantity } from './add-on-selection-helpers';
|
|
9
|
+
import {
|
|
10
|
+
EMERALD_LAKE_LUNCH_ADD_ON_ID,
|
|
11
|
+
privateShuttleLunchAddOnIsVisible,
|
|
12
|
+
} from './private-shuttle-lunch-visibility';
|
|
6
13
|
import styles from './PrivateShuttleBookingFlow.module.css';
|
|
7
14
|
|
|
8
15
|
export type PrivateShuttleAddOnSelection = {
|
|
@@ -21,7 +28,9 @@ export interface PrivateShuttleAddOnsSectionProps {
|
|
|
21
28
|
selectedDestinationIds: string[];
|
|
22
29
|
currency: Currency;
|
|
23
30
|
locale: Locale;
|
|
31
|
+
foodRestrictions: string;
|
|
24
32
|
onAddOnSelectionsChange: (update: PrivateShuttleAddOnSelectionUpdate) => void;
|
|
33
|
+
onFoodRestrictionsChange: (value: string) => void;
|
|
25
34
|
}
|
|
26
35
|
|
|
27
36
|
export function PrivateShuttleAddOnsSection({
|
|
@@ -31,72 +40,130 @@ export function PrivateShuttleAddOnsSection({
|
|
|
31
40
|
selectedDestinationIds,
|
|
32
41
|
currency,
|
|
33
42
|
locale,
|
|
43
|
+
foodRestrictions,
|
|
34
44
|
onAddOnSelectionsChange,
|
|
45
|
+
onFoodRestrictionsChange,
|
|
35
46
|
}: PrivateShuttleAddOnsSectionProps) {
|
|
36
|
-
if (passengerCount <= 0
|
|
47
|
+
if (passengerCount <= 0) return null;
|
|
37
48
|
|
|
38
|
-
const animalsAddOn = addOns.find((addOn) => addOn.addOnId === 'addon_animals');
|
|
39
49
|
const isAnimalsSelected = addOnSelections.some((selection) => selection.addOnId === 'addon_animals');
|
|
40
|
-
const lunchAddOn = selectedDestinationIds.includes('emerald_lake')
|
|
41
|
-
? addOns.find((addOn) => addOn.addOnId === 'addon_el_lunch')
|
|
42
|
-
: null;
|
|
43
50
|
|
|
44
51
|
return (
|
|
45
|
-
|
|
46
|
-
{
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
52
|
+
<>
|
|
53
|
+
{addOns.length > 0 ? (
|
|
54
|
+
<div className={styles.section}>
|
|
55
|
+
<label className={`${styles.sectionLabel} private-shuttle-section-label`}>Add-ons</label>
|
|
56
|
+
<div className={styles.addOnItems}>
|
|
57
|
+
{addOns.map((addOn) => {
|
|
58
|
+
if (!privateShuttleLunchAddOnIsVisible(addOn.addOnId, selectedDestinationIds)) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const selectedQuantity = selectedAddOnQuantity(addOn.addOnId, addOnSelections);
|
|
62
|
+
const emoji = addOn.emoji?.trim();
|
|
63
|
+
if (addOn.addOnId === EMERALD_LAKE_LUNCH_ADD_ON_ID) {
|
|
64
|
+
if (!canUseMealDrinkSelector(addOn)) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return (
|
|
68
|
+
<CollapsibleAddOnItem
|
|
69
|
+
key={addOn.addOnId}
|
|
70
|
+
title={`${emoji ? `${emoji} ` : ''}Order lunch?`}
|
|
71
|
+
selectedQuantity={selectedQuantity}
|
|
72
|
+
>
|
|
73
|
+
<MealDrinkAddOnSelector
|
|
74
|
+
addOn={addOn}
|
|
75
|
+
selections={addOnSelections}
|
|
76
|
+
onSelectionsChange={(updater) => onAddOnSelectionsChange((prev) => updater(prev))}
|
|
77
|
+
currency={currency}
|
|
78
|
+
locale={locale}
|
|
79
|
+
sectionLabel="Order lunch?"
|
|
80
|
+
headerClassName={styles.addOnHeader}
|
|
81
|
+
titleClassName={styles.addOnTitle}
|
|
82
|
+
descriptionClassName={styles.addOnDescription}
|
|
83
|
+
hideTitle
|
|
84
|
+
/>
|
|
85
|
+
</CollapsibleAddOnItem>
|
|
86
|
+
);
|
|
72
87
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
88
|
+
if (addOn.addOnId === 'addon_animals') {
|
|
89
|
+
return (
|
|
90
|
+
<CollapsibleAddOnItem
|
|
91
|
+
key={addOn.addOnId}
|
|
92
|
+
title={`${emoji ? `${emoji} ` : ''}Traveling with animals?`}
|
|
93
|
+
selectedQuantity={selectedQuantity}
|
|
94
|
+
>
|
|
95
|
+
<div className={styles.addOnHeader}>
|
|
96
|
+
<p className={styles.addOnDescription}>
|
|
97
|
+
{addOn.description || 'Cleaning fee for traveling with animals'}
|
|
98
|
+
</p>
|
|
99
|
+
</div>
|
|
100
|
+
<div className="flex flex-wrap gap-2">
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
onClick={() =>
|
|
104
|
+
onAddOnSelectionsChange(
|
|
105
|
+
addOnSelections.filter((selection) => selection.addOnId !== 'addon_animals'),
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
className={`${styles.btnTime} private-shuttle-btn-time ${
|
|
109
|
+
!isAnimalsSelected ? styles.btnTimeSelected : styles.btnTimeDefault
|
|
110
|
+
}`}
|
|
111
|
+
>
|
|
112
|
+
No animals
|
|
113
|
+
</button>
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
onClick={() => {
|
|
117
|
+
if (isAnimalsSelected) return;
|
|
118
|
+
onAddOnSelectionsChange([...addOnSelections, { addOnId: 'addon_animals', quantity: 1 }]);
|
|
119
|
+
}}
|
|
120
|
+
className={`${styles.btnTime} private-shuttle-btn-time flex items-center gap-2 ${
|
|
121
|
+
isAnimalsSelected ? styles.btnTimeSelected : styles.btnTimeDefault
|
|
122
|
+
}`}
|
|
123
|
+
>
|
|
124
|
+
<span>Yes, traveling with animals</span>
|
|
125
|
+
<span className="text-sm font-semibold opacity-90">
|
|
126
|
+
+{formatCurrencyAmount(addOn.price || 0, currency, locale)}
|
|
127
|
+
</span>
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
</CollapsibleAddOnItem>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return (
|
|
134
|
+
<AddOnsSection
|
|
135
|
+
key={addOn.addOnId}
|
|
136
|
+
addOns={[addOn]}
|
|
137
|
+
addOnSelections={addOnSelections}
|
|
138
|
+
currency={currency}
|
|
139
|
+
locale={locale}
|
|
140
|
+
onSelectionsChange={onAddOnSelectionsChange}
|
|
141
|
+
embedded
|
|
142
|
+
itemHeaderClassName={styles.addOnHeader}
|
|
143
|
+
itemTitleClassName={styles.addOnTitle}
|
|
144
|
+
itemDescriptionClassName={styles.addOnDescription}
|
|
145
|
+
/>
|
|
146
|
+
);
|
|
147
|
+
})}
|
|
97
148
|
</div>
|
|
98
149
|
</div>
|
|
99
150
|
) : null}
|
|
100
|
-
|
|
151
|
+
<div className={styles.section}>
|
|
152
|
+
<label htmlFor="food-restrictions" className={`${styles.sectionLabel} private-shuttle-section-label`}>
|
|
153
|
+
Food restrictions
|
|
154
|
+
</label>
|
|
155
|
+
<p className="mb-2 text-sm text-stone-500">
|
|
156
|
+
Shuttle includes croissants, coffee, tea, hot chocolate, and trail snacks.
|
|
157
|
+
</p>
|
|
158
|
+
<textarea
|
|
159
|
+
id="food-restrictions"
|
|
160
|
+
value={foodRestrictions}
|
|
161
|
+
onChange={(event) => onFoodRestrictionsChange(event.target.value)}
|
|
162
|
+
placeholder="Any dietary restrictions or allergies?"
|
|
163
|
+
rows={2}
|
|
164
|
+
className={styles.input}
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
</>
|
|
101
168
|
);
|
|
102
169
|
}
|
|
@@ -22,6 +22,32 @@
|
|
|
22
22
|
|
|
23
23
|
/* Overridden by booking-flow.css for orange/Poppins - see .private-shuttle-sectionLabel */
|
|
24
24
|
|
|
25
|
+
.addOnItems {
|
|
26
|
+
display: flex;
|
|
27
|
+
flex-direction: column;
|
|
28
|
+
gap: 0.625rem;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.addOnHeader {
|
|
32
|
+
margin-bottom: 0.75rem;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.addOnTitle {
|
|
36
|
+
display: block;
|
|
37
|
+
font-family: 'Figtree', var(--booking-font-sans, ui-sans-serif), sans-serif !important;
|
|
38
|
+
font-size: 1.0625rem;
|
|
39
|
+
font-weight: 700;
|
|
40
|
+
line-height: 1.35;
|
|
41
|
+
color: var(--accent-orange, #f4511e) !important;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.addOnDescription {
|
|
45
|
+
margin: 0.25rem 0 0;
|
|
46
|
+
font-size: 0.875rem;
|
|
47
|
+
line-height: 1.5;
|
|
48
|
+
color: var(--booking-stone-500, #78716c);
|
|
49
|
+
}
|
|
50
|
+
|
|
25
51
|
.optionGrid {
|
|
26
52
|
display: grid;
|
|
27
53
|
grid-template-columns: 1fr;
|
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
privateShuttleTermsInitiallyAccepted,
|
|
59
59
|
resolveInitialPrivateShuttlePassengerCount,
|
|
60
60
|
} from './private-shuttle-passenger-count';
|
|
61
|
+
import { privateShuttleHiddenLunchAddOnId } from './private-shuttle-lunch-visibility';
|
|
61
62
|
|
|
62
63
|
interface PrivateShuttleBookingFlowProps {
|
|
63
64
|
product: Product;
|
|
@@ -342,9 +343,11 @@ export function PrivateShuttleBookingFlow({
|
|
|
342
343
|
}, [addOns]);
|
|
343
344
|
|
|
344
345
|
useEffect(() => {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
346
|
+
const hiddenLunchAddOnId = privateShuttleHiddenLunchAddOnId(draftItineraryDestinations);
|
|
347
|
+
setAddOnSelections((prev) => {
|
|
348
|
+
const next = prev.filter((selection) => selection.addOnId !== hiddenLunchAddOnId);
|
|
349
|
+
return next.length === prev.length ? prev : next;
|
|
350
|
+
});
|
|
348
351
|
}, [draftItineraryDestinations]);
|
|
349
352
|
|
|
350
353
|
const suggestedStartTimes = useMemo(() => {
|
|
@@ -922,9 +925,7 @@ export function PrivateShuttleBookingFlow({
|
|
|
922
925
|
<PrivateShuttlePreferencesSection
|
|
923
926
|
passengerCount={passengerCount}
|
|
924
927
|
childSafetySeatsCount={childSafetySeatsCount}
|
|
925
|
-
foodRestrictions={foodRestrictions}
|
|
926
928
|
onChildSafetySeatsCountChange={setChildSafetySeatsCount}
|
|
927
|
-
onFoodRestrictionsChange={setFoodRestrictions}
|
|
928
929
|
/>
|
|
929
930
|
)}
|
|
930
931
|
|
|
@@ -936,7 +937,9 @@ export function PrivateShuttleBookingFlow({
|
|
|
936
937
|
selectedDestinationIds={draftItineraryDestinations}
|
|
937
938
|
currency={currency}
|
|
938
939
|
locale={locale}
|
|
940
|
+
foodRestrictions={foodRestrictions}
|
|
939
941
|
onAddOnSelectionsChange={setAddOnSelections}
|
|
942
|
+
onFoodRestrictionsChange={setFoodRestrictions}
|
|
940
943
|
/>
|
|
941
944
|
)}
|
|
942
945
|
|
|
@@ -3,73 +3,47 @@ import styles from './PrivateShuttleBookingFlow.module.css';
|
|
|
3
3
|
export interface PrivateShuttlePreferencesSectionProps {
|
|
4
4
|
passengerCount: number;
|
|
5
5
|
childSafetySeatsCount: number;
|
|
6
|
-
foodRestrictions: string;
|
|
7
6
|
onChildSafetySeatsCountChange: (count: number) => void;
|
|
8
|
-
onFoodRestrictionsChange: (value: string) => void;
|
|
9
7
|
}
|
|
10
8
|
|
|
11
9
|
export function PrivateShuttlePreferencesSection({
|
|
12
10
|
passengerCount,
|
|
13
11
|
childSafetySeatsCount,
|
|
14
|
-
foodRestrictions,
|
|
15
12
|
onChildSafetySeatsCountChange,
|
|
16
|
-
onFoodRestrictionsChange,
|
|
17
13
|
}: PrivateShuttlePreferencesSectionProps) {
|
|
18
14
|
if (passengerCount <= 0) return null;
|
|
19
15
|
|
|
20
16
|
return (
|
|
21
|
-
|
|
22
|
-
<
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
className={styles.qtyBtn}
|
|
48
|
-
>
|
|
49
|
-
+
|
|
50
|
-
</button>
|
|
51
|
-
</div>
|
|
52
|
-
</div>
|
|
53
|
-
|
|
54
|
-
<div className={styles.section}>
|
|
55
|
-
<label
|
|
56
|
-
htmlFor="food-restrictions"
|
|
57
|
-
className={`${styles.sectionLabel} private-shuttle-section-label`}
|
|
17
|
+
<div className={styles.section}>
|
|
18
|
+
<label className={`${styles.sectionLabel} private-shuttle-section-label`}>
|
|
19
|
+
Safety seats for kids
|
|
20
|
+
</label>
|
|
21
|
+
<p className="text-sm text-stone-500 mb-2">
|
|
22
|
+
How many child safety seats do you need?
|
|
23
|
+
</p>
|
|
24
|
+
<div className="flex items-center gap-2">
|
|
25
|
+
<button
|
|
26
|
+
type="button"
|
|
27
|
+
onClick={() => onChildSafetySeatsCountChange(Math.max(0, childSafetySeatsCount - 1))}
|
|
28
|
+
disabled={childSafetySeatsCount <= 0}
|
|
29
|
+
className={styles.qtyBtn}
|
|
30
|
+
>
|
|
31
|
+
-
|
|
32
|
+
</button>
|
|
33
|
+
<span className="w-8 text-center font-medium tabular-nums">
|
|
34
|
+
{childSafetySeatsCount}
|
|
35
|
+
</span>
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
onClick={() =>
|
|
39
|
+
onChildSafetySeatsCountChange(Math.min(passengerCount, childSafetySeatsCount + 1))
|
|
40
|
+
}
|
|
41
|
+
disabled={childSafetySeatsCount >= passengerCount}
|
|
42
|
+
className={styles.qtyBtn}
|
|
58
43
|
>
|
|
59
|
-
|
|
60
|
-
</
|
|
61
|
-
<p className="text-sm text-stone-500 mb-2">
|
|
62
|
-
Shuttle includes croissants, coffee, tea, hot chocolate, trail snacks.
|
|
63
|
-
</p>
|
|
64
|
-
<textarea
|
|
65
|
-
id="food-restrictions"
|
|
66
|
-
value={foodRestrictions}
|
|
67
|
-
onChange={(event) => onFoodRestrictionsChange(event.target.value)}
|
|
68
|
-
placeholder="Any dietary restrictions or allergies?"
|
|
69
|
-
rows={2}
|
|
70
|
-
className={styles.input}
|
|
71
|
-
/>
|
|
44
|
+
+
|
|
45
|
+
</button>
|
|
72
46
|
</div>
|
|
73
|
-
|
|
47
|
+
</div>
|
|
74
48
|
);
|
|
75
49
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type AddOnSelectionLike = {
|
|
2
|
+
addOnId: string;
|
|
3
|
+
quantity?: number;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export function selectedAddOnQuantity(addOnId: string, selections: AddOnSelectionLike[]): number {
|
|
7
|
+
return selections
|
|
8
|
+
.filter((selection) => selection.addOnId === addOnId)
|
|
9
|
+
.reduce((total, selection) => {
|
|
10
|
+
const quantity = selection.quantity ?? 1;
|
|
11
|
+
return total + (Number.isFinite(quantity) ? Math.max(0, quantity) : 0);
|
|
12
|
+
}, 0);
|
|
13
|
+
}
|
|
@@ -60,7 +60,7 @@ function buildChangeBookingAddOnCheckoutLines(
|
|
|
60
60
|
const amount = ((addOn.price ?? 0) + (variant?.priceAdjustment ?? 0)) * quantity;
|
|
61
61
|
const label = variant?.label
|
|
62
62
|
? `${addOn.name} (${variant.label})${quantity > 1 ? ` \u00d7 ${quantity}` : ''}`
|
|
63
|
-
: addOn.name
|
|
63
|
+
: `${addOn.name}${quantity > 1 ? ` \u00d7 ${quantity}` : ''}`;
|
|
64
64
|
|
|
65
65
|
lines.push({
|
|
66
66
|
label,
|
|
@@ -87,7 +87,7 @@ function buildPrivateShuttleAddOnLineItems(
|
|
|
87
87
|
const amount = ((addOn.price ?? 0) + (variant?.priceAdjustment ?? 0)) * quantity;
|
|
88
88
|
const checkoutLabel = variant?.label
|
|
89
89
|
? `${addOn.name} (${variant.label})${quantity > 1 ? ` \u00d7 ${quantity}` : ''}`
|
|
90
|
-
: addOn.name
|
|
90
|
+
: `${addOn.name}${quantity > 1 ? ` \u00d7 ${quantity}` : ''}`;
|
|
91
91
|
|
|
92
92
|
checkoutLines.push({
|
|
93
93
|
label: checkoutLabel,
|
|
@@ -95,7 +95,7 @@ function buildPrivateShuttleAddOnLineItems(
|
|
|
95
95
|
type: 'FEE',
|
|
96
96
|
});
|
|
97
97
|
modalFeeLineItems.push({
|
|
98
|
-
name:
|
|
98
|
+
name: checkoutLabel,
|
|
99
99
|
totalAmount: amount,
|
|
100
100
|
});
|
|
101
101
|
}
|