aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/frontend/app')
-rw-r--r--src/frontend/app/components/layout/Drawer.tsx6
-rw-r--r--src/frontend/app/config/constants.ts1
-rw-r--r--src/frontend/app/contexts/MapContext.tsx16
-rw-r--r--src/frontend/app/data/StopDataProvider.ts93
-rw-r--r--src/frontend/app/routes.tsx1
-rw-r--r--src/frontend/app/routes/privacy.tsx375
-rw-r--r--src/frontend/app/routes/settings.tsx82
7 files changed, 502 insertions, 72 deletions
diff --git a/src/frontend/app/components/layout/Drawer.tsx b/src/frontend/app/components/layout/Drawer.tsx
index 55aa3a0..3f7b4d5 100644
--- a/src/frontend/app/components/layout/Drawer.tsx
+++ b/src/frontend/app/components/layout/Drawer.tsx
@@ -1,4 +1,4 @@
-import { Info, Settings, Star, X } from "lucide-react";
+import { Info, Settings, Shield, Star, X } from "lucide-react";
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router";
@@ -45,6 +45,10 @@ export const Drawer: React.FC<DrawerProps> = ({ isOpen, onClose }) => {
<Info size={20} />
<span>{t("about.title", "Acerca de")}</span>
</Link>
+ <Link to="/politica-privacidad" className="drawer__link">
+ <Shield size={20} />
+ <span>{t("navbar.privacy", "Privacidad")}</span>
+ </Link>
</nav>
</div>
</>
diff --git a/src/frontend/app/config/constants.ts b/src/frontend/app/config/constants.ts
index 3cbb37c..5ede933 100644
--- a/src/frontend/app/config/constants.ts
+++ b/src/frontend/app/config/constants.ts
@@ -1,7 +1,6 @@
import type { LngLatLike } from "maplibre-gl";
export const APP_CONSTANTS = {
- id: "vigo",
defaultCenter: {
lat: 42.229188855975046,
lng: -8.72246955783102,
diff --git a/src/frontend/app/contexts/MapContext.tsx b/src/frontend/app/contexts/MapContext.tsx
index 75851f4..d93fefc 100644
--- a/src/frontend/app/contexts/MapContext.tsx
+++ b/src/frontend/app/contexts/MapContext.tsx
@@ -25,15 +25,23 @@ interface MapContextProps {
const MapContext = createContext<MapContextProps | undefined>(undefined);
+const LOCATION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
+
export const MapProvider = ({ children }: { children: ReactNode }) => {
const [mapState, setMapState] = useState<MapState>(() => {
const savedMapState = localStorage.getItem("mapState");
if (savedMapState) {
try {
const parsed = JSON.parse(savedMapState);
+ const locationAge = parsed.userLocationTimestamp
+ ? Date.now() - parsed.userLocationTimestamp
+ : Infinity;
return {
paths: parsed.paths || {},
- userLocation: parsed.userLocation || null,
+ userLocation:
+ locationAge < LOCATION_TTL_MS
+ ? (parsed.userLocation ?? null)
+ : null,
hasLocationPermission: parsed.hasLocationPermission || false,
};
} catch (e) {
@@ -51,7 +59,11 @@ export const MapProvider = ({ children }: { children: ReactNode }) => {
const setUserLocation = useCallback((userLocation: LngLatLike | null) => {
setMapState((prev) => {
- const newState = { ...prev, userLocation };
+ const newState = {
+ ...prev,
+ userLocation,
+ userLocationTimestamp: userLocation ? Date.now() : null,
+ };
localStorage.setItem("mapState", JSON.stringify(newState));
return newState;
});
diff --git a/src/frontend/app/data/StopDataProvider.ts b/src/frontend/app/data/StopDataProvider.ts
index 76182c7..d8219c9 100644
--- a/src/frontend/app/data/StopDataProvider.ts
+++ b/src/frontend/app/data/StopDataProvider.ts
@@ -1,5 +1,3 @@
-import { APP_CONSTANTS } from "~/config/constants";
-
export interface Stop {
stopId: string;
stopCode?: string;
@@ -20,14 +18,14 @@ interface CacheEntry {
timestamp: number;
}
-const CACHE_KEY = `stops_cache_${APP_CONSTANTS.id}`;
+const CACHE_KEY = `stops_cache`;
const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
// In-memory cache for the current session
const memoryCache: Record<string, Stop> = {};
// Custom names loaded from localStorage per region
-const customNamesByRegion: Record<string, Record<string, string>> = {};
+let customNames: Record<string, string> = {};
// Helper to normalize ID
function normalizeId(id: number | string): string {
@@ -128,58 +126,38 @@ function getDisplayName(stop: Stop): string {
function setCustomName(stopId: string | number, label: string) {
const id = normalizeId(stopId);
- if (!customNamesByRegion[APP_CONSTANTS.id]) {
- const rawCustom = localStorage.getItem(
- `customStopNames_${APP_CONSTANTS.id}`
- );
- customNamesByRegion[APP_CONSTANTS.id] = rawCustom
- ? JSON.parse(rawCustom)
- : {};
+ if (!customNames) {
+ const rawCustom = localStorage.getItem(`customStopNames`);
+ customNames = rawCustom ? JSON.parse(rawCustom) : {};
}
- customNamesByRegion[APP_CONSTANTS.id][id] = label;
- localStorage.setItem(
- `customStopNames_${APP_CONSTANTS.id}`,
- JSON.stringify(customNamesByRegion[APP_CONSTANTS.id])
- );
+ customNames[id] = label;
+ localStorage.setItem(`customStopNames`, JSON.stringify(customNames));
}
function removeCustomName(stopId: string | number) {
const id = normalizeId(stopId);
- if (!customNamesByRegion[APP_CONSTANTS.id]) {
- const rawCustom = localStorage.getItem(
- `customStopNames_${APP_CONSTANTS.id}`
- );
- customNamesByRegion[APP_CONSTANTS.id] = rawCustom
- ? JSON.parse(rawCustom)
- : {};
+ if (!customNames) {
+ const rawCustom = localStorage.getItem(`customStopNames`);
+ customNames = rawCustom ? JSON.parse(rawCustom) : {};
}
- if (customNamesByRegion[APP_CONSTANTS.id][id]) {
- delete customNamesByRegion[APP_CONSTANTS.id][id];
- localStorage.setItem(
- `customStopNames_${APP_CONSTANTS.id}`,
- JSON.stringify(customNamesByRegion[APP_CONSTANTS.id])
- );
+ if (customNames[id]) {
+ delete customNames[id];
+ localStorage.setItem(`customStopNames`, JSON.stringify(customNames));
}
}
function getCustomName(stopId: string | number): string | undefined {
const id = normalizeId(stopId);
- if (!customNamesByRegion[APP_CONSTANTS.id]) {
- const rawCustom = localStorage.getItem(
- `customStopNames_${APP_CONSTANTS.id}`
- );
- customNamesByRegion[APP_CONSTANTS.id] = rawCustom
- ? JSON.parse(rawCustom)
- : {};
+ if (!customNames) {
+ const rawCustom = localStorage.getItem(`customStopNames`);
+ customNames = rawCustom ? JSON.parse(rawCustom) : {};
}
- return customNamesByRegion[APP_CONSTANTS.id][id];
+ return customNames[id];
}
function addFavourite(stopId: string | number) {
const id = normalizeId(stopId);
- const rawFavouriteStops = localStorage.getItem(
- `favouriteStops_${APP_CONSTANTS.id}`
- );
+ const rawFavouriteStops = localStorage.getItem(`favouriteStops`);
let favouriteStops: string[] = [];
if (rawFavouriteStops) {
favouriteStops = (JSON.parse(rawFavouriteStops) as (number | string)[]).map(
@@ -189,18 +167,13 @@ function addFavourite(stopId: string | number) {
if (!favouriteStops.includes(id)) {
favouriteStops.push(id);
- localStorage.setItem(
- `favouriteStops_${APP_CONSTANTS.id}`,
- JSON.stringify(favouriteStops)
- );
+ localStorage.setItem(`favouriteStops`, JSON.stringify(favouriteStops));
}
}
function removeFavourite(stopId: string | number) {
const id = normalizeId(stopId);
- const rawFavouriteStops = localStorage.getItem(
- `favouriteStops_${APP_CONSTANTS.id}`
- );
+ const rawFavouriteStops = localStorage.getItem(`favouriteStops`);
let favouriteStops: string[] = [];
if (rawFavouriteStops) {
favouriteStops = (JSON.parse(rawFavouriteStops) as (number | string)[]).map(
@@ -209,17 +182,12 @@ function removeFavourite(stopId: string | number) {
}
const newFavouriteStops = favouriteStops.filter((sid) => sid !== id);
- localStorage.setItem(
- `favouriteStops_${APP_CONSTANTS.id}`,
- JSON.stringify(newFavouriteStops)
- );
+ localStorage.setItem(`favouriteStops`, JSON.stringify(newFavouriteStops));
}
function isFavourite(stopId: string | number): boolean {
const id = normalizeId(stopId);
- const rawFavouriteStops = localStorage.getItem(
- `favouriteStops_${APP_CONSTANTS.id}`
- );
+ const rawFavouriteStops = localStorage.getItem(`favouriteStops`);
if (rawFavouriteStops) {
const favouriteStops = (
JSON.parse(rawFavouriteStops) as (number | string)[]
@@ -233,9 +201,7 @@ const RECENT_STOPS_LIMIT = 10;
function pushRecent(stopId: string | number) {
const id = normalizeId(stopId);
- const rawRecentStops = localStorage.getItem(
- `recentStops_${APP_CONSTANTS.id}`
- );
+ const rawRecentStops = localStorage.getItem(`recentStops`);
let recentStops: string[] = [];
if (rawRecentStops) {
recentStops = (JSON.parse(rawRecentStops) as (number | string)[]).map(
@@ -251,16 +217,11 @@ function pushRecent(stopId: string | number) {
recentStops = recentStops.slice(0, RECENT_STOPS_LIMIT);
}
- localStorage.setItem(
- `recentStops_${APP_CONSTANTS.id}`,
- JSON.stringify(recentStops)
- );
+ localStorage.setItem(`recentStops`, JSON.stringify(recentStops));
}
function getRecent(): string[] {
- const rawRecentStops = localStorage.getItem(
- `recentStops_${APP_CONSTANTS.id}`
- );
+ const rawRecentStops = localStorage.getItem(`recentStops`);
if (rawRecentStops) {
return (JSON.parse(rawRecentStops) as (number | string)[]).map(normalizeId);
}
@@ -268,9 +229,7 @@ function getRecent(): string[] {
}
function getFavouriteIds(): string[] {
- const rawFavouriteStops = localStorage.getItem(
- `favouriteStops_${APP_CONSTANTS.id}`
- );
+ const rawFavouriteStops = localStorage.getItem(`favouriteStops`);
if (rawFavouriteStops) {
return (JSON.parse(rawFavouriteStops) as (number | string)[]).map(
normalizeId
diff --git a/src/frontend/app/routes.tsx b/src/frontend/app/routes.tsx
index 8e98734..7c02728 100644
--- a/src/frontend/app/routes.tsx
+++ b/src/frontend/app/routes.tsx
@@ -11,4 +11,5 @@ export default [
route("/about", "routes/about.tsx"),
route("/favourites", "routes/favourites.tsx"),
route("/planner", "routes/planner.tsx"),
+ route("/politica-privacidad", "routes/privacy.tsx"),
] satisfies RouteConfig;
diff --git a/src/frontend/app/routes/privacy.tsx b/src/frontend/app/routes/privacy.tsx
new file mode 100644
index 0000000..2998223
--- /dev/null
+++ b/src/frontend/app/routes/privacy.tsx
@@ -0,0 +1,375 @@
+import { usePageTitle } from "~/contexts/PageTitleContext";
+import "../tailwind-full.css";
+
+export default function Privacy() {
+ usePageTitle("Política de privacidad");
+
+ return (
+ <div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
+ <h1 className="text-3xl font-bold mb-2 text-text">
+ Política de privacidad
+ </h1>
+ <p className="text-sm text-muted mb-8">
+ Última actualización: 16 de marzo de 2026
+ </p>
+
+ {/* 1. Responsable */}
+ <section className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 border-b border-border pb-2 text-text">
+ 1. Responsable del tratamiento
+ </h2>
+ <p className="text-text opacity-90 leading-relaxed">
+ El responsable del tratamiento de los datos personales recogidos a
+ través de esta aplicación es:
+ </p>
+ <ul className="mt-3 space-y-1 text-text opacity-90 list-none ml-0">
+ <li>
+ <strong>Nombre:</strong> Ariel Costas Guerrero
+ </li>
+ <li>
+ <strong>Correo de contacto:</strong>{" "}
+ <a
+ href="mailto:privacidad@enmarcha.app"
+ className="text-blue-600 dark:text-blue-400 hover:underline"
+ >
+ privacidad@enmarcha.app
+ </a>
+ </li>
+ </ul>
+ </section>
+
+ {/* 2. Datos tratados */}
+ <section className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 border-b border-border pb-2 text-text">
+ 2. Datos que recogemos y por qué
+ </h2>
+ <p className="text-text opacity-90 mb-4">
+ Esta aplicación es un servicio de consulta de transporte público. No
+ se crean cuentas de usuario ni se realiza ningún tipo de seguimiento
+ publicitario.
+ </p>
+
+ <div className="overflow-x-auto">
+ <table className="w-full text-sm text-text border-collapse bg-surface rounded shadow overflow-hidden">
+ <thead>
+ <tr className="border-b border-border">
+ <th className="text-left p-3 font-semibold">Actividad</th>
+ <th className="text-left p-3 font-semibold">Datos</th>
+ <th className="text-left p-3 font-semibold">Base jurídica</th>
+ <th className="text-left p-3 font-semibold">Conservación</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr className="border-b border-border">
+ <td className="p-3">Consulta de llegadas a una parada</td>
+ <td className="p-3">Código de parada (no personal)</td>
+ <td className="p-3">
+ Interés legítimo (prestación del servicio)
+ </td>
+ <td className="p-3">No se almacena</td>
+ </tr>
+ <tr className="border-b border-border bg-surface/40">
+ <td className="p-3">Planificación de rutas</td>
+ <td className="p-3">
+ Coordenadas de origen y destino, hora deseada
+ </td>
+ <td className="p-3">
+ Interés legítimo (prestación del servicio)
+ </td>
+ <td className="p-3">2 horas (caché local en tu dispositivo)</td>
+ </tr>
+ <tr className="border-b border-border">
+ <td className="p-3">
+ Geocodificación / geocodificación inversa
+ </td>
+ <td className="p-3">Coordenadas o texto de búsqueda</td>
+ <td className="p-3">
+ Interés legítimo (prestación del servicio)
+ </td>
+ <td className="p-3">No se almacena en servidor</td>
+ </tr>
+ <tr className="border-b border-border bg-surface/40">
+ <td className="p-3">
+ Paradas favoritas y nombres personalizados
+ </td>
+ <td className="p-3">
+ Identificadores de parada, nombres que tú asignas
+ </td>
+ <td className="p-3">
+ Acción del usuario (guardado voluntario)
+ </td>
+ <td className="p-3">Hasta que los borres tú</td>
+ </tr>
+ <tr className="border-b border-border">
+ <td className="p-3">Paradas y lugares recientes</td>
+ <td className="p-3">
+ Códigos de parada, coordenadas de búsquedas recientes
+ </td>
+ <td className="p-3">Interés legítimo (facilitar el uso)</td>
+ <td className="p-3">Persistente hasta que los borres tú</td>
+ </tr>
+ <tr className="border-b border-border bg-surface/40">
+ <td className="p-3">Ubicaciones de casa y trabajo</td>
+ <td className="p-3">Dirección descriptiva y coordenadas</td>
+ <td className="p-3">
+ Acción del usuario (configurado voluntariamente)
+ </td>
+ <td className="p-3">Hasta que los borres tú</td>
+ </tr>
+ <tr className="border-b border-border">
+ <td className="p-3">Posición GPS del mapa</td>
+ <td className="p-3">Coordenadas GPS de tu dispositivo</td>
+ <td className="p-3">
+ Consentimiento (permiso de geolocalización del navegador)
+ </td>
+ <td className="p-3">
+ 30 días (caché local; se descarta automáticamente)
+ </td>
+ </tr>
+ <tr className="border-b border-border bg-surface/40">
+ <td className="p-3">Registros operativos del servidor</td>
+ <td className="p-3">
+ Dirección IP anonimizada, identificadores de parada/ruta
+ </td>
+ <td className="p-3">
+ Interés legítimo (operación y seguridad)
+ </td>
+ <td className="p-3">Rotación estándar de logs del servidor</td>
+ </tr>
+ <tr className="border-b border-border">
+ <td className="p-3">Telemetría de rendimiento</td>
+ <td className="p-3">
+ Identificadores de parada/ruta (sin coordenadas)
+ </td>
+ <td className="p-3">Interés legítimo (mejora del servicio)</td>
+ <td className="p-3">Según configuración del colector OTLP</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </section>
+
+ {/* 3. Geolocalización */}
+ <section className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 border-b border-border pb-2 text-text">
+ 3. Geolocalización
+ </h2>
+ <p className="text-text opacity-90 leading-relaxed">
+ La aplicación puede solicitar acceso a tu ubicación GPS a través del
+ permiso de geolocalización del navegador. Este acceso es opcional: si
+ lo denegas, la aplicación seguirá funcionando sin centrar el mapa en
+ tu posición.
+ </p>
+ <p className="mt-3 text-text opacity-90 leading-relaxed">
+ Tus coordenadas <strong>no se transmiten al servidor</strong> salvo
+ cuando usas el planificador de rutas para calcular un trayecto desde
+ tu ubicación actual. En ese caso, las coordenadas se envían al
+ servidor únicamente para procesar la consulta y no se almacenan.
+ </p>
+ <p className="mt-3 text-text opacity-90 leading-relaxed">
+ La posición guardada localmente en tu dispositivo se descarta
+ automáticamente tras 30 días.
+ </p>
+ </section>
+
+ {/* 4. Almacenamiento local */}
+ <section className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 border-b border-border pb-2 text-text">
+ 4. Almacenamiento local (localStorage)
+ </h2>
+ <p className="text-text opacity-90 leading-relaxed">
+ Esta aplicación utiliza el almacenamiento local del navegador (
+ <code className="bg-surface px-1 rounded text-sm">localStorage</code>)
+ para guardar tus preferencias, favoritos e historial de uso. Este
+ almacenamiento <strong>nunca se comparte con terceros</strong> y es de
+ carácter estrictamente funcional, no de seguimiento ni publicidad.
+ </p>
+ <p className="mt-3 text-text opacity-90 leading-relaxed">
+ No se usan <em>cookies</em> de sesión ni de rastreo. La única
+ excepción es la preferencia de idioma, que puede almacenarse tanto en{" "}
+ <code className="bg-surface px-1 rounded text-sm">localStorage</code>{" "}
+ como en una cookie de primer nivel para mantener la selección entre
+ visitas.
+ </p>
+ <p className="mt-3 text-text opacity-90 leading-relaxed">
+ Puedes eliminar todos los datos personales guardados localmente en
+ cualquier momento desde{" "}
+ <strong>
+ Ajustes → Privacidad y datos → Borrar mis datos guardados
+ </strong>
+ .
+ </p>
+ </section>
+
+ {/* 5. Encargados del tratamiento */}
+ <section className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 border-b border-border pb-2 text-text">
+ 5. Encargados y destinatarios del tratamiento
+ </h2>
+ <p className="text-text opacity-90 mb-4">
+ Para prestar el servicio, algunas de tus consultas se transmiten a los
+ siguientes terceros. En ningún caso se transfiere información que
+ permita identificarte personalmente como usuario registrado, ya que la
+ aplicación no tiene sistema de cuentas.
+ </p>
+ <ul className="space-y-3 text-text opacity-90 list-disc ml-5">
+ <li>
+ <strong>Geoapify</strong> (api.geoapify.com) — buscador de
+ direcciones y geocodificación inversa. Recibe el texto de búsqueda o
+ las coordenadas de la consulta. Política de privacidad:{" "}
+ <a
+ href="https://www.geoapify.com/privacy-policy"
+ className="text-blue-600 dark:text-blue-400 hover:underline"
+ rel="nofollow noreferrer noopener"
+ target="_blank"
+ >
+ geoapify.com/privacy-policy
+ </a>
+ </li>
+ <li>
+ <strong>OpenTripPlanner</strong> — motor de planificación de rutas.
+ Recibe las coordenadas de origen y destino y la hora del viaje.
+ </li>
+ <li>
+ <strong>Vitrasa / Concello de Vigo</strong> (datos.vigo.org) — datos
+ de llegadas en tiempo real para Vigo. Solo recibe el código numérico
+ de la parada, y un resultado puede ser utilizado para varios
+ usuarios, de modo que no se asocia a un individuo concreto.
+ </li>
+ <li>
+ <strong>TUSSA</strong> (app.tussa.org) — datos de llegadas en tiempo
+ real para Santiago de Compostela. Solo recibe el código de la
+ parada, y un resultado puede ser utilizado para varios usuarios, de
+ modo que no se asocia a un individuo concreto.
+ </li>
+ <li>
+ <strong>Tranvías de A Coruña</strong> (itranvias.com) — datos de
+ llegadas en tiempo real para A Coruña. Solo recibe el código de la
+ parada, y un resultado puede ser utilizado para varios usuarios, de
+ modo que no se asocia a un individuo concreto.
+ </li>
+ <li>
+ <strong>Renfe GTFS-Realtime</strong> — feed público de posiciones de
+ trenes. No se envían datos del usuario.
+ </li>
+ </ul>
+ </section>
+
+ {/* 6. Derechos */}
+ <section className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 border-b border-border pb-2 text-text">
+ 6. Tus derechos
+ </h2>
+ <p className="text-text opacity-90 mb-3 leading-relaxed">
+ De acuerdo con el Reglamento General de Protección de Datos (RGPD) y
+ la Ley Orgánica 3/2018 de Protección de Datos Personales y garantía de
+ los derechos digitales (LOPDGDD), tienes derecho a:
+ </p>
+ <ul className="space-y-2 text-text opacity-90 list-disc ml-5">
+ <li>
+ <strong>Acceso</strong>: conocer qué datos tratamos sobre ti (los
+ datos que mencionamos previamente).
+ </li>
+ <li>
+ <strong>Rectificación</strong>: solicitar la corrección de datos
+ inexactos (puedes hacerlo a través de la aplicación).
+ </li>
+ <li>
+ <strong>Supresión</strong>: solicitar la eliminación de tus datos
+ («derecho al olvido»). Los datos guardados en el dispositivo los
+ debes eliminar manualmente, los datos en servidores se eliminan tras
+ el periodo de consevación indicado, y no se pueden eliminar
+ previamente por no ser asociados a un usuario concreto.
+ </li>
+ <li>
+ <strong>Oposición</strong>: oponerte al tratamiento basado en
+ interés legítimo. Dado que el tratamiento de datos en esta
+ aplicación se basa principalmente en el interés legítimo para
+ prestar el servicio, no es posible ejercer este derecho sin dejar de
+ usar la aplicación, o las funciones que implican este tratamiento
+ (planificador de rutas con "ubicación actual", por ejemplo).
+ </li>
+ <li>
+ <strong>Portabilidad</strong>: recibir tus datos en un formato
+ estructurado. Los datos están exclusivamente en el dispositivo,
+ basta con acceder mediante las herramientas de desarrollo del
+ navegador para copiarlos, o visualizarlos en la aplicación.
+ </li>
+ <li>
+ <strong>Limitación</strong>: solicitar que restrinjamos el
+ tratamiento de tus datos. Dado que la aplicación no almacena datos
+ personales en servidores de forma persistente, este derecho se puede
+ ejercer eliminando los datos desde la propia aplicación o dejando de
+ usar las funciones que implican el tratamiento.
+ </li>
+ </ul>
+ <p className="mt-4 text-text opacity-90 leading-relaxed">
+ Dado que la aplicación no almacena datos personales en ningún servidor
+ de forma persistente (todo lo personal reside en tu propio
+ dispositivo), la mayoría de estos derechos los puedes ejercer
+ directamente borrando los datos desde la propia aplicación (ver
+ sección 4).
+ </p>
+ <p className="mt-3 text-text opacity-90 leading-relaxed">
+ Para cualquier consulta o solicitud formal, puedes dirigirte a{" "}
+ <a
+ href="mailto:privacidad@enmarcha.app"
+ className="text-blue-600 dark:text-blue-400 hover:underline"
+ >
+ privacidad@enmarcha.app
+ </a>
+ .
+ </p>
+ </section>
+
+ {/* 7. AEPD */}
+ <section className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 border-b border-border pb-2 text-text">
+ 7. Derecho a reclamar ante la autoridad de control
+ </h2>
+ <p className="text-text opacity-90 leading-relaxed">
+ Si consideras que el tratamiento de tus datos no se ajusta a la
+ normativa vigente, puedes presentar una reclamación ante la{" "}
+ <strong>Agencia Española de Protección de Datos (AEPD)</strong>:
+ </p>
+ <ul className="mt-3 space-y-1 text-text opacity-90 list-none ml-0">
+ <li>
+ Web:{" "}
+ <a
+ href="https://www.aepd.es"
+ className="text-blue-600 dark:text-blue-400 hover:underline"
+ rel="nofollow noreferrer noopener"
+ target="_blank"
+ >
+ www.aepd.es
+ </a>
+ </li>
+ <li>
+ Sede electrónica:{" "}
+ <a
+ href="https://sedeagpd.gob.es"
+ className="text-blue-600 dark:text-blue-400 hover:underline"
+ rel="nofollow noreferrer noopener"
+ target="_blank"
+ >
+ sedeagpd.gob.es
+ </a>
+ </li>
+ </ul>
+ </section>
+
+ {/* 8. Cambios */}
+ <section className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 border-b border-border pb-2 text-text">
+ 8. Cambios en esta política
+ </h2>
+ <p className="text-text opacity-90 leading-relaxed">
+ Esta política puede actualizarse para reflejar cambios en la
+ aplicación o en la normativa aplicable. La fecha de «Última
+ actualización» indicada en la cabecera refleja la versión vigente. Te
+ recomendamos revisarla periódicamente.
+ </p>
+ </section>
+ </div>
+ );
+}
diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx
index e7fdffa..0497f34 100644
--- a/src/frontend/app/routes/settings.tsx
+++ b/src/frontend/app/routes/settings.tsx
@@ -1,5 +1,7 @@
-import { Computer, Moon, Sun } from "lucide-react";
+import { Computer, Moon, Sun, Trash2 } from "lucide-react";
+import { useState } from "react";
import { useTranslation } from "react-i18next";
+import { Link } from "react-router";
import { usePageTitle } from "~/contexts/PageTitleContext";
import { useApp, type Theme } from "../AppContext";
import "../tailwind-full.css";
@@ -7,6 +9,8 @@ import "../tailwind-full.css";
export default function Settings() {
const { t, i18n } = useTranslation();
usePageTitle(t("navbar.settings", "Ajustes"));
+ const [showClearConfirm, setShowClearConfirm] = useState(false);
+ const [cleared, setCleared] = useState(false);
const {
theme,
setTheme,
@@ -192,6 +196,82 @@ export default function Settings() {
<option value="en-GB">English</option>
</select>
</section>
+
+ {/* Privacy / Clear data */}
+ <section className="mt-8 pt-8 border-t border-border">
+ <h2 className="text-xl font-semibold mb-4 text-text">
+ {t("settings.privacy_title", "Privacidad y datos")}
+ </h2>
+ {!showClearConfirm && !cleared && (
+ <button
+ onClick={() => setShowClearConfirm(true)}
+ className="flex items-center gap-2 px-4 py-3 rounded-lg border border-border
+ bg-surface text-text hover:bg-red-50 hover:border-red-300 hover:text-red-700
+ dark:hover:bg-red-950 dark:hover:border-red-700 dark:hover:text-red-400
+ transition-colors duration-200 focus:outline-none focus:ring focus:ring-red-300"
+ >
+ <Trash2 className="w-5 h-5" />
+ {t("settings.clear_data", "Borrar mis datos guardados")}
+ </button>
+ )}
+ {showClearConfirm && (
+ <div className="p-4 rounded-lg border border-red-300 bg-red-50 dark:bg-red-950 dark:border-red-700">
+ <p className="text-sm text-text mb-4">
+ {t(
+ "settings.clear_data_confirm",
+ "Se eliminarán tus paradas favoritas, nombres personalizados, paradas recientes, historial de rutas, lugares guardados y posición del mapa. Las preferencias de ajustes se conservarán."
+ )}
+ </p>
+ <div className="flex gap-3">
+ <button
+ onClick={() => {
+ const personalKeys = [
+ "mapState",
+ "specialPlace_home",
+ "specialPlace_work",
+ "recentPlaces",
+ "planner_route_history",
+ `favouriteStops`,
+ `recentStops`,
+ `customStopNames`,
+ `stops_cache`,
+ ];
+ personalKeys.forEach((key) => localStorage.removeItem(key));
+ setShowClearConfirm(false);
+ setCleared(true);
+ }}
+ className="px-4 py-2 rounded-lg bg-red-600 text-white hover:bg-red-700
+ transition-colors duration-200 focus:outline-none focus:ring focus:ring-red-300"
+ >
+ {t("settings.clear_data_confirm_btn", "Sí, borrar datos")}
+ </button>
+ <button
+ onClick={() => setShowClearConfirm(false)}
+ className="px-4 py-2 rounded-lg border border-border bg-surface text-text
+ hover:bg-surface/50 transition-colors duration-200 focus:outline-none focus:ring focus:ring-primary/50"
+ >
+ {t("settings.cancel", "Cancelar")}
+ </button>
+ </div>
+ </div>
+ )}
+ {cleared && (
+ <p className="text-sm text-green-700 dark:text-green-400">
+ {t(
+ "settings.clear_data_done",
+ "Tus datos se han borrado correctamente."
+ )}
+ </p>
+ )}
+ <p className="mt-4 text-sm text-muted">
+ <Link
+ to="/politica-privacidad"
+ className="underline hover:text-text transition-colors"
+ >
+ {t("settings.privacy_policy_link", "Política de privacidad")}
+ </Link>
+ </p>
+ </section>
</div>
);
}