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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user