Prepara versao dev 1.0

This commit is contained in:
Erik Silva
2025-12-08 21:47:38 -03:00
parent 512287698e
commit 190fde20c3
85 changed files with 7755 additions and 2317 deletions

1046
1. docs/old/projeto.md Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,61 @@
# Aggios App # Aggios App
Aplicação Aggios Plataforma composta por serviços de autenticação, painel administrativo (superadmin) e site institucional da Aggios, orquestrados via Docker Compose.
## Descrição ## Visão geral
- **Objetivo**: permitir que superadministradores cadastrem e gerenciem agências (tenants) enquanto o site institucional apresenta informações públicas da empresa.
- **Stack**: Go (backend), Next.js 14 (dashboard e site), PostgreSQL, Traefik, Docker.
- **Status**: fluxo de autenticação e gestão de agências concluído; ambiente dockerizável pronto para uso local.
Projeto em desenvolvimento. ## Componentes principais
- `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.
- `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).
- `traefik/`: reverse proxy e certificados automatizados.
### Atualização recente ## Funcionalidades entregues
- Login de superadmin via JWT e restrição de rotas protegidas no dashboard.
- Cadastro de agências: criação de tenant e usuário administrador atrelado.
- Listagem, detalhamento e exclusão de agências diretamente pelo painel superadmin.
- Proxy interno (`app/api/admin/agencies/[id]/route.ts`) garantindo chamadas autenticadas do Next para o backend.
- Site institucional com dark mode, componentes compartilhados e tokens de design centralizados.
- Documentação atualizada em `1. docs/` com fluxos, arquiteturas e changelog.
- 07/12/2025: Site institucional (`frontend-aggios.app`) atualizado com suporte completo a dark mode baseado em Tailwind CSS v4 e `next-themes`. ## Executando o projeto
1. **Pré-requisitos**: Docker Desktop e Node.js 20+ (para utilitários opcionais).
2. **Variáveis**: ajustar `.env` conforme referências existentes (`docker-compose.yml`, arquivos `config`).
3. **Subir os serviços**:
```powershell
docker-compose up --build
```
4. **Hosts locais**:
- Painel: `https://dash.localhost`
- Site: `https://aggios.app.localhost`
- API: `https://api.localhost`
5. **Credenciais padrão**: ver `postgres/init-db.sql` para usuário superadmin seed.
## Como Usar ## Estrutura de diretórios (resumo)
```
backend/ API Go (config, domínio, handlers, serviços)
front-end-dash.aggios.app/ Dashboard Next.js Superadmin
frontend-aggios.app/ Site institucional Next.js
postgres/ Scripts SQL de seed
traefik/ Regras de roteamento e TLS
1. docs/ Documentação funcional e técnica
```
Para configurar e executar o projeto, consulte a documentação em `docs/`. ## Testes e validação
- Consultar `1. docs/TESTING_GUIDE.md` para cenários funcionais.
- Requisições de verificação recomendadas:
- `curl http://api.localhost/api/admin/agencies` (lista) requer token JWT válido.
- `curl http://dash.localhost/api/admin/agencies` (proxy Next) usado pelo painel.
- Fluxo manual via painel `dash.localhost/superadmin`.
## Próximos passos sugeridos
- Implementar soft delete e trilhas de auditoria para exclusão de agências.
- Expandir testes automatizados (unitários e e2e) focados no fluxo do dashboard.
- Disponibilizar pipeline CI/CD com validações de lint/build.
## Repositório ## Repositório
- Principal: https://git.stackbyte.cloud/erik/aggios.app.git
Repositório oficial: https://git.stackbyte.cloud/erik/aggios.app.git

View File

@@ -3,15 +3,18 @@ FROM golang:1.23-alpine AS builder
WORKDIR /build WORKDIR /build
# Copy go.mod and go.sum from cmd/server # Copy go module files
COPY cmd/server/go.mod cmd/server/go.sum ./ COPY go.mod ./
RUN go mod download RUN test -f go.sum && cp go.sum go.sum.bak || true
# Copy source code # Copy entire source tree (internal/, cmd/)
COPY cmd/server/main.go ./ COPY . .
# Build # Ensure go.sum is up to date
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server . RUN go mod tidy
# Build from root (module is defined there)
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server ./cmd/server
# Runtime image # Runtime image
FROM alpine:latest FROM alpine:latest

View File

@@ -1,10 +0,0 @@
module server
go 1.23.12
require (
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/lib/pq v1.10.9
golang.org/x/crypto v0.27.0
)

View File

@@ -1,8 +0,0 @@
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=

View File

@@ -2,576 +2,127 @@ package main
import ( import (
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
_ "github.com/lib/pq" _ "github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
"aggios-app/backend/internal/api/handlers"
"aggios-app/backend/internal/api/middleware"
"aggios-app/backend/internal/config"
"aggios-app/backend/internal/repository"
"aggios-app/backend/internal/service"
) )
var db *sql.DB func initDB(cfg *config.Config) (*sql.DB, error) {
// jwtSecret carrega o secret do ambiente ou usa fallback (NUNCA use fallback em produção)
var jwtSecret = []byte(getEnvOrDefault("JWT_SECRET", "INSECURE-fallback-secret-CHANGE-THIS"))
// Rate limiting simples (IP -> timestamp das últimas tentativas)
var loginAttempts = make(map[string][]time.Time)
var registerAttempts = make(map[string][]time.Time)
const maxAttemptsPerMinute = 5
// corsMiddleware adiciona headers CORS
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// CORS - apenas domínios permitidos
allowedOrigins := map[string]bool{
"http://localhost": true, // Dev local
"http://dash.localhost": true, // Dashboard dev
"http://aggios.local": true, // Institucional dev
"http://dash.aggios.local": true, // Dashboard dev alternativo
"https://aggios.app": true, // Institucional prod
"https://dash.aggios.app": true, // Dashboard prod
"https://www.aggios.app": true, // Institucional prod www
}
origin := r.Header.Get("Origin")
if allowedOrigins[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Credentials", "true")
// Headers de segurança
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
// Handle preflight
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
// Log da requisição (sem dados sensíveis)
log.Printf("📥 %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
next(w, r)
}
}
// RegisterRequest representa os dados completos de registro
type RegisterRequest struct {
// Step 1 - Dados Pessoais
Email string `json:"email"`
Password string `json:"password"`
FullName string `json:"fullName"`
Newsletter bool `json:"newsletter"`
// Step 2 - Empresa
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"`
// Step 3 - Localização
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 []struct {
ID int `json:"id"`
WhatsApp string `json:"whatsapp"`
} `json:"contacts"`
// Step 4 - Domínio
Subdomain string `json:"subdomain"`
// Step 5 - Personalização
PrimaryColor string `json:"primaryColor"`
SecondaryColor string `json:"secondaryColor"`
LogoURL string `json:"logoUrl"`
}
// RegisterResponse representa a resposta do registro
type RegisterResponse struct {
Token string `json:"token"`
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
TenantID string `json:"tenantId"`
Company string `json:"company"`
Subdomain string `json:"subdomain"`
CreatedAt string `json:"created_at"`
}
// ErrorResponse representa uma resposta de erro
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
}
// LoginRequest representa os dados de login
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// LoginResponse representa a resposta do login
type LoginResponse struct {
Token string `json:"token"`
User UserPayload `json:"user"`
}
// UserPayload representa os dados do usuário no token
type UserPayload struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
TenantID string `json:"tenantId"`
Company string `json:"company"`
Subdomain string `json:"subdomain"`
}
// Claims customizado para JWT
type Claims struct {
UserID string `json:"userId"`
Email string `json:"email"`
TenantID string `json:"tenantId"`
jwt.RegisteredClaims
}
// getEnvOrDefault retorna variável de ambiente ou valor padrão
func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// checkRateLimit verifica se IP excedeu limite de tentativas
func checkRateLimit(ip string, attempts map[string][]time.Time) bool {
now := time.Now()
cutoff := now.Add(-1 * time.Minute)
// Limpar tentativas antigas
if timestamps, exists := attempts[ip]; exists {
var recent []time.Time
for _, t := range timestamps {
if t.After(cutoff) {
recent = append(recent, t)
}
}
attempts[ip] = recent
// Verificar se excedeu limite
if len(recent) >= maxAttemptsPerMinute {
return false
}
}
// Adicionar nova tentativa
attempts[ip] = append(attempts[ip], now)
return true
}
// validateEmail valida formato de email
func validateEmail(email string) bool {
if len(email) < 3 || len(email) > 254 {
return false
}
// Regex simples para validação
return strings.Contains(email, "@") && strings.Contains(email, ".")
}
func initDB() error {
connStr := fmt.Sprintf( connStr := fmt.Sprintf(
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", "host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
os.Getenv("DB_HOST"), cfg.Database.Host,
os.Getenv("DB_PORT"), cfg.Database.Port,
os.Getenv("DB_USER"), cfg.Database.User,
os.Getenv("DB_PASSWORD"), cfg.Database.Password,
os.Getenv("DB_NAME"), cfg.Database.Name,
) )
var err error db, err := sql.Open("postgres", connStr)
db, err = sql.Open("postgres", connStr)
if err != nil { if err != nil {
return fmt.Errorf("erro ao abrir conexão: %v", err) return nil, fmt.Errorf("erro ao abrir conexão: %v", err)
} }
if err = db.Ping(); err != nil { if err = db.Ping(); err != nil {
return fmt.Errorf("erro ao conectar ao banco: %v", err) return nil, fmt.Errorf("erro ao conectar ao banco: %v", err)
} }
log.Println("✅ Conectado ao PostgreSQL") log.Println("✅ Conectado ao PostgreSQL")
return nil return db, nil
} }
func main() { func main() {
// Inicializar banco de dados // Load configuration
if err := initDB(); err != nil { cfg := config.Load()
// Initialize database
db, err := initDB(cfg)
if err != nil {
log.Fatalf("❌ Erro ao inicializar banco: %v", err) log.Fatalf("❌ Erro ao inicializar banco: %v", err)
} }
defer db.Close() defer db.Close()
// Health check handlers // Initialize repositories
http.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) { userRepo := repository.NewUserRepository(db)
w.Header().Set("Content-Type", "application/json") tenantRepo := repository.NewTenantRepository(db)
w.WriteHeader(http.StatusOK) companyRepo := repository.NewCompanyRepository(db)
fmt.Fprintf(w, `{"status":"healthy","version":"1.0.0","database":"pending","redis":"pending","minio":"pending"}`)
})
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { // Initialize services
w.Header().Set("Content-Type", "application/json") authService := service.NewAuthService(userRepo, tenantRepo, cfg)
w.WriteHeader(http.StatusOK) agencyService := service.NewAgencyService(userRepo, tenantRepo, cfg)
fmt.Fprintf(w, `{"status":"ok"}`) tenantService := service.NewTenantService(tenantRepo)
}) companyService := service.NewCompanyService(companyRepo)
// Auth routes (com CORS) // Initialize handlers
http.HandleFunc("/api/auth/register", corsMiddleware(handleRegister)) healthHandler := handlers.NewHealthHandler()
http.HandleFunc("/api/auth/login", corsMiddleware(handleLogin)) authHandler := handlers.NewAuthHandler(authService)
http.HandleFunc("/api/me", corsMiddleware(authMiddleware(handleMe))) agencyProfileHandler := handlers.NewAgencyHandler(tenantRepo)
agencyHandler := handlers.NewAgencyRegistrationHandler(agencyService, cfg)
tenantHandler := handlers.NewTenantHandler(tenantService)
companyHandler := handlers.NewCompanyHandler(companyService)
port := os.Getenv("SERVER_PORT") // Create middleware chain
if port == "" { tenantDetector := middleware.TenantDetector(tenantRepo)
port = "8080" corsMiddleware := middleware.CORS(cfg)
securityMiddleware := middleware.SecurityHeaders
rateLimitMiddleware := middleware.RateLimit(cfg)
authMiddleware := middleware.Auth(cfg)
// Setup routes
mux := http.NewServeMux()
// Health check (no auth)
mux.HandleFunc("/health", healthHandler.Check)
mux.HandleFunc("/api/health", healthHandler.Check)
// Auth routes (public with rate limiting)
mux.HandleFunc("/api/auth/login", authHandler.Login)
// Protected auth routes
mux.Handle("/api/auth/change-password", authMiddleware(http.HandlerFunc(authHandler.ChangePassword)))
// Agency management (SUPERADMIN only)
mux.HandleFunc("/api/admin/agencies/register", agencyHandler.RegisterAgency)
mux.HandleFunc("/api/admin/agencies", tenantHandler.ListAll)
mux.HandleFunc("/api/admin/agencies/", agencyHandler.HandleAgency)
// Client registration (ADMIN_AGENCIA only - requires auth)
mux.Handle("/api/agencies/clients/register", authMiddleware(http.HandlerFunc(agencyHandler.RegisterClient)))
// Agency profile routes (protected)
mux.Handle("/api/agency/profile", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
agencyProfileHandler.GetProfile(w, r)
} else if r.Method == http.MethodPut || r.Method == http.MethodPatch {
agencyProfileHandler.UpdateProfile(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
} }
})))
addr := fmt.Sprintf(":%s", port) // Protected routes (require authentication)
mux.Handle("/api/companies", authMiddleware(http.HandlerFunc(companyHandler.List)))
mux.Handle("/api/companies/create", authMiddleware(http.HandlerFunc(companyHandler.Create)))
// Apply global middlewares: tenant -> cors -> security -> rateLimit -> mux
handler := tenantDetector(corsMiddleware(securityMiddleware(rateLimitMiddleware(mux))))
// Start server
addr := fmt.Sprintf(":%s", cfg.Server.Port)
log.Printf("🚀 Server starting on %s", addr) log.Printf("🚀 Server starting on %s", addr)
log.Printf("📍 Health check: http://localhost:%s/health", port) log.Printf("📍 Health check: http://localhost:%s/health", cfg.Server.Port)
log.Printf("🔗 API: http://localhost:%s/api/health", port) log.Printf("🔗 API: http://localhost:%s/api/health", cfg.Server.Port)
log.Printf("👤 Register: http://localhost:%s/api/auth/register", port) log.Printf("🏢 Register Agency (SUPERADMIN): http://localhost:%s/api/admin/agencies/register", cfg.Server.Port)
log.Printf("🔐 Login: http://localhost:%s/api/auth/login", port) log.Printf("🔐 Login: http://localhost:%s/api/auth/login", cfg.Server.Port)
log.Printf("👤 Me: http://localhost:%s/api/me", port)
if err := http.ListenAndServe(addr, nil); err != nil { if err := http.ListenAndServe(addr, handler); err != nil {
log.Fatalf("❌ Server error: %v", err) log.Fatalf("❌ Server error: %v", err)
} }
} }
// handleRegister handler para criar novo usuário
func handleRegister(w http.ResponseWriter, r *http.Request) {
// Apenas POST
if r.Method != http.MethodPost {
sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Rate limiting
ip := strings.Split(r.RemoteAddr, ":")[0]
if !checkRateLimit(ip, registerAttempts) {
sendError(w, "Too many registration attempts. Please try again later.", http.StatusTooManyRequests)
return
}
// Parse JSON
var req RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendError(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Validações básicas
if !validateEmail(req.Email) {
sendError(w, "Invalid email format", http.StatusBadRequest)
return
}
if req.Password == "" {
sendError(w, "Password is required", http.StatusBadRequest)
return
}
if len(req.Password) < 8 {
sendError(w, "Password must be at least 8 characters", http.StatusBadRequest)
return
}
if req.FullName == "" {
sendError(w, "Full name is required", http.StatusBadRequest)
return
}
if req.CompanyName == "" {
sendError(w, "Company name is required", http.StatusBadRequest)
return
}
if req.Subdomain == "" {
sendError(w, "Subdomain is required", http.StatusBadRequest)
return
}
// Verificar se email já existe
var exists bool
err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)", req.Email).Scan(&exists)
if err != nil {
sendError(w, "Database error", http.StatusInternalServerError)
log.Printf("Erro ao verificar email: %v", err)
return
}
if exists {
sendError(w, "Email already registered", http.StatusConflict)
return
}
// Hash da senha com bcrypt
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
sendError(w, "Error processing password", http.StatusInternalServerError)
log.Printf("Erro ao hash senha: %v", err)
return
}
// Criar Tenant (empresa)
tenantID := uuid.New().String()
domain := fmt.Sprintf("%s.aggios.app", req.Subdomain)
createdAt := time.Now()
_, err = db.Exec(
"INSERT INTO tenants (id, name, domain, subdomain, is_active, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)",
tenantID, req.CompanyName, domain, req.Subdomain, true, createdAt, createdAt,
)
if err != nil {
sendError(w, "Error creating company", http.StatusInternalServerError)
log.Printf("Erro ao criar tenant: %v", err)
return
}
log.Printf("✅ Tenant criado: %s (%s)", req.CompanyName, tenantID)
// Criar Usuário (administrador do tenant)
userID := uuid.New().String()
firstName := req.FullName
lastName := ""
_, err = db.Exec(
"INSERT INTO users (id, tenant_id, email, password_hash, first_name, last_name, is_active, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
userID, tenantID, req.Email, string(hashedPassword), firstName, lastName, true, createdAt, createdAt,
)
if err != nil {
sendError(w, "Error creating user", http.StatusInternalServerError)
log.Printf("Erro ao inserir usuário: %v", err)
return
}
log.Printf("✅ Usuário criado: %s (%s)", req.Email, userID)
// Gerar token JWT para login automático
token, err := generateToken(userID, req.Email, tenantID)
if err != nil {
sendError(w, "Error generating token", http.StatusInternalServerError)
log.Printf("Erro ao gerar token: %v", err)
return
}
response := RegisterResponse{
Token: token,
ID: userID,
Email: req.Email,
Name: req.FullName,
TenantID: tenantID,
Company: req.CompanyName,
Subdomain: req.Subdomain,
CreatedAt: createdAt.Format(time.RFC3339),
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)
}
// sendError envia uma resposta de erro padronizada
func sendError(w http.ResponseWriter, message string, statusCode int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(ErrorResponse{
Error: http.StatusText(statusCode),
Message: message,
})
}
// generateToken gera um JWT token para o usuário
func generateToken(userID, email, tenantID string) (string, error) {
claims := Claims{
UserID: userID,
Email: email,
TenantID: tenantID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "aggios-api",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
}
// authMiddleware verifica o token JWT
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
sendError(w, "Authorization header required", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
sendError(w, "Invalid authorization format", http.StatusUnauthorized)
return
}
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
return jwtSecret, nil
})
if err != nil || !token.Valid {
sendError(w, "Invalid or expired token", http.StatusUnauthorized)
return
}
// Adicionar claims ao contexto (simplificado: usar headers)
r.Header.Set("X-User-ID", claims.UserID)
r.Header.Set("X-User-Email", claims.Email)
r.Header.Set("X-Tenant-ID", claims.TenantID)
next(w, r)
}
}
// handleLogin handler para fazer login
func handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Rate limiting
ip := strings.Split(r.RemoteAddr, ":")[0]
if !checkRateLimit(ip, loginAttempts) {
sendError(w, "Too many login attempts. Please try again later.", http.StatusTooManyRequests)
return
}
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendError(w, "Invalid JSON", http.StatusBadRequest)
return
}
if !validateEmail(req.Email) || req.Password == "" {
sendError(w, "Invalid credentials", http.StatusBadRequest)
return
}
// Buscar usuário no banco
var userID, email, passwordHash, firstName, tenantID string
var tenantName, subdomain string
err := db.QueryRow(`
SELECT u.id, u.email, u.password_hash, u.first_name, u.tenant_id, t.name, t.subdomain
FROM users u
INNER JOIN tenants t ON u.tenant_id = t.id
WHERE u.email = $1 AND u.is_active = true
`, req.Email).Scan(&userID, &email, &passwordHash, &firstName, &tenantID, &tenantName, &subdomain)
if err == sql.ErrNoRows {
sendError(w, "Invalid credentials", http.StatusUnauthorized)
return
}
if err != nil {
sendError(w, "Database error", http.StatusInternalServerError)
log.Printf("Erro ao buscar usuário: %v", err)
return
}
// Verificar senha
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
sendError(w, "Invalid credentials", http.StatusUnauthorized)
return
}
// Gerar token JWT
token, err := generateToken(userID, email, tenantID)
if err != nil {
sendError(w, "Error generating token", http.StatusInternalServerError)
log.Printf("Erro ao gerar token: %v", err)
return
}
log.Printf("✅ Login bem-sucedido: %s", email)
response := LoginResponse{
Token: token,
User: UserPayload{
ID: userID,
Email: email,
Name: firstName,
TenantID: tenantID,
Company: tenantName,
Subdomain: subdomain,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// handleMe retorna dados do usuário autenticado
func handleMe(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
userID := r.Header.Get("X-User-ID")
tenantID := r.Header.Get("X-Tenant-ID")
var email, firstName, lastName string
var tenantName, subdomain string
err := db.QueryRow(`
SELECT u.email, u.first_name, u.last_name, t.name, t.subdomain
FROM users u
INNER JOIN tenants t ON u.tenant_id = t.id
WHERE u.id = $1 AND u.tenant_id = $2
`, userID, tenantID).Scan(&email, &firstName, &lastName, &tenantName, &subdomain)
if err != nil {
sendError(w, "User not found", http.StatusNotFound)
log.Printf("Erro ao buscar usuário: %v", err)
return
}
fullName := firstName
if lastName != "" {
fullName += " " + lastName
}
response := UserPayload{
ID: userID,
Email: email,
Name: fullName,
TenantID: tenantID,
Company: tenantName,
Subdomain: subdomain,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

View File

@@ -1,20 +1,10 @@
module backend module aggios-app/backend
go 1.23 go 1.23
require ( 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/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/minio/minio-go/v7 v7.0.70
github.com/redis/go-redis/v9 v9.5.1
golang.org/x/crypto v0.27.0 golang.org/x/crypto v0.27.0
) )
require (
github.com/cespare/xxhash/v2 v2.2.0
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f
github.com/klauspost/compress v1.17.9
github.com/klauspost/cpuid/v2 v2.2.8
)

View File

@@ -1,12 +1,8 @@
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/minio/minio-go/v7 v7.0.70/go.mod h1:4yBA8v80xGA30cfM3fz0DKYMXunWl/AV/6tWEs9ryzo= golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@@ -0,0 +1,192 @@
package handlers
import (
"encoding/json"
"errors"
"log"
"net/http"
"strings"
"time"
"aggios-app/backend/internal/config"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/service"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// AgencyRegistrationHandler handles agency management endpoints
type AgencyRegistrationHandler struct {
agencyService *service.AgencyService
cfg *config.Config
}
// NewAgencyRegistrationHandler creates a new agency registration handler
func NewAgencyRegistrationHandler(agencyService *service.AgencyService, cfg *config.Config) *AgencyRegistrationHandler {
return &AgencyRegistrationHandler{
agencyService: agencyService,
cfg: cfg,
}
}
// RegisterAgency handles agency registration (SUPERADMIN only)
func (h *AgencyRegistrationHandler) RegisterAgency(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req domain.RegisterAgencyRequest
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("📥 Registering agency: %s (subdomain: %s)", req.AgencyName, req.Subdomain)
tenant, admin, err := h.agencyService.RegisterAgency(req)
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)
func (h *AgencyRegistrationHandler) RegisterClient(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// TODO: Get tenant_id from authenticated user context
// For now, this would need the auth middleware to set it
var req domain.RegisterClientRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Get tenantID from context (set by middleware)
tenantIDStr := r.Header.Get("X-Tenant-ID")
if tenantIDStr == "" {
http.Error(w, "Tenant not found", http.StatusBadRequest)
return
}
// Parse tenant ID
// tenantID, _ := uuid.Parse(tenantIDStr)
// client, err := h.agencyService.RegisterClient(req, tenantID)
// ... handle response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{
"message": "Client registration endpoint - implementation pending",
})
}
// HandleAgency supports GET (details) and DELETE operations for a specific agency
func (h *AgencyRegistrationHandler) HandleAgency(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/admin/agencies/" {
http.Error(w, "Agency ID required", http.StatusBadRequest)
return
}
agencyID := strings.TrimPrefix(r.URL.Path, "/api/admin/agencies/")
if agencyID == "" || agencyID == r.URL.Path {
http.NotFound(w, r)
return
}
id, err := uuid.Parse(agencyID)
if err != nil {
http.Error(w, "Invalid agency ID", http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodGet:
details, err := h.agencyService.GetAgencyDetails(id)
if 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.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(details)
case http.MethodDelete:
if err := h.agencyService.DeleteAgency(id); 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.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}

View File

@@ -0,0 +1,179 @@
package handlers
import (
"encoding/json"
"net/http"
"aggios-app/backend/internal/repository"
"github.com/google/uuid"
)
type AgencyHandler struct {
tenantRepo *repository.TenantRepository
}
func NewAgencyHandler(tenantRepo *repository.TenantRepository) *AgencyHandler {
return &AgencyHandler{
tenantRepo: tenantRepo,
}
}
type AgencyProfileResponse struct {
ID string `json:"id"`
Name string `json:"name"`
CNPJ string `json:"cnpj"`
Email string `json:"email"`
Phone string `json:"phone"`
Website string `json:"website"`
Address string `json:"address"`
City string `json:"city"`
State string `json:"state"`
Zip string `json:"zip"`
RazaoSocial string `json:"razao_social"`
Description string `json:"description"`
Industry string `json:"industry"`
}
type UpdateAgencyProfileRequest struct {
Name string `json:"name"`
CNPJ string `json:"cnpj"`
Email string `json:"email"`
Phone string `json:"phone"`
Website string `json:"website"`
Address string `json:"address"`
City string `json:"city"`
State string `json:"state"`
Zip string `json:"zip"`
RazaoSocial string `json:"razao_social"`
Description string `json:"description"`
Industry string `json:"industry"`
}
// GetProfile returns the current agency profile
func (h *AgencyHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get tenant from context (set by middleware)
tenantID := r.Context().Value("tenantID")
if tenantID == nil {
http.Error(w, "Tenant not found", http.StatusUnauthorized)
return
}
// Parse tenant ID
tid, err := uuid.Parse(tenantID.(string))
if err != nil {
http.Error(w, "Invalid tenant ID", http.StatusBadRequest)
return
}
// Get tenant from database
tenant, err := h.tenantRepo.FindByID(tid)
if err != nil {
http.Error(w, "Error fetching profile", http.StatusInternalServerError)
return
}
if tenant == nil {
http.Error(w, "Tenant not found", http.StatusNotFound)
return
}
response := AgencyProfileResponse{
ID: tenant.ID.String(),
Name: tenant.Name,
CNPJ: tenant.CNPJ,
Email: tenant.Email,
Phone: tenant.Phone,
Website: tenant.Website,
Address: tenant.Address,
City: tenant.City,
State: tenant.State,
Zip: tenant.Zip,
RazaoSocial: tenant.RazaoSocial,
Description: tenant.Description,
Industry: tenant.Industry,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// UpdateProfile updates the current agency profile
func (h *AgencyHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut && r.Method != http.MethodPatch {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get tenant from context
tenantID := r.Context().Value("tenantID")
if tenantID == nil {
http.Error(w, "Tenant not found", http.StatusUnauthorized)
return
}
var req UpdateAgencyProfileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Parse tenant ID
tid, err := uuid.Parse(tenantID.(string))
if err != nil {
http.Error(w, "Invalid tenant ID", http.StatusBadRequest)
return
}
// Prepare updates
updates := map[string]interface{}{
"name": req.Name,
"cnpj": req.CNPJ,
"razao_social": req.RazaoSocial,
"email": req.Email,
"phone": req.Phone,
"website": req.Website,
"address": req.Address,
"city": req.City,
"state": req.State,
"zip": req.Zip,
"description": req.Description,
"industry": req.Industry,
}
// Update in database
if err := h.tenantRepo.UpdateProfile(tid, updates); err != nil {
http.Error(w, "Error updating profile", http.StatusInternalServerError)
return
}
// Fetch updated data
tenant, err := h.tenantRepo.FindByID(tid)
if err != nil {
http.Error(w, "Error fetching updated profile", http.StatusInternalServerError)
return
}
response := AgencyProfileResponse{
ID: tenant.ID.String(),
Name: tenant.Name,
CNPJ: tenant.CNPJ,
Email: tenant.Email,
Phone: tenant.Phone,
Website: tenant.Website,
Address: tenant.Address,
City: tenant.City,
State: tenant.State,
Zip: tenant.Zip,
RazaoSocial: tenant.RazaoSocial,
Description: tenant.Description,
Industry: tenant.Industry,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

View File

@@ -0,0 +1,139 @@
package handlers
import (
"encoding/json"
"io"
"net/http"
"strings"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/service"
)
// AuthHandler handles authentication endpoints
type AuthHandler struct {
authService *service.AuthService
}
// NewAuthHandler creates a new auth handler
func NewAuthHandler(authService *service.AuthService) *AuthHandler {
return &AuthHandler{
authService: authService,
}
}
// Register handles user registration
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req domain.CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
user, err := h.authService.Register(req)
if err != nil {
switch err {
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
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
// Login handles user login
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
// Trim whitespace to avoid decode errors caused by BOM or stray chars
sanitized := strings.TrimSpace(string(bodyBytes))
var req domain.LoginRequest
if err := json.Unmarshal([]byte(sanitized), &req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
response, err := h.authService.Login(req)
if err != nil {
if err == service.ErrInvalidCredentials {
http.Error(w, err.Error(), http.StatusUnauthorized)
} else {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// ChangePasswordRequest represents a password change request
type ChangePasswordRequest struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
// ChangePassword handles password change
func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get user ID from context (set by auth middleware)
userID, ok := r.Context().Value("userID").(string)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req ChangePasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.CurrentPassword == "" || req.NewPassword == "" {
http.Error(w, "Current password and new password are required", http.StatusBadRequest)
return
}
// Call auth service to change password
if err := h.authService.ChangePassword(userID, req.CurrentPassword, req.NewPassword); err != nil {
if err == service.ErrInvalidCredentials {
http.Error(w, "Current password is incorrect", http.StatusUnauthorized)
} else if err == service.ErrWeakPassword {
http.Error(w, "New password is too weak", http.StatusBadRequest)
} else {
http.Error(w, "Error changing password", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Password changed successfully",
})
}

View File

@@ -0,0 +1,90 @@
package handlers
import (
"encoding/json"
"net/http"
"aggios-app/backend/internal/api/middleware"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/service"
"github.com/google/uuid"
)
// CompanyHandler handles company endpoints
type CompanyHandler struct {
companyService *service.CompanyService
}
// NewCompanyHandler creates a new company handler
func NewCompanyHandler(companyService *service.CompanyService) *CompanyHandler {
return &CompanyHandler{
companyService: companyService,
}
}
// Create handles company creation
func (h *CompanyHandler) Create(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get user ID from context (set by auth middleware)
userIDStr, ok := r.Context().Value(middleware.UserIDKey).(string)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
userID, err := uuid.Parse(userIDStr)
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
var req domain.CreateCompanyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// TODO: Get tenantID from user context
// For now, this is a placeholder - you'll need to get the tenant from the authenticated user
tenantID := uuid.New() // Replace with actual tenant from user
company, err := h.companyService.Create(req, tenantID, userID)
if err != nil {
switch err {
case service.ErrCNPJAlreadyExists:
http.Error(w, err.Error(), http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(company)
}
// List handles listing companies for a tenant
func (h *CompanyHandler) List(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// TODO: Get tenantID from authenticated user
tenantID := uuid.New() // Replace with actual tenant from user
companies, err := h.companyService.ListByTenant(tenantID)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(companies)
}

View File

@@ -0,0 +1,31 @@
package handlers
import (
"encoding/json"
"net/http"
)
// HealthHandler handles health check endpoint
type HealthHandler struct{}
// NewHealthHandler creates a new health handler
func NewHealthHandler() *HealthHandler {
return &HealthHandler{}
}
// Check returns API health status
func (h *HealthHandler) Check(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
response := map[string]interface{}{
"status": "healthy",
"service": "aggios-api",
"version": "1.0.0",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

View File

@@ -0,0 +1,42 @@
package handlers
import (
"encoding/json"
"net/http"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/service"
)
// TenantHandler handles tenant/agency listing endpoints
type TenantHandler struct {
tenantService *service.TenantService
}
// NewTenantHandler creates a new tenant handler
func NewTenantHandler(tenantService *service.TenantService) *TenantHandler {
return &TenantHandler{
tenantService: tenantService,
}
}
// ListAll lists all agencies/tenants (SUPERADMIN only)
func (h *TenantHandler) ListAll(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
tenants, err := h.tenantService.ListAll()
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if tenants == nil {
tenants = []*domain.Tenant{}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(tenants)
}

View File

@@ -0,0 +1,53 @@
package middleware
import (
"context"
"net/http"
"strings"
"aggios-app/backend/internal/config"
"github.com/golang-jwt/jwt/v5"
)
type contextKey string
const UserIDKey contextKey = "userID"
// Auth validates JWT tokens
func Auth(cfg *config.Config) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
bearerToken := strings.Split(authHeader, " ")
if len(bearerToken) != 2 || bearerToken[0] != "Bearer" {
http.Error(w, "Invalid token format", http.StatusUnauthorized)
return
}
token, err := jwt.Parse(bearerToken[1], func(token *jwt.Token) (interface{}, error) {
return []byte(cfg.JWT.Secret), nil
})
if err != nil || !token.Valid {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
http.Error(w, "Invalid token claims", http.StatusUnauthorized)
return
}
userID := claims["user_id"].(string)
ctx := context.WithValue(r.Context(), UserIDKey, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

View File

@@ -0,0 +1,34 @@
package middleware
import (
"net/http"
"aggios-app/backend/internal/config"
)
// CORS adds CORS headers to responses
func CORS(cfg *config.Config) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// Allow all localhost origins for development
if origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, Host")
w.Header().Set("Access-Control-Max-Age", "3600")
// Handle preflight request
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,96 @@
package middleware
import (
"net/http"
"sync"
"time"
"aggios-app/backend/internal/config"
)
type rateLimiter struct {
mu sync.Mutex
attempts map[string][]time.Time
maxAttempts int
}
func newRateLimiter(maxAttempts int) *rateLimiter {
rl := &rateLimiter{
attempts: make(map[string][]time.Time),
maxAttempts: maxAttempts,
}
// Clean old entries every minute
go func() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
rl.cleanup()
}
}()
return rl
}
func (rl *rateLimiter) cleanup() {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
for ip, attempts := range rl.attempts {
var valid []time.Time
for _, t := range attempts {
if now.Sub(t) < time.Minute {
valid = append(valid, t)
}
}
if len(valid) == 0 {
delete(rl.attempts, ip)
} else {
rl.attempts[ip] = valid
}
}
}
func (rl *rateLimiter) isAllowed(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
attempts := rl.attempts[ip]
// Filter attempts within the last minute
var validAttempts []time.Time
for _, t := range attempts {
if now.Sub(t) < time.Minute {
validAttempts = append(validAttempts, t)
}
}
if len(validAttempts) >= rl.maxAttempts {
return false
}
validAttempts = append(validAttempts, now)
rl.attempts[ip] = validAttempts
return true
}
// RateLimit limits requests per IP address
func RateLimit(cfg *config.Config) func(http.Handler) http.Handler {
limiter := newRateLimiter(cfg.Security.MaxAttemptsPerMin)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
if !limiter.isAllowed(ip) {
http.Error(w, "Too many requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,17 @@
package middleware
import (
"net/http"
)
// SecurityHeaders adds security headers to responses
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,56 @@
package middleware
import (
"context"
"net/http"
"strings"
"aggios-app/backend/internal/repository"
)
type tenantContextKey string
const TenantIDKey tenantContextKey = "tenantID"
const SubdomainKey tenantContextKey = "subdomain"
// TenantDetector detects tenant from subdomain
func TenantDetector(tenantRepo *repository.TenantRepository) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := r.Host
// Extract subdomain
// Examples:
// - agencia-xyz.localhost -> agencia-xyz
// - agencia-xyz.aggios.app -> agencia-xyz
// - dash.localhost -> dash (master admin)
// - localhost -> (institutional site)
parts := strings.Split(host, ".")
var subdomain string
if len(parts) >= 2 {
// Has subdomain
subdomain = parts[0]
// Remove port if present
if strings.Contains(subdomain, ":") {
subdomain = strings.Split(subdomain, ":")[0]
}
}
// Add subdomain to context
ctx := context.WithValue(r.Context(), SubdomainKey, subdomain)
// If subdomain is not empty and not "dash" or "api", try to find tenant
if subdomain != "" && subdomain != "dash" && subdomain != "api" && subdomain != "localhost" {
tenant, err := tenantRepo.FindBySubdomain(subdomain)
if err == nil && tenant != nil {
ctx = context.WithValue(ctx, TenantIDKey, tenant.ID.String())
}
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

View File

@@ -0,0 +1,96 @@
package config
import (
"os"
)
// Config holds all application configuration
type Config struct {
Server ServerConfig
Database DatabaseConfig
JWT JWTConfig
Security SecurityConfig
App AppConfig
}
// AppConfig holds application-level settings
type AppConfig struct {
Environment string // "development" or "production"
BaseDomain string // "localhost" or "aggios.app"
}
// ServerConfig holds server-specific configuration
type ServerConfig struct {
Port string
}
// DatabaseConfig holds database connection settings
type DatabaseConfig struct {
Host string
Port string
User string
Password string
Name string
}
// JWTConfig holds JWT configuration
type JWTConfig struct {
Secret string
}
// SecurityConfig holds security settings
type SecurityConfig struct {
AllowedOrigins []string
MaxAttemptsPerMin int
PasswordMinLength int
}
// Load loads configuration from environment variables
func Load() *Config {
env := getEnvOrDefault("APP_ENV", "development")
baseDomain := "localhost"
if env == "production" {
baseDomain = "aggios.app"
}
return &Config{
Server: ServerConfig{
Port: getEnvOrDefault("SERVER_PORT", "8080"),
},
Database: DatabaseConfig{
Host: getEnvOrDefault("DB_HOST", "localhost"),
Port: getEnvOrDefault("DB_PORT", "5432"),
User: getEnvOrDefault("DB_USER", "postgres"),
Password: getEnvOrDefault("DB_PASSWORD", "postgres"),
Name: getEnvOrDefault("DB_NAME", "aggios"),
},
JWT: JWTConfig{
Secret: getEnvOrDefault("JWT_SECRET", "INSECURE-fallback-secret-CHANGE-THIS"),
},
App: AppConfig{
Environment: env,
BaseDomain: baseDomain,
},
Security: SecurityConfig{
AllowedOrigins: []string{
"http://localhost",
"http://dash.localhost",
"http://aggios.local",
"http://dash.aggios.local",
"https://aggios.app",
"https://dash.aggios.app",
"https://www.aggios.app",
},
MaxAttemptsPerMin: 5,
PasswordMinLength: 8,
},
}
}
// getEnvOrDefault returns environment variable or default value
func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}

View File

@@ -0,0 +1,31 @@
package domain
import (
"time"
"github.com/google/uuid"
)
// Company represents a company in the system
type Company struct {
ID uuid.UUID `json:"id" db:"id"`
CNPJ string `json:"cnpj" db:"cnpj"`
RazaoSocial string `json:"razao_social" db:"razao_social"`
NomeFantasia string `json:"nome_fantasia" db:"nome_fantasia"`
Email string `json:"email" db:"email"`
Telefone string `json:"telefone" db:"telefone"`
Status string `json:"status" db:"status"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
CreatedByUserID *uuid.UUID `json:"created_by_user_id,omitempty" db:"created_by_user_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// CreateCompanyRequest represents the request to create a new company
type CreateCompanyRequest struct {
CNPJ string `json:"cnpj"`
RazaoSocial string `json:"razao_social"`
NomeFantasia string `json:"nome_fantasia"`
Email string `json:"email"`
Telefone string `json:"telefone"`
}

View File

@@ -0,0 +1,43 @@
package domain
import (
"time"
"github.com/google/uuid"
)
// Tenant represents a tenant (agency) in the system
type Tenant struct {
ID uuid.UUID `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Domain string `json:"domain" db:"domain"`
Subdomain string `json:"subdomain" db:"subdomain"`
CNPJ string `json:"cnpj,omitempty" db:"cnpj"`
RazaoSocial string `json:"razao_social,omitempty" db:"razao_social"`
Email string `json:"email,omitempty" db:"email"`
Phone string `json:"phone,omitempty" db:"phone"`
Website string `json:"website,omitempty" db:"website"`
Address string `json:"address,omitempty" db:"address"`
City string `json:"city,omitempty" db:"city"`
State string `json:"state,omitempty" db:"state"`
Zip string `json:"zip,omitempty" db:"zip"`
Description string `json:"description,omitempty" db:"description"`
Industry string `json:"industry,omitempty" db:"industry"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// CreateTenantRequest represents the request to create a new tenant
type CreateTenantRequest struct {
Name string `json:"name"`
Domain string `json:"domain"`
Subdomain string `json:"subdomain"`
}
// AgencyDetails aggregates tenant info with its admin user for superadmin view
type AgencyDetails struct {
Tenant *Tenant `json:"tenant"`
Admin *User `json:"admin,omitempty"`
AccessURL string `json:"access_url"`
}

View File

@@ -0,0 +1,73 @@
package domain
import (
"time"
"github.com/google/uuid"
)
// User represents a user in the system
type User struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID *uuid.UUID `json:"tenant_id,omitempty" db:"tenant_id"`
Email string `json:"email" db:"email"`
Password string `json:"-" db:"password_hash"`
Name string `json:"name" db:"first_name"`
Role string `json:"role" db:"role"` // SUPERADMIN, ADMIN_AGENCIA, CLIENTE
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// CreateUserRequest represents the request to create a new user
type CreateUserRequest struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
Role string `json:"role,omitempty"` // Optional, defaults to CLIENTE
}
// RegisterAgencyRequest represents agency registration (SUPERADMIN only)
type RegisterAgencyRequest struct {
// Agência
AgencyName string `json:"agencyName"`
Subdomain string `json:"subdomain"`
CNPJ string `json:"cnpj"`
RazaoSocial string `json:"razaoSocial"`
Description string `json:"description"`
Website string `json:"website"`
Industry string `json:"industry"`
// Endereço
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"`
// Admin da Agência
AdminEmail string `json:"adminEmail"`
AdminPassword string `json:"adminPassword"`
AdminName string `json:"adminName"`
}
// RegisterClientRequest represents client registration (ADMIN_AGENCIA only)
type RegisterClientRequest struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
}
// LoginRequest represents the login request
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// LoginResponse represents the login response
type LoginResponse struct {
Token string `json:"token"`
User User `json:"user"`
Subdomain *string `json:"subdomain,omitempty"`
}

View File

@@ -0,0 +1,127 @@
package repository
import (
"database/sql"
"time"
"aggios-app/backend/internal/domain"
"github.com/google/uuid"
)
// CompanyRepository handles database operations for companies
type CompanyRepository struct {
db *sql.DB
}
// NewCompanyRepository creates a new company repository
func NewCompanyRepository(db *sql.DB) *CompanyRepository {
return &CompanyRepository{db: db}
}
// Create creates a new company
func (r *CompanyRepository) Create(company *domain.Company) error {
query := `
INSERT INTO companies (id, cnpj, razao_social, nome_fantasia, email, telefone, status, tenant_id, created_by_user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, created_at, updated_at
`
now := time.Now()
company.ID = uuid.New()
company.CreatedAt = now
company.UpdatedAt = now
return r.db.QueryRow(
query,
company.ID,
company.CNPJ,
company.RazaoSocial,
company.NomeFantasia,
company.Email,
company.Telefone,
company.Status,
company.TenantID,
company.CreatedByUserID,
company.CreatedAt,
company.UpdatedAt,
).Scan(&company.ID, &company.CreatedAt, &company.UpdatedAt)
}
// FindByID finds a company by ID
func (r *CompanyRepository) FindByID(id uuid.UUID) (*domain.Company, error) {
query := `
SELECT id, cnpj, razao_social, nome_fantasia, email, telefone, status, tenant_id, created_by_user_id, created_at, updated_at
FROM companies
WHERE id = $1
`
company := &domain.Company{}
err := r.db.QueryRow(query, id).Scan(
&company.ID,
&company.CNPJ,
&company.RazaoSocial,
&company.NomeFantasia,
&company.Email,
&company.Telefone,
&company.Status,
&company.TenantID,
&company.CreatedByUserID,
&company.CreatedAt,
&company.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
return company, err
}
// FindByTenantID finds all companies for a tenant
func (r *CompanyRepository) FindByTenantID(tenantID uuid.UUID) ([]*domain.Company, error) {
query := `
SELECT id, cnpj, razao_social, nome_fantasia, email, telefone, status, tenant_id, created_by_user_id, created_at, updated_at
FROM companies
WHERE tenant_id = $1
ORDER BY created_at DESC
`
rows, err := r.db.Query(query, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var companies []*domain.Company
for rows.Next() {
company := &domain.Company{}
err := rows.Scan(
&company.ID,
&company.CNPJ,
&company.RazaoSocial,
&company.NomeFantasia,
&company.Email,
&company.Telefone,
&company.Status,
&company.TenantID,
&company.CreatedByUserID,
&company.CreatedAt,
&company.UpdatedAt,
)
if err != nil {
return nil, err
}
companies = append(companies, company)
}
return companies, nil
}
// CNPJExists checks if a CNPJ is already registered for a tenant
func (r *CompanyRepository) CNPJExists(cnpj string, tenantID uuid.UUID) (bool, error) {
var exists bool
query := `SELECT EXISTS(SELECT 1 FROM companies WHERE cnpj = $1 AND tenant_id = $2)`
err := r.db.QueryRow(query, cnpj, tenantID).Scan(&exists)
return exists, err
}

View File

@@ -0,0 +1,268 @@
package repository
import (
"database/sql"
"time"
"aggios-app/backend/internal/domain"
"github.com/google/uuid"
)
// TenantRepository handles database operations for tenants
type TenantRepository struct {
db *sql.DB
}
// NewTenantRepository creates a new tenant repository
func NewTenantRepository(db *sql.DB) *TenantRepository {
return &TenantRepository{db: db}
}
// Create creates a new tenant
func (r *TenantRepository) Create(tenant *domain.Tenant) error {
query := `
INSERT INTO tenants (
id, name, domain, subdomain, cnpj, razao_social, email, website,
address, city, state, zip, description, industry, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING id, created_at, updated_at
`
now := time.Now()
tenant.ID = uuid.New()
tenant.CreatedAt = now
tenant.UpdatedAt = now
return r.db.QueryRow(
query,
tenant.ID,
tenant.Name,
tenant.Domain,
tenant.Subdomain,
tenant.CNPJ,
tenant.RazaoSocial,
tenant.Email,
tenant.Website,
tenant.Address,
tenant.City,
tenant.State,
tenant.Zip,
tenant.Description,
tenant.Industry,
tenant.CreatedAt,
tenant.UpdatedAt,
).Scan(&tenant.ID, &tenant.CreatedAt, &tenant.UpdatedAt)
}
// FindByID finds a tenant by ID
func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
query := `
SELECT id, name, domain, subdomain, cnpj, razao_social, email, phone, website,
address, city, state, zip, description, industry, is_active, created_at, updated_at
FROM tenants
WHERE id = $1
`
tenant := &domain.Tenant{}
var cnpj, razaoSocial, email, phone, website, address, city, state, zip, description, industry sql.NullString
err := r.db.QueryRow(query, id).Scan(
&tenant.ID,
&tenant.Name,
&tenant.Domain,
&tenant.Subdomain,
&cnpj,
&razaoSocial,
&email,
&phone,
&website,
&address,
&city,
&state,
&zip,
&description,
&industry,
&tenant.IsActive,
&tenant.CreatedAt,
&tenant.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
// Handle nullable fields
if cnpj.Valid {
tenant.CNPJ = cnpj.String
}
if razaoSocial.Valid {
tenant.RazaoSocial = razaoSocial.String
}
if email.Valid {
tenant.Email = email.String
}
if phone.Valid {
tenant.Phone = phone.String
}
if website.Valid {
tenant.Website = website.String
}
if address.Valid {
tenant.Address = address.String
}
if city.Valid {
tenant.City = city.String
}
if state.Valid {
tenant.State = state.String
}
if zip.Valid {
tenant.Zip = zip.String
}
if description.Valid {
tenant.Description = description.String
}
if industry.Valid {
tenant.Industry = industry.String
}
return tenant, nil
}
// FindBySubdomain finds a tenant by subdomain
func (r *TenantRepository) FindBySubdomain(subdomain string) (*domain.Tenant, error) {
query := `
SELECT id, name, domain, subdomain, created_at, updated_at
FROM tenants
WHERE subdomain = $1
`
tenant := &domain.Tenant{}
err := r.db.QueryRow(query, subdomain).Scan(
&tenant.ID,
&tenant.Name,
&tenant.Domain,
&tenant.Subdomain,
&tenant.CreatedAt,
&tenant.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
return tenant, err
}
// SubdomainExists checks if a subdomain is already taken
func (r *TenantRepository) SubdomainExists(subdomain string) (bool, error) {
var exists bool
query := `SELECT EXISTS(SELECT 1 FROM tenants WHERE subdomain = $1)`
err := r.db.QueryRow(query, subdomain).Scan(&exists)
return exists, err
}
// FindAll returns all tenants
func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) {
query := `
SELECT id, name, domain, subdomain, is_active, created_at, updated_at
FROM tenants
ORDER BY created_at DESC
`
rows, err := r.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var tenants []*domain.Tenant
for rows.Next() {
tenant := &domain.Tenant{}
err := rows.Scan(
&tenant.ID,
&tenant.Name,
&tenant.Domain,
&tenant.Subdomain,
&tenant.IsActive,
&tenant.CreatedAt,
&tenant.UpdatedAt,
)
if err != nil {
return nil, err
}
tenants = append(tenants, tenant)
}
if tenants == nil {
return []*domain.Tenant{}, nil
}
return tenants, nil
}
// Delete removes a tenant (and cascades to related data)
func (r *TenantRepository) Delete(id uuid.UUID) error {
result, err := r.db.Exec(`DELETE FROM tenants WHERE id = $1`, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return sql.ErrNoRows
}
return nil
}
// UpdateProfile updates tenant profile information
func (r *TenantRepository) UpdateProfile(id uuid.UUID, updates map[string]interface{}) error {
query := `
UPDATE tenants SET
name = COALESCE($1, name),
cnpj = COALESCE($2, cnpj),
razao_social = COALESCE($3, razao_social),
email = COALESCE($4, email),
phone = COALESCE($5, phone),
website = COALESCE($6, website),
address = COALESCE($7, address),
city = COALESCE($8, city),
state = COALESCE($9, state),
zip = COALESCE($10, zip),
description = COALESCE($11, description),
industry = COALESCE($12, industry),
updated_at = $13
WHERE id = $14
`
_, err := r.db.Exec(
query,
updates["name"],
updates["cnpj"],
updates["razao_social"],
updates["email"],
updates["phone"],
updates["website"],
updates["address"],
updates["city"],
updates["state"],
updates["zip"],
updates["description"],
updates["industry"],
time.Now(),
id,
)
return err
}

View File

@@ -0,0 +1,154 @@
package repository
import (
"database/sql"
"time"
"aggios-app/backend/internal/domain"
"github.com/google/uuid"
)
// UserRepository handles database operations for users
type UserRepository struct {
db *sql.DB
}
// NewUserRepository creates a new user repository
func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{db: db}
}
// Create creates a new user
func (r *UserRepository) Create(user *domain.User) error {
query := `
INSERT INTO users (id, tenant_id, email, password_hash, first_name, role, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, created_at, updated_at
`
now := time.Now()
user.ID = uuid.New()
user.CreatedAt = now
user.UpdatedAt = now
// Default role to CLIENTE if not specified
if user.Role == "" {
user.Role = "CLIENTE"
}
return r.db.QueryRow(
query,
user.ID,
user.TenantID,
user.Email,
user.Password,
user.Name,
user.Role,
true, // is_active
user.CreatedAt,
user.UpdatedAt,
).Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt)
}
// FindByEmail finds a user by email
func (r *UserRepository) FindByEmail(email string) (*domain.User, error) {
query := `
SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at
FROM users
WHERE email = $1 AND is_active = true
`
user := &domain.User{}
err := r.db.QueryRow(query, email).Scan(
&user.ID,
&user.TenantID,
&user.Email,
&user.Password,
&user.Name,
&user.Role,
&user.CreatedAt,
&user.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
return user, err
}
// FindByID finds a user by ID
func (r *UserRepository) FindByID(id uuid.UUID) (*domain.User, error) {
query := `
SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at
FROM users
WHERE id = $1 AND is_active = true
`
user := &domain.User{}
err := r.db.QueryRow(query, id).Scan(
&user.ID,
&user.TenantID,
&user.Email,
&user.Password,
&user.Name,
&user.Role,
&user.CreatedAt,
&user.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
return user, err
}
// EmailExists checks if an email is already registered
func (r *UserRepository) EmailExists(email string) (bool, error) {
var exists bool
query := `SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)`
err := r.db.QueryRow(query, email).Scan(&exists)
return exists, err
}
// UpdatePassword updates a user's password
func (r *UserRepository) UpdatePassword(userID, hashedPassword string) error {
query := `UPDATE users SET password_hash = $1, updated_at = $2 WHERE id = $3`
_, err := r.db.Exec(query, hashedPassword, time.Now(), userID)
return err
}
// FindAdminByTenantID returns the primary admin user for a tenant
func (r *UserRepository) FindAdminByTenantID(tenantID uuid.UUID) (*domain.User, error) {
query := `
SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at
FROM users
WHERE tenant_id = $1 AND role = 'ADMIN_AGENCIA' AND is_active = true
ORDER BY created_at ASC
LIMIT 1
`
user := &domain.User{}
err := r.db.QueryRow(query, tenantID).Scan(
&user.ID,
&user.TenantID,
&user.Email,
&user.Password,
&user.Name,
&user.Role,
&user.CreatedAt,
&user.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return user, nil
}

View File

@@ -0,0 +1,191 @@
package service
import (
"aggios-app/backend/internal/config"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"fmt"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// AgencyService handles agency registration and management
type AgencyService struct {
userRepo *repository.UserRepository
tenantRepo *repository.TenantRepository
cfg *config.Config
}
// NewAgencyService creates a new agency service
func NewAgencyService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, cfg *config.Config) *AgencyService {
return &AgencyService{
userRepo: userRepo,
tenantRepo: tenantRepo,
cfg: cfg,
}
}
// RegisterAgency creates a new agency (tenant) and its admin user
// Only SUPERADMIN can call this
func (s *AgencyService) RegisterAgency(req domain.RegisterAgencyRequest) (*domain.Tenant, *domain.User, error) {
// Validate password
if len(req.AdminPassword) < s.cfg.Security.PasswordMinLength {
return nil, nil, ErrWeakPassword
}
// Check if subdomain is available
exists, err := s.tenantRepo.SubdomainExists(req.Subdomain)
if err != nil {
return nil, nil, err
}
if exists {
return nil, nil, ErrSubdomainTaken
}
// Check if admin email already exists
emailExists, err := s.userRepo.EmailExists(req.AdminEmail)
if err != nil {
return nil, nil, err
}
if emailExists {
return nil, nil, ErrEmailAlreadyExists
}
// Create tenant
address := req.Street
if req.Number != "" {
address += ", " + req.Number
}
if req.Complement != "" {
address += " - " + req.Complement
}
if req.Neighborhood != "" {
address += " - " + req.Neighborhood
}
tenant := &domain.Tenant{
Name: req.AgencyName,
Domain: fmt.Sprintf("%s.%s", req.Subdomain, s.cfg.App.BaseDomain),
Subdomain: req.Subdomain,
CNPJ: req.CNPJ,
RazaoSocial: req.RazaoSocial,
Email: req.AdminEmail,
Website: req.Website,
Address: address,
City: req.City,
State: req.State,
Zip: req.CEP,
Description: req.Description,
Industry: req.Industry,
}
if err := s.tenantRepo.Create(tenant); err != nil {
return nil, nil, err
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.AdminPassword), bcrypt.DefaultCost)
if err != nil {
return nil, nil, err
}
// Create admin user for the agency
adminUser := &domain.User{
TenantID: &tenant.ID,
Email: req.AdminEmail,
Password: string(hashedPassword),
Name: req.AdminName,
Role: "ADMIN_AGENCIA",
}
if err := s.userRepo.Create(adminUser); err != nil {
return nil, nil, err
}
return tenant, adminUser, nil
}
// RegisterClient creates a new client user for a specific agency
// Only ADMIN_AGENCIA can call this
func (s *AgencyService) RegisterClient(req domain.RegisterClientRequest, tenantID uuid.UUID) (*domain.User, error) {
// Validate password
if len(req.Password) < s.cfg.Security.PasswordMinLength {
return nil, ErrWeakPassword
}
// Check if email already exists
exists, err := s.userRepo.EmailExists(req.Email)
if err != nil {
return nil, err
}
if exists {
return nil, ErrEmailAlreadyExists
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
// Create client user
client := &domain.User{
TenantID: &tenantID,
Email: req.Email,
Password: string(hashedPassword),
Name: req.Name,
Role: "CLIENTE",
}
if err := s.userRepo.Create(client); err != nil {
return nil, err
}
return client, nil
}
// GetAgencyDetails returns tenant and admin information for superadmin view
func (s *AgencyService) GetAgencyDetails(id uuid.UUID) (*domain.AgencyDetails, error) {
tenant, err := s.tenantRepo.FindByID(id)
if err != nil {
return nil, err
}
if tenant == nil {
return nil, ErrTenantNotFound
}
admin, err := s.userRepo.FindAdminByTenantID(id)
if err != nil {
return nil, err
}
protocol := "http://"
if s.cfg.App.Environment == "production" {
protocol = "https://"
}
details := &domain.AgencyDetails{
Tenant: tenant,
AccessURL: fmt.Sprintf("%s%s", protocol, tenant.Domain),
}
if admin != nil {
details.Admin = admin
}
return details, nil
}
// DeleteAgency removes a tenant and its related resources
func (s *AgencyService) DeleteAgency(id uuid.UUID) error {
tenant, err := s.tenantRepo.FindByID(id)
if err != nil {
return err
}
if tenant == nil {
return ErrTenantNotFound
}
return s.tenantRepo.Delete(id)
}

View File

@@ -0,0 +1,170 @@
package service
import (
"errors"
"time"
"aggios-app/backend/internal/config"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
var (
ErrEmailAlreadyExists = errors.New("email already registered")
ErrInvalidCredentials = errors.New("invalid email or password")
ErrWeakPassword = errors.New("password too weak")
ErrSubdomainTaken = errors.New("subdomain already taken")
ErrUnauthorized = errors.New("unauthorized access")
)
// AuthService handles authentication business logic
type AuthService struct {
userRepo *repository.UserRepository
tenantRepo *repository.TenantRepository
cfg *config.Config
}
// NewAuthService creates a new auth service
func NewAuthService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, cfg *config.Config) *AuthService {
return &AuthService{
userRepo: userRepo,
tenantRepo: tenantRepo,
cfg: cfg,
}
}
// Register creates a new user account
func (s *AuthService) Register(req domain.CreateUserRequest) (*domain.User, error) {
// Validate password strength
if len(req.Password) < s.cfg.Security.PasswordMinLength {
return nil, ErrWeakPassword
}
// Check if email already exists
exists, err := s.userRepo.EmailExists(req.Email)
if err != nil {
return nil, err
}
if exists {
return nil, ErrEmailAlreadyExists
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
// Create user
user := &domain.User{
Email: req.Email,
Password: string(hashedPassword),
Name: req.Name,
}
if err := s.userRepo.Create(user); err != nil {
return nil, err
}
return user, nil
}
// Login authenticates a user and returns a JWT token
func (s *AuthService) Login(req domain.LoginRequest) (*domain.LoginResponse, error) {
// Find user by email
user, err := s.userRepo.FindByEmail(req.Email)
if err != nil {
return nil, err
}
if user == nil {
return nil, ErrInvalidCredentials
}
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
return nil, ErrInvalidCredentials
}
// Generate JWT token
token, err := s.generateToken(user)
if err != nil {
return nil, err
}
response := &domain.LoginResponse{
Token: token,
User: *user,
}
// If user has a tenant, get the subdomain
if user.TenantID != nil {
tenant, err := s.tenantRepo.FindByID(*user.TenantID)
if err == nil && tenant != nil {
response.Subdomain = &tenant.Subdomain
}
}
return response, nil
}
func (s *AuthService) generateToken(user *domain.User) (string, error) {
claims := jwt.MapClaims{
"user_id": user.ID.String(),
"email": user.Email,
"role": user.Role,
"tenant_id": nil,
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
}
if user.TenantID != nil {
claims["tenant_id"] = user.TenantID.String()
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(s.cfg.JWT.Secret))
}
// ChangePassword changes a user's password
func (s *AuthService) ChangePassword(userID string, currentPassword, newPassword string) error {
// Validate new password strength
if len(newPassword) < s.cfg.Security.PasswordMinLength {
return ErrWeakPassword
}
// Parse userID
uid, err := parseUUID(userID)
if err != nil {
return ErrInvalidCredentials
}
// Find user
user, err := s.userRepo.FindByID(uid)
if err != nil {
return err
}
if user == nil {
return ErrInvalidCredentials
}
// Verify current password
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(currentPassword)); err != nil {
return ErrInvalidCredentials
}
// Hash new password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
// Update password
return s.userRepo.UpdatePassword(userID, string(hashedPassword))
}
func parseUUID(s string) (uuid.UUID, error) {
return uuid.Parse(s)
}

View File

@@ -0,0 +1,73 @@
package service
import (
"errors"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"github.com/google/uuid"
)
var (
ErrCompanyNotFound = errors.New("company not found")
ErrCNPJAlreadyExists = errors.New("CNPJ already registered")
)
// CompanyService handles company business logic
type CompanyService struct {
companyRepo *repository.CompanyRepository
}
// NewCompanyService creates a new company service
func NewCompanyService(companyRepo *repository.CompanyRepository) *CompanyService {
return &CompanyService{
companyRepo: companyRepo,
}
}
// Create creates a new company
func (s *CompanyService) Create(req domain.CreateCompanyRequest, tenantID, userID uuid.UUID) (*domain.Company, error) {
// Check if CNPJ already exists for this tenant
exists, err := s.companyRepo.CNPJExists(req.CNPJ, tenantID)
if err != nil {
return nil, err
}
if exists {
return nil, ErrCNPJAlreadyExists
}
company := &domain.Company{
CNPJ: req.CNPJ,
RazaoSocial: req.RazaoSocial,
NomeFantasia: req.NomeFantasia,
Email: req.Email,
Telefone: req.Telefone,
Status: "active",
TenantID: tenantID,
CreatedByUserID: &userID,
}
if err := s.companyRepo.Create(company); err != nil {
return nil, err
}
return company, nil
}
// GetByID retrieves a company by ID
func (s *CompanyService) GetByID(id uuid.UUID) (*domain.Company, error) {
company, err := s.companyRepo.FindByID(id)
if err != nil {
return nil, err
}
if company == nil {
return nil, ErrCompanyNotFound
}
return company, nil
}
// ListByTenant retrieves all companies for a tenant
func (s *CompanyService) ListByTenant(tenantID uuid.UUID) ([]*domain.Company, error) {
return s.companyRepo.FindByTenantID(tenantID)
}

View File

@@ -0,0 +1,91 @@
package service
import (
"database/sql"
"errors"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"github.com/google/uuid"
)
var (
ErrTenantNotFound = errors.New("tenant not found")
)
// TenantService handles tenant business logic
type TenantService struct {
tenantRepo *repository.TenantRepository
}
// NewTenantService creates a new tenant service
func NewTenantService(tenantRepo *repository.TenantRepository) *TenantService {
return &TenantService{
tenantRepo: tenantRepo,
}
}
// Create creates a new tenant
func (s *TenantService) Create(req domain.CreateTenantRequest) (*domain.Tenant, error) {
// Check if subdomain already exists
exists, err := s.tenantRepo.SubdomainExists(req.Subdomain)
if err != nil {
return nil, err
}
if exists {
return nil, ErrSubdomainTaken
}
tenant := &domain.Tenant{
Name: req.Name,
Domain: req.Domain,
Subdomain: req.Subdomain,
}
if err := s.tenantRepo.Create(tenant); err != nil {
return nil, err
}
return tenant, nil
}
// GetByID retrieves a tenant by ID
func (s *TenantService) GetByID(id uuid.UUID) (*domain.Tenant, error) {
tenant, err := s.tenantRepo.FindByID(id)
if err != nil {
return nil, err
}
if tenant == nil {
return nil, ErrTenantNotFound
}
return tenant, nil
}
// GetBySubdomain retrieves a tenant by subdomain
func (s *TenantService) GetBySubdomain(subdomain string) (*domain.Tenant, error) {
tenant, err := s.tenantRepo.FindBySubdomain(subdomain)
if err != nil {
return nil, err
}
if tenant == nil {
return nil, ErrTenantNotFound
}
return tenant, nil
}
// ListAll retrieves all tenants
func (s *TenantService) ListAll() ([]*domain.Tenant, error) {
return s.tenantRepo.FindAll()
}
// Delete removes a tenant by ID
func (s *TenantService) Delete(id uuid.UUID) error {
if err := s.tenantRepo.Delete(id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrTenantNotFound
}
return err
}
return nil
}

View File

@@ -1,7 +1,7 @@
services: services:
# Traefik - Reverse Proxy # Traefik - Reverse Proxy
traefik: traefik:
image: traefik:latest image: traefik:v3.2
container_name: aggios-traefik container_name: aggios-traefik
restart: unless-stopped restart: unless-stopped
command: command:
@@ -10,12 +10,18 @@ services:
- "--providers.docker.endpoint=tcp://host.docker.internal:2375" - "--providers.docker.endpoint=tcp://host.docker.internal:2375"
- "--providers.docker.exposedbydefault=false" - "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=aggios-network" - "--providers.docker.network=aggios-network"
- "--providers.file.directory=/etc/traefik/dynamic"
- "--providers.file.watch=true"
- "--entrypoints.web.address=:80" - "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443" - "--entrypoints.websecure.address=:443"
- "--log.level=DEBUG"
- "--accesslog=true"
ports: ports:
- "80:80" - "80:80"
- "443:443" - "443:443"
- "8080:8080" # Dashboard Traefik - "8080:8080" # Dashboard Traefik
volumes:
- ./traefik/dynamic:/etc/traefik/dynamic:ro
networks: networks:
- aggios-network - aggios-network
@@ -41,24 +47,6 @@ services:
networks: networks:
- aggios-network - aggios-network
# pgAdmin - PostgreSQL Web Interface
pgadmin:
image: dpage/pgadmin4:latest
container_name: aggios-pgadmin
restart: unless-stopped
ports:
- "5050:80"
environment:
PGADMIN_DEFAULT_EMAIL: admin@aggios.app
PGADMIN_DEFAULT_PASSWORD: admin123
PGADMIN_CONFIG_SERVER_MODE: 'False'
volumes:
- pgadmin_data:/var/lib/pgadmin
depends_on:
- postgres
networks:
- aggios-network
# Redis Cache # Redis Cache
redis: redis:
image: redis:7-alpine image: redis:7-alpine
@@ -138,7 +126,7 @@ services:
# Frontend - Institucional (aggios.app) # Frontend - Institucional (aggios.app)
institucional: institucional:
build: build:
context: ./front-end-aggios.app-institucional context: ./frontend-aggios.app
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: aggios-institucional container_name: aggios-institucional
restart: unless-stopped restart: unless-stopped
@@ -186,8 +174,6 @@ services:
volumes: volumes:
postgres_data: postgres_data:
driver: local driver: local
pgadmin_data:
driver: local
redis_data: redis_data:
driver: local driver: local
minio_data: minio_data:

View File

@@ -0,0 +1,26 @@
"use client";
export default function ClientesPage() {
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">Clientes</h1>
<p className="text-gray-600 dark:text-gray-400">Gerencie sua carteira de clientes</p>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-12 border border-gray-200 dark:border-gray-700 text-center">
<div className="max-w-md mx-auto">
<div className="w-24 h-24 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<i className="ri-user-line text-5xl text-blue-600 dark:text-blue-400"></i>
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
Módulo CRM em Desenvolvimento
</h2>
<p className="text-gray-600 dark:text-gray-400">
Em breve você poderá gerenciar seus clientes com recursos avançados de CRM.
</p>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,181 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { getUser } from "@/lib/auth";
import {
ChartBarIcon,
UserGroupIcon,
FolderIcon,
CurrencyDollarIcon,
ArrowTrendingUpIcon,
ArrowTrendingDownIcon
} from '@heroicons/react/24/outline';
interface StatCardProps {
title: string;
value: string | number;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
trend?: number;
color: 'blue' | 'purple' | 'gray' | 'green';
}
const colorClasses = {
blue: {
iconBg: 'bg-blue-50 dark:bg-blue-900/20',
iconColor: 'text-blue-600 dark:text-blue-400',
trend: 'text-blue-600 dark:text-blue-400'
},
purple: {
iconBg: 'bg-purple-50 dark:bg-purple-900/20',
iconColor: 'text-purple-600 dark:text-purple-400',
trend: 'text-purple-600 dark:text-purple-400'
},
gray: {
iconBg: 'bg-gray-50 dark:bg-gray-900/20',
iconColor: 'text-gray-600 dark:text-gray-400',
trend: 'text-gray-600 dark:text-gray-400'
},
green: {
iconBg: 'bg-emerald-50 dark:bg-emerald-900/20',
iconColor: 'text-emerald-600 dark:text-emerald-400',
trend: 'text-emerald-600 dark:text-emerald-400'
}
};
function StatCard({ title, value, icon: Icon, trend, color }: StatCardProps) {
const colors = colorClasses[color];
const isPositive = trend && trend > 0;
return (
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-shadow">
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">{title}</p>
<p className="text-3xl font-semibold text-gray-900 dark:text-white mt-2">{value}</p>
{trend !== undefined && (
<div className="flex items-center mt-2">
{isPositive ? (
<ArrowTrendingUpIcon className="w-4 h-4 text-emerald-500" />
) : (
<ArrowTrendingDownIcon className="w-4 h-4 text-red-500" />
)}
<span className={`text-sm font-medium ml-1 ${isPositive ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}>
{Math.abs(trend)}%
</span>
<span className="text-xs text-gray-500 dark:text-gray-400 ml-1">vs mês anterior</span>
</div>
)}
</div>
<div className={`${colors.iconBg} p-3 rounded-xl`}>
<Icon className={`w-8 h-8 ${colors.iconColor}`} />
</div>
</div>
</div>
);
}
export default function DashboardPage() {
const router = useRouter();
const [stats, setStats] = useState({
clientes: 0,
projetos: 0,
tarefas: 0,
faturamento: 0
});
useEffect(() => {
// Verificar se é SUPERADMIN e redirecionar
const user = getUser();
if (user && user.role === 'SUPERADMIN') {
router.push('/superadmin');
return;
}
// Simulando carregamento de dados
setTimeout(() => {
setStats({
clientes: 127,
projetos: 18,
tarefas: 64,
faturamento: 87500
});
}, 300);
}, [router]);
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">
Dashboard
</h1>
<p className="text-gray-600 dark:text-gray-400">
Bem-vindo ao seu painel de controle
</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<StatCard
title="Clientes Ativos"
value={stats.clientes}
icon={UserGroupIcon}
trend={12.5}
color="blue"
/>
<StatCard
title="Projetos em Andamento"
value={stats.projetos}
icon={FolderIcon}
trend={8.2}
color="purple"
/>
<StatCard
title="Tarefas Pendentes"
value={stats.tarefas}
icon={ChartBarIcon}
trend={-3.1}
color="gray"
/>
<StatCard
title="Faturamento"
value={new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL',
minimumFractionDigits: 0
}).format(stats.faturamento)}
icon={CurrencyDollarIcon}
trend={25.3}
color="green"
/>
</div>
{/* Coming Soon Card */}
<div className="bg-linear-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 rounded-2xl border border-gray-200 dark:border-gray-700 p-12">
<div className="max-w-2xl mx-auto text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-6" style={{ background: 'var(--gradient-primary)' }}>
<ChartBarIcon className="w-8 h-8 text-white" />
</div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-3">
Em Desenvolvimento
</h2>
<p className="text-lg text-gray-600 dark:text-gray-400 mb-8">
Estamos construindo recursos incríveis de CRM e ERP para sua agência.
Em breve você terá acesso a análises detalhadas, gestão completa de clientes e muito mais.
</p>
<div className="flex flex-wrap items-center justify-center gap-3">
{['CRM', 'ERP', 'Projetos', 'Pagamentos', 'Documentos', 'Suporte', 'Contratos'].map((item) => (
<span
key={item}
className="inline-flex items-center px-4 py-2 rounded-full text-sm font-medium bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700"
>
{item}
</span>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,569 @@
"use client";
import { useEffect, useState, Fragment } from 'react';
import { useRouter } from 'next/navigation';
import dynamic from 'next/dynamic';
import { Menu, Transition } from '@headlessui/react';
import {
Bars3Icon,
XMarkIcon,
MagnifyingGlassIcon,
BellIcon,
Cog6ToothIcon,
UserCircleIcon,
ArrowRightOnRectangleIcon,
ChevronDownIcon,
ChevronRightIcon,
UserGroupIcon,
BuildingOfficeIcon,
FolderIcon,
CreditCardIcon,
DocumentTextIcon,
LifebuoyIcon,
DocumentCheckIcon,
UsersIcon,
UserPlusIcon,
PhoneIcon,
FunnelIcon,
ChartBarIcon,
HomeIcon,
CubeIcon,
ShoppingCartIcon,
BanknotesIcon,
DocumentDuplicateIcon,
ShareIcon,
DocumentMagnifyingGlassIcon,
TrashIcon,
RectangleStackIcon,
CalendarIcon,
UserGroupIcon as TeamIcon,
ReceiptPercentIcon,
CreditCardIcon as PaymentIcon,
ChatBubbleLeftRightIcon,
BookOpenIcon,
ArchiveBoxIcon,
PencilSquareIcon,
} from '@heroicons/react/24/outline';
const ThemeToggle = dynamic(() => import('@/components/ThemeToggle'), { ssr: false });
const ThemeTester = dynamic(() => import('@/components/ThemeTester'), { ssr: false });
export default function AgencyLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const [user, setUser] = useState<any>(null);
const [agencyName, setAgencyName] = useState('');
const [sidebarOpen, setSidebarOpen] = useState(true);
const [searchOpen, setSearchOpen] = useState(false);
const [activeSubmenu, setActiveSubmenu] = useState<number | null>(null);
const [selectedClient, setSelectedClient] = useState<any>(null);
// Mock de clientes - no futuro virá da API
const clients = [
{ id: 1, name: 'Todos os Clientes', avatar: null },
{ id: 2, name: 'Empresa ABC Ltda', avatar: 'A' },
{ id: 3, name: 'Tech Solutions Inc', avatar: 'T' },
{ id: 4, name: 'Marketing Pro', avatar: 'M' },
{ id: 5, name: 'Design Studio', avatar: 'D' },
];
useEffect(() => {
const token = localStorage.getItem('token');
const userData = localStorage.getItem('user');
if (!token || !userData) {
router.push('/login');
return;
}
const parsedUser = JSON.parse(userData);
setUser(parsedUser);
if (parsedUser.role === 'SUPERADMIN') {
router.push('/superadmin');
return;
}
const hostname = window.location.hostname;
const subdomain = hostname.split('.')[0];
setAgencyName(subdomain);
// Inicializar com "Todos os Clientes"
setSelectedClient(clients[0]);
// Atalho de teclado para abrir pesquisa (Ctrl/Cmd + K)
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
setSearchOpen(true);
}
if (e.key === 'Escape') {
setSearchOpen(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [router]);
if (!user) {
return null;
}
const menuItems = [
{
icon: UserGroupIcon,
label: 'CRM',
href: '/crm',
submenu: [
{ icon: UsersIcon, label: 'Clientes', href: '/crm/clientes' },
{ icon: UserPlusIcon, label: 'Leads', href: '/crm/leads' },
{ icon: PhoneIcon, label: 'Contatos', href: '/crm/contatos' },
{ icon: FunnelIcon, label: 'Funil de Vendas', href: '/crm/funil' },
{ icon: ChartBarIcon, label: 'Relatórios', href: '/crm/relatorios' },
]
},
{
icon: BuildingOfficeIcon,
label: 'ERP',
href: '/erp',
submenu: [
{ icon: HomeIcon, label: 'Dashboard', href: '/erp/dashboard' },
{ icon: CubeIcon, label: 'Estoque', href: '/erp/estoque' },
{ icon: ShoppingCartIcon, label: 'Compras', href: '/erp/compras' },
{ icon: BanknotesIcon, label: 'Vendas', href: '/erp/vendas' },
{ icon: ChartBarIcon, label: 'Financeiro', href: '/erp/financeiro' },
]
},
{
icon: FolderIcon,
label: 'Projetos',
href: '/projetos',
submenu: [
{ icon: RectangleStackIcon, label: 'Todos Projetos', href: '/projetos/todos' },
{ icon: RectangleStackIcon, label: 'Kanban', href: '/projetos/kanban' },
{ icon: CalendarIcon, label: 'Calendário', href: '/projetos/calendario' },
{ icon: TeamIcon, label: 'Equipes', href: '/projetos/equipes' },
]
},
{
icon: CreditCardIcon,
label: 'Pagamentos',
href: '/pagamentos',
submenu: [
{ icon: DocumentTextIcon, label: 'Faturas', href: '/pagamentos/faturas' },
{ icon: ReceiptPercentIcon, label: 'Recebimentos', href: '/pagamentos/recebimentos' },
{ icon: PaymentIcon, label: 'Assinaturas', href: '/pagamentos/assinaturas' },
{ icon: BanknotesIcon, label: 'Gateway', href: '/pagamentos/gateway' },
]
},
{
icon: DocumentTextIcon,
label: 'Documentos',
href: '/documentos',
submenu: [
{ icon: FolderIcon, label: 'Meus Arquivos', href: '/documentos/arquivos' },
{ icon: ShareIcon, label: 'Compartilhados', href: '/documentos/compartilhados' },
{ icon: DocumentDuplicateIcon, label: 'Modelos', href: '/documentos/modelos' },
{ icon: TrashIcon, label: 'Lixeira', href: '/documentos/lixeira' },
]
},
{
icon: LifebuoyIcon,
label: 'Suporte',
href: '/suporte',
submenu: [
{ icon: DocumentMagnifyingGlassIcon, label: 'Tickets', href: '/suporte/tickets' },
{ icon: BookOpenIcon, label: 'Base de Conhecimento', href: '/suporte/kb' },
{ icon: ChatBubbleLeftRightIcon, label: 'Chat', href: '/suporte/chat' },
]
},
{
icon: DocumentCheckIcon,
label: 'Contratos',
href: '/contratos',
submenu: [
{ icon: DocumentCheckIcon, label: 'Ativos', href: '/contratos/ativos' },
{ icon: PencilSquareIcon, label: 'Rascunhos', href: '/contratos/rascunhos' },
{ icon: ArchiveBoxIcon, label: 'Arquivados', href: '/contratos/arquivados' },
{ icon: DocumentDuplicateIcon, label: 'Modelos', href: '/contratos/modelos' },
]
},
];
const handleLogout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
router.push('/login');
};
return (
<div className="flex h-screen bg-gray-50 dark:bg-gray-950">
{/* 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`}>
{/* Logo */}
<div className="h-16 flex items-center justify-center border-b border-gray-200 dark:border-gray-800">
{(sidebarOpen && activeSubmenu === null) ? (
<div className="flex items-center justify-between px-4 w-full">
<div className="flex items-center space-x-3">
<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>
</div>
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors cursor-pointer"
>
<XMarkIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
</button>
</div>
) : (
<div className="w-8 h-8 rounded-lg" style={{ background: 'var(--gradient-primary)' }}></div>
)}
</div>
{/* Menu */}
<nav className="flex-1 overflow-y-auto py-4 px-3">
{menuItems.map((item, idx) => {
const Icon = item.icon;
const isActive = activeSubmenu === idx;
return (
<button
key={idx}
onClick={() => setActiveSubmenu(isActive ? null : idx)}
className={`w-full flex items-center ${(sidebarOpen && activeSubmenu === null) ? 'space-x-3 px-3' : 'justify-center px-0'} py-2.5 mb-1 rounded-lg transition-all group cursor-pointer ${isActive
? 'bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900'
: 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<Icon className={`${(sidebarOpen && activeSubmenu === null) ? 'w-5 h-5' : 'w-[18px] h-[18px]'} stroke-[1.5]`} />
{(sidebarOpen && activeSubmenu === null) && (
<>
<span className="flex-1 text-left text-sm font-normal">{item.label}</span>
<ChevronRightIcon className={`w-4 h-4 transition-transform ${isActive ? 'rotate-90' : ''}`} />
</>
)}
</button>
);
})}
</nav>
{/* User Menu */}
<div className="p-4 border-t border-gray-200 dark:border-gray-800">
{(sidebarOpen && activeSubmenu === null) ? (
<Menu as="div" className="relative">
<Menu.Button className="w-full flex items-center space-x-3 p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors">
<div className="w-10 h-10 rounded-full flex items-center justify-center text-white font-semibold" style={{ background: 'var(--gradient-primary)' }}>
{user?.name?.charAt(0).toUpperCase()}
</div>
<div className="flex-1 text-left">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">{user?.name}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{user?.role === 'ADMIN_AGENCIA' ? 'Admin' : 'Cliente'}</p>
</div>
<ChevronDownIcon className="w-4 h-4 text-gray-400" />
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute bottom-full left-0 right-0 mb-2 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<Menu.Item>
{({ active }) => (
<a
href="/perfil"
className={`${active ? 'bg-gray-100 dark:bg-gray-700' : ''} flex items-center px-4 py-3 text-sm text-gray-700 dark:text-gray-300`}
>
<UserCircleIcon className="w-5 h-5 mr-3" />
Meu Perfil
</a>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<button
onClick={handleLogout}
className={`${active ? 'bg-gray-100 dark:bg-gray-700' : ''} w-full flex items-center px-4 py-3 text-sm text-red-600 dark:text-red-400`}
>
<ArrowRightOnRectangleIcon className="w-5 h-5 mr-3" />
Sair
</button>
)}
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
) : (
<button
onClick={handleLogout}
className="w-10 h-10 mx-auto rounded-full flex items-center justify-center text-white cursor-pointer"
style={{ background: 'var(--gradient-primary)' }}
title="Sair"
>
<ArrowRightOnRectangleIcon className="w-5 h-5" />
</button>
)}
</div>
</aside>
{/* Submenu Lateral */}
<Transition
show={activeSubmenu !== null}
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="transform -translate-x-full opacity-0"
enterTo="transform translate-x-0 opacity-100"
leave="transition ease-in duration-150"
leaveFrom="transform translate-x-0 opacity-100"
leaveTo="transform -translate-x-full opacity-0"
>
<aside className="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col">
{activeSubmenu !== null && (
<>
<div className="h-16 flex items-center justify-between px-4 border-b border-gray-200 dark:border-gray-800">
<h2 className="font-semibold text-gray-900 dark:text-white">{menuItems[activeSubmenu].label}</h2>
<button
onClick={() => setActiveSubmenu(null)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors cursor-pointer"
>
<XMarkIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
</button>
</div>
<nav className="flex-1 overflow-y-auto py-4 px-3">
{menuItems[activeSubmenu].submenu?.map((subItem, idx) => {
const SubIcon = subItem.icon;
return (
<a
key={idx}
href={subItem.href}
className="flex items-center space-x-3 px-4 py-2.5 mb-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
>
<SubIcon className="w-5 h-5 stroke-[1.5]" />
<span>{subItem.label}</span>
</a>
);
})}
</nav>
</>
)}
</aside>
</Transition>
{/* Main Content */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<header className="h-16 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 flex items-center justify-between px-6">
<div className="flex items-center space-x-4">
<h1 className="text-lg font-semibold text-gray-900 dark:text-white">
Dashboard
</h1>
{/* Seletor de Cliente */}
<Menu as="div" className="relative">
<Menu.Button className="flex items-center space-x-2 px-3 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors cursor-pointer">
{selectedClient?.avatar ? (
<div className="w-6 h-6 rounded-full flex items-center justify-center text-white text-xs font-semibold" style={{ background: 'var(--gradient-primary)' }}>
{selectedClient.avatar}
</div>
) : (
<UsersIcon className="w-4 h-4 text-gray-600 dark:text-gray-400" />
)}
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{selectedClient?.name || 'Selecionar Cliente'}
</span>
<ChevronDownIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute left-0 mt-2 w-72 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50">
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
<div className="relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Buscar cliente..."
className="w-full pl-9 pr-3 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600"
/>
</div>
</div>
<div className="max-h-64 overflow-y-auto p-2">
{clients.map((client) => (
<Menu.Item key={client.id}>
{({ active }) => (
<button
onClick={() => setSelectedClient(client)}
className={`${active ? 'bg-gray-100 dark:bg-gray-700' : ''
} ${selectedClient?.id === client.id ? 'bg-gray-100 dark:bg-gray-800' : ''
} w-full flex items-center space-x-3 px-3 py-2.5 rounded-lg transition-colors cursor-pointer`}
>
{client.avatar ? (
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-semibold shrink-0" style={{ background: 'var(--gradient-primary)' }}>
{client.avatar}
</div>
) : (
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0">
<UsersIcon className="w-4 h-4 text-gray-600 dark:text-gray-400" />
</div>
)}
<span className="flex-1 text-left text-sm font-medium text-gray-900 dark:text-white">
{client.name}
</span>
{selectedClient?.id === client.id && (
<div className="w-2 h-2 rounded-full bg-gray-900 dark:bg-gray-100"></div>
)}
</button>
)}
</Menu.Item>
))}
</div>
</Menu.Items>
</Transition>
</Menu>
</div>
<div className="flex items-center space-x-2">
{/* Pesquisa */}
<button
onClick={() => setSearchOpen(true)}
className="flex items-center space-x-2 px-3 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<MagnifyingGlassIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
<span className="text-sm text-gray-500 dark:text-gray-400">Pesquisar...</span>
<kbd className="hidden sm:inline-flex items-center px-2 py-0.5 text-xs font-medium text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded">
Ctrl K
</kbd>
</button>
<ThemeToggle />
{/* Notificações */}
<Menu as="div" className="relative">
<Menu.Button className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg relative transition-colors">
<BellIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full"></span>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 mt-2 w-80 bg-white dark:bg-gray-800 rounded-xl shadow-lg 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">Notificações</h3>
</div>
<div className="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
Nenhuma notificação no momento
</div>
</Menu.Items>
</Transition>
</Menu>
{/* Configurações */}
<a
href="/configuracoes"
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
>
<Cog6ToothIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</a>
</div>
</header>
{/* Page Content */}
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-950">
{children}
</main>
</div>
{/* Modal de Pesquisa */}
<Transition appear show={searchOpen} as={Fragment}>
<div className="fixed inset-0 z-50 overflow-y-auto">
<Transition.Child
as={Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setSearchOpen(false)} />
</Transition.Child>
<div className="flex min-h-full items-start justify-center p-4 pt-[15vh]">
<Transition.Child
as={Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-150"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div className="w-full max-w-2xl bg-white dark:bg-gray-900 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-800 overflow-hidden relative z-10">
<div className="flex items-center px-4 border-b border-gray-200 dark:border-gray-800">
<MagnifyingGlassIcon className="w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Pesquisar páginas, clientes, projetos..."
autoFocus
className="w-full px-4 py-4 bg-transparent text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none"
/>
<button
onClick={() => setSearchOpen(false)}
className="text-xs text-gray-500 dark:text-gray-400 px-2 py-1 border border-gray-300 dark:border-gray-700 rounded"
>
ESC
</button>
</div>
<div className="p-4 max-h-96 overflow-y-auto">
<div className="text-center py-12">
<MagnifyingGlassIcon className="w-12 h-12 text-gray-300 dark:text-gray-700 mx-auto mb-3" />
<p className="text-sm text-gray-500 dark:text-gray-400">
Digite para buscar...
</p>
</div>
</div>
<div className="px-4 py-3 border-t border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-950">
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<div className="flex items-center space-x-4">
<span className="flex items-center">
<kbd className="px-2 py-1 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded mr-1"></kbd>
navegar
</span>
<span className="flex items-center">
<kbd className="px-2 py-1 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded mr-1"></kbd>
selecionar
</span>
</div>
</div>
</div>
</div>
</Transition.Child>
</div>
</div>
</Transition>
{/* Theme Tester - Temporário para desenvolvimento */}
<ThemeTester />
</div>
);
}

View File

@@ -1,12 +1,7 @@
'use client'; 'use client';
import { ThemeProvider } from '@/contexts/ThemeContext';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
export default function AuthLayoutWrapper({ children }: { children: ReactNode }) { export default function AuthLayoutWrapper({ children }: { children: ReactNode }) {
return ( return <>{children}</>;
<ThemeProvider>
{children}
</ThemeProvider>
);
} }

View File

@@ -38,19 +38,6 @@ export default function CadastroPage() {
// Carregar dados do localStorage ao montar // Carregar dados do localStorage ao montar
useEffect(() => { useEffect(() => {
// Mostrar dica de atalho
setTimeout(() => {
toast('💡 Dica: Pressione a tecla T para preencher dados de teste automaticamente!', {
duration: 5000,
icon: '⚡',
style: {
background: '#FFA500',
color: '#fff',
fontWeight: 'bold',
},
});
}, 1000);
const saved = localStorage.getItem('cadastroFormData'); const saved = localStorage.getItem('cadastroFormData');
if (saved) { if (saved) {
try { try {
@@ -94,20 +81,6 @@ export default function CadastroPage() {
localStorage.setItem('cadastroFormData', JSON.stringify(dataToSave)); localStorage.setItem('cadastroFormData', JSON.stringify(dataToSave));
}, [currentStep, completedSteps, formData, contacts, password, passwordStrength, cnpjData, cepData, subdomain, domainAvailable, primaryColor, secondaryColor, logoUrl]); }, [currentStep, completedSteps, formData, contacts, password, passwordStrength, cnpjData, cepData, subdomain, domainAvailable, primaryColor, secondaryColor, logoUrl]);
// ATALHO DE TECLADO - Pressione T para preencher dados de teste
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
if (e.key === 't' || e.key === 'T') {
if (confirm('🚀 PREENCHER DADOS DE TESTE?\n\nIsso vai preencher todos os campos automaticamente e ir pro Step 5.\n\nClique OK para continuar.')) {
fillTestData();
}
}
};
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, []);
// Função para atualizar formData // Função para atualizar formData
const updateFormData = (name: string, value: any) => { const updateFormData = (name: string, value: any) => {
setFormData(prev => ({ ...prev, [name]: value })); setFormData(prev => ({ ...prev, [name]: value }));
@@ -323,48 +296,48 @@ export default function CadastroPage() {
const handleSubmitRegistration = async () => { const handleSubmitRegistration = async () => {
try { try {
const payload = { const payload = {
// Step 1 - Dados Pessoais // Dados da agência
email: formData.email, agencyName: formData.companyName,
password: password, subdomain: subdomain,
fullName: formData.fullName,
newsletter: formData.newsletter || false,
// Step 2 - Empresa
companyName: formData.companyName,
cnpj: formData.cnpj, cnpj: formData.cnpj,
razaoSocial: cnpjData.razaoSocial, razaoSocial: formData.razaoSocial,
description: formData.description, description: formData.description,
website: formData.website, website: formData.website,
industry: formData.industry, industry: formData.industry,
teamSize: formData.teamSize,
// Step 3 - Localização e Contato // Endereço
cep: formData.cep, cep: formData.cep,
state: cepData.state, state: formData.state,
city: cepData.city, city: formData.city,
neighborhood: cepData.neighborhood, neighborhood: formData.neighborhood,
street: cepData.street, street: formData.street,
number: formData.number, number: formData.number,
complement: formData.complement, complement: formData.complement,
contacts: contacts,
// Step 4 - Domínio // Admin
subdomain: subdomain, adminEmail: formData.email,
adminPassword: password,
// Step 5 - Personalização adminName: formData.fullName,
primaryColor: primaryColor,
secondaryColor: secondaryColor,
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 data = await apiRequest(API_ENDPOINTS.register, { const response = await fetch('/api/admin/agencies', {
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
@@ -373,6 +346,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',
tenantId: data.tenantId, tenantId: data.tenantId,
company: data.company, company: data.company,
subdomain: data.subdomain subdomain: data.subdomain
@@ -382,7 +356,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 o painel...', { toast.success('Conta criada com sucesso! Redirecionando para seu painel...', {
id: 'register', id: 'register',
duration: 2000, duration: 2000,
style: { style: {
@@ -391,9 +365,10 @@ export default function CadastroPage() {
}, },
}); });
// Aguardar 2 segundos e redirecionar para o painel // Redirecionar para o painel da agência no subdomínio
setTimeout(() => { setTimeout(() => {
window.location.href = '/painel'; const agencyUrl = `http://${data.subdomain}.localhost/login`;
window.location.href = agencyUrl;
}, 2000); }, 2000);
} catch (error: any) { } catch (error: any) {
@@ -702,35 +677,12 @@ export default function CadastroPage() {
{currentStepData?.description} {currentStepData?.description}
</p> </p>
</div> </div>
{/* BOTÃO TESTE RÁPIDO - GRANDE E VISÍVEL */}
<button
type="button"
onClick={fillTestData}
className="px-8 py-4 text-xl font-bold text-white bg-yellow-500 hover:bg-yellow-600 rounded-lg shadow-2xl border-4 border-yellow-700 animate-pulse"
style={{ minWidth: '250px' }}
>
TESTE RÁPIDO
</button>
</div> </div>
</div> </div>
{/* Formulário */} {/* Formulário */}
<div className="flex-1 overflow-y-auto bg-[#FDFDFC] px-6 sm:px-12 py-6"> <div className="flex-1 overflow-y-auto bg-[#FDFDFC] px-6 sm:px-12 py-6">
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
{/* Botão Teste Rápido GRANDE */}
<div className="mb-6 p-4 bg-yellow-50 border-2 border-yellow-400 rounded-lg">
<button
type="button"
onClick={fillTestData}
className="w-full px-6 py-4 text-lg font-bold text-white bg-gradient-to-r from-[#FF3A05] to-[#FF0080] rounded-lg hover:opacity-90 transition-opacity shadow-lg"
>
CLIQUE AQUI - PREENCHER DADOS DE TESTE AUTOMATICAMENTE
</button>
<p className="text-sm text-yellow-800 mt-2 text-center">
Preenche todos os campos e vai direto pro Step 5 para você clicar em Finalizar
</p>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleNext(e); }} className="space-y-6"> <form onSubmit={(e) => { e.preventDefault(); handleNext(e); }} className="space-y-6">
{currentStep === 1 && ( {currentStep === 1 && (
<div className="space-y-5"> <div className="space-y-5">

View File

@@ -1,17 +1,13 @@
"use client"; "use client";
import { ThemeProvider } from '@/contexts/ThemeContext';
export default function LoginLayout({ export default function LoginLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<ThemeProvider>
<div className="min-h-screen bg-[#FDFDFC] dark:bg-gray-900"> <div className="min-h-screen bg-[#FDFDFC] dark:bg-gray-900">
{children} {children}
</div> </div>
</ThemeProvider>
); );
} }

View File

@@ -77,7 +77,7 @@ export default function RecuperarSenhaPage() {
<div className="w-full max-w-md"> <div className="w-full max-w-md">
{/* Logo mobile */} {/* Logo mobile */}
<div className="lg:hidden text-center mb-8"> <div className="lg:hidden text-center 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" style={{ background: 'var(--gradient-primary)' }}>
<h1 className="text-3xl font-bold text-white">aggios</h1> <h1 className="text-3xl font-bold text-white">aggios</h1>
</div> </div>
</div> </div>
@@ -86,10 +86,10 @@ export default function RecuperarSenhaPage() {
<> <>
{/* Header */} {/* Header */}
<div className="mb-8"> <div className="mb-8">
<h2 className="text-[28px] font-bold text-[#000000]"> <h2 className="text-[28px] font-bold text-zinc-900 dark:text-white">
Recuperar senha Recuperar Senha
</h2> </h2>
<p className="text-[14px] text-[#7D7D7D] mt-2"> <p className="text-[14px] text-zinc-600 dark:text-zinc-400 mt-2">
Digite seu email e enviaremos um link para redefinir sua senha Digite seu email e enviaremos um link para redefinir sua senha
</p> </p>
</div> </div>
@@ -136,15 +136,15 @@ export default function RecuperarSenhaPage() {
<i className="ri-mail-check-line text-4xl text-[#10B981]" /> <i className="ri-mail-check-line text-4xl text-[#10B981]" />
</div> </div>
<h2 className="text-[28px] font-bold text-[#000000] mb-4"> <h2 className="text-[28px] font-bold text-zinc-900 dark:text-white mb-4">
Email enviado! Email enviado!
</h2> </h2>
<p className="text-[14px] text-[#7D7D7D] mb-2"> <p className="text-[14px] text-zinc-600 dark:text-zinc-400 mb-2">
Enviamos um link de recuperação para: Enviamos um link de recuperação para:
</p> </p>
<p className="text-[16px] font-semibold text-[#000000] mb-6"> <p className="text-[16px] font-semibold text-zinc-900 dark:text-white mb-6">
{email} {email}
</p> </p>
@@ -152,10 +152,10 @@ export default function RecuperarSenhaPage() {
<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-[#0EA5E9] text-xl mt-0.5" />
<div> <div>
<h4 className="text-sm font-semibold text-[#000000] 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
</h4> </h4>
<p className="text-xs text-[#7D7D7D]"> <p className="text-xs text-zinc-600 dark:text-zinc-400">
Clique no link que enviamos para redefinir sua senha. Clique no link que enviamos para redefinir sua senha.
Se não receber em alguns minutos, verifique sua pasta de spam. Se não receber em alguns minutos, verifique sua pasta de spam.
</p> </p>
@@ -185,12 +185,12 @@ export default function RecuperarSenhaPage() {
</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">
<div className="inline-block px-6 py-3 rounded-2xl bg-white/10 backdrop-blur-sm border border-white/20"> <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"> <h1 className="text-5xl font-bold tracking-tight text-white">
aggios aggios
</h1> </h1>
</div> </div>

View File

@@ -1,12 +1,7 @@
'use client'; 'use client';
import { ThemeProvider } from '@/contexts/ThemeContext';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
export default function LayoutWrapper({ children }: { children: ReactNode }) { export default function LayoutWrapper({ children }: { children: ReactNode }) {
return ( return <>{children}</>;
<ThemeProvider>
{children}
</ThemeProvider>
);
} }

View File

@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
const BACKEND_BASE_URL = 'http://aggios-backend:8080';
export async function GET(_request: NextRequest, context: { params: Promise<{ id: string }> }) {
try {
const { id } = await context.params;
const response = await fetch(`${BACKEND_BASE_URL}/api/admin/agencies/${id}`, {
method: 'GET',
});
const contentType = response.headers.get('content-type');
const isJSON = contentType && contentType.includes('application/json');
const payload = isJSON ? await response.json() : await response.text();
if (!response.ok) {
const errorBody = typeof payload === 'string' ? { error: payload } : payload;
return NextResponse.json(errorBody, { status: response.status });
}
return NextResponse.json(payload, { status: response.status });
} catch (error) {
console.error('Agency detail proxy error:', error);
return NextResponse.json({ error: 'Erro ao buscar detalhes da agência' }, { status: 500 });
}
}
export async function DELETE(_request: NextRequest, context: { params: Promise<{ id: string }> }) {
try {
const { id } = await context.params;
const response = await fetch(`${BACKEND_BASE_URL}/api/admin/agencies/${id}`, {
method: 'DELETE',
});
if (!response.ok && response.status !== 204) {
const payload = await response.json().catch(() => ({ error: 'Erro ao excluir agência' }));
return NextResponse.json(payload, { status: response.status });
}
return new NextResponse(null, { status: response.status });
} catch (error) {
console.error('Agency delete proxy error:', error);
return NextResponse.json({ error: 'Erro ao excluir agência' }, { status: 500 });
}
}

View File

@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
try {
const response = await fetch('http://aggios-backend:8080/api/admin/agencies', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const data = await response.json();
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
console.error('Agencies list error:', error);
return NextResponse.json(
{ error: 'Erro ao buscar agências' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const response = await fetch('http://aggios-backend:8080/api/admin/agencies/register', {
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('Agency registration error:', error);
return NextResponse.json(
{ error: 'Erro ao registrar agência' },
{ status: 500 }
);
}
}

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

View File

@@ -1,94 +1,165 @@
@config "../tailwind.config.js";
@import "tailwindcss"; @import "tailwindcss";
@import "remixicon/fonts/remixicon.css"; @import "./tokens.css";
@custom-variant dark (&:is(.dark *));
:root { :root {
/* Cores do Design System Aggios */ color-scheme: light;
--primary: #FF3A05; --radius: 0.625rem;
--secondary: #FF0080; --background: oklch(1 0 0);
--background: #FDFDFC; --foreground: oklch(0.145 0 0);
--foreground: #000000; --card: oklch(1 0 0);
--text-secondary: #7D7D7D; --card-foreground: oklch(0.145 0 0);
--border: #E5E5E5; --popover: oklch(1 0 0);
--white: #FFFFFF; --popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
/* Gradiente */ --primary-foreground: oklch(0.985 0 0);
--gradient: linear-gradient(90deg, #FF3A05, #FF0080); --secondary: oklch(0.97 0 0);
--gradient-text: linear-gradient(to right, #FF3A05, #FF0080); --secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
/* Espaçamentos */ --muted-foreground: oklch(0.556 0 0);
--space-xs: 4px; --accent: oklch(0.97 0 0);
--space-sm: 8px; --accent-foreground: oklch(0.205 0 0);
--space-md: 16px; --destructive: oklch(0.577 0.245 27.325);
--space-lg: 24px; --border: oklch(0.922 0 0);
--space-xl: 32px; --input: oklch(0.922 0 0);
--space-2xl: 48px; --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);
} }
@theme inline { html.dark {
--color-background: var(--background); color-scheme: dark;
--color-foreground: var(--foreground); }
--color-primary: var(--primary);
--color-text-secondary: var(--text-secondary); @layer base {
--color-border: var(--border); * {
--font-sans: var(--font-inter); font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
--font-heading: var(--font-open-sans);
--font-mono: var(--font-fira-code);
} }
body { body {
background: var(--background); background-color: var(--color-surface-muted);
color: var(--foreground); color: var(--color-text-primary);
font-family: var(--font-sans), Arial, Helvetica, sans-serif; transition: background-color 0.25s ease, color 0.25s ease;
line-height: 1.5;
} }
/* Estilos base dos inputs */ ::selection {
input, background-color: var(--color-brand-500);
select, color: var(--color-text-inverse);
textarea {
font-size: 14px;
box-shadow: none !important;
} }
input:focus, .surface-card {
select:focus, background-color: var(--color-surface-card);
textarea:focus { border: 1px solid var(--color-border-strong);
box-shadow: none !important; box-shadow: 0 20px 80px rgba(15, 23, 42, 0.08);
outline: none !important;
} }
/* Focus visible para acessibilidade */ .glass-panel {
*:focus-visible { background: linear-gradient(120deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.05));
outline: 2px solid var(--primary); border: 1px solid rgba(255, 255, 255, 0.2);
outline-offset: 2px; box-shadow: 0 25px 50px -12px rgba(15, 23, 42, 0.25);
backdrop-filter: blur(20px);
} }
/* Hero section gradient text */
.gradient-text { .gradient-text {
background: var(--gradient-text); background: var(--color-gradient-brand);
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
}
/* Hover gradient text */
.hover\:gradient-text:hover {
background: var(--gradient-text);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; }
} }
/* Group hover para remover gradiente e usar cor sólida */ @theme inline {
.group:hover .group-hover\:text-white { --radius-sm: calc(var(--radius) - 4px);
background: none !important; --radius-md: calc(var(--radius) - 2px);
-webkit-background-clip: unset !important; --radius-lg: var(--radius);
-webkit-text-fill-color: unset !important; --radius-xl: calc(var(--radius) + 4px);
background-clip: unset !important; --color-background: var(--background);
color: white !important; --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);
} }
/* Smooth scroll */ .dark {
html { --background: oklch(0.145 0 0);
scroll-behavior: smooth; --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;
}
} }

View File

@@ -2,17 +2,18 @@ import type { Metadata } from "next";
import { Inter, Open_Sans, Fira_Code } from "next/font/google"; import { Inter, Open_Sans, Fira_Code } from "next/font/google";
import "./globals.css"; import "./globals.css";
import LayoutWrapper from "./LayoutWrapper"; import LayoutWrapper from "./LayoutWrapper";
import { ThemeProvider } from "next-themes";
const inter = Inter({ const inter = Inter({
variable: "--font-inter", variable: "--font-inter",
subsets: ["latin"], subsets: ["latin"],
weight: ["400", "500", "600"], weight: ["400", "500", "600", "700"],
}); });
const openSans = Open_Sans({ const openSans = Open_Sans({
variable: "--font-open-sans", variable: "--font-open-sans",
subsets: ["latin"], subsets: ["latin"],
weight: ["700"], weight: ["600", "700"],
}); });
const firaCode = Fira_Code({ const firaCode = Fira_Code({
@@ -32,31 +33,16 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="pt-BR"> <html lang="pt-BR" suppressHydrationWarning>
<head> <head>
<script <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/remixicon@4.3.0/fonts/remixicon.css" />
dangerouslySetInnerHTML={{
__html: `
(function() {
const theme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = theme === 'dark' || (!theme && prefersDark);
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
})();
`,
}}
/>
</head> </head>
<body <body className={`${inter.variable} ${openSans.variable} ${firaCode.variable} antialiased`}>
className={`${inter.variable} ${openSans.variable} ${firaCode.variable} antialiased bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors`} <ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
>
<LayoutWrapper> <LayoutWrapper>
{children} {children}
</LayoutWrapper> </LayoutWrapper>
</ThemeProvider>
</body> </body>
</html> </html>
); );

View File

@@ -1,27 +1,37 @@
"use client"; "use client";
import { useState } from "react"; 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 } from '@/lib/auth';
import { API_ENDPOINTS, apiRequest } from '@/lib/api';
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 });
export default function LoginPage() { export default function LoginPage() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSuperAdmin, setIsSuperAdmin] = useState(false);
const [subdomain, setSubdomain] = useState<string>('');
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
email: "", email: "",
password: "", password: "",
rememberMe: false, rememberMe: false,
}); });
useEffect(() => {
// Detectar se é dash (SUPERADMIN) ou agência
if (typeof window !== 'undefined') {
const hostname = window.location.hostname;
const sub = hostname.split('.')[0];
setSubdomain(sub);
setIsSuperAdmin(sub === 'dash');
}
}, []);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
// Validações básicas
if (!formData.email) { if (!formData.email) {
toast.error('Por favor, insira seu email'); toast.error('Por favor, insira seu email');
return; return;
@@ -40,9 +50,11 @@ export default function LoginPage() {
setIsLoading(true); setIsLoading(true);
try { try {
const response = await fetch('http://localhost:3000/api/auth/login', { const response = await fetch('/api/auth/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ body: JSON.stringify({
email: formData.email, email: formData.email,
password: formData.password, password: formData.password,
@@ -56,20 +68,23 @@ export default function LoginPage() {
const data = await response.json(); const data = await response.json();
// Salvar token e dados do usuário
localStorage.setItem('token', data.token); localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user)); localStorage.setItem('user', JSON.stringify(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 = '/painel'; window.location.href = '/dashboard';
}, 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.');
setIsLoading(false); setIsLoading(false);
} }
}; return ( };
return (
<> <>
<Toaster <Toaster
position="top-center" position="top-center"
@@ -107,8 +122,10 @@ export default function LoginPage() {
<div className="w-full max-w-md"> <div className="w-full max-w-md">
{/* Logo mobile */} {/* Logo mobile */}
<div className="lg:hidden text-center mb-8"> <div className="lg:hidden text-center 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" style={{ background: 'var(--gradient-primary)' }}>
<h1 className="text-3xl font-bold text-white">aggios</h1> <h1 className="text-3xl font-bold text-white">
{isSuperAdmin ? 'aggios' : subdomain}
</h1>
</div> </div>
</div> </div>
@@ -120,10 +137,13 @@ export default function LoginPage() {
{/* Header */} {/* Header */}
<div className="mb-8"> <div className="mb-8">
<h2 className="text-[28px] font-bold text-[#000000] dark:text-white"> <h2 className="text-[28px] font-bold text-[#000000] dark:text-white">
Bem-vindo de volta {isSuperAdmin ? 'Painel Administrativo' : 'Bem-vindo de volta'}
</h2> </h2>
<p className="text-[14px] text-[#7D7D7D] dark:text-gray-400 mt-2"> <p className="text-[14px] text-[#7D7D7D] dark:text-gray-400 mt-2">
Entre com suas credenciais para acessar o painel {isSuperAdmin
? 'Acesso exclusivo para administradores Aggios'
: 'Entre com suas credenciais para acessar o painel'
}
</p> </p>
</div> </div>
@@ -145,23 +165,21 @@ export default function LoginPage() {
placeholder="Digite sua senha" placeholder="Digite sua senha"
leftIcon="ri-lock-line" leftIcon="ri-lock-line"
value={formData.password} value={formData.password}
onChange={(e) => onChange={(e) => setFormData({ ...formData, password: e.target.value })}
setFormData({ ...formData, password: e.target.value })
}
required required
/> />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Checkbox <Checkbox
id="rememberMe"
label="Lembrar de mim" label="Lembrar de mim"
checked={formData.rememberMe} checked={formData.rememberMe}
onChange={(e) => onChange={(e) => setFormData({ ...formData, rememberMe: e.target.checked })}
setFormData({ ...formData, rememberMe: e.target.checked })
}
/> />
<Link <Link
href="/recuperar-senha" href="/recuperar-senha"
className="text-[14px] gradient-text hover:underline font-medium cursor-pointer" className="text-[14px] font-medium hover:opacity-80 transition-opacity"
style={{ background: 'var(--gradient-primary)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}
> >
Esqueceu a senha? Esqueceu a senha?
</Link> </Link>
@@ -170,105 +188,68 @@ export default function LoginPage() {
<Button <Button
type="submit" type="submit"
variant="primary" variant="primary"
className="w-full"
size="lg" size="lg"
isLoading={isLoading} className="w-full"
disabled={isLoading}
> >
Entrar {isLoading ? 'Entrando...' : 'Entrar'}
</Button> </Button>
</form>
{/* Divider */} {/* Link para cadastro - apenas para agências */}
<div className="relative my-8"> {!isSuperAdmin && (
<div className="absolute inset-0 flex items-center"> <p className="text-center text-[14px] text-[#7D7D7D] dark:text-gray-400">
<div className="w-full border-t border-[#E5E5E5]" /> Ainda não tem conta?{' '}
</div> <a
<div className="relative flex justify-center text-[13px]"> href="http://dash.localhost/cadastro"
<span className="px-4 bg-white text-[#7D7D7D]"> className="font-medium hover:opacity-80 transition-opacity"
ou continue com style={{ background: 'var(--gradient-primary)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}
</span> >
</div> Cadastre sua agência
</div> </a>
{/* Social Login */}
<div className="grid grid-cols-2 gap-4">
<Button variant="outline" leftIcon="ri-google-fill">
Google
</Button>
<Button variant="outline" leftIcon="ri-microsoft-fill">
Microsoft
</Button>
</div>
{/* Sign up link */}
<p className="text-center mt-8 text-[14px] text-[#7D7D7D]">
Não tem uma conta?{" "}
<Link href="/cadastro" className="gradient-text font-medium hover:underline cursor-pointer">
Criar conta
</Link>
</p> </p>
)}
</form>
</div> </div>
</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="absolute inset-0 flex flex-col items-center justify-center p-12 text-white">
{/* Logo */} <div className="max-w-md text-center">
<div className="mb-8"> <h1 className="text-5xl font-bold mb-6">
<div className="inline-block px-6 py-3 rounded-2xl bg-white/10 backdrop-blur-sm border border-white/20"> {isSuperAdmin ? 'aggios' : subdomain}
<h1 className="text-5xl font-bold tracking-tight bg-linear-to-r from-white to-white/80 bg-clip-text text-transparent">
aggios
</h1> </h1>
</div> <p className="text-xl opacity-90 mb-8">
</div> {isSuperAdmin
? 'Gerencie todas as agências em um só lugar'
{/* Conteúdo */} : 'Gerencie seus clientes com eficiência'
<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-dashboard-line text-4xl" />
</div>
<h2 className="text-4xl font-bold mb-4">Gerencie seus projetos com facilidade</h2>
<p className="text-white/80 text-lg mb-8">
Tenha controle total sobre seus projetos, equipe e clientes em um lugar.
</p> </p>
<div className="grid grid-cols-2 gap-6 text-left">
{/* Features */} <div>
<div className="space-y-4 text-left"> <i className="ri-shield-check-line text-3xl mb-2"></i>
<div className="flex items-start gap-3"> <h3 className="font-semibold mb-1">Seguro</h3>
<div className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center shrink-0 mt-0.5"> <p className="text-sm opacity-80">Proteção de dados</p>
<i className="ri-shield-check-line text-sm" />
</div> </div>
<div> <div>
<h4 className="font-semibold mb-1">Gestão Completa</h4> <i className="ri-speed-line text-3xl mb-2"></i>
<p className="text-white/70 text-sm">Controle projetos, tarefas e prazos em tempo real</p> <h3 className="font-semibold mb-1">Rápido</h3>
</div> <p className="text-sm opacity-80">Performance otimizada</p>
</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-check-line text-sm" />
</div> </div>
<div> <div>
<h4 className="font-semibold mb-1">Relatórios Detalhados</h4> <i className="ri-team-line text-3xl mb-2"></i>
<p className="text-white/70 text-sm">Análises e métricas para tomada de decisão</p> <h3 className="font-semibold mb-1">Colaborativo</h3>
</div> <p className="text-sm opacity-80">Trabalho em equipe</p>
</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-check-line text-sm" />
</div> </div>
<div> <div>
<h4 className="font-semibold mb-1">Colaboração em Equipe</h4> <i className="ri-line-chart-line text-3xl mb-2"></i>
<p className="text-white/70 text-sm">Trabalhe junto com sua equipe de forma eficiente</p> <h3 className="font-semibold mb-1">Insights</h3>
<p className="text-sm opacity-80">Relatórios detalhados</p>
</div> </div>
</div> </div>
</div> </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> </div>
</> </>
); );

View File

@@ -1,175 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { isAuthenticated, getUser, clearAuth } from '@/lib/auth';
export default function PainelPage() {
const router = useRouter();
const [userData, setUserData] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Verificar se usuário está logado
if (!isAuthenticated()) {
router.push('/login');
return;
}
const user = getUser();
if (user) {
setUserData(user);
setLoading(false);
} else {
router.push('/login');
}
}, [router]);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#FF3A05] mx-auto mb-4"></div>
<p className="text-gray-600">Carregando...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white border-b border-gray-200 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center justify-center w-10 h-10 bg-gradient-to-r from-[#FF3A05] to-[#FF0080] rounded-lg">
<span className="text-white font-bold text-lg">A</span>
</div>
<div>
<h1 className="text-xl font-bold text-gray-900">Aggios</h1>
<p className="text-sm text-gray-500">{userData?.company}</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-sm font-medium text-gray-900">{userData?.name}</p>
<p className="text-xs text-gray-500">{userData?.email}</p>
</div>
<button
onClick={() => {
clearAuth();
router.push('/login');
}}
className="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900"
>
Sair
</button>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Welcome Card */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<div className="flex items-center gap-4 mb-4">
<div className="flex-shrink-0">
<div className="w-16 h-16 bg-gradient-to-br from-[#FF3A05] to-[#FF0080] rounded-full flex items-center justify-center">
<span className="text-2xl">🎉</span>
</div>
</div>
<div>
<h2 className="text-2xl font-bold text-gray-900">
Bem-vindo ao Aggios, {userData?.name}!
</h2>
<p className="text-gray-600 mt-1">
Sua conta foi criada com sucesso. Este é o seu painel administrativo.
</p>
</div>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Projetos</p>
<p className="text-3xl font-bold text-gray-900 mt-2">0</p>
</div>
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Clientes</p>
<p className="text-3xl font-bold text-gray-900 mt-2">0</p>
</div>
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Receita</p>
<p className="text-3xl font-bold text-gray-900 mt-2">R$ 0</p>
</div>
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
</div>
</div>
{/* Next Steps */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Próximos Passos</h3>
<div className="space-y-3">
<div className="flex items-start gap-3 p-4 bg-gray-50 rounded-lg">
<div className="flex-shrink-0 w-6 h-6 bg-[#FF3A05] rounded-full flex items-center justify-center text-white text-sm font-bold">
1
</div>
<div>
<h4 className="font-medium text-gray-900">Complete seu perfil</h4>
<p className="text-sm text-gray-600 mt-1">Adicione mais informações sobre sua empresa</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-gray-50 rounded-lg">
<div className="flex-shrink-0 w-6 h-6 bg-[#FF3A05] rounded-full flex items-center justify-center text-white text-sm font-bold">
2
</div>
<div>
<h4 className="font-medium text-gray-900">Adicione seu primeiro cliente</h4>
<p className="text-sm text-gray-600 mt-1">Comece a gerenciar seus clientes</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-gray-50 rounded-lg">
<div className="flex-shrink-0 w-6 h-6 bg-[#FF3A05] rounded-full flex items-center justify-center text-white text-sm font-bold">
3
</div>
<div>
<h4 className="font-medium text-gray-900">Crie seu primeiro projeto</h4>
<p className="text-sm text-gray-600 mt-1">Organize seu trabalho em projetos</p>
</div>
</div>
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,485 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { isAuthenticated, getUser, clearAuth } from '@/lib/auth';
interface Agency {
id: string;
name: string;
subdomain: string;
domain: string;
is_active: boolean;
created_at: string;
}
interface AgencyDetails {
access_url: string;
tenant: {
id: string;
name: string;
domain: string;
subdomain: string;
cnpj?: string;
razao_social?: string;
email?: string;
phone?: string;
website?: string;
address?: string;
city?: string;
state?: string;
zip?: string;
description?: string;
industry?: string;
is_active: boolean;
created_at: string;
updated_at: string;
};
admin?: {
id: string;
email: string;
name: string;
role: string;
created_at: string;
tenant_id?: string;
};
}
export default function PainelPage() {
const router = useRouter();
const [userData, setUserData] = useState<any>(null);
const [agencies, setAgencies] = useState<Agency[]>([]);
const [loading, setLoading] = useState(true);
const [loadingAgencies, setLoadingAgencies] = useState(true);
const [selectedAgencyId, setSelectedAgencyId] = useState<string | null>(null);
const [selectedDetails, setSelectedDetails] = useState<AgencyDetails | null>(null);
const [detailsLoadingId, setDetailsLoadingId] = useState<string | null>(null);
const [detailsError, setDetailsError] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
useEffect(() => {
// Verificar se usuário está logado
if (!isAuthenticated()) {
router.push('/login');
return;
}
const user = getUser();
if (user) {
// Verificar se é SUPERADMIN
if (user.role !== 'SUPERADMIN') {
alert('Acesso negado. Apenas SUPERADMIN pode acessar este painel.');
clearAuth();
router.push('/login');
return;
}
setUserData(user);
setLoading(false);
loadAgencies();
} else {
router.push('/login');
}
}, [router]);
const loadAgencies = async () => {
setLoadingAgencies(true);
try {
const response = await fetch('/api/admin/agencies');
if (response.ok) {
const data = await response.json();
setAgencies(data);
if (selectedAgencyId && !data.some((agency: Agency) => agency.id === selectedAgencyId)) {
setSelectedAgencyId(null);
setSelectedDetails(null);
}
} else {
console.error('Erro ao carregar agências');
}
} catch (error) {
console.error('Erro ao carregar agências:', error);
} finally {
setLoadingAgencies(false);
}
};
const handleViewDetails = async (agencyId: string) => {
setDetailsError(null);
setDetailsLoadingId(agencyId);
setSelectedAgencyId(agencyId);
setSelectedDetails(null);
try {
const response = await fetch(`/api/admin/agencies/${agencyId}`);
const data = await response.json();
if (!response.ok) {
setDetailsError(data?.error || 'Não foi possível carregar os detalhes da agência.');
setSelectedAgencyId(null);
return;
}
setSelectedDetails(data);
} catch (error) {
console.error('Erro ao carregar detalhes da agência:', error);
setDetailsError('Erro ao carregar detalhes da agência.');
setSelectedAgencyId(null);
} finally {
setDetailsLoadingId(null);
}
};
const handleDeleteAgency = async (agencyId: string) => {
const confirmDelete = window.confirm('Tem certeza que deseja excluir esta agência? Esta ação não pode ser desfeita.');
if (!confirmDelete) {
return;
}
setDeletingId(agencyId);
try {
const response = await fetch(`/api/admin/agencies/${agencyId}`, {
method: 'DELETE',
});
if (!response.ok && response.status !== 204) {
const data = await response.json().catch(() => ({ error: 'Erro ao excluir agência.' }));
alert(data?.error || 'Erro ao excluir agência.');
return;
}
alert('Agência excluída com sucesso!');
if (selectedAgencyId === agencyId) {
setSelectedAgencyId(null);
setSelectedDetails(null);
}
await loadAgencies();
} catch (error) {
console.error('Erro ao excluir agência:', error);
alert('Erro ao excluir agência.');
} finally {
setDeletingId(null);
}
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#FF3A05] mx-auto mb-4"></div>
<p className="text-gray-600 dark:text-gray-400">Carregando...</p>
</div>
{detailsLoadingId && (
<div className="mt-8 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-dashed border-[#FF3A05] p-6 text-sm text-gray-600 dark:text-gray-300">
Carregando detalhes da agência selecionada...
</div>
)}
{detailsError && !detailsLoadingId && (
<div className="mt-8 bg-red-50 dark:bg-red-900/40 border border-red-200 dark:border-red-800 rounded-lg p-6 text-red-700 dark:text-red-200">
{detailsError}
</div>
)}
{selectedDetails && !detailsLoadingId && (
<div className="mt-8 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Detalhes da Agência</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">Informações enviadas no cadastro e dados administrativos</p>
</div>
<div className="flex items-center gap-3">
<a
href={selectedDetails.access_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-[#FF3A05] hover:text-[#FF0080]"
>
Abrir painel da agência
</a>
<button
onClick={() => {
setSelectedAgencyId(null);
setSelectedDetails(null);
setDetailsError(null);
}}
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
>
Fechar
</button>
</div>
</div>
<div className="px-6 py-6 space-y-6">
<div>
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Dados da Agência</h4>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-500 dark:text-gray-400">Nome Fantasia</p>
<p className="text-gray-900 dark:text-white font-medium">{selectedDetails.tenant.name}</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400">Razão Social</p>
<p className="text-gray-900 dark:text-white font-medium">{selectedDetails.tenant.razao_social || '—'}</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400">CNPJ</p>
<p className="text-gray-900 dark:text-white font-medium">{selectedDetails.tenant.cnpj || '—'}</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400">Segmento</p>
<p className="text-gray-900 dark:text-white font-medium">{selectedDetails.tenant.industry || '—'}</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400">Descrição</p>
<p className="text-gray-900 dark:text-white">{selectedDetails.tenant.description || '—'}</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400">Status</p>
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold ${selectedDetails.tenant.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300' : 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-200'}`}>
{selectedDetails.tenant.is_active ? 'Ativa' : 'Inativa'}
</span>
</div>
</div>
</div>
<div>
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Endereço e Contato</h4>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-500 dark:text-gray-400">Endereço completo</p>
<p className="text-gray-900 dark:text-white">{selectedDetails.tenant.address || '—'}</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400">Cidade / Estado</p>
<p className="text-gray-900 dark:text-white">{selectedDetails.tenant.city || '—'} {selectedDetails.tenant.state ? `- ${selectedDetails.tenant.state}` : ''}</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400">CEP</p>
<p className="text-gray-900 dark:text-white">{selectedDetails.tenant.zip || '—'}</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400">Website</p>
{selectedDetails.tenant.website ? (
<a href={selectedDetails.tenant.website} target="_blank" rel="noopener noreferrer" className="text-[#FF3A05] hover:text-[#FF0080]">
{selectedDetails.tenant.website}
</a>
) : (
<p className="text-gray-900 dark:text-white"></p>
)}
</div>
<div>
<p className="text-gray-500 dark:text-gray-400">E-mail comercial</p>
<p className="text-gray-900 dark:text-white">{selectedDetails.tenant.email || '—'}</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400">Telefone</p>
<p className="text-gray-900 dark:text-white">{selectedDetails.tenant.phone || '—'}</p>
</div>
</div>
</div>
<div>
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Administrador da Agência</h4>
{selectedDetails.admin ? (
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-500 dark:text-gray-400">Nome</p>
<p className="text-gray-900 dark:text-white font-medium">{selectedDetails.admin.name}</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400">E-mail</p>
<p className="text-gray-900 dark:text-white">{selectedDetails.admin.email}</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400">Perfil</p>
<p className="text-gray-900 dark:text-white">{selectedDetails.admin.role}</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400">Criado em</p>
<p className="text-gray-900 dark:text-white">{new Date(selectedDetails.admin.created_at).toLocaleString('pt-BR')}</p>
</div>
</div>
) : (
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">Nenhum administrador associado encontrado.</p>
)}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500">
Última atualização: {new Date(selectedDetails.tenant.updated_at).toLocaleString('pt-BR')}
</div>
</div>
</div>
)}
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<header className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center justify-center w-10 h-10 bg-gradient-to-r from-[#FF3A05] to-[#FF0080] rounded-lg">
<span className="text-white font-bold text-lg">A</span>
</div>
<div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Aggios</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">Painel Administrativo</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-sm font-medium text-gray-900 dark:text-white">Admin AGGIOS</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{userData?.email}</p>
</div>
<button
onClick={() => {
clearAuth();
router.push('/login');
}}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white"
>
Sair
</button>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Total de Agências</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-2">{agencies.length}</p>
</div>
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Agências Ativas</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-2">{agencies.filter(a => a.is_active).length}</p>
</div>
<div className="w-12 h-12 bg-green-100 dark:bg-green-900 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Agências Inativas</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-2">{agencies.filter(a => !a.is_active).length}</p>
</div>
<div className="w-12 h-12 bg-red-100 dark:bg-red-900 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
</div>
</div>
{/* Agencies Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Agências Cadastradas</h2>
</div>
{loadingAgencies ? (
<div className="p-8 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#FF3A05] mx-auto mb-4"></div>
<p className="text-gray-600 dark:text-gray-400">Carregando agências...</p>
</div>
) : agencies.length === 0 ? (
<div className="p-8 text-center">
<p className="text-gray-600 dark:text-gray-400">Nenhuma agência cadastrada ainda.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Agência</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Subdomínio</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Domínio</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Data de Criação</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{agencies.map((agency) => (
<tr
key={agency.id}
className={`hover:bg-gray-50 dark:hover:bg-gray-700 ${selectedAgencyId === agency.id ? 'bg-orange-50/60 dark:bg-gray-700/60' : ''}`}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10 bg-gradient-to-br from-[#FF3A05] to-[#FF0080] rounded-lg flex items-center justify-center">
<span className="text-white font-bold">{agency.name.charAt(0).toUpperCase()}</span>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900 dark:text-white">{agency.name}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900 dark:text-white font-mono">{agency.subdomain}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500 dark:text-gray-400">{agency.domain || '-'}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${agency.is_active
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'
}`}>
{agency.is_active ? 'Ativa' : 'Inativa'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{new Date(agency.created_at).toLocaleDateString('pt-BR')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
<button
onClick={() => handleViewDetails(agency.id)}
className="inline-flex items-center px-3 py-1.5 rounded-md bg-[#FF3A05] text-white hover:bg-[#FF0080] transition"
disabled={detailsLoadingId === agency.id || deletingId === agency.id}
>
{detailsLoadingId === agency.id ? 'Carregando...' : 'Visualizar'}
</button>
<button
onClick={() => handleDeleteAgency(agency.id)}
className="inline-flex items-center px-3 py-1.5 rounded-md border border-red-500 text-red-600 hover:bg-red-500 hover:text-white transition disabled:opacity-60"
disabled={deletingId === agency.id || detailsLoadingId === agency.id}
>
{deletingId === agency.id ? 'Excluindo...' : 'Excluir'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,50 @@
@layer theme {
:root {
/* Gradientes */
--gradient: linear-gradient(90deg, #FF3A05, #FF0080);
--gradient-text: linear-gradient(to right, #FF3A05, #FF0080);
--gradient-primary: linear-gradient(90deg, #FF3A05, #FF0080);
--color-gradient-brand: linear-gradient(to right, #FF3A05, #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;
}
}

View File

@@ -0,0 +1,100 @@
"use client";
import { useState } from 'react';
import { SwatchIcon } from '@heroicons/react/24/outline';
const themePresets = [
{
name: 'Laranja/Rosa (Padrão)',
gradient: 'linear-gradient(90deg, #FF3A05, #FF0080)',
},
{
name: 'Azul/Roxo',
gradient: 'linear-gradient(90deg, #0066FF, #9333EA)',
},
{
name: 'Verde/Esmeralda',
gradient: 'linear-gradient(90deg, #10B981, #059669)',
},
{
name: 'Ciano/Azul',
gradient: 'linear-gradient(90deg, #06B6D4, #3B82F6)',
},
{
name: 'Rosa/Roxo',
gradient: 'linear-gradient(90deg, #EC4899, #A855F7)',
},
{
name: 'Vermelho/Laranja',
gradient: 'linear-gradient(90deg, #EF4444, #F97316)',
},
{
name: 'Índigo/Violeta',
gradient: 'linear-gradient(90deg, #6366F1, #8B5CF6)',
},
{
name: 'Âmbar/Amarelo',
gradient: 'linear-gradient(90deg, #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.replace('90deg', 'to right'));
document.documentElement.style.setProperty('--color-gradient-brand', gradient.replace('90deg', 'to right'));
};
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>
);
}

View File

@@ -1,20 +1,35 @@
'use client'; 'use client';
import { useTheme } from '@/contexts/ThemeContext'; import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
import { MoonIcon, SunIcon } from '@heroicons/react/24/outline';
export default function ThemeToggle() { export default function ThemeToggle() {
const { toggleTheme } = useTheme(); 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 ( return (
<button <button
onClick={toggleTheme} type="button"
className="p-2 rounded-lg bg-gradient-to-r from-primary/10 to-secondary/10 hover:from-primary/20 hover:to-secondary/20 transition-all duration-300 border border-primary/20 hover:border-primary/40" onClick={() => setTheme(isDark ? 'light' : 'dark')}
aria-label="Alternar tema" 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'}
> >
{typeof window !== 'undefined' && document.documentElement.classList.contains('dark') ? ( {isDark ? (
<i className="ri-sun-line text-lg gradient-text font-bold" /> <SunIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
) : ( ) : (
<i className="ri-moon-line text-lg gradient-text font-bold" /> <MoonIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
)} )}
</button> </button>
); );

View File

@@ -47,7 +47,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
<button <button
ref={ref} ref={ref}
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`} className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
style={variant === 'primary' ? { background: 'var(--gradient)' } : undefined} style={variant === 'primary' ? { background: 'var(--gradient-primary)' } : undefined}
disabled={disabled || isLoading} disabled={disabled || isLoading}
{...props} {...props}
> >

View File

@@ -36,7 +36,7 @@ const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
${className} ${className}
`} `}
style={{ style={{
background: checked ? 'var(--gradient)' : undefined, background: checked ? 'var(--gradient-primary)' : undefined,
}} }}
checked={checked} checked={checked}
onChange={handleChange} onChange={handleChange}

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

View File

@@ -107,7 +107,7 @@ const SearchableSelect = forwardRef<HTMLSelectElement, SearchableSelectProps>(
</select> </select>
{label && ( {label && (
<label className="block text-[13px] font-semibold text-[#000000] mb-2"> <label className="block text-[13px] font-semibold text-zinc-900 dark:text-white mb-2">
{label} {label}
{required && <span className="text-[#FF3A05] ml-1">*</span>} {required && <span className="text-[#FF3A05] ml-1">*</span>}
</label> </label>
@@ -116,7 +116,7 @@ const SearchableSelect = forwardRef<HTMLSelectElement, SearchableSelectProps>(
<div ref={containerRef} className="relative"> <div ref={containerRef} className="relative">
{leftIcon && ( {leftIcon && (
<i <i
className={`${leftIcon} absolute left-3.5 top-1/2 -translate-y-1/2 text-[#7D7D7D] text-[20px] pointer-events-none z-10`} 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`}
/> />
)} )}
@@ -126,41 +126,41 @@ const SearchableSelect = forwardRef<HTMLSelectElement, SearchableSelectProps>(
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className={` className={`
w-full px-3.5 py-3 text-[14px] font-normal w-full px-3.5 py-3 text-[14px] font-normal
border rounded-md bg-white border rounded-md bg-white dark:bg-zinc-800
text-[#000000] text-left text-zinc-900 dark:text-white text-left
transition-all transition-all
cursor-pointer cursor-pointer
${leftIcon ? "pl-11" : ""} ${leftIcon ? "pl-11" : ""}
pr-11 pr-11
${error ${error
? "border-[#FF3A05]" ? "border-[#FF3A05]"
: "border-[#E5E5E5] focus:border-[#FF3A05]" : "border-zinc-200 dark:border-zinc-700 focus:border-[#FF3A05]"
} }
outline-none ring-0 focus:ring-0 shadow-none focus:shadow-none outline-none ring-0 focus:ring-0 shadow-none focus:shadow-none
${className} ${className}
`} `}
> >
{selectedOption ? selectedOption.label : ( {selectedOption ? selectedOption.label : (
<span className="text-[#7D7D7D]">{placeholder || "Selecione uma opção"}</span> <span className="text-zinc-500 dark:text-zinc-400">{placeholder || "Selecione uma opção"}</span>
)} )}
</button> </button>
<i className={`ri-arrow-${isOpen ? 'up' : 'down'}-s-line absolute right-3.5 top-1/2 -translate-y-1/2 text-[#7D7D7D] text-[20px] pointer-events-none transition-transform`} /> <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 */} {/* Dropdown */}
{isOpen && ( {isOpen && (
<div className="absolute z-50 w-full mt-2 bg-white border border-[#E5E5E5] rounded-md shadow-lg max-h-[300px] overflow-hidden"> <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 */} {/* Search input */}
<div className="p-2 border-b border-[#E5E5E5]"> <div className="p-2 border-b border-zinc-200 dark:border-zinc-700">
<div className="relative"> <div className="relative">
<i className="ri-search-line absolute left-3 top-1/2 -translate-y-1/2 text-[#7D7D7D] text-[16px]" /> <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 <input
ref={searchInputRef} ref={searchInputRef}
type="text" type="text"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Buscar..." placeholder="Buscar..."
className="w-full pl-9 pr-3 py-2 text-[14px] border border-[#E5E5E5] rounded-md outline-none focus:border-[#FF3A05] shadow-none" 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-[#FF3A05] 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>
</div> </div>
@@ -175,15 +175,15 @@ const SearchableSelect = forwardRef<HTMLSelectElement, SearchableSelectProps>(
onClick={() => handleSelect(option)} onClick={() => handleSelect(option)}
className={` className={`
w-full px-4 py-2.5 text-left text-[14px] transition-colors w-full px-4 py-2.5 text-left text-[14px] transition-colors
hover:bg-[#FDFDFC] cursor-pointer hover:bg-zinc-100 dark:hover:bg-zinc-700 cursor-pointer
${selectedOption?.value === option.value ? 'bg-[#FF3A05]/10 text-[#FF3A05] font-medium' : 'text-[#000000]'} ${selectedOption?.value === option.value ? 'bg-[#FF3A05]/10 text-[#FF3A05] font-medium' : 'text-zinc-900 dark:text-white'}
`} `}
> >
{option.label} {option.label}
</button> </button>
)) ))
) : ( ) : (
<div className="px-4 py-8 text-center text-[#7D7D7D] text-[14px]"> <div className="px-4 py-8 text-center text-zinc-500 dark:text-zinc-400 text-[14px]">
Nenhum resultado encontrado Nenhum resultado encontrado
</div> </div>
)} )}
@@ -193,7 +193,7 @@ const SearchableSelect = forwardRef<HTMLSelectElement, SearchableSelectProps>(
</div> </div>
{helperText && !error && ( {helperText && !error && (
<p className="mt-1.5 text-[12px] text-[#7D7D7D]">{helperText}</p> <p className="mt-1.5 text-[12px] text-zinc-600 dark:text-zinc-400">{helperText}</p>
)} )}
{error && ( {error && (
<p className="mt-1 text-[13px] text-[#FF3A05] flex items-center gap-1"> <p className="mt-1 text-[13px] text-[#FF3A05] flex items-center gap-1">

View File

@@ -3,3 +3,4 @@ export { default as Input } from "./Input";
export { default as Checkbox } from "./Checkbox"; export { default as Checkbox } from "./Checkbox";
export { default as Select } from "./Select"; export { default as Select } from "./Select";
export { default as SearchableSelect } from "./SearchableSelect"; export { default as SearchableSelect } from "./SearchableSelect";
export { default as Dialog } from "./Dialog";

View File

@@ -1,45 +0,0 @@
'use client';
import { createContext, useContext, ReactNode } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const toggleTheme = () => {
const html = document.documentElement;
const isDark = html.classList.contains('dark');
if (isDark) {
html.classList.remove('dark');
localStorage.setItem('theme', 'light');
} else {
html.classList.add('dark');
localStorage.setItem('theme', 'dark');
}
};
// Detectar tema atual
const isDark = typeof window !== 'undefined' && document.documentElement.classList.contains('dark');
const theme: Theme = isDark ? 'dark' : 'light';
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}

View File

@@ -6,9 +6,10 @@ export interface User {
id: string; id: string;
email: string; email: string;
name: string; name: string;
tenantId: string; role: string;
company: string; tenantId?: string;
subdomain: string; company?: string;
subdomain?: string;
} }
const TOKEN_KEY = 'token'; const TOKEN_KEY = 'token';

View File

@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const hostname = request.headers.get('host') || '';
const url = request.nextUrl;
// Extrair subdomínio
const subdomain = hostname.split('.')[0];
// Se for dash.localhost - rotas administrativas (SUPERADMIN)
if (subdomain === 'dash') {
// Permitir acesso a /superadmin, /cadastro, /login
return NextResponse.next();
}
// Se for agência ({subdomain}.localhost) - rotas de tenant
// Permitir /dashboard, /login, /clientes, etc.
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).*)',
],
};

View File

@@ -1,7 +1,9 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ experimental: {
externalDir: true,
},
}; };
export default nextConfig; export default nextConfig;

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,12 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "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": "16.0.7",
"next-themes": "^0.4.6",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",

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

View 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: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
950: '#082f49',
},
surface: {
light: '#ffffff',
dark: '#0a0a0a',
},
},
boxShadow: {
glow: '0 0 20px rgba(14, 165, 233, 0.3)',
},
},
},
};

View File

@@ -0,0 +1,28 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Runtime stage
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000', (res) => { if (res.statusCode !== 200) { throw new Error('Status ' + res.statusCode); } }).on('error', (err) => { throw err; });"
CMD ["npm", "start"]

View File

@@ -1,49 +1,37 @@
@config "../tailwind.config.js"; /* @config "../tailwind.config.js"; */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Open+Sans:wght@400;600;700&family=Fira+Code:wght@400&display=swap');
@import url('https://cdn.jsdelivr.net/npm/remixicon@4.0.0/fonts/remixicon.css');
@import "tailwindcss"; @import "tailwindcss";
@import "./tokens.css";
/* Configuração global */ :root {
* { color-scheme: light;
@apply border-zinc-200 dark:border-zinc-800;
} }
html { html.dark {
@apply antialiased; color-scheme: dark;
} }
@layer base {
body { body {
@apply bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100; background-color: var(--color-surface-muted);
font-family: 'Inter', system-ui, sans-serif; color: var(--color-text-primary);
scroll-behavior: smooth; transition: background-color 0.25s ease, color 0.25s ease;
transition: background-color 0.3s ease, color 0.3s ease;
} }
.font-heading { ::selection {
font-family: 'Open Sans', system-ui, sans-serif; background-color: var(--color-brand-500);
color: var(--color-text-inverse);
} }
.font-mono { .surface-card {
font-family: 'Fira Code', ui-monospace, monospace; background-color: var(--color-surface-card);
border: 1px solid var(--color-border-strong);
box-shadow: 0 20px 80px rgba(15, 23, 42, 0.08);
} }
/* Classes do design system - gradientes sempre visíveis */ .glass-panel {
.gradient-text { background: linear-gradient(120deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.05));
background: linear-gradient(135deg, #FF3A05, #FF0080); border: 1px solid rgba(255, 255, 255, 0.2);
-webkit-background-clip: text; box-shadow: 0 25px 50px -12px rgba(15, 23, 42, 0.25);
background-clip: text; backdrop-filter: blur(20px);
-webkit-text-fill-color: transparent;
} }
.bg-linear-to-r {
background: linear-gradient(to right, #FF3A05, #FF0080);
}
.bg-linear-to-br {
background: linear-gradient(to bottom right, #FF3A05, #FF0080);
}
/* Estados de foco */
*:focus-visible {
@apply outline-2 outline-offset-2 outline-orange-500;
} }

View File

@@ -1,7 +1,26 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Inter, Open_Sans, Fira_Code } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { ThemeProvider } from "next-themes"; 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 = { export const metadata: Metadata = {
title: "Aggios - Plataforma de Gestão Financeira", title: "Aggios - Plataforma de Gestão Financeira",
description: "A plataforma completa para gestão de agências e clientes. Controle financeiro, relatórios inteligentes e muito mais.", description: "A plataforma completa para gestão de agências e clientes. Controle financeiro, relatórios inteligentes e muito mais.",
@@ -20,7 +39,10 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="pt-BR" suppressHydrationWarning> <html lang="pt-BR" suppressHydrationWarning>
<body className="antialiased"> <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}> <ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
{children} {children}
</ThemeProvider> </ThemeProvider>

View File

@@ -1,72 +1,145 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import ThemeToggle from "@/components/ThemeToggle"; import Header from "@/components/Header";
export default function Home() { export default function Home() {
return ( return (
<> <>
{/* Header */} <Header />
<header className="fixed inset-x-0 top-0 z-50 border-b border-zinc-200/70 dark:border-zinc-800/70 bg-white/90 dark:bg-zinc-950/80 backdrop-blur-xl shadow-sm transition-colors">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="flex items-center justify-between py-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-linear-to-r from-[#FF3A05] to-[#FF0080] rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">A</span>
</div>
<span className="font-heading font-bold text-xl text-zinc-900 dark:text-white transition-colors">aggios</span>
</div>
<nav className="hidden md:flex items-center gap-8">
<a href="#features" className="text-zinc-600 dark:text-zinc-400 hover:bg-linear-to-r hover:from-[#FF3A05] hover:to-[#FF0080] hover:bg-clip-text hover:text-transparent transition-all">Recursos</a>
<a href="#pricing" className="text-zinc-600 dark:text-zinc-400 hover:bg-linear-to-r hover:from-[#FF3A05] hover:to-[#FF0080] hover:bg-clip-text hover:text-transparent transition-all">Preços</a>
<a href="#contact" className="text-zinc-600 dark:text-zinc-400 hover:bg-linear-to-r hover:from-[#FF3A05] hover:to-[#FF0080] hover:bg-clip-text hover:text-transparent transition-all">Contato</a>
</nav>
<div className="flex items-center gap-4">
<ThemeToggle />
<Link href="https://dash.aggios.app/login" className="text-zinc-600 dark:text-zinc-400 hover:bg-linear-to-r hover:from-[#FF3A05] hover:to-[#FF0080] hover:bg-clip-text hover:text-transparent transition-all font-medium">
Entrar
</Link>
<Link href="https://dash.aggios.app/cadastro" className="px-6 py-2 bg-linear-to-r from-[#FF3A05] to-[#FF0080] text-white font-semibold rounded-lg hover:opacity-90 transition-opacity shadow-lg">
Começar Grátis
</Link>
</div>
</div>
</div>
</header>
<main className="pt-28 bg-white dark:bg-zinc-950 transition-colors"> <main className="pt-28 bg-white dark:bg-zinc-950 transition-colors">
{/* Hero Section */} {/* Hero Section */}
<section className="py-20 bg-white dark:bg-zinc-900 transition-colors"> <section className="py-28 bg-white dark:bg-zinc-900 transition-colors">
<div className="max-w-7xl mx-auto px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-6 lg:px-8 grid lg:grid-cols-2 gap-14 items-center">
<div className="text-center"> <div>
<div className="inline-flex items-center gap-2 px-4 py-2 bg-linear-to-r from-[#FF3A05] to-[#FF0080] rounded-full text-sm font-semibold text-white shadow-lg shadow-[#FF3A05]/25 mb-6"> <div className="inline-flex items-center gap-2 px-4 py-2 bg-linear-to-r from-[#FF3A05] to-[#FF0080] rounded-full text-sm font-semibold text-white shadow-lg shadow-[#FF3A05]/25 mb-8">
<i className="ri-rocket-line text-base"></i> <i className="ri-rocket-line text-base"></i>
<span> <span>Plataforma de Gestão Financeira</span>
Plataforma de Gestão Financeira
</span>
</div> </div>
<h1 className="font-heading font-bold text-5xl lg:text-7xl text-zinc-900 dark:text-white mb-6 leading-tight transition-colors"> <h1 className="font-heading font-bold text-5xl lg:text-6xl text-zinc-900 dark:text-white mb-6 leading-tight transition-colors">
Transforme sua <br /> Transforme sua <span className="gradient-text">gestão financeira</span> em uma única interface.
<span className="gradient-text">gestão financeira</span>
</h1> </h1>
<p className="text-xl text-zinc-600 dark:text-zinc-400 mb-8 max-w-3xl mx-auto leading-relaxed transition-colors"> <p className="text-lg text-zinc-600 dark:text-zinc-400 mb-10 leading-relaxed transition-colors">
A plataforma completa para gestão de agências e clientes. Conecte CRM, ERP, projetos, pagamentos e suporte com dashboards inteligentes, automações e insights em tempo real para sua agência.
Controle financeiro, relatórios inteligentes e muito mais.
</p> </p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4"> <div className="flex flex-col sm:flex-row items-center gap-4">
<Link href="https://dash.aggios.app/cadastro" className="px-6 py-3 bg-linear-to-r from-[#FF3A05] to-[#FF0080] text-white font-semibold rounded-lg hover:opacity-90 transition-opacity shadow-lg"> <Link href="http://dash.localhost/cadastro" className="px-8 py-3 bg-linear-to-r from-[#FF3A05] to-[#FF0080] text-white font-semibold rounded-xl hover:opacity-90 transition-opacity shadow-lg shadow-[#FF3A05]/30">
<i className="ri-arrow-right-line mr-2"></i> <i className="ri-arrow-right-line mr-2"></i>
Começar Grátis Começar Grátis
</Link> </Link>
<Link href="#demo" className="px-6 py-3 border-2 border-zinc-300 text-zinc-700 font-semibold rounded-lg hover:border-transparent hover:bg-linear-to-r hover:from-[#FF3A05] hover:to-[#FF0080] hover:text-white transition-all"> <Link href="#demo" className="px-8 py-3 rounded-xl border border-zinc-300 text-zinc-700 dark:text-white font-semibold hover:border-transparent hover:bg-linear-to-r hover:from-[#FF3A05] hover:to-[#FF0080] hover:text-white transition-all">
<i className="ri-play-circle-line mr-2"></i> <i className="ri-play-circle-line mr-2"></i>
Ver Demo Ver Demo
</Link> </Link>
</div> </div>
<div className="mt-8 flex items-center gap-6 text-sm text-zinc-500 dark:text-zinc-400">
<div className="flex items-center gap-2">
<i className="ri-shield-check-line text-lg gradient-text"></i>
Segurança bancária
</div>
<div className="flex items-center gap-2">
<i className="ri-flashlight-line text-lg gradient-text"></i>
Automatizações inteligentes
</div>
</div>
</div>
<div className="relative">
<div className="absolute inset-0 blur-3xl bg-linear-to-r from-[#FF3A05]/20 to-[#FF0080]/20 rounded-[40px]"></div>
<div className="relative rounded-4xl border border-white/30 bg-white/80 dark:bg-zinc-950/80 backdrop-blur-xl shadow-2xl overflow-hidden">
<div className="flex items-center justify-between px-8 py-6 border-b border-zinc-100 dark:border-zinc-800">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-zinc-400">Painel ao vivo</p>
<h3 className="text-xl font-semibold text-zinc-900 dark:text-white">Aggios Dashboard</h3>
</div>
<span className="px-3 py-1 rounded-full bg-emerald-100 text-emerald-700 text-xs font-semibold">Online</span>
</div>
<div className="p-8 space-y-6">
<div className="grid grid-cols-3 gap-4">
{[
{ label: 'MRR', value: 'R$ 128K' },
{ label: 'Clientes ativos', value: '312' },
{ label: 'NPS', value: '92' },
].map((stat) => (
<div key={stat.label} className="rounded-2xl border border-zinc-100 dark:border-zinc-800 p-4">
<p className="text-xs uppercase tracking-wide text-zinc-400">{stat.label}</p>
<p className="text-2xl font-semibold text-zinc-900 dark:text-white mt-1">{stat.value}</p>
</div>
))}
</div>
<div className="rounded-2xl border border-zinc-100 dark:border-zinc-800 p-6">
<div className="flex items-center justify-between mb-4">
<p className="font-semibold text-zinc-900 dark:text-white">Fluxo de caixa</p>
<span className="text-sm text-emerald-500">+18% este mês</span>
</div>
<div className="h-40 bg-linear-to-r from-[#FF3A05]/20 via-transparent to-[#FF0080]/20 rounded-xl relative">
<div className="absolute inset-4 rounded-xl border border-dashed border-zinc-200 dark:border-zinc-800"></div>
<div className="absolute inset-0 flex items-end gap-2 p-6">
{[40, 60, 80, 50, 90, 70].map((height, index) => (
<span key={index} className="w-4 rounded-full bg-linear-to-r from-[#FF3A05] to-[#FF0080]" style={{ height: `${height}%` }}></span>
))}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Solutions Section */}
<section id="solutions" className="py-24 bg-zinc-50/60 dark:bg-zinc-900/40 border-y border-zinc-200 dark:border-zinc-800">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="mb-16 text-center max-w-3xl mx-auto space-y-6">
<div>
<p className="text-sm uppercase tracking-[0.3em] text-zinc-500">Soluções</p>
<h2 className="font-heading font-bold text-4xl lg:text-5xl text-zinc-900 dark:text-white mt-4 leading-tight">
Suite completa para gerir <span className="gradient-text">todo o ciclo financeiro.</span>
</h2>
</div>
<p className="text-lg text-zinc-600 dark:text-zinc-400">
Escolha o módulo ideal ou combine todos para criar um ecossistema integrado com CRM, ERP, projetos, pagamentos e helpdesk em um lugar.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{[
{ id: "crm", icon: "ri-customer-service-2-line", title: "CRM Inteligente", desc: "Automatize funis, acompanhe negociações e tenha visão 360° dos clientes em tempo real." },
{ id: "erp", icon: "ri-database-2-line", title: "ERP Financeiro", desc: "Centralize contas a pagar/receber, fluxo de caixa e integrações bancárias sem planilhas." },
{ id: "gestao-projetos", icon: "ri-trello-line", title: "Gestão de Projetos", desc: "Planeje sprints, distribua tarefas e monitore entregas com dashboards interativos." },
{ id: "gestao-pagamentos", icon: "ri-secure-payment-line", title: "Gestão de Pagamentos", desc: "Controle cobranças, split de receitas e reconciliação automática com gateways líderes." },
{ id: "helpdesk", icon: "ri-customer-service-line", title: "Helpdesk 360°", desc: "Organize tickets, SLAs e base de conhecimento para oferecer suporte premium." },
{ id: "integra", icon: "ri-share-forward-line", title: "Integrações API", desc: "Conecte a Aggios com BI, contabilidade e ferramentas internas via API segura." },
].map((item) => (
<div id={item.id} key={item.id} className="relative rounded-4xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950/80 backdrop-blur-xl p-8 shadow-lg overflow-hidden">
<div className="absolute -top-20 -right-10 h-40 w-40 rounded-full bg-linear-to-r from-[#FF3A05]/10 to-[#FF0080]/10 blur-3xl" aria-hidden="true"></div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-6 relative z-10">
<div>
<div className="inline-flex items-center gap-3 mb-4 px-4 py-2 rounded-full border border-zinc-200 dark:border-zinc-800 text-xs uppercase tracking-[0.2em] text-zinc-500">
<span className="flex h-8 w-8 items-center justify-center rounded-2xl bg-linear-to-r from-[#FF3A05] to-[#FF0080] text-white text-lg">
<i className={item.icon}></i>
</span>
{item.title}
</div>
<p className="text-lg text-zinc-700 dark:text-zinc-300 leading-relaxed">
{item.desc}
</p>
</div>
<div className="w-full sm:w-48 h-36 rounded-2xl border border-dashed border-zinc-200 dark:border-zinc-800 p-4 text-sm text-zinc-500 dark:text-zinc-400 bg-white/60 dark:bg-zinc-900/60">
<p className="font-semibold text-zinc-900 dark:text-white mb-2">Módulo em ação</p>
<div className="space-y-2">
<div className="h-2 rounded-full bg-linear-to-r from-[#FF3A05] to-[#FF0080]/70"></div>
<div className="h-2 rounded-full bg-linear-to-r from-[#FF0080] to-[#FF3A05]/70 w-3/4"></div>
<div className="h-2 rounded-full bg-zinc-200 dark:bg-zinc-800 w-2/3"></div>
</div>
</div>
</div>
</div>
))}
</div> </div>
</div> </div>
</section> </section>
@@ -161,24 +234,32 @@ export default function Home() {
<ul className="space-y-4 mb-8"> <ul className="space-y-4 mb-8">
<li className="flex items-center gap-3"> <li className="flex items-center gap-3">
<i className="ri-check-line text-lg bg-linear-to-r from-[#FF3A05] to-[#FF0080] bg-clip-text text-transparent"></i> <span className="flex h-6 w-6 items-center justify-center rounded-full bg-linear-to-r from-[#FF3A05] to-[#FF0080] text-white shadow-sm">
<i className="ri-check-line text-[13px]"></i>
</span>
<span className="text-zinc-600 dark:text-zinc-300 transition-colors">Até 10 clientes</span> <span className="text-zinc-600 dark:text-zinc-300 transition-colors">Até 10 clientes</span>
</li> </li>
<li className="flex items-center gap-3"> <li className="flex items-center gap-3">
<i className="ri-check-line text-lg bg-linear-to-r from-[#FF3A05] to-[#FF0080] bg-clip-text text-transparent"></i> <span className="flex h-6 w-6 items-center justify-center rounded-full bg-linear-to-r from-[#FF3A05] to-[#FF0080] text-white shadow-sm">
<i className="ri-check-line text-[13px]"></i>
</span>
<span className="text-zinc-600 dark:text-zinc-300 transition-colors">Dashboard básico</span> <span className="text-zinc-600 dark:text-zinc-300 transition-colors">Dashboard básico</span>
</li> </li>
<li className="flex items-center gap-3"> <li className="flex items-center gap-3">
<i className="ri-check-line text-lg bg-linear-to-r from-[#FF3A05] to-[#FF0080] bg-clip-text text-transparent"></i> <span className="flex h-6 w-6 items-center justify-center rounded-full bg-linear-to-r from-[#FF3A05] to-[#FF0080] text-white shadow-sm">
<i className="ri-check-line text-[13px]"></i>
</span>
<span className="text-zinc-600 dark:text-zinc-300 transition-colors">Relatórios mensais</span> <span className="text-zinc-600 dark:text-zinc-300 transition-colors">Relatórios mensais</span>
</li> </li>
<li className="flex items-center gap-3"> <li className="flex items-center gap-3">
<i className="ri-check-line text-lg bg-linear-to-r from-[#FF3A05] to-[#FF0080] bg-clip-text text-transparent"></i> <span className="flex h-6 w-6 items-center justify-center rounded-full bg-linear-to-r from-[#FF3A05] to-[#FF0080] text-white shadow-sm">
<i className="ri-check-line text-[13px]"></i>
</span>
<span className="text-zinc-600 dark:text-zinc-300 transition-colors">Suporte por email</span> <span className="text-zinc-600 dark:text-zinc-300 transition-colors">Suporte por email</span>
</li> </li>
</ul> </ul>
<Link href="https://dash.aggios.app/cadastro" className="w-full px-6 py-3 border-2 border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 font-semibold rounded-lg hover:border-transparent hover:bg-linear-to-r hover:from-[#FF3A05] hover:to-[#FF0080] hover:text-white transition-all block text-center"> <Link href="http://dash.localhost/cadastro" className="w-full px-6 py-3 border-2 border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 font-semibold rounded-lg hover:border-transparent hover:bg-linear-to-r hover:from-[#FF3A05] hover:to-[#FF0080] hover:text-white transition-all block text-center">
Começar Grátis Começar Grátis
</Link> </Link>
</div> </div>
@@ -221,7 +302,7 @@ export default function Home() {
</li> </li>
</ul> </ul>
<Link href="https://dash.aggios.app/cadastro" className="w-full px-6 py-3 bg-white text-zinc-900 font-semibold rounded-lg hover:bg-white/90 transition-colors block text-center"> <Link href="http://dash.localhost/cadastro" className="w-full px-6 py-3 bg-white text-zinc-900 font-semibold rounded-lg hover:bg-white/90 transition-colors block text-center">
Começar Agora Começar Agora
</Link> </Link>
</div> </div>
@@ -239,23 +320,33 @@ export default function Home() {
<ul className="space-y-4 mb-8"> <ul className="space-y-4 mb-8">
<li className="flex items-center gap-3"> <li className="flex items-center gap-3">
<i className="ri-check-line text-lg bg-linear-to-r from-[#FF3A05] to-[#FF0080] bg-clip-text text-transparent"></i> <span className="flex h-6 w-6 items-center justify-center rounded-full bg-linear-to-r from-[#FF3A05] to-[#FF0080] text-white shadow-sm">
<i className="ri-check-line text-[13px]"></i>
</span>
<span className="text-zinc-600 dark:text-zinc-300 transition-colors">Clientes ilimitados</span> <span className="text-zinc-600 dark:text-zinc-300 transition-colors">Clientes ilimitados</span>
</li> </li>
<li className="flex items-center gap-3"> <li className="flex items-center gap-3">
<i className="ri-check-line text-lg bg-linear-to-r from-[#FF3A05] to-[#FF0080] bg-clip-text text-transparent"></i> <span className="flex h-6 w-6 items-center justify-center rounded-full bg-linear-to-r from-[#FF3A05] to-[#FF0080] text-white shadow-sm">
<i className="ri-check-line text-[13px]"></i>
</span>
<span className="text-zinc-600 dark:text-zinc-300 transition-colors">Dashboard personalizado</span> <span className="text-zinc-600 dark:text-zinc-300 transition-colors">Dashboard personalizado</span>
</li> </li>
<li className="flex items-center gap-3"> <li className="flex items-center gap-3">
<i className="ri-check-line text-lg bg-linear-to-r from-[#FF3A05] to-[#FF0080] bg-clip-text text-transparent"></i> <span className="flex h-6 w-6 items-center justify-center rounded-full bg-linear-to-r from-[#FF3A05] to-[#FF0080] text-white shadow-sm">
<i className="ri-check-line text-[13px]"></i>
</span>
<span className="text-zinc-600 dark:text-zinc-300 transition-colors">Relatórios avançados</span> <span className="text-zinc-600 dark:text-zinc-300 transition-colors">Relatórios avançados</span>
</li> </li>
<li className="flex items-center gap-3"> <li className="flex items-center gap-3">
<i className="ri-check-line text-lg bg-linear-to-r from-[#FF3A05] to-[#FF0080] bg-clip-text text-transparent"></i> <span className="flex h-6 w-6 items-center justify-center rounded-full bg-linear-to-r from-[#FF3A05] to-[#FF0080] text-white shadow-sm">
<i className="ri-check-line text-[13px]"></i>
</span>
<span className="text-zinc-600 dark:text-zinc-300 transition-colors">Suporte dedicado</span> <span className="text-zinc-600 dark:text-zinc-300 transition-colors">Suporte dedicado</span>
</li> </li>
<li className="flex items-center gap-3"> <li className="flex items-center gap-3">
<i className="ri-check-line text-lg bg-linear-to-r from-[#FF3A05] to-[#FF0080] bg-clip-text text-transparent"></i> <span className="flex h-6 w-6 items-center justify-center rounded-full bg-linear-to-r from-[#FF3A05] to-[#FF0080] text-white shadow-sm">
<i className="ri-check-line text-[13px]"></i>
</span>
<span className="text-zinc-600 dark:text-zinc-300 transition-colors">White label</span> <span className="text-zinc-600 dark:text-zinc-300 transition-colors">White label</span>
</li> </li>
</ul> </ul>
@@ -280,7 +371,7 @@ export default function Home() {
</p> </p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4"> <div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link href="https://dash.aggios.app/cadastro" className="px-6 py-3 bg-linear-to-r from-[#FF3A05] to-[#FF0080] text-white font-semibold rounded-lg hover:opacity-90 transition-opacity shadow-lg"> <Link href="http://dash.localhost/cadastro" className="px-6 py-3 bg-linear-to-r from-[#FF3A05] to-[#FF0080] text-white font-semibold rounded-lg hover:opacity-90 transition-opacity shadow-lg">
<i className="ri-rocket-line mr-2"></i> <i className="ri-rocket-line mr-2"></i>
Começar Grátis Agora Começar Grátis Agora
</Link> </Link>

View File

@@ -0,0 +1,67 @@
@layer theme {
:root {
/* Paleta de cores principais */
--color-brand-50: #f0f9ff;
--color-brand-100: #e0f2fe;
--color-brand-200: #bae6fd;
--color-brand-300: #7dd3fc;
--color-brand-400: #38bdf8;
--color-brand-500: #0ea5e9;
--color-brand-600: #0284c7;
--color-brand-700: #0369a1;
--color-brand-800: #075985;
--color-brand-900: #0c4a6e;
--color-brand-950: #082f49;
/* 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;
/* Gradientes */
--gradient-primary: linear-gradient(135deg, var(--color-brand-500) 0%, var(--color-brand-700) 100%);
--gradient-accent: linear-gradient(135deg, #6366f1 0%, #a855f7 100%);
/* Focus ring */
--focus-ring: 0 0 0 3px rgba(14, 165, 233, 0.3);
}
.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;
/* Ajustando gradientes para dark */
--gradient-primary: linear-gradient(135deg, var(--color-brand-600) 0%, var(--color-brand-800) 100%);
}
}

View File

@@ -0,0 +1,148 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import ThemeToggle from "@/components/ThemeToggle";
const navItems = [
{ href: "#features", label: "Recursos" },
{ href: "#pricing", label: "Preços" },
{ href: "#contact", label: "Contato" },
];
export default function Header() {
const [isMobileOpen, setIsMobileOpen] = useState(false);
const toggleMobileMenu = () => setIsMobileOpen((prev) => !prev);
const closeMobileMenu = () => setIsMobileOpen(false);
return (
<header className="fixed inset-x-0 top-0 z-50 border-b border-zinc-200/70 dark:border-zinc-800/70 bg-white/90 dark:bg-zinc-950/80 backdrop-blur-xl shadow-sm transition-colors">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="flex items-center justify-between py-4">
<Link href="#" className="flex items-center gap-2" onClick={closeMobileMenu}>
<div className="w-8 h-8 bg-linear-to-r from-[#FF3A05] to-[#FF0080] rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">A</span>
</div>
<span className="font-heading font-bold text-xl text-zinc-900 dark:text-white transition-colors">aggios</span>
</Link>
<nav className="hidden md:flex items-center gap-6 lg:gap-8 text-sm font-medium relative">
{navItems.map((item) => (
<a key={item.href} href={item.href} className="text-zinc-600 dark:text-zinc-400 hover:bg-linear-to-r hover:from-[#FF3A05] hover:to-[#FF0080] hover:bg-clip-text hover:text-transparent transition-all">
{item.label}
</a>
))}
<div className="relative group">
<button className="flex items-center gap-1 text-zinc-600 dark:text-zinc-400 hover:bg-linear-to-r hover:from-[#FF3A05] hover:to-[#FF0080] hover:bg-clip-text hover:text-transparent transition-all">
Soluções
<i className="ri-arrow-down-s-line text-sm" />
</button>
<div className="invisible opacity-0 group-hover:visible group-hover:opacity-100 transition-all duration-200 absolute left-1/2 -translate-x-1/2 top-full mt-6 w-screen max-w-6xl rounded-3xl border border-zinc-200 dark:border-zinc-800 bg-white/95 dark:bg-zinc-900/95 backdrop-blur-2xl shadow-2xl p-8">
<div className="grid grid-cols-2 lg:grid-cols-3 gap-5">
{[
{ href: '#crm', icon: 'ri-customer-service-2-line', title: 'CRM Inteligente', desc: 'Funis, negociações e visão 360° de clientes.' },
{ href: '#erp', icon: 'ri-database-2-line', title: 'ERP Financeiro', desc: 'Fluxo de caixa, bancos e conciliação automática.' },
{ href: '#gestao-projetos', icon: 'ri-trello-line', title: 'Gestão de Projetos', desc: 'Boards, sprints e status em tempo real.' },
{ href: '#gestao-pagamentos', icon: 'ri-secure-payment-line', title: 'Gestão de Pagamentos', desc: 'Cobranças, split e gateways líderes.' },
{ href: '#helpdesk', icon: 'ri-customer-service-line', title: 'Helpdesk 360°', desc: 'Tickets, SLAs e base de conhecimento.' },
{ href: '#integra', icon: 'ri-share-forward-line', title: 'Integrações API', desc: 'Conexões com BI, contabilidade e apps internos.' },
].map((item) => (
<a key={item.href} href={item.href} className="flex items-start gap-3 rounded-2xl px-4 py-3 text-left text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100/70 dark:hover:bg-zinc-800/70 transition-colors">
<span className="mt-1 flex h-10 w-10 items-center justify-center rounded-2xl bg-linear-to-r from-[#FF3A05] to-[#FF0080] text-white text-xl">
<i className={item.icon}></i>
</span>
<span>
<span className="block font-semibold text-base text-zinc-900 dark:text-white">{item.title}</span>
<span className="block text-sm text-zinc-500 dark:text-zinc-400">{item.desc}</span>
</span>
</a>
))}
</div>
</div>
</div>
</nav>
<div className="hidden md:flex items-center gap-4">
<ThemeToggle />
<Link href="http://dash.localhost/login" className="text-zinc-600 dark:text-zinc-400 hover:bg-linear-to-r hover:from-[#FF3A05] hover:to-[#FF0080] hover:bg-clip-text hover:text-transparent transition-all font-medium">
Entrar
</Link>
<Link href="http://dash.localhost/cadastro" className="px-6 py-2 bg-linear-to-r from-[#FF3A05] to-[#FF0080] text-white font-semibold rounded-lg hover:opacity-90 transition-opacity shadow-lg">
Começar Grátis
</Link>
</div>
<div className="md:hidden flex items-center gap-4">
<ThemeToggle />
<button
onClick={toggleMobileMenu}
aria-label="Abrir menu"
className="w-11 h-11 rounded-2xl border border-zinc-200 dark:border-zinc-800 flex items-center justify-center text-2xl text-zinc-700 dark:text-zinc-200"
>
<i className={isMobileOpen ? "ri-close-line" : "ri-menu-line"}></i>
</button>
</div>
</div>
</div>
<div
className={`md:hidden fixed inset-x-4 top-20 rounded-3xl border border-zinc-200 dark:border-zinc-800 bg-white/95 dark:bg-zinc-950/95 backdrop-blur-2xl shadow-2xl p-6 transition-all duration-200 ${isMobileOpen ? "opacity-100 visible translate-y-0" : "opacity-0 invisible -translate-y-2"
}`}
>
<div className="space-y-4">
{navItems.map((item) => (
<a
key={item.href}
href={item.href}
onClick={closeMobileMenu}
className="flex items-center justify-between text-lg font-semibold text-zinc-900 dark:text-white"
>
{item.label}
<i className="ri-arrow-right-up-line text-xl text-zinc-400"></i>
</a>
))}
<div className="pt-4 border-t border-zinc-200 dark:border-zinc-800">
<p className="text-sm uppercase tracking-[0.3em] text-zinc-500 mb-4">Soluções</p>
<div className="space-y-3">
{[
{ href: '#crm', title: 'CRM Inteligente' },
{ href: '#erp', title: 'ERP Financeiro' },
{ href: '#gestao-projetos', title: 'Gestão de Projetos' },
{ href: '#gestao-pagamentos', title: 'Gestão de Pagamentos' },
{ href: '#helpdesk', title: 'Helpdesk 360°' },
{ href: '#integra', title: 'Integrações API' },
].map((item) => (
<a
key={item.href}
href={item.href}
onClick={closeMobileMenu}
className="flex items-center justify-between rounded-2xl border border-zinc-200 dark:border-zinc-800 px-4 py-3 text-zinc-700 dark:text-zinc-300"
>
{item.title}
<i className="ri-arrow-right-line"></i>
</a>
))}
</div>
</div>
<div className="pt-4 space-y-3">
<Link
href="http://dash.localhost/login"
onClick={closeMobileMenu}
className="w-full px-5 py-3 rounded-2xl border border-zinc-200 dark:border-zinc-800 text-center font-semibold text-zinc-700 dark:text-zinc-200"
>
Entrar
</Link>
<Link
href="http://dash.localhost/cadastro"
onClick={closeMobileMenu}
className="w-full px-5 py-3 rounded-2xl bg-linear-to-r from-[#FF3A05] to-[#FF0080] text-white font-semibold shadow-lg"
>
Começar Grátis
</Link>
</div>
</div>
</div>
</header>
);
}

View File

@@ -1,7 +1,9 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ experimental: {
externalDir: true,
},
}; };
export default nextConfig; export default nextConfig;

View File

@@ -11,7 +11,8 @@
"next": "16.0.7", "next": "16.0.7",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0" "react-dom": "19.2.0",
"remixicon": "^4.7.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.0.0-alpha.25", "@tailwindcss/postcss": "^4.0.0-alpha.25",
@@ -25,6 +26,12 @@
"typescript": "^5" "typescript": "^5"
} }
}, },
"../design-system": {
"name": "@aggios/design-system",
"version": "0.0.1",
"extraneous": true,
"license": "MIT"
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -5401,6 +5408,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/remixicon": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/remixicon/-/remixicon-4.7.0.tgz",
"integrity": "sha512-g2pHOofQWARWpxdbrQu5+K3C8ZbqguQFzE54HIMWFCpFa63pumaAltIgZmFMRQpKKBScRWQASQfWxS9asNCcHQ==",
"license": "Apache-2.0"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",

View File

@@ -12,7 +12,8 @@
"next": "16.0.7", "next": "16.0.7",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0" "react-dom": "19.2.0",
"remixicon": "^4.7.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.0.0-alpha.25", "@tailwindcss/postcss": "^4.0.0-alpha.25",

View File

@@ -1,9 +1,12 @@
const sharedPreset = require("./tailwind.preset.js");
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
const config = { const config = {
presets: [sharedPreset],
content: [ content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}", "./app/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}",
], ],
darkMode: "class",
}; };
module.exports = config; module.exports = config;

View 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: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
950: '#082f49',
},
surface: {
light: '#ffffff',
dark: '#0a0a0a',
},
},
boxShadow: {
glow: '0 0 20px rgba(14, 165, 233, 0.3)',
},
},
},
};

View File

@@ -9,6 +9,17 @@ CREATE TABLE IF NOT EXISTS tenants (
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
domain VARCHAR(255) UNIQUE NOT NULL, domain VARCHAR(255) UNIQUE NOT NULL,
subdomain VARCHAR(63) UNIQUE NOT NULL, subdomain VARCHAR(63) UNIQUE NOT NULL,
cnpj VARCHAR(18),
razao_social VARCHAR(255),
email VARCHAR(255),
phone VARCHAR(20),
website VARCHAR(255),
address TEXT,
city VARCHAR(100),
state VARCHAR(2),
zip VARCHAR(10),
description TEXT,
industry VARCHAR(100),
is_active BOOLEAN DEFAULT true, is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
@@ -17,15 +28,15 @@ CREATE TABLE IF NOT EXISTS tenants (
-- Users table -- Users table
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(128), first_name VARCHAR(128),
last_name VARCHAR(128), last_name VARCHAR(128),
role VARCHAR(50) DEFAULT 'CLIENTE' CHECK (role IN ('SUPERADMIN', 'ADMIN_AGENCIA', 'CLIENTE')),
is_active BOOLEAN DEFAULT true, is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
UNIQUE(tenant_id, email)
); );
-- Refresh tokens table -- Refresh tokens table
@@ -45,6 +56,31 @@ CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at ON refresh_tokens(expir
CREATE INDEX IF NOT EXISTS idx_tenants_subdomain ON tenants(subdomain); CREATE INDEX IF NOT EXISTS idx_tenants_subdomain ON tenants(subdomain);
CREATE INDEX IF NOT EXISTS idx_tenants_domain ON tenants(domain); CREATE INDEX IF NOT EXISTS idx_tenants_domain ON tenants(domain);
-- Companies table
CREATE TABLE IF NOT EXISTS companies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
cnpj VARCHAR(18) NOT NULL,
razao_social VARCHAR(255) NOT NULL,
nome_fantasia VARCHAR(255),
email VARCHAR(255),
telefone VARCHAR(20),
status VARCHAR(50) DEFAULT 'active',
created_by_user_id UUID REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(tenant_id, cnpj)
);
-- Create indexes
CREATE INDEX IF NOT EXISTS idx_companies_tenant_id ON companies(tenant_id);
CREATE INDEX IF NOT EXISTS idx_companies_cnpj ON companies(cnpj);
-- Insert SUPERADMIN user (você - admin master da AGGIOS)
INSERT INTO users (email, password_hash, first_name, role, is_active)
VALUES ('admin@aggios.app', '$2a$10$YourHashedPasswordHere', 'Admin Master', 'SUPERADMIN', true)
ON CONFLICT (email) DO NOTHING;
-- Insert sample tenant for testing -- Insert sample tenant for testing
INSERT INTO tenants (name, domain, subdomain, is_active) INSERT INTO tenants (name, domain, subdomain, is_active)
VALUES ('Agência Teste', 'agencia-teste.aggios.app', 'agencia-teste', true) VALUES ('Agência Teste', 'agencia-teste.aggios.app', 'agencia-teste', true)

1
test_agency_payload.json Normal file
View File

@@ -0,0 +1 @@
{"agencyName":"Nova Agency","subdomain":"novaagency","cnpj":"98.765.432/0001-10","razaoSocial":"Nova Agency LTDA","description":"Ag<41>ncia focada em marketing de performance.","website":"https://novaagency.com","industry":"marketing","cep":"04567-890","state":"SP","city":"S<>o Paulo","neighborhood":"Itaim Bibi","street":"Rua Teste","number":"200","complement":"Conj. 202","adminEmail":"admin@novaagency.com","adminPassword":"SenhaForte123!","adminName":"Lucas Nova"}

View File

@@ -9,17 +9,42 @@ http:
X-XSS-Protection: "1; mode=block" X-XSS-Protection: "1; mode=block"
routers: routers:
# API Backend Router # API Backend Router (only api.localhost)
api-router: api-router:
entryPoints: entryPoints:
- web - web
rule: "HostRegexp(`{subdomain:.+}.localhost`) || Host(`api.localhost`)" rule: "Host(`api.localhost`)"
middlewares: middlewares:
- security-headers - security-headers
service: api-service service: api-service
# Dashboard Router (dash.localhost for SUPERADMIN)
dashboard-main-router:
entryPoints:
- web
rule: "Host(`dash.localhost`)"
priority: 2
middlewares:
- security-headers
service: dashboard-service
# Multi-tenant Dashboard Router (agency subdomains)
dashboard-tenant-router:
entryPoints:
- web
rule: "HostRegexp(`^.+\\.localhost$`)"
priority: 1
middlewares:
- security-headers
service: dashboard-service
services: services:
api-service: api-service:
loadBalancer: loadBalancer:
servers: servers:
- url: http://backend:8080 - url: http://backend:8080
dashboard-service:
loadBalancer:
servers:
- url: http://dashboard:3000

View File

@@ -13,6 +13,9 @@ providers:
endpoint: unix:///var/run/docker.sock endpoint: unix:///var/run/docker.sock
exposedByDefault: false exposedByDefault: false
network: traefik-network network: traefik-network
file:
directory: /etc/traefik/dynamic
watch: true
log: log:
level: INFO level: INFO