PreRelease v1

This commit is contained in:
2026-03-25 13:52:45 +01:00
parent 94b0c68703
commit 6bf998a52a
56 changed files with 11427 additions and 847 deletions

View File

@@ -8,13 +8,15 @@ import {
Lock,
Check,
Zap,
Link,
Link2,
FileText,
MessageSquare,
Shield,
TrendingUp,
Bell,
} from "lucide-react";
import Link from "next/link";
import { SignedIn, SignedOut } from "@clerk/nextjs";
// Ripple Effect Component
function BackgroundRipple() {
@@ -407,7 +409,7 @@ export function Hero() {
{ icon: Lock, label: "Bank-Level Security" },
{ icon: Check, label: "GDPR Certified" },
{ icon: Zap, label: "Real-Time AI" },
{ icon: Link, label: "Blockchain Verified" },
{ icon: Link2, label: "Blockchain Verified" },
];
return (
@@ -544,8 +546,10 @@ export function Hero() {
}`}
>
{/* Primary CTA */}
<Button
className="
<SignedOut>
<Button
asChild
className="
group relative px-12 py-5
text-lg md:text-xl font-semibold
text-white rounded-2xl
@@ -560,10 +564,11 @@ export function Hero() {
hover:shadow-xl hover:shadow-blue-500/40
active:scale-[0.98]
"
>
{/* Glow background layer */}
<div
className="
>
<Link href="/sign-in">
{/* Glow background layer */}
<div
className="
absolute inset-0
opacity-0 group-hover:opacity-100
transition-opacity duration-500
@@ -573,31 +578,89 @@ export function Hero() {
to-teal-400/20
blur-xl
"
/>
/>
{/* Animated gradient shift layer */}
<div
className="
{/* Animated gradient shift layer */}
<div
className="
absolute inset-0
bg-[length:200%_200%]
animate-gradient-shift
opacity-70
mix-blend-overlay
"
/>
/>
<span className="relative z-10 flex items-center gap-3">
Get Started
<Rocket
className="
<span className="relative z-10 flex items-center gap-3">
Get Started
<Rocket
className="
w-6 h-6
transition-transform duration-300
group-hover:translate-x-1
group-hover:-translate-y-1
"
/>
</span>
</Button>
/>
</span>
</Link>
</Button>
</SignedOut>
<SignedIn>
<Button
asChild
className="
group relative px-12 py-5
text-lg md:text-xl font-semibold
text-white rounded-2xl
overflow-hidden
bg-gradient-to-r
from-[hsl(var(--primary))]
via-[hsl(var(--accent))]
to-[hsl(var(--secondary))]
shadow-lg shadow-blue-500/20
transition-all duration-300 ease-out
hover:scale-[1.04]
hover:shadow-xl hover:shadow-blue-500/40
active:scale-[0.98]
"
>
<Link href="/dashboard">
<div
className="
absolute inset-0
opacity-0 group-hover:opacity-100
transition-opacity duration-500
bg-gradient-to-r
from-blue-400/20
via-purple-400/20
to-teal-400/20
blur-xl
"
/>
<div
className="
absolute inset-0
bg-[length:200%_200%]
animate-gradient-shift
opacity-70
mix-blend-overlay
"
/>
<span className="relative z-10 flex items-center gap-3">
Go To Dashboard
<Rocket
className="
w-6 h-6
transition-transform duration-300
group-hover:translate-x-1
group-hover:-translate-y-1
"
/>
</span>
</Link>
</Button>
</SignedIn>
</div>
{/* Trust Indicators */}

View File

@@ -440,7 +440,7 @@ export function HowItWorks() {
number: "02",
title: "AI Analysis",
description:
"GPT-4 Turbo extracts and analyzes every clause, term, and detail automatically.",
"AI extracts and analyzes every clause, term, and detail automatically.",
icon: Cpu,
gradient: "bg-gradient-to-br from-violet-500 to-purple-600",
glowColor: "rgba(139, 92, 246, 0.5)",

View File

@@ -3,8 +3,16 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { ModeToggle } from "@/components/ui/mode-toggle";
import { Sparkles, X, ArrowRight } from "lucide-react";
import { X, ArrowRight } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import {
SignedIn,
SignedOut,
SignInButton,
SignUpButton,
UserButton,
} from "@clerk/nextjs";
const navLinks = [
{ label: "Features", href: "#features" },
@@ -17,12 +25,6 @@ export function Navbar() {
const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [activeLink, setActiveLink] = useState("");
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 20);
@@ -46,26 +48,6 @@ export function Navbar() {
setIsMobileMenuOpen(false);
}
};
if (!mounted) {
return (
<nav className="fixed top-0 left-0 right-0 z-50 mt-6 px-4">
<div className="max-w-6xl mx-auto">
<div className="glass rounded-full px-8 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Sparkles className="w-6 h-6 text-blue-600" />
<span className="text-xl font-bold gradient-text">
LexiChain
</span>
</div>
</div>
</div>
</div>
</nav>
);
}
return (
<>
<nav
@@ -100,44 +82,15 @@ export function Navbar() {
}}
>
<div className="relative flex items-center justify-center">
{/* Subtle background badge */}
<div
className="
absolute inset-0
rounded-xl
bg-gradient-to-tr
from-blue-500/10
via-purple-500/10
to-teal-500/10
opacity-0
group-hover:opacity-100
transition-opacity duration-300
"
/>
{/* Logo */}
<div className="absolute inset-0 rounded-xl bg-gradient-to-tr from-blue-500/10 via-purple-500/10 to-teal-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<Image
src="/LexiChain.png"
alt="LexiChain Logo"
width={34}
height={34}
className="
relative z-10
w-8 h-8 object-contain
transition-all duration-300 ease-out
group-hover:scale-110
"
className="relative z-10 w-8 h-8 object-contain transition-all duration-300 ease-out group-hover:scale-110"
/>
</div>
<span
className="
text-lg font-semibold
gradient-text
sm:hidden
"
>
LC
</span>
</a>
{/* Desktop Navigation */}
@@ -163,46 +116,45 @@ export function Navbar() {
{/* Right Section */}
<div className="flex items-center gap-2 md:gap-3">
{/* Theme Toggle */}
<ModeToggle />
{/* Global Clerk context usage */}
<SignedOut>
<SignInButton mode="modal">
<Button variant="outline" className="hidden md:flex">
Sign In
</Button>
</SignInButton>
{/* Sign In - Desktop */}
<Button
variant="outline"
className="hidden md:flex items-center gap-2 px-5 py-2 text-sm font-medium text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-600 rounded-full hover:bg-slate-50 dark:hover:bg-slate-800 transition-all duration-200"
>
Sign In
</Button>
<SignUpButton mode="modal">
<Button className="hidden md:flex btn-gradient">
Get Started
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</SignUpButton>
</SignedOut>
<SignedIn>
<Link href="/dashboard" className="hidden md:inline-block">
<Button variant="outline">Dashboard</Button>
</Link>
{/* Get Started - Desktop */}
<Button className="hidden md:flex items-center gap-2 px-6 py-2.5 text-sm font-semibold text-white btn-gradient rounded-full group">
Get Started
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</Button>
<UserButton afterSignOutUrl="/" />
</SignedIn>
{/* Mobile Menu Button */}
<Button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className="lg:hidden p-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors duration-200"
aria-label="Toggle menu"
className="lg:hidden"
variant="ghost"
size="icon"
>
<div className="relative w-6 h-6">
<span
className={`absolute left-0 w-6 h-0.5 bg-slate-700 dark:bg-slate-300 transition-all duration-300 ${
isMobileMenuOpen ? "top-3 rotate-45" : "top-1"
}`}
className={`absolute left-0 w-6 h-0.5 bg-current transition-all ${isMobileMenuOpen ? "top-3 rotate-45" : "top-1"}`}
/>
<span
className={`absolute left-0 top-3 w-6 h-0.5 bg-slate-700 dark:bg-slate-300 transition-all duration-300 ${
isMobileMenuOpen ? "opacity-0" : "opacity-100"
}`}
className={`absolute left-0 top-3 w-6 h-0.5 bg-current transition-all ${isMobileMenuOpen ? "opacity-0" : "opacity-100"}`}
/>
<span
className={`absolute left-0 w-6 h-0.5 bg-slate-700 dark:bg-slate-300 transition-all duration-300 ${
isMobileMenuOpen ? "top-3 -rotate-45" : "top-5"
}`}
className={`absolute left-0 w-6 h-0.5 bg-current transition-all ${isMobileMenuOpen ? "top-3 -rotate-45" : "top-5"}`}
/>
</div>
</Button>
@@ -214,27 +166,20 @@ export function Navbar() {
{/* Mobile Menu */}
<div
className={`fixed inset-0 z-40 lg:hidden transition-all duration-500 ${
isMobileMenuOpen ? "opacity-100 visible" : "opacity-0 invisible"
}`}
className={`fixed inset-0 z-40 lg:hidden transition-all duration-500 ${isMobileMenuOpen ? "opacity-100 visible" : "opacity-0 invisible"}`}
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setIsMobileMenuOpen(false)}
/>
{/* Menu Panel */}
<div
className={`absolute right-0 top-0 h-full w-80 max-w-full bg-white dark:bg-slate-900 shadow-2xl transition-transform duration-500 ${
isMobileMenuOpen ? "translate-x-0" : "translate-x-full"
}`}
className={`absolute right-0 top-0 h-full w-80 max-w-full bg-white dark:bg-slate-900 shadow-2xl transition-transform duration-500 ${isMobileMenuOpen ? "translate-x-0" : "translate-x-full"}`}
>
<div className="p-6 pt-20">
<Button
onClick={() => setIsMobileMenuOpen(false)}
className="absolute top-6 right-6 p-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
aria-label="Close menu"
className="absolute top-6 right-6"
variant="ghost"
size="icon"
>
@@ -242,36 +187,54 @@ export function Navbar() {
</Button>
<div className="flex flex-col gap-4">
{navLinks.map((link, index) => (
{navLinks.map((link) => (
<a
key={link.href}
href={link.href}
onClick={(e) => handleLinkClick(e, link.href)}
className="px-4 py-3 text-lg font-medium text-slate-700 dark:text-slate-300 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800 transition-all duration-200"
style={{
animationDelay: `${index * 0.1}s`,
animation: isMobileMenuOpen
? "slide-up 0.4s ease-out forwards"
: "none",
opacity: isMobileMenuOpen ? 1 : 0,
}}
className="px-4 py-3 text-lg font-medium"
>
{link.label}
</a>
))}
</div>
<div className="mt-8 flex flex-col gap-3">
<Button
variant="outline"
className="w-full px-5 py-3 text-sm font-medium text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-600 rounded-full hover:bg-slate-50 dark:hover:bg-slate-800 transition-all duration-200"
>
Sign In
</Button>
<Button className="w-full flex items-center justify-center gap-2 px-6 py-3 text-sm font-semibold text-white btn-gradient rounded-full">
Get Started
<ArrowRight className="w-4 h-4" />
</Button>
<div className="mt-4 border-t border-slate-200/70 dark:border-slate-700/70 pt-5 space-y-3">
<SignedOut>
<Link
href="/sign-in"
onClick={() => setIsMobileMenuOpen(false)}
>
<Button variant="outline" className="w-full">
Sign In
</Button>
</Link>
<Link
href="/sign-up"
onClick={() => setIsMobileMenuOpen(false)}
>
<Button className="w-full btn-gradient">
Get Started
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</Link>
</SignedOut>
<SignedIn>
<Link
href="/dashboard"
onClick={() => setIsMobileMenuOpen(false)}
className="block"
>
<Button className="w-full">Go to Dashboard</Button>
</Link>
<div className="flex items-center justify-between rounded-xl border border-slate-200/70 dark:border-slate-700/70 px-3 py-2">
<span className="text-sm text-slate-600 dark:text-slate-300">
Account
</span>
<UserButton afterSignOutUrl="/" />
</div>
</SignedIn>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,295 @@
"use client";
import { useMemo } from "react";
import {
AreaChart,
Area,
BarChart,
Bar,
Line,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
type TrendData = Array<{ date: string; count: number }>;
type TypeData = Array<{ type: string; count: number }>;
type StatusData = Array<{ name: string; count: number }>;
const PIE_COLORS: Record<string, string> = {
Uploaded: "hsl(38 92% 50%)",
Processing: "hsl(var(--primary))",
Analyzed: "hsl(160 84% 39%)",
Failed: "hsl(var(--destructive))",
};
const FALLBACK_COLORS = [
"hsl(var(--primary))",
"hsl(var(--secondary))",
"hsl(var(--accent))",
"hsl(var(--destructive))",
];
const tooltipStyle = {
backgroundColor: "hsl(var(--background))",
border: "1px solid hsl(var(--border))",
borderRadius: "12px",
color: "hsl(var(--foreground))",
};
export function TrendChart({ data }: { data: TrendData }) {
const trendData = useMemo(
() =>
data.map((point, index) => {
const start = Math.max(0, index - 6);
const window = data.slice(start, index + 1);
const average =
window.reduce((sum, item) => sum + item.count, 0) / window.length;
return {
...point,
movingAverage: Number(average.toFixed(2)),
};
}),
[data],
);
const xAxisInterval =
trendData.length > 12 ? Math.floor(trendData.length / 8) : 0;
return (
<div className="w-full h-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={trendData}
margin={{ top: 10, right: 10, left: -24, bottom: 0 }}
>
<defs>
<linearGradient id="trendFill" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="hsl(var(--primary))"
stopOpacity={0.65}
/>
<stop
offset="95%"
stopColor="hsl(var(--primary))"
stopOpacity={0.05}
/>
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
vertical={false}
/>
<XAxis
dataKey="date"
stroke="hsl(var(--muted-foreground))"
interval={xAxisInterval}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="hsl(var(--muted-foreground))"
allowDecimals={false}
tickLine={false}
axisLine={false}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(
value: number | string | undefined,
name: string | number | undefined,
) => {
const numericValue = Number(value ?? 0);
if (name === "movingAverage") {
return [numericValue.toFixed(1), "7-day avg"];
}
return [numericValue, "Uploads"];
}}
/>
<Area
type="monotone"
dataKey="count"
stroke="hsl(var(--primary))"
strokeWidth={2.25}
fillOpacity={1}
fill="url(#trendFill)"
activeDot={{ r: 5 }}
/>
<Line
type="monotone"
dataKey="movingAverage"
stroke="hsl(var(--secondary))"
strokeWidth={2}
dot={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}
export function ContractTypeChart({ data }: { data: TypeData }) {
const sortedData = useMemo(
() => [...data].sort((a, b) => b.count - a.count),
[data],
);
return (
<div className="w-full h-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={sortedData}
layout="vertical"
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
horizontal={false}
/>
<XAxis
type="number"
stroke="hsl(var(--muted-foreground))"
allowDecimals={false}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
/>
<YAxis
type="category"
dataKey="type"
width={128}
stroke="hsl(var(--muted-foreground))"
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
/>
<Tooltip
contentStyle={tooltipStyle}
cursor={false}
formatter={(value: number | string | undefined) => [
Number(value ?? 0),
"Files",
]}
/>
<Bar dataKey="count" radius={[0, 8, 8, 0]}>
{sortedData.map((item, index) => {
const opacity = Math.max(0.35, 0.95 - index * 0.12);
return (
<Cell
key={`${item.type}-${index}`}
fill={`hsl(var(--primary) / ${opacity})`}
/>
);
})}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
}
export function ContractStatusChart({ data }: { data: StatusData }) {
const total = useMemo(
() => data.reduce((sum, item) => sum + item.count, 0),
[data],
);
return (
<div className="w-full h-full flex flex-col">
<div className="h-[76%] w-full">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={62}
outerRadius={94}
paddingAngle={3}
dataKey="count"
stroke="hsl(var(--background))"
strokeWidth={2}
>
{data.map((entry, index) => (
<Cell
key={`${entry.name}-${index}`}
fill={
PIE_COLORS[entry.name] ??
FALLBACK_COLORS[index % FALLBACK_COLORS.length]
}
/>
))}
</Pie>
{total > 0 && (
<text
x="50%"
y="50%"
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x="50%"
y="50%"
className="fill-foreground text-base font-semibold"
>
{total}
</tspan>
<tspan
x="50%"
dy="16"
className="fill-muted-foreground text-[11px]"
>
Files
</tspan>
</text>
)}
<Tooltip
contentStyle={tooltipStyle}
formatter={(value: number | string | undefined) => [
Number(value ?? 0),
"Files",
]}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="grid grid-cols-2 gap-2 pt-2">
{data.map((item, index) => {
const color =
PIE_COLORS[item.name] ??
FALLBACK_COLORS[index % FALLBACK_COLORS.length];
return (
<div
key={`${item.name}-legend`}
className="flex items-center gap-2 rounded-lg border border-border/50 bg-muted/25 px-2.5 py-1.5"
>
<span
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="text-[11px] text-muted-foreground truncate">
{item.name}
</span>
<span className="ml-auto text-[11px] font-medium text-foreground">
{item.count}
</span>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,44 @@
"use client";
import React from "react";
import Link from "next/link";
import { ArrowLeft, ShieldCheck, Sparkles } from "lucide-react";
import { BackgroundBeams } from "@/components/ui/background-beams";
export function ContactsHeader() {
return (
<div className="border-b border-border/50 bg-background/80 backdrop-blur-sm">
<BackgroundBeams className="opacity-70" />
<div className="max-w-7xl mx-auto px-6 py-8 space-y-6">
<Link
href="/dashboard"
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Back to Dashboard
</Link>
<div className="space-y-3">
<h1 className="text-4xl md:text-5xl font-semibold tracking-tight bg-gradient-to-r from-primary via-accent to-secondary bg-clip-text text-transparent">
Contracts Manager
</h1>
<p className="max-w-3xl text-lg text-muted-foreground">
Upload, review, and analyze your financial contracts with a focused
workspace built for speed and clarity.
</p>
</div>
<div className="flex flex-wrap gap-3 pt-1">
<div className="flex items-center gap-2 rounded-full border border-primary/20 bg-primary/10 px-4 py-2 text-sm font-medium text-primary">
<Sparkles className="w-4 h-4" />
AI-powered review
</div>
<div className="flex items-center gap-2 rounded-full border border-emerald-400/20 bg-emerald-400/10 px-4 py-2 text-sm font-medium text-emerald-500">
<ShieldCheck className="w-4 h-4" />
Compliance-focused workflow
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,141 @@
"use client";
import { UploadDropzone } from "@uploadthing/react";
import { AlertCircle, Sparkles, Wand2, ShieldCheck } from "lucide-react";
import { Card } from "@/components/ui/card";
import { saveContract } from "@/lib/actions/contract.action";
import { toast } from "sonner";
import type { OurFileRouter } from "@/lib/upload";
import { useRouter } from "next/navigation";
export function ContractUploadForm({
onUploadSuccess,
}: {
onUploadSuccess: () => void;
}) {
const router = useRouter();
const emitNotificationRefresh = () => {
window.dispatchEvent(new Event("notifications:refresh"));
const channel = new BroadcastChannel("notifications-channel");
channel.postMessage({ type: "notifications:refresh" });
channel.close();
};
return (
<Card className="relative overflow-hidden border border-border/60 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary)/0.14),transparent_45%),radial-gradient(circle_at_bottom_left,hsl(var(--secondary)/0.1),transparent_42%)] p-0">
<div className="pointer-events-none absolute inset-0">
<svg
viewBox="0 0 480 220"
fill="none"
aria-hidden="true"
className="absolute -right-8 top-0 h-40 w-80 opacity-45"
>
<path
d="M8 176C76 140 114 68 198 68C260 68 286 116 346 116C394 116 430 88 474 74"
stroke="hsl(var(--primary))"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M10 204C72 174 122 146 186 146C250 146 294 178 350 178C400 178 434 162 474 146"
stroke="hsl(var(--secondary))"
strokeWidth="2"
strokeDasharray="5 7"
strokeLinecap="round"
/>
</svg>
</div>
<div className="relative p-6 md:p-8">
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<p className="inline-flex items-center gap-1.5 rounded-full border border-primary/25 bg-primary/10 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.14em] text-primary">
<Sparkles className="h-3.5 w-3.5" />
AI-Ready Intake
</p>
<h3 className="mt-3 text-xl font-semibold tracking-tight text-foreground">
Upload contracts for structured extraction
</h3>
<p className="mt-1 text-sm text-muted-foreground">
Clean intake pipeline for contract parsing, validation, and
analysis.
</p>
</div>
<div className="flex items-center gap-2 rounded-xl border border-border/60 bg-background/70 px-3 py-2 text-xs text-muted-foreground">
<ShieldCheck className="h-4 w-4 text-emerald-500" />
Theme-aware secure upload
</div>
</div>
<UploadDropzone<OurFileRouter, "contractUploader">
endpoint="contractUploader"
onClientUploadComplete={async (res) => {
if (!res || res.length === 0) {
toast.error("Upload failed");
return;
}
const file = res[0];
// Save to database
const result = await saveContract({
fileName: file.name,
fileUrl: file.url,
fileSize: file.size,
mimeType: file.type,
});
if (result.success) {
toast.success("Contract uploaded successfully!");
emitNotificationRefresh();
onUploadSuccess();
router.refresh();
} else {
toast.error(result.error || "Failed to save contract");
}
}}
onUploadError={(error: Error) => {
toast.error(`Upload failed: ${error.message}`);
}}
appearance={{
container:
"w-full cursor-pointer rounded-2xl border border-dashed border-primary/35 bg-background/85 px-4 py-8 backdrop-blur-sm transition-all duration-300 hover:border-primary/55 hover:bg-background ut-uploading:cursor-not-allowed",
button:
"bg-gradient-to-r from-primary to-accent text-white font-semibold px-6 py-3 rounded-xl transition-all duration-300 hover:from-primary/90 hover:to-accent/90 ut-uploading:cursor-not-allowed",
label: "text-base md:text-lg text-foreground font-semibold",
uploadIcon: "w-11 h-11 text-primary",
allowedContent: "mt-2 text-sm text-muted-foreground",
}}
content={{
label: "Upload Your Contract",
allowedContent: "PDF, JPG, PNG, WEBP up to 8MB",
}}
/>
<div className="mt-6 grid gap-3 border-t border-border/50 pt-5 sm:grid-cols-3">
<div className="rounded-xl border border-border/50 bg-muted/25 px-3 py-2 text-xs text-muted-foreground">
<div className="mb-1 font-semibold text-foreground">Formats</div>
<div>PDF, JPG, PNG, WEBP</div>
</div>
<div className="rounded-xl border border-border/50 bg-muted/25 px-3 py-2 text-xs text-muted-foreground">
<div className="mb-1 font-semibold text-foreground">Max Size</div>
<div>8 MB</div>
</div>
<div className="rounded-xl border border-border/50 bg-muted/25 px-3 py-2 text-xs text-muted-foreground flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-accent" />
<div>
<div className="mb-1 font-semibold text-foreground">AI Flow</div>
<div>Upload first, then click Analyze when ready</div>
</div>
</div>
</div>
<div className="mt-4 inline-flex items-center gap-2 rounded-lg border border-border/50 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
<Wand2 className="h-3.5 w-3.5 text-secondary" />
Extraction quality improves as more contracts are analyzed.
</div>
</div>
</Card>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
"use client";
import { FileText, Inbox } from "lucide-react";
import { Card } from "@/components/ui/card";
export function EmptyContractsState() {
return (
<Card className="border-dashed border-border hover:border-primary/50 transition-colors duration-300">
<div className="p-12 md:p-16 flex flex-col items-center justify-center min-h-[300px]">
<div className="mb-6 relative">
<div className="absolute inset-0 bg-gradient-to-r from-primary/20 to-accent/20 rounded-full blur-3xl"></div>
<div className="relative p-4 bg-background dark:bg-card rounded-full border border-border/50">
<Inbox className="w-8 h-8 text-muted-foreground" />
</div>
</div>
<div className="text-center max-w-md">
<h3 className="text-xl font-semibold text-foreground mb-2">
No contracts yet
</h3>
<p className="text-sm text-muted-foreground mb-1">
Upload your first contract to get started.
</p>
<p className="text-xs text-muted-foreground">
Our AI will automatically analyze and extract key information from
your documents.
</p>
</div>
<div className="mt-8 grid grid-cols-3 gap-4 w-full text-center text-xs">
<div className="p-3 rounded-lg bg-primary/5 dark:bg-primary/10 border border-primary/20">
<FileText className="w-5 h-5 mx-auto mb-2 text-primary" />
<span className="text-muted-foreground">Fast Upload</span>
</div>
<div className="p-3 rounded-lg bg-accent/5 dark:bg-accent/10 border border-accent/20">
<FileText className="w-5 h-5 mx-auto mb-2 text-accent" />
<span className="text-muted-foreground">AI Analysis</span>
</div>
<div className="p-3 rounded-lg bg-secondary/5 dark:bg-secondary/10 border border-secondary/20">
<FileText className="w-5 h-5 mx-auto mb-2 text-secondary" />
<span className="text-muted-foreground">Blockchain</span>
</div>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
import React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { BarChart3, FileText, LogOut } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SignOutButton, UserButton } from "@clerk/nextjs";
import { motion } from "motion/react";
import Image from "next/image";
import { ModeToggle } from "@/components/ui/mode-toggle";
import NotificationBar from "./notification-bar";
interface NavItem {
href: string;
label: string;
icon: React.ReactNode;
description: string;
}
const navItems: NavItem[] = [
{
href: "/dashboard",
label: "Analytics",
icon: <BarChart3 className="w-5 h-5" />,
description: "View your statistics",
},
{
href: "/contacts",
label: "Contracts",
icon: <FileText className="w-5 h-5" />,
description: "Manage contracts",
},
];
export function DashboardNavigation() {
const pathname = usePathname();
return (
<div className="fixed left-0 top-0 h-screen w-72 border-r border-border/60 bg-background/95 backdrop-blur-xl flex flex-col overflow-hidden">
<div className="pointer-events-none absolute inset-0">
<div className="absolute -top-16 left-1/2 h-44 w-44 -translate-x-1/2 rounded-full bg-primary/12 blur-3xl" />
<svg
viewBox="0 0 320 400"
fill="none"
aria-hidden="true"
className="absolute bottom-0 left-0 w-full opacity-50"
>
<path
d="M0 338C56 312 82 252 142 252C194 252 214 302 266 302C294 302 306 290 320 276"
stroke="hsl(var(--primary))"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M0 372C52 342 88 322 132 322C176 322 212 346 252 346C282 346 304 338 320 326"
stroke="hsl(var(--secondary))"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray="4 7"
/>
</svg>
</div>
{/* Logo section */}
<div className="relative z-10 p-6 border-b border-border/40">
<Link href="/dashboard" className="flex items-center gap-3 group">
<Image
src="/LexiChain.png"
alt="LexiCHAIN Logo"
width={32}
height={32}
className="w-8 h-8"
/>
<div className="flex flex-col">
<span className="font-semibold text-foreground">LexiChain</span>
<span className="text-xs text-muted-foreground">
Operations Console
</span>
</div>
</Link>
</div>
{/* Navigation items */}
<nav className="relative z-10 flex-1 p-4 space-y-2 overflow-y-auto">
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<motion.div
key={item.href}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3 }}
>
<Link href={item.href}>
<div
className={`relative p-3 rounded-lg transition-all duration-200 cursor-pointer group ${
isActive
? "bg-primary/10 border border-primary/20"
: "hover:bg-muted/60 border border-transparent"
}`}
>
{isActive && (
<motion.div
layoutId="active-nav"
className="absolute inset-0 rounded-lg bg-primary/5"
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
/>
)}
<div className="relative z-10 flex items-center gap-3">
<span
className={`transition-colors ${
isActive
? "text-primary"
: "text-muted-foreground group-hover:text-foreground"
}`}
>
{item.icon}
</span>
<div className="flex-1">
<div
className={`font-medium text-sm ${isActive ? "text-foreground" : "text-muted-foreground group-hover:text-foreground"}`}
>
{item.label}
</div>
<div className="text-xs text-muted-foreground">
{item.description}
</div>
</div>
</div>
</div>
</Link>
</motion.div>
);
})}
</nav>
{/* Bottom section */}
<div className="relative z-10 p-4 border-t border-border/40 space-y-3">
<div className="rounded-xl border border-border/60 bg-gradient-to-r from-muted/35 via-background to-muted/25 px-3 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<UserButton afterSignOutUrl="/" />
</div>
<div className="flex items-center gap-2">
<NotificationBar />
<ModeToggle />
</div>
</div>
<div className="mt-2 flex items-center gap-2 text-[11px] text-muted-foreground">
<span className="h-2 w-2 rounded-full bg-emerald-500" />
Trusted workspace
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,617 @@
/**
* Notification Bar Component
*
* Displays a beautiful notification bar/dropdown for showing:
* - Unread notifications count
* - Recent notifications from users' actions
* - Deadline/renewal alerts
* - Notification management (mark as read, delete)
*
* Features:
* - Real-time badge count
* - Smooth animations
* - Theme support (dark/light mode)
* - Responsive design
* - Auto-refresh on intervals
*/
"use client";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import {
Bell,
X,
Check,
CheckCheck,
AlertCircle,
AlertTriangle,
CheckCircle2,
Info,
Clock,
Trash2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
getNotifications,
getUnreadNotificationCount,
markNotificationAsRead,
markAllNotificationsAsRead,
deleteNotification,
checkDeadlineNotifications,
} from "@/lib/actions/notification.action";
import { cn } from "@/lib/utils";
/**
* Notification type interface matching the database structure
*/
interface Notification {
id: string;
type: "SUCCESS" | "WARNING" | "ERROR" | "INFO" | "DEADLINE";
title: string;
message: string;
read: boolean;
createdAt: string;
icon?: string;
actionType?: string;
contractId?: string;
contract?: {
id: string;
title: string;
fileName: string;
endDate?: string;
};
}
/**
* Returns the appropriate icon component based on notification type
*
* Mapping:
* - SUCCESS: CheckCircle2 (green)
* - WARNING: AlertTriangle (yellow/orange)
* - ERROR: AlertCircle (red)
* - INFO: Info (blue)
* - DEADLINE: Clock (critical/urgent)
*/
const getNotificationIcon = (type: Notification["type"], icon?: string) => {
switch (type) {
case "SUCCESS":
return <CheckCircle2 className="h-5 w-5 text-green-500" />;
case "WARNING":
return <AlertTriangle className="h-5 w-5 text-yellow-500" />;
case "ERROR":
return <AlertCircle className="h-5 w-5 text-red-500" />;
case "DEADLINE":
return <Clock className="h-5 w-5 text-red-500" />;
case "INFO":
default:
return <Info className="h-5 w-5 text-blue-500" />;
}
};
/**
* Returns the color styling based on notification type
* Used for background and border colors in the notification card
*/
const getNotificationColor = (type: Notification["type"]) => {
switch (type) {
case "SUCCESS":
return "bg-green-50 border-green-200 dark:bg-green-950/30 dark:border-green-800";
case "WARNING":
return "bg-yellow-50 border-yellow-200 dark:bg-yellow-950/30 dark:border-yellow-800";
case "ERROR":
return "bg-red-50 border-red-200 dark:bg-red-950/30 dark:border-red-800";
case "DEADLINE":
return "bg-red-50 border-red-200 dark:bg-red-950/30 dark:border-red-800";
case "INFO":
default:
return "bg-blue-50 border-blue-200 dark:bg-blue-950/30 dark:border-blue-800";
}
};
/**
* Single Notification Item Component
*
* Displays an individual notification with:
* - Type-specific icon and coloring
* - Title and message
* - Timestamp
* - Action buttons (mark as read, delete)
* - Visual indicator for unread status
*/
const NotificationItem: React.FC<{
notification: Notification;
onRead: (id: string) => void;
onDelete: (id: string) => void;
}> = ({ notification, onRead, onDelete }) => {
const formatTime = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
};
return (
<Card
className={cn(
"flex items-start gap-3 p-3 border transition-all hover:shadow-sm",
getNotificationColor(notification.type),
!notification.read && "ring-1 ring-primary/20",
)}
>
{/* Notification Icon */}
<div className="mt-1 flex-shrink-0">
{getNotificationIcon(notification.type, notification.icon)}
</div>
{/* Notification Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-semibold text-sm text-foreground line-clamp-1">
{notification.title}
</p>
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{notification.message}
</p>
{/* Contract link if available */}
{notification.contract && (
<p className="text-xs text-muted-foreground mt-2">
📄 {notification.contract.title}
</p>
)}
</div>
{/* Unread dot indicator */}
{!notification.read && (
<div className="h-2 w-2 rounded-full bg-primary flex-shrink-0 mt-1" />
)}
</div>
{/* Timestamp */}
<p className="text-xs text-muted-foreground mt-2">
{formatTime(notification.createdAt)}
</p>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-1 flex-shrink-0">
{!notification.read && (
<button
onClick={() => onRead(notification.id)}
className="p-1.5 hover:bg-white/20 dark:hover:bg-white/10 rounded-md transition-colors"
title="Mark as read"
>
<Check className="h-4 w-4" />
</button>
)}
<button
onClick={() => onDelete(notification.id)}
className="p-1.5 hover:bg-white/20 dark:hover:bg-white/10 rounded-md transition-colors"
title="Delete notification"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</Card>
);
};
/**
* Main Notification Bar Component
*
* Displays:
* 1. Bell icon with unread count badge
* 2. Dropdown showing recent notifications
* 3. Ability to mark as read individually or all at once
* 4. Action buttons and time formatting
*/
export default function NotificationBar() {
// State management
const [isMounted, setIsMounted] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const [panelPosition, setPanelPosition] = useState({ top: 70, left: 250 });
const channelRef = useRef<BroadcastChannel | null>(null);
const updatePanelPosition = useCallback(() => {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const panelWidth = 420;
const panelHeightEstimate = panelRef.current?.offsetHeight ?? 240;
const viewportPadding = 12;
const preferredLeft = rect.right + 14;
const maxLeft = window.innerWidth - panelWidth - viewportPadding;
const left = Math.max(viewportPadding, Math.min(preferredLeft, maxLeft));
// Keep the panel visually balanced around the bell row,
// with a slightly higher anchor so it does not feel too low.
const triggerCenterY = (rect.top + rect.bottom) / 2;
const preferredTop = triggerCenterY - panelHeightEstimate * 0.42;
const maxTop = window.innerHeight - viewportPadding - panelHeightEstimate;
const top = Math.max(viewportPadding, Math.min(preferredTop, maxTop));
setPanelPosition({ top, left });
}, []);
/**
* Fetches unread notifications and deadline checks
*
* Steps:
* 1. Check for upcoming deadlines and create notifications
* 2. Fetch unread notifications from database
* 3. Update state with fresh notification data
* 4. Get unread count for badge display
*/
const fetchNotifications = useCallback(
async (options?: { showLoader?: boolean }) => {
const showLoader = options?.showLoader ?? false;
try {
if (showLoader) {
setIsLoading(true);
}
// Fetch unread notifications
const response = await getNotifications(15);
if (response.success) {
setNotifications(response.data || []);
}
// Update unread count
const countResponse = await getUnreadNotificationCount();
if (countResponse.success) {
setUnreadCount(countResponse.data?.count || 0);
}
} catch (error) {
console.error("Error fetching notifications:", error);
} finally {
if (showLoader) {
setIsLoading(false);
}
}
},
[],
);
const notifyRealtimeRefresh = useCallback(() => {
void fetchNotifications({ showLoader: false });
}, [fetchNotifications]);
const broadcastRealtimeRefresh = useCallback(() => {
const event = new Event("notifications:refresh");
window.dispatchEvent(event);
channelRef.current?.postMessage({ type: "notifications:refresh" });
}, []);
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
if (!isMounted) return;
// Cross-tab realtime notifications without polling loops.
const channel = new BroadcastChannel("notifications-channel");
channelRef.current = channel;
const onMessage = (message: MessageEvent<{ type?: string }>) => {
if (message.data?.type === "notifications:refresh") {
void fetchNotifications({ showLoader: false });
}
};
channel.addEventListener("message", onMessage);
return () => {
channel.removeEventListener("message", onMessage);
channel.close();
channelRef.current = null;
};
}, [isMounted, fetchNotifications]);
/**
* Handles marking a notification as read
*
* For non-deadline notifications: Automatically deletes after marking as read to save storage
* For deadline notifications: Keeps them to show ongoing contract expiry info
*
* Updates local state immediately for optimistic UI
* Then calls server action to persist to database
*/
const handleMarkAsRead = useCallback(
async (notificationId: string) => {
// Find the notification to check its type
const notification = notifications.find((n) => n.id === notificationId);
const isDeadlineNotification = notification?.type === "DEADLINE";
if (isDeadlineNotification) {
// For DEADLINE notifications: just mark as read
setNotifications((prev) =>
prev.map((notif) =>
notif.id === notificationId ? { ...notif, read: true } : notif,
),
);
setUnreadCount((prev) => Math.max(0, prev - 1));
// Persist to database
await markNotificationAsRead(notificationId);
} else {
// For non-deadline notifications: delete after marking read (auto-cleanup)
// Optimistic update: remove from UI immediately
setNotifications((prev) =>
prev.filter((notif) => notif.id !== notificationId),
);
setUnreadCount((prev) => Math.max(0, prev - 1));
// Persist to database: delete the notification
await deleteNotification(notificationId);
}
broadcastRealtimeRefresh();
},
[notifications, broadcastRealtimeRefresh],
);
/**
* Handles marking all notifications as read
*/
const handleMarkAllAsRead = useCallback(async () => {
setNotifications((prev) => prev.map((notif) => ({ ...notif, read: true })));
setUnreadCount(0);
await markAllNotificationsAsRead();
broadcastRealtimeRefresh();
}, [broadcastRealtimeRefresh]);
/**
* Handles deleting a notification
*
* Removes from UI immediately
* Then calls server action to delete from database
*/
const handleDelete = useCallback(
async (notificationId: string) => {
// Optimistic update
setNotifications((prev) =>
prev.filter((notif) => notif.id !== notificationId),
);
// Persist to database
await deleteNotification(notificationId);
// Refresh unread count
const countResponse = await getUnreadNotificationCount();
if (countResponse.success) {
setUnreadCount(countResponse.data?.count || 0);
}
broadcastRealtimeRefresh();
},
[broadcastRealtimeRefresh],
);
/**
* Effect: Close dropdown when clicking outside
*/
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
const clickedTrigger = dropdownRef.current?.contains(target);
const clickedPanel = panelRef.current?.contains(target);
if (!clickedTrigger && !clickedPanel) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}
}, [isOpen]);
/**
* Effect: Event-driven realtime updates (no intervals)
*
* Initial load: one fetch with loader
* Refresh triggers:
* - custom notifications:refresh event (same tab)
* - BroadcastChannel message (cross-tab)
* - focus/visibility return
*/
useEffect(() => {
if (!isMounted) return;
// Initial load
void fetchNotifications({ showLoader: true });
// Deadline check runs once per mount (no loop)
void checkDeadlineNotifications();
const focusRefresh = () => void fetchNotifications({ showLoader: false });
const visibilityRefresh = () => {
if (document.visibilityState === "visible") {
void fetchNotifications({ showLoader: false });
}
};
window.addEventListener("focus", focusRefresh);
document.addEventListener("visibilitychange", visibilityRefresh);
window.addEventListener("notifications:refresh", notifyRealtimeRefresh);
return () => {
window.removeEventListener("focus", focusRefresh);
document.removeEventListener("visibilitychange", visibilityRefresh);
window.removeEventListener(
"notifications:refresh",
notifyRealtimeRefresh,
);
};
}, [isMounted, fetchNotifications, notifyRealtimeRefresh]);
useEffect(() => {
if (!isMounted) return;
if (!isOpen) return;
updatePanelPosition();
const handleResize = () => updatePanelPosition();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [isMounted, isOpen, updatePanelPosition]);
if (!isMounted) {
return (
<div className="relative">
<button
className="relative rounded-xl border border-border/70 bg-background/95 p-2.5 text-foreground shadow-sm backdrop-blur"
title="Notifications"
aria-label="Notifications"
type="button"
>
<Bell className="h-5 w-5" />
</button>
</div>
);
}
const toggleOpen = () => {
if (!isOpen) {
updatePanelPosition();
void fetchNotifications({ showLoader: notifications.length === 0 });
}
setIsOpen((prev) => !prev);
};
return (
<div ref={dropdownRef} className="relative">
{/* Bell Icon Button with Badge */}
<button
ref={triggerRef}
onClick={toggleOpen}
className="relative rounded-xl border border-border/70 bg-background/95 p-2.5 text-foreground shadow-sm backdrop-blur hover:bg-accent transition-colors"
title="Notifications"
type="button"
>
<Bell className="h-5 w-5" />
{/* Unread Badge */}
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center text-xs font-bold text-white bg-red-500 rounded-full animate-pulse">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
{/* Dropdown Panel */}
{isOpen &&
createPortal(
<div
ref={panelRef}
style={{
top: `${panelPosition.top}px`,
left: `${panelPosition.left}px`,
}}
className="fixed w-[420px] max-w-[calc(100vw-2rem)] rounded-2xl border border-border/80 bg-background/98 shadow-2xl backdrop-blur z-[90] max-h-[70vh] flex flex-col overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between border-b border-border/70 bg-muted/30 px-4 py-3">
<div>
<h2 className="font-semibold text-foreground">Notifications</h2>
<p className="text-xs text-muted-foreground">
Renewal reminders and activity updates
</p>
</div>
<button
onClick={() => setIsOpen(false)}
className="p-1 hover:bg-accent rounded-md transition-colors"
type="button"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Notifications List */}
<div className="flex-1 overflow-y-auto bg-background">
{isLoading ? (
<div className="p-4 text-center text-muted-foreground">
<p>Loading notifications...</p>
</div>
) : notifications.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center p-8 bg-gradient-to-b from-background to-muted/10">
<div className="mb-4 p-3 rounded-full bg-muted/50">
<Bell className="h-8 w-8 text-muted-foreground/40" />
</div>
<p className="font-medium text-foreground">All caught up!</p>
<p className="text-sm text-muted-foreground text-center mt-2">
No new notifications right now. Non-deadline notifications
are auto-deleted after viewing.
</p>
<p className="text-xs text-muted-foreground text-center mt-3 px-2">
Deadline reminders for upcoming contract expirations will
appear here.
</p>
</div>
) : (
<div className="space-y-2 p-3">
{notifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
onRead={handleMarkAsRead}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
{/* Footer Actions */}
{notifications.length > 0 && (
<div className="border-t border-border/70 bg-muted/20 p-3 flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleMarkAllAsRead}
className="flex-1"
>
<CheckCheck className="h-4 w-4 mr-2" />
Mark all as read
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setIsOpen(false)}
type="button"
>
Close
</Button>
</div>
)}
</div>,
document.body,
)}
</div>
);
}