@ticketboothapp/booking 1.2.24 → 1.2.25

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 (158) hide show
  1. package/package.json +29 -2
  2. package/src/assets/icons/minus.svg +7 -0
  3. package/src/assets/icons/partner-logos/getyourguide.svg +8 -0
  4. package/src/assets/icons/plus.svg +3 -0
  5. package/src/colours.css +23 -0
  6. package/src/components/BookingDetails.module.css +1591 -0
  7. package/src/components/BookingDetails.tsx +2264 -0
  8. package/src/components/BookingWidget.tsx +305 -0
  9. package/src/components/ManageBookingView.tsx +437 -0
  10. package/src/components/PhoneInputWithCountry.module.css +131 -0
  11. package/src/components/PhoneInputWithCountry.tsx +44 -0
  12. package/src/components/PickupLocationDialog.module.css +360 -0
  13. package/src/components/PickupLocationDialog.tsx +357 -0
  14. package/src/components/PostBookingDependentAddOnUpsell.module.css +174 -0
  15. package/src/components/PostBookingDependentAddOnUpsell.tsx +407 -0
  16. package/src/components/booking/AddOnsSection.module.css +10 -0
  17. package/src/components/booking/AddOnsSection.tsx +184 -0
  18. package/src/components/booking/AdminPaymentChoiceModal.tsx +98 -0
  19. package/src/components/booking/BookingDialog.module.css +643 -0
  20. package/src/components/booking/BookingDialog.tsx +356 -0
  21. package/src/components/booking/BookingFlow.tsx +4385 -0
  22. package/src/components/booking/BookingFlowCollage.module.css +148 -0
  23. package/src/components/booking/BookingFlowCollage.tsx +184 -0
  24. package/src/components/booking/BookingFlowPlaceholder.module.css +27 -0
  25. package/src/components/booking/BookingFlowPlaceholder.tsx +25 -0
  26. package/src/components/booking/BookingFlowPreview.tsx +51 -0
  27. package/src/components/booking/BookingProductGrid.module.css +359 -0
  28. package/src/components/booking/BookingProductGrid.tsx +497 -0
  29. package/src/components/booking/Calendar.module.css +616 -0
  30. package/src/components/booking/Calendar.tsx +1123 -0
  31. package/src/components/booking/CancellationPolicySelector.module.css +124 -0
  32. package/src/components/booking/CancellationPolicySelector.tsx +142 -0
  33. package/src/components/booking/ChangeBookingDialog.tsx +562 -0
  34. package/src/components/booking/CheckoutForm.module.css +244 -0
  35. package/src/components/booking/CheckoutForm.tsx +364 -0
  36. package/src/components/booking/CheckoutModal.tsx +451 -0
  37. package/src/components/booking/CurrencySwitcher.tsx +81 -0
  38. package/src/components/booking/DapFlowCollage.tsx +88 -0
  39. package/src/components/booking/DapTourDescription.tsx +35 -0
  40. package/src/components/booking/DependentAddOnBookingDialog.tsx +1350 -0
  41. package/src/components/booking/DependentAddOnPaymentForm.tsx +124 -0
  42. package/src/components/booking/ErrorBoundary.tsx +63 -0
  43. package/src/components/booking/InfoTooltip.tsx +108 -0
  44. package/src/components/booking/ItineraryBox.module.css +258 -0
  45. package/src/components/booking/ItineraryBox.tsx +550 -0
  46. package/src/components/booking/ItineraryBuilder.tsx +82 -0
  47. package/src/components/booking/ItineraryPlaceholder.module.css +45 -0
  48. package/src/components/booking/ItineraryPlaceholder.tsx +26 -0
  49. package/src/components/booking/MealDrinkAddOnSelector.tsx +338 -0
  50. package/src/components/booking/PickupLocationSelector.module.css +124 -0
  51. package/src/components/booking/PickupLocationSelector.tsx +1566 -0
  52. package/src/components/booking/PickupTimeSelector.module.css +134 -0
  53. package/src/components/booking/PickupTimeSelector.tsx +112 -0
  54. package/src/components/booking/PriceBreakdown.tsx +154 -0
  55. package/src/components/booking/PriceSummary.tsx +234 -0
  56. package/src/components/booking/PrivateShuttleBookingFlow.module.css +357 -0
  57. package/src/components/booking/PrivateShuttleBookingFlow.tsx +2662 -0
  58. package/src/components/booking/PromoCodeInput.module.css +166 -0
  59. package/src/components/booking/PromoCodeInput.tsx +99 -0
  60. package/src/components/booking/ReturnTimeSelector.module.css +173 -0
  61. package/src/components/booking/ReturnTimeSelector.tsx +145 -0
  62. package/src/components/booking/TermsAcceptance.tsx +111 -0
  63. package/src/components/booking/TicketSelector.module.css +164 -0
  64. package/src/components/booking/TicketSelector.tsx +199 -0
  65. package/src/components/booking/TourDescription.module.css +304 -0
  66. package/src/components/booking/TourDescription.tsx +273 -0
  67. package/src/components/booking/booking-flow-ui.ts +38 -0
  68. package/src/components/booking/booking-flow.css +944 -0
  69. package/src/components/button.css +245 -0
  70. package/src/components/button.tsx +152 -0
  71. package/src/components/colorable-svg.tsx +29 -0
  72. package/src/components/image.css +29 -0
  73. package/src/components/image.tsx +113 -0
  74. package/src/components/partner/PartnerBookingPage.module.css +130 -0
  75. package/src/components/partner/PartnerBookingPage.tsx +390 -0
  76. package/src/components/partner/PartnerBookingPageWithBrowserMetadata.tsx +45 -0
  77. package/src/components/product-tag.module.css +30 -0
  78. package/src/components/product-tag.tsx +34 -0
  79. package/src/components/product-theme-pages/image-modal.tsx +248 -0
  80. package/src/components/product-theme-pages/photo-gallery.module.css +200 -0
  81. package/src/components/terms/TermsContent.tsx +178 -0
  82. package/src/components/value-pill.module.css +59 -0
  83. package/src/components/value-pill.tsx +46 -0
  84. package/src/constants/images.ts +556 -0
  85. package/src/constants/pill-values.ts +210 -0
  86. package/src/constants/products.ts +155 -0
  87. package/src/contexts/AvailabilitiesCacheContext.tsx +125 -0
  88. package/src/contexts/BookingAppContext.tsx +134 -0
  89. package/src/contexts/CompanyContext.tsx +70 -0
  90. package/src/data/dap-descriptions/session-couples-families-friends.en.json +61 -0
  91. package/src/data/dap-descriptions/session-elopements.en.json +60 -0
  92. package/src/data/dap-descriptions/session-proposals.en.json +60 -0
  93. package/src/data/product-descriptions/afternoon-delight.en.json +35 -0
  94. package/src/data/product-descriptions/emerald-lake-escape.en.json +68 -0
  95. package/src/data/product-descriptions/lake-louise-adventure.en.json +74 -0
  96. package/src/data/product-descriptions/moraine-lake-adventure.en.json +78 -0
  97. package/src/data/product-descriptions/moraine-lake-sunrise-lake-louise-golden-hour.en.json +65 -0
  98. package/src/data/product-descriptions/moraine-lake-sunrise.en.json +64 -0
  99. package/src/data/product-descriptions/private-tour.en.json +80 -0
  100. package/src/data/product-descriptions/two-lakes-combo.en.json +65 -0
  101. package/src/data/products-config.json +101 -0
  102. package/src/hooks/useBookingSourceMetadataFromLocation.ts +21 -0
  103. package/src/hooks/useIsBookingLaunchLive.ts +49 -0
  104. package/src/index.ts +79 -0
  105. package/src/lib/analytics.ts +197 -0
  106. package/src/lib/booking/booking-source.ts +51 -0
  107. package/src/lib/booking/checkout-breakdown.ts +69 -0
  108. package/src/lib/booking/correlation-id.ts +46 -0
  109. package/src/lib/booking/i18n/config.ts +21 -0
  110. package/src/lib/booking/i18n/index.tsx +144 -0
  111. package/src/lib/booking/i18n/messages/en.json +236 -0
  112. package/src/lib/booking/i18n/messages/fr.json +236 -0
  113. package/src/lib/booking/itinerary-display.ts +36 -0
  114. package/src/lib/booking/itinerary-labels.ts +70 -0
  115. package/src/lib/booking/location-calculations.ts +43 -0
  116. package/src/lib/booking/location-utils.ts +165 -0
  117. package/src/lib/booking/map-utils.ts +153 -0
  118. package/src/lib/booking/marker-icons.ts +113 -0
  119. package/src/lib/booking/normalize-booking-product-id.ts +21 -0
  120. package/src/lib/booking/pickup-location-types.ts +25 -0
  121. package/src/lib/booking/places-api.ts +154 -0
  122. package/src/lib/booking/pricing.ts +466 -0
  123. package/src/lib/booking/product-option-id.ts +35 -0
  124. package/src/lib/booking/source-metadata.ts +226 -0
  125. package/src/lib/booking/sunday-week.ts +14 -0
  126. package/src/lib/booking/theme.ts +83 -0
  127. package/src/lib/booking/trace-context.ts +62 -0
  128. package/src/lib/booking/utils.ts +9 -0
  129. package/src/lib/booking-api.ts +1793 -0
  130. package/src/lib/booking-constants.ts +23 -0
  131. package/src/lib/booking-ref.ts +13 -0
  132. package/src/lib/booking-types.ts +36 -0
  133. package/src/lib/currency.ts +81 -0
  134. package/src/lib/dap-descriptions.ts +50 -0
  135. package/src/lib/dap-itinerary-preview.ts +315 -0
  136. package/src/lib/dependent-add-on-api.ts +434 -0
  137. package/src/lib/env.ts +96 -0
  138. package/src/lib/firebase.ts +20 -0
  139. package/src/lib/job-application-api.ts +83 -0
  140. package/src/lib/manage-booking-embed-print.ts +16 -0
  141. package/src/lib/manage-booking-post-checkout.ts +68 -0
  142. package/src/lib/photo-dap-config.ts +228 -0
  143. package/src/lib/photo-packages.ts +75 -0
  144. package/src/lib/pickup/map-utils.ts +56 -0
  145. package/src/lib/pickup/marker-icons.ts +19 -0
  146. package/src/lib/product-descriptions.ts +66 -0
  147. package/src/lib/products-config.ts +73 -0
  148. package/src/providers/booking-dialog-provider.tsx +282 -0
  149. package/src/providers/dependent-add-on-dialog-provider.tsx +105 -0
  150. package/src/radius.css +5 -0
  151. package/src/spacing.css +7 -0
  152. package/src/strings/en.json +1774 -0
  153. package/src/strings/es.json +1573 -0
  154. package/src/strings/fr.json +1573 -0
  155. package/src/strings/index.js +23 -0
  156. package/src/text-style.css +56 -0
  157. package/src/utils/currency-converter.ts +101 -0
  158. package/tsconfig.json +8 -2
@@ -0,0 +1,26 @@
1
+ 'use client';
2
+
3
+ import styles from './ItineraryPlaceholder.module.css';
4
+
5
+ type TranslationFn = (key: string, params?: Record<string, string>) => string;
6
+
7
+ interface ItineraryPlaceholderProps {
8
+ formattedDate: string;
9
+ t: TranslationFn;
10
+ }
11
+
12
+ export function ItineraryPlaceholder({ formattedDate, t }: ItineraryPlaceholderProps) {
13
+ return (
14
+ <div className={styles.box}>
15
+ <h3 className={styles.title}>
16
+ {t('booking.buildYourItinerary')}
17
+ {formattedDate && (
18
+ <span className={styles.dateSubtitle}> · {formattedDate}</span>
19
+ )}
20
+ </h3>
21
+ <p className={styles.hint}>
22
+ {t('booking.selectPickupTimeToSeeItinerary') || 'Select a pickup time below to see your schedule.'}
23
+ </p>
24
+ </div>
25
+ );
26
+ }
@@ -0,0 +1,338 @@
1
+ 'use client';
2
+
3
+ import { useMemo, useCallback } from 'react';
4
+ import { formatCurrencyAmount } from '@/lib/currency';
5
+ import type { AddOn, AddOnVariant } from '@/lib/booking-api';
6
+ import type { Currency } from './CurrencySwitcher';
7
+ import type { Locale } from '@/lib/booking/i18n/config';
8
+
9
+ export interface AddOnSelection {
10
+ addOnId: string;
11
+ variantId?: string;
12
+ quantity?: number;
13
+ }
14
+
15
+ export interface MealDrinkAddOnSelectorProps {
16
+ addOn: AddOn;
17
+ selections: AddOnSelection[];
18
+ onSelectionsChange: (updater: (prev: AddOnSelection[]) => AddOnSelection[]) => void;
19
+ currency: Currency;
20
+ locale: Locale;
21
+ /** Optional label override for step 1 (default: "Meals") */
22
+ step1Label?: string;
23
+ /** Optional label override for step 2 (default: "Of your N lunch(es), how many with Water vs Gatorade?") */
24
+ step2Label?: (total: number) => string;
25
+ /** Optional label for the add-on section (default: "Order lunch?") */
26
+ sectionLabel?: string;
27
+ /** Minimum total quantity locked by original booking in change flow. */
28
+ minimumTotal?: number;
29
+ }
30
+
31
+ /**
32
+ * Check if an add-on can use the 2-step meal+drink selector UI.
33
+ */
34
+ export function canUseMealDrinkSelector(addOn: AddOn): boolean {
35
+ return parseTwoStepVariants(addOn.variants ?? []) !== null;
36
+ }
37
+
38
+ /**
39
+ * Parses multi_quantity add-on variants that follow a "{meal} + {drink}" pattern
40
+ * into step1 (meal types) and step2 (drink types) for a 2-step selection UI.
41
+ */
42
+ function parseTwoStepVariants(variants: AddOnVariant[]): {
43
+ step1Options: { id: string; label: string }[];
44
+ step2Options: { id: string; label: string }[];
45
+ getVariantId: (step1Id: string, step2Id: string) => string | null;
46
+ } | null {
47
+ if (!variants?.length) return null;
48
+
49
+ const step2Suffixes = ['_water', '_gatorade'];
50
+ const step1Options: { id: string; label: string }[] = [];
51
+ const step2Options: { id: string; label: string }[] = [];
52
+ const variantByKey = new Map<string, string>();
53
+
54
+ for (const v of variants) {
55
+ const labelParts = v.label.split(/\s+\+\s+/);
56
+ if (labelParts.length !== 2) return null;
57
+
58
+ const [step1Label, step2Label] = labelParts.map((s) => s.trim());
59
+ let step1Id = '';
60
+ let step2Id = '';
61
+
62
+ for (const suffix of step2Suffixes) {
63
+ if (v.id.endsWith(suffix)) {
64
+ step2Id = suffix.slice(1);
65
+ step1Id = v.id.slice(0, -suffix.length);
66
+ break;
67
+ }
68
+ }
69
+ if (!step1Id || !step2Id) return null;
70
+
71
+ if (!step1Options.some((o) => o.id === step1Id)) {
72
+ step1Options.push({ id: step1Id, label: step1Label });
73
+ }
74
+ if (!step2Options.some((o) => o.id === step2Id)) {
75
+ step2Options.push({ id: step2Id, label: step2Label });
76
+ }
77
+ variantByKey.set(`${step1Id}:${step2Id}`, v.id);
78
+ }
79
+
80
+ const getVariantId = (s1: string, s2: string) => variantByKey.get(`${s1}:${s2}`) ?? null;
81
+
82
+ if (step1Options.length < 2 || step2Options.length < 2) return null;
83
+ // Sort step2 so "water" comes first when reducing meals (cap water at total, rest to other)
84
+ const sortedStep2 = [...step2Options].sort((a, b) => (a.id === 'water' ? -1 : b.id === 'water' ? 1 : 0));
85
+ return { step1Options, step2Options: sortedStep2, getVariantId };
86
+ }
87
+
88
+ /**
89
+ * Derive step1 counts and step2 counts from addOnSelections for a given add-on.
90
+ */
91
+ function deriveCounts(
92
+ addOnId: string,
93
+ selections: AddOnSelection[],
94
+ getVariantId: (s1: string, s2: string) => string | null,
95
+ step1Ids: string[],
96
+ step2Ids: string[]
97
+ ): {
98
+ step1Counts: Record<string, number>;
99
+ step2Counts: Record<string, number>;
100
+ } {
101
+ const step1Counts: Record<string, number> = Object.fromEntries(step1Ids.map((id) => [id, 0]));
102
+ const step2Counts: Record<string, number> = Object.fromEntries(step2Ids.map((id) => [id, 0]));
103
+
104
+ for (const sel of selections) {
105
+ if (sel.addOnId !== addOnId || !sel.variantId) continue;
106
+ const qty = sel.quantity ?? 1;
107
+ for (const s1 of step1Ids) {
108
+ for (const s2 of step2Ids) {
109
+ if (getVariantId(s1, s2) === sel.variantId) {
110
+ step1Counts[s1] = (step1Counts[s1] ?? 0) + qty;
111
+ step2Counts[s2] = (step2Counts[s2] ?? 0) + qty;
112
+ break;
113
+ }
114
+ }
115
+ }
116
+ }
117
+ return { step1Counts, step2Counts };
118
+ }
119
+
120
+ /**
121
+ * Convert step1 and step2 counts back to addOnSelections (variant entries).
122
+ * Assumes step2 is a split of the total (water + gatorade = total).
123
+ */
124
+ function countsToSelections(
125
+ addOnId: string,
126
+ step1Counts: Record<string, number>,
127
+ step2Counts: Record<string, number>,
128
+ getVariantId: (s1: string, s2: string) => string | null,
129
+ step1Ids: string[],
130
+ step2Ids: string[]
131
+ ): AddOnSelection[] {
132
+ const total = Object.values(step1Counts).reduce((a, b) => a + b, 0);
133
+ const step2Total = Object.values(step2Counts).reduce((a, b) => a + b, 0);
134
+ if (total === 0 || total !== step2Total) return [];
135
+
136
+ const step1Slots: string[] = [];
137
+ for (const id of step1Ids) {
138
+ for (let i = 0; i < (step1Counts[id] ?? 0); i++) step1Slots.push(id);
139
+ }
140
+ const step2Slots: string[] = [];
141
+ for (const id of step2Ids) {
142
+ for (let i = 0; i < (step2Counts[id] ?? 0); i++) step2Slots.push(id);
143
+ }
144
+
145
+ const variantCounts: Record<string, number> = {};
146
+ for (let i = 0; i < step1Slots.length; i++) {
147
+ const vid = getVariantId(step1Slots[i], step2Slots[i]);
148
+ if (vid) variantCounts[vid] = (variantCounts[vid] ?? 0) + 1;
149
+ }
150
+
151
+ return Object.entries(variantCounts)
152
+ .filter(([, qty]) => qty > 0)
153
+ .map(([variantId, quantity]) => ({ addOnId, variantId, quantity }));
154
+ }
155
+
156
+ export function MealDrinkAddOnSelector({
157
+ addOn,
158
+ selections,
159
+ onSelectionsChange,
160
+ currency,
161
+ locale,
162
+ step2Label = (total) =>
163
+ `Of your ${total} lunch${total !== 1 ? 'es' : ''}, how many with Water vs Gatorade?`,
164
+ sectionLabel = '🍽️ Order lunch?',
165
+ minimumTotal = 0,
166
+ }: MealDrinkAddOnSelectorProps) {
167
+ const parsed = useMemo(
168
+ () => (addOn.variantType === 'multi_quantity' && addOn.variants ? parseTwoStepVariants(addOn.variants) : null),
169
+ [addOn.variantType, addOn.variants]
170
+ );
171
+
172
+ const { step1Counts, step2Counts } = useMemo(() => {
173
+ if (!parsed) return { step1Counts: {} as Record<string, number>, step2Counts: {} as Record<string, number> };
174
+ return deriveCounts(
175
+ addOn.addOnId,
176
+ selections,
177
+ parsed.getVariantId,
178
+ parsed.step1Options.map((o) => o.id),
179
+ parsed.step2Options.map((o) => o.id)
180
+ );
181
+ }, [addOn.addOnId, selections, parsed]);
182
+
183
+ const updateSelections = useCallback(
184
+ (step1CountsNew: Record<string, number>, step2CountsNew: Record<string, number>) => {
185
+ if (!parsed) return;
186
+ const step1Ids = parsed.step1Options.map((o) => o.id);
187
+ const step2Ids = parsed.step2Options.map((o) => o.id);
188
+ const newEntries = countsToSelections(
189
+ addOn.addOnId,
190
+ step1CountsNew,
191
+ step2CountsNew,
192
+ parsed.getVariantId,
193
+ step1Ids,
194
+ step2Ids
195
+ );
196
+ onSelectionsChange((prev) => [...prev.filter((s) => s.addOnId !== addOn.addOnId), ...newEntries]);
197
+ },
198
+ [addOn.addOnId, parsed, onSelectionsChange]
199
+ );
200
+
201
+ if (!parsed) return null;
202
+
203
+ const price = addOn.price ?? 0;
204
+ const totalStep1 = Object.values(step1Counts).reduce((a, b) => a + b, 0);
205
+ const totalStep2 = Object.values(step2Counts).reduce((a, b) => a + b, 0);
206
+ const step1Ids = parsed.step1Options.map((o) => o.id);
207
+ const step2Ids = parsed.step2Options.map((o) => o.id);
208
+
209
+ return (
210
+ <div>
211
+ <div className="mb-2">
212
+ <label className="block text-sm font-medium text-stone-700">{sectionLabel}</label>
213
+ {addOn.description && (
214
+ <p className="text-sm text-stone-500 mt-0.5">{addOn.description} - lunch packages include sandwich, a drink, and a banana bread snack.</p>
215
+ )}
216
+ </div>
217
+ <div className="space-y-4">
218
+ {/* Step 1: Meal type quantities (no "Meals" subheading) */}
219
+ <div>
220
+ <div className="space-y-2">
221
+ {parsed.step1Options.map((opt) => {
222
+ const qty = step1Counts[opt.id] ?? 0;
223
+ const canDecrementTotal = totalStep1 > minimumTotal;
224
+ return (
225
+ <div key={opt.id} className="flex items-center gap-3">
226
+ <span className="font-medium text-stone-800">{opt.label}</span>
227
+ <span className="text-sm text-stone-500 shrink-0">
228
+ {formatCurrencyAmount(price, currency, locale)} ea
229
+ </span>
230
+ <div className="flex items-center gap-1 shrink-0">
231
+ <button
232
+ type="button"
233
+ onClick={() => {
234
+ if (!canDecrementTotal) return;
235
+ const next = { ...step1Counts, [opt.id]: Math.max(0, qty - 1) };
236
+ const total = Object.values(next).reduce((a, b) => a + b, 0);
237
+ const drinks =
238
+ total > 0
239
+ ? {
240
+ [step2Ids[0]]: Math.min(step2Counts[step2Ids[0]] ?? 0, total),
241
+ [step2Ids[1]]: total - Math.min(step2Counts[step2Ids[0]] ?? 0, total),
242
+ }
243
+ : step2Ids.reduce((acc, id) => ({ ...acc, [id]: 0 }), {} as Record<string, number>);
244
+ updateSelections(next, drinks);
245
+ }}
246
+ disabled={qty <= 0 || !canDecrementTotal}
247
+ 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"
248
+ >
249
+
250
+ </button>
251
+ <span className="w-6 text-center font-medium tabular-nums text-sm">{qty}</span>
252
+ <button
253
+ type="button"
254
+ onClick={() => {
255
+ const next = { ...step1Counts, [opt.id]: qty + 1 };
256
+ const drinks = {
257
+ [step2Ids[0]]: (step2Counts[step2Ids[0]] ?? 0) + 1,
258
+ [step2Ids[1]]: step2Counts[step2Ids[1]] ?? 0,
259
+ };
260
+ updateSelections(next, drinks);
261
+ }}
262
+ className="h-8 w-8 rounded-full border border-stone-300 bg-white text-stone-600 hover:bg-stone-50 text-sm"
263
+ >
264
+ +
265
+ </button>
266
+ </div>
267
+ </div>
268
+ );
269
+ })}
270
+ </div>
271
+ </div>
272
+
273
+ {/* Step 2: Drink split (Water vs Gatorade) — zero-sum between options */}
274
+ {totalStep1 > 0 && (
275
+ <div className="pt-3 border-t border-stone-200">
276
+ <p className="text-xs font-medium text-stone-600 mb-2">{step2Label(totalStep1)}</p>
277
+ <div className="flex flex-wrap items-center gap-4">
278
+ {parsed.step2Options.map((opt) => {
279
+ const qty = step2Counts[opt.id] ?? 0;
280
+ const otherId = step2Ids.find((id) => id !== opt.id)!;
281
+ const otherQty = step2Counts[otherId] ?? 0;
282
+ return (
283
+ <div key={opt.id} className="flex items-center gap-2">
284
+ <span className="text-sm text-stone-700">{opt.label}</span>
285
+ <div className="flex items-center gap-1">
286
+ <button
287
+ type="button"
288
+ onClick={() => {
289
+ if (qty <= 0) return;
290
+ const next = {
291
+ [opt.id]: qty - 1,
292
+ [otherId]: otherQty + 1,
293
+ };
294
+ updateSelections(step1Counts, { ...step2Counts, ...next });
295
+ }}
296
+ disabled={qty <= 0}
297
+ 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"
298
+ >
299
+
300
+ </button>
301
+ <span className="w-6 text-center font-medium tabular-nums text-sm">{qty}</span>
302
+ <button
303
+ type="button"
304
+ onClick={() => {
305
+ if (otherQty <= 0) return;
306
+ const next = {
307
+ [opt.id]: qty + 1,
308
+ [otherId]: otherQty - 1,
309
+ };
310
+ updateSelections(step1Counts, { ...step2Counts, ...next });
311
+ }}
312
+ disabled={otherQty <= 0}
313
+ 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"
314
+ >
315
+ +
316
+ </button>
317
+ </div>
318
+ </div>
319
+ );
320
+ })}
321
+ </div>
322
+ {totalStep2 !== totalStep1 && (
323
+ <p className="text-xs text-amber-600 mt-2">
324
+ {parsed.step2Options.map((o) => o.label).join(' + ')} must equal {totalStep1}
325
+ </p>
326
+ )}
327
+ </div>
328
+ )}
329
+
330
+ {totalStep1 > 0 && totalStep2 === totalStep1 && (
331
+ <p className="text-sm font-medium text-stone-700">
332
+ Total: {formatCurrencyAmount(price * totalStep1, currency, locale)}
333
+ </p>
334
+ )}
335
+ </div>
336
+ </div>
337
+ );
338
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * PickupLocationSelector layout - 2-column on desktop, stacked on mobile.
3
+ * Uses CSS module to avoid Tailwind purging/override issues in booking-flow.
4
+ */
5
+
6
+ /* Filter pills - horizontal scroll on mobile, scrollbar hidden until scroll (overlay-style) */
7
+ .filterPillsScroll {
8
+ display: flex;
9
+ flex-wrap: nowrap;
10
+ overflow-x: auto;
11
+ gap: 0.5rem;
12
+ margin-bottom: 1rem;
13
+ -webkit-overflow-scrolling: touch;
14
+ scrollbar-width: none; /* Firefox - hide scrollbar */
15
+ -ms-overflow-style: none; /* IE/Edge - hide scrollbar */
16
+ }
17
+ .filterPillsScroll::-webkit-scrollbar {
18
+ display: none; /* Chrome, Safari - hide scrollbar */
19
+ }
20
+ .filterPillsScroll > div:first-child {
21
+ display: flex;
22
+ flex-wrap: nowrap;
23
+ flex-shrink: 0;
24
+ gap: 0.5rem;
25
+ }
26
+ .filterPillsScroll > div:first-child > button,
27
+ .filterPillsScroll > button {
28
+ flex-shrink: 0;
29
+ }
30
+
31
+ /* Skip modal buttons - override booking-flow-preflight button reset */
32
+ .skipModalNevermindBtn {
33
+ padding: 0.5rem 1.25rem !important;
34
+ font-weight: 500 !important;
35
+ font-size: 0.875rem !important;
36
+ color: #57534e !important;
37
+ background-color: #f5f5f4 !important;
38
+ border: 1px solid #e7e5e4 !important;
39
+ border-radius: 0.5rem;
40
+ transition: color 0.2s, background-color 0.2s;
41
+ }
42
+ .skipModalNevermindBtn:hover {
43
+ color: #1c1917 !important;
44
+ background-color: #e7e5e4 !important;
45
+ }
46
+
47
+ .skipModalUnderstandBtn {
48
+ padding: 0.5rem 1.5rem !important;
49
+ min-width: 6rem;
50
+ }
51
+
52
+ .twoColLayout {
53
+ display: flex;
54
+ flex-direction: column;
55
+ width: 100%;
56
+ gap: 1.5rem;
57
+ }
58
+
59
+ @media (min-width: 768px) {
60
+ .twoColLayout {
61
+ flex-direction: row;
62
+ align-items: flex-start;
63
+ }
64
+ }
65
+
66
+ .leftColumn {
67
+ flex: 1;
68
+ min-width: 0;
69
+ }
70
+
71
+ /* Scrollable list - same height as map */
72
+ .locationList {
73
+ display: flex;
74
+ flex-direction: column;
75
+ gap: 0.5rem;
76
+ overflow-y: auto;
77
+ height: 14rem; /* matches mapWrapper mobile */
78
+ }
79
+
80
+ @media (min-width: 768px) {
81
+ .locationList {
82
+ height: 18rem; /* matches mapWrapper desktop */
83
+ }
84
+ }
85
+
86
+ .rightColumn {
87
+ width: 100%;
88
+ }
89
+
90
+ @media (min-width: 768px) {
91
+ .rightColumn {
92
+ width: 20rem; /* 320px - md:w-80 */
93
+ flex-shrink: 0;
94
+ position: sticky;
95
+ top: 1rem;
96
+ }
97
+ }
98
+
99
+ @media (min-width: 1024px) {
100
+ .rightColumn {
101
+ width: 24rem; /* 384px - lg:w-96 */
102
+ }
103
+ }
104
+
105
+ /* Constrain map height - Google Maps adds inline styles that can override */
106
+ .mapWrapper {
107
+ width: 100%;
108
+ height: 14rem; /* 224px - h-56 */
109
+ border-radius: 0.5rem;
110
+ border: 1px solid #d6d3d1;
111
+ overflow: hidden;
112
+ }
113
+
114
+ @media (min-width: 768px) {
115
+ .mapWrapper {
116
+ height: 18rem; /* 288px - h-72 */
117
+ }
118
+ }
119
+
120
+ /* Map container - fixed height to prevent Google Maps from expanding */
121
+ .mapWrapper > div {
122
+ height: 100% !important;
123
+ min-height: 0 !important;
124
+ }