Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/Firebase/SignUp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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');
Comment on lines 264 to 267

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Duplicate welcome notifications for returning Google users.

signInWithPopup succeeds for both new and existing users. A returning user who uses the signup page will receive another welcome notification. Use getAdditionalUserInfo to check if the user is new before triggering the notification.

🐛 Proposed fix to only notify new users
+import { getAdditionalUserInfo } from 'firebase/auth';
 const handleGoogleSignIn = async () => {
   setError('');
   try {
     const provider = new GoogleAuthProvider();
-    const { user } = await signInWithPopup(auth, provider);
-    notifyWelcome(user.uid, user.displayName ?? user.email ?? 'there');
+    const result = await signInWithPopup(auth, provider);
+    const { user } = result;
+    if (getAdditionalUserInfo(result)?.isNewUser) {
+      notifyWelcome(user.uid, user.displayName ?? user.email ?? 'there');
+    }
     navigate('/dashboard');
   } catch (err: unknown) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const provider = new GoogleAuthProvider();
await signInWithPopup(auth, provider);
const { user } = await signInWithPopup(auth, provider);
notifyWelcome(user.uid, user.displayName ?? user.email ?? 'there');
navigate('/dashboard');
const provider = new GoogleAuthProvider();
const result = await signInWithPopup(auth, provider);
const { user } = result;
if (getAdditionalUserInfo(result)?.isNewUser) {
notifyWelcome(user.uid, user.displayName ?? user.email ?? 'there');
}
navigate('/dashboard');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Firebase/SignUp.tsx` around lines 264 - 267, The current implementation
calls notifyWelcome for all users who sign in via the GoogleAuthProvider,
including returning users. To fix this, use getAdditionalUserInfo from Firebase
to check if the user is new before sending the welcome notification. Destructure
the result of signInWithPopup to include the additional user info, then check
the isNewUser property from getAdditionalUserInfo before calling notifyWelcome.
Only invoke notifyWelcome when isNewUser is true to prevent duplicate
notifications for returning users.

} catch (err: unknown) {
if (typeof err === 'object' && err !== null && 'code' in err && 'message' in err) {
Expand Down
4 changes: 4 additions & 0 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down Expand Up @@ -180,6 +182,8 @@ export default function App() {
<Route path="resources" element={<ProjectResources />} />
<Route path="estimation" element={<ProjectEstimation />} />
<Route path="pricing-config" element={<PricingConfig />} />
<Route path="notifications" element={<NotificationCenter />} />
<Route path="notification-preferences" element={<NotificationPreferences />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
Expand Down
15 changes: 12 additions & 3 deletions src/dashboard/components/DashboardLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -179,6 +182,7 @@ export function DashboardLayout() {
);

return (
<NotificationProvider>
<div className="min-h-screen bg-slate-50 dark:bg-slate-950">
{/* Desktop sidebar */}
<aside className="fixed inset-y-0 left-0 z-30 hidden w-64 border-r border-gray-200 bg-white dark:border-slate-800 dark:bg-slate-900 lg:block">
Expand Down Expand Up @@ -220,21 +224,24 @@ export function DashboardLayout() {

{/* Main content */}
<div className="lg:pl-64">
{/* Top bar (mobile) */}
<header className="sticky top-0 z-20 flex h-14 items-center justify-between border-b border-gray-200 bg-white/80 backdrop-blur-md px-4 dark:border-slate-800 dark:bg-slate-900/80 lg:hidden">
{/* Top bar */}
<header className="sticky top-0 z-20 flex h-14 items-center justify-between border-b border-gray-200 bg-white/80 backdrop-blur-md px-4 dark:border-slate-800 dark:bg-slate-900/80">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => setSidebarOpen(true)}
aria-label="Open sidebar"
className="lg:hidden"
>
<Menu className="h-5 w-5" />
</Button>
<span className="text-sm font-semibold bg-gradient-to-r from-indigo-600 via-purple-600 to-cyan-500 bg-clip-text text-transparent">
<span className="text-sm font-semibold bg-gradient-to-r from-indigo-600 via-purple-600 to-cyan-500 bg-clip-text text-transparent lg:hidden">
Servio Dashboard
</span>
</div>
<div className="flex items-center gap-1">
<NotificationBell />
<Button
variant="ghost"
size="icon"
Expand All @@ -247,12 +254,14 @@ export function DashboardLayout() {
<Moon className="h-5 w-5 text-gray-600" />
)}
</Button>
</div>
</header>

<main className="p-4 md:p-6 lg:p-8">
<Outlet />
</main>
</div>
</div>
</NotificationProvider>
);
}
198 changes: 198 additions & 0 deletions src/dashboard/notifications/NotificationBell.tsx
Original file line number Diff line number Diff line change
@@ -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 <CheckCircle2 className="h-4 w-4 text-emerald-500 shrink-0" />;
case "warning":
return <AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />;
case "error":
return <XCircle className="h-4 w-4 text-red-500 shrink-0" />;
default:
return <Info className="h-4 w-4 text-blue-500 shrink-0" />;
}
}

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 (
<button
onClick={handleClick}
className={`w-full text-left px-4 py-3 flex items-start gap-3 hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors ${
!notification.isRead
? "bg-indigo-50/50 dark:bg-indigo-950/20"
: ""
}`}
>
<div className="mt-0.5">{typeIcon(notification.type)}</div>
<div className="flex-1 min-w-0">
<p
className={`text-sm leading-tight ${
!notification.isRead
? "font-semibold text-gray-900 dark:text-gray-100"
: "font-medium text-gray-700 dark:text-gray-300"
}`}
>
{notification.title}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 line-clamp-2">
{notification.message}
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
{formatDistanceToNow(notification.createdAt, { addSuffix: true })}
</p>
</div>
{notification.actionUrl && (
<ExternalLink className="h-3.5 w-3.5 text-gray-400 shrink-0 mt-1" />
)}
{!notification.isRead && (
<div className="h-2 w-2 rounded-full bg-indigo-500 shrink-0 mt-1.5" />
)}
</button>
);
}

export function NotificationBell() {
const [open, setOpen] = useState(false);
const panelRef = useRef<HTMLDivElement>(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 (
<div className="relative" ref={panelRef}>
<Button
variant="ghost"
size="icon"
onClick={() => setOpen((prev) => !prev)}
className="relative"
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ""}`}
>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute -top-0.5 -right-0.5 flex h-4.5 w-4.5 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white ring-2 ring-white dark:ring-slate-900"
>
{unreadCount > 99 ? "99+" : unreadCount}
</motion.span>
)}
</Button>

<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -8, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.96 }}
transition={{ duration: 0.15 }}
className="absolute right-0 top-full mt-2 w-[360px] max-h-[480px] rounded-xl border border-gray-200 bg-white shadow-xl dark:border-slate-700 dark:bg-slate-900 z-50 flex flex-col overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-slate-800">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Notifications
</h3>
{unreadCount > 0 && (
<button
onClick={() => markAllAsRead()}
className="flex items-center gap-1 text-xs font-medium text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300"
>
<CheckCheck className="h-3.5 w-3.5" />
Mark all read
</button>
)}
</div>

{/* Body */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent" />
</div>
) : recentNotifications.length === 0 ? (
<div className="flex flex-col items-center py-12 px-4 text-center">
<Bell className="h-10 w-10 text-gray-300 dark:text-gray-600 mb-3" />
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
No notifications yet
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
We'll notify you when something important happens.
</p>
</div>
) : (
<div className="divide-y divide-gray-100 dark:divide-slate-800">
{recentNotifications.map((n) => (
<NotificationItem
key={n.id}
notification={n}
onRead={markAsRead}
onNavigate={() => setOpen(false)}
/>
))}
</div>
)}
</div>

{/* Footer */}
{notifications.length > 0 && (
<div className="border-t border-gray-100 dark:border-slate-800 px-4 py-2.5">
<Link
to="/dashboard/notifications"
onClick={() => 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
</Link>
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
Loading
Loading