From 5e9f1094a50bbcdd514e958dcd67d0f0a844589d Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Fri, 12 Dec 2025 20:30:44 +0100 Subject: feat: implement favourites management, add recent places functionality, and enhance planner features --- .../Services/OtpService.cs | 4 +- .../Types/Otp/OtpModels.cs | 10 +- src/frontend/app/components/PlaceListItem.tsx | 47 ++++ src/frontend/app/components/PlannerOverlay.tsx | 163 ++++++++--- src/frontend/app/data/SpecialPlacesProvider.ts | 78 ++++++ src/frontend/app/i18n/locales/en-GB.json | 22 +- src/frontend/app/i18n/locales/es-ES.json | 22 +- src/frontend/app/i18n/locales/gl-ES.json | 22 +- src/frontend/app/routes/favourites.tsx | 309 ++++++++++++++++++++- src/frontend/app/routes/planner.tsx | 28 +- src/frontend/package-lock.json | 7 +- src/frontend/package.json | 1 + 12 files changed, 661 insertions(+), 52 deletions(-) create mode 100644 src/frontend/app/components/PlaceListItem.tsx create mode 100644 src/frontend/app/data/SpecialPlacesProvider.ts (limited to 'src') 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(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 ( + + ); + case "address": + case "street": + case "favourite-stop": + case "current-location": + default: + return ; + } +} + +export default function PlaceListItem({ + place, + onClick, +}: { + place: PlannerSearchResult; + onClick: (place: PlannerSearchResult) => void; +}) { + return ( +
  • + +
  • + ); +} 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 = ({ const [favouriteStops, setFavouriteStops] = useState( [] ); + const [recentPlaces, setRecentPlaces] = useState([]); + const RECENT_KEY = `recentPlaces_${REGION_DATA.id}`; + const clearRecentPlaces = useCallback(() => { + setRecentPlaces([]); + try { + localStorage.removeItem(RECENT_KEY); + } catch {} + }, []); const pickerInputRef = useRef(null); @@ -100,6 +111,43 @@ export const PlannerOverlay: React.FC = ({ .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 = ({ ); }, [favouriteStops, pickerQuery]); + const sortedRemoteResults = useMemo(() => { + const order: Record = { 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 = ({ setDestination(result); setDestQuery(result.name || ""); } + addRecentPlace(result); setPickerOpen(false); }; @@ -484,15 +555,17 @@ export const PlannerOverlay: React.FC = ({ /> @@ -506,60 +579,74 @@ export const PlannerOverlay: React.FC = ({ onClick={setOriginFromCurrentLocation} disabled={locationLoading} > -
    -
    - {t("planner.current_location")} -
    -
    - {t("planner.gps")} +
    + + + +
    +
    + {t("planner.current_location")} +
    +
    + {t("planner.gps")} +
    - {locationLoading ? "…" : "📍"} + {locationLoading ? "…" : ""}
    )} + {(remoteLoading || sortedRemoteResults.length > 0) && ( +
  • + {remoteLoading + ? t("planner.searching_ellipsis") + : t("planner.results", "Results")} +
  • + )} + + {sortedRemoteResults.map((r, i) => ( + + ))} + {filteredFavouriteStops.length > 0 && ( <>
  • {t("planner.favourite_stops")}
  • {filteredFavouriteStops.map((r, i) => ( -
  • - -
  • + place={r} + onClick={applyPickedResult} + /> ))} )} - {(remoteLoading || remoteResults.length > 0) && ( -
  • - {remoteLoading - ? t("planner.searching_ellipsis") - : t("planner.results", "Results")} + {recentPlaces.length > 0 && ( +
  • + + {t("planner.recent_locations", "Recent locations")} + +
  • )} - {remoteResults.map((r, i) => ( + {recentPlaces.map((r, i) => (
  • + + ) : ( + + )} +
  • +
    + + ); +} + +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 ( +
  • +
    + +
    + + ★ + + + ({stop.stopId}) + +
    +
    + {StopDataProvider.getDisplayName(stop)} +
    +
    + {stop.lines?.slice(0, 6).map((line) => ( + + ))} + {stop.lines && stop.lines.length > 6 && ( + + +{stop.lines.length - 6} + + )} +
    + +
    + +
    +
    +
  • + ); +} 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( null @@ -839,7 +841,31 @@ export default function PlannerPage() {

    )} - 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", -- cgit v1.3