feat: granular per-category push notification settings
New Settings → Notifications section with a toggle per category (follow, comment, reply, reaction, rating, mention, leftover-expiring, shared shopping list) — previously it was all-or-nothing (browser permission only). userNotificationPrefs (one row per user, defaults all-on so existing users see no behavior change until they opt out of something). Gated the push send in the three places that dispatch one: lib/notifications.ts (the 6 in-app notification types), the leftover-expiry cron, and shopping-list-notify — in-app notification-center entries and email are unaffected, this only gates the push itself, matching the literal ask. Verified locally: GET/PUT round-trips correctly, disabling a category and confirming the settings page renders all 8 toggles.
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { NotificationCategory, NotificationPrefs } from "@/lib/notification-prefs";
|
||||
|
||||
const CATEGORIES: NotificationCategory[] = [
|
||||
"follow", "comment", "reply", "reaction", "rating", "mention", "leftoverExpiring", "shoppingList",
|
||||
];
|
||||
|
||||
export function NotificationCategoriesForm() {
|
||||
const t = useTranslations("settingsForm.notificationCategories");
|
||||
const t_common = useTranslations("common");
|
||||
const [prefs, setPrefs] = useState<NotificationPrefs | null>(null);
|
||||
const [saving, setSaving] = useState<NotificationCategory | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/v1/users/me/notification-prefs")
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((json) => setPrefs(json?.data ?? null))
|
||||
.catch(() => setPrefs(null));
|
||||
}, []);
|
||||
|
||||
async function toggle(category: NotificationCategory, checked: boolean) {
|
||||
if (!prefs) return;
|
||||
const previous = prefs;
|
||||
setPrefs({ ...prefs, [category]: checked });
|
||||
setSaving(category);
|
||||
try {
|
||||
const res = await fetch("/api/v1/users/me/notification-prefs", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ [category]: checked }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setPrefs(previous);
|
||||
toast.error(t_common("saveFailed"));
|
||||
}
|
||||
} catch {
|
||||
setPrefs(previous);
|
||||
toast.error(t_common("saveFailed"));
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!prefs) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{CATEGORIES.map((category) => (
|
||||
<div key={category} className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor={`notif-${category}`} className="cursor-pointer">
|
||||
{t(category)}
|
||||
</Label>
|
||||
<Switch
|
||||
id={`notif-${category}`}
|
||||
checked={prefs[category]}
|
||||
disabled={saving === category}
|
||||
onCheckedChange={(checked) => { void toggle(category, checked); }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user