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

@@ -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');
// 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() {
<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>
@@ -1117,4 +1307,5 @@ export default function EditHomePage() {
</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,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 });