Implementacao da personalizacao do hero banner com preview e integracao backend
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s Details

This commit is contained in:
Sidney Gomes 2026-07-25 09:49:10 -03:00
parent 2bfdfc0240
commit e669914735
6 changed files with 218 additions and 10 deletions

View File

@ -284,6 +284,11 @@ model SystemSettings {
termsOfUse String? @map("terms_of_use") @db.Text termsOfUse String? @map("terms_of_use") @db.Text
privacyPolicy String? @map("privacy_policy") @db.Text privacyPolicy String? @map("privacy_policy") @db.Text
featuredCourseId String? @map("featured_course_id")
heroImageUrl String? @map("hero_image_url")
heroTitle String? @map("hero_title")
heroDescription String? @map("hero_description") @db.Text
smtpHost String? @map("smtp_host") smtpHost String? @map("smtp_host")
smtpPort Int? @default(587) @map("smtp_port") smtpPort Int? @default(587) @map("smtp_port")
smtpUser String? @map("smtp_user") smtpUser String? @map("smtp_user")

View File

@ -1301,7 +1301,11 @@ app.get('/api/settings', async (req: Request, res: Response) => {
cnpj: settings?.cnpj || '', cnpj: settings?.cnpj || '',
address: settings?.address || '', address: settings?.address || '',
termsOfUse: settings?.termsOfUse || '', termsOfUse: settings?.termsOfUse || '',
privacyPolicy: settings?.privacyPolicy || '' privacyPolicy: settings?.privacyPolicy || '',
featuredCourseId: settings?.featuredCourseId || null,
heroImageUrl: settings?.heroImageUrl || null,
heroTitle: settings?.heroTitle || null,
heroDescription: settings?.heroDescription || null
}); });
}); });
@ -1331,7 +1335,11 @@ app.get('/api/admin/settings', authenticateToken, requireAdmin, async (req: Auth
smtpFrom: settings?.smtpFrom || 'no-reply@microtecinformaticacurso.com.br', smtpFrom: settings?.smtpFrom || 'no-reply@microtecinformaticacurso.com.br',
smtpSecure: settings?.smtpSecure || false, smtpSecure: settings?.smtpSecure || false,
termsOfUse: settings?.termsOfUse || '', termsOfUse: settings?.termsOfUse || '',
privacyPolicy: settings?.privacyPolicy || '' privacyPolicy: settings?.privacyPolicy || '',
featuredCourseId: settings?.featuredCourseId || null,
heroImageUrl: settings?.heroImageUrl || null,
heroTitle: settings?.heroTitle || null,
heroDescription: settings?.heroDescription || null
}); });
}); });
@ -1344,7 +1352,7 @@ app.post('/api/admin/settings', authenticateToken, requireAdmin, async (req: Aut
'maxBoletoInstallments', 'evolutionApiUrl', 'evolutionApiKey', 'evolutionInstance', 'maxBoletoInstallments', 'evolutionApiUrl', 'evolutionApiKey', 'evolutionInstance',
'whatsappConnected', 'whatsappPhoneNumber', 'brandName', 'brandSlogan', 'cnpj', 'whatsappConnected', 'whatsappPhoneNumber', 'brandName', 'brandSlogan', 'cnpj',
'address', 'smtpHost', 'smtpPort', 'smtpUser', 'smtpPass', 'smtpFrom', 'smtpSecure', 'address', 'smtpHost', 'smtpPort', 'smtpUser', 'smtpPass', 'smtpFrom', 'smtpSecure',
'termsOfUse', 'privacyPolicy' 'termsOfUse', 'privacyPolicy', 'featuredCourseId', 'heroImageUrl', 'heroTitle', 'heroDescription'
]; ];
const cleanedSettings: any = {}; const cleanedSettings: any = {};
for (const key of allowedKeys) { for (const key of allowedKeys) {

View File

@ -53,7 +53,7 @@ export default function App() {
// Course catalogue // Course catalogue
const [courses, setCourses] = useState<(CourseType & { progress?: number; totalLessons?: number; completedLessons?: number })[]>([]); const [courses, setCourses] = useState<(CourseType & { progress?: number; totalLessons?: number; completedLessons?: number })[]>([]);
const [isLoadingCourses, setIsLoadingCourses] = useState(false); const [isLoadingCourses, setIsLoadingCourses] = useState(false);
const [settings, setSettings] = useState<{ brandName?: string; brandSlogan?: string; cnpj?: string; address?: string; termsOfUse?: string; privacyPolicy?: string } | null>(null); const [settings, setSettings] = useState<{ brandName?: string; brandSlogan?: string; cnpj?: string; address?: string; termsOfUse?: string; privacyPolicy?: string; featuredCourseId?: string | null; heroImageUrl?: string | null; heroTitle?: string | null; heroDescription?: string | null } | null>(null);
const [infoModal, setInfoModal] = useState<{isOpen: boolean, title: string, content: string}>({isOpen: false, title: '', content: ''}); const [infoModal, setInfoModal] = useState<{isOpen: boolean, title: string, content: string}>({isOpen: false, title: '', content: ''});
useEffect(() => { useEffect(() => {
@ -297,7 +297,13 @@ export default function App() {
<div className="space-y-12"> <div className="space-y-12">
{/* Hero Banner (Featured last course or first catalogue course) */} {/* Hero Banner (Featured last course or first catalogue course) */}
<HeroBanner <HeroBanner
course={courses.length > 0 ? courses[0] : null} course={
(settings?.featuredCourseId && courses.find(c => c.id === settings.featuredCourseId))
|| (courses.length > 0 ? courses[0] : null)
}
customHeroTitle={settings?.heroTitle}
customHeroDescription={settings?.heroDescription}
customHeroImage={settings?.heroImageUrl}
onPlay={(courseId) => { onPlay={(courseId) => {
const selected = courses.find(c => c.id === courseId); const selected = courses.find(c => c.id === courseId);
if (selected && (selected as any).isUnlocked === false) { if (selected && (selected as any).isUnlocked === false) {

View File

@ -80,6 +80,16 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
const [uploadingMaterial, setUploadingMaterial] = useState(false); const [uploadingMaterial, setUploadingMaterial] = useState(false);
const [editingCourseMaterialUrl, setEditingCourseMaterialUrl] = useState<string | null>(null); const [editingCourseMaterialUrl, setEditingCourseMaterialUrl] = useState<string | null>(null);
// Hero Banner Settings State
const [heroConfig, setHeroConfig] = useState({
featuredCourseId: '',
heroTitle: '',
heroDescription: '',
heroImageUrl: ''
});
const [isSavingHero, setIsSavingHero] = useState(false);
const [uploadingHeroImage, setUploadingHeroImage] = useState(false);
// Drag and Drop States // Drag and Drop States
const [draggedCourseIndex, setDraggedCourseIndex] = useState<number | null>(null); const [draggedCourseIndex, setDraggedCourseIndex] = useState<number | null>(null);
const [draggedModuleIndex, setDraggedModuleIndex] = useState<number | null>(null); const [draggedModuleIndex, setDraggedModuleIndex] = useState<number | null>(null);
@ -266,8 +276,80 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
useEffect(() => { useEffect(() => {
fetchCourses(); fetchCourses();
fetchHeroConfig();
}, []); }, []);
const fetchHeroConfig = async () => {
try {
const res = await fetch('/api/admin/settings', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setHeroConfig({
featuredCourseId: data.featuredCourseId || '',
heroTitle: data.heroTitle || '',
heroDescription: data.heroDescription || '',
heroImageUrl: data.heroImageUrl || ''
});
}
} catch (err) {
console.error('Error fetching hero config:', err);
}
};
const handleSaveHeroConfig = async () => {
setIsSavingHero(true);
try {
const res = await fetch('/api/admin/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(heroConfig)
});
if (res.ok) {
showSuccess('Configuração do Banner salva com sucesso!');
} else {
showError('Erro ao salvar configuração.');
}
} catch (err) {
showError('Erro de conexão ao salvar.');
} finally {
setIsSavingHero(false);
}
};
const handleHeroImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files || e.target.files.length === 0) return;
const file = e.target.files[0];
const formData = new FormData();
formData.append('file', file);
setUploadingHeroImage(true);
try {
const res = await fetch('/api/admin/upload', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
const data = await res.json();
if (res.ok && data.url) {
setHeroConfig(prev => ({ ...prev, heroImageUrl: data.url }));
showSuccess('Capa do banner carregada!');
} else {
showError(data.error || 'Erro ao fazer upload da capa');
}
} catch (err) {
showError('Erro ao fazer upload da capa');
} finally {
setUploadingHeroImage(false);
if (e.target) e.target.value = '';
}
};
const fetchCourses = async () => { const fetchCourses = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
@ -613,6 +695,99 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
</div> </div>
)} )}
{/* --- HERO CONFIGURATION PANEL --- */}
{!selectedCourse && !showCourseForm && (
<div className="glass-card p-6 rounded-2xl space-y-5 border border-white/10">
<div className="flex items-center justify-between border-b border-white/5 pb-3">
<h3 className="text-base font-bold text-white flex items-center gap-2">
<Film className="w-5 h-5 text-[#E50914]" />
Configuração do Banner Principal (Capa)
</h3>
<button
onClick={handleSaveHeroConfig}
disabled={isSavingHero || uploadingHeroImage}
className="glass-btn-primary text-white text-xs font-bold px-4 py-2 rounded-lg flex items-center gap-2 hover:scale-[1.02] transition-transform disabled:opacity-50"
>
<Save className="w-3.5 h-3.5" />
{isSavingHero ? 'Salvando...' : 'Salvar Banner'}
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Curso em Destaque</label>
<select
value={heroConfig.featuredCourseId}
onChange={(e) => setHeroConfig({ ...heroConfig, featuredCourseId: e.target.value })}
className="w-full glass-input rounded-lg p-2.5 text-sm text-white focus:outline-none bg-zinc-900"
>
<option value="">-- Automático (Último assistido ou catálogo) --</option>
{courses.map(c => (
<option key={c.id} value={c.id}>{c.title}</option>
))}
</select>
<p className="text-[10px] text-zinc-500">O botão "Ver Detalhes" levará para este curso.</p>
</div>
<div className="space-y-1.5">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Título Personalizado</label>
<input
type="text"
value={heroConfig.heroTitle}
onChange={(e) => setHeroConfig({ ...heroConfig, heroTitle: e.target.value })}
placeholder="Ex: Assinatura Premium de TI"
className="w-full glass-input rounded-lg p-2.5 text-sm text-white focus:outline-none"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Descrição Personalizada</label>
<textarea
value={heroConfig.heroDescription}
onChange={(e) => setHeroConfig({ ...heroConfig, heroDescription: e.target.value })}
placeholder="Texto atrativo para a capa do portal..."
className="w-full glass-input rounded-lg p-2.5 text-sm text-white focus:outline-none min-h-[80px]"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider flex items-center justify-between">
<span>Imagem de Fundo (16:9)</span>
{uploadingHeroImage && <span className="text-[#E50914] flex items-center gap-1"><RefreshCw className="w-3 h-3 animate-spin"/> Enviando...</span>}
</label>
<div className="relative group w-full aspect-video bg-zinc-900 rounded-xl overflow-hidden border border-dashed border-zinc-700 hover:border-white/20 transition-colors">
{heroConfig.heroImageUrl ? (
<img src={heroConfig.heroImageUrl} alt="Hero Banner" className="w-full h-full object-cover opacity-60" />
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-zinc-500">
<Film className="w-8 h-8 mb-2 opacity-50" />
<span className="text-xs font-semibold">Capa Automática do Curso</span>
</div>
)}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<label className="cursor-pointer bg-white/10 hover:bg-white/20 backdrop-blur text-white px-4 py-2 rounded-lg text-xs font-bold transition-colors">
Alterar Imagem
<input type="file" className="hidden" accept="image/jpeg,image/png,image/webp" onChange={handleHeroImageUpload} disabled={uploadingHeroImage} />
</label>
{heroConfig.heroImageUrl && (
<button
onClick={() => setHeroConfig({ ...heroConfig, heroImageUrl: '' })}
className="ml-2 bg-red-500/20 hover:bg-red-500/40 text-red-400 px-3 py-2 rounded-lg text-xs font-bold transition-colors"
>
Remover
</button>
)}
</div>
</div>
</div>
</div>
</div>
)}
{/* --- RENDER COURSE FORM --- */} {/* --- RENDER COURSE FORM --- */}
{showCourseForm && ( {showCourseForm && (
<div className="glass-card p-6 rounded-2xl space-y-6"> <div className="glass-card p-6 rounded-2xl space-y-6">

View File

@ -8,9 +8,12 @@ interface HeroBannerProps {
onDetail: (courseId: string) => void; onDetail: (courseId: string) => void;
subscriptionStatus: string; subscriptionStatus: string;
setView: (view: string) => void; setView: (view: string) => void;
customHeroTitle?: string | null;
customHeroDescription?: string | null;
customHeroImage?: string | null;
} }
export default function HeroBanner({ course, onPlay, onDetail, subscriptionStatus, setView }: HeroBannerProps) { export default function HeroBanner({ course, onPlay, onDetail, subscriptionStatus, setView, customHeroTitle, customHeroDescription, customHeroImage }: HeroBannerProps) {
if (!course) { if (!course) {
return ( return (
<div className="h-[30vh] md:h-[45vh] w-full bg-zinc-900 flex items-center justify-center text-zinc-500 rounded-lg border border-zinc-800"> <div className="h-[30vh] md:h-[45vh] w-full bg-zinc-900 flex items-center justify-center text-zinc-500 rounded-lg border border-zinc-800">
@ -26,8 +29,8 @@ export default function HeroBanner({ course, onPlay, onDetail, subscriptionStatu
{/* Background Image / Gradient */} {/* Background Image / Gradient */}
<div className="absolute inset-0 z-0"> <div className="absolute inset-0 z-0">
<img <img
src={course.thumbnail} src={customHeroImage || course.thumbnail}
alt={course.title} alt={customHeroTitle || course.title}
className="w-full h-full object-cover brightness-[0.40] scale-105 transition-all duration-700" className="w-full h-full object-cover brightness-[0.40] scale-105 transition-all duration-700"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
@ -45,12 +48,12 @@ export default function HeroBanner({ course, onPlay, onDetail, subscriptionStatu
{/* Title */} {/* Title */}
<h1 className="text-3xl md:text-5xl font-display font-black tracking-tight text-white leading-tight"> <h1 className="text-3xl md:text-5xl font-display font-black tracking-tight text-white leading-tight">
{course.title} {customHeroTitle || course.title}
</h1> </h1>
{/* Description */} {/* Description */}
<p className="text-sm md:text-base text-zinc-300 font-normal leading-relaxed line-clamp-3"> <p className="text-sm md:text-base text-zinc-300 font-normal leading-relaxed line-clamp-3">
{course.description} {customHeroDescription || course.description}
</p> </p>
{/* Progress Bar (If started) */} {/* Progress Bar (If started) */}

View File

@ -39,6 +39,17 @@ export interface SystemSettings {
evolutionInstance?: string; evolutionInstance?: string;
whatsappConnected?: boolean; whatsappConnected?: boolean;
whatsappPhoneNumber?: string; whatsappPhoneNumber?: string;
brandName?: string;
brandSlogan?: string;
cnpj?: string;
address?: string;
termsOfUse?: string;
privacyPolicy?: string;
featuredCourseId?: string | null;
heroImageUrl?: string | null;
heroTitle?: string | null;
heroDescription?: string | null;
} }
export interface Course { export interface Course {