@ticketboothapp/booking 0.1.3 → 0.1.7

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 (45) hide show
  1. package/package.json +21 -1
  2. package/src/components/BookingDetails.tsx +546 -0
  3. package/src/components/BookingFlow.tsx +2952 -0
  4. package/src/components/BookingWidget.tsx +349 -0
  5. package/src/components/Calendar.tsx +906 -0
  6. package/src/components/CheckoutModal.tsx +294 -0
  7. package/src/components/CurrencySwitcher.tsx +81 -0
  8. package/src/components/ErrorBoundary.tsx +63 -0
  9. package/src/components/ItineraryBuilder.tsx +83 -0
  10. package/src/components/LanguageSwitcher.tsx +30 -0
  11. package/src/components/ManageBookingView.tsx +409 -0
  12. package/src/components/MealDrinkAddOnSelector.tsx +330 -0
  13. package/src/components/PickupLocationSelector.tsx +1541 -0
  14. package/src/components/PriceBreakdown.tsx +154 -0
  15. package/src/components/PriceSummary.tsx +211 -0
  16. package/src/components/PrivateShuttleBookingFlow.tsx +2290 -0
  17. package/src/components/ProductList.tsx +78 -0
  18. package/src/components/TermsAcceptance.tsx +110 -0
  19. package/src/components/WhatsAppPhoneInput.tsx +224 -0
  20. package/src/components/index.ts +31 -0
  21. package/src/contexts/CompanyContext.tsx +8 -20
  22. package/src/index.ts +16 -0
  23. package/src/lib/api.ts +801 -0
  24. package/src/lib/booking-ref.ts +13 -0
  25. package/src/lib/checkout-breakdown.test.ts +70 -0
  26. package/src/lib/checkout-breakdown.ts +69 -0
  27. package/src/lib/constants.ts +17 -0
  28. package/src/lib/currency.ts +88 -0
  29. package/src/lib/env.ts +10 -12
  30. package/src/lib/i18n/config.ts +21 -0
  31. package/src/lib/i18n/index.tsx +144 -0
  32. package/src/lib/i18n/messages/en.json +192 -0
  33. package/src/lib/i18n/messages/fr.json +192 -0
  34. package/src/lib/itinerary-labels.ts +70 -0
  35. package/src/lib/location-calculations.ts +43 -0
  36. package/src/lib/location-utils.ts +139 -0
  37. package/src/lib/map-utils.ts +153 -0
  38. package/src/lib/marker-icons.ts +113 -0
  39. package/src/lib/pickup-location-types.ts +25 -0
  40. package/src/lib/places-api.ts +154 -0
  41. package/src/lib/pricing.ts +466 -0
  42. package/src/lib/theme.ts +83 -0
  43. package/src/lib/utils.ts +9 -0
  44. package/src/types/google-maps.d.ts +2 -0
  45. package/tsconfig.json +8 -2
@@ -0,0 +1,1541 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
4
+ import { GoogleMap, Marker, InfoWindow, useJsApiLoader } from '@react-google-maps/api';
5
+ import type { PickupLocation, Destination } from '@/lib/api';
6
+ import {
7
+ formatDistance,
8
+ formatTime,
9
+ geocodeAddress,
10
+ } from '@/lib/location-utils';
11
+ import {
12
+ calculateNearbyLocations,
13
+ isExactMatch,
14
+ } from '@/lib/location-calculations';
15
+ import {
16
+ getAutocompleteSuggestions,
17
+ getPlaceDetails,
18
+ type AutocompleteSuggestion,
19
+ } from '@/lib/places-api';
20
+ import {
21
+ createDistanceMarkerIcon,
22
+ createPinMarkerIcon,
23
+ createSearchedLocationPinIcon,
24
+ createDestinationMarkerIcon,
25
+ } from '@/lib/marker-icons';
26
+ import { ENV } from '@/lib/env';
27
+ import {
28
+ calculateMapCenter,
29
+ calculateMapBounds,
30
+ getMapOptions,
31
+ panToLocationIfNeeded,
32
+ } from '@/lib/map-utils';
33
+ import type { SearchedLocation, NearbyLocation } from '@/lib/pickup-location-types';
34
+ import { useTranslations } from '@/lib/i18n';
35
+ import { useBookingApp } from '@/contexts/BookingAppContext';
36
+
37
+ export interface CustomPickupLocation {
38
+ address: string;
39
+ coordinates?: { lat: number; lng: number };
40
+ }
41
+
42
+ interface PickupLocationSelectorProps {
43
+ pickupLocations: PickupLocation[];
44
+ selectedLocationId: string | null;
45
+ selectedCustomAddress?: string | null;
46
+ /** When true (default), show "Use this address" for custom locations. Only Private Shuttle uses this. */
47
+ allowCustomLocation?: boolean;
48
+ onLocationSelect: (locationId: string | null, customLocation?: CustomPickupLocation) => void;
49
+ onSkip?: () => void;
50
+ isSkipped?: boolean;
51
+ destinations?: Destination[]; // Destination locations to show on the map
52
+ }
53
+
54
+ // Constants
55
+ const libraries: ('places')[] = ['places'];
56
+ const LOCATION_BIAS = {
57
+ latitude: 51.1784,
58
+ longitude: -115.5708,
59
+ radius: 50000.0, // 50km radius around Banff
60
+ };
61
+ const MARKER_COLORS = {
62
+ default: '#dc2626', // Dark red/orange
63
+ hover: '#1e3a8a', // Navy
64
+ destination: '#facc15', // Bright yellow for destinations
65
+ } as const;
66
+ const SUGGESTION_HIDE_DELAY = 200; // ms
67
+ const DEFAULT_MAP_ZOOM = 10;
68
+ const SEARCHED_LOCATION_ZOOM = 14;
69
+ const MAX_ZOOM = 15;
70
+
71
+ // Filter definitions
72
+ const FILTERS = [
73
+ { id: 'canmore', label: 'Canmore' },
74
+ { id: 'banff', label: 'Banff' },
75
+ { id: 'lake-louise', label: 'Lake Louise' },
76
+ { id: 'free-parking', label: 'Free parking' },
77
+ ] as const;
78
+
79
+ type FilterId = typeof FILTERS[number]['id'];
80
+
81
+ // Helper function to determine which city a location belongs to
82
+ function getLocationCity(location: PickupLocation): FilterId | null {
83
+ const addressLower = location.address.toLowerCase();
84
+ if (addressLower.includes('canmore')) return 'canmore';
85
+ if (addressLower.includes('banff')) return 'banff';
86
+ if (addressLower.includes('lake louise')) return 'lake-louise';
87
+ return null;
88
+ }
89
+
90
+ // Helper function to check if location has free parking
91
+ function hasFreeParking(location: PickupLocation): boolean {
92
+ // Use the freeParking field if available, otherwise fall back to checking the name
93
+ if (location.freeParking !== undefined) {
94
+ return location.freeParking;
95
+ }
96
+ // Fallback: check name for backward compatibility
97
+ return location.name.toLowerCase().includes('free parking');
98
+ }
99
+
100
+ // Filter locations based on selected filters (AND logic)
101
+ function filterLocations(
102
+ locations: PickupLocation[],
103
+ selectedFilters: Set<string>
104
+ ): PickupLocation[] {
105
+ if (selectedFilters.size === 0) return locations;
106
+
107
+ return locations.filter(location => {
108
+ // Check city filters (only one city can be selected at a time)
109
+ const cityFilters = Array.from(selectedFilters).filter(f =>
110
+ ['canmore', 'banff', 'lake-louise'].includes(f)
111
+ );
112
+ if (cityFilters.length > 0) {
113
+ const locationCity = getLocationCity(location);
114
+ // Location must match the selected city (if any city filter is selected)
115
+ if (!locationCity || !cityFilters.includes(locationCity)) {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ // Check free parking filter (AND with city filter if both are selected)
121
+ if (selectedFilters.has('free-parking')) {
122
+ if (!hasFreeParking(location)) {
123
+ return false;
124
+ }
125
+ }
126
+
127
+ return true;
128
+ });
129
+ }
130
+
131
+ // Check if a filter would result in any results when combined with current filters
132
+ function wouldFilterHaveResults(
133
+ locations: PickupLocation[],
134
+ currentFilters: Set<string>,
135
+ filterToCheck: FilterId
136
+ ): boolean {
137
+ const testFilters = new Set(currentFilters);
138
+ testFilters.add(filterToCheck);
139
+ return filterLocations(locations, testFilters).length > 0;
140
+ }
141
+
142
+ // FilterPills component
143
+ interface FilterPillsProps {
144
+ pickupLocations: PickupLocation[];
145
+ selectedFilters: Set<string>;
146
+ onFilterToggle: (filterId: string) => void;
147
+ }
148
+
149
+ function FilterPills({ pickupLocations, selectedFilters, onFilterToggle }: FilterPillsProps) {
150
+ // Memoize which filters would have results to avoid recalculating on every render
151
+ const filterResults = useMemo(() => {
152
+ const results = new Map<string, boolean>();
153
+ FILTERS.forEach(filter => {
154
+ results.set(filter.id, wouldFilterHaveResults(pickupLocations, selectedFilters, filter.id));
155
+ });
156
+ return results;
157
+ }, [pickupLocations, selectedFilters]);
158
+
159
+ return (
160
+ <div className="mb-4 flex flex-wrap gap-2">
161
+ {FILTERS.map(filter => {
162
+ const isSelected = selectedFilters.has(filter.id);
163
+ const isCityFilter = ['canmore', 'banff', 'lake-louise'].includes(filter.id);
164
+
165
+ // For city filters: if another city is selected, hide this one (mutually exclusive)
166
+ if (isCityFilter && !isSelected) {
167
+ const otherCitySelected = Array.from(selectedFilters).some(f =>
168
+ ['canmore', 'banff', 'lake-louise'].includes(f) && f !== filter.id
169
+ );
170
+ if (otherCitySelected) {
171
+ return null; // Hide this city filter if another city is selected
172
+ }
173
+ }
174
+
175
+ // Check if this filter would have results (from memoized results)
176
+ const wouldHaveResults = filterResults.get(filter.id) ?? false;
177
+
178
+ // Hide filter if it wouldn't have results (unless it's already selected)
179
+ if (!isSelected && !wouldHaveResults) {
180
+ return null;
181
+ }
182
+
183
+ return (
184
+ <button
185
+ key={filter.id}
186
+ type="button"
187
+ onClick={() => onFilterToggle(filter.id)}
188
+ className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
189
+ isSelected
190
+ ? 'bg-emerald-600 text-white hover:bg-emerald-700'
191
+ : 'bg-stone-100 text-stone-700 hover:bg-stone-200'
192
+ }`}
193
+ >
194
+ {filter.label}
195
+ </button>
196
+ );
197
+ })}
198
+ </div>
199
+ );
200
+ }
201
+
202
+ /**
203
+ * Inner component that loads the Google Maps script. Only mounted when an API key is present
204
+ * so we never trigger ApiProjectMapError or load the script with an invalid/empty key.
205
+ */
206
+ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
207
+ const {
208
+ pickupLocations,
209
+ selectedLocationId,
210
+ selectedCustomAddress = null,
211
+ allowCustomLocation = false,
212
+ onLocationSelect,
213
+ onSkip,
214
+ isSkipped = false,
215
+ destinations = [],
216
+ } = props;
217
+ const { t } = useTranslations();
218
+ const [searchInput, setSearchInput] = useState('');
219
+ const [searchedLocation, setSearchedLocation] = useState<SearchedLocation | null>(null);
220
+ const [isValidLocation, setIsValidLocation] = useState<boolean | null>(null);
221
+ const [nearbyLocations, setNearbyLocations] = useState<NearbyLocation[]>([]);
222
+ const [useImperial, setUseImperial] = useState(false);
223
+ const [isGeocoding, setIsGeocoding] = useState(false);
224
+ const [selectedMarker, setSelectedMarker] = useState<string | null>(null);
225
+ const [hoveredMarker, setHoveredMarker] = useState<string | null>(null);
226
+ const [autocompleteSuggestions, setAutocompleteSuggestions] = useState<AutocompleteSuggestion[]>([]);
227
+ const [exactMatchLocationId, setExactMatchLocationId] = useState<string | null>(null);
228
+ const [cityFilter, setCityFilter] = useState<string | null>(null);
229
+ const [selectedFilters, setSelectedFilters] = useState<Set<string>>(new Set());
230
+ const [showSuggestions, setShowSuggestions] = useState(false);
231
+ const [mapZoom, setMapZoom] = useState(DEFAULT_MAP_ZOOM);
232
+ const [showSkipWarning, setShowSkipWarning] = useState(false);
233
+ const mapRef = useRef<google.maps.Map | null>(null);
234
+ const suggestionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
235
+ const searchInputRef = useRef<HTMLInputElement | null>(null);
236
+ const focusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
237
+
238
+ const { googleMapsApiKey: keyFromContext } = useBookingApp();
239
+ const keyFromWindow = typeof window !== 'undefined' ? (window as unknown as { __TICKETBOOTH_GOOGLE_MAPS_API_KEY__?: string }).__TICKETBOOTH_GOOGLE_MAPS_API_KEY__ : undefined;
240
+ const googleMapsApiKey = keyFromContext ?? keyFromWindow ?? ENV.GOOGLE_MAPS_API_KEY;
241
+
242
+ // ============ Google Maps API Loading ============
243
+ const { isLoaded, loadError } = useJsApiLoader({
244
+ id: 'google-map-script',
245
+ googleMapsApiKey,
246
+ libraries,
247
+ });
248
+
249
+ // ============ Effects ============
250
+
251
+ // Apply filter pills to pickup locations
252
+ const filteredPickupLocations = useMemo(() => {
253
+ return filterLocations(pickupLocations, selectedFilters);
254
+ }, [pickupLocations, selectedFilters]);
255
+
256
+ // Calculate nearby locations when user searches
257
+ useEffect(() => {
258
+ // Case 1: Exact match - show only that one location (if it passes filters)
259
+ if (exactMatchLocationId) {
260
+ const exactLocation = filteredPickupLocations.find(loc => loc.id === exactMatchLocationId);
261
+ if (exactLocation?.coordinates) {
262
+ setNearbyLocations([{
263
+ ...exactLocation,
264
+ distance: 0,
265
+ walkingTime: 0,
266
+ drivingTime: 0,
267
+ }]);
268
+ setIsValidLocation(true);
269
+ } else {
270
+ // Exact match doesn't pass filters
271
+ setNearbyLocations([]);
272
+ setIsValidLocation(false);
273
+ }
274
+ return;
275
+ }
276
+
277
+ // Case 2: City/town filter - show all locations in that city (already filtered by pills)
278
+ if (cityFilter) {
279
+ const cityLocations = filteredPickupLocations
280
+ .filter(loc => {
281
+ if (!loc.coordinates) return false;
282
+ const addressLower = loc.address.toLowerCase();
283
+ return addressLower.includes(cityFilter.toLowerCase());
284
+ })
285
+ .map(loc => ({
286
+ ...loc,
287
+ distance: 0, // No distance calculation for city filter
288
+ walkingTime: 0,
289
+ drivingTime: 0,
290
+ }));
291
+ setNearbyLocations(cityLocations);
292
+ setIsValidLocation(null);
293
+ return;
294
+ }
295
+
296
+ // Case 3: Address search - show 3 closest locations (from filtered list)
297
+ if (!searchedLocation?.coordinates) {
298
+ setIsValidLocation(null);
299
+ setNearbyLocations([]);
300
+ return;
301
+ }
302
+
303
+ const locationsWithDistance = calculateNearbyLocations(
304
+ searchedLocation.coordinates,
305
+ filteredPickupLocations
306
+ );
307
+
308
+ // Take only the 3 closest locations (already sorted by distance)
309
+ const closestLocations = locationsWithDistance.slice(0, 3);
310
+
311
+ setNearbyLocations(closestLocations);
312
+ setIsValidLocation(isExactMatch(locationsWithDistance));
313
+ }, [searchedLocation, filteredPickupLocations, exactMatchLocationId, cityFilter]);
314
+
315
+ // Reset search state when switching to "I don't know" option
316
+ useEffect(() => {
317
+ if (isSkipped) {
318
+ setSearchInput('');
319
+ setSearchedLocation(null);
320
+ setIsValidLocation(null);
321
+ setNearbyLocations([]);
322
+ setShowSuggestions(false);
323
+ setAutocompleteSuggestions([]);
324
+ setSelectedMarker(null);
325
+ setExactMatchLocationId(null);
326
+ setCityFilter(null);
327
+ setHoveredMarker(null);
328
+ }
329
+ }, [isSkipped]);
330
+
331
+ // Clear search state when a location is selected externally (from parent)
332
+ useEffect(() => {
333
+ if (selectedLocationId && (searchedLocation || searchInput)) {
334
+ // Location was selected externally, clear search state
335
+ setSearchInput('');
336
+ setSearchedLocation(null);
337
+ setIsValidLocation(null);
338
+ setNearbyLocations([]);
339
+ setShowSuggestions(false);
340
+ setAutocompleteSuggestions([]);
341
+ }
342
+ }, [selectedLocationId, searchedLocation, searchInput]);
343
+
344
+ // Cleanup timeouts on unmount
345
+ useEffect(() => {
346
+ return () => {
347
+ if (suggestionTimeoutRef.current) {
348
+ clearTimeout(suggestionTimeoutRef.current);
349
+ }
350
+ if (focusTimeoutRef.current) {
351
+ clearTimeout(focusTimeoutRef.current);
352
+ }
353
+ };
354
+ }, []);
355
+
356
+ // ============ Event Handlers ============
357
+
358
+ // Handle autocomplete suggestions as user types
359
+ const handleSearchInputChange = async (value: string) => {
360
+ setSearchInput(value);
361
+
362
+ if (!value.trim() || !googleMapsApiKey) {
363
+ setAutocompleteSuggestions([]);
364
+ setShowSuggestions(false);
365
+ return;
366
+ }
367
+
368
+ try {
369
+ const suggestions = await getAutocompleteSuggestions(value, googleMapsApiKey, LOCATION_BIAS);
370
+ // Only update if the input value hasn't changed (avoid race conditions)
371
+ setAutocompleteSuggestions(suggestions);
372
+ setShowSuggestions(suggestions.length > 0);
373
+ } catch (error) {
374
+ console.error('Error fetching autocomplete suggestions:', error);
375
+ setAutocompleteSuggestions([]);
376
+ setShowSuggestions(false);
377
+ }
378
+ };
379
+
380
+ const handleSuggestionSelect = async (suggestion: AutocompleteSuggestion) => {
381
+ setSearchInput(suggestion.description);
382
+ setShowSuggestions(false);
383
+ setAutocompleteSuggestions([]);
384
+ setExactMatchLocationId(null);
385
+ setCityFilter(null);
386
+
387
+ const searchText = suggestion.description.split(',')[0].trim().toLowerCase();
388
+ const searchWords = searchText.split(/\s+/).filter(w => w.length > 0);
389
+
390
+ // Case 1: Check for exact pickup location match (multiple words)
391
+ if (searchWords.length > 1) {
392
+ const matchingPickupLocation = filteredPickupLocations.find((loc) => {
393
+ const nameLower = loc.name.toLowerCase();
394
+ // Check if the location name starts with the search text (not just contains it)
395
+ // This prevents "banff" from matching "Banff Aspen Lodge"
396
+ return nameLower.startsWith(searchText) ||
397
+ // Or if the search contains the full location name
398
+ (searchText.includes(nameLower) && nameLower.length > 10);
399
+ });
400
+
401
+ // If exact match found, filter to show only that location (don't auto-select)
402
+ if (matchingPickupLocation) {
403
+ setExactMatchLocationId(matchingPickupLocation.id);
404
+ setCityFilter(null); // Clear city filter if it was set
405
+ setSearchedLocation(null); // Don't show searched location pin
406
+ return;
407
+ }
408
+ }
409
+
410
+ // Case 2 & 3: Get place details to determine if it's a city or specific location
411
+ setIsGeocoding(true);
412
+ try {
413
+ // Get place details including types to determine if it's a city
414
+ const placeDetails = await getPlaceDetails(suggestion.placeId, googleMapsApiKey);
415
+
416
+ if (placeDetails) {
417
+ const { lat, lng, types } = placeDetails;
418
+
419
+ // Check if it's a city/locality based on place types
420
+ // Cities typically have types like: "locality", "political", "administrative_area_level_2", etc.
421
+ // Specific locations have types like: "lodging", "establishment", "point_of_interest", etc.
422
+ const isCity = types.some(type =>
423
+ ['locality', 'administrative_area_level_1', 'administrative_area_level_2', 'political'].includes(type)
424
+ ) && !types.some(type =>
425
+ ['lodging', 'establishment', 'point_of_interest', 'restaurant', 'store'].includes(type)
426
+ );
427
+
428
+ if (isCity) {
429
+ // Case 2: It's a city - find which city it matches
430
+ const knownCities = ['banff', 'canmore', 'lake louise', 'harvie heights', 'baker creek'];
431
+ const matchedCity = knownCities.find(city =>
432
+ suggestion.description.toLowerCase().includes(city)
433
+ );
434
+
435
+ if (matchedCity) {
436
+ setCityFilter(matchedCity);
437
+ setExactMatchLocationId(null);
438
+ setSearchedLocation(null);
439
+ setIsGeocoding(false);
440
+ return;
441
+ }
442
+ }
443
+
444
+ // Case 3: It's a specific location - geocode and show 3 closest
445
+ setExactMatchLocationId(null);
446
+ setCityFilter(null);
447
+ setSearchedLocation({
448
+ address: suggestion.description,
449
+ coordinates: { lat, lng },
450
+ });
451
+ } else {
452
+ // Fallback to geocoding if place details doesn't work
453
+ const coordinates = await geocodeAddress(suggestion.description, googleMapsApiKey);
454
+ if (coordinates) {
455
+ setExactMatchLocationId(null);
456
+ setCityFilter(null);
457
+ setSearchedLocation({
458
+ address: suggestion.description,
459
+ coordinates,
460
+ });
461
+ } else {
462
+ setSearchedLocation(null);
463
+ setIsValidLocation(false);
464
+ setNearbyLocations([]);
465
+ }
466
+ }
467
+ } catch (error) {
468
+ console.error('Error getting place details:', error);
469
+ // Final fallback to geocoding
470
+ try {
471
+ const coordinates = await geocodeAddress(suggestion.description, googleMapsApiKey);
472
+ if (coordinates) {
473
+ setExactMatchLocationId(null);
474
+ setCityFilter(null);
475
+ setSearchedLocation({
476
+ address: suggestion.description,
477
+ coordinates,
478
+ });
479
+ } else {
480
+ setSearchedLocation(null);
481
+ setIsValidLocation(false);
482
+ setNearbyLocations([]);
483
+ }
484
+ } catch (geocodeError) {
485
+ console.error('Geocoding error:', geocodeError);
486
+ setSearchedLocation(null);
487
+ setIsValidLocation(false);
488
+ setNearbyLocations([]);
489
+ }
490
+ } finally {
491
+ setIsGeocoding(false);
492
+ }
493
+ };
494
+
495
+ const handleSearch = async () => {
496
+ if (!searchInput.trim()) {
497
+ setSearchedLocation(null);
498
+ setIsValidLocation(null);
499
+ setNearbyLocations([]);
500
+ setShowSuggestions(false);
501
+ setExactMatchLocationId(null);
502
+ setCityFilter(null);
503
+ return;
504
+ }
505
+
506
+ setShowSuggestions(false);
507
+ setExactMatchLocationId(null);
508
+ setCityFilter(null);
509
+
510
+ const searchText = searchInput.split(',')[0].trim().toLowerCase();
511
+ const searchWords = searchText.split(/\s+/).filter(w => w.length > 0);
512
+
513
+ // Case 1: Check for exact pickup location match (multiple words)
514
+ if (searchWords.length > 1) {
515
+ const matchingPickupLocation = filteredPickupLocations.find((loc) => {
516
+ const nameLower = loc.name.toLowerCase();
517
+ return nameLower.startsWith(searchText) ||
518
+ (searchText.includes(nameLower) && nameLower.length > 10);
519
+ });
520
+
521
+ // If exact match found, filter to show only that location (don't auto-select)
522
+ if (matchingPickupLocation) {
523
+ setExactMatchLocationId(matchingPickupLocation.id);
524
+ setCityFilter(null); // Clear city filter if it was set
525
+ setSearchedLocation(null); // Don't show searched location pin
526
+ return;
527
+ }
528
+ }
529
+
530
+ // Case 2: Check if it's a city/town name (only for single-word searches or city-only searches)
531
+ // Don't trigger for addresses that contain city names (those should be Case 3)
532
+ const knownCities = ['banff', 'canmore', 'lake louise', 'harvie heights', 'baker creek'];
533
+ // Only treat as city search if:
534
+ // 1. It's a single word that matches a city name exactly, OR
535
+ // 2. The input is just the city name (no street names, hotel names, etc.)
536
+ const isCityOnlySearch = searchWords.length === 1 && knownCities.includes(searchText);
537
+ const isCityInputOnly = knownCities.some(city => {
538
+ const inputLower = searchInput.toLowerCase();
539
+ // Check if input is essentially just the city (maybe with "Canada" or province)
540
+ const cityPattern = new RegExp(`^${city}(,\\s*(ab|alberta|canada))?$`, 'i');
541
+ return cityPattern.test(inputLower.trim());
542
+ });
543
+
544
+ if (isCityOnlySearch || isCityInputOnly) {
545
+ const matchedCity = knownCities.find(city => {
546
+ if (searchWords.length === 1 && searchText === city) return true;
547
+ const inputLower = searchInput.toLowerCase();
548
+ const cityPattern = new RegExp(`^${city}(,\\s*(ab|alberta|canada))?$`, 'i');
549
+ return cityPattern.test(inputLower.trim());
550
+ });
551
+ if (matchedCity) {
552
+ setCityFilter(matchedCity);
553
+ setExactMatchLocationId(null); // Clear exact match if it was set
554
+ setSearchedLocation(null); // Don't show searched location pin
555
+ return;
556
+ }
557
+ }
558
+
559
+ // Case 3: Address search - make sure we clear exact match and city filter states
560
+ setExactMatchLocationId(null);
561
+ setCityFilter(null);
562
+ setIsGeocoding(true);
563
+ try {
564
+ const coordinates = await geocodeAddress(searchInput, googleMapsApiKey);
565
+ if (coordinates) {
566
+ setSearchedLocation({
567
+ address: searchInput,
568
+ coordinates,
569
+ });
570
+ } else {
571
+ setSearchedLocation(null);
572
+ setIsValidLocation(false);
573
+ setNearbyLocations([]);
574
+ }
575
+ } catch (error) {
576
+ console.error('Geocoding error:', error);
577
+ setSearchedLocation(null);
578
+ setIsValidLocation(false);
579
+ setNearbyLocations([]);
580
+ } finally {
581
+ setIsGeocoding(false);
582
+ }
583
+ };
584
+
585
+ const handleSearchKeyPress = (e: React.KeyboardEvent) => {
586
+ if (e.key === 'Enter') {
587
+ handleSearch();
588
+ }
589
+ };
590
+
591
+ const handleLocationSelect = (locationId: string) => {
592
+ onLocationSelect(locationId);
593
+ // Clear search state when a location is selected
594
+ setSearchInput('');
595
+ setSearchedLocation(null);
596
+ setIsValidLocation(null);
597
+ setNearbyLocations([]);
598
+ setShowSuggestions(false);
599
+ setSelectedMarker(null);
600
+ setHoveredMarker(null);
601
+ };
602
+
603
+ const handleCustomLocationSelect = (address: string, coordinates?: { lat: number; lng: number }) => {
604
+ onLocationSelect(null, { address, coordinates });
605
+ // Keep search state so user sees their selection; clear on "Change"
606
+ setSelectedMarker(null);
607
+ };
608
+
609
+ const handleYesOptionClick = () => {
610
+ // When clicking "Yes, I can add it now", clear any skip state
611
+ // Call onLocationSelect with null to signal clearing skip state
612
+ // The parent will handle clearing the skip state
613
+ if (isSkipped) {
614
+ onLocationSelect(null);
615
+ }
616
+ // Focus the search input to show the interface is active
617
+ // Clear any existing focus timeout
618
+ if (focusTimeoutRef.current) {
619
+ clearTimeout(focusTimeoutRef.current);
620
+ }
621
+ focusTimeoutRef.current = setTimeout(() => {
622
+ searchInputRef.current?.focus();
623
+ focusTimeoutRef.current = null;
624
+ }, 0);
625
+ };
626
+
627
+ const handleSkip = () => {
628
+ // Show warning modal when user clicks "I don't know yet"
629
+ setShowSkipWarning(true);
630
+ };
631
+
632
+ const handleSkipConfirm = () => {
633
+ // User confirmed they understand - proceed with skip
634
+ setShowSkipWarning(false);
635
+
636
+ // Clear all search-related state when skipping
637
+ setSearchInput('');
638
+ setSearchedLocation(null);
639
+ setIsValidLocation(null);
640
+ setNearbyLocations([]);
641
+ setShowSuggestions(false);
642
+ setAutocompleteSuggestions([]);
643
+ setSelectedMarker(null);
644
+ setHoveredMarker(null);
645
+
646
+ onLocationSelect(null);
647
+ if (onSkip) {
648
+ onSkip();
649
+ }
650
+ };
651
+
652
+ // ============ Map Configuration ============
653
+
654
+ // Calculate map center
655
+ const mapCenter = useMemo(
656
+ () => calculateMapCenter(searchedLocation, nearbyLocations, filteredPickupLocations),
657
+ [nearbyLocations, searchedLocation, filteredPickupLocations]
658
+ );
659
+
660
+ // Center and zoom map based on search state
661
+ useEffect(() => {
662
+ if (!mapRef.current) return;
663
+
664
+ // Case 1: Exact match - zoom to that single location
665
+ if (exactMatchLocationId) {
666
+ const exactLocation = filteredPickupLocations.find(loc => loc.id === exactMatchLocationId);
667
+ if (exactLocation?.coordinates) {
668
+ mapRef.current.setCenter(exactLocation.coordinates);
669
+ mapRef.current.setZoom(SEARCHED_LOCATION_ZOOM);
670
+ setMapZoom(SEARCHED_LOCATION_ZOOM);
671
+ return;
672
+ }
673
+ }
674
+
675
+ // Case 2: City filter - fit bounds to all locations in that city
676
+ if (cityFilter && nearbyLocations.length > 0) {
677
+ const bounds = new google.maps.LatLngBounds();
678
+ nearbyLocations.forEach(loc => {
679
+ if (loc.coordinates) {
680
+ bounds.extend(loc.coordinates);
681
+ }
682
+ });
683
+ if (!bounds.isEmpty()) {
684
+ mapRef.current.fitBounds(bounds);
685
+ // Limit max zoom
686
+ google.maps.event.addListenerOnce(mapRef.current, 'bounds_changed', () => {
687
+ const currentZoom = mapRef.current?.getZoom();
688
+ if (currentZoom && currentZoom > MAX_ZOOM) {
689
+ mapRef.current?.setZoom(MAX_ZOOM);
690
+ }
691
+ });
692
+ }
693
+ return;
694
+ }
695
+
696
+ // Case 3: Address search - zoom to searched location
697
+ if (searchedLocation?.coordinates) {
698
+ mapRef.current.setCenter(searchedLocation.coordinates);
699
+ mapRef.current.setZoom(SEARCHED_LOCATION_ZOOM);
700
+ setMapZoom(SEARCHED_LOCATION_ZOOM);
701
+ } else if (!searchedLocation && !exactMatchLocationId && !cityFilter) {
702
+ // Reset zoom when search is cleared
703
+ setMapZoom(DEFAULT_MAP_ZOOM);
704
+ }
705
+ }, [searchedLocation, exactMatchLocationId, cityFilter, nearbyLocations, filteredPickupLocations]);
706
+
707
+ const mapOptions = useMemo(() => getMapOptions(), []);
708
+
709
+ // Memoize icons for markers to avoid recreating on every hover
710
+ // Only create icons for the actually hovered location and all others use default
711
+ const markerIcons = useMemo(() => {
712
+ const icons = new Map<string, { normal: string; hover: string }>();
713
+
714
+ // Pre-create icons for nearby locations (distance markers)
715
+ nearbyLocations.forEach((location) => {
716
+ if (!location.coordinates) return;
717
+ const showDistanceMarker = searchedLocation !== null && !exactMatchLocationId && !cityFilter;
718
+
719
+ if (showDistanceMarker) {
720
+ const walkingTimeStr = formatTime(location.walkingTime);
721
+ const distanceStr = formatDistance(location.distance, useImperial);
722
+ icons.set(location.id, {
723
+ normal: createDistanceMarkerIcon({
724
+ bgColor: MARKER_COLORS.default,
725
+ distanceStr,
726
+ walkingTimeStr,
727
+ }),
728
+ hover: createDistanceMarkerIcon({
729
+ bgColor: MARKER_COLORS.hover,
730
+ distanceStr,
731
+ walkingTimeStr,
732
+ }),
733
+ });
734
+ } else {
735
+ icons.set(location.id, {
736
+ normal: createPinMarkerIcon(MARKER_COLORS.default),
737
+ hover: createPinMarkerIcon(MARKER_COLORS.hover),
738
+ });
739
+ }
740
+ });
741
+
742
+ // Pre-create icons for all pickup locations (when no search)
743
+ if (nearbyLocations.length === 0 && !exactMatchLocationId && !cityFilter && !searchedLocation) {
744
+ filteredPickupLocations.forEach((location) => {
745
+ if (!location.coordinates) return;
746
+ icons.set(location.id, {
747
+ normal: createPinMarkerIcon(MARKER_COLORS.default),
748
+ hover: createPinMarkerIcon(MARKER_COLORS.hover),
749
+ });
750
+ });
751
+ }
752
+
753
+ return icons;
754
+ }, [nearbyLocations, searchedLocation, exactMatchLocationId, cityFilter, useImperial, filteredPickupLocations]);
755
+
756
+ // Map load handler
757
+ const onMapLoad = useCallback(
758
+ (map: google.maps.Map) => {
759
+ mapRef.current = map;
760
+
761
+ // Case 1: Exact match - zoom to that single location
762
+ if (exactMatchLocationId) {
763
+ const exactLocation = filteredPickupLocations.find(loc => loc.id === exactMatchLocationId);
764
+ if (exactLocation?.coordinates) {
765
+ map.setCenter(exactLocation.coordinates);
766
+ map.setZoom(SEARCHED_LOCATION_ZOOM);
767
+ setMapZoom(SEARCHED_LOCATION_ZOOM);
768
+ return;
769
+ }
770
+ }
771
+
772
+ // Case 2: City filter - fit bounds to all locations in that city
773
+ if (cityFilter && nearbyLocations.length > 0) {
774
+ const bounds = new google.maps.LatLngBounds();
775
+ nearbyLocations.forEach(loc => {
776
+ if (loc.coordinates) {
777
+ bounds.extend(loc.coordinates);
778
+ }
779
+ });
780
+ if (!bounds.isEmpty()) {
781
+ map.fitBounds(bounds);
782
+ // Limit max zoom
783
+ google.maps.event.addListenerOnce(map, 'bounds_changed', () => {
784
+ const currentZoom = map.getZoom();
785
+ if (currentZoom && currentZoom > MAX_ZOOM) {
786
+ map.setZoom(MAX_ZOOM);
787
+ }
788
+ });
789
+ }
790
+ return;
791
+ }
792
+
793
+ // Case 3: Address search - zoom to searched location
794
+ if (searchedLocation?.coordinates) {
795
+ map.setCenter(searchedLocation.coordinates);
796
+ map.setZoom(SEARCHED_LOCATION_ZOOM);
797
+ setMapZoom(SEARCHED_LOCATION_ZOOM);
798
+ return;
799
+ }
800
+
801
+ // Otherwise, fit bounds to show all relevant locations
802
+ const bounds = calculateMapBounds(
803
+ nearbyLocations,
804
+ searchedLocation,
805
+ isValidLocation,
806
+ pickupLocations
807
+ );
808
+
809
+ if (bounds) {
810
+ map.fitBounds(bounds);
811
+ // Don't zoom in too much - listener removes itself after first use
812
+ google.maps.event.addListenerOnce(map, 'bounds_changed', () => {
813
+ const currentZoom = map.getZoom();
814
+ if (currentZoom && currentZoom > MAX_ZOOM) {
815
+ map.setZoom(MAX_ZOOM);
816
+ }
817
+ });
818
+ }
819
+ },
820
+ [nearbyLocations, searchedLocation, isValidLocation, pickupLocations, exactMatchLocationId, cityFilter, filteredPickupLocations]
821
+ );
822
+
823
+ // ============ Render ============
824
+
825
+ if (loadError) {
826
+ return (
827
+ <div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
828
+ Error loading Google Maps. Please check your API key.
829
+ </div>
830
+ );
831
+ }
832
+
833
+ return (
834
+ <div className="space-y-6">
835
+ <h2 className="text-2xl font-bold text-stone-900">
836
+ {t('pickup.title')}
837
+ </h2>
838
+
839
+ {/* Radio buttons for selection */}
840
+ <div className="space-y-4">
841
+ <label className="flex items-start gap-3 cursor-pointer">
842
+ <input
843
+ type="radio"
844
+ name="pickup-option"
845
+ checked={!isSkipped}
846
+ onChange={handleYesOptionClick}
847
+ className="mt-1 w-5 h-5 text-emerald-600 focus:ring-emerald-500"
848
+ aria-label={t('pickup.yesAddNow')}
849
+ />
850
+ <div className="flex-1">
851
+ <span className="font-medium text-stone-900">
852
+ {t('pickup.yesAddNow')}
853
+ </span>
854
+ <p className="text-sm text-stone-600 mt-1">
855
+ {t('pickup.yesAddNowSubtext')}
856
+ </p>
857
+ {/* Show search and map interface when "Yes" option is selected (not skipped) */}
858
+ {!isSkipped && (
859
+ <div className="mt-2">
860
+ <div className="relative">
861
+ <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
862
+ <svg
863
+ className="h-5 w-5 text-stone-400"
864
+ fill="none"
865
+ stroke="currentColor"
866
+ viewBox="0 0 24 24"
867
+ >
868
+ <path
869
+ strokeLinecap="round"
870
+ strokeLinejoin="round"
871
+ strokeWidth={2}
872
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
873
+ />
874
+ </svg>
875
+ </div>
876
+ <input
877
+ ref={searchInputRef}
878
+ type="text"
879
+ value={searchInput}
880
+ onChange={(e) => handleSearchInputChange(e.target.value)}
881
+ onKeyPress={handleSearchKeyPress}
882
+ onKeyDown={(e) => {
883
+ // Handle arrow keys for suggestion navigation
884
+ if (showSuggestions && autocompleteSuggestions.length > 0) {
885
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
886
+ e.preventDefault();
887
+ // Could implement keyboard navigation here if needed
888
+ }
889
+ if (e.key === 'Escape') {
890
+ setShowSuggestions(false);
891
+ }
892
+ }
893
+ }}
894
+ onFocus={() => {
895
+ // Clear any pending timeout
896
+ if (suggestionTimeoutRef.current) {
897
+ clearTimeout(suggestionTimeoutRef.current);
898
+ suggestionTimeoutRef.current = null;
899
+ }
900
+ if (autocompleteSuggestions.length > 0) {
901
+ setShowSuggestions(true);
902
+ }
903
+ }}
904
+ onBlur={() => {
905
+ // Clear any existing timeout
906
+ if (suggestionTimeoutRef.current) {
907
+ clearTimeout(suggestionTimeoutRef.current);
908
+ }
909
+ // Delay hiding suggestions to allow click events
910
+ suggestionTimeoutRef.current = setTimeout(() => {
911
+ setShowSuggestions(false);
912
+ suggestionTimeoutRef.current = null;
913
+ }, SUGGESTION_HIDE_DELAY);
914
+ }}
915
+ placeholder={t('pickup.enterAddress')}
916
+ aria-label={t('pickup.enterAddress')}
917
+ aria-autocomplete="list"
918
+ aria-controls="autocomplete-suggestions"
919
+ className="w-full pl-10 pr-4 py-3 rounded-lg border border-stone-300 focus:outline-none focus:border-stone-500 text-stone-900"
920
+ />
921
+ {/* Autocomplete suggestions dropdown */}
922
+ {showSuggestions && autocompleteSuggestions.length > 0 && (
923
+ <div
924
+ id="autocomplete-suggestions"
925
+ role="listbox"
926
+ aria-label="Address suggestions"
927
+ className="absolute z-50 w-full mt-1 bg-white border border-stone-300 rounded-lg shadow-lg max-h-60 overflow-y-auto"
928
+ >
929
+ {autocompleteSuggestions.map((suggestion) => (
930
+ <button
931
+ key={suggestion.placeId}
932
+ type="button"
933
+ role="option"
934
+ aria-selected="false"
935
+ onClick={() => handleSuggestionSelect(suggestion)}
936
+ className="w-full text-left px-4 py-3 hover:bg-stone-50 transition-colors border-b border-stone-100 last:border-b-0 focus:bg-stone-50 focus:outline-none"
937
+ >
938
+ <div className="flex items-start gap-3">
939
+ <div className="mt-0.5">
940
+ <svg
941
+ className="w-5 h-5 text-stone-400"
942
+ fill="none"
943
+ stroke="currentColor"
944
+ viewBox="0 0 24 24"
945
+ >
946
+ <path
947
+ strokeLinecap="round"
948
+ strokeLinejoin="round"
949
+ strokeWidth={2}
950
+ d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
951
+ />
952
+ </svg>
953
+ </div>
954
+ <div className="flex-1 min-w-0">
955
+ <p className="text-sm font-medium text-stone-900 truncate">
956
+ {suggestion.mainText}
957
+ </p>
958
+ {suggestion.secondaryText && (
959
+ <p className="text-xs text-stone-500 truncate">
960
+ {suggestion.secondaryText}
961
+ </p>
962
+ )}
963
+ </div>
964
+ </div>
965
+ </button>
966
+ ))}
967
+ </div>
968
+ )}
969
+ </div>
970
+ {isGeocoding && (
971
+ <p className="mt-1 text-sm text-stone-500">
972
+ {t('pickup.searchingLocation')}
973
+ </p>
974
+ )}
975
+ {searchedLocation && !searchedLocation.coordinates && !isGeocoding && (
976
+ <div className="mt-2 p-3 bg-red-50 border border-red-200 rounded-lg">
977
+ <div className="flex items-start gap-2">
978
+ <svg
979
+ className="w-5 h-5 text-red-600 mt-0.5 flex-shrink-0"
980
+ fill="currentColor"
981
+ viewBox="0 0 20 20"
982
+ >
983
+ <path
984
+ fillRule="evenodd"
985
+ d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
986
+ clipRule="evenodd"
987
+ />
988
+ </svg>
989
+ <p className="text-sm text-red-700">
990
+ {t('pickup.locationNotFound')}
991
+ </p>
992
+ </div>
993
+ </div>
994
+ )}
995
+ </div>
996
+ )}
997
+ </div>
998
+ </label>
999
+
1000
+ {/* Map - Show when "Yes" option is selected (not skipped) */}
1001
+ {!isSkipped && isLoaded && pickupLocations.length > 0 && (
1002
+ <div className="ml-8">
1003
+ {/* Filter Pills */}
1004
+ <FilterPills
1005
+ pickupLocations={pickupLocations}
1006
+ selectedFilters={selectedFilters}
1007
+ onFilterToggle={(filterId) => {
1008
+ const newFilters = new Set(selectedFilters);
1009
+ const cityFilterIds = ['canmore', 'banff', 'lake-louise'] as const;
1010
+ const isCityFilter = cityFilterIds.includes(filterId as typeof cityFilterIds[number]);
1011
+
1012
+ if (isCityFilter) {
1013
+ // For city filters: deselect other cities first (mutually exclusive)
1014
+ cityFilterIds.forEach(cityId => {
1015
+ if (cityId !== filterId) {
1016
+ newFilters.delete(cityId);
1017
+ }
1018
+ });
1019
+ // Toggle this city filter
1020
+ if (newFilters.has(filterId)) {
1021
+ newFilters.delete(filterId);
1022
+ } else {
1023
+ newFilters.add(filterId);
1024
+ }
1025
+ } else {
1026
+ // For non-city filters (like free-parking), just toggle
1027
+ if (newFilters.has(filterId)) {
1028
+ newFilters.delete(filterId);
1029
+ } else {
1030
+ newFilters.add(filterId);
1031
+ }
1032
+ }
1033
+
1034
+ setSelectedFilters(newFilters);
1035
+
1036
+ // When a city filter is active, also drive the map zoom like a typed city search
1037
+ const activeCityFilter = Array.from(newFilters).find(f =>
1038
+ cityFilterIds.includes(f as typeof cityFilterIds[number])
1039
+ ) as FilterId | undefined;
1040
+
1041
+ if (activeCityFilter) {
1042
+ // Map filter id to the city string used elsewhere (note lake-louise vs lake louise)
1043
+ const cityString =
1044
+ activeCityFilter === 'lake-louise' ? 'lake louise' : activeCityFilter;
1045
+ setCityFilter(cityString);
1046
+ // Clear any previous exact-match / searched state so case 2 (city) takes over
1047
+ setExactMatchLocationId(null);
1048
+ setSearchedLocation(null);
1049
+ } else {
1050
+ // No city filter selected via pills – clear city-based zoom state
1051
+ setCityFilter(null);
1052
+ }
1053
+ }}
1054
+ />
1055
+
1056
+ {/* No results message */}
1057
+ {nearbyLocations.length === 0 &&
1058
+ !exactMatchLocationId &&
1059
+ !cityFilter &&
1060
+ !searchedLocation &&
1061
+ filteredPickupLocations.length === 0 &&
1062
+ selectedFilters.size > 0 && (
1063
+ <div className="mb-4 p-4 bg-stone-50 border border-stone-200 rounded-lg">
1064
+ <p className="text-sm text-stone-600 text-center">
1065
+ No pickup locations match the selected filters. Try adjusting your filters.
1066
+ </p>
1067
+ </div>
1068
+ )}
1069
+
1070
+ <GoogleMap
1071
+ mapContainerClassName="w-full h-96 rounded-lg border border-stone-300"
1072
+ center={mapCenter}
1073
+ zoom={mapZoom}
1074
+ options={mapOptions}
1075
+ onLoad={onMapLoad}
1076
+ >
1077
+ {/* Markers for filtered locations */}
1078
+ {nearbyLocations.map((location) => {
1079
+ if (!location.coordinates) return null;
1080
+ const isHovered = hoveredMarker === location.id;
1081
+
1082
+ // Show distance markers only for address searches (Case 3)
1083
+ // For exact match (Case 1) and city filter (Case 2), show regular pins
1084
+ const showDistanceMarker = searchedLocation !== null && !exactMatchLocationId && !cityFilter;
1085
+
1086
+ if (showDistanceMarker) {
1087
+ const iconUrls = markerIcons.get(location.id);
1088
+ const iconUrl = iconUrls ? (isHovered ? iconUrls.hover : iconUrls.normal) : createDistanceMarkerIcon({
1089
+ bgColor: MARKER_COLORS.default,
1090
+ distanceStr: formatDistance(location.distance, useImperial),
1091
+ walkingTimeStr: formatTime(location.walkingTime),
1092
+ });
1093
+
1094
+ return (
1095
+ <Marker
1096
+ key={location.id}
1097
+ position={location.coordinates}
1098
+ title={location.name}
1099
+ zIndex={isHovered ? 200 : 100}
1100
+ icon={{
1101
+ url: iconUrl,
1102
+ scaledSize: new google.maps.Size(120, 32),
1103
+ anchor: new google.maps.Point(60, 32),
1104
+ }}
1105
+ onMouseOver={() => setHoveredMarker(location.id)}
1106
+ onMouseOut={() => setHoveredMarker(null)}
1107
+ onClick={() => setSelectedMarker(location.id)}
1108
+ >
1109
+ {selectedMarker === location.id && (
1110
+ <InfoWindow
1111
+ position={location.coordinates}
1112
+ onCloseClick={() => setSelectedMarker(null)}
1113
+ >
1114
+ <div className="p-2">
1115
+ <h3 className="font-semibold text-stone-900 mb-1">
1116
+ {location.name}
1117
+ </h3>
1118
+ <p className="text-sm text-stone-600 mb-2">
1119
+ {location.address}
1120
+ </p>
1121
+ <div className="text-xs text-stone-500 space-y-1">
1122
+ <p>
1123
+ 📍 {formatDistance(location.distance, useImperial)} {t('pickup.away')}
1124
+ </p>
1125
+ <p>🚶 {formatTime(location.walkingTime)} {t('pickup.walk')}</p>
1126
+ <p>🚗 {formatTime(location.drivingTime)} {t('pickup.drive')}</p>
1127
+ </div>
1128
+ <button
1129
+ onClick={() => {
1130
+ handleLocationSelect(location.id);
1131
+ setSelectedMarker(null);
1132
+ }}
1133
+ className="mt-2 w-full px-3 py-1.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 transition-colors"
1134
+ >
1135
+ {t('pickup.selectThisLocation')}
1136
+ </button>
1137
+ </div>
1138
+ </InfoWindow>
1139
+ )}
1140
+ </Marker>
1141
+ );
1142
+ } else {
1143
+ // Regular pin markers for exact match or city filter
1144
+ const iconUrls = markerIcons.get(location.id);
1145
+ const iconUrl = iconUrls ? (isHovered ? iconUrls.hover : iconUrls.normal) : createPinMarkerIcon(MARKER_COLORS.default);
1146
+
1147
+ return (
1148
+ <Marker
1149
+ key={location.id}
1150
+ position={location.coordinates}
1151
+ title={location.name}
1152
+ zIndex={isHovered ? 200 : 100}
1153
+ icon={{
1154
+ url: iconUrl,
1155
+ scaledSize: new google.maps.Size(32, 40),
1156
+ anchor: new google.maps.Point(16, 40),
1157
+ }}
1158
+ onMouseOver={() => setHoveredMarker(location.id)}
1159
+ onMouseOut={() => setHoveredMarker(null)}
1160
+ onClick={() => setSelectedMarker(location.id)}
1161
+ >
1162
+ {selectedMarker === location.id && (
1163
+ <InfoWindow
1164
+ position={location.coordinates}
1165
+ onCloseClick={() => setSelectedMarker(null)}
1166
+ >
1167
+ <div className="p-2">
1168
+ <h3 className="font-semibold text-stone-900 mb-1">
1169
+ {location.name}
1170
+ </h3>
1171
+ <p className="text-sm text-stone-600 mb-2">
1172
+ {location.address}
1173
+ </p>
1174
+ {location.notes && (
1175
+ <p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded p-2 mb-2">
1176
+ {location.notes}
1177
+ </p>
1178
+ )}
1179
+ <button
1180
+ onClick={() => {
1181
+ handleLocationSelect(location.id);
1182
+ setSelectedMarker(null);
1183
+ }}
1184
+ className="mt-2 w-full px-3 py-1.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 transition-colors"
1185
+ >
1186
+ {t('pickup.selectThisLocation')}
1187
+ </button>
1188
+ </div>
1189
+ </InfoWindow>
1190
+ )}
1191
+ </Marker>
1192
+ );
1193
+ }
1194
+ })}
1195
+
1196
+ {/* Markers for all pickup locations (when no search, no exact match, no city filter) */}
1197
+ {nearbyLocations.length === 0 && !exactMatchLocationId && !cityFilter && !searchedLocation &&
1198
+ filteredPickupLocations.map((location) => {
1199
+ if (!location.coordinates) return null;
1200
+ const isHovered = hoveredMarker === location.id;
1201
+ const iconUrls = markerIcons.get(location.id);
1202
+ const iconUrl = iconUrls ? (isHovered ? iconUrls.hover : iconUrls.normal) : createPinMarkerIcon(MARKER_COLORS.default);
1203
+
1204
+ return (
1205
+ <Marker
1206
+ key={location.id}
1207
+ position={location.coordinates}
1208
+ title={location.name}
1209
+ zIndex={isHovered ? 200 : 100}
1210
+ icon={{
1211
+ url: iconUrl,
1212
+ scaledSize: new google.maps.Size(32, 40),
1213
+ anchor: new google.maps.Point(16, 40),
1214
+ }}
1215
+ onMouseOver={() => setHoveredMarker(location.id)}
1216
+ onMouseOut={() => setHoveredMarker(null)}
1217
+ onClick={() => setSelectedMarker(location.id)}
1218
+ >
1219
+ {selectedMarker === location.id && (
1220
+ <InfoWindow
1221
+ position={location.coordinates}
1222
+ onCloseClick={() => setSelectedMarker(null)}
1223
+ >
1224
+ <div className="p-2">
1225
+ <h3 className="font-semibold text-stone-900 mb-1">
1226
+ {location.name}
1227
+ </h3>
1228
+ <p className="text-sm text-stone-600 mb-2">
1229
+ {location.address}
1230
+ </p>
1231
+ {location.notes && (
1232
+ <p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded p-2 mb-2">
1233
+ {location.notes}
1234
+ </p>
1235
+ )}
1236
+ <button
1237
+ onClick={() => {
1238
+ handleLocationSelect(location.id);
1239
+ setSelectedMarker(null);
1240
+ }}
1241
+ className="mt-2 w-full px-3 py-1.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 transition-colors"
1242
+ >
1243
+ {t('pickup.selectThisLocation')}
1244
+ </button>
1245
+ </div>
1246
+ </InfoWindow>
1247
+ )}
1248
+ </Marker>
1249
+ );
1250
+ })}
1251
+
1252
+ {/* Marker for searched location (user's hotel/Airbnb) - only show for address searches */}
1253
+ {searchedLocation?.coordinates && !exactMatchLocationId && !cityFilter && (
1254
+ <Marker
1255
+ position={searchedLocation.coordinates}
1256
+ title={`Your searched location\n${searchedLocation.address}`}
1257
+ zIndex={200}
1258
+ icon={{
1259
+ url: createSearchedLocationPinIcon(),
1260
+ scaledSize: new google.maps.Size(32, 40),
1261
+ anchor: new google.maps.Point(16, 40),
1262
+ }}
1263
+ animation={google.maps.Animation.DROP}
1264
+ onClick={() => setSelectedMarker('searched')}
1265
+ >
1266
+ {selectedMarker === 'searched' && searchedLocation.coordinates && (
1267
+ <InfoWindow
1268
+ position={searchedLocation.coordinates}
1269
+ onCloseClick={() => setSelectedMarker(null)}
1270
+ >
1271
+ <div className="p-2">
1272
+ <h3 className="font-semibold text-stone-900 mb-1">
1273
+ {t('pickup.yourLocation')}
1274
+ </h3>
1275
+ <p className="text-sm text-stone-600 mb-2">
1276
+ {searchedLocation.address}
1277
+ </p>
1278
+ {allowCustomLocation && (
1279
+ <button
1280
+ onClick={() => handleCustomLocationSelect(searchedLocation.address, searchedLocation.coordinates ?? undefined)}
1281
+ className="w-full px-3 py-1.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 transition-colors mb-2"
1282
+ >
1283
+ {t('pickup.useThisAddress')}
1284
+ </button>
1285
+ )}
1286
+ <p className="text-xs text-stone-500 mt-2">
1287
+ {t('pickup.chooseClosest')}
1288
+ </p>
1289
+ </div>
1290
+ </InfoWindow>
1291
+ )}
1292
+ </Marker>
1293
+ )}
1294
+
1295
+ {/* Destination markers */}
1296
+ {destinations && destinations.length > 0 && destinations.map((destination) => (
1297
+ <Marker
1298
+ key={`destination-${destination.name}`}
1299
+ position={{ lat: destination.latitude, lng: destination.longitude }}
1300
+ title={destination.name}
1301
+ zIndex={50}
1302
+ icon={{
1303
+ url: createDestinationMarkerIcon(destination.name, MARKER_COLORS.destination),
1304
+ scaledSize: new google.maps.Size(80, 60),
1305
+ anchor: new google.maps.Point(40, 40), // Anchor at the pin point (not including text)
1306
+ }}
1307
+ cursor="default"
1308
+ />
1309
+ ))}
1310
+ </GoogleMap>
1311
+ </div>
1312
+ )}
1313
+
1314
+ {/* Filtered locations list */}
1315
+ {nearbyLocations.length > 0 && (
1316
+ <div className="ml-8 space-y-4">
1317
+ <h3 className="font-semibold text-stone-900">
1318
+ {exactMatchLocationId
1319
+ ? t('pickup.exactMatch')
1320
+ : cityFilter
1321
+ ? t('pickup.locationsInCity', { city: cityFilter.charAt(0).toUpperCase() + cityFilter.slice(1) })
1322
+ : t('pickup.chooseNearby')}
1323
+ </h3>
1324
+ <div className="space-y-3">
1325
+ {/* Custom address option - only when user searched for an address (Case 3) and allowCustomLocation is true */}
1326
+ {allowCustomLocation && searchedLocation && !exactMatchLocationId && !cityFilter && (
1327
+ <label
1328
+ className="flex items-start gap-3 cursor-pointer p-3 rounded-lg hover:bg-stone-50 border border-stone-200 bg-emerald-50/30"
1329
+ onMouseEnter={() => {
1330
+ setHoveredMarker('searched');
1331
+ if (mapRef.current && searchedLocation.coordinates) {
1332
+ panToLocationIfNeeded(mapRef.current, searchedLocation.coordinates);
1333
+ }
1334
+ }}
1335
+ onMouseLeave={() => setHoveredMarker(null)}
1336
+ >
1337
+ <input
1338
+ type="radio"
1339
+ name="pickup-location"
1340
+ checked={selectedCustomAddress === searchedLocation.address}
1341
+ onChange={() => handleCustomLocationSelect(searchedLocation.address, searchedLocation.coordinates ?? undefined)}
1342
+ className="mt-1 w-5 h-5 text-emerald-600 focus:ring-emerald-500"
1343
+ />
1344
+ <div className="flex-1">
1345
+ <p className="font-medium text-stone-900">
1346
+ {t('pickup.useThisAddress')} — {searchedLocation.address}
1347
+ </p>
1348
+ <p className="text-sm text-stone-600 mt-1">
1349
+ {t('pickup.yourLocation')}
1350
+ </p>
1351
+ </div>
1352
+ </label>
1353
+ )}
1354
+ {nearbyLocations.map((location) => (
1355
+ <label
1356
+ key={location.id}
1357
+ className="flex items-start gap-3 cursor-pointer p-3 rounded-lg hover:bg-stone-50 border border-stone-200"
1358
+ onMouseEnter={() => {
1359
+ setHoveredMarker(location.id);
1360
+ if (mapRef.current && location.coordinates) {
1361
+ panToLocationIfNeeded(mapRef.current, location.coordinates);
1362
+ }
1363
+ }}
1364
+ onMouseLeave={() => {
1365
+ setHoveredMarker(null);
1366
+ }}
1367
+ >
1368
+ <input
1369
+ type="radio"
1370
+ name="pickup-location"
1371
+ checked={selectedLocationId === location.id}
1372
+ onChange={() => handleLocationSelect(location.id)}
1373
+ className="mt-1 w-5 h-5 text-emerald-600 focus:ring-emerald-500"
1374
+ />
1375
+ <div className="flex-1">
1376
+ <p className="font-medium text-stone-900">
1377
+ {location.name}
1378
+ </p>
1379
+ <p className="text-sm text-stone-600 mt-1">
1380
+ {location.address}
1381
+ </p>
1382
+ {/* Only show distance/time for address searches (Case 3) */}
1383
+ {searchedLocation && !exactMatchLocationId && !cityFilter && (
1384
+ <div className="flex items-center gap-4 mt-2 text-sm text-stone-500">
1385
+ <span className="flex items-center gap-1">
1386
+ <svg
1387
+ className="w-4 h-4"
1388
+ fill="none"
1389
+ stroke="currentColor"
1390
+ viewBox="0 0 24 24"
1391
+ >
1392
+ <path
1393
+ strokeLinecap="round"
1394
+ strokeLinejoin="round"
1395
+ strokeWidth={2}
1396
+ d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
1397
+ />
1398
+ <path
1399
+ strokeLinecap="round"
1400
+ strokeLinejoin="round"
1401
+ strokeWidth={2}
1402
+ d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
1403
+ />
1404
+ </svg>
1405
+ {formatDistance(location.distance, useImperial)}
1406
+ </span>
1407
+ <span className="flex items-center gap-1">
1408
+ <svg
1409
+ className="w-4 h-4"
1410
+ fill="none"
1411
+ stroke="currentColor"
1412
+ viewBox="0 0 24 24"
1413
+ >
1414
+ <path
1415
+ strokeLinecap="round"
1416
+ strokeLinejoin="round"
1417
+ strokeWidth={2}
1418
+ d="M13 10V3L4 14h7v7l9-11h-7z"
1419
+ />
1420
+ </svg>
1421
+ {formatTime(location.walkingTime)} {t('pickup.walk')}
1422
+ </span>
1423
+ <span className="flex items-center gap-1">
1424
+ <svg
1425
+ className="w-4 h-4"
1426
+ fill="none"
1427
+ stroke="currentColor"
1428
+ viewBox="0 0 24 24"
1429
+ >
1430
+ <path
1431
+ strokeLinecap="round"
1432
+ strokeLinejoin="round"
1433
+ strokeWidth={2}
1434
+ d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"
1435
+ />
1436
+ </svg>
1437
+ {formatTime(location.drivingTime)} {t('pickup.drive')}
1438
+ </span>
1439
+ </div>
1440
+ )}
1441
+ </div>
1442
+ </label>
1443
+ ))}
1444
+ </div>
1445
+ <button
1446
+ onClick={() => setUseImperial(!useImperial)}
1447
+ className="text-sm text-emerald-600 hover:text-emerald-700 underline"
1448
+ >
1449
+ {t('pickup.switchUnits', { unit: useImperial ? t('pickup.metric') : t('pickup.imperial') })}
1450
+ </button>
1451
+ </div>
1452
+ )}
1453
+
1454
+ {/* Skip option */}
1455
+ <label className="flex items-start gap-3 cursor-pointer">
1456
+ <input
1457
+ type="radio"
1458
+ name="pickup-option"
1459
+ checked={isSkipped && selectedLocationId === null && searchedLocation === null}
1460
+ onChange={handleSkip}
1461
+ className="mt-1 w-5 h-5 text-emerald-600 focus:ring-emerald-500"
1462
+ aria-label={t('pickup.dontKnow')}
1463
+ />
1464
+ <div className="flex-1">
1465
+ <span className="font-medium text-stone-900">
1466
+ {t('pickup.dontKnow')}
1467
+ </span>
1468
+ <p className="text-sm text-stone-600 mt-1">
1469
+ {t('pickup.dontKnowSubtext')}
1470
+ </p>
1471
+ </div>
1472
+ </label>
1473
+ </div>
1474
+
1475
+ {/* Warning Modal - Only closes when "I understand" is clicked */}
1476
+ {showSkipWarning && (
1477
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
1478
+ <div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
1479
+ <h3 className="text-lg font-semibold text-stone-900 mb-4">
1480
+ {t('pickup.skipWarningTitle')}
1481
+ </h3>
1482
+ <p
1483
+ className="text-stone-700 mb-6"
1484
+ dangerouslySetInnerHTML={{ __html: t('pickup.skipWarningMessage') }}
1485
+ />
1486
+ <div className="flex justify-end">
1487
+ <button
1488
+ onClick={handleSkipConfirm}
1489
+ className="px-4 py-2 bg-emerald-600 text-white font-medium rounded-lg hover:bg-emerald-700 transition-colors"
1490
+ >
1491
+ {t('pickup.iUnderstand')}
1492
+ </button>
1493
+ </div>
1494
+ </div>
1495
+ </div>
1496
+ )}
1497
+ </div>
1498
+ );
1499
+ }
1500
+
1501
+ /**
1502
+ * Public component. Only loads the Google Maps script when NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
1503
+ * is set; otherwise renders a friendly message so embedded contexts (e.g. provider dashboard)
1504
+ * don't hit ApiProjectMapError.
1505
+ */
1506
+ export function PickupLocationSelector(props: PickupLocationSelectorProps) {
1507
+ const { t } = useTranslations();
1508
+ const { googleMapsApiKey: keyFromContext } = useBookingApp();
1509
+ const keyFromWindow = typeof window !== 'undefined' ? (window as unknown as { __TICKETBOOTH_GOOGLE_MAPS_API_KEY__?: string }).__TICKETBOOTH_GOOGLE_MAPS_API_KEY__ : undefined;
1510
+ const mapsKey = keyFromContext ?? keyFromWindow ?? ENV.GOOGLE_MAPS_API_KEY;
1511
+ const hasMapsKey = Boolean(mapsKey?.trim());
1512
+ const showMapsDebug =
1513
+ typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('maps_debug') === '1';
1514
+
1515
+ if (!hasMapsKey) {
1516
+ return (
1517
+ <div className="space-y-6">
1518
+ <h2 className="text-2xl font-bold text-stone-900">
1519
+ {props.isSkipped ? null : t('pickup.title')}
1520
+ </h2>
1521
+ <div className="p-4 bg-stone-50 border border-stone-200 rounded-lg text-stone-600 text-sm">
1522
+ Map unavailable (no API key). You can still complete your booking. Add{' '}
1523
+ <code className="bg-stone-200 px-1 rounded">NEXT_PUBLIC_GOOGLE_MAPS_API_KEY</code> to enable the map.
1524
+ {showMapsDebug && (
1525
+ <p className="mt-2 text-amber-700 text-xs">
1526
+ Debug: no API key from context, window, or build. Ensure NEXT_PUBLIC_GOOGLE_MAPS_API_KEY is set in Vercel
1527
+ (provider-dashboard), redeploy, then hard-refresh.
1528
+ </p>
1529
+ )}
1530
+ </div>
1531
+ {!props.isSkipped && (
1532
+ <p className="text-sm text-stone-500">
1533
+ Select a pickup location from the list, or skip this step.
1534
+ </p>
1535
+ )}
1536
+ </div>
1537
+ );
1538
+ }
1539
+
1540
+ return <PickupLocationSelectorWithMap {...props} />;
1541
+ }