@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.
Files changed (25) hide show
  1. package/package.json +1 -1
  2. package/src/components/booking/AddOnsSection.module.css +36 -5
  3. package/src/components/booking/AddOnsSection.tsx +250 -144
  4. package/src/components/booking/AdminChangeBookingFlow.tsx +3 -2
  5. package/src/components/booking/CollapsibleAddOnItem.module.css +72 -0
  6. package/src/components/booking/CollapsibleAddOnItem.tsx +38 -0
  7. package/src/components/booking/MealDrinkAddOnSelector.tsx +70 -29
  8. package/src/components/booking/PrivateShuttleAddOnsSection.tsx +124 -57
  9. package/src/components/booking/PrivateShuttleBookingFlow.module.css +26 -0
  10. package/src/components/booking/PrivateShuttleBookingFlow.tsx +8 -5
  11. package/src/components/booking/PrivateShuttlePreferencesSection.tsx +29 -55
  12. package/src/components/booking/add-on-selection-helpers.ts +13 -0
  13. package/src/components/booking/change-booking-checkout-builders.ts +1 -1
  14. package/src/components/booking/private-shuttle-checkout-builders.ts +2 -2
  15. package/src/components/booking/private-shuttle-lunch-visibility.ts +20 -0
  16. package/src/components/booking/standard-booking-checkout-builders.ts +1 -1
  17. package/src/components/booking/useAdminChangeProtectedPricing.ts +1 -1
  18. package/src/components/booking/useBookingAvailabilityAddOns.ts +26 -4
  19. package/src/components/booking/useChangeBookingProtectedPricing.ts +1 -1
  20. package/src/components/booking/usePrivateShuttlePriceSummary.ts +13 -6
  21. package/src/components/booking/useStandardBookingPriceSummary.ts +1 -1
  22. package/src/lib/booking-api.ts +4 -1
  23. package/test/add-on-selection-helpers.test.ts +24 -0
  24. package/test/change-booking-helpers.test.ts +42 -1
  25. 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
- `Of your ${total} lunch${total !== 1 ? 'es' : ''}, how many with Water vs Gatorade?`,
165
- sectionLabel = '🍽️ Order lunch?',
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) return { step1Counts: {} as Record<string, number>, step2Counts: {} as Record<string, number> };
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="mb-2">
214
- <label className="block text-sm font-medium text-stone-700">{sectionLabel}</label>
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="text-sm text-stone-500 mt-0.5">{addOn.description} - lunch packages include sandwich, a drink, and a banana bread snack.</p>
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="font-medium text-stone-800">{opt.label}</span>
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 = { ...step1Counts, [opt.id]: Math.max(0, qty - 1) };
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="flex flex-wrap items-center gap-4">
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-2">
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, { ...step2Counts, ...next });
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, { ...step2Counts, ...next });
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 || addOns.length === 0) return null;
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
- <div className={styles.section}>
46
- {lunchAddOn && canUseMealDrinkSelector(lunchAddOn) ? (
47
- <MealDrinkAddOnSelector
48
- addOn={lunchAddOn}
49
- selections={addOnSelections}
50
- onSelectionsChange={(updater) =>
51
- onAddOnSelectionsChange((prev) => updater(prev))
52
- }
53
- currency={currency}
54
- locale={locale}
55
- />
56
- ) : null}
57
- {animalsAddOn ? (
58
- <div>
59
- <label className={`${styles.sectionLabel} private-shuttle-section-label`}>
60
- Traveling with animals?
61
- </label>
62
- <p className="text-sm text-stone-500 mb-2">
63
- {animalsAddOn.description || 'Cleaning fee for traveling with animals'}
64
- </p>
65
- <div className="flex flex-wrap gap-2">
66
- <button
67
- type="button"
68
- onClick={() =>
69
- onAddOnSelectionsChange(
70
- addOnSelections.filter((selection) => selection.addOnId !== 'addon_animals'),
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
- className={`${styles.btnTime} private-shuttle-btn-time ${
74
- !isAnimalsSelected ? styles.btnTimeSelected : styles.btnTimeDefault
75
- }`}
76
- >
77
- No animals
78
- </button>
79
- <button
80
- type="button"
81
- onClick={() => {
82
- if (isAnimalsSelected) return;
83
- onAddOnSelectionsChange([
84
- ...addOnSelections,
85
- { addOnId: 'addon_animals', quantity: 1 },
86
- ]);
87
- }}
88
- className={`${styles.btnTime} private-shuttle-btn-time flex items-center gap-2 ${
89
- isAnimalsSelected ? styles.btnTimeSelected : styles.btnTimeDefault
90
- }`}
91
- >
92
- <span>Yes, traveling with animals</span>
93
- <span className="text-sm font-semibold opacity-90">
94
- +{formatCurrencyAmount(animalsAddOn.price || 0, currency, locale)}
95
- </span>
96
- </button>
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
- </div>
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
- if (!draftItineraryDestinations.includes('emerald_lake')) {
346
- setAddOnSelections((prev) => prev.filter((s) => s.addOnId !== 'addon_el_lunch'));
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
- <div className={styles.section}>
23
- <label className={`${styles.sectionLabel} private-shuttle-section-label`}>
24
- Safety seats for kids
25
- </label>
26
- <p className="text-sm text-stone-500 mb-2">
27
- How many child safety seats do you need?
28
- </p>
29
- <div className="flex items-center gap-2">
30
- <button
31
- type="button"
32
- onClick={() => onChildSafetySeatsCountChange(Math.max(0, childSafetySeatsCount - 1))}
33
- disabled={childSafetySeatsCount <= 0}
34
- className={styles.qtyBtn}
35
- >
36
- -
37
- </button>
38
- <span className="w-8 text-center font-medium tabular-nums">
39
- {childSafetySeatsCount}
40
- </span>
41
- <button
42
- type="button"
43
- onClick={() =>
44
- onChildSafetySeatsCountChange(Math.min(passengerCount, childSafetySeatsCount + 1))
45
- }
46
- disabled={childSafetySeatsCount >= passengerCount}
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
- Food restrictions
60
- </label>
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: addOn.name,
98
+ name: checkoutLabel,
99
99
  totalAmount: amount,
100
100
  });
101
101
  }