feat: CMS com limites de caracteres, traduções auto e painel de notificações

This commit is contained in:
Erik
2025-11-27 12:05:23 -03:00
parent ea0c4ac5a6
commit 6e32ffdc95
40 changed files with 3665 additions and 278 deletions

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

View File

@@ -3,7 +3,7 @@
## 🔧 Melhorias a Implementar
### 🌐 Internacionalização
- [ ] **Tradução incompleta** - Revisar e traduzir todos os componentes que ainda estão em inglês
- [ ] **Tradução incompleta** - Revisar e traduzir todos os componentes que ainda estão em inglês - em andamento
### 🎨 Configurações do Admin
- [ ] **Upload de Logotipo** - Criar formulário para o cliente poder trocar o logotipo do site nas configurações
@@ -22,6 +22,7 @@
- Leads não lidos
- Últimas mensagens recebidas
- Adicionar modo noturno no painel
---
## ✅ Concluídas

91
frontend/middleware.ts Normal file
View 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|.*\\..*).*)',
],
};

View File

@@ -3,7 +3,10 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: 'standalone',
images: {
domains: ['localhost', 'images.unsplash.com'],
remotePatterns: [
{ protocol: 'http', hostname: 'localhost' },
{ protocol: 'https', hostname: 'images.unsplash.com' },
],
},
typescript: {
ignoreBuildErrors: true,

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

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

View File

@@ -63,9 +63,27 @@ model Message {
}
// Modelo de Conteúdo de Página (para textos editáveis)
// Cada página tem uma versão para cada idioma
model PageContent {
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
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])
}

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

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

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

View File

@@ -2,6 +2,7 @@ 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";
export default function PublicLayout({
children,
@@ -9,7 +10,7 @@ export default function PublicLayout({
children: React.ReactNode;
}) {
return (
<>
<LocaleProvider locale="pt">
<Header />
<div className="grow">
{children}
@@ -17,6 +18,6 @@ export default function PublicLayout({
<Footer />
<CookieConsent />
<WhatsAppButton />
</>
</LocaleProvider>
);
}

View File

@@ -2,22 +2,19 @@
import Link from "next/link";
import { usePageContent } from "@/hooks/usePageContent";
import { useTranslatedContent, T } from "@/components/TranslatedText";
export default function Home() {
const { content, loading } = usePageContent('home');
// Português é o idioma padrão - busca diretamente sem tradução
const { content, loading } = usePageContent('home', 'pt');
// Traduzir conteúdo do banco automaticamente
const { translatedContent } = useTranslatedContent(content);
// Usar conteúdo traduzido ou fallback
const hero = translatedContent?.hero || {
// 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 = translatedContent?.features || {
const features = content?.features || {
pretitle: 'Por que nos escolher',
title: 'Nossos Diferenciais',
items: [
@@ -27,7 +24,7 @@ export default function Home() {
] as Array<{ icon: string; title: string; description: string }>
};
const services = translatedContent?.services || {
const services = content?.services || {
pretitle: 'Nossos Serviços',
title: 'O Que Fazemos',
items: [
@@ -38,7 +35,7 @@ export default function Home() {
] as Array<{ icon: string; title: string; description: string }>
};
const about = translatedContent?.about || {
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.',
@@ -49,7 +46,7 @@ export default function Home() {
] as string[]
};
const testimonials = translatedContent?.testimonials || {
const testimonials = content?.testimonials || {
pretitle: 'Depoimentos',
title: 'O Que Dizem Nossos Clientes',
items: [
@@ -59,13 +56,13 @@ export default function Home() {
] as Array<{ name: string; role: string; text: string }>
};
const stats = translatedContent?.stats || {
const stats = content?.stats || {
clients: '500+',
projects: '1200+',
years: '15'
};
const cta = translatedContent?.cta || {
const cta = content?.cta || {
title: 'Pronto para tirar seu projeto do papel?',
text: 'Entre em contato com nossa equipe de especialistas.',
button: 'Fale Conosco'
@@ -83,7 +80,7 @@ export default function Home() {
<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>Prestador de Serviço Oficial</T> <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>
<h1 className="text-5xl md:text-6xl font-bold font-headline mb-6 leading-tight">
@@ -97,7 +94,7 @@ export default function Home() {
{hero.buttonText}
</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">
<T>Ver Soluções</T>
Ver Soluções
</Link>
</div>
</div>
@@ -145,7 +142,7 @@ export default function Home() {
</div>
<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">
<T>Ver todos os serviços</T> <i className="ri-arrow-right-line"></i>
Ver todos os serviços <i className="ri-arrow-right-line"></i>
</Link>
</div>
</div>
@@ -175,7 +172,7 @@ export default function Home() {
))}
</ul>
<Link href="/sobre" className="text-primary font-bold hover:text-white transition-colors flex items-center gap-2">
<T>Conheça nossa expertise</T> <i className="ri-arrow-right-line"></i>
Conheça nossa expertise <i className="ri-arrow-right-line"></i>
</Link>
</div>
</div>
@@ -186,11 +183,11 @@ export default function Home() {
<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>Portfólio</T></h2>
<h3 className="text-4xl font-bold font-headline text-secondary dark:text-white"><T>Projetos Recentes</T></h3>
<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">Projetos Recentes</h3>
</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">
<T>Ver todos os projetos</T>
Ver todos os projetos
</Link>
</div>
@@ -204,11 +201,11 @@ export default function Home() {
<div className="absolute inset-0 bg-cover bg-center transition-transform duration-500 group-hover:scale-110" style={{ backgroundImage: `url('${project.img}')` }}></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"><T>{project.cat}</T></span>
<h3 className="text-2xl font-bold font-headline text-white mb-2"><T>{project.title}</T></h3>
<span className="text-primary font-bold text-sm uppercase tracking-wider mb-2 block">{project.cat}</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>Ver detalhes</T> <i className="ri-arrow-right-line"></i>
Ver detalhes <i className="ri-arrow-right-line"></i>
</span>
</div>
</div>

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

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

View File

@@ -0,0 +1,252 @@
"use client";
import Link from "next/link";
import { usePageContent } from "@/hooks/usePageContent";
import { useLocale } from "@/contexts/LocaleContext";
export default function Home() {
const { locale, t } = useLocale();
// 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}`;
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">
{[
{ img: "https://images.unsplash.com/photo-1616401784845-180882ba9ba8?q=80&w=2070&auto=format&fit=crop", title: "Projeto de Adequação - Coca-Cola", cat: "Engenharia Veicular" },
{ img: "https://images.unsplash.com/photo-1581092335397-9583eb92d232?q=80&w=2070&auto=format&fit=crop", title: "Laudo de Guindaste Articulado", cat: "Inspeção Técnica" },
{ img: "https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?q=80&w=2070&auto=format&fit=crop", title: "Dispositivo de Içamento Especial", cat: "Projeto Mecânico" }
].map((project, index) => (
<div key={index} className="group relative overflow-hidden rounded-xl h-[400px] cursor-pointer">
<div className="absolute inset-0 bg-cover bg-center transition-transform duration-500 group-hover:scale-110" style={{ backgroundImage: `url('${project.img}')` }}></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.cat}</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>
</div>
))}
</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>
);
}

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

View File

@@ -0,0 +1,117 @@
"use client";
import Link from "next/link";
import { useLocale } from "@/contexts/LocaleContext";
export default function ProjetosPage() {
const { t, locale } = useLocale();
const prefix = locale === 'pt' ? '' : `/${locale}`;
// Placeholder data - will be replaced by database content
const projects = [
{
id: 1,
title: t('projects.items.item1.title'),
category: t('projects.categories.vehicular'),
location: "Vitória, ES",
image: "https://images.unsplash.com/photo-1616401784845-180882ba9ba8?q=80&w=2070&auto=format&fit=crop",
description: t('projects.items.item1.description')
},
{
id: 2,
title: t('projects.items.item2.title'),
category: t('projects.categories.reports'),
location: "Serra, ES",
image: "https://images.unsplash.com/photo-1535082623926-b3a33d531740?q=80&w=2052&auto=format&fit=crop",
description: t('projects.items.item2.description')
},
{
id: 3,
title: t('projects.items.item3.title'),
category: t('projects.categories.mechanical'),
location: "Aracruz, ES",
image: "https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?q=80&w=2070&auto=format&fit=crop",
description: t('projects.items.item3.description')
},
{
id: 4,
title: t('projects.items.item4.title'),
category: t('projects.categories.safety'),
location: "Linhares, ES",
image: "https://images.unsplash.com/photo-1581092921461-eab62e97a782?q=80&w=2070&auto=format&fit=crop",
description: t('projects.items.item4.description')
},
{
id: 5,
title: t('projects.items.item5.title'),
category: t('projects.categories.vehicular'),
location: "Viana, ES",
image: "https://images.unsplash.com/photo-1591768793355-74d04bb6608f?q=80&w=2070&auto=format&fit=crop",
description: t('projects.items.item5.description')
},
{
id: 6,
title: t('projects.items.item6.title'),
category: t('projects.categories.safety'),
location: "Cariacica, ES",
image: "https://images.unsplash.com/photo-1504328345606-18bbc8c9d7d1?q=80&w=2070&auto=format&fit=crop",
description: t('projects.items.item6.description')
}
];
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">
{/* Filters */}
<div className="flex flex-wrap gap-4 mb-12 justify-center">
<button className="px-6 py-2 bg-primary text-white rounded-full font-bold shadow-md">{t('projects.filters.all')}</button>
<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.filters.implements')}</button>
<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.filters.mechanical')}</button>
<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.filters.reports')}</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{projects.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">
<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-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}
</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-map-pin-line"></i>
<span>{project.location}</span>
</div>
<p className="text-gray-600 dark:text-gray-400 text-sm mb-6 line-clamp-3 grow">
{project.description}
</p>
<Link href={`${prefix}/projetos/${project.id}`} className="inline-flex items-center gap-2 text-primary font-bold hover:gap-3 transition-all mt-auto">
{t('projects.viewDetails')} <i className="ri-arrow-right-line"></i>
</Link>
</div>
</div>
))}
</div>
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import Link from "next/link";
import { useLocale } from "@/contexts/LocaleContext";
export default function ServicosPage() {
const { t, locale } = useLocale();
const prefix = locale === 'pt' ? '' : `/${locale}`;
const services = [
{
icon: "ri-draft-line",
title: t('services.technical.title'),
description: t('services.technical.description'),
features: [
t('services.technical.feature1'),
t('services.technical.feature2'),
t('services.technical.feature3'),
t('services.technical.feature4')
]
},
{
icon: "ri-truck-line",
title: t('services.vehicular.title'),
description: t('services.vehicular.description'),
features: [
t('services.vehicular.feature1'),
t('services.vehicular.feature2'),
t('services.vehicular.feature3'),
t('services.vehicular.feature4')
]
},
{
icon: "ri-file-paper-2-line",
title: t('services.reports.title'),
description: t('services.reports.description'),
features: [
t('services.reports.feature1'),
t('services.reports.feature2'),
t('services.reports.feature3'),
t('services.reports.feature4')
]
},
{
icon: "ri-tools-fill",
title: t('services.consulting.title'),
description: t('services.consulting.description'),
features: [
t('services.consulting.feature1'),
t('services.consulting.feature2'),
t('services.consulting.feature3'),
t('services.consulting.feature4')
]
}
];
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">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{services.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 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 pb-0 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 mb-8">
{service.description}
</p>
</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>
</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>
);
}

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

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

View File

@@ -1,11 +1,17 @@
"use client";
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { useToast } from '@/contexts/ToastContext';
import { useConfirm } from '@/contexts/ConfirmContext';
type TranslationSummary = {
slug: string;
timestamps: Partial<Record<'pt' | 'en' | 'es', string>>;
pendingLocales: Array<'en' | 'es'>;
};
export default function AdminLayout({
children,
}: {
@@ -13,12 +19,65 @@ export default function AdminLayout({
}) {
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [user, setUser] = useState<{ name: string; email: string; avatar?: string | null } | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [showAvatarModal, setShowAvatarModal] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const pathname = usePathname();
const router = useRouter();
const { success, error } = useToast();
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(() => {
const fetchUser = async () => {
@@ -27,13 +86,68 @@ export default function AdminLayout({
if (response.ok) {
const data = await response.json();
setUser(data.user);
} else {
// Não autenticado - redirecionar para login
router.push('/acesso');
return;
}
} catch (error) {
console.error('Erro ao buscar dados do usuário:', error);
} catch (err) {
console.error('Erro ao buscar dados do usuário:', err);
router.push('/acesso');
return;
} finally {
setIsLoading(false);
}
};
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 () => {
try {
@@ -113,6 +227,8 @@ export default function AdminLayout({
{ icon: 'ri-settings-3-line', label: 'Configurações', href: '/admin/configuracoes' },
];
const pendingCount = translationSummary.filter((page) => page.pendingLocales.length > 0).length;
return (
<div className="min-h-screen bg-gray-50 dark:bg-[#121212] flex">
{/* Sidebar */}
@@ -168,6 +284,68 @@ export default function AdminLayout({
</button>
<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="text-right hidden sm:block">
<p className="text-sm font-bold text-secondary dark:text-white">{user?.name || 'Carregando...'}</p>

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useToast } from '@/contexts/ToastContext';
import { CharLimitBadge } from '@/components/admin/CharLimitBadge';
const AVAILABLE_ICONS = [
// Pessoas e Equipe
@@ -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 {
icon: string;
title: string;
@@ -281,7 +310,11 @@ export default function EditContactPage() {
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) {
showError('Erro ao salvar alterações');
} finally {
@@ -380,31 +413,46 @@ export default function EditContactPage() {
<div className="grid grid-cols-1 gap-6">
<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
type="text"
value={formData.hero.pretitle}
onChange={(e) => setFormData({...formData, hero: {...formData.hero, pretitle: e.target.value}})}
maxLength={CONTACT_TEXT_LIMITS.hero.pretitle}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
</div>
<div>
<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
type="text"
value={formData.hero.title}
onChange={(e) => setFormData({...formData, hero: {...formData.hero, title: e.target.value}})}
maxLength={CONTACT_TEXT_LIMITS.hero.title}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
</div>
<div>
<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
value={formData.hero.subtitle}
onChange={(e) => setFormData({...formData, hero: {...formData.hero, subtitle: e.target.value}})}
rows={2}
maxLength={CONTACT_TEXT_LIMITS.hero.subtitle}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all resize-none"
></textarea>
</div>
@@ -422,29 +470,44 @@ export default function EditContactPage() {
<div className="grid grid-cols-1 gap-6 mb-6">
<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
type="text"
value={formData.info.title}
onChange={(e) => setFormData({...formData, info: {...formData.info, title: e.target.value}})}
maxLength={CONTACT_TEXT_LIMITS.info.title}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
</div>
<div>
<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
type="text"
value={formData.info.subtitle}
onChange={(e) => setFormData({...formData, info: {...formData.info, subtitle: e.target.value}})}
maxLength={CONTACT_TEXT_LIMITS.info.subtitle}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
</div>
<div>
<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
value={formData.info.description}
onChange={(e) => setFormData({...formData, info: {...formData.info, description: e.target.value}})}
rows={2}
maxLength={CONTACT_TEXT_LIMITS.info.description}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all resize-none"
></textarea>
</div>
@@ -467,7 +530,11 @@ export default function EditContactPage() {
}}
/>
<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
type="text"
value={item.title}
@@ -476,11 +543,16 @@ export default function EditContactPage() {
newItems[index].title = e.target.value;
setFormData({...formData, info: {...formData.info, items: newItems}});
}}
maxLength={CONTACT_TEXT_LIMITS.info.itemTitle}
className="w-full px-4 py-3 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
</div>
<div>
<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
value={item.description}
onChange={(e) => {
@@ -489,11 +561,16 @@ export default function EditContactPage() {
setFormData({...formData, info: {...formData.info, items: newItems}});
}}
rows={3}
maxLength={CONTACT_TEXT_LIMITS.info.itemDescription}
className="w-full px-4 py-3 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all resize-none"
></textarea>
</div>
<div>
<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
type="text"
value={item.link}
@@ -503,11 +580,16 @@ export default function EditContactPage() {
setFormData({...formData, info: {...formData.info, items: newItems}});
}}
placeholder="https://..."
maxLength={CONTACT_TEXT_LIMITS.info.link}
className="w-full px-4 py-3 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
</div>
<div>
<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
type="text"
value={item.linkText}
@@ -516,6 +598,7 @@ export default function EditContactPage() {
newItems[index].linkText = e.target.value;
setFormData({...formData, info: {...formData.info, items: newItems}});
}}
maxLength={CONTACT_TEXT_LIMITS.info.linkText}
className="w-full px-4 py-3 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
</div>

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useToast } from '@/contexts/ToastContext';
import { CharLimitBadge } from '@/components/admin/CharLimitBadge';
// Ícones pré-definidos para seleção
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 {
icon: string;
title: string;
@@ -346,7 +401,11 @@ export default function EditHomePage() {
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) {
showError('Erro ao salvar alterações');
} finally {
@@ -500,31 +559,46 @@ export default function EditHomePage() {
<div className="grid grid-cols-1 gap-6">
<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
type="text"
value={formData.hero.title}
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"
/>
</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
value={formData.hero.subtitle}
onChange={(e) => setFormData({...formData, hero: {...formData.hero, subtitle: e.target.value}})}
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"
></textarea>
</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
type="text"
value={formData.hero.buttonText}
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"
/>
</div>
@@ -542,20 +616,30 @@ export default function EditHomePage() {
<div className="grid grid-cols-1 gap-6 mb-6">
<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
type="text"
value={formData.features.pretitle}
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"
/>
</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
type="text"
value={formData.features.title}
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"
/>
</div>
@@ -578,7 +662,11 @@ export default function EditHomePage() {
}}
/>
<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
type="text"
value={item.title}
@@ -587,11 +675,16 @@ export default function EditHomePage() {
newItems[index].title = e.target.value;
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"
/>
</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
value={item.description}
onChange={(e) => {
@@ -600,6 +693,7 @@ export default function EditHomePage() {
setFormData({...formData, features: {...formData.features, items: newItems}});
}}
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"
></textarea>
</div>
@@ -620,20 +714,30 @@ export default function EditHomePage() {
<div className="grid grid-cols-1 gap-6 mb-6">
<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
type="text"
value={formData.services.pretitle}
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"
/>
</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
type="text"
value={formData.services.title}
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"
/>
</div>
@@ -656,7 +760,11 @@ export default function EditHomePage() {
}}
/>
<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
type="text"
value={item.title}
@@ -665,11 +773,16 @@ export default function EditHomePage() {
newItems[index].title = e.target.value;
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"
/>
</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
value={item.description}
onChange={(e) => {
@@ -678,6 +791,7 @@ export default function EditHomePage() {
setFormData({...formData, services: {...formData.services, items: newItems}});
}}
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"
></textarea>
</div>
@@ -699,36 +813,56 @@ export default function EditHomePage() {
<div className="grid grid-cols-1 gap-6">
<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
type="text"
value={formData.about.pretitle}
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"
/>
</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
type="text"
value={formData.about.title}
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"
/>
</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
value={formData.about.description}
onChange={(e) => setFormData({...formData, about: {...formData.about, description: e.target.value}})}
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"
></textarea>
</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) => (
<div key={index} className="mb-3">
<LabelWithLimit
label={`Destaque ${index + 1}`}
value={highlight}
limit={HOME_TEXT_LIMITS.about.highlight}
/>
<input
type="text"
value={highlight}
@@ -738,6 +872,7 @@ export default function EditHomePage() {
setFormData({...formData, about: {...formData.about, highlights: newHighlights}});
}}
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"
/>
</div>
@@ -758,20 +893,30 @@ export default function EditHomePage() {
<div className="grid grid-cols-1 gap-6 mb-6">
<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
type="text"
value={formData.testimonials.pretitle}
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"
/>
</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
type="text"
value={formData.testimonials.title}
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"
/>
</div>
@@ -785,7 +930,11 @@ export default function EditHomePage() {
</div>
<div className="grid grid-cols-1 gap-4">
<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
type="text"
value={item.name}
@@ -794,11 +943,16 @@ export default function EditHomePage() {
newItems[index].name = e.target.value;
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"
/>
</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
type="text"
value={item.role}
@@ -807,11 +961,16 @@ export default function EditHomePage() {
newItems[index].role = e.target.value;
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"
/>
</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
value={item.text}
onChange={(e) => {
@@ -820,6 +979,7 @@ export default function EditHomePage() {
setFormData({...formData, testimonials: {...formData.testimonials, items: newItems}});
}}
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"
></textarea>
</div>
@@ -841,31 +1001,46 @@ export default function EditHomePage() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<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
type="text"
value={formData.stats.clients}
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"
/>
</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
type="text"
value={formData.stats.projects}
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"
/>
</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
type="text"
value={formData.stats.years}
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"
/>
</div>
@@ -884,31 +1059,46 @@ export default function EditHomePage() {
<div className="grid grid-cols-1 gap-6">
<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
type="text"
value={formData.cta.title}
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"
/>
</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
value={formData.cta.text}
onChange={(e) => setFormData({...formData, cta: {...formData.cta, text: e.target.value}})}
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"
></textarea>
</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
type="text"
value={formData.cta.button}
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"
/>
</div>
@@ -1118,3 +1308,4 @@ export default function EditHomePage() {
</>
);
}

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useToast } from '@/contexts/ToastContext';
import { CharLimitBadge } from '@/components/admin/CharLimitBadge';
// Ícones pré-definidos para seleção
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 {
icon: string;
title: string;
@@ -273,7 +295,11 @@ export default function EditAboutPage() {
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) {
showError('Erro ao salvar alterações');
} finally {
@@ -383,21 +409,31 @@ export default function EditAboutPage() {
<div className="grid grid-cols-1 gap-6">
<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
type="text"
value={formData.hero.title}
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"
/>
</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
value={formData.hero.subtitle}
onChange={(e) => setFormData({...formData, hero: {...formData.hero, subtitle: e.target.value}})}
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"
></textarea>
</div>
@@ -415,41 +451,61 @@ export default function EditAboutPage() {
<div className="grid grid-cols-1 gap-6">
<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
type="text"
value={formData.history.title}
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"
/>
</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
type="text"
value={formData.history.subtitle}
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"
/>
</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
value={formData.history.paragraph1}
onChange={(e) => setFormData({...formData, history: {...formData.history, paragraph1: e.target.value}})}
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"
></textarea>
</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
value={formData.history.paragraph2}
onChange={(e) => setFormData({...formData, history: {...formData.history, paragraph2: e.target.value}})}
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"
></textarea>
</div>
@@ -467,20 +523,30 @@ export default function EditAboutPage() {
<div className="grid grid-cols-1 gap-6 mb-6">
<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
type="text"
value={formData.values.title}
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"
/>
</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
type="text"
value={formData.values.subtitle}
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"
/>
</div>
@@ -503,7 +569,11 @@ export default function EditAboutPage() {
}}
/>
<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
type="text"
value={item.title}
@@ -512,11 +582,16 @@ export default function EditAboutPage() {
newItems[index].title = e.target.value;
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"
/>
</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
value={item.description}
onChange={(e) => {
@@ -525,6 +600,7 @@ export default function EditAboutPage() {
setFormData({...formData, values: {...formData.values, items: newItems}});
}}
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"
></textarea>
</div>

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

View File

@@ -3,8 +3,9 @@ import prisma from '@/lib/prisma';
export async function GET() {
try {
// Config é global, sempre usa 'pt' como locale base
const config = await prisma.pageContent.findUnique({
where: { slug: 'config' }
where: { slug_locale: { slug: 'config', locale: 'pt' } }
});
if (!config) {
@@ -23,12 +24,13 @@ export async function PUT(request: NextRequest) {
const { primaryColor } = await request.json();
const config = await prisma.pageContent.upsert({
where: { slug: 'config' },
where: { slug_locale: { slug: 'config', locale: 'pt' } },
update: {
content: { primaryColor }
},
create: {
slug: 'config',
locale: 'pt',
content: { primaryColor }
}
});

View File

@@ -1,8 +1,13 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { Prisma } from '@prisma/client';
import { cookies } from 'next/headers';
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
async function authenticate() {
const cookieStore = await cookies();
@@ -24,23 +29,149 @@ async function authenticate() {
}
}
// Tradução com cache
async function translateText(text: string, targetLang: string): Promise<string> {
if (!text || text.trim() === '' || targetLang === 'pt') return text;
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);
}
try {
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;
try {
await prisma.translation.create({
data: {
sourceText: text,
sourceLang: 'pt',
targetLang,
translatedText
}
});
} catch (cacheError) {
console.warn('[i18n] Falha ao salvar cache de tradução:', cacheError);
}
return translatedText;
}
} catch (error) {
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)
// Suporta ?locale=en para buscar versão traduzida
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
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({
where: { slug }
where: {
slug_locale: { slug, locale }
}
});
if (!page) {
return NextResponse.json({ error: 'Página não encontrada' }, { status: 404 });
if (page) {
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) {
console.error('Erro ao buscar página:', error);
return NextResponse.json({ error: 'Erro ao buscar página' }, { status: 500 });
@@ -48,6 +179,7 @@ export async function GET(
}
// PUT /api/pages/[slug] - Atualizar página (admin apenas)
// Quando salva em PT, automaticamente traduz e salva EN e ES
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
@@ -66,13 +198,23 @@ export async function PUT(
return NextResponse.json({ error: 'Conteúdo é obrigatório' }, { status: 400 });
}
const page = await prisma.pageContent.upsert({
where: { slug },
// 1. Salvar versão em português (principal)
const ptPage = await prisma.pageContent.upsert({
where: { slug_locale: { slug, locale: 'pt' } },
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) {
console.error('Erro ao atualizar página:', error);
return NextResponse.json({ error: 'Erro ao atualizar página' }, { status: 500 });
@@ -80,6 +222,7 @@ export async function PUT(
}
// DELETE /api/pages/[slug] - Deletar página (admin apenas)
// Remove todas as versões (PT, EN, ES)
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
@@ -92,11 +235,12 @@ export async function DELETE(
const { slug } = await params;
await prisma.pageContent.delete({
// Deletar todas as versões de idioma
await prisma.pageContent.deleteMany({
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) {
console.error('Erro ao deletar página:', error);
return NextResponse.json({ error: 'Erro ao deletar página' }, { status: 500 });

View File

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

View File

@@ -31,22 +31,33 @@ export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const slug = searchParams.get('slug');
const locale = searchParams.get('locale') || 'pt';
if (slug) {
// Buscar página específica
// Buscar página específica com locale
const page = await prisma.pageContent.findUnique({
where: { slug }
where: { slug_locale: { slug, locale } }
});
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(page);
}
// Listar todas as páginas
// Listar todas as páginas (só PT para admin)
const pages = await prisma.pageContent.findMany({
where: { locale: 'pt' },
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 });
}
// Upsert: criar ou atualizar se já existir
// Upsert: criar ou atualizar se já existir (versão PT)
const page = await prisma.pageContent.upsert({
where: { slug },
where: { slug_locale: { slug, locale: 'pt' } },
update: { content },
create: { slug, content }
create: { slug, locale: 'pt', content }
});
return NextResponse.json({ success: true, page });

View File

@@ -1,10 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
const LIBRETRANSLATE_URL = process.env.LIBRETRANSLATE_URL || 'https://libretranslate.stackbyte.cloud';
// Cache simples em memória para traduções
const translationCache = new Map<string, { text: string; timestamp: number }>();
const CACHE_TTL = 1000 * 60 * 60 * 24; // 24 horas
// 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 {
@@ -19,15 +19,33 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ translatedText: text });
}
// Verificar cache
const cacheKey = `${source}:${target}:${text}`;
const cached = translationCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return NextResponse.json({ translatedText: cached.text, cached: true });
// 1. Verificar cache em memória (mais rápido)
if (memoryCache.has(cacheKey)) {
return NextResponse.json({ translatedText: memoryCache.get(cacheKey), cached: 'memory' });
}
// Chamar LibreTranslate
// 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: {
@@ -49,10 +67,26 @@ export async function POST(request: NextRequest) {
const data = await response.json();
const translatedText = data.translatedText || text;
// Salvar no cache
translationCache.set(cacheKey, { text: translatedText, timestamp: Date.now() });
// 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)');
}
return NextResponse.json({ translatedText });
// 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 });
@@ -72,39 +106,96 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({ translations: texts });
}
const translations = await Promise.all(
texts.map(async (text: string) => {
if (!text) return text;
const results: string[] = [];
const toTranslate: { index: number; text: string }[] = [];
const cacheKey = `${source}:${target}:${text}`;
const cached = translationCache.get(cacheKey);
// Verificar quais já existem no banco
for (let i = 0; i < texts.length; i++) {
const text = texts[i];
if (!text) {
results[i] = text || '';
continue;
}
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.text;
}
const cacheKey = `${source}:${target}:${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' }),
});
// Verificar memória
if (memoryCache.has(cacheKey)) {
results[i] = memoryCache.get(cacheKey)!;
continue;
}
if (response.ok) {
const data = await response.json();
const translatedText = data.translatedText || text;
translationCache.set(cacheKey, { text: translatedText, timestamp: Date.now() });
return translatedText;
// 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;
}
} catch (e) {
console.error('Translation error for:', text, e);
}
})
);
}
return text; // Fallback
})
);
return NextResponse.json({ translations });
return NextResponse.json({ translations: results });
} catch (error) {
console.error('Batch translation error:', error);
return NextResponse.json({ error: 'Erro ao traduzir' }, { status: 500 });

View File

@@ -1,11 +1,13 @@
"use client";
import Link from 'next/link';
import { useLanguage } from '@/contexts/LanguageContext';
import { T } from '@/components/TranslatedText';
import { useLocale } from '@/contexts/LocaleContext';
export default function Footer() {
const { t } = useLanguage();
const { locale, t } = useLocale();
// Prefixo para links
const prefix = locale === 'pt' ? '' : `/${locale}`;
return (
<footer className="bg-secondary text-white pt-16 pb-8">
@@ -21,12 +23,12 @@ export default function Footer() {
</div>
</div>
<p className="text-gray-400 mb-6">
<T>Soluções em engenharia mecânica e segurança para movimentação de carga.</T>
{t('footer.description')}
</p>
<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>
<span className="text-xs font-bold text-gray-300 uppercase tracking-wide"><T>Prestador Oficial</T> <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 className="flex gap-4">
@@ -44,30 +46,30 @@ export default function Footer() {
{/* Links */}
<div>
<h3 className="text-lg font-bold font-headline mb-6"><T>Links Rápidos</T></h3>
<h3 className="text-lg font-bold font-headline mb-6">{t('footer.quickLinks')}</h3>
<ul className="space-y-4">
<li><Link href="/" className="text-gray-400 hover:text-primary transition-colors"><T>{t('nav.home')}</T></Link></li>
<li><Link href="/sobre" className="text-gray-400 hover:text-primary transition-colors"><T>{t('nav.about')}</T></Link></li>
<li><Link href="/servicos" className="text-gray-400 hover:text-primary transition-colors"><T>{t('nav.services')}</T></Link></li>
<li><Link href="/projetos" className="text-gray-400 hover:text-primary transition-colors"><T>{t('nav.projects')}</T></Link></li>
<li><Link href="/contato" className="text-gray-400 hover:text-primary transition-colors"><T>{t('nav.contact')}</T></Link></li>
<li><Link href={`${prefix}/`} className="text-gray-400 hover:text-primary transition-colors">{t('nav.home')}</Link></li>
<li><Link href={`${prefix}/sobre`} className="text-gray-400 hover:text-primary transition-colors">{t('nav.about')}</Link></li>
<li><Link href={`${prefix}/servicos`} className="text-gray-400 hover:text-primary transition-colors">{t('nav.services')}</Link></li>
<li><Link href={`${prefix}/projetos`} className="text-gray-400 hover:text-primary transition-colors">{t('nav.projects')}</Link></li>
<li><Link href={`${prefix}/contato`} className="text-gray-400 hover:text-primary transition-colors">{t('nav.contact')}</Link></li>
</ul>
</div>
{/* Services */}
<div>
<h3 className="text-lg font-bold font-headline mb-6"><T>{t('services.title')}</T></h3>
<h3 className="text-lg font-bold font-headline mb-6">{t('nav.services')}</h3>
<ul className="space-y-4">
<li className="text-gray-400"><T>Projetos de Dispositivos</T></li>
<li className="text-gray-400"><T>Engenharia de Implementos</T></li>
<li className="text-gray-400"><T>Inspeção de Equipamentos</T></li>
<li className="text-gray-400"><T>Laudos Técnicos (NR-11/12)</T></li>
<li className="text-gray-400">{t('services.deviceProjects')}</li>
<li className="text-gray-400">{t('services.implementEngineering')}</li>
<li className="text-gray-400">{t('services.equipmentInspection')}</li>
<li className="text-gray-400">{t('services.technicalReports')}</li>
</ul>
</div>
{/* Contact */}
<div>
<h3 className="text-lg font-bold font-headline mb-6"><T>{t('nav.contact')}</T></h3>
<h3 className="text-lg font-bold font-headline mb-6">{t('nav.contact')}</h3>
<ul className="space-y-4">
<li className="flex items-start gap-3 text-gray-400">
<i className="ri-map-pin-line mt-1 text-primary"></i>
@@ -87,11 +89,11 @@ export default function Footer() {
<div className="border-t border-white/10 pt-8 flex flex-col md:flex-row justify-between items-center gap-4">
<p className="text-gray-500 text-sm">
© {new Date().getFullYear()} OCCTO Engenharia. <T>{t('footer.rights')}</T>
© {new Date().getFullYear()} OCCTO Engenharia. {t('footer.rights')}
</p>
<div className="flex gap-6 text-sm text-gray-500">
<Link href="/privacidade" className="hover:text-white"><T>Política de Privacidade</T></Link>
<Link href="/termos" className="hover:text-white"><T>Termos de Uso</T></Link>
<Link href={`${prefix}/privacidade`} className="hover:text-white">{t('footer.privacyPolicy')}</Link>
<Link href={`${prefix}/termos`} className="hover:text-white">{t('footer.termsOfUse')}</Link>
</div>
</div>
</div>

View File

@@ -3,16 +3,19 @@
import Link from 'next/link';
import { useState, useEffect } from 'react';
import { useTheme } from "next-themes";
import { useLanguage } from '@/contexts/LanguageContext';
import { T } from '@/components/TranslatedText';
import { useLocale } from '@/contexts/LocaleContext';
import { localeFlags, localeNames, type Locale } from '@/lib/i18n';
export default function Header() {
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const { theme, setTheme } = useTheme();
const { language, setLanguage, t } = useLanguage();
const { locale, setLocale, t } = useLocale();
const [mounted, setMounted] = useState(false);
// Prefixo para links baseado no locale
const prefix = locale === 'pt' ? '' : `/${locale}`;
useEffect(() => {
setMounted(true);
}, []);
@@ -33,17 +36,10 @@ export default function Header() {
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 (
<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">
<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>
<div className="flex items-center gap-2">
<span className="text-3xl font-bold text-secondary dark:text-white font-headline leading-none">OCCTO</span>
@@ -67,35 +63,35 @@ export default function Header() {
</div>
<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>
<span className="hidden lg:inline"><T>{t('nav.home')}</T></span>
<span className="hidden lg:inline">{t('nav.home')}</span>
</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>
<span className="hidden lg:inline"><T>{t('nav.services')}</T></span>
<span className="hidden lg:inline">{t('nav.services')}</span>
</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>
<span className="hidden lg:inline"><T>{t('nav.projects')}</T></span>
<span className="hidden lg:inline">{t('nav.projects')}</span>
</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>
<span className="hidden lg:inline"><T>{t('nav.contact')}</T></span>
<span className="hidden lg:inline">{t('nav.contact')}</span>
</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>
<span className="hidden lg:inline"><T>{t('nav.about')}</T></span>
<span className="hidden lg:inline">{t('nav.about')}</span>
</Link>
</nav>
<div className="shrink-0 ml-2">
<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"
>
<i className="ri-whatsapp-line"></i>
<span className="hidden xl:inline"><T>{t('nav.contact_us')}</T></span>
<span className="hidden xl:inline">{t('nav.contactUs')}</span>
</Link>
</div>
@@ -119,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"
aria-label="Alterar idioma"
>
<span>{language === 'PT' ? '🇧🇷' : language === 'EN' ? '🇺🇸' : '🇪🇸'}</span>
<span>{language}</span>
<span>{localeFlags[locale]}</span>
<span>{locale.toUpperCase()}</span>
<i className="ri-arrow-down-s-line text-xs opacity-50"></i>
</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="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">
<span className="text-lg">🇧🇷</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">Português</span>
<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">{localeFlags.pt}</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">{localeNames.pt}</span>
</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">
<span className="text-lg">🇺🇸</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">English</span>
<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">{localeFlags.en}</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">{localeNames.en}</span>
</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">
<span className="text-lg">🇪🇸</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">Español</span>
<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">{localeFlags.es}</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">{localeNames.es}</span>
</button>
</div>
</div>
@@ -167,43 +163,43 @@ export default function Header() {
</div>
<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>
<T>{t('nav.home')}</T>
{t('nav.home')}
</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>
<T>{t('nav.services')}</T>
{t('nav.services')}
</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>
<T>{t('nav.projects')}</T>
{t('nav.projects')}
</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>
<T>{t('nav.contact')}</T>
{t('nav.contact')}
</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>
<T>{t('nav.about')}</T>
{t('nav.about')}
</Link>
</nav>
<div className="mt-6 flex flex-col gap-4 pb-8 shrink-0">
<Link
href="/contato"
href={`${prefix}/contato`}
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"
>
<i className="ri-whatsapp-line text-xl"></i>
<T>{t('nav.contact_us')}</T>
{t('nav.contactUs')}
</Link>
<div
className="flex items-center justify-between p-4 bg-gray-50 dark:bg-white/5 rounded-xl cursor-pointer hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
onClick={toggleTheme}
>
<span className="text-sm font-bold text-gray-500 dark:text-gray-400"><T>{t('nav.theme')}</T></span>
<span className="text-sm font-bold text-gray-500 dark:text-gray-400">{t('nav.theme')}</span>
<button
className="w-10 h-10 rounded-full bg-white dark:bg-white/10 flex items-center justify-center text-gray-600 dark:text-yellow-400 shadow-sm transition-colors"
>
@@ -216,11 +212,11 @@ export default function Header() {
</div>
<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>{t('nav.language')}</T></span>
<span className="text-sm font-bold text-gray-500 dark:text-gray-400">{t('nav.language')}</span>
<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={() => 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={() => 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('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={() => 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={() => 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>

View File

@@ -1,17 +1,18 @@
'use client';
import { useEffect, useState, ReactNode } from 'react';
import { useLanguage, Language } from '@/contexts/LanguageContext';
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
// 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)!;
}
@@ -30,14 +31,78 @@ async function translateText(text: string, targetLang: string): Promise<string>
return translated;
}
} catch (error) {
console.error('Translation error:', 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: string;
children: ReactNode;
as?: 'span' | 'p' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'div' | 'li' | 'label';
className?: string;
}
@@ -47,28 +112,59 @@ interface AutoTranslateProps {
* Uso: <T>Texto em português</T>
*/
export function T({ children, as = 'span', className }: AutoTranslateProps) {
const { language } = useLanguage();
const [translatedText, setTranslatedText] = useState(children);
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(() => {
if (language === 'PT') {
setTranslatedText(children);
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;
}
let cancelled = false;
const targetLang = language.toLowerCase();
// Evitar tradução duplicada
if (
lastTranslatedRef.current?.text === originalText &&
lastTranslatedRef.current?.lang === locale
) {
console.log('[T] Pulando - já traduzido');
return;
}
translateText(children, targetLang).then((result) => {
// 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;
};
}, [children, language]);
}, [originalText, locale]);
const Tag = as;
return <Tag className={className}>{translatedText}</Tag>;
@@ -81,15 +177,15 @@ export const AutoTranslate = T;
* Hook para traduzir texto programaticamente
*/
export function useTranslate() {
const { language } = useLanguage();
const { locale } = useLocale();
const [isTranslating, setIsTranslating] = useState(false);
const translate = async (text: string): Promise<string> => {
if (!text || language === 'PT') return text;
if (!text || locale === 'pt') return text;
setIsTranslating(true);
try {
const result = await translateText(text, language.toLowerCase());
const result = await translateText(text, locale);
return result;
} finally {
setIsTranslating(false);
@@ -97,12 +193,12 @@ export function useTranslate() {
};
const translateBatch = async (texts: string[]): Promise<string[]> => {
if (language === 'PT') return texts;
if (locale === 'pt') return texts;
setIsTranslating(true);
try {
const results = await Promise.all(
texts.map(text => translateText(text, language.toLowerCase()))
texts.map(text => translateText(text, locale))
);
return results;
} finally {
@@ -110,7 +206,7 @@ export function useTranslate() {
}
};
return { translate, translateBatch, isTranslating, language };
return { translate, translateBatch, isTranslating, locale };
}
/**
@@ -120,18 +216,18 @@ export function useTranslatedContent<T extends Record<string, unknown>>(content:
translatedContent: T | null;
isTranslating: boolean;
} {
const { language } = useLanguage();
const { locale } = useLocale();
const [translatedContent, setTranslatedContent] = useState<T | null>(content);
const [isTranslating, setIsTranslating] = useState(false);
useEffect(() => {
if (!content || language === 'PT') {
if (!content || locale === 'pt') {
setTranslatedContent(content);
return;
}
let cancelled = false;
const targetLang = language.toLowerCase();
const targetLang = locale;
const translateContent = async () => {
setIsTranslating(true);
@@ -199,7 +295,7 @@ export function useTranslatedContent<T extends Record<string, unknown>>(content:
return () => {
cancelled = true;
};
}, [content, language]);
}, [content, locale]);
return { translatedContent, isTranslating };
}

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

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

View File

@@ -3,23 +3,29 @@ import { useState, useEffect } from 'react';
interface PageContentData {
id: string;
slug: string;
locale: string;
content: any;
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 [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isFallback, setIsFallback] = useState(false);
useEffect(() => {
const fetchContent = async () => {
setLoading(true);
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) {
const data: PageContentData = await response.json();
setContent(data.content);
setIsFallback(data.fallback || false);
} else if (response.status === 404) {
// Página ainda não foi configurada no admin
setContent(null);
@@ -34,7 +40,7 @@ export function usePageContent(slug: string) {
};
fetchContent();
}, [slug]);
}, [slug, locale]);
return { content, loading, error };
return { content, loading, error, isFallback };
}

44
frontend/src/lib/i18n.ts Normal file
View 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;
}

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

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

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