aboutsummaryrefslogtreecommitdiff
path: root/src/frontend
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-12-12 10:24:43 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-12-12 10:24:54 +0100
commit3a1a1e6dc2f6f0abceac5da0cfb530fdb45fc6f5 (patch)
tree0b887eece835ff12ebd2eea831483407223e1a22 /src/frontend
parentd65ce8288bbda3cb6e0b37613c29d7bf52703ba7 (diff)
Initial ultra-ñapa implementation of OTP integration
Diffstat (limited to 'src/frontend')
-rw-r--r--src/frontend/app/components/layout/AppShell.css27
-rw-r--r--src/frontend/app/components/layout/AppShell.tsx3
-rw-r--r--src/frontend/app/components/layout/NavBar.module.css3
-rw-r--r--src/frontend/app/components/layout/NavBar.tsx7
-rw-r--r--src/frontend/app/config/RegionConfig.ts5
-rw-r--r--src/frontend/app/data/PlannerApi.ts96
-rw-r--r--src/frontend/app/hooks/usePlanner.ts101
-rw-r--r--src/frontend/app/maps/styleloader.ts8
-rw-r--r--src/frontend/app/routes.tsx1
-rw-r--r--src/frontend/app/routes/map.tsx16
-rw-r--r--src/frontend/app/routes/planner.tsx415
11 files changed, 644 insertions, 38 deletions
diff --git a/src/frontend/app/components/layout/AppShell.css b/src/frontend/app/components/layout/AppShell.css
index eee678c..17aae8c 100644
--- a/src/frontend/app/components/layout/AppShell.css
+++ b/src/frontend/app/components/layout/AppShell.css
@@ -14,20 +14,12 @@
.app-shell__body {
display: flex;
+ flex-direction: column;
flex: 1;
overflow: hidden;
position: relative;
}
-.app-shell__sidebar {
- display: none; /* Hidden on mobile */
- width: 80px;
- border-right: 1px solid var(--border-color);
- background: var(--background-color);
- flex-shrink: 0;
- z-index: 5;
-}
-
.app-shell__main {
flex: 1;
overflow: auto;
@@ -37,17 +29,12 @@
.app-shell__bottom-nav {
flex-shrink: 0;
- display: block; /* Visible on mobile */
+ display: block;
z-index: 10;
-}
-
-/* Desktop styles */
-@media (min-width: 768px) {
- .app-shell__sidebar {
- display: block;
- }
- .app-shell__bottom-nav {
- display: none;
- }
+ position: sticky;
+ bottom: 0;
+ width: 100%;
+ background: var(--background-color);
+ border-top: 1px solid var(--border-color);
}
diff --git a/src/frontend/app/components/layout/AppShell.tsx b/src/frontend/app/components/layout/AppShell.tsx
index 08aee59..afc19f3 100644
--- a/src/frontend/app/components/layout/AppShell.tsx
+++ b/src/frontend/app/components/layout/AppShell.tsx
@@ -24,9 +24,6 @@ const AppShellContent: React.FC = () => {
/>
<Drawer isOpen={isDrawerOpen} onClose={() => setIsDrawerOpen(false)} />
<div className="app-shell__body">
- <aside className="app-shell__sidebar">
- <NavBar orientation="vertical" />
- </aside>
<main className="app-shell__main">
<Outlet />
</main>
diff --git a/src/frontend/app/components/layout/NavBar.module.css b/src/frontend/app/components/layout/NavBar.module.css
index 504b93b..6b46459 100644
--- a/src/frontend/app/components/layout/NavBar.module.css
+++ b/src/frontend/app/components/layout/NavBar.module.css
@@ -6,6 +6,9 @@
background-color: var(--background-color);
border-top: 1px solid var(--border-color);
+
+ max-width: 500px;
+ margin-inline: auto;
}
.vertical {
diff --git a/src/frontend/app/components/layout/NavBar.tsx b/src/frontend/app/components/layout/NavBar.tsx
index 40591c4..150755f 100644
--- a/src/frontend/app/components/layout/NavBar.tsx
+++ b/src/frontend/app/components/layout/NavBar.tsx
@@ -1,4 +1,4 @@
-import { Home, Map, Route } from "lucide-react";
+import { Home, Map, Navigation2, Route } from "lucide-react";
import type { LngLatLike } from "maplibre-gl";
import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router";
@@ -71,6 +71,11 @@ export default function NavBar({ orientation = "horizontal" }: NavBarProps) {
icon: Route,
path: "/lines",
},
+ {
+ name: t("navbar.planner", "Planificador"),
+ icon: Navigation2,
+ path: "/planner",
+ },
];
return (
diff --git a/src/frontend/app/config/RegionConfig.ts b/src/frontend/app/config/RegionConfig.ts
index 75da06d..d595b3f 100644
--- a/src/frontend/app/config/RegionConfig.ts
+++ b/src/frontend/app/config/RegionConfig.ts
@@ -28,7 +28,10 @@ export const REGION_DATA: RegionData = {
consolidatedCirculationsEndpoint: "/api/vigo/GetConsolidatedCirculations",
timetableEndpoint: "/api/vigo/GetStopTimetable",
shapeEndpoint: "/api/vigo/GetShape",
- defaultCenter: [42.229188855975046, -8.72246955783102] as LngLatLike,
+ defaultCenter: {
+ lat: 42.229188855975046,
+ lng: -8.72246955783102,
+ } as LngLatLike,
bounds: {
sw: [-8.951059, 42.098923] as LngLatLike,
ne: [-8.447748, 42.3496] as LngLatLike,
diff --git a/src/frontend/app/data/PlannerApi.ts b/src/frontend/app/data/PlannerApi.ts
new file mode 100644
index 0000000..db47dcc
--- /dev/null
+++ b/src/frontend/app/data/PlannerApi.ts
@@ -0,0 +1,96 @@
+export interface PlannerSearchResult {
+ name?: string;
+ label?: string;
+ lat: number;
+ lon: number;
+ layer?: string;
+}
+
+export interface RoutePlan {
+ itineraries: Itinerary[];
+}
+
+export interface Itinerary {
+ durationSeconds: number;
+ startTime: string;
+ endTime: string;
+ walkDistanceMeters: number;
+ walkTimeSeconds: number;
+ transitTimeSeconds: number;
+ waitingTimeSeconds: number;
+ legs: Leg[];
+}
+
+export interface Leg {
+ mode?: string;
+ routeName?: string;
+ routeShortName?: string;
+ routeLongName?: string;
+ headsign?: string;
+ agencyName?: string;
+ from?: PlannerPlace;
+ to?: PlannerPlace;
+ startTime: string;
+ endTime: string;
+ distanceMeters: number;
+ geometry?: PlannerGeometry;
+ steps: Step[];
+}
+
+export interface PlannerPlace {
+ name?: string;
+ lat: number;
+ lon: number;
+ stopId?: string;
+ stopCode?: string;
+}
+
+export interface PlannerGeometry {
+ type: string;
+ coordinates: number[][];
+}
+
+export interface Step {
+ distanceMeters: number;
+ relativeDirection?: string;
+ absoluteDirection?: string;
+ streetName?: string;
+ lat: number;
+ lon: number;
+}
+
+export async function searchPlaces(
+ query: string
+): Promise<PlannerSearchResult[]> {
+ const response = await fetch(
+ `/api/planner/autocomplete?query=${encodeURIComponent(query)}`
+ );
+ if (!response.ok) return [];
+ return response.json();
+}
+
+export async function reverseGeocode(
+ lat: number,
+ lon: number
+): Promise<PlannerSearchResult | null> {
+ const response = await fetch(`/api/planner/reverse?lat=${lat}&lon=${lon}`);
+ if (!response.ok) return null;
+ return response.json();
+}
+
+export async function planRoute(
+ fromLat: number,
+ fromLon: number,
+ toLat: number,
+ toLon: number,
+ time?: Date,
+ arriveBy: boolean = false
+): Promise<RoutePlan> {
+ let url = `/api/planner/plan?fromLat=${fromLat}&fromLon=${fromLon}&toLat=${toLat}&toLon=${toLon}&arriveBy=${arriveBy}`;
+ if (time) {
+ url += `&time=${time.toISOString()}`;
+ }
+ const response = await fetch(url);
+ if (!response.ok) throw new Error("Failed to plan route");
+ return response.json();
+}
diff --git a/src/frontend/app/hooks/usePlanner.ts b/src/frontend/app/hooks/usePlanner.ts
new file mode 100644
index 0000000..1572896
--- /dev/null
+++ b/src/frontend/app/hooks/usePlanner.ts
@@ -0,0 +1,101 @@
+import { useEffect, useState } from "react";
+import {
+ type PlannerSearchResult,
+ type RoutePlan,
+ planRoute,
+} from "../data/PlannerApi";
+
+const STORAGE_KEY = "planner_last_route";
+const EXPIRY_MS = 2 * 60 * 60 * 1000; // 2 hours
+
+interface StoredRoute {
+ timestamp: number;
+ origin: PlannerSearchResult;
+ destination: PlannerSearchResult;
+ plan: RoutePlan;
+}
+
+export function usePlanner() {
+ const [origin, setOrigin] = useState<PlannerSearchResult | null>(null);
+ const [destination, setDestination] = useState<PlannerSearchResult | null>(
+ null
+ );
+ const [plan, setPlan] = useState<RoutePlan | null>(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ // Load from storage on mount
+ useEffect(() => {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored) {
+ try {
+ const data: StoredRoute = JSON.parse(stored);
+ if (Date.now() - data.timestamp < EXPIRY_MS) {
+ setOrigin(data.origin);
+ setDestination(data.destination);
+ setPlan(data.plan);
+ } else {
+ localStorage.removeItem(STORAGE_KEY);
+ }
+ } catch (e) {
+ localStorage.removeItem(STORAGE_KEY);
+ }
+ }
+ }, []);
+
+ const searchRoute = async (
+ from: PlannerSearchResult,
+ to: PlannerSearchResult,
+ time?: Date,
+ arriveBy: boolean = false
+ ) => {
+ setLoading(true);
+ setError(null);
+ try {
+ const result = await planRoute(
+ from.lat,
+ from.lon,
+ to.lat,
+ to.lon,
+ time,
+ arriveBy
+ );
+ setPlan(result);
+ setOrigin(from);
+ setDestination(to);
+
+ // Save to storage
+ const toStore: StoredRoute = {
+ timestamp: Date.now(),
+ origin: from,
+ destination: to,
+ plan: result,
+ };
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore));
+ } catch (err) {
+ setError("Failed to calculate route. Please try again.");
+ setPlan(null);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const clearRoute = () => {
+ setPlan(null);
+ setOrigin(null);
+ setDestination(null);
+ localStorage.removeItem(STORAGE_KEY);
+ };
+
+ return {
+ origin,
+ setOrigin,
+ destination,
+ setDestination,
+ plan,
+ loading,
+ error,
+ searchRoute,
+ clearRoute,
+ };
+}
diff --git a/src/frontend/app/maps/styleloader.ts b/src/frontend/app/maps/styleloader.ts
index 8109e0b..7d90116 100644
--- a/src/frontend/app/maps/styleloader.ts
+++ b/src/frontend/app/maps/styleloader.ts
@@ -5,6 +5,14 @@ export interface StyleLoaderOptions {
includeTraffic?: boolean;
}
+export const DEFAULT_STYLE: StyleSpecification = {
+ version: 8,
+ glyphs: `${window.location.origin}/maps/fonts/{fontstack}/{range}.pbf`,
+ sprite: `${window.location.origin}/maps/spritesheet/sprite`,
+ sources: {},
+ layers: [],
+};
+
export async function loadStyle(
styleName: string,
colorScheme: Theme,
diff --git a/src/frontend/app/routes.tsx b/src/frontend/app/routes.tsx
index 16d0da7..052eb83 100644
--- a/src/frontend/app/routes.tsx
+++ b/src/frontend/app/routes.tsx
@@ -9,4 +9,5 @@ export default [
route("/settings", "routes/settings.tsx"),
route("/about", "routes/about.tsx"),
route("/favourites", "routes/favourites.tsx"),
+ route("/planner", "routes/planner.tsx"),
] satisfies RouteConfig;
diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx
index 187e9f2..182f4ce 100644
--- a/src/frontend/app/routes/map.tsx
+++ b/src/frontend/app/routes/map.tsx
@@ -1,7 +1,7 @@
import StopDataProvider, { type Stop } from "../data/StopDataProvider";
import "./map.css";
-import { loadStyle } from "app/maps/styleloader";
+import { DEFAULT_STYLE, loadStyle } from "app/maps/styleloader";
import type { Feature as GeoJsonFeature, Point } from "geojson";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -19,15 +19,6 @@ import { REGION_DATA } from "~/config/RegionConfig";
import { usePageTitle } from "~/contexts/PageTitleContext";
import { useApp } from "../AppContext";
-// Default minimal fallback style before dynamic loading
-const defaultStyle: StyleSpecification = {
- version: 8,
- glyphs: `${window.location.origin}/maps/fonts/{fontstack}/{range}.pbf`,
- sprite: `${window.location.origin}/maps/spritesheet/sprite`,
- sources: {},
- layers: [],
-};
-
// Componente principal del mapa
export default function StopMap() {
const { t } = useTranslation();
@@ -48,10 +39,9 @@ export default function StopMap() {
const [isSheetOpen, setIsSheetOpen] = useState(false);
const { mapState, updateMapState, theme } = useApp();
const mapRef = useRef<MapRef>(null);
- const [mapStyleKey, setMapStyleKey] = useState<string>("light");
// Style state for Map component
- const [mapStyle, setMapStyle] = useState<StyleSpecification>(defaultStyle);
+ const [mapStyle, setMapStyle] = useState<StyleSpecification>(DEFAULT_STYLE);
// Handle click events on clusters and individual stops
const onMapClick = (e: MapLayerMouseEvent) => {
@@ -111,7 +101,7 @@ export default function StopMap() {
loadStyle(styleName, theme)
.then((style) => setMapStyle(style))
.catch((error) => console.error("Failed to load map style:", error));
- }, [mapStyleKey, theme]);
+ }, [theme]);
useEffect(() => {
const handleMapChange = () => {
diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx
new file mode 100644
index 0000000..094ff8e
--- /dev/null
+++ b/src/frontend/app/routes/planner.tsx
@@ -0,0 +1,415 @@
+import maplibregl, { type StyleSpecification } from "maplibre-gl";
+import "maplibre-gl/dist/maplibre-gl.css";
+import React, { useEffect, useRef, useState } from "react";
+import Map, { Layer, Source, type MapRef } from "react-map-gl/maplibre";
+import { Sheet } from "react-modal-sheet";
+import { useApp } from "~/AppContext";
+import { REGION_DATA } from "~/config/RegionConfig";
+import {
+ searchPlaces,
+ type Itinerary,
+ type PlannerSearchResult,
+} from "~/data/PlannerApi";
+import { usePlanner } from "~/hooks/usePlanner";
+import { DEFAULT_STYLE, loadStyle } from "~/maps/styleloader";
+import "../tailwind-full.css";
+
+// --- Components ---
+
+const AutocompleteInput = ({
+ label,
+ value,
+ onChange,
+ placeholder,
+}: {
+ label: string;
+ value: PlannerSearchResult | null;
+ onChange: (val: PlannerSearchResult | null) => void;
+ placeholder: string;
+}) => {
+ const [query, setQuery] = useState(value?.name || "");
+ const [results, setResults] = useState<PlannerSearchResult[]>([]);
+ const [showResults, setShowResults] = useState(false);
+
+ useEffect(() => {
+ if (value) setQuery(value.name || "");
+ }, [value]);
+
+ useEffect(() => {
+ const timer = setTimeout(async () => {
+ if (query.length > 2 && query !== value?.name) {
+ const res = await searchPlaces(query);
+ setResults(res);
+ setShowResults(true);
+ } else {
+ setResults([]);
+ }
+ }, 500);
+ return () => clearTimeout(timer);
+ }, [query, value]);
+
+ return (
+ <div className="mb-4 relative">
+ <label className="block text-sm font-medium text-gray-700 mb-1">
+ {label}
+ </label>
+ <div className="flex gap-2">
+ <input
+ type="text"
+ className="w-full p-2 border rounded shadow-sm"
+ value={query}
+ onChange={(e) => {
+ setQuery(e.target.value);
+ if (!e.target.value) onChange(null);
+ }}
+ placeholder={placeholder}
+ onFocus={() => setShowResults(true)}
+ />
+ {value && (
+ <button
+ onClick={() => {
+ setQuery("");
+ onChange(null);
+ }}
+ className="px-2 text-gray-500"
+ >
+ ✕
+ </button>
+ )}
+ </div>
+ {showResults && results.length > 0 && (
+ <ul className="absolute z-10 w-full bg-white border rounded shadow-lg mt-1 max-h-60 overflow-auto">
+ {results.map((res, idx) => (
+ <li
+ key={idx}
+ className="p-2 hover:bg-gray-100 cursor-pointer border-b last:border-b-0"
+ onClick={() => {
+ onChange(res);
+ setQuery(res.name || "");
+ setShowResults(false);
+ }}
+ >
+ <div className="font-medium">{res.name}</div>
+ <div className="text-xs text-gray-500">{res.label}</div>
+ </li>
+ ))}
+ </ul>
+ )}
+ </div>
+ );
+};
+
+const ItinerarySummary = ({
+ itinerary,
+ onClick,
+}: {
+ itinerary: Itinerary;
+ onClick: () => void;
+}) => {
+ const durationMinutes = Math.round(itinerary.durationSeconds / 60);
+ const startTime = new Date(itinerary.startTime).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ const endTime = new Date(itinerary.endTime).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+
+ return (
+ <div
+ className="bg-white p-4 rounded-lg shadow mb-3 cursor-pointer hover:bg-gray-50 border border-gray-200"
+ onClick={onClick}
+ >
+ <div className="flex justify-between items-center mb-2">
+ <div className="font-bold text-lg">
+ {startTime} - {endTime}
+ </div>
+ <div className="text-gray-600">{durationMinutes} min</div>
+ </div>
+ <div className="flex items-center gap-2 overflow-x-auto pb-2">
+ {itinerary.legs.map((leg, idx) => (
+ <React.Fragment key={idx}>
+ {idx > 0 && <span className="text-gray-400">›</span>}
+ <div
+ className={`px-2 py-1 rounded text-sm whitespace-nowrap ${
+ leg.mode === "WALK"
+ ? "bg-gray-200 text-gray-700"
+ : "bg-blue-600 text-white"
+ }`}
+ >
+ {leg.mode === "WALK" ? "Walk" : leg.routeShortName || leg.mode}
+ </div>
+ </React.Fragment>
+ ))}
+ </div>
+ <div className="text-sm text-gray-500 mt-1">
+ Walk: {Math.round(itinerary.walkDistanceMeters)}m
+ </div>
+ </div>
+ );
+};
+
+const ItineraryDetail = ({
+ itinerary,
+ onClose,
+}: {
+ itinerary: Itinerary;
+ onClose: () => void;
+}) => {
+ const mapRef = useRef<MapRef>(null);
+ const [sheetOpen, setSheetOpen] = useState(true);
+
+ // Prepare GeoJSON for the route
+ const routeGeoJson = {
+ type: "FeatureCollection",
+ features: itinerary.legs.map((leg) => ({
+ type: "Feature",
+ geometry: {
+ type: "LineString",
+ coordinates: leg.geometry?.coordinates || [],
+ },
+ properties: {
+ mode: leg.mode,
+ color: leg.mode === "WALK" ? "#9ca3af" : "#2563eb", // Gray for walk, Blue for transit
+ },
+ })),
+ };
+
+ // Fit bounds on mount
+ useEffect(() => {
+ if (mapRef.current && itinerary.legs.length > 0) {
+ const bounds = new maplibregl.LngLatBounds();
+ itinerary.legs.forEach((leg) => {
+ leg.geometry?.coordinates.forEach((coord) => {
+ bounds.extend([coord[0], coord[1]]);
+ });
+ });
+ mapRef.current.fitBounds(bounds, { padding: 50 });
+ }
+ }, [itinerary]);
+
+ const { theme } = useApp();
+
+ const [mapStyle, setMapStyle] = useState<StyleSpecification>(DEFAULT_STYLE);
+ useEffect(() => {
+ //const styleName = "carto";
+ const styleName = "openfreemap";
+ loadStyle(styleName, theme)
+ .then((style) => setMapStyle(style))
+ .catch((error) => console.error("Failed to load map style:", error));
+ }, [theme]);
+
+ return (
+ <div className="fixed inset-0 z-50 bg-white flex flex-col">
+ <div className="relative flex-1">
+ <Map
+ ref={mapRef}
+ initialViewState={{
+ longitude: REGION_DATA.defaultCenter.lng,
+ latitude: REGION_DATA.defaultCenter.lat,
+ zoom: 13,
+ }}
+ mapStyle={mapStyle}
+ attributionControl={false}
+ >
+ <Source id="route" type="geojson" data={routeGeoJson as any}>
+ <Layer
+ id="route-line"
+ type="line"
+ layout={{
+ "line-join": "round",
+ "line-cap": "round",
+ }}
+ paint={{
+ "line-color": ["get", "color"],
+ "line-width": 5,
+ }}
+ />
+ </Source>
+ {/* Markers for start/end/transfers could be added here */}
+ </Map>
+
+ <button
+ onClick={onClose}
+ className="absolute top-4 left-4 bg-white p-2 rounded-full shadow z-10"
+ >
+ ← Back
+ </button>
+ </div>
+
+ <Sheet
+ isOpen={sheetOpen}
+ onClose={() => setSheetOpen(false)}
+ detent="content"
+ initialSnap={0}
+ >
+ <Sheet.Container>
+ <Sheet.Header />
+ <Sheet.Content className="px-4 pb-4 overflow-y-auto">
+ <h2 className="text-xl font-bold mb-4">Itinerary Details</h2>
+ <div className="space-y-4">
+ {itinerary.legs.map((leg, idx) => (
+ <div key={idx} className="flex gap-3">
+ <div className="flex flex-col items-center">
+ <div
+ className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold ${
+ leg.mode === "WALK"
+ ? "bg-gray-200 text-gray-700"
+ : "bg-blue-600 text-white"
+ }`}
+ >
+ {leg.mode === "WALK" ? "🚶" : "🚌"}
+ </div>
+ {idx < itinerary.legs.length - 1 && (
+ <div className="w-0.5 flex-1 bg-gray-300 my-1"></div>
+ )}
+ </div>
+ <div className="flex-1 pb-4">
+ <div className="font-bold">
+ {leg.mode === "WALK"
+ ? "Walk"
+ : `${leg.routeShortName} ${leg.headsign}`}
+ </div>
+ <div className="text-sm text-gray-600">
+ {new Date(leg.startTime).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ })}
+ {" - "}
+ {new Date(leg.endTime).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ })}
+ </div>
+ <div className="text-sm mt-1">
+ {leg.mode === "WALK" ? (
+ <span>
+ Walk {Math.round(leg.distanceMeters)}m to{" "}
+ {leg.to?.name}
+ </span>
+ ) : (
+ <span>
+ From {leg.from?.name} to {leg.to?.name}
+ </span>
+ )}
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </Sheet.Content>
+ </Sheet.Container>
+ <Sheet.Backdrop onTap={() => setSheetOpen(false)} />
+ </Sheet>
+ </div>
+ );
+};
+
+// --- Main Page ---
+
+export default function PlannerPage() {
+ const {
+ origin,
+ setOrigin,
+ destination,
+ setDestination,
+ plan,
+ loading,
+ error,
+ searchRoute,
+ clearRoute,
+ } = usePlanner();
+
+ const [selectedItinerary, setSelectedItinerary] = useState<Itinerary | null>(
+ null
+ );
+
+ const handleSearch = () => {
+ if (origin && destination) {
+ searchRoute(origin, destination);
+ }
+ };
+
+ if (selectedItinerary) {
+ return (
+ <ItineraryDetail
+ itinerary={selectedItinerary}
+ onClose={() => setSelectedItinerary(null)}
+ />
+ );
+ }
+
+ return (
+ <div className="p-4 max-w-md mx-auto pb-20">
+ <h1 className="text-2xl font-bold mb-4">Route Planner</h1>
+
+ {/* Form */}
+ <div className="bg-white p-4 rounded-lg shadow mb-6">
+ <AutocompleteInput
+ label="From"
+ value={origin}
+ onChange={setOrigin}
+ placeholder="Search origin..."
+ />
+ <AutocompleteInput
+ label="To"
+ value={destination}
+ onChange={setDestination}
+ placeholder="Search destination..."
+ />
+
+ <button
+ onClick={handleSearch}
+ disabled={!origin || !destination || loading}
+ className={`w-full py-3 rounded font-bold text-white ${
+ !origin || !destination || loading
+ ? "bg-gray-400"
+ : "bg-green-600 hover:bg-green-700"
+ }`}
+ >
+ {loading ? "Calculating..." : "Find Route"}
+ </button>
+
+ {error && (
+ <div className="mt-4 p-3 bg-red-100 text-red-700 rounded">
+ {error}
+ </div>
+ )}
+ </div>
+
+ {/* Results */}
+ {plan && (
+ <div>
+ <div className="flex justify-between items-center mb-4">
+ <h2 className="text-xl font-bold">Results</h2>
+ <button onClick={clearRoute} className="text-sm text-red-500">
+ Clear
+ </button>
+ </div>
+
+ {plan.itineraries.length === 0 ? (
+ <div className="p-8 text-center bg-gray-50 rounded-lg border border-dashed border-gray-300">
+ <div className="text-4xl mb-2">😕</div>
+ <h3 className="text-lg font-bold mb-1">No routes found</h3>
+ <p className="text-gray-600">
+ We couldn't find a route for your trip. Try changing the time or
+ locations.
+ </p>
+ </div>
+ ) : (
+ <div className="space-y-3">
+ {plan.itineraries.map((itinerary, idx) => (
+ <ItinerarySummary
+ key={idx}
+ itinerary={itinerary}
+ onClick={() => setSelectedItinerary(itinerary)}
+ />
+ ))}
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}