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
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")
smtpPort Int? @default(587) @map("smtp_port")
smtpUser String? @map("smtp_user")

View File

@ -1301,7 +1301,11 @@ app.get('/api/settings', async (req: Request, res: Response) => {
cnpj: settings?.cnpj || '',
address: settings?.address || '',
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',
smtpSecure: settings?.smtpSecure || false,
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',
'whatsappConnected', 'whatsappPhoneNumber', 'brandName', 'brandSlogan', 'cnpj',
'address', 'smtpHost', 'smtpPort', 'smtpUser', 'smtpPass', 'smtpFrom', 'smtpSecure',
'termsOfUse', 'privacyPolicy'
'termsOfUse', 'privacyPolicy', 'featuredCourseId', 'heroImageUrl', 'heroTitle', 'heroDescription'
];
const cleanedSettings: any = {};
for (const key of allowedKeys) {

View File

@ -53,7 +53,7 @@ export default function App() {
// Course catalogue
const [courses, setCourses] = useState<(CourseType & { progress?: number; totalLessons?: number; completedLessons?: number })[]>([]);
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: ''});
useEffect(() => {
@ -297,7 +297,13 @@ export default function App() {
<div className="space-y-12">
{/* Hero Banner (Featured last course or first catalogue course) */}
<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) => {
const selected = courses.find(c => c.id === courseId);
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 [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
const [draggedCourseIndex, setDraggedCourseIndex] = useState<number | null>(null);
const [draggedModuleIndex, setDraggedModuleIndex] = useState<number | null>(null);
@ -266,8 +276,80 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
useEffect(() => {
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 () => {
setIsLoading(true);
try {
@ -613,6 +695,99 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
</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 --- */}
{showCourseForm && (
<div className="glass-card p-6 rounded-2xl space-y-6">

View File

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

View File

@ -39,6 +39,17 @@ export interface SystemSettings {
evolutionInstance?: string;
whatsappConnected?: boolean;
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 {