aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/components
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2026-04-02 12:38:10 +0200
committerAriel Costas Guerrero <ariel@costas.dev>2026-04-02 12:45:33 +0200
commit1b4f4a674ac533c0b51260ba35ab91dd2cf9486d (patch)
tree9fdaf418bef86c51737bcf203483089c9e2b908b /src/frontend/app/components
parent749e04d6fc2304bb29920db297d1fa4d73b57648 (diff)
Basic push notification system for service alerts
Co-authored-by: Copilot <copilot@github.com>
Diffstat (limited to 'src/frontend/app/components')
-rw-r--r--src/frontend/app/components/PushNotificationSettings.tsx198
-rw-r--r--src/frontend/app/components/ServiceAlerts.tsx115
2 files changed, 303 insertions, 10 deletions
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<Status>("loading");
+ const [working, setWorking] = useState(false);
+
+ useEffect(() => {
+ checkStatus().then(setStatus);
+ }, []);
+
+ async function checkStatus(): Promise<Status> {
+ 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 (
+ <section className="mb-8">
+ <h2 className="text-xl font-semibold mb-2 text-text">
+ {t("settings.push_title", "Notificaciones")}
+ </h2>
+ <p className="text-sm text-muted mb-4">
+ {t(
+ "settings.push_description",
+ "Recibe notificaciones cuando haya alertas de servicio relevantes para tus paradas, líneas o operadores favoritos."
+ )}
+ </p>
+
+ {status === "loading" && (
+ <div className="flex items-center gap-2 text-muted text-sm">
+ <Loader className="w-4 h-4 animate-spin" />
+ {t("common.loading", "Cargando...")}
+ </div>
+ )}
+
+ {status === "unsupported" && (
+ <p className="text-sm text-muted p-4 rounded-lg border border-border bg-surface">
+ {t(
+ "settings.push_unsupported",
+ "Tu navegador no soporta notificaciones push. Prueba con Chrome, Edge o Firefox."
+ )}
+ </p>
+ )}
+
+ {status === "denied" && (
+ <p className="text-sm text-muted p-4 rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-950 dark:border-amber-700">
+ {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."
+ )}
+ </p>
+ )}
+
+ {(status === "subscribed" || status === "unsubscribed") && (
+ <label className="flex items-center justify-between p-4 rounded-lg border border-border bg-surface cursor-pointer hover:bg-surface/50 transition-colors">
+ <span className="flex items-center gap-2 text-text font-medium">
+ {status === "subscribed" ? (
+ <BellRing className="w-5 h-5 text-primary" />
+ ) : (
+ <BellOff className="w-5 h-5 text-muted" />
+ )}
+ {status === "subscribed"
+ ? t("settings.push_subscribed", "Notificaciones activadas")
+ : t(
+ "settings.push_subscribe",
+ "Activar notificaciones de alertas"
+ )}
+ </span>
+ <button
+ onClick={status === "subscribed" ? unsubscribe : subscribe}
+ disabled={working}
+ aria-pressed={status === "subscribed"}
+ className={`
+ relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200
+ focus:outline-none focus:ring-2 focus:ring-primary/50
+ disabled:opacity-50
+ ${status === "subscribed" ? "bg-primary" : "bg-border"}
+ `}
+ >
+ <span
+ className={`
+ inline-block h-4 w-4 rounded-full bg-white shadow transition-transform duration-200
+ ${status === "subscribed" ? "translate-x-6" : "translate-x-1"}
+ `}
+ />
+ </button>
+ </label>
+ )}
+ </section>
+ );
+}
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<string, string>;
+ description: Record<string, string>;
+ 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<string, string> = {
+ 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<ServiceAlertsProps> = ({ selectorFilter }) => {
+ const { t, i18n } = useTranslation();
+ const lang = i18n.language.slice(0, 2);
+
+ const { data: alerts, isLoading } = useQuery<ServiceAlert[]>({
+ 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 (
<div className="service-alerts-container stoplist-section">
<h2 className="page-subtitle">{t("stoplist.service_alerts")}</h2>
- <div className="service-alert info">
- <div className="alert-icon">ℹ️</div>
- <div className="alert-content">
- <div className="alert-title">{t("stoplist.alerts_coming_soon")}</div>
- <div className="alert-message">
- {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 (
+ <div key={alert.id} className={`service-alert ${severity}`}>
+ <div className="alert-icon">{icon}</div>
+ <div className="alert-content">
+ <div className="alert-title">{title}</div>
+ {body && <div className="alert-message">{body}</div>}
+ {alert.infoUrls.length > 0 && (
+ <div className="alert-message" style={{ marginTop: "0.25rem" }}>
+ {alert.infoUrls.map((url, i) => (
+ <a
+ key={i}
+ href={url}
+ target="_blank"
+ rel="noopener noreferrer"
+ style={{ display: "block" }}
+ >
+ {url}
+ </a>
+ ))}
+ </div>
+ )}
+ </div>
</div>
- </div>
- </div>
+ );
+ })}
</div>
);
};