From 1b4f4a674ac533c0b51260ba35ab91dd2cf9486d Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Thu, 2 Apr 2026 12:38:10 +0200 Subject: Basic push notification system for service alerts Co-authored-by: Copilot --- .../app/components/PushNotificationSettings.tsx | 198 +++++++++++++++++++++ src/frontend/app/components/ServiceAlerts.tsx | 115 ++++++++++-- 2 files changed, 303 insertions(+), 10 deletions(-) create mode 100644 src/frontend/app/components/PushNotificationSettings.tsx (limited to 'src/frontend/app/components') diff --git a/src/frontend/app/components/PushNotificationSettings.tsx b/src/frontend/app/components/PushNotificationSettings.tsx new file mode 100644 index 0000000..af3206a --- /dev/null +++ b/src/frontend/app/components/PushNotificationSettings.tsx @@ -0,0 +1,198 @@ +import { BellOff, BellRing, Loader } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { writeFavorites } from "~/utils/idb"; + +/** Convert a base64url string (as returned by the VAPID endpoint) to a Uint8Array. */ +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + const rawData = atob(base64); + return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0))); +} + +/** Sync all three favourites lists from localStorage to IndexedDB. */ +async function syncFavouritesToIdb() { + const keys = [ + "favouriteStops", + "favouriteRoutes", + "favouriteAgencies", + ] as const; + await Promise.all( + keys.map((key) => { + const raw = localStorage.getItem(key); + const ids: string[] = raw ? JSON.parse(raw) : []; + return writeFavorites(key, ids); + }) + ); +} + +type Status = + | "loading" // checking current state + | "unsupported" // browser does not support Push API + | "denied" // permission was explicitly blocked + | "subscribed" // user is actively subscribed + | "unsubscribed"; // user is not subscribed (or permission not yet granted) + +export function PushNotificationSettings() { + const { t } = useTranslation(); + const [status, setStatus] = useState("loading"); + const [working, setWorking] = useState(false); + + useEffect(() => { + checkStatus().then(setStatus); + }, []); + + async function checkStatus(): Promise { + if (!("serviceWorker" in navigator) || !("PushManager" in window)) { + return "unsupported"; + } + if (Notification.permission === "denied") return "denied"; + + try { + const reg = await navigator.serviceWorker.ready; + const sub = await reg.pushManager.getSubscription(); + return sub ? "subscribed" : "unsubscribed"; + } catch { + return "unsubscribed"; + } + } + + async function subscribe() { + setWorking(true); + try { + const permission = await Notification.requestPermission(); + if (permission !== "granted") { + setStatus("denied"); + return; + } + + // Fetch the VAPID public key + const res = await fetch("/api/push/vapid-public-key"); + if (!res.ok) { + console.error("Push notifications not configured on this server."); + return; + } + const { publicKey } = await res.json(); + + const reg = await navigator.serviceWorker.ready; + const subscription = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }); + + // Mirror favourites to IDB before registering so the SW has them from day 1 + await syncFavouritesToIdb(); + + const json = subscription.toJSON(); + await fetch("/api/push/subscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + endpoint: json.endpoint, + p256Dh: json.keys?.p256dh, + auth: json.keys?.auth, + }), + }); + + setStatus("subscribed"); + } finally { + setWorking(false); + } + } + + async function unsubscribe() { + setWorking(true); + try { + const reg = await navigator.serviceWorker.ready; + const sub = await reg.pushManager.getSubscription(); + if (sub) { + const endpoint = sub.endpoint; + await sub.unsubscribe(); + await fetch("/api/push/unsubscribe", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ endpoint }), + }); + } + setStatus("unsubscribed"); + } finally { + setWorking(false); + } + } + + return ( +
+

+ {t("settings.push_title", "Notificaciones")} +

+

+ {t( + "settings.push_description", + "Recibe notificaciones cuando haya alertas de servicio relevantes para tus paradas, líneas o operadores favoritos." + )} +

+ + {status === "loading" && ( +
+ + {t("common.loading", "Cargando...")} +
+ )} + + {status === "unsupported" && ( +

+ {t( + "settings.push_unsupported", + "Tu navegador no soporta notificaciones push. Prueba con Chrome, Edge o Firefox." + )} +

+ )} + + {status === "denied" && ( +

+ {t( + "settings.push_permission_denied", + "Has bloqueado los permisos de notificación en este navegador. Para activarlos, ve a la configuración del sitio y permite las notificaciones." + )} +

+ )} + + {(status === "subscribed" || status === "unsubscribed") && ( + + )} +
+ ); +} diff --git a/src/frontend/app/components/ServiceAlerts.tsx b/src/frontend/app/components/ServiceAlerts.tsx index a6a1ee8..168ea83 100644 --- a/src/frontend/app/components/ServiceAlerts.tsx +++ b/src/frontend/app/components/ServiceAlerts.tsx @@ -1,22 +1,117 @@ +import { useQuery } from "@tanstack/react-query"; import React from "react"; import { useTranslation } from "react-i18next"; import "./ServiceAlerts.css"; -const ServiceAlerts: React.FC = () => { - const { t } = useTranslation(); +interface ServiceAlert { + id: string; + version: number; + phase: string; + cause: string; + effect: string; + header: Record; + description: Record; + selectors: string[]; + infoUrls: string[]; + eventStartDate: string; + eventEndDate: string; +} + +/** Maps an alert effect to one of the three CSS severity classes. */ +function effectToSeverity(effect: string): "info" | "warning" | "error" { + if (["NoService", "SignificantDelays", "AccessibilityIssue"].includes(effect)) + return "error"; + if ( + ["ReducedService", "Detour", "ModifiedService", "StopMoved"].includes( + effect + ) + ) + return "warning"; + return "info"; +} + +/** Maps an effect to an emoji icon. */ +function effectToIcon(effect: string): string { + const map: Record = { + NoService: "🚫", + ReducedService: "⚠️", + SignificantDelays: "🕐", + Detour: "↩️", + AdditionalService: "➕", + ModifiedService: "🔄", + StopMoved: "📍", + AccessibilityIssue: "♿", + }; + return map[effect] ?? "ℹ️"; +} + +interface ServiceAlertsProps { + /** If provided, only alerts whose selectors overlap with this list are shown. */ + selectorFilter?: string[]; +} + +const ServiceAlerts: React.FC = ({ selectorFilter }) => { + const { t, i18n } = useTranslation(); + const lang = i18n.language.slice(0, 2); + + const { data: alerts, isLoading } = useQuery({ + queryKey: ["service-alerts"], + queryFn: () => fetch("/api/alerts").then((r) => r.json()), + staleTime: 5 * 60 * 1000, + retry: false, + }); + + if (isLoading || !alerts) return null; + + const visible = alerts.filter((alert) => { + if (!selectorFilter || selectorFilter.length === 0) return true; + return alert.selectors.some((s) => selectorFilter.includes(s)); + }); + + if (visible.length === 0) return null; return (

{t("stoplist.service_alerts")}

-
-
ℹ️
-
-
{t("stoplist.alerts_coming_soon")}
-
- {t("stoplist.alerts_description")} + {visible.map((alert) => { + const severity = effectToSeverity(alert.effect); + const icon = effectToIcon(alert.effect); + const title = + alert.header[lang] ?? + alert.header["es"] ?? + Object.values(alert.header)[0] ?? + ""; + const body = + alert.description[lang] ?? + alert.description["es"] ?? + Object.values(alert.description)[0] ?? + ""; + + return ( +
+
{icon}
+
+
{title}
+ {body &&
{body}
} + {alert.infoUrls.length > 0 && ( +
+ {alert.infoUrls.map((url, i) => ( + + {url} + + ))} +
+ )} +
-
-
+ ); + })}
); }; -- cgit v1.3