Add Web Push warning notification backend

This commit is contained in:
zv
2026-06-11 19:17:13 +02:00
parent 531e678922
commit 054d75b1f4
15 changed files with 645 additions and 36 deletions

View File

@@ -8,11 +8,12 @@ import { LanguageToggle } from "@/components/ui/language-toggle";
import { ThemeToggle } from "@/components/ui/theme-toggle";
import { DEFAULT_STATION_ID } from "@/lib/constants";
import { useI18n } from "@/lib/i18n";
import { decodeBase64UrlKey, deletePushSubscription, fetchVapidPublicKey, savePushSubscription } from "@/lib/notification-api";
import { formatProvinceName, getProvinceForSelection, PROVINCES } from "@/lib/provinces";
import { useWeatherStore } from "@/lib/store";
import type { Province } from "@/types/province";
type NotificationSupportStatus = "checking" | "unsupported" | "needs-install" | "default" | "denied" | "granted";
type NotificationSupportStatus = "checking" | "unconfigured" | "unsupported" | "needs-install" | "default" | "denied" | "granted";
function isStandaloneApp() {
if (typeof window === "undefined") return false;
@@ -47,6 +48,9 @@ function SettingsRow({ icon: Icon, title, description, children }: { icon: typeo
export function SettingsPage() {
const { language, t } = useI18n();
const [notificationStatus, setNotificationStatus] = useState<NotificationSupportStatus>("checking");
const [vapidPublicKey, setVapidPublicKey] = useState<string | null>(null);
const [isSavingSubscription, setIsSavingSubscription] = useState(false);
const [notificationMessage, setNotificationMessage] = useState<string | null>(null);
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
const notificationsEnabled = useWeatherStore((state) => state.warningNotificationsEnabled);
@@ -61,12 +65,44 @@ export function SettingsPage() {
const effectiveProvinceLabel = effectiveProvince ? formatProvinceName(effectiveProvince, language) : t("settings.notifications.noProvince");
useEffect(() => {
const animationFrame = window.requestAnimationFrame(() => setNotificationStatus(getNotificationSupportStatus()));
return () => window.cancelAnimationFrame(animationFrame);
const abortController = new AbortController();
const animationFrame = window.requestAnimationFrame(() => {
setNotificationStatus(getNotificationSupportStatus());
fetchVapidPublicKey(abortController.signal)
.then((config) => {
setVapidPublicKey(config.publicKey);
if (!config.configured) setNotificationStatus("unconfigured");
})
.catch(() => setNotificationStatus("unconfigured"));
});
return () => {
abortController.abort();
window.cancelAnimationFrame(animationFrame);
};
}, []);
useEffect(() => {
if (!notificationsEnabled || !vapidPublicKey || !effectiveProvince || notificationStatus !== "granted") return;
const province = effectiveProvince;
let cancelled = false;
async function syncSubscriptionProvince() {
const registration = await navigator.serviceWorker?.getRegistration();
const subscription = await registration?.pushManager.getSubscription();
if (!subscription || cancelled) return;
await savePushSubscription(subscription, province, language).catch(() => undefined);
}
syncSubscriptionProvince();
return () => {
cancelled = true;
};
}, [effectiveProvince, language, notificationStatus, notificationsEnabled, vapidPublicKey]);
const notificationStatusLabel = useMemo(() => {
switch (notificationStatus) {
case "unconfigured":
return t("settings.notifications.statusUnconfigured");
case "unsupported":
return t("settings.notifications.statusUnsupported");
case "needs-install":
@@ -82,17 +118,65 @@ export function SettingsPage() {
}
}, [notificationStatus, t]);
const canRequestPermission = notificationStatus === "default";
const canEnablePreference = notificationStatus === "granted";
const canUsePush = Boolean(vapidPublicKey && effectiveProvince && notificationStatus !== "unsupported" && notificationStatus !== "needs-install" && notificationStatus !== "denied" && notificationStatus !== "unconfigured");
async function requestNotificationPermission() {
async function getServiceWorkerRegistration() {
if (!("serviceWorker" in navigator)) throw new Error("Service worker is not available.");
return await navigator.serviceWorker.getRegistration() ?? await navigator.serviceWorker.register("/sw.js");
}
async function enableWarningNotifications() {
if (!vapidPublicKey || !effectiveProvince) {
setNotificationMessage(t("settings.notifications.saveError"));
return;
}
if (!("Notification" in window)) {
setNotificationStatus("unsupported");
return;
}
const permission = await Notification.requestPermission();
setNotificationStatus(permission);
setNotificationsEnabled(permission === "granted");
setIsSavingSubscription(true);
setNotificationMessage(null);
try {
const permission = Notification.permission === "granted" ? "granted" : await Notification.requestPermission();
setNotificationStatus(permission);
if (permission !== "granted") {
setNotificationsEnabled(false);
return;
}
const registration = await getServiceWorkerRegistration();
const existingSubscription = await registration.pushManager.getSubscription();
const subscription = existingSubscription ?? await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: decodeBase64UrlKey(vapidPublicKey),
});
await savePushSubscription(subscription, effectiveProvince, language);
setNotificationsEnabled(true);
setNotificationMessage(t("settings.notifications.saveSuccess"));
} catch {
setNotificationsEnabled(false);
setNotificationMessage(t("settings.notifications.saveError"));
} finally {
setIsSavingSubscription(false);
}
}
async function disableWarningNotifications() {
setIsSavingSubscription(true);
setNotificationMessage(null);
try {
const registration = await navigator.serviceWorker?.getRegistration();
const subscription = await registration?.pushManager.getSubscription();
if (subscription) {
await deletePushSubscription(subscription.endpoint).catch(() => undefined);
await subscription.unsubscribe();
}
setNotificationsEnabled(false);
setNotificationMessage(t("settings.notifications.disableSuccess"));
} catch {
setNotificationMessage(t("settings.notifications.saveError"));
} finally {
setIsSavingSubscription(false);
}
}
return (
@@ -186,26 +270,20 @@ export function SettingsPage() {
</div>
</div>
<div className="mt-4 rounded-card border border-border/60 bg-surface px-3 py-3">
<label className="flex items-center justify-between gap-4 text-sm">
<span>
<span className="block font-medium">{t("settings.notifications.enable")}</span>
<span className="mt-0.5 block text-xs leading-5 text-muted">{t("settings.notifications.enableDescription")}</span>
</span>
<input
type="checkbox"
checked={notificationsEnabled}
disabled={!canEnablePreference}
onChange={(event) => setNotificationsEnabled(event.target.checked)}
className="size-5 accent-accent"
/>
</label>
<div className="mt-4 rounded-card border border-border/60 bg-surface px-3 py-3 text-sm">
<p className="font-medium">{t("settings.notifications.enable")}</p>
<p className="mt-0.5 text-xs leading-5 text-muted">{t("settings.notifications.enableDescription")}</p>
{notificationMessage && <p className="mt-3 text-xs font-medium text-accent">{notificationMessage}</p>}
</div>
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
<Button type="button" disabled={!canRequestPermission} onClick={requestNotificationPermission}>
<Button type="button" disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription} onClick={notificationsEnabled ? disableWarningNotifications : enableWarningNotifications}>
<Bell className="size-4" />
{t("settings.notifications.requestPermission")}
{isSavingSubscription
? t("settings.notifications.saving")
: notificationsEnabled
? t("settings.notifications.disable")
: t("settings.notifications.enableDevice")}
</Button>
</div>