1// Enhanced autocomplete with C#-driven dropdown data 2// Material Design 3 - Fixed dropdown positioning 3// Version: v65 - Section header styling (FAVORITES, AIRPORTS pop more) 4 5window.__gmVersion = "AddrEntry v83-BoldPrimaryText"; 6console.log("[GOOGLEMAPS-DROPDOWN] loaded", window.__gmVersion); 7 8// ============================================================ 9// SS NAMESPACE - Ensure it exists and add nearby location functions 10// ============================================================ 11window.ss = window.ss || {}; 12 13// Store nearby location from C# reverse geocoding 14window.ss._nearbyLocation = null; 15 16window.ss.setNearbyLocation = function (locationData) { 17 console.log('[SS] setNearbyLocation called:', locationData); 18 19 // Normalize property names: C# sends different names than JS expects 20 if (locationData) { 21 // address â formattedAddress 22 if (locationData.address && !locationData.formattedAddress) { 23 locationData.formattedAddress = locationData.address; 24 } 25 // lat â latitude 26 if (locationData.lat !== undefined && locationData.latitude === undefined) { 27 locationData.latitude = locationData.lat; 28 } 29 // lng â longitude 30 if (locationData.lng !== undefined && locationData.longitude === undefined) { 31 locationData.longitude = locationData.lng; 32 } 33 } 34 35 window.ss._nearbyLocation = locationData; 36 // locationData contains: { address, placeId, lat, lng } (normalized to formattedAddress, latitude, longitude) 37 38 // Dispatch event so any open dropdown can re-render with the new location 39 window.dispatchEvent(new CustomEvent('nearbyLocation:ready', { detail: locationData })); 40}; 41 42window.ss.getNearbyLocation = function () { 43 return window.ss._nearbyLocation; 44}; 45// ============================================================ 46 47// ============================================================ 48// KEYBOARD NAVIGATION STATE AND HELPERS 49// Enables Arrow Up/Down and Enter to select from autocomplete dropdown 50// ============================================================ 51window.__keyboardHighlightIndex = -1; 52 53/** 54 * Get all selectable dropdown items (excludes section headers, show-more, etc.) 55 */ 56window.__getSelectableItems = function (dropdown) { 57 if (!dropdown) { 58 console.log("[GMAPS] __getSelectableItems: dropdown is null"); 59 return []; 60 } 61 // Get dropdown-item elements that are NOT show-more links or non-selectable items 62 const items = Array.from(dropdown.querySelectorAll('.dropdown-item:not(.show-more-item)')); 63 console.log("[GMAPS] __getSelectableItems found:", items.length, "items"); 64 return items; 65}; 66 67/** 68 * Clear keyboard highlight from all items in dropdown 69 */ 70window.__clearKeyboardHighlight = function (dropdown) { 71 if (!dropdown) return; 72 dropdown.querySelectorAll('.dropdown-item.keyboard-highlight').forEach(item => { 73 item.classList.remove('keyboard-highlight'); 74 // Clear inline styles 75 item.style.backgroundColor = ''; 76 item.style.outline = ''; 77 item.style.outlineOffset = ''; 78 }); 79}; 80 81/** 82 * Highlight item at given index 83 */ 84window.__highlightItemAtIndex = function (dropdown, index, inputEl) { 85 if (!dropdown) { 86 console.log("[GMAPS] __highlightItemAtIndex: dropdown is null"); 87 return; 88 } 89 90 window.__clearKeyboardHighlight(dropdown); 91 92 const items = window.__getSelectableItems(dropdown); 93 console.log("[GMAPS] __highlightItemAtIndex:", { index, totalItems: items.length }); 94 95 if (index >= 0 && index < items.length) { 96 const item = items[index]; 97 item.classList.add('keyboard-highlight'); 98 99 // Apply inline styles directly for guaranteed visibility 100 item.style.backgroundColor = '#e8f0fe'; 101 item.style.outline = '2px solid #1a73e8'; 102 item.style.outlineOffset = '-2px'; 103 104 // Set ID for aria-activedescendant 105 const itemId = 'autocomplete-item-' + index; 106 item.id = itemId; 107 item.setAttribute('aria-selected', 'true'); 108 109 // Update input's aria-activedescendant for screen reader announcement 110 if (inputEl) { 111 inputEl.setAttribute('aria-activedescendant', itemId); 112 } 113 114 console.log("[GMAPS] Added keyboard-highlight to item:", item.getAttribute('aria-label') || item.textContent?.substring(0, 50)); 115 116 // Scroll item into view if needed 117 item.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
118 } else if (inputEl) { 119 // Clear aria-activedescendant when no item is highlighted 120 inputEl.removeAttribute('aria-activedescendant'); 121 } 122}; 123 124/** 125 * Reset keyboard navigation state 126 */ 127window.__resetKeyboardNavigation = function (dropdown) { 128 window.__keyboardHighlightIndex = -1; 129 if (dropdown) { 130 window.__clearKeyboardHighlight(dropdown); 131 } 132}; 133// ============================================================ 134 135// PROGRESSIVE LOCATION STRATEGY: 136// 1. App start: NO location request (no permission prompt) 137// 2. "Book a Reservation" click: Request location (valid user gesture on main page) 138// 3. Modal opens: Use pre-fetched location from C# (Safari iOS doesn't allow new requests in modal) 139// 4. "Use Current Location" click: Use cached location, only request fresh if nothing cached 140 141/** 142 * Initialize autocomplete with pre-prepared dropdown data from C# 143 * @param {HTMLElement} inputEl - The input element 144 * @param {object} dropdownData - Complete dropdown structure from C# 145 * @param {object} allAirportsData - Full airport list for "View all" functionality 146 * @param {object} dotNetRef - .NET object reference for callbacks 147 * @param {string} callbackName - Method name to invoke on selection 148 * @param {object} locationBias - Bias settings for Google Places (includes deviceAccuracy, deviceLatitude, deviceLongitude, mode, referenceLatitude, referenceLongitude) 149 */ 150window.initAutocompleteWithDropdown = function (inputEl, dropdownData, allAirportsData, dotNetRef, callbackName, locationBias) { 151 if (!inputEl) { 152 console.error('[GMAPS] initAutocompleteWithDropdown: input element is null'); 153 return; 154 } 155 156 console.log("[GMAPS] initAutocompleteWithDropdown v59-SafariIOSLocationFix", { 157 sections: dropdownData?.sections?.length, 158 allAirports: allAirportsData?.length, 159 callback: callbackName, 160 deviceAccuracy: locationBias?.deviceAccuracy, 161 hasDeviceLocation: !!(locationBias?.deviceLatitude && locationBias?.deviceLongitude), 162 mode: locationBias?.mode, 163 hasReferenceLocation: !!(locationBias?.referenceLatitude && locationBias?.referenceLongitude) 164 }); 165 166 // If we have device location from C#, store it in global cache for consistency 167 if (locationBias?.deviceLatitude && locationBias?.deviceLongitude) { 168 window.__lastGeo = { 169 lat: locationBias.deviceLatitude, 170 lng: locationBias.deviceLongitude, 171 accuracy: locationBias.deviceAccuracy || 30 172 }; 173 console.log("[GMAPS] Stored C# location in global cache:", window.__lastGeo); 174 } 175 176 // Request COARSE location in background for autocomplete bias (silent, no UI) 177 // This happens when modal opens - doesn't block or show loading 178 // NOTE: This may fail on Safari iOS in modal context, but that's OK - we have C# location 179 if (!window.__lastGeo && navigator.geolocation) { 180 console.log("[GMAPS] Requesting coarse location for autocomplete bias (background)..."); 181 navigator.geolocation.getCurrentPosition( 182 (position) => { 183 window.__lastGeo = { 184 lat: position.coords.latitude, 185 lng: position.coords.longitude, 186 accuracy: position.coords.accuracy 187 }; 188 console.log("[GMAPS] Got coarse location for bias:", { 189 lat: position.coords.latitude, 190 lng: position.coords.longitude, 191 accuracy: position.coords.accuracy + "m" 192 }); 193 194 // Dispatch event for other components 195 window.dispatchEvent(new CustomEvent('geo:ready', { 196 detail: window.__lastGeo 197 })); 198 }, 199 (error) => { 200 // Silent fail - not critical, just improves autocomplete 201 console.log("[GMAPS] Coarse location not available (not critical):", error.message); 202 }, 203 {
204 enableHighAccuracy: false, // COARSE - WiFi/cell tower (~3-5 seconds) 205 timeout: 10000, 206 maximumAge: 300000 // 5 minutes cache 207 } 208 ); 209 } 210 211 // Listen for location updates (in case location arrives after dropdown initializes) 212 const onGeoReady = (event) => { 213 console.log("[GMAPS] Location became available, rebuilding nearby airports"); 214 215 const geoData = event.detail || window.__lastGeo; 216 if (!geoData || !geoData.lat || !geoData.lng) { 217 console.warn("[GMAPS] No valid location data in geo:ready event"); 218 return; 219 } 220 221 // Check if this input is still focused and has no value 222 if (document.activeElement === inputEl && !inputEl.value.trim()) { 223 console.log("[GMAPS] Rebuilding dropdown with nearby airports based on location"); 224 225 // Rebuild dropdown with nearby airports 226 rebuildDropdownWithLocation(geoData.lat, geoData.lng); 227 } 228 }; 229 230 // Listen once for geo:ready event 231 window.addEventListener('geo:ready', onGeoReady, { once: true }); 232 233 // Listen for nearby location updates (reverse geocoding completed after modal opened) 234 const onNearbyLocationReady = (event) => { 235 // Update our local variables from the global cache (already normalized in setNearbyLocation) 236 nearbyLocation = window.ss._nearbyLocation; 237 hasNearbyLocation = nearbyLocation && nearbyLocation.formattedAddress; 238 239 console.log("[GMAPS] Nearby location became available:", nearbyLocation?.formattedAddress, "hasNearbyLocation:", hasNearbyLocation); 240 241 // Only re-render if input is focused and user hasn't typed anything 242 if (document.activeElement === inputEl && !inputEl.value.trim()) { 243 console.log("[GMAPS] Re-rendering dropdown with 'Your Location'"); 244 renderDropdown(originalDropdownData); 245 } 246 }; 247 248 // Listen for nearbyLocation:ready event (can fire multiple times if location updates) 249 window.addEventListener('nearbyLocation:ready', onNearbyLocationReady); 250 251 // Store reference for cleanup 252 inputEl.__nearbyLocationHandler = onNearbyLocationReady; 253 254 // Function to rebuild dropdown with nearby airports when location becomes available 255 function rebuildDropdownWithLocation(lat, lng) { 256 if (!originalDropdownData || !originalDropdownData.sections || !allAirportsData) { 257 console.log("[GMAPS] Cannot rebuild - missing data"); 258 return; 259 } 260 261 console.log("[GMAPS] Rebuilding with location:", lat, lng); 262 263 // Filter to serviced airports only, then calculate distances 264 const servicedAirports = allAirportsData.filter(airport => isAirportServiced(airport)); 265 console.log("[GMAPS] Serviced airports:", servicedAirports.length, "of", allAirportsData.length); 266 267 const airportsWithDistance = servicedAirports.map(airport => { 268 const distance = calculateDistance(lat, lng, airport.latitude, airport.longitude); 269 return { ...airport, distance }; 270 }); 271 272 // Sort by distance, then by major status 273 airportsWithDistance.sort((a, b) => { 274 // Major airports first 275 const aMajor = a.isMajorAirport || a.IsMajorAirport; 276 const bMajor = b.isMajorAirport || b.IsMajorAirport; 277 if (aMajor && !bMajor) return -1; 278 if (!aMajor && bMajor) return 1; 279 280 // Then by distance 281 return a.distance - b.distance; 282 }); 283 284 // Get nearby airports (within ~120 miles / 200km) 285 const nearbyAirports = airportsWithDistance 286 .filter(a => a.distance <= 120) 287 .slice(0, 6); 288 289 // Start with nearby airports 290 let displayAirports = [...nearbyAirports]; 291 292 // If fewer than 6 nearby, fill with top-ranked airports 293 if (displayAirports.length < 6) { 294 // Get airports not already in the nearby list, sorted by rank 295 const nearbyIds = new Set(nearbyAirports.map(a => (a.airportID || a.AirportID || '').toUpperCase())); 296 297 let rankedAirports = servicedAirports 298 .filter(a => { 299 const id = (a.airportID || a.AirportID || '').toUpperCase(); 300 return !nearbyIds.has(id); 301 }) 302 .map(a => ({ 303 ...a, 304 distance: calculateDistance(lat, lng, a.latitude, a.longitude) 305 })); 306 307 // For dropoff mode, limit ranked fill airports to 200 miles 308 if (isDropoffMode) { 309 rankedAirports = rankedAirports.filter(a => a.distance <= 200); 310 } 311 312 rankedAirports = rankedAirports.sort((a, b) => { 313 // Sort by AirportRank (lower is better, 999 is unranked) 314 const rankA = a.airportRank ?? a.AirportRank ?? 999; 315 const rankB = b.airportRank ?? b.AirportRank ?? 999; 316 return rankA - rankB; 317 }); 318 319 // Fill up to 6 total
320 const needed = 6 - displayAirports.length; 321 const topRanked = rankedAirports.slice(0, needed); 322 displayAirports = [...displayAirports, ...topRanked]; 323 324 console.log("[GMAPS] Filled airports:", displayAirports.length, 325 "(", nearbyAirports.length, "nearby +", topRanked.length, "ranked)", 326 isDropoffMode ? "(dropoff mode - 200mi limit)" : ""); 327 } 328 329 // If still no airports (edge case), show top 6 by rank (but respect dropoff distance limit) 330 if (displayAirports.length === 0) { 331 console.log("[GMAPS] No nearby airports, showing top ranked"); 332 let fallbackAirports = servicedAirports 333 .map(a => ({ 334 ...a, 335 distance: calculateDistance(lat, lng, a.latitude, a.longitude) 336 })); 337 338 // For dropoff mode, still limit to 200 miles 339 if (isDropoffMode) { 340 fallbackAirports = fallbackAirports.filter(a => a.distance <= 200); 341 } 342 343 displayAirports = fallbackAirports 344 .sort((a, b) => { 345 const rankA = a.airportRank ?? a.AirportRank ?? 999; 346 const rankB = b.airportRank ?? b.AirportRank ?? 999; 347 return rankA - rankB; 348 }) 349 .slice(0, 6); 350 } 351 352 console.log("[GMAPS] Final airport list:", displayAirports.map(a => a.airportID || a.AirportID).join(", ")); 353 354 // Create updated dropdown data with airports section + View All link 355 const airportItems = displayAirports.map(airport => ({ 356 type: "airport", 357 icon: getPortIcon(airport.portType || airport.PortType, isDropoffMode ? 1 : 0), 358 primaryText: `${airport.airportID || airport.AirportID}`, 359 secondaryText: `${airport.name || airport.Name || ''} - ${airport.distance.toFixed(0)} mi`, 360 data: airport 361 })); 362 363 // Add "View All Airports" link 364 airportItems.push({ 365 type: "view_all_airports", 366 icon: "arrow_forward", 367 primaryText: "View all airports", 368 secondaryText: "", 369 isAction: true 370 }); 371 372 const updatedData = { 373 sections: [ 374 // Keep existing sections (favorites, etc.) but add/update airports 375 ...originalDropdownData.sections.filter(s => s.type !== 'airports'), 376 { 377 title: "Airports", 378 type: "airports", 379 icon: "flight", 380 items: airportItems 381 } 382 ] 383 }; 384 385 // Store updated data as new original 386 originalDropdownData = updatedData; 387 388 // Re-render dropdown 389 renderDropdown(updatedData); 390 } 391 392 // Detect if mobile 393 const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || window.innerWidth <= 768; 394 395 // Get mode (Pickup = 0, Dropoff = 1) 396 const isDropoffMode = locationBias?.mode === 1; 397 398 // Get reference location for distance filtering 399 const referenceLocation = (locationBias?.referenceLatitude && locationBias?.referenceLongitude) 400 ? { lat: locationBias.referenceLatitude, lng: locationBias.referenceLongitude } 401 : null; 402 403 // Check if we already have a precise location from parent component (â¤30m accuracy) 404 const hasGoodLocation = locationBias?.deviceLatitude && 405 locationBias?.deviceLongitude && 406 locationBias?.deviceAccuracy && 407 locationBias.deviceAccuracy <= 30; 408 409 // Check if we have a reverse-geocoded nearby location from C# 410 // Use 'let' so we can update from global cache if it arrives later 411 let nearbyLocation = locationBias?.nearbyLocation; 412 let hasNearbyLocation = nearbyLocation && nearbyLocation.formattedAddress; 413 414 // Check if we should hide "Your Location" option (for dropoff when pickup is > 200 miles away) 415 const hideCurrentLocation = locationBias?.hideCurrentLocation === true; 416 417 console.log("[GMAPS] Configuration:", { 418 isMobile, 419 isDropoffMode, 420 suppressAirports: locationBias?.suppressAirports, 421 hasReferenceLocation: !!referenceLocation, 422 hasGoodLocation, 423 deviceAccuracy: locationBias?.deviceAccuracy, 424 hasNearbyLocation, 425 nearbyAddress: nearbyLocation?.formattedAddress, 426 hideCurrentLocation 427 });
428 429 // Additional debug logging 430 console.log("[GMAPS] Location state detail:", { 431 "locationBias.deviceLatitude": locationBias?.deviceLatitude, 432 "locationBias.deviceLongitude": locationBias?.deviceLongitude, 433 "locationBias.deviceAccuracy": locationBias?.deviceAccuracy, 434 "locationBias.nearbyLocation": locationBias?.nearbyLocation ? "exists" : "null", 435 "nearbyLocation.formattedAddress": nearbyLocation?.formattedAddress || "n/a" 436 }); 437 438 // Find the .address-field container (the float-field wrapper) 439 // This is the positioning anchor for the dropdown 440 const fieldContainer = inputEl.closest('.address-field') || inputEl.closest('.float-field') || inputEl.parentElement; 441 442 // Ensure the container has relative positioning 443 fieldContainer.style.position = "relative"; 444 445 // Create dropdown menu - append to field container for proper positioning 446 const menu = document.createElement("div"); 447 menu.className = "autocomplete-dropdown"; 448 menu.setAttribute("role", "listbox"); 449 menu.setAttribute("aria-label", "Address suggestions"); 450 451 // Function to calculate and set dropdown height 452 const setDropdownHeight = () => { 453 const inputRect = inputEl.getBoundingClientRect(); 454 const modalContent = inputEl.closest('.address-dialog-content'); 455 456 let availableHeight; 457 if (modalContent) { 458 const modalRect = modalContent.getBoundingClientRect(); 459 availableHeight = modalRect.bottom - inputRect.bottom - 100; 460 console.log('[GMAPS] Dropdown sizing (modal-based):', { 461 inputBottom: inputRect.bottom, 462 modalBottom: modalRect.bottom, 463 modalHeight: modalRect.height, 464 calculated: availableHeight 465 }); 466 } else { 467 availableHeight = window.innerHeight - inputRect.bottom - 150; 468 console.log('[GMAPS] Dropdown sizing (viewport-based):', { 469 viewportHeight: window.innerHeight, 470 inputBottom: inputRect.bottom, 471 calculated: availableHeight 472 }); 473 } 474 475 const dropdownMaxHeight = Math.max(350, Math.min(availableHeight, 600)); 476 console.log('[GMAPS] Final dropdown height:', dropdownMaxHeight); 477 478 // Update the max-height 479 menu.style.maxHeight = `${dropdownMaxHeight}px`; 480 return dropdownMaxHeight; 481 }; 482 483 // Initial calculation and styling 484 const initialHeight = setDropdownHeight(); 485 486 // Apply critical positioning styles inline 487 menu.style.cssText = ` 488 position: absolute; 489 top: 100%; 490 left: 0; 491 width: 100%; 492 background: white; 493 box-shadow: 0 4px 12px rgba(0,0,0,0.15); 494 border-radius: 0 0 8px 8px; 495 overflow-y: auto; 496 z-index: 10000; 497 max-height: ${initialHeight}px; 498 `; 499 500 // Track if menu is attached 501 if (!window.__acMenus) window.__acMenus = new Set(); 502 window.__acMenus.add(menu); 503 fieldContainer.appendChild(menu); 504 505 // Set up ARIA attributes on input for screen reader support 506 // NOTE: On touch devices, skip role="combobox" â iPadOS Safari suppresses the 507 // virtual keyboard for combobox inputs, expecting a native picker instead. 508 const menuId = 'autocomplete-listbox-' + Math.random().toString(36).substr(2, 9); 509 menu.id = menuId; 510 if (navigator.maxTouchPoints === 0) { 511 inputEl.setAttribute('role', 'combobox'); 512 inputEl.setAttribute('aria-haspopup', 'listbox'); 513 } 514 inputEl.setAttribute('aria-autocomplete', 'list'); 515 inputEl.setAttribute('aria-controls', menuId); 516 inputEl.setAttribute('aria-expanded', 'false'); 517 518 // Handle window resize 519 let resizeTimeout; 520 window.addEventListener('resize', () => { 521 clearTimeout(resizeTimeout); 522 resizeTimeout = setTimeout(() => { 523 if (menu.parentElement) { 524 setDropdownHeight(); 525 } 526 }, 100); 527 }); 528 529 // Keep original dropdown data for reset 530 let originalDropdownData = JSON.parse(JSON.stringify(dropdownData || {})); 531 532 // Store state on input element for external updates (e.g., when pickup changes) 533 inputEl.__acState = { 534 dropdownData: originalDropdownData, 535 allAirportsData: allAirportsData, 536 locationBias: locationBias, 537 // Update function to modify closure variables 538 updateData: function(newDropdownData, newAirportsData, newLocationBias) { 539 originalDropdownData = JSON.parse(JSON.stringify(newDropdownData || {})); 540 allAirportsData = newAirportsData; 541 // Update locationBias properties that affect filtering 542 if (newLocationBias) { 543 locationBias = { ...locationBias, ...newLocationBias }; 544 } 545 console.log("[GMAPS] Updated autocomplete state:", { 546 sections: originalDropdownData?.sections?.length, 547 airports: allAirportsData?.length, 548 suppressAirports: locationBias?.suppressAirports 549 }); 550
551 // If the initial quick-picks dropdown is currently open on an empty input 552 // (e.g., focus landed on Dropoff right as the pickup-based airports were 553 // rebuilt), re-render it now so the user sees the updated nearby airports 554 // immediately instead of stale data. Don't touch it while the user is typing, 555 // so live search results aren't clobbered. 556 try { 557 const isOpen = menu && menu.parentElement && menu.children.length > 0; 558 if (isOpen && inputEl.value.trim().length === 0) { 559 console.log("[GMAPS] Re-rendering open quick-picks dropdown with updated data"); 560 renderDropdown(originalDropdownData); 561 } 562 } catch (e) { 563 console.warn("[GMAPS] Could not re-render dropdown after update:", e); 564 } 565 } 566 }; 567 568 // Google Places Autocomplete Service 569 let autocompleteService = null; 570 if (window.google && google.maps && google.maps.places) { 571 autocompleteService = new google.maps.places.AutocompleteService(); 572 } 573 574 // Extract unique country codes from serviced airports for Google Places restriction 575 // Google Places API allows up to 5 countries in componentRestrictions 576 let servicedCountryCodes = []; 577 if (allAirportsData && allAirportsData.length > 0) { 578 const uniqueCountries = new Set(); 579 allAirportsData.forEach(airport => { 580 // Only include serviced airports 581 if (isAirportServiced(airport)) { 582 const code = (airport.countryCode || airport.CountryCode || '').toUpperCase(); 583 if (code && code.length === 2) { 584 uniqueCountries.add(code); 585 } 586 } 587 }); 588 servicedCountryCodes = Array.from(uniqueCountries).slice(0, 5); // Google allows max 5 589 console.log("[GMAPS] Country restriction from serviced airports:", servicedCountryCodes); 590 } 591 592 // Render dropdown with dynamic data 593 function renderDropdown(data) { 594 // Update aria-expanded when dropdown has content 595 inputEl.setAttribute('aria-expanded', 'true'); 596 597 // Fallback: check global cache if we don't have nearby location from init 598 // This handles the case where reverse geocoding completes after modal opens 599 if (!nearbyLocation && window.ss._nearbyLocation) { 600 nearbyLocation = window.ss._nearbyLocation; 601 // Normalize property names (should already be normalized in setNearbyLocation, but be safe) 602 if (nearbyLocation.address && !nearbyLocation.formattedAddress) { 603 nearbyLocation.formattedAddress = nearbyLocation.address; 604 } 605 if (nearbyLocation.lat !== undefined && nearbyLocation.latitude === undefined) { 606 nearbyLocation.latitude = nearbyLocation.lat; 607 } 608 if (nearbyLocation.lng !== undefined && nearbyLocation.longitude === undefined) { 609 nearbyLocation.longitude = nearbyLocation.lng; 610 } 611 hasNearbyLocation = nearbyLocation && nearbyLocation.formattedAddress; 612 console.log("[GMAPS] Picked up nearby location from global cache:", nearbyLocation?.formattedAddress); 613 } 614 615 // Recalculate max-height now that modal is positioned 616 setDropdownHeight(); 617 menu.innerHTML = ""; 618 619 // Reset keyboard navigation when dropdown content changes 620 window.__resetKeyboardNavigation(menu); 621 622 console.log("[GMAPS] renderDropdown called, sections:", data?.sections?.length); 623 624 // Determine what location option to show at the top 625 const isTyping = inputEl.value.trim().length > 0; 626 627 // Check if we should defer "Your Location" to after airports 628 // (for dropoff mode when pickup was not an airport) 629 const pickupWasNotAirport = !locationBias?.suppressAirports; 630 const deferYourLocation = isDropoffMode && pickupWasNotAirport; 631 632 console.log("[GMAPS] Location option decision:", { 633 isTyping, 634 hasNearbyLocation, 635 hideCurrentLocation, 636 deferYourLocation, 637 willShow: hideCurrentLocation ? "hidden (pickup > 200mi from current location)" : 638 (!isTyping && hasNearbyLocation ? (deferYourLocation ? "Your Location (after airports)" : "Your Location (at top)") : "nothing") 639 }); 640 641 // Show "Your Location" at top UNLESS we're deferring it to after airports OR hiding due to distance 642 if (!isTyping && hasNearbyLocation && !deferYourLocation && !hideCurrentLocation) { 643 // We have a reverse-geocoded address - show "Your Location" with the address 644 console.log("[GMAPS] â Showing 'Your Location' item at top:", nearbyLocation?.formattedAddress); 645 const yourLocationItem = createYourLocationItem(nearbyLocation); 646 menu.appendChild(yourLocationItem); 647 } 648 649 // Now check if we have any sections to render
650 if (!data || !data.sections || data.sections.length === 0) { 651 console.log("[GMAPS] No sections to render"); 652 return; 653 } 654 655 // Reorder sections based on pickup type: 656 // - If pickup was NOT an airport: put airports first (most trips are to/from airports) 657 // - If pickup WAS an airport: put airports last (airport-to-airport trips are rare) 658 let sectionsToRender = data.sections; 659 660 // Helper to identify airports section (check type OR title) 661 const isAirportsSection = (s) => 662 s.type === "airports" || 663 s.type === "Airports" || 664 (s.title && s.title.toLowerCase() === "airports"); 665 666 const pickupWasAirport = !pickupWasNotAirport; 667 668 console.log("[GMAPS] Section reorder check:", { 669 isDropoffMode, 670 suppressAirports: locationBias?.suppressAirports, 671 pickupWasNotAirport, 672 pickupWasAirport, 673 shouldPrioritizeAirports: isDropoffMode && pickupWasNotAirport, 674 shouldDeprioritizeAirports: isDropoffMode && pickupWasAirport, 675 sections: data.sections.map(s => ({ type: s.type, title: s.title, isAirports: isAirportsSection(s) })) 676 }); 677 678 // Track if we've rendered "Your Location" after airports 679 let yourLocationRendered = false; 680 681 if (isDropoffMode && pickupWasNotAirport) { 682 console.log("[GMAPS] Dropoff mode with non-airport pickup - prioritizing airports in dropdown"); 683 // Sort sections: airports first, then everything else in original order 684 sectionsToRender = [...data.sections].sort((a, b) => { 685 const aIsAirports = isAirportsSection(a); 686 const bIsAirports = isAirportsSection(b); 687 if (aIsAirports && !bIsAirports) return -1; 688 if (!aIsAirports && bIsAirports) return 1; 689 return 0; // Keep original order for non-airport sections 690 }); 691 console.log("[GMAPS] Reordered sections:", sectionsToRender.map(s => s.title || s.type)); 692 } else if (isDropoffMode && pickupWasAirport) { 693 console.log("[GMAPS] Dropoff mode with airport pickup - moving airports to end of dropdown"); 694 // Sort sections: airports last, everything else keeps original order 695 sectionsToRender = [...data.sections].sort((a, b) => { 696 const aIsAirports = isAirportsSection(a); 697 const bIsAirports = isAirportsSection(b); 698 if (aIsAirports && !bIsAirports) return 1; // airports go to end 699 if (!aIsAirports && bIsAirports) return -1; // non-airports stay before 700 return 0; // Keep original order within same type 701 }); 702 console.log("[GMAPS] Reordered sections:", sectionsToRender.map(s => s.title || s.type)); 703 } 704 705 // Show "Quick Picks" banner when user hasn't typed anything yet 706 if (!isTyping && sectionsToRender.length > 0) { 707 const quickPicksBanner = document.createElement("div"); 708 quickPicksBanner.className = "quick-picks-banner"; 709 quickPicksBanner.style.cssText = ` 710 padding: 8px 14px 6px; 711 font-size: 11px; 712 border-bottom: 1px solid #e2e8f0; 713 display: flex; 714 align-items: center; 715 justify-content: flex-start; 716 gap: 5px; 717 `; 718 const label = document.createElement("span"); 719 label.style.cssText = "font-weight: 500; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;"; 720 label.textContent = "Quick picks"; 721 const hint = document.createElement("span"); 722 hint.style.cssText = "font-weight: 400; color: #b0b7c3; font-size: 11px;"; 723 hint.textContent = "â or type any address above"; 724 quickPicksBanner.appendChild(label); 725 quickPicksBanner.appendChild(hint); 726 menu.appendChild(quickPicksBanner); 727 } 728 729 sectionsToRender.forEach((section, index) => { 730 // Skip empty sections 731 if (!section.items || section.items.length === 0) return; 732 733 // Skip "nearby" section - JS already shows this as "Your Location" item 734 // This prevents duplicate location entries in the dropdown 735 if (section.type === "nearby" || section.type === "nearby_location" || 736 (section.title && section.title.toLowerCase().includes("nearby"))) { 737 console.log("[GMAPS] Skipping nearby section - already shown as 'Your Location' item"); 738 return; 739 } 740 741 // Filter items based on distance for dropoff mode 742 let filteredItems = section.items; 743 744 // For dropoff mode, filter favorites/nearby if > 200 miles from reference location 745 if (isDropoffMode && referenceLocation && (section.type === "favorites" || section.type === "nearby")) { 746 filteredItems = section.items.filter(item => { 747 // Get item location 748 const itemLat = item.data?.latitude || item.data?.Latitude; 749 const itemLng = item.data?.longitude || item.data?.Longitude; 750 751 if (itemLat && itemLng) { 752 const distance = calculateDistance(referenceLocation.lat, referenceLocation.lng, itemLat, itemLng); 753 console.log(`[GMAPS] Distance check for ${item.primaryText}: ${distance.toFixed(1)} miles`); 754 return distance <= 200; 755 } 756 757 // If no coordinates, include it (let user decide) 758 return true; 759 }); 760 761 console.log(`[GMAPS] Dropoff filtering: ${section.items.length} â ${filteredItems.length} items (within 200 miles)`); 762 } 763 764 // Skip if all items filtered out 765 if (filteredItems.length === 0) return; 766 767 const sectionDiv = document.createElement("div"); 768 sectionDiv.className = "dropdown-section"; 769 sectionDiv.style.cssText = "border-bottom: 1px solid #f1f3f4;"; 770 771 // Add grid layout for airports and favorites 772 if (section.type === "airports") { 773 sectionDiv.classList.add("airports-grid"); 774 } else if (section.type === "favorites") { 775 sectionDiv.classList.add("favorites-grid"); 776 } else if (section.type === "landmarks") { 777 sectionDiv.classList.add("landmarks-grid"); 778 } 779 780 // Section header (if title exists) - prominent styling 781 if (section.title) { 782 const header = document.createElement("div"); 783 header.className = "section-header"; 784 header.style.cssText = ` 785 padding: 10px 14px 6px; 786 font-size: 11px; 787 font-weight: 700; 788 color: #102B5B; 789 text-transform: uppercase; 790 letter-spacing: 0.8px; 791 background: linear-gradient(to bottom, #e8eef5, #f4f6f9); 792 border-left: 3px solid #2963A8; 793 display: flex; 794 align-items: center; 795 gap: 6px; 796 `; 797 798 // Add icon based on section type 799 if (section.icon) { 800 const icon = document.createElement("span"); 801 icon.className = "material-icons"; 802 icon.textContent = getMaterialIcon(section.icon); 803 icon.style.cssText = "font-size: 16px; color: #2963A8;"; 804 header.appendChild(icon); 805 } 806 807 const titleSpan = document.createElement("span"); 808 titleSpan.textContent = section.title; 809 header.appendChild(titleSpan); 810 811 sectionDiv.appendChild(header); 812 } 813 814 // Items container 815 const itemsContainer = document.createElement("div"); 816 itemsContainer.className = "dropdown-items"; 817 818 // Apply layout based on section type 819 if (section.type === "airports") { 820 // Single column layout for airports (to show city, state) 821 itemsContainer.style.cssText = ` 822 display: flex; 823 flex-direction: column; 824 gap: 0; 825 padding: 0; 826 `; 827 } else if (section.type === "favorites") { 828 // Horizontal flex layout for favorites (single row) - compact 829 itemsContainer.style.cssText = ` 830 display: flex; 831 flex-wrap: nowrap; 832 overflow-x: auto; 833 gap: 4px; 834 padding: 4px; 835 `; 836 } else if (section.type === "landmarks") { 837 // Vertical list layout for landmarks (hotels) - show full address 838 itemsContainer.style.cssText = ` 839 display: flex; 840 flex-direction: column; 841 gap: 0; 842 padding: 0; 843 `; 844 } 845 846 // Determine if we should limit favorites display 847 const isFavoritesSection = section.type === "favorites" || 848 (section.title && section.title.toLowerCase().includes("favorite")); 849 const shouldLimitFavorites = isFavoritesSection && filteredItems.length > 2; 850 851 // Show first 2 favorites, or all if not limiting 852 const itemsToShow = shouldLimitFavorites ? filteredItems.slice(0, 2) : filteredItems; 853 const hiddenItemsCount = shouldLimitFavorites ? filteredItems.length - 2 : 0; 854 855 // Render visible items 856 itemsToShow.forEach(item => { 857 const itemEl = createDropdownItem(item); 858 itemsContainer.appendChild(itemEl); 859 }); 860 861 // Add "Show More Favorites" link if needed 862 if (shouldLimitFavorites) { 863 const showMoreItem = createShowMoreFavoritesItem(hiddenItemsCount, () => { 864 // Clear and show all favorites 865 itemsContainer.innerHTML = "";
866 filteredItems.forEach(item => { 867 const itemEl = createDropdownItem(item); 868 itemsContainer.appendChild(itemEl); 869 }); 870 }); 871 itemsContainer.appendChild(showMoreItem); 872 } 873 874 sectionDiv.appendChild(itemsContainer); 875 menu.appendChild(sectionDiv); 876 877 // Insert "Your Location" after airports section when deferred (and not hidden due to distance) 878 if (deferYourLocation && !yourLocationRendered && !isTyping && hasNearbyLocation && !hideCurrentLocation && isAirportsSection(section)) { 879 console.log("[GMAPS] â Inserting 'Your Location' after airports section"); 880 const yourLocationItem = createYourLocationItem(nearbyLocation); 881 menu.appendChild(yourLocationItem); 882 yourLocationRendered = true; 883 } 884 }); 885 886 console.log("[GMAPS] Dropdown rendered"); 887 } 888 889 // Calculate distance between two points in miles using Haversine formula 890 function calculateDistance(lat1, lon1, lat2, lon2) { 891 const R = 3959; // Earth's radius in miles 892 const dLat = (lat2 - lat1) * Math.PI / 180; 893 const dLon = (lon2 - lon1) * Math.PI / 180; 894 const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + 895 Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * 896 Math.sin(dLon / 2) * Math.sin(dLon / 2); 897 const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); 898 return R * c; 899 } 900 901 // Get Material Icon name 902 function getMaterialIcon(iconName) { 903 const iconMap = { 904 'star': 'star', 905 'favorite': 'favorite', 906 'place': 'place', 907 'location_on': 'location_on', 908 'flight_land': 'flight_land', 909 'flight_takeoff': 'flight_takeoff', 910 'flight': 'flight', 911 'local_airport': 'flight', 912 'arrow_forward': 'arrow_forward', 913 'link': 'arrow_forward', 914 'near_me': 'near_me', 915 'my_location': 'my_location', 916 'search': 'search', 917 'history': 'history', 918 'directions_boat': 'directions_boat', 919 'train': 'train', 920 'hotel': 'hotel', 921 'business': 'business', 922 'event': 'event', 923 'restaurant': 'restaurant' 924 }; 925 return iconMap[iconName] || iconName || 'place'; 926 } 927 928 // Get icon based on port type (0=airport, 1=cruise terminal, 2=train station) 929 // For airports, uses mode to determine direction (pickup=landing, dropoff=takeoff) 930 function getPortIcon(portType, mode) { 931 const type = parseInt(portType, 10); 932 switch (type) { 933 case 1: return 'directions_boat'; // Cruise terminal 934 case 2: return 'train'; // Train station 935 case 0: // Airport 936 default: 937 // mode 0 = pickup (landing), mode 1 = dropoff (takeoff) 938 return mode === 1 ? 'flight_takeoff' : 'flight_land'; 939 } 940 } 941 942 // Check if airport/port is serviced 943 function isAirportServiced(airport) { 944 if (!airport) { 945 console.log("[GMAPS] isAirportServiced: airport is null/undefined"); 946 return false; 947 } 948 949 // Use serviced property (single source of truth) 950 const serviced = airport.serviced ?? airport.Serviced; 951 const airportID = airport.airportID || airport.AirportID; 952 953 //console.log("[GMAPS] isAirportServiced:", airportID, "serviced=", serviced, "type=", typeof serviced); 954 955 // Handle boolean, string, or number values 956 if (typeof serviced === 'boolean') { 957 return serviced === true; 958 } else if (typeof serviced === 'string') { 959 return serviced.toLowerCase() === 'true' || serviced === '1'; 960 } else if (typeof serviced === 'number') { 961 return serviced === 1; 962 } 963 964 //console.log("[GMAPS] isAirportServiced:", airportID, "- serviced not found, assuming false"); 965 return false; 966 } 967 968 // Helper function to build full address from data object 969 function buildFullAddress(data) { 970 if (!data) return null; 971 972 console.log("[GMAPS] buildFullAddress - input data:", JSON.stringify(data).substring(0, 500)); 973 974 const parts = []; 975 976 // Street address (try multiple property names from different models) 977 const street = data.streetAddress || data.StreetAddress || data.street_address || 978 data.address || data.Address || data.formatted_address || data.FormattedAddress; 979 if (street) parts.push(street); 980 981 // City (MemberLocationModel uses "City", PlaceDetailsModel uses "city") 982 const city = data.city || data.City; 983 if (city) parts.push(city); 984 985 // State 986 const state = data.state || data.State; 987 if (state) parts.push(state); 988 989 // Zip (MemberLocationModel uses "Zip", PlaceDetailsModel uses "zipCode" or "ZipCode") 990 const zip = data.zip || data.Zip || data.zipCode || data.ZipCode || data.postal_code; 991 if (zip) parts.push(zip); 992 993 const result = parts.length > 1 ? parts.join(', ') : null; // Only return if we have more than just street 994 console.log("[GMAPS] buildFullAddress - result:", result); 995 return result; 996 } 997 998 // Create "Current Location" item (always shown as first item, compact single-line) 999 function createCurrentLocationItem() { 1000 const itemDiv = document.createElement("div"); 1001 itemDiv.className = "dropdown-item current-location-item"; 1002 itemDiv.style.cssText = ` 1003 padding: 6px 14px; 1004 cursor: pointer; 1005 display: flex; 1006 align-items: center; 1007 gap: 8px; 1008 transition: background-color 0.1s ease; 1009 border-bottom: 1px solid #f1f3f4; 1010 min-height: 32px; 1011 background-color: #e8f0fe; 1012 `; 1013 1014 // Hover effect 1015 itemDiv.addEventListener("mouseenter", () => { 1016 itemDiv.style.backgroundColor = "#d3e3fd"; 1017 }); 1018 itemDiv.addEventListener("mouseleave", () => { 1019 itemDiv.style.backgroundColor = "#e8f0fe"; 1020 }); 1021 1022 // Icon 1023 const icon = document.createElement("span"); 1024 icon.className = "material-icons"; 1025 icon.textContent = "my_location"; 1026 icon.style.cssText = "font-size: 18px; color: #1a73e8; flex-shrink: 0;"; 1027 icon.setAttribute("aria-hidden", "true"); 1028 itemDiv.appendChild(icon); 1029 1030 // Text 1031 const text = document.createElement("div"); 1032 text.textContent = "Use Current Location"; 1033 text.style.cssText = "font-size: 13px; font-weight: 500; color: #1a73e8;"; 1034 itemDiv.appendChild(text); 1035 1036 // Accessibility 1037 itemDiv.setAttribute("role", "option"); 1038 itemDiv.setAttribute("aria-label", "Use Current Location"); 1039 1040 // Click handler - use pre-fetched location or request if not available 1041 itemDiv.addEventListener("mousedown", (e) => e.preventDefault()); 1042 itemDiv.addEventListener("click", async () => { 1043 console.log("[GMAPS] Current Location clicked"); 1044 1045 // Platform detection for custom messaging 1046 const isDesktop = !isMobile; 1047 const loadingMessage = isDesktop 1048 ? "Getting your location... (may take 10-15 seconds on desktop)" 1049 : "Getting your location..."; 1050 1051 // Show loading state 1052 icon.textContent = "hourglass_empty"; 1053 text.textContent = loadingMessage; 1054 itemDiv.style.cursor = "wait"; 1055 1056 try { 1057 let lat, lng, accuracy; 1058 1059 // FIRST: Check if we already have location from C# (passed via locationBias) 1060 // This is the location obtained when user clicked "Book a Reservation"
1061 // This is critical for Safari iOS which won't allow new geolocation requests in modal 1062 if (locationBias?.deviceLatitude && locationBias?.deviceLongitude) { 1063 console.log("[GMAPS] Using pre-fetched location from C#:", locationBias.deviceLatitude, locationBias.deviceLongitude, "accuracy:", locationBias.deviceAccuracy); 1064 lat = locationBias.deviceLatitude; 1065 lng = locationBias.deviceLongitude; 1066 accuracy = locationBias.deviceAccuracy || 30; // Default to acceptable accuracy if not provided 1067 } 1068 // SECOND: Check global cache from app-init or earlier requests 1069 else if (window.__lastGeo?.lat && window.__lastGeo?.lng) { 1070 console.log("[GMAPS] Using cached location from window.__lastGeo:", window.__lastGeo); 1071 lat = window.__lastGeo.lat; 1072 lng = window.__lastGeo.lng; 1073 accuracy = window.__lastGeo.accuracy || 30; 1074 } 1075 // THIRD: Only request NEW location if we have nothing cached 1076 // NOTE: This will likely FAIL on Safari iOS in modal context, but we try anyway 1077 else { 1078 console.log("[GMAPS] No cached location - requesting fresh (may fail on Safari iOS in modal)"); 1079 1080 if (!navigator.geolocation) { 1081 await dotNetRef.invokeMethodAsync('OnLocationError', 'Geolocation is not supported by your browser'); 1082 resetButtonState(); 1083 return; 1084 } 1085 1086 const position = await new Promise((resolve, reject) => { 1087 navigator.geolocation.getCurrentPosition(resolve, reject, { 1088 enableHighAccuracy: true, 1089 timeout: 20000, 1090 maximumAge: 600000 // 10 minutes - user isn't moving while booking 1091 }); 1092 }); 1093 1094 lat = position.coords.latitude; 1095 lng = position.coords.longitude; 1096 accuracy = position.coords.accuracy; 1097 console.log("[GMAPS] Got fresh location:", { lat, lng, accuracy }); 1098 } 1099 1100 console.log("[GMAPS] Using location:", { lat, lng, accuracy }); 1101 1102 // Check if accuracy is good enough for reverse geocoding (â¤30m threshold) 1103 if (accuracy > 30) { 1104 console.warn(`[GMAPS] Accuracy too low for address (${accuracy}m > 30m threshold)`); 1105 1106 // Show error via C# callback 1107 await dotNetRef.invokeMethodAsync( 1108 'OnLocationError', 1109 `We couldn't pinpoint your exact location (accuracy: ${Math.round(accuracy)}m). Please enter your address manually or try again in a location with better GPS signal.` 1110 ); 1111 1112 resetButtonState(); 1113 return; 1114 } 1115 1116 // Accuracy is good enough (â¤30m) - proceed with reverse geocoding 1117 text.textContent = "Finding your address..."; 1118 1119 // Reverse geocode using existing function from googleMaps.js 1120 const reverseGeocodeResult = await window.ss.reverseGeocode(lat, lng); 1121 1122 console.log("[GMAPS] Reverse geocoded address:", reverseGeocodeResult); 1123 1124 // Convert to our format 1125 const addressData = { 1126 formattedAddress: reverseGeocodeResult.address || reverseGeocodeResult.line1, 1127 streetAddress: reverseGeocodeResult.line1 || '', 1128 city: reverseGeocodeResult.city || '', 1129 state: reverseGeocodeResult.state || '', 1130 zipCode: reverseGeocodeResult.postal || '', 1131 country: reverseGeocodeResult.country || 'US', 1132 placeId: reverseGeocodeResult.placeId || '', 1133 latitude: lat, 1134 longitude: lng 1135 }; 1136 1137 // Create location data with full address details 1138 const locationData = { 1139 type: "current_location_geocoded", 1140 latitude: lat, 1141 longitude: lng, 1142 accuracy: accuracy, 1143 formattedAddress: addressData.formattedAddress, 1144 streetAddress: addressData.streetAddress, 1145 city: addressData.city, 1146 state: addressData.state, 1147 zipCode: addressData.zipCode, 1148 country: addressData.country, 1149 placeId: addressData.placeId, 1150 types: ['street_address'] // Default type for geocoded locations 1151 }; 1152 1153 // Call back to C# with the geocoded location 1154 await dotNetRef.invokeMethodAsync(callbackName, locationData); 1155 1156 // Success state 1157 icon.textContent = "check_circle"; 1158 text.textContent = "Location set!"; 1159 itemDiv.style.backgroundColor = "#e6f4ea"; 1160 1161 // Close dropdown after brief delay 1162 setTimeout(() => { 1163 closeDropdown(true); // true = was a selection 1164 }, 500); 1165 1166 } catch (error) { 1167 console.error("[GMAPS] Location error:", error); 1168 1169 // Determine error type and message 1170 let errorMessage = "Unable to get your location"; 1171 1172 if (error.code === 1) { // PERMISSION_DENIED 1173 errorMessage = "Location access denied. Please enable location permissions in your browser settings."; 1174 } else if (error.code === 2) { // POSITION_UNAVAILABLE 1175 errorMessage = "Location unavailable. Please check your device settings or try again from the main booking screen."; 1176 } else if (error.code === 3) { // TIMEOUT 1177 errorMessage = "Location request timed out. Please try again or enter your address manually."; 1178 } else if (error.message && error.message.includes("Geocoding")) { 1179 errorMessage = "Unable to determine your address. Please enter it manually."; 1180 } 1181 1182 // Show error state 1183 icon.textContent = "location_off"; 1184 text.textContent = "Location failed"; 1185 itemDiv.style.backgroundColor = "#fce8e6"; 1186 1187 // Send error to C# for validation display 1188 await dotNetRef.invokeMethodAsync('OnLocationError', errorMessage); 1189 1190 // Reset after delay 1191 setTimeout(resetButtonState, 3000); 1192 } 1193 1194 function resetButtonState() { 1195 icon.textContent = "my_location"; 1196 text.textContent = "Use Current Location"; 1197 itemDiv.style.backgroundColor = "#e8f0fe"; 1198 itemDiv.style.cursor = "pointer"; 1199 } 1200 }); 1201 1202 return itemDiv; 1203 } 1204 1205 // Create "Your Location" item when we have a reverse-geocoded address 1206 function createYourLocationItem(location) { 1207 const itemDiv = document.createElement("div"); 1208 itemDiv.className = "dropdown-item your-location-item"; 1209 itemDiv.style.cssText = ` 1210 padding: 8px 14px; 1211 cursor: pointer; 1212 display: flex; 1213 align-items: flex-start; 1214 gap: 10px; 1215 transition: background-color 0.1s ease; 1216 border-bottom: 1px solid #f1f3f4; 1217 min-height: 44px;
1218 background-color: #e8f0fe; 1219 `; 1220 1221 // Hover effect 1222 itemDiv.addEventListener("mouseenter", () => { 1223 itemDiv.style.backgroundColor = "#d3e3fd"; 1224 }); 1225 itemDiv.addEventListener("mouseleave", () => { 1226 itemDiv.style.backgroundColor = "#e8f0fe"; 1227 }); 1228 1229 // Icon 1230 const icon = document.createElement("span"); 1231 icon.className = "material-icons"; 1232 icon.textContent = "my_location"; 1233 icon.style.cssText = "font-size: 20px; color: #1a73e8; flex-shrink: 0; margin-top: 2px;"; 1234 icon.setAttribute("aria-hidden", "true"); 1235 itemDiv.appendChild(icon); 1236 1237 // Text container 1238 const textContainer = document.createElement("div"); 1239 textContainer.style.cssText = "flex: 1; min-width: 0;"; 1240 1241 // Label 1242 const label = document.createElement("div"); 1243 label.textContent = "Your Location"; 1244 label.style.cssText = "font-size: 13px; font-weight: 600; color: #1a73e8; margin-bottom: 2px;"; 1245 textContainer.appendChild(label); 1246 1247 // Address 1248 const addressText = location.formattedAddress || location.streetAddress || "Current location"; 1249 const address = document.createElement("div"); 1250 address.textContent = addressText; 1251 address.style.cssText = "font-size: 12px; color: #5f6368; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"; 1252 textContainer.appendChild(address); 1253 1254 itemDiv.appendChild(textContainer); 1255 1256 // Accessibility 1257 itemDiv.setAttribute("role", "option"); 1258 itemDiv.setAttribute("aria-label", "Your Location: " + addressText); 1259 1260 // Click handler - use the pre-geocoded location directly 1261 itemDiv.addEventListener("mousedown", (e) => e.preventDefault()); 1262 itemDiv.addEventListener("click", async () => { 1263 console.log("[GMAPS] Your Location clicked - using pre-geocoded address:", location); 1264 1265 // Show selection feedback 1266 icon.textContent = "check_circle"; 1267 label.textContent = "Location selected!"; 1268 itemDiv.style.backgroundColor = "#e6f4ea"; 1269 1270 // Build the location data for C# 1271 const locationData = { 1272 type: "current_location_geocoded", 1273 latitude: location.latitude, 1274 longitude: location.longitude, 1275 accuracy: locationBias?.deviceAccuracy || 30, 1276 formattedAddress: location.formattedAddress, 1277 streetAddress: location.streetAddress || '', 1278 city: location.city || '', 1279 state: location.state || '', 1280 zipCode: location.zipCode || '', 1281 country: location.country || 'US', 1282 placeId: location.placeId || '', 1283 types: ['street_address'] 1284 }; 1285 1286 // Call back to C# with the location 1287 await dotNetRef.invokeMethodAsync(callbackName, locationData); 1288 1289 // Close dropdown after brief delay 1290 setTimeout(() => { 1291 closeDropdown(true); // true = was a selection 1292 }, 300); 1293 }); 1294 1295 return itemDiv; 1296 } 1297 1298 // Create "Show More Favorites" link (compact) 1299 function createShowMoreFavoritesItem(count, onClick) { 1300 const itemDiv = document.createElement("div"); 1301 itemDiv.className = "dropdown-item show-more-item"; 1302 itemDiv.style.cssText = ` 1303 padding: 4px 14px; 1304 cursor: pointer; 1305 display: flex; 1306 align-items: center; 1307 justify-content: center; 1308 gap: 4px; 1309 transition: background-color 0.1s ease; 1310 min-height: 28px; 1311 font-size: 12px; 1312 color: #1a73e8; 1313 font-weight: 500; 1314 `; 1315 1316 itemDiv.addEventListener("mouseenter", () => { 1317 itemDiv.style.backgroundColor = "#f1f3f4"; 1318 }); 1319 itemDiv.addEventListener("mouseleave", () => { 1320 itemDiv.style.backgroundColor = "transparent"; 1321 }); 1322 1323 const iconEl = document.createElement("span"); 1324 iconEl.className = "material-icons"; 1325 iconEl.textContent = "expand_more"; 1326 iconEl.style.cssText = "font-size: 16px;"; 1327 itemDiv.appendChild(iconEl); 1328 1329 const textEl = document.createElement("span"); 1330 textEl.textContent = `+${count} more favorites`; 1331 itemDiv.appendChild(textEl); 1332 1333 itemDiv.addEventListener("mousedown", (e) => e.preventDefault()); 1334 itemDiv.addEventListener("click", onClick); 1335 1336 return itemDiv; 1337 } 1338 1339 // Create "Show More Favorites" link (compact) 1340 function createDropdownItem(item) { 1341 const itemDiv = document.createElement("div"); 1342 itemDiv.className = "dropdown-item"; 1343 1344 // Base styling - single line, compact 1345 itemDiv.style.cssText = ` 1346 padding: 6px 14px; 1347 cursor: pointer; 1348 display: flex; 1349 align-items: center; 1350 gap: 8px; 1351 transition: background-color 0.1s ease; 1352 min-height: 32px; 1353 `; 1354 1355 // Special sizing for favorites in horizontal layout 1356 if (item.type === "favorite") { 1357 itemDiv.style.cssText = ` 1358 padding: 6px 12px; 1359 cursor: pointer; 1360 display: flex; 1361 align-items: center; 1362 gap: 8px; 1363 transition: background-color 0.1s ease; 1364 min-height: 32px; 1365 flex: 0 0 auto; 1366 min-width: 140px; 1367 `; 1368 } 1369 1370 // Compact two-line layout for Google Places (hotels, businesses, etc.) 1371 if (item.type === "google_place") { 1372 itemDiv.style.cssText = ` 1373 padding: 4px 14px; 1374 cursor: pointer; 1375 display: flex; 1376 align-items: center; 1377 gap: 8px; 1378 transition: background-color 0.1s ease; 1379 min-height: 38px; 1380 `; 1381 itemDiv.classList.add("google-place-item"); 1382 } 1383 1384 // Special styling for action links (like "View All Airports") 1385 if (item.type === "view_all_airports" || item.isAction) { 1386 itemDiv.style.cssText = ` 1387 padding: 6px 14px; 1388 cursor: pointer; 1389 display: flex; 1390 align-items: center; 1391 gap: 8px; 1392 transition: background-color 0.1s ease; 1393 min-height: 32px; 1394 background-color: #f8f9fa; 1395 border-top: 1px solid #e8eaed; 1396 grid-column: 1 / -1; 1397 `; 1398 } 1399 1400 // Hover effect 1401 itemDiv.addEventListener("mouseenter", () => { 1402 itemDiv.style.backgroundColor = "#f1f3f4"; 1403 }); 1404 itemDiv.addEventListener("mouseleave", () => { 1405 if (item.type === "view_all_airports" || item.isAction) { 1406 itemDiv.style.backgroundColor = "#f8f9fa"; 1407 } else { 1408 itemDiv.style.backgroundColor = "transparent"; 1409 } 1410 }); 1411 1412 // Icon - smaller for compact layout 1413 if (item.icon) { 1414 const iconEl = document.createElement("span"); 1415 iconEl.className = "material-icons"; 1416 iconEl.textContent = getMaterialIcon(item.icon); 1417 iconEl.style.cssText = "font-size: 18px; color: #5f6368; flex-shrink: 0;"; 1418 iconEl.setAttribute("aria-hidden", "true"); // Hide icon from screen readers 1419 1420 // Blue icon for action links 1421 if (item.type === "view_all_airports" || item.isAction) { 1422 iconEl.style.color = "#1a73e8"; 1423 } 1424 1425 itemDiv.appendChild(iconEl); 1426 } 1427 1428 // Build accessible label for screen readers 1429 let accessibleLabel = item.primaryText || ""; 1430 if (item.secondaryText) { 1431 accessibleLabel += ", " + item.secondaryText; 1432 } 1433 itemDiv.setAttribute("role", "option"); 1434 itemDiv.setAttribute("aria-label", accessibleLabel); 1435 1436 // Text container - single line with primary and secondary inline 1437 const textContainer = document.createElement("div"); 1438 textContainer.style.cssText = "flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"; 1439 1440 // Build single-line content based on item type 1441 if (item.type === "airport" && item.primaryText) { 1442 // Airport/Train/Cruise: "PHX - Phoenix Sky Harbor" or "BB1 - Boston South Train Station" 1443 // Match codes that can contain letters and numbers (2-4 characters) 1444 const match = item.primaryText.match(/^([A-Z0-9]{2,4})\s*-?\s*(.*)$/i);
1445 if (match) { 1446 const airportCode = match[1].toUpperCase(); 1447 const airportName = match[2] || ""; 1448 1449 // Bold airport code 1450 const codeSpan = document.createElement("strong"); 1451 codeSpan.textContent = airportCode; 1452 codeSpan.style.cssText = "font-weight: 700; color: #202124; font-size: 14px;"; 1453 textContainer.appendChild(codeSpan); 1454 1455 // Add name if present 1456 if (airportName) { 1457 const nameSpan = document.createElement("span"); 1458 nameSpan.textContent = " " + airportName; 1459 nameSpan.style.cssText = "font-weight: 400; color: #5f6368; font-size: 13px;"; 1460 textContainer.appendChild(nameSpan); 1461 } 1462 1463 // Add secondary (distance) if present 1464 if (item.secondaryText) { 1465 const secondarySpan = document.createElement("span"); 1466 secondarySpan.textContent = " · " + item.secondaryText; 1467 secondarySpan.style.cssText = "color: #80868b; font-size: 12px;"; 1468 textContainer.appendChild(secondarySpan); 1469 } 1470 } else { 1471 textContainer.textContent = item.primaryText || ""; 1472 } 1473 } else if (item.type === "view_all_airports" || item.isAction) { 1474 // Action link styling 1475 const actionText = document.createElement("span"); 1476 actionText.style.cssText = "font-size: 13px; color: #1a73e8; font-weight: 500;"; 1477 actionText.textContent = item.primaryText || ""; 1478 textContainer.appendChild(actionText); 1479 } else if (item.type === "favorite") { 1480 // Favorite: "Name · Address" 1481 const nameSpan = document.createElement("span"); 1482 nameSpan.textContent = item.primaryText || ""; 1483 nameSpan.style.cssText = "font-weight: 600; color: #202124; font-size: 13px;"; 1484 textContainer.appendChild(nameSpan); 1485 1486 if (item.secondaryText) { 1487 const addrSpan = document.createElement("span"); 1488 addrSpan.textContent = " · " + item.secondaryText; 1489 addrSpan.style.cssText = "color: #5f6368; font-size: 12px;"; 1490 textContainer.appendChild(addrSpan); 1491 } 1492 } else if (item.type === "google_place") { 1493 // Google place: Two-line compact layout 1494 // Name on first line (bold), address on second line 1495 textContainer.style.cssText = "flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; justify-content: center;"; 1496 1497 const nameSpan = document.createElement("div"); 1498 nameSpan.textContent = item.primaryText || ""; 1499 nameSpan.style.cssText = "font-weight: 600; color: #202124; font-size: 13px; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"; 1500 textContainer.appendChild(nameSpan); 1501 1502 if (item.secondaryText) { 1503 const addrSpan = document.createElement("div"); 1504 addrSpan.textContent = item.secondaryText; 1505 addrSpan.style.cssText = "color: #5f6368; font-size: 11px; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"; 1506 textContainer.appendChild(addrSpan); 1507 } 1508 } else { 1509 // Default: primary text (semi-bold), then secondary if exists 1510 const primarySpan = document.createElement("span"); 1511 primarySpan.textContent = item.primaryText || ""; 1512 primarySpan.style.cssText = "font-weight: 600; color: #202124; font-size: 14px;"; 1513 textContainer.appendChild(primarySpan); 1514 1515 if (item.secondaryText) { 1516 const secondarySpan = document.createElement("span"); 1517 secondarySpan.textContent = " · " + item.secondaryText; 1518 secondarySpan.style.cssText = "color: #5f6368; font-size: 12px;"; 1519 textContainer.appendChild(secondarySpan); 1520 } 1521 } 1522 1523 itemDiv.appendChild(textContainer); 1524 1525 // Click handler 1526 itemDiv.addEventListener("mousedown", (e) => e.preventDefault()); 1527 itemDiv.addEventListener("click", async () => { 1528 console.log("[GMAPS] Dropdown item clicked:", item); 1529 1530 // Handle based on item type 1531 if (item.type === "google_place") { 1532 // Fetch place details 1533 await fetchPlaceDetails(item.data.placeId); 1534 } else if (item.type === "view_all_airports" || item.data === "show_all_airports") { 1535 // Show all airports - handle both type check and data check 1536 showAllAirports(); 1537 } else if (item.type === "airport" && item.data) { 1538 // Check if airport is serviced 1539 if (isAirportServiced(item.data)) { 1540 // Serviced airport - close dropdown first for immediate feedback, then process 1541 closeDropdown(true); // true = was a selection 1542 await dotNetRef.invokeMethodAsync(callbackName, item.data); 1543 } else { 1544 // Non-serviced airport - call different callback to show error modal 1545 console.log("[GMAPS] Non-serviced airport selected:", item.data.airportID || item.data.AirportID); 1546 await dotNetRef.invokeMethodAsync("OnNonServicedAirportSelected", item.data); 1547 closeDropdown(); // false - not a valid selection, user needs to pick again 1548 } 1549 } else { 1550 // Direct callback with data (favorites, etc.) - close dropdown first for immediate feedback 1551 closeDropdown(true); // true = was a selection 1552 await dotNetRef.invokeMethodAsync(callbackName, item.data); 1553 } 1554 }); 1555 1556 return itemDiv; 1557 } 1558 1559 // Leading house number from what the customer typed: "4926 S Quiet Way" -> "4926".
1560 // Accepts a trailing letter and hyphenated numbers ("120B", "1234-A"), both of which 1561 // appear in real US addresses. Requires something after it, so a bare number - which 1562 // is a search in progress, not an address - yields nothing. 1563 function extractLeadingHouseNumber(text) { 1564 const match = /^\s*(\d{1,6}(?:\s*-\s*\d{1,6})?[A-Za-z]?)\s+\S/.exec(text || ""); 1565 return match ? match[1].replace(/\s+/g, "") : null; 1566 } 1567 1568 // A street-level result: Google placed the road but not a building on it. Tested by the 1569 // absence of a street_number component rather than by types alone, since a result can 1570 // carry "route" alongside other types. 1571 function isRouteLevelResult(details) { 1572 const components = details?.address_components || []; 1573 const hasStreetNumber = components.some(c => (c.types || []).includes("street_number")); 1574 const types = details?.types || []; 1575 return !hasStreetNumber && types.includes("route"); 1576 } 1577 1578 // Fetch place details from Google 1579 async function fetchPlaceDetails(placeId) { 1580 if (!window.google || !google.maps || !google.maps.places) { 1581 console.error("[GMAPS] Google Maps API not loaded"); 1582 return; 1583 } 1584 1585 // Captured before closeDropdown() runs, which can rewrite the field. This is the 1586 // only record of the house number when the customer picked a street-level result. 1587 const typedText = inputEl?.value || ""; 1588 1589 try { 1590 const service = new google.maps.places.PlacesService(document.createElement('div')); 1591 1592 const details = await new Promise((resolve, reject) => { 1593 service.getDetails({ 1594 placeId: placeId, 1595 fields: ['address_components', 'formatted_address', 'geometry', 'name', 'place_id', 'types'] 1596 }, (result, status) => { 1597 if (status === google.maps.places.PlacesServiceStatus.OK) { 1598 resolve(result); 1599 } else { 1600 reject(new Error(`Place details failed: ${status}`)); 1601 } 1602 }); 1603 }); 1604 1605 console.log("[GMAPS] Place details:", details); 1606 1607 // Close dropdown first for immediate feedback, then process callback 1608 closeDropdown(true); // true = was a selection 1609 1610 // Extract street address from components (handles international addresses 1611 // where Google returns street-level info in untyped components) 1612 const parsed = typeof extractAddressComponents === 'function' 1613 ? extractAddressComponents(details.address_components) 1614 : { streetAddress: null }; 1615 1616 let finalStreetAddress = parsed.streetAddress; 1617 let finalAddressComponents = details.address_components; 1618 let finalFormattedAddress = details.formatted_address; 1619 1620 // When Google can't place a house number it still offers a prediction showing the 1621 // number the customer typed ("500 S Quiet Way") whose place_id is synthetic - it 1622 // decodes to that literal text - and whose details are only the street. Selecting 1623 // one dropped the number silently, and the step then demanded a street number the 1624 // customer had already supplied. Report it so C# can say which number failed. 1625 // Only ever a diagnosis: nothing is re-resolved, because when Google CAN place the 1626 // number it offers a precise prediction and the customer never lands here. 1627 let unresolvedHouseNumber = null; 1628 1629 if (isRouteLevelResult(details)) { 1630 const typedNumber = extractLeadingHouseNumber(typedText); 1631 if (typedNumber && parsed.streetAddress) { 1632 console.warn(`[GMAPS] Street-level result - "${typedNumber}" is not a known number on ${parsed.streetAddress}`); 1633 unresolvedHouseNumber = typedNumber; 1634 } 1635 } 1636 1637 // If no street address found (common for hotels/lodging/POIs), try reverse geocoding 1638 // But ONLY for specific locations (buildings, establishments, POIs) â not for 1639 // cities, states, neighborhoods, or other generic/area-level results 1640 const placeTypes = details.types || []; 1641 const genericAreaTypes = new Set([ 1642 'locality', 'political', 'administrative_area_level_1', 'administrative_area_level_2', 1643 'administrative_area_level_3', 'country', 'postal_code', 'neighborhood', 1644 'sublocality', 'sublocality_level_1', 'colloquial_area', 'natural_feature' 1645 ]); 1646 const isGenericArea = placeTypes.length > 0 && placeTypes.every(t => genericAreaTypes.has(t)); 1647 1648 if (!finalStreetAddress && details.geometry?.location && !isGenericArea) { 1649 console.log("[GMAPS] No street address from place details, attempting reverse geocode (types:", placeTypes.join(', '), ")"); 1650 try { 1651 const geocoder = new google.maps.Geocoder(); 1652 const latLng = { 1653 lat: details.geometry.location.lat(), 1654 lng: details.geometry.location.lng() 1655 }; 1656 const geocodeResult = await new Promise((resolve, reject) => { 1657 geocoder.geocode({ location: latLng }, (results, status) => { 1658 if (status === "OK" && results && results.length > 0) { 1659 resolve(results); 1660 } else { 1661 reject(new Error(`Reverse geocode failed: ${status}`)); 1662 } 1663 }); 1664 }); 1665 1666 // Find the first result that has a street address 1667 for (const result of geocodeResult) { 1668 const reverseComponents = typeof extractAddressComponents === 'function' 1669 ? extractAddressComponents(result.address_components) 1670 : { streetAddress: null }; 1671 if (reverseComponents.streetAddress) { 1672 finalStreetAddress = reverseComponents.streetAddress; 1673 // Merge street-level components into the original address_components 1674 // so C# ParseAddressComponents can pick them up 1675 const existingTypes = new Set();
1676 finalAddressComponents.forEach(c => (c.types || []).forEach(t => existingTypes.add(t))); 1677 for (const comp of result.address_components) { 1678 const compTypes = comp.types || []; 1679 if (compTypes.includes('street_number') || compTypes.includes('route') || compTypes.includes('postal_code')) { 1680 if (!existingTypes.has(compTypes[0])) { 1681 finalAddressComponents.push(comp); 1682 } 1683 } 1684 } 1685 // Only replace formatted_address if the original didn't contain 1686 // a meaningful place name (avoid losing hotel/POI names) 1687 const originalHasName = details.name && 1688 finalFormattedAddress.includes(details.name); 1689 if (!originalHasName && result.formatted_address) { 1690 finalFormattedAddress = result.formatted_address; 1691 } 1692 console.log("[GMAPS] Reverse geocode found street address:", finalStreetAddress); 1693 break; 1694 } 1695 } 1696 1697 if (!finalStreetAddress) { 1698 console.log("[GMAPS] Reverse geocode did not yield a street address"); 1699 } 1700 } catch (geoError) { 1701 console.warn("[GMAPS] Reverse geocode fallback failed:", geoError.message); 1702 } 1703 } else if (!finalStreetAddress && isGenericArea) { 1704 console.log("[GMAPS] Skipping reverse geocode for generic area (types:", placeTypes.join(', '), ")"); 1705 } 1706 1707 // Strip place.name when it's just the street address echoed back for plain residential 1708 // premises (Google does this for non-POI addresses). C# also guards against this, but 1709 // clearing here keeps logs and the JS-side place data consistent. 1710 let placeName = details.name || ''; 1711 const detailsTypes = details.types || []; 1712 const realPoiTypes = ['lodging', 'tourist_attraction', 'stadium', 'amusement_park', 1713 'aquarium', 'art_gallery', 'casino', 'church', 'city_hall', 'convention_center', 1714 'courthouse', 'embassy', 'hospital', 'library', 'mosque', 'museum', 1715 'rv_park', 'synagogue', 'temple', 'zoo', 'airport']; 1716 const hasRealPoiType = detailsTypes.some(t => realPoiTypes.includes(t)); 1717 1718 if (placeName && !hasRealPoiType) { 1719 const nameLower = placeName.trim().toLowerCase(); 1720 const streetLower = (finalStreetAddress || '').trim().toLowerCase(); 1721 const formattedLower = (finalFormattedAddress || '').trim().toLowerCase(); 1722 const nameIsAddress = 1723 (streetLower && (nameLower === streetLower || nameLower.startsWith(streetLower + ','))) || 1724 (formattedLower && (nameLower === formattedLower || formattedLower.startsWith(nameLower + ','))); 1725 if (nameIsAddress) { 1726 console.log("[GMAPS] Stripping name (matches street address):", placeName); 1727 placeName = ''; 1728 } 1729 } 1730 1731 // Call back to C# with place data 1732 await dotNetRef.invokeMethodAsync(callbackName, { 1733 type: "google_place", 1734 placeId: details.place_id, 1735 formattedAddress: finalFormattedAddress, 1736 name: placeName, 1737 latitude: details.geometry.location.lat(), 1738 longitude: details.geometry.location.lng(), 1739 addressComponents: finalAddressComponents, 1740 types: details.types, 1741 streetAddress: finalStreetAddress, 1742 // Present only when the typed house number could not be placed on this street. 1743 unresolvedHouseNumber: unresolvedHouseNumber, 1744 typedText: typedText 1745 }); 1746 } catch (error) { 1747 console.error("[GMAPS] Error fetching place details:", error); 1748 } 1749 } 1750 1751 // Show all airports (serviced only) 1752 function showAllAirports() { 1753 if (!allAirportsData || allAirportsData.length === 0) { 1754 console.warn("[GMAPS] No airports data available"); 1755 return; 1756 } 1757 1758 // Filter to serviced airports only 1759 const servicedAirports = allAirportsData.filter(airport => isAirportServiced(airport)); 1760 console.log("[GMAPS] Showing all serviced airports:", servicedAirports.length, "of", allAirportsData.length); 1761 1762 const airportsSection = { 1763 title: "All Airports", 1764 icon: "flight", 1765 type: "airports", 1766 items: servicedAirports.map(airport => ({ 1767 type: "airport", 1768 icon: getPortIcon(airport.portType || airport.PortType, isDropoffMode ? 1 : 0), 1769 primaryText: `${airport.airportID || airport.AirportID} - ${airport.name || airport.Name}`, 1770 secondaryText: `${airport.city || airport.City}, ${airport.state || airport.State}`, 1771 data: airport 1772 })) 1773 }; 1774 1775 renderDropdown({ sections: [airportsSection] }); 1776 } 1777 1778 // Filter dropdown based on input 1779 function filterAndFetchResults(query) { 1780 // Reset keyboard navigation when results change 1781 window.__resetKeyboardNavigation(menu); 1782 1783 query = query.trim(); 1784 1785 if (query.length === 0) { 1786 currentAirportSection = null; 1787 renderDropdown(originalDropdownData); 1788 return; 1789 } 1790 1791 console.log(`[GMAPS] Filtering for query: "${query}"`); 1792 1793 // Match our own airport records by code or name. Held in currentAirportSection so the 1794 // Places callback can render it above its results instead of wiping it. 1795 currentAirportSection = buildAirportSection(query); 1796
1797 // An exact port code ("LHR", "P27") is unambiguous - show it alone and skip Places, 1798 // which is how a three-character code search has always behaved. 1799 if (currentAirportSection && hasExactCodeMatch(query)) { 1800 console.log("[GMAPS] Exact port code match - showing airports only"); 1801 renderDropdown({ sections: [currentAirportSection] }); 1802 return; 1803 } 1804 1805 // Filter local data first 1806 const filteredSections = []; 1807 1808 if (originalDropdownData && originalDropdownData.sections) { 1809 originalDropdownData.sections.forEach(section => { 1810 if (!section.items) return; 1811 1812 const matchingItems = section.items.filter(item => { 1813 const primary = (item.primaryText || "").toLowerCase(); 1814 const secondary = (item.secondaryText || "").toLowerCase(); 1815 const queryLower = query.toLowerCase(); 1816 1817 return primary.includes(queryLower) || secondary.includes(queryLower); 1818 }); 1819 1820 if (matchingItems.length > 0) { 1821 filteredSections.push({ 1822 ...section, 1823 items: matchingItems 1824 }); 1825 } 1826 }); 1827 } 1828 1829 // Our airports go above the nearby/favourite matches - a named airport is a stronger 1830 // hit than a substring match in a saved location. 1831 const localSections = currentAirportSection 1832 ? [currentAirportSection, ...filteredSections] 1833 : filteredSections; 1834 1835 if (localSections.length > 0) { 1836 renderDropdown({ sections: localSections }); 1837 } else { 1838 menu.innerHTML = ""; 1839 } 1840 1841 // Also query Google Places 1842 if (autocompleteService && query.length >= 3) { 1843 queryGooglePlaces(query); 1844 } 1845 } 1846 1847 // Airports section for the current query, held so Google Places results can be shown 1848 // alongside it rather than replacing it (queryGooglePlaces re-renders the whole menu).
1849 let currentAirportSection = null; 1850 1851 // Maximum airport matches to show, so a broad query ("london") cannot bury the Places 1852 // results underneath a wall of our own records. 1853 const MAX_AIRPORT_MATCHES = 6; 1854 1855 // Search airports/ports by code OR name (searches ALL airports, not just nearby). 1856 // Matches airport codes (PHX), cruise port codes (P27), train station codes, and now the 1857 // airport name and city - typing "heathrow" used to find nothing, because only the code 1858 // was ever compared and only for queries of exactly three characters. 1859 // Note: Non-serviced airports are included for code matches only - clicking them shows an 1860 // error modal (handled in click handler), which is deliberate, so someone asking about a 1861 // specific airport by code learns we know it rather than being told it does not exist. 1862 // They are excluded from name and city matches; see the score check below. 1863 // Returns a dropdown section, or null when nothing matched. 1864 function buildAirportSection(query) { 1865 if (!allAirportsData || allAirportsData.length === 0) { 1866 console.warn("[GMAPS] No airports data for search"); 1867 return null; 1868 } 1869 1870 const q = query.trim().toUpperCase(); 1871 if (q.length < 2) return null; 1872 1873 // Lower rank = shown first. Code matches beat name matches, and a name that starts 1874 // with the query beats one that merely contains it, so "LHR" and "heathrow" both put 1875 // Heathrow first while "london" still surfaces every London airport. 1876 const scored = []; 1877 allAirportsData.forEach(airport => { 1878 const code = (airport.iataCode || airport.airportID || "").toUpperCase(); 1879 const name = (airport.airportName || airport.name || "").toUpperCase(); 1880 const city = (airport.city || airport.City || "").toUpperCase(); 1881 1882 let score; 1883 if (code === q) score = 0; 1884 else if (code.startsWith(q)) score = 1; 1885 else if (name.startsWith(q)) score = 2; 1886 else if (name.includes(q)) score = 3; 1887 else if (city.startsWith(q)) score = 4; 1888 else return; 1889 1890 // Non-serviced airports are kept for code matches (0-1) but dropped for name and 1891 // city matches (2-4). Typing a code is an explicit question about one airport, and 1892 // "we don't service HHH" answered by the click-through modal beats silence. Typing 1893 // a name is exploratory - someone searching "hilton" wants a hotel, and Hilton Head 1894 // Island is then just a dead end occupying one of MAX_AIRPORT_MATCHES slots. 1895 if (score >= 2 && !isAirportServiced(airport)) return; 1896 1897 scored.push({ airport, score, rank: airport.airportRank ?? airport.AirportRank ?? 999 }); 1898 }); 1899 1900 if (scored.length === 0) return null; 1901 1902 scored.sort((a, b) => a.score - b.score || a.rank - b.rank); 1903 const matches = scored.slice(0, MAX_AIRPORT_MATCHES).map(s => s.airport); 1904 1905 console.log(`[GMAPS] Airport search "${query}": ${scored.length} match(es), showing ${matches.length}`); 1906 1907 return { 1908 title: "Airports", 1909 icon: "flight", 1910 type: "airports", 1911 items: matches.map(airport => ({ 1912 type: "airport", 1913 icon: getPortIcon(airport.portType || airport.PortType, isDropoffMode ? 1 : 0), 1914 primaryText: airport.iataCode || airport.airportID, 1915 secondaryText: airport.airportName || airport.name || "", 1916 data: airport 1917 })) 1918 }; 1919 } 1920 1921 // True when the query is an exact hit on a port code, in which case the airport is almost 1922 // certainly what was wanted and there is no reason to wait on Places. 1923 function hasExactCodeMatch(query) { 1924 const q = query.trim().toUpperCase(); 1925 return !!allAirportsData && allAirportsData.some(airport => 1926 (airport.iataCode || airport.airportID || "").toUpperCase() === q); 1927 } 1928 1929 // Query Google Places API 1930 function queryGooglePlaces(query) { 1931 // Retry creating service if it wasn't available at init time 1932 if (!autocompleteService) { 1933 if (window.google?.maps?.places) { 1934 console.log("[GMAPS] Autocomplete service was null, creating now (late init recovery)"); 1935 autocompleteService = new google.maps.places.AutocompleteService(); 1936 } else { 1937 console.warn("[GMAPS] Autocomplete service not available - Google Maps API not loaded"); 1938 return; 1939 } 1940 } 1941 1942 const request = { 1943 input: query, 1944 types: ['geocode', 'establishment'] 1945 }; 1946 1947 // Add country restriction based on serviced airports 1948 // This limits results to countries where we actually have service 1949 if (servicedCountryCodes.length > 0) { 1950 request.componentRestrictions = { country: servicedCountryCodes }; 1951 console.log("[GMAPS] Applying country restriction:", servicedCountryCodes); 1952 } 1953 1954 // Add location bias if available 1955 // NOTE: We intentionally do NOT use locationRestriction (bounds) because it overrides 1956 // the soft bias behavior. Instead, we rely on location+radius for ranking and filter 1957 // results by distance client-side after receiving them. 1958 if (locationBias && locationBias.center) { 1959 // Soft bias - ranks nearby results higher 1960 request.location = new google.maps.LatLng( 1961 locationBias.center.lat, 1962 locationBias.center.lng 1963 ); 1964 request.radius = locationBias.radiusMeters || 80000; // ~50 miles default 1965 1966 console.log("[GMAPS] Search bias applied:", { 1967 center: { lat: locationBias.center.lat, lng: locationBias.center.lng }, 1968 radiusMiles: Math.round((locationBias.radiusMeters || 80000) / 1609) 1969 }); 1970 } 1971 1972 autocompleteService.getPlacePredictions(request, (predictions, status) => { 1973 if (status === google.maps.places.PlacesServiceStatus.OK && predictions) { 1974 console.log('=== GOOGLE AUTOCOMPLETE DATA ==='); 1975 predictions.forEach((p, index) => { 1976 console.log(`Result ${index + 1}:`, { 1977 description: p.description, 1978 main_text: p.structured_formatting.main_text, 1979 secondary_text: p.structured_formatting.secondary_text, 1980 place_id: p.place_id, 1981 types: p.types 1982 }); 1983 }); 1984 console.log('================================'); 1985 1986 const googleSection = { 1987 title: "Search Results", 1988 icon: "search", 1989 items: predictions.map(p => ({ 1990 type: "google_place", 1991 icon: "place", 1992 primaryText: p.structured_formatting?.main_text || p.description, 1993 secondaryText: p.structured_formatting?.secondary_text || "", 1994 data: { placeId: p.place_id } 1995 })) 1996 }; 1997 // Keep our own airport matches above the Places results - this callback 1998 // re-renders the whole menu, so without this the Airports section built in 1999 // filterAndFetchResults would vanish as soon as Places replied. 2000 renderDropdown({ 2001 sections: currentAirportSection 2002 ? [currentAirportSection, googleSection] 2003 : [googleSection] 2004 }); 2005 } else { 2006 showNoResults(); 2007 } 2008 }); 2009 } 2010 2011 // Show no results message 2012 function showNoResults() { 2013 // Places found nothing, but we may still have matched an airport by code or name - 2014 // showing "No results found" over the top of a valid match would be a lie. 2015 if (currentAirportSection) { 2016 renderDropdown({ sections: [currentAirportSection] }); 2017 return; 2018 } 2019 2020 menu.innerHTML = ""; 2021 2022 // Show "Your Location" at top if we have reverse-geocoded address (and not hiding due to distance) 2023 const currentQuery = inputEl.value.trim(); 2024 const isTyping = currentQuery.length > 0;
2025 2026 if (!isTyping && hasNearbyLocation && !hideCurrentLocation) { 2027 // We have a reverse-geocoded address - show "Your Location" 2028 const yourLocationItem = createYourLocationItem(nearbyLocation); 2029 menu.appendChild(yourLocationItem); 2030 } 2031 2032 const noResults = document.createElement("div"); 2033 noResults.className = "dropdown-item"; 2034 noResults.style.cssText = "color: #5f6368; font-style: italic; justify-content: center; cursor: default; padding: 8px 14px; font-size: 13px;"; 2035 2036 // Show encouraging message if query is short (< 5 chars) 2037 if (currentQuery.length > 0 && currentQuery.length < 5) { 2038 noResults.textContent = "Keep typing to search..."; 2039 } else { 2040 noResults.textContent = "No results found"; 2041 } 2042 2043 noResults.addEventListener("mousedown", e => e.preventDefault()); 2044 menu.appendChild(noResults); 2045 } 2046 2047 // Close dropdown 2048 // @param {boolean} wasSelection - true if closing because user made a selection 2049 function closeDropdown(wasSelection = false) { 2050 window.__resetKeyboardNavigation(menu); 2051 menu.innerHTML = ""; 2052 inputEl.setAttribute('aria-expanded', 'false'); 2053 inputEl.removeAttribute('aria-activedescendant'); 2054 2055 // If user made a selection, mark it so we don't show dropdown on re-focus 2056 if (wasSelection) { 2057 hasCompleteSelection = true; 2058 console.log("[GMAPS] Selection made - suppressing future dropdown on focus"); 2059 } 2060 } 2061 2062 // Track initial value - if input has a value when initialized, don't auto-show dropdown 2063 const initialValue = inputEl.value.trim(); 2064 2065 // Track if user has made a selection (either pre-filled or selected from dropdown) 2066 // When true, suppress dropdown on focus until user clears or modifies the input 2067 let hasCompleteSelection = initialValue.length > 0; 2068 2069 console.log("[GMAPS] Initial value check:", { 2070 initialValue: initialValue || "(empty)", 2071 hasPrefilledValue: initialValue.length > 0, 2072 hasCompleteSelection 2073 }); 2074 2075 // If the user typed an exact 3-letter airport code (e.g. "phx"), resolve it 2076 // straight to that airport instead of making them pick from the dropdown. 2077 // Returns true if it matched and a selection was triggered. 2078 const tryResolveAirportCode = (rawValue) => { 2079 const code = (rawValue || "").trim().toUpperCase(); 2080 if (code.length !== 3 || !allAirportsData) return false; 2081 2082 const match = allAirportsData.find(a => 2083 (a.airportID || a.AirportID || a.iataCode || "").toUpperCase() === code); 2084 if (!match) return false; 2085 2086 hasCompleteSelection = true; 2087 closeDropdown(); 2088 2089 if (isAirportServiced(match)) { 2090 console.log("[GMAPS] Auto-selected airport by typed code:", code); 2091 dotNetRef.invokeMethodAsync(callbackName, match).catch(() => { }); 2092 } else { 2093 console.log("[GMAPS] Typed code is a non-serviced airport:", code); 2094 dotNetRef.invokeMethodAsync("OnNonServicedAirportSelected", match).catch(() => { }); 2095 } 2096 return true; 2097 }; 2098 2099 // Named event handler functions (needed for clone-and-replace on touch devices) 2100 const handleInput = (e) => { 2101 // User is typing/modifying - reset selection state so dropdown can show 2102 hasCompleteSelection = false; 2103 console.log("[GMAPS] Input changed - reset hasCompleteSelection"); 2104 filterAndFetchResults(e.target.value); 2105 }; 2106 2107 const handleFocus = () => { 2108 // User is back to fix the address â clear any "choose from the list" flag. 2109 if (dotNetRef) { 2110 dotNetRef.invokeMethodAsync("ClearUnselectedFlag").catch(() => { }); 2111 } 2112 2113 const currentValue = inputEl.value.trim(); 2114 2115 // If user has made a complete selection (either pre-filled or selected from dropdown), 2116 // suppress dropdown until they clear or modify the input 2117 if (hasCompleteSelection && currentValue.length > 0) { 2118 console.log("[GMAPS] Suppressing dropdown - has complete selection"); 2119 return; 2120 } 2121 2122 // On touch devices (iPad/iPhone), delay dropdown rendering until the 2123 // virtual keyboard has fully appeared. DOM manipulation during focus 2124 // causes Safari to suppress the keyboard entirely. 2125 // On desktop, render immediately via requestAnimationFrame. 2126 const isTouchDevice = navigator.maxTouchPoints > 0; 2127 2128 const showDropdown = () => { 2129 // Verify input is still focused (user may have tapped away) 2130 if (document.activeElement !== inputEl) return; 2131 2132 if (currentValue.length === 0) { 2133 // Only render dropdown if we have data 2134 if (originalDropdownData && originalDropdownData.sections) { 2135 renderDropdown(originalDropdownData); 2136 } 2137 } else { 2138 filterAndFetchResults(currentValue); 2139 } 2140 }; 2141 2142 // Clone-and-replace strips Blazor's async event listeners on touch devices, 2143 // so DOM manipulation during focus no longer suppresses the keyboard. 2144 // Use requestAnimationFrame for all devices for responsive dropdown display. 2145 requestAnimationFrame(showDropdown); 2146 }; 2147 2148 const handleBlur = () => { 2149 setTimeout(() => { 2150 closeDropdown(); 2151 // If the user typed something but never picked a suggestion, no address was 2152 // captured. Flag the field so they get immediate feedback (rather than a box 2153 // that looks filled but silently fails when they click Continue). The 150ms 2154 // delay lets a dropdown-item click register first (which sets hasCompleteSelection). 2155 if (!hasCompleteSelection && inputEl.value.trim().length > 0 && dotNetRef) { 2156 // If they typed an exact airport code, resolve it instead of flagging. 2157 if (tryResolveAirportCode(inputEl.value)) return; 2158 dotNetRef.invokeMethodAsync("FlagUnselectedBlur").catch(() => { });
2159 } 2160 }, 150); 2161 }; 2162 2163 const handleKeydown = (e) => { 2164 // Exact airport code + Enter â resolve immediately, unless the user is actively 2165 // highlighting a dropdown item (that case is handled by the navigation logic below). 2166 if (e.key === "Enter" && 2167 (window.__keyboardHighlightIndex == null || window.__keyboardHighlightIndex < 0)) { 2168 if (tryResolveAirportCode(inputEl.value)) { 2169 e.preventDefault(); 2170 return; 2171 } 2172 } 2173 2174 // Handle keyboard navigation in dropdown 2175 // Check visibility more robustly - menu has content and is attached to DOM 2176 const hasContent = menu && menu.innerHTML.trim() !== ''; 2177 const isAttached = menu && menu.parentElement !== null; 2178 const hasChildren = menu && menu.children.length > 0; 2179 const isDropdownVisible = hasContent && isAttached && hasChildren; 2180 2181 // Debug logging 2182 if (e.key === "ArrowDown" || e.key === "ArrowUp") { 2183 console.log("[GMAPS] Keyboard nav debug:", { 2184 key: e.key, 2185 hasContent, 2186 isAttached, 2187 hasChildren, 2188 isDropdownVisible, 2189 menuChildCount: menu?.children?.length, 2190 highlightIndex: window.__keyboardHighlightIndex 2191 }); 2192 } 2193 2194 if (isDropdownVisible) { 2195 const items = window.__getSelectableItems(menu); 2196 2197 console.log("[GMAPS] Selectable items:", items.length); 2198 2199 if (items.length > 0) { 2200 switch (e.key) { 2201 case "ArrowDown": 2202 e.preventDefault(); 2203 window.__keyboardHighlightIndex = Math.min( 2204 window.__keyboardHighlightIndex + 1, 2205 items.length - 1 2206 ); 2207 console.log("[GMAPS] ArrowDown - new index:", window.__keyboardHighlightIndex); 2208 window.__highlightItemAtIndex(menu, window.__keyboardHighlightIndex, inputEl); 2209 return; 2210 2211 case "ArrowUp": 2212 e.preventDefault(); 2213 if (window.__keyboardHighlightIndex <= 0) { 2214 window.__keyboardHighlightIndex = 0; 2215 } else { 2216 window.__keyboardHighlightIndex--; 2217 } 2218 console.log("[GMAPS] ArrowUp - new index:", window.__keyboardHighlightIndex); 2219 window.__highlightItemAtIndex(menu, window.__keyboardHighlightIndex, inputEl); 2220 return; 2221 2222 case "Enter": 2223 case "Tab": 2224 if (window.__keyboardHighlightIndex >= 0 && window.__keyboardHighlightIndex < items.length) { 2225 e.preventDefault(); 2226 console.log("[GMAPS] " + e.key + " - selecting index:", window.__keyboardHighlightIndex); 2227 const selectedItem = items[window.__keyboardHighlightIndex]; 2228 selectedItem.click(); 2229 window.__keyboardHighlightIndex = -1; 2230 // Tab should also advance focus to the next tabbable field 2231 if (e.key === "Tab") { 2232 const tabbableSelector = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; 2233 const all = Array.from(document.querySelectorAll(tabbableSelector)) 2234 .filter(el => el.offsetParent !== null && el.getAttribute('tabindex') !== '-1'); 2235 const idx = all.indexOf(inputEl); 2236 const next = e.shiftKey 2237 ? (idx > 0 ? all[idx - 1] : null) 2238 : (idx >= 0 && idx + 1 < all.length ? all[idx + 1] : null); 2239 if (next) { 2240 console.log("[GMAPS] Tab - advancing focus to:", next.id || next.tagName); 2241 next.focus(); 2242 } 2243 } 2244 return; 2245 } 2246 break; 2247 } 2248 } 2249 } 2250 2251 // Escape key handling 2252 if (e.key === "Escape") { 2253 window.__resetKeyboardNavigation(menu); 2254 closeDropdown(); 2255 inputEl.blur(); 2256 } 2257 }; 2258 2259 const handleTouchClick = () => { 2260 if (!inputEl.readOnly) { 2261 inputEl.focus(); 2262 } 2263 }; 2264 2265 // Attach event listeners 2266 inputEl.addEventListener("input", handleInput); 2267 inputEl.addEventListener("focus", handleFocus); 2268 inputEl.addEventListener("blur", handleBlur); 2269 inputEl.addEventListener("keydown", handleKeydown); 2270 2271 // iPad Safari keyboard fix: On iPad (wide-screen touch devices), Blazor's async 2272 // WASM event dispatch (from @onfocus/@onclick handlers) intercepts the native 2273 // focus/click events and breaks iPad Safari's user-gesture chain, preventing the 2274 // virtual keyboard from appearing on first tap. Solution: clone the input element 2275 // to strip ALL event listeners (including Blazor's), then re-attach only our 2276 // native JS listeners. cloneNode(true) copies attributes but NOT event listeners. 2277 // NOTE: Only do this for iPad/tablet (wide screen + touch). iPhone uses a modal 2278 // for address entry and doesn't have this keyboard issue â cloning there breaks
2279 // Blazor's @oninput sync and the modal's select/clear workflow. 2280 const isTouchDevice = navigator.maxTouchPoints > 0; 2281 const isTabletTouch = isTouchDevice && window.innerWidth >= 768; 2282 if (isTabletTouch) { 2283 console.log("[GMAPS] Touch device detected - cloning input to strip Blazor event listeners"); 2284 // Save JS properties that cloneNode doesn't copy (it only copies DOM attributes) 2285 const savedAcState = inputEl.__acState; 2286 const savedNearbyHandler = inputEl.__nearbyLocationHandler; 2287 2288 const clone = inputEl.cloneNode(true); 2289 inputEl.parentNode.replaceChild(clone, inputEl); 2290 // Update inputEl to point to the clone â all closure references now use the new element 2291 inputEl = clone; 2292 2293 // Restore JS properties on the clone so updateAutocompleteDropdown can find them 2294 clone.__acState = savedAcState; 2295 if (savedNearbyHandler) { 2296 clone.__nearbyLocationHandler = savedNearbyHandler; 2297 } 2298 2299 // Re-attach our native event listeners on the clean clone 2300 clone.addEventListener("input", handleInput); 2301 clone.addEventListener("focus", handleFocus); 2302 clone.addEventListener("blur", handleBlur); 2303 clone.addEventListener("keydown", handleKeydown); 2304 clone.addEventListener("click", handleTouchClick); 2305 } 2306 2307 // Initial render after short delay (only if we have dropdown data AND no pre-filled value) 2308 // Skip for inline mode (skipInitialDropdown flag) - dropdown shows on focus instead 2309 // Skip for touch devices - DOM manipulation during init suppresses keyboard on iPad Safari 2310 const isTouchInit = isTabletTouch; 2311 setTimeout(() => { 2312 if (locationBias?.skipInitialDropdown) { 2313 console.log("[GMAPS] Skipping initial dropdown render - inline mode"); 2314 } else if (isTouchInit) { 2315 console.log("[GMAPS] Skipping initial dropdown render - touch device (will render on focus)"); 2316 } else if (originalDropdownData && originalDropdownData.sections && initialValue.length === 0) { 2317 renderDropdown(originalDropdownData); 2318 } else if (initialValue.length > 0) { 2319 console.log("[GMAPS] Skipping initial dropdown render - input has pre-filled value"); 2320 } 2321 }, 50); 2322 2323 console.log("[GMAPS] Autocomplete initialized"); 2324}; 2325 2326// Clear autocomplete input value and hide dropdown 2327window.clearAutocompleteInput = function (inputId) { 2328 const inputEl = document.getElementById(inputId); 2329 if (inputEl) { 2330 inputEl.value = ''; 2331 // Hide any open dropdown 2332 if (window.__acMenus) { 2333 window.__acMenus.forEach(menu => { 2334 if (menu?.style) { 2335 menu.style.display = 'none'; 2336 } 2337 }); 2338 } 2339 } 2340}; 2341 2342// Set autocomplete input value (used when converting addresses to airports) 2343window.setAutocompleteInputValue = function (inputId, value) { 2344 const inputEl = document.getElementById(inputId); 2345 if (inputEl) { 2346 inputEl.value = value; 2347 } 2348}; 2349 2350// Trigger autocomplete search for a pre-filled address (chatbot deep links) 2351// This simulates user input to show the dropdown with matching results 2352window.triggerAutocompleteSearch = function (inputId, searchText) { 2353 const inputEl = document.getElementById(inputId); 2354 if (!inputEl) { 2355 console.error('[GMAPS] triggerAutocompleteSearch: input element not found:', inputId); 2356 return; 2357 } 2358 2359 console.log('[GMAPS] triggerAutocompleteSearch:', inputId, searchText); 2360 2361 // Focus the input first 2362 inputEl.focus(); 2363 2364 // Dispatch an input event to trigger the autocomplete search 2365 // This mimics the user typing and will show the dropdown 2366 const inputEvent = new Event('input', { bubbles: true, cancelable: true }); 2367 inputEl.dispatchEvent(inputEvent); 2368}; 2369 2370// Update autocomplete dropdown data (called when pickup changes and we need to update dropoff options) 2371window.updateAutocompleteDropdown = function (inputId, newDropdownData, newAirportsData, newLocationBias) { 2372 const inputEl = document.getElementById(inputId); 2373 if (!inputEl) { 2374 console.error('[GMAPS] updateAutocompleteDropdown: input element not found:', inputId); 2375 return; 2376 } 2377 2378 // Check if this input has autocomplete state stored 2379 if (!inputEl.__acState) { 2380 console.warn('[GMAPS] updateAutocompleteDropdown: no autocomplete state found for:', inputId); 2381 return; 2382 } 2383 2384 console.log("[GMAPS] updateAutocompleteDropdown for:", inputId, { 2385 sections: newDropdownData?.sections?.length, 2386 airports: newAirportsData?.length, 2387 suppressAirports: newLocationBias?.suppressAirports 2388 });
2389 2390 // Update the stored state 2391 inputEl.__acState.dropdownData = newDropdownData; 2392 inputEl.__acState.allAirportsData = newAirportsData; 2393 inputEl.__acState.locationBias = newLocationBias; 2394 2395 // Update the closure variables via the stored update function 2396 if (inputEl.__acState.updateData) { 2397 inputEl.__acState.updateData(newDropdownData, newAirportsData, newLocationBias); 2398 } 2399}; 2400 2401// Cleanup function 2402window.cleanupAutocomplete = function (inputEl) { 2403 // Remove nearbyLocation event listener if attached 2404 if (inputEl?.__nearbyLocationHandler) { 2405 window.removeEventListener('nearbyLocation:ready', inputEl.__nearbyLocationHandler); 2406 delete inputEl.__nearbyLocationHandler; 2407 } 2408 2409 if (window.__acMenus) { 2410 window.__acMenus.forEach(menu => { 2411 if (menu?.parentElement) { 2412 menu.remove(); 2413 } 2414 }); 2415 window.__acMenus.clear(); 2416 } 2417}; 2418 2419console.log("[GOOGLEMAPS-DROPDOWN] Finished loading", window.__gmVersion, "- check for 'Your Location' display");
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.