From 7713e02b8f992f1c388c6a95974efa3b710874fa Mon Sep 17 00:00:00 2001 From: Jayam Srivastava Date: Sun, 21 Jun 2026 18:03:47 +0000 Subject: [PATCH 1/2] feat: add in-app notification system (#98) - Notification types, Firestore service with real-time listeners - NotificationProvider context with subscribe/mark-read/preferences - NotificationBell dropdown with unread badge in dashboard top bar - NotificationCenter page at /dashboard/notifications with filters, search, pagination - NotificationPreferences page at /dashboard/notification-preferences - Notification trigger utilities for project, payment, account, and admin events - Routes and sidebar navigation integration Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/app/App.tsx | 4 + src/dashboard/components/DashboardLayout.tsx | 15 +- .../notifications/NotificationBell.tsx | 198 ++++++++++ .../notifications/NotificationCenter.tsx | 352 ++++++++++++++++++ .../notifications/NotificationContext.tsx | 102 +++++ .../NotificationContextObject.ts | 23 ++ .../notifications/NotificationPreferences.tsx | 228 ++++++++++++ src/dashboard/notifications/index.ts | 11 + .../notifications/notificationService.ts | 161 ++++++++ .../notifications/notificationTriggers.ts | 206 ++++++++++ src/dashboard/notifications/types.ts | 47 +++ .../notifications/useNotifications.ts | 6 + 12 files changed, 1350 insertions(+), 3 deletions(-) create mode 100644 src/dashboard/notifications/NotificationBell.tsx create mode 100644 src/dashboard/notifications/NotificationCenter.tsx create mode 100644 src/dashboard/notifications/NotificationContext.tsx create mode 100644 src/dashboard/notifications/NotificationContextObject.ts create mode 100644 src/dashboard/notifications/NotificationPreferences.tsx create mode 100644 src/dashboard/notifications/index.ts create mode 100644 src/dashboard/notifications/notificationService.ts create mode 100644 src/dashboard/notifications/notificationTriggers.ts create mode 100644 src/dashboard/notifications/types.ts create mode 100644 src/dashboard/notifications/useNotifications.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index 5ad77dd..8766be5 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -33,6 +33,8 @@ import { InvoiceManagement } from "../dashboard/pages/InvoiceManagement"; import { ProjectResources } from "../dashboard/pages/ProjectResources"; import { ProjectEstimation } from "../dashboard/pages/ProjectEstimation"; import { PricingConfig } from "../dashboard/pages/PricingConfig"; +import { NotificationCenter } from "../dashboard/notifications/NotificationCenter"; +import { NotificationPreferences } from "../dashboard/notifications/NotificationPreferences"; const REVEAL_EASE: [number, number, number, number] = [0.4, 0, 0.2, 1]; @@ -180,6 +182,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> diff --git a/src/dashboard/components/DashboardLayout.tsx b/src/dashboard/components/DashboardLayout.tsx index 861558e..bedfd95 100644 --- a/src/dashboard/components/DashboardLayout.tsx +++ b/src/dashboard/components/DashboardLayout.tsx @@ -24,11 +24,14 @@ import { import { Avatar, AvatarFallback, AvatarImage } from "../../app/components/ui/avatar"; import { Button } from "../../app/components/ui/button"; import { Separator } from "../../app/components/ui/separator"; +import { NotificationProvider } from "../notifications/NotificationContext"; +import { NotificationBell } from "../notifications/NotificationBell"; const NAV_ITEMS = [ { to: "/dashboard", icon: LayoutDashboard, label: "Overview", end: true }, { to: "/dashboard/progress", icon: GitBranch, label: "Progress" }, { to: "/dashboard/updates", icon: Bell, label: "Updates" }, + { to: "/dashboard/notifications", icon: Bell, label: "Notifications" }, { to: "/dashboard/payments", icon: CreditCard, label: "Payments" }, { to: "/dashboard/invoices", icon: FileText, label: "Invoices" }, { to: "/dashboard/resources", icon: FolderOpen, label: "Resources" }, @@ -179,6 +182,7 @@ export function DashboardLayout() { ); return ( +
{/* Desktop sidebar */}
+
); } diff --git a/src/dashboard/notifications/NotificationBell.tsx b/src/dashboard/notifications/NotificationBell.tsx new file mode 100644 index 0000000..08ad50a --- /dev/null +++ b/src/dashboard/notifications/NotificationBell.tsx @@ -0,0 +1,198 @@ +import { useState, useRef, useEffect } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { motion, AnimatePresence } from "motion/react"; +import { + Bell, + CheckCheck, + Info, + CheckCircle2, + AlertTriangle, + XCircle, + ExternalLink, +} from "lucide-react"; +import { Button } from "../../app/components/ui/button"; +import { useNotifications } from "./useNotifications"; +import type { Notification, NotificationType } from "./types"; +import { formatDistanceToNow } from "date-fns"; + +function typeIcon(type: NotificationType) { + switch (type) { + case "success": + return ; + case "warning": + return ; + case "error": + return ; + default: + return ; + } +} + +function NotificationItem({ + notification, + onRead, + onNavigate, +}: { + notification: Notification; + onRead: (id: string) => void; + onNavigate: () => void; +}) { + const navigate = useNavigate(); + + const handleClick = () => { + if (!notification.isRead) onRead(notification.id); + if (notification.actionUrl) { + onNavigate(); + navigate(notification.actionUrl); + } + }; + + return ( + + ); +} + +export function NotificationBell() { + const [open, setOpen] = useState(false); + const panelRef = useRef(null); + const { notifications, unreadCount, loading, markAsRead, markAllAsRead } = + useNotifications(); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + if (open) document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [open]); + + const recentNotifications = notifications.slice(0, 8); + + return ( +
+ + + + {open && ( + + {/* Header */} +
+

+ Notifications +

+ {unreadCount > 0 && ( + + )} +
+ + {/* Body */} +
+ {loading ? ( +
+
+
+ ) : recentNotifications.length === 0 ? ( +
+ +

+ No notifications yet +

+

+ We'll notify you when something important happens. +

+
+ ) : ( +
+ {recentNotifications.map((n) => ( + setOpen(false)} + /> + ))} +
+ )} +
+ + {/* Footer */} + {notifications.length > 0 && ( +
+ setOpen(false)} + className="block w-full text-center text-xs font-medium text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300" + > + View all notifications + +
+ )} + + )} + +
+ ); +} diff --git a/src/dashboard/notifications/NotificationCenter.tsx b/src/dashboard/notifications/NotificationCenter.tsx new file mode 100644 index 0000000..c5453be --- /dev/null +++ b/src/dashboard/notifications/NotificationCenter.tsx @@ -0,0 +1,352 @@ +import { useState, useMemo } from "react"; +import { useNavigate, Link } from "react-router-dom"; +import { motion } from "motion/react"; +import { + Bell, + CheckCheck, + Search, + Filter, + Info, + CheckCircle2, + AlertTriangle, + XCircle, + ExternalLink, + ChevronLeft, + ChevronRight, + Settings2, +} from "lucide-react"; +import { Card, CardContent } from "../../app/components/ui/card"; +import { Button } from "../../app/components/ui/button"; +import { Badge } from "../../app/components/ui/badge"; +import { Skeleton } from "../../app/components/ui/skeleton"; +import { useNotifications } from "./useNotifications"; +import type { + Notification, + NotificationType, + NotificationCategory, +} from "./types"; +import { formatDistanceToNow } from "date-fns"; + +const ITEMS_PER_PAGE = 10; + +type ReadFilter = "all" | "unread" | "read"; + +function typeIcon(type: NotificationType) { + switch (type) { + case "success": + return ; + case "warning": + return ; + case "error": + return ; + default: + return ; + } +} + +function categoryLabel(cat: NotificationCategory) { + switch (cat) { + case "project": + return "Project"; + case "payment": + return "Payment"; + case "message": + return "Message"; + case "system": + return "System"; + } +} + +function categoryBadgeClass(cat: NotificationCategory) { + switch (cat) { + case "project": + return "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-950/50 dark:text-indigo-400 dark:border-indigo-800"; + case "payment": + return "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950/50 dark:text-emerald-400 dark:border-emerald-800"; + case "message": + return "bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-800"; + case "system": + return "bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700"; + } +} + +function NotificationRow({ + notification, + onRead, +}: { + notification: Notification; + onRead: (id: string) => void; +}) { + const navigate = useNavigate(); + + const handleClick = () => { + if (!notification.isRead) onRead(notification.id); + if (notification.actionUrl) navigate(notification.actionUrl); + }; + + return ( + + + +
{typeIcon(notification.type)}
+
+
+

+ {notification.title} +

+ + {categoryLabel(notification.category)} + + {!notification.isRead && ( + + )} +
+

+ {notification.message} +

+

+ {formatDistanceToNow(notification.createdAt, { + addSuffix: true, + })} +

+
+ {notification.actionUrl && ( + + )} +
+
+
+ ); +} + +export function NotificationCenter() { + const { notifications, unreadCount, loading, markAsRead, markAllAsRead } = + useNotifications(); + + const [searchQuery, setSearchQuery] = useState(""); + const [categoryFilter, setCategoryFilter] = + useState("all"); + const [readFilter, setReadFilter] = useState("all"); + const [page, setPage] = useState(1); + + const filtered = useMemo(() => { + let result = notifications; + + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + result = result.filter( + (n) => + n.title.toLowerCase().includes(q) || + n.message.toLowerCase().includes(q), + ); + } + + if (categoryFilter !== "all") { + result = result.filter((n) => n.category === categoryFilter); + } + + if (readFilter === "unread") { + result = result.filter((n) => !n.isRead); + } else if (readFilter === "read") { + result = result.filter((n) => n.isRead); + } + + return result; + }, [notifications, searchQuery, categoryFilter, readFilter]); + + const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE)); + const safePage = Math.min(page, totalPages); + const paginated = filtered.slice( + (safePage - 1) * ITEMS_PER_PAGE, + safePage * ITEMS_PER_PAGE, + ); + + if (loading) { + return ( +
+ + +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+ ); + } + + return ( +
+ {/* Header */} + +
+

+ Notifications +

+

+ {unreadCount > 0 + ? `You have ${unreadCount} unread notification${unreadCount === 1 ? "" : "s"}` + : "You're all caught up!"} +

+
+
+ {unreadCount > 0 && ( + + )} + + + +
+
+ + {/* Filters */} +
+ {/* Search */} +
+ + { + setSearchQuery(e.target.value); + setPage(1); + }} + className="w-full rounded-lg border border-gray-200 bg-white py-2 pl-10 pr-4 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-100 dark:placeholder-gray-500" + /> +
+ + {/* Category filter */} +
+ + +
+ + {/* Read filter */} +
+ {(["all", "unread", "read"] as ReadFilter[]).map((filter) => ( + + ))} +
+
+ + {/* Notification list */} + {paginated.length === 0 ? ( +
+ +

+ {searchQuery || categoryFilter !== "all" || readFilter !== "all" + ? "No notifications match your filters." + : "No notifications yet."} +

+

+ {searchQuery || categoryFilter !== "all" || readFilter !== "all" + ? "Try adjusting your search or filters." + : "We'll notify you when something important happens."} +

+
+ ) : ( +
+ {paginated.map((n) => ( + + ))} +
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Showing {(safePage - 1) * ITEMS_PER_PAGE + 1}– + {Math.min(safePage * ITEMS_PER_PAGE, filtered.length)} of{" "} + {filtered.length} +

+
+ + + {safePage} / {totalPages} + + +
+
+ )} +
+ ); +} diff --git a/src/dashboard/notifications/NotificationContext.tsx b/src/dashboard/notifications/NotificationContext.tsx new file mode 100644 index 0000000..fe5d669 --- /dev/null +++ b/src/dashboard/notifications/NotificationContext.tsx @@ -0,0 +1,102 @@ +import { + useEffect, + useState, + useCallback, + type ReactNode, +} from "react"; +import { useAuth } from "../../Firebase/useAuth"; +import { + subscribeToNotifications, + markAsRead as markAsReadService, + markAllAsRead as markAllAsReadService, + subscribeToPreferences, + updatePreferences as updatePreferencesService, +} from "./notificationService"; +import { NotificationContext } from "./NotificationContextObject"; +import type { Notification, NotificationPreferences } from "./types"; +import { DEFAULT_PREFERENCES } from "./types"; + +export function NotificationProvider({ children }: { children: ReactNode }) { + const { currentUser } = useAuth(); + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(true); + const [preferences, setPreferences] = + useState(DEFAULT_PREFERENCES); + + useEffect(() => { + if (!currentUser) { + setNotifications([]); + setLoading(false); + return; + } + + setLoading(true); + const unsubNotifications = subscribeToNotifications( + currentUser.uid, + (data) => { + setNotifications(data); + setLoading(false); + }, + () => { + setLoading(false); + }, + ); + + const unsubPrefs = subscribeToPreferences(currentUser.uid, setPreferences); + + return () => { + unsubNotifications(); + unsubPrefs(); + }; + }, [currentUser]); + + const unreadCount = notifications.filter((n) => !n.isRead).length; + + const markAsRead = useCallback( + async (id: string) => { + try { + await markAsReadService(id); + } catch (error) { + console.error("Failed to mark notification as read:", error); + } + }, + [], + ); + + const markAllAsRead = useCallback(async () => { + if (!currentUser) return; + try { + await markAllAsReadService(currentUser.uid); + } catch (error) { + console.error("Failed to mark all notifications as read:", error); + } + }, [currentUser]); + + const updatePreferences = useCallback( + async (prefs: NotificationPreferences) => { + if (!currentUser) return; + try { + await updatePreferencesService(currentUser.uid, prefs); + } catch (error) { + console.error("Failed to update preferences:", error); + } + }, + [currentUser], + ); + + return ( + + {children} + + ); +} diff --git a/src/dashboard/notifications/NotificationContextObject.ts b/src/dashboard/notifications/NotificationContextObject.ts new file mode 100644 index 0000000..3a62dab --- /dev/null +++ b/src/dashboard/notifications/NotificationContextObject.ts @@ -0,0 +1,23 @@ +import { createContext } from "react"; +import type { Notification, NotificationPreferences } from "./types"; +import { DEFAULT_PREFERENCES } from "./types"; + +export interface NotificationContextValue { + notifications: Notification[]; + unreadCount: number; + loading: boolean; + preferences: NotificationPreferences; + markAsRead: (id: string) => Promise; + markAllAsRead: () => Promise; + updatePreferences: (prefs: NotificationPreferences) => Promise; +} + +export const NotificationContext = createContext({ + notifications: [], + unreadCount: 0, + loading: true, + preferences: DEFAULT_PREFERENCES, + markAsRead: async () => {}, + markAllAsRead: async () => {}, + updatePreferences: async () => {}, +}); diff --git a/src/dashboard/notifications/NotificationPreferences.tsx b/src/dashboard/notifications/NotificationPreferences.tsx new file mode 100644 index 0000000..f820c70 --- /dev/null +++ b/src/dashboard/notifications/NotificationPreferences.tsx @@ -0,0 +1,228 @@ +import { useState } from "react"; +import { motion } from "motion/react"; +import { + Save, + Bell, + Mail, + Smartphone, + GitBranch, + CreditCard, + MessageSquare, + Shield, +} from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "../../app/components/ui/card"; +import { Button } from "../../app/components/ui/button"; +import { Switch } from "../../app/components/ui/switch"; +import { Label } from "../../app/components/ui/label"; +import { Separator } from "../../app/components/ui/separator"; +import { useNotifications } from "./useNotifications"; +import type { NotificationPreferences as PrefsType } from "./types"; + +interface ToggleRowProps { + icon: React.ReactNode; + label: string; + description: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; +} + +function ToggleRow({ + icon, + label, + description, + checked, + onCheckedChange, +}: ToggleRowProps) { + return ( +
+
+
+ {icon} +
+
+ +

+ {description} +

+
+
+ +
+ ); +} + +export function NotificationPreferences() { + const { preferences, updatePreferences } = useNotifications(); + const [localPrefs, setLocalPrefs] = useState(preferences); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + + const hasChanges = + JSON.stringify(localPrefs) !== JSON.stringify(preferences); + + const handleSave = async () => { + setSaving(true); + try { + await updatePreferences(localPrefs); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + } finally { + setSaving(false); + } + }; + + const updateCategory = ( + key: keyof PrefsType["categories"], + value: boolean, + ) => { + setLocalPrefs((prev) => ({ + ...prev, + categories: { ...prev.categories, [key]: value }, + })); + }; + + const updateChannel = ( + key: keyof PrefsType["channels"], + value: boolean, + ) => { + setLocalPrefs((prev) => ({ + ...prev, + channels: { ...prev.channels, [key]: value }, + })); + }; + + return ( +
+ +

+ Notification Preferences +

+

+ Choose which notifications you'd like to receive and how. +

+
+ + {/* Category preferences */} + + + + Notification Categories + + + + } + label="Project Updates" + description="Project creation, status changes, milestones, and deliveries" + checked={localPrefs.categories.project} + onCheckedChange={(v) => updateCategory("project", v)} + /> + + + } + label="Payment Updates" + description="Payment receipts, invoices, and refunds" + checked={localPrefs.categories.payment} + onCheckedChange={(v) => updateCategory("payment", v)} + /> + + + } + label="Messages" + description="New messages and replies" + checked={localPrefs.categories.message} + onCheckedChange={(v) => updateCategory("message", v)} + /> + + + } + label="System Alerts" + description="Account security, maintenance, and announcements" + checked={localPrefs.categories.system} + onCheckedChange={(v) => updateCategory("system", v)} + /> + + + + + {/* Channel preferences */} + + + + Delivery Channels + + + } + label="In-App" + description="Notifications inside the Servio dashboard" + checked={localPrefs.channels.inApp} + onCheckedChange={(v) => updateChannel("inApp", v)} + /> + + } + label="Email" + description="Receive notifications via email (coming soon)" + checked={localPrefs.channels.email} + onCheckedChange={(v) => updateChannel("email", v)} + /> + + + } + label="Push Notifications" + description="Browser push notifications (coming soon)" + checked={localPrefs.channels.push} + onCheckedChange={(v) => updateChannel("push", v)} + /> + + + + + {/* Save button */} + + + {saved && ( + + Preferences saved successfully. + + )} + +
+ ); +} diff --git a/src/dashboard/notifications/index.ts b/src/dashboard/notifications/index.ts new file mode 100644 index 0000000..d585f17 --- /dev/null +++ b/src/dashboard/notifications/index.ts @@ -0,0 +1,11 @@ +export { NotificationProvider } from "./NotificationContext"; +export { useNotifications } from "./useNotifications"; +export { NotificationBell } from "./NotificationBell"; +export { NotificationCenter } from "./NotificationCenter"; +export { NotificationPreferences } from "./NotificationPreferences"; +export type { + Notification, + NotificationType, + NotificationCategory, + NotificationPreferences as NotificationPreferencesType, +} from "./types"; diff --git a/src/dashboard/notifications/notificationService.ts b/src/dashboard/notifications/notificationService.ts new file mode 100644 index 0000000..a2c1664 --- /dev/null +++ b/src/dashboard/notifications/notificationService.ts @@ -0,0 +1,161 @@ +import { + collection, + query, + where, + orderBy, + onSnapshot, + doc, + updateDoc, + addDoc, + writeBatch, + getDocs, + Timestamp, + limit, + startAfter, + type QueryDocumentSnapshot, + type DocumentData, +} from "firebase/firestore"; +import { db } from "../../Firebase/firebase"; +import type { + Notification, + NotificationType, + NotificationCategory, + NotificationPreferences, +} from "./types"; +import { DEFAULT_PREFERENCES } from "./types"; + +const NOTIFICATIONS_COLLECTION = "notifications"; +const PREFERENCES_COLLECTION = "notificationPreferences"; +const PAGE_SIZE = 20; + +function docToNotification( + docSnap: QueryDocumentSnapshot, +): Notification { + const data = docSnap.data(); + return { + id: docSnap.id, + userId: data.userId as string, + title: data.title as string, + message: data.message as string, + type: data.type as NotificationType, + category: data.category as NotificationCategory, + isRead: data.isRead as boolean, + createdAt: (data.createdAt as Timestamp).toDate(), + actionUrl: (data.actionUrl as string | undefined) ?? undefined, + }; +} + +export function subscribeToNotifications( + userId: string, + callback: (notifications: Notification[]) => void, + onError?: (error: Error) => void, +) { + const q = query( + collection(db, NOTIFICATIONS_COLLECTION), + where("userId", "==", userId), + orderBy("createdAt", "desc"), + limit(50), + ); + + return onSnapshot( + q, + (snapshot) => { + const notifications = snapshot.docs.map(docToNotification); + callback(notifications); + }, + (error) => { + console.error("Notification subscription error:", error); + onError?.(error); + }, + ); +} + +export async function fetchNotificationsPage( + userId: string, + lastDoc?: QueryDocumentSnapshot, +): Promise<{ + notifications: Notification[]; + lastDoc: QueryDocumentSnapshot | null; + hasMore: boolean; +}> { + const q = lastDoc + ? query( + collection(db, NOTIFICATIONS_COLLECTION), + where("userId", "==", userId), + orderBy("createdAt", "desc"), + startAfter(lastDoc), + limit(PAGE_SIZE + 1), + ) + : query( + collection(db, NOTIFICATIONS_COLLECTION), + where("userId", "==", userId), + orderBy("createdAt", "desc"), + limit(PAGE_SIZE + 1), + ); + + const snapshot = await getDocs(q); + + const hasMore = snapshot.docs.length > PAGE_SIZE; + const docs = hasMore ? snapshot.docs.slice(0, PAGE_SIZE) : snapshot.docs; + + return { + notifications: docs.map(docToNotification), + lastDoc: docs.length > 0 ? docs[docs.length - 1] : null, + hasMore, + }; +} + +export async function markAsRead(notificationId: string): Promise { + const ref = doc(db, NOTIFICATIONS_COLLECTION, notificationId); + await updateDoc(ref, { isRead: true }); +} + +export async function markAllAsRead(userId: string): Promise { + const q = query( + collection(db, NOTIFICATIONS_COLLECTION), + where("userId", "==", userId), + where("isRead", "==", false), + ); + const snapshot = await getDocs(q); + if (snapshot.empty) return; + + const batch = writeBatch(db); + snapshot.docs.forEach((docSnap) => { + batch.update(docSnap.ref, { isRead: true }); + }); + await batch.commit(); +} + +export async function createNotification( + data: Omit, +): Promise { + const docRef = await addDoc(collection(db, NOTIFICATIONS_COLLECTION), { + ...data, + isRead: false, + createdAt: Timestamp.now(), + }); + return docRef.id; +} + +export function subscribeToPreferences( + userId: string, + callback: (prefs: NotificationPreferences) => void, +) { + const ref = doc(db, PREFERENCES_COLLECTION, userId); + return onSnapshot(ref, (snap) => { + if (snap.exists()) { + callback(snap.data() as NotificationPreferences); + } else { + callback(DEFAULT_PREFERENCES); + } + }); +} + +export async function updatePreferences( + userId: string, + prefs: NotificationPreferences, +): Promise { + const { setDoc } = await import("firebase/firestore"); + const ref = doc(db, PREFERENCES_COLLECTION, userId); + await setDoc(ref, prefs, { merge: true }); +} diff --git a/src/dashboard/notifications/notificationTriggers.ts b/src/dashboard/notifications/notificationTriggers.ts new file mode 100644 index 0000000..7448082 --- /dev/null +++ b/src/dashboard/notifications/notificationTriggers.ts @@ -0,0 +1,206 @@ +import { createNotification } from "./notificationService"; +import type { NotificationType, NotificationCategory } from "./types"; + +interface TriggerParams { + userId: string; + title: string; + message: string; + type: NotificationType; + category: NotificationCategory; + actionUrl?: string; +} + +async function trigger(params: TriggerParams) { + try { + await createNotification(params); + } catch (error) { + console.error("Failed to create notification:", error); + } +} + +// ── Project Events ── + +export function notifyProjectCreated(userId: string, projectName: string) { + return trigger({ + userId, + title: "New Project Created", + message: `Your project "${projectName}" has been created successfully.`, + type: "success", + category: "project", + actionUrl: "/dashboard", + }); +} + +export function notifyProjectAssigned( + userId: string, + projectName: string, + projectId: string, +) { + return trigger({ + userId, + title: "Project Assigned", + message: `You have been assigned to project "${projectName}".`, + type: "info", + category: "project", + actionUrl: `/dashboard/progress?project=${projectId}`, + }); +} + +export function notifyStatusChanged( + userId: string, + projectName: string, + newStatus: string, +) { + return trigger({ + userId, + title: "Project Status Updated", + message: `"${projectName}" status changed to ${newStatus}.`, + type: "info", + category: "project", + actionUrl: "/dashboard/progress", + }); +} + +export function notifyMilestoneCompleted( + userId: string, + projectName: string, + milestone: string, +) { + return trigger({ + userId, + title: "Milestone Completed", + message: `"${milestone}" has been completed for "${projectName}".`, + type: "success", + category: "project", + actionUrl: "/dashboard/progress", + }); +} + +export function notifyProjectDelivered( + userId: string, + projectName: string, +) { + return trigger({ + userId, + title: "Project Delivered", + message: `"${projectName}" has been delivered. Please review the final deliverables.`, + type: "success", + category: "project", + actionUrl: "/dashboard/resources", + }); +} + +// ── Payment Events ── + +export function notifyPaymentReceived( + userId: string, + amount: number, + projectName: string, +) { + return trigger({ + userId, + title: "Payment Received", + message: `Payment of $${amount.toLocaleString()} received for "${projectName}".`, + type: "success", + category: "payment", + actionUrl: "/dashboard/payments", + }); +} + +export function notifyInvoiceGenerated( + userId: string, + invoiceNumber: string, +) { + return trigger({ + userId, + title: "Invoice Generated", + message: `Invoice ${invoiceNumber} has been generated and is ready for review.`, + type: "info", + category: "payment", + actionUrl: "/dashboard/invoices", + }); +} + +export function notifyRefundIssued( + userId: string, + amount: number, + reference: string, +) { + return trigger({ + userId, + title: "Refund Issued", + message: `A refund of $${amount.toLocaleString()} (ref: ${reference}) has been processed.`, + type: "info", + category: "payment", + actionUrl: "/dashboard/payments", + }); +} + +// ── Account Events ── + +export function notifyPasswordChanged(userId: string) { + return trigger({ + userId, + title: "Password Changed", + message: + "Your password has been changed. If you did not make this change, please contact support.", + type: "warning", + category: "system", + }); +} + +export function notifyProfileUpdated(userId: string) { + return trigger({ + userId, + title: "Profile Updated", + message: "Your profile information has been updated successfully.", + type: "success", + category: "system", + }); +} + +export function notifyNewLogin(userId: string, device: string) { + return trigger({ + userId, + title: "New Login Detected", + message: `A new login was detected from ${device}. If this wasn't you, please secure your account.`, + type: "warning", + category: "system", + }); +} + +export function notifyWelcome(userId: string, name: string) { + return trigger({ + userId, + title: "Welcome to Servio!", + message: `Hi ${name}, welcome aboard! Explore your dashboard to track projects and more.`, + type: "success", + category: "system", + actionUrl: "/dashboard", + }); +} + +// ── Admin Events ── + +export function notifyAnnouncement(userId: string, announcement: string) { + return trigger({ + userId, + title: "New Announcement", + message: announcement, + type: "info", + category: "system", + }); +} + +export function notifyMaintenanceScheduled( + userId: string, + scheduledDate: string, +) { + return trigger({ + userId, + title: "Scheduled Maintenance", + message: `System maintenance is scheduled for ${scheduledDate}. You may experience brief downtime.`, + type: "warning", + category: "system", + }); +} diff --git a/src/dashboard/notifications/types.ts b/src/dashboard/notifications/types.ts new file mode 100644 index 0000000..879d433 --- /dev/null +++ b/src/dashboard/notifications/types.ts @@ -0,0 +1,47 @@ +export type NotificationType = "info" | "success" | "warning" | "error"; + +export type NotificationCategory = + | "project" + | "payment" + | "message" + | "system"; + +export interface Notification { + id: string; + userId: string; + title: string; + message: string; + type: NotificationType; + category: NotificationCategory; + isRead: boolean; + createdAt: Date; + actionUrl?: string; +} + +export interface NotificationPreferences { + categories: { + project: boolean; + payment: boolean; + message: boolean; + system: boolean; + }; + channels: { + inApp: boolean; + email: boolean; + push: boolean; + }; +} + +export const DEFAULT_PREFERENCES: NotificationPreferences = { + categories: { + project: true, + payment: true, + message: true, + system: true, + }, + channels: { + inApp: true, + email: false, + push: false, + }, +}; diff --git a/src/dashboard/notifications/useNotifications.ts b/src/dashboard/notifications/useNotifications.ts new file mode 100644 index 0000000..480c1d4 --- /dev/null +++ b/src/dashboard/notifications/useNotifications.ts @@ -0,0 +1,6 @@ +import { useContext } from "react"; +import { NotificationContext } from "./NotificationContextObject"; + +export function useNotifications() { + return useContext(NotificationContext); +} From af49c79055e8b3b7bde63d47c0cda9cbdc1e1d64 Mon Sep 17 00:00:00 2001 From: Jayam Srivastava Date: Sun, 21 Jun 2026 18:13:02 +0000 Subject: [PATCH 2/2] feat: trigger welcome notification on new user signup Calls notifyWelcome() after both email and Google sign-up flows, creating a welcome notification in the user's notification center. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/Firebase/SignUp.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Firebase/SignUp.tsx b/src/Firebase/SignUp.tsx index 5ecc191..990dade 100644 --- a/src/Firebase/SignUp.tsx +++ b/src/Firebase/SignUp.tsx @@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'; import { motion, AnimatePresence } from 'motion/react'; import { createUserWithEmailAndPassword, signInWithPopup, GoogleAuthProvider } from 'firebase/auth'; import { auth } from './firebase'; +import { notifyWelcome } from '../dashboard/notifications/notificationTriggers'; import { Home, Check, X, Eye, EyeOff, AlertCircle } from 'lucide-react'; import { analysePassword, @@ -245,7 +246,8 @@ export function SignUp() { } try { - await createUserWithEmailAndPassword(auth, email, password); + const { user } = await createUserWithEmailAndPassword(auth, email, password); + notifyWelcome(user.uid, user.displayName ?? user.email ?? 'there'); navigate('/dashboard'); } catch (err: unknown) { if (typeof err === 'object' && err !== null && 'code' in err && 'message' in err) { @@ -260,7 +262,8 @@ export function SignUp() { setError(''); try { const provider = new GoogleAuthProvider(); - await signInWithPopup(auth, provider); + const { user } = await signInWithPopup(auth, provider); + notifyWelcome(user.uid, user.displayName ?? user.email ?? 'there'); navigate('/dashboard'); } catch (err: unknown) { if (typeof err === 'object' && err !== null && 'code' in err && 'message' in err) {