diff --git a/.env.example b/.env.example index 9f7268457..fe70a9559 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,4 @@ DATABASE_URL="postgres://postgres:postgres@localhost:5432/peterplate" +BETTER_AUTH_SECRET="insert secret here" +NEXT_PUBLIC_VAPID_PUBLIC_KEY="generate with: npx web-push generate-vapid-keys" +VAPID_PRIVATE_KEY="generate with: npx web-push generate-vapid-keys" \ No newline at end of file diff --git a/apps/next/.env.example b/apps/next/.env.example new file mode 100644 index 000000000..b513e1543 --- /dev/null +++ b/apps/next/.env.example @@ -0,0 +1,2 @@ +NEXT_PUBLIC_VAPID_PUBLIC_KEY="generate with: npx web-push generate-vapid-keys" +VAPID_PRIVATE_KEY="generate with: npx web-push generate-vapid-keys" \ No newline at end of file diff --git a/apps/next/src/components/ui/notifications-panel.tsx b/apps/next/src/components/ui/notifications-panel.tsx new file mode 100644 index 000000000..1f2e48031 --- /dev/null +++ b/apps/next/src/components/ui/notifications-panel.tsx @@ -0,0 +1,340 @@ +"use client"; + +import { + ArrowBack as ArrowBackIcon, + Close as CloseIcon, + Notifications as NotificationsIcon, +} from "@mui/icons-material"; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Switch, + Typography, +} from "@mui/material"; +import { useEffect, useRef, useState } from "react"; +import { useSession } from "@/utils/auth-client"; +import { trpc } from "@/utils/trpc"; + +const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY as string; + +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +function isPushSupported(): boolean { + if (typeof window === "undefined" || typeof navigator === "undefined") { + return false; + } + return ( + "serviceWorker" in navigator && + "PushManager" in window && + "Notification" in window + ); +} + +async function ensurePushSubscription(): Promise<{ + endpoint: string; + p256dh: string; + auth: string; +}> { + const registration = await navigator.serviceWorker.ready; + let sub = await registration.pushManager.getSubscription(); + if (!sub) { + sub = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array( + VAPID_PUBLIC_KEY, + ) as BufferSource, + }); + } + const json = sub.toJSON(); + if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) { + throw new Error("Invalid push subscription keys generated."); + } + return { + endpoint: json.endpoint, + p256dh: json.keys.p256dh, + auth: json.keys.auth, + }; +} + +type ToggleKind = "food" | "event"; + +export default function NotificationsPanel({ onBack }: { onBack: () => void }) { + const { data: session } = useSession(); + const userId = session?.user?.id ?? ""; + + const utils = trpc.useUtils(); + + const { data: notifications } = + trpc.notification.getSavedNotifications.useQuery( + { userId }, + { enabled: !!userId }, + ); + + const { data: subscription, isLoading: subLoading } = + trpc.notification.getSubscription.useQuery( + { userId }, + { enabled: !!userId }, + ); + + const subscribeMutation = trpc.notification.subscribe.useMutation(); + const updateSubscriptionMutation = + trpc.notification.updateSubscription.useMutation(); + const markAllReadMutation = trpc.notification.markAllRead.useMutation(); + + const [dishNotifs, setDishNotifs] = useState(false); + const [eventNotifs, setEventNotifs] = useState(false); + const [isBusy, setIsBusy] = useState(false); + const [dialog, setDialog] = useState<{ + open: boolean; + title: string; + message: string; + }>({ open: false, title: "", message: "" }); + + useEffect(() => { + setDishNotifs(subscription?.isSubscribedFoodFavorites ?? false); + setEventNotifs(subscription?.isSubscribedEvents ?? false); + }, [subscription]); + + const hasMarkedReadRef = useRef(false); + useEffect(() => { + if (!userId || hasMarkedReadRef.current) return; + hasMarkedReadRef.current = true; + + utils.notification.getSavedNotifications.setData({ userId }, (prev) => + prev?.map((n) => ({ ...n, isRead: true })), + ); + + markAllReadMutation.mutate( + { userId }, + { + onSettled: () => { + void utils.notification.getSavedNotifications.invalidate({ userId }); + }, + }, + ); + }, [userId, utils, markAllReadMutation]); + + const showDialog = (title: string, message: string) => + setDialog({ open: true, title, message }); + + const handleToggle = async (kind: ToggleKind, nextValue: boolean) => { + if (!userId) { + showDialog( + "Sign In Required", + "You must be logged in to manage notifications.", + ); + return; + } + if (isBusy) return; + + const prevDish = dishNotifs; + const prevEvent = eventNotifs; + const revert = () => { + setDishNotifs(prevDish); + setEventNotifs(prevEvent); + }; + + if (kind === "food") setDishNotifs(nextValue); + else setEventNotifs(nextValue); + + const nextDish = kind === "food" ? nextValue : prevDish; + const nextEvent = kind === "event" ? nextValue : prevEvent; + + setIsBusy(true); + try { + if (nextValue) { + if (!isPushSupported()) { + showDialog( + "Not Supported", + "Push notifications are not supported in this browser.", + ); + revert(); + return; + } + + const permission = await Notification.requestPermission(); + if (permission !== "granted") { + showDialog( + "Permission Denied", + "Reset notification permissions in your browser's address bar and try again.", + ); + revert(); + return; + } + + const subData = await ensurePushSubscription(); + await subscribeMutation.mutateAsync({ + userId, + endpoint: subData.endpoint, + p256dh: subData.p256dh, + auth: subData.auth, + isSubscribedFoodFavorites: nextDish, + isSubscribedEvents: nextEvent, + }); + } else { + await updateSubscriptionMutation.mutateAsync({ + userId, + isSubscribedFoodFavorites: nextDish, + isSubscribedEvents: nextEvent, + }); + } + + await utils.notification.getSubscription.invalidate({ userId }); + } catch (err) { + console.error("Notification toggle failed:", err); + showDialog("Something Went Wrong", "Action failed. Please try again."); + revert(); + } finally { + setIsBusy(false); + } + }; + + return ( + + {/* Header */} +
+
+ + + + +
+ + Notifications + + + {notifications?.filter((n) => !n.isRead).length ?? 0} Unread + +
+
+ +
+ + {/* Notification list from saved_notifications table */} +
+ {notifications?.length ? ( + notifications.map((n) => ( +
+
+ + {n.message} + + + {new Date(n.createdAt).toLocaleDateString()} + +
+ )) + ) : ( +
+ + No notifications yet. + +
+ )} +
+ + {/* Toggles — drive push_subscriptions row + isSubscribedFoodFavorites / isSubscribedEvents */} +
+
+ + Dish Notifications + + handleToggle("food", e.target.checked)} + size="small" + /> +
+
+ + Event Notifications + + handleToggle("event", e.target.checked)} + size="small" + /> +
+
+ + setDialog((d) => ({ ...d, open: false }))} + slotProps={{ + paper: { + sx: { + borderRadius: "16px", + backgroundImage: "none", + ".dark &": { + backgroundColor: "#303035", + backgroundImage: "none", + }, + }, + }, + }} + > + + + {dialog.title} + + + + + {dialog.message} + + + + + + + + ); +} diff --git a/apps/next/src/components/ui/sidebar/sidebar-content.tsx b/apps/next/src/components/ui/sidebar/sidebar-content.tsx index 80f2f955e..e5352a0c3 100644 --- a/apps/next/src/components/ui/sidebar/sidebar-content.tsx +++ b/apps/next/src/components/ui/sidebar/sidebar-content.tsx @@ -9,6 +9,7 @@ import { Info as InfoIcon, LightMode as LightModeIcon, Logout as LogoutIcon, + Notifications as NotificationsIcon, } from "@mui/icons-material"; import { Box, Tooltip, Typography } from "@mui/material"; import Image from "next/image"; @@ -18,6 +19,7 @@ import type React from "react"; import type { ReactNode } from "react"; import { useEffect, useState } from "react"; import { GoogleSignInButton } from "@/components/auth/google-sign-in"; +import NotificationsPanel from "@/components/ui/notifications-panel"; import { signOut, useSession } from "@/utils/auth-client"; import { formatDietaryKey } from "@/utils/dietary"; import { trpc } from "@/utils/trpc"; @@ -36,6 +38,7 @@ export default function SidebarContent({ const userId = user?.id; const { theme, setTheme } = useTheme(); const [mounted, setMounted] = useState(false); + const [showNotifications, setShowNotifications] = useState(false); useEffect(() => setMounted(true), []); @@ -60,6 +63,10 @@ export default function SidebarContent({ window.location.href = "/"; }; + if (showNotifications) { + return setShowNotifications(false)} />; + } + return ( + + + + + +