feat: CMS com limites de caracteres, traduções auto e painel de notificações
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
317
frontend/src/app/[locale]/contato/page.tsx
Normal file
317
frontend/src/app/[locale]/contato/page.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
"use client";
|
||||
|
||||
import { useToast } from "@/contexts/ToastContext";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useLocale } from "@/contexts/LocaleContext";
|
||||
|
||||
interface ContactInfo {
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
link: string;
|
||||
linkText: string;
|
||||
}
|
||||
|
||||
interface ContactContent {
|
||||
hero: {
|
||||
pretitle: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
info: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description: string;
|
||||
items: ContactInfo[];
|
||||
};
|
||||
}
|
||||
|
||||
export default function ContatoPage() {
|
||||
const { success, error: showError } = useToast();
|
||||
const { locale, t } = useLocale();
|
||||
const [content, setContent] = useState<ContactContent | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
subject: '',
|
||||
message: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchContent();
|
||||
}, [locale]);
|
||||
|
||||
const fetchContent = async () => {
|
||||
try {
|
||||
// Busca conteúdo JÁ TRADUZIDO do banco
|
||||
const response = await fetch(`/api/pages/contact?locale=${locale}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.content) {
|
||||
setContent(data.content);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar conteúdo:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Erro ao enviar mensagem');
|
||||
|
||||
// Limpar formulário
|
||||
setFormData({
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
subject: '',
|
||||
message: ''
|
||||
});
|
||||
|
||||
success('Mensagem enviada com sucesso! Entraremos em contato em breve.');
|
||||
} catch (error) {
|
||||
showError('Erro ao enviar mensagem. Tente novamente.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Valores padrão caso não tenha conteúdo salvo
|
||||
const hero = content?.hero || {
|
||||
pretitle: t('contact.pretitle'),
|
||||
title: t('contact.title'),
|
||||
subtitle: t('contact.subtitle')
|
||||
};
|
||||
|
||||
const info = content?.info || {
|
||||
title: t('contact.infoTitle'),
|
||||
subtitle: t('contact.infoSubtitle'),
|
||||
description: t('contact.infoDescription'),
|
||||
items: [
|
||||
{
|
||||
icon: 'ri-whatsapp-line',
|
||||
title: t('contact.phone'),
|
||||
description: t('contact.phoneDescription'),
|
||||
link: 'https://wa.me/5527999999999',
|
||||
linkText: '(27) 99999-9999'
|
||||
},
|
||||
{
|
||||
icon: 'ri-mail-send-line',
|
||||
title: t('contact.email'),
|
||||
description: t('contact.emailDescription'),
|
||||
link: 'mailto:contato@octto.com.br',
|
||||
linkText: 'contato@octto.com.br'
|
||||
},
|
||||
{
|
||||
icon: 'ri-map-pin-line',
|
||||
title: t('contact.address'),
|
||||
description: t('contact.addressDescription'),
|
||||
link: 'https://maps.google.com',
|
||||
linkText: t('contact.viewOnMap')
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="bg-white dark:bg-secondary transition-colors duration-300">
|
||||
{/* Hero Section */}
|
||||
<section className="relative h-[400px] flex items-center bg-secondary text-white overflow-hidden">
|
||||
<div className="absolute inset-0 bg-linear-to-r from-black/80 to-black/40 z-10"></div>
|
||||
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center"></div>
|
||||
<div className="container mx-auto px-4 relative z-20">
|
||||
<div className="max-w-3xl">
|
||||
<div className="inline-flex items-center gap-2 bg-primary/20 backdrop-blur-sm border border-primary/30 rounded-full px-4 py-1 mb-6">
|
||||
<span className="w-2 h-2 rounded-full bg-primary animate-pulse"></span>
|
||||
<span className="text-sm font-bold text-primary uppercase tracking-wider">{hero.pretitle}</span>
|
||||
</div>
|
||||
<h1 className="text-5xl md:text-6xl font-bold font-headline mb-6 leading-tight">{hero.title}</h1>
|
||||
<p className="text-xl text-gray-300 max-w-2xl leading-relaxed">
|
||||
{hero.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-20 bg-white dark:bg-secondary relative">
|
||||
{/* Decorative Elements */}
|
||||
<div className="absolute top-0 right-0 w-1/3 h-full bg-gray-50 dark:bg-white/5 -z-10 hidden lg:block"></div>
|
||||
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-20">
|
||||
{/* Informações de Contato */}
|
||||
<div className="lg:col-span-5 space-y-12">
|
||||
<div>
|
||||
<h2 className="text-primary font-bold tracking-wider uppercase mb-3">{info.title}</h2>
|
||||
<h3 className="text-3xl md:text-4xl font-bold font-headline text-secondary dark:text-white mb-6">{info.subtitle}</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-lg leading-relaxed">
|
||||
{info.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{info.items.map((item, index) => (
|
||||
<div key={index} className="group bg-gray-50 dark:bg-white/5 p-6 rounded-2xl border border-gray-100 dark:border-white/10 hover:border-primary/50 transition-colors">
|
||||
<div className="flex items-start gap-5">
|
||||
<div className="w-14 h-14 bg-white dark:bg-white/10 rounded-xl flex items-center justify-center text-primary shadow-sm group-hover:scale-110 transition-transform duration-300">
|
||||
<i className={`${item.icon} text-3xl`}></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-2">{item.title}</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-3 text-sm whitespace-pre-line">{item.description}</p>
|
||||
<a href={item.link} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2 text-primary font-bold hover:gap-3 transition-all">
|
||||
{item.linkText} <i className="ri-arrow-right-line"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Formulário */}
|
||||
<div className="lg:col-span-7">
|
||||
<div className="bg-white dark:bg-secondary p-8 md:p-10 rounded-3xl shadow-xl border border-gray-100 dark:border-white/10 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-primary/5 rounded-bl-full -mr-10 -mt-10"></div>
|
||||
|
||||
<h3 className="text-2xl font-bold font-headline text-secondary dark:text-white mb-8 relative z-10">{t('contact.sendMessage')}</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6 relative z-10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="group">
|
||||
<label htmlFor="nome" className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 group-focus-within:text-primary transition-colors">{t('contact.form.name')}</label>
|
||||
<div className="relative">
|
||||
<i className="ri-user-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-primary transition-colors"></i>
|
||||
<input
|
||||
type="text"
|
||||
id="nome"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({...formData, name: e.target.value})}
|
||||
className="w-full pl-11 pr-4 py-3.5 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
placeholder={t('contact.form.namePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="group">
|
||||
<label htmlFor="telefone" className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 group-focus-within:text-primary transition-colors">{t('contact.form.phone')}</label>
|
||||
<div className="relative">
|
||||
<i className="ri-phone-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-primary transition-colors"></i>
|
||||
<input
|
||||
type="tel"
|
||||
id="telefone"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({...formData, phone: e.target.value})}
|
||||
className="w-full pl-11 pr-4 py-3.5 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
placeholder="(00) 00000-0000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group">
|
||||
<label htmlFor="email" className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 group-focus-within:text-primary transition-colors">{t('contact.form.email')}</label>
|
||||
<div className="relative">
|
||||
<i className="ri-mail-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-primary transition-colors"></i>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({...formData, email: e.target.value})}
|
||||
className="w-full pl-11 pr-4 py-3.5 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
placeholder={t('contact.form.emailPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group">
|
||||
<label htmlFor="assunto" className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 group-focus-within:text-primary transition-colors">{t('contact.form.subject')}</label>
|
||||
<div className="relative">
|
||||
<i className="ri-file-list-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-primary transition-colors"></i>
|
||||
<select
|
||||
id="assunto"
|
||||
value={formData.subject}
|
||||
onChange={(e) => setFormData({...formData, subject: e.target.value})}
|
||||
className="w-full pl-11 pr-10 py-3.5 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="">{t('contact.form.subjectPlaceholder')}</option>
|
||||
<option value="orcamento">{t('contact.form.subjectQuote')}</option>
|
||||
<option value="duvida">{t('contact.form.subjectQuestion')}</option>
|
||||
<option value="parceria">{t('contact.form.subjectPartnership')}</option>
|
||||
<option value="trabalhe">{t('contact.form.subjectOther')}</option>
|
||||
</select>
|
||||
<i className="ri-arrow-down-s-line absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group">
|
||||
<label htmlFor="mensagem" className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 group-focus-within:text-primary transition-colors">{t('contact.form.message')}</label>
|
||||
<div className="relative">
|
||||
<i className="ri-message-2-line absolute left-4 top-6 text-gray-400 group-focus-within:text-primary transition-colors"></i>
|
||||
<textarea
|
||||
id="mensagem"
|
||||
required
|
||||
value={formData.message}
|
||||
onChange={(e) => setFormData({...formData, message: e.target.value})}
|
||||
className="w-full pl-11 pr-4 py-3.5 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl h-40 text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all resize-none"
|
||||
placeholder={t('contact.form.messagePlaceholder')}
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="mt-4 w-full bg-primary text-white py-4 rounded-xl font-bold hover-primary transition-all shadow-lg hover:shadow-primary/30 flex items-center justify-center gap-2 group disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<i className="ri-loader-4-line animate-spin"></i>
|
||||
<span>{t('contact.form.sending')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{t('contact.form.submit')}</span>
|
||||
<i className="ri-send-plane-fill group-hover:translate-x-1 transition-transform"></i>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Map Section */}
|
||||
<section className="h-[400px] w-full bg-gray-200 dark:bg-white/5 relative grayscale hover:grayscale-0 transition-all duration-700">
|
||||
<iframe
|
||||
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3741.447687667888!2d-40.29799692398269!3d-20.32313498115656!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0xb817d0a5b5b5b5%3A0x5b5b5b5b5b5b5b5b!2sAv.%20Nossa%20Sra.%20da%20Penha%2C%20Vit%C3%B3ria%20-%20ES!5e0!3m2!1spt-BR!2sbr!4v1700000000000!5m2!1spt-BR!2sbr"
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ border: 0 }}
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
className="absolute inset-0"
|
||||
></iframe>
|
||||
<div className="absolute inset-0 bg-primary/10 pointer-events-none"></div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
35
frontend/src/app/[locale]/layout.tsx
Normal file
35
frontend/src/app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import CookieConsent from "@/components/CookieConsent";
|
||||
import WhatsAppButton from "@/components/WhatsAppButton";
|
||||
import { LocaleProvider } from "@/contexts/LocaleContext";
|
||||
import { locales, type Locale, defaultLocale } from "@/lib/i18n";
|
||||
|
||||
// Gerar rotas estáticas para cada locale
|
||||
export function generateStaticParams() {
|
||||
return locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: Locale }>;
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({ children, params }: Props) {
|
||||
const { locale } = await params;
|
||||
|
||||
// Validar locale
|
||||
const validLocale = locales.includes(locale) ? locale : defaultLocale;
|
||||
|
||||
return (
|
||||
<LocaleProvider locale={validLocale}>
|
||||
<Header />
|
||||
<div className="grow">
|
||||
{children}
|
||||
</div>
|
||||
<Footer />
|
||||
<CookieConsent />
|
||||
<WhatsAppButton />
|
||||
</LocaleProvider>
|
||||
);
|
||||
}
|
||||
252
frontend/src/app/[locale]/page.tsx
Normal file
252
frontend/src/app/[locale]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
frontend/src/app/[locale]/privacidade/page.tsx
Normal file
61
frontend/src/app/[locale]/privacidade/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "@/contexts/LocaleContext";
|
||||
|
||||
export default function PrivacyPolicy() {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<main className="py-20 bg-white dark:bg-secondary transition-colors duration-300">
|
||||
<div className="container mx-auto px-4 max-w-4xl">
|
||||
<h1 className="text-4xl font-bold font-headline text-secondary dark:text-white mb-8">{t('footer.privacyPolicy')}</h1>
|
||||
|
||||
<div className="prose prose-lg text-gray-600 dark:text-gray-300">
|
||||
<p className="mb-6">
|
||||
A Octto Engenharia valoriza a privacidade de seus usuários e clientes. Esta Política de Privacidade descreve como coletamos, usamos e protegemos suas informações pessoais ao utilizar nosso site e serviços.
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">1. Coleta de Informações</h2>
|
||||
<p className="mb-4">
|
||||
Coletamos informações que você nos fornece diretamente, como quando preenche nosso formulário de contato, solicita um orçamento ou se inscreve em nossa newsletter. As informações podem incluir nome, e-mail, telefone e detalhes sobre sua empresa ou projeto.
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">2. Uso das Informações</h2>
|
||||
<p className="mb-4">
|
||||
Utilizamos as informações coletadas para:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-6 space-y-2">
|
||||
<li>Responder a suas consultas e solicitações de orçamento;</li>
|
||||
<li>Fornecer informações sobre nossos serviços de engenharia e laudos técnicos;</li>
|
||||
<li>Melhorar a experiência do usuário em nosso site;</li>
|
||||
<li>Cumprir obrigações legais e regulatórias.</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">3. Proteção de Dados</h2>
|
||||
<p className="mb-4">
|
||||
Adotamos medidas de segurança técnicas e organizacionais adequadas para proteger seus dados pessoais contra acesso não autorizado, alteração, divulgação ou destruição.
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">4. Compartilhamento de Informações</h2>
|
||||
<p className="mb-4">
|
||||
Não vendemos, trocamos ou transferimos suas informações pessoais para terceiros, exceto quando necessário para a prestação de nossos serviços (ex: parceiros técnicos envolvidos em um projeto específico) ou quando exigido por lei.
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">5. Cookies</h2>
|
||||
<p className="mb-4">
|
||||
Nosso site pode utilizar cookies para melhorar a navegação e entender como os visitantes interagem com nosso conteúdo. Você pode desativar os cookies nas configurações do seu navegador, se preferir.
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">6. Contato</h2>
|
||||
<p className="mb-4">
|
||||
Se você tiver dúvidas sobre esta Política de Privacidade, entre em contato conosco através do e-mail: contato@octto.com.br.
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-12">
|
||||
Última atualização: Novembro de 2025.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
117
frontend/src/app/[locale]/projetos/page.tsx
Normal file
117
frontend/src/app/[locale]/projetos/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
frontend/src/app/[locale]/servicos/page.tsx
Normal file
126
frontend/src/app/[locale]/servicos/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
frontend/src/app/[locale]/sobre/page.tsx
Normal file
82
frontend/src/app/[locale]/sobre/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "@/contexts/LocaleContext";
|
||||
|
||||
export default function SobrePage() {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<main className="bg-white dark:bg-secondary transition-colors duration-300">
|
||||
{/* Hero Section */}
|
||||
<section className="relative h-[400px] flex items-center bg-secondary text-white overflow-hidden">
|
||||
<div className="absolute inset-0 bg-black/60 z-10"></div>
|
||||
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1504307651254-35680f356dfd?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center"></div>
|
||||
<div className="container mx-auto px-4 relative z-20">
|
||||
<h1 className="text-5xl font-bold font-headline mb-4">{t('about.hero.title')}</h1>
|
||||
<p className="text-xl text-gray-300 max-w-2xl">
|
||||
{t('about.hero.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* História e Missão */}
|
||||
<section className="py-20 bg-white dark:bg-secondary">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex flex-col md:flex-row gap-12 items-center">
|
||||
<div className="w-full md:w-1/2">
|
||||
<h2 className="text-primary font-bold tracking-wider uppercase mb-2">{t('about.history.pretitle')}</h2>
|
||||
<h3 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-6">{t('about.history.title')}</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4 leading-relaxed">
|
||||
{t('about.history.paragraph1')}
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4 leading-relaxed">
|
||||
{t('about.history.paragraph2')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full md:w-1/2 grid grid-cols-2 gap-4">
|
||||
<div className="h-64 rounded-xl bg-gray-200 dark:bg-white/10 overflow-hidden relative">
|
||||
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1581092160562-40aa08e78837?q=80&w=1000&auto=format&fit=crop')] bg-cover bg-center"></div>
|
||||
</div>
|
||||
<div className="h-64 rounded-xl bg-gray-200 dark:bg-white/10 overflow-hidden relative mt-8">
|
||||
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?q=80&w=1000&auto=format&fit=crop')] bg-cover bg-center"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Valores */}
|
||||
<section className="py-20 bg-gray-50 dark:bg-[#121212]">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-primary font-bold tracking-wider uppercase mb-2">{t('about.values.pretitle')}</h2>
|
||||
<h3 className="text-3xl font-bold font-headline text-secondary dark:text-white">{t('about.values.title')}</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="bg-white dark:bg-secondary p-8 rounded-xl shadow-sm border-t-4 border-primary">
|
||||
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center text-primary mb-6">
|
||||
<i className="ri-medal-line text-2xl"></i>
|
||||
</div>
|
||||
<h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-3">{t('about.values.quality.title')}</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">{t('about.values.quality.description')}</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-secondary p-8 rounded-xl shadow-sm border-t-4 border-primary">
|
||||
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center text-primary mb-6">
|
||||
<i className="ri-shake-hands-line text-2xl"></i>
|
||||
</div>
|
||||
<h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-3">{t('about.values.transparency.title')}</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">{t('about.values.transparency.description')}</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-secondary p-8 rounded-xl shadow-sm border-t-4 border-primary">
|
||||
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center text-primary mb-6">
|
||||
<i className="ri-leaf-line text-2xl"></i>
|
||||
</div>
|
||||
<h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-3">{t('about.values.sustainability.title')}</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">{t('about.values.sustainability.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
55
frontend/src/app/[locale]/termos/page.tsx
Normal file
55
frontend/src/app/[locale]/termos/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "@/contexts/LocaleContext";
|
||||
|
||||
export default function TermsOfUse() {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<main className="py-20 bg-white dark:bg-secondary transition-colors duration-300">
|
||||
<div className="container mx-auto px-4 max-w-4xl">
|
||||
<h1 className="text-4xl font-bold font-headline text-secondary dark:text-white mb-8">{t('footer.termsOfUse')}</h1>
|
||||
|
||||
<div className="prose prose-lg text-gray-600 dark:text-gray-300">
|
||||
<p className="mb-6">
|
||||
Bem-vindo ao site da Octto Engenharia. Ao acessar e utilizar este site, você concorda em cumprir e estar vinculado aos seguintes Termos de Uso. Se você não concordar com qualquer parte destes termos, por favor, não utilize nosso site.
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">1. Uso do Site</h2>
|
||||
<p className="mb-4">
|
||||
O conteúdo deste site é apenas para fins informativos gerais sobre nossos serviços de engenharia mecânica, laudos e projetos. Reservamo-nos o direito de alterar ou descontinuar qualquer aspecto do site a qualquer momento.
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">2. Propriedade Intelectual</h2>
|
||||
<p className="mb-4">
|
||||
Todo o conteúdo presente neste site, incluindo textos, gráficos, logotipos, ícones, imagens e software, é propriedade da Octto Engenharia ou de seus fornecedores de conteúdo e é protegido pelas leis de direitos autorais do Brasil e internacionais.
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">3. Limitação de Responsabilidade</h2>
|
||||
<p className="mb-4">
|
||||
A Octto Engenharia não se responsabiliza por quaisquer danos diretos, indiretos, incidentais ou consequenciais resultantes do uso ou da incapacidade de uso deste site ou de qualquer informação nele contida. As informações técnicas fornecidas no site não substituem a consulta profissional e a emissão de laudos técnicos específicos para cada caso.
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">4. Links para Terceiros</h2>
|
||||
<p className="mb-4">
|
||||
Nosso site pode conter links para sites de terceiros. Estes links são fornecidos apenas para sua conveniência. A Octto Engenharia não tem controle sobre o conteúdo desses sites e não assume responsabilidade por eles.
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">5. Alterações nos Termos</h2>
|
||||
<p className="mb-4">
|
||||
Podemos revisar estes Termos de Uso a qualquer momento. Ao utilizar este site, você concorda em ficar vinculado à versão atual desses Termos de Uso.
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-bold font-headline text-secondary dark:text-white mt-8 mb-4">6. Legislação Aplicável</h2>
|
||||
<p className="mb-4">
|
||||
Estes termos são regidos e interpretados de acordo com as leis da República Federativa do Brasil. Qualquer disputa relacionada a estes termos será submetida à jurisdição exclusiva dos tribunais competentes.
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-12">
|
||||
Última atualização: Novembro de 2025.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
"use client";
|
||||
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
193
frontend/src/app/api/admin/translate-pages/route.ts
Normal file
193
frontend/src/app/api/admin/translate-pages/route.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { cookies } from 'next/headers';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
const LIBRETRANSLATE_URL = process.env.LIBRETRANSLATE_URL || 'https://libretranslate.stackbyte.cloud';
|
||||
const SUPPORTED_LOCALES = ['en', 'es'];
|
||||
|
||||
// Autenticação
|
||||
async function authenticate() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('auth_token')?.value;
|
||||
|
||||
if (!token) return null;
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string };
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: { id: true, email: true, name: true }
|
||||
});
|
||||
return user;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Traduzir um texto
|
||||
async function translateText(text: string, targetLang: string): Promise<string> {
|
||||
if (!text || text.trim() === '' || targetLang === 'pt') return text;
|
||||
|
||||
// Verificar cache no banco primeiro
|
||||
const cached = await prisma.translation.findUnique({
|
||||
where: {
|
||||
sourceText_sourceLang_targetLang: {
|
||||
sourceText: text,
|
||||
sourceLang: 'pt',
|
||||
targetLang: targetLang,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (cached) {
|
||||
return cached.translatedText;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[i18n] Traduzindo: "${text.substring(0, 30)}..." para ${targetLang}`);
|
||||
|
||||
const response = await fetch(`${LIBRETRANSLATE_URL}/translate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ q: text, source: 'pt', target: targetLang, format: 'text' }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const translatedText = data.translatedText || text;
|
||||
|
||||
// Salvar no cache
|
||||
try {
|
||||
await prisma.translation.create({
|
||||
data: {
|
||||
sourceText: text,
|
||||
sourceLang: 'pt',
|
||||
targetLang: targetLang,
|
||||
translatedText,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Ignorar se já existe
|
||||
}
|
||||
|
||||
return translatedText;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[i18n] Erro ao traduzir para ${targetLang}:`, error);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// Traduzir objeto recursivamente
|
||||
async function translateContent(content: unknown, targetLang: string): Promise<unknown> {
|
||||
if (typeof content === 'string') {
|
||||
return await translateText(content, targetLang);
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
const results = [];
|
||||
for (const item of content) {
|
||||
results.push(await translateContent(item, targetLang));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
if (content && typeof content === 'object') {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(content)) {
|
||||
// Não traduzir campos técnicos
|
||||
if (['icon', 'image', 'img', 'url', 'href', 'id', 'slug', 'src', 'link'].includes(key)) {
|
||||
result[key] = value;
|
||||
} else {
|
||||
result[key] = await translateContent(value, targetLang);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
// POST /api/admin/translate-pages - Traduzir todas as páginas para EN e ES
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await authenticate();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const slugFilter = body.slug; // Opcional: traduzir só uma página específica
|
||||
|
||||
// Buscar todas as páginas em português
|
||||
const ptPages = await prisma.pageContent.findMany({
|
||||
where: slugFilter
|
||||
? { slug: slugFilter, locale: 'pt' }
|
||||
: { locale: 'pt' }
|
||||
});
|
||||
|
||||
if (ptPages.length === 0) {
|
||||
return NextResponse.json({ error: 'Nenhuma página encontrada para traduzir' }, { status: 404 });
|
||||
}
|
||||
|
||||
const results: { slug: string; locale: string; status: string }[] = [];
|
||||
|
||||
for (const page of ptPages) {
|
||||
for (const targetLocale of SUPPORTED_LOCALES) {
|
||||
try {
|
||||
console.log(`[i18n] Traduzindo página "${page.slug}" para ${targetLocale}...`);
|
||||
|
||||
const translatedContent = await translateContent(page.content, targetLocale) as Prisma.InputJsonValue;
|
||||
|
||||
await prisma.pageContent.upsert({
|
||||
where: { slug_locale: { slug: page.slug, locale: targetLocale } },
|
||||
update: { content: translatedContent },
|
||||
create: { slug: page.slug, locale: targetLocale, content: translatedContent }
|
||||
});
|
||||
|
||||
results.push({ slug: page.slug, locale: targetLocale, status: 'success' });
|
||||
console.log(`[i18n] ✓ Página "${page.slug}" traduzida para ${targetLocale}`);
|
||||
} catch (error) {
|
||||
console.error(`[i18n] ✗ Erro ao traduzir "${page.slug}" para ${targetLocale}:`, error);
|
||||
results.push({ slug: page.slug, locale: targetLocale, status: 'error' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Tradução concluída para ${ptPages.length} página(s)`,
|
||||
results
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erro ao traduzir páginas:', error);
|
||||
return NextResponse.json({ error: 'Erro ao traduzir páginas' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/admin/translate-pages - Status das traduções
|
||||
export async function GET() {
|
||||
try {
|
||||
const pages = await prisma.pageContent.findMany({
|
||||
select: { slug: true, locale: true, updatedAt: true },
|
||||
orderBy: [{ slug: 'asc' }, { locale: 'asc' }]
|
||||
});
|
||||
|
||||
// Agrupar por slug
|
||||
const grouped: Record<string, { pt?: Date; en?: Date; es?: Date }> = {};
|
||||
|
||||
for (const page of pages) {
|
||||
if (!grouped[page.slug]) {
|
||||
grouped[page.slug] = {};
|
||||
}
|
||||
grouped[page.slug][page.locale as 'pt' | 'en' | 'es'] = page.updatedAt;
|
||||
}
|
||||
|
||||
return NextResponse.json({ pages: grouped });
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar status:', error);
|
||||
return NextResponse.json({ error: 'Erro ao buscar status' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const page = await prisma.pageContent.findUnique({
|
||||
where: { slug: 'contact' }
|
||||
});
|
||||
|
||||
if (!page) {
|
||||
return NextResponse.json({ content: null }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ content: page.content });
|
||||
} catch (error) {
|
||||
console.error('Error fetching contact page:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch page' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const { content } = await request.json();
|
||||
|
||||
const page = await prisma.pageContent.upsert({
|
||||
where: { slug: 'contact' },
|
||||
update: {
|
||||
content
|
||||
},
|
||||
create: {
|
||||
slug: 'contact',
|
||||
content
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, page });
|
||||
} catch (error) {
|
||||
console.error('Error updating contact page:', error);
|
||||
return NextResponse.json({ error: 'Failed to update page' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -31,22 +31,33 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user