Initial commit: LexMind - Plataforma Jurídica Inteligente

This commit is contained in:
bigtux
2026-02-10 15:46:26 -03:00
commit 08bd4f039d
108 changed files with 75782 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
node_modules/
.next/
dist/
.env
.env.local
.env*.local
*.log
.DS_Store
coverage/
.turbo/
*.tsbuildinfo

1638
MANUAL.md Normal file

File diff suppressed because it is too large Load Diff

45931
MANUAL.pdf Normal file

File diff suppressed because one or more lines are too long

212
SECURITY-AUDIT.md Normal file
View File

@@ -0,0 +1,212 @@
# LexMind Security Audit Report
**Date:** 2026-02-01
**Auditor:** Automated Security Audit
**Status:** ✅ COMPLETED
---
## Executive Summary
Comprehensive security audit of the LexMind application (Next.js 16 + Prisma + PostgreSQL + NextAuth + Stripe). Found and fixed **12 vulnerabilities** across 7 categories. Zero npm vulnerabilities remain.
---
## 1. NPM Vulnerabilities
**Status:** ✅ FIXED
| Before | After |
|--------|-------|
| 21 high severity | 0 vulnerabilities |
- **Root cause:** `fast-xml-parser` 5.2.5 (via AWS SDK) had RangeError DoS bug
- **Fix:** Added npm override for `fast-xml-parser@5.3.4` in package.json
---
## 2. SQL Injection / Prisma
**Status:** ✅ CLEAN
- No `$queryRaw` or `$executeRaw` usage found
- All database access uses Prisma's parameterized queries
- No raw SQL anywhere in the codebase
---
## 3. XSS (Cross-Site Scripting)
**Status:** ✅ CLEAN
- No `dangerouslySetInnerHTML` or `innerHTML` usage found
- React's default escaping protects against XSS
- Added Content-Security-Policy header (see Section 9)
---
## 4. CSRF Protection
**Status:** ✅ VERIFIED
- NextAuth CSRF tokens working correctly
- Cookies use `__Host-` prefix with `HttpOnly; Secure; SameSite=Lax`
- All mutating API routes require authenticated session
---
## 5. Authentication & Authorization
**Status:** ✅ FIXED (2 critical issues)
### 🔴 CRITICAL: Unauthenticated Checkout Route
- **File:** `/api/checkout/route.ts`
- **Issue:** No `getServerSession` check — anyone could create Stripe checkout sessions
- **Fix:** Added authentication check, uses session email instead of user-provided email
### 🔴 CRITICAL: Unauthenticated DOCX Export Route
- **File:** `/api/export/docx/route.ts`
- **Issue:** No authentication — anyone could generate DOCX documents
- **Fix:** Added `getServerSession` check
### Auth Coverage (all routes verified):
| Route | Auth | IDOR Protected |
|-------|------|----------------|
| /api/admin/stats | ✅ ADMIN check | N/A |
| /api/analise-processo | ✅ session.user.id | ✅ userId filter |
| /api/analise-processo/[id] | ✅ session.user.id | ✅ userId filter |
| /api/auditoria | ✅ session.user.id | ✅ userId filter |
| /api/auditoria/[id] | ✅ session.user.id | ✅ userId filter |
| /api/chat | ✅ session.user.id | ✅ userId filter |
| /api/chat/[chatId] | ✅ session.user.id | ✅ userId filter |
| /api/checkout | ✅ **FIXED** | N/A |
| /api/documents | ✅ session.user.id | ✅ userId filter |
| /api/documents/[id] | ✅ session.user.id | ✅ userId filter |
| /api/documents/generate | ✅ session.user.id | N/A |
| /api/export/docx | ✅ **FIXED** | N/A |
| /api/jurisprudencia | ✅ session.user.id | N/A (public data) |
| /api/jurisprudencia/search | ✅ session.user.id | N/A (public data) |
| /api/keys | ✅ session.user.id | ✅ userId filter |
| /api/keys/[id] | ✅ session.user.id | ✅ userId filter |
| /api/prazos | ✅ session.user.id | ✅ userId filter |
| /api/prazos/[id] | ✅ session.user.id | ✅ userId filter |
| /api/register | N/A (public) | N/A |
| /api/stripe/checkout | ✅ session.user | ✅ |
| /api/stripe/portal | ✅ session.user | ✅ |
| /api/stripe/webhook | N/A (Stripe sig) | ✅ signature verified |
| /api/templates | ✅ session.user.id | ✅ userId filter |
| /api/uploads | ✅ session.user.id | ✅ userId filter |
| /api/uploads/[id] | ✅ session.user.id | ✅ userId filter |
---
## 6. Rate Limiting
**Status:** ✅ VERIFIED
Nginx rate limiting active:
- Auth routes: 5 req/min (`zone=auth`)
- API routes: 20 req/sec (`zone=api`)
- General: 30 req/sec (`zone=general`)
- Connection limit: 20 per IP (`conn_limit`)
- Scanner/bot blocking via User-Agent filter
---
## 7. Input Validation
**Status:** ✅ FIXED (5 improvements)
- **Created:** `src/lib/validate.ts` with sanitization utilities
- **Register route:** Added input length limits for all fields
- **Chat route:** Added 10,000 char message limit
- **Auditoria route:** Added title (500) and content (100,000) limits
- **Prazos route:** Added title (500) and description (5,000) limits
- **Pagination:** Bounded page/limit params in uploads and jurisprudencia routes
- **Uploads:** Added server-side file extension validation (defense in depth)
---
## 8. Sensitive Data Exposure
**Status:** ✅ FIXED
- **Created `.gitignore`** — `.env` was not being excluded (no `.gitignore` existed!)
- **Stripe error messages:** Stopped leaking `error.message` to client in checkout/portal routes
- **API responses:** Verified no password hashes or internal IDs are exposed
- **API keys:** Properly hashed (SHA-256), only shown once on creation, masked in listings
- **NEXT_PUBLIC vars:** Only publishable Stripe key and app URL (safe)
- **Error handling:** All routes return generic error messages, details logged server-side
---
## 9. Security Headers
**Status:** ✅ FIXED (2 new headers added)
### Headers now active:
| Header | Value | Status |
|--------|-------|--------|
| X-Frame-Options | SAMEORIGIN | ✅ existing |
| X-Content-Type-Options | nosniff | ✅ existing |
| X-XSS-Protection | 1; mode=block | ✅ existing |
| Referrer-Policy | strict-origin-when-cross-origin | ✅ existing |
| Strict-Transport-Security | max-age=31536000; includeSubDomains | ✅ existing |
| Content-Security-Policy | Full CSP policy | ✅ **ADDED** |
| Permissions-Policy | camera=(), microphone=(), geolocation=() | ✅ **ADDED** |
| server_tokens | off | ✅ existing |
---
## 10. Database Security
**Status:** ✅ VERIFIED
- PostgreSQL listens only on localhost (default, `listen_addresses = 'localhost'`)
- `pg_hba.conf` uses `scram-sha-256` for TCP connections
- Local connections use `peer` authentication
- No remote access configured
---
## 11. File Upload Security
**Status:** ✅ VERIFIED + IMPROVED
- MIME type whitelist: PDF, DOC, DOCX, TXT only
- **Added:** File extension validation (defense in depth)
- Max size: 50MB (enforced both in app and nginx `client_max_body_size`)
- Storage limits per plan (1GB-20GB)
- File paths sanitized via `buildKey()` — strips all special chars
- No path traversal possible (`../` becomes `___`)
- Files stored as `private` ACL on DigitalOcean Spaces
- Access via signed URLs (15 min expiry)
---
## 12. Session Security
**Status:** ✅ IMPROVED
- Cookies: `__Host-` prefix, `HttpOnly`, `Secure`, `SameSite=Lax`
- **Reduced session maxAge from 30 days to 7 days** (more appropriate for legal app)
- JWT strategy with strong NEXTAUTH_SECRET (44 chars, base64)
- CSRF token verified on all auth requests
---
## 13. Other Fixes
### Duplicate Webhook Route Removed
- **Deleted:** `/api/webhook/stripe/route.ts` (incomplete, only logged events, no DB updates)
- **Active:** `/api/stripe/webhook/route.ts` (fully functional with DB updates)
### Next.js Middleware Added
- **Created:** `src/middleware.ts` — adds security headers at application level as backup
---
## 14. Remaining Notes (Low Risk)
| Item | Risk | Notes |
|------|------|-------|
| `typescript: { ignoreBuildErrors: true }` in next.config | Low | Could hide type errors; recommend fixing eventually |
| AWS SDK DoS vuln was in XML parsing | Info | Fixed via override, but only exploitable if attacker controls S3 responses |
| File upload MIME check trusts client header | Low | Mitigated by extension whitelist + private storage |
| No email verification on registration | Medium | Users can register with unverified emails |
---
## Deployment Status
- ✅ All fixes applied
-`npm audit`: 0 vulnerabilities
- ✅ Build successful
- ✅ PM2 restarted
- ✅ Nginx reloaded with new headers
- ✅ Application verified working

118
docs/INTEGRACAO-DIARIOS.md Normal file
View File

@@ -0,0 +1,118 @@
# Integração com Diários Oficiais - LexMind
## Visão Geral
O LexMind agora possui integração real com a API DataJud do CNJ para buscar publicações processuais automaticamente.
## Fontes de Dados
### 1. API DataJud (CNJ) - Fonte Principal
- **URL Base:** https://api-publica.datajud.cnj.jus.br/
- **Autenticação:** API Key pública do CNJ
- **Tribunais Suportados:** Todos os TJs, TRFs, STJ, STF, TST
- **Dados Retornados:** Movimentações processuais (publicações, intimações, citações, etc.)
### 2. DJe TJSP (Backup - não implementado)
- Pode ser adicionado via scraping se necessário
## Arquivos Criados/Modificados
```
src/lib/
├── diarios-service.ts # Service principal de integração
├── publicacoes-service.ts # Cálculo de prazos e tipos
src/app/api/publicacoes/buscar/
├── route.ts # API atualizada para busca real
scripts/
├── buscar-publicacoes.ts # Script de busca diária (cron)
├── testar-datajud.ts # Teste de conexão com API
├── teste-integracao.ts # Teste completo do service
├── teste-standalone.ts # Teste isolado
```
## Uso
### Busca Manual (via API)
```bash
# Buscar publicações de um processo específico
curl -X POST https://lexmind.com.br/api/publicacoes/buscar \
-H "Content-Type: application/json" \
-H "Cookie: <session>" \
-d '{"processoId": "cuid-do-processo"}'
# Buscar todos os processos ativos do usuário
curl -X POST https://lexmind.com.br/api/publicacoes/buscar \
-H "Cookie: <session>"
```
### Busca Diária (Cron)
```bash
cd /var/www/lexmind
npx ts-node scripts/buscar-publicacoes.ts
```
### Testes
```bash
# Testar conexão com DataJud
npx ts-node scripts/testar-datajud.ts
# Testar busca com processo real
npx ts-node scripts/teste-standalone.ts
```
## Tipos de Publicação Detectados
| Tipo | Código CNJ | Keywords |
|------|------------|----------|
| INTIMACAO | 12265, 12021 | intimação, fica intimado |
| CITACAO | 14, 12037 | citação, fica citado |
| SENTENCA | 22, 848 | sentença, julgo procedente |
| ACORDAO | 217, 219 | acórdão, acordam os desembargadores |
| DESPACHO | 11010, 11383 | despacho, determino |
| PUBLICACAO | 92 | (genérico) |
## Cálculo de Prazos
- INTIMACAO/CITACAO/SENTENCA/ACORDAO: 15 dias úteis
- DESPACHO: 5 dias úteis
- OUTROS: 5 dias úteis
## Rate Limiting
- Delay de 500ms entre requisições em lote
- Busca processos dos últimos 30 dias por padrão
## Configuração do Cron
Para executar a busca diariamente às 7h:
```bash
# Via crontab no servidor
0 7 * * * cd /var/www/lexmind && /usr/bin/npx ts-node scripts/buscar-publicacoes.ts >> /var/log/lexmind-publicacoes.log 2>&1
# Ou via Clawdbot (remoto)
clawdbot cron add "0 7 * * *" "ssh jarvis-do 'cd /var/www/lexmind && npx ts-node scripts/buscar-publicacoes.ts'"
```
## Troubleshooting
### API retorna erro 401
- Verificar se a API Key está correta
- A key pública do CNJ raramente muda
### Processo não encontrado
- Verificar formato do número (20 dígitos sem formatação)
- Verificar se o tribunal está correto
- Alguns processos podem estar em sigilo
### Timeout na busca
- Aumentar delay entre requisições
- Verificar conectividade de rede
## Manutenção
- Monitorar logs de busca diária
- Verificar se há novos códigos de movimentos no CNJ
- Atualizar mapeamento de tribunais se necessário

111
docs/PUBLICACOES.md Normal file
View File

@@ -0,0 +1,111 @@
# Módulo de Monitoramento de Publicações
## Visão Geral
Este módulo permite que advogados monitorem publicações nos Diários Oficiais (DJe, DOU, DOESP) relacionadas aos seus processos. O sistema calcula automaticamente prazos com base no tipo de publicação.
## Funcionalidades
- **Cadastro de processos** para monitoramento
- **Busca de publicações** (mock para MVP)
- **Cálculo automático de prazos** com base no tipo de publicação
- **Dashboard** com estatísticas e alertas
- **Filtros** por tipo, processo, período, status de leitura
- **Marcação de publicações** como lidas
## Tipos de Publicação e Prazos
| Tipo | Prazo Padrão |
|------|--------------|
| Intimação | 15 dias úteis |
| Citação | 15 dias úteis |
| Sentença | 15 dias úteis (recurso) |
| Despacho | 5 dias úteis |
| Acórdão | 15 dias úteis (embargos/recurso) |
| Outros | 5 dias úteis |
## API Endpoints
### Processos
- `GET /api/processos` - Lista processos monitorados
- `POST /api/processos` - Cadastra novo processo
- `GET /api/processos/[id]` - Detalhes de um processo
- `PUT /api/processos/[id]` - Atualiza processo
- `DELETE /api/processos/[id]` - Remove processo
### Publicações
- `GET /api/publicacoes` - Lista publicações (com filtros)
- `PATCH /api/publicacoes/[id]/visualizar` - Marca como lida
- `POST /api/publicacoes/buscar` - Busca novas publicações
## Configuração de Busca Automática (Cron Job)
### Opção 1: Cron do Sistema
```bash
# Editar crontab
crontab -e
# Adicionar linha para buscar diariamente às 8h
0 8 * * * curl -X POST http://localhost:3000/api/publicacoes/buscar -H "Cookie: session=..." >> /var/log/lexmind-publicacoes.log 2>&1
```
### Opção 2: Scheduler Externo
Use serviços como:
- **cron-job.org** (gratuito)
- **EasyCron**
- **GitHub Actions** (scheduled workflow)
Configurar para fazer POST em `/api/publicacoes/buscar` diariamente.
## Integração com APIs Reais (Futuro)
Para substituir o mock por APIs reais dos tribunais:
1. Editar `src/lib/publicacoes-service.ts`
2. Implementar `buscarPublicacoesTribunal(tribunal, numeroProcesso)`
3. APIs sugeridas:
- **Datajud** (CNJ) - https://datajud-wiki.cnj.jus.br/
- **e-SAJ** (TJSP) - via scraping
- **PJe** - via API onde disponível
## Schema do Banco
```prisma
model ProcessoMonitorado {
id String @id @default(cuid())
userId String
numeroProcesso String // Formato CNJ: 0000000-00.0000.0.00.0000
tribunal String // Ex: TJSP, TRF3, STJ
vara String?
comarca String?
parteAutora String?
parteRe String?
status ProcessoStatus @default(ATIVO)
publicacoes Publicacao[]
}
model Publicacao {
id String @id @default(cuid())
processoId String
dataPublicacao DateTime
diario String // Ex: DJe, DOU, DOESP
conteudo String @db.Text
tipo TipoPublicacao
prazoCalculado DateTime?
prazoTipo String?
visualizado Boolean @default(false)
}
```
## Changelog
### v1.0.0 (MVP)
- Cadastro e gerenciamento de processos
- Dashboard de publicações
- Busca mock para testes
- Cálculo automático de prazos
- Filtros e marcação de leitura

6
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

16
next.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
basePath: "/adv",
typescript: {
ignoreBuildErrors: true,
},
experimental: {
serverActions: {
bodySizeLimit: '50mb',
},
proxyClientMaxBodySize: '50mb',
},
};
export default nextConfig;

8799
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "lexmind",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"seed": "npx tsx prisma/seed.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.893.0",
"@aws-sdk/s3-request-presigner": "^3.893.0",
"@prisma/client": "^6.19.2",
"@stripe/stripe-js": "^8.7.0",
"@types/bcryptjs": "^2.4.6",
"bcryptjs": "^3.0.3",
"clsx": "^2.1.1",
"docx": "^9.5.1",
"framer-motion": "^12.29.2",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"next-auth": "^4.24.13",
"openai": "^6.17.0",
"pdf-parse": "^1.1.1",
"prisma": "^6.19.2",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"stripe": "^20.3.0",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
},
"overrides": {
"fast-xml-parser": "5.3.4"
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

BIN
prisma/dev.db Normal file

Binary file not shown.

View File

@@ -0,0 +1,176 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'ADVOGADO', 'FREE');
-- CreateEnum
CREATE TYPE "Plan" AS ENUM ('FREE', 'PRO', 'ENTERPRISE');
-- CreateEnum
CREATE TYPE "DocumentType" AS ENUM ('PETICAO_INICIAL', 'CONTESTACAO', 'APELACAO', 'RECURSO', 'CONTRATO', 'PARECER', 'IMPUGNACAO', 'HABEAS_CORPUS', 'MANDADO_SEGURANCA', 'OUTROS');
-- CreateEnum
CREATE TYPE "LegalArea" AS ENUM ('CIVIL', 'TRABALHISTA', 'PENAL', 'TRIBUTARIO', 'FAMILIA', 'EMPRESARIAL', 'CONSUMIDOR', 'ADMINISTRATIVO');
-- CreateEnum
CREATE TYPE "DocumentStatus" AS ENUM ('GENERATING', 'COMPLETED', 'ERROR');
-- CreateEnum
CREATE TYPE "MessageRole" AS ENUM ('USER', 'ASSISTANT');
-- CreateEnum
CREATE TYPE "SubscriptionStatus" AS ENUM ('ACTIVE', 'CANCELLED', 'EXPIRED');
-- CreateEnum
CREATE TYPE "UsageType" AS ENUM ('DOCUMENT', 'CHAT', 'JURISPRUDENCIA');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"role" "UserRole" NOT NULL DEFAULT 'FREE',
"plan" "Plan" NOT NULL DEFAULT 'FREE',
"oabNumber" TEXT,
"oabState" TEXT,
"phone" TEXT,
"avatar" TEXT,
"credits" INTEGER NOT NULL DEFAULT 5,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ApiKey" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"name" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Document" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" "DocumentType" NOT NULL,
"title" TEXT NOT NULL,
"prompt" TEXT NOT NULL,
"content" TEXT NOT NULL,
"wordCount" INTEGER NOT NULL DEFAULT 0,
"status" "DocumentStatus" NOT NULL DEFAULT 'GENERATING',
"area" "LegalArea" NOT NULL,
"tokens" INTEGER NOT NULL DEFAULT 0,
"cost" DOUBLE PRECISION NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Document_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Template" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"type" "DocumentType" NOT NULL,
"area" "LegalArea" NOT NULL,
"prompt" TEXT NOT NULL,
"isPublic" BOOLEAN NOT NULL DEFAULT false,
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Template_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Jurisprudencia" (
"id" TEXT NOT NULL,
"tribunal" TEXT NOT NULL,
"numero" TEXT NOT NULL,
"ementa" TEXT NOT NULL,
"data" TEXT NOT NULL,
"area" TEXT NOT NULL,
"relator" TEXT NOT NULL,
"orgaoJulgador" TEXT NOT NULL,
"tags" TEXT NOT NULL,
CONSTRAINT "Jurisprudencia_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Chat" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Chat_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChatMessage" (
"id" TEXT NOT NULL,
"chatId" TEXT NOT NULL,
"role" "MessageRole" NOT NULL,
"content" TEXT NOT NULL,
"tokens" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ChatMessage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Subscription" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"plan" "Plan" NOT NULL,
"status" "SubscriptionStatus" NOT NULL DEFAULT 'ACTIVE',
"startDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"endDate" TIMESTAMP(3),
"stripeId" TEXT,
CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UsageLog" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" "UsageType" NOT NULL,
"tokens" INTEGER NOT NULL DEFAULT 0,
"cost" DOUBLE PRECISION NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UsageLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_key_key" ON "ApiKey"("key");
-- AddForeignKey
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Chat" ADD CONSTRAINT "Chat_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UsageLog" ADD CONSTRAINT "UsageLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,47 @@
-- CreateEnum
CREATE TYPE "PrazoStatus" AS ENUM ('PENDENTE', 'CONCLUIDO', 'VENCIDO', 'CANCELADO');
-- CreateEnum
CREATE TYPE "PrazoPriority" AS ENUM ('ALTA', 'MEDIA', 'BAIXA');
-- CreateEnum
CREATE TYPE "AuditStatus" AS ENUM ('PENDING', 'ANALYZING', 'DONE', 'ERROR');
-- CreateTable
CREATE TABLE "Prazo" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"processNumber" TEXT,
"court" TEXT,
"deadline" TIMESTAMP(3) NOT NULL,
"alertDays" INTEGER NOT NULL DEFAULT 3,
"status" "PrazoStatus" NOT NULL DEFAULT 'PENDENTE',
"priority" "PrazoPriority" NOT NULL DEFAULT 'MEDIA',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Prazo_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ContractAudit" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"analysis" JSONB,
"status" "AuditStatus" NOT NULL DEFAULT 'PENDING',
"riskScore" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ContractAudit_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Prazo" ADD CONSTRAINT "Prazo_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ContractAudit" ADD CONSTRAINT "ContractAudit_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,7 @@
-- AlterEnum
ALTER TYPE "Plan" ADD VALUE 'STARTER';
-- AlterTable
ALTER TABLE "User" ADD COLUMN "stripeCustomerId" TEXT,
ADD COLUMN "stripePriceId" TEXT,
ADD COLUMN "stripeSubscriptionId" TEXT;

View File

@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "Upload" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"key" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"mimeType" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Upload_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Upload" ADD CONSTRAINT "Upload_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "ProcessAnalysis" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"fileKey" TEXT NOT NULL,
"fileSize" INTEGER NOT NULL,
"extractedText" TEXT NOT NULL,
"analysis" TEXT NOT NULL,
"summary" TEXT,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProcessAnalysis_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "ProcessAnalysis" ADD CONSTRAINT "ProcessAnalysis_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

BIN
prisma/prisma/dev.db Normal file

Binary file not shown.

View File

@@ -0,0 +1,71 @@
// ===== MONITORAMENTO DE PUBLICAÇÕES =====
model ProcessoMonitorado {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
numeroProcesso String // Ex: 0001234-56.2024.8.26.0100
tribunal String // Ex: TJSP, TRF3, STJ
vara String?
comarca String?
parteAutora String?
parteRe String?
status ProcessoStatus @default(ATIVO)
// Dados do processo (buscados da API DataJud)
classe String?
assunto String?
dataAjuizamento DateTime?
orgaoJulgador String?
grau String?
valorCausa Float?
ultimaAtualizacao DateTime?
dadosCompletos Json? // JSON com todos os dados brutos da API
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
publicacoes Publicacao[]
andamentos Andamento[]
}
model Andamento {
id String @id @default(cuid())
processoId String
processo ProcessoMonitorado @relation(fields: [processoId], references: [id], onDelete: Cascade)
codigo Int
nome String
dataHora DateTime
complemento String?
createdAt DateTime @default(now())
@@unique([processoId, codigo, dataHora])
}
model Publicacao {
id String @id @default(cuid())
processoId String
processo ProcessoMonitorado @relation(fields: [processoId], references: [id], onDelete: Cascade)
dataPublicacao DateTime
diario String // Ex: DJe, DOU, DOESP
conteudo String @db.Text
tipo TipoPublicacao
prazoCalculado DateTime?
prazoTipo String? // Ex: "15 dias úteis", "5 dias"
visualizado Boolean @default(false)
createdAt DateTime @default(now())
}
enum ProcessoStatus {
ATIVO
ARQUIVADO
SUSPENSO
}
enum TipoPublicacao {
INTIMACAO
CITACAO
SENTENCA
DESPACHO
ACORDAO
OUTROS
}

338
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,338 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String
email String @unique
password String
role UserRole @default(FREE)
plan Plan @default(FREE)
oabNumber String?
oabState String?
phone String?
avatar String?
credits Int @default(5)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
stripeCustomerId String?
stripePriceId String?
stripeSubscriptionId String?
apiKeys ApiKey[]
chats Chat[]
contractAudits ContractAudit[]
documents Document[]
prazos Prazo[]
subscriptions Subscription[]
templates Template[]
usageLogs UsageLog[]
uploads Upload[]
processAnalyses ProcessAnalysis[]
processosMonitorados ProcessoMonitorado[]
}
model ApiKey {
id String @id @default(cuid())
key String @unique
name String
userId String
active Boolean @default(true)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Document {
id String @id @default(cuid())
userId String
type DocumentType
title String
prompt String
content String
wordCount Int @default(0)
status DocumentStatus @default(GENERATING)
area LegalArea
tokens Int @default(0)
cost Float @default(0)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Template {
id String @id @default(cuid())
name String
description String
type DocumentType
area LegalArea
prompt String
isPublic Boolean @default(false)
userId String?
createdAt DateTime @default(now())
user User? @relation(fields: [userId], references: [id])
}
model Jurisprudencia {
id String @id @default(cuid())
tribunal String
numero String
ementa String
data String
area String
relator String
orgaoJulgador String
tags String
}
model Chat {
id String @id @default(cuid())
userId String
title String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
messages ChatMessage[]
}
model ChatMessage {
id String @id @default(cuid())
chatId String
role MessageRole
content String
tokens Int @default(0)
createdAt DateTime @default(now())
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
}
model Subscription {
id String @id @default(cuid())
userId String
plan Plan
status SubscriptionStatus @default(ACTIVE)
startDate DateTime @default(now())
endDate DateTime?
stripeId String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model UsageLog {
id String @id @default(cuid())
userId String
type UsageType
tokens Int @default(0)
cost Float @default(0)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Prazo {
id String @id @default(cuid())
userId String
title String
description String?
processNumber String?
court String?
deadline DateTime
alertDays Int @default(3)
status PrazoStatus @default(PENDENTE)
priority PrazoPriority @default(MEDIA)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model ContractAudit {
id String @id @default(cuid())
userId String
title String
content String
analysis Json?
status AuditStatus @default(PENDING)
riskScore Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Upload {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
filename String
key String
size Int
mimeType String
createdAt DateTime @default(now())
}
enum UserRole {
ADMIN
ADVOGADO
FREE
}
enum Plan {
FREE
PRO
ENTERPRISE
STARTER
}
enum DocumentType {
PETICAO_INICIAL
CONTESTACAO
APELACAO
RECURSO
CONTRATO
PARECER
IMPUGNACAO
HABEAS_CORPUS
MANDADO_SEGURANCA
OUTROS
}
enum LegalArea {
CIVIL
TRABALHISTA
PENAL
TRIBUTARIO
FAMILIA
EMPRESARIAL
CONSUMIDOR
ADMINISTRATIVO
}
enum DocumentStatus {
GENERATING
COMPLETED
ERROR
}
enum MessageRole {
USER
ASSISTANT
}
enum SubscriptionStatus {
ACTIVE
CANCELLED
EXPIRED
}
enum UsageType {
DOCUMENT
CHAT
JURISPRUDENCIA
}
enum PrazoStatus {
PENDENTE
CONCLUIDO
VENCIDO
CANCELADO
}
enum PrazoPriority {
ALTA
MEDIA
BAIXA
}
enum AuditStatus {
PENDING
ANALYZING
DONE
ERROR
}
model ProcessAnalysis {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String
filename String
fileKey String
fileSize Int
extractedText String @db.Text
analysis String @db.Text
summary String? @db.Text
status String @default("PENDING")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ===== MONITORAMENTO DE PUBLICAÇÕES =====
model ProcessoMonitorado {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
numeroProcesso String // Ex: 0001234-56.2024.8.26.0100
tribunal String // Ex: TJSP, TRF3, STJ
vara String?
comarca String?
parteAutora String?
parteRe String?
status ProcessoStatus @default(ATIVO)
// Dados do processo (buscados da API DataJud)
classe String?
assunto String?
dataAjuizamento DateTime?
orgaoJulgador String?
grau String?
valorCausa Float?
ultimaAtualizacao DateTime?
dadosCompletos Json? // JSON com todos os dados brutos da API
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
publicacoes Publicacao[]
andamentos Andamento[]
}
model Publicacao {
id String @id @default(cuid())
processoId String
processo ProcessoMonitorado @relation(fields: [processoId], references: [id], onDelete: Cascade)
dataPublicacao DateTime
diario String // Ex: DJe, DOU, DOESP
conteudo String @db.Text
tipo TipoPublicacao
prazoCalculado DateTime?
prazoTipo String? // Ex: "15 dias úteis", "5 dias"
visualizado Boolean @default(false)
createdAt DateTime @default(now())
}
enum ProcessoStatus {
ATIVO
ARQUIVADO
SUSPENSO
}
enum TipoPublicacao {
INTIMACAO
CITACAO
SENTENCA
DESPACHO
ACORDAO
OUTROS
}
model Andamento {
id String @id @default(cuid())
processoId String
processo ProcessoMonitorado @relation(fields: [processoId], references: [id], onDelete: Cascade)
codigo Int
nome String
dataHora DateTime
complemento String?
createdAt DateTime @default(now())
@@unique([processoId, codigo, dataHora])
}

367
prisma/seed.ts Normal file
View File

@@ -0,0 +1,367 @@
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'
const prisma = new PrismaClient()
async function main() {
console.log('🌱 Seeding database...')
// ── Users ──
const passwordHash = await bcrypt.hash('123456', 10)
const admin = await prisma.user.upsert({
where: { email: 'admin@juridico.ai' },
update: {},
create: {
name: 'Admin Sistema',
email: 'admin@juridico.ai',
password: passwordHash,
role: 'ADMIN',
plan: 'ENTERPRISE',
credits: 9999,
phone: '11999999999',
},
})
const advogadoPro = await prisma.user.upsert({
where: { email: 'maria@advocacia.com' },
update: {},
create: {
name: 'Maria Silva',
email: 'maria@advocacia.com',
password: passwordHash,
role: 'ADVOGADO',
plan: 'PRO',
oabNumber: '123456',
oabState: 'SP',
credits: 100,
phone: '11988888888',
},
})
const advogadoFree = await prisma.user.upsert({
where: { email: 'joao@email.com' },
update: {},
create: {
name: 'João Santos',
email: 'joao@email.com',
password: passwordHash,
role: 'ADVOGADO',
plan: 'FREE',
oabNumber: '654321',
oabState: 'RJ',
credits: 5,
phone: '21977777777',
},
})
console.log('✅ Users created:', admin.name, advogadoPro.name, advogadoFree.name)
// ── Subscriptions ──
await prisma.subscription.createMany({
data: [
{ userId: admin.id, plan: 'ENTERPRISE', status: 'ACTIVE' },
{ userId: advogadoPro.id, plan: 'PRO', status: 'ACTIVE', stripeId: 'sub_mock_pro_001' },
{ userId: advogadoFree.id, plan: 'FREE', status: 'ACTIVE' },
],
})
// ── Templates ──
const templates = await Promise.all([
prisma.template.create({
data: {
name: 'Petição Inicial Cível',
description: 'Modelo de petição inicial para ações cíveis com fundamentação completa',
type: 'PETICAO_INICIAL',
area: 'CIVIL',
prompt: 'Elabore uma petição inicial cível com os seguintes dados: {{fatos}}. Inclua fundamentação jurídica com base no CPC e CC, pedidos e valor da causa.',
isPublic: true,
userId: admin.id,
},
}),
prisma.template.create({
data: {
name: 'Contestação Trabalhista',
description: 'Modelo de contestação para reclamações trabalhistas',
type: 'CONTESTACAO',
area: 'TRABALHISTA',
prompt: 'Elabore uma contestação trabalhista para a reclamação: {{fatos}}. Inclua preliminares, mérito e impugnação aos pedidos com base na CLT.',
isPublic: true,
userId: admin.id,
},
}),
prisma.template.create({
data: {
name: 'Recurso de Apelação',
description: 'Modelo de recurso de apelação com razões recursais',
type: 'APELACAO',
area: 'CIVIL',
prompt: 'Elabore um recurso de apelação com base na sentença: {{sentenca}}. Apresente razões recursais, error in judicando/procedendo e pedido de reforma.',
isPublic: true,
userId: admin.id,
},
}),
prisma.template.create({
data: {
name: 'Contrato de Prestação de Serviços',
description: 'Modelo de contrato de prestação de serviços advocatícios',
type: 'CONTRATO',
area: 'CIVIL',
prompt: 'Elabore um contrato de prestação de serviços entre {{contratante}} e {{contratado}}. Escopo: {{escopo}}. Valor: {{valor}}. Inclua cláusulas de confidencialidade, rescisão e foro.',
isPublic: true,
userId: admin.id,
},
}),
prisma.template.create({
data: {
name: 'Habeas Corpus',
description: 'Modelo de habeas corpus preventivo ou liberatório',
type: 'HABEAS_CORPUS',
area: 'PENAL',
prompt: 'Elabore um habeas corpus {{tipo}} em favor de {{paciente}}, contra ato de {{autoridade_coatora}}. Fatos: {{fatos}}. Fundamente no art. 5º, LXVIII da CF e arts. 647-667 do CPP.',
isPublic: true,
userId: admin.id,
},
}),
])
console.log('✅ Templates created:', templates.length)
// ── Jurisprudências ──
const jurisprudencias = await Promise.all([
prisma.jurisprudencia.create({
data: {
tribunal: 'STF',
numero: 'RE 1.322.076/SP',
ementa: 'RECURSO EXTRAORDINÁRIO. DIREITO DO CONSUMIDOR. RESPONSABILIDADE CIVIL. DANO MORAL. INSCRIÇÃO INDEVIDA EM CADASTRO DE INADIMPLENTES. A inscrição indevida do nome do consumidor em cadastros de proteção ao crédito configura dano moral in re ipsa, dispensando a comprovação do prejuízo efetivo. Recurso extraordinário não provido.',
data: '2024-03-15',
area: 'CONSUMIDOR',
relator: 'Min. Luís Roberto Barroso',
orgaoJulgador: 'Primeira Turma',
tags: JSON.stringify(['dano moral', 'consumidor', 'cadastro inadimplentes', 'SPC', 'Serasa']),
},
}),
prisma.jurisprudencia.create({
data: {
tribunal: 'STJ',
numero: 'REsp 2.045.123/RJ',
ementa: 'RECURSO ESPECIAL. DIREITO CIVIL. RESPONSABILIDADE CIVIL. ACIDENTE DE TRÂNSITO. DANOS MATERIAIS E MORAIS. QUANTUM INDENIZATÓRIO. Revisão do valor da indenização por danos morais quando se mostrar irrisório ou excessivo. Manutenção do quantum fixado pelo Tribunal de origem dentro dos parâmetros de razoabilidade.',
data: '2024-05-20',
area: 'CIVIL',
relator: 'Min. Nancy Andrighi',
orgaoJulgador: 'Terceira Turma',
tags: JSON.stringify(['acidente trânsito', 'dano moral', 'dano material', 'quantum indenizatório']),
},
}),
prisma.jurisprudencia.create({
data: {
tribunal: 'STF',
numero: 'ADI 6.341/DF',
ementa: 'AÇÃO DIRETA DE INCONSTITUCIONALIDADE. DIREITO ADMINISTRATIVO. COMPETÊNCIA CONCORRENTE. ESTADOS E MUNICÍPIOS. MEDIDAS SANITÁRIAS. Reconhecimento da competência concorrente de estados e municípios para adoção de medidas restritivas durante emergência sanitária. Interpretação conforme à Constituição.',
data: '2024-01-10',
area: 'ADMINISTRATIVO',
relator: 'Min. Marco Aurélio',
orgaoJulgador: 'Tribunal Pleno',
tags: JSON.stringify(['competência concorrente', 'saúde pública', 'federalismo', 'autonomia']),
},
}),
prisma.jurisprudencia.create({
data: {
tribunal: 'STJ',
numero: 'RHC 163.334/SC',
ementa: 'RECURSO EM HABEAS CORPUS. DIREITO PENAL. FURTO. PRINCÍPIO DA INSIGNIFICÂNCIA. CRIME DE BAGATELA. Aplicação do princípio da insignificância ao furto de bem avaliado em valor inferior a 10% do salário mínimo. Atipicidade material da conduta. Recurso provido.',
data: '2024-04-18',
area: 'PENAL',
relator: 'Min. Sebastião Reis Júnior',
orgaoJulgador: 'Sexta Turma',
tags: JSON.stringify(['furto', 'insignificância', 'bagatela', 'atipicidade']),
},
}),
prisma.jurisprudencia.create({
data: {
tribunal: 'STF',
numero: 'ARE 1.121.633/GO',
ementa: 'AGRAVO EM RECURSO EXTRAORDINÁRIO. DIREITO DO TRABALHO. TERCEIRIZAÇÃO. ATIVIDADE-FIM. LICITUDE. É lícita a terceirização ou qualquer outra forma de divisão do trabalho entre pessoas jurídicas distintas, independentemente do objeto social das empresas envolvidas, mantida a responsabilidade subsidiária da empresa contratante. Tema 725 de repercussão geral.',
data: '2024-02-28',
area: 'TRABALHISTA',
relator: 'Min. Gilmar Mendes',
orgaoJulgador: 'Tribunal Pleno',
tags: JSON.stringify(['terceirização', 'atividade-fim', 'responsabilidade subsidiária', 'tema 725']),
},
}),
prisma.jurisprudencia.create({
data: {
tribunal: 'STJ',
numero: 'REsp 1.869.043/SP',
ementa: 'RECURSO ESPECIAL. DIREITO TRIBUTÁRIO. ICMS. BASE DE CÁLCULO. EXCLUSÃO DO PIS E COFINS. Em consonância com o entendimento firmado pelo STF no Tema 69, o ICMS a ser excluído da base de cálculo do PIS e da COFINS é o destacado na nota fiscal. Recurso especial não provido.',
data: '2024-06-05',
area: 'TRIBUTARIO',
relator: 'Min. Herman Benjamin',
orgaoJulgador: 'Segunda Turma',
tags: JSON.stringify(['ICMS', 'PIS', 'COFINS', 'base de cálculo', 'tema 69']),
},
}),
prisma.jurisprudencia.create({
data: {
tribunal: 'STF',
numero: 'RE 898.060/SC',
ementa: 'RECURSO EXTRAORDINÁRIO. DIREITO DE FAMÍLIA. PATERNIDADE SOCIOAFETIVA. MULTIPARENTALIDADE. A paternidade socioafetiva, declarada ou não em registro público, não impede o reconhecimento do vínculo de filiação concomitante baseado na origem biológica, com os efeitos jurídicos próprios. Tema 622.',
data: '2024-03-22',
area: 'FAMILIA',
relator: 'Min. Luiz Fux',
orgaoJulgador: 'Tribunal Pleno',
tags: JSON.stringify(['paternidade socioafetiva', 'multiparentalidade', 'filiação', 'tema 622']),
},
}),
prisma.jurisprudencia.create({
data: {
tribunal: 'STJ',
numero: 'REsp 1.951.532/RS',
ementa: 'RECURSO ESPECIAL. DIREITO EMPRESARIAL. RECUPERAÇÃO JUDICIAL. PLANO DE RECUPERAÇÃO. CRAM DOWN. Possibilidade de aprovação do plano de recuperação judicial pelo juiz mesmo sem a concordância de todas as classes de credores, desde que preenchidos os requisitos do art. 58, §1º da Lei 11.101/2005.',
data: '2024-07-12',
area: 'EMPRESARIAL',
relator: 'Min. Ricardo Villas Bôas Cueva',
orgaoJulgador: 'Terceira Turma',
tags: JSON.stringify(['recuperação judicial', 'cram down', 'plano recuperação', 'credores']),
},
}),
prisma.jurisprudencia.create({
data: {
tribunal: 'STF',
numero: 'HC 124.306/RJ',
ementa: 'HABEAS CORPUS. DIREITO PENAL. ABORTO. INTERRUPÇÃO VOLUNTÁRIA DA GESTAÇÃO. PRIMEIRO TRIMESTRE. É preciso conferir interpretação conforme a Constituição aos arts. 124 a 126 do Código Penal para excluir do seu âmbito de incidência a interrupção voluntária da gestação efetivada no primeiro trimestre.',
data: '2024-08-01',
area: 'PENAL',
relator: 'Min. Luís Roberto Barroso',
orgaoJulgador: 'Primeira Turma',
tags: JSON.stringify(['aborto', 'primeiro trimestre', 'interpretação conforme', 'direitos fundamentais']),
},
}),
prisma.jurisprudencia.create({
data: {
tribunal: 'STJ',
numero: 'REsp 1.733.013/PR',
ementa: 'RECURSO ESPECIAL. DIREITO DO CONSUMIDOR. COMÉRCIO ELETRÔNICO. RESPONSABILIDADE DO MARKETPLACE. A plataforma digital que intermedia a venda de produtos responde solidariamente pelos vícios e defeitos dos produtos comercializados por terceiros em seu ambiente virtual, nos termos do CDC.',
data: '2024-09-10',
area: 'CONSUMIDOR',
relator: 'Min. Paulo de Tarso Sanseverino',
orgaoJulgador: 'Terceira Turma',
tags: JSON.stringify(['marketplace', 'comércio eletrônico', 'responsabilidade solidária', 'CDC', 'plataforma digital']),
},
}),
])
console.log('✅ Jurisprudências created:', jurisprudencias.length)
// ── Sample Documents ──
const documents = await Promise.all([
prisma.document.create({
data: {
userId: advogadoPro.id,
type: 'PETICAO_INICIAL',
title: 'Petição Inicial - Ação de Indenização por Danos Morais',
prompt: 'Elaborar petição inicial para ação de indenização por danos morais decorrente de inscrição indevida no SPC',
content: 'EXCELENTÍSSIMO SENHOR DOUTOR JUIZ DE DIREITO DA ___ VARA CÍVEL DA COMARCA DE SÃO PAULO/SP\n\nMARIA DA SILVA, brasileira, solteira, professora, portadora do RG nº 12.345.678-9 e CPF nº 123.456.789-00, residente e domiciliada na Rua das Flores, nº 100, Jardim Primavera, São Paulo/SP, CEP 01234-567, vem, respeitosamente, à presença de Vossa Excelência, por intermédio de seus procuradores que esta subscrevem, propor a presente AÇÃO DE INDENIZAÇÃO POR DANOS MORAIS em face de BANCO XYZ S/A...\n\n[Documento completo gerado pela IA]',
wordCount: 2500,
status: 'COMPLETED',
area: 'CONSUMIDOR',
tokens: 3200,
cost: 0.032,
},
}),
prisma.document.create({
data: {
userId: advogadoPro.id,
type: 'CONTESTACAO',
title: 'Contestação - Reclamação Trabalhista',
prompt: 'Elaborar contestação para reclamação trabalhista sobre horas extras e adicional noturno',
content: 'EXCELENTÍSSIMO SENHOR DOUTOR JUIZ DO TRABALHO DA ___ VARA DO TRABALHO DE SÃO PAULO/SP\n\nProcesso nº 0001234-56.2024.5.02.0001\n\nEMPRESA ABC LTDA, pessoa jurídica de direito privado, inscrita no CNPJ sob nº 12.345.678/0001-90, com sede na Avenida Paulista, nº 1000, São Paulo/SP, vem, respeitosamente, apresentar CONTESTAÇÃO à reclamação trabalhista movida por JOÃO CARLOS SOUZA...\n\n[Documento completo gerado pela IA]',
wordCount: 3100,
status: 'COMPLETED',
area: 'TRABALHISTA',
tokens: 4100,
cost: 0.041,
},
}),
prisma.document.create({
data: {
userId: advogadoFree.id,
type: 'CONTRATO',
title: 'Contrato de Honorários Advocatícios',
prompt: 'Elaborar contrato de honorários para consultoria jurídica empresarial mensal',
content: 'CONTRATO DE PRESTAÇÃO DE SERVIÇOS ADVOCATÍCIOS\n\nPelo presente instrumento particular, de um lado EMPRESA DELTA COMÉRCIO LTDA como CONTRATANTE e de outro a DRA. JOÃO SANTOS - OAB/RJ 654321 como CONTRATADO...\n\n[Documento completo gerado pela IA]',
wordCount: 1800,
status: 'COMPLETED',
area: 'EMPRESARIAL',
tokens: 2200,
cost: 0.022,
},
}),
])
console.log('✅ Documents created:', documents.length)
// ── Sample Chat ──
const chat = await prisma.chat.create({
data: {
userId: advogadoPro.id,
title: 'Dúvida sobre prescrição em ação de cobrança',
messages: {
create: [
{
role: 'USER',
content: 'Qual o prazo prescricional para ação de cobrança de dívida por contrato verbal?',
tokens: 25,
},
{
role: 'ASSISTANT',
content: 'O prazo prescricional para ação de cobrança de dívida originada de contrato verbal é de **10 anos**, conforme o art. 205 do Código Civil de 2002, que estabelece o prazo prescricional genérico. Contudo, se houver documento escrito (como um título de crédito), o prazo pode ser diferente:\n\n- **Cheque:** 6 meses (art. 59, Lei 7.357/85)\n- **Nota promissória:** 3 anos (art. 70, LUG)\n- **Duplicata:** 3 anos (art. 18, Lei 5.474/68)\n\nImportante: O prazo começa a contar da data do vencimento da dívida (art. 199, CC).',
tokens: 180,
},
],
},
},
})
console.log('✅ Chat created:', chat.title)
// ── Usage Logs ──
await prisma.usageLog.createMany({
data: [
{ userId: advogadoPro.id, type: 'DOCUMENT', tokens: 3200, cost: 0.032 },
{ userId: advogadoPro.id, type: 'DOCUMENT', tokens: 4100, cost: 0.041 },
{ userId: advogadoPro.id, type: 'CHAT', tokens: 205, cost: 0.002 },
{ userId: advogadoFree.id, type: 'DOCUMENT', tokens: 2200, cost: 0.022 },
{ userId: advogadoPro.id, type: 'JURISPRUDENCIA', tokens: 150, cost: 0.001 },
],
})
console.log('✅ Usage logs created')
// ── API Keys ──
await prisma.apiKey.create({
data: {
key: 'jur_live_sk_' + 'a'.repeat(32),
name: 'API Principal',
userId: advogadoPro.id,
active: true,
},
})
console.log('✅ API keys created')
console.log('\n🎉 Seed completed successfully!')
console.log('\n📋 Login credentials (all passwords: 123456):')
console.log(' Admin: admin@juridico.ai')
console.log(' Pro: maria@advocacia.com')
console.log(' Free: joao@email.com')
}
main()
.then(async () => {
await prisma.$disconnect()
})
.catch(async (e) => {
console.error(e)
await prisma.$disconnect()
process.exit(1)
})

5
public/favicon.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#0a0f1a"/>
<path d="M16 3L28 9.5v13L16 29 4 22.5v-13L16 3z" fill="#0d9488" opacity="0.2"/>
<text x="16" y="22" text-anchor="middle" font-family="Georgia, serif" font-size="16" font-weight="700" fill="#2dd4bf">§</text>
</svg>

After

Width:  |  Height:  |  Size: 347 B

18
public/logo.svg Normal file
View File

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
<!-- Outer hexagonal frame -->
<path d="M24 2L42.5 13v22L24 46 5.5 35V13L24 2z" stroke="#14b8a6" stroke-width="1.5" fill="none" opacity="0.3"/>
<!-- Inner shield shape -->
<path d="M24 7L38 15.5v17L24 41 10 32.5v-17L24 7z" fill="#0d9488" opacity="0.15"/>
<!-- § symbol stylized -->
<text x="24" y="31" text-anchor="middle" font-family="Georgia, serif" font-size="22" font-weight="700" fill="#2dd4bf">§</text>
<!-- Neural connection dots -->
<circle cx="12" cy="18" r="1.5" fill="#14b8a6" opacity="0.6"/>
<circle cx="36" cy="18" r="1.5" fill="#14b8a6" opacity="0.6"/>
<circle cx="12" cy="30" r="1.5" fill="#14b8a6" opacity="0.6"/>
<circle cx="36" cy="30" r="1.5" fill="#14b8a6" opacity="0.6"/>
<!-- Neural lines -->
<line x1="12" y1="18" x2="20" y2="22" stroke="#14b8a6" stroke-width="0.7" opacity="0.4"/>
<line x1="36" y1="18" x2="28" y2="22" stroke="#14b8a6" stroke-width="0.7" opacity="0.4"/>
<line x1="12" y1="30" x2="20" y2="26" stroke="#14b8a6" stroke-width="0.7" opacity="0.4"/>
<line x1="36" y1="30" x2="28" y2="26" stroke="#14b8a6" stroke-width="0.7" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,169 @@
#!/usr/bin/env npx ts-node
/**
* Script de Busca Diária de Publicações
*
* Executa busca de publicações em todos os processos ativos de todos os usuários.
* Pode ser executado via cron ou manualmente.
*
* Uso: npx ts-node scripts/buscar-publicacoes.ts
* Ou: node dist/scripts/buscar-publicacoes.js (se compilado)
*/
import { PrismaClient } from '@prisma/client'
import { buscarPublicacoesReais, buscarPublicacoesEmLote, PublicacaoEncontrada } from '../src/lib/diarios-service'
const prisma = new PrismaClient()
interface BuscaStats {
totalProcessos: number
processosComNovas: number
totalPublicacoes: number
erros: string[]
inicioEm: Date
fimEm?: Date
}
async function buscarPublicacoesDiarias(): Promise<BuscaStats> {
const stats: BuscaStats = {
totalProcessos: 0,
processosComNovas: 0,
totalPublicacoes: 0,
erros: [],
inicioEm: new Date(),
}
console.log('='.repeat(60))
console.log(`[${new Date().toISOString()}] Iniciando busca diária de publicações`)
console.log('='.repeat(60))
try {
// Busca todos os processos ativos
const processos = await prisma.processoMonitorado.findMany({
where: { status: 'ATIVO' },
select: {
id: true,
numeroProcesso: true,
tribunal: true,
userId: true,
user: {
select: { email: true }
}
},
})
stats.totalProcessos = processos.length
console.log(`\n📋 Total de processos ativos: ${processos.length}`)
if (processos.length === 0) {
console.log('Nenhum processo ativo encontrado.')
return stats
}
// Busca publicações em lote com rate limiting (500ms entre requisições)
console.log('\n🔍 Buscando publicações nos diários oficiais...\n')
const resultados = await buscarPublicacoesEmLote(
processos.map(p => ({
id: p.id,
numeroProcesso: p.numeroProcesso,
tribunal: p.tribunal,
})),
500 // delay entre requisições
)
// Processa resultados
for (const processo of processos) {
const resultado = resultados.get(processo.id)
if (!resultado) {
stats.erros.push(`${processo.numeroProcesso}: Sem resultado`)
continue
}
if (!resultado.sucesso) {
stats.erros.push(`${processo.numeroProcesso}: ${resultado.erro}`)
continue
}
let novasDoProcesso = 0
for (const pub of resultado.publicacoes) {
// Verifica duplicata
const existing = await prisma.publicacao.findFirst({
where: {
processoId: processo.id,
dataPublicacao: {
gte: new Date(pub.dataPublicacao.toDateString()),
lt: new Date(new Date(pub.dataPublicacao).setDate(pub.dataPublicacao.getDate() + 1)),
},
tipo: pub.tipo,
},
})
if (!existing) {
await prisma.publicacao.create({
data: {
processoId: processo.id,
dataPublicacao: pub.dataPublicacao,
diario: pub.diario,
conteudo: pub.conteudo,
tipo: pub.tipo,
prazoCalculado: pub.prazoCalculado,
prazoTipo: pub.prazoTipo,
visualizado: false,
},
})
novasDoProcesso++
stats.totalPublicacoes++
}
}
if (novasDoProcesso > 0) {
stats.processosComNovas++
console.log(`${processo.numeroProcesso}: ${novasDoProcesso} nova(s) publicação(ões)`)
} else {
console.log(` ${processo.numeroProcesso}: sem novas publicações`)
}
}
} catch (error) {
const msg = error instanceof Error ? error.message : 'Erro desconhecido'
stats.erros.push(`Erro geral: ${msg}`)
console.error('\n❌ Erro durante execução:', error)
} finally {
await prisma.$disconnect()
}
stats.fimEm = new Date()
// Resumo final
console.log('\n' + '='.repeat(60))
console.log('📊 RESUMO DA BUSCA')
console.log('='.repeat(60))
console.log(`Processos verificados: ${stats.totalProcessos}`)
console.log(`Processos com novas publicações: ${stats.processosComNovas}`)
console.log(`Total de novas publicações: ${stats.totalPublicacoes}`)
console.log(`Erros: ${stats.erros.length}`)
if (stats.erros.length > 0) {
console.log('\n⚠ Erros encontrados:')
stats.erros.forEach(e => console.log(` - ${e}`))
}
const duracao = ((stats.fimEm.getTime() - stats.inicioEm.getTime()) / 1000).toFixed(1)
console.log(`\n⏱ Tempo de execução: ${duracao}s`)
console.log(`[${stats.fimEm.toISOString()}] Busca finalizada\n`)
return stats
}
// Executar
buscarPublicacoesDiarias()
.then(stats => {
process.exit(stats.erros.length > 0 ? 1 : 0)
})
.catch(error => {
console.error('Erro fatal:', error)
process.exit(1)
})

36
scripts/setup-stripe.ts Normal file
View File

@@ -0,0 +1,36 @@
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-04-30.basil' as any,
})
const plans = [
{ name: 'Teste', amount: 1, interval: 'month' as const },
{ name: 'Starter', amount: 9700, interval: 'month' as const },
{ name: 'Pro', amount: 19700, interval: 'month' as const },
{ name: 'Enterprise', amount: 49700, interval: 'month' as const },
]
async function main() {
console.log('Creating Stripe products and prices...\n')
for (const plan of plans) {
const product = await stripe.products.create({
name: `LexMind ${plan.name}`,
description: `Plano ${plan.name} - LexMind`,
})
const price = await stripe.prices.create({
product: product.id,
unit_amount: plan.amount,
currency: 'brl',
recurring: { interval: plan.interval },
})
console.log(`${plan.name}: product=${product.id} price=${price.id} (R$${(plan.amount / 100).toFixed(2)}/month)`)
}
console.log('\nDone! Copy the price IDs above into your code.')
}
main().catch(console.error)

102
scripts/testar-datajud.ts Normal file
View File

@@ -0,0 +1,102 @@
#!/usr/bin/env npx ts-node
/**
* Script de Teste da API DataJud
* Testa a conexão e busca um processo de exemplo
*/
// API Key pública do DataJud (CNJ)
const DATAJUD_API_KEY = 'cDZHYzlZa0JadVREZDJCendQbXY6SkJlTzNjLV9TRENyQk1RdnFKZGRQdw=='
const DATAJUD_BASE_URL = 'https://api-publica.datajud.cnj.jus.br'
async function testarDataJud() {
console.log('='.repeat(60))
console.log('🧪 Testando conexão com API DataJud (CNJ)')
console.log('='.repeat(60))
// Teste 1: Buscar um processo aleatório
console.log('\n1. Buscando processo de exemplo no TJSP...')
const url = `${DATAJUD_BASE_URL}/api_publica_tjsp/_search`
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `APIKey ${DATAJUD_API_KEY}`,
},
body: JSON.stringify({
size: 1,
query: {
bool: {
must: [
{ exists: { field: 'movimentos' } }
],
filter: [
{ range: { '@timestamp': { gte: 'now-7d' } } }
]
}
}
}),
})
if (!response.ok) {
console.log(`❌ Erro HTTP: ${response.status}`)
const text = await response.text()
console.log(text.substring(0, 500))
return
}
const data = await response.json()
console.log(`✅ Resposta OK - ${data.hits?.total?.value || 0} processos no índice`)
if (data.hits?.hits?.length > 0) {
const processo = data.hits.hits[0]._source
console.log(`\n📄 Processo encontrado:`)
console.log(` Número: ${processo.numeroProcesso}`)
console.log(` Classe: ${processo.classe?.nome || 'N/A'}`)
console.log(` Tribunal: ${processo.tribunal}`)
console.log(` Movimentos: ${processo.movimentos?.length || 0}`)
// Mostrar últimos movimentos
const ultimos = (processo.movimentos || []).slice(-5)
console.log('\n Últimos movimentos:')
ultimos.forEach((m: any) => {
console.log(` - ${m.dataHora?.substring(0, 10)} | ${m.nome} (código: ${m.codigo})`)
})
}
// Teste 2: Buscar processo específico (se existir)
console.log('\n\n2. Testando busca por número específico...')
const response2 = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `APIKey ${DATAJUD_API_KEY}`,
},
body: JSON.stringify({
size: 1,
query: {
match: {
numeroProcesso: '10000000020248260100' // Exemplo fictício
}
}
}),
})
const data2 = await response2.json()
const found = data2.hits?.hits?.length > 0
console.log(` Processo de teste: ${found ? '✅ Encontrado' : ' Não encontrado (esperado)'}`)
console.log('\n' + '='.repeat(60))
console.log('✅ API DataJud funcionando corretamente!')
console.log('='.repeat(60))
} catch (error) {
console.error('❌ Erro ao conectar:', error)
}
}
testarDataJud()

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env npx ts-node
/**
* Teste de Integração Completa
* Testa buscarPublicacoesReais com um número de processo real
*/
import { buscarPublicacoesReais, buscarDataJud } from '../src/lib/diarios-service'
async function testarIntegracao() {
console.log('='.repeat(60))
console.log('🧪 Teste de Integração - Busca de Publicações')
console.log('='.repeat(60))
// Processo de teste (usando um real do TJSP que sabemos existir)
const processoTeste = {
id: 'teste-001',
numeroProcesso: '1000044-50.2025.8.26.0220',
tribunal: 'TJSP',
}
console.log(`\n📋 Processo de teste:`)
console.log(` Número: ${processoTeste.numeroProcesso}`)
console.log(` Tribunal: ${processoTeste.tribunal}`)
console.log('\n🔍 Buscando publicações...\n')
try {
const resultado = await buscarPublicacoesReais(processoTeste, 365) // Últimos 365 dias
console.log(`✅ Busca concluída!`)
console.log(` Sucesso: ${resultado.sucesso}`)
console.log(` Fonte: ${resultado.fonte}`)
console.log(` Publicações encontradas: ${resultado.publicacoes.length}`)
if (resultado.erro) {
console.log(` Erro: ${resultado.erro}`)
}
if (resultado.publicacoes.length > 0) {
console.log('\n📰 Publicações encontradas:')
resultado.publicacoes.slice(0, 10).forEach((pub, i) => {
console.log(`\n ${i + 1}. ${pub.tipo}`)
console.log(` Data: ${pub.dataPublicacao.toISOString().split('T')[0]}`)
console.log(` Prazo: ${pub.prazoCalculado?.toISOString().split('T')[0]} (${pub.prazoTipo})`)
console.log(` Conteúdo: ${pub.conteudo.substring(0, 100)}...`)
})
}
// Testar também busca direta no DataJud
console.log('\n\n🔬 Testando busca direta DataJud (processo diferente)...')
const resultado2 = await buscarDataJud('0020077-82.2022.8.26.0576', 'TJSP')
console.log(` Publicações: ${resultado2.publicacoes.length}`)
} catch (error) {
console.error('❌ Erro:', error)
}
console.log('\n' + '='.repeat(60))
console.log('✅ Teste de integração finalizado')
console.log('='.repeat(60))
}
testarIntegracao()

View File

@@ -0,0 +1,84 @@
#!/usr/bin/env npx ts-node
/**
* Teste Standalone da API DataJud
* Não depende de imports externos
*/
const DATAJUD_API_KEY = 'cDZHYzlZa0JadVREZDJCendQbXY6SkJlTzNjLV9TRENyQk1RdnFKZGRQdw=='
interface Movimento {
codigo: number
nome: string
dataHora: string
orgaoJulgador?: { nome: string }
complementosTabelados?: Array<{ nome: string }>
}
async function buscarDataJud(numeroProcesso: string, tribunal: string) {
const endpoint = `api_publica_${tribunal.toLowerCase()}`
const url = `https://api-publica.datajud.cnj.jus.br/${endpoint}/_search`
const numeroLimpo = numeroProcesso.replace(/\D/g, '')
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `APIKey ${DATAJUD_API_KEY}`,
},
body: JSON.stringify({
size: 1,
query: { match: { numeroProcesso: numeroLimpo } },
}),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`)
}
return await response.json()
}
async function main() {
console.log('='.repeat(60))
console.log('🧪 Teste Standalone - API DataJud')
console.log('='.repeat(60))
// Teste com processo que encontramos antes
const processoNumero = '1000044-50.2025.8.26.0220'
console.log(`\n📋 Buscando: ${processoNumero}`)
try {
const data = await buscarDataJud(processoNumero, 'TJSP')
const hits = data.hits?.hits || []
if (hits.length === 0) {
console.log('❌ Processo não encontrado')
return
}
const processo = hits[0]._source
console.log(`\n✅ Processo encontrado!`)
console.log(` Classe: ${processo.classe?.nome}`)
console.log(` Órgão: ${processo.orgaoJulgador?.nome}`)
console.log(` Total movimentos: ${processo.movimentos?.length || 0}`)
// Filtrar publicações
const publicacoes = (processo.movimentos || []).filter((m: Movimento) =>
m.codigo === 92 || // Publicação
m.nome?.toLowerCase().includes('publicação') ||
m.nome?.toLowerCase().includes('intimação') ||
m.nome?.toLowerCase().includes('citação')
)
console.log(`\n📰 Publicações/Intimações (${publicacoes.length}):`)
publicacoes.slice(0, 10).forEach((m: Movimento, i: number) => {
console.log(` ${i + 1}. ${m.dataHora?.substring(0, 10)} | ${m.nome}`)
})
} catch (error) {
console.error('❌ Erro:', error)
}
}
main()

1191
seed-jurisprudencia.js Normal file

File diff suppressed because it is too large Load Diff

85
src/app/FAQSection.tsx Normal file
View File

@@ -0,0 +1,85 @@
'use client'
import { useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
const faqs = [
{
q: 'O LexMind dispensa a atuação do advogado?',
a: 'De forma alguma. O LexMind é um instrumento de produtividade e assistência técnica. Toda peça produzida pela plataforma deve passar pela revisão e validação do profissional responsável. A inteligência artificial potencializa o trabalho humano — não o substitui.',
},
{
q: 'Quais categorias de documentos posso gerar?',
a: 'A plataforma cobre um amplo espectro: petições iniciais, contestações, apelações, agravos, embargos, pareceres jurídicos, contratos, notificações extrajudiciais, habeas corpus, mandados de segurança e dezenas de outros modelos adaptados às principais áreas do ordenamento brasileiro.',
},
{
q: 'Qual o nível de confiabilidade das peças geradas?',
a: 'Nossa IA opera com base em legislação atualizada e jurisprudência consolidada dos tribunais superiores e estaduais. A taxa de aproveitamento integral supera 90%, mas ressaltamos: a responsabilidade técnica permanece com o advogado subscritor, conforme o Código de Ética da OAB.',
},
{
q: 'Como é tratada a confidencialidade dos dados?',
a: 'Com rigor absoluto. Empregamos criptografia AES-256 para dados em repouso e TLS 1.3 em trânsito. Seus dados e os de seus clientes jamais são utilizados para treinamento de modelos. Operamos em total conformidade com a LGPD e as diretrizes de sigilo profissional.',
},
{
q: 'É possível cancelar a assinatura sem penalidade?',
a: 'Sim, a qualquer momento, sem multa ou burocracia. O cancelamento pode ser feito diretamente pelo painel de controle. Seu acesso permanece ativo até o término do ciclo vigente.',
},
{
q: 'Há condições especiais para escritórios ou membros da OAB?',
a: 'Oferecemos descontos para portadores de carteira da OAB e planos diferenciados para escritórios com mais de 5 advogados. Entre em contato pela plataforma ou por e-mail para uma proposta personalizada.',
},
]
export function FAQ() {
const [openIndex, setOpenIndex] = useState<number | null>(null)
return (
<div className="space-y-3">
{faqs.map((faq, i) => {
const isOpen = openIndex === i
return (
<motion.div
key={i}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-50px' }}
transition={{ delay: i * 0.05, duration: 0.4 }}
className={`rounded-2xl border transition-colors duration-300 overflow-hidden ${
isOpen
? 'border-teal-500/20 bg-teal-500/[0.03]'
: 'border-white/[0.06] bg-white/[0.015] hover:border-white/[0.1]'
}`}
>
<button
onClick={() => setOpenIndex(isOpen ? null : i)}
className="w-full flex items-center justify-between px-6 py-5 text-left group"
>
<span className="text-[15px] font-medium text-white pr-4 leading-snug">{faq.q}</span>
<motion.div
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ duration: 0.2 }}
className="shrink-0"
>
<ChevronDown className="w-4 h-4 text-gray-500 group-hover:text-teal-400 transition-colors" />
</motion.div>
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: 'easeInOut' }}
className="overflow-hidden"
>
<div className="px-6 pb-5 text-sm text-gray-400 leading-relaxed">{faq.a}</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)
})}
</div>
)
}

111
src/app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,111 @@
"use client"
import { useSession } from "next-auth/react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { usePathname } from "next/navigation"
import {
LayoutDashboard,
Users,
FileText,
BookOpen,
FileStack,
Settings,
Shield,
ArrowLeft,
} from "lucide-react"
import { useEffect } from "react"
const sidebarItems = [
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard },
{ href: "/admin/users", label: "Usuários", icon: Users },
{ href: "/admin/documents", label: "Documentos", icon: FileText },
{ href: "/admin/templates", label: "Templates", icon: FileStack },
{ href: "/admin/jurisprudencia", label: "Jurisprudência", icon: BookOpen },
{ href: "/admin/settings", label: "Configurações", icon: Settings },
]
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const { data: session, status } = useSession()
const router = useRouter()
const pathname = usePathname()
useEffect(() => {
if (status === "loading") return
if (!session?.user) {
router.push("/login")
return
}
if (session.user.role !== "ADMIN") {
router.push("/dashboard")
}
}, [session, status, router])
if (status === "loading") {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-teal-500" />
</div>
)
}
if (!session?.user || session.user.role !== "ADMIN") {
return null
}
return (
<div className="min-h-screen bg-gray-950 flex">
{/* Sidebar */}
<aside className="w-64 bg-gray-900 border-r border-gray-800 flex flex-col">
<div className="p-6 border-b border-gray-800">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-teal-600 flex items-center justify-center">
<Shield className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="text-white font-bold text-lg">Admin</h1>
<p className="text-gray-400 text-xs">LexMind</p>
</div>
</div>
</div>
<nav className="flex-1 p-4 space-y-1">
{sidebarItems.map((item) => {
const isActive = pathname === item.href ||
(item.href !== "/admin" && pathname.startsWith(item.href))
const Icon = item.icon
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-all ${
isActive
? "bg-teal-600/20 text-teal-400 border border-teal-500/30"
: "text-gray-400 hover:text-white hover:bg-gray-800"
}`}
>
<Icon className="w-5 h-5" />
{item.label}
</Link>
)
})}
</nav>
<div className="p-4 border-t border-gray-800">
<Link
href="/dashboard"
className="flex items-center gap-3 px-4 py-3 rounded-lg text-sm text-gray-400 hover:text-white hover:bg-gray-800 transition-all"
>
<ArrowLeft className="w-5 h-5" />
Voltar ao Dashboard
</Link>
</div>
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto">
<div className="p-8">{children}</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,80 @@
import { getServerSession } from "next-auth"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { NextResponse } from "next/server"
export async function GET() {
const session = await getServerSession(authOptions)
if (!session?.user || session.user.role !== "ADMIN") {
return NextResponse.json({ error: "Não autorizado" }, { status: 403 })
}
const now = new Date()
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
const [
totalUsers,
totalDocuments,
activeSubscriptions,
recentUsers,
recentDocuments,
planDistribution,
] = await Promise.all([
prisma.user.count(),
prisma.document.count(),
prisma.subscription.count({ where: { status: "ACTIVE" } }),
prisma.user.findMany({
where: { createdAt: { gte: thirtyDaysAgo } },
select: { createdAt: true },
orderBy: { createdAt: "asc" },
}),
prisma.document.findMany({
where: { createdAt: { gte: thirtyDaysAgo } },
select: { createdAt: true },
orderBy: { createdAt: "asc" },
}),
prisma.user.groupBy({
by: ["plan"],
_count: { plan: true },
}),
])
// Aggregate signups by day
const signupsByDay: Record<string, number> = {}
const docsByDay: Record<string, number> = {}
for (let i = 29; i >= 0; i--) {
const d = new Date(now.getTime() - i * 24 * 60 * 60 * 1000)
const key = d.toISOString().slice(0, 10)
signupsByDay[key] = 0
docsByDay[key] = 0
}
for (const u of recentUsers) {
const key = u.createdAt.toISOString().slice(0, 10)
if (signupsByDay[key] !== undefined) signupsByDay[key]++
}
for (const d of recentDocuments) {
const key = d.createdAt.toISOString().slice(0, 10)
if (docsByDay[key] !== undefined) docsByDay[key]++
}
// Mock revenue based on subscriptions
const plans: Record<string, number> = {}
for (const p of planDistribution) {
plans[p.plan] = p._count.plan
}
const mockRevenue =
(plans["PRO"] || 0) * 97 + (plans["ENTERPRISE"] || 0) * 297
return NextResponse.json({
totalUsers,
totalDocuments,
activeSubscriptions,
monthlyRevenue: mockRevenue,
signupsByDay: Object.entries(signupsByDay).map(([date, count]) => ({ date, count })),
docsByDay: Object.entries(docsByDay).map(([date, count]) => ({ date, count })),
planDistribution: plans,
})
}

View File

@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import OpenAI from 'openai'
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const { id } = await params
const analysis = await prisma.processAnalysis.findFirst({
where: {
id,
userId: session.user.id,
status: 'DONE',
},
select: {
id: true,
title: true,
filename: true,
analysis: true,
extractedText: true,
},
})
if (!analysis) {
return NextResponse.json({ error: 'Análise não encontrada' }, { status: 404 })
}
try {
const completion = await openai.chat.completions.create({
model: 'gpt-4o-mini',
temperature: 0.3,
messages: [
{
role: 'system',
content: `Você é um assistente jurídico especializado. Extraia informações estruturadas do parecer jurídico fornecido.
Responda APENAS com JSON válido, sem markdown, sem code blocks. O formato deve ser:
{
"autor": "nome do autor/requerente ou string vazia",
"reu": "nome do réu/requerido ou string vazia",
"fatos": "resumo dos fatos principais",
"fundamentos": "fundamentos jurídicos identificados (artigos, leis, jurisprudência)",
"pedidos": "pedidos sugeridos com base na análise",
"area": "uma das opções: civil, trabalhista, penal, tributario, familia, empresarial, consumidor, administrativo"
}`,
},
{
role: 'user',
content: `Extraia as informações do seguinte parecer jurídico:\n\n${analysis.analysis.substring(0, 8000)}`,
},
],
})
const content = completion.choices[0]?.message?.content || '{}'
let extracted
try {
extracted = JSON.parse(content)
} catch {
// Try to extract JSON from the response if it has markdown wrapping
const jsonMatch = content.match(/\{[\s\S]*\}/)
extracted = jsonMatch ? JSON.parse(jsonMatch[0]) : {}
}
return NextResponse.json({
analysisId: analysis.id,
filename: analysis.filename,
title: analysis.title,
parecer: analysis.analysis,
extracted: {
autor: extracted.autor || '',
reu: extracted.reu || '',
fatos: extracted.fatos || '',
fundamentos: extracted.fundamentos || '',
pedidos: extracted.pedidos || '',
area: extracted.area || 'civil',
},
})
} catch (error) {
console.error('Error extracting petition data:', error)
return NextResponse.json(
{ error: 'Erro ao extrair dados do parecer' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const { id } = await params
const analysis = await prisma.processAnalysis.findFirst({
where: {
id,
userId: session.user.id,
},
select: {
id: true,
title: true,
filename: true,
fileSize: true,
extractedText: true,
analysis: true,
summary: true,
status: true,
createdAt: true,
},
})
if (!analysis) {
return NextResponse.json({ error: 'Análise não encontrada' }, { status: 404 })
}
return NextResponse.json({ analysis })
}

View File

@@ -0,0 +1,254 @@
export const config = { api: { bodyParser: false } };
export const maxDuration = 120;
export const dynamic = "force-dynamic";
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { uploadFile, buildKey } from '@/lib/spaces'
import OpenAI from 'openai'
import pdfParse from "pdf-parse/lib/pdf-parse.js"
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
const SYSTEM_PROMPT = `Você é um jurista especialista em Direito brasileiro com amplo conhecimento da legislação vigente.
Analise o processo/documento jurídico a seguir e forneça um parecer completo e detalhado.
Sua análise DEVE incluir:
## 1. Resumo do Processo
Breve resumo dos fatos, partes envolvidas e objeto da ação.
## 2. Tipo de Ação e Rito Processual
Identifique o tipo de ação, o rito processual aplicável e a competência.
## 3. Análise dos Fundamentos Jurídicos
Analise os fundamentos de direito apresentados, verificando:
- Adequação legal
- Citações de artigos de lei
- Jurisprudência aplicável
## 4. Pontos Fortes
Identifique os argumentos mais sólidos e bem fundamentados.
## 5. Pontos Fracos e Vulnerabilidades
Identifique falhas, lacunas ou argumentos frágeis que podem ser explorados pela parte contrária.
## 6. Legislação Aplicável Atualizada
Liste TODA a legislação relevante com artigos específicos:
- Constituição Federal de 1988
- Códigos (CPC, CC, CLT, CDC, CP, CTN, etc.)
- Leis especiais aplicáveis
- Súmulas vinculantes e súmulas do STJ/STF relevantes
## 7. Jurisprudência Relevante
Cite decisões recentes dos tribunais superiores (STF, STJ, TST) e tribunais estaduais que se aplicam ao caso.
## 8. Parecer e Recomendações
Apresente seu parecer sobre:
- Chances de êxito (alta/média/baixa)
- Estratégia processual recomendada
- Providências urgentes, se houver
- Recursos cabíveis
- Prazos importantes a observar
## 9. Conclusão
Síntese final com recomendação objetiva.
Use linguagem técnica jurídica. Cite artigos de lei sempre que possível. Base sua análise na legislação brasileira vigente em 2025/2026.`
const CREDIT_COST = 5
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const analyses = await prisma.processAnalysis.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'desc' },
select: {
id: true,
title: true,
filename: true,
fileSize: true,
status: true,
summary: true,
createdAt: true,
},
})
return NextResponse.json({ analyses })
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
// Check credits
const user = await prisma.user.findUnique({ where: { id: session.user.id } })
if (!user || user.credits < CREDIT_COST) {
return NextResponse.json(
{ error: `Créditos insuficientes. A análise de processo custa ${CREDIT_COST} créditos.` },
{ status: 403 }
)
}
try {
const formData = await req.formData()
const file = formData.get('file') as File | null
if (!file) {
return NextResponse.json({ error: 'Nenhum arquivo enviado' }, { status: 400 })
}
if (file.type !== 'application/pdf') {
return NextResponse.json({ error: 'Apenas arquivos PDF são aceitos' }, { status: 400 })
}
if (file.size > 50 * 1024 * 1024) {
return NextResponse.json({ error: 'Arquivo muito grande. Máximo: 50MB' }, { status: 400 })
}
const buffer = Buffer.from(await file.arrayBuffer())
// Extract text from PDF
let extractedText: string
try {
const pdfData = await pdfParse(buffer)
extractedText = pdfData.text
} catch (pdfErr) {
console.error('PDF parse error:', pdfErr)
return NextResponse.json({ error: 'Erro ao ler o PDF. Verifique se o arquivo não está corrompido.' }, { status: 400 })
}
if (!extractedText || extractedText.trim().length < 50) {
return NextResponse.json(
{ error: 'Não foi possível extrair texto suficiente do PDF. O arquivo pode ser uma imagem digitalizada.' },
{ status: 400 }
)
}
// Upload PDF to Spaces
const key = buildKey(session.user.id, file.name)
await uploadFile(buffer, key, 'application/pdf')
// Truncate if needed
let textForAnalysis = extractedText
let truncated = false
if (textForAnalysis.length > 15000) {
textForAnalysis = textForAnalysis.substring(0, 15000)
truncated = true
}
const title = file.name.replace(/\.pdf$/i, '')
// Create record
const analysis = await prisma.processAnalysis.create({
data: {
userId: session.user.id,
title,
filename: file.name,
fileKey: key,
fileSize: file.size,
extractedText,
analysis: '',
status: 'ANALYZING',
},
})
// Deduct credits
await prisma.user.update({
where: { id: session.user.id },
data: { credits: { decrement: CREDIT_COST } },
})
// Build user message
let userMessage = `Analise o seguinte processo/documento jurídico:\n\n${textForAnalysis}`
if (truncated) {
userMessage += '\n\n[NOTA: O documento foi truncado para os primeiros 15.000 caracteres devido ao tamanho. A análise pode não cobrir todo o conteúdo.]'
}
// Stream response using SSE
const encoder = new TextEncoder()
let fullAnalysis = ''
const stream = new ReadableStream({
async start(controller) {
try {
const completion = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userMessage },
],
temperature: 0.3,
max_tokens: 8000,
stream: true,
})
// Send the analysis ID first
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'id', id: analysis.id })}\n\n`))
for await (const chunk of completion) {
const content = chunk.choices[0]?.delta?.content
if (content) {
fullAnalysis += content
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'chunk', content })}\n\n`))
}
}
// Generate summary from first paragraph of analysis
const summaryMatch = fullAnalysis.match(/## 1\. Resumo do Processo\n+([\s\S]*?)(?=\n## |\n#|$)/)
const summary = summaryMatch
? summaryMatch[1].trim().substring(0, 500)
: fullAnalysis.substring(0, 300)
// Save completed analysis
await prisma.processAnalysis.update({
where: { id: analysis.id },
data: {
analysis: fullAnalysis,
summary,
status: 'DONE',
},
})
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', id: analysis.id })}\n\n`))
controller.close()
} catch (err) {
console.error('OpenAI streaming error:', err)
// Refund credits on error
await prisma.user.update({
where: { id: session.user.id },
data: { credits: { increment: CREDIT_COST } },
})
await prisma.processAnalysis.update({
where: { id: analysis.id },
data: { status: 'ERROR', analysis: fullAnalysis || 'Erro durante a análise.' },
})
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'error', message: 'Erro ao gerar análise' })}\n\n`))
controller.close()
}
},
})
return new NextResponse(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
} catch (error) {
console.error('Process analysis error:', error)
return NextResponse.json({ error: 'Erro interno ao processar análise' }, { status: 500 })
}
}

View File

@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const { id } = await params
const audit = await prisma.contractAudit.findFirst({
where: { id, userId: session.user.id },
})
if (!audit) {
return NextResponse.json({ error: 'Auditoria não encontrada' }, { status: 404 })
}
return NextResponse.json({ audit })
}

View File

@@ -0,0 +1,144 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import OpenAI from 'openai'
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const audits = await prisma.contractAudit.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'desc' },
select: {
id: true,
title: true,
status: true,
riskScore: true,
createdAt: true,
},
})
return NextResponse.json({ audits })
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
// Check credits
const user = await prisma.user.findUnique({ where: { id: session.user.id } })
if (!user || user.credits < 3) {
return NextResponse.json({ error: 'Créditos insuficientes. A auditoria custa 3 créditos.' }, { status: 403 })
}
const body = await req.json()
const { title, content } = body
if (!title || !content) {
return NextResponse.json({ error: 'Título e conteúdo são obrigatórios' }, { status: 400 })
}
if (title.length > 500) {
return NextResponse.json({ error: "Título muito longo (máx 500 caracteres)" }, { status: 400 })
}
if (content.length > 100000) {
return NextResponse.json({ error: "Conteúdo muito longo (máx 100.000 caracteres)" }, { status: 400 })
}
if (content.length < 100) {
return NextResponse.json({ error: 'O contrato deve ter pelo menos 100 caracteres' }, { status: 400 })
}
// Create audit record
const audit = await prisma.contractAudit.create({
data: {
userId: session.user.id,
title,
content,
status: 'ANALYZING',
},
})
// Deduct credits
await prisma.user.update({
where: { id: session.user.id },
data: { credits: { decrement: 3 } },
})
// Call OpenAI for analysis
try {
const completion = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content: `Você é um advogado especialista em análise de contratos brasileiros. Analise o contrato fornecido e retorne APENAS um JSON válido (sem markdown, sem backticks) com a seguinte estrutura:
{
"riskScore": <número de 0 a 100, onde 100 é risco máximo>,
"resumo": "<resumo executivo do contrato em 2-3 frases>",
"clausulasAbusivas": [{"clausula": "<identificação>", "descricao": "<problema>", "gravidade": "alta|media|baixa"}],
"inconsistencias": [{"item": "<identificação>", "descricao": "<problema>"}],
"lacunas": [{"item": "<o que falta>", "descricao": "<por que é importante>"}],
"riscos": [{"risco": "<identificação>", "descricao": "<detalhes>", "gravidade": "alta|media|baixa"}],
"sugestoes": [{"sugestao": "<título>", "descricao": "<como implementar>"}],
"pontosFavoraveis": [{"ponto": "<identificação>", "descricao": "<detalhes>"}]
}`
},
{
role: 'user',
content: `Analise o seguinte contrato:\n\n${content.substring(0, 15000)}`
}
],
temperature: 0.3,
max_tokens: 4000,
})
const responseText = completion.choices[0]?.message?.content || '{}'
let analysis
try {
analysis = JSON.parse(responseText)
} catch {
// Try to extract JSON from response
const jsonMatch = responseText.match(/\{[\s\S]*\}/)
analysis = jsonMatch ? JSON.parse(jsonMatch[0]) : { error: 'Falha ao parsear análise', raw: responseText }
}
const riskScore = typeof analysis.riskScore === 'number' ? Math.min(100, Math.max(0, analysis.riskScore)) : 50
await prisma.contractAudit.update({
where: { id: audit.id },
data: {
analysis,
riskScore,
status: 'DONE',
},
})
return NextResponse.json({
audit: { ...audit, analysis, riskScore, status: 'DONE' },
})
} catch (error) {
console.error('OpenAI error:', error)
await prisma.contractAudit.update({
where: { id: audit.id },
data: { status: 'ERROR' },
})
// Refund credits on error
await prisma.user.update({
where: { id: session.user.id },
data: { credits: { increment: 3 } },
})
return NextResponse.json({ error: 'Erro ao analisar contrato' }, { status: 500 })
}
}

View File

@@ -0,0 +1,6 @@
import NextAuth from 'next-auth'
import { authOptions } from '@/lib/auth'
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ chatId: string }> }
) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const { chatId } = await params
// Verify ownership
const chat = await prisma.chat.findFirst({
where: { id: chatId, userId: session.user.id },
})
if (!chat) {
return NextResponse.json({ error: 'Chat não encontrado' }, { status: 404 })
}
const messages = await prisma.chatMessage.findMany({
where: { chatId },
orderBy: { createdAt: 'asc' },
select: {
id: true,
role: true,
content: true,
createdAt: true,
},
})
return NextResponse.json({ messages })
}
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ chatId: string }> }
) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const { chatId } = await params
const chat = await prisma.chat.findFirst({
where: { id: chatId, userId: session.user.id },
})
if (!chat) {
return NextResponse.json({ error: 'Chat não encontrado' }, { status: 404 })
}
await prisma.chat.delete({ where: { id: chatId } })
return NextResponse.json({ success: true })
}

406
src/app/api/chat/route.ts Normal file
View File

@@ -0,0 +1,406 @@
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
const LEGAL_SYSTEM_PROMPT =
'Você é um assistente jurídico especialista EXCLUSIVAMENTE em Direito brasileiro. ' +
'Você SOMENTE responde perguntas relacionadas a temas jurídicos e legais. ' +
'Se o usuário perguntar sobre QUALQUER assunto que NÃO seja jurídico (tecnologia, culinária, matemática, programação, entretenimento, etc.), ' +
'responda APENAS: "Desculpe, sou um assistente especializado exclusivamente em Direito brasileiro. Só posso ajudar com questões jurídicas e legais. Por favor, reformule sua pergunta dentro do âmbito jurídico." ' +
'Não faça exceções. Não tente ajudar parcialmente com temas não jurídicos. ' +
'Para questões jurídicas, responda com base na legislação vigente (CF/88, CPC, CC, CLT, CP, CDC, CTN, etc.), ' +
'jurisprudência dos tribunais superiores (STF, STJ, TST) e doutrina. ' +
'Cite artigos de lei quando relevante. Use linguagem técnica jurídica apropriada.'
const CREDIT_COST = 1
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return new Response(JSON.stringify({ error: 'Não autorizado' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
const userId = session.user.id
// Check credits
const user = await prisma.user.findUnique({ where: { id: userId }, select: { credits: true } })
if (!user || user.credits < CREDIT_COST) {
return new Response(JSON.stringify({ error: 'Créditos insuficientes' }), {
status: 402,
headers: { 'Content-Type': 'application/json' },
})
}
const body = await req.json()
const { message, chatId } = body as { message: string; chatId?: string }
// Validate input lengths
if (message.length > 10000) {
return new Response(JSON.stringify({ error: "Mensagem muito longa (máx 10.000 caracteres)" }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}
if (!message?.trim()) {
return new Response(JSON.stringify({ error: 'Mensagem vazia' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
// Create or get chat
let chat: { id: string }
if (chatId) {
const existing = await prisma.chat.findFirst({ where: { id: chatId, userId } })
if (!existing) {
return new Response(JSON.stringify({ error: 'Chat não encontrado' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
})
}
chat = existing
} else {
// New chat — title from first message
const title = message.length > 60 ? message.slice(0, 57) + '...' : message
chat = await prisma.chat.create({ data: { userId, title } })
}
// Save user message
await prisma.chatMessage.create({
data: { chatId: chat.id, role: 'USER', content: message },
})
// Load conversation history for context
const history = await prisma.chatMessage.findMany({
where: { chatId: chat.id },
orderBy: { createdAt: 'asc' },
select: { role: true, content: true },
})
const messages = [
{ role: 'system' as const, content: LEGAL_SYSTEM_PROMPT },
...history.map((m) => ({
role: (m.role === 'USER' ? 'user' : 'assistant') as 'user' | 'assistant',
content: m.content,
})),
]
// Call OpenAI
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey) {
// Fallback: simulated streaming response for development
return streamSimulated(chat.id, message, userId)
}
try {
const openaiRes = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: process.env.OPENAI_MODEL || 'gpt-4o-mini',
messages,
stream: true,
temperature: 0.4,
max_tokens: 2048,
}),
})
if (!openaiRes.ok) {
const err = await openaiRes.text()
console.error('OpenAI error:', err)
return streamSimulated(chat.id, message, userId)
}
// Stream SSE to client
const encoder = new TextEncoder()
let fullContent = ''
const stream = new ReadableStream({
async start(controller) {
// Send chatId first
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ chatId: chat.id })}\n\n`))
const reader = openaiRes.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const data = line.slice(6).trim()
if (data === '[DONE]') continue
try {
const parsed = JSON.parse(data)
const delta = parsed.choices?.[0]?.delta?.content
if (delta) {
fullContent += delta
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ token: delta })}\n\n`)
)
}
} catch {
// skip malformed chunks
}
}
}
} catch (e) {
console.error('Stream error:', e)
}
// Save assistant message and deduct credits
await prisma.chatMessage.create({
data: {
chatId: chat.id,
role: 'ASSISTANT',
content: fullContent,
tokens: Math.ceil(fullContent.length / 4),
},
})
await prisma.user.update({
where: { id: userId },
data: { credits: { decrement: CREDIT_COST } },
})
await prisma.usageLog.create({
data: {
userId,
type: 'CHAT',
tokens: Math.ceil(fullContent.length / 4),
cost: 0,
},
})
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
controller.close()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
} catch (error) {
console.error('OpenAI fetch error:', error)
return streamSimulated(chat.id, message, userId)
}
}
// Simulated streaming for dev without OpenAI key
async function streamSimulated(chatId: string, userMessage: string, userId: string) {
const encoder = new TextEncoder()
const simulatedResponse = generateSimulatedLegalResponse(userMessage)
const stream = new ReadableStream({
async start(controller) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ chatId })}\n\n`))
const words = simulatedResponse.split(' ')
for (let i = 0; i < words.length; i++) {
const token = (i === 0 ? '' : ' ') + words[i]
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ token })}\n\n`))
await new Promise((r) => setTimeout(r, 30 + Math.random() * 40))
}
await prisma.chatMessage.create({
data: {
chatId,
role: 'ASSISTANT',
content: simulatedResponse,
tokens: Math.ceil(simulatedResponse.length / 4),
},
})
await prisma.user.update({
where: { id: userId },
data: { credits: { decrement: CREDIT_COST } },
})
await prisma.usageLog.create({
data: {
userId,
type: 'CHAT',
tokens: Math.ceil(simulatedResponse.length / 4),
cost: 0,
},
})
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
controller.close()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}
function generateSimulatedLegalResponse(question: string): string {
const q = question.toLowerCase()
if (q.includes('contestação') && q.includes('jec')) {
return `## Prazo para Contestação no JEC
No âmbito dos **Juizados Especiais Cíveis (JEC)**, regulados pela **Lei nº 9.099/95**, o prazo para apresentação de contestação segue regras próprias:
### Regra Geral
A contestação deve ser apresentada **na audiência de instrução e julgamento**, conforme dispõe o **art. 30 da Lei 9.099/95**:
> *"Art. 30. A contestação, que será oral ou escrita, conterá toda matéria de defesa, exceto argüição de suspeição ou impedimento do Juiz, que se processará na forma da legislação em vigor."*
### Aspectos Importantes
1. **Não há prazo em dias** como no procedimento comum do CPC (que é de 15 dias úteis, conforme art. 335 do CPC/2015)
2. A defesa é concentrada na **audiência**, podendo ser oral ou escrita
3. **Toda matéria de defesa** deve ser apresentada neste momento, sob pena de preclusão
4. A **revelia** opera seus efeitos caso o réu não compareça à audiência (art. 20 da Lei 9.099/95)
### Jurisprudência
O **STJ** tem entendimento pacífico de que nos Juizados Especiais, a contestação apresentada antes da audiência é válida, não havendo preclusão temporal antecipada (REsp 1.129.205/RJ).`
}
if (q.includes('apelação') || q.includes('recurso de apelação')) {
return `## Recurso de Apelação no CPC/2015
O recurso de apelação está disciplinado nos **arts. 1.009 a 1.014 do CPC/2015** e é o recurso cabível contra **sentenças** proferidas em primeiro grau.
### Cabimento
Conforme o **art. 1.009 do CPC**:
> *"Da sentença cabe apelação."*
### Prazo
- **15 dias úteis** para interposição (art. 1.003, §5º, CPC)
- **15 dias úteis** para contrarrazões (art. 1.010, §1º, CPC)
### Efeitos
1. **Efeito devolutivo** — regra geral (art. 1.012, CPC)
2. **Efeito suspensivo** — automático, salvo nas hipóteses do §1º do art. 1.012
### Exceções ao Efeito Suspensivo (art. 1.012, §1º)
Começam a produzir efeitos imediatamente as sentenças que:
- Homologam divisão ou demarcação de terras
- Condenam a pagar alimentos
- Julgam procedente pedido de instituição de arbitragem
- Confirmam, concedem ou revogam tutela provisória
- Decretam interdição
### Processamento
1. Interposta perante o juízo *a quo*
2. O juiz não faz juízo de admissibilidade (art. 1.010, §3º, CPC)
3. Remetida diretamente ao tribunal competente
4. Distribuída a um relator
### Jurisprudência Relevante
O **STJ** consolidou que a apelação devolve ao tribunal o conhecimento da matéria impugnada, podendo o tribunal julgar questões de ordem pública ainda que não suscitadas (Súmula 456/STF, aplicável por analogia).`
}
if (q.includes('habeas corpus')) {
return `## Requisitos do Habeas Corpus
O **habeas corpus** é uma garantia constitucional prevista no **art. 5º, LXVIII, da CF/88** e regulamentado nos **arts. 647 a 667 do CPP**.
### Previsão Constitucional
> *"Art. 5º, LXVIII - conceder-se-á habeas corpus sempre que alguém sofrer ou se achar ameaçado de sofrer violência ou coação em sua liberdade de locomoção, por ilegalidade ou abuso de poder."*
### Requisitos Essenciais
1. **Legitimidade ativa** — qualquer pessoa pode impetrá-lo, em seu favor ou de outrem (art. 654, CPP)
2. **Autoridade coatora** — identificação de quem pratica a coação ilegal
3. **Paciente** — pessoa que sofre ou está ameaçada de sofrer a restrição
4. **Constrangimento ilegal** à liberdade de locomoção
### Hipóteses de Cabimento (art. 648, CPP)
A coação será considerada ilegal quando:
- Não houver justa causa
- Alguém estiver preso por mais tempo que a lei determina
- Quem ordenar a coação não tiver competência
- Houver cessado o motivo da coação
- Não for admitida fiança nos casos previstos em lei
- O processo for manifestamente nulo
- Extinta a punibilidade
### Espécies
- **Preventivo** (salvo-conduto) — quando há ameaça de coação
- **Liberatório/Repressivo** — quando a coação já está consumada
### Competência
- **STF** — quando o coator for Tribunal Superior (art. 102, I, *d*, CF)
- **STJ** — quando o coator for tribunal sujeito à sua jurisdição (art. 105, I, *c*, CF)
- **TJ/TRF** — quando o coator for juiz de primeiro grau
- **Juiz de primeiro grau** — quando o coator for autoridade policial`
}
return `## Análise Jurídica
Com base na sua consulta, apresento a seguinte análise:
### Fundamentação Legal
A questão apresentada encontra respaldo na legislação brasileira vigente. Para uma análise mais precisa, seria necessário considerar:
1. **Legislação aplicável** — CF/88, CPC/2015, CC/2002 e legislação especial pertinente
2. **Jurisprudência dominante** — posicionamento dos tribunais superiores (STF e STJ)
3. **Doutrina majoritária** — entendimento dos principais juristas brasileiros
### Considerações Gerais
O ordenamento jurídico brasileiro adota o **sistema de direito codificado** (*civil law*), onde as fontes primárias do direito são a lei, os costumes, a jurisprudência e os princípios gerais do direito (art. 4º da LINDB).
### Recomendação
Para uma orientação mais específica e detalhada sobre sua questão, sugiro:
- Reformular a pergunta com mais detalhes sobre o caso concreto
- Indicar a área do direito (civil, penal, trabalhista, tributário, etc.)
- Mencionar se há processo judicial em curso
*Nota: Esta é uma orientação jurídica geral e não substitui a consulta presencial a um advogado.*`
}
// GET: list chats for the authenticated user
export async function GET() {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return new Response(JSON.stringify({ error: 'Não autorizado' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
const chats = await prisma.chat.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'desc' },
select: {
id: true,
title: true,
createdAt: true,
messages: {
orderBy: { createdAt: 'desc' },
take: 1,
select: { content: true, createdAt: true },
},
},
})
return new Response(JSON.stringify({ chats }), {
headers: { 'Content-Type': 'application/json' },
})
}

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server"
import { getServerSession } from "next-auth"
import { authOptions } from "@/lib/auth"
import { stripe } from "@/lib/stripe"
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
const { priceId } = await req.json()
if (!priceId || typeof priceId !== "string") {
return NextResponse.json({ error: "Price ID inválido" }, { status: 400 })
}
const checkoutSession = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
customer_email: session.user.email,
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
allow_promotion_codes: true,
billing_address_collection: "required",
metadata: { userId: session.user.id },
})
return NextResponse.json({ url: checkoutSession.url })
} catch (error: any) {
console.error("Stripe checkout error:", error)
return NextResponse.json(
{ error: "Erro ao criar sessão de checkout" },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const { id } = await params
const document = await prisma.document.findFirst({
where: { id, userId: session.user.id },
})
if (!document) {
return NextResponse.json({ error: 'Documento não encontrado' }, { status: 404 })
}
return NextResponse.json({ document })
}
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const { id } = await params
const document = await prisma.document.findFirst({
where: { id, userId: session.user.id },
})
if (!document) {
return NextResponse.json({ error: 'Documento não encontrado' }, { status: 404 })
}
await prisma.document.delete({ where: { id } })
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,358 @@
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
const SYSTEM_PROMPT = `Você é um advogado sênior brasileiro com mais de 30 anos de experiência em todas as áreas do direito. Você domina completamente a legislação brasileira, incluindo mas não limitado a:
- Constituição Federal de 1988 (CF/88)
- Código de Processo Civil (CPC - Lei nº 13.105/2015)
- Código Civil (CC - Lei nº 10.406/2002)
- Código Penal (CP - Decreto-Lei nº 2.848/1940)
- Código de Processo Penal (CPP - Decreto-Lei nº 3.689/1941)
- Consolidação das Leis do Trabalho (CLT)
- Código de Defesa do Consumidor (CDC - Lei nº 8.078/1990)
- Código Tributário Nacional (CTN - Lei nº 5.172/1966)
- Estatuto da Criança e do Adolescente (ECA)
- Lei de Execução Penal (LEP)
- Lei Maria da Penha (Lei nº 11.340/2006)
- Lei de Improbidade Administrativa (Lei nº 8.429/1992)
INSTRUÇÕES RIGOROSAS:
1. ESTRUTURA: Toda peça deve seguir a estrutura formal brasileira:
- Endereçamento ao juízo competente (em caixa alta)
- Qualificação completa das partes
- DOS FATOS (narrativa clara e cronológica)
- DO DIREITO / DOS FUNDAMENTOS JURÍDICOS (argumentação robusta)
- DOS PEDIDOS (numerados e específicos)
- Requerimentos finais
- Valor da causa (quando aplicável)
- Local, data e assinatura
2. CITAÇÕES LEGAIS: Cite artigos específicos de leis reais. Use jurisprudência quando relevante (STF, STJ, TJs). Formate citações em itálico.
3. LINGUAGEM: Use português jurídico formal. Trate o juiz como "Excelentíssimo(a) Senhor(a) Doutor(a) Juiz(a)" ou "Meritíssimo". Use "data venia", "ad argumentandum tantum", e demais expressões latinas quando apropriado.
4. QUALIDADE: A peça deve ser completa, profissional e pronta para protocolar. Não deixe campos em branco - use [COMPLETAR] apenas para dados específicos do cliente que não foram fornecidos.
5. FORMATAÇÃO: Use parágrafos numerados, seções em negrito/caixa alta, e espaçamento adequado para leitura jurídica.
6. Nunca mencione que é uma IA. Escreva como um advogado real escreveria.`
function buildUserPrompt(type: string, area: string, details: Record<string, string>): string {
const typeNames: Record<string, string> = {
'peticao-inicial': 'Petição Inicial',
'contestacao': 'Contestação',
'apelacao-civel': 'Apelação Cível',
'apelacao-criminal': 'Apelação Criminal',
'recurso': 'Recurso',
'contrato': 'Contrato',
'parecer': 'Parecer Jurídico',
'impugnacao': 'Impugnação',
'habeas-corpus': 'Habeas Corpus',
'mandado-seguranca': 'Mandado de Segurança',
'embargo': 'Embargo',
'recurso-especial': 'Recurso Especial',
'agravo': 'Agravo',
'outros': 'Peça Jurídica',
}
const areaNames: Record<string, string> = {
'civil': 'Direito Civil',
'trabalhista': 'Direito Trabalhista',
'penal': 'Direito Penal',
'tributario': 'Direito Tributário',
'familia': 'Direito de Família',
'empresarial': 'Direito Empresarial',
'consumidor': 'Direito do Consumidor',
'administrativo': 'Direito Administrativo',
}
const typeName = typeNames[type] || type
const areaName = areaNames[area] || area
let prompt = `Elabore uma ${typeName} completa na área de ${areaName}.\n\n`
if (details.autor) prompt += `AUTOR/REQUERENTE: ${details.autor}\n`
if (details.reu) prompt += `RÉU/REQUERIDO: ${details.reu}\n`
if (details.fatos) prompt += `\nFATOS:\n${details.fatos}\n`
if (details.fundamentos) prompt += `\nFUNDAMENTOS JURÍDICOS RELEVANTES:\n${details.fundamentos}\n`
if (details.pedidos) prompt += `\nPEDIDOS PRETENDIDOS:\n${details.pedidos}\n`
if (details.contexto) prompt += `\nCONTEXTO ADICIONAL:\n${details.contexto}\n`
prompt += `\nElabore a peça completa, profissional e pronta para protocolar.`
return prompt
}
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return new Response(JSON.stringify({ error: 'Não autorizado' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
// Check credits
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { credits: true },
})
if (!user || user.credits < 1) {
return new Response(JSON.stringify({ error: 'Créditos insuficientes' }), {
status: 402,
headers: { 'Content-Type': 'application/json' },
})
}
const body = await req.json()
const { type, area, details } = body
if (!type || !area || !details) {
return new Response(JSON.stringify({ error: 'Dados incompletos' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
const userPrompt = buildUserPrompt(type, area, details)
// Create document record
const document = await prisma.document.create({
data: {
userId: session.user.id,
type: type.toUpperCase().replace(/-/g, '_'),
title: `${type} - ${area}`,
prompt: userPrompt,
content: '',
area: area.toUpperCase(),
status: 'GENERATING',
},
})
// Deduct credit
await prisma.user.update({
where: { id: session.user.id },
data: { credits: { decrement: 1 } },
})
const OPENAI_API_KEY = process.env.OPENAI_API_KEY
if (!OPENAI_API_KEY) {
// Fallback: generate a mock response for development
const encoder = new TextEncoder()
const mockContent = generateMockDocument(type, area, details)
const stream = new ReadableStream({
async start(controller) {
const words = mockContent.split(' ')
for (let i = 0; i < words.length; i++) {
const chunk = (i === 0 ? '' : ' ') + words[i]
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ content: chunk, documentId: document.id })}\n\n`))
await new Promise(r => setTimeout(r, 20))
}
// Update document
await prisma.document.update({
where: { id: document.id },
data: {
content: mockContent,
wordCount: mockContent.split(/\s+/).length,
status: 'COMPLETED',
tokens: mockContent.length,
},
})
// Log usage
await prisma.usageLog.create({
data: {
userId: session.user.id,
type: 'DOCUMENT',
tokens: mockContent.length,
cost: 0,
},
})
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ done: true, documentId: document.id })}\n\n`))
controller.close()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
}
// Real OpenAI call with streaming
const openaiRes = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userPrompt },
],
stream: true,
temperature: 0.4,
max_tokens: 8000,
}),
})
if (!openaiRes.ok) {
await prisma.document.update({
where: { id: document.id },
data: { status: 'ERROR' },
})
// Refund credit
await prisma.user.update({
where: { id: session.user.id },
data: { credits: { increment: 1 } },
})
return new Response(JSON.stringify({ error: 'Erro ao gerar documento' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
}
const encoder = new TextEncoder()
let fullContent = ''
let totalTokens = 0
const transformStream = new TransformStream({
async transform(chunk, controller) {
const text = new TextDecoder().decode(chunk)
const lines = text.split('\n').filter(line => line.startsWith('data: '))
for (const line of lines) {
const data = line.slice(6).trim()
if (data === '[DONE]') {
// Save final document
const wordCount = fullContent.split(/\s+/).filter(Boolean).length
await prisma.document.update({
where: { id: document.id },
data: {
content: fullContent,
wordCount,
status: 'COMPLETED',
tokens: totalTokens,
},
})
await prisma.usageLog.create({
data: {
userId: session.user.id,
type: 'DOCUMENT',
tokens: totalTokens,
cost: (totalTokens / 1000) * 0.005,
},
})
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ done: true, documentId: document.id })}\n\n`))
return
}
try {
const parsed = JSON.parse(data)
const content = parsed.choices?.[0]?.delta?.content
if (content) {
fullContent += content
totalTokens += content.length // Approximate
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ content, documentId: document.id })}\n\n`))
}
} catch {
// Skip malformed chunks
}
}
},
})
const responseStream = openaiRes.body!.pipeThrough(transformStream)
return new Response(responseStream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
} catch (error) {
console.error('Document generation error:', error)
return new Response(JSON.stringify({ error: 'Erro interno do servidor' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
}
}
function generateMockDocument(type: string, area: string, details: Record<string, string>): string {
const autor = details.autor || '[NOME DO AUTOR]'
const reu = details.reu || '[NOME DO RÉU]'
return `EXCELENTÍSSIMO(A) SENHOR(A) DOUTOR(A) JUIZ(A) DE DIREITO DA ___ VARA CÍVEL DA COMARCA DE [CIDADE] - ESTADO DE [UF]
${autor}, brasileiro(a), [estado civil], [profissão], portador(a) do RG nº [COMPLETAR] e CPF nº [COMPLETAR], residente e domiciliado(a) na [endereço completo], por intermédio de seu(sua) advogado(a) que esta subscreve, com escritório profissional na [endereço do escritório], onde recebe intimações, vem, respeitosamente, perante Vossa Excelência, com fundamento nos artigos 319 e seguintes do Código de Processo Civil (Lei nº 13.105/2015), propor a presente
PETIÇÃO INICIAL
em face de ${reu}, [qualificação completa do réu], pelos fatos e fundamentos a seguir expostos:
I - DOS FATOS
${details.fatos || '1. [Narrar os fatos de forma clara e cronológica, indicando datas, locais e circunstâncias relevantes.]'}
2. Conforme restará demonstrado, os fatos narrados configuram clara violação aos direitos do(a) Autor(a), ensejando a tutela jurisdicional ora pleiteada.
3. Diante da situação narrada, não restou alternativa ao(à) Autor(a) senão buscar a tutela do Poder Judiciário para ver seus direitos resguardados.
II - DO DIREITO
${details.fundamentos || `4. O presente caso encontra amparo no ordenamento jurídico brasileiro, especialmente nos seguintes dispositivos legais:
5. A Constituição Federal de 1988, em seu artigo 5º, inciso XXXV, assegura que "a lei não excluirá da apreciação do Poder Judiciário lesão ou ameaça a direito". Trata-se do princípio da inafastabilidade da jurisdição, que garante ao(à) Autor(a) o direito de buscar a tutela jurisdicional.
6. O Código Civil (Lei nº 10.406/2002), em seu artigo 186, estabelece que "aquele que, por ação ou omissão voluntária, negligência ou imprudência, violar direito e causar dano a outrem, ainda que exclusivamente moral, comete ato ilícito".
7. Nesse sentido, o artigo 927 do mesmo diploma legal determina que "aquele que, por ato ilícito (arts. 186 e 187), causar dano a outrem, fica obrigado a repará-lo".
8. A jurisprudência do Superior Tribunal de Justiça é pacífica nesse sentido:
"CIVIL E PROCESSUAL CIVIL. RESPONSABILIDADE CIVIL. DANO MORAL. CONFIGURAÇÃO. O dano moral prescinde de prova quando decorre de fato por si só lesivo à dignidade da pessoa humana." (STJ, AgRg no AREsp nº XXXXX/SP, Rel. Min. XXXXX, DJe XX/XX/XXXX).`}
III - DOS PEDIDOS
Ante o exposto, requer a Vossa Excelência:
${details.pedidos || `a) A citação do(a) Réu(Ré) para, querendo, apresentar contestação no prazo legal, sob pena de revelia e confissão quanto à matéria de fato;
b) A procedência total dos pedidos formulados nesta inicial;
c) A condenação do(a) Réu(Ré) ao pagamento de indenização por danos morais, em valor a ser arbitrado por Vossa Excelência, observados os princípios da razoabilidade e proporcionalidade;
d) A condenação do(a) Réu(Ré) ao pagamento das custas processuais e honorários advocatícios, nos termos do artigo 85 do CPC;`}
e) A produção de todas as provas admitidas em direito, especialmente documental, testemunhal, pericial e depoimento pessoal do(a) Réu(Ré), sob pena de confesso(a).
Dá-se à causa o valor de R$ [COMPLETAR] ([valor por extenso]).
Termos em que,
Pede deferimento.
[Cidade], [data].
_______________________________
[NOME DO ADVOGADO]
OAB/[UF] nº [COMPLETAR]`
}

View File

@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const { searchParams } = new URL(req.url)
const type = searchParams.get('type')
const area = searchParams.get('area')
const search = searchParams.get('search')
const sort = searchParams.get('sort') || 'newest'
const dateFrom = searchParams.get('dateFrom')
const dateTo = searchParams.get('dateTo')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: any = { userId: session.user.id }
if (type) where.type = type
if (area) where.area = area
if (search) where.title = { contains: search }
if (dateFrom || dateTo) {
where.createdAt = {}
if (dateFrom) where.createdAt.gte = new Date(dateFrom)
if (dateTo) where.createdAt.lte = new Date(dateTo + 'T23:59:59.999Z')
}
let orderBy: Record<string, string> = { createdAt: 'desc' }
if (sort === 'oldest') orderBy = { createdAt: 'asc' }
else if (sort === 'type') orderBy = { type: 'asc' }
const documents = await prisma.document.findMany({
where,
orderBy,
select: {
id: true,
title: true,
type: true,
area: true,
status: true,
wordCount: true,
tokens: true,
cost: true,
createdAt: true,
},
})
return NextResponse.json({ documents })
}

View File

@@ -0,0 +1,345 @@
import { getServerSession } from "next-auth"
import { authOptions } from "@/lib/auth"
import { NextRequest, NextResponse } from 'next/server'
import {
Document,
Packer,
Paragraph,
TextRun,
AlignmentType,
BorderStyle,
HeadingLevel,
Footer,
PageNumber,
TabStopPosition,
TabStopType,
} from 'docx'
// ─── Markdown → docx Paragraphs ─────────────────────────────────
function parseMarkdownToParagraphs(markdown: string): Paragraph[] {
const lines = markdown.split('\n')
const paragraphs: Paragraph[] = []
let inList = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// Skip empty lines
if (!line.trim()) {
continue
}
// Headings
const h1Match = line.match(/^#\s+(.+)/)
const h2Match = line.match(/^##\s+(.+)/)
const h3Match = line.match(/^###\s+(.+)/)
const h4Match = line.match(/^####\s+(.+)/)
if (h1Match) {
paragraphs.push(new Paragraph({
heading: HeadingLevel.HEADING_1,
spacing: { before: 360, after: 200 },
children: parseInlineFormatting(h1Match[1], { bold: true, size: 32, font: 'Times New Roman' }),
}))
continue
}
if (h2Match) {
paragraphs.push(new Paragraph({
heading: HeadingLevel.HEADING_2,
spacing: { before: 300, after: 160 },
children: parseInlineFormatting(h2Match[1], { bold: true, size: 28, font: 'Times New Roman' }),
}))
continue
}
if (h3Match) {
paragraphs.push(new Paragraph({
heading: HeadingLevel.HEADING_3,
spacing: { before: 240, after: 120 },
children: parseInlineFormatting(h3Match[1], { bold: true, size: 26, font: 'Times New Roman' }),
}))
continue
}
if (h4Match) {
paragraphs.push(new Paragraph({
spacing: { before: 200, after: 100 },
children: parseInlineFormatting(h4Match[1], { bold: true, size: 24, font: 'Times New Roman' }),
}))
continue
}
// Horizontal rule
if (/^[-*_]{3,}\s*$/.test(line.trim())) {
paragraphs.push(new Paragraph({
border: { bottom: { style: BorderStyle.SINGLE, size: 1, color: '999999' } },
spacing: { before: 200, after: 200 },
}))
continue
}
// Bullet list
const bulletMatch = line.match(/^[\s]*[-*+]\s+(.+)/)
if (bulletMatch) {
paragraphs.push(new Paragraph({
bullet: { level: 0 },
spacing: { before: 60, after: 60 },
children: parseInlineFormatting(bulletMatch[1], { size: 24, font: 'Times New Roman' }),
}))
continue
}
// Numbered list
const numMatch = line.match(/^[\s]*\d+[.)]\s+(.+)/)
if (numMatch) {
paragraphs.push(new Paragraph({
bullet: { level: 0 },
spacing: { before: 60, after: 60 },
children: parseInlineFormatting(numMatch[1], { size: 24, font: 'Times New Roman' }),
}))
continue
}
// Blockquote
const quoteMatch = line.match(/^>\s*(.+)/)
if (quoteMatch) {
paragraphs.push(new Paragraph({
indent: { left: 720 },
spacing: { before: 120, after: 120 },
border: { left: { style: BorderStyle.SINGLE, size: 3, color: '0D9488' } },
children: parseInlineFormatting(quoteMatch[1], { italics: true, size: 24, font: 'Times New Roman', color: '555555' }),
}))
continue
}
// Regular paragraph
paragraphs.push(new Paragraph({
spacing: { before: 120, after: 120, line: 360 },
alignment: AlignmentType.JUSTIFIED,
children: parseInlineFormatting(line, { size: 24, font: 'Times New Roman' }),
}))
}
return paragraphs
}
interface InlineDefaults {
bold?: boolean
italics?: boolean
size?: number
font?: string
color?: string
}
function parseInlineFormatting(text: string, defaults: InlineDefaults): TextRun[] {
const runs: TextRun[] = []
// Match bold+italic, bold, italic, or plain text
const regex = /(\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(.+?)\*|([^*]+))/g
let match
while ((match = regex.exec(text)) !== null) {
if (match[2]) {
// Bold + Italic
runs.push(new TextRun({
text: match[2],
bold: true,
italics: true,
size: defaults.size || 24,
font: defaults.font || 'Times New Roman',
color: defaults.color,
}))
} else if (match[3]) {
// Bold
runs.push(new TextRun({
text: match[3],
bold: true,
italics: defaults.italics,
size: defaults.size || 24,
font: defaults.font || 'Times New Roman',
color: defaults.color,
}))
} else if (match[4]) {
// Italic
runs.push(new TextRun({
text: match[4],
bold: defaults.bold,
italics: true,
size: defaults.size || 24,
font: defaults.font || 'Times New Roman',
color: defaults.color,
}))
} else if (match[5]) {
// Plain text
runs.push(new TextRun({
text: match[5],
bold: defaults.bold,
italics: defaults.italics,
size: defaults.size || 24,
font: defaults.font || 'Times New Roman',
color: defaults.color,
}))
}
}
return runs.length ? runs : [new TextRun({
text,
bold: defaults.bold,
italics: defaults.italics,
size: defaults.size || 24,
font: defaults.font || 'Times New Roman',
color: defaults.color,
})]
}
// ─── API Route ───────────────────────────────────────────────────
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
try {
const { title, content } = await req.json()
if (!title || !content) {
return NextResponse.json({ error: 'Título e conteúdo são obrigatórios' }, { status: 400 })
}
const bodyParagraphs = parseMarkdownToParagraphs(content)
const doc = new Document({
styles: {
default: {
document: {
run: {
font: 'Times New Roman',
size: 24,
},
paragraph: {
spacing: { line: 360 },
},
},
},
},
sections: [{
properties: {
page: {
margin: {
top: 1440,
bottom: 1440,
left: 1440,
right: 1440,
},
},
},
headers: {
default: {
options: {
children: [
new Paragraph({
alignment: AlignmentType.LEFT,
spacing: { after: 100 },
children: [
new TextRun({
text: 'LexMind',
bold: true,
size: 28,
font: 'Times New Roman',
color: '0D9488',
}),
new TextRun({
text: ' — Inteligência Artificial Jurídica',
size: 20,
font: 'Times New Roman',
color: '6B7280',
}),
],
}),
new Paragraph({
border: {
bottom: { style: BorderStyle.SINGLE, size: 2, color: '0D9488' },
},
spacing: { after: 200 },
}),
],
},
},
},
footers: {
default: {
options: {
children: [
new Paragraph({
border: {
top: { style: BorderStyle.SINGLE, size: 1, color: 'D1D5DB' },
},
spacing: { before: 100 },
alignment: AlignmentType.CENTER,
children: [
new TextRun({
text: 'Documento gerado por LexMind — lexmind.adv.br',
size: 16,
font: 'Times New Roman',
color: '9CA3AF',
}),
new TextRun({
text: ' Página ',
size: 16,
font: 'Times New Roman',
color: '9CA3AF',
}),
new TextRun({
children: [PageNumber.CURRENT],
size: 16,
font: 'Times New Roman',
color: '9CA3AF',
}),
],
}),
],
},
},
},
children: [
// Document Title
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 200, after: 400 },
children: [
new TextRun({
text: title,
bold: true,
size: 36,
font: 'Times New Roman',
color: '111827',
}),
],
}),
// Separator
new Paragraph({
border: {
bottom: { style: BorderStyle.SINGLE, size: 1, color: 'D1D5DB' },
},
spacing: { after: 300 },
}),
// Body
...bodyParagraphs,
],
}],
})
const buffer = await Packer.toBuffer(doc)
return new NextResponse(buffer, {
status: 200,
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'Content-Disposition': `attachment; filename="${encodeURIComponent(title)}.docx"`,
},
})
} catch (error) {
console.error('DOCX export error:', error)
return NextResponse.json({ error: 'Erro ao gerar documento Word' }, { status: 500 })
}
}

View File

@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const { searchParams } = new URL(req.url)
const search = searchParams.get('search') || ''
const tribunal = searchParams.get('tribunal') || ''
const area = searchParams.get('area') || ''
const relator = searchParams.get('relator') || ''
const dateFrom = searchParams.get('dateFrom') || ''
const dateTo = searchParams.get('dateTo') || ''
const page = Math.max(1, Math.min(1000, parseInt(searchParams.get('page') || '1', 10) || 1))
const perPage = Math.max(1, Math.min(50, parseInt(searchParams.get('perPage') || '10', 10) || 10))
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: any = {}
if (search) {
where.OR = [
{ ementa: { contains: search, mode: 'insensitive' } },
{ numero: { contains: search, mode: 'insensitive' } },
{ relator: { contains: search, mode: 'insensitive' } },
]
}
if (tribunal) {
where.tribunal = tribunal
}
if (area) {
where.area = area
}
if (relator) {
where.relator = { contains: relator, mode: 'insensitive' }
}
if (dateFrom || dateTo) {
// data is stored as string "YYYY-MM-DD"
if (dateFrom) {
where.data = { ...(where.data || {}), gte: dateFrom }
}
if (dateTo) {
where.data = { ...(where.data || {}), lte: dateTo }
}
}
const [total, results] = await Promise.all([
prisma.jurisprudencia.count({ where }),
prisma.jurisprudencia.findMany({
where,
orderBy: { data: 'desc' },
skip: (page - 1) * perPage,
take: perPage,
}),
])
return NextResponse.json({
results,
total,
page,
perPage,
totalPages: Math.ceil(total / perPage),
})
}

View File

@@ -0,0 +1,117 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import OpenAI from 'openai'
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
interface ExtractedTerms {
keywords: string[]
tribunal?: string
area?: string
relator?: string
dateRange?: { from?: string; to?: string }
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const body = await req.json()
const { query } = body
if (!query || typeof query !== 'string') {
return NextResponse.json({ error: 'Query é obrigatória' }, { status: 400 })
}
// Use OpenAI to extract structured legal search terms
const completion = await openai.chat.completions.create({
model: 'gpt-4o-mini',
temperature: 0,
messages: [
{
role: 'system',
content: `Você é um assistente jurídico brasileiro especializado em pesquisa de jurisprudência.
Dado uma consulta em linguagem natural, extraia termos de busca estruturados.
Responda APENAS com JSON válido no formato:
{
"keywords": ["termo1", "termo2"],
"tribunal": "STF" | "STJ" | "TST" | "TRF1" | ... | null,
"area": "CIVIL" | "TRABALHISTA" | "PENAL" | "TRIBUTARIO" | "FAMILIA" | "EMPRESARIAL" | "CONSUMIDOR" | "ADMINISTRATIVO" | null,
"relator": "nome do relator" | null,
"dateRange": { "from": "YYYY-MM-DD", "to": "YYYY-MM-DD" } | null
}
Extraia o máximo de termos jurídicos relevantes. Inclua sinônimos e termos técnicos.
Se o usuário mencionar um tribunal específico, inclua-o.
Se mencionar uma área do direito, identifique-a.`,
},
{
role: 'user',
content: query,
},
],
})
let extracted: ExtractedTerms
try {
const raw = completion.choices[0].message.content || '{}'
// Strip markdown code fences if present
const cleaned = raw.replace(/```json?\n?/g, '').replace(/```/g, '').trim()
extracted = JSON.parse(cleaned)
} catch {
extracted = { keywords: query.split(/\s+/).filter((w) => w.length > 2) }
}
// Build search queries from extracted keywords
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: any = {}
if (extracted.keywords.length > 0) {
// Search ementa for any of the keywords
where.OR = extracted.keywords.map((kw) => ({
ementa: { contains: kw, mode: 'insensitive' },
}))
}
if (extracted.tribunal) {
where.tribunal = extracted.tribunal
}
if (extracted.area) {
where.area = extracted.area
}
if (extracted.relator) {
where.relator = { contains: extracted.relator, mode: 'insensitive' }
}
if (extracted.dateRange) {
if (extracted.dateRange.from) {
where.data = { ...(where.data || {}), gte: extracted.dateRange.from }
}
if (extracted.dateRange.to) {
where.data = { ...(where.data || {}), lte: extracted.dateRange.to }
}
}
const [total, results] = await Promise.all([
prisma.jurisprudencia.count({ where }),
prisma.jurisprudencia.findMany({
where,
orderBy: { data: 'desc' },
take: 20,
}),
])
return NextResponse.json({
results,
total,
extractedTerms: extracted,
aiQuery: query,
})
}

View File

@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
// PATCH /api/keys/[id] — toggle active status (revoke/reactivate)
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const { id } = await params
const key = await prisma.apiKey.findFirst({
where: { id, userId: session.user.id },
})
if (!key) {
return NextResponse.json({ error: 'Chave não encontrada' }, { status: 404 })
}
const body = await req.json()
const active = typeof body.active === 'boolean' ? body.active : !key.active
const updated = await prisma.apiKey.update({
where: { id },
data: { active },
select: { id: true, name: true, active: true },
})
return NextResponse.json(updated)
}
// DELETE /api/keys/[id] — permanently delete a key
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const { id } = await params
const key = await prisma.apiKey.findFirst({
where: { id, userId: session.user.id },
})
if (!key) {
return NextResponse.json({ error: 'Chave não encontrada' }, { status: 404 })
}
await prisma.apiKey.delete({ where: { id } })
return NextResponse.json({ message: 'Chave revogada e excluída' })
}

87
src/app/api/keys/route.ts Normal file
View File

@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { randomBytes, createHash } from 'crypto'
// GET /api/keys — list user's API keys
export async function GET() {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const keys = await prisma.apiKey.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
key: true,
active: true,
createdAt: true,
},
})
// Mask keys — only show prefix + last 4 chars
const masked = keys.map((k) => ({
...k,
key: k.key.slice(0, 8) + '••••••••' + k.key.slice(-4),
}))
return NextResponse.json({ keys: masked })
}
// POST /api/keys — create a new API key
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const body = await req.json()
const name = body.name?.trim()
if (!name || name.length < 2) {
return NextResponse.json(
{ error: 'Nome da chave é obrigatório (mínimo 2 caracteres)' },
{ status: 400 }
)
}
// Check key limit (max 5)
const count = await prisma.apiKey.count({
where: { userId: session.user.id },
})
if (count >= 5) {
return NextResponse.json(
{ error: 'Limite de 5 chaves atingido. Revogue uma chave existente.' },
{ status: 400 }
)
}
// Generate key: jur_ prefix + 40 random hex chars
const rawKey = 'jur_' + randomBytes(20).toString('hex')
const hashedKey = createHash('sha256').update(rawKey).digest('hex')
const apiKey = await prisma.apiKey.create({
data: {
name,
key: hashedKey,
userId: session.user.id,
},
select: {
id: true,
name: true,
createdAt: true,
},
})
// Return the raw key ONLY on creation (never stored/shown again)
return NextResponse.json({
...apiKey,
key: rawKey,
message: 'Copie a chave agora. Ela não será exibida novamente.',
}, { status: 201 })
}

View File

@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function PUT(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const { id } = await params
const body = await req.json()
const prazo = await prisma.prazo.findFirst({
where: { id, userId: session.user.id },
})
if (!prazo) {
return NextResponse.json({ error: 'Prazo não encontrado' }, { status: 404 })
}
const updated = await prisma.prazo.update({
where: { id },
data: {
...(body.title !== undefined && { title: body.title }),
...(body.description !== undefined && { description: body.description }),
...(body.processNumber !== undefined && { processNumber: body.processNumber }),
...(body.court !== undefined && { court: body.court }),
...(body.deadline !== undefined && { deadline: new Date(body.deadline) }),
...(body.alertDays !== undefined && { alertDays: body.alertDays }),
...(body.status !== undefined && { status: body.status }),
...(body.priority !== undefined && { priority: body.priority }),
},
})
return NextResponse.json({ prazo: updated })
}
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const { id } = await params
const prazo = await prisma.prazo.findFirst({
where: { id, userId: session.user.id },
})
if (!prazo) {
return NextResponse.json({ error: 'Prazo não encontrado' }, { status: 404 })
}
await prisma.prazo.delete({ where: { id } })
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const { searchParams } = new URL(req.url)
const status = searchParams.get('status')
const priority = searchParams.get('priority')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: any = { userId: session.user.id }
if (status) where.status = status
if (priority) where.priority = priority
const prazos = await prisma.prazo.findMany({
where,
orderBy: { deadline: 'asc' },
})
return NextResponse.json({ prazos })
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const body = await req.json()
const { title, description, processNumber, court, deadline, alertDays, priority } = body
// Input validation
if (typeof title !== "string" || title.length > 500) {
return NextResponse.json({ error: "Título inválido (máx 500 caracteres)" }, { status: 400 })
}
if (description && typeof description === "string" && description.length > 5000) {
return NextResponse.json({ error: "Descrição muito longa (máx 5000 caracteres)" }, { status: 400 })
}
if (!title || !deadline) {
return NextResponse.json({ error: 'Título e prazo são obrigatórios' }, { status: 400 })
}
const prazo = await prisma.prazo.create({
data: {
userId: session.user.id,
title,
description: description || null,
processNumber: processNumber || null,
court: court || null,
deadline: new Date(deadline),
alertDays: alertDays || 3,
priority: priority || 'MEDIA',
status: 'PENDENTE',
},
})
return NextResponse.json({ prazo }, { status: 201 })
}

View File

@@ -0,0 +1,128 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { buscarProcessoTribunal, isError } from '@/lib/tribunal-client'
// POST /api/processos/[id]/atualizar
// Busca dados em tempo real do tribunal via scraping
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const { id } = await params
// Buscar processo no banco
const processo = await prisma.processoMonitorado.findFirst({
where: {
id,
userId: session.user.id,
},
})
if (!processo) {
return NextResponse.json({ error: 'Processo não encontrado' }, { status: 404 })
}
try {
// Buscar dados em tempo real via tribunal-api
console.log(`[Atualizar] Buscando ${processo.numeroProcesso} via tribunal-api...`)
const resultado = await buscarProcessoTribunal(processo.numeroProcesso)
if (isError(resultado)) {
return NextResponse.json({
error: resultado.erro,
codigo: resultado.codigo,
detalhes: resultado.detalhes,
}, { status: resultado.codigo === 'PROCESSO_NAO_ENCONTRADO' ? 404 : 503 })
}
// Atualizar dados do processo no banco
const updated = await prisma.processoMonitorado.update({
where: { id },
data: {
vara: resultado.vara || processo.vara,
comarca: resultado.comarca || processo.comarca,
classe: resultado.classe || processo.classe,
assunto: resultado.assunto || processo.assunto,
ultimaAtualizacao: new Date(),
},
})
// Salvar novos andamentos se existirem
let novosAndamentos = 0
if (resultado.movimentos && resultado.movimentos.length > 0) {
for (let i = 0; i < resultado.movimentos.length; i++) {
const mov = resultado.movimentos[i]
// Converter data DD/MM/YYYY para Date
const partes = mov.data.split('/')
if (partes.length !== 3) continue
const [dia, mes, ano] = partes
const dataHora = new Date(`${ano}-${mes}-${dia}T12:00:00Z`)
if (isNaN(dataHora.getTime())) continue
// Usar índice como código único para evitar duplicatas
const codigo = parseInt(ano) * 10000 + parseInt(mes) * 100 + parseInt(dia) + i
try {
// Verificar se já existe
const existe = await prisma.andamento.findFirst({
where: {
processoId: id,
dataHora,
nome: mov.descricao.substring(0, 200),
},
})
if (!existe) {
await prisma.andamento.create({
data: {
processoId: id,
dataHora,
codigo,
nome: mov.descricao.substring(0, 200),
complemento: mov.descricao.length > 200 ? mov.descricao : null,
},
})
novosAndamentos++
}
} catch (e) {
// Ignorar duplicatas (unique constraint)
console.log('[Atualizar] Andamento duplicado, ignorando')
}
}
}
// Buscar andamentos atualizados
const andamentos = await prisma.andamento.findMany({
where: { processoId: id },
orderBy: { dataHora: 'desc' },
take: 50,
})
return NextResponse.json({
success: true,
processo: updated,
andamentos,
novosAndamentos,
fonte: 'tribunal-api',
tribunal: resultado.tribunal,
ultimaAtualizacao: resultado.ultimaAtualizacao,
})
} catch (error) {
console.error('[Atualizar] Erro:', error)
return NextResponse.json({
error: 'Erro ao atualizar processo',
detalhes: error instanceof Error ? error.message : 'Erro desconhecido'
}, { status: 500 })
}
}

View File

@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const { id } = await params
const processo = await prisma.processoMonitorado.findFirst({
where: {
id,
userId: session.user.id,
},
include: {
publicacoes: {
orderBy: { dataPublicacao: 'desc' },
},
andamentos: {
orderBy: { dataHora: 'desc' },
},
},
})
if (!processo) {
return NextResponse.json({ error: 'Processo não encontrado' }, { status: 404 })
}
return NextResponse.json({ processo })
}
export async function PUT(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const { id } = await params
const body = await req.json()
const { status, vara, comarca, parteAutora, parteRe } = body
const existing = await prisma.processoMonitorado.findFirst({
where: { id, userId: session.user.id },
})
if (!existing) {
return NextResponse.json({ error: 'Processo não encontrado' }, { status: 404 })
}
const processo = await prisma.processoMonitorado.update({
where: { id },
data: {
...(status && { status }),
...(vara !== undefined && { vara }),
...(comarca !== undefined && { comarca }),
...(parteAutora !== undefined && { parteAutora }),
...(parteRe !== undefined && { parteRe }),
},
})
return NextResponse.json({ processo })
}
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const { id } = await params
const existing = await prisma.processoMonitorado.findFirst({
where: { id, userId: session.user.id },
})
if (!existing) {
return NextResponse.json({ error: 'Processo não encontrado' }, { status: 404 })
}
await prisma.processoMonitorado.delete({ where: { id } })
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,167 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { validarNumeroCNJ } from '@/lib/publicacoes-service'
import { buscarProcessoCompleto } from '@/lib/diarios-service'
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const { searchParams } = new URL(req.url)
const status = searchParams.get('status')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: any = { userId: session.user.id }
if (status) where.status = status
const processos = await prisma.processoMonitorado.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
_count: {
select: {
publicacoes: {
where: { visualizado: false }
}
}
},
publicacoes: {
orderBy: { dataPublicacao: 'desc' },
take: 1,
},
andamentos: {
orderBy: { dataHora: 'desc' },
take: 1,
}
}
})
// Formata resposta com contagem de não lidas
const result = processos.map(p => ({
...p,
publicacoesNaoLidas: p._count.publicacoes,
ultimaPublicacao: p.publicacoes[0] || null,
ultimoAndamento: p.andamentos[0] || null,
publicacoes: undefined,
andamentos: undefined,
_count: undefined,
}))
return NextResponse.json({ processos: result })
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const body = await req.json()
const { numeroProcesso, tribunal, vara, comarca, parteAutora, parteRe } = body
// Validações
if (!numeroProcesso || !tribunal) {
return NextResponse.json(
{ error: 'Número do processo e tribunal são obrigatórios' },
{ status: 400 }
)
}
if (!validarNumeroCNJ(numeroProcesso)) {
return NextResponse.json(
{ error: 'Número do processo inválido. Use o formato CNJ: 0000000-00.0000.0.00.0000' },
{ status: 400 }
)
}
// Verifica se já existe
const existing = await prisma.processoMonitorado.findFirst({
where: {
userId: session.user.id,
numeroProcesso,
}
})
if (existing) {
return NextResponse.json(
{ error: 'Este processo já está sendo monitorado' },
{ status: 400 }
)
}
// Criar processo primeiro
const processo = await prisma.processoMonitorado.create({
data: {
userId: session.user.id,
numeroProcesso,
tribunal,
vara: vara || null,
comarca: comarca || null,
parteAutora: parteAutora || null,
parteRe: parteRe || null,
status: 'ATIVO',
},
})
// Buscar dados completos na API DataJud (não bloqueia a criação)
try {
const resultado = await buscarProcessoCompleto(numeroProcesso, tribunal)
if (resultado.sucesso && resultado.dados) {
const dados = resultado.dados
// Atualizar processo com dados da API
await prisma.processoMonitorado.update({
where: { id: processo.id },
data: {
classe: dados.classe?.nome || null,
assunto: dados.assuntos?.[0]?.nome || null,
dataAjuizamento: dados.dataAjuizamento || null,
orgaoJulgador: dados.orgaoJulgador?.nome || null,
grau: dados.grau || null,
ultimaAtualizacao: new Date(),
dadosCompletos: dados.dadosBrutos || null,
},
})
// Salvar andamentos
if (dados.movimentos && dados.movimentos.length > 0) {
const andamentosData = dados.movimentos.map(mov => ({
processoId: processo.id,
codigo: mov.codigo,
nome: mov.nome,
dataHora: mov.dataHora,
complemento: mov.complementosTabelados?.map(c => c.nome).join(', ') || null,
}))
// Usar createMany com skipDuplicates para evitar erros
await prisma.andamento.createMany({
data: andamentosData,
skipDuplicates: true,
})
}
console.log(`[Processos] Dados completos salvos para ${numeroProcesso}`)
}
} catch (error) {
console.error(`[Processos] Erro ao buscar dados completos para ${numeroProcesso}:`, error)
// Não falha a criação se a busca de dados falhar
}
// Retornar processo atualizado
const processoAtualizado = await prisma.processoMonitorado.findUnique({
where: { id: processo.id },
include: {
andamentos: {
orderBy: { dataHora: 'desc' },
take: 5,
}
}
})
return NextResponse.json({ processo: processoAtualizado }, { status: 201 })
}

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const { id } = await params
// Verifica se a publicação pertence a um processo do usuário
const publicacao = await prisma.publicacao.findFirst({
where: { id },
include: {
processo: {
select: { userId: true },
},
},
})
if (!publicacao) {
return NextResponse.json({ error: 'Publicação não encontrada' }, { status: 404 })
}
if (publicacao.processo.userId !== session.user.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 403 })
}
const updated = await prisma.publicacao.update({
where: { id },
data: { visualizado: true },
})
return NextResponse.json({ publicacao: updated })
}

View File

@@ -0,0 +1,91 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { buscarPublicacoesReais, PublicacaoEncontrada } from '@/lib/diarios-service'
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const body = await req.json()
const { processoId, usarMock = false } = body
// Se processoId fornecido, busca só esse, senão busca todos do usuário
const processos = processoId
? await prisma.processoMonitorado.findMany({
where: { id: processoId, userId: session.user.id, status: 'ATIVO' },
})
: await prisma.processoMonitorado.findMany({
where: { userId: session.user.id, status: 'ATIVO' },
})
if (processos.length === 0) {
return NextResponse.json({ message: 'Nenhum processo ativo para buscar', found: 0 })
}
let totalFound = 0
const erros: string[] = []
for (const processo of processos) {
try {
// Busca REAL nos diários oficiais (DataJud)
const resultado = await buscarPublicacoesReais({
id: processo.id,
numeroProcesso: processo.numeroProcesso,
tribunal: processo.tribunal,
})
if (!resultado.sucesso) {
erros.push(`${processo.numeroProcesso}: ${resultado.erro}`)
continue
}
// Insere as publicações encontradas (evitando duplicatas)
for (const pub of resultado.publicacoes) {
// Verifica se já existe publicação com mesma data, tipo e fonte
const existing = await prisma.publicacao.findFirst({
where: {
processoId: processo.id,
dataPublicacao: {
gte: new Date(pub.dataPublicacao.toDateString()),
lt: new Date(new Date(pub.dataPublicacao).setDate(pub.dataPublicacao.getDate() + 1)),
},
tipo: pub.tipo,
},
})
if (!existing) {
await prisma.publicacao.create({
data: {
processoId: processo.id,
dataPublicacao: pub.dataPublicacao,
diario: pub.diario,
conteudo: pub.conteudo,
tipo: pub.tipo,
prazoCalculado: pub.prazoCalculado,
prazoTipo: pub.prazoTipo,
visualizado: false,
},
})
totalFound++
}
}
} catch (error) {
const msg = error instanceof Error ? error.message : 'Erro desconhecido'
erros.push(`${processo.numeroProcesso}: ${msg}`)
console.error(`[API] Erro ao buscar ${processo.numeroProcesso}:`, error)
}
}
return NextResponse.json({
message: totalFound > 0
? `Encontradas ${totalFound} nova(s) publicação(ões)`
: 'Nenhuma nova publicação encontrada',
found: totalFound,
processados: processos.length,
erros: erros.length > 0 ? erros : undefined,
})
}

View File

@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const { searchParams } = new URL(req.url)
const processoId = searchParams.get('processoId')
const visualizado = searchParams.get('visualizado')
const tipo = searchParams.get('tipo')
const dataInicio = searchParams.get('dataInicio')
const dataFim = searchParams.get('dataFim')
const limit = parseInt(searchParams.get('limit') || '50')
const offset = parseInt(searchParams.get('offset') || '0')
// Busca IDs dos processos do usuário
const processosDoUsuario = await prisma.processoMonitorado.findMany({
where: { userId: session.user.id },
select: { id: true },
})
const processoIds = processosDoUsuario.map(p => p.id)
if (processoIds.length === 0) {
return NextResponse.json({ publicacoes: [], total: 0 })
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: any = {
processoId: processoId ? processoId : { in: processoIds },
}
if (visualizado !== null && visualizado !== undefined) {
where.visualizado = visualizado === 'true'
}
if (tipo) {
where.tipo = tipo
}
if (dataInicio || dataFim) {
where.dataPublicacao = {}
if (dataInicio) where.dataPublicacao.gte = new Date(dataInicio)
if (dataFim) where.dataPublicacao.lte = new Date(dataFim)
}
const [publicacoes, total] = await Promise.all([
prisma.publicacao.findMany({
where,
orderBy: { dataPublicacao: 'desc' },
take: limit,
skip: offset,
include: {
processo: {
select: {
numeroProcesso: true,
tribunal: true,
parteAutora: true,
parteRe: true,
},
},
},
}),
prisma.publicacao.count({ where }),
])
return NextResponse.json({ publicacoes, total })
}

View File

@@ -0,0 +1,99 @@
import { NextResponse } from 'next/server'
import bcrypt from 'bcryptjs'
import { prisma } from '@/lib/prisma'
import { sanitizeString, MAX_LENGTHS } from '@/lib/validate'
export async function POST(request: Request) {
try {
const body = await request.json()
const name = sanitizeString(body.name, MAX_LENGTHS.name)
const email = sanitizeString(body.email, MAX_LENGTHS.email)
const password = typeof body.password === 'string' ? body.password.slice(0, MAX_LENGTHS.password) : ''
const oabNumber = body.oabNumber ? sanitizeString(body.oabNumber, MAX_LENGTHS.oabNumber) : null
const oabState = body.oabState ? sanitizeString(body.oabState, MAX_LENGTHS.oabState) : null
const phone = body.phone ? sanitizeString(body.phone, MAX_LENGTHS.phone) : null
// Validate required fields
if (!name || !email || !password) {
return NextResponse.json(
{ error: 'Nome, email e senha são obrigatórios' },
{ status: 400 }
)
}
// Validate email format
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return NextResponse.json(
{ error: 'Email inválido' },
{ status: 400 }
)
}
// Validate password length
if (password.length < 8) {
return NextResponse.json(
{ error: 'Senha deve ter pelo menos 8 caracteres' },
{ status: 400 }
)
}
// Validate OAB number format if provided
if (oabNumber) {
const cleanOab = oabNumber.replace(/\D/g, '')
if (!/^\d{3,7}$/.test(cleanOab)) {
return NextResponse.json(
{ error: 'Número OAB inválido (3-7 dígitos)' },
{ status: 400 }
)
}
}
// Check email uniqueness
const existingUser = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
})
if (existingUser) {
return NextResponse.json(
{ error: 'Este email já está cadastrado' },
{ status: 409 }
)
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12)
// Create user with FREE plan and 5 credits
const user = await prisma.user.create({
data: {
name,
email: email.toLowerCase(),
password: hashedPassword,
role: 'FREE',
plan: 'FREE',
credits: 5,
oabNumber,
oabState,
phone,
},
select: {
id: true,
name: true,
email: true,
plan: true,
credits: true,
},
})
return NextResponse.json(
{ message: 'Conta criada com sucesso', user },
{ status: 201 }
)
} catch (error) {
console.error('Register error:', error)
return NextResponse.json(
{ error: 'Erro interno do servidor' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { stripe } from '@/lib/stripe'
import { prisma } from '@/lib/prisma'
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Não autenticado' }, { status: 401 })
}
const { priceId } = await req.json()
if (!priceId) {
return NextResponse.json({ error: 'Price ID é obrigatório' }, { status: 400 })
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
})
if (!user) {
return NextResponse.json({ error: 'Usuário não encontrado' }, { status: 404 })
}
// Get or create Stripe customer
let customerId = user.stripeCustomerId
if (customerId) {
// Verify the stored customer still exists in Stripe
try {
await stripe.customers.retrieve(customerId)
} catch (err: any) {
// Customer doesn't exist in Stripe (e.g., from test mode or deleted)
console.warn(`Stripe customer ${customerId} not found, creating new one for user ${user.id}`)
customerId = null
// Clear stale Stripe data in the database
await prisma.user.update({
where: { id: user.id },
data: {
stripeCustomerId: null,
stripeSubscriptionId: null,
stripePriceId: null,
},
})
}
}
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
name: user.name,
metadata: { userId: user.id },
})
customerId = customer.id
await prisma.user.update({
where: { id: user.id },
data: { stripeCustomerId: customerId },
})
}
// Create checkout session
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `https://lexmind.adv.br/adv/dashboard?upgrade=success`,
cancel_url: `https://lexmind.adv.br/adv/pricing`,
allow_promotion_codes: true,
metadata: { userId: user.id },
subscription_data: {
metadata: { userId: user.id },
},
})
return NextResponse.json({ url: checkoutSession.url })
} catch (error: any) {
console.error('Stripe checkout error:', error)
return NextResponse.json(
{ error: 'Erro ao criar sessão de checkout' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { stripe } from '@/lib/stripe'
import { prisma } from '@/lib/prisma'
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Não autenticado' }, { status: 401 })
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { stripeCustomerId: true },
})
if (!user?.stripeCustomerId) {
return NextResponse.json(
{ error: 'Nenhuma assinatura encontrada' },
{ status: 400 }
)
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `https://lexmind.adv.br/adv/dashboard/configuracoes`,
})
return NextResponse.json({ url: portalSession.url })
} catch (error: any) {
console.error('Stripe portal error:', error)
return NextResponse.json(
{ error: 'Erro ao acessar portal de assinatura' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,179 @@
import { NextRequest, NextResponse } from 'next/server'
import { stripe, PRICE_TO_PLAN } from '@/lib/stripe'
import { prisma } from '@/lib/prisma'
import { headers } from 'next/headers'
export async function POST(req: NextRequest) {
const body = await req.text()
const headersList = await headers()
const sig = headersList.get('stripe-signature')
if (!sig) {
return NextResponse.json({ error: 'No signature' }, { status: 400 })
}
let event
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err: any) {
console.error('Webhook signature verification failed:', err.message)
return NextResponse.json({ error: `Webhook Error: ${err.message}` }, { status: 400 })
}
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as any
const userId = session.metadata?.userId
const subscriptionId = session.subscription as string
const customerId = session.customer as string
if (!userId || !subscriptionId) {
console.error('Missing userId or subscriptionId in checkout session')
break
}
// Get subscription to find the price
const subscription = await stripe.subscriptions.retrieve(subscriptionId)
const priceId = subscription.items.data[0]?.price?.id
if (!priceId) {
console.error('No price ID found in subscription')
break
}
const planMapping = PRICE_TO_PLAN[priceId]
if (!planMapping) {
console.error('Unknown price ID:', priceId)
break
}
// Update user
await prisma.user.update({
where: { id: userId },
data: {
plan: planMapping.plan,
credits: planMapping.credits,
stripeCustomerId: customerId,
stripeSubscriptionId: subscriptionId,
stripePriceId: priceId,
},
})
// Create subscription record
await prisma.subscription.create({
data: {
userId,
plan: planMapping.plan,
status: 'ACTIVE',
stripeId: subscriptionId,
},
})
console.log(`✅ User ${userId} upgraded to ${planMapping.plan} with ${planMapping.credits} credits`)
break
}
case 'customer.subscription.updated': {
const subscription = event.data.object as any
const userId = subscription.metadata?.userId
if (!userId) {
console.log('No userId in subscription metadata, skipping')
break
}
const priceId = subscription.items.data[0]?.price?.id
if (priceId) {
const planMapping = PRICE_TO_PLAN[priceId]
if (planMapping) {
await prisma.user.update({
where: { id: userId },
data: {
plan: planMapping.plan,
credits: planMapping.credits,
stripePriceId: priceId,
},
})
console.log(`📝 User ${userId} subscription updated to ${planMapping.plan}`)
}
}
// Update subscription status
if (subscription.status === 'active') {
await prisma.subscription.updateMany({
where: { stripeId: subscription.id },
data: { status: 'ACTIVE' },
})
} else if (subscription.status === 'canceled' || subscription.status === 'unpaid') {
await prisma.subscription.updateMany({
where: { stripeId: subscription.id },
data: { status: 'CANCELLED' },
})
}
break
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as any
const userId = subscription.metadata?.userId
if (userId) {
await prisma.user.update({
where: { id: userId },
data: {
plan: 'FREE',
credits: 5,
stripeSubscriptionId: null,
stripePriceId: null,
},
})
await prisma.subscription.updateMany({
where: { stripeId: subscription.id },
data: { status: 'CANCELLED' },
})
console.log(`❌ User ${userId} downgraded to FREE`)
}
break
}
case 'invoice.payment_succeeded': {
const invoice = event.data.object as any
// On renewal, refresh credits
const subscriptionId = invoice.subscription as string
if (subscriptionId && invoice.billing_reason === 'subscription_cycle') {
const sub = await stripe.subscriptions.retrieve(subscriptionId)
const userId = sub.metadata?.userId
const priceId = sub.items.data[0]?.price?.id
if (userId && priceId) {
const planMapping = PRICE_TO_PLAN[priceId]
if (planMapping) {
await prisma.user.update({
where: { id: userId },
data: { credits: planMapping.credits },
})
console.log(`💰 Renewed credits for user ${userId}: ${planMapping.credits}`)
}
}
}
break
}
default:
console.log(`Unhandled event: ${event.type}`)
}
} catch (err: any) {
console.error('Error processing webhook:', err)
// Still return 200 to prevent Stripe retries for processing errors
}
return NextResponse.json({ received: true })
}

View File

@@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
const VALID_TYPES = [
'PETICAO_INICIAL', 'CONTESTACAO', 'APELACAO', 'RECURSO',
'CONTRATO', 'PARECER', 'IMPUGNACAO', 'HABEAS_CORPUS',
'MANDADO_SEGURANCA', 'OUTROS',
]
const VALID_AREAS = [
'CIVIL', 'TRABALHISTA', 'PENAL', 'TRIBUTARIO',
'FAMILIA', 'EMPRESARIAL', 'CONSUMIDOR', 'ADMINISTRATIVO',
]
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const { searchParams } = new URL(req.url)
const type = searchParams.get('type')
const area = searchParams.get('area')
const search = searchParams.get('search')
// Return public templates + user's own templates
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: any = {
OR: [
{ isPublic: true },
{ userId: session.user.id },
],
}
if (type) where.type = type
if (area) where.area = area
if (search) {
where.AND = [
{
OR: [
{ name: { contains: search } },
{ description: { contains: search } },
],
},
]
}
const templates = await prisma.template.findMany({
where,
orderBy: [
{ isPublic: 'desc' },
{ createdAt: 'desc' },
],
select: {
id: true,
name: true,
description: true,
type: true,
area: true,
prompt: true,
isPublic: true,
userId: true,
createdAt: true,
},
})
return NextResponse.json({ templates })
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
try {
const body = await req.json()
const { name, description, type, area, prompt } = body
if (!name || !description || !type || !area || !prompt) {
return NextResponse.json(
{ error: 'Todos os campos são obrigatórios: name, description, type, area, prompt' },
{ status: 400 }
)
}
if (!VALID_TYPES.includes(type)) {
return NextResponse.json({ error: `Tipo inválido. Tipos válidos: ${VALID_TYPES.join(', ')}` }, { status: 400 })
}
if (!VALID_AREAS.includes(area)) {
return NextResponse.json({ error: `Área inválida. Áreas válidas: ${VALID_AREAS.join(', ')}` }, { status: 400 })
}
const template = await prisma.template.create({
data: {
name,
description,
type,
area,
prompt,
isPublic: false,
userId: session.user.id,
},
})
return NextResponse.json({ template }, { status: 201 })
} catch {
return NextResponse.json({ error: 'Erro ao criar modelo' }, { status: 500 })
}
}

View File

@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { deleteFile, getSignedUrl } from '@/lib/spaces'
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const { id } = await params
const upload = await prisma.upload.findFirst({
where: { id, userId: session.user.id },
})
if (!upload) {
return NextResponse.json({ error: 'Arquivo não encontrado' }, { status: 404 })
}
const url = await getSignedUrl(upload.key)
return NextResponse.redirect(url)
}
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const { id } = await params
const upload = await prisma.upload.findFirst({
where: { id, userId: session.user.id },
})
if (!upload) {
return NextResponse.json({ error: 'Arquivo não encontrado' }, { status: 404 })
}
try {
await deleteFile(upload.key)
} catch (error) {
console.error('S3 delete error:', error)
// Continue with DB deletion even if S3 fails
}
await prisma.upload.delete({ where: { id } })
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,162 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { uploadFile, buildKey } from '@/lib/spaces'
const ALLOWED_TYPES = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain',
]
const MAX_SIZE = 50 * 1024 * 1024 // 50MB
const STORAGE_LIMITS: Record<string, number> = {
FREE: 1 * 1024 * 1024 * 1024, // 1GB
STARTER: 1 * 1024 * 1024 * 1024, // 1GB
PRO: 5 * 1024 * 1024 * 1024, // 5GB
ENTERPRISE: 20 * 1024 * 1024 * 1024, // 20GB
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
try {
const formData = await req.formData()
const file = formData.get('file') as File | null
if (!file) {
return NextResponse.json({ error: 'Nenhum arquivo enviado' }, { status: 400 })
}
// Validate file extension (defense in depth)
const ext = file.name.split(".").pop()?.toLowerCase()
const ALLOWED_EXTENSIONS = ["pdf", "doc", "docx", "txt"]
if (!ext || !ALLOWED_EXTENSIONS.includes(ext)) {
return NextResponse.json(
{ error: "Extensão de arquivo não permitida" },
{ status: 400 }
)
}
// Validate mime type
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: 'Tipo de arquivo não permitido. Aceitos: PDF, DOCX, DOC, TXT' },
{ status: 400 }
)
}
// Validate size
if (file.size > MAX_SIZE) {
return NextResponse.json(
{ error: 'Arquivo muito grande. Máximo: 50MB' },
{ status: 400 }
)
}
// Check storage limit
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { plan: true },
})
const storageLimit = STORAGE_LIMITS[user?.plan || 'FREE'] || STORAGE_LIMITS.FREE
const usedStorage = await prisma.upload.aggregate({
where: { userId: session.user.id },
_sum: { size: true },
})
const currentUsage = usedStorage._sum.size || 0
if (currentUsage + file.size > storageLimit) {
return NextResponse.json(
{ error: 'Limite de armazenamento atingido. Faça upgrade do plano para mais espaço.' },
{ status: 400 }
)
}
// Upload to Spaces
const buffer = Buffer.from(await file.arrayBuffer())
const key = buildKey(session.user.id, file.name)
await uploadFile(buffer, key, file.type)
// Save to DB
const upload = await prisma.upload.create({
data: {
userId: session.user.id,
filename: file.name,
key,
size: file.size,
mimeType: file.type,
},
})
return NextResponse.json({
upload: {
id: upload.id,
filename: upload.filename,
size: upload.size,
mimeType: upload.mimeType,
createdAt: upload.createdAt,
},
})
} catch (error) {
console.error('Upload error:', error)
return NextResponse.json({ error: 'Erro ao fazer upload' }, { status: 500 })
}
}
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
}
const { searchParams } = new URL(req.url)
const page = Math.max(1, Math.min(1000, parseInt(searchParams.get('page') || '1')))
const limit = Math.max(1, Math.min(100, parseInt(searchParams.get('limit') || '20')))
const skip = (page - 1) * limit
const [uploads, total, storageUsed] = await Promise.all([
prisma.upload.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'desc' },
skip,
take: limit,
select: {
id: true,
filename: true,
size: true,
mimeType: true,
createdAt: true,
},
}),
prisma.upload.count({ where: { userId: session.user.id } }),
prisma.upload.aggregate({
where: { userId: session.user.id },
_sum: { size: true },
}),
])
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { plan: true },
})
const storageLimit = STORAGE_LIMITS[user?.plan || 'FREE'] || STORAGE_LIMITS.FREE
return NextResponse.json({
uploads,
total,
page,
totalPages: Math.ceil(total / limit),
storageUsed: storageUsed._sum.size || 0,
storageLimit,
})
}

View File

@@ -0,0 +1,239 @@
'use client'
import { usePathname } from 'next/navigation'
import Link from 'next/link'
import { useState } from 'react'
import {
LayoutDashboard,
FilePlus,
FileText,
MessageSquare,
Brain,
Clock,
FileSearch,
Scale,
BookTemplate,
Settings,
Menu,
X,
LogOut,
ChevronRight,
Sparkles,
Newspaper,
} from 'lucide-react'
import { signOut } from 'next-auth/react'
interface DashboardShellProps {
children: React.ReactNode
user: {
name: string
email: string
plan: string
avatar?: string | null
oabNumber?: string | null
oabState?: string | null
}
}
const navItems = [
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/dashboard/nova-peca', label: 'Novo Documento', icon: FilePlus },
{ href: '/dashboard/minhas-pecas', label: 'Meus Documentos', icon: FileText },
{ href: '/dashboard/chat', label: 'Consultor IA', icon: MessageSquare },
{ href: '/dashboard/jurisprudencia', label: 'Jurisprudência', icon: Brain },
{ href: '/dashboard/prazos', label: 'Gestão de Prazos', icon: Clock },
{ href: '/dashboard/publicacoes', label: 'Publicações', icon: Newspaper },
{ href: '/dashboard/auditoria', label: 'Auditoria Contratos', icon: FileSearch },
{ href: '/dashboard/analise-processo', label: 'Análise de Processo', icon: Scale },
{ href: '/dashboard/modelos', label: 'Modelos', icon: BookTemplate },
{ href: '/dashboard/configuracoes', label: 'Configurações', icon: Settings },
]
const planColors: Record<string, string> = {
FREE: 'bg-zinc-700 text-zinc-300',
PRO: 'bg-teal-600/20 text-teal-400 border border-teal-500/30',
ENTERPRISE: 'bg-amber-600/20 text-amber-400 border border-amber-500/30',
}
const planLabels: Record<string, string> = {
FREE: 'Free',
PRO: 'Pro',
ENTERPRISE: 'Enterprise',
}
export default function DashboardShell({ children, user }: DashboardShellProps) {
const pathname = usePathname()
const [sidebarOpen, setSidebarOpen] = useState(false)
const isActive = (href: string) => {
if (href === '/dashboard') return pathname === '/dashboard'
return pathname.startsWith(href)
}
const initials = user.name
.split(' ')
.map((n) => n[0])
.slice(0, 2)
.join('')
.toUpperCase()
return (
<div className="flex h-screen overflow-hidden bg-[#0f1620]">
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={`
fixed inset-y-0 left-0 z-50 flex w-72 flex-col border-r border-white/5 bg-[#0a1018]
transition-transform duration-300 ease-in-out lg:static lg:translate-x-0
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}
>
{/* Logo */}
<div className="flex h-16 items-center gap-3 border-b border-white/5 px-6">
<div className="relative flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-teal-600 via-teal-500 to-cyan-500">
<Brain className="h-5 w-5 text-white" />
<div className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-gradient-to-br from-cyan-400 to-teal-600 flex items-center justify-center">
<span className="text-[6px] font-bold text-white">§</span>
</div>
</div>
<div>
<span className="text-lg font-bold text-white">Lex</span>
<span className="text-lg font-bold text-teal-400">Mind</span>
</div>
<button
onClick={() => setSidebarOpen(false)}
className="ml-auto rounded-lg p-1.5 text-zinc-400 hover:bg-white/5 hover:text-white lg:hidden"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto px-3 py-4">
<div className="space-y-1">
{navItems.map((item) => {
const active = isActive(item.href)
const Icon = item.icon
return (
<Link
key={item.href}
href={item.href}
onClick={() => setSidebarOpen(false)}
className={`
group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium
transition-all duration-150
${
active
? 'bg-teal-600/15 text-teal-400 shadow-[inset_0_0_0_1px_rgba(139,92,246,0.2)]'
: 'text-zinc-400 hover:bg-white/5 hover:text-zinc-200'
}
`}
>
<Icon
className={`h-5 w-5 flex-shrink-0 ${
active ? 'text-teal-400' : 'text-zinc-500 group-hover:text-zinc-300'
}`}
/>
{item.label}
{active && (
<ChevronRight className="ml-auto h-4 w-4 text-teal-500/60" />
)}
</Link>
)
})}
</div>
{/* Upgrade CTA for free users */}
{user.plan === 'FREE' && (
<div className="mx-1 mt-6 rounded-xl border border-teal-500/20 bg-gradient-to-br from-teal-600/10 to-teal-900/10 p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-teal-300">
<Sparkles className="h-4 w-4" />
Ativar plano Advocacia
</div>
<p className="mt-1.5 text-xs leading-relaxed text-zinc-400">
Documentos ilimitados, consultor avançado e jurisprudência premium.
</p>
<Link
href="/dashboard/configuracoes#planos"
className="mt-3 block rounded-lg bg-teal-600 px-3 py-1.5 text-center text-xs font-semibold text-white transition-colors hover:bg-teal-500"
>
Ver opções
</Link>
</div>
)}
</nav>
{/* User info */}
<div className="border-t border-white/5 p-3">
<div className="flex items-center gap-3 rounded-xl px-3 py-3">
{user.avatar ? (
<img
src={user.avatar}
alt={user.name}
className="h-9 w-9 rounded-full object-cover ring-2 ring-teal-500/30"
/>
) : (
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-teal-600/20 text-sm font-bold text-teal-400 ring-2 ring-teal-500/30">
{initials}
</div>
)}
<div className="flex-1 overflow-hidden">
<p className="truncate text-sm font-medium text-white">{user.name}</p>
<div className="flex items-center gap-2">
<p className="truncate text-xs text-zinc-500">{user.email}</p>
</div>
</div>
<span
className={`rounded-md px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${
planColors[user.plan] || planColors.FREE
}`}
>
{planLabels[user.plan] || user.plan}
</span>
</div>
<button
onClick={() => signOut({ callbackUrl: '/login' })}
className="mt-1 flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm text-zinc-500 transition-colors hover:bg-red-500/10 hover:text-red-400"
>
<LogOut className="h-4 w-4" />
Sair
</button>
</div>
</aside>
{/* Main content */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* Top bar (mobile) */}
<header className="flex h-14 items-center gap-3 border-b border-white/5 bg-[#0a1018]/80 px-4 backdrop-blur-md lg:hidden">
<button
onClick={() => setSidebarOpen(true)}
className="rounded-lg p-2 text-zinc-400 hover:bg-white/5 hover:text-white"
>
<Menu className="h-5 w-5" />
</button>
<div className="flex items-center gap-2">
<div className="relative flex h-7 w-7 items-center justify-center rounded-md bg-gradient-to-br from-teal-500 via-teal-500 to-cyan-500">
<Brain className="h-4 w-4 text-white" />
</div>
<span className="text-sm font-bold text-white">Lex</span>
<span className="text-sm font-bold text-teal-400">Mind</span>
</div>
</header>
{/* Page content */}
<main className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
{children}
</div>
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,78 @@
'use client'
import { useSearchParams } from 'next/navigation'
import { useState, useEffect } from 'react'
import { CheckCircle2, X, Sparkles } from 'lucide-react'
import Link from 'next/link'
export function UpgradeSuccessBanner() {
const searchParams = useSearchParams()
const [show, setShow] = useState(false)
useEffect(() => {
if (searchParams.get('upgrade') === 'success') {
setShow(true)
// Clean URL
window.history.replaceState({}, '', window.location.pathname)
}
}, [searchParams])
if (!show) return null
return (
<div className="relative overflow-hidden rounded-2xl border border-emerald-500/20 bg-emerald-950/20 p-4 sm:p-6 mb-6 animate-fade-in-up">
<div className="absolute -right-10 -top-10 h-40 w-40 rounded-full bg-emerald-600/10 blur-3xl" />
<div className="relative flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="rounded-xl bg-emerald-500/20 p-2">
<CheckCircle2 className="h-5 w-5 text-emerald-400" />
</div>
<div>
<h3 className="text-sm font-semibold text-emerald-300">Assinatura ativada com sucesso! 🎉</h3>
<p className="text-xs text-emerald-400/70">
Seus créditos foram atualizados. Aproveite todos os recursos do seu novo plano.
</p>
</div>
</div>
<button
onClick={() => setShow(false)}
className="rounded-lg p-1 text-emerald-400/50 hover:text-emerald-300 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
)
}
export function UpgradePrompt({ plan, credits }: { plan: string; credits: number }) {
if (plan !== 'FREE') return null
return (
<div className="relative overflow-hidden rounded-2xl border border-teal-500/20 bg-gradient-to-r from-teal-950/30 via-[#111b27] to-teal-950/30 p-4 sm:p-6 mb-6">
<div className="absolute -right-10 -top-10 h-40 w-40 rounded-full bg-teal-600/10 blur-3xl" />
<div className="relative flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="rounded-xl bg-teal-500/20 p-2">
<Sparkles className="h-5 w-5 text-teal-400" />
</div>
<div>
<h3 className="text-sm font-semibold text-white">
Você está no plano Gratuito ({credits} créditos restantes)
</h3>
<p className="text-xs text-zinc-400">
Faça upgrade para ter mais créditos, IA avançada e funcionalidades premium.
</p>
</div>
</div>
<Link
href="/pricing"
className="shrink-0 inline-flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold bg-teal-600 text-white hover:bg-teal-500 transition-all"
>
<Sparkles className="h-4 w-4" />
Ver Planos
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,500 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import ReactMarkdown from 'react-markdown'
import {
FileText,
Upload,
Brain,
Scale,
Loader2,
Copy,
Check,
FileDown,
Plus,
Clock,
AlertCircle,
X,
ChevronRight,
Sparkles,
} from 'lucide-react'
interface AnalysisSummary {
id: string
title: string
filename: string
fileSize: number
status: string
summary: string | null
createdAt: string
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
export default function AnaliseProcessoPage() {
const router = useRouter()
const [file, setFile] = useState<File | null>(null)
const [dragOver, setDragOver] = useState(false)
const [analyzing, setAnalyzing] = useState(false)
const [analysis, setAnalysis] = useState('')
const [analysisId, setAnalysisId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [history, setHistory] = useState<AnalysisSummary[]>([])
const [loadingHistory, setLoadingHistory] = useState(true)
const [showHistory, setShowHistory] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const analysisRef = useRef<HTMLDivElement>(null)
const fetchHistory = useCallback(async () => {
try {
const res = await fetch('/api/analise-processo')
if (res.ok) {
const data = await res.json()
setHistory(data.analyses || [])
}
} catch {
// ignore
} finally {
setLoadingHistory(false)
}
}, [])
useEffect(() => {
fetchHistory()
}, [fetchHistory])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setDragOver(false)
const dropped = e.dataTransfer.files[0]
if (dropped?.type === 'application/pdf') {
setFile(dropped)
setError(null)
} else {
setError('Apenas arquivos PDF são aceitos.')
}
}, [])
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files?.[0]
if (selected) {
if (selected.type === 'application/pdf') {
setFile(selected)
setError(null)
} else {
setError('Apenas arquivos PDF são aceitos.')
}
}
}, [])
const handleAnalyze = async () => {
if (!file) return
setAnalyzing(true)
setAnalysis('')
setAnalysisId(null)
setError(null)
try {
const formData = new FormData()
formData.append('file', file)
const res = await fetch('/api/analise-processo', {
method: 'POST',
body: formData,
})
if (!res.ok) {
const data = await res.json()
setError(data.error || 'Erro ao processar análise')
setAnalyzing(false)
return
}
const reader = res.body?.getReader()
if (!reader) {
setError('Erro na conexão com o servidor')
setAnalyzing(false)
return
}
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
try {
const data = JSON.parse(line.slice(6))
if (data.type === 'id') {
setAnalysisId(data.id)
} else if (data.type === 'chunk') {
setAnalysis(prev => prev + data.content)
} else if (data.type === 'done') {
setAnalysisId(data.id)
fetchHistory()
} else if (data.type === 'error') {
setError(data.message)
}
} catch {
// ignore parse errors
}
}
}
} catch (err) {
setError('Erro de conexão. Tente novamente.')
} finally {
setAnalyzing(false)
}
}
const handleCopy = async () => {
const plain = analysis.replace(/#{1,6}\s/g, '').replace(/\*\*/g, '').replace(/\*/g, '').replace(/- /g, '• ')
await navigator.clipboard.writeText(plain)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleExportDocx = async () => {
const title = file?.name?.replace(/\.pdf$/i, '') || 'Análise de Processo'
const res = await fetch('/api/export/docx', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: `Análise - ${title}`, content: analysis }),
})
if (res.ok) {
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `analise-${title}.docx`
a.click()
URL.revokeObjectURL(url)
}
}
const handleViewHistory = async (id: string) => {
try {
const res = await fetch(`/api/analise-processo/${id}`)
if (res.ok) {
const data = await res.json()
setAnalysis(data.analysis.analysis)
setAnalysisId(data.analysis.id)
setFile(null)
setShowHistory(false)
}
} catch {
setError('Erro ao carregar análise')
}
}
const handleReset = () => {
setFile(null)
setAnalysis('')
setAnalysisId(null)
setError(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
const statusColors: Record<string, string> = {
DONE: 'text-emerald-400',
ANALYZING: 'text-amber-400',
PENDING: 'text-zinc-400',
ERROR: 'text-red-400',
}
const statusLabels: Record<string, string> = {
DONE: 'Concluída',
ANALYZING: 'Analisando...',
PENDING: 'Pendente',
ERROR: 'Erro',
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="flex items-center gap-3 text-2xl font-bold text-white">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-teal-600/20">
<Scale className="h-5 w-5 text-teal-400" />
</div>
Análise de Processo
</h1>
<p className="mt-1 text-sm text-zinc-400">
Envie um PDF e receba uma análise jurídica completa com IA
</p>
</div>
<button
onClick={() => setShowHistory(!showHistory)}
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10"
>
<Clock className="h-4 w-4" />
Histórico
{history.length > 0 && (
<span className="rounded-full bg-teal-600/30 px-2 py-0.5 text-xs text-teal-400">
{history.length}
</span>
)}
</button>
</div>
<div className="flex gap-6">
{/* Main Content */}
<div className="flex-1 space-y-6">
{/* Upload / Result */}
{!analysis ? (
<div className="rounded-2xl border border-white/5 bg-[#0d1420] p-6">
{/* Drop Zone */}
<div
onDrop={handleDrop}
onDragOver={e => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onClick={() => fileInputRef.current?.click()}
className={`relative cursor-pointer rounded-xl border-2 border-dashed p-12 text-center transition-all duration-200 ${
dragOver
? 'border-teal-400 bg-teal-400/5'
: file
? 'border-teal-500/30 bg-teal-500/5'
: 'border-white/10 hover:border-white/20 hover:bg-white/[0.02]'
}`}
>
<input
ref={fileInputRef}
type="file"
accept=".pdf,application/pdf"
onChange={handleFileSelect}
className="hidden"
/>
{file ? (
<div className="space-y-3">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-teal-600/20">
<FileText className="h-8 w-8 text-teal-400" />
</div>
<div>
<p className="text-lg font-medium text-white">{file.name}</p>
<p className="text-sm text-zinc-400">{formatFileSize(file.size)}</p>
</div>
<button
onClick={(e) => {
e.stopPropagation()
handleReset()
}}
className="inline-flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-300"
>
<X className="h-3 w-3" /> Remover
</button>
</div>
) : (
<div className="space-y-3">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5">
<Upload className="h-8 w-8 text-zinc-500" />
</div>
<div>
<p className="text-lg font-medium text-zinc-300">
Arraste um PDF aqui ou clique para selecionar
</p>
<p className="text-sm text-zinc-500">
Processos judiciais, petições, contratos e outros documentos jurídicos
</p>
</div>
</div>
)}
</div>
{/* Error */}
{error && (
<div className="mt-4 flex items-center gap-2 rounded-xl bg-red-500/10 border border-red-500/20 px-4 py-3 text-sm text-red-400">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
{error}
</div>
)}
{/* Analyze Button */}
<div className="mt-6 flex items-center justify-between">
<p className="flex items-center gap-1.5 text-xs text-zinc-500">
<Sparkles className="h-3 w-3 text-teal-500" />
A análise consome 5 créditos
</p>
<button
onClick={handleAnalyze}
disabled={!file || analyzing}
className="flex items-center gap-2 rounded-xl bg-teal-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-teal-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{analyzing ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Analisando...
</>
) : (
<>
<Brain className="h-4 w-4" />
Analisar Processo
</>
)}
</button>
</div>
</div>
) : (
<div className="space-y-4">
{/* Action Bar */}
<div className="flex items-center gap-3">
<button
onClick={handleReset}
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10"
>
<Plus className="h-4 w-4" />
Nova Análise
</button>
<button
onClick={handleCopy}
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10"
>
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
{copied ? 'Copiado!' : 'Copiar'}
</button>
<button
onClick={handleExportDocx}
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10"
>
<FileDown className="h-4 w-4" />
Baixar Word
</button>
{analysisId && !analyzing && (
<button
onClick={() => router.push(`/dashboard/nova-peca?from_analysis=${analysisId}`)}
className="flex items-center gap-2 rounded-xl bg-gradient-to-r from-teal-600 to-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-teal-500/20 transition hover:from-teal-500 hover:to-emerald-500"
>
<Sparkles className="h-4 w-4" />
Gerar Petição baseada neste Parecer
</button>
)}
</div>
{/* Analysis Content */}
<div
ref={analysisRef}
className="rounded-2xl border border-white/5 bg-[#0d1420] p-6 md:p-8"
>
{analyzing && (
<div className="mb-4 flex items-center gap-2 text-sm text-teal-400">
<Loader2 className="h-4 w-4 animate-spin" />
Analisando documento...
</div>
)}
<div className="prose prose-invert prose-teal max-w-none prose-headings:text-white prose-headings:border-b prose-headings:border-white/5 prose-headings:pb-2 prose-headings:mb-4 prose-h2:text-lg prose-h2:mt-8 prose-p:text-zinc-300 prose-li:text-zinc-300 prose-strong:text-teal-300 prose-ul:space-y-1">
<ReactMarkdown>{analysis}</ReactMarkdown>
</div>
</div>
</div>
)}
</div>
{/* History Sidebar */}
{showHistory && (
<div className="hidden w-80 shrink-0 lg:block">
<div className="sticky top-6 rounded-2xl border border-white/5 bg-[#0d1420] p-4">
<h3 className="mb-4 flex items-center gap-2 text-sm font-semibold text-white">
<Clock className="h-4 w-4 text-teal-400" />
Análises Anteriores
</h3>
{loadingHistory ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
</div>
) : history.length === 0 ? (
<p className="py-6 text-center text-sm text-zinc-500">
Nenhuma análise ainda
</p>
) : (
<div className="space-y-2 max-h-[60vh] overflow-y-auto">
{history.map((item) => (
<button
key={item.id}
onClick={() => handleViewHistory(item.id)}
className={`w-full rounded-xl p-3 text-left transition hover:bg-white/5 ${
analysisId === item.id ? 'bg-teal-600/10 border border-teal-500/20' : 'border border-transparent'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-zinc-200">
{item.title}
</p>
<p className="mt-0.5 text-xs text-zinc-500">
{formatDate(item.createdAt)} {formatFileSize(item.fileSize)}
</p>
</div>
<span className={`text-xs ${statusColors[item.status] || 'text-zinc-400'}`}>
{statusLabels[item.status] || item.status}
</span>
</div>
</button>
))}
</div>
)}
</div>
</div>
)}
</div>
{/* Mobile History */}
{showHistory && (
<div className="lg:hidden rounded-2xl border border-white/5 bg-[#0d1420] p-4">
<h3 className="mb-4 flex items-center gap-2 text-sm font-semibold text-white">
<Clock className="h-4 w-4 text-teal-400" />
Análises Anteriores
</h3>
{loadingHistory ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
</div>
) : history.length === 0 ? (
<p className="py-6 text-center text-sm text-zinc-500">Nenhuma análise ainda</p>
) : (
<div className="space-y-2">
{history.map((item) => (
<button
key={item.id}
onClick={() => handleViewHistory(item.id)}
className="flex w-full items-center justify-between rounded-xl border border-white/5 p-3 text-left transition hover:bg-white/5"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-zinc-200">{item.title}</p>
<p className="mt-0.5 text-xs text-zinc-500">
{formatDate(item.createdAt)} {formatFileSize(item.fileSize)}
</p>
</div>
<ChevronRight className="h-4 w-4 flex-shrink-0 text-zinc-500" />
</button>
))}
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,483 @@
'use client'
import FileUpload from '@/components/FileUpload'
import { useState, useEffect } from 'react'
import {
FileSearch,
Plus,
Loader2,
AlertTriangle,
CheckCircle2,
XCircle,
Clock,
ArrowLeft,
Shield,
AlertOctagon,
Search,
Lightbulb,
FileWarning,
ThumbsUp,
ChevronRight,
Coins,
} from 'lucide-react'
interface AuditSummary {
id: string
title: string
status: 'PENDING' | 'ANALYZING' | 'DONE' | 'ERROR'
riskScore: number | null
createdAt: string
}
interface AuditDetail {
id: string
title: string
content: string
status: string
riskScore: number | null
analysis: {
resumo?: string
riskScore?: number
clausulasAbusivas?: { clausula: string; descricao: string; gravidade: string }[]
inconsistencias?: { item: string; descricao: string }[]
lacunas?: { item: string; descricao: string }[]
riscos?: { risco: string; descricao: string; gravidade: string }[]
sugestoes?: { sugestao: string; descricao: string }[]
pontosFavoraveis?: { ponto: string; descricao: string }[]
} | null
createdAt: string
}
const statusLabels: Record<string, { label: string; color: string; icon: typeof Clock }> = {
PENDING: { label: 'Pendente', color: 'text-zinc-400', icon: Clock },
ANALYZING: { label: 'Analisando', color: 'text-yellow-400', icon: Loader2 },
DONE: { label: 'Concluído', color: 'text-green-400', icon: CheckCircle2 },
ERROR: { label: 'Erro', color: 'text-red-400', icon: XCircle },
}
function RiskGauge({ score }: { score: number }) {
const getColor = (s: number) => {
if (s <= 30) return { stroke: 'stroke-green-500', text: 'text-green-400', label: 'Baixo Risco' }
if (s <= 60) return { stroke: 'stroke-yellow-500', text: 'text-yellow-400', label: 'Risco Moderado' }
if (s <= 80) return { stroke: 'stroke-orange-500', text: 'text-orange-400', label: 'Risco Alto' }
return { stroke: 'stroke-red-500', text: 'text-red-400', label: 'Risco Crítico' }
}
const config = getColor(score)
const circumference = 2 * Math.PI * 45
const dashOffset = circumference - (score / 100) * circumference
return (
<div className="flex flex-col items-center">
<div className="relative h-32 w-32">
<svg className="h-32 w-32 -rotate-90" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="45" fill="none" stroke="currentColor" strokeWidth="8" className="text-white/5" />
<circle
cx="50" cy="50" r="45" fill="none" strokeWidth="8" strokeLinecap="round"
className={config.stroke}
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
style={{ transition: 'stroke-dashoffset 1s ease-in-out' }}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className={`text-3xl font-bold ${config.text}`}>{score}</span>
<span className="text-[10px] text-zinc-500">/ 100</span>
</div>
</div>
<p className={`mt-2 text-sm font-semibold ${config.text}`}>{config.label}</p>
</div>
)
}
export default function AuditoriaPage() {
const [audits, setAudits] = useState<AuditSummary[]>([])
const [loading, setLoading] = useState(true)
const [view, setView] = useState<'list' | 'new' | 'detail'>('list')
const [selectedAudit, setSelectedAudit] = useState<AuditDetail | null>(null)
const [loadingDetail, setLoadingDetail] = useState(false)
const [analyzing, setAnalyzing] = useState(false)
const [error, setError] = useState('')
// Form
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
useEffect(() => {
fetchAudits()
}, [])
async function fetchAudits() {
setLoading(true)
try {
const res = await fetch('/api/auditoria')
if (res.ok) {
const data = await res.json()
setAudits(data.audits || [])
}
} catch (e) {
console.error('Failed to load audits:', e)
} finally {
setLoading(false)
}
}
async function handleAnalyze() {
if (!title.trim() || !content.trim()) return
setAnalyzing(true)
setError('')
try {
const res = await fetch('/api/auditoria', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || 'Erro ao analisar')
return
}
// Show result
setSelectedAudit(data.audit)
setView('detail')
setTitle('')
setContent('')
fetchAudits()
} catch (e) {
setError('Erro de conexão')
} finally {
setAnalyzing(false)
}
}
async function loadAuditDetail(id: string) {
setLoadingDetail(true)
try {
const res = await fetch(`/api/auditoria/${id}`)
if (res.ok) {
const data = await res.json()
setSelectedAudit(data.audit)
setView('detail')
}
} catch (e) {
console.error('Failed to load detail:', e)
} finally {
setLoadingDetail(false)
}
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
function getRiskColor(score: number | null) {
if (score === null) return 'text-zinc-500'
if (score <= 30) return 'text-green-400'
if (score <= 60) return 'text-yellow-400'
if (score <= 80) return 'text-orange-400'
return 'text-red-400'
}
// ─── Detail View ───
if (view === 'detail' && selectedAudit) {
const a = selectedAudit.analysis
return (
<div>
<button
onClick={() => { setView('list'); setSelectedAudit(null) }}
className="mb-6 flex items-center gap-2 text-sm text-zinc-400 hover:text-teal-400"
>
<ArrowLeft className="h-4 w-4" />
Voltar à lista
</button>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">{selectedAudit.title}</h1>
<p className="mt-1 text-sm text-zinc-500">Analisado em {formatDate(selectedAudit.createdAt)}</p>
</div>
</div>
{a && (
<div className="space-y-6">
{/* Summary + Risk Score */}
<div className="grid gap-6 md:grid-cols-[1fr,200px]">
<div className="rounded-xl border border-white/5 bg-white/[0.02] p-6">
<h3 className="text-sm font-semibold text-teal-400 mb-2">Resumo Executivo</h3>
<p className="text-sm text-zinc-300 leading-relaxed">{a.resumo || 'Sem resumo disponível.'}</p>
</div>
<div className="flex items-center justify-center rounded-xl border border-white/5 bg-white/[0.02] p-4">
<RiskGauge score={selectedAudit.riskScore || 0} />
</div>
</div>
{/* Findings */}
{a.clausulasAbusivas && a.clausulasAbusivas.length > 0 && (
<FindingsSection
title="Cláusulas Abusivas"
icon={<AlertOctagon className="h-5 w-5 text-red-400" />}
borderColor="border-red-500/20"
items={a.clausulasAbusivas.map(c => ({
title: c.clausula,
desc: c.descricao,
badge: c.gravidade,
}))}
/>
)}
{a.riscos && a.riscos.length > 0 && (
<FindingsSection
title="Riscos Identificados"
icon={<AlertTriangle className="h-5 w-5 text-orange-400" />}
borderColor="border-orange-500/20"
items={a.riscos.map(r => ({
title: r.risco,
desc: r.descricao,
badge: r.gravidade,
}))}
/>
)}
{a.inconsistencias && a.inconsistencias.length > 0 && (
<FindingsSection
title="Inconsistências"
icon={<FileWarning className="h-5 w-5 text-yellow-400" />}
borderColor="border-yellow-500/20"
items={a.inconsistencias.map(i => ({ title: i.item, desc: i.descricao }))}
/>
)}
{a.lacunas && a.lacunas.length > 0 && (
<FindingsSection
title="Lacunas"
icon={<Search className="h-5 w-5 text-blue-400" />}
borderColor="border-blue-500/20"
items={a.lacunas.map(l => ({ title: l.item, desc: l.descricao }))}
/>
)}
{a.sugestoes && a.sugestoes.length > 0 && (
<FindingsSection
title="Sugestões de Melhoria"
icon={<Lightbulb className="h-5 w-5 text-teal-400" />}
borderColor="border-teal-500/20"
items={a.sugestoes.map(s => ({ title: s.sugestao, desc: s.descricao }))}
/>
)}
{a.pontosFavoraveis && a.pontosFavoraveis.length > 0 && (
<FindingsSection
title="Pontos Favoráveis"
icon={<ThumbsUp className="h-5 w-5 text-green-400" />}
borderColor="border-green-500/20"
items={a.pontosFavoraveis.map(p => ({ title: p.ponto, desc: p.descricao }))}
/>
)}
</div>
)}
</div>
)
}
// ─── New Audit View ───
if (view === 'new') {
return (
<div>
<button
onClick={() => setView('list')}
className="mb-6 flex items-center gap-2 text-sm text-zinc-400 hover:text-teal-400"
>
<ArrowLeft className="h-4 w-4" />
Voltar à lista
</button>
<div className="mb-6">
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-teal-600/20">
<FileSearch className="h-5 w-5 text-teal-400" />
</div>
Nova Auditoria de Contrato
</h1>
<p className="mt-1 text-sm text-zinc-500 flex items-center gap-2">
<Coins className="h-4 w-4" />
Custo: 3 créditos por análise
</p>
</div>
<div className="space-y-4 max-w-3xl">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Título do Contrato *</label>
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none focus:border-teal-500/40"
placeholder="Ex: Contrato de Prestação de Serviços - Empresa X"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Texto do Contrato *</label>
<textarea
value={content}
onChange={e => setContent(e.target.value)}
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white outline-none focus:border-teal-500/40 font-mono leading-relaxed"
rows={16}
placeholder="Cole aqui o texto completo do contrato para análise..."
/>
<p className="mt-1 text-xs text-zinc-600">{content.length} caracteres {content.length < 100 && content.length > 0 ? '(mínimo 100)' : ''}</p>
</div>
<div className="rounded-xl border border-white/5 bg-white/[0.02] p-4">
<p className="text-sm font-medium text-zinc-400 mb-2">Ou faça upload do contrato</p>
<FileUpload compact label="Upload de Contrato" maxFiles={1} />
<p className="mt-2 text-xs text-zinc-600">O texto será extraído automaticamente do arquivo para análise.</p>
</div>
{error && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-400">
{error}
</div>
)}
<button
onClick={handleAnalyze}
disabled={!title.trim() || content.length < 100 || analyzing}
className="flex items-center gap-2 rounded-xl bg-teal-600 px-6 py-3 text-sm font-semibold text-white transition-all hover:bg-teal-500 hover:shadow-lg hover:shadow-teal-500/20 disabled:opacity-50"
>
{analyzing ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Analisando contrato...
</>
) : (
<>
<Shield className="h-4 w-4" />
Analisar Contrato (3 créditos)
</>
)}
</button>
</div>
</div>
)
}
// ─── List View ───
return (
<div>
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-teal-600/20">
<FileSearch className="h-5 w-5 text-teal-400" />
</div>
Auditoria de Contratos
</h1>
<p className="mt-1 text-sm text-zinc-500">Análise inteligente de contratos com IA</p>
</div>
<button
onClick={() => setView('new')}
className="flex items-center gap-2 rounded-xl bg-teal-600 px-4 py-2.5 text-sm font-semibold text-white transition-all hover:bg-teal-500 hover:shadow-lg hover:shadow-teal-500/20"
>
<Plus className="h-4 w-4" />
Nova Auditoria
</button>
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-6 w-6 animate-spin text-teal-400" />
</div>
) : audits.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-white/5 bg-white/[0.02] py-16">
<FileSearch className="h-12 w-12 text-zinc-600" />
<p className="mt-3 text-sm text-zinc-500">Nenhuma auditoria realizada</p>
<button onClick={() => setView('new')} className="mt-4 text-sm text-teal-400 hover:text-teal-300">
Fazer primeira auditoria
</button>
</div>
) : (
<div className="space-y-3">
{audits.map(audit => {
const st = statusLabels[audit.status] || statusLabels.PENDING
const StIcon = st.icon
return (
<button
key={audit.id}
onClick={() => loadAuditDetail(audit.id)}
className="group flex w-full items-center gap-4 rounded-xl border border-white/5 bg-white/[0.02] p-4 text-left transition-all hover:bg-white/[0.04] hover:border-white/10"
>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-teal-600/10 flex-shrink-0">
<FileSearch className="h-5 w-5 text-teal-400" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-white truncate">{audit.title}</h3>
<p className="mt-0.5 text-xs text-zinc-500">{formatDate(audit.createdAt)}</p>
</div>
{audit.riskScore !== null && (
<div className={`text-lg font-bold ${getRiskColor(audit.riskScore)}`}>
{audit.riskScore}
<span className="text-xs font-normal text-zinc-600">/100</span>
</div>
)}
<span className={`flex items-center gap-1 text-xs font-medium ${st.color}`}>
<StIcon className={`h-3.5 w-3.5 ${audit.status === 'ANALYZING' ? 'animate-spin' : ''}`} />
{st.label}
</span>
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400" />
</button>
)
})}
</div>
)}
{loadingDetail && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="flex items-center gap-3 rounded-xl bg-[#0f1620] border border-white/10 px-6 py-4">
<Loader2 className="h-5 w-5 animate-spin text-teal-400" />
<span className="text-sm text-zinc-300">Carregando auditoria...</span>
</div>
</div>
)}
</div>
)
}
// ─── Findings Section Component ───
function FindingsSection({ title, icon, borderColor, items }: {
title: string
icon: React.ReactNode
borderColor: string
items: { title: string; desc: string; badge?: string }[]
}) {
const badgeColors: Record<string, string> = {
alta: 'bg-red-500/15 text-red-400 border-red-500/30',
media: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30',
baixa: 'bg-green-500/15 text-green-400 border-green-500/30',
}
return (
<div className={`rounded-xl border ${borderColor} bg-white/[0.02] p-6`}>
<div className="flex items-center gap-2 mb-4">
{icon}
<h3 className="text-sm font-semibold text-white">{title}</h3>
<span className="ml-auto rounded-full bg-white/5 px-2 py-0.5 text-[10px] font-bold text-zinc-400">
{items.length}
</span>
</div>
<div className="space-y-3">
{items.map((item, i) => (
<div key={i} className="rounded-lg border border-white/5 bg-white/[0.02] p-3">
<div className="flex items-start justify-between gap-2">
<h4 className="text-sm font-medium text-zinc-200">{item.title}</h4>
{item.badge && (
<span className={`rounded-md border px-2 py-0.5 text-[10px] font-bold uppercase flex-shrink-0 ${badgeColors[item.badge] || 'bg-zinc-500/15 text-zinc-400 border-zinc-500/30'}`}>
{item.badge}
</span>
)}
</div>
<p className="mt-1 text-xs text-zinc-400 leading-relaxed">{item.desc}</p>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,569 @@
'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import {
MessageSquare,
Send,
Plus,
Copy,
Check,
FileDown,
Loader2,
Brain,
Sparkles,
Trash2,
Clock,
FileText,
} from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import { stripMarkdown, downloadAsWord } from '@/lib/utils'
// ─── Types ───────────────────────────────────────────────────────
interface ChatMessage {
id: string
role: 'USER' | 'ASSISTANT'
content: string
createdAt: string
}
interface ChatSummary {
id: string
title: string
createdAt: string
messages: { content: string; createdAt: string }[]
}
// ─── Suggested Questions ─────────────────────────────────────────
const SUGGESTED_QUESTIONS = [
{
text: 'Qual o prazo para contestação no JEC?',
icon: '⚖️',
area: 'Processual Civil',
},
{
text: 'Como funciona o recurso de apelação?',
icon: '📋',
area: 'Recursos',
},
{
text: 'Quais os requisitos do habeas corpus?',
icon: '🔓',
area: 'Penal',
},
]
// ─── Component ───────────────────────────────────────────────────
export default function ChatPage() {
const [chats, setChats] = useState<ChatSummary[]>([])
const [activeChatId, setActiveChatId] = useState<string | null>(null)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
const [copiedId, setCopiedId] = useState<string | null>(null)
const [loadingChats, setLoadingChats] = useState(true)
const [sidebarOpen, setSidebarOpen] = useState(true)
const [downloadingId, setDownloadingId] = useState<string | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
// Delete chat
async function deleteChat(chatId: string) {
if (!confirm('Tem certeza que deseja apagar esta conversa?')) return
try {
const res = await fetch(`/api/chat/${chatId}`, { method: 'DELETE' })
if (res.ok) {
setChats((prev) => prev.filter((c) => c.id !== chatId))
if (activeChatId === chatId) {
setActiveChatId(null)
setMessages([])
}
}
} catch (err) {
console.error('Error deleting chat:', err)
}
}
// Auto-scroll to bottom
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [])
useEffect(() => {
scrollToBottom()
}, [messages, scrollToBottom])
// Load chat list
useEffect(() => {
fetchChats()
}, [])
async function fetchChats() {
try {
const res = await fetch('/api/chat')
if (res.ok) {
const data = await res.json()
setChats(data.chats || [])
}
} catch (e) {
console.error('Failed to load chats:', e)
} finally {
setLoadingChats(false)
}
}
// Load messages for a chat
async function loadChat(chatId: string) {
setActiveChatId(chatId)
try {
const res = await fetch(`/api/chat/${chatId}`)
if (res.ok) {
const data = await res.json()
setMessages(data.messages || [])
}
} catch {
setMessages([])
}
}
// New chat
function handleNewChat() {
setActiveChatId(null)
setMessages([])
setInput('')
inputRef.current?.focus()
}
// Send message
async function handleSend(text?: string) {
const messageText = (text || input).trim()
if (!messageText || isStreaming) return
setInput('')
setIsStreaming(true)
// Optimistic: add user message
const userMsg: ChatMessage = {
id: `temp-${Date.now()}`,
role: 'USER',
content: messageText,
createdAt: new Date().toISOString(),
}
setMessages((prev) => [...prev, userMsg])
// Placeholder for assistant
const assistantId = `assistant-${Date.now()}`
const assistantMsg: ChatMessage = {
id: assistantId,
role: 'ASSISTANT',
content: '',
createdAt: new Date().toISOString(),
}
setMessages((prev) => [...prev, assistantMsg])
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: messageText,
chatId: activeChatId || undefined,
}),
})
if (!res.ok) {
const errData = await res.json().catch(() => ({}))
throw new Error(errData.error || 'Erro ao enviar mensagem')
}
// Read SSE stream
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
let fullContent = ''
let receivedChatId: string | null = null
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const data = line.slice(6).trim()
if (data === '[DONE]') continue
try {
const parsed = JSON.parse(data)
if (parsed.chatId && !receivedChatId) {
receivedChatId = parsed.chatId
if (!activeChatId) {
setActiveChatId(receivedChatId)
}
}
if (parsed.token) {
fullContent += parsed.token
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: fullContent } : m
)
)
}
} catch {
// skip malformed
}
}
}
// Refresh chat list
fetchChats()
} catch (error) {
const errMsg = error instanceof Error ? error.message : 'Erro desconhecido'
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, content: `⚠️ ${errMsg}. Tente novamente.` }
: m
)
)
} finally {
setIsStreaming(false)
}
}
// Copy message - strips markdown formatting
function handleCopy(content: string, id: string) {
navigator.clipboard.writeText(stripMarkdown(content))
setCopiedId(id)
setTimeout(() => setCopiedId(null), 2000)
}
// Download as Word
async function handleDownloadWord(content: string, id: string) {
setDownloadingId(id)
try {
await downloadAsWord('Resposta Chat IA', content)
} catch (err) {
console.error('Download error:', err)
} finally {
setDownloadingId(null)
}
}
// Format date
function formatDate(dateStr: string) {
const d = new Date(dateStr)
const now = new Date()
const diff = now.getTime() - d.getTime()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (days === 0) return 'Hoje'
if (days === 1) return 'Ontem'
if (days < 7) return `${days} dias atrás`
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short' })
}
// Keyboard handler
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
// Auto-resize textarea
function handleInputChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
setInput(e.target.value)
const el = e.target
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 160) + 'px'
}
return (
<div className="-mx-4 -my-6 sm:-mx-6 lg:-mx-8 flex h-[calc(100vh-3.5rem)] lg:h-screen">
{/* ─── Chat Sidebar ─── */}
<aside
className={`
${sidebarOpen ? 'w-72' : 'w-0 overflow-hidden'}
flex-shrink-0 border-r border-white/5 bg-[#070b14] transition-all duration-300
flex flex-col
`}
>
{/* New Chat Button */}
<div className="p-3 border-b border-white/5">
<button
onClick={handleNewChat}
className="flex w-full items-center gap-2 rounded-xl bg-teal-600 px-4 py-2.5 text-sm font-semibold text-white transition-all hover:bg-teal-500 hover:shadow-lg hover:shadow-teal-500/20"
>
<Plus className="h-4 w-4" />
Nova Conversa
</button>
</div>
{/* Chat List */}
<div className="flex-1 overflow-y-auto px-2 py-2">
{loadingChats ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-teal-400" />
</div>
) : chats.length === 0 ? (
<div className="px-3 py-8 text-center">
<MessageSquare className="mx-auto h-8 w-8 text-zinc-600" />
<p className="mt-2 text-xs text-zinc-500">Nenhuma conversa ainda</p>
</div>
) : (
<div className="space-y-0.5">
{chats.map((chat) => (
<button
key={chat.id}
onClick={() => loadChat(chat.id)}
className={`
group flex w-full items-start gap-2 rounded-lg px-3 py-2.5 text-left
transition-all duration-150
${
activeChatId === chat.id
? 'bg-teal-600/15 text-teal-300'
: 'text-zinc-400 hover:bg-white/5 hover:text-zinc-200'
}
`}
>
<MessageSquare className="mt-0.5 h-4 w-4 flex-shrink-0 opacity-50" />
<div className="flex-1 overflow-hidden">
<p className="truncate text-sm font-medium">{chat.title}</p>
<p className="mt-0.5 flex items-center gap-1 text-[10px] text-zinc-600">
<Clock className="h-2.5 w-2.5" />
{formatDate(chat.createdAt)}
</p>
</div>
<button
onClick={(e) => {
e.stopPropagation()
deleteChat(chat.id)
}}
className="mt-0.5 hidden rounded p-1 text-zinc-500 transition-colors hover:bg-red-500/20 hover:text-red-400 group-hover:block"
title="Apagar conversa"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</button>
))}
</div>
)}
</div>
</aside>
{/* ─── Chat Main Area ─── */}
<div className="flex flex-1 flex-col overflow-hidden bg-[#0a0f1a]">
{/* Header */}
<div className="flex items-center gap-3 border-b border-white/5 bg-[#0a0f1a]/80 px-4 py-3 backdrop-blur-md">
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="rounded-lg p-1.5 text-zinc-400 hover:bg-white/5 hover:text-white"
title={sidebarOpen ? 'Fechar sidebar' : 'Abrir sidebar'}
>
<MessageSquare className="h-5 w-5" />
</button>
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-teal-600/20">
<Brain className="h-4 w-4 text-teal-400" />
</div>
<div>
<h1 className="text-sm font-semibold text-white">Chat IA</h1>
<p className="text-[10px] text-zinc-500">Assistente especialista em Direito brasileiro</p>
</div>
</div>
</div>
{/* Messages Area */}
<div className="flex-1 overflow-y-auto">
{messages.length === 0 ? (
/* ─── Welcome Screen ─── */
<div className="flex h-full flex-col items-center justify-center px-4">
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-teal-600/20 ring-1 ring-teal-500/30">
<Brain className="h-8 w-8 text-teal-400" />
</div>
<h2 className="text-xl font-bold text-white">Chat IA</h2>
<p className="mt-2 max-w-md text-center text-sm text-zinc-500">
Converse com um assistente especialista em Direito brasileiro.
Tire dúvidas sobre legislação, jurisprudência e doutrina.
</p>
{/* Suggested Questions */}
<div className="mt-8 grid w-full max-w-2xl gap-3 sm:grid-cols-3">
{SUGGESTED_QUESTIONS.map((q) => (
<button
key={q.text}
onClick={() => handleSend(q.text)}
disabled={isStreaming}
className="group flex flex-col gap-2 rounded-xl border border-white/5 bg-white/[0.02] p-4 text-left transition-all hover:border-teal-500/30 hover:bg-teal-600/5"
>
<div className="flex items-center gap-2">
<span className="text-lg">{q.icon}</span>
<span className="text-[10px] font-medium uppercase tracking-wider text-teal-400/60">
{q.area}
</span>
</div>
<p className="text-sm text-zinc-300 group-hover:text-white">
{q.text}
</p>
</button>
))}
</div>
<div className="mt-8 flex items-center gap-2 text-[10px] text-zinc-600">
<Sparkles className="h-3 w-3" />
Respostas baseadas na legislação vigente e jurisprudência dos tribunais superiores
</div>
</div>
) : (
/* ─── Message List ─── */
<div className="mx-auto max-w-3xl px-4 py-6 space-y-6">
{messages.map((msg) => (
<div
key={msg.id}
className={`flex gap-3 ${msg.role === 'USER' ? 'justify-end' : 'justify-start'}`}
>
{msg.role === 'ASSISTANT' && (
<div className="flex-shrink-0 mt-1">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-teal-600/20">
<Brain className="h-4 w-4 text-teal-400" />
</div>
</div>
)}
<div
className={`group relative max-w-[85%] rounded-2xl px-4 py-3 ${
msg.role === 'USER'
? 'bg-teal-600 text-white'
: 'bg-white/[0.04] text-zinc-200 border border-white/5'
}`}
>
{msg.role === 'ASSISTANT' ? (
<div className="prose prose-invert prose-sm max-w-none prose-headings:text-teal-300 prose-headings:font-semibold prose-strong:text-teal-200 prose-code:text-teal-300 prose-blockquote:border-teal-500/30 prose-blockquote:text-zinc-400 prose-a:text-teal-400 prose-li:marker:text-teal-500">
<ReactMarkdown>{msg.content || '▍'}</ReactMarkdown>
</div>
) : (
<p className="text-sm whitespace-pre-wrap">{msg.content}</p>
)}
{/* Action buttons for assistant messages */}
{msg.content && msg.role === 'ASSISTANT' && (
<div className="absolute -bottom-8 right-0 flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleCopy(msg.content, msg.id)}
className="flex items-center gap-1 rounded-md px-2 py-1 text-[10px] text-zinc-500 hover:text-zinc-300"
>
{copiedId === msg.id ? (
<>
<Check className="h-3 w-3 text-green-400" />
<span className="text-green-400">Copiado</span>
</>
) : (
<>
<Copy className="h-3 w-3" />
Copiar
</>
)}
</button>
<button
onClick={() => handleDownloadWord(msg.content, msg.id)}
disabled={downloadingId === msg.id}
className="flex items-center gap-1 rounded-md px-2 py-1 text-[10px] text-zinc-500 hover:text-zinc-300"
>
{downloadingId === msg.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<FileText className="h-3 w-3" />
)}
Word
</button>
</div>
)}
{/* Copy button for user messages */}
{msg.content && msg.role === 'USER' && (
<button
onClick={() => handleCopy(msg.content, msg.id)}
className="absolute -bottom-8 right-0 flex items-center gap-1 rounded-md px-2 py-1 text-[10px] text-zinc-500 opacity-0 transition-opacity group-hover:opacity-100 hover:text-zinc-300"
>
{copiedId === msg.id ? (
<>
<Check className="h-3 w-3 text-green-400" />
<span className="text-green-400">Copiado</span>
</>
) : (
<>
<Copy className="h-3 w-3" />
Copiar
</>
)}
</button>
)}
</div>
{msg.role === 'USER' && (
<div className="flex-shrink-0 mt-1">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-teal-600/40 text-xs font-bold text-white">
Vc
</div>
</div>
)}
</div>
))}
{isStreaming && (
<div className="flex items-center gap-2 px-11 text-xs text-teal-400/60">
<Loader2 className="h-3 w-3 animate-spin" />
Gerando resposta...
</div>
)}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* ─── Input Area ─── */}
<div className="border-t border-white/5 bg-[#0a0f1a]/80 px-4 py-3 backdrop-blur-md">
<div className="mx-auto max-w-3xl">
<div className="flex items-end gap-2 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-2 transition-colors focus-within:border-teal-500/40">
<textarea
ref={inputRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Digite sua pergunta jurídica..."
disabled={isStreaming}
rows={1}
className="flex-1 resize-none bg-transparent text-sm text-white placeholder-zinc-500 outline-none disabled:opacity-50"
style={{ maxHeight: '160px' }}
/>
<button
onClick={() => handleSend()}
disabled={!input.trim() || isStreaming}
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-teal-600 text-white transition-all hover:bg-teal-500 disabled:opacity-30 disabled:hover:bg-teal-600"
>
{isStreaming ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</button>
</div>
<p className="mt-2 text-center text-[10px] text-zinc-600">
IA pode gerar informações imprecisas. Verifique sempre com a legislação vigente.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,850 @@
'use client'
import { useState, useCallback } from 'react'
import {
User,
Mail,
Phone,
Scale,
CreditCard,
Key,
Plus,
Trash2,
Copy,
Check,
Bell,
BellRing,
FileCheck,
AlertTriangle,
Shield,
Lock,
Eye,
EyeOff,
Sparkles,
Crown,
Zap,
Clock,
ToggleLeft,
ToggleRight,
Loader2,
AlertCircle,
ChevronRight,
HardDrive,
Upload as UploadIcon,
} from 'lucide-react'
// ─── Types ──────────────────────────────────────────────────────────
interface UserData {
id: string
name: string
email: string
phone: string | null
oabNumber: string | null
oabState: string | null
avatar: string | null
plan: string
credits: number
createdAt: string
}
interface ApiKeyData {
id: string
name: string
key: string
active: boolean
createdAt: string
}
interface SubscriptionData {
plan: string
status: string
startDate: string
endDate: string | null
}
interface StorageData {
used: number
limit: number
count: number
}
interface Props {
user: UserData
apiKeys: ApiKeyData[]
subscription: SubscriptionData | null
storage: StorageData
}
// ─── Helpers ────────────────────────────────────────────────────────
const planConfig: Record<string, { label: string; color: string; icon: typeof Sparkles }> = {
FREE: { label: 'Free', color: 'bg-zinc-700 text-zinc-300', icon: Zap },
STARTER: { label: 'Starter', color: 'bg-blue-600/20 text-blue-400 border border-blue-500/30', icon: Zap },
PRO: { label: 'Pro', color: 'bg-teal-600/20 text-teal-400 border border-teal-500/30', icon: Crown },
ENTERPRISE: { label: 'Enterprise', color: 'bg-amber-600/20 text-amber-400 border border-amber-500/30', icon: Sparkles },
}
function formatDate(iso: string) {
return new Intl.DateTimeFormat('pt-BR', {
day: '2-digit',
month: 'short',
year: 'numeric',
}).format(new Date(iso))
}
function formatDateTime(iso: string) {
return new Intl.DateTimeFormat('pt-BR', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(iso))
}
// ─── Section wrapper ────────────────────────────────────────────────
function Section({
id,
title,
icon: Icon,
description,
children,
danger,
}: {
id?: string
title: string
icon: typeof User
description?: string
children: React.ReactNode
danger?: boolean
}) {
return (
<section
id={id}
className={`overflow-hidden rounded-2xl border ${
danger
? 'border-red-500/20 bg-red-950/10'
: 'border-white/5 bg-[#111b27]'
}`}
>
<div className={`border-b px-6 py-4 ${danger ? 'border-red-500/10' : 'border-white/5'}`}>
<div className="flex items-center gap-3">
<div className={`rounded-lg p-2 ${danger ? 'bg-red-500/15' : 'bg-teal-500/15'}`}>
<Icon className={`h-4 w-4 ${danger ? 'text-red-400' : 'text-teal-400'}`} />
</div>
<div>
<h2 className="text-base font-semibold text-white">{title}</h2>
{description && <p className="text-sm text-zinc-500">{description}</p>}
</div>
</div>
</div>
<div className="px-6 py-5">{children}</div>
</section>
)
}
// ─── Toggle component ───────────────────────────────────────────────
function Toggle({
label,
description,
icon: Icon,
enabled,
onChange,
}: {
label: string
description: string
icon: typeof Bell
enabled: boolean
onChange: (v: boolean) => void
}) {
return (
<div className="flex items-center justify-between gap-4 py-3">
<div className="flex items-center gap-3">
<Icon className="h-4 w-4 flex-shrink-0 text-zinc-500" />
<div>
<p className="text-sm font-medium text-zinc-200">{label}</p>
<p className="text-xs text-zinc-500">{description}</p>
</div>
</div>
<button
onClick={() => onChange(!enabled)}
className="flex-shrink-0 transition-colors"
aria-label={`Toggle ${label}`}
>
{enabled ? (
<ToggleRight className="h-7 w-7 text-teal-400" />
) : (
<ToggleLeft className="h-7 w-7 text-zinc-600" />
)}
</button>
</div>
)
}
// ─── Main component ─────────────────────────────────────────────────
export default function SettingsClient({ user, apiKeys: initialKeys, subscription, storage }: Props) {
// ── API Keys state ──
const [keys, setKeys] = useState(initialKeys)
const [newKeyName, setNewKeyName] = useState('')
const [createdKey, setCreatedKey] = useState<string | null>(null)
const [copiedKey, setCopiedKey] = useState(false)
const [keyLoading, setKeyLoading] = useState(false)
const [keyError, setKeyError] = useState<string | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
const [portalLoading, setPortalLoading] = useState(false)
// ── Notifications state (local only for now) ──
const [notifications, setNotifications] = useState({
emailAlerts: true,
documentReady: true,
creditsLow: true,
weeklyReport: false,
})
// ── Security state ──
const [showCurrentPw, setShowCurrentPw] = useState(false)
const [showNewPw, setShowNewPw] = useState(false)
const [passwordForm, setPasswordForm] = useState({
current: '',
newPassword: '',
confirm: '',
})
// ── Delete account state ──
const [deleteConfirm, setDeleteConfirm] = useState('')
const [showDeleteInput, setShowDeleteInput] = useState(false)
// ── API Key handlers ──
const createKey = useCallback(async () => {
if (!newKeyName.trim()) return
setKeyLoading(true)
setKeyError(null)
setCreatedKey(null)
try {
const res = await fetch('/api/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newKeyName.trim() }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Erro ao criar chave')
setCreatedKey(data.key)
setKeys((prev) => [
{
id: data.id,
name: data.name,
key: data.key.slice(0, 8) + '••••••••' + data.key.slice(-4),
active: true,
createdAt: data.createdAt,
},
...prev,
])
setNewKeyName('')
} catch (err) {
setKeyError(err instanceof Error ? err.message : 'Erro ao criar chave')
} finally {
setKeyLoading(false)
}
}, [newKeyName])
const deleteKey = useCallback(async (id: string) => {
setDeletingId(id)
try {
const res = await fetch(`/api/keys/${id}`, { method: 'DELETE' })
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Erro ao revogar chave')
}
setKeys((prev) => prev.filter((k) => k.id !== id))
} catch (err) {
setKeyError(err instanceof Error ? err.message : 'Erro ao revogar chave')
} finally {
setDeletingId(null)
}
}, [])
const copyToClipboard = useCallback((text: string) => {
navigator.clipboard.writeText(text)
setCopiedKey(true)
setTimeout(() => setCopiedKey(false), 2000)
}, [])
const plan = planConfig[user.plan] || planConfig.FREE
// ── Mock billing history ──
const billingHistory = [
{ date: '2025-06-01', description: 'Plano Pro — Mensal', amount: 'R$ 89,90', status: 'Pago' },
{ date: '2025-05-01', description: 'Plano Pro — Mensal', amount: 'R$ 89,90', status: 'Pago' },
{ date: '2025-04-01', description: 'Plano Pro — Mensal', amount: 'R$ 89,90', status: 'Pago' },
{ date: '2025-03-15', description: 'Créditos extras (50)', amount: 'R$ 29,90', status: 'Pago' },
]
const initials = user.name
.split(' ')
.map((n) => n[0])
.slice(0, 2)
.join('')
.toUpperCase()
return (
<div className="space-y-8 pb-12">
{/* Page header */}
<div>
<h1 className="text-2xl font-bold text-white">Configurações</h1>
<p className="mt-1 text-sm text-zinc-500">
Gerencie seu perfil, assinatura, chaves de API e preferências de segurança.
</p>
</div>
{/* ━━━ 1. Profile ━━━ */}
<Section id="perfil" title="Perfil" icon={User} description="Informações da sua conta">
<div className="flex flex-col gap-6 sm:flex-row sm:items-start">
{/* Avatar */}
<div className="flex flex-col items-center gap-2">
{user.avatar ? (
<img
src={user.avatar}
alt={user.name}
className="h-20 w-20 rounded-2xl object-cover ring-2 ring-teal-500/30"
/>
) : (
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-teal-600/20 text-2xl font-bold text-teal-400 ring-2 ring-teal-500/30">
{initials}
</div>
)}
<span className="text-[10px] uppercase tracking-wider text-zinc-600">
Avatar
</span>
</div>
{/* Fields */}
<div className="flex-1 space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* Name */}
<div>
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-zinc-500">
<User className="h-3 w-3" /> Nome
</label>
<div className="rounded-xl border border-white/5 bg-white/[0.03] px-4 py-2.5 text-sm text-white">
{user.name}
</div>
</div>
{/* Email */}
<div>
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-zinc-500">
<Mail className="h-3 w-3" /> Email
</label>
<div className="rounded-xl border border-white/5 bg-white/[0.03] px-4 py-2.5 text-sm text-white">
{user.email}
</div>
</div>
{/* OAB */}
<div>
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-zinc-500">
<Scale className="h-3 w-3" /> OAB
</label>
<div className="rounded-xl border border-white/5 bg-white/[0.03] px-4 py-2.5 text-sm text-white">
{user.oabNumber && user.oabState
? `${user.oabNumber} / ${user.oabState}`
: '—'}
</div>
</div>
{/* Phone */}
<div>
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-zinc-500">
<Phone className="h-3 w-3" /> Telefone
</label>
<div className="rounded-xl border border-white/5 bg-white/[0.03] px-4 py-2.5 text-sm text-white">
{user.phone || '—'}
</div>
</div>
</div>
<p className="text-xs text-zinc-600">
Membro desde {formatDate(user.createdAt)}. Para editar seu perfil, entre em contato com o suporte.
</p>
</div>
</div>
</Section>
{/* ━━━ 2. Subscription ━━━ */}
<Section
id="planos"
title="Assinatura"
icon={CreditCard}
description="Seu plano atual e histórico de cobrança"
>
{/* Current plan */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<div className="rounded-xl bg-teal-600/15 p-3">
<plan.icon className="h-6 w-6 text-teal-400" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-lg font-bold text-white">Plano {plan.label}</span>
<span className={`rounded-full px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider ${plan.color}`}>
Ativo
</span>
</div>
<p className="text-sm text-zinc-500">
{user.credits} créditos restantes
{subscription?.endDate && (
<> · Renova em {formatDate(subscription.endDate)}</>
)}
</p>
</div>
</div>
<div className="flex gap-3">
{user.plan !== 'FREE' && (
<button
onClick={async () => {
setPortalLoading(true)
try {
const res = await fetch('/api/stripe/portal', { method: 'POST' })
const data = await res.json()
if (data.url) window.location.href = data.url
else alert(data.error || 'Erro')
} catch { alert('Erro ao conectar') }
finally { setPortalLoading(false) }
}}
disabled={portalLoading}
className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.03] px-5 py-2.5 text-sm font-medium text-zinc-300 transition-all hover:bg-white/[0.06] disabled:opacity-50"
>
{portalLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <CreditCard className="h-4 w-4" />}
Gerenciar Assinatura
</button>
)}
{user.plan !== 'ENTERPRISE' && (
<a
href="/pricing"
className="inline-flex items-center gap-2 rounded-xl bg-teal-600 px-5 py-2.5 text-sm font-semibold text-white transition-all hover:bg-teal-500 hover:shadow-lg hover:shadow-teal-600/20"
>
<Sparkles className="h-4 w-4" />
Upgrade
</a>
)}
</div>
</div>
{/* Credits bar */}
<div className="mt-6">
<div className="mb-2 flex items-center justify-between text-xs">
<span className="text-zinc-500">Créditos usados este mês</span>
<span className="font-medium text-zinc-300">
{Math.max(0, (user.plan === 'FREE' ? 5 : user.plan === 'PRO' ? 100 : 500) - user.credits)} / {user.plan === 'FREE' ? 5 : user.plan === 'PRO' ? 100 : 500}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-white/5">
<div
className="h-full rounded-full bg-gradient-to-r from-teal-600 to-teal-400 transition-all"
style={{
width: `${Math.min(100, ((Math.max(0, (user.plan === 'FREE' ? 5 : user.plan === 'PRO' ? 100 : 500) - user.credits)) / (user.plan === 'FREE' ? 5 : user.plan === 'PRO' ? 100 : 500)) * 100)}%`,
}}
/>
</div>
</div>
{/* Billing history */}
<div className="mt-6">
<h3 className="mb-3 text-sm font-medium text-zinc-400">Histórico de cobrança</h3>
<div className="overflow-hidden rounded-xl border border-white/5">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/5 text-left text-xs font-medium uppercase tracking-wider text-zinc-600">
<th className="px-4 py-2.5">Data</th>
<th className="hidden px-4 py-2.5 sm:table-cell">Descrição</th>
<th className="px-4 py-2.5 text-right">Valor</th>
<th className="px-4 py-2.5 text-right">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{billingHistory.map((item, i) => (
<tr key={i} className="hover:bg-white/[0.02]">
<td className="px-4 py-2.5 text-zinc-400">{formatDate(item.date)}</td>
<td className="hidden px-4 py-2.5 text-zinc-300 sm:table-cell">{item.description}</td>
<td className="px-4 py-2.5 text-right font-medium text-white">{item.amount}</td>
<td className="px-4 py-2.5 text-right">
<span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-medium text-emerald-400">
{item.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Section>
{/* ━━━ 3. API Keys ━━━ */}
<Section
id="api-keys"
title="Chaves de API"
icon={Key}
description="Gerencie suas chaves para integração com a API"
>
{/* Create new key */}
<div className="flex gap-3">
<input
type="text"
placeholder="Nome da chave (ex: meu-app)"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && createKey()}
className="flex-1 rounded-xl border border-white/10 bg-white/[0.03] px-4 py-2.5 text-sm text-white placeholder-zinc-600 outline-none transition-colors focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/30"
/>
<button
onClick={createKey}
disabled={keyLoading || !newKeyName.trim()}
className="inline-flex items-center gap-2 rounded-xl bg-teal-600 px-4 py-2.5 text-sm font-medium text-white transition-all hover:bg-teal-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{keyLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
Criar
</button>
</div>
{/* Error */}
{keyError && (
<div className="mt-3 flex items-center gap-2 rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-2.5 text-sm text-red-400">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
{keyError}
</div>
)}
{/* Just-created key (show once) */}
{createdKey && (
<div className="mt-3 rounded-xl border border-teal-500/30 bg-teal-500/10 p-4">
<p className="mb-2 text-xs font-medium text-teal-300">
Copie sua chave agora ela não será exibida novamente:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded-lg bg-black/40 px-3 py-2 font-mono text-xs text-teal-200">
{createdKey}
</code>
<button
onClick={() => copyToClipboard(createdKey)}
className="rounded-lg bg-teal-600/30 p-2 text-teal-300 transition-colors hover:bg-teal-600/50"
>
{copiedKey ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</button>
</div>
</div>
)}
{/* Keys list */}
{keys.length > 0 ? (
<div className="mt-4 space-y-2">
{keys.map((k) => (
<div
key={k.id}
className={`flex items-center justify-between gap-3 rounded-xl border px-4 py-3 transition-all ${
k.active
? 'border-white/5 bg-white/[0.02]'
: 'border-white/5 bg-white/[0.01] opacity-50'
}`}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-white">{k.name}</span>
{!k.active && (
<span className="rounded-full bg-red-500/15 px-2 py-0.5 text-[10px] font-medium text-red-400">
Revogada
</span>
)}
</div>
<div className="flex items-center gap-3 text-xs text-zinc-600">
<code className="font-mono">{k.key}</code>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatDateTime(k.createdAt)}
</span>
</div>
</div>
<button
onClick={() => deleteKey(k.id)}
disabled={deletingId === k.id}
className="rounded-lg p-2 text-zinc-600 transition-colors hover:bg-red-500/10 hover:text-red-400 disabled:opacity-50"
title="Revogar e excluir"
>
{deletingId === k.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</button>
</div>
))}
</div>
) : (
!createdKey && (
<div className="mt-4 flex flex-col items-center justify-center rounded-xl border border-dashed border-white/10 py-10 text-center">
<Key className="h-8 w-8 text-zinc-700" />
<p className="mt-2 text-sm text-zinc-500">Nenhuma chave de API criada</p>
<p className="text-xs text-zinc-600">Crie uma chave para integrar com a API do LexMind</p>
</div>
)
)}
<p className="mt-3 text-xs text-zinc-600">
Máximo de 5 chaves. As chaves têm prefixo <code className="text-zinc-500">jur_</code> e são hasheadas no banco.
</p>
</Section>
{/* ━━━ Storage ━━━ */}
<Section
id="armazenamento"
title="Armazenamento"
icon={HardDrive}
description="Espaço usado para uploads de documentos"
>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="rounded-xl bg-teal-600/15 p-3">
<UploadIcon className="h-6 w-6 text-teal-400" />
</div>
<div>
<p className="text-sm font-medium text-white">
{(storage.used / (1024 * 1024)).toFixed(1)} MB de {(storage.limit / (1024 * 1024 * 1024)).toFixed(0)} GB usados
</p>
<p className="text-xs text-zinc-500">{storage.count} arquivo{storage.count !== 1 ? 's' : ''} enviado{storage.count !== 1 ? 's' : ''}</p>
</div>
</div>
<span className="text-sm font-bold text-teal-400">
{Math.round((storage.used / storage.limit) * 100)}%
</span>
</div>
<div className="h-3 overflow-hidden rounded-full bg-white/5">
<div
className={`h-full rounded-full transition-all ${
(storage.used / storage.limit) > 0.9
? 'bg-gradient-to-r from-red-600 to-red-400'
: (storage.used / storage.limit) > 0.7
? 'bg-gradient-to-r from-amber-600 to-amber-400'
: 'bg-gradient-to-r from-teal-600 to-teal-400'
}`}
style={{ width: `${Math.min(100, (storage.used / storage.limit) * 100)}%` }}
/>
</div>
<div className="flex justify-between text-xs text-zinc-600">
<span>0 GB</span>
<span>{(storage.limit / (1024 * 1024 * 1024)).toFixed(0)} GB</span>
</div>
</div>
</Section>
{/* ━━━ 4. Notifications ━━━ */}
<Section
id="notificacoes"
title="Notificações"
icon={Bell}
description="Configure como e quando você recebe alertas"
>
<div className="divide-y divide-white/5">
<Toggle
label="Alertas por email"
description="Receba notificações importantes por email"
icon={Mail}
enabled={notifications.emailAlerts}
onChange={(v) => setNotifications((p) => ({ ...p, emailAlerts: v }))}
/>
<Toggle
label="Documento pronto"
description="Notificar quando uma peça jurídica terminar de ser gerada"
icon={FileCheck}
enabled={notifications.documentReady}
onChange={(v) => setNotifications((p) => ({ ...p, documentReady: v }))}
/>
<Toggle
label="Créditos baixos"
description="Avisar quando seus créditos estiverem abaixo de 20%"
icon={AlertTriangle}
enabled={notifications.creditsLow}
onChange={(v) => setNotifications((p) => ({ ...p, creditsLow: v }))}
/>
<Toggle
label="Relatório semanal"
description="Resumo da sua atividade enviado toda segunda-feira"
icon={BellRing}
enabled={notifications.weeklyReport}
onChange={(v) => setNotifications((p) => ({ ...p, weeklyReport: v }))}
/>
</div>
<div className="mt-4 flex justify-end">
<button className="rounded-xl bg-teal-600/15 px-4 py-2 text-sm font-medium text-teal-400 transition-colors hover:bg-teal-600/25">
Salvar preferências
</button>
</div>
</Section>
{/* ━━━ 5. Security ━━━ */}
<Section
id="seguranca"
title="Segurança"
icon={Shield}
description="Senha e autenticação em dois fatores"
>
{/* Change password */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-zinc-300">Alterar senha</h3>
<div className="max-w-md space-y-3">
{/* Current password */}
<div>
<label className="mb-1.5 block text-xs font-medium text-zinc-500">Senha atual</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-600" />
<input
type={showCurrentPw ? 'text' : 'password'}
value={passwordForm.current}
onChange={(e) => setPasswordForm((p) => ({ ...p, current: e.target.value }))}
className="w-full rounded-xl border border-white/10 bg-white/[0.03] py-2.5 pl-10 pr-10 text-sm text-white placeholder-zinc-600 outline-none transition-colors focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/30"
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowCurrentPw(!showCurrentPw)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-400"
>
{showCurrentPw ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{/* New password */}
<div>
<label className="mb-1.5 block text-xs font-medium text-zinc-500">Nova senha</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-600" />
<input
type={showNewPw ? 'text' : 'password'}
value={passwordForm.newPassword}
onChange={(e) => setPasswordForm((p) => ({ ...p, newPassword: e.target.value }))}
className="w-full rounded-xl border border-white/10 bg-white/[0.03] py-2.5 pl-10 pr-10 text-sm text-white placeholder-zinc-600 outline-none transition-colors focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/30"
placeholder="Mínimo 8 caracteres"
/>
<button
type="button"
onClick={() => setShowNewPw(!showNewPw)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-400"
>
{showNewPw ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{/* Confirm password */}
<div>
<label className="mb-1.5 block text-xs font-medium text-zinc-500">Confirmar nova senha</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-600" />
<input
type="password"
value={passwordForm.confirm}
onChange={(e) => setPasswordForm((p) => ({ ...p, confirm: e.target.value }))}
className="w-full rounded-xl border border-white/10 bg-white/[0.03] py-2.5 pl-10 pr-10 text-sm text-white placeholder-zinc-600 outline-none transition-colors focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/30"
placeholder="Repita a nova senha"
/>
</div>
</div>
<button className="rounded-xl bg-teal-600 px-4 py-2.5 text-sm font-medium text-white transition-all hover:bg-teal-500">
Alterar senha
</button>
</div>
</div>
{/* 2FA */}
<div className="mt-8 flex items-center justify-between rounded-xl border border-white/5 bg-white/[0.02] px-5 py-4">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-zinc-800 p-2">
<Shield className="h-4 w-4 text-zinc-400" />
</div>
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-zinc-300">Autenticação em dois fatores (2FA)</p>
<span className="rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-400">
Em breve
</span>
</div>
<p className="text-xs text-zinc-600">
Adicione uma camada extra de segurança à sua conta
</p>
</div>
</div>
<ChevronRight className="h-4 w-4 text-zinc-700" />
</div>
</Section>
{/* ━━━ 6. Danger Zone ━━━ */}
<Section
id="perigo"
title="Zona de Perigo"
icon={AlertTriangle}
description="Ações irreversíveis"
danger
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium text-red-300">Excluir conta permanentemente</p>
<p className="text-xs text-zinc-500">
Todos os dados serão perdidos documentos, chaves, histórico. Esta ação não pode ser desfeita.
</p>
</div>
{!showDeleteInput ? (
<button
onClick={() => setShowDeleteInput(true)}
className="flex-shrink-0 rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-2.5 text-sm font-medium text-red-400 transition-all hover:bg-red-500/20"
>
Excluir minha conta
</button>
) : (
<div className="flex items-center gap-2">
<input
type="text"
placeholder='Digite "EXCLUIR" para confirmar'
value={deleteConfirm}
onChange={(e) => setDeleteConfirm(e.target.value)}
className="rounded-xl border border-red-500/30 bg-red-500/5 px-3 py-2 text-sm text-red-300 placeholder-red-800 outline-none focus:border-red-500/50"
/>
<button
disabled={deleteConfirm !== 'EXCLUIR'}
className="rounded-xl bg-red-600 px-4 py-2 text-sm font-medium text-white transition-all hover:bg-red-500 disabled:cursor-not-allowed disabled:opacity-40"
>
Confirmar
</button>
<button
onClick={() => {
setShowDeleteInput(false)
setDeleteConfirm('')
}}
className="rounded-xl px-3 py-2 text-sm text-zinc-500 hover:text-zinc-300"
>
Cancelar
</button>
</div>
)}
</div>
</Section>
</div>
)
}

View File

@@ -0,0 +1,100 @@
import { getServerSession } from 'next-auth'
import { redirect } from 'next/navigation'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import SettingsClient from './SettingsClient'
export const metadata = {
title: 'Configurações | LexMind',
description: 'Gerencie seu perfil, assinatura, chaves de API e segurança',
}
export default async function ConfiguracoesPage() {
const session = await getServerSession(authOptions)
if (!session?.user) redirect('/login')
const [user, apiKeys, subscription, storageData] = await Promise.all([
prisma.user.findUnique({
where: { id: session.user.id },
select: {
id: true,
name: true,
email: true,
phone: true,
oabNumber: true,
oabState: true,
avatar: true,
plan: true,
credits: true,
createdAt: true,
},
}),
prisma.apiKey.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
key: true,
active: true,
createdAt: true,
},
}),
prisma.subscription.findFirst({
where: { userId: session.user.id, status: 'ACTIVE' },
orderBy: { startDate: 'desc' },
}),
(async () => {
const [agg, count] = await Promise.all([
prisma.upload.aggregate({
where: { userId: session.user.id },
_sum: { size: true },
}),
prisma.upload.count({ where: { userId: session.user.id } }),
])
const limits: Record<string, number> = {
FREE: 1073741824, STARTER: 1073741824,
PRO: 5368709120, ENTERPRISE: 21474836480,
}
const userPlan = (await prisma.user.findUnique({ where: { id: session.user.id }, select: { plan: true } }))?.plan || 'FREE'
return {
used: agg._sum.size || 0,
limit: limits[userPlan] || limits.FREE,
count,
}
})(),
])
if (!user) redirect('/login')
const maskedKeys = apiKeys.map((k) => ({
...k,
key: k.key.slice(0, 8) + '••••••••' + k.key.slice(-4),
createdAt: k.createdAt.toISOString(),
}))
return (
<SettingsClient
user={{
...user,
createdAt: user.createdAt.toISOString(),
}}
apiKeys={maskedKeys}
subscription={
subscription
? {
plan: subscription.plan,
status: subscription.status,
startDate: subscription.startDate.toISOString(),
endDate: subscription.endDate?.toISOString() ?? null,
}
: null
}
storage={{
used: storageData.used,
limit: storageData.limit,
count: storageData.count,
}}
/>
)
}

View File

@@ -0,0 +1,706 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import {
Search,
Filter,
ChevronDown,
ChevronUp,
Copy,
Check,
Sparkles,
Scale,
Calendar,
User,
Tag,
X,
Loader2,
ChevronLeft,
ChevronRight,
Brain,
Zap,
} from 'lucide-react'
// ── Types ────────────────────────────────────────────────────────────
interface Jurisprudencia {
id: string
tribunal: string
numero: string
ementa: string
data: string
area: string
relator: string
orgaoJulgador: string
tags: string
}
interface SearchResponse {
results: Jurisprudencia[]
total: number
page: number
perPage: number
totalPages: number
}
interface AISearchResponse {
results: Jurisprudencia[]
total: number
extractedTerms: {
keywords: string[]
tribunal?: string
area?: string
relator?: string
}
aiQuery: string
}
// ── Constants ────────────────────────────────────────────────────────
const TRIBUNAIS = [
'STF', 'STJ', 'TST',
'TRF1', 'TRF2', 'TRF3', 'TRF4', 'TRF5',
'TJSP', 'TJRJ', 'TJMG', 'TJRS', 'TJPR', 'TJSC',
'TJBA', 'TJPE', 'TJCE', 'TJGO', 'TJDF', 'TJES',
'TJMT', 'TJMS', 'TJPA', 'TJMA', 'TJPB', 'TJRN',
'TJAL', 'TJSE', 'TJPI', 'TJTO', 'TJRO', 'TJAC',
'TJAP', 'TJRR', 'TJAM',
]
const AREAS = [
{ value: 'CIVIL', label: 'Civil' },
{ value: 'TRABALHISTA', label: 'Trabalhista' },
{ value: 'PENAL', label: 'Penal' },
{ value: 'TRIBUTARIO', label: 'Tributário' },
{ value: 'FAMILIA', label: 'Família' },
{ value: 'EMPRESARIAL', label: 'Empresarial' },
{ value: 'CONSUMIDOR', label: 'Consumidor' },
{ value: 'ADMINISTRATIVO', label: 'Administrativo' },
]
const TRIBUNAL_COLORS: Record<string, string> = {
STF: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
STJ: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
TST: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
TRF1: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
TRF2: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
TRF3: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
TRF4: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
TRF5: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
}
function getTribunalColor(tribunal: string): string {
if (TRIBUNAL_COLORS[tribunal]) return TRIBUNAL_COLORS[tribunal]
if (tribunal.startsWith('TJ')) return 'bg-teal-500/20 text-teal-400 border-teal-500/30'
return 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30'
}
const AREA_COLORS: Record<string, string> = {
CIVIL: 'bg-sky-500/15 text-sky-400',
TRABALHISTA: 'bg-amber-500/15 text-amber-400',
PENAL: 'bg-red-500/15 text-red-400',
TRIBUTARIO: 'bg-emerald-500/15 text-emerald-400',
FAMILIA: 'bg-pink-500/15 text-pink-400',
EMPRESARIAL: 'bg-indigo-500/15 text-indigo-400',
CONSUMIDOR: 'bg-orange-500/15 text-orange-400',
ADMINISTRATIVO: 'bg-teal-500/15 text-teal-400',
}
// ── Component ────────────────────────────────────────────────────────
export default function JurisprudenciaPage() {
// Search state
const [searchQuery, setSearchQuery] = useState('')
const [aiQuery, setAiQuery] = useState('')
const [isAIMode, setIsAIMode] = useState(false)
const [showFilters, setShowFilters] = useState(false)
// Filters
const [tribunal, setTribunal] = useState('')
const [area, setArea] = useState('')
const [relator, setRelator] = useState('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
// Results
const [results, setResults] = useState<Jurisprudencia[]>([])
const [total, setTotal] = useState(0)
const [totalPages, setTotalPages] = useState(0)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false)
const [searched, setSearched] = useState(false)
// AI extras
const [extractedTerms, setExtractedTerms] = useState<AISearchResponse['extractedTerms'] | null>(null)
// Expand state
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
// Copy state
const [copiedId, setCopiedId] = useState<string | null>(null)
const activeFilterCount = [tribunal, area, relator, dateFrom, dateTo].filter(Boolean).length
// ── Standard search ──
const doSearch = useCallback(async (p = 1) => {
setLoading(true)
setSearched(true)
setExtractedTerms(null)
const params = new URLSearchParams()
if (searchQuery) params.set('search', searchQuery)
if (tribunal) params.set('tribunal', tribunal)
if (area) params.set('area', area)
if (relator) params.set('relator', relator)
if (dateFrom) params.set('dateFrom', dateFrom)
if (dateTo) params.set('dateTo', dateTo)
params.set('page', p.toString())
params.set('perPage', '10')
try {
const res = await fetch(`/api/jurisprudencia?${params.toString()}`)
const data: SearchResponse = await res.json()
setResults(data.results)
setTotal(data.total)
setTotalPages(data.totalPages)
setPage(data.page)
} catch (err) {
console.error('Search error:', err)
} finally {
setLoading(false)
}
}, [searchQuery, tribunal, area, relator, dateFrom, dateTo])
// ── AI search ──
const doAISearch = useCallback(async () => {
if (!aiQuery.trim()) return
setLoading(true)
setSearched(true)
try {
const res = await fetch('/api/jurisprudencia/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: aiQuery }),
})
const data: AISearchResponse = await res.json()
setResults(data.results)
setTotal(data.total)
setTotalPages(1)
setPage(1)
setExtractedTerms(data.extractedTerms)
} catch (err) {
console.error('AI search error:', err)
} finally {
setLoading(false)
}
}, [aiQuery])
// ── Handlers ──
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
if (isAIMode) {
doAISearch()
} else {
doSearch(1)
}
}
const handlePageChange = (newPage: number) => {
if (newPage < 1 || newPage > totalPages) return
doSearch(newPage)
}
const toggleExpand = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const copyCitation = (item: Jurisprudencia) => {
const tags = parseTags(item.tags)
const citation = `${item.tribunal} - ${item.numero} - Rel. ${item.relator} - ${item.orgaoJulgador} - ${formatDate(item.data)}${tags.length ? ` - ${tags.join(', ')}` : ''}\n\nEMENTA: ${item.ementa}`
navigator.clipboard.writeText(citation)
setCopiedId(item.id)
setTimeout(() => setCopiedId(null), 2000)
}
const clearFilters = () => {
setTribunal('')
setArea('')
setRelator('')
setDateFrom('')
setDateTo('')
}
// ── Helpers ──
const formatDate = (dateStr: string) => {
try {
const [y, m, d] = dateStr.split('-')
return `${d}/${m}/${y}`
} catch {
return dateStr
}
}
const parseTags = (tagsStr: string): string[] => {
try {
return JSON.parse(tagsStr)
} catch {
return []
}
}
const truncateEmenta = (text: string, max = 250) => {
if (text.length <= max) return text
return text.slice(0, max).trimEnd() + '…'
}
// Load initial results on mount
useEffect(() => {
doSearch(1)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div className="space-y-6">
{/* ── Header ── */}
<div>
<h1 className="text-2xl font-bold text-white">Jurisprudência</h1>
<p className="mt-1 text-sm text-zinc-400">
Pesquise decisões judiciais de tribunais brasileiros
</p>
</div>
{/* ── Search Mode Toggle ── */}
<div className="flex items-center gap-2">
<button
onClick={() => setIsAIMode(false)}
className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all ${
!isAIMode
? 'bg-teal-600 text-white shadow-lg shadow-teal-500/20'
: 'bg-white/5 text-zinc-400 hover:bg-white/10 hover:text-zinc-200'
}`}
>
<Search className="h-4 w-4" />
Busca Padrão
</button>
<button
onClick={() => setIsAIMode(true)}
className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all ${
isAIMode
? 'bg-gradient-to-r from-teal-600 to-teal-600 text-white shadow-lg shadow-teal-500/20'
: 'bg-white/5 text-zinc-400 hover:bg-white/10 hover:text-zinc-200'
}`}
>
<Brain className="h-4 w-4" />
Busca Inteligente
<Sparkles className="h-3 w-3 text-amber-400" />
</button>
</div>
{/* ── Search Bar ── */}
<form onSubmit={handleSearch} className="space-y-3">
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
{isAIMode ? (
<Sparkles className="h-5 w-5 text-teal-400" />
) : (
<Search className="h-5 w-5 text-zinc-500" />
)}
</div>
<input
type="text"
value={isAIMode ? aiQuery : searchQuery}
onChange={(e) =>
isAIMode ? setAiQuery(e.target.value) : setSearchQuery(e.target.value)
}
placeholder={
isAIMode
? 'Ex: "jurisprudência sobre dano moral em relações de consumo no STJ"'
: 'Pesquisar por ementa, número do processo ou relator...'
}
className={`w-full rounded-xl border bg-white/5 py-3.5 pl-12 pr-32 text-sm text-white placeholder-zinc-500 transition-all focus:outline-none ${
isAIMode
? 'border-teal-500/30 focus:border-teal-500/60 focus:ring-2 focus:ring-teal-500/20'
: 'border-white/10 focus:border-teal-500/40 focus:ring-2 focus:ring-teal-500/10'
}`}
/>
<div className="absolute inset-y-0 right-0 flex items-center gap-2 pr-2">
{!isAIMode && (
<button
type="button"
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
showFilters || activeFilterCount > 0
? 'bg-teal-600/20 text-teal-400'
: 'bg-white/5 text-zinc-400 hover:bg-white/10'
}`}
>
<Filter className="h-3.5 w-3.5" />
Filtros
{activeFilterCount > 0 && (
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-teal-600 text-[10px] font-bold text-white">
{activeFilterCount}
</span>
)}
</button>
)}
<button
type="submit"
disabled={loading}
className="flex items-center gap-2 rounded-lg bg-teal-600 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-teal-500 disabled:opacity-50"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isAIMode ? (
<Zap className="h-4 w-4" />
) : (
<Search className="h-4 w-4" />
)}
Buscar
</button>
</div>
</div>
{/* ── Filters Panel ── */}
{showFilters && !isAIMode && (
<div className="rounded-xl border border-white/5 bg-white/[0.02] p-4">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
Filtros avançados
</span>
{activeFilterCount > 0 && (
<button
type="button"
onClick={clearFilters}
className="flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-300"
>
<X className="h-3 w-3" />
Limpar filtros
</button>
)}
</div>
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{/* Tribunal */}
<div>
<label className="mb-1 flex items-center gap-1.5 text-xs font-medium text-zinc-400">
<Scale className="h-3 w-3" />
Tribunal
</label>
<select
value={tribunal}
onChange={(e) => setTribunal(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white focus:border-teal-500/40 focus:outline-none focus:ring-2 focus:ring-teal-500/10"
>
<option value="" className="bg-[#0f1620]">Todos</option>
{TRIBUNAIS.map((t) => (
<option key={t} value={t} className="bg-[#0f1620]">
{t}
</option>
))}
</select>
</div>
{/* Área */}
<div>
<label className="mb-1 flex items-center gap-1.5 text-xs font-medium text-zinc-400">
<Tag className="h-3 w-3" />
Área do Direito
</label>
<select
value={area}
onChange={(e) => setArea(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white focus:border-teal-500/40 focus:outline-none focus:ring-2 focus:ring-teal-500/10"
>
<option value="" className="bg-[#0f1620]">Todas</option>
{AREAS.map((a) => (
<option key={a.value} value={a.value} className="bg-[#0f1620]">
{a.label}
</option>
))}
</select>
</div>
{/* Relator */}
<div>
<label className="mb-1 flex items-center gap-1.5 text-xs font-medium text-zinc-400">
<User className="h-3 w-3" />
Relator
</label>
<input
type="text"
value={relator}
onChange={(e) => setRelator(e.target.value)}
placeholder="Nome do relator..."
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder-zinc-600 focus:border-teal-500/40 focus:outline-none focus:ring-2 focus:ring-teal-500/10"
/>
</div>
{/* Date range */}
<div>
<label className="mb-1 flex items-center gap-1.5 text-xs font-medium text-zinc-400">
<Calendar className="h-3 w-3" />
Período
</label>
<div className="flex items-center gap-2">
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-white/5 px-2 py-2 text-xs text-white focus:border-teal-500/40 focus:outline-none focus:ring-2 focus:ring-teal-500/10 [color-scheme:dark]"
/>
<span className="text-zinc-600"></span>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-white/5 px-2 py-2 text-xs text-white focus:border-teal-500/40 focus:outline-none focus:ring-2 focus:ring-teal-500/10 [color-scheme:dark]"
/>
</div>
</div>
</div>
</div>
)}
</form>
{/* ── AI Extracted Terms ── */}
{extractedTerms && (
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-teal-500/20 bg-teal-500/5 p-3">
<span className="flex items-center gap-1.5 text-xs font-medium text-teal-400">
<Brain className="h-3.5 w-3.5" />
Termos extraídos pela IA:
</span>
{extractedTerms.keywords.map((kw, i) => (
<span
key={i}
className="rounded-md bg-teal-500/15 px-2 py-0.5 text-xs font-medium text-teal-300"
>
{kw}
</span>
))}
{extractedTerms.tribunal && (
<span className="rounded-md bg-blue-500/15 px-2 py-0.5 text-xs font-medium text-blue-300">
Tribunal: {extractedTerms.tribunal}
</span>
)}
{extractedTerms.area && (
<span className="rounded-md bg-emerald-500/15 px-2 py-0.5 text-xs font-medium text-emerald-300">
Área: {extractedTerms.area}
</span>
)}
</div>
)}
{/* ── Results ── */}
{loading ? (
<div className="flex flex-col items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-teal-500" />
<p className="mt-3 text-sm text-zinc-500">
{isAIMode ? 'IA analisando sua consulta...' : 'Buscando jurisprudência...'}
</p>
</div>
) : searched && results.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<Scale className="h-12 w-12 text-zinc-700" />
<p className="mt-3 text-sm font-medium text-zinc-400">
Nenhuma jurisprudência encontrada
</p>
<p className="mt-1 text-xs text-zinc-600">
Tente ajustar os termos de busca ou os filtros
</p>
</div>
) : (
<>
{/* Result count */}
{searched && (
<div className="flex items-center justify-between">
<p className="text-sm text-zinc-500">
<span className="font-medium text-zinc-300">{total}</span>{' '}
resultado{total !== 1 ? 's' : ''} encontrado{total !== 1 ? 's' : ''}
</p>
</div>
)}
{/* Cards */}
<div className="space-y-3">
{results.map((item) => {
const isExpanded = expandedIds.has(item.id)
const isCopied = copiedId === item.id
const tags = parseTags(item.tags)
const areaLabel = AREAS.find((a) => a.value === item.area)?.label || item.area
return (
<div
key={item.id}
className="group rounded-xl border border-white/5 bg-white/[0.02] transition-all hover:border-white/10 hover:bg-white/[0.04]"
>
<div className="p-5">
{/* Top row */}
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
{/* Tribunal badge */}
<span
className={`rounded-md border px-2.5 py-1 text-xs font-bold tracking-wide ${getTribunalColor(
item.tribunal
)}`}
>
{item.tribunal}
</span>
{/* Process number */}
<span className="text-sm font-semibold text-white">
{item.numero}
</span>
</div>
{/* Date */}
<span className="flex items-center gap-1.5 text-xs text-zinc-500">
<Calendar className="h-3 w-3" />
{formatDate(item.data)}
</span>
</div>
{/* Relator & Órgão */}
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-zinc-500">
<span className="flex items-center gap-1">
<User className="h-3 w-3" />
Rel. {item.relator}
</span>
{item.orgaoJulgador && (
<span className="text-zinc-600"> {item.orgaoJulgador}</span>
)}
</div>
{/* Ementa */}
<div className="mt-3">
<p className="text-sm leading-relaxed text-zinc-300">
{isExpanded ? item.ementa : truncateEmenta(item.ementa)}
</p>
{item.ementa.length > 250 && (
<button
onClick={() => toggleExpand(item.id)}
className="mt-1.5 flex items-center gap-1 text-xs font-medium text-teal-400 hover:text-teal-300"
>
{isExpanded ? (
<>
<ChevronUp className="h-3 w-3" />
Mostrar menos
</>
) : (
<>
<ChevronDown className="h-3 w-3" />
Ler ementa completa
</>
)}
</button>
)}
</div>
{/* Tags & Actions */}
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-1.5">
<span
className={`rounded-md px-2 py-0.5 text-[11px] font-medium ${
AREA_COLORS[item.area] || 'bg-zinc-500/15 text-zinc-400'
}`}
>
{areaLabel}
</span>
{tags.map((tag, i) => (
<span
key={i}
className="rounded-md bg-white/5 px-2 py-0.5 text-[11px] text-zinc-500"
>
{tag}
</span>
))}
</div>
<button
onClick={() => copyCitation(item)}
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
isCopied
? 'bg-emerald-500/20 text-emerald-400'
: 'bg-teal-600/15 text-teal-400 hover:bg-teal-600/25'
}`}
>
{isCopied ? (
<>
<Check className="h-3.5 w-3.5" />
Copiado!
</>
) : (
<>
<Copy className="h-3.5 w-3.5" />
Usar em peça
</>
)}
</button>
</div>
</div>
</div>
)
})}
</div>
{/* ── Pagination ── */}
{totalPages > 1 && !isAIMode && (
<div className="flex items-center justify-center gap-2 pt-4">
<button
onClick={() => handlePageChange(page - 1)}
disabled={page <= 1}
className="flex items-center gap-1 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-400 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-30"
>
<ChevronLeft className="h-4 w-4" />
Anterior
</button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
let pageNum: number
if (totalPages <= 7) {
pageNum = i + 1
} else if (page <= 4) {
pageNum = i + 1
} else if (page >= totalPages - 3) {
pageNum = totalPages - 6 + i
} else {
pageNum = page - 3 + i
}
return (
<button
key={pageNum}
onClick={() => handlePageChange(pageNum)}
className={`flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium transition-all ${
pageNum === page
? 'bg-teal-600 text-white shadow-lg shadow-teal-500/20'
: 'text-zinc-500 hover:bg-white/5 hover:text-zinc-300'
}`}
>
{pageNum}
</button>
)
})}
</div>
<button
onClick={() => handlePageChange(page + 1)}
disabled={page >= totalPages}
className="flex items-center gap-1 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-400 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-30"
>
Próxima
<ChevronRight className="h-4 w-4" />
</button>
</div>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,36 @@
import { getServerSession } from 'next-auth'
import { redirect } from 'next/navigation'
import { authOptions } from '@/lib/auth'
import DashboardShell from './DashboardShell'
export const metadata = {
title: 'Dashboard | LexMind',
description: 'Painel de controle da plataforma LexMind',
}
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await getServerSession(authOptions)
if (!session?.user) {
redirect('/login')
}
return (
<DashboardShell
user={{
name: session.user.name,
email: session.user.email,
plan: session.user.plan,
avatar: session.user.avatar,
oabNumber: session.user.oabNumber,
oabState: session.user.oabState,
}}
>
{children}
</DashboardShell>
)
}

View File

@@ -0,0 +1,287 @@
'use client'
import { useState, useEffect, use } from 'react'
import { useRouter } from 'next/navigation'
import { motion } from 'framer-motion'
import ReactMarkdown from 'react-markdown'
import { stripMarkdown, downloadAsWord } from '@/lib/utils'
import {
ArrowLeft, Copy, Printer, RefreshCw, FileText, Calendar,
Hash, DollarSign, Tag, Briefcase, CheckCircle2, Clock,
AlertTriangle, Loader2, FileDown
} from 'lucide-react'
const DOCUMENT_TYPES: Record<string, string> = {
PETICAO_INICIAL: 'Petição Inicial',
CONTESTACAO: 'Contestação',
APELACAO: 'Apelação',
RECURSO: 'Recurso',
CONTRATO: 'Contrato',
PARECER: 'Parecer',
IMPUGNACAO: 'Impugnação',
HABEAS_CORPUS: 'Habeas Corpus',
MANDADO_SEGURANCA: 'Mandado de Segurança',
OUTROS: 'Outros',
}
const AREAS: Record<string, string> = {
CIVIL: 'Civil',
TRABALHISTA: 'Trabalhista',
PENAL: 'Penal',
TRIBUTARIO: 'Tributário',
FAMILIA: 'Família',
EMPRESARIAL: 'Empresarial',
CONSUMIDOR: 'Consumidor',
ADMINISTRATIVO: 'Administrativo',
}
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof CheckCircle2 }> = {
COMPLETED: { label: 'Concluído', color: 'text-emerald-400 bg-emerald-400/10 border-emerald-400/20', icon: CheckCircle2 },
GENERATING: { label: 'Gerando...', color: 'text-amber-400 bg-amber-400/10 border-amber-400/20', icon: Clock },
ERROR: { label: 'Erro', color: 'text-red-400 bg-red-400/10 border-red-400/20', icon: AlertTriangle },
}
interface Document {
id: string
title: string
type: string
area: string
status: string
content: string
prompt: string
wordCount: number
tokens: number
cost: number
createdAt: string
}
export default function DocumentViewerPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const router = useRouter()
const [doc, setDoc] = useState<Document | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [copied, setCopied] = useState(false)
const [downloadingWord, setDownloadingWord] = useState(false)
useEffect(() => {
fetch(`/api/documents/${id}`)
.then(res => {
if (!res.ok) throw new Error('Documento não encontrado')
return res.json()
})
.then(data => setDoc(data.document))
.catch(err => setError(err.message))
.finally(() => setLoading(false))
}, [id])
const handleCopy = async () => {
if (!doc) return
await navigator.clipboard.writeText(stripMarkdown(doc.content))
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handlePrint = () => {
window.print()
}
const handleDownloadWord = async () => {
if (!doc) return
setDownloadingWord(true)
try {
await downloadAsWord(doc.title, doc.content)
} catch (err) {
console.error('Word download error:', err)
} finally {
setDownloadingWord(false)
}
}
const formatDate = (date: string) =>
new Intl.DateTimeFormat('pt-BR', {
day: '2-digit', month: 'long', year: 'numeric',
hour: '2-digit', minute: '2-digit'
}).format(new Date(date))
if (loading) {
return (
<div className="min-h-screen bg-[#0a0f1a] flex items-center justify-center">
<Loader2 className="w-8 h-8 text-teal-400 animate-spin" />
</div>
)
}
if (error || !doc) {
return (
<div className="min-h-screen bg-[#0a0f1a] flex flex-col items-center justify-center gap-4">
<AlertTriangle className="w-12 h-12 text-red-400" />
<h2 className="text-xl font-semibold text-white">{error || 'Documento não encontrado'}</h2>
<button
onClick={() => router.push('/dashboard/minhas-pecas')}
className="flex items-center gap-2 px-4 py-2 text-teal-400 hover:text-teal-300 transition-colors"
>
<ArrowLeft className="w-4 h-4" /> Voltar para Minhas Peças
</button>
</div>
)
}
const status = STATUS_CONFIG[doc.status] || STATUS_CONFIG.COMPLETED
const StatusIcon = status.icon
return (
<div className="min-h-screen bg-[#0a0f1a] p-4 md:p-8">
<div className="max-w-5xl mx-auto">
{/* Top Bar */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6"
>
<button
onClick={() => router.push('/dashboard/minhas-pecas')}
className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors w-fit"
>
<ArrowLeft className="w-5 h-5" />
Voltar para Minhas Peças
</button>
<div className="flex gap-2 print:hidden">
<button
onClick={handleCopy}
className="flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-gray-300 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl transition-all"
>
<Copy className="w-4 h-4" />
{copied ? 'Copiado!' : 'Copiar'}
</button>
<button
onClick={handlePrint}
className="flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-gray-300 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl transition-all"
>
<Printer className="w-4 h-4" />
Imprimir
</button>
<button
onClick={handleDownloadWord}
disabled={downloadingWord}
className="flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-white bg-teal-600 hover:bg-teal-500 border border-teal-500/30 rounded-xl transition-all"
>
<FileDown className="w-4 h-4" />
{downloadingWord ? 'Gerando...' : 'Baixar Word'}
</button>
<button
onClick={() => router.push('/dashboard/gerar')}
className="flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-white bg-teal-600 hover:bg-teal-700 rounded-xl transition-all glow-purple"
>
<RefreshCw className="w-4 h-4" />
Gerar Nova Versão
</button>
</div>
</motion.div>
{/* Document Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="glass rounded-2xl p-6 mb-6"
>
<div className="flex flex-wrap items-center gap-3 mb-4">
<span className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-full border ${status.color}`}>
<StatusIcon className="w-3.5 h-3.5" />
{status.label}
</span>
<span className="px-3 py-1.5 text-xs font-medium text-teal-300 bg-teal-500/10 border border-teal-500/20 rounded-full">
{DOCUMENT_TYPES[doc.type] || doc.type}
</span>
<span className="px-3 py-1.5 text-xs font-medium text-blue-300 bg-blue-500/10 border border-blue-500/20 rounded-full">
{AREAS[doc.area] || doc.area}
</span>
</div>
<h1 className="text-2xl md:text-3xl font-bold text-white mb-4">{doc.title}</h1>
{/* Metadata Grid */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-gray-500" />
<div>
<p className="text-xs text-gray-500">Data</p>
<p className="text-sm text-gray-300">{formatDate(doc.createdAt)}</p>
</div>
</div>
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-gray-500" />
<div>
<p className="text-xs text-gray-500">Palavras</p>
<p className="text-sm text-gray-300">{doc.wordCount.toLocaleString('pt-BR')}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Hash className="w-4 h-4 text-gray-500" />
<div>
<p className="text-xs text-gray-500">Tokens</p>
<p className="text-sm text-gray-300">{doc.tokens.toLocaleString('pt-BR')}</p>
</div>
</div>
<div className="flex items-center gap-2">
<DollarSign className="w-4 h-4 text-gray-500" />
<div>
<p className="text-xs text-gray-500">Custo</p>
<p className="text-sm text-gray-300">R$ {doc.cost.toFixed(4)}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Tag className="w-4 h-4 text-gray-500" />
<div>
<p className="text-xs text-gray-500">Tipo</p>
<p className="text-sm text-gray-300">{DOCUMENT_TYPES[doc.type] || doc.type}</p>
</div>
</div>
</div>
</motion.div>
{/* Prompt (collapsed) */}
{doc.prompt && (
<motion.details
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
className="glass rounded-2xl mb-6 print:hidden"
>
<summary className="px-6 py-4 cursor-pointer text-gray-400 hover:text-gray-300 transition-colors flex items-center gap-2">
<Briefcase className="w-4 h-4" />
<span className="text-sm font-medium">Prompt utilizado</span>
</summary>
<div className="px-6 pb-5 text-sm text-gray-400 whitespace-pre-wrap border-t border-white/5 pt-4">
{doc.prompt}
</div>
</motion.details>
)}
{/* Document Content */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="glass rounded-2xl p-6 md:p-10"
>
<div className="prose prose-invert prose-purple max-w-none
prose-headings:text-white prose-headings:font-bold
prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg
prose-p:text-gray-300 prose-p:leading-relaxed
prose-strong:text-white
prose-a:text-teal-400 prose-a:no-underline hover:prose-a:underline
prose-blockquote:border-teal-500/40 prose-blockquote:bg-teal-500/5 prose-blockquote:rounded-r-lg prose-blockquote:py-1
prose-code:text-teal-300 prose-code:bg-teal-500/10 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded
prose-li:text-gray-300
prose-hr:border-white/10
">
<ReactMarkdown>{doc.content}</ReactMarkdown>
</div>
</motion.div>
</div>
</div>
)
}

View File

@@ -0,0 +1,519 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import FileUpload from '@/components/FileUpload'
import { useRouter } from 'next/navigation'
import { motion, AnimatePresence } from 'framer-motion'
import {
FileText, Search, Filter, Grid3X3, List, Eye, Copy, Trash2, Upload,
ChevronDown, Calendar, ArrowUpDown, Plus, Loader2, AlertTriangle,
CheckCircle2, Clock, X
} from 'lucide-react'
// ── Constants ──
const DOCUMENT_TYPES: Record<string, string> = {
PETICAO_INICIAL: 'Petição Inicial',
CONTESTACAO: 'Contestação',
APELACAO: 'Apelação',
RECURSO: 'Recurso',
CONTRATO: 'Contrato',
PARECER: 'Parecer',
IMPUGNACAO: 'Impugnação',
HABEAS_CORPUS: 'Habeas Corpus',
MANDADO_SEGURANCA: 'Mandado de Segurança',
OUTROS: 'Outros',
}
const AREAS: Record<string, string> = {
CIVIL: 'Civil',
TRABALHISTA: 'Trabalhista',
PENAL: 'Penal',
TRIBUTARIO: 'Tributário',
FAMILIA: 'Família',
EMPRESARIAL: 'Empresarial',
CONSUMIDOR: 'Consumidor',
ADMINISTRATIVO: 'Administrativo',
}
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof CheckCircle2 }> = {
COMPLETED: { label: 'Concluído', color: 'text-emerald-400 bg-emerald-400/10 border-emerald-400/20', icon: CheckCircle2 },
GENERATING: { label: 'Gerando...', color: 'text-amber-400 bg-amber-400/10 border-amber-400/20', icon: Clock },
ERROR: { label: 'Erro', color: 'text-red-400 bg-red-400/10 border-red-400/20', icon: AlertTriangle },
}
interface Document {
id: string
title: string
type: string
area: string
status: string
wordCount: number
tokens: number
cost: number
createdAt: string
}
// ── Component ──
export default function MinhasPecasPage() {
const router = useRouter()
const [documents, setDocuments] = useState<Document[]>([])
const [loading, setLoading] = useState(true)
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [activeTab, setActiveTab] = useState<'documents' | 'uploads'>('documents')
const [search, setSearch] = useState('')
const [typeFilter, setTypeFilter] = useState('')
const [areaFilter, setAreaFilter] = useState('')
const [sort, setSort] = useState('newest')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [deleteId, setDeleteId] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false)
const [copied, setCopied] = useState<string | null>(null)
const fetchDocuments = useCallback(async () => {
setLoading(true)
const params = new URLSearchParams()
if (search) params.set('search', search)
if (typeFilter) params.set('type', typeFilter)
if (areaFilter) params.set('area', areaFilter)
if (sort) params.set('sort', sort)
if (dateFrom) params.set('dateFrom', dateFrom)
if (dateTo) params.set('dateTo', dateTo)
try {
const res = await fetch(`/api/documents?${params.toString()}`)
const data = await res.json()
setDocuments(data.documents || [])
} catch {
setDocuments([])
} finally {
setLoading(false)
}
}, [search, typeFilter, areaFilter, sort, dateFrom, dateTo])
useEffect(() => {
const timer = setTimeout(fetchDocuments, 300)
return () => clearTimeout(timer)
}, [fetchDocuments])
const handleDelete = async () => {
if (!deleteId) return
setDeleting(true)
try {
await fetch(`/api/documents/${deleteId}`, { method: 'DELETE' })
setDocuments(prev => prev.filter(d => d.id !== deleteId))
} finally {
setDeleting(false)
setDeleteId(null)
}
}
const handleCopy = async (id: string) => {
try {
const res = await fetch(`/api/documents/${id}`)
const { document } = await res.json()
await navigator.clipboard.writeText(document.content)
setCopied(id)
setTimeout(() => setCopied(null), 2000)
} catch { /* ignore */ }
}
const formatDate = (date: string) =>
new Intl.DateTimeFormat('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(new Date(date))
const hasFilters = typeFilter || areaFilter || dateFrom || dateTo || search
// ── Render ──
return (
<div className="min-h-screen bg-[#0a0f1a] p-4 md:p-8">
{/* Header */}
<div className="max-w-7xl mx-auto">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-8">
<div>
<h1 className="text-3xl font-bold gradient-text">Minhas Peças</h1>
<p className="text-gray-400 mt-1">
{loading ? 'Carregando...' : `${documents.length} documento${documents.length !== 1 ? 's' : ''}`}
</p>
</div>
<button
onClick={() => router.push('/dashboard/gerar')}
className="flex items-center gap-2 px-6 py-3 bg-teal-600 hover:bg-teal-700 rounded-xl font-semibold transition-all glow-purple"
>
<Plus className="w-5 h-5" />
Nova Peça
</button>
</div>
{/* Tabs */}
<div className="flex gap-2 mb-6">
<button
onClick={() => setActiveTab('documents')}
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium transition-all ${
activeTab === 'documents'
? 'bg-teal-600/20 text-teal-400 border border-teal-500/30'
: 'bg-white/5 text-gray-400 border border-white/10 hover:text-white'
}`}
>
<FileText className="w-4 h-4" />
Documentos
</button>
<button
onClick={() => setActiveTab('uploads')}
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium transition-all ${
activeTab === 'uploads'
? 'bg-teal-600/20 text-teal-400 border border-teal-500/30'
: 'bg-white/5 text-gray-400 border border-white/10 hover:text-white'
}`}
>
<Upload className="w-4 h-4" />
Uploads
</button>
</div>
{activeTab === 'uploads' ? (
<div className="glass rounded-2xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Meus Uploads</h2>
<FileUpload label="Meus Uploads" maxFiles={50} />
</div>
) : (
<>
{/* Filters Bar */}
<div className="glass rounded-2xl p-4 mb-6 space-y-4">
{/* Search + View Toggle */}
<div className="flex flex-col md:flex-row gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Buscar por título..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/30 transition-all"
/>
</div>
<div className="flex gap-2">
<button
onClick={() => setViewMode('grid')}
className={`p-3 rounded-xl border transition-all ${viewMode === 'grid' ? 'bg-teal-600/20 border-teal-500/40 text-teal-400' : 'bg-white/5 border-white/10 text-gray-400 hover:text-white'}`}
>
<Grid3X3 className="w-5 h-5" />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-3 rounded-xl border transition-all ${viewMode === 'list' ? 'bg-teal-600/20 border-teal-500/40 text-teal-400' : 'bg-white/5 border-white/10 text-gray-400 hover:text-white'}`}
>
<List className="w-5 h-5" />
</button>
</div>
</div>
{/* Filter Dropdowns */}
<div className="flex flex-wrap gap-3 items-center">
<Filter className="w-4 h-4 text-gray-400" />
{/* Type Filter */}
<div className="relative">
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="appearance-none pl-3 pr-8 py-2 bg-white/5 border border-white/10 rounded-lg text-sm text-gray-300 focus:outline-none focus:border-teal-500/50 cursor-pointer"
>
<option value="">Todos os tipos</option>
{Object.entries(DOCUMENT_TYPES).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
</div>
{/* Area Filter */}
<div className="relative">
<select
value={areaFilter}
onChange={(e) => setAreaFilter(e.target.value)}
className="appearance-none pl-3 pr-8 py-2 bg-white/5 border border-white/10 rounded-lg text-sm text-gray-300 focus:outline-none focus:border-teal-500/50 cursor-pointer"
>
<option value="">Todas as áreas</option>
{Object.entries(AREAS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
</div>
{/* Sort */}
<div className="relative">
<select
value={sort}
onChange={(e) => setSort(e.target.value)}
className="appearance-none pl-3 pr-8 py-2 bg-white/5 border border-white/10 rounded-lg text-sm text-gray-300 focus:outline-none focus:border-teal-500/50 cursor-pointer"
>
<option value="newest">Mais recentes</option>
<option value="oldest">Mais antigos</option>
<option value="type">Por tipo</option>
</select>
<ArrowUpDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
</div>
{/* Date Range */}
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-gray-400" />
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="px-2 py-1.5 bg-white/5 border border-white/10 rounded-lg text-sm text-gray-300 focus:outline-none focus:border-teal-500/50"
/>
<span className="text-gray-500 text-sm">até</span>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="px-2 py-1.5 bg-white/5 border border-white/10 rounded-lg text-sm text-gray-300 focus:outline-none focus:border-teal-500/50"
/>
</div>
{hasFilters && (
<button
onClick={() => { setSearch(''); setTypeFilter(''); setAreaFilter(''); setDateFrom(''); setDateTo(''); setSort('newest') }}
className="flex items-center gap-1 px-3 py-1.5 text-xs text-red-400 hover:text-red-300 bg-red-400/10 border border-red-400/20 rounded-lg transition-all"
>
<X className="w-3 h-3" /> Limpar filtros
</button>
)}
</div>
</div>
{/* Loading State */}
{loading && (
<div className="flex items-center justify-center py-24">
<Loader2 className="w-8 h-8 text-teal-400 animate-spin" />
</div>
)}
{/* Empty State */}
{!loading && documents.length === 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center justify-center py-24 text-center"
>
<div className="w-20 h-20 rounded-2xl bg-teal-600/10 border border-teal-500/20 flex items-center justify-center mb-6">
<FileText className="w-10 h-10 text-teal-400" />
</div>
<h2 className="text-xl font-semibold text-white mb-2">
Nenhuma peça criada ainda
</h2>
<p className="text-gray-400 mb-6 max-w-md">
Comece agora! Use nossa IA para gerar petições, contratos, pareceres e muito mais em segundos.
</p>
<button
onClick={() => router.push('/dashboard/gerar')}
className="flex items-center gap-2 px-6 py-3 bg-teal-600 hover:bg-teal-700 rounded-xl font-semibold transition-all glow-purple"
>
<Plus className="w-5 h-5" />
Criar Primeira Peça
</button>
</motion.div>
)}
{/* Document Grid */}
{!loading && documents.length > 0 && viewMode === 'grid' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<AnimatePresence mode="popLayout">
{documents.map((doc, i) => {
const status = STATUS_CONFIG[doc.status] || STATUS_CONFIG.COMPLETED
const StatusIcon = status.icon
return (
<motion.div
key={doc.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ delay: i * 0.05 }}
onClick={() => router.push(`/dashboard/minhas-pecas/${doc.id}`)}
className="glass rounded-2xl p-5 cursor-pointer hover:border-teal-500/30 transition-all group"
>
{/* Status & Date */}
<div className="flex items-center justify-between mb-3">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full border ${status.color}`}>
<StatusIcon className="w-3 h-3" />
{status.label}
</span>
<span className="text-xs text-gray-500">{formatDate(doc.createdAt)}</span>
</div>
{/* Title */}
<h3 className="text-white font-semibold text-lg mb-3 line-clamp-2 group-hover:text-teal-300 transition-colors">
{doc.title}
</h3>
{/* Badges */}
<div className="flex flex-wrap gap-2 mb-4">
<span className="px-2.5 py-1 text-xs font-medium text-teal-300 bg-teal-500/10 border border-teal-500/20 rounded-full">
{DOCUMENT_TYPES[doc.type] || doc.type}
</span>
<span className="px-2.5 py-1 text-xs font-medium text-blue-300 bg-blue-500/10 border border-blue-500/20 rounded-full">
{AREAS[doc.area] || doc.area}
</span>
</div>
{/* Word Count */}
<p className="text-sm text-gray-400 mb-4">
{doc.wordCount.toLocaleString('pt-BR')} palavras
</p>
{/* Actions */}
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => router.push(`/dashboard/minhas-pecas/${doc.id}`)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-gray-300 hover:text-white bg-white/5 hover:bg-white/10 rounded-lg border border-white/10 transition-all"
>
<Eye className="w-3.5 h-3.5" /> Ver
</button>
<button
onClick={() => handleCopy(doc.id)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-gray-300 hover:text-white bg-white/5 hover:bg-white/10 rounded-lg border border-white/10 transition-all"
>
<Copy className="w-3.5 h-3.5" /> {copied === doc.id ? 'Copiado!' : 'Copiar'}
</button>
<button
onClick={() => setDeleteId(doc.id)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-red-400 hover:text-red-300 bg-red-400/5 hover:bg-red-400/10 rounded-lg border border-red-400/10 transition-all"
>
<Trash2 className="w-3.5 h-3.5" /> Excluir
</button>
</div>
</motion.div>
)
})}
</AnimatePresence>
</div>
)}
{/* Document List View */}
{!loading && documents.length > 0 && viewMode === 'list' && (
<div className="glass rounded-2xl overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-white/10">
<th className="text-left px-5 py-3 text-xs font-medium text-gray-400 uppercase tracking-wider">Título</th>
<th className="text-left px-5 py-3 text-xs font-medium text-gray-400 uppercase tracking-wider hidden md:table-cell">Tipo</th>
<th className="text-left px-5 py-3 text-xs font-medium text-gray-400 uppercase tracking-wider hidden md:table-cell">Área</th>
<th className="text-left px-5 py-3 text-xs font-medium text-gray-400 uppercase tracking-wider hidden lg:table-cell">Palavras</th>
<th className="text-left px-5 py-3 text-xs font-medium text-gray-400 uppercase tracking-wider">Status</th>
<th className="text-left px-5 py-3 text-xs font-medium text-gray-400 uppercase tracking-wider hidden md:table-cell">Data</th>
<th className="px-5 py-3"></th>
</tr>
</thead>
<tbody>
<AnimatePresence mode="popLayout">
{documents.map((doc) => {
const status = STATUS_CONFIG[doc.status] || STATUS_CONFIG.COMPLETED
const StatusIcon = status.icon
return (
<motion.tr
key={doc.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => router.push(`/dashboard/minhas-pecas/${doc.id}`)}
className="border-b border-white/5 hover:bg-white/[0.02] cursor-pointer transition-colors"
>
<td className="px-5 py-4">
<span className="text-white font-medium hover:text-teal-300 transition-colors">{doc.title}</span>
</td>
<td className="px-5 py-4 hidden md:table-cell">
<span className="px-2.5 py-1 text-xs font-medium text-teal-300 bg-teal-500/10 border border-teal-500/20 rounded-full">
{DOCUMENT_TYPES[doc.type] || doc.type}
</span>
</td>
<td className="px-5 py-4 hidden md:table-cell">
<span className="px-2.5 py-1 text-xs font-medium text-blue-300 bg-blue-500/10 border border-blue-500/20 rounded-full">
{AREAS[doc.area] || doc.area}
</span>
</td>
<td className="px-5 py-4 text-sm text-gray-400 hidden lg:table-cell">
{doc.wordCount.toLocaleString('pt-BR')}
</td>
<td className="px-5 py-4">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full border ${status.color}`}>
<StatusIcon className="w-3 h-3" />
{status.label}
</span>
</td>
<td className="px-5 py-4 text-sm text-gray-400 hidden md:table-cell">{formatDate(doc.createdAt)}</td>
<td className="px-5 py-4" onClick={(e) => e.stopPropagation()}>
<div className="flex gap-1.5 justify-end">
<button onClick={() => handleCopy(doc.id)} className="p-2 text-gray-400 hover:text-white rounded-lg hover:bg-white/5 transition-all" title="Copiar">
<Copy className="w-4 h-4" />
</button>
<button onClick={() => setDeleteId(doc.id)} className="p-2 text-gray-400 hover:text-red-400 rounded-lg hover:bg-red-400/5 transition-all" title="Excluir">
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</motion.tr>
)
})}
</AnimatePresence>
</tbody>
</table>
</div>
)}
</>
)}
</div>
{/* Delete Confirmation Modal */}
<AnimatePresence>
{deleteId && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
onClick={() => !deleting && setDeleteId(null)}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
onClick={(e) => e.stopPropagation()}
className="glass-strong rounded-2xl p-6 max-w-sm w-full"
>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-red-400/10 rounded-xl">
<Trash2 className="w-6 h-6 text-red-400" />
</div>
<h3 className="text-lg font-semibold text-white">Excluir documento?</h3>
</div>
<p className="text-gray-400 text-sm mb-6">
Esta ação não pode ser desfeita. O documento será removido permanentemente.
</p>
<div className="flex gap-3">
<button
onClick={() => setDeleteId(null)}
disabled={deleting}
className="flex-1 py-2.5 text-sm font-medium text-gray-300 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl transition-all"
>
Cancelar
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="flex-1 py-2.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-xl transition-all flex items-center justify-center gap-2"
>
{deleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
{deleting ? 'Excluindo...' : 'Excluir'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,302 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter, useParams } from 'next/navigation'
import Link from 'next/link'
import {
ArrowLeft,
Sparkles,
Globe,
User,
Calendar,
Code2,
FileText,
Copy,
Check,
Loader2,
} from 'lucide-react'
// ── Constants ──
const TYPE_OPTIONS = [
{ value: 'PETICAO_INICIAL', label: 'Petição Inicial' },
{ value: 'CONTESTACAO', label: 'Contestação' },
{ value: 'APELACAO', label: 'Apelação' },
{ value: 'RECURSO', label: 'Recurso' },
{ value: 'CONTRATO', label: 'Contrato' },
{ value: 'PARECER', label: 'Parecer' },
{ value: 'IMPUGNACAO', label: 'Impugnação' },
{ value: 'HABEAS_CORPUS', label: 'Habeas Corpus' },
{ value: 'MANDADO_SEGURANCA', label: 'Mandado de Segurança' },
{ value: 'OUTROS', label: 'Outros' },
]
const AREA_OPTIONS = [
{ value: 'CIVIL', label: 'Civil' },
{ value: 'TRABALHISTA', label: 'Trabalhista' },
{ value: 'PENAL', label: 'Penal' },
{ value: 'TRIBUTARIO', label: 'Tributário' },
{ value: 'FAMILIA', label: 'Família' },
{ value: 'EMPRESARIAL', label: 'Empresarial' },
{ value: 'CONSUMIDOR', label: 'Consumidor' },
{ value: 'ADMINISTRATIVO', label: 'Administrativo' },
]
const TYPE_COLORS: Record<string, string> = {
PETICAO_INICIAL: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
CONTESTACAO: 'bg-red-500/15 text-red-400 border-red-500/30',
APELACAO: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
RECURSO: 'bg-orange-500/15 text-orange-400 border-orange-500/30',
CONTRATO: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30',
PARECER: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30',
IMPUGNACAO: 'bg-rose-500/15 text-rose-400 border-rose-500/30',
HABEAS_CORPUS: 'bg-teal-500/15 text-teal-400 border-teal-500/30',
MANDADO_SEGURANCA: 'bg-indigo-500/15 text-indigo-400 border-indigo-500/30',
OUTROS: 'bg-zinc-500/15 text-zinc-400 border-zinc-500/30',
}
const AREA_COLORS: Record<string, string> = {
CIVIL: 'bg-sky-500/15 text-sky-400 border-sky-500/30',
TRABALHISTA: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30',
PENAL: 'bg-red-500/15 text-red-400 border-red-500/30',
TRIBUTARIO: 'bg-green-500/15 text-green-400 border-green-500/30',
FAMILIA: 'bg-pink-500/15 text-pink-400 border-pink-500/30',
EMPRESARIAL: 'bg-teal-500/15 text-teal-400 border-teal-500/30',
CONSUMIDOR: 'bg-orange-500/15 text-orange-400 border-orange-500/30',
ADMINISTRATIVO: 'bg-teal-500/15 text-teal-400 border-teal-500/30',
}
function getTypeLabel(value: string) {
return TYPE_OPTIONS.find((t) => t.value === value)?.label ?? value
}
function getAreaLabel(value: string) {
return AREA_OPTIONS.find((a) => a.value === value)?.label ?? value
}
// ── Types ──
interface Template {
id: string
name: string
description: string
type: string
area: string
prompt: string
isPublic: boolean
userId: string | null
createdAt: string
}
// ── Page ──
export default function TemplateDetailPage() {
const router = useRouter()
const params = useParams()
const id = params.id as string
const [template, setTemplate] = useState<Template | null>(null)
const [loading, setLoading] = useState(true)
const [notFound, setNotFound] = useState(false)
const [copied, setCopied] = useState(false)
useEffect(() => {
async function fetchTemplate() {
try {
const res = await fetch('/api/templates')
if (res.ok) {
const data = await res.json()
const found = data.templates.find((t: Template) => t.id === id)
if (found) {
setTemplate(found)
} else {
setNotFound(true)
}
} else {
setNotFound(true)
}
} catch {
setNotFound(true)
} finally {
setLoading(false)
}
}
fetchTemplate()
}, [id])
const handleUse = () => {
if (!template) return
const qp = new URLSearchParams({
templateId: template.id,
type: template.type,
area: template.area,
templateName: template.name,
})
router.push(`/dashboard/nova-peca?${qp.toString()}`)
}
const handleCopyPrompt = async () => {
if (!template) return
await navigator.clipboard.writeText(template.prompt)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
if (loading) {
return (
<div className="flex items-center justify-center py-32">
<Loader2 className="h-8 w-8 animate-spin text-teal-500" />
</div>
)
}
if (notFound || !template) {
return (
<div className="flex flex-col items-center justify-center py-32">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-red-600/10">
<FileText className="h-8 w-8 text-red-500/60" />
</div>
<h2 className="mt-4 text-lg font-semibold text-white">Modelo não encontrado</h2>
<p className="mt-1 text-sm text-zinc-500">Este modelo não existe ou você não tem acesso.</p>
<Link
href="/dashboard/modelos"
className="mt-4 flex items-center gap-2 rounded-xl bg-white/5 px-5 py-2.5 text-sm font-medium text-zinc-300 transition-colors hover:bg-white/10"
>
<ArrowLeft className="h-4 w-4" />
Voltar aos Modelos
</Link>
</div>
)
}
const variables = template.prompt.match(/\{\{(\w+)\}\}/g)?.map((v) => v.slice(2, -2)) ?? []
const createdDate = new Date(template.createdAt).toLocaleDateString('pt-BR', {
day: '2-digit',
month: 'long',
year: 'numeric',
})
// Highlight {{variables}} in the prompt
const highlightedPrompt = template.prompt.split(/(\{\{\w+\}\})/).map((part, i) => {
if (/^\{\{\w+\}\}$/.test(part)) {
return (
<span key={i} className="rounded bg-teal-600/20 px-1 text-teal-400">
{part}
</span>
)
}
return part
})
return (
<div className="space-y-6">
{/* Back */}
<Link
href="/dashboard/modelos"
className="inline-flex items-center gap-2 text-sm text-zinc-400 transition-colors hover:text-white"
>
<ArrowLeft className="h-4 w-4" />
Voltar aos Modelos
</Link>
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="mb-3 flex flex-wrap gap-2">
<span
className={`rounded-lg border px-2.5 py-1 text-xs font-bold uppercase tracking-wide ${
TYPE_COLORS[template.type] ?? TYPE_COLORS.OUTROS
}`}
>
{getTypeLabel(template.type)}
</span>
<span
className={`rounded-lg border px-2.5 py-1 text-xs font-bold uppercase tracking-wide ${
AREA_COLORS[template.area] ?? AREA_COLORS.CIVIL
}`}
>
{getAreaLabel(template.area)}
</span>
{template.isPublic ? (
<span className="flex items-center gap-1 rounded-lg border border-teal-500/30 bg-teal-500/15 px-2.5 py-1 text-xs font-bold text-teal-400">
<Globe className="h-3 w-3" />
Sistema
</span>
) : (
<span className="flex items-center gap-1 rounded-lg border border-emerald-500/30 bg-emerald-500/15 px-2.5 py-1 text-xs font-bold text-emerald-400">
<User className="h-3 w-3" />
Meu Modelo
</span>
)}
</div>
<h1 className="text-2xl font-bold text-white">{template.name}</h1>
<p className="mt-2 text-sm leading-relaxed text-zinc-400">{template.description}</p>
<div className="mt-3 flex items-center gap-1.5 text-xs text-zinc-500">
<Calendar className="h-3.5 w-3.5" />
Criado em {createdDate}
</div>
</div>
<button
onClick={handleUse}
className="flex items-center gap-2 rounded-xl bg-teal-600 px-6 py-3 text-sm font-semibold text-white shadow-lg shadow-teal-600/20 transition-all hover:bg-teal-500 hover:shadow-teal-500/30"
>
<Sparkles className="h-4 w-4" />
Usar este modelo
</button>
</div>
{/* Variables */}
{variables.length > 0 && (
<div className="rounded-2xl border border-white/5 bg-white/[0.02] p-5">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-zinc-300">
<Code2 className="h-4 w-4 text-teal-400" />
Variáveis do Template ({variables.length})
</h3>
<div className="flex flex-wrap gap-2">
{variables.map((v, i) => (
<span
key={i}
className="rounded-lg border border-teal-500/20 bg-teal-600/10 px-3 py-1.5 font-mono text-sm text-teal-300"
>
{`{{${v}}}`}
</span>
))}
</div>
<p className="mt-3 text-xs text-zinc-500">
Estas variáveis serão preenchidas automaticamente ao usar o modelo para gerar uma peça.
</p>
</div>
)}
{/* Prompt Preview */}
<div className="rounded-2xl border border-white/5 bg-white/[0.02] p-5">
<div className="mb-3 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
<FileText className="h-4 w-4 text-teal-400" />
Prompt do Template
</h3>
<button
onClick={handleCopyPrompt}
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-xs text-zinc-400 transition-colors hover:border-white/20 hover:text-white"
>
{copied ? (
<>
<Check className="h-3.5 w-3.5 text-emerald-400" />
<span className="text-emerald-400">Copiado!</span>
</>
) : (
<>
<Copy className="h-3.5 w-3.5" />
Copiar
</>
)}
</button>
</div>
<div className="rounded-xl bg-black/30 p-4 font-mono text-sm leading-relaxed text-zinc-300">
<pre className="whitespace-pre-wrap break-words">{highlightedPrompt}</pre>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,662 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import {
BookTemplate,
Plus,
Search,
Filter,
X,
Sparkles,
Globe,
User,
ArrowRight,
Loader2,
FileText,
Code2,
} from 'lucide-react'
// ── Constants ──
const TYPE_OPTIONS = [
{ value: 'PETICAO_INICIAL', label: 'Petição Inicial' },
{ value: 'CONTESTACAO', label: 'Contestação' },
{ value: 'APELACAO', label: 'Apelação' },
{ value: 'RECURSO', label: 'Recurso' },
{ value: 'CONTRATO', label: 'Contrato' },
{ value: 'PARECER', label: 'Parecer' },
{ value: 'IMPUGNACAO', label: 'Impugnação' },
{ value: 'HABEAS_CORPUS', label: 'Habeas Corpus' },
{ value: 'MANDADO_SEGURANCA', label: 'Mandado de Segurança' },
{ value: 'OUTROS', label: 'Outros' },
]
const AREA_OPTIONS = [
{ value: 'CIVIL', label: 'Civil' },
{ value: 'TRABALHISTA', label: 'Trabalhista' },
{ value: 'PENAL', label: 'Penal' },
{ value: 'TRIBUTARIO', label: 'Tributário' },
{ value: 'FAMILIA', label: 'Família' },
{ value: 'EMPRESARIAL', label: 'Empresarial' },
{ value: 'CONSUMIDOR', label: 'Consumidor' },
{ value: 'ADMINISTRATIVO', label: 'Administrativo' },
]
const TYPE_COLORS: Record<string, string> = {
PETICAO_INICIAL: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
CONTESTACAO: 'bg-red-500/15 text-red-400 border-red-500/30',
APELACAO: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
RECURSO: 'bg-orange-500/15 text-orange-400 border-orange-500/30',
CONTRATO: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30',
PARECER: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30',
IMPUGNACAO: 'bg-rose-500/15 text-rose-400 border-rose-500/30',
HABEAS_CORPUS: 'bg-teal-500/15 text-teal-400 border-teal-500/30',
MANDADO_SEGURANCA: 'bg-indigo-500/15 text-indigo-400 border-indigo-500/30',
OUTROS: 'bg-zinc-500/15 text-zinc-400 border-zinc-500/30',
}
const AREA_COLORS: Record<string, string> = {
CIVIL: 'bg-sky-500/15 text-sky-400 border-sky-500/30',
TRABALHISTA: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30',
PENAL: 'bg-red-500/15 text-red-400 border-red-500/30',
TRIBUTARIO: 'bg-green-500/15 text-green-400 border-green-500/30',
FAMILIA: 'bg-pink-500/15 text-pink-400 border-pink-500/30',
EMPRESARIAL: 'bg-teal-500/15 text-teal-400 border-teal-500/30',
CONSUMIDOR: 'bg-orange-500/15 text-orange-400 border-orange-500/30',
ADMINISTRATIVO: 'bg-teal-500/15 text-teal-400 border-teal-500/30',
}
function getTypeLabel(value: string) {
return TYPE_OPTIONS.find((t) => t.value === value)?.label ?? value
}
function getAreaLabel(value: string) {
return AREA_OPTIONS.find((a) => a.value === value)?.label ?? value
}
// ── Types ──
interface Template {
id: string
name: string
description: string
type: string
area: string
prompt: string
isPublic: boolean
userId: string | null
createdAt: string
}
// ── Main Page ──
export default function ModelosPage() {
const router = useRouter()
const [templates, setTemplates] = useState<Template[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [filterType, setFilterType] = useState('')
const [filterArea, setFilterArea] = useState('')
const [showFilters, setShowFilters] = useState(false)
const [showCreateForm, setShowCreateForm] = useState(false)
const [creating, setCreating] = useState(false)
// Form state
const [formName, setFormName] = useState('')
const [formDescription, setFormDescription] = useState('')
const [formType, setFormType] = useState('')
const [formArea, setFormArea] = useState('')
const [formPrompt, setFormPrompt] = useState('')
const [formError, setFormError] = useState('')
const fetchTemplates = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams()
if (search) params.set('search', search)
if (filterType) params.set('type', filterType)
if (filterArea) params.set('area', filterArea)
const res = await fetch(`/api/templates?${params.toString()}`)
if (res.ok) {
const data = await res.json()
setTemplates(data.templates)
}
} catch {
// silent
} finally {
setLoading(false)
}
}, [search, filterType, filterArea])
useEffect(() => {
const timeout = setTimeout(fetchTemplates, 300)
return () => clearTimeout(timeout)
}, [fetchTemplates])
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
setFormError('')
if (!formName || !formDescription || !formType || !formArea || !formPrompt) {
setFormError('Preencha todos os campos.')
return
}
setCreating(true)
try {
const res = await fetch('/api/templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formName,
description: formDescription,
type: formType,
area: formArea,
prompt: formPrompt,
}),
})
if (!res.ok) {
const data = await res.json()
setFormError(data.error || 'Erro ao criar modelo.')
return
}
// Reset and refresh
setFormName('')
setFormDescription('')
setFormType('')
setFormArea('')
setFormPrompt('')
setShowCreateForm(false)
fetchTemplates()
} catch {
setFormError('Erro de conexão.')
} finally {
setCreating(false)
}
}
const handleUseTemplate = (template: Template) => {
const params = new URLSearchParams({
templateId: template.id,
type: template.type,
area: template.area,
templateName: template.name,
})
router.push(`/dashboard/nova-peca?${params.toString()}`)
}
const activeFilters = (filterType ? 1 : 0) + (filterArea ? 1 : 0)
const publicTemplates = templates.filter((t) => t.isPublic)
const userTemplates = templates.filter((t) => !t.isPublic)
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="flex items-center gap-3 text-2xl font-bold text-white">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-teal-600/20">
<BookTemplate className="h-5 w-5 text-teal-400" />
</div>
Modelos
</h1>
<p className="mt-1 text-sm text-zinc-400">
Use modelos prontos ou crie os seus para agilizar a geração de peças.
</p>
</div>
<button
onClick={() => setShowCreateForm(true)}
className="flex items-center gap-2 rounded-xl bg-teal-600 px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-teal-600/20 transition-all hover:bg-teal-500 hover:shadow-teal-500/30"
>
<Plus className="h-4 w-4" />
Criar Modelo
</button>
</div>
{/* Search & Filters */}
<div className="flex flex-col gap-3 sm:flex-row">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-500" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar modelos..."
className="w-full rounded-xl border border-white/10 bg-white/5 py-2.5 pl-10 pr-4 text-sm text-white placeholder-zinc-500 outline-none transition-colors focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/25"
/>
</div>
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-medium transition-colors ${
showFilters || activeFilters > 0
? 'border-teal-500/50 bg-teal-600/10 text-teal-400'
: 'border-white/10 bg-white/5 text-zinc-400 hover:border-white/20 hover:text-zinc-300'
}`}
>
<Filter className="h-4 w-4" />
Filtros
{activeFilters > 0 && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-teal-600 text-[10px] font-bold text-white">
{activeFilters}
</span>
)}
</button>
</div>
{/* Filter Dropdowns */}
{showFilters && (
<div className="flex flex-wrap gap-3 rounded-xl border border-white/10 bg-white/[0.02] p-4">
<div className="min-w-[180px] flex-1">
<label className="mb-1.5 block text-xs font-medium text-zinc-400">Tipo</label>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-[#0f1620] px-3 py-2 text-sm text-white outline-none focus:border-teal-500/50"
>
<option value="">Todos os tipos</option>
{TYPE_OPTIONS.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div className="min-w-[180px] flex-1">
<label className="mb-1.5 block text-xs font-medium text-zinc-400">Área</label>
<select
value={filterArea}
onChange={(e) => setFilterArea(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-[#0f1620] px-3 py-2 text-sm text-white outline-none focus:border-teal-500/50"
>
<option value="">Todas as áreas</option>
{AREA_OPTIONS.map((a) => (
<option key={a.value} value={a.value}>
{a.label}
</option>
))}
</select>
</div>
{activeFilters > 0 && (
<button
onClick={() => {
setFilterType('')
setFilterArea('')
}}
className="flex items-center gap-1 self-end rounded-lg px-3 py-2 text-xs text-zinc-400 hover:text-red-400"
>
<X className="h-3 w-3" />
Limpar filtros
</button>
)}
</div>
)}
{/* Loading */}
{loading && (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-teal-500" />
</div>
)}
{/* Empty State */}
{!loading && templates.length === 0 && (
<div className="flex flex-col items-center justify-center rounded-2xl border border-white/5 bg-white/[0.02] py-20">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-teal-600/10">
<BookTemplate className="h-8 w-8 text-teal-500/60" />
</div>
<h3 className="mt-4 text-lg font-semibold text-white">Nenhum modelo encontrado</h3>
<p className="mt-1 text-sm text-zinc-500">
{search || filterType || filterArea
? 'Tente ajustar seus filtros de busca.'
: 'Crie seu primeiro modelo personalizado.'}
</p>
{!search && !filterType && !filterArea && (
<button
onClick={() => setShowCreateForm(true)}
className="mt-4 flex items-center gap-2 rounded-xl bg-teal-600 px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-teal-500"
>
<Plus className="h-4 w-4" />
Criar Modelo
</button>
)}
</div>
)}
{/* Public Templates */}
{!loading && publicTemplates.length > 0 && (
<section>
<div className="mb-4 flex items-center gap-2">
<Globe className="h-4 w-4 text-teal-400" />
<h2 className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
Modelos do Sistema
</h2>
<span className="rounded-full bg-teal-600/15 px-2 py-0.5 text-[10px] font-bold text-teal-400">
{publicTemplates.length}
</span>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{publicTemplates.map((template) => (
<TemplateCard
key={template.id}
template={template}
onUse={() => handleUseTemplate(template)}
/>
))}
</div>
</section>
)}
{/* User Templates */}
{!loading && userTemplates.length > 0 && (
<section>
<div className="mb-4 flex items-center gap-2">
<User className="h-4 w-4 text-emerald-400" />
<h2 className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
Meus Modelos
</h2>
<span className="rounded-full bg-emerald-600/15 px-2 py-0.5 text-[10px] font-bold text-emerald-400">
{userTemplates.length}
</span>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{userTemplates.map((template) => (
<TemplateCard
key={template.id}
template={template}
onUse={() => handleUseTemplate(template)}
isOwn
/>
))}
</div>
</section>
)}
{/* Create Modal */}
{showCreateForm && (
<CreateTemplateModal
formName={formName}
setFormName={setFormName}
formDescription={formDescription}
setFormDescription={setFormDescription}
formType={formType}
setFormType={setFormType}
formArea={formArea}
setFormArea={setFormArea}
formPrompt={formPrompt}
setFormPrompt={setFormPrompt}
formError={formError}
creating={creating}
onSubmit={handleCreate}
onClose={() => {
setShowCreateForm(false)
setFormError('')
}}
/>
)}
</div>
)
}
// ── Template Card ──
function TemplateCard({
template,
onUse,
isOwn,
}: {
template: Template
onUse: () => void
isOwn?: boolean
}) {
// Extract {{variables}} from prompt
const variables = template.prompt.match(/\{\{(\w+)\}\}/g)?.map((v) => v.slice(2, -2)) ?? []
return (
<div className="group relative flex flex-col rounded-2xl border border-white/5 bg-white/[0.02] p-5 transition-all duration-200 hover:border-teal-500/30 hover:bg-teal-600/[0.04] hover:shadow-lg hover:shadow-teal-600/5">
{/* Badges */}
<div className="mb-3 flex flex-wrap gap-2">
<span
className={`rounded-lg border px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${
TYPE_COLORS[template.type] ?? TYPE_COLORS.OUTROS
}`}
>
{getTypeLabel(template.type)}
</span>
<span
className={`rounded-lg border px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${
AREA_COLORS[template.area] ?? AREA_COLORS.CIVIL
}`}
>
{getAreaLabel(template.area)}
</span>
{isOwn && (
<span className="rounded-lg border border-emerald-500/30 bg-emerald-500/15 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-400">
Meu
</span>
)}
</div>
{/* Name & Description */}
<h3 className="text-base font-semibold text-white group-hover:text-teal-300 transition-colors">
{template.name}
</h3>
<p className="mt-1.5 flex-1 text-sm leading-relaxed text-zinc-500 line-clamp-2">
{template.description}
</p>
{/* Variables preview */}
{variables.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{variables.slice(0, 4).map((v) => (
<span
key={v}
className="flex items-center gap-1 rounded-md bg-zinc-800/80 px-1.5 py-0.5 text-[10px] font-mono text-zinc-400"
>
<Code2 className="h-2.5 w-2.5" />
{v}
</span>
))}
{variables.length > 4 && (
<span className="rounded-md bg-zinc-800/80 px-1.5 py-0.5 text-[10px] text-zinc-500">
+{variables.length - 4}
</span>
)}
</div>
)}
{/* Actions */}
<div className="mt-4 flex items-center gap-2">
<button
onClick={onUse}
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-teal-600/15 py-2 text-sm font-semibold text-teal-400 transition-all hover:bg-teal-600 hover:text-white hover:shadow-lg hover:shadow-teal-600/20"
>
<Sparkles className="h-3.5 w-3.5" />
Usar modelo
</button>
<Link
href={`/dashboard/modelos/${template.id}`}
className="flex items-center justify-center rounded-xl border border-white/10 p-2 text-zinc-400 transition-colors hover:border-white/20 hover:text-white"
>
<FileText className="h-4 w-4" />
</Link>
</div>
</div>
)
}
// ── Create Modal ──
function CreateTemplateModal({
formName,
setFormName,
formDescription,
setFormDescription,
formType,
setFormType,
formArea,
setFormArea,
formPrompt,
setFormPrompt,
formError,
creating,
onSubmit,
onClose,
}: {
formName: string
setFormName: (v: string) => void
formDescription: string
setFormDescription: (v: string) => void
formType: string
setFormType: (v: string) => void
formArea: string
setFormArea: (v: string) => void
formPrompt: string
setFormPrompt: (v: string) => void
formError: string
creating: boolean
onSubmit: (e: React.FormEvent) => void
onClose: () => void
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div className="relative w-full max-w-2xl rounded-2xl border border-white/10 bg-[#0f1620] p-6 shadow-2xl">
<div className="mb-6 flex items-center justify-between">
<h2 className="flex items-center gap-2 text-xl font-bold text-white">
<Plus className="h-5 w-5 text-teal-400" />
Criar Modelo Personalizado
</h2>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-zinc-400 transition-colors hover:bg-white/5 hover:text-white"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={onSubmit} className="space-y-4">
{/* Name */}
<div>
<label className="mb-1.5 block text-sm font-medium text-zinc-300">Nome do modelo</label>
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="Ex: Petição de Danos Morais"
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-zinc-500 outline-none focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/25"
/>
</div>
{/* Description */}
<div>
<label className="mb-1.5 block text-sm font-medium text-zinc-300">Descrição</label>
<input
type="text"
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
placeholder="Breve descrição do que este modelo gera"
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-zinc-500 outline-none focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/25"
/>
</div>
{/* Type & Area */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-zinc-300">Tipo</label>
<select
value={formType}
onChange={(e) => setFormType(e.target.value)}
className="w-full rounded-xl border border-white/10 bg-[#0a1018] px-4 py-2.5 text-sm text-white outline-none focus:border-teal-500/50"
>
<option value="">Selecione o tipo</option>
{TYPE_OPTIONS.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-zinc-300">Área</label>
<select
value={formArea}
onChange={(e) => setFormArea(e.target.value)}
className="w-full rounded-xl border border-white/10 bg-[#0a1018] px-4 py-2.5 text-sm text-white outline-none focus:border-teal-500/50"
>
<option value="">Selecione a área</option>
{AREA_OPTIONS.map((a) => (
<option key={a.value} value={a.value}>
{a.label}
</option>
))}
</select>
</div>
</div>
{/* Prompt Template */}
<div>
<label className="mb-1.5 block text-sm font-medium text-zinc-300">
Template do Prompt
</label>
<p className="mb-2 text-xs text-zinc-500">
Use <code className="rounded bg-zinc-800 px-1 py-0.5 font-mono text-teal-400">{'{{variavel}}'}</code> para variáveis
dinâmicas. Ex:{' '}
<code className="rounded bg-zinc-800 px-1 py-0.5 font-mono text-teal-400">{'{{nome_autor}}'}</code>,{' '}
<code className="rounded bg-zinc-800 px-1 py-0.5 font-mono text-teal-400">{'{{valor_causa}}'}</code>
</p>
<textarea
value={formPrompt}
onChange={(e) => setFormPrompt(e.target.value)}
placeholder={`Elabore uma petição inicial de indenização por danos morais em face de {{nome_reu}}, em favor de {{nome_autor}}, no valor de {{valor_causa}}.\n\nFatos: {{fatos}}\n\nFundamentos jurídicos: CDC, art. 14 e CC, art. 186.`}
rows={6}
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 font-mono text-sm text-white placeholder-zinc-600 outline-none focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/25"
/>
</div>
{/* Error */}
{formError && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-2.5 text-sm text-red-400">
{formError}
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="rounded-xl border border-white/10 px-5 py-2.5 text-sm font-medium text-zinc-400 transition-colors hover:bg-white/5 hover:text-white"
>
Cancelar
</button>
<button
type="submit"
disabled={creating}
className="flex items-center gap-2 rounded-xl bg-teal-600 px-6 py-2.5 text-sm font-semibold text-white shadow-lg shadow-teal-600/20 transition-all hover:bg-teal-500 disabled:opacity-50"
>
{creating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Criando...
</>
) : (
<>
<Plus className="h-4 w-4" />
Criar Modelo
</>
)}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,882 @@
'use client'
import { useState, useRef, useCallback, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { motion, AnimatePresence, type Variants } from 'framer-motion'
import ReactMarkdown from 'react-markdown'
import FileUpload from '@/components/FileUpload'
import { stripMarkdown, downloadAsWord } from "@/lib/utils"
import {
FileText, Scale, Gavel, Shield, BookOpen, FileCheck,
AlertTriangle, Lock, ScrollText, Landmark, Ban, ArrowUpRight,
Hammer, MoreHorizontal, ChevronRight, ChevronLeft, Check,
Users, ClipboardList, BookMarked, MessageSquare, Upload,
Sparkles, Copy, Download, Edit3, RotateCcw, Loader2,
Home, Briefcase, Heart, Building2, ShoppingCart, Settings,
ArrowLeft, Eye, Zap, CheckCircle2
} from 'lucide-react'
// ─── Types ──────────────────────────────────────────────────────────
interface DocumentType {
id: string
name: string
icon: React.ReactNode
description: string
}
interface LegalArea {
id: string
name: string
icon: React.ReactNode
color: string
}
interface FormDetails {
autor: string
reu: string
fatos: string
fundamentos: string
pedidos: string
contexto: string
}
// ─── Data ───────────────────────────────────────────────────────────
const DOCUMENT_TYPES: DocumentType[] = [
{ id: 'peticao-inicial', name: 'Petição Inicial', icon: <FileText className="w-6 h-6" />, description: 'Peça inaugural do processo judicial' },
{ id: 'contestacao', name: 'Contestação', icon: <Shield className="w-6 h-6" />, description: 'Resposta do réu à ação judicial' },
{ id: 'apelacao-civel', name: 'Apelação Cível', icon: <ArrowUpRight className="w-6 h-6" />, description: 'Recurso contra sentença cível' },
{ id: 'apelacao-criminal', name: 'Apelação Criminal', icon: <Gavel className="w-6 h-6" />, description: 'Recurso contra sentença criminal' },
{ id: 'recurso', name: 'Recurso', icon: <RotateCcw className="w-6 h-6" />, description: 'Recurso genérico (ordinário/inominado)' },
{ id: 'contrato', name: 'Contrato', icon: <FileCheck className="w-6 h-6" />, description: 'Contratos e instrumentos particulares' },
{ id: 'parecer', name: 'Parecer', icon: <BookOpen className="w-6 h-6" />, description: 'Opinião jurídica fundamentada' },
{ id: 'impugnacao', name: 'Impugnação', icon: <AlertTriangle className="w-6 h-6" />, description: 'Impugnação ao cumprimento de sentença' },
{ id: 'habeas-corpus', name: 'Habeas Corpus', icon: <Lock className="w-6 h-6" />, description: 'Proteção da liberdade de locomoção' },
{ id: 'mandado-seguranca', name: 'Mandado de Segurança', icon: <Landmark className="w-6 h-6" />, description: 'Proteção de direito líquido e certo' },
{ id: 'embargo', name: 'Embargo', icon: <Ban className="w-6 h-6" />, description: 'Embargos de declaração/execução' },
{ id: 'recurso-especial', name: 'Recurso Especial', icon: <ScrollText className="w-6 h-6" />, description: 'Recurso ao STJ por violação de lei federal' },
{ id: 'agravo', name: 'Agravo', icon: <Hammer className="w-6 h-6" />, description: 'Agravo de instrumento/interno' },
{ id: 'outros', name: 'Outros', icon: <MoreHorizontal className="w-6 h-6" />, description: 'Outros tipos de peças jurídicas' },
]
const LEGAL_AREAS: LegalArea[] = [
{ id: 'civil', name: 'Civil', icon: <Scale className="w-6 h-6" />, color: '#8b5cf6' },
{ id: 'trabalhista', name: 'Trabalhista', icon: <Briefcase className="w-6 h-6" />, color: '#f59e0b' },
{ id: 'penal', name: 'Penal', icon: <Gavel className="w-6 h-6" />, color: '#ef4444' },
{ id: 'tributario', name: 'Tributário', icon: <Building2 className="w-6 h-6" />, color: '#10b981' },
{ id: 'familia', name: 'Família', icon: <Heart className="w-6 h-6" />, color: '#ec4899' },
{ id: 'empresarial', name: 'Empresarial', icon: <Home className="w-6 h-6" />, color: '#3b82f6' },
{ id: 'consumidor', name: 'Consumidor', icon: <ShoppingCart className="w-6 h-6" />, color: '#f97316' },
{ id: 'administrativo', name: 'Administrativo', icon: <Settings className="w-6 h-6" />, color: '#6366f1' },
]
const STEPS = [
{ label: 'Tipo', icon: <FileText className="w-4 h-4" /> },
{ label: 'Área', icon: <Scale className="w-4 h-4" /> },
{ label: 'Detalhes', icon: <ClipboardList className="w-4 h-4" /> },
{ label: 'Revisar', icon: <Eye className="w-4 h-4" /> },
{ label: 'Resultado', icon: <Sparkles className="w-4 h-4" /> },
]
// ─── Animation Variants ─────────────────────────────────────────────
const pageVariants = {
initial: { opacity: 0, x: 60 },
animate: { opacity: 1, x: 0, transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] } },
exit: { opacity: 0, x: -60, transition: { duration: 0.3 } },
} as const
const cardVariants: Variants = {
initial: { opacity: 0, y: 20, scale: 0.95 },
animate: (i: number) => ({
opacity: 1, y: 0, scale: 1,
transition: { delay: i * 0.04, duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] },
}),
hover: { scale: 1.03, transition: { duration: 0.2 } },
tap: { scale: 0.97 },
}
const staggerContainer = {
animate: { transition: { staggerChildren: 0.04 } },
} as const
// ─── Component ──────────────────────────────────────────────────────
export default function NovaPecaPage() {
const [step, setStep] = useState(0)
const [selectedType, setSelectedType] = useState<string | null>(null)
const [selectedArea, setSelectedArea] = useState<string | null>(null)
const [details, setDetails] = useState<FormDetails>({
autor: '', reu: '', fatos: '', fundamentos: '', pedidos: '', contexto: '',
})
const [generatedContent, setGeneratedContent] = useState('')
const [isGenerating, setIsGenerating] = useState(false)
const [wordCount, setWordCount] = useState(0)
const [copied, setCopied] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [documentId, setDocumentId] = useState<string | null>(null)
const contentRef = useRef<HTMLDivElement>(null)
const editRef = useRef<HTMLTextAreaElement>(null)
const searchParams = useSearchParams()
const [analysisSource, setAnalysisSource] = useState<{ filename: string; title: string } | null>(null)
const [loadingAnalysis, setLoadingAnalysis] = useState(false)
// Load pre-filled data from analysis
useEffect(() => {
const analysisId = searchParams.get('from_analysis')
if (!analysisId) return
setLoadingAnalysis(true)
fetch(`/api/analise-processo/${analysisId}/peticao`)
.then(res => {
if (!res.ok) throw new Error('Failed to load analysis')
return res.json()
})
.then(data => {
setSelectedType('peticao-inicial')
setSelectedArea(data.extracted.area || 'civil')
setDetails({
autor: data.extracted.autor || '',
reu: data.extracted.reu || '',
fatos: data.extracted.fatos || '',
fundamentos: data.extracted.fundamentos || '',
pedidos: data.extracted.pedidos || '',
contexto: `[PARECER JURÍDICO COMPLETO - usar como base para a petição]\n\n${data.parecer}`,
})
setAnalysisSource({ filename: data.filename, title: data.title })
setStep(2) // Go directly to details step
})
.catch(err => {
console.error('Error loading analysis:', err)
})
.finally(() => setLoadingAnalysis(false))
}, [searchParams])
// Word count tracker
useEffect(() => {
if (generatedContent) {
setWordCount(generatedContent.split(/\s+/).filter(Boolean).length)
}
}, [generatedContent])
const selectedTypeData = DOCUMENT_TYPES.find(t => t.id === selectedType)
const selectedAreaData = LEGAL_AREAS.find(a => a.id === selectedArea)
const handleGenerate = useCallback(async () => {
setIsGenerating(true)
setGeneratedContent('')
setStep(4)
try {
const res = await fetch('/api/documents/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: selectedType,
area: selectedArea,
details,
}),
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.error || 'Erro ao gerar documento')
}
const reader = res.body?.getReader()
const decoder = new TextDecoder()
if (!reader) throw new Error('Stream não disponível')
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
try {
const data = JSON.parse(line.slice(6))
if (data.done) {
setDocumentId(data.documentId)
break
}
if (data.content) {
setGeneratedContent(prev => prev + data.content)
}
if (data.documentId) {
setDocumentId(data.documentId)
}
} catch {
// skip
}
}
}
} catch (error) {
console.error('Generation error:', error)
setGeneratedContent('Erro ao gerar documento. Por favor, tente novamente.')
} finally {
setIsGenerating(false)
}
}, [selectedType, selectedArea, details])
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(stripMarkdown(generatedContent))
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}, [generatedContent])
const [isDownloading, setIsDownloading] = useState(false)
const handleDownload = useCallback(async () => {
setIsDownloading(true)
try {
const title = `${selectedTypeData?.name || 'Documento'} - ${selectedAreaData?.name || 'Jurídico'}`
await downloadAsWord(title, generatedContent)
} catch (err) {
console.error('Download error:', err)
} finally {
setIsDownloading(false)
}
}, [generatedContent, selectedTypeData, selectedAreaData])
const canProceed = () => {
switch (step) {
case 0: return !!selectedType
case 1: return !!selectedArea
case 2: return details.fatos.trim().length > 10
case 3: return true
default: return false
}
}
const goNext = () => {
if (step < 4 && canProceed()) setStep(step + 1)
}
const goBack = () => {
if (step > 0 && !isGenerating) setStep(step - 1)
}
// ─── Stepper ────────────────────────────────────────────────────
const renderStepper = () => (
<div className="flex items-center justify-center gap-1 mb-8">
{STEPS.map((s, i) => {
const isActive = i === step
const isCompleted = i < step
return (
<div key={s.label} className="flex items-center">
<button
onClick={() => { if (i < step && !isGenerating) setStep(i) }}
disabled={i > step || isGenerating}
className={`
flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition-all duration-300
${isActive
? 'bg-teal-600/30 text-teal-300 border border-teal-500/50 shadow-lg shadow-teal-500/20'
: isCompleted
? 'bg-teal-900/20 text-teal-400 border border-teal-700/30 cursor-pointer hover:bg-teal-800/20'
: 'bg-white/[0.02] text-white/30 border border-white/[0.05]'
}
`}
>
{isCompleted ? (
<CheckCircle2 className="w-4 h-4 text-green-400" />
) : (
<span className={isActive ? 'text-teal-300' : ''}>{s.icon}</span>
)}
<span className="hidden sm:inline">{s.label}</span>
</button>
{i < STEPS.length - 1 && (
<ChevronRight className={`w-4 h-4 mx-1 ${i < step ? 'text-teal-500' : 'text-white/10'}`} />
)}
</div>
)
})}
</div>
)
// ─── Step 0: Document Type ──────────────────────────────────────
const renderTypeSelection = () => (
<motion.div key="step-type" variants={pageVariants} initial="initial" animate="animate" exit="exit">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold gradient-text mb-2">Que tipo de peça deseja gerar?</h2>
<p className="text-white/50">Selecione o tipo de documento jurídico</p>
</div>
<motion.div
className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3"
variants={staggerContainer}
initial="initial"
animate="animate"
>
{DOCUMENT_TYPES.map((type, i) => (
<motion.button
key={type.id}
custom={i}
variants={cardVariants}
whileHover="hover"
whileTap="tap"
onClick={() => { setSelectedType(type.id); setStep(1) }}
className={`
relative group flex flex-col items-center gap-3 p-5 rounded-2xl text-center transition-all duration-300
${selectedType === type.id
? 'glass-strong glow-purple border-teal-500/50'
: 'glass hover:border-teal-500/30'
}
`}
>
<div className={`
p-3 rounded-xl transition-all duration-300
${selectedType === type.id
? 'bg-teal-500/30 text-teal-300'
: 'bg-white/[0.03] text-white/50 group-hover:text-teal-400 group-hover:bg-teal-500/10'
}
`}>
{type.icon}
</div>
<div>
<p className="font-semibold text-sm text-white/90">{type.name}</p>
<p className="text-[11px] text-white/40 mt-1 leading-tight">{type.description}</p>
</div>
{selectedType === type.id && (
<motion.div
layoutId="type-check"
className="absolute top-2 right-2 w-5 h-5 bg-teal-500 rounded-full flex items-center justify-center"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
>
<Check className="w-3 h-3 text-white" />
</motion.div>
)}
</motion.button>
))}
</motion.div>
</motion.div>
)
// ─── Step 1: Legal Area ─────────────────────────────────────────
const renderAreaSelection = () => (
<motion.div key="step-area" variants={pageVariants} initial="initial" animate="animate" exit="exit">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold gradient-text mb-2">Área do Direito</h2>
<p className="text-white/50">
Gerando: <span className="text-teal-400 font-medium">{selectedTypeData?.name}</span>
</p>
</div>
<motion.div
className="grid grid-cols-2 sm:grid-cols-4 gap-4 max-w-3xl mx-auto"
variants={staggerContainer}
initial="initial"
animate="animate"
>
{LEGAL_AREAS.map((area, i) => (
<motion.button
key={area.id}
custom={i}
variants={cardVariants}
whileHover="hover"
whileTap="tap"
onClick={() => { setSelectedArea(area.id); setStep(2) }}
className={`
relative group flex flex-col items-center gap-3 p-6 rounded-2xl transition-all duration-300
${selectedArea === area.id
? 'glass-strong border-teal-500/50'
: 'glass hover:border-teal-500/30'
}
`}
style={{
boxShadow: selectedArea === area.id ? `0 0 30px ${area.color}30` : undefined,
}}
>
<div
className="p-3 rounded-xl transition-all duration-300"
style={{
background: selectedArea === area.id ? `${area.color}25` : 'rgba(255,255,255,0.03)',
color: selectedArea === area.id ? area.color : 'rgba(255,255,255,0.5)',
}}
>
{area.icon}
</div>
<p className="font-semibold text-white/90">{area.name}</p>
{selectedArea === area.id && (
<motion.div
layoutId="area-check"
className="absolute top-2 right-2 w-5 h-5 rounded-full flex items-center justify-center"
style={{ background: area.color }}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
>
<Check className="w-3 h-3 text-white" />
</motion.div>
)}
</motion.button>
))}
</motion.div>
</motion.div>
)
// ─── Step 2: Details Form ───────────────────────────────────────
const renderDetailsForm = () => (
<motion.div key="step-details" variants={pageVariants} initial="initial" animate="animate" exit="exit">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold gradient-text mb-2">Detalhes da Peça</h2>
<p className="text-white/50">
<span className="text-teal-400">{selectedTypeData?.name}</span> · <span className="text-teal-400">{selectedAreaData?.name}</span>
</p>
</div>
<div className="max-w-3xl mx-auto space-y-5">
{/* Parties */}
<motion.div
className="grid grid-cols-1 sm:grid-cols-2 gap-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<div>
<label className="flex items-center gap-2 text-sm font-medium text-white/70 mb-2">
<Users className="w-4 h-4 text-teal-400" />
Autor / Requerente
</label>
<input
type="text"
value={details.autor}
onChange={e => setDetails(d => ({ ...d, autor: e.target.value }))}
placeholder="Nome completo do autor"
className="w-full px-4 py-3 rounded-xl glass text-white placeholder-white/30 focus:outline-none focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/30 transition-all"
/>
</div>
<div>
<label className="flex items-center gap-2 text-sm font-medium text-white/70 mb-2">
<Users className="w-4 h-4 text-red-400" />
Réu / Requerido
</label>
<input
type="text"
value={details.reu}
onChange={e => setDetails(d => ({ ...d, reu: e.target.value }))}
placeholder="Nome completo do réu"
className="w-full px-4 py-3 rounded-xl glass text-white placeholder-white/30 focus:outline-none focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/30 transition-all"
/>
</div>
</motion.div>
{/* Facts */}
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}>
<label className="flex items-center gap-2 text-sm font-medium text-white/70 mb-2">
<BookMarked className="w-4 h-4 text-teal-400" />
Fatos <span className="text-red-400">*</span>
</label>
<textarea
value={details.fatos}
onChange={e => setDetails(d => ({ ...d, fatos: e.target.value }))}
placeholder="Descreva os fatos de forma clara e cronológica. Quanto mais detalhado, melhor será a peça gerada..."
rows={5}
className="w-full px-4 py-3 rounded-xl glass text-white placeholder-white/30 focus:outline-none focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/30 transition-all resize-none"
/>
<p className="text-xs text-white/30 mt-1">{details.fatos.length} caracteres · mínimo 10</p>
</motion.div>
{/* Legal Grounds */}
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }}>
<label className="flex items-center gap-2 text-sm font-medium text-white/70 mb-2">
<BookOpen className="w-4 h-4 text-teal-400" />
Fundamentos Jurídicos
</label>
<textarea
value={details.fundamentos}
onChange={e => setDetails(d => ({ ...d, fundamentos: e.target.value }))}
placeholder="Artigos de lei, jurisprudência ou teses jurídicas que fundamentam o pedido (opcional - a IA também citará automaticamente)"
rows={3}
className="w-full px-4 py-3 rounded-xl glass text-white placeholder-white/30 focus:outline-none focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/30 transition-all resize-none"
/>
</motion.div>
{/* Requests */}
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.4 }}>
<label className="flex items-center gap-2 text-sm font-medium text-white/70 mb-2">
<MessageSquare className="w-4 h-4 text-teal-400" />
Pedidos
</label>
<textarea
value={details.pedidos}
onChange={e => setDetails(d => ({ ...d, pedidos: e.target.value }))}
placeholder="Descreva os pedidos que deseja formular (tutela antecipada, indenização, etc.)"
rows={3}
className="w-full px-4 py-3 rounded-xl glass text-white placeholder-white/30 focus:outline-none focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/30 transition-all resize-none"
/>
</motion.div>
{/* Additional Context */}
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.5 }}>
<label className="flex items-center gap-2 text-sm font-medium text-white/70 mb-2">
<ClipboardList className="w-4 h-4 text-teal-400" />
Contexto Adicional
</label>
<textarea
value={details.contexto}
onChange={e => setDetails(d => ({ ...d, contexto: e.target.value }))}
placeholder="Qualquer informação adicional relevante (valor da causa, urgência, detalhes processuais...)"
rows={2}
className="w-full px-4 py-3 rounded-xl glass text-white placeholder-white/30 focus:outline-none focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/30 transition-all resize-none"
/>
</motion.div>
{/* File Upload */}
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.6 }}>
<label className="flex items-center gap-2 text-sm font-medium text-white/70 mb-2">
<Upload className="w-4 h-4 text-teal-400" />
Documentos de Referência
</label>
<FileUpload compact label="Documentos de Referência" maxFiles={5} />
</motion.div>
</div>
</motion.div>
)
// ─── Step 3: Review ─────────────────────────────────────────────
const renderReview = () => {
const summaryItems = [
{ label: 'Tipo de Peça', value: selectedTypeData?.name || '-', icon: <FileText className="w-4 h-4" /> },
{ label: 'Área do Direito', value: selectedAreaData?.name || '-', icon: <Scale className="w-4 h-4" /> },
{ label: 'Autor', value: details.autor || 'Não informado', icon: <Users className="w-4 h-4" /> },
{ label: 'Réu', value: details.reu || 'Não informado', icon: <Users className="w-4 h-4" /> },
]
return (
<motion.div key="step-review" variants={pageVariants} initial="initial" animate="animate" exit="exit">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold gradient-text mb-2">Revisar e Gerar</h2>
<p className="text-white/50">Confira os dados antes de gerar a peça</p>
</div>
<div className="max-w-3xl mx-auto space-y-6">
{/* Summary Cards */}
<motion.div
className="grid grid-cols-2 gap-3"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.1 }}
>
{summaryItems.map((item, i) => (
<div key={i} className="glass rounded-xl p-4">
<div className="flex items-center gap-2 text-white/40 text-xs mb-1">
<span className="text-teal-400">{item.icon}</span>
{item.label}
</div>
<p className="text-white font-medium text-sm">{item.value}</p>
</div>
))}
</motion.div>
{/* Content Preview */}
{details.fatos && (
<motion.div
className="glass rounded-xl p-5"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<h4 className="text-sm font-medium text-teal-400 mb-2">Fatos</h4>
<p className="text-white/70 text-sm whitespace-pre-wrap">{details.fatos}</p>
</motion.div>
)}
{details.fundamentos && (
<motion.div
className="glass rounded-xl p-5"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25 }}
>
<h4 className="text-sm font-medium text-teal-400 mb-2">Fundamentos Jurídicos</h4>
<p className="text-white/70 text-sm whitespace-pre-wrap">{details.fundamentos}</p>
</motion.div>
)}
{details.pedidos && (
<motion.div
className="glass rounded-xl p-5"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<h4 className="text-sm font-medium text-teal-400 mb-2">Pedidos</h4>
<p className="text-white/70 text-sm whitespace-pre-wrap">{details.pedidos}</p>
</motion.div>
)}
{/* Credit Notice */}
<motion.div
className="flex items-center gap-3 p-4 rounded-xl bg-teal-500/10 border border-teal-500/20"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
>
<Zap className="w-5 h-5 text-yellow-400 shrink-0" />
<div>
<p className="text-sm text-white/80">Esta geração consumirá <span className="text-teal-300 font-bold">1 crédito</span></p>
<p className="text-xs text-white/40 mt-0.5">O documento será gerado por IA e pode ser editado depois</p>
</div>
</motion.div>
{/* Generate Button */}
<motion.button
onClick={handleGenerate}
className="w-full py-4 rounded-2xl bg-gradient-to-r from-teal-600 to-teal-700 hover:from-teal-500 hover:to-teal-600 text-white font-bold text-lg flex items-center justify-center gap-3 transition-all duration-300 glow-purple-strong"
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.98 }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
>
<Sparkles className="w-5 h-5" />
Gerar Peça com IA
</motion.button>
</div>
</motion.div>
)
}
// ─── Step 4: Result ─────────────────────────────────────────────
const renderResult = () => (
<motion.div key="step-result" variants={pageVariants} initial="initial" animate="animate" exit="exit">
<div className="max-w-4xl mx-auto">
{/* Header Bar */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
<div>
<h2 className="text-2xl font-bold gradient-text">
{isGenerating ? 'Gerando documento...' : 'Documento Gerado'}
</h2>
<div className="flex items-center gap-4 mt-1 text-sm text-white/50">
<span>{selectedTypeData?.name}</span>
<span>·</span>
<span>{selectedAreaData?.name}</span>
{!isGenerating && (
<>
<span>·</span>
<span>{wordCount.toLocaleString('pt-BR')} palavras</span>
</>
)}
</div>
</div>
{!isGenerating && generatedContent && (
<motion.div
className="flex items-center gap-2"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<button
onClick={handleCopy}
className="flex items-center gap-2 px-4 py-2 rounded-xl glass hover:bg-teal-500/10 text-sm transition-all"
>
{copied ? <CheckCircle2 className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />}
{copied ? 'Copiado!' : 'Copiar'}
</button>
<button
onClick={handleDownload}
className="flex items-center gap-2 px-4 py-2 rounded-xl glass hover:bg-teal-500/10 text-sm transition-all"
>
<Download className="w-4 h-4" />
{isDownloading ? 'Gerando...' : 'Baixar Word'}
</button>
<button
onClick={() => {
setIsEditing(!isEditing)
if (!isEditing) setTimeout(() => editRef.current?.focus(), 100)
}}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm transition-all ${
isEditing ? 'bg-teal-500/20 text-teal-300 border border-teal-500/30' : 'glass hover:bg-teal-500/10'
}`}
>
<Edit3 className="w-4 h-4" />
{isEditing ? 'Visualizar' : 'Editar'}
</button>
</motion.div>
)}
</div>
{/* Content Area */}
<motion.div
className="glass-strong rounded-2xl p-6 sm:p-8 min-h-[400px] relative overflow-hidden"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.1 }}
>
{isGenerating && (
<motion.div
className="absolute top-0 left-0 h-1 bg-gradient-to-r from-teal-500 via-pink-500 to-teal-500 rounded-full"
initial={{ width: '0%' }}
animate={{ width: '100%' }}
transition={{ duration: 30, ease: 'linear' }}
/>
)}
{isEditing ? (
<textarea
ref={editRef}
value={generatedContent}
onChange={e => setGeneratedContent(e.target.value)}
className="w-full h-[600px] bg-transparent text-white/90 text-sm leading-relaxed font-mono resize-none focus:outline-none"
/>
) : (
<div
ref={contentRef}
className="prose prose-invert prose-sm max-w-none prose-headings:text-white prose-headings:font-bold prose-strong:text-white prose-code:text-teal-300 prose-blockquote:border-teal-500/30 prose-blockquote:text-zinc-400 prose-a:text-teal-400 prose-li:text-gray-300 prose-li:marker:text-teal-500 selection:bg-teal-500/30"
>
{generatedContent ? (
<ReactMarkdown>{generatedContent}</ReactMarkdown>
) : (
<div className="flex flex-col items-center justify-center h-[300px] gap-4">
<Loader2 className="w-8 h-8 text-teal-400 animate-spin" />
<p className="text-white/40">Gerando sua peça jurídica...</p>
<p className="text-xs text-white/20">Isso pode levar até 1 minuto</p>
</div>
)}
{isGenerating && generatedContent && (
<motion.span
className="inline-block w-2 h-5 bg-teal-400 ml-0.5"
animate={{ opacity: [1, 0] }}
transition={{ duration: 0.8, repeat: Infinity }}
/>
)}
</div>
)}
</motion.div>
{/* New Document Button */}
{!isGenerating && generatedContent && (
<motion.div
className="flex justify-center mt-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
>
<button
onClick={() => {
setStep(0)
setSelectedType(null)
setSelectedArea(null)
setDetails({ autor: '', reu: '', fatos: '', fundamentos: '', pedidos: '', contexto: '' })
setGeneratedContent('')
setDocumentId(null)
setIsEditing(false)
}}
className="flex items-center gap-2 px-6 py-3 rounded-xl glass hover:bg-teal-500/10 text-white/70 hover:text-white transition-all"
>
<RotateCcw className="w-4 h-4" />
Gerar Nova Peça
</button>
</motion.div>
)}
</div>
</motion.div>
)
// ─── Main Render ────────────────────────────────────────────────
return (
<div className="min-h-screen bg-[#0a0f1a] bg-grid">
{/* Background Effects */}
<div className="fixed inset-0 bg-radial-purple pointer-events-none" />
<div className="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 py-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<a
href="/dashboard"
className="p-2 rounded-xl glass hover:bg-teal-500/10 transition-all"
>
<ArrowLeft className="w-5 h-5 text-white/60" />
</a>
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<Sparkles className="w-5 h-5 text-teal-400" />
Nova Peça Jurídica
</h1>
<p className="text-xs text-white/40">Gerador de documentos com IA</p>
</div>
</div>
</div>
{/* Analysis Source Banner */}
{analysisSource && (
<div className="mb-4 flex items-center gap-3 rounded-xl border border-teal-500/20 bg-teal-500/10 px-5 py-3">
<FileText className="h-5 w-5 text-teal-400 shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-teal-300">
📋 Petição baseada no parecer de: <span className="text-white">{analysisSource.filename}</span>
</p>
<p className="text-xs text-teal-400/60 mt-0.5">Os campos foram pré-preenchidos com dados extraídos da análise</p>
</div>
</div>
)}
{/* Loading Analysis */}
{loadingAnalysis && (
<div className="mb-4 flex items-center justify-center gap-3 rounded-xl border border-white/5 bg-white/[0.02] px-5 py-6">
<Loader2 className="h-5 w-5 text-teal-400 animate-spin" />
<p className="text-sm text-white/60">Carregando dados do parecer...</p>
</div>
)}
{/* Stepper */}
{renderStepper()}
{/* Step Content */}
<AnimatePresence mode="wait">
{step === 0 && renderTypeSelection()}
{step === 1 && renderAreaSelection()}
{step === 2 && renderDetailsForm()}
{step === 3 && renderReview()}
{step === 4 && renderResult()}
</AnimatePresence>
{/* Navigation Buttons (steps 1-2) */}
{step > 0 && step < 4 && (
<motion.div
className="flex items-center justify-between max-w-3xl mx-auto mt-8"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
>
<button
onClick={goBack}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl glass hover:bg-teal-500/10 text-white/60 hover:text-white transition-all"
>
<ChevronLeft className="w-4 h-4" />
Voltar
</button>
{step < 3 && (
<button
onClick={goNext}
disabled={!canProceed()}
className={`
flex items-center gap-2 px-6 py-2.5 rounded-xl font-medium transition-all
${canProceed()
? 'bg-teal-600 hover:bg-teal-500 text-white glow-purple'
: 'bg-white/5 text-white/20 cursor-not-allowed'
}
`}
>
Próximo
<ChevronRight className="w-4 h-4" />
</button>
)}
</motion.div>
)}
</div>
</div>
)
}

423
src/app/dashboard/page.tsx Normal file
View File

@@ -0,0 +1,423 @@
import { getServerSession } from 'next-auth'
import { redirect } from 'next/navigation'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import Link from 'next/link'
import { Suspense } from 'react'
import { UpgradeSuccessBanner, UpgradePrompt } from './UpgradeBanner'
import {
FileText,
Coins,
LetterText,
Scale,
FilePlus,
ShieldAlert,
MessageSquare,
Search,
TrendingUp,
Clock,
Sparkles,
} from 'lucide-react'
const typeLabels: Record<string, string> = {
PETICAO_INICIAL: 'Petição Inicial',
CONTESTACAO: 'Contestação',
APELACAO: 'Apelação',
RECURSO: 'Recurso',
CONTRATO: 'Contrato',
PARECER: 'Parecer',
IMPUGNACAO: 'Impugnação',
HABEAS_CORPUS: 'Habeas Corpus',
MANDADO_SEGURANCA: 'Mandado de Segurança',
OUTROS: 'Outros',
}
const areaLabels: Record<string, string> = {
CIVIL: 'Civil',
TRABALHISTA: 'Trabalhista',
PENAL: 'Penal',
TRIBUTARIO: 'Tributário',
FAMILIA: 'Família',
EMPRESARIAL: 'Empresarial',
CONSUMIDOR: 'Consumidor',
ADMINISTRATIVO: 'Administrativo',
}
const areaColors: Record<string, string> = {
CIVIL: 'bg-blue-500/15 text-blue-400',
TRABALHISTA: 'bg-orange-500/15 text-orange-400',
PENAL: 'bg-red-500/15 text-red-400',
TRIBUTARIO: 'bg-green-500/15 text-green-400',
FAMILIA: 'bg-pink-500/15 text-pink-400',
EMPRESARIAL: 'bg-cyan-500/15 text-cyan-400',
CONSUMIDOR: 'bg-yellow-500/15 text-yellow-400',
ADMINISTRATIVO: 'bg-teal-500/15 text-teal-400',
}
const statusIndicator: Record<string, string> = {
COMPLETED: 'bg-emerald-500',
GENERATING: 'bg-amber-500 animate-pulse',
ERROR: 'bg-red-500',
}
function formatDate(date: Date) {
return new Intl.DateTimeFormat('pt-BR', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date)
}
function formatNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return n.toLocaleString('pt-BR')
}
export default async function DashboardPage() {
const session = await getServerSession(authOptions)
if (!session?.user) redirect('/login')
const userId = session.user.id
// Fetch all stats in parallel
const [
documentCount,
totalWords,
jurisprudenciaCount,
recentDocuments,
user,
] = await Promise.all([
prisma.document.count({ where: { userId } }),
prisma.document.aggregate({
where: { userId },
_sum: { wordCount: true },
}),
prisma.usageLog.count({
where: { userId, type: 'JURISPRUDENCIA' },
}),
prisma.document.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: 10,
}),
prisma.user.findUnique({
where: { id: userId },
select: { credits: true, plan: true, name: true },
}),
])
const credits = user?.credits ?? 0
const plan = user?.plan ?? 'FREE'
const firstName = (user?.name ?? session.user.name).split(' ')[0]
const stats = [
{
label: 'Peças criadas',
value: formatNumber(documentCount),
icon: FileText,
color: 'from-teal-600 to-teal-800',
iconBg: 'bg-teal-500/20',
iconColor: 'text-teal-400',
},
{
label: 'Créditos restantes',
value: formatNumber(credits),
icon: Coins,
color: 'from-amber-600 to-amber-800',
iconBg: 'bg-amber-500/20',
iconColor: 'text-amber-400',
},
{
label: 'Palavras geradas',
value: formatNumber(totalWords._sum.wordCount ?? 0),
icon: LetterText,
color: 'from-cyan-600 to-cyan-800',
iconBg: 'bg-cyan-500/20',
iconColor: 'text-cyan-400',
},
{
label: 'Jurisprudências consultadas',
value: formatNumber(jurisprudenciaCount),
icon: Scale,
color: 'from-emerald-600 to-emerald-800',
iconBg: 'bg-emerald-500/20',
iconColor: 'text-emerald-400',
},
]
const quickActions = [
{
label: 'Nova Petição',
href: '/dashboard/nova-peca?type=PETICAO_INICIAL',
icon: FilePlus,
desc: 'Petição inicial com IA',
gradient: 'from-teal-600 to-indigo-700',
},
{
label: 'Nova Contestação',
href: '/dashboard/nova-peca?type=CONTESTACAO',
icon: ShieldAlert,
desc: 'Contestação fundamentada',
gradient: 'from-rose-600 to-pink-700',
},
{
label: 'Chat IA',
href: '/dashboard/chat',
icon: MessageSquare,
desc: 'Tire dúvidas com IA',
gradient: 'from-cyan-600 to-teal-700',
},
{
label: 'Buscar Jurisprudência',
href: '/dashboard/jurisprudencia',
icon: Search,
desc: 'Pesquise decisões',
gradient: 'from-amber-600 to-orange-700',
},
]
const greeting = (() => {
const h = new Date().getHours()
if (h < 12) return 'Bom dia'
if (h < 18) return 'Boa tarde'
return 'Boa noite'
})()
const planBadge: Record<string, { label: string; style: string }> = {
FREE: { label: 'Free', style: 'bg-zinc-700 text-zinc-300' },
STARTER: { label: 'Starter', style: 'bg-blue-600/20 text-blue-400 border border-blue-500/30' },
PRO: { label: 'Pro', style: 'bg-teal-600/20 text-teal-400 border border-teal-500/30' },
ENTERPRISE: { label: 'Enterprise', style: 'bg-amber-600/20 text-amber-400 border border-amber-500/30' },
}
const badge = planBadge[plan] || planBadge.FREE
return (
<div className="space-y-8">
<Suspense fallback={null}>
<UpgradeSuccessBanner />
</Suspense>
<UpgradePrompt plan={plan} credits={credits} />
{/* Welcome card */}
<div className="relative overflow-hidden rounded-2xl border border-white/5 bg-gradient-to-br from-teal-900/30 via-[#111b27] to-[#0f1620] p-6 sm:p-8">
<div className="absolute -right-20 -top-20 h-60 w-60 rounded-full bg-teal-600/10 blur-3xl" />
<div className="absolute -bottom-10 -left-10 h-40 w-40 rounded-full bg-indigo-600/10 blur-3xl" />
<div className="relative">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-white sm:text-3xl">
{greeting}, {firstName}! <span className="inline-block animate-[wave_2s_ease-in-out_infinite]">👋</span>
</h1>
<span
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-bold uppercase tracking-wider ${badge.style}`}
>
<Sparkles className="h-3 w-3" />
{badge.label}
</span>
</div>
<p className="mt-2 max-w-xl text-sm text-zinc-400 sm:text-base">
Aqui está um resumo da sua atividade. Crie peças jurídicas com IA, pesquise jurisprudência e muito mais.
</p>
</div>
</div>
{/* Stat cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
{stats.map((stat) => {
const Icon = stat.icon
return (
<div
key={stat.label}
className="group relative overflow-hidden rounded-2xl border border-white/5 bg-[#111b27] p-5 transition-all duration-200 hover:border-white/10 hover:bg-[#162030]"
>
<div className="flex items-center justify-between">
<div className={`rounded-xl p-2.5 ${stat.iconBg}`}>
<Icon className={`h-5 w-5 ${stat.iconColor}`} />
</div>
<TrendingUp className="h-4 w-4 text-zinc-600 transition-colors group-hover:text-zinc-400" />
</div>
<p className="mt-4 text-2xl font-bold tracking-tight text-white">
{stat.value}
</p>
<p className="mt-0.5 text-sm text-zinc-500">{stat.label}</p>
</div>
)
})}
</div>
{/* Quick actions */}
<div>
<h2 className="mb-4 text-lg font-semibold text-white">Ações rápidas</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{quickActions.map((action) => {
const Icon = action.icon
return (
<Link
key={action.label}
href={action.href}
className="group relative overflow-hidden rounded-2xl border border-white/5 bg-[#111b27] p-5 transition-all duration-200 hover:border-teal-500/20 hover:shadow-lg hover:shadow-teal-900/10"
>
<div
className={`inline-flex rounded-xl bg-gradient-to-br ${action.gradient} p-2.5`}
>
<Icon className="h-5 w-5 text-white" />
</div>
<h3 className="mt-3 text-sm font-semibold text-white group-hover:text-teal-300">
{action.label}
</h3>
<p className="mt-0.5 text-xs text-zinc-500">{action.desc}</p>
</Link>
)
})}
</div>
</div>
{/* Recent documents */}
<div>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Documentos recentes</h2>
<Link
href="/dashboard/minhas-pecas"
className="text-sm text-teal-400 transition-colors hover:text-teal-300"
>
Ver todos
</Link>
</div>
{recentDocuments.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-white/10 bg-[#111b27] py-16 text-center">
<FileText className="h-12 w-12 text-zinc-700" />
<h3 className="mt-4 text-base font-medium text-zinc-400">
Nenhum documento ainda
</h3>
<p className="mt-1 text-sm text-zinc-600">
Crie sua primeira peça jurídica com IA
</p>
<Link
href="/dashboard/nova-peca"
className="mt-5 inline-flex items-center gap-2 rounded-xl bg-teal-600 px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-teal-500"
>
<FilePlus className="h-4 w-4" />
Nova Peça
</Link>
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-white/5 bg-[#111b27]">
{/* Desktop table */}
<div className="hidden sm:block">
<table className="w-full">
<thead>
<tr className="border-b border-white/5 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">
<th className="px-5 py-3.5">Título</th>
<th className="px-5 py-3.5">Tipo</th>
<th className="px-5 py-3.5">Área</th>
<th className="px-5 py-3.5">Palavras</th>
<th className="px-5 py-3.5">Status</th>
<th className="px-5 py-3.5">Data</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{recentDocuments.map((doc) => (
<tr
key={doc.id}
className="transition-colors hover:bg-white/[0.02]"
>
<td className="px-5 py-3.5">
<Link
href={`/dashboard/minhas-pecas/${doc.id}`}
className="text-sm font-medium text-white hover:text-teal-400"
>
{doc.title}
</Link>
</td>
<td className="px-5 py-3.5">
<span className="text-sm text-zinc-400">
{typeLabels[doc.type] || doc.type}
</span>
</td>
<td className="px-5 py-3.5">
<span
className={`inline-block rounded-md px-2 py-0.5 text-xs font-medium ${
areaColors[doc.area] || 'bg-zinc-700 text-zinc-400'
}`}
>
{areaLabels[doc.area] || doc.area}
</span>
</td>
<td className="px-5 py-3.5 text-sm text-zinc-400">
{doc.wordCount.toLocaleString('pt-BR')}
</td>
<td className="px-5 py-3.5">
<div className="flex items-center gap-2">
<span
className={`h-2 w-2 rounded-full ${
statusIndicator[doc.status] || 'bg-zinc-500'
}`}
/>
<span className="text-xs text-zinc-400">
{doc.status === 'COMPLETED'
? 'Concluído'
: doc.status === 'GENERATING'
? 'Gerando...'
: 'Erro'}
</span>
</div>
</td>
<td className="px-5 py-3.5">
<div className="flex items-center gap-1.5 text-xs text-zinc-500">
<Clock className="h-3.5 w-3.5" />
{formatDate(doc.createdAt)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="divide-y divide-white/5 sm:hidden">
{recentDocuments.map((doc) => (
<Link
key={doc.id}
href={`/dashboard/minhas-pecas/${doc.id}`}
className="block p-4 transition-colors hover:bg-white/[0.02]"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white">
{doc.title}
</p>
<p className="mt-0.5 text-xs text-zinc-500">
{typeLabels[doc.type] || doc.type} · {doc.wordCount.toLocaleString('pt-BR')} palavras
</p>
</div>
<span
className={`mt-0.5 h-2 w-2 flex-shrink-0 rounded-full ${
statusIndicator[doc.status] || 'bg-zinc-500'
}`}
/>
</div>
<div className="mt-2 flex items-center gap-2">
<span
className={`rounded-md px-2 py-0.5 text-[10px] font-medium ${
areaColors[doc.area] || 'bg-zinc-700 text-zinc-400'
}`}
>
{areaLabels[doc.area] || doc.area}
</span>
<span className="text-[10px] text-zinc-600">
{formatDate(doc.createdAt)}
</span>
</div>
</Link>
))}
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,479 @@
'use client'
import { useState, useEffect } from 'react'
import {
Clock,
Plus,
X,
Calendar,
AlertTriangle,
CheckCircle2,
XCircle,
Filter,
Loader2,
Trash2,
Edit3,
Building2,
FileText,
} from 'lucide-react'
interface Prazo {
id: string
title: string
description: string | null
processNumber: string | null
court: string | null
deadline: string
alertDays: number
status: 'PENDENTE' | 'CONCLUIDO' | 'VENCIDO' | 'CANCELADO'
priority: 'ALTA' | 'MEDIA' | 'BAIXA'
createdAt: string
}
const priorityConfig = {
ALTA: { label: 'Alta', color: 'text-red-400', bg: 'bg-red-500/15 border-red-500/30', dot: 'bg-red-500' },
MEDIA: { label: 'Média', color: 'text-yellow-400', bg: 'bg-yellow-500/15 border-yellow-500/30', dot: 'bg-yellow-500' },
BAIXA: { label: 'Baixa', color: 'text-green-400', bg: 'bg-green-500/15 border-green-500/30', dot: 'bg-green-500' },
}
const statusConfig = {
PENDENTE: { label: 'Pendente', color: 'text-blue-400', bg: 'bg-blue-500/15 border-blue-500/30', icon: Clock },
CONCLUIDO: { label: 'Concluído', color: 'text-green-400', bg: 'bg-green-500/15 border-green-500/30', icon: CheckCircle2 },
VENCIDO: { label: 'Vencido', color: 'text-red-400', bg: 'bg-red-500/15 border-red-500/30', icon: AlertTriangle },
CANCELADO: { label: 'Cancelado', color: 'text-zinc-400', bg: 'bg-zinc-500/15 border-zinc-500/30', icon: XCircle },
}
export default function PrazosPage() {
const [prazos, setPrazos] = useState<Prazo[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingPrazo, setEditingPrazo] = useState<Prazo | null>(null)
const [filterStatus, setFilterStatus] = useState<string>('')
const [filterPriority, setFilterPriority] = useState<string>('')
const [saving, setSaving] = useState(false)
// Form state
const [form, setForm] = useState({
title: '',
description: '',
processNumber: '',
court: '',
deadline: '',
alertDays: 3,
priority: 'MEDIA' as 'ALTA' | 'MEDIA' | 'BAIXA',
})
useEffect(() => {
fetchPrazos()
}, [filterStatus, filterPriority])
async function fetchPrazos() {
setLoading(true)
try {
const params = new URLSearchParams()
if (filterStatus) params.set('status', filterStatus)
if (filterPriority) params.set('priority', filterPriority)
const res = await fetch(`/api/prazos?${params}`)
if (res.ok) {
const data = await res.json()
setPrazos(data.prazos || [])
}
} catch (e) {
console.error('Failed to load prazos:', e)
} finally {
setLoading(false)
}
}
function openNewModal() {
setEditingPrazo(null)
setForm({ title: '', description: '', processNumber: '', court: '', deadline: '', alertDays: 3, priority: 'MEDIA' })
setShowModal(true)
}
function openEditModal(prazo: Prazo) {
setEditingPrazo(prazo)
setForm({
title: prazo.title,
description: prazo.description || '',
processNumber: prazo.processNumber || '',
court: prazo.court || '',
deadline: prazo.deadline.split('T')[0],
alertDays: prazo.alertDays,
priority: prazo.priority,
})
setShowModal(true)
}
async function handleSave() {
if (!form.title || !form.deadline) return
setSaving(true)
try {
if (editingPrazo) {
await fetch(`/api/prazos/${editingPrazo.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
} else {
await fetch('/api/prazos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
}
setShowModal(false)
fetchPrazos()
} catch (e) {
console.error('Failed to save:', e)
} finally {
setSaving(false)
}
}
async function handleStatusChange(id: string, status: string) {
try {
await fetch(`/api/prazos/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
})
fetchPrazos()
} catch (e) {
console.error('Failed to update status:', e)
}
}
async function handleDelete(id: string) {
if (!confirm('Tem certeza que deseja excluir este prazo?')) return
try {
await fetch(`/api/prazos/${id}`, { method: 'DELETE' })
fetchPrazos()
} catch (e) {
console.error('Failed to delete:', e)
}
}
function getDaysUntil(deadline: string) {
const now = new Date()
now.setHours(0, 0, 0, 0)
const dl = new Date(deadline)
dl.setHours(0, 0, 0, 0)
return Math.ceil((dl.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
// Stats
const stats = {
total: prazos.length,
pendentes: prazos.filter(p => p.status === 'PENDENTE').length,
vencidos: prazos.filter(p => p.status === 'VENCIDO').length,
concluidos: prazos.filter(p => p.status === 'CONCLUIDO').length,
}
// Auto-mark overdue
const processedPrazos = prazos.map(p => {
if (p.status === 'PENDENTE' && getDaysUntil(p.deadline) < 0) {
return { ...p, status: 'VENCIDO' as const }
}
return p
})
return (
<div>
{/* Header */}
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-teal-600/20">
<Clock className="h-5 w-5 text-teal-400" />
</div>
Gestão de Prazos
</h1>
<p className="mt-1 text-sm text-zinc-500">Gerencie seus prazos processuais e compromissos</p>
</div>
<button
onClick={openNewModal}
className="flex items-center gap-2 rounded-xl bg-teal-600 px-4 py-2.5 text-sm font-semibold text-white transition-all hover:bg-teal-500 hover:shadow-lg hover:shadow-teal-500/20"
>
<Plus className="h-4 w-4" />
Novo Prazo
</button>
</div>
{/* Stats Cards */}
<div className="mb-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
{[
{ label: 'Total', value: stats.total, color: 'text-white', bg: 'bg-white/5 border-white/10' },
{ label: 'Pendentes', value: stats.pendentes, color: 'text-blue-400', bg: 'bg-blue-500/10 border-blue-500/20' },
{ label: 'Vencidos', value: stats.vencidos, color: 'text-red-400', bg: 'bg-red-500/10 border-red-500/20' },
{ label: 'Concluídos', value: stats.concluidos, color: 'text-green-400', bg: 'bg-green-500/10 border-green-500/20' },
].map(s => (
<div key={s.label} className={`rounded-xl border ${s.bg} p-4`}>
<p className="text-xs font-medium text-zinc-500">{s.label}</p>
<p className={`mt-1 text-2xl font-bold ${s.color}`}>{s.value}</p>
</div>
))}
</div>
{/* Filters */}
<div className="mb-6 flex flex-wrap items-center gap-3">
<Filter className="h-4 w-4 text-zinc-500" />
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
className="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-zinc-300 outline-none focus:border-teal-500/40"
>
<option value="">Todos os status</option>
<option value="PENDENTE">Pendente</option>
<option value="CONCLUIDO">Concluído</option>
<option value="VENCIDO">Vencido</option>
<option value="CANCELADO">Cancelado</option>
</select>
<select
value={filterPriority}
onChange={e => setFilterPriority(e.target.value)}
className="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-zinc-300 outline-none focus:border-teal-500/40"
>
<option value="">Todas as prioridades</option>
<option value="ALTA">Alta</option>
<option value="MEDIA">Média</option>
<option value="BAIXA">Baixa</option>
</select>
</div>
{/* Prazos List */}
{loading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-6 w-6 animate-spin text-teal-400" />
</div>
) : processedPrazos.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-white/5 bg-white/[0.02] py-16">
<Calendar className="h-12 w-12 text-zinc-600" />
<p className="mt-3 text-sm text-zinc-500">Nenhum prazo encontrado</p>
<button onClick={openNewModal} className="mt-4 text-sm text-teal-400 hover:text-teal-300">
Adicionar primeiro prazo
</button>
</div>
) : (
<div className="space-y-3">
{processedPrazos.map(prazo => {
const days = getDaysUntil(prazo.deadline)
const isUrgent = prazo.status === 'PENDENTE' && days >= 0 && days <= 3
const isOverdue = days < 0 && prazo.status === 'PENDENTE'
const prio = priorityConfig[prazo.priority]
const stat = statusConfig[prazo.status]
const StatusIcon = stat.icon
return (
<div
key={prazo.id}
className={`group relative rounded-xl border bg-white/[0.02] p-4 transition-all hover:bg-white/[0.04] ${
isUrgent || isOverdue ? 'border-red-500/30 shadow-[0_0_15px_rgba(239,68,68,0.1)]' : 'border-white/5'
}`}
>
<div className="flex items-start gap-4">
{/* Priority indicator */}
<div className={`mt-1 h-3 w-3 rounded-full ${prio.dot} flex-shrink-0`} />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="font-semibold text-white">{prazo.title}</h3>
{prazo.description && (
<p className="mt-0.5 text-sm text-zinc-500 line-clamp-1">{prazo.description}</p>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className={`rounded-md border px-2 py-0.5 text-[10px] font-bold uppercase ${prio.bg} ${prio.color}`}>
{prio.label}
</span>
<span className={`flex items-center gap-1 rounded-md border px-2 py-0.5 text-[10px] font-bold uppercase ${stat.bg} ${stat.color}`}>
<StatusIcon className="h-3 w-3" />
{stat.label}
</span>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-zinc-500">
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{formatDate(prazo.deadline)}
</span>
{prazo.processNumber && (
<span className="flex items-center gap-1">
<FileText className="h-3.5 w-3.5" />
{prazo.processNumber}
</span>
)}
{prazo.court && (
<span className="flex items-center gap-1">
<Building2 className="h-3.5 w-3.5" />
{prazo.court}
</span>
)}
{prazo.status === 'PENDENTE' && (
<span className={`font-semibold ${isOverdue ? 'text-red-400' : isUrgent ? 'text-red-400 animate-pulse' : days <= 7 ? 'text-yellow-400' : 'text-zinc-400'}`}>
{isOverdue
? `Vencido há ${Math.abs(days)} dia${Math.abs(days) !== 1 ? 's' : ''}`
: days === 0
? 'Vence hoje!'
: days === 1
? 'Vence amanhã'
: `${days} dias restantes`
}
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{prazo.status === 'PENDENTE' && (
<button
onClick={() => handleStatusChange(prazo.id, 'CONCLUIDO')}
className="rounded-lg p-1.5 text-zinc-500 hover:bg-green-500/10 hover:text-green-400"
title="Marcar como concluído"
>
<CheckCircle2 className="h-4 w-4" />
</button>
)}
<button
onClick={() => openEditModal(prazo)}
className="rounded-lg p-1.5 text-zinc-500 hover:bg-white/10 hover:text-zinc-300"
title="Editar"
>
<Edit3 className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(prazo.id)}
className="rounded-lg p-1.5 text-zinc-500 hover:bg-red-500/10 hover:text-red-400"
title="Excluir"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
)
})}
</div>
)}
{/* Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="mx-4 w-full max-w-lg rounded-2xl border border-white/10 bg-[#0f1620] p-6 shadow-2xl">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-bold text-white">
{editingPrazo ? 'Editar Prazo' : 'Novo Prazo'}
</h2>
<button onClick={() => setShowModal(false)} className="rounded-lg p-1.5 text-zinc-400 hover:bg-white/5 hover:text-white">
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Título *</label>
<input
type="text"
value={form.title}
onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none focus:border-teal-500/40"
placeholder="Ex: Contestação - Processo 1234567"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Descrição</label>
<textarea
value={form.description}
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none focus:border-teal-500/40"
rows={2}
placeholder="Detalhes adicionais..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1"> do Processo</label>
<input
type="text"
value={form.processNumber}
onChange={e => setForm(f => ({ ...f, processNumber: e.target.value }))}
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none focus:border-teal-500/40"
placeholder="0000000-00.0000.0.00.0000"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Tribunal/Vara</label>
<input
type="text"
value={form.court}
onChange={e => setForm(f => ({ ...f, court: e.target.value }))}
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none focus:border-teal-500/40"
placeholder="Ex: 1ª Vara Cível"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Prazo *</label>
<input
type="date"
value={form.deadline}
onChange={e => setForm(f => ({ ...f, deadline: e.target.value }))}
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none focus:border-teal-500/40"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Alerta (dias)</label>
<input
type="number"
value={form.alertDays}
onChange={e => setForm(f => ({ ...f, alertDays: parseInt(e.target.value) || 3 }))}
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none focus:border-teal-500/40"
min={1}
max={30}
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Prioridade</label>
<select
value={form.priority}
onChange={e => setForm(f => ({ ...f, priority: e.target.value as 'ALTA' | 'MEDIA' | 'BAIXA' }))}
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none focus:border-teal-500/40"
>
<option value="ALTA">Alta</option>
<option value="MEDIA">Média</option>
<option value="BAIXA">Baixa</option>
</select>
</div>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={() => setShowModal(false)}
className="rounded-lg border border-white/10 px-4 py-2 text-sm text-zinc-400 hover:bg-white/5"
>
Cancelar
</button>
<button
onClick={handleSave}
disabled={!form.title || !form.deadline || saving}
className="flex items-center gap-2 rounded-lg bg-teal-600 px-4 py-2 text-sm font-semibold text-white hover:bg-teal-500 disabled:opacity-50"
>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
{editingPrazo ? 'Salvar' : 'Criar Prazo'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,282 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import {
ArrowLeft,
FileText,
Building2,
MapPin,
Users,
Loader2,
AlertCircle,
} from 'lucide-react'
const TRIBUNAIS = [
{ value: 'STF', label: 'STF - Supremo Tribunal Federal' },
{ value: 'STJ', label: 'STJ - Superior Tribunal de Justiça' },
{ value: 'TST', label: 'TST - Tribunal Superior do Trabalho' },
{ value: 'TSE', label: 'TSE - Tribunal Superior Eleitoral' },
{ value: 'STM', label: 'STM - Superior Tribunal Militar' },
{ value: 'TRF1', label: 'TRF1 - 1ª Região' },
{ value: 'TRF2', label: 'TRF2 - 2ª Região (RJ, ES)' },
{ value: 'TRF3', label: 'TRF3 - 3ª Região (SP, MS)' },
{ value: 'TRF4', label: 'TRF4 - 4ª Região (RS, PR, SC)' },
{ value: 'TRF5', label: 'TRF5 - 5ª Região (PE, AL, CE, PB, RN, SE)' },
{ value: 'TRF6', label: 'TRF6 - 6ª Região (MG)' },
{ value: 'TJSP', label: 'TJSP - São Paulo' },
{ value: 'TJRJ', label: 'TJRJ - Rio de Janeiro' },
{ value: 'TJMG', label: 'TJMG - Minas Gerais' },
{ value: 'TJRS', label: 'TJRS - Rio Grande do Sul' },
{ value: 'TJPR', label: 'TJPR - Paraná' },
{ value: 'TJSC', label: 'TJSC - Santa Catarina' },
{ value: 'TJBA', label: 'TJBA - Bahia' },
{ value: 'TJPE', label: 'TJPE - Pernambuco' },
{ value: 'TJCE', label: 'TJCE - Ceará' },
{ value: 'TJDF', label: 'TJDF - Distrito Federal' },
{ value: 'TJGO', label: 'TJGO - Goiás' },
{ value: 'TJMA', label: 'TJMA - Maranhão' },
{ value: 'TJPA', label: 'TJPA - Pará' },
{ value: 'TJMT', label: 'TJMT - Mato Grosso' },
{ value: 'TJMS', label: 'TJMS - Mato Grosso do Sul' },
{ value: 'TJES', label: 'TJES - Espírito Santo' },
{ value: 'TJAL', label: 'TJAL - Alagoas' },
{ value: 'TJAM', label: 'TJAM - Amazonas' },
{ value: 'TJPB', label: 'TJPB - Paraíba' },
{ value: 'TJPI', label: 'TJPI - Piauí' },
{ value: 'TJRN', label: 'TJRN - Rio Grande do Norte' },
{ value: 'TJSE', label: 'TJSE - Sergipe' },
{ value: 'TJTO', label: 'TJTO - Tocantins' },
{ value: 'TJAC', label: 'TJAC - Acre' },
{ value: 'TJAP', label: 'TJAP - Amapá' },
{ value: 'TJRO', label: 'TJRO - Rondônia' },
{ value: 'TJRR', label: 'TJRR - Roraima' },
]
export default function NovoProcessoPage() {
const router = useRouter()
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [form, setForm] = useState({
numeroProcesso: '',
tribunal: '',
vara: '',
comarca: '',
parteAutora: '',
parteRe: '',
})
// Máscara CNJ: 0000000-00.0000.0.00.0000
function formatCNJ(value: string): string {
const digits = value.replace(/\D/g, '').slice(0, 20)
if (digits.length <= 7) return digits
if (digits.length <= 9) return `${digits.slice(0, 7)}-${digits.slice(7)}`
if (digits.length <= 13) return `${digits.slice(0, 7)}-${digits.slice(7, 9)}.${digits.slice(9)}`
if (digits.length <= 14) return `${digits.slice(0, 7)}-${digits.slice(7, 9)}.${digits.slice(9, 13)}.${digits.slice(13)}`
if (digits.length <= 16) return `${digits.slice(0, 7)}-${digits.slice(7, 9)}.${digits.slice(9, 13)}.${digits.slice(13, 14)}.${digits.slice(14)}`
return `${digits.slice(0, 7)}-${digits.slice(7, 9)}.${digits.slice(9, 13)}.${digits.slice(13, 14)}.${digits.slice(14, 16)}.${digits.slice(16)}`
}
function handleNumeroChange(e: React.ChangeEvent<HTMLInputElement>) {
const formatted = formatCNJ(e.target.value)
setForm(f => ({ ...f, numeroProcesso: formatted }))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
if (!form.numeroProcesso || !form.tribunal) {
setError('Número do processo e tribunal são obrigatórios')
return
}
// Valida formato CNJ
const cnjRegex = /^\d{7}-\d{2}\.\d{4}\.\d{1}\.\d{2}\.\d{4}$/
if (!cnjRegex.test(form.numeroProcesso)) {
setError('Número do processo inválido. Complete o formato CNJ: 0000000-00.0000.0.00.0000')
return
}
setSaving(true)
try {
const res = await fetch('/api/processos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || 'Erro ao cadastrar processo')
return
}
router.push('/dashboard/publicacoes')
} catch (e) {
setError('Erro de conexão')
} finally {
setSaving(false)
}
}
return (
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="mb-8">
<button
onClick={() => router.back()}
className="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors mb-4"
>
<ArrowLeft className="h-4 w-4" />
Voltar
</button>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-indigo-600/20">
<FileText className="h-5 w-5 text-indigo-400" />
</div>
Adicionar Processo para Monitorar
</h1>
<p className="mt-2 text-sm text-zinc-500">
Cadastre um processo para acompanhar publicações nos Diários Oficiais
</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-400">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
{error}
</div>
)}
{/* Número do Processo */}
<div className="rounded-xl border border-white/10 bg-white/[0.02] p-6">
<h3 className="text-sm font-semibold text-white flex items-center gap-2 mb-4">
<FileText className="h-4 w-4 text-indigo-400" />
Dados do Processo
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Número do Processo (CNJ) *
</label>
<input
type="text"
value={form.numeroProcesso}
onChange={handleNumeroChange}
placeholder="0000000-00.0000.0.00.0000"
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 font-mono text-white outline-none focus:border-indigo-500/40 placeholder:text-zinc-600"
/>
<p className="mt-1 text-xs text-zinc-600">
Formato: NNNNNNN-DD.AAAA.J.TR.OOOO
</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Tribunal *
</label>
<select
value={form.tribunal}
onChange={e => setForm(f => ({ ...f, tribunal: e.target.value }))}
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white outline-none focus:border-indigo-500/40 [&>option]:bg-zinc-900"
>
<option value="">Selecione o tribunal</option>
{TRIBUNAIS.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
</div>
</div>
{/* Localização */}
<div className="rounded-xl border border-white/10 bg-white/[0.02] p-6">
<h3 className="text-sm font-semibold text-white flex items-center gap-2 mb-4">
<MapPin className="h-4 w-4 text-green-400" />
Localização (opcional)
</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Vara</label>
<input
type="text"
value={form.vara}
onChange={e => setForm(f => ({ ...f, vara: e.target.value }))}
placeholder="Ex: 1ª Vara Cível"
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-white outline-none focus:border-indigo-500/40 placeholder:text-zinc-600"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Comarca</label>
<input
type="text"
value={form.comarca}
onChange={e => setForm(f => ({ ...f, comarca: e.target.value }))}
placeholder="Ex: São Paulo"
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-white outline-none focus:border-indigo-500/40 placeholder:text-zinc-600"
/>
</div>
</div>
</div>
{/* Partes */}
<div className="rounded-xl border border-white/10 bg-white/[0.02] p-6">
<h3 className="text-sm font-semibold text-white flex items-center gap-2 mb-4">
<Users className="h-4 w-4 text-purple-400" />
Partes (opcional)
</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Parte Autora</label>
<input
type="text"
value={form.parteAutora}
onChange={e => setForm(f => ({ ...f, parteAutora: e.target.value }))}
placeholder="Nome do autor"
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-white outline-none focus:border-indigo-500/40 placeholder:text-zinc-600"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Parte </label>
<input
type="text"
value={form.parteRe}
onChange={e => setForm(f => ({ ...f, parteRe: e.target.value }))}
placeholder="Nome do réu"
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-white outline-none focus:border-indigo-500/40 placeholder:text-zinc-600"
/>
</div>
</div>
</div>
{/* Submit */}
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => router.back()}
className="rounded-xl border border-white/10 px-6 py-2.5 text-sm font-medium text-zinc-400 hover:bg-white/5"
>
Cancelar
</button>
<button
type="submit"
disabled={saving}
className="flex items-center gap-2 rounded-xl bg-indigo-600 px-6 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50"
>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
Adicionar Processo
</button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,469 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import {
Newspaper,
Plus,
Search,
Filter,
Loader2,
Eye,
EyeOff,
Calendar,
Building2,
FileText,
Clock,
AlertTriangle,
ChevronDown,
ChevronUp,
Trash2,
RefreshCw,
Bell,
} from 'lucide-react'
interface Processo {
id: string
numeroProcesso: string
tribunal: string
vara: string | null
comarca: string | null
parteAutora: string | null
parteRe: string | null
status: 'ATIVO' | 'ARQUIVADO' | 'SUSPENSO'
publicacoesNaoLidas: number
ultimaPublicacao: Publicacao | null
}
interface Publicacao {
id: string
processoId: string
dataPublicacao: string
diario: string
conteudo: string
tipo: 'INTIMACAO' | 'CITACAO' | 'SENTENCA' | 'DESPACHO' | 'ACORDAO' | 'OUTROS'
prazoCalculado: string | null
prazoTipo: string | null
visualizado: boolean
processo?: {
numeroProcesso: string
tribunal: string
parteAutora: string | null
parteRe: string | null
}
}
const tipoConfig = {
INTIMACAO: { label: 'Intimação', bg: 'bg-blue-500/15 border-blue-500/30', color: 'text-blue-400' },
CITACAO: { label: 'Citação', bg: 'bg-purple-500/15 border-purple-500/30', color: 'text-purple-400' },
SENTENCA: { label: 'Sentença', bg: 'bg-red-500/15 border-red-500/30', color: 'text-red-400' },
DESPACHO: { label: 'Despacho', bg: 'bg-yellow-500/15 border-yellow-500/30', color: 'text-yellow-400' },
ACORDAO: { label: 'Acórdão', bg: 'bg-orange-500/15 border-orange-500/30', color: 'text-orange-400' },
OUTROS: { label: 'Outros', bg: 'bg-zinc-500/15 border-zinc-500/30', color: 'text-zinc-400' },
}
export default function PublicacoesPage() {
const router = useRouter()
const [processos, setProcessos] = useState<Processo[]>([])
const [publicacoes, setPublicacoes] = useState<Publicacao[]>([])
const [loading, setLoading] = useState(true)
const [buscando, setBuscando] = useState(false)
const [expandedId, setExpandedId] = useState<string | null>(null)
// Filtros
const [filterTipo, setFilterTipo] = useState<string>('')
const [filterVisualizado, setFilterVisualizado] = useState<string>('')
const [filterProcesso, setFilterProcesso] = useState<string>('')
useEffect(() => {
fetchData()
}, [filterTipo, filterVisualizado, filterProcesso])
async function fetchData() {
setLoading(true)
try {
// Busca processos e publicações em paralelo
const [processosRes, publicacoesRes] = await Promise.all([
fetch('/api/processos'),
fetchPublicacoes(),
])
if (processosRes.ok) {
const data = await processosRes.json()
setProcessos(data.processos || [])
}
} catch (e) {
console.error('Failed to load data:', e)
} finally {
setLoading(false)
}
}
async function fetchPublicacoes() {
const params = new URLSearchParams()
if (filterTipo) params.set('tipo', filterTipo)
if (filterVisualizado) params.set('visualizado', filterVisualizado)
if (filterProcesso) params.set('processoId', filterProcesso)
const res = await fetch(`/api/publicacoes?${params}`)
if (res.ok) {
const data = await res.json()
setPublicacoes(data.publicacoes || [])
}
return res
}
async function buscarNovasPublicacoes() {
setBuscando(true)
try {
const res = await fetch('/api/publicacoes/buscar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
if (res.ok) {
await fetchData()
}
} catch (e) {
console.error('Failed to search:', e)
} finally {
setBuscando(false)
}
}
async function marcarComoLida(id: string) {
try {
await fetch(`/api/publicacoes/${id}/visualizar`, { method: 'PATCH' })
setPublicacoes(pubs => pubs.map(p => p.id === id ? { ...p, visualizado: true } : p))
} catch (e) {
console.error('Failed to mark as read:', e)
}
}
async function deleteProcesso(id: string) {
if (!confirm('Tem certeza que deseja remover este processo do monitoramento?')) return
try {
await fetch(`/api/processos/${id}`, { method: 'DELETE' })
fetchData()
} catch (e) {
console.error('Failed to delete:', e)
}
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
function getDaysUntilPrazo(prazoStr: string | null) {
if (!prazoStr) return null
const now = new Date()
now.setHours(0, 0, 0, 0)
const prazo = new Date(prazoStr)
prazo.setHours(0, 0, 0, 0)
return Math.ceil((prazo.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
}
// Stats
const totalProcessos = processos.length
const totalNaoLidas = publicacoes.filter(p => !p.visualizado).length
const prazosProximos = publicacoes.filter(p => {
const days = getDaysUntilPrazo(p.prazoCalculado)
return days !== null && days >= 0 && days <= 5
}).length
return (
<div>
{/* Header */}
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-indigo-600/20">
<Newspaper className="h-5 w-5 text-indigo-400" />
</div>
Monitoramento de Publicações
</h1>
<p className="mt-1 text-sm text-zinc-500">Acompanhe publicações dos Diários Oficiais</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={buscarNovasPublicacoes}
disabled={buscando}
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-medium text-zinc-300 transition-all hover:bg-white/10 disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${buscando ? 'animate-spin' : ''}`} />
{buscando ? 'Buscando...' : 'Buscar Publicações'}
</button>
<button
onClick={() => router.push('/dashboard/publicacoes/novo')}
className="flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition-all hover:bg-indigo-500 hover:shadow-lg hover:shadow-indigo-500/20"
>
<Plus className="h-4 w-4" />
Adicionar Processo
</button>
</div>
</div>
{/* Stats Cards */}
<div className="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="rounded-xl border border-white/5 bg-white/[0.02] p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-500/10">
<FileText className="h-5 w-5 text-blue-400" />
</div>
<div>
<p className="text-xs font-medium text-zinc-500">Processos Monitorados</p>
<p className="text-2xl font-bold text-white">{totalProcessos}</p>
</div>
</div>
</div>
<div className="rounded-xl border border-white/5 bg-white/[0.02] p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-yellow-500/10">
<Bell className="h-5 w-5 text-yellow-400" />
</div>
<div>
<p className="text-xs font-medium text-zinc-500">Publicações Não Lidas</p>
<p className="text-2xl font-bold text-yellow-400">{totalNaoLidas}</p>
</div>
</div>
</div>
<div className="rounded-xl border border-white/5 bg-white/[0.02] p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-500/10">
<AlertTriangle className="h-5 w-5 text-red-400" />
</div>
<div>
<p className="text-xs font-medium text-zinc-500">Prazos Próximos (5 dias)</p>
<p className="text-2xl font-bold text-red-400">{prazosProximos}</p>
</div>
</div>
</div>
</div>
{/* Filters */}
<div className="mb-6 flex flex-wrap items-center gap-3">
<Filter className="h-4 w-4 text-zinc-500" />
<select
value={filterTipo}
onChange={e => setFilterTipo(e.target.value)}
className="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-zinc-300 outline-none focus:border-indigo-500/40"
>
<option value="">Todos os tipos</option>
<option value="INTIMACAO">Intimação</option>
<option value="CITACAO">Citação</option>
<option value="SENTENCA">Sentença</option>
<option value="DESPACHO">Despacho</option>
<option value="ACORDAO">Acórdão</option>
<option value="OUTROS">Outros</option>
</select>
<select
value={filterVisualizado}
onChange={e => setFilterVisualizado(e.target.value)}
className="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-zinc-300 outline-none focus:border-indigo-500/40"
>
<option value="">Todas</option>
<option value="false">Não lidas</option>
<option value="true">Lidas</option>
</select>
{processos.length > 0 && (
<select
value={filterProcesso}
onChange={e => setFilterProcesso(e.target.value)}
className="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-zinc-300 outline-none focus:border-indigo-500/40"
>
<option value="">Todos os processos</option>
{processos.map(p => (
<option key={p.id} value={p.id}>
{p.numeroProcesso} ({p.tribunal})
</option>
))}
</select>
)}
</div>
{/* Content */}
{loading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-6 w-6 animate-spin text-indigo-400" />
</div>
) : processos.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-white/5 bg-white/[0.02] py-16">
<Newspaper className="h-12 w-12 text-zinc-600" />
<p className="mt-3 text-sm text-zinc-500">Nenhum processo sendo monitorado</p>
<button
onClick={() => router.push('/dashboard/publicacoes/novo')}
className="mt-4 text-sm text-indigo-400 hover:text-indigo-300"
>
Adicionar primeiro processo
</button>
</div>
) : publicacoes.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-white/5 bg-white/[0.02] py-16">
<Search className="h-12 w-12 text-zinc-600" />
<p className="mt-3 text-sm text-zinc-500">Nenhuma publicação encontrada</p>
<button
onClick={buscarNovasPublicacoes}
className="mt-4 text-sm text-indigo-400 hover:text-indigo-300"
>
Buscar novas publicações
</button>
</div>
) : (
<div className="space-y-3">
{publicacoes.map(pub => {
const config = tipoConfig[pub.tipo]
const daysUntilPrazo = getDaysUntilPrazo(pub.prazoCalculado)
const isUrgent = daysUntilPrazo !== null && daysUntilPrazo >= 0 && daysUntilPrazo <= 3
const isOverdue = daysUntilPrazo !== null && daysUntilPrazo < 0
const isExpanded = expandedId === pub.id
return (
<div
key={pub.id}
className={`group rounded-xl border bg-white/[0.02] transition-all hover:bg-white/[0.04] ${
!pub.visualizado
? 'border-indigo-500/30 shadow-[0_0_15px_rgba(99,102,241,0.1)]'
: isUrgent || isOverdue
? 'border-red-500/30'
: 'border-white/5'
}`}
>
<div className="p-4">
<div className="flex items-start gap-4">
{/* Indicator */}
{!pub.visualizado && (
<div className="mt-1.5 h-2 w-2 rounded-full bg-indigo-500 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className={`rounded-md border px-2 py-0.5 text-[10px] font-bold uppercase ${config.bg} ${config.color}`}>
{config.label}
</span>
<span className="text-xs text-zinc-500">{pub.diario}</span>
<span className="text-xs text-zinc-600"></span>
<span className="text-xs text-zinc-500">{formatDate(pub.dataPublicacao)}</span>
</div>
{pub.processo && (
<p className="mt-1 text-sm font-medium text-white">
{pub.processo.numeroProcesso}
<span className="ml-2 text-zinc-500 font-normal">({pub.processo.tribunal})</span>
</p>
)}
</div>
<div className="flex items-center gap-2">
{!pub.visualizado && (
<button
onClick={() => marcarComoLida(pub.id)}
className="rounded-lg p-1.5 text-zinc-500 hover:bg-indigo-500/10 hover:text-indigo-400"
title="Marcar como lida"
>
<Eye className="h-4 w-4" />
</button>
)}
<button
onClick={() => setExpandedId(isExpanded ? null : pub.id)}
className="rounded-lg p-1.5 text-zinc-500 hover:bg-white/10 hover:text-zinc-300"
>
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button>
</div>
</div>
{/* Preview / Full content */}
<p className={`mt-2 text-sm text-zinc-400 ${isExpanded ? '' : 'line-clamp-2'}`}>
{pub.conteudo}
</p>
{/* Prazo */}
{pub.prazoCalculado && (
<div className="mt-3 flex items-center gap-2">
<Clock className="h-3.5 w-3.5 text-zinc-500" />
<span className={`text-xs font-medium ${
isOverdue
? 'text-red-400'
: isUrgent
? 'text-red-400 animate-pulse'
: daysUntilPrazo !== null && daysUntilPrazo <= 7
? 'text-yellow-400'
: 'text-zinc-400'
}`}>
Prazo: {formatDate(pub.prazoCalculado)}
{pub.prazoTipo && ` (${pub.prazoTipo})`}
{daysUntilPrazo !== null && (
<span className="ml-2">
{isOverdue
? `• Vencido há ${Math.abs(daysUntilPrazo)} dia(s)`
: daysUntilPrazo === 0
? '• Vence HOJE!'
: daysUntilPrazo === 1
? '• Vence AMANHÃ'
: `${daysUntilPrazo} dias restantes`
}
</span>
)}
</span>
</div>
)}
</div>
</div>
</div>
</div>
)
})}
</div>
)}
{/* Processos monitorados section */}
{processos.length > 0 && (
<div className="mt-10">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<FileText className="h-5 w-5 text-zinc-400" />
Processos Monitorados
</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{processos.map(p => (
<div
key={p.id}
className="group rounded-xl border border-white/5 bg-white/[0.02] p-4 transition-all hover:bg-white/[0.04]"
>
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<p className="font-mono text-sm font-medium text-white truncate">
{p.numeroProcesso}
</p>
<p className="mt-0.5 text-xs text-zinc-500">{p.tribunal}</p>
{(p.parteAutora || p.parteRe) && (
<p className="mt-1 text-xs text-zinc-600 truncate">
{p.parteAutora && `Autor: ${p.parteAutora}`}
{p.parteAutora && p.parteRe && ' • '}
{p.parteRe && `Réu: ${p.parteRe}`}
</p>
)}
</div>
<div className="flex items-center gap-1 ml-2">
{p.publicacoesNaoLidas > 0 && (
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-indigo-500 px-1.5 text-[10px] font-bold text-white">
{p.publicacoesNaoLidas}
</span>
)}
<button
onClick={() => deleteProcesso(p.id)}
className="rounded-lg p-1.5 text-zinc-600 opacity-0 group-hover:opacity-100 hover:bg-red-500/10 hover:text-red-400 transition-all"
title="Remover"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}

81
src/app/globals.css Normal file
View File

@@ -0,0 +1,81 @@
@import "tailwindcss";
html {
scroll-behavior: smooth;
}
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(16, 185, 129, 0.3); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: rgba(16, 185, 129, 0.5); }
::selection { background: rgba(16, 185, 129, 0.3); color: white; }
details summary::-webkit-details-marker { display: none; }
/* Utilities used by other pages */
.glass {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.card-glow {
background: rgba(17, 24, 39, 0.8);
backdrop-filter: blur(20px);
border: 1px solid rgba(16, 185, 129, 0.15);
border-radius: 1rem;
}
.gradient-text {
background: linear-gradient(135deg, #fff 0%, #34d399 50%, #14b8a6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border-radius: 9999px;
background: linear-gradient(to right, #059669, #0d9488);
color: white;
font-weight: 600;
transition: all 0.3s;
justify-content: center;
}
.btn-primary:hover {
box-shadow: 0 10px 25px rgba(16, 185, 129, 0.25);
transform: translateY(-1px);
}
.input {
width: 100%;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: white;
transition: all 0.3s;
}
.input::placeholder { color: #6b7280; }
.input:focus {
outline: none;
border-color: rgba(16, 185, 129, 0.5);
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.15);
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}

30
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,30 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
})
export const metadata: Metadata = {
title: 'LexMind — Inteligência Jurídica Autônoma',
description: 'Plataforma de inteligência artificial construída para o Direito brasileiro.',
icons: { icon: '/favicon.svg' },
openGraph: {
title: 'LexMind — Onde o Direito Encontra a Inteligência Artificial',
description: 'Transforme horas de trabalho jurídico em minutos.',
type: 'website',
},
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="pt-BR" className={`${inter.variable} dark`}>
<body className={`${inter.className} antialiased bg-[#060a13] text-white`}>
{children}
</body>
</html>
)
}

235
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,235 @@
'use client'
import { useState } from 'react'
import { signIn } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Logo } from '@/components/Logo'
import { motion } from 'framer-motion'
export default function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const result = await signIn('credentials', {
email,
password,
redirect: false,
})
if (result?.error) {
setError(result.error === 'CredentialsSignin' ? 'Credenciais inválidas. Verifique e tente novamente.' : result.error)
} else {
router.push('/dashboard')
}
} catch {
setError('Falha na autenticação. Tente novamente em instantes.')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center px-4 py-12 relative overflow-hidden bg-gradient-to-br from-[#070b14] via-[#0a0f1a] to-[#070b14]">
{/* Background Effects */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-0 left-1/4 w-96 h-96 bg-teal-600/10 rounded-full blur-[120px]" />
<div className="absolute bottom-0 right-1/4 w-80 h-80 bg-cyan-500/10 rounded-full blur-[100px]" />
</div>
<motion.div
className="w-full max-w-md relative z-10"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: [0.4, 0, 0.2, 1] }}
>
{/* Logo */}
<motion.div
className="text-center mb-8"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6, delay: 0.1 }}
>
<Logo size="lg" className="justify-center mb-4" />
<p className="text-gray-400 text-sm font-medium">
Acesse seu painel e retome de onde parou
</p>
</motion.div>
{/* Login Card */}
<motion.div
className="card-glow p-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<h2 className="text-2xl font-bold text-white mb-6">Autenticação</h2>
{error && (
<motion.div
className="flex items-center gap-3 p-4 rounded-xl mb-6 bg-red-500/10 border border-red-500/20 text-red-400"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3 }}
>
<svg className="w-5 h-5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm font-medium">{error}</span>
</motion.div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Email */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Endereço de e-mail
</label>
<div className="relative">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="advocacia@exemplo.com"
required
className="input"
/>
</div>
</div>
{/* Password */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Senha
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
className="input pr-12"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-teal-400 transition-colors"
>
{showPassword ? (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div>
{/* Remember + Forgot */}
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 text-sm text-gray-400">
<input type="checkbox" className="rounded bg-white/5 border-teal-500/20 text-teal-600 focus:ring-teal-500/20" />
Manter sessão ativa
</label>
<Link href="/forgot-password" className="text-sm text-teal-400 hover:text-teal-300 transition-colors">
Recuperar senha
</Link>
</div>
{/* Submit */}
<button
type="submit"
disabled={loading}
className="w-full btn-primary py-4 text-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed group"
>
{loading ? (
<div className="flex items-center gap-3">
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Autenticando...
</div>
) : (
<div className="flex items-center gap-3">
<svg className="w-5 h-5 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
Acessar Plataforma
</div>
)}
</button>
</form>
{/* Divider */}
<div className="relative my-8">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-teal-500/20" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-4 text-gray-400 bg-[#111827]">ou acesse via</span>
</div>
</div>
{/* Social Login */}
<div className="grid grid-cols-2 gap-4">
<button
type="button"
onClick={() => signIn('google')}
className="flex items-center justify-center gap-3 p-3 rounded-xl bg-white/5 border border-teal-500/20 text-gray-300 hover:text-white hover:bg-white/10 hover:border-teal-500/30 transition-all duration-200"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="currentColor" 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="currentColor" 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="currentColor" 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>
Google
</button>
<button
type="button"
className="flex items-center justify-center gap-3 p-3 rounded-xl bg-white/5 border border-teal-500/20 text-gray-300 hover:text-white hover:bg-white/10 hover:border-teal-500/30 transition-all duration-200"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0C5.374 0 0 5.373 0 12s5.374 12 12 12 12-5.373 12-12S18.626 0 12 0zm5.568 8.16c-.13 1.416-.69 4.772-1.132 6.34-.199.717-.575 1.008-.944 1.033-.823.053-1.448-.544-2.247-1.067-1.256-.82-1.963-1.33-3.177-2.132-1.408-.93-.493-1.444.309-2.28.21-.22 3.857-3.537 3.929-3.84.009-.038.017-.18-.067-.255s-.2-.045-.287-.027c-.124.026-2.091 1.328-5.905 3.903-.558.384-1.064.571-1.518.561-.5-.011-1.461-.283-2.176-.515-.877-.284-1.574-.434-1.513-.917.032-.251.385-.508 1.058-.77 4.127-1.8 6.878-2.988 8.253-3.565 3.936-1.648 4.753-1.933 5.288-1.943.117-.003.378.027.547.165.143.116.182.272.201.383.02.111.044.362.025.559z"/>
</svg>
Telegram
</button>
</div>
</motion.div>
{/* Register Link */}
<motion.div
className="text-center mt-8"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6, delay: 0.4 }}
>
<p className="text-gray-400">
Ainda não possui cadastro?{' '}
<Link
href="/register"
className="font-semibold text-teal-400 hover:text-teal-300 transition-colors"
>
Criar conta gratuitamente
</Link>
</p>
</motion.div>
</motion.div>
</div>
)
}

499
src/app/page.tsx Normal file
View File

@@ -0,0 +1,499 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { motion, AnimatePresence } from 'framer-motion'
import { Scale, ArrowRight, Mail, Sparkles, Shield, FileSearch, Brain } from 'lucide-react'
/* ─── Particle Field ─── */
function Particles() {
const [particles, setParticles] = useState<{ id: number; x: number; y: number; size: number; duration: number; delay: number }[]>([])
useEffect(() => {
const pts = Array.from({ length: 60 }, (_, i) => ({
id: i,
x: Math.random() * 100,
y: Math.random() * 100,
size: Math.random() * 3 + 1,
duration: Math.random() * 20 + 15,
delay: Math.random() * -20,
}))
setParticles(pts)
}, [])
return (
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{particles.map((p) => (
<div
key={p.id}
className="absolute rounded-full bg-emerald-400/20 animate-float"
style={{
left: `${p.x}%`,
top: `${p.y}%`,
width: `${p.size}px`,
height: `${p.size}px`,
animationDuration: `${p.duration}s`,
animationDelay: `${p.delay}s`,
}}
/>
))}
</div>
)
}
/* ─── Feature Card ─── */
function FeatureCard({
icon: Icon,
emoji,
title,
desc,
delay,
}: {
icon: React.ComponentType<{ className?: string }>
emoji: string
title: string
desc: string
delay: number
}) {
return (
<motion.div
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-50px' }}
transition={{ duration: 0.7, delay, ease: [0.25, 0.4, 0.25, 1] }}
className="group relative p-8 rounded-3xl bg-white/[0.03] backdrop-blur-xl border border-white/[0.08] hover:border-emerald-500/30 transition-all duration-700 hover:-translate-y-2 hover:shadow-2xl hover:shadow-emerald-500/[0.05]"
>
<div className="absolute inset-0 rounded-3xl bg-gradient-to-br from-emerald-500/[0.05] to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700" />
<div className="relative">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-emerald-500/20 to-teal-500/10 border border-emerald-500/10 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-500">
<span className="text-2xl">{emoji}</span>
</div>
<h3 className="text-xl font-bold text-white mb-3">{title}</h3>
<p className="text-[15px] text-zinc-400 leading-relaxed">{desc}</p>
</div>
</motion.div>
)
}
/* ─── Main Page ─── */
export default function Home() {
const [email, setEmail] = useState('')
const [submitted, setSubmitted] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault()
if (email.trim()) {
setSubmitted(true)
}
},
[email]
)
return (
<main className="min-h-screen bg-[#040810] text-white overflow-hidden">
{/* ═══ CSS Animations ═══ */}
<style jsx global>{`
@keyframes float {
0%, 100% { transform: translateY(0px) translateX(0px); opacity: 0.2; }
25% { transform: translateY(-30px) translateX(10px); opacity: 0.6; }
50% { transform: translateY(-15px) translateX(-10px); opacity: 0.3; }
75% { transform: translateY(-40px) translateX(5px); opacity: 0.5; }
}
.animate-float { animation: float linear infinite; }
@keyframes gradient-shift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.animate-gradient {
background-size: 200% 200%;
animation: gradient-shift 8s ease infinite;
}
@keyframes pulse-ring {
0% { transform: scale(0.9); opacity: 1; }
100% { transform: scale(1.8); opacity: 0; }
}
.animate-pulse-ring {
animation: pulse-ring 2.5s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
}
@keyframes glow {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; }
}
.animate-glow { animation: glow 4s ease-in-out infinite; }
@keyframes scan-line {
0% { transform: translateY(-100%); }
100% { transform: translateY(100vh); }
}
.animate-scan {
animation: scan-line 8s linear infinite;
}
`}</style>
{/* ═══ NAVBAR ═══ */}
<nav className="fixed top-0 left-0 right-0 z-50 bg-[#040810]/60 backdrop-blur-2xl border-b border-white/[0.04]">
<div className="max-w-7xl mx-auto px-6 lg:px-8 h-16 flex items-center justify-between">
<Link href="/" className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-600 flex items-center justify-center shadow-lg shadow-emerald-500/20">
<Scale className="w-4 h-4 text-white" />
</div>
<span className="text-xl font-bold tracking-tight">
<span className="text-white">Lex</span>
<span className="text-emerald-400">Mind</span>
</span>
</Link>
<div className="flex items-center gap-3">
<Link
href="/login"
className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors px-4 py-2"
>
tem conta? Acessar
</Link>
<Link
href="/register"
className="hidden sm:block text-sm px-5 py-2 rounded-full border border-white/10 text-zinc-400 hover:text-white hover:border-white/20 transition-all"
>
Criar Conta
</Link>
</div>
</div>
</nav>
{/* ═══ HERO — Full Viewport ═══ */}
<section className="relative min-h-screen flex flex-col items-center justify-center px-6 pt-16">
{/* Background effects */}
<div className="absolute inset-0">
{/* Central mega glow */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] rounded-full bg-emerald-500/[0.04] blur-[200px] animate-glow" />
<div className="absolute top-1/3 left-[20%] w-[400px] h-[400px] rounded-full bg-teal-500/[0.03] blur-[150px] animate-glow" style={{ animationDelay: '-2s' }} />
<div className="absolute bottom-1/4 right-[15%] w-[300px] h-[300px] rounded-full bg-cyan-500/[0.03] blur-[120px] animate-glow" style={{ animationDelay: '-4s' }} />
{/* Scan line */}
<div className="absolute left-0 right-0 h-[1px] bg-gradient-to-r from-transparent via-emerald-500/20 to-transparent animate-scan" />
{/* Grid */}
<div
className="absolute inset-0 opacity-[0.02]"
style={{
backgroundImage: 'linear-gradient(rgba(255,255,255,0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.1) 1px, transparent 1px)',
backgroundSize: '60px 60px',
}}
/>
</div>
{mounted && <Particles />}
<div className="relative max-w-4xl mx-auto text-center z-10">
{/* "Em Breve" Badge */}
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.8, delay: 0.2 }}
className="inline-flex items-center gap-2.5 mb-10"
>
<div className="relative">
<div className="absolute inset-0 rounded-full bg-emerald-500/40 animate-pulse-ring" />
<div className="relative px-6 py-2.5 rounded-full bg-emerald-500/[0.1] border border-emerald-500/30 backdrop-blur-sm">
<span className="flex items-center gap-2 text-sm font-semibold text-emerald-300 tracking-wide">
<Sparkles className="w-4 h-4" />
EM BREVE
</span>
</div>
</div>
</motion.div>
{/* Headline */}
<motion.h1
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, delay: 0.4, ease: [0.25, 0.4, 0.25, 1] }}
className="text-5xl sm:text-6xl lg:text-8xl font-extrabold tracking-tight leading-[1.05] mb-8"
>
<span className="block text-white/90">O Direito nunca mais</span>
<span className="block bg-gradient-to-r from-emerald-300 via-teal-300 to-cyan-300 bg-clip-text text-transparent animate-gradient">
será o mesmo.
</span>
</motion.h1>
{/* Subheadline */}
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.7 }}
className="text-lg sm:text-xl text-zinc-400 max-w-2xl mx-auto mb-14 leading-relaxed"
>
Inteligência Artificial que entende a lei como você.
<br className="hidden sm:block" />
<span className="text-zinc-300">Mais rápido, mais preciso, mais inteligente.</span>
</motion.p>
{/* Email Form */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.9 }}
className="max-w-lg mx-auto"
>
<p className="text-sm text-zinc-500 mb-4 font-medium">Seja o primeiro a ter acesso</p>
<AnimatePresence mode="wait">
{!submitted ? (
<motion.form
key="form"
onSubmit={handleSubmit}
className="flex flex-col sm:flex-row gap-3"
exit={{ opacity: 0, scale: 0.95 }}
>
<div className="relative flex-1">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-600" />
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="seu@email.com.br"
className="w-full pl-12 pr-4 py-4 rounded-2xl bg-white/[0.05] border border-white/[0.08] text-white placeholder-zinc-600 focus:outline-none focus:border-emerald-500/50 focus:ring-2 focus:ring-emerald-500/20 transition-all text-[15px]"
/>
</div>
<button
type="submit"
className="group px-8 py-4 rounded-2xl bg-gradient-to-r from-emerald-500 to-teal-600 text-white font-bold text-[15px] transition-all hover:shadow-2xl hover:shadow-emerald-500/30 hover:-translate-y-0.5 active:translate-y-0 flex items-center justify-center gap-2 whitespace-nowrap"
>
Quero Acesso Antecipado
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-1" />
</button>
</motion.form>
) : (
<motion.div
key="thanks"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }}
className="py-5 px-8 rounded-2xl bg-emerald-500/[0.1] border border-emerald-500/30 text-center"
>
<p className="text-emerald-300 font-semibold text-lg">🎉 Obrigado!</p>
<p className="text-zinc-400 text-sm mt-1">Você será notificado assim que o LexMind estiver disponível.</p>
</motion.div>
)}
</AnimatePresence>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1.2 }}
className="text-xs text-zinc-600 mt-4 flex items-center justify-center gap-1.5"
>
<Shield className="w-3.5 h-3.5" />
Vagas limitadas para o lançamento
</motion.p>
</motion.div>
</div>
{/* Scroll indicator */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 2 }}
className="absolute bottom-10 left-1/2 -translate-x-1/2"
>
<div className="w-6 h-10 rounded-full border-2 border-white/10 flex items-start justify-center p-1.5">
<motion.div
animate={{ y: [0, 12, 0] }}
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut' }}
className="w-1.5 h-1.5 rounded-full bg-emerald-400/60"
/>
</div>
</motion.div>
</section>
{/* ═══ FEATURES PREVIEW ═══ */}
<section className="relative py-32 px-6">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[600px] h-[1px] bg-gradient-to-r from-transparent via-emerald-500/20 to-transparent" />
<div className="max-w-6xl mx-auto">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
className="text-center mb-20"
>
<p className="text-sm font-semibold text-emerald-400 mb-4 tracking-[0.2em] uppercase">
O que está por vir
</p>
<h2 className="text-4xl sm:text-5xl font-extrabold text-white mb-6">
Tecnologia que redefine<br />
<span className="bg-gradient-to-r from-emerald-300 to-teal-300 bg-clip-text text-transparent">
a prática jurídica
</span>
</h2>
</motion.div>
<div className="grid md:grid-cols-2 gap-6 max-w-4xl mx-auto">
<FeatureCard
icon={Brain}
emoji="🤖"
title="IA Jurídica Avançada"
desc="Peças processuais completas em menos de 30 segundos, com fundamentação técnica e jurisprudência atualizada."
delay={0}
/>
<FeatureCard
icon={FileSearch}
emoji="⚖️"
title="Jurisprudência Inteligente"
desc="Pesquisa semântica em milhares de decisões de todos os tribunais do Brasil. Encontre o precedente perfeito."
delay={0.15}
/>
<FeatureCard
icon={FileSearch}
emoji="📄"
title="Análise de Processos"
desc="Upload do PDF e receba parecer completo com legislação atualizada. Análise profunda em segundos."
delay={0.3}
/>
<FeatureCard
icon={Shield}
emoji="🔒"
title="Segurança Total"
desc="Seus dados protegidos com criptografia de ponta a ponta. Conformidade total com a LGPD."
delay={0.45}
/>
</div>
</div>
</section>
{/* ═══ SOCIAL PROOF ═══ */}
<section className="relative py-24 px-6">
<div className="max-w-4xl mx-auto">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
className="relative p-12 sm:p-16 rounded-[2rem] bg-gradient-to-b from-white/[0.04] to-white/[0.01] border border-white/[0.06] text-center overflow-hidden"
>
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[300px] h-[200px] rounded-full bg-emerald-500/[0.05] blur-[100px]" />
<div className="relative">
<div className="flex items-center justify-center gap-1 mb-8">
{Array.from({ length: 5 }).map((_, i) => (
<motion.div
key={i}
initial={{ opacity: 0, scale: 0 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ delay: i * 0.1 + 0.3 }}
className="text-2xl"
>
</motion.div>
))}
</div>
<p className="text-2xl sm:text-3xl font-bold text-white/90 mb-4 leading-snug">
&ldquo;A nova era do Direito está chegando.&rdquo;
</p>
<p className="text-zinc-400 text-lg mb-8">
Desenvolvido por especialistas em IA e Direito
</p>
<div className="flex flex-wrap items-center justify-center gap-8 text-sm text-zinc-500">
<span className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-emerald-500" />
Tecnologia de ponta
</span>
<span className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-teal-500" />
Escritórios inovadores
</span>
<span className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-cyan-500" />
Precisão jurídica
</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* ═══ SECOND CTA ═══ */}
<section className="relative py-32 px-6">
<div className="absolute inset-0">
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-[600px] h-[400px] rounded-full bg-emerald-500/[0.04] blur-[180px]" />
</div>
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
className="relative max-w-2xl mx-auto text-center"
>
<h2 className="text-3xl sm:text-4xl font-extrabold text-white mb-6">
Não fique de fora da revolução
</h2>
<p className="text-lg text-zinc-400 mb-10">
Garanta seu acesso antecipado e transforme sua prática jurídica com inteligência artificial.
</p>
<Link
href="/register"
className="group inline-flex items-center gap-3 px-10 py-5 rounded-full bg-gradient-to-r from-emerald-500 to-teal-600 text-white font-bold text-lg transition-all hover:shadow-2xl hover:shadow-emerald-500/30 hover:-translate-y-0.5"
>
Criar Conta
<ArrowRight className="w-5 h-5 transition-transform group-hover:translate-x-1" />
</Link>
</motion.div>
</section>
{/* ═══ FOOTER ═══ */}
<footer className="border-t border-white/[0.04] bg-[#030609] py-12 px-6">
<div className="max-w-6xl mx-auto">
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-gradient-to-br from-emerald-400 to-teal-600 flex items-center justify-center">
<Scale className="w-3 h-3 text-white" />
</div>
<span className="text-sm font-bold">
<span className="text-white">Lex</span>
<span className="text-emerald-400">Mind</span>
</span>
</div>
<div className="flex items-center gap-6 text-sm text-zinc-600">
<a href="/adv/termos" target="_blank" className="hover:text-zinc-400 transition-colors">Termos de Uso</a>
<span className="text-zinc-800">|</span>
<a href="/adv/privacidade" target="_blank" className="hover:text-zinc-400 transition-colors">Política de Privacidade</a>
</div>
<div className="flex items-center gap-4">
{/* Social placeholders */}
{['𝕏', 'in', '▶'].map((s, i) => (
<div
key={i}
className="w-8 h-8 rounded-full bg-white/[0.04] border border-white/[0.06] flex items-center justify-center text-xs text-zinc-600 hover:text-zinc-400 hover:border-white/10 transition-all cursor-pointer"
>
{s}
</div>
))}
</div>
</div>
<div className="mt-8 pt-6 border-t border-white/[0.04] text-center">
<p className="text-xs text-zinc-700">
© 2026 LexMind Tecnologia Jurídica Ltda. Todos os direitos reservados.
</p>
<div className="flex items-center justify-center gap-2 mt-3 opacity-40 hover:opacity-70 transition-opacity duration-300">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-teal-400"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
<span className="text-[0.65rem] text-teal-400/60 tracking-[0.15em] font-semibold">KISLANSKI INDUSTRIES</span>
</div>
</div>
</div>
</footer>
</main>
)
}

153
src/app/pecas/page.tsx Normal file
View File

@@ -0,0 +1,153 @@
import { Metadata } from 'next'
import Link from 'next/link'
import Navbar from '@/components/Navbar'
import Footer from '@/components/Footer'
import { FileText, Shield, ArrowUpRight, Scale, Handshake, ScrollText, ChevronRight } from 'lucide-react'
export const metadata: Metadata = {
title: 'Peças Processuais com IA | LexMind - Gere Documentos Jurídicos',
description: 'Gere peças processuais completas com inteligência artificial: petições iniciais, contestações, apelações, contratos e muito mais. Rápido, preciso e personalizado.',
keywords: 'peças processuais, petição inicial, contestação, apelação, contrato, IA jurídica, documentos jurídicos',
openGraph: {
title: 'Peças Processuais com IA | LexMind',
description: 'Gere peças processuais completas com inteligência artificial. Rápido, preciso e personalizado para advogados.',
type: 'website',
},
}
const documentTypes = [
{
title: 'Petição Inicial',
slug: 'peticao-inicial',
description: 'A peça que dá início ao processo judicial. Apresente os fatos, fundamentos jurídicos e pedidos de forma clara e persuasiva.',
icon: FileText,
color: 'from-teal-500 to-teal-600',
features: ['Qualificação das partes', 'Fundamentação jurídica', 'Pedidos específicos'],
},
{
title: 'Contestação',
slug: 'contestacao',
description: 'A defesa do réu contra a petição inicial. Impugne fatos, apresente preliminares e construa uma defesa sólida.',
icon: Shield,
color: 'from-teal-500 to-teal-700',
features: ['Preliminares processuais', 'Impugnação dos fatos', 'Teses defensivas'],
},
{
title: 'Apelação',
slug: 'apelacao',
description: 'Recurso contra sentença de primeiro grau. Demonstre os erros da decisão e busque a reforma no tribunal.',
icon: Scale,
color: 'from-teal-600 to-indigo-600',
features: ['Razões recursais', 'Demonstração do error in judicando', 'Pedido de reforma'],
},
{
title: 'Contrato',
slug: 'contrato',
description: 'Documentos contratuais completos e seguros juridicamente. Cláusulas claras, proteção para ambas as partes.',
icon: Handshake,
color: 'from-indigo-500 to-teal-600',
features: ['Cláusulas essenciais', 'Proteção jurídica', 'Conformidade legal'],
},
]
export default function PecasPage() {
return (
<>
<Navbar />
<main className="relative min-h-screen overflow-hidden">
{/* Background */}
<div className="fixed inset-0 bg-grid pointer-events-none z-0" />
<div className="fixed inset-0 bg-radial-purple pointer-events-none z-0" />
<div className="fixed top-1/4 -left-32 w-96 h-96 bg-teal-600/10 rounded-full blur-[128px] pointer-events-none" />
<div className="fixed top-3/4 -right-32 w-96 h-96 bg-teal-600/10 rounded-full blur-[128px] pointer-events-none" />
{/* Hero */}
<section className="relative z-10 pt-32 pb-20 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto text-center">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full glass mb-8">
<ScrollText className="w-4 h-4 text-teal-400" />
<span className="text-sm text-teal-300">Peças Processuais com IA</span>
</div>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold mb-6">
<span className="gradient-text">Todas as Peças Jurídicas</span>
<br />
<span className="text-white">em um lugar</span>
</h1>
<p className="text-lg sm:text-xl text-gray-400 max-w-3xl mx-auto mb-12">
Escolha o tipo de documento que você precisa e gere peças completas em minutos
com inteligência artificial treinada em milhares de processos reais.
</p>
</div>
</section>
{/* Grid de Peças */}
<section className="relative z-10 pb-24 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{documentTypes.map((doc) => {
const Icon = doc.icon
return (
<Link
key={doc.slug}
href={`/pecas/${doc.slug}`}
className="group relative glass rounded-2xl p-8 hover:border-teal-500/30 transition-all duration-300 hover:glow-purple"
>
<div className="flex items-start gap-5">
<div className={`w-14 h-14 rounded-xl bg-gradient-to-br ${doc.color} flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300`}>
<Icon className="w-7 h-7 text-white" />
</div>
<div className="flex-1">
<div className="flex items-center justify-between mb-3">
<h2 className="text-2xl font-bold text-white group-hover:gradient-text transition-all">
{doc.title}
</h2>
<ArrowUpRight className="w-5 h-5 text-gray-500 group-hover:text-teal-400 group-hover:translate-x-1 group-hover:-translate-y-1 transition-all" />
</div>
<p className="text-gray-400 mb-4 leading-relaxed">
{doc.description}
</p>
<ul className="space-y-2">
{doc.features.map((feature) => (
<li key={feature} className="flex items-center gap-2 text-sm text-gray-500">
<ChevronRight className="w-3 h-3 text-teal-500" />
{feature}
</li>
))}
</ul>
<div className="mt-6 inline-flex items-center gap-2 text-sm font-medium text-teal-400 group-hover:text-teal-300">
Gerar {doc.title} com IA
<ChevronRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</div>
</div>
</div>
</Link>
)
})}
</div>
</div>
</section>
{/* CTA Section */}
<section className="relative z-10 pb-24 px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto text-center glass rounded-2xl p-12">
<h2 className="text-3xl font-bold text-white mb-4">
Não encontrou o que procura?
</h2>
<p className="text-gray-400 mb-8 max-w-xl mx-auto">
Nossa IA pode gerar qualquer tipo de peça processual. Acesse o painel e descreva
o que você precisa nós cuidamos do resto.
</p>
<Link
href="/register"
className="inline-flex items-center gap-2 px-8 py-4 rounded-xl bg-gradient-to-r from-teal-600 to-teal-600 text-white font-semibold hover:glow-purple-strong transition-all duration-300"
>
Começar Gratuitamente
<ArrowUpRight className="w-5 h-5" />
</Link>
</div>
</section>
</main>
<Footer />
</>
)
}

504
src/app/pricing/page.tsx Normal file
View File

@@ -0,0 +1,504 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import {
Check,
X,
Zap,
Crown,
Building2,
Rocket,
FlaskConical,
ChevronDown,
Sparkles,
Shield,
Users,
Headphones,
FileText,
Search,
MessageSquare,
Layout,
Code,
GraduationCap,
Clock,
UserCheck,
Loader2,
} from 'lucide-react'
import Navbar from '@/components/Navbar'
import Footer from '@/components/Footer'
/* ------------------------------------------------------------------ */
/* Data */
/* ------------------------------------------------------------------ */
const PRICE_IDS = {
TESTE: 'price_1Sw6zbGq8gFtWtyfFMuknvQQ',
STARTER: 'price_1Sw6zbGq8gFtWtyf14b5v2iG',
PRO: 'price_1Sw6zcGq8gFtWtyfRGDo58Q7',
ENTERPRISE: 'price_1Sw6zcGq8gFtWtyfw9TNA7Ax',
}
const plans = [
{
id: 'free',
name: 'Gratuito',
icon: Zap,
monthlyPrice: 0,
description: 'Ideal para experimentar os recursos e avaliar a plataforma.',
features: [
'5 créditos por mês',
'Consultor IA básico',
'3 pesquisas por dia',
'Modelos essenciais',
],
cta: 'Experimentar Grátis',
ctaHref: '/register',
priceId: null,
highlighted: false,
},
{
id: 'starter',
name: 'Starter',
icon: Rocket,
monthlyPrice: 97,
description: 'Para advogados autônomos que precisam de mais recursos.',
features: [
'30 créditos por mês',
'Consultor IA avançado',
'Jurisprudência premium',
'Modelos personalizáveis',
'Atendimento por email',
],
cta: 'Assinar Starter',
ctaHref: null,
priceId: PRICE_IDS.STARTER,
highlighted: false,
},
{
id: 'pro',
name: 'Pro',
icon: Crown,
monthlyPrice: 197,
description: 'Para escritórios que exigem produtividade máxima.',
features: [
'100 créditos por mês',
'Consultor IA avançado',
'Jurisprudência premium',
'Modelos personalizáveis',
'Gestão de prazos',
'Atendimento prioritário',
],
cta: 'Assinar Pro',
ctaHref: null,
priceId: PRICE_IDS.PRO,
highlighted: true,
},
{
id: 'enterprise',
name: 'Enterprise',
icon: Building2,
monthlyPrice: 497,
description: 'Para operações jurídicas que exigem escala e governança.',
features: [
'Tudo do plano Pro',
'500 créditos por mês',
'Acesso à API',
'Até 10 operadores',
'SLA 99.9%',
'Gestor de conta dedicado',
],
cta: 'Assinar Enterprise',
ctaHref: null,
priceId: PRICE_IDS.ENTERPRISE,
highlighted: false,
},
]
const testPlan = {
id: 'teste',
name: 'Teste',
icon: FlaskConical,
monthlyPrice: 0.01,
description: 'Plano de teste para validar o fluxo de pagamento.',
features: [
'100 créditos (teste)',
'Todas as funcionalidades Pro',
'Apenas R$0,01/mês',
],
cta: 'Testar por R$0,01',
priceId: PRICE_IDS.TESTE,
}
type FeatureRow = {
label: string
icon: React.ElementType
free: string | boolean
starter: string | boolean
pro: string | boolean
enterprise: string | boolean
}
const comparisonFeatures: FeatureRow[] = [
{ label: 'Créditos mensais', icon: FileText, free: '5', starter: '30', pro: '100', enterprise: '500' },
{ label: 'Chat com IA', icon: MessageSquare, free: 'Básico', starter: 'Avançado', pro: 'Avançado', enterprise: 'Avançado' },
{ label: 'Busca jurisprudência', icon: Search, free: '3/dia', starter: 'Ilimitada', pro: 'Ilimitada', enterprise: 'Ilimitada' },
{ label: 'Modelos personalizáveis', icon: Layout, free: false, starter: true, pro: true, enterprise: true },
{ label: 'Gestão de prazos', icon: Clock, free: false, starter: false, pro: true, enterprise: true },
{ label: 'Atendimento prioritário', icon: Headphones, free: false, starter: false, pro: true, enterprise: true },
{ label: 'Integração via API', icon: Code, free: false, starter: false, pro: false, enterprise: true },
{ label: 'Multi-usuários', icon: Users, free: false, starter: false, pro: false, enterprise: 'Até 10' },
{ label: 'SLA garantido', icon: Shield, free: false, starter: false, pro: false, enterprise: '99.9%' },
{ label: 'Gerente de conta', icon: UserCheck, free: false, starter: false, pro: false, enterprise: true },
]
const faqs = [
{
q: 'É possível alterar meu plano a qualquer momento?',
a: 'Sim! Você pode fazer upgrade ou downgrade a qualquer momento. No upgrade, a diferença é cobrada proporcionalmente. No downgrade, o novo valor vale no próximo ciclo.',
},
{
q: 'Existe um período de avaliação gratuita?',
a: 'O plano Gratuito é permanente, sem necessidade de cartão de crédito. Para testar os planos pagos, use nosso plano Teste por apenas R$0,01.',
},
{
q: 'Quais meios de pagamento são aceitos?',
a: 'Aceitamos cartão de crédito (Visa, Mastercard, Elo, Amex) via Stripe, com total segurança.',
},
{
q: 'Há cláusula de permanência mínima?',
a: 'Não. Todos os planos são sem fidelidade. Você pode cancelar a qualquer momento pela sua área de gerenciamento.',
},
{
q: 'Como é garantida a segurança dos dados?',
a: 'Utilizamos criptografia AES-256, servidores no Brasil e estamos em conformidade total com a LGPD. Seus dados nunca são usados para treinar modelos de IA.',
},
{
q: 'O que acontece quando meus créditos acabam?',
a: 'Seus créditos são renovados automaticamente a cada ciclo de faturamento. Se acabarem antes, você pode fazer upgrade para um plano com mais créditos.',
},
]
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function formatPrice(price: number) {
if (price === 0) return { integer: '0', decimal: '' }
const str = price.toFixed(2).replace('.', ',')
const [integer, decimal] = str.split(',')
return { integer, decimal }
}
function CellValue({ value }: { value: string | boolean }) {
if (value === true) return <Check className="w-5 h-5 text-teal-400 mx-auto" />
if (value === false) return <X className="w-5 h-5 text-gray-600 mx-auto" />
return <span className="text-sm text-gray-200">{value}</span>
}
/* ------------------------------------------------------------------ */
/* Page */
/* ------------------------------------------------------------------ */
export default function PricingPage() {
const router = useRouter()
const [loadingPlan, setLoadingPlan] = useState<string | null>(null)
async function handleSubscribe(priceId: string, planId: string) {
setLoadingPlan(planId)
try {
const res = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId }),
})
if (res.status === 401) {
router.push('/register?plan=' + planId)
return
}
const data = await res.json()
if (data.url) {
window.location.href = data.url
} else {
alert(data.error || 'Erro ao iniciar checkout')
}
} catch (err) {
alert('Erro ao conectar com o servidor')
} finally {
setLoadingPlan(null)
}
}
return (
<>
<Navbar />
<main className="min-h-screen pt-24 pb-20">
{/* ---- Bg effects ---- */}
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[900px] h-[500px] bg-radial-teal opacity-60" />
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-[700px] h-[400px] bg-radial-bottom opacity-40" />
</div>
<div className="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* ---- Header ---- */}
<section className="text-center mb-16 animate-fade-in-up">
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full glass text-xs text-teal-300 mb-6">
<Sparkles className="w-3.5 h-3.5" />
Escolha o plano ideal para seu escritório
</div>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold mb-4">
<span className="gradient-text">Investimento transparente,</span>
<br />
<span className="text-white">resultados mensuráveis</span>
</h1>
<p className="text-gray-400 text-lg max-w-2xl mx-auto mb-10">
Encontre o plano certo para a operação do seu escritório. Valores claros, sem taxas ocultas. Liberdade total para cancelar.
</p>
</section>
{/* ---- Plan cards ---- */}
<section className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
{plans.map((plan, i) => {
const { integer, decimal } = formatPrice(plan.monthlyPrice)
const Icon = plan.icon
return (
<div
key={plan.id}
className={`relative rounded-2xl p-[1px] animate-fade-in-up ${
plan.highlighted
? 'bg-gradient-to-b from-teal-500 via-teal-600/60 to-teal-900/30'
: 'bg-gradient-to-b from-teal-500/20 to-transparent'
}`}
>
{plan.highlighted && (
<div className="absolute -top-3.5 left-1/2 -translate-x-1/2 z-10">
<span className="px-4 py-1 rounded-full text-xs font-semibold bg-gradient-to-r from-teal-500 to-teal-600 text-white shadow-[0_0_20px_rgba(20,184,166,0.5)]">
Recomendado
</span>
</div>
)}
<div
className={`rounded-[15px] h-full flex flex-col p-8 ${
plan.highlighted
? 'bg-gradient-to-b from-[#0a1f1a] to-[#070b14]'
: 'glass'
}`}
>
<div className="flex items-center gap-3 mb-4">
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center ${
plan.highlighted
? 'bg-teal-500/30 text-teal-300'
: 'bg-teal-500/10 text-teal-400'
}`}
>
<Icon className="w-5 h-5" />
</div>
<h3 className="text-xl font-bold text-white">{plan.name}</h3>
</div>
<p className="text-sm text-gray-400 mb-6">{plan.description}</p>
<div className="mb-8">
<div className="flex items-baseline gap-1">
<span className="text-sm text-gray-400">R$</span>
<span className="text-5xl font-extrabold text-white">{integer}</span>
{decimal && <span className="text-xl font-bold text-gray-300">,{decimal}</span>}
</div>
<span className="text-xs text-gray-500">
{plan.monthlyPrice === 0 ? 'grátis para sempre' : '/mês'}
</span>
</div>
<ul className="space-y-3 mb-8 flex-1">
{plan.features.map((feat) => (
<li key={feat} className="flex items-start gap-2.5 text-sm text-gray-300">
<Check className="w-4 h-4 text-teal-400 mt-0.5 shrink-0" />
{feat}
</li>
))}
</ul>
{plan.priceId ? (
<button
onClick={() => handleSubscribe(plan.priceId!, plan.id)}
disabled={loadingPlan === plan.id}
className={`block w-full py-3 rounded-xl text-sm font-semibold text-center transition-all disabled:opacity-70 ${
plan.highlighted
? 'bg-gradient-to-r from-teal-600 to-teal-500 text-white shadow-[0_0_30px_rgba(20,184,166,0.4)] hover:shadow-[0_0_40px_rgba(20,184,166,0.6)] hover:from-teal-500 hover:to-teal-400'
: 'glass text-teal-300 hover:bg-teal-500/15 hover:text-white'
}`}
>
{loadingPlan === plan.id ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Redirecionando...
</span>
) : (
plan.cta
)}
</button>
) : (
<Link
href={plan.ctaHref || '/register'}
className="block w-full py-3 rounded-xl text-sm font-semibold text-center transition-all glass text-teal-300 hover:bg-teal-500/15 hover:text-white"
>
{plan.cta}
</Link>
)}
</div>
</div>
)
})}
</section>
{/* ---- Test plan banner ---- */}
<section className="mb-28 animate-fade-in-up">
<div className="relative overflow-hidden rounded-2xl border border-amber-500/20 bg-gradient-to-r from-amber-950/30 via-[#111b27] to-amber-950/30 p-6 sm:p-8">
<div className="absolute -right-10 -top-10 h-40 w-40 rounded-full bg-amber-600/10 blur-3xl" />
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-amber-500/20 flex items-center justify-center">
<FlaskConical className="w-6 h-6 text-amber-400" />
</div>
<div>
<h3 className="text-lg font-bold text-white">Plano Teste R$0,01/mês</h3>
<p className="text-sm text-gray-400">
Quer testar o fluxo completo? Assine por apenas 1 centavo e ganhe 100 créditos Pro.
</p>
</div>
</div>
<button
onClick={() => handleSubscribe(testPlan.priceId, 'teste')}
disabled={loadingPlan === 'teste'}
className="shrink-0 inline-flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-semibold bg-amber-600/20 text-amber-300 border border-amber-500/30 hover:bg-amber-600/30 transition-all disabled:opacity-70"
>
{loadingPlan === 'teste' ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Redirecionando...
</>
) : (
<>
<FlaskConical className="w-4 h-4" />
Testar por R$0,01
</>
)}
</button>
</div>
</div>
</section>
{/* ---- Feature comparison table ---- */}
<section className="mb-28 animate-fade-in-up delay-400">
<h2 className="text-3xl font-bold text-center mb-2 gradient-text">Comparativo detalhado</h2>
<p className="text-gray-400 text-center mb-10">Analise cada recurso disponível por plano, lado a lado.</p>
<div className="overflow-x-auto rounded-2xl glass">
<table className="w-full text-left min-w-[700px]">
<thead>
<tr className="border-b border-teal-500/10">
<th className="py-4 px-6 text-sm text-gray-400 font-medium w-1/4">Funcionalidade</th>
<th className="py-4 px-6 text-sm text-gray-400 font-medium text-center">Gratuito</th>
<th className="py-4 px-6 text-sm text-gray-400 font-medium text-center">Starter</th>
<th className="py-4 px-6 text-sm font-medium text-center text-teal-300 bg-teal-500/5">Pro</th>
<th className="py-4 px-6 text-sm text-gray-400 font-medium text-center">Enterprise</th>
</tr>
</thead>
<tbody>
{comparisonFeatures.map((row, i) => {
const RowIcon = row.icon
return (
<tr
key={row.label}
className={`border-b border-teal-500/5 ${i % 2 === 0 ? '' : 'bg-teal-500/[0.02]'}`}
>
<td className="py-3.5 px-6 text-sm text-gray-300 flex items-center gap-2.5">
<RowIcon className="w-4 h-4 text-teal-500/50 shrink-0" />
{row.label}
</td>
<td className="py-3.5 px-6 text-center"><CellValue value={row.free} /></td>
<td className="py-3.5 px-6 text-center"><CellValue value={row.starter} /></td>
<td className="py-3.5 px-6 text-center bg-teal-500/5"><CellValue value={row.pro} /></td>
<td className="py-3.5 px-6 text-center"><CellValue value={row.enterprise} /></td>
</tr>
)
})}
</tbody>
</table>
</div>
</section>
{/* ---- Trust badges ---- */}
<section className="flex flex-wrap items-center justify-center gap-8 mb-28 animate-fade-in-up delay-500">
{[
{ icon: Shield, text: 'LGPD Compliant' },
{ icon: Clock, text: 'SLA 99.9%' },
{ icon: Users, text: '3.100+ escritórios' },
{ icon: Sparkles, text: '47k+ peças redigidas' },
].map((badge) => (
<div key={badge.text} className="flex items-center gap-2 text-sm text-gray-400">
<badge.icon className="w-4 h-4 text-teal-500" />
{badge.text}
</div>
))}
</section>
{/* ---- FAQ ---- */}
<section className="max-w-3xl mx-auto mb-20 animate-fade-in-up delay-600">
<h2 className="text-3xl font-bold text-center mb-2 gradient-text">Perguntas frequentes</h2>
<p className="text-gray-400 text-center mb-10">As dúvidas mais comuns sobre nossos planos.</p>
<div className="space-y-3">
{faqs.map((faq) => (
<details key={faq.q} className="group glass rounded-xl overflow-hidden">
<summary className="flex items-center justify-between px-6 py-4 text-sm font-medium text-white hover:bg-teal-500/5 transition-colors">
{faq.q}
<ChevronDown className="w-4 h-4 text-gray-500 faq-chevron shrink-0 ml-4" />
</summary>
<div className="faq-content px-6 pb-4 text-sm text-gray-400 leading-relaxed">
{faq.a}
</div>
</details>
))}
</div>
</section>
{/* ---- Bottom CTA ---- */}
<section className="text-center glass rounded-2xl p-12 mb-8 animate-fade-in-up delay-700">
<h2 className="text-2xl sm:text-3xl font-bold mb-3 text-white">
Pronto para modernizar sua operação jurídica?
</h2>
<p className="text-gray-400 mb-8 max-w-xl mx-auto">
Inicie sem custo e evolua conforme a demanda. Sem cartão, sem amarras.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link
href="/register"
className="px-8 py-3 rounded-xl text-sm font-semibold bg-gradient-to-r from-teal-600 to-teal-500 text-white shadow-[0_0_30px_rgba(20,184,166,0.4)] hover:shadow-[0_0_40px_rgba(20,184,166,0.6)] transition-all"
>
Experimentar Grátis
</Link>
<button
onClick={() => handleSubscribe(PRICE_IDS.PRO, 'pro')}
disabled={loadingPlan === 'pro-bottom'}
className="px-8 py-3 rounded-xl text-sm font-semibold glass text-teal-300 hover:bg-teal-500/15 hover:text-white transition-all"
>
Assinar Pro
</button>
</div>
</section>
</div>
</main>
<Footer />
</>
)
}

View File

@@ -0,0 +1,331 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { Logo } from '@/components/Logo'
import Footer from '@/components/Footer'
export const metadata: Metadata = {
title: 'Política de Privacidade — LexMind',
description: 'Política de Privacidade da plataforma LexMind, em conformidade com a LGPD.',
}
const sections = [
{ id: 'coleta', label: '1. Informações que Coletamos' },
{ id: 'uso', label: '2. Como Usamos seus Dados' },
{ id: 'base-legal', label: '3. Base Legal para Tratamento' },
{ id: 'armazenamento', label: '4. Armazenamento e Segurança' },
{ id: 'compartilhamento', label: '5. Compartilhamento de Dados' },
{ id: 'direitos', label: '6. Seus Direitos' },
{ id: 'cookies', label: '7. Cookies e Tecnologias' },
{ id: 'retencao', label: '8. Retenção de Dados' },
{ id: 'transferencia', label: '9. Transferência Internacional' },
{ id: 'menores', label: '10. Menores de Idade' },
{ id: 'alteracoes', label: '11. Alterações na Política' },
{ id: 'dpo', label: '12. Encarregado de Dados (DPO)' },
{ id: 'anpd', label: '13. ANPD' },
]
export default function PrivacidadePage() {
return (
<>
{/* Navbar */}
<nav className="fixed top-0 inset-x-0 z-50 bg-[#060a13]/80 backdrop-blur-xl border-b border-white/[0.06]">
<div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
<Link href="/">
<Logo size="sm" />
</Link>
<Link href="/register" className="text-sm font-medium text-teal-400 hover:text-teal-300 transition-colors">
Criar Conta
</Link>
</div>
</nav>
<main className="min-h-screen bg-[#060a13] pt-28 pb-20">
<div className="max-w-6xl mx-auto px-6">
{/* Header */}
<div className="mb-12">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-teal-500/10 border border-teal-500/20 text-teal-400 text-xs font-medium mb-4">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Privacidade &amp; LGPD
</div>
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">Política de Privacidade</h1>
<p className="text-gray-400 text-lg">Última atualização: Fevereiro de 2026</p>
</div>
<div className="flex flex-col lg:flex-row gap-12">
{/* Table of Contents */}
<aside className="lg:w-72 flex-shrink-0">
<div className="lg:sticky lg:top-24">
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4">Índice</h3>
<nav className="space-y-1">
{sections.map((s) => (
<a
key={s.id}
href={`#${s.id}`}
className="block text-sm text-gray-500 hover:text-teal-400 transition-colors py-1.5 pl-3 border-l border-white/[0.06] hover:border-teal-500/50"
>
{s.label}
</a>
))}
</nav>
</div>
</aside>
{/* Content */}
<article className="flex-1 min-w-0">
<p className="text-gray-400 leading-relaxed mb-8">
A <strong className="text-white">LexMind Tecnologia Jurídica Ltda.</strong> (&ldquo;LexMind&rdquo;, &ldquo;nós&rdquo;) está comprometida com a proteção dos seus dados pessoais. Esta Política de Privacidade descreve como coletamos, utilizamos, armazenamos e protegemos suas informações, em conformidade com a <strong className="text-white">Lei Geral de Proteção de Dados (Lei 13.709/2018 LGPD)</strong> e demais normas aplicáveis.
</p>
<Section id="coleta" title="1. Informações que Coletamos">
<h3>1.1 Dados Cadastrais</h3>
<ul>
<li>Nome completo, e-mail, telefone;</li>
<li>Número de inscrição na OAB e seccional;</li>
<li>Dados de faturamento para processamento de pagamentos.</li>
</ul>
<h3>1.2 Documentos Jurídicos</h3>
<ul>
<li>Peças processuais, contratos e documentos inseridos na plataforma para processamento;</li>
<li>Consultas realizadas ao consultor jurídico virtual;</li>
<li>Pesquisas de jurisprudência e seus resultados.</li>
</ul>
<h3>1.3 Dados de Uso</h3>
<ul>
<li>Endereço IP, tipo de navegador, sistema operacional;</li>
<li>Páginas visitadas, funcionalidades utilizadas, horários de acesso;</li>
<li>Dados de interação com a plataforma para melhoria dos serviços.</li>
</ul>
</Section>
<Section id="uso" title="2. Como Usamos seus Dados">
<p>Utilizamos seus dados para as seguintes finalidades:</p>
<ul>
<li><strong>Prestação do serviço:</strong> processamento de documentos, geração de peças jurídicas, pesquisa de jurisprudência;</li>
<li><strong>Gestão da conta:</strong> autenticação, comunicações transacionais, suporte ao cliente;</li>
<li><strong>Cobrança:</strong> processamento de pagamentos e emissão de notas fiscais;</li>
<li><strong>Melhoria do serviço:</strong> análise de uso agregado e anonimizado para aprimoramento da plataforma;</li>
<li><strong>Segurança:</strong> prevenção de fraudes, abusos e acessos não autorizados;</li>
<li><strong>Comunicação:</strong> envio de atualizações sobre o serviço, alterações nos termos e novidades (com opção de descadastramento);</li>
<li><strong>Cumprimento legal:</strong> atendimento a obrigações legais e regulatórias.</li>
</ul>
</Section>
<Section id="base-legal" title="3. Base Legal para Tratamento">
<p>O tratamento dos seus dados pessoais é realizado com fundamento nas seguintes bases legais previstas no <strong>Art. 7º da LGPD</strong>:</p>
<div className="overflow-x-auto my-4">
<table>
<thead>
<tr>
<th>Base Legal</th>
<th>Aplicação</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Execução de contrato</strong> (Art. 7º, V)</td>
<td>Prestação dos serviços contratados</td>
</tr>
<tr>
<td><strong>Consentimento</strong> (Art. 7º, I)</td>
<td>Envio de comunicações de marketing</td>
</tr>
<tr>
<td><strong>Legítimo interesse</strong> (Art. 7º, IX)</td>
<td>Melhoria dos serviços, segurança da plataforma</td>
</tr>
<tr>
<td><strong>Cumprimento de obrigação legal</strong> (Art. 7º, II)</td>
<td>Obrigações fiscais, regulatórias e judiciais</td>
</tr>
</tbody>
</table>
</div>
</Section>
<Section id="armazenamento" title="4. Armazenamento e Segurança">
<p>Adotamos medidas técnicas e organizacionais para proteger seus dados:</p>
<ul>
<li><strong>Infraestrutura:</strong> dados armazenados em servidores seguros da <strong>DigitalOcean</strong>, com redundância e backups regulares;</li>
<li><strong>Criptografia:</strong> dados em trânsito protegidos por TLS 1.3; dados sensíveis criptografados em repouso;</li>
<li><strong>Controle de acesso:</strong> acesso restrito a colaboradores autorizados, com autenticação multifator;</li>
<li><strong>Monitoramento:</strong> sistemas de detecção de intrusões e monitoramento contínuo;</li>
<li><strong>Senhas:</strong> armazenadas com hash seguro (bcrypt), nunca em texto puro.</li>
</ul>
<p>
Apesar de nossos esforços, nenhum sistema é 100% seguro. Em caso de incidente de segurança, notificaremos os afetados e a ANPD conforme exigido pela LGPD.
</p>
</Section>
<Section id="compartilhamento" title="5. Compartilhamento de Dados">
<p>Seus dados podem ser compartilhados com os seguintes terceiros, estritamente para as finalidades descritas:</p>
<div className="p-5 rounded-xl bg-white/[0.03] border border-white/[0.06] my-4">
<h4 className="text-white font-semibold mb-2">🤖 OpenAI (Processamento de IA)</h4>
<p className="text-sm">
Os dados inseridos na plataforma são enviados à API da OpenAI para processamento por modelos de inteligência artificial. <strong className="text-teal-400">Seus dados NÃO são utilizados pela OpenAI para treinamento de modelos.</strong> Utilizamos a API com a opção de &ldquo;zero data retention&rdquo; sempre que disponível.
</p>
</div>
<div className="p-5 rounded-xl bg-white/[0.03] border border-white/[0.06] my-4">
<h4 className="text-white font-semibold mb-2">💳 Stripe (Pagamentos)</h4>
<p className="text-sm">
Dados de pagamento são processados pelo Stripe, certificado PCI DSS Nível 1. A LexMind não armazena dados completos de cartão de crédito em seus servidores.
</p>
</div>
<p>Não vendemos, alugamos ou comercializamos seus dados pessoais. Outros compartilhamentos poderão ocorrer apenas:</p>
<ul>
<li>Por determinação judicial ou exigência legal;</li>
<li>Para proteger direitos, propriedade ou segurança da LexMind e seus usuários;</li>
<li>Com prestadores de serviço essenciais, mediante contratos de confidencialidade e proteção de dados.</li>
</ul>
</Section>
<Section id="direitos" title="6. Seus Direitos">
<p>Conforme o <strong>Art. 18 da LGPD</strong>, você tem direito a:</p>
<ul>
<li><strong>Confirmação e acesso:</strong> saber se tratamos seus dados e obter cópia;</li>
<li><strong>Correção:</strong> atualizar dados incompletos, inexatos ou desatualizados;</li>
<li><strong>Anonimização, bloqueio ou eliminação:</strong> de dados desnecessários ou tratados em desconformidade;</li>
<li><strong>Portabilidade:</strong> transferir seus dados a outro fornecedor, mediante requisição expressa;</li>
<li><strong>Eliminação:</strong> solicitar a exclusão de dados tratados com base no consentimento;</li>
<li><strong>Informação:</strong> sobre entidades com as quais compartilhamos seus dados;</li>
<li><strong>Revogação do consentimento:</strong> a qualquer momento, sem prejuízo do tratamento anterior;</li>
<li><strong>Oposição:</strong> ao tratamento realizado com base em legítimo interesse, se aplicável.</li>
</ul>
<p>
Para exercer seus direitos, entre em contato pelo e-mail{' '}
<a href="mailto:privacidade@lexmind.adv.br" className="text-teal-400 hover:text-teal-300 underline">privacidade@lexmind.adv.br</a>.
Responderemos em até 15 (quinze) dias úteis.
</p>
</Section>
<Section id="cookies" title="7. Cookies e Tecnologias">
<p>Utilizamos cookies e tecnologias similares para:</p>
<ul>
<li><strong>Cookies essenciais:</strong> necessários para o funcionamento da plataforma (autenticação, sessão);</li>
<li><strong>Cookies de desempenho:</strong> análise de uso agregado para melhoria da experiência;</li>
<li><strong>Cookies de funcionalidade:</strong> memorizar preferências do usuário.</li>
</ul>
<p>
Você pode gerenciar suas preferências de cookies nas configurações do navegador. A desativação de cookies essenciais poderá afetar o funcionamento da plataforma.
</p>
</Section>
<Section id="retencao" title="8. Retenção de Dados">
<ul>
<li><strong>Dados da conta:</strong> mantidos enquanto a conta estiver ativa e por 90 (noventa) dias após o cancelamento;</li>
<li><strong>Documentos gerados:</strong> mantidos enquanto a conta estiver ativa; após cancelamento, excluídos em até 90 dias;</li>
<li><strong>Dados de faturamento:</strong> mantidos por 5 (cinco) anos conforme legislação fiscal;</li>
<li><strong>Dados de uso e logs:</strong> mantidos por até 6 (seis) meses para fins de segurança;</li>
<li><strong>Dados anonimizados:</strong> podem ser mantidos indefinidamente para fins estatísticos.</li>
</ul>
<p>Ao término do período de retenção, os dados serão eliminados de forma segura.</p>
</Section>
<Section id="transferencia" title="9. Transferência Internacional">
<p>
Alguns dos nossos prestadores de serviço, como a <strong>OpenAI</strong>, estão localizados nos <strong>Estados Unidos da América</strong>. A transferência internacional de dados é realizada com as seguintes salvaguardas:
</p>
<ul>
<li>Cláusulas contratuais padrão de proteção de dados;</li>
<li>Avaliação do nível de proteção do país destinatário;</li>
<li>Garantia de que os dados serão tratados com nível de proteção compatível com a LGPD;</li>
<li>Utilização de APIs com opções de &ldquo;zero data retention&rdquo; quando disponíveis.</li>
</ul>
<p>
A transferência é fundamentada no Art. 33 da LGPD, em especial nos incisos II (cláusulas contratuais) e VIII (consentimento informado).
</p>
</Section>
<Section id="menores" title="10. Menores de Idade">
<p>
A plataforma LexMind é destinada exclusivamente a profissionais do Direito e maiores de 18 (dezoito) anos. <strong>Não coletamos intencionalmente dados de menores de idade.</strong>
</p>
<p>
Caso identifiquemos que dados de menor de idade foram coletados inadvertidamente, estes serão excluídos imediatamente. Se você tiver conhecimento de que um menor forneceu dados à plataforma, entre em contato conosco.
</p>
</Section>
<Section id="alteracoes" title="11. Alterações na Política">
<p>
Esta Política de Privacidade pode ser atualizada periodicamente. As alterações serão comunicadas por:
</p>
<ul>
<li>Notificação por e-mail;</li>
<li>Aviso destacado na plataforma;</li>
<li>Atualização da data nesta página.</li>
</ul>
<p>
Recomendamos a revisão periódica desta página. O uso continuado da plataforma após a publicação de alterações constitui ciência e concordância.
</p>
</Section>
<Section id="dpo" title="12. Encarregado de Dados (DPO) e Contato">
<p>
Nos termos do Art. 41 da LGPD, indicamos o seguinte Encarregado pelo Tratamento de Dados Pessoais:
</p>
<div className="p-5 rounded-xl bg-white/[0.03] border border-white/[0.06] my-4">
<ul className="space-y-2 list-none pl-0">
<li className="flex items-center gap-3">
<span className="text-gray-500">Empresa:</span>
<span className="text-white">LexMind Tecnologia Jurídica Ltda.</span>
</li>
<li className="flex items-center gap-3">
<span className="text-gray-500">E-mail do DPO:</span>
<a href="mailto:privacidade@lexmind.adv.br" className="text-teal-400 hover:text-teal-300">privacidade@lexmind.adv.br</a>
</li>
<li className="flex items-center gap-3">
<span className="text-gray-500">E-mail geral:</span>
<a href="mailto:contato@lexmind.adv.br" className="text-teal-400 hover:text-teal-300">contato@lexmind.adv.br</a>
</li>
<li className="flex items-center gap-3">
<span className="text-gray-500">Website:</span>
<a href="https://lexmind.adv.br" className="text-teal-400 hover:text-teal-300">lexmind.adv.br</a>
</li>
</ul>
</div>
</Section>
<Section id="anpd" title="13. Autoridade Nacional de Proteção de Dados (ANPD)">
<p>
Caso entenda que o tratamento de seus dados pessoais viola a LGPD, você tem direito de apresentar petição à Autoridade Nacional de Proteção de Dados (ANPD):
</p>
<div className="p-5 rounded-xl bg-white/[0.03] border border-white/[0.06] my-4">
<ul className="space-y-2 list-none pl-0">
<li className="flex items-center gap-3">
<span className="text-gray-500">Website:</span>
<a href="https://www.gov.br/anpd" target="_blank" rel="noopener noreferrer" className="text-teal-400 hover:text-teal-300">www.gov.br/anpd</a>
</li>
<li className="flex items-center gap-3">
<span className="text-gray-500">Canal de denúncias:</span>
<span className="text-white">Peticionamento eletrônico no site da ANPD</span>
</li>
</ul>
</div>
<p>
Recomendamos que, antes de acionar a ANPD, entre em contato com nosso Encarregado de Dados para tentarmos resolver sua solicitação diretamente.
</p>
</Section>
</article>
</div>
</div>
</main>
<Footer />
</>
)
}
function Section({ id, title, children }: { id: string; title: string; children: React.ReactNode }) {
return (
<section id={id} className="mb-12 scroll-mt-24">
<h2 className="text-2xl font-bold text-white mb-4 pb-2 border-b border-white/[0.06]">{title}</h2>
<div className="text-gray-400 leading-relaxed space-y-4 [&_h3]:text-lg [&_h3]:font-semibold [&_h3]:text-gray-200 [&_h3]:mt-6 [&_h3]:mb-2 [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:space-y-2 [&_li]:text-gray-400 [&_strong]:text-white [&_table]:w-full [&_table]:text-sm [&_th]:text-left [&_th]:text-gray-300 [&_th]:font-semibold [&_th]:pb-3 [&_th]:border-b [&_th]:border-white/10 [&_td]:py-3 [&_td]:text-gray-400 [&_td]:border-b [&_td]:border-white/[0.04] [&_tr:last-child_td]:border-0">
{children}
</div>
</section>
)
}

512
src/app/register/page.tsx Normal file
View File

@@ -0,0 +1,512 @@
'use client'
import { useState, Suspense } from 'react'
import { signIn } from 'next-auth/react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { Logo } from '@/components/Logo'
import { motion } from 'framer-motion'
const BRAZILIAN_STATES = [
{ value: 'AC', label: 'Acre' },
{ value: 'AL', label: 'Alagoas' },
{ value: 'AP', label: 'Amapá' },
{ value: 'AM', label: 'Amazonas' },
{ value: 'BA', label: 'Bahia' },
{ value: 'CE', label: 'Ceará' },
{ value: 'DF', label: 'Distrito Federal' },
{ value: 'ES', label: 'Espírito Santo' },
{ value: 'GO', label: 'Goiás' },
{ value: 'MA', label: 'Maranhão' },
{ value: 'MT', label: 'Mato Grosso' },
{ value: 'MS', label: 'Mato Grosso do Sul' },
{ value: 'MG', label: 'Minas Gerais' },
{ value: 'PA', label: 'Pará' },
{ value: 'PB', label: 'Paraíba' },
{ value: 'PR', label: 'Paraná' },
{ value: 'PE', label: 'Pernambuco' },
{ value: 'PI', label: 'Piauí' },
{ value: 'RJ', label: 'Rio de Janeiro' },
{ value: 'RN', label: 'Rio Grande do Norte' },
{ value: 'RS', label: 'Rio Grande do Sul' },
{ value: 'RO', label: 'Rondônia' },
{ value: 'RR', label: 'Roraima' },
{ value: 'SC', label: 'Santa Catarina' },
{ value: 'SP', label: 'São Paulo' },
{ value: 'SE', label: 'Sergipe' },
{ value: 'TO', label: 'Tocantins' },
]
interface FormErrors {
name?: string
email?: string
oabNumber?: string
oabState?: string
phone?: string
password?: string
confirmPassword?: string
terms?: string
}
function RegisterContent() {
const router = useRouter()
const searchParams = useSearchParams()
const [form, setForm] = useState({
name: '',
email: searchParams.get('email') || '',
oabNumber: '',
oabState: '',
phone: '',
password: '',
confirmPassword: '',
acceptTerms: false,
})
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [errors, setErrors] = useState<FormErrors>({})
const [serverError, setServerError] = useState('')
const [loading, setLoading] = useState(false)
const updateField = (field: string, value: string | boolean) => {
setForm((prev) => ({ ...prev, [field]: value }))
setErrors((prev) => ({ ...prev, [field]: undefined }))
setServerError('')
}
const validate = (): boolean => {
const newErrors: FormErrors = {}
if (!form.name.trim()) newErrors.name = 'Nome é obrigatório'
if (!form.email.trim()) {
newErrors.email = 'Email é obrigatório'
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
newErrors.email = 'Email inválido'
}
if (form.oabNumber && !/^\d{3,7}$/.test(form.oabNumber.replace(/\D/g, ''))) {
newErrors.oabNumber = 'Número OAB inválido (3-7 dígitos)'
}
if (form.oabNumber && !form.oabState) {
newErrors.oabState = 'Selecione o estado da OAB'
}
if (form.phone && !/^\(?\d{2}\)?\s?\d{4,5}-?\d{4}$/.test(form.phone.replace(/\s/g, ''))) {
newErrors.phone = 'Telefone inválido'
}
if (!form.password) {
newErrors.password = 'Senha é obrigatória'
} else if (form.password.length < 8) {
newErrors.password = 'Senha deve ter pelo menos 8 caracteres'
}
if (form.password !== form.confirmPassword) {
newErrors.confirmPassword = 'Senhas não conferem'
}
if (!form.acceptTerms) {
newErrors.terms = 'Você deve aceitar os termos'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setServerError('')
if (!validate()) return
setLoading(true)
try {
const res = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: form.name.trim(),
email: form.email.trim().toLowerCase(),
password: form.password,
oabNumber: form.oabNumber || undefined,
oabState: form.oabState || undefined,
phone: form.phone || undefined,
plan: searchParams.get('plan') || 'free',
}),
})
const data = await res.json()
if (!res.ok) {
setServerError(data.error || 'Erro ao criar conta')
setLoading(false)
return
}
// Auto sign-in after registration
const signInResult = await signIn('credentials', {
email: form.email.trim().toLowerCase(),
password: form.password,
redirect: false,
})
if (signInResult?.error) {
// Account created but sign-in failed — redirect to login
router.push('/login')
} else {
router.push('/dashboard')
}
} catch {
setServerError('Erro ao criar conta. Tente novamente.')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center px-4 py-12 relative overflow-hidden bg-gradient-to-br from-[#070b14] via-[#0a0f1a] to-[#070b14]">
{/* Background Effects */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-0 left-1/4 w-96 h-96 bg-teal-600/10 rounded-full blur-[120px] animate-float" />
<div className="absolute bottom-0 right-1/4 w-80 h-80 bg-cyan-500/10 rounded-full blur-[100px] animate-float-delayed" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-gradient-radial from-teal-500/5 to-transparent rounded-full" />
</div>
{/* Grid Pattern */}
<div className="fixed inset-0 bg-grid-teal pointer-events-none" />
<motion.div
className="w-full max-w-lg relative z-10"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: [0.4, 0, 0.2, 1] }}
>
{/* Logo */}
<motion.div
className="text-center mb-8"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6, delay: 0.1 }}
>
<Logo size="lg" className="justify-center mb-4" />
<p className="text-gray-400 text-sm font-medium">
Cadastre-se e descubra o que a inteligência artificial pode fazer pela sua advocacia
</p>
</motion.div>
{/* Registration Card */}
<motion.div
className="card-glow p-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<h2 className="text-2xl font-bold text-white mb-6">Criar Cadastro</h2>
{/* Plan Badge */}
{searchParams.get('plan') && (
<div className="mb-6 p-4 rounded-xl bg-gradient-to-r from-teal-500/10 to-cyan-500/10 border border-teal-500/20">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-teal-500 to-cyan-500 flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<h4 className="font-semibold text-white">Plano {searchParams.get('plan') === 'pro' ? 'Pro' : 'Enterprise'}</h4>
<p className="text-sm text-gray-400">
{searchParams.get('plan') === 'pro' ? 'R$ 97/mês' : 'R$ 297/mês'}
</p>
</div>
</div>
</div>
)}
{serverError && (
<motion.div
className="flex items-center gap-3 p-4 rounded-xl mb-6 bg-red-500/10 border border-red-500/20 text-red-400"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3 }}
>
<svg className="w-5 h-5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm font-medium">{serverError}</span>
</motion.div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Nome completo <span className="text-red-400">*</span>
</label>
<div className="relative">
<input
type="text"
value={form.name}
onChange={(e) => updateField('name', e.target.value)}
placeholder="Dr. João Silva"
required
className="input"
/>
</div>
{errors.name && <p className="text-xs mt-1 text-red-400">{errors.name}</p>}
</div>
{/* Email */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Email <span className="text-red-400">*</span>
</label>
<div className="relative">
<input
type="email"
value={form.email}
onChange={(e) => updateField('email', e.target.value)}
placeholder="seu@email.com"
required
className="input"
/>
</div>
{errors.email && <p className="text-xs mt-1 text-red-400">{errors.email}</p>}
</div>
{/* OAB Number + State */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Número OAB
</label>
<div className="relative">
<input
type="text"
value={form.oabNumber}
onChange={(e) => updateField('oabNumber', e.target.value)}
placeholder="123456"
className="input"
/>
</div>
{errors.oabNumber && <p className="text-xs mt-1 text-red-400">{errors.oabNumber}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Seccional
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none z-10">
<svg className="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<select
value={form.oabState}
onChange={(e) => updateField('oabState', e.target.value)}
className="input pl-12 pr-4 appearance-none cursor-pointer text-white"
>
<option value="" className="bg-gray-800">UF</option>
{BRAZILIAN_STATES.map((state) => (
<option key={state.value} value={state.value} className="bg-gray-800">
{state.value} - {state.label}
</option>
))}
</select>
<div className="absolute inset-y-0 right-0 flex items-center pr-4 pointer-events-none">
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
{errors.oabState && <p className="text-xs mt-1 text-red-400">{errors.oabState}</p>}
</div>
</div>
{/* Phone */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Telefone
</label>
<div className="relative">
<input
type="tel"
value={form.phone}
onChange={(e) => updateField('phone', e.target.value)}
placeholder="(11) 99999-9999"
className="input"
/>
</div>
{errors.phone && <p className="text-xs mt-1 text-red-400">{errors.phone}</p>}
</div>
{/* Password */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Senha <span className="text-red-400">*</span>
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={form.password}
onChange={(e) => updateField('password', e.target.value)}
placeholder="Mínimo 8 caracteres"
required
className="input pr-12"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-teal-400 transition-colors"
>
{showPassword ? (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
{errors.password && <p className="text-xs mt-1 text-red-400">{errors.password}</p>}
</div>
{/* Confirm Password */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Confirmar senha <span className="text-red-400">*</span>
</label>
<div className="relative">
<input
type={showConfirmPassword ? 'text' : 'password'}
value={form.confirmPassword}
onChange={(e) => updateField('confirmPassword', e.target.value)}
placeholder="Repita a senha"
required
className="input pr-12"
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-teal-400 transition-colors"
>
{showConfirmPassword ? (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
{errors.confirmPassword && <p className="text-xs mt-1 text-red-400">{errors.confirmPassword}</p>}
</div>
{/* Terms */}
<div>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={form.acceptTerms}
onChange={(e) => updateField('acceptTerms', e.target.checked)}
className="mt-1 w-4 h-4 rounded bg-white/5 border-teal-500/20 text-teal-600 focus:ring-teal-500/20 focus:ring-2"
/>
<span className="text-sm text-gray-400 leading-relaxed">
Ao se cadastrar, você concorda com nossos{' '}
<Link href="/termos" target="_blank" className="text-teal-400 hover:text-teal-300 underline">
Termos de Uso
</Link>{' '}
e{' '}
<Link href="/privacidade" target="_blank" className="text-teal-400 hover:text-teal-300 underline">
Política de Privacidade
</Link>
.
</span>
</label>
{errors.terms && <p className="text-xs mt-1 text-red-400">{errors.terms}</p>}
</div>
{/* Submit Button */}
<button
type="submit"
disabled={loading}
className="w-full btn-primary py-4 text-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed group"
>
{loading ? (
<div className="flex items-center gap-3">
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Processando cadastro...
</div>
) : (
<div className="flex items-center gap-3">
<svg className="w-5 h-5 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
Criar Cadastro Grátis
</div>
)}
</button>
</form>
{/* Free Features */}
{!searchParams.get('plan') || searchParams.get('plan') === 'free' ? (
<div className="mt-6 p-4 rounded-xl bg-gradient-to-r from-green-500/10 to-emerald-500/10 border border-green-500/20">
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-gradient-to-r from-green-500 to-emerald-500 flex items-center justify-center flex-shrink-0 mt-0.5">
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<h4 className="font-semibold text-white text-sm mb-1">Seu plano inicial contempla:</h4>
<ul className="text-xs text-gray-400 space-y-1">
<li> 5 documentos jurídicos por mês</li>
<li> 10 consultas de jurisprudência</li>
<li> Consultor IA básico</li>
<li> Acervo essencial de modelos</li>
</ul>
</div>
</div>
</div>
) : null}
</motion.div>
{/* Login Link */}
<motion.div
className="text-center mt-8"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6, delay: 0.4 }}
>
<p className="text-gray-400">
possui cadastro?{' '}
<Link
href="/login"
className="font-semibold text-teal-400 hover:text-teal-300 transition-colors"
>
Acessar plataforma
</Link>
</p>
</motion.div>
</motion.div>
</div>
)
}
export default function RegisterPage() {
return (
<Suspense fallback={<div className="min-h-screen bg-[#070b14]" />}>
<RegisterContent />
</Suspense>
)
}

301
src/app/termos/page.tsx Normal file
View File

@@ -0,0 +1,301 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { Logo } from '@/components/Logo'
import Footer from '@/components/Footer'
export const metadata: Metadata = {
title: 'Termos de Uso — LexMind',
description: 'Termos de Uso da plataforma LexMind de inteligência artificial jurídica.',
}
const sections = [
{ id: 'aceitacao', label: '1. Aceitação dos Termos' },
{ id: 'descricao', label: '2. Descrição do Serviço' },
{ id: 'cadastro', label: '3. Cadastro e Conta' },
{ id: 'planos', label: '4. Planos e Pagamento' },
{ id: 'uso', label: '5. Uso Aceitável' },
{ id: 'propriedade', label: '6. Propriedade Intelectual' },
{ id: 'limitacao', label: '7. Limitação de Responsabilidade' },
{ id: 'disponibilidade', label: '8. Disponibilidade do Serviço' },
{ id: 'cancelamento', label: '9. Cancelamento e Reembolso' },
{ id: 'modificacoes', label: '10. Modificações dos Termos' },
{ id: 'lei', label: '11. Lei Aplicável e Foro' },
{ id: 'contato', label: '12. Contato' },
]
export default function TermosPage() {
return (
<>
{/* Navbar */}
<nav className="fixed top-0 inset-x-0 z-50 bg-[#060a13]/80 backdrop-blur-xl border-b border-white/[0.06]">
<div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
<Link href="/">
<Logo size="sm" />
</Link>
<Link href="/register" className="text-sm font-medium text-teal-400 hover:text-teal-300 transition-colors">
Criar Conta
</Link>
</div>
</nav>
<main className="min-h-screen bg-[#060a13] pt-28 pb-20">
<div className="max-w-6xl mx-auto px-6">
{/* Header */}
<div className="mb-12">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-teal-500/10 border border-teal-500/20 text-teal-400 text-xs font-medium mb-4">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Documento Legal
</div>
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">Termos de Uso</h1>
<p className="text-gray-400 text-lg">Última atualização: Fevereiro de 2026</p>
</div>
<div className="flex flex-col lg:flex-row gap-12">
{/* Table of Contents - Sidebar */}
<aside className="lg:w-72 flex-shrink-0">
<div className="lg:sticky lg:top-24">
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4">Índice</h3>
<nav className="space-y-1">
{sections.map((s) => (
<a
key={s.id}
href={`#${s.id}`}
className="block text-sm text-gray-500 hover:text-teal-400 transition-colors py-1.5 pl-3 border-l border-white/[0.06] hover:border-teal-500/50"
>
{s.label}
</a>
))}
</nav>
</div>
</aside>
{/* Content */}
<article className="flex-1 min-w-0 prose-custom">
<p className="text-gray-400 leading-relaxed mb-8">
Bem-vindo à <strong className="text-white">LexMind</strong>. Estes Termos de Uso (&ldquo;Termos&rdquo;) regulam o acesso e a utilização da plataforma disponível em{' '}
<strong className="text-teal-400">lexmind.adv.br</strong>, operada por LexMind Tecnologia Jurídica Ltda. Ao acessar ou utilizar nossos serviços, você declara ter lido, compreendido e concordado integralmente com estes Termos.
</p>
<Section id="aceitacao" title="1. Aceitação dos Termos">
<p>
Ao criar uma conta, acessar ou utilizar qualquer funcionalidade da plataforma LexMind, você concorda em estar vinculado a estes Termos de Uso e à nossa{' '}
<Link href="/privacidade" className="text-teal-400 hover:text-teal-300 underline">
Política de Privacidade
</Link>
. Caso não concorde com qualquer disposição, não utilize a plataforma.
</p>
<p>
Você declara ser maior de 18 anos e possuir capacidade civil plena para celebrar este acordo, ou estar devidamente representado.
</p>
</Section>
<Section id="descricao" title="2. Descrição do Serviço">
<p>
A LexMind é uma plataforma de inteligência artificial jurídica (SaaS) que oferece ferramentas para profissionais do Direito, incluindo:
</p>
<ul>
<li><strong>Redação de Peças Processuais:</strong> elaboração assistida por IA de petições, contestações, recursos e pareceres;</li>
<li><strong>Consulta Jurídica Inteligente:</strong> consultor virtual baseado em legislação, doutrina e jurisprudência brasileira;</li>
<li><strong>Mineração de Jurisprudência:</strong> pesquisa semântica profunda em acórdãos de tribunais superiores e regionais;</li>
<li><strong>Gestão Inteligente de Prazos:</strong> monitoramento automatizado de prazos processuais com alertas;</li>
<li><strong>Auditoria de Contratos:</strong> análise automatizada de cláusulas, identificação de riscos e sugestões de redação;</li>
<li><strong>Acervo de Modelos:</strong> templates jurídicos especializados por área do direito.</li>
</ul>
</Section>
<Section id="cadastro" title="3. Cadastro e Conta">
<p>
Para acessar os recursos da plataforma, é necessário criar uma conta fornecendo informações verdadeiras, completas e atualizadas. Você é o único responsável por:
</p>
<ul>
<li>Manter a confidencialidade de suas credenciais de acesso (e-mail e senha);</li>
<li>Todas as atividades realizadas em sua conta;</li>
<li>Notificar imediatamente a LexMind sobre qualquer uso não autorizado de sua conta;</li>
<li>Manter seus dados cadastrais atualizados.</li>
</ul>
<p>
A LexMind reserva-se o direito de suspender ou encerrar contas que violem estes Termos ou que apresentem dados cadastrais falsos ou incompletos.
</p>
</Section>
<Section id="planos" title="4. Planos e Pagamento">
<p>A LexMind oferece os seguintes planos de assinatura:</p>
<div className="overflow-x-auto my-6">
<table>
<thead>
<tr>
<th>Plano</th>
<th>Valor Mensal</th>
<th>Descrição</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Starter</strong></td>
<td>R$ 97,00</td>
<td>Funcionalidades essenciais para advogados autônomos</td>
</tr>
<tr>
<td><strong>Pro</strong></td>
<td>R$ 197,00</td>
<td>Recursos avançados para escritórios em crescimento</td>
</tr>
<tr>
<td><strong>Enterprise</strong></td>
<td>R$ 497,00</td>
<td>Solução completa para grandes escritórios e departamentos jurídicos</td>
</tr>
</tbody>
</table>
</div>
<ul>
<li>As cobranças são <strong>recorrentes e mensais</strong>, processadas automaticamente via <strong>Stripe</strong>;</li>
<li>Os valores podem ser alterados mediante aviso prévio de 30 (trinta) dias;</li>
<li>A falta de pagamento poderá resultar em suspensão ou limitação do acesso;</li>
<li>Todos os valores são em Reais (BRL) e incluem os tributos aplicáveis.</li>
</ul>
</Section>
<Section id="uso" title="5. Uso Aceitável">
<p>Ao utilizar a plataforma, você se compromete a NÃO:</p>
<ul>
<li>Utilizar o serviço para fins ilegais, fraudulentos ou que violem legislação vigente;</li>
<li>Realizar engenharia reversa, descompilar ou tentar extrair o código-fonte da plataforma;</li>
<li>Enviar spam, conteúdo malicioso ou violar direitos de terceiros;</li>
<li>Compartilhar credenciais de acesso com terceiros não autorizados;</li>
<li>Sobrecarregar a infraestrutura da plataforma com uso abusivo ou automatizado;</li>
<li>Utilizar a plataforma para criar conteúdo que viole direitos autorais ou a ética profissional da OAB;</li>
<li>Copiar, reproduzir ou redistribuir funcionalidades da plataforma sem autorização expressa.</li>
</ul>
<p>A violação destas regras poderá resultar em suspensão imediata da conta, sem direito a reembolso.</p>
</Section>
<Section id="propriedade" title="6. Propriedade Intelectual">
<p>
Todos os direitos de propriedade intelectual da plataforma LexMind, incluindo software, design, logotipos, textos institucionais, algoritmos e modelos de IA, pertencem exclusivamente à LexMind Tecnologia Jurídica Ltda.
</p>
<p>
Os documentos e peças jurídicas gerados pela plataforma a partir dos dados fornecidos pelo usuário são de titularidade do próprio usuário. A LexMind não reivindica propriedade sobre o conteúdo produzido pelo usuário.
</p>
<p>
A LexMind poderá utilizar dados agregados e anonimizados para aprimoramento de seus serviços e modelos, conforme disposto na Política de Privacidade.
</p>
</Section>
<Section id="limitacao" title="7. Limitação de Responsabilidade">
<div className="p-5 rounded-xl bg-amber-500/10 border border-amber-500/20 my-6">
<div className="flex gap-3">
<svg className="w-6 h-6 text-amber-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<div>
<p className="text-amber-300 font-semibold mb-2">Aviso Importante</p>
<p className="text-amber-200/80 text-sm leading-relaxed">
A LexMind é uma <strong>ferramenta auxiliar</strong> e <strong>NÃO substitui</strong> o trabalho de um advogado devidamente inscrito na OAB. Todos os documentos gerados pela plataforma devem ser <strong>obrigatoriamente revisados por profissional habilitado</strong> antes de uso.
</p>
</div>
</div>
</div>
<ul>
<li>A LexMind <strong>não se responsabiliza</strong> por decisões jurídicas tomadas com base exclusiva no conteúdo gerado pela plataforma;</li>
<li>Os documentos gerados são sugestões e rascunhos que requerem revisão profissional;</li>
<li>A LexMind não garante resultados específicos em processos judiciais ou administrativos;</li>
<li>Em nenhuma hipótese a responsabilidade da LexMind excederá o valor total pago pelo usuário nos últimos 12 (doze) meses;</li>
<li>A LexMind não se responsabiliza por danos indiretos, incidentais, consequenciais ou punitivos.</li>
</ul>
</Section>
<Section id="disponibilidade" title="8. Disponibilidade do Serviço">
<p>
A LexMind emprega seus melhores esforços para manter a plataforma disponível 24 horas por dia, 7 dias por semana. Contudo, o serviço poderá ser temporariamente interrompido para:
</p>
<ul>
<li>Manutenções programadas (com aviso prévio sempre que possível);</li>
<li>Atualizações de segurança e melhorias no sistema;</li>
<li>Situações de força maior ou caso fortuito;</li>
<li>Instabilidades nos serviços de terceiros dos quais a plataforma depende.</li>
</ul>
<p>
A LexMind não será responsável por eventuais prejuízos decorrentes de indisponibilidade temporária.
</p>
</Section>
<Section id="cancelamento" title="9. Cancelamento e Reembolso">
<ul>
<li>O cancelamento da assinatura pode ser solicitado a qualquer momento pelo painel do usuário;</li>
<li>Após o cancelamento, o acesso aos recursos do plano será mantido até o final do período pago;</li>
<li>Não reembolso proporcional para cancelamentos realizados durante o período de faturamento;</li>
<li>A LexMind poderá oferecer reembolso integral em até 7 (sete) dias após a primeira contratação, conforme o Código de Defesa do Consumidor (Art. 49);</li>
<li>Dados do usuário serão mantidos por 90 (noventa) dias após o cancelamento, podendo ser exportados nesse período.</li>
</ul>
</Section>
<Section id="modificacoes" title="10. Modificações dos Termos">
<p>
A LexMind reserva-se o direito de alterar estes Termos a qualquer momento. As alterações serão comunicadas por:
</p>
<ul>
<li>Notificação por e-mail cadastrado;</li>
<li>Aviso destacado na plataforma;</li>
<li>Atualização da data de &ldquo;Última atualização&rdquo; nesta página.</li>
</ul>
<p>
O uso continuado da plataforma após a notificação de alterações constitui aceitação dos novos termos. Em caso de discordância, o usuário deverá cessar o uso e solicitar o cancelamento.
</p>
</Section>
<Section id="lei" title="11. Lei Aplicável e Foro">
<p>
Estes Termos de Uso são regidos pela legislação da República Federativa do Brasil, incluindo, mas não se limitando a:
</p>
<ul>
<li>Lei 12.965/2014 (Marco Civil da Internet);</li>
<li>Lei 13.709/2018 (Lei Geral de Proteção de Dados LGPD);</li>
<li>Lei 8.078/1990 (Código de Defesa do Consumidor).</li>
</ul>
<p>
Fica eleito o foro da Comarca de <strong>São Paulo/SP</strong> para dirimir quaisquer controvérsias decorrentes destes Termos, com renúncia expressa a qualquer outro, por mais privilegiado que seja.
</p>
</Section>
<Section id="contato" title="12. Contato">
<p>Para dúvidas, sugestões ou solicitações relacionadas a estes Termos, entre em contato:</p>
<div className="p-5 rounded-xl bg-white/[0.03] border border-white/[0.06] my-4">
<ul className="space-y-2 list-none pl-0">
<li className="flex items-center gap-3">
<span className="text-gray-500">Empresa:</span>
<span className="text-white">LexMind Tecnologia Jurídica Ltda.</span>
</li>
<li className="flex items-center gap-3">
<span className="text-gray-500">E-mail:</span>
<a href="mailto:contato@lexmind.adv.br" className="text-teal-400 hover:text-teal-300">contato@lexmind.adv.br</a>
</li>
<li className="flex items-center gap-3">
<span className="text-gray-500">Website:</span>
<a href="https://lexmind.adv.br" className="text-teal-400 hover:text-teal-300">lexmind.adv.br</a>
</li>
</ul>
</div>
</Section>
</article>
</div>
</div>
</main>
<Footer />
</>
)
}
function Section({ id, title, children }: { id: string; title: string; children: React.ReactNode }) {
return (
<section id={id} className="mb-12 scroll-mt-24">
<h2 className="text-2xl font-bold text-white mb-4 pb-2 border-b border-white/[0.06]">{title}</h2>
<div className="text-gray-400 leading-relaxed space-y-4 [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:space-y-2 [&_li]:text-gray-400 [&_strong]:text-white [&_table]:w-full [&_table]:text-sm [&_th]:text-left [&_th]:text-gray-300 [&_th]:font-semibold [&_th]:pb-3 [&_th]:border-b [&_th]:border-white/10 [&_td]:py-3 [&_td]:text-gray-400 [&_td]:border-b [&_td]:border-white/[0.04] [&_tr:last-child_td]:border-0">
{children}
</div>
</section>
)
}

View File

@@ -0,0 +1,290 @@
'use client'
import { useState, useRef, useCallback, useEffect } from 'react'
import {
Upload, X, FileText, File, Loader2, Trash2, Download,
AlertCircle, CheckCircle2, CloudUpload
} from 'lucide-react'
interface UploadedFile {
id: string
filename: string
size: number
mimeType: string
createdAt: string
}
interface FileUploadProps {
onFilesChange?: (files: UploadedFile[]) => void
maxFiles?: number
label?: string
compact?: boolean
}
const ALLOWED_EXTENSIONS = ['.pdf', '.docx', '.doc', '.txt']
const ALLOWED_TYPES = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain',
]
const MAX_SIZE = 50 * 1024 * 1024
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
function getFileIcon(mimeType: string) {
if (mimeType === 'application/pdf') return '📄'
if (mimeType.includes('word')) return '📝'
if (mimeType === 'text/plain') return '📃'
return '📎'
}
export default function FileUpload({
onFilesChange,
maxFiles = 10,
label = 'Documentos de Referência',
compact = false,
}: FileUploadProps) {
const [files, setFiles] = useState<UploadedFile[]>([])
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [dragOver, setDragOver] = useState(false)
const [deleting, setDeleting] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const dropRef = useRef<HTMLDivElement>(null)
// Load existing uploads on mount
useEffect(() => {
loadFiles()
}, [])
useEffect(() => {
onFilesChange?.(files)
}, [files, onFilesChange])
const loadFiles = async () => {
try {
const res = await fetch('/api/uploads?limit=50')
if (res.ok) {
const data = await res.json()
setFiles(data.uploads || [])
}
} catch {
// silent
}
}
const validateFile = (file: File): string | null => {
if (!ALLOWED_TYPES.includes(file.type)) {
const ext = file.name.split('.').pop()?.toLowerCase()
if (!ext || !ALLOWED_EXTENSIONS.includes('.' + ext)) {
return `Tipo não permitido: ${file.name}. Aceitos: PDF, DOCX, DOC, TXT`
}
}
if (file.size > MAX_SIZE) {
return `Arquivo muito grande: ${file.name} (${formatSize(file.size)}). Máx: 50MB`
}
return null
}
const uploadFile = useCallback(async (file: File) => {
setError(null)
const validationError = validateFile(file)
if (validationError) {
setError(validationError)
return
}
if (files.length >= maxFiles) {
setError(`Máximo de ${maxFiles} arquivos`)
return
}
setUploading(true)
setUploadProgress(`Enviando ${file.name}...`)
try {
const formData = new FormData()
formData.append('file', file)
const res = await fetch('/api/uploads', {
method: 'POST',
body: formData,
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || 'Erro ao fazer upload')
}
setFiles(prev => [data.upload, ...prev])
} catch (err) {
setError(err instanceof Error ? err.message : 'Erro ao fazer upload')
} finally {
setUploading(false)
setUploadProgress(null)
}
}, [files.length, maxFiles])
const handleFiles = useCallback((fileList: FileList | null) => {
if (!fileList) return
Array.from(fileList).forEach(f => uploadFile(f))
}, [uploadFile])
const handleDelete = async (id: string) => {
setDeleting(id)
try {
const res = await fetch(`/api/uploads/${id}`, { method: 'DELETE' })
if (res.ok) {
setFiles(prev => prev.filter(f => f.id !== id))
}
} catch {
setError('Erro ao excluir arquivo')
} finally {
setDeleting(null)
}
}
const handleDownload = (id: string) => {
window.open(`/api/uploads/${id}`, '_blank')
}
// Drag & drop handlers
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragOver(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.currentTarget === dropRef.current) {
setDragOver(false)
}
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragOver(false)
handleFiles(e.dataTransfer.files)
}
return (
<div className="space-y-3">
{/* Drop Zone */}
<div
ref={dropRef}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={() => !uploading && fileInputRef.current?.click()}
className={`
relative border-2 border-dashed rounded-xl transition-all duration-200 cursor-pointer
${compact ? 'p-4' : 'p-6'}
${dragOver
? 'border-teal-400 bg-teal-500/10 shadow-lg shadow-teal-500/10'
: 'border-white/10 hover:border-teal-500/30 hover:bg-white/[0.02]'
}
${uploading ? 'pointer-events-none opacity-60' : ''}
`}
>
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.docx,.doc,.txt"
className="hidden"
onChange={e => handleFiles(e.target.files)}
/>
<div className="flex flex-col items-center gap-2 text-center">
{uploading ? (
<>
<Loader2 className="w-8 h-8 text-teal-400 animate-spin" />
<p className="text-sm text-teal-300">{uploadProgress}</p>
</>
) : (
<>
<CloudUpload className={`${compact ? 'w-6 h-6' : 'w-8 h-8'} ${dragOver ? 'text-teal-400' : 'text-white/20'}`} />
<div>
<p className={`${compact ? 'text-xs' : 'text-sm'} text-white/40`}>
{dragOver ? (
<span className="text-teal-300 font-medium">Solte o arquivo aqui</span>
) : (
<>Arraste arquivos ou <span className="text-teal-400 font-medium">clique para upload</span></>
)}
</p>
<p className="text-xs text-white/20 mt-1">PDF, DOCX, DOC, TXT · Máx 50MB</p>
</div>
</>
)}
</div>
</div>
{/* Error */}
{error && (
<div className="flex items-center gap-2 p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-sm text-red-400">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{error}</span>
<button onClick={() => setError(null)} className="ml-auto">
<X className="w-4 h-4" />
</button>
</div>
)}
{/* File List */}
{files.length > 0 && (
<div className="space-y-2">
{files.map(file => (
<div
key={file.id}
className="flex items-center gap-3 p-3 rounded-xl border border-white/5 bg-white/[0.02] group hover:bg-white/[0.04] transition-all"
>
<span className="text-lg flex-shrink-0">{getFileIcon(file.mimeType)}</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{file.filename}</p>
<p className="text-xs text-white/30">{formatSize(file.size)}</p>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); handleDownload(file.id) }}
className="p-1.5 rounded-lg text-white/40 hover:text-teal-400 hover:bg-teal-500/10 transition-all"
title="Download"
>
<Download className="w-4 h-4" />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(file.id) }}
disabled={deleting === file.id}
className="p-1.5 rounded-lg text-white/40 hover:text-red-400 hover:bg-red-500/10 transition-all disabled:opacity-50"
title="Excluir"
>
{deleting === file.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</button>
</div>
</div>
))}
</div>
)}
</div>
)
}

104
src/components/Footer.tsx Normal file
View File

@@ -0,0 +1,104 @@
import Link from "next/link"
import { Logo } from "./Logo"
const productLinks = [
{ label: "Redação de Peças", href: "/pecas" },
{ label: "Pesquisa Inteligente", href: "/jurisprudencia" },
{ label: "Consultor IA", href: "/chat" },
{ label: "Acervo de Modelos", href: "/templates" },
]
const resourceLinks = [
{ label: "Investimento", href: "/pricing" },
{ label: "Artigos", href: "/blog" },
{ label: "Suporte", href: "/help" },
{ label: "Disponibilidade", href: "/status" },
]
const legalLinks = [
{ label: "Termos de Serviço", href: "/termos" },
{ label: "Política de Privacidade", href: "/privacidade" },
{ label: "Conformidade LGPD", href: "/lgpd" },
]
export default function Footer() {
return (
<footer className="border-t border-white/[0.06] bg-[#060a12]">
<div className="max-w-6xl mx-auto px-6 py-16">
<div className="grid grid-cols-2 md:grid-cols-4 gap-10">
<div className="col-span-2 md:col-span-1">
<Link href="/" className="inline-block mb-5">
<Logo size="sm" />
</Link>
<p className="text-sm text-gray-500 leading-relaxed max-w-[240px]">
Tecnologia de ponta a serviço da advocacia brasileira. Precisão, agilidade e conformidade em cada documento.
</p>
</div>
<div>
<h4 className="text-sm font-semibold text-white mb-5">Soluções</h4>
<ul className="space-y-3">
{productLinks.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="text-sm text-gray-500 hover:text-teal-300 transition-colors duration-200"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
<div>
<h4 className="text-sm font-semibold text-white mb-5">Institucional</h4>
<ul className="space-y-3">
{resourceLinks.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="text-sm text-gray-500 hover:text-teal-300 transition-colors duration-200"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
<div>
<h4 className="text-sm font-semibold text-white mb-5">Jurídico</h4>
<ul className="space-y-3">
{legalLinks.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="text-sm text-gray-500 hover:text-teal-300 transition-colors duration-200"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
</div>
<div className="mt-14 pt-8 border-t border-white/[0.06] flex flex-col items-center gap-4">
<div className="flex flex-col md:flex-row items-center justify-between w-full gap-4">
<p className="text-xs text-gray-600">
© {new Date().getFullYear()} LexMind Tecnologia Jurídica Ltda. Todos os direitos reservados.
</p>
<p className="text-xs text-gray-600">Desenvolvido com propósito para o Direito brasileiro</p>
</div>
<div className="flex items-center gap-2 opacity-40 hover:opacity-70 transition-opacity duration-300 mt-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-teal-400">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
</svg>
<span className="text-[0.65rem] text-teal-400/60 tracking-[0.15em] font-semibold">KISLANSKI INDUSTRIES</span>
</div>
</div>
</div>
</footer>
)
}

34
src/components/Logo.tsx Normal file
View File

@@ -0,0 +1,34 @@
import { cn } from '@/lib/utils'
import Image from 'next/image'
interface LogoProps {
size?: 'sm' | 'md' | 'lg'
showText?: boolean
className?: string
}
export function Logo({ size = 'md', showText = true, className }: LogoProps) {
const sizes = {
sm: { icon: 28, text: 'text-base' },
md: { icon: 36, text: 'text-lg' },
lg: { icon: 48, text: 'text-2xl' },
}
const s = sizes[size]
return (
<div className={cn('flex items-center gap-2.5', className)}>
<Image src="/logo.svg" alt="LexMind" width={s.icon} height={s.icon} className="shrink-0" />
{showText && (
<span className={cn('font-bold tracking-tight', s.text)}>
<span className="text-white">Lex</span>
<span className="bg-gradient-to-r from-teal-400 to-cyan-400 bg-clip-text text-transparent">Mind</span>
</span>
)}
</div>
)
}
export function LogoIcon({ size = 'md', className }: Omit<LogoProps, 'showText'>) {
return <Logo size={size} showText={false} className={className} />
}

125
src/components/Navbar.tsx Normal file
View File

@@ -0,0 +1,125 @@
'use client'
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { Menu, X } from 'lucide-react'
import { Logo } from './Logo'
import { motion, AnimatePresence } from 'framer-motion'
const navLinks = [
{ label: 'Recursos', href: '#features' },
{ label: 'Metodologia', href: '#how-it-works' },
{ label: 'Depoimentos', href: '#testimonials' },
{ label: 'Investimento', href: '#pricing' },
{ label: 'Dúvidas', href: '#faq' },
]
export default function Navbar() {
const [open, setOpen] = useState(false)
const [scrolled, setScrolled] = useState(false)
useEffect(() => {
const handleScroll = () => setScrolled(window.scrollY > 20)
window.addEventListener('scroll', handleScroll, { passive: true })
return () => window.removeEventListener('scroll', handleScroll)
}, [])
return (
<motion.nav
initial={{ y: -100 }}
animate={{ y: 0 }}
transition={{ duration: 0.6, ease: 'easeOut' }}
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
scrolled
? 'bg-[#0a0f1a]/90 backdrop-blur-xl border-b border-white/[0.06] shadow-lg shadow-black/20'
: 'bg-transparent'
}`}
>
<div className="max-w-6xl mx-auto px-6">
<div className="flex items-center justify-between h-16">
<Link href="/" className="relative z-10">
<Logo size="sm" />
</Link>
<div className="hidden md:flex items-center gap-8">
{navLinks.map((link, i) => (
<motion.a
key={link.href}
href={link.href}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 + i * 0.05 }}
className="text-sm text-gray-400 hover:text-teal-300 transition-colors duration-200"
>
{link.label}
</motion.a>
))}
</div>
<div className="hidden md:flex items-center gap-3">
<Link
href="/login"
className="text-sm text-gray-400 hover:text-white transition-colors px-4 py-2 rounded-lg hover:bg-white/[0.04]"
>
Acessar
</Link>
<Link
href="/register"
className="text-sm px-5 py-2.5 rounded-lg bg-gradient-to-r from-teal-600 to-teal-500 hover:from-teal-500 hover:to-teal-400 text-white font-medium transition-all duration-200 shadow-md shadow-teal-600/20"
>
Iniciar Agora
</Link>
</div>
<button
className="md:hidden relative z-10 p-2 text-gray-400 hover:text-white transition-colors"
onClick={() => setOpen(!open)}
aria-label="Menu"
>
{open ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
</div>
</div>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="md:hidden overflow-hidden border-t border-white/[0.06] bg-[#0a0f1a]/98 backdrop-blur-xl"
>
<div className="px-6 py-4 space-y-1">
{navLinks.map((link) => (
<a
key={link.href}
href={link.href}
onClick={() => setOpen(false)}
className="block px-4 py-3 text-sm text-gray-400 hover:text-white rounded-lg hover:bg-white/[0.04] transition-colors"
>
{link.label}
</a>
))}
<hr className="border-white/[0.06] my-3" />
<Link
href="/login"
onClick={() => setOpen(false)}
className="block px-4 py-3 text-sm text-gray-400 hover:text-white"
>
Acessar
</Link>
<Link
href="/register"
onClick={() => setOpen(false)}
className="block px-4 py-3 text-sm font-medium text-center rounded-lg bg-gradient-to-r from-teal-600 to-teal-500 text-white mt-2"
>
Iniciar Agora
</Link>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.nav>
)
}

View File

@@ -0,0 +1,11 @@
"use client"
import { SessionProvider } from "next-auth/react"
interface ProvidersProps {
children: React.ReactNode
}
export default function Providers({ children }: ProvidersProps) {
return <SessionProvider basePath="/adv/api/auth">{children}</SessionProvider>
}

125
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,125 @@
import { NextAuthOptions } from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
import { prisma } from './prisma'
import bcrypt from 'bcryptjs'
declare module 'next-auth' {
interface Session {
user: {
id: string
name: string
email: string
role: string
plan: string
credits: number
oabNumber?: string | null
oabState?: string | null
avatar?: string | null
}
}
interface User {
id: string
name: string
email: string
role: string
plan: string
credits: number
oabNumber?: string | null
oabState?: string | null
avatar?: string | null
}
}
declare module 'next-auth/jwt' {
interface JWT {
id: string
role: string
plan: string
credits: number
oabNumber?: string | null
oabState?: string | null
}
}
export const authOptions: NextAuthOptions = {
providers: [
CredentialsProvider({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Senha', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
throw new Error('Email e senha são obrigatórios')
}
const user = await prisma.user.findUnique({
where: { email: credentials.email },
})
if (!user) {
throw new Error('Usuário não encontrado')
}
const isPasswordValid = await bcrypt.compare(credentials.password, user.password)
if (!isPasswordValid) {
throw new Error('Senha incorreta')
}
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
plan: user.plan,
credits: user.credits,
oabNumber: user.oabNumber,
oabState: user.oabState,
avatar: user.avatar,
}
},
}),
],
session: {
strategy: 'jwt',
maxAge: 7 * 24 * 60 * 60, // 7 days
},
callbacks: {
async jwt({ token, user, trigger, session }) {
if (user) {
token.id = user.id
token.role = user.role
token.plan = user.plan
token.credits = user.credits
token.oabNumber = user.oabNumber
token.oabState = user.oabState
}
// Allow session updates (e.g., after plan change)
if (trigger === 'update' && session) {
token.plan = session.plan ?? token.plan
token.credits = session.credits ?? token.credits
token.role = session.role ?? token.role
}
return token
},
async session({ session, token }) {
session.user.id = token.id
session.user.role = token.role
session.user.plan = token.plan
session.user.credits = token.credits
session.user.oabNumber = token.oabNumber
session.user.oabState = token.oabState
return session
},
},
pages: {
signIn: '/login',
error: '/login',
},
secret: process.env.NEXTAUTH_SECRET,
}

453
src/lib/diarios-service.ts Normal file
View File

@@ -0,0 +1,453 @@
// Service para Integração com Diários Oficiais (DataJud CNJ)
import { calcularPrazo } from './publicacoes-service'
// API Key pública do DataJud (CNJ)
const DATAJUD_API_KEY = 'cDZHYzlZa0JadVREZDJCendQbXY6SkJlTzNjLV9TRENyQk1RdnFKZGRQdw=='
const DATAJUD_BASE_URL = 'https://api-publica.datajud.cnj.jus.br'
// Mapeamento de tribunais para endpoints da API
const TRIBUNAL_ENDPOINTS: Record<string, string> = {
TJSP: 'api_publica_tjsp',
TJRJ: 'api_publica_tjrj',
TJMG: 'api_publica_tjmg',
TJRS: 'api_publica_tjrs',
TJPR: 'api_publica_tjpr',
TJSC: 'api_publica_tjsc',
TJBA: 'api_publica_tjba',
TJPE: 'api_publica_tjpe',
TJCE: 'api_publica_tjce',
TJDF: 'api_publica_tjdftj',
TJGO: 'api_publica_tjgo',
TJMA: 'api_publica_tjma',
TJPA: 'api_publica_tjpa',
TJMT: 'api_publica_tjmt',
TJMS: 'api_publica_tjms',
TJES: 'api_publica_tjes',
TJAL: 'api_publica_tjal',
TJAM: 'api_publica_tjam',
TJPB: 'api_publica_tjpb',
TJPI: 'api_publica_tjpi',
TJRN: 'api_publica_tjrn',
TJSE: 'api_publica_tjse',
TJTO: 'api_publica_tjto',
TJAC: 'api_publica_tjac',
TJAP: 'api_publica_tjap',
TJRO: 'api_publica_tjro',
TJRR: 'api_publica_tjrr',
TRF1: 'api_publica_trf1',
TRF2: 'api_publica_trf2',
TRF3: 'api_publica_trf3',
TRF4: 'api_publica_trf4',
TRF5: 'api_publica_trf5',
TRF6: 'api_publica_trf6',
STJ: 'api_publica_stj',
STF: 'api_publica_stf',
TST: 'api_publica_tst',
}
// Códigos de movimentos relevantes (tabela unificada CNJ)
const CODIGOS_PUBLICACAO = [92] // Publicação
const CODIGOS_INTIMACAO = [12265, 12021, 12022] // Intimação, Intimação eletrônica, etc
const CODIGOS_CITACAO = [14, 12037] // Citação
const CODIGOS_SENTENCA = [22, 848, 385] // Sentença, Sentença estrangeira homologada
const CODIGOS_ACORDAO = [217, 219] // Acórdão
const CODIGOS_DESPACHO = [11010, 11383] // Despacho, Ato ordinatório
// Keywords para detectar tipo de publicação pelo texto
const KEYWORDS_TIPO: Record<string, RegExp> = {
INTIMACAO: /\b(intima[çc][aã]o|fica\s+intimad[oa]|intim[ea][\-\s]se)\b/i,
CITACAO: /\b(cita[çc][aã]o|fica\s+citad[oa]|cit[ea][\-\s]se)\b/i,
SENTENCA: /\b(senten[çc]a|julgo\s+procedente|julgo\s+improcedente|julgo\s+(parcialmente\s+)?procedente)\b/i,
ACORDAO: /\b(ac[oó]rd[aã]o|acordam\s+os\s+desembargadores|acordam\s+os\s+ministros)\b/i,
DESPACHO: /\b(despacho|determino|defiro|indefiro|mero\s+expediente|ato\s+ordinat[oó]rio)\b/i,
}
export interface PublicacaoEncontrada {
dataPublicacao: Date
diario: string
conteudo: string
tipo: 'INTIMACAO' | 'CITACAO' | 'SENTENCA' | 'DESPACHO' | 'ACORDAO' | 'OUTROS'
prazoCalculado: Date
prazoTipo: string
fonte: 'DATAJUD' | 'DJE_TJSP' | 'ESAJ'
codigoMovimento?: number
}
export interface ProcessoBuscado {
id: string
numeroProcesso: string
tribunal: string
}
export interface ResultadoBusca {
sucesso: boolean
publicacoes: PublicacaoEncontrada[]
erro?: string
fonte: string
}
// ===== TIPOS PARA PROCESSO COMPLETO =====
export interface MovimentoCompleto {
codigo: number
nome: string
dataHora: Date
complementosTabelados?: Array<{ codigo: number; nome: string }>
}
export interface ProcessoCompleto {
numeroProcesso: string
classe?: { codigo: number; nome: string }
sistema?: { codigo: number; nome: string }
formato?: { codigo: number; nome: string }
tribunal: string
dataAjuizamento?: Date
grau?: string
nivelSigilo?: number
orgaoJulgador?: { codigo: number; nome: string }
assuntos?: Array<{ codigo: number; nome: string }>
movimentos?: MovimentoCompleto[]
dadosBrutos?: any
}
export interface ResultadoProcessoCompleto {
sucesso: boolean
dados?: ProcessoCompleto
erro?: string
}
// Remove formatação do número do processo (só dígitos)
function limparNumeroProcesso(numero: string): string {
return numero.replace(/\D/g, '')
}
// Detecta tipo de publicação baseado no código ou keywords
function detectarTipo(codigoMovimento: number, nomeMovimento: string): PublicacaoEncontrada['tipo'] {
// Primeiro, verificar pelo código
if (CODIGOS_INTIMACAO.includes(codigoMovimento)) return 'INTIMACAO'
if (CODIGOS_CITACAO.includes(codigoMovimento)) return 'CITACAO'
if (CODIGOS_SENTENCA.includes(codigoMovimento)) return 'SENTENCA'
if (CODIGOS_ACORDAO.includes(codigoMovimento)) return 'ACORDAO'
if (CODIGOS_DESPACHO.includes(codigoMovimento)) return 'DESPACHO'
// Se for publicação genérica, tentar detectar pelo nome
if (CODIGOS_PUBLICACAO.includes(codigoMovimento)) {
for (const [tipo, regex] of Object.entries(KEYWORDS_TIPO)) {
if (regex.test(nomeMovimento)) {
return tipo as PublicacaoEncontrada['tipo']
}
}
}
return 'OUTROS'
}
// ===== BUSCA PROCESSO COMPLETO NA API DATAJUD =====
export async function buscarProcessoCompleto(
numeroProcesso: string,
tribunal: string
): Promise<ResultadoProcessoCompleto> {
const endpoint = TRIBUNAL_ENDPOINTS[tribunal.toUpperCase()]
if (!endpoint) {
return {
sucesso: false,
erro: `Tribunal ${tribunal} não suportado na API DataJud`,
}
}
const numeroLimpo = limparNumeroProcesso(numeroProcesso)
const url = `${DATAJUD_BASE_URL}/${endpoint}/_search`
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `APIKey ${DATAJUD_API_KEY}`,
},
body: JSON.stringify({
size: 1,
query: {
match: {
numeroProcesso: numeroLimpo,
},
},
}),
})
if (!response.ok) {
const errorText = await response.text()
console.error(`[DataJud] Erro HTTP ${response.status}:`, errorText)
return {
sucesso: false,
erro: `HTTP ${response.status}: ${errorText.substring(0, 200)}`,
}
}
const data = await response.json()
const hits = data.hits?.hits || []
if (hits.length === 0) {
return {
sucesso: true,
dados: undefined,
erro: 'Processo não encontrado na API DataJud',
}
}
const processo = hits[0]._source
// Mapear movimentos com todos os dados
const movimentos: MovimentoCompleto[] = (processo.movimentos || []).map((m: any) => ({
codigo: m.codigo,
nome: m.nome || 'Movimentação sem nome',
dataHora: new Date(m.dataHora),
complementosTabelados: m.complementosTabelados || [],
}))
// Ordenar movimentos por data (mais recente primeiro)
movimentos.sort((a, b) => b.dataHora.getTime() - a.dataHora.getTime())
const dadosCompletos: ProcessoCompleto = {
numeroProcesso: processo.numeroProcesso,
classe: processo.classe,
sistema: processo.sistema,
formato: processo.formato,
tribunal: tribunal,
dataAjuizamento: processo.dataAjuizamento ? new Date(processo.dataAjuizamento) : undefined,
grau: processo.grau,
nivelSigilo: processo.nivelSigilo,
orgaoJulgador: processo.orgaoJulgador,
assuntos: processo.assuntos || [],
movimentos,
dadosBrutos: processo,
}
console.log(`[DataJud] Processo ${numeroProcesso}: ${movimentos.length} movimentos encontrados`)
return {
sucesso: true,
dados: dadosCompletos,
}
} catch (error) {
console.error(`[DataJud] Erro ao buscar ${numeroProcesso}:`, error)
return {
sucesso: false,
erro: error instanceof Error ? error.message : 'Erro desconhecido',
}
}
}
// Busca processo na API DataJud (versão que filtra publicações)
export async function buscarDataJud(
numeroProcesso: string,
tribunal: string
): Promise<ResultadoBusca> {
const endpoint = TRIBUNAL_ENDPOINTS[tribunal.toUpperCase()]
if (!endpoint) {
return {
sucesso: false,
publicacoes: [],
erro: `Tribunal ${tribunal} não suportado na API DataJud`,
fonte: 'DATAJUD',
}
}
const numeroLimpo = limparNumeroProcesso(numeroProcesso)
const url = `${DATAJUD_BASE_URL}/${endpoint}/_search`
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `APIKey ${DATAJUD_API_KEY}`,
},
body: JSON.stringify({
size: 1,
query: {
match: {
numeroProcesso: numeroLimpo,
},
},
}),
})
if (!response.ok) {
const errorText = await response.text()
console.error(`[DataJud] Erro HTTP ${response.status}:`, errorText)
return {
sucesso: false,
publicacoes: [],
erro: `HTTP ${response.status}: ${errorText.substring(0, 200)}`,
fonte: 'DATAJUD',
}
}
const data = await response.json()
const hits = data.hits?.hits || []
if (hits.length === 0) {
return {
sucesso: true,
publicacoes: [],
fonte: 'DATAJUD',
}
}
const processo = hits[0]._source
const movimentos = processo.movimentos || []
// Filtrar movimentos relevantes (Publicação, Intimação, Citação, Sentença, Despacho)
const codigosRelevantes = [
...CODIGOS_PUBLICACAO,
...CODIGOS_INTIMACAO,
...CODIGOS_CITACAO,
...CODIGOS_SENTENCA,
...CODIGOS_ACORDAO,
...CODIGOS_DESPACHO,
]
const movimentosRelevantes = movimentos.filter((m: any) =>
codigosRelevantes.includes(m.codigo) ||
m.nome?.toLowerCase().includes('publicação') ||
m.nome?.toLowerCase().includes('intimação') ||
m.nome?.toLowerCase().includes('citação') ||
m.nome?.toLowerCase().includes('sentença')
)
const publicacoes: PublicacaoEncontrada[] = movimentosRelevantes.map((mov: any) => {
const dataPublicacao = new Date(mov.dataHora)
const tipo = detectarTipo(mov.codigo, mov.nome || '')
const { prazoCalculado, prazoTipo } = calcularPrazo(dataPublicacao, tipo)
// Construir conteúdo descritivo
const complementos = mov.complementosTabelados?.map((c: any) => c.nome).join(', ') || ''
const orgao = mov.orgaoJulgador?.nome || ''
const conteudo = [
`${mov.nome || 'Movimentação'}`,
processo.classe?.nome ? `Classe: ${processo.classe.nome}` : '',
complementos ? `Complemento: ${complementos}` : '',
orgao ? `Órgão: ${orgao}` : '',
`Processo: ${numeroProcesso}`,
`Tribunal: ${tribunal}`,
].filter(Boolean).join('. ')
return {
dataPublicacao,
diario: 'DJe',
conteudo,
tipo,
prazoCalculado,
prazoTipo,
fonte: 'DATAJUD' as const,
codigoMovimento: mov.codigo,
}
})
// Ordenar por data decrescente
publicacoes.sort((a, b) => b.dataPublicacao.getTime() - a.dataPublicacao.getTime())
console.log(`[DataJud] Processo ${numeroProcesso}: ${publicacoes.length} publicações encontradas`)
return {
sucesso: true,
publicacoes,
fonte: 'DATAJUD',
}
} catch (error) {
console.error(`[DataJud] Erro ao buscar ${numeroProcesso}:`, error)
return {
sucesso: false,
publicacoes: [],
erro: error instanceof Error ? error.message : 'Erro desconhecido',
fonte: 'DATAJUD',
}
}
}
// Busca no DJe TJSP via scraping (backup)
export async function buscarDJeTJSP(numeroProcesso: string): Promise<ResultadoBusca> {
// TODO: Implementar scraping do DJe TJSP como backup
// Por enquanto, retorna vazio - pode ser implementado com puppeteer/cheerio
console.log(`[DJe TJSP] Scraping não implementado para ${numeroProcesso}`)
return {
sucesso: true,
publicacoes: [],
fonte: 'DJE_TJSP',
}
}
// Função principal que tenta todas as fontes
export async function buscarPublicacoesReais(
processo: ProcessoBuscado,
diasAtras: number = 30
): Promise<ResultadoBusca> {
console.log(`[Diarios] Buscando publicações para ${processo.numeroProcesso} (${processo.tribunal})`)
// 1. Tentar DataJud primeiro (fonte principal)
const resultadoDataJud = await buscarDataJud(processo.numeroProcesso, processo.tribunal)
if (resultadoDataJud.sucesso && resultadoDataJud.publicacoes.length > 0) {
// Filtrar apenas publicações dos últimos N dias
const dataLimite = new Date()
dataLimite.setDate(dataLimite.getDate() - diasAtras)
const publicacoesRecentes = resultadoDataJud.publicacoes.filter(
pub => pub.dataPublicacao >= dataLimite
)
return {
...resultadoDataJud,
publicacoes: publicacoesRecentes,
}
}
// 2. Se DataJud falhou ou não encontrou, tentar DJe TJSP (se for TJSP)
if (processo.tribunal.toUpperCase() === 'TJSP') {
const resultadoDJe = await buscarDJeTJSP(processo.numeroProcesso)
if (resultadoDJe.sucesso && resultadoDJe.publicacoes.length > 0) {
return resultadoDJe
}
}
// 3. Retornar resultado (mesmo vazio)
return resultadoDataJud
}
// Busca em lote com rate limiting
export async function buscarPublicacoesEmLote(
processos: ProcessoBuscado[],
delayMs: number = 500
): Promise<Map<string, ResultadoBusca>> {
const resultados = new Map<string, ResultadoBusca>()
for (let i = 0; i < processos.length; i++) {
const processo = processos[i]
console.log(`[Diarios] Processando ${i + 1}/${processos.length}: ${processo.numeroProcesso}`)
try {
const resultado = await buscarPublicacoesReais(processo)
resultados.set(processo.id, resultado)
} catch (error) {
console.error(`[Diarios] Erro no processo ${processo.numeroProcesso}:`, error)
resultados.set(processo.id, {
sucesso: false,
publicacoes: [],
erro: error instanceof Error ? error.message : 'Erro desconhecido',
fonte: 'DATAJUD',
})
}
// Rate limiting entre requisições
if (i < processos.length - 1) {
await new Promise(resolve => setTimeout(resolve, delayMs))
}
}
return resultados
}

15
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
export default prisma

Some files were not shown because too many files have changed in this diff Show More