diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/Costasdev.Busurbano.Backend/Services/OtpService.cs | 4 | ||||
| -rw-r--r-- | src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs | 10 | ||||
| -rw-r--r-- | src/frontend/app/components/PlaceListItem.tsx | 47 | ||||
| -rw-r--r-- | src/frontend/app/components/PlannerOverlay.tsx | 163 | ||||
| -rw-r--r-- | src/frontend/app/data/SpecialPlacesProvider.ts | 78 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/en-GB.json | 22 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/es-ES.json | 22 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/gl-ES.json | 22 | ||||
| -rw-r--r-- | src/frontend/app/routes/favourites.tsx | 309 | ||||
| -rw-r--r-- | src/frontend/app/routes/planner.tsx | 28 | ||||
| -rw-r--r-- | src/frontend/package-lock.json | 7 | ||||
| -rw-r--r-- | src/frontend/package.json | 1 |
12 files changed, 661 insertions, 52 deletions
diff --git a/src/Costasdev.Busurbano.Backend/Services/OtpService.cs b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs index 6b01c5c..e42be81 100644 --- a/src/Costasdev.Busurbano.Backend/Services/OtpService.cs +++ b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs @@ -36,13 +36,13 @@ public class OtpService try { // https://planificador-rutas-api.vigo.org/v1/autocomplete?text=XXXX&layers=venue,street,address&lang=es - var url = $"{_config.OtpGeocodingBaseUrl}/autocomplete?text={Uri.EscapeDataString(query)}&layers=venue,street,address&lang=es"; + var url = $"{_config.OtpGeocodingBaseUrl}/autocomplete?text={Uri.EscapeDataString(query)}&layers=venue,address&lang=es"; var response = await _httpClient.GetFromJsonAsync<OtpGeocodeResponse>(url); var results = response?.Features.Select(f => new PlannerSearchResult { Name = f.Properties?.Name, - Label = f.Properties?.Label, + Label = $"{f.Properties?.PostalCode} ${f.Properties?.LocalAdmin}, {f.Properties?.Region}", Layer = f.Properties?.Layer, Lat = f.Geometry?.Coordinates.Count > 1 ? f.Geometry.Coordinates[1] : 0, Lon = f.Geometry?.Coordinates.Count > 0 ? f.Geometry.Coordinates[0] : 0 diff --git a/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs b/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs index 93c4d8b..1c47a4a 100644 --- a/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs +++ b/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs @@ -191,8 +191,14 @@ public class OtpGeocodeProperties [JsonPropertyName("name")] public string? Name { get; set; } - [JsonPropertyName("label")] - public string? Label { get; set; } + [JsonPropertyName("postalcode")] + public string? PostalCode { get; set; } + + [JsonPropertyName("localadmin")] + public string? LocalAdmin { get; set; } + + [JsonPropertyName("region")] + public string? Region { get; set; } [JsonPropertyName("layer")] public string? Layer { get; set; } diff --git a/src/frontend/app/components/PlaceListItem.tsx b/src/frontend/app/components/PlaceListItem.tsx new file mode 100644 index 0000000..6c4f4a7 --- /dev/null +++ b/src/frontend/app/components/PlaceListItem.tsx @@ -0,0 +1,47 @@ +import { Building2, MapPin } from "lucide-react"; +import type { PlannerSearchResult } from "~/data/PlannerApi"; + +function getIcon(layer?: string) { + switch ((layer || "").toLowerCase()) { + case "venue": + return ( + <Building2 className="w-4 h-4 text-slate-600 dark:text-slate-400" /> + ); + case "address": + case "street": + case "favourite-stop": + case "current-location": + default: + return <MapPin className="w-4 h-4 text-slate-600 dark:text-slate-400" />; + } +} + +export default function PlaceListItem({ + place, + onClick, +}: { + place: PlannerSearchResult; + onClick: (place: PlannerSearchResult) => void; +}) { + return ( + <li className="border-t border-slate-100 dark:border-slate-700"> + <button + type="button" + className="w-full px-4 py-3 text-left hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors duration-200" + onClick={() => onClick(place)} + > + <div className="text-sm font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-2"> + <span className="inline-flex items-center justify-center w-4 h-4"> + {getIcon(place.layer)} + </span> + <span>{place.name}</span> + </div> + {place.label && ( + <div className="text-xs text-slate-500 dark:text-slate-400"> + {place.label} + </div> + )} + </button> + </li> + ); +} diff --git a/src/frontend/app/components/PlannerOverlay.tsx b/src/frontend/app/components/PlannerOverlay.tsx index 8046ab2..12cfb0f 100644 --- a/src/frontend/app/components/PlannerOverlay.tsx +++ b/src/frontend/app/components/PlannerOverlay.tsx @@ -1,3 +1,4 @@ +import { MapPin } from "lucide-react"; import React, { useCallback, useEffect, @@ -6,6 +7,8 @@ import React, { useState, } from "react"; import { useTranslation } from "react-i18next"; +import PlaceListItem from "~/components/PlaceListItem"; +import { REGION_DATA } from "~/config/RegionConfig"; import { reverseGeocode, searchPlaces, @@ -55,6 +58,14 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ const [favouriteStops, setFavouriteStops] = useState<PlannerSearchResult[]>( [] ); + const [recentPlaces, setRecentPlaces] = useState<PlannerSearchResult[]>([]); + const RECENT_KEY = `recentPlaces_${REGION_DATA.id}`; + const clearRecentPlaces = useCallback(() => { + setRecentPlaces([]); + try { + localStorage.removeItem(RECENT_KEY); + } catch {} + }, []); const pickerInputRef = useRef<HTMLInputElement | null>(null); @@ -100,6 +111,43 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ .catch(() => setFavouriteStops([])); }, []); + // Load recent places from localStorage + useEffect(() => { + try { + const raw = localStorage.getItem(RECENT_KEY); + if (raw) { + const parsed = JSON.parse(raw) as PlannerSearchResult[]; + setRecentPlaces(parsed.slice(0, 20)); + } + } catch { + setRecentPlaces([]); + } + }, []); + + const addRecentPlace = useCallback( + (p: PlannerSearchResult) => { + const key = `${p.lat.toFixed(5)},${p.lon.toFixed(5)}`; + const existing = recentPlaces.filter( + (rp) => `${rp.lat.toFixed(5)},${rp.lon.toFixed(5)}` !== key + ); + const updated = [ + { + name: p.name, + label: p.label, + lat: p.lat, + lon: p.lon, + layer: p.layer, + }, + ...existing, + ].slice(0, 20); + setRecentPlaces(updated); + try { + localStorage.setItem(RECENT_KEY, JSON.stringify(updated)); + } catch {} + }, + [recentPlaces] + ); + const filteredFavouriteStops = useMemo(() => { const q = pickerQuery.trim().toLowerCase(); if (!q) return favouriteStops; @@ -110,6 +158,28 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ ); }, [favouriteStops, pickerQuery]); + const sortedRemoteResults = useMemo(() => { + const order: Record<string, number> = { venue: 0, address: 1, street: 2 }; + const q = pickerQuery.trim().toLowerCase(); + const base = q + ? remoteResults.filter( + (s) => + (s.name || "").toLowerCase().includes(q) || + (s.label || "").toLowerCase().includes(q) + ) + : remoteResults; + return [...base].sort((a, b) => { + const oa = order[a.layer || ""] ?? 99; + const ob = order[b.layer || ""] ?? 99; + if (oa !== ob) return oa - ob; + // Secondary: shorter label first, then name alpha + const la = (a.label || "").length; + const lb = (b.label || "").length; + if (la !== lb) return la - lb; + return (a.name || "").localeCompare(b.name || ""); + }); + }, [remoteResults, pickerQuery]); + const openPicker = (field: PickerField) => { setPickerField(field); setPickerQuery( @@ -134,6 +204,7 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ setDestination(result); setDestQuery(result.name || ""); } + addRecentPlace(result); setPickerOpen(false); }; @@ -484,15 +555,17 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ /> <button type="button" - aria-label={t("planner.confirm")} - className="absolute right-2 top-1/2 -translate-y-1/2 rounded-xl px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold" + aria-label={t("planner.clear")} + className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md px-2 py-1 bg-slate-200 hover:bg-slate-300 dark:bg-slate-700 dark:hover:bg-slate-600 text-slate-800 dark:text-slate-100 text-xs font-semibold" onClick={() => { - const pick = remoteResults[0] || filteredFavouriteStops[0]; - if (pick) applyPickedResult(pick); - else setPickerOpen(false); + if (pickerQuery) { + setPickerQuery(""); + } else { + setPickerOpen(false); + } }} > - {t("planner.confirm")} + × </button> </div> </div> @@ -506,60 +579,74 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ onClick={setOriginFromCurrentLocation} disabled={locationLoading} > - <div> - <div className="text-sm font-semibold text-slate-900 dark:text-slate-100"> - {t("planner.current_location")} - </div> - <div className="text-xs text-slate-500 dark:text-slate-400"> - {t("planner.gps")} + <div className="flex items-center gap-2"> + <span className="inline-flex items-center justify-center w-4 h-4"> + <MapPin className="w-4 h-4 text-slate-600 dark:text-slate-400" /> + </span> + <div> + <div className="text-sm font-semibold text-slate-900 dark:text-slate-100"> + {t("planner.current_location")} + </div> + <div className="text-xs text-slate-500 dark:text-slate-400"> + {t("planner.gps")} + </div> </div> </div> <div className="text-lg text-slate-600 dark:text-slate-400"> - {locationLoading ? "…" : "📍"} + {locationLoading ? "…" : ""} </div> </button> </li> )} + {(remoteLoading || sortedRemoteResults.length > 0) && ( + <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", "Results")} + </li> + )} + + {sortedRemoteResults.map((r, i) => ( + <PlaceListItem + key={`remote-${i}`} + place={r} + onClick={applyPickedResult} + /> + ))} + {filteredFavouriteStops.length > 0 && ( <> <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"> {t("planner.favourite_stops")} </li> {filteredFavouriteStops.map((r, i) => ( - <li + <PlaceListItem key={`fav-${i}`} - className="border-t border-slate-100 dark:border-slate-700" - > - <button - type="button" - className="w-full px-4 py-3 text-left hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors duration-200" - onClick={() => applyPickedResult(r)} - > - <div className="text-sm font-semibold text-slate-900 dark:text-slate-100"> - {r.name} - </div> - {r.label && ( - <div className="text-xs text-slate-500 dark:text-slate-400"> - {r.label} - </div> - )} - </button> - </li> + place={r} + onClick={applyPickedResult} + /> ))} </> )} - {(remoteLoading || remoteResults.length > 0) && ( - <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", "Results")} + {recentPlaces.length > 0 && ( + <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 flex items-center justify-between"> + <span> + {t("planner.recent_locations", "Recent locations")} + </span> + <button + type="button" + className="text-xs font-semibold text-red-600 dark:text-red-400 hover:underline" + onClick={clearRecentPlaces} + > + {t("planner.clear")} + </button> </li> )} - {remoteResults.map((r, i) => ( + {recentPlaces.map((r, i) => ( <li - key={`remote-${i}`} + key={`recent-${i}`} className="border-t border-slate-100 dark:border-slate-700" > <button diff --git a/src/frontend/app/data/SpecialPlacesProvider.ts b/src/frontend/app/data/SpecialPlacesProvider.ts new file mode 100644 index 0000000..2e3be68 --- /dev/null +++ b/src/frontend/app/data/SpecialPlacesProvider.ts @@ -0,0 +1,78 @@ +import { REGION_DATA } from "~/config/RegionConfig"; + +export interface SpecialPlace { + name: string; + type: "stop" | "address"; + stopId?: string; + address?: string; + latitude?: number; + longitude?: number; +} + +const STORAGE_KEY_HOME = `specialPlace_home_${REGION_DATA.id}`; +const STORAGE_KEY_WORK = `specialPlace_work_${REGION_DATA.id}`; + +function getHome(): SpecialPlace | null { + try { + const raw = localStorage.getItem(STORAGE_KEY_HOME); + if (raw) { + return JSON.parse(raw) as SpecialPlace; + } + } catch (error) { + console.error("Error reading home location:", error); + } + return null; +} + +function setHome(place: SpecialPlace): void { + try { + localStorage.setItem(STORAGE_KEY_HOME, JSON.stringify(place)); + } catch (error) { + console.error("Error saving home location:", error); + } +} + +function removeHome(): void { + try { + localStorage.removeItem(STORAGE_KEY_HOME); + } catch (error) { + console.error("Error removing home location:", error); + } +} + +function getWork(): SpecialPlace | null { + try { + const raw = localStorage.getItem(STORAGE_KEY_WORK); + if (raw) { + return JSON.parse(raw) as SpecialPlace; + } + } catch (error) { + console.error("Error reading work location:", error); + } + return null; +} + +function setWork(place: SpecialPlace): void { + try { + localStorage.setItem(STORAGE_KEY_WORK, JSON.stringify(place)); + } catch (error) { + console.error("Error saving work location:", error); + } +} + +function removeWork(): void { + try { + localStorage.removeItem(STORAGE_KEY_WORK); + } catch (error) { + console.error("Error removing work location:", error); + } +} + +export default { + getHome, + setHome, + removeHome, + getWork, + setWork, + removeWork, +}; diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json index 9138e4b..819329e 100644 --- a/src/frontend/app/i18n/locales/en-GB.json +++ b/src/frontend/app/i18n/locales/en-GB.json @@ -141,7 +141,27 @@ "home": "Home", "map": "Map", "planner": "Planner", - "lines": "Lines" + "lines": "Lines", + "favourites": "Favourites" + }, + "favourites": { + "title": "Favourites", + "empty": "You don't have any favourite stops yet.", + "empty_description": "Go to a stop and mark it as favourite to see it here.", + "special_places": "Special Places", + "home": "Home", + "work": "Work", + "set_home": "Set Home", + "set_work": "Set Work", + "edit_home": "Edit Home", + "edit_work": "Edit Work", + "remove_home": "Remove Home", + "remove_work": "Remove Work", + "not_set": "Not set", + "favourite_stops": "Favourite Stops", + "remove": "Remove", + "view_estimates": "View estimates", + "confirm_remove": "Remove this favourite?" }, "lines": { "description": "Below is a list of Vigo urban bus lines with their respective routes and links to official timetables." diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json index ab18c7b..b5df57f 100644 --- a/src/frontend/app/i18n/locales/es-ES.json +++ b/src/frontend/app/i18n/locales/es-ES.json @@ -141,7 +141,27 @@ "home": "Inicio", "map": "Mapa", "planner": "Planificador", - "lines": "Líneas" + "lines": "Líneas", + "favourites": "Favoritos" + }, + "favourites": { + "title": "Favoritos", + "empty": "Aún no tienes paradas favoritas.", + "empty_description": "Accede a una parada y márcala como favorita para verla aquí.", + "special_places": "Lugares especiales", + "home": "Casa", + "work": "Trabajo", + "set_home": "Establecer Casa", + "set_work": "Establecer Trabajo", + "edit_home": "Editar Casa", + "edit_work": "Editar Trabajo", + "remove_home": "Eliminar Casa", + "remove_work": "Eliminar Trabajo", + "not_set": "No establecido", + "favourite_stops": "Paradas favoritas", + "remove": "Eliminar", + "view_estimates": "Ver estimaciones", + "confirm_remove": "¿Eliminar esta parada de favoritos?" }, "lines": { "description": "A continuación se muestra una lista de las líneas de autobús urbano de Vigo con sus respectivas rutas y enlaces a los horarios oficiales." diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json index 0d91efb..3fdf5ba 100644 --- a/src/frontend/app/i18n/locales/gl-ES.json +++ b/src/frontend/app/i18n/locales/gl-ES.json @@ -141,7 +141,27 @@ "home": "Inicio", "map": "Mapa", "planner": "Planificador", - "lines": "Liñas" + "lines": "Liñas", + "favourites": "Favoritos" + }, + "favourites": { + "title": "Favoritos", + "empty": "Aínda non tes paradas favoritas.", + "empty_description": "Accede a unha parada e márcaa como favorita para vela aquí.", + "special_places": "Lugares especiais", + "home": "Casa", + "work": "Traballo", + "set_home": "Establecer Casa", + "set_work": "Establecer Traballo", + "edit_home": "Editar Casa", + "edit_work": "Editar Traballo", + "remove_home": "Eliminar Casa", + "remove_work": "Eliminar Traballo", + "not_set": "Non establecido", + "favourite_stops": "Paradas favoritas", + "remove": "Eliminar", + "view_estimates": "Ver estimacións", + "confirm_remove": "Queres eliminar esta parada dos favoritos?" }, "lines": { "description": "A continuación se mostra unha lista das liñas de autobús urbano de Vigo coas súas respectivas rutas e ligazóns ós horarios oficiais." diff --git a/src/frontend/app/routes/favourites.tsx b/src/frontend/app/routes/favourites.tsx index 5b74391..ff229b2 100644 --- a/src/frontend/app/routes/favourites.tsx +++ b/src/frontend/app/routes/favourites.tsx @@ -1,13 +1,316 @@ +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import LineIcon from "~/components/LineIcon"; import { usePageTitle } from "~/contexts/PageTitleContext"; +import SpecialPlacesProvider, { + type SpecialPlace, +} from "~/data/SpecialPlacesProvider"; +import StopDataProvider, { type Stop } from "~/data/StopDataProvider"; export default function Favourites() { const { t } = useTranslation(); - usePageTitle(t("navbar.favourites", "Favoritos")); + usePageTitle(t("favourites.title", "Favourites")); + + const [home, setHome] = useState<SpecialPlace | null>(null); + const [work, setWork] = useState<SpecialPlace | null>(null); + const [favouriteStops, setFavouriteStops] = useState<Stop[]>([]); + const [loading, setLoading] = useState(true); + + const loadData = useCallback(async () => { + try { + setLoading(true); + + // Load special places + setHome(SpecialPlacesProvider.getHome()); + setWork(SpecialPlacesProvider.getWork()); + + // Load favourite stops + const favouriteIds = StopDataProvider.getFavouriteIds(); + const allStops = await StopDataProvider.getStops(); + const favStops = allStops.filter((stop) => + favouriteIds.includes(stop.stopId) + ); + setFavouriteStops(favStops); + } catch (error) { + console.error("Error loading favourites:", error); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + const handleRemoveFavourite = (stopId: string) => { + StopDataProvider.removeFavourite(stopId); + setFavouriteStops((prev) => prev.filter((s) => s.stopId !== stopId)); + }; + + const handleRemoveHome = () => { + SpecialPlacesProvider.removeHome(); + setHome(null); + }; + + const handleRemoveWork = () => { + SpecialPlacesProvider.removeWork(); + setWork(null); + }; + + const isEmpty = !home && !work && favouriteStops.length === 0; + + if (loading) { + return ( + <div className="page-container"> + <div className="flex items-center justify-center py-12"> + <div className="text-gray-500 dark:text-gray-400"> + {t("common.loading", "Loading...")} + </div> + </div> + </div> + ); + } + + if (isEmpty) { + return ( + <div className="page-container"> + <div className="flex flex-col items-center justify-center py-12 px-4 text-center"> + <svg + className="w-16 h-16 text-gray-300 dark:text-gray-600 mb-4" + fill="none" + viewBox="0 0 24 24" + stroke="currentColor" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={1.5} + d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" + /> + </svg> + <p className="text-lg font-semibold text-gray-700 dark:text-gray-300 mb-2"> + {t("favourites.empty", "You don't have any favourite stops yet.")} + </p> + <p className="text-sm text-gray-500 dark:text-gray-400"> + {t( + "favourites.empty_description", + "Go to a stop and mark it as favourite to see it here." + )} + </p> + </div> + </div> + ); + } return ( - <div className="page-container"> - <p>{t("favourites.empty", "No tienes paradas favoritas.")}</p> + <div className="page-container pb-8"> + {/* Special Places Section */} + {(home || work) && ( + <div className="px-4 pt-4 pb-6"> + <h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4"> + {t("favourites.special_places", "Special Places")} + </h2> + <div className="flex flex-col gap-3"> + {/* Home */} + <SpecialPlaceCard + icon="🏠" + label={t("favourites.home", "Home")} + place={home} + onRemove={handleRemoveHome} + editLabel={t("favourites.edit_home", "Edit Home")} + removeLabel={t("favourites.remove_home", "Remove Home")} + notSetLabel={t("favourites.not_set", "Not set")} + setLabel={t("favourites.set_home", "Set Home")} + /> + {/* Work */} + <SpecialPlaceCard + icon="💼" + label={t("favourites.work", "Work")} + place={work} + onRemove={handleRemoveWork} + editLabel={t("favourites.edit_work", "Edit Work")} + removeLabel={t("favourites.remove_work", "Remove Work")} + notSetLabel={t("favourites.not_set", "Not set")} + setLabel={t("favourites.set_work", "Set Work")} + /> + </div> + </div> + )} + + {/* Favourite Stops Section */} + {favouriteStops.length > 0 && ( + <div className="px-4 pt-4"> + <h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4"> + {t("favourites.favourite_stops", "Favourite Stops")} + </h2> + <ul className="list-none p-0 m-0 flex flex-col gap-2"> + {favouriteStops.map((stop) => ( + <FavouriteStopItem + key={stop.stopId} + stop={stop} + onRemove={handleRemoveFavourite} + removeLabel={t("favourites.remove", "Remove")} + viewLabel={t("favourites.view_estimates", "View estimates")} + /> + ))} + </ul> + </div> + )} </div> ); } + +interface SpecialPlaceCardProps { + icon: string; + label: string; + place: SpecialPlace | null; + onRemove: () => void; + editLabel: string; + removeLabel: string; + notSetLabel: string; + setLabel: string; +} + +function SpecialPlaceCard({ + icon, + label, + place, + onRemove, + editLabel, + removeLabel, + notSetLabel, + setLabel, +}: SpecialPlaceCardProps) { + return ( + <div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4"> + <div className="flex items-start justify-between gap-3"> + <div className="flex items-start gap-3 flex-1 min-w-0"> + <span className="text-2xl" aria-hidden="true"> + {icon} + </span> + <div className="flex-1 min-w-0"> + <h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1"> + {label} + </h3> + {place ? ( + <div className="text-sm text-gray-600 dark:text-gray-400"> + <p className="font-medium text-gray-900 dark:text-gray-100"> + {place.name} + </p> + {place.type === "stop" && place.stopId && ( + <p className="text-xs mt-1">({place.stopId})</p> + )} + {place.type === "address" && place.address && ( + <p className="text-xs mt-1">{place.address}</p> + )} + </div> + ) : ( + <p className="text-sm text-gray-500 dark:text-gray-400"> + {notSetLabel} + </p> + )} + </div> + </div> + <div className="flex flex-col gap-2"> + {place ? ( + <> + {place.type === "stop" && place.stopId && ( + <Link + to={`/stops/${place.stopId}`} + className="text-xs text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap" + > + {editLabel} + </Link> + )} + <button + onClick={onRemove} + className="text-xs text-red-600 dark:text-red-400 hover:underline whitespace-nowrap" + type="button" + > + {removeLabel} + </button> + </> + ) : ( + <button + onClick={() => { + // TODO: Open modal/dialog to set location + console.log("Set location not implemented yet"); + }} + className="text-xs text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap" + type="button" + > + {setLabel} + </button> + )} + </div> + </div> + </div> + ); +} + +interface FavouriteStopItemProps { + stop: Stop; + onRemove: (stopId: string) => void; + removeLabel: string; + viewLabel: string; +} + +function FavouriteStopItem({ + stop, + onRemove, + removeLabel, + viewLabel, +}: FavouriteStopItemProps) { + const { t } = useTranslation(); + const confirmAndRemove = () => { + const ok = window.confirm( + t("favourites.confirm_remove", "Remove this favourite?") + ); + if (!ok) return; + onRemove(stop.stopId); + }; + + return ( + <li className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg"> + <div className="flex items-stretch justify-between gap-2"> + <Link + to={`/stops/${stop.stopId}`} + className="flex-1 min-w-0 p-3 no-underline hover:bg-gray-50 dark:hover:bg-gray-800/80 rounded-l-lg transition-colors" + > + <div className="flex items-center gap-2 mb-1"> + <span className="text-yellow-500 text-base" aria-label="Favourite"> + ★ + </span> + <span className="text-xs text-gray-600 dark:text-gray-400 font-medium"> + ({stop.stopId}) + </span> + </div> + <div className="font-semibold text-gray-900 dark:text-gray-100 mb-2"> + {StopDataProvider.getDisplayName(stop)} + </div> + <div className="flex flex-wrap gap-1 items-center"> + {stop.lines?.slice(0, 6).map((line) => ( + <LineIcon key={line} line={line} /> + ))} + {stop.lines && stop.lines.length > 6 && ( + <span className="text-xs text-gray-500 dark:text-gray-400 ml-1"> + +{stop.lines.length - 6} + </span> + )} + </div> + </Link> + <div className="flex items-center pr-3"> + <button + onClick={confirmAndRemove} + className="text-sm px-3 py-1 rounded-md border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors whitespace-nowrap" + type="button" + aria-label={removeLabel} + > + {removeLabel} + </button> + </div> + </div> + </li> + ); +} diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx index 5121dce..55d1553 100644 --- a/src/frontend/app/routes/planner.tsx +++ b/src/frontend/app/routes/planner.tsx @@ -754,6 +754,8 @@ export default function PlannerPage() { selectedItineraryIndex, selectItinerary, deselectItinerary, + setOrigin, + setDestination, } = usePlanner(); const [selectedItinerary, setSelectedItinerary] = useState<Itinerary | null>( null @@ -839,7 +841,31 @@ export default function PlannerPage() { </p> )} </div> - <button onClick={clearRoute} className="text-sm text-red-500"> + <button + onClick={() => { + clearRoute(); + setDestination(null); + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + async (pos) => { + const initial = { + name: t("planner.current_location"), + label: "GPS", + lat: pos.coords.latitude, + lon: pos.coords.longitude, + layer: "current-location", + } as any; + setOrigin(initial); + }, + () => { + // If geolocation fails, just keep origin empty + }, + { enableHighAccuracy: true, timeout: 10000 } + ); + } + }} + className="text-sm text-red-500" + > {t("planner.clear")} </button> </div> diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index c51f69b..5c12580 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -38,6 +38,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.6", "@types/react-dom": "^19.2.3", + "baseline-browser-mapping": "^2.9.7", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", @@ -2716,9 +2717,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.16", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz", - "integrity": "sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==", + "version": "2.9.7", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.7.tgz", + "integrity": "sha512-k9xFKplee6KIio3IDbwj+uaCLpqzOwakOgmqzPezM0sFJlFKcg30vk2wOiAJtkTSfx0SSQDSe8q+mWA/fSH5Zg==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/src/frontend/package.json b/src/frontend/package.json index 90c9ea7..7734ae2 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -44,6 +44,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.6", "@types/react-dom": "^19.2.3", + "baseline-browser-mapping": "^2.9.7", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", |
