Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83ce15bb36 | ||
|
|
dc98d5dccc | ||
|
|
053e180321 | ||
|
|
6ec29c7eef | ||
|
|
1ea381224d | ||
|
|
9e80aa1d70 | ||
|
|
74857bf106 | ||
|
|
0fee59082b | ||
|
|
331d50e677 | ||
|
|
00d0793dab | ||
|
|
fc310c0616 | ||
|
|
9ece6e88fe | ||
|
|
773172c63c | ||
|
|
86e4afb916 | ||
|
|
44db6195f6 | ||
|
|
a33fb2f544 | ||
|
|
f553114c06 |
14
.claude/settings.local.json
Normal file
14
.claude/settings.local.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(docker-compose up:*)",
|
||||||
|
"Bash(docker-compose ps:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(cat:*)",
|
||||||
|
"Bash(docker logs:*)",
|
||||||
|
"Bash(docker exec:*)",
|
||||||
|
"Bash(npx tsc:*)",
|
||||||
|
"Bash(docker-compose restart:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
36
.vscode/settings.json
vendored
Normal file
36
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
// ============================================
|
||||||
|
// CONFIGURAÇÕES TAILWIND CSS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
"tailwindCSS.validate": false, // DESATIVA validação para remover avisos chatos
|
||||||
|
"tailwindCSS.showPixelEquivalents": false,
|
||||||
|
|
||||||
|
// ⚠️ ATENÇÃO: AVISOS "suggestCanonicalClasses" SÃO BUGS DO PLUGIN
|
||||||
|
// O Tailwind CSS IntelliSense está bugado e sugere sintaxe ERRADA.
|
||||||
|
//
|
||||||
|
// ✅ Sintaxe CORRETA (Tailwind v4):
|
||||||
|
// - [var(--brand-color)] ← Use isso!
|
||||||
|
// - bg-gradient-to-r ← Use isso!
|
||||||
|
//
|
||||||
|
// ❌ Sintaxe ERRADA (sugestão bugada):
|
||||||
|
// - (--brand-color) ← NÃO funciona!
|
||||||
|
// - bg-linear-to-r ← NÃO funciona!
|
||||||
|
//
|
||||||
|
// Por isso desativamos a validação acima (tailwindCSS.validate: false)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CONFIGURAÇÕES CSS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
"css.validate": true,
|
||||||
|
"css.lint.unknownAtRules": "ignore",
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MELHORIAS NO EDITOR
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
"editor.quickSuggestions": {
|
||||||
|
"strings": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,7 +71,7 @@ AGGIOS-APP/
|
|||||||
│ └─ letsencrypt/
|
│ └─ letsencrypt/
|
||||||
│ └─ acme.json (auto-generated)
|
│ └─ acme.json (auto-generated)
|
||||||
│
|
│
|
||||||
├─ 📂 postgres/ ← PostgreSQL Setup (NOVO)
|
├─ 📂 backend/internal/data/postgres/ ← PostgreSQL Setup (NOVO)
|
||||||
│ └─ init-db.sql ✅ Initial schema
|
│ └─ init-db.sql ✅ Initial schema
|
||||||
│
|
│
|
||||||
├─ 📂 scripts/ ← Helper Scripts (NOVO)
|
├─ 📂 scripts/ ← Helper Scripts (NOVO)
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ aggios-app/
|
|||||||
│ ├─ dynamic/rules.yml
|
│ ├─ dynamic/rules.yml
|
||||||
│ └─ letsencrypt/
|
│ └─ letsencrypt/
|
||||||
│
|
│
|
||||||
├─ 📂 postgres/ .............................. PostgreSQL (NOVO)
|
├─ 📂 backend/internal/data/postgres/ ........ PostgreSQL (NOVO)
|
||||||
│ └─ init-db.sql
|
│ └─ init-db.sql
|
||||||
│
|
│
|
||||||
├─ 📂 scripts/ ............................... Scripts (NOVO)
|
├─ 📂 scripts/ ............................... Scripts (NOVO)
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ aggios-app/
|
|||||||
│ ├── dynamic/rules.yml # Dynamic routing rules
|
│ ├── dynamic/rules.yml # Dynamic routing rules
|
||||||
│ └── letsencrypt/ # Certificados (auto-gerado)
|
│ └── letsencrypt/ # Certificados (auto-gerado)
|
||||||
│
|
│
|
||||||
├── postgres/ # Inicialização PostgreSQL
|
├── backend/internal/data/postgres/ # Inicialização PostgreSQL
|
||||||
│ └── init-db.sql # Schema initial
|
│ └── init-db.sql # Schema initial
|
||||||
│
|
│
|
||||||
├── scripts/
|
├── scripts/
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ DOCKER:
|
|||||||
|
|
||||||
CONFIGURAÇÃO:
|
CONFIGURAÇÃO:
|
||||||
├─ YAML files: 2 (traefik.yml, rules.yml)
|
├─ YAML files: 2 (traefik.yml, rules.yml)
|
||||||
├─ SQL files: 1 (init-db.sql)
|
├─ SQL files: 1 (backend/internal/data/postgres/init-db.sql)
|
||||||
├─ .env example: 1
|
├─ .env example: 1
|
||||||
├─ Dockerfiles: 1
|
├─ Dockerfiles: 1
|
||||||
└─ Scripts: 2 (start-dev.sh, start-dev.bat)
|
└─ Scripts: 2 (start-dev.sh, start-dev.bat)
|
||||||
|
|||||||
529
1. docs/mapa-mental-projeto.md
Normal file
529
1. docs/mapa-mental-projeto.md
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
# 🧠 Mapa Mental - Projeto Aggios
|
||||||
|
|
||||||
|
## 📌 Visão Geral
|
||||||
|
**Aggios** é uma plataforma **SaaS multi-tenant** que gerencia agências digitais com controle centralizado, gestão de clientes, soluções integradas (CRM/ERP) e sistema de pagamento.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏛️ Arquitetura Geral
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ AGGIOS PLATFORM │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Super Admin Dashboard (dash.localhost) │ │
|
||||||
|
│ │ - Gerenciar todas as agências │ │
|
||||||
|
│ │ - Visualizar cadastros │ │
|
||||||
|
│ │ - Excluir/arquivar agências │ │
|
||||||
|
│ │ - Controle de planos e pagamentos │ │
|
||||||
|
│ └──────────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌────────────┼────────────┐ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ┌────────▼──┐ ┌─────▼────┐ ┌───▼────────┐ │
|
||||||
|
│ │ Agência A │ │ Agência B │ │ Agência N │ │
|
||||||
|
│ │ Subdomain │ │ Subdomain │ │ Subdomain │ │
|
||||||
|
│ │ A │ │ B │ │ N │ │
|
||||||
|
│ └─────┬─────┘ └──────┬────┘ └────┬───────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ┌─────▼──────┐ ┌─────▼──────┐ ┌─▼───────────┐ │
|
||||||
|
│ │CRM / ERP │ │CRM / ERP │ │CRM / ERP │ │
|
||||||
|
│ │Clientes │ │Clientes │ │Clientes │ │
|
||||||
|
│ │Soluções │ │Soluções │ │Soluções │ │
|
||||||
|
│ └────────────┘ └────────────┘ └─────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Sistema de Autenticação
|
||||||
|
|
||||||
|
### Níveis de Acesso
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ PERMISSÕES E ROLES │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ SUPERADMIN (admin@aggios.app) │
|
||||||
|
│ ├─ Gerenciar todas as agências │
|
||||||
|
│ ├─ Visualizar cadastros │
|
||||||
|
│ ├─ Excluir/arquivar agências │
|
||||||
|
│ ├─ Controlar planos │
|
||||||
|
│ └─ Gerenciar pagamentos │
|
||||||
|
│ │
|
||||||
|
│ ADMIN_AGENCIA (por agência) │
|
||||||
|
│ ├─ Gerenciar clientes próprios │
|
||||||
|
│ ├─ Acessar CRM/ERP │
|
||||||
|
│ ├─ Visualizar relatórios │
|
||||||
|
│ └─ Configurar agência │
|
||||||
|
│ │
|
||||||
|
│ CLIENTE (por agência) │
|
||||||
|
│ ├─ Visualizar próprios dados │
|
||||||
|
│ ├─ Acessar serviços contratados │
|
||||||
|
│ └─ Submeter solicitações │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fluxo de Login
|
||||||
|
|
||||||
|
```
|
||||||
|
Usuário acessa:
|
||||||
|
dash.localhost
|
||||||
|
↓
|
||||||
|
Detecta "dash" no hostname
|
||||||
|
↓
|
||||||
|
Busca localStorage (token + user)
|
||||||
|
↓
|
||||||
|
┌─ Token válido? → Redireciona para /superadmin
|
||||||
|
│
|
||||||
|
└─ Sem token? → Mostra /login
|
||||||
|
↓
|
||||||
|
Submete credenciais
|
||||||
|
↓
|
||||||
|
Backend valida contra DB
|
||||||
|
↓
|
||||||
|
┌─ Válido → Retorna JWT + user data
|
||||||
|
│ → Salva em localStorage
|
||||||
|
│ → Redireciona para /superadmin
|
||||||
|
│
|
||||||
|
└─ Inválido → Toast error
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏢 Estrutura de Tenants
|
||||||
|
|
||||||
|
### Multi-Tenant Model
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ TENANT (Agência) │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ID: UUID │
|
||||||
|
│ name: "Agência Ideal Pages" │
|
||||||
|
│ subdomain: "idealpages" │
|
||||||
|
│ domain: "idealpages.aggios.app" │
|
||||||
|
│ cnpj: "XX.XXX.XXX/XXXX-XX" │
|
||||||
|
│ razao_social: "Ideal Pages Ltda" │
|
||||||
|
│ status: active | inactive │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────┐ │
|
||||||
|
│ │ USERS (pertencentes ao tenant) │ │
|
||||||
|
│ ├─────────────────────────────────┤ │
|
||||||
|
│ │ - Admin (ADMIN_AGENCIA) │ │
|
||||||
|
│ │ - Operadores │ │
|
||||||
|
│ │ - Suporte │ │
|
||||||
|
│ │ - Clientes │ │
|
||||||
|
│ └─────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────┐ │
|
||||||
|
│ │ COMPANIES (clientes) │ │
|
||||||
|
│ ├─────────────────────────────────┤ │
|
||||||
|
│ │ - ID, CNPJ, email, telefone │ │
|
||||||
|
│ │ - Dados de contato │ │
|
||||||
|
│ │ - Status │ │
|
||||||
|
│ └─────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────┐ │
|
||||||
|
│ │ SOLUTIONS (CRM, ERP, etc) │ │
|
||||||
|
│ ├─────────────────────────────────┤ │
|
||||||
|
│ │ - Módulos disponíveis │ │
|
||||||
|
│ │ - Integrações │ │
|
||||||
|
│ │ - Configurações │ │
|
||||||
|
│ └─────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
```
|
||||||
|
Backend (Go)
|
||||||
|
├─ HTTP Server (net/http)
|
||||||
|
├─ JWT Authentication
|
||||||
|
├─ Password Hashing (Argon2)
|
||||||
|
├─ PostgreSQL (SQL direto, sem ORM)
|
||||||
|
├─ Redis (cache/sessions)
|
||||||
|
├─ MinIO (object storage)
|
||||||
|
└─ Middleware (CORS, Security, Rate Limit)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
```
|
||||||
|
Frontend (Next.js 14)
|
||||||
|
├─ Dashboard (Superadmin)
|
||||||
|
│ ├─ Listagem de agências
|
||||||
|
│ ├─ Detalhes/visualização
|
||||||
|
│ └─ Excluir/arquivar
|
||||||
|
│
|
||||||
|
├─ Portais de Agência
|
||||||
|
│ ├─ Login específico por subdomain
|
||||||
|
│ ├─ Dashboard da agência
|
||||||
|
│ ├─ Gerenciador de clientes (CRM)
|
||||||
|
│ ├─ ERP
|
||||||
|
│ └─ Integrações
|
||||||
|
│
|
||||||
|
└─ Site Institucional (aggios.app)
|
||||||
|
├─ Landing page
|
||||||
|
├─ Pricing/Planos
|
||||||
|
├─ Documentação
|
||||||
|
└─ Contato
|
||||||
|
```
|
||||||
|
|
||||||
|
### Infraestrutura
|
||||||
|
```
|
||||||
|
Docker Compose
|
||||||
|
├─ PostgreSQL 16 (DB)
|
||||||
|
├─ Redis 7 (Cache)
|
||||||
|
├─ MinIO (S3-compatible storage)
|
||||||
|
├─ Traefik (Reverse Proxy)
|
||||||
|
├─ Backend (Go)
|
||||||
|
├─ Dashboard (Next.js)
|
||||||
|
└─ Institucional (Next.js)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Banco de Dados
|
||||||
|
|
||||||
|
### Schema Principal
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ DATABASE SCHEMA │
|
||||||
|
├──────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ TENANTS │
|
||||||
|
│ ├─ id (UUID) │
|
||||||
|
│ ├─ name, subdomain, domain │
|
||||||
|
│ ├─ cnpj, razao_social │
|
||||||
|
│ ├─ email, phone, website, address │
|
||||||
|
│ ├─ description, industry │
|
||||||
|
│ ├─ is_active │
|
||||||
|
│ └─ timestamps (created_at, updated_at) │
|
||||||
|
│ ↑ │
|
||||||
|
│ └─── FK em USERS │
|
||||||
|
│ └─── FK em COMPANIES │
|
||||||
|
│ │
|
||||||
|
│ USERS │
|
||||||
|
│ ├─ id (UUID) │
|
||||||
|
│ ├─ tenant_id (FK → TENANTS) │
|
||||||
|
│ ├─ email (UNIQUE) │
|
||||||
|
│ ├─ password_hash │
|
||||||
|
│ ├─ first_name, last_name │
|
||||||
|
│ ├─ role (SUPERADMIN | ADMIN_AGENCIA | CLIENTE) │
|
||||||
|
│ ├─ is_active │
|
||||||
|
│ └─ timestamps │
|
||||||
|
│ │
|
||||||
|
│ REFRESH_TOKENS │
|
||||||
|
│ ├─ id (UUID) │
|
||||||
|
│ ├─ user_id (FK → USERS) │
|
||||||
|
│ ├─ token_hash │
|
||||||
|
│ ├─ expires_at │
|
||||||
|
│ └─ created_at │
|
||||||
|
│ │
|
||||||
|
│ COMPANIES (Clientes das agências) │
|
||||||
|
│ ├─ id (UUID) │
|
||||||
|
│ ├─ tenant_id (FK → TENANTS) │
|
||||||
|
│ ├─ cnpj (UNIQUE por tenant) │
|
||||||
|
│ ├─ razao_social, nome_fantasia │
|
||||||
|
│ ├─ email, telefone │
|
||||||
|
│ ├─ status │
|
||||||
|
│ ├─ created_by_user_id (FK → USERS) │
|
||||||
|
│ └─ timestamps │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Fluxo de Cadastro (Registro de Nova Agência)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. INICIO
|
||||||
|
│
|
||||||
|
├─ Usuário acessa: http://dash.localhost/cadastro
|
||||||
|
│
|
||||||
|
├─ Preenche formulário:
|
||||||
|
│ ├─ Nome fantasia
|
||||||
|
│ ├─ Razão social
|
||||||
|
│ ├─ CNPJ
|
||||||
|
│ ├─ Email comercial
|
||||||
|
│ ├─ Telefone
|
||||||
|
│ ├─ Website
|
||||||
|
│ ├─ Endereço completo
|
||||||
|
│ ├─ Cidade/Estado/CEP
|
||||||
|
│ ├─ Segmento (indústria)
|
||||||
|
│ ├─ Descrição
|
||||||
|
│ ├─ Email do admin da agência
|
||||||
|
│ └─ Senha inicial do admin
|
||||||
|
│
|
||||||
|
├─ Validação Frontend
|
||||||
|
│ ├─ Campos obrigatórios
|
||||||
|
│ ├─ Formato de email
|
||||||
|
│ ├─ Força de senha
|
||||||
|
│ └─ CNPJ válido?
|
||||||
|
│
|
||||||
|
├─ POST /api/admin/agencies/register (Backend)
|
||||||
|
│ │
|
||||||
|
│ ├─ Validação Backend (regras de negócio)
|
||||||
|
│ │
|
||||||
|
│ ├─ Transação DB:
|
||||||
|
│ │ ├─ Criar TENANT (gera UUID, subdomain)
|
||||||
|
│ │ ├─ Criar USER (ADMIN_AGENCIA)
|
||||||
|
│ │ ├─ Hash password (Argon2)
|
||||||
|
│ │ └─ Commit
|
||||||
|
│ │
|
||||||
|
│ └─ Retorna: {tenant_id, subdomain, access_url}
|
||||||
|
│
|
||||||
|
├─ Frontend recebe resposta
|
||||||
|
│ ├─ Exibe toast de sucesso
|
||||||
|
│ ├─ Salva dados temporários
|
||||||
|
│ └─ Redireciona para /superadmin
|
||||||
|
│
|
||||||
|
└─ FIM (Agência criada e pronta para uso)
|
||||||
|
└─ Acesso: {subdomain}.localhost/login
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 Funcionalidades por Módulo
|
||||||
|
|
||||||
|
### 🔷 Superadmin Dashboard
|
||||||
|
|
||||||
|
```
|
||||||
|
dash.localhost/superadmin
|
||||||
|
├─ Header
|
||||||
|
│ ├─ Logo Aggios
|
||||||
|
│ ├─ Título "Painel Administrativo"
|
||||||
|
│ ├─ Email do admin
|
||||||
|
│ └─ Botão Sair
|
||||||
|
│
|
||||||
|
├─ Stats (KPIs)
|
||||||
|
│ ├─ Total de agências
|
||||||
|
│ ├─ Agências ativas
|
||||||
|
│ ├─ Agências inativas
|
||||||
|
│ └─ (Expandível: faturamento, etc)
|
||||||
|
│
|
||||||
|
├─ Listagem de Agências
|
||||||
|
│ ├─ Tabela com:
|
||||||
|
│ │ ├─ Nome fantasia
|
||||||
|
│ │ ├─ Subdomain
|
||||||
|
│ │ ├─ Status (ativo/inativo)
|
||||||
|
│ │ ├─ Data de criação
|
||||||
|
│ │ └─ Ações (Ver detalhes, Deletar)
|
||||||
|
│ │
|
||||||
|
│ └─ Busca/Filtro
|
||||||
|
│
|
||||||
|
└─ Modal de Detalhes
|
||||||
|
├─ Seção: Dados da Agência
|
||||||
|
│ ├─ Nome fantasia, razão social
|
||||||
|
│ ├─ CNPJ, segmento
|
||||||
|
│ ├─ Descrição
|
||||||
|
│ └─ Status
|
||||||
|
│
|
||||||
|
├─ Seção: Endereço e Contato
|
||||||
|
│ ├─ Endereço, cidade, estado, CEP
|
||||||
|
│ ├─ Website
|
||||||
|
│ ├─ Email comercial
|
||||||
|
│ └─ Telefone
|
||||||
|
│
|
||||||
|
├─ Seção: Administrador
|
||||||
|
│ ├─ Nome do admin
|
||||||
|
│ ├─ Email do admin
|
||||||
|
│ ├─ Role
|
||||||
|
│ └─ Data de criação
|
||||||
|
│
|
||||||
|
└─ Botões
|
||||||
|
├─ Abrir painel da agência (link externo)
|
||||||
|
├─ Deletar agência
|
||||||
|
└─ Fechar
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔶 Dashboard da Agência (Em Desenvolvimento)
|
||||||
|
|
||||||
|
```
|
||||||
|
{subdomain}.localhost/dashboard
|
||||||
|
├─ Sidebar
|
||||||
|
│ ├─ Dashboard
|
||||||
|
│ ├─ Clientes (CRM)
|
||||||
|
│ ├─ Projetos
|
||||||
|
│ ├─ Financeiro (ERP)
|
||||||
|
│ ├─ Configurações
|
||||||
|
│ └─ Suporte
|
||||||
|
│
|
||||||
|
├─ Stats
|
||||||
|
│ ├─ Total de clientes
|
||||||
|
│ ├─ Projetos em andamento
|
||||||
|
│ ├─ Tarefas pendentes
|
||||||
|
│ └─ Faturamento
|
||||||
|
│
|
||||||
|
└─ Seções (em construção)
|
||||||
|
├─ CRM → Gerenciar clientes, pipeline, negociações
|
||||||
|
├─ ERP → Pedidos, estoque, NF, financeiro
|
||||||
|
├─ Projetos → Planejamento, execução, entrega
|
||||||
|
└─ Integrações → API, webhooks, automações
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔌 APIs Principais
|
||||||
|
|
||||||
|
### Autenticação
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/auth/login
|
||||||
|
Request: { email, password }
|
||||||
|
Response: { token, user: { id, email, name, role } }
|
||||||
|
|
||||||
|
POST /api/auth/change-password
|
||||||
|
Request: { old_password, new_password }
|
||||||
|
Response: { success: true }
|
||||||
|
|
||||||
|
POST /api/auth/logout
|
||||||
|
Request: {}
|
||||||
|
Response: { success: true }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agências (Superadmin)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/admin/agencies
|
||||||
|
Response: [{ id, name, subdomain, status, ... }]
|
||||||
|
|
||||||
|
POST /api/admin/agencies/register
|
||||||
|
Request: { name, cnpj, email, admin_email, admin_password, ... }
|
||||||
|
Response: { tenant_id, subdomain, access_url }
|
||||||
|
|
||||||
|
GET /api/admin/agencies/{id}
|
||||||
|
Response: { tenant, admin, access_url, ... }
|
||||||
|
|
||||||
|
DELETE /api/admin/agencies/{id}
|
||||||
|
Response: { success: true } | 204 No Content
|
||||||
|
|
||||||
|
PATCH /api/admin/agencies/{id}
|
||||||
|
Request: { status, ... }
|
||||||
|
Response: { tenant }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Empresas/Clientes
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/companies
|
||||||
|
Response: [{ id, cnpj, razao_social, email, ... }]
|
||||||
|
|
||||||
|
POST /api/companies/create
|
||||||
|
Request: { cnpj, razao_social, email, telefone, ... }
|
||||||
|
Response: { company }
|
||||||
|
|
||||||
|
GET /api/companies/{id}
|
||||||
|
Response: { company }
|
||||||
|
|
||||||
|
PUT /api/companies/{id}
|
||||||
|
Request: { razao_social, email, ... }
|
||||||
|
Response: { company }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Ciclo de Desenvolvimento Atual
|
||||||
|
|
||||||
|
### v1.1 (dev-1.1) - Em Progresso
|
||||||
|
|
||||||
|
- ✅ Reorganização do banco (init-db em backend/internal/data/postgres)
|
||||||
|
- ✅ Autenticação de login com redirect automático
|
||||||
|
- ✅ Aumento de rate limit em dev (30 tentativas/min)
|
||||||
|
- 🔄 Melhorias na UX do dashboard superadmin
|
||||||
|
- ⏳ Implementação de CRM (clientes, pipeline)
|
||||||
|
- ⏳ Implementação de ERP básico (pedidos, financeiro)
|
||||||
|
|
||||||
|
### Próximas Versões
|
||||||
|
|
||||||
|
- 📅 v1.2: Soft delete, auditoria, trilha de mudanças
|
||||||
|
- 📅 v1.3: Integrações externas (Zapier, Make, etc)
|
||||||
|
- 📅 v1.4: Sistema de pagamento (Stripe, PagSeguro)
|
||||||
|
- 📅 v2.0: Marketplace de templates/extensões
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Checklist de Implementação
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- [x] Setup inicial (config, database, middleware)
|
||||||
|
- [x] Autenticação (JWT, refresh tokens)
|
||||||
|
- [x] Repositórios (sem ORM, SQL direto)
|
||||||
|
- [x] Serviços (business logic)
|
||||||
|
- [x] Handlers (endpoints)
|
||||||
|
- [x] Rate limiting
|
||||||
|
- [ ] Soft delete & auditoria
|
||||||
|
- [ ] Logging estruturado
|
||||||
|
- [ ] Testes unitários
|
||||||
|
- [ ] Documentação de API
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- [x] Login com redirect automático
|
||||||
|
- [x] Dashboard superadmin (lista, detalhes, delete)
|
||||||
|
- [x] Site institucional
|
||||||
|
- [ ] Dashboard da agência (CRM base)
|
||||||
|
- [ ] Gestão de clientes
|
||||||
|
- [ ] Formulários avançados
|
||||||
|
- [ ] Testes e2e
|
||||||
|
|
||||||
|
### DevOps
|
||||||
|
- [x] Docker Compose com todos os serviços
|
||||||
|
- [x] Traefik reverse proxy
|
||||||
|
- [x] PostgreSQL com seed data
|
||||||
|
- [x] Redis e MinIO
|
||||||
|
- [ ] CI/CD pipeline
|
||||||
|
- [ ] Monitoramento
|
||||||
|
- [ ] Backup strategy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 Notas Importantes
|
||||||
|
|
||||||
|
### Por Que Sem ORM?
|
||||||
|
|
||||||
|
- Controle fino sobre queries
|
||||||
|
- Performance previsível
|
||||||
|
- Menos abstrações, mais explícito
|
||||||
|
- Facilita debugging
|
||||||
|
- Legível para new devs
|
||||||
|
|
||||||
|
**Trade-off:** Mais boilerplate de SQL, mas melhor para equipes experientes.
|
||||||
|
|
||||||
|
### Segurança
|
||||||
|
|
||||||
|
- JWT + Refresh tokens
|
||||||
|
- Password hashing (Argon2)
|
||||||
|
- Rate limiting (5 req/min em prod, 30 em dev)
|
||||||
|
- CORS configurado
|
||||||
|
- Security headers
|
||||||
|
- Input validation em frontend + backend
|
||||||
|
|
||||||
|
### Escalabilidade
|
||||||
|
|
||||||
|
- Multi-tenant isolado por tenant_id
|
||||||
|
- Índices em FK e campos frequentes
|
||||||
|
- Redis para cache de sessions
|
||||||
|
- MinIO para object storage
|
||||||
|
- Stateless backend (escalável horizontalmente)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Contatos & Referências
|
||||||
|
|
||||||
|
- **Repository:** https://git.stackbyte.cloud/erik/aggios.app.git
|
||||||
|
- **Documentação detalhada:** `/1. docs/backend-deployment/`
|
||||||
|
- **API Reference:** `/1. docs/backend-deployment/API_REFERENCE.md`
|
||||||
|
- **Deployment Guide:** `/1. docs/backend-deployment/DEPLOYMENT.md`
|
||||||
|
|
||||||
174
1. docs/mind-projeto-simples.md
Normal file
174
1. docs/mind-projeto-simples.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# Arquitetura Multi-tenant - Modelo de Negócio Aggios
|
||||||
|
|
||||||
|
## Visão Geral da Plataforma
|
||||||
|
|
||||||
|
A plataforma Aggios utiliza uma arquitetura multi-tenant em três camadas principais:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ aggios.app (Site Institucional) │
|
||||||
|
│ - Marketing │
|
||||||
|
│ - Cadastro de novas agências │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ dash.aggios.app (SuperAdmin) │
|
||||||
|
│ - Você (dono da plataforma) │
|
||||||
|
│ - Gerencia TODAS as agências │
|
||||||
|
│ - Vê analytics globais │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────┴───────────┐
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────────┐ ┌──────────────────┐
|
||||||
|
│ idealpages. │ │ outraagencia. │
|
||||||
|
│ aggios.app │ │ aggios.app │
|
||||||
|
├──────────────────┤ ├──────────────────┤
|
||||||
|
│ Painel da │ │ Painel da │
|
||||||
|
│ IdeaPages │ │ Outra Agência │
|
||||||
|
│ │ │ │
|
||||||
|
│ • CRM │ │ • CRM │
|
||||||
|
│ • ERP │ │ • ERP │
|
||||||
|
│ • Projetos │ │ • Projetos │
|
||||||
|
│ • White Label │ │ • White Label │
|
||||||
|
│ (seu logo) │ │ (logo deles) │
|
||||||
|
└──────────────────┘ └──────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
Clientes da Clientes da
|
||||||
|
IdeaPages Outra Agência
|
||||||
|
```
|
||||||
|
|
||||||
|
## Como Funciona na Prática
|
||||||
|
|
||||||
|
### 1. Sua Agência (Exemplo: IdeaPages)
|
||||||
|
- **URL**: `idealpages.aggios.app`
|
||||||
|
- **White Label**: Logo e cores da IdeaPages
|
||||||
|
- **Clientes**: Cadastrados DENTRO da agência IdeaPages
|
||||||
|
- **Isolamento**: Cada cliente é isolado por tenant_id (multi-tenant)
|
||||||
|
|
||||||
|
### 2. Quando um Cliente Precisa do CRM
|
||||||
|
|
||||||
|
**Você SEMPRE manda a URL da sua agência**, não aggios.app!
|
||||||
|
|
||||||
|
- Cliente cria conta em `idealpages.aggios.app`
|
||||||
|
- Cliente acessa `idealpages.aggios.app` com login próprio
|
||||||
|
- Cliente vê **SEU logo** (IdeaPages)
|
||||||
|
- Cliente vê **SEU white label**
|
||||||
|
- Cliente só vê os dados DELE (isolamento por tenant)
|
||||||
|
|
||||||
|
### 3. Estrutura de Clientes
|
||||||
|
|
||||||
|
```
|
||||||
|
IdeaPages (você - agência)
|
||||||
|
├── Cliente 1 (Empresa ABC)
|
||||||
|
│ ├── Vê: Logo IdeaPages
|
||||||
|
│ ├── Acessa: idealpages.aggios.app
|
||||||
|
│ └── Usa: CRM, ERP, Projetos (dados isolados)
|
||||||
|
│
|
||||||
|
├── Cliente 2 (Tech Solutions)
|
||||||
|
│ ├── Vê: Logo IdeaPages
|
||||||
|
│ ├── Acessa: idealpages.aggios.app
|
||||||
|
│ └── Usa: CRM, ERP, Projetos (dados isolados)
|
||||||
|
│
|
||||||
|
└── Cliente 3 (Marketing Pro)
|
||||||
|
├── Vê: Logo IdeaPages
|
||||||
|
├── Acessa: idealpages.aggios.app
|
||||||
|
└── Usa: CRM, ERP, Projetos (dados isolados)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefícios para a Agência
|
||||||
|
|
||||||
|
✅ **White Label Completo**: Cliente vê sua marca, não "Aggios"
|
||||||
|
✅ **Controle Total**: Você gerencia todos os seus clientes
|
||||||
|
✅ **Isolamento de Dados**: Cada cliente só vê os próprios dados
|
||||||
|
✅ **Escalável**: Adicione quantos clientes quiser na mesma agência
|
||||||
|
✅ **Identidade Visual**: Logo e cores personalizadas por agência
|
||||||
|
|
||||||
|
## Fluxo de Trabalho
|
||||||
|
|
||||||
|
1. **Agência se cadastra** → Cria subdomínio (ex: idealpages.aggios.app)
|
||||||
|
2. **Agência personaliza** → Upload de logo, cores, identidade visual
|
||||||
|
3. **Agência adiciona clientes** → Cada cliente recebe credenciais
|
||||||
|
4. **Cliente acessa** → idealpages.aggios.app (vê marca da agência)
|
||||||
|
5. **Cliente usa módulos** → CRM, ERP, Projetos (dados isolados)
|
||||||
|
|
||||||
|
## Resposta Direta
|
||||||
|
|
||||||
|
**Pergunta**: "Cliente precisa do CRM, mando aggios.app ou idealpages.aggios.app?"
|
||||||
|
|
||||||
|
**Resposta**: **`idealpages.aggios.app`** ✅
|
||||||
|
|
||||||
|
O cliente SEMPRE acessa o painel da sua agência, onde verá sua marca e terá acesso aos módulos que você liberar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sistema de Links de Cadastro Personalizados
|
||||||
|
|
||||||
|
### Visão Geral
|
||||||
|
|
||||||
|
Sistema que permite ao SuperAdmin criar links de cadastro customizados, escolhendo:
|
||||||
|
- **Campos do formulário**: Quais informações coletar
|
||||||
|
- **Módulos habilitados**: Quais funcionalidades o cliente terá acesso
|
||||||
|
- **Branding**: Logo e cores personalizadas
|
||||||
|
|
||||||
|
### Fluxo de Uso
|
||||||
|
|
||||||
|
1. **SuperAdmin** acessa `dash.aggios.app/superadmin/signup-templates`
|
||||||
|
2. **Cria template** selecionando:
|
||||||
|
- Campos: email, senha, subdomínio, CNPJ, telefone, etc.
|
||||||
|
- Módulos: CRM, ERP, PROJECTS, FINANCIAL, etc.
|
||||||
|
- Slug: URL amigável (ex: `crm-rapido`)
|
||||||
|
3. **Compartilha link**: `aggios.app/cadastro/crm-rapido`
|
||||||
|
4. **Cliente acessa** e vê formulário personalizado
|
||||||
|
5. **Após cadastro**, tenant criado com módulos específicos
|
||||||
|
|
||||||
|
### Exemplo Real: DH Projects
|
||||||
|
|
||||||
|
```
|
||||||
|
Template: "CRM Rápido"
|
||||||
|
Slug: crm-rapido
|
||||||
|
Campos: email, senha, subdomínio, nome da empresa
|
||||||
|
Módulos: CRM
|
||||||
|
|
||||||
|
Link gerado: aggios.app/cadastro/crm-rapido
|
||||||
|
|
||||||
|
Cliente preenche:
|
||||||
|
- Email: contato@dhprojects.com
|
||||||
|
- Senha: ********
|
||||||
|
- Subdomínio: dhprojects
|
||||||
|
- Empresa: DH Projects
|
||||||
|
|
||||||
|
Resultado:
|
||||||
|
✅ Tenant criado: dhprojects.aggios.app
|
||||||
|
✅ Módulo CRM habilitado
|
||||||
|
✅ Outros módulos desabilitados
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estrutura Técnica
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- Tabela: `signup_templates`
|
||||||
|
- Repository: `SignupTemplateRepository`
|
||||||
|
- Handlers: `/api/admin/signup-templates` (CRUD)
|
||||||
|
- Handler público: `/api/signup-templates/slug/{slug}` (renderiza form)
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- Gerenciamento: `dash.aggios.app/superadmin/signup-templates`
|
||||||
|
- Cadastro público: `aggios.app/cadastro/{slug}`
|
||||||
|
|
||||||
|
**Campos Disponíveis:**
|
||||||
|
- email, password, subdomain (obrigatórios)
|
||||||
|
- company_name, cnpj, phone, address, city, state, zipcode (opcionais)
|
||||||
|
|
||||||
|
**Módulos Disponíveis:**
|
||||||
|
- CRM, ERP, PROJECTS, FINANCIAL, INVENTORY, HR
|
||||||
|
|
||||||
|
### Benefícios
|
||||||
|
|
||||||
|
✅ Cadastro rápido para clientes específicos
|
||||||
|
✅ Coleta apenas informações necessárias
|
||||||
|
✅ Habilita somente módulos contratados
|
||||||
|
✅ Reduz fricção no onboarding
|
||||||
|
✅ Personalização por caso de uso
|
||||||
149
1. docs/nova-interface.md
Normal file
149
1. docs/nova-interface.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# System Instruction: Arquitetura de Layout com Sidebar Expansível
|
||||||
|
|
||||||
|
**Role:** Senior React Developer & UI Specialist
|
||||||
|
**Tech Stack:** React, Tailwind CSS (Sem bibliotecas de ícones ou fontes externas).
|
||||||
|
|
||||||
|
**Objetivo:**
|
||||||
|
Implementar um sistema de layout "Dashboard" composto por um **Menu Lateral (Sidebar)** que expande e colapsa suavemente e uma área de conteúdo principal.
|
||||||
|
|
||||||
|
**Requisitos Críticos de Animação:**
|
||||||
|
1. A transição de largura da sidebar deve ser suave (transition-all duration-300).
|
||||||
|
2. O texto dos botões **não deve quebrar** ou desaparecer bruscamente. Use a técnica de transição de `max-width` e `opacity` para que o texto deslize suavemente para fora.
|
||||||
|
3. Não utilize bibliotecas de animação (Framer Motion, etc), apenas Tailwind CSS puro.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Componente: `DashboardLayout.tsx` (Container Principal)
|
||||||
|
|
||||||
|
Este componente deve gerenciar o estado global do menu (aberto/fechado) para evitar "prop drilling" desnecessário.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { SidebarRail } from './SidebarRail';
|
||||||
|
|
||||||
|
interface DashboardLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DashboardLayout: React.FC<DashboardLayoutProps> = ({ children }) => {
|
||||||
|
// Estado centralizado do layout
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
const [activeTab, setActiveTab] = useState('home');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-full bg-gray-900 text-slate-900 overflow-hidden p-3 gap-3">
|
||||||
|
{/* Sidebar controla seu próprio estado visual via props */}
|
||||||
|
<SidebarRail
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={setActiveTab}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
onToggle={() => setIsExpanded(!isExpanded)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Área de Conteúdo (Children) */}
|
||||||
|
<main className="flex-1 h-full min-w-0 overflow-hidden flex flex-col bg-white rounded-3xl shadow-xl relative">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Componente: `SidebarRail.tsx` (Lógica de Animação)
|
||||||
|
|
||||||
|
Aqui reside a lógica visual. Substitua os ícones por `<span>Icon</span>` ou SVGs genéricos para manter o código agnóstico.
|
||||||
|
|
||||||
|
**Pontos de atenção no código abaixo:**
|
||||||
|
* `w-[220px]` vs `w-[72px]`: Define a largura física.
|
||||||
|
* `max-w-[150px]` vs `max-w-0`: Define a animação do texto.
|
||||||
|
* `whitespace-nowrap`: Impede que o texto pule de linha enquanto fecha.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface SidebarRailProps {
|
||||||
|
activeTab: string;
|
||||||
|
onTabChange: (tab: string) => void;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarRail: React.FC<SidebarRailProps> = ({ activeTab, onTabChange, isExpanded, onToggle }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
h-full bg-zinc-900 rounded-3xl flex flex-col py-6 gap-4 text-gray-400 shrink-0 border border-white/10 shadow-xl
|
||||||
|
transition-[width] duration-300 ease-[cubic-bezier(0.25,0.1,0.25,1)] px-3
|
||||||
|
${isExpanded ? 'w-[220px]' : 'w-[72px]'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Header / Toggle */}
|
||||||
|
<div className={`flex items-center w-full relative transition-all duration-300 mb-4 ${isExpanded ? 'justify-between px-1' : 'justify-center'}`}>
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center text-white font-bold shrink-0 z-10">
|
||||||
|
Logo
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Título com animação de opacidade e largura */}
|
||||||
|
<div className={`overflow-hidden transition-all duration-300 ease-in-out whitespace-nowrap absolute left-14 ${isExpanded ? 'opacity-100 max-w-[100px]' : 'opacity-0 max-w-0'}`}>
|
||||||
|
<span className="font-bold text-white text-lg">App Name</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navegação */}
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
<RailButton
|
||||||
|
label="Dashboard"
|
||||||
|
active={activeTab === 'home'}
|
||||||
|
onClick={() => onTabChange('home')}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
/>
|
||||||
|
<RailButton
|
||||||
|
label="Settings"
|
||||||
|
active={activeTab === 'settings'}
|
||||||
|
onClick={() => onTabChange('settings')}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer / Toggle Button */}
|
||||||
|
<div className="mt-auto">
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="w-full p-2 rounded-xl hover:bg-white/10 text-gray-400 hover:text-white transition-colors flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{/* Ícone de Toggle Genérico */}
|
||||||
|
<span>{isExpanded ? '<<' : '>>'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Subcomponente do Botão (Essencial para a animação do texto)
|
||||||
|
const RailButton = ({ label, active, onClick, isExpanded }: any) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`
|
||||||
|
flex items-center p-2.5 rounded-xl transition-all duration-300 group relative overflow-hidden
|
||||||
|
${active ? 'bg-white/10 text-white' : 'hover:bg-white/5 hover:text-gray-200'}
|
||||||
|
${isExpanded ? '' : 'justify-center'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Placeholder do Ícone */}
|
||||||
|
<div className="shrink-0 flex items-center justify-center w-6 h-6 bg-gray-700/50 rounded text-[10px]">Icon</div>
|
||||||
|
|
||||||
|
{/* Lógica Mágica do Texto: Max-Width Transition */}
|
||||||
|
<div className={`
|
||||||
|
overflow-hidden whitespace-nowrap transition-all duration-300 ease-in-out
|
||||||
|
${isExpanded ? 'max-w-[150px] opacity-100 ml-3' : 'max-w-0 opacity-0 ml-0'}
|
||||||
|
`}>
|
||||||
|
<span className="font-medium text-sm">{label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Indicador de Ativo (Barra lateral pequena quando fechado) */}
|
||||||
|
{active && !isExpanded && (
|
||||||
|
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-3 bg-white rounded-r-full -ml-3" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
```
|
||||||
22
README.md
22
README.md
@@ -11,16 +11,20 @@ Plataforma composta por serviços de autenticação, painel administrativo (supe
|
|||||||
- `backend/`: API Go com serviços de autenticação, operadores e CRUD de agências (endpoints `/api/admin/agencies` e `/api/admin/agencies/{id}`).
|
- `backend/`: API Go com serviços de autenticação, operadores e CRUD de agências (endpoints `/api/admin/agencies` e `/api/admin/agencies/{id}`).
|
||||||
- `front-end-dash.aggios.app/`: painel Next.js – login do superadmin, listagem de agências, exibição detalhada e exclusão definitiva.
|
- `front-end-dash.aggios.app/`: painel Next.js – login do superadmin, listagem de agências, exibição detalhada e exclusão definitiva.
|
||||||
- `frontend-aggios.app/`: site institucional Next.js com suporte a temas claro/escuro e compartilhamento de tokens de design.
|
- `frontend-aggios.app/`: site institucional Next.js com suporte a temas claro/escuro e compartilhamento de tokens de design.
|
||||||
- `postgres/`: scripts de inicialização do banco (estrutura base de tenants e usuários).
|
- `backend/internal/data/postgres/`: scripts de inicialização do banco (estrutura base de tenants e usuários).
|
||||||
- `traefik/`: reverse proxy e certificados automatizados.
|
- `traefik/`: reverse proxy e certificados automatizados.
|
||||||
|
|
||||||
## Funcionalidades entregues
|
## Funcionalidades entregues
|
||||||
- Login de superadmin via JWT e restrição de rotas protegidas no dashboard.
|
- **Redesign da Interface (v1.2)**: Adoção de design "Flat" (sem sombras), focado em bordas e limpeza visual em todas as rotas principais (Login, Dashboard, Agências, Cadastro).
|
||||||
- Cadastro de agências: criação de tenant e usuário administrador atrelado.
|
- **Gestão Avançada de Agências**:
|
||||||
- Listagem, detalhamento e exclusão de agências diretamente pelo painel superadmin.
|
- Listagem com filtros robustos: Busca textual, Status (Ativo/Inativo) e Filtros de Data (Presets de 7/15/30 dias e intervalo personalizado).
|
||||||
- Proxy interno (`app/api/admin/agencies/[id]/route.ts`) garantindo chamadas autenticadas do Next para o backend.
|
- Detalhamento completo da agência com visualização de logo, cores e dados cadastrais.
|
||||||
- Site institucional com dark mode, componentes compartilhados e tokens de design centralizados.
|
- Edição e Exclusão de agências.
|
||||||
- Documentação atualizada em `1. docs/` com fluxos, arquiteturas e changelog.
|
- **Login de Superadmin**: Autenticação via JWT com restrição de rotas protegidas.
|
||||||
|
- **Cadastro de Agências**: Criação de tenant e usuário administrador atrelado.
|
||||||
|
- **Proxy Interno**: Camada de API no Next.js (`app/api/...`) garantindo chamadas autenticadas e seguras ao backend Go.
|
||||||
|
- **Site Institucional**: Suporte a dark mode, componentes compartilhados e tokens de design centralizados.
|
||||||
|
- **Documentação**: Atualizada em `1. docs/` com fluxos, arquiteturas e changelog.
|
||||||
|
|
||||||
## Executando o projeto
|
## Executando o projeto
|
||||||
1. **Pré-requisitos**: Docker Desktop e Node.js 20+ (para utilitários opcionais).
|
1. **Pré-requisitos**: Docker Desktop e Node.js 20+ (para utilitários opcionais).
|
||||||
@@ -33,14 +37,14 @@ Plataforma composta por serviços de autenticação, painel administrativo (supe
|
|||||||
- Painel: `https://dash.localhost`
|
- Painel: `https://dash.localhost`
|
||||||
- Site: `https://aggios.app.localhost`
|
- Site: `https://aggios.app.localhost`
|
||||||
- API: `https://api.localhost`
|
- API: `https://api.localhost`
|
||||||
5. **Credenciais padrão**: ver `postgres/init-db.sql` para usuário superadmin seed.
|
5. **Credenciais padrão**: ver `backend/internal/data/postgres/init-db.sql` para usuário superadmin seed.
|
||||||
|
|
||||||
## Estrutura de diretórios (resumo)
|
## Estrutura de diretórios (resumo)
|
||||||
```
|
```
|
||||||
backend/ API Go (config, domínio, handlers, serviços)
|
backend/ API Go (config, domínio, handlers, serviços)
|
||||||
|
backend/internal/data/postgres/ Scripts SQL de seed
|
||||||
front-end-dash.aggios.app/ Dashboard Next.js Superadmin
|
front-end-dash.aggios.app/ Dashboard Next.js Superadmin
|
||||||
frontend-aggios.app/ Site institucional Next.js
|
frontend-aggios.app/ Site institucional Next.js
|
||||||
postgres/ Scripts SQL de seed
|
|
||||||
traefik/ Regras de roteamento e TLS
|
traefik/ Regras de roteamento e TLS
|
||||||
1. docs/ Documentação funcional e técnica
|
1. docs/ Documentação funcional e técnica
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
_ "github.com/lib/pq"
|
_ "github.com/lib/pq"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
"aggios-app/backend/internal/api/handlers"
|
"aggios-app/backend/internal/api/handlers"
|
||||||
"aggios-app/backend/internal/api/middleware"
|
"aggios-app/backend/internal/api/middleware"
|
||||||
@@ -53,6 +54,8 @@ func main() {
|
|||||||
userRepo := repository.NewUserRepository(db)
|
userRepo := repository.NewUserRepository(db)
|
||||||
tenantRepo := repository.NewTenantRepository(db)
|
tenantRepo := repository.NewTenantRepository(db)
|
||||||
companyRepo := repository.NewCompanyRepository(db)
|
companyRepo := repository.NewCompanyRepository(db)
|
||||||
|
signupTemplateRepo := repository.NewSignupTemplateRepository(db)
|
||||||
|
agencyTemplateRepo := repository.NewAgencyTemplateRepository(db)
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
authService := service.NewAuthService(userRepo, tenantRepo, cfg)
|
authService := service.NewAuthService(userRepo, tenantRepo, cfg)
|
||||||
@@ -67,6 +70,14 @@ func main() {
|
|||||||
agencyHandler := handlers.NewAgencyRegistrationHandler(agencyService, cfg)
|
agencyHandler := handlers.NewAgencyRegistrationHandler(agencyService, cfg)
|
||||||
tenantHandler := handlers.NewTenantHandler(tenantService)
|
tenantHandler := handlers.NewTenantHandler(tenantService)
|
||||||
companyHandler := handlers.NewCompanyHandler(companyService)
|
companyHandler := handlers.NewCompanyHandler(companyService)
|
||||||
|
signupTemplateHandler := handlers.NewSignupTemplateHandler(signupTemplateRepo, userRepo, tenantRepo, agencyService)
|
||||||
|
agencyTemplateHandler := handlers.NewAgencyTemplateHandler(agencyTemplateRepo, agencyService, userRepo, tenantRepo)
|
||||||
|
|
||||||
|
// Initialize upload handler
|
||||||
|
uploadHandler, err := handlers.NewUploadHandler(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ Erro ao inicializar upload handler: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Create middleware chain
|
// Create middleware chain
|
||||||
tenantDetector := middleware.TenantDetector(tenantRepo)
|
tenantDetector := middleware.TenantDetector(tenantRepo)
|
||||||
@@ -76,43 +87,95 @@ func main() {
|
|||||||
authMiddleware := middleware.Auth(cfg)
|
authMiddleware := middleware.Auth(cfg)
|
||||||
|
|
||||||
// Setup routes
|
// Setup routes
|
||||||
mux := http.NewServeMux()
|
router := mux.NewRouter()
|
||||||
|
|
||||||
// Health check (no auth)
|
// Serve static files (uploads)
|
||||||
mux.HandleFunc("/health", healthHandler.Check)
|
fs := http.FileServer(http.Dir("./uploads"))
|
||||||
mux.HandleFunc("/api/health", healthHandler.Check)
|
router.PathPrefix("/uploads/").Handler(http.StripPrefix("/uploads", fs))
|
||||||
|
|
||||||
// Auth routes (public with rate limiting)
|
// ==================== PUBLIC ROUTES ====================
|
||||||
mux.HandleFunc("/api/auth/login", authHandler.Login)
|
|
||||||
|
|
||||||
// Protected auth routes
|
// Health check
|
||||||
mux.Handle("/api/auth/change-password", authMiddleware(http.HandlerFunc(authHandler.ChangePassword)))
|
router.HandleFunc("/health", healthHandler.Check)
|
||||||
|
router.HandleFunc("/api/health", healthHandler.Check)
|
||||||
|
|
||||||
// Agency management (SUPERADMIN only)
|
// Auth
|
||||||
mux.HandleFunc("/api/admin/agencies/register", agencyHandler.RegisterAgency)
|
router.HandleFunc("/api/auth/login", authHandler.Login)
|
||||||
mux.HandleFunc("/api/admin/agencies", tenantHandler.ListAll)
|
router.HandleFunc("/api/auth/register", agencyHandler.PublicRegister).Methods("POST")
|
||||||
mux.HandleFunc("/api/admin/agencies/", agencyHandler.HandleAgency)
|
|
||||||
|
|
||||||
// Client registration (ADMIN_AGENCIA only - requires auth)
|
// Public agency template registration (for creating new agencies)
|
||||||
mux.Handle("/api/agencies/clients/register", authMiddleware(http.HandlerFunc(agencyHandler.RegisterClient)))
|
router.HandleFunc("/api/agency-templates", agencyTemplateHandler.GetTemplateBySlug).Methods("GET")
|
||||||
|
router.HandleFunc("/api/agency-signup/register", agencyTemplateHandler.PublicRegisterAgency).Methods("POST")
|
||||||
|
|
||||||
|
// Public client signup via templates
|
||||||
|
router.HandleFunc("/api/signup-templates/slug/{slug}", signupTemplateHandler.GetTemplateBySlug).Methods("GET")
|
||||||
|
router.HandleFunc("/api/signup/register", signupTemplateHandler.PublicRegister).Methods("POST")
|
||||||
|
|
||||||
|
// File upload (public for signup, will also work with auth)
|
||||||
|
router.HandleFunc("/api/upload", uploadHandler.Upload).Methods("POST")
|
||||||
|
|
||||||
|
// Tenant check (public)
|
||||||
|
router.HandleFunc("/api/tenant/check", tenantHandler.CheckExists).Methods("GET")
|
||||||
|
|
||||||
|
// Hash generator (dev only - remove in production)
|
||||||
|
router.HandleFunc("/api/hash", handlers.GenerateHash).Methods("POST")
|
||||||
|
|
||||||
|
// ==================== PROTECTED ROUTES ====================
|
||||||
|
|
||||||
|
// Auth (protected)
|
||||||
|
router.Handle("/api/auth/change-password", authMiddleware(http.HandlerFunc(authHandler.ChangePassword))).Methods("POST")
|
||||||
|
|
||||||
|
// SUPERADMIN: Agency management
|
||||||
|
router.HandleFunc("/api/admin/agencies/register", agencyHandler.RegisterAgency).Methods("POST")
|
||||||
|
router.HandleFunc("/api/admin/agencies", tenantHandler.ListAll).Methods("GET")
|
||||||
|
router.HandleFunc("/api/admin/agencies/{id}", agencyHandler.HandleAgency).Methods("GET", "PATCH", "DELETE")
|
||||||
|
|
||||||
|
// SUPERADMIN: Agency template management
|
||||||
|
router.Handle("/api/admin/agency-templates", authMiddleware(http.HandlerFunc(agencyTemplateHandler.ListTemplates))).Methods("GET")
|
||||||
|
router.Handle("/api/admin/agency-templates", authMiddleware(http.HandlerFunc(agencyTemplateHandler.CreateTemplate))).Methods("POST")
|
||||||
|
|
||||||
|
// SUPERADMIN: Client signup template management
|
||||||
|
router.Handle("/api/admin/signup-templates", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
signupTemplateHandler.ListTemplates(w, r)
|
||||||
|
} else if r.Method == http.MethodPost {
|
||||||
|
signupTemplateHandler.CreateTemplate(w, r)
|
||||||
|
}
|
||||||
|
}))).Methods("GET", "POST")
|
||||||
|
|
||||||
|
router.Handle("/api/admin/signup-templates/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
signupTemplateHandler.GetTemplateByID(w, r)
|
||||||
|
case http.MethodPut, http.MethodPatch:
|
||||||
|
signupTemplateHandler.UpdateTemplate(w, r)
|
||||||
|
case http.MethodDelete:
|
||||||
|
signupTemplateHandler.DeleteTemplate(w, r)
|
||||||
|
}
|
||||||
|
}))).Methods("GET", "PUT", "PATCH", "DELETE")
|
||||||
|
|
||||||
|
// ADMIN_AGENCIA: Client registration
|
||||||
|
router.Handle("/api/agencies/clients/register", authMiddleware(http.HandlerFunc(agencyHandler.RegisterClient))).Methods("POST")
|
||||||
|
|
||||||
// Agency profile routes (protected)
|
// Agency profile routes (protected)
|
||||||
mux.Handle("/api/agency/profile", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
router.Handle("/api/agency/profile", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == http.MethodGet {
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
agencyProfileHandler.GetProfile(w, r)
|
agencyProfileHandler.GetProfile(w, r)
|
||||||
} else if r.Method == http.MethodPut || r.Method == http.MethodPatch {
|
case http.MethodPut, http.MethodPatch:
|
||||||
agencyProfileHandler.UpdateProfile(w, r)
|
agencyProfileHandler.UpdateProfile(w, r)
|
||||||
} else {
|
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
}
|
}
|
||||||
})))
|
}))).Methods("GET", "PUT", "PATCH")
|
||||||
|
|
||||||
// Protected routes (require authentication)
|
// Agency logo upload (protected)
|
||||||
mux.Handle("/api/companies", authMiddleware(http.HandlerFunc(companyHandler.List)))
|
router.Handle("/api/agency/logo", authMiddleware(http.HandlerFunc(agencyProfileHandler.UploadLogo))).Methods("POST")
|
||||||
mux.Handle("/api/companies/create", authMiddleware(http.HandlerFunc(companyHandler.Create)))
|
|
||||||
|
|
||||||
// Apply global middlewares: tenant -> cors -> security -> rateLimit -> mux
|
// Company routes (protected)
|
||||||
handler := tenantDetector(corsMiddleware(securityMiddleware(rateLimitMiddleware(mux))))
|
router.Handle("/api/companies", authMiddleware(http.HandlerFunc(companyHandler.List))).Methods("GET")
|
||||||
|
router.Handle("/api/companies/create", authMiddleware(http.HandlerFunc(companyHandler.Create))).Methods("POST")
|
||||||
|
|
||||||
|
// Apply global middlewares: tenant -> cors -> security -> rateLimit -> router
|
||||||
|
handler := tenantDetector(corsMiddleware(securityMiddleware(rateLimitMiddleware(router))))
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
addr := fmt.Sprintf(":%s", cfg.Server.Port)
|
addr := fmt.Sprintf(":%s", cfg.Server.Port)
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ require (
|
|||||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
|
github.com/minio/minio-go/v7 v7.0.63
|
||||||
golang.org/x/crypto v0.27.0
|
golang.org/x/crypto v0.27.0
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"aggios-app/backend/internal/config"
|
"aggios-app/backend/internal/config"
|
||||||
@@ -13,6 +12,7 @@ import (
|
|||||||
"aggios-app/backend/internal/service"
|
"aggios-app/backend/internal/service"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,6 +45,8 @@ func (h *AgencyRegistrationHandler) RegisterAgency(w http.ResponseWriter, r *htt
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("📥 Registering agency: %s (subdomain: %s)", req.AgencyName, req.Subdomain)
|
log.Printf("📥 Registering agency: %s (subdomain: %s)", req.AgencyName, req.Subdomain)
|
||||||
|
log.Printf("📊 Payload received: RazaoSocial=%s, Phone=%s, City=%s, State=%s, Neighborhood=%s, TeamSize=%s, PrimaryColor=%s, SecondaryColor=%s",
|
||||||
|
req.RazaoSocial, req.Phone, req.City, req.State, req.Neighborhood, req.TeamSize, req.PrimaryColor, req.SecondaryColor)
|
||||||
|
|
||||||
tenant, admin, err := h.agencyService.RegisterAgency(req)
|
tenant, admin, err := h.agencyService.RegisterAgency(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -104,6 +106,112 @@ func (h *AgencyRegistrationHandler) RegisterAgency(w http.ResponseWriter, r *htt
|
|||||||
json.NewEncoder(w).Encode(response)
|
json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PublicRegister handles public agency registration
|
||||||
|
func (h *AgencyRegistrationHandler) PublicRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req domain.PublicRegisterAgencyRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
log.Printf("❌ Error decoding request: %v", err)
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("📥 Public Registering agency: %s (subdomain: %s)", req.CompanyName, req.Subdomain)
|
||||||
|
log.Printf("📦 Full Payload: %+v", req)
|
||||||
|
|
||||||
|
// Map to internal request
|
||||||
|
phone := ""
|
||||||
|
if len(req.Contacts) > 0 {
|
||||||
|
phone = req.Contacts[0].Whatsapp
|
||||||
|
}
|
||||||
|
|
||||||
|
internalReq := domain.RegisterAgencyRequest{
|
||||||
|
AgencyName: req.CompanyName,
|
||||||
|
Subdomain: req.Subdomain,
|
||||||
|
CNPJ: req.CNPJ,
|
||||||
|
RazaoSocial: req.RazaoSocial,
|
||||||
|
Description: req.Description,
|
||||||
|
Website: req.Website,
|
||||||
|
Industry: req.Industry,
|
||||||
|
Phone: phone,
|
||||||
|
TeamSize: req.TeamSize,
|
||||||
|
CEP: req.CEP,
|
||||||
|
State: req.State,
|
||||||
|
City: req.City,
|
||||||
|
Neighborhood: req.Neighborhood,
|
||||||
|
Street: req.Street,
|
||||||
|
Number: req.Number,
|
||||||
|
Complement: req.Complement,
|
||||||
|
PrimaryColor: req.PrimaryColor,
|
||||||
|
SecondaryColor: req.SecondaryColor,
|
||||||
|
LogoURL: req.LogoURL,
|
||||||
|
AdminEmail: req.Email,
|
||||||
|
AdminPassword: req.Password,
|
||||||
|
AdminName: req.FullName,
|
||||||
|
}
|
||||||
|
|
||||||
|
tenant, admin, err := h.agencyService.RegisterAgency(internalReq)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ Error registering agency: %v", err)
|
||||||
|
switch err {
|
||||||
|
case service.ErrSubdomainTaken:
|
||||||
|
http.Error(w, err.Error(), http.StatusConflict)
|
||||||
|
case service.ErrEmailAlreadyExists:
|
||||||
|
http.Error(w, err.Error(), http.StatusConflict)
|
||||||
|
case service.ErrWeakPassword:
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
default:
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("✅ Agency created: %s (ID: %s)", tenant.Name, tenant.ID)
|
||||||
|
|
||||||
|
// Generate JWT token for the new admin
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"user_id": admin.ID.String(),
|
||||||
|
"email": admin.Email,
|
||||||
|
"role": admin.Role,
|
||||||
|
"tenant_id": tenant.ID.String(),
|
||||||
|
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
|
||||||
|
"iat": time.Now().Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
tokenString, err := token.SignedString([]byte(h.cfg.JWT.Secret))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol := "http://"
|
||||||
|
if h.cfg.App.Environment == "production" {
|
||||||
|
protocol = "https://"
|
||||||
|
}
|
||||||
|
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"token": tokenString,
|
||||||
|
"id": admin.ID,
|
||||||
|
"email": admin.Email,
|
||||||
|
"name": admin.Name,
|
||||||
|
"role": admin.Role,
|
||||||
|
"tenantId": tenant.ID,
|
||||||
|
"company": tenant.Name,
|
||||||
|
"subdomain": tenant.Subdomain,
|
||||||
|
"message": "Agency registered successfully",
|
||||||
|
"access_url": protocol + tenant.Domain,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterClient handles client registration (ADMIN_AGENCIA only)
|
// RegisterClient handles client registration (ADMIN_AGENCIA only)
|
||||||
func (h *AgencyRegistrationHandler) RegisterClient(w http.ResponseWriter, r *http.Request) {
|
func (h *AgencyRegistrationHandler) RegisterClient(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
@@ -147,9 +255,10 @@ func (h *AgencyRegistrationHandler) HandleAgency(w http.ResponseWriter, r *http.
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
agencyID := strings.TrimPrefix(r.URL.Path, "/api/admin/agencies/")
|
vars := mux.Vars(r)
|
||||||
if agencyID == "" || agencyID == r.URL.Path {
|
agencyID := vars["id"]
|
||||||
http.NotFound(w, r)
|
if agencyID == "" {
|
||||||
|
http.Error(w, "Missing agency ID", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +283,27 @@ func (h *AgencyRegistrationHandler) HandleAgency(w http.ResponseWriter, r *http.
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(details)
|
json.NewEncoder(w).Encode(details)
|
||||||
|
|
||||||
|
case http.MethodPatch:
|
||||||
|
var updateData map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&updateData); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isActive, ok := updateData["is_active"].(bool); ok {
|
||||||
|
if err := h.agencyService.UpdateAgencyStatus(id, isActive); err != nil {
|
||||||
|
if errors.Is(err, service.ErrTenantNotFound) {
|
||||||
|
http.Error(w, "Agency not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"message": "Status updated"})
|
||||||
|
|
||||||
case http.MethodDelete:
|
case http.MethodDelete:
|
||||||
if err := h.agencyService.DeleteAgency(id); err != nil {
|
if err := h.agencyService.DeleteAgency(id); err != nil {
|
||||||
if errors.Is(err, service.ErrTenantNotFound) {
|
if errors.Is(err, service.ErrTenantNotFound) {
|
||||||
|
|||||||
225
backend/internal/api/handlers/agency_logo.go
Normal file
225
backend/internal/api/handlers/agency_logo.go
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"aggios-app/backend/internal/api/middleware"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/minio/minio-go/v7"
|
||||||
|
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UploadLogo handles logo file uploads
|
||||||
|
func (h *AgencyHandler) UploadLogo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Only accept POST
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Logo upload request received from tenant")
|
||||||
|
|
||||||
|
// Get tenant ID from context
|
||||||
|
tenantIDVal := r.Context().Value(middleware.TenantIDKey)
|
||||||
|
if tenantIDVal == nil {
|
||||||
|
log.Printf("No tenant ID in context")
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get as uuid.UUID first, if that fails try string and parse
|
||||||
|
var tenantID uuid.UUID
|
||||||
|
var ok bool
|
||||||
|
|
||||||
|
tenantID, ok = tenantIDVal.(uuid.UUID)
|
||||||
|
if !ok {
|
||||||
|
// Try as string
|
||||||
|
tenantIDStr, isString := tenantIDVal.(string)
|
||||||
|
if !isString {
|
||||||
|
log.Printf("Invalid tenant ID type: %T", tenantIDVal)
|
||||||
|
http.Error(w, "Invalid tenant ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
tenantID, err = uuid.Parse(tenantIDStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to parse tenant ID: %v", err)
|
||||||
|
http.Error(w, "Invalid tenant ID format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Processing logo upload for tenant: %s", tenantID)
|
||||||
|
|
||||||
|
// Parse multipart form (2MB max)
|
||||||
|
const maxLogoSize = 2 * 1024 * 1024
|
||||||
|
if err := r.ParseMultipartForm(maxLogoSize); err != nil {
|
||||||
|
http.Error(w, "File too large", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, header, err := r.FormFile("logo")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to read file", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
contentType := header.Header.Get("Content-Type")
|
||||||
|
if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/svg+xml" && contentType != "image/jpg" {
|
||||||
|
http.Error(w, "Only PNG, JPG or SVG files are allowed", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get logo type (logo or horizontal)
|
||||||
|
logoType := r.FormValue("type")
|
||||||
|
if logoType != "logo" && logoType != "horizontal" {
|
||||||
|
logoType = "logo"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current logo URL from database to delete old file
|
||||||
|
var currentLogoURL string
|
||||||
|
var queryErr error
|
||||||
|
if logoType == "horizontal" {
|
||||||
|
queryErr = h.tenantRepo.DB().QueryRow("SELECT logo_horizontal_url FROM tenants WHERE id = $1", tenantID).Scan(¤tLogoURL)
|
||||||
|
} else {
|
||||||
|
queryErr = h.tenantRepo.DB().QueryRow("SELECT logo_url FROM tenants WHERE id = $1", tenantID).Scan(¤tLogoURL)
|
||||||
|
}
|
||||||
|
if queryErr != nil && queryErr.Error() != "sql: no rows in result set" {
|
||||||
|
log.Printf("Warning: Failed to get current logo URL: %v", queryErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize MinIO client
|
||||||
|
minioClient, err := minio.New("aggios-minio:9000", &minio.Options{
|
||||||
|
Creds: credentials.NewStaticV4("minioadmin", "M1n10_S3cur3_P@ss_2025!", ""),
|
||||||
|
Secure: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create MinIO client: %v", err)
|
||||||
|
http.Error(w, "Storage service unavailable", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure bucket exists
|
||||||
|
bucketName := "aggios-logos"
|
||||||
|
ctx := context.Background()
|
||||||
|
exists, err := minioClient.BucketExists(ctx, bucketName)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to check bucket: %v", err)
|
||||||
|
http.Error(w, "Storage error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
err = minioClient.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create bucket: %v", err)
|
||||||
|
http.Error(w, "Storage error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Set bucket policy to public-read
|
||||||
|
policy := fmt.Sprintf(`{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": {"AWS": ["*"]},
|
||||||
|
"Action": ["s3:GetObject"],
|
||||||
|
"Resource": ["arn:aws:s3:::%s/*"]
|
||||||
|
}]
|
||||||
|
}`, bucketName)
|
||||||
|
err = minioClient.SetBucketPolicy(ctx, bucketName, policy)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Failed to set bucket policy: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file content
|
||||||
|
fileBytes, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to read file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
ext := filepath.Ext(header.Filename)
|
||||||
|
filename := fmt.Sprintf("tenants/%s/%s-%d%s", tenantID, logoType, time.Now().Unix(), ext)
|
||||||
|
|
||||||
|
// Upload to MinIO
|
||||||
|
_, err = minioClient.PutObject(
|
||||||
|
ctx,
|
||||||
|
bucketName,
|
||||||
|
filename,
|
||||||
|
bytes.NewReader(fileBytes),
|
||||||
|
int64(len(fileBytes)),
|
||||||
|
minio.PutObjectOptions{
|
||||||
|
ContentType: contentType,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to upload to MinIO: %v", err)
|
||||||
|
http.Error(w, "Failed to save file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate public URL
|
||||||
|
logoURL := fmt.Sprintf("http://localhost:9000/%s/%s", bucketName, filename)
|
||||||
|
|
||||||
|
log.Printf("Logo uploaded successfully: %s", logoURL)
|
||||||
|
|
||||||
|
// Delete old logo file from MinIO if exists
|
||||||
|
if currentLogoURL != "" && currentLogoURL != "https://via.placeholder.com/150" {
|
||||||
|
// Extract object key from URL
|
||||||
|
// Example: http://localhost:9000/aggios-logos/tenants/uuid/logo-123.png -> tenants/uuid/logo-123.png
|
||||||
|
oldFilename := ""
|
||||||
|
if len(currentLogoURL) > 0 {
|
||||||
|
// Split by bucket name
|
||||||
|
if idx := len("http://localhost:9000/aggios-logos/"); idx < len(currentLogoURL) {
|
||||||
|
oldFilename = currentLogoURL[idx:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if oldFilename != "" {
|
||||||
|
err = minioClient.RemoveObject(ctx, bucketName, oldFilename, minio.RemoveObjectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Failed to delete old logo %s: %v", oldFilename, err)
|
||||||
|
// Don't fail the request if deletion fails
|
||||||
|
} else {
|
||||||
|
log.Printf("Old logo deleted successfully: %s", oldFilename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tenant record in database
|
||||||
|
var err2 error
|
||||||
|
if logoType == "horizontal" {
|
||||||
|
_, err2 = h.tenantRepo.DB().Exec("UPDATE tenants SET logo_horizontal_url = $1, updated_at = NOW() WHERE id = $2", logoURL, tenantID)
|
||||||
|
} else {
|
||||||
|
_, err2 = h.tenantRepo.DB().Exec("UPDATE tenants SET logo_url = $1, updated_at = NOW() WHERE id = $2", logoURL, tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err2 != nil {
|
||||||
|
log.Printf("Failed to update logo: %v", err2)
|
||||||
|
http.Error(w, "Failed to update database", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return success response
|
||||||
|
response := map[string]string{
|
||||||
|
"logo_url": logoURL,
|
||||||
|
"message": "Logo uploaded successfully",
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"aggios-app/backend/internal/api/middleware"
|
||||||
"aggios-app/backend/internal/repository"
|
"aggios-app/backend/internal/repository"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -27,12 +29,20 @@ type AgencyProfileResponse struct {
|
|||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Website string `json:"website"`
|
Website string `json:"website"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
|
Neighborhood string `json:"neighborhood"`
|
||||||
|
Number string `json:"number"`
|
||||||
|
Complement string `json:"complement"`
|
||||||
City string `json:"city"`
|
City string `json:"city"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
Zip string `json:"zip"`
|
Zip string `json:"zip"`
|
||||||
RazaoSocial string `json:"razao_social"`
|
RazaoSocial string `json:"razao_social"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Industry string `json:"industry"`
|
Industry string `json:"industry"`
|
||||||
|
TeamSize string `json:"team_size"`
|
||||||
|
PrimaryColor string `json:"primary_color"`
|
||||||
|
SecondaryColor string `json:"secondary_color"`
|
||||||
|
LogoURL string `json:"logo_url"`
|
||||||
|
LogoHorizontalURL string `json:"logo_horizontal_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateAgencyProfileRequest struct {
|
type UpdateAgencyProfileRequest struct {
|
||||||
@@ -42,12 +52,20 @@ type UpdateAgencyProfileRequest struct {
|
|||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Website string `json:"website"`
|
Website string `json:"website"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
|
Neighborhood string `json:"neighborhood"`
|
||||||
|
Number string `json:"number"`
|
||||||
|
Complement string `json:"complement"`
|
||||||
City string `json:"city"`
|
City string `json:"city"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
Zip string `json:"zip"`
|
Zip string `json:"zip"`
|
||||||
RazaoSocial string `json:"razao_social"`
|
RazaoSocial string `json:"razao_social"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Industry string `json:"industry"`
|
Industry string `json:"industry"`
|
||||||
|
TeamSize string `json:"team_size"`
|
||||||
|
PrimaryColor string `json:"primary_color"`
|
||||||
|
SecondaryColor string `json:"secondary_color"`
|
||||||
|
LogoURL string `json:"logo_url"`
|
||||||
|
LogoHorizontalURL string `json:"logo_horizontal_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetProfile returns the current agency profile
|
// GetProfile returns the current agency profile
|
||||||
@@ -57,10 +75,11 @@ func (h *AgencyHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get tenant from context (set by middleware)
|
// Get tenant from context (set by auth middleware)
|
||||||
tenantID := r.Context().Value("tenantID")
|
tenantID := r.Context().Value(middleware.TenantIDKey)
|
||||||
|
|
||||||
if tenantID == nil {
|
if tenantID == nil {
|
||||||
http.Error(w, "Tenant not found", http.StatusUnauthorized)
|
http.Error(w, "Tenant not found in context", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +101,10 @@ func (h *AgencyHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("🔍 GetProfile for tenant %s: Found %s", tid, tenant.Name)
|
||||||
|
log.Printf("📄 Tenant Data: Address=%s, Number=%s, TeamSize=%s, RazaoSocial=%s",
|
||||||
|
tenant.Address, tenant.Number, tenant.TeamSize, tenant.RazaoSocial)
|
||||||
|
|
||||||
response := AgencyProfileResponse{
|
response := AgencyProfileResponse{
|
||||||
ID: tenant.ID.String(),
|
ID: tenant.ID.String(),
|
||||||
Name: tenant.Name,
|
Name: tenant.Name,
|
||||||
@@ -90,12 +113,20 @@ func (h *AgencyHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
Phone: tenant.Phone,
|
Phone: tenant.Phone,
|
||||||
Website: tenant.Website,
|
Website: tenant.Website,
|
||||||
Address: tenant.Address,
|
Address: tenant.Address,
|
||||||
|
Neighborhood: tenant.Neighborhood,
|
||||||
|
Number: tenant.Number,
|
||||||
|
Complement: tenant.Complement,
|
||||||
City: tenant.City,
|
City: tenant.City,
|
||||||
State: tenant.State,
|
State: tenant.State,
|
||||||
Zip: tenant.Zip,
|
Zip: tenant.Zip,
|
||||||
RazaoSocial: tenant.RazaoSocial,
|
RazaoSocial: tenant.RazaoSocial,
|
||||||
Description: tenant.Description,
|
Description: tenant.Description,
|
||||||
Industry: tenant.Industry,
|
Industry: tenant.Industry,
|
||||||
|
TeamSize: tenant.TeamSize,
|
||||||
|
PrimaryColor: tenant.PrimaryColor,
|
||||||
|
SecondaryColor: tenant.SecondaryColor,
|
||||||
|
LogoURL: tenant.LogoURL,
|
||||||
|
LogoHorizontalURL: tenant.LogoHorizontalURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -109,8 +140,8 @@ func (h *AgencyHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get tenant from context
|
// Get tenant from context (set by auth middleware)
|
||||||
tenantID := r.Context().Value("tenantID")
|
tenantID := r.Context().Value(middleware.TenantIDKey)
|
||||||
if tenantID == nil {
|
if tenantID == nil {
|
||||||
http.Error(w, "Tenant not found", http.StatusUnauthorized)
|
http.Error(w, "Tenant not found", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
@@ -138,11 +169,19 @@ func (h *AgencyHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
"phone": req.Phone,
|
"phone": req.Phone,
|
||||||
"website": req.Website,
|
"website": req.Website,
|
||||||
"address": req.Address,
|
"address": req.Address,
|
||||||
|
"neighborhood": req.Neighborhood,
|
||||||
|
"number": req.Number,
|
||||||
|
"complement": req.Complement,
|
||||||
"city": req.City,
|
"city": req.City,
|
||||||
"state": req.State,
|
"state": req.State,
|
||||||
"zip": req.Zip,
|
"zip": req.Zip,
|
||||||
"description": req.Description,
|
"description": req.Description,
|
||||||
"industry": req.Industry,
|
"industry": req.Industry,
|
||||||
|
"team_size": req.TeamSize,
|
||||||
|
"primary_color": req.PrimaryColor,
|
||||||
|
"secondary_color": req.SecondaryColor,
|
||||||
|
"logo_url": req.LogoURL,
|
||||||
|
"logo_horizontal_url": req.LogoHorizontalURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update in database
|
// Update in database
|
||||||
@@ -166,14 +205,23 @@ func (h *AgencyHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
Phone: tenant.Phone,
|
Phone: tenant.Phone,
|
||||||
Website: tenant.Website,
|
Website: tenant.Website,
|
||||||
Address: tenant.Address,
|
Address: tenant.Address,
|
||||||
|
Neighborhood: tenant.Neighborhood,
|
||||||
|
Number: tenant.Number,
|
||||||
|
Complement: tenant.Complement,
|
||||||
City: tenant.City,
|
City: tenant.City,
|
||||||
State: tenant.State,
|
State: tenant.State,
|
||||||
Zip: tenant.Zip,
|
Zip: tenant.Zip,
|
||||||
RazaoSocial: tenant.RazaoSocial,
|
RazaoSocial: tenant.RazaoSocial,
|
||||||
Description: tenant.Description,
|
Description: tenant.Description,
|
||||||
Industry: tenant.Industry,
|
Industry: tenant.Industry,
|
||||||
|
TeamSize: tenant.TeamSize,
|
||||||
|
PrimaryColor: tenant.PrimaryColor,
|
||||||
|
SecondaryColor: tenant.SecondaryColor,
|
||||||
|
LogoURL: tenant.LogoURL,
|
||||||
|
LogoHorizontalURL: tenant.LogoHorizontalURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(response)
|
json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
239
backend/internal/api/handlers/agency_template_handler.go
Normal file
239
backend/internal/api/handlers/agency_template_handler.go
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"aggios-app/backend/internal/domain"
|
||||||
|
"aggios-app/backend/internal/repository"
|
||||||
|
"aggios-app/backend/internal/service"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AgencyTemplateHandler struct {
|
||||||
|
templateRepo *repository.AgencyTemplateRepository
|
||||||
|
agencyService *service.AgencyService
|
||||||
|
userRepo *repository.UserRepository
|
||||||
|
tenantRepo *repository.TenantRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAgencyTemplateHandler(
|
||||||
|
templateRepo *repository.AgencyTemplateRepository,
|
||||||
|
agencyService *service.AgencyService,
|
||||||
|
userRepo *repository.UserRepository,
|
||||||
|
tenantRepo *repository.TenantRepository,
|
||||||
|
) *AgencyTemplateHandler {
|
||||||
|
return &AgencyTemplateHandler{
|
||||||
|
templateRepo: templateRepo,
|
||||||
|
agencyService: agencyService,
|
||||||
|
userRepo: userRepo,
|
||||||
|
tenantRepo: tenantRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTemplateBySlug - Public endpoint to get template details
|
||||||
|
func (h *AgencyTemplateHandler) GetTemplateBySlug(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.URL.Query().Get("slug")
|
||||||
|
if slug == "" {
|
||||||
|
http.Error(w, "Missing slug parameter", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
template, err := h.templateRepo.FindBySlug(slug)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Template not found: %v", err)
|
||||||
|
http.Error(w, "Template not found or expired", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicRegisterAgency - Public endpoint for agency registration via template
|
||||||
|
func (h *AgencyTemplateHandler) PublicRegisterAgency(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req domain.AgencyRegistrationViaTemplate
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Validar template
|
||||||
|
template, err := h.templateRepo.FindBySlug(req.TemplateSlug)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Template error: %v", err)
|
||||||
|
http.Error(w, "Invalid or expired template", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Validar campos obrigatórios
|
||||||
|
if req.AgencyName == "" || req.Subdomain == "" || req.AdminEmail == "" || req.AdminPassword == "" {
|
||||||
|
http.Error(w, "Missing required fields", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Validar senha
|
||||||
|
if len(req.AdminPassword) < 8 {
|
||||||
|
http.Error(w, "Password must be at least 8 characters", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Verificar se email já existe
|
||||||
|
existingUser, _ := h.userRepo.FindByEmail(req.AdminEmail)
|
||||||
|
if existingUser != nil {
|
||||||
|
http.Error(w, "Email already registered", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Verificar se subdomain já existe
|
||||||
|
existingTenant, _ := h.tenantRepo.FindBySubdomain(req.Subdomain)
|
||||||
|
if existingTenant != nil {
|
||||||
|
http.Error(w, "Subdomain already taken", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Hash da senha
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.AdminPassword), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error hashing password: %v", err)
|
||||||
|
http.Error(w, "Error processing password", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Criar tenant (agência)
|
||||||
|
tenant := &domain.Tenant{
|
||||||
|
Name: req.AgencyName,
|
||||||
|
Domain: req.Subdomain + ".aggios.app",
|
||||||
|
Subdomain: req.Subdomain,
|
||||||
|
CNPJ: req.CNPJ,
|
||||||
|
RazaoSocial: req.RazaoSocial,
|
||||||
|
Website: req.Website,
|
||||||
|
Phone: req.Phone,
|
||||||
|
Description: req.Description,
|
||||||
|
Industry: req.Industry,
|
||||||
|
TeamSize: req.TeamSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endereço (se fornecido)
|
||||||
|
if req.Address != nil {
|
||||||
|
tenant.Address = req.Address["street"]
|
||||||
|
tenant.Number = req.Address["number"]
|
||||||
|
tenant.Complement = req.Address["complement"]
|
||||||
|
tenant.Neighborhood = req.Address["neighborhood"]
|
||||||
|
tenant.City = req.Address["city"]
|
||||||
|
tenant.State = req.Address["state"]
|
||||||
|
tenant.Zip = req.Address["cep"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Personalização do template
|
||||||
|
if template.CustomPrimaryColor.Valid {
|
||||||
|
tenant.PrimaryColor = template.CustomPrimaryColor.String
|
||||||
|
}
|
||||||
|
if template.CustomLogoURL.Valid {
|
||||||
|
tenant.LogoURL = template.CustomLogoURL.String
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.tenantRepo.Create(tenant); err != nil {
|
||||||
|
log.Printf("Error creating tenant: %v", err)
|
||||||
|
http.Error(w, "Error creating agency", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Criar usuário admin da agência
|
||||||
|
user := &domain.User{
|
||||||
|
Email: req.AdminEmail,
|
||||||
|
Password: string(hashedPassword),
|
||||||
|
Name: req.AdminName,
|
||||||
|
Role: "ADMIN_AGENCIA",
|
||||||
|
TenantID: &tenant.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.userRepo.Create(user); err != nil {
|
||||||
|
log.Printf("Error creating user: %v", err)
|
||||||
|
http.Error(w, "Error creating admin user", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Incrementar contador de uso do template
|
||||||
|
if err := h.templateRepo.IncrementUsageCount(template.ID.String()); err != nil {
|
||||||
|
log.Printf("Warning: failed to increment usage count: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Preparar resposta com redirect
|
||||||
|
redirectURL := template.RedirectURL.String
|
||||||
|
if redirectURL == "" {
|
||||||
|
redirectURL = "http://" + req.Subdomain + ".localhost/login"
|
||||||
|
}
|
||||||
|
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": template.SuccessMessage.String,
|
||||||
|
"tenant_id": tenant.ID,
|
||||||
|
"user_id": user.ID,
|
||||||
|
"redirect_url": redirectURL,
|
||||||
|
"subdomain": req.Subdomain,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTemplate - SUPERADMIN only
|
||||||
|
func (h *AgencyTemplateHandler) CreateTemplate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req domain.CreateAgencyTemplateRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
formFieldsJSON, _ := repository.FormFieldsToJSON(req.FormFields)
|
||||||
|
modulesJSON, _ := json.Marshal(req.AvailableModules)
|
||||||
|
|
||||||
|
template := &domain.AgencySignupTemplate{
|
||||||
|
Name: req.Name,
|
||||||
|
Slug: req.Slug,
|
||||||
|
Description: req.Description,
|
||||||
|
FormFields: formFieldsJSON,
|
||||||
|
AvailableModules: modulesJSON,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.CustomPrimaryColor != "" {
|
||||||
|
template.CustomPrimaryColor.Valid = true
|
||||||
|
template.CustomPrimaryColor.String = req.CustomPrimaryColor
|
||||||
|
}
|
||||||
|
if req.CustomLogoURL != "" {
|
||||||
|
template.CustomLogoURL.Valid = true
|
||||||
|
template.CustomLogoURL.String = req.CustomLogoURL
|
||||||
|
}
|
||||||
|
if req.RedirectURL != "" {
|
||||||
|
template.RedirectURL.Valid = true
|
||||||
|
template.RedirectURL.String = req.RedirectURL
|
||||||
|
}
|
||||||
|
if req.SuccessMessage != "" {
|
||||||
|
template.SuccessMessage.Valid = true
|
||||||
|
template.SuccessMessage.String = req.SuccessMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.templateRepo.Create(template); err != nil {
|
||||||
|
log.Printf("Error creating template: %v", err)
|
||||||
|
http.Error(w, "Error creating template", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTemplates - SUPERADMIN only
|
||||||
|
func (h *AgencyTemplateHandler) ListTemplates(w http.ResponseWriter, r *http.Request) {
|
||||||
|
templates, err := h.templateRepo.List()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Error fetching templates", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(templates)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -55,28 +56,38 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Login handles user login
|
// Login handles user login
|
||||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Printf("🔐 LOGIN HANDLER CALLED - Method: %s", r.Method)
|
||||||
|
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
|
log.Printf("❌ Method not allowed: %s", r.Method)
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyBytes, err := io.ReadAll(r.Body)
|
bodyBytes, err := io.ReadAll(r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("❌ Failed to read body: %v", err)
|
||||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
log.Printf("📥 Raw body: %s", string(bodyBytes))
|
||||||
|
|
||||||
// Trim whitespace to avoid decode errors caused by BOM or stray chars
|
// Trim whitespace to avoid decode errors caused by BOM or stray chars
|
||||||
sanitized := strings.TrimSpace(string(bodyBytes))
|
sanitized := strings.TrimSpace(string(bodyBytes))
|
||||||
var req domain.LoginRequest
|
var req domain.LoginRequest
|
||||||
if err := json.Unmarshal([]byte(sanitized), &req); err != nil {
|
if err := json.Unmarshal([]byte(sanitized), &req); err != nil {
|
||||||
|
log.Printf("❌ JSON parse error: %v", err)
|
||||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("📧 Login attempt for email: %s", req.Email)
|
||||||
|
|
||||||
response, err := h.authService.Login(req)
|
response, err := h.authService.Login(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("❌ authService.Login error: %v", err)
|
||||||
if err == service.ErrInvalidCredentials {
|
if err == service.ErrInvalidCredentials {
|
||||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||||
} else {
|
} else {
|
||||||
@@ -85,6 +96,7 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("✅ Login successful for %s, role=%s", response.User.Email, response.User.Role)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(response)
|
json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|||||||
38
backend/internal/api/handlers/hash.go
Normal file
38
backend/internal/api/handlers/hash.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HashRequest struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HashResponse struct {
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateHash(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req HashRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to generate hash", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(HashResponse{Hash: string(hash)})
|
||||||
|
}
|
||||||
180
backend/internal/api/handlers/signup_template.go
Normal file
180
backend/internal/api/handlers/signup_template.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"aggios-app/backend/internal/api/middleware"
|
||||||
|
"aggios-app/backend/internal/domain"
|
||||||
|
"aggios-app/backend/internal/repository"
|
||||||
|
"aggios-app/backend/internal/service"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SignupTemplateHandler struct {
|
||||||
|
repo *repository.SignupTemplateRepository
|
||||||
|
userRepo *repository.UserRepository
|
||||||
|
tenantRepo *repository.TenantRepository
|
||||||
|
agencyService *service.AgencyService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSignupTemplateHandler(
|
||||||
|
repo *repository.SignupTemplateRepository,
|
||||||
|
userRepo *repository.UserRepository,
|
||||||
|
tenantRepo *repository.TenantRepository,
|
||||||
|
agencyService *service.AgencyService,
|
||||||
|
) *SignupTemplateHandler {
|
||||||
|
return &SignupTemplateHandler{
|
||||||
|
repo: repo,
|
||||||
|
userRepo: userRepo,
|
||||||
|
tenantRepo: tenantRepo,
|
||||||
|
agencyService: agencyService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTemplate cria um novo template (SuperAdmin)
|
||||||
|
func (h *SignupTemplateHandler) CreateTemplate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var template domain.SignupTemplate
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&template); err != nil {
|
||||||
|
log.Printf("Error decoding request body: %v", err)
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pegar user_id do contexto (do middleware de autenticação)
|
||||||
|
userIDStr, ok := r.Context().Value(middleware.UserIDKey).(string)
|
||||||
|
if !ok || userIDStr == "" {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := uuid.Parse(userIDStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error parsing user_id: %v", err)
|
||||||
|
http.Error(w, "Invalid user ID", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
template.CreatedBy = userID
|
||||||
|
template.IsActive = true
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := h.repo.Create(ctx, &template); err != nil {
|
||||||
|
log.Printf("Error creating signup template: %v", err)
|
||||||
|
http.Error(w, "Error creating template", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTemplates lista todos os templates (SuperAdmin)
|
||||||
|
func (h *SignupTemplateHandler) ListTemplates(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := context.Background()
|
||||||
|
templates, err := h.repo.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error listing signup templates: %v", err)
|
||||||
|
http.Error(w, "Error listing templates", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(templates)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTemplateBySlug retorna um template pelo slug (público)
|
||||||
|
func (h *SignupTemplateHandler) GetTemplateBySlug(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
slug := vars["slug"]
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
template, err := h.repo.FindBySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error finding signup template by slug %s: %v", slug, err)
|
||||||
|
http.Error(w, "Template not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTemplateByID retorna um template pelo ID (SuperAdmin)
|
||||||
|
func (h *SignupTemplateHandler) GetTemplateByID(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
idStr := vars["id"]
|
||||||
|
|
||||||
|
id, err := uuid.Parse(idStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid template ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
template, err := h.repo.FindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error finding signup template by ID %s: %v", idStr, err)
|
||||||
|
http.Error(w, "Template not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTemplate atualiza um template (SuperAdmin)
|
||||||
|
func (h *SignupTemplateHandler) UpdateTemplate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
idStr := vars["id"]
|
||||||
|
|
||||||
|
id, err := uuid.Parse(idStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid template ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var template domain.SignupTemplate
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&template); err != nil {
|
||||||
|
log.Printf("Error decoding request body: %v", err)
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
template.ID = id
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := h.repo.Update(ctx, &template); err != nil {
|
||||||
|
log.Printf("Error updating signup template: %v", err)
|
||||||
|
http.Error(w, "Error updating template", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTemplate deleta um template (SuperAdmin)
|
||||||
|
func (h *SignupTemplateHandler) DeleteTemplate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
idStr := vars["id"]
|
||||||
|
|
||||||
|
id, err := uuid.Parse(idStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid template ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := h.repo.Delete(ctx, id); err != nil {
|
||||||
|
log.Printf("Error deleting signup template: %v", err)
|
||||||
|
http.Error(w, "Error deleting template", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
121
backend/internal/api/handlers/signup_template_register.go
Normal file
121
backend/internal/api/handlers/signup_template_register.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"aggios-app/backend/internal/domain"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PublicSignupRequest representa o cadastro público via template
|
||||||
|
type PublicSignupRequest struct {
|
||||||
|
TemplateSlug string `json:"template_slug"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Subdomain string `json:"subdomain"`
|
||||||
|
CompanyName string `json:"company_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicRegister handles public registration via template
|
||||||
|
func (h *SignupTemplateHandler) PublicRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req PublicSignupRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
log.Printf("Error decoding request body: %v", err)
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 1. Buscar o template
|
||||||
|
template, err := h.repo.FindBySlug(ctx, req.TemplateSlug)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error finding template: %v", err)
|
||||||
|
http.Error(w, "Template not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Incrementar usage_count
|
||||||
|
if err := h.repo.IncrementUsageCount(ctx, template.ID); err != nil {
|
||||||
|
log.Printf("Error incrementing usage count: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Verificar se email já existe
|
||||||
|
emailExists, err := h.userRepo.EmailExists(req.Email)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error checking email: %v", err)
|
||||||
|
http.Error(w, "Error processing registration", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if emailExists {
|
||||||
|
http.Error(w, "Email already registered", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Verificar se subdomain já existe (se fornecido)
|
||||||
|
if req.Subdomain != "" {
|
||||||
|
exists, err := h.tenantRepo.SubdomainExists(req.Subdomain)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error checking subdomain: %v", err)
|
||||||
|
http.Error(w, "Error processing registration", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
http.Error(w, "Subdomain already taken", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Hash da senha
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error hashing password: %v", err)
|
||||||
|
http.Error(w, "Error processing registration", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Criar tenant (empresa/cliente)
|
||||||
|
tenant := &domain.Tenant{
|
||||||
|
Name: req.CompanyName,
|
||||||
|
Domain: req.Subdomain + ".aggios.app",
|
||||||
|
Subdomain: req.Subdomain,
|
||||||
|
Description: "Registered via " + template.Name,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.tenantRepo.Create(tenant); err != nil {
|
||||||
|
log.Printf("Error creating tenant: %v", err)
|
||||||
|
http.Error(w, "Error creating account", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Criar usuário admin do tenant
|
||||||
|
user := &domain.User{
|
||||||
|
Email: req.Email,
|
||||||
|
Password: string(hashedPassword),
|
||||||
|
Name: req.Name,
|
||||||
|
Role: "CLIENTE",
|
||||||
|
TenantID: &tenant.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.userRepo.Create(user); err != nil {
|
||||||
|
log.Printf("Error creating user: %v", err)
|
||||||
|
http.Error(w, "Error creating user", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Resposta de sucesso
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": template.SuccessMessage,
|
||||||
|
"tenant_id": tenant.ID,
|
||||||
|
"user_id": user.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
@@ -40,3 +40,30 @@ func (h *TenantHandler) ListAll(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
json.NewEncoder(w).Encode(tenants)
|
json.NewEncoder(w).Encode(tenants)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckExists returns 200 if tenant exists by subdomain, otherwise 404
|
||||||
|
func (h *TenantHandler) CheckExists(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
subdomain := r.URL.Query().Get("subdomain")
|
||||||
|
if subdomain == "" {
|
||||||
|
http.Error(w, "subdomain is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := h.tenantService.GetBySubdomain(subdomain)
|
||||||
|
if err != nil {
|
||||||
|
if err == service.ErrTenantNotFound {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|||||||
130
backend/internal/api/handlers/upload.go
Normal file
130
backend/internal/api/handlers/upload.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"aggios-app/backend/internal/api/middleware"
|
||||||
|
"aggios-app/backend/internal/config"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/minio/minio-go/v7"
|
||||||
|
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UploadHandler handles file upload endpoints
|
||||||
|
type UploadHandler struct {
|
||||||
|
minioClient *minio.Client
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUploadHandler creates a new upload handler
|
||||||
|
func NewUploadHandler(cfg *config.Config) (*UploadHandler, error) {
|
||||||
|
// Initialize MinIO client
|
||||||
|
minioClient, err := minio.New(cfg.Minio.Endpoint, &minio.Options{
|
||||||
|
Creds: credentials.NewStaticV4(cfg.Minio.RootUser, cfg.Minio.RootPassword, ""),
|
||||||
|
Secure: cfg.Minio.UseSSL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create MinIO client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure bucket exists
|
||||||
|
ctx := context.Background()
|
||||||
|
bucketName := cfg.Minio.BucketName
|
||||||
|
exists, err := minioClient.BucketExists(ctx, bucketName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check bucket existence: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
err = minioClient.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create bucket: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &UploadHandler{
|
||||||
|
minioClient: minioClient,
|
||||||
|
cfg: cfg,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadResponse represents the upload response
|
||||||
|
type UploadResponse struct {
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
FileName string `json:"file_name"`
|
||||||
|
FileURL string `json:"file_url"`
|
||||||
|
FileSize int64 `json:"file_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload handles file upload
|
||||||
|
func (h *UploadHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get user ID from context (optional for signup flow)
|
||||||
|
userIDStr, _ := r.Context().Value(middleware.UserIDKey).(string)
|
||||||
|
|
||||||
|
// Use temp tenant for unauthenticated uploads (signup flow)
|
||||||
|
tenantID := uuid.MustParse("00000000-0000-0000-0000-000000000000")
|
||||||
|
if userIDStr != "" {
|
||||||
|
// TODO: Query database to get tenant_id from user_id when authenticated
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse multipart form (max 10MB)
|
||||||
|
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||||
|
http.Error(w, "File too large (max 10MB)", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file from form
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to read file", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Validate file type (images only)
|
||||||
|
contentType := header.Header.Get("Content-Type")
|
||||||
|
if !strings.HasPrefix(contentType, "image/") {
|
||||||
|
http.Error(w, "Only images are allowed", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique file ID
|
||||||
|
fileID := uuid.New()
|
||||||
|
ext := filepath.Ext(header.Filename)
|
||||||
|
objectName := fmt.Sprintf("tenants/%s/logos/%s%s", tenantID.String(), fileID.String(), ext)
|
||||||
|
|
||||||
|
// Upload to MinIO
|
||||||
|
ctx := context.Background()
|
||||||
|
_, err = h.minioClient.PutObject(ctx, h.cfg.Minio.BucketName, objectName, file, header.Size, minio.PutObjectOptions{
|
||||||
|
ContentType: contentType,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to upload file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate public URL (replace internal hostname with localhost for browser access)
|
||||||
|
fileURL := fmt.Sprintf("http://localhost:9000/%s/%s", h.cfg.Minio.BucketName, objectName)
|
||||||
|
|
||||||
|
// Return response
|
||||||
|
response := UploadResponse{
|
||||||
|
FileID: fileID.String(),
|
||||||
|
FileName: header.Filename,
|
||||||
|
FileURL: fileURL,
|
||||||
|
FileSize: header.Size,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
type contextKey string
|
type contextKey string
|
||||||
|
|
||||||
const UserIDKey contextKey = "userID"
|
const UserIDKey contextKey = "userID"
|
||||||
|
const TenantIDKey contextKey = "tenantID"
|
||||||
|
|
||||||
// Auth validates JWT tokens
|
// Auth validates JWT tokens
|
||||||
func Auth(cfg *config.Config) func(http.Handler) http.Handler {
|
func Auth(cfg *config.Config) func(http.Handler) http.Handler {
|
||||||
@@ -45,9 +46,27 @@ func Auth(cfg *config.Config) func(http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID := claims["user_id"].(string)
|
// Verificar se user_id existe e é do tipo correto
|
||||||
|
userIDClaim, ok := claims["user_id"]
|
||||||
|
if !ok || userIDClaim == nil {
|
||||||
|
http.Error(w, "Missing user_id in token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, ok := userIDClaim.(string)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Invalid user_id format in token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// tenant_id pode ser nil para SuperAdmin
|
||||||
|
var tenantID string
|
||||||
|
if tenantIDClaim, ok := claims["tenant_id"]; ok && tenantIDClaim != nil {
|
||||||
|
tenantID, _ = tenantIDClaim.(string)
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
||||||
|
ctx = context.WithValue(ctx, TenantIDKey, tenantID)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,22 +2,29 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"aggios-app/backend/internal/repository"
|
"aggios-app/backend/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
type tenantContextKey string
|
const SubdomainKey contextKey = "subdomain"
|
||||||
|
|
||||||
const TenantIDKey tenantContextKey = "tenantID"
|
|
||||||
const SubdomainKey tenantContextKey = "subdomain"
|
|
||||||
|
|
||||||
// TenantDetector detects tenant from subdomain
|
// TenantDetector detects tenant from subdomain
|
||||||
func TenantDetector(tenantRepo *repository.TenantRepository) func(http.Handler) http.Handler {
|
func TenantDetector(tenantRepo *repository.TenantRepository) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
host := r.Host
|
// Get host from X-Forwarded-Host header (set by Next.js proxy) or Host header
|
||||||
|
host := r.Header.Get("X-Forwarded-Host")
|
||||||
|
if host == "" {
|
||||||
|
host = r.Header.Get("X-Original-Host")
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
host = r.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("TenantDetector: host = %s (from headers), path = %s", host, r.RequestURI)
|
||||||
|
|
||||||
// Extract subdomain
|
// Extract subdomain
|
||||||
// Examples:
|
// Examples:
|
||||||
@@ -39,6 +46,8 @@ func TenantDetector(tenantRepo *repository.TenantRepository) func(http.Handler)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("TenantDetector: extracted subdomain = %s", subdomain)
|
||||||
|
|
||||||
// Add subdomain to context
|
// Add subdomain to context
|
||||||
ctx := context.WithValue(r.Context(), SubdomainKey, subdomain)
|
ctx := context.WithValue(r.Context(), SubdomainKey, subdomain)
|
||||||
|
|
||||||
@@ -46,7 +55,10 @@ func TenantDetector(tenantRepo *repository.TenantRepository) func(http.Handler)
|
|||||||
if subdomain != "" && subdomain != "dash" && subdomain != "api" && subdomain != "localhost" {
|
if subdomain != "" && subdomain != "dash" && subdomain != "api" && subdomain != "localhost" {
|
||||||
tenant, err := tenantRepo.FindBySubdomain(subdomain)
|
tenant, err := tenantRepo.FindBySubdomain(subdomain)
|
||||||
if err == nil && tenant != nil {
|
if err == nil && tenant != nil {
|
||||||
|
log.Printf("TenantDetector: found tenant %s for subdomain %s", tenant.ID.String(), subdomain)
|
||||||
ctx = context.WithValue(ctx, TenantIDKey, tenant.ID.String())
|
ctx = context.WithValue(ctx, TenantIDKey, tenant.ID.String())
|
||||||
|
} else {
|
||||||
|
log.Printf("TenantDetector: tenant not found for subdomain %s (err=%v)", subdomain, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type Config struct {
|
|||||||
JWT JWTConfig
|
JWT JWTConfig
|
||||||
Security SecurityConfig
|
Security SecurityConfig
|
||||||
App AppConfig
|
App AppConfig
|
||||||
|
Minio MinioConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppConfig holds application-level settings
|
// AppConfig holds application-level settings
|
||||||
@@ -45,6 +46,15 @@ type SecurityConfig struct {
|
|||||||
PasswordMinLength int
|
PasswordMinLength int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MinioConfig holds MinIO configuration
|
||||||
|
type MinioConfig struct {
|
||||||
|
Endpoint string
|
||||||
|
RootUser string
|
||||||
|
RootPassword string
|
||||||
|
UseSSL bool
|
||||||
|
BucketName string
|
||||||
|
}
|
||||||
|
|
||||||
// Load loads configuration from environment variables
|
// Load loads configuration from environment variables
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
env := getEnvOrDefault("APP_ENV", "development")
|
env := getEnvOrDefault("APP_ENV", "development")
|
||||||
@@ -53,6 +63,12 @@ func Load() *Config {
|
|||||||
baseDomain = "aggios.app"
|
baseDomain = "aggios.app"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rate limit: more lenient in dev, strict in prod
|
||||||
|
maxAttempts := 30
|
||||||
|
if env == "production" {
|
||||||
|
maxAttempts = 5
|
||||||
|
}
|
||||||
|
|
||||||
return &Config{
|
return &Config{
|
||||||
Server: ServerConfig{
|
Server: ServerConfig{
|
||||||
Port: getEnvOrDefault("SERVER_PORT", "8080"),
|
Port: getEnvOrDefault("SERVER_PORT", "8080"),
|
||||||
@@ -81,9 +97,16 @@ func Load() *Config {
|
|||||||
"https://dash.aggios.app",
|
"https://dash.aggios.app",
|
||||||
"https://www.aggios.app",
|
"https://www.aggios.app",
|
||||||
},
|
},
|
||||||
MaxAttemptsPerMin: 5,
|
MaxAttemptsPerMin: maxAttempts,
|
||||||
PasswordMinLength: 8,
|
PasswordMinLength: 8,
|
||||||
},
|
},
|
||||||
|
Minio: MinioConfig{
|
||||||
|
Endpoint: getEnvOrDefault("MINIO_ENDPOINT", "minio:9000"),
|
||||||
|
RootUser: getEnvOrDefault("MINIO_ROOT_USER", "minioadmin"),
|
||||||
|
RootPassword: getEnvOrDefault("MINIO_ROOT_PASSWORD", "changeme"),
|
||||||
|
UseSSL: getEnvOrDefault("MINIO_USE_SSL", "false") == "true",
|
||||||
|
BucketName: getEnvOrDefault("MINIO_BUCKET_NAME", "aggios"),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
66
backend/internal/domain/agency_template.go
Normal file
66
backend/internal/domain/agency_template.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgencySignupTemplate represents a signup template for agencies (SuperAdmin → Agency)
|
||||||
|
type AgencySignupTemplate struct {
|
||||||
|
ID uuid.UUID `json:"id" db:"id"`
|
||||||
|
Name string `json:"name" db:"name"`
|
||||||
|
Slug string `json:"slug" db:"slug"`
|
||||||
|
Description string `json:"description" db:"description"`
|
||||||
|
FormFields []byte `json:"form_fields" db:"form_fields"` // JSONB
|
||||||
|
AvailableModules []byte `json:"available_modules" db:"available_modules"` // JSONB
|
||||||
|
CustomPrimaryColor sql.NullString `json:"custom_primary_color" db:"custom_primary_color"`
|
||||||
|
CustomLogoURL sql.NullString `json:"custom_logo_url" db:"custom_logo_url"`
|
||||||
|
RedirectURL sql.NullString `json:"redirect_url" db:"redirect_url"`
|
||||||
|
SuccessMessage sql.NullString `json:"success_message" db:"success_message"`
|
||||||
|
IsActive bool `json:"is_active" db:"is_active"`
|
||||||
|
UsageCount int `json:"usage_count" db:"usage_count"`
|
||||||
|
MaxUses sql.NullInt64 `json:"max_uses" db:"max_uses"`
|
||||||
|
ExpiresAt sql.NullTime `json:"expires_at" db:"expires_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAgencyTemplateRequest for creating a new agency template
|
||||||
|
type CreateAgencyTemplateRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
FormFields []string `json:"form_fields"`
|
||||||
|
AvailableModules []string `json:"available_modules"`
|
||||||
|
CustomPrimaryColor string `json:"custom_primary_color"`
|
||||||
|
CustomLogoURL string `json:"custom_logo_url"`
|
||||||
|
RedirectURL string `json:"redirect_url"`
|
||||||
|
SuccessMessage string `json:"success_message"`
|
||||||
|
MaxUses int `json:"max_uses"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgencyRegistrationViaTemplate for public registration via template
|
||||||
|
type AgencyRegistrationViaTemplate struct {
|
||||||
|
TemplateSlug string `json:"template_slug"`
|
||||||
|
|
||||||
|
// Agency info
|
||||||
|
AgencyName string `json:"agencyName"`
|
||||||
|
Subdomain string `json:"subdomain"`
|
||||||
|
CNPJ string `json:"cnpj"`
|
||||||
|
RazaoSocial string `json:"razaoSocial"`
|
||||||
|
Website string `json:"website"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
AdminEmail string `json:"adminEmail"`
|
||||||
|
AdminPassword string `json:"adminPassword"`
|
||||||
|
AdminName string `json:"adminName"`
|
||||||
|
|
||||||
|
// Optional fields
|
||||||
|
Description string `json:"description"`
|
||||||
|
Industry string `json:"industry"`
|
||||||
|
TeamSize string `json:"teamSize"`
|
||||||
|
Address map[string]string `json:"address"`
|
||||||
|
}
|
||||||
35
backend/internal/domain/signup_template.go
Normal file
35
backend/internal/domain/signup_template.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormField representa um campo do formulário de cadastro
|
||||||
|
type FormField struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Type string `json:"type"` // email, password, text, tel, etc
|
||||||
|
Required bool `json:"required"`
|
||||||
|
Order int `json:"order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignupTemplate representa um template de cadastro personalizado
|
||||||
|
type SignupTemplate struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
FormFields []FormField `json:"form_fields"`
|
||||||
|
EnabledModules []string `json:"enabled_modules"` // ["CRM", "ERP", "PROJECTS"]
|
||||||
|
RedirectURL string `json:"redirect_url,omitempty"`
|
||||||
|
SuccessMessage string `json:"success_message,omitempty"`
|
||||||
|
CustomLogoURL string `json:"custom_logo_url,omitempty"`
|
||||||
|
CustomPrimaryColor string `json:"custom_primary_color,omitempty"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
UsageCount int `json:"usage_count"`
|
||||||
|
CreatedBy uuid.UUID `json:"created_by"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
@@ -18,11 +18,19 @@ type Tenant struct {
|
|||||||
Phone string `json:"phone,omitempty" db:"phone"`
|
Phone string `json:"phone,omitempty" db:"phone"`
|
||||||
Website string `json:"website,omitempty" db:"website"`
|
Website string `json:"website,omitempty" db:"website"`
|
||||||
Address string `json:"address,omitempty" db:"address"`
|
Address string `json:"address,omitempty" db:"address"`
|
||||||
|
Neighborhood string `json:"neighborhood,omitempty" db:"neighborhood"`
|
||||||
|
Number string `json:"number,omitempty" db:"number"`
|
||||||
|
Complement string `json:"complement,omitempty" db:"complement"`
|
||||||
City string `json:"city,omitempty" db:"city"`
|
City string `json:"city,omitempty" db:"city"`
|
||||||
State string `json:"state,omitempty" db:"state"`
|
State string `json:"state,omitempty" db:"state"`
|
||||||
Zip string `json:"zip,omitempty" db:"zip"`
|
Zip string `json:"zip,omitempty" db:"zip"`
|
||||||
Description string `json:"description,omitempty" db:"description"`
|
Description string `json:"description,omitempty" db:"description"`
|
||||||
Industry string `json:"industry,omitempty" db:"industry"`
|
Industry string `json:"industry,omitempty" db:"industry"`
|
||||||
|
TeamSize string `json:"team_size,omitempty" db:"team_size"`
|
||||||
|
PrimaryColor string `json:"primary_color,omitempty" db:"primary_color"`
|
||||||
|
SecondaryColor string `json:"secondary_color,omitempty" db:"secondary_color"`
|
||||||
|
LogoURL string `json:"logo_url,omitempty" db:"logo_url"`
|
||||||
|
LogoHorizontalURL string `json:"logo_horizontal_url,omitempty" db:"logo_horizontal_url"`
|
||||||
IsActive bool `json:"is_active" db:"is_active"`
|
IsActive bool `json:"is_active" db:"is_active"`
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ type RegisterAgencyRequest struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Website string `json:"website"`
|
Website string `json:"website"`
|
||||||
Industry string `json:"industry"`
|
Industry string `json:"industry"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
TeamSize string `json:"teamSize"`
|
||||||
|
|
||||||
// Endereço
|
// Endereço
|
||||||
CEP string `json:"cep"`
|
CEP string `json:"cep"`
|
||||||
@@ -46,12 +48,59 @@ type RegisterAgencyRequest struct {
|
|||||||
Number string `json:"number"`
|
Number string `json:"number"`
|
||||||
Complement string `json:"complement"`
|
Complement string `json:"complement"`
|
||||||
|
|
||||||
|
// Personalização
|
||||||
|
PrimaryColor string `json:"primaryColor"`
|
||||||
|
SecondaryColor string `json:"secondaryColor"`
|
||||||
|
LogoURL string `json:"logoUrl"`
|
||||||
|
LogoHorizontalURL string `json:"logoHorizontalUrl"`
|
||||||
|
|
||||||
// Admin da Agência
|
// Admin da Agência
|
||||||
AdminEmail string `json:"adminEmail"`
|
AdminEmail string `json:"adminEmail"`
|
||||||
AdminPassword string `json:"adminPassword"`
|
AdminPassword string `json:"adminPassword"`
|
||||||
AdminName string `json:"adminName"`
|
AdminName string `json:"adminName"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PublicRegisterAgencyRequest represents the public signup payload
|
||||||
|
type PublicRegisterAgencyRequest struct {
|
||||||
|
// User
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
FullName string `json:"fullName"`
|
||||||
|
Newsletter bool `json:"newsletter"`
|
||||||
|
|
||||||
|
// Company
|
||||||
|
CompanyName string `json:"companyName"`
|
||||||
|
CNPJ string `json:"cnpj"`
|
||||||
|
RazaoSocial string `json:"razaoSocial"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Website string `json:"website"`
|
||||||
|
Industry string `json:"industry"`
|
||||||
|
TeamSize string `json:"teamSize"`
|
||||||
|
|
||||||
|
// Address
|
||||||
|
CEP string `json:"cep"`
|
||||||
|
State string `json:"state"`
|
||||||
|
City string `json:"city"`
|
||||||
|
Neighborhood string `json:"neighborhood"`
|
||||||
|
Street string `json:"street"`
|
||||||
|
Number string `json:"number"`
|
||||||
|
Complement string `json:"complement"`
|
||||||
|
|
||||||
|
// Contacts (simplified for now, taking the first one as phone if available)
|
||||||
|
Contacts []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Whatsapp string `json:"whatsapp"`
|
||||||
|
} `json:"contacts"`
|
||||||
|
|
||||||
|
// Domain
|
||||||
|
Subdomain string `json:"subdomain"`
|
||||||
|
|
||||||
|
// Branding
|
||||||
|
PrimaryColor string `json:"primaryColor"`
|
||||||
|
SecondaryColor string `json:"secondaryColor"`
|
||||||
|
LogoURL string `json:"logoUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterClientRequest represents client registration (ADMIN_AGENCIA only)
|
// RegisterClientRequest represents client registration (ADMIN_AGENCIA only)
|
||||||
type RegisterClientRequest struct {
|
type RegisterClientRequest struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
|||||||
168
backend/internal/repository/agency_template_repository.go
Normal file
168
backend/internal/repository/agency_template_repository.go
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"aggios-app/backend/internal/domain"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AgencyTemplateRepository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAgencyTemplateRepository(db *sql.DB) *AgencyTemplateRepository {
|
||||||
|
return &AgencyTemplateRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AgencyTemplateRepository) Create(template *domain.AgencySignupTemplate) error {
|
||||||
|
query := `
|
||||||
|
INSERT INTO agency_signup_templates (
|
||||||
|
name, slug, description, form_fields, available_modules,
|
||||||
|
custom_primary_color, custom_logo_url, redirect_url, success_message, max_uses
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING id, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
return r.db.QueryRow(
|
||||||
|
query,
|
||||||
|
template.Name,
|
||||||
|
template.Slug,
|
||||||
|
template.Description,
|
||||||
|
template.FormFields,
|
||||||
|
template.AvailableModules,
|
||||||
|
template.CustomPrimaryColor,
|
||||||
|
template.CustomLogoURL,
|
||||||
|
template.RedirectURL,
|
||||||
|
template.SuccessMessage,
|
||||||
|
template.MaxUses,
|
||||||
|
).Scan(&template.ID, &template.CreatedAt, &template.UpdatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AgencyTemplateRepository) FindBySlug(slug string) (*domain.AgencySignupTemplate, error) {
|
||||||
|
var template domain.AgencySignupTemplate
|
||||||
|
query := `
|
||||||
|
SELECT id, name, slug, description, form_fields, available_modules,
|
||||||
|
custom_primary_color, custom_logo_url, redirect_url, success_message,
|
||||||
|
is_active, usage_count, max_uses, expires_at, created_at, updated_at
|
||||||
|
FROM agency_signup_templates
|
||||||
|
WHERE slug = $1 AND is_active = true
|
||||||
|
`
|
||||||
|
|
||||||
|
err := r.db.QueryRow(query, slug).Scan(
|
||||||
|
&template.ID, &template.Name, &template.Slug, &template.Description,
|
||||||
|
&template.FormFields, &template.AvailableModules,
|
||||||
|
&template.CustomPrimaryColor, &template.CustomLogoURL,
|
||||||
|
&template.RedirectURL, &template.SuccessMessage,
|
||||||
|
&template.IsActive, &template.UsageCount, &template.MaxUses,
|
||||||
|
&template.ExpiresAt, &template.CreatedAt, &template.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar se expirou
|
||||||
|
if template.ExpiresAt.Valid && template.ExpiresAt.Time.Before(sql.NullTime{}.Time) {
|
||||||
|
return nil, fmt.Errorf("template expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar limite de usos
|
||||||
|
if template.MaxUses.Valid && template.UsageCount >= int(template.MaxUses.Int64) {
|
||||||
|
return nil, fmt.Errorf("template usage limit reached")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &template, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AgencyTemplateRepository) List() ([]domain.AgencySignupTemplate, error) {
|
||||||
|
var templates []domain.AgencySignupTemplate
|
||||||
|
query := `
|
||||||
|
SELECT id, name, slug, description, form_fields, available_modules,
|
||||||
|
custom_primary_color, custom_logo_url, redirect_url, success_message,
|
||||||
|
is_active, usage_count, max_uses, expires_at, created_at, updated_at
|
||||||
|
FROM agency_signup_templates
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var t domain.AgencySignupTemplate
|
||||||
|
if err := rows.Scan(
|
||||||
|
&t.ID, &t.Name, &t.Slug, &t.Description,
|
||||||
|
&t.FormFields, &t.AvailableModules,
|
||||||
|
&t.CustomPrimaryColor, &t.CustomLogoURL,
|
||||||
|
&t.RedirectURL, &t.SuccessMessage,
|
||||||
|
&t.IsActive, &t.UsageCount, &t.MaxUses,
|
||||||
|
&t.ExpiresAt, &t.CreatedAt, &t.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
templates = append(templates, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
return templates, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AgencyTemplateRepository) IncrementUsageCount(id string) error {
|
||||||
|
query := `UPDATE agency_signup_templates SET usage_count = usage_count + 1 WHERE id = $1`
|
||||||
|
_, err := r.db.Exec(query, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AgencyTemplateRepository) Update(template *domain.AgencySignupTemplate) error {
|
||||||
|
query := `
|
||||||
|
UPDATE agency_signup_templates
|
||||||
|
SET name = $1, description = $2, form_fields = $3, available_modules = $4,
|
||||||
|
custom_primary_color = $5, custom_logo_url = $6, redirect_url = $7,
|
||||||
|
success_message = $8, is_active = $9, max_uses = $10, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $11
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := r.db.Exec(
|
||||||
|
query,
|
||||||
|
template.Name,
|
||||||
|
template.Description,
|
||||||
|
template.FormFields,
|
||||||
|
template.AvailableModules,
|
||||||
|
template.CustomPrimaryColor,
|
||||||
|
template.CustomLogoURL,
|
||||||
|
template.RedirectURL,
|
||||||
|
template.SuccessMessage,
|
||||||
|
template.IsActive,
|
||||||
|
template.MaxUses,
|
||||||
|
template.ID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AgencyTemplateRepository) Delete(id string) error {
|
||||||
|
query := `DELETE FROM agency_signup_templates WHERE id = $1`
|
||||||
|
_, err := r.db.Exec(query, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Convert form fields to JSON
|
||||||
|
func FormFieldsToJSON(fields []string) ([]byte, error) {
|
||||||
|
type FormField struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Required bool `json:"required"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var formFields []FormField
|
||||||
|
for _, field := range fields {
|
||||||
|
formFields = append(formFields, FormField{
|
||||||
|
Name: field,
|
||||||
|
Required: field == "agencyName" || field == "subdomain" || field == "adminEmail" || field == "adminPassword",
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(formFields)
|
||||||
|
}
|
||||||
280
backend/internal/repository/signup_template_repository.go
Normal file
280
backend/internal/repository/signup_template_repository.go
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"aggios-app/backend/internal/domain"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SignupTemplateRepository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSignupTemplateRepository(db *sql.DB) *SignupTemplateRepository {
|
||||||
|
return &SignupTemplateRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create cria um novo template de cadastro
|
||||||
|
func (r *SignupTemplateRepository) Create(ctx context.Context, template *domain.SignupTemplate) error {
|
||||||
|
formFieldsJSON, err := json.Marshal(template.FormFields)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error marshaling form_fields: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
modulesJSON, err := json.Marshal(template.EnabledModules)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error marshaling enabled_modules: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
INSERT INTO signup_templates (
|
||||||
|
name, description, slug, form_fields, enabled_modules,
|
||||||
|
redirect_url, success_message, custom_logo_url, custom_primary_color,
|
||||||
|
is_active, created_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
RETURNING id, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
err = r.db.QueryRowContext(
|
||||||
|
ctx, query,
|
||||||
|
template.Name, template.Description, template.Slug,
|
||||||
|
formFieldsJSON, modulesJSON,
|
||||||
|
template.RedirectURL, template.SuccessMessage,
|
||||||
|
template.CustomLogoURL, template.CustomPrimaryColor,
|
||||||
|
template.IsActive, template.CreatedBy,
|
||||||
|
).Scan(&template.ID, &template.CreatedAt, &template.UpdatedAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error creating signup template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindBySlug busca um template pelo slug
|
||||||
|
func (r *SignupTemplateRepository) FindBySlug(ctx context.Context, slug string) (*domain.SignupTemplate, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, description, slug, form_fields, enabled_modules,
|
||||||
|
redirect_url, success_message, custom_logo_url, custom_primary_color,
|
||||||
|
is_active, usage_count, created_by, created_at, updated_at
|
||||||
|
FROM signup_templates
|
||||||
|
WHERE slug = $1 AND is_active = true
|
||||||
|
`
|
||||||
|
|
||||||
|
var template domain.SignupTemplate
|
||||||
|
var formFieldsJSON, modulesJSON []byte
|
||||||
|
var redirectURL, successMessage, customLogoURL, customPrimaryColor sql.NullString
|
||||||
|
|
||||||
|
err := r.db.QueryRowContext(ctx, query, slug).Scan(
|
||||||
|
&template.ID, &template.Name, &template.Description, &template.Slug,
|
||||||
|
&formFieldsJSON, &modulesJSON,
|
||||||
|
&redirectURL, &successMessage,
|
||||||
|
&customLogoURL, &customPrimaryColor,
|
||||||
|
&template.IsActive, &template.UsageCount, &template.CreatedBy,
|
||||||
|
&template.CreatedAt, &template.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if redirectURL.Valid {
|
||||||
|
template.RedirectURL = redirectURL.String
|
||||||
|
}
|
||||||
|
if successMessage.Valid {
|
||||||
|
template.SuccessMessage = successMessage.String
|
||||||
|
}
|
||||||
|
if customLogoURL.Valid {
|
||||||
|
template.CustomLogoURL = customLogoURL.String
|
||||||
|
}
|
||||||
|
if customPrimaryColor.Valid {
|
||||||
|
template.CustomPrimaryColor = customPrimaryColor.String
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("signup template not found")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error finding signup template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(formFieldsJSON, &template.FormFields); err != nil {
|
||||||
|
return nil, fmt.Errorf("error unmarshaling form_fields: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(modulesJSON, &template.EnabledModules); err != nil {
|
||||||
|
return nil, fmt.Errorf("error unmarshaling enabled_modules: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &template, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID busca um template pelo ID
|
||||||
|
func (r *SignupTemplateRepository) FindByID(ctx context.Context, id uuid.UUID) (*domain.SignupTemplate, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, description, slug, form_fields, enabled_modules,
|
||||||
|
redirect_url, success_message, custom_logo_url, custom_primary_color,
|
||||||
|
is_active, usage_count, created_by, created_at, updated_at
|
||||||
|
FROM signup_templates
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
var template domain.SignupTemplate
|
||||||
|
var formFieldsJSON, modulesJSON []byte
|
||||||
|
var redirectURL, successMessage, customLogoURL, customPrimaryColor sql.NullString
|
||||||
|
|
||||||
|
err := r.db.QueryRowContext(ctx, query, id).Scan(
|
||||||
|
&template.ID, &template.Name, &template.Description, &template.Slug,
|
||||||
|
&formFieldsJSON, &modulesJSON,
|
||||||
|
&redirectURL, &successMessage,
|
||||||
|
&customLogoURL, &customPrimaryColor,
|
||||||
|
&template.IsActive, &template.UsageCount, &template.CreatedBy,
|
||||||
|
&template.CreatedAt, &template.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if redirectURL.Valid {
|
||||||
|
template.RedirectURL = redirectURL.String
|
||||||
|
}
|
||||||
|
if successMessage.Valid {
|
||||||
|
template.SuccessMessage = successMessage.String
|
||||||
|
}
|
||||||
|
if customLogoURL.Valid {
|
||||||
|
template.CustomLogoURL = customLogoURL.String
|
||||||
|
}
|
||||||
|
if customPrimaryColor.Valid {
|
||||||
|
template.CustomPrimaryColor = customPrimaryColor.String
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("signup template not found")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error finding signup template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(formFieldsJSON, &template.FormFields); err != nil {
|
||||||
|
return nil, fmt.Errorf("error unmarshaling form_fields: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(modulesJSON, &template.EnabledModules); err != nil {
|
||||||
|
return nil, fmt.Errorf("error unmarshaling enabled_modules: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &template, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List lista todos os templates
|
||||||
|
func (r *SignupTemplateRepository) List(ctx context.Context) ([]*domain.SignupTemplate, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, description, slug, form_fields, enabled_modules,
|
||||||
|
redirect_url, success_message, custom_logo_url, custom_primary_color,
|
||||||
|
is_active, usage_count, created_by, created_at, updated_at
|
||||||
|
FROM signup_templates
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.db.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error listing signup templates: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var templates []*domain.SignupTemplate
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var template domain.SignupTemplate
|
||||||
|
var formFieldsJSON, modulesJSON []byte
|
||||||
|
var redirectURL, successMessage, customLogoURL, customPrimaryColor sql.NullString
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&template.ID, &template.Name, &template.Description, &template.Slug,
|
||||||
|
&formFieldsJSON, &modulesJSON,
|
||||||
|
&redirectURL, &successMessage,
|
||||||
|
&customLogoURL, &customPrimaryColor,
|
||||||
|
&template.IsActive, &template.UsageCount, &template.CreatedBy,
|
||||||
|
&template.CreatedAt, &template.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error scanning signup template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if redirectURL.Valid {
|
||||||
|
template.RedirectURL = redirectURL.String
|
||||||
|
}
|
||||||
|
if successMessage.Valid {
|
||||||
|
template.SuccessMessage = successMessage.String
|
||||||
|
}
|
||||||
|
if customLogoURL.Valid {
|
||||||
|
template.CustomLogoURL = customLogoURL.String
|
||||||
|
}
|
||||||
|
if customPrimaryColor.Valid {
|
||||||
|
template.CustomPrimaryColor = customPrimaryColor.String
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(formFieldsJSON, &template.FormFields); err != nil {
|
||||||
|
return nil, fmt.Errorf("error unmarshaling form_fields: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(modulesJSON, &template.EnabledModules); err != nil {
|
||||||
|
return nil, fmt.Errorf("error unmarshaling enabled_modules: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
templates = append(templates, &template)
|
||||||
|
}
|
||||||
|
|
||||||
|
return templates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementUsageCount incrementa o contador de uso
|
||||||
|
func (r *SignupTemplateRepository) IncrementUsageCount(ctx context.Context, id uuid.UUID) error {
|
||||||
|
query := `UPDATE signup_templates SET usage_count = usage_count + 1 WHERE id = $1`
|
||||||
|
_, err := r.db.ExecContext(ctx, query, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update atualiza um template
|
||||||
|
func (r *SignupTemplateRepository) Update(ctx context.Context, template *domain.SignupTemplate) error {
|
||||||
|
formFieldsJSON, err := json.Marshal(template.FormFields)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error marshaling form_fields: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
modulesJSON, err := json.Marshal(template.EnabledModules)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error marshaling enabled_modules: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
UPDATE signup_templates SET
|
||||||
|
name = $1, description = $2, slug = $3, form_fields = $4, enabled_modules = $5,
|
||||||
|
redirect_url = $6, success_message = $7, custom_logo_url = $8, custom_primary_color = $9,
|
||||||
|
is_active = $10
|
||||||
|
WHERE id = $11
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err = r.db.ExecContext(
|
||||||
|
ctx, query,
|
||||||
|
template.Name, template.Description, template.Slug,
|
||||||
|
formFieldsJSON, modulesJSON,
|
||||||
|
template.RedirectURL, template.SuccessMessage,
|
||||||
|
template.CustomLogoURL, template.CustomPrimaryColor,
|
||||||
|
template.IsActive, template.ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error updating signup template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deleta um template
|
||||||
|
func (r *SignupTemplateRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||||
|
query := `DELETE FROM signup_templates WHERE id = $1`
|
||||||
|
_, err := r.db.ExecContext(ctx, query, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error deleting signup template: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -19,14 +19,21 @@ func NewTenantRepository(db *sql.DB) *TenantRepository {
|
|||||||
return &TenantRepository{db: db}
|
return &TenantRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DB returns the underlying database connection
|
||||||
|
func (r *TenantRepository) DB() *sql.DB {
|
||||||
|
return r.db
|
||||||
|
}
|
||||||
|
|
||||||
// Create creates a new tenant
|
// Create creates a new tenant
|
||||||
func (r *TenantRepository) Create(tenant *domain.Tenant) error {
|
func (r *TenantRepository) Create(tenant *domain.Tenant) error {
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO tenants (
|
INSERT INTO tenants (
|
||||||
id, name, domain, subdomain, cnpj, razao_social, email, website,
|
id, name, domain, subdomain, cnpj, razao_social, email, phone, website,
|
||||||
address, city, state, zip, description, industry, created_at, updated_at
|
address, neighborhood, number, complement, city, state, zip,
|
||||||
|
description, industry, team_size, primary_color, secondary_color,
|
||||||
|
logo_url, logo_horizontal_url, created_at, updated_at
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25)
|
||||||
RETURNING id, created_at, updated_at
|
RETURNING id, created_at, updated_at
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -44,13 +51,22 @@ func (r *TenantRepository) Create(tenant *domain.Tenant) error {
|
|||||||
tenant.CNPJ,
|
tenant.CNPJ,
|
||||||
tenant.RazaoSocial,
|
tenant.RazaoSocial,
|
||||||
tenant.Email,
|
tenant.Email,
|
||||||
|
tenant.Phone,
|
||||||
tenant.Website,
|
tenant.Website,
|
||||||
tenant.Address,
|
tenant.Address,
|
||||||
|
tenant.Neighborhood,
|
||||||
|
tenant.Number,
|
||||||
|
tenant.Complement,
|
||||||
tenant.City,
|
tenant.City,
|
||||||
tenant.State,
|
tenant.State,
|
||||||
tenant.Zip,
|
tenant.Zip,
|
||||||
tenant.Description,
|
tenant.Description,
|
||||||
tenant.Industry,
|
tenant.Industry,
|
||||||
|
tenant.TeamSize,
|
||||||
|
tenant.PrimaryColor,
|
||||||
|
tenant.SecondaryColor,
|
||||||
|
tenant.LogoURL,
|
||||||
|
tenant.LogoHorizontalURL,
|
||||||
tenant.CreatedAt,
|
tenant.CreatedAt,
|
||||||
tenant.UpdatedAt,
|
tenant.UpdatedAt,
|
||||||
).Scan(&tenant.ID, &tenant.CreatedAt, &tenant.UpdatedAt)
|
).Scan(&tenant.ID, &tenant.CreatedAt, &tenant.UpdatedAt)
|
||||||
@@ -60,13 +76,15 @@ func (r *TenantRepository) Create(tenant *domain.Tenant) error {
|
|||||||
func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
|
func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, name, domain, subdomain, cnpj, razao_social, email, phone, website,
|
SELECT id, name, domain, subdomain, cnpj, razao_social, email, phone, website,
|
||||||
address, city, state, zip, description, industry, is_active, created_at, updated_at
|
address, neighborhood, number, complement, city, state, zip, description, industry, team_size,
|
||||||
|
primary_color, secondary_color, logo_url, logo_horizontal_url,
|
||||||
|
is_active, created_at, updated_at
|
||||||
FROM tenants
|
FROM tenants
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
tenant := &domain.Tenant{}
|
tenant := &domain.Tenant{}
|
||||||
var cnpj, razaoSocial, email, phone, website, address, city, state, zip, description, industry sql.NullString
|
var cnpj, razaoSocial, email, phone, website, address, neighborhood, number, complement, city, state, zip, description, industry, teamSize, primaryColor, secondaryColor, logoURL, logoHorizontalURL sql.NullString
|
||||||
|
|
||||||
err := r.db.QueryRow(query, id).Scan(
|
err := r.db.QueryRow(query, id).Scan(
|
||||||
&tenant.ID,
|
&tenant.ID,
|
||||||
@@ -79,11 +97,19 @@ func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
|
|||||||
&phone,
|
&phone,
|
||||||
&website,
|
&website,
|
||||||
&address,
|
&address,
|
||||||
|
&neighborhood,
|
||||||
|
&number,
|
||||||
|
&complement,
|
||||||
&city,
|
&city,
|
||||||
&state,
|
&state,
|
||||||
&zip,
|
&zip,
|
||||||
&description,
|
&description,
|
||||||
&industry,
|
&industry,
|
||||||
|
&teamSize,
|
||||||
|
&primaryColor,
|
||||||
|
&secondaryColor,
|
||||||
|
&logoURL,
|
||||||
|
&logoHorizontalURL,
|
||||||
&tenant.IsActive,
|
&tenant.IsActive,
|
||||||
&tenant.CreatedAt,
|
&tenant.CreatedAt,
|
||||||
&tenant.UpdatedAt,
|
&tenant.UpdatedAt,
|
||||||
@@ -116,6 +142,15 @@ func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
|
|||||||
if address.Valid {
|
if address.Valid {
|
||||||
tenant.Address = address.String
|
tenant.Address = address.String
|
||||||
}
|
}
|
||||||
|
if neighborhood.Valid {
|
||||||
|
tenant.Neighborhood = neighborhood.String
|
||||||
|
}
|
||||||
|
if number.Valid {
|
||||||
|
tenant.Number = number.String
|
||||||
|
}
|
||||||
|
if complement.Valid {
|
||||||
|
tenant.Complement = complement.String
|
||||||
|
}
|
||||||
if city.Valid {
|
if city.Valid {
|
||||||
tenant.City = city.String
|
tenant.City = city.String
|
||||||
}
|
}
|
||||||
@@ -131,6 +166,21 @@ func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
|
|||||||
if industry.Valid {
|
if industry.Valid {
|
||||||
tenant.Industry = industry.String
|
tenant.Industry = industry.String
|
||||||
}
|
}
|
||||||
|
if teamSize.Valid {
|
||||||
|
tenant.TeamSize = teamSize.String
|
||||||
|
}
|
||||||
|
if primaryColor.Valid {
|
||||||
|
tenant.PrimaryColor = primaryColor.String
|
||||||
|
}
|
||||||
|
if secondaryColor.Valid {
|
||||||
|
tenant.SecondaryColor = secondaryColor.String
|
||||||
|
}
|
||||||
|
if logoURL.Valid {
|
||||||
|
tenant.LogoURL = logoURL.String
|
||||||
|
}
|
||||||
|
if logoHorizontalURL.Valid {
|
||||||
|
tenant.LogoHorizontalURL = logoHorizontalURL.String
|
||||||
|
}
|
||||||
|
|
||||||
return tenant, nil
|
return tenant, nil
|
||||||
}
|
}
|
||||||
@@ -171,7 +221,7 @@ func (r *TenantRepository) SubdomainExists(subdomain string) (bool, error) {
|
|||||||
// FindAll returns all tenants
|
// FindAll returns all tenants
|
||||||
func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) {
|
func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, name, domain, subdomain, is_active, created_at, updated_at
|
SELECT id, name, domain, subdomain, email, phone, cnpj, logo_url, is_active, created_at, updated_at
|
||||||
FROM tenants
|
FROM tenants
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`
|
`
|
||||||
@@ -185,11 +235,17 @@ func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) {
|
|||||||
var tenants []*domain.Tenant
|
var tenants []*domain.Tenant
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
tenant := &domain.Tenant{}
|
tenant := &domain.Tenant{}
|
||||||
|
var email, phone, cnpj, logoURL sql.NullString
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&tenant.ID,
|
&tenant.ID,
|
||||||
&tenant.Name,
|
&tenant.Name,
|
||||||
&tenant.Domain,
|
&tenant.Domain,
|
||||||
&tenant.Subdomain,
|
&tenant.Subdomain,
|
||||||
|
&email,
|
||||||
|
&phone,
|
||||||
|
&cnpj,
|
||||||
|
&logoURL,
|
||||||
&tenant.IsActive,
|
&tenant.IsActive,
|
||||||
&tenant.CreatedAt,
|
&tenant.CreatedAt,
|
||||||
&tenant.UpdatedAt,
|
&tenant.UpdatedAt,
|
||||||
@@ -197,6 +253,20 @@ func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if email.Valid {
|
||||||
|
tenant.Email = email.String
|
||||||
|
}
|
||||||
|
if phone.Valid {
|
||||||
|
tenant.Phone = phone.String
|
||||||
|
}
|
||||||
|
if cnpj.Valid {
|
||||||
|
tenant.CNPJ = cnpj.String
|
||||||
|
}
|
||||||
|
if logoURL.Valid {
|
||||||
|
tenant.LogoURL = logoURL.String
|
||||||
|
}
|
||||||
|
|
||||||
tenants = append(tenants, tenant)
|
tenants = append(tenants, tenant)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +279,21 @@ func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) {
|
|||||||
|
|
||||||
// Delete removes a tenant (and cascades to related data)
|
// Delete removes a tenant (and cascades to related data)
|
||||||
func (r *TenantRepository) Delete(id uuid.UUID) error {
|
func (r *TenantRepository) Delete(id uuid.UUID) error {
|
||||||
result, err := r.db.Exec(`DELETE FROM tenants WHERE id = $1`, id)
|
// Start transaction
|
||||||
|
tx, err := r.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Delete all users associated with this tenant first
|
||||||
|
_, err = tx.Exec(`DELETE FROM users WHERE tenant_id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the tenant
|
||||||
|
result, err := tx.Exec(`DELETE FROM tenants WHERE id = $1`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -223,7 +307,8 @@ func (r *TenantRepository) Delete(id uuid.UUID) error {
|
|||||||
return sql.ErrNoRows
|
return sql.ErrNoRows
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
// Commit transaction
|
||||||
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateProfile updates tenant profile information
|
// UpdateProfile updates tenant profile information
|
||||||
@@ -237,13 +322,21 @@ func (r *TenantRepository) UpdateProfile(id uuid.UUID, updates map[string]interf
|
|||||||
phone = COALESCE($5, phone),
|
phone = COALESCE($5, phone),
|
||||||
website = COALESCE($6, website),
|
website = COALESCE($6, website),
|
||||||
address = COALESCE($7, address),
|
address = COALESCE($7, address),
|
||||||
city = COALESCE($8, city),
|
neighborhood = COALESCE($8, neighborhood),
|
||||||
state = COALESCE($9, state),
|
number = COALESCE($9, number),
|
||||||
zip = COALESCE($10, zip),
|
complement = COALESCE($10, complement),
|
||||||
description = COALESCE($11, description),
|
city = COALESCE($11, city),
|
||||||
industry = COALESCE($12, industry),
|
state = COALESCE($12, state),
|
||||||
updated_at = $13
|
zip = COALESCE($13, zip),
|
||||||
WHERE id = $14
|
description = COALESCE($14, description),
|
||||||
|
industry = COALESCE($15, industry),
|
||||||
|
team_size = COALESCE($16, team_size),
|
||||||
|
primary_color = COALESCE($17, primary_color),
|
||||||
|
secondary_color = COALESCE($18, secondary_color),
|
||||||
|
logo_url = COALESCE($19, logo_url),
|
||||||
|
logo_horizontal_url = COALESCE($20, logo_horizontal_url),
|
||||||
|
updated_at = $21
|
||||||
|
WHERE id = $22
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err := r.db.Exec(
|
_, err := r.db.Exec(
|
||||||
@@ -255,14 +348,29 @@ func (r *TenantRepository) UpdateProfile(id uuid.UUID, updates map[string]interf
|
|||||||
updates["phone"],
|
updates["phone"],
|
||||||
updates["website"],
|
updates["website"],
|
||||||
updates["address"],
|
updates["address"],
|
||||||
|
updates["neighborhood"],
|
||||||
|
updates["number"],
|
||||||
|
updates["complement"],
|
||||||
updates["city"],
|
updates["city"],
|
||||||
updates["state"],
|
updates["state"],
|
||||||
updates["zip"],
|
updates["zip"],
|
||||||
updates["description"],
|
updates["description"],
|
||||||
updates["industry"],
|
updates["industry"],
|
||||||
|
updates["team_size"],
|
||||||
|
updates["primary_color"],
|
||||||
|
updates["secondary_color"],
|
||||||
|
updates["logo_url"],
|
||||||
|
updates["logo_horizontal_url"],
|
||||||
time.Now(),
|
time.Now(),
|
||||||
id,
|
id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateStatus updates the is_active status of a tenant
|
||||||
|
func (r *TenantRepository) UpdateStatus(id uuid.UUID, isActive bool) error {
|
||||||
|
query := `UPDATE tenants SET is_active = $1, updated_at = $2 WHERE id = $3`
|
||||||
|
_, err := r.db.Exec(query, isActive, time.Now(), id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"aggios-app/backend/internal/domain"
|
"aggios-app/backend/internal/domain"
|
||||||
@@ -53,6 +54,8 @@ func (r *UserRepository) Create(user *domain.User) error {
|
|||||||
|
|
||||||
// FindByEmail finds a user by email
|
// FindByEmail finds a user by email
|
||||||
func (r *UserRepository) FindByEmail(email string) (*domain.User, error) {
|
func (r *UserRepository) FindByEmail(email string) (*domain.User, error) {
|
||||||
|
log.Printf("🔍 FindByEmail called with: %s", email)
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at
|
SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at
|
||||||
FROM users
|
FROM users
|
||||||
@@ -72,10 +75,16 @@ func (r *UserRepository) FindByEmail(email string) (*domain.User, error) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
|
log.Printf("❌ User not found: %s", email)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ DB error finding user %s: %v", email, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return user, err
|
log.Printf("✅ Found user: %s, role: %s", user.Email, user.Role)
|
||||||
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindByID finds a user by ID
|
// FindByID finds a user by ID
|
||||||
|
|||||||
@@ -60,9 +60,6 @@ func (s *AgencyService) RegisterAgency(req domain.RegisterAgencyRequest) (*domai
|
|||||||
if req.Complement != "" {
|
if req.Complement != "" {
|
||||||
address += " - " + req.Complement
|
address += " - " + req.Complement
|
||||||
}
|
}
|
||||||
if req.Neighborhood != "" {
|
|
||||||
address += " - " + req.Neighborhood
|
|
||||||
}
|
|
||||||
|
|
||||||
tenant := &domain.Tenant{
|
tenant := &domain.Tenant{
|
||||||
Name: req.AgencyName,
|
Name: req.AgencyName,
|
||||||
@@ -71,13 +68,22 @@ func (s *AgencyService) RegisterAgency(req domain.RegisterAgencyRequest) (*domai
|
|||||||
CNPJ: req.CNPJ,
|
CNPJ: req.CNPJ,
|
||||||
RazaoSocial: req.RazaoSocial,
|
RazaoSocial: req.RazaoSocial,
|
||||||
Email: req.AdminEmail,
|
Email: req.AdminEmail,
|
||||||
|
Phone: req.Phone,
|
||||||
Website: req.Website,
|
Website: req.Website,
|
||||||
Address: address,
|
Address: address,
|
||||||
|
Neighborhood: req.Neighborhood,
|
||||||
|
Number: req.Number,
|
||||||
|
Complement: req.Complement,
|
||||||
City: req.City,
|
City: req.City,
|
||||||
State: req.State,
|
State: req.State,
|
||||||
Zip: req.CEP,
|
Zip: req.CEP,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
Industry: req.Industry,
|
Industry: req.Industry,
|
||||||
|
TeamSize: req.TeamSize,
|
||||||
|
PrimaryColor: req.PrimaryColor,
|
||||||
|
SecondaryColor: req.SecondaryColor,
|
||||||
|
LogoURL: req.LogoURL,
|
||||||
|
LogoHorizontalURL: req.LogoHorizontalURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.tenantRepo.Create(tenant); err != nil {
|
if err := s.tenantRepo.Create(tenant); err != nil {
|
||||||
@@ -189,3 +195,16 @@ func (s *AgencyService) DeleteAgency(id uuid.UUID) error {
|
|||||||
|
|
||||||
return s.tenantRepo.Delete(id)
|
return s.tenantRepo.Delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateAgencyStatus updates the is_active status of a tenant
|
||||||
|
func (s *AgencyService) UpdateAgencyStatus(id uuid.UUID, isActive bool) error {
|
||||||
|
tenant, err := s.tenantRepo.FindByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tenant == nil {
|
||||||
|
return ErrTenantNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.tenantRepo.UpdateStatus(id, isActive)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"aggios-app/backend/internal/config"
|
"aggios-app/backend/internal/config"
|
||||||
@@ -78,14 +79,20 @@ func (s *AuthService) Login(req domain.LoginRequest) (*domain.LoginResponse, err
|
|||||||
// Find user by email
|
// Find user by email
|
||||||
user, err := s.userRepo.FindByEmail(req.Email)
|
user, err := s.userRepo.FindByEmail(req.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("❌ DB error finding user %s: %v", req.Email, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if user == nil {
|
if user == nil {
|
||||||
|
log.Printf("❌ User not found: %s", req.Email)
|
||||||
return nil, ErrInvalidCredentials
|
return nil, ErrInvalidCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("🔍 Attempting login for %s with password_hash: %.10s...", req.Email, user.Password)
|
||||||
|
log.Printf("🔍 Provided password length: %d", len(req.Password))
|
||||||
|
|
||||||
// Verify password
|
// Verify password
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
|
||||||
|
log.Printf("❌ Password mismatch for %s: %v", req.Email, err)
|
||||||
return nil, ErrInvalidCredentials
|
return nil, ErrInvalidCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ services:
|
|||||||
POSTGRES_DB: ${DB_NAME:-aggios_db}
|
POSTGRES_DB: ${DB_NAME:-aggios_db}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
- ./postgres/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
|
- ./backend/internal/data/postgres/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD-SHELL", "pg_isready -U aggios -d aggios_db" ]
|
test: [ "CMD-SHELL", "pg_isready -U aggios -d aggios_db" ]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
command:
|
||||||
- "--api.insecure=true"
|
- "--api.insecure=true"
|
||||||
- "--providers.docker=true"
|
|
||||||
- "--providers.docker.endpoint=tcp://host.docker.internal:2375"
|
|
||||||
- "--providers.docker.exposedbydefault=false"
|
|
||||||
- "--providers.docker.network=aggios-network"
|
|
||||||
- "--providers.file.directory=/etc/traefik/dynamic"
|
- "--providers.file.directory=/etc/traefik/dynamic"
|
||||||
- "--providers.file.watch=true"
|
- "--providers.file.watch=true"
|
||||||
- "--entrypoints.web.address=:80"
|
- "--entrypoints.web.address=:80"
|
||||||
@@ -38,7 +34,7 @@ services:
|
|||||||
POSTGRES_DB: aggios_db
|
POSTGRES_DB: aggios_db
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
- ./postgres/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
|
- ./backend/internal/data/postgres/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD-SHELL", "pg_isready -U aggios -d aggios_db" ]
|
test: [ "CMD-SHELL", "pg_isready -U aggios -d aggios_db" ]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -171,6 +167,30 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- aggios-network
|
- aggios-network
|
||||||
|
|
||||||
|
# Frontend - Agency (tenant-only)
|
||||||
|
agency:
|
||||||
|
build:
|
||||||
|
context: ./front-end-agency
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: aggios-agency
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.agency.rule=Host(`agency.aggios.local`) || Host(`agency.localhost`) || HostRegexp(`^.+\\.localhost$`)"
|
||||||
|
- "traefik.http.routers.agency.entrypoints=web"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- NEXT_PUBLIC_API_URL=http://api.localhost
|
||||||
|
- API_INTERNAL_URL=http://backend:8080
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000" ]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
networks:
|
||||||
|
- aggios-network
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|||||||
41
front-end-agency/.gitignore
vendored
Normal file
41
front-end-agency/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
41
front-end-agency/Dockerfile
Normal file
41
front-end-agency/Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build Next.js
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
|
||||||
|
# Install only production dependencies
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
# Copy built app from builder
|
||||||
|
COPY --from=builder /app/.next ./.next
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD node -e "require('http').get('http://localhost:3000', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"
|
||||||
|
|
||||||
|
# Start app
|
||||||
|
CMD ["npm", "start"]
|
||||||
36
front-end-agency/README.md
Normal file
36
front-end-agency/README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
1105
front-end-agency/app/(agency)/configuracoes/page.tsx
Normal file
1105
front-end-agency/app/(agency)/configuracoes/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, Fragment } from 'react';
|
import { useEffect, useState, Fragment } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { Menu, Transition } from '@headlessui/react';
|
import { Menu, Transition } from '@headlessui/react';
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +47,16 @@ import {
|
|||||||
|
|
||||||
const ThemeToggle = dynamic(() => import('@/components/ThemeToggle'), { ssr: false });
|
const ThemeToggle = dynamic(() => import('@/components/ThemeToggle'), { ssr: false });
|
||||||
const ThemeTester = dynamic(() => import('@/components/ThemeTester'), { ssr: false });
|
const ThemeTester = dynamic(() => import('@/components/ThemeTester'), { ssr: false });
|
||||||
|
const DynamicFavicon = dynamic(() => import('@/components/DynamicFavicon'), { ssr: false });
|
||||||
|
|
||||||
|
const DEFAULT_GRADIENT = 'linear-gradient(135deg, #ff3a05, #ff0080)';
|
||||||
|
|
||||||
|
const setGradientVariables = (gradient: string) => {
|
||||||
|
document.documentElement.style.setProperty('--gradient-primary', gradient);
|
||||||
|
document.documentElement.style.setProperty('--gradient', gradient);
|
||||||
|
document.documentElement.style.setProperty('--gradient-text', gradient.replace('90deg', 'to right'));
|
||||||
|
document.documentElement.style.setProperty('--color-gradient-brand', gradient.replace('90deg', 'to right'));
|
||||||
|
};
|
||||||
|
|
||||||
export default function AgencyLayout({
|
export default function AgencyLayout({
|
||||||
children,
|
children,
|
||||||
@@ -54,8 +64,10 @@ export default function AgencyLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
const [user, setUser] = useState<any>(null);
|
const [user, setUser] = useState<any>(null);
|
||||||
const [agencyName, setAgencyName] = useState('');
|
const [agencyName, setAgencyName] = useState('');
|
||||||
|
const [agencyLogo, setAgencyLogo] = useState('');
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
const [activeSubmenu, setActiveSubmenu] = useState<number | null>(null);
|
const [activeSubmenu, setActiveSubmenu] = useState<number | null>(null);
|
||||||
@@ -78,7 +90,6 @@ export default function AgencyLayout({
|
|||||||
router.push('/login');
|
router.push('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedUser = JSON.parse(userData);
|
const parsedUser = JSON.parse(userData);
|
||||||
setUser(parsedUser);
|
setUser(parsedUser);
|
||||||
|
|
||||||
@@ -88,8 +99,36 @@ export default function AgencyLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hostname = window.location.hostname;
|
const hostname = window.location.hostname;
|
||||||
const subdomain = hostname.split('.')[0];
|
const hostSubdomain = hostname.split('.')[0] || 'default';
|
||||||
setAgencyName(subdomain);
|
const themeKey = parsedUser?.subdomain || parsedUser?.tenantId || parsedUser?.tenant_id || hostSubdomain;
|
||||||
|
|
||||||
|
setAgencyName(parsedUser?.subdomain || hostSubdomain);
|
||||||
|
|
||||||
|
// Buscar logo da agência
|
||||||
|
const fetchAgencyLogo = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/agency/profile', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.logo_url) {
|
||||||
|
setAgencyLogo(data.logo_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar logo da agência:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAgencyLogo();
|
||||||
|
|
||||||
|
const storedGradient = localStorage.getItem(`agency-theme:${themeKey}`);
|
||||||
|
setGradientVariables(storedGradient || DEFAULT_GRADIENT);
|
||||||
|
|
||||||
// Inicializar com "Todos os Clientes"
|
// Inicializar com "Todos os Clientes"
|
||||||
setSelectedClient(clients[0]);
|
setSelectedClient(clients[0]);
|
||||||
@@ -106,9 +145,23 @@ export default function AgencyLayout({
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
setGradientVariables(DEFAULT_GRADIENT);
|
||||||
|
};
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hostname = window.location.hostname;
|
||||||
|
const hostSubdomain = hostname.split('.')[0] || 'default';
|
||||||
|
const userData = localStorage.getItem('user');
|
||||||
|
const parsedUser = userData ? JSON.parse(userData) : null;
|
||||||
|
const themeKey = parsedUser?.subdomain || parsedUser?.tenantId || parsedUser?.tenant_id || hostSubdomain;
|
||||||
|
const storedGradient = localStorage.getItem(`agency-theme:${themeKey}`) || DEFAULT_GRADIENT;
|
||||||
|
|
||||||
|
setGradientVariables(storedGradient);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -202,6 +255,9 @@ export default function AgencyLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-gray-50 dark:bg-gray-950">
|
<div className="flex h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
{/* Favicon Dinâmico */}
|
||||||
|
<DynamicFavicon logoUrl={agencyLogo} />
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside className={`${activeSubmenu !== null ? 'w-20' : (sidebarOpen ? 'w-64' : 'w-20')} transition-all duration-300 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col`}>
|
<aside className={`${activeSubmenu !== null ? 'w-20' : (sidebarOpen ? 'w-64' : 'w-20')} transition-all duration-300 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col`}>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
@@ -209,7 +265,11 @@ export default function AgencyLayout({
|
|||||||
{(sidebarOpen && activeSubmenu === null) ? (
|
{(sidebarOpen && activeSubmenu === null) ? (
|
||||||
<div className="flex items-center justify-between px-4 w-full">
|
<div className="flex items-center justify-between px-4 w-full">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
|
{agencyLogo ? (
|
||||||
|
<img src={agencyLogo} alt="Logo" className="w-8 h-8 rounded-lg object-contain shrink-0" />
|
||||||
|
) : (
|
||||||
<div className="w-8 h-8 rounded-lg shrink-0" style={{ background: 'var(--gradient-primary)' }}></div>
|
<div className="w-8 h-8 rounded-lg shrink-0" style={{ background: 'var(--gradient-primary)' }}></div>
|
||||||
|
)}
|
||||||
<span className="font-bold text-lg dark:text-white capitalize">{agencyName}</span>
|
<span className="font-bold text-lg dark:text-white capitalize">{agencyName}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -219,9 +279,15 @@ export default function AgencyLayout({
|
|||||||
<XMarkIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
<XMarkIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{agencyLogo ? (
|
||||||
|
<img src={agencyLogo} alt="Logo" className="w-8 h-8 rounded-lg object-contain" />
|
||||||
) : (
|
) : (
|
||||||
<div className="w-8 h-8 rounded-lg" style={{ background: 'var(--gradient-primary)' }}></div>
|
<div className="w-8 h-8 rounded-lg" style={{ background: 'var(--gradient-primary)' }}></div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Menu */}
|
{/* Menu */}
|
||||||
1640
front-end-agency/app/(auth)/cadastro/page.tsx
Normal file
1640
front-end-agency/app/(auth)/cadastro/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -56,7 +56,7 @@ export default function RecuperarSenhaPage() {
|
|||||||
error: {
|
error: {
|
||||||
icon: '⚠️',
|
icon: '⚠️',
|
||||||
style: {
|
style: {
|
||||||
background: '#ff3a05',
|
background: '#ef4444',
|
||||||
color: '#FFFFFF',
|
color: '#FFFFFF',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
},
|
},
|
||||||
@@ -150,7 +150,7 @@ export default function RecuperarSenhaPage() {
|
|||||||
|
|
||||||
<div className="p-6 bg-[#F0F9FF] border border-[#BAE6FD] rounded-md text-left mb-6">
|
<div className="p-6 bg-[#F0F9FF] border border-[#BAE6FD] rounded-md text-left mb-6">
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<i className="ri-information-line text-[#0EA5E9] text-xl mt-0.5" />
|
<i className="ri-information-line text-[#ff3a05] text-xl mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-white mb-1">
|
<h4 className="text-sm font-semibold text-zinc-900 dark:text-white mb-1">
|
||||||
Verifique sua caixa de entrada
|
Verifique sua caixa de entrada
|
||||||
25
front-end-agency/app/LayoutWrapper.tsx
Normal file
25
front-end-agency/app/LayoutWrapper.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
const DEFAULT_GRADIENT = 'linear-gradient(135deg, #ff3a05, #ff0080)';
|
||||||
|
|
||||||
|
const setGradientVariables = (gradient: string) => {
|
||||||
|
document.documentElement.style.setProperty('--gradient-primary', gradient);
|
||||||
|
document.documentElement.style.setProperty('--gradient', gradient);
|
||||||
|
document.documentElement.style.setProperty('--gradient-text', gradient.replace('90deg', 'to right'));
|
||||||
|
document.documentElement.style.setProperty('--color-gradient-brand', gradient.replace('90deg', 'to right'));
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LayoutWrapper({ children }: { children: ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Em toda troca de rota, volta para o tema padrão; layouts específicos (ex.: agência) aplicam o próprio na sequência
|
||||||
|
setGradientVariables(DEFAULT_GRADIENT);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
80
front-end-agency/app/api/[...path]/route.ts
Normal file
80
front-end-agency/app/api/[...path]/route.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const { path: pathArray } = await params;
|
||||||
|
const path = pathArray?.join("/") || "";
|
||||||
|
const token = req.headers.get("authorization");
|
||||||
|
const host = req.headers.get("host");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://backend:8080/api/${path}${req.nextUrl.search}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Authorization": token || "",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Forwarded-Host": host || "",
|
||||||
|
"X-Original-Host": host || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API proxy error:", error);
|
||||||
|
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const { path: pathArray } = await params;
|
||||||
|
const path = pathArray?.join("/") || "";
|
||||||
|
const token = req.headers.get("authorization");
|
||||||
|
const host = req.headers.get("host");
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://backend:8080/api/${path}${req.nextUrl.search}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Authorization": token || "",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Forwarded-Host": host || "",
|
||||||
|
"X-Original-Host": host || "",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API proxy error:", error);
|
||||||
|
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const { path: pathArray } = await params;
|
||||||
|
const path = pathArray?.join("/") || "";
|
||||||
|
const token = req.headers.get("authorization");
|
||||||
|
const host = req.headers.get("host");
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://backend:8080/api/${path}${req.nextUrl.search}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": token || "",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Forwarded-Host": host || "",
|
||||||
|
"X-Original-Host": host || "",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API proxy error:", error);
|
||||||
|
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
50
front-end-agency/app/api/agency/logo/route.ts
Normal file
50
front-end-agency/app/api/agency/logo/route.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.API_INTERNAL_URL || 'http://aggios-backend:8080';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authorization = request.headers.get('authorization');
|
||||||
|
|
||||||
|
if (!authorization) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get form data from request
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
console.log('Forwarding logo upload to backend:', BACKEND_URL);
|
||||||
|
|
||||||
|
// Forward to backend
|
||||||
|
const response = await fetch(`${BACKEND_URL}/api/agency/logo`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': authorization,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Backend response status:', response.status);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Backend error:', errorText);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: errorText || 'Failed to upload logo' },
|
||||||
|
{ status: response.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logo upload error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error: ' + (error instanceof Error ? error.message : String(error)) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
front-end-agency/app/api/auth/login/route.ts
Normal file
29
front-end-agency/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const response = await fetch('http://aggios-backend:8080/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Erro ao processar login' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
front-end-agency/app/favicon.ico
Normal file
BIN
front-end-agency/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
183
front-end-agency/app/globals.css
Normal file
183
front-end-agency/app/globals.css
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
@config "../tailwind.config.js";
|
||||||
|
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "./tokens.css";
|
||||||
|
|
||||||
|
@variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
a,
|
||||||
|
button,
|
||||||
|
[role="button"],
|
||||||
|
input[type="submit"],
|
||||||
|
input[type="reset"],
|
||||||
|
input[type="button"],
|
||||||
|
label[for] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--color-surface-muted);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
transition: background-color 0.25s ease, color 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: var(--color-brand-500);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Seleção em campos de formulário usa o gradiente padrão da marca */
|
||||||
|
input::selection,
|
||||||
|
textarea::selection,
|
||||||
|
select::selection {
|
||||||
|
background: var(--color-gradient-brand);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-card {
|
||||||
|
background-color: var(--color-surface-card);
|
||||||
|
border: 1px solid var(--color-border-strong);
|
||||||
|
box-shadow: 0 20px 80px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-panel {
|
||||||
|
background: linear-gradient(120deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.05));
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(15, 23, 42, 0.25);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-text {
|
||||||
|
background: var(--color-gradient-brand);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
front-end-agency/app/layout.tsx
Normal file
49
front-end-agency/app/layout.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Inter, Open_Sans, Fira_Code } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import LayoutWrapper from "./LayoutWrapper";
|
||||||
|
import { ThemeProvider } from "next-themes";
|
||||||
|
|
||||||
|
const inter = Inter({
|
||||||
|
variable: "--font-inter",
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "500", "600", "700"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const openSans = Open_Sans({
|
||||||
|
variable: "--font-open-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["600", "700"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const firaCode = Fira_Code({
|
||||||
|
variable: "--font-fira-code",
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "600"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Aggios - Dashboard",
|
||||||
|
description: "Plataforma SaaS para agências digitais",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="pt-BR" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/remixicon@4.3.0/fonts/remixicon.css" />
|
||||||
|
</head>
|
||||||
|
<body className={`${inter.variable} ${openSans.variable} ${firaCode.variable} antialiased`}>
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||||
|
<LayoutWrapper>
|
||||||
|
{children}
|
||||||
|
</LayoutWrapper>
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
286
front-end-agency/app/login/page.tsx
Normal file
286
front-end-agency/app/login/page.tsx
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button, Input, Checkbox } from "@/components/ui";
|
||||||
|
import toast, { Toaster } from 'react-hot-toast';
|
||||||
|
import { saveAuth, isAuthenticated } from '@/lib/auth';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
const ThemeToggle = dynamic(() => import('@/components/ThemeToggle'), { ssr: false });
|
||||||
|
|
||||||
|
const DEFAULT_GRADIENT = 'linear-gradient(135deg, #ff3a05, #ff0080)';
|
||||||
|
|
||||||
|
const setGradientVariables = (gradient: string) => {
|
||||||
|
document.documentElement.style.setProperty('--gradient-primary', gradient);
|
||||||
|
document.documentElement.style.setProperty('--gradient', gradient);
|
||||||
|
document.documentElement.style.setProperty('--gradient-text', gradient.replace('90deg', 'to right'));
|
||||||
|
document.documentElement.style.setProperty('--color-gradient-brand', gradient.replace('90deg', 'to right'));
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isSuperAdmin, setIsSuperAdmin] = useState(false);
|
||||||
|
const [subdomain, setSubdomain] = useState<string>('');
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
rememberMe: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const hostname = window.location.hostname;
|
||||||
|
const sub = hostname.split('.')[0];
|
||||||
|
const superAdmin = sub === 'dash';
|
||||||
|
setSubdomain(sub);
|
||||||
|
setIsSuperAdmin(superAdmin);
|
||||||
|
|
||||||
|
// Aplicar tema: dash sempre padrão; tenants aplicam o salvo ou vindo via query param
|
||||||
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
|
const themeParam = searchParams.get('theme');
|
||||||
|
|
||||||
|
if (superAdmin) {
|
||||||
|
setGradientVariables(DEFAULT_GRADIENT);
|
||||||
|
} else {
|
||||||
|
const stored = localStorage.getItem(`agency-theme:${sub}`);
|
||||||
|
const gradient = themeParam || stored || DEFAULT_GRADIENT;
|
||||||
|
setGradientVariables(gradient);
|
||||||
|
|
||||||
|
if (themeParam) {
|
||||||
|
localStorage.setItem(`agency-theme:${sub}`, gradient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated()) {
|
||||||
|
const target = superAdmin ? '/superadmin' : '/dashboard';
|
||||||
|
window.location.href = target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!formData.email) {
|
||||||
|
toast.error('Por favor, insira seu email');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||||
|
toast.error('Por favor, insira um email válido');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.password) {
|
||||||
|
toast.error('Por favor, insira sua senha');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.message || 'Credenciais inválidas');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
saveAuth(data.token, data.user);
|
||||||
|
|
||||||
|
console.log('Login successful:', data.user);
|
||||||
|
|
||||||
|
toast.success('Login realizado com sucesso! Redirecionando...');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const target = isSuperAdmin ? '/superadmin' : '/dashboard';
|
||||||
|
window.location.href = target;
|
||||||
|
}, 1000);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Erro ao fazer login. Verifique suas credenciais.');
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toaster
|
||||||
|
position="top-center"
|
||||||
|
toastOptions={{
|
||||||
|
duration: 5000,
|
||||||
|
style: {
|
||||||
|
background: '#FFFFFF',
|
||||||
|
color: '#000000',
|
||||||
|
padding: '16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #E5E5E5',
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
icon: '⚠️',
|
||||||
|
style: {
|
||||||
|
background: '#ef4444',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
border: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
icon: '✓',
|
||||||
|
style: {
|
||||||
|
background: '#10B981',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
border: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
{/* Lado Esquerdo - Formulário */}
|
||||||
|
<div className="w-full lg:w-1/2 flex items-center justify-center px-6 sm:px-12 py-12">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Logo mobile */}
|
||||||
|
<div className="lg:hidden text-center mb-8">
|
||||||
|
<div className="inline-block px-6 py-3 rounded-2xl" style={{ background: 'var(--gradient-primary)' }}>
|
||||||
|
<h1 className="text-3xl font-bold text-white">
|
||||||
|
{isSuperAdmin ? 'aggios' : subdomain}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Theme Toggle */}
|
||||||
|
<div className="flex justify-end mb-4">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-[28px] font-bold text-[#000000] dark:text-white">
|
||||||
|
{isSuperAdmin ? 'Painel Administrativo' : 'Bem-vindo de volta'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[14px] text-[#7D7D7D] dark:text-gray-400 mt-2">
|
||||||
|
{isSuperAdmin
|
||||||
|
? 'Acesso exclusivo para administradores Aggios'
|
||||||
|
: 'Entre com suas credenciais para acessar o painel'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="seu@email.com"
|
||||||
|
leftIcon="ri-mail-line"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Senha"
|
||||||
|
type="password"
|
||||||
|
placeholder="Digite sua senha"
|
||||||
|
leftIcon="ri-lock-line"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Checkbox
|
||||||
|
id="rememberMe"
|
||||||
|
label="Lembrar de mim"
|
||||||
|
checked={formData.rememberMe}
|
||||||
|
onChange={(e) => setFormData({ ...formData, rememberMe: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
href="/recuperar-senha"
|
||||||
|
className="text-[14px] font-medium hover:opacity-80 transition-opacity"
|
||||||
|
style={{ background: 'var(--gradient-primary)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}
|
||||||
|
>
|
||||||
|
Esqueceu a senha?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Entrando...' : 'Entrar'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Link para cadastro - apenas para agências */}
|
||||||
|
{!isSuperAdmin && (
|
||||||
|
<p className="text-center text-[14px] text-[#7D7D7D] dark:text-gray-400">
|
||||||
|
Ainda não tem conta?{' '}
|
||||||
|
<a
|
||||||
|
href="http://dash.localhost/cadastro"
|
||||||
|
className="font-medium hover:opacity-80 transition-opacity"
|
||||||
|
style={{ background: 'var(--gradient-primary)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}
|
||||||
|
>
|
||||||
|
Cadastre sua agência
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lado Direito - Branding */}
|
||||||
|
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden" style={{ background: 'var(--gradient-primary)' }}>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center p-12 text-white">
|
||||||
|
<div className="max-w-md text-center">
|
||||||
|
<h1 className="text-5xl font-bold mb-6">
|
||||||
|
{isSuperAdmin ? 'aggios' : subdomain}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl opacity-90 mb-8">
|
||||||
|
{isSuperAdmin
|
||||||
|
? 'Gerencie todas as agências em um só lugar'
|
||||||
|
: 'Gerencie seus clientes com eficiência'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-6 text-left">
|
||||||
|
<div>
|
||||||
|
<i className="ri-shield-check-line text-3xl mb-2"></i>
|
||||||
|
<h3 className="font-semibold mb-1">Seguro</h3>
|
||||||
|
<p className="text-sm opacity-80">Proteção de dados</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<i className="ri-speed-line text-3xl mb-2"></i>
|
||||||
|
<h3 className="font-semibold mb-1">Rápido</h3>
|
||||||
|
<p className="text-sm opacity-80">Performance otimizada</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<i className="ri-team-line text-3xl mb-2"></i>
|
||||||
|
<h3 className="font-semibold mb-1">Colaborativo</h3>
|
||||||
|
<p className="text-sm opacity-80">Trabalho em equipe</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<i className="ri-line-chart-line text-3xl mb-2"></i>
|
||||||
|
<h3 className="font-semibold mb-1">Insights</h3>
|
||||||
|
<p className="text-sm opacity-80">Relatórios detalhados</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
front-end-agency/app/not-found.tsx
Normal file
146
front-end-agency/app/not-found.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
{/* Lado Esquerdo - Conteúdo 404 */}
|
||||||
|
<div className="w-full lg:w-1/2 flex items-center justify-center px-6 sm:px-12 py-12">
|
||||||
|
<div className="w-full max-w-md text-center">
|
||||||
|
{/* Logo mobile */}
|
||||||
|
<div className="lg:hidden mb-8">
|
||||||
|
<div className="inline-block px-6 py-3 rounded-2xl bg-linear-to-r from-brand-500 to-brand-700">
|
||||||
|
<h1 className="text-3xl font-bold text-white">aggios</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 404 Number */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-[120px] font-bold leading-none gradient-text">
|
||||||
|
404
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-[28px] font-bold text-[#000000] mb-2">
|
||||||
|
Página não encontrada
|
||||||
|
</h2>
|
||||||
|
<p className="text-[14px] text-[#7D7D7D] leading-relaxed">
|
||||||
|
Desculpe, a página que você está procurando não existe ou foi movida.
|
||||||
|
Verifique a URL ou volte para a página inicial.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
leftIcon="ri-login-box-line"
|
||||||
|
onClick={() => window.location.href = '/login'}
|
||||||
|
>
|
||||||
|
Fazer login
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
leftIcon="ri-user-add-line"
|
||||||
|
onClick={() => window.location.href = '/cadastro'}
|
||||||
|
>
|
||||||
|
Criar conta
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Help Section */}
|
||||||
|
<div className="mt-8 p-5 bg-[#F5F5F5] rounded-lg text-left">
|
||||||
|
<h4 className="text-[13px] font-semibold text-[#000000] mb-3 flex items-center gap-2">
|
||||||
|
<i className="ri-questionnaire-line text-[16px] gradient-text" />
|
||||||
|
Precisa de ajuda?
|
||||||
|
</h4>
|
||||||
|
<ul className="text-[13px] text-[#7D7D7D] space-y-2">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<i className="ri-arrow-right-s-line text-[16px] gradient-text mt-0.5" />
|
||||||
|
<span>Verifique se a URL está correta</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<i className="ri-arrow-right-s-line text-[16px] gradient-text mt-0.5" />
|
||||||
|
<span>Tente buscar no menu principal</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<i className="ri-arrow-right-s-line text-[16px] gradient-text mt-0.5" />
|
||||||
|
<span>Entre em contato com o suporte se o problema persistir</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lado Direito - Branding */}
|
||||||
|
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden" style={{ background: 'var(--gradient-primary)' }}>
|
||||||
|
<div className="relative z-10 flex flex-col justify-center items-center w-full p-12 text-white">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="inline-block px-6 py-3 rounded-2xl bg-white/10 backdrop-blur-sm border border-white/20">
|
||||||
|
<h1 className="text-5xl font-bold tracking-tight bg-linear-to-r from-white to-white/80 bg-clip-text text-transparent">
|
||||||
|
aggios
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conteúdo */}
|
||||||
|
<div className="max-w-lg text-center">
|
||||||
|
<div className="w-20 h-20 rounded-2xl bg-white/20 flex items-center justify-center mb-6 mx-auto">
|
||||||
|
<i className="ri-compass-3-line text-4xl" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-4xl font-bold mb-4">Perdido? Estamos aqui!</h2>
|
||||||
|
<p className="text-white/80 text-lg mb-8">
|
||||||
|
Mesmo que esta página não exista, temos muitas outras funcionalidades incríveis
|
||||||
|
esperando por você no Aggios.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<div className="space-y-4 text-left">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center shrink-0 mt-0.5">
|
||||||
|
<i className="ri-dashboard-line text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold mb-1">Dashboard Completo</h4>
|
||||||
|
<p className="text-white/70 text-sm">Visualize todos os seus projetos e métricas</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center shrink-0 mt-0.5">
|
||||||
|
<i className="ri-team-line text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold mb-1">Gestão de Equipe</h4>
|
||||||
|
<p className="text-white/70 text-sm">Organize e acompanhe sua equipe</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center shrink-0 mt-0.5">
|
||||||
|
<i className="ri-customer-service-line text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold mb-1">Suporte 24/7</h4>
|
||||||
|
<p className="text-white/70 text-sm">Estamos sempre disponíveis para ajudar</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Círculos decorativos */}
|
||||||
|
<div className="absolute top-0 right-0 w-96 h-96 bg-white/5 rounded-full blur-3xl" />
|
||||||
|
<div className="absolute bottom-0 left-0 w-96 h-96 bg-white/5 rounded-full blur-3xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
front-end-agency/app/page.tsx
Normal file
5
front-end-agency/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
54
front-end-agency/app/tokens.css
Normal file
54
front-end-agency/app/tokens.css
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
@layer theme {
|
||||||
|
:root {
|
||||||
|
/* Gradientes */
|
||||||
|
--gradient: linear-gradient(135deg, #ff3a05, #ff0080);
|
||||||
|
--gradient-text: linear-gradient(to right, #ff3a05, #ff0080);
|
||||||
|
--gradient-primary: linear-gradient(135deg, #ff3a05, #ff0080);
|
||||||
|
--color-gradient-brand: linear-gradient(135deg, #ff3a05, #ff0080);
|
||||||
|
|
||||||
|
/* Cores sólidas de marca (usadas em textos/bordas) */
|
||||||
|
--brand-color: #ff3a05;
|
||||||
|
--brand-color-strong: #ff0080;
|
||||||
|
|
||||||
|
/* Superfícies e tipografia */
|
||||||
|
--color-surface-light: #ffffff;
|
||||||
|
--color-surface-dark: #0a0a0a;
|
||||||
|
--color-surface-muted: #f5f7fb;
|
||||||
|
--color-surface-card: #ffffff;
|
||||||
|
--color-border-strong: rgba(15, 23, 42, 0.08);
|
||||||
|
--color-text-primary: #0f172a;
|
||||||
|
--color-text-secondary: #475569;
|
||||||
|
--color-text-inverse: #f8fafc;
|
||||||
|
--color-gray-50: #f9fafb;
|
||||||
|
--color-gray-100: #f3f4f6;
|
||||||
|
--color-gray-200: #e5e7eb;
|
||||||
|
--color-gray-300: #d1d5db;
|
||||||
|
--color-gray-400: #9ca3af;
|
||||||
|
--color-gray-500: #6b7280;
|
||||||
|
--color-gray-600: #4b5563;
|
||||||
|
--color-gray-700: #374151;
|
||||||
|
--color-gray-800: #1f2937;
|
||||||
|
--color-gray-900: #111827;
|
||||||
|
--color-gray-950: #030712;
|
||||||
|
|
||||||
|
/* Espaçamento */
|
||||||
|
--spacing-xs: 0.25rem;
|
||||||
|
--spacing-sm: 0.5rem;
|
||||||
|
--spacing-md: 1rem;
|
||||||
|
--spacing-lg: 1.5rem;
|
||||||
|
--spacing-xl: 2rem;
|
||||||
|
--spacing-2xl: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
/* Invertendo superfícies e texto para dark mode */
|
||||||
|
--color-surface-light: #020617;
|
||||||
|
--color-surface-dark: #f8fafc;
|
||||||
|
--color-surface-muted: #0b1220;
|
||||||
|
--color-surface-card: #0f172a;
|
||||||
|
--color-border-strong: rgba(148, 163, 184, 0.25);
|
||||||
|
--color-text-primary: #f8fafc;
|
||||||
|
--color-text-secondary: #cbd5f5;
|
||||||
|
--color-text-inverse: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
front-end-agency/components/DynamicFavicon.tsx
Normal file
33
front-end-agency/components/DynamicFavicon.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
interface DynamicFaviconProps {
|
||||||
|
logoUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DynamicFavicon({ logoUrl }: DynamicFaviconProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!logoUrl) return;
|
||||||
|
|
||||||
|
// Remove favicons antigos
|
||||||
|
const existingLinks = document.querySelectorAll("link[rel*='icon']");
|
||||||
|
existingLinks.forEach(link => link.remove());
|
||||||
|
|
||||||
|
// Adiciona novo favicon
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.type = 'image/x-icon';
|
||||||
|
link.rel = 'shortcut icon';
|
||||||
|
link.href = logoUrl;
|
||||||
|
document.getElementsByTagName('head')[0].appendChild(link);
|
||||||
|
|
||||||
|
// Adiciona Apple touch icon
|
||||||
|
const appleLink = document.createElement('link');
|
||||||
|
appleLink.rel = 'apple-touch-icon';
|
||||||
|
appleLink.href = logoUrl;
|
||||||
|
document.getElementsByTagName('head')[0].appendChild(appleLink);
|
||||||
|
|
||||||
|
}, [logoUrl]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
100
front-end-agency/components/ThemeTester.tsx
Normal file
100
front-end-agency/components/ThemeTester.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { SwatchIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
const themePresets = [
|
||||||
|
{
|
||||||
|
name: 'Azul (Marca)',
|
||||||
|
gradient: 'linear-gradient(135deg, #0ea5e9, #0284c7)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Azul/Roxo',
|
||||||
|
gradient: 'linear-gradient(135deg, #0066FF, #9333EA)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Verde/Esmeralda',
|
||||||
|
gradient: 'linear-gradient(135deg, #10B981, #059669)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Ciano/Azul',
|
||||||
|
gradient: 'linear-gradient(135deg, #06B6D4, #3B82F6)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Rosa/Roxo',
|
||||||
|
gradient: 'linear-gradient(135deg, #EC4899, #A855F7)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Vermelho/Laranja',
|
||||||
|
gradient: 'linear-gradient(135deg, #EF4444, #F97316)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Índigo/Violeta',
|
||||||
|
gradient: 'linear-gradient(135deg, #6366F1, #8B5CF6)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Âmbar/Amarelo',
|
||||||
|
gradient: 'linear-gradient(135deg, #F59E0B, #EAB308)',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ThemeTester() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const applyTheme = (gradient: string) => {
|
||||||
|
document.documentElement.style.setProperty('--gradient-primary', gradient);
|
||||||
|
document.documentElement.style.setProperty('--gradient', gradient);
|
||||||
|
document.documentElement.style.setProperty('--gradient-text', gradient);
|
||||||
|
document.documentElement.style.setProperty('--color-gradient-brand', gradient);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50">
|
||||||
|
{/* Botão flutuante */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="w-14 h-14 rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110"
|
||||||
|
style={{ background: 'var(--gradient-primary)' }}
|
||||||
|
title="Testar Temas"
|
||||||
|
>
|
||||||
|
<SwatchIcon className="w-6 h-6 text-white" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Painel de temas */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute bottom-16 right-0 w-80 bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white">Testar Gradientes</h3>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Clique para aplicar temporariamente
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 max-h-96 overflow-y-auto space-y-2">
|
||||||
|
{themePresets.map((theme) => (
|
||||||
|
<button
|
||||||
|
key={theme.name}
|
||||||
|
onClick={() => applyTheme(theme.gradient)}
|
||||||
|
className="w-full flex items-center space-x-3 p-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors group"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded-lg shrink-0"
|
||||||
|
style={{ background: theme.gradient }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-white text-left">
|
||||||
|
{theme.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 text-center">
|
||||||
|
💡 Recarregue a página para voltar ao tema original
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
front-end-agency/components/ThemeToggle.tsx
Normal file
37
front-end-agency/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { MoonIcon, SunIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
export default function ThemeToggle() {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return <div className="w-9 h-9 rounded-lg bg-gray-100 dark:bg-gray-800" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDark = resolvedTheme === 'dark';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
aria-label={isDark ? 'Ativar tema claro' : 'Ativar tema escuro'}
|
||||||
|
title={isDark ? 'Alterar para modo claro' : 'Alterar para modo escuro'}
|
||||||
|
>
|
||||||
|
{isDark ? (
|
||||||
|
<SunIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<MoonIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
153
front-end-agency/components/cadastro/DashboardPreview.tsx
Normal file
153
front-end-agency/components/cadastro/DashboardPreview.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
interface DashboardPreviewProps {
|
||||||
|
companyName: string;
|
||||||
|
subdomain: string;
|
||||||
|
primaryColor: string;
|
||||||
|
secondaryColor: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPreview({
|
||||||
|
companyName,
|
||||||
|
subdomain,
|
||||||
|
primaryColor,
|
||||||
|
secondaryColor,
|
||||||
|
logoUrl
|
||||||
|
}: DashboardPreviewProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg border-2 border-[#E5E5E5] overflow-hidden shadow-lg">
|
||||||
|
{/* Header do Preview */}
|
||||||
|
<div className="bg-[#F5F5F5] px-3 py-2 border-b border-[#E5E5E5] flex items-center gap-2">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-[#FF5F57]" />
|
||||||
|
<div className="w-3 h-3 rounded-full bg-[#FFBD2E]" />
|
||||||
|
<div className="w-3 h-3 rounded-full bg-[#28CA42]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-center">
|
||||||
|
<span className="text-xs text-[#7D7D7D]">
|
||||||
|
{subdomain || 'seu-dominio'}.aggios.app
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conteúdo do Preview - Dashboard */}
|
||||||
|
<div className="aspect-video bg-[#F8F9FA] relative overflow-hidden">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 bottom-0 w-16 flex flex-col items-center py-4 gap-3"
|
||||||
|
style={{ backgroundColor: primaryColor }}
|
||||||
|
>
|
||||||
|
{/* Logo/Initial */}
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-white/20 flex items-center justify-center text-white font-bold text-sm overflow-hidden">
|
||||||
|
{logoUrl ? (
|
||||||
|
<img src={logoUrl} alt="Logo" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span>{(companyName || 'E')[0].toUpperCase()}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Menu Icons */}
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
|
||||||
|
<i className="ri-dashboard-line text-white text-lg" />
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-white/60">
|
||||||
|
<i className="ri-folder-line text-lg" />
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-white/60">
|
||||||
|
<i className="ri-team-line text-lg" />
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-white/60">
|
||||||
|
<i className="ri-settings-3-line text-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="ml-16 p-4">
|
||||||
|
{/* Top Bar */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-bold text-[#000000]">
|
||||||
|
{companyName || 'Sua Empresa'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-[#7D7D7D]">Dashboard</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-[#E5E5E5]" />
|
||||||
|
<div className="w-6 h-6 rounded-full bg-[#E5E5E5]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||||
|
<div className="bg-white rounded-lg p-2 border border-[#E5E5E5]">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: `${primaryColor}20` }}
|
||||||
|
>
|
||||||
|
<i className="ri-folder-line text-xs" style={{ color: primaryColor }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-[#7D7D7D]">Projetos</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-bold" style={{ color: primaryColor }}>24</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg p-2 border border-[#E5E5E5]">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: secondaryColor ? `${secondaryColor}20` : '#10B98120' }}
|
||||||
|
>
|
||||||
|
<i className="ri-team-line text-xs" style={{ color: secondaryColor || '#10B981' }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-[#7D7D7D]">Clientes</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-bold" style={{ color: secondaryColor || '#10B981' }}>15</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg p-2 border border-[#E5E5E5]">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<div className="w-6 h-6 rounded flex items-center justify-center bg-[#7D7D7D]/10">
|
||||||
|
<i className="ri-money-dollar-circle-line text-xs text-[#7D7D7D]" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-[#7D7D7D]">Receita</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-bold text-[#7D7D7D]">R$ 45k</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart Area */}
|
||||||
|
<div className="bg-white rounded-lg p-3 border border-[#E5E5E5]">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-semibold text-[#000000]">Desempenho</span>
|
||||||
|
<button
|
||||||
|
className="px-2 py-0.5 rounded text-[10px] text-white"
|
||||||
|
style={{ backgroundColor: primaryColor }}
|
||||||
|
>
|
||||||
|
Este mês
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-1 h-16">
|
||||||
|
{[40, 70, 45, 80, 60, 90, 75].map((height, i) => (
|
||||||
|
<div key={i} className="flex-1 flex flex-col justify-end">
|
||||||
|
<div
|
||||||
|
className="w-full rounded-t transition-all"
|
||||||
|
style={{
|
||||||
|
height: `${height}%`,
|
||||||
|
backgroundColor: i === 6 ? primaryColor : `${primaryColor}40`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Preview */}
|
||||||
|
<div className="bg-[#F5F5F5] px-3 py-2 text-center border-t border-[#E5E5E5]">
|
||||||
|
<p className="text-[10px] text-[#7D7D7D]">
|
||||||
|
Preview do seu painel • As cores e layout podem ser ajustados
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
234
front-end-agency/components/cadastro/DynamicBranding.tsx
Normal file
234
front-end-agency/components/cadastro/DynamicBranding.tsx
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import DashboardPreview from "./DashboardPreview";
|
||||||
|
|
||||||
|
interface DynamicBrandingProps {
|
||||||
|
currentStep: number;
|
||||||
|
companyName?: string;
|
||||||
|
subdomain?: string;
|
||||||
|
primaryColor?: string;
|
||||||
|
secondaryColor?: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DynamicBranding({
|
||||||
|
currentStep,
|
||||||
|
companyName = '',
|
||||||
|
subdomain = '',
|
||||||
|
primaryColor = '#0ea5e9',
|
||||||
|
secondaryColor = '#0284c7',
|
||||||
|
logoUrl = ''
|
||||||
|
}: DynamicBrandingProps) {
|
||||||
|
const [activeTestimonial, setActiveTestimonial] = useState(0);
|
||||||
|
|
||||||
|
const testimonials = [
|
||||||
|
{
|
||||||
|
text: "Com o Aggios, nossa produtividade aumentou 40%. Gestão de projetos nunca foi tão simples!",
|
||||||
|
author: "Maria Silva",
|
||||||
|
company: "DigitalWorks",
|
||||||
|
avatar: "MS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Reduzi 60% do tempo gasto com controle financeiro. Tudo centralizado em um só lugar.",
|
||||||
|
author: "João Santos",
|
||||||
|
company: "TechHub",
|
||||||
|
avatar: "JS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "A melhor decisão para nossa agência. Dashboard intuitivo e relatórios incríveis!",
|
||||||
|
author: "Ana Costa",
|
||||||
|
company: "CreativeFlow",
|
||||||
|
avatar: "AC"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const stepContent = [
|
||||||
|
{
|
||||||
|
icon: "ri-user-heart-line",
|
||||||
|
title: "Bem-vindo ao Aggios!",
|
||||||
|
description: "Vamos criar sua conta em poucos passos",
|
||||||
|
benefits: [
|
||||||
|
"✓ Acesso completo ao painel",
|
||||||
|
"✓ Gestão ilimitada de projetos",
|
||||||
|
"✓ Suporte prioritário"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "ri-building-line",
|
||||||
|
title: "Configure sua Empresa",
|
||||||
|
description: "Personalize de acordo com seu negócio",
|
||||||
|
benefits: [
|
||||||
|
"✓ Dashboard personalizado",
|
||||||
|
"✓ Gestão de equipe e clientes",
|
||||||
|
"✓ Controle financeiro integrado"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "ri-map-pin-line",
|
||||||
|
title: "Quase lá!",
|
||||||
|
description: "Informações de localização e contato",
|
||||||
|
benefits: [
|
||||||
|
"✓ Multi-contatos configuráveis",
|
||||||
|
"✓ Integração com WhatsApp",
|
||||||
|
"✓ Notificações em tempo real"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "ri-global-line",
|
||||||
|
title: "Seu Domínio Exclusivo",
|
||||||
|
description: "Escolha como acessar seu painel",
|
||||||
|
benefits: [
|
||||||
|
"✓ Subdomínio personalizado",
|
||||||
|
"✓ SSL incluído gratuitamente",
|
||||||
|
"✓ Domínio próprio (opcional)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "ri-palette-line",
|
||||||
|
title: "Personalize as Cores",
|
||||||
|
description: "Deixe com a cara da sua empresa",
|
||||||
|
benefits: [
|
||||||
|
"✓ Preview em tempo real",
|
||||||
|
"✓ Paleta de cores customizada",
|
||||||
|
"✓ Identidade visual única"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const content = stepContent[currentStep - 1] || stepContent[0];
|
||||||
|
|
||||||
|
// Auto-rotate testimonials
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setActiveTestimonial((prev) => (prev + 1) % testimonials.length);
|
||||||
|
}, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [testimonials.length]);
|
||||||
|
|
||||||
|
// Se for etapa 5, mostrar preview do dashboard
|
||||||
|
if (currentStep === 5) {
|
||||||
|
return (
|
||||||
|
<div className="relative z-10 flex flex-col justify-center items-center w-full p-12">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="inline-block px-6 py-3 rounded-2xl bg-white/10 backdrop-blur-sm border border-white/20">
|
||||||
|
<h1 className="text-5xl font-bold tracking-tight bg-linear-to-r from-white to-white/80 bg-clip-text text-transparent">
|
||||||
|
aggios
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conteúdo */}
|
||||||
|
<div className="max-w-lg text-center">
|
||||||
|
<h2 className="text-3xl font-bold mb-2 text-white">Preview do seu Painel</h2>
|
||||||
|
<p className="text-white/80 text-lg">Veja como ficará seu dashboard personalizado</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="w-full max-w-3xl">
|
||||||
|
<DashboardPreview
|
||||||
|
companyName={companyName}
|
||||||
|
subdomain={subdomain}
|
||||||
|
primaryColor={primaryColor}
|
||||||
|
secondaryColor={secondaryColor}
|
||||||
|
logoUrl={logoUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-white/70 text-sm">
|
||||||
|
As cores e configurações são atualizadas em tempo real
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Decorative circles */}
|
||||||
|
<div className="absolute -bottom-32 -left-32 w-96 h-96 rounded-full bg-white/5" />
|
||||||
|
<div className="absolute -top-16 -right-16 w-64 h-64 rounded-full bg-white/5" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative z-10 flex flex-col justify-between w-full p-12 text-white">
|
||||||
|
{/* Logo e Conteúdo da Etapa */}
|
||||||
|
<div className="flex flex-col justify-center flex-1">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="inline-block px-6 py-3 rounded-2xl bg-white/10 backdrop-blur-sm border border-white/20">
|
||||||
|
<h1 className="text-5xl font-bold tracking-tight bg-linear-to-r from-white to-white/80 bg-clip-text text-transparent">
|
||||||
|
aggios
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ícone e Título da Etapa */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-white/20 flex items-center justify-center mb-4">
|
||||||
|
<i className={`${content.icon} text-3xl`} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-bold mb-2">{content.title}</h2>
|
||||||
|
<p className="text-white/80 text-lg">{content.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Benefícios */}
|
||||||
|
<div className="space-y-3 mb-8">
|
||||||
|
{content.benefits.map((benefit, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-3 text-white/90 animate-fade-in"
|
||||||
|
style={{ animationDelay: `${index * 100}ms` }}
|
||||||
|
>
|
||||||
|
<span className="text-lg">{benefit}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Carrossel de Depoimentos */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20">
|
||||||
|
<div className="mb-4">
|
||||||
|
<i className="ri-double-quotes-l text-3xl text-white/40" />
|
||||||
|
</div>
|
||||||
|
<p className="text-white/95 mb-4 min-h-[60px]">
|
||||||
|
{testimonials[activeTestimonial].text}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center font-semibold">
|
||||||
|
{testimonials[activeTestimonial].avatar}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-white">
|
||||||
|
{testimonials[activeTestimonial].author}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-white/70">
|
||||||
|
{testimonials[activeTestimonial].company}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Indicadores */}
|
||||||
|
<div className="flex gap-2 justify-center mt-4">
|
||||||
|
{testimonials.map((_, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => setActiveTestimonial(index)}
|
||||||
|
className={`h-1.5 rounded-full transition-all ${index === activeTestimonial
|
||||||
|
? "w-8 bg-white"
|
||||||
|
: "w-1.5 bg-white/40 hover:bg-white/60"
|
||||||
|
}`}
|
||||||
|
aria-label={`Ir para depoimento ${index + 1}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Decorative circles */}
|
||||||
|
<div className="absolute -bottom-32 -left-32 w-96 h-96 rounded-full bg-white/5" />
|
||||||
|
<div className="absolute -top-16 -right-16 w-64 h-64 rounded-full bg-white/5" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
front-end-agency/components/ui/Button.tsx
Normal file
71
front-end-agency/components/ui/Button.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ButtonHTMLAttributes, forwardRef } from "react";
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: "primary" | "secondary" | "outline" | "ghost";
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
isLoading?: boolean;
|
||||||
|
leftIcon?: string;
|
||||||
|
rightIcon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
children,
|
||||||
|
variant = "primary",
|
||||||
|
size = "md",
|
||||||
|
isLoading = false,
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
className = "",
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const baseStyles =
|
||||||
|
"inline-flex items-center justify-center font-medium rounded-[6px] transition-opacity focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer";
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
primary: "text-white hover:opacity-90 active:opacity-80",
|
||||||
|
secondary:
|
||||||
|
"bg-[#E5E5E5] dark:bg-gray-700 text-[#000000] dark:text-white hover:opacity-90 active:opacity-80",
|
||||||
|
outline:
|
||||||
|
"border border-[#E5E5E5] dark:border-gray-600 text-[#000000] dark:text-white hover:bg-[#E5E5E5]/10 dark:hover:bg-gray-700/50 active:bg-[#E5E5E5]/20 dark:active:bg-gray-700",
|
||||||
|
ghost: "text-[#000000] dark:text-white hover:bg-[#E5E5E5]/20 dark:hover:bg-gray-700/30 active:bg-[#E5E5E5]/30 dark:active:bg-gray-700/50",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: "h-9 px-3 text-[13px]",
|
||||||
|
md: "h-10 px-4 text-[14px]",
|
||||||
|
lg: "h-12 px-6 text-[14px]",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||||
|
style={variant === 'primary' ? { background: 'var(--gradient-primary)' } : undefined}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isLoading && (
|
||||||
|
<i className="ri-loader-4-line animate-spin mr-2 text-[20px]" />
|
||||||
|
)}
|
||||||
|
{!isLoading && leftIcon && (
|
||||||
|
<i className={`${leftIcon} mr-2 text-[20px]`} />
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
{!isLoading && rightIcon && (
|
||||||
|
<i className={`${rightIcon} ml-2 text-[20px]`} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export default Button;
|
||||||
69
front-end-agency/components/ui/Checkbox.tsx
Normal file
69
front-end-agency/components/ui/Checkbox.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { InputHTMLAttributes, forwardRef, useState } from "react";
|
||||||
|
|
||||||
|
interface CheckboxProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string | React.ReactNode;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
||||||
|
({ label, error, className = "", onChange, checked: controlledChecked, ...props }, ref) => {
|
||||||
|
const [isChecked, setIsChecked] = useState(controlledChecked || false);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setIsChecked(e.target.checked);
|
||||||
|
if (onChange) {
|
||||||
|
onChange(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checked = controlledChecked !== undefined ? controlledChecked : isChecked;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<label className="flex items-start gap-3 cursor-pointer group">
|
||||||
|
<div className="relative flex items-center justify-center mt-0.5">
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="checkbox"
|
||||||
|
className={`
|
||||||
|
appearance-none w-[18px] h-[18px] border rounded-sm
|
||||||
|
border-zinc-200 dark:border-gray-600 bg-white dark:bg-gray-700
|
||||||
|
checked:border-brand-500
|
||||||
|
focus:outline-none focus:border-brand-500
|
||||||
|
transition-colors cursor-pointer
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
background: checked ? 'var(--gradient-primary)' : undefined,
|
||||||
|
}}
|
||||||
|
checked={checked}
|
||||||
|
onChange={handleChange}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<i
|
||||||
|
className={`ri-check-line absolute text-white text-[14px] pointer-events-none transition-opacity ${checked ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{label && (
|
||||||
|
<span className="text-[14px] text-zinc-900 dark:text-white select-none">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1 text-[13px] text-red-500 flex items-center gap-1">
|
||||||
|
<i className="ri-error-warning-line" />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Checkbox.displayName = "Checkbox";
|
||||||
|
|
||||||
|
export default Checkbox;
|
||||||
95
front-end-agency/components/ui/Dialog.tsx
Normal file
95
front-end-agency/components/ui/Dialog.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { Fragment } from 'react';
|
||||||
|
import { Dialog as HeadlessDialog, Transition } from '@headlessui/react';
|
||||||
|
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
interface DialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
showClose?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'max-w-md',
|
||||||
|
md: 'max-w-lg',
|
||||||
|
lg: 'max-w-2xl',
|
||||||
|
xl: 'max-w-4xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Dialog({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
size = 'md',
|
||||||
|
showClose = true,
|
||||||
|
}: DialogProps) {
|
||||||
|
return (
|
||||||
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
|
<HeadlessDialog as="div" className="relative z-50" onClose={onClose}>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<HeadlessDialog.Panel
|
||||||
|
className={`w-full ${sizeClasses[size]} transform rounded-2xl bg-white dark:bg-gray-800 p-6 text-left align-middle shadow-xl transition-all border border-gray-200 dark:border-gray-700`}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<HeadlessDialog.Title
|
||||||
|
as="h3"
|
||||||
|
className="text-lg font-semibold text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</HeadlessDialog.Title>
|
||||||
|
{showClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</HeadlessDialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</HeadlessDialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Componente auxiliar para o corpo do dialog
|
||||||
|
Dialog.Body = function DialogBody({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
||||||
|
return <div className={`text-sm text-gray-600 dark:text-gray-300 ${className}`}>{children}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Componente auxiliar para o rodapé do dialog
|
||||||
|
Dialog.Footer = function DialogFooter({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
||||||
|
return <div className={`mt-6 flex items-center justify-end space-x-3 ${className}`}>{children}</div>;
|
||||||
|
};
|
||||||
105
front-end-agency/components/ui/Input.tsx
Normal file
105
front-end-agency/components/ui/Input.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { InputHTMLAttributes, forwardRef, useState } from "react";
|
||||||
|
|
||||||
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
helperText?: string;
|
||||||
|
leftIcon?: string;
|
||||||
|
rightIcon?: string;
|
||||||
|
onRightIconClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
helperText,
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
onRightIconClick,
|
||||||
|
className = "",
|
||||||
|
type,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const isPassword = type === "password";
|
||||||
|
|
||||||
|
const inputType = isPassword ? (showPassword ? "text" : "password") : type;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{label && (
|
||||||
|
<label className="block text-[13px] font-semibold text-zinc-900 dark:text-white mb-2">
|
||||||
|
{label}
|
||||||
|
{props.required && <span className="text-brand-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="relative">
|
||||||
|
{leftIcon && (
|
||||||
|
<i
|
||||||
|
className={`${leftIcon} absolute left-3.5 top-1/2 -translate-y-1/2 text-[#7D7D7D] dark:text-gray-400 text-[20px]`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type={inputType}
|
||||||
|
className={`
|
||||||
|
w-full px-3.5 py-3 text-[14px] font-normal
|
||||||
|
border rounded-md bg-white dark:bg-gray-700 dark:text-white
|
||||||
|
placeholder:text-zinc-500 dark:placeholder:text-gray-400
|
||||||
|
transition-all
|
||||||
|
${leftIcon ? "pl-11" : ""}
|
||||||
|
${isPassword || rightIcon ? "pr-11" : ""}
|
||||||
|
${error
|
||||||
|
? "border-red-500 focus:border-red-500"
|
||||||
|
: "border-zinc-200 dark:border-gray-600 focus:border-brand-500"
|
||||||
|
}
|
||||||
|
outline-none ring-0 focus:ring-0 shadow-none focus:shadow-none
|
||||||
|
disabled:bg-zinc-100 disabled:cursor-not-allowed
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{isPassword && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-900 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={`${showPassword ? "ri-eye-off-line" : "ri-eye-line"} text-[20px]`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isPassword && rightIcon && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRightIconClick}
|
||||||
|
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-900 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<i className={`${rightIcon} text-[20px]`} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1 text-[13px] text-red-500 flex items-center gap-1">
|
||||||
|
<i className="ri-error-warning-line" />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{helperText && !error && (
|
||||||
|
<p className="mt-1 text-[13px] text-zinc-500">{helperText}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export default Input;
|
||||||
211
front-end-agency/components/ui/SearchableSelect.tsx
Normal file
211
front-end-agency/components/ui/SearchableSelect.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SelectHTMLAttributes, forwardRef, useState, useRef, useEffect } from "react";
|
||||||
|
|
||||||
|
interface SelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchableSelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'onChange'> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
helperText?: string;
|
||||||
|
leftIcon?: string;
|
||||||
|
options: SelectOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchableSelect = forwardRef<HTMLSelectElement, SearchableSelectProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
helperText,
|
||||||
|
leftIcon,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
className = "",
|
||||||
|
onChange,
|
||||||
|
value,
|
||||||
|
required,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [selectedOption, setSelectedOption] = useState<SelectOption | null>(
|
||||||
|
options.find(opt => opt.value === value) || null
|
||||||
|
);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const filteredOptions = options.filter(option =>
|
||||||
|
option.label.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && searchInputRef.current) {
|
||||||
|
searchInputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value) {
|
||||||
|
const option = options.find(opt => opt.value === value);
|
||||||
|
if (option) {
|
||||||
|
setSelectedOption(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [value, options]);
|
||||||
|
|
||||||
|
const handleSelect = (option: SelectOption) => {
|
||||||
|
setSelectedOption(option);
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearchTerm("");
|
||||||
|
if (onChange) {
|
||||||
|
onChange(option.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{/* Hidden select for form compatibility */}
|
||||||
|
<select
|
||||||
|
ref={ref}
|
||||||
|
value={selectedOption?.value || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const option = options.find(opt => opt.value === e.target.value);
|
||||||
|
if (option) handleSelect(option);
|
||||||
|
}}
|
||||||
|
className="hidden"
|
||||||
|
required={required}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
{placeholder || "Selecione uma opção"}
|
||||||
|
</option>
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{label && (
|
||||||
|
<label className="block text-[13px] font-semibold text-zinc-900 dark:text-white mb-2">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-brand-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
{leftIcon && (
|
||||||
|
<i
|
||||||
|
className={`${leftIcon} absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-500 dark:text-zinc-400 text-[20px] pointer-events-none z-10`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Custom trigger */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={`
|
||||||
|
w-full px-3.5 py-3 text-[14px] font-normal
|
||||||
|
border rounded-md bg-white dark:bg-zinc-800
|
||||||
|
text-zinc-900 dark:text-white text-left
|
||||||
|
transition-all
|
||||||
|
cursor-pointer
|
||||||
|
${leftIcon ? "pl-11" : ""}
|
||||||
|
pr-11
|
||||||
|
${error
|
||||||
|
? "border-red-500 focus:border-red-500"
|
||||||
|
: "border-zinc-200 dark:border-zinc-700 focus:border-brand-500"
|
||||||
|
}
|
||||||
|
outline-none ring-0 focus:ring-0 shadow-none focus:shadow-none
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{selectedOption ? selectedOption.label : (
|
||||||
|
<span className="text-zinc-500 dark:text-zinc-400">{placeholder || "Selecione uma opção"}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<i className={`ri-arrow-${isOpen ? 'up' : 'down'}-s-line absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 dark:text-zinc-400 text-[20px] pointer-events-none transition-transform`} />
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute z-50 w-full mt-2 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-md shadow-lg max-h-[300px] overflow-hidden">
|
||||||
|
{/* Search input */}
|
||||||
|
<div className="p-2 border-b border-zinc-200 dark:border-zinc-700">
|
||||||
|
<div className="relative">
|
||||||
|
<i className="ri-search-line absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500 dark:text-zinc-400 text-[16px]" />
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder="Buscar..."
|
||||||
|
className="w-full pl-9 pr-3 py-2 text-[14px] border border-zinc-200 dark:border-zinc-700 rounded-md outline-none focus:border-brand-500 shadow-none bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder:text-zinc-500 dark:placeholder:text-zinc-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Options list */}
|
||||||
|
<div className="overflow-y-auto max-h-60">
|
||||||
|
{filteredOptions.length > 0 ? (
|
||||||
|
filteredOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelect(option)}
|
||||||
|
className={`
|
||||||
|
w-full px-4 py-2.5 text-left text-[14px] transition-colors
|
||||||
|
hover:bg-zinc-100 dark:hover:bg-zinc-700 cursor-pointer
|
||||||
|
${selectedOption?.value === option.value ? 'bg-brand-500/10 text-brand-600 font-medium' : 'text-zinc-900 dark:text-white'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="px-4 py-8 text-center text-zinc-500 dark:text-zinc-400 text-[14px]">
|
||||||
|
Nenhum resultado encontrado
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{helperText && !error && (
|
||||||
|
<p className="mt-1.5 text-[12px] text-zinc-600 dark:text-zinc-400">{helperText}</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1 text-[13px] text-red-500 flex items-center gap-1">
|
||||||
|
<i className="ri-error-warning-line" />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
SearchableSelect.displayName = "SearchableSelect";
|
||||||
|
|
||||||
|
export default SearchableSelect;
|
||||||
89
front-end-agency/components/ui/Select.tsx
Normal file
89
front-end-agency/components/ui/Select.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SelectHTMLAttributes, forwardRef } from "react";
|
||||||
|
|
||||||
|
interface SelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
helperText?: string;
|
||||||
|
leftIcon?: string;
|
||||||
|
options: SelectOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
helperText,
|
||||||
|
leftIcon,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
className = "",
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{label && (
|
||||||
|
<label className="block text-[13px] font-semibold text-zinc-900 mb-2">
|
||||||
|
{label}
|
||||||
|
{props.required && <span className="text-brand-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="relative">
|
||||||
|
{leftIcon && (
|
||||||
|
<i
|
||||||
|
className={`${leftIcon} absolute left-3.5 top-1/2 -translate-y-1/2 text-[#7D7D7D] text-[20px] pointer-events-none z-10`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
ref={ref}
|
||||||
|
className={`
|
||||||
|
w-full px-3.5 py-3 text-[14px] font-normal
|
||||||
|
border rounded-md bg-white
|
||||||
|
text-zinc-900
|
||||||
|
transition-all appearance-none
|
||||||
|
cursor-pointer
|
||||||
|
${leftIcon ? "pl-11" : ""}
|
||||||
|
pr-11
|
||||||
|
${error
|
||||||
|
? "border-red-500 focus:border-red-500"
|
||||||
|
: "border-zinc-200 focus:border-brand-500"
|
||||||
|
}
|
||||||
|
outline-none ring-0 focus:ring-0 shadow-none focus:shadow-none
|
||||||
|
disabled:bg-zinc-100 disabled:cursor-not-allowed
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
{placeholder || "Selecione uma opção"}
|
||||||
|
</option>
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<i className="ri-arrow-down-s-line absolute right-3.5 top-1/2 -translate-y-1/2 text-[#7D7D7D] text-[20px] pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
{helperText && !error && (
|
||||||
|
<p className="mt-1.5 text-[12px] text-zinc-500">{helperText}</p>
|
||||||
|
)}
|
||||||
|
{error && <p className="mt-1.5 text-[12px] text-red-500">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Select.displayName = "Select";
|
||||||
|
|
||||||
|
export default Select;
|
||||||
6
front-end-agency/components/ui/index.ts
Normal file
6
front-end-agency/components/ui/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export { default as Button } from "./Button";
|
||||||
|
export { default as Input } from "./Input";
|
||||||
|
export { default as Checkbox } from "./Checkbox";
|
||||||
|
export { default as Select } from "./Select";
|
||||||
|
export { default as SearchableSelect } from "./SearchableSelect";
|
||||||
|
export { default as Dialog } from "./Dialog";
|
||||||
18
front-end-agency/eslint.config.mjs
Normal file
18
front-end-agency/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
56
front-end-agency/lib/api.ts
Normal file
56
front-end-agency/lib/api.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* API Configuration - URLs e funções de requisição
|
||||||
|
*/
|
||||||
|
|
||||||
|
// URL base da API - pode ser alterada por variável de ambiente
|
||||||
|
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://api.localhost';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoints da API
|
||||||
|
*/
|
||||||
|
export const API_ENDPOINTS = {
|
||||||
|
// Auth
|
||||||
|
register: `${API_BASE_URL}/api/auth/register`,
|
||||||
|
login: `${API_BASE_URL}/api/auth/login`,
|
||||||
|
logout: `${API_BASE_URL}/api/auth/logout`,
|
||||||
|
refresh: `${API_BASE_URL}/api/auth/refresh`,
|
||||||
|
me: `${API_BASE_URL}/api/me`,
|
||||||
|
|
||||||
|
// Admin / Agencies
|
||||||
|
adminAgencyRegister: `${API_BASE_URL}/api/admin/agencies/register`,
|
||||||
|
|
||||||
|
// Health
|
||||||
|
health: `${API_BASE_URL}/health`,
|
||||||
|
apiHealth: `${API_BASE_URL}/api/health`,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper para fetch com tratamento de erros
|
||||||
|
*/
|
||||||
|
export async function apiRequest<T = any>(
|
||||||
|
url: string,
|
||||||
|
options?: RequestInit
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || `Erro ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error('Erro desconhecido na requisição');
|
||||||
|
}
|
||||||
|
}
|
||||||
79
front-end-agency/lib/auth.ts
Normal file
79
front-end-agency/lib/auth.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Auth utilities - Gerenciamento de autenticação no cliente
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
tenantId?: string;
|
||||||
|
company?: string;
|
||||||
|
subdomain?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'token';
|
||||||
|
const USER_KEY = 'user';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Salva token e dados do usuário no localStorage
|
||||||
|
*/
|
||||||
|
export function saveAuth(token: string, user: User): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna o token JWT armazenado
|
||||||
|
*/
|
||||||
|
export function getToken(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna os dados do usuário armazenados
|
||||||
|
*/
|
||||||
|
export function getUser(): User | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
|
||||||
|
const userStr = localStorage.getItem(USER_KEY);
|
||||||
|
if (!userStr) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(userStr);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica se o usuário está autenticado
|
||||||
|
*/
|
||||||
|
export function isAuthenticated(): boolean {
|
||||||
|
return !!getToken() && !!getUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove token e dados do usuário (logout)
|
||||||
|
*/
|
||||||
|
export function clearAuth(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(USER_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna headers com Authorization para requisições autenticadas
|
||||||
|
*/
|
||||||
|
export function getAuthHeaders(): HeadersInit {
|
||||||
|
const token = getToken();
|
||||||
|
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
48
front-end-agency/middleware.ts
Normal file
48
front-end-agency/middleware.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
const hostname = request.headers.get('host') || '';
|
||||||
|
const url = request.nextUrl;
|
||||||
|
|
||||||
|
const apiBase = process.env.API_INTERNAL_URL || 'http://backend:8080';
|
||||||
|
|
||||||
|
// Extrair subdomínio
|
||||||
|
const subdomain = hostname.split('.')[0];
|
||||||
|
|
||||||
|
// Validar subdomínio de agência ({subdomain}.localhost)
|
||||||
|
if (hostname.includes('.')) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/api/tenant/check?subdomain=${subdomain}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const baseHost = hostname.split('.').slice(1).join('.') || hostname;
|
||||||
|
const redirectUrl = new URL(url.toString());
|
||||||
|
redirectUrl.hostname = baseHost;
|
||||||
|
redirectUrl.pathname = '/';
|
||||||
|
return NextResponse.redirect(redirectUrl);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const baseHost = hostname.split('.').slice(1).join('.') || hostname;
|
||||||
|
const redirectUrl = new URL(url.toString());
|
||||||
|
redirectUrl.hostname = baseHost;
|
||||||
|
redirectUrl.pathname = '/';
|
||||||
|
return NextResponse.redirect(redirectUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permitir acesso normal
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
/*
|
||||||
|
* Match all request paths except for the ones starting with:
|
||||||
|
* - api (API routes)
|
||||||
|
* - _next/static (static files)
|
||||||
|
* - _next/image (image optimization files)
|
||||||
|
* - favicon.ico (favicon file)
|
||||||
|
*/
|
||||||
|
'/((?!api|_next/static|_next/image|favicon.ico).*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
32
front-end-agency/next.config.ts
Normal file
32
front-end-agency/next.config.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
experimental: {
|
||||||
|
externalDir: true,
|
||||||
|
},
|
||||||
|
async rewrites() {
|
||||||
|
return {
|
||||||
|
beforeFiles: [
|
||||||
|
{
|
||||||
|
source: "/api/:path*",
|
||||||
|
destination: "http://backend:8080/api/:path*",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
headers: async () => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/api/:path*",
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: "X-Forwarded-For",
|
||||||
|
value: "127.0.0.1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
7524
front-end-agency/package-lock.json
generated
Normal file
7524
front-end-agency/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
front-end-agency/package.json
Normal file
33
front-end-agency/package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "agency.aggios.app",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@headlessui/react": "^2.2.9",
|
||||||
|
"@heroicons/react": "^2.2.0",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"lucide-react": "^0.556.0",
|
||||||
|
"next": "16.0.7",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"react": "19.2.0",
|
||||||
|
"react-dom": "19.2.0",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"remixicon": "^4.7.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.0.7",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
front-end-agency/postcss.config.mjs
Normal file
7
front-end-agency/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
front-end-agency/public/file.svg
Normal file
1
front-end-agency/public/file.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
front-end-agency/public/globe.svg
Normal file
1
front-end-agency/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
front-end-agency/public/next.svg
Normal file
1
front-end-agency/public/next.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
front-end-agency/public/vercel.svg
Normal file
1
front-end-agency/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
front-end-agency/public/window.svg
Normal file
1
front-end-agency/public/window.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
12
front-end-agency/tailwind.config.js
Normal file
12
front-end-agency/tailwind.config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const sharedPreset = require("./tailwind.preset.js");
|
||||||
|
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
const config = {
|
||||||
|
presets: [sharedPreset],
|
||||||
|
content: [
|
||||||
|
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
35
front-end-agency/tailwind.preset.js
Normal file
35
front-end-agency/tailwind.preset.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: "class",
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['var(--font-inter)', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
||||||
|
mono: ['var(--font-fira-code)', 'ui-monospace', 'SFMono-Regular', 'monospace'],
|
||||||
|
heading: ['var(--font-open-sans)', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: '#fff4ef',
|
||||||
|
100: '#ffe8df',
|
||||||
|
200: '#ffd0c0',
|
||||||
|
300: '#ffb093',
|
||||||
|
400: '#ff8a66',
|
||||||
|
500: '#ff3a05',
|
||||||
|
600: '#ff1f45',
|
||||||
|
700: '#ff0080',
|
||||||
|
800: '#d10069',
|
||||||
|
900: '#9e0050',
|
||||||
|
950: '#4b0028',
|
||||||
|
},
|
||||||
|
surface: {
|
||||||
|
light: '#ffffff',
|
||||||
|
dark: '#0a0a0a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
glow: '0 0 20px rgba(255, 58, 5, 0.25)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
34
front-end-agency/tsconfig.json
Normal file
34
front-end-agency/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -1,713 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Tab } from '@headlessui/react';
|
|
||||||
import { Dialog } from '@/components/ui';
|
|
||||||
import {
|
|
||||||
BuildingOfficeIcon,
|
|
||||||
SwatchIcon,
|
|
||||||
PhotoIcon,
|
|
||||||
UserGroupIcon,
|
|
||||||
ShieldCheckIcon,
|
|
||||||
BellIcon,
|
|
||||||
} from '@heroicons/react/24/outline';
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ name: 'Dados da Agência', icon: BuildingOfficeIcon },
|
|
||||||
{ name: 'Personalização', icon: SwatchIcon },
|
|
||||||
{ name: 'Logo e Marca', icon: PhotoIcon },
|
|
||||||
{ name: 'Equipe', icon: UserGroupIcon },
|
|
||||||
{ name: 'Segurança', icon: ShieldCheckIcon },
|
|
||||||
{ name: 'Notificações', icon: BellIcon },
|
|
||||||
];
|
|
||||||
|
|
||||||
const themePresets = [
|
|
||||||
{ name: 'Laranja/Rosa', gradient: 'linear-gradient(90deg, #FF3A05, #FF0080)', colors: ['#FF3A05', '#FF0080'] },
|
|
||||||
{ name: 'Azul/Roxo', gradient: 'linear-gradient(90deg, #0066FF, #9333EA)', colors: ['#0066FF', '#9333EA'] },
|
|
||||||
{ name: 'Verde/Esmeralda', gradient: 'linear-gradient(90deg, #10B981, #059669)', colors: ['#10B981', '#059669'] },
|
|
||||||
{ name: 'Ciano/Azul', gradient: 'linear-gradient(90deg, #06B6D4, #3B82F6)', colors: ['#06B6D4', '#3B82F6'] },
|
|
||||||
{ name: 'Rosa/Roxo', gradient: 'linear-gradient(90deg, #EC4899, #A855F7)', colors: ['#EC4899', '#A855F7'] },
|
|
||||||
{ name: 'Vermelho/Laranja', gradient: 'linear-gradient(90deg, #EF4444, #F97316)', colors: ['#EF4444', '#F97316'] },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ConfiguracoesPage() {
|
|
||||||
const [selectedTab, setSelectedTab] = useState(0);
|
|
||||||
const [selectedTheme, setSelectedTheme] = useState(0);
|
|
||||||
const [customColor1, setCustomColor1] = useState('#FF3A05');
|
|
||||||
const [customColor2, setCustomColor2] = useState('#FF0080');
|
|
||||||
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
|
|
||||||
const [successMessage, setSuccessMessage] = useState('');
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
// Dados da agência (buscados da API)
|
|
||||||
const [agencyData, setAgencyData] = useState({
|
|
||||||
name: '',
|
|
||||||
cnpj: '',
|
|
||||||
email: '',
|
|
||||||
phone: '',
|
|
||||||
website: '',
|
|
||||||
address: '',
|
|
||||||
city: '',
|
|
||||||
state: '',
|
|
||||||
zip: '',
|
|
||||||
razaoSocial: '',
|
|
||||||
description: '',
|
|
||||||
industry: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Dados para alteração de senha
|
|
||||||
const [passwordData, setPasswordData] = useState({
|
|
||||||
currentPassword: '',
|
|
||||||
newPassword: '',
|
|
||||||
confirmPassword: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Buscar dados da agência da API
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchAgencyData = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
const userData = localStorage.getItem('user');
|
|
||||||
|
|
||||||
if (!token || !userData) {
|
|
||||||
console.error('Usuário não autenticado');
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Buscar dados da API
|
|
||||||
const response = await fetch('http://localhost:8080/api/agency/profile', {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setAgencyData({
|
|
||||||
name: data.name || '',
|
|
||||||
cnpj: data.cnpj || '',
|
|
||||||
email: data.email || '',
|
|
||||||
phone: data.phone || '',
|
|
||||||
website: data.website || '',
|
|
||||||
address: data.address || '',
|
|
||||||
city: data.city || '',
|
|
||||||
state: data.state || '',
|
|
||||||
zip: data.zip || '',
|
|
||||||
razaoSocial: data.razao_social || '',
|
|
||||||
description: data.description || '',
|
|
||||||
industry: data.industry || '',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.error('Erro ao buscar dados:', response.status);
|
|
||||||
// Fallback para localStorage se API falhar
|
|
||||||
const savedData = localStorage.getItem('cadastroData');
|
|
||||||
if (savedData) {
|
|
||||||
const data = JSON.parse(savedData);
|
|
||||||
const user = JSON.parse(userData);
|
|
||||||
setAgencyData({
|
|
||||||
name: data.formData?.companyName || '',
|
|
||||||
cnpj: data.formData?.cnpj || '',
|
|
||||||
email: data.formData?.email || user.email || '',
|
|
||||||
phone: data.contacts?.[0]?.phone || '',
|
|
||||||
website: data.formData?.website || '',
|
|
||||||
address: `${data.cepData?.logradouro || ''}, ${data.formData?.number || ''}`,
|
|
||||||
city: data.cepData?.localidade || '',
|
|
||||||
state: data.cepData?.uf || '',
|
|
||||||
zip: data.formData?.cep || '',
|
|
||||||
razaoSocial: data.cnpjData?.razaoSocial || '',
|
|
||||||
description: data.formData?.description || '',
|
|
||||||
industry: data.formData?.industry || '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro ao buscar dados da agência:', error);
|
|
||||||
setSuccessMessage('Erro ao carregar dados da agência.');
|
|
||||||
setShowSuccessDialog(true);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchAgencyData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const applyTheme = (gradient: string) => {
|
|
||||||
document.documentElement.style.setProperty('--gradient-primary', gradient);
|
|
||||||
document.documentElement.style.setProperty('--gradient', gradient);
|
|
||||||
document.documentElement.style.setProperty('--gradient-text', gradient.replace('90deg', 'to right'));
|
|
||||||
document.documentElement.style.setProperty('--color-gradient-brand', gradient.replace('90deg', 'to right'));
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyCustomTheme = () => {
|
|
||||||
const gradient = `linear-gradient(90deg, ${customColor1}, ${customColor2})`;
|
|
||||||
applyTheme(gradient);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveAgency = async () => {
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
if (!token) {
|
|
||||||
setSuccessMessage('Você precisa estar autenticado.');
|
|
||||||
setShowSuccessDialog(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch('http://localhost:8080/api/agency/profile', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: agencyData.name,
|
|
||||||
cnpj: agencyData.cnpj,
|
|
||||||
email: agencyData.email,
|
|
||||||
phone: agencyData.phone,
|
|
||||||
website: agencyData.website,
|
|
||||||
address: agencyData.address,
|
|
||||||
city: agencyData.city,
|
|
||||||
state: agencyData.state,
|
|
||||||
zip: agencyData.zip,
|
|
||||||
razao_social: agencyData.razaoSocial,
|
|
||||||
description: agencyData.description,
|
|
||||||
industry: agencyData.industry,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setSuccessMessage('Dados da agência salvos com sucesso!');
|
|
||||||
} else {
|
|
||||||
setSuccessMessage('Erro ao salvar dados. Tente novamente.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro ao salvar:', error);
|
|
||||||
setSuccessMessage('Erro ao salvar dados. Verifique sua conexão.');
|
|
||||||
}
|
|
||||||
setShowSuccessDialog(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveTheme = () => {
|
|
||||||
// TODO: Integrar com API para salvar no banco
|
|
||||||
const selectedGradient = themePresets[selectedTheme].gradient;
|
|
||||||
console.log('Salvando tema:', selectedGradient);
|
|
||||||
setSuccessMessage('Tema salvo com sucesso!');
|
|
||||||
setShowSuccessDialog(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChangePassword = async () => {
|
|
||||||
// Validações
|
|
||||||
if (!passwordData.currentPassword) {
|
|
||||||
setSuccessMessage('Por favor, informe sua senha atual.');
|
|
||||||
setShowSuccessDialog(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!passwordData.newPassword || passwordData.newPassword.length < 8) {
|
|
||||||
setSuccessMessage('A nova senha deve ter pelo menos 8 caracteres.');
|
|
||||||
setShowSuccessDialog(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (passwordData.newPassword !== passwordData.confirmPassword) {
|
|
||||||
setSuccessMessage('As senhas não coincidem.');
|
|
||||||
setShowSuccessDialog(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
if (!token) {
|
|
||||||
setSuccessMessage('Você precisa estar autenticado.');
|
|
||||||
setShowSuccessDialog(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch('http://localhost:8080/api/auth/change-password', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
currentPassword: passwordData.currentPassword,
|
|
||||||
newPassword: passwordData.newPassword,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
|
||||||
setSuccessMessage('Senha alterada com sucesso!');
|
|
||||||
} else {
|
|
||||||
const error = await response.text();
|
|
||||||
setSuccessMessage(error || 'Erro ao alterar senha. Verifique sua senha atual.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro ao alterar senha:', error);
|
|
||||||
setSuccessMessage('Erro ao alterar senha. Verifique sua conexão.');
|
|
||||||
}
|
|
||||||
setShowSuccessDialog(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6 max-w-7xl mx-auto">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
|
||||||
Configurações
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
|
||||||
Gerencie as configurações da sua agência
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Loading State */}
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 dark:border-gray-100"></div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Tabs */}
|
|
||||||
<Tab.Group selectedIndex={selectedTab} onChange={setSelectedTab}>
|
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-gray-100 dark:bg-gray-800 p-1 mb-8">
|
|
||||||
{tabs.map((tab) => {
|
|
||||||
const Icon = tab.icon;
|
|
||||||
return (
|
|
||||||
<Tab
|
|
||||||
key={tab.name}
|
|
||||||
className={({ selected }) =>
|
|
||||||
`w-full flex items-center justify-center space-x-2 rounded-lg py-2.5 text-sm font-medium leading-5 transition-all
|
|
||||||
${selected
|
|
||||||
? 'bg-white dark:bg-gray-900 text-gray-900 dark:text-white shadow'
|
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:bg-white/[0.5] dark:hover:bg-gray-700/[0.5] hover:text-gray-900 dark:hover:text-white'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon className="w-5 h-5" />
|
|
||||||
<span className="hidden sm:inline">{tab.name}</span>
|
|
||||||
</Tab>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Tab.List>
|
|
||||||
|
|
||||||
<Tab.Panels>
|
|
||||||
{/* Tab 1: Dados da Agência */}
|
|
||||||
<Tab.Panel className="rounded-xl bg-white dark:bg-gray-800 p-6 border border-gray-200 dark:border-gray-700">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">
|
|
||||||
Informações da Agência
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Nome da Agência
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={agencyData.name}
|
|
||||||
onChange={(e) => setAgencyData({ ...agencyData, name: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
CNPJ
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={agencyData.cnpj}
|
|
||||||
onChange={(e) => setAgencyData({ ...agencyData, cnpj: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
E-mail
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={agencyData.email}
|
|
||||||
onChange={(e) => setAgencyData({ ...agencyData, email: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Telefone
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
value={agencyData.phone}
|
|
||||||
onChange={(e) => setAgencyData({ ...agencyData, phone: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Website
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={agencyData.website}
|
|
||||||
onChange={(e) => setAgencyData({ ...agencyData, website: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Endereço
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={agencyData.address}
|
|
||||||
onChange={(e) => setAgencyData({ ...agencyData, address: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Cidade
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={agencyData.city}
|
|
||||||
onChange={(e) => setAgencyData({ ...agencyData, city: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Estado
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={agencyData.state}
|
|
||||||
onChange={(e) => setAgencyData({ ...agencyData, state: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
CEP
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={agencyData.zip}
|
|
||||||
onChange={(e) => setAgencyData({ ...agencyData, zip: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end">
|
|
||||||
<button
|
|
||||||
onClick={handleSaveAgency}
|
|
||||||
className="px-6 py-2 rounded-lg text-white font-medium transition-all hover:scale-105"
|
|
||||||
style={{ background: 'var(--gradient-primary)' }}
|
|
||||||
>
|
|
||||||
Salvar Alterações
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Tab.Panel>
|
|
||||||
|
|
||||||
{/* Tab 2: Personalização */}
|
|
||||||
<Tab.Panel className="rounded-xl bg-white dark:bg-gray-800 p-6 border border-gray-200 dark:border-gray-700">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">
|
|
||||||
Personalização do Dashboard
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* Temas Pré-definidos */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">
|
|
||||||
Temas Pré-definidos
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
||||||
{themePresets.map((theme, idx) => (
|
|
||||||
<button
|
|
||||||
key={theme.name}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedTheme(idx);
|
|
||||||
applyTheme(theme.gradient);
|
|
||||||
}}
|
|
||||||
className={`p-4 rounded-xl border-2 transition-all hover:scale-105 ${selectedTheme === idx
|
|
||||||
? 'border-gray-900 dark:border-gray-100'
|
|
||||||
: 'border-gray-200 dark:border-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-full h-24 rounded-lg mb-3"
|
|
||||||
style={{ background: theme.gradient }}
|
|
||||||
/>
|
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
|
||||||
{theme.name}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cores Customizadas */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">
|
|
||||||
Cores Personalizadas
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-gray-600 dark:text-gray-400 mb-2">
|
|
||||||
Cor Primária
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={customColor1}
|
|
||||||
onChange={(e) => setCustomColor1(e.target.value)}
|
|
||||||
className="w-20 h-20 rounded-lg cursor-pointer border-2 border-gray-300 dark:border-gray-600"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-gray-600 dark:text-gray-400 mb-2">
|
|
||||||
Cor Secundária
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={customColor2}
|
|
||||||
onChange={(e) => setCustomColor2(e.target.value)}
|
|
||||||
className="w-20 h-20 rounded-lg cursor-pointer border-2 border-gray-300 dark:border-gray-600"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="block text-xs text-gray-600 dark:text-gray-400 mb-2">
|
|
||||||
Preview
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
className="h-20 rounded-lg"
|
|
||||||
style={{ background: `linear-gradient(90deg, ${customColor1}, ${customColor2})` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={applyCustomTheme}
|
|
||||||
className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-lg font-medium hover:scale-105 transition-all"
|
|
||||||
>
|
|
||||||
Aplicar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end">
|
|
||||||
<button
|
|
||||||
onClick={handleSaveTheme}
|
|
||||||
className="px-6 py-2 rounded-lg text-white font-medium transition-all hover:scale-105"
|
|
||||||
style={{ background: 'var(--gradient-primary)' }}
|
|
||||||
>
|
|
||||||
Salvar Tema
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Tab.Panel>
|
|
||||||
|
|
||||||
{/* Tab 3: Logo e Marca */}
|
|
||||||
<Tab.Panel className="rounded-xl bg-white dark:bg-gray-800 p-6 border border-gray-200 dark:border-gray-700">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">
|
|
||||||
Logo e Identidade Visual
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">
|
|
||||||
Logo Principal
|
|
||||||
</label>
|
|
||||||
<div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-8 text-center">
|
|
||||||
<PhotoIcon className="w-12 h-12 mx-auto text-gray-400 mb-3" />
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
|
||||||
Arraste e solte sua logo aqui ou clique para fazer upload
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
|
||||||
PNG, JPG ou SVG (máx. 2MB)
|
|
||||||
</p>
|
|
||||||
<button className="mt-4 px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-lg text-sm font-medium hover:scale-105 transition-all">
|
|
||||||
Selecionar Arquivo
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">
|
|
||||||
Favicon
|
|
||||||
</label>
|
|
||||||
<div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-8 text-center">
|
|
||||||
<PhotoIcon className="w-12 h-12 mx-auto text-gray-400 mb-3" />
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
|
||||||
Upload do favicon (ícone da aba do navegador)
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
|
||||||
ICO ou PNG 32x32 pixels
|
|
||||||
</p>
|
|
||||||
<button className="mt-4 px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-lg text-sm font-medium hover:scale-105 transition-all">
|
|
||||||
Selecionar Arquivo
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end">
|
|
||||||
<button
|
|
||||||
className="px-6 py-2 rounded-lg text-white font-medium transition-all hover:scale-105"
|
|
||||||
style={{ background: 'var(--gradient-primary)' }}
|
|
||||||
>
|
|
||||||
Salvar Alterações
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Tab.Panel>
|
|
||||||
|
|
||||||
{/* Tab 4: Equipe */}
|
|
||||||
<Tab.Panel className="rounded-xl bg-white dark:bg-gray-800 p-6 border border-gray-200 dark:border-gray-700">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">
|
|
||||||
Gerenciamento de Equipe
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<UserGroupIcon className="w-16 h-16 mx-auto text-gray-300 dark:text-gray-600 mb-4" />
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
|
||||||
Em breve: gerenciamento completo de usuários e permissões
|
|
||||||
</p>
|
|
||||||
<button className="px-6 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-lg font-medium hover:scale-105 transition-all">
|
|
||||||
Convidar Membro
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Tab.Panel>
|
|
||||||
|
|
||||||
{/* Tab 5: Segurança */}
|
|
||||||
<Tab.Panel className="rounded-xl bg-white dark:bg-gray-800 p-6 border border-gray-200 dark:border-gray-700">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">
|
|
||||||
Segurança e Privacidade
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* Alteração de Senha */}
|
|
||||||
<div className="max-w-2xl">
|
|
||||||
<h3 className="text-md font-medium text-gray-900 dark:text-white mb-4">
|
|
||||||
Alterar Senha
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Senha Atual
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={passwordData.currentPassword}
|
|
||||||
onChange={(e) => setPasswordData({ ...passwordData, currentPassword: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
|
||||||
placeholder="Digite sua senha atual"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Nova Senha
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={passwordData.newPassword}
|
|
||||||
onChange={(e) => setPasswordData({ ...passwordData, newPassword: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
|
||||||
placeholder="Digite a nova senha (mínimo 8 caracteres)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Confirmar Nova Senha
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={passwordData.confirmPassword}
|
|
||||||
onChange={(e) => setPasswordData({ ...passwordData, confirmPassword: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none"
|
|
||||||
placeholder="Digite a nova senha novamente"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-4">
|
|
||||||
<button
|
|
||||||
onClick={handleChangePassword}
|
|
||||||
className="px-6 py-2 rounded-lg text-white font-medium transition-all hover:scale-105"
|
|
||||||
style={{ background: 'var(--gradient-primary)' }}
|
|
||||||
>
|
|
||||||
Alterar Senha
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recursos Futuros */}
|
|
||||||
<div className="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<h3 className="text-md font-medium text-gray-900 dark:text-white mb-4">
|
|
||||||
Recursos em Desenvolvimento
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<ShieldCheckIcon className="w-5 h-5" />
|
|
||||||
<span>Autenticação em duas etapas (2FA)</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<ShieldCheckIcon className="w-5 h-5" />
|
|
||||||
<span>Histórico de acessos</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<ShieldCheckIcon className="w-5 h-5" />
|
|
||||||
<span>Dispositivos conectados</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tab.Panel>
|
|
||||||
|
|
||||||
{/* Tab 6: Notificações */}
|
|
||||||
<Tab.Panel className="rounded-xl bg-white dark:bg-gray-800 p-6 border border-gray-200 dark:border-gray-700">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">
|
|
||||||
Preferências de Notificações
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<BellIcon className="w-16 h-16 mx-auto text-gray-300 dark:text-gray-600 mb-4" />
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
|
||||||
Em breve: configuração de notificações por e-mail, push e mais
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Tab.Panel>
|
|
||||||
</Tab.Panels>
|
|
||||||
</Tab.Group>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Dialog de Sucesso */}
|
|
||||||
<Dialog
|
|
||||||
isOpen={showSuccessDialog}
|
|
||||||
onClose={() => setShowSuccessDialog(false)}
|
|
||||||
title="Sucesso"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<Dialog.Body>
|
|
||||||
<p className="text-center py-4">{successMessage}</p>
|
|
||||||
</Dialog.Body>
|
|
||||||
<Dialog.Footer>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowSuccessDialog(false)}
|
|
||||||
className="px-6 py-2 rounded-lg text-white font-medium transition-all hover:scale-105"
|
|
||||||
style={{ background: 'var(--gradient-primary)' }}
|
|
||||||
>
|
|
||||||
OK
|
|
||||||
</button>
|
|
||||||
</Dialog.Footer>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,25 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
const DEFAULT_GRADIENT = 'linear-gradient(135deg, #ff3a05, #ff0080)';
|
||||||
|
|
||||||
|
const setGradientVariables = (gradient: string) => {
|
||||||
|
document.documentElement.style.setProperty('--gradient-primary', gradient);
|
||||||
|
document.documentElement.style.setProperty('--gradient', gradient);
|
||||||
|
document.documentElement.style.setProperty('--gradient-text', gradient.replace('90deg', 'to right'));
|
||||||
|
document.documentElement.style.setProperty('--color-gradient-brand', gradient.replace('90deg', 'to right'));
|
||||||
|
};
|
||||||
|
|
||||||
export default function LayoutWrapper({ children }: { children: ReactNode }) {
|
export default function LayoutWrapper({ children }: { children: ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Reseta tema padrão em toda troca de rota
|
||||||
|
setGradientVariables(DEFAULT_GRADIENT);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
80
front-end-dash.aggios.app/app/api/[...path]/route.ts
Normal file
80
front-end-dash.aggios.app/app/api/[...path]/route.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const { path: pathArray } = await params;
|
||||||
|
const path = pathArray?.join("/") || "";
|
||||||
|
const token = req.headers.get("authorization");
|
||||||
|
const host = req.headers.get("host");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://backend:8080/api/${path}${req.nextUrl.search}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Authorization": token || "",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Forwarded-Host": host || "",
|
||||||
|
"X-Original-Host": host || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API proxy error:", error);
|
||||||
|
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const { path: pathArray } = await params;
|
||||||
|
const path = pathArray?.join("/") || "";
|
||||||
|
const token = req.headers.get("authorization");
|
||||||
|
const host = req.headers.get("host");
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://backend:8080/api/${path}${req.nextUrl.search}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Authorization": token || "",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Forwarded-Host": host || "",
|
||||||
|
"X-Original-Host": host || "",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API proxy error:", error);
|
||||||
|
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const { path: pathArray } = await params;
|
||||||
|
const path = pathArray?.join("/") || "";
|
||||||
|
const token = req.headers.get("authorization");
|
||||||
|
const host = req.headers.get("host");
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://backend:8080/api/${path}${req.nextUrl.search}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": token || "",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Forwarded-Host": host || "",
|
||||||
|
"X-Original-Host": host || "",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API proxy error:", error);
|
||||||
|
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,13 @@ export async function POST(request: NextRequest) {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const text = await response.text();
|
||||||
|
let data: any;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch (e) {
|
||||||
|
data = { error: text };
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return NextResponse.json(data, { status: response.status });
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
|||||||
267
front-end-dash.aggios.app/app/cadastro/[slug]/page.tsx
Normal file
267
front-end-dash.aggios.app/app/cadastro/[slug]/page.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Input from '@/components/ui/Input';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import { CheckCircleIcon } from '@heroicons/react/24/solid';
|
||||||
|
|
||||||
|
interface FormField {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
required: boolean;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SignupTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
slug: string;
|
||||||
|
form_fields: FormField[];
|
||||||
|
enabled_modules: string[];
|
||||||
|
redirect_url?: string;
|
||||||
|
success_message?: string;
|
||||||
|
custom_logo_url?: string;
|
||||||
|
custom_primary_color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomSignupPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [template, setTemplate] = useState<SignupTemplate | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||||
|
const [slug, setSlug] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
params.then(p => {
|
||||||
|
setSlug(p.slug);
|
||||||
|
});
|
||||||
|
}, [params]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (slug) {
|
||||||
|
loadTemplate();
|
||||||
|
}
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
const loadTemplate = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/signup-templates/slug/${slug}`);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setTemplate(data);
|
||||||
|
|
||||||
|
// Inicializar formData com campos vazios
|
||||||
|
const initialData: Record<string, string> = {};
|
||||||
|
data.form_fields.forEach((field: FormField) => {
|
||||||
|
initialData[field.name] = '';
|
||||||
|
});
|
||||||
|
setFormData(initialData);
|
||||||
|
} else {
|
||||||
|
setError('Template de cadastro não encontrado');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Erro ao carregar formulário de cadastro');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Registro público via template
|
||||||
|
const payload = {
|
||||||
|
template_slug: slug,
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
name: formData.company_name || formData.subdomain || 'Cliente',
|
||||||
|
subdomain: formData.subdomain,
|
||||||
|
company_name: formData.company_name,
|
||||||
|
...formData, // Incluir todos os campos adicionais
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch('/api/signup/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setSuccess(true);
|
||||||
|
|
||||||
|
// Redirecionar após 2 segundos
|
||||||
|
setTimeout(() => {
|
||||||
|
if (template?.redirect_url) {
|
||||||
|
window.location.href = template.redirect_url;
|
||||||
|
} else {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
setError(data.error || 'Erro ao realizar cadastro');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Erro ao processar cadastro');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (fieldName: string, value: string) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[fieldName]: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 dark:border-white"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !template) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg p-8 max-w-md w-full text-center border border-gray-200 dark:border-gray-800">
|
||||||
|
<div className="w-16 h-16 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<span className="text-3xl">⚠️</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Link Inválido
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => router.push('/')}>
|
||||||
|
Voltar para Início
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg p-8 max-w-md w-full text-center border border-gray-200 dark:border-gray-800">
|
||||||
|
<div className="w-16 h-16 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<CheckCircleIcon className="w-10 h-10 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Cadastro Realizado!
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
{template?.success_message || 'Seu cadastro foi realizado com sucesso. Redirecionando...'}
|
||||||
|
</p>
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 dark:border-white mx-auto"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedFields = [...(template?.form_fields || [])].sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg p-8 max-w-md w-full border border-gray-200 dark:border-gray-800">
|
||||||
|
{/* Logo personalizado */}
|
||||||
|
{template?.custom_logo_url && (
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<img
|
||||||
|
src={template.custom_logo_url}
|
||||||
|
alt="Logo"
|
||||||
|
className="h-12 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cabeçalho */}
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
{template?.name}
|
||||||
|
</h1>
|
||||||
|
{template?.description && (
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{template.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Módulos incluídos */}
|
||||||
|
{template && template.enabled_modules.length > 0 && (
|
||||||
|
<div className="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||||
|
<p className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
|
||||||
|
Módulos incluídos:
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{template.enabled_modules.map((module) => (
|
||||||
|
<span
|
||||||
|
key={module}
|
||||||
|
className="px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded text-xs font-medium"
|
||||||
|
>
|
||||||
|
{module}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Formulário */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{sortedFields.map((field) => (
|
||||||
|
<Input
|
||||||
|
key={field.name}
|
||||||
|
label={field.label}
|
||||||
|
type={field.type}
|
||||||
|
value={formData[field.name] || ''}
|
||||||
|
onChange={(e) => handleInputChange(field.name, e.target.value)}
|
||||||
|
required={field.required}
|
||||||
|
placeholder={`Digite ${field.label.toLowerCase()}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={submitting}
|
||||||
|
style={template?.custom_primary_color ? {
|
||||||
|
background: template.custom_primary_color
|
||||||
|
} : undefined}
|
||||||
|
>
|
||||||
|
{submitting ? 'Cadastrando...' : 'Criar Conta'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Link para login */}
|
||||||
|
<p className="mt-6 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Já tem uma conta?{' '}
|
||||||
|
<a href="/login" className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400">
|
||||||
|
Fazer login
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -107,12 +107,6 @@ export default function CadastroPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
number: 4,
|
number: 4,
|
||||||
title: "Domínio",
|
|
||||||
heading: "Escolha seu Domínio",
|
|
||||||
description: "Defina o endereço único para acessar o painel da sua empresa."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: 5,
|
|
||||||
title: "Personalização",
|
title: "Personalização",
|
||||||
heading: "Personalize seu Painel",
|
heading: "Personalize seu Painel",
|
||||||
description: "Configure as cores e identidade visual da sua empresa."
|
description: "Configure as cores e identidade visual da sua empresa."
|
||||||
@@ -224,6 +218,26 @@ export default function CadastroPage() {
|
|||||||
toast.error('Por favor, selecione o tamanho da equipe da sua empresa.');
|
toast.error('Por favor, selecione o tamanho da equipe da sua empresa.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!subdomain || subdomain.trim().length < 3) {
|
||||||
|
toast.error('O subdomínio deve ter pelo menos 3 caracteres. Exemplo: minhaempresa');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[a-z0-9-]+$/.test(subdomain)) {
|
||||||
|
toast.error('O subdomínio deve conter apenas letras minúsculas, números e hífens.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domainAvailable === false) {
|
||||||
|
toast.error('Este subdomínio já está em uso. Escolha outro.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domainAvailable === null) {
|
||||||
|
toast.error('Aguarde a verificação de disponibilidade do domínio.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentStep === 3) {
|
if (currentStep === 3) {
|
||||||
@@ -267,27 +281,7 @@ export default function CadastroPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentStep === 4) {
|
|
||||||
if (!subdomain || subdomain.trim().length < 3) {
|
|
||||||
toast.error('O subdomínio deve ter pelo menos 3 caracteres. Exemplo: minhaempresa');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/^[a-z0-9-]+$/.test(subdomain)) {
|
|
||||||
toast.error('O subdomínio deve conter apenas letras minúsculas, números e hífens.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (domainAvailable === false) {
|
|
||||||
toast.error('Este subdomínio já está em uso. Escolha outro.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (domainAvailable === null) {
|
|
||||||
toast.error('Aguarde a verificação de disponibilidade do domínio.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
@@ -296,48 +290,46 @@ export default function CadastroPage() {
|
|||||||
const handleSubmitRegistration = async () => {
|
const handleSubmitRegistration = async () => {
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
// Dados da agência
|
// Step 1 - Dados Pessoais
|
||||||
agencyName: formData.companyName,
|
email: formData.email,
|
||||||
subdomain: subdomain,
|
password: password,
|
||||||
|
fullName: formData.fullName,
|
||||||
|
newsletter: formData.newsletter || false,
|
||||||
|
|
||||||
|
// Step 2 - Empresa
|
||||||
|
companyName: formData.companyName,
|
||||||
cnpj: formData.cnpj,
|
cnpj: formData.cnpj,
|
||||||
razaoSocial: formData.razaoSocial,
|
razaoSocial: cnpjData.razaoSocial,
|
||||||
description: formData.description,
|
description: formData.description,
|
||||||
website: formData.website,
|
website: formData.website,
|
||||||
industry: formData.industry,
|
industry: formData.industry,
|
||||||
|
teamSize: formData.teamSize,
|
||||||
|
subdomain: subdomain,
|
||||||
|
|
||||||
// Endereço
|
// Step 3 - Localização e Contato
|
||||||
cep: formData.cep,
|
cep: formData.cep,
|
||||||
state: formData.state,
|
state: cepData.state,
|
||||||
city: formData.city,
|
city: cepData.city,
|
||||||
neighborhood: formData.neighborhood,
|
neighborhood: cepData.neighborhood,
|
||||||
street: formData.street,
|
street: cepData.street,
|
||||||
number: formData.number,
|
number: formData.number,
|
||||||
complement: formData.complement,
|
complement: formData.complement,
|
||||||
|
contacts: contacts,
|
||||||
|
|
||||||
// Admin
|
// Step 4 - Personalização
|
||||||
adminEmail: formData.email,
|
primaryColor: primaryColor,
|
||||||
adminPassword: password,
|
secondaryColor: secondaryColor,
|
||||||
adminName: formData.fullName,
|
logoUrl: logoUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('📤 Enviando cadastro completo:', payload);
|
console.log('📤 Enviando cadastro completo:', payload);
|
||||||
toast.loading('Criando sua conta...', { id: 'register' });
|
toast.loading('Criando sua conta...', { id: 'register' });
|
||||||
|
|
||||||
const response = await fetch('/api/admin/agencies', {
|
const data = await apiRequest(API_ENDPOINTS.register, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.message || 'Erro ao criar conta');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
console.log('📥 Resposta data:', data);
|
console.log('📥 Resposta data:', data);
|
||||||
|
|
||||||
// Salvar autenticação
|
// Salvar autenticação
|
||||||
@@ -346,7 +338,7 @@ export default function CadastroPage() {
|
|||||||
id: data.id,
|
id: data.id,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
role: data.role || 'ADMIN_AGENCIA',
|
role: data.role,
|
||||||
tenantId: data.tenantId,
|
tenantId: data.tenantId,
|
||||||
company: data.company,
|
company: data.company,
|
||||||
subdomain: data.subdomain
|
subdomain: data.subdomain
|
||||||
@@ -356,7 +348,7 @@ export default function CadastroPage() {
|
|||||||
// Sucesso - limpar localStorage do form
|
// Sucesso - limpar localStorage do form
|
||||||
localStorage.removeItem('cadastroFormData');
|
localStorage.removeItem('cadastroFormData');
|
||||||
|
|
||||||
toast.success('Conta criada com sucesso! Redirecionando para seu painel...', {
|
toast.success('Conta criada com sucesso! Redirecionando para o painel...', {
|
||||||
id: 'register',
|
id: 'register',
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
style: {
|
style: {
|
||||||
@@ -365,10 +357,15 @@ export default function CadastroPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Redirecionar para o painel da agência no subdomínio
|
// Aguardar 2 segundos e redirecionar para o painel
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const agencyUrl = `http://${data.subdomain}.localhost/login`;
|
if (data.access_url) {
|
||||||
window.location.href = agencyUrl;
|
// Redirecionar para o domínio criado (com token se possível, ou pedir login lá)
|
||||||
|
// Idealmente, passar um token de uso único ou setar cookie cross-domain
|
||||||
|
window.location.href = data.access_url;
|
||||||
|
} else {
|
||||||
|
window.location.href = '/painel';
|
||||||
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -379,44 +376,7 @@ export default function CadastroPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// MODO TESTE - Preencher dados automaticamente
|
|
||||||
const fillTestData = () => {
|
|
||||||
const testData = {
|
|
||||||
fullName: "Teste Usuario",
|
|
||||||
email: "teste@idealpages.com",
|
|
||||||
confirmPassword: "senha12345",
|
|
||||||
terms: true,
|
|
||||||
newsletter: false,
|
|
||||||
companyName: "IdealPages",
|
|
||||||
cnpj: "12.345.678/0001-90",
|
|
||||||
description: "Agência de desenvolvimento web e aplicativos mobile especializada em soluções digitais",
|
|
||||||
website: "https://idealpages.com",
|
|
||||||
industry: "agencia-digital",
|
|
||||||
teamSize: "1-10",
|
|
||||||
cep: "01310-100",
|
|
||||||
number: "123",
|
|
||||||
complement: "Sala 101",
|
|
||||||
};
|
|
||||||
|
|
||||||
setFormData(testData);
|
|
||||||
setPassword("senha12345");
|
|
||||||
setPasswordStrength(4);
|
|
||||||
setCnpjData({ razaoSocial: "IdealPages LTDA", endereco: "Av Paulista, 1000" });
|
|
||||||
setCepData({ state: "SP", city: "São Paulo", neighborhood: "Bela Vista", street: "Av Paulista" });
|
|
||||||
setContacts([{ id: 1, whatsapp: "(11) 98765-4321" }]);
|
|
||||||
setSubdomain("idealpages");
|
|
||||||
setDomainAvailable(true);
|
|
||||||
setPrimaryColor("#FF3A05");
|
|
||||||
setSecondaryColor("#FF0080");
|
|
||||||
|
|
||||||
// Marcar todos os steps como completos e ir pro step 5
|
|
||||||
setCompletedSteps([1, 2, 3, 4]);
|
|
||||||
setCurrentStep(5);
|
|
||||||
|
|
||||||
toast.success('Dados de teste preenchidos! Clique em Finalizar.', {
|
|
||||||
duration: 3000,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNext = (e?: React.FormEvent) => {
|
const handleNext = (e?: React.FormEvent) => {
|
||||||
if (e) {
|
if (e) {
|
||||||
@@ -427,7 +387,7 @@ export default function CadastroPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentStep < 5) {
|
if (currentStep < 4) {
|
||||||
setCompletedSteps([...completedSteps, currentStep]);
|
setCompletedSteps([...completedSteps, currentStep]);
|
||||||
setCurrentStep(currentStep + 1);
|
setCurrentStep(currentStep + 1);
|
||||||
} else {
|
} else {
|
||||||
@@ -654,7 +614,7 @@ export default function CadastroPage() {
|
|||||||
strokeWidth="4"
|
strokeWidth="4"
|
||||||
fill="none"
|
fill="none"
|
||||||
strokeDasharray={`${2 * Math.PI * 28}`}
|
strokeDasharray={`${2 * Math.PI * 28}`}
|
||||||
strokeDashoffset={`${2 * Math.PI * 28 * (1 - (currentStep / 5))}`}
|
strokeDashoffset={`${2 * Math.PI * 28 * (1 - (currentStep / 4))}`}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
className="transition-all duration-300"
|
className="transition-all duration-300"
|
||||||
/>
|
/>
|
||||||
@@ -666,7 +626,7 @@ export default function CadastroPage() {
|
|||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<span className="text-sm font-bold text-[#000000]">{Math.round((currentStep / 5) * 100)}%</span>
|
<span className="text-sm font-bold text-[#000000]">{Math.round((currentStep / 4) * 100)}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -677,6 +637,8 @@ export default function CadastroPage() {
|
|||||||
{currentStepData?.description}
|
{currentStepData?.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -792,9 +754,60 @@ export default function CadastroPage() {
|
|||||||
placeholder="Ex: IdeaPages, DevStudio"
|
placeholder="Ex: IdeaPages, DevStudio"
|
||||||
leftIcon="ri-building-line"
|
leftIcon="ri-building-line"
|
||||||
value={formData.companyName || ''}
|
value={formData.companyName || ''}
|
||||||
onChange={(e) => updateFormData('companyName', e.target.value)}
|
onChange={(e) => {
|
||||||
|
const name = e.target.value;
|
||||||
|
updateFormData('companyName', name);
|
||||||
|
// Auto-generate subdomain
|
||||||
|
const slug = name.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||||||
|
setSubdomain(slug);
|
||||||
|
setDomainAvailable(null);
|
||||||
|
}}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
name="subdomain"
|
||||||
|
label="Subdomínio (URL do Painel)"
|
||||||
|
placeholder="minhaempresa"
|
||||||
|
leftIcon="ri-global-line"
|
||||||
|
rightIcon={
|
||||||
|
checkingDomain ? "ri-loader-4-line animate-spin text-brand-500" :
|
||||||
|
domainAvailable === true ? "ri-checkbox-circle-fill text-green-500" :
|
||||||
|
domainAvailable === false ? "ri-close-circle-fill text-red-500" :
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
value={subdomain}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '');
|
||||||
|
setSubdomain(value);
|
||||||
|
setDomainAvailable(null);
|
||||||
|
}}
|
||||||
|
onBlur={() => subdomain && checkDomainAvailability(subdomain)}
|
||||||
|
helperText={
|
||||||
|
<span className="flex items-center justify-between w-full">
|
||||||
|
<span>
|
||||||
|
Seu painel: <strong className="text-zinc-900 dark:text-white">{subdomain || '...'}</strong>.aggios.app
|
||||||
|
</span>
|
||||||
|
{checkingDomain && (
|
||||||
|
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-brand-600 bg-brand-50 px-2.5 py-0.5 rounded-full border border-brand-100">
|
||||||
|
<i className="ri-loader-4-line animate-spin"></i> VERIFICANDO
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{domainAvailable === true && (
|
||||||
|
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-700 bg-emerald-50 px-2.5 py-0.5 rounded-full border border-emerald-200">
|
||||||
|
<i className="ri-check-double-line"></i> DISPONÍVEL
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{domainAvailable === false && (
|
||||||
|
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-red-700 bg-red-50 px-2.5 py-0.5 rounded-full border border-red-200">
|
||||||
|
<i className="ri-close-line"></i> INDISPONÍVEL
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
name="cnpj"
|
name="cnpj"
|
||||||
label="CNPJ"
|
label="CNPJ"
|
||||||
@@ -1040,81 +1053,9 @@ export default function CadastroPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{currentStep === 4 && (
|
{currentStep === 4 && (
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Subdomínio Aggios */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-sm font-medium text-[#000000]">
|
|
||||||
Subdomínio Aggios <span className="text-[#FF3A05]">*</span>
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
|
||||||
<i className="ri-global-line text-[#7D7D7D] text-[18px]" />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={subdomain}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
||||||
setSubdomain(value);
|
|
||||||
setDomainAvailable(null);
|
|
||||||
}}
|
|
||||||
onBlur={() => subdomain && checkDomainAvailability(subdomain)}
|
|
||||||
placeholder="minhaempresa"
|
|
||||||
className="w-full pl-10 pr-4 py-2 text-sm border border-[#E5E5E5] rounded-md focus:border-[#FF3A05] transition-colors"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{checkingDomain && (
|
|
||||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
|
||||||
<div className="w-4 h-4 border-2 border-[#FF3A05] border-t-transparent rounded-full animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!checkingDomain && domainAvailable === true && (
|
|
||||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
|
||||||
<i className="ri-checkbox-circle-fill text-[#10B981] text-[20px]" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!checkingDomain && domainAvailable === false && (
|
|
||||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
|
||||||
<i className="ri-close-circle-fill text-[#FF3A05] text-[20px]" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-[#7D7D7D] flex items-center gap-1">
|
|
||||||
<i className="ri-information-line" />
|
|
||||||
Seu painel ficará em: <span className="font-medium text-[#000000]">{subdomain || 'seu-dominio'}.aggios.app</span>
|
|
||||||
</p>
|
|
||||||
{domainAvailable === true && (
|
|
||||||
<p className="text-xs text-[#10B981] flex items-center gap-1">
|
|
||||||
<i className="ri-checkbox-circle-line" />
|
|
||||||
Disponível! Este subdomínio pode ser usado.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{domainAvailable === false && (
|
|
||||||
<p className="text-xs text-[#FF3A05] flex items-center gap-1">
|
|
||||||
<i className="ri-error-warning-line" />
|
|
||||||
Indisponível. Este subdomínio já está em uso.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Informações Adicionais */}
|
|
||||||
<div className="p-6 bg-[#F5F5F5] rounded-md space-y-3">
|
|
||||||
<h4 className="text-sm font-semibold text-[#000000] flex items-center gap-2">
|
|
||||||
<i className="ri-lightbulb-line text-[#FF3A05]" />
|
|
||||||
Dicas para escolher seu domínio
|
|
||||||
</h4>
|
|
||||||
<ul className="text-xs text-[#7D7D7D] space-y-1 ml-6">
|
|
||||||
<li className="list-disc">Use o nome da sua empresa</li>
|
|
||||||
<li className="list-disc">Evite números e hífens quando possível</li>
|
|
||||||
<li className="list-disc">Escolha algo fácil de lembrar e digitar</li>
|
|
||||||
<li className="list-disc">Mínimo de 3 caracteres</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentStep === 5 && (
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Botão Toggle Preview (Mobile Only) */}
|
{/* Botão Toggle Preview (Mobile Only) */}
|
||||||
<div className="lg:hidden">
|
<div className="lg:hidden">
|
||||||
@@ -1388,9 +1329,9 @@ export default function CadastroPage() {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
rightIcon={currentStep === 5 ? "ri-check-line" : "ri-arrow-right-line"}
|
rightIcon={currentStep === 4 ? "ri-check-line" : "ri-arrow-right-line"}
|
||||||
>
|
>
|
||||||
{currentStep === 5 ? "Finalizar" : "Continuar"}
|
{currentStep === 4 ? "Finalizar" : "Continuar"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1431,10 +1372,10 @@ export default function CadastroPage() {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
rightIcon={currentStep === 5 ? "ri-check-line" : "ri-arrow-right-line"}
|
rightIcon={currentStep === 4 ? "ri-check-line" : "ri-arrow-right-line"}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
{currentStep === 5 ? "Finalizar" : "Continuar"}
|
{currentStep === 4 ? "Finalizar" : "Continuar"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "./tokens.css";
|
@import "./tokens.css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
@@ -47,7 +47,17 @@ html.dark {
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
font-family: var(--font-arimo), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
a,
|
||||||
|
button,
|
||||||
|
[role="button"],
|
||||||
|
input[type="submit"],
|
||||||
|
input[type="reset"],
|
||||||
|
input[type="button"],
|
||||||
|
label[for] {
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -61,6 +71,14 @@ html.dark {
|
|||||||
color: var(--color-text-inverse);
|
color: var(--color-text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Seleção em campos de formulário usa o gradiente padrão da marca */
|
||||||
|
input::selection,
|
||||||
|
textarea::selection,
|
||||||
|
select::selection {
|
||||||
|
background: var(--color-gradient-brand);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
.surface-card {
|
.surface-card {
|
||||||
background-color: var(--color-surface-card);
|
background-color: var(--color-surface-card);
|
||||||
border: 1px solid var(--color-border-strong);
|
border: 1px solid var(--color-border-strong);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter, Open_Sans, Fira_Code } from "next/font/google";
|
import { Open_Sans, Fira_Code, Arimo } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import LayoutWrapper from "./LayoutWrapper";
|
import LayoutWrapper from "./LayoutWrapper";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
|
|
||||||
const inter = Inter({
|
const arimo = Arimo({
|
||||||
variable: "--font-inter",
|
variable: "--font-arimo",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
weight: ["400", "500", "600", "700"],
|
weight: ["400", "500", "600", "700"],
|
||||||
});
|
});
|
||||||
@@ -24,7 +24,7 @@ const firaCode = Fira_Code({
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Aggios - Dashboard",
|
title: "Aggios - Dashboard",
|
||||||
description: "Plataforma SaaS para agências digitais",
|
description: "Painel administrativo SuperAdmin",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -37,7 +37,7 @@ export default function RootLayout({
|
|||||||
<head>
|
<head>
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/remixicon@4.3.0/fonts/remixicon.css" />
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/remixicon@4.3.0/fonts/remixicon.css" />
|
||||||
</head>
|
</head>
|
||||||
<body className={`${inter.variable} ${openSans.variable} ${firaCode.variable} antialiased`}>
|
<body className={`${arimo.variable} ${openSans.variable} ${firaCode.variable} antialiased`}>
|
||||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||||
<LayoutWrapper>
|
<LayoutWrapper>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -4,11 +4,20 @@ import { useState, useEffect } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button, Input, Checkbox } from "@/components/ui";
|
import { Button, Input, Checkbox } from "@/components/ui";
|
||||||
import toast, { Toaster } from 'react-hot-toast';
|
import toast, { Toaster } from 'react-hot-toast';
|
||||||
import { saveAuth } from '@/lib/auth';
|
import { saveAuth, isAuthenticated } from '@/lib/auth';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
const ThemeToggle = dynamic(() => import('@/components/ThemeToggle'), { ssr: false });
|
const ThemeToggle = dynamic(() => import('@/components/ThemeToggle'), { ssr: false });
|
||||||
|
|
||||||
|
const DEFAULT_GRADIENT = 'linear-gradient(135deg, #ff3a05, #ff0080)';
|
||||||
|
|
||||||
|
const setGradientVariables = (gradient: string) => {
|
||||||
|
document.documentElement.style.setProperty('--gradient-primary', gradient);
|
||||||
|
document.documentElement.style.setProperty('--gradient', gradient);
|
||||||
|
document.documentElement.style.setProperty('--gradient-text', gradient.replace('90deg', 'to right'));
|
||||||
|
document.documentElement.style.setProperty('--color-gradient-brand', gradient.replace('90deg', 'to right'));
|
||||||
|
};
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isSuperAdmin, setIsSuperAdmin] = useState(false);
|
const [isSuperAdmin, setIsSuperAdmin] = useState(false);
|
||||||
@@ -20,12 +29,22 @@ export default function LoginPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Detectar se é dash (SUPERADMIN) ou agência
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const hostname = window.location.hostname;
|
setIsSuperAdmin(true);
|
||||||
const sub = hostname.split('.')[0];
|
setGradientVariables(DEFAULT_GRADIENT);
|
||||||
setSubdomain(sub);
|
|
||||||
setIsSuperAdmin(sub === 'dash');
|
if (isAuthenticated()) {
|
||||||
|
const userData = localStorage.getItem('user');
|
||||||
|
if (userData) {
|
||||||
|
const user = JSON.parse(userData);
|
||||||
|
if (user.role === 'SUPERADMIN') {
|
||||||
|
window.location.href = '/superadmin';
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -68,15 +87,15 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
localStorage.setItem('token', data.token);
|
saveAuth(data.token, data.user);
|
||||||
localStorage.setItem('user', JSON.stringify(data.user));
|
|
||||||
|
|
||||||
console.log('Login successful:', data.user);
|
console.log('Login successful:', data.user);
|
||||||
|
|
||||||
toast.success('Login realizado com sucesso! Redirecionando...');
|
toast.success('Login realizado com sucesso! Redirecionando...');
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = '/dashboard';
|
const target = isSuperAdmin ? '/superadmin' : '/dashboard';
|
||||||
|
window.location.href = target;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message || 'Erro ao fazer login. Verifique suas credenciais.');
|
toast.error(error.message || 'Erro ao fazer login. Verifique suas credenciais.');
|
||||||
@@ -101,7 +120,7 @@ export default function LoginPage() {
|
|||||||
error: {
|
error: {
|
||||||
icon: '⚠️',
|
icon: '⚠️',
|
||||||
style: {
|
style: {
|
||||||
background: '#ff3a05',
|
background: '#ef4444',
|
||||||
color: '#FFFFFF',
|
color: '#FFFFFF',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
},
|
},
|
||||||
@@ -194,20 +213,6 @@ export default function LoginPage() {
|
|||||||
>
|
>
|
||||||
{isLoading ? 'Entrando...' : 'Entrar'}
|
{isLoading ? 'Entrando...' : 'Entrar'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Link para cadastro - apenas para agências */}
|
|
||||||
{!isSuperAdmin && (
|
|
||||||
<p className="text-center text-[14px] text-[#7D7D7D] dark:text-gray-400">
|
|
||||||
Ainda não tem conta?{' '}
|
|
||||||
<a
|
|
||||||
href="http://dash.localhost/cadastro"
|
|
||||||
className="font-medium hover:opacity-80 transition-opacity"
|
|
||||||
style={{ background: 'var(--gradient-primary)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}
|
|
||||||
>
|
|
||||||
Cadastre sua agência
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,13 +222,10 @@ export default function LoginPage() {
|
|||||||
<div className="absolute inset-0 flex flex-col items-center justify-center p-12 text-white">
|
<div className="absolute inset-0 flex flex-col items-center justify-center p-12 text-white">
|
||||||
<div className="max-w-md text-center">
|
<div className="max-w-md text-center">
|
||||||
<h1 className="text-5xl font-bold mb-6">
|
<h1 className="text-5xl font-bold mb-6">
|
||||||
{isSuperAdmin ? 'aggios' : subdomain}
|
aggios
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl opacity-90 mb-8">
|
<p className="text-xl opacity-90 mb-8">
|
||||||
{isSuperAdmin
|
Gerencie todas as agências em um só lugar
|
||||||
? 'Gerencie todas as agências em um só lugar'
|
|
||||||
: 'Gerencie seus clientes com eficiência'
|
|
||||||
}
|
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-6 text-left">
|
<div className="grid grid-cols-2 gap-6 text-left">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function NotFound() {
|
|||||||
<div className="w-full max-w-md text-center">
|
<div className="w-full max-w-md text-center">
|
||||||
{/* Logo mobile */}
|
{/* Logo mobile */}
|
||||||
<div className="lg:hidden mb-8">
|
<div className="lg:hidden mb-8">
|
||||||
<div className="inline-block px-6 py-3 rounded-2xl bg-linear-to-r from-[#FF3A05] to-[#FF0080]">
|
<div className="inline-block px-6 py-3 rounded-2xl bg-linear-to-r from-brand-500 to-brand-700">
|
||||||
<h1 className="text-3xl font-bold text-white">aggios</h1>
|
<h1 className="text-3xl font-bold text-white">aggios</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,7 +82,7 @@ export default function NotFound() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lado Direito - Branding */}
|
{/* Lado Direito - Branding */}
|
||||||
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden" style={{ background: 'linear-gradient(90deg, #FF3A05, #FF0080)' }}>
|
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden" style={{ background: 'var(--gradient-primary)' }}>
|
||||||
<div className="relative z-10 flex flex-col justify-center items-center w-full p-12 text-white">
|
<div className="relative z-10 flex flex-col justify-center items-center w-full p-12 text-white">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user