@ticketboothapp/booking 1.2.177 → 1.2.178
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 +236 -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 +48 -27
- 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 +3 -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;
|
|
@@ -94,7 +98,7 @@ function deriveCounts(
|
|
|
94
98
|
selections: AddOnSelection[],
|
|
95
99
|
getVariantId: (s1: string, s2: string) => string | null,
|
|
96
100
|
step1Ids: string[],
|
|
97
|
-
step2Ids: string[]
|
|
101
|
+
step2Ids: string[],
|
|
98
102
|
): {
|
|
99
103
|
step1Counts: Record<string, number>;
|
|
100
104
|
step2Counts: Record<string, number>;
|
|
@@ -128,7 +132,7 @@ function countsToSelections(
|
|
|
128
132
|
step2Counts: Record<string, number>,
|
|
129
133
|
getVariantId: (s1: string, s2: string) => string | null,
|
|
130
134
|
step1Ids: string[],
|
|
131
|
-
step2Ids: string[]
|
|
135
|
+
step2Ids: string[],
|
|
132
136
|
): AddOnSelection[] {
|
|
133
137
|
const total = Object.values(step1Counts).reduce((a, b) => a + b, 0);
|
|
134
138
|
const step2Total = Object.values(step2Counts).reduce((a, b) => a + b, 0);
|
|
@@ -160,25 +164,32 @@ export function MealDrinkAddOnSelector({
|
|
|
160
164
|
onSelectionsChange,
|
|
161
165
|
currency,
|
|
162
166
|
locale,
|
|
163
|
-
step2Label = (total) =>
|
|
164
|
-
|
|
165
|
-
|
|
167
|
+
step2Label = (total) => `Of your ${total} lunch${total !== 1 ? 'es' : ''}, how many with Water vs Gatorade?`,
|
|
168
|
+
sectionLabel = 'Order lunch?',
|
|
169
|
+
headerClassName,
|
|
170
|
+
titleClassName,
|
|
171
|
+
descriptionClassName,
|
|
172
|
+
hideTitle = false,
|
|
166
173
|
minimumTotal = 0,
|
|
167
174
|
suppressPrices = false,
|
|
168
175
|
}: MealDrinkAddOnSelectorProps) {
|
|
169
176
|
const parsed = useMemo(
|
|
170
177
|
() => (addOn.variantType === 'multi_quantity' && addOn.variants ? parseTwoStepVariants(addOn.variants) : null),
|
|
171
|
-
[addOn.variantType, addOn.variants]
|
|
178
|
+
[addOn.variantType, addOn.variants],
|
|
172
179
|
);
|
|
173
180
|
|
|
174
181
|
const { step1Counts, step2Counts } = useMemo(() => {
|
|
175
|
-
if (!parsed)
|
|
182
|
+
if (!parsed)
|
|
183
|
+
return {
|
|
184
|
+
step1Counts: {} as Record<string, number>,
|
|
185
|
+
step2Counts: {} as Record<string, number>,
|
|
186
|
+
};
|
|
176
187
|
return deriveCounts(
|
|
177
188
|
addOn.addOnId,
|
|
178
189
|
selections,
|
|
179
190
|
parsed.getVariantId,
|
|
180
191
|
parsed.step1Options.map((o) => o.id),
|
|
181
|
-
parsed.step2Options.map((o) => o.id)
|
|
192
|
+
parsed.step2Options.map((o) => o.id),
|
|
182
193
|
);
|
|
183
194
|
}, [addOn.addOnId, selections, parsed]);
|
|
184
195
|
|
|
@@ -193,11 +204,11 @@ export function MealDrinkAddOnSelector({
|
|
|
193
204
|
step2CountsNew,
|
|
194
205
|
parsed.getVariantId,
|
|
195
206
|
step1Ids,
|
|
196
|
-
step2Ids
|
|
207
|
+
step2Ids,
|
|
197
208
|
);
|
|
198
209
|
onSelectionsChange((prev) => [...prev.filter((s) => s.addOnId !== addOn.addOnId), ...newEntries]);
|
|
199
210
|
},
|
|
200
|
-
[addOn.addOnId, parsed, onSelectionsChange]
|
|
211
|
+
[addOn.addOnId, parsed, onSelectionsChange],
|
|
201
212
|
);
|
|
202
213
|
|
|
203
214
|
if (!parsed) return null;
|
|
@@ -210,10 +221,17 @@ export function MealDrinkAddOnSelector({
|
|
|
210
221
|
|
|
211
222
|
return (
|
|
212
223
|
<div>
|
|
213
|
-
<div className=
|
|
214
|
-
|
|
224
|
+
<div className={headerClassName || 'mb-2'}>
|
|
225
|
+
{!hideTitle ? (
|
|
226
|
+
<label className={titleClassName || 'block text-sm font-medium text-stone-700'}>
|
|
227
|
+
{addOn.emoji?.trim() ? `${addOn.emoji.trim()} ` : ''}
|
|
228
|
+
{sectionLabel}
|
|
229
|
+
</label>
|
|
230
|
+
) : null}
|
|
215
231
|
{addOn.description && (
|
|
216
|
-
<p className=
|
|
232
|
+
<p className={descriptionClassName || 'text-sm text-stone-500 mt-0.5'}>
|
|
233
|
+
{addOn.description} - lunch packages include sandwich, a drink, and a banana bread snack.
|
|
234
|
+
</p>
|
|
217
235
|
)}
|
|
218
236
|
</div>
|
|
219
237
|
<div className="space-y-4">
|
|
@@ -224,8 +242,8 @@ export function MealDrinkAddOnSelector({
|
|
|
224
242
|
const qty = step1Counts[opt.id] ?? 0;
|
|
225
243
|
const canDecrementTotal = totalStep1 > minimumTotal;
|
|
226
244
|
return (
|
|
227
|
-
<div key={opt.id} className="flex items-center gap-3">
|
|
228
|
-
<span className="font-medium text-stone-800">{opt.label}</span>
|
|
245
|
+
<div key={opt.id} className="flex flex-wrap items-center gap-3 p-3 bg-stone-50 rounded-lg">
|
|
246
|
+
<span className="font-medium text-stone-800 flex-1">{opt.label}</span>
|
|
229
247
|
<span className="text-sm text-stone-500 shrink-0">
|
|
230
248
|
{suppressPrices ? '—' : `${formatCurrencyAmount(price, currency, locale)} ea`}
|
|
231
249
|
</span>
|
|
@@ -234,7 +252,10 @@ export function MealDrinkAddOnSelector({
|
|
|
234
252
|
type="button"
|
|
235
253
|
onClick={() => {
|
|
236
254
|
if (!canDecrementTotal) return;
|
|
237
|
-
const next = {
|
|
255
|
+
const next = {
|
|
256
|
+
...step1Counts,
|
|
257
|
+
[opt.id]: Math.max(0, qty - 1),
|
|
258
|
+
};
|
|
238
259
|
const total = Object.values(next).reduce((a, b) => a + b, 0);
|
|
239
260
|
const drinks =
|
|
240
261
|
total > 0
|
|
@@ -276,14 +297,14 @@ export function MealDrinkAddOnSelector({
|
|
|
276
297
|
{totalStep1 > 0 && (
|
|
277
298
|
<div className="pt-3 border-t border-stone-200">
|
|
278
299
|
<p className="text-xs font-medium text-stone-600 mb-2">{step2Label(totalStep1)}</p>
|
|
279
|
-
<div className="
|
|
300
|
+
<div className="space-y-2">
|
|
280
301
|
{parsed.step2Options.map((opt) => {
|
|
281
302
|
const qty = step2Counts[opt.id] ?? 0;
|
|
282
303
|
const otherId = step2Ids.find((id) => id !== opt.id)!;
|
|
283
304
|
const otherQty = step2Counts[otherId] ?? 0;
|
|
284
305
|
return (
|
|
285
|
-
<div key={opt.id} className="flex items-center gap-
|
|
286
|
-
<span className="text-sm text-stone-700">{opt.label}</span>
|
|
306
|
+
<div key={opt.id} className="flex items-center gap-3 p-3 bg-stone-50 rounded-lg">
|
|
307
|
+
<span className="text-sm text-stone-700 flex-1">{opt.label}</span>
|
|
287
308
|
<div className="flex items-center gap-1">
|
|
288
309
|
<button
|
|
289
310
|
type="button"
|
|
@@ -293,7 +314,10 @@ export function MealDrinkAddOnSelector({
|
|
|
293
314
|
[opt.id]: qty - 1,
|
|
294
315
|
[otherId]: otherQty + 1,
|
|
295
316
|
};
|
|
296
|
-
updateSelections(step1Counts, {
|
|
317
|
+
updateSelections(step1Counts, {
|
|
318
|
+
...step2Counts,
|
|
319
|
+
...next,
|
|
320
|
+
});
|
|
297
321
|
}}
|
|
298
322
|
disabled={qty <= 0}
|
|
299
323
|
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 +333,10 @@ export function MealDrinkAddOnSelector({
|
|
|
309
333
|
[opt.id]: qty + 1,
|
|
310
334
|
[otherId]: otherQty - 1,
|
|
311
335
|
};
|
|
312
|
-
updateSelections(step1Counts, {
|
|
336
|
+
updateSelections(step1Counts, {
|
|
337
|
+
...step2Counts,
|
|
338
|
+
...next,
|
|
339
|
+
});
|
|
313
340
|
}}
|
|
314
341
|
disabled={otherQty <= 0}
|
|
315
342
|
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 +355,6 @@ export function MealDrinkAddOnSelector({
|
|
|
328
355
|
)}
|
|
329
356
|
</div>
|
|
330
357
|
)}
|
|
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
358
|
</div>
|
|
338
359
|
</div>
|
|
339
360
|
);
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const EMERALD_LAKE_LUNCH_ADD_ON_ID = 'addon_el_lunch';
|
|
2
|
+
export const WILD_FLOUR_LUNCH_ADD_ON_ID = 'addon_wild_flour_bakery_lunch_package';
|
|
3
|
+
|
|
4
|
+
const EMERALD_LAKE_DESTINATION_ID = 'emerald_lake';
|
|
5
|
+
|
|
6
|
+
export function privateShuttleLunchAddOnIsVisible(
|
|
7
|
+
addOnId: string,
|
|
8
|
+
selectedDestinationIds: string[],
|
|
9
|
+
): boolean {
|
|
10
|
+
const includesEmeraldLake = selectedDestinationIds.includes(EMERALD_LAKE_DESTINATION_ID);
|
|
11
|
+
if (addOnId === EMERALD_LAKE_LUNCH_ADD_ON_ID) return includesEmeraldLake;
|
|
12
|
+
if (addOnId === WILD_FLOUR_LUNCH_ADD_ON_ID) return !includesEmeraldLake;
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function privateShuttleHiddenLunchAddOnId(selectedDestinationIds: string[]): string {
|
|
17
|
+
return selectedDestinationIds.includes(EMERALD_LAKE_DESTINATION_ID)
|
|
18
|
+
? WILD_FLOUR_LUNCH_ADD_ON_ID
|
|
19
|
+
: EMERALD_LAKE_LUNCH_ADD_ON_ID;
|
|
20
|
+
}
|
|
@@ -104,7 +104,7 @@ function buildStandardBookingAddOnLineItems(
|
|
|
104
104
|
const amount = ((addOn.price ?? 0) + (variant?.priceAdjustment ?? 0)) * quantity;
|
|
105
105
|
const label = variant?.label
|
|
106
106
|
? `${addOn.name} (${variant.label})${quantity > 1 ? ` \u00d7 ${quantity}` : ''}`
|
|
107
|
-
: addOn.name
|
|
107
|
+
: `${addOn.name}${quantity > 1 ? ` \u00d7 ${quantity}` : ''}`;
|
|
108
108
|
|
|
109
109
|
checkoutLines.push({
|
|
110
110
|
label,
|
|
@@ -283,7 +283,7 @@ export function useAdminChangeProtectedPricing({
|
|
|
283
283
|
: null;
|
|
284
284
|
const name = variantLabel
|
|
285
285
|
? `${addOn.name} (${variantLabel})${qty > 1 ? ` \u00d7 ${qty}` : ''}`
|
|
286
|
-
: addOn.name
|
|
286
|
+
: `${addOn.name}${qty > 1 ? ` \u00d7 ${qty}` : ''}`;
|
|
287
287
|
return { name, totalAmount: amt, description: addOn.description ?? undefined };
|
|
288
288
|
})
|
|
289
289
|
.filter((x): x is NonNullable<typeof x> => x != null);
|