feat: per-category email prefs, admin ops webhooks, per-user feature toggles (v0.61.0)
- Notification email preferences: every push category (follow, comment, reply, reaction, rating, mention, leftoverExpiring, shoppingList) now has an independent email toggle, plus a Weekly Digest toggle. Previously email sent unconditionally whenever the recipient had one; now gated the same way push already was. The weekly-digest cron route excludes opted-out users. - Admin-only site-wide webhooks (Admin → Webhooks): new signups, support tickets, and reports filed can now fire an HMAC-signed HTTP webhook (Slack/Discord/ops alerting), independent of the existing per-user webhooks (which stay scoped to a user's own recipe/meal-plan/shopping-list events). Signing/delivery logic factored into lib/webhook-delivery.ts and shared by both dispatchers instead of duplicated. - Settings → Features: users can hide Nutrition, Pantry, Meal Plan, Shopping Lists, Collections, or Messages from their own nav. Purely cosmetic — hidden pages stay reachable by direct link, nothing is access-restricted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import { BookOpen, Calendar, Package, ChefHat, User, FolderOpen, ShoppingCart, Shield, Search, Compass, Menu, Sun, Moon, Monitor, Apple, LifeBuoy, Settings, LogOut } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import type { FeaturePrefs } from "@/lib/feature-prefs";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -28,13 +30,13 @@ import { authClient } from "@/lib/auth/client";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/recipes", key: "recipes", icon: BookOpen },
|
||||
{ href: "/explore", key: "explore", icon: Search },
|
||||
{ href: "/collections", key: "collections", icon: FolderOpen },
|
||||
{ href: "/meal-plan", key: "mealPlan", icon: Calendar },
|
||||
{ href: "/nutrition", key: "nutrition", icon: Apple },
|
||||
{ href: "/pantry", key: "pantry", icon: Package },
|
||||
{ href: "/shopping-lists", key: "shopping", icon: ShoppingCart },
|
||||
{ href: "/recipes", key: "recipes", icon: BookOpen, feature: null },
|
||||
{ href: "/explore", key: "explore", icon: Search, feature: null },
|
||||
{ href: "/collections", key: "collections", icon: FolderOpen, feature: "collections" },
|
||||
{ href: "/meal-plan", key: "mealPlan", icon: Calendar, feature: "mealPlan" },
|
||||
{ href: "/nutrition", key: "nutrition", icon: Apple, feature: "nutrition" },
|
||||
{ href: "/pantry", key: "pantry", icon: Package, feature: "pantry" },
|
||||
{ href: "/shopping-lists", key: "shopping", icon: ShoppingCart, feature: "shoppingLists" },
|
||||
] as const;
|
||||
|
||||
export function Nav() {
|
||||
@@ -45,6 +47,30 @@ export function Nav() {
|
||||
const username = (session?.user as { username?: string } | undefined)?.username;
|
||||
const { theme, setTheme } = useTheme();
|
||||
const t = useTranslations("nav");
|
||||
|
||||
// Defaults to "everything on" until the fetch resolves, matching the
|
||||
// backend default — avoids a flash of items disappearing on load for the
|
||||
// (much more common) case where a user hasn't hidden anything.
|
||||
const [featurePrefs, setFeaturePrefs] = useState<FeaturePrefs>({
|
||||
nutrition: true, pantry: true, mealPlan: true, shoppingLists: true, collections: true, messages: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function load() {
|
||||
fetch("/api/v1/users/me/feature-prefs")
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((json) => { if (json?.data) setFeaturePrefs(json.data); })
|
||||
.catch(() => {});
|
||||
}
|
||||
load();
|
||||
// Settings → Features saves on the same origin without a full nav
|
||||
// remount, so it fires this event to make the change visible immediately
|
||||
// rather than only on the next page load.
|
||||
window.addEventListener("epicure:feature-prefs-changed", load);
|
||||
return () => window.removeEventListener("epicure:feature-prefs-changed", load);
|
||||
}, []);
|
||||
|
||||
const visibleNavItems = NAV_ITEMS.filter((item) => item.feature === null || featurePrefs[item.feature]);
|
||||
const THEME_OPTIONS = [
|
||||
{ value: "light", icon: Sun, label: t("lightMode") },
|
||||
{ value: "dark", icon: Moon, label: t("darkMode") },
|
||||
@@ -68,7 +94,7 @@ export function Nav() {
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="flex flex-col gap-1 px-2">
|
||||
{NAV_ITEMS.map(({ href, key, icon: Icon }) => (
|
||||
{visibleNavItems.map(({ href, key, icon: Icon }) => (
|
||||
<SheetClose
|
||||
key={href}
|
||||
nativeButton={false}
|
||||
@@ -110,7 +136,7 @@ export function Nav() {
|
||||
))}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<MessagesNavLink />
|
||||
{featurePrefs.messages && <MessagesNavLink />}
|
||||
<NotificationBell />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
|
||||
Reference in New Issue
Block a user