From 1b4f4a674ac533c0b51260ba35ab91dd2cf9486d Mon Sep 17 00:00:00 2001
From: Ariel Costas Guerrero <ariel@costas.dev>
Date: Thu, 2 Apr 2026 12:38:10 +0200
Subject: Basic push notification system for service alerts

Co-authored-by: Copilot <copilot@github.com>
---
 src/frontend/app/components/ServiceAlerts.tsx | 115 +++++++++++++++++++++++---
 1 file changed, 105 insertions(+), 10 deletions(-)

(limited to 'src/frontend/app/components/ServiceAlerts.tsx')

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

