feat: implementa sistema de logotipo dinâmico

- Adiciona campo 'logo' ao modelo Settings no Prisma
- Atualiza API /api/settings para lidar com upload de logo
- Cria aba Logotipo funcional no admin com upload de imagem
- Atualiza Header para exibir logo dinâmico (fallback para ícone)
- Atualiza Footer para exibir logo dinâmico
- Atualiza Admin Layout para exibir logo dinâmico
- Logo é atualizado em tempo real via evento settings:refresh
This commit is contained in:
Erik
2025-11-29 16:36:25 -03:00
parent cbad251b39
commit e503069a86
6 changed files with 308 additions and 16 deletions

View File

@@ -1,9 +1,10 @@
"use client";
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useSearchParams } from 'next/navigation';
import { useToast } from '@/contexts/ToastContext';
import { BackupManager } from '@/components/admin/BackupManager';
import Image from 'next/image';
const PRESET_COLORS = [
{ name: 'Laranja (Padrão)', value: '#FF6B35', gradient: 'from-orange-500 to-orange-600' },
@@ -25,6 +26,11 @@ export default function ConfiguracoesPage() {
const [customColor, setCustomColor] = useState('#FF6B35');
const [showPartnerBadge, setShowPartnerBadge] = useState(false);
const [partnerName, setPartnerName] = useState('Coca-Cola');
// Campo de logotipo
const [logo, setLogo] = useState<string | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null);
const [uploadingLogo, setUploadingLogo] = useState(false);
const logoInputRef = useRef<HTMLInputElement>(null);
// Campos de contato
const [address, setAddress] = useState('');
const [phone, setPhone] = useState('');
@@ -65,6 +71,8 @@ export default function ConfiguracoesPage() {
const data = await response.json();
setShowPartnerBadge(data.showPartnerBadge || false);
setPartnerName(data.partnerName || 'Coca-Cola');
setLogo(data.logo || null);
setLogoPreview(data.logo || null);
setAddress(data.address || '');
setPhone(data.phone || '');
setEmail(data.email || '');
@@ -78,6 +86,82 @@ export default function ConfiguracoesPage() {
}
};
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validar tipo de arquivo
if (!file.type.startsWith('image/')) {
showError('Por favor, selecione uma imagem válida');
return;
}
// Validar tamanho (max 5MB)
if (file.size > 5 * 1024 * 1024) {
showError('A imagem deve ter no máximo 5MB');
return;
}
// Preview local
const reader = new FileReader();
reader.onload = (e) => {
setLogoPreview(e.target?.result as string);
};
reader.readAsDataURL(file);
// Upload
setUploadingLogo(true);
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) throw new Error('Erro no upload');
const data = await response.json();
setLogo(data.url);
success('Logotipo carregado! Clique em "Salvar" para aplicar.');
} catch (error) {
showError('Erro ao fazer upload do logotipo');
setLogoPreview(logo); // Restaurar preview anterior
} finally {
setUploadingLogo(false);
}
};
const handleRemoveLogo = () => {
setLogo(null);
setLogoPreview(null);
if (logoInputRef.current) {
logoInputRef.current.value = '';
}
};
const handleSaveLogo = async () => {
setSaving(true);
try {
const response = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ logo })
});
if (!response.ok) throw new Error('Erro ao salvar');
success('Logotipo salvo com sucesso!');
// Dispatch event para atualizar em tempo real
window.dispatchEvent(new Event('settings:refresh'));
} catch (error) {
showError('Erro ao salvar logotipo');
} finally {
setSaving(false);
}
};
const handleSaveSettings = async () => {
try {
const response = await fetch('/api/settings', {
@@ -460,22 +544,136 @@ export default function ConfiguracoesPage() {
<div className="flex-1">
<h2 className="text-xl font-bold text-secondary dark:text-white mb-1">Logotipo</h2>
<p className="text-gray-500 dark:text-gray-400 text-sm">
Configure o logotipo que aparece no cabeçalho e rodapé do site.
Configure o logotipo que aparece no cabeçalho, rodapé e painel administrativo do site.
</p>
</div>
</div>
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-4 flex items-start gap-3">
<i className="ri-tools-fill text-amber-600 dark:text-amber-400 text-xl mt-0.5"></i>
<div className="flex-1">
<p className="text-sm text-amber-900 dark:text-amber-200 font-medium mb-1">
Em Desenvolvimento
</p>
<p className="text-sm text-amber-700 dark:text-amber-300">
A funcionalidade de upload de logotipo estará disponível em breve.
</p>
{/* Current Logo Preview */}
<div className="mb-6">
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-3">
Logotipo Atual
</label>
<div className="flex items-center gap-6">
<div className="relative w-48 h-24 bg-gray-100 dark:bg-white/5 rounded-xl border-2 border-dashed border-gray-300 dark:border-white/20 flex items-center justify-center overflow-hidden">
{logoPreview ? (
<Image
src={logoPreview}
alt="Logotipo"
fill
className="object-contain p-2"
unoptimized
/>
) : (
<div className="flex flex-col items-center text-gray-400 dark:text-gray-500">
<i className="ri-building-2-fill text-4xl"></i>
<span className="text-xs mt-1">Sem logotipo</span>
</div>
)}
{uploadingLogo && (
<div className="absolute inset-0 bg-white/80 dark:bg-black/80 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)}
</div>
{/* Logo in Header Preview */}
<div className="flex-1">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">Prévia no cabeçalho:</p>
<div className="bg-gray-900 rounded-lg p-4 flex items-center gap-2">
{logoPreview ? (
<Image
src={logoPreview}
alt="Logo Preview"
width={32}
height={32}
className="object-contain"
unoptimized
/>
) : (
<i className="ri-building-2-fill text-xl text-white"></i>
)}
<span className="text-white font-bold text-sm">OCCTO</span>
</div>
</div>
</div>
</div>
{/* Upload Section */}
<div className="mb-6">
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-3">
Carregar Novo Logotipo
</label>
<div className="flex items-start gap-4">
<div className="flex-1">
<input
ref={logoInputRef}
type="file"
accept="image/*"
onChange={handleLogoUpload}
className="hidden"
id="logo-upload"
/>
<label
htmlFor="logo-upload"
className="flex items-center justify-center gap-3 px-6 py-4 border-2 border-dashed border-gray-300 dark:border-white/20 rounded-xl cursor-pointer hover:border-primary hover:bg-primary/5 dark:hover:bg-primary/10 transition-colors"
>
<i className="ri-upload-cloud-2-line text-2xl text-gray-400 dark:text-gray-500"></i>
<div className="text-left">
<p className="font-medium text-gray-700 dark:text-gray-300">Clique para selecionar</p>
<p className="text-xs text-gray-500 dark:text-gray-400">PNG, JPG, SVG ou WebP até 5MB</p>
</div>
</label>
</div>
{logoPreview && (
<button
onClick={handleRemoveLogo}
className="px-4 py-4 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-xl hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors cursor-pointer"
title="Remover logotipo"
>
<i className="ri-delete-bin-line text-xl"></i>
</button>
)}
</div>
</div>
{/* Tips */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-4 mb-6">
<div className="flex items-start gap-3">
<i className="ri-lightbulb-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-2">
Dicas para um bom logotipo:
</p>
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li> Use imagens com fundo transparente (PNG ou SVG)</li>
<li> Recomendado: formato horizontal ou quadrado</li>
<li> Resolução mínima sugerida: 200x100 pixels</li>
<li> O logotipo será redimensionado automaticamente</li>
</ul>
</div>
</div>
</div>
{/* Save Button */}
<button
onClick={handleSaveLogo}
disabled={saving}
className="w-full px-6 py-3 bg-primary text-white rounded-xl font-bold hover:opacity-90 transition-colors shadow-lg shadow-primary/20 flex items-center justify-center gap-2 cursor-pointer disabled:opacity-50"
>
{saving ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
Salvando...
</>
) : (
<>
<i className="ri-save-line"></i>
Salvar Logotipo
</>
)}
</button>
</div>
</div>
)}

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { usePathname, useRouter } from 'next/navigation';
import { useToast } from '@/contexts/ToastContext';
import { useConfirm } from '@/contexts/ConfirmContext';
@@ -20,6 +21,7 @@ export default function AdminLayout({
}) {
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [user, setUser] = useState<{ name: string; email: string; avatar?: string | null } | null>(null);
const [logo, setLogo] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [showAvatarModal, setShowAvatarModal] = useState(false);
const [isUploading, setIsUploading] = useState(false);
@@ -104,6 +106,27 @@ export default function AdminLayout({
}
};
fetchUser();
// Buscar logo das configurações
const fetchLogo = async () => {
try {
const response = await fetch('/api/settings');
if (response.ok) {
const data = await response.json();
if (data.logo) {
setLogo(data.logo);
}
}
} catch (err) {
console.error('Erro ao buscar logo:', err);
}
};
fetchLogo();
// Listener para atualização em tempo real
const handleSettingsRefresh = () => fetchLogo();
window.addEventListener('settings:refresh', handleSettingsRefresh);
return () => window.removeEventListener('settings:refresh', handleSettingsRefresh);
}, [router]);
useEffect(() => {
@@ -239,7 +262,18 @@ export default function AdminLayout({
<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>
{logo ? (
<Image
src={logo}
alt="Logo"
width={32}
height={32}
className="object-contain"
unoptimized
/>
) : (
<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>

View File

@@ -69,6 +69,7 @@ export async function POST(request: NextRequest) {
const {
showPartnerBadge,
partnerName,
logo,
address,
phone,
email,
@@ -85,6 +86,7 @@ export async function POST(request: NextRequest) {
data: {
showPartnerBadge: showPartnerBadge ?? false,
partnerName: partnerName ?? 'Coca-Cola',
logo: logo ?? null,
address: address ?? null,
phone: phone ?? null,
email: email ?? null,
@@ -100,6 +102,7 @@ export async function POST(request: NextRequest) {
data: {
...(showPartnerBadge !== undefined && { showPartnerBadge }),
...(partnerName !== undefined && { partnerName }),
...(logo !== undefined && { logo }),
...(address !== undefined && { address }),
...(phone !== undefined && { phone }),
...(email !== undefined && { email }),

View File

@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { useLocale } from '@/contexts/LocaleContext';
import { PartnerBadge } from './PartnerBadge';
@@ -13,6 +14,7 @@ type ContactSettings = {
linkedin?: string | null;
facebook?: string | null;
whatsapp?: string | null;
logo?: string | null;
};
export default function Footer() {
@@ -35,7 +37,8 @@ export default function Footer() {
instagram: data.instagram,
linkedin: data.linkedin,
facebook: data.facebook,
whatsapp: data.whatsapp
whatsapp: data.whatsapp,
logo: data.logo
});
}
} catch (error) {
@@ -58,7 +61,18 @@ export default function Footer() {
{/* Brand */}
<div className="col-span-1 md:col-span-1">
<div className="flex items-center gap-2 mb-6">
<i className="ri-building-2-fill text-4xl text-primary"></i>
{contact.logo ? (
<Image
src={contact.logo}
alt="Logo"
width={40}
height={40}
className="object-contain"
unoptimized
/>
) : (
<i className="ri-building-2-fill text-4xl text-primary"></i>
)}
<div className="flex items-center gap-2">
<span className="text-2xl font-bold font-headline">OCCTO</span>
<span className="text-[10px] font-bold text-primary bg-white/10 px-2 py-1 rounded-md uppercase tracking-wider">ENG.</span>

View File

@@ -1,6 +1,7 @@
"use client";
import Link from 'next/link';
import Image from 'next/image';
import { useState, useEffect } from 'react';
import { useTheme } from "next-themes";
import { useLocale } from '@/contexts/LocaleContext';
@@ -10,6 +11,7 @@ export default function Header() {
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [logo, setLogo] = useState<string | null>(null);
const { theme, setTheme } = useTheme();
const { locale, setLocale, t } = useLocale();
const [mounted, setMounted] = useState(false);
@@ -30,7 +32,20 @@ export default function Header() {
})
.catch(() => setIsLoggedIn(false));
// Busca o número do WhatsApp do CMS
// Busca as configurações (logo e whatsapp)
fetch('/api/settings')
.then(res => res.json())
.then(data => {
if (data.logo) {
setLogo(data.logo);
}
if (data.whatsapp) {
setWhatsappLink(`https://wa.me/${data.whatsapp.replace(/\D/g, '')}`);
}
})
.catch(console.error);
// Busca o número do WhatsApp do CMS (fallback)
fetch('/api/contact-info')
.then(res => res.json())
.then(data => {
@@ -39,6 +54,21 @@ export default function Header() {
}
})
.catch(console.error);
// Listener para atualização em tempo real
const handleSettingsRefresh = () => {
fetch('/api/settings')
.then(res => res.json())
.then(data => {
if (data.logo !== undefined) {
setLogo(data.logo);
}
})
.catch(console.error);
};
window.addEventListener('settings:refresh', handleSettingsRefresh);
return () => window.removeEventListener('settings:refresh', handleSettingsRefresh);
}, []);
// Prevent scrolling when mobile menu is open
@@ -81,7 +111,18 @@ export default function Header() {
<header className={`w-full bg-white dark:bg-secondary shadow-sm sticky ${isLoggedIn ? 'top-0' : 'top-0'} z-50 transition-colors duration-300`}>
<div className="container mx-auto px-4 h-20 flex items-center justify-between gap-4">
<Link href={`${prefix}/`} className="flex items-center gap-3 shrink-0 group mr-auto z-50 relative">
<i className="ri-building-2-fill text-4xl text-primary group-hover:scale-105 transition-transform"></i>
{logo ? (
<Image
src={logo}
alt="Logo"
width={40}
height={40}
className="object-contain group-hover:scale-105 transition-transform"
unoptimized
/>
) : (
<i className="ri-building-2-fill text-4xl text-primary group-hover:scale-105 transition-transform"></i>
)}
<div className="flex items-center gap-2">
<span className="text-3xl font-bold text-secondary dark:text-white font-headline leading-none">OCCTO</span>
<span className="text-[10px] font-bold text-primary bg-primary/10 px-2 py-1 rounded-md uppercase tracking-wider">ENG.</span>