@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.177",
3
+ "version": "1.2.178",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -1,10 +1,41 @@
1
- /**
2
- * Add-ons section - label styled like tickets, cancellation policy (via booking-flow.css preflight)
3
- */
1
+ .section {
2
+ padding-top: 1.5rem;
3
+ margin-top: 1.5rem;
4
+ border-top: 1px solid var(--booking-stone-200, #e7e5e4);
5
+ }
6
+
7
+ .items {
8
+ display: flex;
9
+ flex-direction: column;
10
+ gap: 0.625rem;
11
+ margin-top: 1rem;
12
+ }
4
13
 
5
14
  .label {
6
15
  display: block;
16
+ font-family: 'Figtree', var(--booking-font-sans, ui-sans-serif), sans-serif;
17
+ font-size: 1.5rem;
18
+ font-weight: 800;
19
+ line-height: 1.2;
20
+ color: var(--accent-orange, #f4511e);
21
+ }
22
+
23
+ .itemHeader {
24
+ margin-bottom: 0.75rem;
25
+ }
26
+
27
+ .itemTitle {
28
+ display: block;
29
+ font-family: 'Figtree', var(--booking-font-sans, ui-sans-serif), sans-serif !important;
30
+ font-size: 1.0625rem;
31
+ font-weight: 700;
32
+ line-height: 1.35;
33
+ color: var(--accent-orange, #f4511e) !important;
34
+ }
35
+
36
+ .itemDescription {
37
+ margin: 0.25rem 0 0;
7
38
  font-size: 0.875rem;
8
- font-weight: 500;
9
- color: var(--booking-stone-700, #44403c);
39
+ line-height: 1.5;
40
+ color: var(--booking-stone-500, #78716c);
10
41
  }
@@ -2,11 +2,22 @@
2
2
 
3
3
  import { formatCurrencyAmount } from '../../lib/currency';
4
4
  import type { AddOn } from '../../lib/booking-api';
5
+ import { CollapsibleAddOnItem } from './CollapsibleAddOnItem';
5
6
  import { MealDrinkAddOnSelector, canUseMealDrinkSelector } from './MealDrinkAddOnSelector';
7
+ import { selectedAddOnQuantity } from './add-on-selection-helpers';
6
8
  import type { Currency } from './CurrencySwitcher';
7
9
  import styles from './AddOnsSection.module.css';
8
10
 
9
- export type AddOnSelection = { addOnId: string; variantId?: string; quantity?: number };
11
+ export type AddOnSelection = {
12
+ addOnId: string;
13
+ variantId?: string;
14
+ quantity?: number;
15
+ };
16
+
17
+ function addOnTitle(addOn: AddOn, label = addOn.name): string {
18
+ const emoji = addOn.emoji?.trim();
19
+ return emoji ? `${emoji} ${label}` : label;
20
+ }
10
21
 
11
22
  interface AddOnsSectionProps {
12
23
  addOns: AddOn[];
@@ -16,6 +27,11 @@ interface AddOnsSectionProps {
16
27
  onSelectionsChange: (selections: AddOnSelection[] | ((prev: AddOnSelection[]) => AddOnSelection[])) => void;
17
28
  minimumTotalByAddOnId?: Map<string, number>;
18
29
  suppressPrices?: boolean;
30
+ embedded?: boolean;
31
+ className?: string;
32
+ itemHeaderClassName?: string;
33
+ itemTitleClassName?: string;
34
+ itemDescriptionClassName?: string;
19
35
  }
20
36
 
21
37
  export function AddOnsSection({
@@ -26,162 +42,238 @@ export function AddOnsSection({
26
42
  onSelectionsChange,
27
43
  minimumTotalByAddOnId,
28
44
  suppressPrices = false,
45
+ embedded = false,
46
+ className,
47
+ itemHeaderClassName,
48
+ itemTitleClassName,
49
+ itemDescriptionClassName,
29
50
  }: AddOnsSectionProps) {
51
+ const layoutClassName = embedded ? className || 'space-y-4' : `${styles.section}${className ? ` ${className}` : ''}`;
52
+ const headerClassName = itemHeaderClassName || styles.itemHeader;
53
+ const titleClassName = itemTitleClassName || styles.itemTitle;
54
+ const descriptionClassName = itemDescriptionClassName || styles.itemDescription;
55
+
30
56
  return (
31
- <div className="border-t border-stone-200 pt-6 space-y-4">
32
- <label className={styles.label}>
33
- Add-ons
34
- </label>
35
- {addOns.map((addOn) => {
36
- const minTotalForAddOn = minimumTotalByAddOnId?.get(addOn.addOnId) ?? 0;
37
- if (addOn.variantType === 'none') {
38
- const isSelected = addOnSelections.some((s) => s.addOnId === addOn.addOnId);
39
- const canToggleOff = minTotalForAddOn <= 0;
40
- return (
41
- <div key={addOn.addOnId}>
42
- <label className="block text-sm font-medium text-stone-700 mb-2">{addOn.name}</label>
43
- {addOn.description && (
44
- <p className="text-sm text-stone-500 mb-2">{addOn.description}</p>
45
- )}
46
- <button
47
- type="button"
48
- onClick={() => {
49
- onSelectionsChange((prev) => {
50
- if (isSelected) {
51
- if (!canToggleOff) return prev;
52
- return prev.filter((s) => s.addOnId !== addOn.addOnId);
53
- }
54
- return [...prev, { addOnId: addOn.addOnId, quantity: 1 }];
55
- });
56
- }}
57
- className={`flex items-center justify-between w-full p-4 rounded-lg border-2 text-left transition-colors ${
58
- isSelected ? 'border-emerald-500 bg-emerald-50' : 'border-stone-200 bg-white hover:border-stone-300'
59
- }`}
60
- >
61
- <span className="font-medium text-stone-900">
62
- {isSelected ? `Yes, add ${addOn.name}` : `No ${addOn.name}`}
63
- </span>
64
- <span className="text-sm font-semibold text-stone-700">
65
- {suppressPrices ? '—' : `+${formatCurrencyAmount(addOn.price ?? 0, currency, locale as 'en' | 'fr')}`}
66
- </span>
67
- </button>
68
- </div>
69
- );
70
- }
71
- if (addOn.variantType === 'multi_quantity' && addOn.variants?.length) {
72
- if (canUseMealDrinkSelector(addOn)) {
57
+ <div className={layoutClassName}>
58
+ {!embedded ? <label className={styles.label}>Add-ons</label> : null}
59
+ <div className={!embedded ? styles.items : undefined}>
60
+ {addOns.map((addOn) => {
61
+ const minTotalForAddOn = minimumTotalByAddOnId?.get(addOn.addOnId) ?? 0;
62
+ const usesMealSelector = addOn.variantType === 'multi_quantity' && canUseMealDrinkSelector(addOn);
63
+ const title = addOnTitle(addOn, usesMealSelector ? 'Order lunch?' : addOn.name);
64
+ const selectedQuantity = selectedAddOnQuantity(addOn.addOnId, addOnSelections);
65
+ if (addOn.variantType === 'none') {
66
+ const selection = addOnSelections.find((s) => s.addOnId === addOn.addOnId);
67
+ const quantity = Math.max(0, selection?.quantity ?? 0);
68
+ const canDecrement = quantity > minTotalForAddOn;
73
69
  return (
74
- <MealDrinkAddOnSelector
70
+ <CollapsibleAddOnItem
75
71
  key={addOn.addOnId}
76
- addOn={addOn}
77
- selections={addOnSelections}
78
- onSelectionsChange={onSelectionsChange}
79
- currency={currency}
80
- locale={locale as 'en' | 'fr'}
81
- minimumTotal={minTotalForAddOn}
82
- suppressPrices={suppressPrices}
83
- />
84
- );
85
- }
86
- return (
87
- <div key={addOn.addOnId}>
88
- <label className="block text-sm font-medium text-stone-700 mb-2">{addOn.name}</label>
89
- {addOn.description && (
90
- <p className="text-sm text-stone-500 mb-2">{addOn.description}</p>
91
- )}
92
- <div className="space-y-2">
93
- {addOn.variants.map((variant) => {
94
- const sel = addOnSelections.find((s) => s.addOnId === addOn.addOnId && s.variantId === variant.id);
95
- const qty = sel?.quantity ?? 0;
96
- const currentTotalForAddOn = addOnSelections
97
- .filter((s) => s.addOnId === addOn.addOnId)
98
- .reduce((sum, s) => sum + Math.max(1, Number(s.quantity) || 1), 0);
99
- const canDecrement = qty > 0 && currentTotalForAddOn > minTotalForAddOn;
100
- const price = (addOn.price ?? 0) + (variant.priceAdjustment ?? 0);
101
- return (
102
- <div key={variant.id} className="flex items-center gap-3 p-3 bg-stone-50 rounded-lg">
103
- <span className="text-sm text-stone-800">{variant.label}</span>
104
- <div className="flex items-center gap-2 shrink-0">
105
- <button
106
- type="button"
107
- onClick={() => {
108
- onSelectionsChange((prev) => {
109
- const next = Math.max(0, qty - 1);
110
- const rest = prev.filter((s) => !(s.addOnId === addOn.addOnId && s.variantId === variant.id));
111
- if (next > 0) return [...rest, { addOnId: addOn.addOnId, variantId: variant.id, quantity: next }];
112
- return rest;
113
- });
114
- }}
115
- disabled={!canDecrement}
116
- 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"
117
- >
118
-
119
- </button>
120
- <span className="w-6 text-center font-medium tabular-nums text-sm">{qty}</span>
121
- <button
122
- type="button"
123
- onClick={() => {
124
- onSelectionsChange((prev) => {
125
- const rest = prev.filter((s) => !(s.addOnId === addOn.addOnId && s.variantId === variant.id));
126
- return [...rest, { addOnId: addOn.addOnId, variantId: variant.id, quantity: qty + 1 }];
127
- });
128
- }}
129
- className="h-8 w-8 rounded-full border border-stone-300 bg-white text-stone-600 hover:bg-stone-50 text-sm"
130
- >
131
- +
132
- </button>
133
- <span className="text-sm font-medium text-stone-700 w-16 text-right">
134
- {suppressPrices ? '—' : `${formatCurrencyAmount(price, currency, locale as 'en' | 'fr')} ea`}
135
- </span>
136
- </div>
137
- </div>
138
- );
139
- })}
140
- </div>
141
- </div>
142
- );
143
- }
144
- if (addOn.variantType === 'single_choice' && addOn.variants?.length) {
145
- const sel = addOnSelections.find((s) => s.addOnId === addOn.addOnId);
146
- const hasLockedInitialSelection = minTotalForAddOn > 0;
147
- return (
148
- <div key={addOn.addOnId}>
149
- <label className="block text-sm font-medium text-stone-700 mb-2">{addOn.name}</label>
150
- {addOn.description && (
151
- <p className="text-sm text-stone-500 mb-2">{addOn.description}</p>
152
- )}
153
- <div className="space-y-2">
154
- {addOn.variants.map((variant) => {
155
- const isSelected = sel?.variantId === variant.id;
156
- const price = (addOn.price ?? 0) + (variant.priceAdjustment ?? 0);
157
- return (
72
+ title={title}
73
+ selectedQuantity={selectedQuantity}
74
+ >
75
+ <div className={headerClassName}>
76
+ {addOn.description && <p className={descriptionClassName}>{addOn.description}</p>}
77
+ </div>
78
+ <div className="flex flex-wrap items-center gap-3 rounded-lg bg-stone-50 p-3">
79
+ <span className="flex-1 text-sm text-stone-800">Quantity</span>
80
+ <span className="shrink-0 text-sm text-stone-500">
81
+ {suppressPrices
82
+ ? '—'
83
+ : `${formatCurrencyAmount(addOn.price ?? 0, currency, locale as 'en' | 'fr')} ea`}
84
+ </span>
85
+ <div className="flex shrink-0 items-center gap-2">
158
86
  <button
159
- key={variant.id}
160
87
  type="button"
161
88
  onClick={() => {
162
89
  onSelectionsChange((prev) => {
90
+ if (!canDecrement) return prev;
91
+ const next = Math.max(0, quantity - 1);
163
92
  const rest = prev.filter((s) => s.addOnId !== addOn.addOnId);
164
- if (isSelected) return hasLockedInitialSelection ? prev : rest;
165
- return [...rest, { addOnId: addOn.addOnId, variantId: variant.id, quantity: 1 }];
93
+ return next > 0 ? [...rest, { addOnId: addOn.addOnId, quantity: next }] : rest;
166
94
  });
167
95
  }}
168
- className={`flex items-center justify-between w-full p-3 rounded-lg border-2 text-left transition-colors ${
169
- isSelected ? 'border-emerald-500 bg-emerald-50' : 'border-stone-200 bg-white hover:border-stone-300'
170
- }`}
96
+ disabled={!canDecrement}
97
+ className="h-8 w-8 rounded-full border border-stone-300 bg-white text-sm text-stone-600 hover:bg-stone-50 disabled:cursor-not-allowed disabled:opacity-50"
171
98
  >
172
- <span className="text-sm font-medium text-stone-800">{variant.label}</span>
173
- <span className="text-sm font-semibold text-stone-700">
174
- {suppressPrices ? '—' : `+${formatCurrencyAmount(price, currency, locale as 'en' | 'fr')}`}
175
- </span>
99
+
176
100
  </button>
177
- );
178
- })}
179
- </div>
180
- </div>
181
- );
182
- }
183
- return null;
184
- })}
101
+ <span className="w-6 text-center text-sm font-medium tabular-nums">{quantity}</span>
102
+ <button
103
+ type="button"
104
+ onClick={() => {
105
+ onSelectionsChange((prev) => {
106
+ const rest = prev.filter((s) => s.addOnId !== addOn.addOnId);
107
+ return [...rest, { addOnId: addOn.addOnId, quantity: quantity + 1 }];
108
+ });
109
+ }}
110
+ className="h-8 w-8 rounded-full border border-stone-300 bg-white text-sm text-stone-600 hover:bg-stone-50"
111
+ >
112
+ +
113
+ </button>
114
+ </div>
115
+ </div>
116
+ </CollapsibleAddOnItem>
117
+ );
118
+ }
119
+ if (addOn.variantType === 'multi_quantity' && addOn.variants?.length) {
120
+ if (usesMealSelector) {
121
+ return (
122
+ <CollapsibleAddOnItem
123
+ key={addOn.addOnId}
124
+ title={title}
125
+ selectedQuantity={selectedQuantity}
126
+ >
127
+ <MealDrinkAddOnSelector
128
+ addOn={addOn}
129
+ selections={addOnSelections}
130
+ onSelectionsChange={onSelectionsChange}
131
+ currency={currency}
132
+ locale={locale as 'en' | 'fr'}
133
+ minimumTotal={minTotalForAddOn}
134
+ suppressPrices={suppressPrices}
135
+ headerClassName={headerClassName}
136
+ titleClassName={titleClassName}
137
+ descriptionClassName={descriptionClassName}
138
+ hideTitle
139
+ />
140
+ </CollapsibleAddOnItem>
141
+ );
142
+ }
143
+ return (
144
+ <CollapsibleAddOnItem
145
+ key={addOn.addOnId}
146
+ title={title}
147
+ selectedQuantity={selectedQuantity}
148
+ >
149
+ <div className={headerClassName}>
150
+ {addOn.description && <p className={descriptionClassName}>{addOn.description}</p>}
151
+ </div>
152
+ <div className="space-y-2">
153
+ {addOn.variants.map((variant) => {
154
+ const sel = addOnSelections.find((s) => s.addOnId === addOn.addOnId && s.variantId === variant.id);
155
+ const qty = sel?.quantity ?? 0;
156
+ const currentTotalForAddOn = addOnSelections
157
+ .filter((s) => s.addOnId === addOn.addOnId)
158
+ .reduce((sum, s) => sum + Math.max(1, Number(s.quantity) || 1), 0);
159
+ const canDecrement = qty > 0 && currentTotalForAddOn > minTotalForAddOn;
160
+ const price = (addOn.price ?? 0) + (variant.priceAdjustment ?? 0);
161
+ return (
162
+ <div key={variant.id} className="flex flex-wrap items-center gap-3 p-3 bg-stone-50 rounded-lg">
163
+ <span className="text-sm text-stone-800 flex-1">{variant.label}</span>
164
+ <span className="text-sm text-stone-500 shrink-0">
165
+ {suppressPrices ? '—' : `${formatCurrencyAmount(price, currency, locale as 'en' | 'fr')} ea`}
166
+ </span>
167
+ <div className="flex items-center gap-2 shrink-0">
168
+ <button
169
+ type="button"
170
+ onClick={() => {
171
+ onSelectionsChange((prev) => {
172
+ const next = Math.max(0, qty - 1);
173
+ const rest = prev.filter(
174
+ (s) => !(s.addOnId === addOn.addOnId && s.variantId === variant.id),
175
+ );
176
+ if (next > 0)
177
+ return [
178
+ ...rest,
179
+ {
180
+ addOnId: addOn.addOnId,
181
+ variantId: variant.id,
182
+ quantity: next,
183
+ },
184
+ ];
185
+ return rest;
186
+ });
187
+ }}
188
+ disabled={!canDecrement}
189
+ 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"
190
+ >
191
+
192
+ </button>
193
+ <span className="w-6 text-center font-medium tabular-nums text-sm">{qty}</span>
194
+ <button
195
+ type="button"
196
+ onClick={() => {
197
+ onSelectionsChange((prev) => {
198
+ const rest = prev.filter(
199
+ (s) => !(s.addOnId === addOn.addOnId && s.variantId === variant.id),
200
+ );
201
+ return [
202
+ ...rest,
203
+ {
204
+ addOnId: addOn.addOnId,
205
+ variantId: variant.id,
206
+ quantity: qty + 1,
207
+ },
208
+ ];
209
+ });
210
+ }}
211
+ className="h-8 w-8 rounded-full border border-stone-300 bg-white text-stone-600 hover:bg-stone-50 text-sm"
212
+ >
213
+ +
214
+ </button>
215
+ </div>
216
+ </div>
217
+ );
218
+ })}
219
+ </div>
220
+ </CollapsibleAddOnItem>
221
+ );
222
+ }
223
+ if (addOn.variantType === 'single_choice' && addOn.variants?.length) {
224
+ const sel = addOnSelections.find((s) => s.addOnId === addOn.addOnId);
225
+ const hasLockedInitialSelection = minTotalForAddOn > 0;
226
+ return (
227
+ <CollapsibleAddOnItem
228
+ key={addOn.addOnId}
229
+ title={title}
230
+ selectedQuantity={selectedQuantity}
231
+ >
232
+ <div className={headerClassName}>
233
+ {addOn.description && <p className={descriptionClassName}>{addOn.description}</p>}
234
+ </div>
235
+ <div className="space-y-2">
236
+ {addOn.variants.map((variant) => {
237
+ const isSelected = sel?.variantId === variant.id;
238
+ const price = (addOn.price ?? 0) + (variant.priceAdjustment ?? 0);
239
+ return (
240
+ <button
241
+ key={variant.id}
242
+ type="button"
243
+ onClick={() => {
244
+ onSelectionsChange((prev) => {
245
+ const rest = prev.filter((s) => s.addOnId !== addOn.addOnId);
246
+ if (isSelected) return hasLockedInitialSelection ? prev : rest;
247
+ return [
248
+ ...rest,
249
+ {
250
+ addOnId: addOn.addOnId,
251
+ variantId: variant.id,
252
+ quantity: 1,
253
+ },
254
+ ];
255
+ });
256
+ }}
257
+ className={`flex items-center justify-between w-full p-3 rounded-lg border-2 text-left transition-colors ${
258
+ isSelected
259
+ ? 'border-emerald-500 bg-emerald-50'
260
+ : 'border-stone-200 bg-stone-50 hover:border-stone-300'
261
+ }`}
262
+ >
263
+ <span className="text-sm font-medium text-stone-800">{variant.label}</span>
264
+ <span className="text-sm text-stone-500">
265
+ {suppressPrices ? '—' : `+${formatCurrencyAmount(price, currency, locale as 'en' | 'fr')}`}
266
+ </span>
267
+ </button>
268
+ );
269
+ })}
270
+ </div>
271
+ </CollapsibleAddOnItem>
272
+ );
273
+ }
274
+ return null;
275
+ })}
276
+ </div>
185
277
  </div>
186
278
  );
187
279
  }
@@ -203,6 +203,8 @@ export function AdminChangeBookingFlow({
203
203
  } = useBookingAvailabilityAddOns({
204
204
  selectedAvailability,
205
205
  companyId: product.companyId,
206
+ ignoreCutoff: true,
207
+ companyTimezone,
206
208
  });
207
209
  const [knownAddOns, setKnownAddOns] = useState<AddOn[]>([]);
208
210
  const mergeKnownAddOns = useCallback((rows: AddOn[]) => {
@@ -227,7 +229,6 @@ export function AdminChangeBookingFlow({
227
229
  if (!product.companyId) return;
228
230
  getAddOns(product.companyId, {
229
231
  preCheckout: true,
230
- dateTime: initialValues?.dateTime?.trim() || undefined,
231
232
  })
232
233
  .then((rows) => {
233
234
  if (!cancelled) mergeKnownAddOns(rows);
@@ -238,7 +239,7 @@ export function AdminChangeBookingFlow({
238
239
  return () => {
239
240
  cancelled = true;
240
241
  };
241
- }, [product.companyId, initialValues?.dateTime, mergeKnownAddOns]);
242
+ }, [product.companyId, mergeKnownAddOns]);
242
243
  const addOnCatalogLoaded =
243
244
  availabilityProductOptionId != null &&
244
245
  loadedProductOptionId === availabilityProductOptionId;
@@ -0,0 +1,72 @@
1
+ .card {
2
+ overflow: hidden;
3
+ border: 1px solid var(--booking-stone-200, #e7e5e4);
4
+ border-radius: 0.75rem;
5
+ background: #fff;
6
+ }
7
+
8
+ .summary {
9
+ display: flex;
10
+ min-height: 2.875rem;
11
+ cursor: pointer;
12
+ list-style: none;
13
+ align-items: center;
14
+ gap: 0.5rem;
15
+ padding: 0.625rem 0.875rem;
16
+ transition: background-color 150ms ease;
17
+ }
18
+
19
+ .summary::-webkit-details-marker {
20
+ display: none;
21
+ }
22
+
23
+ .summary:hover {
24
+ background: var(--booking-stone-50, #fafaf9);
25
+ }
26
+
27
+ .summary:focus-visible {
28
+ outline: 2px solid var(--accent-orange, #f4511e);
29
+ outline-offset: -2px;
30
+ }
31
+
32
+ .title {
33
+ min-width: 0;
34
+ flex: 1;
35
+ font-family: 'Figtree', var(--booking-font-sans, ui-sans-serif), sans-serif !important;
36
+ font-size: 1rem;
37
+ font-weight: 700;
38
+ line-height: 1.35;
39
+ color: var(--booking-stone-900, #1c1917) !important;
40
+ }
41
+
42
+ .selection {
43
+ flex: none;
44
+ border-radius: 9999px;
45
+ background: var(--booking-stone-100, #f5f5f4);
46
+ padding: 0.125rem 0.5rem;
47
+ font-size: 0.6875rem;
48
+ font-weight: 600;
49
+ color: var(--booking-stone-600, #57534e);
50
+ }
51
+
52
+ .selectionActive {
53
+ background: #ecfdf5;
54
+ color: #047857;
55
+ }
56
+
57
+ .chevron {
58
+ width: 0.875rem;
59
+ height: 0.875rem;
60
+ flex: none;
61
+ color: var(--booking-stone-500, #78716c);
62
+ transition: transform 150ms ease;
63
+ }
64
+
65
+ .card[open] .chevron {
66
+ transform: rotate(180deg);
67
+ }
68
+
69
+ .content {
70
+ border-top: 1px solid var(--booking-stone-100, #f5f5f4);
71
+ padding: 0.75rem 0.875rem;
72
+ }
@@ -0,0 +1,38 @@
1
+ import type { ReactNode } from 'react';
2
+ import styles from './CollapsibleAddOnItem.module.css';
3
+
4
+ export interface CollapsibleAddOnItemProps {
5
+ title: string;
6
+ selectedQuantity?: number;
7
+ children: ReactNode;
8
+ }
9
+
10
+ export function CollapsibleAddOnItem({
11
+ title,
12
+ selectedQuantity = 0,
13
+ children,
14
+ }: CollapsibleAddOnItemProps) {
15
+ return (
16
+ <details className={styles.card}>
17
+ <summary className={styles.summary}>
18
+ <span className={styles.title}>{title}</span>
19
+ {selectedQuantity > 0 ? (
20
+ <span className={`${styles.selection} ${styles.selectionActive}`}>
21
+ {selectedQuantity} selected
22
+ </span>
23
+ ) : null}
24
+ <svg
25
+ className={styles.chevron}
26
+ viewBox="0 0 20 20"
27
+ fill="none"
28
+ stroke="currentColor"
29
+ strokeWidth="2"
30
+ aria-hidden="true"
31
+ >
32
+ <path d="m5 7.5 5 5 5-5" strokeLinecap="round" strokeLinejoin="round" />
33
+ </svg>
34
+ </summary>
35
+ <div className={styles.content}>{children}</div>
36
+ </details>
37
+ );
38
+ }