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 }),