PreRelease v2
This commit is contained in:
14
features/analytics/api/stats.action.ts
Normal file
14
features/analytics/api/stats.action.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { getUserStats } from "@/lib/services/stats.service";
|
||||
|
||||
export async function getStatsAction() {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return { success: false, error: "Unauthorized" };
|
||||
}
|
||||
|
||||
return await getUserStats(userId);
|
||||
}
|
||||
295
features/analytics/components/charts.tsx
Normal file
295
features/analytics/components/charts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
features/auth/api/user.action.ts
Normal file
49
features/auth/api/user.action.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Server action to manually sync current user to database
|
||||
"use server";
|
||||
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { clerkClient } from "@clerk/nextjs/server";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
|
||||
export async function syncCurrentUser() {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return { success: false, error: "Not authenticated" };
|
||||
}
|
||||
|
||||
// Get user details from Clerk
|
||||
const clerk = await clerkClient();
|
||||
const user = await clerk.users.getUser(userId);
|
||||
|
||||
// Create or update user in database
|
||||
await prisma.user.upsert({
|
||||
where: { clerkId: userId },
|
||||
create: {
|
||||
clerkId: userId,
|
||||
email: user.emailAddresses[0]?.emailAddress || "",
|
||||
firstName: user.firstName || null,
|
||||
lastName: user.lastName || null,
|
||||
imageUrl: user.imageUrl || null,
|
||||
},
|
||||
update: {
|
||||
email: user.emailAddresses[0]?.emailAddress || "",
|
||||
firstName: user.firstName || null,
|
||||
lastName: user.lastName || null,
|
||||
imageUrl: user.imageUrl || null,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `User ${user.emailAddresses[0]?.emailAddress} synced successfully!`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Sync error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
548
features/contracts/api/contract.action.ts
Normal file
548
features/contracts/api/contract.action.ts
Normal file
@@ -0,0 +1,548 @@
|
||||
/**
|
||||
* Contract Server Actions
|
||||
*
|
||||
* Handles all contract-related operations including:
|
||||
* - Saving uploaded contracts
|
||||
* - Retrieving contracts
|
||||
* - Analyzing contracts with AI
|
||||
* - Deleting contracts
|
||||
* - Asking questions about contracts
|
||||
*
|
||||
* Each action integrates with:
|
||||
* - Clerk for authentication
|
||||
* - Contract service for database operations
|
||||
* - AI service for document analysis
|
||||
* - Notification service for user feedback
|
||||
*
|
||||
* All operations include comprehensive error handling and notification creation.
|
||||
*/
|
||||
|
||||
"use server";
|
||||
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
ContractService,
|
||||
saveContract as savePendingContract,
|
||||
} from "@/lib/services/contract.service";
|
||||
import { AIService } from "@/lib/services/ai.service";
|
||||
import { NotificationService } from "@/lib/services/notification.service";
|
||||
|
||||
/**
|
||||
* Saves a new contract after UploadThing upload
|
||||
*
|
||||
* Steps:
|
||||
* 1. Get authenticated user from Clerk
|
||||
* 2. Get internal user ID from database
|
||||
* 3. Save contract to database with UPLOADED status
|
||||
* 4. Create success notification for the user
|
||||
* 5. Revalidate dashboard and contacts pages
|
||||
*
|
||||
* @param data - Contract file metadata from UploadThing
|
||||
* @returns Success status with contract data or error message
|
||||
*/
|
||||
export async function saveContract(data: {
|
||||
fileName: string;
|
||||
fileUrl: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
}) {
|
||||
try {
|
||||
// Get authenticated user
|
||||
const { userId: clerkId } = await auth();
|
||||
if (!clerkId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
// Save contract
|
||||
const result = await savePendingContract(data);
|
||||
|
||||
if (result.success && result.contract) {
|
||||
// Get internal user ID for notification
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
|
||||
if (user) {
|
||||
// Create success notification
|
||||
await NotificationService.create({
|
||||
userId: user.id,
|
||||
type: "SUCCESS",
|
||||
title: "📄 Contract Uploaded",
|
||||
message: `"${data.fileName}" has been uploaded successfully. Click "Analyze" to extract contract details.`,
|
||||
contractId: result.contract.id,
|
||||
actionType: "UPLOAD_SUCCESS",
|
||||
icon: "FileCheck",
|
||||
expiresIn: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/contacts");
|
||||
revalidatePath("/dashboard");
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
console.error("Save contract error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all contracts for the authenticated user
|
||||
*
|
||||
* Steps:
|
||||
* 1. Query database for contracts matching filter criteria
|
||||
* 2. Serialize data: convert Decimal to number, dates to ISO strings
|
||||
* 3. Return paginated/filtered contract list
|
||||
*
|
||||
* Supported Filters:
|
||||
* - status: UPLOADED, PROCESSING, COMPLETED, FAILED
|
||||
* - type: INSURANCE_AUTO, INSURANCE_HOME, etc.
|
||||
* - search: Searches title, provider, policyNumber, fileName
|
||||
* - userId: Auto-filtered to authenticated user
|
||||
*
|
||||
* @param filters - Filter criteria
|
||||
* @returns Array of contracts with serialized data
|
||||
*/
|
||||
export async function getContracts(filters?: Record<string, unknown>) {
|
||||
try {
|
||||
const contracts = await ContractService.getAll(filters);
|
||||
|
||||
// Serialize contracts: convert Decimal to number, dates to ISO strings
|
||||
const serializedContracts = contracts.map((contract: any) => ({
|
||||
id: contract.id,
|
||||
fileName: contract.fileName,
|
||||
fileSize: contract.fileSize,
|
||||
mimeType: contract.mimeType,
|
||||
status: contract.status,
|
||||
createdAt: contract.createdAt?.toISOString() || new Date().toISOString(),
|
||||
fileUrl: contract.fileUrl,
|
||||
// AI Analysis fields
|
||||
title: contract.title || null,
|
||||
type: contract.type || null,
|
||||
provider: contract.provider || null,
|
||||
policyNumber: contract.policyNumber || null,
|
||||
startDate: contract.startDate ? contract.startDate.toISOString() : null,
|
||||
endDate: contract.endDate ? contract.endDate.toISOString() : null,
|
||||
premium: contract.premium
|
||||
? parseFloat(contract.premium.toString())
|
||||
: null,
|
||||
summary: contract.summary || null,
|
||||
keyPoints: contract.keyPoints || null,
|
||||
extractedText: contract.extractedText || null,
|
||||
}));
|
||||
|
||||
return { success: true, contracts: serializedContracts };
|
||||
} catch (error: unknown) {
|
||||
console.error("Get contracts error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a single contract by ID
|
||||
*
|
||||
* @param id - Contract ID
|
||||
* @returns Contract details or error
|
||||
*/
|
||||
export async function getContract(id: string) {
|
||||
try {
|
||||
const contract = await ContractService.getById(id);
|
||||
return { success: true, contract };
|
||||
} catch (error: unknown) {
|
||||
console.error("Get contract error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a contract from both cloud storage and database
|
||||
*
|
||||
* Steps:
|
||||
* 1. Get authenticated user from Clerk
|
||||
* 2. Get internal user ID from database
|
||||
* 3. Verify user owns the contract
|
||||
* 4. Delete file from UploadThing cloud storage
|
||||
* 5. Delete contract record from database
|
||||
* 6. Create success notification
|
||||
* 7. Revalidate pages
|
||||
*
|
||||
* @param id - Contract ID to delete
|
||||
* @returns Success status or error message
|
||||
*
|
||||
* Security: Only the contract owner can delete their contracts
|
||||
*/
|
||||
export async function deleteContract(id: string) {
|
||||
try {
|
||||
// Get authenticated user
|
||||
const { userId: clerkId } = await auth();
|
||||
if (!clerkId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
// Get contract to verify ownership and get title
|
||||
const contract = await ContractService.getById(id);
|
||||
const contractTitle = contract.title || contract.fileName;
|
||||
|
||||
// Get internal user ID
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
|
||||
if (!user || contract.userId !== user.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized: Contract not found or does not belong to you",
|
||||
};
|
||||
}
|
||||
|
||||
// Delete contract (handles both storage and database)
|
||||
await ContractService.delete(id);
|
||||
|
||||
if (user) {
|
||||
// Create success notification
|
||||
await NotificationService.create({
|
||||
userId: user.id,
|
||||
type: "SUCCESS",
|
||||
title: "🗑️ Contract Deleted",
|
||||
message: `"${contractTitle}" has been permanently deleted.`,
|
||||
actionType: "DELETE_SUCCESS",
|
||||
icon: "Trash2",
|
||||
expiresIn: 24 * 60 * 60 * 1000, // 24 hours
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/contacts");
|
||||
revalidatePath("/dashboard");
|
||||
|
||||
return { success: true, message: "Contract deleted successfully" };
|
||||
} catch (error: unknown) {
|
||||
console.error("Delete error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves dashboard statistics for the authenticated user
|
||||
*
|
||||
* Returns:
|
||||
* - Total contracts count
|
||||
* - Status breakdown (uploaded, processing, completed, failed)
|
||||
* - Contract type distribution
|
||||
* - AI learning telemetry data
|
||||
*
|
||||
* @returns Statistics object or error
|
||||
*/
|
||||
export async function getContractStats() {
|
||||
try {
|
||||
const stats = await ContractService.getStats();
|
||||
return { success: true, stats };
|
||||
} catch (error: unknown) {
|
||||
console.error("Stats error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyzes a contract using AI service
|
||||
*
|
||||
* Steps:
|
||||
* 1. Authenticate user
|
||||
* 2. Get contract details
|
||||
* 3. Update status to PROCESSING
|
||||
* 4. Call AI service to analyze contract
|
||||
* 5. Validate AI results
|
||||
* 6. Save results to database with COMPLETED status
|
||||
* 7. Create success notification
|
||||
* 8. Return analysis results or error
|
||||
*
|
||||
* On Error:
|
||||
* - Detects if contract is invalid vs analysis failed
|
||||
* - Saves failure reason to database
|
||||
* - Creates error notification
|
||||
* - Returns appropriate error code for UI handling
|
||||
*
|
||||
* @param id - Contract ID to analyze
|
||||
* @returns Success with analysis results or error with error code
|
||||
*
|
||||
* Error Codes:
|
||||
* - INVALID_CONTRACT: File is not a valid contract document
|
||||
* - ANALYSIS_ERROR: Analysis failed during processing
|
||||
*/
|
||||
export async function analyzeContractAction(id: string) {
|
||||
try {
|
||||
const { userId: clerkId } = await auth();
|
||||
if (!clerkId) {
|
||||
return { success: false, error: "Unauthorized" };
|
||||
}
|
||||
|
||||
// Get internal user ID
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
if (!user) {
|
||||
return { success: false, error: "User not found" };
|
||||
}
|
||||
|
||||
// Get contract
|
||||
const contract = await ContractService.getById(id);
|
||||
|
||||
// Verify ownership
|
||||
if (contract.userId !== user.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized: Contract not found or does not belong to you",
|
||||
};
|
||||
}
|
||||
|
||||
// Update status to PROCESSING
|
||||
await ContractService.updateStatus(id, "PROCESSING");
|
||||
|
||||
// Create processing notification
|
||||
await NotificationService.create({
|
||||
userId: user.id,
|
||||
type: "INFO",
|
||||
title: "⏳ Analyzing Contract",
|
||||
message: `"${contract.fileName}" is being analyzed. This may take a few seconds...`,
|
||||
contractId: id,
|
||||
actionType: "ANALYSIS_STARTED",
|
||||
icon: "Loader",
|
||||
});
|
||||
|
||||
// Analyze with AI
|
||||
const aiResults = await AIService.analyzeContract(contract.fileUrl, {
|
||||
userId: contract.userId,
|
||||
fileName: contract.fileName,
|
||||
maxRetries: 3,
|
||||
});
|
||||
|
||||
// Validate results
|
||||
if (!AIService.validateAnalysis(aiResults)) {
|
||||
console.error("❌ AI validation failed");
|
||||
await ContractService.markFailed(
|
||||
id,
|
||||
"AI validation failed. The file may be incomplete or not a valid contract.",
|
||||
);
|
||||
|
||||
// Create error notification
|
||||
await NotificationService.create({
|
||||
userId: user.id,
|
||||
type: "ERROR",
|
||||
title: "❌ Analysis Failed",
|
||||
message:
|
||||
"The AI could not validate the analysis result. The file may be incomplete or corrupted.",
|
||||
contractId: id,
|
||||
actionType: "ANALYSIS_FAILED",
|
||||
icon: "AlertCircle",
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "AI analysis validation failed. Please try again.",
|
||||
errorCode: "ANALYSIS_ERROR",
|
||||
};
|
||||
}
|
||||
|
||||
// Persist AI learning metadata inside keyPoints JSON so future analyses can adapt
|
||||
// without requiring DB schema changes.
|
||||
const keyPointsWithLearning = {
|
||||
...(aiResults.keyPoints ?? {}),
|
||||
aiMeta: {
|
||||
language: (aiResults as any).language ?? null,
|
||||
keyPeople: (aiResults as any).keyPeople ?? [],
|
||||
contactInfo: (aiResults as any).contactInfo ?? null,
|
||||
importantContacts: (aiResults as any).importantContacts ?? [],
|
||||
relevantDates: (aiResults as any).relevantDates ?? [],
|
||||
premiumCurrency: (aiResults as any).premiumCurrency ?? null,
|
||||
learnedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
// Save AI results to database (convert nulls to undefined for optional fields)
|
||||
await ContractService.updateWithAIResults(id, {
|
||||
...aiResults,
|
||||
keyPoints: keyPointsWithLearning,
|
||||
provider: aiResults.provider ?? undefined,
|
||||
policyNumber: aiResults.policyNumber ?? undefined,
|
||||
startDate: aiResults.startDate ?? undefined,
|
||||
endDate: aiResults.endDate ?? undefined,
|
||||
premium: aiResults.premium ?? undefined,
|
||||
});
|
||||
|
||||
// Create success notification with extracted info
|
||||
const contractTitle = aiResults.title || "Contract";
|
||||
const contractProvider = aiResults.provider || "Unknown Provider";
|
||||
const endDate = aiResults.endDate
|
||||
? new Date(aiResults.endDate).toLocaleDateString()
|
||||
: "N/A";
|
||||
|
||||
await NotificationService.create({
|
||||
userId: user.id,
|
||||
type: "SUCCESS",
|
||||
title: "✅ Contract Analyzed",
|
||||
message: `"${contractTitle}" from ${contractProvider} (Expires: ${endDate}) has been successfully analyzed and saved.`,
|
||||
contractId: id,
|
||||
actionType: "ANALYSIS_SUCCESS",
|
||||
icon: "CheckCircle2",
|
||||
expiresIn: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
|
||||
revalidatePath("/contacts");
|
||||
revalidatePath("/dashboard");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Contract analyzed successfully!",
|
||||
contract: aiResults,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.error("Analyze error:", error);
|
||||
|
||||
try {
|
||||
const { userId: clerkId } = await auth();
|
||||
const user = clerkId && (await ContractService.getUserByClerkId(clerkId));
|
||||
|
||||
// Update contract status to FAILED
|
||||
const reason =
|
||||
error instanceof Error ? error.message : "Unknown error occurred";
|
||||
await ContractService.markFailed(id, reason);
|
||||
|
||||
// Create error notification
|
||||
if (user) {
|
||||
const contract = await ContractService.getById(id);
|
||||
await NotificationService.create({
|
||||
userId: user.id,
|
||||
type: "ERROR",
|
||||
title: "❌ Analysis Failed",
|
||||
message: reason,
|
||||
contractId: id,
|
||||
actionType: "ANALYSIS_ERROR",
|
||||
icon: "AlertCircle",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to update status or create notification", e);
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error occurred";
|
||||
|
||||
// Detect if contract is invalid vs analysis failed
|
||||
const invalidContractSignals = [
|
||||
"not recognized as a valid contract",
|
||||
"contract confidence is too low",
|
||||
"does not contain enough contract-specific signals",
|
||||
"uploaded file is not recognized as a contract",
|
||||
"invalid_contract",
|
||||
];
|
||||
const normalizedError = errorMessage.toLowerCase();
|
||||
const isInvalidContract = invalidContractSignals.some((signal) =>
|
||||
normalizedError.includes(signal),
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
errorCode: isInvalidContract ? "INVALID_CONTRACT" : "ANALYSIS_ERROR",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks a question about a specific contract using AI
|
||||
*
|
||||
* Steps:
|
||||
* 1. Authenticate user
|
||||
* 2. Validate question is not empty
|
||||
* 3. Retrieve contract details
|
||||
* 4. Call AI service with contract context
|
||||
* 5. Return answer or error
|
||||
*
|
||||
* The AI uses the contract's extracted data to provide contextual answers about:
|
||||
* - Contract terms and conditions
|
||||
* - Dates and expiration information
|
||||
* - Coverage details
|
||||
* - Renewal terms
|
||||
* - Specific clauses and provisions
|
||||
*
|
||||
* @param id - Contract ID
|
||||
* @param question - User's question about the contract
|
||||
* @returns AI-generated answer or error
|
||||
*
|
||||
* Example Questions:
|
||||
* - "When does this insurance expire?"
|
||||
* - "What is the coverage limit?"
|
||||
* - "What are the exclusions?"
|
||||
* - "How much is the premium?"
|
||||
*/
|
||||
export async function askContractQuestionAction(id: string, question: string) {
|
||||
try {
|
||||
const { userId: clerkId } = await auth();
|
||||
if (!clerkId) {
|
||||
return { success: false, error: "Unauthorized" };
|
||||
}
|
||||
|
||||
const trimmedQuestion = question.trim();
|
||||
if (!trimmedQuestion) {
|
||||
return { success: false, error: "Question cannot be empty" };
|
||||
}
|
||||
|
||||
// Get contract details
|
||||
const contract = await ContractService.getById(id);
|
||||
|
||||
// Get internal user ID
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
if (!user || contract.userId !== user.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized: Contract not found or does not belong to you",
|
||||
};
|
||||
}
|
||||
|
||||
// Ask AI about contract with full context
|
||||
const answer = await AIService.askAboutContract({
|
||||
question: trimmedQuestion,
|
||||
contract: {
|
||||
fileName: contract.fileName,
|
||||
title: contract.title,
|
||||
type: contract.type,
|
||||
provider: contract.provider,
|
||||
policyNumber: contract.policyNumber,
|
||||
startDate: contract.startDate,
|
||||
endDate: contract.endDate,
|
||||
premium: contract.premium
|
||||
? parseFloat(contract.premium.toString())
|
||||
: null,
|
||||
summary: contract.summary,
|
||||
keyPoints:
|
||||
(contract.keyPoints as Record<string, unknown> | null) ?? null,
|
||||
extractedText: contract.extractedText,
|
||||
language: (contract.keyPoints as any)?.aiMeta?.language ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, answer };
|
||||
} catch (error: unknown) {
|
||||
console.error("Ask contract question error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
141
features/contracts/components/forms/contract-upload-form.tsx
Normal file
141
features/contracts/components/forms/contract-upload-form.tsx
Normal 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 "@/features/contracts/api/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>
|
||||
);
|
||||
}
|
||||
1631
features/contracts/components/list/contracts-list.tsx
Normal file
1631
features/contracts/components/list/contracts-list.tsx
Normal file
File diff suppressed because it is too large
Load Diff
47
features/contracts/components/list/empty-contracts-state.tsx
Normal file
47
features/contracts/components/list/empty-contracts-state.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
215
features/contracts/components/modals/contract-chat-modal.tsx
Normal file
215
features/contracts/components/modals/contract-chat-modal.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { MessageSquare, Briefcase, Scale, Bot, User, Loader2, Send } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { askContractQuestionAction } from "@/features/contracts/api/contract.action";
|
||||
|
||||
interface Contract {
|
||||
id: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface ContractChatModalProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
contract: Contract | null;
|
||||
renderRichParagraphs: (text: string, prefix: string) => React.ReactNode[];
|
||||
}
|
||||
|
||||
export function ContractChatModal({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
contract,
|
||||
renderRichParagraphs,
|
||||
}: ContractChatModalProps) {
|
||||
const [question, setQuestion] = useState("");
|
||||
const [isAsking, setIsAsking] = useState(false);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Ask me anything about this contract. I will answer based on the file analysis.",
|
||||
},
|
||||
]);
|
||||
|
||||
const quickQuestions = [
|
||||
"What are the main obligations and deadlines?",
|
||||
"What are the non-compliance risks under general EU/US principles?",
|
||||
"What are the most important exclusions and liabilities?",
|
||||
];
|
||||
|
||||
const handleAskQuestion = async () => {
|
||||
if (!contract) return;
|
||||
|
||||
const trimmedQuestion = question.trim();
|
||||
if (!trimmedQuestion) return;
|
||||
|
||||
setMessages((prev) => [...prev, { role: "user", content: trimmedQuestion }]);
|
||||
setQuestion("");
|
||||
setIsAsking(true);
|
||||
|
||||
try {
|
||||
const result = await askContractQuestionAction(contract.id, trimmedQuestion);
|
||||
|
||||
if (result.success && result.answer) {
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: result.answer as string }]);
|
||||
} else {
|
||||
const errorMessage = result.error || "Failed to get AI response";
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: `Error: ${errorMessage}` }]);
|
||||
}
|
||||
} catch (error) {
|
||||
const fallbackMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: `Error: ${fallbackMessage}` }]);
|
||||
} finally {
|
||||
setIsAsking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto border-border/60 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary)/0.18),transparent_40%),radial-gradient(circle_at_bottom_left,hsl(var(--secondary)/0.12),transparent_45%)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
Ask About This File
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{contract && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/20 dark:border-white/10 bg-background/40 p-4 shadow-xl backdrop-blur-xl ring-1 ring-black/5 dark:ring-white/5 transition-all duration-500 hover:shadow-primary/5 hover:border-primary/20">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Contract Intelligence Assistant
|
||||
</p>
|
||||
<p className="text-sm font-medium truncate mt-1">{contract.fileName}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-muted/30 px-2 py-1">
|
||||
<Briefcase className="w-3.5 h-3.5" />
|
||||
Business
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-muted/30 px-2 py-1">
|
||||
<Scale className="w-3.5 h-3.5" />
|
||||
Legal Context
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">Quick prompts</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{quickQuestions.map((quickQuestion) => (
|
||||
<Button
|
||||
key={quickQuestion}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isAsking}
|
||||
onClick={() => setQuestion(quickQuestion)}
|
||||
className="border-primary/25 bg-background/80 text-xs hover:border-primary/50 hover:bg-primary/10"
|
||||
>
|
||||
{quickQuestion}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-80 space-y-3 overflow-y-auto rounded-2xl border border-white/10 bg-black/5 dark:bg-white/5 p-4 shadow-inner backdrop-blur-md">
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div className="flex max-w-[88%] items-start gap-2">
|
||||
{message.role === "assistant" && (
|
||||
<span className="mt-1 inline-flex h-7 w-7 items-center justify-center rounded-full border border-border/60 bg-muted/40 text-muted-foreground">
|
||||
<Bot className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={`rounded-2xl px-3 py-2 text-sm whitespace-pre-wrap break-words shadow-sm transition-all duration-300 hover:shadow-md ${
|
||||
message.role === "user"
|
||||
? "bg-gradient-to-r from-primary to-accent text-primary-foreground shadow-primary/25"
|
||||
: "border border-white/20 dark:border-white/10 bg-white/50 dark:bg-black/50 backdrop-blur-md shadow-[0_4px_30px_rgba(0,0,0,0.05)]"
|
||||
}`}
|
||||
>
|
||||
{message.role === "assistant"
|
||||
? renderRichParagraphs(message.content, `chat-assistant-${index}`)
|
||||
: message.content}
|
||||
</div>
|
||||
{message.role === "user" && (
|
||||
<span className="mt-1 inline-flex h-7 w-7 items-center justify-center rounded-full border border-primary/25 bg-primary/10 text-primary">
|
||||
<User className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isAsking && (
|
||||
<div className="flex justify-start">
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-border/70 bg-background px-3 py-2 text-sm shadow-sm">
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full border border-border/60 bg-muted/30 text-muted-foreground">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Preparing a professional legal-business answer...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={question}
|
||||
onChange={(event) => setQuestion(event.target.value)}
|
||||
placeholder="Ask about obligations, liabilities, legal exposure, compliance risks, or business impact..."
|
||||
rows={3}
|
||||
disabled={isAsking}
|
||||
className="rounded-2xl border-white/20 dark:border-white/10 bg-background/50 backdrop-blur-md focus:bg-background/80 transition-all duration-300 shadow-inner"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && !isAsking && question.trim()) {
|
||||
event.preventDefault();
|
||||
void handleAskQuestion();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleAskQuestion}
|
||||
disabled={isAsking || !question.trim()}
|
||||
className="gap-2 bg-gradient-to-r from-primary to-accent text-white shadow-md hover:from-primary/90 hover:to-accent/90"
|
||||
>
|
||||
{isAsking ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4" />
|
||||
Send
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Tip: press Enter to send, Shift+Enter for a new line.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
157
features/contracts/components/modals/contract-proof-modal.tsx
Normal file
157
features/contracts/components/modals/contract-proof-modal.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
interface ProofData {
|
||||
fieldKey: string;
|
||||
field: string;
|
||||
sourceSnippet: string;
|
||||
confidence: number | null;
|
||||
page: string | null;
|
||||
section: string | null;
|
||||
lineNumber: number | null;
|
||||
contextStartLine: number | null;
|
||||
context: string[];
|
||||
resolutionMode: "exact" | "fuzzy" | "fallback";
|
||||
}
|
||||
|
||||
interface ContractProofModalProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
proofData: ProofData | null;
|
||||
}
|
||||
|
||||
export function ContractProofModal({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
proofData,
|
||||
}: ContractProofModalProps) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[92vh] max-w-5xl overflow-y-auto border-border/60 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary)/0.16),transparent_38%),radial-gradient(circle_at_bottom_left,hsl(var(--secondary)/0.12),transparent_42%)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Info className="h-5 w-5 text-primary" />
|
||||
Field Proof
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{proofData && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-3xl border border-white/20 dark:border-white/10 bg-background/40 p-5 shadow-2xl backdrop-blur-2xl ring-1 ring-black/5 dark:ring-white/5 md:p-6 transition-all duration-500 hover:shadow-primary/5 hover:border-primary/20">
|
||||
<div className="grid auto-rows-fr gap-2 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<div className="rounded-xl border border-primary/25 bg-primary/10 px-2.5 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-primary/80">
|
||||
Field
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-semibold text-primary truncate">
|
||||
{proofData.field}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/60 bg-muted/30 px-2.5 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Line
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-semibold text-foreground">
|
||||
{proofData.lineNumber ?? "Not found"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/60 bg-muted/30 px-2.5 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Page
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-semibold text-foreground truncate">
|
||||
{proofData.page ?? "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/60 bg-muted/30 px-2.5 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Section
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-semibold text-foreground truncate">
|
||||
{proofData.section ?? "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/60 bg-muted/30 px-2.5 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Confidence
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-semibold text-foreground">
|
||||
{proofData.confidence ?? "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-white/20 dark:border-white/10 bg-background/40 p-5 shadow-2xl backdrop-blur-2xl ring-1 ring-black/5 dark:ring-white/5 md:p-6 transition-all duration-500 hover:shadow-primary/5 hover:border-primary/20">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
Exact Source Snippet
|
||||
</p>
|
||||
<div className="mt-2 min-h-[92px] rounded-xl border border-border/60 bg-muted/20 px-3 py-2 text-sm italic text-muted-foreground whitespace-pre-wrap break-words">
|
||||
“{proofData.sourceSnippet}”
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-white/20 dark:border-white/10 bg-background/40 p-5 shadow-2xl backdrop-blur-2xl ring-1 ring-black/5 dark:ring-white/5 md:p-6 transition-all duration-500 hover:shadow-primary/5 hover:border-primary/20">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
Contract Lines Context
|
||||
</p>
|
||||
<span
|
||||
className={`rounded-md border px-2 py-1 text-[10px] font-medium ${
|
||||
proofData.context.length > 0 && proofData.lineNumber
|
||||
? "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||
: "border-amber-500/25 bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
||||
}`}
|
||||
>
|
||||
{proofData.context.length > 0 && proofData.lineNumber
|
||||
? "Resolved from extracted text"
|
||||
: "Fallback snippet evidence"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{proofData.context.length > 0 && proofData.contextStartLine ? (
|
||||
<div className="mt-2 h-[320px] overflow-auto rounded-xl border border-border/60 bg-muted/20">
|
||||
<pre className="p-3 text-xs leading-6 text-muted-foreground whitespace-pre-wrap break-words">
|
||||
{proofData.context.map((line, idx) => {
|
||||
const currentLineNumber =
|
||||
proofData.contextStartLine! + idx;
|
||||
const isMatch =
|
||||
proofData.lineNumber === currentLineNumber;
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className={
|
||||
isMatch ? "font-bold text-primary block" : "block"
|
||||
}
|
||||
>
|
||||
{String(currentLineNumber).padStart(4, " ")} | {line}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 h-[320px] rounded-xl border border-border/60 bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Precise line mapping is unavailable for this field. The
|
||||
quoted snippet remains the verified AI evidence.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground/80">
|
||||
This usually happens when OCR compressed multiple lines,
|
||||
formatting changed, or the source value appears in a
|
||||
table-like structure.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
442
features/home/components/Features.tsx
Normal file
442
features/home/components/Features.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
"use client";
|
||||
import { useScrollAnimation } from "@/hooks/useScrollAnimation";
|
||||
import {
|
||||
MessageSquare,
|
||||
Brain,
|
||||
Shield,
|
||||
BarChart3,
|
||||
Bell,
|
||||
Lock,
|
||||
Sparkles,
|
||||
Link,
|
||||
Zap,
|
||||
Check,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import { GlowingEffect } from "@/components/ui/glowing-effect";
|
||||
// Feature Card Component with Glowing Effect and Dotted Background
|
||||
interface FeatureCardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ElementType;
|
||||
gradient: string;
|
||||
gridArea: string;
|
||||
children?: React.ReactNode;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
function FeatureCard({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
gradient,
|
||||
gridArea,
|
||||
children,
|
||||
delay = 0,
|
||||
}: FeatureCardProps) {
|
||||
const { ref, isVisible } = useScrollAnimation<HTMLLIElement>({
|
||||
threshold: 0.2,
|
||||
});
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={ref}
|
||||
className={`min-h-[16rem] list-none ${gridArea}`}
|
||||
style={{
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? "translateY(0)" : "translateY(30px)",
|
||||
transition: `all 0.6s cubic-bezier(0.16, 1, 0.3, 1) ${delay}s`,
|
||||
}}
|
||||
>
|
||||
<div className="relative h-full rounded-xl border border-slate-200/80 dark:border-slate-700/80 p-1.5 md:rounded-2xl md:p-2 hover:border-blue-500/40 dark:hover:border-blue-400/40 transition-colors duration-500 overflow-hidden">
|
||||
{/* Glowing Effect */}
|
||||
<GlowingEffect
|
||||
spread={40}
|
||||
glow={true}
|
||||
disabled={false}
|
||||
proximity={60}
|
||||
inactiveZone={0.01}
|
||||
borderWidth={1.5}
|
||||
/>
|
||||
<div className="relative flex h-full flex-col justify-between gap-3 overflow-hidden rounded-lg md:rounded-xl p-4 md:p-5 bg-white/60 dark:bg-slate-900/60 backdrop-blur-xl shadow-lg dark:shadow-[0px_0px_30px_0px_rgba(59,130,246,0.08)]">
|
||||
<div className="relative flex flex-1 flex-col justify-between gap-3">
|
||||
{/* Icon Container */}
|
||||
<div className="relative inline-flex w-fit">
|
||||
<div
|
||||
className={`p-2.5 rounded-xl ${gradient} shadow-md group-hover:shadow-lg transition-all duration-500`}
|
||||
>
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-bold text-base md:text-lg text-slate-900 dark:text-white leading-tight">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-xs md:text-sm text-slate-600 dark:text-slate-400 leading-relaxed line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Animated Content */}
|
||||
{children && <div className="mt-auto">{children}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// Animated Chat Messages (Compact)
|
||||
function ChatAnimation() {
|
||||
const messages = [
|
||||
{ text: "What's the liability cap?", isUser: true },
|
||||
{ text: "$5M per Section 8.3", isUser: false },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-2">
|
||||
{messages.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex ${msg.isUser ? "justify-end" : "justify-start"}`}
|
||||
style={{
|
||||
animation: `slide-up 0.3s ease-out forwards`,
|
||||
animationDelay: `${i * 0.6 + 1}s`,
|
||||
opacity: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[90%] px-3 py-1.5 rounded-xl text-xs shadow-sm ${
|
||||
msg.isUser
|
||||
? "bg-blue-600 text-white rounded-tr-sm"
|
||||
: "bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 rounded-tl-sm"
|
||||
}`}
|
||||
>
|
||||
{msg.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Document Extraction Animation (Compact)
|
||||
function ExtractionAnimation() {
|
||||
const fields = [
|
||||
{ label: "Contract Type", value: "Service", status: "done" },
|
||||
{ label: "Parties", value: "3 found", status: "done" },
|
||||
{ label: "Term", value: "24 mo", status: "processing" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{fields.map((field, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between p-2 rounded-lg bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm"
|
||||
style={{
|
||||
animation: `slide-up 0.3s ease-out forwards`,
|
||||
animationDelay: `${i * 0.2 + 0.5}s`,
|
||||
opacity: 0,
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px] text-slate-500 dark:text-slate-400 font-medium">
|
||||
{field.label}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-semibold text-slate-700 dark:text-slate-300">
|
||||
{field.value}
|
||||
</span>
|
||||
{field.status === "done" && (
|
||||
<Check className="w-3 h-3 text-emerald-500" />
|
||||
)}
|
||||
{field.status === "processing" && (
|
||||
<div className="w-3 h-3 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Blockchain Animation (Compact)
|
||||
function BlockchainAnimation() {
|
||||
return (
|
||||
<div className="mt-2 relative h-24">
|
||||
{/* Nodes */}
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-8 h-8 rounded-full bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center shadow-lg"
|
||||
style={{
|
||||
left: `${i * 33}%`,
|
||||
top: i % 2 === 0 ? "20%" : "50%",
|
||||
animation: `float 3s ease-in-out infinite`,
|
||||
animationDelay: `${i * 0.3}s`,
|
||||
}}
|
||||
>
|
||||
<Link className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Connection Lines */}
|
||||
<svg className="absolute inset-0 w-full h-full" style={{ zIndex: -1 }}>
|
||||
<defs>
|
||||
<linearGradient id="lineGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#8b5cf6" stopOpacity="0.5" />
|
||||
<stop offset="100%" stopColor="#a855f7" stopOpacity="0.5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{[0, 1].map((i) => (
|
||||
<line
|
||||
key={i}
|
||||
x1={`${i * 33 + 12}%`}
|
||||
y1={i % 2 === 0 ? "35%" : "65%"}
|
||||
x2={`${(i + 1) * 33 + 12}%`}
|
||||
y2={i % 2 === 0 ? "65%" : "35%"}
|
||||
stroke="url(#lineGradient)"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="4,4"
|
||||
className="animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* Transaction Hash */}
|
||||
<div
|
||||
className="absolute bottom-0 left-1/2 -translate-x-1/2 px-2.5 py-1 rounded-full bg-violet-500/15 dark:bg-violet-500/25 backdrop-blur-sm"
|
||||
style={{
|
||||
animation: `slide-up 0.4s ease-out forwards`,
|
||||
animationDelay: "1.2s",
|
||||
opacity: 0,
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px] font-mono font-semibold text-violet-600 dark:text-violet-400">
|
||||
0x7f8a...9b2c ✓
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Dashboard Mini Preview (Compact)
|
||||
function DashboardPreview() {
|
||||
return (
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
{/* Mini Chart */}
|
||||
<div className="col-span-2 bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm rounded-lg p-2 shadow-sm">
|
||||
<div className="flex items-end gap-0.5 h-14">
|
||||
{[30, 50, 40, 70, 55, 80, 65].map((h, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 bg-gradient-to-t from-blue-500 to-blue-400 rounded-t-sm transition-all duration-300 hover:opacity-80"
|
||||
style={{
|
||||
height: `${h}%`,
|
||||
animation: `slide-up 0.3s ease-out forwards`,
|
||||
animationDelay: `${i * 0.08 + 0.4}s`,
|
||||
opacity: 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm rounded-lg p-2 text-center shadow-sm">
|
||||
<TrendingUp className="w-4 h-4 mx-auto text-emerald-500 mb-0.5" />
|
||||
<span className="text-xs font-bold text-slate-700 dark:text-slate-300">
|
||||
+24%
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm rounded-lg p-2 text-center shadow-sm">
|
||||
<Zap className="w-4 h-4 mx-auto text-amber-500 mb-0.5" />
|
||||
<span className="text-xs font-bold text-slate-700 dark:text-slate-300">
|
||||
98.9%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Notification Animation (Compact)
|
||||
function NotificationAnimation() {
|
||||
const notifications = [
|
||||
{ icon: Bell, text: "Renewal in 7 days", color: "text-amber-500" },
|
||||
{ icon: Check, text: "Doc verified", color: "text-emerald-500" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{notifications.map((notif, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 p-2 rounded-lg bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm"
|
||||
style={{
|
||||
animation: `slide-up 0.3s ease-out forwards`,
|
||||
animationDelay: `${i * 0.4 + 0.6}s`,
|
||||
opacity: 0,
|
||||
}}
|
||||
>
|
||||
<notif.icon className={`w-4 h-4 flex-shrink-0 ${notif.color}`} />
|
||||
<span className="text-xs text-slate-700 dark:text-slate-300 font-medium truncate">
|
||||
{notif.text}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Security Animation (Compact)
|
||||
function SecurityAnimation() {
|
||||
return (
|
||||
<div className="mt-2 flex flex-col items-center">
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-500 to-green-600 flex items-center justify-center shadow-xl animate-pulse-glow">
|
||||
<Lock className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div className="absolute -top-0.5 -right-0.5 w-5 h-5 rounded-full bg-emerald-500 flex items-center justify-center shadow-md">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-1.5 flex-wrap justify-center">
|
||||
{["AES-256", "GDPR", "ISO"].map((badge, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-2.5 py-1 rounded-full text-[10px] font-bold bg-emerald-500/15 dark:bg-emerald-500/25 text-emerald-600 dark:text-emerald-400"
|
||||
style={{
|
||||
animation: `slide-up 0.3s ease-out forwards`,
|
||||
animationDelay: `${i * 0.15 + 0.4}s`,
|
||||
opacity: 0,
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Features() {
|
||||
const { ref: headerRef, isVisible: headerVisible } =
|
||||
useScrollAnimation<HTMLDivElement>();
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: "AI-Powered Assistant",
|
||||
description:
|
||||
"Ask questions about your contracts and get instant, precise answers with legal context.",
|
||||
icon: MessageSquare,
|
||||
gradient: "bg-gradient-to-br from-blue-500 to-blue-600",
|
||||
gridArea: "md:[grid-area:1/1/2/3]",
|
||||
content: <ChatAnimation />,
|
||||
},
|
||||
{
|
||||
title: "Document Extraction",
|
||||
description:
|
||||
"Advanced OCR + AI extracts and structures information from PDFs automatically.",
|
||||
icon: Brain,
|
||||
gradient: "bg-gradient-to-br from-teal-500 to-teal-600",
|
||||
gridArea: "md:[grid-area:1/3/2/4]",
|
||||
content: <ExtractionAnimation />,
|
||||
},
|
||||
{
|
||||
title: "Blockchain Proof",
|
||||
description:
|
||||
"Immutable timestamping on Polygon with cryptographic verification.",
|
||||
icon: Shield,
|
||||
gradient: "bg-gradient-to-br from-violet-500 to-purple-600",
|
||||
gridArea: "md:[grid-area:2/3/3/4]",
|
||||
content: <BlockchainAnimation />,
|
||||
},
|
||||
{
|
||||
title: "Analytics Dashboard",
|
||||
description:
|
||||
"Visualize contracts, renewals, and analytics in one interface.",
|
||||
icon: BarChart3,
|
||||
gradient: "bg-gradient-to-br from-indigo-500 to-indigo-600",
|
||||
gridArea: "md:[grid-area:2/1/3/3]",
|
||||
content: <DashboardPreview />,
|
||||
},
|
||||
{
|
||||
title: "Smart Alerts",
|
||||
description:
|
||||
"Automated notifications for renewals, deadlines, and key events.",
|
||||
icon: Bell,
|
||||
gradient: "bg-gradient-to-br from-amber-500 to-orange-600",
|
||||
gridArea: "md:[grid-area:3/3/4/4]",
|
||||
content: <NotificationAnimation />,
|
||||
},
|
||||
{
|
||||
title: "Enterprise Security",
|
||||
description: "AES-256 encryption, GDPR compliant with secure hosting.",
|
||||
icon: Lock,
|
||||
gradient: "bg-gradient-to-br from-emerald-500 to-green-600",
|
||||
gridArea: "md:[grid-area:3/1/4/3]",
|
||||
content: <SecurityAnimation />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section
|
||||
id="features"
|
||||
className="relative pt-16 pb-0 px-4 sm:px-6 lg:px-8 overflow-hidden"
|
||||
>
|
||||
{/* Background */}
|
||||
<div className="absolute inset-0 gradient-bg-mesh opacity-30" />
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-blue-50/50 to-transparent dark:via-slate-900/50" />
|
||||
|
||||
<div className="relative max-w-6xl mx-auto pb-6">
|
||||
{/* Section Header */}
|
||||
<div
|
||||
ref={headerRef}
|
||||
className="text-center mb-12"
|
||||
style={{
|
||||
opacity: headerVisible ? 1 : 0,
|
||||
transform: headerVisible ? "translateY(0)" : "translateY(20px)",
|
||||
transition: "all 0.6s cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
}}
|
||||
>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full glass mb-6 shadow-md">
|
||||
<Sparkles className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-xs font-bold text-slate-700 dark:text-slate-300">
|
||||
Features
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl md:text-5xl font-black text-slate-900 dark:text-white mb-4 leading-tight">
|
||||
Everything You Need to{" "}
|
||||
<span className="gradient-text">Manage Contracts</span>
|
||||
</h2>
|
||||
|
||||
<p className="text-lg md:text-xl text-slate-600 dark:text-slate-400 max-w-2xl mx-auto leading-relaxed">
|
||||
Powerful AI combined with blockchain security.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bento Grid - Compact */}
|
||||
<ul className="grid grid-cols-1 grid-rows-none gap-3 md:grid-cols-3 md:grid-rows-3 lg:gap-4">
|
||||
{features.map((feature, index) => (
|
||||
<FeatureCard
|
||||
key={feature.title}
|
||||
title={feature.title}
|
||||
description={feature.description}
|
||||
icon={feature.icon}
|
||||
gradient={feature.gradient}
|
||||
gridArea={feature.gridArea}
|
||||
delay={index * 0.08}
|
||||
>
|
||||
{feature.content}
|
||||
</FeatureCard>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
126
features/home/components/Footer.tsx
Normal file
126
features/home/components/Footer.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useScrollAnimation } from "@/hooks/useScrollAnimation";
|
||||
import { Sparkles, Github, Twitter, Linkedin, Mail } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
// Social Icon Component
|
||||
function SocialIcon({
|
||||
icon: Icon,
|
||||
href,
|
||||
label,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
href: string;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={label}
|
||||
className="text-slate-500 hover:text-slate-900 dark:hover:text-white transition-colors duration-200"
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function Footer() {
|
||||
const { ref, isVisible } = useScrollAnimation<HTMLElement>({
|
||||
threshold: 0.1,
|
||||
});
|
||||
|
||||
const navLinks = [
|
||||
{ label: "Features", href: "#features" },
|
||||
{ label: "How It Works", href: "#how-it-works" },
|
||||
{ label: "About", href: "#about" },
|
||||
];
|
||||
|
||||
return (
|
||||
<footer
|
||||
id="footer"
|
||||
ref={ref}
|
||||
className="relative bg-white dark:bg-slate-950 border-t border-slate-200 dark:border-slate-800 py-12 px-4 sm:px-6 lg:px-8"
|
||||
>
|
||||
<div
|
||||
className="max-w-7xl mx-auto"
|
||||
style={{
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? "translateY(0)" : "translateY(20px)",
|
||||
transition: "all 0.6s ease-out",
|
||||
}}
|
||||
>
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-col items-center space-y-8">
|
||||
{/* Logo */}
|
||||
<a href="#" className="inline-flex items-center gap-2 group">
|
||||
<div className="relative">
|
||||
<Image
|
||||
src="/LexiChain.png"
|
||||
alt="LexiChain Logo"
|
||||
width={48}
|
||||
height={48}
|
||||
className="
|
||||
relative z-10
|
||||
w-12 h-12 object-contain
|
||||
transition-all duration-300 ease-out
|
||||
group-hover:scale-110
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<nav className="flex items-center gap-8">
|
||||
{navLinks.map((link) => (
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
className="text-sm text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition-colors duration-200"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-full max-w-4xl h-px bg-slate-200 dark:bg-slate-800" />
|
||||
|
||||
{/* Bottom Row */}
|
||||
<div className="flex items-center gap-8">
|
||||
{/* Copyright */}
|
||||
<p className="text-sm text-slate-500 dark:text-slate-500">
|
||||
© LexiChain
|
||||
</p>
|
||||
|
||||
{/* Social Icons */}
|
||||
<div className="flex items-center gap-4">
|
||||
<SocialIcon
|
||||
icon={Twitter}
|
||||
href="https://twitter.com"
|
||||
label="Twitter"
|
||||
/>
|
||||
<SocialIcon
|
||||
icon={Linkedin}
|
||||
href="https://linkedin.com"
|
||||
label="LinkedIn"
|
||||
/>
|
||||
<SocialIcon
|
||||
icon={Github}
|
||||
href="https://github.com"
|
||||
label="GitHub"
|
||||
/>
|
||||
<SocialIcon
|
||||
icon={Mail}
|
||||
href="mailto:contact@lexichain.com"
|
||||
label="Email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
902
features/home/components/Hero.tsx
Normal file
902
features/home/components/Hero.tsx
Normal file
@@ -0,0 +1,902 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sparkles,
|
||||
Rocket,
|
||||
Lock,
|
||||
Check,
|
||||
Zap,
|
||||
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() {
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="ripple-gradient-1"
|
||||
x1="0%"
|
||||
y1="0%"
|
||||
x2="100%"
|
||||
y2="100%"
|
||||
>
|
||||
<stop offset="0%" stopColor="rgb(59, 130, 246)" stopOpacity="0.3" />
|
||||
<stop
|
||||
offset="50%"
|
||||
stopColor="rgb(139, 92, 246)"
|
||||
stopOpacity="0.2"
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="rgb(20, 184, 166)"
|
||||
stopOpacity="0.1"
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="ripple-gradient-2"
|
||||
x1="100%"
|
||||
y1="0%"
|
||||
x2="0%"
|
||||
y2="100%"
|
||||
>
|
||||
<stop offset="0%" stopColor="rgb(139, 92, 246)" stopOpacity="0.3" />
|
||||
<stop
|
||||
offset="50%"
|
||||
stopColor="rgb(59, 130, 246)"
|
||||
stopOpacity="0.2"
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="rgb(20, 184, 166)"
|
||||
stopOpacity="0.1"
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* First set of ripples */}
|
||||
<circle
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
r="0"
|
||||
fill="none"
|
||||
stroke="url(#ripple-gradient-1)"
|
||||
strokeWidth="2"
|
||||
opacity="0"
|
||||
>
|
||||
<animate
|
||||
attributeName="r"
|
||||
from="0"
|
||||
to="800"
|
||||
dur="8s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;0.5;0.3;0"
|
||||
dur="8s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
|
||||
<circle
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
r="0"
|
||||
fill="none"
|
||||
stroke="url(#ripple-gradient-2)"
|
||||
strokeWidth="2"
|
||||
opacity="0"
|
||||
>
|
||||
<animate
|
||||
attributeName="r"
|
||||
from="0"
|
||||
to="800"
|
||||
dur="8s"
|
||||
begin="2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;0.4;0.2;0"
|
||||
dur="8s"
|
||||
begin="2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
|
||||
<circle
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
r="0"
|
||||
fill="none"
|
||||
stroke="url(#ripple-gradient-1)"
|
||||
strokeWidth="3"
|
||||
opacity="0"
|
||||
>
|
||||
<animate
|
||||
attributeName="r"
|
||||
from="0"
|
||||
to="800"
|
||||
dur="8s"
|
||||
begin="4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;0.6;0.4;0"
|
||||
dur="8s"
|
||||
begin="4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
|
||||
<circle
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
r="0"
|
||||
fill="none"
|
||||
stroke="url(#ripple-gradient-2)"
|
||||
strokeWidth="2"
|
||||
opacity="0"
|
||||
>
|
||||
<animate
|
||||
attributeName="r"
|
||||
from="0"
|
||||
to="800"
|
||||
dur="8s"
|
||||
begin="6s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;0.5;0.3;0"
|
||||
dur="8s"
|
||||
begin="6s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
|
||||
{/* Additional slower ripples for depth */}
|
||||
<circle
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
r="0"
|
||||
fill="none"
|
||||
stroke="url(#ripple-gradient-1)"
|
||||
strokeWidth="4"
|
||||
opacity="0"
|
||||
>
|
||||
<animate
|
||||
attributeName="r"
|
||||
from="0"
|
||||
to="1000"
|
||||
dur="12s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;0.3;0.15;0"
|
||||
dur="12s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
|
||||
<circle
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
r="0"
|
||||
fill="none"
|
||||
stroke="url(#ripple-gradient-2)"
|
||||
strokeWidth="3"
|
||||
opacity="0"
|
||||
>
|
||||
<animate
|
||||
attributeName="r"
|
||||
from="0"
|
||||
to="1000"
|
||||
dur="12s"
|
||||
begin="4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;0.25;0.12;0"
|
||||
dur="12s"
|
||||
begin="4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Floating Orb Component
|
||||
function FloatingOrb({
|
||||
size,
|
||||
color,
|
||||
delay,
|
||||
duration,
|
||||
left,
|
||||
top,
|
||||
}: {
|
||||
size: number;
|
||||
color: string;
|
||||
delay: number;
|
||||
duration: number;
|
||||
left: string;
|
||||
top: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="absolute rounded-full blur-3xl pointer-events-none"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundColor: color,
|
||||
left,
|
||||
top,
|
||||
animation: `float-slow ${duration}s ease-in-out infinite`,
|
||||
animationDelay: `${delay}s`,
|
||||
opacity: 0.4,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Particle Component
|
||||
function Particle({
|
||||
delay,
|
||||
left,
|
||||
size,
|
||||
duration,
|
||||
}: {
|
||||
delay: number;
|
||||
left: string;
|
||||
size: number;
|
||||
duration: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="absolute rounded-full bg-blue-500/30 dark:bg-blue-400/30 pointer-events-none"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
left,
|
||||
bottom: "-10px",
|
||||
animation: `particle-float ${duration}s ease-in-out infinite`,
|
||||
animationDelay: `${delay}s`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Mockup Card Component
|
||||
function MockupCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
value,
|
||||
trend,
|
||||
delay,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
value: string;
|
||||
trend: string;
|
||||
delay: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="glass rounded-xl p-4 hover-lift cursor-pointer"
|
||||
style={{
|
||||
animation: `slide-up 0.6s ease-out forwards`,
|
||||
animationDelay: `${delay}s`,
|
||||
opacity: 0,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 rounded-lg bg-blue-500/10 dark:bg-blue-500/20">
|
||||
<Icon className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-end justify-between">
|
||||
<span className="text-2xl font-bold text-slate-800 dark:text-slate-200">
|
||||
{value}
|
||||
</span>
|
||||
<span className="text-xs text-emerald-500 font-medium">{trend}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Chat Message Component
|
||||
function ChatMessage({
|
||||
message,
|
||||
isAI,
|
||||
delay,
|
||||
}: {
|
||||
message: string;
|
||||
isAI: boolean;
|
||||
delay: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex ${isAI ? "justify-start" : "justify-end"} mb-3`}
|
||||
style={{
|
||||
animation: `slide-up 0.4s ease-out forwards`,
|
||||
animationDelay: `${delay}s`,
|
||||
opacity: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[80%] px-4 py-2 rounded-2xl text-sm ${
|
||||
isAI
|
||||
? "bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 rounded-tl-sm"
|
||||
: "bg-blue-600 text-white rounded-tr-sm"
|
||||
}`}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Hero() {
|
||||
const [isLoaded] = useState(true);
|
||||
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
|
||||
const heroRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const particles = useMemo(
|
||||
() => [
|
||||
{ left: "8%", size: 3, duration: 9 },
|
||||
{ left: "18%", size: 4, duration: 10 },
|
||||
{ left: "28%", size: 2, duration: 8 },
|
||||
{ left: "38%", size: 5, duration: 11 },
|
||||
{ left: "48%", size: 3, duration: 9 },
|
||||
{ left: "58%", size: 4, duration: 10 },
|
||||
{ left: "68%", size: 2, duration: 8 },
|
||||
{ left: "78%", size: 5, duration: 11 },
|
||||
{ left: "88%", size: 3, duration: 9 },
|
||||
{ left: "12%", size: 4, duration: 10 },
|
||||
{ left: "22%", size: 2, duration: 8 },
|
||||
{ left: "32%", size: 5, duration: 11 },
|
||||
{ left: "52%", size: 3, duration: 9 },
|
||||
{ left: "72%", size: 4, duration: 10 },
|
||||
{ left: "92%", size: 2, duration: 8 },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!heroRef.current) return;
|
||||
const rect = heroRef.current.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left - rect.width / 2) / rect.width;
|
||||
const y = (e.clientY - rect.top - rect.height / 2) / rect.height;
|
||||
setMousePosition({ x: x * 10, y: y * 10 });
|
||||
};
|
||||
|
||||
const heroElement = heroRef.current;
|
||||
if (heroElement) {
|
||||
heroElement.addEventListener("mousemove", handleMouseMove);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (heroElement) {
|
||||
heroElement.removeEventListener("mousemove", handleMouseMove);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const trustBadges = [
|
||||
{ icon: Lock, label: "Bank-Level Security" },
|
||||
{ icon: Check, label: "GDPR Certified" },
|
||||
{ icon: Zap, label: "Real-Time AI" },
|
||||
{ icon: Link2, label: "Blockchain Verified" },
|
||||
];
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={heroRef}
|
||||
className="relative min-h-screen flex flex-col items-center justify-center overflow-hidden pt-24 pb-16 px-4 sm:px-6 lg:px-8"
|
||||
>
|
||||
{/* Background Layer 1 - Animated Gradient Mesh */}
|
||||
<div className="absolute inset-0 gradient-bg-mesh animate-gradient-shift" />
|
||||
|
||||
{/* Background Layer 2 - Ripple Effect */}
|
||||
<BackgroundRipple />
|
||||
|
||||
{/* Background Layer 3 - Grid Pattern */}
|
||||
<div
|
||||
className="absolute inset-0 grid-pattern"
|
||||
style={{
|
||||
maskImage:
|
||||
"radial-gradient(ellipse at center, black 0%, transparent 70%)",
|
||||
WebkitMaskImage:
|
||||
"radial-gradient(ellipse at center, black 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Background Layer 4 - Floating Orbs */}
|
||||
<FloatingOrb
|
||||
size={200}
|
||||
color="rgba(59, 130, 246, 0.3)"
|
||||
delay={0}
|
||||
duration={8}
|
||||
left="10%"
|
||||
top="20%"
|
||||
/>
|
||||
<FloatingOrb
|
||||
size={150}
|
||||
color="rgba(139, 92, 246, 0.3)"
|
||||
delay={2}
|
||||
duration={10}
|
||||
left="70%"
|
||||
top="15%"
|
||||
/>
|
||||
<FloatingOrb
|
||||
size={180}
|
||||
color="rgba(20, 184, 166, 0.3)"
|
||||
delay={1}
|
||||
duration={9}
|
||||
left="80%"
|
||||
top="60%"
|
||||
/>
|
||||
<FloatingOrb
|
||||
size={120}
|
||||
color="rgba(59, 130, 246, 0.25)"
|
||||
delay={3}
|
||||
duration={7}
|
||||
left="15%"
|
||||
top="70%"
|
||||
/>
|
||||
<FloatingOrb
|
||||
size={100}
|
||||
color="rgba(139, 92, 246, 0.2)"
|
||||
delay={4}
|
||||
duration={11}
|
||||
left="50%"
|
||||
top="80%"
|
||||
/>
|
||||
|
||||
{/* Background Layer 5 - Spotlight Effect */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-30 dark:opacity-20"
|
||||
style={{
|
||||
background: `radial-gradient(600px circle at ${50 + mousePosition.x}% ${50 + mousePosition.y}%, rgba(59, 130, 246, 0.15), transparent 40%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Particles */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
{particles.map((particle, i) => (
|
||||
<Particle
|
||||
key={`${particle.left}-${particle.size}-${i}`}
|
||||
delay={i * 0.5}
|
||||
left={particle.left}
|
||||
size={particle.size}
|
||||
duration={particle.duration}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 max-w-7xl mx-auto text-center">
|
||||
{/* Announcement Badge */}
|
||||
<div
|
||||
className={`inline-flex items-center gap-2 px-4 py-2 mt-4 rounded-full glass mb-8 transition-all duration-700 ${
|
||||
isLoaded ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-4"
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-4 h-4 text-blue-600 dark:text-blue-400 animate-spin-slow" />
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
Powered by Ai + Blockchain Integration
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Main Headline */}
|
||||
<h1
|
||||
className={`text-4xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl font-black leading-[1.1] tracking-tight max-w-6xl mx-auto transition-all duration-700 delay-100 ${
|
||||
isLoaded ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
}`}
|
||||
>
|
||||
<span className="block text-slate-900 dark:text-white">
|
||||
Transform Your Contracts
|
||||
</span>
|
||||
<span className="block mt-2 gradient-text">
|
||||
Into Actionable Intelligence
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{/* Subheadline */}
|
||||
<p
|
||||
className={`mt-6 text-lg md:text-xl lg:text-2xl text-slate-600 dark:text-slate-400 max-w-3xl mx-auto leading-relaxed transition-all duration-700 delay-200 ${
|
||||
isLoaded ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
}`}
|
||||
>
|
||||
AI-Powered contract analysis meets blockchain verification. The only
|
||||
platform that truly understands your banking and insurance documents.
|
||||
<span className="text-blue-600 dark:text-blue-400 font-medium">
|
||||
{" "}
|
||||
Secure, transparent, instant.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<div
|
||||
className={`flex flex-wrap items-center justify-center gap-4 mt-10 transition-all duration-700 delay-300 ${
|
||||
isLoaded ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
}`}
|
||||
>
|
||||
{/* Primary CTA */}
|
||||
<SignedOut>
|
||||
<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="/sign-in">
|
||||
{/* Glow background layer */}
|
||||
<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
|
||||
"
|
||||
/>
|
||||
|
||||
{/* 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="
|
||||
w-6 h-6
|
||||
transition-transform duration-300
|
||||
group-hover:translate-x-1
|
||||
group-hover:-translate-y-1
|
||||
"
|
||||
/>
|
||||
</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 */}
|
||||
<div
|
||||
className={`flex flex-wrap items-center justify-center gap-3 mt-12 transition-all duration-700 delay-400 ${
|
||||
isLoaded ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
}`}
|
||||
>
|
||||
{trustBadges.map((badge, index) => (
|
||||
<div
|
||||
key={badge.label}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-full glass hover-lift cursor-default"
|
||||
style={{
|
||||
animation: isLoaded
|
||||
? `slide-up 0.5s ease-out forwards`
|
||||
: "none",
|
||||
animationDelay: `${0.5 + index * 0.1}s`,
|
||||
opacity: 0,
|
||||
}}
|
||||
>
|
||||
<badge.icon className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{badge.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Hero Visual - Dashboard Mockup */}
|
||||
<div
|
||||
className={`mt-16 lg:mt-20 perspective-1000 transition-all duration-1000 delay-500 ${
|
||||
isLoaded ? "opacity-100 translate-y-0" : "opacity-0 translate-y-16"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="relative max-w-5xl mx-auto transform-3d"
|
||||
style={{
|
||||
transform: `rotateX(-5deg) rotateY(${mousePosition.x * 0.3}deg)`,
|
||||
transition: "transform 0.1s ease-out",
|
||||
}}
|
||||
>
|
||||
{/* Main Dashboard Container */}
|
||||
<div className="relative rounded-3xl overflow-hidden shadow-[0_60px_120px_rgba(0,0,0,0.25)] border border-slate-200/50 dark:border-slate-700/50">
|
||||
{/* Dashboard Header */}
|
||||
<div className="bg-slate-50 dark:bg-slate-900/90 backdrop-blur-xl px-6 py-4 border-b border-slate-200/50 dark:border-slate-700/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-400" />
|
||||
<div className="w-3 h-3 rounded-full bg-amber-400" />
|
||||
<div className="w-3 h-3 rounded-full bg-emerald-400" />
|
||||
</div>
|
||||
<div className="h-6 w-px bg-slate-300 dark:bg-slate-700" />
|
||||
<span className="text-sm font-medium text-slate-600 dark:text-slate-400">
|
||||
Dashboard
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-500 to-violet-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard Content */}
|
||||
<div className="bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl p-6 grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Left Column - Stats */}
|
||||
<div className="space-y-4">
|
||||
<MockupCard
|
||||
icon={FileText}
|
||||
title="Total Contracts"
|
||||
value="1,247"
|
||||
trend="+12%"
|
||||
delay={0.6}
|
||||
/>
|
||||
<MockupCard
|
||||
icon={Shield}
|
||||
title="Verified"
|
||||
value="98.5%"
|
||||
trend="+2.1%"
|
||||
delay={0.7}
|
||||
/>
|
||||
<MockupCard
|
||||
icon={TrendingUp}
|
||||
title="Processing"
|
||||
value="24ms"
|
||||
trend="-15%"
|
||||
delay={0.8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Center Column - Chat Interface */}
|
||||
<div className="md:col-span-1 glass rounded-xl p-4 flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-4 pb-3 border-b border-slate-200/50 dark:border-slate-700/50">
|
||||
<div className="p-2 rounded-lg bg-violet-500/10 dark:bg-violet-500/20">
|
||||
<MessageSquare className="w-4 h-4 text-violet-600 dark:text-violet-400" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-slate-700 dark:text-slate-300">
|
||||
AI Assistant
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<ChatMessage
|
||||
message="What are the key clauses in Contract #4521?"
|
||||
isAI={false}
|
||||
delay={0.9}
|
||||
/>
|
||||
<ChatMessage
|
||||
message="I've analyzed the contract. Key clauses include: Termination (Section 4.2), Liability Cap ($2M), and Governing Law (Delaware)."
|
||||
isAI={true}
|
||||
delay={1.1}
|
||||
/>
|
||||
<ChatMessage
|
||||
message="When does it expire?"
|
||||
isAI={false}
|
||||
delay={1.3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Notifications & Chart (Equal Heights) */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Notifications - 50% height */}
|
||||
<div
|
||||
className="glass rounded-xl p-4 flex-1"
|
||||
style={{
|
||||
animation: `slide-up 0.6s ease-out forwards`,
|
||||
animationDelay: `1s`,
|
||||
opacity: 0,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Bell className="w-4 h-4 text-amber-500" />
|
||||
<span className="text-sm font-semibold text-slate-700 dark:text-slate-300">
|
||||
Recent Activity
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{
|
||||
text: "Contract #4521 verified",
|
||||
time: "2m ago",
|
||||
color: "bg-emerald-500",
|
||||
},
|
||||
{
|
||||
text: "New AI analysis complete",
|
||||
time: "5m ago",
|
||||
color: "bg-blue-500",
|
||||
},
|
||||
{
|
||||
text: "Blockchain timestamp added",
|
||||
time: "12m ago",
|
||||
color: "bg-violet-500",
|
||||
},
|
||||
].map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${item.color}`}
|
||||
/>
|
||||
<span className="text-slate-600 dark:text-slate-400 flex-1">
|
||||
{item.text}
|
||||
</span>
|
||||
<span className="text-slate-400 dark:text-slate-500">
|
||||
{item.time}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini Chart - 50% height */}
|
||||
<div
|
||||
className="glass rounded-xl p-4 flex-1 flex flex-col"
|
||||
style={{
|
||||
animation: `slide-up 0.6s ease-out forwards`,
|
||||
animationDelay: `1.2s`,
|
||||
opacity: 0,
|
||||
}}
|
||||
>
|
||||
<span className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3 block">
|
||||
Processing Volume
|
||||
</span>
|
||||
<div className="flex items-end gap-1 flex-1">
|
||||
{[40, 65, 45, 80, 55, 90, 70, 85, 60, 95, 75, 88].map(
|
||||
(height, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 bg-gradient-to-t from-blue-500 to-violet-500 rounded-t-sm transition-all duration-300 hover:opacity-80"
|
||||
style={{
|
||||
height: `${height}%`,
|
||||
animation: `slide-up 0.4s ease-out forwards`,
|
||||
animationDelay: `${1.3 + i * 0.05}s`,
|
||||
opacity: 0,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Gradient Overlay */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-20 bg-gradient-to-t from-white/50 dark:from-slate-900/50 to-transparent pointer-events-none" />
|
||||
</div>
|
||||
|
||||
{/* Floating Elements Around Mockup */}
|
||||
<div
|
||||
className="absolute -top-4 -right-4 glass rounded-lg px-3 py-2 shadow-lg animate-float"
|
||||
style={{ animationDelay: "0.5s" }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-emerald-500" />
|
||||
<span className="text-xs font-medium text-slate-700 dark:text-slate-300">
|
||||
Contract Verified!
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute -bottom-4 -left-4 glass rounded-lg px-3 py-2 shadow-lg animate-float-delayed">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-xs font-medium text-slate-700 dark:text-slate-300">
|
||||
+1 Document
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-1/2 -right-8 glass rounded-full p-3 shadow-lg animate-bounce-subtle">
|
||||
<Lock className="w-5 h-5 text-violet-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
154
features/home/components/HomePage.tsx
Normal file
154
features/home/components/HomePage.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Navbar } from "@/features/home/components/Navbar";
|
||||
import { Hero } from "@/features/home/components/Hero";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
// Dynamically load below-the-fold components to improve initial response time and rendering speed
|
||||
const Features = dynamic(() => import("@/features/home/components/Features").then(mod => mod.Features), { ssr: true });
|
||||
const HowItWorks = dynamic(() => import("@/features/home/components/HowItWorks").then(mod => mod.HowItWorks), { ssr: true });
|
||||
const Stats = dynamic(() => import("@/features/home/components/Stats").then(mod => mod.Stats), { ssr: true });
|
||||
const Footer = dynamic(() => import("@/features/home/components/Footer").then(mod => mod.Footer), { ssr: true });
|
||||
|
||||
function LoadingScreen({ onComplete }: { onComplete: () => void }) {
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
if (prev >= 100) {
|
||||
clearInterval(interval);
|
||||
setTimeout(onComplete, 300);
|
||||
return 100;
|
||||
}
|
||||
return prev + Math.random() * 15;
|
||||
});
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [onComplete]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] bg-slate-900 flex flex-col items-center justify-center">
|
||||
<div className="relative mb-8">
|
||||
<div className="absolute inset-0 bg-blue-500/30 blur-3xl rounded-full animate-pulse" />
|
||||
<Sparkles
|
||||
className="w-16 h-16 text-blue-500 relative z-10"
|
||||
style={{ animation: "spin-slow 3s linear infinite" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold gradient-text mb-8">LexiChain</h1>
|
||||
|
||||
<div className="w-64 h-1 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 via-violet-500 to-teal-500 rounded-full transition-all duration-200"
|
||||
style={{ width: `${Math.min(progress, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm text-slate-500">
|
||||
Loading amazing experience...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollProgressBar() {
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const scrollTop = window.scrollY;
|
||||
const docHeight =
|
||||
document.documentElement.scrollHeight - window.innerHeight;
|
||||
const scrollPercent = (scrollTop / docHeight) * 100;
|
||||
setProgress(scrollPercent);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 right-0 z-[60] h-1 bg-transparent">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 via-violet-500 to-teal-500 transition-all duration-100"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackToTop() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsVisible(window.scrollY > 500);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={scrollToTop}
|
||||
className={`fixed bottom-8 right-8 z-50 w-12 h-12 rounded-full bg-gradient-to-r from-blue-600 to-violet-600 text-white shadow-lg hover:shadow-xl hover:scale-110 transition-all duration-300 flex items-center justify-center ${
|
||||
isVisible
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-10 pointer-events-none"
|
||||
}`}
|
||||
aria-label="Back to top"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const handleLoadingComplete = () => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-background text-foreground overflow-x-hidden">
|
||||
{isLoading && <LoadingScreen onComplete={handleLoadingComplete} />}
|
||||
<ScrollProgressBar />
|
||||
|
||||
<Navbar />
|
||||
|
||||
<main className="relative">
|
||||
<Hero />
|
||||
<Features />
|
||||
<HowItWorks />
|
||||
<Stats />
|
||||
<Footer />
|
||||
</main>
|
||||
|
||||
<BackToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
529
features/home/components/HowItWorks.tsx
Normal file
529
features/home/components/HowItWorks.tsx
Normal file
@@ -0,0 +1,529 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Upload,
|
||||
Cpu,
|
||||
MessageSquare,
|
||||
Shield,
|
||||
FileText,
|
||||
Check,
|
||||
Sparkles,
|
||||
Zap,
|
||||
Link,
|
||||
Target,
|
||||
} from "lucide-react";
|
||||
import { useScrollAnimation } from "@/hooks/useScrollAnimation";
|
||||
import { GlowingEffect } from "@/components/ui/glowing-effect";
|
||||
import { BackgroundBeams } from "@/components/ui/background-beams";
|
||||
|
||||
// Step Card Component with Glowing Effect
|
||||
interface StepCardProps {
|
||||
number: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ElementType;
|
||||
gradient: string;
|
||||
glowColor: string;
|
||||
delay: number;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function StepCard({
|
||||
number,
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
gradient,
|
||||
glowColor,
|
||||
delay,
|
||||
children,
|
||||
}: StepCardProps) {
|
||||
const { ref, isVisible } = useScrollAnimation<HTMLDivElement>({
|
||||
threshold: 0.2,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="relative h-full"
|
||||
style={{
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? "translateY(0)" : "translateY(40px)",
|
||||
transition: `all 0.8s cubic-bezier(0.16, 1, 0.3, 1) ${delay}s`,
|
||||
}}
|
||||
>
|
||||
{/* Glowing Border Card */}
|
||||
<div className="relative h-full rounded-2xl border border-slate-200/80 dark:border-slate-700/80 p-2 hover:border-blue-500/40 dark:hover:border-blue-400/40 transition-colors duration-500 overflow-hidden">
|
||||
{/* Rainbow Glowing Effect */}
|
||||
<GlowingEffect
|
||||
spread={60}
|
||||
glow={true}
|
||||
disabled={false}
|
||||
proximity={100}
|
||||
inactiveZone={0.01}
|
||||
borderWidth={2}
|
||||
variant="default"
|
||||
/>
|
||||
|
||||
<div className="relative h-full flex flex-col rounded-xl p-6 bg-white/90 dark:bg-slate-900/90 backdrop-blur-xl shadow-lg">
|
||||
{/* Modern Number Badge */}
|
||||
<div className="absolute -top-3 -right-3 z-10">
|
||||
<div className="relative">
|
||||
{/* Outer glow ring */}
|
||||
<div
|
||||
className={`absolute inset-0 ${gradient} rounded-2xl blur-xl opacity-60 animate-pulse-glow`}
|
||||
style={{ padding: "4px" }}
|
||||
/>
|
||||
|
||||
{/* Badge container */}
|
||||
<div className="relative">
|
||||
{/* Glass background */}
|
||||
<div className="absolute inset-0 bg-white/90 dark:bg-slate-900/90 backdrop-blur-xl rounded-2xl" />
|
||||
|
||||
{/* Gradient border */}
|
||||
<div
|
||||
className={`absolute inset-0 ${gradient} rounded-2xl p-[2px]`}
|
||||
>
|
||||
<div className="w-full h-full bg-white dark:bg-slate-900 rounded-2xl" />
|
||||
</div>
|
||||
|
||||
{/* Number */}
|
||||
<div className="relative px-4 py-2 flex items-center justify-center">
|
||||
<span
|
||||
className={`text-2xl font-black bg-gradient-to-br ${gradient.replace("bg-", "from-")} to-transparent bg-clip-text text-transparent`}
|
||||
>
|
||||
{number}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon with Glow */}
|
||||
<div className="relative inline-flex mb-6">
|
||||
<div
|
||||
className={`absolute inset-0 ${gradient} rounded-2xl blur-2xl opacity-40 animate-pulse-glow`}
|
||||
/>
|
||||
<div
|
||||
className={`relative w-20 h-20 rounded-2xl ${gradient} flex items-center justify-center shadow-2xl`}
|
||||
>
|
||||
<Icon className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl md:text-2xl font-black text-slate-900 dark:text-white mb-3 leading-tight">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm md:text-base text-slate-600 dark:text-slate-400 leading-relaxed mb-4">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Animation Content */}
|
||||
{children && <div className="mt-auto">{children}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Upload Animation
|
||||
function UploadAnimation() {
|
||||
const { ref, isVisible } = useScrollAnimation<HTMLDivElement>({
|
||||
threshold: 0.3,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={ref} className="mt-4">
|
||||
{/* Drop Zone */}
|
||||
<div
|
||||
className="relative border-2 border-dashed border-blue-400/60 dark:border-blue-500/60 rounded-xl p-4 text-center overflow-hidden group hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
|
||||
style={{
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? "scale(1)" : "scale(0.95)",
|
||||
transition: "all 0.5s ease-out 0.3s",
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/5 via-violet-500/5 to-teal-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
|
||||
<Upload
|
||||
className="w-6 h-6 mx-auto text-blue-500 mb-2 relative z-10"
|
||||
style={{
|
||||
animation: isVisible
|
||||
? "bounce-subtle 2s ease-in-out infinite"
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400 relative z-10">
|
||||
Drop files here
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
<div className="mt-3 space-y-2">
|
||||
{[
|
||||
{ name: "contract.pdf", size: "2.4 MB", progress: 100 },
|
||||
{ name: "agreement.jpg", size: "1.8 MB", progress: 75 },
|
||||
].map((file, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 p-2 rounded-lg bg-slate-50/80 dark:bg-slate-800/80 backdrop-blur-sm"
|
||||
style={{
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? "translateX(0)" : "translateX(-20px)",
|
||||
transition: `all 0.4s ease-out ${0.5 + i * 0.2}s`,
|
||||
}}
|
||||
>
|
||||
<FileText className="w-4 h-4 text-blue-500 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-slate-700 dark:text-slate-300 truncate">
|
||||
{file.name}
|
||||
</p>
|
||||
<div className="h-1 bg-slate-200 dark:bg-slate-700 rounded-full mt-1 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 via-violet-500 to-teal-500 rounded-full transition-all duration-1000"
|
||||
style={{
|
||||
width: isVisible ? `${file.progress}%` : "0%",
|
||||
transitionDelay: `${0.8 + i * 0.2}s`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500">{file.size}</span>
|
||||
{file.progress === 100 && (
|
||||
<Check className="w-3 h-3 text-emerald-500" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// AI Analysis Animation
|
||||
function AIAnalysisAnimation() {
|
||||
const { ref, isVisible } = useScrollAnimation<HTMLDivElement>({
|
||||
threshold: 0.3,
|
||||
});
|
||||
|
||||
const scanHeights = useMemo(
|
||||
() => [
|
||||
22, 38, 55, 40, 62, 30, 70, 45, 58, 35, 66, 28, 52, 48, 60, 33, 57, 41,
|
||||
64, 36,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const analysisPoints = [
|
||||
{ label: "Clauses", value: "47", icon: Target, color: "text-blue-500" },
|
||||
{ label: "Risk", value: "Low", icon: Shield, color: "text-emerald-500" },
|
||||
{ label: "Speed", value: "2.3s", icon: Zap, color: "text-amber-500" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div ref={ref} className="mt-4">
|
||||
{/* AI Brain */}
|
||||
<div className="flex justify-center mb-3">
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-br from-violet-500 to-purple-600 rounded-2xl blur-xl opacity-60"
|
||||
style={{
|
||||
animation: isVisible
|
||||
? "pulse-glow 2s ease-in-out infinite"
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
<div className="relative w-14 h-14 rounded-2xl bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center shadow-xl">
|
||||
<Cpu className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div className="absolute -top-1 -right-1">
|
||||
<Sparkles className="w-4 h-4 text-amber-400 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analysis Points */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{analysisPoints.map((point, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="p-2 rounded-lg bg-slate-50/80 dark:bg-slate-800/80 backdrop-blur-sm text-center"
|
||||
style={{
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? "scale(1)" : "scale(0.9)",
|
||||
transition: `all 0.4s ease-out ${0.3 + i * 0.15}s`,
|
||||
}}
|
||||
>
|
||||
<point.icon className={`w-4 h-4 mx-auto mb-1 ${point.color}`} />
|
||||
<p className="text-xs font-bold text-slate-800 dark:text-slate-200">
|
||||
{point.value}
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-500">{point.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Scanning Bar with Waveform */}
|
||||
<div className="mt-3 relative h-12 rounded-lg bg-slate-100/80 dark:bg-slate-800/80 overflow-hidden">
|
||||
{/* Background waveform */}
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-0.5 opacity-20">
|
||||
{scanHeights.map((height, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-1 bg-slate-400 dark:bg-slate-600 rounded-full"
|
||||
style={{ height: `${height}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Scanning line */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-0.5 bg-gradient-to-b from-violet-500 via-purple-500 to-transparent"
|
||||
style={{
|
||||
left: isVisible ? "100%" : "0%",
|
||||
transition: "left 2s ease-in-out 0.5s",
|
||||
boxShadow: "0 0 20px rgba(139, 92, 246, 0.6)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Chat Animation
|
||||
function ChatStepAnimation() {
|
||||
const { ref, isVisible } = useScrollAnimation<HTMLDivElement>({
|
||||
threshold: 0.3,
|
||||
});
|
||||
|
||||
const messages = [
|
||||
{ text: "Explain termination clause", isUser: true },
|
||||
{ text: "Section 12.3: 30 days notice...", isUser: false },
|
||||
];
|
||||
|
||||
return (
|
||||
<div ref={ref} className="mt-4">
|
||||
<div className="space-y-2">
|
||||
{messages.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex ${msg.isUser ? "justify-end" : "justify-start"} items-end gap-2`}
|
||||
style={{
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? "translateY(0)" : "translateY(10px)",
|
||||
transition: `all 0.4s ease-out ${0.3 + i * 0.4}s`,
|
||||
}}
|
||||
>
|
||||
{!msg.isUser && (
|
||||
<div className="w-6 h-6 rounded-full bg-gradient-to-br from-teal-500 to-cyan-600 flex items-center justify-center flex-shrink-0 shadow-lg">
|
||||
<Sparkles className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[75%] px-3 py-2 rounded-xl text-xs shadow-sm ${
|
||||
msg.isUser
|
||||
? "bg-blue-600 text-white rounded-tr-sm"
|
||||
: "bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 rounded-tl-sm"
|
||||
}`}
|
||||
>
|
||||
{msg.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* RAG Indicator */}
|
||||
<div
|
||||
className="mt-3 flex items-center justify-center gap-1.5 text-[10px] text-slate-500"
|
||||
style={{
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transition: "opacity 0.4s ease-out 1.2s",
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-0.5">
|
||||
{[0, 0.2, 0.4].map((delay, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="w-1.5 h-1.5 rounded-full bg-teal-500 animate-pulse"
|
||||
style={{ animationDelay: `${delay}s` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span>RAG Powered</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Blockchain Animation
|
||||
function BlockchainStepAnimation() {
|
||||
const { ref, isVisible } = useScrollAnimation<HTMLDivElement>({
|
||||
threshold: 0.3,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={ref} className="mt-4">
|
||||
{/* Blockchain Chain */}
|
||||
<div className="flex items-center justify-center gap-2 mb-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="relative">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg bg-gradient-to-br from-emerald-500 to-green-600 flex items-center justify-center shadow-lg"
|
||||
style={{
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? "scale(1)" : "scale(0.8)",
|
||||
transition: `all 0.4s ease-out ${0.2 + i * 0.2}s`,
|
||||
}}
|
||||
>
|
||||
<Link className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
{i < 2 && (
|
||||
<div
|
||||
className="absolute top-1/2 -right-2 w-4 h-0.5 bg-gradient-to-r from-emerald-500 to-green-500"
|
||||
style={{
|
||||
transform: isVisible ? "scaleX(1)" : "scaleX(0)",
|
||||
transformOrigin: "left",
|
||||
transition: `transform 0.3s ease-out ${0.4 + i * 0.2}s`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Certificate */}
|
||||
<div
|
||||
className="rounded-xl p-3 bg-gradient-to-br from-emerald-50 to-green-50 dark:from-emerald-950/30 dark:to-green-950/30 border border-emerald-200/50 dark:border-emerald-700/50"
|
||||
style={{
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? "translateY(0)" : "translateY(10px)",
|
||||
transition: "all 0.5s ease-out 0.8s",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<Shield className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-bold text-emerald-900 dark:text-emerald-100">
|
||||
Polygon Verified
|
||||
</p>
|
||||
<p className="text-[10px] font-mono text-emerald-700 dark:text-emerald-300 truncate">
|
||||
0x3f7a...9e2d
|
||||
</p>
|
||||
</div>
|
||||
<Check className="w-4 h-4 text-emerald-600 dark:text-emerald-400 flex-shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HowItWorks() {
|
||||
const { ref: headerRef, isVisible: headerVisible } =
|
||||
useScrollAnimation<HTMLDivElement>();
|
||||
|
||||
const steps = [
|
||||
{
|
||||
number: "01",
|
||||
title: "Upload Contract",
|
||||
description:
|
||||
"PDF, images, or scans. Drag and drop or browse to upload instantly.",
|
||||
icon: Upload,
|
||||
gradient: "bg-gradient-to-br from-blue-500 to-blue-600",
|
||||
glowColor: "rgba(59, 130, 246, 0.5)",
|
||||
animation: <UploadAnimation />,
|
||||
},
|
||||
{
|
||||
number: "02",
|
||||
title: "AI Analysis",
|
||||
description:
|
||||
"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)",
|
||||
animation: <AIAnalysisAnimation />,
|
||||
},
|
||||
{
|
||||
number: "03",
|
||||
title: "Chat with AI",
|
||||
description:
|
||||
"Ask anything. Get instant, precise answers powered by RAG technology.",
|
||||
icon: MessageSquare,
|
||||
gradient: "bg-gradient-to-br from-teal-500 to-cyan-600",
|
||||
glowColor: "rgba(20, 184, 166, 0.5)",
|
||||
animation: <ChatStepAnimation />,
|
||||
},
|
||||
{
|
||||
number: "04",
|
||||
title: "Blockchain Proof",
|
||||
description:
|
||||
"Immutable certification on Polygon. Legally valid timestamped proof.",
|
||||
icon: Shield,
|
||||
gradient: "bg-gradient-to-br from-emerald-500 to-green-600",
|
||||
glowColor: "rgba(16, 185, 129, 0.5)",
|
||||
animation: <BlockchainStepAnimation />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section
|
||||
id="how-it-works"
|
||||
className="relative py-20 px-4 sm:px-6 lg:px-8 overflow-hidden"
|
||||
>
|
||||
{/* Background with Beams */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-slate-50 via-blue-50/30 to-slate-50 dark:from-slate-950 dark:via-slate-900 dark:to-slate-950">
|
||||
<BackgroundBeams className="opacity-55" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto">
|
||||
{/* Section Header */}
|
||||
<div
|
||||
ref={headerRef}
|
||||
className="text-center mb-16"
|
||||
style={{
|
||||
opacity: headerVisible ? 1 : 0,
|
||||
transform: headerVisible ? "translateY(0)" : "translateY(30px)",
|
||||
transition: "all 0.8s cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
}}
|
||||
>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full glass mb-6 shadow-lg">
|
||||
<Target className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm font-bold text-slate-700 dark:text-slate-300">
|
||||
Simple Process
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl md:text-5xl font-black text-slate-900 dark:text-white mb-4 leading-tight">
|
||||
From Upload to Certification in{" "}
|
||||
<span className="gradient-text">4 Easy Steps</span>
|
||||
</h2>
|
||||
|
||||
<p className="text-lg md:text-xl text-slate-600 dark:text-slate-400 max-w-2xl mx-auto">
|
||||
Our intelligent system handles everything automatically
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Steps Grid - Better Alignment */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-4">
|
||||
{steps.map((step, index) => (
|
||||
<StepCard
|
||||
key={step.number}
|
||||
number={step.number}
|
||||
title={step.title}
|
||||
description={step.description}
|
||||
icon={step.icon}
|
||||
gradient={step.gradient}
|
||||
glowColor={step.glowColor}
|
||||
delay={index * 0.1}
|
||||
>
|
||||
{step.animation}
|
||||
</StepCard>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
244
features/home/components/Navbar.tsx
Normal file
244
features/home/components/Navbar.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ModeToggle } from "@/components/ui/mode-toggle";
|
||||
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" },
|
||||
{ label: "How It Works", href: "#how-it-works" },
|
||||
{ label: "About", href: "#stats" },
|
||||
{ label: "Contact", href: "#footer" },
|
||||
];
|
||||
|
||||
export function Navbar() {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [activeLink, setActiveLink] = useState("");
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 20);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
const handleLinkClick = (
|
||||
e: React.MouseEvent<HTMLAnchorElement>,
|
||||
href: string,
|
||||
) => {
|
||||
e.preventDefault();
|
||||
const targetId = href.replace("#", "");
|
||||
const element = document.getElementById(targetId);
|
||||
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
setActiveLink(href);
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-500 ${
|
||||
isScrolled ? "mt-0 px-0" : "mt-6 px-4"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`mx-auto transition-all duration-500 ${
|
||||
isScrolled ? "max-w-full" : "max-w-6xl"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`glass rounded-full px-4 md:px-8 py-3 md:py-4 transition-all duration-500 ${
|
||||
isScrolled
|
||||
? "rounded-none shadow-lg backdrop-blur-2xl"
|
||||
: "shadow-xl hover:shadow-2xl"
|
||||
} ${
|
||||
isScrolled
|
||||
? "border-b border-slate-200/50 dark:border-slate-700/50"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Logo */}
|
||||
<a
|
||||
href="#"
|
||||
className="flex items-center gap-3 group"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
>
|
||||
<div className="relative flex items-center justify-center">
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden lg:flex items-center gap-8">
|
||||
{navLinks.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={(e) => handleLinkClick(e, link.href)}
|
||||
className={`relative px-4 py-2 text-sm font-medium transition-all duration-200 rounded-full ${
|
||||
activeLink === link.href
|
||||
? "text-blue-600 dark:text-blue-400"
|
||||
: "text-slate-700 dark:text-slate-300 hover:bg-slate-100/50 dark:hover:bg-slate-800/50"
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
{activeLink === link.href && (
|
||||
<span className="absolute bottom-0 left-1/2 -translate-x-1/2 w-1 h-1 bg-blue-600 dark:bg-blue-400 rounded-full" />
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right Section */}
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<ModeToggle />
|
||||
{/* Global Clerk context usage */}
|
||||
<SignedOut>
|
||||
<SignInButton mode="modal">
|
||||
<Button variant="outline" className="hidden md:flex">
|
||||
Sign In
|
||||
</Button>
|
||||
</SignInButton>
|
||||
|
||||
<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>
|
||||
|
||||
<UserButton afterSignOutUrl="/" />
|
||||
</SignedIn>
|
||||
|
||||
<Button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
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-current transition-all ${isMobileMenuOpen ? "top-3 rotate-45" : "top-1"}`}
|
||||
/>
|
||||
<span
|
||||
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-current transition-all ${isMobileMenuOpen ? "top-3 -rotate-45" : "top-5"}`}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<div
|
||||
className={`fixed inset-0 z-40 lg:hidden transition-all duration-500 ${isMobileMenuOpen ? "opacity-100 visible" : "opacity-0 invisible"}`}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
|
||||
<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"}`}
|
||||
>
|
||||
<div className="p-6 pt-20">
|
||||
<Button
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="absolute top-6 right-6"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{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"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
389
features/home/components/Stats.tsx
Normal file
389
features/home/components/Stats.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import {
|
||||
Target,
|
||||
Zap,
|
||||
Shield,
|
||||
Lock,
|
||||
TrendingUp,
|
||||
Award,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { BentoGrid } from "@/components/ui/bento-grid";
|
||||
import { Spotlight } from "@/components/ui/spotlight-new";
|
||||
import { GlowingEffect } from "@/components/ui/glowing-effect";
|
||||
import { useScrollAnimation } from "@/hooks/useScrollAnimation";
|
||||
|
||||
// Count Up Hook
|
||||
function useCountUp(
|
||||
end: number,
|
||||
duration: number = 2000,
|
||||
startOnView: boolean = true,
|
||||
) {
|
||||
const [count, setCount] = useState(0);
|
||||
const [hasStarted, setHasStarted] = useState(!startOnView);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!startOnView) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting && !hasStarted) {
|
||||
setHasStarted(true);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.5 },
|
||||
);
|
||||
|
||||
if (ref.current) {
|
||||
observer.observe(ref.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [hasStarted, startOnView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasStarted) return;
|
||||
|
||||
let startTime: number | null = null;
|
||||
let animationFrame: number;
|
||||
|
||||
const animate = (timestamp: number) => {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const progress = Math.min((timestamp - startTime) / duration, 1);
|
||||
|
||||
// Ease out cubic
|
||||
const easeOut = 1 - Math.pow(1 - progress, 3);
|
||||
const nextValue = end * easeOut;
|
||||
const formattedValue = Number.isInteger(end)
|
||||
? Math.floor(nextValue)
|
||||
: Number(nextValue.toFixed(1));
|
||||
setCount(formattedValue);
|
||||
|
||||
if (progress < 1) {
|
||||
animationFrame = requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
animationFrame = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (animationFrame) {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
}
|
||||
};
|
||||
}, [hasStarted, end, duration]);
|
||||
|
||||
return { count, ref };
|
||||
}
|
||||
|
||||
// Stat Card Component
|
||||
interface StatCardProps {
|
||||
value: string;
|
||||
numericValue?: number;
|
||||
suffix?: string;
|
||||
prefix?: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
gradient: string;
|
||||
className?: string;
|
||||
cardColor?: string;
|
||||
glowColor?: string;
|
||||
spotlight?: {
|
||||
gradientFirst?: string;
|
||||
gradientSecond?: string;
|
||||
gradientThird?: string;
|
||||
duration?: number;
|
||||
xOffset?: number;
|
||||
};
|
||||
delay: number;
|
||||
additional?: string;
|
||||
isText?: boolean;
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
value,
|
||||
numericValue,
|
||||
suffix = "",
|
||||
prefix = "",
|
||||
label,
|
||||
icon: Icon,
|
||||
gradient,
|
||||
className,
|
||||
cardColor,
|
||||
glowColor,
|
||||
spotlight,
|
||||
delay,
|
||||
additional,
|
||||
isText = false,
|
||||
}: StatCardProps) {
|
||||
const { ref: scrollRef, isVisible } = useScrollAnimation<HTMLDivElement>({
|
||||
threshold: 0.3,
|
||||
});
|
||||
const { count, ref: countRef } = useCountUp(numericValue || 0, 2000);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={`relative ${className || ""}`}
|
||||
style={{
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? "translateY(0)" : "translateY(30px)",
|
||||
transition: `all 0.6s ease-out ${delay}s`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`relative h-full overflow-hidden rounded-2xl border border-border/60 p-7 shadow-[0_20px_60px_-30px_rgba(15,23,42,0.25)] backdrop-blur group ${cardColor || "bg-card/80"}`}
|
||||
>
|
||||
{/* Glowing Effect */}
|
||||
<GlowingEffect
|
||||
disabled={false}
|
||||
blur={40}
|
||||
spread={60}
|
||||
proximity={80}
|
||||
variant="default"
|
||||
borderWidth={2}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity duration-500"
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0">
|
||||
<Spotlight
|
||||
gradientFirst={spotlight?.gradientFirst}
|
||||
gradientSecond={spotlight?.gradientSecond}
|
||||
gradientThird={spotlight?.gradientThird}
|
||||
duration={spotlight?.duration ?? 8}
|
||||
xOffset={spotlight?.xOffset ?? 120}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hover Glow */}
|
||||
{glowColor && (
|
||||
<div
|
||||
className={`absolute -inset-1 ${glowColor} opacity-0 group-hover:opacity-20 blur-2xl transition-opacity duration-500 rounded-2xl`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Background Icon */}
|
||||
<div className="absolute -top-6 -right-6 opacity-10">
|
||||
<Icon className="w-28 h-28 text-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 flex h-full flex-col justify-between">
|
||||
<div>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={`inline-flex items-center gap-2 rounded-full ${gradient} px-4 py-2 text-foreground shadow-lg`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="text-xs font-semibold tracking-widest uppercase text-foreground/80">
|
||||
Performance
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Value */}
|
||||
<div ref={countRef} className="mt-6">
|
||||
{isText ? (
|
||||
<span className="text-4xl md:text-5xl lg:text-6xl font-semibold text-foreground tracking-tight">
|
||||
{value}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-4xl md:text-5xl lg:text-6xl font-semibold text-foreground tracking-tight">
|
||||
{prefix}
|
||||
{numericValue !== undefined && !Number.isInteger(numericValue)
|
||||
? count.toFixed(1)
|
||||
: numericValue !== undefined
|
||||
? count
|
||||
: value}
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<p className="mt-4 text-base md:text-lg font-medium text-foreground/90">
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Additional Info */}
|
||||
{additional && (
|
||||
<div className="flex items-center gap-2 pt-6">
|
||||
<TrendingUp className="w-4 h-4 text-emerald-500" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{additional}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Stats() {
|
||||
const { ref: headerRef, isVisible: headerVisible } =
|
||||
useScrollAnimation<HTMLDivElement>();
|
||||
|
||||
interface StatItem {
|
||||
value: string;
|
||||
numericValue?: number;
|
||||
suffix?: string;
|
||||
prefix?: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
gradient: string;
|
||||
className?: string;
|
||||
cardColor?: string;
|
||||
glowColor?: string;
|
||||
spotlight?: {
|
||||
gradientFirst?: string;
|
||||
gradientSecond?: string;
|
||||
gradientThird?: string;
|
||||
duration?: number;
|
||||
xOffset?: number;
|
||||
};
|
||||
additional?: string;
|
||||
isText?: boolean;
|
||||
}
|
||||
|
||||
const stats: StatItem[] = [
|
||||
{
|
||||
value: "99.9",
|
||||
numericValue: 99.9,
|
||||
suffix: "%",
|
||||
label: "OCR + AI Accuracy",
|
||||
icon: Target,
|
||||
gradient: "bg-gradient-to-r from-blue-500 to-indigo-600",
|
||||
cardColor:
|
||||
"bg-gradient-to-br from-blue-50/80 to-indigo-50/80 dark:from-blue-950/30 dark:to-indigo-950/30",
|
||||
glowColor: "bg-blue-500",
|
||||
className: "md:col-span-2",
|
||||
spotlight: {
|
||||
gradientFirst:
|
||||
"radial-gradient(68.54% 68.72% at 55.02% 31.46%, hsla(217, 91%, 60%, .28) 0, hsla(217, 91%, 60%, .12) 55%, hsla(217, 91%, 60%, 0) 80%)",
|
||||
gradientSecond:
|
||||
"radial-gradient(50% 50% at 50% 50%, hsla(258, 90%, 66%, .24) 0, hsla(258, 90%, 66%, .08) 80%, transparent 100%)",
|
||||
gradientThird:
|
||||
"radial-gradient(50% 50% at 50% 50%, hsla(217, 91%, 60%, .18) 0, hsla(217, 91%, 60%, .06) 80%, transparent 100%)",
|
||||
},
|
||||
additional: "+0.3% this month",
|
||||
},
|
||||
{
|
||||
value: "< 3",
|
||||
label: "Average AI Response Time",
|
||||
icon: Zap,
|
||||
gradient: "bg-gradient-to-r from-amber-500 to-orange-600",
|
||||
cardColor:
|
||||
"bg-gradient-to-br from-amber-50/80 to-orange-50/80 dark:from-amber-950/30 dark:to-orange-950/30",
|
||||
glowColor: "bg-amber-500",
|
||||
spotlight: {
|
||||
gradientFirst:
|
||||
"radial-gradient(68.54% 68.72% at 55.02% 31.46%, hsla(48, 96%, 53%, .28) 0, hsla(48, 96%, 53%, .12) 55%, hsla(48, 96%, 53%, 0) 80%)",
|
||||
},
|
||||
additional: "Average under 3 seconds",
|
||||
isText: true,
|
||||
},
|
||||
{
|
||||
value: "100",
|
||||
numericValue: 100,
|
||||
suffix: "%",
|
||||
label: "Blockchain Verified",
|
||||
icon: Shield,
|
||||
gradient: "bg-gradient-to-r from-emerald-500 to-teal-600",
|
||||
cardColor:
|
||||
"bg-gradient-to-br from-emerald-50/80 to-teal-50/80 dark:from-emerald-950/30 dark:to-teal-950/30",
|
||||
glowColor: "bg-emerald-500",
|
||||
spotlight: {
|
||||
gradientFirst:
|
||||
"radial-gradient(68.54% 68.72% at 55.02% 31.46%, hsla(158, 64%, 52%, .28) 0, hsla(158, 64%, 52%, .12) 55%, hsla(158, 64%, 52%, 0) 80%)",
|
||||
},
|
||||
additional: "All documents certified",
|
||||
},
|
||||
{
|
||||
value: "GDPR",
|
||||
label: "Full European Compliance",
|
||||
icon: Lock,
|
||||
gradient: "bg-gradient-to-r from-violet-500 to-purple-600",
|
||||
cardColor:
|
||||
"bg-gradient-to-br from-violet-50/80 to-purple-50/80 dark:from-violet-950/30 dark:to-purple-950/30",
|
||||
glowColor: "bg-violet-500",
|
||||
className: "md:col-span-2",
|
||||
spotlight: {
|
||||
gradientFirst:
|
||||
"radial-gradient(68.54% 68.72% at 55.02% 31.46%, hsla(258, 90%, 66%, .28) 0, hsla(258, 90%, 66%, .12) 55%, hsla(258, 90%, 66%, 0) 80%)",
|
||||
},
|
||||
additional: "ISO 27001 certified",
|
||||
isText: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section
|
||||
id="stats"
|
||||
className="relative py-16 px-4 sm:px-6 lg:px-8 overflow-hidden"
|
||||
>
|
||||
{/* Gradient Background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-background via-muted/40 to-background">
|
||||
{/* Grid Pattern Overlay */}
|
||||
<div className="absolute inset-0 grid-pattern opacity-15" />
|
||||
|
||||
{/* Radial Glow */}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,hsla(var(--primary),0.2)_0%,transparent_55%)]" />
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-7xl mx-auto">
|
||||
{/* Section Header */}
|
||||
<div
|
||||
ref={headerRef}
|
||||
className="text-center mb-14"
|
||||
style={{
|
||||
opacity: headerVisible ? 1 : 0,
|
||||
transform: headerVisible ? "translateY(0)" : "translateY(30px)",
|
||||
transition: "all 0.6s ease-out",
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/60 px-4 py-1 text-xs uppercase tracking-[0.3em] text-muted-foreground">
|
||||
Platform Metrics
|
||||
</span>
|
||||
|
||||
<h2 className="mt-6 text-4xl md:text-5xl lg:text-6xl font-semibold text-foreground">
|
||||
Corporate-Grade Results, Measured.
|
||||
</h2>
|
||||
|
||||
<p className="mt-4 text-base md:text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
Transparent performance benchmarks that prove reliability, accuracy,
|
||||
and compliance at enterprise scale.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<BentoGrid className="max-w-6xl gap-6 md:auto-rows-[16rem]">
|
||||
{stats.map((stat, index) => (
|
||||
<StatCard
|
||||
key={stat.label}
|
||||
value={stat.value}
|
||||
numericValue={stat.numericValue}
|
||||
suffix={stat.suffix || ""}
|
||||
prefix={stat.prefix || ""}
|
||||
label={stat.label}
|
||||
icon={stat.icon}
|
||||
gradient={stat.gradient}
|
||||
className={stat.className}
|
||||
cardColor={stat.cardColor}
|
||||
glowColor={stat.glowColor}
|
||||
spotlight={stat.spotlight}
|
||||
delay={index * 0.1}
|
||||
additional={stat.additional}
|
||||
isText={stat.isText}
|
||||
/>
|
||||
))}
|
||||
</BentoGrid>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
470
features/notifications/api/notification.action.ts
Normal file
470
features/notifications/api/notification.action.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* Notification Server Actions
|
||||
*
|
||||
* Handles all notification-related server actions including:
|
||||
* - Fetching notifications
|
||||
* - Marking notifications as read
|
||||
* - Deleting notifications
|
||||
* - Checking for deadline notifications
|
||||
*
|
||||
* These actions are called from client components and provide real-time
|
||||
* notification management with Clerk authentication.
|
||||
*/
|
||||
|
||||
"use server";
|
||||
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { ContractService } from "@/lib/services/contract.service";
|
||||
import { NotificationService } from "@/lib/services/notification.service";
|
||||
|
||||
const isNotificationTableMissingError = (error: unknown): boolean => {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
|
||||
const maybePrismaError = error as {
|
||||
code?: string;
|
||||
meta?: { table?: string };
|
||||
message?: string;
|
||||
};
|
||||
|
||||
if (maybePrismaError.code !== "P2021") return false;
|
||||
|
||||
const tableFromMeta = maybePrismaError.meta?.table ?? "";
|
||||
const message = maybePrismaError.message ?? "";
|
||||
|
||||
return (
|
||||
tableFromMeta.includes("Notification") ||
|
||||
message.includes("public.Notification")
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches all unread notifications for the current authenticated user
|
||||
*
|
||||
* Uses Clerk authentication to get the current user's ID
|
||||
*
|
||||
* @param limit - Maximum number of notifications to return (default: 10)
|
||||
* @returns Object with success status and notifications array
|
||||
*
|
||||
* Steps:
|
||||
* 1. Authenticate user via Clerk
|
||||
* 2. Get internal user ID from database using Clerk ID
|
||||
* 3. Fetch unread notifications from database
|
||||
* 4. Include contract details (title, endDate)
|
||||
* 5. Return sorted by creation date (newest first)
|
||||
*
|
||||
* Example usage in component:
|
||||
* ```typescript
|
||||
* const { unreadNotifications } = await getNotifications();
|
||||
* ```
|
||||
*/
|
||||
export async function getNotifications(limit: number = 10) {
|
||||
try {
|
||||
// Step 1: Authenticate user
|
||||
const { userId: clerkId } = await auth();
|
||||
|
||||
if (!clerkId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Get internal user ID from database
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
error: "User not found",
|
||||
};
|
||||
}
|
||||
|
||||
// Step 3: Enforce cleanup policy for seen non-deadline notifications
|
||||
await NotificationService.cleanupReadNonDeadline(user.id);
|
||||
|
||||
// Step 4: Fetch unread notifications
|
||||
const result = await NotificationService.getUnread(user.id, limit);
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
return {
|
||||
success: true,
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Get notifications error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches complete notification history for the current user
|
||||
*
|
||||
* Returns both read and unread notifications for notification center/log view
|
||||
*
|
||||
* @param limit - Maximum number of notifications to return (default: 50)
|
||||
* @returns Object with success status and all notifications array
|
||||
*/
|
||||
export async function getAllNotifications(limit: number = 50) {
|
||||
try {
|
||||
const { userId: clerkId } = await auth();
|
||||
|
||||
if (!clerkId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
error: "User not found",
|
||||
};
|
||||
}
|
||||
|
||||
await NotificationService.cleanupReadNonDeadline(user.id);
|
||||
|
||||
const result = await NotificationService.getAll(user.id, limit);
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
return {
|
||||
success: true,
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Get all notifications error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets count of unread notifications for badge display
|
||||
*
|
||||
* Used to show badge count on notification icon in the UI
|
||||
*
|
||||
* @returns Object with unread notification count
|
||||
*
|
||||
* Example usage:
|
||||
* ```typescript
|
||||
* const { data } = await getUnreadNotificationCount();
|
||||
* // Display data.count as badge on notification bell icon
|
||||
* ```
|
||||
*/
|
||||
export async function getUnreadNotificationCount() {
|
||||
try {
|
||||
const { userId: clerkId } = await auth();
|
||||
|
||||
if (!clerkId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
error: "User not found",
|
||||
};
|
||||
}
|
||||
|
||||
await NotificationService.cleanupReadNonDeadline(user.id);
|
||||
|
||||
const result = await NotificationService.getUnreadCount(user.id);
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
return {
|
||||
success: true,
|
||||
data: { count: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Get unread count error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a single notification as read
|
||||
*
|
||||
* @param notificationId - The ID of the notification to mark as read
|
||||
* @returns Object with success status
|
||||
*
|
||||
* Security: Verifies the notification belongs to the current user
|
||||
*/
|
||||
export async function markNotificationAsRead(notificationId: string) {
|
||||
try {
|
||||
const { userId: clerkId } = await auth();
|
||||
|
||||
if (!clerkId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
error: "User not found",
|
||||
};
|
||||
}
|
||||
|
||||
// Verify notification belongs to user
|
||||
const notification = await prisma.notification.findUnique({
|
||||
where: { id: notificationId },
|
||||
});
|
||||
|
||||
if (!notification || notification.userId !== user.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await NotificationService.markAsRead(notificationId);
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Mark notification as read error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks all unread notifications as read for the current user
|
||||
*
|
||||
* @returns Object with success status and count of updated notifications
|
||||
*/
|
||||
export async function markAllNotificationsAsRead() {
|
||||
try {
|
||||
const { userId: clerkId } = await auth();
|
||||
|
||||
if (!clerkId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
error: "User not found",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await NotificationService.markAllAsRead(user.id);
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
return {
|
||||
success: true,
|
||||
data: { count: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Mark all notifications as read error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force cleanup of seen non-deadline notifications for current user.
|
||||
* Useful as a maintenance endpoint from UI hooks.
|
||||
*/
|
||||
export async function cleanupSeenNotifications() {
|
||||
try {
|
||||
const { userId: clerkId } = await auth();
|
||||
|
||||
if (!clerkId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
error: "User not found",
|
||||
};
|
||||
}
|
||||
|
||||
return await NotificationService.cleanupReadNonDeadline(user.id);
|
||||
} catch (error: unknown) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
return {
|
||||
success: true,
|
||||
data: { count: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Cleanup seen notifications error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a notification
|
||||
*
|
||||
* @param notificationId - The ID of the notification to delete
|
||||
* @returns Object with success status
|
||||
*
|
||||
* Security: Verifies the notification belongs to the current user before deletion
|
||||
*/
|
||||
export async function deleteNotification(notificationId: string) {
|
||||
try {
|
||||
const { userId: clerkId } = await auth();
|
||||
|
||||
if (!clerkId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
error: "User not found",
|
||||
};
|
||||
}
|
||||
|
||||
// Verify notification belongs to user
|
||||
const notification = await prisma.notification.findUnique({
|
||||
where: { id: notificationId },
|
||||
});
|
||||
|
||||
if (!notification || notification.userId !== user.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await NotificationService.delete(notificationId);
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Delete notification error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for upcoming contract deadlines and creates notifications
|
||||
*
|
||||
* Scans all user's contracts and creates DEADLINE type notifications for:
|
||||
* - 30 days before expiration (CRITICAL - 🔴)
|
||||
* - 15 days before expiration (WARNING - 🟠)
|
||||
* - 7 days before expiration (URGENT - 🟡)
|
||||
*
|
||||
* @returns Object with success status and count of created notifications
|
||||
*
|
||||
* Should be called:
|
||||
* - On dashboard page load (to refresh deadline list)
|
||||
* - Once per day at a scheduled time via cron job
|
||||
* - When monitoring upcoming deadlines
|
||||
*
|
||||
* Example usage:
|
||||
* ```typescript
|
||||
* // In dashboard page effect
|
||||
* useEffect(() => {
|
||||
* checkDeadlineNotifications();
|
||||
* }, []);
|
||||
* ```
|
||||
*/
|
||||
export async function checkDeadlineNotifications() {
|
||||
try {
|
||||
const { userId: clerkId } = await auth();
|
||||
|
||||
if (!clerkId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
error: "User not found",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await NotificationService.checkUpcomingDeadlines(user.id);
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
return {
|
||||
success: true,
|
||||
data: { count: 0, contractIds: [] },
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Check deadline notifications error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
636
features/notifications/components/notification-bar.tsx
Normal file
636
features/notifications/components/notification-bar.tsx
Normal file
@@ -0,0 +1,636 @@
|
||||
/**
|
||||
* 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 "@/features/notifications/api/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 ?? 340;
|
||||
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();
|
||||
|
||||
// Reposition after paint so empty-state and populated-state heights are both measured correctly.
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
updatePanelPosition();
|
||||
});
|
||||
|
||||
// Track panel content height changes in real time.
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
if (panelRef.current && typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
updatePanelPosition();
|
||||
});
|
||||
resizeObserver.observe(panelRef.current);
|
||||
}
|
||||
|
||||
const handleResize = () => updatePanelPosition();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [isMounted, isOpen, updatePanelPosition, notifications.length, isLoading]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user