Initial commit: CMS completo com gerenciamento de leads e personalização de tema

This commit is contained in:
Erik
2025-11-26 14:09:21 -03:00
commit aaa1709e41
106 changed files with 26268 additions and 0 deletions

View 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>
);
}

View 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 />
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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"> 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>
);
}

View 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>
</>
);
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 }
);
}
}

View 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 });
}
}

View 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;
}

View 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 }
);
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 }
);
}
}

View 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 });
}
}

View 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 }
);
}
}

View 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 });
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1 @@
@import url('https://fonts.googleapis.com/css2?family=Stack+Sans+Headline&display=swap');

View 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);
}

View 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

View 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>
);
}