From 6e32ffdc95ae9121bd15bcc02a8a425d7752105e Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 27 Nov 2025 12:05:23 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20CMS=20com=20limites=20de=20caracteres,?= =?UTF-8?q?=20tradu=C3=A7=C3=B5es=20auto=20e=20painel=20de=20notifica?= =?UTF-8?q?=C3=A7=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/diario-de-bordo/resumo-25-27.md | 49 +++ docs/tasks.md | 3 +- frontend/middleware.ts | 91 +++++ frontend/next.config.ts | 5 +- frontend/prisma/check-translations.mjs | 23 ++ frontend/prisma/migrate-locale.mjs | 49 +++ frontend/prisma/schema.prisma | 20 +- frontend/prisma/translate-pages.mjs | 147 ++++++++ frontend/scripts/checkTranslations.cjs | 44 +++ frontend/scripts/translateSlug.cjs | 126 +++++++ frontend/src/app/(public)/layout.tsx | 5 +- frontend/src/app/(public)/page.tsx | 43 ++- frontend/src/app/[locale]/contato/page.tsx | 317 ++++++++++++++++++ frontend/src/app/[locale]/layout.tsx | 35 ++ frontend/src/app/[locale]/page.tsx | 252 ++++++++++++++ .../src/app/[locale]/privacidade/page.tsx | 61 ++++ frontend/src/app/[locale]/projetos/page.tsx | 117 +++++++ frontend/src/app/[locale]/servicos/page.tsx | 126 +++++++ frontend/src/app/[locale]/sobre/page.tsx | 82 +++++ frontend/src/app/[locale]/termos/page.tsx | 55 +++ frontend/src/app/admin/layout.tsx | 186 +++++++++- .../src/app/admin/paginas/contato/page.tsx | 105 +++++- frontend/src/app/admin/paginas/home/page.tsx | 247 ++++++++++++-- frontend/src/app/admin/paginas/sobre/page.tsx | 98 +++++- .../app/api/admin/translate-pages/route.ts | 193 +++++++++++ frontend/src/app/api/config/route.ts | 6 +- frontend/src/app/api/pages/[slug]/route.ts | 164 ++++++++- frontend/src/app/api/pages/contact/route.ts | 41 --- frontend/src/app/api/pages/route.ts | 23 +- frontend/src/app/api/translate/route.ts | 169 +++++++--- frontend/src/components/Footer.tsx | 42 +-- frontend/src/components/Header.tsx | 98 +++--- frontend/src/components/TranslatedText.tsx | 142 ++++++-- .../src/components/admin/CharLimitBadge.tsx | 24 ++ frontend/src/contexts/LocaleContext.tsx | 85 +++++ frontend/src/hooks/usePageContent.ts | 14 +- frontend/src/lib/i18n.ts | 44 +++ frontend/src/locales/en.json | 204 +++++++++++ frontend/src/locales/es.json | 204 +++++++++++ frontend/src/locales/pt.json | 204 +++++++++++ 40 files changed, 3665 insertions(+), 278 deletions(-) create mode 100644 docs/diario-de-bordo/resumo-25-27.md create mode 100644 frontend/middleware.ts create mode 100644 frontend/prisma/check-translations.mjs create mode 100644 frontend/prisma/migrate-locale.mjs create mode 100644 frontend/prisma/translate-pages.mjs create mode 100644 frontend/scripts/checkTranslations.cjs create mode 100644 frontend/scripts/translateSlug.cjs create mode 100644 frontend/src/app/[locale]/contato/page.tsx create mode 100644 frontend/src/app/[locale]/layout.tsx create mode 100644 frontend/src/app/[locale]/page.tsx create mode 100644 frontend/src/app/[locale]/privacidade/page.tsx create mode 100644 frontend/src/app/[locale]/projetos/page.tsx create mode 100644 frontend/src/app/[locale]/servicos/page.tsx create mode 100644 frontend/src/app/[locale]/sobre/page.tsx create mode 100644 frontend/src/app/[locale]/termos/page.tsx create mode 100644 frontend/src/app/api/admin/translate-pages/route.ts delete mode 100644 frontend/src/app/api/pages/contact/route.ts create mode 100644 frontend/src/components/admin/CharLimitBadge.tsx create mode 100644 frontend/src/contexts/LocaleContext.tsx create mode 100644 frontend/src/lib/i18n.ts create mode 100644 frontend/src/locales/en.json create mode 100644 frontend/src/locales/es.json create mode 100644 frontend/src/locales/pt.json diff --git a/docs/diario-de-bordo/resumo-25-27.md b/docs/diario-de-bordo/resumo-25-27.md new file mode 100644 index 0000000..40927e1 --- /dev/null +++ b/docs/diario-de-bordo/resumo-25-27.md @@ -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` + `` 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. diff --git a/docs/tasks.md b/docs/tasks.md index 1953e48..46a77fc 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -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 diff --git a/frontend/middleware.ts b/frontend/middleware.ts new file mode 100644 index 0000000..209dad9 --- /dev/null +++ b/frontend/middleware.ts @@ -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|.*\\..*).*)', + ], +}; diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 034a997..f4b5e4d 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -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, diff --git a/frontend/prisma/check-translations.mjs b/frontend/prisma/check-translations.mjs new file mode 100644 index 0000000..0830563 --- /dev/null +++ b/frontend/prisma/check-translations.mjs @@ -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) diff --git a/frontend/prisma/migrate-locale.mjs b/frontend/prisma/migrate-locale.mjs new file mode 100644 index 0000000..86318fe --- /dev/null +++ b/frontend/prisma/migrate-locale.mjs @@ -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(); + }); diff --git a/frontend/prisma/schema.prisma b/frontend/prisma/schema.prisma index fcf0269..45a5d3d 100644 --- a/frontend/prisma/schema.prisma +++ b/frontend/prisma/schema.prisma @@ -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]) } diff --git a/frontend/prisma/translate-pages.mjs b/frontend/prisma/translate-pages.mjs new file mode 100644 index 0000000..e5e2c52 --- /dev/null +++ b/frontend/prisma/translate-pages.mjs @@ -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(); + }); diff --git a/frontend/scripts/checkTranslations.cjs b/frontend/scripts/checkTranslations.cjs new file mode 100644 index 0000000..005a950 --- /dev/null +++ b/frontend/scripts/checkTranslations.cjs @@ -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; +}); diff --git a/frontend/scripts/translateSlug.cjs b/frontend/scripts/translateSlug.cjs new file mode 100644 index 0000000..d3d4b33 --- /dev/null +++ b/frontend/scripts/translateSlug.cjs @@ -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 '); + 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(); + }); diff --git a/frontend/src/app/(public)/layout.tsx b/frontend/src/app/(public)/layout.tsx index 4a56be6..65f1f47 100644 --- a/frontend/src/app/(public)/layout.tsx +++ b/frontend/src/app/(public)/layout.tsx @@ -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 ( - <> +
{children} @@ -17,6 +18,6 @@ export default function PublicLayout({
- + ); } diff --git a/frontend/src/app/(public)/page.tsx b/frontend/src/app/(public)/page.tsx index dfcf423..5fe594b 100644 --- a/frontend/src/app/(public)/page.tsx +++ b/frontend/src/app/(public)/page.tsx @@ -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'); - - // Traduzir conteúdo do banco automaticamente - const { translatedContent } = useTranslatedContent(content); + // Português é o idioma padrão - busca diretamente sem tradução + const { content, loading } = usePageContent('home', 'pt'); - // 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() {
- Prestador de Serviço Oficial Coca-Cola + Prestador de Serviço Oficial Coca-Cola

@@ -97,7 +94,7 @@ export default function Home() { {hero.buttonText} - Ver Soluções + Ver Soluções

@@ -145,7 +142,7 @@ export default function Home() {
- Ver todos os serviços + Ver todos os serviços
@@ -175,7 +172,7 @@ export default function Home() { ))} - Conheça nossa expertise + Conheça nossa expertise @@ -186,11 +183,11 @@ export default function Home() {
-

Portfólio

-

Projetos Recentes

+

Portfólio

+

Projetos Recentes

- Ver todos os projetos + Ver todos os projetos
@@ -204,11 +201,11 @@ export default function Home() {
- {project.cat} -

{project.title}

+ {project.cat} +

{project.title}

- Ver detalhes + Ver detalhes
diff --git a/frontend/src/app/[locale]/contato/page.tsx b/frontend/src/app/[locale]/contato/page.tsx new file mode 100644 index 0000000..40d2a5e --- /dev/null +++ b/frontend/src/app/[locale]/contato/page.tsx @@ -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(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 ( +
+ {/* Hero Section */} +
+
+
+
+
+
+ + {hero.pretitle} +
+

{hero.title}

+

+ {hero.subtitle} +

+
+
+
+ +
+ {/* Decorative Elements */} +
+ +
+
+ {/* Informações de Contato */} +
+
+

{info.title}

+

{info.subtitle}

+

+ {info.description} +

+
+ +
+ {info.items.map((item, index) => ( +
+
+
+ +
+
+

{item.title}

+

{item.description}

+ + {item.linkText} + +
+
+
+ ))} +
+
+ + {/* Formulário */} +
+
+
+ +

{t('contact.sendMessage')}

+ +
+
+
+ +
+ + 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')} + /> +
+
+
+ +
+ + 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" + /> +
+
+
+ +
+ +
+ + 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')} + /> +
+
+ +
+ +
+ + + +
+
+ +
+ +
+ + +
+
+ + +
+
+
+
+
+
+ + {/* Map Section */} +
+ +
+
+
+ ); +} diff --git a/frontend/src/app/[locale]/layout.tsx b/frontend/src/app/[locale]/layout.tsx new file mode 100644 index 0000000..fcec6c4 --- /dev/null +++ b/frontend/src/app/[locale]/layout.tsx @@ -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 ( + +
+
+ {children} +
+