Compare commits

...

71 Commits

Author SHA1 Message Date
Sidney Gomes 24e0fbd100 Corrige posicionamento da modal de perfil do admin (removida de dentro da tag aside)
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s Details
2026-07-25 21:49:53 -03:00
Sidney Gomes 5e4a341e47 Adicionado endpoint e botão para testar envio de mídias (imagens, PDFs, vídeos) pelo WhatsApp no painel admin
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s Details
2026-07-25 21:45:50 -03:00
Sidney Gomes 4f5ac1f363 Fix build: add AdminProfileModal.tsx
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s Details
2026-07-25 21:43:01 -03:00
Sidney Gomes 8dde8bf9e3 Adicionado modal de perfil do admin, corrigido emoji picker, tags em todas as caixas
Build and Deploy (Gitea) / build-and-deploy (push) Failing after 38s Details
2026-07-25 21:36:45 -03:00
Sidney Gomes 3b0313d5c0 Adicionado endpoint e botao para Testar Envio de Template do WhatsApp
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 58s Details
2026-07-25 21:19:48 -03:00
Sidney Gomes 4cc23a9e5b Implementado envio automatico de WhatsApp por templates, broadcast com anti-spam e emoji picker
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s Details
2026-07-25 21:07:56 -03:00
Sidney Gomes 4f4fceab29 Implementada nova aba de Modelos de Mensagem WhatsApp no painel Admin
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s Details
2026-07-25 20:49:17 -03:00
Sidney Gomes 9c5143bea6 Corrigido endpoint de liberação e bloqueio manual de cursos no painel Admin
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s Details
2026-07-25 19:31:04 -03:00
Sidney Gomes 3708444f51 Corrigido navegação mobile, exclusão visual de notificações lidas e exibição da foto do perfil do aluno no NavBar
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m0s Details
2026-07-25 19:24:02 -03:00
Sidney Gomes 036bb98eb7 Corrigido erro no mapeamento de modules no Modal de Detalhes das Aulas do Aluno
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m0s Details
2026-07-25 12:05:21 -03:00
Sidney Gomes f449cf087e Corrigido erro que quebrava o AdminDashboard por falta do estado actionLoading
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 58s Details
2026-07-25 11:45:37 -03:00
Sidney Gomes fd8b7f6d36 Adicionado botões de navegação Aula Anterior e Próxima Aula
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s Details
2026-07-25 11:42:40 -03:00
Sidney Gomes c616362e0f Corrigido estado de curtidas na interface do aluno e crash do modal do admin
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s Details
2026-07-25 11:33:12 -03:00
Sidney Gomes 99f7b8b9ee Implementa círculos de progresso com % no centro e curtidas de aula por aluno
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 58s Details
2026-07-25 11:24:51 -03:00
Sidney Gomes f1dde389d2 feat: reestruturação da barra lateral de módulos e aulas no CourseView
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 58s Details
2026-07-25 10:35:34 -03:00
Sidney Gomes c4feb1b109 Corrigindo fundo branco nas caixas de selecao (select) globalmente
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 58s Details
2026-07-25 09:57:15 -03:00
Sidney Gomes 913245b86e Corrigindo import faltando no AdminContentManager
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s Details
2026-07-25 09:52:40 -03:00
Sidney Gomes e669914735 Implementacao da personalizacao do hero banner com preview e integracao backend
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s Details
2026-07-25 09:49:10 -03:00
Sidney Gomes 2bfdfc0240 Batch_Melhorias
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s Details
2026-07-25 09:28:51 -03:00
Sidney Gomes d4b216b0b6 fix campos CVV validade e melhoria carrosel aulas
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m1s Details
2026-07-25 08:50:58 -03:00
Sidney Gomes 8ff22ca6d7 feat: adicionar reordenação por drag-and-drop para cursos módulos e aulas
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s Details
2026-07-25 00:34:12 -03:00
Sidney Gomes 54d1646cba feat: suportar campo order no PUT de cursos do backend
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 24s Details
2026-07-25 00:07:52 -03:00
Sidney Gomes 71f0d15022 feat: adicionar reordenacao de cursos no admin campos de endereco no portal do aluno e botao alterar senha
Build and Deploy (Gitea) / build-and-deploy (push) Has been cancelled Details
2026-07-25 00:07:06 -03:00
Sidney Gomes cbb0e8b6e7 fix: ajustar calculos do dashboard para faturamento usar apenas status RECEIVED
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s Details
2026-07-24 23:45:17 -03:00
Sidney Gomes 98c0228a25 feat: ajustar proporcao do thumbnail do card do curso para retrato 3:4 com zoom object-cover
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s Details
2026-07-24 23:33:58 -03:00
Sidney Gomes 29a7bfcc6b fix: corrigir nome do campo thumbnail no update do curso no prisma
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s Details
2026-07-24 23:29:08 -03:00
Sidney Gomes 714e59e8bc 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
2026-07-24 23:22:39 -03:00
Sidney Gomes c44ac79a35 feat: corrigir botoes hero banner e mudar visual dos cards para retrato com transicao horizontal no hover/selecao
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s Details
2026-07-24 23:11:51 -03:00
Sidney Gomes 43643d5930 fix: unificar modulo de notificação de whatsapp no painel de mensagens
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m1s Details
2026-07-24 23:00:50 -03:00
Sidney Gomes f82fe92d70 fix: resolver erro de liberação de curso em pagamento por cartão Asaas webhook omitindo description
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m2s Details
2026-07-24 22:55:14 -03:00
Sidney Gomes 00f12e721d feat: sistema global de modais nativos do app corrige erro salvar curso e remove todos alert/confirm do navegador
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m2s Details
2026-07-24 22:12:42 -03:00
Sidney Gomes 0c83b67025 feat: checkout unificado com PaymentCheckoutCard planos dinamicos e PIX para assinaturas
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m2s Details
2026-07-24 20:48:08 -03:00
Sidney Gomes 445caaa994 fix: add prisma termsOfUse privacyPolicy columns, clean payload on server, update lang pt-BR and remove slogan truncation
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m1s Details
2026-07-24 20:09:11 -03:00
Sidney Gomes 82b0e0a054 feat: replace alerts with custom glass modals and add Terms/Privacy policy fields
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m8s Details
2026-07-24 19:46:40 -03:00
Sidney Gomes 85eb68c771 fix: conserta token de auth nas notificacoes e payload do evolution api v2
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m6s Details
2026-07-24 19:16:35 -03:00
Sidney Gomes 9efcf4f4b1 fix: torna api/settings publica para a logo carregar antes do login e para chamadas sem header auth
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m5s Details
2026-07-24 19:11:53 -03:00
Sidney Gomes be3e39e62d feat: suporte modal de tickets, aba de mensagens no admin, refatoracoes ui devflix e correcoes uppercase do brandName
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m4s Details
2026-07-24 18:58:48 -03:00
Sidney Gomes 8e19edb4f8 feat: capitalize brandName everywhere and remove hardcoded microtec strings
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m2s Details
2026-07-24 18:33:14 -03:00
Sidney Gomes 1f18813b41 Add S3 MinIO upload feature for lesson cover images in admin manager
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 4m26s Details
2026-07-24 17:56:11 -03:00
Sidney Gomes 279a1510d3 Support horizontal scrolling lesson cards on course click and add thumbnail_url field for lessons in admin
Build and Deploy (Gitea) / build-and-deploy (push) Failing after 37s Details
2026-07-24 17:49:58 -03:00
Sidney Gomes 97b0a0e880 Support installment boleto configuration in admin and checkout with auto-cancellation after 2 days
Build and Deploy (Gitea) / build-and-deploy (push) Failing after 28s Details
2026-07-23 23:45:17 -03:00
Sidney Gomes d9a67f60ff Fix DOM crash by adding notranslate, translate=no to payment form and wrap price in span tag
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m0s Details
2026-07-23 23:29:27 -03:00
Sidney Gomes c36a707b77 Fix syntax brace mismatch in asaas.ts
Build and Deploy (Gitea) / build-and-deploy (push) Failing after 8s Details
2026-07-23 23:24:38 -03:00
Sidney Gomes c7ba765b80 Implement 5 minutes PIX expiration timer, frontend countdown, backend cancel endpoint and auto-cleanup worker
Build and Deploy (Gitea) / build-and-deploy (push) Has been cancelled Details
2026-07-23 23:24:02 -03:00
Sidney Gomes c3bffcbc65 Create local password-recovery workspace skill for code reuse and documentation
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 58s Details
2026-07-23 23:10:31 -03:00
Sidney Gomes 4ce618c318 Allow student login using either email or phone number in frontend and backend
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s Details
2026-07-23 23:08:25 -03:00
Sidney Gomes 59ce33736b Update evolution-api skill with correct sendText payload schema and renumbered headers
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m0s Details
2026-07-23 23:02:50 -03:00
Sidney Gomes 13ee2d5569 Update WhatsappConnectCard to automatically load status on mount and show a reset button in all connection states
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m4s Details
2026-07-23 22:53:53 -03:00
Sidney Gomes a4f42a31ec Optimize forgot-password whatsapp number matching and automatic DDI 55 prefix injection
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m4s Details
2026-07-23 22:48:51 -03:00
Sidney Gomes b6a57c6f00 Create local evolution-api workspace skill for development reuse
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s Details
2026-07-23 22:42:42 -03:00
Sidney Gomes eda76c8914 Update whatsapp logout endpoint to completely delete the instance session state for cleaner reconnects
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s Details
2026-07-23 22:32:52 -03:00
Sidney Gomes 1b98a72b1a Salva regras de integração Evolution API v2 e do Gitea Runner no guia de deploy e regras de agente
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 57s Details
2026-07-23 22:29:36 -03:00
Sidney Gomes ccd42b50d4 Fix payload in Evolution API instance creation by adding integration parameter
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 58s Details
2026-07-23 22:24:23 -03:00
Sidney Gomes 4d130b5b5d Implementa fluxo de Esqueci a Senha duplo com e-mail SMTP e notificacoes WhatsApp via Evolution API
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s Details
2026-07-23 22:05:10 -03:00
Sidney Gomes 16cbca320e Admin panel fixes: rename 'Planos' to 'Planos e Assinaturas', remove slogan from admin sidebar, and make admin login logo dynamically use SaaS brandName
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 58s Details
2026-07-23 21:36:41 -03:00
Sidney Gomes 7dcd2cfa69 Fix checkout payment: add CPF validation, update Asaas customer and add CPF field in checkout modal
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 56s Details
2026-07-23 11:44:32 -03:00
Sidney Gomes 772a308667 Create local agents configuration, rules, and microtecflix development skill
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m0s Details
2026-07-23 11:32:15 -03:00
Sidney Gomes 6f7e7fbcc6 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
2026-07-23 11:26:00 -03:00
Sidney Gomes 8a5427402f feat: add password show/hide eye toggle and remove admin quick login option from portal login
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m17s Details
2026-07-23 10:58:38 -03:00
Sidney Gomes a66391e925 fix: return thumbnail instead of non-existent thumbnailUrl in /api/courses route
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m14s Details
2026-07-23 10:50:28 -03:00
Sidney Gomes d79792898d fix: decimal conversion and add image upload button for courses directly to MinIO S3
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m14s Details
2026-07-23 10:41:30 -03:00
Sidney Gomes 2288eb2784 fix(webhook): fix webhook logger crash and webhook logs mapping in admin dashboard
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m15s Details
2026-07-23 10:20:23 -03:00
Sidney Gomes 9a51bc3dd0 Fix Prisma WebhookLog query causing server crashes
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m15s Details
2026-07-22 23:04:32 -03:00
Sidney Gomes c62ceabab6 Fix esbuild version mismatch for tsx
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m17s Details
2026-07-22 22:58:56 -03:00
Sidney Gomes 5bdfe73fde Fix build: remove Windows package-lock.json
Build and Deploy (Gitea) / build-and-deploy (push) Failing after 44s Details
2026-07-22 22:56:20 -03:00
Sidney Gomes 3215bfc0bc Update schema
Build and Deploy (Gitea) / build-and-deploy (push) Failing after 21s Details
2026-07-22 22:51:27 -03:00
Sidney Gomes d7e16f2fe8 fix(backend): correct Prisma model references for payment and notifications to prevent 502 errors
Build and Deploy (Gitea) / build-and-deploy (push) Failing after 44s Details
2026-07-22 22:33:28 -03:00
Sidney Gomes fa180dee0c fix(backend): fix production crash with vite, make asaas async and add prisma db push retries
Build and Deploy (Gitea) / build-and-deploy (push) Failing after 44s Details
2026-07-22 22:27:19 -03:00
Sidney Gomes 159ae1e8db fix: run prisma db push and seed db on startup
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m15s Details
2026-07-22 22:16:46 -03:00
Sidney Gomes 4e28601237 feat(whatsapp): integrate evolution api and admin settings
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m16s Details
2026-07-22 22:10:37 -03:00
Sidney Gomes a4a1f35550 fix(docker): generate prisma client in runner stage
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m19s Details
2026-07-22 22:06:42 -03:00
40 changed files with 12807 additions and 2012 deletions

45
.agents/AGENTS.md Normal file
View File

@ -0,0 +1,45 @@
# MicrotecFlix - Regras do Agente (Workspace Rules)
Este arquivo define as restrições comportamentais e as diretrizes arquiteturais específicas do MicrotecFlix que o agente deve sempre obedecer em futuras edições.
---
## 1. Conexão com Banco de Dados
* **PostgreSQL Dedicado**: O banco de dados PostgreSQL roda em container isolado na porta **`5435:5432`** (evitando conflito com a porta padrão 5432 usada por outros bancos na VPS, como o do AgendaPRO).
* **Nunca use db.json**: Toda a persistência de dados foi migrada para o PostgreSQL. Não reintroduza o arquivo `db.json` nem funções locais como `loadDb()`.
* **Prisma Client**: Sempre que fizer modificações em `prisma/schema.prisma`, execute `npx prisma generate` localmente e garanta que `npx prisma db push` seja executado no ambiente do deploy para sincronizar o banco.
---
## 2. Roteamento de Domínios & Portais
* **Roteamento no Root**: O arquivo `src/Root.tsx` realiza o roteamento com base no host:
* Subdomínio com `admin` (`admin-estudo.microtecinformaticacurso.com.br`) renderiza o `AdminApp.tsx`.
* Subdomínio padrão (`estudo.microtecinformaticacurso.com.br`) renderiza o `App.tsx` (Painel do Aluno).
* **Mantenha os layouts isolados**: Não misture imports do portal do aluno dentro do administrador e vice-versa.
---
## 3. Customização SaaS (Branding)
* **Logomarca Dinâmica**: A logo deve renderizar o prefixo `brandName` em vermelho e a extensão `FLIX` em branco. O slogan abaixo da logo deve carregar de forma dinâmica do estado público de configurações (`SystemSettings`).
* **CNPJ e Endereço**: Exibir sempre os dados de CNPJ e Endereço legais retornados por `/api/settings` nos rodapés públicos.
---
## 4. Segurança e Webhooks
* **Webhook Asaas**: A rota `/api/webhooks/asaas` exige validação do header `asaas-access-token`. Se for incorreto, retorne **HTTP 401** informando o token recebido vs o esperado para facilitar a depuração no painel do Asaas.
* **PCI-DSS**: Dados confidenciais de cartão (número completo, CVV) **nunca** devem ser persistidos no banco de dados. Use a tokenização da API do Asaas e grave apenas os últimos 4 dígitos e o token de cartão.
---
## 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.
* **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`.
---
## 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.

View File

@ -0,0 +1,122 @@
---
name: evolution-api
description: Manage connection, creation, deletion and messaging workflows for WhatsApp using Evolution API v2.
---
# Evolution API v2 Integration & Troubleshooting Skill
This guide outlines how to configure, connect, and troubleshoot WhatsApp integration using Evolution API (v2.x.x) across SaaS environments.
## 1. Instance Creation Payload (Evolution API v2)
When creating a WhatsApp instance via `POST /instance/create`, the endpoint requires specific fields. In version **v2.3.x or higher**, the parameter `"integration": "WHATSAPP-BAILEYS"` is **mandatory**. Omitting it will result in a `400 Bad Request` with the error `Invalid integration`.
### Request Configuration:
- **Method**: `POST`
- **URL**: `https://<evolution_url>/instance/create`
- **Headers**:
- `apikey`: `<global_api_key>`
- `Content-Type`: `application/json`
- **Body**:
```json
{
"instanceName": "microtecflix",
"token": "optional_custom_token",
"qrcode": true,
"integration": "WHATSAPP-BAILEYS"
}
```
---
## 2. Checking Connection State
To check if the WhatsApp instance is connected:
- **Method**: `GET`
- **URL**: `https://<evolution_url>/instance/connectionState/<instanceName>`
- **Headers**: `apikey: <global_api_key>`
- **Response**: Returns the state (e.g., `open`, `connecting`, `closed`).
- If it returns `open`, the device is successfully paired.
---
## 3. Retrieving the QR Code
To fetch the QR code for scanning:
- **Method**: `GET`
- **URL**: `https://<evolution_url>/instance/connect/<instanceName>`
- **Headers**: `apikey: <global_api_key>`
- **Response**: Returns a JSON containing `{ base64: "data:image/png;base64,...", pairingCode: "..." }`. Display this `base64` image on the frontend.
---
## 4. Sending a Text Message (v2.x.x Payload Schema)
In Evolution API v2, the `POST /message/sendText/<instanceName>` endpoint expects a simplified body structure compared to v1.
- **Method**: `POST`
- **URL**: `https://<evolution_url>/message/sendText/<instanceName>`
- **Headers**:
- `apikey`: `<global_api_key>`
- `Content-Type`: `application/json`
- **Body**:
```json
{
"number": "5585981145217",
"text": "Your message here",
"delay": 1200
}
```
*Note: Do not use the legacy `textMessage` or `options` wrappers (like `{ options: { delay: 1200 }, textMessage: { text: "..." } }`), as these will return a `400 Bad Request` in v2.*
---
## 5. Troubleshooting: "Não foi possível conectar o dispositivo" / Connection Failure
A common error during QR code scanning is `Não foi possível conectar o dispositivo` on the phone, while the API is stuck. This is caused by a corrupted Baileys session cache in the Evolution API container.
### Resolution Steps:
1. **Do not use logout only**: Just running `DELETE /instance/logout/<instanceName>` only closes the current session but leaves the corrupted files on the server.
2. **Delete the Instance**: Completely remove the instance from the server using the delete endpoint:
- **Method**: `DELETE`
- **URL**: `https://<evolution_url>/instance/delete/<instanceName>`
- **Headers**: `apikey: <global_api_key>`
3. **Re-create and Re-scan**: Call the create endpoint (`POST /instance/create`) again with the Baileys integration. This allocates a clean session directory, generating a fresh QR Code that scans instantly.
---
## 6. Reference Implementation (Node.js/Express)
```typescript
import axios from 'axios';
async function getQRCode(apiUrl: string, apiKey: string, instance: string) {
const headers = { apikey: apiKey };
// 1. Check connection state
try {
const stateRes = await axios.get(`${apiUrl}/instance/connectionState/${instance}`, { headers });
if (stateRes.data?.instance?.state === 'open') {
return { connected: true };
}
} catch (err: any) {
if (err.response?.status === 404) {
// 2. Automatically create instance if not found
await axios.post(`${apiUrl}/instance/create`, {
instanceName: instance,
qrcode: true,
integration: 'WHATSAPP-BAILEYS'
}, { headers });
} else {
throw err;
}
}
// 3. Fetch fresh connection QR Code
const connectRes = await axios.get(`${apiUrl}/instance/connect/${instance}`, { headers });
return {
connected: false,
qrCode: connectRes.data.base64 || connectRes.data.qrcode || null
};
}
async function resetInstance(apiUrl: string, apiKey: string, instance: string) {
const headers = { apikey: apiKey };
// Completely delete the instance to wipe the Baileys session cache
await axios.delete(`${apiUrl}/instance/delete/${instance}`, { headers });
}
```

View File

@ -0,0 +1,40 @@
---
name: microtecflix
description: Assist in developing, refactoring, compiling, and deploying the MicrotecFlix course and subscription streaming portal.
---
# MicrotecFlix - Guia de Desenvolvimento de Baixo Consumo de Tokens
Este guia contém as instruções essenciais de arquitetura e do banco de dados para realizar edições de forma rápida, segura e com o mínimo consumo de tokens do modelo Antigravity.
## 1. Mapeamento de Diretórios Principais
- `server.ts`: Backend Express (Rotas, autenticação JWT, integrador Asaas, Evolution API/WhatsApp e logs de Webhook).
- `prisma/schema.prisma`: Esquema de dados PostgreSQL.
- `src/Root.tsx`: Ponto de entrada do roteador frontend (Roteia subdomínio admin vs portal do aluno).
- `src/App.tsx`: Portal do Aluno (Catálogo, player de vídeo, perfil e upload de avatar para MinIO S3).
- `src/AdminApp.tsx` / `src/components/AdminDashboard.tsx`: Painel Administrativo com o menu lateral estilizado do AgendaPRO.
- `src/components/AdminContentManager.tsx`: Gerenciador de Cursos, Módulos e Aulas.
## 2. Banco de Dados & Prisma
O banco roda na porta `5435` do host da VPS.
Para adicionar campos ou tabelas:
1. Altere `prisma/schema.prisma`.
2. Rode `npx prisma generate` localmente para atualizar as tipagens no editor.
3. Commit e envie para o repositório. O CI/CD irá reconstruir a imagem Docker.
4. Execute `npx prisma db push` dentro do container em execução no servidor para sincronizar com o banco:
`ssh -i C:\Users\Sidney\id_oci_rsa ubuntu@150.230.87.131 "sudo docker exec <container-id> npx prisma db push"`
## 3. Customização do SaaS (Branding)
- Rota pública do backend: `/api/settings`
- Rota protegida por JWT Admin: `/api/admin/settings`
- Campos em `SystemSettings`: `brandName` (Prefix em vermelho), `brandSlogan`, `cnpj`, `address`, `enableBoleto`.
- Ao renderizar a logo no frontend:
```tsx
<span className="text-[#E50914]">{brandName}</span>
<span className="text-white">FLIX</span>
```
## 4. Segurança e Gateway de Pagamentos
- Asaas webhook expects header `asaas-access-token`. Validate against `asaasWebhookSecret` database value.
- Do not log credit card PAN, CVV, or expiry dates in the database (PCI-DSS compliance).
- Always use `ErrorBoundary` wrapping for main DOM rendering to handle screen crashes caused by translation tools.

View File

@ -0,0 +1,284 @@
---
name: password-recovery
description: Complete dual-channel password recovery system (Email SMTP + WhatsApp Evolution API v2) with token generation and expiration.
---
# Dual-Channel Password Recovery System (Email SMTP + WhatsApp Evolution API v2)
This skill documents how to build a complete, production-ready, dual-channel password recovery flow using **Email (SMTP)** and **WhatsApp (Evolution API v2)**.
---
## 1. Database Schema (Prisma ORM)
Add fields for tracking reset tokens in the `User` model, and fields to store SMTP configuration in the `SystemSettings` (SaaS tenant settings) model.
```prisma
model User {
id String @id @default(cuid())
email String @unique
whatsapp String?
password String
// Password Recovery Fields
resetPasswordToken String? @db.Text
resetPasswordExpires DateTime?
}
model SystemSettings {
id String @id @default("default")
// SMTP Email Server Configurations
smtpHost String?
smtpPort Int? @default(587)
smtpUser String?
smtpPass String?
smtpFrom String? @default("no-reply@yourdomain.com")
smtpSecure Boolean @default(false)
// Evolution API Configurations (for WhatsApp notifications)
evolutionApiUrl String @default("https://evolution.yourdomain.com")
evolutionApiKey String @default("")
evolutionInstance String @default("default_instance")
whatsappConnected Boolean @default(false)
}
```
---
## 2. Backend Implementation (Node.js & Express)
### SMTP Transporter Setup (using `nodemailer`)
Dynamically create the SMTP transporter using the configurations fetched from the database:
```typescript
import nodemailer from 'nodemailer';
async function getMailTransporter(settings: any) {
if (!settings || !settings.smtpHost || !settings.smtpUser || !settings.smtpPass) {
throw new Error('Configurações SMTP não preenchidas no banco de dados.');
}
return nodemailer.createTransport({
host: settings.smtpHost,
port: settings.smtpPort || 587,
secure: settings.smtpSecure, // true for 465, false for other ports
auth: {
user: settings.smtpUser,
pass: settings.smtpPass,
},
tls: {
rejectUnauthorized: false // Avoid SSL handshake errors on custom/VPS SMTP servers
}
});
}
```
### Endpoint: Request Reset Token (`/api/auth/forgot-password`)
Generates a cryptographically secure token, saves it with a 1-hour expiration time, and triggers the delivery channel (Email or WhatsApp):
```typescript
import crypto from 'crypto';
import Express, { Request, Response } from 'express';
const app = Express();
app.post('/api/auth/forgot-password', async (req: Request, res: Response) => {
const { identifier, channel } = req.body; // channel: 'email' | 'whatsapp'
if (!identifier || !channel) {
return res.status(400).json({ error: 'Identificador e canal são obrigatórios.' });
}
try {
let user;
if (channel === 'whatsapp') {
const cleanPhone = identifier.replace(/\D/g, '');
const withoutDDI = cleanPhone.replace(/^55/, '');
const withDDI = '55' + withoutDDI;
// Robust phone matching: matches any format of the phone stored in the database
user = await prisma.user.findFirst({
where: {
OR: [
{ whatsapp: cleanPhone },
{ whatsapp: withoutDDI },
{ whatsapp: withDDI }
]
}
});
} else {
user = await prisma.user.findFirst({
where: { email: identifier.toLowerCase() }
});
}
// Always return success even if user doesn't exist to prevent enumeration attacks
if (!user) {
return res.json({ success: true, message: 'Link de recuperação enviado com sucesso.' });
}
// Generate secure 32-byte hex token valid for 1 hour
const token = crypto.randomBytes(32).toString('hex');
const expires = new Date(Date.now() + 3600000); // +1 hour
await prisma.user.update({
where: { id: user.id },
data: { resetPasswordToken: token, resetPasswordExpires: expires }
});
const resetUrl = `${req.headers.origin}/reset-password?token=${token}`;
if (channel === 'email') {
const settings = await prisma.systemSettings.findFirst();
const transporter = await getMailTransporter(settings);
const mailOptions = {
from: settings?.smtpFrom || '"Recuperação de Senha" <no-reply@yourdomain.com>',
to: user.email,
subject: 'Redefinição de Senha',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #141414; color: #ffffff; border-radius: 8px;">
<h2 style="color: #E50914;">Recuperação de Senha</h2>
<p>Olá, ${user.name || 'Aluno'}.</p>
<p>Recebemos uma solicitação para redefinir a senha da sua conta.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${resetUrl}" style="background-color: #E50914; color: #ffffff; padding: 12px 24px; text-decoration: none; font-weight: bold; border-radius: 4px;">Redefinir Minha Senha</a>
</div>
<p>Se você não fez essa solicitação, pode ignorar este e-mail com segurança.</p>
<p style="font-size: 12px; color: #666666; margin-top: 30px; border-t: 1px solid #333333; padding-top: 10px;">Link válido por 1 hora.</p>
</div>
`
};
await transporter.sendMail(mailOptions);
} else if (channel === 'whatsapp') {
const text = `*Recuperação de Senha*\n\nOlá, você solicitou a recuperação de sua senha.\nClique no link abaixo para criar uma nova senha:\n\n${resetUrl}\n\n_Válido por 1 hora._`;
// Send message via sendWhatsappNotification (explained in Section 4)
await sendWhatsappNotification(user.whatsapp || identifier, text);
}
res.json({ success: true, message: 'Link de recuperação enviado com sucesso.' });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao processar solicitação.' });
}
});
```
### Endpoint: Save New Password (`/api/auth/reset-password`)
Validates the token, checks if it has expired, hashes the new password using `bcrypt`, updates the database, and voids the token.
```typescript
import bcrypt from 'bcrypt';
app.post('/api/auth/reset-password', async (req: Request, res: Response) => {
const { token, newPassword } = req.body;
if (!token || !newPassword) {
return res.status(400).json({ error: 'Token e nova senha são obrigatórios.' });
}
try {
const user = await prisma.user.findFirst({
where: {
resetPasswordToken: token,
resetPasswordExpires: { gt: new Date() } // Check if expiration date is greater than now
}
});
if (!user) {
return res.status(400).json({ error: 'Token inválido ou expirado.' });
}
// Hash the password and clear the reset fields
const hashedPassword = bcrypt.hashSync(newPassword, 10);
await prisma.user.update({
where: { id: user.id },
data: {
password: hashedPassword,
resetPasswordToken: null,
resetPasswordExpires: null
}
});
res.json({ success: true, message: 'Senha redefinida com sucesso!' });
} catch (err: any) {
res.status(500).json({ error: 'Erro ao redefinir a senha.' });
}
});
```
---
## 3. WhatsApp Dispatch System (Evolution API v2)
Always format phone numbers correctly (prepending Brazil's DDI `55` if it only has 10 or 11 digits) and dispatch with the correct Evolution API v2 JSON payload.
```typescript
import axios from 'axios';
async function sendWhatsappNotification(phone: string, text: string) {
try {
const settings = await prisma.systemSettings.findFirst();
if (!settings?.whatsappConnected || !settings.evolutionApiUrl || !settings.evolutionApiKey) {
return false; // WhatsApp configuration missing or inactive
}
// Clean and validate digits
let cleanPhone = phone.replace(/\D/g, '');
if (!cleanPhone) return false;
// Inject country code (DDI) 55 if missing (standard Brazilian number with DDD)
if (cleanPhone.length === 10 || cleanPhone.length === 11) {
cleanPhone = '55' + cleanPhone;
}
const apiUrl = settings.evolutionApiUrl.replace(/\/$/, '');
const instance = settings.evolutionInstance || 'default_instance';
// MUST use the Evolution API v2 body schema: { number, text, delay }
await axios.post(`${apiUrl}/message/sendText/${instance}`, {
number: cleanPhone,
text: text,
delay: 1200
}, {
headers: { apikey: settings.evolutionApiKey }
});
return true;
} catch (err: any) {
console.error('Falha ao enviar mensagem de WhatsApp:', err.message);
return false;
}
}
```
---
## 4. Frontend Integration (React)
### Parsing Token from URL
Read the token parameter in a React page/component on load:
```tsx
import React, { useEffect, useState } from 'react';
export const App = () => {
const [resetToken, setResetToken] = useState<string | null>(null);
const [isForgotPassword, setIsForgotPassword] = useState(false);
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const tokenParam = urlParams.get('token');
// Check if path matches /reset-password and token is present
if (window.location.pathname === '/reset-password' && tokenParam) {
setResetToken(tokenParam);
setIsForgotPassword(true); // Open password modal automatically
}
}, []);
// Render forgot password modal or main login here...
};
```

View File

@ -16,10 +16,12 @@ ENV NODE_ENV=production
ENV PORT=3000
COPY package.json package-lock.json* bun.lock* ./
RUN npm install --only=production
COPY prisma ./prisma
RUN npm install --omit=dev
RUN npx prisma generate
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.cjs"]
CMD ["sh", "-c", "npx prisma db push && node dist/server.cjs"]

View File

@ -159,3 +159,35 @@ Passos executados:
| **Proteção Anti-Carding** | Middleware Rate Limiting | **Alto** | Limita o número de tentativas de pagamento por IP no checkout para evitar testes automatizados por bots. |
| **Proteção Anti-Crash (DOM)** | React `ErrorBoundary` + Encapsulamento `<span>` | **Alto** | Previne travamentos de tela branca causados por extensões do navegador ou tradução automática do Google Chrome. |
---
## 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`. 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.
---
## 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.

View File

@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

View File

@ -16,9 +16,13 @@ O **MicrotecFlix** é uma plataforma EAD / Streaming de cursos de informática e
* Assinatura e checkout integrado via Pix, Boleto e Cartão de Crédito.
* Perfil Dinâmico: Edição de dados pessoais (Nome, Data de Nascimento, WhatsApp).
* Upload de Avatar: Integração com armazenamento S3 (MinIO) para hospedar fotos de perfil.
* Identidade Visual Customizada: Logomarca, slogan e rodapé legais (CNPJ e Endereço) atualizados dinamicamente com base nas configurações ativas do SaaS.
### Funcionalidades do Administrador (`AdminApp.tsx`):
* Dashboard com métricas financeiras (MRR, Receita Total, Taxa de Inadimplência, Alunos Ativos).
### Funcionalidades do Administrador (`AdminApp.tsx` / `AdminDashboard.tsx`):
* **Menu Lateral Responsivo (Estilo AgendaPRO)** dividido em seções:
* **VISÃO GERAL**: Dashboard com métricas financeiras (MRR, Receita Total, Taxa de Inadimplência, Alunos Ativos).
* **GESTÃO**: Assinantes, Grade de Cursos, Planos de Combos e Histórico de Pagamentos.
* **SISTEMA**: Configurações Asaas, Evolution API, Webhooks Asaas e Configuração do SaaS (onde se alteram o nome, slogan, CNPJ e endereço da plataforma).
* CRUD de Cursos, Módulos e Aulas.
* Gestão de Alunos e alteração manual de status de assinatura (`ACTIVE`, `OVERDUE`, `CANCELED`, `TRIAL`).
* Visualização do histórico de Webhooks do Asaas.
@ -27,10 +31,10 @@ O **MicrotecFlix** é uma plataforma EAD / Streaming de cursos de informática e
## 2. Esquema de Entidades (`prisma/schema.prisma` / PostgreSQL)
Todo o sistema foi **100% migrado para o PostgreSQL** utilizando o **Prisma ORM**. O antigo arquivo `db.json` e a função genérica `loadDb()` foram completamente descartados e removidos da base de código backend (`server-prisma.ts`).
Todo o sistema foi **100% migrado para o PostgreSQL** utilizando o **Prisma ORM**. O antigo arquivo `db.json` e a função genérica `loadDb()` foram completamente descartados e removidos da base de código backend (`server.ts`).
1. **`User`**: Usuários da plataforma (`admin` ou `student`) com status de assinatura (`ACTIVE`, `OVERDUE`, `CANCELED`, `TRIAL`).
2. **`Course`**: Cursos cadastrados com título, descrição, thumbnail, categoria e preço avulso/bloqueio.
2. **`Course`**: Cursos cadastrados com título, descrição, thumbnail, categoria, certificadoMode (`FULL_COURSE` ou `PER_MODULE`) e preço avulso/bloqueio.
3. **`Module`**: Módulos organizados sequencialmente por curso.
4. **`Lesson`**: Aulas vinculadas ao módulo com URL de vídeo (`youtube` ou `direct`) e conteúdo complementar.
5. **`Note`**: Anotações pessoais feitas pelos alunos por aula.
@ -41,7 +45,7 @@ Todo o sistema foi **100% migrado para o PostgreSQL** utilizando o **Prisma ORM*
10. **`Certificate`**: Certificados ganhos pelos alunos (por Módulo ou por Curso) gerados automaticamente.
11. **`Plan`**: Planos de assinatura (Combos vitálcios, Assinaturas Mensais, Anuais).
12. **`Notification`**: Alertas e avisos aos usuários, gerenciados em tabela relacional em tempo real.
13. **`SystemSettings`**: Configurações dinâmicas do sistema, chaves do Asaas e texto de ajuda.
13. **`SystemSettings`**: Configurações dinâmicas do sistema, chaves do Asaas, texto de ajuda, Evolution API, marca SaaS (`brandName` em vermelho, `brandSlogan`, `cnpj`, `address`) e opção de habilitar/desabilitar boleto bancário (`enableBoleto`).
---
@ -63,7 +67,7 @@ Todo o sistema foi **100% migrado para o PostgreSQL** utilizando o **Prisma ORM*
## 4. Diretrizes para Futuras Edições
1. **Preservação da Autenticação JWT**: O token JWT é armazenado em `localStorage` sob a chave `devflix_token`. Qualquer nova rota de API restrita deve utilizar o middleware `authenticateToken`.
1. **Preservação da Autenticação JWT**: O token JWT é armazenado em `localStorage` sob a chave `devflix_token` (alunos) e `devflix_admin_token` (administradores). Qualquer nova rota de API restrita deve utilizar o middleware `authenticateToken`.
2. **Segurança do Webhook (Asaas)**: A rota `/api/webhooks/asaas` exige validação do header `asaas-access-token` contra a chave `asaasWebhookSecret` salva nas Configurações Globais. Caso as chaves divirjam, a requisição sofre rejeição com **HTTP 401 Unauthorized** e um payload detalhando qual token foi recebido e qual era o esperado.
3. **Liberação de Cursos/Assinaturas**: Webhooks com status de confirmação (`PAYMENT_CONFIRMED`, `PAYMENT_RECEIVED`) atualizam automaticamente o status da assinatura ou liberam o curso no array `unlockedCourses` se a validação de segurança passar.
4. **Isolamento de Banco de Dados**: O container `postgres-microtecflix` roda na porta 5435 do host para garantir que não haja conflitos de porta com o banco PostgreSQL de outros projetos (ex: AgendaPRO na porta 5432).

5276
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -14,15 +14,18 @@
"@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",
"bcryptjs": "^3.0.3",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"multer": "^1.4.5-lts.1",
"nodemailer": "^9.0.3",
"prisma": "^6.4.1",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"recharts": "^2.15.0",
@ -34,12 +37,13 @@
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^1.4.12",
"@types/node": "^22.14.0",
"@types/nodemailer": "^8.0.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"prisma": "^6.4.1",
"esbuild": "^0.28.1",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3"
"typescript": "~5.8.2"
}
}

View File

@ -57,15 +57,26 @@ model User {
whatsapp String?
birthDate String? @map("birth_date")
avatarUrl String? @map("avatar_url") @db.Text
cep String?
address String?
number String?
complement String?
reference String?
neighborhood String?
city String?
state String?
trialEndsAt DateTime? @map("trial_ends_at")
resetPasswordToken String? @map("reset_password_token") @db.Text
resetPasswordExpires DateTime? @map("reset_password_expires")
createdAt DateTime @default(now()) @map("created_at")
notes Note[]
progress Progress[]
payments Payment[]
moduleGrades ModuleGrade[]
certificates Certificate[]
unlockedCourses String[] @default([]) @map("unlocked_courses")
supportTickets SupportTicket[]
lessonReactions LessonReaction[]
@@map("users")
}
@ -79,9 +90,12 @@ model Course {
certificateMode CertificateMode @default(FULL_COURSE) @map("certificate_mode")
isLocked Boolean @default(false) @map("is_locked")
price Float?
order Int @default(0)
studyMaterialUrl String? @map("study_material_url") @db.Text
createdAt DateTime @default(now()) @map("created_at")
modules Module[]
payments Payment[]
@@map("courses")
}
@ -108,11 +122,13 @@ model Lesson {
videoType VideoType @map("video_type")
order Int
content String? @db.Text
thumbnailUrl String? @map("thumbnail_url") @db.Text
createdAt DateTime @default(now()) @map("created_at")
module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade)
notes Note[]
progress Progress[]
reactions LessonReaction[]
@@map("lessons")
}
@ -146,6 +162,20 @@ model Progress {
@@map("progress")
}
model LessonReaction {
id String @id @default(uuid())
userId String @map("user_id")
lessonId String @map("lesson_id")
type String // 'LIKE' or 'DISLIKE'
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
@@unique([userId, lessonId])
@@map("lesson_reactions")
}
model Payment {
id String @id
userId String @map("user_id")
@ -161,6 +191,8 @@ model Payment {
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
plan Plan? @relation(fields: [planId], references: [id], onDelete: SetNull)
course Course? @relation(fields: [courseId], references: [id], onDelete: SetNull)
@@map("payments")
}
@ -174,6 +206,18 @@ model WebhookLog {
@@map("webhook_logs")
}
model Notification {
id String @id @default(uuid())
userId String @map("user_id") // Can be 'ALL' for system wide
title String
message String @db.Text
type String @default("info") // 'info', 'success', 'warning', 'error'
read Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
@@map("notifications")
}
model ModuleActivity {
id String @id @default(uuid())
moduleId String @unique @map("module_id")
@ -226,6 +270,7 @@ model Plan {
active Boolean @default(true)
includedCourses String[] @default([]) @map("included_courses")
createdAt DateTime @default(now()) @map("created_at")
payments Payment[]
@@map("plans")
}
@ -238,7 +283,52 @@ 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")
enableInstallmentBoleto Boolean @default(false) @map("enable_installment_boleto")
maxBoletoInstallments Int @default(3) @map("max_boleto_installments")
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")
whatsappTemplatePayment String? @map("whatsapp_template_payment") @db.Text
whatsappTemplateWelcome String? @map("whatsapp_template_welcome") @db.Text
whatsappTemplateNewContent String? @map("whatsapp_template_new_content") @db.Text
whatsappTemplatePromo String? @map("whatsapp_template_promo") @db.Text
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")
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")
smtpPass String? @map("smtp_pass")
smtpFrom String? @default("no-reply@microtecinformaticacurso.com.br") @map("smtp_from")
smtpSecure Boolean @default(false) @map("smtp_secure")
@@map("system_settings")
}
model SupportTicket {
id String @id @default(uuid())
userId String @map("user_id")
content String @db.Text
status String @default("OPEN") // OPEN, REPLIED
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("support_tickets")
}

1228
server.ts

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,7 @@ import AdminDashboard from './components/AdminDashboard';
import AdminContentManager from './components/AdminContentManager';
export default function AdminApp() {
const [token, setToken] = useState<string | null>(localStorage.getItem('devflix_admin_token'));
const [token, setToken] = useState<string | null>(localStorage.getItem('admin_token'));
const [adminUser, setAdminUser] = useState<{
name: string;
email: string;
@ -22,6 +22,19 @@ export default function AdminApp() {
const [loginPassword, setLoginPassword] = useState('');
const [authError, setAuthError] = useState<string | null>(null);
const [authLoading, setAuthLoading] = 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 - Admin Portal`;
}
})
.catch(err => console.error('Error fetching settings:', err));
}, []);
useEffect(() => {
if (token) {
@ -75,7 +88,7 @@ export default function AdminApp() {
throw new Error('Acesso recusado. Apenas administradores podem acessar esta área.');
}
localStorage.setItem('devflix_admin_token', data.token);
localStorage.setItem('admin_token', data.token);
setToken(data.token);
setAdminUser(data.user);
setLoginEmail('');
@ -88,7 +101,7 @@ export default function AdminApp() {
};
const handleLogout = () => {
localStorage.removeItem('devflix_admin_token');
localStorage.removeItem('admin_token');
setToken(null);
setAdminUser(null);
};
@ -102,93 +115,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' }}>
@ -204,7 +131,7 @@ export default function AdminApp() {
</div>
<div className="space-y-1">
<h1 className="font-display font-black text-2xl tracking-tighter text-white">
MICROTEC<span className="text-[#E50914]">FLIX</span> <span className="text-zinc-500 font-medium text-lg">ADMIN</span>
{(settings?.brandName || 'Plataforma')}<span className="text-[#E50914]">FLIX</span> <span className="text-zinc-500 font-medium text-lg">ADMIN</span>
</h1>
<p className="text-[11px] text-zinc-400 font-medium">Autenticação obrigatória para gestores, administradores e moderadores.</p>
</div>
@ -229,7 +156,7 @@ export default function AdminApp() {
required
value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)}
placeholder="email.admin@devflix.com"
placeholder="admin@email.com"
className="w-full glass-input rounded-xl p-3.5 pl-11 text-xs text-white focus:outline-none"
/>
</div>

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
Play, Shield, User, GraduationCap, ArrowRight, Lock, Mail,
Plus, CheckCircle, ChevronRight, HelpCircle, Film, Laptop
Plus, CheckCircle, ChevronRight, HelpCircle, Film, Laptop, Eye, EyeOff
} from 'lucide-react';
import Navbar from './components/Navbar';
import HeroBanner from './components/HeroBanner';
@ -12,26 +12,35 @@ import CourseUnlockModal from './components/CourseUnlockModal';
import CertificatesTab from './components/CertificatesTab';
import ProfileView from './components/ProfileView';
import HelpView from './components/HelpView';
import CustomModal from './components/CustomModal';
import { User as UserType, Course as CourseType, SubscriptionStatus } from './types';
export default function App() {
const [token, setToken] = useState<string | null>(localStorage.getItem('devflix_token'));
const [token, setToken] = useState<string | null>(localStorage.getItem('app_token'));
const [user, setUser] = useState<{
name: string;
email: string;
role: 'admin' | 'student';
subscriptionStatus: SubscriptionStatus;
avatarUrl?: string;
} | null>(null);
// App routing
const [activeView, setView] = useState<string>('home'); // home, course-view, subscription, admin-dashboard, admin-content
const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null);
const [unlockingCourse, setUnlockingCourse] = useState<CourseType | null>(null);
const [expandedCourseId, setExpandedCourseId] = useState<string | null>(null);
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null);
const lessonShelfRefs = useRef<Record<string, HTMLDivElement | null>>({});
// Authentication forms
const [isRegister, setIsRegister] = useState(false);
const [isForgotPassword, setIsForgotPassword] = useState(false);
const [resetToken, setResetToken] = useState<string | null>(null);
const [loginEmail, setLoginEmail] = useState('');
const [loginPassword, setLoginPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showRegPassword, setShowRegPassword] = useState(false);
const [regName, setRegName] = useState('');
const [regEmail, setRegEmail] = useState('');
const [regPassword, setRegPassword] = useState('');
@ -45,6 +54,27 @@ 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; 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(() => {
const urlParams = new URLSearchParams(window.location.search);
const tokenParam = urlParams.get('token');
if (window.location.pathname === '/reset-password' && tokenParam) {
setResetToken(tokenParam);
setIsForgotPassword(true);
}
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) {
@ -96,6 +126,55 @@ export default function App() {
}
};
const handleForgotPassword = async (e: React.FormEvent) => {
e.preventDefault();
setAuthError(null);
setAuthSuccess(null);
setAuthLoading(true);
try {
const res = await fetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: loginEmail, channel: loginEmail.includes('@') ? 'email' : 'whatsapp' })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Erro ao solicitar recuperação.');
setAuthSuccess(data.message || 'Link de recuperação enviado.');
} catch (err: any) {
setAuthError(err.message);
} finally {
setAuthLoading(false);
}
};
const handleResetPassword = async (e: React.FormEvent) => {
e.preventDefault();
if (regPassword !== regPassword.trim() || regPassword.length < 6) {
setAuthError('A senha deve ter no mínimo 6 caracteres.');
return;
}
setAuthError(null);
setAuthSuccess(null);
setAuthLoading(true);
try {
const res = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: resetToken, newPassword: regPassword })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Erro ao redefinir senha.');
setAuthSuccess(data.message || 'Senha redefinida com sucesso! Redirecionando...');
setTimeout(() => {
window.location.href = '/';
}, 3000);
} catch (err: any) {
setAuthError(err.message);
} finally {
setAuthLoading(false);
}
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setAuthError(null);
@ -113,7 +192,7 @@ export default function App() {
throw new Error(data.error || 'Erro ao efetuar login');
}
localStorage.setItem('devflix_token', data.token);
localStorage.setItem('app_token', data.token);
setToken(data.token);
setUser(data.user);
setLoginEmail('');
@ -143,7 +222,7 @@ export default function App() {
throw new Error(data.error || 'Erro ao efetuar cadastro');
}
localStorage.setItem('devflix_token', data.token);
localStorage.setItem('app_token', data.token);
setToken(data.token);
setUser(data.user);
setRegName('');
@ -157,11 +236,13 @@ export default function App() {
};
const handleLogout = () => {
localStorage.removeItem('devflix_token');
localStorage.removeItem('app_token');
setToken(null);
setUser(null);
setView('home');
setSelectedCourseId(null);
setExpandedCourseId(null);
setSelectedLessonId(null);
};
const handleSelectCourse = (courseId: string) => {
@ -170,8 +251,18 @@ export default function App() {
setUnlockingCourse(selected);
return;
}
setSelectedCourseId(courseId);
setView('course-view');
if (expandedCourseId === courseId) {
setExpandedCourseId(null);
} else {
setExpandedCourseId(courseId);
// Smooth scroll to lesson shelf after brief render delay
setTimeout(() => {
const el = lessonShelfRefs.current[courseId];
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 80);
}
};
// Group courses by Category
@ -197,6 +288,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">
@ -206,8 +298,25 @@ 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}
onPlay={handleSelectCourse}
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) {
setUnlockingCourse(selected);
return;
}
setSelectedCourseId(courseId);
const firstLesson = selected?.lessons && selected.lessons.length > 0 ? selected.lessons[0].id : null;
setSelectedLessonId(firstLesson);
setView('course-view');
}}
onDetail={handleSelectCourse}
subscriptionStatus={user.subscriptionStatus}
setView={setView}
/>
@ -223,16 +332,134 @@ export default function App() {
Nenhum curso disponível no momento.
</div>
) : (
categories.map(cat => (
categories.map(cat => {
const categoryCourses = courses.filter(c => c.category === cat);
const expandedCourse = categoryCourses.find(c => c.id === expandedCourseId);
return (
<div key={cat} className="space-y-4">
<CourseCarousel
key={cat}
title={cat}
courses={courses.filter(c => c.category === cat)}
courses={categoryCourses}
onSelectCourse={handleSelectCourse}
onUnlockCourse={(course) => setUnlockingCourse(course)}
subscriptionStatus={user.subscriptionStatus}
/>
))
{/* Expanded Lessons Shelf */}
{expandedCourse && expandedCourse.lessons && expandedCourse.lessons.length > 0 && (
<div
ref={(el) => { lessonShelfRefs.current[expandedCourse.id] = el; }}
className="lesson-shelf-enter bg-[#141414]/95 border border-white/5 rounded-2xl p-5 space-y-4 shadow-2xl relative overflow-hidden"
>
{/* Red accent left bar */}
<div className="absolute top-0 left-0 w-1.5 h-full bg-[#E50914] rounded-l-2xl" />
{/* Header: course thumbnail + title + close */}
<div className="flex items-center justify-between pl-3 gap-3">
<div className="flex items-center gap-3 min-w-0">
{/* Course miniature */}
<img
src={expandedCourse.thumbnail}
alt={expandedCourse.title}
className="w-14 h-20 object-cover rounded-lg border border-white/10 flex-shrink-0 shadow-lg"
referrerPolicy="no-referrer"
/>
<div className="min-w-0">
<h3 className="text-[10px] font-extrabold text-[#E50914] uppercase tracking-widest">
Aulas do Curso: <span className="text-white">{expandedCourse.title}</span>
</h3>
<p className="text-[10px] text-zinc-500 mt-0.5">{expandedCourse.lessons.length} aulas disponíveis</p>
{(expandedCourse as any).studyMaterialUrl && (
<a
href={(expandedCourse as any).studyMaterialUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 mt-2 bg-[#E50914]/10 hover:bg-[#E50914]/20 border border-[#E50914]/30 text-[#E50914] text-[10px] font-bold px-2.5 py-1 rounded-md transition-colors"
>
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
Baixar Material (PDF)
</a>
)}
</div>
</div>
<button
onClick={() => setExpandedCourseId(null)}
className="text-zinc-400 hover:text-white p-2 rounded-full hover:bg-zinc-800/50 transition-colors text-sm font-bold flex-shrink-0"
title="Fechar"
>
</button>
</div>
{/* Lessons carousel - portrait cards */}
<div className="flex overflow-x-auto gap-4 py-2 pl-3 custom-scrollbar snap-x scroll-smooth">
{expandedCourse.lessons.map(lesson => (
<div
key={lesson.id}
onClick={() => {
setSelectedCourseId(expandedCourse.id);
setSelectedLessonId(lesson.id);
setView('course-view');
}}
className="flex-shrink-0 w-44 glass-card glass-card-hover rounded-xl overflow-hidden cursor-pointer snap-start transition-all duration-300 group/lesson flex flex-col"
>
{/* Portrait thumbnail */}
<div className="relative w-full aspect-[3/4] bg-zinc-950 overflow-hidden flex-shrink-0">
{lesson.thumbnailUrl ? (
<img
src={lesson.thumbnailUrl}
alt={lesson.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover/lesson:scale-110"
referrerPolicy="no-referrer"
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-zinc-800 via-zinc-900 to-zinc-950 flex flex-col items-center justify-center p-3 text-center">
<span className="text-[9px] text-[#E50914] font-extrabold tracking-widest uppercase mb-1.5 bg-[#E50914]/10 px-2 py-0.5 rounded-full border border-[#E50914]/20">Aula {lesson.order}</span>
<span className="text-xs text-white/50 font-semibold line-clamp-3 leading-snug">{lesson.title}</span>
</div>
)}
{/* Play overlay */}
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover/lesson: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/lesson:scale-100 transition-all duration-300 shadow-lg">
<svg className="w-5 h-5 fill-white" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
</div>
{/* Order badge */}
<span className="absolute top-2 left-2 bg-black/70 text-[#E50914] text-[9px] font-extrabold px-1.5 py-0.5 rounded backdrop-blur-sm border border-white/10">
#{lesson.order}
</span>
{/* Progress Bar Overlay */}
{(lesson as any).progress !== undefined && (
<div className="absolute bottom-0 left-0 w-full h-1.5 bg-black/80">
<div
className="h-full bg-[#E50914] transition-all duration-300"
style={{ width: `${(lesson as any).progress.watchedPercentage}%` }}
/>
</div>
)}
</div>
{/* Info below */}
<div className="p-2.5 flex-grow">
<h4 className="text-[11px] font-bold text-white group-hover/lesson:text-[#E50914] transition-colors line-clamp-2 leading-snug">
{lesson.title}
</h4>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
})
)}
</div>
</div>
@ -243,7 +470,8 @@ export default function App() {
<CourseView
courseId={selectedCourseId}
token={token}
onBack={() => { setView('home'); setSelectedCourseId(null); fetchCourses(); }}
initialLessonId={selectedLessonId}
onBack={() => { setView('home'); setSelectedCourseId(null); setSelectedLessonId(null); fetchCourses(); }}
/>
)}
@ -287,6 +515,7 @@ export default function App() {
{unlockingCourse && (
<CourseUnlockModal
course={unlockingCourse}
user={user}
onClose={() => setUnlockingCourse(null)}
onSuccess={() => {
setUnlockingCourse(null);
@ -299,10 +528,39 @@ 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 || 'Plataforma')}FLIX - Plataforma de Cursos Profissionalizantes</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>
)}
<div className="flex items-center justify-center gap-4 mt-2">
<button
onClick={() => setInfoModal({isOpen: true, title: 'Termos de Uso', content: settings?.termsOfUse || 'Termos de uso não definidos.'})}
className="text-[11px] hover:text-white transition-colors underline"
>
Termos de Uso
</button>
<button
onClick={() => setInfoModal({isOpen: true, title: 'Política de Privacidade', content: settings?.privacyPolicy || 'Política de privacidade não definida.'})}
className="text-[11px] hover:text-white transition-colors underline"
>
Política de Privacidade
</button>
</div>
<p className="text-zinc-600 mt-2">© 2026 {(settings?.brandName || 'Plataforma')}FLIX Inc. Todos os direitos reservados. Integrado oficialmente com a API Asaas.</p>
</footer>
{/* Info Modal for Terms and Privacy */}
<CustomModal
isOpen={infoModal.isOpen}
onClose={() => setInfoModal({...infoModal, isOpen: false})}
title={infoModal.title}
isGlass={true}
>
<div className="whitespace-pre-wrap text-sm text-zinc-300">
{infoModal.content}
</div>
</CustomModal>
</>
) : (
/* --- UNAUTHENTICATED PORTAL (LOGIN / REGISTER) --- */
@ -318,19 +576,93 @@ export default function App() {
<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 || 'Plataforma')}<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 */}
{authError && (
<div className="bg-red-950/40 border border-red-900/50 text-red-400 p-3 rounded-lg text-xs leading-relaxed text-center">
{authError}
</div>
)}
{isRegister ? (
{authSuccess && (
<div className="bg-emerald-950/40 border border-emerald-900/50 text-emerald-400 p-3 rounded-lg text-xs leading-relaxed text-center">
{authSuccess}
</div>
)}
{resetToken ? (
/* RESET PASSWORD FORM */
<form onSubmit={handleResetPassword} className="space-y-4">
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Nova Senha</label>
<div className="relative">
<Lock className="absolute left-3 top-3 w-4 h-4 text-zinc-500" />
<input
type={showPassword ? "text" : "password"}
required
value={regPassword}
onChange={(e) => setRegPassword(e.target.value)}
placeholder="Mínimo 6 caracteres"
className="w-full glass-input rounded-lg p-3 pl-10 pr-10 text-xs text-white focus:outline-none"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-3 text-zinc-500 hover:text-white transition-colors"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<button
type="submit"
disabled={authLoading}
className="w-full glass-btn-primary text-white text-xs font-bold py-3 px-4 rounded-lg flex items-center justify-center space-x-1.5 cursor-pointer disabled:opacity-50"
>
<span>{authLoading ? 'Redefinindo...' : 'Salvar Nova Senha'}</span>
</button>
</form>
) : isForgotPassword ? (
/* FORGOT PASSWORD FORM */
<form onSubmit={handleForgotPassword} className="space-y-4">
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">E-mail ou WhatsApp (com DDI ex: 5511999999999)</label>
<div className="relative">
<Mail className="absolute left-3 top-3 w-4 h-4 text-zinc-500" />
<input
type="text"
required
value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)}
placeholder="email@exemplo.com ou 5511999999999"
className="w-full glass-input rounded-lg p-3 pl-10 text-xs text-white focus:outline-none"
/>
</div>
</div>
<button
type="submit"
disabled={authLoading}
className="w-full glass-btn-primary text-white text-xs font-bold py-3 px-4 rounded-lg flex items-center justify-center space-x-1.5 cursor-pointer disabled:opacity-50"
>
<span>{authLoading ? 'Enviando...' : 'Receber Link de Recuperação'}</span>
</button>
<p className="text-center text-xs text-zinc-500">
Lembrou a senha?{' '}
<button
type="button"
onClick={() => { setIsForgotPassword(false); setAuthError(null); setAuthSuccess(null); }}
className="text-white hover:underline font-bold"
>
Fazer Login
</button>
</p>
</form>
) : isRegister ? (
/* REGISTER FORM */
<form onSubmit={handleRegister} className="space-y-4">
<div className="space-y-1">
@ -394,14 +726,23 @@ export default function App() {
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Crie uma Senha</label>
<div className="relative">
<input
type="password"
type={showRegPassword ? "text" : "password"}
required
value={regPassword}
onChange={(e) => setRegPassword(e.target.value)}
placeholder="Mínimo 6 caracteres"
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none"
className="w-full glass-input rounded-lg p-3 pr-10 text-xs text-white focus:outline-none"
/>
<button
type="button"
onClick={() => setShowRegPassword(!showRegPassword)}
className="absolute right-3 top-2.5 text-zinc-500 hover:text-white transition-colors"
>
{showRegPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<button
@ -428,15 +769,15 @@ export default function App() {
/* LOGIN FORM */
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">E-mail</label>
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">E-mail ou WhatsApp</label>
<div className="relative">
<Mail className="absolute left-3 top-3 w-4 h-4 text-zinc-500" />
<input
type="email"
type="text"
required
value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)}
placeholder="email@exemplo.com"
placeholder="email@exemplo.com ou 5585981145217"
className="w-full glass-input rounded-lg p-3 pl-10 text-xs text-white focus:outline-none"
/>
</div>
@ -447,16 +788,33 @@ export default function App() {
<div className="relative">
<Lock className="absolute left-3 top-3 w-4 h-4 text-zinc-500" />
<input
type="password"
type={showPassword ? "text" : "password"}
required
value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)}
placeholder="Digite sua senha"
className="w-full glass-input rounded-lg p-3 pl-10 text-xs text-white focus:outline-none"
className="w-full glass-input rounded-lg p-3 pl-10 pr-10 text-xs text-white focus:outline-none"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-3 text-zinc-500 hover:text-white transition-colors animate-fade-in"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div className="flex justify-end">
<button
type="button"
onClick={() => { setIsForgotPassword(true); setAuthError(null); setAuthSuccess(null); }}
className="text-[10px] text-zinc-400 hover:text-white transition-colors"
>
Esqueci minha senha
</button>
</div>
<button
type="submit"
disabled={authLoading}
@ -480,21 +838,14 @@ export default function App() {
{/* QUICK PRESETS FOR SANDBOX DEMO */}
<div className="border-t border-white/10 pt-4 space-y-3">
<p className="text-center text-[10px] text-zinc-500 font-bold uppercase tracking-wider">Acesso de Teste Rápido (Iframe Sandbox)</p>
<div className="grid grid-cols-2 gap-2">
<div className="flex justify-center">
<button
type="button"
onClick={() => quickFill('aluno@microtecflix.com', 'aluno123')}
className="glass-btn-secondary text-zinc-300 font-semibold p-2 rounded text-[10px] transition-colors"
className="w-full glass-btn-secondary text-zinc-300 font-semibold p-2.5 rounded text-[10px] transition-colors text-center"
>
Logar como Aluno
</button>
<button
type="button"
onClick={() => quickFill('sidney.gomes1989@gmail.com', 'admin123')}
className="glass-btn-secondary text-zinc-300 font-semibold p-2 rounded text-[10px] transition-colors"
>
Logar como Admin
</button>
</div>
</div>
</form>

View File

@ -2,6 +2,7 @@ import React, { useState } from 'react';
import App from './App.tsx';
import AdminApp from './AdminApp.tsx';
import { ErrorBoundary } from './components/ErrorBoundary.tsx';
import { ModalProvider } from './contexts/ModalContext.tsx';
export default function Root() {
const [simulatedDomain] = useState<'student' | 'admin'>(() => {
@ -22,7 +23,7 @@ export default function Root() {
React.useEffect(() => {
// Dynamically update document title and favicon
document.title = isAdmin ? 'MicrotecFlix - Admin Portal' : 'MicrotecFlix - Plataforma de Estudos';
document.title = isAdmin ? 'Admin Portal' : 'Plataforma de Estudos';
let link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
if (!link) {
@ -35,9 +36,11 @@ export default function Root() {
return (
<ErrorBoundary>
<ModalProvider>
<div className="relative min-h-screen">
{isAdmin ? <AdminApp /> : <App />}
</div>
</ModalProvider>
</ErrorBoundary>
);
}

View File

@ -1,8 +1,11 @@
import React, { useState, useEffect } from 'react';
import {
GraduationCap, Plus, Edit, Trash2, FolderPlus, FilePlus, ChevronRight,
ArrowLeft, Film, Youtube, RefreshCw, Layers, CheckCircle, CheckCircle2
ArrowLeft, Film, Youtube, RefreshCw, Layers, CheckCircle, CheckCircle2,
GripVertical, FileText, Download, X, Save
} from 'lucide-react';
import { useModal } from '../contexts/ModalContext';
import ToggleSwitch from './ToggleSwitch';
interface Lesson {
id: string;
@ -12,6 +15,7 @@ interface Lesson {
videoType: 'youtube' | 'direct';
order: number;
content: string;
thumbnailUrl?: string;
}
interface Module {
@ -30,6 +34,9 @@ interface Course {
category: string;
isLocked?: boolean;
price?: number;
certificateMode?: string;
order?: number;
studyMaterialUrl?: string;
}
interface AdminContentManagerProps {
@ -37,6 +44,7 @@ interface AdminContentManagerProps {
}
export default function AdminContentManager({ token }: AdminContentManagerProps) {
const { alert: showAlert, success: showSuccess, error: showError, confirm: showConfirm } = useModal();
const [courses, setCourses] = useState<Course[]>([]);
const [selectedCourse, setSelectedCourse] = useState<Course | null>(null);
const [modules, setModules] = useState<Module[]>([]);
@ -48,7 +56,7 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
const [showCourseForm, setShowCourseForm] = useState(false);
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
const [courseForm, setCourseForm] = useState({
title: '', description: '', thumbnail: '', category: '', isLocked: false, price: 0, certificateMode: 'FULL_COURSE' as 'FULL_COURSE' | 'PER_MODULE'
title: '', description: '', thumbnail: '', category: '', isLocked: false, price: 0, certificateMode: 'FULL_COURSE' as 'FULL_COURSE' | 'PER_MODULE', order: 0
});
const [showModuleForm, setShowModuleForm] = useState(false);
@ -59,7 +67,7 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
const [editingLesson, setEditingLesson] = useState<Lesson | null>(null);
const [activeModuleForLesson, setActiveModuleForLesson] = useState<string | null>(null);
const [lessonForm, setLessonForm] = useState({
title: '', videoUrl: '', videoType: 'youtube' as 'youtube' | 'direct', order: 1, content: ''
title: '', videoUrl: '', videoType: 'youtube' as 'youtube' | 'direct', order: 1, content: '', thumbnailUrl: ''
});
const [managingActivityForModule, setManagingActivityForModule] = useState<string | null>(null);
@ -68,10 +76,280 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
const [activeModuleForActivity, setActiveModuleForActivity] = useState<string | null>(null);
const [activityQuestions, setActivityQuestions] = useState<{question: string, options: string[], correctIndex: number}[]>([]);
const [uploadingImage, setUploadingImage] = useState(false);
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);
const [draggedLessonInfo, setDraggedLessonInfo] = useState<{ lessonId: string, moduleId: string, index: number } | null>(null);
const handleCourseDragStart = (e: React.DragEvent, index: number) => {
setDraggedCourseIndex(index);
e.dataTransfer.effectAllowed = 'move';
};
const handleCourseDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
const handleCourseDrop = async (e: React.DragEvent, targetIndex: number) => {
e.preventDefault();
if (draggedCourseIndex === null || draggedCourseIndex === targetIndex) return;
const reorderedCourses = [...courses];
const [draggedCourse] = reorderedCourses.splice(draggedCourseIndex, 1);
reorderedCourses.splice(targetIndex, 0, draggedCourse);
setCourses(reorderedCourses);
setDraggedCourseIndex(null);
const itemsToSave = reorderedCourses.map((c, idx) => ({ id: c.id, order: idx + 1 }));
try {
await fetch('/api/admin/reorder', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ type: 'courses', items: itemsToSave })
});
} catch (err) {
console.error('Error reordering courses:', err);
}
};
const handleModuleDragStart = (e: React.DragEvent, index: number) => {
setDraggedModuleIndex(index);
e.dataTransfer.effectAllowed = 'move';
};
const handleModuleDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
const handleModuleDrop = async (e: React.DragEvent, targetIndex: number) => {
e.preventDefault();
if (draggedModuleIndex === null || draggedModuleIndex === targetIndex) return;
const reorderedModules = [...modules];
const [draggedModule] = reorderedModules.splice(draggedModuleIndex, 1);
reorderedModules.splice(targetIndex, 0, draggedModule);
const updatedModules = reorderedModules.map((m, idx) => ({ ...m, order: idx + 1 }));
setModules(updatedModules);
setDraggedModuleIndex(null);
const itemsToSave = updatedModules.map((m) => ({ id: m.id, order: m.order }));
try {
await fetch('/api/admin/reorder', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ type: 'modules', items: itemsToSave })
});
} catch (err) {
console.error('Error reordering modules:', err);
}
};
const handleLessonDragStart = (e: React.DragEvent, lessonId: string, moduleId: string, index: number) => {
setDraggedLessonInfo({ lessonId, moduleId, index });
e.dataTransfer.effectAllowed = 'move';
};
const handleLessonDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
const handleLessonDrop = async (e: React.DragEvent, targetModuleId: string, targetIndex: number) => {
e.preventDefault();
if (!draggedLessonInfo || draggedLessonInfo.moduleId !== targetModuleId || draggedLessonInfo.index === targetIndex) return;
const targetModule = modules.find(m => m.id === targetModuleId);
if (!targetModule) return;
const reorderedLessons = [...targetModule.lessons];
const [draggedLesson] = reorderedLessons.splice(draggedLessonInfo.index, 1);
reorderedLessons.splice(targetIndex, 0, draggedLesson);
const updatedLessons = reorderedLessons.map((l, idx) => ({ ...l, order: idx + 1 }));
const updatedModules = modules.map(m => {
if (m.id === targetModuleId) {
return { ...m, lessons: updatedLessons };
}
return m;
});
setModules(updatedModules);
setDraggedLessonInfo(null);
const itemsToSave = updatedLessons.map((l) => ({ id: l.id, order: l.order }));
try {
await fetch('/api/admin/reorder', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ type: 'lessons', items: itemsToSave })
});
} catch (err) {
console.error('Error reordering lessons:', err);
}
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
setUploadingImage(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) {
setCourseForm(prev => ({ ...prev, thumbnail: data.url }));
} else {
showError(data.error || 'Erro ao fazer upload da imagem');
}
} catch (err) {
console.error(err);
showError('Erro de conexão ao fazer upload');
} finally {
setUploadingImage(false);
}
};
const [uploadingLessonImage, setUploadingLessonImage] = useState(false);
const handleLessonImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
setUploadingLessonImage(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) {
setLessonForm(prev => ({ ...prev, thumbnailUrl: data.url }));
} else {
showError(data.error || 'Erro ao fazer upload da imagem');
}
} catch (err) {
console.error(err);
showError('Erro de conexão ao fazer upload');
} finally {
setUploadingLessonImage(false);
}
};
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 {
@ -131,14 +409,15 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
await fetchCourses();
setShowCourseForm(false);
setEditingCourse(null);
setCourseForm({ title: '', description: '', thumbnail: '', category: '', isLocked: false, price: 0, certificateMode: 'FULL_COURSE' });
setCourseForm({ title: '', description: '', thumbnail: '', category: '', isLocked: false, price: 0, certificateMode: 'FULL_COURSE', order: 0 });
showSuccess(editingCourse ? 'Curso atualizado com sucesso!' : 'Curso criado com sucesso!');
} else {
const errData = await res.json();
alert(`Erro ao salvar curso: ${errData.error || 'Falha na resposta do servidor'}`);
showError(`Erro ao salvar curso: ${errData.error || 'Falha na resposta do servidor'}`);
}
} catch (err) {
console.error('Error saving course:', err);
alert('Erro de rede ao salvar curso.');
showError('Erro de rede ao salvar curso.');
}
};
@ -151,23 +430,25 @@ 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',
order: course.order || 0
});
setEditingCourseMaterialUrl(course.studyMaterialUrl || null);
setShowCourseForm(true);
setTimeout(() => {
document.getElementById('admin-content-top')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 50);
};
const handleDeleteCourse = async (courseId: string) => {
if (!window.confirm('Tem certeza que deseja excluir este curso e todo o seu conteúdo (módulos, aulas, anotações)?')) return;
const handleDeleteCourse = (courseId: string) => {
showConfirm(
'Tem certeza que deseja excluir este curso e todo o seu conteúdo (módulos, aulas, anotações)?',
async () => {
try {
const res = await fetch(`/api/admin/courses/${courseId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
fetchCourses();
if (selectedCourse?.id === courseId) {
@ -178,6 +459,9 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
} catch (err) {
console.error('Error deleting course:', err);
}
},
{ title: 'Excluir Curso', confirmLabel: 'Sim, Excluir', danger: true }
);
};
// --- MODULE CRUD ---
@ -219,22 +503,23 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
}, 50);
};
const handleDeleteModule = async (modId: string) => {
if (!window.confirm('Excluir este módulo apagará todas as suas aulas associadas. Continuar?')) return;
const handleDeleteModule = (modId: string) => {
if (!selectedCourse) return;
showConfirm(
'Excluir este módulo apagará todas as suas aulas associadas. Continuar?',
async () => {
try {
const res = await fetch(`/api/admin/modules/${modId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
fetchCourseModulesAndLessons(selectedCourse.id);
}
if (res.ok) fetchCourseModulesAndLessons(selectedCourse.id);
} catch (err) {
console.error('Error deleting module:', err);
}
},
{ title: 'Excluir Módulo', confirmLabel: 'Sim, Excluir', danger: true }
);
};
// --- LESSON CRUD ---
@ -261,7 +546,7 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
setShowLessonForm(false);
setEditingLesson(null);
setActiveModuleForLesson(null);
setLessonForm({ title: '', videoUrl: '', videoType: 'youtube', order: 1, content: '' });
setLessonForm({ title: '', videoUrl: '', videoType: 'youtube', order: 1, content: '', thumbnailUrl: '' });
}
} catch (err) {
console.error('Error saving lesson:', err);
@ -276,7 +561,8 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
videoUrl: '',
videoType: 'youtube',
order: lessonsCount + 1,
content: ''
content: '',
thumbnailUrl: ''
});
setShowLessonForm(true);
setTimeout(() => {
@ -292,7 +578,8 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
videoUrl: lesson.videoUrl,
videoType: lesson.videoType,
order: lesson.order,
content: lesson.content || ''
content: lesson.content || '',
thumbnailUrl: lesson.thumbnailUrl || ''
});
setShowLessonForm(true);
setTimeout(() => {
@ -331,34 +618,35 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
body: JSON.stringify({ questions: activityQuestions })
});
if (res.ok) {
alert('Atividade salva com sucesso!');
showSuccess('Atividade salva com sucesso!');
setShowActivityModal(false);
} else {
alert('Erro ao salvar atividade.');
showError('Erro ao salvar atividade.');
}
} catch (err) {
console.error('Error saving activity:', err);
alert('Erro ao salvar atividade.');
showError('Erro ao salvar atividade.');
}
};
const handleDeleteLesson = async (lessonId: string) => {
if (!window.confirm('Excluir esta aula?')) return;
const handleDeleteLesson = (lessonId: string) => {
if (!selectedCourse) return;
showConfirm(
'Excluir esta aula? Esta ação não pode ser desfeita.',
async () => {
try {
const res = await fetch(`/api/admin/lessons/${lessonId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
fetchCourseModulesAndLessons(selectedCourse.id);
}
if (res.ok) fetchCourseModulesAndLessons(selectedCourse.id);
} catch (err) {
console.error('Error deleting lesson:', err);
}
},
{ title: 'Excluir Aula', confirmLabel: 'Sim, Excluir', danger: true }
);
};
if (isLoading && courses.length === 0) {
@ -383,7 +671,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', order: courses.length + 1 }); 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" />
@ -407,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">
@ -449,14 +830,26 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
<div className="md:col-span-2 space-y-2">
<label className="text-xs text-zinc-400 font-bold uppercase tracking-wider">Imagem de Capa (Thumbnail URL)</label>
<div className="flex gap-2">
<input
type="url"
required
value={courseForm.thumbnail}
onChange={(e) => setCourseForm({ ...courseForm, thumbnail: e.target.value })}
placeholder="https://images.unsplash.com/... ou link de imagem direta"
className="w-full glass-input rounded-lg p-3 text-sm text-white focus:outline-none"
className="flex-1 glass-input rounded-lg p-3 text-sm text-white focus:outline-none"
/>
<label className="bg-zinc-800 hover:bg-zinc-700 text-white text-xs font-bold py-3 px-4 rounded-lg border border-white/10 transition-all flex items-center justify-center space-x-1.5 cursor-pointer select-none">
{uploadingImage ? 'Enviando...' : 'Upload S3'}
<input
type="file"
accept="image/*"
onChange={handleImageUpload}
disabled={uploadingImage}
className="hidden"
/>
</label>
</div>
</div>
<div className="space-y-2">
@ -483,17 +876,24 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
</select>
</div>
<div className="space-y-2">
<label className="text-xs text-zinc-400 font-bold uppercase tracking-wider">Ordem/Posição do Curso</label>
<input
type="number"
min="0"
value={courseForm.order}
onChange={(e) => setCourseForm({ ...courseForm, order: Number(e.target.value) })}
className="w-full glass-input rounded-lg p-3 text-sm text-white focus:outline-none"
/>
</div>
<div className="md:col-span-2 space-y-2 flex flex-col justify-center">
<label className="text-xs text-zinc-400 font-bold uppercase tracking-wider mb-2">Status de Bloqueio</label>
<label className="inline-flex items-center space-x-3 cursor-pointer">
<input
type="checkbox"
<ToggleSwitch
checked={courseForm.isLocked}
onChange={(e) => setCourseForm({ ...courseForm, isLocked: e.target.checked })}
className="rounded bg-zinc-900 border-white/10 text-[#E50914] focus:ring-[#E50914] focus:ring-offset-0 w-4 h-4"
onChange={(v) => setCourseForm({ ...courseForm, isLocked: v })}
label="Curso Trancado (Requer pagamento ou desbloqueio manual)"
/>
<span className="text-sm font-semibold text-zinc-300">Curso Trancado (Requer pagamento ou desbloqueio manual)</span>
</label>
</div>
<div className="md:col-span-2 space-y-2">
@ -507,6 +907,91 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
/>
</div>
{/* Material de Estudo PDF - só aparece ao editar */}
{editingCourse && (
<div className="md:col-span-2 space-y-3 glass-card rounded-xl p-4 border border-white/5">
<label className="text-xs text-zinc-400 font-bold uppercase tracking-wider flex items-center gap-2">
<FileText className="w-3.5 h-3.5 text-[#E50914]" />
Material de Estudo (PDF)
</label>
{editingCourseMaterialUrl ? (
<div className="flex items-center gap-3 bg-zinc-900/50 rounded-lg p-3 border border-white/5">
<FileText className="w-5 h-5 text-[#E50914] flex-shrink-0" />
<a
href={editingCourseMaterialUrl}
target="_blank"
rel="noreferrer"
className="text-xs text-zinc-300 hover:text-white truncate flex-1 underline underline-offset-2"
>
{editingCourseMaterialUrl.split('/').pop()}
</a>
<button
type="button"
onClick={async () => {
await fetch(`/api/admin/courses/${editingCourse.id}/remove-material`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
setEditingCourseMaterialUrl(null);
showSuccess('Material removido!');
}}
className="text-zinc-500 hover:text-red-400 transition-colors p-1"
title="Remover material"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<p className="text-xs text-zinc-600 italic">Nenhum material de estudo anexado.</p>
)}
<div className="flex items-center gap-3">
<label
className={`flex items-center gap-2 cursor-pointer glass-btn-secondary text-white text-xs font-bold px-4 py-2 rounded-lg transition-all hover:scale-105 ${
uploadingMaterial ? 'opacity-60 pointer-events-none' : ''
}`}
>
<FileText className="w-4 h-4" />
{uploadingMaterial ? 'Enviando PDF...' : 'Upload PDF'}
<input
type="file"
accept=".pdf"
className="hidden"
disabled={uploadingMaterial}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingMaterial(true);
try {
const formData = new FormData();
formData.append('file', file);
const res = await fetch(`/api/admin/courses/${editingCourse.id}/upload-material`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
const data = await res.json();
if (res.ok) {
setEditingCourseMaterialUrl(data.studyMaterialUrl);
showSuccess('Material de estudo enviado com sucesso!');
} else {
showError(data.error || 'Erro ao enviar PDF.');
}
} catch {
showError('Erro de conexão ao enviar PDF.');
} finally {
setUploadingMaterial(false);
}
}}
/>
</label>
<span className="text-[10px] text-zinc-600">Máx. recomendado: 50MB Formato: PDF</span>
</div>
</div>
)}
<div className="md:col-span-2 flex justify-end">
<button
type="submit"
@ -658,6 +1143,29 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
/>
</div>
<div className="md:col-span-3 space-y-2">
<label className="text-xs text-zinc-400 font-bold uppercase">Imagem de Capa da Aula (Thumbnail URL - Opcional)</label>
<div className="flex items-center space-x-2">
<input
type="text"
value={lessonForm.thumbnailUrl}
onChange={(e) => setLessonForm({ ...lessonForm, thumbnailUrl: e.target.value })}
placeholder="Cole a URL ou use o botão para fazer upload para o S3"
className="flex-1 glass-input rounded-lg p-3 text-xs text-white focus:outline-none"
/>
<label className="bg-zinc-800 hover:bg-zinc-700 text-white text-[11px] font-bold py-3.5 px-3 rounded-lg border border-white/10 transition-all flex items-center justify-center space-x-1.5 cursor-pointer select-none">
{uploadingLessonImage ? 'Enviando...' : 'Upload S3'}
<input
type="file"
accept="image/*"
onChange={handleLessonImageUpload}
disabled={uploadingLessonImage}
className="hidden"
/>
</label>
</div>
</div>
<div className="md:col-span-3 space-y-2">
<label className="text-xs text-zinc-400 font-bold uppercase font-sans">Recursos Extras & Material de Apoio (Opcional)</label>
<textarea
@ -693,11 +1201,19 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
</div>
) : (
modules.map((mod, modIdx) => (
<div key={mod.id} className="glass-card rounded-xl overflow-hidden">
<div
key={mod.id}
className="glass-card rounded-xl overflow-hidden"
draggable
onDragStart={(e) => handleModuleDragStart(e, modIdx)}
onDragOver={handleModuleDragOver}
onDrop={(e) => handleModuleDrop(e, modIdx)}
>
{/* Module Header Panel */}
<div className="glass-accordion-header px-4 py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 cursor-grab active:cursor-grabbing">
<GripVertical className="w-4 h-4 text-zinc-500" />
<span className="bg-zinc-800 border border-zinc-700 text-[#E50914] text-[10px] font-extrabold px-1.5 py-0.5 rounded">
M{mod.order}
</span>
@ -751,9 +1267,17 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
{mod.lessons.length === 0 ? (
<div className="p-4 text-xs text-zinc-600 italic text-center">Nenhuma aula cadastrada neste módulo.</div>
) : (
mod.lessons.map(lesson => (
<div key={lesson.id} className="px-6 py-3 flex items-center justify-between text-xs hover:bg-zinc-900/10 transition-colors">
<div className="flex items-center space-x-3 min-w-0">
mod.lessons.map((lesson, lessonIdx) => (
<div
key={lesson.id}
className="px-6 py-3 flex items-center justify-between text-xs hover:bg-zinc-900/10 transition-colors"
draggable
onDragStart={(e) => handleLessonDragStart(e, lesson.id, mod.id, lessonIdx)}
onDragOver={handleLessonDragOver}
onDrop={(e) => handleLessonDrop(e, mod.id, lessonIdx)}
>
<div className="flex items-center space-x-3 min-w-0 cursor-grab active:cursor-grabbing">
<GripVertical className="w-3.5 h-3.5 text-zinc-600 flex-shrink-0" />
<span className="text-zinc-500 font-mono font-bold w-4 text-right">#{lesson.order}</span>
{lesson.videoType === 'youtube' ? (
<Youtube className="w-4 h-4 text-red-500 flex-shrink-0" />
@ -797,18 +1321,25 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
) : (
/* --- LIST ALL COURSES IN CATALOGUE --- */
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{courses.map(course => (
{courses.map((course, courseIdx) => (
<div
key={course.id}
className="glass-card rounded-2xl overflow-hidden flex flex-col justify-between"
draggable
onDragStart={(e) => handleCourseDragStart(e, courseIdx)}
onDragOver={handleCourseDragOver}
onDrop={(e) => handleCourseDrop(e, courseIdx)}
>
<div>
{/* Thumbnail */}
<div className="relative w-full aspect-video">
<div className="relative w-full aspect-video cursor-grab active:cursor-grabbing">
<img src={course.thumbnail} alt={course.title} className="w-full h-full object-cover" referrerPolicy="no-referrer" />
<span className="absolute top-3 left-3 bg-[#E50914]/85 text-white text-[10px] font-black px-2 py-0.5 rounded uppercase tracking-wider glass-badge">
{course.category}
</span>
<span className="absolute top-3 right-3 bg-black/60 text-white p-1 rounded backdrop-blur-sm border border-white/10 opacity-70 hover:opacity-100 transition-opacity">
<GripVertical className="w-3.5 h-3.5" />
</span>
</div>
{/* Info */}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,809 @@
import React, { useState, useEffect, useRef } from 'react';
import { Mail, MessageSquare, BookOpen, Send, User, Reply, CheckCircle2, AlertCircle, Smartphone, Save, Smile, Megaphone, Clock, Paperclip } from 'lucide-react';
import CustomModal from './CustomModal';
interface SupportTicket {
id: string;
content: string;
status: string;
createdAt: string;
user: {
id: string;
name: string;
email: string;
avatarUrl: string | null;
whatsapp: string | null;
};
}
interface Note {
id: string;
content: string;
createdAt: string;
user: {
id: string;
name: string;
email: string;
avatarUrl: string | null;
whatsapp: string | null;
};
lesson: {
title: string;
module: {
course: {
title: string;
}
}
};
}
// Simple emoji picker groups
const EMOJI_LIST = [
'😊','😁','🎉','🙏','💪','🚀','❤️','✅','⭐','🔥','💡','📚','🎓','👏','🤝',
'💰','💳','🛒','📱','📧','🌟','🏆','✨','🎯','📢','👋','😀','🥳','💎','🌈'
];
// Default template texts
const DEFAULT_TEMPLATES: Record<string, string> = {
whatsappTemplatePayment: `Olá, *{nome}*! 🎉
Recebemos a confirmação do seu pagamento com sucesso!
💰 *Valor:* {valor}
📦 *Plano/Curso:* {plano}{curso}
Seu acesso está liberado na plataforma. Qualquer dúvida, estamos à disposição.
Muito obrigado pela sua confiança! 🙏
_Equipe MicrotecFlix_`,
whatsappTemplateWelcome: `Olá, *{nome}*! 👋
Seja muito bem-vindo(a) à *MicrotecFlix*! 🎓🚀
Estamos felizes em ter você conosco. Agora você tem acesso à nossa plataforma de cursos de informática e pacote Office.
Acesse: https://estudo.microtecinformaticacurso.com.br
Se tiver qualquer dúvida, é chamar. 😊
_Equipe MicrotecFlix_`,
whatsappTemplateNewContent: `Oi, *{nome}*! 🎬
Temos uma novidade imperdível para você na plataforma!
*Novo conteúdo disponível:* Acabamos de publicar um novo curso/módulo/aula.
Acesse agora mesmo e aproveite: https://estudo.microtecinformaticacurso.com.br
Bons estudos! 💪
_Equipe MicrotecFlix_`,
whatsappTemplatePromo: `Oi, *{nome}*! 🌟
Temos uma oferta especial para você hoje!
🎯 Aproveite nossa promoção exclusiva e acelere seus estudos de informática e pacote Office.
Não perca essa oportunidade única! Acesse agora:
https://estudo.microtecinformaticacurso.com.br
Qualquer dúvida, estamos aqui. 🙏
_Equipe MicrotecFlix_`
};
export default function AdminMessagesView() {
const [activeTab, setActiveTab] = useState<'tickets' | 'notes' | 'whatsapp_templates'>('tickets');
const [tickets, setTickets] = useState<SupportTicket[]>([]);
const [notes, setNotes] = useState<Note[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [replyText, setReplyText] = useState('');
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [isSending, setIsSending] = useState(false);
const [alertModal, setAlertModal] = useState<{isOpen: boolean, title: string, message: string, isError?: boolean}>({isOpen: false, title: '', message: ''});
// WhatsApp Templates State
const [settings, setSettings] = useState<any>(null);
const [activeTemplateModal, setActiveTemplateModal] = useState<string | null>(null);
const [currentTemplateText, setCurrentTemplateText] = useState('');
const [isSavingTemplate, setIsSavingTemplate] = useState(false);
const [isTestingTemplate, setIsTestingTemplate] = useState<string | null>(null);
const [isSendingMedia, setIsSendingMedia] = useState<string | null>(null);
const [activeMediaTemplate, setActiveMediaTemplate] = useState<string | null>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Broadcast state
const [broadcastText, setBroadcastText] = useState('');
const [broadcastDelay, setBroadcastDelay] = useState(10);
const [isBroadcasting, setIsBroadcasting] = useState(false);
const [showBroadcastEmojiPicker, setShowBroadcastEmojiPicker] = useState(false);
const broadcastRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
setIsLoading(true);
try {
const token = localStorage.getItem('admin_token');
const [msgRes, settingsRes] = await Promise.all([
fetch('/api/admin/messages', { headers: { 'Authorization': `Bearer ${token}` } }),
fetch('/api/admin/settings', { headers: { 'Authorization': `Bearer ${token}` } })
]);
if (msgRes.ok) {
const data = await msgRes.json();
setTickets(data.tickets);
setNotes(data.notes);
}
if (settingsRes.ok) {
const data = await settingsRes.json();
setSettings(data);
// Pre-fill broadcast from template
if (data.whatsappTemplatePromo) {
setBroadcastText(data.whatsappTemplatePromo);
}
}
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setIsLoading(false);
}
};
const handleReply = async (userId: string, ticketId?: string) => {
if (!replyText.trim()) return;
setIsSending(true);
try {
const token = localStorage.getItem('admin_token');
const res = await fetch('/api/admin/reply-message', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ userId, message: replyText, ticketId })
});
if (res.ok) {
setReplyText('');
setReplyingTo(null);
fetchData();
setAlertModal({isOpen: true, title: 'Sucesso', message: 'Resposta enviada com sucesso!'});
} else {
setAlertModal({isOpen: true, title: 'Erro', message: 'Erro ao enviar resposta.', isError: true});
}
} catch (error) {
console.error('Error replying:', error);
setAlertModal({isOpen: true, title: 'Erro', message: 'Erro interno ao enviar resposta.', isError: true});
} finally {
setIsSending(false);
}
};
const handleSaveTemplate = async (field: string) => {
setIsSavingTemplate(true);
try {
const token = localStorage.getItem('admin_token');
const res = await fetch('/api/admin/settings', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: currentTemplateText })
});
if (res.ok) {
setSettings({...settings, [field]: currentTemplateText});
setActiveTemplateModal(null);
setAlertModal({isOpen: true, title: 'Sucesso', message: 'Template salvo com sucesso! O envio automático já está ativado.'});
} else {
setAlertModal({isOpen: true, title: 'Erro', message: 'Erro ao salvar template.', isError: true});
}
} catch (error) {
console.error('Error saving template:', error);
setAlertModal({isOpen: true, title: 'Erro', message: 'Erro interno ao salvar template.', isError: true});
} finally {
setIsSavingTemplate(false);
}
};
const handleTestTemplate = async (templateId: string, e: React.MouseEvent) => {
e.stopPropagation();
setIsTestingTemplate(templateId);
try {
const token = localStorage.getItem('admin_token');
const res = await fetch('/api/admin/whatsapp/test-template', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ templateId })
});
const data = await res.json();
if (res.ok) {
setAlertModal({isOpen: true, title: 'Enviado! 📱', message: data.message});
} else {
setAlertModal({isOpen: true, title: 'Erro no Teste', message: data.error, isError: true});
}
} catch (error) {
console.error('Error testing template:', error);
setAlertModal({isOpen: true, title: 'Erro', message: 'Erro interno ao testar template.', isError: true});
} finally {
setIsTestingTemplate(null);
}
};
const triggerMediaSelect = (templateId: string, e: React.MouseEvent) => {
e.stopPropagation();
setActiveMediaTemplate(templateId);
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const handleMediaSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !activeMediaTemplate) return;
if (fileInputRef.current) fileInputRef.current.value = ''; // Reset input
// In a real app, this should test with admin's profile phone number.
// For now we will rely on the backend checking settings for adminWhatsapp? No, the backend route expects `phone`.
// Let's use the admin's whatsapp saved in settings, or prompt if missing.
setIsSendingMedia(activeMediaTemplate);
try {
// First get admin's phone from their profile or settings. We'll fetch profile since it's the admin.
const token = localStorage.getItem('admin_token');
const profileRes = await fetch('/api/users/profile', {
headers: { 'Authorization': `Bearer ${token}` }
});
const profileData = await profileRes.json();
const adminPhone = profileData.whatsapp;
if (!adminPhone) {
setAlertModal({isOpen: true, title: 'Atenção', message: 'Configure seu número de WhatsApp no Perfil do Admin antes de testar mídias.', isError: true});
setIsSendingMedia(null);
setActiveMediaTemplate(null);
return;
}
const reader = new FileReader();
reader.onloadend = async () => {
const result = reader.result as string;
const mediaBase64 = result.split(',')[1];
const mediaType = file.type;
const res = await fetch('/api/admin/whatsapp/send-media', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
phone: adminPhone,
caption: 'Teste de anexo',
mediaBase64,
mediaType,
fileName: file.name
})
});
const data = await res.json();
if (res.ok) {
setAlertModal({isOpen: true, title: 'Mídia Enviada! 📎📱', message: data.message});
} else {
setAlertModal({isOpen: true, title: 'Erro no Teste', message: data.error, isError: true});
}
setIsSendingMedia(null);
setActiveMediaTemplate(null);
};
reader.readAsDataURL(file);
} catch (error) {
console.error('Error sending media:', error);
setAlertModal({isOpen: true, title: 'Erro', message: 'Erro interno ao processar e enviar mídia.', isError: true});
setIsSendingMedia(null);
setActiveMediaTemplate(null);
}
};
const insertEmoji = (emoji: string, isForBroadcast = false) => {
const ref = isForBroadcast ? broadcastRef : textareaRef;
const setter = isForBroadcast ? setBroadcastText : setCurrentTemplateText;
const current = isForBroadcast ? broadcastText : currentTemplateText;
if (ref.current) {
const start = ref.current.selectionStart;
const end = ref.current.selectionEnd;
const newText = current.substring(0, start) + emoji + current.substring(end);
setter(newText);
setTimeout(() => {
if (ref.current) {
ref.current.focus();
ref.current.setSelectionRange(start + emoji.length, start + emoji.length);
}
}, 0);
} else {
setter(current + emoji);
}
if (isForBroadcast) setShowBroadcastEmojiPicker(false);
else setShowEmojiPicker(false);
};
const handleBroadcast = async () => {
if (!broadcastText.trim()) return;
setIsBroadcasting(true);
try {
const token = localStorage.getItem('admin_token');
const res = await fetch('/api/admin/whatsapp/broadcast', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ message: broadcastText, delaySeconds: broadcastDelay })
});
const data = await res.json();
if (res.ok) {
setAlertModal({isOpen: true, title: 'Envio Iniciado! 🚀', message: data.message || 'Broadcast iniciado com sucesso!'});
} else {
setAlertModal({isOpen: true, title: 'Erro', message: data.error || 'Erro ao iniciar broadcast.', isError: true});
}
} catch (error) {
console.error('Error broadcasting:', error);
setAlertModal({isOpen: true, title: 'Erro', message: 'Erro interno ao enviar.', isError: true});
} finally {
setIsBroadcasting(false);
}
};
const whatsappTemplates = [
{ id: 'whatsappTemplatePayment', title: 'Pagamento Confirmado', desc: 'Enviada automaticamente quando o aluno paga um plano ou curso.', icon: '💰', tags: ['{nome}', '{valor}', '{plano}', '{curso}'] },
{ id: 'whatsappTemplateWelcome', title: 'Boas-Vindas (Nova Conta)', desc: 'Enviada automaticamente quando o aluno cria uma conta.', icon: '👋', tags: ['{nome}', '{curso}', '{plano}', '{valor}'] },
{ id: 'whatsappTemplateNewContent', title: 'Novo Conteúdo (Carrossel)', desc: 'Enviada manualmente ao postar curso/módulo/aula.', icon: '🎬', tags: ['{nome}', '{curso}', '{modulo}', '{plano}', '{valor}'] },
];
return (
<div className="p-8">
<input
type="file"
ref={fileInputRef}
onChange={handleMediaSelect}
accept="image/*,video/*,application/pdf"
className="hidden"
/>
<div className="space-y-6">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h2 className="text-2xl font-bold text-white flex items-center space-x-2">
<Mail className="w-6 h-6 text-[#E50914]" />
<span>Mensagens e Comunicações</span>
</h2>
<p className="text-zinc-400 text-sm mt-1">Gerencie dúvidas, anotações e configure as mensagens automáticas do WhatsApp.</p>
</div>
</div>
<div className="flex space-x-4 border-b border-white/10 overflow-x-auto">
<button
onClick={() => setActiveTab('tickets')}
className={`pb-3 px-1 text-sm font-bold flex items-center space-x-2 border-b-2 transition-colors whitespace-nowrap ${activeTab === 'tickets' ? 'border-[#E50914] text-white' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`}
>
<MessageSquare className="w-4 h-4" />
<span>Comentários / Ajuda ({tickets.length})</span>
</button>
<button
onClick={() => setActiveTab('notes')}
className={`pb-3 px-1 text-sm font-bold flex items-center space-x-2 border-b-2 transition-colors whitespace-nowrap ${activeTab === 'notes' ? 'border-[#E50914] text-white' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`}
>
<BookOpen className="w-4 h-4" />
<span>Anotações de Aulas ({notes.length})</span>
</button>
<button
onClick={() => setActiveTab('whatsapp_templates')}
className={`pb-3 px-1 text-sm font-bold flex items-center space-x-2 border-b-2 transition-colors whitespace-nowrap ${activeTab === 'whatsapp_templates' ? 'border-[#E50914] text-white' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`}
>
<Smartphone className="w-4 h-4" />
<span>Modelos WhatsApp</span>
</button>
</div>
{isLoading ? (
<div className="flex justify-center items-center py-20">
<div className="w-8 h-8 border-4 border-[#E50914] border-t-transparent rounded-full animate-spin"></div>
</div>
) : (
<div className="space-y-4">
{activeTab === 'tickets' && (
tickets.length === 0 ? (
<div className="text-center py-12 text-zinc-500">Nenhum comentário recebido.</div>
) : (
tickets.map(ticket => (
<div key={ticket.id} className="bg-zinc-900 border border-white/10 rounded-xl p-5 space-y-4 relative">
{ticket.status === 'REPLIED' && (
<div className="absolute top-4 right-4 text-[10px] uppercase font-bold text-emerald-500 bg-emerald-500/10 px-2 py-1 rounded">
Respondido
</div>
)}
<div className="flex items-start space-x-4">
<div className="w-10 h-10 rounded-full bg-zinc-800 flex items-center justify-center flex-shrink-0 overflow-hidden">
{ticket.user.avatarUrl ? (
<img src={ticket.user.avatarUrl} alt={ticket.user.name} className="w-full h-full object-cover" />
) : (
<User className="w-5 h-5 text-zinc-400" />
)}
</div>
<div>
<h4 className="font-bold text-white text-sm">{ticket.user.name}</h4>
<p className="text-xs text-zinc-500">{ticket.user.email} {ticket.user.whatsapp && `• WA: ${ticket.user.whatsapp}`}</p>
<p className="text-[10px] text-zinc-600 mt-0.5">{new Date(ticket.createdAt).toLocaleString('pt-BR')}</p>
</div>
</div>
<div className="bg-black/20 p-4 rounded-lg text-zinc-300 text-sm whitespace-pre-wrap">
{ticket.content}
</div>
{replyingTo === ticket.id ? (
<div className="mt-4 space-y-3">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Escreva sua resposta (será enviada via Notificação e WhatsApp se ativado)..."
className="w-full h-24 bg-black/40 border border-white/10 rounded-lg p-3 text-white text-sm focus:outline-none focus:border-[#E50914] resize-none"
/>
<div className="flex justify-end space-x-2">
<button onClick={() => { setReplyingTo(null); setReplyText(''); }} className="px-3 py-1.5 text-xs text-zinc-400 hover:text-white transition-colors">Cancelar</button>
<button
onClick={() => handleReply(ticket.user.id, ticket.id)}
disabled={!replyText.trim() || isSending}
className="bg-[#E50914] hover:bg-red-700 text-white px-4 py-1.5 rounded text-xs font-bold transition-colors disabled:opacity-50 flex items-center space-x-2"
>
{isSending ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <><Send className="w-3 h-3" /><span>Enviar Resposta</span></>}
</button>
</div>
</div>
) : (
<button onClick={() => { setReplyingTo(ticket.id); setReplyText(''); }} className="text-xs text-[#E50914] hover:text-red-400 transition-colors font-bold flex items-center space-x-1">
<Reply className="w-3 h-3" />
<span>Responder</span>
</button>
)}
</div>
))
)
)}
{activeTab === 'notes' && (
notes.length === 0 ? (
<div className="text-center py-12 text-zinc-500">Nenhuma anotação registrada.</div>
) : (
notes.map(note => (
<div key={note.id} className="bg-zinc-900 border border-white/10 rounded-xl p-5 space-y-4">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-4">
<div className="w-10 h-10 rounded-full bg-zinc-800 flex items-center justify-center flex-shrink-0 overflow-hidden">
{note.user.avatarUrl ? (
<img src={note.user.avatarUrl} alt={note.user.name} className="w-full h-full object-cover" />
) : (
<User className="w-5 h-5 text-zinc-400" />
)}
</div>
<div>
<h4 className="font-bold text-white text-sm">{note.user.name}</h4>
<p className="text-xs text-zinc-500">{note.user.email}</p>
<p className="text-[10px] text-zinc-600 mt-0.5">{new Date(note.createdAt).toLocaleString('pt-BR')}</p>
</div>
</div>
<div className="text-right max-w-[200px]">
<span className="text-[10px] uppercase font-bold text-blue-500 bg-blue-500/10 px-2 py-1 rounded block truncate">
{note.lesson.module.course.title}
</span>
<span className="text-xs text-zinc-400 block mt-1 truncate">{note.lesson.title}</span>
</div>
</div>
<div className="bg-black/20 p-4 rounded-lg text-zinc-300 text-sm whitespace-pre-wrap">
{note.content}
</div>
{replyingTo === note.id ? (
<div className="mt-4 space-y-3">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Escreva sua resposta para esta anotação..."
className="w-full h-24 bg-black/40 border border-white/10 rounded-lg p-3 text-white text-sm focus:outline-none focus:border-[#E50914] resize-none"
/>
<div className="flex justify-end space-x-2">
<button onClick={() => { setReplyingTo(null); setReplyText(''); }} className="px-3 py-1.5 text-xs text-zinc-400 hover:text-white transition-colors">Cancelar</button>
<button
onClick={() => handleReply(note.user.id)}
disabled={!replyText.trim() || isSending}
className="bg-[#E50914] hover:bg-red-700 text-white px-4 py-1.5 rounded text-xs font-bold transition-colors disabled:opacity-50 flex items-center space-x-2"
>
{isSending ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <><Send className="w-3 h-3" /><span>Enviar Resposta</span></>}
</button>
</div>
</div>
) : (
<button onClick={() => { setReplyingTo(note.id); setReplyText(''); }} className="text-xs text-[#E50914] hover:text-red-400 transition-colors font-bold flex items-center space-x-1">
<Reply className="w-3 h-3" />
<span>Responder</span>
</button>
)}
</div>
))
)
)}
{activeTab === 'whatsapp_templates' && (
<div className="space-y-6 mt-4">
{/* Automatic Templates */}
<div>
<h3 className="text-sm font-bold text-zinc-400 uppercase tracking-wider mb-3 flex items-center gap-2">
<Smartphone className="w-4 h-4 text-green-500" />
Mensagens Automáticas
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{whatsappTemplates.map(t => (
<div
key={t.id}
className="bg-zinc-900 border border-white/10 rounded-xl p-5 hover:border-white/25 transition-all cursor-pointer group"
onClick={() => {
setCurrentTemplateText(settings?.[t.id] || DEFAULT_TEMPLATES[t.id] || '');
setActiveTemplateModal(t.id);
}}
>
<div className="flex items-start gap-3 mb-3">
<span className="text-2xl">{t.icon}</span>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-bold text-white group-hover:text-[#E50914] transition-colors">{t.title}</h3>
<p className="text-[11px] text-zinc-500 leading-snug mt-0.5">{t.desc}</p>
</div>
</div>
<div className="bg-black/30 p-2.5 rounded-lg text-[11px] text-zinc-400 line-clamp-3 min-h-[50px] whitespace-pre-wrap mb-2">
{settings?.[t.id] || DEFAULT_TEMPLATES[t.id] || 'Clique para configurar...'}
</div>
<div className="flex flex-wrap gap-1">
{t.tags.map(tag => (
<span key={tag} className="text-[9px] bg-blue-500/10 text-blue-300 px-1.5 py-0.5 rounded font-mono">{tag}</span>
))}
</div>
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${settings?.[t.id] ? 'bg-green-500' : 'bg-zinc-600'}`}></div>
<span className={`text-[10px] font-bold ${settings?.[t.id] ? 'text-green-400' : 'text-zinc-500'}`}>
{settings?.[t.id] ? 'Ativo' : 'Não configurado'}
</span>
</div>
{settings?.[t.id] && (
<div className="flex gap-2">
<button
onClick={(e) => triggerMediaSelect(t.id, e)}
disabled={isSendingMedia === t.id}
className="bg-zinc-800 hover:bg-zinc-700 text-amber-400 hover:text-amber-300 px-2 py-1 rounded text-[10px] font-bold flex items-center gap-1 transition-colors"
title="Testar envio de mídia (Imagem, Vídeo, PDF)"
>
{isSendingMedia === t.id ? (
<div className="w-3 h-3 border-2 border-amber-400/30 border-t-amber-400 rounded-full animate-spin" />
) : (
<Paperclip className="w-3 h-3" />
)}
</button>
<button
onClick={(e) => handleTestTemplate(t.id, e)}
disabled={isTestingTemplate === t.id}
className="bg-zinc-800 hover:bg-zinc-700 text-zinc-300 px-3 py-1 rounded text-[10px] font-bold flex items-center gap-1 transition-colors"
>
{isTestingTemplate === t.id ? (
<div className="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
<Send className="w-3 h-3" />
<span>Testar Envio</span>
</>
)}
</button>
</div>
)}
</div>
</div>
))}
</div>
</div>
{/* Mass Broadcast */}
<div className="bg-zinc-900 border border-amber-500/20 rounded-xl p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-amber-500/10 rounded-full flex items-center justify-center text-xl">🚀</div>
<div>
<h3 className="text-lg font-bold text-white flex items-center gap-2">
<Megaphone className="w-5 h-5 text-amber-400" />
Promoção em Massa
</h3>
<p className="text-xs text-zinc-500">Envia para todos os alunos que têm WhatsApp cadastrado.</p>
</div>
</div>
<div className="bg-amber-500/5 border border-amber-500/15 p-3 rounded-lg text-xs text-amber-200 mb-4 space-y-1">
<p><strong> Anti-Spam:</strong> Configure o intervalo de envio para evitar bloqueio pelo WhatsApp.</p>
<p><strong>Tag dinâmica:</strong> Use <code className="bg-black/40 px-1 rounded">{'{nome}'}</code> para personalizar com o primeiro nome do aluno.</p>
</div>
<div className="space-y-3">
<div className="relative">
<textarea
ref={broadcastRef}
value={broadcastText}
onChange={(e) => setBroadcastText(e.target.value)}
className="w-full h-40 bg-black/40 border border-white/10 rounded-lg p-3 text-white text-sm focus:outline-none focus:border-amber-500/50 resize-none"
placeholder="Escreva a mensagem de promoção aqui..."
/>
</div>
{/* Tags e emoji para broadcast */}
<div className="flex flex-wrap gap-2 items-center">
<span className="text-[11px] text-zinc-500">Tags:</span>
{['{nome}', '{curso}', '{modulo}', '{plano}', '{valor}'].map(tag => (
<code
key={tag}
className="bg-zinc-800 hover:bg-zinc-700 text-amber-300 px-1.5 py-0.5 rounded font-mono cursor-pointer border border-zinc-600 hover:border-amber-400 transition-colors text-[10px]"
title="Clique para inserir"
onClick={() => {
if (broadcastRef.current) {
const start = broadcastRef.current.selectionStart;
const end = broadcastRef.current.selectionEnd;
const newText = broadcastText.substring(0, start) + tag + broadcastText.substring(end);
setBroadcastText(newText);
setTimeout(() => {
broadcastRef.current?.focus();
broadcastRef.current?.setSelectionRange(start + tag.length, start + tag.length);
}, 0);
} else {
setBroadcastText(prev => prev + tag);
}
}}
>{tag}</code>
))}
<div className="relative ml-auto">
<button
onClick={() => setShowBroadcastEmojiPicker(!showBroadcastEmojiPicker)}
className="flex items-center gap-1.5 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 hover:border-zinc-500 text-zinc-300 hover:text-white px-2.5 py-1 rounded-lg text-[11px] font-bold transition-all"
>
<Smile className="w-3.5 h-3.5" />
<span>Emoji</span>
</button>
{showBroadcastEmojiPicker && (
<div className="absolute top-8 right-0 bg-zinc-900 border border-zinc-700 rounded-xl p-3 grid grid-cols-8 gap-1.5 z-50 shadow-2xl">
{EMOJI_LIST.map(emoji => (
<button key={emoji} onClick={() => insertEmoji(emoji, true)} className="text-xl hover:scale-125 transition-transform p-1 rounded hover:bg-white/10">
{emoji}
</button>
))}
</div>
)}
</div>
</div>
<div className="flex items-center gap-3">
<Clock className="w-4 h-4 text-amber-400 flex-shrink-0" />
<label className="text-xs text-zinc-300 flex-shrink-0">Intervalo entre mensagens:</label>
<input
type="number"
value={broadcastDelay}
onChange={e => setBroadcastDelay(Math.max(5, Math.min(120, Number(e.target.value))))}
min={5}
max={120}
className="w-20 bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-amber-500/50"
/>
<span className="text-xs text-zinc-500">segundos (mín: 5s / máx: 120s)</span>
</div>
<div className="flex justify-end">
<button
onClick={handleBroadcast}
disabled={!broadcastText.trim() || isBroadcasting}
className="bg-amber-600 hover:bg-amber-500 text-white px-6 py-2.5 rounded-lg font-bold transition-colors disabled:opacity-50 flex items-center space-x-2"
>
{isBroadcasting
? <><div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /><span>Iniciando...</span></>
: <><Megaphone className="w-4 h-4" /><span>Enviar para Todos</span></>
}
</button>
</div>
</div>
</div>
</div>
)}
</div>
)}
<CustomModal isOpen={!!activeTemplateModal} onClose={() => { setActiveTemplateModal(null); setShowEmojiPicker(false); }} title={whatsappTemplates.find(t => t.id === activeTemplateModal)?.title || 'Editar Template'} isGlass={true}>
<div className="space-y-4">
<div className="bg-blue-500/10 border border-blue-500/20 p-3 rounded-lg text-xs text-blue-200 space-y-2">
<p><strong>Formatação do WhatsApp:</strong> Use <code className="bg-black/40 px-1 rounded">*negrito*</code>, <code className="bg-black/40 px-1 rounded">_itálico_</code>, <code className="bg-black/40 px-1 rounded">~riscado~</code>.</p>
<div className="flex flex-wrap gap-1 items-center">
<strong>Tags:</strong>
{['{nome}', '{curso}', '{modulo}', '{plano}', '{valor}'].map(tag => (
<code
key={tag}
className="bg-zinc-800 hover:bg-zinc-700 text-blue-300 px-1.5 py-0.5 rounded font-mono cursor-pointer border border-zinc-600 hover:border-blue-400 transition-colors"
title="Clique para inserir"
onClick={() => {
if (textareaRef.current) {
const start = textareaRef.current.selectionStart;
const end = textareaRef.current.selectionEnd;
const newText = currentTemplateText.substring(0, start) + tag + currentTemplateText.substring(end);
setCurrentTemplateText(newText);
setTimeout(() => {
textareaRef.current?.focus();
textareaRef.current?.setSelectionRange(start + tag.length, start + tag.length);
}, 0);
} else {
setCurrentTemplateText(prev => prev + tag);
}
}}
>{tag}</code>
))}
</div>
</div>
<div className="relative">
<textarea
ref={textareaRef}
value={currentTemplateText}
onChange={(e) => setCurrentTemplateText(e.target.value)}
className="w-full h-52 bg-black/40 border border-white/10 rounded-lg p-3 text-white text-sm focus:outline-none focus:border-[#E50914] resize-none"
placeholder="Escreva a mensagem aqui..."
/>
</div>
{/* Emoji picker row - outside textarea, fully visible */}
<div className="relative">
<button
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
className="flex items-center gap-2 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 hover:border-zinc-500 text-zinc-300 hover:text-white px-3 py-1.5 rounded-lg text-xs font-bold transition-all"
title="Inserir emoji"
>
<Smile className="w-4 h-4" />
<span>Inserir Emoji</span>
</button>
{showEmojiPicker && (
<div className="absolute top-10 left-0 bg-zinc-900 border border-zinc-700 rounded-xl p-3 grid grid-cols-8 gap-1.5 z-50 shadow-2xl">
{EMOJI_LIST.map(emoji => (
<button key={emoji} onClick={() => insertEmoji(emoji, false)} className="text-xl hover:scale-125 transition-transform p-1 rounded hover:bg-white/10">
{emoji}
</button>
))}
</div>
)}
</div>
<div className="flex justify-end space-x-2">
<button onClick={() => { setActiveTemplateModal(null); setShowEmojiPicker(false); }} className="px-4 py-2 text-zinc-400 hover:text-white transition-colors text-sm">
Cancelar
</button>
<button
onClick={() => handleSaveTemplate(activeTemplateModal!)}
disabled={isSavingTemplate}
className="bg-[#E50914] hover:bg-red-700 text-white px-6 py-2 rounded-lg font-bold transition-colors disabled:opacity-50 flex items-center space-x-2"
>
{isSavingTemplate ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <><Save className="w-4 h-4" /><span>Salvar Template</span></>}
</button>
</div>
</div>
</CustomModal>
{/* Alert Modal */}
<CustomModal isOpen={alertModal.isOpen} onClose={() => setAlertModal({...alertModal, isOpen: false})} title={alertModal.title} isGlass={true}>
<div className="flex items-start gap-4">
{alertModal.isError ? (
<div className="bg-red-500/10 p-3 rounded-full shrink-0"><AlertCircle className="w-6 h-6 text-red-500" /></div>
) : (
<div className="bg-green-500/10 p-3 rounded-full shrink-0"><CheckCircle2 className="w-6 h-6 text-green-500" /></div>
)}
<div className="flex-1">
<p className="text-zinc-300 text-base">{alertModal.message}</p>
<div className="mt-6 flex justify-end">
<button onClick={() => setAlertModal({...alertModal, isOpen: false})} className="px-6 py-2 bg-zinc-800 hover:bg-zinc-700 text-white rounded-lg font-bold transition-colors">
OK
</button>
</div>
</div>
</div>
</CustomModal>
</div>
</div>
);
}

View File

@ -1,13 +1,21 @@
import React, { useState, useEffect } from 'react';
import { RefreshCw, Save, Plus, Trash2, Edit, CheckCircle2, AlertTriangle, BookOpen, Layers, DollarSign } from 'lucide-react';
import { Course, Plan } from '../types';
import { useModal } from '../contexts/ModalContext';
import ToggleSwitch from './ToggleSwitch';
interface AdminPricingManagerProps {
courses: Course[];
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);
@ -74,6 +82,7 @@ function CoursePricingCard({ course, saveLoading, onSave }: { course: Course, sa
}
export const AdminPricingManager: React.FC<AdminPricingManagerProps> = ({ courses, fetchCourses }) => {
const { confirm: showConfirm, success: showSuccess, error: showError } = useModal();
const [plans, setPlans] = useState<Plan[]>([]);
const [loading, setLoading] = useState(true);
const [saveLoading, setSaveLoading] = useState<string | null>(null);
@ -82,7 +91,7 @@ export const AdminPricingManager: React.FC<AdminPricingManagerProps> = ({ course
const [showPlanModal, setShowPlanModal] = useState(false);
const [planForm, setPlanForm] = useState<Partial<Plan>>({ cycle: 'LIFETIME', active: true, includedCourses: [] });
const token = localStorage.getItem('devflix_admin_token');
const token = localStorage.getItem('admin_token');
const fetchPlans = async () => {
setLoading(true);
@ -149,9 +158,10 @@ export const AdminPricingManager: React.FC<AdminPricingManagerProps> = ({ course
}
};
const handleDeletePlan = async (planId: string) => {
if (!window.confirm('Tem certeza que deseja excluir este plano?')) return;
const handleDeletePlan = (planId: string) => {
showConfirm(
'Tem certeza que deseja excluir este plano? Alunos vinculados não serão afetados imediatamente.',
async () => {
try {
await fetch(`/api/admin/plans/${planId}`, {
method: 'DELETE',
@ -161,6 +171,9 @@ export const AdminPricingManager: React.FC<AdminPricingManagerProps> = ({ course
} catch (err) {
console.error(err);
}
},
{ title: 'Excluir Plano', confirmLabel: 'Sim, Excluir', danger: true }
);
};
const toggleCourseInPlan = (courseId: string) => {
@ -193,12 +206,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 => (
<div key={course.id}>
<CoursePricingCard
key={course.id}
course={course}
saveLoading={saveLoading}
onSave={handleUpdateCoursePrice}
/>
</div>
))}
</div>
</div>
@ -361,18 +375,13 @@ export const AdminPricingManager: React.FC<AdminPricingManagerProps> = ({ course
</div>
</div>
<label className="flex items-center gap-3 cursor-pointer p-3 bg-black/20 rounded-lg border border-white/5 hover:bg-black/40 transition-colors">
<input
type="checkbox"
<div className="flex flex-col justify-center">
<ToggleSwitch
checked={planForm.active}
onChange={e => setPlanForm({...planForm, active: e.target.checked})}
className="w-5 h-5 rounded border-zinc-600 text-purple-600 focus:ring-purple-600 focus:ring-offset-zinc-900 bg-zinc-800"
onChange={v => setPlanForm({...planForm, active: v})}
label="Plano Ativo (Visível para os alunos comprarem)"
/>
<div>
<span className="text-sm font-bold text-white block">Plano Ativo (Visível)</span>
<span className="text-xs text-zinc-500">Alunos poderão comprar este plano.</span>
</div>
</label>
</div>
{/* Course Selection */}
@ -387,41 +396,34 @@ export const AdminPricingManager: React.FC<AdminPricingManagerProps> = ({ course
</div>
<div className="p-4 overflow-y-auto space-y-2 flex-grow custom-scrollbar">
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${
<div className={`p-3 rounded-lg border transition-all ${
planForm.includedCourses?.length === 0
? 'bg-purple-900/20 border-purple-500/50'
: 'bg-zinc-900/50 border-white/5 hover:border-white/20'
: 'bg-zinc-900/50 border-white/5'
}`}>
<input
type="checkbox"
<ToggleSwitch
checked={planForm.includedCourses?.length === 0}
onChange={() => setPlanForm({...planForm, includedCourses: []})}
className="w-5 h-5 rounded-full border-zinc-600 text-purple-600 focus:ring-purple-600 bg-zinc-800"
onChange={(v) => { if(v) setPlanForm({...planForm, includedCourses: []}) }}
label="Acesso Global (All-Access) - Libera todos os cursos, presentes e futuros."
/>
<div>
<span className="text-sm font-bold text-white block">Acesso Global (All-Access)</span>
<span className="text-xs text-zinc-400">Libera todos os cursos, presentes e futuros.</span>
</div>
</label>
<div className="my-4 border-t border-white/10 relative">
<span className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-black px-2 text-xs font-bold text-zinc-600">OU ESCOLHA ESPECÍFICOS</span>
</div>
{courses.map(course => (
<label key={course.id} className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${
<div key={course.id} className={`p-3 rounded-lg border transition-all ${
planForm.includedCourses?.includes(course.id)
? 'bg-purple-900/20 border-purple-500/50'
: 'bg-zinc-900/50 border-white/5 hover:border-white/20'
: 'bg-zinc-900/50 border-white/5'
}`}>
<input
type="checkbox"
checked={planForm.includedCourses?.includes(course.id)}
<ToggleSwitch
checked={planForm.includedCourses?.includes(course.id) || false}
onChange={() => toggleCourseInPlan(course.id)}
className="w-5 h-5 rounded border-zinc-600 text-purple-600 focus:ring-purple-600 bg-zinc-800"
label={course.title}
/>
<span className="text-sm font-medium text-zinc-300 truncate">{course.title}</span>
</label>
</div>
))}
</div>
</div>

View File

@ -0,0 +1,288 @@
import React, { useState, useEffect } from 'react';
import { X, Save, User, Phone, MapPin, Calendar, CreditCard, Building2, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
interface AdminProfileModalProps {
isOpen: boolean;
onClose: () => void;
token: string | null;
onUpdated?: (data: any) => void;
}
export default function AdminProfileModal({ isOpen, onClose, token, onUpdated }: AdminProfileModalProps) {
const [form, setForm] = useState({
name: '',
whatsapp: '',
birthDate: '',
cpf: '',
cep: '',
address: '',
number: '',
complement: '',
neighborhood: '',
city: '',
state: '',
});
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [status, setStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null);
useEffect(() => {
if (isOpen && token) {
fetchProfile();
}
}, [isOpen]);
const fetchProfile = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/auth/me', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setForm({
name: data.name || '',
whatsapp: data.whatsapp || '',
birthDate: data.birthDate ? data.birthDate.substring(0, 10) : '',
cpf: data.cpf || '',
cep: data.cep || '',
address: data.address || '',
number: data.number || '',
complement: data.complement || '',
neighborhood: data.neighborhood || '',
city: data.city || '',
state: data.state || '',
});
}
} catch (e) {
console.error('Erro ao carregar perfil:', e);
} finally {
setIsLoading(false);
}
};
const handleSave = async () => {
setIsSaving(true);
setStatus(null);
try {
const res = await fetch('/api/users/profile', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(form)
});
const data = await res.json();
if (res.ok) {
setStatus({ type: 'success', msg: 'Dados salvos com sucesso! O seu WhatsApp já será usado para os testes de envio.' });
if (onUpdated) onUpdated(data.user);
} else {
setStatus({ type: 'error', msg: data.error || 'Erro ao salvar dados.' });
}
} catch (e) {
setStatus({ type: 'error', msg: 'Erro interno ao salvar.' });
} finally {
setIsSaving(false);
}
};
const set = (field: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
setForm(f => ({ ...f, [field]: e.target.value }));
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-fade-in">
<div className="relative w-full max-w-2xl bg-zinc-900 border border-white/10 rounded-2xl shadow-2xl flex flex-col max-h-[90vh]">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-[#E50914] flex items-center justify-center">
<User className="w-5 h-5 text-white" />
</div>
<div>
<h2 className="text-base font-bold text-white">Meu Perfil Administrador</h2>
<p className="text-[10px] text-zinc-500">Esses dados são usados para testes de envio de WhatsApp</p>
</div>
</div>
<button onClick={onClose} className="w-8 h-8 flex items-center justify-center rounded-full text-zinc-400 hover:text-white hover:bg-white/10 transition-colors">
<X className="w-4 h-4" />
</button>
</div>
{/* Body */}
<div className="overflow-y-auto p-6 space-y-6 flex-1">
{isLoading ? (
<div className="flex justify-center items-center py-12">
<Loader2 className="w-8 h-8 text-[#E50914] animate-spin" />
</div>
) : (
<>
{/* Dados Pessoais */}
<div>
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider mb-3 flex items-center gap-2">
<User className="w-3.5 h-3.5" />
Dados Pessoais
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-[11px] text-zinc-400 mb-1 font-medium">Nome Completo</label>
<input
value={form.name}
onChange={set('name')}
className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#E50914]/50"
placeholder="Seu nome completo"
/>
</div>
<div>
<label className="block text-[11px] text-zinc-400 mb-1 font-medium flex items-center gap-1">
<CreditCard className="w-3 h-3" /> CPF
</label>
<input
value={form.cpf}
onChange={set('cpf')}
className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#E50914]/50"
placeholder="000.000.000-00"
/>
</div>
<div>
<label className="block text-[11px] text-zinc-400 mb-1 font-medium flex items-center gap-1">
<Calendar className="w-3 h-3" /> Data de Nascimento
</label>
<input
type="date"
value={form.birthDate}
onChange={set('birthDate')}
className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#E50914]/50"
/>
</div>
</div>
</div>
{/* Contato */}
<div>
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider mb-3 flex items-center gap-2">
<Phone className="w-3.5 h-3.5" />
Contato
</p>
<div>
<label className="block text-[11px] text-zinc-400 mb-1 font-medium">
WhatsApp <span className="text-amber-400 font-bold">( Obrigatório para Testar Envios)</span>
</label>
<input
value={form.whatsapp}
onChange={set('whatsapp')}
className="w-full bg-black/40 border border-amber-500/30 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-amber-500/60"
placeholder="(85) 99999-9999 — sem código do país"
/>
<p className="text-[10px] text-zinc-500 mt-1">Quando clicar em "Testar Envio" em qualquer modelo, a mensagem chegará neste número.</p>
</div>
</div>
{/* Endereço */}
<div>
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider mb-3 flex items-center gap-2">
<MapPin className="w-3.5 h-3.5" />
Endereço
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[11px] text-zinc-400 mb-1 font-medium">CEP</label>
<input
value={form.cep}
onChange={set('cep')}
className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#E50914]/50"
placeholder="00000-000"
/>
</div>
<div className="md:col-span-2">
<label className="block text-[11px] text-zinc-400 mb-1 font-medium">Logradouro (Rua, Av...)</label>
<input
value={form.address}
onChange={set('address')}
className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#E50914]/50"
placeholder="Rua das Flores"
/>
</div>
<div>
<label className="block text-[11px] text-zinc-400 mb-1 font-medium">Número</label>
<input
value={form.number}
onChange={set('number')}
className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#E50914]/50"
placeholder="123"
/>
</div>
<div>
<label className="block text-[11px] text-zinc-400 mb-1 font-medium">Complemento</label>
<input
value={form.complement}
onChange={set('complement')}
className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#E50914]/50"
placeholder="Apto 12, Bloco B..."
/>
</div>
<div>
<label className="block text-[11px] text-zinc-400 mb-1 font-medium">Bairro</label>
<input
value={form.neighborhood}
onChange={set('neighborhood')}
className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#E50914]/50"
placeholder="Centro"
/>
</div>
<div className="md:col-span-2">
<label className="block text-[11px] text-zinc-400 mb-1 font-medium">Cidade</label>
<input
value={form.city}
onChange={set('city')}
className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#E50914]/50"
placeholder="Fortaleza"
/>
</div>
<div>
<label className="block text-[11px] text-zinc-400 mb-1 font-medium">Estado (UF)</label>
<select
value={form.state}
onChange={set('state')}
className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-[#E50914]/50"
>
<option value="">Selecione</option>
{['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG','PA','PB','PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO'].map(uf => (
<option key={uf} value={uf}>{uf}</option>
))}
</select>
</div>
</div>
</div>
{/* Status feedback */}
{status && (
<div className={`flex items-start gap-3 p-3 rounded-lg border ${status.type === 'success' ? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-300' : 'bg-red-500/10 border-red-500/30 text-red-300'}`}>
{status.type === 'success' ? <CheckCircle2 className="w-5 h-5 flex-shrink-0 mt-0.5" /> : <AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5" />}
<p className="text-sm">{status.msg}</p>
</div>
)}
</>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-3 px-6 py-4 border-t border-white/10 flex-shrink-0 bg-zinc-900 rounded-b-2xl">
<button onClick={onClose} className="px-4 py-2 text-sm text-zinc-400 hover:text-white transition-colors">
Fechar
</button>
<button
onClick={handleSave}
disabled={isSaving || isLoading}
className="bg-[#E50914] hover:bg-red-700 text-white px-6 py-2 rounded-lg font-bold text-sm transition-colors disabled:opacity-50 flex items-center gap-2"
>
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
<span>{isSaving ? 'Salvando...' : 'Salvar Dados'}</span>
</button>
</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,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { ArrowLeft, CheckCircle, XCircle, Award } from 'lucide-react';
import { useModal } from '../contexts/ModalContext';
interface CourseActivityProps {
token: string | null;
@ -9,6 +10,7 @@ interface CourseActivityProps {
}
export default function CourseActivity({ token, moduleId, onBack, onCompleted }: CourseActivityProps) {
const { alert: showAlert, error: showError } = useModal();
const [activity, setActivity] = useState<any>(null);
const [answers, setAnswers] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(true);
@ -46,7 +48,7 @@ export default function CourseActivity({ token, moduleId, onBack, onCompleted }:
const handleSubmit = async () => {
if (answers.includes(-1)) {
alert('Por favor, responda todas as perguntas.');
showAlert('Por favor, responda todas as perguntas antes de enviar.');
return;
}
@ -68,7 +70,7 @@ export default function CourseActivity({ token, moduleId, onBack, onCompleted }:
onCompleted(true);
}
} else {
alert('Erro ao enviar atividade.');
showError('Erro ao enviar atividade. Tente novamente.');
}
} catch (err) {
console.error('Error submitting activity:', err);

View File

@ -27,7 +27,7 @@ export default function CourseCard({ course, onSelect, onUnlockRequest, subscrip
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' : ''}`}
>
{/* Thumbnail */}
<div className="relative w-full aspect-video overflow-hidden">
<div className="relative w-full aspect-[3/4] overflow-hidden">
<img
src={course.thumbnail}
alt={course.title}

View File

@ -1,177 +1,69 @@
import React, { useState, useEffect } from 'react';
import { X, Check, CreditCard, QrCode, FileText, Sparkles, Loader2, Play, Layers } from 'lucide-react';
import { Check, Layers, Loader2 } from 'lucide-react';
import { Course, Plan } from '../types';
import PaymentCheckoutCard from './PaymentCheckoutCard';
interface CourseUnlockModalProps {
course: Course & { price?: number };
user: any;
onClose: () => void;
onSuccess: () => void;
token: string;
}
export default function CourseUnlockModal({ course, onClose, onSuccess, token }: CourseUnlockModalProps) {
const [paymentMethod, setPaymentMethod] = useState<'PIX' | 'CREDIT_CARD' | 'BOLETO'>('PIX');
const [isPaying, setIsPaying] = useState(false);
const [pixCopied, setPixCopied] = useState(false);
const [unlocked, setUnlocked] = useState(false);
const [error, setError] = useState<string | null>(null);
const [paymentData, setPaymentData] = useState<any>(null);
// Form states
const [ccNumber, setCcNumber] = useState('');
const [ccName, setCcName] = useState('');
const [ccExpiry, setCcExpiry] = useState('');
const [ccCvv, setCcCvv] = useState('');
const [installments, setInstallments] = useState(1);
// Pricing & Plans Selection
export default function CourseUnlockModal({ course, user, onClose, onSuccess, token }: CourseUnlockModalProps) {
const [availablePlans, setAvailablePlans] = useState<Plan[]>([]);
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null); // null means 'Avulso'
const [loadingPlans, setLoadingPlans] = useState(true);
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null); // null = Avulso
const coursePrice = course.price || 49.90;
const handleCopyPix = () => {
if (paymentData?.pixData?.payload) {
navigator.clipboard.writeText(paymentData.pixData.payload);
setPixCopied(true);
setTimeout(() => setPixCopied(false), 2000);
}
};
const handleCopyBoleto = () => {
if (paymentData?.payment?.invoiceUrl) {
window.open(paymentData.payment.invoiceUrl, '_blank');
}
};
const [enableBoleto, setEnableBoleto] = useState<boolean>(true);
useEffect(() => {
if (token) {
fetch('/api/settings', {
headers: { 'Authorization': `Bearer ${token}` }
})
.then(res => res.json())
.then(data => {
if (data && data.enableBoleto === false) {
setEnableBoleto(false);
}
})
.catch(() => {});
// Fetch available plans for this course
fetch('/api/plans', {
headers: { 'Authorization': `Bearer ${token}` }
})
.then(res => res.json())
.then(data => {
const plans = data as Plan[];
// Filter plans that include this course or are global (empty includedCourses)
const relevant = plans.filter(p => !p.includedCourses || p.includedCourses.length === 0 || p.includedCourses.includes(course.id));
if (!token) return;
fetch('/api/plans', { headers: { Authorization: `Bearer ${token}` } })
.then(r => r.json())
.then((data: Plan[]) => {
// Show plans that include this course or are global (no specific courses)
const relevant = data.filter(
p => !p.includedCourses || p.includedCourses.length === 0 || p.includedCourses.includes(course.id)
);
setAvailablePlans(relevant);
setLoadingPlans(false);
})
.catch(() => setLoadingPlans(false));
}
.catch(() => {})
.finally(() => setLoadingPlans(false));
}, [token, course.id]);
const handleConfirmPayment = async (e: React.FormEvent) => {
e.preventDefault();
setIsPaying(true);
setError(null);
const creditCardInfo = paymentMethod === 'CREDIT_CARD' ? {
creditCard: {
holderName: ccName,
number: ccNumber.replace(/\s+/g, ''),
expiryMonth: ccExpiry.split('/')[0]?.trim(),
expiryYear: '20' + ccExpiry.split('/')[1]?.trim(),
ccv: ccCvv
},
creditCardHolderInfo: {
name: ccName,
email: 'aluno@microtecflix.com', // Will be fetched by server
cpfCnpj: '000.000.000-00',
postalCode: '01001-000',
addressNumber: '123',
phone: '11999999999'
}
} : undefined;
try {
let endpoint = '';
let bodyData: any = {
billingType: paymentMethod,
creditCardInfo
};
if (selectedPlanId) {
endpoint = `/api/subscription/subscribe`;
bodyData.planId = selectedPlanId;
} else {
endpoint = `/api/courses/${course.id}/unlock`;
bodyData.installmentCount = paymentMethod === 'CREDIT_CARD' ? installments : 1;
}
const res = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(bodyData)
});
const contentType = res.headers.get('content-type');
let data: any = {};
if (contentType && contentType.includes('application/json')) {
data = await res.json();
} else {
const text = await res.text();
console.error('Non-JSON response received:', text);
throw new Error(`Ocorreu um erro no servidor de pagamentos (${res.status}).`);
}
if (!res.ok) {
throw new Error(data.error || data.message || 'Erro ao processar pagamento com o Asaas');
}
if (paymentMethod === 'PIX' || paymentMethod === 'BOLETO') {
setPaymentData(data);
} else {
setUnlocked(true); // Credit Card assumes instant in sandbox
}
} catch (err: any) {
setError(err.message || 'Erro de conexão.');
} finally {
setIsPaying(false);
}
};
const selectedPlanObj = selectedPlanId ? availablePlans.find(p => p.id === selectedPlanId) : null;
const currentPrice = selectedPlanObj ? selectedPlanObj.price : coursePrice;
const currentItem = selectedPlanObj
? { id: selectedPlanObj.id, type: 'plan' as const, title: selectedPlanObj.title, price: selectedPlanObj.price, cycle: selectedPlanObj.cycle as any, description: selectedPlanObj.description || undefined }
: { id: course.id, type: 'course' as const, title: course.title, price: coursePrice, cycle: 'LIFETIME' as const, description: undefined };
const getCycleLabel = (cycle: string) => {
switch (cycle) {
case 'MONTHLY': return '/ mês';
case 'YEARLY': return '/ ano';
case 'LIFETIME': return 'taxa única';
default: return '';
}
};
return (
<div id="unlock-modal-overlay" className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-fade-in overflow-y-auto">
<div
id="unlock-modal-overlay"
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-fade-in overflow-y-auto"
>
<div className="relative w-full max-w-4xl bg-[#141414]/95 border border-white/10 rounded-2xl shadow-2xl overflow-hidden md:flex my-8">
{/* Close Button */}
<button
onClick={onClose}
className="absolute top-4 right-4 z-20 bg-black/40 text-zinc-400 hover:text-white p-2 rounded-full border border-white/5 transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
{!unlocked ? (
<>
{/* Left: Options & Selection */}
<div className="w-full md:w-1/2 bg-zinc-950 p-6 md:p-8 flex flex-col border-r border-white/5 overflow-y-auto custom-scrollbar max-h-[85vh]">
{/* LEFT: Course info + Plan selection */}
<div className="w-full md:w-5/12 bg-zinc-950 p-6 md:p-8 flex flex-col border-r border-white/5 overflow-y-auto custom-scrollbar max-h-[85vh]">
{/* Course header */}
<div className="mb-6">
<div className="flex items-center gap-4 mb-4">
<img src={course.thumbnail} alt={course.title} className="w-20 h-20 object-cover rounded-lg border border-white/10" />
<img
src={course.thumbnail}
alt={course.title}
className="w-20 h-20 object-cover rounded-lg border border-white/10"
/>
<div>
<span className="text-[10px] uppercase tracking-widest font-extrabold text-[#E50914] bg-[#E50914]/10 px-2.5 py-1 rounded-full border border-[#E50914]/20 inline-block mb-1.5">
Adquirir Acesso
@ -181,11 +73,12 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
</h3>
</div>
</div>
<p className="text-sm text-zinc-400">Escolha a melhor oferta para você acessar este curso e expandir seus conhecimentos.</p>
<p className="text-sm text-zinc-400">Escolha a melhor oferta para você.</p>
</div>
<div className="space-y-4">
{/* Option 1: Avulso */}
{/* Option list */}
<div className="space-y-3">
{/* Avulso */}
<div
onClick={() => setSelectedPlanId(null)}
className={`relative p-4 rounded-xl border-2 cursor-pointer transition-all ${
@ -195,10 +88,12 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
}`}
>
{selectedPlanId === null && (
<div className="absolute top-3 right-3 text-[#E50914]"><Check className="w-5 h-5" /></div>
<div className="absolute top-3 right-3 text-[#E50914]">
<Check className="w-5 h-5" />
</div>
)}
<h4 className="font-bold text-white mb-1">Compra Avulsa (Apenas este curso)</h4>
<p className="text-xs text-zinc-400 mb-3">Pague uma vez e tenha acesso vitalício ao curso selecionado.</p>
<p className="text-xs text-zinc-400 mb-3">Pague uma vez e tenha acesso vitalício ao curso.</p>
<div className="flex items-baseline gap-1">
<span className="text-sm text-zinc-500 font-bold">R$</span>
<span className="text-2xl font-black text-white">{coursePrice.toFixed(2)}</span>
@ -206,10 +101,13 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
</div>
</div>
{/* Option 2+: Plans/Combos */}
{/* Plans/Combos */}
{loadingPlans ? (
<div className="p-8 flex justify-center"><Loader2 className="w-6 h-6 animate-spin text-[#E50914]" /></div>
) : availablePlans.map(plan => (
<div className="p-8 flex justify-center">
<Loader2 className="w-6 h-6 animate-spin text-[#E50914]" />
</div>
) : (
availablePlans.map(plan => (
<div
key={plan.id}
onClick={() => setSelectedPlanId(plan.id)}
@ -219,284 +117,48 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
: 'bg-zinc-900/50 border-white/5 hover:border-white/20'
}`}
>
<div className="absolute top-0 right-0 bg-purple-600 text-white text-[9px] font-bold px-3 py-1 rounded-bl-lg uppercase tracking-wider">
<div className="absolute top-0 right-0 bg-purple-600 text-white text-[9px] font-bold px-3 py-1 rounded-bl-lg rounded-tr-lg uppercase tracking-wider">
Recomendado
</div>
{selectedPlanId === plan.id && (
<div className="absolute top-3 right-3 mt-3 text-purple-400"><Check className="w-5 h-5" /></div>
<div className="absolute top-3 right-3 mt-4 text-purple-400">
<Check className="w-5 h-5" />
</div>
)}
<h4 className="font-bold text-white mb-1 flex items-center gap-2 mt-2">
<Layers className="w-4 h-4 text-purple-400" />
{plan.title}
</h4>
<p className="text-xs text-zinc-400 mb-3">{plan.description || (plan.includedCourses.length === 0 ? 'Acesso Global a todos os cursos' : 'Combo promocional')}</p>
<p className="text-xs text-zinc-400 mb-3">
{plan.description || (plan.includedCourses.length === 0 ? 'Acesso Global a todos os cursos' : 'Combo promocional')}
</p>
<div className="flex items-baseline gap-1">
<span className="text-sm text-zinc-500 font-bold">R$</span>
<span className="text-2xl font-black text-white">{plan.price.toFixed(2)}</span>
<span className="text-xs text-zinc-500 font-medium ml-1">
{plan.cycle === 'MONTHLY' ? '/ mês' : plan.cycle === 'YEARLY' ? '/ ano' : 'taxa única'}
</span>
<span className="text-xs text-zinc-500 font-medium ml-1">{getCycleLabel(plan.cycle)}</span>
</div>
</div>
))}
</div>
</div>
{/* Right: Payment Forms */}
<div className="w-full md:w-1/2 p-6 md:p-8 flex flex-col space-y-6 max-h-[85vh] overflow-y-auto custom-scrollbar bg-[#111]">
<div>
<h4 className="font-display font-bold text-sm text-white uppercase tracking-wider mb-4">Escolha a forma de pagamento</h4>
{/* Tabs */}
<div className={`grid ${enableBoleto ? 'grid-cols-3' : 'grid-cols-2'} gap-2 p-1 bg-zinc-900/80 rounded-xl border border-white/5 mb-6`}>
<button
type="button"
onClick={() => setPaymentMethod('PIX')}
className={`flex flex-col items-center justify-center py-2.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${paymentMethod === 'PIX' ? 'bg-[#E50914] text-white shadow-lg' : 'text-zinc-400 hover:text-white'}`}
>
<QrCode className="w-4 h-4 mb-1" />
<span>PIX</span>
</button>
<button
type="button"
onClick={() => setPaymentMethod('CREDIT_CARD')}
className={`flex flex-col items-center justify-center py-2.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${paymentMethod === 'CREDIT_CARD' ? 'bg-[#E50914] text-white shadow-lg' : 'text-zinc-400 hover:text-white'}`}
>
<CreditCard className="w-4 h-4 mb-1" />
<span>Cartão</span>
</button>
{enableBoleto && (
<button
type="button"
onClick={() => setPaymentMethod('BOLETO')}
className={`flex flex-col items-center justify-center py-2.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${paymentMethod === 'BOLETO' ? 'bg-[#E50914] text-white shadow-lg' : 'text-zinc-400 hover:text-white'}`}
>
<FileText className="w-4 h-4 mb-1" />
<span>Boleto</span>
</button>
))
)}
</div>
</div>
{error && (
<div className="bg-red-950/40 border border-red-900/50 text-red-400 p-3 rounded-xl text-xs text-center mb-4">
{error}
</div>
)}
{/* Tab Contents */}
<form onSubmit={handleConfirmPayment} className="space-y-4">
{paymentMethod === 'PIX' && (
<div className="space-y-4 text-center">
{!paymentData ? (
<div className="text-zinc-400 text-xs py-4 text-center">
Clique em Confirmar para gerar seu QR Code PIX no valor de R$ {currentPrice.toFixed(2)}
</div>
) : (
<>
<div className="bg-white p-3 rounded-xl inline-block shadow-xl border border-zinc-200">
{paymentData.pixData?.encodedImage && paymentData.pixData.encodedImage.length > 20 ? (
<img
src={paymentData.pixData.encodedImage.startsWith('data:') ? paymentData.pixData.encodedImage : `data:image/png;base64,${paymentData.pixData.encodedImage}`}
alt="QR Code PIX"
className="w-40 h-40 object-contain mx-auto"
/>
) : paymentData.pixData?.payload ? (
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=${encodeURIComponent(paymentData.pixData.payload)}`}
alt="QR Code PIX"
className="w-40 h-40 object-contain mx-auto"
/>
) : (
<div className="w-40 h-40 bg-zinc-100 flex items-center justify-center text-zinc-500 text-xs font-bold rounded-lg">Gerando QR Code...</div>
)}
</div>
<div className="space-y-2">
<p className="text-xs text-zinc-400 max-w-sm mx-auto">
Escaneie o QR Code acima com o app do seu banco ou copie a chave Pix Copia e Cola abaixo.
</p>
<div className="flex items-center space-x-2 bg-zinc-900 rounded-lg p-2.5 border border-white/5">
<input
type="text"
readOnly
value={paymentData.pixData?.payload || ""}
className="bg-transparent text-zinc-300 text-xs font-mono select-all focus:outline-none flex-grow"
/>
<button
type="button"
onClick={handleCopyPix}
className="bg-zinc-800 text-xs text-white hover:bg-zinc-700 px-3 py-1.5 rounded-md font-bold transition-all cursor-pointer flex items-center space-x-1"
>
{pixCopied ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : null}
<span>{pixCopied ? 'Copiado!' : 'Copiar'}</span>
</button>
</div>
</div>
</>
)}
</div>
)}
{paymentMethod === 'CREDIT_CARD' && (
<div className="space-y-3.5">
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Número do Cartão</label>
<input
type="text"
required
value={ccNumber}
onChange={(e) => setCcNumber(e.target.value.replace(/\D/g, '').replace(/(\d{4})/g, '$1 ').trim().slice(0, 19))}
placeholder="0000 0000 0000 0000"
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none bg-black/40"
{/* RIGHT: Unified checkout form via PaymentCheckoutCard */}
<div className="w-full md:w-7/12 p-6 md:p-8 max-h-[85vh] overflow-y-auto custom-scrollbar bg-[#111]">
<PaymentCheckoutCard
itemId={currentItem.id}
itemType={currentItem.type}
title={currentItem.title}
price={currentItem.price}
cycle={currentItem.cycle}
description={currentItem.description}
user={user}
token={token}
embedded={true}
onClose={onClose}
onSuccess={onSuccess}
/>
</div>
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Nome Impresso no Cartão</label>
<input
type="text"
required
value={ccName}
onChange={(e) => setCcName(e.target.value.toUpperCase())}
placeholder="NOME IGUAL DO CARTÃO"
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none bg-black/40"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Validade</label>
<input
type="text"
required
value={ccExpiry}
onChange={(e) => setCcExpiry(e.target.value.replace(/\D/g, '').replace(/(\d{2})/g, '$1/').slice(0, 5))}
placeholder="MM/AA"
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none bg-black/40"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">CVV</label>
<input
type="password"
required
value={ccCvv}
onChange={(e) => setCcCvv(e.target.value.replace(/\D/g, '').slice(0, 4))}
placeholder="123"
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none bg-black/40"
/>
</div>
</div>
{/* Parcelamento (apenas para Vitalício/Pagamento Único) */}
{(!selectedPlanObj || selectedPlanObj.cycle === 'LIFETIME') && (
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Opções de Parcelamento</label>
<select
value={installments}
onChange={(e) => setInstallments(Number(e.target.value))}
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none cursor-pointer bg-zinc-900 border-white/10"
>
{[1, 2, 3, 4, 5, 6, 10, 12].map(n => {
const val = (currentPrice / n).toFixed(2);
return (
<option key={n} value={n} className="bg-zinc-900 text-white">
{n}x de R$ {val} {n === 1 ? '(À vista sem juros)' : '(sem juros)'}
</option>
);
})}
</select>
</div>
)}
</div>
)}
{paymentMethod === 'BOLETO' && (
<div className="space-y-4 text-center py-4 bg-zinc-900/40 rounded-xl border border-white/5">
<div className="bg-[#E50914]/10 text-[#E50914] p-3 rounded-full inline-block">
<FileText className="w-8 h-8" />
</div>
<div className="space-y-2">
<p className="text-sm font-bold text-white">Boleto Bancário</p>
<p className="text-xs text-zinc-400 max-w-sm mx-auto">
O boleto será gerado no valor de R$ {currentPrice.toFixed(2)}. O prazo de compensação é de 1 a 2 dias úteis.
</p>
<div className="pt-2">
{paymentData?.payment?.invoiceUrl ? (
<button
type="button"
onClick={handleCopyBoleto}
className="glass-btn-secondary text-xs text-white font-semibold py-2 px-4 rounded-lg inline-flex items-center space-x-2"
>
<FileText className="w-4 h-4" />
<span>Visualizar e Imprimir Boleto</span>
</button>
) : (
<div className="text-xs text-zinc-500">Clique abaixo para gerar o boleto</div>
)}
</div>
</div>
</div>
)}
{/* Submit button */}
{!paymentData && (
<div className="pt-4 mt-auto">
<button
type="submit"
disabled={isPaying}
className={`w-full text-white text-xs font-bold py-4 px-4 rounded-xl flex items-center justify-center space-x-2 cursor-pointer disabled:opacity-50 transition-all ${
selectedPlanId ? 'bg-purple-600 hover:bg-purple-700 shadow-[0_0_20px_rgba(147,51,234,0.4)]' : 'bg-[#E50914] hover:bg-red-700 shadow-[0_0_20px_rgba(229,9,20,0.4)]'
}`}
>
{isPaying ? (
<>
<Loader2 className="w-4 h-4 animate-spin text-white" />
<span>Processando Pagamento Segurizado...</span>
</>
) : (
<>
<Sparkles className="w-4 h-4 text-yellow-400" />
<span className="text-sm">Confirmar: R$ {currentPrice.toFixed(2)}</span>
</>
)}
</button>
<div className="text-center text-[10px] text-zinc-500 mt-4">
Pagamento via API Oficial Sandbox Asaas.
</div>
</div>
)}
</form>
</div>
</div>
</>
) : (
/* Success Step */
<div className="w-full p-8 md:p-12 text-center flex flex-col items-center justify-center space-y-6">
<div className="bg-emerald-500 text-white p-4 rounded-full shadow-[0_0_25px_rgba(16,185,129,0.5)] border border-emerald-400 animate-bounce">
<Check className="w-10 h-10 stroke-[3]" />
</div>
<div className="space-y-2 max-w-md">
<h3 className="font-display font-black text-2xl text-white">
Pagamento Confirmado!
</h3>
<p className="text-zinc-400 text-xs leading-relaxed">
Parabéns! O seu pagamento foi aprovado com sucesso na plataforma. Seu acesso está liberado!
</p>
</div>
<button
onClick={onSuccess}
className="glass-btn-primary text-white text-xs font-bold py-3 px-8 rounded-lg flex items-center justify-center space-x-2 shadow-lg transition-transform hover:scale-105 cursor-pointer bg-emerald-600 border-none"
>
<Play className="w-4 h-4 fill-white" />
<span>Começar a Estudar Agora</span>
</button>
</div>
)}
</div>
</div>
);

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { ArrowLeft, BookOpen, ChevronRight, FileText, CheckCircle2, Circle, MessageSquare, AlertCircle, Award } from 'lucide-react';
import { ArrowLeft, BookOpen, ChevronRight, ChevronLeft, FileText, CheckCircle2, Circle, MessageSquare, AlertCircle, Award, Download } from 'lucide-react';
import VideoPlayer from './VideoPlayer';
import CourseActivity from './CourseActivity';
@ -34,18 +34,21 @@ interface Course {
description: string;
thumbnail: string;
category: string;
studyMaterialUrl?: string;
}
interface CourseViewProps {
courseId: string;
token: string | null;
onBack: () => void;
initialLessonId?: string | null;
}
export default function CourseView({ courseId, token, onBack }: CourseViewProps) {
export default function CourseView({ courseId, token, onBack, initialLessonId }: CourseViewProps) {
const [course, setCourse] = useState<Course | null>(null);
const [modules, setModules] = useState<Module[]>([]);
const [activeLesson, setActiveLesson] = useState<Lesson | null>(null);
const [expandedModules, setExpandedModules] = useState<string[]>([]);
const [noteContent, setNoteContent] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -72,13 +75,28 @@ export default function CourseView({ courseId, token, onBack }: CourseViewProps)
setCourse(data.course);
setModules(data.modules);
// Default to the first lesson of the first module
// Default to the selected initial lesson, or the first lesson of the first module
if (data.modules && data.modules.length > 0) {
let selectedLsn = null;
if (initialLessonId) {
for (const mod of data.modules) {
const found = mod.lessons?.find((l: any) => l.id === initialLessonId);
if (found) {
selectedLsn = found;
break;
}
}
}
if (!selectedLsn) {
const firstMod = data.modules[0];
if (firstMod.lessons && firstMod.lessons.length > 0) {
const firstLsn = firstMod.lessons[0];
setActiveLesson(firstLsn);
setNoteContent(firstLsn.note || '');
selectedLsn = firstMod.lessons[0];
}
}
if (selectedLsn) {
handleSelectLesson(selectedLsn, data.modules.find((m: any) => m.lessons.some((l: any) => l.id === selectedLsn.id))?.id);
}
}
} catch (err: any) {
@ -88,10 +106,62 @@ export default function CourseView({ courseId, token, onBack }: CourseViewProps)
}
};
const handleSelectLesson = (lesson: Lesson) => {
const handleSelectLesson = (lesson: Lesson, moduleId?: string) => {
setActiveLesson(lesson);
setNoteContent(lesson.note || '');
setSaveStatus('idle');
if (moduleId) {
setExpandedModules(prev => prev.includes(moduleId) ? prev : [...prev, moduleId]);
} else {
// Find module if not passed
const parentMod = modules.find(m => m.lessons.some(l => l.id === lesson.id));
if (parentMod) {
setExpandedModules(prev => prev.includes(parentMod.id) ? prev : [...prev, parentMod.id]);
}
}
};
const toggleModule = (moduleId: string) => {
setExpandedModules(prev =>
prev.includes(moduleId) ? prev.filter(id => id !== moduleId) : [...prev, moduleId]
);
};
const totalLessonsCount = modules.reduce((acc, mod) => acc + mod.lessons.length, 0);
const completedLessonsCount = modules.reduce((acc, mod) => acc + mod.lessons.filter(l => l.progress?.completed).length, 0);
const overallProgress = totalLessonsCount === 0 ? 0 : Math.round((completedLessonsCount / totalLessonsCount) * 100);
const handleReact = async (type: string | null) => {
if (!activeLesson) return;
try {
const res = await fetch(`/api/lessons/${activeLesson.id}/react`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ type })
});
if (res.ok) {
// Update local state for lesson reaction
setModules(prev => prev.map(mod => {
return {
...mod,
lessons: mod.lessons.map(lsn => {
if (lsn.id === activeLesson.id) {
return { ...lsn, reaction: type };
}
return lsn;
})
};
}));
setActiveLesson(prev => prev ? { ...prev, reaction: type } : null);
}
} catch (err) {
console.error('Error saving reaction:', err);
}
};
const handleProgressUpdate = async (watchedPercentage: number) => {
@ -227,6 +297,13 @@ export default function CourseView({ courseId, token, onBack }: CourseViewProps)
);
}
// Derived state for prev/next lesson
const allLessons = modules.flatMap(m => m.lessons);
const activeLessonIndex = activeLesson ? allLessons.findIndex(l => l.id === activeLesson.id) : -1;
const prevLesson = activeLessonIndex > 0 ? allLessons[activeLessonIndex - 1] : null;
const nextLesson = activeLessonIndex >= 0 && activeLessonIndex < allLessons.length - 1 ? allLessons[activeLessonIndex + 1] : null;
const isCurrentLessonCompleted = activeLesson?.progress?.completed;
return (
<div className="space-y-6">
{/* Back Header */}
@ -258,8 +335,41 @@ export default function CourseView({ courseId, token, onBack }: CourseViewProps)
lessonId={activeLesson.id}
onProgressUpdate={handleProgressUpdate}
initialProgress={activeLesson.progress?.watchedPercentage || 0}
reaction={activeLesson.reaction}
onReact={handleReact}
/>
{/* Navigation Buttons */}
<div className="flex justify-between items-center gap-4 py-2">
<button
onClick={() => prevLesson && setActiveLesson(prevLesson)}
disabled={!prevLesson}
className={`flex-1 sm:flex-none flex items-center justify-center space-x-2 px-4 py-2.5 rounded-lg font-bold transition-all ${
prevLesson
? 'bg-zinc-800/50 hover:bg-zinc-800 text-white cursor-pointer'
: 'bg-zinc-800/20 text-zinc-600 cursor-not-allowed opacity-50'
}`}
>
<ChevronLeft className="w-5 h-5" />
<span>Aula Anterior</span>
</button>
<button
onClick={() => nextLesson && setActiveLesson(nextLesson)}
disabled={!nextLesson}
className={`flex-1 sm:flex-none flex items-center justify-center space-x-2 px-6 py-2.5 rounded-lg font-bold transition-all ${
!nextLesson
? 'bg-zinc-800/20 text-zinc-600 cursor-not-allowed opacity-50'
: isCurrentLessonCompleted
? 'bg-[#E50914] hover:bg-[#b80710] text-white animate-pulse shadow-[0_0_15px_rgba(229,9,20,0.5)] cursor-pointer'
: 'bg-white text-black hover:bg-zinc-200 cursor-pointer'
}`}
>
<span>Próxima Aula</span>
<ChevronRight className="w-5 h-5" />
</button>
</div>
{/* Lesson Description Content */}
{activeLesson.content && (
<div className="glass-card p-5 rounded-xl space-y-2">
@ -314,34 +424,85 @@ export default function CourseView({ courseId, token, onBack }: CourseViewProps)
{/* Right Hand: Interactive Modules Accordion */}
<div className="space-y-4">
<div className="glass-card p-4 rounded-xl space-y-1">
<div className="glass-card p-4 rounded-xl space-y-3">
<div>
<h3 className="font-sans font-extrabold text-sm text-white tracking-tight">Conteúdo do Curso</h3>
<p className="text-xs text-zinc-500 font-medium">{course.title}</p>
<p className="text-xs text-zinc-500 font-medium mt-1">{course.title}</p>
</div>
{course.studyMaterialUrl && (
<a
href={course.studyMaterialUrl}
target="_blank"
rel="noreferrer"
className="flex items-center justify-center gap-2 w-full glass-btn-secondary text-[#E50914] text-xs font-bold py-2 px-3 rounded-lg hover:scale-[1.02] transition-all"
>
<Download className="w-4 h-4" />
Baixar Material de Estudo
</a>
)}
</div>
{/* Overall Progress Block */}
<div className="glass-card rounded-xl p-4 mb-4 border border-white/5 flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="relative w-10 h-10 flex items-center justify-center flex-shrink-0">
<svg viewBox="0 0 36 36" className="w-10 h-10 -rotate-90 absolute inset-0">
<path className="text-zinc-800" strokeWidth="3" stroke="currentColor" fill="none" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
<path className="text-[#E50914] transition-all duration-1000 ease-out" strokeDasharray={`${overallProgress}, 100`} strokeWidth="3" strokeLinecap="round" stroke="currentColor" fill="none" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
</svg>
<span className="text-[10px] font-bold text-white z-10">{overallProgress}%</span>
</div>
<div>
<h4 className="text-sm font-bold text-white">Meu progresso</h4>
<p className="text-xs text-zinc-500">{completedLessonsCount} de {totalLessonsCount} {totalLessonsCount === 1 ? 'aula' : 'aulas'}</p>
</div>
</div>
</div>
<div className="space-y-3">
{modules.map((mod, modIdx) => (
{modules.map((mod, modIdx) => {
const isExpanded = expandedModules.includes(mod.id);
const moduleTotal = mod.lessons.length;
const moduleCompleted = mod.lessons.filter(l => l.progress?.completed).length;
const isModuleFullyCompleted = moduleTotal > 0 && moduleTotal === moduleCompleted;
const moduleProgressPercent = moduleTotal > 0 ? Math.round((moduleCompleted / moduleTotal) * 100) : 0;
return (
<div
key={mod.id}
className="glass-card rounded-xl overflow-hidden"
className="glass-card rounded-xl overflow-hidden border border-white/5"
>
{/* Module Title bar */}
<div className="glass-accordion-header px-4 py-3 flex items-center justify-between">
<div className="flex items-center space-x-2">
<span className="text-[#E50914] text-xs font-black">M{modIdx + 1}</span>
{/* Module Title bar (Clickable) */}
<button
onClick={() => toggleModule(mod.id)}
className="w-full text-left px-4 py-3.5 flex items-center justify-between hover:bg-white/5 transition-colors"
>
<div className="flex items-center space-x-3">
<div className="relative w-8 h-8 flex items-center justify-center flex-shrink-0">
<svg viewBox="0 0 36 36" className="w-8 h-8 -rotate-90 absolute inset-0">
<path className="text-zinc-800" strokeWidth="3" stroke="currentColor" fill="none" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
<path className="text-[#E50914] transition-all duration-1000 ease-out" strokeDasharray={`${moduleProgressPercent}, 100`} strokeWidth="3" strokeLinecap="round" stroke="currentColor" fill="none" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
</svg>
<span className="text-[8px] font-bold text-white z-10">{moduleProgressPercent}%</span>
</div>
<div>
<h4 className="font-sans font-bold text-xs text-zinc-200 tracking-tight leading-snug line-clamp-1">
{mod.title}
</h4>
<p className="text-[10px] text-zinc-500 mt-0.5">
{moduleTotal} {moduleTotal === 1 ? 'aula' : 'aulas'}
</p>
</div>
<span className="text-[10px] text-zinc-500 font-bold whitespace-nowrap">
{mod.lessons.length} {mod.lessons.length === 1 ? 'aula' : 'aulas'}
</span>
</div>
</button>
{/* Lessons list inside module */}
<div className="divide-y divide-white/5">
{isExpanded && (
<div className="px-4 pb-3">
<div className="ml-2 border-l border-zinc-700 pl-4 py-1 space-y-1">
{mod.lessons.length === 0 ? (
<div className="p-3 text-xs text-zinc-600 text-center italic">Nenhuma aula neste módulo.</div>
<div className="py-2 text-xs text-zinc-600 italic">Nenhuma aula neste módulo.</div>
) : (
mod.lessons.map(lesson => {
const isActive = activeLesson?.id === lesson.id;
@ -350,57 +511,51 @@ export default function CourseView({ courseId, token, onBack }: CourseViewProps)
return (
<button
key={lesson.id}
onClick={() => handleSelectLesson(lesson)}
className={`w-full px-4 py-3 text-left flex items-start space-x-3 transition-colors ${
isActive
? 'bg-[#E50914]/10 text-white'
: 'hover:bg-zinc-800/40 text-zinc-400 hover:text-zinc-200'
}`}
onClick={() => handleSelectLesson(lesson, mod.id)}
className="relative w-full py-2.5 text-left flex items-start space-x-3 group"
>
<div className="mt-0.5 flex-shrink-0">
{isLsnCompleted ? (
<CheckCircle2 className="w-4 h-4 text-emerald-500 fill-emerald-500/10" />
) : (
<Circle className="w-4 h-4 text-zinc-600" />
)}
</div>
{/* Timeline Dot indicator overlaying the border */}
<div className={`absolute -left-[21px] top-3.5 w-2 h-2 rounded-full ring-4 ring-[#141414] ${
isLsnCompleted ? 'bg-emerald-500' : isActive ? 'bg-[#E50914]' : 'bg-zinc-600'
}`} />
<div className="space-y-0.5">
<span className={`font-medium text-xs leading-snug line-clamp-2 ${isActive ? 'text-[#E50914] font-bold' : ''}`}>
{lesson.title}
<span className={`font-medium text-xs leading-snug line-clamp-2 transition-colors ${
isActive ? 'text-[#E50914] font-bold' : 'text-zinc-400 group-hover:text-zinc-200'
}`}>
{lesson.order}. {lesson.title}
</span>
{lesson.progress?.watchedPercentage > 0 && !isLsnCompleted && (
<div className="flex items-center space-x-1.5 pt-1">
<div className="w-16 bg-zinc-800 h-1 rounded-full overflow-hidden">
<div
className="bg-amber-500 h-full"
style={{ width: `${lesson.progress.watchedPercentage}%` }}
/>
</div>
<span className="text-[9px] text-zinc-500 font-bold">{lesson.progress.watchedPercentage}%</span>
</div>
)}
</div>
</button>
);
})
)}
{/* Module Activity Button */}
{/* Module Activity Button (End of Timeline) */}
{mod.lessons.length > 0 && (
<div className="p-3 border-t border-white/5 bg-black/20">
<div className="relative pt-4 pb-1">
<div className="absolute -left-[21px] top-[22px] w-2 h-2 rounded-full bg-zinc-700 ring-4 ring-[#141414]" />
<button
onClick={() => setActiveActivityModuleId(mod.id)}
className="w-full flex items-center justify-center space-x-2 glass-btn-primary hover:scale-[1.02] text-white transition-all py-2.5 px-3 rounded-lg text-xs font-extrabold uppercase tracking-wider cursor-pointer shadow-md group"
onClick={() => isModuleFullyCompleted && setActiveActivityModuleId(mod.id)}
disabled={!isModuleFullyCompleted}
className={`w-full flex items-center justify-center space-x-2 transition-all py-2.5 px-3 rounded-lg text-xs font-extrabold uppercase tracking-wider shadow-md border ${
isModuleFullyCompleted
? 'glass-btn-primary hover:scale-[1.02] text-white cursor-pointer border-transparent'
: 'bg-zinc-800/50 text-zinc-500 cursor-not-allowed border-zinc-700/50'
}`}
title={!isModuleFullyCompleted ? "Conclua todas as aulas deste módulo para liberar a avaliação" : ""}
>
<Award className="w-4 h-4 text-white flex-shrink-0" />
<span className="truncate">Avaliação do Módulo {modIdx + 1}: {mod.title}</span>
<Award className="w-4 h-4 flex-shrink-0" />
<span className="truncate">Avaliação do Módulo</span>
</button>
</div>
)}
</div>
</div>
))}
)}
</div>
);
})}
</div>
</div>

View File

@ -0,0 +1,43 @@
import React from 'react';
import { X } from 'lucide-react';
interface CustomModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
isGlass?: boolean;
}
export default function CustomModal({ isOpen, onClose, title, children, isGlass = false }: CustomModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
onClick={onClose}
/>
<div className={`relative w-full max-w-3xl max-h-[85vh] flex flex-col transform transition-all animate-in fade-in zoom-in-95 duration-200
${isGlass
? 'bg-black/40 backdrop-blur-xl border border-white/10 rounded-2xl shadow-[0_0_50px_rgba(0,0,0,0.5)]'
: 'bg-[#141414] border border-zinc-800 rounded-xl shadow-2xl'
}`}
>
<div className={`flex items-center justify-between p-5 border-b ${isGlass ? 'border-white/10' : 'border-zinc-800'}`}>
<h3 className="text-xl font-bold text-white font-display">{title}</h3>
<button
onClick={onClose}
className={`p-2 rounded-full transition-colors ${isGlass ? 'hover:bg-white/10 text-white/70 hover:text-white' : 'hover:bg-zinc-800 text-zinc-400 hover:text-white'}`}
>
<X size={20} />
</button>
</div>
<div className="p-6 overflow-y-auto custom-scrollbar flex-1 text-zinc-300">
{children}
</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

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { ArrowLeft, HelpCircle } from 'lucide-react';
import { ArrowLeft, HelpCircle, MessageSquare, X, Send } from 'lucide-react';
interface HelpViewProps {
token: string | null;
@ -9,6 +9,10 @@ interface HelpViewProps {
export default function HelpView({ token, onBack }: HelpViewProps) {
const [helpText, setHelpText] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [ticketContent, setTicketContent] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitSuccess, setSubmitSuccess] = useState(false);
useEffect(() => {
fetchHelpText();
@ -31,6 +35,33 @@ export default function HelpView({ token, onBack }: HelpViewProps) {
}
};
const handleSubmitTicket = async () => {
if (!ticketContent.trim()) return;
setIsSubmitting(true);
try {
const res = await fetch('/api/support-tickets', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ content: ticketContent })
});
if (res.ok) {
setSubmitSuccess(true);
setTicketContent('');
setTimeout(() => {
setShowModal(false);
setSubmitSuccess(false);
}, 3000);
}
} catch (err) {
console.error('Error creating support ticket:', err);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="space-y-6 max-w-4xl mx-auto">
<button
@ -42,10 +73,19 @@ export default function HelpView({ token, onBack }: HelpViewProps) {
</button>
<div className="glass-card rounded-2xl overflow-hidden shadow-2xl border border-white/10 p-8 min-h-[50vh]">
<div className="flex items-center space-x-3 mb-8 border-b border-white/10 pb-4">
<div className="flex items-center justify-between mb-8 border-b border-white/10 pb-4">
<div className="flex items-center space-x-3">
<HelpCircle className="w-8 h-8 text-[#E50914]" />
<h2 className="text-2xl font-bold text-white">Central de Ajuda</h2>
</div>
<button
onClick={() => setShowModal(true)}
className="flex items-center space-x-2 bg-[#E50914] text-white px-4 py-2 rounded-lg font-bold hover:bg-red-700 transition-colors text-sm"
>
<MessageSquare className="w-4 h-4" />
<span>Falar com o Suporte</span>
</button>
</div>
{isLoading ? (
<div className="flex justify-center items-center py-12">
@ -57,6 +97,52 @@ export default function HelpView({ token, onBack }: HelpViewProps) {
</div>
)}
</div>
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
<div className="bg-zinc-900 border border-white/10 p-6 rounded-2xl w-full max-w-md">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-white">Enviar Comentário ou Dúvida</h3>
<button
onClick={() => setShowModal(false)}
className="text-zinc-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{submitSuccess ? (
<div className="bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 p-4 rounded-xl text-center">
<p className="font-bold">Mensagem enviada com sucesso!</p>
<p className="text-sm mt-1">Nossa equipe responderá em breve.</p>
</div>
) : (
<div className="space-y-4">
<textarea
value={ticketContent}
onChange={(e) => setTicketContent(e.target.value)}
placeholder="Escreva aqui sua dúvida, comentário ou solicitação de ajuda..."
className="w-full h-32 bg-zinc-800 border border-white/10 rounded-xl p-4 text-white text-sm focus:outline-none focus:border-[#E50914] resize-none"
/>
<button
onClick={handleSubmitTicket}
disabled={!ticketContent.trim() || isSubmitting}
className="w-full flex justify-center items-center space-x-2 bg-[#E50914] hover:bg-red-700 text-white font-bold py-3 px-4 rounded-xl transition-colors disabled:opacity-50"
>
{isSubmitting ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
<span>Enviar Mensagem</span>
<Send className="w-4 h-4 ml-2" />
</>
)}
</button>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@ -5,11 +5,15 @@ import { Course } from '../types';
interface HeroBannerProps {
course: (Course & { progress?: number; totalLessons?: number }) | null;
onPlay: (courseId: string) => void;
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, 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">
@ -25,8 +29,8 @@ export default function HeroBanner({ course, onPlay, subscriptionStatus, setView
{/* 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"
/>
@ -44,12 +48,12 @@ export default function HeroBanner({ course, onPlay, subscriptionStatus, setView
{/* 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) */}
@ -90,13 +94,23 @@ export default function HeroBanner({ course, onPlay, subscriptionStatus, setView
</button>
)}
{course.progress > 0 ? (
<button
onClick={() => onPlay(course.id)}
className="glass-btn-secondary text-white font-medium px-5 py-2.5 rounded-lg flex items-center space-x-2 cursor-pointer"
>
<Info className="w-5 h-5 text-zinc-300" />
<span>Continuar Treinamento</span>
</button>
) : (
<button
onClick={() => onDetail(course.id)}
className="glass-btn-secondary text-white font-medium px-5 py-2.5 rounded-lg flex items-center space-x-2 cursor-pointer"
>
<Info className="w-5 h-5 text-zinc-300" />
<span>Ver Detalhes</span>
</button>
)}
</div>
</div>
</div>

View File

@ -8,13 +8,15 @@ interface NavbarProps {
email: string;
role: 'admin' | 'student';
subscriptionStatus: SubscriptionStatus;
avatarUrl?: string;
} | null;
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[]>([]);
@ -46,7 +48,7 @@ export default function Navbar({ user, activeView, setView, onLogout }: NavbarPr
try {
// In a real app we would pass the token or it would be in cookies
// Assuming server uses some auth, for demo we might just fetch
const token = localStorage.getItem('token');
const token = localStorage.getItem('app_token') || localStorage.getItem('admin_token');
if (!token) return;
const res = await fetch('/api/notifications', {
headers: { 'Authorization': `Bearer ${token}` }
@ -62,7 +64,7 @@ export default function Navbar({ user, activeView, setView, onLogout }: NavbarPr
const markAsRead = async (id: string) => {
try {
const token = localStorage.getItem('token');
const token = localStorage.getItem('app_token') || localStorage.getItem('admin_token');
if (!token) return;
await fetch(`/api/notifications/${id}/read`, {
method: 'PUT',
@ -98,24 +100,8 @@ export default function Navbar({ user, activeView, setView, onLogout }: NavbarPr
}
};
return (
<nav className="sticky top-0 z-50 glass-navbar px-4 py-3 md:px-8 flex items-center justify-between">
<div className="flex items-center space-x-8">
{/* LOGO */}
<div
onClick={() => setView('home')}
className="flex items-center space-x-2 cursor-pointer select-none group"
>
<div className="bg-[#E50914] text-white p-1.5 rounded flex items-center justify-center group-hover:scale-105 transition-transform">
<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>
</span>
</div>
{/* NAVIGATION LINKS */}
<div className="hidden md:flex items-center space-x-3 lg:space-x-6 text-sm flex-shrink-0">
const navLinks = (
<>
<button
onClick={() => setView('home')}
className={`font-semibold transition-colors hover:text-[#E50914] whitespace-nowrap flex-shrink-0 ${activeView === 'home' || activeView === 'course-view' ? 'text-white' : 'text-zinc-400'}`}
@ -136,6 +122,35 @@ export default function Navbar({ user, activeView, setView, onLogout }: NavbarPr
<GraduationCap className="w-4 h-4 text-[#E50914]" />
<span>Meus Certificados</span>
</button>
</>
);
return (
<nav className="sticky top-0 z-50 glass-navbar px-4 py-3 md:px-8 flex flex-wrap items-center justify-between gap-y-3">
<div className="flex items-center space-x-8">
{/* LOGO */}
<div className="flex flex-col">
<div
onClick={() => setView('home')}
className="flex items-center space-x-2 cursor-pointer select-none group"
>
<div className="bg-[#E50914] text-white p-1.5 rounded flex items-center justify-center group-hover:scale-105 transition-transform">
<Play className="fill-white w-5 h-5" />
</div>
<span className="font-sans font-black text-xl md:text-2xl tracking-tighter text-[#E50914]">
{(settings?.brandName || 'Plataforma')}<span className="text-white">FLIX</span>
</span>
</div>
{settings?.brandSlogan && (
<span className="text-[10px] text-zinc-400 font-medium ml-10 mt-0.5 max-w-[350px] leading-tight hidden md:block">
{settings.brandSlogan}
</span>
)}
</div>
{/* NAVIGATION LINKS */}
<div className="hidden md:flex items-center space-x-3 lg:space-x-6 text-sm flex-shrink-0">
{navLinks}
</div>
</div>
@ -172,22 +187,20 @@ export default function Navbar({ user, activeView, setView, onLogout }: NavbarPr
<h4 className="text-xs font-bold text-white uppercase tracking-wider">Notificações</h4>
</div>
<div className="max-h-64 overflow-y-auto">
{notifications.length === 0 ? (
{notifications.filter(n => !n.read).length === 0 ? (
<div className="p-4 text-center text-zinc-500 text-xs">Nenhuma notificação no momento.</div>
) : (
notifications.map(n => (
notifications.filter(n => !n.read).map(n => (
<div
key={n.id}
className={`p-3 border-b border-white/5 text-xs transition-colors ${n.read ? 'bg-transparent text-zinc-400' : 'bg-white/5 text-white'}`}
className={`p-3 border-b border-white/5 text-xs transition-colors bg-white/5 text-white`}
>
<p>{n.message}</p>
<div className="flex items-center justify-between mt-2">
<span className="text-[10px] text-zinc-500">{new Date(n.createdAt).toLocaleDateString('pt-BR')}</span>
{!n.read && (
<button onClick={() => markAsRead(n.id)} className="text-[10px] text-[#E50914] hover:text-white flex items-center gap-1">
<Check className="w-3 h-3" /> Lida
</button>
)}
</div>
</div>
))
@ -206,9 +219,13 @@ export default function Navbar({ user, activeView, setView, onLogout }: NavbarPr
<div className="relative" ref={profileRef}>
<button
onClick={() => setShowProfileMenu(!showProfileMenu)}
className="w-10 h-10 rounded-full bg-zinc-800 flex items-center justify-center text-zinc-300 hover:bg-zinc-700 transition-colors border border-white/10"
className="w-10 h-10 rounded-full bg-zinc-800 flex items-center justify-center text-zinc-300 hover:bg-zinc-700 transition-colors border border-white/10 overflow-hidden"
>
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="Avatar" className="w-full h-full object-cover" />
) : (
<User className="w-5 h-5" />
)}
</button>
{showProfileMenu && (
@ -225,7 +242,7 @@ export default function Navbar({ user, activeView, setView, onLogout }: NavbarPr
className="w-full px-4 py-2 text-left flex items-center space-x-2 text-sm text-zinc-300 hover:bg-zinc-800 hover:text-white transition-colors"
>
<GraduationCap className="w-4 h-4" />
<span>Meu Certificado</span>
<span>Meus Certificados</span>
</button>
<button
onClick={() => { setView('help'); setShowProfileMenu(false); }}
@ -247,6 +264,11 @@ export default function Navbar({ user, activeView, setView, onLogout }: NavbarPr
</div>
</div>
</div>
{/* MOBILE NAVIGATION LINKS */}
<div className="flex md:hidden items-center space-x-6 text-sm overflow-x-auto w-full pt-3 pb-1 border-t border-white/10 scrollbar-hide">
{navLinks}
</div>
</nav>
);
}

View File

@ -0,0 +1,592 @@
import React, { useState, useEffect } from 'react';
import {
X, Check, CreditCard, QrCode, FileText, Sparkles, Loader2, Play, Clock, RefreshCw, AlertTriangle
} from 'lucide-react';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type PaymentItemType = 'course' | 'plan';
export type BillingCycle = 'MONTHLY' | 'YEARLY' | 'LIFETIME';
export type BillingMethod = 'PIX' | 'CREDIT_CARD' | 'BOLETO';
export interface PaymentCheckoutCardProps {
/** Unique ID (course id or plan id) */
itemId: string;
itemType: PaymentItemType;
/** Display title shown on the checkout */
title: string;
price: number;
/** Billing cycle LIFETIME = single payment */
cycle?: BillingCycle;
/** Optional extra description / subtitle */
description?: string;
user: { name: string; email: string; cpf?: string } | null;
token: string | null;
onSuccess?: () => void;
/** Close button handler (modal use-case) */
onClose?: () => void;
/** When true, renders without the modal overlay wrapper */
embedded?: boolean;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
const s = (seconds % 60).toString().padStart(2, '0');
return `${m}:${s}`;
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const PaymentCheckoutCard: React.FC<PaymentCheckoutCardProps> = ({
itemId,
itemType,
title,
price,
cycle = 'LIFETIME',
description,
user,
token,
onSuccess,
onClose,
embedded = false,
}) => {
// ── Settings ──────────────────────────────────────────────────────────────
const [enableBoleto, setEnableBoleto] = useState(true);
const [enableInstallmentBoleto, setEnableInstallmentBoleto] = useState(false);
const [maxBoletoInstallments, setMaxBoletoInstallments] = useState(3);
// ── Payment method & form fields ──────────────────────────────────────────
const [billingType, setBillingType] = useState<BillingMethod>('PIX');
const [installments, setInstallments] = useState(1);
const [ccNumber, setCcNumber] = useState('');
const [ccName, setCcName] = useState('');
const [ccExpiry, setCcExpiry] = useState('');
const [ccCvv, setCcCvv] = useState('');
const [checkoutCpf, setCheckoutCpf] = useState(
user?.cpf && user.cpf !== '000.000.000-00' ? user.cpf : ''
);
// ── State ─────────────────────────────────────────────────────────────────
const [isPaying, setIsPaying] = useState(false);
const [error, setError] = useState<string | null>(null);
const [paymentData, setPaymentData] = useState<any>(null);
const [pixCopied, setPixCopied] = useState(false);
const [timeLeft, setTimeLeft] = useState<number | null>(null);
const [unlocked, setUnlocked] = useState(false);
// ── Fetch settings ────────────────────────────────────────────────────────
useEffect(() => {
if (!token) return;
fetch('/api/settings', { headers: { Authorization: `Bearer ${token}` } })
.then(r => r.json())
.then(d => {
if (d?.enableBoleto === false) setEnableBoleto(false);
if (d?.enableInstallmentBoleto === true) setEnableInstallmentBoleto(true);
if (d?.maxBoletoInstallments) setMaxBoletoInstallments(d.maxBoletoInstallments);
})
.catch(() => {});
}, [token]);
// ── PIX countdown ─────────────────────────────────────────────────────────
useEffect(() => {
if (timeLeft === null) return;
if (timeLeft <= 0) {
const cancel = async () => {
if (!paymentData?.payment?.id) return;
try {
await fetch(`/api/payments/${paymentData.payment.id}/cancel`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
} catch {}
};
cancel();
setPaymentData(null);
setError('Tempo limite do PIX esgotado (5 minutos). O pagamento foi cancelado automaticamente.');
setTimeLeft(null);
return;
}
const t = setInterval(() => setTimeLeft(p => (p !== null ? p - 1 : null)), 1000);
return () => clearInterval(t);
}, [timeLeft, paymentData, token]);
// ── Handlers ──────────────────────────────────────────────────────────────
const handleCopyPix = () => {
if (paymentData?.pixData?.payload) {
navigator.clipboard.writeText(paymentData.pixData.payload);
setPixCopied(true);
setTimeout(() => setPixCopied(false), 2000);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsPaying(true);
setError(null);
if (!checkoutCpf || checkoutCpf.replace(/\D/g, '').length < 11) {
setError('Por favor, informe um CPF ou CNPJ válido.');
setIsPaying(false);
return;
}
const creditCardInfo =
billingType === 'CREDIT_CARD'
? {
creditCard: {
holderName: ccName,
number: ccNumber.replace(/\s+/g, ''),
expiryMonth: ccExpiry.split('/')[0]?.trim(),
expiryYear: '20' + ccExpiry.split('/')[1]?.trim(),
ccv: ccCvv,
},
creditCardHolderInfo: {
name: ccName,
email: user?.email || '',
cpfCnpj: checkoutCpf,
postalCode: '01001-000',
addressNumber: '123',
phone: '11999999999',
},
}
: undefined;
const endpoint =
itemType === 'course'
? `/api/courses/${itemId}/unlock`
: `/api/subscription/subscribe`;
const bodyData: Record<string, any> = { billingType, creditCardInfo, cpf: checkoutCpf };
if (itemType === 'plan') bodyData.planId = itemId;
if (
itemType === 'course' &&
(billingType === 'CREDIT_CARD' || (billingType === 'BOLETO' && enableInstallmentBoleto))
) {
bodyData.installmentCount = installments;
}
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(bodyData),
});
const ct = res.headers.get('content-type');
let data: any = {};
if (ct?.includes('application/json')) {
data = await res.json();
} else {
const text = await res.text();
console.error('Non-JSON response:', text);
throw new Error(`Erro no servidor de pagamentos (${res.status}).`);
}
if (!res.ok) throw new Error(data.error || data.message || 'Erro ao processar pagamento.');
if (billingType === 'PIX') {
setPaymentData(data);
setTimeLeft(300);
} else if (billingType === 'BOLETO') {
setPaymentData(data);
} else {
setUnlocked(true);
}
} catch (err: any) {
setError(err.message || 'Erro de conexão. Tente novamente.');
} finally {
setIsPaying(false);
}
};
// Single-payment: course avulso or LIFETIME combo (allows installments)
const isSinglePayment = cycle === 'LIFETIME' || itemType === 'course';
const inputClass =
'w-full rounded-lg p-3 text-xs text-white focus:outline-none border transition-colors ' +
'bg-zinc-900 border-zinc-700 focus:border-[#E50914] placeholder-zinc-600 ' +
'[color-scheme:dark] [&:-webkit-autofill]:bg-zinc-900 [&:-webkit-autofill]:[webkit-text-fill-color:white]';
// ── SUCCESS SCREEN ─────────────────────────────────────────────────────────
if (unlocked) {
return (
<div className="w-full p-8 text-center flex flex-col items-center justify-center space-y-6">
<div className="bg-emerald-500 text-white p-4 rounded-full shadow-[0_0_25px_rgba(16,185,129,0.5)] border border-emerald-400 animate-bounce">
<Check className="w-10 h-10 stroke-[3]" />
</div>
<div className="space-y-2 max-w-md">
<h3 className="font-display font-black text-2xl text-white">Pagamento Confirmado!</h3>
<p className="text-zinc-400 text-xs leading-relaxed">
Parabéns! Seu pagamento foi aprovado. O acesso está liberado!
</p>
</div>
{onSuccess && (
<button
onClick={onSuccess}
className="glass-btn-primary text-white text-xs font-bold py-3 px-8 rounded-lg flex items-center space-x-2 shadow-lg hover:scale-105 transition-transform cursor-pointer bg-emerald-600 border-none"
>
<Play className="w-4 h-4 fill-white" />
<span>Começar a Estudar Agora</span>
</button>
)}
{onClose && (
<button onClick={onClose} className="text-zinc-500 hover:text-white text-xs cursor-pointer underline">
Fechar
</button>
)}
</div>
);
}
// ── MAIN CARD ─────────────────────────────────────────────────────────────
const inner = (
<div className="relative w-full space-y-6" translate="no">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<span className="text-[10px] uppercase tracking-widest font-extrabold text-[#E50914] bg-[#E50914]/10 px-2.5 py-1 rounded-full border border-[#E50914]/20 inline-block mb-2">
{itemType === 'plan'
? cycle === 'LIFETIME'
? 'Combo — Pagamento Único'
: cycle === 'YEARLY'
? 'Plano Anual'
: 'Assinatura Mensal'
: 'Compra Avulsa do Curso'}
</span>
<h3 className="font-display font-black text-xl text-white leading-tight">{title}</h3>
{description && <p className="text-xs text-zinc-400 mt-1">{description}</p>}
<div className="flex items-baseline gap-1 mt-3">
<span className="text-sm text-zinc-500 font-bold">R$</span>
<span className="text-3xl font-black text-white">{price.toFixed(2)}</span>
<span className="text-xs text-zinc-500 font-medium ml-1">
{cycle === 'MONTHLY' ? '/ mês' : cycle === 'YEARLY' ? '/ ano' : 'taxa única'}
</span>
</div>
</div>
{onClose && (
<button
onClick={onClose}
className="bg-black/40 text-zinc-400 hover:text-white p-2 rounded-full border border-white/5 transition-colors cursor-pointer ml-4 flex-shrink-0"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Payment Method Tabs */}
<div
className={`grid ${enableBoleto ? 'grid-cols-3' : 'grid-cols-2'} gap-2 p-1 bg-zinc-900/80 rounded-xl border border-white/5`}
>
{(['PIX', 'CREDIT_CARD', ...(enableBoleto ? ['BOLETO'] : [])] as BillingMethod[]).map(method => {
const icons: Record<BillingMethod, React.ReactNode> = {
PIX: <QrCode className="w-4 h-4 mb-1" />,
CREDIT_CARD: <CreditCard className="w-4 h-4 mb-1" />,
BOLETO: <FileText className="w-4 h-4 mb-1" />,
};
const labels: Record<BillingMethod, string> = { PIX: 'PIX', CREDIT_CARD: 'Cartão', BOLETO: 'Boleto' };
return (
<button
key={method}
type="button"
onClick={() => {
setBillingType(method);
setError(null);
setPaymentData(null);
setTimeLeft(null);
setInstallments(1);
}}
className={`flex flex-col items-center justify-center py-2.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${
billingType === method ? 'bg-[#E50914] text-white shadow-lg' : 'text-zinc-400 hover:text-white'
}`}
>
{icons[method]}
<span>{labels[method]}</span>
</button>
);
})}
</div>
{/* Error banner */}
{error && (
<div className="bg-red-950/40 border border-red-900/50 text-red-400 p-3 rounded-xl text-xs flex items-center gap-2">
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
<span>{error}</span>
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* ── PIX ────────────────────────────────────────────────────────── */}
{billingType === 'PIX' && (
<div className="space-y-4 text-center">
{!paymentData ? (
<div className="text-zinc-400 text-xs py-4">
Clique em <span className="font-bold text-white">Confirmar</span> para gerar seu QR Code PIX de{' '}
<span className="font-bold text-white">R$ {price.toFixed(2)}</span>.
</div>
) : (
<>
<div className="bg-white p-3 rounded-xl inline-block shadow-xl border border-zinc-200">
{paymentData.pixData?.encodedImage && paymentData.pixData.encodedImage.length > 20 ? (
<img
src={
paymentData.pixData.encodedImage.startsWith('data:')
? paymentData.pixData.encodedImage
: `data:image/png;base64,${paymentData.pixData.encodedImage}`
}
alt="QR Code PIX"
className="w-44 h-44 object-contain mx-auto"
/>
) : paymentData.pixData?.payload ? (
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=${encodeURIComponent(paymentData.pixData.payload)}`}
alt="QR Code PIX"
className="w-44 h-44 object-contain mx-auto"
/>
) : (
<div className="w-44 h-44 bg-zinc-100 flex items-center justify-center text-zinc-500 text-xs font-bold rounded-lg">
Gerando QR Code...
</div>
)}
</div>
<p className="text-xs text-zinc-400 max-w-sm mx-auto">
Escaneie o QR Code com o app do seu banco ou copie a chave Pix Copia e Cola abaixo.
</p>
<div className="flex items-center space-x-2 bg-zinc-900 rounded-lg p-2.5 border border-white/5">
<input
type="text"
readOnly
value={paymentData.pixData?.payload || ''}
className="bg-transparent text-zinc-300 text-xs font-mono select-all focus:outline-none flex-grow"
/>
<button
type="button"
onClick={handleCopyPix}
className="bg-zinc-800 text-xs text-white hover:bg-zinc-700 px-3 py-1.5 rounded-md font-bold transition-all cursor-pointer flex items-center space-x-1"
>
{pixCopied ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : null}
<span>{pixCopied ? 'Copiado!' : 'Copiar'}</span>
</button>
</div>
{timeLeft !== null && (
<div className="flex items-center justify-center space-x-1.5 text-xs font-semibold text-zinc-400 bg-zinc-900/60 border border-white/5 py-2 px-4 rounded-xl w-fit mx-auto">
<Clock className="w-3.5 h-3.5 text-[#E50914] animate-pulse" />
<span>Expira em:</span>
<span className="text-white font-mono font-bold">{formatTime(timeLeft)}</span>
</div>
)}
</>
)}
</div>
)}
{/* ── CREDIT CARD ──────────────────────────────────────────────── */}
{billingType === 'CREDIT_CARD' && (
<div className="space-y-3.5">
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Número do Cartão</label>
<input
type="text"
required
value={ccNumber}
onChange={e =>
setCcNumber(
e.target.value
.replace(/\D/g, '')
.replace(/(\d{4})/g, '$1 ')
.trim()
.slice(0, 19)
)
}
placeholder="0000 0000 0000 0000"
className={inputClass}
/>
</div>
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Nome impresso no Cartão</label>
<input
type="text"
required
value={ccName}
onChange={e => setCcName(e.target.value.toUpperCase())}
placeholder="NOME IGUAL DO CARTÃO"
className={inputClass}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Validade</label>
<input
type="text"
required
value={ccExpiry}
onChange={e =>
setCcExpiry(
e.target.value
.replace(/\D/g, '')
.replace(/(\d{2})(\d)/, '$1/$2')
.slice(0, 5)
)
}
placeholder="MM/AA"
className={inputClass}
/>
</div>
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">CVV</label>
<input
type="password"
required
value={ccCvv}
onChange={e => setCcCvv(e.target.value.replace(/\D/g, '').slice(0, 4))}
placeholder="123"
className={inputClass}
/>
</div>
</div>
{/* Installments only for single-payment items */}
{isSinglePayment && (
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Parcelamento</label>
<select
value={installments}
onChange={e => setInstallments(Number(e.target.value))}
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none cursor-pointer bg-zinc-900 border-white/10"
>
{[1, 2, 3, 4, 5, 6, 10, 12].map(n => (
<option key={n} value={n} className="bg-zinc-900 text-white">
{n}x de R$ {(price / n).toFixed(2)} {n === 1 ? '(À vista sem juros)' : '(sem juros)'}
</option>
))}
</select>
</div>
)}
</div>
)}
{/* ── BOLETO ───────────────────────────────────────────────────── */}
{billingType === 'BOLETO' && (
<div className="space-y-4 text-center py-4 bg-zinc-900/40 rounded-xl border border-white/5">
<div className="bg-[#E50914]/10 text-[#E50914] p-3 rounded-full inline-block">
<FileText className="w-8 h-8" />
</div>
<div className="space-y-2 px-4">
<p className="text-sm font-bold text-white">Boleto Bancário</p>
{!paymentData ? (
<p className="text-xs text-zinc-400 max-w-sm mx-auto">
O boleto será gerado no valor de R$ {price.toFixed(2)}. Prazo de compensação: 12 dias úteis.
</p>
) : (
<p className="text-xs text-zinc-400 max-w-sm mx-auto">
Boleto gerado no valor de R$ {price.toFixed(2)}.
</p>
)}
{!paymentData && enableInstallmentBoleto && isSinglePayment && (
<div className="max-w-xs mx-auto text-left mt-2 pb-2 space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider block">Parcelamento</label>
<select
value={installments}
onChange={e => setInstallments(Number(e.target.value))}
className="w-full glass-input rounded-lg p-2.5 text-xs text-white focus:outline-none cursor-pointer bg-zinc-900 border-white/10"
>
{Array.from({ length: maxBoletoInstallments }, (_, i) => i + 1).map(n => (
<option key={n} value={n} className="bg-zinc-900 text-white">
{n}x de R$ {(price / n).toFixed(2)} {n === 1 ? '(À vista)' : '(parcelado)'}
</option>
))}
</select>
</div>
)}
{paymentData?.payment?.invoiceUrl && (
<button
type="button"
onClick={() => window.open(paymentData.payment.invoiceUrl, '_blank')}
className="glass-btn-secondary text-xs text-white font-semibold py-2 px-4 rounded-lg inline-flex items-center space-x-2 mt-2 cursor-pointer"
>
<FileText className="w-4 h-4" />
<span>Visualizar e Imprimir Boleto</span>
</button>
)}
</div>
</div>
)}
{/* CPF field */}
{!paymentData && (
<div className="space-y-1">
<label className="text-zinc-400 text-xs font-bold block">CPF ou CNPJ do Titular</label>
<input
type="text"
placeholder="000.000.000-00"
value={checkoutCpf}
onChange={e => setCheckoutCpf(e.target.value)}
className="w-full bg-zinc-900 border border-white/10 rounded-xl py-3 px-4 text-xs text-white focus:border-[#E50914] focus:outline-none transition-colors"
required
/>
<p className="text-[10px] text-zinc-500">Necessário para emissão do Pix, boleto ou cartão via Asaas.</p>
</div>
)}
{/* Submit button */}
{!paymentData && (
<div className="pt-2">
<button
type="submit"
disabled={isPaying}
className="w-full bg-[#E50914] hover:bg-red-700 shadow-[0_0_20px_rgba(229,9,20,0.35)] text-white text-sm font-bold py-4 px-4 rounded-xl flex items-center justify-center space-x-2 cursor-pointer disabled:opacity-50 transition-all"
>
{isPaying ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
<span>Processando pagamento segurizado...</span>
</>
) : (
<>
<Sparkles className="w-4 h-4 text-yellow-400" />
<span>Confirmar R$ {price.toFixed(2)}</span>
</>
)}
</button>
<p className="text-center text-[10px] text-zinc-500 mt-3">
Pagamento via API Oficial Asaas. Seus dados são criptografados e protegidos.
</p>
</div>
)}
</form>
</div>
);
// ── Embedded vs Modal wrapper ──────────────────────────────────────────────
if (embedded) {
return <div className="bg-zinc-900/60 border border-white/10 rounded-2xl p-6">{inner}</div>;
}
return (
<div
id="payment-checkout-overlay"
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-fade-in overflow-y-auto"
>
<div className="relative w-full max-w-lg bg-[#141414]/98 border border-white/10 rounded-2xl shadow-2xl p-6 md:p-8 my-8">
{inner}
</div>
</div>
);
};
export default PaymentCheckoutCard;

View File

@ -1,6 +1,7 @@
import React, { useState, useRef } from 'react';
import { User as UserIcon, Shield, CreditCard, Mail, Phone, Calendar, ArrowLeft, Camera, Edit2, Save, X, Loader2 } from 'lucide-react';
import { User as UserIcon, Shield, CreditCard, Mail, Phone, Calendar, ArrowLeft, Camera, Edit2, Save, X, Loader2, Fingerprint } from 'lucide-react';
import { User } from '../types';
import { useModal } from '../contexts/ModalContext';
interface ProfileViewProps {
user: User;
@ -9,11 +10,30 @@ interface ProfileViewProps {
}
export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewProps) {
const { error: showError, success: showSuccess } = useModal();
const [isEditing, setIsEditing] = useState(false);
const [name, setName] = useState(user.name);
const [whatsapp, setWhatsapp] = useState(user.whatsapp || '');
const [birthDate, setBirthDate] = useState(user.birthDate || '');
const [cpf, setCpf] = useState(user.cpf || '');
// Address States
const [cep, setCep] = useState(user.cep || '');
const [address, setAddress] = useState(user.address || '');
const [number, setNumber] = useState(user.number || '');
const [complement, setComplement] = useState(user.complement || '');
const [reference, setReference] = useState(user.reference || '');
const [neighborhood, setNeighborhood] = useState(user.neighborhood || '');
const [city, setCity] = useState(user.city || '');
const [state, setState] = useState(user.state || '');
// Password Modal States
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isChangingPassword, setIsChangingPassword] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isUploading, setIsUploading] = useState(false);
@ -42,29 +62,68 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP
const handleSave = async () => {
setIsSaving(true);
try {
const token = localStorage.getItem('devflix_token');
const token = localStorage.getItem('app_token');
const res = await fetch('/api/users/profile', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` })
},
body: JSON.stringify({ name, whatsapp, birthDate, cpf })
body: JSON.stringify({ name, whatsapp, birthDate, cpf, cep, address, number, complement, reference, neighborhood, city, state })
});
const data = await res.json();
if (data.success && onUpdateUser) {
onUpdateUser(data.user);
setIsEditing(false);
} else {
alert(data.error || 'Erro ao atualizar perfil');
showError(data.error || 'Erro ao atualizar perfil');
}
} catch (err) {
alert('Erro de conexão ao salvar perfil');
showError('Erro de conexão ao salvar perfil');
} finally {
setIsSaving(false);
}
};
const handleChangePasswordSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!currentPassword || !newPassword || !confirmPassword) {
showError('Todos os campos são obrigatórios.');
return;
}
if (newPassword !== confirmPassword) {
showError('As senhas não coincidem.');
return;
}
setIsChangingPassword(true);
try {
const token = localStorage.getItem('app_token');
const res = await fetch('/api/users/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` })
},
body: JSON.stringify({ currentPassword, newPassword })
});
const data = await res.json();
if (res.ok) {
showSuccess('Senha alterada com sucesso!');
setShowPasswordModal(false);
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
} else {
showError(data.error || 'Erro ao alterar senha.');
}
} catch (err) {
showError('Erro de conexão ao alterar senha.');
} finally {
setIsChangingPassword(false);
}
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
@ -74,7 +133,7 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP
formData.append('avatar', file);
try {
const token = localStorage.getItem('devflix_token');
const token = localStorage.getItem('app_token');
const res = await fetch('/api/users/avatar', {
method: 'POST',
headers: {
@ -86,10 +145,10 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP
if (data.success && onUpdateUser) {
onUpdateUser({ ...user, avatarUrl: data.avatarUrl });
} else {
alert(data.error || 'Erro ao fazer upload da foto');
showError(data.error || 'Erro ao fazer upload da foto');
}
} catch (err) {
alert('Erro de conexão ao enviar foto');
showError('Erro de conexão ao enviar foto');
} finally {
setIsUploading(false);
}
@ -107,13 +166,22 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP
</button>
{!isEditing ? (
<div className="flex items-center space-x-3">
<button
onClick={() => setShowPasswordModal(true)}
className="flex items-center space-x-2 glass-btn-secondary px-4 py-2 rounded-lg text-sm font-bold text-white hover:text-[#E50914] transition-colors cursor-pointer"
>
<Fingerprint className="w-4 h-4" />
<span>Alterar Senha</span>
</button>
<button
onClick={() => setIsEditing(true)}
className="flex items-center space-x-2 glass-btn-secondary px-4 py-2 rounded-lg text-sm font-bold text-white hover:text-[#E50914] transition-colors"
className="flex items-center space-x-2 glass-btn-secondary px-4 py-2 rounded-lg text-sm font-bold text-white hover:text-[#E50914] transition-colors cursor-pointer"
>
<Edit2 className="w-4 h-4" />
<span>Editar Perfil</span>
</button>
</div>
) : (
<div className="flex items-center space-x-2">
<button
@ -123,8 +191,16 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP
setWhatsapp(user.whatsapp || '');
setBirthDate(user.birthDate || '');
setCpf(user.cpf || '');
setCep(user.cep || '');
setAddress(user.address || '');
setNumber(user.number || '');
setComplement(user.complement || '');
setReference(user.reference || '');
setNeighborhood(user.neighborhood || '');
setCity(user.city || '');
setState(user.state || '');
}}
className="flex items-center space-x-1 px-4 py-2 rounded-lg text-sm font-bold text-zinc-400 hover:text-white transition-colors"
className="flex items-center space-x-1 px-4 py-2 rounded-lg text-sm font-bold text-zinc-400 hover:text-white transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
<span>Cancelar</span>
@ -132,7 +208,7 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP
<button
onClick={handleSave}
disabled={isSaving}
className="flex items-center space-x-1 glass-btn-primary px-4 py-2 rounded-lg text-sm font-bold text-white transition-colors disabled:opacity-50"
className="flex items-center space-x-1 glass-btn-primary px-4 py-2 rounded-lg text-sm font-bold text-white transition-colors disabled:opacity-50 cursor-pointer"
>
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
<span>{isSaving ? 'Salvando...' : 'Salvar Alterações'}</span>
@ -285,6 +361,139 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP
</div>
</div>
{/* Address Area */}
<div className="border-t border-white/10 pt-6 mt-6 space-y-6">
<h3 className="text-xl font-bold text-white flex items-center space-x-2">
<span>Meu endereço</span>
</h3>
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
{/* CEP */}
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col justify-center md:col-span-4">
<p className="text-[10px] uppercase font-bold text-zinc-500 mb-1">CEP</p>
{isEditing ? (
<input
type="text"
value={cep}
onChange={(e) => setCep(e.target.value.replace(/\D/g, '').replace(/(\d{5})(\d)/, '$1-$2'))}
maxLength={9}
placeholder="00000-000"
className="w-full bg-black/50 border border-white/10 rounded-md p-1.5 text-sm text-white focus:outline-none focus:border-[#E50914]"
/>
) : (
<p className="text-sm font-medium text-white">{cep || 'Não informado'}</p>
)}
</div>
<div className="md:col-span-8"></div>
{/* Endereço */}
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col justify-center md:col-span-9">
<p className="text-[10px] uppercase font-bold text-zinc-500 mb-1">Endereço</p>
{isEditing ? (
<input
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
className="w-full bg-black/50 border border-white/10 rounded-md p-1.5 text-sm text-white focus:outline-none focus:border-[#E50914]"
/>
) : (
<p className="text-sm font-medium text-white">{address || 'Não informado'}</p>
)}
</div>
{/* Número */}
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col justify-center md:col-span-3">
<p className="text-[10px] uppercase font-bold text-zinc-500 mb-1">Número</p>
{isEditing ? (
<input
type="text"
value={number}
onChange={(e) => setNumber(e.target.value)}
className="w-full bg-black/50 border border-white/10 rounded-md p-1.5 text-sm text-white focus:outline-none focus:border-[#E50914]"
/>
) : (
<p className="text-sm font-medium text-white">{number || 'Não informado'}</p>
)}
</div>
{/* Complemento */}
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col justify-center md:col-span-6">
<p className="text-[10px] uppercase font-bold text-zinc-500 mb-1">Complemento</p>
{isEditing ? (
<input
type="text"
value={complement}
onChange={(e) => setComplement(e.target.value)}
className="w-full bg-black/50 border border-white/10 rounded-md p-1.5 text-sm text-white focus:outline-none focus:border-[#E50914]"
/>
) : (
<p className="text-sm font-medium text-white">{complement || 'Não informado'}</p>
)}
</div>
{/* Referência */}
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col justify-center md:col-span-6">
<p className="text-[10px] uppercase font-bold text-zinc-500 mb-1">Referência</p>
{isEditing ? (
<input
type="text"
value={reference}
onChange={(e) => setReference(e.target.value)}
className="w-full bg-black/50 border border-white/10 rounded-md p-1.5 text-sm text-white focus:outline-none focus:border-[#E50914]"
/>
) : (
<p className="text-sm font-medium text-white">{reference || 'Não informado'}</p>
)}
</div>
{/* Bairro */}
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col justify-center md:col-span-5">
<p className="text-[10px] uppercase font-bold text-zinc-500 mb-1">Bairro</p>
{isEditing ? (
<input
type="text"
value={neighborhood}
onChange={(e) => setNeighborhood(e.target.value)}
className="w-full bg-black/50 border border-white/10 rounded-md p-1.5 text-sm text-white focus:outline-none focus:border-[#E50914]"
/>
) : (
<p className="text-sm font-medium text-white">{neighborhood || 'Não informado'}</p>
)}
</div>
{/* Cidade */}
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col justify-center md:col-span-5">
<p className="text-[10px] uppercase font-bold text-zinc-500 mb-1">Cidade</p>
{isEditing ? (
<input
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
className="w-full bg-black/50 border border-white/10 rounded-md p-1.5 text-sm text-white focus:outline-none focus:border-[#E50914]"
/>
) : (
<p className="text-sm font-medium text-white">{city || 'Não informado'}</p>
)}
</div>
{/* Estado */}
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col justify-center md:col-span-2">
<p className="text-[10px] uppercase font-bold text-zinc-500 mb-1">Estado</p>
{isEditing ? (
<input
type="text"
value={state}
onChange={(e) => setState(e.target.value.toUpperCase())}
maxLength={2}
className="w-full bg-black/50 border border-white/10 rounded-md p-1.5 text-sm text-white focus:outline-none focus:border-[#E50914]"
/>
) : (
<p className="text-sm font-medium text-white">{state || 'Não informado'}</p>
)}
</div>
</div>
</div>
{user.role === 'admin' && (
<div className="mt-4 p-4 bg-[#E50914]/10 border border-[#E50914]/30 rounded-xl flex items-start space-x-3">
<Shield className="w-5 h-5 text-[#E50914] mt-0.5" />
@ -297,6 +506,77 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP
</div>
</div>
</div>
{showPasswordModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-fade-in p-4">
<div className="bg-zinc-900 border border-white/10 rounded-2xl w-full max-w-md p-6 relative flex flex-col space-y-4 animate-slide-up">
<button
onClick={() => setShowPasswordModal(false)}
className="absolute top-4 right-4 text-zinc-400 hover:text-white transition-colors cursor-pointer"
>
<X className="w-5 h-5" />
</button>
<div className="flex items-center space-x-2 border-b border-white/5 pb-3">
<Fingerprint className="w-6 h-6 text-[#E50914]" />
<h3 className="text-lg font-bold text-white">Alterar Senha</h3>
</div>
<form onSubmit={handleChangePasswordSubmit} className="space-y-4">
<div className="space-y-1">
<label className="text-xs text-zinc-400 font-bold uppercase tracking-wider">Senha Atual</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="w-full glass-input rounded-lg p-2.5 text-sm text-white focus:outline-none focus:border-[#E50914]"
required
/>
</div>
<div className="space-y-1">
<label className="text-xs text-zinc-400 font-bold uppercase tracking-wider">Nova Senha</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full glass-input rounded-lg p-2.5 text-sm text-white focus:outline-none focus:border-[#E50914]"
required
/>
</div>
<div className="space-y-1">
<label className="text-xs text-zinc-400 font-bold uppercase tracking-wider">Confirmar Nova Senha</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full glass-input rounded-lg p-2.5 text-sm text-white focus:outline-none focus:border-[#E50914]"
required
/>
</div>
<div className="flex justify-end space-x-3 pt-2">
<button
type="button"
onClick={() => setShowPasswordModal(false)}
className="px-4 py-2 rounded-lg text-sm font-bold text-zinc-400 hover:text-white transition-colors cursor-pointer"
>
Cancelar
</button>
<button
type="submit"
disabled={isChangingPassword}
className="glass-btn-primary px-4 py-2 rounded-lg text-sm font-bold text-white transition-colors disabled:opacity-50 flex items-center space-x-1.5 cursor-pointer"
>
{isChangingPassword && <Loader2 className="w-4 h-4 animate-spin" />}
<span>{isChangingPassword ? 'Alterando...' : 'Alterar Senha'}</span>
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -1,14 +1,17 @@
import React, { useState, useEffect } from 'react';
import {
CreditCard, CheckCircle, RefreshCw, AlertTriangle, Play, Calendar, DollarSign,
ShieldCheck, ArrowRight, ExternalLink, QrCode
CreditCard, CheckCircle, RefreshCw, AlertTriangle, Calendar, ExternalLink,
ShieldCheck, Layers, Sparkles, Clock3
} from 'lucide-react';
import { SubscriptionStatus, SubscriptionPayment } from '../types';
import { Plan } from '../types';
import PaymentCheckoutCard from './PaymentCheckoutCard';
interface SubscriptionViewProps {
user: {
name: string;
email: string;
cpf?: string;
subscriptionStatus: SubscriptionStatus;
} | null;
token: string | null;
@ -20,42 +23,21 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
const [subStatus, setSubStatus] = useState<SubscriptionStatus | null>(user ? user.subscriptionStatus : null);
const [isLoading, setIsLoading] = useState(true);
// Checkout flow state
const [billingType, setBillingType] = useState<'PIX' | 'CREDIT_CARD' | 'BOLETO'>('PIX');
const [cardName, setCardName] = useState('');
const [cardNumber, setCardNumber] = useState('');
const [cardExpiry, setCardExpiry] = useState('');
const [cardCvv, setCardCvv] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [successPayload, setSuccessPayload] = useState<any>(null);
const [checkoutError, setCheckoutError] = useState<string | null>(null);
const [enableBoleto, setEnableBoleto] = useState<boolean>(true);
// Plans
const [availablePlans, setAvailablePlans] = useState<Plan[]>([]);
const [loadingPlans, setLoadingPlans] = useState(true);
const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
useEffect(() => {
fetchSubscriptionDetails();
if (token) {
fetch('/api/settings', {
headers: { 'Authorization': `Bearer ${token}` }
})
.then(res => res.json())
.then(data => {
if (data && data.enableBoleto === false) {
setEnableBoleto(false);
}
})
.catch(() => {});
}
fetchPlans();
}, []);
const fetchSubscriptionDetails = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/subscription/status', {
headers: {
'Authorization': `Bearer ${token}`
}
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
@ -70,55 +52,20 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
}
};
const handleCheckout = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setCheckoutError(null);
setSuccessPayload(null);
const creditCardInfo = billingType === 'CREDIT_CARD' ? {
creditCard: {
holderName: cardName,
number: cardNumber.replace(/\s+/g, ''),
expiryMonth: cardExpiry.split('/')[0]?.trim(),
expiryYear: '20' + cardExpiry.split('/')[1]?.trim(),
ccv: cardCvv
},
creditCardHolderInfo: {
name: cardName,
email: user?.email,
cpfCnpj: '000.000.000-00',
postalCode: '01001-000',
addressNumber: '123',
phone: '11999999999'
}
} : undefined;
const fetchPlans = async () => {
setLoadingPlans(true);
try {
const res = await fetch('/api/subscription/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
billingType,
creditCardInfo
})
const res = await fetch('/api/plans', {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Erro ao processar assinatura');
if (res.ok) {
const data: Plan[] = await res.json();
setAvailablePlans(data);
}
setSuccessPayload(data);
// Wait a moment and fetch updated profile status
await fetchSubscriptionDetails();
} catch (err: any) {
setCheckoutError(err.message || 'Erro de rede. Tente novamente.');
} catch (err) {
console.error('Error fetching plans:', err);
} finally {
setIsSubmitting(false);
setLoadingPlans(false);
}
};
@ -142,6 +89,15 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
}
};
const getCycleLabel = (cycle: string) => {
switch (cycle) {
case 'MONTHLY': return '/ mês';
case 'YEARLY': return '/ ano';
case 'LIFETIME': return 'pagamento único';
default: return '';
}
};
const getPaymentStatusTag = (status: string) => {
switch (status) {
case 'CONFIRMED':
@ -165,27 +121,51 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
);
}
const hasPlans = availablePlans.length > 0;
return (
<div className="space-y-8 max-w-4xl mx-auto">
{/* Top Title */}
{/* Title */}
<div className="border-b border-white/10 pb-4">
<h1 className="text-2xl md:text-3xl font-display font-extrabold text-white tracking-tight flex items-center space-x-2">
<CreditCard className="w-8 h-8 text-[#E50914]" />
<span>Gestão de Assinatura & Cobranças</span>
<span>Minha Assinatura & Cobranças</span>
</h1>
<p className="text-xs text-zinc-500 mt-1">Veja seus comprovantes, históricos de faturamento e mude seu plano de estudos.</p>
<p className="text-xs text-zinc-500 mt-1">
Veja seus comprovantes, histórico de faturamento e adquira planos ou cursos.
</p>
</div>
{/* Main Grid: Status summary left, checkout right */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Checkout Modal (when a plan is selected) */}
{selectedPlan && (
<PaymentCheckoutCard
itemId={selectedPlan.id}
itemType="plan"
title={selectedPlan.title}
price={selectedPlan.price}
cycle={selectedPlan.cycle as any}
description={selectedPlan.description || undefined}
user={user}
token={token}
onClose={() => setSelectedPlan(null)}
onSuccess={() => {
setSelectedPlan(null);
fetchSubscriptionDetails();
}}
/>
)}
{/* Left Hand: Current Plan details & invoice history */}
<div className={`grid grid-cols-1 ${hasPlans ? 'md:grid-cols-2' : ''} gap-8`}>
{/* LEFT: Account status + Invoice history */}
<div className="space-y-6">
{/* Account Status Card */}
<div className="glass-card p-6 rounded-2xl space-y-5">
<h3 className="font-display font-bold text-sm text-zinc-400 uppercase tracking-wider">Sua Conta</h3>
<div className="flex items-center space-x-4">
<div className="w-12 h-12 rounded-full glass-badge flex items-center justify-center text-zinc-300">
<div className="w-12 h-12 rounded-full glass-badge flex items-center justify-center">
<ShieldCheck className="w-6 h-6 text-[#E50914]" />
</div>
<div className="space-y-0.5">
@ -194,7 +174,7 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
</div>
</div>
<div className="border-t border-zinc-850 pt-4 space-y-2">
<div className="border-t border-zinc-800 pt-4 space-y-2">
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-wider">Status Atual do Acesso</p>
{subStatus && (
<div className={`inline-block px-3 py-1.5 rounded-lg border text-sm font-extrabold ${getStatusColor(subStatus)}`}>
@ -204,37 +184,33 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
{subStatus === 'TRIAL' && (
<p className="text-xs text-zinc-400 font-medium pt-1">
Seu período de teste está ativo. Para continuar com acesso ilimitado a todos os cursos, módulos e certificados, ative sua assinatura mensal abaixo.
Seu período de teste está ativo. Para continuar com acesso completo, adquira um dos planos abaixo.
</p>
)}
{subStatus === 'ACTIVE' && (
<p className="text-xs text-emerald-500/80 font-bold flex items-center space-x-1 pt-1">
<CheckCircle className="w-4 h-4 fill-emerald-500/10" />
<span>Acesso integral liberado a todos os cursos de informática, hardware e servidores!</span>
<span>Acesso integral liberado a todos os cursos!</span>
</p>
)}
{subStatus === 'OVERDUE' && (
<p className="text-xs text-red-500/80 font-semibold flex items-center space-x-1 pt-1">
<AlertTriangle className="w-4 h-4 fill-red-500/10" />
<span>Seu acesso foi bloqueado por falta de pagamento. Regularize via Pix ou Cartão para liberar.</span>
<span>Seu acesso foi bloqueado por falta de pagamento. Regularize via um dos planos abaixo.</span>
</p>
)}
</div>
</div>
{/* Past Payments History */}
{/* Invoice History */}
<div className="glass-card p-6 rounded-2xl space-y-4">
<h3 className="font-display font-bold text-sm text-zinc-400 uppercase tracking-wider">Faturas e Segunda Via</h3>
<div className="space-y-3">
{payments.length === 0 ? (
<p className="text-xs text-zinc-500 italic">Nenhum pagamento registrado nesta conta.</p>
) : (
payments.map(pay => (
<div
key={pay.id}
className="p-3.5 glass-badge rounded-xl flex items-center justify-between"
>
<div key={pay.id} className="p-3.5 glass-badge rounded-xl flex items-center justify-between">
<div className="space-y-1">
<div className="flex items-center space-x-2">
<span className="text-white text-xs font-bold">R$ {pay.value.toFixed(2)}</span>
@ -245,14 +221,13 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
<span>Vence em: {new Date(pay.dueDate).toLocaleDateString('pt-BR')}</span>
</p>
</div>
<a
href={pay.invoiceUrl}
target="_blank"
rel="noreferrer"
className="text-xs text-zinc-400 hover:text-[#E50914] glass-btn-secondary px-3 py-1.5 rounded-lg font-bold transition-all flex items-center space-x-1"
>
<span>Boleto / Pix / Detalhes</span>
<span>Detalhes</span>
<ExternalLink className="w-3 h-3" />
</a>
</div>
@ -262,177 +237,70 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
</div>
</div>
{/* Right Hand: Checkout Form (Create Subscription) */}
{/* RIGHT: Plans (only shown if plans exist) */}
{hasPlans && (
<div className="space-y-6">
<div className="glass-card p-6 rounded-2xl space-y-6">
<div className="space-y-1">
<h3 className="font-display font-extrabold text-lg text-white tracking-tight">Assine o Plano Ilimitado</h3>
<p className="text-xs text-zinc-500 font-semibold flex items-center space-x-1">
<span>Trilha completa DevFlix por apenas</span>
<span className="text-[#E50914] font-black text-sm">R$ 49,90/mês</span>
<div className="glass-card p-6 rounded-2xl space-y-4">
<div className="flex items-center space-x-2">
<Sparkles className="w-5 h-5 text-[#E50914]" />
<h3 className="font-display font-bold text-sm text-zinc-400 uppercase tracking-wider">
Planos & Combos Disponíveis
</h3>
</div>
<p className="text-xs text-zinc-500">
Escolha o plano ideal e adquira acesso aos conteúdos da plataforma.
</p>
</div>
{/* Billing Types Toggle */}
<div className={`grid ${enableBoleto ? 'grid-cols-3' : 'grid-cols-2'} gap-2 p-1 glass-badge rounded-lg`}>
<button
type="button"
onClick={() => { setBillingType('PIX'); setCheckoutError(null); }}
className={`py-2 text-xs font-bold rounded-md transition-all cursor-pointer ${billingType === 'PIX' ? 'bg-[#E50914] text-white' : 'text-zinc-400 hover:text-zinc-200'}`}
>
PIX
</button>
<button
type="button"
onClick={() => { setBillingType('CREDIT_CARD'); setCheckoutError(null); }}
className={`py-2 text-xs font-bold rounded-md transition-all cursor-pointer ${billingType === 'BOLETO' && !enableBoleto ? 'bg-[#E50914] text-white' : billingType === 'CREDIT_CARD' ? 'bg-[#E50914] text-white' : 'text-zinc-400 hover:text-zinc-200'}`}
>
Cartão de Crédito
</button>
{enableBoleto && (
<button
type="button"
onClick={() => { setBillingType('BOLETO'); setCheckoutError(null); }}
className={`py-2 text-xs font-bold rounded-md transition-all cursor-pointer ${billingType === 'BOLETO' ? 'bg-[#E50914] text-white' : 'text-zinc-400 hover:text-zinc-200'}`}
>
Boleto
</button>
)}
</div>
{/* Error alerts */}
{checkoutError && (
<div className="p-3 bg-red-950/40 border border-red-900/50 rounded-lg text-xs text-red-400">
{checkoutError}
</div>
)}
{/* SUCCESS PAYLOAD (PIX QR CODE OR CREDIT CARD CLEAR) */}
{successPayload ? (
<div className="bg-zinc-900/60 border border-emerald-950 p-5 rounded-xl space-y-4 text-center">
<div className="w-10 h-10 bg-emerald-500/10 text-emerald-500 rounded-full flex items-center justify-center mx-auto">
<CheckCircle className="w-6 h-6 fill-emerald-500/10" />
</div>
<div className="space-y-1">
<h4 className="text-sm font-bold text-white">Inscrição criada com sucesso!</h4>
<p className="text-xs text-zinc-400">
Sua assinatura está sendo faturada e integrada com o Asaas.
</p>
</div>
{billingType === 'PIX' && (
<div className="space-y-3 pt-2">
<p className="text-[11px] text-zinc-400 max-w-sm mx-auto">
Para sua segurança, os pagamentos recorrentes via PIX e Boleto são processados diretamente no ambiente seguro do Asaas. Clique no botão abaixo para escanear o QR Code oficial ou copiar a linha digitável.
</p>
</div>
)}
<div className="pt-2">
<a
href={successPayload.payment?.invoiceUrl}
target="_blank"
rel="noreferrer"
className="w-full bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-bold py-2.5 px-4 rounded-lg transition-colors flex items-center justify-center space-x-1.5"
>
<span>Abrir Fatura Oficial (Asaas)</span>
<ExternalLink className="w-4 h-4" />
</a>
</div>
{loadingPlans ? (
<div className="flex justify-center py-8">
<RefreshCw className="w-6 h-6 animate-spin text-[#E50914]" />
</div>
) : (
/* ACTIVE CHECKOUT FORM */
<form onSubmit={handleCheckout} className="space-y-4">
{billingType === 'CREDIT_CARD' ? (
<div className="space-y-4">
<div className="space-y-1">
<label className="text-[10px] text-zinc-500 font-bold uppercase">Nome impresso no Cartão</label>
<input
type="text"
required
value={cardName}
onChange={(e) => setCardName(e.target.value)}
placeholder="Ex: SIDNEY GOMES"
className="w-full glass-input rounded-lg p-2.5 text-xs text-white focus:outline-none"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] text-zinc-500 font-bold uppercase">Número do Cartão de Crédito</label>
<input
type="text"
required
maxLength={19}
value={cardNumber}
onChange={(e) => setCardNumber(e.target.value.replace(/\s?/g, '').replace(/(\d{4})/g, '$1 ').trim())}
placeholder="4551 1234 5678 9012"
className="w-full glass-input rounded-lg p-2.5 text-xs text-white focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-[10px] text-zinc-500 font-bold uppercase">Vencimento (MM/AA)</label>
<input
type="text"
required
maxLength={5}
placeholder="12/29"
value={cardExpiry}
onChange={(e) => setCardExpiry(e.target.value)}
className="w-full glass-input rounded-lg p-2.5 text-xs text-white focus:outline-none"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] text-zinc-500 font-bold uppercase">CVV</label>
<input
type="password"
required
maxLength={4}
placeholder="123"
value={cardCvv}
onChange={(e) => setCardCvv(e.target.value)}
className="w-full glass-input rounded-lg p-2.5 text-xs text-white focus:outline-none"
/>
</div>
</div>
</div>
) : (
/* PIX or BOLETO Info */
<div className="p-4 glass-badge rounded-xl text-xs text-zinc-400 space-y-2 leading-relaxed">
<p className="font-semibold text-white">Informações da Assinatura:</p>
<p>
- Faturamento recorrente mensal (R$ 49,90) via {billingType}.
</p>
<p>
- O link para pagamento, Pix Copia e Cola e código de barras serão gerados dinamicamente de forma integrada com a API oficial do Asaas.
</p>
</div>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full glass-btn-primary text-white text-xs font-bold py-3 px-4 rounded-lg flex items-center justify-center space-x-1.5 cursor-pointer disabled:opacity-50"
<div className="space-y-3">
{availablePlans.map(plan => (
<div
key={plan.id}
className="relative p-4 rounded-xl border border-white/10 hover:border-[#E50914]/50 bg-zinc-900/50 hover:bg-zinc-800/60 transition-all cursor-pointer group"
onClick={() => setSelectedPlan(plan)}
>
{isSubmitting ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
<span>Registrando no Asaas...</span>
</>
) : (
<>
<span>Confirmar Assinatura - R$ 49,90/mês</span>
<ArrowRight className="w-4 h-4" />
</>
)}
</button>
</form>
)}
</div>
{plan.cycle === 'LIFETIME' && (
<div className="absolute top-0 right-0 bg-purple-600 text-white text-[9px] font-bold px-3 py-1 rounded-bl-lg rounded-tr-lg uppercase tracking-wider">
Combo
</div>
)}
<div className="flex items-start gap-3">
<div className="mt-0.5">
<Layers className="w-5 h-5 text-[#E50914]" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-bold text-white text-sm leading-tight">{plan.title}</h4>
{plan.description && (
<p className="text-xs text-zinc-400 mt-0.5">{plan.description}</p>
)}
{plan.includedCourses && plan.includedCourses.length === 0 && (
<p className="text-xs text-emerald-400 mt-0.5 font-medium"> Acesso global a todos os cursos</p>
)}
<div className="flex items-baseline gap-1 mt-2">
<span className="text-[11px] text-zinc-500 font-bold">R$</span>
<span className="text-xl font-black text-white">{plan.price.toFixed(2)}</span>
<span className="text-[11px] text-zinc-500 ml-1">{getCycleLabel(plan.cycle)}</span>
</div>
</div>
<div className="flex-shrink-0 flex items-center justify-center">
<div className="text-[10px] font-bold text-[#E50914] bg-[#E50914]/10 border border-[#E50914]/20 px-3 py-1.5 rounded-lg group-hover:bg-[#E50914] group-hover:text-white transition-all whitespace-nowrap flex items-center gap-1">
<Clock3 className="w-3 h-3" />
Contratar
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
</div>
);

View File

@ -0,0 +1,53 @@
import React from 'react';
interface ToggleSwitchProps {
checked: boolean;
onChange: (checked: boolean) => void;
label?: string;
disabled?: boolean;
id?: string;
}
export default function ToggleSwitch({ checked, onChange, label, disabled = false, id }: ToggleSwitchProps) {
const uniqueId = id || `toggle-${Math.random().toString(36).slice(2, 9)}`;
return (
<label
htmlFor={uniqueId}
className={`inline-flex items-center gap-2.5 ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
>
<div className="relative">
<input
id={uniqueId}
type="checkbox"
className="sr-only peer"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
/>
{/* Track */}
<div
className={`
w-11 h-6 rounded-full border transition-all duration-300
${checked
? 'bg-[#E50914] border-[#E50914] shadow-[0_0_10px_rgba(229,9,20,0.35)]'
: 'bg-zinc-800 border-zinc-700'
}
`}
/>
{/* Thumb */}
<div
className={`
absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow-md
transition-all duration-300 transform
${checked ? 'translate-x-5' : 'translate-x-0'}
`}
/>
</div>
{label && (
<span className="text-sm font-medium text-zinc-300 select-none">
{label}
</span>
)}
</label>
);
}

View File

@ -1,5 +1,5 @@
import React, { useRef, useState, useEffect } from 'react';
import { Play, Pause, RotateCcw, Volume2, Maximize, CheckCircle, ExternalLink } from 'lucide-react';
import { Play, Pause, RotateCcw, Volume2, Maximize, CheckCircle, ExternalLink, ThumbsUp, ThumbsDown } from 'lucide-react';
interface VideoPlayerProps {
videoUrl: string;
@ -7,9 +7,11 @@ interface VideoPlayerProps {
lessonId: string;
onProgressUpdate: (percentage: number) => void;
initialProgress?: number;
reaction?: string | null;
onReact?: (type: string | null) => void;
}
export default function VideoPlayer({ videoUrl, videoType, lessonId, onProgressUpdate, initialProgress = 0 }: VideoPlayerProps) {
export default function VideoPlayer({ videoUrl, videoType, lessonId, onProgressUpdate, initialProgress = 0, reaction, onReact }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
@ -221,7 +223,24 @@ export default function VideoPlayer({ videoUrl, videoType, lessonId, onProgressU
/>
</div>
<div className="w-full sm:w-auto flex justify-end">
<div className="w-full sm:w-auto flex justify-end items-center gap-2">
{onReact && (
<div className="flex items-center space-x-1 mr-2 border-r border-white/10 pr-3">
<button
onClick={() => onReact(reaction === 'LIKE' ? null : 'LIKE')}
className={`p-2 rounded-full transition-colors ${reaction === 'LIKE' ? 'bg-[#E50914] text-white' : 'text-zinc-400 hover:text-white hover:bg-white/10'}`}
>
<ThumbsUp className="w-4 h-4" />
</button>
<button
onClick={() => onReact(reaction === 'DISLIKE' ? null : 'DISLIKE')}
className={`p-2 rounded-full transition-colors ${reaction === 'DISLIKE' ? 'bg-[#E50914] text-white' : 'text-zinc-400 hover:text-white hover:bg-white/10'}`}
>
<ThumbsDown className="w-4 h-4" />
</button>
</div>
)}
{!isCompleted ? (
<button
onClick={triggerManualCompletion}

View File

@ -0,0 +1,151 @@
import React, { useState, useEffect } from 'react';
import { Smartphone, QrCode, LogOut, CheckCircle2, Loader2, AlertCircle } from 'lucide-react';
interface WhatsappConnectCardProps {
token: string | null;
}
export const WhatsappConnectCard: React.FC<WhatsappConnectCardProps> = ({ token }) => {
const [loading, setLoading] = useState(false);
const [qrCode, setQrCode] = useState<string | null>(null);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchStatus = async () => {
if (!token) return;
setLoading(true);
setError(null);
try {
const res = await fetch('/api/admin/whatsapp/qrcode', {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Erro ao comunicar com WhatsApp');
if (data.connected) {
setConnected(true);
setQrCode(null);
} else if (data.qrCode) {
setQrCode(data.qrCode);
setConnected(false);
}
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStatus();
}, [token]);
const handleLogout = async () => {
if (!token) return;
setLoading(true);
try {
await fetch('/api/admin/whatsapp/logout', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
setConnected(false);
setQrCode(null);
} catch (err) {
console.error('Logout error:', err);
} finally {
setLoading(false);
}
};
return (
<div className="glass-card rounded-2xl p-6 border border-white/5 space-y-6 mt-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="bg-green-500/20 p-2.5 rounded-xl">
<Smartphone className="w-5 h-5 text-green-500" />
</div>
<div>
<h3 className="text-sm font-bold text-white uppercase tracking-wider">Conexão WhatsApp (Evolution API)</h3>
<p className="text-xs text-zinc-400">Escaneie o QR Code para conectar a API e enviar notificações automáticas.</p>
</div>
</div>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-3 flex items-start space-x-3">
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-xs text-red-400">{error}</p>
</div>
)}
<div className="bg-black/30 rounded-xl p-6 flex flex-col items-center justify-center border border-white/5 min-h-[250px]">
{loading ? (
<div className="flex flex-col items-center space-y-3">
<Loader2 className="w-8 h-8 text-green-500 animate-spin" />
<p className="text-xs text-zinc-400">Processando conexão...</p>
</div>
) : connected ? (
<div className="flex flex-col items-center space-y-4">
<div className="bg-green-500/20 p-4 rounded-full">
<CheckCircle2 className="w-12 h-12 text-green-500" />
</div>
<div className="text-center">
<h4 className="text-sm font-bold text-white">WhatsApp Conectado!</h4>
<p className="text-xs text-zinc-400 mt-1">A plataforma pode enviar notificações automáticas.</p>
</div>
<button
onClick={handleLogout}
className="mt-4 flex items-center space-x-2 bg-red-500/10 hover:bg-red-500/20 text-red-500 text-xs font-bold px-4 py-2 rounded-lg transition-colors cursor-pointer"
>
<LogOut className="w-4 h-4" />
<span>Desconectar Instância</span>
</button>
</div>
) : qrCode ? (
<div className="flex flex-col items-center space-y-4">
<div className="bg-white p-2 rounded-xl">
<img src={qrCode.startsWith('data:') ? qrCode : `data:image/png;base64,${qrCode}`} alt="WhatsApp QR Code" className="w-48 h-48 object-contain" />
</div>
<p className="text-xs text-zinc-400 text-center max-w-xs">
Abra o WhatsApp no seu celular, em "Aparelhos Conectados" e escaneie o código acima.
</p>
<div className="flex items-center space-x-4 mt-2">
<button
onClick={fetchStatus}
className="text-xs text-green-400 hover:text-green-300 transition-colors cursor-pointer font-bold"
>
escaneei (Atualizar)
</button>
<button
onClick={handleLogout}
className="text-xs text-red-400 hover:text-red-300 transition-colors cursor-pointer font-bold"
>
Resetar Conexão
</button>
</div>
</div>
) : (
<div className="flex flex-col items-center space-y-4">
<QrCode className="w-12 h-12 text-zinc-600" />
<p className="text-xs text-zinc-400">Nenhuma instância conectada.</p>
<div className="flex items-center space-x-3">
<button
onClick={fetchStatus}
className="bg-green-600 hover:bg-green-500 text-white text-xs font-bold px-4 py-2 rounded-lg transition-colors cursor-pointer"
>
Gerar QR Code de Conexão
</button>
<button
onClick={handleLogout}
className="bg-red-500/10 hover:bg-red-500/20 text-red-500 text-xs font-bold px-4 py-2 rounded-lg transition-colors cursor-pointer"
>
Forçar Exclusão / Resetar
</button>
</div>
</div>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,203 @@
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import { AlertTriangle, CheckCircle, Info, HelpCircle, X, Trash2 } from 'lucide-react';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ModalType = 'alert' | 'confirm' | 'success' | 'error' | 'info';
interface ModalOptions {
type?: ModalType;
title?: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
onConfirm?: () => void | Promise<void>;
onCancel?: () => void;
/** Set to true for destructive (delete) confirmations */
danger?: boolean;
}
interface ModalContextValue {
showModal: (options: ModalOptions) => void;
alert: (message: string, title?: string) => void;
success: (message: string, title?: string) => void;
error: (message: string, title?: string) => void;
confirm: (message: string, onConfirm: () => void | Promise<void>, options?: Partial<ModalOptions>) => void;
}
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
const ModalContext = createContext<ModalContextValue | null>(null);
export const useModal = () => {
const ctx = useContext(ModalContext);
if (!ctx) throw new Error('useModal must be used inside <ModalProvider>');
return ctx;
};
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export const ModalProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [modal, setModal] = useState<(ModalOptions & { isOpen: boolean }) | null>(null);
const [isConfirming, setIsConfirming] = useState(false);
const close = useCallback(() => {
setModal(null);
setIsConfirming(false);
}, []);
const showModal = useCallback((options: ModalOptions) => {
setModal({ ...options, isOpen: true });
}, []);
const alert = useCallback((message: string, title = 'Aviso') => {
showModal({ type: 'alert', title, message });
}, [showModal]);
const success = useCallback((message: string, title = 'Sucesso') => {
showModal({ type: 'success', title, message });
}, [showModal]);
const error = useCallback((message: string, title = 'Erro') => {
showModal({ type: 'error', title, message });
}, [showModal]);
const confirm = useCallback(
(message: string, onConfirm: () => void | Promise<void>, options: Partial<ModalOptions> = {}) => {
showModal({
type: 'confirm',
title: options.title || 'Confirmar',
message,
confirmLabel: options.confirmLabel || 'Confirmar',
cancelLabel: options.cancelLabel || 'Cancelar',
danger: options.danger,
onConfirm,
});
},
[showModal]
);
const handleConfirm = async () => {
if (!modal?.onConfirm) { close(); return; }
setIsConfirming(true);
try {
await modal.onConfirm();
} finally {
close();
}
};
const handleCancel = () => {
modal?.onCancel?.();
close();
};
const type = modal?.type || 'alert';
const iconMap: Record<ModalType, React.ReactNode> = {
alert: <AlertTriangle className="w-7 h-7 text-amber-400" />,
confirm: modal?.danger ? <Trash2 className="w-7 h-7 text-red-400" /> : <HelpCircle className="w-7 h-7 text-blue-400" />,
success: <CheckCircle className="w-7 h-7 text-emerald-400" />,
error: <AlertTriangle className="w-7 h-7 text-red-400" />,
info: <Info className="w-7 h-7 text-blue-400" />,
};
const bgMap: Record<ModalType, string> = {
alert: 'bg-amber-500/10 border-amber-500/20',
confirm: modal?.danger ? 'bg-red-500/10 border-red-500/20' : 'bg-blue-500/10 border-blue-500/20',
success: 'bg-emerald-500/10 border-emerald-500/20',
error: 'bg-red-500/10 border-red-500/20',
info: 'bg-blue-500/10 border-blue-500/20',
};
const confirmBtnMap: Record<string, string> = {
alert: 'bg-amber-500 hover:bg-amber-400 text-black',
success: 'bg-emerald-600 hover:bg-emerald-500 text-white',
error: 'bg-red-600 hover:bg-red-500 text-white',
info: 'bg-blue-600 hover:bg-blue-500 text-white',
confirm_normal: 'bg-blue-600 hover:bg-blue-500 text-white',
confirm_danger: 'bg-red-600 hover:bg-red-500 text-white',
};
const getConfirmBtnClass = () => {
if (type === 'confirm') return modal?.danger ? confirmBtnMap['confirm_danger'] : confirmBtnMap['confirm_normal'];
return confirmBtnMap[type] || confirmBtnMap['alert'];
};
return (
<ModalContext.Provider value={{ showModal, alert, success, error, confirm }}>
{children}
{/* Modal overlay */}
{modal?.isOpen && (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-fade-in"
onClick={(e) => { if (e.target === e.currentTarget && type !== 'confirm') handleCancel(); }}
>
<div className="relative w-full max-w-md bg-[#1a1a1a] border border-white/10 rounded-2xl shadow-2xl overflow-hidden animate-slide-up">
{/* Top icon stripe */}
<div className={`border-b border-white/5 p-5 flex items-center gap-4 ${bgMap[type]}`}>
<div className="flex-shrink-0">
{iconMap[type]}
</div>
<div>
<h3 className="font-display font-black text-base text-white leading-tight">
{modal.title || 'Aviso'}
</h3>
</div>
{type !== 'confirm' && (
<button
onClick={handleCancel}
className="absolute top-4 right-4 text-zinc-500 hover:text-white transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Body */}
<div className="p-6">
<p className="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap">
{modal.message}
</p>
</div>
{/* Actions */}
<div className="px-6 pb-6 flex gap-3 justify-end">
{type === 'confirm' && (
<button
onClick={handleCancel}
className="px-5 py-2.5 text-sm font-bold text-zinc-300 bg-zinc-800 hover:bg-zinc-700 border border-white/10 rounded-xl transition-colors cursor-pointer"
>
{modal.cancelLabel || 'Cancelar'}
</button>
)}
<button
onClick={type === 'confirm' ? handleConfirm : handleCancel}
disabled={isConfirming}
className={`px-5 py-2.5 text-sm font-bold rounded-xl transition-colors cursor-pointer disabled:opacity-60 flex items-center gap-2 ${getConfirmBtnClass()}`}
>
{isConfirming && (
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
</svg>
)}
{type === 'confirm' ? (modal.confirmLabel || 'Confirmar') : 'OK'}
</button>
</div>
</div>
</div>
)}
</ModalContext.Provider>
);
};
export default ModalProvider;

View File

@ -95,3 +95,58 @@ html, body {
backdrop-filter: blur(6px);
border: 1px solid rgba(255, 255, 255, 0.08);
}
/* ── Modal system animations ── */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(20px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.animate-fade-in {
animation: fade-in 0.18s ease-out forwards;
}
.animate-slide-up {
animation: slide-up 0.22s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
/* ── Custom scrollbar ── */
.custom-scrollbar::-webkit-scrollbar { width: 4px; height: 4px; }
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(229,9,20,0.35); border-radius: 99px; }
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: rgba(229,9,20,0.6); }
/* ── Lesson shelf animation ── */
@keyframes lesson-shelf-in {
from { opacity: 0; transform: translateY(-12px); max-height: 0; }
to { opacity: 1; transform: translateY(0); max-height: 600px; }
}
.lesson-shelf-enter {
animation: lesson-shelf-in 0.38s cubic-bezier(0.4, 0, 0.2, 1) forwards;
overflow: hidden;
}
/* ── Fix autocomplete white background on dark inputs ── */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 60px #1a1a1a inset !important;
-webkit-text-fill-color: #fff !important;
caret-color: #fff;
transition: background-color 5000s ease-in-out 0s;
}
/* ── Fix select dropdown options styling globally ── */
select {
color-scheme: dark;
}
select option {
background-color: #1a1a1a;
color: #ffffff;
}

View File

@ -1,20 +1,20 @@
import { SubscriptionPayment } from '../types';
import { loadDb } from '../db/db';
import { prisma } from '../db/prisma';
export class AsaasService {
public static getApiKey(): string | null {
const db = loadDb();
return db.settings?.asaasApiKey || process.env.ASAAS_API_KEY || null;
public static async getApiKey(): Promise<string | null> {
const db = await prisma.systemSettings.findUnique({ where: { id: 'default' } });
return db?.asaasApiKey || process.env.ASAAS_API_KEY || null;
}
private static getApiUrl(): string {
const db = loadDb();
const env = db.settings?.asaasEnvironment || 'sandbox';
private static async getApiUrl(): Promise<string> {
const db = await prisma.systemSettings.findUnique({ where: { id: 'default' } });
const env = db?.asaasEnvironment || 'sandbox';
return env === 'production' ? 'https://api.asaas.com/v3' : 'https://sandbox.asaas.com/api/v3';
}
private static getHeaders() {
const key = this.getApiKey();
private static async getHeaders(): Promise<Record<string, string>> {
const key = await this.getApiKey();
return {
'Content-Type': 'application/json',
'access_token': key || ''
@ -25,8 +25,9 @@ export class AsaasService {
* Test the connection to Asaas API
*/
public static async testConnection(customApiKey?: string, customEnv?: string): Promise<{ success: boolean; message: string; environment: string }> {
const apiKey = customApiKey || this.getApiKey();
const env = customEnv || loadDb().settings?.asaasEnvironment || 'sandbox';
const apiKey = customApiKey || await this.getApiKey();
const db = await prisma.systemSettings.findUnique({ where: { id: 'default' } });
const env = customEnv || db?.asaasEnvironment || 'sandbox';
const apiUrl = env === 'production' ? 'https://api.asaas.com/v3' : 'https://sandbox.asaas.com/v3';
if (!apiKey || apiKey.trim() === '') {
@ -74,7 +75,7 @@ export class AsaasService {
* Create a customer in Asaas
*/
public static async createCustomer(name: string, email: string, cpfCnpj?: string): Promise<string> {
const apiKey = this.getApiKey();
const apiKey = await this.getApiKey();
if (!apiKey) {
console.warn('ASAAS_API_KEY not configured. Simulating customer creation.');
return `cus_sim_${Math.random().toString(36).substring(2, 9)}`;
@ -91,9 +92,9 @@ export class AsaasService {
}
try {
const response = await fetch(`${this.getApiUrl()}/customers`, {
const response = await fetch(`${await this.getApiUrl()}/customers`, {
method: 'POST',
headers: this.getHeaders(),
headers: await this.getHeaders(),
body: JSON.stringify(payload)
});
@ -111,6 +112,39 @@ export class AsaasService {
}
}
/**
* Update an existing customer in Asaas
*/
public static async updateCustomer(customerId: string, data: { name?: string, email?: string, cpfCnpj?: string }): Promise<void> {
const apiKey = await this.getApiKey();
if (!apiKey || customerId.startsWith('cus_sim_') || customerId.startsWith('cus_fb_') || customerId === 'cus_test' || ['cus_00001', 'cus_00002', 'cus_00003', 'cus_00004'].includes(customerId)) {
return;
}
const payload: any = {};
if (data.name) payload.name = data.name;
if (data.email) payload.email = data.email;
if (data.cpfCnpj && data.cpfCnpj !== '000.000.000-00') {
payload.cpfCnpj = data.cpfCnpj.replace(/\D/g, '');
}
try {
const response = await fetch(`${await this.getApiUrl()}/customers/${customerId}`, {
method: 'POST',
headers: await this.getHeaders(),
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const desc = errorData.errors?.[0]?.description || `HTTP ${response.status}`;
console.error(`Falha ao atualizar cliente no Asaas: ${desc}`);
}
} catch (err) {
console.error('Asaas updateCustomer error:', err);
}
}
/**
* Create a recurring subscription in Asaas
*/
@ -120,9 +154,9 @@ export class AsaasService {
billingType: 'PIX' | 'CREDIT_CARD' | 'BOLETO',
creditCardInfo?: any,
cycle: 'MONTHLY' | 'YEARLY' = 'MONTHLY',
description: string = 'Assinatura Mensal DevFlix'
description: string = 'Assinatura Mensal da Plataforma'
): Promise<{ subscriptionId: string; payment: Partial<SubscriptionPayment> }> {
const apiKey = this.getApiKey();
const apiKey = await this.getApiKey();
if (!apiKey) {
throw new Error('A Chave de API do Asaas (API Key) não foi configurada. Acesse o Painel Admin -> Configurações Globais, cole a sua chave do Asaas Sandbox e clique em Salvar Configurações.');
}
@ -142,9 +176,9 @@ export class AsaasService {
payload.creditCardHolderInfo = creditCardInfo.creditCardHolderInfo;
}
const response = await fetch(`${this.getApiUrl()}/subscriptions`, {
const response = await fetch(`${await this.getApiUrl()}/subscriptions`, {
method: 'POST',
headers: this.getHeaders(),
headers: await this.getHeaders(),
body: JSON.stringify(payload)
});
@ -192,7 +226,7 @@ export class AsaasService {
* Fetch PIX QR Code for a given payment ID
*/
public static async getPixQrCode(paymentId: string): Promise<{ encodedImage: string, payload: string } | null> {
const apiKey = this.getApiKey();
const apiKey = await this.getApiKey();
const fallbackPayload = `00020126580014br.gov.bcb.pix0136${paymentId}_microtecflix520400005303986540549.905802BR5915MicrotecFlix6009Sao Paulo62070503***6304`;
if (!apiKey || paymentId.startsWith('pay_sim_') || paymentId.startsWith('pay_fb_')) {
@ -203,9 +237,9 @@ export class AsaasService {
}
try {
const response = await fetch(`${this.getApiUrl()}/payments/${paymentId}/pixQrCode`, {
const response = await fetch(`${await this.getApiUrl()}/payments/${paymentId}/pixQrCode`, {
method: 'GET',
headers: this.getHeaders()
headers: await this.getHeaders()
});
if (!response.ok) {
@ -237,7 +271,7 @@ export class AsaasService {
creditCard: any,
creditCardHolderInfo: any
): Promise<{ creditCardToken: string; creditCardNumber: string; creditCardBrand: string }> {
const apiKey = this.getApiKey();
const apiKey = await this.getApiKey();
if (!apiKey) {
return {
creditCardToken: `tok_sim_${Math.random().toString(36).substring(2, 9)}`,
@ -247,9 +281,9 @@ export class AsaasService {
}
try {
const response = await fetch(`${this.getApiUrl()}/creditCard/tokenize`, {
const response = await fetch(`${await this.getApiUrl()}/creditCard/tokenize`, {
method: 'POST',
headers: this.getHeaders(),
headers: await this.getHeaders(),
body: JSON.stringify({
customer: customerId,
creditCard,
@ -280,7 +314,7 @@ export class AsaasService {
creditCardInfo?: any,
installmentCount?: number
): Promise<any> {
const apiKey = this.getApiKey();
const apiKey = await this.getApiKey();
const futureDueDate = new Date(Date.now() + 3 * 24 * 3600 * 1000).toISOString().split('T')[0];
if (!apiKey) {
@ -310,9 +344,9 @@ export class AsaasService {
}
}
const response = await fetch(`${this.getApiUrl()}/payments`, {
const response = await fetch(`${await this.getApiUrl()}/payments`, {
method: 'POST',
headers: this.getHeaders(),
headers: await this.getHeaders(),
body: JSON.stringify(payload)
});
@ -328,4 +362,32 @@ export class AsaasService {
throw err;
}
}
/**
* Delete/Cancel a payment in Asaas
*/
public static async deletePayment(paymentId: string): Promise<boolean> {
const apiKey = await this.getApiKey();
if (!apiKey || paymentId.startsWith('pay_sim_') || paymentId.startsWith('pay_fb_')) {
return true; // Ignore simulated/fallback payments
}
try {
const response = await fetch(`${await this.getApiUrl()}/payments/${paymentId}`, {
method: 'DELETE',
headers: await this.getHeaders()
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error('Failed to delete payment in Asaas:', errorData.errors?.[0]?.description);
return false;
}
return true;
} catch (err) {
console.error('Asaas deletePayment error:', err);
return false;
}
}
}

View File

@ -15,6 +15,18 @@ export interface User {
whatsapp?: string;
avatarUrl?: string;
trialEndsAt?: string | null;
totalLikes?: number;
totalDislikes?: number;
lessonReactions?: any[];
progress?: any[];
cep?: string;
address?: string;
number?: string;
complement?: string;
reference?: string;
neighborhood?: string;
city?: string;
state?: string;
createdAt: string;
}
@ -26,6 +38,22 @@ export interface SystemSettings {
asaasWebhookSecret: string;
helpText: string;
enableBoleto?: boolean;
evolutionApiUrl?: string;
evolutionApiKey?: string;
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 {
@ -38,6 +66,7 @@ export interface Course {
isLocked?: boolean; // Se true, o aluno precisa comprar ou assinar para acessar
price?: number; // Preço para compra avulsa (vitalícia)
createdAt: string;
lessons?: Lesson[];
}
export interface Plan {
@ -67,7 +96,9 @@ export interface Lesson {
videoType: 'youtube' | 'direct';
order: number;
content: string; // Additional lesson resources/description
thumbnailUrl?: string;
createdAt: string;
reaction?: string | null;
}
export interface Note {