From 531e67892257644e929a01a5e13a78c1b223dd8a Mon Sep 17 00:00:00 2001 From: zv Date: Thu, 11 Jun 2026 18:39:01 +0200 Subject: [PATCH] Add settings page for alert preferences --- README.md | 3 + app/settings/page.tsx | 8 + components/layout/app-shell.tsx | 12 +- components/settings/settings-page.tsx | 218 ++++++++++++++++++++++++++ lib/constants.ts | 1 + lib/i18n.tsx | 52 ++++++ lib/provinces.ts | 2 + lib/store.ts | 15 ++ public/sw.js | 43 ++++- 9 files changed, 344 insertions(+), 10 deletions(-) create mode 100644 app/settings/page.tsx create mode 100644 components/settings/settings-page.tsx diff --git a/README.md b/README.md index 016314f..1e1d1bc 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ components/dashboard dashboard aplikacji components/weather/ hero, stacje, metryki i szczegóły components/warnings/ alerty meteo i hydro components/hydro/ lista stacji hydrologicznych +components/settings/ ustawienia języka, motywu i preferencji alertów components/ui/ bazowe komponenty interfejsu components/states/ loading, empty i error states hooks/ zapytania TanStack Query @@ -127,6 +128,8 @@ public/ manifest, ikony i service worker Manifest znajduje się w `public/manifest.json`, a service worker w `public/sw.js`. Rejestracja service workera działa w buildzie produkcyjnym. Powłoka aplikacji ma podstawowy offline fallback. Odpowiedzi API mogą być dostępne z pamięci urządzenia przy braku sieci, ale UI nadal prezentuje czas pomiaru i status świeżości. +Widok `/settings` zawiera pierwszy etap konfiguracji powiadomień o ostrzeżeniach meteorologicznych IMGW: wybór województwa, preferencję alertów i sprawdzenie zgody Web Push na urządzeniu. Na iOS powiadomienia webowe wymagają dodania PWA do ekranu początkowego i uruchomienia aplikacji z ikony. Realna wysyłka powiadomień wymaga jeszcze backendowego zapisu subskrypcji Web Push, kluczy VAPID oraz cyklicznego sprawdzania endpointu IMGW `warningsmeteo`; obecny UI nie wysyła jeszcze alertów. + ## Wdrożenie na Vercel 1. Umieść repozytorium w serwisie Git. diff --git a/app/settings/page.tsx b/app/settings/page.tsx new file mode 100644 index 0000000..5b99841 --- /dev/null +++ b/app/settings/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { SettingsPage } from "@/components/settings/settings-page"; + +export const metadata: Metadata = { title: "Ustawienia / Settings" }; + +export default function Page() { + return ; +} diff --git a/components/layout/app-shell.tsx b/components/layout/app-shell.tsx index 2f6cdb3..9ff6aa5 100644 --- a/components/layout/app-shell.tsx +++ b/components/layout/app-shell.tsx @@ -2,15 +2,13 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { CloudSun, Droplets, TriangleAlert } from "lucide-react"; +import { CloudSun, Droplets, Settings, TriangleAlert } from "lucide-react"; import { NAV_ITEMS } from "@/lib/constants"; import { cn } from "@/lib/utils"; import { InstallPWAButton } from "@/components/ui/install-pwa-button"; -import { ThemeToggle } from "@/components/ui/theme-toggle"; -import { LanguageToggle } from "@/components/ui/language-toggle"; import { useI18n } from "@/lib/i18n"; -const icons = [CloudSun, TriangleAlert, Droplets]; +const icons = [CloudSun, TriangleAlert, Droplets, Settings]; export function AppShell({ children }: { children: React.ReactNode }) { const pathname = usePathname(); @@ -34,8 +32,6 @@ export function AppShell({ children }: { children: React.ReactNode }) {
- -
@@ -45,9 +41,9 @@ export function AppShell({ children }: { children: React.ReactNode }) { const Icon = icons[index]; const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href); return ( - + - {t(item.labelKey)} + {t(item.labelKey)} ); })} diff --git a/components/settings/settings-page.tsx b/components/settings/settings-page.tsx new file mode 100644 index 0000000..b71e23f --- /dev/null +++ b/components/settings/settings-page.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Bell, BellRing, Languages, MapPinned, Palette, Settings, Smartphone } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +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 { 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"; + +function isStandaloneApp() { + if (typeof window === "undefined") return false; + const navigatorWithStandalone = window.navigator as Navigator & { standalone?: boolean }; + return window.matchMedia("(display-mode: standalone)").matches || navigatorWithStandalone.standalone === true; +} + +function getNotificationSupportStatus(): NotificationSupportStatus { + if (typeof window === "undefined") return "checking"; + if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window)) return "unsupported"; + if (!isStandaloneApp()) return "needs-install"; + return Notification.permission; +} + +function SettingsRow({ icon: Icon, title, description, children }: { icon: typeof Settings; title: string; description: string; children: React.ReactNode }) { + return ( +
+
+
+
+
+

{title}

+

{description}

+
+
+
{children}
+
+ ); +} + +export function SettingsPage() { + const { language, t } = useI18n(); + const [notificationStatus, setNotificationStatus] = useState("checking"); + const selectedStationId = useWeatherStore((state) => state.selectedStationId); + const selectedLocation = useWeatherStore((state) => state.selectedLocation); + const notificationsEnabled = useWeatherStore((state) => state.warningNotificationsEnabled); + const provinceMode = useWeatherStore((state) => state.warningNotificationProvinceMode); + const manualProvince = useWeatherStore((state) => state.warningNotificationProvince); + const setNotificationsEnabled = useWeatherStore((state) => state.setWarningNotificationsEnabled); + const setProvinceMode = useWeatherStore((state) => state.setWarningNotificationProvinceMode); + const setManualProvince = useWeatherStore((state) => state.setWarningNotificationProvince); + + const selectedProvince = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID); + const effectiveProvince = provinceMode === "manual" ? manualProvince : selectedProvince; + const effectiveProvinceLabel = effectiveProvince ? formatProvinceName(effectiveProvince, language) : t("settings.notifications.noProvince"); + + useEffect(() => { + const animationFrame = window.requestAnimationFrame(() => setNotificationStatus(getNotificationSupportStatus())); + return () => window.cancelAnimationFrame(animationFrame); + }, []); + + const notificationStatusLabel = useMemo(() => { + switch (notificationStatus) { + case "unsupported": + return t("settings.notifications.statusUnsupported"); + case "needs-install": + return t("settings.notifications.statusNeedsInstall"); + case "denied": + return t("settings.notifications.statusDenied"); + case "granted": + return t("settings.notifications.statusGranted"); + case "default": + return t("settings.notifications.statusReady"); + default: + return t("settings.notifications.statusChecking"); + } + }, [notificationStatus, t]); + + const canRequestPermission = notificationStatus === "default"; + const canEnablePreference = notificationStatus === "granted"; + + async function requestNotificationPermission() { + if (!("Notification" in window)) { + setNotificationStatus("unsupported"); + return; + } + const permission = await Notification.requestPermission(); + setNotificationStatus(permission); + setNotificationsEnabled(permission === "granted"); + } + + return ( +
+
+

{t("settings.section")}

+

{t("settings.title")}

+

{t("settings.description")}

+
+ + + + + + + + + + + +
+
+
+
+

IMGW

+

{t("settings.notifications.title")}

+

{t("settings.notifications.description")}

+
+
+ +
+
+
+
+ +
+ + +
+
+ +
+
+
+ +
+ +
+ +
+ +
+ +

{t("settings.notifications.implementationNote")}

+
+
+
+
+ ); +} diff --git a/lib/constants.ts b/lib/constants.ts index 50e1612..05fcaad 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -10,4 +10,5 @@ export const NAV_ITEMS = [ { href: "/", labelKey: "nav.weather" }, { href: "/warnings", labelKey: "nav.warnings" }, { href: "/hydro", labelKey: "nav.hydro" }, + { href: "/settings", labelKey: "nav.settings" }, ] as const; diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 269353a..c6324d4 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -10,6 +10,7 @@ const translations = { "nav.weather": "Pogoda", "nav.warnings": "Ostrzeżenia", "nav.hydro": "Hydro", + "nav.settings": "Ustawienia", "nav.main": "Główna nawigacja", "nav.mobile": "Mobilna nawigacja", "language.label": "Wybierz język", @@ -18,6 +19,31 @@ const translations = { "theme.change": "Zmień motyw", "theme.light": "Włącz jasny motyw", "theme.dark": "Włącz ciemny motyw", + "settings.section": "Preferencje", + "settings.title": "Ustawienia", + "settings.description": "Zarządzaj językiem, wyglądem i przygotowaniem powiadomień o ostrzeżeniach meteorologicznych IMGW.", + "settings.language.title": "Język", + "settings.language.description": "Zmieniaj język interfejsu. Nazwy stacji i treści IMGW pozostają bez automatycznego tłumaczenia.", + "settings.theme.title": "Motyw", + "settings.theme.description": "Przełącz jasny lub ciemny wygląd aplikacji na tym urządzeniu.", + "settings.notifications.title": "Powiadomienia o ostrzeżeniach meteo", + "settings.notifications.description": "Przygotuj alerty dla nowych ostrzeżeń meteorologicznych IMGW, takich jak burze, upał, silny wiatr lub intensywny deszcz.", + "settings.notifications.regionTitle": "Obszar alertów", + "settings.notifications.regionDescription": "Aktualnie wybrany obszar dla przyszłych powiadomień: {province}.", + "settings.notifications.regionSelected": "Używaj lokalizacji wybranej w aplikacji", + "settings.notifications.regionManual": "Wybierz województwo ręcznie", + "settings.notifications.noProvince": "brak wybranego województwa", + "settings.notifications.deviceTitle": "To urządzenie", + "settings.notifications.enable": "Alerty IMGW", + "settings.notifications.enableDescription": "Preferencja zostanie użyta po dodaniu backendowej subskrypcji Web Push.", + "settings.notifications.requestPermission": "Sprawdź zgodę", + "settings.notifications.statusChecking": "Sprawdzam obsługę powiadomień w tej przeglądarce.", + "settings.notifications.statusUnsupported": "Ta przeglądarka nie udostępnia Web Push dla tej aplikacji.", + "settings.notifications.statusNeedsInstall": "Na iPhonie dodaj aplikację do ekranu początkowego przez Udostępnij → Dodaj do ekranu początkowego, a potem otwórz ją z ikony.", + "settings.notifications.statusDenied": "Powiadomienia są zablokowane w ustawieniach systemu lub przeglądarki.", + "settings.notifications.statusGranted": "To urządzenie ma zgodę na powiadomienia.", + "settings.notifications.statusReady": "Możesz poprosić system o zgodę na powiadomienia.", + "settings.notifications.implementationNote": "To pierwszy etap konfiguracji. Wysyłka po nowych ostrzeżeniach IMGW wymaga jeszcze zapisania subskrypcji Web Push i cyklicznego sprawdzania API IMGW po stronie serwera.", "pwa.install": "Zainstaluj", "common.noData": "Brak danych", "common.loading": "Ładowanie danych", @@ -196,6 +222,7 @@ const translations = { "nav.weather": "Weather", "nav.warnings": "Warnings", "nav.hydro": "Hydro", + "nav.settings": "Settings", "nav.main": "Main navigation", "nav.mobile": "Mobile navigation", "language.label": "Select language", @@ -204,6 +231,31 @@ const translations = { "theme.change": "Change theme", "theme.light": "Enable light theme", "theme.dark": "Enable dark theme", + "settings.section": "Preferences", + "settings.title": "Settings", + "settings.description": "Manage language, appearance and preparation for IMGW meteorological warning notifications.", + "settings.language.title": "Language", + "settings.language.description": "Change the interface language. Station names and IMGW content are not translated automatically.", + "settings.theme.title": "Theme", + "settings.theme.description": "Switch the light or dark appearance on this device.", + "settings.notifications.title": "Weather warning notifications", + "settings.notifications.description": "Prepare alerts for new IMGW meteorological warnings, such as thunderstorms, heat, strong wind or heavy rain.", + "settings.notifications.regionTitle": "Alert area", + "settings.notifications.regionDescription": "Current area for future notifications: {province}.", + "settings.notifications.regionSelected": "Use the location selected in the app", + "settings.notifications.regionManual": "Choose province manually", + "settings.notifications.noProvince": "no province selected", + "settings.notifications.deviceTitle": "This device", + "settings.notifications.enable": "IMGW alerts", + "settings.notifications.enableDescription": "This preference will be used after the Web Push backend subscription is added.", + "settings.notifications.requestPermission": "Check permission", + "settings.notifications.statusChecking": "Checking notification support in this browser.", + "settings.notifications.statusUnsupported": "This browser does not provide Web Push for this app.", + "settings.notifications.statusNeedsInstall": "On iPhone, add the app to the Home Screen using Share → Add to Home Screen, then open it from the icon.", + "settings.notifications.statusDenied": "Notifications are blocked in system or browser settings.", + "settings.notifications.statusGranted": "This device has notification permission.", + "settings.notifications.statusReady": "You can ask the system for notification permission.", + "settings.notifications.implementationNote": "This is the first configuration stage. Sending alerts for new IMGW warnings still requires saving a Web Push subscription and polling the IMGW API on the server.", "pwa.install": "Install", "common.noData": "No data", "common.loading": "Loading data", diff --git a/lib/provinces.ts b/lib/provinces.ts index ba78964..8989ffd 100644 --- a/lib/provinces.ts +++ b/lib/provinces.ts @@ -103,6 +103,8 @@ const provinceLabels: Record> = { "zachodniopomorskie": { pl: "zachodniopomorskie", en: "West Pomeranian" }, }; +export const PROVINCES = Object.keys(provinceLabels) as Province[]; + function simplifyProvinceName(value: string) { return value .normalize("NFD") diff --git a/lib/store.ts b/lib/store.ts index 106997d..a0048a2 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -3,14 +3,23 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import type { SelectedLocation } from "@/types/location"; +import type { Province } from "@/types/province"; + +type NotificationProvinceMode = "selected" | "manual"; interface WeatherStore { favorites: string[]; selectedStationId: string | null; selectedLocation: SelectedLocation | null; + warningNotificationsEnabled: boolean; + warningNotificationProvinceMode: NotificationProvinceMode; + warningNotificationProvince: Province | null; toggleFavorite: (id: string) => void; selectStation: (id: string) => void; selectLocation: (location: SelectedLocation) => void; + setWarningNotificationsEnabled: (enabled: boolean) => void; + setWarningNotificationProvinceMode: (mode: NotificationProvinceMode) => void; + setWarningNotificationProvince: (province: Province | null) => void; } export const useWeatherStore = create()( @@ -19,6 +28,9 @@ export const useWeatherStore = create()( favorites: [], selectedStationId: null, selectedLocation: null, + warningNotificationsEnabled: false, + warningNotificationProvinceMode: "selected", + warningNotificationProvince: null, toggleFavorite: (id) => set((state) => ({ favorites: state.favorites.includes(id) @@ -27,6 +39,9 @@ export const useWeatherStore = create()( })), selectStation: (id) => set({ selectedStationId: id, selectedLocation: null }), selectLocation: (location) => set({ selectedStationId: location.stationId, selectedLocation: location }), + setWarningNotificationsEnabled: (enabled) => set({ warningNotificationsEnabled: enabled }), + setWarningNotificationProvinceMode: (mode) => set({ warningNotificationProvinceMode: mode }), + setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }), }), { name: "wtr:preferences" }, ), diff --git a/public/sw.js b/public/sw.js index 948493c..e54f647 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ -const CACHE_NAME = "wtr-shell-v3"; -const SHELL = ["/", "/offline", "/manifest.json", "/icons/icon.svg", "/icons/maskable.svg", "/icons/icon-192.png", "/icons/icon-512.png", "/icons/maskable-512.png"]; +const CACHE_NAME = "wtr-shell-v4"; +const SHELL = ["/", "/settings", "/offline", "/manifest.json", "/icons/icon.svg", "/icons/maskable.svg", "/icons/icon-192.png", "/icons/icon-512.png", "/icons/maskable-512.png"]; self.addEventListener("install", (event) => { event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL))); @@ -32,3 +32,42 @@ self.addEventListener("fetch", (event) => { event.respondWith(fetch(event.request).catch(() => caches.match(event.request).then((response) => response || caches.match("/offline")))); } }); + +self.addEventListener("push", (event) => { + const fallbackPayload = { + title: "IMGW", + body: "Nowe ostrzeżenie meteorologiczne.", + url: "/warnings", + }; + let payload = fallbackPayload; + if (event.data) { + const text = event.data.text(); + try { + payload = { ...fallbackPayload, ...JSON.parse(text) }; + } catch { + payload = { ...fallbackPayload, body: text }; + } + } + + event.waitUntil( + self.registration.showNotification(payload.title, { + body: payload.body, + icon: "/icons/icon-192.png", + badge: "/icons/icon-192.png", + data: { url: payload.url || "/warnings" }, + }), + ); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const targetUrl = new URL(event.notification.data?.url || "/warnings", self.location.origin).href; + + event.waitUntil( + self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => { + const existingClient = clients.find((client) => client.url === targetUrl); + if (existingClient) return existingClient.focus(); + return self.clients.openWindow(targetUrl); + }), + ); +});