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