feat: reverter cards de curso para retrato vertical e aplicar efeito glass hover no carrossel de aulas
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m0s
Details
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m0s
Details
This commit is contained in:
parent
c44ac79a35
commit
714e59e8bc
|
|
@ -33,5 +33,13 @@ Este arquivo define as restrições comportamentais e as diretrizes arquiteturai
|
||||||
|
|
||||||
## 5. Integração com Evolution API v2 & CI/CD
|
## 5. Integração com Evolution API v2 & CI/CD
|
||||||
* **Parâmetro Integration**: Nas chamadas de criação de instância da Evolution API v2 (`/instance/create`), forneça sempre o parâmetro `"integration": "WHATSAPP-BAILEYS"` para evitar erros `400 Bad Request` na API.
|
* **Parâmetro Integration**: Nas chamadas de criação de instância da Evolution API v2 (`/instance/create`), forneça sempre o parâmetro `"integration": "WHATSAPP-BAILEYS"` para evitar erros `400 Bad Request` na API.
|
||||||
|
* **Envio de Mensagens (REST)**: O envio de mensagens deve ser feito via HTTP (usando `fetch`) para a rota `/message/sendText/:instance`, com a `apikey` no Header e utilizando o parâmetro `delay: 1200` para estabilidade. **Não** deve-se utilizar SDK do Baileys diretamente no Node.js para conectar; o servidor deve se comunicar estritamente como cliente REST da API Evolution.
|
||||||
* **Pipeline do Gitea Runner**: O deploy na VPS é automatizado através de um runner de Gitea Actions conectado ao Portainer. Não há necessidade de build e deploy manual no Portainer ao enviar para a branch `main`.
|
* **Pipeline do Gitea Runner**: O deploy na VPS é automatizado através de um runner de Gitea Actions conectado ao Portainer. Não há necessidade de build e deploy manual no Portainer ao enviar para a branch `main`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Interface do Usuário (UI) & Modais
|
||||||
|
* **Proibição de Alertas Nativos**: É **ESTRITAMENTE PROIBIDO** utilizar `alert()`, `window.alert()`, `confirm()`, `window.confirm()` ou `prompt()`.
|
||||||
|
* **Sistema de Modais Global**: Todo o aplicativo é envolvido por um `<ModalProvider>`. Utilize o hook `useModal` (importado de `src/contexts/ModalContext.tsx`) que retorna os helpers `{ alert, success, error, confirm }`.
|
||||||
|
* **Componentes Compartilhados**: Utilize o componente `PaymentCheckoutCard` para cobranças de planos, combos ou cursos avulsos. O layout é unificado e o componente se adapta dinamicamente às propriedades fornecidas.
|
||||||
|
* **Políticas e Termos**: Textos longos de uso e políticas são exibidos via modais (`useModal`) injetados com o conteúdo de banco de dados (`SaaSConfig`), mantendo o aluno no contexto do App sem recarregar a página.
|
||||||
|
|
|
||||||
26
gemini.md
26
gemini.md
|
|
@ -163,7 +163,31 @@ Passos executados:
|
||||||
|
|
||||||
## 7. Integração Evolution API v2 & Runner Gitea
|
## 7. Integração Evolution API v2 & Runner Gitea
|
||||||
|
|
||||||
* **Evolution API v2**: A partir da versão `2.3.x`, a rota de criação de instâncias (`POST /instance/create`) obrigatoriamente requer o parâmetro `"integration": "WHATSAPP-BAILEYS"` no corpo da requisição. Sem isso, a API retorna `400 Bad Request`.
|
* **Evolution API v2**: A partir da versão `2.3.x`, a rota de criação de instâncias (`POST /instance/create`) obrigatoriamente requer o parâmetro `"integration": "WHATSAPP-BAILEYS"` no corpo da requisição. Sem isso, a API retorna `400 Bad Request`. Para o envio de mensagens pelo backend (Node.js), utilizamos chamadas REST com `fetch` na rota `/message/sendText/:instance`, enviando o cabeçalho `apikey`.
|
||||||
* **Deploy Contínuo (Gitea Actions)**: O projeto utiliza um Gitea Runner que compila a aplicação (`npm run build`) e faz deploy automático no Portainer. Qualquer alteração empurrada na branch `main` executa a pipeline e substitui a imagem `microtecflix-app:local` de forma transparente.
|
* **Deploy Contínuo (Gitea Actions)**: O projeto utiliza um Gitea Runner que compila a aplicação (`npm run build`) e faz deploy automático no Portainer. Qualquer alteração empurrada na branch `main` executa a pipeline e substitui a imagem `microtecflix-app:local` de forma transparente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Sistema Global de Modais (UI Nativa)
|
||||||
|
|
||||||
|
* **Proibição de Alertas Nativos**: O uso de `window.alert()` e `window.confirm()` está estritamente proibido. Todas as interações de sistema (confirmações de exclusão, avisos de erro, sucesso) devem utilizar o `ModalProvider` configurado globalmente.
|
||||||
|
* **Como usar**: Importe o hook `useModal` de `src/contexts/ModalContext.tsx`. Ele provê os métodos `alert`, `success`, `error` e `confirm`.
|
||||||
|
* **Renderização**: Os modais utilizam a mesma estilização padrão "glassmorphism" do sistema e suportam animações CSS (`animate-fade-in` e `animate-slide-up`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Termos de Uso, Política de Privacidade e Configurações de SaaS
|
||||||
|
|
||||||
|
* Os textos longos de **Termos de Uso** e **Política de Privacidade** são dinamicamente gravados na tabela `SaaSConfig` do PostgreSQL.
|
||||||
|
* No **Painel do Aluno**, no rodapé, ao invés de navegar para uma nova página, os links devem abrir Modais flutuantes que renderizam o conteúdo armazenado.
|
||||||
|
* No **Painel do Admin**, esses dados são editáveis em caixas de texto longas na aba de "Configurações do SaaS".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Assinaturas, Cursos e Certificados
|
||||||
|
|
||||||
|
* **Venda de Cursos (Checkout)**: A tela "Minha Assinatura" (`SubscriptionView.tsx`) exibe os Planos Dinâmicos criados no Admin. Removemos referências locais (hardcoded) a combos ou assinaturas padrão; tudo vem do banco de dados (tabela `Plan`).
|
||||||
|
* **Cartão de Pagamento**: O componente `PaymentCheckoutCard.tsx` é global e flexível, mudando os valores do Pix/Boleto/Cartão dinamicamente com base no curso avulso ou assinatura que o aluno estiver comprando.
|
||||||
|
* **Modo do Certificado**: Cada Curso agora tem o campo `certificateMode` no Prisma (opções: `FULL_COURSE` ou `PER_MODULE`), o que define como a lógica de desbloqueio do certificado se comporta.
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -329,7 +329,6 @@ export default function App() {
|
||||||
onSelectCourse={handleSelectCourse}
|
onSelectCourse={handleSelectCourse}
|
||||||
onUnlockCourse={(course) => setUnlockingCourse(course)}
|
onUnlockCourse={(course) => setUnlockingCourse(course)}
|
||||||
subscriptionStatus={user.subscriptionStatus}
|
subscriptionStatus={user.subscriptionStatus}
|
||||||
selectedCourseId={expandedCourseId}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Expanded Lessons Shelf */}
|
{/* Expanded Lessons Shelf */}
|
||||||
|
|
@ -360,7 +359,7 @@ export default function App() {
|
||||||
setSelectedLessonId(lesson.id);
|
setSelectedLessonId(lesson.id);
|
||||||
setView('course-view');
|
setView('course-view');
|
||||||
}}
|
}}
|
||||||
className="flex-shrink-0 w-60 md:w-64 bg-zinc-900/80 border border-white/5 rounded-xl overflow-hidden hover:border-[#E50914]/80 transition-all duration-300 group/lesson cursor-pointer snap-start"
|
className="flex-shrink-0 w-60 md:w-64 glass-card glass-card-hover rounded-xl overflow-hidden cursor-pointer snap-start transition-all duration-300 group/lesson"
|
||||||
>
|
>
|
||||||
<div className="relative aspect-video w-full bg-zinc-950 overflow-hidden">
|
<div className="relative aspect-video w-full bg-zinc-950 overflow-hidden">
|
||||||
{lesson.thumbnailUrl ? (
|
{lesson.thumbnailUrl ? (
|
||||||
|
|
|
||||||
|
|
@ -8,25 +8,12 @@ interface CourseCardProps {
|
||||||
onSelect: (courseId: string) => void;
|
onSelect: (courseId: string) => void;
|
||||||
onUnlockRequest?: (course: any) => void;
|
onUnlockRequest?: (course: any) => void;
|
||||||
subscriptionStatus: string;
|
subscriptionStatus: string;
|
||||||
isSelected?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CourseCard({ course, onSelect, onUnlockRequest, subscriptionStatus, isSelected = false }: CourseCardProps) {
|
export default function CourseCard({ course, onSelect, onUnlockRequest, subscriptionStatus }: CourseCardProps) {
|
||||||
const isBlocked = subscriptionStatus === 'OVERDUE' || subscriptionStatus === 'CANCELED';
|
const isBlocked = subscriptionStatus === 'OVERDUE' || subscriptionStatus === 'CANCELED';
|
||||||
const isUnlocked = course.isUnlocked !== false; // defaults to true if undefined
|
const isUnlocked = course.isUnlocked !== false; // defaults to true if undefined
|
||||||
|
|
||||||
const cardClasses = isSelected
|
|
||||||
? "absolute top-0 left-0 flex flex-row w-[380px] max-w-[90vw] h-[220px] bg-zinc-900 border-2 border-[#E50914] rounded-xl overflow-hidden cursor-pointer shadow-2xl z-20 transition-all duration-300"
|
|
||||||
: "absolute top-0 left-0 w-full h-full flex flex-col bg-zinc-950/40 border border-white/10 rounded-xl overflow-hidden cursor-pointer transition-all duration-300 z-10 md:group-hover:z-30 md:group-hover:flex-row md:group-hover:scale-105 md:group-hover:w-[380px] md:group-hover:h-[220px] md:group-hover:shadow-2xl shadow-lg";
|
|
||||||
|
|
||||||
const imgContainerClasses = isSelected
|
|
||||||
? "relative w-[150px] h-full flex-shrink-0 overflow-hidden"
|
|
||||||
: "relative w-full h-[180px] md:group-hover:w-[150px] md:group-hover:h-full flex-shrink-0 overflow-hidden transition-all duration-300";
|
|
||||||
|
|
||||||
const infoContainerClasses = isSelected
|
|
||||||
? "p-4 flex-grow flex flex-col justify-between min-w-0 h-full bg-zinc-900"
|
|
||||||
: "p-3 flex-grow flex flex-col justify-between min-w-0 h-full bg-zinc-900/60 md:group-hover:bg-zinc-900 md:group-hover:p-4 transition-all duration-300";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={`course-card-${course.id}`}
|
id={`course-card-${course.id}`}
|
||||||
|
|
@ -37,97 +24,95 @@ export default function CourseCard({ course, onSelect, onUnlockRequest, subscrip
|
||||||
onSelect(course.id);
|
onSelect(course.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`relative h-[320px] w-full group ${!isUnlocked ? 'filter grayscale brightness-75 hover:brightness-100 hover:grayscale-0' : ''}`}
|
className={`group relative glass-card glass-card-hover rounded-xl overflow-hidden cursor-pointer flex flex-col h-full transition-all duration-300 ${!isUnlocked ? 'filter grayscale brightness-75 hover:brightness-100 hover:grayscale-0' : ''}`}
|
||||||
>
|
>
|
||||||
<div className={cardClasses}>
|
{/* Thumbnail */}
|
||||||
{/* Thumbnail */}
|
<div className="relative w-full aspect-video overflow-hidden">
|
||||||
<div className={imgContainerClasses}>
|
<img
|
||||||
<img
|
src={course.thumbnail}
|
||||||
src={course.thumbnail}
|
alt={course.title}
|
||||||
alt={course.title}
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
referrerPolicy="no-referrer"
|
||||||
referrerPolicy="no-referrer"
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Lock Overlay */}
|
{/* Lock Overlay */}
|
||||||
{!isUnlocked && (
|
{!isUnlocked && (
|
||||||
<div className="absolute inset-0 bg-black/60 flex flex-col items-center justify-center space-y-1.5 z-10 transition-opacity">
|
<div className="absolute inset-0 bg-black/60 flex flex-col items-center justify-center space-y-1.5 z-10 transition-opacity">
|
||||||
<div className="bg-[#E50914] text-white p-2 rounded-full shadow-[0_0_15px_rgba(229,9,20,0.4)] border border-white/10 animate-pulse">
|
<div className="bg-[#E50914] text-white p-2.5 rounded-full shadow-[0_0_15px_rgba(229,9,20,0.4)] border border-white/10 animate-pulse">
|
||||||
<Lock className="w-4 h-4" />
|
<Lock className="w-5 h-5" />
|
||||||
</div>
|
|
||||||
<span className="text-[9px] uppercase tracking-widest font-extrabold text-white bg-black/50 px-2 py-0.5 rounded-full border border-white/5">
|
|
||||||
Bloqueado
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<span className="text-[10px] uppercase tracking-widest font-extrabold text-white bg-black/50 px-2.5 py-0.5 rounded-full border border-white/5">
|
||||||
|
Curso Trancado
|
||||||
{/* Play Overlay */}
|
|
||||||
{isUnlocked && (
|
|
||||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
|
||||||
<div className="bg-[#E50914] text-white p-2.5 rounded-full transform scale-75 group-hover:scale-100 transition-all duration-300 shadow-lg">
|
|
||||||
<Play className="w-5 h-5 fill-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Progress bar at the bottom of the thumbnail */}
|
|
||||||
{course.progress !== undefined && course.progress > 0 && (
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-white/10 backdrop-blur-sm">
|
|
||||||
<div
|
|
||||||
className="bg-[#E50914] h-full transition-all duration-300 shadow-[0_0_10px_rgba(229,9,20,0.5)]"
|
|
||||||
style={{ width: `${course.progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Course Info */}
|
|
||||||
<div className={infoContainerClasses}>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{/* Category */}
|
|
||||||
<span className="text-[9px] font-bold text-[#E50914] tracking-wider uppercase flex items-center justify-between">
|
|
||||||
<span>{course.category}</span>
|
|
||||||
{!isUnlocked && course.price !== undefined && (
|
|
||||||
<span className="text-white bg-zinc-800 px-1.5 py-0.5 rounded text-[8px] font-mono border border-white/5">
|
|
||||||
R$ {course.price.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
{/* Title */}
|
|
||||||
<h3 className="font-display font-bold text-sm md:text-base text-white tracking-tight leading-snug group-hover:text-[#E50914] transition-colors line-clamp-1">
|
|
||||||
{course.title}
|
|
||||||
</h3>
|
|
||||||
{/* Description */}
|
|
||||||
<p className="text-[11px] text-zinc-400 font-normal line-clamp-2 md:group-hover:line-clamp-3 leading-relaxed">
|
|
||||||
{course.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Footer Metrics */}
|
{/* Play Overlay */}
|
||||||
<div className="flex items-center justify-between pt-1.5 border-t border-white/5 text-[10px] text-zinc-500">
|
{isUnlocked && (
|
||||||
<div className="flex items-center space-x-1 font-medium">
|
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
<BookOpen className="w-3 h-3 text-zinc-400" />
|
<div className="bg-[#E50914] text-white p-3 rounded-full transform scale-75 group-hover:scale-100 transition-all duration-300 shadow-lg">
|
||||||
<span>{course.totalLessons || 0} Aulas</span>
|
<Play className="w-6 h-6 fill-white" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{course.progress !== undefined && course.progress > 0 ? (
|
{/* Progress bar at the bottom of the thumbnail */}
|
||||||
<div className="flex items-center space-x-1 text-[#E50914] font-semibold">
|
{course.progress !== undefined && course.progress > 0 && (
|
||||||
{course.progress === 100 ? (
|
<div className="absolute bottom-0 left-0 right-0 h-1 bg-white/10 backdrop-blur-sm">
|
||||||
<div className="flex items-center space-x-1 text-emerald-500">
|
<div
|
||||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
className="bg-[#E50914] h-full transition-all duration-300 shadow-[0_0_10px_rgba(229,9,20,0.5)]"
|
||||||
<span>Concluído</span>
|
style={{ width: `${course.progress}%` }}
|
||||||
</div>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<span>{course.progress}%</span>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
) : (
|
{/* Course Info */}
|
||||||
<span className="text-zinc-500 text-[9px] uppercase font-bold flex items-center gap-1">
|
<div className="p-4 flex-grow flex flex-col justify-between space-y-3">
|
||||||
{!isUnlocked && <Lock className="w-2 text-[#E50914]" />}
|
<div className="space-y-1">
|
||||||
{!isUnlocked ? 'Bloqueado' : 'Não iniciado'}
|
{/* Category */}
|
||||||
|
<span className="text-[10px] font-bold text-[#E50914] tracking-wider uppercase flex items-center justify-between">
|
||||||
|
<span>{course.category}</span>
|
||||||
|
{!isUnlocked && course.price !== undefined && (
|
||||||
|
<span className="text-white bg-zinc-800 px-1.5 py-0.5 rounded text-[9px] font-mono border border-white/5">
|
||||||
|
R$ {course.price.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</span>
|
||||||
|
{/* Title */}
|
||||||
|
<h3 className="font-display font-bold text-sm md:text-base text-white tracking-tight leading-snug group-hover:text-[#E50914] transition-colors line-clamp-1">
|
||||||
|
{course.title}
|
||||||
|
</h3>
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-xs text-zinc-400 font-normal line-clamp-2 leading-relaxed">
|
||||||
|
{course.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Metrics */}
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t border-white/5 text-[11px] text-zinc-500">
|
||||||
|
<div className="flex items-center space-x-1 font-medium">
|
||||||
|
<BookOpen className="w-3.5 h-3.5 text-zinc-400" />
|
||||||
|
<span>{course.totalLessons || 0} Aulas</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{course.progress !== undefined && course.progress > 0 ? (
|
||||||
|
<div className="flex items-center space-x-1 text-[#E50914] font-semibold">
|
||||||
|
{course.progress === 100 ? (
|
||||||
|
<div className="flex items-center space-x-1 text-emerald-500">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||||
|
<span>Concluído</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span>{course.progress}% assistido</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-zinc-500 text-[10px] uppercase font-bold flex items-center gap-1">
|
||||||
|
{!isUnlocked && <Lock className="w-2.5 h-2.5 text-[#E50914]" />}
|
||||||
|
{!isUnlocked ? 'Bloqueado' : 'Não iniciado'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,9 @@ interface CourseCarouselProps {
|
||||||
onSelectCourse: (courseId: string) => void;
|
onSelectCourse: (courseId: string) => void;
|
||||||
onUnlockCourse?: (course: any) => void;
|
onUnlockCourse?: (course: any) => void;
|
||||||
subscriptionStatus: string;
|
subscriptionStatus: string;
|
||||||
selectedCourseId?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CourseCarousel({ title, courses, onSelectCourse, onUnlockCourse, subscriptionStatus, selectedCourseId }: CourseCarouselProps) {
|
export default function CourseCarousel({ title, courses, onSelectCourse, onUnlockCourse, subscriptionStatus }: CourseCarouselProps) {
|
||||||
if (courses.length === 0) return null;
|
if (courses.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -36,7 +35,6 @@ export default function CourseCarousel({ title, courses, onSelectCourse, onUnloc
|
||||||
onSelect={onSelectCourse}
|
onSelect={onSelectCourse}
|
||||||
onUnlockRequest={onUnlockCourse}
|
onUnlockRequest={onUnlockCourse}
|
||||||
subscriptionStatus={subscriptionStatus}
|
subscriptionStatus={subscriptionStatus}
|
||||||
isSelected={selectedCourseId === course.id}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue