From 4420def7411a053e930b44117e2bf63625d824dc Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Fri, 7 Nov 2025 12:43:18 +0100 Subject: Make "stops" page be the home (renaming only) --- src/frontend/app/routes/home.css | 310 +++++++++++++++++++++++++++++++++ src/frontend/app/routes/home.tsx | 322 +++++++++++++++++++++++++++++++++++ src/frontend/app/routes/index.tsx | 5 - src/frontend/app/routes/settings.tsx | 2 +- src/frontend/app/routes/stoplist.css | 310 --------------------------------- src/frontend/app/routes/stoplist.tsx | 321 ---------------------------------- 6 files changed, 633 insertions(+), 637 deletions(-) create mode 100644 src/frontend/app/routes/home.css create mode 100644 src/frontend/app/routes/home.tsx delete mode 100644 src/frontend/app/routes/index.tsx delete mode 100644 src/frontend/app/routes/stoplist.css delete mode 100644 src/frontend/app/routes/stoplist.tsx (limited to 'src/frontend/app/routes') diff --git a/src/frontend/app/routes/home.css b/src/frontend/app/routes/home.css new file mode 100644 index 0000000..253c0ab --- /dev/null +++ b/src/frontend/app/routes/home.css @@ -0,0 +1,310 @@ +/* Common page styles */ +.page-title { + font-size: 1.8rem; + margin-bottom: 1rem; + font-weight: 600; + color: var(--text-color); +} + +.page-subtitle { + font-size: 1.4rem; + margin-top: 1.5rem; + margin-bottom: 0.75rem; + font-weight: 500; + color: var(--subtitle-color); +} + +/* Form styles */ +.search-form { + margin-bottom: 1.5rem; +} + +.form-group { + margin-bottom: 1rem; + display: flex; + flex-direction: column; +} + +.form-label { + font-size: 0.9rem; + margin-bottom: 0.25rem; + font-weight: 500; +} + +.form-input { + padding: 0.75rem; + font-size: 1rem; + border: 1px solid var(--border-color); + border-radius: 8px; +} + +.form-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 1rem; + + padding: 0.75rem 1rem; + background-color: var(--button-background-color); + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + width: 100%; + margin-top: 0.5rem; +} + +.form-button:hover { + background-color: var(--button-hover-background-color); +} + +/* List styles */ +.list-container { + margin-bottom: 1.5rem; +} + +.list { + list-style: none; + padding: 0; + margin: 0; +} + +.list-item { + padding: 1rem; + border-bottom: 1px solid var(--border-color); +} + +.list-item-link { + display: block; + color: var(--text-color); + text-decoration: none; + font-size: 1.1rem; /* Increased font size for stop name */ +} + +.list-item-link:hover { + color: var(--button-background-color); +} + +.list-item-link:hover .line-icon { + color: var(--text-color); +} + +.distance-info { + font-size: 0.9rem; + color: var(--subtitle-color); +} + +/* Message styles */ +.message { + padding: 1rem; + background-color: var(--message-background-color); + border-radius: 8px; + margin-bottom: 1rem; +} + +/* About page specific styles */ +.about-page { + text-align: center; + padding: 1rem; +} + +.about-version { + color: var(--subtitle-color); + font-size: 0.9rem; + margin-top: 2rem; +} + +.about-description { + margin-top: 1rem; + line-height: 1.6; +} + +/* Map page specific styles */ +.map-container { + height: calc(100dvh - 140px); + margin: -16px; + margin-bottom: 1rem; + position: relative; +} + +/* Fullscreen map styles */ +.fullscreen-container { + position: absolute; + top: 0; + left: 0; + width: 100vw; + height: 100dvh; + padding: 0; + margin: 0; + max-width: none; + overflow: hidden; +} + +.fullscreen-map { + width: 100%; + height: 100%; +} + +.fullscreen-loading { + display: flex; + justify-content: center; + align-items: center; + height: 100dvh; + width: 100vw; + font-size: 1.8rem; + font-weight: 600; + color: var(--text-color); +} + +/* Map marker and popup styles */ +.stop-marker { + box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); + transition: all 0.2s ease-in-out; +} + +.stop-marker:hover { + transform: scale(1.2); +} + +.maplibregl-popup { + max-width: 250px; +} + +.maplibregl-popup-content { + padding: 12px; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.popup-line-icons { + display: flex; + flex-wrap: wrap; + margin: 6px 0; + gap: 5px; +} + +.popup-line { + display: inline-block; + background-color: var(--button-background-color); + color: white; + padding: 2px 6px; + margin-right: 4px; + border-radius: 4px; + font-size: 0.8rem; + font-weight: 500; +} + +.popup-link { + display: block; + margin-top: 8px; + color: var(--button-background-color); + text-decoration: none; + font-weight: 500; +} + +.popup-link:hover { + text-decoration: underline; +} + +/* Estimates page specific styles */ +.estimates-header { + display: flex; + align-items: center; + margin-bottom: 1rem; +} + +.estimates-stop-id { + font-size: 1rem; + color: var(--subtitle-color); + margin-left: 0.5rem; +} + +.estimates-arrival { + color: #28a745; + font-weight: 500; +} + +.estimates-delayed { + color: #dc3545; +} + +.button-group { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.button { + padding: 0.75rem 1rem; + background-color: var(--button-background-color); + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + text-align: center; + text-decoration: none; + display: inline-block; +} + +.button:hover { + background-color: var(--button-hover-background-color); +} + +.button:disabled { + background-color: var(--button-disabled-background-color); + cursor: not-allowed; +} + +.star-icon { + margin-right: 0.5rem; + color: #ccc; + fill: none; +} + +.star-icon.active { + color: var(--star-color); /* Yellow color for active star */ + fill: var(--star-color); +} + +/* Tablet and larger breakpoint */ +@media (min-width: 768px) { + .search-form { + display: flex; + align-items: flex-end; + gap: 1rem; + } + + .form-group { + flex: 1; + margin-bottom: 0; + } + + .form-button { + width: auto; + margin-top: 0; + } + + .list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; + } + + .list-item { + border: 1px solid var(--border-color); + border-radius: 8px; + margin-bottom: 0; + } +} + +/* Desktop breakpoint */ +@media (min-width: 1024px) { + .list { + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + } +} diff --git a/src/frontend/app/routes/home.tsx b/src/frontend/app/routes/home.tsx new file mode 100644 index 0000000..88c774b --- /dev/null +++ b/src/frontend/app/routes/home.tsx @@ -0,0 +1,322 @@ +"use client"; +import { useEffect, useMemo, useRef, useState, useCallback } from "react"; +import StopDataProvider, { type Stop } from "../data/StopDataProvider"; +import StopItem from "../components/StopItem"; +import StopItemSkeleton from "../components/StopItemSkeleton"; +import StopGallery from "../components/StopGallery"; +import ServiceAlerts from "../components/ServiceAlerts"; +import Fuse from "fuse.js"; +import "./home.css"; +import { useTranslation } from "react-i18next"; +import { useApp } from "../AppContext"; +import { REGIONS } from "~/data/RegionConfig"; + +export default function StopList() { + const { t } = useTranslation(); + const { region } = useApp(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [searchResults, setSearchResults] = useState(null); + const [favouriteIds, setFavouriteIds] = useState([]); + const [recentIds, setRecentIds] = useState([]); + const [favouriteStops, setFavouriteStops] = useState([]); + const [recentStops, setRecentStops] = useState([]); + const [userLocation, setUserLocation] = useState<{ + latitude: number; + longitude: number; + } | null>(null); + const searchTimeout = useRef(null); + + const randomPlaceholder = useMemo( + () => t("stoplist.search_placeholder"), + [t], + ); + + const fuse = useMemo( + () => + new Fuse(data || [], { + threshold: 0.3, + keys: ["name.original", "name.intersect", "stopId"], + }), + [data], + ); + + const requestUserLocation = useCallback(() => { + if (typeof window === "undefined" || !("geolocation" in navigator)) { + return; + } + + navigator.geolocation.getCurrentPosition( + (position) => { + setUserLocation({ + latitude: position.coords.latitude, + longitude: position.coords.longitude, + }); + }, + (error) => { + console.warn("Unable to obtain user location", error); + }, + { + enableHighAccuracy: false, + maximumAge: 5 * 60 * 1000, + }, + ); + }, []); + + useEffect(() => { + if (typeof window === "undefined" || !("geolocation" in navigator)) { + return; + } + + let permissionStatus: PermissionStatus | null = null; + + const handlePermissionChange = () => { + if (permissionStatus?.state === "granted") { + requestUserLocation(); + } + }; + + const checkPermission = async () => { + try { + if (navigator.permissions?.query) { + permissionStatus = await navigator.permissions.query({ + name: "geolocation", + }); + if (permissionStatus.state === "granted") { + requestUserLocation(); + } + permissionStatus.addEventListener("change", handlePermissionChange); + } else { + requestUserLocation(); + } + } catch (error) { + console.warn("Geolocation permission check failed", error); + requestUserLocation(); + } + }; + + checkPermission(); + + return () => { + permissionStatus?.removeEventListener("change", handlePermissionChange); + }; + }, [requestUserLocation]); + + // Sort stops by proximity when we know where the user is located. + const sortedAllStops = useMemo(() => { + if (!data) { + return [] as Stop[]; + } + + if (!userLocation) { + return [...data].sort((a, b) => a.stopId - b.stopId); + } + + const toRadians = (value: number) => (value * Math.PI) / 180; + const getDistance = ( + lat1: number, + lon1: number, + lat2: number, + lon2: number, + ) => { + const R = 6371000; // meters + const dLat = toRadians(lat2 - lat1); + const dLon = toRadians(lon2 - lon1); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(toRadians(lat1)) * + Math.cos(toRadians(lat2)) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; + }; + + return data + .map((stop) => { + if ( + typeof stop.latitude !== "number" || + typeof stop.longitude !== "number" + ) { + return { stop, distance: Number.POSITIVE_INFINITY }; + } + + const distance = getDistance( + userLocation.latitude, + userLocation.longitude, + stop.latitude, + stop.longitude, + ); + + return { stop, distance }; + }) + .sort((a, b) => { + if (a.distance === b.distance) { + return a.stop.stopId - b.stop.stopId; + } + return a.distance - b.distance; + }) + .map(({ stop }) => stop); + }, [data, userLocation]); + + // Load favourite and recent IDs immediately from localStorage + useEffect(() => { + setFavouriteIds(StopDataProvider.getFavouriteIds(region)); + setRecentIds(StopDataProvider.getRecent(region)); + }, [region]); + + // Load stops from network + const loadStops = useCallback(async () => { + try { + setLoading(true); + + const stops = await StopDataProvider.loadStopsFromNetwork(region); + + // Add favourite flags to stops + const favouriteStopsIds = StopDataProvider.getFavouriteIds(region); + const stopsWithFavourites = stops.map((stop) => ({ + ...stop, + favourite: favouriteStopsIds.includes(stop.stopId), + })); + + setData(stopsWithFavourites); + + // Update favourite and recent stops with full data + const favStops = stopsWithFavourites.filter((stop) => + favouriteStopsIds.includes(stop.stopId), + ); + setFavouriteStops(favStops); + + const recIds = StopDataProvider.getRecent(region); + const recStops = recIds + .map((id) => stopsWithFavourites.find((stop) => stop.stopId === id)) + .filter(Boolean) as Stop[]; + setRecentStops(recStops.reverse()); + } catch (error) { + console.error("Failed to load stops:", error); + } finally { + setLoading(false); + } + }, [region]); + + useEffect(() => { + loadStops(); + }, [loadStops]); + + const handleStopSearch = (event: React.ChangeEvent) => { + const searchQuery = event.target.value || ""; + + if (searchTimeout.current) { + clearTimeout(searchTimeout.current); + } + + searchTimeout.current = setTimeout(() => { + if (searchQuery.length === 0) { + setSearchResults(null); + return; + } + + if (!data) { + console.error("No data available for search"); + return; + } + + // Check if search query is a number (stop code search) + const isNumericSearch = /^\d+$/.test(searchQuery.trim()); + + let items: Stop[]; + if (isNumericSearch) { + // Direct match for stop codes + const stopId = parseInt(searchQuery.trim(), 10); + const exactMatch = data.filter((stop) => stop.stopId === stopId); + if (exactMatch.length > 0) { + items = exactMatch; + } else { + // Fuzzy search if no exact match + const results = fuse.search(searchQuery); + items = results.map((result) => result.item); + } + } else { + // Text search using Fuse.js + const results = fuse.search(searchQuery); + items = results.map((result) => result.item); + } + + setSearchResults(items); + }, 300); + }; + + return ( +
+

BusUrbano - {REGIONS[region].name}

+ +
+
+ + +
+
+ + {searchResults && searchResults.length > 0 && ( +
+

+ {t("stoplist.search_results", "Resultados de la búsqueda")} +

+
    + {searchResults.map((stop: Stop) => ( + + ))} +
+
+ )} + + {!loading && ( + a.stopId - b.stopId)} + title={t("stoplist.favourites")} + emptyMessage={t("stoplist.no_favourites")} + /> + )} + + {!loading && ( + + )} + + + +
+

+ {userLocation + ? t("stoplist.nearby_stops", "Nearby stops") + : t("stoplist.all_stops", "Paradas")} +

+ +
    + {loading && ( + <> + {Array.from({ length: 6 }, (_, index) => ( + + ))} + + )} + {!loading && data + ? (userLocation ? sortedAllStops.slice(0, 6) : sortedAllStops).map( + (stop) => , + ) + : null} +
+
+
+ ); +} diff --git a/src/frontend/app/routes/index.tsx b/src/frontend/app/routes/index.tsx deleted file mode 100644 index 252abec..0000000 --- a/src/frontend/app/routes/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { Navigate, redirect, type LoaderFunction } from "react-router"; - -export default function Index() { - return ; -} diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx index d9efa2e..d687fab 100644 --- a/src/frontend/app/routes/settings.tsx +++ b/src/frontend/app/routes/settings.tsx @@ -35,7 +35,7 @@ export default function Settings() { setRegion(pendingRegion as any); setShowModal(false); setPendingRegion(null); - navigate("/stops"); + navigate("/"); } }; diff --git a/src/frontend/app/routes/stoplist.css b/src/frontend/app/routes/stoplist.css deleted file mode 100644 index 253c0ab..0000000 --- a/src/frontend/app/routes/stoplist.css +++ /dev/null @@ -1,310 +0,0 @@ -/* Common page styles */ -.page-title { - font-size: 1.8rem; - margin-bottom: 1rem; - font-weight: 600; - color: var(--text-color); -} - -.page-subtitle { - font-size: 1.4rem; - margin-top: 1.5rem; - margin-bottom: 0.75rem; - font-weight: 500; - color: var(--subtitle-color); -} - -/* Form styles */ -.search-form { - margin-bottom: 1.5rem; -} - -.form-group { - margin-bottom: 1rem; - display: flex; - flex-direction: column; -} - -.form-label { - font-size: 0.9rem; - margin-bottom: 0.25rem; - font-weight: 500; -} - -.form-input { - padding: 0.75rem; - font-size: 1rem; - border: 1px solid var(--border-color); - border-radius: 8px; -} - -.form-button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 1rem; - - padding: 0.75rem 1rem; - background-color: var(--button-background-color); - color: white; - border: none; - border-radius: 8px; - font-size: 1rem; - font-weight: 500; - cursor: pointer; - width: 100%; - margin-top: 0.5rem; -} - -.form-button:hover { - background-color: var(--button-hover-background-color); -} - -/* List styles */ -.list-container { - margin-bottom: 1.5rem; -} - -.list { - list-style: none; - padding: 0; - margin: 0; -} - -.list-item { - padding: 1rem; - border-bottom: 1px solid var(--border-color); -} - -.list-item-link { - display: block; - color: var(--text-color); - text-decoration: none; - font-size: 1.1rem; /* Increased font size for stop name */ -} - -.list-item-link:hover { - color: var(--button-background-color); -} - -.list-item-link:hover .line-icon { - color: var(--text-color); -} - -.distance-info { - font-size: 0.9rem; - color: var(--subtitle-color); -} - -/* Message styles */ -.message { - padding: 1rem; - background-color: var(--message-background-color); - border-radius: 8px; - margin-bottom: 1rem; -} - -/* About page specific styles */ -.about-page { - text-align: center; - padding: 1rem; -} - -.about-version { - color: var(--subtitle-color); - font-size: 0.9rem; - margin-top: 2rem; -} - -.about-description { - margin-top: 1rem; - line-height: 1.6; -} - -/* Map page specific styles */ -.map-container { - height: calc(100dvh - 140px); - margin: -16px; - margin-bottom: 1rem; - position: relative; -} - -/* Fullscreen map styles */ -.fullscreen-container { - position: absolute; - top: 0; - left: 0; - width: 100vw; - height: 100dvh; - padding: 0; - margin: 0; - max-width: none; - overflow: hidden; -} - -.fullscreen-map { - width: 100%; - height: 100%; -} - -.fullscreen-loading { - display: flex; - justify-content: center; - align-items: center; - height: 100dvh; - width: 100vw; - font-size: 1.8rem; - font-weight: 600; - color: var(--text-color); -} - -/* Map marker and popup styles */ -.stop-marker { - box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); - transition: all 0.2s ease-in-out; -} - -.stop-marker:hover { - transform: scale(1.2); -} - -.maplibregl-popup { - max-width: 250px; -} - -.maplibregl-popup-content { - padding: 12px; - border-radius: 8px; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); -} - -.popup-line-icons { - display: flex; - flex-wrap: wrap; - margin: 6px 0; - gap: 5px; -} - -.popup-line { - display: inline-block; - background-color: var(--button-background-color); - color: white; - padding: 2px 6px; - margin-right: 4px; - border-radius: 4px; - font-size: 0.8rem; - font-weight: 500; -} - -.popup-link { - display: block; - margin-top: 8px; - color: var(--button-background-color); - text-decoration: none; - font-weight: 500; -} - -.popup-link:hover { - text-decoration: underline; -} - -/* Estimates page specific styles */ -.estimates-header { - display: flex; - align-items: center; - margin-bottom: 1rem; -} - -.estimates-stop-id { - font-size: 1rem; - color: var(--subtitle-color); - margin-left: 0.5rem; -} - -.estimates-arrival { - color: #28a745; - font-weight: 500; -} - -.estimates-delayed { - color: #dc3545; -} - -.button-group { - display: flex; - gap: 1rem; - margin-bottom: 1.5rem; - flex-wrap: wrap; -} - -.button { - padding: 0.75rem 1rem; - background-color: var(--button-background-color); - color: white; - border: none; - border-radius: 8px; - font-size: 1rem; - font-weight: 500; - cursor: pointer; - text-align: center; - text-decoration: none; - display: inline-block; -} - -.button:hover { - background-color: var(--button-hover-background-color); -} - -.button:disabled { - background-color: var(--button-disabled-background-color); - cursor: not-allowed; -} - -.star-icon { - margin-right: 0.5rem; - color: #ccc; - fill: none; -} - -.star-icon.active { - color: var(--star-color); /* Yellow color for active star */ - fill: var(--star-color); -} - -/* Tablet and larger breakpoint */ -@media (min-width: 768px) { - .search-form { - display: flex; - align-items: flex-end; - gap: 1rem; - } - - .form-group { - flex: 1; - margin-bottom: 0; - } - - .form-button { - width: auto; - margin-top: 0; - } - - .list { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: 1rem; - } - - .list-item { - border: 1px solid var(--border-color); - border-radius: 8px; - margin-bottom: 0; - } -} - -/* Desktop breakpoint */ -@media (min-width: 1024px) { - .list { - grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); - } -} diff --git a/src/frontend/app/routes/stoplist.tsx b/src/frontend/app/routes/stoplist.tsx deleted file mode 100644 index a98b2b4..0000000 --- a/src/frontend/app/routes/stoplist.tsx +++ /dev/null @@ -1,321 +0,0 @@ -import { useEffect, useMemo, useRef, useState, useCallback } from "react"; -import StopDataProvider, { type Stop } from "../data/StopDataProvider"; -import StopItem from "../components/StopItem"; -import StopItemSkeleton from "../components/StopItemSkeleton"; -import StopGallery from "../components/StopGallery"; -import ServiceAlerts from "../components/ServiceAlerts"; -import Fuse from "fuse.js"; -import "./stoplist.css"; -import { useTranslation } from "react-i18next"; -import { useApp } from "../AppContext"; -import { REGIONS } from "~/data/RegionConfig"; - -export default function StopList() { - const { t } = useTranslation(); - const { region } = useApp(); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [searchResults, setSearchResults] = useState(null); - const [favouriteIds, setFavouriteIds] = useState([]); - const [recentIds, setRecentIds] = useState([]); - const [favouriteStops, setFavouriteStops] = useState([]); - const [recentStops, setRecentStops] = useState([]); - const [userLocation, setUserLocation] = useState<{ - latitude: number; - longitude: number; - } | null>(null); - const searchTimeout = useRef(null); - - const randomPlaceholder = useMemo( - () => t("stoplist.search_placeholder"), - [t], - ); - - const fuse = useMemo( - () => - new Fuse(data || [], { - threshold: 0.3, - keys: ["name.original", "name.intersect", "stopId"], - }), - [data], - ); - - const requestUserLocation = useCallback(() => { - if (typeof window === "undefined" || !("geolocation" in navigator)) { - return; - } - - navigator.geolocation.getCurrentPosition( - (position) => { - setUserLocation({ - latitude: position.coords.latitude, - longitude: position.coords.longitude, - }); - }, - (error) => { - console.warn("Unable to obtain user location", error); - }, - { - enableHighAccuracy: false, - maximumAge: 5 * 60 * 1000, - }, - ); - }, []); - - useEffect(() => { - if (typeof window === "undefined" || !("geolocation" in navigator)) { - return; - } - - let permissionStatus: PermissionStatus | null = null; - - const handlePermissionChange = () => { - if (permissionStatus?.state === "granted") { - requestUserLocation(); - } - }; - - const checkPermission = async () => { - try { - if (navigator.permissions?.query) { - permissionStatus = await navigator.permissions.query({ - name: "geolocation", - }); - if (permissionStatus.state === "granted") { - requestUserLocation(); - } - permissionStatus.addEventListener("change", handlePermissionChange); - } else { - requestUserLocation(); - } - } catch (error) { - console.warn("Geolocation permission check failed", error); - requestUserLocation(); - } - }; - - checkPermission(); - - return () => { - permissionStatus?.removeEventListener("change", handlePermissionChange); - }; - }, [requestUserLocation]); - - // Sort stops by proximity when we know where the user is located. - const sortedAllStops = useMemo(() => { - if (!data) { - return [] as Stop[]; - } - - if (!userLocation) { - return [...data].sort((a, b) => a.stopId - b.stopId); - } - - const toRadians = (value: number) => (value * Math.PI) / 180; - const getDistance = ( - lat1: number, - lon1: number, - lat2: number, - lon2: number, - ) => { - const R = 6371000; // meters - const dLat = toRadians(lat2 - lat1); - const dLon = toRadians(lon2 - lon1); - const a = - Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos(toRadians(lat1)) * - Math.cos(toRadians(lat2)) * - Math.sin(dLon / 2) * - Math.sin(dLon / 2); - const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - return R * c; - }; - - return data - .map((stop) => { - if ( - typeof stop.latitude !== "number" || - typeof stop.longitude !== "number" - ) { - return { stop, distance: Number.POSITIVE_INFINITY }; - } - - const distance = getDistance( - userLocation.latitude, - userLocation.longitude, - stop.latitude, - stop.longitude, - ); - - return { stop, distance }; - }) - .sort((a, b) => { - if (a.distance === b.distance) { - return a.stop.stopId - b.stop.stopId; - } - return a.distance - b.distance; - }) - .map(({ stop }) => stop); - }, [data, userLocation]); - - // Load favourite and recent IDs immediately from localStorage - useEffect(() => { - setFavouriteIds(StopDataProvider.getFavouriteIds(region)); - setRecentIds(StopDataProvider.getRecent(region)); - }, [region]); - - // Load stops from network - const loadStops = useCallback(async () => { - try { - setLoading(true); - - const stops = await StopDataProvider.loadStopsFromNetwork(region); - - // Add favourite flags to stops - const favouriteStopsIds = StopDataProvider.getFavouriteIds(region); - const stopsWithFavourites = stops.map((stop) => ({ - ...stop, - favourite: favouriteStopsIds.includes(stop.stopId), - })); - - setData(stopsWithFavourites); - - // Update favourite and recent stops with full data - const favStops = stopsWithFavourites.filter((stop) => - favouriteStopsIds.includes(stop.stopId), - ); - setFavouriteStops(favStops); - - const recIds = StopDataProvider.getRecent(region); - const recStops = recIds - .map((id) => stopsWithFavourites.find((stop) => stop.stopId === id)) - .filter(Boolean) as Stop[]; - setRecentStops(recStops.reverse()); - } catch (error) { - console.error("Failed to load stops:", error); - } finally { - setLoading(false); - } - }, [region]); - - useEffect(() => { - loadStops(); - }, [loadStops]); - - const handleStopSearch = (event: React.ChangeEvent) => { - const searchQuery = event.target.value || ""; - - if (searchTimeout.current) { - clearTimeout(searchTimeout.current); - } - - searchTimeout.current = setTimeout(() => { - if (searchQuery.length === 0) { - setSearchResults(null); - return; - } - - if (!data) { - console.error("No data available for search"); - return; - } - - // Check if search query is a number (stop code search) - const isNumericSearch = /^\d+$/.test(searchQuery.trim()); - - let items: Stop[]; - if (isNumericSearch) { - // Direct match for stop codes - const stopId = parseInt(searchQuery.trim(), 10); - const exactMatch = data.filter((stop) => stop.stopId === stopId); - if (exactMatch.length > 0) { - items = exactMatch; - } else { - // Fuzzy search if no exact match - const results = fuse.search(searchQuery); - items = results.map((result) => result.item); - } - } else { - // Text search using Fuse.js - const results = fuse.search(searchQuery); - items = results.map((result) => result.item); - } - - setSearchResults(items); - }, 300); - }; - - return ( -
-

BusUrbano - {REGIONS[region].name}

- -
-
- - -
-
- - {searchResults && searchResults.length > 0 && ( -
-

- {t("stoplist.search_results", "Resultados de la búsqueda")} -

-
    - {searchResults.map((stop: Stop) => ( - - ))} -
-
- )} - - {!loading && ( - a.stopId - b.stopId)} - title={t("stoplist.favourites")} - emptyMessage={t("stoplist.no_favourites")} - /> - )} - - {!loading && ( - - )} - - - -
-

- {userLocation - ? t("stoplist.nearby_stops", "Nearby stops") - : t("stoplist.all_stops", "Paradas")} -

- -
    - {loading && ( - <> - {Array.from({ length: 6 }, (_, index) => ( - - ))} - - )} - {!loading && data - ? (userLocation ? sortedAllStops.slice(0, 6) : sortedAllStops).map( - (stop) => , - ) - : null} -
-
-
- ); -} -- cgit v1.3