@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.
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 +236 -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 +48 -27
  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 +3 -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;
@@ -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
- `Of your ${total} lunch${total !== 1 ? 'es' : ''}, how many with Water vs Gatorade?`,
165
- sectionLabel = '🍽️ Order lunch?',
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) return { step1Counts: {} as Record<string, number>, step2Counts: {} as Record<string, number> };
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="mb-2">
214
- <label className="block text-sm font-medium text-stone-700">{sectionLabel}</label>
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="text-sm text-stone-500 mt-0.5">{addOn.description} - lunch packages include sandwich, a drink, and a banana bread snack.</p>
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 = { ...step1Counts, [opt.id]: Math.max(0, qty - 1) };
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="flex flex-wrap items-center gap-4">
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-2">
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, { ...step2Counts, ...next });
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, { ...step2Counts, ...next });
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 || 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
  }
@@ -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);