Compare commits
44 Commits
cms-octto-
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b02a7d176a | ||
|
|
82d56506c7 | ||
|
|
20be219137 | ||
|
|
89b5a2edc1 | ||
|
|
737d1f57ce | ||
|
|
7fb9d88b5b | ||
|
|
61d8f707dc | ||
|
|
a5d42028e6 | ||
|
|
5ecff30584 | ||
|
|
7f7c635efd | ||
|
|
e24e5eb4b2 | ||
|
|
191127b3b3 | ||
|
|
f69e0a10c4 | ||
|
|
1138747565 | ||
|
|
f077569bc1 | ||
|
|
b8310f3691 | ||
|
|
19ae5a25aa | ||
|
|
7605b72312 | ||
|
|
65736471ad | ||
|
|
64e99298f4 | ||
|
|
ca3eac5e1e | ||
|
|
30ddb5392e | ||
|
|
01885be3bb | ||
|
|
86cfda78b2 | ||
|
|
2db3873ee7 | ||
|
|
ad16a50008 | ||
|
|
ddf833e1e1 | ||
|
|
4ab537735e | ||
|
|
0c74ea107a | ||
|
|
82e7fa958f | ||
|
|
17d8ad313c | ||
|
|
24e03f954b | ||
|
|
9903452af1 | ||
|
|
5e33da4063 | ||
|
|
8aa4aacd25 | ||
|
|
af9c3166b4 | ||
|
|
12b79e7948 | ||
|
|
6e32ffdc95 | ||
|
|
ea0c4ac5a6 | ||
|
|
6044a437f8 | ||
|
|
0bde8d4a56 | ||
|
|
5ac57449b7 | ||
|
|
686df732ea | ||
|
|
d8332b894f |
162
README.md
162
README.md
@@ -1,162 +0,0 @@
|
|||||||
# CMS OCCTO Engenharia v1.0
|
|
||||||
|
|
||||||
Sistema de gerenciamento de conteúdo (CMS) desenvolvido para a OCCTO Engenharia.
|
|
||||||
|
|
||||||
## 📋 Visão Geral
|
|
||||||
|
|
||||||
CMS completo para gerenciamento do site institucional da OCCTO Engenharia, empresa especializada em engenharia veicular, mecânica e segurança do trabalho.
|
|
||||||
|
|
||||||
## 🚀 Tecnologias
|
|
||||||
|
|
||||||
- **Frontend:** Next.js 16 (App Router)
|
|
||||||
- **Estilização:** Tailwind CSS 4
|
|
||||||
- **Banco de Dados:** PostgreSQL 12 + Prisma ORM
|
|
||||||
- **Storage:** MinIO (S3-compatible)
|
|
||||||
- **Autenticação:** JWT com bcryptjs
|
|
||||||
- **Deploy:** Docker Compose + Dokploy
|
|
||||||
|
|
||||||
## 📁 Estrutura do Projeto
|
|
||||||
|
|
||||||
```
|
|
||||||
├── frontend/ # Aplicação Next.js
|
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── app/
|
|
||||||
│ │ │ ├── (public)/ # Páginas públicas (home, sobre, contato, projetos, serviços)
|
|
||||||
│ │ │ ├── admin/ # Painel administrativo
|
|
||||||
│ │ │ ├── acesso/ # Página de login
|
|
||||||
│ │ │ └── api/ # API Routes
|
|
||||||
│ │ ├── components/ # Componentes reutilizáveis
|
|
||||||
│ │ └── lib/ # Utilitários (auth, minio, prisma)
|
|
||||||
│ ├── prisma/
|
|
||||||
│ │ ├── schema.prisma # Schema do banco de dados
|
|
||||||
│ │ └── seed.mjs # Dados iniciais
|
|
||||||
│ └── public/ # Assets estáticos
|
|
||||||
├── docker-compose.yml # Configuração Docker
|
|
||||||
└── docs/ # Documentação
|
|
||||||
```
|
|
||||||
|
|
||||||
## ✨ Funcionalidades
|
|
||||||
|
|
||||||
### Painel Administrativo
|
|
||||||
|
|
||||||
- **📄 Páginas** - Edição completa das páginas Home, Sobre e Contato
|
|
||||||
- **🛠️ Serviços** - CRUD de serviços com ícones personalizáveis
|
|
||||||
- **📂 Projetos** - CRUD de projetos/portfólio com upload de imagens
|
|
||||||
- **👥 Usuários** - Gerenciamento de usuários administradores
|
|
||||||
- **💬 Leads** - Visualização de mensagens de contato recebidas
|
|
||||||
- **⚙️ Configurações** - Personalização de cor primária do tema
|
|
||||||
|
|
||||||
### Site Público
|
|
||||||
|
|
||||||
- **Home** - Hero, diferenciais, serviços, sobre, depoimentos, estatísticas, CTA
|
|
||||||
- **Sobre** - Missão, visão, valores da empresa
|
|
||||||
- **Serviços** - Lista de serviços oferecidos
|
|
||||||
- **Projetos** - Portfólio de projetos realizados
|
|
||||||
- **Contato** - Formulário de contato com informações da empresa
|
|
||||||
|
|
||||||
### Recursos Técnicos
|
|
||||||
|
|
||||||
- ✅ Autenticação JWT segura
|
|
||||||
- ✅ Upload de imagens para MinIO (S3)
|
|
||||||
- ✅ Tema dinâmico (cor primária configurável)
|
|
||||||
- ✅ Design responsivo
|
|
||||||
- ✅ SEO otimizado
|
|
||||||
- ✅ Cookies consent LGPD
|
|
||||||
- ✅ Loading states e feedback visual
|
|
||||||
- ✅ Confirmação de exclusão
|
|
||||||
|
|
||||||
## 🗄️ Banco de Dados
|
|
||||||
|
|
||||||
### Modelos Prisma
|
|
||||||
|
|
||||||
- **User** - Usuários administradores
|
|
||||||
- **PageContent** - Conteúdo das páginas (JSON flexível)
|
|
||||||
- **Service** - Serviços oferecidos
|
|
||||||
- **Project** - Projetos do portfólio
|
|
||||||
- **Lead** - Mensagens de contato
|
|
||||||
- **SiteSettings** - Configurações do site (tema)
|
|
||||||
|
|
||||||
## 🐳 Deploy com Docker
|
|
||||||
|
|
||||||
### Serviços
|
|
||||||
|
|
||||||
- **postgres** - PostgreSQL 12 Alpine
|
|
||||||
- **minio** - MinIO (storage S3-compatible)
|
|
||||||
- **frontend** - Aplicação Next.js
|
|
||||||
|
|
||||||
### Variáveis de Ambiente
|
|
||||||
|
|
||||||
```env
|
|
||||||
# PostgreSQL
|
|
||||||
POSTGRES_USER=admin
|
|
||||||
POSTGRES_PASSWORD=sua_senha_segura
|
|
||||||
POSTGRES_DB=occto_db
|
|
||||||
|
|
||||||
# MinIO
|
|
||||||
MINIO_ROOT_USER=admin
|
|
||||||
MINIO_ROOT_PASSWORD=sua_senha_segura
|
|
||||||
|
|
||||||
# App
|
|
||||||
DATABASE_URL=postgresql://admin:senha@postgres:5432/occto_db?schema=public
|
|
||||||
JWT_SECRET=seu_jwt_secret_muito_seguro
|
|
||||||
MINIO_ENDPOINT=minio
|
|
||||||
MINIO_PORT=9000
|
|
||||||
MINIO_ACCESS_KEY=admin
|
|
||||||
MINIO_SECRET_KEY=sua_senha_segura
|
|
||||||
MINIO_BUCKET_NAME=occto-images
|
|
||||||
```
|
|
||||||
|
|
||||||
### Comandos
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Deploy
|
|
||||||
docker compose up -d --build
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
docker compose logs -f frontend
|
|
||||||
|
|
||||||
# Rebuild
|
|
||||||
docker compose down && docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔐 Credenciais Padrão
|
|
||||||
|
|
||||||
Após o seed inicial:
|
|
||||||
|
|
||||||
- **Email:** `admin@occto.com`
|
|
||||||
- **Senha:** `admin`
|
|
||||||
|
|
||||||
⚠️ **Importante:** Altere as credenciais em produção!
|
|
||||||
|
|
||||||
## 📝 Changelog v1.0
|
|
||||||
|
|
||||||
### Funcionalidades Implementadas
|
|
||||||
|
|
||||||
- [x] Sistema de autenticação JWT
|
|
||||||
- [x] CRUD completo de serviços
|
|
||||||
- [x] CRUD completo de projetos
|
|
||||||
- [x] CRUD completo de usuários
|
|
||||||
- [x] Edição de páginas (Home, Sobre, Contato)
|
|
||||||
- [x] Sistema de leads/mensagens de contato
|
|
||||||
- [x] Upload de imagens para MinIO
|
|
||||||
- [x] Configuração de tema (cor primária)
|
|
||||||
- [x] Docker Compose para deploy
|
|
||||||
- [x] Integração com Dokploy
|
|
||||||
- [x] Seed de dados iniciais
|
|
||||||
|
|
||||||
### Correções
|
|
||||||
|
|
||||||
- [x] Hover dinâmico baseado na cor primária
|
|
||||||
- [x] Healthcheck do PostgreSQL
|
|
||||||
- [x] Compatibilidade com CPUs antigos (PostgreSQL 12 Alpine)
|
|
||||||
- [x] Seed em JavaScript puro (sem ts-node em produção)
|
|
||||||
|
|
||||||
## 📄 Licença
|
|
||||||
|
|
||||||
Projeto desenvolvido para OCCTO Engenharia. Todos os direitos reservados.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Versão:** 1.0
|
|
||||||
**Data:** Novembro/2025
|
|
||||||
**Desenvolvido por:** Assistente IA + Erik
|
|
||||||
75
docker-compose-dev.yml
Normal file
75
docker-compose-dev.yml
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Docker Compose para ambiente de DESENVOLVIMENTO
|
||||||
|
# Usa banco de dados separado e subdomínio dev.octto.stackbyte.cloud
|
||||||
|
# Branch: dev
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres_dev:
|
||||||
|
image: postgres:12-alpine
|
||||||
|
container_name: occto_postgres_dev
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-admin}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-adminpassword}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-occto_db_dev}
|
||||||
|
volumes:
|
||||||
|
- postgres_data_dev:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- occto_network_dev
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U admin -d occto_db_dev"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
minio_dev:
|
||||||
|
image: minio/minio:RELEASE.2025-09-07T16-13-09Z-cpuv1
|
||||||
|
container_name: occto_minio_dev
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-admin}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-adminpassword}
|
||||||
|
volumes:
|
||||||
|
- minio_data_dev:/data
|
||||||
|
networks:
|
||||||
|
occto_network_dev:
|
||||||
|
aliases:
|
||||||
|
- minio
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend_dev:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: occto_frontend_dev
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- DATABASE_URL=postgresql://admin:adminpassword@postgres_dev:5432/occto_db_dev?schema=public
|
||||||
|
- MINIO_ENDPOINT=minio
|
||||||
|
- MINIO_PORT=9000
|
||||||
|
- MINIO_USE_SSL=false
|
||||||
|
- MINIO_ACCESS_KEY=admin
|
||||||
|
- MINIO_SECRET_KEY=adminpassword
|
||||||
|
- MINIO_BUCKET_NAME=occto-images-dev
|
||||||
|
- JWT_SECRET=${JWT_SECRET:-dev_jwt_secret_change_in_production_1234567890}
|
||||||
|
- LIBRETRANSLATE_URL=https://libretranslate.stackbyte.cloud
|
||||||
|
depends_on:
|
||||||
|
postgres_dev:
|
||||||
|
condition: service_healthy
|
||||||
|
minio_dev:
|
||||||
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
- occto_network_dev
|
||||||
|
- dokploy-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
occto_network_dev:
|
||||||
|
driver: bridge
|
||||||
|
dokploy-network:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data_dev:
|
||||||
|
minio_data_dev:
|
||||||
@@ -47,6 +47,7 @@ services:
|
|||||||
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY:-adminpassword}
|
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY:-adminpassword}
|
||||||
- MINIO_BUCKET_NAME=${MINIO_BUCKET_NAME:-occto-images}
|
- MINIO_BUCKET_NAME=${MINIO_BUCKET_NAME:-occto-images}
|
||||||
- JWT_SECRET=${JWT_SECRET:-b33500bb3dc5504535c34cc5f79f4ca0f60994b093bded14d48f76c0c090f032234693219e60398cab053a9c55c1d426ef7b1768104db9040254ba7db452f708}
|
- JWT_SECRET=${JWT_SECRET:-b33500bb3dc5504535c34cc5f79f4ca0f60994b093bded14d48f76c0c090f032234693219e60398cab053a9c55c1d426ef7b1768104db9040254ba7db452f708}
|
||||||
|
- LIBRETRANSLATE_URL=${LIBRETRANSLATE_URL:-https://libretranslate.stackbyte.cloud}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
49
docs/diario-de-bordo/resumo-25-27.md
Normal file
49
docs/diario-de-bordo/resumo-25-27.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
## Resumo Geral do Projeto (Atualizado em 27/11/2025)
|
||||||
|
|
||||||
|
### 1. Visão Geral
|
||||||
|
- Plataforma Next.js full-stack com duas frentes principais:
|
||||||
|
- **Site público** dentro de `src/app/(public)` e rotas localizadas em `src/app/[locale]` alimentadas por conteúdo dinâmico vindo do CMS.
|
||||||
|
- **Painel Admin** em `src/app/admin` para operação interna (gestão de páginas, serviços, projetos, usuários e mensagens).
|
||||||
|
- Back-end único via rotas App Router + Prisma/PostgreSQL (`prisma/pageContent`, `project`, `service`, etc.).
|
||||||
|
|
||||||
|
### 2. Conteúdo Dinâmico & CMS
|
||||||
|
- CRUD de páginas no admin acessa `/api/pages` (genérico) e `/api/pages/[slug]` (detalhe com autenticação JWT).
|
||||||
|
- Páginas gerenciadas até agora: `home`, `sobre`, `contato`, além de `config` (metadados globais) e rotas públicas estruturadas.
|
||||||
|
- Formular pós-edição acionam `translation:refresh` no front para atualizar badges do sininho.
|
||||||
|
- Layout administrativo (`admin/layout.tsx`) fornece sidebar, menu, avatar modal, confirmação padrão e o **painel de traduções** com polling + badge.
|
||||||
|
|
||||||
|
### 3. Plataforma de Traduções
|
||||||
|
- Salvar conteúdo em PT dispara `translateInBackground` (EN/ES) dentro de `src/app/api/pages/[slug]/route.ts` utilizando LibreTranslate + cache em `prisma.translation`.
|
||||||
|
- API auxiliar `/api/admin/translate-pages` permite rodadas manuais e retorno de status consolidado.
|
||||||
|
- Front exibe estado por slug (Concluída / Em andamento) e dispara notificações quando pendências são resolvidas.
|
||||||
|
- Job `prisma/check-translations.mjs` e script `scripts/checkTranslations.cjs` ajudam na auditoria dos timestamps.
|
||||||
|
- Endpoint redundante `/api/pages/contact` foi removido para evitar inconsistências; tudo passa pelo handler dinâmico.
|
||||||
|
|
||||||
|
### 4. Experiência do Editor
|
||||||
|
- Campos do CMS agora possuem limites visuais via `CharLimitBadge` (estilo Twitter) com `LabelWithLimit`, aplicados a **Home**, **Sobre** e **Contato**.
|
||||||
|
- Limites também reforçados com `maxLength` para impedir que textos comprometam o layout público.
|
||||||
|
- Foram definidos ícones reutilizáveis (selector custom) e componentes de formulário padronizados.
|
||||||
|
|
||||||
|
### 5. Site Público
|
||||||
|
- Utiliza `useTranslatedContent` + `<T>` para mesclar conteúdo dinâmico com fallback estático.
|
||||||
|
- Páginas principais refletem exatamente o que foi configurado no admin (banner hero, diferenciais, CTA, depoimentos, etc.).
|
||||||
|
- Formulário de contato envia para `/api/messages`, com feedback via `ToastContext`.
|
||||||
|
|
||||||
|
### 6. Segurança e Infra
|
||||||
|
- Autenticação do admin baseada em cookie `auth_token` (JWT) validado nas rotas protegidas.
|
||||||
|
- Upload/remoção de avatar gerenciado via `/api/auth/avatar` com modal padrão.
|
||||||
|
- Prisma centraliza o schema (`User`, `Project`, `Service`, `Message`, `PageContent`, `Translation`).
|
||||||
|
|
||||||
|
### 7. Histórico de Entregas Relevantes
|
||||||
|
1. Estruturação do CMS e rotas dinâmicas para páginas públicas.
|
||||||
|
2. Implementação de tradução automática assíncrona + cache.
|
||||||
|
3. Criação do painel de notificações com polling/badges.
|
||||||
|
4. Inclusão de limites de caracteres visíveis e enforce client-side.
|
||||||
|
5. Remoção de API duplicada e ajustes para manter EN/ES sincronizados.
|
||||||
|
|
||||||
|
### 8. Pendências & Próximos Passos
|
||||||
|
- Rodar tradução manual para `contact` e `config` (EN/ES ainda desatualizados segundo `scripts/checkTranslations.cjs`).
|
||||||
|
- Expandir o CMS para outras páginas (ex.: serviços e projetos públicos) caso necessário.
|
||||||
|
- Opcional: reforçar validação server-side dos limites e criar testes automatizados para o fluxo de tradução.
|
||||||
|
|
||||||
|
Este resumo deve servir como onboarding rápido para qualquer pessoa ou nova IA que precise continuar o desenvolvimento.
|
||||||
@@ -1,9 +1,39 @@
|
|||||||
- Tradução não funcionou em todos componentes
|
# 📋 Tarefas Pendentes - CMS OCCTO
|
||||||
|
|
||||||
- Logotipo (Formulário)
|
## 🔧 Melhorias a Implementar
|
||||||
|
|
||||||
- Link do WhatsApp
|
### 🌐 Internacionalização
|
||||||
|
- [ ] **Tradução incompleta** - Revisar e traduzir todos os componentes que ainda estão em inglês - em andamento
|
||||||
|
|
||||||
- Desabilitar local
|
### 🎨 Configurações do Admin
|
||||||
|
- [ ] **Upload de Logotipo** - Criar formulário para o cliente poder trocar o logotipo do site nas configurações
|
||||||
|
|
||||||
- Dados Reais no Dashboard
|
### 📱 Integrações
|
||||||
|
- [ ] **Link do WhatsApp** - Adicionar configuração do número de WhatsApp e botão flutuante no site
|
||||||
|
|
||||||
|
### 🗺️ Localização
|
||||||
|
- [ ] **Desabilitar local** - Adicionar opção para ocultar/desabilitar seção de localização/mapa na página de contato
|
||||||
|
|
||||||
|
### 📊 Dashboard
|
||||||
|
- [ ] **Dados Reais no Dashboard** - Implementar estatísticas reais no painel admin:
|
||||||
|
- Total de projetos
|
||||||
|
- Total de serviços
|
||||||
|
- Total de leads/mensagens
|
||||||
|
- Leads não lidos
|
||||||
|
- Últimas mensagens recebidas
|
||||||
|
|
||||||
|
- Adicionar modo noturno no painel
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Concluídas
|
||||||
|
|
||||||
|
- [x] Sistema de autenticação
|
||||||
|
- [x] CRUD de serviços
|
||||||
|
- [x] CRUD de projetos
|
||||||
|
- [x] CRUD de usuários
|
||||||
|
- [x] Edição de páginas
|
||||||
|
- [x] Sistema de leads
|
||||||
|
- [x] Upload de imagens (MinIO)
|
||||||
|
- [x] Configuração de cor primária
|
||||||
|
- [x] Hover dinâmico
|
||||||
|
- [x] Deploy Docker/Dokploy
|
||||||
|
|||||||
91
frontend/middleware.ts
Normal file
91
frontend/middleware.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
// Definir locales diretamente aqui para evitar problemas com edge runtime
|
||||||
|
const locales = ['pt', 'en', 'es'] as const;
|
||||||
|
type Locale = (typeof locales)[number];
|
||||||
|
const defaultLocale: Locale = 'pt';
|
||||||
|
|
||||||
|
// Rotas que NÃO devem ter prefixo de idioma
|
||||||
|
const publicPaths = ['/api', '/admin', '/acesso', '/_next', '/favicon', '/icon'];
|
||||||
|
|
||||||
|
function getLocaleFromPath(pathname: string): Locale | null {
|
||||||
|
const segments = pathname.split('/');
|
||||||
|
const possibleLocale = segments[1];
|
||||||
|
|
||||||
|
if (locales.includes(possibleLocale as Locale)) {
|
||||||
|
return possibleLocale as Locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocaleFromHeader(request: NextRequest): Locale {
|
||||||
|
const acceptLanguage = request.headers.get('accept-language') || '';
|
||||||
|
|
||||||
|
// Verificar se o navegador prefere algum dos nossos idiomas
|
||||||
|
for (const locale of locales) {
|
||||||
|
if (acceptLanguage.toLowerCase().includes(locale)) {
|
||||||
|
return locale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
// Ignorar rotas públicas (API, admin, etc)
|
||||||
|
if (publicPaths.some(path => pathname.startsWith(path))) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignorar arquivos estáticos
|
||||||
|
if (pathname.includes('.')) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se já tem locale na URL
|
||||||
|
const pathnameLocale = getLocaleFromPath(pathname);
|
||||||
|
|
||||||
|
if (pathnameLocale) {
|
||||||
|
// URL já tem locale, continuar
|
||||||
|
const response = NextResponse.next();
|
||||||
|
response.headers.set('x-locale', pathnameLocale);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar cookie de preferência
|
||||||
|
const cookieLocale = request.cookies.get('locale')?.value as Locale | undefined;
|
||||||
|
|
||||||
|
// Determinar locale: cookie > navegador > padrão
|
||||||
|
let locale: Locale;
|
||||||
|
|
||||||
|
if (cookieLocale && locales.includes(cookieLocale)) {
|
||||||
|
locale = cookieLocale;
|
||||||
|
} else {
|
||||||
|
locale = getLocaleFromHeader(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se for o locale padrão (pt), não adiciona prefixo na URL
|
||||||
|
// Isso mantém as URLs limpas: occto.com.br/ ao invés de occto.com.br/pt/
|
||||||
|
if (locale === defaultLocale) {
|
||||||
|
const response = NextResponse.next();
|
||||||
|
response.headers.set('x-locale', locale);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirecionar para URL com locale
|
||||||
|
const newUrl = new URL(`/${locale}${pathname}`, request.url);
|
||||||
|
newUrl.search = request.nextUrl.search;
|
||||||
|
|
||||||
|
return NextResponse.redirect(newUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
// Todas as rotas exceto arquivos estáticos
|
||||||
|
'/((?!_next/static|_next/image|favicon.ico|icon.svg|.*\\..*).*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -3,7 +3,10 @@ import type { NextConfig } from "next";
|
|||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
images: {
|
images: {
|
||||||
domains: ['localhost', 'images.unsplash.com'],
|
remotePatterns: [
|
||||||
|
{ protocol: 'http', hostname: 'localhost' },
|
||||||
|
{ protocol: 'https', hostname: 'images.unsplash.com' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
typescript: {
|
typescript: {
|
||||||
ignoreBuildErrors: true,
|
ignoreBuildErrors: true,
|
||||||
|
|||||||
3046
frontend/package-lock.json
generated
3046
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
@@ -13,17 +13,17 @@
|
|||||||
"seed": "node prisma/seed.mjs"
|
"seed": "node prisma/seed.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.940.0",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"minio": "^8.0.6",
|
"next": "15.1.0",
|
||||||
"next": "16.0.4",
|
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"react": "19.2.0",
|
"react": "^18.3.1",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "^18.3.1",
|
||||||
"remixicon": "^4.7.0",
|
"remixicon": "^4.7.0",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
@@ -32,11 +32,11 @@
|
|||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^18",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.4",
|
"eslint-config-next": "15.1.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
|||||||
23
frontend/prisma/check-translations.mjs
Normal file
23
frontend/prisma/check-translations.mjs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
const pages = await prisma.pageContent.findMany({
|
||||||
|
where: { slug: 'home' },
|
||||||
|
orderBy: { locale: 'asc' }
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('\n=== VERSÕES DA PÁGINA HOME ===\n')
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
const content = typeof page.content === 'string' ? JSON.parse(page.content) : page.content
|
||||||
|
console.log(`[${page.locale.toUpperCase()}] Hero title: ${content.hero?.title?.substring(0, 70)}...`)
|
||||||
|
console.log(` Atualizado: ${page.updatedAt}`)
|
||||||
|
console.log('')
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
check().catch(console.error)
|
||||||
49
frontend/prisma/migrate-locale.mjs
Normal file
49
frontend/prisma/migrate-locale.mjs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// Script para migrar dados existentes de PageContent para o novo formato com locale
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🔄 Migrando dados para incluir locale...\n');
|
||||||
|
|
||||||
|
// Buscar todos os registros que não têm locale definido ou têm locale null
|
||||||
|
const pages = await prisma.pageContent.findMany();
|
||||||
|
|
||||||
|
console.log(`📄 Encontrados ${pages.length} registros de PageContent\n`);
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
// Se o registro já tem locale 'pt' e está no formato correto, pular
|
||||||
|
if (page.locale === 'pt') {
|
||||||
|
console.log(`✓ "${page.slug}" (${page.locale}) - já migrado`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se tem locale diferente de pt, pular também (já foi migrado)
|
||||||
|
if (page.locale && ['en', 'es'].includes(page.locale)) {
|
||||||
|
console.log(`✓ "${page.slug}" (${page.locale}) - já é tradução`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualizar para ter locale 'pt'
|
||||||
|
try {
|
||||||
|
await prisma.pageContent.update({
|
||||||
|
where: { id: page.id },
|
||||||
|
data: { locale: 'pt' }
|
||||||
|
});
|
||||||
|
console.log(`✅ "${page.slug}" - atualizado para locale 'pt'`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Erro ao atualizar "${page.slug}":`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n✨ Migração concluída!');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('Erro na migração:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -63,9 +63,27 @@ model Message {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Modelo de Conteúdo de Página (para textos editáveis)
|
// Modelo de Conteúdo de Página (para textos editáveis)
|
||||||
|
// Cada página tem uma versão para cada idioma
|
||||||
model PageContent {
|
model PageContent {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
slug String @unique // "home", "sobre", "contato"
|
slug String // "home", "sobre", "contato"
|
||||||
|
locale String @default("pt") // "pt", "en", "es"
|
||||||
content Json
|
content Json
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([slug, locale]) // Uma entrada por página+idioma
|
||||||
|
@@index([slug])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modelo de Tradução (cache persistente)
|
||||||
|
model Translation {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sourceText String @db.Text
|
||||||
|
sourceLang String @default("pt")
|
||||||
|
targetLang String
|
||||||
|
translatedText String @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([sourceText, sourceLang, targetLang])
|
||||||
|
@@index([sourceLang, targetLang])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,10 +24,11 @@ async function main() {
|
|||||||
|
|
||||||
// 2. Seed para página Home
|
// 2. Seed para página Home
|
||||||
const homePage = await prisma.pageContent.upsert({
|
const homePage = await prisma.pageContent.upsert({
|
||||||
where: { slug: 'home' },
|
where: { slug_locale: { slug: 'home', locale: 'pt' } },
|
||||||
update: {},
|
update: {},
|
||||||
create: {
|
create: {
|
||||||
slug: 'home',
|
slug: 'home',
|
||||||
|
locale: 'pt',
|
||||||
content: {
|
content: {
|
||||||
hero: {
|
hero: {
|
||||||
title: 'Engenharia de Excelência para Seus Projetos',
|
title: 'Engenharia de Excelência para Seus Projetos',
|
||||||
@@ -130,10 +131,11 @@ async function main() {
|
|||||||
|
|
||||||
// 3. Seed para página Sobre
|
// 3. Seed para página Sobre
|
||||||
const sobrePage = await prisma.pageContent.upsert({
|
const sobrePage = await prisma.pageContent.upsert({
|
||||||
where: { slug: 'sobre' },
|
where: { slug_locale: { slug: 'sobre', locale: 'pt' } },
|
||||||
update: {},
|
update: {},
|
||||||
create: {
|
create: {
|
||||||
slug: 'sobre',
|
slug: 'sobre',
|
||||||
|
locale: 'pt',
|
||||||
content: {
|
content: {
|
||||||
hero: {
|
hero: {
|
||||||
title: 'Sobre a OCCTO Engenharia',
|
title: 'Sobre a OCCTO Engenharia',
|
||||||
@@ -165,10 +167,11 @@ async function main() {
|
|||||||
|
|
||||||
// 4. Seed para página Contato
|
// 4. Seed para página Contato
|
||||||
const contatoPage = await prisma.pageContent.upsert({
|
const contatoPage = await prisma.pageContent.upsert({
|
||||||
where: { slug: 'contato' },
|
where: { slug_locale: { slug: 'contato', locale: 'pt' } },
|
||||||
update: {},
|
update: {},
|
||||||
create: {
|
create: {
|
||||||
slug: 'contato',
|
slug: 'contato',
|
||||||
|
locale: 'pt',
|
||||||
content: {
|
content: {
|
||||||
hero: {
|
hero: {
|
||||||
title: 'Entre em Contato',
|
title: 'Entre em Contato',
|
||||||
|
|||||||
147
frontend/prisma/translate-pages.mjs
Normal file
147
frontend/prisma/translate-pages.mjs
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
// Script para traduzir todas as páginas PT para EN e ES
|
||||||
|
// Executar: node prisma/translate-pages.mjs
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const LIBRETRANSLATE_URL = process.env.LIBRETRANSLATE_URL || 'https://libretranslate.stackbyte.cloud';
|
||||||
|
const SUPPORTED_LOCALES = ['en', 'es'];
|
||||||
|
|
||||||
|
// Traduzir um texto
|
||||||
|
async function translateText(text, targetLang) {
|
||||||
|
if (!text || text.trim() === '' || targetLang === 'pt') return text;
|
||||||
|
|
||||||
|
// Verificar cache no banco primeiro
|
||||||
|
const cached = await prisma.translation.findUnique({
|
||||||
|
where: {
|
||||||
|
sourceText_sourceLang_targetLang: {
|
||||||
|
sourceText: text,
|
||||||
|
sourceLang: 'pt',
|
||||||
|
targetLang: targetLang,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
console.log(` [cache] "${text.substring(0, 25)}..."`);
|
||||||
|
return cached.translatedText;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(` [traduzindo] "${text.substring(0, 25)}..." -> ${targetLang}`);
|
||||||
|
|
||||||
|
const response = await fetch(`${LIBRETRANSLATE_URL}/translate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ q: text, source: 'pt', target: targetLang, format: 'text' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const translatedText = data.translatedText || text;
|
||||||
|
|
||||||
|
// Salvar no cache
|
||||||
|
try {
|
||||||
|
await prisma.translation.create({
|
||||||
|
data: {
|
||||||
|
sourceText: text,
|
||||||
|
sourceLang: 'pt',
|
||||||
|
targetLang: targetLang,
|
||||||
|
translatedText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignorar se já existe
|
||||||
|
}
|
||||||
|
|
||||||
|
return translatedText;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(` [erro] ${error.message}`);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traduzir objeto recursivamente
|
||||||
|
async function translateContent(content, targetLang) {
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
return await translateText(content, targetLang);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
const results = [];
|
||||||
|
for (const item of content) {
|
||||||
|
results.push(await translateContent(item, targetLang));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content && typeof content === 'object') {
|
||||||
|
const result = {};
|
||||||
|
for (const [key, value] of Object.entries(content)) {
|
||||||
|
// Não traduzir campos técnicos
|
||||||
|
if (['icon', 'image', 'img', 'url', 'href', 'id', 'slug', 'src', 'link', 'linkText'].includes(key)) {
|
||||||
|
result[key] = value;
|
||||||
|
} else {
|
||||||
|
result[key] = await translateContent(value, targetLang);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🌐 Iniciando tradução de páginas...\n');
|
||||||
|
console.log(`📡 LibreTranslate: ${LIBRETRANSLATE_URL}\n`);
|
||||||
|
|
||||||
|
// Buscar todas as páginas em português
|
||||||
|
const ptPages = await prisma.pageContent.findMany({
|
||||||
|
where: { locale: 'pt' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ptPages.length === 0) {
|
||||||
|
console.log('❌ Nenhuma página encontrada em português');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📄 Encontradas ${ptPages.length} páginas em PT\n`);
|
||||||
|
|
||||||
|
for (const page of ptPages) {
|
||||||
|
console.log(`\n📝 Página: ${page.slug}`);
|
||||||
|
console.log('─'.repeat(40));
|
||||||
|
|
||||||
|
for (const targetLocale of SUPPORTED_LOCALES) {
|
||||||
|
console.log(`\n 🔄 Traduzindo para ${targetLocale.toUpperCase()}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const translatedContent = await translateContent(page.content, targetLocale);
|
||||||
|
|
||||||
|
await prisma.pageContent.upsert({
|
||||||
|
where: { slug_locale: { slug: page.slug, locale: targetLocale } },
|
||||||
|
update: { content: translatedContent },
|
||||||
|
create: { slug: page.slug, locale: targetLocale, content: translatedContent }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ✅ ${page.slug} -> ${targetLocale.toUpperCase()} concluído!`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(` ❌ Erro: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n' + '═'.repeat(40));
|
||||||
|
console.log('✨ Tradução concluída!');
|
||||||
|
console.log('═'.repeat(40));
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('Erro:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
44
frontend/scripts/checkTranslations.cjs
Normal file
44
frontend/scripts/checkTranslations.cjs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
try {
|
||||||
|
const pages = await prisma.pageContent.findMany({
|
||||||
|
orderBy: [{ slug: 'asc' }, { locale: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const grouped = pages.reduce((acc, page) => {
|
||||||
|
acc[page.slug] = acc[page.slug] || [];
|
||||||
|
acc[page.slug].push({ locale: page.locale, updatedAt: page.updatedAt });
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
for (const [slug, entries] of Object.entries(grouped)) {
|
||||||
|
console.log(`\n=== ${slug.toUpperCase()} ===`);
|
||||||
|
const pt = entries.find((e) => e.locale === 'pt');
|
||||||
|
const ptDate = pt ? new Date(pt.updatedAt) : null;
|
||||||
|
|
||||||
|
for (const locale of ['pt', 'en', 'es']) {
|
||||||
|
const entry = entries.find((e) => e.locale === locale);
|
||||||
|
if (!entry) {
|
||||||
|
console.log(`${locale.toUpperCase()}: missing`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dt = new Date(entry.updatedAt);
|
||||||
|
let status = 'ok';
|
||||||
|
if (ptDate && locale !== 'pt' && dt < ptDate) {
|
||||||
|
status = 'outdated';
|
||||||
|
}
|
||||||
|
console.log(`${locale.toUpperCase()}: ${dt.toISOString()} (${status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
126
frontend/scripts/translateSlug.cjs
Normal file
126
frontend/scripts/translateSlug.cjs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const LIBRETRANSLATE_URL = process.env.LIBRETRANSLATE_URL || 'https://libretranslate.stackbyte.cloud';
|
||||||
|
const TARGET_LOCALES = ['en', 'es'];
|
||||||
|
const SKIP_KEYS = ['icon', 'image', 'img', 'url', 'href', 'id', 'slug', 'src', 'email', 'phone', 'whatsapp', 'link', 'linkText'];
|
||||||
|
|
||||||
|
async function translateText(text, targetLang) {
|
||||||
|
if (!text || typeof text !== 'string' || text.trim() === '' || targetLang === 'pt') {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = await prisma.translation.findUnique({
|
||||||
|
where: {
|
||||||
|
sourceText_sourceLang_targetLang: {
|
||||||
|
sourceText: text,
|
||||||
|
sourceLang: 'pt',
|
||||||
|
targetLang,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
return cached.translatedText;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${LIBRETRANSLATE_URL}/translate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ q: text, source: 'pt', target: targetLang, format: 'text' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn(`Translation API error (${targetLang}): ${response.status}`);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const translatedText = data.translatedText || text;
|
||||||
|
|
||||||
|
await prisma.translation.upsert({
|
||||||
|
where: {
|
||||||
|
sourceText_sourceLang_targetLang: {
|
||||||
|
sourceText: text,
|
||||||
|
sourceLang: 'pt',
|
||||||
|
targetLang,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: { translatedText },
|
||||||
|
create: {
|
||||||
|
sourceText: text,
|
||||||
|
sourceLang: 'pt',
|
||||||
|
targetLang,
|
||||||
|
translatedText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return translatedText;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function translateContent(content, targetLang) {
|
||||||
|
if (targetLang === 'pt') return content;
|
||||||
|
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
return translateText(content, targetLang);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
const results = [];
|
||||||
|
for (const item of content) {
|
||||||
|
results.push(await translateContent(item, targetLang));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content && typeof content === 'object') {
|
||||||
|
const result = {};
|
||||||
|
for (const [key, value] of Object.entries(content)) {
|
||||||
|
if (SKIP_KEYS.includes(key)) {
|
||||||
|
result[key] = value;
|
||||||
|
} else {
|
||||||
|
result[key] = await translateContent(value, targetLang);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const slug = process.argv[2];
|
||||||
|
if (!slug) {
|
||||||
|
console.error('Usage: node scripts/translateSlug.cjs <slug>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ptPage = await prisma.pageContent.findUnique({
|
||||||
|
where: { slug_locale: { slug, locale: 'pt' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ptPage) {
|
||||||
|
console.error(`Slug "${slug}" not found in locale PT.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const locale of TARGET_LOCALES) {
|
||||||
|
console.log(`Translating ${slug} -> ${locale.toUpperCase()}...`);
|
||||||
|
const translatedContent = await translateContent(ptPage.content, locale);
|
||||||
|
await prisma.pageContent.upsert({
|
||||||
|
where: { slug_locale: { slug, locale } },
|
||||||
|
update: { content: translatedContent },
|
||||||
|
create: { slug, locale, content: translatedContent },
|
||||||
|
});
|
||||||
|
console.log(`✓ ${locale.toUpperCase()} done.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useLanguage } from "@/contexts/LanguageContext";
|
|
||||||
import { useToast } from "@/contexts/ToastContext";
|
import { useToast } from "@/contexts/ToastContext";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useTranslatedContent, T } from "@/components/TranslatedText";
|
||||||
|
|
||||||
interface ContactInfo {
|
interface ContactInfo {
|
||||||
icon: string;
|
icon: string;
|
||||||
@@ -27,7 +27,6 @@ interface ContactContent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ContatoPage() {
|
export default function ContatoPage() {
|
||||||
const { t } = useLanguage();
|
|
||||||
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 [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -40,6 +39,9 @@ export default function ContatoPage() {
|
|||||||
message: ''
|
message: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Traduzir conteúdo do banco automaticamente
|
||||||
|
const { translatedContent } = useTranslatedContent(content);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchContent();
|
fetchContent();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -91,34 +93,34 @@ export default function ContatoPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Valores padrão caso não tenha conteúdo salvo
|
// Valores padrão caso não tenha conteúdo salvo
|
||||||
const hero = content?.hero || {
|
const hero = translatedContent?.hero || {
|
||||||
pretitle: t('contact.info.pretitle'),
|
pretitle: 'Fale Conosco',
|
||||||
title: t('contact.hero.title'),
|
title: 'Entre em Contato',
|
||||||
subtitle: t('contact.hero.subtitle')
|
subtitle: 'Estamos prontos para atender sua empresa com soluções de engenharia de alta qualidade'
|
||||||
};
|
};
|
||||||
|
|
||||||
const info = content?.info || {
|
const info = translatedContent?.info || {
|
||||||
title: t('contact.info.title'),
|
title: 'Informações',
|
||||||
subtitle: t('contact.info.subtitle'),
|
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: [
|
||||||
{
|
{
|
||||||
icon: 'ri-whatsapp-line',
|
icon: 'ri-whatsapp-line',
|
||||||
title: t('contact.info.phone.title'),
|
title: 'Telefone',
|
||||||
description: t('contact.info.whatsapp.desc'),
|
description: 'Atendimento de segunda a sexta, das 8h às 18h',
|
||||||
link: 'https://wa.me/5527999999999',
|
link: 'https://wa.me/5527999999999',
|
||||||
linkText: '(27) 99999-9999'
|
linkText: '(27) 99999-9999'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'ri-mail-send-line',
|
icon: 'ri-mail-send-line',
|
||||||
title: t('contact.info.email.title'),
|
title: 'E-mail',
|
||||||
description: t('contact.info.email.desc'),
|
description: 'Responderemos em até 24 horas úteis',
|
||||||
link: 'mailto:contato@octto.com.br',
|
link: 'mailto:contato@octto.com.br',
|
||||||
linkText: 'contato@octto.com.br'
|
linkText: 'contato@octto.com.br'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'ri-map-pin-line',
|
icon: 'ri-map-pin-line',
|
||||||
title: t('contact.info.address.title'),
|
title: 'Endereço',
|
||||||
description: 'Av. Nossa Senhora da Penha, 1234\nSanta Lúcia, Vitória - ES\nCEP: 29056-000',
|
description: 'Av. Nossa Senhora da Penha, 1234\nSanta Lúcia, Vitória - ES\nCEP: 29056-000',
|
||||||
link: 'https://maps.google.com',
|
link: 'https://maps.google.com',
|
||||||
linkText: 'Ver no mapa'
|
linkText: 'Ver no mapa'
|
||||||
@@ -187,12 +189,12 @@ export default function ContatoPage() {
|
|||||||
<div className="bg-white dark:bg-secondary p-8 md:p-10 rounded-3xl shadow-xl border border-gray-100 dark:border-white/10 relative overflow-hidden">
|
<div className="bg-white dark:bg-secondary p-8 md:p-10 rounded-3xl shadow-xl border border-gray-100 dark:border-white/10 relative overflow-hidden">
|
||||||
<div className="absolute top-0 right-0 w-32 h-32 bg-primary/5 rounded-bl-full -mr-10 -mt-10"></div>
|
<div className="absolute top-0 right-0 w-32 h-32 bg-primary/5 rounded-bl-full -mr-10 -mt-10"></div>
|
||||||
|
|
||||||
<h3 className="text-2xl font-bold font-headline text-secondary dark:text-white mb-8 relative z-10">{t('contact.form.title')}</h3>
|
<h3 className="text-2xl font-bold font-headline text-secondary dark:text-white mb-8 relative z-10"><T>Envie uma Mensagem</T></h3>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6 relative z-10">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-6 relative z-10">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="group">
|
<div className="group">
|
||||||
<label htmlFor="nome" className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 group-focus-within:text-primary transition-colors">{t('contact.form.name')}</label>
|
<label htmlFor="nome" className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 group-focus-within:text-primary transition-colors"><T>Nome</T></label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<i className="ri-user-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-primary transition-colors"></i>
|
<i className="ri-user-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-primary transition-colors"></i>
|
||||||
<input
|
<input
|
||||||
@@ -202,12 +204,12 @@ export default function ContatoPage() {
|
|||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({...formData, name: e.target.value})}
|
onChange={(e) => setFormData({...formData, name: e.target.value})}
|
||||||
className="w-full pl-11 pr-4 py-3.5 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 pl-11 pr-4 py-3.5 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={t('contact.form.name.placeholder')}
|
placeholder="Seu nome completo"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="group">
|
<div className="group">
|
||||||
<label htmlFor="telefone" className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 group-focus-within:text-primary transition-colors">{t('contact.form.phone')}</label>
|
<label htmlFor="telefone" className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 group-focus-within:text-primary transition-colors"><T>Telefone</T></label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<i className="ri-phone-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-primary transition-colors"></i>
|
<i className="ri-phone-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-primary transition-colors"></i>
|
||||||
<input
|
<input
|
||||||
@@ -223,7 +225,7 @@ export default function ContatoPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="group">
|
<div className="group">
|
||||||
<label htmlFor="email" className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 group-focus-within:text-primary transition-colors">{t('contact.form.email')}</label>
|
<label htmlFor="email" className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 group-focus-within:text-primary transition-colors"><T>E-mail</T></label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<i className="ri-mail-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-primary transition-colors"></i>
|
<i className="ri-mail-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-primary transition-colors"></i>
|
||||||
<input
|
<input
|
||||||
@@ -233,13 +235,13 @@ export default function ContatoPage() {
|
|||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={(e) => setFormData({...formData, email: e.target.value})}
|
onChange={(e) => setFormData({...formData, email: e.target.value})}
|
||||||
className="w-full pl-11 pr-4 py-3.5 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 pl-11 pr-4 py-3.5 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={t('contact.form.email.placeholder')}
|
placeholder="seu@email.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="group">
|
<div className="group">
|
||||||
<label htmlFor="assunto" className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 group-focus-within:text-primary transition-colors">{t('contact.form.subject')}</label>
|
<label htmlFor="assunto" className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 group-focus-within:text-primary transition-colors"><T>Assunto</T></label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<i className="ri-file-list-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-primary transition-colors"></i>
|
<i className="ri-file-list-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-primary transition-colors"></i>
|
||||||
<select
|
<select
|
||||||
@@ -248,18 +250,18 @@ export default function ContatoPage() {
|
|||||||
onChange={(e) => setFormData({...formData, subject: e.target.value})}
|
onChange={(e) => setFormData({...formData, subject: e.target.value})}
|
||||||
className="w-full pl-11 pr-10 py-3.5 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 appearance-none cursor-pointer"
|
className="w-full pl-11 pr-10 py-3.5 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 appearance-none cursor-pointer"
|
||||||
>
|
>
|
||||||
<option value="">{t('contact.form.subject.select')}</option>
|
<option value="">Selecione um assunto</option>
|
||||||
<option value="orcamento">{t('contact.form.subject.quote')}</option>
|
<option value="orcamento">Solicitar Orçamento</option>
|
||||||
<option value="duvida">{t('contact.form.subject.doubt')}</option>
|
<option value="duvida">Dúvida Técnica</option>
|
||||||
<option value="parceria">{t('contact.form.subject.partnership')}</option>
|
<option value="parceria">Proposta de Parceria</option>
|
||||||
<option value="trabalhe">{t('contact.form.subject.other')}</option>
|
<option value="trabalhe">Outro Assunto</option>
|
||||||
</select>
|
</select>
|
||||||
<i className="ri-arrow-down-s-line absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"></i>
|
<i className="ri-arrow-down-s-line absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="group">
|
<div className="group">
|
||||||
<label htmlFor="mensagem" className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 group-focus-within:text-primary transition-colors">{t('contact.form.message')}</label>
|
<label htmlFor="mensagem" className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 group-focus-within:text-primary transition-colors"><T>Mensagem</T></label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<i className="ri-message-2-line absolute left-4 top-6 text-gray-400 group-focus-within:text-primary transition-colors"></i>
|
<i className="ri-message-2-line absolute left-4 top-6 text-gray-400 group-focus-within:text-primary transition-colors"></i>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -268,7 +270,7 @@ export default function ContatoPage() {
|
|||||||
value={formData.message}
|
value={formData.message}
|
||||||
onChange={(e) => setFormData({...formData, message: e.target.value})}
|
onChange={(e) => setFormData({...formData, message: e.target.value})}
|
||||||
className="w-full pl-11 pr-4 py-3.5 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl h-40 text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all resize-none"
|
className="w-full pl-11 pr-4 py-3.5 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl h-40 text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all resize-none"
|
||||||
placeholder={t('contact.form.message.placeholder')}
|
placeholder="Descreva como podemos ajudá-lo..."
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,11 +283,11 @@ export default function ContatoPage() {
|
|||||||
{submitting ? (
|
{submitting ? (
|
||||||
<>
|
<>
|
||||||
<i className="ri-loader-4-line animate-spin"></i>
|
<i className="ri-loader-4-line animate-spin"></i>
|
||||||
<span>Enviando...</span>
|
<span><T>Enviando...</T></span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span>{t('contact.form.submit')}</span>
|
<span><T>Enviar Mensagem</T></span>
|
||||||
<i className="ri-send-plane-fill group-hover:translate-x-1 transition-transform"></i>
|
<i className="ri-send-plane-fill group-hover:translate-x-1 transition-transform"></i>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Header from "@/components/Header";
|
|||||||
import Footer from "@/components/Footer";
|
import Footer from "@/components/Footer";
|
||||||
import CookieConsent from "@/components/CookieConsent";
|
import CookieConsent from "@/components/CookieConsent";
|
||||||
import WhatsAppButton from "@/components/WhatsAppButton";
|
import WhatsAppButton from "@/components/WhatsAppButton";
|
||||||
|
import { LocaleProvider } from "@/contexts/LocaleContext";
|
||||||
|
|
||||||
export default function PublicLayout({
|
export default function PublicLayout({
|
||||||
children,
|
children,
|
||||||
@@ -9,7 +10,7 @@ export default function PublicLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<LocaleProvider locale="pt">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="grow">
|
<div className="grow">
|
||||||
{children}
|
{children}
|
||||||
@@ -17,6 +18,6 @@ export default function PublicLayout({
|
|||||||
<Footer />
|
<Footer />
|
||||||
<CookieConsent />
|
<CookieConsent />
|
||||||
<WhatsAppButton />
|
<WhatsAppButton />
|
||||||
</>
|
</LocaleProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,98 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useLanguage } from "@/contexts/LanguageContext";
|
|
||||||
import { usePageContent } from "@/hooks/usePageContent";
|
import { usePageContent } from "@/hooks/usePageContent";
|
||||||
|
|
||||||
export default function Home() {
|
type PortfolioProject = {
|
||||||
const { t } = useLanguage();
|
id: string;
|
||||||
const { content, loading } = usePageContent('home');
|
title: string;
|
||||||
|
category: string;
|
||||||
|
coverImage: string | null;
|
||||||
|
galleryImages: string[];
|
||||||
|
status: string;
|
||||||
|
createdAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
// Usar conteúdo personalizado do banco ou fallback para traduções
|
type FallbackProject = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
image: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_PROJECTS: FallbackProject[] = [
|
||||||
|
{
|
||||||
|
id: "fallback-1",
|
||||||
|
title: "Projeto de Adequação - Coca-Cola",
|
||||||
|
category: "Engenharia Veicular",
|
||||||
|
image: "https://images.unsplash.com/photo-1616401784845-180882ba9ba8?q=80&w=2070&auto=format&fit=crop",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fallback-2",
|
||||||
|
title: "Laudo de Guindaste Articulado",
|
||||||
|
category: "Inspeção Técnica",
|
||||||
|
image: "https://images.unsplash.com/photo-1581092335397-9583eb92d232?q=80&w=2070&auto=format&fit=crop",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fallback-3",
|
||||||
|
title: "Dispositivo de Içamento Especial",
|
||||||
|
category: "Projeto Mecânico",
|
||||||
|
image: "https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?q=80&w=2070&auto=format&fit=crop",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
// Português é o idioma padrão - busca diretamente sem tradução
|
||||||
|
const { content, loading } = usePageContent('home', 'pt');
|
||||||
|
const [latestProjects, setLatestProjects] = useState<PortfolioProject[]>([]);
|
||||||
|
|
||||||
|
// Usar conteúdo do banco ou fallback
|
||||||
const hero = content?.hero || {
|
const hero = content?.hero || {
|
||||||
title: t('home.hero.title'),
|
title: 'Engenharia de Excelência para Seus Projetos',
|
||||||
subtitle: t('home.hero.subtitle'),
|
subtitle: 'Soluções completas em engenharia veicular, mecânica e segurança do trabalho com mais de 15 anos de experiência.',
|
||||||
buttonText: t('home.hero.cta_primary')
|
buttonText: 'Conheça Nossos Serviços'
|
||||||
};
|
};
|
||||||
|
|
||||||
const features = content?.features || {
|
const features = content?.features || {
|
||||||
pretitle: t('home.features.pretitle'),
|
pretitle: 'Por que nos escolher',
|
||||||
title: t('home.features.title'),
|
title: 'Nossos Diferenciais',
|
||||||
items: [
|
items: [
|
||||||
{ icon: 'ri-shield-star-line', title: t('home.features.1.title'), description: t('home.features.1.desc') },
|
{ icon: 'ri-shield-star-line', title: 'Qualidade Garantida', description: 'Processos certificados e equipe altamente qualificada.' },
|
||||||
{ icon: 'ri-settings-4-line', title: t('home.features.2.title'), description: t('home.features.2.desc') },
|
{ icon: 'ri-settings-4-line', title: 'Soluções Personalizadas', description: 'Atendimento sob medida para suas necessidades.' },
|
||||||
{ icon: 'ri-truck-line', title: t('home.features.3.title'), description: t('home.features.3.desc') }
|
{ icon: 'ri-truck-line', title: 'Especialização Veicular', description: 'Expertise em engenharia automotiva.' }
|
||||||
] as Array<{ icon: string; title: string; description: string }>
|
] as Array<{ icon: string; title: string; description: string }>
|
||||||
};
|
};
|
||||||
|
|
||||||
const services = content?.services || {
|
const services = content?.services || {
|
||||||
pretitle: t('home.services.pretitle'),
|
pretitle: 'Nossos Serviços',
|
||||||
title: t('home.services.title'),
|
title: 'O Que Fazemos',
|
||||||
items: [
|
items: [
|
||||||
{ icon: 'ri-draft-line', title: t('home.services.1.title'), description: t('home.services.1.desc') },
|
{ icon: 'ri-draft-line', title: 'Projetos Técnicos', description: 'Desenvolvimento de projetos de engenharia.' },
|
||||||
{ icon: 'ri-file-paper-2-line', title: t('home.services.2.title'), description: t('home.services.2.desc') },
|
{ icon: 'ri-file-paper-2-line', title: 'Laudos e Perícias', description: 'Emissão de laudos técnicos.' },
|
||||||
{ icon: 'ri-alert-line', title: t('home.services.3.title'), description: t('home.services.3.desc') },
|
{ icon: 'ri-alert-line', title: 'Segurança do Trabalho', description: 'Implementação de normas de segurança.' },
|
||||||
{ icon: 'ri-truck-fill', title: t('home.services.4.title'), description: t('home.services.4.desc') }
|
{ icon: 'ri-truck-fill', title: 'Engenharia Veicular', description: 'Modificações e adaptações de veículos.' }
|
||||||
] as Array<{ icon: string; title: string; description: string }>
|
] as Array<{ icon: string; title: string; description: string }>
|
||||||
};
|
};
|
||||||
|
|
||||||
const about = content?.about || {
|
const about = content?.about || {
|
||||||
pretitle: t('home.about.pretitle'),
|
pretitle: 'Conheça a OCCTO',
|
||||||
title: t('home.about.title'),
|
title: 'Sobre Nós',
|
||||||
description: t('home.about.desc'),
|
description: 'Com mais de 15 anos de experiência, a OCCTO Engenharia se consolidou como referência em soluções de engenharia.',
|
||||||
highlights: [
|
highlights: [
|
||||||
t('home.about.list.1'),
|
'Mais de 500 clientes atendidos',
|
||||||
t('home.about.list.2'),
|
'Equipe técnica qualificada',
|
||||||
t('home.about.list.3')
|
'Parceiro oficial de grandes empresas'
|
||||||
] as string[]
|
] as string[]
|
||||||
};
|
};
|
||||||
|
|
||||||
const testimonials = content?.testimonials || {
|
const testimonials = content?.testimonials || {
|
||||||
pretitle: t('home.testimonials.pretitle'),
|
pretitle: 'Depoimentos',
|
||||||
title: t('home.testimonials.title'),
|
title: 'O Que Dizem Nossos Clientes',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Ricardo Mendes', role: t('home.testimonials.1.role'), text: t('home.testimonials.1.text') },
|
{ name: 'Ricardo Mendes', role: 'Gerente de Frota', text: 'Excelente trabalho!' },
|
||||||
{ name: 'Fernanda Costa', role: t('home.testimonials.2.role'), text: t('home.testimonials.2.text') },
|
{ name: 'Fernanda Costa', role: 'Diretora de Operações', text: 'Parceria de confiança.' },
|
||||||
{ name: 'Paulo Oliveira', role: t('home.testimonials.3.role'), text: t('home.testimonials.3.text') }
|
{ name: 'Paulo Oliveira', role: 'Engenheiro Chefe', text: 'Conhecimento técnico incomparável.' }
|
||||||
] as Array<{ name: string; role: string; text: string }>
|
] as Array<{ name: string; role: string; text: string }>
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,11 +103,57 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cta = content?.cta || {
|
const cta = content?.cta || {
|
||||||
title: t('home.cta.title'),
|
title: 'Pronto para tirar seu projeto do papel?',
|
||||||
text: t('home.cta.desc'),
|
text: 'Entre em contato com nossa equipe de especialistas.',
|
||||||
button: t('home.cta.button')
|
button: 'Fale Conosco'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const fetchProjects = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/projects', {
|
||||||
|
method: 'GET',
|
||||||
|
cache: 'no-store',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Falha ao buscar projetos recentes (status ${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (isMounted && Array.isArray(data)) {
|
||||||
|
const published = data
|
||||||
|
.filter((project: PortfolioProject) => project.status !== 'Rascunho')
|
||||||
|
.sort((a, b) => {
|
||||||
|
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||||
|
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||||
|
return dateB - dateA;
|
||||||
|
})
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
setLatestProjects(published);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name !== 'AbortError') {
|
||||||
|
console.error('Erro ao buscar projetos recentes:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchProjects();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
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 */}
|
||||||
@@ -81,7 +166,7 @@ export default function Home() {
|
|||||||
<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="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">
|
||||||
<i className="ri-verified-badge-fill text-primary text-xl"></i>
|
<i className="ri-verified-badge-fill text-primary text-xl"></i>
|
||||||
<span className="text-sm font-bold tracking-wider uppercase text-white">{t('home.hero.badge')} <span className="text-primary">Coca-Cola</span></span>
|
<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">
|
||||||
@@ -95,7 +180,7 @@ export default function Home() {
|
|||||||
{hero.buttonText}
|
{hero.buttonText}
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/projetos" className="px-8 py-4 border-2 border-white text-white rounded-lg font-bold hover:bg-white hover:text-secondary transition-colors text-center">
|
<Link href="/projetos" className="px-8 py-4 border-2 border-white text-white rounded-lg font-bold hover:bg-white hover:text-secondary transition-colors text-center">
|
||||||
{t('home.hero.cta_secondary')}
|
Ver Soluções
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,7 +228,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-center mt-12">
|
<div className="text-center mt-12">
|
||||||
<Link href="/servicos" className="text-primary font-bold hover:text-secondary dark:hover:text-white transition-colors inline-flex items-center gap-2">
|
<Link href="/servicos" className="text-primary font-bold hover:text-secondary dark:hover:text-white transition-colors inline-flex items-center gap-2">
|
||||||
{t('home.services.link')} <i className="ri-arrow-right-line"></i>
|
Ver todos os serviços <i className="ri-arrow-right-line"></i>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -173,7 +258,7 @@ export default function Home() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<Link href="/sobre" className="text-primary font-bold hover:text-white transition-colors flex items-center gap-2">
|
<Link href="/sobre" className="text-primary font-bold hover:text-white transition-colors flex items-center gap-2">
|
||||||
{t('home.about.link')} <i className="ri-arrow-right-line"></i>
|
Conheça nossa expertise <i className="ri-arrow-right-line"></i>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,33 +269,37 @@ export default function Home() {
|
|||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-12 gap-4">
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-12 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-primary font-bold tracking-wider uppercase mb-2">{t('home.projects.pretitle')}</h2>
|
<h2 className="text-primary font-bold tracking-wider uppercase mb-2">Portfólio</h2>
|
||||||
<h3 className="text-4xl font-bold font-headline text-secondary dark:text-white">{t('home.projects.title')}</h3>
|
<h3 className="text-4xl font-bold font-headline text-secondary dark:text-white">Projetos Recentes</h3>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/projetos" className="px-6 py-3 border border-secondary dark:border-white text-secondary dark:text-white rounded-lg font-bold hover:bg-secondary hover:text-white dark:hover:bg-white dark:hover:text-secondary transition-colors">
|
<Link href="/projetos" className="px-6 py-3 border border-secondary dark:border-white text-secondary dark:text-white rounded-lg font-bold hover:bg-secondary hover:text-white dark:hover:bg-white dark:hover:text-secondary transition-colors">
|
||||||
{t('home.projects.link')}
|
Ver todos os projetos
|
||||||
</Link>
|
</Link>
|
||||||
</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">
|
||||||
{[
|
{(latestProjects.length > 0
|
||||||
{ img: "https://images.unsplash.com/photo-1616401784845-180882ba9ba8?q=80&w=2070&auto=format&fit=crop", title: t('home.projects.1.title'), cat: t('home.projects.1.cat') },
|
? latestProjects.map((project) => ({
|
||||||
{ img: "https://images.unsplash.com/photo-1581092335397-9583eb92d232?q=80&w=2070&auto=format&fit=crop", title: t('home.projects.2.title'), cat: t('home.projects.2.cat') },
|
id: project.id,
|
||||||
{ img: "https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?q=80&w=2070&auto=format&fit=crop", title: t('home.projects.3.title'), cat: t('home.projects.3.cat') }
|
title: project.title,
|
||||||
].map((project, index) => (
|
category: project.category,
|
||||||
<div key={index} className="group relative overflow-hidden rounded-xl h-[400px] cursor-pointer">
|
image: project.coverImage || project.galleryImages?.[0] || FALLBACK_PROJECTS[0].image,
|
||||||
<div className="absolute inset-0 bg-cover bg-center transition-transform duration-500 group-hover:scale-110" style={{ backgroundImage: `url('${project.img}')` }}></div>
|
}))
|
||||||
|
: FALLBACK_PROJECTS
|
||||||
|
).map((project) => (
|
||||||
|
<Link key={project.id} href={`/projetos/${project.id}`} className="group relative overflow-hidden rounded-xl h-[400px] cursor-pointer block">
|
||||||
|
<div className="absolute inset-0 bg-cover bg-center transition-transform duration-500 group-hover:scale-110" style={{ backgroundImage: `url('${project.image}')` }}></div>
|
||||||
<div className="absolute inset-0 bg-linear-to-t from-black/90 via-black/20 to-transparent opacity-80 group-hover:opacity-90 transition-opacity"></div>
|
<div className="absolute inset-0 bg-linear-to-t from-black/90 via-black/20 to-transparent opacity-80 group-hover:opacity-90 transition-opacity"></div>
|
||||||
<div className="absolute bottom-0 left-0 p-8 w-full transform translate-y-4 group-hover:translate-y-0 transition-transform">
|
<div className="absolute bottom-0 left-0 p-8 w-full transform translate-y-4 group-hover:translate-y-0 transition-transform">
|
||||||
<span className="text-primary font-bold text-sm uppercase tracking-wider mb-2 block">{project.cat}</span>
|
<span className="text-primary font-bold text-sm uppercase tracking-wider mb-2 block">{project.category}</span>
|
||||||
<h3 className="text-2xl font-bold font-headline text-white mb-2">{project.title}</h3>
|
<h3 className="text-2xl font-bold font-headline text-white mb-2">{project.title}</h3>
|
||||||
<div className="h-0 group-hover:h-auto overflow-hidden transition-all">
|
<div className="h-0 group-hover:h-auto overflow-hidden transition-all">
|
||||||
<span className="text-white/80 text-sm flex items-center gap-2 mt-4">
|
<span className="text-white/80 text-sm flex items-center gap-2 mt-4">
|
||||||
{t('home.projects.view_details')} <i className="ri-arrow-right-line"></i>
|
Ver detalhes <i className="ri-arrow-right-line"></i>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,157 +1,295 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { T } from "@/components/TranslatedText";
|
||||||
|
|
||||||
// Mock data - same as in the main projects page
|
interface Project {
|
||||||
// In a real app, this would come from a database or API
|
id: string;
|
||||||
const projects = [
|
title: string;
|
||||||
{
|
category: string;
|
||||||
id: 1,
|
client: string | null;
|
||||||
title: "Engenharia de Adequação - Frota Coca-Cola",
|
status: string;
|
||||||
category: "Engenharia Veicular",
|
completionDate: string | null;
|
||||||
location: "Vitória, ES",
|
description: string | null;
|
||||||
image: "https://images.unsplash.com/photo-1616401784845-180882ba9ba8?q=80&w=2070&auto=format&fit=crop",
|
coverImage: string | null;
|
||||||
description: "Projeto de adequação técnica de 50 caminhões para instalação de carrocerias especiais e sistemas de segurança.",
|
galleryImages: string[];
|
||||||
details: "Desenvolvimento completo do projeto de engenharia para adequação de frota de distribuição de bebidas. O escopo incluiu o cálculo estrutural para rebaixamento de carrocerias, instalação de sistemas de proteção lateral e traseira conforme resoluções do CONTRAN, e homologação junto aos órgãos competentes. O projeto resultou em aumento de 15% na capacidade de carga e total conformidade normativa.",
|
featured: boolean;
|
||||||
features: ["Cálculo Estrutural", "Homologação DENATRAN", "Segurança Operacional", "Adequação de Carroceria"]
|
createdAt: string;
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: "Laudo de Guindaste Articulado",
|
|
||||||
category: "Inspeção Técnica",
|
|
||||||
location: "Serra, ES",
|
|
||||||
image: "https://images.unsplash.com/photo-1535082623926-b3a33d531740?q=80&w=2052&auto=format&fit=crop",
|
|
||||||
description: "Inspeção completa e emissão de laudo técnico para guindaste de 45 toneladas, com testes de carga e verificação estrutural.",
|
|
||||||
details: "Realização de inspeção detalhada em guindaste articulado (Munck) com capacidade de 45 toneladas. Foram realizados ensaios não destrutivos (líquido penetrante) em pontos críticos de solda, verificação do sistema hidráulico, testes de carga estática e dinâmica conforme NR-11. O laudo técnico atestou a integridade do equipamento para operação segura.",
|
|
||||||
features: ["Ensaio Não Destrutivo", "Teste de Carga", "Verificação Hidráulica", "ART de Inspeção"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: "Projeto de Dispositivo de Içamento",
|
|
||||||
category: "Projeto Mecânico",
|
|
||||||
location: "Aracruz, ES",
|
|
||||||
image: "https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?q=80&w=2070&auto=format&fit=crop",
|
|
||||||
description: "Desenvolvimento e cálculo estrutural de Spreader para movimentação de contêineres em área portuária.",
|
|
||||||
details: "Projeto mecânico de um Spreader (balancim) automático para içamento de contêineres de 20 e 40 pés. O dispositivo foi projetado para suportar cargas de até 30 toneladas, com sistema de travamento twist-lock automático. Entregamos o projeto completo em 3D, desenhos de fabricação, memorial de cálculo e manual de operação.",
|
|
||||||
features: ["Modelagem 3D", "Cálculo de Elementos Finitos", "Detalhamento de Fabricação", "Manual de Operação"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: "Certificação NR-12 - Parque Industrial",
|
|
||||||
category: "Laudos",
|
|
||||||
location: "Linhares, ES",
|
|
||||||
image: "https://images.unsplash.com/photo-1581092921461-eab62e97a782?q=80&w=2070&auto=format&fit=crop",
|
|
||||||
description: "Inventário e adequação de segurança de 120 máquinas operatrizes conforme norma regulamentadora NR-12.",
|
|
||||||
details: "Consultoria completa para adequação à NR-12 em parque fabril. Realizamos o inventário de 120 máquinas, análise de risco (HRN), projeto de proteções mecânicas e sistemas de segurança eletrônica. Acompanhamos a implementação e emitimos os laudos de validação final, garantindo a segurança dos operadores.",
|
|
||||||
features: ["Análise de Risco", "Projeto de Proteções", "Sistemas de Segurança", "Laudo de Validação"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
title: "Homologação de Plataforma Elevatória",
|
|
||||||
category: "Engenharia Veicular",
|
|
||||||
location: "Viana, ES",
|
|
||||||
image: "https://images.unsplash.com/photo-1591768793355-74d04bb6608f?q=80&w=2070&auto=format&fit=crop",
|
|
||||||
description: "Processo completo de homologação e certificação de plataformas elevatórias para distribuição urbana.",
|
|
||||||
details: "Assessoria técnica para fabricante de plataformas elevatórias veiculares. Realizamos os cálculos de estabilidade, testes de tombamento e resistência estrutural necessários para a obtenção do CAT (Certificado de Adequação à Legislação de Trânsito). O equipamento foi homologado com sucesso para uso em veículos urbanos de carga.",
|
|
||||||
features: ["Cálculo de Estabilidade", "Teste de Tombamento", "Dossiê Técnico", "Homologação INMETRO/DENATRAN"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
title: "Projeto de Linha de Vida para Caminhões",
|
|
||||||
category: "Segurança do Trabalho",
|
|
||||||
location: "Cariacica, ES",
|
|
||||||
image: "https://images.unsplash.com/photo-1504328345606-18bbc8c9d7d1?q=80&w=2070&auto=format&fit=crop",
|
|
||||||
description: "Projeto e instalação de sistema de linha de vida para proteção contra quedas em operações de carga e descarga.",
|
|
||||||
details: "Projeto e instalação de sistema de linha de vida rígida sobre estrutura metálica para proteção de quedas durante o enlonamento de caminhões. O sistema permite que o operador trabalhe com segurança em toda a extensão da carroceria. Fornecimento de projeto, ART e treinamento de uso para a equipe.",
|
|
||||||
features: ["Projeto Estrutural", "Sistema de Ancoragem", "Treinamento NR-35", "ART de Instalação"]
|
|
||||||
}
|
}
|
||||||
];
|
|
||||||
|
|
||||||
export default function ProjectDetails({ params }: { params: { id: string } }) {
|
export default function ProjectDetails({ params }: { params: { id: string } }) {
|
||||||
const project = projects.find((p) => p.id === parseInt(params.id));
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||||
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
|
|
||||||
if (!project) {
|
useEffect(() => {
|
||||||
|
async function fetchProject() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/projects/${params.id}`, { cache: "no-store" });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setProject(data);
|
||||||
|
setSelectedImage(data.coverImage || data.galleryImages?.[0] || null);
|
||||||
|
} else if (res.status === 404) {
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Erro ao carregar projeto:", err);
|
||||||
|
setError(true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchProject();
|
||||||
|
}, [params.id]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<main className="bg-white dark:bg-secondary min-h-screen">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-[500px] bg-gray-300 dark:bg-gray-700"></div>
|
||||||
|
<div className="container mx-auto px-4 py-20">
|
||||||
|
<div className="flex flex-col lg:flex-row gap-16">
|
||||||
|
<div className="lg:w-2/3 space-y-4">
|
||||||
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-1/3"></div>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-2/3"></div>
|
||||||
|
</div>
|
||||||
|
<div className="lg:w-1/3">
|
||||||
|
<div className="h-64 bg-gray-200 dark:bg-gray-700 rounded-xl"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !project) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultImage = "https://images.unsplash.com/photo-1504307651254-35680f356dfd?q=80&w=2070&auto=format&fit=crop";
|
||||||
|
const heroImage = project.coverImage || defaultImage;
|
||||||
|
const allImages = [
|
||||||
|
...(project.coverImage ? [project.coverImage] : []),
|
||||||
|
...project.galleryImages,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const completionYear = project.completionDate
|
||||||
|
? new Date(project.completionDate).getFullYear()
|
||||||
|
: new Date(project.createdAt).getFullYear();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main className="bg-white dark:bg-secondary transition-colors duration-300">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative h-[500px] flex items-center bg-secondary text-white overflow-hidden">
|
<section className="relative h-[500px] flex items-center bg-secondary text-white overflow-hidden">
|
||||||
<div className="absolute inset-0 bg-black/60 z-10"></div>
|
<div className="absolute inset-0 bg-black/60 z-10"></div>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-cover bg-center"
|
className="absolute inset-0 bg-cover bg-center"
|
||||||
style={{ backgroundImage: `url('${project.image}')` }}
|
style={{ backgroundImage: `url('${heroImage}')` }}
|
||||||
></div>
|
></div>
|
||||||
<div className="container mx-auto px-4 relative z-20">
|
<div className="container mx-auto px-4 relative z-20">
|
||||||
|
{project.category && (
|
||||||
<span className="inline-block px-3 py-1 bg-primary text-white text-sm font-bold rounded-md mb-4 uppercase tracking-wider">
|
<span className="inline-block px-3 py-1 bg-primary text-white text-sm font-bold rounded-md mb-4 uppercase tracking-wider">
|
||||||
{project.category}
|
{project.category}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
<h1 className="text-4xl md:text-6xl font-bold font-headline mb-4 leading-tight max-w-4xl">
|
<h1 className="text-4xl md:text-6xl font-bold font-headline mb-4 leading-tight max-w-4xl">
|
||||||
{project.title}
|
{project.title}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-2 text-gray-300 text-lg">
|
<div className="flex items-center gap-4 text-gray-300 text-lg flex-wrap">
|
||||||
<i className="ri-map-pin-line text-primary"></i>
|
{project.client && (
|
||||||
<span>{project.location}</span>
|
<div className="flex items-center gap-2">
|
||||||
|
<i className="ri-building-line text-primary"></i>
|
||||||
|
<span>{project.client}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<i className="ri-calendar-line text-primary"></i>
|
||||||
|
<span>{completionYear}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`px-2 py-1 text-xs rounded ${project.status === 'Concluído' ? 'bg-green-500/20 text-green-300' : 'bg-yellow-500/20 text-yellow-300'}`}>
|
||||||
|
{project.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Content Section */}
|
{/* Content Section */}
|
||||||
<section className="py-20 bg-white">
|
<section className="py-20 bg-white dark:bg-secondary">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="flex flex-col lg:flex-row gap-16">
|
<div className="flex flex-col lg:flex-row gap-16">
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="lg:w-2/3">
|
<div className="lg:w-2/3">
|
||||||
<h2 className="text-3xl font-bold font-headline text-secondary mb-6">Sobre o Projeto</h2>
|
{/* Description */}
|
||||||
<p className="text-gray-600 text-lg leading-relaxed mb-8">
|
{project.description && (
|
||||||
{project.details}
|
<>
|
||||||
|
<h2 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-6">
|
||||||
|
<T>Sobre o Projeto</T>
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 text-lg leading-relaxed mb-12 whitespace-pre-line">
|
||||||
|
{project.description}
|
||||||
</p>
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<h3 className="text-2xl font-bold font-headline text-secondary mb-6">Escopo Técnico</h3>
|
{/* Image Gallery */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
{allImages.length > 0 && (
|
||||||
{project.features.map((feature, index) => (
|
<>
|
||||||
<div key={index} className="flex items-center gap-3 p-4 bg-gray-50 rounded-lg border border-gray-100">
|
<h3 className="text-2xl font-bold font-headline text-secondary dark:text-white mb-6">
|
||||||
<i className="ri-checkbox-circle-line text-primary text-xl"></i>
|
<T>Galeria de Imagens</T>
|
||||||
<span className="font-medium text-gray-700">{feature}</span>
|
</h3>
|
||||||
|
|
||||||
|
{/* Main Image */}
|
||||||
|
<div
|
||||||
|
className="relative aspect-video rounded-xl overflow-hidden mb-4 cursor-pointer group"
|
||||||
|
onClick={() => {
|
||||||
|
setLightboxOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={selectedImage || heroImage}
|
||||||
|
alt={project.title}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
|
||||||
|
<i className="ri-zoom-in-line text-4xl text-white opacity-0 group-hover:opacity-100 transition-opacity"></i>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnail Grid */}
|
||||||
|
{allImages.length > 1 && (
|
||||||
|
<div className="grid grid-cols-4 md:grid-cols-6 gap-2">
|
||||||
|
{allImages.map((img, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => setSelectedImage(img)}
|
||||||
|
className={`aspect-square rounded-lg overflow-hidden border-2 transition-all ${
|
||||||
|
selectedImage === img
|
||||||
|
? 'border-primary ring-2 ring-primary/30'
|
||||||
|
: 'border-transparent hover:border-gray-300 dark:hover:border-white/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={img}
|
||||||
|
alt={`${project.title} - Imagem ${index + 1}`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="lg:w-1/3">
|
<div className="lg:w-1/3">
|
||||||
<div className="bg-gray-50 p-8 rounded-xl border border-gray-100 sticky top-24">
|
<div className="bg-gray-50 dark:bg-white/5 p-8 rounded-xl border border-gray-100 dark:border-white/10 sticky top-24">
|
||||||
<h3 className="text-xl font-bold font-headline text-secondary mb-6">Ficha Técnica</h3>
|
<h3 className="text-xl font-bold font-headline text-secondary dark:text-white mb-6">
|
||||||
|
<T>Ficha Técnica</T>
|
||||||
|
</h3>
|
||||||
<ul className="space-y-4 mb-8">
|
<ul className="space-y-4 mb-8">
|
||||||
<li className="flex justify-between border-b border-gray-200 pb-3">
|
{project.client && (
|
||||||
<span className="text-gray-500">Cliente</span>
|
<li className="flex justify-between border-b border-gray-200 dark:border-white/10 pb-3">
|
||||||
<span className="font-medium text-secondary">Confidencial</span>
|
<span className="text-gray-500 dark:text-gray-400"><T>Cliente</T></span>
|
||||||
|
<span className="font-medium text-secondary dark:text-white">{project.client}</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex justify-between border-b border-gray-200 pb-3">
|
)}
|
||||||
<span className="text-gray-500">Categoria</span>
|
{project.category && (
|
||||||
<span className="font-medium text-secondary">{project.category}</span>
|
<li className="flex justify-between border-b border-gray-200 dark:border-white/10 pb-3">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400"><T>Categoria</T></span>
|
||||||
|
<span className="font-medium text-secondary dark:text-white">{project.category}</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex justify-between border-b border-gray-200 pb-3">
|
)}
|
||||||
<span className="text-gray-500">Local</span>
|
<li className="flex justify-between border-b border-gray-200 dark:border-white/10 pb-3">
|
||||||
<span className="font-medium text-secondary">{project.location}</span>
|
<span className="text-gray-500 dark:text-gray-400"><T>Status</T></span>
|
||||||
|
<span className={`font-medium ${project.status === 'Concluído' ? 'text-green-600' : 'text-yellow-600'}`}>
|
||||||
|
{project.status}
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex justify-between border-b border-gray-200 pb-3">
|
<li className="flex justify-between border-b border-gray-200 dark:border-white/10 pb-3">
|
||||||
<span className="text-gray-500">Ano</span>
|
<span className="text-gray-500 dark:text-gray-400"><T>Ano</T></span>
|
||||||
<span className="font-medium text-secondary">2024</span>
|
<span className="font-medium text-secondary dark:text-white">{completionYear}</span>
|
||||||
</li>
|
</li>
|
||||||
|
{project.featured && (
|
||||||
|
<li className="flex justify-between border-b border-gray-200 dark:border-white/10 pb-3">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400"><T>Destaque</T></span>
|
||||||
|
<span className="font-medium text-primary">
|
||||||
|
<i className="ri-star-fill"></i> <T>Sim</T>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<Link href="/contato" className="block w-full py-4 bg-primary text-white text-center rounded-lg font-bold hover-primary transition-colors">
|
<Link href="/contato" className="block w-full py-4 bg-primary text-white text-center rounded-lg font-bold hover:bg-primary/90 transition-colors">
|
||||||
Solicitar Orçamento Similar
|
<T>Solicitar Orçamento Similar</T>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/projetos" className="block w-full py-4 mt-4 border border-gray-300 text-gray-600 text-center rounded-lg font-bold hover:bg-gray-100 transition-colors">
|
<Link href="/projetos" className="block w-full py-4 mt-4 border border-gray-300 dark:border-white/20 text-gray-600 dark:text-gray-300 text-center rounded-lg font-bold hover:bg-gray-100 dark:hover:bg-white/10 transition-colors">
|
||||||
Voltar para Projetos
|
<T>Voltar para Projetos</T>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Lightbox */}
|
||||||
|
{lightboxOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4"
|
||||||
|
onClick={() => setLightboxOpen(false)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="absolute top-4 right-4 text-white text-4xl hover:text-primary transition-colors"
|
||||||
|
onClick={() => setLightboxOpen(false)}
|
||||||
|
>
|
||||||
|
<i className="ri-close-line"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{allImages.length > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="absolute left-4 top-1/2 -translate-y-1/2 text-white text-4xl hover:text-primary transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const currentIndex = allImages.indexOf(selectedImage || '');
|
||||||
|
const prevIndex = currentIndex <= 0 ? allImages.length - 1 : currentIndex - 1;
|
||||||
|
setSelectedImage(allImages[prevIndex]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="ri-arrow-left-s-line"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 text-white text-4xl hover:text-primary transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const currentIndex = allImages.indexOf(selectedImage || '');
|
||||||
|
const nextIndex = currentIndex >= allImages.length - 1 ? 0 : currentIndex + 1;
|
||||||
|
setSelectedImage(allImages[nextIndex]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="ri-arrow-right-s-line"></i>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={selectedImage || heroImage}
|
||||||
|
alt={project.title}
|
||||||
|
className="max-w-full max-h-[90vh] object-contain"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +1,67 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useLanguage } from "@/contexts/LanguageContext";
|
import { T } from "@/components/TranslatedText";
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
location: string | null;
|
||||||
|
description: string | null;
|
||||||
|
coverImage: string | null;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProjetosPage() {
|
export default function ProjetosPage() {
|
||||||
const { t } = useLanguage();
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
|
|
||||||
// Placeholder data - will be replaced by database content
|
useEffect(() => {
|
||||||
const projects = [
|
async function fetchProjects() {
|
||||||
{
|
try {
|
||||||
id: 1,
|
const res = await fetch("/api/projects", { cache: "no-store" });
|
||||||
title: t('home.projects.1.title'),
|
if (res.ok) {
|
||||||
category: t('home.projects.1.cat'),
|
const data = await res.json();
|
||||||
location: "Vitória, ES",
|
// Mostrar todos os projetos (status pode ser "Em andamento" ou "Concluído")
|
||||||
image: "https://images.unsplash.com/photo-1616401784845-180882ba9ba8?q=80&w=2070&auto=format&fit=crop",
|
setProjects(data);
|
||||||
description: "Projeto de adequação técnica de 50 caminhões para instalação de carrocerias especiais e sistemas de segurança."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: t('home.projects.2.title'),
|
|
||||||
category: t('home.projects.2.cat'),
|
|
||||||
location: "Serra, ES",
|
|
||||||
image: "https://images.unsplash.com/photo-1535082623926-b3a33d531740?q=80&w=2052&auto=format&fit=crop",
|
|
||||||
description: "Inspeção completa e emissão de laudo técnico para guindaste de 45 toneladas, com testes de carga e verificação estrutural."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: t('home.projects.3.title'),
|
|
||||||
category: t('home.projects.3.cat'),
|
|
||||||
location: "Aracruz, ES",
|
|
||||||
image: "https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?q=80&w=2070&auto=format&fit=crop",
|
|
||||||
description: "Desenvolvimento e cálculo estrutural de Spreader para movimentação de contêineres em área portuária."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: t('home.projects.4.title'),
|
|
||||||
category: t('home.projects.4.cat'),
|
|
||||||
location: "Linhares, ES",
|
|
||||||
image: "https://images.unsplash.com/photo-1581092921461-eab62e97a782?q=80&w=2070&auto=format&fit=crop",
|
|
||||||
description: "Inventário e adequação de segurança de 120 máquinas operatrizes conforme norma regulamentadora NR-12."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
title: t('home.projects.5.title'),
|
|
||||||
category: t('home.projects.5.cat'),
|
|
||||||
location: "Viana, ES",
|
|
||||||
image: "https://images.unsplash.com/photo-1591768793355-74d04bb6608f?q=80&w=2070&auto=format&fit=crop",
|
|
||||||
description: "Processo completo de homologação e certificação de plataformas elevatórias para distribuição urbana."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
title: t('home.projects.6.title'),
|
|
||||||
category: t('home.projects.6.cat'),
|
|
||||||
location: "Cariacica, ES",
|
|
||||||
image: "https://images.unsplash.com/photo-1504328345606-18bbc8c9d7d1?q=80&w=2070&auto=format&fit=crop",
|
|
||||||
description: "Projeto e instalação de sistema de linha de vida para proteção contra quedas em operações de carga e descarga."
|
|
||||||
}
|
}
|
||||||
];
|
} catch (error) {
|
||||||
|
console.error("Erro ao carregar projetos:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchProjects();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Extrair categorias únicas dos projetos
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const cats = new Set<string>();
|
||||||
|
projects.forEach((p) => {
|
||||||
|
if (p.category) cats.add(p.category);
|
||||||
|
});
|
||||||
|
return Array.from(cats);
|
||||||
|
}, [projects]);
|
||||||
|
|
||||||
|
// Filtrar projetos por categoria e pesquisa
|
||||||
|
const filteredProjects = useMemo(() => {
|
||||||
|
return projects.filter((project) => {
|
||||||
|
const matchesCategory = !selectedCategory || project.category === selectedCategory;
|
||||||
|
const matchesSearch =
|
||||||
|
!searchTerm ||
|
||||||
|
project.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
project.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
project.category?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
project.location?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
return matchesCategory && matchesSearch;
|
||||||
|
});
|
||||||
|
}, [projects, selectedCategory, searchTerm]);
|
||||||
|
|
||||||
|
const defaultImage = "https://images.unsplash.com/photo-1504307651254-35680f356dfd?q=80&w=2070&auto=format&fit=crop";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="bg-white dark:bg-secondary transition-colors duration-300">
|
<main className="bg-white dark:bg-secondary transition-colors duration-300">
|
||||||
@@ -65,9 +70,9 @@ export default function ProjetosPage() {
|
|||||||
<div className="absolute inset-0 bg-black/60 z-10"></div>
|
<div className="absolute inset-0 bg-black/60 z-10"></div>
|
||||||
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1504307651254-35680f356dfd?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center"></div>
|
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1504307651254-35680f356dfd?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center"></div>
|
||||||
<div className="container mx-auto px-4 relative z-20">
|
<div className="container mx-auto px-4 relative z-20">
|
||||||
<h1 className="text-5xl font-bold font-headline mb-4">{t('projects.hero.title')}</h1>
|
<h1 className="text-5xl font-bold font-headline mb-4"><T>Nossos Projetos</T></h1>
|
||||||
<p className="text-xl text-gray-300 max-w-2xl">
|
<p className="text-xl text-gray-300 max-w-2xl">
|
||||||
{t('projects.hero.subtitle')}
|
<T>Conheça alguns dos projetos que já realizamos para nossos clientes</T>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -75,40 +80,137 @@ export default function ProjetosPage() {
|
|||||||
{/* Projects Grid */}
|
{/* Projects Grid */}
|
||||||
<section className="py-20 bg-white dark:bg-secondary">
|
<section className="py-20 bg-white dark:bg-secondary">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
{/* Filters (Placeholder) */}
|
{/* Search Bar */}
|
||||||
<div className="flex flex-wrap gap-4 mb-12 justify-center">
|
<div className="max-w-xl mx-auto mb-8">
|
||||||
<button className="px-6 py-2 bg-primary text-white rounded-full font-bold shadow-md">{t('projects.filter.all')}</button>
|
<div className="relative">
|
||||||
<button className="px-6 py-2 bg-gray-100 dark:bg-white/10 text-gray-600 dark:text-gray-300 rounded-full font-bold hover:bg-gray-200 dark:hover:bg-white/20 transition-colors">{t('projects.filter.implements')}</button>
|
<input
|
||||||
<button className="px-6 py-2 bg-gray-100 dark:bg-white/10 text-gray-600 dark:text-gray-300 rounded-full font-bold hover:bg-gray-200 dark:hover:bg-white/20 transition-colors">{t('projects.filter.mechanical')}</button>
|
type="text"
|
||||||
<button className="px-6 py-2 bg-gray-100 dark:bg-white/10 text-gray-600 dark:text-gray-300 rounded-full font-bold hover:bg-gray-200 dark:hover:bg-white/20 transition-colors">{t('projects.filter.reports')}</button>
|
placeholder="Pesquisar projetos..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full px-5 py-3 pl-12 rounded-full border border-gray-200 dark:border-white/20 bg-white dark:bg-white/5 text-secondary dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
|
||||||
|
/>
|
||||||
|
<i className="ri-search-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 text-xl"></i>
|
||||||
|
{searchTerm && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchTerm("")}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<i className="ri-close-line text-xl"></i>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Category Filters */}
|
||||||
|
<div className="flex flex-wrap gap-3 mb-12 justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedCategory(null)}
|
||||||
|
className={`px-6 py-2 rounded-full font-bold transition-all ${
|
||||||
|
selectedCategory === null
|
||||||
|
? "bg-primary text-white shadow-md"
|
||||||
|
: "bg-gray-100 dark:bg-white/10 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-white/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<T>Todos</T>
|
||||||
|
</button>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category}
|
||||||
|
onClick={() => setSelectedCategory(category)}
|
||||||
|
className={`px-6 py-2 rounded-full font-bold transition-all ${
|
||||||
|
selectedCategory === category
|
||||||
|
? "bg-primary text-white shadow-md"
|
||||||
|
: "bg-gray-100 dark:bg-white/10 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-white/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex justify-center items-center py-20">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{!loading && filteredProjects.length === 0 && (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<i className="ri-folder-open-line text-6xl text-gray-300 dark:text-gray-600 mb-4"></i>
|
||||||
|
<h3 className="text-xl font-bold text-gray-500 dark:text-gray-400 mb-2">
|
||||||
|
<T>Nenhum projeto encontrado</T>
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-400 dark:text-gray-500">
|
||||||
|
{searchTerm || selectedCategory ? (
|
||||||
|
<T>Tente ajustar os filtros de busca</T>
|
||||||
|
) : (
|
||||||
|
<T>Em breve adicionaremos novos projetos</T>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{(searchTerm || selectedCategory) && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSearchTerm("");
|
||||||
|
setSelectedCategory(null);
|
||||||
|
}}
|
||||||
|
className="mt-4 px-6 py-2 bg-primary text-white rounded-full font-bold hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
<T>Limpar filtros</T>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Projects Grid */}
|
||||||
|
{!loading && filteredProjects.length > 0 && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{projects.map((project) => (
|
{filteredProjects.map((project) => (
|
||||||
<div key={project.id} className="group bg-white dark:bg-secondary rounded-xl overflow-hidden shadow-sm hover:shadow-xl transition-all duration-300 border border-gray-100 dark:border-white/10 flex flex-col">
|
<Link key={project.id} href={`/projetos/${project.id}`} className="group bg-white dark:bg-secondary rounded-xl overflow-hidden shadow-sm hover:shadow-xl transition-all duration-300 border border-gray-100 dark:border-white/10 flex flex-col cursor-pointer">
|
||||||
<div className="relative h-64 overflow-hidden">
|
<div className="relative h-64 overflow-hidden">
|
||||||
<div className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110" style={{ backgroundImage: `url('${project.image}')` }}></div>
|
<div
|
||||||
|
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110"
|
||||||
|
style={{ backgroundImage: `url('${project.coverImage || defaultImage}')` }}
|
||||||
|
></div>
|
||||||
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/0 transition-colors"></div>
|
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/0 transition-colors"></div>
|
||||||
|
{project.category && (
|
||||||
<div className="absolute top-4 left-4 bg-white/90 backdrop-blur-sm px-3 py-1 rounded-md text-xs font-bold text-secondary uppercase tracking-wider">
|
<div className="absolute top-4 left-4 bg-white/90 backdrop-blur-sm px-3 py-1 rounded-md text-xs font-bold text-secondary uppercase tracking-wider">
|
||||||
{project.category}
|
{project.category}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 grow flex flex-col">
|
<div className="p-6 grow flex flex-col">
|
||||||
<h3 className="text-xl font-bold font-headline text-secondary dark:text-white mb-2 group-hover:text-primary transition-colors">{project.title}</h3>
|
<h3 className="text-xl font-bold font-headline text-secondary dark:text-white mb-2 group-hover:text-primary transition-colors">
|
||||||
|
{project.title}
|
||||||
|
</h3>
|
||||||
|
{project.location && (
|
||||||
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400 text-sm mb-4">
|
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400 text-sm mb-4">
|
||||||
<i className="ri-map-pin-line"></i>
|
<i className="ri-map-pin-line"></i>
|
||||||
<span>{project.location}</span>
|
<span>{project.location}</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{project.description && (
|
||||||
<p className="text-gray-600 dark:text-gray-400 text-sm mb-6 line-clamp-3 grow">
|
<p className="text-gray-600 dark:text-gray-400 text-sm mb-6 line-clamp-3 grow">
|
||||||
{project.description}
|
{project.description}
|
||||||
</p>
|
</p>
|
||||||
<Link href={`/projetos/${project.id}`} className="inline-flex items-center gap-2 text-primary font-bold hover:gap-3 transition-all mt-auto">
|
)}
|
||||||
{t('projects.card.details')} <i className="ri-arrow-right-line"></i>
|
<span className="inline-flex items-center gap-2 text-primary font-bold group-hover:gap-3 transition-all mt-auto">
|
||||||
|
<T>Ver Detalhes</T> <i className="ri-arrow-right-line"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results Count */}
|
||||||
|
{!loading && filteredProjects.length > 0 && (
|
||||||
|
<div className="text-center mt-12 text-gray-500 dark:text-gray-400">
|
||||||
|
<T>Exibindo</T> {filteredProjects.length} <T>de</T> {projects.length} <T>projetos</T>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,38 +1,69 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useLanguage } from "@/contexts/LanguageContext";
|
import { T } from "@/components/TranslatedText";
|
||||||
|
|
||||||
export default function ServicosPage() {
|
type Service = {
|
||||||
const { t } = useLanguage();
|
id: string;
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
shortDescription: string | null;
|
||||||
|
fullDescription: string | null;
|
||||||
|
active: boolean;
|
||||||
|
order: number;
|
||||||
|
};
|
||||||
|
|
||||||
const services = [
|
const FALLBACK_SERVICES = [
|
||||||
{
|
{
|
||||||
icon: "ri-draft-line",
|
icon: "ri-draft-line",
|
||||||
title: t('home.services.1.title'),
|
title: "Projetos Técnicos",
|
||||||
description: t('home.services.1.desc'),
|
shortDescription: "Desenvolvimento de projetos de engenharia mecânica, estrutural e veicular com alta precisão e conformidade normativa.",
|
||||||
features: ["Projeto Mecânico 3D", "Cálculo Estrutural", "Dispositivos Especiais", "Homologação de Equipamentos"]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: "ri-truck-line",
|
icon: "ri-truck-line",
|
||||||
title: t('home.features.3.title'),
|
title: "Engenharia Veicular",
|
||||||
description: t('home.features.3.desc'),
|
shortDescription: "Expertise em modificações, adaptações e homologações veiculares com foco em segurança e conformidade.",
|
||||||
features: ["Projeto de Instalação", "Estudo de Estabilidade", "Adequação de Carrocerias", "Regularização Veicular"]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: "ri-file-paper-2-line",
|
icon: "ri-file-paper-2-line",
|
||||||
title: t('home.services.2.title'),
|
title: "Laudos e Perícias",
|
||||||
description: t('home.services.2.desc'),
|
shortDescription: "Emissão de laudos técnicos e pareceres periciais para equipamentos, estruturas e veículos.",
|
||||||
features: ["Laudos de Munck/Guindaste", "Inspeção de Segurança", "Teste de Carga", "Certificação de Equipamentos"]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: "ri-tools-fill",
|
icon: "ri-tools-fill",
|
||||||
title: "Consultoria Técnica",
|
title: "Consultoria Técnica",
|
||||||
description: "Assessoria especializada para adequação de frotas, planos de Rigging e supervisão de manutenção de equipamentos de carga.",
|
shortDescription: "Assessoria especializada para adequação de frotas, planos de Rigging e supervisão de manutenção de equipamentos de carga.",
|
||||||
features: ["Plano de Rigging", "Supervisão de Manutenção", "Consultoria em Normas", "Treinamento Operacional"]
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export default function ServicosPage() {
|
||||||
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchServices() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/services');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
// Filtrar apenas serviços ativos e ordenar
|
||||||
|
const activeServices = data
|
||||||
|
.filter((s: Service) => s.active)
|
||||||
|
.sort((a: Service, b: Service) => a.order - b.order);
|
||||||
|
setServices(activeServices);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao carregar serviços:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchServices();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const displayServices = services.length > 0 ? services : FALLBACK_SERVICES;
|
||||||
|
|
||||||
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 */}
|
||||||
@@ -40,9 +71,9 @@ export default function ServicosPage() {
|
|||||||
<div className="absolute inset-0 bg-black/60 z-10"></div>
|
<div className="absolute inset-0 bg-black/60 z-10"></div>
|
||||||
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1581092160562-40aa08e78837?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center"></div>
|
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1581092160562-40aa08e78837?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center"></div>
|
||||||
<div className="container mx-auto px-4 relative z-20">
|
<div className="container mx-auto px-4 relative z-20">
|
||||||
<h1 className="text-5xl font-bold font-headline mb-4">{t('services.hero.title')}</h1>
|
<h1 className="text-5xl font-bold font-headline mb-4"><T>Nossos Serviços</T></h1>
|
||||||
<p className="text-xl text-gray-300 max-w-2xl">
|
<p className="text-xl text-gray-300 max-w-2xl">
|
||||||
{t('services.hero.subtitle')}
|
<T>Soluções completas em engenharia para atender às necessidades da sua empresa</T>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -50,14 +81,19 @@ export default function ServicosPage() {
|
|||||||
{/* Services List */}
|
{/* Services List */}
|
||||||
<section className="py-20 bg-gray-50 dark:bg-[#121212]">
|
<section className="py-20 bg-gray-50 dark:bg-[#121212]">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<i className="ri-loader-4-line animate-spin text-4xl text-primary"></i>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
{services.map((service, index) => (
|
{displayServices.map((service, index) => (
|
||||||
<div key={index} className="group bg-white dark:bg-secondary rounded-2xl shadow-sm hover:shadow-xl transition-all duration-300 overflow-hidden border border-gray-100 dark:border-white/10 flex flex-col relative">
|
<div key={'id' in service ? service.id : index} className="group bg-white dark:bg-secondary rounded-2xl shadow-sm hover:shadow-xl transition-all duration-300 overflow-hidden border border-gray-100 dark:border-white/10 flex flex-col relative">
|
||||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||||
<i className={`${service.icon} text-9xl text-primary`}></i>
|
<i className={`${service.icon} text-9xl text-primary`}></i>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8 pb-0 relative z-10">
|
<div className="p-8 relative z-10">
|
||||||
<div className="flex justify-between items-start mb-6">
|
<div className="flex justify-between items-start mb-6">
|
||||||
<div className="w-16 h-16 bg-primary/10 rounded-xl flex items-center justify-center text-primary group-hover:bg-primary group-hover:text-white transition-colors duration-300 shadow-sm">
|
<div className="w-16 h-16 bg-primary/10 rounded-xl flex items-center justify-center text-primary group-hover:bg-primary group-hover:text-white transition-colors duration-300 shadow-sm">
|
||||||
<i className={`${service.icon} text-3xl`}></i>
|
<i className={`${service.icon} text-3xl`}></i>
|
||||||
@@ -65,38 +101,26 @@ export default function ServicosPage() {
|
|||||||
<span className="text-5xl font-bold text-gray-100 dark:text-white/10 font-headline select-none">0{index + 1}</span>
|
<span className="text-5xl font-bold text-gray-100 dark:text-white/10 font-headline select-none">0{index + 1}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-2xl font-bold font-headline text-secondary dark:text-white mb-4 group-hover:text-primary transition-colors">{service.title}</h3>
|
<h3 className="text-2xl font-bold font-headline text-secondary dark:text-white mb-4 group-hover:text-primary transition-colors">
|
||||||
<p className="text-gray-600 dark:text-gray-400 leading-relaxed mb-8">
|
<T>{service.title}</T>
|
||||||
{service.description}
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||||
|
<T>{service.shortDescription || ''}</T>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-auto bg-gray-50/50 dark:bg-white/5 p-8 border-t border-gray-100 dark:border-white/10 backdrop-blur-sm">
|
|
||||||
<h4 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-4 flex items-center gap-2">
|
|
||||||
<span className="w-8 h-px bg-primary"></span>
|
|
||||||
{t('services.scope')}
|
|
||||||
</h4>
|
|
||||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-y-3 gap-x-4">
|
|
||||||
{service.features.map((feature, idx) => (
|
|
||||||
<li key={idx} className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
<i className="ri-checkbox-circle-fill text-primary/80"></i>
|
|
||||||
{feature}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className="py-16 bg-primary text-white text-center">
|
<section className="py-16 bg-primary text-white text-center">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<h2 className="text-3xl font-bold font-headline mb-6">{t('services.cta.title')}</h2>
|
<h2 className="text-3xl font-bold font-headline mb-6"><T>Precisa de um serviço especializado?</T></h2>
|
||||||
<Link href="/contato" className="inline-block px-8 py-3 bg-white text-primary rounded-lg font-bold hover:bg-gray-100 transition-colors">
|
<Link href="/contato" className="inline-block px-8 py-3 bg-white text-primary rounded-lg font-bold hover:bg-gray-100 transition-colors">
|
||||||
{t('services.cta.button')}
|
<T>Solicite um Orçamento</T>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useLanguage } from "@/contexts/LanguageContext";
|
import { T } from "@/components/TranslatedText";
|
||||||
|
|
||||||
export default function SobrePage() {
|
export default function SobrePage() {
|
||||||
const { t } = useLanguage();
|
|
||||||
|
|
||||||
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 */}
|
||||||
@@ -13,9 +11,9 @@ export default function SobrePage() {
|
|||||||
<div className="absolute inset-0 bg-black/60 z-10"></div>
|
<div className="absolute inset-0 bg-black/60 z-10"></div>
|
||||||
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1504307651254-35680f356dfd?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center"></div>
|
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1504307651254-35680f356dfd?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center"></div>
|
||||||
<div className="container mx-auto px-4 relative z-20">
|
<div className="container mx-auto px-4 relative z-20">
|
||||||
<h1 className="text-5xl font-bold font-headline mb-4">{t('about.hero.title')}</h1>
|
<h1 className="text-5xl font-bold font-headline mb-4"><T>Sobre a OCCTO</T></h1>
|
||||||
<p className="text-xl text-gray-300 max-w-2xl">
|
<p className="text-xl text-gray-300 max-w-2xl">
|
||||||
{t('about.hero.subtitle')}
|
<T>Conheça nossa história, missão e valores que nos guiam na entrega de excelência em engenharia</T>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -25,13 +23,13 @@ export default function SobrePage() {
|
|||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="flex flex-col md:flex-row gap-12 items-center">
|
<div className="flex flex-col md:flex-row gap-12 items-center">
|
||||||
<div className="w-full md:w-1/2">
|
<div className="w-full md:w-1/2">
|
||||||
<h2 className="text-primary font-bold tracking-wider uppercase mb-2">{t('about.history.title')}</h2>
|
<h2 className="text-primary font-bold tracking-wider uppercase mb-2"><T>Nossa História</T></h2>
|
||||||
<h3 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-6">{t('about.history.subtitle')}</h3>
|
<h3 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-6"><T>Mais de 15 anos de experiência em engenharia</T></h3>
|
||||||
<p className="text-gray-600 dark:text-gray-300 mb-4 leading-relaxed">
|
<p className="text-gray-600 dark:text-gray-300 mb-4 leading-relaxed">
|
||||||
{t('about.history.p1')}
|
<T>A OCCTO Engenharia foi fundada com o objetivo de oferecer soluções completas em engenharia mecânica, veicular e segurança do trabalho. Ao longo de mais de 15 anos, construímos uma trajetória sólida baseada na excelência técnica e no compromisso com a satisfação dos nossos clientes.</T>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-600 dark:text-gray-300 mb-4 leading-relaxed">
|
<p className="text-gray-600 dark:text-gray-300 mb-4 leading-relaxed">
|
||||||
{t('about.history.p2')}
|
<T>Nossa equipe é formada por engenheiros altamente qualificados e especializados, que trabalham com as mais modernas ferramentas e metodologias para garantir resultados precisos e confiáveis em cada projeto.</T>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full md:w-1/2 grid grid-cols-2 gap-4">
|
<div className="w-full md:w-1/2 grid grid-cols-2 gap-4">
|
||||||
@@ -50,30 +48,30 @@ export default function SobrePage() {
|
|||||||
<section className="py-20 bg-gray-50 dark:bg-[#121212]">
|
<section className="py-20 bg-gray-50 dark:bg-[#121212]">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<h2 className="text-primary font-bold tracking-wider uppercase mb-2">{t('about.values.title')}</h2>
|
<h2 className="text-primary font-bold tracking-wider uppercase mb-2"><T>Nossos Valores</T></h2>
|
||||||
<h3 className="text-3xl font-bold font-headline text-secondary dark:text-white">{t('about.values.subtitle')}</h3>
|
<h3 className="text-3xl font-bold font-headline text-secondary dark:text-white"><T>O que nos move</T></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">
|
||||||
<div className="bg-white dark:bg-secondary p-8 rounded-xl shadow-sm border-t-4 border-primary">
|
<div className="bg-white dark:bg-secondary p-8 rounded-xl shadow-sm border-t-4 border-primary">
|
||||||
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center text-primary mb-6">
|
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center text-primary mb-6">
|
||||||
<i className="ri-medal-line text-2xl"></i>
|
<i className="ri-medal-line text-2xl"></i>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-3">{t('about.values.quality.title')}</h4>
|
<h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-3"><T>Qualidade</T></h4>
|
||||||
<p className="text-gray-600 dark:text-gray-400">{t('about.values.quality.desc')}</p>
|
<p className="text-gray-600 dark:text-gray-400"><T>Comprometimento com a excelência em cada projeto, garantindo precisão e conformidade em todas as entregas.</T></p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white dark:bg-secondary p-8 rounded-xl shadow-sm border-t-4 border-primary">
|
<div className="bg-white dark:bg-secondary p-8 rounded-xl shadow-sm border-t-4 border-primary">
|
||||||
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center text-primary mb-6">
|
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center text-primary mb-6">
|
||||||
<i className="ri-shake-hands-line text-2xl"></i>
|
<i className="ri-shake-hands-line text-2xl"></i>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-3">{t('about.values.transparency.title')}</h4>
|
<h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-3"><T>Transparência</T></h4>
|
||||||
<p className="text-gray-600 dark:text-gray-400">{t('about.values.transparency.desc')}</p>
|
<p className="text-gray-600 dark:text-gray-400"><T>Relações baseadas na honestidade e comunicação clara, mantendo nossos clientes sempre informados.</T></p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white dark:bg-secondary p-8 rounded-xl shadow-sm border-t-4 border-primary">
|
<div className="bg-white dark:bg-secondary p-8 rounded-xl shadow-sm border-t-4 border-primary">
|
||||||
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center text-primary mb-6">
|
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center text-primary mb-6">
|
||||||
<i className="ri-leaf-line text-2xl"></i>
|
<i className="ri-leaf-line text-2xl"></i>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-3">{t('about.values.sustainability.title')}</h4>
|
<h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-3"><T>Sustentabilidade</T></h4>
|
||||||
<p className="text-gray-600 dark:text-gray-400">{t('about.values.sustainability.desc')}</p>
|
<p className="text-gray-600 dark:text-gray-400"><T>Compromisso com práticas responsáveis e soluções que minimizam impactos ambientais.</T></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
317
frontend/src/app/[locale]/contato/page.tsx
Normal file
317
frontend/src/app/[locale]/contato/page.tsx
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useToast } from "@/contexts/ToastContext";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useLocale } from "@/contexts/LocaleContext";
|
||||||
|
|
||||||
|
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 ContatoPage() {
|
||||||
|
const { success, error: showError } = useToast();
|
||||||
|
const { locale, t } = useLocale();
|
||||||
|
const [content, setContent] = useState<ContactContent | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
subject: '',
|
||||||
|
message: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchContent();
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
|
const fetchContent = async () => {
|
||||||
|
try {
|
||||||
|
// Busca conteúdo JÁ TRADUZIDO do banco
|
||||||
|
const response = await fetch(`/api/pages/contact?locale=${locale}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.content) {
|
||||||
|
setContent(data.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao carregar conteúdo:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/messages', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Erro ao enviar mensagem');
|
||||||
|
|
||||||
|
// Limpar formulário
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
subject: '',
|
||||||
|
message: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
success('Mensagem enviada com sucesso! Entraremos em contato em breve.');
|
||||||
|
} catch (error) {
|
||||||
|
showError('Erro ao enviar mensagem. Tente novamente.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Valores padrão caso não tenha conteúdo salvo
|
||||||
|
const hero = content?.hero || {
|
||||||
|
pretitle: t('contact.pretitle'),
|
||||||
|
title: t('contact.title'),
|
||||||
|
subtitle: t('contact.subtitle')
|
||||||
|
};
|
||||||
|
|
||||||
|
const info = content?.info || {
|
||||||
|
title: t('contact.infoTitle'),
|
||||||
|
subtitle: t('contact.infoSubtitle'),
|
||||||
|
description: t('contact.infoDescription'),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="bg-white dark:bg-secondary transition-colors duration-300">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<section className="relative h-[400px] flex items-center bg-secondary text-white overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-linear-to-r from-black/80 to-black/40 z-10"></div>
|
||||||
|
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center"></div>
|
||||||
|
<div className="container mx-auto px-4 relative z-20">
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<div className="inline-flex items-center gap-2 bg-primary/20 backdrop-blur-sm border border-primary/30 rounded-full px-4 py-1 mb-6">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-primary animate-pulse"></span>
|
||||||
|
<span className="text-sm font-bold text-primary uppercase tracking-wider">{hero.pretitle}</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-5xl md:text-6xl font-bold font-headline mb-6 leading-tight">{hero.title}</h1>
|
||||||
|
<p className="text-xl text-gray-300 max-w-2xl leading-relaxed">
|
||||||
|
{hero.subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="py-20 bg-white dark:bg-secondary relative">
|
||||||
|
{/* Decorative Elements */}
|
||||||
|
<div className="absolute top-0 right-0 w-1/3 h-full bg-gray-50 dark:bg-white/5 -z-10 hidden lg:block"></div>
|
||||||
|
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-20">
|
||||||
|
{/* Informações de Contato */}
|
||||||
|
<div className="lg:col-span-5 space-y-12">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-primary font-bold tracking-wider uppercase mb-3">{info.title}</h2>
|
||||||
|
<h3 className="text-3xl md:text-4xl font-bold font-headline text-secondary dark:text-white mb-6">{info.subtitle}</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 text-lg leading-relaxed">
|
||||||
|
{info.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{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 group-hover:scale-110 transition-transform duration-300">
|
||||||
|
<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} target="_blank" rel="noopener noreferrer" 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>
|
||||||
|
|
||||||
|
{/* Formulário */}
|
||||||
|
<div className="lg:col-span-7">
|
||||||
|
<div className="bg-white dark:bg-secondary p-8 md:p-10 rounded-3xl shadow-xl border border-gray-100 dark:border-white/10 relative overflow-hidden">
|
||||||
|
<div className="absolute top-0 right-0 w-32 h-32 bg-primary/5 rounded-bl-full -mr-10 -mt-10"></div>
|
||||||
|
|
||||||
|
<h3 className="text-2xl font-bold font-headline text-secondary dark:text-white mb-8 relative z-10">{t('contact.sendMessage')}</h3>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-6 relative z-10">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="group">
|
||||||
|
<label htmlFor="nome" className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 group-focus-within:text-primary transition-colors">{t('contact.form.name')}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<i className="ri-user-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-primary transition-colors"></i>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="nome"
|
||||||
|
required
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({...formData, name: e.target.value})}
|
||||||
|
className="w-full pl-11 pr-4 py-3.5 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={t('contact.form.namePlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="group">
|
||||||
|
<label htmlFor="telefone" className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 group-focus-within:text-primary transition-colors">{t('contact.form.phone')}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<i className="ri-phone-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-primary transition-colors"></i>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
id="telefone"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={(e) => setFormData({...formData, phone: e.target.value})}
|
||||||
|
className="w-full pl-11 pr-4 py-3.5 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="(00) 00000-0000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="group">
|
||||||
|
<label htmlFor="email" className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 group-focus-within:text-primary transition-colors">{t('contact.form.email')}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<i className="ri-mail-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-primary transition-colors"></i>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
required
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({...formData, email: e.target.value})}
|
||||||
|
className="w-full pl-11 pr-4 py-3.5 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={t('contact.form.emailPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="group">
|
||||||
|
<label htmlFor="assunto" className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 group-focus-within:text-primary transition-colors">{t('contact.form.subject')}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<i className="ri-file-list-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-primary transition-colors"></i>
|
||||||
|
<select
|
||||||
|
id="assunto"
|
||||||
|
value={formData.subject}
|
||||||
|
onChange={(e) => setFormData({...formData, subject: e.target.value})}
|
||||||
|
className="w-full pl-11 pr-10 py-3.5 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 appearance-none cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="">{t('contact.form.subjectPlaceholder')}</option>
|
||||||
|
<option value="orcamento">{t('contact.form.subjectQuote')}</option>
|
||||||
|
<option value="duvida">{t('contact.form.subjectQuestion')}</option>
|
||||||
|
<option value="parceria">{t('contact.form.subjectPartnership')}</option>
|
||||||
|
<option value="trabalhe">{t('contact.form.subjectOther')}</option>
|
||||||
|
</select>
|
||||||
|
<i className="ri-arrow-down-s-line absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="group">
|
||||||
|
<label htmlFor="mensagem" className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 group-focus-within:text-primary transition-colors">{t('contact.form.message')}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<i className="ri-message-2-line absolute left-4 top-6 text-gray-400 group-focus-within:text-primary transition-colors"></i>
|
||||||
|
<textarea
|
||||||
|
id="mensagem"
|
||||||
|
required
|
||||||
|
value={formData.message}
|
||||||
|
onChange={(e) => setFormData({...formData, message: e.target.value})}
|
||||||
|
className="w-full pl-11 pr-4 py-3.5 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl h-40 text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all resize-none"
|
||||||
|
placeholder={t('contact.form.messagePlaceholder')}
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="mt-4 w-full bg-primary text-white py-4 rounded-xl font-bold hover-primary transition-all shadow-lg hover:shadow-primary/30 flex items-center justify-center gap-2 group disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<>
|
||||||
|
<i className="ri-loader-4-line animate-spin"></i>
|
||||||
|
<span>{t('contact.form.sending')}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>{t('contact.form.submit')}</span>
|
||||||
|
<i className="ri-send-plane-fill group-hover:translate-x-1 transition-transform"></i>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Map Section */}
|
||||||
|
<section className="h-[400px] w-full bg-gray-200 dark:bg-white/5 relative grayscale hover:grayscale-0 transition-all duration-700">
|
||||||
|
<iframe
|
||||||
|
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3741.447687667888!2d-40.29799692398269!3d-20.32313498115656!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0xb817d0a5b5b5b5%3A0x5b5b5b5b5b5b5b5b!2sAv.%20Nossa%20Sra.%20da%20Penha%2C%20Vit%C3%B3ria%20-%20ES!5e0!3m2!1spt-BR!2sbr!4v1700000000000!5m2!1spt-BR!2sbr"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
style={{ border: 0 }}
|
||||||
|
allowFullScreen
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer-when-downgrade"
|
||||||
|
className="absolute inset-0"
|
||||||
|
></iframe>
|
||||||
|
<div className="absolute inset-0 bg-primary/10 pointer-events-none"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
frontend/src/app/[locale]/layout.tsx
Normal file
35
frontend/src/app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import Header from "@/components/Header";
|
||||||
|
import Footer from "@/components/Footer";
|
||||||
|
import CookieConsent from "@/components/CookieConsent";
|
||||||
|
import WhatsAppButton from "@/components/WhatsAppButton";
|
||||||
|
import { LocaleProvider } from "@/contexts/LocaleContext";
|
||||||
|
import { locales, type Locale, defaultLocale } from "@/lib/i18n";
|
||||||
|
|
||||||
|
// Gerar rotas estáticas para cada locale
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return locales.map((locale) => ({ locale }));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ locale: Locale }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LocaleLayout({ children, params }: Props) {
|
||||||
|
const { locale } = await params;
|
||||||
|
|
||||||
|
// Validar locale
|
||||||
|
const validLocale = locales.includes(locale) ? locale : defaultLocale;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LocaleProvider locale={validLocale}>
|
||||||
|
<Header />
|
||||||
|
<div className="grow">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
<CookieConsent />
|
||||||
|
<WhatsAppButton />
|
||||||
|
</LocaleProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
342
frontend/src/app/[locale]/page.tsx
Normal file
342
frontend/src/app/[locale]/page.tsx
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePageContent } from "@/hooks/usePageContent";
|
||||||
|
import { useLocale } from "@/contexts/LocaleContext";
|
||||||
|
|
||||||
|
type PortfolioProject = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
coverImage: string | null;
|
||||||
|
galleryImages: string[];
|
||||||
|
status: string;
|
||||||
|
createdAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FallbackProject = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
image: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_PROJECTS: FallbackProject[] = [
|
||||||
|
{
|
||||||
|
id: 'fallback-1',
|
||||||
|
title: 'Projeto de Adequação - Coca-Cola',
|
||||||
|
category: 'Engenharia Veicular',
|
||||||
|
image: 'https://images.unsplash.com/photo-1616401784845-180882ba9ba8?q=80&w=2070&auto=format&fit=crop',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fallback-2',
|
||||||
|
title: 'Laudo de Guindaste Articulado',
|
||||||
|
category: 'Inspeção Técnica',
|
||||||
|
image: 'https://images.unsplash.com/photo-1581092335397-9583eb92d232?q=80&w=2070&auto=format&fit=crop',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fallback-3',
|
||||||
|
title: 'Dispositivo de Içamento Especial',
|
||||||
|
category: 'Projeto Mecânico',
|
||||||
|
image: 'https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?q=80&w=2070&auto=format&fit=crop',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const { locale, t } = useLocale();
|
||||||
|
const [latestProjects, setLatestProjects] = useState<PortfolioProject[]>([]);
|
||||||
|
|
||||||
|
// Busca conteúdo JÁ TRADUZIDO do banco (sem tradução em tempo real!)
|
||||||
|
const { content, loading } = usePageContent('home', locale);
|
||||||
|
|
||||||
|
// Usar conteúdo do banco ou fallback
|
||||||
|
const hero = 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'
|
||||||
|
};
|
||||||
|
|
||||||
|
const features = content?.features || {
|
||||||
|
pretitle: 'Por que nos escolher',
|
||||||
|
title: 'Nossos Diferenciais',
|
||||||
|
items: [
|
||||||
|
{ icon: 'ri-shield-star-line', title: 'Qualidade Garantida', description: 'Processos certificados e equipe altamente qualificada.' },
|
||||||
|
{ icon: 'ri-settings-4-line', title: 'Soluções Personalizadas', description: 'Atendimento sob medida para suas necessidades.' },
|
||||||
|
{ icon: 'ri-truck-line', title: 'Especialização Veicular', description: 'Expertise em engenharia automotiva.' }
|
||||||
|
] as Array<{ icon: string; title: string; description: string }>
|
||||||
|
};
|
||||||
|
|
||||||
|
const services = content?.services || {
|
||||||
|
pretitle: 'Nossos Serviços',
|
||||||
|
title: 'O Que Fazemos',
|
||||||
|
items: [
|
||||||
|
{ icon: 'ri-draft-line', title: 'Projetos Técnicos', description: 'Desenvolvimento de projetos de engenharia.' },
|
||||||
|
{ icon: 'ri-file-paper-2-line', title: 'Laudos e Perícias', description: 'Emissão de laudos técnicos.' },
|
||||||
|
{ icon: 'ri-alert-line', title: 'Segurança do Trabalho', description: 'Implementação de normas de segurança.' },
|
||||||
|
{ icon: 'ri-truck-fill', title: 'Engenharia Veicular', description: 'Modificações e adaptações de veículos.' }
|
||||||
|
] as Array<{ icon: string; title: string; description: string }>
|
||||||
|
};
|
||||||
|
|
||||||
|
const about = content?.about || {
|
||||||
|
pretitle: 'Conheça a OCCTO',
|
||||||
|
title: 'Sobre Nós',
|
||||||
|
description: 'Com mais de 15 anos de experiência, a OCCTO Engenharia se consolidou como referência em soluções de engenharia.',
|
||||||
|
highlights: [
|
||||||
|
'Mais de 500 clientes atendidos',
|
||||||
|
'Equipe técnica qualificada',
|
||||||
|
'Parceiro oficial de grandes empresas'
|
||||||
|
] as string[]
|
||||||
|
};
|
||||||
|
|
||||||
|
const testimonials = content?.testimonials || {
|
||||||
|
pretitle: 'Depoimentos',
|
||||||
|
title: 'O Que Dizem Nossos Clientes',
|
||||||
|
items: [
|
||||||
|
{ name: 'Ricardo Mendes', role: 'Gerente de Frota', text: 'Excelente trabalho!' },
|
||||||
|
{ name: 'Fernanda Costa', role: 'Diretora de Operações', text: 'Parceria de confiança.' },
|
||||||
|
{ name: 'Paulo Oliveira', role: 'Engenheiro Chefe', text: 'Conhecimento técnico incomparável.' }
|
||||||
|
] as Array<{ name: string; role: string; text: string }>
|
||||||
|
};
|
||||||
|
|
||||||
|
const cta = content?.cta || {
|
||||||
|
title: 'Pronto para tirar seu projeto do papel?',
|
||||||
|
text: 'Entre em contato com nossa equipe de especialistas.',
|
||||||
|
button: 'Fale Conosco'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prefix para links baseado no locale
|
||||||
|
const prefix = locale === 'pt' ? '' : `/${locale}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const fetchProjects = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/projects', {
|
||||||
|
method: 'GET',
|
||||||
|
cache: 'no-store',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Falha ao buscar projetos mais recentes (status ${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (isMounted && Array.isArray(data)) {
|
||||||
|
const publishedProjects = data
|
||||||
|
.filter((project: PortfolioProject) => project.status !== 'Rascunho')
|
||||||
|
.sort((a, b) => {
|
||||||
|
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||||
|
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||||
|
return dateB - dateA;
|
||||||
|
})
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
setLatestProjects(publishedProjects);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name !== 'AbortError') {
|
||||||
|
console.error('Erro ao buscar projetos recentes:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchProjects();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="bg-white dark:bg-secondary transition-colors duration-300">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<section className="relative h-[600px] flex items-center bg-secondary text-white overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-black/60 z-10"></div>
|
||||||
|
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1581094288338-2314dddb7ece?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center"></div>
|
||||||
|
|
||||||
|
<div className="container mx-auto px-4 relative z-20">
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<h1 className="text-5xl md:text-6xl font-bold font-headline mb-6 leading-tight">
|
||||||
|
{hero.title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-gray-300 mb-8 max-w-2xl">
|
||||||
|
{hero.subtitle}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<Link href={`${prefix}/contato`} className="px-8 py-4 bg-primary text-white rounded-lg font-bold hover-primary transition-colors text-center">
|
||||||
|
{hero.buttonText}
|
||||||
|
</Link>
|
||||||
|
<Link href={`${prefix}/projetos`} className="px-8 py-4 border-2 border-white text-white rounded-lg font-bold hover:bg-white hover:text-secondary transition-colors text-center">
|
||||||
|
{t('home.viewSolutions')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Features Section */}
|
||||||
|
<section className="py-20 bg-white dark:bg-secondary">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h2 className="text-primary font-bold tracking-wider uppercase mb-2">{features.pretitle}</h2>
|
||||||
|
<h3 className="text-4xl font-bold font-headline text-secondary dark:text-white">{features.title}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
{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 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>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-bold font-headline mb-4 text-secondary dark:text-white">{feature.title}</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">{feature.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Services Section */}
|
||||||
|
<section className="py-20 bg-gray-50 dark:bg-[#121212]">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h2 className="text-primary font-bold tracking-wider uppercase mb-2">{services.pretitle}</h2>
|
||||||
|
<h3 className="text-4xl font-bold font-headline text-secondary dark:text-white">{services.title}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{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">
|
||||||
|
<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>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 text-sm">{service.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-center mt-12">
|
||||||
|
<Link href={`${prefix}/servicos`} className="text-primary font-bold hover:text-secondary dark:hover:text-white transition-colors inline-flex items-center gap-2">
|
||||||
|
{t('home.viewAllServices')} <i className="ri-arrow-right-line"></i>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* About Preview */}
|
||||||
|
<section className="py-20 bg-secondary text-white">
|
||||||
|
<div className="container mx-auto px-4 flex flex-col md:flex-row items-center gap-12">
|
||||||
|
<div className="w-full md:w-1/2 hidden md:block">
|
||||||
|
<div className="relative h-[400px] w-full rounded-xl overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1581092160562-40aa08e78837?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full md:w-1/2">
|
||||||
|
<h2 className="text-primary font-bold tracking-wider uppercase mb-2">{about.pretitle}</h2>
|
||||||
|
<h3 className="text-4xl font-bold font-headline mb-6">{about.title}</h3>
|
||||||
|
<p className="text-gray-400 mb-6 text-lg">{about.description}</p>
|
||||||
|
<ul className="space-y-4 mb-8">
|
||||||
|
{about.highlights.map((highlight: string, index: number) => (
|
||||||
|
<li key={index} className="flex items-center gap-3">
|
||||||
|
<i className="ri-check-double-line text-primary text-xl"></i>
|
||||||
|
<span>{highlight}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Link href={`${prefix}/sobre`} className="text-primary font-bold hover:text-white transition-colors flex items-center gap-2">
|
||||||
|
{t('home.knowExpertise')} <i className="ri-arrow-right-line"></i>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Latest Projects Section */}
|
||||||
|
<section className="py-20 bg-white dark:bg-secondary">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-12 gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-primary font-bold tracking-wider uppercase mb-2">{t('home.portfolio')}</h2>
|
||||||
|
<h3 className="text-4xl font-bold font-headline text-secondary dark:text-white">{t('home.recentProjects')}</h3>
|
||||||
|
</div>
|
||||||
|
<Link href={`${prefix}/projetos`} className="px-6 py-3 border border-secondary dark:border-white text-secondary dark:text-white rounded-lg font-bold hover:bg-secondary hover:text-white dark:hover:bg-white dark:hover:text-secondary transition-colors">
|
||||||
|
{t('home.viewAllProjects')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
{(latestProjects.length > 0
|
||||||
|
? latestProjects.map((project) => ({
|
||||||
|
id: project.id,
|
||||||
|
title: project.title,
|
||||||
|
category: project.category,
|
||||||
|
image: project.coverImage || project.galleryImages?.[0] || FALLBACK_PROJECTS[0].image,
|
||||||
|
}))
|
||||||
|
: FALLBACK_PROJECTS
|
||||||
|
).map((project) => (
|
||||||
|
<Link key={project.id} href={`${prefix}/projetos/${project.id}`} className="group relative overflow-hidden rounded-xl h-[400px] cursor-pointer block">
|
||||||
|
<div className="absolute inset-0 bg-cover bg-center transition-transform duration-500 group-hover:scale-110" style={{ backgroundImage: `url('${project.image}')` }}></div>
|
||||||
|
<div className="absolute inset-0 bg-linear-to-t from-black/90 via-black/20 to-transparent opacity-80 group-hover:opacity-90 transition-opacity"></div>
|
||||||
|
<div className="absolute bottom-0 left-0 p-8 w-full transform translate-y-4 group-hover:translate-y-0 transition-transform">
|
||||||
|
<span className="text-primary font-bold text-sm uppercase tracking-wider mb-2 block">{project.category}</span>
|
||||||
|
<h3 className="text-2xl font-bold font-headline text-white mb-2">{project.title}</h3>
|
||||||
|
<div className="h-0 group-hover:h-auto overflow-hidden transition-all">
|
||||||
|
<span className="text-white/80 text-sm flex items-center gap-2 mt-4">
|
||||||
|
{t('home.viewDetails')} <i className="ri-arrow-right-line"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Testimonials Section */}
|
||||||
|
<section className="py-20 bg-gray-50 dark:bg-[#121212]">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h2 className="text-primary font-bold tracking-wider uppercase mb-2">{testimonials.pretitle}</h2>
|
||||||
|
<h3 className="text-4xl font-bold font-headline text-secondary dark:text-white">{testimonials.title}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
{testimonials.items.map((testimonial: { name: string; role: string; text: 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">
|
||||||
|
<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>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-gray-200 dark:bg-white/10 rounded-full flex items-center justify-center text-gray-400">
|
||||||
|
<i className="ri-user-line text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold font-headline text-secondary dark:text-white">{testimonial.name}</h4>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">{testimonial.role}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA Section */}
|
||||||
|
<section className="py-24 bg-primary">
|
||||||
|
<div className="container mx-auto px-4 text-center">
|
||||||
|
<h2 className="text-4xl font-bold font-headline text-white mb-6">{cta.title}</h2>
|
||||||
|
<p className="text-white/90 text-xl mb-8 max-w-2xl mx-auto">{cta.text}</p>
|
||||||
|
<Link href={`${prefix}/contato`} className="inline-block px-10 py-4 bg-white text-primary rounded-lg font-bold hover:bg-gray-100 transition-colors shadow-lg">
|
||||||
|
{cta.button}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
frontend/src/app/[locale]/privacidade/page.tsx
Normal file
61
frontend/src/app/[locale]/privacidade/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useLocale } from "@/contexts/LocaleContext";
|
||||||
|
|
||||||
|
export default function PrivacyPolicy() {
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="py-20 bg-white dark:bg-secondary transition-colors duration-300">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div className="prose prose-lg text-gray-600 dark:text-gray-300">
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">1. Coleta de Informações</h2>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">2. Uso das Informações</h2>
|
||||||
|
<p className="mb-4">
|
||||||
|
Utilizamos as informações coletadas para:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-6 mb-6 space-y-2">
|
||||||
|
<li>Responder a suas consultas e solicitações de orçamento;</li>
|
||||||
|
<li>Fornecer informações sobre nossos serviços de engenharia e laudos técnicos;</li>
|
||||||
|
<li>Melhorar a experiência do usuário em nosso site;</li>
|
||||||
|
<li>Cumprir obrigações legais e regulatórias.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">3. Proteção de Dados</h2>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">4. Compartilhamento de Informações</h2>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">5. Cookies</h2>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">6. Contato</h2>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-12">
|
||||||
|
Última atualização: Novembro de 2025.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
317
frontend/src/app/[locale]/projetos/[id]/page.tsx
Normal file
317
frontend/src/app/[locale]/projetos/[id]/page.tsx
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useLocale } from "@/contexts/LocaleContext";
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
client: string | null;
|
||||||
|
status: string;
|
||||||
|
completionDate: string | null;
|
||||||
|
description: string | null;
|
||||||
|
coverImage: string | null;
|
||||||
|
galleryImages: string[];
|
||||||
|
featured: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectDetails({ params }: { params: { id: string; locale: string } }) {
|
||||||
|
const { t, locale } = useLocale();
|
||||||
|
const prefix = locale === 'pt' ? '' : `/${locale}`;
|
||||||
|
|
||||||
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||||
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
|
|
||||||
|
// Translations
|
||||||
|
const texts = {
|
||||||
|
aboutProject: locale === 'pt' ? 'Sobre o Projeto' : locale === 'es' ? 'Sobre el Proyecto' : 'About the Project',
|
||||||
|
imageGallery: locale === 'pt' ? 'Galeria de Imagens' : locale === 'es' ? 'Galería de Imágenes' : 'Image Gallery',
|
||||||
|
technicalSheet: locale === 'pt' ? 'Ficha Técnica' : locale === 'es' ? 'Ficha Técnica' : 'Technical Sheet',
|
||||||
|
client: locale === 'pt' ? 'Cliente' : locale === 'es' ? 'Cliente' : 'Client',
|
||||||
|
category: locale === 'pt' ? 'Categoria' : locale === 'es' ? 'Categoría' : 'Category',
|
||||||
|
status: locale === 'pt' ? 'Status' : locale === 'es' ? 'Estado' : 'Status',
|
||||||
|
year: locale === 'pt' ? 'Ano' : locale === 'es' ? 'Año' : 'Year',
|
||||||
|
featured: locale === 'pt' ? 'Destaque' : locale === 'es' ? 'Destacado' : 'Featured',
|
||||||
|
yes: locale === 'pt' ? 'Sim' : locale === 'es' ? 'Sí' : 'Yes',
|
||||||
|
requestQuote: locale === 'pt' ? 'Solicitar Orçamento Similar' : locale === 'es' ? 'Solicitar Presupuesto Similar' : 'Request Similar Quote',
|
||||||
|
backToProjects: locale === 'pt' ? 'Voltar para Projetos' : locale === 'es' ? 'Volver a Proyectos' : 'Back to Projects',
|
||||||
|
completed: locale === 'pt' ? 'Concluído' : locale === 'es' ? 'Completado' : 'Completed',
|
||||||
|
inProgress: locale === 'pt' ? 'Em andamento' : locale === 'es' ? 'En progreso' : 'In Progress',
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchProject() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/projects/${params.id}`, { cache: "no-store" });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setProject(data);
|
||||||
|
setSelectedImage(data.coverImage || data.galleryImages?.[0] || null);
|
||||||
|
} else if (res.status === 404) {
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Erro ao carregar projeto:", err);
|
||||||
|
setError(true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchProject();
|
||||||
|
}, [params.id]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<main className="bg-white dark:bg-secondary min-h-screen">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-[500px] bg-gray-300 dark:bg-gray-700"></div>
|
||||||
|
<div className="container mx-auto px-4 py-20">
|
||||||
|
<div className="flex flex-col lg:flex-row gap-16">
|
||||||
|
<div className="lg:w-2/3 space-y-4">
|
||||||
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-1/3"></div>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-2/3"></div>
|
||||||
|
</div>
|
||||||
|
<div className="lg:w-1/3">
|
||||||
|
<div className="h-64 bg-gray-200 dark:bg-gray-700 rounded-xl"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !project) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultImage = "https://images.unsplash.com/photo-1504307651254-35680f356dfd?q=80&w=2070&auto=format&fit=crop";
|
||||||
|
const heroImage = project.coverImage || defaultImage;
|
||||||
|
const allImages = [
|
||||||
|
...(project.coverImage ? [project.coverImage] : []),
|
||||||
|
...project.galleryImages,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const completionYear = project.completionDate
|
||||||
|
? new Date(project.completionDate).getFullYear()
|
||||||
|
: new Date(project.createdAt).getFullYear();
|
||||||
|
|
||||||
|
const statusText = project.status === 'Concluído' ? texts.completed : texts.inProgress;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="bg-white dark:bg-secondary transition-colors duration-300">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<section className="relative h-[500px] flex items-center bg-secondary text-white overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-black/60 z-10"></div>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-cover bg-center"
|
||||||
|
style={{ backgroundImage: `url('${heroImage}')` }}
|
||||||
|
></div>
|
||||||
|
<div className="container mx-auto px-4 relative z-20">
|
||||||
|
{project.category && (
|
||||||
|
<span className="inline-block px-3 py-1 bg-primary text-white text-sm font-bold rounded-md mb-4 uppercase tracking-wider">
|
||||||
|
{project.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<h1 className="text-4xl md:text-6xl font-bold font-headline mb-4 leading-tight max-w-4xl">
|
||||||
|
{project.title}
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-4 text-gray-300 text-lg flex-wrap">
|
||||||
|
{project.client && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<i className="ri-building-line text-primary"></i>
|
||||||
|
<span>{project.client}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<i className="ri-calendar-line text-primary"></i>
|
||||||
|
<span>{completionYear}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`px-2 py-1 text-xs rounded ${project.status === 'Concluído' ? 'bg-green-500/20 text-green-300' : 'bg-yellow-500/20 text-yellow-300'}`}>
|
||||||
|
{statusText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Content Section */}
|
||||||
|
<section className="py-20 bg-white dark:bg-secondary">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="flex flex-col lg:flex-row gap-16">
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="lg:w-2/3">
|
||||||
|
{/* Description */}
|
||||||
|
{project.description && (
|
||||||
|
<>
|
||||||
|
<h2 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-6">
|
||||||
|
{texts.aboutProject}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 text-lg leading-relaxed mb-12 whitespace-pre-line">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Image Gallery */}
|
||||||
|
{allImages.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h3 className="text-2xl font-bold font-headline text-secondary dark:text-white mb-6">
|
||||||
|
{texts.imageGallery}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Main Image */}
|
||||||
|
<div
|
||||||
|
className="relative aspect-video rounded-xl overflow-hidden mb-4 cursor-pointer group"
|
||||||
|
onClick={() => {
|
||||||
|
setLightboxOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={selectedImage || heroImage}
|
||||||
|
alt={project.title}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
|
||||||
|
<i className="ri-zoom-in-line text-4xl text-white opacity-0 group-hover:opacity-100 transition-opacity"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnail Grid */}
|
||||||
|
{allImages.length > 1 && (
|
||||||
|
<div className="grid grid-cols-4 md:grid-cols-6 gap-2">
|
||||||
|
{allImages.map((img, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => setSelectedImage(img)}
|
||||||
|
className={`aspect-square rounded-lg overflow-hidden border-2 transition-all ${
|
||||||
|
selectedImage === img
|
||||||
|
? 'border-primary ring-2 ring-primary/30'
|
||||||
|
: 'border-transparent hover:border-gray-300 dark:hover:border-white/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={img}
|
||||||
|
alt={`${project.title} - ${index + 1}`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="lg:w-1/3">
|
||||||
|
<div className="bg-gray-50 dark:bg-white/5 p-8 rounded-xl border border-gray-100 dark:border-white/10 sticky top-24">
|
||||||
|
<h3 className="text-xl font-bold font-headline text-secondary dark:text-white mb-6">
|
||||||
|
{texts.technicalSheet}
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-4 mb-8">
|
||||||
|
{project.client && (
|
||||||
|
<li className="flex justify-between border-b border-gray-200 dark:border-white/10 pb-3">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">{texts.client}</span>
|
||||||
|
<span className="font-medium text-secondary dark:text-white">{project.client}</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{project.category && (
|
||||||
|
<li className="flex justify-between border-b border-gray-200 dark:border-white/10 pb-3">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">{texts.category}</span>
|
||||||
|
<span className="font-medium text-secondary dark:text-white">{project.category}</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
<li className="flex justify-between border-b border-gray-200 dark:border-white/10 pb-3">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">{texts.status}</span>
|
||||||
|
<span className={`font-medium ${project.status === 'Concluído' ? 'text-green-600' : 'text-yellow-600'}`}>
|
||||||
|
{statusText}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex justify-between border-b border-gray-200 dark:border-white/10 pb-3">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">{texts.year}</span>
|
||||||
|
<span className="font-medium text-secondary dark:text-white">{completionYear}</span>
|
||||||
|
</li>
|
||||||
|
{project.featured && (
|
||||||
|
<li className="flex justify-between border-b border-gray-200 dark:border-white/10 pb-3">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">{texts.featured}</span>
|
||||||
|
<span className="font-medium text-primary">
|
||||||
|
<i className="ri-star-fill"></i> {texts.yes}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Link href={`${prefix}/contato`} className="block w-full py-4 bg-primary text-white text-center rounded-lg font-bold hover:bg-primary/90 transition-colors">
|
||||||
|
{texts.requestQuote}
|
||||||
|
</Link>
|
||||||
|
<Link href={`${prefix}/projetos`} className="block w-full py-4 mt-4 border border-gray-300 dark:border-white/20 text-gray-600 dark:text-gray-300 text-center rounded-lg font-bold hover:bg-gray-100 dark:hover:bg-white/10 transition-colors">
|
||||||
|
{texts.backToProjects}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Lightbox */}
|
||||||
|
{lightboxOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4"
|
||||||
|
onClick={() => setLightboxOpen(false)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="absolute top-4 right-4 text-white text-4xl hover:text-primary transition-colors"
|
||||||
|
onClick={() => setLightboxOpen(false)}
|
||||||
|
>
|
||||||
|
<i className="ri-close-line"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{allImages.length > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="absolute left-4 top-1/2 -translate-y-1/2 text-white text-4xl hover:text-primary transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const currentIndex = allImages.indexOf(selectedImage || '');
|
||||||
|
const prevIndex = currentIndex <= 0 ? allImages.length - 1 : currentIndex - 1;
|
||||||
|
setSelectedImage(allImages[prevIndex]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="ri-arrow-left-s-line"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 text-white text-4xl hover:text-primary transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const currentIndex = allImages.indexOf(selectedImage || '');
|
||||||
|
const nextIndex = currentIndex >= allImages.length - 1 ? 0 : currentIndex + 1;
|
||||||
|
setSelectedImage(allImages[nextIndex]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="ri-arrow-right-s-line"></i>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={selectedImage || heroImage}
|
||||||
|
alt={project.title}
|
||||||
|
className="max-w-full max-h-[90vh] object-contain"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
235
frontend/src/app/[locale]/projetos/page.tsx
Normal file
235
frontend/src/app/[locale]/projetos/page.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useLocale } from "@/contexts/LocaleContext";
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
description: string | null;
|
||||||
|
coverImage: string | null;
|
||||||
|
galleryImages: string[];
|
||||||
|
status: string;
|
||||||
|
client: string | null;
|
||||||
|
completionDate: string | null;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK_IMAGE = "https://images.unsplash.com/photo-1616401784845-180882ba9ba8?q=80&w=2070&auto=format&fit=crop";
|
||||||
|
|
||||||
|
export default function ProjetosPage() {
|
||||||
|
const { t, locale } = useLocale();
|
||||||
|
const prefix = locale === 'pt' ? '' : `/${locale}`;
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string>('todos');
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
|
const searchPlaceholder = locale === 'pt' ? 'Pesquisar projetos...' : locale === 'es' ? 'Buscar proyectos...' : 'Search projects...';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const fetchProjects = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/projects', {
|
||||||
|
method: 'GET',
|
||||||
|
cache: 'no-store',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Falha ao buscar projetos (status ${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (isMounted && Array.isArray(data)) {
|
||||||
|
const publishedProjects = (data as Project[])
|
||||||
|
.filter((project) => project.status !== 'Rascunho')
|
||||||
|
.sort((a, b) => {
|
||||||
|
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||||
|
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
|
||||||
|
setProjects(publishedProjects);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name !== 'AbortError') {
|
||||||
|
console.error('Erro ao carregar projetos:', err);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchProjects();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const unique = new Set<string>();
|
||||||
|
projects.forEach((project) => {
|
||||||
|
if (project.category) {
|
||||||
|
unique.add(project.category);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(unique);
|
||||||
|
}, [projects]);
|
||||||
|
|
||||||
|
const filteredProjects = useMemo(() => {
|
||||||
|
return projects.filter((project) => {
|
||||||
|
const matchesCategory = selectedCategory === 'todos' || project.category === selectedCategory;
|
||||||
|
const matchesSearch =
|
||||||
|
!searchTerm ||
|
||||||
|
project.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
project.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
project.category?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
project.client?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
return matchesCategory && matchesSearch;
|
||||||
|
});
|
||||||
|
}, [projects, selectedCategory, searchTerm]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="bg-white dark:bg-secondary transition-colors duration-300">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<section className="relative h-[400px] flex items-center bg-secondary text-white overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-black/60 z-10"></div>
|
||||||
|
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1504307651254-35680f356dfd?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center"></div>
|
||||||
|
<div className="container mx-auto px-4 relative z-20">
|
||||||
|
<h1 className="text-5xl font-bold font-headline mb-4">{t('projects.hero.title')}</h1>
|
||||||
|
<p className="text-xl text-gray-300 max-w-2xl">
|
||||||
|
{t('projects.hero.subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Projects Grid */}
|
||||||
|
<section className="py-20 bg-white dark:bg-secondary">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="max-w-xl mx-auto mb-8">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full px-5 py-3 pl-12 rounded-full border border-gray-200 dark:border-white/20 bg-white dark:bg-white/5 text-secondary dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
|
||||||
|
/>
|
||||||
|
<i className="ri-search-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 text-xl"></i>
|
||||||
|
{searchTerm && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchTerm("")}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<i className="ri-close-line text-xl"></i>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap gap-4 mb-12 justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedCategory('todos')}
|
||||||
|
className={`px-6 py-2 rounded-full font-bold transition-colors ${selectedCategory === 'todos' ? 'bg-primary text-white shadow-md' : 'bg-gray-100 dark:bg-white/10 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-white/20'}`}
|
||||||
|
>
|
||||||
|
{t('projects.filters.all')}
|
||||||
|
</button>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category}
|
||||||
|
onClick={() => setSelectedCategory(category)}
|
||||||
|
className={`px-6 py-2 rounded-full font-bold transition-colors ${selectedCategory === category ? 'bg-primary text-white shadow-md' : 'bg-gray-100 dark:bg-white/10 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-white/20'}`}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<div key={index} className="animate-pulse bg-white dark:bg-secondary rounded-xl border border-gray-100 dark:border-white/10 h-96"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filteredProjects.length === 0 ? (
|
||||||
|
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
|
||||||
|
<i className="ri-folder-open-line text-5xl mb-4"></i>
|
||||||
|
<h3 className="text-xl font-bold mb-2">
|
||||||
|
{locale === 'pt' ? 'Nenhum projeto encontrado' : locale === 'es' ? 'Ningún proyecto encontrado' : 'No projects found'}
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
{searchTerm || selectedCategory !== 'todos'
|
||||||
|
? (locale === 'pt' ? 'Tente ajustar os filtros de busca' : locale === 'es' ? 'Intente ajustar los filtros de búsqueda' : 'Try adjusting your search filters')
|
||||||
|
: (locale === 'pt' ? 'Em breve adicionaremos novos projetos' : locale === 'es' ? 'Pronto agregaremos nuevos proyectos' : 'New projects coming soon')}
|
||||||
|
</p>
|
||||||
|
{(searchTerm || selectedCategory !== 'todos') && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSearchTerm("");
|
||||||
|
setSelectedCategory('todos');
|
||||||
|
}}
|
||||||
|
className="mt-4 px-6 py-2 bg-primary text-white rounded-full font-bold hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
{locale === 'pt' ? 'Limpar filtros' : locale === 'es' ? 'Limpiar filtros' : 'Clear filters'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{filteredProjects.map((project) => {
|
||||||
|
const image = project.coverImage || project.galleryImages[0] || FALLBACK_IMAGE;
|
||||||
|
const description = project.description || (locale === 'pt' ? 'Descrição disponível em breve.' : locale === 'es' ? 'Descripción disponible pronto.' : 'Description coming soon.');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link key={project.id} href={`${prefix}/projetos/${project.id}`} className="group bg-white dark:bg-secondary rounded-xl overflow-hidden shadow-sm hover:shadow-xl transition-all duration-300 border border-gray-100 dark:border-white/10 flex flex-col cursor-pointer">
|
||||||
|
<div className="relative h-64 overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110" style={{ backgroundImage: `url('${image}')` }}></div>
|
||||||
|
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/0 transition-colors"></div>
|
||||||
|
<div className="absolute top-4 left-4 bg-white/90 backdrop-blur-sm px-3 py-1 rounded-md text-xs font-bold text-secondary uppercase tracking-wider">
|
||||||
|
{project.category || t('projects.filters.all')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 grow flex flex-col">
|
||||||
|
<h3 className="text-xl font-bold font-headline text-secondary dark:text-white mb-2 group-hover:text-primary transition-colors">{project.title}</h3>
|
||||||
|
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400 text-sm mb-4">
|
||||||
|
<i className="ri-roadster-line"></i>
|
||||||
|
<span>{project.client || (locale === 'pt' ? 'Cliente confidencial' : locale === 'es' ? 'Cliente confidencial' : 'Confidential client')}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 text-sm mb-6 line-clamp-3 grow">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
<span className="inline-flex items-center gap-2 text-primary font-bold group-hover:gap-3 transition-all mt-auto">
|
||||||
|
{t('projects.viewDetails')} <i className="ri-arrow-right-line"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results Count */}
|
||||||
|
{!loading && filteredProjects.length > 0 && (
|
||||||
|
<div className="text-center mt-12 text-gray-500 dark:text-gray-400">
|
||||||
|
{locale === 'pt' ? 'Exibindo' : locale === 'es' ? 'Mostrando' : 'Showing'} {filteredProjects.length} {locale === 'pt' ? 'de' : locale === 'es' ? 'de' : 'of'} {projects.length} {locale === 'pt' ? 'projetos' : locale === 'es' ? 'proyectos' : 'projects'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
frontend/src/app/[locale]/servicos/page.tsx
Normal file
132
frontend/src/app/[locale]/servicos/page.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useLocale } from "@/contexts/LocaleContext";
|
||||||
|
|
||||||
|
type Service = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
shortDescription: string | null;
|
||||||
|
fullDescription: string | null;
|
||||||
|
active: boolean;
|
||||||
|
order: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ServicosPage() {
|
||||||
|
const { t, locale } = useLocale();
|
||||||
|
const prefix = locale === 'pt' ? '' : `/${locale}`;
|
||||||
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Fallback services usando traduções
|
||||||
|
const fallbackServices = [
|
||||||
|
{
|
||||||
|
icon: "ri-draft-line",
|
||||||
|
title: t('services.technical.title'),
|
||||||
|
shortDescription: t('services.technical.description'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "ri-truck-line",
|
||||||
|
title: t('services.vehicular.title'),
|
||||||
|
shortDescription: t('services.vehicular.description'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "ri-file-paper-2-line",
|
||||||
|
title: t('services.reports.title'),
|
||||||
|
shortDescription: t('services.reports.description'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "ri-tools-fill",
|
||||||
|
title: t('services.consulting.title'),
|
||||||
|
shortDescription: t('services.consulting.description'),
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchServices() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/services');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
// Filtrar apenas serviços ativos e ordenar
|
||||||
|
const activeServices = data
|
||||||
|
.filter((s: Service) => s.active)
|
||||||
|
.sort((a: Service, b: Service) => a.order - b.order);
|
||||||
|
setServices(activeServices);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao carregar serviços:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchServices();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const displayServices = services.length > 0 ? services : fallbackServices;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="bg-white dark:bg-secondary transition-colors duration-300">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<section className="relative h-[400px] flex items-center bg-secondary text-white overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-black/60 z-10"></div>
|
||||||
|
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1581092160562-40aa08e78837?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center"></div>
|
||||||
|
<div className="container mx-auto px-4 relative z-20">
|
||||||
|
<h1 className="text-5xl font-bold font-headline mb-4">{t('services.hero.title')}</h1>
|
||||||
|
<p className="text-xl text-gray-300 max-w-2xl">
|
||||||
|
{t('services.hero.subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Services List */}
|
||||||
|
<section className="py-20 bg-gray-50 dark:bg-[#121212]">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<i className="ri-loader-4-line animate-spin text-4xl text-primary"></i>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
{displayServices.map((service, index) => (
|
||||||
|
<div key={'id' in service ? service.id : index} className="group bg-white dark:bg-secondary rounded-2xl shadow-sm hover:shadow-xl transition-all duration-300 overflow-hidden border border-gray-100 dark:border-white/10 flex flex-col relative">
|
||||||
|
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||||
|
<i className={`${service.icon} text-9xl text-primary`}></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8 relative z-10">
|
||||||
|
<div className="flex justify-between items-start mb-6">
|
||||||
|
<div className="w-16 h-16 bg-primary/10 rounded-xl flex items-center justify-center text-primary group-hover:bg-primary group-hover:text-white transition-colors duration-300 shadow-sm">
|
||||||
|
<i className={`${service.icon} text-3xl`}></i>
|
||||||
|
</div>
|
||||||
|
<span className="text-5xl font-bold text-gray-100 dark:text-white/10 font-headline select-none">0{index + 1}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-2xl font-bold font-headline text-secondary dark:text-white mb-4 group-hover:text-primary transition-colors">
|
||||||
|
{service.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||||
|
{service.shortDescription || ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<section className="py-16 bg-primary text-white text-center">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<h2 className="text-3xl font-bold font-headline mb-6">{t('services.cta.title')}</h2>
|
||||||
|
<Link href={`${prefix}/contato`} className="inline-block px-8 py-3 bg-white text-primary rounded-lg font-bold hover:bg-gray-100 transition-colors">
|
||||||
|
{t('services.cta.button')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
frontend/src/app/[locale]/sobre/page.tsx
Normal file
82
frontend/src/app/[locale]/sobre/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useLocale } from "@/contexts/LocaleContext";
|
||||||
|
|
||||||
|
export default function SobrePage() {
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="bg-white dark:bg-secondary transition-colors duration-300">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<section className="relative h-[400px] flex items-center bg-secondary text-white overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-black/60 z-10"></div>
|
||||||
|
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1504307651254-35680f356dfd?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center"></div>
|
||||||
|
<div className="container mx-auto px-4 relative z-20">
|
||||||
|
<h1 className="text-5xl font-bold font-headline mb-4">{t('about.hero.title')}</h1>
|
||||||
|
<p className="text-xl text-gray-300 max-w-2xl">
|
||||||
|
{t('about.hero.subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* História e Missão */}
|
||||||
|
<section className="py-20 bg-white dark:bg-secondary">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="flex flex-col md:flex-row gap-12 items-center">
|
||||||
|
<div className="w-full md:w-1/2">
|
||||||
|
<h2 className="text-primary font-bold tracking-wider uppercase mb-2">{t('about.history.pretitle')}</h2>
|
||||||
|
<h3 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-6">{t('about.history.title')}</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-300 mb-4 leading-relaxed">
|
||||||
|
{t('about.history.paragraph1')}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600 dark:text-gray-300 mb-4 leading-relaxed">
|
||||||
|
{t('about.history.paragraph2')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-full md:w-1/2 grid grid-cols-2 gap-4">
|
||||||
|
<div className="h-64 rounded-xl bg-gray-200 dark:bg-white/10 overflow-hidden relative">
|
||||||
|
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1581092160562-40aa08e78837?q=80&w=1000&auto=format&fit=crop')] bg-cover bg-center"></div>
|
||||||
|
</div>
|
||||||
|
<div className="h-64 rounded-xl bg-gray-200 dark:bg-white/10 overflow-hidden relative mt-8">
|
||||||
|
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?q=80&w=1000&auto=format&fit=crop')] bg-cover bg-center"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Valores */}
|
||||||
|
<section className="py-20 bg-gray-50 dark:bg-[#121212]">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h2 className="text-primary font-bold tracking-wider uppercase mb-2">{t('about.values.pretitle')}</h2>
|
||||||
|
<h3 className="text-3xl font-bold font-headline text-secondary dark:text-white">{t('about.values.title')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
<div className="bg-white dark:bg-secondary p-8 rounded-xl shadow-sm border-t-4 border-primary">
|
||||||
|
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center text-primary mb-6">
|
||||||
|
<i className="ri-medal-line text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-3">{t('about.values.quality.title')}</h4>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">{t('about.values.quality.description')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-secondary p-8 rounded-xl shadow-sm border-t-4 border-primary">
|
||||||
|
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center text-primary mb-6">
|
||||||
|
<i className="ri-shake-hands-line text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-3">{t('about.values.transparency.title')}</h4>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">{t('about.values.transparency.description')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-secondary p-8 rounded-xl shadow-sm border-t-4 border-primary">
|
||||||
|
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center text-primary mb-6">
|
||||||
|
<i className="ri-leaf-line text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-3">{t('about.values.sustainability.title')}</h4>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">{t('about.values.sustainability.description')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
frontend/src/app/[locale]/termos/page.tsx
Normal file
55
frontend/src/app/[locale]/termos/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useLocale } from "@/contexts/LocaleContext";
|
||||||
|
|
||||||
|
export default function TermsOfUse() {
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="py-20 bg-white dark:bg-secondary transition-colors duration-300">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div className="prose prose-lg text-gray-600 dark:text-gray-300">
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">1. Uso do Site</h2>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">2. Propriedade Intelectual</h2>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">3. Limitação de Responsabilidade</h2>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">4. Links para Terceiros</h2>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">5. Alterações nos Termos</h2>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">6. Legislação Aplicável</h2>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-12">
|
||||||
|
Última atualização: Novembro de 2025.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
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';
|
||||||
|
|
||||||
|
type TranslationSummary = {
|
||||||
|
slug: string;
|
||||||
|
timestamps: Partial<Record<'pt' | 'en' | 'es', string>>;
|
||||||
|
pendingLocales: Array<'en' | 'es'>;
|
||||||
|
};
|
||||||
|
|
||||||
export default function AdminLayout({
|
export default function AdminLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@@ -13,12 +19,65 @@ 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 [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 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 [showNotifications, setShowNotifications] = useState(false);
|
||||||
|
const [translationSummary, setTranslationSummary] = useState<TranslationSummary[]>([]);
|
||||||
|
const [isFetchingTranslations, setIsFetchingTranslations] = useState(false);
|
||||||
|
const notificationsRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const pendingTranslationsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const fetchTranslationStatus = useCallback(async (withLoader = false) => {
|
||||||
|
if (withLoader) {
|
||||||
|
setIsFetchingTranslations(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/translate-pages');
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const pages: Record<string, Partial<Record<'pt' | 'en' | 'es', string>>> = data.pages || {};
|
||||||
|
|
||||||
|
const summary: TranslationSummary[] = Object.entries(pages).map(([slug, timestamps]) => {
|
||||||
|
const pendingLocales: Array<'en' | 'es'> = [];
|
||||||
|
const ptDate = timestamps.pt ? new Date(timestamps.pt) : null;
|
||||||
|
|
||||||
|
(['en', 'es'] as const).forEach((locale) => {
|
||||||
|
const localeDate = timestamps[locale] ? new Date(timestamps[locale] as string) : null;
|
||||||
|
if (ptDate && (!localeDate || localeDate < ptDate)) {
|
||||||
|
pendingLocales.push(locale);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { slug, timestamps, pendingLocales };
|
||||||
|
});
|
||||||
|
|
||||||
|
setTranslationSummary(summary);
|
||||||
|
|
||||||
|
const pendingSlugs = summary.filter((page) => page.pendingLocales.length > 0).map((page) => page.slug);
|
||||||
|
const previousPending = pendingTranslationsRef.current;
|
||||||
|
|
||||||
|
previousPending.forEach((slug) => {
|
||||||
|
if (!pendingSlugs.includes(slug)) {
|
||||||
|
success(`Tradução da página "${slug}" concluída!`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingTranslationsRef.current = new Set(pendingSlugs);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao buscar status das traduções:', err);
|
||||||
|
} finally {
|
||||||
|
if (withLoader) {
|
||||||
|
setIsFetchingTranslations(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [success]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUser = async () => {
|
const fetchUser = async () => {
|
||||||
@@ -27,13 +86,68 @@ export default function AdminLayout({
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setUser(data.user);
|
setUser(data.user);
|
||||||
|
} else {
|
||||||
|
// Não autenticado - redirecionar para login
|
||||||
|
router.push('/acesso');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
console.error('Erro ao buscar dados do usuário:', error);
|
console.error('Erro ao buscar dados do usuário:', err);
|
||||||
|
router.push('/acesso');
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchUser();
|
fetchUser();
|
||||||
}, []);
|
}, [router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchTranslationStatus();
|
||||||
|
const interval = setInterval(() => fetchTranslationStatus(), 10000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [user, fetchTranslationStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => fetchTranslationStatus();
|
||||||
|
window.addEventListener('translation:refresh', handler);
|
||||||
|
return () => window.removeEventListener('translation:refresh', handler);
|
||||||
|
}, [fetchTranslationStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showNotifications) return;
|
||||||
|
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
if (notificationsRef.current && !notificationsRef.current.contains(event.target as Node)) {
|
||||||
|
setShowNotifications(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
|
}, [showNotifications]);
|
||||||
|
|
||||||
|
// Mostrar loading enquanto verifica autenticação
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-[#121212] flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">Verificando autenticação...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se não tem usuário após loading, não renderizar nada (está redirecionando)
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -113,6 +227,8 @@ export default function AdminLayout({
|
|||||||
{ icon: 'ri-settings-3-line', label: 'Configurações', href: '/admin/configuracoes' },
|
{ icon: 'ri-settings-3-line', label: 'Configurações', href: '/admin/configuracoes' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const pendingCount = translationSummary.filter((page) => page.pendingLocales.length > 0).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-[#121212] flex">
|
<div className="min-h-screen bg-gray-50 dark:bg-[#121212] flex">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
@@ -168,6 +284,68 @@ export default function AdminLayout({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
<div ref={notificationsRef} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowNotifications((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
if (!prev) {
|
||||||
|
fetchTranslationStatus();
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="relative 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-gray-300 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<i className="ri-notification-3-line text-xl"></i>
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 min-w-[18px] h-[18px] text-[11px] font-bold rounded-full bg-primary text-white flex items-center justify-center px-1">
|
||||||
|
{pendingCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showNotifications && (
|
||||||
|
<div className="absolute right-0 mt-3 w-80 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl shadow-xl z-50">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-100 dark:border-white/10">
|
||||||
|
<p className="font-semibold text-sm text-secondary dark:text-white">Traduções</p>
|
||||||
|
<button
|
||||||
|
onClick={() => fetchTranslationStatus(true)}
|
||||||
|
className="text-xs text-primary hover:text-secondary dark:hover:text-white font-semibold"
|
||||||
|
disabled={isFetchingTranslations}
|
||||||
|
>
|
||||||
|
{isFetchingTranslations ? 'Atualizando...' : 'Atualizar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-72 overflow-y-auto divide-y divide-gray-100 dark:divide-white/10">
|
||||||
|
{translationSummary.length === 0 ? (
|
||||||
|
<p className="px-4 py-6 text-sm text-gray-500 dark:text-gray-400">Nenhuma tradução registrada.</p>
|
||||||
|
) : (
|
||||||
|
translationSummary.map((page) => (
|
||||||
|
<div key={page.slug} className="px-4 py-3 text-sm flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-secondary dark:text-white capitalize">{page.slug}</p>
|
||||||
|
{page.pendingLocales.length > 0 ? (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Atualizando {page.pendingLocales.map((loc) => loc.toUpperCase()).join(', ')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Tudo traduzido</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{page.pendingLocales.length > 0 ? (
|
||||||
|
<span className="text-xs font-semibold text-primary bg-primary/10 px-2.5 py-1 rounded-full">Em andamento</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-semibold text-emerald-600 bg-emerald-100/80 dark:bg-emerald-900/30 px-2.5 py-1 rounded-full">Concluída</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pl-4 border-l border-gray-200 dark:border-white/10">
|
<div className="flex items-center gap-3 pl-4 border-l border-gray-200 dark:border-white/10">
|
||||||
<div className="text-right hidden sm:block">
|
<div className="text-right hidden sm:block">
|
||||||
<p className="text-sm font-bold text-secondary dark:text-white">{user?.name || 'Carregando...'}</p>
|
<p className="text-sm font-bold text-secondary dark:text-white">{user?.name || 'Carregando...'}</p>
|
||||||
|
|||||||
@@ -1,4 +1,146 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
status: string;
|
||||||
|
coverImage: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Service {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Contact {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
subject: string | null;
|
||||||
|
message: string;
|
||||||
|
createdAt: string;
|
||||||
|
read: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
projects: number;
|
||||||
|
activeProjects: number;
|
||||||
|
services: number;
|
||||||
|
activeServices: number;
|
||||||
|
contacts: number;
|
||||||
|
unreadContacts: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminDashboard() {
|
export default function AdminDashboard() {
|
||||||
|
const [stats, setStats] = useState<Stats>({
|
||||||
|
projects: 0,
|
||||||
|
activeProjects: 0,
|
||||||
|
services: 0,
|
||||||
|
activeServices: 0,
|
||||||
|
contacts: 0,
|
||||||
|
unreadContacts: 0,
|
||||||
|
});
|
||||||
|
const [recentProjects, setRecentProjects] = useState<Project[]>([]);
|
||||||
|
const [recentContacts, setRecentContacts] = useState<Contact[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchData() {
|
||||||
|
try {
|
||||||
|
// Buscar projetos
|
||||||
|
const projectsRes = await fetch('/api/projects');
|
||||||
|
const projects: Project[] = projectsRes.ok ? await projectsRes.json() : [];
|
||||||
|
|
||||||
|
// Buscar serviços
|
||||||
|
const servicesRes = await fetch('/api/services');
|
||||||
|
const services: Service[] = servicesRes.ok ? await servicesRes.json() : [];
|
||||||
|
|
||||||
|
// Buscar contatos
|
||||||
|
const contactsRes = await fetch('/api/contacts');
|
||||||
|
const contacts: Contact[] = contactsRes.ok ? await contactsRes.json() : [];
|
||||||
|
|
||||||
|
// Calcular estatísticas
|
||||||
|
setStats({
|
||||||
|
projects: projects.length,
|
||||||
|
activeProjects: projects.filter(p => p.status === 'Concluído' || p.status === 'Em andamento').length,
|
||||||
|
services: services.length,
|
||||||
|
activeServices: services.filter(s => s.active).length,
|
||||||
|
contacts: contacts.length,
|
||||||
|
unreadContacts: contacts.filter(c => !c.read).length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Projetos recentes (últimos 5)
|
||||||
|
setRecentProjects(
|
||||||
|
projects
|
||||||
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
|
.slice(0, 5)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Contatos recentes (últimos 5)
|
||||||
|
setRecentContacts(
|
||||||
|
contacts
|
||||||
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
|
.slice(0, 5)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao carregar dados do dashboard:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatTimeAgo = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 60) return `Há ${diffMins} min`;
|
||||||
|
if (diffHours < 24) return `Há ${diffHours}h`;
|
||||||
|
if (diffDays < 7) return `Há ${diffDays}d`;
|
||||||
|
return date.toLocaleDateString('pt-BR');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInitials = (name: string) => {
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map(n => n[0])
|
||||||
|
.slice(0, 2)
|
||||||
|
.join('')
|
||||||
|
.toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusStyle = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'Concluído':
|
||||||
|
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
|
||||||
|
case 'Em andamento':
|
||||||
|
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<i className="ri-loader-4-line animate-spin text-4xl text-primary"></i>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
@@ -7,71 +149,124 @@ export default function AdminDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
{[
|
<Link href="/admin/projetos" 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">
|
||||||
{ label: 'Projetos Ativos', value: '12', icon: 'ri-briefcase-line', color: 'text-blue-500', bg: 'bg-blue-50 dark:bg-blue-900/20' },
|
|
||||||
{ label: 'Mensagens Novas', value: '5', icon: 'ri-message-3-line', color: 'text-green-500', bg: 'bg-green-50 dark:bg-green-900/20' },
|
|
||||||
{ label: 'Serviços', value: '8', icon: 'ri-tools-line', color: 'text-orange-500', bg: 'bg-orange-50 dark:bg-orange-900/20' },
|
|
||||||
{ label: 'Visitas Hoje', value: '145', icon: 'ri-eye-line', color: 'text-purple-500', bg: 'bg-purple-50 dark:bg-purple-900/20' },
|
|
||||||
].map((stat, index) => (
|
|
||||||
<div key={index} 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 ${stat.bg} ${stat.color}`}>
|
<div className="w-12 h-12 rounded-xl flex items-center justify-center bg-blue-50 dark:bg-blue-900/20 text-blue-500">
|
||||||
<i className={`${stat.icon} text-2xl`}></i>
|
<i className="ri-briefcase-line text-2xl"></i>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-2xl font-bold text-secondary dark:text-white">{stat.value}</span>
|
<span className="text-2xl font-bold text-secondary dark:text-white">{stats.projects}</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-gray-500 dark:text-gray-400 font-medium">{stat.label}</h3>
|
<h3 className="text-gray-500 dark:text-gray-400 font-medium">Projetos</h3>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{stats.activeProjects} ativos</p>
|
||||||
|
</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">
|
||||||
|
<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">
|
||||||
|
<i className="ri-message-3-line text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold text-secondary dark:text-white">{stats.contacts}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-gray-500 dark:text-gray-400 font-medium">Mensagens</h3>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{stats.unreadContacts} não lidas</p>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/admin/servicos" 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="w-12 h-12 rounded-xl flex items-center justify-center bg-orange-50 dark:bg-orange-900/20 text-orange-500">
|
||||||
|
<i className="ri-tools-line text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold text-secondary dark:text-white">{stats.services}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-gray-500 dark:text-gray-400 font-medium">Serviços</h3>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{stats.activeServices} ativos</p>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<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-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl flex items-center justify-center bg-purple-50 dark:bg-purple-900/20 text-purple-500">
|
||||||
|
<i className="ri-eye-line text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold text-secondary dark:text-white">—</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-gray-500 dark:text-gray-400 font-medium">Visitas</h3>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">Em breve</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<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>
|
||||||
<button className="text-primary text-sm font-bold hover:underline cursor-pointer">Ver todas</button>
|
<Link href="/admin/contatos" className="text-primary text-sm font-bold hover:underline">Ver todas</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{[1, 2, 3].map((i) => (
|
{recentContacts.length === 0 ? (
|
||||||
<div key={i} 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">
|
<p className="text-gray-500 dark:text-gray-400 text-center py-8">Nenhuma mensagem recebida.</p>
|
||||||
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-white/10 flex items-center justify-center shrink-0">
|
) : (
|
||||||
<span className="font-bold text-gray-500 dark:text-gray-400">JD</span>
|
recentContacts.map((contact) => (
|
||||||
|
<Link
|
||||||
|
key={contact.id}
|
||||||
|
href="/admin/contatos"
|
||||||
|
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">
|
||||||
|
<span className="font-bold text-primary text-sm">{getInitials(contact.name)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<h4 className="font-bold text-secondary dark:text-white text-sm">João da Silva</h4>
|
<h4 className="font-bold text-secondary dark:text-white text-sm truncate">
|
||||||
<span className="text-xs text-gray-400">Há 2 horas</span>
|
{contact.name}
|
||||||
|
{!contact.read && (
|
||||||
|
<span className="ml-2 w-2 h-2 bg-primary rounded-full inline-block"></span>
|
||||||
|
)}
|
||||||
|
</h4>
|
||||||
|
<span className="text-xs text-gray-400 shrink-0 ml-2">{formatTimeAgo(contact.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
|
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||||
Gostaria de solicitar um orçamento para adequação de frota conforme NR-12...
|
{contact.message}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">Projetos Recentes</h3>
|
<h3 className="text-lg font-bold text-secondary dark:text-white">Projetos Recentes</h3>
|
||||||
<button className="text-primary text-sm font-bold hover:underline cursor-pointer">Ver todos</button>
|
<Link href="/admin/projetos" className="text-primary text-sm font-bold hover:underline">Ver todos</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{[1, 2, 3].map((i) => (
|
{recentProjects.length === 0 ? (
|
||||||
<div key={i} className="flex items-center 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">
|
<p className="text-gray-500 dark:text-gray-400 text-center py-8">Nenhum projeto cadastrado.</p>
|
||||||
<div className="w-16 h-12 rounded-lg bg-gray-200 dark:bg-white/10 overflow-hidden">
|
) : (
|
||||||
{/* Placeholder image */}
|
recentProjects.map((project) => (
|
||||||
<div className="w-full h-full bg-gray-300 dark:bg-white/20"></div>
|
<Link
|
||||||
|
key={project.id}
|
||||||
|
href={`/admin/projetos/${project.id}/editar`}
|
||||||
|
className="flex items-center 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-16 h-12 rounded-lg bg-gray-200 dark:bg-white/10 overflow-hidden shrink-0">
|
||||||
|
{project.coverImage ? (
|
||||||
|
<img src={project.coverImage} alt={project.title} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-gray-300 dark:bg-white/20 flex items-center justify-center">
|
||||||
|
<i className="ri-image-line text-gray-400"></i>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
)}
|
||||||
<h4 className="font-bold text-secondary dark:text-white text-sm">Adequação Coca-Cola</h4>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">Engenharia Veicular</p>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="px-3 py-1 rounded-full text-xs font-bold bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
<div className="flex-1 min-w-0">
|
||||||
Concluído
|
<h4 className="font-bold text-secondary dark:text-white text-sm truncate">{project.title}</h4>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">{project.category}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`px-3 py-1 rounded-full text-xs font-bold shrink-0 ${getStatusStyle(project.status)}`}>
|
||||||
|
{project.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</Link>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useToast } from '@/contexts/ToastContext';
|
import { useToast } from '@/contexts/ToastContext';
|
||||||
|
import { CharLimitBadge } from '@/components/admin/CharLimitBadge';
|
||||||
|
|
||||||
const AVAILABLE_ICONS = [
|
const AVAILABLE_ICONS = [
|
||||||
// Pessoas e Equipe
|
// Pessoas e Equipe
|
||||||
@@ -167,6 +168,34 @@ function IconSelector({ value, onChange, label }: IconSelectorProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
interface ContactInfo {
|
||||||
icon: string;
|
icon: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -281,7 +310,11 @@ export default function EditContactPage() {
|
|||||||
|
|
||||||
if (!response.ok) throw new Error('Erro ao salvar');
|
if (!response.ok) throw new Error('Erro ao salvar');
|
||||||
|
|
||||||
success('Conteúdo da página Contato atualizado com sucesso!');
|
await response.json();
|
||||||
|
success('Conteúdo salvo com sucesso!');
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new Event('translation:refresh'));
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError('Erro ao salvar alterações');
|
showError('Erro ao salvar alterações');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -380,31 +413,46 @@ export default function EditContactPage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6">
|
<div className="grid grid-cols-1 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Pré-título</label>
|
<LabelWithLimit
|
||||||
|
label="Pré-título"
|
||||||
|
value={formData.hero.pretitle}
|
||||||
|
limit={CONTACT_TEXT_LIMITS.hero.pretitle}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.hero.pretitle}
|
value={formData.hero.pretitle}
|
||||||
onChange={(e) => setFormData({...formData, hero: {...formData.hero, pretitle: e.target.value}})}
|
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"
|
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>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título Principal</label>
|
<LabelWithLimit
|
||||||
|
label="Título Principal"
|
||||||
|
value={formData.hero.title}
|
||||||
|
limit={CONTACT_TEXT_LIMITS.hero.title}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.hero.title}
|
value={formData.hero.title}
|
||||||
onChange={(e) => setFormData({...formData, hero: {...formData.hero, title: e.target.value}})}
|
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"
|
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>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Subtítulo</label>
|
<LabelWithLimit
|
||||||
|
label="Subtítulo"
|
||||||
|
value={formData.hero.subtitle}
|
||||||
|
limit={CONTACT_TEXT_LIMITS.hero.subtitle}
|
||||||
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
value={formData.hero.subtitle}
|
value={formData.hero.subtitle}
|
||||||
onChange={(e) => setFormData({...formData, hero: {...formData.hero, subtitle: e.target.value}})}
|
onChange={(e) => setFormData({...formData, hero: {...formData.hero, subtitle: e.target.value}})}
|
||||||
rows={2}
|
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"
|
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>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
@@ -422,29 +470,44 @@ export default function EditContactPage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 mb-6">
|
<div className="grid grid-cols-1 gap-6 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Pré-título</label>
|
<LabelWithLimit
|
||||||
|
label="Pré-título"
|
||||||
|
value={formData.info.title}
|
||||||
|
limit={CONTACT_TEXT_LIMITS.info.title}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.info.title}
|
value={formData.info.title}
|
||||||
onChange={(e) => setFormData({...formData, info: {...formData.info, title: e.target.value}})}
|
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"
|
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>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título da Seção</label>
|
<LabelWithLimit
|
||||||
|
label="Título da Seção"
|
||||||
|
value={formData.info.subtitle}
|
||||||
|
limit={CONTACT_TEXT_LIMITS.info.subtitle}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.info.subtitle}
|
value={formData.info.subtitle}
|
||||||
onChange={(e) => setFormData({...formData, info: {...formData.info, subtitle: e.target.value}})}
|
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"
|
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>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Descrição</label>
|
<LabelWithLimit
|
||||||
|
label="Descrição"
|
||||||
|
value={formData.info.description}
|
||||||
|
limit={CONTACT_TEXT_LIMITS.info.description}
|
||||||
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
value={formData.info.description}
|
value={formData.info.description}
|
||||||
onChange={(e) => setFormData({...formData, info: {...formData.info, description: e.target.value}})}
|
onChange={(e) => setFormData({...formData, info: {...formData.info, description: e.target.value}})}
|
||||||
rows={2}
|
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"
|
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>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
@@ -467,7 +530,11 @@ export default function EditContactPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Título</label>
|
<LabelWithLimit
|
||||||
|
label="Título"
|
||||||
|
value={item.title}
|
||||||
|
limit={CONTACT_TEXT_LIMITS.info.itemTitle}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={item.title}
|
value={item.title}
|
||||||
@@ -476,11 +543,16 @@ export default function EditContactPage() {
|
|||||||
newItems[index].title = e.target.value;
|
newItems[index].title = e.target.value;
|
||||||
setFormData({...formData, info: {...formData.info, items: newItems}});
|
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"
|
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>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Descrição</label>
|
<LabelWithLimit
|
||||||
|
label="Descrição"
|
||||||
|
value={item.description}
|
||||||
|
limit={CONTACT_TEXT_LIMITS.info.itemDescription}
|
||||||
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
value={item.description}
|
value={item.description}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -489,11 +561,16 @@ export default function EditContactPage() {
|
|||||||
setFormData({...formData, info: {...formData.info, items: newItems}});
|
setFormData({...formData, info: {...formData.info, items: newItems}});
|
||||||
}}
|
}}
|
||||||
rows={3}
|
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"
|
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>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Link</label>
|
<LabelWithLimit
|
||||||
|
label="Link"
|
||||||
|
value={item.link}
|
||||||
|
limit={CONTACT_TEXT_LIMITS.info.link}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={item.link}
|
value={item.link}
|
||||||
@@ -503,11 +580,16 @@ export default function EditContactPage() {
|
|||||||
setFormData({...formData, info: {...formData.info, items: newItems}});
|
setFormData({...formData, info: {...formData.info, items: newItems}});
|
||||||
}}
|
}}
|
||||||
placeholder="https://..."
|
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"
|
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>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Texto do Link</label>
|
<LabelWithLimit
|
||||||
|
label="Texto do Link"
|
||||||
|
value={item.linkText}
|
||||||
|
limit={CONTACT_TEXT_LIMITS.info.linkText}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={item.linkText}
|
value={item.linkText}
|
||||||
@@ -516,6 +598,7 @@ export default function EditContactPage() {
|
|||||||
newItems[index].linkText = e.target.value;
|
newItems[index].linkText = e.target.value;
|
||||||
setFormData({...formData, info: {...formData.info, items: newItems}});
|
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"
|
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>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useToast } from '@/contexts/ToastContext';
|
import { useToast } from '@/contexts/ToastContext';
|
||||||
|
import { CharLimitBadge } from '@/components/admin/CharLimitBadge';
|
||||||
|
|
||||||
// Ícones pré-definidos para seleção
|
// Ícones pré-definidos para seleção
|
||||||
const AVAILABLE_ICONS = [
|
const AVAILABLE_ICONS = [
|
||||||
@@ -171,6 +172,60 @@ function IconSelector({ value, onChange, label }: IconSelectorProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HOME_TEXT_LIMITS = {
|
||||||
|
hero: { title: 70, subtitle: 200, buttonText: 24 },
|
||||||
|
features: {
|
||||||
|
pretitle: 40,
|
||||||
|
title: 70,
|
||||||
|
itemTitle: 40,
|
||||||
|
itemDescription: 120,
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
pretitle: 40,
|
||||||
|
title: 70,
|
||||||
|
itemTitle: 40,
|
||||||
|
itemDescription: 120,
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
pretitle: 40,
|
||||||
|
title: 70,
|
||||||
|
description: 260,
|
||||||
|
highlight: 70,
|
||||||
|
},
|
||||||
|
testimonials: {
|
||||||
|
pretitle: 40,
|
||||||
|
title: 70,
|
||||||
|
name: 36,
|
||||||
|
role: 60,
|
||||||
|
text: 180,
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
clients: 10,
|
||||||
|
projects: 10,
|
||||||
|
years: 6,
|
||||||
|
},
|
||||||
|
cta: {
|
||||||
|
title: 90,
|
||||||
|
text: 180,
|
||||||
|
button: 24,
|
||||||
|
},
|
||||||
|
} 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 FeatureItem {
|
interface FeatureItem {
|
||||||
icon: string;
|
icon: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -346,7 +401,11 @@ export default function EditHomePage() {
|
|||||||
|
|
||||||
if (!response.ok) throw new Error('Erro ao salvar');
|
if (!response.ok) throw new Error('Erro ao salvar');
|
||||||
|
|
||||||
success('Conteúdo da página inicial atualizado com sucesso!');
|
await response.json();
|
||||||
|
success('Conteúdo salvo com sucesso!');
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new Event('translation:refresh'));
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError('Erro ao salvar alterações');
|
showError('Erro ao salvar alterações');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -500,31 +559,46 @@ export default function EditHomePage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6">
|
<div className="grid grid-cols-1 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título Principal</label>
|
<LabelWithLimit
|
||||||
|
label="Título Principal"
|
||||||
|
value={formData.hero.title}
|
||||||
|
limit={HOME_TEXT_LIMITS.hero.title}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.hero.title}
|
value={formData.hero.title}
|
||||||
onChange={(e) => setFormData({...formData, hero: {...formData.hero, title: e.target.value}})}
|
onChange={(e) => setFormData({...formData, hero: {...formData.hero, title: e.target.value}})}
|
||||||
|
maxLength={HOME_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"
|
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>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Subtítulo</label>
|
<LabelWithLimit
|
||||||
|
label="Subtítulo"
|
||||||
|
value={formData.hero.subtitle}
|
||||||
|
limit={HOME_TEXT_LIMITS.hero.subtitle}
|
||||||
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
value={formData.hero.subtitle}
|
value={formData.hero.subtitle}
|
||||||
onChange={(e) => setFormData({...formData, hero: {...formData.hero, subtitle: e.target.value}})}
|
onChange={(e) => setFormData({...formData, hero: {...formData.hero, subtitle: e.target.value}})}
|
||||||
rows={3}
|
rows={3}
|
||||||
|
maxLength={HOME_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"
|
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>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Texto do Botão</label>
|
<LabelWithLimit
|
||||||
|
label="Texto do Botão"
|
||||||
|
value={formData.hero.buttonText}
|
||||||
|
limit={HOME_TEXT_LIMITS.hero.buttonText}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.hero.buttonText}
|
value={formData.hero.buttonText}
|
||||||
onChange={(e) => setFormData({...formData, hero: {...formData.hero, buttonText: e.target.value}})}
|
onChange={(e) => setFormData({...formData, hero: {...formData.hero, buttonText: e.target.value}})}
|
||||||
|
maxLength={HOME_TEXT_LIMITS.hero.buttonText}
|
||||||
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>
|
||||||
@@ -542,20 +616,30 @@ export default function EditHomePage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 mb-6">
|
<div className="grid grid-cols-1 gap-6 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Pré-título</label>
|
<LabelWithLimit
|
||||||
|
label="Pré-título"
|
||||||
|
value={formData.features.pretitle}
|
||||||
|
limit={HOME_TEXT_LIMITS.features.pretitle}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.features.pretitle}
|
value={formData.features.pretitle}
|
||||||
onChange={(e) => setFormData({...formData, features: {...formData.features, pretitle: e.target.value}})}
|
onChange={(e) => setFormData({...formData, features: {...formData.features, pretitle: e.target.value}})}
|
||||||
|
maxLength={HOME_TEXT_LIMITS.features.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"
|
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>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título da Seção</label>
|
<LabelWithLimit
|
||||||
|
label="Título da Seção"
|
||||||
|
value={formData.features.title}
|
||||||
|
limit={HOME_TEXT_LIMITS.features.title}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.features.title}
|
value={formData.features.title}
|
||||||
onChange={(e) => setFormData({...formData, features: {...formData.features, title: e.target.value}})}
|
onChange={(e) => setFormData({...formData, features: {...formData.features, title: e.target.value}})}
|
||||||
|
maxLength={HOME_TEXT_LIMITS.features.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"
|
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>
|
||||||
@@ -578,7 +662,11 @@ export default function EditHomePage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Título</label>
|
<LabelWithLimit
|
||||||
|
label="Título"
|
||||||
|
value={item.title}
|
||||||
|
limit={HOME_TEXT_LIMITS.features.itemTitle}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={item.title}
|
value={item.title}
|
||||||
@@ -587,11 +675,16 @@ export default function EditHomePage() {
|
|||||||
newItems[index].title = e.target.value;
|
newItems[index].title = e.target.value;
|
||||||
setFormData({...formData, features: {...formData.features, items: newItems}});
|
setFormData({...formData, features: {...formData.features, items: newItems}});
|
||||||
}}
|
}}
|
||||||
|
maxLength={HOME_TEXT_LIMITS.features.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"
|
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>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Descrição</label>
|
<LabelWithLimit
|
||||||
|
label="Descrição"
|
||||||
|
value={item.description}
|
||||||
|
limit={HOME_TEXT_LIMITS.features.itemDescription}
|
||||||
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
value={item.description}
|
value={item.description}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -600,6 +693,7 @@ export default function EditHomePage() {
|
|||||||
setFormData({...formData, features: {...formData.features, items: newItems}});
|
setFormData({...formData, features: {...formData.features, items: newItems}});
|
||||||
}}
|
}}
|
||||||
rows={2}
|
rows={2}
|
||||||
|
maxLength={HOME_TEXT_LIMITS.features.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"
|
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>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
@@ -620,20 +714,30 @@ export default function EditHomePage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 mb-6">
|
<div className="grid grid-cols-1 gap-6 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Pré-título</label>
|
<LabelWithLimit
|
||||||
|
label="Pré-título"
|
||||||
|
value={formData.services.pretitle}
|
||||||
|
limit={HOME_TEXT_LIMITS.services.pretitle}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.services.pretitle}
|
value={formData.services.pretitle}
|
||||||
onChange={(e) => setFormData({...formData, services: {...formData.services, pretitle: e.target.value}})}
|
onChange={(e) => setFormData({...formData, services: {...formData.services, pretitle: e.target.value}})}
|
||||||
|
maxLength={HOME_TEXT_LIMITS.services.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"
|
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>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título da Seção</label>
|
<LabelWithLimit
|
||||||
|
label="Título da Seção"
|
||||||
|
value={formData.services.title}
|
||||||
|
limit={HOME_TEXT_LIMITS.services.title}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.services.title}
|
value={formData.services.title}
|
||||||
onChange={(e) => setFormData({...formData, services: {...formData.services, title: e.target.value}})}
|
onChange={(e) => setFormData({...formData, services: {...formData.services, title: e.target.value}})}
|
||||||
|
maxLength={HOME_TEXT_LIMITS.services.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"
|
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>
|
||||||
@@ -656,7 +760,11 @@ export default function EditHomePage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Título</label>
|
<LabelWithLimit
|
||||||
|
label="Título"
|
||||||
|
value={item.title}
|
||||||
|
limit={HOME_TEXT_LIMITS.services.itemTitle}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={item.title}
|
value={item.title}
|
||||||
@@ -665,11 +773,16 @@ export default function EditHomePage() {
|
|||||||
newItems[index].title = e.target.value;
|
newItems[index].title = e.target.value;
|
||||||
setFormData({...formData, services: {...formData.services, items: newItems}});
|
setFormData({...formData, services: {...formData.services, items: newItems}});
|
||||||
}}
|
}}
|
||||||
|
maxLength={HOME_TEXT_LIMITS.services.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"
|
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>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Descrição</label>
|
<LabelWithLimit
|
||||||
|
label="Descrição"
|
||||||
|
value={item.description}
|
||||||
|
limit={HOME_TEXT_LIMITS.services.itemDescription}
|
||||||
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
value={item.description}
|
value={item.description}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -678,6 +791,7 @@ export default function EditHomePage() {
|
|||||||
setFormData({...formData, services: {...formData.services, items: newItems}});
|
setFormData({...formData, services: {...formData.services, items: newItems}});
|
||||||
}}
|
}}
|
||||||
rows={2}
|
rows={2}
|
||||||
|
maxLength={HOME_TEXT_LIMITS.services.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"
|
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>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
@@ -699,36 +813,56 @@ export default function EditHomePage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6">
|
<div className="grid grid-cols-1 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Pré-título</label>
|
<LabelWithLimit
|
||||||
|
label="Pré-título"
|
||||||
|
value={formData.about.pretitle}
|
||||||
|
limit={HOME_TEXT_LIMITS.about.pretitle}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.about.pretitle}
|
value={formData.about.pretitle}
|
||||||
onChange={(e) => setFormData({...formData, about: {...formData.about, pretitle: e.target.value}})}
|
onChange={(e) => setFormData({...formData, about: {...formData.about, pretitle: e.target.value}})}
|
||||||
|
maxLength={HOME_TEXT_LIMITS.about.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"
|
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>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título da Seção</label>
|
<LabelWithLimit
|
||||||
|
label="Título da Seção"
|
||||||
|
value={formData.about.title}
|
||||||
|
limit={HOME_TEXT_LIMITS.about.title}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.about.title}
|
value={formData.about.title}
|
||||||
onChange={(e) => setFormData({...formData, about: {...formData.about, title: e.target.value}})}
|
onChange={(e) => setFormData({...formData, about: {...formData.about, title: e.target.value}})}
|
||||||
|
maxLength={HOME_TEXT_LIMITS.about.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"
|
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>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Descrição</label>
|
<LabelWithLimit
|
||||||
|
label="Descrição"
|
||||||
|
value={formData.about.description}
|
||||||
|
limit={HOME_TEXT_LIMITS.about.description}
|
||||||
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
value={formData.about.description}
|
value={formData.about.description}
|
||||||
onChange={(e) => setFormData({...formData, about: {...formData.about, description: e.target.value}})}
|
onChange={(e) => setFormData({...formData, about: {...formData.about, description: e.target.value}})}
|
||||||
rows={4}
|
rows={4}
|
||||||
|
maxLength={HOME_TEXT_LIMITS.about.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"
|
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>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Destaques</label>
|
<span className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Destaques</span>
|
||||||
{formData.about.highlights.map((highlight, index) => (
|
{formData.about.highlights.map((highlight, index) => (
|
||||||
<div key={index} className="mb-3">
|
<div key={index} className="mb-3">
|
||||||
|
<LabelWithLimit
|
||||||
|
label={`Destaque ${index + 1}`}
|
||||||
|
value={highlight}
|
||||||
|
limit={HOME_TEXT_LIMITS.about.highlight}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={highlight}
|
value={highlight}
|
||||||
@@ -738,6 +872,7 @@ export default function EditHomePage() {
|
|||||||
setFormData({...formData, about: {...formData.about, highlights: newHighlights}});
|
setFormData({...formData, about: {...formData.about, highlights: newHighlights}});
|
||||||
}}
|
}}
|
||||||
placeholder={`Destaque ${index + 1}`}
|
placeholder={`Destaque ${index + 1}`}
|
||||||
|
maxLength={HOME_TEXT_LIMITS.about.highlight}
|
||||||
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>
|
||||||
@@ -758,20 +893,30 @@ export default function EditHomePage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 mb-6">
|
<div className="grid grid-cols-1 gap-6 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Pré-título</label>
|
<LabelWithLimit
|
||||||
|
label="Pré-título"
|
||||||
|
value={formData.testimonials.pretitle}
|
||||||
|
limit={HOME_TEXT_LIMITS.testimonials.pretitle}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.testimonials.pretitle}
|
value={formData.testimonials.pretitle}
|
||||||
onChange={(e) => setFormData({...formData, testimonials: {...formData.testimonials, pretitle: e.target.value}})}
|
onChange={(e) => setFormData({...formData, testimonials: {...formData.testimonials, pretitle: e.target.value}})}
|
||||||
|
maxLength={HOME_TEXT_LIMITS.testimonials.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"
|
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>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título da Seção</label>
|
<LabelWithLimit
|
||||||
|
label="Título da Seção"
|
||||||
|
value={formData.testimonials.title}
|
||||||
|
limit={HOME_TEXT_LIMITS.testimonials.title}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.testimonials.title}
|
value={formData.testimonials.title}
|
||||||
onChange={(e) => setFormData({...formData, testimonials: {...formData.testimonials, title: e.target.value}})}
|
onChange={(e) => setFormData({...formData, testimonials: {...formData.testimonials, title: e.target.value}})}
|
||||||
|
maxLength={HOME_TEXT_LIMITS.testimonials.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"
|
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>
|
||||||
@@ -785,7 +930,11 @@ export default function EditHomePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Nome</label>
|
<LabelWithLimit
|
||||||
|
label="Nome"
|
||||||
|
value={item.name}
|
||||||
|
limit={HOME_TEXT_LIMITS.testimonials.name}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={item.name}
|
value={item.name}
|
||||||
@@ -794,11 +943,16 @@ export default function EditHomePage() {
|
|||||||
newItems[index].name = e.target.value;
|
newItems[index].name = e.target.value;
|
||||||
setFormData({...formData, testimonials: {...formData.testimonials, items: newItems}});
|
setFormData({...formData, testimonials: {...formData.testimonials, items: newItems}});
|
||||||
}}
|
}}
|
||||||
|
maxLength={HOME_TEXT_LIMITS.testimonials.name}
|
||||||
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"
|
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>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Cargo/Empresa</label>
|
<LabelWithLimit
|
||||||
|
label="Cargo/Empresa"
|
||||||
|
value={item.role}
|
||||||
|
limit={HOME_TEXT_LIMITS.testimonials.role}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={item.role}
|
value={item.role}
|
||||||
@@ -807,11 +961,16 @@ export default function EditHomePage() {
|
|||||||
newItems[index].role = e.target.value;
|
newItems[index].role = e.target.value;
|
||||||
setFormData({...formData, testimonials: {...formData.testimonials, items: newItems}});
|
setFormData({...formData, testimonials: {...formData.testimonials, items: newItems}});
|
||||||
}}
|
}}
|
||||||
|
maxLength={HOME_TEXT_LIMITS.testimonials.role}
|
||||||
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"
|
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>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Depoimento</label>
|
<LabelWithLimit
|
||||||
|
label="Depoimento"
|
||||||
|
value={item.text}
|
||||||
|
limit={HOME_TEXT_LIMITS.testimonials.text}
|
||||||
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
value={item.text}
|
value={item.text}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -820,6 +979,7 @@ export default function EditHomePage() {
|
|||||||
setFormData({...formData, testimonials: {...formData.testimonials, items: newItems}});
|
setFormData({...formData, testimonials: {...formData.testimonials, items: newItems}});
|
||||||
}}
|
}}
|
||||||
rows={3}
|
rows={3}
|
||||||
|
maxLength={HOME_TEXT_LIMITS.testimonials.text}
|
||||||
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"
|
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>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
@@ -841,31 +1001,46 @@ export default function EditHomePage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Clientes Atendidos</label>
|
<LabelWithLimit
|
||||||
|
label="Clientes Atendidos"
|
||||||
|
value={formData.stats.clients}
|
||||||
|
limit={HOME_TEXT_LIMITS.stats.clients}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.stats.clients}
|
value={formData.stats.clients}
|
||||||
onChange={(e) => setFormData({...formData, stats: {...formData.stats, clients: e.target.value}})}
|
onChange={(e) => setFormData({...formData, stats: {...formData.stats, clients: e.target.value}})}
|
||||||
|
maxLength={HOME_TEXT_LIMITS.stats.clients}
|
||||||
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>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Projetos Realizados</label>
|
<LabelWithLimit
|
||||||
|
label="Projetos Realizados"
|
||||||
|
value={formData.stats.projects}
|
||||||
|
limit={HOME_TEXT_LIMITS.stats.projects}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.stats.projects}
|
value={formData.stats.projects}
|
||||||
onChange={(e) => setFormData({...formData, stats: {...formData.stats, projects: e.target.value}})}
|
onChange={(e) => setFormData({...formData, stats: {...formData.stats, projects: e.target.value}})}
|
||||||
|
maxLength={HOME_TEXT_LIMITS.stats.projects}
|
||||||
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>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Anos de Experiência</label>
|
<LabelWithLimit
|
||||||
|
label="Anos de Experiência"
|
||||||
|
value={formData.stats.years}
|
||||||
|
limit={HOME_TEXT_LIMITS.stats.years}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.stats.years}
|
value={formData.stats.years}
|
||||||
onChange={(e) => setFormData({...formData, stats: {...formData.stats, years: e.target.value}})}
|
onChange={(e) => setFormData({...formData, stats: {...formData.stats, years: e.target.value}})}
|
||||||
|
maxLength={HOME_TEXT_LIMITS.stats.years}
|
||||||
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>
|
||||||
@@ -884,31 +1059,46 @@ export default function EditHomePage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6">
|
<div className="grid grid-cols-1 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título da Chamada</label>
|
<LabelWithLimit
|
||||||
|
label="Título da Chamada"
|
||||||
|
value={formData.cta.title}
|
||||||
|
limit={HOME_TEXT_LIMITS.cta.title}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.cta.title}
|
value={formData.cta.title}
|
||||||
onChange={(e) => setFormData({...formData, cta: {...formData.cta, title: e.target.value}})}
|
onChange={(e) => setFormData({...formData, cta: {...formData.cta, title: e.target.value}})}
|
||||||
|
maxLength={HOME_TEXT_LIMITS.cta.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"
|
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>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Texto de Apoio</label>
|
<LabelWithLimit
|
||||||
|
label="Texto de Apoio"
|
||||||
|
value={formData.cta.text}
|
||||||
|
limit={HOME_TEXT_LIMITS.cta.text}
|
||||||
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
value={formData.cta.text}
|
value={formData.cta.text}
|
||||||
onChange={(e) => setFormData({...formData, cta: {...formData.cta, text: e.target.value}})}
|
onChange={(e) => setFormData({...formData, cta: {...formData.cta, text: e.target.value}})}
|
||||||
rows={3}
|
rows={3}
|
||||||
|
maxLength={HOME_TEXT_LIMITS.cta.text}
|
||||||
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"
|
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>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Texto do Botão</label>
|
<LabelWithLimit
|
||||||
|
label="Texto do Botão"
|
||||||
|
value={formData.cta.button}
|
||||||
|
limit={HOME_TEXT_LIMITS.cta.button}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.cta.button}
|
value={formData.cta.button}
|
||||||
onChange={(e) => setFormData({...formData, cta: {...formData.cta, button: e.target.value}})}
|
onChange={(e) => setFormData({...formData, cta: {...formData.cta, button: e.target.value}})}
|
||||||
|
maxLength={HOME_TEXT_LIMITS.cta.button}
|
||||||
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>
|
||||||
@@ -1118,3 +1308,4 @@ export default function EditHomePage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useToast } from '@/contexts/ToastContext';
|
import { useToast } from '@/contexts/ToastContext';
|
||||||
|
import { CharLimitBadge } from '@/components/admin/CharLimitBadge';
|
||||||
|
|
||||||
// Ícones pré-definidos para seleção
|
// Ícones pré-definidos para seleção
|
||||||
const AVAILABLE_ICONS = [
|
const AVAILABLE_ICONS = [
|
||||||
@@ -171,6 +172,27 @@ function IconSelector({ value, onChange, label }: IconSelectorProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ABOUT_TEXT_LIMITS = {
|
||||||
|
hero: { title: 70, subtitle: 200 },
|
||||||
|
history: { title: 36, subtitle: 80, paragraph: 320 },
|
||||||
|
values: { title: 36, subtitle: 80, itemTitle: 40, itemDescription: 140 },
|
||||||
|
} 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 ValueItem {
|
interface ValueItem {
|
||||||
icon: string;
|
icon: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -273,7 +295,11 @@ export default function EditAboutPage() {
|
|||||||
|
|
||||||
if (!response.ok) throw new Error('Erro ao salvar');
|
if (!response.ok) throw new Error('Erro ao salvar');
|
||||||
|
|
||||||
success('Conteúdo da página Sobre atualizado com sucesso!');
|
await response.json();
|
||||||
|
success('Conteúdo salvo com sucesso!');
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new Event('translation:refresh'));
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError('Erro ao salvar alterações');
|
showError('Erro ao salvar alterações');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -383,21 +409,31 @@ export default function EditAboutPage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6">
|
<div className="grid grid-cols-1 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título Principal</label>
|
<LabelWithLimit
|
||||||
|
label="Título Principal"
|
||||||
|
value={formData.hero.title}
|
||||||
|
limit={ABOUT_TEXT_LIMITS.hero.title}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.hero.title}
|
value={formData.hero.title}
|
||||||
onChange={(e) => setFormData({...formData, hero: {...formData.hero, title: e.target.value}})}
|
onChange={(e) => setFormData({...formData, hero: {...formData.hero, title: e.target.value}})}
|
||||||
|
maxLength={ABOUT_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"
|
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>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Subtítulo</label>
|
<LabelWithLimit
|
||||||
|
label="Subtítulo"
|
||||||
|
value={formData.hero.subtitle}
|
||||||
|
limit={ABOUT_TEXT_LIMITS.hero.subtitle}
|
||||||
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
value={formData.hero.subtitle}
|
value={formData.hero.subtitle}
|
||||||
onChange={(e) => setFormData({...formData, hero: {...formData.hero, subtitle: e.target.value}})}
|
onChange={(e) => setFormData({...formData, hero: {...formData.hero, subtitle: e.target.value}})}
|
||||||
rows={2}
|
rows={2}
|
||||||
|
maxLength={ABOUT_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"
|
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>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
@@ -415,41 +451,61 @@ export default function EditAboutPage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6">
|
<div className="grid grid-cols-1 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Pré-título</label>
|
<LabelWithLimit
|
||||||
|
label="Pré-título"
|
||||||
|
value={formData.history.title}
|
||||||
|
limit={ABOUT_TEXT_LIMITS.history.title}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.history.title}
|
value={formData.history.title}
|
||||||
onChange={(e) => setFormData({...formData, history: {...formData.history, title: e.target.value}})}
|
onChange={(e) => setFormData({...formData, history: {...formData.history, title: e.target.value}})}
|
||||||
|
maxLength={ABOUT_TEXT_LIMITS.history.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"
|
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>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título</label>
|
<LabelWithLimit
|
||||||
|
label="Título"
|
||||||
|
value={formData.history.subtitle}
|
||||||
|
limit={ABOUT_TEXT_LIMITS.history.subtitle}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.history.subtitle}
|
value={formData.history.subtitle}
|
||||||
onChange={(e) => setFormData({...formData, history: {...formData.history, subtitle: e.target.value}})}
|
onChange={(e) => setFormData({...formData, history: {...formData.history, subtitle: e.target.value}})}
|
||||||
|
maxLength={ABOUT_TEXT_LIMITS.history.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"
|
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>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Parágrafo 1</label>
|
<LabelWithLimit
|
||||||
|
label="Parágrafo 1"
|
||||||
|
value={formData.history.paragraph1}
|
||||||
|
limit={ABOUT_TEXT_LIMITS.history.paragraph}
|
||||||
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
value={formData.history.paragraph1}
|
value={formData.history.paragraph1}
|
||||||
onChange={(e) => setFormData({...formData, history: {...formData.history, paragraph1: e.target.value}})}
|
onChange={(e) => setFormData({...formData, history: {...formData.history, paragraph1: e.target.value}})}
|
||||||
rows={4}
|
rows={4}
|
||||||
|
maxLength={ABOUT_TEXT_LIMITS.history.paragraph}
|
||||||
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"
|
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>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Parágrafo 2</label>
|
<LabelWithLimit
|
||||||
|
label="Parágrafo 2"
|
||||||
|
value={formData.history.paragraph2}
|
||||||
|
limit={ABOUT_TEXT_LIMITS.history.paragraph}
|
||||||
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
value={formData.history.paragraph2}
|
value={formData.history.paragraph2}
|
||||||
onChange={(e) => setFormData({...formData, history: {...formData.history, paragraph2: e.target.value}})}
|
onChange={(e) => setFormData({...formData, history: {...formData.history, paragraph2: e.target.value}})}
|
||||||
rows={4}
|
rows={4}
|
||||||
|
maxLength={ABOUT_TEXT_LIMITS.history.paragraph}
|
||||||
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"
|
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>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
@@ -467,20 +523,30 @@ export default function EditAboutPage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 mb-6">
|
<div className="grid grid-cols-1 gap-6 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Pré-título</label>
|
<LabelWithLimit
|
||||||
|
label="Pré-título"
|
||||||
|
value={formData.values.title}
|
||||||
|
limit={ABOUT_TEXT_LIMITS.values.title}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.values.title}
|
value={formData.values.title}
|
||||||
onChange={(e) => setFormData({...formData, values: {...formData.values, title: e.target.value}})}
|
onChange={(e) => setFormData({...formData, values: {...formData.values, title: e.target.value}})}
|
||||||
|
maxLength={ABOUT_TEXT_LIMITS.values.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"
|
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>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título da Seção</label>
|
<LabelWithLimit
|
||||||
|
label="Título da Seção"
|
||||||
|
value={formData.values.subtitle}
|
||||||
|
limit={ABOUT_TEXT_LIMITS.values.subtitle}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.values.subtitle}
|
value={formData.values.subtitle}
|
||||||
onChange={(e) => setFormData({...formData, values: {...formData.values, subtitle: e.target.value}})}
|
onChange={(e) => setFormData({...formData, values: {...formData.values, subtitle: e.target.value}})}
|
||||||
|
maxLength={ABOUT_TEXT_LIMITS.values.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"
|
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>
|
||||||
@@ -503,7 +569,11 @@ export default function EditAboutPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Título</label>
|
<LabelWithLimit
|
||||||
|
label="Título"
|
||||||
|
value={item.title}
|
||||||
|
limit={ABOUT_TEXT_LIMITS.values.itemTitle}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={item.title}
|
value={item.title}
|
||||||
@@ -512,11 +582,16 @@ export default function EditAboutPage() {
|
|||||||
newItems[index].title = e.target.value;
|
newItems[index].title = e.target.value;
|
||||||
setFormData({...formData, values: {...formData.values, items: newItems}});
|
setFormData({...formData, values: {...formData.values, items: newItems}});
|
||||||
}}
|
}}
|
||||||
|
maxLength={ABOUT_TEXT_LIMITS.values.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"
|
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>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Descrição</label>
|
<LabelWithLimit
|
||||||
|
label="Descrição"
|
||||||
|
value={item.description}
|
||||||
|
limit={ABOUT_TEXT_LIMITS.values.itemDescription}
|
||||||
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
value={item.description}
|
value={item.description}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -525,6 +600,7 @@ export default function EditAboutPage() {
|
|||||||
setFormData({...formData, values: {...formData.values, items: newItems}});
|
setFormData({...formData, values: {...formData.values, items: newItems}});
|
||||||
}}
|
}}
|
||||||
rows={2}
|
rows={2}
|
||||||
|
maxLength={ABOUT_TEXT_LIMITS.values.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"
|
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>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
479
frontend/src/app/admin/projetos/[id]/editar/page.tsx
Normal file
479
frontend/src/app/admin/projetos/[id]/editar/page.tsx
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useToast } from '@/contexts/ToastContext';
|
||||||
|
|
||||||
|
type UploadedImage = {
|
||||||
|
url: string;
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_OPTIONS = [
|
||||||
|
{ value: 'Engenharia Veicular', label: 'Engenharia Veicular' },
|
||||||
|
{ value: 'Projetos Mecânicos', label: 'Projetos Mecânicos' },
|
||||||
|
{ value: 'Laudos e Inspeções', label: 'Laudos e Inspeções' },
|
||||||
|
{ value: 'Segurança do Trabalho', label: 'Segurança do Trabalho' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: 'Concluído', label: 'Concluído' },
|
||||||
|
{ value: 'Em andamento', label: 'Em andamento' },
|
||||||
|
{ value: 'Rascunho', label: 'Rascunho' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function EditProject({ params }: { params: { id: string } }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { success, error } = useToast();
|
||||||
|
const coverInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const galleryInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [loadingData, setLoadingData] = useState(true);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: '',
|
||||||
|
category: '',
|
||||||
|
client: '',
|
||||||
|
status: 'Concluído',
|
||||||
|
description: '',
|
||||||
|
date: '',
|
||||||
|
featured: false,
|
||||||
|
});
|
||||||
|
const [coverImage, setCoverImage] = useState<UploadedImage | null>(null);
|
||||||
|
const [galleryImages, setGalleryImages] = useState<UploadedImage[]>([]);
|
||||||
|
const [uploadingCover, setUploadingCover] = useState(false);
|
||||||
|
const [uploadingGallery, setUploadingGallery] = useState(false);
|
||||||
|
|
||||||
|
const isSaving = loading || uploadingCover || uploadingGallery;
|
||||||
|
|
||||||
|
// Carregar dados do projeto
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchProject() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/projects/${params.id}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Projeto não encontrado');
|
||||||
|
}
|
||||||
|
const project = await res.json();
|
||||||
|
|
||||||
|
setFormData({
|
||||||
|
title: project.title || '',
|
||||||
|
category: project.category || '',
|
||||||
|
client: project.client || '',
|
||||||
|
status: project.status || 'Concluído',
|
||||||
|
description: project.description || '',
|
||||||
|
date: project.completionDate ? project.completionDate.split('T')[0] : '',
|
||||||
|
featured: project.featured || false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (project.coverImage) {
|
||||||
|
setCoverImage({
|
||||||
|
url: project.coverImage,
|
||||||
|
path: project.coverImage,
|
||||||
|
name: 'Imagem de capa',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.galleryImages && project.galleryImages.length > 0) {
|
||||||
|
setGalleryImages(
|
||||||
|
project.galleryImages.map((url: string, index: number) => ({
|
||||||
|
url,
|
||||||
|
path: url,
|
||||||
|
name: `Imagem ${index + 1}`,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao carregar projeto:', err);
|
||||||
|
error('Não foi possível carregar o projeto.');
|
||||||
|
router.push('/admin/projetos');
|
||||||
|
} finally {
|
||||||
|
setLoadingData(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchProject();
|
||||||
|
}, [params.id, error, router]);
|
||||||
|
|
||||||
|
const uploadFile = async (file: File): Promise<UploadedImage | null> => {
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
error('Arquivo maior que 2MB. Escolha uma imagem menor.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supportedTypes = ['image/png', 'image/jpeg', 'image/webp'];
|
||||||
|
if (!supportedTypes.includes(file.type)) {
|
||||||
|
error('Formato inválido. Utilize PNG, JPG ou WEBP.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = new FormData();
|
||||||
|
body.append('file', file);
|
||||||
|
|
||||||
|
const response = await fetch('/api/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data?.error || 'Erro ao enviar imagem');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return {
|
||||||
|
url: data.url,
|
||||||
|
path: data.path,
|
||||||
|
name: file.name,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCoverSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploadingCover(true);
|
||||||
|
try {
|
||||||
|
const uploaded = await uploadFile(file);
|
||||||
|
if (uploaded) {
|
||||||
|
setCoverImage(uploaded);
|
||||||
|
success('Imagem de capa carregada.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao enviar imagem de capa:', err);
|
||||||
|
error(err instanceof Error ? err.message : 'Falha ao enviar imagem de capa.');
|
||||||
|
} finally {
|
||||||
|
setUploadingCover(false);
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGallerySelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(event.target.files ?? []);
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
if (galleryImages.length + files.length > 8) {
|
||||||
|
error('Limite de 8 imagens atingido. Remova algumas antes de adicionar novas.');
|
||||||
|
event.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadingGallery(true);
|
||||||
|
try {
|
||||||
|
for (const file of files) {
|
||||||
|
const uploaded = await uploadFile(file);
|
||||||
|
if (uploaded) {
|
||||||
|
setGalleryImages((prev) => [...prev, uploaded]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
success('Galeria atualizada.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao enviar imagem da galeria:', err);
|
||||||
|
error(err instanceof Error ? err.message : 'Falha ao enviar imagem da galeria.');
|
||||||
|
} finally {
|
||||||
|
setUploadingGallery(false);
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveGalleryImage = (path: string) => {
|
||||||
|
setGalleryImages((prev) => prev.filter((image) => image.path !== path));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!formData.title || !formData.category) {
|
||||||
|
error('Informe ao menos o título e a categoria do projeto.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${params.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: formData.title.trim(),
|
||||||
|
category: formData.category,
|
||||||
|
client: formData.client?.trim() || null,
|
||||||
|
status: formData.status,
|
||||||
|
description: formData.description?.trim() || null,
|
||||||
|
completionDate: formData.date ? new Date(formData.date).toISOString() : null,
|
||||||
|
coverImage: coverImage?.url ?? null,
|
||||||
|
galleryImages: galleryImages.map((image) => image.url),
|
||||||
|
featured: formData.featured,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data?.error || 'Erro ao atualizar projeto');
|
||||||
|
}
|
||||||
|
|
||||||
|
success('Projeto atualizado com sucesso!');
|
||||||
|
router.push('/admin/projetos');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao atualizar projeto:', err);
|
||||||
|
error(err instanceof Error ? err.message : 'Não foi possível atualizar o projeto.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loadingData) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<i className="ri-loader-4-line animate-spin text-4xl text-primary"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<Link
|
||||||
|
href="/admin/projetos"
|
||||||
|
className="w-10 h-10 rounded-xl bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 flex items-center justify-center text-gray-500 hover:text-primary hover:border-primary transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
<i className="ri-arrow-left-line text-xl"></i>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-1">Editar Projeto</h1>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">Atualize as informações do projeto.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
|
{/* Basic 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 Básicas
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título do Projeto</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => setFormData({...formData, title: e.target.value})}
|
||||||
|
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"
|
||||||
|
placeholder="Ex: Adequação de Frota Coca-Cola"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Categoria</label>
|
||||||
|
<select
|
||||||
|
value={formData.category}
|
||||||
|
onChange={(e) => setFormData({...formData, category: e.target.value})}
|
||||||
|
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 appearance-none cursor-pointer"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Selecione uma categoria</option>
|
||||||
|
{CATEGORY_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Cliente</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.client}
|
||||||
|
onChange={(e) => setFormData({...formData, client: e.target.value})}
|
||||||
|
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"
|
||||||
|
placeholder="Ex: Coca-Cola FEMSA"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Data de Conclusão</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={formData.date}
|
||||||
|
onChange={(e) => setFormData({...formData, date: e.target.value})}
|
||||||
|
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>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Status</label>
|
||||||
|
<select
|
||||||
|
value={formData.status}
|
||||||
|
onChange={(e) => setFormData({...formData, status: e.target.value})}
|
||||||
|
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 appearance-none cursor-pointer"
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Descrição Detalhada</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({...formData, description: e.target.value})}
|
||||||
|
rows={6}
|
||||||
|
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"
|
||||||
|
placeholder="Descreva os detalhes técnicos, desafios e soluções do projeto..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="flex items-center gap-3 text-sm font-bold text-gray-700 dark:text-gray-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.featured}
|
||||||
|
onChange={(e) => setFormData({ ...formData, featured: e.target.checked })}
|
||||||
|
className="h-5 w-5 rounded border-gray-300 text-primary focus:ring-primary"
|
||||||
|
/>
|
||||||
|
Destacar este projeto no site
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Projetos em destaque podem ser exibidos em seções especiais como a home.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Media */}
|
||||||
|
<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-image-line text-primary"></i>
|
||||||
|
Mídia
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Imagem de Capa</label>
|
||||||
|
<div
|
||||||
|
onClick={() => coverInputRef.current?.click()}
|
||||||
|
className="border-2 border-dashed border-gray-300 dark:border-white/20 rounded-xl p-6 text-center hover:border-primary dark:hover:border-primary transition-colors cursor-pointer bg-gray-50 dark:bg-white/5 relative min-h-[200px] flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{coverImage ? (
|
||||||
|
<div className="w-full h-full relative">
|
||||||
|
<img src={coverImage.url} alt={coverImage.name} className="w-full h-full object-cover rounded-lg" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setCoverImage(null);
|
||||||
|
}}
|
||||||
|
className="absolute top-3 right-3 bg-black/60 text-white w-8 h-8 rounded-full flex items-center justify-center hover:bg-black/80 transition-colors"
|
||||||
|
>
|
||||||
|
<i className="ri-close-line"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center text-primary mb-4">
|
||||||
|
{uploadingCover ? (
|
||||||
|
<i className="ri-loader-4-line text-3xl animate-spin"></i>
|
||||||
|
) : (
|
||||||
|
<i className="ri-upload-cloud-2-line text-3xl"></i>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 dark:text-gray-300 font-medium mb-1">
|
||||||
|
{uploadingCover ? 'Enviando imagem...' : 'Clique para fazer upload ou arraste e solte'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400">PNG, JPG ou WEBP (máximo 2MB)</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={coverInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleCoverSelect}
|
||||||
|
disabled={uploadingCover}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Galeria de Fotos</label>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => galleryInputRef.current?.click()}
|
||||||
|
className="aspect-square border-2 border-dashed border-gray-300 dark:border-white/20 rounded-xl flex flex-col items-center justify-center text-gray-400 hover:text-primary hover:border-primary transition-colors cursor-pointer bg-gray-50 dark:bg-white/5"
|
||||||
|
disabled={uploadingGallery}
|
||||||
|
>
|
||||||
|
{uploadingGallery ? (
|
||||||
|
<>
|
||||||
|
<i className="ri-loader-4-line text-3xl mb-2 animate-spin"></i>
|
||||||
|
<span className="text-xs font-bold">Enviando...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<i className="ri-add-line text-3xl mb-2"></i>
|
||||||
|
<span className="text-xs font-bold">Adicionar</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{galleryImages.map((image) => (
|
||||||
|
<div key={image.path} className="aspect-square rounded-xl bg-gray-200 dark:bg-white/10 relative group overflow-hidden">
|
||||||
|
<img src={image.url} alt={image.name} className="w-full h-full object-cover" />
|
||||||
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveGalleryImage(image.path)}
|
||||||
|
className="w-9 h-9 rounded-lg bg-white/20 hover:bg-white/40 text-white flex items-center justify-center backdrop-blur-sm transition-colors"
|
||||||
|
>
|
||||||
|
<i className="ri-delete-bin-line"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
ref={galleryInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleGallerySelect}
|
||||||
|
disabled={uploadingGallery}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">Adicione até 8 imagens para apresentar detalhes do projeto.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-end gap-4 pt-4">
|
||||||
|
<Link
|
||||||
|
href="/admin/projetos"
|
||||||
|
className="px-8 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
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
className="px-8 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-70 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<i className="ri-loader-4-line animate-spin"></i>
|
||||||
|
Salvando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<i className="ri-save-line"></i>
|
||||||
|
Atualizar Projeto
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,31 +1,175 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useToast } from '@/contexts/ToastContext';
|
||||||
|
|
||||||
|
type UploadedImage = {
|
||||||
|
url: string;
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_OPTIONS = [
|
||||||
|
{ value: 'Engenharia Veicular', label: 'Engenharia Veicular' },
|
||||||
|
{ value: 'Projetos Mecânicos', label: 'Projetos Mecânicos' },
|
||||||
|
{ value: 'Laudos e Inspeções', label: 'Laudos e Inspeções' },
|
||||||
|
{ value: 'Segurança do Trabalho', label: 'Segurança do Trabalho' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: 'Concluído', label: 'Concluído' },
|
||||||
|
{ value: 'Em andamento', label: 'Em andamento' },
|
||||||
|
{ value: 'Rascunho', label: 'Rascunho' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function NewProject() {
|
export default function NewProject() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { success, error } = useToast();
|
||||||
|
const coverInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const galleryInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
title: '',
|
title: '',
|
||||||
category: '',
|
category: '',
|
||||||
client: '',
|
client: '',
|
||||||
status: 'active',
|
status: 'Concluído',
|
||||||
description: '',
|
description: '',
|
||||||
date: ''
|
date: '',
|
||||||
|
featured: false,
|
||||||
});
|
});
|
||||||
|
const [coverImage, setCoverImage] = useState<UploadedImage | null>(null);
|
||||||
|
const [galleryImages, setGalleryImages] = useState<UploadedImage[]>([]);
|
||||||
|
const [uploadingCover, setUploadingCover] = useState(false);
|
||||||
|
const [uploadingGallery, setUploadingGallery] = useState(false);
|
||||||
|
|
||||||
|
const isSaving = loading || uploadingCover || uploadingGallery;
|
||||||
|
|
||||||
|
const uploadFile = async (file: File): Promise<UploadedImage | null> => {
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
error('Arquivo maior que 2MB. Escolha uma imagem menor.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supportedTypes = ['image/png', 'image/jpeg', 'image/webp'];
|
||||||
|
if (!supportedTypes.includes(file.type)) {
|
||||||
|
error('Formato inválido. Utilize PNG, JPG ou WEBP.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = new FormData();
|
||||||
|
body.append('file', file);
|
||||||
|
|
||||||
|
const response = await fetch('/api/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data?.error || 'Erro ao enviar imagem');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return {
|
||||||
|
url: data.url,
|
||||||
|
path: data.path,
|
||||||
|
name: file.name,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCoverSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploadingCover(true);
|
||||||
|
try {
|
||||||
|
const uploaded = await uploadFile(file);
|
||||||
|
if (uploaded) {
|
||||||
|
setCoverImage(uploaded);
|
||||||
|
success('Imagem de capa carregada.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao enviar imagem de capa:', err);
|
||||||
|
error(err instanceof Error ? err.message : 'Falha ao enviar imagem de capa.');
|
||||||
|
} finally {
|
||||||
|
setUploadingCover(false);
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGallerySelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(event.target.files ?? []);
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
if (galleryImages.length + files.length > 8) {
|
||||||
|
error('Limite de 8 imagens atingido. Remova algumas antes de adicionar novas.');
|
||||||
|
event.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadingGallery(true);
|
||||||
|
try {
|
||||||
|
for (const file of files) {
|
||||||
|
const uploaded = await uploadFile(file);
|
||||||
|
if (uploaded) {
|
||||||
|
setGalleryImages((prev) => [...prev, uploaded]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
success('Galeria atualizada.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao enviar imagem da galeria:', err);
|
||||||
|
error(err instanceof Error ? err.message : 'Falha ao enviar imagem da galeria.');
|
||||||
|
} finally {
|
||||||
|
setUploadingGallery(false);
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveGalleryImage = (path: string) => {
|
||||||
|
setGalleryImages((prev) => prev.filter((image) => image.path !== path));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
if (!formData.title || !formData.category) {
|
||||||
|
error('Informe ao menos o título e a categoria do projeto.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Simulate API call
|
setLoading(true);
|
||||||
setTimeout(() => {
|
try {
|
||||||
console.log('Project data:', formData);
|
const response = await fetch('/api/projects', {
|
||||||
setLoading(false);
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: formData.title.trim(),
|
||||||
|
category: formData.category,
|
||||||
|
client: formData.client?.trim() || null,
|
||||||
|
status: formData.status,
|
||||||
|
description: formData.description?.trim() || null,
|
||||||
|
completionDate: formData.date ? new Date(formData.date).toISOString() : null,
|
||||||
|
coverImage: coverImage?.url ?? null,
|
||||||
|
galleryImages: galleryImages.map((image) => image.url),
|
||||||
|
featured: formData.featured,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data?.error || 'Erro ao salvar projeto');
|
||||||
|
}
|
||||||
|
|
||||||
|
success('Projeto cadastrado com sucesso!');
|
||||||
router.push('/admin/projetos');
|
router.push('/admin/projetos');
|
||||||
}, 1500);
|
} catch (err) {
|
||||||
|
console.error('Erro ao salvar projeto:', err);
|
||||||
|
error(err instanceof Error ? err.message : 'Não foi possível salvar o projeto.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -73,10 +217,9 @@ export default function NewProject() {
|
|||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">Selecione uma categoria</option>
|
<option value="">Selecione uma categoria</option>
|
||||||
<option value="veicular">Engenharia Veicular</option>
|
{CATEGORY_OPTIONS.map((option) => (
|
||||||
<option value="mecanica">Projetos Mecânicos</option>
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
<option value="laudos">Laudos e Inspeções</option>
|
))}
|
||||||
<option value="seguranca">Segurança do Trabalho</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -108,9 +251,9 @@ export default function NewProject() {
|
|||||||
onChange={(e) => setFormData({...formData, status: e.target.value})}
|
onChange={(e) => setFormData({...formData, status: e.target.value})}
|
||||||
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 appearance-none cursor-pointer"
|
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 appearance-none cursor-pointer"
|
||||||
>
|
>
|
||||||
<option value="active">Concluído</option>
|
{STATUS_OPTIONS.map((option) => (
|
||||||
<option value="pending">Em Andamento</option>
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
<option value="draft">Rascunho</option>
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -124,6 +267,19 @@ export default function NewProject() {
|
|||||||
placeholder="Descreva os detalhes técnicos, desafios e soluções do projeto..."
|
placeholder="Descreva os detalhes técnicos, desafios e soluções do projeto..."
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="flex items-center gap-3 text-sm font-bold text-gray-700 dark:text-gray-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.featured}
|
||||||
|
onChange={(e) => setFormData({ ...formData, featured: e.target.checked })}
|
||||||
|
className="h-5 w-5 rounded border-gray-300 text-primary focus:ring-primary"
|
||||||
|
/>
|
||||||
|
Destacar este projeto no site
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Projetos em destaque podem ser exibidos em seções especiais como a home.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -137,33 +293,97 @@ export default function NewProject() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Imagem de Capa</label>
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Imagem de Capa</label>
|
||||||
<div className="border-2 border-dashed border-gray-300 dark:border-white/20 rounded-xl p-8 text-center hover:border-primary dark:hover:border-primary transition-colors cursor-pointer bg-gray-50 dark:bg-white/5">
|
<div
|
||||||
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center text-primary mx-auto mb-4">
|
onClick={() => coverInputRef.current?.click()}
|
||||||
<i className="ri-upload-cloud-2-line text-3xl"></i>
|
className="border-2 border-dashed border-gray-300 dark:border-white/20 rounded-xl p-6 text-center hover:border-primary dark:hover:border-primary transition-colors cursor-pointer bg-gray-50 dark:bg-white/5 relative min-h-[200px] flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{coverImage ? (
|
||||||
|
<div className="w-full h-full relative">
|
||||||
|
<img src={coverImage.url} alt={coverImage.name} className="w-full h-full object-cover rounded-lg" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setCoverImage(null);
|
||||||
|
}}
|
||||||
|
className="absolute top-3 right-3 bg-black/60 text-white w-8 h-8 rounded-full flex items-center justify-center hover:bg-black/80 transition-colors"
|
||||||
|
>
|
||||||
|
<i className="ri-close-line"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-600 dark:text-gray-300 font-medium mb-1">Clique para fazer upload ou arraste e solte</p>
|
) : (
|
||||||
<p className="text-xs text-gray-400">PNG, JPG ou WEBP (Max. 2MB)</p>
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center text-primary mb-4">
|
||||||
|
{uploadingCover ? (
|
||||||
|
<i className="ri-loader-4-line text-3xl animate-spin"></i>
|
||||||
|
) : (
|
||||||
|
<i className="ri-upload-cloud-2-line text-3xl"></i>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 dark:text-gray-300 font-medium mb-1">
|
||||||
|
{uploadingCover ? 'Enviando imagem...' : 'Clique para fazer upload ou arraste e solte'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400">PNG, JPG ou WEBP (máximo 2MB)</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={coverInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleCoverSelect}
|
||||||
|
disabled={uploadingCover}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Galeria de Fotos</label>
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Galeria de Fotos</label>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div className="aspect-square border-2 border-dashed border-gray-300 dark:border-white/20 rounded-xl flex flex-col items-center justify-center text-gray-400 hover:text-primary hover:border-primary transition-colors cursor-pointer bg-gray-50 dark:bg-white/5">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => galleryInputRef.current?.click()}
|
||||||
|
className="aspect-square border-2 border-dashed border-gray-300 dark:border-white/20 rounded-xl flex flex-col items-center justify-center text-gray-400 hover:text-primary hover:border-primary transition-colors cursor-pointer bg-gray-50 dark:bg-white/5"
|
||||||
|
disabled={uploadingGallery}
|
||||||
|
>
|
||||||
|
{uploadingGallery ? (
|
||||||
|
<>
|
||||||
|
<i className="ri-loader-4-line text-3xl mb-2 animate-spin"></i>
|
||||||
|
<span className="text-xs font-bold">Enviando...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<i className="ri-add-line text-3xl mb-2"></i>
|
<i className="ri-add-line text-3xl mb-2"></i>
|
||||||
<span className="text-xs font-bold">Adicionar</span>
|
<span className="text-xs font-bold">Adicionar</span>
|
||||||
</div>
|
</>
|
||||||
{/* Placeholders for uploaded images */}
|
)}
|
||||||
{[1, 2].map((i) => (
|
</button>
|
||||||
<div key={i} className="aspect-square rounded-xl bg-gray-200 dark:bg-white/10 relative group overflow-hidden">
|
|
||||||
|
{galleryImages.map((image) => (
|
||||||
|
<div key={image.path} className="aspect-square rounded-xl bg-gray-200 dark:bg-white/10 relative group overflow-hidden">
|
||||||
|
<img src={image.url} alt={image.name} className="w-full h-full object-cover" />
|
||||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||||
<button type="button" className="w-8 h-8 rounded-lg bg-white/20 hover:bg-white/40 text-white flex items-center justify-center backdrop-blur-sm transition-colors">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveGalleryImage(image.path)}
|
||||||
|
className="w-9 h-9 rounded-lg bg-white/20 hover:bg-white/40 text-white flex items-center justify-center backdrop-blur-sm transition-colors"
|
||||||
|
>
|
||||||
<i className="ri-delete-bin-line"></i>
|
<i className="ri-delete-bin-line"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
<input
|
||||||
|
ref={galleryInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleGallerySelect}
|
||||||
|
disabled={uploadingGallery}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">Adicione até 8 imagens para apresentar detalhes do projeto.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,10 +398,10 @@ export default function NewProject() {
|
|||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={isSaving}
|
||||||
className="px-8 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-70 disabled:cursor-not-allowed"
|
className="px-8 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-70 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{isSaving ? (
|
||||||
<>
|
<>
|
||||||
<i className="ri-loader-4-line animate-spin"></i>
|
<i className="ri-loader-4-line animate-spin"></i>
|
||||||
Salvando...
|
Salvando...
|
||||||
|
|||||||
@@ -1,8 +1,174 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useToast } from '@/contexts/ToastContext';
|
||||||
|
import { useConfirm } from '@/contexts/ConfirmContext';
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
client: string | null;
|
||||||
|
status: string;
|
||||||
|
completionDate: string | null;
|
||||||
|
description: string | null;
|
||||||
|
coverImage: string | null;
|
||||||
|
galleryImages: string[];
|
||||||
|
featured: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<string, string> = {
|
||||||
|
'Concluído': 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
'Em andamento': 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
'Rascunho': 'bg-gray-100 text-gray-600 dark:bg-white/10 dark:text-gray-300',
|
||||||
|
};
|
||||||
|
|
||||||
export default function ProjectsList() {
|
export default function ProjectsList() {
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [filterCategory, setFilterCategory] = useState<string>('');
|
||||||
|
const [filterStatus, setFilterStatus] = useState<string>('');
|
||||||
|
const [filterDateFrom, setFilterDateFrom] = useState<string>('');
|
||||||
|
const [filterDateTo, setFilterDateTo] = useState<string>('');
|
||||||
|
const { success, error } = useToast();
|
||||||
|
const { confirm } = useConfirm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProjects();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchProjects = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch('/api/projects');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Falha ao carregar projetos');
|
||||||
|
}
|
||||||
|
const data: Project[] = await response.json();
|
||||||
|
setProjects(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao carregar projetos:', err);
|
||||||
|
error('Não foi possível carregar os projetos.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extrair categorias e status únicos
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const cats = new Set<string>();
|
||||||
|
projects.forEach((p) => {
|
||||||
|
if (p.category) cats.add(p.category);
|
||||||
|
});
|
||||||
|
return Array.from(cats).sort();
|
||||||
|
}, [projects]);
|
||||||
|
|
||||||
|
const statuses = useMemo(() => {
|
||||||
|
const stats = new Set<string>();
|
||||||
|
projects.forEach((p) => {
|
||||||
|
if (p.status) stats.add(p.status);
|
||||||
|
});
|
||||||
|
return Array.from(stats);
|
||||||
|
}, [projects]);
|
||||||
|
|
||||||
|
// Filtrar projetos
|
||||||
|
const filteredProjects = useMemo(() => {
|
||||||
|
return projects.filter((project) => {
|
||||||
|
// Filtro de pesquisa
|
||||||
|
const matchesSearch = !searchTerm ||
|
||||||
|
project.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
project.client?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
project.category?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
project.description?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
|
||||||
|
// Filtro de categoria
|
||||||
|
const matchesCategory = !filterCategory || project.category === filterCategory;
|
||||||
|
|
||||||
|
// Filtro de status
|
||||||
|
const matchesStatus = !filterStatus || project.status === filterStatus;
|
||||||
|
|
||||||
|
// Filtro de data (criação)
|
||||||
|
let matchesDateFrom = true;
|
||||||
|
let matchesDateTo = true;
|
||||||
|
|
||||||
|
if (filterDateFrom) {
|
||||||
|
const projectDate = new Date(project.createdAt);
|
||||||
|
const fromDate = new Date(filterDateFrom);
|
||||||
|
matchesDateFrom = projectDate >= fromDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterDateTo) {
|
||||||
|
const projectDate = new Date(project.createdAt);
|
||||||
|
const toDate = new Date(filterDateTo);
|
||||||
|
toDate.setHours(23, 59, 59, 999);
|
||||||
|
matchesDateTo = projectDate <= toDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchesSearch && matchesCategory && matchesStatus && matchesDateFrom && matchesDateTo;
|
||||||
|
});
|
||||||
|
}, [projects, searchTerm, filterCategory, filterStatus, filterDateFrom, filterDateTo]);
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearchTerm('');
|
||||||
|
setFilterCategory('');
|
||||||
|
setFilterStatus('');
|
||||||
|
setFilterDateFrom('');
|
||||||
|
setFilterDateTo('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasActiveFilters = searchTerm || filterCategory || filterStatus || filterDateFrom || filterDateTo;
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: 'Excluir projeto',
|
||||||
|
message: 'Tem certeza que deseja remover este projeto? Esta ação não pode ser desfeita.',
|
||||||
|
confirmText: 'Excluir',
|
||||||
|
cancelText: 'Cancelar',
|
||||||
|
type: 'danger',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${id}`, { method: 'DELETE' });
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result?.error || 'Falha ao excluir projeto');
|
||||||
|
}
|
||||||
|
|
||||||
|
success('Projeto excluído com sucesso!');
|
||||||
|
fetchProjects();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao excluir projeto:', err);
|
||||||
|
error('Não foi possível excluir o projeto.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStatus = (status: string) => {
|
||||||
|
const style = STATUS_STYLES[status] || 'bg-gray-100 text-gray-600 dark:bg-white/10 dark:text-gray-300';
|
||||||
|
return (
|
||||||
|
<span className={`px-3 py-1 rounded-full text-xs font-bold ${style}`}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (value: string | null) => {
|
||||||
|
if (!value) return '—';
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('pt-BR').format(new Date(value));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao formatar data:', err);
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
@@ -19,7 +185,128 @@ export default function ProjectsList() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Filters Section */}
|
||||||
|
<div className="bg-white dark:bg-secondary rounded-xl border border-gray-200 dark:border-white/10 shadow-sm p-4 mb-6">
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Pesquisar projetos..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full px-4 py-2.5 pl-10 rounded-lg border border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5 text-secondary dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
|
||||||
|
/>
|
||||||
|
<i className="ri-search-line absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
|
||||||
|
{searchTerm && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchTerm('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<i className="ri-close-line"></i>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Filter */}
|
||||||
|
<div className="w-full lg:w-48">
|
||||||
|
<select
|
||||||
|
value={filterCategory}
|
||||||
|
onChange={(e) => setFilterCategory(e.target.value)}
|
||||||
|
className="w-full px-4 py-2.5 rounded-lg border border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5 text-secondary dark:text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all appearance-none cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="">Todas categorias</option>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<option key={cat} value={cat}>{cat}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className="w-full lg:w-40">
|
||||||
|
<select
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={(e) => setFilterStatus(e.target.value)}
|
||||||
|
className="w-full px-4 py-2.5 rounded-lg border border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5 text-secondary dark:text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all appearance-none cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="">Todos status</option>
|
||||||
|
{statuses.map((status) => (
|
||||||
|
<option key={status} value={status}>{status}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date From Filter */}
|
||||||
|
<div className="w-full lg:w-40">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filterDateFrom}
|
||||||
|
onChange={(e) => setFilterDateFrom(e.target.value)}
|
||||||
|
className="w-full px-4 py-2.5 rounded-lg border border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5 text-secondary dark:text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all cursor-pointer"
|
||||||
|
title="Data inicial"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date To Filter */}
|
||||||
|
<div className="w-full lg:w-40">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filterDateTo}
|
||||||
|
onChange={(e) => setFilterDateTo(e.target.value)}
|
||||||
|
className="w-full px-4 py-2.5 rounded-lg border border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5 text-secondary dark:text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all cursor-pointer"
|
||||||
|
title="Data final"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clear Filters */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="px-4 py-2.5 text-gray-500 hover:text-red-500 dark:text-gray-400 dark:hover:text-red-400 font-medium transition-colors flex items-center gap-1 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<i className="ri-filter-off-line"></i>
|
||||||
|
Limpar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results count */}
|
||||||
|
{!loading && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-100 dark:border-white/5 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{hasActiveFilters ? (
|
||||||
|
<span>Exibindo {filteredProjects.length} de {projects.length} projetos</span>
|
||||||
|
) : (
|
||||||
|
<span>{projects.length} projeto{projects.length !== 1 ? 's' : ''} cadastrado{projects.length !== 1 ? 's' : ''}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-secondary rounded-xl border border-gray-200 dark:border-white/10 shadow-sm overflow-hidden">
|
<div className="bg-white dark:bg-secondary rounded-xl border border-gray-200 dark:border-white/10 shadow-sm overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<i className="ri-loader-4-line animate-spin text-3xl text-primary"></i>
|
||||||
|
</div>
|
||||||
|
) : filteredProjects.length === 0 ? (
|
||||||
|
<div className="py-16 text-center text-gray-500 dark:text-gray-400 flex flex-col items-center gap-3">
|
||||||
|
<i className="ri-briefcase-line text-4xl"></i>
|
||||||
|
{hasActiveFilters ? (
|
||||||
|
<>
|
||||||
|
Nenhum projeto encontrado com os filtros aplicados.
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="mt-2 px-4 py-2 bg-primary text-white rounded-lg font-medium hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
Limpar filtros
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Nenhum projeto cadastrado ainda.'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left border-collapse">
|
<table className="w-full text-left border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -27,42 +314,62 @@ export default function ProjectsList() {
|
|||||||
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Projeto</th>
|
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Projeto</th>
|
||||||
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Categoria</th>
|
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Categoria</th>
|
||||||
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Cliente</th>
|
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Cliente</th>
|
||||||
|
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Inserção</th>
|
||||||
|
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Conclusão</th>
|
||||||
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Status</th>
|
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Status</th>
|
||||||
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider text-right">Ações</th>
|
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider text-right">Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100 dark:divide-white/5">
|
<tbody className="divide-y divide-gray-100 dark:divide-white/5">
|
||||||
{[
|
{filteredProjects.map((project) => (
|
||||||
{ title: 'Adequação Coca-Cola', cat: 'Engenharia Veicular', client: 'Coca-Cola FEMSA', status: 'Concluído' },
|
<tr key={project.id} className="hover:bg-gray-50 dark:hover:bg-white/5 transition-colors group">
|
||||||
{ title: 'Laudo Guindaste Articulado', cat: 'Inspeção Técnica', client: 'Logística Express', status: 'Concluído' },
|
|
||||||
{ title: 'Dispositivo de Içamento', cat: 'Projeto Mecânico', client: 'Metalúrgica ABC', status: 'Em Andamento' },
|
|
||||||
].map((project, index) => (
|
|
||||||
<tr key={index} className="hover:bg-gray-50 dark:hover:bg-white/5 transition-colors group">
|
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-12 h-12 rounded-lg bg-gray-200 dark:bg-white/10 overflow-hidden shrink-0">
|
<div className="w-12 h-12 rounded-lg bg-gray-200 dark:bg-white/10 overflow-hidden shrink-0 border border-gray-100 dark:border-white/10">
|
||||||
<div className="w-full h-full bg-gray-300 dark:bg-white/20"></div>
|
{project.coverImage ? (
|
||||||
|
<img src={project.coverImage} alt={project.title} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
||||||
|
<i className="ri-image-line"></i>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-secondary dark:text-white flex items-center gap-2">
|
||||||
|
{project.title}
|
||||||
|
{project.featured && (
|
||||||
|
<span className="text-[10px] uppercase tracking-wider bg-primary/10 text-primary px-2 py-0.5 rounded-full font-bold">Destaque</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-secondary dark:text-white">{project.title}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4 text-gray-600 dark:text-gray-400">{project.cat}</td>
|
<td className="p-4 text-gray-600 dark:text-gray-400">{project.category}</td>
|
||||||
<td className="p-4 text-gray-600 dark:text-gray-400">{project.client}</td>
|
<td className="p-4 text-gray-600 dark:text-gray-400">{project.client || '—'}</td>
|
||||||
<td className="p-4">
|
<td className="p-4 text-gray-600 dark:text-gray-400">
|
||||||
<span className={`px-3 py-1 rounded-full text-xs font-bold ${
|
<div className="flex flex-col">
|
||||||
project.status === 'Concluído'
|
<span>{formatDate(project.createdAt)}</span>
|
||||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||||
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
{new Date(project.createdAt).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
|
||||||
}`}>
|
|
||||||
{project.status}
|
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="p-4 text-gray-600 dark:text-gray-400">{formatDate(project.completionDate)}</td>
|
||||||
|
<td className="p-4">{renderStatus(project.status)}</td>
|
||||||
<td className="p-4 text-right">
|
<td className="p-4 text-right">
|
||||||
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button className="w-8 h-8 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10 flex items-center justify-center text-gray-500 hover:text-primary transition-colors cursor-pointer" title="Editar">
|
<Link
|
||||||
|
href={`/admin/projetos/${project.id}/editar`}
|
||||||
|
className="w-8 h-8 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/20 flex items-center justify-center text-gray-500 hover:text-blue-500 transition-colors"
|
||||||
|
title="Editar"
|
||||||
|
>
|
||||||
<i className="ri-pencil-line"></i>
|
<i className="ri-pencil-line"></i>
|
||||||
</button>
|
</Link>
|
||||||
<button className="w-8 h-8 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center justify-center text-gray-500 hover:text-red-500 transition-colors cursor-pointer" title="Excluir">
|
<button
|
||||||
|
onClick={() => handleDelete(project.id)}
|
||||||
|
className="w-8 h-8 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center justify-center text-gray-500 hover:text-red-500 transition-colors cursor-pointer"
|
||||||
|
title="Excluir"
|
||||||
|
>
|
||||||
<i className="ri-delete-bin-line"></i>
|
<i className="ri-delete-bin-line"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,6 +379,7 @@ export default function ProjectsList() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
384
frontend/src/app/admin/servicos/[id]/editar/page.tsx
Normal file
384
frontend/src/app/admin/servicos/[id]/editar/page.tsx
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, use, useMemo } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useToast } from '@/contexts/ToastContext';
|
||||||
|
|
||||||
|
// Lista expandida de ícones do Remix Icon organizados por categoria
|
||||||
|
const ICON_LIST = [
|
||||||
|
// Veículos e Transporte
|
||||||
|
{ value: 'ri-truck-line', label: 'Caminhão', category: 'veiculos' },
|
||||||
|
{ value: 'ri-truck-fill', label: 'Caminhão Preenchido', category: 'veiculos' },
|
||||||
|
{ value: 'ri-bus-line', label: 'Ônibus', category: 'veiculos' },
|
||||||
|
{ value: 'ri-car-line', label: 'Carro', category: 'veiculos' },
|
||||||
|
{ value: 'ri-roadster-line', label: 'Veículo Esportivo', category: 'veiculos' },
|
||||||
|
{ value: 'ri-taxi-line', label: 'Táxi', category: 'veiculos' },
|
||||||
|
{ value: 'ri-caravan-line', label: 'Trailer', category: 'veiculos' },
|
||||||
|
|
||||||
|
// Ferramentas e Engenharia
|
||||||
|
{ value: 'ri-tools-line', label: 'Ferramentas', category: 'engenharia' },
|
||||||
|
{ value: 'ri-tools-fill', label: 'Ferramentas Preenchido', category: 'engenharia' },
|
||||||
|
{ value: 'ri-hammer-line', label: 'Martelo', category: 'engenharia' },
|
||||||
|
{ value: 'ri-screwdriver-line', label: 'Chave de Fenda', category: 'engenharia' },
|
||||||
|
{ value: 'ri-settings-3-line', label: 'Configurações', category: 'engenharia' },
|
||||||
|
{ value: 'ri-settings-4-line', label: 'Engrenagem', category: 'engenharia' },
|
||||||
|
{ value: 'ri-compass-3-line', label: 'Compasso', category: 'engenharia' },
|
||||||
|
{ value: 'ri-ruler-line', label: 'Régua', category: 'engenharia' },
|
||||||
|
{ value: 'ri-ruler-2-line', label: 'Régua 2', category: 'engenharia' },
|
||||||
|
{ value: 'ri-pencil-ruler-line', label: 'Lápis e Régua', category: 'engenharia' },
|
||||||
|
|
||||||
|
// Documentos e Laudos
|
||||||
|
{ value: 'ri-draft-line', label: 'Documento', category: 'documentos' },
|
||||||
|
{ value: 'ri-file-text-line', label: 'Arquivo Texto', category: 'documentos' },
|
||||||
|
{ value: 'ri-file-chart-line', label: 'Relatório', category: 'documentos' },
|
||||||
|
{ value: 'ri-file-paper-2-line', label: 'Laudo', category: 'documentos' },
|
||||||
|
{ value: 'ri-file-list-3-line', label: 'Lista', category: 'documentos' },
|
||||||
|
{ value: 'ri-clipboard-line', label: 'Prancheta', category: 'documentos' },
|
||||||
|
{ value: 'ri-survey-line', label: 'Pesquisa', category: 'documentos' },
|
||||||
|
{ value: 'ri-task-line', label: 'Tarefa', category: 'documentos' },
|
||||||
|
|
||||||
|
// Segurança
|
||||||
|
{ value: 'ri-shield-check-line', label: 'Escudo Check', category: 'seguranca' },
|
||||||
|
{ value: 'ri-shield-line', label: 'Escudo', category: 'seguranca' },
|
||||||
|
{ value: 'ri-shield-star-line', label: 'Escudo Estrela', category: 'seguranca' },
|
||||||
|
{ value: 'ri-safe-line', label: 'Cofre', category: 'seguranca' },
|
||||||
|
{ value: 'ri-alarm-warning-line', label: 'Alerta', category: 'seguranca' },
|
||||||
|
{ value: 'ri-error-warning-line', label: 'Aviso', category: 'seguranca' },
|
||||||
|
|
||||||
|
// Construção e Equipamentos
|
||||||
|
{ value: 'ri-building-2-line', label: 'Prédio', category: 'construcao' },
|
||||||
|
{ value: 'ri-building-line', label: 'Edifício', category: 'construcao' },
|
||||||
|
{ value: 'ri-home-gear-line', label: 'Casa Engrenagem', category: 'construcao' },
|
||||||
|
{ value: 'ri-home-line', label: 'Casa', category: 'construcao' },
|
||||||
|
{ value: 'ri-store-2-line', label: 'Loja', category: 'construcao' },
|
||||||
|
|
||||||
|
// Inspeção e Verificação
|
||||||
|
{ value: 'ri-search-eye-line', label: 'Inspeção', category: 'inspecao' },
|
||||||
|
{ value: 'ri-flashlight-line', label: 'Lanterna', category: 'inspecao' },
|
||||||
|
{ value: 'ri-eye-line', label: 'Olho', category: 'inspecao' },
|
||||||
|
{ value: 'ri-zoom-in-line', label: 'Zoom', category: 'inspecao' },
|
||||||
|
{ value: 'ri-checkbox-circle-line', label: 'Check Círculo', category: 'inspecao' },
|
||||||
|
{ value: 'ri-check-double-line', label: 'Duplo Check', category: 'inspecao' },
|
||||||
|
|
||||||
|
// Geral
|
||||||
|
{ value: 'ri-lightbulb-line', label: 'Lâmpada', category: 'geral' },
|
||||||
|
{ value: 'ri-cpu-line', label: 'CPU', category: 'geral' },
|
||||||
|
{ value: 'ri-dashboard-3-line', label: 'Painel', category: 'geral' },
|
||||||
|
{ value: 'ri-bar-chart-box-line', label: 'Gráfico', category: 'geral' },
|
||||||
|
{ value: 'ri-pie-chart-line', label: 'Pizza', category: 'geral' },
|
||||||
|
{ value: 'ri-team-line', label: 'Equipe', category: 'geral' },
|
||||||
|
{ value: 'ri-user-settings-line', label: 'Usuário Config', category: 'geral' },
|
||||||
|
{ value: 'ri-customer-service-line', label: 'Suporte', category: 'geral' },
|
||||||
|
{ value: 'ri-hand-heart-line', label: 'Cuidado', category: 'geral' },
|
||||||
|
{ value: 'ri-award-line', label: 'Prêmio', category: 'geral' },
|
||||||
|
{ value: 'ri-medal-line', label: 'Medalha', category: 'geral' },
|
||||||
|
{ value: 'ri-verified-badge-line', label: 'Verificado', category: 'geral' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function EditService({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const resolvedParams = use(params);
|
||||||
|
const router = useRouter();
|
||||||
|
const { success, error } = useToast();
|
||||||
|
const [loadingData, setLoadingData] = useState(true);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [iconSearch, setIconSearch] = useState('');
|
||||||
|
const [showIconPicker, setShowIconPicker] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: '',
|
||||||
|
icon: 'ri-settings-3-line',
|
||||||
|
active: true,
|
||||||
|
order: 0,
|
||||||
|
shortDescription: '',
|
||||||
|
fullDescription: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filtrar ícones pela busca
|
||||||
|
const filteredIcons = useMemo(() => {
|
||||||
|
if (!iconSearch) return ICON_LIST;
|
||||||
|
const search = iconSearch.toLowerCase();
|
||||||
|
return ICON_LIST.filter(icon =>
|
||||||
|
icon.label.toLowerCase().includes(search) ||
|
||||||
|
icon.value.toLowerCase().includes(search) ||
|
||||||
|
icon.category.toLowerCase().includes(search)
|
||||||
|
);
|
||||||
|
}, [iconSearch]);
|
||||||
|
|
||||||
|
// Carregar dados do serviço
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchService() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/services/${resolvedParams.id}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Serviço não encontrado');
|
||||||
|
}
|
||||||
|
const service = await res.json();
|
||||||
|
|
||||||
|
setFormData({
|
||||||
|
title: service.title || '',
|
||||||
|
icon: service.icon || 'ri-settings-3-line',
|
||||||
|
active: service.active ?? true,
|
||||||
|
order: service.order ?? 0,
|
||||||
|
shortDescription: service.shortDescription || '',
|
||||||
|
fullDescription: service.fullDescription || '',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao carregar serviço:', err);
|
||||||
|
error('Não foi possível carregar o serviço.');
|
||||||
|
router.push('/admin/servicos');
|
||||||
|
} finally {
|
||||||
|
setLoadingData(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchService();
|
||||||
|
}, [resolvedParams.id, error, router]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!formData.title) {
|
||||||
|
error('Informe ao menos o título do serviço.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/services/${resolvedParams.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: formData.title.trim(),
|
||||||
|
icon: formData.icon.trim() || 'ri-settings-3-line',
|
||||||
|
shortDescription: formData.shortDescription?.trim() || null,
|
||||||
|
fullDescription: formData.fullDescription?.trim() || null,
|
||||||
|
active: formData.active,
|
||||||
|
order: formData.order,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data?.error || 'Erro ao atualizar serviço');
|
||||||
|
}
|
||||||
|
|
||||||
|
success('Serviço atualizado com sucesso!');
|
||||||
|
router.push('/admin/servicos');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao atualizar serviço:', err);
|
||||||
|
error(err instanceof Error ? err.message : 'Não foi possível atualizar o serviço.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loadingData) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<i className="ri-loader-4-line animate-spin text-4xl text-primary"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<Link
|
||||||
|
href="/admin/servicos"
|
||||||
|
className="w-10 h-10 rounded-xl bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 flex items-center justify-center text-gray-500 hover:text-primary hover:border-primary transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
<i className="ri-arrow-left-line text-xl"></i>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-1">Editar Serviço</h1>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">Atualize as informações do serviço.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
|
{/* Informações Básicas */}
|
||||||
|
<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 Básicas
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título do Serviço</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => setFormData({...formData, title: e.target.value})}
|
||||||
|
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"
|
||||||
|
placeholder="Ex: Engenharia Veicular"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Ícone do Serviço</label>
|
||||||
|
|
||||||
|
{/* Ícone selecionado */}
|
||||||
|
<div
|
||||||
|
onClick={() => setShowIconPicker(!showIconPicker)}
|
||||||
|
className="flex items-center gap-4 p-4 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl cursor-pointer hover:border-primary transition-colors"
|
||||||
|
>
|
||||||
|
<div className="w-14 h-14 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center">
|
||||||
|
<i className={`${formData.icon} text-2xl text-primary`}></i>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white">{ICON_LIST.find(i => i.value === formData.icon)?.label || formData.icon}</p>
|
||||||
|
<p className="text-sm text-gray-500">{formData.icon}</p>
|
||||||
|
</div>
|
||||||
|
<i className={`ri-arrow-${showIconPicker ? 'up' : 'down'}-s-line text-xl text-gray-400`}></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Picker de ícones */}
|
||||||
|
{showIconPicker && (
|
||||||
|
<div className="mt-3 p-4 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl shadow-lg">
|
||||||
|
{/* Busca */}
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<i className="ri-search-line absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar ícone..."
|
||||||
|
value={iconSearch}
|
||||||
|
onChange={(e) => setIconSearch(e.target.value)}
|
||||||
|
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-gray-900 dark:text-white focus:outline-none focus:border-primary text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grade de ícones */}
|
||||||
|
<div className="grid grid-cols-6 sm:grid-cols-8 md:grid-cols-10 gap-2 max-h-64 overflow-y-auto">
|
||||||
|
{filteredIcons.map((icon) => (
|
||||||
|
<button
|
||||||
|
key={icon.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFormData({...formData, icon: icon.value});
|
||||||
|
setShowIconPicker(false);
|
||||||
|
setIconSearch('');
|
||||||
|
}}
|
||||||
|
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-all ${
|
||||||
|
formData.icon === icon.value
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-gray-100 dark:bg-white/5 text-gray-600 dark:text-gray-400 hover:bg-primary/10 hover:text-primary'
|
||||||
|
}`}
|
||||||
|
title={icon.label}
|
||||||
|
>
|
||||||
|
<i className={`${icon.value} text-lg`}></i>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredIcons.length === 0 && (
|
||||||
|
<p className="text-center text-gray-500 py-4 text-sm">Nenhum ícone encontrado</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input manual */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-white/10">
|
||||||
|
<p className="text-xs text-gray-500 mb-2">Ou digite manualmente uma classe do Remix Icon:</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.icon}
|
||||||
|
onChange={(e) => setFormData({...formData, icon: e.target.value})}
|
||||||
|
className="w-full px-3 py-2 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg text-gray-900 dark:text-white text-sm focus:outline-none focus:border-primary"
|
||||||
|
placeholder="ri-icon-name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Status</label>
|
||||||
|
<select
|
||||||
|
value={formData.active ? 'active' : 'inactive'}
|
||||||
|
onChange={(e) => setFormData({...formData, active: e.target.value === 'active'})}
|
||||||
|
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 appearance-none cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="active">Ativo</option>
|
||||||
|
<option value="inactive">Inativo</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Ordem de Exibição</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.order}
|
||||||
|
onChange={(e) => setFormData({...formData, order: parseInt(e.target.value) || 0})}
|
||||||
|
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"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Menor número = aparece primeiro</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Descrições */}
|
||||||
|
<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-file-text-line text-primary"></i>
|
||||||
|
Descrições
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Descrição Curta</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.shortDescription}
|
||||||
|
onChange={(e) => setFormData({...formData, shortDescription: e.target.value})}
|
||||||
|
rows={2}
|
||||||
|
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"
|
||||||
|
placeholder="Uma breve descrição do serviço (exibida em cards e listagens)"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{formData.shortDescription.length}/200 caracteres recomendados</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Descrição Completa</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.fullDescription}
|
||||||
|
onChange={(e) => setFormData({...formData, fullDescription: e.target.value})}
|
||||||
|
rows={6}
|
||||||
|
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"
|
||||||
|
placeholder="Descrição detalhada do serviço, benefícios, diferenciais, etc."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<div className="flex items-center justify-end gap-4 pt-4">
|
||||||
|
<Link
|
||||||
|
href="/admin/servicos"
|
||||||
|
className="px-6 py-3 rounded-xl border border-gray-300 dark:border-white/20 text-gray-700 dark:text-white font-medium hover:bg-gray-100 dark:hover:bg-white/5 transition-all"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="px-8 py-3 bg-primary text-white rounded-xl font-bold hover:bg-primary/90 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 shadow-lg shadow-primary/25"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<i className="ri-loader-4-line animate-spin"></i>
|
||||||
|
Salvando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<i className="ri-save-line"></i>
|
||||||
|
Salvar Alterações
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,30 +1,144 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useToast } from '@/contexts/ToastContext';
|
||||||
|
|
||||||
|
// Lista expandida de ícones do Remix Icon organizados por categoria
|
||||||
|
const ICON_LIST = [
|
||||||
|
// Veículos e Transporte
|
||||||
|
{ value: 'ri-truck-line', label: 'Caminhão', category: 'veiculos' },
|
||||||
|
{ value: 'ri-truck-fill', label: 'Caminhão Preenchido', category: 'veiculos' },
|
||||||
|
{ value: 'ri-bus-line', label: 'Ônibus', category: 'veiculos' },
|
||||||
|
{ value: 'ri-car-line', label: 'Carro', category: 'veiculos' },
|
||||||
|
{ value: 'ri-roadster-line', label: 'Veículo Esportivo', category: 'veiculos' },
|
||||||
|
{ value: 'ri-taxi-line', label: 'Táxi', category: 'veiculos' },
|
||||||
|
{ value: 'ri-caravan-line', label: 'Trailer', category: 'veiculos' },
|
||||||
|
|
||||||
|
// Ferramentas e Engenharia
|
||||||
|
{ value: 'ri-tools-line', label: 'Ferramentas', category: 'engenharia' },
|
||||||
|
{ value: 'ri-tools-fill', label: 'Ferramentas Preenchido', category: 'engenharia' },
|
||||||
|
{ value: 'ri-hammer-line', label: 'Martelo', category: 'engenharia' },
|
||||||
|
{ value: 'ri-screwdriver-line', label: 'Chave de Fenda', category: 'engenharia' },
|
||||||
|
{ value: 'ri-settings-3-line', label: 'Configurações', category: 'engenharia' },
|
||||||
|
{ value: 'ri-settings-4-line', label: 'Engrenagem', category: 'engenharia' },
|
||||||
|
{ value: 'ri-compass-3-line', label: 'Compasso', category: 'engenharia' },
|
||||||
|
{ value: 'ri-ruler-line', label: 'Régua', category: 'engenharia' },
|
||||||
|
{ value: 'ri-ruler-2-line', label: 'Régua 2', category: 'engenharia' },
|
||||||
|
{ value: 'ri-pencil-ruler-line', label: 'Lápis e Régua', category: 'engenharia' },
|
||||||
|
|
||||||
|
// Documentos e Laudos
|
||||||
|
{ value: 'ri-draft-line', label: 'Documento', category: 'documentos' },
|
||||||
|
{ value: 'ri-file-text-line', label: 'Arquivo Texto', category: 'documentos' },
|
||||||
|
{ value: 'ri-file-chart-line', label: 'Relatório', category: 'documentos' },
|
||||||
|
{ value: 'ri-file-paper-2-line', label: 'Laudo', category: 'documentos' },
|
||||||
|
{ value: 'ri-file-list-3-line', label: 'Lista', category: 'documentos' },
|
||||||
|
{ value: 'ri-clipboard-line', label: 'Prancheta', category: 'documentos' },
|
||||||
|
{ value: 'ri-survey-line', label: 'Pesquisa', category: 'documentos' },
|
||||||
|
{ value: 'ri-task-line', label: 'Tarefa', category: 'documentos' },
|
||||||
|
|
||||||
|
// Segurança
|
||||||
|
{ value: 'ri-shield-check-line', label: 'Escudo Check', category: 'seguranca' },
|
||||||
|
{ value: 'ri-shield-line', label: 'Escudo', category: 'seguranca' },
|
||||||
|
{ value: 'ri-shield-star-line', label: 'Escudo Estrela', category: 'seguranca' },
|
||||||
|
{ value: 'ri-safe-line', label: 'Cofre', category: 'seguranca' },
|
||||||
|
{ value: 'ri-alarm-warning-line', label: 'Alerta', category: 'seguranca' },
|
||||||
|
{ value: 'ri-error-warning-line', label: 'Aviso', category: 'seguranca' },
|
||||||
|
|
||||||
|
// Construção e Equipamentos
|
||||||
|
{ value: 'ri-building-2-line', label: 'Prédio', category: 'construcao' },
|
||||||
|
{ value: 'ri-building-line', label: 'Edifício', category: 'construcao' },
|
||||||
|
{ value: 'ri-home-gear-line', label: 'Casa Engrenagem', category: 'construcao' },
|
||||||
|
{ value: 'ri-home-line', label: 'Casa', category: 'construcao' },
|
||||||
|
{ value: 'ri-store-2-line', label: 'Loja', category: 'construcao' },
|
||||||
|
|
||||||
|
// Inspeção e Verificação
|
||||||
|
{ value: 'ri-search-eye-line', label: 'Inspeção', category: 'inspecao' },
|
||||||
|
{ value: 'ri-flashlight-line', label: 'Lanterna', category: 'inspecao' },
|
||||||
|
{ value: 'ri-eye-line', label: 'Olho', category: 'inspecao' },
|
||||||
|
{ value: 'ri-zoom-in-line', label: 'Zoom', category: 'inspecao' },
|
||||||
|
{ value: 'ri-checkbox-circle-line', label: 'Check Círculo', category: 'inspecao' },
|
||||||
|
{ value: 'ri-check-double-line', label: 'Duplo Check', category: 'inspecao' },
|
||||||
|
|
||||||
|
// Geral
|
||||||
|
{ value: 'ri-lightbulb-line', label: 'Lâmpada', category: 'geral' },
|
||||||
|
{ value: 'ri-cpu-line', label: 'CPU', category: 'geral' },
|
||||||
|
{ value: 'ri-dashboard-3-line', label: 'Painel', category: 'geral' },
|
||||||
|
{ value: 'ri-bar-chart-box-line', label: 'Gráfico', category: 'geral' },
|
||||||
|
{ value: 'ri-pie-chart-line', label: 'Pizza', category: 'geral' },
|
||||||
|
{ value: 'ri-team-line', label: 'Equipe', category: 'geral' },
|
||||||
|
{ value: 'ri-user-settings-line', label: 'Usuário Config', category: 'geral' },
|
||||||
|
{ value: 'ri-customer-service-line', label: 'Suporte', category: 'geral' },
|
||||||
|
{ value: 'ri-hand-heart-line', label: 'Cuidado', category: 'geral' },
|
||||||
|
{ value: 'ri-award-line', label: 'Prêmio', category: 'geral' },
|
||||||
|
{ value: 'ri-medal-line', label: 'Medalha', category: 'geral' },
|
||||||
|
{ value: 'ri-verified-badge-line', label: 'Verificado', category: 'geral' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function NewService() {
|
export default function NewService() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { success, error } = useToast();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [iconSearch, setIconSearch] = useState('');
|
||||||
|
const [showIconPicker, setShowIconPicker] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
title: '',
|
title: '',
|
||||||
icon: 'ri-settings-3-line',
|
icon: 'ri-settings-3-line',
|
||||||
status: 'active',
|
active: true,
|
||||||
|
order: 0,
|
||||||
shortDescription: '',
|
shortDescription: '',
|
||||||
fullDescription: ''
|
fullDescription: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Filtrar ícones pela busca
|
||||||
|
const filteredIcons = useMemo(() => {
|
||||||
|
if (!iconSearch) return ICON_LIST;
|
||||||
|
const search = iconSearch.toLowerCase();
|
||||||
|
return ICON_LIST.filter(icon =>
|
||||||
|
icon.label.toLowerCase().includes(search) ||
|
||||||
|
icon.value.toLowerCase().includes(search) ||
|
||||||
|
icon.category.toLowerCase().includes(search)
|
||||||
|
);
|
||||||
|
}, [iconSearch]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
// Simulate API call
|
if (!formData.title) {
|
||||||
setTimeout(() => {
|
error('Informe ao menos o título do serviço.');
|
||||||
console.log('Service data:', formData);
|
return;
|
||||||
setLoading(false);
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/services', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: formData.title.trim(),
|
||||||
|
icon: formData.icon.trim() || 'ri-settings-3-line',
|
||||||
|
shortDescription: formData.shortDescription?.trim() || null,
|
||||||
|
fullDescription: formData.fullDescription?.trim() || null,
|
||||||
|
active: formData.active,
|
||||||
|
order: formData.order,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data?.error || 'Erro ao salvar serviço');
|
||||||
|
}
|
||||||
|
|
||||||
|
success('Serviço cadastrado com sucesso!');
|
||||||
router.push('/admin/servicos');
|
router.push('/admin/servicos');
|
||||||
}, 1500);
|
} catch (err) {
|
||||||
|
console.error('Erro ao salvar serviço:', err);
|
||||||
|
error(err instanceof Error ? err.message : 'Não foi possível salvar o serviço.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -62,31 +176,86 @@ export default function NewService() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="md:col-span-2">
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Ícone (Remix Icon Class)</label>
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Ícone do Serviço</label>
|
||||||
<div className="flex gap-3">
|
|
||||||
<div className="w-12 h-12 rounded-xl bg-primary/10 text-primary flex items-center justify-center text-xl shrink-0">
|
{/* Ícone selecionado */}
|
||||||
<i className={formData.icon}></i>
|
<div
|
||||||
|
onClick={() => setShowIconPicker(!showIconPicker)}
|
||||||
|
className="flex items-center gap-4 p-4 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl cursor-pointer hover:border-primary transition-colors"
|
||||||
|
>
|
||||||
|
<div className="w-14 h-14 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center">
|
||||||
|
<i className={`${formData.icon} text-2xl text-primary`}></i>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white">{ICON_LIST.find(i => i.value === formData.icon)?.label || formData.icon}</p>
|
||||||
|
<p className="text-sm text-gray-500">{formData.icon}</p>
|
||||||
|
</div>
|
||||||
|
<i className={`ri-arrow-${showIconPicker ? 'up' : 'down'}-s-line text-xl text-gray-400`}></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Picker de ícones */}
|
||||||
|
{showIconPicker && (
|
||||||
|
<div className="mt-3 p-4 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl shadow-lg">
|
||||||
|
{/* Busca */}
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<i className="ri-search-line absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar ícone..."
|
||||||
|
value={iconSearch}
|
||||||
|
onChange={(e) => setIconSearch(e.target.value)}
|
||||||
|
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-gray-900 dark:text-white focus:outline-none focus:border-primary text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grade de ícones */}
|
||||||
|
<div className="grid grid-cols-6 sm:grid-cols-8 md:grid-cols-10 gap-2 max-h-64 overflow-y-auto">
|
||||||
|
{filteredIcons.map((icon) => (
|
||||||
|
<button
|
||||||
|
key={icon.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFormData({...formData, icon: icon.value});
|
||||||
|
setShowIconPicker(false);
|
||||||
|
setIconSearch('');
|
||||||
|
}}
|
||||||
|
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-all ${
|
||||||
|
formData.icon === icon.value
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-gray-100 dark:bg-white/5 text-gray-600 dark:text-gray-400 hover:bg-primary/10 hover:text-primary'
|
||||||
|
}`}
|
||||||
|
title={icon.label}
|
||||||
|
>
|
||||||
|
<i className={`${icon.value} text-lg`}></i>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredIcons.length === 0 && (
|
||||||
|
<p className="text-center text-gray-500 py-4 text-sm">Nenhum ícone encontrado</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input manual */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-white/10">
|
||||||
|
<p className="text-xs text-gray-500 mb-2">Ou digite manualmente uma classe do Remix Icon:</p>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.icon}
|
value={formData.icon}
|
||||||
onChange={(e) => setFormData({...formData, icon: e.target.value})}
|
onChange={(e) => setFormData({...formData, icon: e.target.value})}
|
||||||
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-3 py-2 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg text-gray-900 dark:text-white text-sm focus:outline-none focus:border-primary"
|
||||||
placeholder="Ex: ri-truck-line"
|
placeholder="ri-icon-name"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
</div>
|
||||||
Consulte a lista de ícones em <a href="https://remixicon.com" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">remixicon.com</a>
|
)}
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Status</label>
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Status</label>
|
||||||
<select
|
<select
|
||||||
value={formData.status}
|
value={formData.active ? 'active' : 'inactive'}
|
||||||
onChange={(e) => setFormData({...formData, status: e.target.value})}
|
onChange={(e) => setFormData({...formData, active: e.target.value === 'active'})}
|
||||||
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 appearance-none cursor-pointer"
|
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 appearance-none cursor-pointer"
|
||||||
>
|
>
|
||||||
<option value="active">Ativo</option>
|
<option value="active">Ativo</option>
|
||||||
@@ -94,6 +263,18 @@ export default function NewService() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Ordem de Exibição</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.order}
|
||||||
|
onChange={(e) => setFormData({...formData, order: parseInt(e.target.value) || 0})}
|
||||||
|
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"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Menor número = aparece primeiro</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Descrição Curta (Resumo)</label>
|
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Descrição Curta (Resumo)</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,8 +1,110 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useToast } from '@/contexts/ToastContext';
|
||||||
|
import { useConfirm } from '@/contexts/ConfirmContext';
|
||||||
|
|
||||||
|
interface Service {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
shortDescription: string | null;
|
||||||
|
fullDescription: string | null;
|
||||||
|
active: boolean;
|
||||||
|
order: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ServicesList() {
|
export default function ServicesList() {
|
||||||
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [filterStatus, setFilterStatus] = useState<string>('');
|
||||||
|
const { success, error } = useToast();
|
||||||
|
const { confirm } = useConfirm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchServices();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchServices = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch('/api/services');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Falha ao carregar serviços');
|
||||||
|
}
|
||||||
|
const data: Service[] = await response.json();
|
||||||
|
setServices(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao carregar serviços:', err);
|
||||||
|
error('Não foi possível carregar os serviços.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filtrar serviços
|
||||||
|
const filteredServices = useMemo(() => {
|
||||||
|
return services.filter((service) => {
|
||||||
|
const matchesSearch = !searchTerm ||
|
||||||
|
service.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
service.shortDescription?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
service.fullDescription?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
|
||||||
|
const matchesStatus = !filterStatus ||
|
||||||
|
(filterStatus === 'active' && service.active) ||
|
||||||
|
(filterStatus === 'inactive' && !service.active);
|
||||||
|
|
||||||
|
return matchesSearch && matchesStatus;
|
||||||
|
});
|
||||||
|
}, [services, searchTerm, filterStatus]);
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearchTerm('');
|
||||||
|
setFilterStatus('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasActiveFilters = searchTerm || filterStatus;
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: 'Excluir serviço',
|
||||||
|
message: 'Tem certeza que deseja remover este serviço? Esta ação não pode ser desfeita.',
|
||||||
|
confirmText: 'Excluir',
|
||||||
|
cancelText: 'Cancelar',
|
||||||
|
type: 'danger',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/services/${id}`, { method: 'DELETE' });
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result?.error || 'Falha ao excluir serviço');
|
||||||
|
}
|
||||||
|
|
||||||
|
success('Serviço excluído com sucesso!');
|
||||||
|
fetchServices();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao excluir serviço:', err);
|
||||||
|
error('Não foi possível excluir o serviço.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (value: string | null) => {
|
||||||
|
if (!value) return '—';
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('pt-BR').format(new Date(value));
|
||||||
|
} catch (err) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
@@ -19,48 +121,153 @@ export default function ServicesList() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Filters Section */}
|
||||||
|
<div className="bg-white dark:bg-secondary rounded-xl border border-gray-200 dark:border-white/10 shadow-sm p-4 mb-6">
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Pesquisar serviços..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full px-4 py-2.5 pl-10 rounded-lg border border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5 text-secondary dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
|
||||||
|
/>
|
||||||
|
<i className="ri-search-line absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
|
||||||
|
{searchTerm && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchTerm('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<i className="ri-close-line"></i>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className="w-full lg:w-48">
|
||||||
|
<select
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={(e) => setFilterStatus(e.target.value)}
|
||||||
|
className="w-full px-4 py-2.5 rounded-lg border border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5 text-secondary dark:text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all appearance-none cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="">Todos os status</option>
|
||||||
|
<option value="active">Ativo</option>
|
||||||
|
<option value="inactive">Inativo</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clear Filters */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="px-4 py-2.5 text-gray-500 hover:text-red-500 dark:text-gray-400 dark:hover:text-red-400 font-medium transition-colors flex items-center gap-1 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<i className="ri-filter-off-line"></i>
|
||||||
|
Limpar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results count */}
|
||||||
|
{!loading && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-100 dark:border-white/5 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{hasActiveFilters ? (
|
||||||
|
<span>Exibindo {filteredServices.length} de {services.length} serviços</span>
|
||||||
|
) : (
|
||||||
|
<span>{services.length} serviço{services.length !== 1 ? 's' : ''} cadastrado{services.length !== 1 ? 's' : ''}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-secondary rounded-xl border border-gray-200 dark:border-white/10 shadow-sm overflow-hidden">
|
<div className="bg-white dark:bg-secondary rounded-xl border border-gray-200 dark:border-white/10 shadow-sm overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<i className="ri-loader-4-line animate-spin text-3xl text-primary"></i>
|
||||||
|
</div>
|
||||||
|
) : filteredServices.length === 0 ? (
|
||||||
|
<div className="py-16 text-center text-gray-500 dark:text-gray-400 flex flex-col items-center gap-3">
|
||||||
|
<i className="ri-customer-service-2-line text-4xl"></i>
|
||||||
|
{hasActiveFilters ? (
|
||||||
|
<>
|
||||||
|
Nenhum serviço encontrado com os filtros aplicados.
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="mt-2 px-4 py-2 bg-primary text-white rounded-lg font-medium hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
Limpar filtros
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Nenhum serviço cadastrado ainda.'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left border-collapse">
|
<table className="w-full text-left border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-gray-50 dark:bg-white/5 border-b border-gray-200 dark:border-white/10">
|
<tr className="bg-gray-50 dark:bg-white/5 border-b border-gray-200 dark:border-white/10">
|
||||||
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Ícone</th>
|
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Serviço</th>
|
||||||
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Título</th>
|
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Descrição</th>
|
||||||
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Descrição Curta</th>
|
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Ordem</th>
|
||||||
|
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Criado em</th>
|
||||||
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Status</th>
|
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Status</th>
|
||||||
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider text-right">Ações</th>
|
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider text-right">Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100 dark:divide-white/5">
|
<tbody className="divide-y divide-gray-100 dark:divide-white/5">
|
||||||
{[
|
{filteredServices.map((service) => (
|
||||||
{ icon: 'ri-truck-line', title: 'Engenharia Veicular', desc: 'Homologação e regularização de veículos modificados.', status: 'Ativo' },
|
<tr key={service.id} className="hover:bg-gray-50 dark:hover:bg-white/5 transition-colors group">
|
||||||
{ icon: 'ri-tools-line', title: 'Projetos Mecânicos', desc: 'Desenvolvimento de máquinas e equipamentos industriais.', status: 'Ativo' },
|
|
||||||
{ icon: 'ri-file-list-3-line', title: 'Laudos Técnicos', desc: 'Vistorias, perícias e emissão de ART.', status: 'Ativo' },
|
|
||||||
{ icon: 'ri-shield-check-line', title: 'Segurança do Trabalho', desc: 'Consultoria em normas regulamentadoras (NRs).', status: 'Inativo' },
|
|
||||||
].map((service, index) => (
|
|
||||||
<tr key={index} className="hover:bg-gray-50 dark:hover:bg-white/5 transition-colors group">
|
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
<div className="w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center text-xl">
|
<div className="flex items-center gap-3">
|
||||||
<i className={service.icon}></i>
|
<div className="w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center text-xl shrink-0">
|
||||||
|
<i className={service.icon || 'ri-settings-3-line'}></i>
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-secondary dark:text-white">{service.title}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-gray-600 dark:text-gray-400 max-w-xs">
|
||||||
|
<span className="line-clamp-2">{service.shortDescription || '—'}</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-gray-600 dark:text-gray-400">
|
||||||
|
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-gray-100 dark:bg-white/10 font-bold text-sm">
|
||||||
|
{service.order}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-gray-600 dark:text-gray-400">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{formatDate(service.createdAt)}</span>
|
||||||
|
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{new Date(service.createdAt).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4 font-bold text-secondary dark:text-white">{service.title}</td>
|
|
||||||
<td className="p-4 text-gray-600 dark:text-gray-400 max-w-xs truncate">{service.desc}</td>
|
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
<span className={`px-3 py-1 rounded-full text-xs font-bold ${
|
<span className={`px-3 py-1 rounded-full text-xs font-bold ${
|
||||||
service.status === 'Ativo'
|
service.active
|
||||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||||
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400'
|
: 'bg-gray-100 text-gray-600 dark:bg-white/10 dark:text-gray-300'
|
||||||
}`}>
|
}`}>
|
||||||
{service.status}
|
{service.active ? 'Ativo' : 'Inativo'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4 text-right">
|
<td className="p-4 text-right">
|
||||||
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button className="w-8 h-8 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10 flex items-center justify-center text-gray-500 hover:text-primary transition-colors cursor-pointer" title="Editar">
|
<Link
|
||||||
|
href={`/admin/servicos/${service.id}/editar`}
|
||||||
|
className="w-8 h-8 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/20 flex items-center justify-center text-gray-500 hover:text-blue-500 transition-colors"
|
||||||
|
title="Editar"
|
||||||
|
>
|
||||||
<i className="ri-pencil-line"></i>
|
<i className="ri-pencil-line"></i>
|
||||||
</button>
|
</Link>
|
||||||
<button className="w-8 h-8 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center justify-center text-gray-500 hover:text-red-500 transition-colors cursor-pointer" title="Excluir">
|
<button
|
||||||
|
onClick={() => handleDelete(service.id)}
|
||||||
|
className="w-8 h-8 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center justify-center text-gray-500 hover:text-red-500 transition-colors cursor-pointer"
|
||||||
|
title="Excluir"
|
||||||
|
>
|
||||||
<i className="ri-delete-bin-line"></i>
|
<i className="ri-delete-bin-line"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,6 +277,7 @@ export default function ServicesList() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
193
frontend/src/app/api/admin/translate-pages/route.ts
Normal file
193
frontend/src/app/api/admin/translate-pages/route.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
const LIBRETRANSLATE_URL = process.env.LIBRETRANSLATE_URL || 'https://libretranslate.stackbyte.cloud';
|
||||||
|
const SUPPORTED_LOCALES = ['en', 'es'];
|
||||||
|
|
||||||
|
// Autenticação
|
||||||
|
async function authenticate() {
|
||||||
|
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 {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traduzir um texto
|
||||||
|
async function translateText(text: string, targetLang: string): Promise<string> {
|
||||||
|
if (!text || text.trim() === '' || targetLang === 'pt') return text;
|
||||||
|
|
||||||
|
// Verificar cache no banco primeiro
|
||||||
|
const cached = await prisma.translation.findUnique({
|
||||||
|
where: {
|
||||||
|
sourceText_sourceLang_targetLang: {
|
||||||
|
sourceText: text,
|
||||||
|
sourceLang: 'pt',
|
||||||
|
targetLang: targetLang,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
return cached.translatedText;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[i18n] Traduzindo: "${text.substring(0, 30)}..." para ${targetLang}`);
|
||||||
|
|
||||||
|
const response = await fetch(`${LIBRETRANSLATE_URL}/translate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ q: text, source: 'pt', target: targetLang, format: 'text' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const translatedText = data.translatedText || text;
|
||||||
|
|
||||||
|
// Salvar no cache
|
||||||
|
try {
|
||||||
|
await prisma.translation.create({
|
||||||
|
data: {
|
||||||
|
sourceText: text,
|
||||||
|
sourceLang: 'pt',
|
||||||
|
targetLang: targetLang,
|
||||||
|
translatedText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignorar se já existe
|
||||||
|
}
|
||||||
|
|
||||||
|
return translatedText;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[i18n] Erro ao traduzir para ${targetLang}:`, error);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traduzir objeto recursivamente
|
||||||
|
async function translateContent(content: unknown, targetLang: string): Promise<unknown> {
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
return await translateText(content, targetLang);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
const results = [];
|
||||||
|
for (const item of content) {
|
||||||
|
results.push(await translateContent(item, targetLang));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content && typeof content === 'object') {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(content)) {
|
||||||
|
// Não traduzir campos técnicos
|
||||||
|
if (['icon', 'image', 'img', 'url', 'href', 'id', 'slug', 'src', 'link'].includes(key)) {
|
||||||
|
result[key] = value;
|
||||||
|
} else {
|
||||||
|
result[key] = await translateContent(value, targetLang);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/admin/translate-pages - Traduzir todas as páginas para EN e ES
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await authenticate();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const slugFilter = body.slug; // Opcional: traduzir só uma página específica
|
||||||
|
|
||||||
|
// Buscar todas as páginas em português
|
||||||
|
const ptPages = await prisma.pageContent.findMany({
|
||||||
|
where: slugFilter
|
||||||
|
? { slug: slugFilter, locale: 'pt' }
|
||||||
|
: { locale: 'pt' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ptPages.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Nenhuma página encontrada para traduzir' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: { slug: string; locale: string; status: string }[] = [];
|
||||||
|
|
||||||
|
for (const page of ptPages) {
|
||||||
|
for (const targetLocale of SUPPORTED_LOCALES) {
|
||||||
|
try {
|
||||||
|
console.log(`[i18n] Traduzindo página "${page.slug}" para ${targetLocale}...`);
|
||||||
|
|
||||||
|
const translatedContent = await translateContent(page.content, targetLocale) as Prisma.InputJsonValue;
|
||||||
|
|
||||||
|
await prisma.pageContent.upsert({
|
||||||
|
where: { slug_locale: { slug: page.slug, locale: targetLocale } },
|
||||||
|
update: { content: translatedContent },
|
||||||
|
create: { slug: page.slug, locale: targetLocale, content: translatedContent }
|
||||||
|
});
|
||||||
|
|
||||||
|
results.push({ slug: page.slug, locale: targetLocale, status: 'success' });
|
||||||
|
console.log(`[i18n] ✓ Página "${page.slug}" traduzida para ${targetLocale}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[i18n] ✗ Erro ao traduzir "${page.slug}" para ${targetLocale}:`, error);
|
||||||
|
results.push({ slug: page.slug, locale: targetLocale, status: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `Tradução concluída para ${ptPages.length} página(s)`,
|
||||||
|
results
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao traduzir páginas:', error);
|
||||||
|
return NextResponse.json({ error: 'Erro ao traduzir páginas' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/admin/translate-pages - Status das traduções
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const pages = await prisma.pageContent.findMany({
|
||||||
|
select: { slug: true, locale: true, updatedAt: true },
|
||||||
|
orderBy: [{ slug: 'asc' }, { locale: 'asc' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agrupar por slug
|
||||||
|
const grouped: Record<string, { pt?: Date; en?: Date; es?: Date }> = {};
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
if (!grouped[page.slug]) {
|
||||||
|
grouped[page.slug] = {};
|
||||||
|
}
|
||||||
|
grouped[page.slug][page.locale as 'pt' | 'en' | 'es'] = page.updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ pages: grouped });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar status:', error);
|
||||||
|
return NextResponse.json({ error: 'Erro ao buscar status' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,11 +72,8 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate public URL
|
const avatarPath = fileName;
|
||||||
const protocol = process.env.MINIO_USE_SSL === 'true' ? 'https' : 'http';
|
const avatarUrl = `/api/files/${avatarPath}`;
|
||||||
const endpoint = process.env.MINIO_ENDPOINT || 'localhost';
|
|
||||||
const port = process.env.MINIO_PORT || '9000';
|
|
||||||
const avatarUrl = `${protocol}://${endpoint}:${port}/${BUCKET_NAME}/${fileName}`;
|
|
||||||
|
|
||||||
// Delete old avatar if exists
|
// Delete old avatar if exists
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
@@ -86,10 +83,17 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
if (user?.avatar) {
|
if (user?.avatar) {
|
||||||
try {
|
try {
|
||||||
// Extract filename from URL
|
const sanitized = user.avatar.replace(/^https?:\/\/[^/]+/i, '');
|
||||||
const oldFileName = user.avatar.split(`${BUCKET_NAME}/`)[1];
|
let objectKey: string | undefined;
|
||||||
if (oldFileName) {
|
|
||||||
await minioClient.removeObject(BUCKET_NAME, oldFileName);
|
if (sanitized.startsWith('/api/files/')) {
|
||||||
|
objectKey = sanitized.replace('/api/files/', '');
|
||||||
|
} else if (sanitized.includes(`${BUCKET_NAME}/`)) {
|
||||||
|
objectKey = sanitized.split(`${BUCKET_NAME}/`)[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (objectKey) {
|
||||||
|
await minioClient.removeObject(BUCKET_NAME, objectKey);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting old avatar:', error);
|
console.error('Error deleting old avatar:', error);
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import prisma from '@/lib/prisma';
|
|||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
// Config é global, sempre usa 'pt' como locale base
|
||||||
const config = await prisma.pageContent.findUnique({
|
const config = await prisma.pageContent.findUnique({
|
||||||
where: { slug: 'config' }
|
where: { slug_locale: { slug: 'config', locale: 'pt' } }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
@@ -23,12 +24,13 @@ export async function PUT(request: NextRequest) {
|
|||||||
const { primaryColor } = await request.json();
|
const { primaryColor } = await request.json();
|
||||||
|
|
||||||
const config = await prisma.pageContent.upsert({
|
const config = await prisma.pageContent.upsert({
|
||||||
where: { slug: 'config' },
|
where: { slug_locale: { slug: 'config', locale: 'pt' } },
|
||||||
update: {
|
update: {
|
||||||
content: { primaryColor }
|
content: { primaryColor }
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
slug: 'config',
|
slug: 'config',
|
||||||
|
locale: 'pt',
|
||||||
content: { primaryColor }
|
content: { primaryColor }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
36
frontend/src/app/api/files/[...path]/route.ts
Normal file
36
frontend/src/app/api/files/[...path]/route.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { minioClient, bucketName } from '@/lib/minio';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await params;
|
||||||
|
const key = path.join('/');
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
return NextResponse.json({ error: 'Arquivo não especificado' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const object = await minioClient.getObject(bucketName, key);
|
||||||
|
|
||||||
|
if (!object || !object.Body) {
|
||||||
|
return NextResponse.json({ error: 'Arquivo não encontrado' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = object.ContentType || 'application/octet-stream';
|
||||||
|
const body = object.Body instanceof Readable ? Readable.toWeb(object.Body) : object.Body;
|
||||||
|
|
||||||
|
return new Response(body as ReadableStream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[files] Erro ao buscar arquivo:', error);
|
||||||
|
return NextResponse.json({ error: 'Erro ao buscar arquivo' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
5
frontend/src/app/api/health/route.ts
Normal file
5
frontend/src/app/api/health/route.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const LIBRETRANSLATE_URL = process.env.LIBRETRANSLATE_URL || 'https://libretranslate.stackbyte.cloud';
|
||||||
|
const SUPPORTED_LOCALES = ['pt', 'en', 'es'];
|
||||||
|
const TARGET_TRANSLATION_LOCALES: Array<'en' | 'es'> = ['en', 'es'];
|
||||||
|
|
||||||
// Middleware de autenticação
|
// Middleware de autenticação
|
||||||
async function authenticate() {
|
async function authenticate() {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
@@ -24,23 +29,169 @@ async function authenticate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tradução com cache e timeout
|
||||||
|
async function translateText(text: string, targetLang: string): Promise<string> {
|
||||||
|
if (!text || text.trim() === '' || targetLang === 'pt') return text;
|
||||||
|
|
||||||
|
// Buscar do cache primeiro
|
||||||
|
try {
|
||||||
|
const cached = await prisma.translation.findUnique({
|
||||||
|
where: {
|
||||||
|
sourceText_sourceLang_targetLang: {
|
||||||
|
sourceText: text,
|
||||||
|
sourceLang: 'pt',
|
||||||
|
targetLang
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
return cached.translatedText;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[i18n] Erro ao buscar cache de tradução:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traduzir via LibreTranslate com timeout de 30s
|
||||||
|
try {
|
||||||
|
console.log(`[i18n] Traduzindo "${text.substring(0, 50)}..." para ${targetLang}`);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||||||
|
|
||||||
|
const response = await fetch(`${LIBRETRANSLATE_URL}/translate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ q: text, source: 'pt', target: targetLang, format: 'text' }),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const translatedText = data.translatedText || text;
|
||||||
|
|
||||||
|
// Salvar no cache
|
||||||
|
try {
|
||||||
|
await prisma.translation.create({
|
||||||
|
data: {
|
||||||
|
sourceText: text,
|
||||||
|
sourceLang: 'pt',
|
||||||
|
targetLang,
|
||||||
|
translatedText
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (cacheError) {
|
||||||
|
// Ignorar erro de duplicata
|
||||||
|
if (!(cacheError instanceof Error && cacheError.message.includes('Unique constraint'))) {
|
||||||
|
console.warn('[i18n] Falha ao salvar cache de tradução:', cacheError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return translatedText;
|
||||||
|
} else {
|
||||||
|
console.error(`[i18n] LibreTranslate retornou status ${response.status}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
console.error(`[i18n] Timeout ao traduzir para ${targetLang} (30s)`);
|
||||||
|
} else {
|
||||||
|
console.error(`[i18n] Erro ao traduzir texto para ${targetLang}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function translateContent(content: unknown, targetLang: string): Promise<unknown> {
|
||||||
|
if (targetLang === 'pt') return content;
|
||||||
|
|
||||||
|
const skipKeys = ['icon', 'image', 'img', 'url', 'href', 'id', 'slug', 'src', 'email', 'phone', 'whatsapp', 'link', 'linkText'];
|
||||||
|
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
return translateText(content, targetLang);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
const translated = [] as unknown[];
|
||||||
|
for (const item of content) {
|
||||||
|
translated.push(await translateContent(item, targetLang));
|
||||||
|
}
|
||||||
|
return translated;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content && typeof content === 'object') {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(content)) {
|
||||||
|
if (skipKeys.includes(key)) {
|
||||||
|
result[key] = value;
|
||||||
|
} else {
|
||||||
|
result[key] = await translateContent(value, targetLang);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function translateInBackground(slug: string, content: unknown) {
|
||||||
|
console.log(`[i18n] Iniciando tradução de "${slug}" para EN/ES em background...`);
|
||||||
|
|
||||||
|
for (const targetLocale of TARGET_TRANSLATION_LOCALES) {
|
||||||
|
try {
|
||||||
|
const translatedContent = await translateContent(content, targetLocale) as Prisma.InputJsonValue;
|
||||||
|
|
||||||
|
await prisma.pageContent.upsert({
|
||||||
|
where: { slug_locale: { slug, locale: targetLocale } },
|
||||||
|
update: { content: translatedContent },
|
||||||
|
create: { slug, locale: targetLocale, content: translatedContent }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[i18n] ✓ "${slug}" traduzido para ${targetLocale.toUpperCase()}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[i18n] ✗ Erro ao traduzir "${slug}" para ${targetLocale}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[i18n] Traduções de "${slug}" finalizadas.`);
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/pages/[slug] - Buscar página específica (público)
|
// GET /api/pages/[slug] - Buscar página específica (público)
|
||||||
|
// Suporta ?locale=en para buscar versão traduzida
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ slug: string }> }
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
const locale = request.nextUrl.searchParams.get('locale') || 'pt';
|
||||||
|
|
||||||
|
// Buscar a versão do idioma solicitado
|
||||||
const page = await prisma.pageContent.findUnique({
|
const page = await prisma.pageContent.findUnique({
|
||||||
where: { slug }
|
where: {
|
||||||
|
slug_locale: { slug, locale }
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!page) {
|
if (page) {
|
||||||
return NextResponse.json({ error: 'Página não encontrada' }, { status: 404 });
|
return NextResponse.json(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(page);
|
// Se não existe a versão traduzida, buscar PT como fallback
|
||||||
|
if (locale !== 'pt') {
|
||||||
|
const ptPage = await prisma.pageContent.findUnique({
|
||||||
|
where: { slug_locale: { slug, locale: 'pt' } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ptPage) {
|
||||||
|
// Retorna versão PT com flag indicando que não está traduzido
|
||||||
|
return NextResponse.json({ ...ptPage, locale: 'pt', fallback: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'Página não encontrada' }, { status: 404 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao buscar página:', error);
|
console.error('Erro ao buscar página:', error);
|
||||||
return NextResponse.json({ error: 'Erro ao buscar página' }, { status: 500 });
|
return NextResponse.json({ error: 'Erro ao buscar página' }, { status: 500 });
|
||||||
@@ -48,6 +199,7 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PUT /api/pages/[slug] - Atualizar página (admin apenas)
|
// PUT /api/pages/[slug] - Atualizar página (admin apenas)
|
||||||
|
// Quando salva em PT, automaticamente traduz e salva EN e ES
|
||||||
export async function PUT(
|
export async function PUT(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ slug: string }> }
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
@@ -66,13 +218,23 @@ export async function PUT(
|
|||||||
return NextResponse.json({ error: 'Conteúdo é obrigatório' }, { status: 400 });
|
return NextResponse.json({ error: 'Conteúdo é obrigatório' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = await prisma.pageContent.upsert({
|
// 1. Salvar versão em português (principal)
|
||||||
where: { slug },
|
const ptPage = await prisma.pageContent.upsert({
|
||||||
|
where: { slug_locale: { slug, locale: 'pt' } },
|
||||||
update: { content },
|
update: { content },
|
||||||
create: { slug, content }
|
create: { slug, locale: 'pt', content }
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ success: true, page });
|
// 2. Disparar traduções em background para EN/ES
|
||||||
|
translateInBackground(slug, content).catch(error => {
|
||||||
|
console.error(`[i18n] Erro fatal na tradução em background de "${slug}":`, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
page: ptPage,
|
||||||
|
message: 'Conteúdo salvo com sucesso!'
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao atualizar página:', error);
|
console.error('Erro ao atualizar página:', error);
|
||||||
return NextResponse.json({ error: 'Erro ao atualizar página' }, { status: 500 });
|
return NextResponse.json({ error: 'Erro ao atualizar página' }, { status: 500 });
|
||||||
@@ -80,6 +242,7 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/pages/[slug] - Deletar página (admin apenas)
|
// DELETE /api/pages/[slug] - Deletar página (admin apenas)
|
||||||
|
// Remove todas as versões (PT, EN, ES)
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ slug: string }> }
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
@@ -92,11 +255,12 @@ export async function DELETE(
|
|||||||
|
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
|
||||||
await prisma.pageContent.delete({
|
// Deletar todas as versões de idioma
|
||||||
|
await prisma.pageContent.deleteMany({
|
||||||
where: { slug }
|
where: { slug }
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ success: true, message: 'Página deletada com sucesso' });
|
return NextResponse.json({ success: true, message: 'Página deletada com sucesso (todos os idiomas)' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao deletar página:', error);
|
console.error('Erro ao deletar página:', error);
|
||||||
return NextResponse.json({ error: 'Erro ao deletar página' }, { status: 500 });
|
return NextResponse.json({ error: 'Erro ao deletar página' }, { status: 500 });
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import prisma from '@/lib/prisma';
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const page = await prisma.pageContent.findUnique({
|
|
||||||
where: { slug: 'contact' }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!page) {
|
|
||||||
return NextResponse.json({ content: null }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ content: page.content });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching contact page:', error);
|
|
||||||
return NextResponse.json({ error: 'Failed to fetch page' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { content } = await request.json();
|
|
||||||
|
|
||||||
const page = await prisma.pageContent.upsert({
|
|
||||||
where: { slug: 'contact' },
|
|
||||||
update: {
|
|
||||||
content
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
slug: 'contact',
|
|
||||||
content
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, page });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating contact page:', error);
|
|
||||||
return NextResponse.json({ error: 'Failed to update page' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,22 +31,33 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const slug = searchParams.get('slug');
|
const slug = searchParams.get('slug');
|
||||||
|
const locale = searchParams.get('locale') || 'pt';
|
||||||
|
|
||||||
if (slug) {
|
if (slug) {
|
||||||
// Buscar página específica
|
// Buscar página específica com locale
|
||||||
const page = await prisma.pageContent.findUnique({
|
const page = await prisma.pageContent.findUnique({
|
||||||
where: { slug }
|
where: { slug_locale: { slug, locale } }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
|
// Fallback para PT se não encontrar
|
||||||
|
if (locale !== 'pt') {
|
||||||
|
const ptPage = await prisma.pageContent.findUnique({
|
||||||
|
where: { slug_locale: { slug, locale: 'pt' } }
|
||||||
|
});
|
||||||
|
if (ptPage) {
|
||||||
|
return NextResponse.json({ ...ptPage, fallback: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
return NextResponse.json({ error: 'Página não encontrada' }, { status: 404 });
|
return NextResponse.json({ error: 'Página não encontrada' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(page);
|
return NextResponse.json(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listar todas as páginas
|
// Listar todas as páginas (só PT para admin)
|
||||||
const pages = await prisma.pageContent.findMany({
|
const pages = await prisma.pageContent.findMany({
|
||||||
|
where: { locale: 'pt' },
|
||||||
orderBy: { slug: 'asc' }
|
orderBy: { slug: 'asc' }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,11 +83,11 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Slug e conteúdo são obrigatórios' }, { status: 400 });
|
return NextResponse.json({ error: 'Slug e conteúdo são obrigatórios' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert: criar ou atualizar se já existir
|
// Upsert: criar ou atualizar se já existir (versão PT)
|
||||||
const page = await prisma.pageContent.upsert({
|
const page = await prisma.pageContent.upsert({
|
||||||
where: { slug },
|
where: { slug_locale: { slug, locale: 'pt' } },
|
||||||
update: { content },
|
update: { content },
|
||||||
create: { slug, content }
|
create: { slug, locale: 'pt', content }
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ success: true, page });
|
return NextResponse.json({ success: true, page });
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
@@ -10,42 +11,60 @@ export async function GET(
|
|||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
if (!project) return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
|
||||||
|
if (!project) {
|
||||||
|
return NextResponse.json({ error: 'Projeto não encontrado' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(project);
|
return NextResponse.json(project);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({ error: 'Error fetching project' }, { status: 500 });
|
console.error('Error fetching project:', error);
|
||||||
|
return NextResponse.json({ error: 'Erro ao buscar projeto' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(
|
export async function PATCH(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const data = await request.json();
|
const body = await request.json();
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (body.title !== undefined) updateData.title = body.title;
|
||||||
|
if (body.category !== undefined) updateData.category = body.category;
|
||||||
|
if (body.client !== undefined) updateData.client = body.client;
|
||||||
|
if (body.status !== undefined) updateData.status = body.status;
|
||||||
|
if (body.description !== undefined) updateData.description = body.description;
|
||||||
|
if (body.coverImage !== undefined) updateData.coverImage = body.coverImage;
|
||||||
|
if (body.galleryImages !== undefined) {
|
||||||
|
updateData.galleryImages = Array.isArray(body.galleryImages) ? body.galleryImages : [];
|
||||||
|
}
|
||||||
|
if (body.featured !== undefined) updateData.featured = Boolean(body.featured);
|
||||||
|
if (body.completionDate !== undefined) {
|
||||||
|
updateData.completionDate = body.completionDate ? new Date(body.completionDate) : null;
|
||||||
|
}
|
||||||
|
|
||||||
const project = await prisma.project.update({
|
const project = await prisma.project.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: updateData,
|
||||||
title: data.title,
|
|
||||||
category: data.category,
|
|
||||||
client: data.client,
|
|
||||||
status: data.status,
|
|
||||||
completionDate: data.completionDate ? new Date(data.completionDate) : null,
|
|
||||||
description: data.description,
|
|
||||||
coverImage: data.coverImage,
|
|
||||||
galleryImages: data.galleryImages,
|
|
||||||
featured: data.featured,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(project);
|
return NextResponse.json(project);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({ error: 'Error updating project' }, { status: 500 });
|
const err = error as Prisma.PrismaClientKnownRequestError;
|
||||||
|
if (err?.code === 'P2025') {
|
||||||
|
return NextResponse.json({ error: 'Projeto não encontrado' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Error updating project:', error);
|
||||||
|
return NextResponse.json({ error: 'Erro ao atualizar projeto' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
@@ -53,8 +72,14 @@ export async function DELETE(
|
|||||||
await prisma.project.delete({
|
await prisma.project.delete({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
return NextResponse.json({ message: 'Project deleted' });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({ error: 'Error deleting project' }, { status: 500 });
|
const err = error as Prisma.PrismaClientKnownRequestError;
|
||||||
|
if (err?.code === 'P2025') {
|
||||||
|
return NextResponse.json({ error: 'Projeto não encontrado' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Error deleting project:', error);
|
||||||
|
return NextResponse.json({ error: 'Erro ao excluir projeto' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,78 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const takeParam = searchParams.get('take');
|
||||||
|
const statusFilter = searchParams.get('status');
|
||||||
|
const featuredFilter = searchParams.get('featured');
|
||||||
|
|
||||||
|
const take = takeParam ? Number.parseInt(takeParam, 10) : undefined;
|
||||||
|
const where: Prisma.ProjectWhereInput = {};
|
||||||
|
|
||||||
|
if (statusFilter === 'published') {
|
||||||
|
where.status = { not: 'Rascunho' };
|
||||||
|
} else if (statusFilter === 'draft') {
|
||||||
|
where.status = 'Rascunho';
|
||||||
|
} else if (statusFilter) {
|
||||||
|
where.status = statusFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (featuredFilter === 'true') {
|
||||||
|
where.featured = true;
|
||||||
|
}
|
||||||
|
|
||||||
const projects = await prisma.project.findMany({
|
const projects = await prisma.project.findMany({
|
||||||
|
where,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: Number.isInteger(take) && take! > 0 ? take : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(projects);
|
return NextResponse.json(projects);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching projects:', error);
|
console.error('Error fetching projects:', error);
|
||||||
return NextResponse.json({ error: 'Error fetching projects' }, { status: 500 });
|
return NextResponse.json({ error: 'Erro ao buscar projetos' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const data = await request.json();
|
const data = await request.json();
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
category,
|
||||||
|
client,
|
||||||
|
status,
|
||||||
|
description,
|
||||||
|
completionDate,
|
||||||
|
coverImage,
|
||||||
|
galleryImages,
|
||||||
|
featured,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
if (!title || !category) {
|
||||||
|
return NextResponse.json({ error: 'Título e categoria são obrigatórios.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const project = await prisma.project.create({
|
const project = await prisma.project.create({
|
||||||
data: {
|
data: {
|
||||||
title: data.title,
|
title,
|
||||||
category: data.category,
|
category,
|
||||||
client: data.client,
|
client,
|
||||||
status: data.status,
|
status: status || 'Em andamento',
|
||||||
completionDate: data.completionDate ? new Date(data.completionDate) : null,
|
description,
|
||||||
description: data.description,
|
completionDate: completionDate ? new Date(completionDate) : null,
|
||||||
coverImage: data.coverImage,
|
coverImage,
|
||||||
galleryImages: data.galleryImages,
|
galleryImages: Array.isArray(galleryImages) ? galleryImages : [],
|
||||||
featured: data.featured,
|
featured: Boolean(featured),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return NextResponse.json(project);
|
|
||||||
|
return NextResponse.json(project, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating project:', error);
|
console.error('Error creating project:', error);
|
||||||
return NextResponse.json({ error: 'Error creating project' }, { status: 500 });
|
return NextResponse.json({ error: 'Erro ao criar projeto' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
203
frontend/src/app/api/translate/route.ts
Normal file
203
frontend/src/app/api/translate/route.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
|
||||||
|
const LIBRETRANSLATE_URL = process.env.LIBRETRANSLATE_URL || 'https://libretranslate.stackbyte.cloud';
|
||||||
|
|
||||||
|
// Cache em memória para evitar queries repetidas na mesma sessão
|
||||||
|
const memoryCache = new Map<string, string>();
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { text, source = 'pt', target = 'en' } = await request.json();
|
||||||
|
|
||||||
|
if (!text || typeof text !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Texto é obrigatório' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se origem e destino são iguais, retorna o texto original
|
||||||
|
if (source === target) {
|
||||||
|
return NextResponse.json({ translatedText: text });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${source}:${target}:${text}`;
|
||||||
|
|
||||||
|
// 1. Verificar cache em memória (mais rápido)
|
||||||
|
if (memoryCache.has(cacheKey)) {
|
||||||
|
return NextResponse.json({ translatedText: memoryCache.get(cacheKey), cached: 'memory' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Verificar banco de dados
|
||||||
|
const dbTranslation = await prisma.translation.findUnique({
|
||||||
|
where: {
|
||||||
|
sourceText_sourceLang_targetLang: {
|
||||||
|
sourceText: text,
|
||||||
|
sourceLang: source,
|
||||||
|
targetLang: target,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dbTranslation) {
|
||||||
|
// Salvar em memória para próximas requisições
|
||||||
|
memoryCache.set(cacheKey, dbTranslation.translatedText);
|
||||||
|
return NextResponse.json({ translatedText: dbTranslation.translatedText, cached: 'database' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Chamar LibreTranslate (só se não tiver no banco)
|
||||||
|
console.log(`[Translate] Chamando LibreTranslate para: "${text.substring(0, 30)}..."`);
|
||||||
|
|
||||||
|
const response = await fetch(`${LIBRETRANSLATE_URL}/translate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
q: text,
|
||||||
|
source: source,
|
||||||
|
target: target,
|
||||||
|
format: 'text',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('LibreTranslate error:', await response.text());
|
||||||
|
return NextResponse.json({ translatedText: text }); // Fallback: retorna original
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const translatedText = data.translatedText || text;
|
||||||
|
|
||||||
|
// 4. Salvar no banco de dados (persistente)
|
||||||
|
try {
|
||||||
|
await prisma.translation.create({
|
||||||
|
data: {
|
||||||
|
sourceText: text,
|
||||||
|
sourceLang: source,
|
||||||
|
targetLang: target,
|
||||||
|
translatedText: translatedText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`[Translate] Salvo no banco: "${text.substring(0, 30)}..." -> "${translatedText.substring(0, 30)}..."`);
|
||||||
|
} catch (dbError) {
|
||||||
|
// Pode falhar se já existir (race condition), ignorar
|
||||||
|
console.log('[Translate] Já existe no banco (race condition)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Salvar em memória
|
||||||
|
memoryCache.set(cacheKey, translatedText);
|
||||||
|
|
||||||
|
return NextResponse.json({ translatedText, cached: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Translation error:', error);
|
||||||
|
return NextResponse.json({ error: 'Erro ao traduzir' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoint para traduzir múltiplos textos de uma vez
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { texts, source = 'pt', target = 'en' } = await request.json();
|
||||||
|
|
||||||
|
if (!texts || !Array.isArray(texts)) {
|
||||||
|
return NextResponse.json({ error: 'Array de textos é obrigatório' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source === target) {
|
||||||
|
return NextResponse.json({ translations: texts });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: string[] = [];
|
||||||
|
const toTranslate: { index: number; text: string }[] = [];
|
||||||
|
|
||||||
|
// Verificar quais já existem no banco
|
||||||
|
for (let i = 0; i < texts.length; i++) {
|
||||||
|
const text = texts[i];
|
||||||
|
if (!text) {
|
||||||
|
results[i] = text || '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${source}:${target}:${text}`;
|
||||||
|
|
||||||
|
// Verificar memória
|
||||||
|
if (memoryCache.has(cacheKey)) {
|
||||||
|
results[i] = memoryCache.get(cacheKey)!;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar banco
|
||||||
|
const dbTranslation = await prisma.translation.findUnique({
|
||||||
|
where: {
|
||||||
|
sourceText_sourceLang_targetLang: {
|
||||||
|
sourceText: text,
|
||||||
|
sourceLang: source,
|
||||||
|
targetLang: target,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dbTranslation) {
|
||||||
|
results[i] = dbTranslation.translatedText;
|
||||||
|
memoryCache.set(cacheKey, dbTranslation.translatedText);
|
||||||
|
} else {
|
||||||
|
toTranslate.push({ index: i, text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se todos estão em cache, retorna direto
|
||||||
|
if (toTranslate.length === 0) {
|
||||||
|
return NextResponse.json({ translations: results, allCached: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traduzir os que faltam (em paralelo, mas com limite)
|
||||||
|
const BATCH_SIZE = 5; // Traduzir 5 por vez para não sobrecarregar
|
||||||
|
|
||||||
|
for (let i = 0; i < toTranslate.length; i += BATCH_SIZE) {
|
||||||
|
const batch = toTranslate.slice(i, i + BATCH_SIZE);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
batch.map(async ({ index, text }) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${LIBRETRANSLATE_URL}/translate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ q: text, source, target, format: 'text' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const translatedText = data.translatedText || text;
|
||||||
|
|
||||||
|
results[index] = translatedText;
|
||||||
|
|
||||||
|
// Salvar no banco
|
||||||
|
try {
|
||||||
|
await prisma.translation.create({
|
||||||
|
data: {
|
||||||
|
sourceText: text,
|
||||||
|
sourceLang: source,
|
||||||
|
targetLang: target,
|
||||||
|
translatedText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Ignorar se já existe
|
||||||
|
}
|
||||||
|
|
||||||
|
memoryCache.set(`${source}:${target}:${text}`, translatedText);
|
||||||
|
} else {
|
||||||
|
results[index] = text;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
results[index] = text;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ translations: results });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Batch translation error:', error);
|
||||||
|
return NextResponse.json({ error: 'Erro ao traduzir' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,11 +20,9 @@ export async function POST(request: Request) {
|
|||||||
'Content-Type': file.type,
|
'Content-Type': file.type,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Construct public URL
|
const url = `/api/files/${filename}`;
|
||||||
// In a real production env, this should be an env var like NEXT_PUBLIC_STORAGE_URL
|
|
||||||
const url = `http://localhost:9000/${bucketName}/${filename}`;
|
|
||||||
|
|
||||||
return NextResponse.json({ url });
|
return NextResponse.json({ url, path: filename });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
return NextResponse.json({ error: 'Error uploading file' }, { status: 500 });
|
return NextResponse.json({ error: 'Error uploading file' }, { status: 500 });
|
||||||
|
|||||||
@@ -80,11 +80,8 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate public URL
|
const avatarPath = fileName;
|
||||||
const protocol = process.env.MINIO_USE_SSL === 'true' ? 'https' : 'http';
|
const avatarUrl = `/api/files/${avatarPath}`;
|
||||||
const endpoint = process.env.MINIO_ENDPOINT || 'localhost';
|
|
||||||
const port = process.env.MINIO_PORT || '9000';
|
|
||||||
const avatarUrl = `${protocol}://${endpoint}:${port}/${BUCKET_NAME}/${fileName}`;
|
|
||||||
|
|
||||||
// Delete old avatar if exists
|
// Delete old avatar if exists
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
@@ -94,10 +91,17 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
if (user?.avatar) {
|
if (user?.avatar) {
|
||||||
try {
|
try {
|
||||||
// Extract filename from URL
|
const sanitized = user.avatar.replace(/^https?:\/\/[^/]+/i, '');
|
||||||
const oldFileName = user.avatar.split(`${BUCKET_NAME}/`)[1];
|
let objectKey: string | undefined;
|
||||||
if (oldFileName) {
|
|
||||||
await minioClient.removeObject(BUCKET_NAME, oldFileName);
|
if (sanitized.startsWith('/api/files/')) {
|
||||||
|
objectKey = sanitized.replace('/api/files/', '');
|
||||||
|
} else if (sanitized.includes(`${BUCKET_NAME}/`)) {
|
||||||
|
objectKey = sanitized.split(`${BUCKET_NAME}/`)[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (objectKey) {
|
||||||
|
await minioClient.removeObject(BUCKET_NAME, objectKey);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting old avatar:', error);
|
console.error('Error deleting old avatar:', error);
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ export default function CookieConsent() {
|
|||||||
<div className="container mx-auto max-w-4xl">
|
<div className="container mx-auto max-w-4xl">
|
||||||
<div className="bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-2xl shadow-2xl p-6 md:flex items-center justify-between gap-6">
|
<div className="bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-2xl shadow-2xl p-6 md:flex items-center justify-between gap-6">
|
||||||
<div className="flex items-start gap-4 mb-6 md:mb-0">
|
<div className="flex items-start gap-4 mb-6 md:mb-0">
|
||||||
<div className="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center shrink-0 text-primary">
|
<div className="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center shrink-0">
|
||||||
<i className="ri-cookie-2-line text-2xl"></i>
|
<span className="text-3xl">🍪</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-600 dark:text-gray-300 text-sm leading-relaxed">
|
<p className="text-gray-600 dark:text-gray-300 text-sm leading-relaxed">
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLanguage } from '@/contexts/LanguageContext';
|
import { useLocale } from '@/contexts/LocaleContext';
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const { t } = useLanguage();
|
const { locale, t } = useLocale();
|
||||||
|
|
||||||
|
// Prefixo para links
|
||||||
|
const prefix = locale === 'pt' ? '' : `/${locale}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-secondary text-white pt-16 pb-8">
|
<footer className="bg-secondary text-white pt-16 pb-8">
|
||||||
@@ -20,12 +23,12 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-400 mb-6">
|
<p className="text-gray-400 mb-6">
|
||||||
Soluções em engenharia mecânica e segurança para movimentação de carga.
|
{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="inline-flex items-center gap-2 bg-white/5 border border-white/10 rounded-lg px-3 py-2 mb-6">
|
||||||
<i className="ri-verified-badge-fill text-primary"></i>
|
<i className="ri-verified-badge-fill text-primary"></i>
|
||||||
<span className="text-xs font-bold text-gray-300 uppercase tracking-wide">Prestador Oficial <span className="text-primary">Coca-Cola</span></span>
|
<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">
|
||||||
@@ -43,24 +46,24 @@ export default function Footer() {
|
|||||||
|
|
||||||
{/* Links */}
|
{/* Links */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-bold font-headline mb-6">Links Rápidos</h3>
|
<h3 className="text-lg font-bold font-headline mb-6">{t('footer.quickLinks')}</h3>
|
||||||
<ul className="space-y-4">
|
<ul className="space-y-4">
|
||||||
<li><Link href="/" className="text-gray-400 hover:text-primary transition-colors">{t('nav.home')}</Link></li>
|
<li><Link href={`${prefix}/`} className="text-gray-400 hover:text-primary transition-colors">{t('nav.home')}</Link></li>
|
||||||
<li><Link href="/sobre" className="text-gray-400 hover:text-primary transition-colors">{t('nav.about')}</Link></li>
|
<li><Link href={`${prefix}/sobre`} className="text-gray-400 hover:text-primary transition-colors">{t('nav.about')}</Link></li>
|
||||||
<li><Link href="/servicos" className="text-gray-400 hover:text-primary transition-colors">{t('nav.services')}</Link></li>
|
<li><Link href={`${prefix}/servicos`} className="text-gray-400 hover:text-primary transition-colors">{t('nav.services')}</Link></li>
|
||||||
<li><Link href="/projetos" className="text-gray-400 hover:text-primary transition-colors">{t('nav.projects')}</Link></li>
|
<li><Link href={`${prefix}/projetos`} className="text-gray-400 hover:text-primary transition-colors">{t('nav.projects')}</Link></li>
|
||||||
<li><Link href="/contato" className="text-gray-400 hover:text-primary transition-colors">{t('nav.contact')}</Link></li>
|
<li><Link href={`${prefix}/contato`} className="text-gray-400 hover:text-primary transition-colors">{t('nav.contact')}</Link></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Services */}
|
{/* Services */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-bold font-headline mb-6">{t('services.title')}</h3>
|
<h3 className="text-lg font-bold font-headline mb-6">{t('nav.services')}</h3>
|
||||||
<ul className="space-y-4">
|
<ul className="space-y-4">
|
||||||
<li className="text-gray-400">Projetos de Dispositivos</li>
|
<li className="text-gray-400">{t('services.deviceProjects')}</li>
|
||||||
<li className="text-gray-400">Engenharia de Implementos</li>
|
<li className="text-gray-400">{t('services.implementEngineering')}</li>
|
||||||
<li className="text-gray-400">Inspeção de Equipamentos</li>
|
<li className="text-gray-400">{t('services.equipmentInspection')}</li>
|
||||||
<li className="text-gray-400">Laudos Técnicos (NR-11/12)</li>
|
<li className="text-gray-400">{t('services.technicalReports')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -89,8 +92,8 @@ export default function Footer() {
|
|||||||
© {new Date().getFullYear()} OCCTO Engenharia. {t('footer.rights')}
|
© {new Date().getFullYear()} OCCTO Engenharia. {t('footer.rights')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-6 text-sm text-gray-500">
|
<div className="flex gap-6 text-sm text-gray-500">
|
||||||
<Link href="/privacidade" className="hover:text-white">Política de Privacidade</Link>
|
<Link href={`${prefix}/privacidade`} className="hover:text-white">{t('footer.privacyPolicy')}</Link>
|
||||||
<Link href="/termos" className="hover:text-white">Termos de Uso</Link>
|
<Link href={`${prefix}/termos`} className="hover:text-white">{t('footer.termsOfUse')}</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,15 +3,19 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { useLanguage } from '@/contexts/LanguageContext';
|
import { useLocale } from '@/contexts/LocaleContext';
|
||||||
|
import { localeFlags, localeNames, type Locale } from '@/lib/i18n';
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { language, setLanguage, t } = useLanguage();
|
const { locale, setLocale, t } = useLocale();
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
// Prefixo para links baseado no locale
|
||||||
|
const prefix = locale === 'pt' ? '' : `/${locale}`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -32,17 +36,10 @@ export default function Header() {
|
|||||||
setTheme(theme === 'dark' ? 'light' : 'dark');
|
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||||
};
|
};
|
||||||
|
|
||||||
const cycleLanguage = () => {
|
|
||||||
const langs: ('PT' | 'EN' | 'ES')[] = ['PT', 'EN', 'ES'];
|
|
||||||
const currentIndex = langs.indexOf(language);
|
|
||||||
const nextIndex = (currentIndex + 1) % langs.length;
|
|
||||||
setLanguage(langs[nextIndex]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="w-full bg-white dark:bg-secondary shadow-sm sticky top-0 z-50 transition-colors duration-300">
|
<header className="w-full bg-white dark:bg-secondary shadow-sm sticky 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="/" 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>
|
<i className="ri-building-2-fill text-4xl text-primary group-hover:scale-105 transition-transform"></i>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-3xl font-bold text-secondary dark:text-white font-headline leading-none">OCCTO</span>
|
<span className="text-3xl font-bold text-secondary dark:text-white font-headline leading-none">OCCTO</span>
|
||||||
@@ -66,23 +63,23 @@ export default function Header() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex items-center gap-6 mr-4">
|
<nav className="flex items-center gap-6 mr-4">
|
||||||
<Link href="/" className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary font-medium transition-colors group">
|
<Link href={`${prefix}/`} className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary font-medium transition-colors group">
|
||||||
<i className="ri-home-4-line text-lg group-hover:scale-110 transition-transform"></i>
|
<i className="ri-home-4-line text-lg group-hover:scale-110 transition-transform"></i>
|
||||||
<span className="hidden lg:inline">{t('nav.home')}</span>
|
<span className="hidden lg:inline">{t('nav.home')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/servicos" className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary font-medium transition-colors group">
|
<Link href={`${prefix}/servicos`} className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary font-medium transition-colors group">
|
||||||
<i className="ri-tools-line text-lg group-hover:scale-110 transition-transform"></i>
|
<i className="ri-tools-line text-lg group-hover:scale-110 transition-transform"></i>
|
||||||
<span className="hidden lg:inline">{t('nav.services')}</span>
|
<span className="hidden lg:inline">{t('nav.services')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/projetos" className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary font-medium transition-colors group">
|
<Link href={`${prefix}/projetos`} className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary font-medium transition-colors group">
|
||||||
<i className="ri-briefcase-line text-lg group-hover:scale-110 transition-transform"></i>
|
<i className="ri-briefcase-line text-lg group-hover:scale-110 transition-transform"></i>
|
||||||
<span className="hidden lg:inline">{t('nav.projects')}</span>
|
<span className="hidden lg:inline">{t('nav.projects')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/contato" className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary font-medium transition-colors group">
|
<Link href={`${prefix}/contato`} className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary font-medium transition-colors group">
|
||||||
<i className="ri-mail-send-line text-lg group-hover:scale-110 transition-transform"></i>
|
<i className="ri-mail-send-line text-lg group-hover:scale-110 transition-transform"></i>
|
||||||
<span className="hidden lg:inline">{t('nav.contact')}</span>
|
<span className="hidden lg:inline">{t('nav.contact')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/sobre" className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary font-medium transition-colors group">
|
<Link href={`${prefix}/sobre`} className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary font-medium transition-colors group">
|
||||||
<i className="ri-user-line text-lg group-hover:scale-110 transition-transform"></i>
|
<i className="ri-user-line text-lg group-hover:scale-110 transition-transform"></i>
|
||||||
<span className="hidden lg:inline">{t('nav.about')}</span>
|
<span className="hidden lg:inline">{t('nav.about')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -90,11 +87,11 @@ export default function Header() {
|
|||||||
|
|
||||||
<div className="shrink-0 ml-2">
|
<div className="shrink-0 ml-2">
|
||||||
<Link
|
<Link
|
||||||
href="/contato"
|
href={`${prefix}/contato`}
|
||||||
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.contact_us')}</span>
|
<span className="hidden xl:inline">{t('nav.contactUs')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -118,24 +115,24 @@ export default function Header() {
|
|||||||
className="h-10 px-3 rounded-full bg-gray-100 dark:bg-white/10 flex items-center justify-center gap-2 text-gray-600 dark:text-white hover:bg-gray-200 dark:hover:bg-white/20 transition-colors font-bold text-sm cursor-pointer"
|
className="h-10 px-3 rounded-full bg-gray-100 dark:bg-white/10 flex items-center justify-center gap-2 text-gray-600 dark:text-white hover:bg-gray-200 dark:hover:bg-white/20 transition-colors font-bold text-sm cursor-pointer"
|
||||||
aria-label="Alterar idioma"
|
aria-label="Alterar idioma"
|
||||||
>
|
>
|
||||||
<span>{language === 'PT' ? '🇧🇷' : language === 'EN' ? '🇺🇸' : '🇪🇸'}</span>
|
<span>{localeFlags[locale]}</span>
|
||||||
<span>{language}</span>
|
<span>{locale.toUpperCase()}</span>
|
||||||
<i className="ri-arrow-down-s-line text-xs opacity-50"></i>
|
<i className="ri-arrow-down-s-line text-xs opacity-50"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="absolute top-full right-0 pt-2 w-32 hidden group-hover:block animate-in fade-in slide-in-from-top-2 duration-200">
|
<div className="absolute top-full right-0 pt-2 w-32 hidden group-hover:block animate-in fade-in slide-in-from-top-2 duration-200">
|
||||||
<div className="bg-white dark:bg-secondary rounded-xl shadow-xl border border-gray-100 dark:border-white/10 overflow-hidden">
|
<div className="bg-white dark:bg-secondary rounded-xl shadow-xl border border-gray-100 dark:border-white/10 overflow-hidden">
|
||||||
<button onClick={() => setLanguage('PT')} className="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-white/5 flex items-center gap-3 transition-colors cursor-pointer">
|
<button onClick={() => setLocale('pt')} className="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-white/5 flex items-center gap-3 transition-colors cursor-pointer">
|
||||||
<span className="text-lg">🇧🇷</span>
|
<span className="text-lg">{localeFlags.pt}</span>
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">Português</span>
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">{localeNames.pt}</span>
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setLanguage('EN')} className="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-white/5 flex items-center gap-3 transition-colors cursor-pointer">
|
<button onClick={() => setLocale('en')} className="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-white/5 flex items-center gap-3 transition-colors cursor-pointer">
|
||||||
<span className="text-lg">🇺🇸</span>
|
<span className="text-lg">{localeFlags.en}</span>
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">English</span>
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">{localeNames.en}</span>
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setLanguage('ES')} className="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-white/5 flex items-center gap-3 transition-colors cursor-pointer">
|
<button onClick={() => setLocale('es')} className="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-white/5 flex items-center gap-3 transition-colors cursor-pointer">
|
||||||
<span className="text-lg">🇪🇸</span>
|
<span className="text-lg">{localeFlags.es}</span>
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">Español</span>
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">{localeNames.es}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -166,23 +163,23 @@ export default function Header() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex flex-col gap-4 text-base font-medium">
|
<nav className="flex flex-col gap-4 text-base font-medium">
|
||||||
<Link href="/" onClick={() => setIsMobileMenuOpen(false)} className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-white/10 text-secondary dark:text-white">
|
<Link href={`${prefix}/`} onClick={() => setIsMobileMenuOpen(false)} className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-white/10 text-secondary dark:text-white">
|
||||||
<i className="ri-home-4-line text-primary text-lg"></i>
|
<i className="ri-home-4-line text-primary text-lg"></i>
|
||||||
{t('nav.home')}
|
{t('nav.home')}
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/servicos" onClick={() => setIsMobileMenuOpen(false)} className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-white/10 text-secondary dark:text-white">
|
<Link href={`${prefix}/servicos`} onClick={() => setIsMobileMenuOpen(false)} className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-white/10 text-secondary dark:text-white">
|
||||||
<i className="ri-tools-line text-primary text-lg"></i>
|
<i className="ri-tools-line text-primary text-lg"></i>
|
||||||
{t('nav.services')}
|
{t('nav.services')}
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/projetos" onClick={() => setIsMobileMenuOpen(false)} className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-white/10 text-secondary dark:text-white">
|
<Link href={`${prefix}/projetos`} onClick={() => setIsMobileMenuOpen(false)} className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-white/10 text-secondary dark:text-white">
|
||||||
<i className="ri-briefcase-line text-primary text-lg"></i>
|
<i className="ri-briefcase-line text-primary text-lg"></i>
|
||||||
{t('nav.projects')}
|
{t('nav.projects')}
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/contato" onClick={() => setIsMobileMenuOpen(false)} className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-white/10 text-secondary dark:text-white">
|
<Link href={`${prefix}/contato`} onClick={() => setIsMobileMenuOpen(false)} className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-white/10 text-secondary dark:text-white">
|
||||||
<i className="ri-mail-send-line text-primary text-lg"></i>
|
<i className="ri-mail-send-line text-primary text-lg"></i>
|
||||||
{t('nav.contact')}
|
{t('nav.contact')}
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/sobre" onClick={() => setIsMobileMenuOpen(false)} className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-white/10 text-secondary dark:text-white">
|
<Link href={`${prefix}/sobre`} onClick={() => setIsMobileMenuOpen(false)} className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-white/10 text-secondary dark:text-white">
|
||||||
<i className="ri-user-line text-primary text-lg"></i>
|
<i className="ri-user-line text-primary text-lg"></i>
|
||||||
{t('nav.about')}
|
{t('nav.about')}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -190,12 +187,12 @@ export default function Header() {
|
|||||||
|
|
||||||
<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
|
<Link
|
||||||
href="/contato"
|
href={`${prefix}/contato`}
|
||||||
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.contact_us')}
|
{t('nav.contactUs')}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -217,9 +214,9 @@ export default function Header() {
|
|||||||
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-white/5 rounded-xl">
|
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-white/5 rounded-xl">
|
||||||
<span className="text-sm font-bold text-gray-500 dark:text-gray-400">{t('nav.language')}</span>
|
<span className="text-sm font-bold text-gray-500 dark:text-gray-400">{t('nav.language')}</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={() => setLanguage('PT')} className={`w-10 h-10 rounded-lg flex items-center justify-center text-xl cursor-pointer transition-all ${language === 'PT' ? 'bg-white dark:bg-white/10 shadow-sm scale-110' : 'opacity-50 hover:opacity-100'}`}>🇧🇷</button>
|
<button onClick={() => setLocale('pt')} className={`w-10 h-10 rounded-lg flex items-center justify-center text-xl cursor-pointer transition-all ${locale === 'pt' ? 'bg-white dark:bg-white/10 shadow-sm scale-110' : 'opacity-50 hover:opacity-100'}`}>{localeFlags.pt}</button>
|
||||||
<button onClick={() => setLanguage('EN')} className={`w-10 h-10 rounded-lg flex items-center justify-center text-xl cursor-pointer transition-all ${language === 'EN' ? 'bg-white dark:bg-white/10 shadow-sm scale-110' : 'opacity-50 hover:opacity-100'}`}>🇺🇸</button>
|
<button onClick={() => setLocale('en')} className={`w-10 h-10 rounded-lg flex items-center justify-center text-xl cursor-pointer transition-all ${locale === 'en' ? 'bg-white dark:bg-white/10 shadow-sm scale-110' : 'opacity-50 hover:opacity-100'}`}>{localeFlags.en}</button>
|
||||||
<button onClick={() => setLanguage('ES')} className={`w-10 h-10 rounded-lg flex items-center justify-center text-xl cursor-pointer transition-all ${language === 'ES' ? 'bg-white dark:bg-white/10 shadow-sm scale-110' : 'opacity-50 hover:opacity-100'}`}>🇪🇸</button>
|
<button onClick={() => setLocale('es')} className={`w-10 h-10 rounded-lg flex items-center justify-center text-xl cursor-pointer transition-all ${locale === 'es' ? 'bg-white dark:bg-white/10 shadow-sm scale-110' : 'opacity-50 hover:opacity-100'}`}>{localeFlags.es}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
301
frontend/src/components/TranslatedText.tsx
Normal file
301
frontend/src/components/TranslatedText.tsx
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, ReactNode, useRef, useMemo } from 'react';
|
||||||
|
import { useLocale } from '@/contexts/LocaleContext';
|
||||||
|
|
||||||
|
// Cache global de traduções
|
||||||
|
const translationCache = new Map<string, string>();
|
||||||
|
|
||||||
|
// Função para traduzir texto via API (requisição individual)
|
||||||
|
async function translateText(text: string, targetLang: string): Promise<string> {
|
||||||
|
if (!text || text.trim() === '') return text;
|
||||||
|
|
||||||
|
const cacheKey = `pt:${targetLang}:${text}`;
|
||||||
|
|
||||||
|
// Cache hit: retorna imediatamente
|
||||||
|
if (translationCache.has(cacheKey)) {
|
||||||
|
return translationCache.get(cacheKey)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/translate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text, source: 'pt', target: targetLang }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const translated = data.translatedText || text;
|
||||||
|
translationCache.set(cacheKey, translated);
|
||||||
|
return translated;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[T] Translation error:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função para traduzir múltiplos textos de uma vez (BATCH - muito mais rápido)
|
||||||
|
async function translateBatchTexts(texts: string[], targetLang: string): Promise<string[]> {
|
||||||
|
if (!texts.length) return texts;
|
||||||
|
|
||||||
|
// Verificar quais já estão em cache
|
||||||
|
const results: string[] = new Array(texts.length);
|
||||||
|
const toTranslate: { index: number; text: string }[] = [];
|
||||||
|
|
||||||
|
texts.forEach((text, i) => {
|
||||||
|
if (!text || text.trim() === '') {
|
||||||
|
results[i] = text || '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `pt:${targetLang}:${text}`;
|
||||||
|
if (translationCache.has(cacheKey)) {
|
||||||
|
results[i] = translationCache.get(cacheKey)!;
|
||||||
|
} else {
|
||||||
|
toTranslate.push({ index: i, text });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Se todos estão em cache, retorna direto
|
||||||
|
if (toTranslate.length === 0) {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traduzir os que faltam via batch API
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/translate', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
texts: toTranslate.map(t => t.text),
|
||||||
|
source: 'pt',
|
||||||
|
target: targetLang
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const translations = data.translations || [];
|
||||||
|
|
||||||
|
toTranslate.forEach((item, idx) => {
|
||||||
|
const translated = translations[idx] || item.text;
|
||||||
|
results[item.index] = translated;
|
||||||
|
translationCache.set(`pt:${targetLang}:${item.text}`, translated);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback: usar textos originais
|
||||||
|
toTranslate.forEach(item => {
|
||||||
|
results[item.index] = item.text;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[T] Batch translation error:', error);
|
||||||
|
toTranslate.forEach(item => {
|
||||||
|
results[item.index] = item.text;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutoTranslateProps {
|
||||||
|
children: ReactNode;
|
||||||
|
as?: 'span' | 'p' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'div' | 'li' | 'label';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Componente que traduz texto automaticamente via LibreTranslate
|
||||||
|
* Uso: <T>Texto em português</T>
|
||||||
|
*/
|
||||||
|
export function T({ children, as = 'span', className }: AutoTranslateProps) {
|
||||||
|
const { locale } = useLocale();
|
||||||
|
|
||||||
|
// Converter children para string de forma estável
|
||||||
|
const originalText = useMemo(() => {
|
||||||
|
return typeof children === 'string' ? children : String(children || '');
|
||||||
|
}, [children]);
|
||||||
|
|
||||||
|
const [translatedText, setTranslatedText] = useState(originalText);
|
||||||
|
const lastTranslatedRef = useRef<{ text: string; lang: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[T] useEffect - locale:', locale, 'text:', originalText.substring(0, 20));
|
||||||
|
|
||||||
|
// Se idioma é PT, mostrar texto original
|
||||||
|
if (locale === 'pt') {
|
||||||
|
setTranslatedText(originalText);
|
||||||
|
lastTranslatedRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evitar tradução duplicada
|
||||||
|
if (
|
||||||
|
lastTranslatedRef.current?.text === originalText &&
|
||||||
|
lastTranslatedRef.current?.lang === locale
|
||||||
|
) {
|
||||||
|
console.log('[T] Pulando - já traduzido');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar cache primeiro (síncrono)
|
||||||
|
const cacheKey = `pt:${locale}:${originalText}`;
|
||||||
|
if (translationCache.has(cacheKey)) {
|
||||||
|
console.log('[T] Cache hit:', originalText.substring(0, 20));
|
||||||
|
setTranslatedText(translationCache.get(cacheKey)!);
|
||||||
|
lastTranslatedRef.current = { text: originalText, lang: locale };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[T] Chamando API para:', originalText.substring(0, 20));
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
translateText(originalText, locale).then((result) => {
|
||||||
|
console.log('[T] Resultado:', result.substring(0, 20));
|
||||||
|
if (!cancelled) {
|
||||||
|
setTranslatedText(result);
|
||||||
|
lastTranslatedRef.current = { text: originalText, lang: locale };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [originalText, locale]);
|
||||||
|
|
||||||
|
const Tag = as;
|
||||||
|
return <Tag className={className}>{translatedText}</Tag>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alias para uso mais curto
|
||||||
|
export const AutoTranslate = T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook para traduzir texto programaticamente
|
||||||
|
*/
|
||||||
|
export function useTranslate() {
|
||||||
|
const { locale } = useLocale();
|
||||||
|
const [isTranslating, setIsTranslating] = useState(false);
|
||||||
|
|
||||||
|
const translate = async (text: string): Promise<string> => {
|
||||||
|
if (!text || locale === 'pt') return text;
|
||||||
|
|
||||||
|
setIsTranslating(true);
|
||||||
|
try {
|
||||||
|
const result = await translateText(text, locale);
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
setIsTranslating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const translateBatch = async (texts: string[]): Promise<string[]> => {
|
||||||
|
if (locale === 'pt') return texts;
|
||||||
|
|
||||||
|
setIsTranslating(true);
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(
|
||||||
|
texts.map(text => translateText(text, locale))
|
||||||
|
);
|
||||||
|
return results;
|
||||||
|
} finally {
|
||||||
|
setIsTranslating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { translate, translateBatch, isTranslating, locale };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook para traduzir conteúdo do banco de dados
|
||||||
|
*/
|
||||||
|
export function useTranslatedContent<T extends Record<string, unknown>>(content: T | null): {
|
||||||
|
translatedContent: T | null;
|
||||||
|
isTranslating: boolean;
|
||||||
|
} {
|
||||||
|
const { locale } = useLocale();
|
||||||
|
const [translatedContent, setTranslatedContent] = useState<T | null>(content);
|
||||||
|
const [isTranslating, setIsTranslating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!content || locale === 'pt') {
|
||||||
|
setTranslatedContent(content);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const targetLang = locale;
|
||||||
|
|
||||||
|
const translateContent = async () => {
|
||||||
|
setIsTranslating(true);
|
||||||
|
|
||||||
|
// Extrair todos os textos do objeto
|
||||||
|
const texts: string[] = [];
|
||||||
|
const paths: string[] = [];
|
||||||
|
|
||||||
|
const extractTexts = (obj: unknown, path: string = '') => {
|
||||||
|
if (typeof obj === 'string' && obj.length > 0 && obj.length < 5000) {
|
||||||
|
texts.push(obj);
|
||||||
|
paths.push(path);
|
||||||
|
} else if (Array.isArray(obj)) {
|
||||||
|
obj.forEach((item, index) => extractTexts(item, `${path}[${index}]`));
|
||||||
|
} else if (obj && typeof obj === 'object') {
|
||||||
|
Object.entries(obj).forEach(([key, value]) => {
|
||||||
|
// Ignorar campos que não devem ser traduzidos
|
||||||
|
if (['icon', 'image', 'img', 'url', 'href', 'id', 'slug'].includes(key)) return;
|
||||||
|
extractTexts(value, path ? `${path}.${key}` : key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
extractTexts(content);
|
||||||
|
|
||||||
|
if (texts.length === 0) {
|
||||||
|
setIsTranslating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Traduzir todos os textos
|
||||||
|
const translations = await Promise.all(
|
||||||
|
texts.map(text => translateText(text, targetLang))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
// Reconstruir objeto com traduções
|
||||||
|
const newContent = JSON.parse(JSON.stringify(content));
|
||||||
|
|
||||||
|
paths.forEach((path, index) => {
|
||||||
|
const parts = path.replace(/\[(\d+)\]/g, '.$1').split('.');
|
||||||
|
let current: Record<string, unknown> = newContent;
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
current = current[parts[i]] as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
current[parts[parts.length - 1]] = translations[index];
|
||||||
|
});
|
||||||
|
|
||||||
|
setTranslatedContent(newContent);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Translation error:', error);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsTranslating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
translateContent();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [content, locale]);
|
||||||
|
|
||||||
|
return { translatedContent, isTranslating };
|
||||||
|
}
|
||||||
24
frontend/src/components/admin/CharLimitBadge.tsx
Normal file
24
frontend/src/components/admin/CharLimitBadge.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
type CharLimitBadgeProps = {
|
||||||
|
value?: string | null;
|
||||||
|
limit: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CharLimitBadge({ value = '', limit }: CharLimitBadgeProps) {
|
||||||
|
const current = value?.length ?? 0;
|
||||||
|
const percentage = Math.min((current / limit) * 100, 100);
|
||||||
|
const isNearLimit = current > limit * 0.85;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-[11px] font-semibold tracking-wide ${isNearLimit ? 'text-red-500' : 'text-gray-400 dark:text-gray-500'}`}>
|
||||||
|
{current}/{limit}
|
||||||
|
</span>
|
||||||
|
<div className="w-16 h-1.5 rounded-full bg-gray-200 dark:bg-white/10 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${isNearLimit ? 'bg-red-500' : 'bg-primary'}`}
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
type Language = 'PT' | 'EN' | 'ES';
|
export type Language = 'PT' | 'EN' | 'ES';
|
||||||
|
|
||||||
interface LanguageContextType {
|
interface LanguageContextType {
|
||||||
language: Language;
|
language: Language;
|
||||||
setLanguage: (lang: Language) => void;
|
setLanguage: (lang: Language) => void;
|
||||||
t: (key: string) => string;
|
t: (text: string) => string; // Retorna texto em PT (será traduzido pelo componente)
|
||||||
tDynamic: (content: { PT: string, EN?: string, ES?: string }) => string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||||
|
|
||||||
export const translations = {
|
// Textos fixos do sistema em português (serão traduzidos automaticamente pelo componente T)
|
||||||
PT: {
|
const systemTexts: Record<string, string> = {
|
||||||
|
// Navegação
|
||||||
'nav.home': 'Início',
|
'nav.home': 'Início',
|
||||||
'nav.services': 'Serviços',
|
'nav.services': 'Serviços',
|
||||||
'nav.projects': 'Projetos',
|
'nav.projects': 'Projetos',
|
||||||
@@ -24,503 +24,42 @@ export const translations = {
|
|||||||
'nav.contact_us': 'Fale Conosco',
|
'nav.contact_us': 'Fale Conosco',
|
||||||
'nav.theme': 'Tema',
|
'nav.theme': 'Tema',
|
||||||
'nav.language': 'Idioma',
|
'nav.language': 'Idioma',
|
||||||
|
|
||||||
|
// Footer
|
||||||
'footer.rights': 'Todos os direitos reservados.',
|
'footer.rights': 'Todos os direitos reservados.',
|
||||||
|
|
||||||
// Home - Hero
|
|
||||||
'home.hero.badge': 'Prestador de Serviço Oficial',
|
|
||||||
'home.hero.title': 'Engenharia de',
|
|
||||||
'home.hero.title_highlight': 'Dispositivos de Içamento',
|
|
||||||
'home.hero.subtitle': 'Desenvolvemos projetos, laudos e soluções técnicas para equipamentos de movimentação de carga. Segurança e conformidade normativa para sua operação.',
|
|
||||||
'home.hero.cta_primary': 'Falar com Engenheiro',
|
|
||||||
'home.hero.cta_secondary': 'Ver Soluções',
|
|
||||||
|
|
||||||
// Home - Features
|
|
||||||
'home.features.pretitle': 'Diferenciais',
|
|
||||||
'home.features.title': 'Segurança e Eficiência',
|
|
||||||
'home.features.1.title': 'Normas Técnicas',
|
|
||||||
'home.features.1.desc': 'Projetos e adequações rigorosamente alinhados com as normas NR-12, NR-11 e resoluções do CONTRAN.',
|
|
||||||
'home.features.2.title': 'Engenharia Mecânica',
|
|
||||||
'home.features.2.desc': 'Desenvolvimento de dispositivos de içamento e soluções personalizadas para otimizar sua logística.',
|
|
||||||
'home.features.3.title': 'Projetos de Implementos',
|
|
||||||
'home.features.3.desc': 'Engenharia especializada para instalação e adequação de Muncks, plataformas e dispositivos em veículos de carga.',
|
|
||||||
|
|
||||||
// Home - Services
|
|
||||||
'home.services.pretitle': 'O que fazemos',
|
|
||||||
'home.services.title': 'Soluções Especializadas',
|
|
||||||
'home.services.1.title': 'Projetos Mecânicos',
|
|
||||||
'home.services.1.desc': 'Desenvolvimento de dispositivos de içamento (Spreaders, Balancins).',
|
|
||||||
'home.services.2.title': 'Laudos Técnicos',
|
|
||||||
'home.services.2.desc': 'Inspeção e certificação de equipamentos de carga conforme normas.',
|
|
||||||
'home.services.3.title': 'Adequação NR-12',
|
|
||||||
'home.services.3.desc': 'Projetos de segurança para máquinas e equipamentos.',
|
|
||||||
'home.services.4.title': 'Engenharia Veicular',
|
|
||||||
'home.services.4.desc': 'Projetos para instalação de equipamentos em caminhões.',
|
|
||||||
'home.services.link': 'Ver todos os serviços',
|
|
||||||
|
|
||||||
// Home - About
|
|
||||||
'home.about.pretitle': 'Sobre Nós',
|
|
||||||
'home.about.title': 'Engenharia que garante segurança',
|
|
||||||
'home.about.desc': 'A Octto Engenharia é parceira técnica de grandes empresas logísticas. Não operamos frotas, nós garantimos que os equipamentos que movem sua carga sejam seguros, eficientes e estejam dentro das normas.',
|
|
||||||
'home.about.list.1': 'Projetos de Dispositivos de Içamento',
|
|
||||||
'home.about.list.2': 'Laudos Técnicos para Muncks e Guindastes',
|
|
||||||
'home.about.list.3': 'Responsabilidade Técnica (ART) garantida',
|
|
||||||
'home.about.link': 'Conheça nossa expertise',
|
|
||||||
|
|
||||||
// Home - Projects
|
|
||||||
'home.projects.pretitle': 'Portfólio',
|
|
||||||
'home.projects.title': 'Projetos Recentes',
|
|
||||||
'home.projects.link': 'Ver todos os projetos',
|
|
||||||
'home.projects.1.cat': 'Engenharia Veicular',
|
|
||||||
'home.projects.1.title': 'Projeto de Adequação - Coca-Cola',
|
|
||||||
'home.projects.2.cat': 'Inspeção Técnica',
|
|
||||||
'home.projects.2.title': 'Laudo de Guindaste Articulado',
|
|
||||||
'home.projects.3.cat': 'Projeto Mecânico',
|
|
||||||
'home.projects.3.title': 'Dispositivo de Içamento Especial',
|
|
||||||
'home.projects.4.cat': 'Laudos',
|
|
||||||
'home.projects.4.title': 'Certificação NR-12 - Parque Industrial',
|
|
||||||
'home.projects.5.cat': 'Engenharia Veicular',
|
|
||||||
'home.projects.5.title': 'Homologação de Plataforma Elevatória',
|
|
||||||
'home.projects.6.cat': 'Segurança do Trabalho',
|
|
||||||
'home.projects.6.title': 'Projeto de Linha de Vida para Caminhões',
|
|
||||||
'home.projects.view_details': 'Ver detalhes',
|
|
||||||
|
|
||||||
// Home - Testimonials
|
|
||||||
'home.testimonials.pretitle': 'Depoimentos',
|
|
||||||
'home.testimonials.title': 'Parceiros que confiam',
|
|
||||||
'home.testimonials.1.text': 'A Octto realizou a adequação de toda nossa frota de caminhões com excelência técnica e rapidez.',
|
|
||||||
'home.testimonials.1.role': 'Gerente de Frota, Distribuidora Bebidas',
|
|
||||||
'home.testimonials.2.text': 'Os laudos técnicos emitidos pela Octto nos deram total segurança jurídica e operacional.',
|
|
||||||
'home.testimonials.2.role': 'Diretora Operacional, Logística Express',
|
|
||||||
'home.testimonials.3.text': 'O projeto do dispositivo de içamento resolveu um gargalo antigo da nossa produção. Recomendo.',
|
|
||||||
'home.testimonials.3.role': 'Engenheiro Chefe, Indústria Metalúrgica',
|
|
||||||
|
|
||||||
// Home - CTA
|
|
||||||
'home.cta.title': 'Pronto para iniciar seu projeto?',
|
|
||||||
'home.cta.desc': 'Entre em contato conosco hoje mesmo e descubra como podemos ajudar a transformar sua visão em realidade.',
|
|
||||||
'home.cta.button': 'Falar com um Especialista',
|
|
||||||
|
|
||||||
// Services Page
|
|
||||||
'services.hero.title': 'Nossos Serviços',
|
|
||||||
'services.hero.subtitle': 'Soluções completas em engenharia mecânica e movimentação de carga.',
|
|
||||||
'services.cta.title': 'Precisa de uma solução personalizada?',
|
|
||||||
'services.cta.button': 'Falar com um Engenheiro',
|
|
||||||
'services.scope': 'Escopo do Serviço',
|
|
||||||
'services.title': 'Serviços',
|
'services.title': 'Serviços',
|
||||||
|
|
||||||
// Projects Page
|
// Cookies
|
||||||
'projects.hero.title': 'Nossos Projetos',
|
|
||||||
'projects.hero.subtitle': 'Explore nosso portfólio de soluções em movimentação de carga e engenharia mecânica.',
|
|
||||||
'projects.filter.all': 'Todos',
|
|
||||||
'projects.filter.implements': 'Implementos',
|
|
||||||
'projects.filter.mechanical': 'Projetos Mecânicos',
|
|
||||||
'projects.filter.reports': 'Laudos',
|
|
||||||
'projects.card.details': 'Ver detalhes',
|
|
||||||
|
|
||||||
// About Page
|
|
||||||
'about.hero.title': 'Sobre a Octto',
|
|
||||||
'about.hero.subtitle': 'Conheça nossa trajetória, valores e o compromisso com a excelência na engenharia.',
|
|
||||||
'about.history.pretitle': 'Nossa História',
|
|
||||||
'about.history.title': 'Nossa História',
|
|
||||||
'about.history.subtitle': 'Engenharia que impulsiona a logística',
|
|
||||||
'about.history.p1': 'A Octto Engenharia nasceu da necessidade do mercado por soluções técnicas especializadas em movimentação de carga e implementos rodoviários. Identificamos que grandes frotas careciam de engenharia de ponta para garantir segurança e eficiência.',
|
|
||||||
'about.history.p2': 'Hoje, somos parceiros estratégicos de grandes empresas de distribuição, como a Coca-Cola, desenvolvendo projetos de adequação, manutenção e certificação de equipamentos que são vitais para a cadeia logística nacional.',
|
|
||||||
'about.values.pretitle': 'Nossos Pilares',
|
|
||||||
'about.values.title': 'Nossos Pilares',
|
|
||||||
'about.values.subtitle': 'Valores que nos guiam',
|
|
||||||
'about.values.1.title': 'Excelência Técnica',
|
|
||||||
'about.values.1.desc': 'Busca incessante pela perfeição em cada detalhe construtivo e de projeto.',
|
|
||||||
'about.values.2.title': 'Transparência',
|
|
||||||
'about.values.2.desc': 'Relacionamento claro e honesto com clientes, fornecedores e colaboradores.',
|
|
||||||
'about.values.3.title': 'Sustentabilidade',
|
|
||||||
'about.values.3.desc': 'Compromisso com práticas que respeitam o meio ambiente e a sociedade.',
|
|
||||||
'about.values.quality.title': 'Excelência Técnica',
|
|
||||||
'about.values.quality.desc': 'Busca incessante pela perfeição em cada detalhe construtivo e de projeto.',
|
|
||||||
'about.values.transparency.title': 'Transparência',
|
|
||||||
'about.values.transparency.desc': 'Relacionamento claro e honesto com clientes, fornecedores e colaboradores.',
|
|
||||||
'about.values.sustainability.title': 'Sustentabilidade',
|
|
||||||
'about.values.sustainability.desc': 'Compromisso com práticas que respeitam o meio ambiente e a sociedade.',
|
|
||||||
|
|
||||||
// Contact Page
|
|
||||||
'contact.hero.title': 'Contato',
|
|
||||||
'contact.hero.subtitle': 'Estamos prontos para ouvir sobre o seu projeto. Entre em contato conosco.',
|
|
||||||
'contact.info.pretitle': 'Fale Conosco',
|
|
||||||
'contact.info.title': 'Canais de Atendimento',
|
|
||||||
'contact.info.subtitle': 'Entre em contato pelos nossos canais oficiais',
|
|
||||||
'contact.info.whatsapp.desc': 'Atendimento rápido e direto.',
|
|
||||||
'contact.info.email.desc': 'Para orçamentos e dúvidas técnicas.',
|
|
||||||
'contact.info.office.title': 'Escritório',
|
|
||||||
'contact.info.phone.title': 'WhatsApp',
|
|
||||||
'contact.info.email.title': 'E-mail',
|
|
||||||
'contact.info.address.title': 'Escritório',
|
|
||||||
'contact.form.title': 'Envie uma mensagem',
|
|
||||||
'contact.form.name': 'Nome',
|
|
||||||
'contact.form.name.placeholder': 'Seu nome',
|
|
||||||
'contact.form.phone': 'Telefone',
|
|
||||||
'contact.form.email': 'E-mail',
|
|
||||||
'contact.form.email.placeholder': 'seu@email.com',
|
|
||||||
'contact.form.subject': 'Assunto',
|
|
||||||
'contact.form.message': 'Mensagem',
|
|
||||||
'contact.form.message.placeholder': 'Como podemos ajudar?',
|
|
||||||
'contact.form.submit': 'Enviar Mensagem',
|
|
||||||
'contact.form.subject.select': 'Selecione um assunto',
|
|
||||||
'contact.form.subject.quote': 'Solicitar Orçamento',
|
|
||||||
'contact.form.subject.doubt': 'Dúvida Técnica',
|
|
||||||
'contact.form.subject.partnership': 'Parceria',
|
|
||||||
'contact.form.subject.other': 'Trabalhe Conosco',
|
|
||||||
|
|
||||||
// Cookie Consent
|
|
||||||
'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',
|
||||||
'cookie.accept': 'Aceitar',
|
'cookie.accept': 'Aceitar',
|
||||||
'cookie.decline': 'Recusar',
|
'cookie.decline': 'Recusar',
|
||||||
|
|
||||||
// WhatsApp
|
|
||||||
'whatsapp.label': 'Atendimento Rápido',
|
|
||||||
},
|
|
||||||
EN: {
|
|
||||||
'nav.home': 'Home',
|
|
||||||
'nav.services': 'Services',
|
|
||||||
'nav.projects': 'Projects',
|
|
||||||
'nav.contact': 'Contact',
|
|
||||||
'nav.about': 'About',
|
|
||||||
'nav.search': 'Search...',
|
|
||||||
'nav.contact_us': 'Contact Us',
|
|
||||||
'nav.theme': 'Theme',
|
|
||||||
'nav.language': 'Language',
|
|
||||||
'footer.rights': 'All rights reserved.',
|
|
||||||
|
|
||||||
// Home - Hero
|
|
||||||
'home.hero.badge': 'Official Service Provider',
|
|
||||||
'home.hero.title': 'Engineering of',
|
|
||||||
'home.hero.title_highlight': 'Lifting Devices',
|
|
||||||
'home.hero.subtitle': 'We develop projects, reports and technical solutions for load handling equipment. Safety and regulatory compliance for your operation.',
|
|
||||||
'home.hero.cta_primary': 'Talk to an Engineer',
|
|
||||||
'home.hero.cta_secondary': 'View Solutions',
|
|
||||||
|
|
||||||
// Home - Features
|
|
||||||
'home.features.pretitle': 'Differentials',
|
|
||||||
'home.features.title': 'Safety and Efficiency',
|
|
||||||
'home.features.1.title': 'Technical Standards',
|
|
||||||
'home.features.1.desc': 'Projects and adaptations strictly aligned with NR-12, NR-11 standards and CONTRAN resolutions.',
|
|
||||||
'home.features.2.title': 'Mechanical Engineering',
|
|
||||||
'home.features.2.desc': 'Development of lifting devices and custom solutions to optimize your logistics.',
|
|
||||||
'home.features.3.title': 'Implement Projects',
|
|
||||||
'home.features.3.desc': 'Specialized engineering for installation and adaptation of Cranes, platforms and devices on cargo vehicles.',
|
|
||||||
|
|
||||||
// Home - Services
|
|
||||||
'home.services.pretitle': 'What we do',
|
|
||||||
'home.services.title': 'Specialized Solutions',
|
|
||||||
'home.services.1.title': 'Mechanical Projects',
|
|
||||||
'home.services.1.desc': 'Development of lifting devices (Spreaders, Beams).',
|
|
||||||
'home.services.2.title': 'Technical Reports',
|
|
||||||
'home.services.2.desc': 'Inspection and certification of cargo equipment according to standards.',
|
|
||||||
'home.services.3.title': 'NR-12 Adaptation',
|
|
||||||
'home.services.3.desc': 'Safety projects for machinery and equipment.',
|
|
||||||
'home.services.4.title': 'Vehicular Engineering',
|
|
||||||
'home.services.4.desc': 'Projects for equipment installation on trucks.',
|
|
||||||
'home.services.link': 'View all services',
|
|
||||||
|
|
||||||
// Home - About
|
|
||||||
'home.about.pretitle': 'About Us',
|
|
||||||
'home.about.title': 'Engineering that ensures safety',
|
|
||||||
'home.about.desc': 'Octto Engineering is a technical partner for major logistics companies. We do not operate fleets, we ensure that the equipment moving your cargo is safe, efficient and compliant.',
|
|
||||||
'home.about.list.1': 'Lifting Device Projects',
|
|
||||||
'home.about.list.2': 'Technical Reports for Cranes',
|
|
||||||
'home.about.list.3': 'Technical Responsibility (ART) guaranteed',
|
|
||||||
'home.about.link': 'Know our expertise',
|
|
||||||
|
|
||||||
// Home - Projects
|
|
||||||
'home.projects.pretitle': 'Portfolio',
|
|
||||||
'home.projects.title': 'Recent Projects',
|
|
||||||
'home.projects.link': 'View all projects',
|
|
||||||
'home.projects.1.cat': 'Vehicular Engineering',
|
|
||||||
'home.projects.1.title': 'Adaptation Project - Coca-Cola',
|
|
||||||
'home.projects.2.cat': 'Technical Inspection',
|
|
||||||
'home.projects.2.title': 'Articulated Crane Report',
|
|
||||||
'home.projects.3.cat': 'Mechanical Project',
|
|
||||||
'home.projects.3.title': 'Special Lifting Device',
|
|
||||||
'home.projects.4.cat': 'Reports',
|
|
||||||
'home.projects.4.title': 'NR-12 Certification - Industrial Park',
|
|
||||||
'home.projects.5.cat': 'Vehicular Engineering',
|
|
||||||
'home.projects.5.title': 'Lifting Platform Homologation',
|
|
||||||
'home.projects.6.cat': 'Work Safety',
|
|
||||||
'home.projects.6.title': 'Lifeline Project for Trucks',
|
|
||||||
'home.projects.view_details': 'View details',
|
|
||||||
|
|
||||||
// Home - Testimonials
|
|
||||||
'home.testimonials.pretitle': 'Testimonials',
|
|
||||||
'home.testimonials.title': 'Partners who trust',
|
|
||||||
'home.testimonials.1.text': 'Octto performed the adaptation of our entire truck fleet with technical excellence and speed.',
|
|
||||||
'home.testimonials.1.role': 'Fleet Manager, Beverage Distributor',
|
|
||||||
'home.testimonials.2.text': 'The technical reports issued by Octto gave us total legal and operational security.',
|
|
||||||
'home.testimonials.2.role': 'Operations Director, Logistics Express',
|
|
||||||
'home.testimonials.3.text': 'The lifting device project solved an old bottleneck in our production. Highly recommend.',
|
|
||||||
'home.testimonials.3.role': 'Chief Engineer, Metallurgical Industry',
|
|
||||||
|
|
||||||
// Home - CTA
|
|
||||||
'home.cta.title': 'Ready to start your project?',
|
|
||||||
'home.cta.desc': 'Contact us today and discover how we can help transform your vision into reality.',
|
|
||||||
'home.cta.button': 'Talk to a Specialist',
|
|
||||||
|
|
||||||
// Services Page
|
|
||||||
'services.hero.title': 'Our Services',
|
|
||||||
'services.hero.subtitle': 'Complete solutions in mechanical engineering and load handling.',
|
|
||||||
'services.cta.title': 'Need a custom solution?',
|
|
||||||
'services.cta.button': 'Talk to an Engineer',
|
|
||||||
'services.scope': 'Service Scope',
|
|
||||||
'services.title': 'Services',
|
|
||||||
|
|
||||||
// Projects Page
|
|
||||||
'projects.hero.title': 'Our Projects',
|
|
||||||
'projects.hero.subtitle': 'Explore our portfolio of solutions in load handling and mechanical engineering.',
|
|
||||||
'projects.filter.all': 'All',
|
|
||||||
'projects.filter.implements': 'Implements',
|
|
||||||
'projects.filter.mechanical': 'Mechanical Projects',
|
|
||||||
'projects.filter.reports': 'Reports',
|
|
||||||
'projects.card.details': 'View details',
|
|
||||||
|
|
||||||
// About Page
|
|
||||||
'about.hero.title': 'About Octto',
|
|
||||||
'about.hero.subtitle': 'Know our trajectory, values and commitment to engineering excellence.',
|
|
||||||
'about.history.pretitle': 'Our History',
|
|
||||||
'about.history.title': 'Our History',
|
|
||||||
'about.history.subtitle': 'Engineering that drives logistics',
|
|
||||||
'about.history.p1': 'Octto Engineering was born from the market need for specialized technical solutions in load handling and road implements. We identified that large fleets lacked cutting-edge engineering to ensure safety and efficiency.',
|
|
||||||
'about.history.p2': 'Today, we are strategic partners of major distribution companies, such as Coca-Cola, developing adaptation, maintenance and equipment certification projects that are vital to the national logistics chain.',
|
|
||||||
'about.values.pretitle': 'Our Pillars',
|
|
||||||
'about.values.title': 'Our Pillars',
|
|
||||||
'about.values.subtitle': 'Values that guide us',
|
|
||||||
'about.values.1.title': 'Technical Excellence',
|
|
||||||
'about.values.1.desc': 'Relentless pursuit of perfection in every constructive and design detail.',
|
|
||||||
'about.values.2.title': 'Transparency',
|
|
||||||
'about.values.2.desc': 'Clear and honest relationship with customers, suppliers and employees.',
|
|
||||||
'about.values.3.title': 'Sustainability',
|
|
||||||
'about.values.3.desc': 'Commitment to practices that respect the environment and society.',
|
|
||||||
'about.values.quality.title': 'Technical Excellence',
|
|
||||||
'about.values.quality.desc': 'Relentless pursuit of perfection in every constructive and design detail.',
|
|
||||||
'about.values.transparency.title': 'Transparency',
|
|
||||||
'about.values.transparency.desc': 'Clear and honest relationship with customers, suppliers and employees.',
|
|
||||||
'about.values.sustainability.title': 'Sustainability',
|
|
||||||
'about.values.sustainability.desc': 'Commitment to practices that respect the environment and society.',
|
|
||||||
|
|
||||||
// Contact Page
|
|
||||||
'contact.hero.title': 'Contact',
|
|
||||||
'contact.hero.subtitle': 'We are ready to hear about your project. Contact us.',
|
|
||||||
'contact.info.pretitle': 'Contact Us',
|
|
||||||
'contact.info.title': 'Service Channels',
|
|
||||||
'contact.info.subtitle': 'Contact us through our official channels',
|
|
||||||
'contact.info.whatsapp.desc': 'Fast and direct service.',
|
|
||||||
'contact.info.email.desc': 'For quotes and technical questions.',
|
|
||||||
'contact.info.office.title': 'Office',
|
|
||||||
'contact.info.phone.title': 'WhatsApp',
|
|
||||||
'contact.info.email.title': 'E-mail',
|
|
||||||
'contact.info.address.title': 'Office',
|
|
||||||
'contact.form.title': 'Send a message',
|
|
||||||
'contact.form.name': 'Name',
|
|
||||||
'contact.form.name.placeholder': 'Your name',
|
|
||||||
'contact.form.phone': 'Phone',
|
|
||||||
'contact.form.email': 'E-mail',
|
|
||||||
'contact.form.email.placeholder': 'your@email.com',
|
|
||||||
'contact.form.subject': 'Subject',
|
|
||||||
'contact.form.message': 'Message',
|
|
||||||
'contact.form.message.placeholder': 'How can we help?',
|
|
||||||
'contact.form.submit': 'Send Message',
|
|
||||||
'contact.form.subject.select': 'Select a subject',
|
|
||||||
'contact.form.subject.quote': 'Request Quote',
|
|
||||||
'contact.form.subject.doubt': 'Technical Question',
|
|
||||||
'contact.form.subject.partnership': 'Partnership',
|
|
||||||
'contact.form.subject.other': 'Work with Us',
|
|
||||||
|
|
||||||
// Cookie Consent
|
|
||||||
'cookie.text': 'We use cookies to improve your experience and analyze site traffic. By continuing to browse, you agree to our',
|
|
||||||
'cookie.policy': 'Privacy Policy',
|
|
||||||
'cookie.accept': 'Accept',
|
|
||||||
'cookie.decline': 'Decline',
|
|
||||||
|
|
||||||
// WhatsApp
|
|
||||||
'whatsapp.label': 'Quick Service',
|
|
||||||
},
|
|
||||||
ES: {
|
|
||||||
'nav.home': 'Inicio',
|
|
||||||
'nav.services': 'Servicios',
|
|
||||||
'nav.projects': 'Proyectos',
|
|
||||||
'nav.contact': 'Contacto',
|
|
||||||
'nav.about': 'Sobre',
|
|
||||||
'nav.search': 'Buscar...',
|
|
||||||
'nav.contact_us': 'Hable con Nosotros',
|
|
||||||
'nav.theme': 'Tema',
|
|
||||||
'nav.language': 'Idioma',
|
|
||||||
'footer.rights': 'Todos los derechos reservados.',
|
|
||||||
|
|
||||||
// Home - Hero
|
|
||||||
'home.hero.badge': 'Proveedor de Servicio Oficial',
|
|
||||||
'home.hero.title': 'Ingeniería de',
|
|
||||||
'home.hero.title_highlight': 'Dispositivos de Elevación',
|
|
||||||
'home.hero.subtitle': 'Desarrollamos proyectos, informes y soluciones técnicas para equipos de movimiento de carga. Seguridad y cumplimiento normativo para su operación.',
|
|
||||||
'home.hero.cta_primary': 'Hablar con Ingeniero',
|
|
||||||
'home.hero.cta_secondary': 'Ver Soluciones',
|
|
||||||
|
|
||||||
// Home - Features
|
|
||||||
'home.features.pretitle': 'Diferenciales',
|
|
||||||
'home.features.title': 'Seguridad y Eficiencia',
|
|
||||||
'home.features.1.title': 'Normas Técnicas',
|
|
||||||
'home.features.1.desc': 'Proyectos y adecuaciones rigurosamente alineados con las normas NR-12, NR-11 y resoluciones del CONTRAN.',
|
|
||||||
'home.features.2.title': 'Ingeniería Mecánica',
|
|
||||||
'home.features.2.desc': 'Desarrollo de dispositivos de elevación y soluciones personalizadas para optimizar su logística.',
|
|
||||||
'home.features.3.title': 'Proyectos de Implementos',
|
|
||||||
'home.features.3.desc': 'Ingeniería especializada para instalación y adecuación de Grúas, plataformas y dispositivos en vehículos de carga.',
|
|
||||||
|
|
||||||
// Home - Services
|
|
||||||
'home.services.pretitle': 'Lo que hacemos',
|
|
||||||
'home.services.title': 'Soluciones Especializadas',
|
|
||||||
'home.services.1.title': 'Proyectos Mecánicos',
|
|
||||||
'home.services.1.desc': 'Desarrollo de dispositivos de elevación (Spreaders, Balancines).',
|
|
||||||
'home.services.2.title': 'Informes Técnicos',
|
|
||||||
'home.services.2.desc': 'Inspección y certificación de equipos de carga conforme normas.',
|
|
||||||
'home.services.3.title': 'Adecuación NR-12',
|
|
||||||
'home.services.3.desc': 'Proyectos de seguridad para máquinas y equipos.',
|
|
||||||
'home.services.4.title': 'Ingeniería Vehicular',
|
|
||||||
'home.services.4.desc': 'Proyectos para instalación de equipos en camiones.',
|
|
||||||
'home.services.link': 'Ver todos los servicios',
|
|
||||||
|
|
||||||
// Home - About
|
|
||||||
'home.about.pretitle': 'Sobre Nosotros',
|
|
||||||
'home.about.title': 'Ingeniería que garantiza seguridad',
|
|
||||||
'home.about.desc': 'Octto Ingeniería es socia técnica de grandes empresas logísticas. No operamos flotas, garantizamos que los equipos que mueven su carga sean seguros, eficientes y cumplan con las normas.',
|
|
||||||
'home.about.list.1': 'Proyectos de Dispositivos de Elevación',
|
|
||||||
'home.about.list.2': 'Informes Técnicos para Grúas',
|
|
||||||
'home.about.list.3': 'Responsabilidad Técnica (ART) garantizada',
|
|
||||||
'home.about.link': 'Conozca nuestra experiencia',
|
|
||||||
|
|
||||||
// Home - Projects
|
|
||||||
'home.projects.pretitle': 'Portafolio',
|
|
||||||
'home.projects.title': 'Proyectos Recientes',
|
|
||||||
'home.projects.link': 'Ver todos los proyectos',
|
|
||||||
'home.projects.1.cat': 'Ingeniería Vehicular',
|
|
||||||
'home.projects.1.title': 'Proyecto de Adecuación - Coca-Cola',
|
|
||||||
'home.projects.2.cat': 'Inspección Técnica',
|
|
||||||
'home.projects.2.title': 'Informe de Grúa Articulada',
|
|
||||||
'home.projects.3.cat': 'Proyecto Mecánico',
|
|
||||||
'home.projects.3.title': 'Dispositivo de Elevación Especial',
|
|
||||||
'home.projects.4.cat': 'Informes',
|
|
||||||
'home.projects.4.title': 'Certificación NR-12 - Parque Industrial',
|
|
||||||
'home.projects.5.cat': 'Ingeniería Vehicular',
|
|
||||||
'home.projects.5.title': 'Homologación de Plataforma Elevadora',
|
|
||||||
'home.projects.6.cat': 'Seguridad Laboral',
|
|
||||||
'home.projects.6.title': 'Proyecto de Línea de Vida para Camiones',
|
|
||||||
'home.projects.view_details': 'Ver detalles',
|
|
||||||
|
|
||||||
// Home - Testimonials
|
|
||||||
'home.testimonials.pretitle': 'Testimonios',
|
|
||||||
'home.testimonials.title': 'Socios que confían',
|
|
||||||
'home.testimonials.1.text': 'Octto realizó la adecuación de toda nuestra flota de camiones con excelencia técnica y rapidez.',
|
|
||||||
'home.testimonials.1.role': 'Gerente de Flota, Distribuidora Bebidas',
|
|
||||||
'home.testimonials.2.text': 'Los informes técnicos emitidos por Octto nos dieron total seguridad jurídica y operativa.',
|
|
||||||
'home.testimonials.2.role': 'Directora Operativa, Logística Express',
|
|
||||||
'home.testimonials.3.text': 'El proyecto del dispositivo de elevación resolvió un cuello de botella antiguo de nuestra producción. Recomiendo.',
|
|
||||||
'home.testimonials.3.role': 'Ingeniero Jefe, Industria Metalúrgica',
|
|
||||||
|
|
||||||
// Home - CTA
|
|
||||||
'home.cta.title': '¿Listo para iniciar su proyecto?',
|
|
||||||
'home.cta.desc': 'Contáctenos hoy mismo y descubra cómo podemos ayudar a transformar su visión en realidad.',
|
|
||||||
'home.cta.button': 'Hablar con un Especialista',
|
|
||||||
|
|
||||||
// Services Page
|
|
||||||
'services.hero.title': 'Nuestros Servicios',
|
|
||||||
'services.hero.subtitle': 'Soluciones completas en ingeniería mecánica y movimiento de carga.',
|
|
||||||
'services.cta.title': '¿Necesita una solución personalizada?',
|
|
||||||
'services.cta.button': 'Hablar con un Ingeniero',
|
|
||||||
'services.scope': 'Alcance del Servicio',
|
|
||||||
'services.title': 'Servicios',
|
|
||||||
|
|
||||||
// Projects Page
|
|
||||||
'projects.hero.title': 'Nuestros Proyectos',
|
|
||||||
'projects.hero.subtitle': 'Explore nuestro portafolio de soluciones en movimiento de carga e ingeniería mecánica.',
|
|
||||||
'projects.filter.all': 'Todos',
|
|
||||||
'projects.filter.implements': 'Implementos',
|
|
||||||
'projects.filter.mechanical': 'Proyectos Mecánicos',
|
|
||||||
'projects.filter.reports': 'Informes',
|
|
||||||
'projects.card.details': 'Ver detalles',
|
|
||||||
|
|
||||||
// About Page
|
|
||||||
'about.hero.title': 'Sobre Octto',
|
|
||||||
'about.hero.subtitle': 'Conozca nuestra trayectoria, valores y el compromiso con la excelencia en la ingeniería.',
|
|
||||||
'about.history.pretitle': 'Nuestra Historia',
|
|
||||||
'about.history.title': 'Nuestra Historia',
|
|
||||||
'about.history.subtitle': 'Ingeniería que impulsa la logística',
|
|
||||||
'about.history.p1': 'Octto Ingeniería nació de la necesidad del mercado por soluciones técnicas especializadas en movimiento de carga e implementos viales. Identificamos que grandes flotas carecían de ingeniería de punta para garantizar seguridad y eficiencia.',
|
|
||||||
'about.history.p2': 'Hoy, somos socios estratégicos de grandes empresas de distribución, como Coca-Cola, desarrollando proyectos de adecuación, mantenimiento y certificación de equipos que son vitales para la cadena logística nacional.',
|
|
||||||
'about.values.pretitle': 'Nuestros Pilares',
|
|
||||||
'about.values.title': 'Nuestros Pilares',
|
|
||||||
'about.values.subtitle': 'Valores que nos guían',
|
|
||||||
'about.values.1.title': 'Excelencia Técnica',
|
|
||||||
'about.values.1.desc': 'Búsqueda incesante de la perfección en cada detalle construtivo e de diseño.',
|
|
||||||
'about.values.2.title': 'Transparencia',
|
|
||||||
'about.values.2.desc': 'Relación clara y honesta con clientes, proveedores y empleados.',
|
|
||||||
'about.values.3.title': 'Sostenibilidad',
|
|
||||||
'about.values.3.desc': 'Compromiso con prácticas que respetan el medio ambiente y la sociedad.',
|
|
||||||
'about.values.quality.title': 'Excelência Técnica',
|
|
||||||
'about.values.quality.desc': 'Búsqueda incesante de la perfección en cada detalhe construtivo e de projeto.',
|
|
||||||
'about.values.transparency.title': 'Transparência',
|
|
||||||
'about.values.transparency.desc': 'Relacionamento claro e honesto com clientes, fornecedores e colaboradores.',
|
|
||||||
'about.values.sustainability.title': 'Sustentabilidade',
|
|
||||||
'about.values.sustainability.desc': 'Compromisso com práticas que respeitam o meio ambiente e a sociedade.',
|
|
||||||
|
|
||||||
// Contact Page
|
|
||||||
'contact.hero.title': 'Contacto',
|
|
||||||
'contact.hero.subtitle': 'Estamos listos para escuchar sobre su proyecto. Contáctenos.',
|
|
||||||
'contact.info.pretitle': 'Hable con Nosotros',
|
|
||||||
'contact.info.title': 'Canales de Atención',
|
|
||||||
'contact.info.subtitle': 'Contáctenos a través de nuestros canales oficiales',
|
|
||||||
'contact.info.whatsapp.desc': 'Atención rápida y directa.',
|
|
||||||
'contact.info.email.desc': 'Para presupuestos y dudas técnicas.',
|
|
||||||
'contact.info.office.title': 'Oficina',
|
|
||||||
'contact.info.phone.title': 'WhatsApp',
|
|
||||||
'contact.info.email.title': 'E-mail',
|
|
||||||
'contact.info.address.title': 'Oficina',
|
|
||||||
'contact.form.title': 'Envíe un mensaje',
|
|
||||||
'contact.form.name': 'Nombre',
|
|
||||||
'contact.form.name.placeholder': 'Su nombre',
|
|
||||||
'contact.form.phone': 'Teléfono',
|
|
||||||
'contact.form.email': 'E-mail',
|
|
||||||
'contact.form.email.placeholder': 'su@email.com',
|
|
||||||
'contact.form.subject': 'Asunto',
|
|
||||||
'contact.form.message': 'Mensaje',
|
|
||||||
'contact.form.message.placeholder': '¿Cómo podemos ayudar?',
|
|
||||||
'contact.form.submit': 'Enviar Mensaje',
|
|
||||||
'contact.form.subject.select': 'Seleccione un asunto',
|
|
||||||
'contact.form.subject.budget': 'Solicitar Presupuesto',
|
|
||||||
'contact.form.subject.tech': 'Duda Técnica',
|
|
||||||
'contact.form.subject.partnership': 'Asociación',
|
|
||||||
'contact.form.subject.work': 'Trabaje con Nosotros',
|
|
||||||
|
|
||||||
// Cookie Consent
|
|
||||||
'cookie.text': 'Utilizamos cookies para mejorar su experiencia y analizar el tráfico del sitio. Al continuar navegando, acepta nuestra',
|
|
||||||
'cookie.policy': 'Política de Privacidad',
|
|
||||||
'cookie.accept': 'Aceptar',
|
|
||||||
'cookie.decline': 'Rechazar',
|
|
||||||
|
|
||||||
// WhatsApp
|
|
||||||
'whatsapp.label': 'Atención Rápida',
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const [language, setLanguage] = useState<Language>('PT');
|
const [language, setLanguageState] = useState<Language>('PT');
|
||||||
|
|
||||||
const t = (key: string): string => {
|
// Carregar idioma salvo
|
||||||
return translations[language][key as keyof typeof translations[typeof language]] || key;
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem('language') as Language;
|
||||||
|
if (saved && ['PT', 'EN', 'ES'].includes(saved)) {
|
||||||
|
setLanguageState(saved);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setLanguage = (lang: Language) => {
|
||||||
|
setLanguageState(lang);
|
||||||
|
localStorage.setItem('language', lang);
|
||||||
};
|
};
|
||||||
|
|
||||||
const tDynamic = (content: { PT: string, EN?: string, ES?: string }): string => {
|
// Função t() agora apenas retorna o texto em PT
|
||||||
if (language === 'PT') return content.PT;
|
// A tradução é feita pelo componente AutoTranslate
|
||||||
if (language === 'EN' && content.EN) return content.EN;
|
const t = (key: string): string => {
|
||||||
if (language === 'ES' && content.ES) return content.ES;
|
return systemTexts[key] || key;
|
||||||
return content.PT;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LanguageContext.Provider value={{ language, setLanguage, t, tDynamic }}>
|
<LanguageContext.Provider value={{ language, setLanguage, t }}>
|
||||||
{children}
|
{children}
|
||||||
</LanguageContext.Provider>
|
</LanguageContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
85
frontend/src/contexts/LocaleContext.tsx
Normal file
85
frontend/src/contexts/LocaleContext.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
|
import { type Locale, locales, defaultLocale, getNestedValue } from '@/lib/i18n';
|
||||||
|
|
||||||
|
// Importar traduções estaticamente
|
||||||
|
import ptTranslations from '@/locales/pt.json';
|
||||||
|
import enTranslations from '@/locales/en.json';
|
||||||
|
import esTranslations from '@/locales/es.json';
|
||||||
|
|
||||||
|
const translations: Record<Locale, typeof ptTranslations> = {
|
||||||
|
pt: ptTranslations,
|
||||||
|
en: enTranslations,
|
||||||
|
es: esTranslations,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LocaleContextType {
|
||||||
|
locale: Locale;
|
||||||
|
setLocale: (locale: Locale) => void;
|
||||||
|
t: (key: string) => string;
|
||||||
|
translations: typeof ptTranslations;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LocaleContext = createContext<LocaleContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
interface LocaleProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
locale: Locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocaleProvider({ children, locale: initialLocale }: LocaleProviderProps) {
|
||||||
|
const [locale, setLocaleState] = useState<Locale>(initialLocale);
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// Função para trocar idioma (navega para nova URL)
|
||||||
|
const setLocale = (newLocale: Locale) => {
|
||||||
|
// Salvar preferência no cookie
|
||||||
|
document.cookie = `locale=${newLocale};path=/;max-age=31536000`; // 1 ano
|
||||||
|
|
||||||
|
// Remover locale atual do pathname
|
||||||
|
let newPathname = pathname;
|
||||||
|
|
||||||
|
// Verificar se pathname começa com locale
|
||||||
|
for (const loc of locales) {
|
||||||
|
if (pathname.startsWith(`/${loc}/`) || pathname === `/${loc}`) {
|
||||||
|
newPathname = pathname.replace(`/${loc}`, '') || '/';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construir nova URL
|
||||||
|
if (newLocale === defaultLocale) {
|
||||||
|
// Português não tem prefixo
|
||||||
|
router.push(newPathname);
|
||||||
|
} else {
|
||||||
|
router.push(`/${newLocale}${newPathname === '/' ? '' : newPathname}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocaleState(newLocale);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Função t() para obter tradução
|
||||||
|
const t = (key: string): string => {
|
||||||
|
return getNestedValue(translations[locale] as Record<string, unknown>, key);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LocaleContext.Provider value={{ locale, setLocale, t, translations: translations[locale] }}>
|
||||||
|
{children}
|
||||||
|
</LocaleContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLocale() {
|
||||||
|
const context = useContext(LocaleContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useLocale must be used within a LocaleProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alias para compatibilidade
|
||||||
|
export const useLanguage = useLocale;
|
||||||
@@ -3,23 +3,29 @@ import { useState, useEffect } from 'react';
|
|||||||
interface PageContentData {
|
interface PageContentData {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
locale: string;
|
||||||
content: any;
|
content: any;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
fallback?: boolean; // true se retornou versão PT por falta da traduzida
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePageContent(slug: string) {
|
export function usePageContent(slug: string, locale: string = 'pt') {
|
||||||
const [content, setContent] = useState<any>(null);
|
const [content, setContent] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isFallback, setIsFallback] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchContent = async () => {
|
const fetchContent = async () => {
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/pages/${slug}`);
|
// Busca com locale para pegar versão já traduzida do banco
|
||||||
|
const response = await fetch(`/api/pages/${slug}?locale=${locale}`);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data: PageContentData = await response.json();
|
const data: PageContentData = await response.json();
|
||||||
setContent(data.content);
|
setContent(data.content);
|
||||||
|
setIsFallback(data.fallback || false);
|
||||||
} else if (response.status === 404) {
|
} else if (response.status === 404) {
|
||||||
// Página ainda não foi configurada no admin
|
// Página ainda não foi configurada no admin
|
||||||
setContent(null);
|
setContent(null);
|
||||||
@@ -34,7 +40,7 @@ export function usePageContent(slug: string) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchContent();
|
fetchContent();
|
||||||
}, [slug]);
|
}, [slug, locale]);
|
||||||
|
|
||||||
return { content, loading, error };
|
return { content, loading, error, isFallback };
|
||||||
}
|
}
|
||||||
|
|||||||
109
frontend/src/hooks/useTranslate.ts
Normal file
109
frontend/src/hooks/useTranslate.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
|
||||||
|
// Cache local no cliente
|
||||||
|
const clientCache = new Map<string, string>();
|
||||||
|
|
||||||
|
export function useTranslate() {
|
||||||
|
const { language } = useLanguage();
|
||||||
|
const [isTranslating, setIsTranslating] = useState(false);
|
||||||
|
|
||||||
|
const translate = useCallback(async (text: string): Promise<string> => {
|
||||||
|
if (!text || language === 'PT') return text;
|
||||||
|
|
||||||
|
const targetLang = language.toLowerCase(); // PT -> pt, EN -> en, ES -> es
|
||||||
|
const cacheKey = `pt:${targetLang}:${text}`;
|
||||||
|
|
||||||
|
// Verificar cache local
|
||||||
|
if (clientCache.has(cacheKey)) {
|
||||||
|
return clientCache.get(cacheKey)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsTranslating(true);
|
||||||
|
const response = await fetch('/api/translate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text, source: 'pt', target: targetLang }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
clientCache.set(cacheKey, data.translatedText);
|
||||||
|
return data.translatedText;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Translation error:', error);
|
||||||
|
} finally {
|
||||||
|
setIsTranslating(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
|
const translateBatch = useCallback(async (texts: string[]): Promise<string[]> => {
|
||||||
|
if (language === 'PT') return texts;
|
||||||
|
|
||||||
|
const targetLang = language.toLowerCase();
|
||||||
|
|
||||||
|
// Separar textos em cache e não em cache
|
||||||
|
const results: string[] = new Array(texts.length);
|
||||||
|
const toTranslate: { index: number; text: string }[] = [];
|
||||||
|
|
||||||
|
texts.forEach((text, index) => {
|
||||||
|
if (!text) {
|
||||||
|
results[index] = text;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `pt:${targetLang}:${text}`;
|
||||||
|
if (clientCache.has(cacheKey)) {
|
||||||
|
results[index] = clientCache.get(cacheKey)!;
|
||||||
|
} else {
|
||||||
|
toTranslate.push({ index, text });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (toTranslate.length === 0) return results;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsTranslating(true);
|
||||||
|
const response = await fetch('/api/translate', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
texts: toTranslate.map(t => t.text),
|
||||||
|
source: 'pt',
|
||||||
|
target: targetLang,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
toTranslate.forEach((item, i) => {
|
||||||
|
const translated = data.translations[i];
|
||||||
|
results[item.index] = translated;
|
||||||
|
clientCache.set(`pt:${targetLang}:${item.text}`, translated);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback: usar texto original
|
||||||
|
toTranslate.forEach(item => {
|
||||||
|
results[item.index] = item.text;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Batch translation error:', error);
|
||||||
|
toTranslate.forEach(item => {
|
||||||
|
results[item.index] = item.text;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsTranslating(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
|
return { translate, translateBatch, isTranslating, language };
|
||||||
|
}
|
||||||
44
frontend/src/lib/i18n.ts
Normal file
44
frontend/src/lib/i18n.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
export const locales = ['pt', 'en', 'es'] as const;
|
||||||
|
export type Locale = (typeof locales)[number];
|
||||||
|
|
||||||
|
export const defaultLocale: Locale = 'pt';
|
||||||
|
|
||||||
|
export const localeNames: Record<Locale, string> = {
|
||||||
|
pt: 'Português',
|
||||||
|
en: 'English',
|
||||||
|
es: 'Español',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const localeFlags: Record<Locale, string> = {
|
||||||
|
pt: '🇧🇷',
|
||||||
|
en: '🇺🇸',
|
||||||
|
es: '🇪🇸',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Função para carregar traduções
|
||||||
|
export async function getTranslations(locale: Locale) {
|
||||||
|
try {
|
||||||
|
const translations = await import(`@/locales/${locale}.json`);
|
||||||
|
return translations.default;
|
||||||
|
} catch {
|
||||||
|
// Fallback para português
|
||||||
|
const translations = await import(`@/locales/pt.json`);
|
||||||
|
return translations.default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função para obter tradução por chave (ex: "nav.home")
|
||||||
|
export function getNestedValue(obj: Record<string, unknown>, path: string): string {
|
||||||
|
const keys = path.split('.');
|
||||||
|
let current: unknown = obj;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (current && typeof current === 'object' && key in current) {
|
||||||
|
current = (current as Record<string, unknown>)[key];
|
||||||
|
} else {
|
||||||
|
return path; // Retorna a chave se não encontrar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof current === 'string' ? current : path;
|
||||||
|
}
|
||||||
@@ -1,22 +1,98 @@
|
|||||||
import * as Minio from 'minio';
|
import { S3Client, HeadBucketCommand, CreateBucketCommand, PutBucketPolicyCommand, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
|
||||||
export const minioClient = new Minio.Client({
|
const endpointHost = process.env.MINIO_ENDPOINT || 'localhost';
|
||||||
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
|
const port = Number.parseInt(process.env.MINIO_PORT || '9000', 10);
|
||||||
port: parseInt(process.env.MINIO_PORT || '9000'),
|
const useSSL = process.env.MINIO_USE_SSL === 'true';
|
||||||
useSSL: process.env.MINIO_USE_SSL === 'true',
|
const protocol = useSSL ? 'https' : 'http';
|
||||||
accessKey: process.env.MINIO_ACCESS_KEY || 'admin',
|
const endpointUrl = `${protocol}://${endpointHost}:${port}`;
|
||||||
secretKey: process.env.MINIO_SECRET_KEY || 'adminpassword',
|
|
||||||
});
|
console.log(`[MinIO] Configurando cliente: ${endpointHost}:${port} (SSL: ${useSSL})`);
|
||||||
|
|
||||||
export const bucketName = process.env.MINIO_BUCKET_NAME || 'occto-images';
|
export const bucketName = process.env.MINIO_BUCKET_NAME || 'occto-images';
|
||||||
|
|
||||||
// Ensure bucket exists
|
const s3Client = new S3Client({
|
||||||
|
region: 'us-east-1',
|
||||||
|
endpoint: endpointUrl,
|
||||||
|
forcePathStyle: true,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.MINIO_ACCESS_KEY || 'admin',
|
||||||
|
secretAccessKey: process.env.MINIO_SECRET_KEY || 'adminpassword',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type MetadataMap = Record<string, string> | undefined;
|
||||||
|
|
||||||
|
function extractContentType(metadata: MetadataMap) {
|
||||||
|
if (!metadata) return undefined;
|
||||||
|
const contentTypeKey = Object.keys(metadata).find(key => key.toLowerCase() === 'content-type');
|
||||||
|
return contentTypeKey ? metadata[contentTypeKey] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeMetadata(metadata: MetadataMap) {
|
||||||
|
if (!metadata) return undefined;
|
||||||
|
const entries = Object.entries(metadata).filter(([key]) => key.toLowerCase() !== 'content-type');
|
||||||
|
return entries.length ? Object.fromEntries(entries) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const minioClient = {
|
||||||
|
async bucketExists(bucket: string) {
|
||||||
|
try {
|
||||||
|
await s3Client.send(new HeadBucketCommand({ Bucket: bucket }));
|
||||||
|
return true;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as { $metadata?: { httpStatusCode?: number } };
|
||||||
|
if (err?.$metadata?.httpStatusCode === 404) return false;
|
||||||
|
if (err?.$metadata?.httpStatusCode === 301) return true; // Bucket existe mas outra região
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async makeBucket(bucket: string) {
|
||||||
|
try {
|
||||||
|
await s3Client.send(new CreateBucketCommand({ Bucket: bucket }));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as { $metadata?: { httpStatusCode?: number } };
|
||||||
|
if (err?.$metadata?.httpStatusCode === 409) {
|
||||||
|
return; // bucket já existe
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async setBucketPolicy(bucket: string, policy: string) {
|
||||||
|
await s3Client.send(new PutBucketPolicyCommand({ Bucket: bucket, Policy: policy }));
|
||||||
|
},
|
||||||
|
|
||||||
|
async putObject(bucket: string, key: string, body: Buffer | Uint8Array | string, size?: number, metadata?: MetadataMap) {
|
||||||
|
const ContentType = extractContentType(metadata);
|
||||||
|
const remainingMetadata = sanitizeMetadata(metadata);
|
||||||
|
|
||||||
|
await s3Client.send(new PutObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: key,
|
||||||
|
Body: body,
|
||||||
|
ContentLength: size,
|
||||||
|
ContentType,
|
||||||
|
Metadata: remainingMetadata,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
async getObject(bucket: string, key: string) {
|
||||||
|
return s3Client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeObject(bucket: string, key: string) {
|
||||||
|
await s3Client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Garante que o bucket exista antes de qualquer upload
|
||||||
export async function ensureBucketExists() {
|
export async function ensureBucketExists() {
|
||||||
try {
|
try {
|
||||||
const exists = await minioClient.bucketExists(bucketName);
|
const exists = await minioClient.bucketExists(bucketName);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
await minioClient.makeBucket(bucketName, 'us-east-1');
|
await minioClient.makeBucket(bucketName);
|
||||||
// Set policy to public read
|
|
||||||
const policy = {
|
const policy = {
|
||||||
Version: '2012-10-17',
|
Version: '2012-10-17',
|
||||||
Statement: [
|
Statement: [
|
||||||
@@ -28,10 +104,11 @@ export async function ensureBucketExists() {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
await minioClient.setBucketPolicy(bucketName, JSON.stringify(policy));
|
await minioClient.setBucketPolicy(bucketName, JSON.stringify(policy));
|
||||||
console.log(`Bucket ${bucketName} created and policy set to public read.`);
|
console.log(`Bucket ${bucketName} criado e política pública aplicada.`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error ensuring bucket exists:', error);
|
console.error('Erro ao garantir existência do bucket MinIO:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
204
frontend/src/locales/en.json
Normal file
204
frontend/src/locales/en.json
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"home": "Home",
|
||||||
|
"services": "Services",
|
||||||
|
"projects": "Projects",
|
||||||
|
"contact": "Contact",
|
||||||
|
"about": "About",
|
||||||
|
"search": "Search...",
|
||||||
|
"contactUs": "Contact Us",
|
||||||
|
"theme": "Theme",
|
||||||
|
"language": "Language"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"rights": "All rights reserved.",
|
||||||
|
"quickLinks": "Quick Links",
|
||||||
|
"description": "Solutions in mechanical engineering and safety for cargo handling.",
|
||||||
|
"privacyPolicy": "Privacy Policy",
|
||||||
|
"termsOfUse": "Terms of Use"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"officialProvider": "Official Service Provider",
|
||||||
|
"viewSolutions": "View Solutions",
|
||||||
|
"viewAllServices": "View all services",
|
||||||
|
"knowExpertise": "Know our expertise",
|
||||||
|
"portfolio": "Portfolio",
|
||||||
|
"recentProjects": "Recent Projects",
|
||||||
|
"viewAllProjects": "View all projects",
|
||||||
|
"viewDetails": "View details",
|
||||||
|
"differentials": "Differentials",
|
||||||
|
"whyChoose": "Why choose Occto?",
|
||||||
|
"testimonials": "Testimonials",
|
||||||
|
"whatClientsSay": "What our clients say"
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"title": "Services",
|
||||||
|
"deviceProjects": "Device Projects",
|
||||||
|
"implementEngineering": "Implement Engineering",
|
||||||
|
"equipmentInspection": "Equipment Inspection",
|
||||||
|
"technicalReports": "Technical Reports (NR-11/12)",
|
||||||
|
"scope": "Scope of Action",
|
||||||
|
"hero": {
|
||||||
|
"title": "Our Services",
|
||||||
|
"subtitle": "Complete engineering solutions to meet your company's needs"
|
||||||
|
},
|
||||||
|
"technical": {
|
||||||
|
"title": "Technical Projects",
|
||||||
|
"description": "Development of mechanical, structural and vehicular engineering projects with high precision and regulatory compliance.",
|
||||||
|
"feature1": "3D Mechanical Design",
|
||||||
|
"feature2": "Structural Calculation",
|
||||||
|
"feature3": "Special Devices",
|
||||||
|
"feature4": "Equipment Approval"
|
||||||
|
},
|
||||||
|
"vehicular": {
|
||||||
|
"title": "Vehicular Engineering",
|
||||||
|
"description": "Expertise in vehicular modifications, adaptations and approvals with focus on safety and compliance.",
|
||||||
|
"feature1": "Installation Project",
|
||||||
|
"feature2": "Stability Study",
|
||||||
|
"feature3": "Body Adaptation",
|
||||||
|
"feature4": "Vehicle Regularization"
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"title": "Reports and Expertise",
|
||||||
|
"description": "Issuance of technical reports and expert opinions for equipment, structures and vehicles.",
|
||||||
|
"feature1": "Crane/Boom Truck Reports",
|
||||||
|
"feature2": "Safety Inspection",
|
||||||
|
"feature3": "Load Testing",
|
||||||
|
"feature4": "Equipment Certification"
|
||||||
|
},
|
||||||
|
"consulting": {
|
||||||
|
"title": "Technical Consulting",
|
||||||
|
"description": "Specialized advisory for fleet adaptation, Rigging plans and supervision of cargo equipment maintenance.",
|
||||||
|
"feature1": "Rigging Plan",
|
||||||
|
"feature2": "Maintenance Supervision",
|
||||||
|
"feature3": "Standards Consulting",
|
||||||
|
"feature4": "Operational Training"
|
||||||
|
},
|
||||||
|
"cta": {
|
||||||
|
"title": "Need a specialized service?",
|
||||||
|
"button": "Request a Quote"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"projects": {
|
||||||
|
"hero": {
|
||||||
|
"title": "Our Projects",
|
||||||
|
"subtitle": "Discover some of the projects we have completed for our clients"
|
||||||
|
},
|
||||||
|
"viewDetails": "View Details",
|
||||||
|
"filters": {
|
||||||
|
"all": "All",
|
||||||
|
"implements": "Implements",
|
||||||
|
"mechanical": "Mechanical Projects",
|
||||||
|
"reports": "Reports"
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"vehicular": "Vehicular Engineering",
|
||||||
|
"reports": "Reports and Expertise",
|
||||||
|
"mechanical": "Mechanical Projects",
|
||||||
|
"safety": "Work Safety"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"item1": {
|
||||||
|
"title": "Truck Fleet Adaptation",
|
||||||
|
"description": "Technical adaptation project for 50 trucks for installation of special bodies and safety systems."
|
||||||
|
},
|
||||||
|
"item2": {
|
||||||
|
"title": "Industrial Crane Technical Report",
|
||||||
|
"description": "Complete inspection and issuance of technical report for 45-ton crane, with load tests and structural verification."
|
||||||
|
},
|
||||||
|
"item3": {
|
||||||
|
"title": "Port Equipment Project",
|
||||||
|
"description": "Development and structural calculation of Spreader for container handling in port area."
|
||||||
|
},
|
||||||
|
"item4": {
|
||||||
|
"title": "NR-12 Production Line Adaptation",
|
||||||
|
"description": "Inventory and safety adaptation of 120 machine tools according to NR-12 regulatory standard."
|
||||||
|
},
|
||||||
|
"item5": {
|
||||||
|
"title": "Special Vehicle Approval",
|
||||||
|
"description": "Complete approval and certification process for elevator platforms for urban distribution."
|
||||||
|
},
|
||||||
|
"item6": {
|
||||||
|
"title": "Fall Protection System",
|
||||||
|
"description": "Project and installation of lifeline system for fall protection in loading and unloading operations."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"hero": {
|
||||||
|
"title": "About OCCTO",
|
||||||
|
"subtitle": "Learn about our history, mission and values that guide us in delivering engineering excellence"
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"pretitle": "Our History",
|
||||||
|
"title": "More than 15 years of engineering experience",
|
||||||
|
"paragraph1": "OCCTO Engineering was founded with the goal of offering complete solutions in mechanical, vehicular and work safety engineering. Over more than 15 years, we have built a solid trajectory based on technical excellence and commitment to customer satisfaction.",
|
||||||
|
"paragraph2": "Our team is composed of highly qualified and specialized engineers, who work with the most modern tools and methodologies to ensure accurate and reliable results in each project."
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"pretitle": "Our Values",
|
||||||
|
"title": "What drives us",
|
||||||
|
"quality": {
|
||||||
|
"title": "Quality",
|
||||||
|
"description": "Commitment to excellence in each project, ensuring precision and compliance in all deliveries."
|
||||||
|
},
|
||||||
|
"transparency": {
|
||||||
|
"title": "Transparency",
|
||||||
|
"description": "Relationships based on honesty and clear communication, keeping our clients always informed."
|
||||||
|
},
|
||||||
|
"sustainability": {
|
||||||
|
"title": "Sustainability",
|
||||||
|
"description": "Commitment to responsible practices and solutions that minimize environmental impacts."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"pretitle": "Contact Us",
|
||||||
|
"title": "Get in Touch",
|
||||||
|
"subtitle": "We are ready to serve your company with high quality engineering solutions",
|
||||||
|
"infoTitle": "Information",
|
||||||
|
"infoSubtitle": "How to find us",
|
||||||
|
"infoDescription": "We are available to serve your company with the technical excellence your project requires.",
|
||||||
|
"phone": "Phone",
|
||||||
|
"phoneDescription": "Service Monday to Friday, 8am to 6pm",
|
||||||
|
"email": "Email",
|
||||||
|
"emailDescription": "We will respond within 24 business hours",
|
||||||
|
"address": "Address",
|
||||||
|
"addressDescription": "Av. Nossa Senhora da Penha, 1234\nSanta Lúcia, Vitória - ES\nZIP: 29056-000",
|
||||||
|
"viewOnMap": "View on map",
|
||||||
|
"sendMessage": "Send a Message",
|
||||||
|
"form": {
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "Your full name",
|
||||||
|
"phone": "Phone",
|
||||||
|
"email": "Email",
|
||||||
|
"emailPlaceholder": "your@email.com",
|
||||||
|
"subject": "Subject",
|
||||||
|
"subjectPlaceholder": "Select a subject",
|
||||||
|
"subjectQuote": "Request Quote",
|
||||||
|
"subjectQuestion": "Technical Question",
|
||||||
|
"subjectPartnership": "Partnership Proposal",
|
||||||
|
"subjectOther": "Other Subject",
|
||||||
|
"message": "Message",
|
||||||
|
"messagePlaceholder": "Describe how we can help you...",
|
||||||
|
"submit": "Send Message",
|
||||||
|
"sending": "Sending..."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cookie": {
|
||||||
|
"text": "We use cookies to improve your experience and analyze site traffic. By continuing to browse, you agree to our",
|
||||||
|
"policy": "Privacy Policy",
|
||||||
|
"accept": "Accept",
|
||||||
|
"decline": "Decline"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "Loading...",
|
||||||
|
"error": "Error",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"previous": "Previous",
|
||||||
|
"readMore": "Read more",
|
||||||
|
"seeMore": "See more",
|
||||||
|
"close": "Close"
|
||||||
|
}
|
||||||
|
}
|
||||||
204
frontend/src/locales/es.json
Normal file
204
frontend/src/locales/es.json
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"home": "Inicio",
|
||||||
|
"services": "Servicios",
|
||||||
|
"projects": "Proyectos",
|
||||||
|
"contact": "Contacto",
|
||||||
|
"about": "Sobre",
|
||||||
|
"search": "Buscar...",
|
||||||
|
"contactUs": "Contáctenos",
|
||||||
|
"theme": "Tema",
|
||||||
|
"language": "Idioma"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"rights": "Todos los derechos reservados.",
|
||||||
|
"quickLinks": "Enlaces Rápidos",
|
||||||
|
"description": "Soluciones en ingeniería mecánica y seguridad para manejo de carga.",
|
||||||
|
"privacyPolicy": "Política de Privacidad",
|
||||||
|
"termsOfUse": "Términos de Uso"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"officialProvider": "Proveedor de Servicio Oficial",
|
||||||
|
"viewSolutions": "Ver Soluciones",
|
||||||
|
"viewAllServices": "Ver todos los servicios",
|
||||||
|
"knowExpertise": "Conozca nuestra experiencia",
|
||||||
|
"portfolio": "Portafolio",
|
||||||
|
"recentProjects": "Proyectos Recientes",
|
||||||
|
"viewAllProjects": "Ver todos los proyectos",
|
||||||
|
"viewDetails": "Ver detalles",
|
||||||
|
"differentials": "Diferenciales",
|
||||||
|
"whyChoose": "¿Por qué elegir Occto?",
|
||||||
|
"testimonials": "Testimonios",
|
||||||
|
"whatClientsSay": "Lo que dicen nuestros clientes"
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"title": "Servicios",
|
||||||
|
"deviceProjects": "Proyectos de Dispositivos",
|
||||||
|
"implementEngineering": "Ingeniería de Implementos",
|
||||||
|
"equipmentInspection": "Inspección de Equipos",
|
||||||
|
"technicalReports": "Informes Técnicos (NR-11/12)",
|
||||||
|
"scope": "Alcance de Actuación",
|
||||||
|
"hero": {
|
||||||
|
"title": "Nuestros Servicios",
|
||||||
|
"subtitle": "Soluciones completas de ingeniería para satisfacer las necesidades de su empresa"
|
||||||
|
},
|
||||||
|
"technical": {
|
||||||
|
"title": "Proyectos Técnicos",
|
||||||
|
"description": "Desarrollo de proyectos de ingeniería mecánica, estructural y vehicular con alta precisión y cumplimiento normativo.",
|
||||||
|
"feature1": "Diseño Mecánico 3D",
|
||||||
|
"feature2": "Cálculo Estructural",
|
||||||
|
"feature3": "Dispositivos Especiales",
|
||||||
|
"feature4": "Aprobación de Equipos"
|
||||||
|
},
|
||||||
|
"vehicular": {
|
||||||
|
"title": "Ingeniería Vehicular",
|
||||||
|
"description": "Experiencia en modificaciones, adaptaciones y homologaciones vehiculares con enfoque en seguridad y cumplimiento.",
|
||||||
|
"feature1": "Proyecto de Instalación",
|
||||||
|
"feature2": "Estudio de Estabilidad",
|
||||||
|
"feature3": "Adaptación de Carrocerías",
|
||||||
|
"feature4": "Regularización Vehicular"
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"title": "Informes y Peritajes",
|
||||||
|
"description": "Emisión de informes técnicos y dictámenes periciales para equipos, estructuras y vehículos.",
|
||||||
|
"feature1": "Informes de Grúas",
|
||||||
|
"feature2": "Inspección de Seguridad",
|
||||||
|
"feature3": "Prueba de Carga",
|
||||||
|
"feature4": "Certificación de Equipos"
|
||||||
|
},
|
||||||
|
"consulting": {
|
||||||
|
"title": "Consultoría Técnica",
|
||||||
|
"description": "Asesoría especializada para adaptación de flotas, planes de Rigging y supervisión de mantenimiento de equipos de carga.",
|
||||||
|
"feature1": "Plan de Rigging",
|
||||||
|
"feature2": "Supervisión de Mantenimiento",
|
||||||
|
"feature3": "Consultoría en Normas",
|
||||||
|
"feature4": "Capacitación Operacional"
|
||||||
|
},
|
||||||
|
"cta": {
|
||||||
|
"title": "¿Necesita un servicio especializado?",
|
||||||
|
"button": "Solicitar Presupuesto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"projects": {
|
||||||
|
"hero": {
|
||||||
|
"title": "Nuestros Proyectos",
|
||||||
|
"subtitle": "Conozca algunos de los proyectos que hemos completado para nuestros clientes"
|
||||||
|
},
|
||||||
|
"viewDetails": "Ver Detalles",
|
||||||
|
"filters": {
|
||||||
|
"all": "Todos",
|
||||||
|
"implements": "Implementos",
|
||||||
|
"mechanical": "Proyectos Mecánicos",
|
||||||
|
"reports": "Informes"
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"vehicular": "Ingeniería Vehicular",
|
||||||
|
"reports": "Informes y Peritajes",
|
||||||
|
"mechanical": "Proyectos Mecánicos",
|
||||||
|
"safety": "Seguridad Laboral"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"item1": {
|
||||||
|
"title": "Adaptación de Flota de Camiones",
|
||||||
|
"description": "Proyecto de adaptación técnica de 50 camiones para instalación de carrocerías especiales y sistemas de seguridad."
|
||||||
|
},
|
||||||
|
"item2": {
|
||||||
|
"title": "Informe Técnico de Grúa Industrial",
|
||||||
|
"description": "Inspección completa y emisión de informe técnico para grúa de 45 toneladas, con pruebas de carga y verificación estructural."
|
||||||
|
},
|
||||||
|
"item3": {
|
||||||
|
"title": "Proyecto de Equipo Portuario",
|
||||||
|
"description": "Desarrollo y cálculo estructural de Spreader para manejo de contenedores en área portuaria."
|
||||||
|
},
|
||||||
|
"item4": {
|
||||||
|
"title": "Adaptación NR-12 de Línea de Producción",
|
||||||
|
"description": "Inventario y adaptación de seguridad de 120 máquinas herramienta según norma regulatoria NR-12."
|
||||||
|
},
|
||||||
|
"item5": {
|
||||||
|
"title": "Homologación de Vehículos Especiales",
|
||||||
|
"description": "Proceso completo de homologación y certificación de plataformas elevadoras para distribución urbana."
|
||||||
|
},
|
||||||
|
"item6": {
|
||||||
|
"title": "Sistema de Protección Contra Caídas",
|
||||||
|
"description": "Proyecto e instalación de sistema de línea de vida para protección contra caídas en operaciones de carga y descarga."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"hero": {
|
||||||
|
"title": "Sobre OCCTO",
|
||||||
|
"subtitle": "Conozca nuestra historia, misión y valores que nos guían en la entrega de excelencia en ingeniería"
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"pretitle": "Nuestra Historia",
|
||||||
|
"title": "Más de 15 años de experiencia en ingeniería",
|
||||||
|
"paragraph1": "OCCTO Ingeniería fue fundada con el objetivo de ofrecer soluciones completas en ingeniería mecánica, vehicular y seguridad laboral. A lo largo de más de 15 años, hemos construido una trayectoria sólida basada en la excelencia técnica y el compromiso con la satisfacción del cliente.",
|
||||||
|
"paragraph2": "Nuestro equipo está formado por ingenieros altamente calificados y especializados, que trabajan con las herramientas y metodologías más modernas para garantizar resultados precisos y confiables en cada proyecto."
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"pretitle": "Nuestros Valores",
|
||||||
|
"title": "Lo que nos mueve",
|
||||||
|
"quality": {
|
||||||
|
"title": "Calidad",
|
||||||
|
"description": "Compromiso con la excelencia en cada proyecto, garantizando precisión y cumplimiento en todas las entregas."
|
||||||
|
},
|
||||||
|
"transparency": {
|
||||||
|
"title": "Transparencia",
|
||||||
|
"description": "Relaciones basadas en la honestidad y comunicación clara, manteniendo a nuestros clientes siempre informados."
|
||||||
|
},
|
||||||
|
"sustainability": {
|
||||||
|
"title": "Sostenibilidad",
|
||||||
|
"description": "Compromiso con prácticas responsables y soluciones que minimizan los impactos ambientales."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"pretitle": "Contáctenos",
|
||||||
|
"title": "Póngase en Contacto",
|
||||||
|
"subtitle": "Estamos listos para atender a su empresa con soluciones de ingeniería de alta calidad",
|
||||||
|
"infoTitle": "Información",
|
||||||
|
"infoSubtitle": "Cómo encontrarnos",
|
||||||
|
"infoDescription": "Estamos disponibles para atender a su empresa con la excelencia técnica que su proyecto requiere.",
|
||||||
|
"phone": "Teléfono",
|
||||||
|
"phoneDescription": "Atención de lunes a viernes, de 8h a 18h",
|
||||||
|
"email": "Correo electrónico",
|
||||||
|
"emailDescription": "Responderemos en hasta 24 horas hábiles",
|
||||||
|
"address": "Dirección",
|
||||||
|
"addressDescription": "Av. Nossa Senhora da Penha, 1234\nSanta Lúcia, Vitória - ES\nCódigo Postal: 29056-000",
|
||||||
|
"viewOnMap": "Ver en el mapa",
|
||||||
|
"sendMessage": "Enviar un Mensaje",
|
||||||
|
"form": {
|
||||||
|
"name": "Nombre",
|
||||||
|
"namePlaceholder": "Su nombre completo",
|
||||||
|
"phone": "Teléfono",
|
||||||
|
"email": "Correo electrónico",
|
||||||
|
"emailPlaceholder": "su@email.com",
|
||||||
|
"subject": "Asunto",
|
||||||
|
"subjectPlaceholder": "Seleccione un asunto",
|
||||||
|
"subjectQuote": "Solicitar Presupuesto",
|
||||||
|
"subjectQuestion": "Pregunta Técnica",
|
||||||
|
"subjectPartnership": "Propuesta de Alianza",
|
||||||
|
"subjectOther": "Otro Asunto",
|
||||||
|
"message": "Mensaje",
|
||||||
|
"messagePlaceholder": "Describa cómo podemos ayudarle...",
|
||||||
|
"submit": "Enviar Mensaje",
|
||||||
|
"sending": "Enviando..."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cookie": {
|
||||||
|
"text": "Utilizamos cookies para mejorar su experiencia y analizar el tráfico del sitio. Al continuar navegando, acepta nuestra",
|
||||||
|
"policy": "Política de Privacidad",
|
||||||
|
"accept": "Aceptar",
|
||||||
|
"decline": "Rechazar"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "Cargando...",
|
||||||
|
"error": "Error",
|
||||||
|
"back": "Volver",
|
||||||
|
"next": "Siguiente",
|
||||||
|
"previous": "Anterior",
|
||||||
|
"readMore": "Leer más",
|
||||||
|
"seeMore": "Ver más",
|
||||||
|
"close": "Cerrar"
|
||||||
|
}
|
||||||
|
}
|
||||||
204
frontend/src/locales/pt.json
Normal file
204
frontend/src/locales/pt.json
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"home": "Início",
|
||||||
|
"services": "Serviços",
|
||||||
|
"projects": "Projetos",
|
||||||
|
"contact": "Contato",
|
||||||
|
"about": "Sobre",
|
||||||
|
"search": "Buscar...",
|
||||||
|
"contactUs": "Fale Conosco",
|
||||||
|
"theme": "Tema",
|
||||||
|
"language": "Idioma"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"rights": "Todos os direitos reservados.",
|
||||||
|
"quickLinks": "Links Rápidos",
|
||||||
|
"description": "Soluções em engenharia mecânica e segurança para movimentação de carga.",
|
||||||
|
"privacyPolicy": "Política de Privacidade",
|
||||||
|
"termsOfUse": "Termos de Uso"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"officialProvider": "Prestador de Serviço Oficial",
|
||||||
|
"viewSolutions": "Ver Soluções",
|
||||||
|
"viewAllServices": "Ver todos os serviços",
|
||||||
|
"knowExpertise": "Conheça nossa expertise",
|
||||||
|
"portfolio": "Portfólio",
|
||||||
|
"recentProjects": "Projetos Recentes",
|
||||||
|
"viewAllProjects": "Ver todos os projetos",
|
||||||
|
"viewDetails": "Ver detalhes",
|
||||||
|
"differentials": "Diferenciais",
|
||||||
|
"whyChoose": "Por que escolher a Occto?",
|
||||||
|
"testimonials": "Depoimentos",
|
||||||
|
"whatClientsSay": "O que nossos clientes dizem"
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"title": "Serviços",
|
||||||
|
"deviceProjects": "Projetos de Dispositivos",
|
||||||
|
"implementEngineering": "Engenharia de Implementos",
|
||||||
|
"equipmentInspection": "Inspeção de Equipamentos",
|
||||||
|
"technicalReports": "Laudos Técnicos (NR-11/12)",
|
||||||
|
"scope": "Escopo de Atuação",
|
||||||
|
"hero": {
|
||||||
|
"title": "Nossos Serviços",
|
||||||
|
"subtitle": "Soluções completas em engenharia para atender às necessidades da sua empresa"
|
||||||
|
},
|
||||||
|
"technical": {
|
||||||
|
"title": "Projetos Técnicos",
|
||||||
|
"description": "Desenvolvimento de projetos de engenharia mecânica, estrutural e veicular com alta precisão e conformidade normativa.",
|
||||||
|
"feature1": "Projeto Mecânico 3D",
|
||||||
|
"feature2": "Cálculo Estrutural",
|
||||||
|
"feature3": "Dispositivos Especiais",
|
||||||
|
"feature4": "Homologação de Equipamentos"
|
||||||
|
},
|
||||||
|
"vehicular": {
|
||||||
|
"title": "Engenharia Veicular",
|
||||||
|
"description": "Expertise em modificações, adaptações e homologações veiculares com foco em segurança e conformidade.",
|
||||||
|
"feature1": "Projeto de Instalação",
|
||||||
|
"feature2": "Estudo de Estabilidade",
|
||||||
|
"feature3": "Adequação de Carrocerias",
|
||||||
|
"feature4": "Regularização Veicular"
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"title": "Laudos e Perícias",
|
||||||
|
"description": "Emissão de laudos técnicos e pareceres periciais para equipamentos, estruturas e veículos.",
|
||||||
|
"feature1": "Laudos de Munck/Guindaste",
|
||||||
|
"feature2": "Inspeção de Segurança",
|
||||||
|
"feature3": "Teste de Carga",
|
||||||
|
"feature4": "Certificação de Equipamentos"
|
||||||
|
},
|
||||||
|
"consulting": {
|
||||||
|
"title": "Consultoria Técnica",
|
||||||
|
"description": "Assessoria especializada para adequação de frotas, planos de Rigging e supervisão de manutenção de equipamentos de carga.",
|
||||||
|
"feature1": "Plano de Rigging",
|
||||||
|
"feature2": "Supervisão de Manutenção",
|
||||||
|
"feature3": "Consultoria em Normas",
|
||||||
|
"feature4": "Treinamento Operacional"
|
||||||
|
},
|
||||||
|
"cta": {
|
||||||
|
"title": "Precisa de um serviço especializado?",
|
||||||
|
"button": "Solicite um Orçamento"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"projects": {
|
||||||
|
"hero": {
|
||||||
|
"title": "Nossos Projetos",
|
||||||
|
"subtitle": "Conheça alguns dos projetos que já realizamos para nossos clientes"
|
||||||
|
},
|
||||||
|
"viewDetails": "Ver Detalhes",
|
||||||
|
"filters": {
|
||||||
|
"all": "Todos",
|
||||||
|
"implements": "Implementos",
|
||||||
|
"mechanical": "Projetos Mecânicos",
|
||||||
|
"reports": "Laudos"
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"vehicular": "Engenharia Veicular",
|
||||||
|
"reports": "Laudos e Perícias",
|
||||||
|
"mechanical": "Projetos Mecânicos",
|
||||||
|
"safety": "Segurança do Trabalho"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"item1": {
|
||||||
|
"title": "Adequação de Frota de Caminhões",
|
||||||
|
"description": "Projeto de adequação técnica de 50 caminhões para instalação de carrocerias especiais e sistemas de segurança."
|
||||||
|
},
|
||||||
|
"item2": {
|
||||||
|
"title": "Laudo Técnico de Guindaste Industrial",
|
||||||
|
"description": "Inspeção completa e emissão de laudo técnico para guindaste de 45 toneladas, com testes de carga e verificação estrutural."
|
||||||
|
},
|
||||||
|
"item3": {
|
||||||
|
"title": "Projeto de Equipamento Portuário",
|
||||||
|
"description": "Desenvolvimento e cálculo estrutural de Spreader para movimentação de contêineres em área portuária."
|
||||||
|
},
|
||||||
|
"item4": {
|
||||||
|
"title": "Adequação NR-12 de Linha de Produção",
|
||||||
|
"description": "Inventário e adequação de segurança de 120 máquinas operatrizes conforme norma regulamentadora NR-12."
|
||||||
|
},
|
||||||
|
"item5": {
|
||||||
|
"title": "Homologação de Veículos Especiais",
|
||||||
|
"description": "Processo completo de homologação e certificação de plataformas elevatórias para distribuição urbana."
|
||||||
|
},
|
||||||
|
"item6": {
|
||||||
|
"title": "Sistema de Proteção Contra Quedas",
|
||||||
|
"description": "Projeto e instalação de sistema de linha de vida para proteção contra quedas em operações de carga e descarga."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"hero": {
|
||||||
|
"title": "Sobre a OCCTO",
|
||||||
|
"subtitle": "Conheça nossa história, missão e valores que nos guiam na entrega de excelência em engenharia"
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"pretitle": "Nossa História",
|
||||||
|
"title": "Mais de 15 anos de experiência em engenharia",
|
||||||
|
"paragraph1": "A OCCTO Engenharia foi fundada com o objetivo de oferecer soluções completas em engenharia mecânica, veicular e segurança do trabalho. Ao longo de mais de 15 anos, construímos uma trajetória sólida baseada na excelência técnica e no compromisso com a satisfação dos nossos clientes.",
|
||||||
|
"paragraph2": "Nossa equipe é formada por engenheiros altamente qualificados e especializados, que trabalham com as mais modernas ferramentas e metodologias para garantir resultados precisos e confiáveis em cada projeto."
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"pretitle": "Nossos Valores",
|
||||||
|
"title": "O que nos move",
|
||||||
|
"quality": {
|
||||||
|
"title": "Qualidade",
|
||||||
|
"description": "Comprometimento com a excelência em cada projeto, garantindo precisão e conformidade em todas as entregas."
|
||||||
|
},
|
||||||
|
"transparency": {
|
||||||
|
"title": "Transparência",
|
||||||
|
"description": "Relações baseadas na honestidade e comunicação clara, mantendo nossos clientes sempre informados."
|
||||||
|
},
|
||||||
|
"sustainability": {
|
||||||
|
"title": "Sustentabilidade",
|
||||||
|
"description": "Compromisso com práticas responsáveis e soluções que minimizam impactos ambientais."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"pretitle": "Fale Conosco",
|
||||||
|
"title": "Entre em Contato",
|
||||||
|
"subtitle": "Estamos prontos para atender sua empresa com soluções de engenharia de alta qualidade",
|
||||||
|
"infoTitle": "Informações",
|
||||||
|
"infoSubtitle": "Como nos encontrar",
|
||||||
|
"infoDescription": "Estamos à disposição para atender sua empresa com a excelência técnica que seu projeto exige.",
|
||||||
|
"phone": "Telefone",
|
||||||
|
"phoneDescription": "Atendimento de segunda a sexta, das 8h às 18h",
|
||||||
|
"email": "E-mail",
|
||||||
|
"emailDescription": "Responderemos em até 24 horas úteis",
|
||||||
|
"address": "Endereço",
|
||||||
|
"addressDescription": "Av. Nossa Senhora da Penha, 1234\nSanta Lúcia, Vitória - ES\nCEP: 29056-000",
|
||||||
|
"viewOnMap": "Ver no mapa",
|
||||||
|
"sendMessage": "Envie uma Mensagem",
|
||||||
|
"form": {
|
||||||
|
"name": "Nome",
|
||||||
|
"namePlaceholder": "Seu nome completo",
|
||||||
|
"phone": "Telefone",
|
||||||
|
"email": "E-mail",
|
||||||
|
"emailPlaceholder": "seu@email.com",
|
||||||
|
"subject": "Assunto",
|
||||||
|
"subjectPlaceholder": "Selecione um assunto",
|
||||||
|
"subjectQuote": "Solicitar Orçamento",
|
||||||
|
"subjectQuestion": "Dúvida Técnica",
|
||||||
|
"subjectPartnership": "Proposta de Parceria",
|
||||||
|
"subjectOther": "Outro Assunto",
|
||||||
|
"message": "Mensagem",
|
||||||
|
"messagePlaceholder": "Descreva como podemos ajudá-lo...",
|
||||||
|
"submit": "Enviar Mensagem",
|
||||||
|
"sending": "Enviando..."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cookie": {
|
||||||
|
"text": "Utilizamos cookies para melhorar sua experiência e analisar o tráfego do site. Ao continuar navegando, você concorda com nossa",
|
||||||
|
"policy": "Política de Privacidade",
|
||||||
|
"accept": "Aceitar",
|
||||||
|
"decline": "Recusar"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "Carregando...",
|
||||||
|
"error": "Erro",
|
||||||
|
"back": "Voltar",
|
||||||
|
"next": "Próximo",
|
||||||
|
"previous": "Anterior",
|
||||||
|
"readMore": "Leia mais",
|
||||||
|
"seeMore": "Ver mais",
|
||||||
|
"close": "Fechar"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -11,7 +15,7 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
@@ -19,7 +23,9 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
@@ -30,5 +36,7 @@
|
|||||||
".next/dev/types/**/*.ts",
|
".next/dev/types/**/*.ts",
|
||||||
"**/*.mts"
|
"**/*.mts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user