aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-12-12 20:30:44 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-12-12 20:30:44 +0100
commit5e9f1094a50bbcdd514e958dcd67d0f0a844589d (patch)
tree6df6dd2f589d18d60df33b82456305b04c6b8688 /src/frontend/app
parentc3363ee0e3808d826c4e4797ffa7207647435e08 (diff)
feat: implement favourites management, add recent places functionality, and enhance planner features
Diffstat (limited to 'src/frontend/app')
-rw-r--r--src/frontend/app/components/PlaceListItem.tsx47
-rw-r--r--src/frontend/app/components/PlannerOverlay.tsx163
-rw-r--r--src/frontend/app/data/SpecialPlacesProvider.ts78
-rw-r--r--src/frontend/app/i18n/locales/en-GB.json22
-rw-r--r--src/frontend/app/i18n/locales/es-ES.json22
-rw-r--r--src/frontend/app/i18n/locales/gl-ES.json22
-rw-r--r--src/frontend/app/routes/favourites.tsx309
-rw-r--r--src/frontend/app/routes/planner.tsx28
8 files changed, 646 insertions, 45 deletions
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>