Implementacao da personalizacao do hero banner com preview e integracao backend
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s
Details
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s
Details
This commit is contained in:
parent
2bfdfc0240
commit
e669914735
|
|
@ -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")
|
||||
|
|
|
|||
14
server.ts
14
server.ts
|
|
@ -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) {
|
||||
|
|
|
|||
10
src/App.tsx
10
src/App.tsx
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) */}
|
||||
|
|
|
|||
11
src/types.ts
11
src/types.ts
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue