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

@@ -0,0 +1,33 @@
// The [[...sign-in]] folder name is MAGIC!
// It catches all Clerk's internal sign-in routes
// (sign-in, sign-in/factor-one, sign-in/sso-callback, etc.)
import { SignIn } from "@clerk/nextjs";
export default function SignInPage() {
return (
<main className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900">
{/* Left side - branding (optional, add later) */}
<div className="w-full max-w-md">
<SignIn
appearance={{
elements: {
// Customize Clerk's UI to match your design
rootBox: "w-full",
card: "bg-white dark:bg-slate-800 shadow-xl border border-slate-200 dark:border-slate-700 rounded-2xl",
headerTitle: "text-slate-900 dark:text-slate-100 font-bold",
headerSubtitle: "text-slate-600 dark:text-slate-400",
socialButtonsBlockButton:
"border border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700",
formFieldInput:
"border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 focus:ring-blue-500",
formButtonPrimary:
"bg-blue-700 hover:bg-blue-800 text-white font-semibold",
footerActionLink: "text-blue-700 hover:text-blue-800 font-medium",
},
}}
/>
</div>
</main>
);
}

View File

@@ -0,0 +1,27 @@
import { SignUp } from "@clerk/nextjs";
export default function SignUpPage() {
return (
<main className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900">
<div className="w-full max-w-md">
<SignUp
appearance={{
elements: {
rootBox: "w-full",
card: "bg-white dark:bg-slate-800 shadow-xl border border-slate-200 dark:border-slate-700 rounded-2xl",
headerTitle: "text-slate-900 dark:text-slate-100 font-bold",
headerSubtitle: "text-slate-600 dark:text-slate-400",
socialButtonsBlockButton:
"border border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700",
formFieldInput:
"border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100",
formButtonPrimary:
"bg-blue-700 hover:bg-blue-800 text-white font-semibold",
footerActionLink: "text-blue-700 hover:text-blue-800 font-medium",
},
}}
/>
</div>
</main>
);
}

View File

@@ -0,0 +1,16 @@
import { redirect } from "next/navigation";
import { auth } from "@clerk/nextjs/server";
export default async function ContactsLayout({
children,
}: {
children: React.ReactNode;
}) {
const { userId } = await auth();
if (!userId) {
redirect("/sign-in");
}
return <>{children}</>;
}

View File

@@ -0,0 +1,110 @@
"use client";
import { ContractUploadForm } from "@/components/views/dashboard/contract-upload-form";
import { EmptyContractsState } from "@/components/views/dashboard/empty-contracts-state";
import { ContractsList } from "@/components/views/dashboard/contracts-list";
import { ContactsHeader } from "@/components/views/dashboard/contacts-header";
import { useState, useEffect } from "react";
import { getContracts } from "@/lib/actions/contract.action";
import { Card } from "@/components/ui/card";
export default function ContactsPage() {
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [showContracts, setShowContracts] = useState(false);
const [isChecking, setIsChecking] = useState(true);
// Check if there are any existing contracts on mount
useEffect(() => {
const checkContracts = async () => {
try {
const result = await getContracts();
if (
result.success &&
Array.isArray(result.contracts) &&
result.contracts.length > 0
) {
setShowContracts(true);
}
} catch (error) {
console.error("Failed to check contracts:", error);
} finally {
setIsChecking(false);
}
};
checkContracts();
}, []);
const handleUploadSuccess = () => {
setRefreshTrigger((prev) => prev + 1);
setShowContracts(true);
};
if (isChecking) {
return (
<>
<div className="min-h-screen bg-background text-foreground overflow-hidden">
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-0 left-1/4 w-96 h-96 bg-primary/20 rounded-full blur-3xl animate-blob"></div>
<div className="absolute top-1/2 right-1/4 w-96 h-96 bg-accent/20 rounded-full blur-3xl animate-blob animation-delay-2000"></div>
<div className="absolute -bottom-8 right-1/3 w-96 h-96 bg-secondary/20 rounded-full blur-3xl animate-blob animation-delay-4000"></div>
</div>
<main className="relative z-10 flex flex-col h-screen overflow-auto items-center justify-center">
<div className="text-center">
<div className="mb-4 inline-block p-4 bg-background dark:bg-card rounded-full border border-border/50">
<div className="w-8 h-8 rounded-full border-2 border-primary border-t-transparent animate-spin"></div>
</div>
<p className="text-muted-foreground">Loading...</p>
</div>
</main>
</div>
</>
);
}
return (
<>
<div className="min-h-screen bg-background text-foreground">
<main className="flex flex-col min-h-screen">
<ContactsHeader />
<div className="flex-1 overflow-auto">
<div className="max-w-7xl mx-auto px-6 py-8 space-y-8">
<Card className="rounded-2xl border-border/60 p-6 md:p-8">
<div className="mb-6">
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight">
Upload Contract
</h2>
<p className="mt-2 text-sm md:text-base text-muted-foreground">
Add PDF contracts and let the AI pipeline extract summary,
key points, and legal-business insights.
</p>
</div>
<ContractUploadForm onUploadSuccess={handleUploadSuccess} />
</Card>
<Card className="rounded-2xl border-border/60 p-6 md:p-8">
<div className="mb-6">
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight">
Your Contracts
</h2>
<p className="mt-2 text-sm md:text-base text-muted-foreground">
Review contract lifecycle, trigger analysis, and ask AI
questions per file.
</p>
</div>
{showContracts ? (
<ContractsList refreshTrigger={refreshTrigger} />
) : (
<EmptyContractsState />
)}
</Card>
</div>
</div>
</main>
</div>
</>
);
}

View File

@@ -0,0 +1,814 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { motion } from "motion/react";
import {
Activity,
AlertTriangle,
ArrowRight,
BarChart3,
Brain,
CheckCircle2,
Clock3,
Database,
FileText,
RefreshCw,
Sparkles,
TrendingUp,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { getStatsAction } from "@/lib/actions/stats.action";
import { checkDeadlineNotifications } from "@/lib/actions/notification.action";
import {
ContractStatusChart,
ContractTypeChart,
TrendChart,
} from "@/components/views/dashboard/charts";
interface DashboardStats {
totalContracts: number;
analyzedContracts: number;
processingContracts: number;
uploadedContracts: number;
failedContracts: number;
analysisRate: number;
}
interface ChartData {
byType: Array<{ type: string; count: number }>;
byStatus: Array<{ status: string; count: number }>;
trends: Array<{ date: string; count: number }>;
}
interface PremiumInfo {
averagePremium: number;
totalPremium: number;
count: number;
}
interface RecentContract {
id: string;
title: string | null;
type: string | null;
createdAt: string;
premium: number | null;
}
interface AILearningTelemetry {
completedSamples: number;
completedLast7Days: number;
avgSummaryLength: number;
avgExtractedTextLength: number;
avgKeyPointsPerContract: number;
learningScore: number;
improvementHint: string;
}
interface StatsActionResult {
success: boolean;
stats?: DashboardStats;
chartData?: ChartData;
premiumInfo?: PremiumInfo;
aiLearningTelemetry?: AILearningTelemetry;
recentContracts?: RecentContract[];
error?: string;
}
const numberFormatter = new Intl.NumberFormat("en-US");
const currencyFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 2,
});
const defaultStats: DashboardStats = {
totalContracts: 0,
analyzedContracts: 0,
processingContracts: 0,
uploadedContracts: 0,
failedContracts: 0,
analysisRate: 0,
};
const formatLastUpdated = (date: Date | null): string => {
if (!date) {
return "Just now";
}
const seconds = Math.max(1, Math.floor((Date.now() - date.getTime()) / 1000));
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
return `${hours}h ago`;
};
const clampPercent = (value: number): number =>
Math.max(0, Math.min(100, value));
export default function DashboardPage() {
const [stats, setStats] = useState<DashboardStats>(defaultStats);
const [chartData, setChartData] = useState<ChartData | null>(null);
const [premiumInfo, setPremiumInfo] = useState<PremiumInfo>({
averagePremium: 0,
totalPremium: 0,
count: 0,
});
const [recentContracts, setRecentContracts] = useState<RecentContract[]>([]);
const [aiLearningTelemetry, setAiLearningTelemetry] =
useState<AILearningTelemetry>({
completedSamples: 0,
completedLast7Days: 0,
avgSummaryLength: 0,
avgExtractedTextLength: 0,
avgKeyPointsPerContract: 0,
learningScore: 0,
improvementHint: "Analyze contracts to build your AI quality profile.",
});
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const loadStats = useCallback(async (options?: { silent?: boolean }) => {
const isSilentRefresh = options?.silent ?? false;
if (isSilentRefresh) {
setIsRefreshing(true);
} else {
setIsLoading(true);
}
try {
const result = (await getStatsAction()) as StatsActionResult;
if (!result.success) {
return;
}
if (result.stats) setStats(result.stats);
if (result.chartData) setChartData(result.chartData);
if (result.premiumInfo) setPremiumInfo(result.premiumInfo);
if (result.aiLearningTelemetry) {
setAiLearningTelemetry(result.aiLearningTelemetry);
}
if (result.recentContracts) setRecentContracts(result.recentContracts);
setLastUpdated(new Date());
} finally {
if (isSilentRefresh) {
setIsRefreshing(false);
} else {
setIsLoading(false);
}
}
}, []);
useEffect(() => {
void loadStats();
// Check for upcoming contract deadlines and create notifications
void checkDeadlineNotifications();
}, [loadStats]);
useEffect(() => {
if (stats.processingContracts === 0 && stats.uploadedContracts === 0) {
return;
}
const intervalId = window.setInterval(() => {
void loadStats({ silent: true });
}, 10000);
return () => window.clearInterval(intervalId);
}, [loadStats, stats.processingContracts, stats.uploadedContracts]);
const hasChartData = useMemo(() => {
if (!chartData) return false;
const trendsCount = chartData.trends.reduce(
(total, entry) => total + entry.count,
0,
);
const byStatusCount = chartData.byStatus.reduce(
(total, entry) => total + entry.count,
0,
);
const byTypeCount = chartData.byType.reduce(
(total, entry) => total + entry.count,
0,
);
return trendsCount + byStatusCount + byTypeCount > 0;
}, [chartData]);
const pendingContracts = stats.processingContracts + stats.uploadedContracts;
const analyzedPercent =
stats.totalContracts > 0
? clampPercent((stats.analyzedContracts / stats.totalContracts) * 100)
: 0;
const pendingPercent =
stats.totalContracts > 0
? clampPercent((pendingContracts / stats.totalContracts) * 100)
: 0;
const failedPercent =
stats.totalContracts > 0
? clampPercent((stats.failedContracts / stats.totalContracts) * 100)
: 0;
const statusRows = [
{
label: "Uploaded",
value: stats.uploadedContracts,
colorClass: "bg-amber-500",
},
{
label: "Processing",
value: stats.processingContracts,
colorClass: "bg-primary",
},
{
label: "Analyzed",
value: stats.analyzedContracts,
colorClass: "bg-emerald-500",
},
{
label: "Failed",
value: stats.failedContracts,
colorClass: "bg-destructive",
},
];
if (isLoading) {
return (
<div className="min-h-screen bg-background">
<div className="mx-auto max-w-7xl px-6 py-16">
<Card className="rounded-3xl border-border/60 p-10">
<div className="flex items-center gap-3 text-muted-foreground">
<RefreshCw className="h-5 w-5 animate-spin" />
Building your analytics workspace...
</div>
</Card>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<div className="relative overflow-hidden border-b border-border/50">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary)/0.22),transparent_45%),radial-gradient(circle_at_bottom_left,hsl(var(--secondary)/0.16),transparent_40%),linear-gradient(180deg,hsl(var(--background)),hsl(var(--background)/0.95))]" />
<svg
viewBox="0 0 640 320"
fill="none"
aria-hidden="true"
className="pointer-events-none absolute right-[-140px] top-[-50px] h-[340px] w-[540px] opacity-50"
>
<path
d="M12 290C96 240 128 112 228 110C300 108 336 206 412 206C492 206 524 138 628 132"
stroke="hsl(var(--primary))"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M4 250C86 200 150 74 238 74C322 74 350 170 430 170C502 170 560 108 636 104"
stroke="hsl(var(--secondary))"
strokeWidth="2"
strokeDasharray="6 8"
strokeLinecap="round"
/>
<circle cx="228" cy="110" r="8" fill="hsl(var(--primary))" />
<circle cx="412" cy="206" r="7" fill="hsl(var(--secondary))" />
<circle cx="430" cy="170" r="7" fill="hsl(var(--accent))" />
</svg>
<div className="relative mx-auto max-w-7xl px-6 py-12">
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="grid gap-8 xl:grid-cols-[1.45fr,0.95fr] xl:items-end"
>
<div className="space-y-4">
<p className="inline-flex items-center gap-2 rounded-full border border-primary/25 bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
<Sparkles className="h-3.5 w-3.5" />
Performance Overview
</p>
<h1 className="text-4xl font-semibold tracking-tight md:text-5xl">
Financial Contracts Analytics
</h1>
<p className="max-w-2xl text-muted-foreground md:text-base">
A reliable command center for uploaded documents, AI analysis
throughput, and portfolio quality across your BFSI workflow.
</p>
<div className="flex flex-wrap items-center gap-2 pt-1 text-xs">
<span className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-muted/50 px-3 py-1 text-muted-foreground">
<Activity className="h-3.5 w-3.5 text-primary" />
Live metrics
</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-muted/50 px-3 py-1 text-muted-foreground">
<Clock3 className="h-3.5 w-3.5 text-secondary" />
{isRefreshing
? "Syncing..."
: `Updated ${formatLastUpdated(lastUpdated)}`}
</span>
</div>
</div>
<Card className="rounded-2xl border-border/60 bg-card/80 p-5 backdrop-blur-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
Pipeline Snapshot
</p>
<p className="mt-1 text-2xl font-semibold">
{numberFormatter.format(stats.totalContracts)} files
</p>
</div>
<div className="rounded-xl border border-primary/20 bg-primary/10 p-2.5">
<Database className="h-5 w-5 text-primary" />
</div>
</div>
<div className="mt-4 rounded-xl border border-border/60 bg-muted/25 p-3">
<svg
viewBox="0 0 260 90"
fill="none"
aria-hidden="true"
className="h-[92px] w-full"
>
<path
d="M4 72H256"
stroke="hsl(var(--border))"
strokeWidth="1"
/>
<rect
x="24"
y="32"
width="28"
height="40"
rx="8"
fill="hsl(var(--primary) / 0.85)"
/>
<rect
x="76"
y="22"
width="28"
height="50"
rx="8"
fill="hsl(var(--secondary) / 0.8)"
/>
<rect
x="128"
y="14"
width="28"
height="58"
rx="8"
fill="hsl(var(--accent) / 0.78)"
/>
<rect
x="180"
y="44"
width="28"
height="28"
rx="8"
fill="hsl(var(--destructive) / 0.8)"
/>
<path
d="M24 30C64 8 98 10 142 18C176 24 214 34 240 22"
stroke="hsl(var(--foreground) / 0.35)"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-xs">
<div className="rounded-lg border border-border/60 bg-muted/30 px-3 py-2">
<p className="text-muted-foreground">Analyzed</p>
<p className="mt-1 text-sm font-semibold text-foreground">
{numberFormatter.format(stats.analyzedContracts)}
</p>
</div>
<div className="rounded-lg border border-border/60 bg-muted/30 px-3 py-2">
<p className="text-muted-foreground">Pending</p>
<p className="mt-1 text-sm font-semibold text-foreground">
{numberFormatter.format(pendingContracts)}
</p>
</div>
</div>
<div className="mt-4 flex gap-2">
<Button
variant="outline"
onClick={() => void loadStats()}
className="w-full rounded-xl"
>
<RefreshCw
className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
Refresh
</Button>
<Button asChild className="w-full rounded-xl">
<Link href="/contacts">
Manage
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</Card>
</motion.div>
</div>
</div>
<div className="mx-auto max-w-7xl px-6 py-10">
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
<Card className="rounded-2xl border-border/60 bg-card/70 p-6">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">Total Files</p>
<FileText className="h-4 w-4 text-primary" />
</div>
<p className="mt-2 text-4xl font-semibold">
{numberFormatter.format(stats.totalContracts)}
</p>
<p className="mt-2 text-xs text-muted-foreground">
Uploaded into your workspace
</p>
</Card>
<Card className="rounded-2xl border-border/60 bg-card/70 p-6">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">Analyzed</p>
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
</div>
<p className="mt-2 text-4xl font-semibold">
{numberFormatter.format(stats.analyzedContracts)}
</p>
<p className="mt-2 text-xs text-muted-foreground">
Completed by AI pipeline
</p>
<div className="mt-3 h-1.5 w-full rounded-full bg-muted/70">
<div
className="h-1.5 rounded-full bg-emerald-500"
style={{ width: `${analyzedPercent}%` }}
/>
</div>
</Card>
<Card className="rounded-2xl border-border/60 bg-card/70 p-6">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">Pending Queue</p>
<Clock3 className="h-4 w-4 text-amber-500" />
</div>
<p className="mt-2 text-4xl font-semibold">
{numberFormatter.format(pendingContracts)}
</p>
<p className="mt-2 text-xs text-muted-foreground">
Uploaded and processing files
</p>
<div className="mt-3 h-1.5 w-full rounded-full bg-muted/70">
<div
className="h-1.5 rounded-full bg-amber-500"
style={{ width: `${pendingPercent}%` }}
/>
</div>
</Card>
<Card className="rounded-2xl border-border/60 bg-card/70 p-6">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">Failed</p>
<AlertTriangle className="h-4 w-4 text-destructive" />
</div>
<p className="mt-2 text-4xl font-semibold">
{numberFormatter.format(stats.failedContracts)}
</p>
<p className="mt-2 text-xs text-muted-foreground">
Items needing re-analysis
</p>
<div className="mt-3 h-1.5 w-full rounded-full bg-muted/70">
<div
className="h-1.5 rounded-full bg-destructive"
style={{ width: `${failedPercent}%` }}
/>
</div>
</Card>
</div>
<Card className="mt-5 rounded-2xl border-border/60 p-5">
<div className="grid gap-6 lg:grid-cols-[1.2fr,0.8fr]">
<div>
<div className="mb-3 flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-primary" />
<h2 className="text-sm font-semibold">Pipeline Pulse</h2>
</div>
<div className="space-y-3">
{statusRows.map((row) => {
const rowPercent =
stats.totalContracts > 0
? clampPercent((row.value / stats.totalContracts) * 100)
: 0;
return (
<div
key={row.label}
className="rounded-xl border border-border/50 bg-muted/25 p-3"
>
<div className="mb-2 flex items-center justify-between text-xs">
<p className="text-muted-foreground">{row.label}</p>
<p className="font-medium text-foreground">
{numberFormatter.format(row.value)}
</p>
</div>
<div className="h-1.5 w-full rounded-full bg-muted/70">
<div
className={`h-1.5 rounded-full ${row.colorClass}`}
style={{ width: `${rowPercent}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
<div className="rounded-xl border border-border/50 bg-muted/25 px-4 py-3">
<p className="text-xs text-muted-foreground">Success Rate</p>
<p className="mt-1 text-2xl font-semibold text-foreground">
{stats.analysisRate}%
</p>
<p className="mt-1 text-xs text-muted-foreground">
Completed vs total files
</p>
</div>
<div className="rounded-xl border border-border/50 bg-muted/25 px-4 py-3">
<p className="text-xs text-muted-foreground">Avg Premium</p>
<p className="mt-1 text-2xl font-semibold text-foreground">
{currencyFormatter.format(premiumInfo.averagePremium)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Across {numberFormatter.format(premiumInfo.count)} analyzed
files
</p>
</div>
<div className="rounded-xl border border-border/50 bg-muted/25 px-4 py-3">
<p className="text-xs text-muted-foreground">Total Premium</p>
<p className="mt-1 text-2xl font-semibold text-foreground">
{currencyFormatter.format(premiumInfo.totalPremium)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Portfolio value captured by AI
</p>
</div>
</div>
</div>
</Card>
<Card className="mt-5 rounded-2xl border-border/60 p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Brain className="h-4 w-4 text-primary" />
<h2 className="text-sm font-semibold">AI Learning Telemetry</h2>
</div>
<span className="inline-flex items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-2.5 py-1 text-xs text-primary">
<TrendingUp className="h-3.5 w-3.5" />
Score {aiLearningTelemetry.learningScore}/100
</span>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-xl border border-border/50 bg-muted/25 px-4 py-3">
<p className="text-xs text-muted-foreground">Completed Samples</p>
<p className="mt-1 text-2xl font-semibold text-foreground">
{numberFormatter.format(aiLearningTelemetry.completedSamples)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{numberFormatter.format(aiLearningTelemetry.completedLast7Days)}{" "}
in last 7 days
</p>
</div>
<div className="rounded-xl border border-border/50 bg-muted/25 px-4 py-3">
<p className="text-xs text-muted-foreground">
Avg Summary Length
</p>
<p className="mt-1 text-2xl font-semibold text-foreground">
{numberFormatter.format(aiLearningTelemetry.avgSummaryLength)}
</p>
<p className="mt-1 text-xs text-muted-foreground">characters</p>
</div>
<div className="rounded-xl border border-border/50 bg-muted/25 px-4 py-3">
<p className="text-xs text-muted-foreground">
Avg Extracted Text
</p>
<p className="mt-1 text-2xl font-semibold text-foreground">
{numberFormatter.format(
aiLearningTelemetry.avgExtractedTextLength,
)}
</p>
<p className="mt-1 text-xs text-muted-foreground">characters</p>
</div>
<div className="rounded-xl border border-border/50 bg-muted/25 px-4 py-3">
<p className="text-xs text-muted-foreground">Avg Key Points</p>
<p className="mt-1 text-2xl font-semibold text-foreground">
{aiLearningTelemetry.avgKeyPointsPerContract.toFixed(1)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
items per analysis
</p>
</div>
</div>
<div className="mt-4 rounded-xl border border-border/50 bg-muted/20 p-3">
<div className="mb-2 flex items-center justify-between text-xs text-muted-foreground">
<span>Learning quality index</span>
<span>{aiLearningTelemetry.learningScore}%</span>
</div>
<div className="h-1.5 w-full rounded-full bg-muted">
<div
className="h-1.5 rounded-full bg-gradient-to-r from-primary to-accent"
style={{
width: `${Math.max(0, Math.min(100, aiLearningTelemetry.learningScore))}%`,
}}
/>
</div>
<p className="mt-2 text-xs text-muted-foreground">
{aiLearningTelemetry.improvementHint}
</p>
</div>
</Card>
{hasChartData ? (
<div className="mt-6 grid grid-cols-1 gap-5 xl:grid-cols-12">
{chartData && chartData.trends.length > 0 && (
<Card className="rounded-2xl border-border/60 p-5 xl:col-span-8">
<div className="mb-4 flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-primary" />
<h2 className="text-sm font-medium">
Upload Trend (30 days)
</h2>
</div>
<div className="h-[320px]">
<TrendChart data={chartData.trends} />
</div>
</Card>
)}
{chartData && chartData.byStatus.length > 0 && (
<Card className="rounded-2xl border-border/60 p-5 xl:col-span-4">
<div className="mb-4 flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-primary" />
<h2 className="text-sm font-medium">Processing Status</h2>
</div>
<div className="h-[320px]">
<ContractStatusChart
data={chartData.byStatus.map((s) => ({
...s,
name: s.status,
}))}
/>
</div>
</Card>
)}
{chartData && chartData.byType.length > 0 && (
<Card className="rounded-2xl border-border/60 p-5 xl:col-span-7">
<div className="mb-4 flex items-center gap-2">
<FileText className="h-4 w-4 text-primary" />
<h2 className="text-sm font-medium">
Contract Type Distribution
</h2>
</div>
<div className="h-[300px]">
<ContractTypeChart data={chartData.byType} />
</div>
</Card>
)}
<Card className="rounded-2xl border-border/60 p-5 xl:col-span-5">
<div className="mb-4 flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
<h2 className="text-sm font-medium">Recent Analyses</h2>
</div>
{recentContracts.length > 0 ? (
<div className="space-y-3">
{recentContracts.map((contract) => (
<div
key={contract.id}
className="rounded-xl border border-border/50 bg-muted/25 p-3"
>
<p className="text-sm font-medium text-foreground line-clamp-1">
{contract.title || "Untitled contract"}
</p>
<div className="mt-1 flex items-center justify-between text-xs text-muted-foreground">
<span>{contract.type || "Unknown type"}</span>
<span>
{new Date(contract.createdAt).toLocaleDateString(
"en-US",
{
month: "short",
day: "numeric",
},
)}
</span>
</div>
<p className="mt-2 text-xs font-medium text-foreground">
Premium:{" "}
{contract.premium !== null
? currencyFormatter.format(contract.premium)
: "Not detected"}
</p>
</div>
))}
</div>
) : (
<div className="flex h-[300px] items-center justify-center rounded-xl border border-dashed border-border/70 bg-muted/20 text-center">
<div>
<p className="text-sm font-medium">
No recent analyses yet
</p>
<p className="mt-1 text-xs text-muted-foreground">
Analyze a contract to populate this activity feed.
</p>
</div>
</div>
)}
</Card>
</div>
) : (
<Card className="mt-6 rounded-2xl border-border/60 p-8">
<div className="mx-auto flex max-w-2xl flex-col items-center text-center">
<svg
width="220"
height="120"
viewBox="0 0 220 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="mb-5 opacity-80"
>
<rect
x="14"
y="26"
width="192"
height="78"
rx="14"
stroke="currentColor"
className="text-muted-foreground"
/>
<path
d="M30 82L62 60L88 74L124 44L162 58L192 36"
stroke="currentColor"
strokeWidth="4"
className="text-primary"
/>
<circle
cx="62"
cy="60"
r="5"
fill="currentColor"
className="text-primary"
/>
<circle
cx="124"
cy="44"
r="5"
fill="currentColor"
className="text-primary"
/>
<circle
cx="192"
cy="36"
r="5"
fill="currentColor"
className="text-secondary"
/>
</svg>
<h3 className="text-xl font-semibold">
Your analytics will appear here
</h3>
<p className="mt-2 text-sm text-muted-foreground">
Upload and analyze contracts to unlock trend and distribution
charts.
</p>
<Button asChild className="mt-5 rounded-xl">
<Link href="/contacts">
Upload first contract
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</Card>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
import { DashboardNavigation } from "@/components/views/dashboard/navigation";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const { userId } = await auth();
if (!userId) {
redirect("/sign-in");
}
return (
<div className="min-h-screen bg-background">
<DashboardNavigation />
<div className="ml-72">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,8 @@
// src/app/api/uploadthing/core.ts
import { createRouteHandler } from "uploadthing/next";
import { ourFileRouter } from "@/lib/upload";
export const { GET, POST } = createRouteHandler({
router: ourFileRouter,
});

View File

@@ -0,0 +1,3 @@
// src/app/api/uploadthing/route.ts
export { GET, POST } from "./core";

View File

@@ -0,0 +1,164 @@
// src/app/api/webhooks/clerk/route.ts
import { Webhook } from "svix";
import { headers } from "next/headers";
import { WebhookEvent } from "@clerk/nextjs/server";
import { prisma } from "@/lib/db/prisma";
export async function POST(req: Request) {
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// STEP 1: Get the webhook secret from environment
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET;
if (!WEBHOOK_SECRET) {
console.error("❌ Missing CLERK_WEBHOOK_SECRET in environment variables");
return new Response("Server configuration error", { status: 500 });
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// STEP 2: Get headers needed for verification
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const headerPayload = await headers();
const svix_id = headerPayload.get("svix-id");
const svix_timestamp = headerPayload.get("svix-timestamp");
const svix_signature = headerPayload.get("svix-signature");
// If there are no headers, error out
if (!svix_id || !svix_timestamp || !svix_signature) {
console.error("❌ Missing svix headers");
return new Response("Missing svix headers", { status: 400 });
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// STEP 3: Get the request body
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const payload = await req.json();
const body = JSON.stringify(payload);
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// STEP 4: Verify the webhook signature
// This ensures the webhook is actually from Clerk
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const wh = new Webhook(WEBHOOK_SECRET);
let evt: WebhookEvent;
try {
evt = wh.verify(body, {
"svix-id": svix_id,
"svix-timestamp": svix_timestamp,
"svix-signature": svix_signature,
}) as WebhookEvent;
} catch (err) {
console.error("❌ Webhook verification failed:", err);
return new Response("Invalid signature", { status: 400 });
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// STEP 5: Handle different webhook events
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const eventType = evt.type;
console.log(`📥 Webhook received: ${eventType}`);
switch (eventType) {
// ═══════════════════════════════════════════════════
// USER CREATED
// ═══════════════════════════════════════════════════
case "user.created": {
const { id, email_addresses, first_name, last_name, image_url } =
evt.data;
try {
// Check if user already exists
const existingUser = await prisma.user.findUnique({
where: { clerkId: id },
});
if (existingUser) {
console.log(`⚠️ User already exists: ${id}`);
return new Response("User already exists", { status: 200 });
}
// Create user in database
const user = await prisma.user.create({
data: {
clerkId: id,
email: email_addresses[0]?.email_address ?? "",
firstName: first_name ?? null,
lastName: last_name ?? null,
imageUrl: image_url ?? null,
},
});
console.log(`✅ User created: ${user.email} (${user.id})`);
return new Response("User created successfully", { status: 201 });
} catch (error) {
console.error("❌ Error creating user:", error);
return new Response("Error creating user", { status: 500 });
}
}
// ═══════════════════════════════════════════════════
// USER UPDATED
// ═══════════════════════════════════════════════════
case "user.updated": {
const { id, email_addresses, first_name, last_name, image_url } =
evt.data;
try {
const user = await prisma.user.update({
where: { clerkId: id },
data: {
email: email_addresses[0]?.email_address ?? "",
firstName: first_name ?? null,
lastName: last_name ?? null,
imageUrl: image_url ?? null,
},
});
console.log(`✅ User updated: ${user.email} (${user.id})`);
return new Response("User updated successfully", { status: 200 });
} catch (error) {
console.error("❌ Error updating user:", error);
return new Response("Error updating user", { status: 500 });
}
}
// ═══════════════════════════════════════════════════
// USER DELETED
// ═══════════════════════════════════════════════════
case "user.deleted": {
const { id } = evt.data;
if (!id) {
console.error("❌ No user ID provided in deletion event");
return new Response("No user ID provided", { status: 400 });
}
try {
// Delete user (CASCADE will delete all related contracts)
await prisma.user.delete({
where: { clerkId: id },
});
console.log(`✅ User deleted: ${id}`);
return new Response("User deleted successfully", { status: 200 });
} catch (error) {
console.error("❌ Error deleting user:", error);
return new Response("Error deleting user", { status: 500 });
}
}
// ═══════════════════════════════════════════════════
// OTHER EVENTS (ignore)
// ═══════════════════════════════════════════════════
default: {
console.log(` Unhandled webhook event: ${eventType}`);
return new Response("Event type not handled", { status: 200 });
}
}
}

30
app/clerk-provider.tsx Normal file
View File

@@ -0,0 +1,30 @@
"use client";
import { ClerkProvider } from "@clerk/nextjs";
import { dark } from "@clerk/themes";
import { useTheme } from "next-themes";
import { ReactNode } from "react";
export function ClerkThemeProvider({ children }: { children: ReactNode }) {
const { resolvedTheme } = useTheme();
const isDark = resolvedTheme === "dark";
return (
<ClerkProvider
appearance={{
baseTheme: isDark ? dark : undefined,
variables: {
colorPrimary: isDark ? "#60A5FA" : "#2563EB",
colorBackground: isDark ? "#0F172A" : "#FFFFFF",
colorInputBackground: isDark ? "#111827" : "#FFFFFF",
colorInputText: isDark ? "#E2E8F0" : "#0F172A",
colorText: isDark ? "#E2E8F0" : "#0F172A",
colorTextSecondary: isDark ? "#94A3B8" : "#475569",
borderRadius: "0.75rem",
},
}}
>
{children}
</ClerkProvider>
);
}

View File

@@ -91,6 +91,38 @@
"rlig" 1,
"calt" 1;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings:
"rlig" 1,
"calt" 1;
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Better font rendering */
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/* Selection styles */
::selection {
@apply bg-primary/20;
}
/* Focus styles */
:focus-visible {
@apply outline-none ring-2 ring-primary ring-offset-2;
}
}
@layer utilities {

View File

@@ -1,24 +1,89 @@
import type { Metadata } from "next";
import { Poppins, Geist_Mono } from "next/font/google";
import { Inter, JetBrains_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "./provider";
const poppins = Poppins({
// Modern sans-serif font for body text
const inter = Inter({
subsets: ["latin"],
weight: ["400", "500", "600", "700", "800", "900"],
variable: "--font-poppins",
variable: "--font-inter",
display: "swap",
weight: ["300", "400", "500", "600", "700"],
});
const geistMono = Geist_Mono({
// Monospace font for code and numbers
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-geist-mono",
variable: "--font-mono",
display: "swap",
weight: ["400", "500", "600", "700"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: {
default: "LexiChain - AI-Powered Contract Management",
template: "%s | LexiChain",
},
description:
"Intelligent BFSI contract management platform with AI-powered analysis. Manage your insurance, loan, and financial contracts with blockchain-verified security.",
keywords: [
"contract management",
"insurance",
"BFSI",
"AI contract analysis",
"document management",
"blockchain",
"smart contracts",
],
authors: [{ name: "Your Name" }],
creator: "Your Name",
publisher: "LexiChain",
metadataBase: new URL("https://lexichain.com"), // Replace with your domain
openGraph: {
type: "website",
locale: "fr_FR",
url: "https://lexichain.com",
title: "LexiChain - AI-Powered Contract Management",
description:
"Intelligent contract management platform with AI analysis and blockchain verification.",
siteName: "LexiChain",
images: [
{
url: "/og-image.png", // Create this image (1200x630px)
width: 1200,
height: 630,
alt: "LexiChain Platform",
},
],
},
twitter: {
card: "summary_large_image",
title: "LexiChain - AI-Powered Contract Management",
description:
"Intelligent contract management platform with AI analysis and blockchain verification.",
images: ["/og-image.png"],
creator: "@lexichain", // Replace with your Twitter handle
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
icons: {
icon: [
{ url: "/favicon.ico" },
{ url: "/icon-192.png", sizes: "192x192", type: "image/png" },
{ url: "/icon-512.png", sizes: "512x512", type: "image/png" },
],
apple: [{ url: "/apple-icon.png", sizes: "180x180", type: "image/png" }],
},
manifest: "/manifest.json",
};
export default function RootLayout({
@@ -27,9 +92,18 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<html lang="fr" suppressHydrationWarning>
<head>
{/* Preconnect to external domains for performance */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin="anonymous"
/>
</head>
<body
className={`${poppins.variable} ${geistMono.variable} min-h-screen bg-background text-foreground antialiased`}
className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}
>
<Providers>{children}</Providers>
</body>

View File

@@ -1,6 +1,9 @@
// src/components/providers.tsx
"use client";
import { ThemeProvider } from "next-themes";
import { ReactNode } from "react";
import { ClerkThemeProvider } from "./clerk-provider";
export function Providers({ children }: { children: ReactNode }) {
return (
@@ -10,7 +13,7 @@ export function Providers({ children }: { children: ReactNode }) {
enableSystem
disableTransitionOnChange
>
{children}
<ClerkThemeProvider>{children}</ClerkThemeProvider>
</ThemeProvider>
);
}