import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { Navigation } from 'lucide-react'; import { cn } from '../../shared/utils'; import { useGoogleMapsLoader } from '../google-maps-loader'; import { useOptionalApiKey } from '../../../contexts/ApiKeyContext'; export type RouteTravelMode = 'DRIVING' | 'WALKING' | 'BICYCLING' | 'TRANSIT'; /** One leg of a multi-stop plan. */ export interface RouteDefinition { id: string; origin: { lat: number; lng: number }; destination: { lat: number; lng: number }; waypoints?: Array<{ lat: number; lng: number }>; travelMode?: RouteTravelMode; /** Semantic token for the drawn line. Falls back to `--primary`. */ colorToken?: `--${string}`; label?: string; } export interface RouteMapProps extends React.HTMLAttributes { /** * Single route. Kept as the primary API — `routes` is additive. * Required unless `routes` is supplied. */ origin?: { lat: number; lng: number }; destination?: { lat: number; lng: number }; waypoints?: Array<{ lat: number; lng: number }>; travelMode?: RouteTravelMode; /** * Several routes at once, each rendered with its own renderer and color. * A transition plan across sites needs distance and duration per leg, which a * single route could not express. */ routes?: RouteDefinition[]; /** Fires once per route as the Directions service returns it. */ onLegCalculated?: (routeId: string, distance: string, duration: string) => void; /** * Hands the route to the device's navigation app. Called with the route and a * ready-made Google Maps deep link the handler may use or ignore. */ onOpenExternalNavigation?: (route: RouteDefinition, url: string) => void; height?: string; mapId?: string; apiKey?: string; mapContainerClassName?: string; disableDefaultUI?: boolean; zoomControl?: boolean; streetViewControl?: boolean; mapTypeControl?: boolean; fullscreenControl?: boolean; onRouteCalculated?: (distance: string, duration: string) => void; /** Title shown when the Google Maps script fails to load. @default "Failed to load Google Maps" */ loadErrorTitle?: string; /** Hint shown below `loadErrorTitle`. @default "Check API key in Settings" */ loadErrorHint?: string; /** @default "Open in navigation" */ externalNavigationLabel?: string; } /** * Builds a Google Maps directions deep link for a route. * * Universal cross-platform URL: iOS and Android hand it to their maps app, and * the desktop browser opens the web client. Exported so a consumer can build the * link without wiring `onOpenExternalNavigation`. */ export function buildExternalNavigationUrl(route: RouteDefinition): string { const params = new URLSearchParams({ api: '1', origin: `${route.origin.lat},${route.origin.lng}`, destination: `${route.destination.lat},${route.destination.lng}`, travelmode: (route.travelMode ?? 'DRIVING').toLowerCase(), }); if (route.waypoints?.length) { params.set('waypoints', route.waypoints.map(w => `${w.lat},${w.lng}`).join('|')); } return `https://www.google.com/maps/dir/?${params.toString()}`; } const RouteMapContent = React.forwardRef( ({ apiKey, ...props }, ref) => { const { isLoaded, loadError, load } = useGoogleMapsLoader(); const { origin, destination, waypoints = [], travelMode = 'DRIVING', height = '450px', mapContainerClassName, disableDefaultUI = false, zoomControl = true, streetViewControl = false, mapTypeControl = false, fullscreenControl = true, onRouteCalculated, loadErrorTitle = 'Failed to load Google Maps', loadErrorHint = 'Check API key in Settings', externalNavigationLabel = 'Open in navigation', className, ...divProps } = props; /* * The singular origin/destination and the plural `routes` normalise into one * list, so the render path below only ever deals with N routes and the old * two-prop API keeps working untouched. */ const resolvedRoutes: RouteDefinition[] = useMemo(() => { if (props.routes?.length) return props.routes; if (!origin || !destination) return []; return [{ id: '__single', origin, destination, waypoints, travelMode }]; }, [props.routes, origin, destination, waypoints, travelMode]); const mapRef = useRef(null); const gmpMapRef = useRef(null); // Ref for custom element const renderersRef = useRef(new Map()); const [mapInstance, setMapInstance] = useState(null); const handleExternalNavigation = useCallback( (route: RouteDefinition) => { props.onOpenExternalNavigation?.(route, buildExternalNavigationUrl(route)); }, // eslint-disable-next-line react-hooks/exhaustive-deps [props.onOpenExternalNavigation] ); const onLegCalculatedRef = useRef(props.onLegCalculated); const onRouteCalculatedRef = useRef(onRouteCalculated); onLegCalculatedRef.current = props.onLegCalculated; onRouteCalculatedRef.current = onRouteCalculated; // Initialize map useEffect(() => { // Load API if needed if (!isLoaded && apiKey && !loadError && load) { load(apiKey).catch(console.error); } }, [isLoaded, apiKey, loadError, load]); // Handle gmp-map initialization and DirectionsRenderer useEffect(() => { if (!isLoaded || !gmpMapRef.current) return; const setupMap = (map: google.maps.Map) => { mapRef.current = map; setMapInstance(map); }; const gmpMap = gmpMapRef.current; if (gmpMap.innerMap) { setupMap(gmpMap.innerMap); } else { const interval = setInterval(() => { if (gmpMap.innerMap) { setupMap(gmpMap.innerMap); clearInterval(interval); } }, 100); return () => clearInterval(interval); } return () => { mapRef.current = null; setMapInstance(null); }; }, [isLoaded]); // Sync properties with gmp-map custom element. With several routes the // camera centres on the first one's origin. const initialCenter = resolvedRoutes[0]?.origin; useEffect(() => { if (gmpMapRef.current && initialCenter) { gmpMapRef.current.center = initialCenter; } }, [initialCenter?.lat, initialCenter?.lng]); useEffect(() => { if (gmpMapRef.current) { gmpMapRef.current.zoom = 13; } }, []); // mapId is now set declaratively on the gmp-map element const waypointsKey = useMemo( () => waypoints.map(wp => `${wp.lat},${wp.lng}`).join(';'), [waypoints] ); /** * One DirectionsRenderer per route, reconciled by id. * * A shared renderer can only hold one result at a time, which is why the * single-route limit existed. Each route also reports its own distance and * duration through `onLegCalculated`, so a transition plan can be costed * leg by leg. */ useEffect(() => { const map = mapInstance; if (!map || !isLoaded || typeof google === 'undefined') return; const computedStyle = getComputedStyle(document.documentElement); const fallbackColor = computedStyle.getPropertyValue('--primary').trim() || '#4F46E5'; const service = new google.maps.DirectionsService(); const live = new Set(); resolvedRoutes.forEach(route => { live.add(route.id); const color = route.colorToken ? computedStyle.getPropertyValue(route.colorToken).trim() || fallbackColor : fallbackColor; const renderer = renderersRef.current.get(route.id) ?? new google.maps.DirectionsRenderer({ suppressMarkers: false }); renderersRef.current.set(route.id, renderer); renderer.setMap(map); renderer.setOptions({ polylineOptions: { strokeColor: color, strokeWeight: 5, strokeOpacity: 0.8 }, }); service.route( { origin: route.origin, destination: route.destination, waypoints: (route.waypoints ?? []).map(wp => ({ location: wp, stopover: true })), travelMode: google.maps.TravelMode[route.travelMode ?? 'DRIVING'], }, (result, status) => { if (status !== 'OK' || !result) return; renderer.setDirections(result); const legs = result.routes[0]?.legs ?? []; if (legs.length === 0) return; let totalDistance = 0; let totalDuration = 0; legs.forEach(leg => { if (leg.distance) totalDistance += leg.distance.value; if (leg.duration) totalDuration += leg.duration.value; }); const distanceText = `${(totalDistance / 1000).toFixed(1)} km`; const hours = Math.floor(totalDuration / 3600); const minutes = Math.floor((totalDuration % 3600) / 60); const durationText = hours > 0 ? `${hours}h ${minutes}min` : `${minutes} min`; onLegCalculatedRef.current?.(route.id, distanceText, durationText); // Preserved for the single-route API. if (route.id === '__single') onRouteCalculatedRef.current?.(distanceText, durationText); } ); }); renderersRef.current.forEach((renderer, id) => { if (live.has(id)) return; renderer.setMap(null); renderersRef.current.delete(id); }); }, [mapInstance, isLoaded, resolvedRoutes]); useEffect(() => { const pool = renderersRef.current; return () => { pool.forEach(renderer => renderer.setMap(null)); pool.clear(); }; }, []); if (loadError) { // ... (Error state same as before) return (

{loadErrorTitle}

{loadErrorHint}

); } if (!isLoaded) { return (
); } return (
{props.onOpenExternalNavigation && resolvedRoutes.length > 0 ? (
{resolvedRoutes.map(route => ( ))}
) : null} {/* @ts-ignore */}
); } ); RouteMapContent.displayName = 'RouteMapContent'; /** * Specialized route display and calculation component for Google Maps. * * @description * Calculates and renders a route between an `origin` and `destination` point * using the Google Directions Service. Supports intermediate `waypoints` and * multiple `travelMode` options (DRIVING, WALKING, BICYCLING, TRANSIT). * * @ai-rules * 1. Provide `origin` and `destination` as LatLng objects `{ lat, lng }`. * 2. Use `onRouteCalculated` to receive the pre-formatted distance and duration strings. * 3. `travelMode` accepts 'DRIVING', 'WALKING', 'BICYCLING', or 'TRANSIT'. * 4. The Directions API must be enabled on your Google Maps API key. */ export const RouteMap = React.forwardRef((props, ref) => { const { isLoaded, loadError } = useGoogleMapsLoader(); const apiKeyContext = useOptionalApiKey(); // Nota: import.meta.env.VITE_GOOGLE_MAPS_API_KEY não pode ser lido aqui — // a lib é pré-buildada com Vite e esse valor já é inlinado (undefined) no // build da própria xertica-ui, não no build do app consumidor. O fallback // precisa vir de um contexto resolvido em runtime no bundle do consumidor. const effectiveApiKey = props.apiKey || apiKeyContext?.googleMapsApiKey || ''; const isValidKey = effectiveApiKey && effectiveApiKey !== 'YOUR_GOOGLE_MAPS_API_KEY_HERE' && effectiveApiKey.startsWith('AIza'); if (isLoaded || isValidKey || loadError) { return ; } // Check if the script is injected in the DOM (loading via provider) const isScriptInjected = typeof document !== 'undefined' && !!document.querySelector('script[src*="maps.googleapis.com/maps/api/js"]'); if (isScriptInjected) { return ; } const { origin, destination, waypoints, travelMode, height, apiKey, mapContainerClassName, disableDefaultUI, zoomControl, streetViewControl, mapTypeControl, fullscreenControl, onRouteCalculated, ...divProps } = props; return (

Configure Google Maps API Key in Settings

); }); RouteMap.displayName = 'RouteMap';