Refactor admin portal layout to match AgendaPRO sidebar styling and add dynamic SaaS branding config
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m0s Details

This commit is contained in:
Sidney Gomes 2026-07-23 11:26:00 -03:00
parent 8a5427402f
commit 6f7e7fbcc6
12 changed files with 6286 additions and 859 deletions

5255
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -14,7 +14,6 @@
"@aws-sdk/client-s3": "^3.700.0",
"@google/genai": "^2.4.0",
"@prisma/client": "^6.4.1",
"multer": "^1.4.5-lts.1",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"axios": "^1.7.9",
@ -24,6 +23,7 @@
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"multer": "^1.4.5-lts.1",
"prisma": "^6.4.1",
"react": "^19.0.1",
"react-dom": "^19.0.1",
@ -36,6 +36,8 @@
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^1.4.12",
"@types/node": "^22.14.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"autoprefixer": "^10.4.21",
"esbuild": "^0.28.1",
"tailwindcss": "^4.1.14",

View File

@ -254,12 +254,18 @@ model SystemSettings {
asaasWebhookUrl String @default("") @map("asaas_webhook_url")
asaasWebhookSecret String @default("") @map("asaas_webhook_secret")
helpText String @db.Text @default("# Central de Ajuda") @map("help_text")
enableBoleto Boolean @default(true) @map("enable_boleto")
evolutionApiUrl String @default("https://evolution.microtecinformaticacurso.com.br") @map("evolution_api_url")
evolutionApiKey String @default("") @map("evolution_api_key")
evolutionInstance String @default("microtecflix") @map("evolution_instance")
whatsappConnected Boolean @default(false) @map("whatsapp_connected")
whatsappPhoneNumber String? @map("whatsapp_phone_number")
brandName String @default("MICROTEC") @map("brand_name")
brandSlogan String @default("Acelere seus estudos em informática e pacote Office no estilo Netflix.") @map("brand_slogan")
cnpj String @default("") @map("cnpj")
address String @default("") @map("address")
@@map("system_settings")
}

View File

@ -365,7 +365,6 @@ app.get('/api/courses', authenticateToken, async (req: AuthenticatedRequest, res
isLocked: course.isLocked,
price: course.price,
category: course.category,
order: course.order,
totalLessons,
completedLessons,
progress: progressPercentage,
@ -767,7 +766,7 @@ app.post('/api/subscription/subscribe', authenticateToken, async (req: Authentic
id: asaasPaymentId,
userId: userId,
value: price,
status: statusAsaas,
status: statusAsaas as any,
billingType: billingType,
invoiceUrl: invoiceUrlStr,
dueDate: new Date(asaasDueDate),
@ -1003,7 +1002,11 @@ app.get('/api/settings', authenticateToken, async (req: AuthenticatedRequest, re
res.json({
trialDurationDays: settings?.trialDurationDays || 7,
helpText: settings?.helpText || '',
enableBoleto: settings?.enableBoleto !== false
enableBoleto: settings?.enableBoleto !== false,
brandName: settings?.brandName || 'MICROTEC',
brandSlogan: settings?.brandSlogan || 'Acelere seus estudos em informática e pacote Office no estilo Netflix.',
cnpj: settings?.cnpj || '',
address: settings?.address || ''
});
});
@ -1021,7 +1024,11 @@ app.get('/api/admin/settings', authenticateToken, requireAdmin, async (req: Auth
evolutionApiKey: settings?.evolutionApiKey || '',
evolutionInstance: settings?.evolutionInstance || 'microtecflix',
whatsappConnected: settings?.whatsappConnected || false,
whatsappPhoneNumber: settings?.whatsappPhoneNumber || ''
whatsappPhoneNumber: settings?.whatsappPhoneNumber || '',
brandName: settings?.brandName || 'MICROTEC',
brandSlogan: settings?.brandSlogan || 'Acelere seus estudos em informática e pacote Office no estilo Netflix.',
cnpj: settings?.cnpj || '',
address: settings?.address || ''
});
});
@ -1215,11 +1222,10 @@ app.post('/api/admin/courses', authenticateToken, requireAdmin, async (req: Auth
data: {
title,
description,
thumbnailUrl: thumbnail,
thumbnail: thumbnail,
category,
isLocked: isLocked === true || isLocked === 'true',
price: price ? Number(price) : 0,
order: await prisma.course.count() + 1
price: price ? Number(price) : 0
}
});

View File

@ -102,93 +102,7 @@ export default function AdminApp() {
return (
<div className="min-h-screen bg-[#0b0c10] text-zinc-100 flex flex-col selection:bg-[#E50914] selection:text-white font-sans">
{token && adminUser ? (
/* --- AUTHENTICATED ADMIN AREA --- */
<>
{/* Admin Header / Navbar */}
<header className="sticky top-0 z-50 glass-navbar px-4 py-4 md:px-8 flex items-center justify-between border-b border-white/5">
<div className="flex items-center space-x-8">
{/* Logo with Admin Tag */}
<div className="flex items-center space-x-2.5 cursor-pointer select-none">
<div className="bg-[#E50914] text-white p-1.5 rounded flex items-center justify-center">
<Shield className="w-5 h-5" />
</div>
<div className="flex flex-col">
<span className="font-display font-black text-lg md:text-xl leading-none tracking-tighter text-[#E50914]">
MICROTEC<span className="text-white">FLIX</span>
</span>
<span className="text-[9px] text-[#E50914] font-extrabold uppercase tracking-widest mt-0.5 leading-none">
Admin Portal
</span>
</div>
</div>
{/* Navigation Tabs */}
<nav className="hidden md:flex items-center space-x-6 text-sm">
<button
onClick={() => setView('dashboard')}
className={`font-semibold transition-all hover:text-white flex items-center space-x-2 px-3 py-1.5 rounded-lg ${activeView === 'dashboard' ? 'text-white bg-white/5' : 'text-zinc-400'}`}
>
<Database className="w-4 h-4 text-[#E50914]" />
<span>Painel Geral</span>
</button>
<button
onClick={() => setView('courses')}
className={`font-semibold transition-all hover:text-white flex items-center space-x-2 px-3 py-1.5 rounded-lg ${activeView === 'courses' ? 'text-white bg-white/5' : 'text-zinc-400'}`}
>
<GraduationCap className="w-4 h-4 text-[#E50914]" />
<span>Grade de Cursos</span>
</button>
</nav>
</div>
{/* Admin Right Menu */}
<div className="flex items-center space-x-4">
<div className="text-right hidden sm:block border-r border-white/10 pr-4">
<p className="text-xs font-bold text-white">{adminUser.name}</p>
<p className="text-[10px] text-[#E50914] font-black uppercase tracking-wider">{adminUser.email}</p>
</div>
{/* Mobile View Toggle Buttons */}
<div className="flex md:hidden items-center bg-white/5 p-1 rounded-lg">
<button
onClick={() => setView('dashboard')}
className={`text-xs font-bold px-2.5 py-1.5 rounded-md ${activeView === 'dashboard' ? 'bg-[#E50914] text-white' : 'text-zinc-400'}`}
>
Geral
</button>
<button
onClick={() => setView('courses')}
className={`text-xs font-bold px-2.5 py-1.5 rounded-md ${activeView === 'courses' ? 'bg-[#E50914] text-white' : 'text-zinc-400'}`}
>
Grade
</button>
</div>
<button
onClick={handleLogout}
title="Sair do Painel Admin"
className="w-9 h-9 rounded-full bg-white/5 hover:bg-red-950/50 text-zinc-400 hover:text-red-400 flex items-center justify-center transition-all cursor-pointer"
>
<LogOut className="w-4 h-4" />
</button>
</div>
</header>
{/* Main workspace */}
<main className="flex-grow p-4 md:p-8 max-w-7xl mx-auto w-full space-y-8">
{activeView === 'dashboard' ? (
<AdminDashboard token={token} />
) : (
<AdminContentManager token={token} />
)}
</main>
{/* Admin Footer */}
<footer className="border-t border-white/5 bg-[#08090d] py-6 px-4 text-center text-[11px] text-zinc-600">
<p className="font-bold uppercase tracking-wider text-zinc-500">MicrotecFlix Admin Control Center Shared PostgreSQL Engine</p>
<p className="mt-1">Acesso corporativo restrito. Todas as auditorias e alterações de cursos e cobranças Asaas são registradas.</p>
</footer>
</>
<AdminDashboard token={token} adminUser={adminUser} onLogout={handleLogout} />
) : (
/* --- UNAUTHENTICATED ADMIN ACCESS SCREEN --- */
<div className="flex-grow flex items-center justify-center p-4 md:p-8 relative" style={{ backgroundImage: `linear-gradient(rgba(11,12,16,0.9), rgba(11,12,16,0.9)), url('https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=1200&auto=format&fit=crop')`, backgroundSize: 'cover', backgroundPosition: 'center' }}>

View File

@ -47,6 +47,19 @@ 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 } | null>(null);
useEffect(() => {
fetch('/api/settings')
.then(res => res.json())
.then(data => {
setSettings(data);
if (data.brandName) {
document.title = `${data.brandName}FLIX - Plataforma de Estudos`;
}
})
.catch(err => console.error('Error fetching settings:', err));
}, []);
useEffect(() => {
if (token) {
@ -199,6 +212,7 @@ export default function App() {
activeView={activeView}
setView={(v) => { setView(v); setSelectedCourseId(null); }}
onLogout={handleLogout}
settings={settings}
/>
<main className="flex-grow p-4 md:p-8 max-w-7xl mx-auto w-full space-y-8">
@ -301,9 +315,12 @@ export default function App() {
{/* Footer */}
<footer className="border-t border-zinc-900 bg-zinc-950 py-8 px-4 text-center text-xs text-zinc-500 space-y-2 mt-12">
<p className="font-semibold text-zinc-400">MICROTECFLIX - Plataforma de Cursos Profissionalizantes de Informática</p>
<p className="font-semibold text-zinc-400">{(settings?.brandName || 'MICROTEC').toUpperCase()}FLIX - Plataforma de Cursos Profissionalizantes de Informática</p>
<p className="max-w-md mx-auto">Desenvolvido com foco em alta experiência de estudo para Windows, Pacote Office (Word, Excel, PowerPoint) e informática profissionalizante.</p>
<p className="text-zinc-600">© 2026 MicrotecFlix Inc. Todos os direitos reservados. Integrado oficialmente com a API Asaas.</p>
{settings?.cnpj && (
<p className="text-[11px] text-zinc-650">CNPJ: {settings.cnpj} Endereço: {settings.address}</p>
)}
<p className="text-zinc-600">© 2026 {settings?.brandName || 'Microtec'}Flix Inc. Todos os direitos reservados. Integrado oficialmente com a API Asaas.</p>
</footer>
</>
) : (
@ -314,15 +331,17 @@ export default function App() {
{/* Login Container */}
<div className="relative z-10 w-full max-w-md glass-card rounded-2xl p-6 md:p-10 space-y-6 shadow-2xl">
{/* Logo */}
{/* Logo */}
<div className="flex flex-col items-center space-y-2 text-center">
<div className="bg-[#E50914] text-white p-2 rounded flex items-center justify-center shadow-lg">
<Play className="fill-white w-6 h-6" />
</div>
<h1 className="font-display font-black text-2xl tracking-tighter text-[#E50914]">
MICROTEC<span className="text-white">FLIX</span>
{settings?.brandName || 'MICROTEC'}<span className="text-white">FLIX</span>
</h1>
<p className="text-xs text-zinc-400 font-medium">Acelere seus estudos em informática e pacote Office no estilo Netflix.</p>
<p className="text-xs text-zinc-400 font-medium">
{settings?.brandSlogan || 'Acelere seus estudos em informática e pacote Office no estilo Netflix.'}
</p>
</div>
{/* Error indicators */}

View File

@ -30,6 +30,7 @@ interface Course {
category: string;
isLocked?: boolean;
price?: number;
certificateMode?: string;
}
interface AdminContentManagerProps {
@ -183,7 +184,7 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
category: course.category,
isLocked: course.isLocked || false,
price: course.price || 0,
certificateMode: course.certificateMode || 'FULL_COURSE'
certificateMode: (course.certificateMode || 'FULL_COURSE') as 'PER_MODULE' | 'FULL_COURSE'
});
setShowCourseForm(true);
setTimeout(() => {
@ -415,7 +416,7 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
</div>
{!selectedCourse && !showCourseForm && (
<button
onClick={() => { setEditingCourse(null); setCourseForm({ title: '', description: '', thumbnail: '', category: '' }); setShowCourseForm(true); }}
onClick={() => { setEditingCourse(null); setCourseForm({ title: '', description: '', thumbnail: '', category: '', isLocked: false, price: 0, certificateMode: 'FULL_COURSE' }); setShowCourseForm(true); }}
className="glass-btn-primary text-white font-bold text-xs px-4 py-2.5 rounded-lg flex items-center justify-center space-x-2 cursor-pointer self-start sm:self-auto"
>
<Plus className="w-4 h-4" />

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,13 @@ interface AdminPricingManagerProps {
fetchCourses: () => void;
}
function CoursePricingCard({ course, saveLoading, onSave }: { course: Course, saveLoading: string | null, onSave: (id: string, locked: boolean, price: number) => void }) {
interface CoursePricingCardProps {
course: Course;
saveLoading: string | null;
onSave: (id: string, locked: boolean, price: number) => any;
}
function CoursePricingCard({ course, saveLoading, onSave }: CoursePricingCardProps) {
const [localPrice, setLocalPrice] = useState(course.price || 0);
const [localLocked, setLocalLocked] = useState(course.isLocked || false);
@ -193,12 +199,13 @@ export const AdminPricingManager: React.FC<AdminPricingManagerProps> = ({ course
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{courses.map(course => (
<CoursePricingCard
key={course.id}
course={course}
saveLoading={saveLoading}
onSave={handleUpdateCoursePrice}
/>
<div key={course.id}>
<CoursePricingCard
course={course}
saveLoading={saveLoading}
onSave={handleUpdateCoursePrice}
/>
</div>
))}
</div>
</div>

View File

@ -67,7 +67,9 @@ export default function CertificatesTab({ token, studentName }: CertificatesTabP
<div className="space-y-16">
{certificates.map(cert => (
<CertificateView key={cert.id} certificate={cert} studentName={studentName} />
<div key={cert.id}>
<CertificateView certificate={cert} studentName={studentName} />
</div>
))}
</div>
</div>

View File

@ -1,4 +1,4 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import React, { ErrorInfo, ReactNode } from 'react';
import { RefreshCw, AlertTriangle } from 'lucide-react';
interface Props {
@ -10,7 +10,7 @@ interface State {
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
export class ErrorBoundary extends React.Component<Props, State> {
public state: State = {
hasError: false,
error: null

View File

@ -12,9 +12,10 @@ interface NavbarProps {
activeView: string;
setView: (view: string) => void;
onLogout: () => void;
settings?: { brandName?: string } | null;
}
export default function Navbar({ user, activeView, setView, onLogout }: NavbarProps) {
export default function Navbar({ user, activeView, setView, onLogout, settings }: NavbarProps) {
const [showNotifications, setShowNotifications] = useState(false);
const [showProfileMenu, setShowProfileMenu] = useState(false);
const [notifications, setNotifications] = useState<any[]>([]);
@ -110,7 +111,7 @@ export default function Navbar({ user, activeView, setView, onLogout }: NavbarPr
<Play className="fill-white w-5 h-5" />
</div>
<span className="font-sans font-black text-xl md:text-2xl tracking-tighter text-[#E50914]">
MICROTEC<span className="text-white">FLIX</span>
{settings?.brandName || 'MICROTEC'}<span className="text-white">FLIX</span>
</span>
</div>