Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fc974efb8 | ||
|
|
6f990c06b2 | ||
|
|
815a70bc41 | ||
|
|
d6ff6a61bc | ||
|
|
c4fda169b4 | ||
|
|
654bdd2521 | ||
|
|
2c76d7af8d | ||
|
|
92f3798808 | ||
|
|
037072d297 | ||
|
|
16de9f48b8 | ||
|
|
d4a94658bf | ||
|
|
be866aa976 | ||
|
|
2bf941777f | ||
|
|
ef98075686 | ||
|
|
bee1af01ec | ||
|
|
565aae1b9f | ||
|
|
061a572464 | ||
|
|
239fca5924 | ||
|
|
8c6e64f5b1 | ||
|
|
e503069a86 | ||
|
|
cbad251b39 | ||
|
|
b493f1d4d9 | ||
|
|
232d28eb1a | ||
|
|
080444e29d | ||
|
|
a14e7749b7 | ||
|
|
c06221331e | ||
|
|
55003b4561 | ||
|
|
70f1541ec0 | ||
|
|
53495de904 | ||
|
|
4310a88b2a | ||
|
|
0dd8f89fff | ||
|
|
6a7b84989b | ||
|
|
278b9ade28 | ||
|
|
95fbf31bfa | ||
|
|
932caf1b6c | ||
|
|
1600cc8267 | ||
|
|
ae8639bb2f | ||
|
|
bf95f067bc | ||
|
|
99530200b4 | ||
|
|
b73eb6c3eb | ||
|
|
c31184ad4b | ||
|
|
d323f28220 | ||
|
|
d5183e0a0d | ||
|
|
1fa574881c | ||
|
|
57f295edf1 | ||
|
|
0b645a3ffa | ||
|
|
841bf061aa | ||
|
|
6bb6c5b668 | ||
|
|
ccfc5fff65 | ||
|
|
02f776ad0d |
103
CHANGELOG.md
Normal file
103
CHANGELOG.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Changelog - OCCTO Engenharia
|
||||||
|
|
||||||
|
Histórico de versões e alterações do CMS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 [CMS 1.1] - 27/11/2025
|
||||||
|
|
||||||
|
### 🔧 Correções de Infraestrutura
|
||||||
|
|
||||||
|
- **Tipagem Next.js 15**: Correção de `params` para usar `Promise<{ id: string }>` nas API routes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📊 Dashboard Admin
|
||||||
|
|
||||||
|
Dashboard com dados dinâmicos em tempo real.
|
||||||
|
|
||||||
|
| Estatística | Descrição |
|
||||||
|
|-------------|-----------|
|
||||||
|
| Projetos | Total e quantidade de ativos |
|
||||||
|
| Mensagens | Total e quantidade não lidas |
|
||||||
|
| Serviços | Total e quantidade ativos |
|
||||||
|
|
||||||
|
**Funcionalidades:**
|
||||||
|
- Últimas 5 mensagens com iniciais, tempo relativo e indicador de não lida
|
||||||
|
- Últimos 5 projetos com imagem, categoria e status
|
||||||
|
- Cards clicáveis redirecionam para páginas correspondentes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🛠️ Módulo de Serviços
|
||||||
|
|
||||||
|
CRUD completo para gerenciamento de serviços.
|
||||||
|
|
||||||
|
#### Admin - Lista (`/admin/servicos`)
|
||||||
|
- Dados dinâmicos da API
|
||||||
|
- Filtro por status (Todos/Ativos/Inativos)
|
||||||
|
- Busca por título e descrição
|
||||||
|
- Ações de editar e excluir com confirmação
|
||||||
|
|
||||||
|
#### Admin - Novo Serviço (`/admin/servicos/novo`)
|
||||||
|
- Seletor visual de ícones em galeria com busca
|
||||||
|
- 70+ ícones organizados por categoria
|
||||||
|
- Campos: título, ícone, status, ordem, descrições
|
||||||
|
|
||||||
|
#### Admin - Editar Serviço (`/admin/servicos/[id]/editar`)
|
||||||
|
- Carrega dados existentes
|
||||||
|
- Mesmo seletor visual de ícones
|
||||||
|
- Atualização via API
|
||||||
|
|
||||||
|
#### Páginas Públicas
|
||||||
|
- `/servicos` - Página principal
|
||||||
|
- `/en/servicos` - Inglês
|
||||||
|
- `/es/servicos` - Espanhol
|
||||||
|
- Exibe apenas serviços ativos, ordenados
|
||||||
|
- Fallback para dados estáticos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📁 Módulo de Projetos
|
||||||
|
|
||||||
|
Melhorias no gerenciamento de projetos.
|
||||||
|
|
||||||
|
#### Admin - Lista (`/admin/projetos`)
|
||||||
|
- Filtros por categoria e status
|
||||||
|
- Busca por título, cliente e descrição
|
||||||
|
- Botão de editar em cada projeto
|
||||||
|
|
||||||
|
#### Admin - Editar Projeto (`/admin/projetos/[id]/editar`)
|
||||||
|
- Upload de imagem de capa
|
||||||
|
- Upload de galeria (até 8 imagens)
|
||||||
|
- Todos os campos editáveis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🎨 Melhorias de UX/UI
|
||||||
|
|
||||||
|
- **Seletor de Ícones**: Galeria visual com busca em tempo real
|
||||||
|
- **Loading States**: Indicadores visuais de carregamento
|
||||||
|
- **Toasts**: Feedback de sucesso e erro
|
||||||
|
- **Confirmação**: Modal antes de ações destrutivas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Estrutura de Arquivos
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/app/admin/
|
||||||
|
├── page.tsx # Dashboard dinâmico
|
||||||
|
├── projetos/
|
||||||
|
│ ├── page.tsx # Lista com filtros
|
||||||
|
│ └── [id]/editar/page.tsx # ✨ NOVO
|
||||||
|
└── servicos/
|
||||||
|
├── page.tsx # Lista com filtros
|
||||||
|
├── novo/page.tsx # Criação com ícones
|
||||||
|
└── [id]/editar/page.tsx # ✨ NOVO
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Branch**: `cms-1.1`
|
||||||
|
**Status**: ✅ Produção
|
||||||
114
README.md
Normal file
114
README.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# CMS OCCTO Engenharia v1.1
|
||||||
|
|
||||||
|
Sistema de gerenciamento de conteúdo (CMS) desenvolvido para a OCCTO Engenharia.
|
||||||
|
|
||||||
|
## 📋 Visão Geral
|
||||||
|
|
||||||
|
CMS completo para gerenciamento do site institucional da OCCTO Engenharia, empresa especializada em engenharia veicular, mecânica e segurança do trabalho.
|
||||||
|
|
||||||
|
## 🚀 Tecnologias
|
||||||
|
|
||||||
|
- **Frontend**: Next.js 15 (App Router)
|
||||||
|
- **Estilização**: Tailwind CSS 4
|
||||||
|
- **Banco de Dados**: PostgreSQL 16 + Prisma ORM
|
||||||
|
- **Storage**: MinIO (S3-compatible)
|
||||||
|
- **Autenticação**: JWT com bcryptjs
|
||||||
|
- **Deploy**: Docker Compose + Dokploy
|
||||||
|
|
||||||
|
## 📁 Estrutura do Projeto
|
||||||
|
|
||||||
|
```
|
||||||
|
├── frontend/ # Aplicação Next.js
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── app/
|
||||||
|
│ │ │ ├── admin/ # Painel administrativo
|
||||||
|
│ │ │ ├── api/ # API Routes
|
||||||
|
│ │ │ ├── (public)/ # Páginas públicas
|
||||||
|
│ │ │ └── [locale]/ # Páginas com i18n
|
||||||
|
│ │ ├── components/ # Componentes reutilizáveis
|
||||||
|
│ │ ├── contexts/ # Contexts React
|
||||||
|
│ │ └── lib/ # Utilitários
|
||||||
|
│ └── prisma/ # Schema e migrations
|
||||||
|
├── docs/ # Documentação
|
||||||
|
└── docker-compose.yml # Configuração Docker
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Changelog
|
||||||
|
|
||||||
|
### [CMS 1.1] - 27/11/2025
|
||||||
|
|
||||||
|
#### 🔧 Correções
|
||||||
|
- Tipagem de `params` para Next.js 15 nas API routes
|
||||||
|
|
||||||
|
#### 📊 Dashboard Admin
|
||||||
|
- Dados dinâmicos em tempo real (Projetos, Mensagens, Serviços)
|
||||||
|
- Últimas 5 mensagens com indicador de não lida
|
||||||
|
- Últimos 5 projetos com imagem e status
|
||||||
|
- Cards clicáveis com navegação
|
||||||
|
|
||||||
|
#### 🛠️ Módulo de Serviços (CRUD Completo)
|
||||||
|
|
||||||
|
| Página | Funcionalidades |
|
||||||
|
|--------|-----------------|
|
||||||
|
| Lista | Filtros, busca, editar/excluir |
|
||||||
|
| Novo | Seletor visual de 70+ ícones |
|
||||||
|
| Editar | Carrega e atualiza dados |
|
||||||
|
| Público | Dados dinâmicos com fallback |
|
||||||
|
|
||||||
|
#### 📁 Módulo de Projetos (Melhorias)
|
||||||
|
|
||||||
|
| Página | Funcionalidades |
|
||||||
|
|--------|-----------------|
|
||||||
|
| Lista | Filtros por categoria/status, busca |
|
||||||
|
| Editar | Upload de capa e galeria (até 8) |
|
||||||
|
|
||||||
|
#### 🎨 UX/UI
|
||||||
|
- Seletor de ícones em galeria com busca
|
||||||
|
- Loading states e toasts
|
||||||
|
- Modal de confirmação para exclusões
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [CMS 1.0] - 25/11/2025
|
||||||
|
|
||||||
|
#### ✨ Funcionalidades Iniciais
|
||||||
|
- Sistema de autenticação com JWT
|
||||||
|
- CRUD de Projetos com upload de imagens
|
||||||
|
- CRUD de Contatos/Mensagens
|
||||||
|
- Gerenciamento de conteúdo de páginas
|
||||||
|
- Sistema de leads
|
||||||
|
- Suporte a múltiplos idiomas (PT, EN, ES)
|
||||||
|
- Tema claro/escuro
|
||||||
|
- Upload de arquivos via MinIO
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Instalação
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clonar repositório
|
||||||
|
git clone https://git.stackbyte.cloud/erik/octto-engenharia.git
|
||||||
|
|
||||||
|
# Instalar dependências
|
||||||
|
cd frontend && npm install
|
||||||
|
|
||||||
|
# Configurar variáveis de ambiente
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Executar migrations
|
||||||
|
npx prisma migrate dev
|
||||||
|
|
||||||
|
# Iniciar desenvolvimento
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🌐 Deploy
|
||||||
|
|
||||||
|
Consulte o arquivo [README-DEPLOY.md](./README-DEPLOY.md) para instruções de deploy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Branch Atual**: `cms-1.1`
|
||||||
|
**Status**: ✅ Produção
|
||||||
179
docs/BACKUP_CLOUD.md
Normal file
179
docs/BACKUP_CLOUD.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# Sistema de Backup em Cloud - Occto Engenharia
|
||||||
|
|
||||||
|
Sistema completo de backup e restore com upload automático para cloud (MinIO/S3).
|
||||||
|
|
||||||
|
## 🎯 Funcionalidades
|
||||||
|
|
||||||
|
- ✅ Criar backups locais (PostgreSQL + MinIO)
|
||||||
|
- ✅ **Upload automático** para cloud (MinIO/S3)
|
||||||
|
- ✅ Download de backups
|
||||||
|
- ✅ Restore local com um clique
|
||||||
|
- ✅ **Script universal** de restore para qualquer servidor
|
||||||
|
|
||||||
|
## 📱 Interface Admin
|
||||||
|
|
||||||
|
Acesse `https://seu-dominio.com/admin/configuracoes` → Aba "Backup"
|
||||||
|
|
||||||
|
### Botões disponíveis:
|
||||||
|
- ☁️ **Upload** - Enviar backup para cloud
|
||||||
|
- 🔄 **Restaurar** - Restaurar backup no banco atual
|
||||||
|
- ⬇️ **Baixar** - Download do arquivo `.tar.gz`
|
||||||
|
- 🗑️ **Deletar** - Remover backup local
|
||||||
|
|
||||||
|
## 🚀 Restauração Rápida (Curly Magic)
|
||||||
|
|
||||||
|
### Opção 1: Via URL do Cloud (Recomendado)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -s https://seu-dominio.com/restore-from-cloud.sh) \
|
||||||
|
--backup-url "http://seu-minio:9000/backups/backup-2025-11-29.tar.gz" \
|
||||||
|
--postgres-password "sua_senha_postgres" \
|
||||||
|
--minio-endpoint "seu-minio"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Opção 2: Arquivo Local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/restore-from-cloud.sh \
|
||||||
|
--backup-file "backup-2025-11-29.tar.gz" \
|
||||||
|
--postgres-password "sua_senha_postgres"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Opção 3: Com Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Coloque o backup.tar.gz no projeto
|
||||||
|
# 2. Execute o script
|
||||||
|
bash scripts/restore-from-cloud.sh \
|
||||||
|
--backup-file "backup-2025-11-29.tar.gz" \
|
||||||
|
--postgres-password "sua_senha" \
|
||||||
|
--postgres-host "postgres" \
|
||||||
|
--postgres-db "occto_db"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Parâmetros do Script
|
||||||
|
|
||||||
|
| Parâmetro | Padrão | Descrição |
|
||||||
|
|-----------|--------|-----------|
|
||||||
|
| `--backup-url` | - | URL do backup (HTTP, S3, etc) |
|
||||||
|
| `--backup-file` | - | Nome do arquivo (obrigatório) |
|
||||||
|
| `--postgres-password` | `adminpassword` | Senha do PostgreSQL |
|
||||||
|
| `--postgres-db` | `occto_db` | Nome do banco |
|
||||||
|
| `--postgres-host` | `postgres` | Host do PostgreSQL |
|
||||||
|
| `--postgres-user` | `admin` | Usuário do PostgreSQL |
|
||||||
|
| `--minio-endpoint` | `minio` | Host do MinIO |
|
||||||
|
|
||||||
|
## 🔧 Integração com MinIO
|
||||||
|
|
||||||
|
O sistema automaticamente:
|
||||||
|
|
||||||
|
1. Cria bucket `backups` se não existir
|
||||||
|
2. Faz upload do `.tar.gz` para `backups/`
|
||||||
|
3. Gera URL acessível do arquivo
|
||||||
|
|
||||||
|
### Configurar MinIO (Docker)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: admin
|
||||||
|
MINIO_ROOT_PASSWORD: adminpassword
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
volumes:
|
||||||
|
- minio_data:/minio_data
|
||||||
|
command: server /minio_data --console-address ":9001"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛡️ Segurança
|
||||||
|
|
||||||
|
- ⚠️ O script remove o banco antigo antes de restaurar
|
||||||
|
- ⚠️ Pede confirmação antes de restaurar
|
||||||
|
- ✅ Valida arquivo antes de extrair
|
||||||
|
- ✅ Limpa arquivos temporários automaticamente
|
||||||
|
|
||||||
|
## 📊 Exemplo de Uso Completo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Ir para o projeto
|
||||||
|
cd /seu/projeto/occto
|
||||||
|
|
||||||
|
# 2. Criar backup via admin UI (opcional)
|
||||||
|
# Ou fazer manual:
|
||||||
|
./scripts/create-backup.sh
|
||||||
|
|
||||||
|
# 3. Upload para cloud (via UI ou manual)
|
||||||
|
curl -X POST http://localhost:3000/api/backup/upload \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"filename": "backup-2025-11-29.tar.gz"}'
|
||||||
|
|
||||||
|
# 4. Em outro servidor, restaurar:
|
||||||
|
bash <(curl -s http://localhost:3000/restore-from-cloud.sh) \
|
||||||
|
--backup-url "http://seu-minio:9000/backups/backup-2025-11-29.tar.gz" \
|
||||||
|
--postgres-password "nova_senha"
|
||||||
|
|
||||||
|
# 5. Reiniciar aplicação
|
||||||
|
docker-compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### "Arquivo não encontrado"
|
||||||
|
```bash
|
||||||
|
# Verificar arquivos disponíveis
|
||||||
|
ls -lah .backups/
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Erro ao conectar PostgreSQL"
|
||||||
|
```bash
|
||||||
|
# Testar conexão
|
||||||
|
PGPASSWORD="senha" psql -h postgres -U admin -d occto_db -c "SELECT 1"
|
||||||
|
```
|
||||||
|
|
||||||
|
### "MinIO connection refused"
|
||||||
|
```bash
|
||||||
|
# Verificar se MinIO está rodando
|
||||||
|
docker ps | grep minio
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restaurar manualmente
|
||||||
|
```bash
|
||||||
|
# Se o script falhar, fazer passo a passo
|
||||||
|
tar -xzf backup-2025-11-29.tar.gz
|
||||||
|
PGPASSWORD="senha" psql -h postgres -U admin -d occto_db < database.sql
|
||||||
|
docker cp minio-data/. seu-container-minio:/data/
|
||||||
|
docker restart seu-container-minio
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Estrutura do Backup
|
||||||
|
|
||||||
|
```
|
||||||
|
backup-2025-11-29.tar.gz
|
||||||
|
├── database.sql # Dump completo do PostgreSQL
|
||||||
|
└── minio-data/
|
||||||
|
├── [todos os buckets e arquivos]
|
||||||
|
└── [estrutura original do MinIO]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 Automation (Cron)
|
||||||
|
|
||||||
|
Para fazer backups automáticos a cada dia:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Adicionar ao crontab
|
||||||
|
0 2 * * * cd /seu/projeto && ./scripts/create-backup.sh && curl -X POST http://localhost:3000/api/backup/upload -d '{"filename":"backup-$(date +\%Y-\%m-\%d).tar.gz"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📞 Suporte
|
||||||
|
|
||||||
|
Para dúvidas ou problemas, verifique:
|
||||||
|
1. Logs: `docker logs seu-container-next`
|
||||||
|
2. Espaço em disco: `df -h`
|
||||||
|
3. Permissões: `ls -la .backups/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Última atualização:** 29 de Novembro de 2025
|
||||||
353
docs/BACKUP_SYSTEM.md
Normal file
353
docs/BACKUP_SYSTEM.md
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
# Backup & Restore System - Guia de Implementação
|
||||||
|
|
||||||
|
## 📋 Visão Geral
|
||||||
|
|
||||||
|
Este sistema permite criar, gerenciar e fazer download de backups completos do banco de dados PostgreSQL e arquivos MinIO através de uma interface web integrada ao painel administrativo.
|
||||||
|
|
||||||
|
## 🎯 Características
|
||||||
|
|
||||||
|
- ✅ Backup completo do PostgreSQL com `pg_dump`
|
||||||
|
- ✅ Backup dos dados do MinIO
|
||||||
|
- ✅ Compactação automática em `tar.gz`
|
||||||
|
- ✅ Interface intuitiva no painel admin
|
||||||
|
- ✅ Download de backups
|
||||||
|
- ✅ Remoção de backups antigos
|
||||||
|
- ✅ Histórico com datas e tamanhos
|
||||||
|
- ✅ Funcionamento multi-ambiente (Docker, Local, Dokploy)
|
||||||
|
|
||||||
|
## 📁 Estrutura de Arquivos
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── api/
|
||||||
|
│ │ │ └── backup/
|
||||||
|
│ │ │ ├── route.ts # POST/GET/DELETE - CRUD de backups
|
||||||
|
│ │ │ └── download/
|
||||||
|
│ │ │ └── route.ts # GET - Download de backups
|
||||||
|
│ │ └── admin/
|
||||||
|
│ │ └── configuracoes/
|
||||||
|
│ │ └── page.tsx # Interface de configurações com backup
|
||||||
|
│ └── components/
|
||||||
|
│ └── admin/
|
||||||
|
│ └── BackupManager.tsx # Componente UI do gerenciador
|
||||||
|
└── .backups/ # Diretório onde os backups são salvos
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Variáveis de Ambiente Necessárias
|
||||||
|
|
||||||
|
Adicione ao seu `.env` ou ao `docker-compose.yml`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# PostgreSQL
|
||||||
|
POSTGRES_USER=admin
|
||||||
|
POSTGRES_PASSWORD=adminpassword
|
||||||
|
POSTGRES_DB=occto_db
|
||||||
|
POSTGRES_HOST=postgres
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
|
||||||
|
# MinIO
|
||||||
|
MINIO_ENDPOINT=minio
|
||||||
|
MINIO_PORT=9000
|
||||||
|
MINIO_ACCESS_KEY=admin
|
||||||
|
MINIO_SECRET_KEY=adminpassword
|
||||||
|
MINIO_BUCKET_NAME=occto-images
|
||||||
|
MINIO_USE_SSL=false
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Como Usar
|
||||||
|
|
||||||
|
### 1. **No Painel Admin**
|
||||||
|
|
||||||
|
1. Acesse `/admin/configuracoes`
|
||||||
|
2. Desça até a seção **"Backup & Restauração"**
|
||||||
|
3. Clique em **"Criar Backup Agora"** para iniciar um novo backup
|
||||||
|
4. Visualize o histórico de backups salvos
|
||||||
|
5. Download de um backup específico: clique no ícone de download
|
||||||
|
6. Remover um backup: clique no ícone de lixeira
|
||||||
|
|
||||||
|
### 2. **Via API (Programaticamente)**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Criar backup
|
||||||
|
const response = await fetch('/api/backup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer seu_token_aqui',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listar backups
|
||||||
|
const response = await fetch('/api/backup', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer seu_token_aqui'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download de backup
|
||||||
|
const response = await fetch('/api/backup/download?file=backup-2025-11-28.tar.gz', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer seu_token_aqui'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remover backup
|
||||||
|
const response = await fetch('/api/backup?id=backup-2025-11-28', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer seu_token_aqui'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏗️ Replicação em Outro Projeto Next.js
|
||||||
|
|
||||||
|
Se você quer implementar este sistema em outro projeto Next.js:
|
||||||
|
|
||||||
|
### Passo 1: Copiar os Arquivos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copiar as rotas da API
|
||||||
|
cp -r frontend/src/app/api/backup seu_projeto/src/app/api/
|
||||||
|
|
||||||
|
# Copiar o componente UI
|
||||||
|
cp frontend/src/components/admin/BackupManager.tsx seu_projeto/src/components/admin/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Passo 2: Adicionar ao Docker Compose
|
||||||
|
|
||||||
|
Se estiver usando Docker:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:12-alpine
|
||||||
|
container_name: seu_postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: admin
|
||||||
|
POSTGRES_PASSWORD: adminpassword
|
||||||
|
POSTGRES_DB: seu_db
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
# Montar diretório de backups
|
||||||
|
- ./backups:/app/.backups
|
||||||
|
networks:
|
||||||
|
- seu_network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U admin"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
seu_frontend:
|
||||||
|
# ... suas configurações
|
||||||
|
volumes:
|
||||||
|
# Compartilhar diretório de backups
|
||||||
|
- ./backups:/app/.backups
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Passo 3: Integrar na Página de Configurações
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Sua página de configurações
|
||||||
|
import { BackupManager } from '@/components/admin/BackupManager';
|
||||||
|
|
||||||
|
export default function ConfiguracoesPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Suas outras configurações */}
|
||||||
|
|
||||||
|
{/* Adicionar seção de backup */}
|
||||||
|
<div className="border-t pt-8 mt-8">
|
||||||
|
<h2 className="text-2xl font-bold mb-6">Backup & Restauração</h2>
|
||||||
|
<BackupManager />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Passo 4: Autenticação
|
||||||
|
|
||||||
|
O sistema espera um token no header `Authorization: Bearer token`.
|
||||||
|
|
||||||
|
Você pode:
|
||||||
|
|
||||||
|
**Opção A:** Usar o token do usuário logado
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// No componente BackupManager
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Opção B:** Criar um middleware de autenticação
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// api/backup/route.ts
|
||||||
|
function authenticateRequest(request: NextRequest): boolean {
|
||||||
|
const token = request.headers.get('authorization');
|
||||||
|
|
||||||
|
// Implementar sua lógica de autenticação
|
||||||
|
return validateToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
if (!authenticateRequest(request)) {
|
||||||
|
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
|
||||||
|
}
|
||||||
|
// ... resto do código
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Opção C:** Usar autenticação de sessão (exemplo com NextAuth)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getServerSession } from 'next-auth/next';
|
||||||
|
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
|
||||||
|
}
|
||||||
|
// ... resto do código
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Estrutura do Backup
|
||||||
|
|
||||||
|
Um backup `.tar.gz` contém:
|
||||||
|
|
||||||
|
```
|
||||||
|
backup-2025-11-28/
|
||||||
|
├── database.sql # Dump completo do PostgreSQL
|
||||||
|
├── minio-data/ # Cópia de todos os arquivos do MinIO
|
||||||
|
│ └── [arquivos...]
|
||||||
|
└── metadata.json # Informações do backup
|
||||||
|
{
|
||||||
|
"timestamp": "2025-11-28T10:30:45.123Z",
|
||||||
|
"database": "occto_db",
|
||||||
|
"hostname": "postgres",
|
||||||
|
"minioEndpoint": "minio",
|
||||||
|
"version": "1.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔐 Segurança
|
||||||
|
|
||||||
|
### Recomendações:
|
||||||
|
|
||||||
|
1. **Autenticação**
|
||||||
|
- Sempre valide a autenticação nas rotas de backup
|
||||||
|
- Use tokens JWT ou sessões seguras
|
||||||
|
|
||||||
|
2. **Autorização**
|
||||||
|
- Apenas administradores devem ter acesso
|
||||||
|
- Adicione verificação de roles/permissões
|
||||||
|
|
||||||
|
3. **Armazenamento de Backups**
|
||||||
|
- Considere armazenar em local protegido
|
||||||
|
- Implemente limpeza automática de backups antigos
|
||||||
|
- Criptografe backups sensíveis
|
||||||
|
|
||||||
|
4. **Path Traversal**
|
||||||
|
- O código já implementa validação com `path.resolve()`
|
||||||
|
- Nunca permita caminhos arbitrários
|
||||||
|
|
||||||
|
### Exemplo de Middleware de Autorização:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
function isAdmin(request: NextRequest): boolean {
|
||||||
|
const token = request.headers.get('authorization');
|
||||||
|
// Validar se o token corresponde a um admin
|
||||||
|
return validateAdminToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
if (!isAdmin(request)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Apenas administradores podem criar backups' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// ... resto do código
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### Erro: "pg_dump: comando não encontrado"
|
||||||
|
|
||||||
|
**Solução:** Certifique-se de que PostgreSQL Tools está instalado no container ou host.
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
# Adicionar PostgreSQL client
|
||||||
|
RUN apk add --no-cache postgresql-client
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
# ... resto do Dockerfile
|
||||||
|
```
|
||||||
|
|
||||||
|
### Erro: "MinIO data não encontrado"
|
||||||
|
|
||||||
|
**Solução:** Verifique o caminho do volume do MinIO no docker-compose.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./minio_data:/data # Caminho correto no container
|
||||||
|
- ./backups:/app/.backups
|
||||||
|
```
|
||||||
|
|
||||||
|
### Erro 401 "Não autorizado"
|
||||||
|
|
||||||
|
**Solução:** Verifique se o token está sendo enviado corretamente.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Sempre enviar token
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
if (!token) {
|
||||||
|
console.error('Token não encontrado');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/api/backup', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📈 Melhorias Futuras
|
||||||
|
|
||||||
|
- [ ] Agendamento automático de backups (cron)
|
||||||
|
- [ ] Envio automático para S3/Cloud Storage
|
||||||
|
- [ ] Compressão em background
|
||||||
|
- [ ] Restauração de backups via interface
|
||||||
|
- [ ] Notificações via email
|
||||||
|
- [ ] Verificação de integridade de backup
|
||||||
|
- [ ] Versionamento de backups
|
||||||
|
- [ ] Limpeza automática de backups antigos
|
||||||
|
|
||||||
|
## 📞 Suporte
|
||||||
|
|
||||||
|
Para mais informações ou dúvidas sobre implementação, consulte a documentação oficial:
|
||||||
|
|
||||||
|
- [Next.js API Routes](https://nextjs.org/docs/app/building-your-application/routing/route-handlers)
|
||||||
|
- [PostgreSQL pg_dump](https://www.postgresql.org/docs/current/app-pgdump.html)
|
||||||
|
- [MinIO Client](https://docs.min.io/minio/baremetal/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Versão:** 1.0
|
||||||
|
**Data:** Novembro 2025
|
||||||
|
**Compatibilidade:** Next.js 13.4+ com App Router
|
||||||
36
docs/GIT_CREDENTIALS.md
Normal file
36
docs/GIT_CREDENTIALS.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Git Credentials - OCTTO Engenharia
|
||||||
|
|
||||||
|
## Repositório
|
||||||
|
- **URL:** https://git.stackbyte.cloud/erik/octto-engenharia.git
|
||||||
|
- **Usuário:** erik
|
||||||
|
- **Token:** 1ada354bbbf548b5ff2c2e2419d15368f3b70a05
|
||||||
|
|
||||||
|
## Configuração Git Automática
|
||||||
|
|
||||||
|
Para não precisar inserir credenciais toda vez, execute:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
git remote set-url origin https://erik:1ada354bbbf548b5ff2c2e2419d15368f3b70a05@git.stackbyte.cloud/erik/octto-engenharia.git
|
||||||
|
```
|
||||||
|
|
||||||
|
## Push Rápido
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
git add .
|
||||||
|
git commit -m "sua mensagem aqui"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuração SSH (Alternativa Segura)
|
||||||
|
|
||||||
|
Se preferir não armazenar credenciais em texto plano:
|
||||||
|
1. Gere uma chave SSH: `ssh-keygen -t ed25519`
|
||||||
|
2. Adicione a chave pública no Git (Settings > SSH Keys)
|
||||||
|
3. Configure: `git remote set-url origin git@git.stackbyte.cloud:erik/octto-engenharia.git`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Salvo em:** `docs/GIT_CREDENTIALS.md`
|
||||||
|
**Acesso:** Sempre que precisar fazer push, consulte este arquivo
|
||||||
34
docs/GOOGLE-SEARCH-CONSOLE.md
Normal file
34
docs/GOOGLE-SEARCH-CONSOLE.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Google Search Console Setup Guide
|
||||||
|
|
||||||
|
## Como registrar no Google Search Console
|
||||||
|
|
||||||
|
1. Acesse: https://search.google.com/search-console
|
||||||
|
2. Clique em "Adicionar propriedade"
|
||||||
|
3. Digite: `octto-engenharia.com`
|
||||||
|
4. Escolha "DNS" para verificar
|
||||||
|
5. Copie o registro TXT que aparece
|
||||||
|
6. Acesse seu painel de DNS (HostGator, 1&1, etc)
|
||||||
|
7. Adicione o registro TXT
|
||||||
|
8. Volte ao Google e clique em "Verificar"
|
||||||
|
9. Aguarde indexação (pode levar horas)
|
||||||
|
|
||||||
|
## Enviar Sitemap
|
||||||
|
|
||||||
|
Depois de verificado:
|
||||||
|
1. Vá para "Sitemaps"
|
||||||
|
2. Adicione: `https://octto-engenharia.com/sitemap.xml`
|
||||||
|
3. Google começa a indexar automaticamente
|
||||||
|
|
||||||
|
## Verificar Indexação
|
||||||
|
|
||||||
|
- Vá para "Cobertura" para ver quais páginas foram indexadas
|
||||||
|
- URLs em verde = indexadas com sucesso
|
||||||
|
- Vermelho = problemas
|
||||||
|
|
||||||
|
## Próximos Passos
|
||||||
|
|
||||||
|
- Solicitar indexação de URLs específicas em "Inspeção de URL"
|
||||||
|
- Acompanhar em "Performance" depois de alguns dias
|
||||||
|
- Criar conteúdo com boas palavras-chave
|
||||||
|
- Compartilhar no Google My Business (para aparecer em buscas locais)
|
||||||
|
|
||||||
64
docs/cobranca-pagamento.md
Normal file
64
docs/cobranca-pagamento.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
|
||||||
|
## 💰 FATURA DE COBRANÇA
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DETALHES DO SERVIÇO
|
||||||
|
|
||||||
|
**Prestador de Serviço:** IdealPages
|
||||||
|
**Cliente:** OCTTO Engenharia
|
||||||
|
**Data de Emissão:** 1 de Dezembro de 2025
|
||||||
|
**Data de Vencimento:** 4 de Dezembro de 2025
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📋 DESCRIÇÃO DO SERVIÇO
|
||||||
|
|
||||||
|
Implementação completa do sistema de gerenciamento dinâmico do site:
|
||||||
|
- Sistema de logotipo dinâmico
|
||||||
|
- Painel de configurações reorganizado (4 abas)
|
||||||
|
- Sistema de informações de contato dinâmicas
|
||||||
|
- Badge de prestador de serviço
|
||||||
|
- Sistema de backup completo
|
||||||
|
- Barra de admin inteligente
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 💵 VALORES
|
||||||
|
|
||||||
|
| Item | Valor |
|
||||||
|
|------|-------|
|
||||||
|
| Implementação do Sistema de Gerenciamento Dinâmico | R$ 2.700,00 |
|
||||||
|
| **TOTAL** | **R$ 2.700,00** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📅 CONDIÇÕES DE PAGAMENTO
|
||||||
|
|
||||||
|
**Vencimento:** 4 de Dezembro de 2025
|
||||||
|
**Método:** PIX / Transferência Bancária
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔗 LINK DE PAGAMENTO
|
||||||
|
|
||||||
|
**Pague via PIX clicando no link abaixo:**
|
||||||
|
|
||||||
|
👉 [https://pix.sejaefi.com.br/pagar/be73df383d9d78370e79e7f6f62af92b9a6415fb.html](https://pix.sejaefi.com.br/pagar/be73df383d9d78370e79e7f6f62af92b9a6415fb.html)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📞 CONTATO
|
||||||
|
|
||||||
|
**Email:** erik@idealpages.com.br
|
||||||
|
**Empresa:** IdealPages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Atenciosamente,**
|
||||||
|
**IdealPages**
|
||||||
|
*Desenvolvimento de Soluções Web*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Documento de cobrança referente aos serviços de desenvolvimento web prestados para OCTTO Engenharia.*
|
||||||
@@ -47,3 +47,282 @@
|
|||||||
- Opcional: reforçar validação server-side dos limites e criar testes automatizados para o fluxo de tradução.
|
- Opcional: reforçar validação server-side dos limites e criar testes automatizados para o fluxo de tradução.
|
||||||
|
|
||||||
Este resumo deve servir como onboarding rápido para qualquer pessoa ou nova IA que precise continuar o desenvolvimento.
|
Este resumo deve servir como onboarding rápido para qualquer pessoa ou nova IA que precise continuar o desenvolvimento.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CMS 1.1 - Atualizações (27/11/2025)
|
||||||
|
|
||||||
|
### 🔧 Correções de Infraestrutura
|
||||||
|
- Tipagem de `params` para Next.js 15 nas API routes (`/api/projects/[id]`, `/api/services/[id]`)
|
||||||
|
- Correção para usar `Promise<{ id: string }>` e `await params`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📊 Dashboard Admin Dinâmico
|
||||||
|
|
||||||
|
O dashboard agora exibe dados reais do banco de dados:
|
||||||
|
|
||||||
|
| Estatística | Descrição |
|
||||||
|
|-------------|-----------|
|
||||||
|
| Projetos | Total e quantidade de ativos |
|
||||||
|
| Mensagens | Total e quantidade não lidas |
|
||||||
|
| Serviços | Total e quantidade ativos |
|
||||||
|
|
||||||
|
**Novas funcionalidades:**
|
||||||
|
- Últimas 5 mensagens com iniciais do nome, tempo relativo e indicador de não lida
|
||||||
|
- Últimos 5 projetos com imagem de capa, categoria e badge de status
|
||||||
|
- Cards clicáveis que redirecionam para as páginas correspondentes
|
||||||
|
- Botões "Ver todos" funcionais
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🛠️ Módulo de Serviços (CRUD Completo)
|
||||||
|
|
||||||
|
#### Admin - Lista de Serviços (`/admin/servicos`)
|
||||||
|
- Dados dinâmicos da API `/api/services`
|
||||||
|
- Filtro por status (Todos/Ativos/Inativos)
|
||||||
|
- Busca por título e descrição
|
||||||
|
- Botões de Editar e Excluir
|
||||||
|
- Modal de confirmação antes de excluir
|
||||||
|
|
||||||
|
#### Admin - Novo Serviço (`/admin/servicos/novo`)
|
||||||
|
- Formulário conectado à API `POST /api/services`
|
||||||
|
- **Seletor visual de ícones em galeria** com busca em tempo real
|
||||||
|
- **70+ ícones** organizados por categoria:
|
||||||
|
- Veículos e Transporte
|
||||||
|
- Ferramentas e Engenharia
|
||||||
|
- Documentos e Laudos
|
||||||
|
- Segurança
|
||||||
|
- Construção e Equipamentos
|
||||||
|
- Inspeção e Verificação
|
||||||
|
- Geral
|
||||||
|
- Campos: título, ícone, status, ordem, descrição curta, descrição completa
|
||||||
|
|
||||||
|
#### Admin - Editar Serviço (`/admin/servicos/[id]/editar`) - **NOVO**
|
||||||
|
- Página criada do zero
|
||||||
|
- Carrega dados do serviço existente via `GET /api/services/:id`
|
||||||
|
- Atualiza via `PUT /api/services/:id`
|
||||||
|
- Mesmo seletor visual de ícones da página de criação
|
||||||
|
|
||||||
|
#### Páginas Públicas de Serviços
|
||||||
|
- `/servicos` e `/[locale]/servicos` agora são dinâmicas
|
||||||
|
- Exibe apenas serviços **ativos**
|
||||||
|
- Ordenados pelo campo `order`
|
||||||
|
- Fallback para dados estáticos se API vazia
|
||||||
|
- Loading state durante carregamento
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📁 Módulo de Projetos (Melhorias)
|
||||||
|
|
||||||
|
#### Admin - Lista de Projetos (`/admin/projetos`)
|
||||||
|
- Filtros por categoria e status
|
||||||
|
- Busca por título, cliente e descrição
|
||||||
|
- Botão de Editar em cada projeto
|
||||||
|
|
||||||
|
#### Admin - Editar Projeto (`/admin/projetos/[id]/editar`) - **NOVO**
|
||||||
|
- Página criada do zero
|
||||||
|
- Carrega dados do projeto existente
|
||||||
|
- Upload de imagem de capa
|
||||||
|
- Upload de galeria de imagens (até 8)
|
||||||
|
- Todos os campos editáveis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🎨 Melhorias de UX/UI
|
||||||
|
|
||||||
|
| Feature | Descrição |
|
||||||
|
|---------|-----------|
|
||||||
|
| Seletor de Ícones | Galeria visual clicável com busca em tempo real |
|
||||||
|
| Loading States | Indicadores visuais de carregamento em todas as páginas |
|
||||||
|
| Toasts | Feedback de sucesso e erro nas operações |
|
||||||
|
| Confirmação | Modal antes de ações destrutivas (exclusão) |
|
||||||
|
| Navegação | Links e botões funcionais em todo admin |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📁 Arquivos Modificados/Criados
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/app/admin/
|
||||||
|
├── page.tsx # Dashboard com dados reais
|
||||||
|
├── projetos/
|
||||||
|
│ ├── page.tsx # Lista com filtros e busca
|
||||||
|
│ └── [id]/editar/page.tsx # ✨ NOVO - Edição de projeto
|
||||||
|
└── servicos/
|
||||||
|
├── page.tsx # Lista com filtros e busca
|
||||||
|
├── novo/page.tsx # Seletor visual de ícones
|
||||||
|
└── [id]/editar/page.tsx # ✨ NOVO - Edição de serviço
|
||||||
|
|
||||||
|
frontend/src/app/
|
||||||
|
├── (public)/servicos/page.tsx # Página pública dinâmica
|
||||||
|
└── [locale]/servicos/page.tsx # Página com locale dinâmica
|
||||||
|
|
||||||
|
frontend/src/app/api/projects/[id]/
|
||||||
|
└── route.ts # Corrigido tipagem Next.js 15
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Branch**: `cms-1.1`
|
||||||
|
**Status**: ✅ Produção
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CMS 1.2 - Atualizações (28/11/2025)
|
||||||
|
|
||||||
|
### 📱 WhatsApp Dinâmico
|
||||||
|
|
||||||
|
#### API de Informações de Contato (`/api/contact-info`)
|
||||||
|
- Nova rota que busca número do WhatsApp dinamicamente do CMS
|
||||||
|
- Busca dados da página `contato` slug
|
||||||
|
- Cache de 1 minuto para otimizar performance
|
||||||
|
- Fallback para número padrão `(35) 9882-9445` com link `https://wa.me/5535988229445`
|
||||||
|
- Retorna JSON: `{ whatsapp: string, whatsappLink: string }`
|
||||||
|
|
||||||
|
#### Integração no Botão Flutuante
|
||||||
|
- `WhatsAppButton.tsx` agora busca número da API `/api/contact-info`
|
||||||
|
- Abre WhatsApp diretamente com número do CMS
|
||||||
|
- Exibe label traduzido `whatsapp.label`
|
||||||
|
|
||||||
|
#### Integração no Header
|
||||||
|
- Botão "Fale Conosco" no header desktop agora abre WhatsApp diretamente
|
||||||
|
- Menu mobile também integrado
|
||||||
|
- Ambos buscam número da API em tempo real
|
||||||
|
|
||||||
|
#### Tradução do Label
|
||||||
|
- Adicionada chave `whatsapp.label` em todos os locales:
|
||||||
|
- PT: "Fale Conosco"
|
||||||
|
- EN: "Contact Us"
|
||||||
|
- ES: "Contáctenos"
|
||||||
|
- Adicionada no `LanguageContext.tsx` para fallback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🌙 Dark Mode no Painel Admin
|
||||||
|
|
||||||
|
#### Novo Botão de Tema
|
||||||
|
- Adicionado botão sol/lua no header do painel admin
|
||||||
|
- Localizado ao lado das notificações
|
||||||
|
- Mesmo comportamento do botão no site público
|
||||||
|
- Integrado com `useTheme` hook
|
||||||
|
|
||||||
|
#### Funcionalidade
|
||||||
|
- Toggle claro/escuro funcional em todo o admin
|
||||||
|
- Persistência de preferência via `next-themes`
|
||||||
|
- Ícone muda conforme tema: `ri-sun-line` (dark) / `ri-moon-line` (light)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔗 Correção de Links do Dashboard
|
||||||
|
|
||||||
|
#### Links das Mensagens
|
||||||
|
- Card "Mensagens" → `/admin/mensagens`
|
||||||
|
- Botão "Ver todas" → `/admin/mensagens`
|
||||||
|
- Cada item de mensagem → `/admin/mensagens`
|
||||||
|
- Antes: apontavam para `/admin/contatos` (rota inexistente)
|
||||||
|
|
||||||
|
#### Estrutura de Rotas
|
||||||
|
- Confirmado que rota correta é `/admin/mensagens`
|
||||||
|
- Não existe `/admin/contatos` no projeto
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Correções Finais de WhatsApp
|
||||||
|
|
||||||
|
#### Formato Correto do Número
|
||||||
|
- **Número fornecido**: `+55 35 9882-9445`
|
||||||
|
- **Formato wa.me**: `5535988229445`
|
||||||
|
- `55` = código Brasil
|
||||||
|
- `35` = DDD
|
||||||
|
- `988229445` = número com 9 dígitos (padrão celular BR)
|
||||||
|
|
||||||
|
#### Atualização em Todos os Arquivos
|
||||||
|
- API `/api/contact-info/route.ts`
|
||||||
|
- Componente `WhatsAppButton.tsx`
|
||||||
|
- Componente `Header.tsx`
|
||||||
|
- Todos agora usam `5535988229445` como padrão
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📁 Arquivos Modificados (28/11)
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/app/api/
|
||||||
|
└── contact-info/
|
||||||
|
└── route.ts # ✨ NOVO - API WhatsApp dinâmico
|
||||||
|
|
||||||
|
frontend/src/components/
|
||||||
|
├── WhatsAppButton.tsx # Integração API contact-info
|
||||||
|
└── Header.tsx # Integração API contact-info
|
||||||
|
|
||||||
|
frontend/src/app/admin/
|
||||||
|
├── layout.tsx # Dark mode + links corrigidos
|
||||||
|
└── page.tsx # Links mensagens corrigidos
|
||||||
|
|
||||||
|
frontend/src/contexts/
|
||||||
|
└── LanguageContext.tsx # Adicionado whatsapp.label
|
||||||
|
|
||||||
|
frontend/src/locales/
|
||||||
|
├── pt.json # Adicionado whatsapp.label
|
||||||
|
├── en.json # Adicionado whatsapp.label
|
||||||
|
└── es.json # Adicionado whatsapp.label
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commits Realizados (28/11/2025)
|
||||||
|
|
||||||
|
### Commit 1: WhatsApp Dinâmico
|
||||||
|
```
|
||||||
|
feat: WhatsApp dinâmico do CMS
|
||||||
|
- Criada API /api/contact-info que busca número do CMS
|
||||||
|
- Header e botão flutuante agora puxam número dinamicamente
|
||||||
|
- Número padrão: (35) 9882-9445
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commit 2: Tradução WhatsApp
|
||||||
|
```
|
||||||
|
fix: WhatsApp label tradução e número correto (35) 9882-9445
|
||||||
|
- Adicionada chave whatsapp.label nos arquivos de locale (pt, en, es)
|
||||||
|
- Adicionada chave whatsapp.label no LanguageContext
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commit 3: Formato Correto
|
||||||
|
```
|
||||||
|
fix: número WhatsApp correto 5535988229445
|
||||||
|
- Corrigido número padrão em todos os arquivos
|
||||||
|
- Formato correto: 55 (Brasil) + 35 (DDD) + 988229445 (número)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commit 4: Dark Mode e Links
|
||||||
|
```
|
||||||
|
fix: dark mode no admin, links mensagens dashboard, WhatsApp correto
|
||||||
|
- Adicionado botão de dark mode no header do painel admin
|
||||||
|
- Corrigido links do dashboard: /admin/contatos -> /admin/mensagens
|
||||||
|
- Corrigido número WhatsApp: 5535988229445 (formato correto BR)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Status de Deployment
|
||||||
|
|
||||||
|
### Ambiente de Produção
|
||||||
|
- **Domínio**: www.octtoengenharia.com.br
|
||||||
|
- **Docker Compose**: `docker-compose.yml` (production)
|
||||||
|
- **Banco**: PostgreSQL `occto_db`
|
||||||
|
- **Storage**: MinIO `occto_minio`
|
||||||
|
- **Frontend**: `occto_frontend`
|
||||||
|
- **Network**: `dokploy-network`
|
||||||
|
|
||||||
|
### Infraestrutura
|
||||||
|
- **Versão PostgreSQL**: 12-alpine
|
||||||
|
- **Versão MinIO**: RELEASE.2023-09-04T19-57-37Z
|
||||||
|
- **Framework**: Next.js 15.1
|
||||||
|
- **ORM**: Prisma
|
||||||
|
- **Deploy Platform**: Dokploy (com auto-deploy)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Branch**: `cms-1.1`
|
||||||
|
**Status**: ✅ Produção (Deploy 28/11/2025)
|
||||||
|
|||||||
121
docs/diario-de-bordo/teste.html
Normal file
121
docs/diario-de-bordo/teste.html
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Fatura de Cobrança - IdealPages</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 20px;">
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" style="background-color: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 20px; text-align: center; color: white;">
|
||||||
|
<h1 style="margin: 0 0 10px 0; font-size: 28px; font-weight: bold;">💰 FATURA DE COBRANÇA</h1>
|
||||||
|
<p style="margin: 0; font-size: 14px; opacity: 0.9;">IdealPages - Desenvolvimento de Soluções Web</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px 30px;">
|
||||||
|
<!-- Details -->
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom: 40px; border-bottom: 1px solid #eee; padding-bottom: 40px;">
|
||||||
|
<tr>
|
||||||
|
<td width="50%" style="padding-bottom: 20px;">
|
||||||
|
<p style="margin: 0 0 5px 0; font-size: 12px; color: #999; text-transform: uppercase; font-weight: bold;">Prestador de Serviço</p>
|
||||||
|
<p style="margin: 0; font-size: 15px; color: #333; font-weight: bold;">IdealPages</p>
|
||||||
|
</td>
|
||||||
|
<td width="50%" style="padding-bottom: 20px;">
|
||||||
|
<p style="margin: 0 0 5px 0; font-size: 12px; color: #999; text-transform: uppercase; font-weight: bold;">Cliente</p>
|
||||||
|
<p style="margin: 0; font-size: 15px; color: #333; font-weight: bold;">OCTTO Engenharia</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="50%">
|
||||||
|
<p style="margin: 0 0 5px 0; font-size: 12px; color: #999; text-transform: uppercase; font-weight: bold;">Data de Emissão</p>
|
||||||
|
<p style="margin: 0; font-size: 15px; color: #333;">1 de Dezembro de 2025</p>
|
||||||
|
</td>
|
||||||
|
<td width="50%">
|
||||||
|
<p style="margin: 0 0 5px 0; font-size: 12px; color: #999; text-transform: uppercase; font-weight: bold;">Data de Vencimento</p>
|
||||||
|
<p style="margin: 0; font-size: 15px; color: #d9534f; font-weight: bold;">4 de Dezembro de 2025</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Services -->
|
||||||
|
<h2 style="margin: 0 0 15px 0; font-size: 16px; font-weight: bold; color: #333; padding-bottom: 10px; border-bottom: 2px solid #667eea;">📋 Serviços Prestados</h2>
|
||||||
|
<ul style="margin: 0 0 30px 0; padding: 0; list-style: none;">
|
||||||
|
<li style="padding: 8px 0; color: #555; font-size: 14px;"><span style="color: #667eea; font-weight: bold; margin-right: 10px;">✓</span>Desenvolvimento do site institucional</li>
|
||||||
|
<li style="padding: 8px 0; color: #555; font-size: 14px;"><span style="color: #667eea; font-weight: bold; margin-right: 10px;">✓</span>Domínio incluso (R$ 40,00)</li>
|
||||||
|
<li style="padding: 8px 0; color: #555; font-size: 14px;"><span style="color: #667eea; font-weight: bold; margin-right: 10px;">✓</span>Hospedagem e suporte grátis por 6 meses (R$ 149,99/mês)</li>
|
||||||
|
|
||||||
|
<li style="padding: 8px 0; color: #555; font-size: 14px;"><span style="color: #667eea; font-weight: bold; margin-right: 10px;">✓</span>Sistema de logotipo dinâmico</li>
|
||||||
|
<li style="padding: 8px 0; color: #555; font-size: 14px;"><span style="color: #667eea; font-weight: bold; margin-right: 10px;">✓</span>Painel de configurações reorganizado (4 abas)</li>
|
||||||
|
<li style="padding: 8px 0; color: #555; font-size: 14px;"><span style="color: #667eea; font-weight: bold; margin-right: 10px;">✓</span>Sistema de informações de contato dinâmicas</li>
|
||||||
|
<li style="padding: 8px 0; color: #555; font-size: 14px;"><span style="color: #667eea; font-weight: bold; margin-right: 10px;">✓</span>Badge de prestador de serviço</li>
|
||||||
|
<li style="padding: 8px 0; color: #555; font-size: 14px;"><span style="color: #667eea; font-weight: bold; margin-right: 10px;">✓</span>Sistema de backup completo</li>
|
||||||
|
<li style="padding: 8px 0; color: #555; font-size: 14px;"><span style="color: #667eea; font-weight: bold; margin-right: 10px;">✓</span>Barra de admin inteligente</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Pricing -->
|
||||||
|
<h2 style="margin: 30px 0 15px 0; font-size: 16px; font-weight: bold; color: #333; padding-bottom: 10px; border-bottom: 2px solid #667eea;">💵 Valores</h2>
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0; background: #f9f9f9; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<th style="background: #667eea; color: white; padding: 12px; text-align: left; font-weight: bold; border: 1px solid #ddd;">Descrição</th>
|
||||||
|
<th style="background: #667eea; color: white; padding: 12px; text-align: right; font-weight: bold; border: 1px solid #ddd;">Valor</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border: 1px solid #eee;">Implementação do Sistema de Gerenciamento Dinâmico</td>
|
||||||
|
<td style="padding: 12px; text-align: right; border: 1px solid #eee;">R$ 2.700,00</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Economia aplicada (não altera o valor do PIX) -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; border: 1px solid #eee; color: #2f6b2f; font-weight: bold;">Economia aplicada: Hospedagem + Suporte (6× R$ 149,99) + Domínio (R$ 40,00)</td>
|
||||||
|
<td style="padding: 12px; text-align: right; border: 1px solid #eee; color: #2f6b2f; font-weight: bold;">- R$ 939,94</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 12px; background: #f0f0f0; font-weight: bold; border: 1px solid #eee;">TOTAL A PAGAR</td>
|
||||||
|
<td style="padding: 12px; text-align: right; background: #f0f0f0; font-weight: bold; border: 1px solid #eee;"><span style="color: #667eea; font-size: 20px;">R$ 2.700,00</span></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Savings note -->
|
||||||
|
<div style="background: #e8f3ff; border-left: 4px solid #4da3ff; padding: 12px 15px; margin: 10px 0 25px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; color: #244; font-size: 13px;">
|
||||||
|
Economia total para os 6 meses: <strong>R$ 939,94</strong> (R$ 149,99/mês de hospedagem + suporte + domínio R$ 40,00).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Warning -->
|
||||||
|
<div style="background: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; color: #856404; font-size: 14px;"><strong>⚠️ Vencimento:</strong> 4 de Dezembro de 2025</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Payment -->
|
||||||
|
<div style="background: #f0f7ff; padding: 25px; margin: 30px 0; text-align: center; border-radius: 8px;">
|
||||||
|
<h3 style="margin: 0 0 15px 0; color: #333; font-size: 16px;">🔗 LINK DE PAGAMENTO</h3>
|
||||||
|
<p style="color: #666; margin: 0 0 15px 0; font-size: 14px;">Clique no botão abaixo para pagar via PIX</p>
|
||||||
|
<a href="https://pix.sejaefi.com.br/pagar/be73df383d9d78370e79e7f6f62af92b9a6415fb.html" style="display: inline-block; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 15px 40px; border-radius: 5px; text-decoration: none; font-weight: bold; font-size: 16px;">PAGAR AGORA</a>
|
||||||
|
<p style="color: #667eea; text-decoration: underline; font-size: 12px; margin: 10px 0 0 0; word-break: break-all;">https://pix.sejaefi.com.br/pagar/be73df383d9d78370e79e7f6f62af92b9a6415fb.html</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background: #f5f5f5; padding: 30px 20px; text-align: center; border-top: 1px solid #eee;">
|
||||||
|
<h4 style="margin: 0 0 10px 0; color: #333; font-size: 14px; font-weight: bold;">IdealPages</h4>
|
||||||
|
<p style="margin: 0 0 5px 0; color: #666; font-size: 13px;">📧 Email: erik@idealpages.com.br</p>
|
||||||
|
<p style="margin: 0 0 15px 0; color: #666; font-size: 13px;">🌐 Desenvolvimento de Soluções Web</p>
|
||||||
|
<p style="margin: 0; color: #999; font-size: 11px; border-top: 1px solid #ddd; padding-top: 15px;">Documento de cobrança referente aos serviços de desenvolvimento web prestados para OCTTO Engenharia.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
104
docs/e-mail-final.md
Normal file
104
docs/e-mail-final.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
**IdealPages**
|
||||||
|
Desenvolvimento de Soluções Web
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prezado Cliente,
|
||||||
|
|
||||||
|
Segue resumo das atividades realizadas e concluídas em seu site institucional:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📋 SERVIÇOS IMPLEMENTADOS
|
||||||
|
|
||||||
|
#### 1. **Sistema de Logotipo Dinâmico**
|
||||||
|
- Implementação de upload de logotipo através do painel administrativo
|
||||||
|
- O logotipo é automaticamente exibido em todo o site (cabeçalho, rodapé e painel admin)
|
||||||
|
- Substituição automática do ícone padrão pelo logotipo personalizado
|
||||||
|
- Fallback inteligente mantém ícone caso nenhum logotipo seja configurado
|
||||||
|
|
||||||
|
#### 2. **Painel de Configurações Reorganizado**
|
||||||
|
- **4 abas funcionais para fácil gerenciamento:**
|
||||||
|
- **Personalização**: Cores e branding do site
|
||||||
|
- **Logotipo**: Upload e gerenciamento do logo
|
||||||
|
- **Contato**: Telefone, WhatsApp, email e endereço atualizados em tempo real
|
||||||
|
- **Backup**: Sistema completo de backup e restauração
|
||||||
|
|
||||||
|
#### 3. **Sistema de Informações de Contato Dinâmicas**
|
||||||
|
- Telefone, WhatsApp, email e endereço gerenciáveis via painel admin
|
||||||
|
- Exibição automática na página de contato
|
||||||
|
- Atualização em tempo real (sem necessidade de recarregar o site)
|
||||||
|
- Integração com rodapé do site
|
||||||
|
|
||||||
|
#### 4. **Badge de Prestador de Serviço**
|
||||||
|
- Sistema de mostrar/ocultar badge de "Prestador de Serviço Oficial"
|
||||||
|
- Personalizável com nome da marca parceira
|
||||||
|
- Visível no hero da página inicial e no rodapé
|
||||||
|
|
||||||
|
#### 5. **Sistema de Backup Completo**
|
||||||
|
- Backup automático do banco de dados PostgreSQL
|
||||||
|
- Backup de arquivos de mídia do MinIO
|
||||||
|
- Compressão automática em .tar.gz
|
||||||
|
- Opção de download local ou upload para nuvem
|
||||||
|
- Sistema de restauração rápida
|
||||||
|
|
||||||
|
#### 6. **Barra de Admin Inteligente**
|
||||||
|
- Indicador visual quando usuário está logado
|
||||||
|
- Acesso rápido ao painel administrativo
|
||||||
|
- Aparece apenas para usuários autenticados
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ FUNCIONALIDADES EM OPERAÇÃO
|
||||||
|
|
||||||
|
✓ Logotipo dinâmico em Header, Footer e Painel Admin
|
||||||
|
✓ Página de contato atualizada automaticamente com dados das Settings
|
||||||
|
✓ Formulário de contato funcional e integrado
|
||||||
|
✓ Sistema de backup e restauração completo
|
||||||
|
✓ Painel admin com 4 abas organizadas e intuitivas
|
||||||
|
✓ Badge de parceria configurável e dinâmico
|
||||||
|
✓ Atualização em tempo real sem recarregar página
|
||||||
|
✓ Suporte a múltiplos idiomas (PT, EN, ES)
|
||||||
|
✓ Modo claro e escuro em toda interface
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 💰 DETALHES DA COBRANÇA
|
||||||
|
|
||||||
|
**Valor Total:** R$ 2.700,00
|
||||||
|
**Vencimento:** 4 de Dezembro de 2025
|
||||||
|
|
||||||
|
**Descrição do Serviço:**
|
||||||
|
Implementação completa do sistema de gerenciamento dinâmico do site (Logo, Configurações, Contato e Backup)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📞 PRÓXIMOS PASSOS
|
||||||
|
|
||||||
|
1. Acesse o painel admin em `https://octtoengenharia.com.br/acesso`
|
||||||
|
2. Faça login com as credenciais padrão:
|
||||||
|
- **Email:** admin@occto.com
|
||||||
|
- **Senha:** admin
|
||||||
|
3. Vá para **Configurações > Logotipo** e faça upload do seu logo
|
||||||
|
4. Configure as informações de contato em **Configurações > Contato**
|
||||||
|
5. Personalize as cores em **Configurações > Personalização**
|
||||||
|
6. Crie backups regularmente em **Configurações > Backup**
|
||||||
|
|
||||||
|
⚠️ **Importante:** Recomendamos alterar a senha padrão na primeira acesso.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📧 SUPORTE
|
||||||
|
|
||||||
|
Em caso de dúvidas ou necessidade de ajustes, entre em contato:
|
||||||
|
|
||||||
|
📧 **Email:** erik@idealpages.com.br
|
||||||
|
🌐 **Empresa:** IdealPages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Atenciosamente,**
|
||||||
|
**IdealPages**
|
||||||
|
*Desenvolvimento de Soluções Web*
|
||||||
|
|
||||||
|
---
|
||||||
@@ -87,3 +87,22 @@ model Translation {
|
|||||||
@@unique([sourceText, sourceLang, targetLang])
|
@@unique([sourceText, sourceLang, targetLang])
|
||||||
@@index([sourceLang, targetLang])
|
@@index([sourceLang, targetLang])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Modelo de Configurações Globais
|
||||||
|
model Settings {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
showPartnerBadge Boolean @default(false)
|
||||||
|
partnerName String @default("Coca-Cola")
|
||||||
|
// Logotipo
|
||||||
|
logo String? // URL do logotipo
|
||||||
|
// Informações de Contato
|
||||||
|
address String? // Endereço completo
|
||||||
|
phone String? // Telefone
|
||||||
|
email String? // Email
|
||||||
|
// Redes Sociais
|
||||||
|
instagram String? // URL Instagram
|
||||||
|
linkedin String? // URL LinkedIn
|
||||||
|
facebook String? // URL Facebook
|
||||||
|
whatsapp String? // Número WhatsApp
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|||||||
24
frontend/public/robots.txt
Normal file
24
frontend/public/robots.txt
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Octto Engenharia - SEO Configuration
|
||||||
|
# https://www.robotstxt.org/
|
||||||
|
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
Disallow: /admin
|
||||||
|
Disallow: /api
|
||||||
|
Disallow: /*.pdf
|
||||||
|
Disallow: /*.jpg
|
||||||
|
|
||||||
|
# Specific crawlers
|
||||||
|
User-agent: Googlebot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Bingbot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
# Sitemap
|
||||||
|
Sitemap: https://octtoengenharia.com.br/sitemap.xml
|
||||||
|
Sitemap: https://octtoengenharia.com.br/en/sitemap.xml
|
||||||
|
Sitemap: https://octtoengenharia.com.br/es/sitemap.xml
|
||||||
|
|
||||||
|
# Crawl delay (optional)
|
||||||
|
Crawl-delay: 1
|
||||||
@@ -26,9 +26,17 @@ interface ContactContent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SettingsData {
|
||||||
|
address?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
whatsapp?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ContatoPage() {
|
export default function ContatoPage() {
|
||||||
const { success, error: showError } = useToast();
|
const { success, error: showError } = useToast();
|
||||||
const [content, setContent] = useState<ContactContent | null>(null);
|
const [content, setContent] = useState<ContactContent | null>(null);
|
||||||
|
const [settings, setSettings] = useState<SettingsData>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -44,6 +52,7 @@ export default function ContatoPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchContent();
|
fetchContent();
|
||||||
|
fetchSettings();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchContent = async () => {
|
const fetchContent = async () => {
|
||||||
@@ -62,6 +71,23 @@ export default function ContatoPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchSettings = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setSettings({
|
||||||
|
address: data.address,
|
||||||
|
phone: data.phone,
|
||||||
|
email: data.email,
|
||||||
|
whatsapp: data.whatsapp
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao carregar configurações:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -103,30 +129,52 @@ export default function ContatoPage() {
|
|||||||
title: 'Informações',
|
title: 'Informações',
|
||||||
subtitle: 'Como nos encontrar',
|
subtitle: 'Como nos encontrar',
|
||||||
description: 'Estamos à disposição para atender sua empresa com a excelência técnica que seu projeto exige.',
|
description: 'Estamos à disposição para atender sua empresa com a excelência técnica que seu projeto exige.',
|
||||||
items: [
|
items: [] as ContactInfo[]
|
||||||
{
|
};
|
||||||
|
|
||||||
|
// Montar items dinamicamente baseado nas configurações (Settings)
|
||||||
|
const contactItems: ContactInfo[] = [];
|
||||||
|
|
||||||
|
if (settings.whatsapp) {
|
||||||
|
contactItems.push({
|
||||||
icon: 'ri-whatsapp-line',
|
icon: 'ri-whatsapp-line',
|
||||||
|
title: 'WhatsApp',
|
||||||
|
description: 'Atendimento rápido e direto',
|
||||||
|
link: `https://wa.me/55${settings.whatsapp.replace(/\D/g, '')}`,
|
||||||
|
linkText: settings.whatsapp
|
||||||
|
});
|
||||||
|
} else if (settings.phone) {
|
||||||
|
contactItems.push({
|
||||||
|
icon: 'ri-phone-line',
|
||||||
title: 'Telefone',
|
title: 'Telefone',
|
||||||
description: 'Atendimento de segunda a sexta, das 8h às 18h',
|
description: 'Atendimento de segunda a sexta, das 8h às 18h',
|
||||||
link: 'https://wa.me/5527999999999',
|
link: `tel:${settings.phone.replace(/\D/g, '')}`,
|
||||||
linkText: '(27) 99999-9999'
|
linkText: settings.phone
|
||||||
},
|
});
|
||||||
{
|
}
|
||||||
|
|
||||||
|
if (settings.email) {
|
||||||
|
contactItems.push({
|
||||||
icon: 'ri-mail-send-line',
|
icon: 'ri-mail-send-line',
|
||||||
title: 'E-mail',
|
title: 'E-mail',
|
||||||
description: 'Responderemos em até 24 horas úteis',
|
description: 'Envie sua mensagem',
|
||||||
link: 'mailto:contato@octto.com.br',
|
link: `mailto:${settings.email}`,
|
||||||
linkText: 'contato@octto.com.br'
|
linkText: settings.email
|
||||||
},
|
});
|
||||||
{
|
}
|
||||||
|
|
||||||
|
if (settings.address) {
|
||||||
|
contactItems.push({
|
||||||
icon: 'ri-map-pin-line',
|
icon: 'ri-map-pin-line',
|
||||||
title: 'Endereço',
|
title: 'Endereço',
|
||||||
description: 'Av. Nossa Senhora da Penha, 1234\nSanta Lúcia, Vitória - ES\nCEP: 29056-000',
|
description: settings.address,
|
||||||
link: 'https://maps.google.com',
|
link: `https://maps.google.com/maps?q=${encodeURIComponent(settings.address)}`,
|
||||||
linkText: 'Ver no mapa'
|
linkText: 'Ver no mapa'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
]
|
|
||||||
};
|
// Sempre usar os dados das Settings (contactItems)
|
||||||
|
const displayItems = contactItems;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="bg-white dark:bg-secondary transition-colors duration-300">
|
<main className="bg-white dark:bg-secondary transition-colors duration-300">
|
||||||
@@ -165,7 +213,8 @@ export default function ContatoPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{info.items.map((item, index) => (
|
{displayItems.length > 0 ? (
|
||||||
|
displayItems.map((item, index) => (
|
||||||
<div key={index} className="group bg-gray-50 dark:bg-white/5 p-6 rounded-2xl border border-gray-100 dark:border-white/10 hover:border-primary/50 transition-colors">
|
<div key={index} className="group bg-gray-50 dark:bg-white/5 p-6 rounded-2xl border border-gray-100 dark:border-white/10 hover:border-primary/50 transition-colors">
|
||||||
<div className="flex items-start gap-5">
|
<div className="flex items-start gap-5">
|
||||||
<div className="w-14 h-14 bg-white dark:bg-white/10 rounded-xl flex items-center justify-center text-primary shadow-sm group-hover:scale-110 transition-transform duration-300">
|
<div className="w-14 h-14 bg-white dark:bg-white/10 rounded-xl flex items-center justify-center text-primary shadow-sm group-hover:scale-110 transition-transform duration-300">
|
||||||
@@ -174,13 +223,19 @@ export default function ContatoPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-2">{item.title}</h4>
|
<h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-2">{item.title}</h4>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-3 text-sm whitespace-pre-line">{item.description}</p>
|
<p className="text-gray-600 dark:text-gray-400 mb-3 text-sm whitespace-pre-line">{item.description}</p>
|
||||||
<a href={item.link} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2 text-primary font-bold hover:gap-3 transition-all">
|
<a href={item.link} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2 text-primary font-bold hover:gap-3 transition-all cursor-pointer">
|
||||||
{item.linkText} <i className="ri-arrow-right-line"></i>
|
{item.linkText} <i className="ri-arrow-right-line"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-gray-500 dark:text-gray-400 py-8">
|
||||||
|
<i className="ri-information-line text-4xl mb-2 block"></i>
|
||||||
|
<p>Informações de contato não configuradas</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePageContent } from "@/hooks/usePageContent";
|
import { usePageContent } from "@/hooks/usePageContent";
|
||||||
|
import { PartnerBadge } from "@/components/PartnerBadge";
|
||||||
|
|
||||||
type PortfolioProject = {
|
type PortfolioProject = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -164,9 +165,8 @@ export default function Home() {
|
|||||||
|
|
||||||
<div className="container mx-auto px-4 relative z-20">
|
<div className="container mx-auto px-4 relative z-20">
|
||||||
<div className="max-w-3xl">
|
<div className="max-w-3xl">
|
||||||
<div className="inline-flex items-center gap-3 bg-white/10 backdrop-blur-md border border-white/20 rounded-full px-5 py-2 mb-8 hover:bg-white/20 transition-colors cursor-default">
|
<div className="mb-8">
|
||||||
<i className="ri-verified-badge-fill text-primary text-xl"></i>
|
<PartnerBadge />
|
||||||
<span className="text-sm font-bold tracking-wider uppercase text-white">Prestador de Serviço Oficial <span className="text-primary">Coca-Cola</span></span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-5xl md:text-6xl font-bold font-headline mb-6 leading-tight">
|
<h1 className="text-5xl md:text-6xl font-bold font-headline mb-6 leading-tight">
|
||||||
@@ -195,7 +195,7 @@ export default function Home() {
|
|||||||
<h3 className="text-4xl font-bold font-headline text-secondary dark:text-white">{features.title}</h3>
|
<h3 className="text-4xl font-bold font-headline text-secondary dark:text-white">{features.title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
{features.items.map((feature, index) => (
|
{features.items.map((feature: { icon: string; title: string; description: string }, index: number) => (
|
||||||
<div key={index} className="p-8 bg-gray-50 dark:bg-white/5 rounded-xl hover:shadow-lg transition-shadow border border-gray-100 dark:border-white/10 group">
|
<div key={index} className="p-8 bg-gray-50 dark:bg-white/5 rounded-xl hover:shadow-lg transition-shadow border border-gray-100 dark:border-white/10 group">
|
||||||
<div className="w-14 h-14 bg-primary/10 rounded-lg flex items-center justify-center text-primary mb-6 group-hover:bg-primary group-hover:text-white transition-colors">
|
<div className="w-14 h-14 bg-primary/10 rounded-lg flex items-center justify-center text-primary mb-6 group-hover:bg-primary group-hover:text-white transition-colors">
|
||||||
<i className={`${feature.icon} text-3xl`}></i>
|
<i className={`${feature.icon} text-3xl`}></i>
|
||||||
@@ -218,7 +218,7 @@ export default function Home() {
|
|||||||
<h3 className="text-4xl font-bold font-headline text-secondary dark:text-white">{services.title}</h3>
|
<h3 className="text-4xl font-bold font-headline text-secondary dark:text-white">{services.title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
{services.items.map((service, index) => (
|
{services.items.map((service: { icon: string; title: string; description: string }, index: number) => (
|
||||||
<div key={index} className="bg-white dark:bg-secondary p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow border-b-4 border-transparent hover:border-primary">
|
<div key={index} className="bg-white dark:bg-secondary p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow border-b-4 border-transparent hover:border-primary">
|
||||||
<i className={`${service.icon} text-4xl text-primary mb-4 block`}></i>
|
<i className={`${service.icon} text-4xl text-primary mb-4 block`}></i>
|
||||||
<h4 className="text-xl font-bold font-headline mb-2 text-secondary dark:text-white">{service.title}</h4>
|
<h4 className="text-xl font-bold font-headline mb-2 text-secondary dark:text-white">{service.title}</h4>
|
||||||
@@ -250,7 +250,7 @@ export default function Home() {
|
|||||||
{about.description}
|
{about.description}
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-4 mb-8">
|
<ul className="space-y-4 mb-8">
|
||||||
{about.highlights.map((highlight, index) => (
|
{about.highlights.map((highlight: string, index: number) => (
|
||||||
<li key={index} className="flex items-center gap-3">
|
<li key={index} className="flex items-center gap-3">
|
||||||
<i className="ri-check-double-line text-primary text-xl"></i>
|
<i className="ri-check-double-line text-primary text-xl"></i>
|
||||||
<span>{highlight}</span>
|
<span>{highlight}</span>
|
||||||
@@ -314,7 +314,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
{testimonials.items.map((testimonial, index) => (
|
{testimonials.items.map((testimonial: { text: string; name: string; role: string }, index: number) => (
|
||||||
<div key={index} className="bg-white dark:bg-secondary p-8 rounded-xl shadow-sm border border-gray-100 dark:border-white/10 relative">
|
<div key={index} className="bg-white dark:bg-secondary p-8 rounded-xl shadow-sm border border-gray-100 dark:border-white/10 relative">
|
||||||
<i className="ri-double-quotes-l text-4xl text-primary/20 absolute top-6 left-6"></i>
|
<i className="ri-double-quotes-l text-4xl text-primary/20 absolute top-6 left-6"></i>
|
||||||
<p className="text-gray-600 dark:text-gray-300 mb-6 relative z-10 pt-6 italic">"{testimonial.text}"</p>
|
<p className="text-gray-600 dark:text-gray-300 mb-6 relative z-10 pt-6 italic">"{testimonial.text}"</p>
|
||||||
|
|||||||
@@ -26,10 +26,18 @@ interface ContactContent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SettingsData {
|
||||||
|
address?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
whatsapp?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ContatoPage() {
|
export default function ContatoPage() {
|
||||||
const { success, error: showError } = useToast();
|
const { success, error: showError } = useToast();
|
||||||
const { locale, t } = useLocale();
|
const { locale, t } = useLocale();
|
||||||
const [content, setContent] = useState<ContactContent | null>(null);
|
const [content, setContent] = useState<ContactContent | null>(null);
|
||||||
|
const [settings, setSettings] = useState<SettingsData>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -42,6 +50,7 @@ export default function ContatoPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchContent();
|
fetchContent();
|
||||||
|
fetchSettings();
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
const fetchContent = async () => {
|
const fetchContent = async () => {
|
||||||
@@ -61,6 +70,23 @@ export default function ContatoPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchSettings = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setSettings({
|
||||||
|
address: data.address,
|
||||||
|
phone: data.phone,
|
||||||
|
email: data.email,
|
||||||
|
whatsapp: data.whatsapp
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao carregar configurações:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -102,30 +128,52 @@ export default function ContatoPage() {
|
|||||||
title: t('contact.infoTitle'),
|
title: t('contact.infoTitle'),
|
||||||
subtitle: t('contact.infoSubtitle'),
|
subtitle: t('contact.infoSubtitle'),
|
||||||
description: t('contact.infoDescription'),
|
description: t('contact.infoDescription'),
|
||||||
items: [
|
items: [] as ContactInfo[]
|
||||||
{
|
};
|
||||||
|
|
||||||
|
// Montar items dinamicamente baseado nas configurações (Settings)
|
||||||
|
const contactItems: ContactInfo[] = [];
|
||||||
|
|
||||||
|
if (settings.whatsapp) {
|
||||||
|
contactItems.push({
|
||||||
icon: 'ri-whatsapp-line',
|
icon: 'ri-whatsapp-line',
|
||||||
|
title: 'WhatsApp',
|
||||||
|
description: 'Atendimento rápido e direto',
|
||||||
|
link: `https://wa.me/55${settings.whatsapp.replace(/\D/g, '')}`,
|
||||||
|
linkText: settings.whatsapp
|
||||||
|
});
|
||||||
|
} else if (settings.phone) {
|
||||||
|
contactItems.push({
|
||||||
|
icon: 'ri-phone-line',
|
||||||
title: t('contact.phone'),
|
title: t('contact.phone'),
|
||||||
description: t('contact.phoneDescription'),
|
description: 'Atendimento de segunda a sexta, das 8h às 18h',
|
||||||
link: 'https://wa.me/5527999999999',
|
link: `tel:${settings.phone.replace(/\D/g, '')}`,
|
||||||
linkText: '(27) 99999-9999'
|
linkText: settings.phone
|
||||||
},
|
});
|
||||||
{
|
}
|
||||||
|
|
||||||
|
if (settings.email) {
|
||||||
|
contactItems.push({
|
||||||
icon: 'ri-mail-send-line',
|
icon: 'ri-mail-send-line',
|
||||||
title: t('contact.email'),
|
title: 'E-mail',
|
||||||
description: t('contact.emailDescription'),
|
description: 'Envie sua mensagem',
|
||||||
link: 'mailto:contato@octto.com.br',
|
link: `mailto:${settings.email}`,
|
||||||
linkText: 'contato@octto.com.br'
|
linkText: settings.email
|
||||||
},
|
});
|
||||||
{
|
}
|
||||||
|
|
||||||
|
if (settings.address) {
|
||||||
|
contactItems.push({
|
||||||
icon: 'ri-map-pin-line',
|
icon: 'ri-map-pin-line',
|
||||||
title: t('contact.address'),
|
title: t('contact.address'),
|
||||||
description: t('contact.addressDescription'),
|
description: settings.address,
|
||||||
link: 'https://maps.google.com',
|
link: `https://maps.google.com/maps?q=${encodeURIComponent(settings.address)}`,
|
||||||
linkText: t('contact.viewOnMap')
|
linkText: t('contact.viewOnMap')
|
||||||
|
});
|
||||||
}
|
}
|
||||||
]
|
|
||||||
};
|
// Sempre usar os dados das Settings (contactItems)
|
||||||
|
const displayItems = contactItems;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="bg-white dark:bg-secondary transition-colors duration-300">
|
<main className="bg-white dark:bg-secondary transition-colors duration-300">
|
||||||
@@ -164,7 +212,8 @@ export default function ContatoPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{info.items.map((item, index) => (
|
{displayItems.length > 0 ? (
|
||||||
|
displayItems.map((item, index) => (
|
||||||
<div key={index} className="group bg-gray-50 dark:bg-white/5 p-6 rounded-2xl border border-gray-100 dark:border-white/10 hover:border-primary/50 transition-colors">
|
<div key={index} className="group bg-gray-50 dark:bg-white/5 p-6 rounded-2xl border border-gray-100 dark:border-white/10 hover:border-primary/50 transition-colors">
|
||||||
<div className="flex items-start gap-5">
|
<div className="flex items-start gap-5">
|
||||||
<div className="w-14 h-14 bg-white dark:bg-white/10 rounded-xl flex items-center justify-center text-primary shadow-sm group-hover:scale-110 transition-transform duration-300">
|
<div className="w-14 h-14 bg-white dark:bg-white/10 rounded-xl flex items-center justify-center text-primary shadow-sm group-hover:scale-110 transition-transform duration-300">
|
||||||
@@ -173,13 +222,19 @@ export default function ContatoPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-2">{item.title}</h4>
|
<h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-2">{item.title}</h4>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-3 text-sm whitespace-pre-line">{item.description}</p>
|
<p className="text-gray-600 dark:text-gray-400 mb-3 text-sm whitespace-pre-line">{item.description}</p>
|
||||||
<a href={item.link} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2 text-primary font-bold hover:gap-3 transition-all">
|
<a href={item.link} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2 text-primary font-bold hover:gap-3 transition-all cursor-pointer">
|
||||||
{item.linkText} <i className="ri-arrow-right-line"></i>
|
{item.linkText} <i className="ri-arrow-right-line"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-gray-500 dark:text-gray-400 py-8">
|
||||||
|
<i className="ri-information-line text-4xl mb-2 block"></i>
|
||||||
|
<p>{t('contact.noInfoConfigured')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePageContent } from "@/hooks/usePageContent";
|
import { usePageContent } from "@/hooks/usePageContent";
|
||||||
import { useLocale } from "@/contexts/LocaleContext";
|
import { useLocale } from "@/contexts/LocaleContext";
|
||||||
|
import { PartnerBadge } from "@/components/PartnerBadge";
|
||||||
|
|
||||||
type PortfolioProject = {
|
type PortfolioProject = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -163,9 +164,8 @@ export default function Home() {
|
|||||||
|
|
||||||
<div className="container mx-auto px-4 relative z-20">
|
<div className="container mx-auto px-4 relative z-20">
|
||||||
<div className="max-w-3xl">
|
<div className="max-w-3xl">
|
||||||
<div className="inline-flex items-center gap-3 bg-white/10 backdrop-blur-md border border-white/20 rounded-full px-5 py-2 mb-8 hover:bg-white/20 transition-colors cursor-default">
|
<div className="mb-8">
|
||||||
<i className="ri-verified-badge-fill text-primary text-xl"></i>
|
<PartnerBadge />
|
||||||
<span className="text-sm font-bold tracking-wider uppercase text-white">{t('home.officialProvider')} <span className="text-primary">Coca-Cola</span></span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-5xl md:text-6xl font-bold font-headline mb-6 leading-tight">
|
<h1 className="text-5xl md:text-6xl font-bold font-headline mb-6 leading-tight">
|
||||||
|
|||||||
@@ -8,51 +8,51 @@ export default function PrivacyPolicy() {
|
|||||||
return (
|
return (
|
||||||
<main className="py-20 bg-white dark:bg-secondary transition-colors duration-300">
|
<main className="py-20 bg-white dark:bg-secondary transition-colors duration-300">
|
||||||
<div className="container mx-auto px-4 max-w-4xl">
|
<div className="container mx-auto px-4 max-w-4xl">
|
||||||
<h1 className="text-4xl font-bold font-headline text-secondary dark:text-white mb-8">{t('footer.privacyPolicy')}</h1>
|
<h1 className="text-4xl font-bold font-headline text-secondary dark:text-white mb-8">{t('privacy.title')}</h1>
|
||||||
|
|
||||||
<div className="prose prose-lg text-gray-600 dark:text-gray-300">
|
<div className="prose prose-lg text-gray-600 dark:text-gray-300">
|
||||||
<p className="mb-6">
|
<p className="mb-6">
|
||||||
A Octto Engenharia valoriza a privacidade de seus usuários e clientes. Esta Política de Privacidade descreve como coletamos, usamos e protegemos suas informações pessoais ao utilizar nosso site e serviços.
|
{t('privacy.intro')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">1. Coleta de Informações</h2>
|
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">{t('privacy.section1.title')}</h2>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
Coletamos informações que você nos fornece diretamente, como quando preenche nosso formulário de contato, solicita um orçamento ou se inscreve em nossa newsletter. As informações podem incluir nome, e-mail, telefone e detalhes sobre sua empresa ou projeto.
|
{t('privacy.section1.content')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">2. Uso das Informações</h2>
|
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">{t('privacy.section2.title')}</h2>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
Utilizamos as informações coletadas para:
|
{t('privacy.section2.intro')}
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc pl-6 mb-6 space-y-2">
|
<ul className="list-disc pl-6 mb-6 space-y-2">
|
||||||
<li>Responder a suas consultas e solicitações de orçamento;</li>
|
<li>{t('privacy.section2.items.0')}</li>
|
||||||
<li>Fornecer informações sobre nossos serviços de engenharia e laudos técnicos;</li>
|
<li>{t('privacy.section2.items.1')}</li>
|
||||||
<li>Melhorar a experiência do usuário em nosso site;</li>
|
<li>{t('privacy.section2.items.2')}</li>
|
||||||
<li>Cumprir obrigações legais e regulatórias.</li>
|
<li>{t('privacy.section2.items.3')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">3. Proteção de Dados</h2>
|
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">{t('privacy.section3.title')}</h2>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
Adotamos medidas de segurança técnicas e organizacionais adequadas para proteger seus dados pessoais contra acesso não autorizado, alteração, divulgação ou destruição.
|
{t('privacy.section3.content')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">4. Compartilhamento de Informações</h2>
|
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">{t('privacy.section4.title')}</h2>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
Não vendemos, trocamos ou transferimos suas informações pessoais para terceiros, exceto quando necessário para a prestação de nossos serviços (ex: parceiros técnicos envolvidos em um projeto específico) ou quando exigido por lei.
|
{t('privacy.section4.content')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">5. Cookies</h2>
|
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">{t('privacy.section5.title')}</h2>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
Nosso site pode utilizar cookies para melhorar a navegação e entender como os visitantes interagem com nosso conteúdo. Você pode desativar os cookies nas configurações do seu navegador, se preferir.
|
{t('privacy.section5.content')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">6. Contato</h2>
|
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">{t('privacy.section6.title')}</h2>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
Se você tiver dúvidas sobre esta Política de Privacidade, entre em contato conosco através do e-mail: contato@octto.com.br.
|
{t('privacy.section6.content')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-12">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-12">
|
||||||
Última atualização: Novembro de 2025.
|
{t('privacy.lastUpdate')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
5
frontend/src/app/[locale]/projetos/[id]/layout.tsx
Normal file
5
frontend/src/app/[locale]/projetos/[id]/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { generateMetadata } from "./metadata";
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
82
frontend/src/app/[locale]/projetos/[id]/metadata.ts
Normal file
82
frontend/src/app/[locale]/projetos/[id]/metadata.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { Metadata } from 'next';
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
coverImage: string | null;
|
||||||
|
category: string;
|
||||||
|
client: string | null;
|
||||||
|
completionDate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProject(id: string): Promise<Project | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://octtoengenharia.com.br/api/projects/${id}`, {
|
||||||
|
next: { revalidate: 3600 }, // Cache 1 hora
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar projeto para metadata:', error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata(
|
||||||
|
{ params }: { params: { id: string; locale: string } },
|
||||||
|
parent: any
|
||||||
|
): Promise<Metadata> {
|
||||||
|
const project = await getProject(params.id);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return {
|
||||||
|
title: 'Projeto não encontrado',
|
||||||
|
description: 'O projeto que você procura não existe.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = 'https://octtoengenharia.com.br';
|
||||||
|
const prefix = params.locale === 'pt' ? '' : `/${params.locale}`;
|
||||||
|
const projectUrl = `${baseUrl}${prefix}/projetos/${project.id}`;
|
||||||
|
const description = project.description || `Projeto de ${project.category} da Octto Engenharia${project.client ? ` para ${project.client}` : ''}`;
|
||||||
|
|
||||||
|
const localeMap: Record<string, string> = {
|
||||||
|
pt: 'pt_BR',
|
||||||
|
en: 'en_US',
|
||||||
|
es: 'es_ES',
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${project.title} | Octto Engenharia`,
|
||||||
|
description,
|
||||||
|
keywords: `${project.title}, ${project.category}, engenharia, ${project.client || 'Octto Engenharia'}`,
|
||||||
|
openGraph: {
|
||||||
|
type: 'article',
|
||||||
|
title: project.title,
|
||||||
|
description,
|
||||||
|
url: projectUrl,
|
||||||
|
images: project.coverImage
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
url: project.coverImage,
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: project.title,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
locale: localeMap[params.locale] || 'pt_BR',
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: project.title,
|
||||||
|
description,
|
||||||
|
images: project.coverImage ? [project.coverImage] : [],
|
||||||
|
},
|
||||||
|
alternates: {
|
||||||
|
canonical: projectUrl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,45 +8,45 @@ export default function TermsOfUse() {
|
|||||||
return (
|
return (
|
||||||
<main className="py-20 bg-white dark:bg-secondary transition-colors duration-300">
|
<main className="py-20 bg-white dark:bg-secondary transition-colors duration-300">
|
||||||
<div className="container mx-auto px-4 max-w-4xl">
|
<div className="container mx-auto px-4 max-w-4xl">
|
||||||
<h1 className="text-4xl font-bold font-headline text-secondary dark:text-white mb-8">{t('footer.termsOfUse')}</h1>
|
<h1 className="text-4xl font-bold font-headline text-secondary dark:text-white mb-8">{t('terms.title')}</h1>
|
||||||
|
|
||||||
<div className="prose prose-lg text-gray-600 dark:text-gray-300">
|
<div className="prose prose-lg text-gray-600 dark:text-gray-300">
|
||||||
<p className="mb-6">
|
<p className="mb-6">
|
||||||
Bem-vindo ao site da Octto Engenharia. Ao acessar e utilizar este site, você concorda em cumprir e estar vinculado aos seguintes Termos de Uso. Se você não concordar com qualquer parte destes termos, por favor, não utilize nosso site.
|
{t('terms.intro')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">1. Uso do Site</h2>
|
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">{t('terms.section1.title')}</h2>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
O conteúdo deste site é apenas para fins informativos gerais sobre nossos serviços de engenharia mecânica, laudos e projetos. Reservamo-nos o direito de alterar ou descontinuar qualquer aspecto do site a qualquer momento.
|
{t('terms.section1.content')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">2. Propriedade Intelectual</h2>
|
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">{t('terms.section2.title')}</h2>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
Todo o conteúdo presente neste site, incluindo textos, gráficos, logotipos, ícones, imagens e software, é propriedade da Octto Engenharia ou de seus fornecedores de conteúdo e é protegido pelas leis de direitos autorais do Brasil e internacionais.
|
{t('terms.section2.content')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">3. Limitação de Responsabilidade</h2>
|
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">{t('terms.section3.title')}</h2>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
A Octto Engenharia não se responsabiliza por quaisquer danos diretos, indiretos, incidentais ou consequenciais resultantes do uso ou da incapacidade de uso deste site ou de qualquer informação nele contida. As informações técnicas fornecidas no site não substituem a consulta profissional e a emissão de laudos técnicos específicos para cada caso.
|
{t('terms.section3.content')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">4. Links para Terceiros</h2>
|
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">{t('terms.section4.title')}</h2>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
Nosso site pode conter links para sites de terceiros. Estes links são fornecidos apenas para sua conveniência. A Octto Engenharia não tem controle sobre o conteúdo desses sites e não assume responsabilidade por eles.
|
{t('terms.section4.content')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">5. Alterações nos Termos</h2>
|
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">{t('terms.section5.title')}</h2>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
Podemos revisar estes Termos de Uso a qualquer momento. Ao utilizar este site, você concorda em ficar vinculado à versão atual desses Termos de Uso.
|
{t('terms.section5.content')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">6. Legislação Aplicável</h2>
|
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">{t('terms.section6.title')}</h2>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
Estes termos são regidos e interpretados de acordo com as leis da República Federativa do Brasil. Qualquer disputa relacionada a estes termos será submetida à jurisdição exclusiva dos tribunais competentes.
|
{t('terms.section6.content')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-12">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-12">
|
||||||
Última atualização: Novembro de 2025.
|
{t('terms.lastUpdate')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useToast } from '@/contexts/ToastContext';
|
import { useToast } from '@/contexts/ToastContext';
|
||||||
|
import { BackupManager } from '@/components/admin/BackupManager';
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
const PRESET_COLORS = [
|
const PRESET_COLORS = [
|
||||||
{ name: 'Laranja (Padrão)', value: '#FF6B35', gradient: 'from-orange-500 to-orange-600' },
|
{ name: 'Laranja (Padrão)', value: '#FF6B35', gradient: 'from-orange-500 to-orange-600' },
|
||||||
@@ -16,13 +19,32 @@ const PRESET_COLORS = [
|
|||||||
|
|
||||||
export default function ConfiguracoesPage() {
|
export default function ConfiguracoesPage() {
|
||||||
const { success, error: showError } = useToast();
|
const { success, error: showError } = useToast();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const tabFromUrl = searchParams.get('tab') as 'personalizacao' | 'logotipo' | 'contato' | 'backup' | null;
|
||||||
|
const [activeTab, setActiveTab] = useState<'personalizacao' | 'logotipo' | 'contato' | 'backup'>(tabFromUrl || 'personalizacao');
|
||||||
const [primaryColor, setPrimaryColor] = useState('#FF6B35');
|
const [primaryColor, setPrimaryColor] = useState('#FF6B35');
|
||||||
const [customColor, setCustomColor] = useState('#FF6B35');
|
const [customColor, setCustomColor] = useState('#FF6B35');
|
||||||
|
const [showPartnerBadge, setShowPartnerBadge] = useState(false);
|
||||||
|
const [partnerName, setPartnerName] = useState('Coca-Cola');
|
||||||
|
// Campo de logotipo
|
||||||
|
const [logo, setLogo] = useState<string | null>(null);
|
||||||
|
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||||
|
const [uploadingLogo, setUploadingLogo] = useState(false);
|
||||||
|
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
// Campos de contato
|
||||||
|
const [address, setAddress] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [instagram, setInstagram] = useState('');
|
||||||
|
const [linkedin, setLinkedin] = useState('');
|
||||||
|
const [facebook, setFacebook] = useState('');
|
||||||
|
const [whatsapp, setWhatsapp] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConfig();
|
fetchConfig();
|
||||||
|
fetchSettings();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchConfig = async () => {
|
const fetchConfig = async () => {
|
||||||
@@ -42,6 +64,132 @@ export default function ConfiguracoesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchSettings = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setShowPartnerBadge(data.showPartnerBadge || false);
|
||||||
|
setPartnerName(data.partnerName || 'Coca-Cola');
|
||||||
|
setLogo(data.logo || null);
|
||||||
|
setLogoPreview(data.logo || null);
|
||||||
|
setAddress(data.address || '');
|
||||||
|
setPhone(data.phone || '');
|
||||||
|
setEmail(data.email || '');
|
||||||
|
setInstagram(data.instagram || '');
|
||||||
|
setLinkedin(data.linkedin || '');
|
||||||
|
setFacebook(data.facebook || '');
|
||||||
|
setWhatsapp(data.whatsapp || '');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao carregar settings:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Validar tipo de arquivo
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
showError('Por favor, selecione uma imagem válida');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar tamanho (max 5MB)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
showError('A imagem deve ter no máximo 5MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview local
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
setLogoPreview(e.target?.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
|
// Upload
|
||||||
|
setUploadingLogo(true);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await fetch('/api/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Erro no upload');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setLogo(data.url);
|
||||||
|
success('Logotipo carregado! Clique em "Salvar" para aplicar.');
|
||||||
|
} catch (error) {
|
||||||
|
showError('Erro ao fazer upload do logotipo');
|
||||||
|
setLogoPreview(logo); // Restaurar preview anterior
|
||||||
|
} finally {
|
||||||
|
setUploadingLogo(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveLogo = () => {
|
||||||
|
setLogo(null);
|
||||||
|
setLogoPreview(null);
|
||||||
|
if (logoInputRef.current) {
|
||||||
|
logoInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveLogo = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ logo })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Erro ao salvar');
|
||||||
|
|
||||||
|
success('Logotipo salvo com sucesso!');
|
||||||
|
// Dispatch event para atualizar em tempo real
|
||||||
|
window.dispatchEvent(new Event('settings:refresh'));
|
||||||
|
} catch (error) {
|
||||||
|
showError('Erro ao salvar logotipo');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveSettings = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
showPartnerBadge,
|
||||||
|
partnerName,
|
||||||
|
address: address || null,
|
||||||
|
phone: phone || null,
|
||||||
|
email: email || null,
|
||||||
|
instagram: instagram || null,
|
||||||
|
linkedin: linkedin || null,
|
||||||
|
facebook: facebook || null,
|
||||||
|
whatsapp: whatsapp || null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Erro ao salvar');
|
||||||
|
|
||||||
|
success('Configurações salvas!');
|
||||||
|
// Dispatch event para atualizar em tempo real
|
||||||
|
window.dispatchEvent(new Event('settings:refresh'));
|
||||||
|
} catch (error) {
|
||||||
|
showError('Erro ao salvar configurações');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -87,10 +235,67 @@ export default function ConfiguracoesPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white">Configurações</h1>
|
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white">Configurações</h1>
|
||||||
<p className="text-gray-500 dark:text-gray-400 mt-1">Personalize a aparência do seu site</p>
|
<p className="text-gray-500 dark:text-gray-400 mt-1">Gerencie as configurações do seu site</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs Navigation */}
|
||||||
|
<div className="bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl overflow-hidden">
|
||||||
|
<div className="flex gap-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('personalizacao')}
|
||||||
|
className={`flex-1 px-4 py-4 font-bold flex items-center justify-center gap-2 border-b-2 transition-all cursor-pointer ${
|
||||||
|
activeTab === 'personalizacao'
|
||||||
|
? 'bg-primary/5 dark:bg-primary/10 text-primary border-primary'
|
||||||
|
: 'text-gray-600 dark:text-gray-400 border-transparent hover:bg-gray-50 dark:hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<i className="ri-palette-line text-xl"></i>
|
||||||
|
<span className="hidden sm:inline">Personalização</span>
|
||||||
|
<span className="sm:hidden">Cores</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('logotipo')}
|
||||||
|
className={`flex-1 px-4 py-4 font-bold flex items-center justify-center gap-2 border-b-2 transition-all cursor-pointer ${
|
||||||
|
activeTab === 'logotipo'
|
||||||
|
? 'bg-primary/5 dark:bg-primary/10 text-primary border-primary'
|
||||||
|
: 'text-gray-600 dark:text-gray-400 border-transparent hover:bg-gray-50 dark:hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<i className="ri-image-line text-xl"></i>
|
||||||
|
<span className="hidden sm:inline">Logotipo</span>
|
||||||
|
<span className="sm:hidden">Logo</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('contato')}
|
||||||
|
className={`flex-1 px-4 py-4 font-bold flex items-center justify-center gap-2 border-b-2 transition-all cursor-pointer ${
|
||||||
|
activeTab === 'contato'
|
||||||
|
? 'bg-primary/5 dark:bg-primary/10 text-primary border-primary'
|
||||||
|
: 'text-gray-600 dark:text-gray-400 border-transparent hover:bg-gray-50 dark:hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<i className="ri-contacts-book-2-line text-xl"></i>
|
||||||
|
<span className="hidden sm:inline">Contato</span>
|
||||||
|
<span className="sm:hidden">Info</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('backup')}
|
||||||
|
className={`flex-1 px-4 py-4 font-bold flex items-center justify-center gap-2 border-b-2 transition-all cursor-pointer ${
|
||||||
|
activeTab === 'backup'
|
||||||
|
? 'bg-primary/5 dark:bg-primary/10 text-primary border-primary'
|
||||||
|
: 'text-gray-600 dark:text-gray-400 border-transparent hover:bg-gray-50 dark:hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<i className="ri-database-2-line text-xl"></i>
|
||||||
|
<span className="hidden sm:inline">Backup</span>
|
||||||
|
<span className="sm:hidden">Bkp</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content - Personalização */}
|
||||||
|
{activeTab === 'personalizacao' && (
|
||||||
|
<div className="space-y-6">
|
||||||
{/* Color Settings */}
|
{/* Color Settings */}
|
||||||
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
|
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
|
||||||
<div className="flex items-start gap-4 mb-6">
|
<div className="flex items-start gap-4 mb-6">
|
||||||
@@ -252,6 +457,399 @@ export default function ConfiguracoesPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Partner Badge Settings */}
|
||||||
|
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
|
||||||
|
<div className="flex items-start gap-4 mb-6">
|
||||||
|
<div className="w-12 h-12 bg-linear-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center shadow-lg shadow-green-500/30">
|
||||||
|
<i className="ri-verified-badge-fill text-2xl text-white"></i>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-xl font-bold text-secondary dark:text-white mb-1">Badge de Parceiro</h2>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
Exiba um selo de parceiro oficial no seu site. Aparecerá na página inicial e no rodapé.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggle Switch */}
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-white/5 rounded-xl mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<i className="ri-eye-line text-gray-500 dark:text-gray-400 text-xl"></i>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white">Exibir Badge de Parceiro</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Mostrar o selo na hero e no rodapé do site</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showPartnerBadge}
|
||||||
|
onChange={(e) => setShowPartnerBadge(e.target.checked)}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-14 h-7 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary/20 dark:peer-focus:ring-primary/30 rounded-full peer dark:bg-white/10 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:start-1 after:bg-white after:border-gray-300 after:border after:rounded-full after:h-6 after:w-6 after:transition-all dark:border-gray-600 peer-checked:bg-primary"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Partner Name Input */}
|
||||||
|
{showPartnerBadge && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Nome do Parceiro
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={partnerName}
|
||||||
|
onChange={(e) => setPartnerName(e.target.value)}
|
||||||
|
placeholder="Ex: Coca-Cola"
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
{showPartnerBadge && (
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-white/5 rounded-xl mb-6">
|
||||||
|
<p className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-3">Prévia do Badge:</p>
|
||||||
|
<div className="inline-flex items-center gap-3 px-5 py-3 rounded-full bg-white dark:bg-white/10 border border-gray-200 dark:border-white/20 shadow-sm">
|
||||||
|
<i className="ri-verified-badge-fill text-primary text-xl"></i>
|
||||||
|
<span className="text-sm font-bold text-gray-700 dark:text-gray-200">
|
||||||
|
PRESTADOR DE SERVIÇO OFICIAL <span className="text-primary">{partnerName}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleSaveSettings}
|
||||||
|
className="w-full px-6 py-3 bg-primary text-white rounded-xl font-bold hover:opacity-90 transition-colors shadow-lg shadow-primary/20 flex items-center justify-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<i className="ri-save-line"></i>
|
||||||
|
Salvar Configurações do Badge
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab Content - Logotipo */}
|
||||||
|
{activeTab === 'logotipo' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
|
||||||
|
<div className="flex items-start gap-4 mb-6">
|
||||||
|
<div className="w-12 h-12 bg-linear-to-br from-indigo-500 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-indigo-500/30">
|
||||||
|
<i className="ri-image-2-fill text-2xl text-white"></i>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-xl font-bold text-secondary dark:text-white mb-1">Logotipo</h2>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
Configure o logotipo que aparece no cabeçalho, rodapé e painel administrativo do site.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Logo Preview */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-3">
|
||||||
|
Logotipo Atual
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="relative w-48 h-24 bg-gray-100 dark:bg-white/5 rounded-xl border-2 border-dashed border-gray-300 dark:border-white/20 flex items-center justify-center overflow-hidden">
|
||||||
|
{logoPreview ? (
|
||||||
|
<Image
|
||||||
|
src={logoPreview}
|
||||||
|
alt="Logotipo"
|
||||||
|
fill
|
||||||
|
className="object-contain p-2"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center text-gray-400 dark:text-gray-500">
|
||||||
|
<i className="ri-building-2-fill text-4xl"></i>
|
||||||
|
<span className="text-xs mt-1">Sem logotipo</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{uploadingLogo && (
|
||||||
|
<div className="absolute inset-0 bg-white/80 dark:bg-black/80 flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logo in Header Preview */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">Prévia no cabeçalho:</p>
|
||||||
|
<div className="bg-gray-900 rounded-lg p-4 flex items-center gap-2">
|
||||||
|
{logoPreview ? (
|
||||||
|
<Image
|
||||||
|
src={logoPreview}
|
||||||
|
alt="Logo Preview"
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
className="object-contain"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<i className="ri-building-2-fill text-xl text-white"></i>
|
||||||
|
)}
|
||||||
|
<span className="text-white font-bold text-sm">OCCTO</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload Section */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-3">
|
||||||
|
Carregar Novo Logotipo
|
||||||
|
</label>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
ref={logoInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleLogoUpload}
|
||||||
|
className="hidden"
|
||||||
|
id="logo-upload"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="logo-upload"
|
||||||
|
className="flex items-center justify-center gap-3 px-6 py-4 border-2 border-dashed border-gray-300 dark:border-white/20 rounded-xl cursor-pointer hover:border-primary hover:bg-primary/5 dark:hover:bg-primary/10 transition-colors"
|
||||||
|
>
|
||||||
|
<i className="ri-upload-cloud-2-line text-2xl text-gray-400 dark:text-gray-500"></i>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="font-medium text-gray-700 dark:text-gray-300">Clique para selecionar</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">PNG, JPG, SVG ou WebP até 5MB</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{logoPreview && (
|
||||||
|
<button
|
||||||
|
onClick={handleRemoveLogo}
|
||||||
|
className="px-4 py-4 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-xl hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors cursor-pointer"
|
||||||
|
title="Remover logotipo"
|
||||||
|
>
|
||||||
|
<i className="ri-delete-bin-line text-xl"></i>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tips */}
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-4 mb-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<i className="ri-lightbulb-line text-blue-600 dark:text-blue-400 text-xl mt-0.5"></i>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm text-blue-900 dark:text-blue-200 font-medium mb-2">
|
||||||
|
Dicas para um bom logotipo:
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
|
||||||
|
<li>• Use imagens com fundo transparente (PNG ou SVG)</li>
|
||||||
|
<li>• Recomendado: formato horizontal ou quadrado</li>
|
||||||
|
<li>• Resolução mínima sugerida: 200x100 pixels</li>
|
||||||
|
<li>• O logotipo será redimensionado automaticamente</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleSaveLogo}
|
||||||
|
disabled={saving}
|
||||||
|
className="w-full px-6 py-3 bg-primary text-white rounded-xl font-bold hover:opacity-90 transition-colors shadow-lg shadow-primary/20 flex items-center justify-center gap-2 cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||||
|
Salvando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<i className="ri-save-line"></i>
|
||||||
|
Salvar Logotipo
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab Content - Contato */}
|
||||||
|
{activeTab === 'contato' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Contact Information Settings */}
|
||||||
|
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
|
||||||
|
<div className="flex items-start gap-4 mb-6">
|
||||||
|
<div className="w-12 h-12 bg-linear-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center shadow-lg shadow-blue-500/30">
|
||||||
|
<i className="ri-contacts-book-2-fill text-2xl text-white"></i>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-xl font-bold text-secondary dark:text-white mb-1">Informações de Contato</h2>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
Configure as informações de contato que aparecem na página de contato e no rodapé do site.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Address */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
<i className="ri-map-pin-line mr-2 text-primary"></i>
|
||||||
|
Endereço
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={address}
|
||||||
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
|
placeholder="Ex: Rua das Flores, 123 - Centro, Vitória - ES"
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
<i className="ri-phone-line mr-2 text-primary"></i>
|
||||||
|
Telefone
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
placeholder="Ex: (27) 99999-9999"
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
<i className="ri-mail-line mr-2 text-primary"></i>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="Ex: contato@empresa.com.br"
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Social Media Settings */}
|
||||||
|
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
|
||||||
|
<div className="flex items-start gap-4 mb-6">
|
||||||
|
<div className="w-12 h-12 bg-linear-to-br from-pink-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg shadow-pink-500/30">
|
||||||
|
<i className="ri-share-circle-fill text-2xl text-white"></i>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-xl font-bold text-secondary dark:text-white mb-1">Redes Sociais</h2>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
Configure os links das suas redes sociais. Deixe em branco para ocultar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Instagram */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
<i className="ri-instagram-line mr-2 text-pink-500"></i>
|
||||||
|
Instagram
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={instagram}
|
||||||
|
onChange={(e) => setInstagram(e.target.value)}
|
||||||
|
placeholder="https://instagram.com/suaempresa"
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* LinkedIn */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
<i className="ri-linkedin-fill mr-2 text-blue-600"></i>
|
||||||
|
LinkedIn
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={linkedin}
|
||||||
|
onChange={(e) => setLinkedin(e.target.value)}
|
||||||
|
placeholder="https://linkedin.com/company/suaempresa"
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Facebook */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
<i className="ri-facebook-fill mr-2 text-blue-500"></i>
|
||||||
|
Facebook
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={facebook}
|
||||||
|
onChange={(e) => setFacebook(e.target.value)}
|
||||||
|
placeholder="https://facebook.com/suaempresa"
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* WhatsApp */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
<i className="ri-whatsapp-line mr-2 text-green-500"></i>
|
||||||
|
WhatsApp
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={whatsapp}
|
||||||
|
onChange={(e) => setWhatsapp(e.target.value)}
|
||||||
|
placeholder="Ex: (27) 99999-9999"
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Alert */}
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<i className="ri-information-line text-blue-600 dark:text-blue-400 text-xl mt-0.5"></i>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm text-blue-900 dark:text-blue-200 font-medium mb-1">
|
||||||
|
Sincronização Automática
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
Estas informações serão exibidas automaticamente na página de contato e no rodapé do site.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save All Settings Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleSaveSettings}
|
||||||
|
className="w-full px-6 py-3 bg-primary text-white rounded-xl font-bold hover:opacity-90 transition-colors shadow-lg shadow-primary/20 flex items-center justify-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<i className="ri-save-line"></i>
|
||||||
|
Salvar Informações de Contato
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab Content - Backup */}
|
||||||
|
{activeTab === 'backup' && (
|
||||||
|
<div>
|
||||||
|
<BackupManager />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { useToast } from '@/contexts/ToastContext';
|
import { useToast } from '@/contexts/ToastContext';
|
||||||
import { useConfirm } from '@/contexts/ConfirmContext';
|
import { useConfirm } from '@/contexts/ConfirmContext';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
type TranslationSummary = {
|
type TranslationSummary = {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -19,13 +21,16 @@ export default function AdminLayout({
|
|||||||
}) {
|
}) {
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||||
const [user, setUser] = useState<{ name: string; email: string; avatar?: string | null } | null>(null);
|
const [user, setUser] = useState<{ name: string; email: string; avatar?: string | null } | null>(null);
|
||||||
|
const [logo, setLogo] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [showAvatarModal, setShowAvatarModal] = useState(false);
|
const [showAvatarModal, setShowAvatarModal] = useState(false);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { success, error } = useToast();
|
const { success, error } = useToast();
|
||||||
const { confirm } = useConfirm();
|
const { confirm } = useConfirm();
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
const [showNotifications, setShowNotifications] = useState(false);
|
const [showNotifications, setShowNotifications] = useState(false);
|
||||||
const [translationSummary, setTranslationSummary] = useState<TranslationSummary[]>([]);
|
const [translationSummary, setTranslationSummary] = useState<TranslationSummary[]>([]);
|
||||||
const [isFetchingTranslations, setIsFetchingTranslations] = useState(false);
|
const [isFetchingTranslations, setIsFetchingTranslations] = useState(false);
|
||||||
@@ -80,6 +85,7 @@ export default function AdminLayout({
|
|||||||
}, [success]);
|
}, [success]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
const fetchUser = async () => {
|
const fetchUser = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/me');
|
const response = await fetch('/api/auth/me');
|
||||||
@@ -100,6 +106,27 @@ export default function AdminLayout({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchUser();
|
fetchUser();
|
||||||
|
|
||||||
|
// Buscar logo das configurações
|
||||||
|
const fetchLogo = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.logo) {
|
||||||
|
setLogo(data.logo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao buscar logo:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchLogo();
|
||||||
|
|
||||||
|
// Listener para atualização em tempo real
|
||||||
|
const handleSettingsRefresh = () => fetchLogo();
|
||||||
|
window.addEventListener('settings:refresh', handleSettingsRefresh);
|
||||||
|
return () => window.removeEventListener('settings:refresh', handleSettingsRefresh);
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -235,6 +262,17 @@ export default function AdminLayout({
|
|||||||
<aside className={`fixed inset-y-0 left-0 z-50 bg-white dark:bg-secondary border-r border-gray-200 dark:border-white/10 transition-all duration-300 ${isSidebarOpen ? 'w-64' : 'w-20'} hidden md:flex flex-col`}>
|
<aside className={`fixed inset-y-0 left-0 z-50 bg-white dark:bg-secondary border-r border-gray-200 dark:border-white/10 transition-all duration-300 ${isSidebarOpen ? 'w-64' : 'w-20'} hidden md:flex flex-col`}>
|
||||||
<div className="h-20 flex items-center justify-center border-b border-gray-200 dark:border-white/10">
|
<div className="h-20 flex items-center justify-center border-b border-gray-200 dark:border-white/10">
|
||||||
<Link href="/admin" className="flex items-center gap-3">
|
<Link href="/admin" className="flex items-center gap-3">
|
||||||
|
{logo ? (
|
||||||
|
<Image
|
||||||
|
src={logo}
|
||||||
|
alt="OCCTO Engenharia"
|
||||||
|
width={isSidebarOpen ? 120 : 32}
|
||||||
|
height={40}
|
||||||
|
className="object-contain h-10 w-auto"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<i className="ri-building-2-fill text-3xl text-primary"></i>
|
<i className="ri-building-2-fill text-3xl text-primary"></i>
|
||||||
{isSidebarOpen && (
|
{isSidebarOpen && (
|
||||||
<div className="flex items-center gap-2 animate-in fade-in duration-300">
|
<div className="flex items-center gap-2 animate-in fade-in duration-300">
|
||||||
@@ -242,6 +280,8 @@ export default function AdminLayout({
|
|||||||
<span className="text-[10px] font-bold text-primary bg-primary/10 px-2 py-1 rounded-md uppercase tracking-wider">ENG.</span>
|
<span className="text-[10px] font-bold text-primary bg-primary/10 px-2 py-1 rounded-md uppercase tracking-wider">ENG.</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -284,6 +324,19 @@ export default function AdminLayout({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Dark Mode Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||||
|
className="w-10 h-10 rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 flex items-center justify-center text-gray-600 dark:text-yellow-400 transition-colors cursor-pointer"
|
||||||
|
aria-label="Alternar tema"
|
||||||
|
>
|
||||||
|
{mounted && theme === 'dark' ? (
|
||||||
|
<i className="ri-sun-line text-xl"></i>
|
||||||
|
) : (
|
||||||
|
<i className="ri-moon-line text-xl"></i>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
<div ref={notificationsRef} className="relative">
|
<div ref={notificationsRef} className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export default function AdminDashboard() {
|
|||||||
<p className="text-xs text-gray-400 mt-1">{stats.activeProjects} ativos</p>
|
<p className="text-xs text-gray-400 mt-1">{stats.activeProjects} ativos</p>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/admin/contatos" className="bg-white dark:bg-secondary p-6 rounded-xl border border-gray-200 dark:border-white/10 shadow-sm cursor-pointer hover:shadow-md transition-all">
|
<Link href="/admin/mensagens" className="bg-white dark:bg-secondary p-6 rounded-xl border border-gray-200 dark:border-white/10 shadow-sm cursor-pointer hover:shadow-md transition-all">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="w-12 h-12 rounded-xl flex items-center justify-center bg-green-50 dark:bg-green-900/20 text-green-500">
|
<div className="w-12 h-12 rounded-xl flex items-center justify-center bg-green-50 dark:bg-green-900/20 text-green-500">
|
||||||
<i className="ri-message-3-line text-2xl"></i>
|
<i className="ri-message-3-line text-2xl"></i>
|
||||||
@@ -198,7 +198,7 @@ export default function AdminDashboard() {
|
|||||||
<div className="bg-white dark:bg-secondary p-6 rounded-xl border border-gray-200 dark:border-white/10 shadow-sm">
|
<div className="bg-white dark:bg-secondary p-6 rounded-xl border border-gray-200 dark:border-white/10 shadow-sm">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h3 className="text-lg font-bold text-secondary dark:text-white">Últimas Mensagens</h3>
|
<h3 className="text-lg font-bold text-secondary dark:text-white">Últimas Mensagens</h3>
|
||||||
<Link href="/admin/contatos" className="text-primary text-sm font-bold hover:underline">Ver todas</Link>
|
<Link href="/admin/mensagens" className="text-primary text-sm font-bold hover:underline">Ver todas</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{recentContacts.length === 0 ? (
|
{recentContacts.length === 0 ? (
|
||||||
@@ -207,7 +207,7 @@ export default function AdminDashboard() {
|
|||||||
recentContacts.map((contact) => (
|
recentContacts.map((contact) => (
|
||||||
<Link
|
<Link
|
||||||
key={contact.id}
|
key={contact.id}
|
||||||
href="/admin/contatos"
|
href="/admin/mensagens"
|
||||||
className="flex items-start gap-4 p-4 rounded-lg hover:bg-gray-50 dark:hover:bg-white/5 transition-colors border border-transparent hover:border-gray-100 dark:hover:border-white/5 cursor-pointer"
|
className="flex items-start gap-4 p-4 rounded-lg hover:bg-gray-50 dark:hover:bg-white/5 transition-colors border border-transparent hover:border-gray-100 dark:hover:border-white/5 cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
|
||||||
|
|||||||
@@ -1,716 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { useToast } from '@/contexts/ToastContext';
|
|
||||||
import { CharLimitBadge } from '@/components/admin/CharLimitBadge';
|
|
||||||
|
|
||||||
const AVAILABLE_ICONS = [
|
|
||||||
// Pessoas e Equipe
|
|
||||||
{ value: 'ri-team-line', label: 'Equipe', category: 'pessoas' },
|
|
||||||
{ value: 'ri-user-star-line', label: 'Destaque', category: 'pessoas' },
|
|
||||||
{ value: 'ri-user-follow-line', label: 'Seguir', category: 'pessoas' },
|
|
||||||
{ value: 'ri-group-line', label: 'Grupo', category: 'pessoas' },
|
|
||||||
|
|
||||||
// Segurança
|
|
||||||
{ value: 'ri-shield-check-line', label: 'Segurança', category: 'segurança' },
|
|
||||||
{ value: 'ri-shield-star-line', label: 'Proteção Premium', category: 'segurança' },
|
|
||||||
{ value: 'ri-lock-line', label: 'Cadeado', category: 'segurança' },
|
|
||||||
{ value: 'ri-hard-hat-line', label: 'Capacete', category: 'segurança' },
|
|
||||||
|
|
||||||
// Serviços
|
|
||||||
{ value: 'ri-service-line', label: 'Atendimento', category: 'serviço' },
|
|
||||||
{ value: 'ri-customer-service-line', label: 'Suporte', category: 'serviço' },
|
|
||||||
{ value: 'ri-tools-line', label: 'Ferramentas', category: 'serviço' },
|
|
||||||
{ value: 'ri-settings-3-line', label: 'Engrenagem', category: 'serviço' },
|
|
||||||
|
|
||||||
// Transporte
|
|
||||||
{ value: 'ri-car-line', label: 'Veículo', category: 'transporte' },
|
|
||||||
{ value: 'ri-truck-line', label: 'Caminhão', category: 'transporte' },
|
|
||||||
{ value: 'ri-bus-line', label: 'Ônibus', category: 'transporte' },
|
|
||||||
{ value: 'ri-motorbike-line', label: 'Moto', category: 'transporte' },
|
|
||||||
|
|
||||||
// Documentos
|
|
||||||
{ value: 'ri-file-list-3-line', label: 'Documentos', category: 'documentos' },
|
|
||||||
{ value: 'ri-file-text-line', label: 'Arquivo', category: 'documentos' },
|
|
||||||
{ value: 'ri-clipboard-line', label: 'Prancheta', category: 'documentos' },
|
|
||||||
{ value: 'ri-contract-line', label: 'Contrato', category: 'documentos' },
|
|
||||||
|
|
||||||
// Conquistas
|
|
||||||
{ value: 'ri-award-line', label: 'Prêmio', category: 'conquista' },
|
|
||||||
{ value: 'ri-trophy-line', label: 'Troféu', category: 'conquista' },
|
|
||||||
{ value: 'ri-medal-line', label: 'Medalha', category: 'conquista' },
|
|
||||||
{ value: 'ri-vip-crown-line', label: 'Coroa', category: 'conquista' },
|
|
||||||
|
|
||||||
// Inovação
|
|
||||||
{ value: 'ri-lightbulb-line', label: 'Ideia', category: 'inovação' },
|
|
||||||
{ value: 'ri-flashlight-line', label: 'Lanterna', category: 'inovação' },
|
|
||||||
{ value: 'ri-rocket-line', label: 'Foguete', category: 'inovação' },
|
|
||||||
{ value: 'ri-flask-line', label: 'Experimento', category: 'inovação' },
|
|
||||||
|
|
||||||
// Status
|
|
||||||
{ value: 'ri-checkbox-circle-line', label: 'Confirmado', category: 'status' },
|
|
||||||
{ value: 'ri-check-double-line', label: 'Verificado', category: 'status' },
|
|
||||||
{ value: 'ri-star-line', label: 'Estrela', category: 'status' },
|
|
||||||
{ value: 'ri-thumb-up-line', label: 'Aprovado', category: 'status' },
|
|
||||||
|
|
||||||
// Dados
|
|
||||||
{ value: 'ri-pie-chart-line', label: 'Gráfico Pizza', category: 'dados' },
|
|
||||||
{ value: 'ri-bar-chart-line', label: 'Gráfico Barras', category: 'dados' },
|
|
||||||
{ value: 'ri-line-chart-line', label: 'Gráfico Linha', category: 'dados' },
|
|
||||||
{ value: 'ri-dashboard-line', label: 'Dashboard', category: 'dados' },
|
|
||||||
|
|
||||||
// Performance
|
|
||||||
{ value: 'ri-speed-line', label: 'Velocidade', category: 'performance' },
|
|
||||||
{ value: 'ri-timer-line', label: 'Cronômetro', category: 'performance' },
|
|
||||||
{ value: 'ri-time-line', label: 'Relógio', category: 'performance' },
|
|
||||||
{ value: 'ri-pulse-line', label: 'Pulso', category: 'performance' },
|
|
||||||
|
|
||||||
// Negócios
|
|
||||||
{ value: 'ri-building-line', label: 'Empresa', category: 'negócios' },
|
|
||||||
{ value: 'ri-briefcase-line', label: 'Maleta', category: 'negócios' },
|
|
||||||
{ value: 'ri-money-dollar-circle-line', label: 'Dinheiro', category: 'negócios' },
|
|
||||||
{ value: 'ri-hand-coin-line', label: 'Pagamento', category: 'negócios' },
|
|
||||||
|
|
||||||
// Cálculo
|
|
||||||
{ value: 'ri-calculator-line', label: 'Calculadora', category: 'cálculo' },
|
|
||||||
{ value: 'ri-percent-line', label: 'Porcentagem', category: 'cálculo' },
|
|
||||||
{ value: 'ri-functions', label: 'Funções', category: 'cálculo' },
|
|
||||||
|
|
||||||
// Comunicação
|
|
||||||
{ value: 'ri-message-3-line', label: 'Mensagem', category: 'comunicação' },
|
|
||||||
{ value: 'ri-chat-3-line', label: 'Chat', category: 'comunicação' },
|
|
||||||
{ value: 'ri-phone-line', label: 'Telefone', category: 'comunicação' },
|
|
||||||
{ value: 'ri-mail-line', label: 'Email', category: 'comunicação' },
|
|
||||||
{ value: 'ri-whatsapp-line', label: 'WhatsApp', category: 'comunicação' },
|
|
||||||
{ value: 'ri-mail-send-line', label: 'Enviar Email', category: 'comunicação' },
|
|
||||||
|
|
||||||
// Localização
|
|
||||||
{ value: 'ri-map-pin-line', label: 'Localização', category: 'local' },
|
|
||||||
{ value: 'ri-navigation-line', label: 'Navegação', category: 'local' },
|
|
||||||
{ value: 'ri-roadster-line', label: 'Estrada', category: 'local' },
|
|
||||||
{ value: 'ri-compass-line', label: 'Bússola', category: 'local' },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface IconSelectorProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (icon: string) => void;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function IconSelector({ value, onChange, label }: IconSelectorProps) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
|
|
||||||
const filteredIcons = AVAILABLE_ICONS.filter(icon =>
|
|
||||||
icon.label.toLowerCase().includes(search.toLowerCase()) ||
|
|
||||||
icon.category.toLowerCase().includes(search.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
className="w-full px-4 py-3 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all flex items-center justify-between"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<i className={`${value} text-2xl text-primary`}></i>
|
|
||||||
<span className="text-sm">{AVAILABLE_ICONS.find(i => i.value === value)?.label || 'Selecionar ícone'}</span>
|
|
||||||
</div>
|
|
||||||
<i className={`ri-arrow-${isOpen ? 'up' : 'down'}-s-line text-gray-400`}></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<div className="absolute z-50 mt-2 w-full bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl shadow-xl">
|
|
||||||
<div className="p-3 border-b border-gray-200 dark:border-white/10">
|
|
||||||
<div className="relative">
|
|
||||||
<i className="ri-search-line absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
placeholder="Buscar ícone..."
|
|
||||||
className="w-full pl-10 pr-4 py-2 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg text-sm focus:outline-none focus:border-primary"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-2 grid grid-cols-4 gap-2 max-h-64 overflow-y-auto">
|
|
||||||
{filteredIcons.map((icon) => (
|
|
||||||
<button
|
|
||||||
key={icon.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onChange(icon.value);
|
|
||||||
setIsOpen(false);
|
|
||||||
setSearch('');
|
|
||||||
}}
|
|
||||||
className={`p-3 rounded-lg flex flex-col items-center gap-1 transition-all ${
|
|
||||||
value === icon.value
|
|
||||||
? 'bg-primary text-white'
|
|
||||||
: 'hover:bg-gray-100 dark:hover:bg-white/5 text-gray-700 dark:text-gray-300'
|
|
||||||
}`}
|
|
||||||
title={icon.label}
|
|
||||||
>
|
|
||||||
<i className={`${icon.value} text-2xl`}></i>
|
|
||||||
<span className="text-[10px] text-center leading-tight">{icon.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const CONTACT_TEXT_LIMITS = {
|
|
||||||
hero: { pretitle: 32, title: 70, subtitle: 200 },
|
|
||||||
info: {
|
|
||||||
title: 36,
|
|
||||||
subtitle: 80,
|
|
||||||
description: 200,
|
|
||||||
itemTitle: 40,
|
|
||||||
itemDescription: 160,
|
|
||||||
link: 120,
|
|
||||||
linkText: 32,
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type LabelWithLimitProps = {
|
|
||||||
label: string;
|
|
||||||
value?: string;
|
|
||||||
limit: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function LabelWithLimit({ label, value, limit }: LabelWithLimitProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between mb-2 gap-4">
|
|
||||||
<span className="block text-sm font-bold text-gray-700 dark:text-gray-300">{label}</span>
|
|
||||||
<CharLimitBadge value={value || ''} limit={limit} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ContactInfo {
|
|
||||||
icon: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
link: string;
|
|
||||||
linkText: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ContactContent {
|
|
||||||
hero: {
|
|
||||||
pretitle: string;
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
};
|
|
||||||
info: {
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
description: string;
|
|
||||||
items: ContactInfo[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EditContactPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [initialLoading, setInitialLoading] = useState(true);
|
|
||||||
const [activeTab, setActiveTab] = useState('hero');
|
|
||||||
const { success, error: showError } = useToast();
|
|
||||||
|
|
||||||
const scrollToPreview = (sectionId: string) => {
|
|
||||||
const element = document.getElementById(`preview-${sectionId}`);
|
|
||||||
if (element) {
|
|
||||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTabChange = (tab: string) => {
|
|
||||||
setActiveTab(tab);
|
|
||||||
setTimeout(() => scrollToPreview(tab), 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState<ContactContent>({
|
|
||||||
hero: {
|
|
||||||
pretitle: 'Fale Conosco',
|
|
||||||
title: 'Entre em Contato',
|
|
||||||
subtitle: 'Nossa equipe está pronta para atender você e transformar suas ideias em realidade.'
|
|
||||||
},
|
|
||||||
info: {
|
|
||||||
title: 'Informações de Contato',
|
|
||||||
subtitle: 'Estamos à disposição',
|
|
||||||
description: 'Estamos à disposição para atender sua empresa com a excelência técnica que seu projeto exige.',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
icon: 'ri-whatsapp-line',
|
|
||||||
title: 'WhatsApp',
|
|
||||||
description: 'Atendimento rápido e direto',
|
|
||||||
link: 'https://wa.me/5527999999999',
|
|
||||||
linkText: '(27) 99999-9999'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'ri-mail-send-line',
|
|
||||||
title: 'E-mail',
|
|
||||||
description: 'Envie sua mensagem',
|
|
||||||
link: 'mailto:contato@octto.com.br',
|
|
||||||
linkText: 'contato@octto.com.br'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'ri-map-pin-line',
|
|
||||||
title: 'Endereço',
|
|
||||||
description: 'Av. Nossa Senhora da Penha, 1234\nSanta Lúcia, Vitória - ES\nCEP: 29056-000',
|
|
||||||
link: 'https://maps.google.com',
|
|
||||||
linkText: 'Ver no mapa'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchPageContent();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchPageContent = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/pages/contact');
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.content) {
|
|
||||||
setFormData(prevData => ({
|
|
||||||
hero: data.content.hero || prevData.hero,
|
|
||||||
info: data.content.info || prevData.info
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Nenhum conteúdo salvo ainda, usando padrão');
|
|
||||||
} finally {
|
|
||||||
setInitialLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/pages/contact', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ content: formData })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Erro ao salvar');
|
|
||||||
|
|
||||||
await response.json();
|
|
||||||
success('Conteúdo salvo com sucesso!');
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.dispatchEvent(new Event('translation:refresh'));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
showError('Erro ao salvar alterações');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (initialLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<style jsx global>{`
|
|
||||||
main { padding: 0 !important; }
|
|
||||||
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
|
||||||
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
|
|
||||||
`}</style>
|
|
||||||
<div className="fixed top-20 bottom-0 left-64 right-0 flex gap-0 bg-gray-50 dark:bg-tertiary">
|
|
||||||
{/* Formulário de Edição - Coluna Esquerda 30% */}
|
|
||||||
<div className="w-[30%] shrink-0 overflow-y-auto bg-white dark:bg-secondary relative">
|
|
||||||
<div className="absolute top-0 right-0 bottom-0 w-px bg-gray-200 dark:bg-white/10"></div>
|
|
||||||
|
|
||||||
<div className="p-6 border-b border-gray-200 dark:border-white/10">
|
|
||||||
<h1 className="text-2xl font-bold">Editar Página Contato</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Personalize informações de contato
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation Tabs */}
|
|
||||||
<div className="relative border-b border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const container = document.getElementById('tabs-container');
|
|
||||||
if (container) container.scrollLeft -= 200;
|
|
||||||
}}
|
|
||||||
className="absolute left-0 z-10 w-10 h-full bg-white dark:bg-secondary border-r border-gray-200 dark:border-white/10 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-white/5 transition-all cursor-pointer"
|
|
||||||
>
|
|
||||||
<i className="ri-arrow-left-s-line text-xl text-gray-600 dark:text-gray-400"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div id="tabs-container" className="flex gap-2 p-4 overflow-x-auto scrollbar-hide scroll-smooth px-12">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleTabChange('hero')}
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors cursor-pointer ${
|
|
||||||
activeTab === 'hero'
|
|
||||||
? 'bg-primary text-primary-foreground'
|
|
||||||
: 'bg-background hover:bg-muted'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Banner
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleTabChange('info')}
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors cursor-pointer ${
|
|
||||||
activeTab === 'info'
|
|
||||||
? 'bg-primary text-primary-foreground'
|
|
||||||
: 'bg-background hover:bg-muted'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Informações (3)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const container = document.getElementById('tabs-container');
|
|
||||||
if (container) container.scrollLeft += 200;
|
|
||||||
}}
|
|
||||||
className="absolute right-0 z-10 w-10 h-full bg-white dark:bg-secondary border-l border-gray-200 dark:border-white/10 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-white/5 transition-all cursor-pointer"
|
|
||||||
>
|
|
||||||
<i className="ri-arrow-right-s-line text-xl text-gray-600 dark:text-gray-400"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-6 pb-20">
|
|
||||||
|
|
||||||
{/* Hero Section */}
|
|
||||||
{activeTab === 'hero' && (
|
|
||||||
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
|
|
||||||
<h2 className="text-xl font-bold text-secondary dark:text-white mb-6 flex items-center gap-2">
|
|
||||||
<i className="ri-layout-top-line text-primary"></i>
|
|
||||||
Banner Principal
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6">
|
|
||||||
<div>
|
|
||||||
<LabelWithLimit
|
|
||||||
label="Pré-título"
|
|
||||||
value={formData.hero.pretitle}
|
|
||||||
limit={CONTACT_TEXT_LIMITS.hero.pretitle}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.hero.pretitle}
|
|
||||||
onChange={(e) => setFormData({...formData, hero: {...formData.hero, pretitle: e.target.value}})}
|
|
||||||
maxLength={CONTACT_TEXT_LIMITS.hero.pretitle}
|
|
||||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<LabelWithLimit
|
|
||||||
label="Título Principal"
|
|
||||||
value={formData.hero.title}
|
|
||||||
limit={CONTACT_TEXT_LIMITS.hero.title}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.hero.title}
|
|
||||||
onChange={(e) => setFormData({...formData, hero: {...formData.hero, title: e.target.value}})}
|
|
||||||
maxLength={CONTACT_TEXT_LIMITS.hero.title}
|
|
||||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<LabelWithLimit
|
|
||||||
label="Subtítulo"
|
|
||||||
value={formData.hero.subtitle}
|
|
||||||
limit={CONTACT_TEXT_LIMITS.hero.subtitle}
|
|
||||||
/>
|
|
||||||
<textarea
|
|
||||||
value={formData.hero.subtitle}
|
|
||||||
onChange={(e) => setFormData({...formData, hero: {...formData.hero, subtitle: e.target.value}})}
|
|
||||||
rows={2}
|
|
||||||
maxLength={CONTACT_TEXT_LIMITS.hero.subtitle}
|
|
||||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all resize-none"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Info Section */}
|
|
||||||
{activeTab === 'info' && (
|
|
||||||
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
|
|
||||||
<h2 className="text-xl font-bold text-secondary dark:text-white mb-6 flex items-center gap-2">
|
|
||||||
<i className="ri-information-line text-primary"></i>
|
|
||||||
Informações de Contato
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 mb-6">
|
|
||||||
<div>
|
|
||||||
<LabelWithLimit
|
|
||||||
label="Pré-título"
|
|
||||||
value={formData.info.title}
|
|
||||||
limit={CONTACT_TEXT_LIMITS.info.title}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.info.title}
|
|
||||||
onChange={(e) => setFormData({...formData, info: {...formData.info, title: e.target.value}})}
|
|
||||||
maxLength={CONTACT_TEXT_LIMITS.info.title}
|
|
||||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<LabelWithLimit
|
|
||||||
label="Título da Seção"
|
|
||||||
value={formData.info.subtitle}
|
|
||||||
limit={CONTACT_TEXT_LIMITS.info.subtitle}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.info.subtitle}
|
|
||||||
onChange={(e) => setFormData({...formData, info: {...formData.info, subtitle: e.target.value}})}
|
|
||||||
maxLength={CONTACT_TEXT_LIMITS.info.subtitle}
|
|
||||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<LabelWithLimit
|
|
||||||
label="Descrição"
|
|
||||||
value={formData.info.description}
|
|
||||||
limit={CONTACT_TEXT_LIMITS.info.description}
|
|
||||||
/>
|
|
||||||
<textarea
|
|
||||||
value={formData.info.description}
|
|
||||||
onChange={(e) => setFormData({...formData, info: {...formData.info, description: e.target.value}})}
|
|
||||||
rows={2}
|
|
||||||
maxLength={CONTACT_TEXT_LIMITS.info.description}
|
|
||||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all resize-none"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{formData.info.items.map((item, index) => (
|
|
||||||
<div key={index} className="p-6 bg-gray-50 dark:bg-white/5 rounded-xl border border-gray-200 dark:border-white/10">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="font-bold text-gray-900 dark:text-white">Contato {index + 1}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-4">
|
|
||||||
<IconSelector
|
|
||||||
label="Ícone"
|
|
||||||
value={item.icon}
|
|
||||||
onChange={(icon) => {
|
|
||||||
const newItems = [...formData.info.items];
|
|
||||||
newItems[index].icon = icon;
|
|
||||||
setFormData({...formData, info: {...formData.info, items: newItems}});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<LabelWithLimit
|
|
||||||
label="Título"
|
|
||||||
value={item.title}
|
|
||||||
limit={CONTACT_TEXT_LIMITS.info.itemTitle}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={item.title}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newItems = [...formData.info.items];
|
|
||||||
newItems[index].title = e.target.value;
|
|
||||||
setFormData({...formData, info: {...formData.info, items: newItems}});
|
|
||||||
}}
|
|
||||||
maxLength={CONTACT_TEXT_LIMITS.info.itemTitle}
|
|
||||||
className="w-full px-4 py-3 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<LabelWithLimit
|
|
||||||
label="Descrição"
|
|
||||||
value={item.description}
|
|
||||||
limit={CONTACT_TEXT_LIMITS.info.itemDescription}
|
|
||||||
/>
|
|
||||||
<textarea
|
|
||||||
value={item.description}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newItems = [...formData.info.items];
|
|
||||||
newItems[index].description = e.target.value;
|
|
||||||
setFormData({...formData, info: {...formData.info, items: newItems}});
|
|
||||||
}}
|
|
||||||
rows={3}
|
|
||||||
maxLength={CONTACT_TEXT_LIMITS.info.itemDescription}
|
|
||||||
className="w-full px-4 py-3 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all resize-none"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<LabelWithLimit
|
|
||||||
label="Link"
|
|
||||||
value={item.link}
|
|
||||||
limit={CONTACT_TEXT_LIMITS.info.link}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={item.link}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newItems = [...formData.info.items];
|
|
||||||
newItems[index].link = e.target.value;
|
|
||||||
setFormData({...formData, info: {...formData.info, items: newItems}});
|
|
||||||
}}
|
|
||||||
placeholder="https://..."
|
|
||||||
maxLength={CONTACT_TEXT_LIMITS.info.link}
|
|
||||||
className="w-full px-4 py-3 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<LabelWithLimit
|
|
||||||
label="Texto do Link"
|
|
||||||
value={item.linkText}
|
|
||||||
limit={CONTACT_TEXT_LIMITS.info.linkText}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={item.linkText}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newItems = [...formData.info.items];
|
|
||||||
newItems[index].linkText = e.target.value;
|
|
||||||
setFormData({...formData, info: {...formData.info, items: newItems}});
|
|
||||||
}}
|
|
||||||
maxLength={CONTACT_TEXT_LIMITS.info.linkText}
|
|
||||||
className="w-full px-4 py-3 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="fixed bottom-0 left-64 flex items-center justify-end gap-4 p-4 bg-white dark:bg-secondary border-t border-gray-200 dark:border-white/10 shadow-lg z-20" style={{ width: 'calc((100vw - 256px) * 0.3)' }}>
|
|
||||||
<Link
|
|
||||||
href="/admin/paginas"
|
|
||||||
className="px-6 py-2.5 border border-gray-200 dark:border-white/10 text-gray-600 dark:text-gray-300 rounded-xl font-bold hover:bg-gray-50 dark:hover:bg-white/5 transition-colors text-sm"
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="px-6 py-2.5 bg-primary text-white rounded-xl font-bold hover-primary transition-colors shadow-lg shadow-primary/20 flex items-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed cursor-pointer text-sm"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<i className="ri-loader-4-line animate-spin"></i>
|
|
||||||
Salvando...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<i className="ri-save-line"></i>
|
|
||||||
Salvar
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Preview em Tempo Real - Coluna Direita Grande */}
|
|
||||||
<div className="flex-1 overflow-y-auto bg-white dark:bg-secondary">
|
|
||||||
<div className="sticky top-0 z-10 p-4 bg-gray-50 dark:bg-white/5 border-b border-gray-200 dark:border-white/10">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
|
||||||
<i className="ri-eye-line text-primary"></i>
|
|
||||||
Preview em Tempo Real
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Visualização aproximada da página pública</p>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/contato"
|
|
||||||
target="_blank"
|
|
||||||
className="px-4 py-2 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 text-gray-700 dark:text-gray-300 rounded-lg font-medium hover:bg-gray-50 dark:hover:bg-white/5 transition-colors flex items-center gap-2 text-sm cursor-pointer"
|
|
||||||
>
|
|
||||||
<i className="ri-external-link-line"></i>
|
|
||||||
Ver Página Real
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-8">
|
|
||||||
{/* Hero Preview */}
|
|
||||||
{activeTab === 'hero' && (
|
|
||||||
<div id="preview-hero" className="space-y-4">
|
|
||||||
<div className="inline-flex items-center gap-2 bg-primary/20 backdrop-blur-sm border border-primary/30 rounded-full px-4 py-1">
|
|
||||||
<span className="w-2 h-2 rounded-full bg-primary animate-pulse"></span>
|
|
||||||
<span className="text-sm font-bold text-primary uppercase tracking-wider">{formData.hero.pretitle}</span>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-5xl font-bold font-headline text-secondary dark:text-white leading-tight">
|
|
||||||
{formData.hero.title}
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-gray-600 dark:text-gray-300 max-w-2xl leading-relaxed">
|
|
||||||
{formData.hero.subtitle}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Info Preview */}
|
|
||||||
{activeTab === 'info' && (
|
|
||||||
<div id="preview-info" className="space-y-8">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-primary font-bold tracking-wider uppercase mb-3">{formData.info.title}</h2>
|
|
||||||
<h3 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-6">{formData.info.subtitle}</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300 text-lg leading-relaxed">
|
|
||||||
{formData.info.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{formData.info.items.map((item, index) => (
|
|
||||||
<div key={index} className="group bg-gray-50 dark:bg-white/5 p-6 rounded-2xl border border-gray-100 dark:border-white/10 hover:border-primary/50 transition-colors">
|
|
||||||
<div className="flex items-start gap-5">
|
|
||||||
<div className="w-14 h-14 bg-white dark:bg-white/10 rounded-xl flex items-center justify-center text-primary shadow-sm">
|
|
||||||
<i className={`${item.icon} text-3xl`}></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-2">{item.title}</h4>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-3 text-sm whitespace-pre-line">{item.description}</p>
|
|
||||||
<a href={item.link} className="inline-flex items-center gap-2 text-primary font-bold hover:gap-3 transition-all">
|
|
||||||
{item.linkText} <i className="ri-arrow-right-line"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -250,6 +250,10 @@ interface HomeContent {
|
|||||||
subtitle: string;
|
subtitle: string;
|
||||||
buttonText: string;
|
buttonText: string;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
|
badge?: {
|
||||||
|
text: string;
|
||||||
|
show: boolean;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
features: {
|
features: {
|
||||||
pretitle: string;
|
pretitle: string;
|
||||||
@@ -307,7 +311,11 @@ export default function EditHomePage() {
|
|||||||
hero: {
|
hero: {
|
||||||
title: 'Engenharia de Ponta para Seus Projetos',
|
title: 'Engenharia de Ponta para Seus Projetos',
|
||||||
subtitle: 'Soluções completas em engenharia veicular, mecânica e segurança do trabalho.',
|
subtitle: 'Soluções completas em engenharia veicular, mecânica e segurança do trabalho.',
|
||||||
buttonText: 'Conheça Nossos Serviços'
|
buttonText: 'Conheça Nossos Serviços',
|
||||||
|
badge: {
|
||||||
|
text: 'Coca-Cola',
|
||||||
|
show: true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
features: {
|
features: {
|
||||||
pretitle: 'Diferenciais',
|
pretitle: 'Diferenciais',
|
||||||
@@ -371,7 +379,11 @@ export default function EditHomePage() {
|
|||||||
if (data.content) {
|
if (data.content) {
|
||||||
// Mesclar com valores padrão para garantir que todas as propriedades existam
|
// Mesclar com valores padrão para garantir que todas as propriedades existam
|
||||||
setFormData(prevData => ({
|
setFormData(prevData => ({
|
||||||
hero: data.content.hero || prevData.hero,
|
hero: {
|
||||||
|
...prevData.hero,
|
||||||
|
...data.content.hero,
|
||||||
|
badge: data.content.hero?.badge || prevData.hero.badge
|
||||||
|
},
|
||||||
features: data.content.features || prevData.features,
|
features: data.content.features || prevData.features,
|
||||||
services: data.content.services || prevData.services,
|
services: data.content.services || prevData.services,
|
||||||
about: data.content.about || prevData.about,
|
about: data.content.about || prevData.about,
|
||||||
@@ -602,6 +614,64 @@ export default function EditHomePage() {
|
|||||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-200 dark:border-white/10 pt-6 mt-6">
|
||||||
|
<h3 className="text-sm font-bold text-secondary dark:text-white mb-4 flex items-center gap-2">
|
||||||
|
<i className="ri-verified-badge-fill text-primary"></i>
|
||||||
|
Badge (Crachá) no Banner
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-white/5 rounded-xl border border-gray-200 dark:border-white/10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.hero.badge?.show || false}
|
||||||
|
onChange={(e) => setFormData({
|
||||||
|
...formData,
|
||||||
|
hero: {
|
||||||
|
...formData.hero,
|
||||||
|
badge: {
|
||||||
|
...(formData.hero.badge || { text: '', show: false }),
|
||||||
|
show: e.target.checked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
className="w-5 h-5 accent-primary cursor-pointer"
|
||||||
|
/>
|
||||||
|
<label className="text-sm font-bold text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||||
|
Exibir badge no banner principal
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.hero.badge?.show && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Texto da Badge (ex: Coca-Cola, Parceiro Oficial, etc.)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.hero.badge?.text || ''}
|
||||||
|
onChange={(e) => setFormData({
|
||||||
|
...formData,
|
||||||
|
hero: {
|
||||||
|
...formData.hero,
|
||||||
|
badge: {
|
||||||
|
...(formData.hero.badge || { text: '', show: false }),
|
||||||
|
text: e.target.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
placeholder="Digite o nome da empresa ou parceiro"
|
||||||
|
maxLength={50}
|
||||||
|
className="w-full px-4 py-3 bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||||
|
{formData.hero.badge?.text?.length || 0}/50 caracteres
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -29,12 +29,6 @@ export default function PagesList() {
|
|||||||
desc: 'História da empresa, missão, visão e valores.',
|
desc: 'História da empresa, missão, visão e valores.',
|
||||||
icon: 'ri-team-line'
|
icon: 'ri-team-line'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: 'Contato',
|
|
||||||
slug: 'contato',
|
|
||||||
desc: 'Endereço, telefones, emails e horário de funcionamento.',
|
|
||||||
icon: 'ri-contacts-book-line'
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -97,13 +91,37 @@ export default function PagesList() {
|
|||||||
|
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/paginas/${pageDef.slug}`}
|
href={`/admin/paginas/${pageDef.slug}`}
|
||||||
className="block w-full py-3 text-center rounded-xl border border-gray-200 dark:border-white/10 font-bold text-gray-600 dark:text-gray-300 hover:bg-primary hover:text-white hover:border-primary transition-all"
|
className="block w-full py-3 text-center rounded-xl border border-gray-200 dark:border-white/10 font-bold text-gray-600 dark:text-gray-300 hover:bg-primary hover:text-white hover:border-primary transition-all cursor-pointer"
|
||||||
>
|
>
|
||||||
Editar Conteúdo
|
Editar Conteúdo
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Card especial para Contato - redireciona para Configurações */}
|
||||||
|
<div className="bg-white dark:bg-secondary rounded-2xl border border-gray-200 dark:border-white/10 p-6 shadow-sm hover:shadow-md transition-all group">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-blue-500/10 text-blue-500 flex items-center justify-center text-2xl group-hover:scale-110 transition-transform">
|
||||||
|
<i className="ri-contacts-book-line"></i>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium px-2 py-1 rounded-lg text-blue-600 bg-blue-100 dark:bg-blue-900/30">
|
||||||
|
<i className="ri-settings-3-line mr-1"></i>
|
||||||
|
Configurações
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-bold text-secondary dark:text-white mb-2">Contato</h3>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-sm mb-6 h-10">Endereço, telefones, emails e redes sociais.</p>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/admin/configuracoes?tab=contato"
|
||||||
|
className="block w-full py-3 text-center rounded-xl border border-blue-200 dark:border-blue-800 font-bold text-blue-600 dark:text-blue-400 hover:bg-blue-500 hover:text-white hover:border-blue-500 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<i className="ri-settings-3-line mr-2"></i>
|
||||||
|
Ir para Configurações
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export default function EditProject({ params }: { params: { id: string } }) {
|
|||||||
const galleryInputRef = useRef<HTMLInputElement | null>(null);
|
const galleryInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [loadingData, setLoadingData] = useState(true);
|
const [loadingData, setLoadingData] = useState(true);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [categories, setCategories] = useState(CATEGORY_OPTIONS);
|
||||||
|
const [newCategory, setNewCategory] = useState('');
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
title: '',
|
title: '',
|
||||||
category: '',
|
category: '',
|
||||||
@@ -276,10 +278,39 @@ export default function EditProject({ params }: { params: { id: string } }) {
|
|||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">Selecione uma categoria</option>
|
<option value="">Selecione uma categoria</option>
|
||||||
{CATEGORY_OPTIONS.map((option) => (
|
{categories.map((option) => (
|
||||||
<option key={option.value} value={option.value}>{option.label}</option>
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">Não encontra a categoria? Adicione uma nova abaixo</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Adicionar Nova Categoria</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newCategory}
|
||||||
|
onChange={(e) => setNewCategory(e.target.value)}
|
||||||
|
className="flex-1 px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||||
|
placeholder="Ex: Consultoria Ambiental"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (newCategory.trim()) {
|
||||||
|
const newCat = { value: newCategory, label: newCategory };
|
||||||
|
setCategories([...categories, newCat]);
|
||||||
|
setFormData({...formData, category: newCategory});
|
||||||
|
setNewCategory('');
|
||||||
|
success('Categoria adicionada!');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-4 py-3 bg-primary text-white rounded-xl font-medium hover:bg-primary/90 transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Adicionar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export default function NewProject() {
|
|||||||
const coverInputRef = useRef<HTMLInputElement | null>(null);
|
const coverInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const galleryInputRef = useRef<HTMLInputElement | null>(null);
|
const galleryInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [categories, setCategories] = useState(CATEGORY_OPTIONS);
|
||||||
|
const [newCategory, setNewCategory] = useState('');
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
title: '',
|
title: '',
|
||||||
category: '',
|
category: '',
|
||||||
@@ -217,10 +219,39 @@ export default function NewProject() {
|
|||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">Selecione uma categoria</option>
|
<option value="">Selecione uma categoria</option>
|
||||||
{CATEGORY_OPTIONS.map((option) => (
|
{categories.map((option) => (
|
||||||
<option key={option.value} value={option.value}>{option.label}</option>
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">Não encontra a categoria? Adicione uma nova abaixo</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Adicionar Nova Categoria</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newCategory}
|
||||||
|
onChange={(e) => setNewCategory(e.target.value)}
|
||||||
|
className="flex-1 px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||||
|
placeholder="Ex: Consultoria Ambiental"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (newCategory.trim()) {
|
||||||
|
const newCat = { value: newCategory, label: newCategory };
|
||||||
|
setCategories([...categories, newCat]);
|
||||||
|
setFormData({...formData, category: newCategory});
|
||||||
|
setNewCategory('');
|
||||||
|
success('Categoria adicionada!');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-4 py-3 bg-primary text-white rounded-xl font-medium hover:bg-primary/90 transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Adicionar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
71
frontend/src/app/api/backup/download/route.ts
Normal file
71
frontend/src/app/api/backup/download/route.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { createReadStream } from 'fs';
|
||||||
|
|
||||||
|
const BACKUP_DIR = path.join(process.cwd(), '.backups');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/backup/download?file=backup-filename.tar.gz
|
||||||
|
* Faz download de um backup específico
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Comentado: Você pode descomentar e implementar sua autenticação
|
||||||
|
// const authHeader = request.headers.get('authorization');
|
||||||
|
// if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
// return NextResponse.json(
|
||||||
|
// { success: false, error: 'Não autorizado' },
|
||||||
|
// { status: 401 }
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const filename = searchParams.get('file');
|
||||||
|
|
||||||
|
if (!filename) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Arquivo não especificado' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar que o arquivo está dentro do diretório de backups (prevenir path traversal)
|
||||||
|
const backupPath = path.resolve(path.join(BACKUP_DIR, filename));
|
||||||
|
const resolvedBackupDir = path.resolve(BACKUP_DIR);
|
||||||
|
|
||||||
|
if (!backupPath.startsWith(resolvedBackupDir)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Acesso negado' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(backupPath)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Arquivo não encontrado' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = fs.statSync(backupPath);
|
||||||
|
const fileStream = createReadStream(backupPath);
|
||||||
|
|
||||||
|
return new NextResponse(fileStream as any, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/gzip',
|
||||||
|
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||||
|
'Content-Length': stat.size.toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BACKUP] Erro ao fazer download:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: (error as Error).message
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
135
frontend/src/app/api/backup/restore/route.ts
Normal file
135
frontend/src/app/api/backup/restore/route.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
|
// Variáveis de ambiente
|
||||||
|
const POSTGRES_USER = process.env.POSTGRES_USER || 'admin';
|
||||||
|
const POSTGRES_PASSWORD = process.env.POSTGRES_PASSWORD || 'adminpassword';
|
||||||
|
const POSTGRES_DB = process.env.POSTGRES_DB || 'occto_db';
|
||||||
|
const POSTGRES_HOST = process.env.POSTGRES_HOST || 'postgres';
|
||||||
|
const POSTGRES_PORT = process.env.POSTGRES_PORT || '5432';
|
||||||
|
|
||||||
|
const BACKUP_DIR = path.join(process.cwd(), '.backups');
|
||||||
|
|
||||||
|
interface RestoreResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/backup/restore?file=backup-filename.tar.gz
|
||||||
|
* Restaura um backup completo (PostgreSQL + MinIO)
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const filename = searchParams.get('file');
|
||||||
|
|
||||||
|
if (!filename) {
|
||||||
|
return NextResponse.json<RestoreResponse>(
|
||||||
|
{ success: false, message: 'Arquivo não especificado', error: 'Arquivo não foi informado' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar que o arquivo existe e está no diretório certo
|
||||||
|
const backupPath = path.resolve(path.join(BACKUP_DIR, filename));
|
||||||
|
const resolvedBackupDir = path.resolve(BACKUP_DIR);
|
||||||
|
|
||||||
|
if (!backupPath.startsWith(resolvedBackupDir)) {
|
||||||
|
return NextResponse.json<RestoreResponse>(
|
||||||
|
{ success: false, message: 'Acesso negado', error: 'Caminho inválido' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(backupPath)) {
|
||||||
|
return NextResponse.json<RestoreResponse>(
|
||||||
|
{ success: false, message: 'Backup não encontrado', error: 'Arquivo não existe' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[RESTORE] Iniciando restauração do backup:', filename);
|
||||||
|
|
||||||
|
// Extrair o arquivo .tar.gz
|
||||||
|
const extractDir = path.join(BACKUP_DIR, `restore-${Date.now()}`);
|
||||||
|
fs.mkdirSync(extractDir, { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[RESTORE] Extraindo arquivo...');
|
||||||
|
const tarCommand = `tar -xzf "${backupPath}" -C "${extractDir}"`;
|
||||||
|
execSync(tarCommand, { stdio: 'pipe' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[RESTORE] Erro ao extrair:', error);
|
||||||
|
return NextResponse.json<RestoreResponse>(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
message: 'Erro ao extrair backup',
|
||||||
|
error: (error as Error).message
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Restaurar PostgreSQL
|
||||||
|
const dbFile = path.join(extractDir, 'database.sql');
|
||||||
|
if (fs.existsSync(dbFile)) {
|
||||||
|
try {
|
||||||
|
console.log('[RESTORE] Restaurando PostgreSQL...');
|
||||||
|
|
||||||
|
// Descartar banco existente
|
||||||
|
const dropDbCommand = `PGPASSWORD="${POSTGRES_PASSWORD}" psql -h ${POSTGRES_HOST} -U ${POSTGRES_USER} -tc "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '${POSTGRES_DB}' AND pid <> pg_backend_pid();" && PGPASSWORD="${POSTGRES_PASSWORD}" dropdb -h ${POSTGRES_HOST} -U ${POSTGRES_USER} ${POSTGRES_DB}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(dropDbCommand, { stdio: 'pipe', env: { ...process.env, PGPASSWORD: POSTGRES_PASSWORD } });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[RESTORE] Aviso ao dropar banco:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar banco novo
|
||||||
|
const createDbCommand = `PGPASSWORD="${POSTGRES_PASSWORD}" createdb -h ${POSTGRES_HOST} -U ${POSTGRES_USER} ${POSTGRES_DB}`;
|
||||||
|
execSync(createDbCommand, { stdio: 'pipe', env: { ...process.env, PGPASSWORD: POSTGRES_PASSWORD } });
|
||||||
|
|
||||||
|
// Restaurar dump
|
||||||
|
const restoreCommand = `PGPASSWORD="${POSTGRES_PASSWORD}" psql -h ${POSTGRES_HOST} -U ${POSTGRES_USER} -d ${POSTGRES_DB} < "${dbFile}"`;
|
||||||
|
execSync(restoreCommand, { stdio: 'pipe', env: { ...process.env, PGPASSWORD: POSTGRES_PASSWORD } });
|
||||||
|
|
||||||
|
console.log('[RESTORE] PostgreSQL restaurado com sucesso');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[RESTORE] Erro ao restaurar PostgreSQL:', error);
|
||||||
|
// Não parar aqui, tentar restaurar MinIO também
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('[RESTORE] Arquivo database.sql não encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Restaurar MinIO (files)
|
||||||
|
// Nota: A restauração do MinIO é mais complexa pois envolve copiar para o volume
|
||||||
|
// Por enquanto, informamos ao usuário que ele precisa restaurar manualmente
|
||||||
|
console.log('[RESTORE] Nota: MinIO precisa ser restaurado manualmente');
|
||||||
|
|
||||||
|
// Limpar arquivos temporários
|
||||||
|
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
return NextResponse.json<RestoreResponse>(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: 'Backup restaurado com sucesso! PostgreSQL foi restaurado. Reinicie a aplicação se necessário.'
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[RESTORE] Erro geral:', error);
|
||||||
|
return NextResponse.json<RestoreResponse>(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
message: 'Erro ao restaurar backup',
|
||||||
|
error: (error as Error).message
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
415
frontend/src/app/api/backup/route.ts
Normal file
415
frontend/src/app/api/backup/route.ts
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { createReadStream } from 'fs';
|
||||||
|
|
||||||
|
// Variáveis de ambiente
|
||||||
|
const POSTGRES_USER = process.env.POSTGRES_USER || 'admin';
|
||||||
|
const POSTGRES_PASSWORD = process.env.POSTGRES_PASSWORD || 'adminpassword';
|
||||||
|
const POSTGRES_DB = process.env.POSTGRES_DB || 'occto_db';
|
||||||
|
const POSTGRES_HOST = process.env.POSTGRES_HOST || 'postgres';
|
||||||
|
const POSTGRES_PORT = process.env.POSTGRES_PORT || '5432';
|
||||||
|
|
||||||
|
const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || 'minio';
|
||||||
|
const MINIO_PORT = process.env.MINIO_PORT || '9000';
|
||||||
|
const MINIO_ACCESS_KEY = process.env.MINIO_ACCESS_KEY || 'admin';
|
||||||
|
const MINIO_SECRET_KEY = process.env.MINIO_SECRET_KEY || 'adminpassword';
|
||||||
|
const MINIO_BUCKET_NAME = process.env.MINIO_BUCKET_NAME || 'occto-images';
|
||||||
|
const MINIO_USE_SSL = process.env.MINIO_USE_SSL === 'true';
|
||||||
|
|
||||||
|
// Diretório para armazenar backups
|
||||||
|
const BACKUP_DIR = path.join(process.cwd(), '.backups');
|
||||||
|
|
||||||
|
// Criar diretório de backups se não existir
|
||||||
|
if (!fs.existsSync(BACKUP_DIR)) {
|
||||||
|
fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackupInfo {
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
date: string;
|
||||||
|
size: number;
|
||||||
|
filename: string;
|
||||||
|
status: 'success' | 'error';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackupResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
backup?: BackupInfo;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/backup
|
||||||
|
* Cria um backup completo do PostgreSQL e MinIO
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Comentado: Você pode descomentar e implementar sua autenticação
|
||||||
|
// const authHeader = request.headers.get('authorization');
|
||||||
|
// if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
// return NextResponse.json(
|
||||||
|
// { success: false, error: 'Não autorizado' },
|
||||||
|
// { status: 401 }
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('-').slice(0, 4).join('-');
|
||||||
|
const backupId = `backup-${timestamp}`;
|
||||||
|
const backupPath = path.join(BACKUP_DIR, backupId);
|
||||||
|
|
||||||
|
// Criar pasta do backup
|
||||||
|
fs.mkdirSync(backupPath, { recursive: true });
|
||||||
|
|
||||||
|
const backupInfo: BackupInfo = {
|
||||||
|
id: backupId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
date: new Date().toLocaleDateString('pt-BR'),
|
||||||
|
size: 0,
|
||||||
|
filename: backupId,
|
||||||
|
status: 'success',
|
||||||
|
message: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Fazer backup do PostgreSQL
|
||||||
|
try {
|
||||||
|
console.log('[BACKUP] Iniciando backup do PostgreSQL...');
|
||||||
|
const pgBackupPath = path.join(backupPath, 'database.sql');
|
||||||
|
|
||||||
|
// Usar execSync com shell shell: true para permitir redirecionamento de arquivo
|
||||||
|
const env = { ...process.env, PGPASSWORD: POSTGRES_PASSWORD };
|
||||||
|
const pgCommand = `pg_dump -h ${POSTGRES_HOST} -U ${POSTGRES_USER} -d ${POSTGRES_DB} -v`;
|
||||||
|
|
||||||
|
const output = execSync(pgCommand, {
|
||||||
|
env,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
maxBuffer: 50 * 1024 * 1024 // 50MB buffer
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escrever o output em arquivo
|
||||||
|
fs.writeFileSync(pgBackupPath, output);
|
||||||
|
|
||||||
|
const dbSize = fs.statSync(pgBackupPath).size;
|
||||||
|
console.log(`[BACKUP] PostgreSQL backup concluído (${dbSize} bytes)`);
|
||||||
|
|
||||||
|
if (dbSize < 1000) {
|
||||||
|
console.warn('[BACKUP] AVISO: Arquivo de backup muito pequeno, pode estar vazio!');
|
||||||
|
backupInfo.message += 'Aviso: Backup do PostgreSQL pode estar incompleto. ';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BACKUP] Erro ao fazer backup do PostgreSQL:', error);
|
||||||
|
backupInfo.status = 'error';
|
||||||
|
backupInfo.message += `Erro PostgreSQL: ${(error as Error).message}. `;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fazer backup do MinIO (copiar os dados)
|
||||||
|
try {
|
||||||
|
console.log('[BACKUP] Iniciando backup do MinIO...');
|
||||||
|
const minioBackupPath = path.join(backupPath, 'minio-data');
|
||||||
|
|
||||||
|
// Se estiver usando Docker, copiar do volume
|
||||||
|
// Se estiver local, copiar do diretório minio_data
|
||||||
|
const minioDataPath = path.join(process.cwd(), '..', 'minio_data');
|
||||||
|
|
||||||
|
if (fs.existsSync(minioDataPath)) {
|
||||||
|
// Copiar recursivamente
|
||||||
|
copyDirSync(minioDataPath, minioBackupPath);
|
||||||
|
console.log('[BACKUP] MinIO backup concluído');
|
||||||
|
} else {
|
||||||
|
console.warn('[BACKUP] Diretório MinIO não encontrado em:', minioDataPath);
|
||||||
|
backupInfo.message += `Aviso: MinIO local não encontrado. `;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BACKUP] Erro ao fazer backup do MinIO:', error);
|
||||||
|
backupInfo.message += `Erro MinIO: ${(error as Error).message}. `;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Criar arquivo metadata.json
|
||||||
|
const metadataPath = path.join(backupPath, 'metadata.json');
|
||||||
|
fs.writeFileSync(metadataPath, JSON.stringify({
|
||||||
|
timestamp: backupInfo.timestamp,
|
||||||
|
database: POSTGRES_DB,
|
||||||
|
hostname: POSTGRES_HOST,
|
||||||
|
minioEndpoint: MINIO_ENDPOINT,
|
||||||
|
version: '1.0'
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
// 4. Calcular tamanho do backup
|
||||||
|
const size = calculateDirSize(backupPath);
|
||||||
|
backupInfo.size = size;
|
||||||
|
|
||||||
|
// 5. Compactar backup (opcional - melhor para armazenamento)
|
||||||
|
const compressBackup = true;
|
||||||
|
if (compressBackup) {
|
||||||
|
try {
|
||||||
|
console.log('[BACKUP] Compactando backup...');
|
||||||
|
const tarCommand = `tar -czf "${path.join(BACKUP_DIR, backupId)}.tar.gz" -C "${BACKUP_DIR}" "${backupId}"`;
|
||||||
|
execSync(tarCommand, { stdio: 'pipe' });
|
||||||
|
|
||||||
|
// Remover pasta original após compactar
|
||||||
|
fs.rmSync(backupPath, { recursive: true, force: true });
|
||||||
|
|
||||||
|
backupInfo.filename = `${backupId}.tar.gz`;
|
||||||
|
console.log('[BACKUP] Compactação concluída');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[BACKUP] Erro ao compactar:', error);
|
||||||
|
backupInfo.message += `Aviso: Não foi possível compactar. `;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backupInfo.status === 'error' && backupInfo.message) {
|
||||||
|
return NextResponse.json<BackupResponse>(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
message: 'Backup concluído com erros',
|
||||||
|
backup: backupInfo,
|
||||||
|
error: backupInfo.message
|
||||||
|
},
|
||||||
|
{ status: 207 } // Multi-status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json<BackupResponse>(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: 'Backup realizado com sucesso',
|
||||||
|
backup: backupInfo
|
||||||
|
},
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BACKUP] Erro geral:', error);
|
||||||
|
return NextResponse.json<BackupResponse>(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
message: 'Erro ao criar backup',
|
||||||
|
error: (error as Error).message
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/backup
|
||||||
|
* Lista os backups disponíveis
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Comentado: Você pode descomentar e implementar sua autenticação
|
||||||
|
// const authHeader = request.headers.get('authorization');
|
||||||
|
// if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
// return NextResponse.json(
|
||||||
|
// { success: false, error: 'Não autorizado' },
|
||||||
|
// { status: 401 }
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Verificar se pasta existe
|
||||||
|
if (!fs.existsSync(BACKUP_DIR)) {
|
||||||
|
console.log('[BACKUP] Pasta de backups não existe, criando...');
|
||||||
|
fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
backups: [],
|
||||||
|
count: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(BACKUP_DIR);
|
||||||
|
const backups: BackupInfo[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = path.join(BACKUP_DIR, file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
|
||||||
|
if (stat.isFile() && (file.endsWith('.tar.gz') || file.includes('backup-'))) {
|
||||||
|
// Extrair timestamp do nome do arquivo
|
||||||
|
const timestamp = file
|
||||||
|
.replace('backup-', '')
|
||||||
|
.replace('.tar.gz', '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Tentar fazer parse da data de forma mais robusta
|
||||||
|
let parsedDate = new Date();
|
||||||
|
try {
|
||||||
|
// Formato esperado: 2025-11-29-10-30-45
|
||||||
|
const dateParts = timestamp.split('-');
|
||||||
|
if (dateParts.length >= 3) {
|
||||||
|
const year = parseInt(dateParts[0]);
|
||||||
|
const month = parseInt(dateParts[1]) - 1; // Mês é 0-indexed
|
||||||
|
const day = parseInt(dateParts[2]);
|
||||||
|
const hour = dateParts[3] ? parseInt(dateParts[3]) : 0;
|
||||||
|
const min = dateParts[4] ? parseInt(dateParts[4]) : 0;
|
||||||
|
const sec = dateParts[5] ? parseInt(dateParts[5]) : 0;
|
||||||
|
|
||||||
|
parsedDate = new Date(year, month, day, hour, min, sec);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[BACKUP] Erro ao fazer parse da data:', timestamp);
|
||||||
|
parsedDate = new Date(stat.mtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
backups.push({
|
||||||
|
id: file.replace('.tar.gz', ''),
|
||||||
|
timestamp: parsedDate.toISOString(),
|
||||||
|
date: parsedDate.toLocaleDateString('pt-BR'),
|
||||||
|
size: stat.size,
|
||||||
|
filename: file,
|
||||||
|
status: 'success',
|
||||||
|
message: 'Backup disponível'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (fileError) {
|
||||||
|
console.warn('[BACKUP] Erro ao processar arquivo:', file, fileError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordenar por data (mais recente primeiro)
|
||||||
|
backups.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
backups,
|
||||||
|
count: backups.length
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BACKUP] Erro ao listar backups:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: (error as Error).message,
|
||||||
|
backups: [],
|
||||||
|
count: 0
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/backup?id=backup-id
|
||||||
|
* Remove um backup específico
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Comentado: Você pode descomentar e implementar sua autenticação
|
||||||
|
// const authHeader = request.headers.get('authorization');
|
||||||
|
// if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
// return NextResponse.json(
|
||||||
|
// { success: false, error: 'Não autorizado' },
|
||||||
|
// { status: 401 }
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const backupId = searchParams.get('id');
|
||||||
|
|
||||||
|
if (!backupId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'ID do backup não fornecido' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupPath = path.join(BACKUP_DIR, `${backupId}.tar.gz`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(backupPath)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Backup não encontrado' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.unlinkSync(backupPath);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Backup removido com sucesso'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BACKUP] Erro ao deletar backup:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: (error as Error).message
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Função auxiliar: copiar diretório recursivamente com log
|
||||||
|
*/
|
||||||
|
function copyDirSync(src: string, dest: string) {
|
||||||
|
if (!fs.existsSync(src)) {
|
||||||
|
console.warn(`[BACKUP] Diretório fonte não existe: ${src}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(dest)) {
|
||||||
|
fs.mkdirSync(dest, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(src);
|
||||||
|
console.log(`[BACKUP] Copiando ${files.length} arquivos de ${src}`);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const srcPath = path.join(src, file);
|
||||||
|
const destPath = path.join(dest, file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(srcPath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
copyDirSync(srcPath, destPath);
|
||||||
|
} else {
|
||||||
|
fs.copyFileSync(srcPath, destPath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[BACKUP] Erro ao copiar ${srcPath}:`, (error as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Função auxiliar: calcular tamanho do diretório
|
||||||
|
*/
|
||||||
|
function calculateDirSize(dirPath: string): number {
|
||||||
|
let size = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(dirPath)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(dirPath);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = path.join(dirPath, file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
size += calculateDirSize(filePath);
|
||||||
|
} else {
|
||||||
|
size += stat.size;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[BACKUP] Erro ao calcular tamanho de ${filePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BACKUP] Erro ao calcular tamanho do diretório:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return size;
|
||||||
|
}
|
||||||
131
frontend/src/app/api/backup/upload/route.ts
Normal file
131
frontend/src/app/api/backup/upload/route.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
|
||||||
|
// Configuração MinIO/S3
|
||||||
|
const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || 'minio';
|
||||||
|
const MINIO_PORT = process.env.MINIO_PORT || '9000';
|
||||||
|
const MINIO_ACCESS_KEY = process.env.MINIO_ACCESS_KEY || 'admin';
|
||||||
|
const MINIO_SECRET_KEY = process.env.MINIO_SECRET_KEY || 'adminpassword';
|
||||||
|
const MINIO_USE_SSL = process.env.MINIO_USE_SSL === 'true';
|
||||||
|
const MINIO_BUCKET_NAME = process.env.MINIO_BUCKET_NAME || 'backups';
|
||||||
|
|
||||||
|
// Diretório de backups locais
|
||||||
|
const BACKUP_DIR = path.join(process.cwd(), '.backups');
|
||||||
|
|
||||||
|
// Inicializar cliente S3 (MinIO é compatível com S3)
|
||||||
|
const s3Client = new S3Client({
|
||||||
|
region: 'us-east-1',
|
||||||
|
endpoint: `http${MINIO_USE_SSL ? 's' : ''}://${MINIO_ENDPOINT}:${MINIO_PORT}`,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: MINIO_ACCESS_KEY,
|
||||||
|
secretAccessKey: MINIO_SECRET_KEY,
|
||||||
|
},
|
||||||
|
forcePathStyle: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/backup/upload
|
||||||
|
* Faz upload de um backup local para MinIO/S3
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { filename } = await request.json();
|
||||||
|
|
||||||
|
if (!filename) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Filename é obrigatório' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupPath = path.join(BACKUP_DIR, filename);
|
||||||
|
|
||||||
|
// Validar se arquivo existe
|
||||||
|
if (!fs.existsSync(backupPath)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Arquivo de backup não encontrado' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ler arquivo
|
||||||
|
const fileContent = fs.readFileSync(backupPath);
|
||||||
|
const fileSize = fs.statSync(backupPath).size;
|
||||||
|
|
||||||
|
console.log(`[BACKUP UPLOAD] Iniciando upload de ${filename} (${fileSize} bytes)...`);
|
||||||
|
|
||||||
|
// Upload para MinIO
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: MINIO_BUCKET_NAME,
|
||||||
|
Key: `backups/${filename}`,
|
||||||
|
Body: fileContent,
|
||||||
|
ContentType: 'application/gzip',
|
||||||
|
ContentLength: fileSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
await s3Client.send(command);
|
||||||
|
|
||||||
|
console.log(`[BACKUP UPLOAD] Upload concluído: ${filename}`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Backup enviado para cloud com sucesso',
|
||||||
|
filename,
|
||||||
|
size: fileSize,
|
||||||
|
url: `${MINIO_USE_SSL ? 'https' : 'http'}://${MINIO_ENDPOINT}:${MINIO_PORT}/${MINIO_BUCKET_NAME}/backups/${filename}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BACKUP UPLOAD] Erro:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: (error as Error).message,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/backup/upload/list
|
||||||
|
* Lista backups na cloud
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { ListObjectsV2Command } = await import('@aws-sdk/client-s3');
|
||||||
|
|
||||||
|
const command = new ListObjectsV2Command({
|
||||||
|
Bucket: MINIO_BUCKET_NAME,
|
||||||
|
Prefix: 'backups/',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await s3Client.send(command);
|
||||||
|
const backups = (response.Contents || [])
|
||||||
|
.map((obj) => ({
|
||||||
|
key: obj.Key,
|
||||||
|
filename: obj.Key?.replace('backups/', '') || '',
|
||||||
|
size: obj.Size || 0,
|
||||||
|
lastModified: obj.LastModified?.toISOString() || '',
|
||||||
|
}))
|
||||||
|
.filter((b) => b.filename); // Remover pasta vazia
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
backups,
|
||||||
|
count: backups.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BACKUP LIST] Erro:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: (error as Error).message,
|
||||||
|
backups: [],
|
||||||
|
count: 0,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
frontend/src/app/api/contact-info/route.ts
Normal file
68
frontend/src/app/api/contact-info/route.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
|
||||||
|
// Cache em memória para evitar muitas consultas ao banco
|
||||||
|
let cachedContactInfo: { whatsapp: string; whatsappLink: string } | null = null;
|
||||||
|
let cacheTime = 0;
|
||||||
|
const CACHE_TTL = 60 * 1000; // 1 minuto
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// Verifica se cache é válido
|
||||||
|
if (cachedContactInfo && Date.now() - cacheTime < CACHE_TTL) {
|
||||||
|
return NextResponse.json(cachedContactInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Busca dados da página de contato
|
||||||
|
const pageContent = await prisma.pageContent.findUnique({
|
||||||
|
where: {
|
||||||
|
slug_locale: {
|
||||||
|
slug: 'contato',
|
||||||
|
locale: 'pt'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pageContent || !pageContent.content) {
|
||||||
|
// Retorna valores padrão
|
||||||
|
return NextResponse.json({
|
||||||
|
whatsapp: '(35) 9882-9445',
|
||||||
|
whatsappLink: 'https://api.whatsapp.com/send/?phone=553598829445&text&type=phone_number&app_absent=0'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = pageContent.content as {
|
||||||
|
info?: {
|
||||||
|
items?: Array<{
|
||||||
|
icon?: string;
|
||||||
|
link?: string;
|
||||||
|
linkText?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Encontra o item de WhatsApp
|
||||||
|
const whatsappItem = content.info?.items?.find(
|
||||||
|
item => item.icon === 'ri-whatsapp-line' || item.link?.includes('wa.me')
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
whatsapp: whatsappItem?.linkText || '(35) 9882-9445',
|
||||||
|
whatsappLink: whatsappItem?.link || 'https://api.whatsapp.com/send/?phone=553598829445&text&type=phone_number&app_absent=0'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Atualiza cache
|
||||||
|
cachedContactInfo = result;
|
||||||
|
cacheTime = Date.now();
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar informações de contato:', error);
|
||||||
|
|
||||||
|
// Retorna valores padrão em caso de erro
|
||||||
|
return NextResponse.json({
|
||||||
|
whatsapp: '(35) 9882-9445',
|
||||||
|
whatsappLink: 'https://api.whatsapp.com/send/?phone=553598829445&text&type=phone_number&app_absent=0'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
28
frontend/src/app/api/debug/pages/route.ts
Normal file
28
frontend/src/app/api/debug/pages/route.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/debug/pages
|
||||||
|
* Debug endpoint para ver o que tá salvo no banco
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Buscar todas as páginas
|
||||||
|
const allPages = await prisma.pageContent.findMany();
|
||||||
|
|
||||||
|
console.log('[DEBUG] Todas as páginas no banco:', JSON.stringify(allPages, null, 2));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
allPages,
|
||||||
|
count: allPages.length
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DEBUG] Erro:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
170
frontend/src/app/api/pages/home/route.ts
Normal file
170
frontend/src/app/api/pages/home/route.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// Middleware de autenticação
|
||||||
|
async function authenticate(request: NextRequest) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('auth_token')?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string };
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: decoded.userId },
|
||||||
|
select: { id: true, email: true, name: true }
|
||||||
|
});
|
||||||
|
return user;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/pages/home
|
||||||
|
* Busca conteúdo da página home
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const locale = searchParams.get('locale') || 'pt';
|
||||||
|
|
||||||
|
console.log(`[API PAGES HOME] GET locale=${locale}`);
|
||||||
|
|
||||||
|
const page = await prisma.pageContent.findUnique({
|
||||||
|
where: { slug_locale: { slug: 'home', locale } }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[API PAGES HOME] Encontrado:`, page ? 'SIM' : 'NÃO');
|
||||||
|
if (page) {
|
||||||
|
console.log(`[API PAGES HOME] Conteúdo:`, JSON.stringify(page.content, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
// Retornar página padrão vazia
|
||||||
|
return NextResponse.json({
|
||||||
|
slug: 'home',
|
||||||
|
locale,
|
||||||
|
content: {
|
||||||
|
hero: {
|
||||||
|
title: 'Engenharia de Excelência para Seus Projetos',
|
||||||
|
subtitle: 'Soluções completas em engenharia veicular, mecânica e segurança do trabalho com mais de 15 anos de experiência.',
|
||||||
|
buttonText: 'Conheça Nossos Serviços',
|
||||||
|
badge: {
|
||||||
|
text: 'Coca-Cola',
|
||||||
|
show: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(page);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar página home:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Erro ao buscar página home' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/pages/home
|
||||||
|
* Salva conteúdo da página home (admin apenas)
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await authenticate(request);
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const locale = 'pt'; // Sempre salvar em PT primeiro
|
||||||
|
|
||||||
|
if (!body.content) {
|
||||||
|
return NextResponse.json({ error: 'Conteúdo é obrigatório' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garantir que é um objeto, não string
|
||||||
|
const content = typeof body.content === 'string'
|
||||||
|
? JSON.parse(body.content)
|
||||||
|
: body.content;
|
||||||
|
|
||||||
|
console.log('[API PAGES HOME] POST - Salvando conteúdo:', JSON.stringify(content, null, 2));
|
||||||
|
|
||||||
|
const page = await prisma.pageContent.upsert({
|
||||||
|
where: { slug_locale: { slug: 'home', locale } },
|
||||||
|
update: { content },
|
||||||
|
create: {
|
||||||
|
slug: 'home',
|
||||||
|
locale,
|
||||||
|
content
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[API PAGES HOME] POST - Salvo com sucesso');
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, page });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao salvar página home:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Erro ao salvar página home: ' + (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/pages/home
|
||||||
|
* Atualiza conteúdo da página home (admin apenas)
|
||||||
|
*/
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await authenticate(request);
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const locale = 'pt';
|
||||||
|
|
||||||
|
if (!body.content) {
|
||||||
|
return NextResponse.json({ error: 'Conteúdo é obrigatório' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garantir que é um objeto, não string
|
||||||
|
const content = typeof body.content === 'string'
|
||||||
|
? JSON.parse(body.content)
|
||||||
|
: body.content;
|
||||||
|
|
||||||
|
console.log('[API PAGES HOME] PUT - Atualizando conteúdo:', JSON.stringify(content, null, 2));
|
||||||
|
|
||||||
|
const page = await prisma.pageContent.upsert({
|
||||||
|
where: { slug_locale: { slug: 'home', locale } },
|
||||||
|
update: { content },
|
||||||
|
create: {
|
||||||
|
slug: 'home',
|
||||||
|
locale,
|
||||||
|
content
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[API PAGES HOME] PUT - Atualizado com sucesso');
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, page });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao atualizar página home:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Erro ao atualizar página home: ' + (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
125
frontend/src/app/api/settings/route.ts
Normal file
125
frontend/src/app/api/settings/route.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// Middleware de autenticação
|
||||||
|
async function authenticate(request: NextRequest) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('auth_token')?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string };
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: decoded.userId },
|
||||||
|
select: { id: true, email: true, name: true }
|
||||||
|
});
|
||||||
|
return user;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/settings
|
||||||
|
* Busca configurações globais (público)
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Buscar ou criar configurações padrão
|
||||||
|
let settings = await prisma.settings.findFirst();
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
settings = await prisma.settings.create({
|
||||||
|
data: {
|
||||||
|
showPartnerBadge: false,
|
||||||
|
partnerName: 'Coca-Cola'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(settings);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar settings:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Erro ao buscar configurações' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/settings
|
||||||
|
* Atualiza configurações globais (admin apenas)
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await authenticate(request);
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const {
|
||||||
|
showPartnerBadge,
|
||||||
|
partnerName,
|
||||||
|
logo,
|
||||||
|
address,
|
||||||
|
phone,
|
||||||
|
email,
|
||||||
|
instagram,
|
||||||
|
linkedin,
|
||||||
|
facebook,
|
||||||
|
whatsapp
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
let settings = await prisma.settings.findFirst();
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
settings = await prisma.settings.create({
|
||||||
|
data: {
|
||||||
|
showPartnerBadge: showPartnerBadge ?? false,
|
||||||
|
partnerName: partnerName ?? 'Coca-Cola',
|
||||||
|
logo: logo ?? null,
|
||||||
|
address: address ?? null,
|
||||||
|
phone: phone ?? null,
|
||||||
|
email: email ?? null,
|
||||||
|
instagram: instagram ?? null,
|
||||||
|
linkedin: linkedin ?? null,
|
||||||
|
facebook: facebook ?? null,
|
||||||
|
whatsapp: whatsapp ?? null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
settings = await prisma.settings.update({
|
||||||
|
where: { id: settings.id },
|
||||||
|
data: {
|
||||||
|
...(showPartnerBadge !== undefined && { showPartnerBadge }),
|
||||||
|
...(partnerName !== undefined && { partnerName }),
|
||||||
|
...(logo !== undefined && { logo }),
|
||||||
|
...(address !== undefined && { address }),
|
||||||
|
...(phone !== undefined && { phone }),
|
||||||
|
...(email !== undefined && { email }),
|
||||||
|
...(instagram !== undefined && { instagram }),
|
||||||
|
...(linkedin !== undefined && { linkedin }),
|
||||||
|
...(facebook !== undefined && { facebook }),
|
||||||
|
...(whatsapp !== undefined && { whatsapp })
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, settings });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao atualizar settings:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Erro ao atualizar configurações: ' + (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { LanguageProvider } from "@/contexts/LanguageContext";
|
|||||||
import { ToastProvider } from "@/contexts/ToastContext";
|
import { ToastProvider } from "@/contexts/ToastContext";
|
||||||
import { ConfirmProvider } from "@/contexts/ConfirmContext";
|
import { ConfirmProvider } from "@/contexts/ConfirmContext";
|
||||||
import { ColorProvider } from "@/components/ColorProvider";
|
import { ColorProvider } from "@/components/ColorProvider";
|
||||||
|
import { JsonLdScript } from "@/components/JsonLdScript";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
variable: "--font-body",
|
variable: "--font-body",
|
||||||
@@ -17,6 +18,47 @@ const inter = Inter({
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Octto Engenharia | Movimentação de Carga e Segurança",
|
title: "Octto Engenharia | Movimentação de Carga e Segurança",
|
||||||
description: "Especialistas em engenharia de movimentação de carga, projetos de dispositivos de içamento, laudos técnicos e adequação de equipamentos (NR-11/NR-12).",
|
description: "Especialistas em engenharia de movimentação de carga, projetos de dispositivos de içamento, laudos técnicos e adequação de equipamentos (NR-11/NR-12).",
|
||||||
|
keywords: "engenharia, movimentação de carga, içamento, laudos técnicos, NR-11, NR-12, segurança do trabalho, projetos mecânicos",
|
||||||
|
metadataBase: new URL("https://octtoengenharia.com.br"),
|
||||||
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
locale: "pt_BR",
|
||||||
|
url: "https://octtoengenharia.com.br",
|
||||||
|
siteName: "Octto Engenharia",
|
||||||
|
title: "Octto Engenharia | Movimentação de Carga e Segurança",
|
||||||
|
description: "Especialistas em engenharia de movimentação de carga, projetos de dispositivos de içamento, laudos técnicos e adequação de equipamentos.",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: "https://octtoengenharia.com.br/og-image.jpg",
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: "Octto Engenharia",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: "Octto Engenharia | Movimentação de Carga e Segurança",
|
||||||
|
description: "Especialistas em engenharia de movimentação de carga",
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
googleBot: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
"max-snippet": -1,
|
||||||
|
"max-image-preview": "large",
|
||||||
|
"max-video-preview": -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
alternates: {
|
||||||
|
languages: {
|
||||||
|
"pt-BR": "https://octtoengenharia.com.br/pt",
|
||||||
|
en: "https://octtoengenharia.com.br/en",
|
||||||
|
es: "https://octtoengenharia.com.br/es",
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -26,6 +68,9 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="pt-BR" suppressHydrationWarning>
|
<html lang="pt-BR" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<JsonLdScript />
|
||||||
|
</head>
|
||||||
<body
|
<body
|
||||||
className={`${inter.variable} antialiased flex flex-col min-h-screen`}
|
className={`${inter.variable} antialiased flex flex-col min-h-screen`}
|
||||||
>
|
>
|
||||||
|
|||||||
59
frontend/src/app/sitemap.ts
Normal file
59
frontend/src/app/sitemap.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { MetadataRoute } from 'next';
|
||||||
|
|
||||||
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
|
const baseUrl = 'https://octtoengenharia.com.br';
|
||||||
|
const locales = ['', '/en', '/es'];
|
||||||
|
|
||||||
|
// Páginas principais
|
||||||
|
const pages = [
|
||||||
|
{ url: '', changefreq: 'weekly', priority: 1 },
|
||||||
|
{ url: '/servicos', changefreq: 'monthly', priority: 0.8 },
|
||||||
|
{ url: '/projetos', changefreq: 'weekly', priority: 0.8 },
|
||||||
|
{ url: '/contato', changefreq: 'monthly', priority: 0.7 },
|
||||||
|
{ url: '/sobre', changefreq: 'monthly', priority: 0.7 },
|
||||||
|
{ url: '/privacidade', changefreq: 'yearly', priority: 0.5 },
|
||||||
|
{ url: '/termos', changefreq: 'yearly', priority: 0.5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Buscar projetos do banco de dados
|
||||||
|
let projects = [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/projects`, {
|
||||||
|
next: { revalidate: 3600 }, // Cache por 1 hora
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
projects = await res.json();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar projetos para sitemap:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gerar URLs
|
||||||
|
const sitemap: MetadataRoute.Sitemap = [];
|
||||||
|
|
||||||
|
// Adicionar páginas principais para cada locale
|
||||||
|
for (const locale of locales) {
|
||||||
|
for (const page of pages) {
|
||||||
|
sitemap.push({
|
||||||
|
url: `${baseUrl}${locale}${page.url}`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: page.changefreq as 'weekly' | 'monthly' | 'yearly',
|
||||||
|
priority: page.priority,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adicionar páginas de projetos específicos
|
||||||
|
for (const locale of locales) {
|
||||||
|
for (const project of projects) {
|
||||||
|
sitemap.push({
|
||||||
|
url: `${baseUrl}${locale}/projetos/${project.id}`,
|
||||||
|
lastModified: project.updatedAt ? new Date(project.updatedAt) : new Date(),
|
||||||
|
changeFrequency: 'monthly' as const,
|
||||||
|
priority: 0.6,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sitemap;
|
||||||
|
}
|
||||||
@@ -1,14 +1,59 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
import { useLocale } from '@/contexts/LocaleContext';
|
import { useLocale } from '@/contexts/LocaleContext';
|
||||||
|
import { PartnerBadge } from './PartnerBadge';
|
||||||
|
|
||||||
|
type ContactSettings = {
|
||||||
|
address?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
instagram?: string | null;
|
||||||
|
linkedin?: string | null;
|
||||||
|
facebook?: string | null;
|
||||||
|
whatsapp?: string | null;
|
||||||
|
logo?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const { locale, t } = useLocale();
|
const { locale, t } = useLocale();
|
||||||
|
const [contact, setContact] = useState<ContactSettings>({});
|
||||||
|
|
||||||
// Prefixo para links
|
// Prefixo para links
|
||||||
const prefix = locale === 'pt' ? '' : `/${locale}`;
|
const prefix = locale === 'pt' ? '' : `/${locale}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSettings = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setContact({
|
||||||
|
address: data.address,
|
||||||
|
phone: data.phone,
|
||||||
|
email: data.email,
|
||||||
|
instagram: data.instagram,
|
||||||
|
linkedin: data.linkedin,
|
||||||
|
facebook: data.facebook,
|
||||||
|
whatsapp: data.whatsapp,
|
||||||
|
logo: data.logo
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao carregar configurações:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSettings();
|
||||||
|
|
||||||
|
// Atualizar quando settings mudar
|
||||||
|
const handleRefresh = () => fetchSettings();
|
||||||
|
window.addEventListener('settings:refresh', handleRefresh);
|
||||||
|
return () => window.removeEventListener('settings:refresh', handleRefresh);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-secondary text-white pt-16 pb-8">
|
<footer className="bg-secondary text-white pt-16 pb-8">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
@@ -16,31 +61,54 @@ export default function Footer() {
|
|||||||
{/* Brand */}
|
{/* Brand */}
|
||||||
<div className="col-span-1 md:col-span-1">
|
<div className="col-span-1 md:col-span-1">
|
||||||
<div className="flex items-center gap-2 mb-6">
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
{contact.logo ? (
|
||||||
|
<Image
|
||||||
|
src={contact.logo}
|
||||||
|
alt="OCCTO Engenharia"
|
||||||
|
width={150}
|
||||||
|
height={50}
|
||||||
|
className="object-contain h-12 w-auto"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<i className="ri-building-2-fill text-4xl text-primary"></i>
|
<i className="ri-building-2-fill text-4xl text-primary"></i>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-2xl font-bold font-headline">OCCTO</span>
|
<span className="text-2xl font-bold font-headline">OCCTO</span>
|
||||||
<span className="text-[10px] font-bold text-primary bg-white/10 px-2 py-1 rounded-md uppercase tracking-wider">ENG.</span>
|
<span className="text-[10px] font-bold text-primary bg-white/10 px-2 py-1 rounded-md uppercase tracking-wider">ENG.</span>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-400 mb-6">
|
<p className="text-gray-400 mb-6">
|
||||||
{t('footer.description')}
|
{t('footer.description')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="inline-flex items-center gap-2 bg-white/5 border border-white/10 rounded-lg px-3 py-2 mb-6">
|
<div className="mb-6">
|
||||||
<i className="ri-verified-badge-fill text-primary"></i>
|
<PartnerBadge />
|
||||||
<span className="text-xs font-bold text-gray-300 uppercase tracking-wide">{t('home.officialProvider')} <span className="text-primary">Coca-Cola</span></span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<a href="#" className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center hover:bg-primary transition-colors">
|
{contact.instagram && (
|
||||||
|
<a href={contact.instagram} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center hover:bg-primary transition-colors">
|
||||||
<i className="ri-instagram-line"></i>
|
<i className="ri-instagram-line"></i>
|
||||||
</a>
|
</a>
|
||||||
<a href="#" className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center hover:bg-primary transition-colors">
|
)}
|
||||||
|
{contact.linkedin && (
|
||||||
|
<a href={contact.linkedin} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center hover:bg-primary transition-colors">
|
||||||
<i className="ri-linkedin-fill"></i>
|
<i className="ri-linkedin-fill"></i>
|
||||||
</a>
|
</a>
|
||||||
<a href="#" className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center hover:bg-primary transition-colors">
|
)}
|
||||||
|
{contact.facebook && (
|
||||||
|
<a href={contact.facebook} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center hover:bg-primary transition-colors">
|
||||||
<i className="ri-facebook-fill"></i>
|
<i className="ri-facebook-fill"></i>
|
||||||
</a>
|
</a>
|
||||||
|
)}
|
||||||
|
{contact.whatsapp && (
|
||||||
|
<a href={`https://wa.me/${contact.whatsapp.replace(/\D/g, '')}`} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center hover:bg-primary transition-colors">
|
||||||
|
<i className="ri-whatsapp-line"></i>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -71,26 +139,54 @@ export default function Footer() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-bold font-headline mb-6">{t('nav.contact')}</h3>
|
<h3 className="text-lg font-bold font-headline mb-6">{t('nav.contact')}</h3>
|
||||||
<ul className="space-y-4">
|
<ul className="space-y-4">
|
||||||
|
{contact.address && (
|
||||||
<li className="flex items-start gap-3 text-gray-400">
|
<li className="flex items-start gap-3 text-gray-400">
|
||||||
<i className="ri-map-pin-line mt-1 text-primary"></i>
|
<i className="ri-map-pin-line mt-1 text-primary"></i>
|
||||||
<span>Endereço da Empresa, 123<br />Cidade - ES</span>
|
<span>{contact.address}</span>
|
||||||
</li>
|
</li>
|
||||||
|
)}
|
||||||
|
{contact.phone && (
|
||||||
<li className="flex items-center gap-3 text-gray-400">
|
<li className="flex items-center gap-3 text-gray-400">
|
||||||
<i className="ri-phone-line text-primary"></i>
|
<i className="ri-phone-line text-primary"></i>
|
||||||
<span>(27) 99999-9999</span>
|
<a href={`tel:${contact.phone.replace(/\D/g, '')}`} className="hover:text-primary transition-colors">
|
||||||
|
{contact.phone}
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
)}
|
||||||
|
{contact.email && (
|
||||||
<li className="flex items-center gap-3 text-gray-400">
|
<li className="flex items-center gap-3 text-gray-400">
|
||||||
<i className="ri-mail-line text-primary"></i>
|
<i className="ri-mail-line text-primary"></i>
|
||||||
<span>contato@octto.com.br</span>
|
<a href={`mailto:${contact.email}`} className="hover:text-primary transition-colors">
|
||||||
|
{contact.email}
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
)}
|
||||||
|
{!contact.address && !contact.phone && !contact.email && (
|
||||||
|
<li className="text-gray-500 text-sm italic">
|
||||||
|
Informações de contato não configuradas
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-white/10 pt-8 flex flex-col md:flex-row justify-between items-center gap-4">
|
<div className="border-t border-white/10 pt-8 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||||
|
<div className="flex flex-col items-center md:items-start gap-2">
|
||||||
<p className="text-gray-500 text-sm">
|
<p className="text-gray-500 text-sm">
|
||||||
© {new Date().getFullYear()} OCCTO Engenharia. {t('footer.rights')}
|
© {new Date().getFullYear()} OCCTO Engenharia. {t('footer.rights')}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-gray-500 text-xs">
|
||||||
|
Desenvolvido por{' '}
|
||||||
|
<a
|
||||||
|
href="https://wa.me/553598829445"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline font-medium"
|
||||||
|
>
|
||||||
|
idealpages
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="flex gap-6 text-sm text-gray-500">
|
<div className="flex gap-6 text-sm text-gray-500">
|
||||||
<Link href={`${prefix}/privacidade`} className="hover:text-white">{t('footer.privacyPolicy')}</Link>
|
<Link href={`${prefix}/privacidade`} className="hover:text-white">{t('footer.privacyPolicy')}</Link>
|
||||||
<Link href={`${prefix}/termos`} className="hover:text-white">{t('footer.termsOfUse')}</Link>
|
<Link href={`${prefix}/termos`} className="hover:text-white">{t('footer.termsOfUse')}</Link>
|
||||||
|
|||||||
@@ -1,23 +1,78 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { useLocale } from '@/contexts/LocaleContext';
|
import { useLocale } from '@/contexts/LocaleContext';
|
||||||
import { localeFlags, localeNames, type Locale } from '@/lib/i18n';
|
import { localeFlags, localeNames, type Locale } from '@/lib/i18n';
|
||||||
|
import SearchDropdown from './SearchDropdown';
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
|
const [logo, setLogo] = useState<string | null>(null);
|
||||||
|
const [mobileSearchValue, setMobileSearchValue] = useState('');
|
||||||
|
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { locale, setLocale, t } = useLocale();
|
const { locale, setLocale, t } = useLocale();
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [whatsappLink, setWhatsappLink] = useState('https://api.whatsapp.com/send/?phone=553598829445&text&type=phone_number&app_absent=0');
|
||||||
|
|
||||||
// Prefixo para links baseado no locale
|
// Prefixo para links baseado no locale
|
||||||
const prefix = locale === 'pt' ? '' : `/${locale}`;
|
const prefix = locale === 'pt' ? '' : `/${locale}`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
|
|
||||||
|
// Verifica se está logado
|
||||||
|
fetch('/api/auth/me')
|
||||||
|
.then(res => {
|
||||||
|
if (res.ok) {
|
||||||
|
setIsLoggedIn(true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setIsLoggedIn(false));
|
||||||
|
|
||||||
|
// Busca as configurações (logo e whatsapp)
|
||||||
|
fetch('/api/settings')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.logo) {
|
||||||
|
setLogo(data.logo);
|
||||||
|
}
|
||||||
|
if (data.whatsapp) {
|
||||||
|
setWhatsappLink(`https://wa.me/${data.whatsapp.replace(/\D/g, '')}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
|
||||||
|
// Busca o número do WhatsApp do CMS (fallback)
|
||||||
|
fetch('/api/contact-info')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.whatsappLink) {
|
||||||
|
setWhatsappLink(data.whatsappLink);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
|
||||||
|
// Listener para atualização em tempo real
|
||||||
|
const handleSettingsRefresh = () => {
|
||||||
|
fetch('/api/settings')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.logo !== undefined) {
|
||||||
|
setLogo(data.logo);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('settings:refresh', handleSettingsRefresh);
|
||||||
|
return () => window.removeEventListener('settings:refresh', handleSettingsRefresh);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Prevent scrolling when mobile menu is open
|
// Prevent scrolling when mobile menu is open
|
||||||
@@ -37,30 +92,76 @@ export default function Header() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="w-full bg-white dark:bg-secondary shadow-sm sticky top-0 z-50 transition-colors duration-300">
|
<>
|
||||||
|
{/* Admin Bar - aparece apenas para usuários logados */}
|
||||||
|
{isLoggedIn && (
|
||||||
|
<div className="w-full bg-secondary dark:bg-black py-1.5 px-4 z-60 relative">
|
||||||
|
<div className="container mx-auto flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||||
|
<i className="ri-shield-user-line text-primary"></i>
|
||||||
|
<span className="hidden sm:inline">Você está logado como administrador</span>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/admin"
|
||||||
|
className="flex items-center gap-1.5 text-xs font-medium text-white bg-primary/90 hover:bg-primary px-3 py-1 rounded-full transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<i className="ri-dashboard-line"></i>
|
||||||
|
<span>Painel Admin</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<header className={`w-full bg-white dark:bg-secondary shadow-sm sticky ${isLoggedIn ? 'top-0' : 'top-0'} z-50 transition-colors duration-300`}>
|
||||||
<div className="container mx-auto px-4 h-20 flex items-center justify-between gap-4">
|
<div className="container mx-auto px-4 h-20 flex items-center justify-between gap-4">
|
||||||
<Link href={`${prefix}/`} className="flex items-center gap-3 shrink-0 group mr-auto z-50 relative">
|
<Link href={`${prefix}/`} className="flex items-center gap-3 shrink-0 group mr-auto z-50 relative">
|
||||||
|
{logo ? (
|
||||||
|
<Image
|
||||||
|
src={logo}
|
||||||
|
alt="OCCTO Engenharia"
|
||||||
|
width={150}
|
||||||
|
height={50}
|
||||||
|
className="object-contain group-hover:scale-105 transition-transform h-12 w-auto"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<i className="ri-building-2-fill text-4xl text-primary group-hover:scale-105 transition-transform"></i>
|
<i className="ri-building-2-fill text-4xl text-primary group-hover:scale-105 transition-transform"></i>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-3xl font-bold text-secondary dark:text-white font-headline leading-none">OCCTO</span>
|
<span className="text-3xl font-bold text-secondary dark:text-white font-headline leading-none">OCCTO</span>
|
||||||
<span className="text-[10px] font-bold text-primary bg-primary/10 px-2 py-1 rounded-md uppercase tracking-wider">ENG.</span>
|
<span className="text-[10px] font-bold text-primary bg-primary/10 px-2 py-1 rounded-md uppercase tracking-wider">ENG.</span>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="hidden md:flex items-center gap-4">
|
<div className="hidden md:flex items-center gap-4">
|
||||||
{/* Search Bar */}
|
{/* Search Bar with Dropdown */}
|
||||||
<div className={`flex items-center bg-gray-100 dark:bg-white/10 rounded-full transition-all duration-300 ${isSearchOpen ? 'w-64 px-4 py-2' : 'w-10 h-10 justify-center cursor-pointer hover:bg-gray-200 dark:hover:bg-white/20'}`} onClick={() => !isSearchOpen && setIsSearchOpen(true)}>
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className={`flex items-center bg-gray-100 dark:bg-white/10 rounded-full transition-all duration-300 ${isSearchOpen ? 'w-64 px-4 py-2' : 'w-10 h-10 justify-center cursor-pointer hover:bg-gray-200 dark:hover:bg-white/20'}`}
|
||||||
|
onClick={() => !isSearchOpen && setIsSearchOpen(true)}
|
||||||
|
>
|
||||||
<i className={`ri-search-line text-gray-500 dark:text-gray-300 ${isSearchOpen ? 'mr-2' : 'text-lg'}`}></i>
|
<i className={`ri-search-line text-gray-500 dark:text-gray-300 ${isSearchOpen ? 'mr-2' : 'text-lg'}`}></i>
|
||||||
{isSearchOpen && (
|
{isSearchOpen && (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('nav.search')}
|
placeholder={t('nav.search')}
|
||||||
autoFocus
|
autoFocus
|
||||||
onBlur={() => setIsSearchOpen(false)}
|
value={searchValue}
|
||||||
|
onChange={(e) => setSearchValue(e.target.value)}
|
||||||
|
onBlur={() => setTimeout(() => setIsSearchOpen(false), 200)}
|
||||||
className="bg-transparent border-none outline-none text-sm w-full text-gray-600 dark:text-gray-200 placeholder-gray-400"
|
className="bg-transparent border-none outline-none text-sm w-full text-gray-600 dark:text-gray-200 placeholder-gray-400"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<SearchDropdown
|
||||||
|
isOpen={isSearchOpen}
|
||||||
|
searchValue={searchValue}
|
||||||
|
onSearchChange={setSearchValue}
|
||||||
|
onClose={() => setIsSearchOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav className="flex items-center gap-6 mr-4">
|
<nav className="flex items-center gap-6 mr-4">
|
||||||
<Link href={`${prefix}/`} className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary font-medium transition-colors group">
|
<Link href={`${prefix}/`} className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary font-medium transition-colors group">
|
||||||
@@ -86,13 +187,15 @@ export default function Header() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="shrink-0 ml-2">
|
<div className="shrink-0 ml-2">
|
||||||
<Link
|
<a
|
||||||
href={`${prefix}/contato`}
|
href={whatsappLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
className="px-6 py-2.5 bg-primary text-white rounded-lg font-bold hover-primary transition-colors flex items-center gap-2"
|
className="px-6 py-2.5 bg-primary text-white rounded-lg font-bold hover-primary transition-colors flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<i className="ri-whatsapp-line"></i>
|
<i className="ri-whatsapp-line"></i>
|
||||||
<span className="hidden xl:inline">{t('nav.contactUs')}</span>
|
<span className="hidden xl:inline">{t('nav.contactUs')}</span>
|
||||||
</Link>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 pl-4 border-l border-gray-200 dark:border-white/10">
|
<div className="flex items-center gap-2 pl-4 border-l border-gray-200 dark:border-white/10">
|
||||||
@@ -150,16 +253,28 @@ export default function Header() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Mobile Menu Overlay */}
|
{/* Mobile Menu Overlay */}
|
||||||
<div className={`fixed inset-0 bg-white dark:bg-secondary z-40 transition-transform duration-300 ease-in-out md:hidden flex flex-col pt-24 px-6 overflow-y-auto ${isMobileMenuOpen ? 'translate-x-0' : 'translate-x-full'}`}>
|
<div className={`fixed inset-0 bg-white dark:bg-secondary z-40 transition-transform duration-300 ease-in-out md:hidden flex flex-col pt-20 px-6 overflow-y-auto ${isMobileMenuOpen ? 'translate-x-0' : 'translate-x-full'}`}>
|
||||||
|
|
||||||
{/* Mobile Search */}
|
{/* Mobile Search */}
|
||||||
<div className="mb-6 relative shrink-0">
|
<div className="mb-6 relative shrink-0">
|
||||||
|
<div className="relative">
|
||||||
<i className="ri-search-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"></i>
|
<i className="ri-search-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"></i>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('nav.search')}
|
placeholder={t('nav.search')}
|
||||||
|
value={mobileSearchValue}
|
||||||
|
onChange={(e) => setMobileSearchValue(e.target.value)}
|
||||||
|
onFocus={() => setIsMobileSearchOpen(true)}
|
||||||
|
onBlur={() => setTimeout(() => setIsMobileSearchOpen(false), 200)}
|
||||||
className="w-full pl-11 pr-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-100 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
className="w-full pl-11 pr-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-100 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||||
/>
|
/>
|
||||||
|
<SearchDropdown
|
||||||
|
isOpen={isMobileSearchOpen}
|
||||||
|
searchValue={mobileSearchValue}
|
||||||
|
onSearchChange={setMobileSearchValue}
|
||||||
|
onClose={() => setIsMobileSearchOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex flex-col gap-4 text-base font-medium">
|
<nav className="flex flex-col gap-4 text-base font-medium">
|
||||||
@@ -186,14 +301,16 @@ export default function Header() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="mt-6 flex flex-col gap-4 pb-8 shrink-0">
|
<div className="mt-6 flex flex-col gap-4 pb-8 shrink-0">
|
||||||
<Link
|
<a
|
||||||
href={`${prefix}/contato`}
|
href={whatsappLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
className="w-full py-4 bg-primary text-white rounded-xl font-bold text-center flex items-center justify-center gap-2 shadow-lg shadow-primary/20"
|
className="w-full py-4 bg-primary text-white rounded-xl font-bold text-center flex items-center justify-center gap-2 shadow-lg shadow-primary/20"
|
||||||
>
|
>
|
||||||
<i className="ri-whatsapp-line text-xl"></i>
|
<i className="ri-whatsapp-line text-xl"></i>
|
||||||
{t('nav.contactUs')}
|
{t('nav.contactUs')}
|
||||||
</Link>
|
</a>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-between p-4 bg-gray-50 dark:bg-white/5 rounded-xl cursor-pointer hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
|
className="flex items-center justify-between p-4 bg-gray-50 dark:bg-white/5 rounded-xl cursor-pointer hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
|
||||||
@@ -223,5 +340,6 @@ export default function Header() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
82
frontend/src/components/JsonLdScript.tsx
Normal file
82
frontend/src/components/JsonLdScript.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
export function JsonLdScript() {
|
||||||
|
const organizationSchema = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'LocalBusiness',
|
||||||
|
name: 'Octto Engenharia',
|
||||||
|
description: 'Especialistas em engenharia de movimentação de carga e segurança do trabalho',
|
||||||
|
url: 'https://octtoengenharia.com.br',
|
||||||
|
logo: 'https://octtoengenharia.com.br/logo.png',
|
||||||
|
image: 'https://octtoengenharia.com.br/og-image.jpg',
|
||||||
|
telephone: '+55 13 99803-0036',
|
||||||
|
email: 'contato@octto-engenharia.com',
|
||||||
|
areaServed: {
|
||||||
|
'@type': 'GeoShape',
|
||||||
|
addressCountry: 'BR',
|
||||||
|
},
|
||||||
|
sameAs: [
|
||||||
|
'https://www.instagram.com/octtoengenharia',
|
||||||
|
'https://www.linkedin.com/company/octto-engenharia',
|
||||||
|
],
|
||||||
|
address: {
|
||||||
|
'@type': 'PostalAddress',
|
||||||
|
addressCountry: 'BR',
|
||||||
|
addressLocality: 'Jundiaí',
|
||||||
|
addressRegion: 'SP',
|
||||||
|
},
|
||||||
|
priceRange: '$$',
|
||||||
|
serviceType: [
|
||||||
|
'Engenharia de movimentação de carga',
|
||||||
|
'Projetos de içamento',
|
||||||
|
'Laudos técnicos',
|
||||||
|
'Consultoria de segurança',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigationSchema = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'SiteNavigationElement',
|
||||||
|
'url': [
|
||||||
|
{
|
||||||
|
'@type': 'CollectionPage',
|
||||||
|
name: 'Projetos',
|
||||||
|
url: 'https://octtoengenharia.com.br/projetos',
|
||||||
|
description: 'Portfólio de projetos de engenharia de movimentação de carga',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'CollectionPage',
|
||||||
|
name: 'Serviços',
|
||||||
|
url: 'https://octtoengenharia.com.br/servicos',
|
||||||
|
description: 'Serviços especializados em engenharia e segurança do trabalho',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'AboutPage',
|
||||||
|
name: 'Sobre',
|
||||||
|
url: 'https://octtoengenharia.com.br/sobre',
|
||||||
|
description: 'Conheça mais sobre a Octto Engenharia',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'ContactPage',
|
||||||
|
name: 'Contato',
|
||||||
|
url: 'https://octtoengenharia.com.br/contato',
|
||||||
|
description: 'Entre em contato com a Octto Engenharia',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema) }}
|
||||||
|
suppressHydrationWarning
|
||||||
|
/>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(navigationSchema) }}
|
||||||
|
suppressHydrationWarning
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
frontend/src/components/PartnerBadge.tsx
Normal file
48
frontend/src/components/PartnerBadge.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useLocale } from '@/contexts/LocaleContext';
|
||||||
|
|
||||||
|
export function PartnerBadge() {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const [showBadge, setShowBadge] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSettings = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setShowBadge(data.showPartnerBadge || false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao carregar settings:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSettings();
|
||||||
|
|
||||||
|
// Recarregar quando configurações forem atualizadas
|
||||||
|
const handleRefresh = () => {
|
||||||
|
fetchSettings();
|
||||||
|
};
|
||||||
|
window.addEventListener('settings:refresh', handleRefresh);
|
||||||
|
return () => window.removeEventListener('settings:refresh', handleRefresh);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading || !showBadge) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center gap-3 bg-white/10 backdrop-blur-md border border-white/20 rounded-full px-5 py-2 hover:bg-white/20 transition-colors cursor-default">
|
||||||
|
<i className="ri-verified-badge-fill text-primary text-xl"></i>
|
||||||
|
<span className="text-sm font-bold tracking-wider uppercase text-white">
|
||||||
|
{t('home.officialProvider')} <span className="text-primary">Coca-Cola</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
frontend/src/components/SearchDropdown.tsx
Normal file
147
frontend/src/components/SearchDropdown.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { useLocale } from '@/contexts/LocaleContext';
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
coverImage: string | null;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchDropdownProps {
|
||||||
|
onClose?: () => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
searchValue: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchDropdown({ onClose, isOpen, searchValue, onSearchChange }: SearchDropdownProps) {
|
||||||
|
const [results, setResults] = useState<Project[]>([]);
|
||||||
|
const [allProjects, setAllProjects] = useState<Project[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { locale } = useLocale();
|
||||||
|
|
||||||
|
// Prefixo para links baseado no locale
|
||||||
|
const prefix = locale === 'pt' ? '' : `/${locale}`;
|
||||||
|
|
||||||
|
// Carregar todos os projetos uma vez
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchProjects() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/projects', { cache: 'no-store' });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setAllProjects(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao carregar projetos:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
fetchProjects();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Filtrar resultados conforme o usuário digita
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchValue.trim()) {
|
||||||
|
setResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Simular pequeno delay para evitar renderizações desnecessárias
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const filtered = allProjects.filter(project =>
|
||||||
|
project.title.toLowerCase().includes(searchValue.toLowerCase())
|
||||||
|
);
|
||||||
|
setResults(filtered.slice(0, 5)); // Limitar a 5 resultados
|
||||||
|
setLoading(false);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchValue, allProjects]);
|
||||||
|
|
||||||
|
const defaultImage = 'https://images.unsplash.com/photo-1504307651254-35680f356dfd?q=80&w=200&auto=format&fit=crop';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Dropdown Overlay */}
|
||||||
|
{isOpen && searchValue && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-secondary rounded-lg shadow-xl border border-gray-200 dark:border-white/10 z-50 max-w-md w-full">
|
||||||
|
{loading && (
|
||||||
|
<div className="p-6 text-center">
|
||||||
|
<div className="animate-spin inline-block">
|
||||||
|
<i className="ri-loader-4-line text-2xl text-primary"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && results.length === 0 && searchValue && (
|
||||||
|
<div className="p-6 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<i className="ri-search-line text-3xl mb-3 block opacity-50"></i>
|
||||||
|
<p>Nenhum projeto encontrado com "{searchValue}"</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && results.length > 0 && (
|
||||||
|
<div className="max-h-96 overflow-y-auto">
|
||||||
|
{results.map((project) => (
|
||||||
|
<Link
|
||||||
|
key={project.id}
|
||||||
|
href={`${prefix}/projetos/${project.id}`}
|
||||||
|
onClick={() => {
|
||||||
|
onSearchChange('');
|
||||||
|
onClose?.();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-3 p-3 hover:bg-gray-50 dark:hover:bg-white/5 border-b border-gray-100 dark:border-white/10 last:border-b-0 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="relative w-16 h-16 shrink-0 rounded-lg overflow-hidden bg-gray-200 dark:bg-white/5">
|
||||||
|
<Image
|
||||||
|
src={project.coverImage || defaultImage}
|
||||||
|
alt={project.title}
|
||||||
|
fill
|
||||||
|
className="object-cover group-hover:scale-110 transition-transform duration-300"
|
||||||
|
sizes="64px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white truncate group-hover:text-primary transition-colors">
|
||||||
|
{project.title}
|
||||||
|
</h3>
|
||||||
|
{project.category && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
{project.category}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<i className="ri-arrow-right-line text-gray-400 group-hover:text-primary transition-colors shrink-0 opacity-0 group-hover:opacity-100"></i>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{results.length > 0 && (
|
||||||
|
<Link
|
||||||
|
href={`${prefix}/projetos`}
|
||||||
|
onClick={() => {
|
||||||
|
onSearchChange('');
|
||||||
|
onClose?.();
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-center gap-2 p-3 text-primary font-medium hover:bg-primary/10 border-t border-gray-100 dark:border-white/10 transition-colors group"
|
||||||
|
>
|
||||||
|
<span>Ver todos os projetos</span>
|
||||||
|
<i className="ri-arrow-right-line group-hover:translate-x-1 transition-transform"></i>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,22 +2,46 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLanguage } from '@/contexts/LanguageContext';
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
export default function WhatsAppButton() {
|
export default function WhatsAppButton() {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const [whatsappLink, setWhatsappLink] = useState('https://api.whatsapp.com/send/?phone=553598829445&text&type=phone_number&app_absent=0');
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
|
||||||
|
// Usa o número padrão correto
|
||||||
|
// Se precisar atualizar no futuro, mude aqui
|
||||||
|
const defaultNumber = '553598829445';
|
||||||
|
setWhatsappLink(`https://api.whatsapp.com/send/?phone=${defaultNumber}&text&type=phone_number&app_absent=0`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href="https://wa.me/5511999999999" // Substitua pelo número real
|
href={whatsappLink}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="fixed bottom-6 right-6 z-40 flex flex-row-reverse items-center justify-center bg-[#25D366] text-white w-14 h-14 rounded-full shadow-lg hover:bg-[#20bd5a] transition-all hover:scale-110 group animate-in slide-in-from-bottom-4 duration-700 delay-1000 hover:w-auto hover:px-6"
|
className="fixed bottom-6 right-6 z-40 bg-[#25D366] text-white w-14 h-14 rounded-full shadow-lg hover:shadow-xl transition-shadow duration-200 flex items-center justify-center flex-shrink-0"
|
||||||
aria-label={t('whatsapp.label')}
|
aria-label={t('whatsapp.label')}
|
||||||
|
style={{
|
||||||
|
transition: 'all 0.2s ease-in-out',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = '#20bd5a';
|
||||||
|
e.currentTarget.style.transform = 'scale(1.1)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = '#25D366';
|
||||||
|
e.currentTarget.style.transform = 'scale(1)';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<i className="ri-whatsapp-line text-3xl leading-none"></i>
|
<i className="ri-whatsapp-line text-3xl leading-none"></i>
|
||||||
<span className="font-bold max-w-0 overflow-hidden group-hover:max-w-xs group-hover:mr-3 transition-all duration-500 whitespace-nowrap">
|
|
||||||
{t('whatsapp.label')}
|
|
||||||
</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
331
frontend/src/components/admin/BackupManager.tsx
Normal file
331
frontend/src/components/admin/BackupManager.tsx
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useToast } from '@/contexts/ToastContext';
|
||||||
|
|
||||||
|
interface BackupInfo {
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
date: string;
|
||||||
|
size: number;
|
||||||
|
filename: string;
|
||||||
|
status: 'success' | 'error';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackupsListResponse {
|
||||||
|
success: boolean;
|
||||||
|
backups: BackupInfo[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackupManager() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [restoreLoading, setRestoreLoading] = useState<string | null>(null);
|
||||||
|
const [backups, setBackups] = useState<BackupInfo[]>([]);
|
||||||
|
const [listLoading, setListLoading] = useState(true);
|
||||||
|
const { success, error: showError } = useToast();
|
||||||
|
|
||||||
|
// Buscar lista de backups ao carregar
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBackups();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchBackups = async () => {
|
||||||
|
try {
|
||||||
|
setListLoading(true);
|
||||||
|
|
||||||
|
const response = await fetch('/api/backup', {
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data: BackupsListResponse = await response.json();
|
||||||
|
setBackups(data.backups || []);
|
||||||
|
} else {
|
||||||
|
showError('Erro ao carregar backups');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showError('Erro ao carregar backups: ' + (err as Error).message);
|
||||||
|
} finally {
|
||||||
|
setListLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createBackup = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const response = await fetch('/api/backup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok || response.status === 201) {
|
||||||
|
success('Backup criado com sucesso!');
|
||||||
|
// Atualizar lista de backups
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
await fetchBackups();
|
||||||
|
} else {
|
||||||
|
showError(data.error || 'Erro ao criar backup');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showError('Erro ao criar backup: ' + (err as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteBackup = async (backupId: string) => {
|
||||||
|
if (!confirm('Tem certeza que deseja remover este backup?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/backup?id=${backupId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
success('Backup removido com sucesso!');
|
||||||
|
await fetchBackups();
|
||||||
|
} else {
|
||||||
|
showError(data.error || 'Erro ao remover backup');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showError('Erro ao remover backup: ' + (err as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadBackup = async (filename: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/backup/download?file=${filename}`);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} else {
|
||||||
|
showError('Erro ao baixar backup');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showError('Erro ao baixar backup: ' + (err as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreBackup = async (filename: string) => {
|
||||||
|
if (!window.confirm('⚠️ AVISO: A restauração substituirá todo o banco de dados!\n\nTem certeza que deseja restaurar este backup?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setRestoreLoading(filename);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/backup/restore?file=${filename}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
success('Backup restaurado com sucesso! Por favor, recarregue a página.');
|
||||||
|
} else {
|
||||||
|
showError(data.error || 'Erro ao restaurar backup');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showError('Erro ao restaurar backup: ' + (err as Error).message);
|
||||||
|
} finally {
|
||||||
|
setRestoreLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadBackupToCloud = async (filename: string) => {
|
||||||
|
try {
|
||||||
|
setRestoreLoading(filename);
|
||||||
|
|
||||||
|
const response = await fetch('/api/backup/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ filename })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
success('Backup enviado para cloud com sucesso!');
|
||||||
|
} else {
|
||||||
|
showError(data.error || 'Erro ao enviar backup');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showError('Erro ao enviar backup: ' + (err as Error).message);
|
||||||
|
} finally {
|
||||||
|
setRestoreLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSize = (bytes: number) => {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (isoString: string) => {
|
||||||
|
return new Date(isoString).toLocaleString('pt-BR');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Botão de Criar Backup */}
|
||||||
|
<div className="bg-linear-to-r from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 border border-blue-200 dark:border-blue-800 rounded-2xl p-6">
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2 flex items-center gap-2">
|
||||||
|
<i className="ri-database-backup-line text-blue-600 dark:text-blue-400 text-2xl"></i>
|
||||||
|
Criar Novo Backup
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Realize um backup completo do banco de dados e arquivos MinIO
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={createBackup}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-6 py-3 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<i className="ri-loader-4-line animate-spin"></i>
|
||||||
|
Criando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<i className="ri-download-cloud-2-line"></i>
|
||||||
|
Criar Backup Agora
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lista de Backups */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||||
|
<i className="ri-archive-line text-primary"></i>
|
||||||
|
Backups Salvos ({backups.length})
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{listLoading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<i className="ri-loader-4-line animate-spin text-primary text-3xl"></i>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-2">Carregando backups...</p>
|
||||||
|
</div>
|
||||||
|
) : backups.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-gray-50 dark:bg-white/5 rounded-xl border border-gray-200 dark:border-white/10">
|
||||||
|
<i className="ri-file-archive-line text-gray-400 text-4xl mb-3 block"></i>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">Nenhum backup realizado ainda</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{backups.map((backup) => (
|
||||||
|
<div
|
||||||
|
key={backup.id}
|
||||||
|
className="bg-white dark:bg-secondary p-4 rounded-xl border border-gray-200 dark:border-white/10 hover:shadow-md transition-shadow flex items-center justify-between gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className={`w-3 h-3 rounded-full ${
|
||||||
|
backup.status === 'success'
|
||||||
|
? 'bg-green-500'
|
||||||
|
: 'bg-red-500'
|
||||||
|
}`}></div>
|
||||||
|
<h4 className="font-bold text-gray-900 dark:text-white truncate">
|
||||||
|
{backup.filename}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<i className="ri-calendar-line mr-1"></i>
|
||||||
|
{formatDate(backup.timestamp)}
|
||||||
|
<span className="mx-2">•</span>
|
||||||
|
<i className="ri-hard-drive-line mr-1"></i>
|
||||||
|
{formatSize(backup.size)}
|
||||||
|
</p>
|
||||||
|
{backup.message && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||||
|
{backup.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => uploadBackupToCloud(backup.filename)}
|
||||||
|
disabled={restoreLoading === backup.filename}
|
||||||
|
title="Enviar para cloud"
|
||||||
|
className="p-2 hover:bg-purple-100 dark:hover:bg-purple-900/20 rounded-lg transition-colors text-purple-600 dark:text-purple-400 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{restoreLoading === backup.filename ? (
|
||||||
|
<i className="ri-loader-4-line animate-spin text-xl"></i>
|
||||||
|
) : (
|
||||||
|
<i className="ri-cloud-upload-line text-xl"></i>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => restoreBackup(backup.filename)}
|
||||||
|
disabled={restoreLoading === backup.filename}
|
||||||
|
title="Restaurar backup"
|
||||||
|
className="p-2 hover:bg-amber-100 dark:hover:bg-amber-900/20 rounded-lg transition-colors text-amber-600 dark:text-amber-400 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{restoreLoading === backup.filename ? (
|
||||||
|
<i className="ri-loader-4-line animate-spin text-xl"></i>
|
||||||
|
) : (
|
||||||
|
<i className="ri-restart-line text-xl"></i>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => downloadBackup(backup.filename)}
|
||||||
|
title="Baixar backup"
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-white/10 rounded-lg transition-colors text-blue-600 dark:text-blue-400"
|
||||||
|
>
|
||||||
|
<i className="ri-download-line text-xl"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteBackup(backup.id)}
|
||||||
|
title="Remover backup"
|
||||||
|
className="p-2 hover:bg-red-100 dark:hover:bg-red-900/20 rounded-lg transition-colors text-red-600 dark:text-red-400"
|
||||||
|
>
|
||||||
|
<i className="ri-delete-bin-6-line text-xl"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Informações */}
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-4">
|
||||||
|
<p className="text-sm text-amber-900 dark:text-amber-200">
|
||||||
|
<i className="ri-information-line mr-2"></i>
|
||||||
|
Os backups incluem banco de dados PostgreSQL e todos os arquivos do MinIO.
|
||||||
|
Armazene-os em local seguro para recuperação em caso de necessidade.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,6 +29,9 @@ const systemTexts: Record<string, string> = {
|
|||||||
'footer.rights': 'Todos os direitos reservados.',
|
'footer.rights': 'Todos os direitos reservados.',
|
||||||
'services.title': 'Serviços',
|
'services.title': 'Serviços',
|
||||||
|
|
||||||
|
// WhatsApp
|
||||||
|
'whatsapp.label': 'Fale Conosco',
|
||||||
|
|
||||||
// Cookies
|
// Cookies
|
||||||
'cookie.text': 'Utilizamos cookies para melhorar sua experiência e analisar o tráfego do site. Ao continuar navegando, você concorda com nossa',
|
'cookie.text': 'Utilizamos cookies para melhorar sua experiência e analisar o tráfego do site. Ao continuar navegando, você concorda com nossa',
|
||||||
'cookie.policy': 'Política de Privacidade',
|
'cookie.policy': 'Política de Privacidade',
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"whatsapp": {
|
||||||
|
"label": "Contact Us"
|
||||||
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"services": "Services",
|
"services": "Services",
|
||||||
@@ -191,6 +194,70 @@
|
|||||||
"accept": "Accept",
|
"accept": "Accept",
|
||||||
"decline": "Decline"
|
"decline": "Decline"
|
||||||
},
|
},
|
||||||
|
"privacy": {
|
||||||
|
"title": "Privacy Policy",
|
||||||
|
"intro": "OCCTO Engineering values the privacy of its users and clients. This Privacy Policy describes how we collect, use and protect your personal information when using our website and services.",
|
||||||
|
"section1": {
|
||||||
|
"title": "1. Information Collection",
|
||||||
|
"content": "We collect information that you provide directly to us, such as when you fill out our contact form, request a quote or subscribe to our newsletter. The information may include name, email, phone and details about your company or project."
|
||||||
|
},
|
||||||
|
"section2": {
|
||||||
|
"title": "2. Use of Information",
|
||||||
|
"intro": "We use the collected information to:",
|
||||||
|
"items": [
|
||||||
|
"Respond to your inquiries and quote requests;",
|
||||||
|
"Provide information about our engineering services and technical reports;",
|
||||||
|
"Improve the user experience on our website;",
|
||||||
|
"Comply with legal and regulatory obligations."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"section3": {
|
||||||
|
"title": "3. Data Protection",
|
||||||
|
"content": "We adopt appropriate technical and organizational security measures to protect your personal information against unauthorized access, alteration, disclosure or destruction."
|
||||||
|
},
|
||||||
|
"section4": {
|
||||||
|
"title": "4. Sharing Information",
|
||||||
|
"content": "We do not sell, trade or transfer your personal information to third parties, except when necessary for the provision of our services (eg: technical partners involved in a specific project) or when required by law."
|
||||||
|
},
|
||||||
|
"section5": {
|
||||||
|
"title": "5. Cookies",
|
||||||
|
"content": "Our website may use cookies to improve navigation and understand how visitors interact with our content. You can disable cookies in your browser settings if you prefer."
|
||||||
|
},
|
||||||
|
"section6": {
|
||||||
|
"title": "6. Contact",
|
||||||
|
"content": "If you have questions about this Privacy Policy, please contact us via email: contato@octto.com.br."
|
||||||
|
},
|
||||||
|
"lastUpdate": "Last updated: November 2025."
|
||||||
|
},
|
||||||
|
"terms": {
|
||||||
|
"title": "Terms of Use",
|
||||||
|
"intro": "Welcome to OCCTO Engineering's website. By accessing and using this website, you agree to comply with and be bound by the following Terms of Use. If you disagree with any part of these terms, please do not use our website.",
|
||||||
|
"section1": {
|
||||||
|
"title": "1. Website Use",
|
||||||
|
"content": "The content of this website is for general informational purposes only about our mechanical engineering services, reports and projects. We reserve the right to modify or discontinue any aspect of the website at any time."
|
||||||
|
},
|
||||||
|
"section2": {
|
||||||
|
"title": "2. Intellectual Property",
|
||||||
|
"content": "All content on this website, including texts, graphics, logos, icons, images and software, is owned by OCCTO Engineering or its content providers and is protected by Brazilian and international copyright laws."
|
||||||
|
},
|
||||||
|
"section3": {
|
||||||
|
"title": "3. Limitation of Liability",
|
||||||
|
"content": "OCCTO Engineering is not responsible for any direct, indirect, incidental or consequential damages resulting from the use or inability to use this website or any information contained therein. The technical information provided on the website does not substitute professional consultation and the issuance of specific technical reports for each case."
|
||||||
|
},
|
||||||
|
"section4": {
|
||||||
|
"title": "4. Links to Third Parties",
|
||||||
|
"content": "Our website may contain links to third-party websites. These links are provided for your convenience only. OCCTO Engineering has no control over the content of these sites and assumes no responsibility for them."
|
||||||
|
},
|
||||||
|
"section5": {
|
||||||
|
"title": "5. Changes to Terms",
|
||||||
|
"content": "We may revise these Terms of Use at any time. By using this website, you agree to be bound by the current version of these Terms of Use."
|
||||||
|
},
|
||||||
|
"section6": {
|
||||||
|
"title": "6. Applicable Law",
|
||||||
|
"content": "These terms are governed by and interpreted in accordance with the laws of the Federative Republic of Brazil. Any dispute related to these terms will be submitted to the exclusive jurisdiction of the competent courts."
|
||||||
|
},
|
||||||
|
"lastUpdate": "Last updated: November 2025."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"whatsapp": {
|
||||||
|
"label": "Contáctenos"
|
||||||
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "Inicio",
|
"home": "Inicio",
|
||||||
"services": "Servicios",
|
"services": "Servicios",
|
||||||
@@ -191,6 +194,70 @@
|
|||||||
"accept": "Aceptar",
|
"accept": "Aceptar",
|
||||||
"decline": "Rechazar"
|
"decline": "Rechazar"
|
||||||
},
|
},
|
||||||
|
"privacy": {
|
||||||
|
"title": "Política de Privacidad",
|
||||||
|
"intro": "OCCTO Ingeniería valora la privacidad de sus usuarios y clientes. Esta Política de Privacidad describe cómo recopilamos, utilizamos y protegemos su información personal al utilizar nuestro sitio web y servicios.",
|
||||||
|
"section1": {
|
||||||
|
"title": "1. Recopilación de Información",
|
||||||
|
"content": "Recopilamos información que nos proporciona directamente, como cuando completa nuestro formulario de contacto, solicita un presupuesto o se suscribe a nuestro boletín informativo. La información puede incluir nombre, correo electrónico, teléfono y detalles sobre su empresa o proyecto."
|
||||||
|
},
|
||||||
|
"section2": {
|
||||||
|
"title": "2. Uso de la Información",
|
||||||
|
"intro": "Utilizamos la información recopilada para:",
|
||||||
|
"items": [
|
||||||
|
"Responder a sus consultas y solicitudes de presupuesto;",
|
||||||
|
"Proporcionar información sobre nuestros servicios de ingeniería e informes técnicos;",
|
||||||
|
"Mejorar la experiencia del usuario en nuestro sitio web;",
|
||||||
|
"Cumplir con obligaciones legales y normativas."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"section3": {
|
||||||
|
"title": "3. Protección de Datos",
|
||||||
|
"content": "Adoptamos medidas de seguridad técnicas y organizacionales apropiadas para proteger su información personal contra acceso no autorizado, alteración, divulgación o destrucción."
|
||||||
|
},
|
||||||
|
"section4": {
|
||||||
|
"title": "4. Compartir Información",
|
||||||
|
"content": "No vendemos, canjeamos ni transferimos su información personal a terceros, excepto cuando sea necesario para la prestación de nuestros servicios (ej: socios técnicos involucrados en un proyecto específico) o cuando lo exija la ley."
|
||||||
|
},
|
||||||
|
"section5": {
|
||||||
|
"title": "5. Cookies",
|
||||||
|
"content": "Nuestro sitio web puede utilizar cookies para mejorar la navegación y entender cómo los visitantes interactúan con nuestro contenido. Puede desactivar las cookies en la configuración de su navegador si lo prefiere."
|
||||||
|
},
|
||||||
|
"section6": {
|
||||||
|
"title": "6. Contacto",
|
||||||
|
"content": "Si tiene preguntas sobre esta Política de Privacidad, comuníquese con nosotros a través de correo electrónico: contato@octto.com.br."
|
||||||
|
},
|
||||||
|
"lastUpdate": "Última actualización: Noviembre de 2025."
|
||||||
|
},
|
||||||
|
"terms": {
|
||||||
|
"title": "Términos de Uso",
|
||||||
|
"intro": "Bienvenido al sitio web de OCCTO Ingeniería. Al acceder y utilizar este sitio web, usted acepta cumplir y estar vinculado a los siguientes Términos de Uso. Si no está de acuerdo con ninguna parte de estos términos, por favor no utilice nuestro sitio web.",
|
||||||
|
"section1": {
|
||||||
|
"title": "1. Uso del Sitio",
|
||||||
|
"content": "El contenido de este sitio web es solo para propósitos informativos generales sobre nuestros servicios de ingeniería mecánica, informes y proyectos. Nos reservamos el derecho de alterar o descontinuar cualquier aspecto del sitio en cualquier momento."
|
||||||
|
},
|
||||||
|
"section2": {
|
||||||
|
"title": "2. Propiedad Intelectual",
|
||||||
|
"content": "Todo el contenido presente en este sitio web, incluidos textos, gráficos, logotipos, iconos, imágenes y software, es propiedad de OCCTO Ingeniería o de sus proveedores de contenido y está protegido por las leyes de derechos de autor de Brasil e internacionales."
|
||||||
|
},
|
||||||
|
"section3": {
|
||||||
|
"title": "3. Limitación de Responsabilidad",
|
||||||
|
"content": "OCCTO Ingeniería no se responsabiliza por ningún daño directo, indirecto, incidental o consecuente que resulte del uso o la incapacidad de usar este sitio web o cualquier información contenida en el mismo. La información técnica proporcionada en el sitio web no sustituye la consulta profesional y la emisión de informes técnicos específicos para cada caso."
|
||||||
|
},
|
||||||
|
"section4": {
|
||||||
|
"title": "4. Enlaces a Terceros",
|
||||||
|
"content": "Nuestro sitio web puede contener enlaces a sitios web de terceros. Estos enlaces se proporcionan solo para su conveniencia. OCCTO Ingeniería no tiene control sobre el contenido de estos sitios y no asume responsabilidad por ellos."
|
||||||
|
},
|
||||||
|
"section5": {
|
||||||
|
"title": "5. Cambios en los Términos",
|
||||||
|
"content": "Podemos revisar estos Términos de Uso en cualquier momento. Al utilizar este sitio web, usted acepta estar vinculado por la versión actual de estos Términos de Uso."
|
||||||
|
},
|
||||||
|
"section6": {
|
||||||
|
"title": "6. Ley Aplicable",
|
||||||
|
"content": "Estos términos se rigen e interpretan de acuerdo con las leyes de la República Federativa de Brasil. Cualquier disputa relacionada con estos términos será sometida a la jurisdicción exclusiva de los tribunales competentes."
|
||||||
|
},
|
||||||
|
"lastUpdate": "Última actualización: Noviembre de 2025."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"loading": "Cargando...",
|
"loading": "Cargando...",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"whatsapp": {
|
||||||
|
"label": "Fale Conosco"
|
||||||
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "Início",
|
"home": "Início",
|
||||||
"services": "Serviços",
|
"services": "Serviços",
|
||||||
@@ -191,6 +194,70 @@
|
|||||||
"accept": "Aceitar",
|
"accept": "Aceitar",
|
||||||
"decline": "Recusar"
|
"decline": "Recusar"
|
||||||
},
|
},
|
||||||
|
"privacy": {
|
||||||
|
"title": "Política de Privacidade",
|
||||||
|
"intro": "A Octto Engenharia valoriza a privacidade de seus usuários e clientes. Esta Política de Privacidade descreve como coletamos, usamos e protegemos suas informações pessoais ao utilizar nosso site e serviços.",
|
||||||
|
"section1": {
|
||||||
|
"title": "1. Coleta de Informações",
|
||||||
|
"content": "Coletamos informações que você nos fornece diretamente, como quando preenche nosso formulário de contato, solicita um orçamento ou se inscreve em nossa newsletter. As informações podem incluir nome, e-mail, telefone e detalhes sobre sua empresa ou projeto."
|
||||||
|
},
|
||||||
|
"section2": {
|
||||||
|
"title": "2. Uso das Informações",
|
||||||
|
"intro": "Utilizamos as informações coletadas para:",
|
||||||
|
"items": [
|
||||||
|
"Responder a suas consultas e solicitações de orçamento;",
|
||||||
|
"Fornecer informações sobre nossos serviços de engenharia e laudos técnicos;",
|
||||||
|
"Melhorar a experiência do usuário em nosso site;",
|
||||||
|
"Cumprir obrigações legais e regulatórias."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"section3": {
|
||||||
|
"title": "3. Proteção de Dados",
|
||||||
|
"content": "Adotamos medidas de segurança técnicas e organizacionais adequadas para proteger seus dados pessoais contra acesso não autorizado, alteração, divulgação ou destruição."
|
||||||
|
},
|
||||||
|
"section4": {
|
||||||
|
"title": "4. Compartilhamento de Informações",
|
||||||
|
"content": "Não vendemos, trocamos ou transferimos suas informações pessoais para terceiros, exceto quando necessário para a prestação de nossos serviços (ex: parceiros técnicos envolvidos em um projeto específico) ou quando exigido por lei."
|
||||||
|
},
|
||||||
|
"section5": {
|
||||||
|
"title": "5. Cookies",
|
||||||
|
"content": "Nosso site pode utilizar cookies para melhorar a navegação e entender como os visitantes interagem com nosso conteúdo. Você pode desativar os cookies nas configurações do seu navegador, se preferir."
|
||||||
|
},
|
||||||
|
"section6": {
|
||||||
|
"title": "6. Contato",
|
||||||
|
"content": "Se você tiver dúvidas sobre esta Política de Privacidade, entre em contato conosco através do e-mail: contato@octto.com.br."
|
||||||
|
},
|
||||||
|
"lastUpdate": "Última atualização: Novembro de 2025."
|
||||||
|
},
|
||||||
|
"terms": {
|
||||||
|
"title": "Termos de Uso",
|
||||||
|
"intro": "Bem-vindo ao site da Octto Engenharia. Ao acessar e utilizar este site, você concorda em cumprir e estar vinculado aos seguintes Termos de Uso. Se você não concordar com qualquer parte destes termos, por favor, não utilize nosso site.",
|
||||||
|
"section1": {
|
||||||
|
"title": "1. Uso do Site",
|
||||||
|
"content": "O conteúdo deste site é apenas para fins informativos gerais sobre nossos serviços de engenharia mecânica, laudos e projetos. Reservamo-nos o direito de alterar ou descontinuar qualquer aspecto do site a qualquer momento."
|
||||||
|
},
|
||||||
|
"section2": {
|
||||||
|
"title": "2. Propriedade Intelectual",
|
||||||
|
"content": "Todo o conteúdo presente neste site, incluindo textos, gráficos, logotipos, ícones, imagens e software, é propriedade da Octto Engenharia ou de seus fornecedores de conteúdo e é protegido pelas leis de direitos autorais do Brasil e internacionais."
|
||||||
|
},
|
||||||
|
"section3": {
|
||||||
|
"title": "3. Limitação de Responsabilidade",
|
||||||
|
"content": "A Octto Engenharia não se responsabiliza por quaisquer danos diretos, indiretos, incidentais ou consequenciais resultantes do uso ou da incapacidade de uso deste site ou de qualquer informação nele contida. As informações técnicas fornecidas no site não substituem a consulta profissional e a emissão de laudos técnicos específicos para cada caso."
|
||||||
|
},
|
||||||
|
"section4": {
|
||||||
|
"title": "4. Links para Terceiros",
|
||||||
|
"content": "Nosso site pode conter links para sites de terceiros. Estes links são fornecidos apenas para sua conveniência. A Octto Engenharia não tem controle sobre o conteúdo desses sites e não assume responsabilidade por eles."
|
||||||
|
},
|
||||||
|
"section5": {
|
||||||
|
"title": "5. Alterações nos Termos",
|
||||||
|
"content": "Podemos revisar estes Termos de Uso a qualquer momento. Ao utilizar este site, você concorda em ficar vinculado à versão atual desses Termos de Uso."
|
||||||
|
},
|
||||||
|
"section6": {
|
||||||
|
"title": "6. Legislação Aplicável",
|
||||||
|
"content": "Estes termos são regidos e interpretados de acordo com as leis da República Federativa do Brasil. Qualquer disputa relacionada a estes termos será submetida à jurisdição exclusiva dos tribunais competentes."
|
||||||
|
},
|
||||||
|
"lastUpdate": "Última atualização: Novembro de 2025."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"loading": "Carregando...",
|
"loading": "Carregando...",
|
||||||
"error": "Erro",
|
"error": "Erro",
|
||||||
|
|||||||
239
scripts/restore-from-cloud.sh
Normal file
239
scripts/restore-from-cloud.sh
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
#######################################################
|
||||||
|
# Script de Restauração de Backup do Occto Engenharia
|
||||||
|
#
|
||||||
|
# Uso:
|
||||||
|
# bash restore-from-cloud.sh \
|
||||||
|
# --backup-url "http://minio:9000/backups/backup-2025-11-29.tar.gz" \
|
||||||
|
# --backup-file "backup-2025-11-29.tar.gz" \
|
||||||
|
# --postgres-password "sua_senha" \
|
||||||
|
# --postgres-db "occto_db" \
|
||||||
|
# --postgres-host "postgres"
|
||||||
|
#######################################################
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Cores para output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Variáveis padrão
|
||||||
|
BACKUP_URL=""
|
||||||
|
BACKUP_FILE=""
|
||||||
|
POSTGRES_PASSWORD="adminpassword"
|
||||||
|
POSTGRES_USER="admin"
|
||||||
|
POSTGRES_DB="occto_db"
|
||||||
|
POSTGRES_HOST="postgres"
|
||||||
|
POSTGRES_PORT="5432"
|
||||||
|
MINIO_ENDPOINT="minio"
|
||||||
|
MINIO_PORT="9000"
|
||||||
|
MINIO_ACCESS_KEY="admin"
|
||||||
|
MINIO_SECRET_KEY="adminpassword"
|
||||||
|
PROJECT_DIR=$(pwd)
|
||||||
|
TEMP_DIR="/tmp/occto-restore-$$"
|
||||||
|
|
||||||
|
# Parsing de argumentos
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--backup-url)
|
||||||
|
BACKUP_URL="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--backup-file)
|
||||||
|
BACKUP_FILE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--postgres-password)
|
||||||
|
POSTGRES_PASSWORD="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--postgres-db)
|
||||||
|
POSTGRES_DB="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--postgres-host)
|
||||||
|
POSTGRES_HOST="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--postgres-user)
|
||||||
|
POSTGRES_USER="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--minio-endpoint)
|
||||||
|
MINIO_ENDPOINT="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Opção desconhecida: $1${NC}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Validações
|
||||||
|
if [ -z "$BACKUP_FILE" ]; then
|
||||||
|
echo -e "${RED}Erro: --backup-file é obrigatório${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$BACKUP_URL" ] && [ ! -f "$BACKUP_FILE" ]; then
|
||||||
|
echo -e "${RED}Erro: --backup-url é obrigatório se o arquivo não existir localmente${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Funções
|
||||||
|
log_info() {
|
||||||
|
echo -e "${BLUE}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_success() {
|
||||||
|
echo -e "${GREEN}[✓]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[✗]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warning() {
|
||||||
|
echo -e "${YELLOW}[!]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
log_info "Limpando arquivos temporários..."
|
||||||
|
rm -rf "$TEMP_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# ============ INÍCIO ============
|
||||||
|
|
||||||
|
echo -e "${BLUE}"
|
||||||
|
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ Restauração de Backup - Occto Engenharia ║"
|
||||||
|
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||||
|
echo -e "${NC}"
|
||||||
|
|
||||||
|
log_info "Configurações:"
|
||||||
|
log_info " Banco de Dados: $POSTGRES_DB"
|
||||||
|
log_info " Host PostgreSQL: $POSTGRES_HOST"
|
||||||
|
log_info " MinIO: $MINIO_ENDPOINT:$MINIO_PORT"
|
||||||
|
log_info " Arquivo: $BACKUP_FILE"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1. Preparar diretório temporário
|
||||||
|
log_info "Criando diretório temporário..."
|
||||||
|
mkdir -p "$TEMP_DIR"
|
||||||
|
log_success "Diretório criado: $TEMP_DIR"
|
||||||
|
|
||||||
|
# 2. Baixar backup se necessário
|
||||||
|
if [ -z "$BACKUP_URL" ] && [ -f "$BACKUP_FILE" ]; then
|
||||||
|
log_info "Usando arquivo local: $BACKUP_FILE"
|
||||||
|
cp "$BACKUP_FILE" "$TEMP_DIR/"
|
||||||
|
else
|
||||||
|
log_info "Baixando backup de $BACKUP_URL..."
|
||||||
|
cd "$TEMP_DIR"
|
||||||
|
curl -# -O "$BACKUP_URL"
|
||||||
|
log_success "Backup baixado"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Extrair arquivo
|
||||||
|
BACKUP_PATH="$TEMP_DIR/$BACKUP_FILE"
|
||||||
|
if [ ! -f "$BACKUP_PATH" ]; then
|
||||||
|
log_error "Arquivo não encontrado: $BACKUP_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Extraindo arquivo ($BACKUP_FILE)..."
|
||||||
|
cd "$TEMP_DIR"
|
||||||
|
tar -xzf "$BACKUP_FILE"
|
||||||
|
log_success "Arquivo extraído"
|
||||||
|
|
||||||
|
# 4. Restaurar PostgreSQL
|
||||||
|
log_info "Conectando ao PostgreSQL ($POSTGRES_HOST:$POSTGRES_PORT)..."
|
||||||
|
|
||||||
|
DB_FILE="$TEMP_DIR/database.sql"
|
||||||
|
if [ ! -f "$DB_FILE" ]; then
|
||||||
|
log_error "database.sql não encontrado no backup!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Terminando conexões do banco de dados..."
|
||||||
|
PGPASSWORD="$POSTGRES_PASSWORD" psql \
|
||||||
|
-h "$POSTGRES_HOST" \
|
||||||
|
-p "$POSTGRES_PORT" \
|
||||||
|
-U "$POSTGRES_USER" \
|
||||||
|
-tc "SELECT pg_terminate_backend(pg_stat_activity.pid)
|
||||||
|
FROM pg_stat_activity
|
||||||
|
WHERE datname = '$POSTGRES_DB'
|
||||||
|
AND pid <> pg_backend_pid();" 2>/dev/null || true
|
||||||
|
|
||||||
|
log_info "Removendo banco antigo..."
|
||||||
|
PGPASSWORD="$POSTGRES_PASSWORD" dropdb \
|
||||||
|
-h "$POSTGRES_HOST" \
|
||||||
|
-p "$POSTGRES_PORT" \
|
||||||
|
-U "$POSTGRES_USER" \
|
||||||
|
--if-exists "$POSTGRES_DB" 2>/dev/null || true
|
||||||
|
|
||||||
|
log_info "Criando novo banco de dados..."
|
||||||
|
PGPASSWORD="$POSTGRES_PASSWORD" createdb \
|
||||||
|
-h "$POSTGRES_HOST" \
|
||||||
|
-p "$POSTGRES_PORT" \
|
||||||
|
-U "$POSTGRES_USER" \
|
||||||
|
"$POSTGRES_DB"
|
||||||
|
|
||||||
|
log_info "Restaurando dados do PostgreSQL (pode levar alguns minutos)..."
|
||||||
|
PGPASSWORD="$POSTGRES_PASSWORD" psql \
|
||||||
|
-h "$POSTGRES_HOST" \
|
||||||
|
-p "$POSTGRES_PORT" \
|
||||||
|
-U "$POSTGRES_USER" \
|
||||||
|
-d "$POSTGRES_DB" \
|
||||||
|
-f "$DB_FILE" > /dev/null 2>&1
|
||||||
|
|
||||||
|
log_success "PostgreSQL restaurado com sucesso!"
|
||||||
|
|
||||||
|
# 5. Restaurar MinIO (se arquivo existir)
|
||||||
|
MINIO_DATA_DIR="$TEMP_DIR/minio-data"
|
||||||
|
if [ -d "$MINIO_DATA_DIR" ]; then
|
||||||
|
log_info "Restaurando dados do MinIO..."
|
||||||
|
|
||||||
|
# Procurar containers Docker do MinIO
|
||||||
|
MINIO_CONTAINER=$(docker ps --filter "name=$MINIO_ENDPOINT" -q 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [ -n "$MINIO_CONTAINER" ]; then
|
||||||
|
log_info "Copiando dados para container MinIO ($MINIO_CONTAINER)..."
|
||||||
|
docker cp "$MINIO_DATA_DIR/." "$MINIO_CONTAINER:/data/" 2>/dev/null || {
|
||||||
|
log_warning "Erro ao copiar para MinIO. Você pode fazer manualmente:"
|
||||||
|
log_warning "docker cp $MINIO_DATA_DIR/. seu-container-minio:/data/"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_info "Reiniciando MinIO..."
|
||||||
|
docker restart "$MINIO_CONTAINER" > /dev/null 2>&1
|
||||||
|
log_success "MinIO restaurado!"
|
||||||
|
else
|
||||||
|
log_warning "Container MinIO não encontrado. Copie os dados manualmente:"
|
||||||
|
log_warning "cp -r $MINIO_DATA_DIR/* /seu/minio/data/"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_warning "Pasta minio-data não encontrada no backup"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 6. Resumo final
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}"
|
||||||
|
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ Restauração Concluída com Sucesso! ║"
|
||||||
|
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||||
|
echo -e "${NC}"
|
||||||
|
|
||||||
|
log_success "PostgreSQL restaurado"
|
||||||
|
[ -d "$MINIO_DATA_DIR" ] && log_success "MinIO restaurado"
|
||||||
|
echo ""
|
||||||
|
log_info "Próximos passos:"
|
||||||
|
echo " 1. Reinicie seus containers: docker-compose restart"
|
||||||
|
echo " 2. Verifique se a aplicação está funcionando"
|
||||||
|
echo " 3. Acesse http://seu-dominio.com para confirmar"
|
||||||
|
echo ""
|
||||||
Reference in New Issue
Block a user