diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2026-01-30 20:48:51 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2026-01-30 20:49:04 +0100 |
| commit | bb6366af0c07116ecb54144dca129f099127d4c3 (patch) | |
| tree | 01a806319070806a13f883d96756d260912c527f /src/frontend/app/routes | |
| parent | 073c7174490ed3d8ae34c3f8c8f1b91bce711f6f (diff) | |
feat: Add trip selection functionality and localization updates for trips in English, Spanish, and Galician
Diffstat (limited to 'src/frontend/app/routes')
| -rw-r--r-- | src/frontend/app/routes/routes-$id.tsx | 196 |
1 files changed, 165 insertions, 31 deletions
diff --git a/src/frontend/app/routes/routes-$id.tsx b/src/frontend/app/routes/routes-$id.tsx index 7de16eb..6fe0424 100644 --- a/src/frontend/app/routes/routes-$id.tsx +++ b/src/frontend/app/routes/routes-$id.tsx @@ -1,5 +1,13 @@ import { useQuery } from "@tanstack/react-query"; -import { LayoutGrid, List, Map as MapIcon } from "lucide-react"; +import { + Bus, + ChevronDown, + Clock, + LayoutGrid, + List, + Map as MapIcon, + X, +} from "lucide-react"; import { useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { @@ -28,6 +36,7 @@ export default function RouteDetailsPage() { const [layoutMode, setLayoutMode] = useState<"balanced" | "map" | "list">( "balanced" ); + const [isPatternPickerOpen, setIsPatternPickerOpen] = useState(false); const [selectedWeekDate, setSelectedWeekDate] = useState<Date>( () => new Date() ); @@ -148,6 +157,10 @@ export default function RouteDetailsPage() { const selectedPattern = activePatterns.find((p) => p.id === selectedPatternId) || activePatterns[0]; + const selectedPatternLabel = selectedPattern + ? selectedPattern.headsign || selectedPattern.name + : t("routes.details", "Detalles de ruta"); + const mapHeightClass = layoutMode === "map" ? "h-[75%] md:h-[75%]" @@ -312,37 +325,25 @@ export default function RouteDetailsPage() { <div className="px-3 py-2 bg-surface border-y border-border"> <div className="flex items-center gap-2"> - <select - className="w-full px-3 py-1.5 box-border bg-surface text-text focus:ring-2 focus:ring-primary outline-none text-sm rounded-md border border-border flex-2" - value={selectedPattern?.id} - onChange={(e) => { - setSelectedPatternId(e.target.value); - setSelectedStopId(null); - }} + <button + type="button" + onClick={() => setIsPatternPickerOpen(true)} + className="w-full flex-2 px-3 py-1.5 text-left box-border bg-surface text-text text-sm rounded-md border border-border hover:border-primary/60 focus:ring-2 focus:ring-primary outline-none" > - {Object.entries(patternsByDirection).map(([dir, patterns]) => ( - <optgroup - key={dir} - label={ - dir === "0" - ? t("routes.direction_outbound", "Ida") - : t("routes.direction_inbound", "Vuelta") - } - > - {patterns.map((pattern) => ( - <option key={pattern.id} value={pattern.id}> - {pattern.code - ? `${parseInt(pattern.code.slice(-2)).toString()}: ` - : ""} - {pattern.headsign || pattern.name}{" "} - {t("routes.trip_count_short", { - count: pattern.tripCount, - })} - </option> - ))} - </optgroup> - ))} - </select> + <div className="flex items-center gap-2"> + <span className="truncate font-medium"> + {selectedPatternLabel} + </span> + {selectedPattern?.tripCount != null && ( + <span className="text-xs text-muted"> + {t("routes.trip_count_short", { + count: selectedPattern.tripCount, + })} + </span> + )} + <ChevronDown size={16} className="ml-auto text-muted" /> + </div> + </button> <select className="w-full px-3 py-1.5 box-border bg-surface text-text focus:ring-2 focus:ring-primary outline-none text-sm rounded-md border border-border flex-1" @@ -366,6 +367,139 @@ export default function RouteDetailsPage() { </div> </div> + {isPatternPickerOpen && ( + <div + className="absolute inset-0 z-20 flex items-end sm:items-center justify-center bg-black/40" + onClick={() => setIsPatternPickerOpen(false)} + > + <div + className="w-full sm:max-w-lg bg-background rounded-t-2xl sm:rounded-2xl border border-border shadow-xl max-h-[75%] overflow-hidden" + onClick={(event) => event.stopPropagation()} + > + <div className="flex items-center justify-between px-4 py-3 border-b border-border"> + <div> + <p className="text-sm font-semibold text-text"> + {t("routes.trips", "Trayectos")} + </p> + <p className="text-xs text-muted"> + {t("routes.choose_trip", "Elige un trayecto")} + </p> + </div> + <button + type="button" + onClick={() => setIsPatternPickerOpen(false)} + className="p-2 rounded-full hover:bg-surface" + aria-label={t("routes.close", "Cerrar")} + > + <X size={18} /> + </button> + </div> + + <div className="overflow-y-auto max-h-[60vh] pb-3"> + {[0, 1].map((dir) => { + const patterns = patternsByDirection[dir] ?? []; + if (patterns.length === 0) return null; + const directionLabel = + dir === 0 + ? t("routes.direction_outbound", "Ida") + : t("routes.direction_inbound", "Vuelta"); + const sortedPatterns = [...patterns].sort( + (a, b) => b.tripCount - a.tripCount + ); + + return ( + <div key={dir}> + <div className="px-4 py-2 text-xs font-semibold text-muted uppercase tracking-wide"> + {directionLabel} + </div> + <div className="space-y-2 px-3 pb-3"> + {sortedPatterns.map((pattern) => { + const destination = + pattern.headsign || pattern.name || ""; + const firstStop = pattern.stops[0]?.name ?? ""; + const lastStop = + pattern.stops[pattern.stops.length - 1]?.name ?? + ""; + const times = + pattern.stops[0]?.scheduledDepartures?.slice( + 0, + 3 + ) ?? []; + + return ( + <button + key={pattern.id} + type="button" + onClick={() => { + setSelectedPatternId(pattern.id); + setSelectedStopId(null); + setIsPatternPickerOpen(false); + }} + className={`w-full text-left rounded-xl border px-3 py-3 transition-colors ${ + selectedPattern?.id === pattern.id + ? "border-primary bg-primary/5" + : "border-border bg-surface hover:border-primary/50" + }`} + > + <div className="flex items-start gap-2"> + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2"> + <p className="text-base font-semibold text-text truncate"> + {destination || + t("routes.trip", "Trayecto")} + </p> + </div> + <p className="text-xs text-muted mt-1 truncate"> + {firstStop} + {firstStop && lastStop ? " → " : ""} + {lastStop} + </p> + </div> + <div className="flex items-center gap-2 text-xs text-muted"> + {pattern.tripCount <= 3 && + times.length > 0 ? ( + <div className="flex items-center gap-1"> + <Clock size={14} /> + <span> + {times + .map((dep) => { + const h = Math.floor(dep / 3600) + .toString() + .padStart(2, "0"); + const m = Math.floor( + (dep % 3600) / 60 + ) + .toString() + .padStart(2, "0"); + return `${h}:${m}`; + }) + .join(" · ")} + </span> + </div> + ) : ( + <div className="flex items-center gap-1"> + <Bus size={14} /> + <span> + {t("routes.trip_count_short", { + count: pattern.tripCount, + })} + </span> + </div> + )} + </div> + </div> + </button> + ); + })} + </div> + </div> + ); + })} + </div> + </div> + </div> + )} + <div className="flex-1 overflow-y-auto px-4 py-3 bg-background"> <h3 className="text-base font-semibold mb-3 text-text"> {t("routes.stops", "Paradas")} |
