Initial commit: CMS completo com gerenciamento de leads e personalização de tema
This commit is contained in:
316
frontend/src/app/(public)/contato/page.tsx
Normal file
316
frontend/src/app/(public)/contato/page.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
"use client";
|
||||
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { useToast } from "@/contexts/ToastContext";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
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 { t } = useLanguage();
|
||||
const { success, error: showError } = useToast();
|
||||
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();
|
||||
}, []);
|
||||
|
||||
const fetchContent = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/pages/contact');
|
||||
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.info.pretitle'),
|
||||
title: t('contact.hero.title'),
|
||||
subtitle: t('contact.hero.subtitle')
|
||||
};
|
||||
|
||||
const info = content?.info || {
|
||||
title: t('contact.info.title'),
|
||||
subtitle: t('contact.info.subtitle'),
|
||||
description: 'Estamos à disposição para atender sua empresa com a excelência técnica que seu projeto exige.',
|
||||
items: [
|
||||
{
|
||||
icon: 'ri-whatsapp-line',
|
||||
title: t('contact.info.phone.title'),
|
||||
description: t('contact.info.whatsapp.desc'),
|
||||
link: 'https://wa.me/5527999999999',
|
||||
linkText: '(27) 99999-9999'
|
||||
},
|
||||
{
|
||||
icon: 'ri-mail-send-line',
|
||||
title: t('contact.info.email.title'),
|
||||
description: t('contact.info.email.desc'),
|
||||
link: 'mailto:contato@octto.com.br',
|
||||
linkText: 'contato@octto.com.br'
|
||||
},
|
||||
{
|
||||
icon: 'ri-map-pin-line',
|
||||
title: t('contact.info.address.title'),
|
||||
description: 'Av. Nossa Senhora da Penha, 1234\nSanta Lúcia, Vitória - ES\nCEP: 29056-000',
|
||||
link: 'https://maps.google.com',
|
||||
linkText: 'Ver no mapa'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
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.form.title')}</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.name.placeholder')}
|
||||
/>
|
||||
</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.email.placeholder')}
|
||||
/>
|
||||
</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.subject.select')}</option>
|
||||
<option value="orcamento">{t('contact.form.subject.quote')}</option>
|
||||
<option value="duvida">{t('contact.form.subject.doubt')}</option>
|
||||
<option value="parceria">{t('contact.form.subject.partnership')}</option>
|
||||
<option value="trabalhe">{t('contact.form.subject.other')}</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.message.placeholder')}
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="mt-4 w-full bg-primary text-white py-4 rounded-xl font-bold hover:bg-orange-600 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>Enviando...</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>
|
||||
);
|
||||
}
|
||||
22
frontend/src/app/(public)/layout.tsx
Normal file
22
frontend/src/app/(public)/layout.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import CookieConsent from "@/components/CookieConsent";
|
||||
import WhatsAppButton from "@/components/WhatsAppButton";
|
||||
|
||||
export default function PublicLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<div className="grow">
|
||||
{children}
|
||||
</div>
|
||||
<Footer />
|
||||
<CookieConsent />
|
||||
<WhatsAppButton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
261
frontend/src/app/(public)/page.tsx
Normal file
261
frontend/src/app/(public)/page.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { usePageContent } from "@/hooks/usePageContent";
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useLanguage();
|
||||
const { content, loading } = usePageContent('home');
|
||||
|
||||
// Usar conteúdo personalizado do banco ou fallback para traduções
|
||||
const hero = content?.hero || {
|
||||
title: t('home.hero.title'),
|
||||
subtitle: t('home.hero.subtitle'),
|
||||
buttonText: t('home.hero.cta_primary')
|
||||
};
|
||||
|
||||
const features = content?.features || {
|
||||
pretitle: t('home.features.pretitle'),
|
||||
title: t('home.features.title'),
|
||||
items: [
|
||||
{ icon: 'ri-shield-star-line', title: t('home.features.1.title'), description: t('home.features.1.desc') },
|
||||
{ icon: 'ri-settings-4-line', title: t('home.features.2.title'), description: t('home.features.2.desc') },
|
||||
{ icon: 'ri-truck-line', title: t('home.features.3.title'), description: t('home.features.3.desc') }
|
||||
]
|
||||
};
|
||||
|
||||
const services = content?.services || {
|
||||
pretitle: t('home.services.pretitle'),
|
||||
title: t('home.services.title'),
|
||||
items: [
|
||||
{ icon: 'ri-draft-line', title: t('home.services.1.title'), description: t('home.services.1.desc') },
|
||||
{ icon: 'ri-file-paper-2-line', title: t('home.services.2.title'), description: t('home.services.2.desc') },
|
||||
{ icon: 'ri-alert-line', title: t('home.services.3.title'), description: t('home.services.3.desc') },
|
||||
{ icon: 'ri-truck-fill', title: t('home.services.4.title'), description: t('home.services.4.desc') }
|
||||
]
|
||||
};
|
||||
|
||||
const about = content?.about || {
|
||||
pretitle: t('home.about.pretitle'),
|
||||
title: t('home.about.title'),
|
||||
description: t('home.about.desc'),
|
||||
highlights: [
|
||||
t('home.about.list.1'),
|
||||
t('home.about.list.2'),
|
||||
t('home.about.list.3')
|
||||
]
|
||||
};
|
||||
|
||||
const testimonials = content?.testimonials || {
|
||||
pretitle: t('home.testimonials.pretitle'),
|
||||
title: t('home.testimonials.title'),
|
||||
items: [
|
||||
{ name: 'Ricardo Mendes', role: t('home.testimonials.1.role'), text: t('home.testimonials.1.text') },
|
||||
{ name: 'Fernanda Costa', role: t('home.testimonials.2.role'), text: t('home.testimonials.2.text') },
|
||||
{ name: 'Paulo Oliveira', role: t('home.testimonials.3.role'), text: t('home.testimonials.3.text') }
|
||||
]
|
||||
};
|
||||
|
||||
const stats = content?.stats || {
|
||||
clients: '500+',
|
||||
projects: '1200+',
|
||||
years: '15'
|
||||
};
|
||||
|
||||
const cta = content?.cta || {
|
||||
title: t('home.cta.title'),
|
||||
text: t('home.cta.desc'),
|
||||
button: t('home.cta.button')
|
||||
};
|
||||
|
||||
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>
|
||||
{/* Placeholder for Hero Image - Industrial/Truck context */}
|
||||
<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.hero.badge')} <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="/contato" className="px-8 py-4 bg-primary text-white rounded-lg font-bold hover:bg-orange-600 transition-colors text-center">
|
||||
{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('home.hero.cta_secondary')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section - Por que nos escolher */}
|
||||
<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, index) => (
|
||||
<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, index) => (
|
||||
<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="/servicos" className="text-primary font-bold hover:text-secondary dark:hover:text-white transition-colors inline-flex items-center gap-2">
|
||||
{t('home.services.link')} <i className="ri-arrow-right-line"></i>
|
||||
</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">
|
||||
{/* Placeholder for About Image - Engineer inspecting */}
|
||||
<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, index) => (
|
||||
<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="/sobre" className="text-primary font-bold hover:text-white transition-colors flex items-center gap-2">
|
||||
{t('home.about.link')} <i className="ri-arrow-right-line"></i>
|
||||
</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.projects.pretitle')}</h2>
|
||||
<h3 className="text-4xl font-bold font-headline text-secondary dark:text-white">{t('home.projects.title')}</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('home.projects.link')}
|
||||
</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: t('home.projects.1.title'), cat: t('home.projects.1.cat') },
|
||||
{ img: "https://images.unsplash.com/photo-1581092335397-9583eb92d232?q=80&w=2070&auto=format&fit=crop", title: t('home.projects.2.title'), cat: t('home.projects.2.cat') },
|
||||
{ img: "https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?q=80&w=2070&auto=format&fit=crop", title: t('home.projects.3.title'), cat: t('home.projects.3.cat') }
|
||||
].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.projects.view_details')} <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, index) => (
|
||||
<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="/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>
|
||||
);
|
||||
}
|
||||
55
frontend/src/app/(public)/privacidade/page.tsx
Normal file
55
frontend/src/app/(public)/privacidade/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
export default function PrivacyPolicy() {
|
||||
return (
|
||||
<main className="py-20 bg-white">
|
||||
<div className="container mx-auto px-4 max-w-4xl">
|
||||
<h1 className="text-4xl font-bold font-headline text-secondary mb-8">Política de Privacidade</h1>
|
||||
|
||||
<div className="prose prose-lg text-gray-600">
|
||||
<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 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 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 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 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 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 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 mt-12">
|
||||
Última atualização: Novembro de 2025.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
157
frontend/src/app/(public)/projetos/[id]/page.tsx
Normal file
157
frontend/src/app/(public)/projetos/[id]/page.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
// Mock data - same as in the main projects page
|
||||
// In a real app, this would come from a database or API
|
||||
const projects = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Engenharia de Adequação - Frota Coca-Cola",
|
||||
category: "Engenharia Veicular",
|
||||
location: "Vitória, ES",
|
||||
image: "https://images.unsplash.com/photo-1616401784845-180882ba9ba8?q=80&w=2070&auto=format&fit=crop",
|
||||
description: "Projeto de adequação técnica de 50 caminhões para instalação de carrocerias especiais e sistemas de segurança.",
|
||||
details: "Desenvolvimento completo do projeto de engenharia para adequação de frota de distribuição de bebidas. O escopo incluiu o cálculo estrutural para rebaixamento de carrocerias, instalação de sistemas de proteção lateral e traseira conforme resoluções do CONTRAN, e homologação junto aos órgãos competentes. O projeto resultou em aumento de 15% na capacidade de carga e total conformidade normativa.",
|
||||
features: ["Cálculo Estrutural", "Homologação DENATRAN", "Segurança Operacional", "Adequação de Carroceria"]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Laudo de Guindaste Articulado",
|
||||
category: "Inspeção Técnica",
|
||||
location: "Serra, ES",
|
||||
image: "https://images.unsplash.com/photo-1535082623926-b3a33d531740?q=80&w=2052&auto=format&fit=crop",
|
||||
description: "Inspeção completa e emissão de laudo técnico para guindaste de 45 toneladas, com testes de carga e verificação estrutural.",
|
||||
details: "Realização de inspeção detalhada em guindaste articulado (Munck) com capacidade de 45 toneladas. Foram realizados ensaios não destrutivos (líquido penetrante) em pontos críticos de solda, verificação do sistema hidráulico, testes de carga estática e dinâmica conforme NR-11. O laudo técnico atestou a integridade do equipamento para operação segura.",
|
||||
features: ["Ensaio Não Destrutivo", "Teste de Carga", "Verificação Hidráulica", "ART de Inspeção"]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Projeto de Dispositivo de Içamento",
|
||||
category: "Projeto Mecânico",
|
||||
location: "Aracruz, ES",
|
||||
image: "https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?q=80&w=2070&auto=format&fit=crop",
|
||||
description: "Desenvolvimento e cálculo estrutural de Spreader para movimentação de contêineres em área portuária.",
|
||||
details: "Projeto mecânico de um Spreader (balancim) automático para içamento de contêineres de 20 e 40 pés. O dispositivo foi projetado para suportar cargas de até 30 toneladas, com sistema de travamento twist-lock automático. Entregamos o projeto completo em 3D, desenhos de fabricação, memorial de cálculo e manual de operação.",
|
||||
features: ["Modelagem 3D", "Cálculo de Elementos Finitos", "Detalhamento de Fabricação", "Manual de Operação"]
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Certificação NR-12 - Parque Industrial",
|
||||
category: "Laudos",
|
||||
location: "Linhares, ES",
|
||||
image: "https://images.unsplash.com/photo-1581092921461-eab62e97a782?q=80&w=2070&auto=format&fit=crop",
|
||||
description: "Inventário e adequação de segurança de 120 máquinas operatrizes conforme norma regulamentadora NR-12.",
|
||||
details: "Consultoria completa para adequação à NR-12 em parque fabril. Realizamos o inventário de 120 máquinas, análise de risco (HRN), projeto de proteções mecânicas e sistemas de segurança eletrônica. Acompanhamos a implementação e emitimos os laudos de validação final, garantindo a segurança dos operadores.",
|
||||
features: ["Análise de Risco", "Projeto de Proteções", "Sistemas de Segurança", "Laudo de Validação"]
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "Homologação de Plataforma Elevatória",
|
||||
category: "Engenharia Veicular",
|
||||
location: "Viana, ES",
|
||||
image: "https://images.unsplash.com/photo-1591768793355-74d04bb6608f?q=80&w=2070&auto=format&fit=crop",
|
||||
description: "Processo completo de homologação e certificação de plataformas elevatórias para distribuição urbana.",
|
||||
details: "Assessoria técnica para fabricante de plataformas elevatórias veiculares. Realizamos os cálculos de estabilidade, testes de tombamento e resistência estrutural necessários para a obtenção do CAT (Certificado de Adequação à Legislação de Trânsito). O equipamento foi homologado com sucesso para uso em veículos urbanos de carga.",
|
||||
features: ["Cálculo de Estabilidade", "Teste de Tombamento", "Dossiê Técnico", "Homologação INMETRO/DENATRAN"]
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: "Projeto de Linha de Vida para Caminhões",
|
||||
category: "Segurança do Trabalho",
|
||||
location: "Cariacica, ES",
|
||||
image: "https://images.unsplash.com/photo-1504328345606-18bbc8c9d7d1?q=80&w=2070&auto=format&fit=crop",
|
||||
description: "Projeto e instalação de sistema de linha de vida para proteção contra quedas em operações de carga e descarga.",
|
||||
details: "Projeto e instalação de sistema de linha de vida rígida sobre estrutura metálica para proteção de quedas durante o enlonamento de caminhões. O sistema permite que o operador trabalhe com segurança em toda a extensão da carroceria. Fornecimento de projeto, ART e treinamento de uso para a equipe.",
|
||||
features: ["Projeto Estrutural", "Sistema de Ancoragem", "Treinamento NR-35", "ART de Instalação"]
|
||||
}
|
||||
];
|
||||
|
||||
export default function ProjectDetails({ params }: { params: { id: string } }) {
|
||||
const project = projects.find((p) => p.id === parseInt(params.id));
|
||||
|
||||
if (!project) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
{/* Hero Section */}
|
||||
<section className="relative h-[500px] flex items-center bg-secondary text-white overflow-hidden">
|
||||
<div className="absolute inset-0 bg-black/60 z-10"></div>
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{ backgroundImage: `url('${project.image}')` }}
|
||||
></div>
|
||||
<div className="container mx-auto px-4 relative z-20">
|
||||
<span className="inline-block px-3 py-1 bg-primary text-white text-sm font-bold rounded-md mb-4 uppercase tracking-wider">
|
||||
{project.category}
|
||||
</span>
|
||||
<h1 className="text-4xl md:text-6xl font-bold font-headline mb-4 leading-tight max-w-4xl">
|
||||
{project.title}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 text-gray-300 text-lg">
|
||||
<i className="ri-map-pin-line text-primary"></i>
|
||||
<span>{project.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Content Section */}
|
||||
<section className="py-20 bg-white">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex flex-col lg:flex-row gap-16">
|
||||
{/* Main Content */}
|
||||
<div className="lg:w-2/3">
|
||||
<h2 className="text-3xl font-bold font-headline text-secondary mb-6">Sobre o Projeto</h2>
|
||||
<p className="text-gray-600 text-lg leading-relaxed mb-8">
|
||||
{project.details}
|
||||
</p>
|
||||
|
||||
<h3 className="text-2xl font-bold font-headline text-secondary mb-6">Escopo Técnico</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{project.features.map((feature, index) => (
|
||||
<div key={index} className="flex items-center gap-3 p-4 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<i className="ri-checkbox-circle-line text-primary text-xl"></i>
|
||||
<span className="font-medium text-gray-700">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="lg:w-1/3">
|
||||
<div className="bg-gray-50 p-8 rounded-xl border border-gray-100 sticky top-24">
|
||||
<h3 className="text-xl font-bold font-headline text-secondary mb-6">Ficha Técnica</h3>
|
||||
<ul className="space-y-4 mb-8">
|
||||
<li className="flex justify-between border-b border-gray-200 pb-3">
|
||||
<span className="text-gray-500">Cliente</span>
|
||||
<span className="font-medium text-secondary">Confidencial</span>
|
||||
</li>
|
||||
<li className="flex justify-between border-b border-gray-200 pb-3">
|
||||
<span className="text-gray-500">Categoria</span>
|
||||
<span className="font-medium text-secondary">{project.category}</span>
|
||||
</li>
|
||||
<li className="flex justify-between border-b border-gray-200 pb-3">
|
||||
<span className="text-gray-500">Local</span>
|
||||
<span className="font-medium text-secondary">{project.location}</span>
|
||||
</li>
|
||||
<li className="flex justify-between border-b border-gray-200 pb-3">
|
||||
<span className="text-gray-500">Ano</span>
|
||||
<span className="font-medium text-secondary">2024</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Link href="/contato" className="block w-full py-4 bg-primary text-white text-center rounded-lg font-bold hover:bg-orange-600 transition-colors">
|
||||
Solicitar Orçamento Similar
|
||||
</Link>
|
||||
<Link href="/projetos" className="block w-full py-4 mt-4 border border-gray-300 text-gray-600 text-center rounded-lg font-bold hover:bg-gray-100 transition-colors">
|
||||
Voltar para Projetos
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
116
frontend/src/app/(public)/projetos/page.tsx
Normal file
116
frontend/src/app/(public)/projetos/page.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
export default function ProjetosPage() {
|
||||
const { t } = useLanguage();
|
||||
|
||||
// Placeholder data - will be replaced by database content
|
||||
const projects = [
|
||||
{
|
||||
id: 1,
|
||||
title: t('home.projects.1.title'),
|
||||
category: t('home.projects.1.cat'),
|
||||
location: "Vitória, ES",
|
||||
image: "https://images.unsplash.com/photo-1616401784845-180882ba9ba8?q=80&w=2070&auto=format&fit=crop",
|
||||
description: "Projeto de adequação técnica de 50 caminhões para instalação de carrocerias especiais e sistemas de segurança."
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: t('home.projects.2.title'),
|
||||
category: t('home.projects.2.cat'),
|
||||
location: "Serra, ES",
|
||||
image: "https://images.unsplash.com/photo-1535082623926-b3a33d531740?q=80&w=2052&auto=format&fit=crop",
|
||||
description: "Inspeção completa e emissão de laudo técnico para guindaste de 45 toneladas, com testes de carga e verificação estrutural."
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: t('home.projects.3.title'),
|
||||
category: t('home.projects.3.cat'),
|
||||
location: "Aracruz, ES",
|
||||
image: "https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?q=80&w=2070&auto=format&fit=crop",
|
||||
description: "Desenvolvimento e cálculo estrutural de Spreader para movimentação de contêineres em área portuária."
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: t('home.projects.4.title'),
|
||||
category: t('home.projects.4.cat'),
|
||||
location: "Linhares, ES",
|
||||
image: "https://images.unsplash.com/photo-1581092921461-eab62e97a782?q=80&w=2070&auto=format&fit=crop",
|
||||
description: "Inventário e adequação de segurança de 120 máquinas operatrizes conforme norma regulamentadora NR-12."
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: t('home.projects.5.title'),
|
||||
category: t('home.projects.5.cat'),
|
||||
location: "Viana, ES",
|
||||
image: "https://images.unsplash.com/photo-1591768793355-74d04bb6608f?q=80&w=2070&auto=format&fit=crop",
|
||||
description: "Processo completo de homologação e certificação de plataformas elevatórias para distribuição urbana."
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: t('home.projects.6.title'),
|
||||
category: t('home.projects.6.cat'),
|
||||
location: "Cariacica, ES",
|
||||
image: "https://images.unsplash.com/photo-1504328345606-18bbc8c9d7d1?q=80&w=2070&auto=format&fit=crop",
|
||||
description: "Projeto e instalação de sistema de linha de vida para proteção contra quedas em operações de carga e descarga."
|
||||
}
|
||||
];
|
||||
|
||||
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 (Placeholder) */}
|
||||
<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.filter.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.filter.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.filter.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.filter.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={`/projetos/${project.id}`} className="inline-flex items-center gap-2 text-primary font-bold hover:gap-3 transition-all mt-auto">
|
||||
{t('projects.card.details')} <i className="ri-arrow-right-line"></i>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
105
frontend/src/app/(public)/servicos/page.tsx
Normal file
105
frontend/src/app/(public)/servicos/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
export default function ServicosPage() {
|
||||
const { t } = useLanguage();
|
||||
|
||||
const services = [
|
||||
{
|
||||
icon: "ri-draft-line",
|
||||
title: t('home.services.1.title'),
|
||||
description: t('home.services.1.desc'),
|
||||
features: ["Projeto Mecânico 3D", "Cálculo Estrutural", "Dispositivos Especiais", "Homologação de Equipamentos"]
|
||||
},
|
||||
{
|
||||
icon: "ri-truck-line",
|
||||
title: t('home.features.3.title'),
|
||||
description: t('home.features.3.desc'),
|
||||
features: ["Projeto de Instalação", "Estudo de Estabilidade", "Adequação de Carrocerias", "Regularização Veicular"]
|
||||
},
|
||||
{
|
||||
icon: "ri-file-paper-2-line",
|
||||
title: t('home.services.2.title'),
|
||||
description: t('home.services.2.desc'),
|
||||
features: ["Laudos de Munck/Guindaste", "Inspeção de Segurança", "Teste de Carga", "Certificação de Equipamentos"]
|
||||
},
|
||||
{
|
||||
icon: "ri-tools-fill",
|
||||
title: "Consultoria Técnica",
|
||||
description: "Assessoria especializada para adequação de frotas, planos de Rigging e supervisão de manutenção de equipamentos de carga.",
|
||||
features: ["Plano de Rigging", "Supervisão de Manutenção", "Consultoria em Normas", "Treinamento Operacional"]
|
||||
}
|
||||
];
|
||||
|
||||
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="/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>
|
||||
);
|
||||
}
|
||||
83
frontend/src/app/(public)/sobre/page.tsx
Normal file
83
frontend/src/app/(public)/sobre/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
export default function SobrePage() {
|
||||
const { t } = useLanguage();
|
||||
|
||||
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.title')}</h2>
|
||||
<h3 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-6">{t('about.history.subtitle')}</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4 leading-relaxed">
|
||||
{t('about.history.p1')}
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4 leading-relaxed">
|
||||
{t('about.history.p2')}
|
||||
</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.title')}</h2>
|
||||
<h3 className="text-3xl font-bold font-headline text-secondary dark:text-white">{t('about.values.subtitle')}</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.desc')}</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.desc')}</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.desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
49
frontend/src/app/(public)/termos/page.tsx
Normal file
49
frontend/src/app/(public)/termos/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
export default function TermsOfUse() {
|
||||
return (
|
||||
<main className="py-20 bg-white">
|
||||
<div className="container mx-auto px-4 max-w-4xl">
|
||||
<h1 className="text-4xl font-bold font-headline text-secondary mb-8">Termos de Uso</h1>
|
||||
|
||||
<div className="prose prose-lg text-gray-600">
|
||||
<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 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 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 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 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 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 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 mt-12">
|
||||
Última atualização: Novembro de 2025.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
134
frontend/src/app/acesso/page.tsx
Normal file
134
frontend/src/app/acesso/page.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || 'Erro ao fazer login');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirecionar para o admin
|
||||
router.push('/admin');
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError('Erro ao conectar com o servidor');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-[#121212] px-4">
|
||||
<div className="max-w-md w-full bg-white dark:bg-secondary rounded-2xl shadow-xl overflow-hidden border border-gray-100 dark:border-white/10">
|
||||
<div className="p-8 md:p-10">
|
||||
<div className="text-center mb-10">
|
||||
<Link href="/" className="inline-flex items-center gap-3 group mb-6">
|
||||
<i className="ri-building-2-fill text-4xl text-primary"></i>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold text-secondary dark:text-white font-headline leading-none">OCCTO</span>
|
||||
<span className="text-[10px] font-bold text-primary bg-primary/10 px-2 py-1 rounded-md uppercase tracking-wider">ENG.</span>
|
||||
</div>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold font-headline text-secondary dark:text-white mb-2">Acesso Administrativo</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">Entre com suas credenciais para gerenciar o site.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl">
|
||||
<p className="text-sm text-red-600 dark:text-red-400 flex items-center gap-2">
|
||||
<i className="ri-error-warning-line"></i>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">E-mail</label>
|
||||
<div className="relative">
|
||||
<i className="ri-mail-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"></i>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full pl-11 pr-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
placeholder="admin@octto.com.br"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Senha</label>
|
||||
<div className="relative">
|
||||
<i className="ri-lock-password-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"></i>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-11 pr-12 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-primary transition-colors cursor-pointer"
|
||||
>
|
||||
<i className={showPassword ? "ri-eye-off-line text-xl" : "ri-eye-line text-xl"}></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3.5 bg-primary text-white rounded-xl font-bold hover:bg-orange-600 transition-colors shadow-lg shadow-primary/20 flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<i className="ri-loader-4-line animate-spin text-xl"></i>
|
||||
<span>Entrando...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Acessar Painel</span>
|
||||
<i className="ri-arrow-right-line"></i>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-white/5 p-4 text-center border-t border-gray-100 dark:border-white/10">
|
||||
<Link href="/" className="text-sm text-gray-500 hover:text-primary transition-colors flex items-center justify-center gap-2">
|
||||
<i className="ri-arrow-left-line"></i> Voltar para o site
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
257
frontend/src/app/admin/configuracoes/page.tsx
Normal file
257
frontend/src/app/admin/configuracoes/page.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
|
||||
const PRESET_COLORS = [
|
||||
{ name: 'Laranja (Padrão)', value: '#FF6B35', gradient: 'from-orange-500 to-orange-600' },
|
||||
{ name: 'Azul Corporativo', value: '#2563EB', gradient: 'from-blue-600 to-blue-700' },
|
||||
{ name: 'Verde Profissional', value: '#059669', gradient: 'from-emerald-600 to-emerald-700' },
|
||||
{ name: 'Roxo Moderno', value: '#7C3AED', gradient: 'from-violet-600 to-violet-700' },
|
||||
{ name: 'Vermelho Vibrante', value: '#DC2626', gradient: 'from-red-600 to-red-700' },
|
||||
{ name: 'Azul Petróleo', value: '#0891B2', gradient: 'from-cyan-600 to-cyan-700' },
|
||||
{ name: 'Rosa Criativo', value: '#DB2777', gradient: 'from-pink-600 to-pink-700' },
|
||||
{ name: 'Âmbar Caloroso', value: '#D97706', gradient: 'from-amber-600 to-amber-700' },
|
||||
];
|
||||
|
||||
export default function ConfiguracoesPage() {
|
||||
const { success, error: showError } = useToast();
|
||||
const [primaryColor, setPrimaryColor] = useState('#FF6B35');
|
||||
const [customColor, setCustomColor] = useState('#FF6B35');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/config');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.primaryColor) {
|
||||
setPrimaryColor(data.primaryColor);
|
||||
setCustomColor(data.primaryColor);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar configurações:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ primaryColor })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Erro ao salvar');
|
||||
|
||||
success('Configurações salvas com sucesso! Recarregando página...');
|
||||
|
||||
// Recarregar a página após 1 segundo para aplicar as mudanças
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
showError('Erro ao salvar configurações');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyPreviewColor = (color: string) => {
|
||||
setPrimaryColor(color);
|
||||
setCustomColor(color);
|
||||
// Preview temporário
|
||||
document.documentElement.style.setProperty('--color-primary', color);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white">Configurações</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">Personalize a aparência do seu site</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Settings */}
|
||||
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="w-12 h-12 bg-linear-to-br from-primary to-orange-600 rounded-xl flex items-center justify-center shadow-lg shadow-primary/30">
|
||||
<i className="ri-palette-line text-2xl text-white"></i>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-secondary dark:text-white mb-1">Cor Primária</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
Escolha a cor principal que representa sua marca. Ela será aplicada em botões, links e destaques.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preset Colors */}
|
||||
<div className="mb-8">
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-4">
|
||||
Cores Predefinidas
|
||||
</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{PRESET_COLORS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
onClick={() => applyPreviewColor(color.value)}
|
||||
className={`group relative p-4 rounded-xl border-2 transition-all ${
|
||||
primaryColor === color.value
|
||||
? 'border-primary shadow-lg shadow-primary/20'
|
||||
: 'border-gray-200 dark:border-white/10 hover:border-gray-300 dark:hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-full h-16 rounded-lg bg-linear-to-br ${color.gradient} mb-3 shadow-md group-hover:scale-105 transition-transform`}></div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white text-center">
|
||||
{color.name}
|
||||
</p>
|
||||
{primaryColor === color.value && (
|
||||
<div className="absolute top-2 right-2 w-6 h-6 bg-primary rounded-full flex items-center justify-center shadow-lg">
|
||||
<i className="ri-check-line text-white text-sm"></i>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Color Picker */}
|
||||
<div className="border-t border-gray-200 dark:border-white/10 pt-8">
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-4">
|
||||
Cor Personalizada
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="color"
|
||||
value={customColor}
|
||||
onChange={(e) => applyPreviewColor(e.target.value)}
|
||||
className="w-20 h-20 rounded-xl border-2 border-gray-200 dark:border-white/10 cursor-pointer shadow-md"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={customColor}
|
||||
onChange={(e) => {
|
||||
setCustomColor(e.target.value);
|
||||
if (/^#[0-9A-F]{6}$/i.test(e.target.value)) {
|
||||
applyPreviewColor(e.target.value);
|
||||
}
|
||||
}}
|
||||
placeholder="#FF6B35"
|
||||
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 font-mono"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
Digite o código hexadecimal da cor (ex: #FF6B35)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Section */}
|
||||
<div className="border-t border-gray-200 dark:border-white/10 mt-8 pt-8">
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-4">
|
||||
Prévia dos Elementos
|
||||
</label>
|
||||
<div className="bg-gray-50 dark:bg-white/5 p-6 rounded-xl space-y-4">
|
||||
<button
|
||||
className="px-6 py-3 bg-primary text-white rounded-xl font-bold hover:opacity-90 transition-all shadow-lg shadow-primary/30"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
>
|
||||
Botão Primário
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center text-white shadow-md"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
>
|
||||
<i className="ri-star-fill text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold" style={{ color: primaryColor }}>Texto em Destaque</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Exemplo de link ou texto importante</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="px-3 py-1 rounded-full text-sm font-medium text-white"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
>
|
||||
Badge
|
||||
</span>
|
||||
<span
|
||||
className="px-3 py-1 rounded-full text-sm font-medium border-2"
|
||||
style={{ borderColor: primaryColor, color: primaryColor }}
|
||||
>
|
||||
Outline Badge
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
<button
|
||||
onClick={fetchConfig}
|
||||
className="px-6 py-3 border border-gray-200 dark:border-white/10 text-gray-600 dark:text-gray-300 rounded-xl font-bold hover:bg-gray-50 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-6 py-3 bg-primary text-white rounded-xl font-bold hover:bg-orange-600 transition-colors shadow-lg shadow-primary/20 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{ backgroundColor: saving ? undefined : primaryColor }}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<i className="ri-loader-4-line animate-spin"></i>
|
||||
Salvando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="ri-save-line"></i>
|
||||
Salvar Alterações
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info Alert */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-4 flex items-start gap-3">
|
||||
<i className="ri-information-line text-blue-600 dark:text-blue-400 text-xl mt-0.5"></i>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-blue-900 dark:text-blue-200 font-medium mb-1">
|
||||
Aplicação Global
|
||||
</p>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
A cor primária será aplicada automaticamente em todo o site institucional e painel administrativo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
frontend/src/app/admin/layout.tsx
Normal file
250
frontend/src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { useConfirm } from '@/contexts/ConfirmContext';
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
const [user, setUser] = useState<{ name: string; email: string; avatar?: string | null } | null>(null);
|
||||
const [showAvatarModal, setShowAvatarModal] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { success, error } = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/me');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUser(data.user);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar dados do usuário:', error);
|
||||
}
|
||||
};
|
||||
fetchUser();
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
router.push('/acesso');
|
||||
} catch (error) {
|
||||
console.error('Erro ao fazer logout:', error);
|
||||
// Fallback: clear cookie manually
|
||||
document.cookie = "auth_token=; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT";
|
||||
router.push('/acesso');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
|
||||
const response = await fetch('/api/auth/avatar', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUser(data.user);
|
||||
setShowAvatarModal(false);
|
||||
success('Foto atualizada com sucesso!');
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
error(errorData.error || 'Erro ao fazer upload');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erro ao fazer upload:', err);
|
||||
error('Erro ao fazer upload do avatar');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAvatar = async () => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Remover Foto',
|
||||
message: 'Deseja remover sua foto de perfil?',
|
||||
confirmText: 'Remover',
|
||||
cancelText: 'Cancelar',
|
||||
type: 'warning',
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/avatar', { method: 'DELETE' });
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUser(data.user);
|
||||
setShowAvatarModal(false);
|
||||
success('Foto removida com sucesso!');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erro ao remover avatar:', err);
|
||||
error('Erro ao remover avatar');
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{ icon: 'ri-dashboard-line', label: 'Dashboard', href: '/admin' },
|
||||
{ icon: 'ri-briefcase-line', label: 'Projetos', href: '/admin/projetos' },
|
||||
{ icon: 'ri-tools-line', label: 'Serviços', href: '/admin/servicos' },
|
||||
{ icon: 'ri-pages-line', label: 'Páginas', href: '/admin/paginas' },
|
||||
{ icon: 'ri-message-3-line', label: 'Mensagens', href: '/admin/mensagens' },
|
||||
{ icon: 'ri-user-settings-line', label: 'Usuários', href: '/admin/usuarios' },
|
||||
{ icon: 'ri-settings-3-line', label: 'Configurações', href: '/admin/configuracoes' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-[#121212] flex">
|
||||
{/* Sidebar */}
|
||||
<aside className={`fixed inset-y-0 left-0 z-50 bg-white dark:bg-secondary border-r border-gray-200 dark:border-white/10 transition-all duration-300 ${isSidebarOpen ? 'w-64' : 'w-20'} hidden md:flex flex-col`}>
|
||||
<div className="h-20 flex items-center justify-center border-b border-gray-200 dark:border-white/10">
|
||||
<Link href="/admin" className="flex items-center gap-3">
|
||||
<i className="ri-building-2-fill text-3xl text-primary"></i>
|
||||
{isSidebarOpen && (
|
||||
<div className="flex items-center gap-2 animate-in fade-in duration-300">
|
||||
<span className="text-xl font-bold text-secondary dark:text-white font-headline leading-none">OCCTO</span>
|
||||
<span className="text-[10px] font-bold text-primary bg-primary/10 px-2 py-1 rounded-md uppercase tracking-wider">ENG.</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 py-6 px-3 space-y-2 overflow-y-auto">
|
||||
{menuItems.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-3 py-3 rounded-xl transition-all group ${isActive ? 'bg-primary text-white shadow-lg shadow-primary/20' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5'}`}
|
||||
>
|
||||
<i className={`${item.icon} text-xl ${isActive ? 'text-white' : 'text-gray-500 dark:text-gray-400 group-hover:text-primary'}`}></i>
|
||||
{isSidebarOpen && <span className="font-medium whitespace-nowrap animate-in fade-in duration-200">{item.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-gray-200 dark:border-white/10">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-3 py-3 rounded-xl text-red-500 hover:bg-red-50 dark:hover:bg-red-900/10 transition-colors cursor-pointer"
|
||||
>
|
||||
<i className="ri-logout-box-line text-xl"></i>
|
||||
{isSidebarOpen && <span className="font-medium">Sair</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className={`flex-1 flex flex-col min-h-screen transition-all duration-300 ${isSidebarOpen ? 'md:ml-64' : 'md:ml-20'}`}>
|
||||
{/* Header */}
|
||||
<header className="h-20 bg-white dark:bg-secondary border-b border-gray-200 dark:border-white/10 sticky top-0 z-40 px-6 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
className="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={isSidebarOpen ? "ri-menu-fold-line text-xl" : "ri-menu-unfold-line text-xl"}></i>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<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>
|
||||
<p className="text-xs text-gray-500">{user?.email || ''}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAvatarModal(true)}
|
||||
className="w-10 h-10 rounded-full overflow-hidden hover:ring-2 hover:ring-primary transition-all cursor-pointer"
|
||||
>
|
||||
{user?.avatar ? (
|
||||
<img src={user.avatar} alt={user.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-200 dark:bg-white/10 flex items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
<i className="ri-user-3-line text-xl"></i>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="p-6 md:p-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Avatar Modal */}
|
||||
{showAvatarModal && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" onClick={() => setShowAvatarModal(false)}>
|
||||
<div className="bg-white dark:bg-secondary rounded-xl p-6 max-w-md w-full" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold text-secondary dark:text-white">Foto de Perfil</h2>
|
||||
<button onClick={() => setShowAvatarModal(false)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||
<i className="ri-close-line text-2xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<div className="w-32 h-32 rounded-full overflow-hidden">
|
||||
{user?.avatar ? (
|
||||
<img src={user.avatar} alt={user.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-200 dark:bg-white/10 flex items-center justify-center">
|
||||
<i className="ri-user-3-line text-5xl text-gray-400"></i>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 w-full">
|
||||
<label className="flex-1 px-4 py-2 bg-primary text-white rounded-lg font-medium hover:bg-orange-600 transition-colors text-center cursor-pointer">
|
||||
{isUploading ? 'Enviando...' : 'Escolher Foto'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={handleAvatarUpload}
|
||||
disabled={isUploading}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
{user?.avatar && (
|
||||
<button
|
||||
onClick={handleRemoveAvatar}
|
||||
disabled={isUploading}
|
||||
className="px-4 py-2 border border-red-500 text-red-500 rounded-lg font-medium hover:bg-red-50 dark:hover:bg-red-900/10 transition-colors"
|
||||
>
|
||||
Remover
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 text-center">
|
||||
Formatos: JPEG, PNG, WEBP • Tamanho máximo: 5MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
361
frontend/src/app/admin/mensagens/page.tsx
Normal file
361
frontend/src/app/admin/mensagens/page.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function MessagesPage() {
|
||||
const { success, error: showError } = useToast();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [filteredMessages, setFilteredMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMessages();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
filterMessages();
|
||||
}, [search, statusFilter, messages]);
|
||||
|
||||
const fetchMessages = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/messages');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setMessages(data);
|
||||
} else {
|
||||
showError('Erro ao carregar mensagens');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar mensagens:', error);
|
||||
showError('Erro ao carregar mensagens');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filterMessages = () => {
|
||||
let filtered = messages;
|
||||
|
||||
if (statusFilter) {
|
||||
filtered = filtered.filter(m => m.status === statusFilter);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase();
|
||||
filtered = filtered.filter(m =>
|
||||
m.name.toLowerCase().includes(searchLower) ||
|
||||
m.email.toLowerCase().includes(searchLower) ||
|
||||
m.subject.toLowerCase().includes(searchLower) ||
|
||||
m.message.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredMessages(filtered);
|
||||
};
|
||||
|
||||
const updateStatus = async (id: string, status: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/messages/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setMessages(messages.map(m => m.id === id ? { ...m, status } : m));
|
||||
if (selectedMessage?.id === id) {
|
||||
setSelectedMessage({ ...selectedMessage, status });
|
||||
}
|
||||
success(`Status alterado para "${status}"`);
|
||||
} else {
|
||||
showError('Erro ao atualizar status');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao atualizar status:', error);
|
||||
showError('Erro ao atualizar status');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMessage = async (id: string) => {
|
||||
if (!confirm('Tem certeza que deseja excluir esta mensagem?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/messages/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setMessages(messages.filter(m => m.id !== id));
|
||||
if (selectedMessage?.id === id) {
|
||||
setSelectedMessage(null);
|
||||
}
|
||||
success('Mensagem excluída com sucesso');
|
||||
} else {
|
||||
showError('Erro ao excluir mensagem');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao deletar mensagem:', error);
|
||||
showError('Erro ao excluir mensagem');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Nova': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
|
||||
case 'Lida': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
|
||||
case 'Respondida': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const stats = {
|
||||
total: messages.length,
|
||||
novas: messages.filter(m => m.status === 'Nova').length,
|
||||
lidas: messages.filter(m => m.status === 'Lida').length,
|
||||
respondidas: messages.filter(m => m.status === 'Respondida').length
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white">Mensagens de Contato</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">Gerencie leads e solicitações de clientes</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div className="bg-white dark:bg-secondary p-6 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Total</p>
|
||||
<p className="text-3xl font-bold text-secondary dark:text-white mt-1">{stats.total}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-gray-100 dark:bg-white/10 rounded-xl flex items-center justify-center">
|
||||
<i className="ri-mail-line text-2xl text-gray-600 dark:text-gray-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-secondary p-6 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Novas</p>
|
||||
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400 mt-1">{stats.novas}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-xl flex items-center justify-center">
|
||||
<i className="ri-mail-unread-line text-2xl text-blue-600 dark:text-blue-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-secondary p-6 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Lidas</p>
|
||||
<p className="text-3xl font-bold text-yellow-600 dark:text-yellow-400 mt-1">{stats.lidas}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-yellow-100 dark:bg-yellow-900/30 rounded-xl flex items-center justify-center">
|
||||
<i className="ri-mail-open-line text-2xl text-yellow-600 dark:text-yellow-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-secondary p-6 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Respondidas</p>
|
||||
<p className="text-3xl font-bold text-green-600 dark:text-green-400 mt-1">{stats.respondidas}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-xl flex items-center justify-center">
|
||||
<i className="ri-mail-check-line text-2xl text-green-600 dark:text-green-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white dark:bg-secondary p-6 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="relative">
|
||||
<i className="ri-search-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"></i>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por nome, email, assunto ou mensagem..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-11 pr-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>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="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 cursor-pointer"
|
||||
>
|
||||
<option value="">Todos os status</option>
|
||||
<option value="Nova">Novas</option>
|
||||
<option value="Lida">Lidas</option>
|
||||
<option value="Respondida">Respondidas</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages List */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Lista */}
|
||||
<div className="bg-white dark:bg-secondary rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm overflow-hidden">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5">
|
||||
<h2 className="font-bold text-gray-900 dark:text-white">
|
||||
{filteredMessages.length} {filteredMessages.length === 1 ? 'mensagem' : 'mensagens'}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200 dark:divide-white/10 max-h-[600px] overflow-y-auto">
|
||||
{filteredMessages.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<i className="ri-mail-line text-4xl mb-2"></i>
|
||||
<p>Nenhuma mensagem encontrada</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredMessages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
onClick={() => setSelectedMessage(msg)}
|
||||
className={`p-4 cursor-pointer transition-colors hover:bg-gray-50 dark:hover:bg-white/5 ${
|
||||
selectedMessage?.id === msg.id ? 'bg-primary/10 dark:bg-primary/20' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-gray-900 dark:text-white truncate">{msg.name}</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">{msg.email}</p>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-medium ${getStatusColor(msg.status)}`}>
|
||||
{msg.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 font-medium mb-1 truncate">{msg.subject}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{format(new Date(msg.createdAt), "dd 'de' MMMM 'às' HH:mm", { locale: ptBR })}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detalhes */}
|
||||
<div className="bg-white dark:bg-secondary rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
|
||||
{selectedMessage ? (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-white/10">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-1">{selectedMessage.name}</h2>
|
||||
<a href={`mailto:${selectedMessage.email}`} className="text-primary hover:underline">
|
||||
{selectedMessage.email}
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteMessage(selectedMessage.id)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||
title="Excluir mensagem"
|
||||
>
|
||||
<i className="ri-delete-bin-line text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<i className="ri-calendar-line"></i>
|
||||
{format(new Date(selectedMessage.createdAt), "dd 'de' MMMM 'de' yyyy 'às' HH:mm", { locale: ptBR })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 flex-1 overflow-y-auto">
|
||||
<div className="mb-4">
|
||||
<label className="text-sm font-bold text-gray-600 dark:text-gray-400 block mb-2">Assunto</label>
|
||||
<p className="text-gray-900 dark:text-white font-medium">{selectedMessage.subject}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-bold text-gray-600 dark:text-gray-400 block mb-2">Mensagem</label>
|
||||
<p className="text-gray-900 dark:text-white whitespace-pre-wrap leading-relaxed">
|
||||
{selectedMessage.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5">
|
||||
<label className="text-sm font-bold text-gray-600 dark:text-gray-400 block mb-2">Status</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => updateStatus(selectedMessage.id, 'Nova')}
|
||||
className={`flex-1 px-4 py-2 rounded-xl font-medium transition-colors ${
|
||||
selectedMessage.status === 'Nova'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-900/50'
|
||||
}`}
|
||||
>
|
||||
Nova
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateStatus(selectedMessage.id, 'Lida')}
|
||||
className={`flex-1 px-4 py-2 rounded-xl font-medium transition-colors ${
|
||||
selectedMessage.status === 'Lida'
|
||||
? 'bg-yellow-600 text-white'
|
||||
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 hover:bg-yellow-200 dark:hover:bg-yellow-900/50'
|
||||
}`}
|
||||
>
|
||||
Lida
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateStatus(selectedMessage.id, 'Respondida')}
|
||||
className={`flex-1 px-4 py-2 rounded-xl font-medium transition-colors ${
|
||||
selectedMessage.status === 'Respondida'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-900/50'
|
||||
}`}
|
||||
>
|
||||
Respondida
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-gray-400 dark:text-gray-500">
|
||||
<div className="text-center">
|
||||
<i className="ri-mail-open-line text-6xl mb-4"></i>
|
||||
<p>Selecione uma mensagem para ver os detalhes</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
frontend/src/app/admin/page.tsx
Normal file
80
frontend/src/app/admin/page.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
export default function AdminDashboard() {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-2">Dashboard</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">Bem-vindo ao painel administrativo da Octto Engenharia.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{[
|
||||
{ label: 'Projetos Ativos', value: '12', icon: 'ri-briefcase-line', color: 'text-blue-500', bg: 'bg-blue-50 dark:bg-blue-900/20' },
|
||||
{ label: 'Mensagens Novas', value: '5', icon: 'ri-message-3-line', color: 'text-green-500', bg: 'bg-green-50 dark:bg-green-900/20' },
|
||||
{ label: 'Serviços', value: '8', icon: 'ri-tools-line', color: 'text-orange-500', bg: 'bg-orange-50 dark:bg-orange-900/20' },
|
||||
{ label: 'Visitas Hoje', value: '145', icon: 'ri-eye-line', color: 'text-purple-500', bg: 'bg-purple-50 dark:bg-purple-900/20' },
|
||||
].map((stat, index) => (
|
||||
<div key={index} className="bg-white dark:bg-secondary p-6 rounded-xl border border-gray-200 dark:border-white/10 shadow-sm cursor-pointer hover:shadow-md transition-all">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${stat.bg} ${stat.color}`}>
|
||||
<i className={`${stat.icon} text-2xl`}></i>
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-secondary dark:text-white">{stat.value}</span>
|
||||
</div>
|
||||
<h3 className="text-gray-500 dark:text-gray-400 font-medium">{stat.label}</h3>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div className="bg-white dark:bg-secondary p-6 rounded-xl border border-gray-200 dark:border-white/10 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-bold text-secondary dark:text-white">Últimas Mensagens</h3>
|
||||
<button className="text-primary text-sm font-bold hover:underline cursor-pointer">Ver todas</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-start gap-4 p-4 rounded-lg hover:bg-gray-50 dark:hover:bg-white/5 transition-colors border border-transparent hover:border-gray-100 dark:hover:border-white/5 cursor-pointer">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-white/10 flex items-center justify-center shrink-0">
|
||||
<span className="font-bold text-gray-500 dark:text-gray-400">JD</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h4 className="font-bold text-secondary dark:text-white text-sm">João da Silva</h4>
|
||||
<span className="text-xs text-gray-400">Há 2 horas</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
Gostaria de solicitar um orçamento para adequação de frota conforme NR-12...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-secondary p-6 rounded-xl border border-gray-200 dark:border-white/10 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-bold text-secondary dark:text-white">Projetos Recentes</h3>
|
||||
<button className="text-primary text-sm font-bold hover:underline cursor-pointer">Ver todos</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4 p-4 rounded-lg hover:bg-gray-50 dark:hover:bg-white/5 transition-colors border border-transparent hover:border-gray-100 dark:hover:border-white/5 cursor-pointer">
|
||||
<div className="w-16 h-12 rounded-lg bg-gray-200 dark:bg-white/10 overflow-hidden">
|
||||
{/* Placeholder image */}
|
||||
<div className="w-full h-full bg-gray-300 dark:bg-white/20"></div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-bold text-secondary dark:text-white text-sm">Adequação Coca-Cola</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Engenharia Veicular</p>
|
||||
</div>
|
||||
<span className="px-3 py-1 rounded-full text-xs font-bold bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||
Concluído
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
633
frontend/src/app/admin/paginas/contato/page.tsx
Normal file
633
frontend/src/app/admin/paginas/contato/page.tsx
Normal file
@@ -0,0 +1,633 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
|
||||
const AVAILABLE_ICONS = [
|
||||
// Pessoas e Equipe
|
||||
{ value: 'ri-team-line', label: 'Equipe', category: 'pessoas' },
|
||||
{ value: 'ri-user-star-line', label: 'Destaque', category: 'pessoas' },
|
||||
{ value: 'ri-user-follow-line', label: 'Seguir', category: 'pessoas' },
|
||||
{ value: 'ri-group-line', label: 'Grupo', category: 'pessoas' },
|
||||
|
||||
// Segurança
|
||||
{ value: 'ri-shield-check-line', label: 'Segurança', category: 'segurança' },
|
||||
{ value: 'ri-shield-star-line', label: 'Proteção Premium', category: 'segurança' },
|
||||
{ value: 'ri-lock-line', label: 'Cadeado', category: 'segurança' },
|
||||
{ value: 'ri-hard-hat-line', label: 'Capacete', category: 'segurança' },
|
||||
|
||||
// Serviços
|
||||
{ value: 'ri-service-line', label: 'Atendimento', category: 'serviço' },
|
||||
{ value: 'ri-customer-service-line', label: 'Suporte', category: 'serviço' },
|
||||
{ value: 'ri-tools-line', label: 'Ferramentas', category: 'serviço' },
|
||||
{ value: 'ri-settings-3-line', label: 'Engrenagem', category: 'serviço' },
|
||||
|
||||
// Transporte
|
||||
{ value: 'ri-car-line', label: 'Veículo', category: 'transporte' },
|
||||
{ value: 'ri-truck-line', label: 'Caminhão', category: 'transporte' },
|
||||
{ value: 'ri-bus-line', label: 'Ônibus', category: 'transporte' },
|
||||
{ value: 'ri-motorbike-line', label: 'Moto', category: 'transporte' },
|
||||
|
||||
// Documentos
|
||||
{ value: 'ri-file-list-3-line', label: 'Documentos', category: 'documentos' },
|
||||
{ value: 'ri-file-text-line', label: 'Arquivo', category: 'documentos' },
|
||||
{ value: 'ri-clipboard-line', label: 'Prancheta', category: 'documentos' },
|
||||
{ value: 'ri-contract-line', label: 'Contrato', category: 'documentos' },
|
||||
|
||||
// Conquistas
|
||||
{ value: 'ri-award-line', label: 'Prêmio', category: 'conquista' },
|
||||
{ value: 'ri-trophy-line', label: 'Troféu', category: 'conquista' },
|
||||
{ value: 'ri-medal-line', label: 'Medalha', category: 'conquista' },
|
||||
{ value: 'ri-vip-crown-line', label: 'Coroa', category: 'conquista' },
|
||||
|
||||
// Inovação
|
||||
{ value: 'ri-lightbulb-line', label: 'Ideia', category: 'inovação' },
|
||||
{ value: 'ri-flashlight-line', label: 'Lanterna', category: 'inovação' },
|
||||
{ value: 'ri-rocket-line', label: 'Foguete', category: 'inovação' },
|
||||
{ value: 'ri-flask-line', label: 'Experimento', category: 'inovação' },
|
||||
|
||||
// Status
|
||||
{ value: 'ri-checkbox-circle-line', label: 'Confirmado', category: 'status' },
|
||||
{ value: 'ri-check-double-line', label: 'Verificado', category: 'status' },
|
||||
{ value: 'ri-star-line', label: 'Estrela', category: 'status' },
|
||||
{ value: 'ri-thumb-up-line', label: 'Aprovado', category: 'status' },
|
||||
|
||||
// Dados
|
||||
{ value: 'ri-pie-chart-line', label: 'Gráfico Pizza', category: 'dados' },
|
||||
{ value: 'ri-bar-chart-line', label: 'Gráfico Barras', category: 'dados' },
|
||||
{ value: 'ri-line-chart-line', label: 'Gráfico Linha', category: 'dados' },
|
||||
{ value: 'ri-dashboard-line', label: 'Dashboard', category: 'dados' },
|
||||
|
||||
// Performance
|
||||
{ value: 'ri-speed-line', label: 'Velocidade', category: 'performance' },
|
||||
{ value: 'ri-timer-line', label: 'Cronômetro', category: 'performance' },
|
||||
{ value: 'ri-time-line', label: 'Relógio', category: 'performance' },
|
||||
{ value: 'ri-pulse-line', label: 'Pulso', category: 'performance' },
|
||||
|
||||
// Negócios
|
||||
{ value: 'ri-building-line', label: 'Empresa', category: 'negócios' },
|
||||
{ value: 'ri-briefcase-line', label: 'Maleta', category: 'negócios' },
|
||||
{ value: 'ri-money-dollar-circle-line', label: 'Dinheiro', category: 'negócios' },
|
||||
{ value: 'ri-hand-coin-line', label: 'Pagamento', category: 'negócios' },
|
||||
|
||||
// Cálculo
|
||||
{ value: 'ri-calculator-line', label: 'Calculadora', category: 'cálculo' },
|
||||
{ value: 'ri-percent-line', label: 'Porcentagem', category: 'cálculo' },
|
||||
{ value: 'ri-functions', label: 'Funções', category: 'cálculo' },
|
||||
|
||||
// Comunicação
|
||||
{ value: 'ri-message-3-line', label: 'Mensagem', category: 'comunicação' },
|
||||
{ value: 'ri-chat-3-line', label: 'Chat', category: 'comunicação' },
|
||||
{ value: 'ri-phone-line', label: 'Telefone', category: 'comunicação' },
|
||||
{ value: 'ri-mail-line', label: 'Email', category: 'comunicação' },
|
||||
{ value: 'ri-whatsapp-line', label: 'WhatsApp', category: 'comunicação' },
|
||||
{ value: 'ri-mail-send-line', label: 'Enviar Email', category: 'comunicação' },
|
||||
|
||||
// Localização
|
||||
{ value: 'ri-map-pin-line', label: 'Localização', category: 'local' },
|
||||
{ value: 'ri-navigation-line', label: 'Navegação', category: 'local' },
|
||||
{ value: 'ri-roadster-line', label: 'Estrada', category: 'local' },
|
||||
{ value: 'ri-compass-line', label: 'Bússola', category: 'local' },
|
||||
];
|
||||
|
||||
interface IconSelectorProps {
|
||||
value: string;
|
||||
onChange: (icon: string) => void;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function IconSelector({ value, onChange, label }: IconSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filteredIcons = AVAILABLE_ICONS.filter(icon =>
|
||||
icon.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
icon.category.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{label}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
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 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<i className={`${value} text-2xl text-primary`}></i>
|
||||
<span className="text-sm">{AVAILABLE_ICONS.find(i => i.value === value)?.label || 'Selecionar ícone'}</span>
|
||||
</div>
|
||||
<i className={`ri-arrow-${isOpen ? 'up' : 'down'}-s-line text-gray-400`}></i>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 mt-2 w-full bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl shadow-xl">
|
||||
<div className="p-3 border-b border-gray-200 dark:border-white/10">
|
||||
<div className="relative">
|
||||
<i className="ri-search-line absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Buscar ícone..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg text-sm focus:outline-none focus:border-primary"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 grid grid-cols-4 gap-2 max-h-64 overflow-y-auto">
|
||||
{filteredIcons.map((icon) => (
|
||||
<button
|
||||
key={icon.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(icon.value);
|
||||
setIsOpen(false);
|
||||
setSearch('');
|
||||
}}
|
||||
className={`p-3 rounded-lg flex flex-col items-center gap-1 transition-all ${
|
||||
value === icon.value
|
||||
? 'bg-primary text-white'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-white/5 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
title={icon.label}
|
||||
>
|
||||
<i className={`${icon.value} text-2xl`}></i>
|
||||
<span className="text-[10px] text-center leading-tight">{icon.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 EditContactPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState('hero');
|
||||
const { success, error: showError } = useToast();
|
||||
|
||||
const scrollToPreview = (sectionId: string) => {
|
||||
const element = document.getElementById(`preview-${sectionId}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (tab: string) => {
|
||||
setActiveTab(tab);
|
||||
setTimeout(() => scrollToPreview(tab), 100);
|
||||
};
|
||||
|
||||
const [formData, setFormData] = useState<ContactContent>({
|
||||
hero: {
|
||||
pretitle: 'Fale Conosco',
|
||||
title: 'Entre em Contato',
|
||||
subtitle: 'Nossa equipe está pronta para atender você e transformar suas ideias em realidade.'
|
||||
},
|
||||
info: {
|
||||
title: 'Informações de Contato',
|
||||
subtitle: 'Estamos à disposição',
|
||||
description: 'Estamos à disposição para atender sua empresa com a excelência técnica que seu projeto exige.',
|
||||
items: [
|
||||
{
|
||||
icon: 'ri-whatsapp-line',
|
||||
title: 'WhatsApp',
|
||||
description: 'Atendimento rápido e direto',
|
||||
link: 'https://wa.me/5527999999999',
|
||||
linkText: '(27) 99999-9999'
|
||||
},
|
||||
{
|
||||
icon: 'ri-mail-send-line',
|
||||
title: 'E-mail',
|
||||
description: 'Envie sua mensagem',
|
||||
link: 'mailto:contato@octto.com.br',
|
||||
linkText: 'contato@octto.com.br'
|
||||
},
|
||||
{
|
||||
icon: 'ri-map-pin-line',
|
||||
title: 'Endereço',
|
||||
description: 'Av. Nossa Senhora da Penha, 1234\nSanta Lúcia, Vitória - ES\nCEP: 29056-000',
|
||||
link: 'https://maps.google.com',
|
||||
linkText: 'Ver no mapa'
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchPageContent();
|
||||
}, []);
|
||||
|
||||
const fetchPageContent = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/pages/contact');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.content) {
|
||||
setFormData(prevData => ({
|
||||
hero: data.content.hero || prevData.hero,
|
||||
info: data.content.info || prevData.info
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Nenhum conteúdo salvo ainda, usando padrão');
|
||||
} finally {
|
||||
setInitialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/pages/contact', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: formData })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Erro ao salvar');
|
||||
|
||||
success('Conteúdo da página Contato atualizado com sucesso!');
|
||||
} catch (err) {
|
||||
showError('Erro ao salvar alterações');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (initialLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<style jsx global>{`
|
||||
main { padding: 0 !important; }
|
||||
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
||||
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
`}</style>
|
||||
<div className="fixed top-20 bottom-0 left-64 right-0 flex gap-0 bg-gray-50 dark:bg-tertiary">
|
||||
{/* Formulário de Edição - Coluna Esquerda 30% */}
|
||||
<div className="w-[30%] shrink-0 overflow-y-auto bg-white dark:bg-secondary relative">
|
||||
<div className="absolute top-0 right-0 bottom-0 w-px bg-gray-200 dark:bg-white/10"></div>
|
||||
|
||||
<div className="p-6 border-b border-gray-200 dark:border-white/10">
|
||||
<h1 className="text-2xl font-bold">Editar Página Contato</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Personalize informações de contato
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="relative border-b border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const container = document.getElementById('tabs-container');
|
||||
if (container) container.scrollLeft -= 200;
|
||||
}}
|
||||
className="absolute left-0 z-10 w-10 h-full bg-white dark:bg-secondary border-r border-gray-200 dark:border-white/10 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-white/5 transition-all cursor-pointer"
|
||||
>
|
||||
<i className="ri-arrow-left-s-line text-xl text-gray-600 dark:text-gray-400"></i>
|
||||
</button>
|
||||
|
||||
<div id="tabs-container" className="flex gap-2 p-4 overflow-x-auto scrollbar-hide scroll-smooth px-12">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabChange('hero')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors cursor-pointer ${
|
||||
activeTab === 'hero'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-background hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
Banner
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabChange('info')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors cursor-pointer ${
|
||||
activeTab === 'info'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-background hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
Informações (3)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const container = document.getElementById('tabs-container');
|
||||
if (container) container.scrollLeft += 200;
|
||||
}}
|
||||
className="absolute right-0 z-10 w-10 h-full bg-white dark:bg-secondary border-l border-gray-200 dark:border-white/10 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-white/5 transition-all cursor-pointer"
|
||||
>
|
||||
<i className="ri-arrow-right-s-line text-xl text-gray-600 dark:text-gray-400"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6 pb-20">
|
||||
|
||||
{/* Hero Section */}
|
||||
{activeTab === 'hero' && (
|
||||
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
|
||||
<h2 className="text-xl font-bold text-secondary dark:text-white mb-6 flex items-center gap-2">
|
||||
<i className="ri-layout-top-line text-primary"></i>
|
||||
Banner Principal
|
||||
</h2>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.hero.pretitle}
|
||||
onChange={(e) => setFormData({...formData, hero: {...formData.hero, pretitle: e.target.value}})}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título Principal</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.hero.title}
|
||||
onChange={(e) => setFormData({...formData, hero: {...formData.hero, title: e.target.value}})}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Subtítulo</label>
|
||||
<textarea
|
||||
value={formData.hero.subtitle}
|
||||
onChange={(e) => setFormData({...formData, hero: {...formData.hero, subtitle: e.target.value}})}
|
||||
rows={2}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Section */}
|
||||
{activeTab === 'info' && (
|
||||
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
|
||||
<h2 className="text-xl font-bold text-secondary dark:text-white mb-6 flex items-center gap-2">
|
||||
<i className="ri-information-line text-primary"></i>
|
||||
Informações de Contato
|
||||
</h2>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.info.title}
|
||||
onChange={(e) => setFormData({...formData, info: {...formData.info, title: e.target.value}})}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título da Seção</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.info.subtitle}
|
||||
onChange={(e) => setFormData({...formData, info: {...formData.info, subtitle: e.target.value}})}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Descrição</label>
|
||||
<textarea
|
||||
value={formData.info.description}
|
||||
onChange={(e) => setFormData({...formData, info: {...formData.info, description: e.target.value}})}
|
||||
rows={2}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{formData.info.items.map((item, index) => (
|
||||
<div key={index} className="p-6 bg-gray-50 dark:bg-white/5 rounded-xl border border-gray-200 dark:border-white/10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-bold text-gray-900 dark:text-white">Contato {index + 1}</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<IconSelector
|
||||
label="Ícone"
|
||||
value={item.icon}
|
||||
onChange={(icon) => {
|
||||
const newItems = [...formData.info.items];
|
||||
newItems[index].icon = icon;
|
||||
setFormData({...formData, info: {...formData.info, items: newItems}});
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Título</label>
|
||||
<input
|
||||
type="text"
|
||||
value={item.title}
|
||||
onChange={(e) => {
|
||||
const newItems = [...formData.info.items];
|
||||
newItems[index].title = e.target.value;
|
||||
setFormData({...formData, info: {...formData.info, items: newItems}});
|
||||
}}
|
||||
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>
|
||||
<textarea
|
||||
value={item.description}
|
||||
onChange={(e) => {
|
||||
const newItems = [...formData.info.items];
|
||||
newItems[index].description = e.target.value;
|
||||
setFormData({...formData, info: {...formData.info, items: newItems}});
|
||||
}}
|
||||
rows={3}
|
||||
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>
|
||||
<input
|
||||
type="text"
|
||||
value={item.link}
|
||||
onChange={(e) => {
|
||||
const newItems = [...formData.info.items];
|
||||
newItems[index].link = e.target.value;
|
||||
setFormData({...formData, info: {...formData.info, items: newItems}});
|
||||
}}
|
||||
placeholder="https://..."
|
||||
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>
|
||||
<input
|
||||
type="text"
|
||||
value={item.linkText}
|
||||
onChange={(e) => {
|
||||
const newItems = [...formData.info.items];
|
||||
newItems[index].linkText = e.target.value;
|
||||
setFormData({...formData, info: {...formData.info, items: newItems}});
|
||||
}}
|
||||
className="w-full px-4 py-3 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="fixed bottom-0 left-64 flex items-center justify-end gap-4 p-4 bg-white dark:bg-secondary border-t border-gray-200 dark:border-white/10 shadow-lg z-20" style={{ width: 'calc((100vw - 256px) * 0.3)' }}>
|
||||
<Link
|
||||
href="/admin/paginas"
|
||||
className="px-6 py-2.5 border border-gray-200 dark:border-white/10 text-gray-600 dark:text-gray-300 rounded-xl font-bold hover:bg-gray-50 dark:hover:bg-white/5 transition-colors text-sm"
|
||||
>
|
||||
Cancelar
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 bg-primary text-white rounded-xl font-bold hover:bg-orange-600 transition-colors shadow-lg shadow-primary/20 flex items-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed cursor-pointer text-sm"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<i className="ri-loader-4-line animate-spin"></i>
|
||||
Salvando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="ri-save-line"></i>
|
||||
Salvar
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Preview em Tempo Real - Coluna Direita Grande */}
|
||||
<div className="flex-1 overflow-y-auto bg-white dark:bg-secondary">
|
||||
<div className="sticky top-0 z-10 p-4 bg-gray-50 dark:bg-white/5 border-b border-gray-200 dark:border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<i className="ri-eye-line text-primary"></i>
|
||||
Preview em Tempo Real
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Visualização aproximada da página pública</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/contato"
|
||||
target="_blank"
|
||||
className="px-4 py-2 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 text-gray-700 dark:text-gray-300 rounded-lg font-medium hover:bg-gray-50 dark:hover:bg-white/5 transition-colors flex items-center gap-2 text-sm cursor-pointer"
|
||||
>
|
||||
<i className="ri-external-link-line"></i>
|
||||
Ver Página Real
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
{/* Hero Preview */}
|
||||
{activeTab === 'hero' && (
|
||||
<div id="preview-hero" className="space-y-4">
|
||||
<div className="inline-flex items-center gap-2 bg-primary/20 backdrop-blur-sm border border-primary/30 rounded-full px-4 py-1">
|
||||
<span className="w-2 h-2 rounded-full bg-primary animate-pulse"></span>
|
||||
<span className="text-sm font-bold text-primary uppercase tracking-wider">{formData.hero.pretitle}</span>
|
||||
</div>
|
||||
<h1 className="text-5xl font-bold font-headline text-secondary dark:text-white leading-tight">
|
||||
{formData.hero.title}
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300 max-w-2xl leading-relaxed">
|
||||
{formData.hero.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Preview */}
|
||||
{activeTab === 'info' && (
|
||||
<div id="preview-info" className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-primary font-bold tracking-wider uppercase mb-3">{formData.info.title}</h2>
|
||||
<h3 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-6">{formData.info.subtitle}</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-lg leading-relaxed">
|
||||
{formData.info.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{formData.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">
|
||||
<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} 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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1120
frontend/src/app/admin/paginas/home/page.tsx
Normal file
1120
frontend/src/app/admin/paginas/home/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
110
frontend/src/app/admin/paginas/page.tsx
Normal file
110
frontend/src/app/admin/paginas/page.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
|
||||
interface PageContent {
|
||||
id: string;
|
||||
slug: string;
|
||||
content: any;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export default function PagesList() {
|
||||
const [pages, setPages] = useState<PageContent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { error: showError } = useToast();
|
||||
|
||||
const pageDefinitions = [
|
||||
{
|
||||
title: 'Página Inicial',
|
||||
slug: 'home',
|
||||
desc: 'Banner principal, textos de destaque e chamadas.',
|
||||
icon: 'ri-home-4-line'
|
||||
},
|
||||
{
|
||||
title: 'Sobre Nós',
|
||||
slug: 'sobre',
|
||||
desc: 'História da empresa, missão, visão e valores.',
|
||||
icon: 'ri-team-line'
|
||||
},
|
||||
{
|
||||
title: 'Contato',
|
||||
slug: 'contato',
|
||||
desc: 'Endereço, telefones, emails e horário de funcionamento.',
|
||||
icon: 'ri-contacts-book-line'
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
fetchPages();
|
||||
}, []);
|
||||
|
||||
const fetchPages = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/pages');
|
||||
if (!response.ok) throw new Error('Erro ao carregar páginas');
|
||||
|
||||
const data = await response.json();
|
||||
setPages(data);
|
||||
} catch (err) {
|
||||
showError('Erro ao carregar páginas');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-2">Gerenciar Páginas</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">Edite o conteúdo estático das páginas do site.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{pageDefinitions.map((pageDef) => {
|
||||
const pageData = pages.find(p => p.slug === pageDef.slug);
|
||||
const lastUpdate = pageData
|
||||
? new Date(pageData.updatedAt).toLocaleDateString('pt-BR')
|
||||
: 'Não configurado';
|
||||
|
||||
return (
|
||||
<div key={pageDef.slug} className="bg-white dark:bg-secondary rounded-2xl border border-gray-200 dark:border-white/10 p-6 shadow-sm hover:shadow-md transition-all group">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-primary/10 text-primary flex items-center justify-center text-2xl group-hover:scale-110 transition-transform">
|
||||
<i className={pageDef.icon}></i>
|
||||
</div>
|
||||
<span className={`text-xs font-medium px-2 py-1 rounded-lg ${
|
||||
pageData
|
||||
? 'text-green-600 bg-green-100 dark:bg-green-900/30'
|
||||
: 'text-orange-600 bg-orange-100 dark:bg-orange-900/30'
|
||||
}`}>
|
||||
{pageData ? `Atualizado ${lastUpdate}` : 'Não configurado'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-bold text-secondary dark:text-white mb-2">{pageDef.title}</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm mb-6 h-10">{pageDef.desc}</p>
|
||||
|
||||
<Link
|
||||
href={`/admin/paginas/${pageDef.slug}`}
|
||||
className="block w-full py-3 text-center rounded-xl border border-gray-200 dark:border-white/10 font-bold text-gray-600 dark:text-gray-300 hover:bg-primary hover:text-white hover:border-primary transition-all"
|
||||
>
|
||||
Editar Conteúdo
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
643
frontend/src/app/admin/paginas/sobre/page.tsx
Normal file
643
frontend/src/app/admin/paginas/sobre/page.tsx
Normal file
@@ -0,0 +1,643 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
|
||||
// Ícones pré-definidos para seleção
|
||||
const AVAILABLE_ICONS = [
|
||||
// Pessoas e Equipe
|
||||
{ value: 'ri-team-line', label: 'Equipe', category: 'pessoas' },
|
||||
{ value: 'ri-user-star-line', label: 'Destaque', category: 'pessoas' },
|
||||
{ value: 'ri-user-follow-line', label: 'Seguir', category: 'pessoas' },
|
||||
{ value: 'ri-group-line', label: 'Grupo', category: 'pessoas' },
|
||||
|
||||
// Segurança
|
||||
{ value: 'ri-shield-check-line', label: 'Segurança', category: 'segurança' },
|
||||
{ value: 'ri-shield-star-line', label: 'Proteção Premium', category: 'segurança' },
|
||||
{ value: 'ri-lock-line', label: 'Cadeado', category: 'segurança' },
|
||||
{ value: 'ri-hard-hat-line', label: 'Capacete', category: 'segurança' },
|
||||
|
||||
// Serviços
|
||||
{ value: 'ri-service-line', label: 'Atendimento', category: 'serviço' },
|
||||
{ value: 'ri-customer-service-line', label: 'Suporte', category: 'serviço' },
|
||||
{ value: 'ri-tools-line', label: 'Ferramentas', category: 'serviço' },
|
||||
{ value: 'ri-settings-3-line', label: 'Engrenagem', category: 'serviço' },
|
||||
|
||||
// Transporte
|
||||
{ value: 'ri-car-line', label: 'Veículo', category: 'transporte' },
|
||||
{ value: 'ri-truck-line', label: 'Caminhão', category: 'transporte' },
|
||||
{ value: 'ri-bus-line', label: 'Ônibus', category: 'transporte' },
|
||||
{ value: 'ri-motorbike-line', label: 'Moto', category: 'transporte' },
|
||||
|
||||
// Documentos
|
||||
{ value: 'ri-file-list-3-line', label: 'Documentos', category: 'documentos' },
|
||||
{ value: 'ri-file-text-line', label: 'Arquivo', category: 'documentos' },
|
||||
{ value: 'ri-clipboard-line', label: 'Prancheta', category: 'documentos' },
|
||||
{ value: 'ri-contract-line', label: 'Contrato', category: 'documentos' },
|
||||
|
||||
// Conquistas
|
||||
{ value: 'ri-award-line', label: 'Prêmio', category: 'conquista' },
|
||||
{ value: 'ri-trophy-line', label: 'Troféu', category: 'conquista' },
|
||||
{ value: 'ri-medal-line', label: 'Medalha', category: 'conquista' },
|
||||
{ value: 'ri-vip-crown-line', label: 'Coroa', category: 'conquista' },
|
||||
|
||||
// Inovação
|
||||
{ value: 'ri-lightbulb-line', label: 'Ideia', category: 'inovação' },
|
||||
{ value: 'ri-flashlight-line', label: 'Lanterna', category: 'inovação' },
|
||||
{ value: 'ri-rocket-line', label: 'Foguete', category: 'inovação' },
|
||||
{ value: 'ri-flask-line', label: 'Experimento', category: 'inovação' },
|
||||
|
||||
// Status
|
||||
{ value: 'ri-checkbox-circle-line', label: 'Confirmado', category: 'status' },
|
||||
{ value: 'ri-check-double-line', label: 'Verificado', category: 'status' },
|
||||
{ value: 'ri-star-line', label: 'Estrela', category: 'status' },
|
||||
{ value: 'ri-thumb-up-line', label: 'Aprovado', category: 'status' },
|
||||
|
||||
// Dados
|
||||
{ value: 'ri-pie-chart-line', label: 'Gráfico Pizza', category: 'dados' },
|
||||
{ value: 'ri-bar-chart-line', label: 'Gráfico Barras', category: 'dados' },
|
||||
{ value: 'ri-line-chart-line', label: 'Gráfico Linha', category: 'dados' },
|
||||
{ value: 'ri-dashboard-line', label: 'Dashboard', category: 'dados' },
|
||||
|
||||
// Performance
|
||||
{ value: 'ri-speed-line', label: 'Velocidade', category: 'performance' },
|
||||
{ value: 'ri-timer-line', label: 'Cronômetro', category: 'performance' },
|
||||
{ value: 'ri-time-line', label: 'Relógio', category: 'performance' },
|
||||
{ value: 'ri-pulse-line', label: 'Pulso', category: 'performance' },
|
||||
|
||||
// Negócios
|
||||
{ value: 'ri-building-line', label: 'Empresa', category: 'negócios' },
|
||||
{ value: 'ri-briefcase-line', label: 'Maleta', category: 'negócios' },
|
||||
{ value: 'ri-money-dollar-circle-line', label: 'Dinheiro', category: 'negócios' },
|
||||
{ value: 'ri-hand-coin-line', label: 'Pagamento', category: 'negócios' },
|
||||
|
||||
// Cálculo
|
||||
{ value: 'ri-calculator-line', label: 'Calculadora', category: 'cálculo' },
|
||||
{ value: 'ri-percent-line', label: 'Porcentagem', category: 'cálculo' },
|
||||
{ value: 'ri-functions', label: 'Funções', category: 'cálculo' },
|
||||
|
||||
// Comunicação
|
||||
{ value: 'ri-message-3-line', label: 'Mensagem', category: 'comunicação' },
|
||||
{ value: 'ri-chat-3-line', label: 'Chat', category: 'comunicação' },
|
||||
{ value: 'ri-phone-line', label: 'Telefone', category: 'comunicação' },
|
||||
{ value: 'ri-mail-line', label: 'Email', category: 'comunicação' },
|
||||
|
||||
// Localização
|
||||
{ value: 'ri-map-pin-line', label: 'Localização', category: 'local' },
|
||||
{ value: 'ri-navigation-line', label: 'Navegação', category: 'local' },
|
||||
{ value: 'ri-roadster-line', label: 'Estrada', category: 'local' },
|
||||
{ value: 'ri-compass-line', label: 'Bússola', category: 'local' },
|
||||
];
|
||||
|
||||
interface IconSelectorProps {
|
||||
value: string;
|
||||
onChange: (icon: string) => void;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function IconSelector({ value, onChange, label }: IconSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filteredIcons = AVAILABLE_ICONS.filter(icon =>
|
||||
icon.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
icon.category.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{label}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
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 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<i className={`${value} text-2xl text-primary`}></i>
|
||||
<span className="text-sm">{AVAILABLE_ICONS.find(i => i.value === value)?.label || 'Selecionar ícone'}</span>
|
||||
</div>
|
||||
<i className={`ri-arrow-${isOpen ? 'up' : 'down'}-s-line text-gray-400`}></i>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 mt-2 w-full bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl shadow-xl">
|
||||
<div className="p-3 border-b border-gray-200 dark:border-white/10">
|
||||
<div className="relative">
|
||||
<i className="ri-search-line absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Buscar ícone..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg text-sm focus:outline-none focus:border-primary"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 grid grid-cols-4 gap-2 max-h-64 overflow-y-auto">
|
||||
{filteredIcons.map((icon) => (
|
||||
<button
|
||||
key={icon.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(icon.value);
|
||||
setIsOpen(false);
|
||||
setSearch('');
|
||||
}}
|
||||
className={`p-3 rounded-lg flex flex-col items-center gap-1 transition-all ${
|
||||
value === icon.value
|
||||
? 'bg-primary text-white'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-white/5 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
title={icon.label}
|
||||
>
|
||||
<i className={`${icon.value} text-2xl`}></i>
|
||||
<span className="text-[10px] text-center leading-tight">{icon.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{filteredIcons.length === 0 && (
|
||||
<div className="p-8 text-center text-gray-400 text-sm">
|
||||
Nenhum ícone encontrado
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ValueItem {
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface AboutContent {
|
||||
hero: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
history: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
paragraph1: string;
|
||||
paragraph2: string;
|
||||
};
|
||||
values: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
items: ValueItem[];
|
||||
};
|
||||
}
|
||||
|
||||
export default function EditAboutPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState('hero');
|
||||
const { success, error: showError } = useToast();
|
||||
|
||||
const scrollToPreview = (sectionId: string) => {
|
||||
const element = document.getElementById(`preview-${sectionId}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (tab: string) => {
|
||||
setActiveTab(tab);
|
||||
setTimeout(() => scrollToPreview(tab), 100);
|
||||
};
|
||||
|
||||
const [formData, setFormData] = useState<AboutContent>({
|
||||
hero: {
|
||||
title: 'Sobre a Occto Engenharia',
|
||||
subtitle: 'Excelência técnica, compromisso e inovação em cada projeto.'
|
||||
},
|
||||
history: {
|
||||
title: 'Nossa História',
|
||||
subtitle: 'Tradição e Inovação em Engenharia',
|
||||
paragraph1: 'Com mais de 15 anos de experiência no mercado, a Occto Engenharia se consolidou como referência em soluções técnicas. Nossa equipe multidisciplinar está preparada para atender demandas complexas com excelência e agilidade.',
|
||||
paragraph2: 'Ao longo dos anos, desenvolvemos projetos que transformaram a realidade de nossos clientes, sempre pautados pela ética, responsabilidade e compromisso com a qualidade.'
|
||||
},
|
||||
values: {
|
||||
title: 'Nossos Valores',
|
||||
subtitle: 'O que nos move',
|
||||
items: [
|
||||
{ icon: 'ri-medal-line', title: 'Qualidade', description: 'Compromisso com a excelência em todos os nossos serviços e entregas.' },
|
||||
{ icon: 'ri-shake-hands-line', title: 'Transparência', description: 'Comunicação clara e honesta em todas as etapas do projeto.' },
|
||||
{ icon: 'ri-leaf-line', title: 'Sustentabilidade', description: 'Soluções que respeitam o meio ambiente e as futuras gerações.' }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchPageContent();
|
||||
}, []);
|
||||
|
||||
const fetchPageContent = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/pages/about');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.content) {
|
||||
setFormData(prevData => ({
|
||||
hero: data.content.hero || prevData.hero,
|
||||
history: data.content.history || prevData.history,
|
||||
values: data.content.values || prevData.values
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Nenhum conteúdo salvo ainda, usando padrão');
|
||||
} finally {
|
||||
setInitialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/pages/about', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: formData })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Erro ao salvar');
|
||||
|
||||
success('Conteúdo da página Sobre atualizado com sucesso!');
|
||||
} catch (err) {
|
||||
showError('Erro ao salvar alterações');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (initialLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<style jsx global>{`
|
||||
main { padding: 0 !important; }
|
||||
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
||||
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
`}</style>
|
||||
<div className="fixed top-20 bottom-0 left-64 right-0 flex gap-0 bg-gray-50 dark:bg-tertiary">
|
||||
{/* Formulário de Edição - Coluna Esquerda 30% */}
|
||||
<div className="w-[30%] shrink-0 overflow-y-auto bg-white dark:bg-secondary relative">
|
||||
<div className="absolute top-0 right-0 bottom-0 w-px bg-gray-200 dark:bg-white/10"></div>
|
||||
|
||||
<div className="p-6 border-b border-gray-200 dark:border-white/10">
|
||||
<h1 className="text-2xl font-bold">Editar Página Sobre</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Personalize o conteúdo institucional
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="relative border-b border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const container = document.getElementById('tabs-container');
|
||||
if (container) container.scrollLeft -= 200;
|
||||
}}
|
||||
className="absolute left-0 z-10 w-10 h-full bg-white dark:bg-secondary border-r border-gray-200 dark:border-white/10 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-white/5 transition-all cursor-pointer"
|
||||
>
|
||||
<i className="ri-arrow-left-s-line text-xl text-gray-600 dark:text-gray-400"></i>
|
||||
</button>
|
||||
|
||||
<div id="tabs-container" className="flex gap-2 p-4 overflow-x-auto scrollbar-hide scroll-smooth px-12">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabChange('hero')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors cursor-pointer ${
|
||||
activeTab === 'hero'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-background hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
Banner
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabChange('history')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors cursor-pointer ${
|
||||
activeTab === 'history'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-background hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
História
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabChange('values')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors cursor-pointer ${
|
||||
activeTab === 'values'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-background hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
Valores (3)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const container = document.getElementById('tabs-container');
|
||||
if (container) container.scrollLeft += 200;
|
||||
}}
|
||||
className="absolute right-0 z-10 w-10 h-full bg-white dark:bg-secondary border-l border-gray-200 dark:border-white/10 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-white/5 transition-all cursor-pointer"
|
||||
>
|
||||
<i className="ri-arrow-right-s-line text-xl text-gray-600 dark:text-gray-400"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6 pb-20">
|
||||
|
||||
{/* Hero Section */}
|
||||
{activeTab === 'hero' && (
|
||||
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
|
||||
<h2 className="text-xl font-bold text-secondary dark:text-white mb-6 flex items-center gap-2">
|
||||
<i className="ri-layout-top-line text-primary"></i>
|
||||
Banner Principal
|
||||
</h2>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.hero.title}
|
||||
onChange={(e) => setFormData({...formData, hero: {...formData.hero, title: e.target.value}})}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Subtítulo</label>
|
||||
<textarea
|
||||
value={formData.hero.subtitle}
|
||||
onChange={(e) => setFormData({...formData, hero: {...formData.hero, subtitle: e.target.value}})}
|
||||
rows={2}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History Section */}
|
||||
{activeTab === 'history' && (
|
||||
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
|
||||
<h2 className="text-xl font-bold text-secondary dark:text-white mb-6 flex items-center gap-2">
|
||||
<i className="ri-history-line text-primary"></i>
|
||||
Nossa História
|
||||
</h2>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.history.title}
|
||||
onChange={(e) => setFormData({...formData, history: {...formData.history, title: e.target.value}})}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.history.subtitle}
|
||||
onChange={(e) => setFormData({...formData, history: {...formData.history, subtitle: e.target.value}})}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Parágrafo 1</label>
|
||||
<textarea
|
||||
value={formData.history.paragraph1}
|
||||
onChange={(e) => setFormData({...formData, history: {...formData.history, paragraph1: e.target.value}})}
|
||||
rows={4}
|
||||
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>
|
||||
<textarea
|
||||
value={formData.history.paragraph2}
|
||||
onChange={(e) => setFormData({...formData, history: {...formData.history, paragraph2: e.target.value}})}
|
||||
rows={4}
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Values Section */}
|
||||
{activeTab === 'values' && (
|
||||
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
|
||||
<h2 className="text-xl font-bold text-secondary dark:text-white mb-6 flex items-center gap-2">
|
||||
<i className="ri-heart-line text-primary"></i>
|
||||
Nossos Valores
|
||||
</h2>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.values.title}
|
||||
onChange={(e) => setFormData({...formData, values: {...formData.values, title: e.target.value}})}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título da Seção</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.values.subtitle}
|
||||
onChange={(e) => setFormData({...formData, values: {...formData.values, subtitle: e.target.value}})}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{formData.values.items.map((item, index) => (
|
||||
<div key={index} className="p-6 bg-gray-50 dark:bg-white/5 rounded-xl border border-gray-200 dark:border-white/10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-bold text-gray-900 dark:text-white">Valor {index + 1}</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<IconSelector
|
||||
label="Ícone"
|
||||
value={item.icon}
|
||||
onChange={(newIcon) => {
|
||||
const newItems = [...formData.values.items];
|
||||
newItems[index].icon = newIcon;
|
||||
setFormData({...formData, values: {...formData.values, items: newItems}});
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Título</label>
|
||||
<input
|
||||
type="text"
|
||||
value={item.title}
|
||||
onChange={(e) => {
|
||||
const newItems = [...formData.values.items];
|
||||
newItems[index].title = e.target.value;
|
||||
setFormData({...formData, values: {...formData.values, items: newItems}});
|
||||
}}
|
||||
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>
|
||||
<textarea
|
||||
value={item.description}
|
||||
onChange={(e) => {
|
||||
const newItems = [...formData.values.items];
|
||||
newItems[index].description = e.target.value;
|
||||
setFormData({...formData, values: {...formData.values, items: newItems}});
|
||||
}}
|
||||
rows={2}
|
||||
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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="fixed bottom-0 left-64 flex items-center justify-end gap-4 p-4 bg-white dark:bg-secondary border-t border-gray-200 dark:border-white/10 shadow-lg z-20" style={{ width: 'calc((100vw - 256px) * 0.3)' }}>
|
||||
<Link
|
||||
href="/admin/paginas"
|
||||
className="px-6 py-2.5 border border-gray-200 dark:border-white/10 text-gray-600 dark:text-gray-300 rounded-xl font-bold hover:bg-gray-50 dark:hover:bg-white/5 transition-colors text-sm"
|
||||
>
|
||||
Cancelar
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 bg-primary text-white rounded-xl font-bold hover:bg-orange-600 transition-colors shadow-lg shadow-primary/20 flex items-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed cursor-pointer text-sm"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<i className="ri-loader-4-line animate-spin"></i>
|
||||
Salvando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="ri-save-line"></i>
|
||||
Salvar
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Preview em Tempo Real - Coluna Direita Grande */}
|
||||
<div className="flex-1 overflow-y-auto bg-white dark:bg-secondary">
|
||||
<div className="sticky top-0 z-10 p-4 bg-gray-50 dark:bg-white/5 border-b border-gray-200 dark:border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<i className="ri-eye-line text-primary"></i>
|
||||
Preview em Tempo Real
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Visualização aproximada da página pública</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/sobre"
|
||||
target="_blank"
|
||||
className="px-4 py-2 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 text-gray-700 dark:text-gray-300 rounded-lg font-medium hover:bg-gray-50 dark:hover:bg-white/5 transition-colors flex items-center gap-2 text-sm cursor-pointer"
|
||||
>
|
||||
<i className="ri-external-link-line"></i>
|
||||
Ver Página Real
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
{/* Hero Preview */}
|
||||
{activeTab === 'hero' && (
|
||||
<div id="preview-hero" className="text-center space-y-4">
|
||||
<h1 className="text-5xl font-bold font-headline text-secondary dark:text-white leading-tight">
|
||||
{formData.hero.title}
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
|
||||
{formData.hero.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History Preview */}
|
||||
{activeTab === 'history' && (
|
||||
<div id="preview-history" className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-primary font-bold tracking-wider uppercase mb-2">{formData.history.title}</h2>
|
||||
<h3 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-6">{formData.history.subtitle}</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4 leading-relaxed">
|
||||
{formData.history.paragraph1}
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
{formData.history.paragraph2}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Values Preview */}
|
||||
{activeTab === 'values' && (
|
||||
<div id="preview-values" className="space-y-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-primary font-bold tracking-wider uppercase mb-2">{formData.values.title}</h2>
|
||||
<h3 className="text-3xl font-bold font-headline text-secondary dark:text-white">{formData.values.subtitle}</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{formData.values.items.map((item, index) => (
|
||||
<div key={index} 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={`${item.icon} text-2xl`}></i>
|
||||
</div>
|
||||
<h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-3">{item.title}</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
200
frontend/src/app/admin/projetos/novo/page.tsx
Normal file
200
frontend/src/app/admin/projetos/novo/page.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function NewProject() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
category: '',
|
||||
client: '',
|
||||
status: 'active',
|
||||
description: '',
|
||||
date: ''
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
console.log('Project data:', formData);
|
||||
setLoading(false);
|
||||
router.push('/admin/projetos');
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<Link
|
||||
href="/admin/projetos"
|
||||
className="w-10 h-10 rounded-xl bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 flex items-center justify-center text-gray-500 hover:text-primary hover:border-primary transition-colors shadow-sm"
|
||||
>
|
||||
<i className="ri-arrow-left-line text-xl"></i>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-1">Novo Projeto</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">Adicione um novo projeto ao portfólio.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Basic Info */}
|
||||
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
|
||||
<h2 className="text-xl font-bold text-secondary dark:text-white mb-6 flex items-center gap-2">
|
||||
<i className="ri-information-line text-primary"></i>
|
||||
Informações Básicas
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título do Projeto</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({...formData, title: e.target.value})}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
placeholder="Ex: Adequação de Frota Coca-Cola"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Categoria</label>
|
||||
<select
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({...formData, category: e.target.value})}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all appearance-none cursor-pointer"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione uma categoria</option>
|
||||
<option value="veicular">Engenharia Veicular</option>
|
||||
<option value="mecanica">Projetos Mecânicos</option>
|
||||
<option value="laudos">Laudos e Inspeções</option>
|
||||
<option value="seguranca">Segurança do Trabalho</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Cliente</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.client}
|
||||
onChange={(e) => setFormData({...formData, client: e.target.value})}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
placeholder="Ex: Coca-Cola FEMSA"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Data de Conclusão</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.date}
|
||||
onChange={(e) => setFormData({...formData, date: e.target.value})}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Status</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({...formData, status: e.target.value})}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="active">Concluído</option>
|
||||
<option value="pending">Em Andamento</option>
|
||||
<option value="draft">Rascunho</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Descrição Detalhada</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({...formData, description: e.target.value})}
|
||||
rows={6}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all resize-none"
|
||||
placeholder="Descreva os detalhes técnicos, desafios e soluções do projeto..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Media */}
|
||||
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
|
||||
<h2 className="text-xl font-bold text-secondary dark:text-white mb-6 flex items-center gap-2">
|
||||
<i className="ri-image-line text-primary"></i>
|
||||
Mídia
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Imagem de Capa</label>
|
||||
<div className="border-2 border-dashed border-gray-300 dark:border-white/20 rounded-xl p-8 text-center hover:border-primary dark:hover:border-primary transition-colors cursor-pointer bg-gray-50 dark:bg-white/5">
|
||||
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center text-primary mx-auto mb-4">
|
||||
<i className="ri-upload-cloud-2-line text-3xl"></i>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-300 font-medium mb-1">Clique para fazer upload ou arraste e solte</p>
|
||||
<p className="text-xs text-gray-400">PNG, JPG ou WEBP (Max. 2MB)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Galeria de Fotos</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="aspect-square border-2 border-dashed border-gray-300 dark:border-white/20 rounded-xl flex flex-col items-center justify-center text-gray-400 hover:text-primary hover:border-primary transition-colors cursor-pointer bg-gray-50 dark:bg-white/5">
|
||||
<i className="ri-add-line text-3xl mb-2"></i>
|
||||
<span className="text-xs font-bold">Adicionar</span>
|
||||
</div>
|
||||
{/* Placeholders for uploaded images */}
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="aspect-square rounded-xl bg-gray-200 dark:bg-white/10 relative group overflow-hidden">
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||
<button type="button" className="w-8 h-8 rounded-lg bg-white/20 hover:bg-white/40 text-white flex items-center justify-center backdrop-blur-sm transition-colors">
|
||||
<i className="ri-delete-bin-line"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-4 pt-4">
|
||||
<Link
|
||||
href="/admin/projetos"
|
||||
className="px-8 py-3 border border-gray-200 dark:border-white/10 text-gray-600 dark:text-gray-300 rounded-xl font-bold hover:bg-gray-50 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-8 py-3 bg-primary text-white rounded-xl font-bold hover:bg-orange-600 transition-colors shadow-lg shadow-primary/20 flex items-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<i className="ri-loader-4-line animate-spin"></i>
|
||||
Salvando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="ri-save-line"></i>
|
||||
Salvar Projeto
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
frontend/src/app/admin/projetos/page.tsx
Normal file
78
frontend/src/app/admin/projetos/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function ProjectsList() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-2">Projetos</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">Gerencie os projetos exibidos no portfólio.</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/projetos/novo"
|
||||
className="px-6 py-3 bg-primary text-white rounded-xl font-bold hover:bg-orange-600 transition-colors shadow-lg shadow-primary/20 flex items-center gap-2"
|
||||
>
|
||||
<i className="ri-add-line text-xl"></i>
|
||||
Novo Projeto
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-secondary rounded-xl border border-gray-200 dark:border-white/10 shadow-sm overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-white/5 border-b border-gray-200 dark:border-white/10">
|
||||
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Projeto</th>
|
||||
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Categoria</th>
|
||||
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Cliente</th>
|
||||
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Status</th>
|
||||
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider text-right">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-white/5">
|
||||
{[
|
||||
{ title: 'Adequação Coca-Cola', cat: 'Engenharia Veicular', client: 'Coca-Cola FEMSA', status: 'Concluído' },
|
||||
{ title: 'Laudo Guindaste Articulado', cat: 'Inspeção Técnica', client: 'Logística Express', status: 'Concluído' },
|
||||
{ title: 'Dispositivo de Içamento', cat: 'Projeto Mecânico', client: 'Metalúrgica ABC', status: 'Em Andamento' },
|
||||
].map((project, index) => (
|
||||
<tr key={index} className="hover:bg-gray-50 dark:hover:bg-white/5 transition-colors group">
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-lg bg-gray-200 dark:bg-white/10 overflow-hidden shrink-0">
|
||||
<div className="w-full h-full bg-gray-300 dark:bg-white/20"></div>
|
||||
</div>
|
||||
<span className="font-bold text-secondary dark:text-white">{project.title}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-gray-600 dark:text-gray-400">{project.cat}</td>
|
||||
<td className="p-4 text-gray-600 dark:text-gray-400">{project.client}</td>
|
||||
<td className="p-4">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-bold ${
|
||||
project.status === 'Concluído'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
}`}>
|
||||
{project.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button className="w-8 h-8 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10 flex items-center justify-center text-gray-500 hover:text-primary transition-colors cursor-pointer" title="Editar">
|
||||
<i className="ri-pencil-line"></i>
|
||||
</button>
|
||||
<button className="w-8 h-8 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center justify-center text-gray-500 hover:text-red-500 transition-colors cursor-pointer" title="Excluir">
|
||||
<i className="ri-delete-bin-line"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
frontend/src/app/admin/servicos/novo/page.tsx
Normal file
151
frontend/src/app/admin/servicos/novo/page.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function NewService() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
icon: 'ri-settings-3-line',
|
||||
status: 'active',
|
||||
shortDescription: '',
|
||||
fullDescription: ''
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
console.log('Service data:', formData);
|
||||
setLoading(false);
|
||||
router.push('/admin/servicos');
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<Link
|
||||
href="/admin/servicos"
|
||||
className="w-10 h-10 rounded-xl bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 flex items-center justify-center text-gray-500 hover:text-primary hover:border-primary transition-colors shadow-sm"
|
||||
>
|
||||
<i className="ri-arrow-left-line text-xl"></i>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-1">Novo Serviço</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">Adicione um novo serviço ao catálogo.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
|
||||
<h2 className="text-xl font-bold text-secondary dark:text-white mb-6 flex items-center gap-2">
|
||||
<i className="ri-information-line text-primary"></i>
|
||||
Informações do Serviço
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título do Serviço</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({...formData, title: e.target.value})}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
placeholder="Ex: Engenharia Veicular"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Ícone (Remix Icon Class)</label>
|
||||
<div className="flex gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-primary/10 text-primary flex items-center justify-center text-xl shrink-0">
|
||||
<i className={formData.icon}></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.icon}
|
||||
onChange={(e) => setFormData({...formData, icon: e.target.value})}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
placeholder="Ex: ri-truck-line"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Consulte a lista de ícones em <a href="https://remixicon.com" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">remixicon.com</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Status</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({...formData, status: e.target.value})}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="active">Ativo</option>
|
||||
<option value="inactive">Inativo</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Descrição Curta (Resumo)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.shortDescription}
|
||||
onChange={(e) => setFormData({...formData, shortDescription: e.target.value})}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
placeholder="Breve descrição para os cards da home..."
|
||||
maxLength={150}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1 text-right">{formData.shortDescription.length}/150</p>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Descrição Completa</label>
|
||||
<textarea
|
||||
value={formData.fullDescription}
|
||||
onChange={(e) => setFormData({...formData, fullDescription: e.target.value})}
|
||||
rows={6}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all resize-none"
|
||||
placeholder="Detalhamento completo do serviço..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-4 pt-4">
|
||||
<Link
|
||||
href="/admin/servicos"
|
||||
className="px-8 py-3 border border-gray-200 dark:border-white/10 text-gray-600 dark:text-gray-300 rounded-xl font-bold hover:bg-gray-50 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-8 py-3 bg-primary text-white rounded-xl font-bold hover:bg-orange-600 transition-colors shadow-lg shadow-primary/20 flex items-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<i className="ri-loader-4-line animate-spin"></i>
|
||||
Salvando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="ri-save-line"></i>
|
||||
Salvar Serviço
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
frontend/src/app/admin/servicos/page.tsx
Normal file
76
frontend/src/app/admin/servicos/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function ServicesList() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-2">Serviços</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">Gerencie os serviços oferecidos pela empresa.</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/servicos/novo"
|
||||
className="px-6 py-3 bg-primary text-white rounded-xl font-bold hover:bg-orange-600 transition-colors shadow-lg shadow-primary/20 flex items-center gap-2"
|
||||
>
|
||||
<i className="ri-add-line text-xl"></i>
|
||||
Novo Serviço
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-secondary rounded-xl border border-gray-200 dark:border-white/10 shadow-sm overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-white/5 border-b border-gray-200 dark:border-white/10">
|
||||
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Ícone</th>
|
||||
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Título</th>
|
||||
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Descrição Curta</th>
|
||||
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Status</th>
|
||||
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider text-right">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-white/5">
|
||||
{[
|
||||
{ icon: 'ri-truck-line', title: 'Engenharia Veicular', desc: 'Homologação e regularização de veículos modificados.', status: 'Ativo' },
|
||||
{ icon: 'ri-tools-line', title: 'Projetos Mecânicos', desc: 'Desenvolvimento de máquinas e equipamentos industriais.', status: 'Ativo' },
|
||||
{ icon: 'ri-file-list-3-line', title: 'Laudos Técnicos', desc: 'Vistorias, perícias e emissão de ART.', status: 'Ativo' },
|
||||
{ icon: 'ri-shield-check-line', title: 'Segurança do Trabalho', desc: 'Consultoria em normas regulamentadoras (NRs).', status: 'Inativo' },
|
||||
].map((service, index) => (
|
||||
<tr key={index} className="hover:bg-gray-50 dark:hover:bg-white/5 transition-colors group">
|
||||
<td className="p-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center text-xl">
|
||||
<i className={service.icon}></i>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 font-bold text-secondary dark:text-white">{service.title}</td>
|
||||
<td className="p-4 text-gray-600 dark:text-gray-400 max-w-xs truncate">{service.desc}</td>
|
||||
<td className="p-4">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-bold ${
|
||||
service.status === 'Ativo'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400'
|
||||
}`}>
|
||||
{service.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button className="w-8 h-8 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10 flex items-center justify-center text-gray-500 hover:text-primary transition-colors cursor-pointer" title="Editar">
|
||||
<i className="ri-pencil-line"></i>
|
||||
</button>
|
||||
<button className="w-8 h-8 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center justify-center text-gray-500 hover:text-red-500 transition-colors cursor-pointer" title="Excluir">
|
||||
<i className="ri-delete-bin-line"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
332
frontend/src/app/admin/usuarios/[id]/page.tsx
Normal file
332
frontend/src/app/admin/usuarios/[id]/page.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { useConfirm } from '@/contexts/ConfirmContext';
|
||||
|
||||
export default function EditUserPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const userId = params.id as string;
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
const [currentAvatar, setCurrentAvatar] = useState<string | null>(null);
|
||||
const { success, error: showError } = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
fetchUser();
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Erro ao buscar usuário');
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
setFormData({
|
||||
name: data.name || '',
|
||||
email: data.email || '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
setCurrentAvatar(data.avatar || null);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar usuário:', error);
|
||||
setError('Erro ao carregar dados do usuário');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setAvatarFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setAvatarPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAvatar = async () => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Remover Foto',
|
||||
message: 'Deseja remover a foto de perfil?',
|
||||
confirmText: 'Remover',
|
||||
cancelText: 'Cancelar',
|
||||
type: 'warning',
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}/avatar`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setCurrentAvatar(null);
|
||||
setAvatarPreview(null);
|
||||
setAvatarFile(null);
|
||||
success('Foto removida com sucesso!');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao remover avatar:', error);
|
||||
showError('Erro ao remover avatar');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// Validações
|
||||
if (!formData.name || !formData.email) {
|
||||
setError('Preencha todos os campos obrigatórios');
|
||||
return;
|
||||
}
|
||||
|
||||
// Se está alterando a senha
|
||||
if (formData.password || formData.confirmPassword) {
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError('As senhas não coincidem');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.password.length < 6) {
|
||||
setError('A senha deve ter no mínimo 6 caracteres');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const payload: any = {
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
};
|
||||
|
||||
// Só envia a senha se foi preenchida
|
||||
if (formData.password) {
|
||||
payload.password = formData.password;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || 'Erro ao atualizar usuário');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload avatar if exists
|
||||
if (avatarFile) {
|
||||
try {
|
||||
const avatarFormData = new FormData();
|
||||
avatarFormData.append('avatar', avatarFile);
|
||||
avatarFormData.append('userId', userId);
|
||||
|
||||
await fetch('/api/users/avatar', {
|
||||
method: 'POST',
|
||||
body: avatarFormData,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Erro ao fazer upload do avatar:', err);
|
||||
}
|
||||
}
|
||||
|
||||
router.push('/admin/usuarios');
|
||||
} catch (err) {
|
||||
setError('Erro ao conectar com o servidor');
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<i className="ri-loader-4-line animate-spin text-4xl text-primary"></i>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href="/admin/usuarios"
|
||||
className="text-primary hover:text-orange-600 transition-colors inline-flex items-center gap-2 mb-4"
|
||||
>
|
||||
<i className="ri-arrow-left-line"></i>
|
||||
Voltar para Usuários
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-2">Editar Usuário</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">Atualize as informações do usuário administrador</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-secondary rounded-xl shadow-sm border border-gray-100 dark:border-white/10 p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl">
|
||||
<p className="text-sm text-red-600 dark:text-red-400 flex items-center gap-2">
|
||||
<i className="ri-error-warning-line"></i>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Foto de Perfil
|
||||
</label>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="w-24 h-24 rounded-full overflow-hidden bg-gray-100 dark:bg-white/5 flex items-center justify-center">
|
||||
{avatarPreview ? (
|
||||
<img src={avatarPreview} alt="Preview" className="w-full h-full object-cover" />
|
||||
) : currentAvatar ? (
|
||||
<img src={currentAvatar} alt="Avatar atual" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<i className="ri-user-3-line text-4xl text-gray-400"></i>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="cursor-pointer inline-flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-white/5 hover:bg-gray-200 dark:hover:bg-white/10 border border-gray-300 dark:border-white/10 rounded-lg transition-colors">
|
||||
<i className="ri-upload-2-line"></i>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Alterar Foto</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={handleAvatarChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
{(currentAvatar || avatarPreview) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveAvatar}
|
||||
className="ml-2 text-sm text-red-500 hover:text-red-600"
|
||||
>
|
||||
Remover
|
||||
</button>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">JPEG, PNG ou WEBP (máx. 5MB)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Nome Completo *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
E-mail *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-white/10 pt-6">
|
||||
<h3 className="text-lg font-bold text-secondary dark:text-white mb-4">Alterar Senha (opcional)</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">Deixe em branco se não quiser alterar a senha</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Nova Senha
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
placeholder="••••••••"
|
||||
minLength={6}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Mínimo de 6 caracteres</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Confirmar Nova Senha
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex-1 py-3 bg-primary text-white rounded-xl font-bold hover:bg-orange-600 transition-colors disabled:opacity-70 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<i className="ri-loader-4-line animate-spin"></i>
|
||||
Salvando...
|
||||
</span>
|
||||
) : (
|
||||
'Salvar Alterações'
|
||||
)}
|
||||
</button>
|
||||
<Link
|
||||
href="/admin/usuarios"
|
||||
className="px-8 py-3 border border-gray-300 dark:border-white/10 text-gray-700 dark:text-gray-300 rounded-xl font-bold hover:bg-gray-50 dark:hover:bg-white/5 transition-colors text-center"
|
||||
>
|
||||
Cancelar
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
249
frontend/src/app/admin/usuarios/novo/page.tsx
Normal file
249
frontend/src/app/admin/usuarios/novo/page.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
|
||||
export default function NewUserPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
const { success, error: showError } = useToast();
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setAvatarFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setAvatarPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAvatar = () => {
|
||||
setAvatarFile(null);
|
||||
setAvatarPreview(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// Validações
|
||||
if (!formData.name || !formData.email || !formData.password) {
|
||||
setError('Preencha todos os campos obrigatórios');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError('As senhas não coincidem');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.password.length < 6) {
|
||||
setError('A senha deve ter no mínimo 6 caracteres');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || 'Erro ao criar usuário');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload avatar if exists
|
||||
if (avatarFile && data.id) {
|
||||
try {
|
||||
const avatarFormData = new FormData();
|
||||
avatarFormData.append('avatar', avatarFile);
|
||||
avatarFormData.append('userId', data.id);
|
||||
|
||||
await fetch('/api/users/avatar', {
|
||||
method: 'POST',
|
||||
body: avatarFormData,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Erro ao fazer upload do avatar:', err);
|
||||
}
|
||||
}
|
||||
|
||||
router.push('/admin/usuarios');
|
||||
} catch (err) {
|
||||
setError('Erro ao conectar com o servidor');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href="/admin/usuarios"
|
||||
className="text-primary hover:text-orange-600 transition-colors inline-flex items-center gap-2 mb-4"
|
||||
>
|
||||
<i className="ri-arrow-left-line"></i>
|
||||
Voltar para Usuários
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-2">Novo Usuário</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">Adicione um novo usuário administrador ao sistema</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-secondary rounded-xl shadow-sm border border-gray-100 dark:border-white/10 p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl">
|
||||
<p className="text-sm text-red-600 dark:text-red-400 flex items-center gap-2">
|
||||
<i className="ri-error-warning-line"></i>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Foto de Perfil
|
||||
</label>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="w-24 h-24 rounded-full overflow-hidden bg-gray-100 dark:bg-white/5 flex items-center justify-center">
|
||||
{avatarPreview ? (
|
||||
<img src={avatarPreview} alt="Preview" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<i className="ri-user-3-line text-4xl text-gray-400"></i>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="cursor-pointer inline-flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-white/5 hover:bg-gray-200 dark:hover:bg-white/10 border border-gray-300 dark:border-white/10 rounded-lg transition-colors">
|
||||
<i className="ri-upload-2-line"></i>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Escolher Foto</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={handleAvatarChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
{avatarPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveAvatar}
|
||||
className="ml-2 text-sm text-red-500 hover:text-red-600"
|
||||
>
|
||||
Remover
|
||||
</button>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">JPEG, PNG ou WEBP (máx. 5MB)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Nome Completo *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
placeholder="João da Silva"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
E-mail *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
placeholder="usuario@occto.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Senha *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
placeholder="••••••••"
|
||||
minLength={6}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Mínimo de 6 caracteres</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Confirmar Senha *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 py-3 bg-primary text-white rounded-xl font-bold hover:bg-orange-600 transition-colors disabled:opacity-70 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<i className="ri-loader-4-line animate-spin"></i>
|
||||
Criando...
|
||||
</span>
|
||||
) : (
|
||||
'Criar Usuário'
|
||||
)}
|
||||
</button>
|
||||
<Link
|
||||
href="/admin/usuarios"
|
||||
className="px-8 py-3 border border-gray-300 dark:border-white/10 text-gray-700 dark:text-gray-300 rounded-xl font-bold hover:bg-gray-50 dark:hover:bg-white/5 transition-colors text-center"
|
||||
>
|
||||
Cancelar
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
frontend/src/app/admin/usuarios/page.tsx
Normal file
157
frontend/src/app/admin/usuarios/page.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { useConfirm } from '@/contexts/ConfirmContext';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
avatar: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { success, error } = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/users');
|
||||
const data = await response.json();
|
||||
setUsers(data);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar usuários:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Excluir Usuário',
|
||||
message: 'Tem certeza que deseja excluir este usuário? Esta ação não pode ser desfeita.',
|
||||
confirmText: 'Excluir',
|
||||
cancelText: 'Cancelar',
|
||||
type: 'danger',
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${id}`, { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
error(data.error || 'Erro ao excluir usuário');
|
||||
return;
|
||||
}
|
||||
|
||||
success('Usuário excluído com sucesso!');
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
console.error('Erro ao excluir usuário:', err);
|
||||
error('Erro ao conectar com o servidor');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<i className="ri-loader-4-line animate-spin text-4xl text-primary"></i>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-2">Usuários</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">Gerencie os usuários administradores do sistema</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/usuarios/novo"
|
||||
className="px-6 py-3 bg-primary text-white rounded-lg font-bold hover:bg-orange-600 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<i className="ri-user-add-line"></i>
|
||||
Novo Usuário
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-secondary rounded-xl shadow-sm border border-gray-100 dark:border-white/10 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-white/5 border-b border-gray-200 dark:border-white/10">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Usuário</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Email</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Criado em</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-bold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-white/10">
|
||||
{users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
|
||||
<i className="ri-user-line text-4xl mb-2 block"></i>
|
||||
Nenhum usuário encontrado
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-white/5 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full overflow-hidden bg-gray-100 dark:bg-white/5 shrink-0">
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} alt={user.name || 'Avatar'} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
||||
<i className="ri-user-3-line text-xl"></i>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-medium text-secondary dark:text-white">{user.name || 'Sem nome'}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-600 dark:text-gray-400">{user.email}</td>
|
||||
<td className="px-6 py-4 text-gray-600 dark:text-gray-400">
|
||||
{new Date(user.createdAt).toLocaleDateString('pt-BR')}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link
|
||||
href={`/admin/usuarios/${user.id}`}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
|
||||
title="Editar"
|
||||
>
|
||||
<i className="ri-edit-line text-lg"></i>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||
title="Excluir"
|
||||
>
|
||||
<i className="ri-delete-bin-line text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
179
frontend/src/app/api/auth/avatar/route.ts
Normal file
179
frontend/src/app/api/auth/avatar/route.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { minioClient, ensureBucketExists } from '@/lib/minio';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
const BUCKET_NAME = process.env.MINIO_BUCKET_NAME || 'occto-images';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Ensure bucket exists
|
||||
await ensureBucketExists();
|
||||
|
||||
const token = request.cookies.get('auth_token')?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Não autenticado' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify JWT token
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { userId: string };
|
||||
|
||||
// Get form data
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('avatar') as File;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Nenhum arquivo enviado' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/jpg'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Tipo de arquivo inválido. Use JPEG, PNG ou WEBP' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
const maxSize = 5 * 1024 * 1024; // 5MB
|
||||
if (file.size > maxSize) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Arquivo muito grande. Tamanho máximo: 5MB' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const fileExtension = file.name.split('.').pop();
|
||||
const fileName = `avatars/${decoded.userId}/${uuidv4()}.${fileExtension}`;
|
||||
|
||||
// Convert file to buffer
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// Upload to MinIO
|
||||
await minioClient.putObject(
|
||||
BUCKET_NAME,
|
||||
fileName,
|
||||
buffer,
|
||||
buffer.length,
|
||||
{
|
||||
'Content-Type': file.type,
|
||||
}
|
||||
);
|
||||
|
||||
// Generate public URL
|
||||
const protocol = process.env.MINIO_USE_SSL === 'true' ? 'https' : 'http';
|
||||
const endpoint = process.env.MINIO_ENDPOINT || 'localhost';
|
||||
const port = process.env.MINIO_PORT || '9000';
|
||||
const avatarUrl = `${protocol}://${endpoint}:${port}/${BUCKET_NAME}/${fileName}`;
|
||||
|
||||
// Delete old avatar if exists
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: { avatar: true },
|
||||
});
|
||||
|
||||
if (user?.avatar) {
|
||||
try {
|
||||
// Extract filename from URL
|
||||
const oldFileName = user.avatar.split(`${BUCKET_NAME}/`)[1];
|
||||
if (oldFileName) {
|
||||
await minioClient.removeObject(BUCKET_NAME, oldFileName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting old avatar:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update user avatar in database
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: decoded.userId },
|
||||
data: { avatar: avatarUrl },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Avatar atualizado com sucesso',
|
||||
user: updatedUser,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error uploading avatar:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erro ao fazer upload do avatar' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const token = request.cookies.get('auth_token')?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Não autenticado' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify JWT token
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { userId: string };
|
||||
|
||||
// Get user's current avatar
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: { avatar: true },
|
||||
});
|
||||
|
||||
if (user?.avatar) {
|
||||
try {
|
||||
// Extract filename from URL
|
||||
const fileName = user.avatar.split(`${BUCKET_NAME}/`)[1];
|
||||
if (fileName) {
|
||||
await minioClient.removeObject(BUCKET_NAME, fileName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting avatar from MinIO:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove avatar from database
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: decoded.userId },
|
||||
data: { avatar: null },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Avatar removido com sucesso',
|
||||
user: updatedUser,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting avatar:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erro ao remover avatar' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
95
frontend/src/app/api/auth/login/route.ts
Normal file
95
frontend/src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-CHANGE-IN-PRODUCTION';
|
||||
const MAX_LOGIN_ATTEMPTS = 5;
|
||||
const LOCKOUT_TIME = 15 * 60 * 1000; // 15 minutos
|
||||
|
||||
// Rate limiting simples (em produção, use Redis)
|
||||
const loginAttempts = new Map<string, { count: number; lastAttempt: number }>();
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { email, password } = await request.json();
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json({ error: 'Email e senha são obrigatórios' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Rate limiting básico
|
||||
const attempts = loginAttempts.get(email);
|
||||
if (attempts) {
|
||||
if (attempts.count >= MAX_LOGIN_ATTEMPTS) {
|
||||
const timeSinceLastAttempt = Date.now() - attempts.lastAttempt;
|
||||
if (timeSinceLastAttempt < LOCKOUT_TIME) {
|
||||
const minutesLeft = Math.ceil((LOCKOUT_TIME - timeSinceLastAttempt) / 60000);
|
||||
return NextResponse.json(
|
||||
{ error: `Muitas tentativas. Tente novamente em ${minutesLeft} minutos.` },
|
||||
{ status: 429 }
|
||||
);
|
||||
} else {
|
||||
loginAttempts.delete(email);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Buscar usuário no banco
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
// Proteção contra timing attacks - sempre fazer hash mesmo se usuário não existir
|
||||
const userPassword = user?.password || '$2a$10$dummyHashToPreventTimingAttack';
|
||||
const passwordMatch = await bcrypt.compare(password, userPassword);
|
||||
|
||||
if (!user || !passwordMatch) {
|
||||
// Incrementar tentativas
|
||||
const current = loginAttempts.get(email) || { count: 0, lastAttempt: 0 };
|
||||
loginAttempts.set(email, {
|
||||
count: current.count + 1,
|
||||
lastAttempt: Date.now(),
|
||||
});
|
||||
|
||||
return NextResponse.json({ error: 'Credenciais inválidas' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Limpar tentativas de login após sucesso
|
||||
loginAttempts.delete(email);
|
||||
|
||||
// Criar JWT
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
// Criar resposta com cookie de sessão
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name
|
||||
}
|
||||
});
|
||||
|
||||
// Definir cookie de autenticação com JWT
|
||||
response.cookies.set('auth_token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 dias
|
||||
path: '/',
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Erro no login:', error);
|
||||
return NextResponse.json({ error: 'Erro ao fazer login' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
10
frontend/src/app/api/auth/logout/route.ts
Normal file
10
frontend/src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ success: true });
|
||||
|
||||
// Remover cookie de autenticação
|
||||
response.cookies.delete('auth_token');
|
||||
|
||||
return response;
|
||||
}
|
||||
48
frontend/src/app/api/auth/me/route.ts
Normal file
48
frontend/src/app/api/auth/me/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const token = request.cookies.get('auth_token')?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Não autenticado' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify JWT token
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { userId: string };
|
||||
|
||||
// Get user from database
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Usuário não encontrado' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ user });
|
||||
} catch (error) {
|
||||
console.error('Error fetching user data:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erro ao buscar dados do usuário' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
41
frontend/src/app/api/config/route.ts
Normal file
41
frontend/src/app/api/config/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const config = await prisma.pageContent.findUnique({
|
||||
where: { slug: 'config' }
|
||||
});
|
||||
|
||||
if (!config) {
|
||||
return NextResponse.json({ primaryColor: '#FF6B35' });
|
||||
}
|
||||
|
||||
return NextResponse.json(config.content);
|
||||
} catch (error) {
|
||||
console.error('Error fetching config:', error);
|
||||
return NextResponse.json({ primaryColor: '#FF6B35' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const { primaryColor } = await request.json();
|
||||
|
||||
const config = await prisma.pageContent.upsert({
|
||||
where: { slug: 'config' },
|
||||
update: {
|
||||
content: { primaryColor }
|
||||
},
|
||||
create: {
|
||||
slug: 'config',
|
||||
content: { primaryColor }
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, config });
|
||||
} catch (error) {
|
||||
console.error('Error updating config:', error);
|
||||
return NextResponse.json({ error: 'Failed to update config' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
52
frontend/src/app/api/messages/[id]/route.ts
Normal file
52
frontend/src/app/api/messages/[id]/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const message = await prisma.message.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!message) return NextResponse.json({ error: 'Message not found' }, { status: 404 });
|
||||
return NextResponse.json(message);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Error fetching message' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const data = await request.json();
|
||||
const message = await prisma.message.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: data.status,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(message);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Error updating message' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
await prisma.message.delete({
|
||||
where: { id },
|
||||
});
|
||||
return NextResponse.json({ message: 'Message deleted' });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Error deleting message' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
69
frontend/src/app/api/messages/route.ts
Normal file
69
frontend/src/app/api/messages/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const search = searchParams.get('search') || '';
|
||||
const status = searchParams.get('status') || '';
|
||||
|
||||
const where: any = {};
|
||||
|
||||
// Filtro de pesquisa
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
{ subject: { contains: search, mode: 'insensitive' } },
|
||||
{ message: { contains: search, mode: 'insensitive' } }
|
||||
];
|
||||
}
|
||||
|
||||
// Filtro de status
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
const messages = await prisma.message.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return NextResponse.json(messages);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Error fetching messages' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { name, email, phone, subject, message } = await request.json();
|
||||
|
||||
// Validação básica
|
||||
if (!name || !email || !message) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Nome, email e mensagem são obrigatórios' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Criar mensagem incluindo telefone no assunto se fornecido
|
||||
const fullSubject = phone
|
||||
? `${subject || 'Sem assunto'} | Tel: ${phone}`
|
||||
: subject || 'Sem assunto';
|
||||
|
||||
const newMessage = await prisma.message.create({
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
subject: fullSubject,
|
||||
message,
|
||||
status: 'Nova'
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json(newMessage, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error creating message:', error);
|
||||
return NextResponse.json({ error: 'Error creating message' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
104
frontend/src/app/api/pages/[slug]/route.ts
Normal file
104
frontend/src/app/api/pages/[slug]/route.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { cookies } from 'next/headers';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
// Middleware de 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 (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/pages/[slug] - Buscar página específica (público)
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> }
|
||||
) {
|
||||
try {
|
||||
const { slug } = await params;
|
||||
|
||||
const page = await prisma.pageContent.findUnique({
|
||||
where: { slug }
|
||||
});
|
||||
|
||||
if (!page) {
|
||||
return NextResponse.json({ error: 'Página não encontrada' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(page);
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar página:', error);
|
||||
return NextResponse.json({ error: 'Erro ao buscar página' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/pages/[slug] - Atualizar página (admin apenas)
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> }
|
||||
) {
|
||||
try {
|
||||
const user = await authenticate();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { slug } = await params;
|
||||
const body = await request.json();
|
||||
const { content } = body;
|
||||
|
||||
if (!content) {
|
||||
return NextResponse.json({ error: 'Conteúdo é obrigatório' }, { status: 400 });
|
||||
}
|
||||
|
||||
const page = await prisma.pageContent.upsert({
|
||||
where: { slug },
|
||||
update: { content },
|
||||
create: { slug, content }
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, page });
|
||||
} catch (error) {
|
||||
console.error('Erro ao atualizar página:', error);
|
||||
return NextResponse.json({ error: 'Erro ao atualizar página' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/pages/[slug] - Deletar página (admin apenas)
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> }
|
||||
) {
|
||||
try {
|
||||
const user = await authenticate();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { slug } = await params;
|
||||
|
||||
await prisma.pageContent.delete({
|
||||
where: { slug }
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Página deletada com sucesso' });
|
||||
} catch (error) {
|
||||
console.error('Erro ao deletar página:', error);
|
||||
return NextResponse.json({ error: 'Erro ao deletar página' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
41
frontend/src/app/api/pages/contact/route.ts
Normal file
41
frontend/src/app/api/pages/contact/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
87
frontend/src/app/api/pages/route.ts
Normal file
87
frontend/src/app/api/pages/route.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { cookies } from 'next/headers';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Middleware de autenticação
|
||||
async function authenticate(request: NextRequest) {
|
||||
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 (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/pages - Listar todas as páginas (público)
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const slug = searchParams.get('slug');
|
||||
|
||||
if (slug) {
|
||||
// Buscar página específica
|
||||
const page = await prisma.pageContent.findUnique({
|
||||
where: { slug }
|
||||
});
|
||||
|
||||
if (!page) {
|
||||
return NextResponse.json({ error: 'Página não encontrada' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(page);
|
||||
}
|
||||
|
||||
// Listar todas as páginas
|
||||
const pages = await prisma.pageContent.findMany({
|
||||
orderBy: { slug: 'asc' }
|
||||
});
|
||||
|
||||
return NextResponse.json(pages);
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar páginas:', error);
|
||||
return NextResponse.json({ error: 'Erro ao buscar páginas' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/pages - Criar ou atualizar página (admin apenas)
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await authenticate(request);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { slug, content } = body;
|
||||
|
||||
if (!slug || !content) {
|
||||
return NextResponse.json({ error: 'Slug e conteúdo são obrigatórios' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Upsert: criar ou atualizar se já existir
|
||||
const page = await prisma.pageContent.upsert({
|
||||
where: { slug },
|
||||
update: { content },
|
||||
create: { slug, content }
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, page });
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar página:', error);
|
||||
return NextResponse.json({ error: 'Erro ao salvar página' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
60
frontend/src/app/api/projects/[id]/route.ts
Normal file
60
frontend/src/app/api/projects/[id]/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!project) return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||
return NextResponse.json(project);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Error fetching project' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const data = await request.json();
|
||||
const project = await prisma.project.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: data.title,
|
||||
category: data.category,
|
||||
client: data.client,
|
||||
status: data.status,
|
||||
completionDate: data.completionDate ? new Date(data.completionDate) : null,
|
||||
description: data.description,
|
||||
coverImage: data.coverImage,
|
||||
galleryImages: data.galleryImages,
|
||||
featured: data.featured,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(project);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Error updating project' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
await prisma.project.delete({
|
||||
where: { id },
|
||||
});
|
||||
return NextResponse.json({ message: 'Project deleted' });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Error deleting project' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
37
frontend/src/app/api/projects/route.ts
Normal file
37
frontend/src/app/api/projects/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const projects = await prisma.project.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return NextResponse.json(projects);
|
||||
} catch (error) {
|
||||
console.error('Error fetching projects:', error);
|
||||
return NextResponse.json({ error: 'Error fetching projects' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const data = await request.json();
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
title: data.title,
|
||||
category: data.category,
|
||||
client: data.client,
|
||||
status: data.status,
|
||||
completionDate: data.completionDate ? new Date(data.completionDate) : null,
|
||||
description: data.description,
|
||||
coverImage: data.coverImage,
|
||||
galleryImages: data.galleryImages,
|
||||
featured: data.featured,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(project);
|
||||
} catch (error) {
|
||||
console.error('Error creating project:', error);
|
||||
return NextResponse.json({ error: 'Error creating project' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
57
frontend/src/app/api/services/[id]/route.ts
Normal file
57
frontend/src/app/api/services/[id]/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const service = await prisma.service.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!service) return NextResponse.json({ error: 'Service not found' }, { status: 404 });
|
||||
return NextResponse.json(service);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Error fetching service' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const data = await request.json();
|
||||
const service = await prisma.service.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: data.title,
|
||||
icon: data.icon,
|
||||
shortDescription: data.shortDescription,
|
||||
fullDescription: data.fullDescription,
|
||||
active: data.active,
|
||||
order: data.order,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(service);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Error updating service' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
await prisma.service.delete({
|
||||
where: { id },
|
||||
});
|
||||
return NextResponse.json({ message: 'Service deleted' });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Error deleting service' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
32
frontend/src/app/api/services/route.ts
Normal file
32
frontend/src/app/api/services/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const services = await prisma.service.findMany({
|
||||
orderBy: { order: 'asc' },
|
||||
});
|
||||
return NextResponse.json(services);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Error fetching services' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const data = await request.json();
|
||||
const service = await prisma.service.create({
|
||||
data: {
|
||||
title: data.title,
|
||||
icon: data.icon,
|
||||
shortDescription: data.shortDescription,
|
||||
fullDescription: data.fullDescription,
|
||||
active: data.active,
|
||||
order: data.order,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(service);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Error creating service' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
32
frontend/src/app/api/upload/route.ts
Normal file
32
frontend/src/app/api/upload/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { minioClient, bucketName, ensureBucketExists } from '@/lib/minio';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await ensureBucketExists();
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file uploaded' }, { status: 400 });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const filename = `${uuidv4()}-${file.name.replace(/\s+/g, '-')}`; // Sanitize filename
|
||||
|
||||
await minioClient.putObject(bucketName, filename, buffer, file.size, {
|
||||
'Content-Type': file.type,
|
||||
});
|
||||
|
||||
// Construct public URL
|
||||
// In a real production env, this should be an env var like NEXT_PUBLIC_STORAGE_URL
|
||||
const url = `http://localhost:9000/${bucketName}/${filename}`;
|
||||
|
||||
return NextResponse.json({ url });
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
return NextResponse.json({ error: 'Error uploading file' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
68
frontend/src/app/api/users/[id]/avatar/route.ts
Normal file
68
frontend/src/app/api/users/[id]/avatar/route.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { minioClient } from '@/lib/minio';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
const BUCKET_NAME = process.env.MINIO_BUCKET_NAME || 'occto-images';
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const token = request.cookies.get('auth_token')?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Não autenticado' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify JWT token
|
||||
jwt.verify(token, JWT_SECRET);
|
||||
|
||||
// Get user's current avatar
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { avatar: true },
|
||||
});
|
||||
|
||||
if (user?.avatar) {
|
||||
try {
|
||||
// Extract filename from URL
|
||||
const fileName = user.avatar.split(`${BUCKET_NAME}/`)[1];
|
||||
if (fileName) {
|
||||
await minioClient.removeObject(BUCKET_NAME, fileName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting avatar from MinIO:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove avatar from database
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id },
|
||||
data: { avatar: null },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Avatar removido com sucesso',
|
||||
user: updatedUser,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting avatar:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erro ao remover avatar' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
96
frontend/src/app/api/users/[id]/route.ts
Normal file
96
frontend/src/app/api/users/[id]/route.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(user);
|
||||
} catch (error) {
|
||||
console.error('Erro no GET /api/users/[id]:', error);
|
||||
return NextResponse.json({ error: 'Error fetching user' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const data = await request.json();
|
||||
|
||||
// Verificar se email já está em uso por outro usuário
|
||||
if (data.email) {
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: data.email,
|
||||
NOT: { id },
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json({ error: 'Email já está em uso' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: any = {
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
};
|
||||
|
||||
// Se enviou senha, fazer hash
|
||||
if (data.password) {
|
||||
updateData.password = await bcrypt.hash(data.password, 10);
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(user);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Error updating user' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
// Verificar se não é o último usuário
|
||||
const userCount = await prisma.user.count();
|
||||
if (userCount <= 1) {
|
||||
return NextResponse.json({ error: 'Não é possível excluir o último usuário do sistema' }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.user.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: 'User deleted' });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Error deleting user' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
130
frontend/src/app/api/users/avatar/route.ts
Normal file
130
frontend/src/app/api/users/avatar/route.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { minioClient, ensureBucketExists } from '@/lib/minio';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
const BUCKET_NAME = process.env.MINIO_BUCKET_NAME || 'occto-images';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Ensure bucket exists
|
||||
await ensureBucketExists();
|
||||
|
||||
const token = request.cookies.get('auth_token')?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Não autenticado' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify JWT token
|
||||
jwt.verify(token, JWT_SECRET);
|
||||
|
||||
// Get form data
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('avatar') as File;
|
||||
const userId = formData.get('userId') as string;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Nenhum arquivo enviado' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ID do usuário não fornecido' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/jpg'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Tipo de arquivo inválido. Use JPEG, PNG ou WEBP' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
const maxSize = 5 * 1024 * 1024; // 5MB
|
||||
if (file.size > maxSize) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Arquivo muito grande. Tamanho máximo: 5MB' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const fileExtension = file.name.split('.').pop();
|
||||
const fileName = `avatars/${userId}/${uuidv4()}.${fileExtension}`;
|
||||
|
||||
// Convert file to buffer
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// Upload to MinIO
|
||||
await minioClient.putObject(
|
||||
BUCKET_NAME,
|
||||
fileName,
|
||||
buffer,
|
||||
buffer.length,
|
||||
{
|
||||
'Content-Type': file.type,
|
||||
}
|
||||
);
|
||||
|
||||
// Generate public URL
|
||||
const protocol = process.env.MINIO_USE_SSL === 'true' ? 'https' : 'http';
|
||||
const endpoint = process.env.MINIO_ENDPOINT || 'localhost';
|
||||
const port = process.env.MINIO_PORT || '9000';
|
||||
const avatarUrl = `${protocol}://${endpoint}:${port}/${BUCKET_NAME}/${fileName}`;
|
||||
|
||||
// Delete old avatar if exists
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { avatar: true },
|
||||
});
|
||||
|
||||
if (user?.avatar) {
|
||||
try {
|
||||
// Extract filename from URL
|
||||
const oldFileName = user.avatar.split(`${BUCKET_NAME}/`)[1];
|
||||
if (oldFileName) {
|
||||
await minioClient.removeObject(BUCKET_NAME, oldFileName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting old avatar:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update user avatar in database
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { avatar: avatarUrl },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Avatar atualizado com sucesso',
|
||||
user: updatedUser,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error uploading avatar:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erro ao fazer upload do avatar' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
65
frontend/src/app/api/users/route.ts
Normal file
65
frontend/src/app/api/users/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const users = await prisma.user.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(users);
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error);
|
||||
return NextResponse.json({ error: 'Error fetching users' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const data = await request.json();
|
||||
|
||||
// Validações
|
||||
if (!data.email || !data.password) {
|
||||
return NextResponse.json({ error: 'Email e senha são obrigatórios' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verificar se email já existe
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: data.email },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json({ error: 'Email já está em uso' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Hash da senha
|
||||
const hashedPassword = await bcrypt.hash(data.password, 10);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
password: hashedPassword,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(user);
|
||||
} catch (error) {
|
||||
console.error('Error creating user:', error);
|
||||
return NextResponse.json({ error: 'Error creating user' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
BIN
frontend/src/app/favicon.ico
Normal file
BIN
frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
1
frontend/src/app/fonts.css
Normal file
1
frontend/src/app/fonts.css
Normal file
@@ -0,0 +1 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Stack+Sans+Headline&display=swap');
|
||||
31
frontend/src/app/globals.css
Normal file
31
frontend/src/app/globals.css
Normal file
@@ -0,0 +1,31 @@
|
||||
@import "tailwindcss";
|
||||
@config "../../tailwind.config.ts";
|
||||
|
||||
@theme {
|
||||
--color-primary: #FF6B35;
|
||||
--color-primary-rgb: 255 107 53;
|
||||
--color-secondary: #1A1A1A;
|
||||
|
||||
--font-headline: 'Stack Sans Headline', sans-serif;
|
||||
--font-body: var(--font-body), sans-serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-headline);
|
||||
}
|
||||
4
frontend/src/app/icon.svg
Normal file
4
frontend/src/app/icon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="48" height="48" rx="12" fill="#FF6B00"/>
|
||||
<path d="M12 19H14V6.00003L20.3939 8.74028C20.7616 8.89786 21 9.2594 21 9.65943V19H23V21H1V19H3V5.6499C3 5.25472 3.23273 4.89659 3.59386 4.73609L11.2969 1.31251C11.5493 1.20035 11.8448 1.314 11.9569 1.56634C11.9853 1.63027 12 1.69945 12 1.76941V19Z" fill="white" transform="translate(12, 12)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 455 B |
51
frontend/src/app/layout.tsx
Normal file
51
frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "remixicon/fonts/remixicon.css";
|
||||
import "./fonts.css";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { LanguageProvider } from "@/contexts/LanguageContext";
|
||||
import { ToastProvider } from "@/contexts/ToastContext";
|
||||
import { ConfirmProvider } from "@/contexts/ConfirmContext";
|
||||
import { ColorProvider } from "@/components/ColorProvider";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-body",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Octto Engenharia | Movimentação de Carga e Segurança",
|
||||
description: "Especialistas em engenharia de movimentação de carga, projetos de dispositivos de içamento, laudos técnicos e adequação de equipamentos (NR-11/NR-12).",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="pt-BR" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${inter.variable} antialiased flex flex-col min-h-screen`}
|
||||
>
|
||||
<ColorProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ToastProvider>
|
||||
<ConfirmProvider>
|
||||
<LanguageProvider>
|
||||
{children}
|
||||
</LanguageProvider>
|
||||
</ConfirmProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</ColorProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
43
frontend/src/components/ColorProvider.tsx
Normal file
43
frontend/src/components/ColorProvider.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function ColorProvider({ children }: { children: React.ReactNode }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
loadPrimaryColor();
|
||||
}, []);
|
||||
|
||||
const loadPrimaryColor = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/config');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.primaryColor) {
|
||||
applyPrimaryColor(data.primaryColor);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar cor primária:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const applyPrimaryColor = (color: string) => {
|
||||
// Converte hex para RGB
|
||||
const r = parseInt(color.slice(1, 3), 16);
|
||||
const g = parseInt(color.slice(3, 5), 16);
|
||||
const b = parseInt(color.slice(5, 7), 16);
|
||||
|
||||
// Define as CSS variables
|
||||
document.documentElement.style.setProperty('--color-primary-rgb', `${r} ${g} ${b}`);
|
||||
document.documentElement.style.setProperty('--color-primary', color);
|
||||
};
|
||||
|
||||
if (!mounted) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
60
frontend/src/components/ConfirmDialog.tsx
Normal file
60
frontend/src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
type?: 'danger' | 'warning' | 'info';
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
title,
|
||||
message,
|
||||
confirmText = 'OK',
|
||||
cancelText = 'Cancelar',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
type = 'danger',
|
||||
}: ConfirmDialogProps) {
|
||||
const buttonStyles = {
|
||||
danger: 'bg-red-500 hover:bg-red-600 text-white',
|
||||
warning: 'bg-yellow-500 hover:bg-yellow-600 text-white',
|
||||
info: 'bg-primary hover:bg-orange-600 text-white',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 animate-in fade-in duration-200"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
className="bg-white dark:bg-secondary rounded-2xl shadow-2xl max-w-md w-full p-6 animate-in zoom-in-95 duration-200"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-xl font-bold text-secondary dark:text-white mb-3">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
{message}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 px-4 py-3 border border-gray-300 dark:border-white/10 text-gray-700 dark:text-gray-300 rounded-xl font-medium hover:bg-gray-50 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`flex-1 px-4 py-3 rounded-xl font-medium transition-colors ${buttonStyles[type]}`}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
frontend/src/components/CookieConsent.tsx
Normal file
69
frontend/src/components/CookieConsent.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
|
||||
export default function CookieConsent() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const { t } = useLanguage();
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user has already made a choice
|
||||
const consent = localStorage.getItem('cookie_consent');
|
||||
if (consent === null) {
|
||||
// Small delay to show animation
|
||||
const timer = setTimeout(() => setIsVisible(true), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAccept = () => {
|
||||
localStorage.setItem('cookie_consent', 'true');
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
const handleDecline = () => {
|
||||
localStorage.setItem('cookie_consent', 'false');
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 p-4 md:p-6 animate-in slide-in-from-bottom-full duration-500">
|
||||
<div className="container mx-auto max-w-4xl">
|
||||
<div className="bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-2xl shadow-2xl p-6 md:flex items-center justify-between gap-6">
|
||||
<div className="flex items-start gap-4 mb-6 md:mb-0">
|
||||
<div className="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center shrink-0 text-primary">
|
||||
<i className="ri-cookie-2-line text-2xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm leading-relaxed">
|
||||
{t('cookie.text')}{' '}
|
||||
<Link href="/privacidade" className="text-primary font-bold hover:underline">
|
||||
{t('cookie.policy')}
|
||||
</Link>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-3 shrink-0">
|
||||
<button
|
||||
onClick={handleDecline}
|
||||
className="px-6 py-2.5 border border-gray-200 dark:border-white/10 text-gray-600 dark:text-gray-300 rounded-lg font-bold hover:bg-gray-50 dark:hover:bg-white/5 transition-colors text-sm cursor-pointer"
|
||||
>
|
||||
{t('cookie.decline')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAccept}
|
||||
className="px-6 py-2.5 bg-primary text-white rounded-lg font-bold hover:bg-orange-600 transition-colors text-sm shadow-lg shadow-primary/20 cursor-pointer"
|
||||
>
|
||||
{t('cookie.accept')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
frontend/src/components/Footer.tsx
Normal file
99
frontend/src/components/Footer.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
|
||||
export default function Footer() {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<footer className="bg-secondary text-white pt-16 pb-8">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-12 mb-12">
|
||||
{/* Brand */}
|
||||
<div className="col-span-1 md:col-span-1">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<i className="ri-building-2-fill text-4xl text-primary"></i>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold font-headline">OCCTO</span>
|
||||
<span className="text-[10px] font-bold text-primary bg-white/10 px-2 py-1 rounded-md uppercase tracking-wider">ENG.</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-400 mb-6">
|
||||
Soluções em engenharia mecânica e segurança para movimentação de carga.
|
||||
</p>
|
||||
|
||||
<div className="inline-flex items-center gap-2 bg-white/5 border border-white/10 rounded-lg px-3 py-2 mb-6">
|
||||
<i className="ri-verified-badge-fill text-primary"></i>
|
||||
<span className="text-xs font-bold text-gray-300 uppercase tracking-wide">Prestador Oficial <span className="text-primary">Coca-Cola</span></span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<a href="#" className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center hover:bg-primary transition-colors">
|
||||
<i className="ri-instagram-line"></i>
|
||||
</a>
|
||||
<a href="#" className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center hover:bg-primary transition-colors">
|
||||
<i className="ri-linkedin-fill"></i>
|
||||
</a>
|
||||
<a href="#" className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center hover:bg-primary transition-colors">
|
||||
<i className="ri-facebook-fill"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold font-headline mb-6">Links Rápidos</h3>
|
||||
<ul className="space-y-4">
|
||||
<li><Link href="/" className="text-gray-400 hover:text-primary transition-colors">{t('nav.home')}</Link></li>
|
||||
<li><Link href="/sobre" className="text-gray-400 hover:text-primary transition-colors">{t('nav.about')}</Link></li>
|
||||
<li><Link href="/servicos" className="text-gray-400 hover:text-primary transition-colors">{t('nav.services')}</Link></li>
|
||||
<li><Link href="/projetos" className="text-gray-400 hover:text-primary transition-colors">{t('nav.projects')}</Link></li>
|
||||
<li><Link href="/contato" className="text-gray-400 hover:text-primary transition-colors">{t('nav.contact')}</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold font-headline mb-6">{t('services.title')}</h3>
|
||||
<ul className="space-y-4">
|
||||
<li className="text-gray-400">Projetos de Dispositivos</li>
|
||||
<li className="text-gray-400">Engenharia de Implementos</li>
|
||||
<li className="text-gray-400">Inspeção de Equipamentos</li>
|
||||
<li className="text-gray-400">Laudos Técnicos (NR-11/12)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold font-headline mb-6">{t('nav.contact')}</h3>
|
||||
<ul className="space-y-4">
|
||||
<li className="flex items-start gap-3 text-gray-400">
|
||||
<i className="ri-map-pin-line mt-1 text-primary"></i>
|
||||
<span>Endereço da Empresa, 123<br />Cidade - ES</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-gray-400">
|
||||
<i className="ri-phone-line text-primary"></i>
|
||||
<span>(27) 99999-9999</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-gray-400">
|
||||
<i className="ri-mail-line text-primary"></i>
|
||||
<span>contato@octto.com.br</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 pt-8 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<p className="text-gray-500 text-sm">
|
||||
© {new Date().getFullYear()} OCCTO Engenharia. {t('footer.rights')}
|
||||
</p>
|
||||
<div className="flex gap-6 text-sm text-gray-500">
|
||||
<Link href="/privacidade" className="hover:text-white">Política de Privacidade</Link>
|
||||
<Link href="/termos" className="hover:text-white">Termos de Uso</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
230
frontend/src/components/Header.tsx
Normal file
230
frontend/src/components/Header.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTheme } from "next-themes";
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
|
||||
export default function Header() {
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { language, setLanguage, t } = useLanguage();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Prevent scrolling when mobile menu is open
|
||||
useEffect(() => {
|
||||
if (isMobileMenuOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'unset';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isMobileMenuOpen]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
const cycleLanguage = () => {
|
||||
const langs: ('PT' | 'EN' | 'ES')[] = ['PT', 'EN', 'ES'];
|
||||
const currentIndex = langs.indexOf(language);
|
||||
const nextIndex = (currentIndex + 1) % langs.length;
|
||||
setLanguage(langs[nextIndex]);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="w-full bg-white dark:bg-secondary shadow-sm sticky top-0 z-50 transition-colors duration-300">
|
||||
<div className="container mx-auto px-4 h-20 flex items-center justify-between gap-4">
|
||||
<Link href="/" className="flex items-center gap-3 shrink-0 group mr-auto z-50 relative">
|
||||
<i className="ri-building-2-fill text-4xl text-primary group-hover:scale-105 transition-transform"></i>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-3xl font-bold text-secondary dark:text-white font-headline leading-none">OCCTO</span>
|
||||
<span className="text-[10px] font-bold text-primary bg-primary/10 px-2 py-1 rounded-md uppercase tracking-wider">ENG.</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="hidden md:flex items-center gap-4">
|
||||
{/* Search Bar */}
|
||||
<div className={`flex items-center bg-gray-100 dark:bg-white/10 rounded-full transition-all duration-300 ${isSearchOpen ? 'w-64 px-4 py-2' : 'w-10 h-10 justify-center cursor-pointer hover:bg-gray-200 dark:hover:bg-white/20'}`} onClick={() => !isSearchOpen && setIsSearchOpen(true)}>
|
||||
<i className={`ri-search-line text-gray-500 dark:text-gray-300 ${isSearchOpen ? 'mr-2' : 'text-lg'}`}></i>
|
||||
{isSearchOpen && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('nav.search')}
|
||||
autoFocus
|
||||
onBlur={() => setIsSearchOpen(false)}
|
||||
className="bg-transparent border-none outline-none text-sm w-full text-gray-600 dark:text-gray-200 placeholder-gray-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center gap-6 mr-4">
|
||||
<Link href="/" className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary font-medium transition-colors group">
|
||||
<i className="ri-home-4-line text-lg group-hover:scale-110 transition-transform"></i>
|
||||
<span className="hidden lg:inline">{t('nav.home')}</span>
|
||||
</Link>
|
||||
<Link href="/servicos" className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary font-medium transition-colors group">
|
||||
<i className="ri-tools-line text-lg group-hover:scale-110 transition-transform"></i>
|
||||
<span className="hidden lg:inline">{t('nav.services')}</span>
|
||||
</Link>
|
||||
<Link href="/projetos" className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary font-medium transition-colors group">
|
||||
<i className="ri-briefcase-line text-lg group-hover:scale-110 transition-transform"></i>
|
||||
<span className="hidden lg:inline">{t('nav.projects')}</span>
|
||||
</Link>
|
||||
<Link href="/contato" className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary font-medium transition-colors group">
|
||||
<i className="ri-mail-send-line text-lg group-hover:scale-110 transition-transform"></i>
|
||||
<span className="hidden lg:inline">{t('nav.contact')}</span>
|
||||
</Link>
|
||||
<Link href="/sobre" className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary font-medium transition-colors group">
|
||||
<i className="ri-user-line text-lg group-hover:scale-110 transition-transform"></i>
|
||||
<span className="hidden lg:inline">{t('nav.about')}</span>
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="shrink-0 ml-2">
|
||||
<Link
|
||||
href="/contato"
|
||||
className="px-6 py-2.5 bg-primary text-white rounded-lg font-bold hover:bg-orange-600 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<i className="ri-whatsapp-line"></i>
|
||||
<span className="hidden xl:inline">{t('nav.contact_us')}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pl-4 border-l border-gray-200 dark:border-white/10">
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="w-10 h-10 rounded-full bg-gray-100 dark:bg-white/10 flex items-center justify-center text-gray-600 dark:text-yellow-400 hover:bg-gray-200 dark:hover:bg-white/20 transition-colors cursor-pointer"
|
||||
aria-label="Alternar tema"
|
||||
>
|
||||
{mounted && theme === 'dark' ? (
|
||||
<i className="ri-sun-line text-xl"></i>
|
||||
) : (
|
||||
<i className="ri-moon-line text-xl"></i>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Language Dropdown */}
|
||||
<div className="relative group">
|
||||
<button
|
||||
className="h-10 px-3 rounded-full bg-gray-100 dark:bg-white/10 flex items-center justify-center gap-2 text-gray-600 dark:text-white hover:bg-gray-200 dark:hover:bg-white/20 transition-colors font-bold text-sm cursor-pointer"
|
||||
aria-label="Alterar idioma"
|
||||
>
|
||||
<span>{language === 'PT' ? '🇧🇷' : language === 'EN' ? '🇺🇸' : '🇪🇸'}</span>
|
||||
<span>{language}</span>
|
||||
<i className="ri-arrow-down-s-line text-xs opacity-50"></i>
|
||||
</button>
|
||||
|
||||
<div className="absolute top-full right-0 pt-2 w-32 hidden group-hover:block animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<div className="bg-white dark:bg-secondary rounded-xl shadow-xl border border-gray-100 dark:border-white/10 overflow-hidden">
|
||||
<button onClick={() => setLanguage('PT')} className="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-white/5 flex items-center gap-3 transition-colors cursor-pointer">
|
||||
<span className="text-lg">🇧🇷</span>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">Português</span>
|
||||
</button>
|
||||
<button onClick={() => setLanguage('EN')} className="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-white/5 flex items-center gap-3 transition-colors cursor-pointer">
|
||||
<span className="text-lg">🇺🇸</span>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">English</span>
|
||||
</button>
|
||||
<button onClick={() => setLanguage('ES')} className="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-white/5 flex items-center gap-3 transition-colors cursor-pointer">
|
||||
<span className="text-lg">🇪🇸</span>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">Español</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className="md:hidden text-2xl text-secondary dark:text-white z-50 relative w-10 h-10 flex items-center justify-center cursor-pointer"
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
aria-label="Menu"
|
||||
>
|
||||
{isMobileMenuOpen ? <i className="ri-close-line"></i> : <i className="ri-menu-line"></i>}
|
||||
</button>
|
||||
|
||||
{/* Mobile Menu Overlay */}
|
||||
<div className={`fixed inset-0 bg-white dark:bg-secondary z-40 transition-transform duration-300 ease-in-out md:hidden flex flex-col pt-24 px-6 overflow-y-auto ${isMobileMenuOpen ? 'translate-x-0' : 'translate-x-full'}`}>
|
||||
|
||||
{/* Mobile Search */}
|
||||
<div className="mb-6 relative shrink-0">
|
||||
<i className="ri-search-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"></i>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('nav.search')}
|
||||
className="w-full pl-11 pr-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-100 dark:border-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>
|
||||
|
||||
<nav className="flex flex-col gap-4 text-base font-medium">
|
||||
<Link href="/" onClick={() => setIsMobileMenuOpen(false)} className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-white/10 text-secondary dark:text-white">
|
||||
<i className="ri-home-4-line text-primary text-lg"></i>
|
||||
{t('nav.home')}
|
||||
</Link>
|
||||
<Link href="/servicos" onClick={() => setIsMobileMenuOpen(false)} className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-white/10 text-secondary dark:text-white">
|
||||
<i className="ri-tools-line text-primary text-lg"></i>
|
||||
{t('nav.services')}
|
||||
</Link>
|
||||
<Link href="/projetos" onClick={() => setIsMobileMenuOpen(false)} className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-white/10 text-secondary dark:text-white">
|
||||
<i className="ri-briefcase-line text-primary text-lg"></i>
|
||||
{t('nav.projects')}
|
||||
</Link>
|
||||
<Link href="/contato" onClick={() => setIsMobileMenuOpen(false)} className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-white/10 text-secondary dark:text-white">
|
||||
<i className="ri-mail-send-line text-primary text-lg"></i>
|
||||
{t('nav.contact')}
|
||||
</Link>
|
||||
<Link href="/sobre" onClick={() => setIsMobileMenuOpen(false)} className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-white/10 text-secondary dark:text-white">
|
||||
<i className="ri-user-line text-primary text-lg"></i>
|
||||
{t('nav.about')}
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4 pb-8 shrink-0">
|
||||
<Link
|
||||
href="/contato"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="w-full py-4 bg-primary text-white rounded-xl font-bold text-center flex items-center justify-center gap-2 shadow-lg shadow-primary/20"
|
||||
>
|
||||
<i className="ri-whatsapp-line text-xl"></i>
|
||||
{t('nav.contact_us')}
|
||||
</Link>
|
||||
|
||||
<div
|
||||
className="flex items-center justify-between p-4 bg-gray-50 dark:bg-white/5 rounded-xl cursor-pointer hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
|
||||
onClick={toggleTheme}
|
||||
>
|
||||
<span className="text-sm font-bold text-gray-500 dark:text-gray-400">{t('nav.theme')}</span>
|
||||
<button
|
||||
className="w-10 h-10 rounded-full bg-white dark:bg-white/10 flex items-center justify-center text-gray-600 dark:text-yellow-400 shadow-sm transition-colors"
|
||||
>
|
||||
{mounted && theme === 'dark' ? (
|
||||
<i className="ri-sun-line text-xl"></i>
|
||||
) : (
|
||||
<i className="ri-moon-line text-xl"></i>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-white/5 rounded-xl">
|
||||
<span className="text-sm font-bold text-gray-500 dark:text-gray-400">{t('nav.language')}</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setLanguage('PT')} className={`w-10 h-10 rounded-lg flex items-center justify-center text-xl cursor-pointer transition-all ${language === 'PT' ? 'bg-white dark:bg-white/10 shadow-sm scale-110' : 'opacity-50 hover:opacity-100'}`}>🇧🇷</button>
|
||||
<button onClick={() => setLanguage('EN')} className={`w-10 h-10 rounded-lg flex items-center justify-center text-xl cursor-pointer transition-all ${language === 'EN' ? 'bg-white dark:bg-white/10 shadow-sm scale-110' : 'opacity-50 hover:opacity-100'}`}>🇺🇸</button>
|
||||
<button onClick={() => setLanguage('ES')} className={`w-10 h-10 rounded-lg flex items-center justify-center text-xl cursor-pointer transition-all ${language === 'ES' ? 'bg-white dark:bg-white/10 shadow-sm scale-110' : 'opacity-50 hover:opacity-100'}`}>🇪🇸</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
43
frontend/src/components/Toast.tsx
Normal file
43
frontend/src/components/Toast.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface ToastProps {
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
onClose: () => void;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export default function Toast({ message, type, onClose, duration = 3000 }: ToastProps) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onClose, duration);
|
||||
return () => clearTimeout(timer);
|
||||
}, [duration, onClose]);
|
||||
|
||||
const styles = {
|
||||
success: 'bg-green-500 text-white',
|
||||
error: 'bg-red-500 text-white',
|
||||
warning: 'bg-yellow-500 text-white',
|
||||
info: 'bg-blue-500 text-white',
|
||||
};
|
||||
|
||||
const icons = {
|
||||
success: 'ri-checkbox-circle-line',
|
||||
error: 'ri-error-warning-line',
|
||||
warning: 'ri-alert-line',
|
||||
info: 'ri-information-line',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 animate-in slide-in-from-top-5 fade-in duration-300">
|
||||
<div className={`${styles[type]} rounded-xl shadow-lg px-6 py-4 flex items-center gap-3 min-w-[300px] max-w-md`}>
|
||||
<i className={`${icons[type]} text-2xl`}></i>
|
||||
<p className="flex-1 font-medium">{message}</p>
|
||||
<button onClick={onClose} className="hover:opacity-70 transition-opacity">
|
||||
<i className="ri-close-line text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/WhatsAppButton.tsx
Normal file
23
frontend/src/components/WhatsAppButton.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
|
||||
export default function WhatsAppButton() {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="https://wa.me/5511999999999" // Substitua pelo número real
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="fixed bottom-6 right-6 z-40 flex flex-row-reverse items-center justify-center bg-[#25D366] text-white w-14 h-14 rounded-full shadow-lg hover:bg-[#20bd5a] transition-all hover:scale-110 group animate-in slide-in-from-bottom-4 duration-700 delay-1000 hover:w-auto hover:px-6"
|
||||
aria-label={t('whatsapp.label')}
|
||||
>
|
||||
<i className="ri-whatsapp-line text-3xl leading-none"></i>
|
||||
<span className="font-bold max-w-0 overflow-hidden group-hover:max-w-xs group-hover:mr-3 transition-all duration-500 whitespace-nowrap">
|
||||
{t('whatsapp.label')}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
11
frontend/src/components/theme-provider.tsx
Normal file
11
frontend/src/components/theme-provider.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
71
frontend/src/contexts/ConfirmContext.tsx
Normal file
71
frontend/src/contexts/ConfirmContext.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
|
||||
interface ConfirmOptions {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
type?: 'danger' | 'warning' | 'info';
|
||||
}
|
||||
|
||||
interface ConfirmContextValue {
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const ConfirmContext = createContext<ConfirmContextValue | undefined>(undefined);
|
||||
|
||||
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
const [dialog, setDialog] = useState<{
|
||||
show: boolean;
|
||||
options: ConfirmOptions;
|
||||
resolve: (value: boolean) => void;
|
||||
} | null>(null);
|
||||
|
||||
const confirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
setDialog({ show: true, options, resolve });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (dialog) {
|
||||
dialog.resolve(true);
|
||||
setDialog(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (dialog) {
|
||||
dialog.resolve(false);
|
||||
setDialog(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={{ confirm }}>
|
||||
{children}
|
||||
{dialog?.show && (
|
||||
<ConfirmDialog
|
||||
title={dialog.options.title}
|
||||
message={dialog.options.message}
|
||||
confirmText={dialog.options.confirmText}
|
||||
cancelText={dialog.options.cancelText}
|
||||
type={dialog.options.type}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
)}
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConfirm() {
|
||||
const context = useContext(ConfirmContext);
|
||||
if (!context) {
|
||||
throw new Error('useConfirm must be used within ConfirmProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
535
frontend/src/contexts/LanguageContext.tsx
Normal file
535
frontend/src/contexts/LanguageContext.tsx
Normal file
@@ -0,0 +1,535 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
type Language = 'PT' | 'EN' | 'ES';
|
||||
|
||||
interface LanguageContextType {
|
||||
language: Language;
|
||||
setLanguage: (lang: Language) => void;
|
||||
t: (key: string) => string;
|
||||
tDynamic: (content: { PT: string, EN?: string, ES?: string }) => string;
|
||||
}
|
||||
|
||||
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||
|
||||
export const translations = {
|
||||
PT: {
|
||||
'nav.home': 'Início',
|
||||
'nav.services': 'Serviços',
|
||||
'nav.projects': 'Projetos',
|
||||
'nav.contact': 'Contato',
|
||||
'nav.about': 'Sobre',
|
||||
'nav.search': 'Buscar...',
|
||||
'nav.contact_us': 'Fale Conosco',
|
||||
'nav.theme': 'Tema',
|
||||
'nav.language': 'Idioma',
|
||||
'footer.rights': 'Todos os direitos reservados.',
|
||||
|
||||
// Home - Hero
|
||||
'home.hero.badge': 'Prestador de Serviço Oficial',
|
||||
'home.hero.title': 'Engenharia de',
|
||||
'home.hero.title_highlight': 'Dispositivos de Içamento',
|
||||
'home.hero.subtitle': 'Desenvolvemos projetos, laudos e soluções técnicas para equipamentos de movimentação de carga. Segurança e conformidade normativa para sua operação.',
|
||||
'home.hero.cta_primary': 'Falar com Engenheiro',
|
||||
'home.hero.cta_secondary': 'Ver Soluções',
|
||||
|
||||
// Home - Features
|
||||
'home.features.pretitle': 'Diferenciais',
|
||||
'home.features.title': 'Segurança e Eficiência',
|
||||
'home.features.1.title': 'Normas Técnicas',
|
||||
'home.features.1.desc': 'Projetos e adequações rigorosamente alinhados com as normas NR-12, NR-11 e resoluções do CONTRAN.',
|
||||
'home.features.2.title': 'Engenharia Mecânica',
|
||||
'home.features.2.desc': 'Desenvolvimento de dispositivos de içamento e soluções personalizadas para otimizar sua logística.',
|
||||
'home.features.3.title': 'Projetos de Implementos',
|
||||
'home.features.3.desc': 'Engenharia especializada para instalação e adequação de Muncks, plataformas e dispositivos em veículos de carga.',
|
||||
|
||||
// Home - Services
|
||||
'home.services.pretitle': 'O que fazemos',
|
||||
'home.services.title': 'Soluções Especializadas',
|
||||
'home.services.1.title': 'Projetos Mecânicos',
|
||||
'home.services.1.desc': 'Desenvolvimento de dispositivos de içamento (Spreaders, Balancins).',
|
||||
'home.services.2.title': 'Laudos Técnicos',
|
||||
'home.services.2.desc': 'Inspeção e certificação de equipamentos de carga conforme normas.',
|
||||
'home.services.3.title': 'Adequação NR-12',
|
||||
'home.services.3.desc': 'Projetos de segurança para máquinas e equipamentos.',
|
||||
'home.services.4.title': 'Engenharia Veicular',
|
||||
'home.services.4.desc': 'Projetos para instalação de equipamentos em caminhões.',
|
||||
'home.services.link': 'Ver todos os serviços',
|
||||
|
||||
// Home - About
|
||||
'home.about.pretitle': 'Sobre Nós',
|
||||
'home.about.title': 'Engenharia que garante segurança',
|
||||
'home.about.desc': 'A Octto Engenharia é parceira técnica de grandes empresas logísticas. Não operamos frotas, nós garantimos que os equipamentos que movem sua carga sejam seguros, eficientes e estejam dentro das normas.',
|
||||
'home.about.list.1': 'Projetos de Dispositivos de Içamento',
|
||||
'home.about.list.2': 'Laudos Técnicos para Muncks e Guindastes',
|
||||
'home.about.list.3': 'Responsabilidade Técnica (ART) garantida',
|
||||
'home.about.link': 'Conheça nossa expertise',
|
||||
|
||||
// Home - Projects
|
||||
'home.projects.pretitle': 'Portfólio',
|
||||
'home.projects.title': 'Projetos Recentes',
|
||||
'home.projects.link': 'Ver todos os projetos',
|
||||
'home.projects.1.cat': 'Engenharia Veicular',
|
||||
'home.projects.1.title': 'Projeto de Adequação - Coca-Cola',
|
||||
'home.projects.2.cat': 'Inspeção Técnica',
|
||||
'home.projects.2.title': 'Laudo de Guindaste Articulado',
|
||||
'home.projects.3.cat': 'Projeto Mecânico',
|
||||
'home.projects.3.title': 'Dispositivo de Içamento Especial',
|
||||
'home.projects.4.cat': 'Laudos',
|
||||
'home.projects.4.title': 'Certificação NR-12 - Parque Industrial',
|
||||
'home.projects.5.cat': 'Engenharia Veicular',
|
||||
'home.projects.5.title': 'Homologação de Plataforma Elevatória',
|
||||
'home.projects.6.cat': 'Segurança do Trabalho',
|
||||
'home.projects.6.title': 'Projeto de Linha de Vida para Caminhões',
|
||||
'home.projects.view_details': 'Ver detalhes',
|
||||
|
||||
// Home - Testimonials
|
||||
'home.testimonials.pretitle': 'Depoimentos',
|
||||
'home.testimonials.title': 'Parceiros que confiam',
|
||||
'home.testimonials.1.text': 'A Octto realizou a adequação de toda nossa frota de caminhões com excelência técnica e rapidez.',
|
||||
'home.testimonials.1.role': 'Gerente de Frota, Distribuidora Bebidas',
|
||||
'home.testimonials.2.text': 'Os laudos técnicos emitidos pela Octto nos deram total segurança jurídica e operacional.',
|
||||
'home.testimonials.2.role': 'Diretora Operacional, Logística Express',
|
||||
'home.testimonials.3.text': 'O projeto do dispositivo de içamento resolveu um gargalo antigo da nossa produção. Recomendo.',
|
||||
'home.testimonials.3.role': 'Engenheiro Chefe, Indústria Metalúrgica',
|
||||
|
||||
// Home - CTA
|
||||
'home.cta.title': 'Pronto para iniciar seu projeto?',
|
||||
'home.cta.desc': 'Entre em contato conosco hoje mesmo e descubra como podemos ajudar a transformar sua visão em realidade.',
|
||||
'home.cta.button': 'Falar com um Especialista',
|
||||
|
||||
// Services Page
|
||||
'services.hero.title': 'Nossos Serviços',
|
||||
'services.hero.subtitle': 'Soluções completas em engenharia mecânica e movimentação de carga.',
|
||||
'services.cta.title': 'Precisa de uma solução personalizada?',
|
||||
'services.cta.button': 'Falar com um Engenheiro',
|
||||
'services.scope': 'Escopo do Serviço',
|
||||
'services.title': 'Serviços',
|
||||
|
||||
// Projects Page
|
||||
'projects.hero.title': 'Nossos Projetos',
|
||||
'projects.hero.subtitle': 'Explore nosso portfólio de soluções em movimentação de carga e engenharia mecânica.',
|
||||
'projects.filter.all': 'Todos',
|
||||
'projects.filter.implements': 'Implementos',
|
||||
'projects.filter.mechanical': 'Projetos Mecânicos',
|
||||
'projects.filter.reports': 'Laudos',
|
||||
'projects.card.details': 'Ver detalhes',
|
||||
|
||||
// About Page
|
||||
'about.hero.title': 'Sobre a Octto',
|
||||
'about.hero.subtitle': 'Conheça nossa trajetória, valores e o compromisso com a excelência na engenharia.',
|
||||
'about.history.pretitle': 'Nossa História',
|
||||
'about.history.title': 'Nossa História',
|
||||
'about.history.subtitle': 'Engenharia que impulsiona a logística',
|
||||
'about.history.p1': 'A Octto Engenharia nasceu da necessidade do mercado por soluções técnicas especializadas em movimentação de carga e implementos rodoviários. Identificamos que grandes frotas careciam de engenharia de ponta para garantir segurança e eficiência.',
|
||||
'about.history.p2': 'Hoje, somos parceiros estratégicos de grandes empresas de distribuição, como a Coca-Cola, desenvolvendo projetos de adequação, manutenção e certificação de equipamentos que são vitais para a cadeia logística nacional.',
|
||||
'about.values.pretitle': 'Nossos Pilares',
|
||||
'about.values.title': 'Nossos Pilares',
|
||||
'about.values.subtitle': 'Valores que nos guiam',
|
||||
'about.values.1.title': 'Excelência Técnica',
|
||||
'about.values.1.desc': 'Busca incessante pela perfeição em cada detalhe construtivo e de projeto.',
|
||||
'about.values.2.title': 'Transparência',
|
||||
'about.values.2.desc': 'Relacionamento claro e honesto com clientes, fornecedores e colaboradores.',
|
||||
'about.values.3.title': 'Sustentabilidade',
|
||||
'about.values.3.desc': 'Compromisso com práticas que respeitam o meio ambiente e a sociedade.',
|
||||
'about.values.quality.title': 'Excelência Técnica',
|
||||
'about.values.quality.desc': 'Busca incessante pela perfeição em cada detalhe construtivo e de projeto.',
|
||||
'about.values.transparency.title': 'Transparência',
|
||||
'about.values.transparency.desc': 'Relacionamento claro e honesto com clientes, fornecedores e colaboradores.',
|
||||
'about.values.sustainability.title': 'Sustentabilidade',
|
||||
'about.values.sustainability.desc': 'Compromisso com práticas que respeitam o meio ambiente e a sociedade.',
|
||||
|
||||
// Contact Page
|
||||
'contact.hero.title': 'Contato',
|
||||
'contact.hero.subtitle': 'Estamos prontos para ouvir sobre o seu projeto. Entre em contato conosco.',
|
||||
'contact.info.pretitle': 'Fale Conosco',
|
||||
'contact.info.title': 'Canais de Atendimento',
|
||||
'contact.info.subtitle': 'Entre em contato pelos nossos canais oficiais',
|
||||
'contact.info.whatsapp.desc': 'Atendimento rápido e direto.',
|
||||
'contact.info.email.desc': 'Para orçamentos e dúvidas técnicas.',
|
||||
'contact.info.office.title': 'Escritório',
|
||||
'contact.info.phone.title': 'WhatsApp',
|
||||
'contact.info.email.title': 'E-mail',
|
||||
'contact.info.address.title': 'Escritório',
|
||||
'contact.form.title': 'Envie uma mensagem',
|
||||
'contact.form.name': 'Nome',
|
||||
'contact.form.name.placeholder': 'Seu nome',
|
||||
'contact.form.phone': 'Telefone',
|
||||
'contact.form.email': 'E-mail',
|
||||
'contact.form.email.placeholder': 'seu@email.com',
|
||||
'contact.form.subject': 'Assunto',
|
||||
'contact.form.message': 'Mensagem',
|
||||
'contact.form.message.placeholder': 'Como podemos ajudar?',
|
||||
'contact.form.submit': 'Enviar Mensagem',
|
||||
'contact.form.subject.select': 'Selecione um assunto',
|
||||
'contact.form.subject.quote': 'Solicitar Orçamento',
|
||||
'contact.form.subject.doubt': 'Dúvida Técnica',
|
||||
'contact.form.subject.partnership': 'Parceria',
|
||||
'contact.form.subject.other': 'Trabalhe Conosco',
|
||||
|
||||
// Cookie Consent
|
||||
'cookie.text': 'Utilizamos cookies para melhorar sua experiência e analisar o tráfego do site. Ao continuar navegando, você concorda com nossa',
|
||||
'cookie.policy': 'Política de Privacidade',
|
||||
'cookie.accept': 'Aceitar',
|
||||
'cookie.decline': 'Recusar',
|
||||
|
||||
// WhatsApp
|
||||
'whatsapp.label': 'Atendimento Rápido',
|
||||
},
|
||||
EN: {
|
||||
'nav.home': 'Home',
|
||||
'nav.services': 'Services',
|
||||
'nav.projects': 'Projects',
|
||||
'nav.contact': 'Contact',
|
||||
'nav.about': 'About',
|
||||
'nav.search': 'Search...',
|
||||
'nav.contact_us': 'Contact Us',
|
||||
'nav.theme': 'Theme',
|
||||
'nav.language': 'Language',
|
||||
'footer.rights': 'All rights reserved.',
|
||||
|
||||
// Home - Hero
|
||||
'home.hero.badge': 'Official Service Provider',
|
||||
'home.hero.title': 'Engineering of',
|
||||
'home.hero.title_highlight': 'Lifting Devices',
|
||||
'home.hero.subtitle': 'We develop projects, reports and technical solutions for load handling equipment. Safety and regulatory compliance for your operation.',
|
||||
'home.hero.cta_primary': 'Talk to an Engineer',
|
||||
'home.hero.cta_secondary': 'View Solutions',
|
||||
|
||||
// Home - Features
|
||||
'home.features.pretitle': 'Differentials',
|
||||
'home.features.title': 'Safety and Efficiency',
|
||||
'home.features.1.title': 'Technical Standards',
|
||||
'home.features.1.desc': 'Projects and adaptations strictly aligned with NR-12, NR-11 standards and CONTRAN resolutions.',
|
||||
'home.features.2.title': 'Mechanical Engineering',
|
||||
'home.features.2.desc': 'Development of lifting devices and custom solutions to optimize your logistics.',
|
||||
'home.features.3.title': 'Implement Projects',
|
||||
'home.features.3.desc': 'Specialized engineering for installation and adaptation of Cranes, platforms and devices on cargo vehicles.',
|
||||
|
||||
// Home - Services
|
||||
'home.services.pretitle': 'What we do',
|
||||
'home.services.title': 'Specialized Solutions',
|
||||
'home.services.1.title': 'Mechanical Projects',
|
||||
'home.services.1.desc': 'Development of lifting devices (Spreaders, Beams).',
|
||||
'home.services.2.title': 'Technical Reports',
|
||||
'home.services.2.desc': 'Inspection and certification of cargo equipment according to standards.',
|
||||
'home.services.3.title': 'NR-12 Adaptation',
|
||||
'home.services.3.desc': 'Safety projects for machinery and equipment.',
|
||||
'home.services.4.title': 'Vehicular Engineering',
|
||||
'home.services.4.desc': 'Projects for equipment installation on trucks.',
|
||||
'home.services.link': 'View all services',
|
||||
|
||||
// Home - About
|
||||
'home.about.pretitle': 'About Us',
|
||||
'home.about.title': 'Engineering that ensures safety',
|
||||
'home.about.desc': 'Octto Engineering is a technical partner for major logistics companies. We do not operate fleets, we ensure that the equipment moving your cargo is safe, efficient and compliant.',
|
||||
'home.about.list.1': 'Lifting Device Projects',
|
||||
'home.about.list.2': 'Technical Reports for Cranes',
|
||||
'home.about.list.3': 'Technical Responsibility (ART) guaranteed',
|
||||
'home.about.link': 'Know our expertise',
|
||||
|
||||
// Home - Projects
|
||||
'home.projects.pretitle': 'Portfolio',
|
||||
'home.projects.title': 'Recent Projects',
|
||||
'home.projects.link': 'View all projects',
|
||||
'home.projects.1.cat': 'Vehicular Engineering',
|
||||
'home.projects.1.title': 'Adaptation Project - Coca-Cola',
|
||||
'home.projects.2.cat': 'Technical Inspection',
|
||||
'home.projects.2.title': 'Articulated Crane Report',
|
||||
'home.projects.3.cat': 'Mechanical Project',
|
||||
'home.projects.3.title': 'Special Lifting Device',
|
||||
'home.projects.4.cat': 'Reports',
|
||||
'home.projects.4.title': 'NR-12 Certification - Industrial Park',
|
||||
'home.projects.5.cat': 'Vehicular Engineering',
|
||||
'home.projects.5.title': 'Lifting Platform Homologation',
|
||||
'home.projects.6.cat': 'Work Safety',
|
||||
'home.projects.6.title': 'Lifeline Project for Trucks',
|
||||
'home.projects.view_details': 'View details',
|
||||
|
||||
// Home - Testimonials
|
||||
'home.testimonials.pretitle': 'Testimonials',
|
||||
'home.testimonials.title': 'Partners who trust',
|
||||
'home.testimonials.1.text': 'Octto performed the adaptation of our entire truck fleet with technical excellence and speed.',
|
||||
'home.testimonials.1.role': 'Fleet Manager, Beverage Distributor',
|
||||
'home.testimonials.2.text': 'The technical reports issued by Octto gave us total legal and operational security.',
|
||||
'home.testimonials.2.role': 'Operations Director, Logistics Express',
|
||||
'home.testimonials.3.text': 'The lifting device project solved an old bottleneck in our production. Highly recommend.',
|
||||
'home.testimonials.3.role': 'Chief Engineer, Metallurgical Industry',
|
||||
|
||||
// Home - CTA
|
||||
'home.cta.title': 'Ready to start your project?',
|
||||
'home.cta.desc': 'Contact us today and discover how we can help transform your vision into reality.',
|
||||
'home.cta.button': 'Talk to a Specialist',
|
||||
|
||||
// Services Page
|
||||
'services.hero.title': 'Our Services',
|
||||
'services.hero.subtitle': 'Complete solutions in mechanical engineering and load handling.',
|
||||
'services.cta.title': 'Need a custom solution?',
|
||||
'services.cta.button': 'Talk to an Engineer',
|
||||
'services.scope': 'Service Scope',
|
||||
'services.title': 'Services',
|
||||
|
||||
// Projects Page
|
||||
'projects.hero.title': 'Our Projects',
|
||||
'projects.hero.subtitle': 'Explore our portfolio of solutions in load handling and mechanical engineering.',
|
||||
'projects.filter.all': 'All',
|
||||
'projects.filter.implements': 'Implements',
|
||||
'projects.filter.mechanical': 'Mechanical Projects',
|
||||
'projects.filter.reports': 'Reports',
|
||||
'projects.card.details': 'View details',
|
||||
|
||||
// About Page
|
||||
'about.hero.title': 'About Octto',
|
||||
'about.hero.subtitle': 'Know our trajectory, values and commitment to engineering excellence.',
|
||||
'about.history.pretitle': 'Our History',
|
||||
'about.history.title': 'Our History',
|
||||
'about.history.subtitle': 'Engineering that drives logistics',
|
||||
'about.history.p1': 'Octto Engineering was born from the market need for specialized technical solutions in load handling and road implements. We identified that large fleets lacked cutting-edge engineering to ensure safety and efficiency.',
|
||||
'about.history.p2': 'Today, we are strategic partners of major distribution companies, such as Coca-Cola, developing adaptation, maintenance and equipment certification projects that are vital to the national logistics chain.',
|
||||
'about.values.pretitle': 'Our Pillars',
|
||||
'about.values.title': 'Our Pillars',
|
||||
'about.values.subtitle': 'Values that guide us',
|
||||
'about.values.1.title': 'Technical Excellence',
|
||||
'about.values.1.desc': 'Relentless pursuit of perfection in every constructive and design detail.',
|
||||
'about.values.2.title': 'Transparency',
|
||||
'about.values.2.desc': 'Clear and honest relationship with customers, suppliers and employees.',
|
||||
'about.values.3.title': 'Sustainability',
|
||||
'about.values.3.desc': 'Commitment to practices that respect the environment and society.',
|
||||
'about.values.quality.title': 'Technical Excellence',
|
||||
'about.values.quality.desc': 'Relentless pursuit of perfection in every constructive and design detail.',
|
||||
'about.values.transparency.title': 'Transparency',
|
||||
'about.values.transparency.desc': 'Clear and honest relationship with customers, suppliers and employees.',
|
||||
'about.values.sustainability.title': 'Sustainability',
|
||||
'about.values.sustainability.desc': 'Commitment to practices that respect the environment and society.',
|
||||
|
||||
// Contact Page
|
||||
'contact.hero.title': 'Contact',
|
||||
'contact.hero.subtitle': 'We are ready to hear about your project. Contact us.',
|
||||
'contact.info.pretitle': 'Contact Us',
|
||||
'contact.info.title': 'Service Channels',
|
||||
'contact.info.subtitle': 'Contact us through our official channels',
|
||||
'contact.info.whatsapp.desc': 'Fast and direct service.',
|
||||
'contact.info.email.desc': 'For quotes and technical questions.',
|
||||
'contact.info.office.title': 'Office',
|
||||
'contact.info.phone.title': 'WhatsApp',
|
||||
'contact.info.email.title': 'E-mail',
|
||||
'contact.info.address.title': 'Office',
|
||||
'contact.form.title': 'Send a message',
|
||||
'contact.form.name': 'Name',
|
||||
'contact.form.name.placeholder': 'Your name',
|
||||
'contact.form.phone': 'Phone',
|
||||
'contact.form.email': 'E-mail',
|
||||
'contact.form.email.placeholder': 'your@email.com',
|
||||
'contact.form.subject': 'Subject',
|
||||
'contact.form.message': 'Message',
|
||||
'contact.form.message.placeholder': 'How can we help?',
|
||||
'contact.form.submit': 'Send Message',
|
||||
'contact.form.subject.select': 'Select a subject',
|
||||
'contact.form.subject.quote': 'Request Quote',
|
||||
'contact.form.subject.doubt': 'Technical Question',
|
||||
'contact.form.subject.partnership': 'Partnership',
|
||||
'contact.form.subject.other': 'Work with Us',
|
||||
|
||||
// Cookie Consent
|
||||
'cookie.text': 'We use cookies to improve your experience and analyze site traffic. By continuing to browse, you agree to our',
|
||||
'cookie.policy': 'Privacy Policy',
|
||||
'cookie.accept': 'Accept',
|
||||
'cookie.decline': 'Decline',
|
||||
|
||||
// WhatsApp
|
||||
'whatsapp.label': 'Quick Service',
|
||||
},
|
||||
ES: {
|
||||
'nav.home': 'Inicio',
|
||||
'nav.services': 'Servicios',
|
||||
'nav.projects': 'Proyectos',
|
||||
'nav.contact': 'Contacto',
|
||||
'nav.about': 'Sobre',
|
||||
'nav.search': 'Buscar...',
|
||||
'nav.contact_us': 'Hable con Nosotros',
|
||||
'nav.theme': 'Tema',
|
||||
'nav.language': 'Idioma',
|
||||
'footer.rights': 'Todos los derechos reservados.',
|
||||
|
||||
// Home - Hero
|
||||
'home.hero.badge': 'Proveedor de Servicio Oficial',
|
||||
'home.hero.title': 'Ingeniería de',
|
||||
'home.hero.title_highlight': 'Dispositivos de Elevación',
|
||||
'home.hero.subtitle': 'Desarrollamos proyectos, informes y soluciones técnicas para equipos de movimiento de carga. Seguridad y cumplimiento normativo para su operación.',
|
||||
'home.hero.cta_primary': 'Hablar con Ingeniero',
|
||||
'home.hero.cta_secondary': 'Ver Soluciones',
|
||||
|
||||
// Home - Features
|
||||
'home.features.pretitle': 'Diferenciales',
|
||||
'home.features.title': 'Seguridad y Eficiencia',
|
||||
'home.features.1.title': 'Normas Técnicas',
|
||||
'home.features.1.desc': 'Proyectos y adecuaciones rigurosamente alineados con las normas NR-12, NR-11 y resoluciones del CONTRAN.',
|
||||
'home.features.2.title': 'Ingeniería Mecánica',
|
||||
'home.features.2.desc': 'Desarrollo de dispositivos de elevación y soluciones personalizadas para optimizar su logística.',
|
||||
'home.features.3.title': 'Proyectos de Implementos',
|
||||
'home.features.3.desc': 'Ingeniería especializada para instalación y adecuación de Grúas, plataformas y dispositivos en vehículos de carga.',
|
||||
|
||||
// Home - Services
|
||||
'home.services.pretitle': 'Lo que hacemos',
|
||||
'home.services.title': 'Soluciones Especializadas',
|
||||
'home.services.1.title': 'Proyectos Mecánicos',
|
||||
'home.services.1.desc': 'Desarrollo de dispositivos de elevación (Spreaders, Balancines).',
|
||||
'home.services.2.title': 'Informes Técnicos',
|
||||
'home.services.2.desc': 'Inspección y certificación de equipos de carga conforme normas.',
|
||||
'home.services.3.title': 'Adecuación NR-12',
|
||||
'home.services.3.desc': 'Proyectos de seguridad para máquinas y equipos.',
|
||||
'home.services.4.title': 'Ingeniería Vehicular',
|
||||
'home.services.4.desc': 'Proyectos para instalación de equipos en camiones.',
|
||||
'home.services.link': 'Ver todos los servicios',
|
||||
|
||||
// Home - About
|
||||
'home.about.pretitle': 'Sobre Nosotros',
|
||||
'home.about.title': 'Ingeniería que garantiza seguridad',
|
||||
'home.about.desc': 'Octto Ingeniería es socia técnica de grandes empresas logísticas. No operamos flotas, garantizamos que los equipos que mueven su carga sean seguros, eficientes y cumplan con las normas.',
|
||||
'home.about.list.1': 'Proyectos de Dispositivos de Elevación',
|
||||
'home.about.list.2': 'Informes Técnicos para Grúas',
|
||||
'home.about.list.3': 'Responsabilidad Técnica (ART) garantizada',
|
||||
'home.about.link': 'Conozca nuestra experiencia',
|
||||
|
||||
// Home - Projects
|
||||
'home.projects.pretitle': 'Portafolio',
|
||||
'home.projects.title': 'Proyectos Recientes',
|
||||
'home.projects.link': 'Ver todos los proyectos',
|
||||
'home.projects.1.cat': 'Ingeniería Vehicular',
|
||||
'home.projects.1.title': 'Proyecto de Adecuación - Coca-Cola',
|
||||
'home.projects.2.cat': 'Inspección Técnica',
|
||||
'home.projects.2.title': 'Informe de Grúa Articulada',
|
||||
'home.projects.3.cat': 'Proyecto Mecánico',
|
||||
'home.projects.3.title': 'Dispositivo de Elevación Especial',
|
||||
'home.projects.4.cat': 'Informes',
|
||||
'home.projects.4.title': 'Certificación NR-12 - Parque Industrial',
|
||||
'home.projects.5.cat': 'Ingeniería Vehicular',
|
||||
'home.projects.5.title': 'Homologación de Plataforma Elevadora',
|
||||
'home.projects.6.cat': 'Seguridad Laboral',
|
||||
'home.projects.6.title': 'Proyecto de Línea de Vida para Camiones',
|
||||
'home.projects.view_details': 'Ver detalles',
|
||||
|
||||
// Home - Testimonials
|
||||
'home.testimonials.pretitle': 'Testimonios',
|
||||
'home.testimonials.title': 'Socios que confían',
|
||||
'home.testimonials.1.text': 'Octto realizó la adecuación de toda nuestra flota de camiones con excelencia técnica y rapidez.',
|
||||
'home.testimonials.1.role': 'Gerente de Flota, Distribuidora Bebidas',
|
||||
'home.testimonials.2.text': 'Los informes técnicos emitidos por Octto nos dieron total seguridad jurídica y operativa.',
|
||||
'home.testimonials.2.role': 'Directora Operativa, Logística Express',
|
||||
'home.testimonials.3.text': 'El proyecto del dispositivo de elevación resolvió un cuello de botella antiguo de nuestra producción. Recomiendo.',
|
||||
'home.testimonials.3.role': 'Ingeniero Jefe, Industria Metalúrgica',
|
||||
|
||||
// Home - CTA
|
||||
'home.cta.title': '¿Listo para iniciar su proyecto?',
|
||||
'home.cta.desc': 'Contáctenos hoy mismo y descubra cómo podemos ayudar a transformar su visión en realidad.',
|
||||
'home.cta.button': 'Hablar con un Especialista',
|
||||
|
||||
// Services Page
|
||||
'services.hero.title': 'Nuestros Servicios',
|
||||
'services.hero.subtitle': 'Soluciones completas en ingeniería mecánica y movimiento de carga.',
|
||||
'services.cta.title': '¿Necesita una solución personalizada?',
|
||||
'services.cta.button': 'Hablar con un Ingeniero',
|
||||
'services.scope': 'Alcance del Servicio',
|
||||
'services.title': 'Servicios',
|
||||
|
||||
// Projects Page
|
||||
'projects.hero.title': 'Nuestros Proyectos',
|
||||
'projects.hero.subtitle': 'Explore nuestro portafolio de soluciones en movimiento de carga e ingeniería mecánica.',
|
||||
'projects.filter.all': 'Todos',
|
||||
'projects.filter.implements': 'Implementos',
|
||||
'projects.filter.mechanical': 'Proyectos Mecánicos',
|
||||
'projects.filter.reports': 'Informes',
|
||||
'projects.card.details': 'Ver detalles',
|
||||
|
||||
// About Page
|
||||
'about.hero.title': 'Sobre Octto',
|
||||
'about.hero.subtitle': 'Conozca nuestra trayectoria, valores y el compromiso con la excelencia en la ingeniería.',
|
||||
'about.history.pretitle': 'Nuestra Historia',
|
||||
'about.history.title': 'Nuestra Historia',
|
||||
'about.history.subtitle': 'Ingeniería que impulsa la logística',
|
||||
'about.history.p1': 'Octto Ingeniería nació de la necesidad del mercado por soluciones técnicas especializadas en movimiento de carga e implementos viales. Identificamos que grandes flotas carecían de ingeniería de punta para garantizar seguridad y eficiencia.',
|
||||
'about.history.p2': 'Hoy, somos socios estratégicos de grandes empresas de distribución, como Coca-Cola, desarrollando proyectos de adecuación, mantenimiento y certificación de equipos que son vitales para la cadena logística nacional.',
|
||||
'about.values.pretitle': 'Nuestros Pilares',
|
||||
'about.values.title': 'Nuestros Pilares',
|
||||
'about.values.subtitle': 'Valores que nos guían',
|
||||
'about.values.1.title': 'Excelencia Técnica',
|
||||
'about.values.1.desc': 'Búsqueda incesante de la perfección en cada detalle construtivo e de diseño.',
|
||||
'about.values.2.title': 'Transparencia',
|
||||
'about.values.2.desc': 'Relación clara y honesta con clientes, proveedores y empleados.',
|
||||
'about.values.3.title': 'Sostenibilidad',
|
||||
'about.values.3.desc': 'Compromiso con prácticas que respetan el medio ambiente y la sociedad.',
|
||||
'about.values.quality.title': 'Excelência Técnica',
|
||||
'about.values.quality.desc': 'Búsqueda incesante de la perfección en cada detalhe construtivo e de projeto.',
|
||||
'about.values.transparency.title': 'Transparência',
|
||||
'about.values.transparency.desc': 'Relacionamento claro e honesto com clientes, fornecedores e colaboradores.',
|
||||
'about.values.sustainability.title': 'Sustentabilidade',
|
||||
'about.values.sustainability.desc': 'Compromisso com práticas que respeitam o meio ambiente e a sociedade.',
|
||||
|
||||
// Contact Page
|
||||
'contact.hero.title': 'Contacto',
|
||||
'contact.hero.subtitle': 'Estamos listos para escuchar sobre su proyecto. Contáctenos.',
|
||||
'contact.info.pretitle': 'Hable con Nosotros',
|
||||
'contact.info.title': 'Canales de Atención',
|
||||
'contact.info.subtitle': 'Contáctenos a través de nuestros canales oficiales',
|
||||
'contact.info.whatsapp.desc': 'Atención rápida y directa.',
|
||||
'contact.info.email.desc': 'Para presupuestos y dudas técnicas.',
|
||||
'contact.info.office.title': 'Oficina',
|
||||
'contact.info.phone.title': 'WhatsApp',
|
||||
'contact.info.email.title': 'E-mail',
|
||||
'contact.info.address.title': 'Oficina',
|
||||
'contact.form.title': 'Envíe un mensaje',
|
||||
'contact.form.name': 'Nombre',
|
||||
'contact.form.name.placeholder': 'Su nombre',
|
||||
'contact.form.phone': 'Teléfono',
|
||||
'contact.form.email': 'E-mail',
|
||||
'contact.form.email.placeholder': 'su@email.com',
|
||||
'contact.form.subject': 'Asunto',
|
||||
'contact.form.message': 'Mensaje',
|
||||
'contact.form.message.placeholder': '¿Cómo podemos ayudar?',
|
||||
'contact.form.submit': 'Enviar Mensaje',
|
||||
'contact.form.subject.select': 'Seleccione un asunto',
|
||||
'contact.form.subject.budget': 'Solicitar Presupuesto',
|
||||
'contact.form.subject.tech': 'Duda Técnica',
|
||||
'contact.form.subject.partnership': 'Asociación',
|
||||
'contact.form.subject.work': 'Trabaje con Nosotros',
|
||||
|
||||
// Cookie Consent
|
||||
'cookie.text': 'Utilizamos cookies para mejorar su experiencia y analizar el tráfico del sitio. Al continuar navegando, acepta nuestra',
|
||||
'cookie.policy': 'Política de Privacidad',
|
||||
'cookie.accept': 'Aceptar',
|
||||
'cookie.decline': 'Rechazar',
|
||||
|
||||
// WhatsApp
|
||||
'whatsapp.label': 'Atención Rápida',
|
||||
}
|
||||
};
|
||||
|
||||
export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [language, setLanguage] = useState<Language>('PT');
|
||||
|
||||
const t = (key: string): string => {
|
||||
return translations[language][key as keyof typeof translations[typeof language]] || key;
|
||||
};
|
||||
|
||||
const tDynamic = (content: { PT: string, EN?: string, ES?: string }): string => {
|
||||
if (language === 'PT') return content.PT;
|
||||
if (language === 'EN' && content.EN) return content.EN;
|
||||
if (language === 'ES' && content.ES) return content.ES;
|
||||
return content.PT;
|
||||
};
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={{ language, setLanguage, t, tDynamic }}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useLanguage = () => {
|
||||
const context = useContext(LanguageContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useLanguage must be used within a LanguageProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
61
frontend/src/contexts/ToastContext.tsx
Normal file
61
frontend/src/contexts/ToastContext.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
import Toast from '@/components/Toast';
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
success: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
warning: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | undefined>(undefined);
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = useCallback((message: string, type: Toast['type']) => {
|
||||
const id = Date.now();
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const success = useCallback((message: string) => showToast(message, 'success'), [showToast]);
|
||||
const error = useCallback((message: string) => showToast(message, 'error'), [showToast]);
|
||||
const warning = useCallback((message: string) => showToast(message, 'warning'), [showToast]);
|
||||
const info = useCallback((message: string) => showToast(message, 'info'), [showToast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ success, error, warning, info }}>
|
||||
{children}
|
||||
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => removeToast(toast.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within ToastProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
44
frontend/src/generated/client/browser.ts
Normal file
44
frontend/src/generated/client/browser.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This file should be your main import to use Prisma-related types and utilities in a browser.
|
||||
* Use it to get access to models, enums, and input types.
|
||||
*
|
||||
* This file does not contain a `PrismaClient` class, nor several other helpers that are intended as server-side only.
|
||||
* See `client.ts` for the standard, server-side entry point.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
import * as Prisma from './internal/prismaNamespaceBrowser'
|
||||
export { Prisma }
|
||||
export * as $Enums from './enums'
|
||||
export * from './enums';
|
||||
/**
|
||||
* Model User
|
||||
*
|
||||
*/
|
||||
export type User = Prisma.UserModel
|
||||
/**
|
||||
* Model Project
|
||||
*
|
||||
*/
|
||||
export type Project = Prisma.ProjectModel
|
||||
/**
|
||||
* Model Service
|
||||
*
|
||||
*/
|
||||
export type Service = Prisma.ServiceModel
|
||||
/**
|
||||
* Model Message
|
||||
*
|
||||
*/
|
||||
export type Message = Prisma.MessageModel
|
||||
/**
|
||||
* Model PageContent
|
||||
*
|
||||
*/
|
||||
export type PageContent = Prisma.PageContentModel
|
||||
66
frontend/src/generated/client/client.ts
Normal file
66
frontend/src/generated/client/client.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types.
|
||||
* If you're looking for something you can import in the client-side of your application, please refer to the `browser.ts` file instead.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
import * as process from 'node:process'
|
||||
import * as path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
globalThis['__dirname'] = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
import * as runtime from "@prisma/client/runtime/client"
|
||||
import * as $Enums from "./enums"
|
||||
import * as $Class from "./internal/class"
|
||||
import * as Prisma from "./internal/prismaNamespace"
|
||||
|
||||
export * as $Enums from './enums'
|
||||
export * from "./enums"
|
||||
/**
|
||||
* ## Prisma Client
|
||||
*
|
||||
* Type-safe database client for TypeScript
|
||||
* @example
|
||||
* ```
|
||||
* const prisma = new PrismaClient()
|
||||
* // Fetch zero or more Users
|
||||
* const users = await prisma.user.findMany()
|
||||
* ```
|
||||
*
|
||||
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client).
|
||||
*/
|
||||
export const PrismaClient = $Class.getPrismaClientClass()
|
||||
export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>
|
||||
export { Prisma }
|
||||
|
||||
/**
|
||||
* Model User
|
||||
*
|
||||
*/
|
||||
export type User = Prisma.UserModel
|
||||
/**
|
||||
* Model Project
|
||||
*
|
||||
*/
|
||||
export type Project = Prisma.ProjectModel
|
||||
/**
|
||||
* Model Service
|
||||
*
|
||||
*/
|
||||
export type Service = Prisma.ServiceModel
|
||||
/**
|
||||
* Model Message
|
||||
*
|
||||
*/
|
||||
export type Message = Prisma.MessageModel
|
||||
/**
|
||||
* Model PageContent
|
||||
*
|
||||
*/
|
||||
export type PageContent = Prisma.PageContentModel
|
||||
427
frontend/src/generated/client/commonInputTypes.ts
Normal file
427
frontend/src/generated/client/commonInputTypes.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This file exports various common sort, input & filter types that are not directly linked to a particular model.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
import type * as runtime from "@prisma/client/runtime/client"
|
||||
import * as $Enums from "./enums"
|
||||
import type * as Prisma from "./internal/prismaNamespace"
|
||||
|
||||
|
||||
export type StringFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedStringFilter<$PrismaModel> | string
|
||||
}
|
||||
|
||||
export type StringNullableFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
|
||||
}
|
||||
|
||||
export type DateTimeFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
|
||||
}
|
||||
|
||||
export type SortOrderInput = {
|
||||
sort: Prisma.SortOrder
|
||||
nulls?: Prisma.NullsOrder
|
||||
}
|
||||
|
||||
export type StringWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type DateTimeNullableFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||
}
|
||||
|
||||
export type BoolFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
|
||||
}
|
||||
|
||||
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type BoolWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type IntFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntFilter<$PrismaModel> | number
|
||||
}
|
||||
|
||||
export type IntWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type JsonFilter<$PrismaModel = never> =
|
||||
| Prisma.PatchUndefined<
|
||||
Prisma.Either<Required<JsonFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonFilterBase<$PrismaModel>>, 'path'>>,
|
||||
Required<JsonFilterBase<$PrismaModel>>
|
||||
>
|
||||
| Prisma.OptionalFlat<Omit<Required<JsonFilterBase<$PrismaModel>>, 'path'>>
|
||||
|
||||
export type JsonFilterBase<$PrismaModel = never> = {
|
||||
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
path?: string[]
|
||||
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
}
|
||||
|
||||
export type JsonWithAggregatesFilter<$PrismaModel = never> =
|
||||
| Prisma.PatchUndefined<
|
||||
Prisma.Either<Required<JsonWithAggregatesFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonWithAggregatesFilterBase<$PrismaModel>>, 'path'>>,
|
||||
Required<JsonWithAggregatesFilterBase<$PrismaModel>>
|
||||
>
|
||||
| Prisma.OptionalFlat<Omit<Required<JsonWithAggregatesFilterBase<$PrismaModel>>, 'path'>>
|
||||
|
||||
export type JsonWithAggregatesFilterBase<$PrismaModel = never> = {
|
||||
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
path?: string[]
|
||||
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedJsonFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedJsonFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedStringFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringFilter<$PrismaModel> | string
|
||||
}
|
||||
|
||||
export type NestedStringNullableFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
|
||||
}
|
||||
|
||||
export type NestedDateTimeFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
|
||||
}
|
||||
|
||||
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedIntFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntFilter<$PrismaModel> | number
|
||||
}
|
||||
|
||||
export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedIntNullableFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
|
||||
}
|
||||
|
||||
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||
}
|
||||
|
||||
export type NestedBoolFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
|
||||
}
|
||||
|
||||
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedFloatFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
|
||||
}
|
||||
|
||||
export type NestedJsonFilter<$PrismaModel = never> =
|
||||
| Prisma.PatchUndefined<
|
||||
Prisma.Either<Required<NestedJsonFilterBase<$PrismaModel>>, Exclude<keyof Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>,
|
||||
Required<NestedJsonFilterBase<$PrismaModel>>
|
||||
>
|
||||
| Prisma.OptionalFlat<Omit<Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>
|
||||
|
||||
export type NestedJsonFilterBase<$PrismaModel = never> = {
|
||||
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
path?: string[]
|
||||
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
}
|
||||
|
||||
|
||||
15
frontend/src/generated/client/enums.ts
Normal file
15
frontend/src/generated/client/enums.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This file exports all enum related types from the schema.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
// This file is empty because there are no enums in the schema.
|
||||
export {}
|
||||
230
frontend/src/generated/client/internal/class.ts
Normal file
230
frontend/src/generated/client/internal/class.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* WARNING: This is an internal file that is subject to change!
|
||||
*
|
||||
* 🛑 Under no circumstances should you import this file directly! 🛑
|
||||
*
|
||||
* Please import the `PrismaClient` class from the `client.ts` file instead.
|
||||
*/
|
||||
|
||||
import * as runtime from "@prisma/client/runtime/client"
|
||||
import type * as Prisma from "./prismaNamespace"
|
||||
|
||||
|
||||
const config: runtime.GetPrismaClientConfig = {
|
||||
"previewFeatures": [],
|
||||
"clientVersion": "7.0.1",
|
||||
"engineVersion": "f09f2815f091dbba658cdcd2264306d88bb5bda6",
|
||||
"activeProvider": "postgresql",
|
||||
"inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/generated/client\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\n// Modelo de Usuário (para o Painel Admin)\nmodel User {\n id String @id @default(cuid())\n email String @unique\n password String\n name String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\n// Modelo de Projeto\nmodel Project {\n id String @id @default(cuid())\n title String\n category String\n client String?\n status String @default(\"Em andamento\") // \"Em andamento\", \"Concluído\"\n completionDate DateTime?\n description String? @db.Text\n coverImage String?\n galleryImages String[]\n featured Boolean @default(false)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\n// Modelo de Serviço\nmodel Service {\n id String @id @default(cuid())\n title String\n icon String\n shortDescription String?\n fullDescription String? @db.Text\n active Boolean @default(true)\n order Int @default(0)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\n// Modelo de Mensagem (Contato)\nmodel Message {\n id String @id @default(cuid())\n name String\n email String\n subject String\n message String @db.Text\n status String @default(\"Nova\") // \"Nova\", \"Lida\", \"Respondida\"\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\n// Modelo de Conteúdo de Página (para textos editáveis)\nmodel PageContent {\n id String @id @default(cuid())\n slug String @unique // \"home\", \"sobre\", \"contato\"\n content Json\n updatedAt DateTime @updatedAt\n}\n",
|
||||
"runtimeDataModel": {
|
||||
"models": {},
|
||||
"enums": {},
|
||||
"types": {}
|
||||
}
|
||||
}
|
||||
|
||||
config.runtimeDataModel = JSON.parse("{\"models\":{\"User\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"password\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Project\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"category\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"client\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"status\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"completionDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"coverImage\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"galleryImages\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"featured\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Service\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"icon\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"shortDescription\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"fullDescription\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"active\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"order\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Message\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"subject\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"message\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"status\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"PageContent\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"slug\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"content\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}")
|
||||
|
||||
async function decodeBase64AsWasm(wasmBase64: string): Promise<WebAssembly.Module> {
|
||||
const { Buffer } = await import('node:buffer')
|
||||
const wasmArray = Buffer.from(wasmBase64, 'base64')
|
||||
return new WebAssembly.Module(wasmArray)
|
||||
}
|
||||
|
||||
config.compilerWasm = {
|
||||
getRuntime: async () => await import("@prisma/client/runtime/query_compiler_bg.postgresql.mjs"),
|
||||
|
||||
getQueryCompilerWasmModule: async () => {
|
||||
const { wasm } = await import("@prisma/client/runtime/query_compiler_bg.postgresql.wasm-base64.mjs")
|
||||
return await decodeBase64AsWasm(wasm)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export type LogOptions<ClientOptions extends Prisma.PrismaClientOptions> =
|
||||
'log' extends keyof ClientOptions ? ClientOptions['log'] extends Array<Prisma.LogLevel | Prisma.LogDefinition> ? Prisma.GetEvents<ClientOptions['log']> : never : never
|
||||
|
||||
export interface PrismaClientConstructor {
|
||||
/**
|
||||
* ## Prisma Client
|
||||
*
|
||||
* Type-safe database client for TypeScript
|
||||
* @example
|
||||
* ```
|
||||
* const prisma = new PrismaClient()
|
||||
* // Fetch zero or more Users
|
||||
* const users = await prisma.user.findMany()
|
||||
* ```
|
||||
*
|
||||
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client).
|
||||
*/
|
||||
|
||||
new <
|
||||
Options extends Prisma.PrismaClientOptions = Prisma.PrismaClientOptions,
|
||||
LogOpts extends LogOptions<Options> = LogOptions<Options>,
|
||||
OmitOpts extends Prisma.PrismaClientOptions['omit'] = Options extends { omit: infer U } ? U : Prisma.PrismaClientOptions['omit'],
|
||||
ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs
|
||||
>(options: Prisma.Subset<Options, Prisma.PrismaClientOptions> ): PrismaClient<LogOpts, OmitOpts, ExtArgs>
|
||||
}
|
||||
|
||||
/**
|
||||
* ## Prisma Client
|
||||
*
|
||||
* Type-safe database client for TypeScript
|
||||
* @example
|
||||
* ```
|
||||
* const prisma = new PrismaClient()
|
||||
* // Fetch zero or more Users
|
||||
* const users = await prisma.user.findMany()
|
||||
* ```
|
||||
*
|
||||
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client).
|
||||
*/
|
||||
|
||||
export interface PrismaClient<
|
||||
in LogOpts extends Prisma.LogLevel = never,
|
||||
in out OmitOpts extends Prisma.PrismaClientOptions['omit'] = undefined,
|
||||
in out ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs
|
||||
> {
|
||||
[K: symbol]: { types: Prisma.TypeMap<ExtArgs>['other'] }
|
||||
|
||||
$on<V extends LogOpts>(eventType: V, callback: (event: V extends 'query' ? Prisma.QueryEvent : Prisma.LogEvent) => void): PrismaClient;
|
||||
|
||||
/**
|
||||
* Connect with the database
|
||||
*/
|
||||
$connect(): runtime.Types.Utils.JsPromise<void>;
|
||||
|
||||
/**
|
||||
* Disconnect from the database
|
||||
*/
|
||||
$disconnect(): runtime.Types.Utils.JsPromise<void>;
|
||||
|
||||
/**
|
||||
* Executes a prepared raw query and returns the number of affected rows.
|
||||
* @example
|
||||
* ```
|
||||
* const result = await prisma.$executeRaw`UPDATE User SET cool = ${true} WHERE email = ${'user@email.com'};`
|
||||
* ```
|
||||
*
|
||||
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/raw-database-access).
|
||||
*/
|
||||
$executeRaw<T = unknown>(query: TemplateStringsArray | Prisma.Sql, ...values: any[]): Prisma.PrismaPromise<number>;
|
||||
|
||||
/**
|
||||
* Executes a raw query and returns the number of affected rows.
|
||||
* Susceptible to SQL injections, see documentation.
|
||||
* @example
|
||||
* ```
|
||||
* const result = await prisma.$executeRawUnsafe('UPDATE User SET cool = $1 WHERE email = $2 ;', true, 'user@email.com')
|
||||
* ```
|
||||
*
|
||||
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/raw-database-access).
|
||||
*/
|
||||
$executeRawUnsafe<T = unknown>(query: string, ...values: any[]): Prisma.PrismaPromise<number>;
|
||||
|
||||
/**
|
||||
* Performs a prepared raw query and returns the `SELECT` data.
|
||||
* @example
|
||||
* ```
|
||||
* const result = await prisma.$queryRaw`SELECT * FROM User WHERE id = ${1} OR email = ${'user@email.com'};`
|
||||
* ```
|
||||
*
|
||||
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/raw-database-access).
|
||||
*/
|
||||
$queryRaw<T = unknown>(query: TemplateStringsArray | Prisma.Sql, ...values: any[]): Prisma.PrismaPromise<T>;
|
||||
|
||||
/**
|
||||
* Performs a raw query and returns the `SELECT` data.
|
||||
* Susceptible to SQL injections, see documentation.
|
||||
* @example
|
||||
* ```
|
||||
* const result = await prisma.$queryRawUnsafe('SELECT * FROM User WHERE id = $1 OR email = $2;', 1, 'user@email.com')
|
||||
* ```
|
||||
*
|
||||
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/raw-database-access).
|
||||
*/
|
||||
$queryRawUnsafe<T = unknown>(query: string, ...values: any[]): Prisma.PrismaPromise<T>;
|
||||
|
||||
|
||||
/**
|
||||
* Allows the running of a sequence of read/write operations that are guaranteed to either succeed or fail as a whole.
|
||||
* @example
|
||||
* ```
|
||||
* const [george, bob, alice] = await prisma.$transaction([
|
||||
* prisma.user.create({ data: { name: 'George' } }),
|
||||
* prisma.user.create({ data: { name: 'Bob' } }),
|
||||
* prisma.user.create({ data: { name: 'Alice' } }),
|
||||
* ])
|
||||
* ```
|
||||
*
|
||||
* Read more in our [docs](https://www.prisma.io/docs/concepts/components/prisma-client/transactions).
|
||||
*/
|
||||
$transaction<P extends Prisma.PrismaPromise<any>[]>(arg: [...P], options?: { isolationLevel?: Prisma.TransactionIsolationLevel }): runtime.Types.Utils.JsPromise<runtime.Types.Utils.UnwrapTuple<P>>
|
||||
|
||||
$transaction<R>(fn: (prisma: Omit<PrismaClient, runtime.ITXClientDenyList>) => runtime.Types.Utils.JsPromise<R>, options?: { maxWait?: number, timeout?: number, isolationLevel?: Prisma.TransactionIsolationLevel }): runtime.Types.Utils.JsPromise<R>
|
||||
|
||||
$extends: runtime.Types.Extensions.ExtendsHook<"extends", Prisma.TypeMapCb<OmitOpts>, ExtArgs, runtime.Types.Utils.Call<Prisma.TypeMapCb<OmitOpts>, {
|
||||
extArgs: ExtArgs
|
||||
}>>
|
||||
|
||||
/**
|
||||
* `prisma.user`: Exposes CRUD operations for the **User** model.
|
||||
* Example usage:
|
||||
* ```ts
|
||||
* // Fetch zero or more Users
|
||||
* const users = await prisma.user.findMany()
|
||||
* ```
|
||||
*/
|
||||
get user(): Prisma.UserDelegate<ExtArgs, { omit: OmitOpts }>;
|
||||
|
||||
/**
|
||||
* `prisma.project`: Exposes CRUD operations for the **Project** model.
|
||||
* Example usage:
|
||||
* ```ts
|
||||
* // Fetch zero or more Projects
|
||||
* const projects = await prisma.project.findMany()
|
||||
* ```
|
||||
*/
|
||||
get project(): Prisma.ProjectDelegate<ExtArgs, { omit: OmitOpts }>;
|
||||
|
||||
/**
|
||||
* `prisma.service`: Exposes CRUD operations for the **Service** model.
|
||||
* Example usage:
|
||||
* ```ts
|
||||
* // Fetch zero or more Services
|
||||
* const services = await prisma.service.findMany()
|
||||
* ```
|
||||
*/
|
||||
get service(): Prisma.ServiceDelegate<ExtArgs, { omit: OmitOpts }>;
|
||||
|
||||
/**
|
||||
* `prisma.message`: Exposes CRUD operations for the **Message** model.
|
||||
* Example usage:
|
||||
* ```ts
|
||||
* // Fetch zero or more Messages
|
||||
* const messages = await prisma.message.findMany()
|
||||
* ```
|
||||
*/
|
||||
get message(): Prisma.MessageDelegate<ExtArgs, { omit: OmitOpts }>;
|
||||
|
||||
/**
|
||||
* `prisma.pageContent`: Exposes CRUD operations for the **PageContent** model.
|
||||
* Example usage:
|
||||
* ```ts
|
||||
* // Fetch zero or more PageContents
|
||||
* const pageContents = await prisma.pageContent.findMany()
|
||||
* ```
|
||||
*/
|
||||
get pageContent(): Prisma.PageContentDelegate<ExtArgs, { omit: OmitOpts }>;
|
||||
}
|
||||
|
||||
export function getPrismaClientClass(): PrismaClientConstructor {
|
||||
return runtime.getPrismaClient(config) as unknown as PrismaClientConstructor
|
||||
}
|
||||
1157
frontend/src/generated/client/internal/prismaNamespace.ts
Normal file
1157
frontend/src/generated/client/internal/prismaNamespace.ts
Normal file
File diff suppressed because it is too large
Load Diff
184
frontend/src/generated/client/internal/prismaNamespaceBrowser.ts
Normal file
184
frontend/src/generated/client/internal/prismaNamespaceBrowser.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* WARNING: This is an internal file that is subject to change!
|
||||
*
|
||||
* 🛑 Under no circumstances should you import this file directly! 🛑
|
||||
*
|
||||
* All exports from this file are wrapped under a `Prisma` namespace object in the browser.ts file.
|
||||
* While this enables partial backward compatibility, it is not part of the stable public API.
|
||||
*
|
||||
* If you are looking for your Models, Enums, and Input Types, please import them from the respective
|
||||
* model files in the `model` directory!
|
||||
*/
|
||||
|
||||
import * as runtime from "@prisma/client/runtime/index-browser"
|
||||
|
||||
export type * from '../models'
|
||||
export type * from './prismaNamespace'
|
||||
|
||||
export const Decimal = runtime.Decimal
|
||||
|
||||
|
||||
export const NullTypes = {
|
||||
DbNull: runtime.NullTypes.DbNull as (new (secret: never) => typeof runtime.DbNull),
|
||||
JsonNull: runtime.NullTypes.JsonNull as (new (secret: never) => typeof runtime.JsonNull),
|
||||
AnyNull: runtime.NullTypes.AnyNull as (new (secret: never) => typeof runtime.AnyNull),
|
||||
}
|
||||
/**
|
||||
* Helper for filtering JSON entries that have `null` on the database (empty on the db)
|
||||
*
|
||||
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
|
||||
*/
|
||||
export const DbNull = runtime.DbNull
|
||||
|
||||
/**
|
||||
* Helper for filtering JSON entries that have JSON `null` values (not empty on the db)
|
||||
*
|
||||
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
|
||||
*/
|
||||
export const JsonNull = runtime.JsonNull
|
||||
|
||||
/**
|
||||
* Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull`
|
||||
*
|
||||
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
|
||||
*/
|
||||
export const AnyNull = runtime.AnyNull
|
||||
|
||||
|
||||
export const ModelName = {
|
||||
User: 'User',
|
||||
Project: 'Project',
|
||||
Service: 'Service',
|
||||
Message: 'Message',
|
||||
PageContent: 'PageContent'
|
||||
} as const
|
||||
|
||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||
|
||||
/*
|
||||
* Enums
|
||||
*/
|
||||
|
||||
export const TransactionIsolationLevel = {
|
||||
ReadUncommitted: 'ReadUncommitted',
|
||||
ReadCommitted: 'ReadCommitted',
|
||||
RepeatableRead: 'RepeatableRead',
|
||||
Serializable: 'Serializable'
|
||||
} as const
|
||||
|
||||
export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
|
||||
|
||||
|
||||
export const UserScalarFieldEnum = {
|
||||
id: 'id',
|
||||
email: 'email',
|
||||
password: 'password',
|
||||
name: 'name',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
|
||||
|
||||
|
||||
export const ProjectScalarFieldEnum = {
|
||||
id: 'id',
|
||||
title: 'title',
|
||||
category: 'category',
|
||||
client: 'client',
|
||||
status: 'status',
|
||||
completionDate: 'completionDate',
|
||||
description: 'description',
|
||||
coverImage: 'coverImage',
|
||||
galleryImages: 'galleryImages',
|
||||
featured: 'featured',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type ProjectScalarFieldEnum = (typeof ProjectScalarFieldEnum)[keyof typeof ProjectScalarFieldEnum]
|
||||
|
||||
|
||||
export const ServiceScalarFieldEnum = {
|
||||
id: 'id',
|
||||
title: 'title',
|
||||
icon: 'icon',
|
||||
shortDescription: 'shortDescription',
|
||||
fullDescription: 'fullDescription',
|
||||
active: 'active',
|
||||
order: 'order',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type ServiceScalarFieldEnum = (typeof ServiceScalarFieldEnum)[keyof typeof ServiceScalarFieldEnum]
|
||||
|
||||
|
||||
export const MessageScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
email: 'email',
|
||||
subject: 'subject',
|
||||
message: 'message',
|
||||
status: 'status',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type MessageScalarFieldEnum = (typeof MessageScalarFieldEnum)[keyof typeof MessageScalarFieldEnum]
|
||||
|
||||
|
||||
export const PageContentScalarFieldEnum = {
|
||||
id: 'id',
|
||||
slug: 'slug',
|
||||
content: 'content',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type PageContentScalarFieldEnum = (typeof PageContentScalarFieldEnum)[keyof typeof PageContentScalarFieldEnum]
|
||||
|
||||
|
||||
export const SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
} as const
|
||||
|
||||
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
|
||||
|
||||
|
||||
export const JsonNullValueInput = {
|
||||
JsonNull: 'JsonNull'
|
||||
} as const
|
||||
|
||||
export type JsonNullValueInput = (typeof JsonNullValueInput)[keyof typeof JsonNullValueInput]
|
||||
|
||||
|
||||
export const QueryMode = {
|
||||
default: 'default',
|
||||
insensitive: 'insensitive'
|
||||
} as const
|
||||
|
||||
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode]
|
||||
|
||||
|
||||
export const NullsOrder = {
|
||||
first: 'first',
|
||||
last: 'last'
|
||||
} as const
|
||||
|
||||
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
|
||||
|
||||
|
||||
export const JsonNullValueFilter = {
|
||||
DbNull: 'DbNull',
|
||||
JsonNull: 'JsonNull',
|
||||
AnyNull: 'AnyNull'
|
||||
} as const
|
||||
|
||||
export type JsonNullValueFilter = (typeof JsonNullValueFilter)[keyof typeof JsonNullValueFilter]
|
||||
|
||||
16
frontend/src/generated/client/models.ts
Normal file
16
frontend/src/generated/client/models.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This is a barrel export file for all models and their related types.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
export type * from './models/User'
|
||||
export type * from './models/Project'
|
||||
export type * from './models/Service'
|
||||
export type * from './models/Message'
|
||||
export type * from './models/PageContent'
|
||||
export type * from './commonInputTypes'
|
||||
1228
frontend/src/generated/client/models/Message.ts
Normal file
1228
frontend/src/generated/client/models/Message.ts
Normal file
File diff suppressed because it is too large
Load Diff
1110
frontend/src/generated/client/models/PageContent.ts
Normal file
1110
frontend/src/generated/client/models/PageContent.ts
Normal file
File diff suppressed because it is too large
Load Diff
1359
frontend/src/generated/client/models/Project.ts
Normal file
1359
frontend/src/generated/client/models/Project.ts
Normal file
File diff suppressed because it is too large
Load Diff
1308
frontend/src/generated/client/models/Service.ts
Normal file
1308
frontend/src/generated/client/models/Service.ts
Normal file
File diff suppressed because it is too large
Load Diff
1184
frontend/src/generated/client/models/User.ts
Normal file
1184
frontend/src/generated/client/models/User.ts
Normal file
File diff suppressed because it is too large
Load Diff
40
frontend/src/hooks/usePageContent.ts
Normal file
40
frontend/src/hooks/usePageContent.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface PageContentData {
|
||||
id: string;
|
||||
slug: string;
|
||||
content: any;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function usePageContent(slug: string) {
|
||||
const [content, setContent] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchContent = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/pages/${slug}`);
|
||||
|
||||
if (response.ok) {
|
||||
const data: PageContentData = await response.json();
|
||||
setContent(data.content);
|
||||
} else if (response.status === 404) {
|
||||
// Página ainda não foi configurada no admin
|
||||
setContent(null);
|
||||
} else {
|
||||
throw new Error('Erro ao carregar conteúdo');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erro desconhecido');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchContent();
|
||||
}, [slug]);
|
||||
|
||||
return { content, loading, error };
|
||||
}
|
||||
36
frontend/src/hooks/useToast.ts
Normal file
36
frontend/src/hooks/useToast.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = useCallback((message: string, type: Toast['type'] = 'info') => {
|
||||
const id = Date.now();
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const success = useCallback((message: string) => showToast(message, 'success'), [showToast]);
|
||||
const error = useCallback((message: string) => showToast(message, 'error'), [showToast]);
|
||||
const warning = useCallback((message: string) => showToast(message, 'warning'), [showToast]);
|
||||
const info = useCallback((message: string) => showToast(message, 'info'), [showToast]);
|
||||
|
||||
return {
|
||||
toasts,
|
||||
removeToast,
|
||||
success,
|
||||
error,
|
||||
warning,
|
||||
info,
|
||||
};
|
||||
}
|
||||
37
frontend/src/lib/minio.ts
Normal file
37
frontend/src/lib/minio.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as Minio from 'minio';
|
||||
|
||||
export const minioClient = new Minio.Client({
|
||||
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
|
||||
port: parseInt(process.env.MINIO_PORT || '9000'),
|
||||
useSSL: process.env.MINIO_USE_SSL === 'true',
|
||||
accessKey: process.env.MINIO_ACCESS_KEY || 'admin',
|
||||
secretKey: process.env.MINIO_SECRET_KEY || 'adminpassword',
|
||||
});
|
||||
|
||||
export const bucketName = process.env.MINIO_BUCKET_NAME || 'occto-images';
|
||||
|
||||
// Ensure bucket exists
|
||||
export async function ensureBucketExists() {
|
||||
try {
|
||||
const exists = await minioClient.bucketExists(bucketName);
|
||||
if (!exists) {
|
||||
await minioClient.makeBucket(bucketName, 'us-east-1');
|
||||
// Set policy to public read
|
||||
const policy = {
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Principal: { AWS: ['*'] },
|
||||
Action: ['s3:GetObject'],
|
||||
Resource: [`arn:aws:s3:::${bucketName}/*`],
|
||||
},
|
||||
],
|
||||
};
|
||||
await minioClient.setBucketPolicy(bucketName, JSON.stringify(policy));
|
||||
console.log(`Bucket ${bucketName} created and policy set to public read.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error ensuring bucket exists:', error);
|
||||
}
|
||||
}
|
||||
15
frontend/src/lib/prisma.ts
Normal file
15
frontend/src/lib/prisma.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prismaClientSingleton = () => {
|
||||
return new PrismaClient()
|
||||
}
|
||||
|
||||
declare const globalThis: {
|
||||
prismaGlobal: ReturnType<typeof prismaClientSingleton>;
|
||||
} & typeof global;
|
||||
|
||||
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
|
||||
|
||||
export default prisma
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma
|
||||
52
frontend/src/proxy.ts
Normal file
52
frontend/src/proxy.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-CHANGE-IN-PRODUCTION';
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
const path = request.nextUrl.pathname;
|
||||
|
||||
// Define public and private paths
|
||||
const isPublicPath = path === '/acesso';
|
||||
const isPrivatePath = path.startsWith('/admin');
|
||||
|
||||
// Get the token from the cookies
|
||||
const token = request.cookies.get('auth_token')?.value || '';
|
||||
|
||||
// Validate JWT token
|
||||
let isValidToken = false;
|
||||
if (token) {
|
||||
try {
|
||||
jwt.verify(token, JWT_SECRET);
|
||||
isValidToken = true;
|
||||
} catch (err) {
|
||||
// Token inválido ou expirado
|
||||
isValidToken = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect logic
|
||||
if (isPrivatePath && !isValidToken) {
|
||||
// If trying to access admin without valid token, redirect to login
|
||||
const response = NextResponse.redirect(new URL('/acesso', request.url));
|
||||
// Remover token inválido
|
||||
response.cookies.delete('auth_token');
|
||||
return response;
|
||||
}
|
||||
|
||||
if (isPublicPath && isValidToken) {
|
||||
// If trying to access login while already logged in, redirect to admin
|
||||
return NextResponse.redirect(new URL('/admin', request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Configure which paths the middleware should run on
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/acesso',
|
||||
'/admin/:path*',
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user