All checks were successful
CI / Lint, test, typecheck and build (push) Successful in 10m45s
811 lines
34 KiB
TypeScript
811 lines
34 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import {
|
|
Bell,
|
|
BellRing,
|
|
ChevronDown,
|
|
Clock3,
|
|
Languages,
|
|
LayoutDashboard,
|
|
MapPinned,
|
|
Palette,
|
|
Settings,
|
|
Smartphone,
|
|
Thermometer,
|
|
Wind,
|
|
} from "lucide-react";
|
|
import type { LucideIcon } 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 { useMeteoStationPositions } from "@/hooks/use-meteo-stations";
|
|
import { useWeatherStations } from "@/hooks/use-weather-stations";
|
|
import { APP_SECTION_SETTINGS } from "@/lib/app-sections";
|
|
import { DEFAULT_STATION_ID } from "@/lib/constants";
|
|
import { DASHBOARD_SECTION_SETTINGS } from "@/lib/dashboard-sections";
|
|
import { useI18n } from "@/lib/i18n";
|
|
import { locateSynopStations } from "@/lib/location-utils";
|
|
import {
|
|
decodeBase64UrlKey,
|
|
deletePushSubscription,
|
|
fetchVapidPublicKey,
|
|
savePushSubscription,
|
|
sendTestPushNotification,
|
|
} from "@/lib/notification-api";
|
|
import { formatProvinceName, getProvinceForSelection, PROVINCES } from "@/lib/provinces";
|
|
import { useWeatherStore } from "@/lib/store";
|
|
import { TEMPERATURE_UNITS, WIND_SPEED_UNITS } from "@/lib/weather-utils";
|
|
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
|
|
|
type NotificationSupportStatus =
|
|
| "checking"
|
|
| "unconfigured"
|
|
| "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 isAppleMobilePlatform() {
|
|
if (typeof window === "undefined") return false;
|
|
const userAgent = window.navigator.userAgent;
|
|
const platform = window.navigator.platform;
|
|
const isIPadOSDesktopMode = platform === "MacIntel" && window.navigator.maxTouchPoints > 1;
|
|
return /iPad|iPhone|iPod/.test(userAgent) || isIPadOSDesktopMode;
|
|
}
|
|
|
|
function getNotificationSupportStatus(): NotificationSupportStatus {
|
|
if (typeof window === "undefined") return "checking";
|
|
if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window))
|
|
return "unsupported";
|
|
if (isAppleMobilePlatform() && !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 (
|
|
<div className="flex flex-col gap-4 border-t border-border/65 py-4 first:border-t-0 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex items-start gap-3">
|
|
<div className="rounded-control border border-border/70 bg-surface-muted/70 p-2 text-accent">
|
|
<Icon className="size-4" aria-hidden="true" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-sm font-semibold">{title}</h2>
|
|
<p className="mt-1 max-w-xl text-sm leading-6 text-muted">{description}</p>
|
|
</div>
|
|
</div>
|
|
<div className="shrink-0">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SwitchIndicator({ checked, disabled }: { checked: boolean; disabled?: boolean }) {
|
|
return (
|
|
<span
|
|
className={`relative inline-flex h-7 w-12 shrink-0 items-center rounded-control border px-0.5 transition-colors ${
|
|
checked ? "border-accent/55 bg-accent" : "border-border/70 bg-surface-muted"
|
|
} ${disabled ? "opacity-55" : ""}`}
|
|
aria-hidden="true"
|
|
>
|
|
<span
|
|
className={`size-5 rounded-full bg-surface-raised shadow-soft transition-transform ${checked ? "translate-x-5" : "translate-x-0"}`}
|
|
/>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function NotificationSwitchLabel({
|
|
icon: Icon,
|
|
title,
|
|
description,
|
|
checked,
|
|
disabled,
|
|
onChange,
|
|
}: {
|
|
icon: LucideIcon;
|
|
title: string;
|
|
description: string;
|
|
checked: boolean;
|
|
disabled?: boolean;
|
|
onChange: (checked: boolean) => void;
|
|
}) {
|
|
return (
|
|
<label
|
|
className={`mt-3 flex cursor-pointer items-start justify-between gap-4 rounded-card border px-3 py-3 text-sm transition-colors ${
|
|
checked ? "border-accent/45 bg-accent/10" : "border-border/60 bg-surface"
|
|
} ${disabled ? "cursor-not-allowed opacity-65" : "hover:border-accent/45"}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
disabled={disabled}
|
|
onChange={(event) => onChange(event.target.checked)}
|
|
className="sr-only"
|
|
/>
|
|
<span className="min-w-0">
|
|
<span className="flex items-center gap-2 font-medium">
|
|
<Icon className="size-3.5 text-accent" aria-hidden="true" />
|
|
{title}
|
|
</span>
|
|
<span className="mt-0.5 block text-xs leading-5 text-muted">{description}</span>
|
|
</span>
|
|
<SwitchIndicator checked={checked} disabled={disabled} />
|
|
</label>
|
|
);
|
|
}
|
|
|
|
const windSpeedUnitKeys: Record<
|
|
WindSpeedUnit,
|
|
"settings.units.wind.ms" | "settings.units.wind.kmh" | "settings.units.wind.mph"
|
|
> = {
|
|
ms: "settings.units.wind.ms",
|
|
kmh: "settings.units.wind.kmh",
|
|
mph: "settings.units.wind.mph",
|
|
};
|
|
|
|
const temperatureUnitKeys: Record<TemperatureUnit, "settings.units.temperature.c" | "settings.units.temperature.f"> = {
|
|
c: "settings.units.temperature.c",
|
|
f: "settings.units.temperature.f",
|
|
};
|
|
|
|
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 [isSendingTestNotification, setIsSendingTestNotification] = useState(false);
|
|
const [notificationMessage, setNotificationMessage] = useState<string | null>(null);
|
|
const [provinceDropdownOpen, setProvinceDropdownOpen] = useState(false);
|
|
const { data: stations } = useWeatherStations();
|
|
const { data: positions = [] } = useMeteoStationPositions();
|
|
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
|
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
|
const notificationsEnabled = useWeatherStore((state) => state.warningNotificationsEnabled);
|
|
const morningBriefEnabled = useWeatherStore((state) => state.morningBriefNotificationsEnabled);
|
|
const tomorrowBriefEnabled = useWeatherStore((state) => state.tomorrowBriefNotificationsEnabled);
|
|
const provinceMode = useWeatherStore((state) => state.warningNotificationProvinceMode);
|
|
const manualProvince = useWeatherStore((state) => state.warningNotificationProvince);
|
|
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
|
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
|
const dashboardSections = useWeatherStore((state) => state.dashboardSections);
|
|
const appSections = useWeatherStore((state) => state.appSections);
|
|
const setNotificationsEnabled = useWeatherStore((state) => state.setWarningNotificationsEnabled);
|
|
const setMorningBriefEnabled = useWeatherStore((state) => state.setMorningBriefNotificationsEnabled);
|
|
const setTomorrowBriefEnabled = useWeatherStore((state) => state.setTomorrowBriefNotificationsEnabled);
|
|
const setProvinceMode = useWeatherStore((state) => state.setWarningNotificationProvinceMode);
|
|
const setManualProvince = useWeatherStore((state) => state.setWarningNotificationProvince);
|
|
const setTemperatureUnit = useWeatherStore((state) => state.setTemperatureUnit);
|
|
const setWindSpeedUnit = useWeatherStore((state) => state.setWindSpeedUnit);
|
|
const setDashboardSectionVisible = useWeatherStore((state) => state.setDashboardSectionVisible);
|
|
const setAppSectionVisible = useWeatherStore((state) => state.setAppSectionVisible);
|
|
|
|
const selectedStation =
|
|
stations?.find((station) => station.id === selectedStationId) ??
|
|
stations?.find((station) => station.id === DEFAULT_STATION_ID) ??
|
|
stations?.[0];
|
|
const activeLocation = selectedLocation;
|
|
const selectedRegion = activeLocation?.region ?? "PL";
|
|
const stationPosition = selectedStation
|
|
? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id)
|
|
: null;
|
|
const notificationLatitude = Number.isFinite(activeLocation?.latitude)
|
|
? activeLocation?.latitude
|
|
: (stationPosition?.latitude ?? null);
|
|
const notificationLongitude = Number.isFinite(activeLocation?.longitude)
|
|
? activeLocation?.longitude
|
|
: (stationPosition?.longitude ?? null);
|
|
const notificationLocationName = activeLocation?.name ?? selectedStation?.name ?? null;
|
|
const notificationTimezone = activeLocation?.timezone ?? (selectedRegion === "PL" ? "Europe/Warsaw" : null);
|
|
const notificationCountyTeryt = selectedRegion === "PL" ? (activeLocation?.countyTeryt ?? null) : null;
|
|
const selectedProvince =
|
|
selectedRegion === "PL"
|
|
? getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID)
|
|
: null;
|
|
const effectiveProvince =
|
|
selectedRegion === "PL" ? (provinceMode === "manual" ? manualProvince : selectedProvince) : null;
|
|
const canUseOfficialWarnings = selectedRegion === "PL" && Boolean(effectiveProvince);
|
|
const effectiveProvinceLabel =
|
|
selectedRegion === "GLOBAL"
|
|
? t("settings.notifications.globalRegion")
|
|
: effectiveProvince
|
|
? formatProvinceName(effectiveProvince, language)
|
|
: t("settings.notifications.noProvince");
|
|
const manualProvinceValue = manualProvince ?? selectedProvince ?? "mazowieckie";
|
|
|
|
useEffect(() => {
|
|
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 || 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, {
|
|
region: selectedRegion,
|
|
officialWarningsEnabled: canUseOfficialWarnings && notificationsEnabled,
|
|
morningBriefEnabled,
|
|
tomorrowBriefEnabled,
|
|
latitude: notificationLatitude,
|
|
longitude: notificationLongitude,
|
|
locationName: notificationLocationName,
|
|
timezone: notificationTimezone,
|
|
countyTeryt: notificationCountyTeryt,
|
|
temperatureUnit,
|
|
windSpeedUnit,
|
|
}).catch(() => undefined);
|
|
}
|
|
|
|
syncSubscriptionProvince();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [
|
|
canUseOfficialWarnings,
|
|
effectiveProvince,
|
|
language,
|
|
morningBriefEnabled,
|
|
notificationCountyTeryt,
|
|
notificationLatitude,
|
|
notificationLocationName,
|
|
notificationLongitude,
|
|
notificationStatus,
|
|
notificationsEnabled,
|
|
notificationTimezone,
|
|
selectedRegion,
|
|
temperatureUnit,
|
|
tomorrowBriefEnabled,
|
|
vapidPublicKey,
|
|
windSpeedUnit,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!provinceDropdownOpen) return;
|
|
|
|
function handleKeyDown(event: KeyboardEvent) {
|
|
if (event.key === "Escape") setProvinceDropdownOpen(false);
|
|
}
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [provinceDropdownOpen]);
|
|
|
|
const notificationStatusLabel = useMemo(() => {
|
|
switch (notificationStatus) {
|
|
case "unconfigured":
|
|
return t("settings.notifications.statusUnconfigured");
|
|
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 canUsePush = Boolean(
|
|
vapidPublicKey &&
|
|
(selectedRegion === "GLOBAL" || effectiveProvince) &&
|
|
notificationStatus !== "unsupported" &&
|
|
notificationStatus !== "needs-install" &&
|
|
notificationStatus !== "denied" &&
|
|
notificationStatus !== "unconfigured",
|
|
);
|
|
|
|
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 || (selectedRegion === "PL" && !effectiveProvince)) {
|
|
setNotificationMessage(t("settings.notifications.saveError"));
|
|
return;
|
|
}
|
|
if (!("Notification" in window)) {
|
|
setNotificationStatus("unsupported");
|
|
return;
|
|
}
|
|
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, {
|
|
region: selectedRegion,
|
|
officialWarningsEnabled: canUseOfficialWarnings,
|
|
morningBriefEnabled,
|
|
tomorrowBriefEnabled,
|
|
latitude: notificationLatitude,
|
|
longitude: notificationLongitude,
|
|
locationName: notificationLocationName,
|
|
timezone: notificationTimezone,
|
|
countyTeryt: notificationCountyTeryt,
|
|
temperatureUnit,
|
|
windSpeedUnit,
|
|
});
|
|
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);
|
|
setMorningBriefEnabled(false);
|
|
setTomorrowBriefEnabled(false);
|
|
setNotificationMessage(t("settings.notifications.disableSuccess"));
|
|
} catch {
|
|
setNotificationMessage(t("settings.notifications.saveError"));
|
|
} finally {
|
|
setIsSavingSubscription(false);
|
|
}
|
|
}
|
|
|
|
async function sendTestNotification() {
|
|
setIsSendingTestNotification(true);
|
|
setNotificationMessage(null);
|
|
try {
|
|
const registration = await navigator.serviceWorker?.getRegistration();
|
|
const subscription = await registration?.pushManager.getSubscription();
|
|
if (!subscription) throw new Error("Push subscription is not available.");
|
|
await sendTestPushNotification(subscription.endpoint);
|
|
setNotificationMessage(t("settings.notifications.testSuccess"));
|
|
} catch {
|
|
setNotificationMessage(t("settings.notifications.testError"));
|
|
} finally {
|
|
setIsSendingTestNotification(false);
|
|
}
|
|
}
|
|
|
|
async function updateMorningBriefPreference(enabled: boolean) {
|
|
setMorningBriefEnabled(enabled);
|
|
if (!notificationsEnabled || !canUsePush || notificationStatus !== "granted") return;
|
|
try {
|
|
const preferences = useWeatherStore.getState();
|
|
const registration = await navigator.serviceWorker?.getRegistration();
|
|
const subscription = await registration?.pushManager.getSubscription();
|
|
if (!subscription) return;
|
|
await savePushSubscription(subscription, effectiveProvince, language, {
|
|
region: selectedRegion,
|
|
officialWarningsEnabled: canUseOfficialWarnings && notificationsEnabled,
|
|
morningBriefEnabled: preferences.morningBriefNotificationsEnabled,
|
|
tomorrowBriefEnabled: preferences.tomorrowBriefNotificationsEnabled,
|
|
latitude: notificationLatitude,
|
|
longitude: notificationLongitude,
|
|
locationName: notificationLocationName,
|
|
timezone: notificationTimezone,
|
|
countyTeryt: notificationCountyTeryt,
|
|
temperatureUnit,
|
|
windSpeedUnit,
|
|
});
|
|
} catch {
|
|
setNotificationMessage(t("settings.notifications.saveError"));
|
|
}
|
|
}
|
|
|
|
async function updateTomorrowBriefPreference(enabled: boolean) {
|
|
setTomorrowBriefEnabled(enabled);
|
|
if (!notificationsEnabled || !canUsePush || notificationStatus !== "granted") return;
|
|
try {
|
|
const preferences = useWeatherStore.getState();
|
|
const registration = await navigator.serviceWorker?.getRegistration();
|
|
const subscription = await registration?.pushManager.getSubscription();
|
|
if (!subscription) return;
|
|
await savePushSubscription(subscription, effectiveProvince, language, {
|
|
region: selectedRegion,
|
|
officialWarningsEnabled: canUseOfficialWarnings && notificationsEnabled,
|
|
morningBriefEnabled: preferences.morningBriefNotificationsEnabled,
|
|
tomorrowBriefEnabled: preferences.tomorrowBriefNotificationsEnabled,
|
|
latitude: notificationLatitude,
|
|
longitude: notificationLongitude,
|
|
locationName: notificationLocationName,
|
|
timezone: notificationTimezone,
|
|
countyTeryt: notificationCountyTeryt,
|
|
temperatureUnit,
|
|
windSpeedUnit,
|
|
});
|
|
} catch {
|
|
setNotificationMessage(t("settings.notifications.saveError"));
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<section>
|
|
<p className="section-kicker flex items-center gap-2">
|
|
<Settings className="size-4" />
|
|
{t("settings.section")}
|
|
</p>
|
|
<h1 className="mt-2 text-3xl font-semibold tracking-tight">{t("settings.title")}</h1>
|
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted">{t("settings.description")}</p>
|
|
</section>
|
|
|
|
<Card className="p-4 sm:p-5">
|
|
<SettingsRow
|
|
icon={Languages}
|
|
title={t("settings.language.title")}
|
|
description={t("settings.language.description")}
|
|
>
|
|
<LanguageToggle />
|
|
</SettingsRow>
|
|
<SettingsRow icon={Palette} title={t("settings.theme.title")} description={t("settings.theme.description")}>
|
|
<ThemeToggle />
|
|
</SettingsRow>
|
|
<SettingsRow
|
|
icon={Thermometer}
|
|
title={t("settings.units.temperature.title")}
|
|
description={t("settings.units.temperature.description")}
|
|
>
|
|
<div
|
|
className="surface-control inline-flex rounded-full p-1"
|
|
role="radiogroup"
|
|
aria-label={t("settings.units.temperature.label")}
|
|
>
|
|
{TEMPERATURE_UNITS.map((unit) => (
|
|
<button
|
|
key={unit}
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={temperatureUnit === unit}
|
|
className={`min-w-14 rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${
|
|
temperatureUnit === unit
|
|
? "border-accent/45 bg-accent/10 text-accent shadow-soft"
|
|
: "border-transparent text-muted hover:bg-surface-muted/70 hover:text-foreground"
|
|
}`}
|
|
onClick={() => setTemperatureUnit(unit)}
|
|
>
|
|
{t(temperatureUnitKeys[unit])}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</SettingsRow>
|
|
<SettingsRow
|
|
icon={Wind}
|
|
title={t("settings.units.wind.title")}
|
|
description={t("settings.units.wind.description")}
|
|
>
|
|
<div
|
|
className="surface-control inline-flex rounded-full p-1"
|
|
role="radiogroup"
|
|
aria-label={t("settings.units.wind.label")}
|
|
>
|
|
{WIND_SPEED_UNITS.map((unit) => (
|
|
<button
|
|
key={unit}
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={windSpeedUnit === unit}
|
|
className={`min-w-14 rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${
|
|
windSpeedUnit === unit
|
|
? "border-accent/45 bg-accent/10 text-accent shadow-soft"
|
|
: "border-transparent text-muted hover:bg-surface-muted/70 hover:text-foreground"
|
|
}`}
|
|
onClick={() => setWindSpeedUnit(unit)}
|
|
>
|
|
{t(windSpeedUnitKeys[unit])}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</SettingsRow>
|
|
</Card>
|
|
|
|
<Card className="p-4 sm:p-5">
|
|
<div className="flex items-start gap-3">
|
|
<div className="rounded-control border border-border/70 bg-surface-muted/70 p-2 text-accent">
|
|
<LayoutDashboard className="size-4" aria-hidden="true" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-sm font-semibold">{t("settings.dashboardSections.title")}</h2>
|
|
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{t("settings.dashboardSections.description")}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-2">
|
|
{DASHBOARD_SECTION_SETTINGS.map((section) => (
|
|
<NotificationSwitchLabel
|
|
key={section.id}
|
|
icon={LayoutDashboard}
|
|
title={t(section.titleKey)}
|
|
description={t(section.descriptionKey)}
|
|
checked={dashboardSections[section.id]}
|
|
onChange={(checked) => setDashboardSectionVisible(section.id, checked)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-4 sm:p-5">
|
|
<div className="flex items-start gap-3">
|
|
<div className="rounded-control border border-border/70 bg-surface-muted/70 p-2 text-accent">
|
|
<Settings className="size-4" aria-hidden="true" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-sm font-semibold">{t("settings.appSections.title")}</h2>
|
|
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{t("settings.appSections.description")}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-2">
|
|
{APP_SECTION_SETTINGS.map((section) => (
|
|
<NotificationSwitchLabel
|
|
key={section.id}
|
|
icon={Settings}
|
|
title={t(section.titleKey)}
|
|
description={t(section.descriptionKey)}
|
|
checked={appSections[section.id]}
|
|
onChange={(checked) => setAppSectionVisible(section.id, checked)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-4 sm:p-5">
|
|
<div className="flex items-start gap-3">
|
|
<div className="rounded-control border border-warning/30 bg-warning/10 p-2 text-warning">
|
|
<BellRing className="size-4" aria-hidden="true" />
|
|
</div>
|
|
<div>
|
|
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-warning">IMGW</p>
|
|
<h2 className="mt-1 text-lg font-semibold tracking-tight">{t("settings.notifications.title")}</h2>
|
|
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{t("settings.notifications.description")}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-5 grid gap-3 lg:grid-cols-2">
|
|
<div className="rounded-card border border-border/65 bg-surface-muted/45 p-4">
|
|
<div className="flex items-start gap-3">
|
|
<MapPinned className="mt-0.5 size-4 text-accent" aria-hidden="true" />
|
|
<div>
|
|
<h3 className="text-sm font-semibold">{t("settings.notifications.regionTitle")}</h3>
|
|
<p className="mt-1 text-sm leading-6 text-muted">
|
|
{t("settings.notifications.regionDescription", { province: effectiveProvinceLabel })}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 space-y-3">
|
|
<label className="flex items-start gap-3 rounded-card border border-border/60 bg-surface px-3 py-3 text-sm">
|
|
<input
|
|
type="radio"
|
|
name="warning-notification-province-mode"
|
|
checked={provinceMode === "selected"}
|
|
disabled={selectedRegion === "GLOBAL"}
|
|
onChange={() => setProvinceMode("selected")}
|
|
className="mt-1 accent-accent"
|
|
/>
|
|
<span>
|
|
<span className="block font-medium">{t("settings.notifications.regionSelected")}</span>
|
|
<span className="mt-0.5 block text-xs leading-5 text-muted">
|
|
{selectedProvince
|
|
? formatProvinceName(selectedProvince, language)
|
|
: t("settings.notifications.noProvince")}
|
|
</span>
|
|
</span>
|
|
</label>
|
|
<label className="flex items-start gap-3 rounded-card border border-border/60 bg-surface px-3 py-3 text-sm">
|
|
<input
|
|
type="radio"
|
|
name="warning-notification-province-mode"
|
|
checked={provinceMode === "manual"}
|
|
disabled={selectedRegion === "GLOBAL"}
|
|
onChange={() => {
|
|
setProvinceMode("manual");
|
|
if (!manualProvince) setManualProvince(selectedProvince ?? "mazowieckie");
|
|
}}
|
|
className="mt-1 accent-accent"
|
|
/>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block font-medium">{t("settings.notifications.regionManual")}</span>
|
|
<span className="relative mt-2 block">
|
|
<button
|
|
type="button"
|
|
aria-label={t("settings.notifications.regionManual")}
|
|
aria-haspopup="listbox"
|
|
aria-expanded={provinceDropdownOpen}
|
|
disabled={selectedRegion === "GLOBAL" || provinceMode !== "manual"}
|
|
onBlur={(event) => {
|
|
if (!event.currentTarget.parentElement?.contains(event.relatedTarget as Node | null))
|
|
setProvinceDropdownOpen(false);
|
|
}}
|
|
onClick={() => setProvinceDropdownOpen((current) => !current)}
|
|
className="surface-control flex w-full items-center justify-between gap-3 rounded-control py-2 pl-3 pr-3 text-left text-sm disabled:opacity-60"
|
|
>
|
|
<span className="truncate">{formatProvinceName(manualProvinceValue, language)}</span>
|
|
<ChevronDown className="size-4 shrink-0 text-muted" aria-hidden="true" />
|
|
</button>
|
|
{provinceDropdownOpen && provinceMode === "manual" && (
|
|
<div
|
|
role="listbox"
|
|
aria-label={t("settings.notifications.regionManual")}
|
|
className="surface-control absolute inset-x-0 top-[calc(100%+0.5rem)] z-30 max-h-72 overflow-y-auto rounded-panel p-1.5 shadow-card"
|
|
>
|
|
{PROVINCES.map((province) => (
|
|
<button
|
|
key={province}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={manualProvinceValue === province}
|
|
onClick={() => {
|
|
setManualProvince(province);
|
|
setProvinceDropdownOpen(false);
|
|
}}
|
|
className={`flex w-full items-center justify-between gap-3 rounded-card px-3 py-2.5 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
|
manualProvinceValue === province
|
|
? "bg-accent/10 font-semibold text-accent"
|
|
: "text-foreground hover:bg-surface-muted/70"
|
|
}`}
|
|
>
|
|
<span className="truncate">{formatProvinceName(province, language)}</span>
|
|
{manualProvinceValue === province && (
|
|
<span className="size-1.5 rounded-full bg-accent" aria-hidden="true" />
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</span>
|
|
</span>
|
|
</label>
|
|
{selectedRegion === "GLOBAL" && (
|
|
<p className="rounded-card border border-border/60 bg-surface px-3 py-3 text-xs leading-5 text-muted">
|
|
{t("settings.notifications.globalAlertsUnavailable")}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-card border border-border/65 bg-surface-muted/45 p-4">
|
|
<div className="flex items-start gap-3">
|
|
<Smartphone className="mt-0.5 size-4 text-accent" aria-hidden="true" />
|
|
<div>
|
|
<h3 className="text-sm font-semibold">{t("settings.notifications.deviceTitle")}</h3>
|
|
<p className="mt-1 text-sm leading-6 text-muted">{notificationStatusLabel}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={notificationsEnabled}
|
|
disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription}
|
|
onClick={notificationsEnabled ? disableWarningNotifications : enableWarningNotifications}
|
|
className={`mt-4 flex w-full items-start justify-between gap-4 rounded-card border px-3 py-3 text-left text-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-65 ${
|
|
notificationsEnabled
|
|
? "border-accent/45 bg-accent/10"
|
|
: "border-border/60 bg-surface hover:border-accent/45"
|
|
}`}
|
|
>
|
|
<span className="min-w-0">
|
|
<span className="flex items-center gap-2 font-medium">
|
|
<Bell className="size-3.5 text-accent" aria-hidden="true" />
|
|
{selectedRegion === "GLOBAL"
|
|
? t("settings.notifications.enableDevice")
|
|
: t("settings.notifications.enable")}
|
|
</span>
|
|
<span className="mt-0.5 block text-xs leading-5 text-muted">
|
|
{selectedRegion === "GLOBAL"
|
|
? t("settings.notifications.enableGlobalDescription")
|
|
: t("settings.notifications.enableDescription")}
|
|
</span>
|
|
<span className="mt-2 block text-xs font-semibold text-accent">
|
|
{isSavingSubscription
|
|
? t("settings.notifications.saving")
|
|
: notificationsEnabled
|
|
? t("settings.notifications.enabledStatus")
|
|
: t("settings.notifications.disabledStatus")}
|
|
</span>
|
|
</span>
|
|
<SwitchIndicator
|
|
checked={notificationsEnabled}
|
|
disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription}
|
|
/>
|
|
</button>
|
|
{notificationMessage && <p className="mt-3 text-xs font-medium text-accent">{notificationMessage}</p>}
|
|
|
|
<NotificationSwitchLabel
|
|
icon={Clock3}
|
|
title={t("settings.notifications.morningBrief")}
|
|
description={t("settings.notifications.morningBriefDescription")}
|
|
checked={morningBriefEnabled}
|
|
disabled={!notificationsEnabled || isSavingSubscription}
|
|
onChange={updateMorningBriefPreference}
|
|
/>
|
|
|
|
<NotificationSwitchLabel
|
|
icon={Clock3}
|
|
title={t("settings.notifications.tomorrowBrief")}
|
|
description={t("settings.notifications.tomorrowBriefDescription")}
|
|
checked={tomorrowBriefEnabled}
|
|
disabled={!notificationsEnabled || isSavingSubscription}
|
|
onChange={updateTomorrowBriefPreference}
|
|
/>
|
|
|
|
<div className="mt-4">
|
|
<Button
|
|
type="button"
|
|
variant="glass"
|
|
disabled={!notificationsEnabled || isSavingSubscription || isSendingTestNotification}
|
|
onClick={sendTestNotification}
|
|
>
|
|
<BellRing className="size-4" />
|
|
{isSendingTestNotification
|
|
? t("settings.notifications.sendingTest")
|
|
: t("settings.notifications.sendTest")}
|
|
</Button>
|
|
</div>
|
|
|
|
<p className="mt-4 text-xs leading-5 text-muted">{t("settings.notifications.implementationNote")}</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|