import { MapPin } from "lucide-react"; import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { useTranslation } from "react-i18next"; import PlaceListItem from "~/components/PlaceListItem"; import { REGION_DATA } from "~/config/RegionConfig"; import { reverseGeocode, searchPlaces, type PlannerSearchResult, } from "~/data/PlannerApi"; import StopDataProvider from "~/data/StopDataProvider"; import { usePlanner } from "~/hooks/usePlanner"; interface PlannerOverlayProps { onSearch: ( origin: PlannerSearchResult, destination: PlannerSearchResult, time?: Date, arriveBy?: boolean ) => void; onNavigateToPlanner?: () => void; forceExpanded?: boolean; inline?: boolean; clearPickerOnOpen?: boolean; showLastDestinationWhenCollapsed?: boolean; cardBackground?: string; } export const PlannerOverlay: React.FC = ({ onSearch, onNavigateToPlanner, forceExpanded, inline, clearPickerOnOpen = false, showLastDestinationWhenCollapsed = true, cardBackground, }) => { const { t } = useTranslation(); const { origin, setOrigin, destination, setDestination, loading, error } = usePlanner(); const [isExpanded, setIsExpanded] = useState(false); const [originQuery, setOriginQuery] = useState(origin?.name || ""); const [destQuery, setDestQuery] = useState(""); type PickerField = "origin" | "destination"; const [pickerOpen, setPickerOpen] = useState(false); const [pickerField, setPickerField] = useState("destination"); const [pickerQuery, setPickerQuery] = useState(""); const [remoteResults, setRemoteResults] = useState([]); const [remoteLoading, setRemoteLoading] = useState(false); const [favouriteStops, setFavouriteStops] = useState( [] ); const [recentPlaces, setRecentPlaces] = useState([]); const RECENT_KEY = `recentPlaces_${REGION_DATA.id}`; const clearRecentPlaces = useCallback(() => { setRecentPlaces([]); try { localStorage.removeItem(RECENT_KEY); } catch {} }, []); const pickerInputRef = useRef(null); const [locationLoading, setLocationLoading] = useState(false); const [timeMode, setTimeMode] = useState<"now" | "depart" | "arrive">("now"); const [timeValue, setTimeValue] = useState(""); const [dateOffset, setDateOffset] = useState(0); // 0 = today, 1 = tomorrow, etc. const canSubmit = useMemo( () => Boolean(origin && destination) && !loading, [origin, destination, loading] ); useEffect(() => { setOriginQuery( origin?.layer === "current-location" ? t("planner.current_location") : origin?.name || "" ); }, [origin, t]); useEffect(() => { setDestQuery(destination?.name || ""); }, [destination]); useEffect(() => { // Load favourites once; used as local suggestions in the picker. StopDataProvider.getStops() .then((stops) => stops .filter((s) => s.favourite && s.latitude && s.longitude) .map( (s) => ({ name: StopDataProvider.getDisplayName(s), label: s.stopId, lat: s.latitude as number, lon: s.longitude as number, layer: "favourite-stop", }) satisfies PlannerSearchResult ) ) .then((mapped) => setFavouriteStops(mapped)) .catch(() => setFavouriteStops([])); }, []); // Load recent places from localStorage useEffect(() => { try { const raw = localStorage.getItem(RECENT_KEY); if (raw) { const parsed = JSON.parse(raw) as PlannerSearchResult[]; setRecentPlaces(parsed.slice(0, 20)); } } catch { setRecentPlaces([]); } }, []); const addRecentPlace = useCallback( (p: PlannerSearchResult) => { const key = `${p.lat.toFixed(5)},${p.lon.toFixed(5)}`; const existing = recentPlaces.filter( (rp) => `${rp.lat.toFixed(5)},${rp.lon.toFixed(5)}` !== key ); const updated = [ { name: p.name, label: p.label, lat: p.lat, lon: p.lon, layer: p.layer, }, ...existing, ].slice(0, 20); setRecentPlaces(updated); try { localStorage.setItem(RECENT_KEY, JSON.stringify(updated)); } catch {} }, [recentPlaces] ); const filteredFavouriteStops = useMemo(() => { const q = pickerQuery.trim().toLowerCase(); if (!q) return favouriteStops; return favouriteStops.filter( (s) => (s.name || "").toLowerCase().includes(q) || (s.label || "").toLowerCase().includes(q) ); }, [favouriteStops, pickerQuery]); const sortedRemoteResults = useMemo(() => { const order: Record = { venue: 0, address: 1, street: 2 }; const q = pickerQuery.trim().toLowerCase(); const base = q ? remoteResults.filter( (s) => (s.name || "").toLowerCase().includes(q) || (s.label || "").toLowerCase().includes(q) ) : remoteResults; return [...base].sort((a, b) => { const oa = order[a.layer || ""] ?? 99; const ob = order[b.layer || ""] ?? 99; if (oa !== ob) return oa - ob; // Secondary: shorter label first, then name alpha const la = (a.label || "").length; const lb = (b.label || "").length; if (la !== lb) return la - lb; return (a.name || "").localeCompare(b.name || ""); }); }, [remoteResults, pickerQuery]); const openPicker = (field: PickerField) => { setPickerField(field); setPickerQuery( clearPickerOnOpen ? "" : field === "origin" ? originQuery : destQuery ); setPickerOpen(true); // When opening destination picker, auto-fill origin from current location if not set if (field === "destination" && !origin) { console.log( "[PlannerOverlay] Destination picker opened with no origin, requesting geolocation" ); setOriginFromCurrentLocation(false); } }; const applyPickedResult = (result: PlannerSearchResult) => { if (pickerField === "origin") { setOrigin(result); setOriginQuery(result.name || ""); } else { setDestination(result); setDestQuery(result.name || ""); } addRecentPlace(result); setPickerOpen(false); }; const setOriginFromCurrentLocation = useCallback( (closePicker: boolean = true) => { console.log( "[PlannerOverlay] setOriginFromCurrentLocation called, closePicker:", closePicker ); if (!navigator.geolocation) { console.warn("[PlannerOverlay] Geolocation not available"); return; } setLocationLoading(true); navigator.geolocation.getCurrentPosition( async (pos) => { console.log( "[PlannerOverlay] Geolocation success:", pos.coords.latitude, pos.coords.longitude ); try { // Set immediately using raw coordinates; refine later if reverse geocode works. const initial: PlannerSearchResult = { name: t("planner.current_location"), label: "GPS", lat: pos.coords.latitude, lon: pos.coords.longitude, layer: "current-location", }; console.log("[PlannerOverlay] Setting initial origin:", initial); setOrigin(initial); setOriginQuery(initial.name || ""); try { const rev = await reverseGeocode( pos.coords.latitude, pos.coords.longitude ); console.log("[PlannerOverlay] Reverse geocode result:", rev); if (rev) { const refined: PlannerSearchResult = { ...initial, name: rev.name || initial.name, label: rev.label || initial.label, layer: "current-location", }; console.log( "[PlannerOverlay] Setting refined origin:", refined ); setOrigin(refined); setOriginQuery(refined.name || ""); } } catch (err) { console.error("[PlannerOverlay] Reverse geocode failed:", err); } if (closePicker) setPickerOpen(false); } finally { setLocationLoading(false); } }, (err) => { console.error("[PlannerOverlay] Geolocation error:", err); setLocationLoading(false); }, { enableHighAccuracy: true, timeout: 10000 } ); }, [setOrigin, t] ); useEffect(() => { if (!pickerOpen) return; // Focus the modal input on open. const t = setTimeout(() => pickerInputRef.current?.focus(), 0); return () => clearTimeout(t); }, [pickerOpen]); useEffect(() => { if (!pickerOpen) return; const q = pickerQuery.trim(); if (q.length < 3) { setRemoteResults([]); setRemoteLoading(false); return; } let cancelled = false; setRemoteLoading(true); const t = setTimeout(async () => { try { const results = await searchPlaces(q); if (!cancelled) setRemoteResults(results); } finally { if (!cancelled) setRemoteLoading(false); } }, 250); return () => { cancelled = true; clearTimeout(t); }; }, [pickerOpen, pickerQuery]); // Allow external triggers (e.g., map movements) to collapse the widget, unless forced expanded useEffect(() => { if (forceExpanded) return; const handler = () => setIsExpanded(false); window.addEventListener("plannerOverlay:collapse", handler); return () => window.removeEventListener("plannerOverlay:collapse", handler); }, [forceExpanded]); // Derive expanded state const expanded = forceExpanded ?? isExpanded; const wrapperClass = inline ? "w-full" : "pointer-events-none absolute left-0 right-0 top-0 z-20 flex justify-center"; const cardClass = inline ? `pointer-events-auto w-full overflow-hidden rounded-xl px-2 flex flex-col gap-3 ${cardBackground || "bg-white dark:bg-slate-900"}` : `pointer-events-auto w-[min(640px,calc(100%-16px))] px-2 py-1 flex flex-col gap-3 m-4 overflow-hidden rounded-xl border border-slate-200/80 dark:border-slate-700/70 shadow-2xl backdrop-blur ${cardBackground || "bg-white/95 dark:bg-slate-900/90"}`; return (
{!expanded ? ( ) : ( <>
{t("planner.when")}
{timeMode !== "now" && (
setTimeValue(e.target.value)} />
)}
{error && (
{error}
)} )}
{pickerOpen && (
    {pickerField === "origin" && (
  • )} {(remoteLoading || sortedRemoteResults.length > 0) && (
  • {remoteLoading ? t("planner.searching_ellipsis") : t("planner.results", "Results")}
  • )} {sortedRemoteResults.map((r, i) => ( ))} {filteredFavouriteStops.length > 0 && ( <>
  • {t("planner.favourite_stops")}
  • {filteredFavouriteStops.map((r, i) => ( ))} )} {recentPlaces.length > 0 && (
  • {t("planner.recent_locations", "Recent locations")}
  • )} {recentPlaces.map((r, i) => (
  • ))}
)} ); };