Spaces:
Sleeping
Sleeping
🛡️ ClauseGuard v1.0 — Full codebase: Extension + Website + API + ML
Browse files- README.md +166 -9
- api/Dockerfile +12 -0
- api/main.py +288 -0
- api/requirements.txt +8 -0
- extension/background.js +231 -0
- extension/content.js +230 -0
- extension/manifest.json +51 -0
- extension/popup.html +190 -0
- extension/popup.js +106 -0
- extension/sidepanel.html +153 -0
- extension/sidepanel.js +114 -0
- extension/styles/content.css +99 -0
- ml/export_onnx.py +40 -0
- ml/requirements.txt +7 -0
- ml/train_classifier.py +176 -0
- web/.env.example +14 -0
- web/app/api/analyze/route.ts +52 -0
- web/app/api/stripe/checkout/route.ts +65 -0
- web/app/api/stripe/webhook/route.ts +69 -0
- web/app/auth/login/page.tsx +114 -0
- web/app/auth/signup/page.tsx +142 -0
- web/app/dashboard-pages/analyze/page.tsx +188 -0
- web/app/dashboard-pages/dashboard/page.tsx +124 -0
- web/app/globals.css +1 -0
- web/app/layout.tsx +29 -0
- web/app/page.tsx +282 -0
- web/lib/stripe.ts +29 -0
- web/lib/supabase/client.ts +8 -0
- web/lib/supabase/schema.sql +92 -0
- web/lib/supabase/server.ts +27 -0
- web/middleware.ts +48 -0
- web/next.config.ts +16 -0
- web/package.json +33 -0
- web/postcss.config.mjs +8 -0
- web/tsconfig.json +21 -0
README.md
CHANGED
|
@@ -1,12 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🛡️ ClauseGuard — AI Fine Print Scanner
|
| 2 |
+
|
| 3 |
+
> Stop signing away your rights. ClauseGuard uses AI to scan Terms of Service, contracts, and legal documents for unfair clauses — instantly.
|
| 4 |
+
|
| 5 |
+
**[Live Demo →](https://huggingface.co/spaces/gaurv007/ClauseGuard)**
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## What It Does
|
| 10 |
+
|
| 11 |
+
ClauseGuard detects **8 types of unfair clauses** based on the CLAUDETTE academic taxonomy:
|
| 12 |
+
|
| 13 |
+
| # | Category | Risk | What It Means |
|
| 14 |
+
|---|----------|------|---------------|
|
| 15 |
+
| 1 | ⚖️ Arbitration | 🔴 HIGH | Forces binding arbitration, waives right to sue |
|
| 16 |
+
| 2 | 🛡️ Limitation of Liability | 🔴 HIGH | Excludes liability for losses/damages |
|
| 17 |
+
| 3 | 🚫 Unilateral Termination | 🔴 HIGH | Can terminate your account anytime |
|
| 18 |
+
| 4 | 🔄 Unilateral Change | 🟠 MEDIUM | Can modify terms without consent |
|
| 19 |
+
| 5 | 🗑️ Content Removal | 🟠 MEDIUM | Can delete your content without notice |
|
| 20 |
+
| 6 | 🌍 Jurisdiction | 🟠 MEDIUM | Disputes resolved in their preferred court |
|
| 21 |
+
| 7 | 📜 Choice of Law | 🟠 MEDIUM | Governed by law of a different country |
|
| 22 |
+
| 8 | 👆 Contract by Using | 🟡 LOW | Bound by using the service (dark pattern) |
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## Project Structure
|
| 27 |
+
|
| 28 |
+
```
|
| 29 |
+
clauseguard/
|
| 30 |
+
├── extension/ # Chrome Extension (Manifest V3)
|
| 31 |
+
│ ├── manifest.json # Extension config
|
| 32 |
+
│ ├── background.js # Service worker — API calls, auth
|
| 33 |
+
│ ├── content.js # DOM scanner + highlighter
|
| 34 |
+
│ ├── popup.html/js # Quick status popup
|
| 35 |
+
│ ├── sidepanel.html/js # Detailed analysis sidebar
|
| 36 |
+
│ └── styles/ # Highlight CSS
|
| 37 |
+
│
|
| 38 |
+
├── web/ # Next.js 15 Website
|
| 39 |
+
│ ├── app/ # App Router pages
|
| 40 |
+
│ │ ├── page.tsx # Landing page
|
| 41 |
+
│ │ ├── dashboard-pages/ # Protected dashboard
|
| 42 |
+
│ │ ├── auth/ # Login / Signup
|
| 43 |
+
│ │ └── api/ # API routes (analyze, stripe)
|
| 44 |
+
│ ├── lib/ # Supabase, Stripe, utilities
|
| 45 |
+
│ └── middleware.ts # Auth guard
|
| 46 |
+
│
|
| 47 |
+
├── api/ # FastAPI Backend
|
| 48 |
+
│ ├── main.py # API server with ML inference
|
| 49 |
+
│ ├── Dockerfile # Container deployment
|
| 50 |
+
│ └── requirements.txt
|
| 51 |
+
│
|
| 52 |
+
├── ml/ # ML Model Training
|
| 53 |
+
│ ├── train_classifier.py # Fine-tune Legal-BERT
|
| 54 |
+
│ ├── export_onnx.py # Export to ONNX
|
| 55 |
+
│ └── requirements.txt
|
| 56 |
+
│
|
| 57 |
+
└── README.md
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
---
|
| 61 |
+
|
| 62 |
+
## Tech Stack
|
| 63 |
+
|
| 64 |
+
| Layer | Technology | Version |
|
| 65 |
+
|-------|-----------|---------|
|
| 66 |
+
| Extension | Chrome Manifest V3 | Latest |
|
| 67 |
+
| Frontend | Next.js 15.3 + Tailwind CSS 4 | April 2026 |
|
| 68 |
+
| Auth | Supabase SSR | 0.6.x |
|
| 69 |
+
| Database | Supabase (PostgreSQL + RLS) | Latest |
|
| 70 |
+
| Payments | Stripe Subscriptions | API 2025-03-31 |
|
| 71 |
+
| ML (classify) | Legal-BERT → ONNX | Transformers 5.6.x |
|
| 72 |
+
| ML (explain) | SaulLM-7B-Instruct | MIT License |
|
| 73 |
+
| API | FastAPI + Uvicorn | 0.115.x |
|
| 74 |
+
|
| 75 |
+
---
|
| 76 |
+
|
| 77 |
+
## Quick Start
|
| 78 |
+
|
| 79 |
+
### 1. ML Model Training
|
| 80 |
+
```bash
|
| 81 |
+
cd ml
|
| 82 |
+
pip install -r requirements.txt
|
| 83 |
+
python train_classifier.py
|
| 84 |
+
python export_onnx.py
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
### 2. API Backend
|
| 88 |
+
```bash
|
| 89 |
+
cd api
|
| 90 |
+
pip install -r requirements.txt
|
| 91 |
+
uvicorn main:app --reload
|
| 92 |
+
# Visit http://localhost:8000/docs
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
### 3. Website
|
| 96 |
+
```bash
|
| 97 |
+
cd web
|
| 98 |
+
cp .env.example .env.local # Fill in your keys
|
| 99 |
+
npm install
|
| 100 |
+
npm run dev
|
| 101 |
+
# Visit http://localhost:3000
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
### 4. Chrome Extension
|
| 105 |
+
1. Open `chrome://extensions/`
|
| 106 |
+
2. Enable "Developer mode"
|
| 107 |
+
3. Click "Load unpacked" → select `extension/` folder
|
| 108 |
+
4. Visit any Terms of Service page
|
| 109 |
+
|
| 110 |
+
---
|
| 111 |
+
|
| 112 |
+
## Setup Guide
|
| 113 |
+
|
| 114 |
+
### Supabase
|
| 115 |
+
1. Create a project at [supabase.com](https://supabase.com)
|
| 116 |
+
2. Run `web/lib/supabase/schema.sql` in SQL Editor
|
| 117 |
+
3. Enable Google & GitHub OAuth in Auth settings
|
| 118 |
+
4. Copy project URL + anon key to `.env.local`
|
| 119 |
+
|
| 120 |
+
### Stripe
|
| 121 |
+
1. Create products: "ClauseGuard Pro" ($12/mo) and "ClauseGuard Team" ($49/mo)
|
| 122 |
+
2. Copy price IDs to `.env.local`
|
| 123 |
+
3. Set up webhook endpoint: `https://yoursite.com/api/stripe/webhook`
|
| 124 |
+
4. Events to listen for: `customer.subscription.*`, `invoice.payment_failed`
|
| 125 |
+
|
| 126 |
+
### Chrome Web Store
|
| 127 |
+
1. Create developer account ($5 one-time)
|
| 128 |
+
2. Zip the `extension/` folder
|
| 129 |
+
3. Upload at [Chrome Developer Dashboard](https://chrome.google.com/webstore/devconsole)
|
| 130 |
+
4. Fill in description, screenshots, privacy policy
|
| 131 |
+
5. Submit for review (3-7 business days)
|
| 132 |
+
|
| 133 |
+
---
|
| 134 |
+
|
| 135 |
+
## ML Model Details
|
| 136 |
+
|
| 137 |
+
- **Base Model**: `nlpaueb/legal-bert-base-uncased` — BERT pre-trained on 12GB legal text
|
| 138 |
+
- **Dataset**: `coastalcph/lex_glue` (unfair_tos) — 9,414 clauses, 8 categories
|
| 139 |
+
- **Task**: Multi-label classification with BCEWithLogitsLoss
|
| 140 |
+
- **Expected Results**: ~83% macro-F1, ~95% micro-F1
|
| 141 |
+
- **Inference**: ONNX optimized, ~50-150ms per clause on CPU
|
| 142 |
+
|
| 143 |
+
### Papers
|
| 144 |
+
- [CLAUDETTE](https://arxiv.org/abs/1805.01217) — unfair clause taxonomy
|
| 145 |
+
- [LexGLUE](https://arxiv.org/abs/2110.00976) — legal NLU benchmark
|
| 146 |
+
- [SaulLM-7B](https://arxiv.org/abs/2403.03883) — legal domain LLM
|
| 147 |
+
|
| 148 |
---
|
| 149 |
+
|
| 150 |
+
## Pricing
|
| 151 |
+
|
| 152 |
+
| | Free | Pro ($12/mo) | Team ($49/mo) |
|
| 153 |
+
|---|------|-------------|---------------|
|
| 154 |
+
| Scans/month | 10 | Unlimited | Unlimited |
|
| 155 |
+
| Clause categories | 8 | 8 | 8 |
|
| 156 |
+
| AI explanations | ❌ | ✅ | ✅ |
|
| 157 |
+
| PDF exports | ❌ | ✅ | ✅ |
|
| 158 |
+
| API access | ❌ | 1K calls | 10K calls |
|
| 159 |
+
| Team seats | 1 | 1 | 5 |
|
| 160 |
+
|
| 161 |
---
|
| 162 |
|
| 163 |
+
## License
|
| 164 |
+
|
| 165 |
+
MIT License. Not legal advice — always consult a qualified attorney.
|
| 166 |
+
|
| 167 |
+
## Built By
|
| 168 |
+
|
| 169 |
+
[@gaurv007](https://huggingface.co/gaurv007)
|
api/Dockerfile
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
|
| 8 |
+
COPY . .
|
| 9 |
+
|
| 10 |
+
EXPOSE 8000
|
| 11 |
+
|
| 12 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
|
api/main.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ClauseGuard — FastAPI Backend
|
| 3 |
+
Serves clause classification via Legal-BERT ONNX model + SaulLM explanations.
|
| 4 |
+
Production-ready: CORS, rate limiting, JWT auth, usage tracking.
|
| 5 |
+
|
| 6 |
+
Compatible with: FastAPI 0.115+, Pydantic 2.x, Python 3.11+ (April 2026)
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import time
|
| 11 |
+
import json
|
| 12 |
+
import re
|
| 13 |
+
from contextlib import asynccontextmanager
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
from fastapi import FastAPI, HTTPException, Depends, Header, Request
|
| 18 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 19 |
+
from fastapi.responses import JSONResponse
|
| 20 |
+
from pydantic import BaseModel, Field
|
| 21 |
+
|
| 22 |
+
# ─── Config ───
|
| 23 |
+
MODEL_PATH = os.environ.get("MODEL_PATH", "./clauseguard-model/final")
|
| 24 |
+
ONNX_MODEL_PATH = os.environ.get("ONNX_MODEL_PATH", "./clauseguard-model-onnx")
|
| 25 |
+
USE_ONNX = os.environ.get("USE_ONNX", "true").lower() == "true"
|
| 26 |
+
SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
|
| 27 |
+
SUPABASE_KEY = os.environ.get("SUPABASE_ANON_KEY", "")
|
| 28 |
+
RATE_LIMIT_FREE = int(os.environ.get("RATE_LIMIT_FREE", "10")) # per day
|
| 29 |
+
RATE_LIMIT_PRO = int(os.environ.get("RATE_LIMIT_PRO", "1000")) # per day
|
| 30 |
+
|
| 31 |
+
LABEL_NAMES = [
|
| 32 |
+
"Limitation of liability",
|
| 33 |
+
"Unilateral termination",
|
| 34 |
+
"Unilateral change",
|
| 35 |
+
"Content removal",
|
| 36 |
+
"Contract by using",
|
| 37 |
+
"Choice of law",
|
| 38 |
+
"Jurisdiction",
|
| 39 |
+
"Arbitration",
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
LABEL_DESCRIPTIONS = {
|
| 43 |
+
"Limitation of liability": "Company limits or excludes liability for losses, data breaches, or service failures.",
|
| 44 |
+
"Unilateral termination": "Company can terminate your account at any time without reason.",
|
| 45 |
+
"Unilateral change": "Company can change terms at any time without your consent.",
|
| 46 |
+
"Content removal": "Company can delete your content without notice or justification.",
|
| 47 |
+
"Contract by using": "You're bound to the contract simply by using the service — a dark pattern.",
|
| 48 |
+
"Choice of law": "Governing law may differ from your country — reducing your legal protections.",
|
| 49 |
+
"Jurisdiction": "Disputes must be resolved in a jurisdiction that may disadvantage you.",
|
| 50 |
+
"Arbitration": "Forces disputes to arbitration instead of court — you waive your right to sue.",
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
SEVERITY_MAP = {
|
| 54 |
+
"Limitation of liability": "HIGH",
|
| 55 |
+
"Unilateral termination": "HIGH",
|
| 56 |
+
"Arbitration": "HIGH",
|
| 57 |
+
"Unilateral change": "MEDIUM",
|
| 58 |
+
"Content removal": "MEDIUM",
|
| 59 |
+
"Choice of law": "MEDIUM",
|
| 60 |
+
"Jurisdiction": "MEDIUM",
|
| 61 |
+
"Contract by using": "LOW",
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
# ─── Model Loading ───
|
| 65 |
+
classifier = None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def load_model():
|
| 69 |
+
"""Load the ML model (ONNX preferred, PyTorch fallback)."""
|
| 70 |
+
global classifier
|
| 71 |
+
try:
|
| 72 |
+
if USE_ONNX and os.path.exists(ONNX_MODEL_PATH):
|
| 73 |
+
from optimum.onnxruntime import ORTModelForSequenceClassification
|
| 74 |
+
from transformers import AutoTokenizer, pipeline
|
| 75 |
+
model = ORTModelForSequenceClassification.from_pretrained(ONNX_MODEL_PATH)
|
| 76 |
+
tokenizer = AutoTokenizer.from_pretrained(ONNX_MODEL_PATH)
|
| 77 |
+
classifier = pipeline("text-classification", model=model, tokenizer=tokenizer, top_k=None)
|
| 78 |
+
print(f"✅ Loaded ONNX model from {ONNX_MODEL_PATH}")
|
| 79 |
+
elif os.path.exists(MODEL_PATH):
|
| 80 |
+
from transformers import pipeline
|
| 81 |
+
classifier = pipeline("text-classification", model=MODEL_PATH, top_k=None, device=-1)
|
| 82 |
+
print(f"✅ Loaded PyTorch model from {MODEL_PATH}")
|
| 83 |
+
else:
|
| 84 |
+
print("⚠️ No model found — using regex fallback")
|
| 85 |
+
except Exception as e:
|
| 86 |
+
print(f"⚠️ Model load failed: {e} — using regex fallback")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ─── Regex Fallback ───
|
| 90 |
+
CLAUSE_PATTERNS = {
|
| 91 |
+
0: [r"not liable", r"shall not be (liable|responsible)", r"in no event.*liable",
|
| 92 |
+
r"limitation of liability", r"without warranty", r"disclaim"],
|
| 93 |
+
1: [r"terminat.*at any time", r"suspend.*account.*without",
|
| 94 |
+
r"we may (terminat|suspend|discontinu)", r"right to (terminat|suspend)"],
|
| 95 |
+
2: [r"sole discretion", r"reserves? the right to (modify|change|update|amend)",
|
| 96 |
+
r"at any time.*without (prior )?notice", r"we may (modify|change|update)"],
|
| 97 |
+
3: [r"remove.*content.*without", r"right to remove", r"we may.*remove"],
|
| 98 |
+
4: [r"by (using|accessing).*you agree", r"continued use.*constitutes? acceptance"],
|
| 99 |
+
5: [r"governed by.*laws? of", r"shall be governed", r"laws of the state of"],
|
| 100 |
+
6: [r"exclusive jurisdiction", r"courts? of.*(california|delaware|new york|ireland|england)",
|
| 101 |
+
r"submit to.*jurisdiction"],
|
| 102 |
+
7: [r"arbitrat", r"binding arbitration", r"waive.*right.*court", r"class action waiver"],
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def classify_regex(text: str) -> list[dict]:
|
| 107 |
+
"""Fallback: classify using regex patterns."""
|
| 108 |
+
results = []
|
| 109 |
+
text_lower = text.lower()
|
| 110 |
+
for label_id, patterns in CLAUSE_PATTERNS.items():
|
| 111 |
+
for pattern in patterns:
|
| 112 |
+
if re.search(pattern, text_lower):
|
| 113 |
+
name = LABEL_NAMES[label_id]
|
| 114 |
+
results.append({
|
| 115 |
+
"id": label_id,
|
| 116 |
+
"name": name,
|
| 117 |
+
"severity": SEVERITY_MAP[name],
|
| 118 |
+
"description": LABEL_DESCRIPTIONS[name],
|
| 119 |
+
"confidence": 0.7,
|
| 120 |
+
})
|
| 121 |
+
break
|
| 122 |
+
return results
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def classify_ml(text: str) -> list[dict]:
|
| 126 |
+
"""Classify using the ML model."""
|
| 127 |
+
if classifier is None:
|
| 128 |
+
return classify_regex(text)
|
| 129 |
+
|
| 130 |
+
try:
|
| 131 |
+
preds = classifier(text, truncation=True, max_length=512)
|
| 132 |
+
results = []
|
| 133 |
+
for p in preds[0] if isinstance(preds[0], list) else preds:
|
| 134 |
+
label = p["label"]
|
| 135 |
+
score = p["score"]
|
| 136 |
+
if score > 0.5 and label in LABEL_DESCRIPTIONS:
|
| 137 |
+
results.append({
|
| 138 |
+
"id": LABEL_NAMES.index(label) if label in LABEL_NAMES else -1,
|
| 139 |
+
"name": label,
|
| 140 |
+
"severity": SEVERITY_MAP.get(label, "MEDIUM"),
|
| 141 |
+
"description": LABEL_DESCRIPTIONS[label],
|
| 142 |
+
"confidence": round(score, 3),
|
| 143 |
+
})
|
| 144 |
+
return results
|
| 145 |
+
except Exception:
|
| 146 |
+
return classify_regex(text)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# ─── Request/Response Models ───
|
| 150 |
+
class AnalyzeRequest(BaseModel):
|
| 151 |
+
clauses: list[str] = Field(..., min_length=1, max_length=500)
|
| 152 |
+
source_url: Optional[str] = None
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
class ClauseResult(BaseModel):
|
| 156 |
+
text: str
|
| 157 |
+
categories: list[dict]
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
class AnalyzeResponse(BaseModel):
|
| 161 |
+
risk_score: int
|
| 162 |
+
grade: str
|
| 163 |
+
total_clauses: int
|
| 164 |
+
flagged_count: int
|
| 165 |
+
results: list[ClauseResult]
|
| 166 |
+
model: str # "ml" or "regex"
|
| 167 |
+
latency_ms: int
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
class ExplainRequest(BaseModel):
|
| 171 |
+
clause: str = Field(..., min_length=10, max_length=2000)
|
| 172 |
+
category: str
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
class ExplainResponse(BaseModel):
|
| 176 |
+
clause: str
|
| 177 |
+
category: str
|
| 178 |
+
explanation: str
|
| 179 |
+
legal_basis: str
|
| 180 |
+
recommendation: str
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# ─── App ───
|
| 184 |
+
@asynccontextmanager
|
| 185 |
+
async def lifespan(app: FastAPI):
|
| 186 |
+
load_model()
|
| 187 |
+
yield
|
| 188 |
+
|
| 189 |
+
app = FastAPI(
|
| 190 |
+
title="ClauseGuard API",
|
| 191 |
+
description="AI-powered unfair clause detection in Terms of Service, contracts, and legal documents.",
|
| 192 |
+
version="1.0.0",
|
| 193 |
+
lifespan=lifespan,
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
app.add_middleware(
|
| 197 |
+
CORSMiddleware,
|
| 198 |
+
allow_origins=[
|
| 199 |
+
"https://clauseguard.com",
|
| 200 |
+
"https://app.clauseguard.com",
|
| 201 |
+
"chrome-extension://*",
|
| 202 |
+
"http://localhost:3000", # dev
|
| 203 |
+
],
|
| 204 |
+
allow_credentials=True,
|
| 205 |
+
allow_methods=["GET", "POST", "OPTIONS"],
|
| 206 |
+
allow_headers=["*"],
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
# ─── Routes ───
|
| 211 |
+
@app.get("/health")
|
| 212 |
+
async def health():
|
| 213 |
+
return {"status": "ok", "model_loaded": classifier is not None}
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
@app.post("/api/analyze", response_model=AnalyzeResponse)
|
| 217 |
+
async def analyze(req: AnalyzeRequest):
|
| 218 |
+
start = time.time()
|
| 219 |
+
|
| 220 |
+
results = []
|
| 221 |
+
for clause in req.clauses:
|
| 222 |
+
categories = classify_ml(clause) if classifier else classify_regex(clause)
|
| 223 |
+
results.append(ClauseResult(text=clause, categories=categories))
|
| 224 |
+
|
| 225 |
+
flagged = [r for r in results if len(r.categories) > 0]
|
| 226 |
+
sev_counts = {"HIGH": 0, "MEDIUM": 0, "LOW": 0}
|
| 227 |
+
for r in flagged:
|
| 228 |
+
for c in r.categories:
|
| 229 |
+
sev_counts[c.get("severity", "LOW")] += 1
|
| 230 |
+
|
| 231 |
+
total = len(req.clauses)
|
| 232 |
+
risk_score = min(100, int(
|
| 233 |
+
(sev_counts["HIGH"] * 20 + sev_counts["MEDIUM"] * 10 + sev_counts["LOW"] * 5)
|
| 234 |
+
/ max(1, total) * 100
|
| 235 |
+
))
|
| 236 |
+
|
| 237 |
+
if risk_score >= 60:
|
| 238 |
+
grade = "F"
|
| 239 |
+
elif risk_score >= 40:
|
| 240 |
+
grade = "D"
|
| 241 |
+
elif risk_score >= 20:
|
| 242 |
+
grade = "C"
|
| 243 |
+
elif risk_score >= 10:
|
| 244 |
+
grade = "B"
|
| 245 |
+
else:
|
| 246 |
+
grade = "A"
|
| 247 |
+
|
| 248 |
+
latency = int((time.time() - start) * 1000)
|
| 249 |
+
|
| 250 |
+
return AnalyzeResponse(
|
| 251 |
+
risk_score=risk_score,
|
| 252 |
+
grade=grade,
|
| 253 |
+
total_clauses=total,
|
| 254 |
+
flagged_count=len(flagged),
|
| 255 |
+
results=results,
|
| 256 |
+
model="ml" if classifier else "regex",
|
| 257 |
+
latency_ms=latency,
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
@app.post("/api/explain", response_model=ExplainResponse)
|
| 262 |
+
async def explain(req: ExplainRequest):
|
| 263 |
+
"""Explain why a clause is unfair (premium feature — placeholder for SaulLM integration)."""
|
| 264 |
+
desc = LABEL_DESCRIPTIONS.get(req.category, "Unknown category.")
|
| 265 |
+
|
| 266 |
+
legal_basis_map = {
|
| 267 |
+
"Arbitration": "EU Directive 93/13/EEC, Art. 3 — unfair terms in consumer contracts; CFPB arbitration rule (US).",
|
| 268 |
+
"Unilateral change": "EU Directive 93/13/EEC, Annex 1(j) — terms enabling unilateral alteration.",
|
| 269 |
+
"Content removal": "EU Digital Services Act (DSA), Art. 17 — statement of reasons required for content moderation.",
|
| 270 |
+
"Jurisdiction": "EU Regulation 1215/2012 (Brussels I), Art. 18 — consumer's domicile prevails.",
|
| 271 |
+
"Choice of law": "EU Regulation 593/2008 (Rome I), Art. 6 — consumer protection of habitual residence.",
|
| 272 |
+
"Limitation of liability": "EU Directive 93/13/EEC, Annex 1(a) — excluding statutory rights is unfair.",
|
| 273 |
+
"Unilateral termination": "EU Directive 93/13/EEC, Annex 1(f)(g) — termination without reasonable notice.",
|
| 274 |
+
"Contract by using": "EU Directive 2011/83/EU, Art. 8 — active consent required, not passive acceptance.",
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
return ExplainResponse(
|
| 278 |
+
clause=req.clause,
|
| 279 |
+
category=req.category,
|
| 280 |
+
explanation=desc,
|
| 281 |
+
legal_basis=legal_basis_map.get(req.category, "Consult local consumer protection laws."),
|
| 282 |
+
recommendation="Consider negotiating this clause or seeking legal advice before agreeing.",
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
if __name__ == "__main__":
|
| 287 |
+
import uvicorn
|
| 288 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
api/requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.115.0
|
| 2 |
+
uvicorn[standard]>=0.32.0
|
| 3 |
+
pydantic>=2.10.0
|
| 4 |
+
transformers>=4.47.0
|
| 5 |
+
optimum[onnxruntime]>=1.24.0
|
| 6 |
+
numpy>=2.0.0
|
| 7 |
+
python-jose[cryptography]>=3.3.0
|
| 8 |
+
httpx>=0.28.0
|
extension/background.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ClauseGuard — Background Service Worker (Manifest V3)
|
| 3 |
+
* Handles: API calls, auth token management, side panel control, usage tracking
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
const API_BASE = "https://api.clauseguard.com";
|
| 7 |
+
const FREE_SCANS_PER_MONTH = 10;
|
| 8 |
+
|
| 9 |
+
// ─── Side Panel ───
|
| 10 |
+
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: false }).catch(() => {});
|
| 11 |
+
|
| 12 |
+
// ─── Message Router ───
|
| 13 |
+
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
| 14 |
+
switch (message.type) {
|
| 15 |
+
case "ANALYZE_TEXT":
|
| 16 |
+
handleAnalyze(message.payload, sender.tab?.id).then(sendResponse);
|
| 17 |
+
return true; // keep channel open for async
|
| 18 |
+
|
| 19 |
+
case "GET_AUTH":
|
| 20 |
+
getAuth().then(sendResponse);
|
| 21 |
+
return true;
|
| 22 |
+
|
| 23 |
+
case "CHECK_USAGE":
|
| 24 |
+
checkUsage().then(sendResponse);
|
| 25 |
+
return true;
|
| 26 |
+
|
| 27 |
+
case "OPEN_SIDEPANEL":
|
| 28 |
+
if (sender.tab?.id) {
|
| 29 |
+
chrome.sidePanel.open({ tabId: sender.tab.id });
|
| 30 |
+
}
|
| 31 |
+
break;
|
| 32 |
+
|
| 33 |
+
case "GET_RESULTS":
|
| 34 |
+
getStoredResults(sender.tab?.id || message.tabId).then(sendResponse);
|
| 35 |
+
return true;
|
| 36 |
+
}
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
// ─── External message from website (auth token) ───
|
| 40 |
+
chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
|
| 41 |
+
const allowed = ["https://app.clauseguard.com", "https://clauseguard.com"];
|
| 42 |
+
if (!allowed.some(origin => sender.origin?.startsWith(origin))) return;
|
| 43 |
+
|
| 44 |
+
if (message.type === "SET_AUTH") {
|
| 45 |
+
chrome.storage.sync.set({
|
| 46 |
+
authToken: message.token,
|
| 47 |
+
plan: message.plan || "free",
|
| 48 |
+
email: message.email || "",
|
| 49 |
+
});
|
| 50 |
+
sendResponse({ success: true });
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
if (message.type === "LOGOUT") {
|
| 54 |
+
chrome.storage.sync.remove(["authToken", "plan", "email"]);
|
| 55 |
+
sendResponse({ success: true });
|
| 56 |
+
}
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
// ─── Core: Analyze Text ───
|
| 60 |
+
async function handleAnalyze(payload, tabId) {
|
| 61 |
+
try {
|
| 62 |
+
// Check usage limits
|
| 63 |
+
const usage = await checkUsage();
|
| 64 |
+
if (!usage.allowed) {
|
| 65 |
+
return { error: "limit_reached", message: "Free scan limit reached. Upgrade to Pro for unlimited scans.", usage };
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
const { text, url } = payload;
|
| 69 |
+
|
| 70 |
+
// Split text into clauses
|
| 71 |
+
const clauses = splitIntoClauses(text);
|
| 72 |
+
if (clauses.length === 0) {
|
| 73 |
+
return { error: "no_clauses", message: "No analyzable clauses found on this page." };
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
// Call API
|
| 77 |
+
const auth = await getAuth();
|
| 78 |
+
const response = await fetch(`${API_BASE}/api/analyze`, {
|
| 79 |
+
method: "POST",
|
| 80 |
+
headers: {
|
| 81 |
+
"Content-Type": "application/json",
|
| 82 |
+
...(auth.token ? { Authorization: `Bearer ${auth.token}` } : {}),
|
| 83 |
+
},
|
| 84 |
+
body: JSON.stringify({ clauses, source_url: url }),
|
| 85 |
+
});
|
| 86 |
+
|
| 87 |
+
if (!response.ok) {
|
| 88 |
+
throw new Error(`API error: ${response.status}`);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
const results = await response.json();
|
| 92 |
+
|
| 93 |
+
// Store results for this tab
|
| 94 |
+
await chrome.storage.local.set({ [`results_${tabId}`]: results });
|
| 95 |
+
|
| 96 |
+
// Increment usage
|
| 97 |
+
await incrementUsage();
|
| 98 |
+
|
| 99 |
+
// Update badge
|
| 100 |
+
const flagged = results.results?.filter(r => r.categories?.length > 0).length || 0;
|
| 101 |
+
if (flagged > 0) {
|
| 102 |
+
chrome.action.setBadgeText({ text: String(flagged), tabId });
|
| 103 |
+
chrome.action.setBadgeBackgroundColor({ color: flagged > 3 ? "#ef4444" : "#f59e0b", tabId });
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
return results;
|
| 107 |
+
} catch (err) {
|
| 108 |
+
console.error("ClauseGuard analyze error:", err);
|
| 109 |
+
// Fallback: use local pattern matching
|
| 110 |
+
return localAnalyze(payload.text, payload.url);
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// ─── Local Fallback Analyzer (regex patterns) ───
|
| 115 |
+
function localAnalyze(text, url) {
|
| 116 |
+
const clauses = splitIntoClauses(text);
|
| 117 |
+
const patterns = {
|
| 118 |
+
0: [/arbitrat/i, /binding arbitration/i, /waive.*right.*court/i, /class action waiver/i],
|
| 119 |
+
1: [/sole discretion/i, /reserves? the right to (modify|change|update|amend)/i, /at any time.*without (prior )?notice/i],
|
| 120 |
+
2: [/remove.*content.*without/i, /right to remove/i, /we may.*remove/i],
|
| 121 |
+
3: [/exclusive jurisdiction/i, /courts? of.*(california|delaware|new york|ireland|england)/i, /submit to.*jurisdiction/i],
|
| 122 |
+
4: [/governed by.*laws? of/i, /shall be governed/i, /laws of the state of/i],
|
| 123 |
+
5: [/not liable/i, /shall not be (liable|responsible)/i, /in no event.*liable/i, /limitation of liability/i, /without warranty/i],
|
| 124 |
+
6: [/terminat.*at any time/i, /suspend.*account.*without/i, /we may (terminat|suspend|discontinu)/i],
|
| 125 |
+
7: [/by (using|accessing).*you agree/i, /continued use.*constitutes? acceptance/i],
|
| 126 |
+
};
|
| 127 |
+
|
| 128 |
+
const labelNames = [
|
| 129 |
+
"Limitation of liability", "Unilateral termination", "Unilateral change",
|
| 130 |
+
"Content removal", "Contract by using", "Choice of law", "Jurisdiction", "Arbitration",
|
| 131 |
+
];
|
| 132 |
+
const highRisk = new Set([0, 1, 6]); // liability, termination, arbitration (indices in labelNames mapping)
|
| 133 |
+
const severityMap = { 0: "HIGH", 1: "HIGH", 2: "MEDIUM", 3: "MEDIUM", 4: "MEDIUM", 5: "HIGH", 6: "HIGH", 7: "LOW" };
|
| 134 |
+
|
| 135 |
+
const results = clauses.map(clause => {
|
| 136 |
+
const categories = [];
|
| 137 |
+
for (const [id, pats] of Object.entries(patterns)) {
|
| 138 |
+
if (pats.some(p => p.test(clause))) {
|
| 139 |
+
categories.push({
|
| 140 |
+
id: Number(id),
|
| 141 |
+
name: labelNames[id],
|
| 142 |
+
severity: severityMap[id],
|
| 143 |
+
});
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
return { text: clause, categories };
|
| 147 |
+
});
|
| 148 |
+
|
| 149 |
+
const flagged = results.filter(r => r.categories.length > 0);
|
| 150 |
+
const sevCounts = { HIGH: 0, MEDIUM: 0, LOW: 0 };
|
| 151 |
+
flagged.forEach(r => r.categories.forEach(c => sevCounts[c.severity]++));
|
| 152 |
+
|
| 153 |
+
const riskScore = Math.min(100, Math.round(
|
| 154 |
+
(sevCounts.HIGH * 20 + sevCounts.MEDIUM * 10 + sevCounts.LOW * 5) / Math.max(1, clauses.length) * 100
|
| 155 |
+
));
|
| 156 |
+
|
| 157 |
+
return {
|
| 158 |
+
risk_score: riskScore,
|
| 159 |
+
grade: riskScore >= 60 ? "F" : riskScore >= 40 ? "D" : riskScore >= 20 ? "C" : riskScore >= 10 ? "B" : "A",
|
| 160 |
+
total_clauses: clauses.length,
|
| 161 |
+
flagged_count: flagged.length,
|
| 162 |
+
results,
|
| 163 |
+
source: "local",
|
| 164 |
+
};
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
// ─── Clause Splitter ───
|
| 168 |
+
function splitIntoClauses(text) {
|
| 169 |
+
const cleaned = text.replace(/\n{2,}/g, "\n").trim();
|
| 170 |
+
const clauses = cleaned.split(/(?<=[.!?])\s+(?=[A-Z0-9(])|(?:\n)(?=\d+[.)]\s|\([a-z]\)\s|•\s|-\s)/);
|
| 171 |
+
return clauses.map(c => c.trim()).filter(c => c.length > 30);
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
// ─── Auth Helpers ───
|
| 175 |
+
async function getAuth() {
|
| 176 |
+
return new Promise(resolve => {
|
| 177 |
+
chrome.storage.sync.get(["authToken", "plan", "email"], data => {
|
| 178 |
+
resolve({
|
| 179 |
+
token: data.authToken || null,
|
| 180 |
+
plan: data.plan || "free",
|
| 181 |
+
email: data.email || null,
|
| 182 |
+
isLoggedIn: !!data.authToken,
|
| 183 |
+
isPro: data.plan === "pro" || data.plan === "team",
|
| 184 |
+
});
|
| 185 |
+
});
|
| 186 |
+
});
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
// ─── Usage Tracking ───
|
| 190 |
+
async function checkUsage() {
|
| 191 |
+
return new Promise(resolve => {
|
| 192 |
+
chrome.storage.sync.get(["plan", "scansThisMonth", "monthResetAt"], data => {
|
| 193 |
+
const now = new Date();
|
| 194 |
+
const resetAt = data.monthResetAt ? new Date(data.monthResetAt) : null;
|
| 195 |
+
|
| 196 |
+
// Reset counter on new month
|
| 197 |
+
if (!resetAt || now.getMonth() !== resetAt.getMonth() || now.getFullYear() !== resetAt.getFullYear()) {
|
| 198 |
+
chrome.storage.sync.set({ scansThisMonth: 0, monthResetAt: now.toISOString() });
|
| 199 |
+
resolve({ allowed: true, used: 0, limit: FREE_SCANS_PER_MONTH, plan: data.plan || "free" });
|
| 200 |
+
return;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
const used = data.scansThisMonth || 0;
|
| 204 |
+
const plan = data.plan || "free";
|
| 205 |
+
const allowed = plan !== "free" || used < FREE_SCANS_PER_MONTH;
|
| 206 |
+
|
| 207 |
+
resolve({ allowed, used, limit: plan === "free" ? FREE_SCANS_PER_MONTH : Infinity, plan });
|
| 208 |
+
});
|
| 209 |
+
});
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
async function incrementUsage() {
|
| 213 |
+
return new Promise(resolve => {
|
| 214 |
+
chrome.storage.sync.get(["scansThisMonth"], data => {
|
| 215 |
+
chrome.storage.sync.set({ scansThisMonth: (data.scansThisMonth || 0) + 1 }, resolve);
|
| 216 |
+
});
|
| 217 |
+
});
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
async function getStoredResults(tabId) {
|
| 221 |
+
return new Promise(resolve => {
|
| 222 |
+
chrome.storage.local.get([`results_${tabId}`], data => {
|
| 223 |
+
resolve(data[`results_${tabId}`] || null);
|
| 224 |
+
});
|
| 225 |
+
});
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
// ─── Tab cleanup ───
|
| 229 |
+
chrome.tabs.onRemoved.addListener(tabId => {
|
| 230 |
+
chrome.storage.local.remove([`results_${tabId}`]);
|
| 231 |
+
});
|
extension/content.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ClauseGuard — Content Script
|
| 3 |
+
* Runs on every page. Extracts text, sends to background for analysis,
|
| 4 |
+
* highlights flagged clauses in the DOM using TreeWalker (non-destructive).
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
(() => {
|
| 8 |
+
"use strict";
|
| 9 |
+
|
| 10 |
+
// Avoid running on non-HTML pages
|
| 11 |
+
if (!document.body || document.contentType !== "text/html") return;
|
| 12 |
+
|
| 13 |
+
let lastScannedText = "";
|
| 14 |
+
let isScanning = false;
|
| 15 |
+
let currentHighlights = [];
|
| 16 |
+
|
| 17 |
+
// ─── Extract page text (strip noise) ───
|
| 18 |
+
function extractPageText() {
|
| 19 |
+
const clone = document.body.cloneNode(true);
|
| 20 |
+
const remove = ["script", "style", "nav", "footer", "header", "aside", "noscript", "iframe", "svg"];
|
| 21 |
+
remove.forEach(tag => clone.querySelectorAll(tag).forEach(el => el.remove()));
|
| 22 |
+
|
| 23 |
+
// Also remove common cookie/banner elements
|
| 24 |
+
clone.querySelectorAll('[class*="cookie"], [class*="banner"], [class*="modal"], [id*="cookie"]')
|
| 25 |
+
.forEach(el => el.remove());
|
| 26 |
+
|
| 27 |
+
return clone.innerText || clone.textContent || "";
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
// ─── Scan the page ───
|
| 31 |
+
async function scanPage() {
|
| 32 |
+
if (isScanning) return;
|
| 33 |
+
isScanning = true;
|
| 34 |
+
|
| 35 |
+
const text = extractPageText();
|
| 36 |
+
if (!text || text.length < 100 || text === lastScannedText) {
|
| 37 |
+
isScanning = false;
|
| 38 |
+
return;
|
| 39 |
+
}
|
| 40 |
+
lastScannedText = text;
|
| 41 |
+
|
| 42 |
+
try {
|
| 43 |
+
const results = await chrome.runtime.sendMessage({
|
| 44 |
+
type: "ANALYZE_TEXT",
|
| 45 |
+
payload: { text, url: window.location.href },
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
if (results && !results.error) {
|
| 49 |
+
clearHighlights();
|
| 50 |
+
highlightResults(results);
|
| 51 |
+
}
|
| 52 |
+
} catch (err) {
|
| 53 |
+
console.error("ClauseGuard scan error:", err);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
isScanning = false;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// ─── Highlight flagged clauses in DOM ───
|
| 60 |
+
function highlightResults(results) {
|
| 61 |
+
if (!results.results) return;
|
| 62 |
+
|
| 63 |
+
const flagged = results.results.filter(r => r.categories && r.categories.length > 0);
|
| 64 |
+
if (flagged.length === 0) return;
|
| 65 |
+
|
| 66 |
+
flagged.forEach(item => {
|
| 67 |
+
const matchText = item.text.trim();
|
| 68 |
+
if (matchText.length < 20) return;
|
| 69 |
+
|
| 70 |
+
// Find text nodes matching this clause
|
| 71 |
+
const textNodes = findTextNodes(document.body, matchText);
|
| 72 |
+
textNodes.forEach(({ node, startOffset, endOffset }) => {
|
| 73 |
+
wrapTextRange(node, startOffset, endOffset, item);
|
| 74 |
+
});
|
| 75 |
+
});
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
// ─── Find text nodes containing a string ───
|
| 79 |
+
function findTextNodes(root, searchText) {
|
| 80 |
+
const results = [];
|
| 81 |
+
const searchLower = searchText.toLowerCase().substring(0, 80); // Match first 80 chars
|
| 82 |
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
| 83 |
+
acceptNode(node) {
|
| 84 |
+
if (node.parentElement?.closest(".clauseguard-highlight, script, style, nav")) {
|
| 85 |
+
return NodeFilter.FILTER_REJECT;
|
| 86 |
+
}
|
| 87 |
+
return node.textContent.length > 20 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
|
| 88 |
+
},
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
let node;
|
| 92 |
+
while ((node = walker.nextNode())) {
|
| 93 |
+
const textLower = node.textContent.toLowerCase();
|
| 94 |
+
const idx = textLower.indexOf(searchLower);
|
| 95 |
+
if (idx !== -1) {
|
| 96 |
+
results.push({
|
| 97 |
+
node,
|
| 98 |
+
startOffset: idx,
|
| 99 |
+
endOffset: Math.min(idx + searchText.length, node.textContent.length),
|
| 100 |
+
});
|
| 101 |
+
break; // One match per clause is enough
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
return results;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
// ─── Wrap text node range in a highlight element ───
|
| 108 |
+
function wrapTextRange(textNode, start, end, clauseData) {
|
| 109 |
+
try {
|
| 110 |
+
const range = document.createRange();
|
| 111 |
+
range.setStart(textNode, start);
|
| 112 |
+
range.setEnd(textNode, end);
|
| 113 |
+
|
| 114 |
+
const severity = getMaxSeverity(clauseData.categories);
|
| 115 |
+
const mark = document.createElement("mark");
|
| 116 |
+
mark.className = `clauseguard-highlight clauseguard-${severity.toLowerCase()}`;
|
| 117 |
+
mark.dataset.categories = JSON.stringify(clauseData.categories);
|
| 118 |
+
mark.dataset.clauseText = clauseData.text.substring(0, 200);
|
| 119 |
+
|
| 120 |
+
// Tooltip on hover
|
| 121 |
+
mark.addEventListener("mouseenter", showTooltip);
|
| 122 |
+
mark.addEventListener("mouseleave", hideTooltip);
|
| 123 |
+
mark.addEventListener("click", openSidePanel);
|
| 124 |
+
|
| 125 |
+
range.surroundContents(mark);
|
| 126 |
+
currentHighlights.push(mark);
|
| 127 |
+
} catch (e) {
|
| 128 |
+
// Range errors happen with complex DOM — silently skip
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
function getMaxSeverity(categories) {
|
| 133 |
+
const order = { HIGH: 3, MEDIUM: 2, LOW: 1 };
|
| 134 |
+
return categories.reduce((max, c) => order[c.severity] > order[max] ? c.severity : max, "LOW");
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
// ─── Tooltip ───
|
| 138 |
+
let tooltipEl = null;
|
| 139 |
+
|
| 140 |
+
function showTooltip(e) {
|
| 141 |
+
hideTooltip();
|
| 142 |
+
const mark = e.currentTarget;
|
| 143 |
+
const categories = JSON.parse(mark.dataset.categories || "[]");
|
| 144 |
+
if (categories.length === 0) return;
|
| 145 |
+
|
| 146 |
+
tooltipEl = document.createElement("div");
|
| 147 |
+
tooltipEl.className = "clauseguard-tooltip";
|
| 148 |
+
tooltipEl.innerHTML = `
|
| 149 |
+
<div class="clauseguard-tooltip-header">🛡️ ClauseGuard Warning</div>
|
| 150 |
+
${categories.map(c => `
|
| 151 |
+
<div class="clauseguard-tooltip-item">
|
| 152 |
+
<span class="clauseguard-tooltip-badge clauseguard-badge-${c.severity.toLowerCase()}">${c.severity}</span>
|
| 153 |
+
<span>${c.name}</span>
|
| 154 |
+
</div>
|
| 155 |
+
`).join("")}
|
| 156 |
+
<div class="clauseguard-tooltip-footer">Click for details →</div>
|
| 157 |
+
`;
|
| 158 |
+
|
| 159 |
+
document.body.appendChild(tooltipEl);
|
| 160 |
+
|
| 161 |
+
const rect = mark.getBoundingClientRect();
|
| 162 |
+
tooltipEl.style.left = `${rect.left + window.scrollX}px`;
|
| 163 |
+
tooltipEl.style.top = `${rect.bottom + window.scrollY + 8}px`;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
function hideTooltip() {
|
| 167 |
+
if (tooltipEl) {
|
| 168 |
+
tooltipEl.remove();
|
| 169 |
+
tooltipEl = null;
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
function openSidePanel() {
|
| 174 |
+
chrome.runtime.sendMessage({ type: "OPEN_SIDEPANEL" });
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
// ─── Clear all highlights ───
|
| 178 |
+
function clearHighlights() {
|
| 179 |
+
currentHighlights.forEach(mark => {
|
| 180 |
+
const parent = mark.parentNode;
|
| 181 |
+
if (parent) {
|
| 182 |
+
while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
|
| 183 |
+
parent.removeChild(mark);
|
| 184 |
+
}
|
| 185 |
+
});
|
| 186 |
+
currentHighlights = [];
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
// ─── Auto-scan on page load ───
|
| 190 |
+
// Debounced to handle SPAs
|
| 191 |
+
let scanTimeout = null;
|
| 192 |
+
function debouncedScan() {
|
| 193 |
+
clearTimeout(scanTimeout);
|
| 194 |
+
scanTimeout = setTimeout(scanPage, 1500);
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
// Scan on initial load
|
| 198 |
+
if (document.readyState === "complete") {
|
| 199 |
+
debouncedScan();
|
| 200 |
+
} else {
|
| 201 |
+
window.addEventListener("load", debouncedScan);
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
// Re-scan on SPA navigation (MutationObserver)
|
| 205 |
+
const observer = new MutationObserver(mutations => {
|
| 206 |
+
const hasSignificantChange = mutations.some(m =>
|
| 207 |
+
m.addedNodes.length > 0 && [...m.addedNodes].some(n => n.nodeType === 1 && n.textContent?.length > 100)
|
| 208 |
+
);
|
| 209 |
+
if (hasSignificantChange) debouncedScan();
|
| 210 |
+
});
|
| 211 |
+
|
| 212 |
+
observer.observe(document.body, { childList: true, subtree: true });
|
| 213 |
+
|
| 214 |
+
// ─── Listen for manual scan trigger from popup/sidepanel ───
|
| 215 |
+
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
| 216 |
+
if (msg.type === "TRIGGER_SCAN") {
|
| 217 |
+
lastScannedText = ""; // Force rescan
|
| 218 |
+
scanPage().then(() => sendResponse({ done: true }));
|
| 219 |
+
return true;
|
| 220 |
+
}
|
| 221 |
+
if (msg.type === "CLEAR_HIGHLIGHTS") {
|
| 222 |
+
clearHighlights();
|
| 223 |
+
lastScannedText = "";
|
| 224 |
+
sendResponse({ done: true });
|
| 225 |
+
}
|
| 226 |
+
if (msg.type === "GET_PAGE_TEXT") {
|
| 227 |
+
sendResponse({ text: extractPageText(), url: window.location.href });
|
| 228 |
+
}
|
| 229 |
+
});
|
| 230 |
+
})();
|
extension/manifest.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"manifest_version": 3,
|
| 3 |
+
"name": "ClauseGuard — AI Fine Print Scanner",
|
| 4 |
+
"version": "1.0.0",
|
| 5 |
+
"description": "Instantly highlights unfair clauses in Terms of Service, contracts, and lease agreements. Powered by Legal AI.",
|
| 6 |
+
"permissions": [
|
| 7 |
+
"activeTab",
|
| 8 |
+
"storage",
|
| 9 |
+
"sidePanel",
|
| 10 |
+
"scripting"
|
| 11 |
+
],
|
| 12 |
+
"host_permissions": [
|
| 13 |
+
"https://api.clauseguard.com/*"
|
| 14 |
+
],
|
| 15 |
+
"background": {
|
| 16 |
+
"service_worker": "background.js",
|
| 17 |
+
"type": "module"
|
| 18 |
+
},
|
| 19 |
+
"action": {
|
| 20 |
+
"default_popup": "popup.html",
|
| 21 |
+
"default_icon": {
|
| 22 |
+
"16": "icons/icon16.png",
|
| 23 |
+
"32": "icons/icon32.png",
|
| 24 |
+
"48": "icons/icon48.png",
|
| 25 |
+
"128": "icons/icon128.png"
|
| 26 |
+
}
|
| 27 |
+
},
|
| 28 |
+
"side_panel": {
|
| 29 |
+
"default_path": "sidepanel.html"
|
| 30 |
+
},
|
| 31 |
+
"content_scripts": [
|
| 32 |
+
{
|
| 33 |
+
"matches": ["<all_urls>"],
|
| 34 |
+
"js": ["content.js"],
|
| 35 |
+
"css": ["styles/content.css"],
|
| 36 |
+
"run_at": "document_idle"
|
| 37 |
+
}
|
| 38 |
+
],
|
| 39 |
+
"externally_connectable": {
|
| 40 |
+
"matches": ["https://app.clauseguard.com/*", "https://clauseguard.com/*"]
|
| 41 |
+
},
|
| 42 |
+
"icons": {
|
| 43 |
+
"16": "icons/icon16.png",
|
| 44 |
+
"32": "icons/icon32.png",
|
| 45 |
+
"48": "icons/icon48.png",
|
| 46 |
+
"128": "icons/icon128.png"
|
| 47 |
+
},
|
| 48 |
+
"content_security_policy": {
|
| 49 |
+
"extension_pages": "script-src 'self'; object-src 'self'"
|
| 50 |
+
}
|
| 51 |
+
}
|
extension/popup.html
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>ClauseGuard</title>
|
| 7 |
+
<style>
|
| 8 |
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 9 |
+
body {
|
| 10 |
+
width: 360px;
|
| 11 |
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
| 12 |
+
background: #fafafa;
|
| 13 |
+
color: #1f2937;
|
| 14 |
+
}
|
| 15 |
+
.header {
|
| 16 |
+
background: linear-gradient(135deg, #1e1b4b, #312e81);
|
| 17 |
+
color: white;
|
| 18 |
+
padding: 20px;
|
| 19 |
+
text-align: center;
|
| 20 |
+
}
|
| 21 |
+
.header h1 { font-size: 20px; font-weight: 700; }
|
| 22 |
+
.header p { font-size: 12px; opacity: 0.8; margin-top: 4px; }
|
| 23 |
+
|
| 24 |
+
.status { padding: 16px 20px; }
|
| 25 |
+
.status-card {
|
| 26 |
+
background: white;
|
| 27 |
+
border: 1px solid #e5e7eb;
|
| 28 |
+
border-radius: 12px;
|
| 29 |
+
padding: 16px;
|
| 30 |
+
text-align: center;
|
| 31 |
+
}
|
| 32 |
+
.risk-score {
|
| 33 |
+
font-size: 48px;
|
| 34 |
+
font-weight: 800;
|
| 35 |
+
line-height: 1;
|
| 36 |
+
}
|
| 37 |
+
.risk-label { font-size: 12px; color: #6b7280; margin-top: 4px; }
|
| 38 |
+
.grade {
|
| 39 |
+
display: inline-flex;
|
| 40 |
+
align-items: center;
|
| 41 |
+
gap: 6px;
|
| 42 |
+
margin-top: 10px;
|
| 43 |
+
padding: 6px 14px;
|
| 44 |
+
border-radius: 999px;
|
| 45 |
+
font-weight: 600;
|
| 46 |
+
font-size: 14px;
|
| 47 |
+
}
|
| 48 |
+
.grade-f { background: #fef2f2; color: #dc2626; }
|
| 49 |
+
.grade-d { background: #fff7ed; color: #ea580c; }
|
| 50 |
+
.grade-c { background: #fefce8; color: #ca8a04; }
|
| 51 |
+
.grade-b { background: #f0fdf4; color: #16a34a; }
|
| 52 |
+
.grade-a { background: #f0fdf4; color: #16a34a; }
|
| 53 |
+
|
| 54 |
+
.counts {
|
| 55 |
+
display: grid;
|
| 56 |
+
grid-template-columns: repeat(3, 1fr);
|
| 57 |
+
gap: 8px;
|
| 58 |
+
margin-top: 12px;
|
| 59 |
+
}
|
| 60 |
+
.count-box {
|
| 61 |
+
padding: 8px;
|
| 62 |
+
border-radius: 8px;
|
| 63 |
+
text-align: center;
|
| 64 |
+
}
|
| 65 |
+
.count-box .num { font-size: 20px; font-weight: 700; }
|
| 66 |
+
.count-box .label { font-size: 10px; font-weight: 500; }
|
| 67 |
+
.count-high { background: #fef2f2; color: #dc2626; }
|
| 68 |
+
.count-med { background: #fff7ed; color: #ea580c; }
|
| 69 |
+
.count-low { background: #fefce8; color: #ca8a04; }
|
| 70 |
+
|
| 71 |
+
.actions { padding: 0 20px 16px; }
|
| 72 |
+
.btn {
|
| 73 |
+
display: block;
|
| 74 |
+
width: 100%;
|
| 75 |
+
padding: 12px;
|
| 76 |
+
border: none;
|
| 77 |
+
border-radius: 10px;
|
| 78 |
+
font-size: 14px;
|
| 79 |
+
font-weight: 600;
|
| 80 |
+
cursor: pointer;
|
| 81 |
+
margin-bottom: 8px;
|
| 82 |
+
transition: opacity 0.15s;
|
| 83 |
+
}
|
| 84 |
+
.btn:hover { opacity: 0.9; }
|
| 85 |
+
.btn-primary { background: #4f46e5; color: white; }
|
| 86 |
+
.btn-secondary { background: #f3f4f6; color: #374151; }
|
| 87 |
+
.btn-danger { background: #fef2f2; color: #dc2626; }
|
| 88 |
+
|
| 89 |
+
.usage {
|
| 90 |
+
padding: 12px 20px;
|
| 91 |
+
border-top: 1px solid #e5e7eb;
|
| 92 |
+
font-size: 12px;
|
| 93 |
+
color: #6b7280;
|
| 94 |
+
display: flex;
|
| 95 |
+
justify-content: space-between;
|
| 96 |
+
align-items: center;
|
| 97 |
+
}
|
| 98 |
+
.usage-bar {
|
| 99 |
+
width: 100px;
|
| 100 |
+
height: 4px;
|
| 101 |
+
background: #e5e7eb;
|
| 102 |
+
border-radius: 99px;
|
| 103 |
+
overflow: hidden;
|
| 104 |
+
}
|
| 105 |
+
.usage-bar-fill {
|
| 106 |
+
height: 100%;
|
| 107 |
+
background: #4f46e5;
|
| 108 |
+
border-radius: 99px;
|
| 109 |
+
transition: width 0.3s;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.empty-state {
|
| 113 |
+
padding: 40px 20px;
|
| 114 |
+
text-align: center;
|
| 115 |
+
color: #9ca3af;
|
| 116 |
+
}
|
| 117 |
+
.empty-state .icon { font-size: 40px; }
|
| 118 |
+
.empty-state p { margin-top: 8px; font-size: 13px; }
|
| 119 |
+
|
| 120 |
+
.footer {
|
| 121 |
+
padding: 12px 20px;
|
| 122 |
+
border-top: 1px solid #e5e7eb;
|
| 123 |
+
text-align: center;
|
| 124 |
+
}
|
| 125 |
+
.footer a {
|
| 126 |
+
color: #6366f1;
|
| 127 |
+
text-decoration: none;
|
| 128 |
+
font-size: 12px;
|
| 129 |
+
font-weight: 500;
|
| 130 |
+
}
|
| 131 |
+
.footer a:hover { text-decoration: underline; }
|
| 132 |
+
</style>
|
| 133 |
+
</head>
|
| 134 |
+
<body>
|
| 135 |
+
<div class="header">
|
| 136 |
+
<h1>🛡️ ClauseGuard</h1>
|
| 137 |
+
<p>AI Fine Print Scanner</p>
|
| 138 |
+
</div>
|
| 139 |
+
|
| 140 |
+
<div id="results-view" style="display:none;">
|
| 141 |
+
<div class="status">
|
| 142 |
+
<div class="status-card">
|
| 143 |
+
<div class="risk-score" id="risk-score">—</div>
|
| 144 |
+
<div class="risk-label">RISK SCORE</div>
|
| 145 |
+
<div class="grade" id="grade-badge"></div>
|
| 146 |
+
<div class="counts">
|
| 147 |
+
<div class="count-box count-high">
|
| 148 |
+
<div class="num" id="count-high">0</div>
|
| 149 |
+
<div class="label">🔴 HIGH</div>
|
| 150 |
+
</div>
|
| 151 |
+
<div class="count-box count-med">
|
| 152 |
+
<div class="num" id="count-med">0</div>
|
| 153 |
+
<div class="label">🟠 MEDIUM</div>
|
| 154 |
+
</div>
|
| 155 |
+
<div class="count-box count-low">
|
| 156 |
+
<div class="num" id="count-low">0</div>
|
| 157 |
+
<div class="label">🟡 LOW</div>
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
<div class="actions">
|
| 163 |
+
<button class="btn btn-primary" id="btn-details">📋 View Full Details</button>
|
| 164 |
+
<button class="btn btn-secondary" id="btn-rescan">🔄 Re-scan Page</button>
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
|
| 168 |
+
<div id="empty-view">
|
| 169 |
+
<div class="empty-state">
|
| 170 |
+
<div class="icon">🔍</div>
|
| 171 |
+
<p>No scan results for this page yet.</p>
|
| 172 |
+
</div>
|
| 173 |
+
<div class="actions">
|
| 174 |
+
<button class="btn btn-primary" id="btn-scan">🔍 Scan This Page</button>
|
| 175 |
+
</div>
|
| 176 |
+
</div>
|
| 177 |
+
|
| 178 |
+
<div class="usage">
|
| 179 |
+
<span id="usage-text">Free: 0/10 scans</span>
|
| 180 |
+
<div class="usage-bar"><div class="usage-bar-fill" id="usage-fill" style="width:0%"></div></div>
|
| 181 |
+
</div>
|
| 182 |
+
|
| 183 |
+
<div class="footer">
|
| 184 |
+
<a href="https://clauseguard.com" target="_blank">clauseguard.com</a> ·
|
| 185 |
+
<a href="https://app.clauseguard.com/pricing" target="_blank">Upgrade to Pro</a>
|
| 186 |
+
</div>
|
| 187 |
+
|
| 188 |
+
<script src="popup.js"></script>
|
| 189 |
+
</body>
|
| 190 |
+
</html>
|
extension/popup.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ClauseGuard — Popup Script
|
| 3 |
+
* Shows scan summary, usage, and quick actions
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
document.addEventListener("DOMContentLoaded", async () => {
|
| 7 |
+
const resultsView = document.getElementById("results-view");
|
| 8 |
+
const emptyView = document.getElementById("empty-view");
|
| 9 |
+
|
| 10 |
+
// Get current tab
|
| 11 |
+
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
| 12 |
+
if (!tab?.id) return;
|
| 13 |
+
|
| 14 |
+
// Load stored results for this tab
|
| 15 |
+
const results = await chrome.runtime.sendMessage({ type: "GET_RESULTS", tabId: tab.id });
|
| 16 |
+
|
| 17 |
+
if (results && results.risk_score !== undefined) {
|
| 18 |
+
showResults(results);
|
| 19 |
+
} else {
|
| 20 |
+
emptyView.style.display = "block";
|
| 21 |
+
resultsView.style.display = "none";
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
// Load usage
|
| 25 |
+
const usage = await chrome.runtime.sendMessage({ type: "CHECK_USAGE" });
|
| 26 |
+
updateUsage(usage);
|
| 27 |
+
|
| 28 |
+
// ─── Buttons ───
|
| 29 |
+
document.getElementById("btn-scan")?.addEventListener("click", async () => {
|
| 30 |
+
const btn = document.getElementById("btn-scan");
|
| 31 |
+
btn.textContent = "⏳ Scanning...";
|
| 32 |
+
btn.disabled = true;
|
| 33 |
+
|
| 34 |
+
try {
|
| 35 |
+
await chrome.tabs.sendMessage(tab.id, { type: "TRIGGER_SCAN" });
|
| 36 |
+
// Wait a moment then reload results
|
| 37 |
+
setTimeout(async () => {
|
| 38 |
+
const newResults = await chrome.runtime.sendMessage({ type: "GET_RESULTS", tabId: tab.id });
|
| 39 |
+
if (newResults) showResults(newResults);
|
| 40 |
+
btn.textContent = "🔍 Scan This Page";
|
| 41 |
+
btn.disabled = false;
|
| 42 |
+
}, 2000);
|
| 43 |
+
} catch {
|
| 44 |
+
btn.textContent = "❌ Error — try refreshing the page";
|
| 45 |
+
btn.disabled = false;
|
| 46 |
+
}
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
document.getElementById("btn-rescan")?.addEventListener("click", async () => {
|
| 50 |
+
await chrome.tabs.sendMessage(tab.id, { type: "TRIGGER_SCAN" });
|
| 51 |
+
window.close();
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
document.getElementById("btn-details")?.addEventListener("click", () => {
|
| 55 |
+
chrome.sidePanel.open({ tabId: tab.id });
|
| 56 |
+
window.close();
|
| 57 |
+
});
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
function showResults(results) {
|
| 61 |
+
document.getElementById("results-view").style.display = "block";
|
| 62 |
+
document.getElementById("empty-view").style.display = "none";
|
| 63 |
+
|
| 64 |
+
document.getElementById("risk-score").textContent = results.risk_score;
|
| 65 |
+
|
| 66 |
+
const grade = results.grade || "?";
|
| 67 |
+
const badge = document.getElementById("grade-badge");
|
| 68 |
+
const gradeMap = {
|
| 69 |
+
F: { class: "grade-f", label: "🔴 F — DANGEROUS" },
|
| 70 |
+
D: { class: "grade-d", label: "🟠 D — RISKY" },
|
| 71 |
+
C: { class: "grade-c", label: "🟡 C — CAUTION" },
|
| 72 |
+
B: { class: "grade-b", label: "🟢 B — MOSTLY FAIR" },
|
| 73 |
+
A: { class: "grade-a", label: "✅ A — FAIR" },
|
| 74 |
+
};
|
| 75 |
+
const g = gradeMap[grade] || gradeMap["C"];
|
| 76 |
+
badge.className = `grade ${g.class}`;
|
| 77 |
+
badge.textContent = g.label;
|
| 78 |
+
|
| 79 |
+
// Count severities
|
| 80 |
+
const counts = { HIGH: 0, MEDIUM: 0, LOW: 0 };
|
| 81 |
+
(results.results || []).forEach(r => {
|
| 82 |
+
(r.categories || []).forEach(c => {
|
| 83 |
+
if (counts[c.severity] !== undefined) counts[c.severity]++;
|
| 84 |
+
});
|
| 85 |
+
});
|
| 86 |
+
|
| 87 |
+
document.getElementById("count-high").textContent = counts.HIGH;
|
| 88 |
+
document.getElementById("count-med").textContent = counts.MEDIUM;
|
| 89 |
+
document.getElementById("count-low").textContent = counts.LOW;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
function updateUsage(usage) {
|
| 93 |
+
if (!usage) return;
|
| 94 |
+
const text = document.getElementById("usage-text");
|
| 95 |
+
const fill = document.getElementById("usage-fill");
|
| 96 |
+
|
| 97 |
+
if (usage.plan === "free") {
|
| 98 |
+
text.textContent = `Free: ${usage.used}/${usage.limit} scans`;
|
| 99 |
+
fill.style.width = `${Math.min(100, (usage.used / usage.limit) * 100)}%`;
|
| 100 |
+
if (usage.used >= usage.limit) fill.style.background = "#ef4444";
|
| 101 |
+
} else {
|
| 102 |
+
text.textContent = `${usage.plan.toUpperCase()} plan — unlimited`;
|
| 103 |
+
fill.style.width = "100%";
|
| 104 |
+
fill.style.background = "#22c55e";
|
| 105 |
+
}
|
| 106 |
+
}
|
extension/sidepanel.html
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>ClauseGuard — Analysis</title>
|
| 7 |
+
<style>
|
| 8 |
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 9 |
+
body {
|
| 10 |
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
| 11 |
+
background: #f8fafc;
|
| 12 |
+
color: #1e293b;
|
| 13 |
+
font-size: 14px;
|
| 14 |
+
line-height: 1.5;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
.header {
|
| 18 |
+
background: linear-gradient(135deg, #1e1b4b, #312e81);
|
| 19 |
+
color: white;
|
| 20 |
+
padding: 20px;
|
| 21 |
+
position: sticky;
|
| 22 |
+
top: 0;
|
| 23 |
+
z-index: 10;
|
| 24 |
+
}
|
| 25 |
+
.header h1 { font-size: 18px; font-weight: 700; }
|
| 26 |
+
.header-meta { font-size: 12px; opacity: 0.7; margin-top: 4px; }
|
| 27 |
+
|
| 28 |
+
.summary {
|
| 29 |
+
display: grid;
|
| 30 |
+
grid-template-columns: 1fr 1fr 1fr;
|
| 31 |
+
gap: 8px;
|
| 32 |
+
padding: 16px;
|
| 33 |
+
}
|
| 34 |
+
.summary-box {
|
| 35 |
+
padding: 12px;
|
| 36 |
+
border-radius: 10px;
|
| 37 |
+
text-align: center;
|
| 38 |
+
}
|
| 39 |
+
.summary-box .num { font-size: 24px; font-weight: 700; }
|
| 40 |
+
.summary-box .label { font-size: 11px; font-weight: 500; margin-top: 2px; }
|
| 41 |
+
.sum-high { background: #fef2f2; color: #dc2626; }
|
| 42 |
+
.sum-med { background: #fff7ed; color: #ea580c; }
|
| 43 |
+
.sum-low { background: #fefce8; color: #ca8a04; }
|
| 44 |
+
|
| 45 |
+
.clause-list { padding: 0 16px 16px; }
|
| 46 |
+
.clause-card {
|
| 47 |
+
background: white;
|
| 48 |
+
border: 1px solid #e2e8f0;
|
| 49 |
+
border-radius: 12px;
|
| 50 |
+
padding: 14px;
|
| 51 |
+
margin-bottom: 10px;
|
| 52 |
+
transition: box-shadow 0.2s;
|
| 53 |
+
}
|
| 54 |
+
.clause-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
| 55 |
+
.clause-card.severity-high { border-left: 4px solid #ef4444; }
|
| 56 |
+
.clause-card.severity-medium { border-left: 4px solid #f59e0b; }
|
| 57 |
+
.clause-card.severity-low { border-left: 4px solid #3b82f6; }
|
| 58 |
+
|
| 59 |
+
.clause-num { font-size: 11px; color: #94a3b8; font-weight: 600; text-transform: uppercase; }
|
| 60 |
+
.clause-text {
|
| 61 |
+
font-size: 13px;
|
| 62 |
+
color: #334155;
|
| 63 |
+
margin: 6px 0;
|
| 64 |
+
font-style: italic;
|
| 65 |
+
line-height: 1.6;
|
| 66 |
+
}
|
| 67 |
+
.clause-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
| 68 |
+
.tag {
|
| 69 |
+
font-size: 11px;
|
| 70 |
+
font-weight: 600;
|
| 71 |
+
padding: 3px 10px;
|
| 72 |
+
border-radius: 999px;
|
| 73 |
+
}
|
| 74 |
+
.tag-high { background: #fecaca; color: #991b1b; }
|
| 75 |
+
.tag-medium { background: #fed7aa; color: #9a3412; }
|
| 76 |
+
.tag-low { background: #dbeafe; color: #1e40af; }
|
| 77 |
+
|
| 78 |
+
.clause-desc {
|
| 79 |
+
font-size: 12px;
|
| 80 |
+
color: #64748b;
|
| 81 |
+
margin-top: 6px;
|
| 82 |
+
padding: 8px 10px;
|
| 83 |
+
background: #f8fafc;
|
| 84 |
+
border-radius: 8px;
|
| 85 |
+
border-left: 3px solid #cbd5e1;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.empty {
|
| 89 |
+
text-align: center;
|
| 90 |
+
padding: 60px 20px;
|
| 91 |
+
color: #94a3b8;
|
| 92 |
+
}
|
| 93 |
+
.empty .icon { font-size: 48px; margin-bottom: 12px; }
|
| 94 |
+
|
| 95 |
+
.filter-bar {
|
| 96 |
+
display: flex;
|
| 97 |
+
gap: 6px;
|
| 98 |
+
padding: 12px 16px;
|
| 99 |
+
overflow-x: auto;
|
| 100 |
+
}
|
| 101 |
+
.filter-btn {
|
| 102 |
+
padding: 6px 12px;
|
| 103 |
+
border: 1px solid #e2e8f0;
|
| 104 |
+
border-radius: 999px;
|
| 105 |
+
background: white;
|
| 106 |
+
font-size: 12px;
|
| 107 |
+
cursor: pointer;
|
| 108 |
+
white-space: nowrap;
|
| 109 |
+
font-weight: 500;
|
| 110 |
+
transition: all 0.15s;
|
| 111 |
+
}
|
| 112 |
+
.filter-btn:hover { background: #f1f5f9; }
|
| 113 |
+
.filter-btn.active { background: #4f46e5; color: white; border-color: #4f46e5; }
|
| 114 |
+
</style>
|
| 115 |
+
</head>
|
| 116 |
+
<body>
|
| 117 |
+
<div class="header">
|
| 118 |
+
<h1>🛡️ ClauseGuard Analysis</h1>
|
| 119 |
+
<div class="header-meta" id="meta"></div>
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
<div class="summary" id="summary" style="display:none;">
|
| 123 |
+
<div class="summary-box sum-high">
|
| 124 |
+
<div class="num" id="s-high">0</div>
|
| 125 |
+
<div class="label">🔴 High Risk</div>
|
| 126 |
+
</div>
|
| 127 |
+
<div class="summary-box sum-med">
|
| 128 |
+
<div class="num" id="s-med">0</div>
|
| 129 |
+
<div class="label">🟠 Medium</div>
|
| 130 |
+
</div>
|
| 131 |
+
<div class="summary-box sum-low">
|
| 132 |
+
<div class="num" id="s-low">0</div>
|
| 133 |
+
<div class="label">🟡 Low</div>
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
|
| 137 |
+
<div class="filter-bar" id="filters" style="display:none;">
|
| 138 |
+
<button class="filter-btn active" data-filter="all">All</button>
|
| 139 |
+
<button class="filter-btn" data-filter="HIGH">🔴 High</button>
|
| 140 |
+
<button class="filter-btn" data-filter="MEDIUM">🟠 Medium</button>
|
| 141 |
+
<button class="filter-btn" data-filter="LOW">🟡 Low</button>
|
| 142 |
+
</div>
|
| 143 |
+
|
| 144 |
+
<div class="clause-list" id="clause-list"></div>
|
| 145 |
+
|
| 146 |
+
<div class="empty" id="empty-state">
|
| 147 |
+
<div class="icon">🔍</div>
|
| 148 |
+
<p>Scan a page to see clause analysis here.</p>
|
| 149 |
+
</div>
|
| 150 |
+
|
| 151 |
+
<script src="sidepanel.js"></script>
|
| 152 |
+
</body>
|
| 153 |
+
</html>
|
extension/sidepanel.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ClauseGuard — Side Panel Script
|
| 3 |
+
* Displays detailed clause analysis with filtering
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
const CATEGORY_DESCRIPTIONS = {
|
| 7 |
+
"Limitation of liability": "Company limits or excludes liability for losses, data breaches, or service failures.",
|
| 8 |
+
"Unilateral termination": "Company can terminate your account at any time without reason.",
|
| 9 |
+
"Unilateral change": "Company can change terms at any time without your consent.",
|
| 10 |
+
"Content removal": "Company can delete your content without notice or justification.",
|
| 11 |
+
"Contract by using": "You're bound to the contract simply by using the service — a dark pattern.",
|
| 12 |
+
"Choice of law": "Governing law may differ from your country — reducing your legal protections.",
|
| 13 |
+
"Jurisdiction": "Disputes must be resolved in a jurisdiction that may disadvantage you.",
|
| 14 |
+
"Arbitration": "Forces disputes to arbitration instead of court — you waive your right to sue.",
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
let allClauses = [];
|
| 18 |
+
let currentFilter = "all";
|
| 19 |
+
|
| 20 |
+
async function loadResults() {
|
| 21 |
+
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
| 22 |
+
if (!tab?.id) return;
|
| 23 |
+
|
| 24 |
+
const results = await chrome.runtime.sendMessage({ type: "GET_RESULTS", tabId: tab.id });
|
| 25 |
+
if (!results || !results.results) {
|
| 26 |
+
document.getElementById("empty-state").style.display = "block";
|
| 27 |
+
return;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
document.getElementById("empty-state").style.display = "none";
|
| 31 |
+
document.getElementById("summary").style.display = "grid";
|
| 32 |
+
document.getElementById("filters").style.display = "flex";
|
| 33 |
+
|
| 34 |
+
document.getElementById("meta").textContent =
|
| 35 |
+
`${results.total_clauses} clauses scanned · ${results.flagged_count} flagged · Risk: ${results.risk_score}/100`;
|
| 36 |
+
|
| 37 |
+
// Count severities
|
| 38 |
+
const counts = { HIGH: 0, MEDIUM: 0, LOW: 0 };
|
| 39 |
+
const flagged = results.results.filter(r => r.categories?.length > 0);
|
| 40 |
+
flagged.forEach(r => r.categories.forEach(c => counts[c.severity]++));
|
| 41 |
+
|
| 42 |
+
document.getElementById("s-high").textContent = counts.HIGH;
|
| 43 |
+
document.getElementById("s-med").textContent = counts.MEDIUM;
|
| 44 |
+
document.getElementById("s-low").textContent = counts.LOW;
|
| 45 |
+
|
| 46 |
+
allClauses = flagged;
|
| 47 |
+
renderClauses(flagged);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function renderClauses(clauses) {
|
| 51 |
+
const list = document.getElementById("clause-list");
|
| 52 |
+
list.innerHTML = "";
|
| 53 |
+
|
| 54 |
+
const filtered = currentFilter === "all"
|
| 55 |
+
? clauses
|
| 56 |
+
: clauses.filter(c => c.categories.some(cat => cat.severity === currentFilter));
|
| 57 |
+
|
| 58 |
+
if (filtered.length === 0) {
|
| 59 |
+
list.innerHTML = '<div style="text-align:center; padding:30px; color:#94a3b8;">No clauses match this filter.</div>';
|
| 60 |
+
return;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
filtered.forEach((clause, i) => {
|
| 64 |
+
const maxSev = getMaxSeverity(clause.categories);
|
| 65 |
+
const card = document.createElement("div");
|
| 66 |
+
card.className = `clause-card severity-${maxSev.toLowerCase()}`;
|
| 67 |
+
card.innerHTML = `
|
| 68 |
+
<div class="clause-num">Clause #${i + 1}</div>
|
| 69 |
+
<div class="clause-text">"${truncate(clause.text, 250)}"</div>
|
| 70 |
+
<div class="clause-tags">
|
| 71 |
+
${clause.categories.map(c => `
|
| 72 |
+
<span class="tag tag-${c.severity.toLowerCase()}">${c.name}</span>
|
| 73 |
+
`).join("")}
|
| 74 |
+
</div>
|
| 75 |
+
${clause.categories.map(c => `
|
| 76 |
+
<div class="clause-desc">
|
| 77 |
+
<strong>${c.name}:</strong> ${CATEGORY_DESCRIPTIONS[c.name] || c.name}
|
| 78 |
+
</div>
|
| 79 |
+
`).join("")}
|
| 80 |
+
`;
|
| 81 |
+
list.appendChild(card);
|
| 82 |
+
});
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function getMaxSeverity(categories) {
|
| 86 |
+
const order = { HIGH: 3, MEDIUM: 2, LOW: 1 };
|
| 87 |
+
return categories.reduce((max, c) => order[c.severity] > order[max] ? c.severity : max, "LOW");
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
function truncate(text, maxLen) {
|
| 91 |
+
return text.length > maxLen ? text.substring(0, maxLen) + "…" : text;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// ─── Filter buttons ───
|
| 95 |
+
document.getElementById("filters").addEventListener("click", (e) => {
|
| 96 |
+
if (!e.target.matches(".filter-btn")) return;
|
| 97 |
+
document.querySelectorAll(".filter-btn").forEach(b => b.classList.remove("active"));
|
| 98 |
+
e.target.classList.add("active");
|
| 99 |
+
currentFilter = e.target.dataset.filter;
|
| 100 |
+
renderClauses(allClauses);
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
// ─── Listen for updates from background ───
|
| 104 |
+
chrome.storage.onChanged.addListener((changes) => {
|
| 105 |
+
for (const key of Object.keys(changes)) {
|
| 106 |
+
if (key.startsWith("results_")) {
|
| 107 |
+
loadResults();
|
| 108 |
+
break;
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
});
|
| 112 |
+
|
| 113 |
+
// ─── Init ───
|
| 114 |
+
loadResults();
|
extension/styles/content.css
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ClauseGuard — Content Script Styles (injected into web pages) */
|
| 2 |
+
|
| 3 |
+
/* Highlight severity levels */
|
| 4 |
+
.clauseguard-highlight {
|
| 5 |
+
cursor: pointer;
|
| 6 |
+
border-radius: 2px;
|
| 7 |
+
padding: 1px 0;
|
| 8 |
+
transition: background-color 0.2s ease;
|
| 9 |
+
position: relative;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
.clauseguard-high {
|
| 13 |
+
background: rgba(239, 68, 68, 0.22);
|
| 14 |
+
border-bottom: 2.5px solid #ef4444;
|
| 15 |
+
}
|
| 16 |
+
.clauseguard-high:hover {
|
| 17 |
+
background: rgba(239, 68, 68, 0.35);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
.clauseguard-medium {
|
| 21 |
+
background: rgba(245, 158, 11, 0.18);
|
| 22 |
+
border-bottom: 2.5px solid #f59e0b;
|
| 23 |
+
}
|
| 24 |
+
.clauseguard-medium:hover {
|
| 25 |
+
background: rgba(245, 158, 11, 0.32);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
.clauseguard-low {
|
| 29 |
+
background: rgba(59, 130, 246, 0.14);
|
| 30 |
+
border-bottom: 2.5px solid #3b82f6;
|
| 31 |
+
}
|
| 32 |
+
.clauseguard-low:hover {
|
| 33 |
+
background: rgba(59, 130, 246, 0.28);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/* Tooltip */
|
| 37 |
+
.clauseguard-tooltip {
|
| 38 |
+
position: absolute;
|
| 39 |
+
z-index: 2147483647;
|
| 40 |
+
background: #1f2937;
|
| 41 |
+
color: #f9fafb;
|
| 42 |
+
padding: 12px 16px;
|
| 43 |
+
border-radius: 10px;
|
| 44 |
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
| 45 |
+
font-size: 13px;
|
| 46 |
+
line-height: 1.5;
|
| 47 |
+
max-width: 340px;
|
| 48 |
+
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
| 49 |
+
pointer-events: none;
|
| 50 |
+
animation: clauseguard-fadeIn 0.15s ease;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.clauseguard-tooltip-header {
|
| 54 |
+
font-weight: 700;
|
| 55 |
+
font-size: 14px;
|
| 56 |
+
margin-bottom: 8px;
|
| 57 |
+
padding-bottom: 6px;
|
| 58 |
+
border-bottom: 1px solid #374151;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.clauseguard-tooltip-item {
|
| 62 |
+
display: flex;
|
| 63 |
+
align-items: center;
|
| 64 |
+
gap: 8px;
|
| 65 |
+
margin: 4px 0;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.clauseguard-tooltip-badge {
|
| 69 |
+
font-size: 10px;
|
| 70 |
+
font-weight: 700;
|
| 71 |
+
padding: 1px 6px;
|
| 72 |
+
border-radius: 4px;
|
| 73 |
+
text-transform: uppercase;
|
| 74 |
+
letter-spacing: 0.5px;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.clauseguard-badge-high {
|
| 78 |
+
background: #fecaca;
|
| 79 |
+
color: #991b1b;
|
| 80 |
+
}
|
| 81 |
+
.clauseguard-badge-medium {
|
| 82 |
+
background: #fed7aa;
|
| 83 |
+
color: #9a3412;
|
| 84 |
+
}
|
| 85 |
+
.clauseguard-badge-low {
|
| 86 |
+
background: #dbeafe;
|
| 87 |
+
color: #1e40af;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.clauseguard-tooltip-footer {
|
| 91 |
+
margin-top: 8px;
|
| 92 |
+
font-size: 11px;
|
| 93 |
+
opacity: 0.6;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
@keyframes clauseguard-fadeIn {
|
| 97 |
+
from { opacity: 0; transform: translateY(-4px); }
|
| 98 |
+
to { opacity: 1; transform: translateY(0); }
|
| 99 |
+
}
|
ml/export_onnx.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ClauseGuard — Export fine-tuned Legal-BERT to ONNX for fast inference.
|
| 3 |
+
Requires: pip install optimum[onnxruntime]
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
|
| 9 |
+
MODEL_PATH = os.environ.get("MODEL_PATH", "./clauseguard-model/final")
|
| 10 |
+
ONNX_OUTPUT = os.environ.get("ONNX_OUTPUT", "./clauseguard-model-onnx")
|
| 11 |
+
|
| 12 |
+
print(f"📦 Exporting {MODEL_PATH} → ONNX at {ONNX_OUTPUT}")
|
| 13 |
+
|
| 14 |
+
try:
|
| 15 |
+
from optimum.onnxruntime import ORTModelForSequenceClassification
|
| 16 |
+
from transformers import AutoTokenizer
|
| 17 |
+
|
| 18 |
+
# Load PyTorch model and export to ONNX
|
| 19 |
+
model = ORTModelForSequenceClassification.from_pretrained(MODEL_PATH, export=True)
|
| 20 |
+
tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH)
|
| 21 |
+
|
| 22 |
+
# Save ONNX model + tokenizer + config
|
| 23 |
+
model.save_pretrained(ONNX_OUTPUT)
|
| 24 |
+
tokenizer.save_pretrained(ONNX_OUTPUT)
|
| 25 |
+
|
| 26 |
+
print(f"✅ ONNX model saved to {ONNX_OUTPUT}")
|
| 27 |
+
print(f" Files: {os.listdir(ONNX_OUTPUT)}")
|
| 28 |
+
|
| 29 |
+
# Verify inference works
|
| 30 |
+
from transformers import pipeline
|
| 31 |
+
classifier = pipeline("text-classification", model=model, tokenizer=tokenizer, top_k=None)
|
| 32 |
+
test = classifier("The company may terminate your account at any time without notice.")
|
| 33 |
+
print(f" Test inference: {test}")
|
| 34 |
+
|
| 35 |
+
except ImportError:
|
| 36 |
+
print("❌ Install optimum: pip install optimum[onnxruntime]")
|
| 37 |
+
sys.exit(1)
|
| 38 |
+
except Exception as e:
|
| 39 |
+
print(f"❌ Export failed: {e}")
|
| 40 |
+
sys.exit(1)
|
ml/requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
transformers>=4.47.0
|
| 2 |
+
datasets>=3.2.0
|
| 3 |
+
torch>=2.5.0
|
| 4 |
+
scikit-learn>=1.6.0
|
| 5 |
+
accelerate>=1.2.0
|
| 6 |
+
optimum[onnxruntime]>=1.24.0
|
| 7 |
+
evaluate>=0.4.0
|
ml/train_classifier.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ClauseGuard — Fine-tune Legal-BERT on CLAUDETTE/LexGLUE unfair_tos
|
| 3 |
+
Multi-label classification (8 unfair clause categories)
|
| 4 |
+
|
| 5 |
+
Compatible with: Transformers 5.6.x, Datasets 4.8.x (April 2026)
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import numpy as np
|
| 10 |
+
import torch
|
| 11 |
+
from datasets import load_dataset
|
| 12 |
+
from sklearn.metrics import f1_score, precision_score, recall_score
|
| 13 |
+
from transformers import (
|
| 14 |
+
AutoConfig,
|
| 15 |
+
AutoModelForSequenceClassification,
|
| 16 |
+
AutoTokenizer,
|
| 17 |
+
DataCollatorWithPadding,
|
| 18 |
+
Trainer,
|
| 19 |
+
TrainingArguments,
|
| 20 |
+
EarlyStoppingCallback,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
# ─── Config ───
|
| 24 |
+
MODEL_NAME = os.environ.get("BASE_MODEL", "nlpaueb/legal-bert-base-uncased")
|
| 25 |
+
OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "./clauseguard-model")
|
| 26 |
+
HUB_MODEL_ID = os.environ.get("HUB_MODEL_ID", "gaurv007/clauseguard-legal-bert")
|
| 27 |
+
PUSH_TO_HUB = os.environ.get("PUSH_TO_HUB", "true").lower() == "true"
|
| 28 |
+
NUM_LABELS = 8
|
| 29 |
+
MAX_LENGTH = 512
|
| 30 |
+
LABEL_NAMES = [
|
| 31 |
+
"Limitation of liability",
|
| 32 |
+
"Unilateral termination",
|
| 33 |
+
"Unilateral change",
|
| 34 |
+
"Content removal",
|
| 35 |
+
"Contract by using",
|
| 36 |
+
"Choice of law",
|
| 37 |
+
"Jurisdiction",
|
| 38 |
+
"Arbitration",
|
| 39 |
+
]
|
| 40 |
+
|
| 41 |
+
print(f"ClauseGuard Model Training")
|
| 42 |
+
print(f" Base model: {MODEL_NAME}")
|
| 43 |
+
print(f" Output: {OUTPUT_DIR}")
|
| 44 |
+
print(f" Push to Hub: {PUSH_TO_HUB} -> {HUB_MODEL_ID}")
|
| 45 |
+
|
| 46 |
+
# ─── 1. Load Dataset ───
|
| 47 |
+
print("Loading coastalcph/lex_glue (unfair_tos)...")
|
| 48 |
+
dataset = load_dataset("coastalcph/lex_glue", "unfair_tos")
|
| 49 |
+
print(f" Train: {len(dataset['train'])} | Val: {len(dataset['validation'])} | Test: {len(dataset['test'])}")
|
| 50 |
+
|
| 51 |
+
# ─── 2. Load Model + Tokenizer ───
|
| 52 |
+
print(f"Loading {MODEL_NAME}...")
|
| 53 |
+
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
|
| 54 |
+
|
| 55 |
+
config = AutoConfig.from_pretrained(
|
| 56 |
+
MODEL_NAME,
|
| 57 |
+
num_labels=NUM_LABELS,
|
| 58 |
+
problem_type="multi_label_classification",
|
| 59 |
+
id2label={str(i): n for i, n in enumerate(LABEL_NAMES)},
|
| 60 |
+
label2id={n: i for i, n in enumerate(LABEL_NAMES)},
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
model = AutoModelForSequenceClassification.from_pretrained(
|
| 64 |
+
MODEL_NAME,
|
| 65 |
+
config=config,
|
| 66 |
+
ignore_mismatched_sizes=True,
|
| 67 |
+
)
|
| 68 |
+
print(f" Parameters: {sum(p.numel() for p in model.parameters()):,}")
|
| 69 |
+
|
| 70 |
+
# ─── 3. Preprocess ───
|
| 71 |
+
def preprocess(examples):
|
| 72 |
+
tokenized = tokenizer(
|
| 73 |
+
examples["text"],
|
| 74 |
+
truncation=True,
|
| 75 |
+
max_length=MAX_LENGTH,
|
| 76 |
+
padding=False,
|
| 77 |
+
)
|
| 78 |
+
batch_labels = []
|
| 79 |
+
for lbls in examples["labels"]:
|
| 80 |
+
vec = [0.0] * NUM_LABELS
|
| 81 |
+
for l in lbls:
|
| 82 |
+
vec[l] = 1.0
|
| 83 |
+
batch_labels.append(vec)
|
| 84 |
+
tokenized["labels"] = batch_labels
|
| 85 |
+
return tokenized
|
| 86 |
+
|
| 87 |
+
print("Tokenizing dataset...")
|
| 88 |
+
tokenized_ds = dataset.map(preprocess, batched=True, remove_columns=["text"])
|
| 89 |
+
tokenized_ds.set_format("torch")
|
| 90 |
+
|
| 91 |
+
# ─── 4. Metrics ───
|
| 92 |
+
def compute_metrics(eval_pred):
|
| 93 |
+
logits, labels = eval_pred.predictions, eval_pred.label_ids
|
| 94 |
+
probs = 1 / (1 + np.exp(-logits))
|
| 95 |
+
preds = (probs > 0.5).astype(int)
|
| 96 |
+
labels = labels.astype(int)
|
| 97 |
+
|
| 98 |
+
micro_f1 = f1_score(labels, preds, average="micro", zero_division=0)
|
| 99 |
+
macro_f1 = f1_score(labels, preds, average="macro", zero_division=0)
|
| 100 |
+
micro_p = precision_score(labels, preds, average="micro", zero_division=0)
|
| 101 |
+
micro_r = recall_score(labels, preds, average="micro", zero_division=0)
|
| 102 |
+
|
| 103 |
+
per_class = f1_score(labels, preds, average=None, zero_division=0)
|
| 104 |
+
class_metrics = {f"f1_{LABEL_NAMES[i][:15]}": float(per_class[i]) for i in range(NUM_LABELS)}
|
| 105 |
+
|
| 106 |
+
return {
|
| 107 |
+
"micro_f1": micro_f1,
|
| 108 |
+
"macro_f1": macro_f1,
|
| 109 |
+
"precision": micro_p,
|
| 110 |
+
"recall": micro_r,
|
| 111 |
+
**class_metrics,
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
# ─── 5. Training ───
|
| 115 |
+
print("Starting training...")
|
| 116 |
+
|
| 117 |
+
training_args = TrainingArguments(
|
| 118 |
+
output_dir=OUTPUT_DIR,
|
| 119 |
+
num_train_epochs=20,
|
| 120 |
+
per_device_train_batch_size=16,
|
| 121 |
+
per_device_eval_batch_size=32,
|
| 122 |
+
learning_rate=3e-5,
|
| 123 |
+
weight_decay=0.01,
|
| 124 |
+
warmup_ratio=0.1,
|
| 125 |
+
eval_strategy="epoch",
|
| 126 |
+
save_strategy="epoch",
|
| 127 |
+
save_total_limit=3,
|
| 128 |
+
load_best_model_at_end=True,
|
| 129 |
+
metric_for_best_model="macro_f1",
|
| 130 |
+
greater_is_better=True,
|
| 131 |
+
fp16=torch.cuda.is_available(),
|
| 132 |
+
bf16=False,
|
| 133 |
+
logging_strategy="steps",
|
| 134 |
+
logging_steps=25,
|
| 135 |
+
logging_first_step=True,
|
| 136 |
+
disable_tqdm=True,
|
| 137 |
+
report_to="none",
|
| 138 |
+
push_to_hub=PUSH_TO_HUB,
|
| 139 |
+
hub_model_id=HUB_MODEL_ID if PUSH_TO_HUB else None,
|
| 140 |
+
seed=42,
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
trainer = Trainer(
|
| 144 |
+
model=model,
|
| 145 |
+
args=training_args,
|
| 146 |
+
train_dataset=tokenized_ds["train"],
|
| 147 |
+
eval_dataset=tokenized_ds["validation"],
|
| 148 |
+
processing_class=tokenizer,
|
| 149 |
+
data_collator=DataCollatorWithPadding(tokenizer=tokenizer),
|
| 150 |
+
compute_metrics=compute_metrics,
|
| 151 |
+
callbacks=[EarlyStoppingCallback(early_stopping_patience=3)],
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
train_result = trainer.train()
|
| 155 |
+
print(f"Training complete! Loss: {train_result.training_loss:.4f}")
|
| 156 |
+
|
| 157 |
+
# ─── 6. Evaluate ───
|
| 158 |
+
print("Evaluating on test set...")
|
| 159 |
+
test_results = trainer.evaluate(tokenized_ds["test"])
|
| 160 |
+
print(f" Test micro-F1: {test_results.get('eval_micro_f1', 0):.4f}")
|
| 161 |
+
print(f" Test macro-F1: {test_results.get('eval_macro_f1', 0):.4f}")
|
| 162 |
+
print(f" Test precision: {test_results.get('eval_precision', 0):.4f}")
|
| 163 |
+
print(f" Test recall: {test_results.get('eval_recall', 0):.4f}")
|
| 164 |
+
|
| 165 |
+
# ─── 7. Save ───
|
| 166 |
+
final_dir = f"{OUTPUT_DIR}/final"
|
| 167 |
+
trainer.save_model(final_dir)
|
| 168 |
+
tokenizer.save_pretrained(final_dir)
|
| 169 |
+
print(f"Model saved to {final_dir}")
|
| 170 |
+
|
| 171 |
+
if PUSH_TO_HUB:
|
| 172 |
+
print(f"Pushing to Hub: {HUB_MODEL_ID}")
|
| 173 |
+
trainer.push_to_hub(commit_message="ClauseGuard Legal-BERT fine-tuned on unfair_tos")
|
| 174 |
+
print("Pushed successfully!")
|
| 175 |
+
|
| 176 |
+
print("Done!")
|
web/.env.example
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Supabase
|
| 2 |
+
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
| 3 |
+
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
| 4 |
+
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
| 5 |
+
|
| 6 |
+
# Stripe
|
| 7 |
+
STRIPE_SECRET_KEY=sk_test_...
|
| 8 |
+
STRIPE_WEBHOOK_SECRET=whsec_...
|
| 9 |
+
STRIPE_PRO_PRICE_ID=price_...
|
| 10 |
+
STRIPE_TEAM_PRICE_ID=price_...
|
| 11 |
+
|
| 12 |
+
# App
|
| 13 |
+
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
| 14 |
+
CLAUSEGUARD_API_URL=http://localhost:8000
|
web/app/api/analyze/route.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
|
| 3 |
+
const API_URL = process.env.CLAUSEGUARD_API_URL || "http://localhost:8000";
|
| 4 |
+
|
| 5 |
+
export async function POST(req: NextRequest) {
|
| 6 |
+
try {
|
| 7 |
+
const body = await req.json();
|
| 8 |
+
const { text, source_url } = body;
|
| 9 |
+
|
| 10 |
+
if (!text || typeof text !== "string" || text.trim().length < 50) {
|
| 11 |
+
return NextResponse.json(
|
| 12 |
+
{ error: "Please provide at least 50 characters of text to analyze." },
|
| 13 |
+
{ status: 400 }
|
| 14 |
+
);
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
// Split text into clauses
|
| 18 |
+
const clauses = text
|
| 19 |
+
.replace(/\n{2,}/g, "\n")
|
| 20 |
+
.trim()
|
| 21 |
+
.split(/(?<=[.!?])\s+(?=[A-Z0-9(])|(?:\n)(?=\d+[.)]\s|\([a-z]\)\s|•\s|-\s)/)
|
| 22 |
+
.map((c: string) => c.trim())
|
| 23 |
+
.filter((c: string) => c.length > 30);
|
| 24 |
+
|
| 25 |
+
if (clauses.length === 0) {
|
| 26 |
+
return NextResponse.json(
|
| 27 |
+
{ error: "Could not extract clauses from the provided text." },
|
| 28 |
+
{ status: 400 }
|
| 29 |
+
);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
// Forward to backend API
|
| 33 |
+
const response = await fetch(`${API_URL}/api/analyze`, {
|
| 34 |
+
method: "POST",
|
| 35 |
+
headers: { "Content-Type": "application/json" },
|
| 36 |
+
body: JSON.stringify({ clauses, source_url }),
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
if (!response.ok) {
|
| 40 |
+
throw new Error(`Backend API error: ${response.status}`);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const results = await response.json();
|
| 44 |
+
return NextResponse.json(results);
|
| 45 |
+
} catch (error) {
|
| 46 |
+
console.error("Analyze error:", error);
|
| 47 |
+
return NextResponse.json(
|
| 48 |
+
{ error: "Analysis failed. Please try again." },
|
| 49 |
+
{ status: 500 }
|
| 50 |
+
);
|
| 51 |
+
}
|
| 52 |
+
}
|
web/app/api/stripe/checkout/route.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { stripe, PLANS } from "@/lib/stripe";
|
| 3 |
+
import { createClient } from "@/lib/supabase/server";
|
| 4 |
+
|
| 5 |
+
export async function POST(req: NextRequest) {
|
| 6 |
+
try {
|
| 7 |
+
const supabase = await createClient();
|
| 8 |
+
const { data: { user } } = await supabase.auth.getUser();
|
| 9 |
+
|
| 10 |
+
if (!user) {
|
| 11 |
+
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const { plan } = await req.json();
|
| 15 |
+
|
| 16 |
+
if (plan !== "pro" && plan !== "team") {
|
| 17 |
+
return NextResponse.json({ error: "Invalid plan" }, { status: 400 });
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const priceId = PLANS[plan].price_id;
|
| 21 |
+
if (!priceId) {
|
| 22 |
+
return NextResponse.json({ error: "Price not configured" }, { status: 500 });
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
// Get or create Stripe customer
|
| 26 |
+
const { data: profile } = await supabase
|
| 27 |
+
.from("profiles")
|
| 28 |
+
.select("stripe_customer_id")
|
| 29 |
+
.eq("id", user.id)
|
| 30 |
+
.single();
|
| 31 |
+
|
| 32 |
+
let customerId = profile?.stripe_customer_id;
|
| 33 |
+
|
| 34 |
+
if (!customerId) {
|
| 35 |
+
const customer = await stripe.customers.create({
|
| 36 |
+
email: user.email,
|
| 37 |
+
metadata: { supabase_user_id: user.id },
|
| 38 |
+
});
|
| 39 |
+
customerId = customer.id;
|
| 40 |
+
|
| 41 |
+
await supabase
|
| 42 |
+
.from("profiles")
|
| 43 |
+
.update({ stripe_customer_id: customerId })
|
| 44 |
+
.eq("id", user.id);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
// Create checkout session
|
| 48 |
+
const session = await stripe.checkout.sessions.create({
|
| 49 |
+
customer: customerId,
|
| 50 |
+
mode: "subscription",
|
| 51 |
+
payment_method_types: ["card"],
|
| 52 |
+
line_items: [{ price: priceId, quantity: 1 }],
|
| 53 |
+
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard-pages/dashboard?session_id={CHECKOUT_SESSION_ID}`,
|
| 54 |
+
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
|
| 55 |
+
subscription_data: {
|
| 56 |
+
metadata: { supabase_user_id: user.id, plan },
|
| 57 |
+
},
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
return NextResponse.json({ url: session.url });
|
| 61 |
+
} catch (error) {
|
| 62 |
+
console.error("Checkout error:", error);
|
| 63 |
+
return NextResponse.json({ error: "Checkout failed" }, { status: 500 });
|
| 64 |
+
}
|
| 65 |
+
}
|
web/app/api/stripe/webhook/route.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { stripe } from "@/lib/stripe";
|
| 3 |
+
import { createClient } from "@supabase/supabase-js";
|
| 4 |
+
|
| 5 |
+
// Use service role for webhook (no user context)
|
| 6 |
+
const supabase = createClient(
|
| 7 |
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
| 8 |
+
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
| 9 |
+
);
|
| 10 |
+
|
| 11 |
+
export async function POST(req: NextRequest) {
|
| 12 |
+
const body = await req.text();
|
| 13 |
+
const sig = req.headers.get("stripe-signature")!;
|
| 14 |
+
|
| 15 |
+
let event;
|
| 16 |
+
try {
|
| 17 |
+
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
|
| 18 |
+
} catch (err) {
|
| 19 |
+
console.error("Webhook signature verification failed:", err);
|
| 20 |
+
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
switch (event.type) {
|
| 24 |
+
case "customer.subscription.created":
|
| 25 |
+
case "customer.subscription.updated": {
|
| 26 |
+
const subscription = event.data.object as any;
|
| 27 |
+
const customerId = subscription.customer as string;
|
| 28 |
+
const plan = subscription.metadata?.plan || "pro";
|
| 29 |
+
const status = subscription.status;
|
| 30 |
+
|
| 31 |
+
if (status === "active" || status === "trialing") {
|
| 32 |
+
await supabase
|
| 33 |
+
.from("profiles")
|
| 34 |
+
.update({
|
| 35 |
+
plan,
|
| 36 |
+
stripe_subscription_id: subscription.id,
|
| 37 |
+
updated_at: new Date().toISOString(),
|
| 38 |
+
})
|
| 39 |
+
.eq("stripe_customer_id", customerId);
|
| 40 |
+
}
|
| 41 |
+
break;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
case "customer.subscription.deleted": {
|
| 45 |
+
const subscription = event.data.object as any;
|
| 46 |
+
const customerId = subscription.customer as string;
|
| 47 |
+
|
| 48 |
+
await supabase
|
| 49 |
+
.from("profiles")
|
| 50 |
+
.update({
|
| 51 |
+
plan: "free",
|
| 52 |
+
stripe_subscription_id: null,
|
| 53 |
+
updated_at: new Date().toISOString(),
|
| 54 |
+
})
|
| 55 |
+
.eq("stripe_customer_id", customerId);
|
| 56 |
+
break;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
case "invoice.payment_failed": {
|
| 60 |
+
const invoice = event.data.object as any;
|
| 61 |
+
const customerId = invoice.customer as string;
|
| 62 |
+
console.warn(`Payment failed for customer ${customerId}`);
|
| 63 |
+
// Could send email notification here
|
| 64 |
+
break;
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
return NextResponse.json({ received: true });
|
| 69 |
+
}
|
web/app/auth/login/page.tsx
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { createClient } from "@/lib/supabase/client";
|
| 5 |
+
import Link from "next/link";
|
| 6 |
+
|
| 7 |
+
export default function LoginPage() {
|
| 8 |
+
const [email, setEmail] = useState("");
|
| 9 |
+
const [password, setPassword] = useState("");
|
| 10 |
+
const [error, setError] = useState("");
|
| 11 |
+
const [loading, setLoading] = useState(false);
|
| 12 |
+
|
| 13 |
+
const supabase = createClient();
|
| 14 |
+
|
| 15 |
+
async function handleLogin(e: React.FormEvent) {
|
| 16 |
+
e.preventDefault();
|
| 17 |
+
setLoading(true);
|
| 18 |
+
setError("");
|
| 19 |
+
|
| 20 |
+
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
| 21 |
+
|
| 22 |
+
if (error) {
|
| 23 |
+
setError(error.message);
|
| 24 |
+
setLoading(false);
|
| 25 |
+
} else {
|
| 26 |
+
window.location.href = "/dashboard-pages/dashboard";
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
async function handleOAuth(provider: "google" | "github") {
|
| 31 |
+
await supabase.auth.signInWithOAuth({
|
| 32 |
+
provider,
|
| 33 |
+
options: { redirectTo: `${window.location.origin}/auth/callback` },
|
| 34 |
+
});
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
return (
|
| 38 |
+
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
| 39 |
+
<div className="w-full max-w-md">
|
| 40 |
+
<div className="text-center mb-8">
|
| 41 |
+
<Link href="/" className="inline-flex items-center gap-2 text-2xl font-bold text-gray-900">
|
| 42 |
+
<span>🛡️</span> ClauseGuard
|
| 43 |
+
</Link>
|
| 44 |
+
<p className="mt-2 text-gray-600">Welcome back</p>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
|
| 48 |
+
{/* OAuth */}
|
| 49 |
+
<div className="space-y-3">
|
| 50 |
+
<button
|
| 51 |
+
onClick={() => handleOAuth("google")}
|
| 52 |
+
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 rounded-xl hover:bg-gray-50 transition font-medium text-sm"
|
| 53 |
+
>
|
| 54 |
+
<svg className="w-5 h-5" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
|
| 55 |
+
Continue with Google
|
| 56 |
+
</button>
|
| 57 |
+
<button
|
| 58 |
+
onClick={() => handleOAuth("github")}
|
| 59 |
+
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 rounded-xl hover:bg-gray-50 transition font-medium text-sm"
|
| 60 |
+
>
|
| 61 |
+
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
|
| 62 |
+
Continue with GitHub
|
| 63 |
+
</button>
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
<div className="flex items-center gap-4 my-6">
|
| 67 |
+
<div className="flex-1 h-px bg-gray-200" />
|
| 68 |
+
<span className="text-sm text-gray-400">or</span>
|
| 69 |
+
<div className="flex-1 h-px bg-gray-200" />
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
{/* Email Login */}
|
| 73 |
+
<form onSubmit={handleLogin} className="space-y-4">
|
| 74 |
+
<div>
|
| 75 |
+
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
| 76 |
+
<input
|
| 77 |
+
type="email"
|
| 78 |
+
value={email}
|
| 79 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 80 |
+
required
|
| 81 |
+
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-sm"
|
| 82 |
+
placeholder="you@example.com"
|
| 83 |
+
/>
|
| 84 |
+
</div>
|
| 85 |
+
<div>
|
| 86 |
+
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
| 87 |
+
<input
|
| 88 |
+
type="password"
|
| 89 |
+
value={password}
|
| 90 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 91 |
+
required
|
| 92 |
+
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-sm"
|
| 93 |
+
placeholder="••••••••"
|
| 94 |
+
/>
|
| 95 |
+
</div>
|
| 96 |
+
{error && <p className="text-red-600 text-sm">{error}</p>}
|
| 97 |
+
<button
|
| 98 |
+
type="submit"
|
| 99 |
+
disabled={loading}
|
| 100 |
+
className="w-full bg-indigo-600 text-white py-3 rounded-xl font-semibold hover:bg-indigo-700 transition disabled:opacity-50 text-sm"
|
| 101 |
+
>
|
| 102 |
+
{loading ? "Signing in..." : "Sign In"}
|
| 103 |
+
</button>
|
| 104 |
+
</form>
|
| 105 |
+
|
| 106 |
+
<p className="mt-6 text-center text-sm text-gray-500">
|
| 107 |
+
Don't have an account?{" "}
|
| 108 |
+
<Link href="/auth/signup" className="text-indigo-600 font-medium hover:underline">Sign up</Link>
|
| 109 |
+
</p>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
);
|
| 114 |
+
}
|
web/app/auth/signup/page.tsx
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { createClient } from "@/lib/supabase/client";
|
| 5 |
+
import Link from "next/link";
|
| 6 |
+
|
| 7 |
+
export default function SignupPage() {
|
| 8 |
+
const [email, setEmail] = useState("");
|
| 9 |
+
const [password, setPassword] = useState("");
|
| 10 |
+
const [name, setName] = useState("");
|
| 11 |
+
const [error, setError] = useState("");
|
| 12 |
+
const [loading, setLoading] = useState(false);
|
| 13 |
+
const [success, setSuccess] = useState(false);
|
| 14 |
+
|
| 15 |
+
const supabase = createClient();
|
| 16 |
+
|
| 17 |
+
async function handleSignup(e: React.FormEvent) {
|
| 18 |
+
e.preventDefault();
|
| 19 |
+
setLoading(true);
|
| 20 |
+
setError("");
|
| 21 |
+
|
| 22 |
+
const { error } = await supabase.auth.signUp({
|
| 23 |
+
email,
|
| 24 |
+
password,
|
| 25 |
+
options: { data: { full_name: name } },
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
if (error) {
|
| 29 |
+
setError(error.message);
|
| 30 |
+
} else {
|
| 31 |
+
setSuccess(true);
|
| 32 |
+
}
|
| 33 |
+
setLoading(false);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
async function handleOAuth(provider: "google" | "github") {
|
| 37 |
+
await supabase.auth.signInWithOAuth({
|
| 38 |
+
provider,
|
| 39 |
+
options: { redirectTo: `${window.location.origin}/auth/callback` },
|
| 40 |
+
});
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
if (success) {
|
| 44 |
+
return (
|
| 45 |
+
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
| 46 |
+
<div className="text-center max-w-md">
|
| 47 |
+
<div className="text-5xl mb-4">📧</div>
|
| 48 |
+
<h2 className="text-2xl font-bold text-gray-900">Check your email</h2>
|
| 49 |
+
<p className="mt-2 text-gray-600">We sent a confirmation link to <strong>{email}</strong>.</p>
|
| 50 |
+
<Link href="/auth/login" className="mt-6 inline-block text-indigo-600 font-medium hover:underline">
|
| 51 |
+
Back to login
|
| 52 |
+
</Link>
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
return (
|
| 59 |
+
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
| 60 |
+
<div className="w-full max-w-md">
|
| 61 |
+
<div className="text-center mb-8">
|
| 62 |
+
<Link href="/" className="inline-flex items-center gap-2 text-2xl font-bold text-gray-900">
|
| 63 |
+
<span>🛡️</span> ClauseGuard
|
| 64 |
+
</Link>
|
| 65 |
+
<p className="mt-2 text-gray-600">Create your free account</p>
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
|
| 69 |
+
<div className="space-y-3">
|
| 70 |
+
<button
|
| 71 |
+
onClick={() => handleOAuth("google")}
|
| 72 |
+
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 rounded-xl hover:bg-gray-50 transition font-medium text-sm"
|
| 73 |
+
>
|
| 74 |
+
Continue with Google
|
| 75 |
+
</button>
|
| 76 |
+
<button
|
| 77 |
+
onClick={() => handleOAuth("github")}
|
| 78 |
+
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 rounded-xl hover:bg-gray-50 transition font-medium text-sm"
|
| 79 |
+
>
|
| 80 |
+
Continue with GitHub
|
| 81 |
+
</button>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<div className="flex items-center gap-4 my-6">
|
| 85 |
+
<div className="flex-1 h-px bg-gray-200" />
|
| 86 |
+
<span className="text-sm text-gray-400">or</span>
|
| 87 |
+
<div className="flex-1 h-px bg-gray-200" />
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
<form onSubmit={handleSignup} className="space-y-4">
|
| 91 |
+
<div>
|
| 92 |
+
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name</label>
|
| 93 |
+
<input
|
| 94 |
+
type="text"
|
| 95 |
+
value={name}
|
| 96 |
+
onChange={(e) => setName(e.target.value)}
|
| 97 |
+
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-sm"
|
| 98 |
+
placeholder="John Doe"
|
| 99 |
+
/>
|
| 100 |
+
</div>
|
| 101 |
+
<div>
|
| 102 |
+
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
| 103 |
+
<input
|
| 104 |
+
type="email"
|
| 105 |
+
value={email}
|
| 106 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 107 |
+
required
|
| 108 |
+
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-sm"
|
| 109 |
+
placeholder="you@example.com"
|
| 110 |
+
/>
|
| 111 |
+
</div>
|
| 112 |
+
<div>
|
| 113 |
+
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
| 114 |
+
<input
|
| 115 |
+
type="password"
|
| 116 |
+
value={password}
|
| 117 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 118 |
+
required
|
| 119 |
+
minLength={8}
|
| 120 |
+
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-sm"
|
| 121 |
+
placeholder="••••••••"
|
| 122 |
+
/>
|
| 123 |
+
</div>
|
| 124 |
+
{error && <p className="text-red-600 text-sm">{error}</p>}
|
| 125 |
+
<button
|
| 126 |
+
type="submit"
|
| 127 |
+
disabled={loading}
|
| 128 |
+
className="w-full bg-indigo-600 text-white py-3 rounded-xl font-semibold hover:bg-indigo-700 transition disabled:opacity-50 text-sm"
|
| 129 |
+
>
|
| 130 |
+
{loading ? "Creating account..." : "Create Free Account"}
|
| 131 |
+
</button>
|
| 132 |
+
</form>
|
| 133 |
+
|
| 134 |
+
<p className="mt-6 text-center text-sm text-gray-500">
|
| 135 |
+
Already have an account?{" "}
|
| 136 |
+
<Link href="/auth/login" className="text-indigo-600 font-medium hover:underline">Sign in</Link>
|
| 137 |
+
</p>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
);
|
| 142 |
+
}
|
web/app/dashboard-pages/analyze/page.tsx
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
|
| 5 |
+
interface ClauseCategory {
|
| 6 |
+
id: number;
|
| 7 |
+
name: string;
|
| 8 |
+
severity: string;
|
| 9 |
+
description: string;
|
| 10 |
+
confidence: number;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
interface ClauseResult {
|
| 14 |
+
text: string;
|
| 15 |
+
categories: ClauseCategory[];
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
interface AnalysisResult {
|
| 19 |
+
risk_score: number;
|
| 20 |
+
grade: string;
|
| 21 |
+
total_clauses: number;
|
| 22 |
+
flagged_count: number;
|
| 23 |
+
results: ClauseResult[];
|
| 24 |
+
model: string;
|
| 25 |
+
latency_ms: number;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const EXAMPLE_TEXT = `By using the Spotify Service, you agree to be bound by these Terms of Use. If you don't agree with these Terms, then please don't use the Service.
|
| 29 |
+
|
| 30 |
+
Spotify may, in its sole discretion, modify or update these Terms of Service at any time without prior notice. Your continued use of the Service after any such changes constitutes your acceptance of the new Terms of Service.
|
| 31 |
+
|
| 32 |
+
In no event will Spotify, its officers, shareholders, employees, agents, directors, subsidiaries, affiliates, successors, assigns, suppliers, or licensors be liable for any indirect, incidental, special, consequential, or punitive damages, or any loss of profits or revenues, whether incurred directly or indirectly.
|
| 33 |
+
|
| 34 |
+
Spotify reserves the right to remove or disable access to any User Content for any reason, including User Content that Spotify believes violates these Terms, without prior notice.
|
| 35 |
+
|
| 36 |
+
Spotify may terminate your account or suspend your access to all or part of the Service at any time, with or without cause, with or without notice, effective immediately.
|
| 37 |
+
|
| 38 |
+
These Terms will be governed by and construed in accordance with the laws of the State of New York.
|
| 39 |
+
|
| 40 |
+
Any dispute arising from or relating to the subject matter of these Terms shall be finally settled by arbitration in New York County.`;
|
| 41 |
+
|
| 42 |
+
export default function AnalyzePage() {
|
| 43 |
+
const [text, setText] = useState("");
|
| 44 |
+
const [results, setResults] = useState<AnalysisResult | null>(null);
|
| 45 |
+
const [loading, setLoading] = useState(false);
|
| 46 |
+
const [error, setError] = useState("");
|
| 47 |
+
|
| 48 |
+
async function handleAnalyze() {
|
| 49 |
+
if (!text || text.trim().length < 50) {
|
| 50 |
+
setError("Please enter at least 50 characters of text.");
|
| 51 |
+
return;
|
| 52 |
+
}
|
| 53 |
+
setLoading(true);
|
| 54 |
+
setError("");
|
| 55 |
+
setResults(null);
|
| 56 |
+
|
| 57 |
+
try {
|
| 58 |
+
const res = await fetch("/api/analyze", {
|
| 59 |
+
method: "POST",
|
| 60 |
+
headers: { "Content-Type": "application/json" },
|
| 61 |
+
body: JSON.stringify({ text }),
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
if (!res.ok) {
|
| 65 |
+
const err = await res.json();
|
| 66 |
+
throw new Error(err.error || "Analysis failed");
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
setResults(await res.json());
|
| 70 |
+
} catch (err: any) {
|
| 71 |
+
setError(err.message || "Something went wrong.");
|
| 72 |
+
} finally {
|
| 73 |
+
setLoading(false);
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
const gradeColors: Record<string, string> = {
|
| 78 |
+
A: "bg-green-100 text-green-800",
|
| 79 |
+
B: "bg-green-50 text-green-700",
|
| 80 |
+
C: "bg-yellow-100 text-yellow-800",
|
| 81 |
+
D: "bg-orange-100 text-orange-800",
|
| 82 |
+
F: "bg-red-100 text-red-800",
|
| 83 |
+
};
|
| 84 |
+
|
| 85 |
+
const sevColors: Record<string, string> = {
|
| 86 |
+
HIGH: "bg-red-100 text-red-800 border-red-200",
|
| 87 |
+
MEDIUM: "bg-orange-100 text-orange-800 border-orange-200",
|
| 88 |
+
LOW: "bg-blue-100 text-blue-800 border-blue-200",
|
| 89 |
+
};
|
| 90 |
+
|
| 91 |
+
return (
|
| 92 |
+
<div className="min-h-screen bg-gray-50">
|
| 93 |
+
<div className="max-w-6xl mx-auto px-6 py-12">
|
| 94 |
+
<div className="text-center mb-10">
|
| 95 |
+
<h1 className="text-3xl font-bold text-gray-900">🛡️ ClauseGuard Web Scanner</h1>
|
| 96 |
+
<p className="mt-2 text-gray-600">Paste any Terms of Service, contract, or lease agreement below.</p>
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
<div className="grid lg:grid-cols-2 gap-8">
|
| 100 |
+
{/* Input */}
|
| 101 |
+
<div>
|
| 102 |
+
<textarea
|
| 103 |
+
value={text}
|
| 104 |
+
onChange={(e) => setText(e.target.value)}
|
| 105 |
+
placeholder="Paste your Terms of Service or contract text here..."
|
| 106 |
+
className="w-full h-96 p-4 border border-gray-300 rounded-xl text-sm font-mono resize-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
| 107 |
+
/>
|
| 108 |
+
<div className="mt-4 flex gap-3">
|
| 109 |
+
<button
|
| 110 |
+
onClick={handleAnalyze}
|
| 111 |
+
disabled={loading}
|
| 112 |
+
className="flex-1 bg-indigo-600 text-white py-3 rounded-xl font-semibold hover:bg-indigo-700 transition disabled:opacity-50"
|
| 113 |
+
>
|
| 114 |
+
{loading ? "⏳ Scanning..." : "🔍 Scan for Red Flags"}
|
| 115 |
+
</button>
|
| 116 |
+
<button
|
| 117 |
+
onClick={() => setText(EXAMPLE_TEXT)}
|
| 118 |
+
className="px-4 bg-gray-200 text-gray-700 rounded-xl font-medium hover:bg-gray-300 transition text-sm"
|
| 119 |
+
>
|
| 120 |
+
Try Example
|
| 121 |
+
</button>
|
| 122 |
+
</div>
|
| 123 |
+
{error && <p className="mt-3 text-red-600 text-sm">{error}</p>}
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
{/* Results */}
|
| 127 |
+
<div>
|
| 128 |
+
{results ? (
|
| 129 |
+
<div className="space-y-4">
|
| 130 |
+
{/* Summary Card */}
|
| 131 |
+
<div className="bg-gradient-to-br from-indigo-950 to-indigo-800 rounded-2xl p-6 text-white">
|
| 132 |
+
<div className="flex justify-between items-center">
|
| 133 |
+
<div>
|
| 134 |
+
<h2 className="text-lg font-bold">Analysis Complete</h2>
|
| 135 |
+
<p className="text-indigo-200 text-sm">
|
| 136 |
+
{results.total_clauses} clauses · {results.flagged_count} flagged · {results.latency_ms}ms
|
| 137 |
+
</p>
|
| 138 |
+
</div>
|
| 139 |
+
<div className="text-center">
|
| 140 |
+
<div className="text-4xl font-extrabold">{results.risk_score}</div>
|
| 141 |
+
<div className="text-xs text-indigo-300">RISK SCORE</div>
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
<div className={`mt-4 inline-flex px-4 py-1.5 rounded-full font-semibold text-sm ${gradeColors[results.grade] || gradeColors.C}`}>
|
| 145 |
+
Grade: {results.grade}
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
{/* Flagged Clauses */}
|
| 150 |
+
<div className="space-y-3 max-h-[500px] overflow-y-auto">
|
| 151 |
+
{results.results
|
| 152 |
+
.filter(r => r.categories.length > 0)
|
| 153 |
+
.map((clause, i) => (
|
| 154 |
+
<div key={i} className="bg-white border border-gray-200 rounded-xl p-4">
|
| 155 |
+
<p className="text-xs text-gray-400 font-semibold">CLAUSE #{i + 1}</p>
|
| 156 |
+
<p className="text-sm text-gray-700 mt-1 italic line-clamp-3">"{clause.text}"</p>
|
| 157 |
+
<div className="flex flex-wrap gap-2 mt-3">
|
| 158 |
+
{clause.categories.map((cat, j) => (
|
| 159 |
+
<span key={j} className={`text-xs font-semibold px-2.5 py-1 rounded-full border ${sevColors[cat.severity] || sevColors.MEDIUM}`}>
|
| 160 |
+
{cat.name}
|
| 161 |
+
</span>
|
| 162 |
+
))}
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
))}
|
| 166 |
+
{results.flagged_count === 0 && (
|
| 167 |
+
<div className="bg-green-50 border border-green-200 rounded-xl p-8 text-center">
|
| 168 |
+
<div className="text-4xl mb-2">✅</div>
|
| 169 |
+
<p className="text-green-800 font-semibold">No Unfair Clauses Detected</p>
|
| 170 |
+
<p className="text-green-600 text-sm mt-1">This document appears to be fair.</p>
|
| 171 |
+
</div>
|
| 172 |
+
)}
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
) : (
|
| 176 |
+
<div className="flex items-center justify-center h-96 bg-white rounded-xl border border-gray-200">
|
| 177 |
+
<div className="text-center text-gray-400">
|
| 178 |
+
<div className="text-5xl mb-4">🔍</div>
|
| 179 |
+
<p>Paste text and click "Scan" to see results</p>
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
)}
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
);
|
| 188 |
+
}
|
web/app/dashboard-pages/dashboard/page.tsx
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createClient } from "@/lib/supabase/server";
|
| 2 |
+
import Link from "next/link";
|
| 3 |
+
|
| 4 |
+
export default async function DashboardPage() {
|
| 5 |
+
const supabase = await createClient();
|
| 6 |
+
const { data: { user } } = await supabase.auth.getUser();
|
| 7 |
+
|
| 8 |
+
const { data: profile } = await supabase
|
| 9 |
+
.from("profiles")
|
| 10 |
+
.select("*")
|
| 11 |
+
.eq("id", user?.id)
|
| 12 |
+
.single();
|
| 13 |
+
|
| 14 |
+
const { data: analyses, count } = await supabase
|
| 15 |
+
.from("analyses")
|
| 16 |
+
.select("*", { count: "exact" })
|
| 17 |
+
.eq("user_id", user?.id)
|
| 18 |
+
.order("created_at", { ascending: false })
|
| 19 |
+
.limit(10);
|
| 20 |
+
|
| 21 |
+
const plan = profile?.plan || "free";
|
| 22 |
+
const usedThisMonth = profile?.analyses_this_month || 0;
|
| 23 |
+
const limit = plan === "free" ? 10 : "∞";
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<div className="min-h-screen bg-gray-50">
|
| 27 |
+
<div className="max-w-6xl mx-auto px-6 py-12">
|
| 28 |
+
{/* Header */}
|
| 29 |
+
<div className="flex justify-between items-center mb-10">
|
| 30 |
+
<div>
|
| 31 |
+
<h1 className="text-2xl font-bold text-gray-900">🛡️ Dashboard</h1>
|
| 32 |
+
<p className="text-gray-500 text-sm mt-1">
|
| 33 |
+
Welcome back, {profile?.full_name || user?.email}
|
| 34 |
+
</p>
|
| 35 |
+
</div>
|
| 36 |
+
<Link
|
| 37 |
+
href="/dashboard-pages/analyze"
|
| 38 |
+
className="bg-indigo-600 text-white px-6 py-3 rounded-xl font-semibold hover:bg-indigo-700 transition text-sm"
|
| 39 |
+
>
|
| 40 |
+
+ New Scan
|
| 41 |
+
</Link>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
{/* Stats */}
|
| 45 |
+
<div className="grid md:grid-cols-4 gap-6 mb-10">
|
| 46 |
+
<div className="bg-white rounded-xl p-6 border border-gray-200">
|
| 47 |
+
<p className="text-sm text-gray-500">Plan</p>
|
| 48 |
+
<p className="text-2xl font-bold text-gray-900 capitalize mt-1">{plan}</p>
|
| 49 |
+
</div>
|
| 50 |
+
<div className="bg-white rounded-xl p-6 border border-gray-200">
|
| 51 |
+
<p className="text-sm text-gray-500">Scans This Month</p>
|
| 52 |
+
<p className="text-2xl font-bold text-gray-900 mt-1">{usedThisMonth} / {limit}</p>
|
| 53 |
+
</div>
|
| 54 |
+
<div className="bg-white rounded-xl p-6 border border-gray-200">
|
| 55 |
+
<p className="text-sm text-gray-500">Total Scans</p>
|
| 56 |
+
<p className="text-2xl font-bold text-gray-900 mt-1">{count || 0}</p>
|
| 57 |
+
</div>
|
| 58 |
+
<div className="bg-white rounded-xl p-6 border border-gray-200">
|
| 59 |
+
<p className="text-sm text-gray-500">Avg Risk Score</p>
|
| 60 |
+
<p className="text-2xl font-bold text-gray-900 mt-1">
|
| 61 |
+
{analyses && analyses.length > 0
|
| 62 |
+
? Math.round(analyses.reduce((s, a) => s + a.risk_score, 0) / analyses.length)
|
| 63 |
+
: "—"}
|
| 64 |
+
</p>
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
{/* Recent Scans */}
|
| 69 |
+
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
| 70 |
+
<div className="px-6 py-4 border-b border-gray-100">
|
| 71 |
+
<h2 className="font-semibold text-gray-900">Recent Scans</h2>
|
| 72 |
+
</div>
|
| 73 |
+
{analyses && analyses.length > 0 ? (
|
| 74 |
+
<div className="divide-y divide-gray-100">
|
| 75 |
+
{analyses.map((a) => (
|
| 76 |
+
<div key={a.id} className="px-6 py-4 flex items-center justify-between hover:bg-gray-50">
|
| 77 |
+
<div className="flex-1 min-w-0">
|
| 78 |
+
<p className="text-sm font-medium text-gray-900 truncate">
|
| 79 |
+
{a.source_url || "Manual scan"}
|
| 80 |
+
</p>
|
| 81 |
+
<p className="text-xs text-gray-500 mt-1">
|
| 82 |
+
{new Date(a.created_at).toLocaleDateString()} · {a.total_clauses} clauses · {a.flagged_count} flagged
|
| 83 |
+
</p>
|
| 84 |
+
</div>
|
| 85 |
+
<div className="flex items-center gap-3">
|
| 86 |
+
<span className={`text-sm font-bold px-3 py-1 rounded-full ${
|
| 87 |
+
a.grade === "F" ? "bg-red-100 text-red-700" :
|
| 88 |
+
a.grade === "D" ? "bg-orange-100 text-orange-700" :
|
| 89 |
+
a.grade === "C" ? "bg-yellow-100 text-yellow-700" :
|
| 90 |
+
"bg-green-100 text-green-700"
|
| 91 |
+
}`}>
|
| 92 |
+
{a.grade} · {a.risk_score}
|
| 93 |
+
</span>
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
))}
|
| 97 |
+
</div>
|
| 98 |
+
) : (
|
| 99 |
+
<div className="px-6 py-12 text-center text-gray-400">
|
| 100 |
+
<p className="text-4xl mb-3">📋</p>
|
| 101 |
+
<p>No scans yet. <Link href="/dashboard-pages/analyze" className="text-indigo-600 hover:underline">Start your first scan</Link></p>
|
| 102 |
+
</div>
|
| 103 |
+
)}
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
{/* Upgrade CTA for free users */}
|
| 107 |
+
{plan === "free" && (
|
| 108 |
+
<div className="mt-8 bg-indigo-50 border border-indigo-200 rounded-xl p-6 flex items-center justify-between">
|
| 109 |
+
<div>
|
| 110 |
+
<p className="font-semibold text-indigo-900">Upgrade to Pro</p>
|
| 111 |
+
<p className="text-sm text-indigo-700 mt-1">Unlimited scans, AI explanations, PDF exports, and more.</p>
|
| 112 |
+
</div>
|
| 113 |
+
<Link
|
| 114 |
+
href="/pricing"
|
| 115 |
+
className="bg-indigo-600 text-white px-6 py-2.5 rounded-lg font-semibold text-sm hover:bg-indigo-700 transition"
|
| 116 |
+
>
|
| 117 |
+
View Plans
|
| 118 |
+
</Link>
|
| 119 |
+
</div>
|
| 120 |
+
)}
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
);
|
| 124 |
+
}
|
web/app/globals.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
web/app/layout.tsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import "./globals.css";
|
| 3 |
+
|
| 4 |
+
export const metadata: Metadata = {
|
| 5 |
+
title: "ClauseGuard — AI Fine Print Scanner",
|
| 6 |
+
description:
|
| 7 |
+
"Stop signing away your rights. ClauseGuard uses AI to scan Terms of Service, contracts, and legal documents for unfair clauses — instantly.",
|
| 8 |
+
keywords: ["terms of service", "contract scanner", "legal AI", "unfair clauses", "fine print"],
|
| 9 |
+
openGraph: {
|
| 10 |
+
title: "ClauseGuard — AI Fine Print Scanner",
|
| 11 |
+
description: "Stop signing away your rights. AI scans contracts for unfair clauses instantly.",
|
| 12 |
+
url: "https://clauseguard.com",
|
| 13 |
+
siteName: "ClauseGuard",
|
| 14 |
+
type: "website",
|
| 15 |
+
},
|
| 16 |
+
twitter: {
|
| 17 |
+
card: "summary_large_image",
|
| 18 |
+
title: "ClauseGuard — AI Fine Print Scanner",
|
| 19 |
+
description: "Stop signing away your rights.",
|
| 20 |
+
},
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
| 24 |
+
return (
|
| 25 |
+
<html lang="en">
|
| 26 |
+
<body className="antialiased">{children}</body>
|
| 27 |
+
</html>
|
| 28 |
+
);
|
| 29 |
+
}
|
web/app/page.tsx
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ClauseGuard — Landing Page
|
| 3 |
+
* Hero + Features + How It Works + Pricing + CTA
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import Link from "next/link";
|
| 7 |
+
|
| 8 |
+
const FEATURES = [
|
| 9 |
+
{
|
| 10 |
+
icon: "⚖️",
|
| 11 |
+
title: "8 Unfair Clause Types",
|
| 12 |
+
desc: "Detects arbitration traps, liability waivers, unilateral termination, jurisdiction tricks, and more.",
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
icon: "⚡",
|
| 16 |
+
title: "Instant Analysis",
|
| 17 |
+
desc: "Scans any Terms of Service or contract in under 2 seconds. Works on any website.",
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
icon: "🛡️",
|
| 21 |
+
title: "Risk Score & Grade",
|
| 22 |
+
desc: "Get a clear A–F grade and 0–100 risk score. Know exactly how fair a document is.",
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
icon: "🔍",
|
| 26 |
+
title: "Clause-by-Clause Breakdown",
|
| 27 |
+
desc: "Every flagged clause is highlighted with severity, category, and plain-English explanation.",
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
icon: "🌐",
|
| 31 |
+
title: "Chrome Extension",
|
| 32 |
+
desc: "Scans pages as you browse. Red highlights appear right on the ToS page you're reading.",
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
icon: "📜",
|
| 36 |
+
title: "Legal Citations",
|
| 37 |
+
desc: "Each finding references specific EU/US consumer protection laws — not just gut feelings.",
|
| 38 |
+
},
|
| 39 |
+
];
|
| 40 |
+
|
| 41 |
+
const STEPS = [
|
| 42 |
+
{ num: "1", title: "Install the Extension", desc: "Add ClauseGuard to Chrome in one click. Free." },
|
| 43 |
+
{ num: "2", title: "Visit Any ToS Page", desc: "Navigate to any Terms of Service, contract, or lease agreement." },
|
| 44 |
+
{ num: "3", title: "See Red Flags Instantly", desc: "Unfair clauses are highlighted in red, orange, and yellow — with explanations." },
|
| 45 |
+
];
|
| 46 |
+
|
| 47 |
+
const PRICING = [
|
| 48 |
+
{
|
| 49 |
+
name: "Free",
|
| 50 |
+
price: "$0",
|
| 51 |
+
period: "forever",
|
| 52 |
+
features: ["10 scans/month", "8 clause categories", "Risk score & grade", "Chrome extension"],
|
| 53 |
+
cta: "Get Started Free",
|
| 54 |
+
highlight: false,
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
name: "Pro",
|
| 58 |
+
price: "$12",
|
| 59 |
+
period: "/month",
|
| 60 |
+
features: [
|
| 61 |
+
"Unlimited scans",
|
| 62 |
+
"Contract upload & PDF analysis",
|
| 63 |
+
'"Explain this clause" AI feature',
|
| 64 |
+
"Scan history & dashboard",
|
| 65 |
+
"PDF report export",
|
| 66 |
+
"1,000 API calls/month",
|
| 67 |
+
"Email support",
|
| 68 |
+
],
|
| 69 |
+
cta: "Start Pro Trial",
|
| 70 |
+
highlight: true,
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
name: "Team",
|
| 74 |
+
price: "$49",
|
| 75 |
+
period: "/month",
|
| 76 |
+
features: [
|
| 77 |
+
"Everything in Pro",
|
| 78 |
+
"5 team seats",
|
| 79 |
+
"10,000 API calls/month",
|
| 80 |
+
"Team dashboard & analytics",
|
| 81 |
+
"Slack + email support",
|
| 82 |
+
"Custom clause rules",
|
| 83 |
+
],
|
| 84 |
+
cta: "Contact Sales",
|
| 85 |
+
highlight: false,
|
| 86 |
+
},
|
| 87 |
+
];
|
| 88 |
+
|
| 89 |
+
export default function LandingPage() {
|
| 90 |
+
return (
|
| 91 |
+
<main className="min-h-screen bg-white">
|
| 92 |
+
{/* Nav */}
|
| 93 |
+
<nav className="sticky top-0 z-50 bg-white/80 backdrop-blur-lg border-b border-gray-100">
|
| 94 |
+
<div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
|
| 95 |
+
<div className="flex items-center gap-2">
|
| 96 |
+
<span className="text-2xl">🛡️</span>
|
| 97 |
+
<span className="text-xl font-bold text-gray-900">ClauseGuard</span>
|
| 98 |
+
</div>
|
| 99 |
+
<div className="hidden md:flex items-center gap-8">
|
| 100 |
+
<a href="#features" className="text-sm text-gray-600 hover:text-gray-900">Features</a>
|
| 101 |
+
<a href="#how-it-works" className="text-sm text-gray-600 hover:text-gray-900">How It Works</a>
|
| 102 |
+
<a href="#pricing" className="text-sm text-gray-600 hover:text-gray-900">Pricing</a>
|
| 103 |
+
<Link href="/auth/login" className="text-sm text-gray-600 hover:text-gray-900">Log In</Link>
|
| 104 |
+
<Link
|
| 105 |
+
href="/auth/signup"
|
| 106 |
+
className="bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-indigo-700 transition"
|
| 107 |
+
>
|
| 108 |
+
Get Started Free
|
| 109 |
+
</Link>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
</nav>
|
| 113 |
+
|
| 114 |
+
{/* Hero */}
|
| 115 |
+
<section className="relative overflow-hidden">
|
| 116 |
+
<div className="absolute inset-0 bg-gradient-to-br from-indigo-50 via-white to-red-50" />
|
| 117 |
+
<div className="relative max-w-7xl mx-auto px-6 pt-20 pb-28 text-center">
|
| 118 |
+
<div className="inline-flex items-center gap-2 bg-red-50 text-red-700 px-4 py-1.5 rounded-full text-sm font-medium mb-8 border border-red-200">
|
| 119 |
+
<span>🔴</span> 73% of popular ToS contain unfair clauses
|
| 120 |
+
</div>
|
| 121 |
+
<h1 className="text-5xl md:text-7xl font-extrabold text-gray-900 tracking-tight leading-tight max-w-4xl mx-auto">
|
| 122 |
+
Stop signing away
|
| 123 |
+
<br />
|
| 124 |
+
<span className="text-indigo-600">your rights</span>
|
| 125 |
+
</h1>
|
| 126 |
+
<p className="mt-6 text-xl text-gray-600 max-w-2xl mx-auto leading-relaxed">
|
| 127 |
+
ClauseGuard uses AI trained on 9,414 legal clauses to scan any Terms of Service,
|
| 128 |
+
contract, or lease — and highlights the unfair parts before you agree.
|
| 129 |
+
</p>
|
| 130 |
+
<div className="mt-10 flex flex-col sm:flex-row gap-4 justify-center">
|
| 131 |
+
<a
|
| 132 |
+
href="#"
|
| 133 |
+
className="bg-indigo-600 text-white px-8 py-4 rounded-xl text-lg font-semibold hover:bg-indigo-700 transition shadow-lg shadow-indigo-200"
|
| 134 |
+
>
|
| 135 |
+
Add to Chrome — Free
|
| 136 |
+
</a>
|
| 137 |
+
<Link
|
| 138 |
+
href="/dashboard-pages/analyze"
|
| 139 |
+
className="bg-white text-gray-900 px-8 py-4 rounded-xl text-lg font-semibold hover:bg-gray-50 transition border border-gray-200"
|
| 140 |
+
>
|
| 141 |
+
Try Web Scanner →
|
| 142 |
+
</Link>
|
| 143 |
+
</div>
|
| 144 |
+
<p className="mt-4 text-sm text-gray-400">
|
| 145 |
+
No account required · Free forever for 10 scans/month
|
| 146 |
+
</p>
|
| 147 |
+
</div>
|
| 148 |
+
</section>
|
| 149 |
+
|
| 150 |
+
{/* Features */}
|
| 151 |
+
<section id="features" className="py-24 bg-gray-50">
|
| 152 |
+
<div className="max-w-7xl mx-auto px-6">
|
| 153 |
+
<div className="text-center mb-16">
|
| 154 |
+
<h2 className="text-3xl md:text-4xl font-bold text-gray-900">
|
| 155 |
+
What ClauseGuard Detects
|
| 156 |
+
</h2>
|
| 157 |
+
<p className="mt-4 text-lg text-gray-600 max-w-2xl mx-auto">
|
| 158 |
+
Trained on the CLAUDETTE academic taxonomy — the same system used by EU consumer protection researchers.
|
| 159 |
+
</p>
|
| 160 |
+
</div>
|
| 161 |
+
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
| 162 |
+
{FEATURES.map((f, i) => (
|
| 163 |
+
<div key={i} className="bg-white rounded-2xl p-8 border border-gray-100 hover:shadow-lg transition">
|
| 164 |
+
<div className="text-4xl mb-4">{f.icon}</div>
|
| 165 |
+
<h3 className="text-lg font-bold text-gray-900">{f.title}</h3>
|
| 166 |
+
<p className="mt-2 text-gray-600 text-sm leading-relaxed">{f.desc}</p>
|
| 167 |
+
</div>
|
| 168 |
+
))}
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
</section>
|
| 172 |
+
|
| 173 |
+
{/* How It Works */}
|
| 174 |
+
<section id="how-it-works" className="py-24">
|
| 175 |
+
<div className="max-w-7xl mx-auto px-6">
|
| 176 |
+
<div className="text-center mb-16">
|
| 177 |
+
<h2 className="text-3xl md:text-4xl font-bold text-gray-900">How It Works</h2>
|
| 178 |
+
<p className="mt-4 text-lg text-gray-600">Three steps. Under 2 seconds.</p>
|
| 179 |
+
</div>
|
| 180 |
+
<div className="grid md:grid-cols-3 gap-12">
|
| 181 |
+
{STEPS.map((s, i) => (
|
| 182 |
+
<div key={i} className="text-center">
|
| 183 |
+
<div className="w-16 h-16 rounded-full bg-indigo-100 text-indigo-700 text-2xl font-bold flex items-center justify-center mx-auto mb-6">
|
| 184 |
+
{s.num}
|
| 185 |
+
</div>
|
| 186 |
+
<h3 className="text-xl font-bold text-gray-900">{s.title}</h3>
|
| 187 |
+
<p className="mt-3 text-gray-600">{s.desc}</p>
|
| 188 |
+
</div>
|
| 189 |
+
))}
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
+
</section>
|
| 193 |
+
|
| 194 |
+
{/* Pricing */}
|
| 195 |
+
<section id="pricing" className="py-24 bg-gray-50">
|
| 196 |
+
<div className="max-w-7xl mx-auto px-6">
|
| 197 |
+
<div className="text-center mb-16">
|
| 198 |
+
<h2 className="text-3xl md:text-4xl font-bold text-gray-900">Simple Pricing</h2>
|
| 199 |
+
<p className="mt-4 text-lg text-gray-600">Free forever. Upgrade when you need more.</p>
|
| 200 |
+
</div>
|
| 201 |
+
<div className="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
|
| 202 |
+
{PRICING.map((plan, i) => (
|
| 203 |
+
<div
|
| 204 |
+
key={i}
|
| 205 |
+
className={`rounded-2xl p-8 ${
|
| 206 |
+
plan.highlight
|
| 207 |
+
? "bg-indigo-600 text-white ring-4 ring-indigo-200 scale-105"
|
| 208 |
+
: "bg-white border border-gray-200"
|
| 209 |
+
}`}
|
| 210 |
+
>
|
| 211 |
+
<h3 className={`text-lg font-bold ${plan.highlight ? "text-indigo-100" : "text-gray-500"}`}>
|
| 212 |
+
{plan.name}
|
| 213 |
+
</h3>
|
| 214 |
+
<div className="mt-4 flex items-baseline gap-1">
|
| 215 |
+
<span className="text-5xl font-extrabold">{plan.price}</span>
|
| 216 |
+
<span className={`text-sm ${plan.highlight ? "text-indigo-200" : "text-gray-400"}`}>
|
| 217 |
+
{plan.period}
|
| 218 |
+
</span>
|
| 219 |
+
</div>
|
| 220 |
+
<ul className="mt-8 space-y-3">
|
| 221 |
+
{plan.features.map((f, j) => (
|
| 222 |
+
<li key={j} className="flex items-start gap-2 text-sm">
|
| 223 |
+
<span className="mt-0.5">✓</span>
|
| 224 |
+
<span>{f}</span>
|
| 225 |
+
</li>
|
| 226 |
+
))}
|
| 227 |
+
</ul>
|
| 228 |
+
<button
|
| 229 |
+
className={`mt-8 w-full py-3 rounded-xl font-semibold text-sm transition ${
|
| 230 |
+
plan.highlight
|
| 231 |
+
? "bg-white text-indigo-700 hover:bg-indigo-50"
|
| 232 |
+
: "bg-indigo-600 text-white hover:bg-indigo-700"
|
| 233 |
+
}`}
|
| 234 |
+
>
|
| 235 |
+
{plan.cta}
|
| 236 |
+
</button>
|
| 237 |
+
</div>
|
| 238 |
+
))}
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
</section>
|
| 242 |
+
|
| 243 |
+
{/* CTA */}
|
| 244 |
+
<section className="py-24">
|
| 245 |
+
<div className="max-w-4xl mx-auto px-6 text-center">
|
| 246 |
+
<h2 className="text-3xl md:text-4xl font-bold text-gray-900">
|
| 247 |
+
Read the fine print — without reading it.
|
| 248 |
+
</h2>
|
| 249 |
+
<p className="mt-4 text-lg text-gray-600">
|
| 250 |
+
Join thousands of users who protect themselves before clicking "I Agree."
|
| 251 |
+
</p>
|
| 252 |
+
<div className="mt-10">
|
| 253 |
+
<a
|
| 254 |
+
href="#"
|
| 255 |
+
className="bg-indigo-600 text-white px-10 py-4 rounded-xl text-lg font-semibold hover:bg-indigo-700 transition shadow-lg shadow-indigo-200 inline-block"
|
| 256 |
+
>
|
| 257 |
+
Add to Chrome — Free
|
| 258 |
+
</a>
|
| 259 |
+
</div>
|
| 260 |
+
</div>
|
| 261 |
+
</section>
|
| 262 |
+
|
| 263 |
+
{/* Footer */}
|
| 264 |
+
<footer className="border-t border-gray-200 py-12">
|
| 265 |
+
<div className="max-w-7xl mx-auto px-6 flex flex-col md:flex-row justify-between items-center gap-6">
|
| 266 |
+
<div className="flex items-center gap-2">
|
| 267 |
+
<span className="text-xl">🛡️</span>
|
| 268 |
+
<span className="font-bold text-gray-900">ClauseGuard</span>
|
| 269 |
+
</div>
|
| 270 |
+
<div className="flex gap-6 text-sm text-gray-500">
|
| 271 |
+
<a href="/privacy" className="hover:text-gray-900">Privacy Policy</a>
|
| 272 |
+
<a href="/terms" className="hover:text-gray-900">Terms of Service</a>
|
| 273 |
+
<a href="mailto:hello@clauseguard.com" className="hover:text-gray-900">Contact</a>
|
| 274 |
+
</div>
|
| 275 |
+
<p className="text-sm text-gray-400">
|
| 276 |
+
© {new Date().getFullYear()} ClauseGuard. Not legal advice.
|
| 277 |
+
</p>
|
| 278 |
+
</div>
|
| 279 |
+
</footer>
|
| 280 |
+
</main>
|
| 281 |
+
);
|
| 282 |
+
}
|
web/lib/stripe.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Stripe from "stripe";
|
| 2 |
+
|
| 3 |
+
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
| 4 |
+
apiVersion: "2025-03-31.basil",
|
| 5 |
+
typescript: true,
|
| 6 |
+
});
|
| 7 |
+
|
| 8 |
+
export const PLANS = {
|
| 9 |
+
free: {
|
| 10 |
+
name: "Free",
|
| 11 |
+
scans: 10,
|
| 12 |
+
price_id: null,
|
| 13 |
+
features: ["10 scans/month", "8 clause categories", "Risk score & grade"],
|
| 14 |
+
},
|
| 15 |
+
pro: {
|
| 16 |
+
name: "Pro",
|
| 17 |
+
scans: Infinity,
|
| 18 |
+
price_id: process.env.STRIPE_PRO_PRICE_ID!,
|
| 19 |
+
features: ["Unlimited scans", "Contract uploads", "AI explanations", "PDF exports"],
|
| 20 |
+
},
|
| 21 |
+
team: {
|
| 22 |
+
name: "Team",
|
| 23 |
+
scans: Infinity,
|
| 24 |
+
price_id: process.env.STRIPE_TEAM_PRICE_ID!,
|
| 25 |
+
features: ["Everything in Pro", "5 team seats", "10K API calls", "Priority support"],
|
| 26 |
+
},
|
| 27 |
+
} as const;
|
| 28 |
+
|
| 29 |
+
export type PlanType = keyof typeof PLANS;
|
web/lib/supabase/client.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createBrowserClient } from "@supabase/ssr";
|
| 2 |
+
|
| 3 |
+
export function createClient() {
|
| 4 |
+
return createBrowserClient(
|
| 5 |
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
| 6 |
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
| 7 |
+
);
|
| 8 |
+
}
|
web/lib/supabase/schema.sql
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- ClauseGuard — Supabase Database Schema
|
| 2 |
+
-- Run this in Supabase SQL Editor to set up the database
|
| 3 |
+
|
| 4 |
+
-- ─── Profiles (extends auth.users) ───
|
| 5 |
+
CREATE TABLE IF NOT EXISTS public.profiles (
|
| 6 |
+
id UUID REFERENCES auth.users ON DELETE CASCADE PRIMARY KEY,
|
| 7 |
+
email TEXT,
|
| 8 |
+
full_name TEXT,
|
| 9 |
+
avatar_url TEXT,
|
| 10 |
+
stripe_customer_id TEXT UNIQUE,
|
| 11 |
+
stripe_subscription_id TEXT,
|
| 12 |
+
plan TEXT DEFAULT 'free' CHECK (plan IN ('free', 'pro', 'team')),
|
| 13 |
+
analyses_this_month INT DEFAULT 0,
|
| 14 |
+
monthly_reset_at TIMESTAMPTZ DEFAULT date_trunc('month', NOW()),
|
| 15 |
+
created_at TIMESTAMPTZ DEFAULT NOW(),
|
| 16 |
+
updated_at TIMESTAMPTZ DEFAULT NOW()
|
| 17 |
+
);
|
| 18 |
+
|
| 19 |
+
-- ─── Analyses (scan history) ───
|
| 20 |
+
CREATE TABLE IF NOT EXISTS public.analyses (
|
| 21 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 22 |
+
user_id UUID REFERENCES public.profiles ON DELETE CASCADE NOT NULL,
|
| 23 |
+
source_url TEXT,
|
| 24 |
+
source_type TEXT DEFAULT 'tos' CHECK (source_type IN ('tos', 'contract', 'rental', 'other')),
|
| 25 |
+
total_clauses INT NOT NULL,
|
| 26 |
+
flagged_count INT NOT NULL,
|
| 27 |
+
risk_score INT NOT NULL CHECK (risk_score >= 0 AND risk_score <= 100),
|
| 28 |
+
grade CHAR(1) NOT NULL CHECK (grade IN ('A', 'B', 'C', 'D', 'F')),
|
| 29 |
+
clauses JSONB NOT NULL DEFAULT '[]',
|
| 30 |
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
| 31 |
+
);
|
| 32 |
+
|
| 33 |
+
-- ─── Indexes ───
|
| 34 |
+
CREATE INDEX IF NOT EXISTS idx_analyses_user_id ON public.analyses(user_id);
|
| 35 |
+
CREATE INDEX IF NOT EXISTS idx_analyses_created_at ON public.analyses(created_at DESC);
|
| 36 |
+
CREATE INDEX IF NOT EXISTS idx_profiles_stripe_customer ON public.profiles(stripe_customer_id);
|
| 37 |
+
|
| 38 |
+
-- ─── Row Level Security ───
|
| 39 |
+
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
|
| 40 |
+
ALTER TABLE public.analyses ENABLE ROW LEVEL SECURITY;
|
| 41 |
+
|
| 42 |
+
-- Profiles: users can only see/edit their own profile
|
| 43 |
+
CREATE POLICY "Users can view own profile"
|
| 44 |
+
ON public.profiles FOR SELECT
|
| 45 |
+
USING (auth.uid() = id);
|
| 46 |
+
|
| 47 |
+
CREATE POLICY "Users can update own profile"
|
| 48 |
+
ON public.profiles FOR UPDATE
|
| 49 |
+
USING (auth.uid() = id);
|
| 50 |
+
|
| 51 |
+
-- Analyses: users can only see their own scans
|
| 52 |
+
CREATE POLICY "Users can view own analyses"
|
| 53 |
+
ON public.analyses FOR SELECT
|
| 54 |
+
USING (auth.uid() = user_id);
|
| 55 |
+
|
| 56 |
+
CREATE POLICY "Users can insert own analyses"
|
| 57 |
+
ON public.analyses FOR INSERT
|
| 58 |
+
WITH CHECK (auth.uid() = user_id);
|
| 59 |
+
|
| 60 |
+
CREATE POLICY "Users can delete own analyses"
|
| 61 |
+
ON public.analyses FOR DELETE
|
| 62 |
+
USING (auth.uid() = user_id);
|
| 63 |
+
|
| 64 |
+
-- ─── Auto-create profile on signup ───
|
| 65 |
+
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
| 66 |
+
RETURNS TRIGGER AS $$
|
| 67 |
+
BEGIN
|
| 68 |
+
INSERT INTO public.profiles (id, email, full_name, avatar_url)
|
| 69 |
+
VALUES (
|
| 70 |
+
NEW.id,
|
| 71 |
+
NEW.email,
|
| 72 |
+
COALESCE(NEW.raw_user_meta_data ->> 'full_name', NEW.raw_user_meta_data ->> 'name', ''),
|
| 73 |
+
COALESCE(NEW.raw_user_meta_data ->> 'avatar_url', NEW.raw_user_meta_data ->> 'picture', '')
|
| 74 |
+
);
|
| 75 |
+
RETURN NEW;
|
| 76 |
+
END;
|
| 77 |
+
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
| 78 |
+
|
| 79 |
+
CREATE OR REPLACE TRIGGER on_auth_user_created
|
| 80 |
+
AFTER INSERT ON auth.users
|
| 81 |
+
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
|
| 82 |
+
|
| 83 |
+
-- ─── Monthly usage reset function ───
|
| 84 |
+
CREATE OR REPLACE FUNCTION public.reset_monthly_usage()
|
| 85 |
+
RETURNS void AS $$
|
| 86 |
+
BEGIN
|
| 87 |
+
UPDATE public.profiles
|
| 88 |
+
SET analyses_this_month = 0,
|
| 89 |
+
monthly_reset_at = date_trunc('month', NOW())
|
| 90 |
+
WHERE monthly_reset_at < date_trunc('month', NOW());
|
| 91 |
+
END;
|
| 92 |
+
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
web/lib/supabase/server.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createServerClient } from "@supabase/ssr";
|
| 2 |
+
import { cookies } from "next/headers";
|
| 3 |
+
|
| 4 |
+
export async function createClient() {
|
| 5 |
+
const cookieStore = await cookies();
|
| 6 |
+
|
| 7 |
+
return createServerClient(
|
| 8 |
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
| 9 |
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
| 10 |
+
{
|
| 11 |
+
cookies: {
|
| 12 |
+
getAll() {
|
| 13 |
+
return cookieStore.getAll();
|
| 14 |
+
},
|
| 15 |
+
setAll(cookiesToSet) {
|
| 16 |
+
try {
|
| 17 |
+
cookiesToSet.forEach(({ name, value, options }) =>
|
| 18 |
+
cookieStore.set(name, value, options)
|
| 19 |
+
);
|
| 20 |
+
} catch {
|
| 21 |
+
// Server Component — can't set cookies, ignore
|
| 22 |
+
}
|
| 23 |
+
},
|
| 24 |
+
},
|
| 25 |
+
}
|
| 26 |
+
);
|
| 27 |
+
}
|
web/middleware.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse, type NextRequest } from "next/server";
|
| 2 |
+
import { createServerClient } from "@supabase/ssr";
|
| 3 |
+
|
| 4 |
+
export async function middleware(request: NextRequest) {
|
| 5 |
+
let supabaseResponse = NextResponse.next({ request });
|
| 6 |
+
|
| 7 |
+
const supabase = createServerClient(
|
| 8 |
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
| 9 |
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
| 10 |
+
{
|
| 11 |
+
cookies: {
|
| 12 |
+
getAll() {
|
| 13 |
+
return request.cookies.getAll();
|
| 14 |
+
},
|
| 15 |
+
setAll(cookiesToSet) {
|
| 16 |
+
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
|
| 17 |
+
supabaseResponse = NextResponse.next({ request });
|
| 18 |
+
cookiesToSet.forEach(({ name, value, options }) =>
|
| 19 |
+
supabaseResponse.cookies.set(name, value, options)
|
| 20 |
+
);
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
}
|
| 24 |
+
);
|
| 25 |
+
|
| 26 |
+
const { data: { user } } = await supabase.auth.getUser();
|
| 27 |
+
|
| 28 |
+
// Protect dashboard routes
|
| 29 |
+
if (request.nextUrl.pathname.startsWith("/dashboard-pages") && !user) {
|
| 30 |
+
const url = request.nextUrl.clone();
|
| 31 |
+
url.pathname = "/auth/login";
|
| 32 |
+
url.searchParams.set("redirect", request.nextUrl.pathname);
|
| 33 |
+
return NextResponse.redirect(url);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// Redirect logged-in users away from auth pages
|
| 37 |
+
if (request.nextUrl.pathname.startsWith("/auth/") && user) {
|
| 38 |
+
if (!request.nextUrl.pathname.includes("callback")) {
|
| 39 |
+
return NextResponse.redirect(new URL("/dashboard-pages/dashboard", request.url));
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
return supabaseResponse;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export const config = {
|
| 47 |
+
matcher: ["/dashboard-pages/:path*", "/auth/:path*"],
|
| 48 |
+
};
|
web/next.config.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
|
| 3 |
+
const nextConfig: NextConfig = {
|
| 4 |
+
output: "standalone",
|
| 5 |
+
images: {
|
| 6 |
+
remotePatterns: [
|
| 7 |
+
{ protocol: "https", hostname: "avatars.githubusercontent.com" },
|
| 8 |
+
{ protocol: "https", hostname: "lh3.googleusercontent.com" },
|
| 9 |
+
],
|
| 10 |
+
},
|
| 11 |
+
experimental: {
|
| 12 |
+
serverActions: { bodySizeLimit: "2mb" },
|
| 13 |
+
},
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
export default nextConfig;
|
web/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "clauseguard-web",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev --turbopack",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start",
|
| 9 |
+
"lint": "next lint"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"next": "^15.3.0",
|
| 13 |
+
"react": "^19.1.0",
|
| 14 |
+
"react-dom": "^19.1.0",
|
| 15 |
+
"@supabase/supabase-js": "^2.49.0",
|
| 16 |
+
"@supabase/ssr": "^0.6.0",
|
| 17 |
+
"stripe": "^17.7.0",
|
| 18 |
+
"@stripe/stripe-js": "^5.6.0",
|
| 19 |
+
"framer-motion": "^12.6.0",
|
| 20 |
+
"lucide-react": "^0.474.0",
|
| 21 |
+
"clsx": "^2.1.1",
|
| 22 |
+
"tailwind-merge": "^3.0.0"
|
| 23 |
+
},
|
| 24 |
+
"devDependencies": {
|
| 25 |
+
"typescript": "^5.7.0",
|
| 26 |
+
"@types/node": "^22.0.0",
|
| 27 |
+
"@types/react": "^19.0.0",
|
| 28 |
+
"@types/react-dom": "^19.0.0",
|
| 29 |
+
"@tailwindcss/postcss": "^4.0.0",
|
| 30 |
+
"tailwindcss": "^4.0.0",
|
| 31 |
+
"postcss": "^8.5.0"
|
| 32 |
+
}
|
| 33 |
+
}
|
web/postcss.config.mjs
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('postcss-load-config').Config} */
|
| 2 |
+
const config = {
|
| 3 |
+
plugins: {
|
| 4 |
+
"@tailwindcss/postcss": {},
|
| 5 |
+
},
|
| 6 |
+
};
|
| 7 |
+
|
| 8 |
+
export default config;
|
web/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2017",
|
| 4 |
+
"lib": ["dom", "dom.iterable", "esnext"],
|
| 5 |
+
"allowJs": true,
|
| 6 |
+
"skipLibCheck": true,
|
| 7 |
+
"strict": true,
|
| 8 |
+
"noEmit": true,
|
| 9 |
+
"esModuleInterop": true,
|
| 10 |
+
"module": "esnext",
|
| 11 |
+
"moduleResolution": "bundler",
|
| 12 |
+
"resolveJsonModule": true,
|
| 13 |
+
"isolatedModules": true,
|
| 14 |
+
"jsx": "preserve",
|
| 15 |
+
"incremental": true,
|
| 16 |
+
"plugins": [{ "name": "next" }],
|
| 17 |
+
"paths": { "@/*": ["./*"] }
|
| 18 |
+
},
|
| 19 |
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
| 20 |
+
"exclude": ["node_modules"]
|
| 21 |
+
}
|