diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-12 18:23:46 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-12 18:23:46 +0100 |
| commit | ffb8ee87898bffe5fee706abb047133585bb5d0d (patch) | |
| tree | 1735caa33ddce3fb9d847c5e9121451dc5aa76a7 /src | |
| parent | 9d38db605e25febc81f8832f4756cbb6cfc010b8 (diff) | |
feat: enhance OTP service logic, improve planner overlay, and update NavBar styles
Diffstat (limited to 'src')
| -rw-r--r-- | src/Costasdev.Busurbano.Backend/Services/OtpService.cs | 13 | ||||
| -rw-r--r-- | src/frontend/app/components/PlannerOverlay.tsx | 120 | ||||
| -rw-r--r-- | src/frontend/app/components/layout/NavBar.module.css | 1 | ||||
| -rw-r--r-- | src/frontend/app/components/layout/NavBar.tsx | 19 | ||||
| -rw-r--r-- | src/frontend/app/root.tsx | 4 | ||||
| -rw-r--r-- | src/frontend/app/routes/map.tsx | 30 | ||||
| -rw-r--r-- | src/frontend/app/routes/planner.tsx | 409 |
7 files changed, 449 insertions, 147 deletions
diff --git a/src/Costasdev.Busurbano.Backend/Services/OtpService.cs b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs index 82c43e0..87895d3 100644 --- a/src/Costasdev.Busurbano.Backend/Services/OtpService.cs +++ b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs @@ -161,13 +161,24 @@ public class OtpService int cardTicketsRequired = 0; DateTime? lastTicketPurchased = null; + int tripsPaidWithTicket = 0; foreach (var leg in busLegs) { - if (lastTicketPurchased == null || (leg.StartTime - lastTicketPurchased.Value).TotalMinutes > 45) + // If no ticket purchased, ticket expired (no free transfers after 45 mins), or max trips with ticket reached + if ( + lastTicketPurchased == null || + (leg.StartTime - lastTicketPurchased.Value).TotalMinutes > 45 || + tripsPaidWithTicket >= 3 + ) { cardTicketsRequired++; lastTicketPurchased = leg.StartTime; + tripsPaidWithTicket = 1; + } + else + { + tripsPaidWithTicket++; } } diff --git a/src/frontend/app/components/PlannerOverlay.tsx b/src/frontend/app/components/PlannerOverlay.tsx index 622884e..8046ab2 100644 --- a/src/frontend/app/components/PlannerOverlay.tsx +++ b/src/frontend/app/components/PlannerOverlay.tsx @@ -1,4 +1,10 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import { reverseGeocode, @@ -20,6 +26,7 @@ interface PlannerOverlayProps { inline?: boolean; clearPickerOnOpen?: boolean; showLastDestinationWhenCollapsed?: boolean; + cardBackground?: string; } export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ @@ -29,6 +36,7 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ inline, clearPickerOnOpen = false, showLastDestinationWhenCollapsed = true, + cardBackground, }) => { const { t } = useTranslation(); const { origin, setOrigin, destination, setDestination, loading, error } = @@ -108,6 +116,14 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ 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) => { @@ -121,34 +137,75 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ setPickerOpen(false); }; - const setOriginFromCurrentLocation = () => { - if (!navigator.geolocation) return; - setLocationLoading(true); - navigator.geolocation.getCurrentPosition( - async (pos) => { - try { - const rev = await reverseGeocode( + 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 ); - const picked: PlannerSearchResult = { - name: rev?.name || "Ubicación actual", - label: rev?.label || "GPS", - lat: pos.coords.latitude, - lon: pos.coords.longitude, - layer: "current-location", - }; - setOrigin(picked); - setOriginQuery(picked.name || ""); - setPickerOpen(false); - } finally { + 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); - } - }, - () => setLocationLoading(false), - { enableHighAccuracy: true, timeout: 10000 } - ); - }; + }, + { enableHighAccuracy: true, timeout: 10000 } + ); + }, + [setOrigin, t] + ); useEffect(() => { if (!pickerOpen) return; @@ -199,8 +256,8 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ : "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 bg-white dark:bg-slate-900 px-2 flex flex-col gap-3" - : "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 bg-white/95 dark:bg-slate-900/90 shadow-2xl backdrop-blur"; + ? `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 ( <div className={wrapperClass}> @@ -346,7 +403,12 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ time = targetDate; } - onSearch(origin, destination, time, timeMode === "arrive"); + await onSearch( + origin, + destination, + time, + timeMode === "arrive" + ); // After search, if origin was current location, switch to reverse-geocoded address if ( @@ -492,7 +554,7 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ <li className="border-t border-slate-100 dark:border-slate-700 px-4 py-2 text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 bg-slate-50 dark:bg-slate-800/70"> {remoteLoading ? t("planner.searching_ellipsis") - : t("planner.results")} + : t("planner.results", "Results")} </li> )} {remoteResults.map((r, i) => ( diff --git a/src/frontend/app/components/layout/NavBar.module.css b/src/frontend/app/components/layout/NavBar.module.css index ddace40..19d0a93 100644 --- a/src/frontend/app/components/layout/NavBar.module.css +++ b/src/frontend/app/components/layout/NavBar.module.css @@ -5,7 +5,6 @@ padding: 0.5rem 0; background-color: var(--background-color); - border-top: 1px solid var(--border-color); max-width: 48rem; margin-inline: auto; diff --git a/src/frontend/app/components/layout/NavBar.tsx b/src/frontend/app/components/layout/NavBar.tsx index 69b3a63..9c42987 100644 --- a/src/frontend/app/components/layout/NavBar.tsx +++ b/src/frontend/app/components/layout/NavBar.tsx @@ -1,7 +1,8 @@ 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"; +import { Link, useLocation, useNavigate } from "react-router"; +import { usePlanner } from "~/hooks/usePlanner"; import { useApp } from "../../AppContext"; import styles from "./NavBar.module.css"; @@ -28,6 +29,8 @@ export default function NavBar({ orientation = "horizontal" }: NavBarProps) { const { t } = useTranslation(); const { mapState, updateMapState, mapPositionMode } = useApp(); const location = useLocation(); + const navigate = useNavigate(); + const { deselectItinerary } = usePlanner(); const navItems = [ { @@ -94,8 +97,18 @@ export default function NavBar({ orientation = "horizontal" }: NavBarProps) { <Link key={item.name} to={item.path} - className={`${styles.link} ${isActive ? styles.active : ""}`} - onClick={item.callback ? item.callback : undefined} + className={`${styles.link} ${isActive ? styles.active : ""}${item.path === "/planner" ? " planner-nav-link" : ""}`} + onClick={(e) => { + if ( + item.path === "/planner" && + location.pathname === "/planner" + ) { + deselectItinerary(); + window.location.reload(); + } else if (item.callback) { + item.callback(); + } + }} title={item.name} aria-label={item.name} > diff --git a/src/frontend/app/root.tsx b/src/frontend/app/root.tsx index 9c105f0..49c9dc8 100644 --- a/src/frontend/app/root.tsx +++ b/src/frontend/app/root.tsx @@ -24,10 +24,6 @@ import "./i18n"; export const links: Route.LinksFunction = () => []; -export function HydrateFallback() { - return "Cargando..."; -} - export function Layout({ children }: { children: React.ReactNode }) { return ( <html lang="es"> diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx index 3d59efb..39fc062 100644 --- a/src/frontend/app/routes/map.tsx +++ b/src/frontend/app/routes/map.tsx @@ -45,38 +45,11 @@ export default function StopMap() { const { mapState, updateMapState, theme } = useApp(); const mapRef = useRef<MapRef>(null); - const { searchRoute, origin, setOrigin } = usePlanner(); + const { searchRoute } = usePlanner(); // Style state for Map component const [mapStyle, setMapStyle] = useState<StyleSpecification>(DEFAULT_STYLE); - // Set default origin to current location on first load (map page) - useEffect(() => { - // On the map page, always default to current location on load, - // overriding any previously used address. The user can change it after. - if (!navigator.geolocation) return; - navigator.geolocation.getCurrentPosition( - async (pos) => { - try { - // Keep display as "Current location" until a search is performed - setOrigin({ - name: t("planner.current_location"), - label: "GPS", - lat: pos.coords.latitude, - lon: pos.coords.longitude, - layer: "current-location", - }); - } catch (_) { - // ignore - } - }, - () => { - // ignore geolocation errors; user can set origin manually - }, - { enableHighAccuracy: true, timeout: 10000 } - ); - }, [setOrigin, t]); - // Handle click events on clusters and individual stops const onMapClick = (e: MapLayerMouseEvent) => { const features = e.features; @@ -222,6 +195,7 @@ export default function StopMap() { onNavigateToPlanner={() => navigate("/planner")} clearPickerOnOpen={true} showLastDestinationWhenCollapsed={false} + cardBackground="bg-white/95 dark:bg-slate-900/90" /> <Map diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx index c44a672..0f52fef 100644 --- a/src/frontend/app/routes/planner.tsx +++ b/src/frontend/app/routes/planner.tsx @@ -3,18 +3,44 @@ import maplibregl, { type StyleSpecification } from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import Map, { Layer, Marker, Source, type MapRef } from "react-map-gl/maplibre"; +import Map, { Layer, Source, type MapRef } from "react-map-gl/maplibre"; import { useLocation } from "react-router"; import { useApp } from "~/AppContext"; import LineIcon from "~/components/LineIcon"; import { PlannerOverlay } from "~/components/PlannerOverlay"; import { REGION_DATA } from "~/config/RegionConfig"; +import { usePageTitle } from "~/contexts/PageTitleContext"; import { type Itinerary } from "~/data/PlannerApi"; import { usePlanner } from "~/hooks/usePlanner"; import { DEFAULT_STYLE, loadStyle } from "~/maps/styleloader"; import "../tailwind-full.css"; +export interface ConsolidatedCirculation { + line: string; + route: string; + schedule?: { + running: boolean; + minutes: number; + serviceId: string; + tripId: string; + shapeId?: string; + }; + realTime?: { + minutes: number; + distance: number; + }; + currentPosition?: { + latitude: number; + longitude: number; + orientationDegrees: number; + shapeIndex?: number; + }; + isPreviousTrip?: boolean; + previousTripShapeId?: string; + nextStreets?: string[]; +} + const FARE_CASH_PER_BUS = 1.63; const FARE_CARD_PER_BUS = 0.67; @@ -106,14 +132,16 @@ const ItinerarySummary = ({ return ( <div - className="bg-white p-4 rounded-lg shadow mb-3 cursor-pointer hover:bg-gray-50 border border-gray-200" + className="bg-white dark:bg-slate-800 p-4 rounded-lg shadow mb-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-slate-700 border border-gray-200 dark:border-slate-700" onClick={onClick} > <div className="flex justify-between items-center mb-2"> - <div className="font-bold text-lg"> + <div className="font-bold text-lg text-slate-900 dark:text-slate-100"> {startTime} - {endTime} </div> - <div className="text-gray-600">{durationMinutes} min</div> + <div className="text-gray-600 dark:text-gray-400"> + {durationMinutes} min + </div> </div> <div className="flex items-center gap-2 overflow-x-auto pb-2"> @@ -146,7 +174,7 @@ const ItinerarySummary = ({ <div className="flex items-center gap-2"> <LineIcon line={leg.routeShortName || leg.routeName || leg.mode || ""} - mode="rounded" + mode="pill" /> </div> )} @@ -155,7 +183,7 @@ const ItinerarySummary = ({ })} </div> - <div className="flex items-center justify-between text-sm text-slate-600 mt-1"> + <div className="flex items-center justify-between text-sm text-slate-600 dark:text-slate-400 mt-1"> <span> {t("planner.walk")}: {formatDistance(walkTotals.meters)} {walkTotals.minutes @@ -163,11 +191,11 @@ const ItinerarySummary = ({ : ""} </span> <span className="flex items-center gap-3"> - <span className="flex items-center gap-1 font-semibold text-slate-700"> + <span className="flex items-center gap-1 font-semibold text-slate-700 dark:text-slate-300"> <Coins className="w-4 h-4" /> {formatCurrency(cashFare)} </span> - <span className="flex items-center gap-1 text-slate-600"> + <span className="flex items-center gap-1 text-slate-600 dark:text-slate-400"> <CreditCard className="w-4 h-4" /> {t("planner.card_fare", { amount: cardFare })} </span> @@ -187,6 +215,9 @@ const ItineraryDetail = ({ const { t } = useTranslation(); const mapRef = useRef<MapRef>(null); const { destination: userDestination } = usePlanner(); + const [nextArrivals, setNextArrivals] = useState< + Record<string, ConsolidatedCirculation[]> + >({}); const routeGeoJson = { type: "FeatureCollection", @@ -208,18 +239,41 @@ const ItineraryDetail = ({ })), }; - // Collect unique stops with their roles (board, alight, transfer) - const stopMarkers = useMemo(() => { + // Create GeoJSON for all markers + const markersGeoJson = useMemo(() => { + const features: any[] = []; + const origin = itinerary.legs[0]?.from; + const destination = itinerary.legs[itinerary.legs.length - 1]?.to; + + // Origin marker (red) + if (origin?.lat && origin?.lon) { + features.push({ + type: "Feature", + geometry: { type: "Point", coordinates: [origin.lon, origin.lat] }, + properties: { type: "origin", name: origin.name || "Origin" }, + }); + } + + // Destination marker (green) + if (destination?.lat && destination?.lon) { + features.push({ + type: "Feature", + geometry: { + type: "Point", + coordinates: [destination.lon, destination.lat], + }, + properties: { + type: "destination", + name: destination.name || "Destination", + }, + }); + } + + // Collect unique stops with their roles (board, alight, transfer) const stopsMap: Record< string, - { - lat: number; - lon: number; - name: string; - type: "board" | "alight" | "transfer"; - } + { lat: number; lon: number; name: string; type: string } > = {}; - itinerary.legs.forEach((leg, idx) => { if (leg.mode !== "WALK") { // Boarding stop @@ -254,7 +308,30 @@ const ItineraryDetail = ({ } }); - return Object.values(stopsMap); + // Add stop markers + Object.values(stopsMap).forEach((stop) => { + features.push({ + type: "Feature", + geometry: { type: "Point", coordinates: [stop.lon, stop.lat] }, + properties: { type: stop.type, name: stop.name }, + }); + }); + + // Add intermediate stops + itinerary.legs.forEach((leg) => { + leg.intermediateStops?.forEach((stop) => { + features.push({ + type: "Feature", + geometry: { type: "Point", coordinates: [stop.lon, stop.lat] }, + properties: { + type: "intermediate", + name: stop.name || "Intermediate stop", + }, + }); + }); + }); + + return { type: "FeatureCollection", features }; }, [itinerary]); // Get origin and destination coordinates @@ -276,6 +353,17 @@ const ItineraryDetail = ({ ); }); + // Also include markers (origin, destination, transfers, intermediate) so all are visible + markersGeoJson.features.forEach((feature: any) => { + if ( + feature.geometry?.type === "Point" && + Array.isArray(feature.geometry.coordinates) + ) { + const [lng, lat] = feature.geometry.coordinates as [number, number]; + bounds.extend([lng, lat]); + } + }); + // Ensure bounds are valid before fitting if (!bounds.isEmpty()) { mapRef.current.fitBounds(bounds, { padding: 80, duration: 1000 }); @@ -284,18 +372,53 @@ const ItineraryDetail = ({ }, 100); return () => clearTimeout(timer); - }, [itinerary]); + }, [mapRef.current, itinerary]); const { theme } = useApp(); const [mapStyle, setMapStyle] = useState<StyleSpecification>(DEFAULT_STYLE); useEffect(() => { const styleName = "openfreemap"; - loadStyle(styleName, theme) + loadStyle(styleName, theme, { includeTraffic: false }) .then((style) => setMapStyle(style)) .catch((error) => console.error("Failed to load map style:", error)); }, [theme]); + // Fetch next arrivals for bus legs + useEffect(() => { + const fetchArrivals = async () => { + const arrivalsByStop: Record<string, ConsolidatedCirculation[]> = {}; + + for (const leg of itinerary.legs) { + if (leg.mode !== "WALK" && leg.from?.stopId) { + const stopKey = leg.from.name || leg.from.stopId; + if (!arrivalsByStop[stopKey]) { + try { + const resp = await fetch( + `${REGION_DATA.consolidatedCirculationsEndpoint}?stopId=${encodeURIComponent(leg.from.stopCode || leg.from.stopId)}`, + { headers: { Accept: "application/json" } } + ); + + if (resp.ok) { + const data: ConsolidatedCirculation[] = await resp.json(); + arrivalsByStop[stopKey] = data; + } + } catch (err) { + console.warn( + `Failed to fetch arrivals for ${leg.from.stopId}:`, + err + ); + } + } + } + } + + setNextArrivals(arrivalsByStop); + }; + + fetchArrivals(); + }, [itinerary]); + return ( <div className="flex flex-col md:flex-row h-full"> {/* Map Section */} @@ -320,7 +443,6 @@ const ItineraryDetail = ({ paint={{ "line-color": ["get", "color"], "line-width": 5, - // Dotted for walking segments, solid for bus segments "line-dasharray": [ "case", ["==", ["get", "mode"], "WALK"], @@ -331,55 +453,119 @@ const ItineraryDetail = ({ /> </Source> - {/* Origin marker (red) */} - {origin?.lat && origin?.lon && ( - <Marker longitude={origin.lon} latitude={origin.lat}> - <div className="w-6 h-6 bg-red-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center"> - <div className="w-2 h-2 bg-white rounded-full"></div> - </div> - </Marker> - )} - - {/* Destination marker (green) */} - {destination?.lat && destination?.lon && ( - <Marker longitude={destination.lon} latitude={destination.lat}> - <div className="w-6 h-6 bg-green-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center"> - <div className="w-2 h-2 bg-white rounded-full"></div> - </div> - </Marker> - )} - - {/* Stop markers (boarding, alighting, transfer) */} - {stopMarkers.map((stop, idx) => ( - <Marker key={idx} longitude={stop.lon} latitude={stop.lat}> - <div - className={`w-5 h-5 rounded-full border-2 border-white shadow-md ${ - stop.type === "board" - ? "bg-blue-500" - : stop.type === "alight" - ? "bg-purple-500" - : "bg-orange-500" - }`} - title={`${stop.name} (${stop.type})`} - /> - </Marker> - ))} - - {/* Intermediate stops (smaller white dots) */} - {itinerary.legs.map((leg, legIdx) => - leg.intermediateStops?.map((stop, stopIdx) => ( - <Marker - key={`intermediate-${legIdx}-${stopIdx}`} - longitude={stop.lon} - latitude={stop.lat} - > - <div - className="w-3 h-3 rounded-full border border-gray-400 bg-white shadow-sm" - title={stop.name || "Intermediate stop"} - /> - </Marker> - )) - )} + {/* All markers as GeoJSON layers */} + <Source id="markers" type="geojson" data={markersGeoJson as any}> + {/* Outer circle for origin/destination markers */} + <Layer + id="markers-outer" + type="circle" + filter={[ + "in", + ["get", "type"], + ["literal", ["origin", "destination"]], + ]} + paint={{ + "circle-radius": [ + "interpolate", + ["linear"], + ["zoom"], + 10, + 6, + 16, + 8, + 20, + 10, + ], + "circle-color": [ + "case", + ["==", ["get", "type"], "origin"], + "#dc2626", + "#16a34a", + ], + "circle-stroke-width": 2, + "circle-stroke-color": "#ffffff", + }} + /> + {/* Inner circle for origin/destination markers */} + <Layer + id="markers-inner" + type="circle" + filter={[ + "in", + ["get", "type"], + ["literal", ["origin", "destination"]], + ]} + paint={{ + "circle-radius": [ + "interpolate", + ["linear"], + ["zoom"], + 10, + 2, + 16, + 3, + 20, + 4, + ], + "circle-color": "#ffffff", + }} + /> + {/* Stop markers (board, alight, transfer) */} + <Layer + id="markers-stops" + type="circle" + filter={[ + "in", + ["get", "type"], + ["literal", ["board", "alight", "transfer"]], + ]} + paint={{ + "circle-radius": [ + "interpolate", + ["linear"], + ["zoom"], + 10, + 4, + 16, + 6, + 20, + 7, + ], + "circle-color": [ + "case", + ["==", ["get", "type"], "board"], + "#3b82f6", + ["==", ["get", "type"], "alight"], + "#a855f7", + "#f97316", + ], + "circle-stroke-width": 2, + "circle-stroke-color": "#ffffff", + }} + /> + {/* Intermediate stops (smaller white dots) */} + <Layer + id="markers-intermediate" + type="circle" + filter={["==", ["get", "type"], "intermediate"]} + paint={{ + "circle-radius": [ + "interpolate", + ["linear"], + ["zoom"], + 10, + 2, + 16, + 3, + 20, + 4, + ], + "circle-color": "#ffffff", + "circle-stroke-width": 1, + "circle-stroke-color": "#9ca3af", + }} + /> + </Source> </Map> <button @@ -393,7 +579,7 @@ const ItineraryDetail = ({ {/* Details Panel */} <div className="h-1/3 md:h-full md:w-96 lg:w-md overflow-y-auto bg-white dark:bg-slate-900 border-t md:border-t-0 md:border-l border-slate-200 dark:border-slate-700"> <div className="px-4 py-4"> - <h2 className="text-xl font-bold mb-4"> + <h2 className="text-xl font-bold mb-4 text-slate-900 dark:text-slate-100"> {t("planner.itinerary_details")} </h2> @@ -438,7 +624,7 @@ const ItineraryDetail = ({ </> )} </div> - <div className="text-sm text-gray-600"> + <div className="text-sm text-gray-600 dark:text-gray-400"> {new Date(leg.startTime).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", @@ -451,6 +637,47 @@ const ItineraryDetail = ({ ).toFixed(0)}{" "} {t("estimates.minutes")} </div> + {leg.mode !== "WALK" && + leg.from?.name && + nextArrivals[leg.from.name] && ( + <div className="mt-2 text-xs text-gray-600 dark:text-gray-400"> + <div className="font-semibold mb-1"> + {t("planner.next_arrivals", "Next arrivals")}: + </div> + {nextArrivals[leg.from.name] + .filter( + (circ) => + circ.line === + (leg.routeShortName || leg.routeName) + ) + .slice(0, 2) + .map((circ, idx) => { + const minutes = + circ.realTime?.minutes ?? circ.schedule?.minutes; + if (minutes === undefined) return null; + return ( + <div + key={idx} + className="flex items-center gap-2 py-0.5" + > + <span className="font-semibold"> + {circ.line} + </span> + <span className="text-gray-500 dark:text-gray-500"> + → + </span> + <span className="flex-1 truncate"> + {circ.route} + </span> + <span className="font-semibold text-emerald-600 dark:text-emerald-400"> + {minutes} {t("estimates.minutes")} + {circ.realTime && " 🟢"} + </span> + </div> + ); + })} + </div> + )} <div className="text-sm mt-1"> {leg.mode === "WALK" ? ( <span> @@ -518,6 +745,7 @@ const ItineraryDetail = ({ export default function PlannerPage() { const { t } = useTranslation(); + usePageTitle(t("navbar.planner", "Planificador")); const location = useLocation(); const { plan, @@ -541,15 +769,31 @@ export default function PlannerPage() { plan.itineraries[selectedItineraryIndex] ) { setSelectedItinerary(plan.itineraries[selectedItineraryIndex]); + } else { + setSelectedItinerary(null); } }, [plan, selectedItineraryIndex]); - // When navigating to /planner (even if already on it), reset the active itinerary + // Intercept back button when viewing itinerary detail useEffect(() => { - setSelectedItinerary(null); - deselectItinerary(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [location.key]); + const handlePopState = (e: PopStateEvent) => { + if (selectedItinerary) { + e.preventDefault(); + setSelectedItinerary(null); + deselectItinerary(); + window.history.pushState(null, "", window.location.href); + } + }; + + if (selectedItinerary) { + window.history.pushState(null, "", window.location.href); + window.addEventListener("popstate", handlePopState); + } + + return () => { + window.removeEventListener("popstate", handlePopState); + }; + }, [selectedItinerary, deselectItinerary]); if (selectedItinerary) { return ( @@ -579,13 +823,14 @@ export default function PlannerPage() { onSearch={(origin, destination, time, arriveBy) => searchRoute(origin, destination, time, arriveBy) } + cardBackground="bg-transparent" /> {plan && ( <div> <div className="flex justify-between items-center my-4"> <div> - <h2 className="text-xl font-bold"> + <h2 className="text-xl font-bold text-slate-900 dark:text-slate-100"> {t("planner.results_title")} </h2> {searchTimeDisplay && ( @@ -601,12 +846,14 @@ export default function PlannerPage() { </div> {plan.itineraries.length === 0 ? ( - <div className="p-8 text-center bg-gray-50 rounded-lg border border-dashed border-gray-300"> + <div className="p-8 text-center bg-gray-50 dark:bg-slate-800 rounded-lg border border-dashed border-gray-300 dark:border-slate-600"> <div className="text-4xl mb-2">😕</div> - <h3 className="text-lg font-bold mb-1"> + <h3 className="text-lg font-bold mb-1 text-slate-900 dark:text-slate-100"> {t("planner.no_routes_found")} </h3> - <p className="text-gray-600">{t("planner.no_routes_message")}</p> + <p className="text-gray-600 dark:text-gray-400"> + {t("planner.no_routes_message")} + </p> </div> ) : ( <div className="space-y-3"> |
