diff options
Diffstat (limited to 'src/frontend/app/components/StopSummarySheet.tsx')
| -rw-r--r-- | src/frontend/app/components/StopSummarySheet.tsx | 208 |
1 files changed, 208 insertions, 0 deletions
diff --git a/src/frontend/app/components/StopSummarySheet.tsx b/src/frontend/app/components/StopSummarySheet.tsx new file mode 100644 index 0000000..17c0afd --- /dev/null +++ b/src/frontend/app/components/StopSummarySheet.tsx @@ -0,0 +1,208 @@ +import { RefreshCw } from "lucide-react"; +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Sheet } from "react-modal-sheet"; +import { Link } from "react-router"; +import { ConsolidatedCirculationList } from "~/components/Stops/ConsolidatedCirculationList"; +import { REGION_DATA } from "~/config/RegionConfig"; +import type { Stop } from "~/data/StopDataProvider"; +import { type ConsolidatedCirculation } from "../routes/stops-$id"; +import { ErrorDisplay } from "./ErrorDisplay"; +import LineIcon from "./LineIcon"; +import { StopAlert } from "./StopAlert"; +import "./StopSummarySheet.css"; +import { StopSummarySheetSkeleton } from "./StopSummarySheetSkeleton"; + +interface StopSheetProps { + isOpen: boolean; + onClose: () => void; + stop: Stop; +} + +interface ErrorInfo { + type: "network" | "server" | "unknown"; + status?: number; + message?: string; +} + +const loadConsolidatedData = async ( + stopId: number +): Promise<ConsolidatedCirculation[]> => { + const resp = await fetch( + `${REGION_DATA.consolidatedCirculationsEndpoint}?stopId=${stopId}`, + { + headers: { + Accept: "application/json", + }, + } + ); + + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); + } + + return await resp.json(); +}; + +export const StopSheet: React.FC<StopSheetProps> = ({ + isOpen, + onClose, + stop, +}) => { + const { t } = useTranslation(); + const [data, setData] = useState<ConsolidatedCirculation[] | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<ErrorInfo | null>(null); + const [lastUpdated, setLastUpdated] = useState<Date | null>(null); + + const parseError = (error: any): ErrorInfo => { + if (!navigator.onLine) { + return { type: "network", message: "No internet connection" }; + } + + if ( + error.message?.includes("Failed to fetch") || + error.message?.includes("NetworkError") + ) { + return { type: "network" }; + } + + if (error.message?.includes("HTTP")) { + const statusMatch = error.message.match(/HTTP (\d+):/); + const status = statusMatch ? parseInt(statusMatch[1]) : undefined; + return { type: "server", status }; + } + + return { type: "unknown", message: error.message }; + }; + + const loadData = async () => { + try { + setLoading(true); + setError(null); + setData(null); + + const stopData = await loadConsolidatedData(stop.stopId); + setData(stopData); + setLastUpdated(new Date()); + } catch (err) { + console.error("Failed to load stop data:", err); + setError(parseError(err)); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (isOpen && stop.stopId) { + loadData(); + } + }, [isOpen, stop.stopId]); + + // Show only the next 4 arrivals + const sortedData = data + ? [...data].sort( + (a, b) => + (a.realTime?.minutes ?? a.schedule?.minutes ?? 999) - + (b.realTime?.minutes ?? b.schedule?.minutes ?? 999) + ) + : []; + const limitedEstimates = sortedData.slice(0, 4); + + return ( + <Sheet isOpen={isOpen} onClose={onClose} detent="content"> + <Sheet.Container> + <Sheet.Header /> + <Sheet.Content drag="y"> + <div className="stop-sheet-content"> + <div className="stop-sheet-header"> + <h2 className="stop-sheet-title">{stop.name.original}</h2> + <span className="stop-sheet-id">({stop.stopId})</span> + </div> + + <div + className={`stop-sheet-lines-container ${stop.lines.length >= 10 ? "scrollable" : ""}`} + > + {stop.lines.map((line) => ( + <div key={line} className="stop-sheet-line-icon"> + <LineIcon line={line} mode="rounded" /> + </div> + ))} + </div> + + <StopAlert stop={stop} compact /> + + {loading ? ( + <StopSummarySheetSkeleton /> + ) : error ? ( + <ErrorDisplay + error={error} + onRetry={loadData} + title={t( + "errors.estimates_title", + "Error al cargar estimaciones" + )} + className="compact" + /> + ) : data ? ( + <> + <div className="stop-sheet-estimates"> + <h3 className="stop-sheet-subtitle"> + {t("estimates.next_arrivals", "Next arrivals")} + </h3> + + {limitedEstimates.length === 0 ? ( + <div className="stop-sheet-no-estimates"> + {t("estimates.none", "No hay estimaciones disponibles")} + </div> + ) : ( + <ConsolidatedCirculationList + data={data.slice(0, 4)} + reduced + /> + )} + </div> + </> + ) : null} + </div> + </Sheet.Content> + + <div className="stop-sheet-footer"> + {lastUpdated && ( + <div className="stop-sheet-timestamp"> + {t("estimates.last_updated", "Actualizado a las")}{" "} + {lastUpdated.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + })} + </div> + )} + + <div className="stop-sheet-actions"> + <button + className="stop-sheet-reload" + onClick={loadData} + disabled={loading} + title={t("estimates.reload", "Recargar estimaciones")} + > + <RefreshCw + className={`reload-icon ${loading ? "spinning" : ""}`} + /> + {t("estimates.reload", "Recargar")} + </button> + + <Link + to={`/stops/${stop.stopId}`} + className="stop-sheet-view-all" + onClick={onClose} + > + {t("map.view_all_estimates", "Ver todas las estimaciones")} + </Link> + </div> + </div> + </Sheet.Container> + <Sheet.Backdrop onTap={onClose} /> + </Sheet> + ); +}; |
