@ticketboothapp/booking 1.2.177 → 1.2.179
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/booking/AddOnsSection.module.css +36 -5
- package/src/components/booking/AddOnsSection.tsx +250 -144
- package/src/components/booking/AdminChangeBookingFlow.tsx +3 -2
- package/src/components/booking/CollapsibleAddOnItem.module.css +72 -0
- package/src/components/booking/CollapsibleAddOnItem.tsx +38 -0
- package/src/components/booking/MealDrinkAddOnSelector.tsx +70 -29
- package/src/components/booking/PrivateShuttleAddOnsSection.tsx +124 -57
- package/src/components/booking/PrivateShuttleBookingFlow.module.css +26 -0
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +8 -5
- package/src/components/booking/PrivateShuttlePreferencesSection.tsx +29 -55
- package/src/components/booking/add-on-selection-helpers.ts +13 -0
- package/src/components/booking/change-booking-checkout-builders.ts +1 -1
- package/src/components/booking/private-shuttle-checkout-builders.ts +2 -2
- package/src/components/booking/private-shuttle-lunch-visibility.ts +20 -0
- package/src/components/booking/standard-booking-checkout-builders.ts +1 -1
- package/src/components/booking/useAdminChangeProtectedPricing.ts +1 -1
- package/src/components/booking/useBookingAvailabilityAddOns.ts +26 -4
- package/src/components/booking/useChangeBookingProtectedPricing.ts +1 -1
- package/src/components/booking/usePrivateShuttlePriceSummary.ts +13 -6
- package/src/components/booking/useStandardBookingPriceSummary.ts +1 -1
- package/src/lib/booking-api.ts +4 -1
- package/test/add-on-selection-helpers.test.ts +24 -0
- package/test/change-booking-helpers.test.ts +42 -1
- package/test/private-shuttle-lunch-visibility.test.ts +27 -0
package/package.json
CHANGED
|
@@ -1,10 +1,41 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
9
|
-
color: var(--booking-stone-
|
|
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 = {
|
|
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,252 @@ 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=
|
|
32
|
-
<label className={styles.label}>
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
<
|
|
70
|
+
<CollapsibleAddOnItem
|
|
75
71
|
key={addOn.addOnId}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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">
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
onClick={() => {
|
|
89
|
+
onSelectionsChange((prev) => {
|
|
90
|
+
if (!canDecrement) return prev;
|
|
91
|
+
const next = Math.max(0, quantity - 1);
|
|
92
|
+
const rest = prev.filter((s) => s.addOnId !== addOn.addOnId);
|
|
93
|
+
return next > 0 ? [...rest, { addOnId: addOn.addOnId, quantity: next }] : rest;
|
|
94
|
+
});
|
|
95
|
+
}}
|
|
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"
|
|
98
|
+
>
|
|
99
|
+
−
|
|
100
|
+
</button>
|
|
101
|
+
<span className="w-6 text-center text-sm font-medium tabular-nums">{quantity}</span>
|
|
158
102
|
<button
|
|
159
|
-
key={variant.id}
|
|
160
103
|
type="button"
|
|
161
104
|
onClick={() => {
|
|
162
105
|
onSelectionsChange((prev) => {
|
|
163
106
|
const rest = prev.filter((s) => s.addOnId !== addOn.addOnId);
|
|
164
|
-
|
|
165
|
-
return [...rest, { addOnId: addOn.addOnId, variantId: variant.id, quantity: 1 }];
|
|
107
|
+
return [...rest, { addOnId: addOn.addOnId, quantity: quantity + 1 }];
|
|
166
108
|
});
|
|
167
109
|
}}
|
|
168
|
-
className=
|
|
169
|
-
isSelected ? 'border-emerald-500 bg-emerald-50' : 'border-stone-200 bg-white hover:border-stone-300'
|
|
170
|
-
}`}
|
|
110
|
+
className="h-8 w-8 rounded-full border border-stone-300 bg-white text-sm text-stone-600 hover:bg-stone-50"
|
|
171
111
|
>
|
|
172
|
-
|
|
173
|
-
<span className="text-sm font-semibold text-stone-700">
|
|
174
|
-
{suppressPrices ? '—' : `+${formatCurrencyAmount(price, currency, locale as 'en' | 'fr')}`}
|
|
175
|
-
</span>
|
|
112
|
+
+
|
|
176
113
|
</button>
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
</
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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="min-w-0 flex-1">
|
|
164
|
+
<span className="block text-sm text-stone-800">{variant.label}</span>
|
|
165
|
+
{variant.description?.trim() ? (
|
|
166
|
+
<span className="mt-0.5 block text-xs leading-snug text-stone-500">
|
|
167
|
+
{variant.description.trim()}
|
|
168
|
+
</span>
|
|
169
|
+
) : null}
|
|
170
|
+
</span>
|
|
171
|
+
<span className="text-sm text-stone-500 shrink-0">
|
|
172
|
+
{suppressPrices ? '—' : `${formatCurrencyAmount(price, currency, locale as 'en' | 'fr')} ea`}
|
|
173
|
+
</span>
|
|
174
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
onClick={() => {
|
|
178
|
+
onSelectionsChange((prev) => {
|
|
179
|
+
const next = Math.max(0, qty - 1);
|
|
180
|
+
const rest = prev.filter(
|
|
181
|
+
(s) => !(s.addOnId === addOn.addOnId && s.variantId === variant.id),
|
|
182
|
+
);
|
|
183
|
+
if (next > 0)
|
|
184
|
+
return [
|
|
185
|
+
...rest,
|
|
186
|
+
{
|
|
187
|
+
addOnId: addOn.addOnId,
|
|
188
|
+
variantId: variant.id,
|
|
189
|
+
quantity: next,
|
|
190
|
+
},
|
|
191
|
+
];
|
|
192
|
+
return rest;
|
|
193
|
+
});
|
|
194
|
+
}}
|
|
195
|
+
disabled={!canDecrement}
|
|
196
|
+
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"
|
|
197
|
+
>
|
|
198
|
+
−
|
|
199
|
+
</button>
|
|
200
|
+
<span className="w-6 text-center font-medium tabular-nums text-sm">{qty}</span>
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
onClick={() => {
|
|
204
|
+
onSelectionsChange((prev) => {
|
|
205
|
+
const rest = prev.filter(
|
|
206
|
+
(s) => !(s.addOnId === addOn.addOnId && s.variantId === variant.id),
|
|
207
|
+
);
|
|
208
|
+
return [
|
|
209
|
+
...rest,
|
|
210
|
+
{
|
|
211
|
+
addOnId: addOn.addOnId,
|
|
212
|
+
variantId: variant.id,
|
|
213
|
+
quantity: qty + 1,
|
|
214
|
+
},
|
|
215
|
+
];
|
|
216
|
+
});
|
|
217
|
+
}}
|
|
218
|
+
className="h-8 w-8 rounded-full border border-stone-300 bg-white text-stone-600 hover:bg-stone-50 text-sm"
|
|
219
|
+
>
|
|
220
|
+
+
|
|
221
|
+
</button>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
})}
|
|
226
|
+
</div>
|
|
227
|
+
</CollapsibleAddOnItem>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
if (addOn.variantType === 'single_choice' && addOn.variants?.length) {
|
|
231
|
+
const sel = addOnSelections.find((s) => s.addOnId === addOn.addOnId);
|
|
232
|
+
const hasLockedInitialSelection = minTotalForAddOn > 0;
|
|
233
|
+
return (
|
|
234
|
+
<CollapsibleAddOnItem
|
|
235
|
+
key={addOn.addOnId}
|
|
236
|
+
title={title}
|
|
237
|
+
selectedQuantity={selectedQuantity}
|
|
238
|
+
>
|
|
239
|
+
<div className={headerClassName}>
|
|
240
|
+
{addOn.description && <p className={descriptionClassName}>{addOn.description}</p>}
|
|
241
|
+
</div>
|
|
242
|
+
<div className="space-y-2">
|
|
243
|
+
{addOn.variants.map((variant) => {
|
|
244
|
+
const isSelected = sel?.variantId === variant.id;
|
|
245
|
+
const price = (addOn.price ?? 0) + (variant.priceAdjustment ?? 0);
|
|
246
|
+
return (
|
|
247
|
+
<button
|
|
248
|
+
key={variant.id}
|
|
249
|
+
type="button"
|
|
250
|
+
onClick={() => {
|
|
251
|
+
onSelectionsChange((prev) => {
|
|
252
|
+
const rest = prev.filter((s) => s.addOnId !== addOn.addOnId);
|
|
253
|
+
if (isSelected) return hasLockedInitialSelection ? prev : rest;
|
|
254
|
+
return [
|
|
255
|
+
...rest,
|
|
256
|
+
{
|
|
257
|
+
addOnId: addOn.addOnId,
|
|
258
|
+
variantId: variant.id,
|
|
259
|
+
quantity: 1,
|
|
260
|
+
},
|
|
261
|
+
];
|
|
262
|
+
});
|
|
263
|
+
}}
|
|
264
|
+
className={`flex items-center justify-between w-full p-3 rounded-lg border-2 text-left transition-colors ${
|
|
265
|
+
isSelected
|
|
266
|
+
? 'border-emerald-500 bg-emerald-50'
|
|
267
|
+
: 'border-stone-200 bg-stone-50 hover:border-stone-300'
|
|
268
|
+
}`}
|
|
269
|
+
>
|
|
270
|
+
<span className="min-w-0 flex-1">
|
|
271
|
+
<span className="block text-sm font-medium text-stone-800">{variant.label}</span>
|
|
272
|
+
{variant.description?.trim() ? (
|
|
273
|
+
<span className="mt-0.5 block text-xs font-normal leading-snug text-stone-500">
|
|
274
|
+
{variant.description.trim()}
|
|
275
|
+
</span>
|
|
276
|
+
) : null}
|
|
277
|
+
</span>
|
|
278
|
+
<span className="text-sm text-stone-500">
|
|
279
|
+
{suppressPrices ? '—' : `+${formatCurrencyAmount(price, currency, locale as 'en' | 'fr')}`}
|
|
280
|
+
</span>
|
|
281
|
+
</button>
|
|
282
|
+
);
|
|
283
|
+
})}
|
|
284
|
+
</div>
|
|
285
|
+
</CollapsibleAddOnItem>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
})}
|
|
290
|
+
</div>
|
|
185
291
|
</div>
|
|
186
292
|
);
|
|
187
293
|
}
|
|
@@ -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,
|
|
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
|
+
}
|