44 Commits

Author SHA1 Message Date
Erik
8fc974efb8 fix: remover 'use client' conflict com generateMetadata 2025-12-04 12:14:56 -03:00
Erik
6f990c06b2 fix: usar número do WhatsApp correto 553598829445 em todos os campos 2025-12-04 12:09:04 -03:00
Erik
815a70bc41 fix: usar número do WhatsApp correto direto sem puxar do banco 2025-12-04 02:58:39 -03:00
Erik
d6ff6a61bc fix: corrigir número do WhatsApp para 553598829445 2025-12-04 01:01:05 -03:00
Erik
c4fda169b4 fix: corrigir número do WhatsApp para 5535988229445 2025-12-04 00:20:01 -03:00
Erik
654bdd2521 fix: corrigir WhatsApp button com URL api.whatsapp.com e remover glitch do hover 2025-12-03 20:08:10 -03:00
Erik
2c76d7af8d fix: corrigir erros de tipagem TypeScript nos maps da página home 2025-12-03 18:29:50 -03:00
Erik
92f3798808 feat: atualizar URLs para octtoengenharia.com.br, adicionar SEO dinâmico em projetos e corrigir WhatsApp button 2025-12-03 17:45:54 -03:00
Erik
037072d297 feat: implementar SEO completo com sitemap, robots.txt, JSON-LD schema e Google Search Console docs 2025-12-03 17:37:41 -03:00
Erik
16de9f48b8 feat: adicionar campo para criar categorias customizadas na adição/edição de projetos 2025-12-03 14:25:51 -03:00
Erik
d4a94658bf feat: adicionar crédito idealpages no rodapé com link WhatsApp 2025-12-01 18:35:39 -03:00
Erik
be866aa976 fix: corrigir link do projeto na busca para usar ID específico 2025-12-01 18:31:22 -03:00
Erik
2bf941777f feat: adicionar busca no menu mobile com SearchDropdown e reduzir espaço superior 2025-12-01 17:15:37 -03:00
Erik
ef98075686 fix: conectar SearchDropdown com Header e passar searchValue corretamente 2025-12-01 16:46:47 -03:00
Erik
bee1af01ec feat: adicionar busca dinâmica de projetos no header com dropdown 2025-12-01 16:13:02 -03:00
Erik
565aae1b9f fix: corrige avisos de estilo do Tailwind CSS
- bg-gradient-to-r -> bg-linear-to-r
- after:start-[4px] -> after:start-1
- z-[60] -> z-60
2025-12-01 12:19:46 -03:00
Erik
061a572464 fix: esconde texto OCCTO ENG quando logo está presente
- Header: mostra apenas logo (150x50)
- Footer: mostra apenas logo
- Admin: mostra apenas logo
2025-11-29 16:53:49 -03:00
Erik
239fca5924 fix: corrige descrições da página de contato com texto fixo 2025-11-29 16:53:05 -03:00
Erik
8c6e64f5b1 fix: página de contato agora usa apenas dados das Settings
- Remove lógica que usava items do CMS antigo
- Sempre usa dados dinâmicos das Settings (whatsapp, email, endereço)
- Corrige ambas versões: (public) e [locale]
2025-11-29 16:47:05 -03:00
Erik
e503069a86 feat: implementa sistema de logotipo dinâmico
- Adiciona campo 'logo' ao modelo Settings no Prisma
- Atualiza API /api/settings para lidar com upload de logo
- Cria aba Logotipo funcional no admin com upload de imagem
- Atualiza Header para exibir logo dinâmico (fallback para ícone)
- Atualiza Footer para exibir logo dinâmico
- Atualiza Admin Layout para exibir logo dinâmico
- Logo é atualizado em tempo real via evento settings:refresh
2025-11-29 16:36:25 -03:00
Erik
cbad251b39 feat: Add subtle admin bar above header for logged-in users 2025-11-29 16:24:37 -03:00
Erik
b493f1d4d9 refactor: Remove contact page from admin, redirect to settings tab 2025-11-29 16:18:24 -03:00
Erik
232d28eb1a fix: Remove duplicate JSX code in contact pages 2025-11-29 16:03:34 -03:00
Erik
080444e29d feat: Reorganize admin config tabs and sync contact info across pages 2025-11-29 16:01:46 -03:00
Erik
a14e7749b7 feat: Add dynamic contact info and social media settings 2025-11-29 15:52:21 -03:00
Erik
c06221331e fix: Replace hardcoded badge with PartnerBadge component in public homepage 2025-11-29 15:43:12 -03:00
Erik
55003b4561 feat: Add partner badge toggle in admin settings 2025-11-29 15:31:50 -03:00
Erik
70f1541ec0 feat: Implement global badge system with Settings model and global PartnerBadge component 2025-11-29 14:07:47 -03:00
Erik
53495de904 fix: hero badge now properly hides when disabled (fallback changed) 2025-11-29 13:36:02 -03:00
Erik
4310a88b2a fix: footer badge now properly hides when disabled in admin 2025-11-29 13:23:24 -03:00
Erik
0dd8f89fff debug: add logging to home page endpoint and debug route 2025-11-29 13:15:50 -03:00
Erik
6a7b84989b feat: make footer badge dynamic from homepage content 2025-11-29 13:03:45 -03:00
Erik
278b9ade28 fix: add /api/pages/home endpoint for badge and hero section 2025-11-29 13:03:11 -03:00
Erik
95fbf31bfa fix: improve pg_dump execution and add better error handling 2025-11-29 12:45:58 -03:00
Erik
932caf1b6c feat: add cloud backup upload and universal restore script 2025-11-29 12:44:47 -03:00
Erik
1600cc8267 fix: improve backup GET endpoint error handling and date parsing 2025-11-29 12:42:17 -03:00
Erik
ae8639bb2f feat: add restore functionality to backup manager 2025-11-29 12:37:34 -03:00
Erik
bf95f067bc refactor: organizar configuracoes em tabs (Personalizacao e Backup) 2025-11-29 12:30:17 -03:00
Erik
99530200b4 feat: adicionar sistema de backup e badge editável na página inicial 2025-11-29 12:22:56 -03:00
Erik
b73eb6c3eb 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) 2025-11-27 20:39:21 -03:00
Erik
c31184ad4b fix: número WhatsApp correto 553598829445 2025-11-27 20:08:24 -03:00
Erik
d323f28220 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 2025-11-27 20:07:27 -03:00
Erik
d5183e0a0d 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 2025-11-27 20:01:11 -03:00
Erik
1fa574881c Merge branch 'cms-1.1' - Release CMS 1.1 2025-11-27 19:42:12 -03:00
48 changed files with 4970 additions and 1058 deletions

179
docs/BACKUP_CLOUD.md Normal file
View 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
View 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
View 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

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

View 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.*

View File

@@ -166,3 +166,163 @@ frontend/src/app/api/projects/[id]/
**Branch**: `cms-1.1` **Branch**: `cms-1.1`
**Status**: ✅ Produção **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)

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

View File

@@ -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
}

View 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

View File

@@ -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,31 +129,53 @@ 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[]
{
icon: 'ri-whatsapp-line',
title: 'Telefone',
description: 'Atendimento de segunda a sexta, das 8h às 18h',
link: 'https://wa.me/5527999999999',
linkText: '(27) 99999-9999'
},
{
icon: 'ri-mail-send-line',
title: 'E-mail',
description: 'Responderemos em até 24 horas úteis',
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'
}
]
}; };
// Montar items dinamicamente baseado nas configurações (Settings)
const contactItems: ContactInfo[] = [];
if (settings.whatsapp) {
contactItems.push({
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',
description: 'Atendimento de segunda a sexta, das 8h às 18h',
link: `tel:${settings.phone.replace(/\D/g, '')}`,
linkText: settings.phone
});
}
if (settings.email) {
contactItems.push({
icon: 'ri-mail-send-line',
title: 'E-mail',
description: 'Envie sua mensagem',
link: `mailto:${settings.email}`,
linkText: settings.email
});
}
if (settings.address) {
contactItems.push({
icon: 'ri-map-pin-line',
title: 'Endereço',
description: settings.address,
link: `https://maps.google.com/maps?q=${encodeURIComponent(settings.address)}`,
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">
{/* Hero Section */} {/* Hero Section */}
@@ -165,22 +213,29 @@ export default function ContatoPage() {
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
{info.items.map((item, index) => ( {displayItems.length > 0 ? (
<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"> displayItems.map((item, index) => (
<div className="flex items-start gap-5"> <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="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="flex items-start gap-5">
<i className={`${item.icon} text-3xl`}></i> <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> <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> <div>
<p className="text-gray-600 dark:text-gray-400 mb-3 text-sm whitespace-pre-line">{item.description}</p> <h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-2">{item.title}</h4>
<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"> <p className="text-gray-600 dark:text-gray-400 mb-3 text-sm whitespace-pre-line">{item.description}</p>
{item.linkText} <i className="ri-arrow-right-line"></i> <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">
</a> {item.linkText} <i className="ri-arrow-right-line"></i>
</a>
</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> </div>

View File

@@ -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>

View File

@@ -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,31 +128,53 @@ 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[]
{
icon: 'ri-whatsapp-line',
title: t('contact.phone'),
description: t('contact.phoneDescription'),
link: 'https://wa.me/5527999999999',
linkText: '(27) 99999-9999'
},
{
icon: 'ri-mail-send-line',
title: t('contact.email'),
description: t('contact.emailDescription'),
link: 'mailto:contato@octto.com.br',
linkText: 'contato@octto.com.br'
},
{
icon: 'ri-map-pin-line',
title: t('contact.address'),
description: t('contact.addressDescription'),
link: 'https://maps.google.com',
linkText: t('contact.viewOnMap')
}
]
}; };
// Montar items dinamicamente baseado nas configurações (Settings)
const contactItems: ContactInfo[] = [];
if (settings.whatsapp) {
contactItems.push({
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'),
description: 'Atendimento de segunda a sexta, das 8h às 18h',
link: `tel:${settings.phone.replace(/\D/g, '')}`,
linkText: settings.phone
});
}
if (settings.email) {
contactItems.push({
icon: 'ri-mail-send-line',
title: 'E-mail',
description: 'Envie sua mensagem',
link: `mailto:${settings.email}`,
linkText: settings.email
});
}
if (settings.address) {
contactItems.push({
icon: 'ri-map-pin-line',
title: t('contact.address'),
description: settings.address,
link: `https://maps.google.com/maps?q=${encodeURIComponent(settings.address)}`,
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">
{/* Hero Section */} {/* Hero Section */}
@@ -164,22 +212,29 @@ export default function ContatoPage() {
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
{info.items.map((item, index) => ( {displayItems.length > 0 ? (
<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"> displayItems.map((item, index) => (
<div className="flex items-start gap-5"> <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="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="flex items-start gap-5">
<i className={`${item.icon} text-3xl`}></i> <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> <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> <div>
<p className="text-gray-600 dark:text-gray-400 mb-3 text-sm whitespace-pre-line">{item.description}</p> <h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-2">{item.title}</h4>
<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"> <p className="text-gray-600 dark:text-gray-400 mb-3 text-sm whitespace-pre-line">{item.description}</p>
{item.linkText} <i className="ri-arrow-right-line"></i> <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">
</a> {item.linkText} <i className="ri-arrow-right-line"></i>
</a>
</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> </div>

View File

@@ -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">

View File

@@ -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>

View File

@@ -0,0 +1,5 @@
export { generateMetadata } from "./metadata";
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}

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

View File

@@ -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>

View File

@@ -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,171 +235,621 @@ 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>
{/* Color Settings */} {/* Tabs Navigation */}
<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 border border-gray-200 dark:border-white/10 rounded-xl overflow-hidden">
<div className="flex items-start gap-4 mb-6"> <div className="flex gap-0">
<div className="w-12 h-12 bg-linear-to-br from-primary to-orange-600 rounded-xl flex items-center justify-center shadow-lg shadow-primary/30"> <button
<i className="ri-palette-line text-2xl text-white"></i> onClick={() => setActiveTab('personalizacao')}
</div> className={`flex-1 px-4 py-4 font-bold flex items-center justify-center gap-2 border-b-2 transition-all cursor-pointer ${
<div className="flex-1"> activeTab === 'personalizacao'
<h2 className="text-xl font-bold text-secondary dark:text-white mb-1">Cor Primária</h2> ? 'bg-primary/5 dark:bg-primary/10 text-primary border-primary'
<p className="text-gray-500 dark:text-gray-400 text-sm"> : 'text-gray-600 dark:text-gray-400 border-transparent hover:bg-gray-50 dark:hover:bg-white/5'
Escolha a cor principal que representa sua marca. Ela será aplicada em botões, links e destaques. }`}
</p> >
</div> <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>
</div>
{/* Preset Colors */} {/* Tab Content - Personalização */}
<div className="mb-8"> {activeTab === 'personalizacao' && (
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-4"> <div className="space-y-6">
Cores Predefinidas {/* Color Settings */}
</label> <div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="flex items-start gap-4 mb-6">
{PRESET_COLORS.map((color) => ( <div className="w-12 h-12 bg-linear-to-br from-primary to-orange-600 rounded-xl flex items-center justify-center shadow-lg shadow-primary/30">
<button <i className="ri-palette-line text-2xl text-white"></i>
key={color.value} </div>
type="button" <div className="flex-1">
onClick={() => applyPreviewColor(color.value)} <h2 className="text-xl font-bold text-secondary dark:text-white mb-1">Cor Primária</h2>
className={`group relative p-4 rounded-xl border-2 transition-all ${ <p className="text-gray-500 dark:text-gray-400 text-sm">
primaryColor === color.value Escolha a cor principal que representa sua marca. Ela será aplicada em botões, links e destaques.
? 'border-primary shadow-lg shadow-primary/20'
: 'border-gray-200 dark:border-white/10 hover:border-gray-300 dark:hover:border-white/20'
}`}
>
<div className={`w-full h-16 rounded-lg bg-linear-to-br ${color.gradient} mb-3 shadow-md group-hover:scale-105 transition-transform`}></div>
<p className="text-sm font-medium text-gray-900 dark:text-white text-center">
{color.name}
</p> </p>
{primaryColor === color.value && ( </div>
<div className="absolute top-2 right-2 w-6 h-6 bg-primary rounded-full flex items-center justify-center shadow-lg">
<i className="ri-check-line text-white text-sm"></i>
</div>
)}
</button>
))}
</div>
</div>
{/* Custom Color Picker */}
<div className="border-t border-gray-200 dark:border-white/10 pt-8">
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-4">
Cor Personalizada
</label>
<div className="flex items-center gap-4">
<div className="relative">
<input
type="color"
value={customColor}
onChange={(e) => applyPreviewColor(e.target.value)}
className="w-20 h-20 rounded-xl border-2 border-gray-200 dark:border-white/10 cursor-pointer shadow-md"
/>
</div> </div>
{/* Preset Colors */}
<div className="mb-8">
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-4">
Cores Predefinidas
</label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{PRESET_COLORS.map((color) => (
<button
key={color.value}
type="button"
onClick={() => applyPreviewColor(color.value)}
className={`group relative p-4 rounded-xl border-2 transition-all ${
primaryColor === color.value
? 'border-primary shadow-lg shadow-primary/20'
: 'border-gray-200 dark:border-white/10 hover:border-gray-300 dark:hover:border-white/20'
}`}
>
<div className={`w-full h-16 rounded-lg bg-linear-to-br ${color.gradient} mb-3 shadow-md group-hover:scale-105 transition-transform`}></div>
<p className="text-sm font-medium text-gray-900 dark:text-white text-center">
{color.name}
</p>
{primaryColor === color.value && (
<div className="absolute top-2 right-2 w-6 h-6 bg-primary rounded-full flex items-center justify-center shadow-lg">
<i className="ri-check-line text-white text-sm"></i>
</div>
)}
</button>
))}
</div>
</div>
{/* Custom Color Picker */}
<div className="border-t border-gray-200 dark:border-white/10 pt-8">
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-4">
Cor Personalizada
</label>
<div className="flex items-center gap-4">
<div className="relative">
<input
type="color"
value={customColor}
onChange={(e) => applyPreviewColor(e.target.value)}
className="w-20 h-20 rounded-xl border-2 border-gray-200 dark:border-white/10 cursor-pointer shadow-md"
/>
</div>
<div className="flex-1">
<input
type="text"
value={customColor}
onChange={(e) => {
setCustomColor(e.target.value);
if (/^#[0-9A-F]{6}$/i.test(e.target.value)) {
applyPreviewColor(e.target.value);
}
}}
placeholder="#FF6B35"
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 font-mono"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
Digite o código hexadecimal da cor (ex: #FF6B35)
</p>
</div>
</div>
</div>
{/* Preview Section */}
<div className="border-t border-gray-200 dark:border-white/10 mt-8 pt-8">
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-4">
Prévia dos Elementos
</label>
<div className="bg-gray-50 dark:bg-white/5 p-6 rounded-xl space-y-4">
<button
className="px-6 py-3 bg-primary text-white rounded-xl font-bold hover:opacity-90 transition-all shadow-lg shadow-primary/30"
style={{ backgroundColor: primaryColor }}
>
Botão Primário
</button>
<div className="flex items-center gap-3">
<div
className="w-12 h-12 rounded-xl flex items-center justify-center text-white shadow-md"
style={{ backgroundColor: primaryColor }}
>
<i className="ri-star-fill text-xl"></i>
</div>
<div>
<p className="font-bold" style={{ color: primaryColor }}>Texto em Destaque</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Exemplo de link ou texto importante</p>
</div>
</div>
<div className="flex items-center gap-2">
<span
className="px-3 py-1 rounded-full text-sm font-medium text-white"
style={{ backgroundColor: primaryColor }}
>
Badge
</span>
<span
className="px-3 py-1 rounded-full text-sm font-medium border-2"
style={{ borderColor: primaryColor, color: primaryColor }}
>
Outline Badge
</span>
</div>
</div>
</div>
</div>
{/* Save Button */}
<div className="flex items-center justify-end gap-4">
<button
onClick={fetchConfig}
className="px-6 py-3 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"
>
Cancelar
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-6 py-3 bg-primary text-white rounded-xl font-bold hover-primary transition-colors shadow-lg shadow-primary/20 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ backgroundColor: saving ? undefined : primaryColor }}
>
{saving ? (
<>
<i className="ri-loader-4-line animate-spin"></i>
Salvando...
</>
) : (
<>
<i className="ri-save-line"></i>
Salvar Alterações
</>
)}
</button>
</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"> <div className="flex-1">
<input <p className="text-sm text-blue-900 dark:text-blue-200 font-medium mb-1">
type="text" Aplicação Global
value={customColor} </p>
onChange={(e) => { <p className="text-sm text-blue-700 dark:text-blue-300">
setCustomColor(e.target.value); A cor primária será aplicada automaticamente em todo o site institucional e painel administrativo.
if (/^#[0-9A-F]{6}$/i.test(e.target.value)) {
applyPreviewColor(e.target.value);
}
}}
placeholder="#FF6B35"
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 font-mono"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
Digite o código hexadecimal da cor (ex: #FF6B35)
</p> </p>
</div> </div>
</div> </div>
</div>
{/* Preview Section */} {/* Partner Badge Settings */}
<div className="border-t border-gray-200 dark:border-white/10 mt-8 pt-8"> <div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-4"> <div className="flex items-start gap-4 mb-6">
Prévia dos Elementos <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">
</label> <i className="ri-verified-badge-fill text-2xl text-white"></i>
<div className="bg-gray-50 dark:bg-white/5 p-6 rounded-xl space-y-4"> </div>
<button <div className="flex-1">
className="px-6 py-3 bg-primary text-white rounded-xl font-bold hover:opacity-90 transition-all shadow-lg shadow-primary/30" <h2 className="text-xl font-bold text-secondary dark:text-white mb-1">Badge de Parceiro</h2>
style={{ backgroundColor: primaryColor }} <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"
> >
Botão Primário <i className="ri-save-line"></i>
Salvar Configurações do Badge
</button> </button>
<div className="flex items-center gap-3">
<div
className="w-12 h-12 rounded-xl flex items-center justify-center text-white shadow-md"
style={{ backgroundColor: primaryColor }}
>
<i className="ri-star-fill text-xl"></i>
</div>
<div>
<p className="font-bold" style={{ color: primaryColor }}>Texto em Destaque</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Exemplo de link ou texto importante</p>
</div>
</div>
<div className="flex items-center gap-2">
<span
className="px-3 py-1 rounded-full text-sm font-medium text-white"
style={{ backgroundColor: primaryColor }}
>
Badge
</span>
<span
className="px-3 py-1 rounded-full text-sm font-medium border-2"
style={{ borderColor: primaryColor, color: primaryColor }}
>
Outline Badge
</span>
</div>
</div> </div>
</div> </div>
</div> )}
{/* Save Button */} {/* Tab Content - Logotipo */}
<div className="flex items-center justify-end gap-4"> {activeTab === 'logotipo' && (
<button <div className="space-y-6">
onClick={fetchConfig} <div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
className="px-6 py-3 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" <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">
Cancelar <i className="ri-image-2-fill text-2xl text-white"></i>
</button> </div>
<button <div className="flex-1">
onClick={handleSave} <h2 className="text-xl font-bold text-secondary dark:text-white mb-1">Logotipo</h2>
disabled={saving} <p className="text-gray-500 dark:text-gray-400 text-sm">
className="px-6 py-3 bg-primary text-white rounded-xl font-bold hover-primary transition-colors shadow-lg shadow-primary/20 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" Configure o logotipo que aparece no cabeçalho, rodapé e painel administrativo do site.
style={{ backgroundColor: saving ? undefined : primaryColor }} </p>
> </div>
{saving ? ( </div>
<>
<i className="ri-loader-4-line animate-spin"></i>
Salvando...
</>
) : (
<>
<i className="ri-save-line"></i>
Salvar Alterações
</>
)}
</button>
</div>
{/* Info Alert */} {/* Current Logo Preview */}
<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"> <div className="mb-6">
<i className="ri-information-line text-blue-600 dark:text-blue-400 text-xl mt-0.5"></i> <label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-3">
<div className="flex-1"> Logotipo Atual
<p className="text-sm text-blue-900 dark:text-blue-200 font-medium mb-1"> </label>
Aplicação Global <div className="flex items-center gap-6">
</p> <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">
<p className="text-sm text-blue-700 dark:text-blue-300"> {logoPreview ? (
A cor primária será aplicada automaticamente em todo o site institucional e painel administrativo. <Image
</p> 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> </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>
); );
} }

View File

@@ -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,12 +262,25 @@ 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">
<i className="ri-building-2-fill text-3xl text-primary"></i> {logo ? (
{isSidebarOpen && ( <Image
<div className="flex items-center gap-2 animate-in fade-in duration-300"> src={logo}
<span className="text-xl font-bold text-secondary dark:text-white font-headline leading-none">OCCTO</span> alt="OCCTO Engenharia"
<span className="text-[10px] font-bold text-primary bg-primary/10 px-2 py-1 rounded-md uppercase tracking-wider">ENG.</span> width={isSidebarOpen ? 120 : 32}
</div> height={40}
className="object-contain h-10 w-auto"
unoptimized
/>
) : (
<>
<i className="ri-building-2-fill text-3xl text-primary"></i>
{isSidebarOpen && (
<div className="flex items-center gap-2 animate-in fade-in duration-300">
<span className="text-xl 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>
</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={() => {

View File

@@ -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">

View File

@@ -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>
</>
);
}

View File

@@ -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>
)} )}

View File

@@ -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>
); );

View File

@@ -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>

View File

@@ -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>

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

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

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

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

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

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

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

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

View File

@@ -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`}
> >

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

View File

@@ -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">
<i className="ri-building-2-fill text-4xl text-primary"></i> {contact.logo ? (
<div className="flex items-center gap-2"> <Image
<span className="text-2xl font-bold font-headline">OCCTO</span> src={contact.logo}
<span className="text-[10px] font-bold text-primary bg-white/10 px-2 py-1 rounded-md uppercase tracking-wider">ENG.</span> alt="OCCTO Engenharia"
</div> width={150}
height={50}
className="object-contain h-12 w-auto"
unoptimized
/>
) : (
<>
<i className="ri-building-2-fill text-4xl text-primary"></i>
<div className="flex items-center gap-2">
<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>
</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 && (
<i className="ri-instagram-line"></i> <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">
</a> <i className="ri-instagram-line"></i>
<a href="#" className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center hover:bg-primary transition-colors"> </a>
<i className="ri-linkedin-fill"></i> )}
</a> {contact.linkedin && (
<a href="#" className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center hover:bg-primary transition-colors"> <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-facebook-fill"></i> <i className="ri-linkedin-fill"></i>
</a> </a>
)}
{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>
</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">
<li className="flex items-start gap-3 text-gray-400"> {contact.address && (
<i className="ri-map-pin-line mt-1 text-primary"></i> <li className="flex items-start gap-3 text-gray-400">
<span>Endereço da Empresa, 123<br />Cidade - ES</span> <i className="ri-map-pin-line mt-1 text-primary"></i>
</li> <span>{contact.address}</span>
<li className="flex items-center gap-3 text-gray-400"> </li>
<i className="ri-phone-line text-primary"></i> )}
<span>(27) 99999-9999</span> {contact.phone && (
</li> <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-mail-line text-primary"></i> <a href={`tel:${contact.phone.replace(/\D/g, '')}`} className="hover:text-primary transition-colors">
<span>contato@octto.com.br</span> {contact.phone}
</li> </a>
</li>
)}
{contact.email && (
<li className="flex items-center gap-3 text-gray-400">
<i className="ri-mail-line text-primary"></i>
<a href={`mailto:${contact.email}`} className="hover:text-primary transition-colors">
{contact.email}
</a>
</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">
<p className="text-gray-500 text-sm"> <div className="flex flex-col items-center md:items-start gap-2">
© {new Date().getFullYear()} OCCTO Engenharia. {t('footer.rights')} <p className="text-gray-500 text-sm">
</p> © {new Date().getFullYear()} OCCTO Engenharia. {t('footer.rights')}
</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>

View File

@@ -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,29 +92,75 @@ 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">
<i className="ri-building-2-fill text-4xl text-primary group-hover:scale-105 transition-transform"></i> {logo ? (
<div className="flex items-center gap-2"> <Image
<span className="text-3xl font-bold text-secondary dark:text-white font-headline leading-none">OCCTO</span> src={logo}
<span className="text-[10px] font-bold text-primary bg-primary/10 px-2 py-1 rounded-md uppercase tracking-wider">ENG.</span> alt="OCCTO Engenharia"
</div> 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>
<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-[10px] font-bold text-primary bg-primary/10 px-2 py-1 rounded-md uppercase tracking-wider">ENG.</span>
</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>
<SearchDropdown
isOpen={isSearchOpen}
searchValue={searchValue}
onSearchChange={setSearchValue}
onClose={() => setIsSearchOpen(false)}
/>
</div> </div>
<nav className="flex items-center gap-6 mr-4"> <nav className="flex items-center gap-6 mr-4">
@@ -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">
<i className="ri-search-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"></i> <div className="relative">
<input <i className="ri-search-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"></i>
type="text" <input
placeholder={t('nav.search')} type="text"
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" 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"
/>
<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>
</>
); );
} }

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

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

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

View File

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

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

View File

@@ -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',

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

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