Spaces:
Sleeping
Sleeping
Fix: 7-day session config, plan gating on scanner (10 free/month), file upload button, upgrade modal when limit hit
Browse files- web/app/dashboard-pages/analyze/page.tsx +109 -35
- web/lib/supabase/client.ts +10 -1
web/app/dashboard-pages/analyze/page.tsx
CHANGED
|
@@ -1,20 +1,21 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import { useState } from "react";
|
| 4 |
import {
|
| 5 |
ScanText, ScanLine, TriangleAlert, CircleAlert, CircleCheck, Info,
|
| 6 |
-
|
| 7 |
-
ShieldCheck, ShieldAlert, Scale, Gavel, Ban, Globe, Eye, Stamp, FileX
|
|
|
|
| 8 |
} from "lucide-react";
|
| 9 |
|
| 10 |
interface Cat { name: string; severity: string; description?: string; confidence?: number; }
|
| 11 |
interface Clause { text: string; categories: Cat[]; }
|
| 12 |
interface Result { risk_score: number; grade: string; total_clauses: number; flagged_count: number; results: Clause[]; model: string; latency_ms: number; }
|
| 13 |
|
| 14 |
-
const SEV_CONFIG: Record<string, { icon: any; label: string; text: string; bg: string; border: string
|
| 15 |
-
HIGH: { icon: TriangleAlert, label: "High", text: "text-red-600", bg: "bg-red-50", border: "border-red-200"
|
| 16 |
-
MEDIUM: { icon: CircleAlert, label: "Medium", text: "text-amber-600", bg: "bg-amber-50", border: "border-amber-200"
|
| 17 |
-
LOW: { icon: Info, label: "Low", text: "text-blue-600", bg: "bg-blue-50", border: "border-blue-200"
|
| 18 |
};
|
| 19 |
|
| 20 |
const GRADE_STYLE: Record<string, string> = {
|
|
@@ -53,18 +54,59 @@ export default function AnalyzePage() {
|
|
| 53 |
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
|
| 54 |
const [filter, setFilter] = useState<string>("all");
|
| 55 |
const [copied, setCopied] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
async function handleAnalyze() {
|
| 58 |
if (!text || text.trim().length < 50) { setError("Enter at least 50 characters."); return; }
|
|
|
|
|
|
|
|
|
|
| 59 |
setLoading(true); setError(""); setResults(null); setExpandedIdx(null);
|
| 60 |
try {
|
| 61 |
const res = await fetch("/api/analyze", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }) });
|
| 62 |
if (!res.ok) throw new Error((await res.json()).error || "Failed");
|
| 63 |
-
|
|
|
|
|
|
|
| 64 |
} catch (e: any) { setError(e.message); }
|
| 65 |
finally { setLoading(false); }
|
| 66 |
}
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
async function handleDownloadPDF() {
|
| 69 |
if (!results) return;
|
| 70 |
try {
|
|
@@ -94,13 +136,50 @@ export default function AnalyzePage() {
|
|
| 94 |
|
| 95 |
return (
|
| 96 |
<div className="min-h-screen bg-white">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
<div className="max-w-6xl mx-auto px-5 py-10">
|
| 98 |
-
<div className="mb-8">
|
| 99 |
-
<
|
| 100 |
-
<
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
</div>
|
| 105 |
|
| 106 |
<div className="grid lg:grid-cols-5 gap-6">
|
|
@@ -108,16 +187,21 @@ export default function AnalyzePage() {
|
|
| 108 |
<div className="lg:col-span-2">
|
| 109 |
<textarea value={text} onChange={(e) => setText(e.target.value)}
|
| 110 |
placeholder="Paste your document text here..."
|
| 111 |
-
className="w-full h-[
|
| 112 |
<div className="mt-3 flex gap-2">
|
| 113 |
<button onClick={handleAnalyze} disabled={loading}
|
| 114 |
className="flex-1 inline-flex items-center justify-center gap-2 bg-zinc-900 text-white py-2.5 rounded-lg text-sm font-medium hover:bg-zinc-800 disabled:opacity-40 transition-colors">
|
| 115 |
{loading ? <><ScanLine className="w-4 h-4 animate-pulse" /> Scanning...</> : <><ScanText className="w-4 h-4" /> Scan</>}
|
| 116 |
</button>
|
| 117 |
<button onClick={() => setText(EXAMPLE)}
|
| 118 |
-
className="px-
|
| 119 |
Example
|
| 120 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
</div>
|
| 122 |
{error && <p className="mt-2 text-sm text-red-600 flex items-center gap-1.5"><TriangleAlert className="w-3.5 h-3.5" />{error}</p>}
|
| 123 |
</div>
|
|
@@ -140,11 +224,9 @@ export default function AnalyzePage() {
|
|
| 140 |
}`} style={{ width: `${results.risk_score}%` }} />
|
| 141 |
</div>
|
| 142 |
</div>
|
| 143 |
-
<
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
</span>
|
| 147 |
-
</div>
|
| 148 |
</div>
|
| 149 |
<div className="mt-4 flex items-center gap-4 text-xs text-zinc-400">
|
| 150 |
<span>{results.total_clauses} clauses</span>
|
|
@@ -154,7 +236,7 @@ export default function AnalyzePage() {
|
|
| 154 |
<span>{results.latency_ms}ms</span>
|
| 155 |
<span className="w-px h-3 bg-zinc-200" />
|
| 156 |
<span className="flex items-center gap-1">
|
| 157 |
-
{results.model === "ml"
|
| 158 |
{results.model === "ml" ? "Legal-BERT" : "Pattern matching"}
|
| 159 |
</span>
|
| 160 |
</div>
|
|
@@ -188,11 +270,11 @@ export default function AnalyzePage() {
|
|
| 188 |
</div>
|
| 189 |
|
| 190 |
{/* Clause list */}
|
| 191 |
-
<div className="space-y-2 max-h-[
|
| 192 |
{filtered.length === 0 ? (
|
| 193 |
<div className="border border-dashed border-zinc-200 rounded-xl p-10 text-center">
|
| 194 |
<CircleCheck className="w-8 h-8 text-emerald-400 mx-auto mb-2" />
|
| 195 |
-
<p className="text-sm text-zinc-500">{filter === "all" ? "No unfair clauses found." : "No clauses at this severity
|
| 196 |
</div>
|
| 197 |
) : filtered.map((clause, i) => {
|
| 198 |
const maxSev = clause.categories.reduce((m, c) => {
|
|
@@ -204,8 +286,7 @@ export default function AnalyzePage() {
|
|
| 204 |
const CatIcon = CATEGORY_ICONS[clause.categories[0]?.name] || TriangleAlert;
|
| 205 |
|
| 206 |
return (
|
| 207 |
-
<div key={i}
|
| 208 |
-
className={`border rounded-xl overflow-hidden transition-all ${conf.border} ${isExpanded ? "shadow-sm" : ""}`}>
|
| 209 |
<button onClick={() => setExpandedIdx(isExpanded ? null : i)}
|
| 210 |
className="w-full text-left p-4 flex items-start gap-3 hover:bg-zinc-50/50 transition-colors">
|
| 211 |
<div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${conf.bg}`}>
|
|
@@ -217,8 +298,7 @@ export default function AnalyzePage() {
|
|
| 217 |
const s = SEV_CONFIG[cat.severity] || SEV_CONFIG.MEDIUM;
|
| 218 |
return (
|
| 219 |
<span key={j} className={`text-[11px] font-medium px-2 py-0.5 rounded border ${s.bg} ${s.text} ${s.border}`}>
|
| 220 |
-
{cat.name}
|
| 221 |
-
{cat.confidence ? ` ${Math.round(cat.confidence * 100)}%` : ""}
|
| 222 |
</span>
|
| 223 |
);
|
| 224 |
})}
|
|
@@ -231,9 +311,7 @@ export default function AnalyzePage() {
|
|
| 231 |
</button>
|
| 232 |
{isExpanded && (
|
| 233 |
<div className="px-4 pb-4 pt-0 border-t border-zinc-100">
|
| 234 |
-
<p className="text-sm text-zinc-700 leading-relaxed mt-3 font-mono bg-zinc-50 rounded-lg p-3">
|
| 235 |
-
{clause.text}
|
| 236 |
-
</p>
|
| 237 |
{clause.categories.map((cat, j) => (
|
| 238 |
<div key={j} className="mt-3 flex items-start gap-2">
|
| 239 |
<TriangleAlert className={`w-3.5 h-3.5 mt-0.5 shrink-0 ${(SEV_CONFIG[cat.severity] || SEV_CONFIG.MEDIUM).text}`} />
|
|
@@ -261,7 +339,3 @@ export default function AnalyzePage() {
|
|
| 261 |
</div>
|
| 262 |
);
|
| 263 |
}
|
| 264 |
-
|
| 265 |
-
function Sparkles({ className }: { className?: string }) {
|
| 266 |
-
return <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/></svg>;
|
| 267 |
-
}
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useState, useRef, useEffect } from "react";
|
| 4 |
import {
|
| 5 |
ScanText, ScanLine, TriangleAlert, CircleAlert, CircleCheck, Info,
|
| 6 |
+
FileDown, ChevronDown, ChevronUp, Copy, Check, Upload, FileText,
|
| 7 |
+
ShieldCheck, ShieldAlert, Scale, Gavel, Ban, Globe, Eye, Stamp, FileX,
|
| 8 |
+
Lock, Sparkles as SparklesIcon, X
|
| 9 |
} from "lucide-react";
|
| 10 |
|
| 11 |
interface Cat { name: string; severity: string; description?: string; confidence?: number; }
|
| 12 |
interface Clause { text: string; categories: Cat[]; }
|
| 13 |
interface Result { risk_score: number; grade: string; total_clauses: number; flagged_count: number; results: Clause[]; model: string; latency_ms: number; }
|
| 14 |
|
| 15 |
+
const SEV_CONFIG: Record<string, { icon: any; label: string; text: string; bg: string; border: string }> = {
|
| 16 |
+
HIGH: { icon: TriangleAlert, label: "High", text: "text-red-600", bg: "bg-red-50", border: "border-red-200" },
|
| 17 |
+
MEDIUM: { icon: CircleAlert, label: "Medium", text: "text-amber-600", bg: "bg-amber-50", border: "border-amber-200" },
|
| 18 |
+
LOW: { icon: Info, label: "Low", text: "text-blue-600", bg: "bg-blue-50", border: "border-blue-200" },
|
| 19 |
};
|
| 20 |
|
| 21 |
const GRADE_STYLE: Record<string, string> = {
|
|
|
|
| 54 |
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
|
| 55 |
const [filter, setFilter] = useState<string>("all");
|
| 56 |
const [copied, setCopied] = useState(false);
|
| 57 |
+
const [scanCount, setScanCount] = useState(0);
|
| 58 |
+
const [userPlan, setUserPlan] = useState("free");
|
| 59 |
+
const [showUpgrade, setShowUpgrade] = useState(false);
|
| 60 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 61 |
+
|
| 62 |
+
const FREE_LIMIT = 10;
|
| 63 |
+
const canScan = userPlan !== "free" || scanCount < FREE_LIMIT;
|
| 64 |
|
| 65 |
async function handleAnalyze() {
|
| 66 |
if (!text || text.trim().length < 50) { setError("Enter at least 50 characters."); return; }
|
| 67 |
+
|
| 68 |
+
if (!canScan) { setShowUpgrade(true); return; }
|
| 69 |
+
|
| 70 |
setLoading(true); setError(""); setResults(null); setExpandedIdx(null);
|
| 71 |
try {
|
| 72 |
const res = await fetch("/api/analyze", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }) });
|
| 73 |
if (!res.ok) throw new Error((await res.json()).error || "Failed");
|
| 74 |
+
const data = await res.json();
|
| 75 |
+
setResults(data);
|
| 76 |
+
setScanCount(prev => prev + 1);
|
| 77 |
} catch (e: any) { setError(e.message); }
|
| 78 |
finally { setLoading(false); }
|
| 79 |
}
|
| 80 |
|
| 81 |
+
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
| 82 |
+
const file = e.target.files?.[0];
|
| 83 |
+
if (!file) return;
|
| 84 |
+
|
| 85 |
+
if (userPlan === "free") { setShowUpgrade(true); return; }
|
| 86 |
+
|
| 87 |
+
setLoading(true);
|
| 88 |
+
setError("");
|
| 89 |
+
|
| 90 |
+
try {
|
| 91 |
+
// Read file as text
|
| 92 |
+
if (file.name.endsWith(".txt") || file.name.endsWith(".md")) {
|
| 93 |
+
const content = await file.text();
|
| 94 |
+
setText(content);
|
| 95 |
+
} else if (file.name.endsWith(".pdf")) {
|
| 96 |
+
setError("PDF parsing requires the Pro plan backend. Paste text for now.");
|
| 97 |
+
} else if (file.name.endsWith(".docx")) {
|
| 98 |
+
setError("DOCX parsing requires the Pro plan backend. Paste text for now.");
|
| 99 |
+
} else {
|
| 100 |
+
const content = await file.text();
|
| 101 |
+
setText(content);
|
| 102 |
+
}
|
| 103 |
+
} catch {
|
| 104 |
+
setError("Could not read file.");
|
| 105 |
+
}
|
| 106 |
+
setLoading(false);
|
| 107 |
+
if (fileInputRef.current) fileInputRef.current.value = "";
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
async function handleDownloadPDF() {
|
| 111 |
if (!results) return;
|
| 112 |
try {
|
|
|
|
| 136 |
|
| 137 |
return (
|
| 138 |
<div className="min-h-screen bg-white">
|
| 139 |
+
{/* Upgrade modal */}
|
| 140 |
+
{showUpgrade && (
|
| 141 |
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
| 142 |
+
<div className="bg-white rounded-2xl p-6 max-w-sm mx-4 shadow-xl">
|
| 143 |
+
<div className="flex justify-between items-start">
|
| 144 |
+
<div className="w-10 h-10 rounded-xl bg-amber-50 flex items-center justify-center">
|
| 145 |
+
<Lock className="w-5 h-5 text-amber-600" />
|
| 146 |
+
</div>
|
| 147 |
+
<button onClick={() => setShowUpgrade(false)} className="p-1 hover:bg-zinc-100 rounded-md"><X className="w-4 h-4 text-zinc-400" /></button>
|
| 148 |
+
</div>
|
| 149 |
+
<h3 className="mt-4 text-lg font-semibold">
|
| 150 |
+
{userPlan === "free" && scanCount >= FREE_LIMIT ? "Free limit reached" : "Pro feature"}
|
| 151 |
+
</h3>
|
| 152 |
+
<p className="mt-1.5 text-sm text-zinc-500 leading-relaxed">
|
| 153 |
+
{userPlan === "free" && scanCount >= FREE_LIMIT
|
| 154 |
+
? `You have used all ${FREE_LIMIT} free scans this month. Upgrade to Pro for unlimited scans, file uploads, and AI explanations.`
|
| 155 |
+
: "File upload is available on the Pro plan. Upgrade to scan contracts and leases directly."}
|
| 156 |
+
</p>
|
| 157 |
+
<div className="mt-5 flex gap-2">
|
| 158 |
+
<a href="/#pricing" className="flex-1 bg-zinc-900 text-white py-2.5 rounded-lg text-sm font-medium text-center hover:bg-zinc-800 transition-colors">
|
| 159 |
+
View plans
|
| 160 |
+
</a>
|
| 161 |
+
<button onClick={() => setShowUpgrade(false)} className="flex-1 border border-zinc-200 py-2.5 rounded-lg text-sm font-medium hover:bg-zinc-50 transition-colors">
|
| 162 |
+
Not now
|
| 163 |
+
</button>
|
| 164 |
+
</div>
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
)}
|
| 168 |
+
|
| 169 |
<div className="max-w-6xl mx-auto px-5 py-10">
|
| 170 |
+
<div className="mb-8 flex items-start justify-between">
|
| 171 |
+
<div>
|
| 172 |
+
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
| 173 |
+
<ScanText className="w-6 h-6 text-zinc-400" />
|
| 174 |
+
Scan a document
|
| 175 |
+
</h1>
|
| 176 |
+
<p className="mt-1 text-sm text-zinc-500">Paste text or upload a file (.txt, .md).</p>
|
| 177 |
+
</div>
|
| 178 |
+
{userPlan === "free" && (
|
| 179 |
+
<span className="text-xs text-zinc-400 border border-zinc-200 px-2.5 py-1 rounded-md">
|
| 180 |
+
{scanCount}/{FREE_LIMIT} free scans
|
| 181 |
+
</span>
|
| 182 |
+
)}
|
| 183 |
</div>
|
| 184 |
|
| 185 |
<div className="grid lg:grid-cols-5 gap-6">
|
|
|
|
| 187 |
<div className="lg:col-span-2">
|
| 188 |
<textarea value={text} onChange={(e) => setText(e.target.value)}
|
| 189 |
placeholder="Paste your document text here..."
|
| 190 |
+
className="w-full h-[380px] p-4 border border-zinc-200 rounded-xl text-sm leading-relaxed resize-none focus:outline-none focus:ring-2 focus:ring-zinc-900/10 focus:border-zinc-300 placeholder:text-zinc-300 font-mono" />
|
| 191 |
<div className="mt-3 flex gap-2">
|
| 192 |
<button onClick={handleAnalyze} disabled={loading}
|
| 193 |
className="flex-1 inline-flex items-center justify-center gap-2 bg-zinc-900 text-white py-2.5 rounded-lg text-sm font-medium hover:bg-zinc-800 disabled:opacity-40 transition-colors">
|
| 194 |
{loading ? <><ScanLine className="w-4 h-4 animate-pulse" /> Scanning...</> : <><ScanText className="w-4 h-4" /> Scan</>}
|
| 195 |
</button>
|
| 196 |
<button onClick={() => setText(EXAMPLE)}
|
| 197 |
+
className="px-3 border border-zinc-200 rounded-lg text-sm text-zinc-500 hover:bg-zinc-50 transition-colors">
|
| 198 |
Example
|
| 199 |
</button>
|
| 200 |
+
<input ref={fileInputRef} type="file" accept=".txt,.md,.pdf,.docx" className="hidden" onChange={handleFileUpload} />
|
| 201 |
+
<button onClick={() => fileInputRef.current?.click()}
|
| 202 |
+
className="px-3 border border-zinc-200 rounded-lg text-zinc-500 hover:bg-zinc-50 transition-colors" title="Upload file">
|
| 203 |
+
<Upload className="w-4 h-4" />
|
| 204 |
+
</button>
|
| 205 |
</div>
|
| 206 |
{error && <p className="mt-2 text-sm text-red-600 flex items-center gap-1.5"><TriangleAlert className="w-3.5 h-3.5" />{error}</p>}
|
| 207 |
</div>
|
|
|
|
| 224 |
}`} style={{ width: `${results.risk_score}%` }} />
|
| 225 |
</div>
|
| 226 |
</div>
|
| 227 |
+
<span className={`text-sm font-semibold px-3 py-1 rounded-lg border ${GRADE_STYLE[results.grade] || GRADE_STYLE.C}`}>
|
| 228 |
+
Grade {results.grade}
|
| 229 |
+
</span>
|
|
|
|
|
|
|
| 230 |
</div>
|
| 231 |
<div className="mt-4 flex items-center gap-4 text-xs text-zinc-400">
|
| 232 |
<span>{results.total_clauses} clauses</span>
|
|
|
|
| 236 |
<span>{results.latency_ms}ms</span>
|
| 237 |
<span className="w-px h-3 bg-zinc-200" />
|
| 238 |
<span className="flex items-center gap-1">
|
| 239 |
+
{results.model === "ml" && <SparklesIcon className="w-3 h-3" />}
|
| 240 |
{results.model === "ml" ? "Legal-BERT" : "Pattern matching"}
|
| 241 |
</span>
|
| 242 |
</div>
|
|
|
|
| 270 |
</div>
|
| 271 |
|
| 272 |
{/* Clause list */}
|
| 273 |
+
<div className="space-y-2 max-h-[380px] overflow-y-auto pr-1">
|
| 274 |
{filtered.length === 0 ? (
|
| 275 |
<div className="border border-dashed border-zinc-200 rounded-xl p-10 text-center">
|
| 276 |
<CircleCheck className="w-8 h-8 text-emerald-400 mx-auto mb-2" />
|
| 277 |
+
<p className="text-sm text-zinc-500">{filter === "all" ? "No unfair clauses found." : "No clauses at this severity."}</p>
|
| 278 |
</div>
|
| 279 |
) : filtered.map((clause, i) => {
|
| 280 |
const maxSev = clause.categories.reduce((m, c) => {
|
|
|
|
| 286 |
const CatIcon = CATEGORY_ICONS[clause.categories[0]?.name] || TriangleAlert;
|
| 287 |
|
| 288 |
return (
|
| 289 |
+
<div key={i} className={`border rounded-xl overflow-hidden transition-all ${conf.border} ${isExpanded ? "shadow-sm" : ""}`}>
|
|
|
|
| 290 |
<button onClick={() => setExpandedIdx(isExpanded ? null : i)}
|
| 291 |
className="w-full text-left p-4 flex items-start gap-3 hover:bg-zinc-50/50 transition-colors">
|
| 292 |
<div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${conf.bg}`}>
|
|
|
|
| 298 |
const s = SEV_CONFIG[cat.severity] || SEV_CONFIG.MEDIUM;
|
| 299 |
return (
|
| 300 |
<span key={j} className={`text-[11px] font-medium px-2 py-0.5 rounded border ${s.bg} ${s.text} ${s.border}`}>
|
| 301 |
+
{cat.name}{cat.confidence ? ` ${Math.round(cat.confidence * 100)}%` : ""}
|
|
|
|
| 302 |
</span>
|
| 303 |
);
|
| 304 |
})}
|
|
|
|
| 311 |
</button>
|
| 312 |
{isExpanded && (
|
| 313 |
<div className="px-4 pb-4 pt-0 border-t border-zinc-100">
|
| 314 |
+
<p className="text-sm text-zinc-700 leading-relaxed mt-3 font-mono bg-zinc-50 rounded-lg p-3">{clause.text}</p>
|
|
|
|
|
|
|
| 315 |
{clause.categories.map((cat, j) => (
|
| 316 |
<div key={j} className="mt-3 flex items-start gap-2">
|
| 317 |
<TriangleAlert className={`w-3.5 h-3.5 mt-0.5 shrink-0 ${(SEV_CONFIG[cat.severity] || SEV_CONFIG.MEDIUM).text}`} />
|
|
|
|
| 339 |
</div>
|
| 340 |
);
|
| 341 |
}
|
|
|
|
|
|
|
|
|
|
|
|
web/lib/supabase/client.ts
CHANGED
|
@@ -3,6 +3,15 @@ import { createBrowserClient } from "@supabase/ssr";
|
|
| 3 |
export function createClient() {
|
| 4 |
return createBrowserClient(
|
| 5 |
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
| 6 |
-
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
);
|
| 8 |
}
|
|
|
|
| 3 |
export function createClient() {
|
| 4 |
return createBrowserClient(
|
| 5 |
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
| 6 |
+
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
| 7 |
+
{
|
| 8 |
+
auth: {
|
| 9 |
+
autoRefreshToken: true,
|
| 10 |
+
persistSession: true,
|
| 11 |
+
// 7 day session — Supabase default is 3600s (1 hour)
|
| 12 |
+
// This must also be set in Supabase Dashboard → Auth → Settings → JWT Expiry
|
| 13 |
+
// Set to 604800 (7 days) in the dashboard
|
| 14 |
+
},
|
| 15 |
+
}
|
| 16 |
);
|
| 17 |
}
|