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
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