PreRelease v2

This commit is contained in:
2026-03-28 23:46:45 +01:00
parent 6bf998a52a
commit 9993bd232f
39 changed files with 3964 additions and 1469 deletions

View File

@@ -0,0 +1,141 @@
"use client";
import { UploadDropzone } from "@uploadthing/react";
import { AlertCircle, Sparkles, Wand2, ShieldCheck } from "lucide-react";
import { Card } from "@/components/ui/card";
import { saveContract } from "@/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>
);
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,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>
);
}

View 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>
);
}