Compare commits
No commits in common. "main" and "feature/postgresql-migration" have entirely different histories.
main
...
feature/po
|
|
@ -1,45 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
---
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,284 +0,0 @@
|
||||||
---
|
|
||||||
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...
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
@ -16,12 +16,10 @@ ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|
||||||
COPY package.json package-lock.json* bun.lock* ./
|
COPY package.json package-lock.json* bun.lock* ./
|
||||||
COPY prisma ./prisma
|
RUN npm install --only=production
|
||||||
RUN npm install --omit=dev
|
|
||||||
RUN npx prisma generate
|
|
||||||
|
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["sh", "-c", "npx prisma db push && node dist/server.cjs"]
|
CMD ["node", "dist/server.cjs"]
|
||||||
|
|
|
||||||
32
gemini.md
32
gemini.md
|
|
@ -159,35 +159,3 @@ 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-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. |
|
| **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.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="pt-BR">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|
|
||||||
16
memory.md
16
memory.md
|
|
@ -16,13 +16,9 @@ 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.
|
* 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).
|
* 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.
|
* 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` / `AdminDashboard.tsx`):
|
### Funcionalidades do Administrador (`AdminApp.tsx`):
|
||||||
* **Menu Lateral Responsivo (Estilo AgendaPRO)** dividido em seções:
|
* Dashboard com métricas financeiras (MRR, Receita Total, Taxa de Inadimplência, Alunos Ativos).
|
||||||
* **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.
|
* CRUD de Cursos, Módulos e Aulas.
|
||||||
* Gestão de Alunos e alteração manual de status de assinatura (`ACTIVE`, `OVERDUE`, `CANCELED`, `TRIAL`).
|
* 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.
|
* Visualização do histórico de Webhooks do Asaas.
|
||||||
|
|
@ -31,10 +27,10 @@ O **MicrotecFlix** é uma plataforma EAD / Streaming de cursos de informática e
|
||||||
|
|
||||||
## 2. Esquema de Entidades (`prisma/schema.prisma` / PostgreSQL)
|
## 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.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-prisma.ts`).
|
||||||
|
|
||||||
1. **`User`**: Usuários da plataforma (`admin` ou `student`) com status de assinatura (`ACTIVE`, `OVERDUE`, `CANCELED`, `TRIAL`).
|
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, certificadoMode (`FULL_COURSE` ou `PER_MODULE`) e preço avulso/bloqueio.
|
2. **`Course`**: Cursos cadastrados com título, descrição, thumbnail, categoria e preço avulso/bloqueio.
|
||||||
3. **`Module`**: Módulos organizados sequencialmente por curso.
|
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.
|
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.
|
5. **`Note`**: Anotações pessoais feitas pelos alunos por aula.
|
||||||
|
|
@ -45,7 +41,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.
|
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).
|
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.
|
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, texto de ajuda, Evolution API, marca SaaS (`brandName` em vermelho, `brandSlogan`, `cnpj`, `address`) e opção de habilitar/desabilitar boleto bancário (`enableBoleto`).
|
13. **`SystemSettings`**: Configurações dinâmicas do sistema, chaves do Asaas e texto de ajuda.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -67,7 +63,7 @@ Todo o sistema foi **100% migrado para o PostgreSQL** utilizando o **Prisma ORM*
|
||||||
|
|
||||||
## 4. Diretrizes para Futuras Edições
|
## 4. Diretrizes para Futuras Edições
|
||||||
|
|
||||||
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`.
|
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`.
|
||||||
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.
|
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.
|
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).
|
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).
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
|
|
@ -14,18 +14,15 @@
|
||||||
"@aws-sdk/client-s3": "^3.700.0",
|
"@aws-sdk/client-s3": "^3.700.0",
|
||||||
"@google/genai": "^2.4.0",
|
"@google/genai": "^2.4.0",
|
||||||
"@prisma/client": "^6.4.1",
|
"@prisma/client": "^6.4.1",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"axios": "^1.7.9",
|
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"multer": "^1.4.5-lts.1",
|
|
||||||
"nodemailer": "^9.0.3",
|
|
||||||
"prisma": "^6.4.1",
|
|
||||||
"react": "^19.0.1",
|
"react": "^19.0.1",
|
||||||
"react-dom": "^19.0.1",
|
"react-dom": "^19.0.1",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
|
|
@ -37,13 +34,12 @@
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.14.0",
|
"@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",
|
"autoprefixer": "^10.4.21",
|
||||||
"esbuild": "^0.28.1",
|
"esbuild": "^0.25.0",
|
||||||
|
"prisma": "^6.4.1",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "~5.8.2"
|
"typescript": "~5.8.2",
|
||||||
|
"vite": "^6.2.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,45 +57,31 @@ model User {
|
||||||
whatsapp String?
|
whatsapp String?
|
||||||
birthDate String? @map("birth_date")
|
birthDate String? @map("birth_date")
|
||||||
avatarUrl String? @map("avatar_url") @db.Text
|
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")
|
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")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
notes Note[]
|
notes Note[]
|
||||||
progress Progress[]
|
progress Progress[]
|
||||||
payments Payment[]
|
payments Payment[]
|
||||||
moduleGrades ModuleGrade[]
|
moduleGrades ModuleGrade[]
|
||||||
certificates Certificate[]
|
certificates Certificate[]
|
||||||
unlockedCourses String[] @default([]) @map("unlocked_courses")
|
unlockedCourses String[] @default([]) @map("unlocked_courses")
|
||||||
supportTickets SupportTicket[]
|
|
||||||
lessonReactions LessonReaction[]
|
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Course {
|
model Course {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
title String
|
title String
|
||||||
description String @db.Text
|
description String @db.Text
|
||||||
thumbnail String
|
thumbnail String
|
||||||
category String
|
category String
|
||||||
certificateMode CertificateMode @default(FULL_COURSE) @map("certificate_mode")
|
certificateMode CertificateMode @default(FULL_COURSE) @map("certificate_mode")
|
||||||
isLocked Boolean @default(false) @map("is_locked")
|
isLocked Boolean @default(false) @map("is_locked")
|
||||||
price Float?
|
price Float?
|
||||||
order Int @default(0)
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
studyMaterialUrl String? @map("study_material_url") @db.Text
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
|
|
||||||
modules Module[]
|
modules Module[]
|
||||||
payments Payment[]
|
|
||||||
|
|
||||||
@@map("courses")
|
@@map("courses")
|
||||||
}
|
}
|
||||||
|
|
@ -122,13 +108,11 @@ model Lesson {
|
||||||
videoType VideoType @map("video_type")
|
videoType VideoType @map("video_type")
|
||||||
order Int
|
order Int
|
||||||
content String? @db.Text
|
content String? @db.Text
|
||||||
thumbnailUrl String? @map("thumbnail_url") @db.Text
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade)
|
module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade)
|
||||||
notes Note[]
|
notes Note[]
|
||||||
progress Progress[]
|
progress Progress[]
|
||||||
reactions LessonReaction[]
|
|
||||||
|
|
||||||
@@map("lessons")
|
@@map("lessons")
|
||||||
}
|
}
|
||||||
|
|
@ -162,20 +146,6 @@ model Progress {
|
||||||
@@map("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 {
|
model Payment {
|
||||||
id String @id
|
id String @id
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
|
|
@ -191,8 +161,6 @@ model Payment {
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
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")
|
@@map("payments")
|
||||||
}
|
}
|
||||||
|
|
@ -206,18 +174,6 @@ model WebhookLog {
|
||||||
@@map("webhook_logs")
|
@@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 {
|
model ModuleActivity {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
moduleId String @unique @map("module_id")
|
moduleId String @unique @map("module_id")
|
||||||
|
|
@ -270,7 +226,6 @@ model Plan {
|
||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
includedCourses String[] @default([]) @map("included_courses")
|
includedCourses String[] @default([]) @map("included_courses")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
payments Payment[]
|
|
||||||
|
|
||||||
@@map("plans")
|
@@map("plans")
|
||||||
}
|
}
|
||||||
|
|
@ -283,52 +238,7 @@ model SystemSettings {
|
||||||
asaasWebhookUrl String @default("") @map("asaas_webhook_url")
|
asaasWebhookUrl String @default("") @map("asaas_webhook_url")
|
||||||
asaasWebhookSecret String @default("") @map("asaas_webhook_secret")
|
asaasWebhookSecret String @default("") @map("asaas_webhook_secret")
|
||||||
helpText String @db.Text @default("# Central de Ajuda") @map("help_text")
|
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")
|
@@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")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
111
src/AdminApp.tsx
111
src/AdminApp.tsx
|
|
@ -7,7 +7,7 @@ import AdminDashboard from './components/AdminDashboard';
|
||||||
import AdminContentManager from './components/AdminContentManager';
|
import AdminContentManager from './components/AdminContentManager';
|
||||||
|
|
||||||
export default function AdminApp() {
|
export default function AdminApp() {
|
||||||
const [token, setToken] = useState<string | null>(localStorage.getItem('admin_token'));
|
const [token, setToken] = useState<string | null>(localStorage.getItem('devflix_admin_token'));
|
||||||
const [adminUser, setAdminUser] = useState<{
|
const [adminUser, setAdminUser] = useState<{
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
|
@ -22,19 +22,6 @@ export default function AdminApp() {
|
||||||
const [loginPassword, setLoginPassword] = useState('');
|
const [loginPassword, setLoginPassword] = useState('');
|
||||||
const [authError, setAuthError] = useState<string | null>(null);
|
const [authError, setAuthError] = useState<string | null>(null);
|
||||||
const [authLoading, setAuthLoading] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
if (token) {
|
||||||
|
|
@ -88,7 +75,7 @@ export default function AdminApp() {
|
||||||
throw new Error('Acesso recusado. Apenas administradores podem acessar esta área.');
|
throw new Error('Acesso recusado. Apenas administradores podem acessar esta área.');
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem('admin_token', data.token);
|
localStorage.setItem('devflix_admin_token', data.token);
|
||||||
setToken(data.token);
|
setToken(data.token);
|
||||||
setAdminUser(data.user);
|
setAdminUser(data.user);
|
||||||
setLoginEmail('');
|
setLoginEmail('');
|
||||||
|
|
@ -101,7 +88,7 @@ export default function AdminApp() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem('admin_token');
|
localStorage.removeItem('devflix_admin_token');
|
||||||
setToken(null);
|
setToken(null);
|
||||||
setAdminUser(null);
|
setAdminUser(null);
|
||||||
};
|
};
|
||||||
|
|
@ -115,7 +102,93 @@ export default function AdminApp() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0b0c10] text-zinc-100 flex flex-col selection:bg-[#E50914] selection:text-white font-sans">
|
<div className="min-h-screen bg-[#0b0c10] text-zinc-100 flex flex-col selection:bg-[#E50914] selection:text-white font-sans">
|
||||||
{token && adminUser ? (
|
{token && adminUser ? (
|
||||||
<AdminDashboard token={token} adminUser={adminUser} onLogout={handleLogout} />
|
/* --- 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>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
/* --- UNAUTHENTICATED ADMIN ACCESS SCREEN --- */
|
/* --- 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' }}>
|
<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' }}>
|
||||||
|
|
@ -131,7 +204,7 @@ export default function AdminApp() {
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="font-display font-black text-2xl tracking-tighter text-white">
|
<h1 className="font-display font-black text-2xl tracking-tighter text-white">
|
||||||
{(settings?.brandName || 'Plataforma')}<span className="text-[#E50914]">FLIX</span> <span className="text-zinc-500 font-medium text-lg">ADMIN</span>
|
MICROTEC<span className="text-[#E50914]">FLIX</span> <span className="text-zinc-500 font-medium text-lg">ADMIN</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[11px] text-zinc-400 font-medium">Autenticação obrigatória para gestores, administradores e moderadores.</p>
|
<p className="text-[11px] text-zinc-400 font-medium">Autenticação obrigatória para gestores, administradores e moderadores.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -156,7 +229,7 @@ export default function AdminApp() {
|
||||||
required
|
required
|
||||||
value={loginEmail}
|
value={loginEmail}
|
||||||
onChange={(e) => setLoginEmail(e.target.value)}
|
onChange={(e) => setLoginEmail(e.target.value)}
|
||||||
placeholder="admin@email.com"
|
placeholder="email.admin@devflix.com"
|
||||||
className="w-full glass-input rounded-xl p-3.5 pl-11 text-xs text-white focus:outline-none"
|
className="w-full glass-input rounded-xl p-3.5 pl-11 text-xs text-white focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
451
src/App.tsx
451
src/App.tsx
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Play, Shield, User, GraduationCap, ArrowRight, Lock, Mail,
|
Play, Shield, User, GraduationCap, ArrowRight, Lock, Mail,
|
||||||
Plus, CheckCircle, ChevronRight, HelpCircle, Film, Laptop, Eye, EyeOff
|
Plus, CheckCircle, ChevronRight, HelpCircle, Film, Laptop
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Navbar from './components/Navbar';
|
import Navbar from './components/Navbar';
|
||||||
import HeroBanner from './components/HeroBanner';
|
import HeroBanner from './components/HeroBanner';
|
||||||
|
|
@ -12,35 +12,26 @@ import CourseUnlockModal from './components/CourseUnlockModal';
|
||||||
import CertificatesTab from './components/CertificatesTab';
|
import CertificatesTab from './components/CertificatesTab';
|
||||||
import ProfileView from './components/ProfileView';
|
import ProfileView from './components/ProfileView';
|
||||||
import HelpView from './components/HelpView';
|
import HelpView from './components/HelpView';
|
||||||
import CustomModal from './components/CustomModal';
|
|
||||||
import { User as UserType, Course as CourseType, SubscriptionStatus } from './types';
|
import { User as UserType, Course as CourseType, SubscriptionStatus } from './types';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [token, setToken] = useState<string | null>(localStorage.getItem('app_token'));
|
const [token, setToken] = useState<string | null>(localStorage.getItem('devflix_token'));
|
||||||
const [user, setUser] = useState<{
|
const [user, setUser] = useState<{
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: 'admin' | 'student';
|
role: 'admin' | 'student';
|
||||||
subscriptionStatus: SubscriptionStatus;
|
subscriptionStatus: SubscriptionStatus;
|
||||||
avatarUrl?: string;
|
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// App routing
|
// App routing
|
||||||
const [activeView, setView] = useState<string>('home'); // home, course-view, subscription, admin-dashboard, admin-content
|
const [activeView, setView] = useState<string>('home'); // home, course-view, subscription, admin-dashboard, admin-content
|
||||||
const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null);
|
const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null);
|
||||||
const [unlockingCourse, setUnlockingCourse] = useState<CourseType | 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
|
// Authentication forms
|
||||||
const [isRegister, setIsRegister] = useState(false);
|
const [isRegister, setIsRegister] = useState(false);
|
||||||
const [isForgotPassword, setIsForgotPassword] = useState(false);
|
|
||||||
const [resetToken, setResetToken] = useState<string | null>(null);
|
|
||||||
const [loginEmail, setLoginEmail] = useState('');
|
const [loginEmail, setLoginEmail] = useState('');
|
||||||
const [loginPassword, setLoginPassword] = useState('');
|
const [loginPassword, setLoginPassword] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [showRegPassword, setShowRegPassword] = useState(false);
|
|
||||||
const [regName, setRegName] = useState('');
|
const [regName, setRegName] = useState('');
|
||||||
const [regEmail, setRegEmail] = useState('');
|
const [regEmail, setRegEmail] = useState('');
|
||||||
const [regPassword, setRegPassword] = useState('');
|
const [regPassword, setRegPassword] = useState('');
|
||||||
|
|
@ -54,27 +45,6 @@ export default function App() {
|
||||||
// Course catalogue
|
// Course catalogue
|
||||||
const [courses, setCourses] = useState<(CourseType & { progress?: number; totalLessons?: number; completedLessons?: number })[]>([]);
|
const [courses, setCourses] = useState<(CourseType & { progress?: number; totalLessons?: number; completedLessons?: number })[]>([]);
|
||||||
const [isLoadingCourses, setIsLoadingCourses] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
if (token) {
|
||||||
|
|
@ -126,55 +96,6 @@ 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) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setAuthError(null);
|
setAuthError(null);
|
||||||
|
|
@ -192,7 +113,7 @@ export default function App() {
|
||||||
throw new Error(data.error || 'Erro ao efetuar login');
|
throw new Error(data.error || 'Erro ao efetuar login');
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem('app_token', data.token);
|
localStorage.setItem('devflix_token', data.token);
|
||||||
setToken(data.token);
|
setToken(data.token);
|
||||||
setUser(data.user);
|
setUser(data.user);
|
||||||
setLoginEmail('');
|
setLoginEmail('');
|
||||||
|
|
@ -222,7 +143,7 @@ export default function App() {
|
||||||
throw new Error(data.error || 'Erro ao efetuar cadastro');
|
throw new Error(data.error || 'Erro ao efetuar cadastro');
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem('app_token', data.token);
|
localStorage.setItem('devflix_token', data.token);
|
||||||
setToken(data.token);
|
setToken(data.token);
|
||||||
setUser(data.user);
|
setUser(data.user);
|
||||||
setRegName('');
|
setRegName('');
|
||||||
|
|
@ -236,13 +157,11 @@ export default function App() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem('app_token');
|
localStorage.removeItem('devflix_token');
|
||||||
setToken(null);
|
setToken(null);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setView('home');
|
setView('home');
|
||||||
setSelectedCourseId(null);
|
setSelectedCourseId(null);
|
||||||
setExpandedCourseId(null);
|
|
||||||
setSelectedLessonId(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectCourse = (courseId: string) => {
|
const handleSelectCourse = (courseId: string) => {
|
||||||
|
|
@ -251,18 +170,8 @@ export default function App() {
|
||||||
setUnlockingCourse(selected);
|
setUnlockingCourse(selected);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (expandedCourseId === courseId) {
|
setSelectedCourseId(courseId);
|
||||||
setExpandedCourseId(null);
|
setView('course-view');
|
||||||
} 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
|
// Group courses by Category
|
||||||
|
|
@ -288,7 +197,6 @@ export default function App() {
|
||||||
activeView={activeView}
|
activeView={activeView}
|
||||||
setView={(v) => { setView(v); setSelectedCourseId(null); }}
|
setView={(v) => { setView(v); setSelectedCourseId(null); }}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
settings={settings}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="flex-grow p-4 md:p-8 max-w-7xl mx-auto w-full space-y-8">
|
<main className="flex-grow p-4 md:p-8 max-w-7xl mx-auto w-full space-y-8">
|
||||||
|
|
@ -298,25 +206,8 @@ export default function App() {
|
||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
{/* Hero Banner (Featured last course or first catalogue course) */}
|
{/* Hero Banner (Featured last course or first catalogue course) */}
|
||||||
<HeroBanner
|
<HeroBanner
|
||||||
course={
|
course={courses.length > 0 ? courses[0] : null}
|
||||||
(settings?.featuredCourseId && courses.find(c => c.id === settings.featuredCourseId))
|
onPlay={handleSelectCourse}
|
||||||
|| (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}
|
subscriptionStatus={user.subscriptionStatus}
|
||||||
setView={setView}
|
setView={setView}
|
||||||
/>
|
/>
|
||||||
|
|
@ -332,134 +223,16 @@ export default function App() {
|
||||||
Nenhum curso disponível no momento.
|
Nenhum curso disponível no momento.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
categories.map(cat => {
|
categories.map(cat => (
|
||||||
const categoryCourses = courses.filter(c => c.category === cat);
|
<CourseCarousel
|
||||||
const expandedCourse = categoryCourses.find(c => c.id === expandedCourseId);
|
key={cat}
|
||||||
|
title={cat}
|
||||||
return (
|
courses={courses.filter(c => c.category === cat)}
|
||||||
<div key={cat} className="space-y-4">
|
onSelectCourse={handleSelectCourse}
|
||||||
<CourseCarousel
|
onUnlockCourse={(course) => setUnlockingCourse(course)}
|
||||||
title={cat}
|
subscriptionStatus={user.subscriptionStatus}
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -470,8 +243,7 @@ export default function App() {
|
||||||
<CourseView
|
<CourseView
|
||||||
courseId={selectedCourseId}
|
courseId={selectedCourseId}
|
||||||
token={token}
|
token={token}
|
||||||
initialLessonId={selectedLessonId}
|
onBack={() => { setView('home'); setSelectedCourseId(null); fetchCourses(); }}
|
||||||
onBack={() => { setView('home'); setSelectedCourseId(null); setSelectedLessonId(null); fetchCourses(); }}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -515,7 +287,6 @@ export default function App() {
|
||||||
{unlockingCourse && (
|
{unlockingCourse && (
|
||||||
<CourseUnlockModal
|
<CourseUnlockModal
|
||||||
course={unlockingCourse}
|
course={unlockingCourse}
|
||||||
user={user}
|
|
||||||
onClose={() => setUnlockingCourse(null)}
|
onClose={() => setUnlockingCourse(null)}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
setUnlockingCourse(null);
|
setUnlockingCourse(null);
|
||||||
|
|
@ -528,39 +299,10 @@ export default function App() {
|
||||||
|
|
||||||
{/* Footer */}
|
{/* 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">
|
<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">{(settings?.brandName || 'Plataforma')}FLIX - Plataforma de Cursos Profissionalizantes</p>
|
<p className="font-semibold text-zinc-400">MICROTECFLIX - Plataforma de Cursos Profissionalizantes de Informática</p>
|
||||||
<p className="max-w-md mx-auto">Desenvolvido com foco em alta experiência de estudo para Windows, Pacote Office (Word, Excel, PowerPoint) e informática profissionalizante.</p>
|
<p className="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>
|
||||||
{settings?.cnpj && (
|
<p className="text-zinc-600">© 2026 MicrotecFlix Inc. Todos os direitos reservados. Integrado oficialmente com a API Asaas.</p>
|
||||||
<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>
|
</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) --- */
|
/* --- UNAUTHENTICATED PORTAL (LOGIN / REGISTER) --- */
|
||||||
|
|
@ -570,99 +312,25 @@ export default function App() {
|
||||||
{/* Login Container */}
|
{/* Login Container */}
|
||||||
<div className="relative z-10 w-full max-w-md glass-card rounded-2xl p-6 md:p-10 space-y-6 shadow-2xl">
|
<div className="relative z-10 w-full max-w-md glass-card rounded-2xl p-6 md:p-10 space-y-6 shadow-2xl">
|
||||||
|
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex flex-col items-center space-y-2 text-center">
|
<div className="flex flex-col items-center space-y-2 text-center">
|
||||||
<div className="bg-[#E50914] text-white p-2 rounded flex items-center justify-center shadow-lg">
|
<div className="bg-[#E50914] text-white p-2 rounded flex items-center justify-center shadow-lg">
|
||||||
<Play className="fill-white w-6 h-6" />
|
<Play className="fill-white w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-display font-black text-2xl tracking-tighter text-[#E50914]">
|
<h1 className="font-display font-black text-2xl tracking-tighter text-[#E50914]">
|
||||||
{(settings?.brandName || 'Plataforma')}<span className="text-white">FLIX</span>
|
MICROTEC<span className="text-white">FLIX</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-zinc-400 font-medium">
|
<p className="text-xs text-zinc-400 font-medium">Acelere seus estudos em informática e pacote Office no estilo Netflix.</p>
|
||||||
{settings?.brandSlogan || 'Acelere seus estudos em informática e pacote Office no estilo Netflix.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Error indicators */}
|
||||||
{authError && (
|
{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">
|
<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}
|
{authError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{authSuccess && (
|
{isRegister ? (
|
||||||
<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 */
|
/* REGISTER FORM */
|
||||||
<form onSubmit={handleRegister} className="space-y-4">
|
<form onSubmit={handleRegister} className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -726,23 +394,14 @@ export default function App() {
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Crie uma Senha</label>
|
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Crie uma Senha</label>
|
||||||
<div className="relative">
|
<input
|
||||||
<input
|
type="password"
|
||||||
type={showRegPassword ? "text" : "password"}
|
required
|
||||||
required
|
value={regPassword}
|
||||||
value={regPassword}
|
onChange={(e) => setRegPassword(e.target.value)}
|
||||||
onChange={(e) => setRegPassword(e.target.value)}
|
placeholder="Mínimo 6 caracteres"
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
@ -769,15 +428,15 @@ export default function App() {
|
||||||
/* LOGIN FORM */
|
/* LOGIN FORM */
|
||||||
<form onSubmit={handleLogin} className="space-y-4">
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">E-mail ou WhatsApp</label>
|
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">E-mail</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Mail className="absolute left-3 top-3 w-4 h-4 text-zinc-500" />
|
<Mail className="absolute left-3 top-3 w-4 h-4 text-zinc-500" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="email"
|
||||||
required
|
required
|
||||||
value={loginEmail}
|
value={loginEmail}
|
||||||
onChange={(e) => setLoginEmail(e.target.value)}
|
onChange={(e) => setLoginEmail(e.target.value)}
|
||||||
placeholder="email@exemplo.com ou 5585981145217"
|
placeholder="email@exemplo.com"
|
||||||
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 text-xs text-white focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -788,33 +447,16 @@ export default function App() {
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-3 w-4 h-4 text-zinc-500" />
|
<Lock className="absolute left-3 top-3 w-4 h-4 text-zinc-500" />
|
||||||
<input
|
<input
|
||||||
type={showPassword ? "text" : "password"}
|
type="password"
|
||||||
required
|
required
|
||||||
value={loginPassword}
|
value={loginPassword}
|
||||||
onChange={(e) => setLoginPassword(e.target.value)}
|
onChange={(e) => setLoginPassword(e.target.value)}
|
||||||
placeholder="Digite sua senha"
|
placeholder="Digite sua senha"
|
||||||
className="w-full glass-input rounded-lg p-3 pl-10 pr-10 text-xs text-white focus:outline-none"
|
className="w-full glass-input rounded-lg p-3 pl-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>
|
</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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={authLoading}
|
disabled={authLoading}
|
||||||
|
|
@ -838,14 +480,21 @@ export default function App() {
|
||||||
{/* QUICK PRESETS FOR SANDBOX DEMO */}
|
{/* QUICK PRESETS FOR SANDBOX DEMO */}
|
||||||
<div className="border-t border-white/10 pt-4 space-y-3">
|
<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>
|
<p className="text-center text-[10px] text-zinc-500 font-bold uppercase tracking-wider">Acesso de Teste Rápido (Iframe Sandbox)</p>
|
||||||
<div className="flex justify-center">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => quickFill('aluno@microtecflix.com', 'aluno123')}
|
onClick={() => quickFill('aluno@microtecflix.com', 'aluno123')}
|
||||||
className="w-full glass-btn-secondary text-zinc-300 font-semibold p-2.5 rounded text-[10px] transition-colors text-center"
|
className="glass-btn-secondary text-zinc-300 font-semibold p-2 rounded text-[10px] transition-colors"
|
||||||
>
|
>
|
||||||
Logar como Aluno
|
Logar como Aluno
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
11
src/Root.tsx
11
src/Root.tsx
|
|
@ -2,7 +2,6 @@ import React, { useState } from 'react';
|
||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
import AdminApp from './AdminApp.tsx';
|
import AdminApp from './AdminApp.tsx';
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary.tsx';
|
import { ErrorBoundary } from './components/ErrorBoundary.tsx';
|
||||||
import { ModalProvider } from './contexts/ModalContext.tsx';
|
|
||||||
|
|
||||||
export default function Root() {
|
export default function Root() {
|
||||||
const [simulatedDomain] = useState<'student' | 'admin'>(() => {
|
const [simulatedDomain] = useState<'student' | 'admin'>(() => {
|
||||||
|
|
@ -23,7 +22,7 @@ export default function Root() {
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// Dynamically update document title and favicon
|
// Dynamically update document title and favicon
|
||||||
document.title = isAdmin ? 'Admin Portal' : 'Plataforma de Estudos';
|
document.title = isAdmin ? 'MicrotecFlix - Admin Portal' : 'MicrotecFlix - Plataforma de Estudos';
|
||||||
|
|
||||||
let link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
|
let link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
|
||||||
if (!link) {
|
if (!link) {
|
||||||
|
|
@ -36,11 +35,9 @@ export default function Root() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ModalProvider>
|
<div className="relative min-h-screen">
|
||||||
<div className="relative min-h-screen">
|
{isAdmin ? <AdminApp /> : <App />}
|
||||||
{isAdmin ? <AdminApp /> : <App />}
|
</div>
|
||||||
</div>
|
|
||||||
</ModalProvider>
|
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
GraduationCap, Plus, Edit, Trash2, FolderPlus, FilePlus, ChevronRight,
|
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';
|
} from 'lucide-react';
|
||||||
import { useModal } from '../contexts/ModalContext';
|
|
||||||
import ToggleSwitch from './ToggleSwitch';
|
|
||||||
|
|
||||||
interface Lesson {
|
interface Lesson {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -15,7 +12,6 @@ interface Lesson {
|
||||||
videoType: 'youtube' | 'direct';
|
videoType: 'youtube' | 'direct';
|
||||||
order: number;
|
order: number;
|
||||||
content: string;
|
content: string;
|
||||||
thumbnailUrl?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Module {
|
interface Module {
|
||||||
|
|
@ -34,9 +30,6 @@ interface Course {
|
||||||
category: string;
|
category: string;
|
||||||
isLocked?: boolean;
|
isLocked?: boolean;
|
||||||
price?: number;
|
price?: number;
|
||||||
certificateMode?: string;
|
|
||||||
order?: number;
|
|
||||||
studyMaterialUrl?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AdminContentManagerProps {
|
interface AdminContentManagerProps {
|
||||||
|
|
@ -44,7 +37,6 @@ interface AdminContentManagerProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminContentManager({ token }: AdminContentManagerProps) {
|
export default function AdminContentManager({ token }: AdminContentManagerProps) {
|
||||||
const { alert: showAlert, success: showSuccess, error: showError, confirm: showConfirm } = useModal();
|
|
||||||
const [courses, setCourses] = useState<Course[]>([]);
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
const [selectedCourse, setSelectedCourse] = useState<Course | null>(null);
|
const [selectedCourse, setSelectedCourse] = useState<Course | null>(null);
|
||||||
const [modules, setModules] = useState<Module[]>([]);
|
const [modules, setModules] = useState<Module[]>([]);
|
||||||
|
|
@ -56,7 +48,7 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
const [showCourseForm, setShowCourseForm] = useState(false);
|
const [showCourseForm, setShowCourseForm] = useState(false);
|
||||||
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
|
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
|
||||||
const [courseForm, setCourseForm] = useState({
|
const [courseForm, setCourseForm] = useState({
|
||||||
title: '', description: '', thumbnail: '', category: '', isLocked: false, price: 0, certificateMode: 'FULL_COURSE' as 'FULL_COURSE' | 'PER_MODULE', order: 0
|
title: '', description: '', thumbnail: '', category: '', isLocked: false, price: 0, certificateMode: 'FULL_COURSE' as 'FULL_COURSE' | 'PER_MODULE'
|
||||||
});
|
});
|
||||||
|
|
||||||
const [showModuleForm, setShowModuleForm] = useState(false);
|
const [showModuleForm, setShowModuleForm] = useState(false);
|
||||||
|
|
@ -67,7 +59,7 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
const [editingLesson, setEditingLesson] = useState<Lesson | null>(null);
|
const [editingLesson, setEditingLesson] = useState<Lesson | null>(null);
|
||||||
const [activeModuleForLesson, setActiveModuleForLesson] = useState<string | null>(null);
|
const [activeModuleForLesson, setActiveModuleForLesson] = useState<string | null>(null);
|
||||||
const [lessonForm, setLessonForm] = useState({
|
const [lessonForm, setLessonForm] = useState({
|
||||||
title: '', videoUrl: '', videoType: 'youtube' as 'youtube' | 'direct', order: 1, content: '', thumbnailUrl: ''
|
title: '', videoUrl: '', videoType: 'youtube' as 'youtube' | 'direct', order: 1, content: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const [managingActivityForModule, setManagingActivityForModule] = useState<string | null>(null);
|
const [managingActivityForModule, setManagingActivityForModule] = useState<string | null>(null);
|
||||||
|
|
@ -76,280 +68,10 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
const [activeModuleForActivity, setActiveModuleForActivity] = useState<string | null>(null);
|
const [activeModuleForActivity, setActiveModuleForActivity] = useState<string | null>(null);
|
||||||
const [activityQuestions, setActivityQuestions] = useState<{question: string, options: string[], correctIndex: number}[]>([]);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchCourses();
|
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 () => {
|
const fetchCourses = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -409,15 +131,14 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
await fetchCourses();
|
await fetchCourses();
|
||||||
setShowCourseForm(false);
|
setShowCourseForm(false);
|
||||||
setEditingCourse(null);
|
setEditingCourse(null);
|
||||||
setCourseForm({ title: '', description: '', thumbnail: '', category: '', isLocked: false, price: 0, certificateMode: 'FULL_COURSE', order: 0 });
|
setCourseForm({ title: '', description: '', thumbnail: '', category: '', isLocked: false, price: 0, certificateMode: 'FULL_COURSE' });
|
||||||
showSuccess(editingCourse ? 'Curso atualizado com sucesso!' : 'Curso criado com sucesso!');
|
|
||||||
} else {
|
} else {
|
||||||
const errData = await res.json();
|
const errData = await res.json();
|
||||||
showError(`Erro ao salvar curso: ${errData.error || 'Falha na resposta do servidor'}`);
|
alert(`Erro ao salvar curso: ${errData.error || 'Falha na resposta do servidor'}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error saving course:', err);
|
console.error('Error saving course:', err);
|
||||||
showError('Erro de rede ao salvar curso.');
|
alert('Erro de rede ao salvar curso.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -430,38 +151,33 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
category: course.category,
|
category: course.category,
|
||||||
isLocked: course.isLocked || false,
|
isLocked: course.isLocked || false,
|
||||||
price: course.price || 0,
|
price: course.price || 0,
|
||||||
certificateMode: (course.certificateMode || 'FULL_COURSE') as 'PER_MODULE' | 'FULL_COURSE',
|
certificateMode: course.certificateMode || 'FULL_COURSE'
|
||||||
order: course.order || 0
|
|
||||||
});
|
});
|
||||||
setEditingCourseMaterialUrl(course.studyMaterialUrl || null);
|
|
||||||
setShowCourseForm(true);
|
setShowCourseForm(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.getElementById('admin-content-top')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
document.getElementById('admin-content-top')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
}, 50);
|
}, 50);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteCourse = (courseId: string) => {
|
const handleDeleteCourse = async (courseId: string) => {
|
||||||
showConfirm(
|
if (!window.confirm('Tem certeza que deseja excluir este curso e todo o seu conteúdo (módulos, aulas, anotações)?')) return;
|
||||||
'Tem certeza que deseja excluir este curso e todo o seu conteúdo (módulos, aulas, anotações)?',
|
|
||||||
async () => {
|
try {
|
||||||
try {
|
const res = await fetch(`/api/admin/courses/${courseId}`, {
|
||||||
const res = await fetch(`/api/admin/courses/${courseId}`, {
|
method: 'DELETE',
|
||||||
method: 'DELETE',
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
});
|
||||||
});
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
fetchCourses();
|
fetchCourses();
|
||||||
if (selectedCourse?.id === courseId) {
|
if (selectedCourse?.id === courseId) {
|
||||||
setSelectedCourse(null);
|
setSelectedCourse(null);
|
||||||
setModules([]);
|
setModules([]);
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error deleting course:', err);
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
{ title: 'Excluir Curso', confirmLabel: 'Sim, Excluir', danger: true }
|
} catch (err) {
|
||||||
);
|
console.error('Error deleting course:', err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- MODULE CRUD ---
|
// --- MODULE CRUD ---
|
||||||
|
|
@ -503,23 +219,22 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
}, 50);
|
}, 50);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteModule = (modId: string) => {
|
const handleDeleteModule = async (modId: string) => {
|
||||||
|
if (!window.confirm('Excluir este módulo apagará todas as suas aulas associadas. Continuar?')) return;
|
||||||
if (!selectedCourse) return;
|
if (!selectedCourse) return;
|
||||||
showConfirm(
|
|
||||||
'Excluir este módulo apagará todas as suas aulas associadas. Continuar?',
|
try {
|
||||||
async () => {
|
const res = await fetch(`/api/admin/modules/${modId}`, {
|
||||||
try {
|
method: 'DELETE',
|
||||||
const res = await fetch(`/api/admin/modules/${modId}`, {
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
method: 'DELETE',
|
});
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
});
|
if (res.ok) {
|
||||||
if (res.ok) fetchCourseModulesAndLessons(selectedCourse.id);
|
fetchCourseModulesAndLessons(selectedCourse.id);
|
||||||
} catch (err) {
|
}
|
||||||
console.error('Error deleting module:', err);
|
} catch (err) {
|
||||||
}
|
console.error('Error deleting module:', err);
|
||||||
},
|
}
|
||||||
{ title: 'Excluir Módulo', confirmLabel: 'Sim, Excluir', danger: true }
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- LESSON CRUD ---
|
// --- LESSON CRUD ---
|
||||||
|
|
@ -546,7 +261,7 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
setShowLessonForm(false);
|
setShowLessonForm(false);
|
||||||
setEditingLesson(null);
|
setEditingLesson(null);
|
||||||
setActiveModuleForLesson(null);
|
setActiveModuleForLesson(null);
|
||||||
setLessonForm({ title: '', videoUrl: '', videoType: 'youtube', order: 1, content: '', thumbnailUrl: '' });
|
setLessonForm({ title: '', videoUrl: '', videoType: 'youtube', order: 1, content: '' });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error saving lesson:', err);
|
console.error('Error saving lesson:', err);
|
||||||
|
|
@ -561,8 +276,7 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
videoUrl: '',
|
videoUrl: '',
|
||||||
videoType: 'youtube',
|
videoType: 'youtube',
|
||||||
order: lessonsCount + 1,
|
order: lessonsCount + 1,
|
||||||
content: '',
|
content: ''
|
||||||
thumbnailUrl: ''
|
|
||||||
});
|
});
|
||||||
setShowLessonForm(true);
|
setShowLessonForm(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -578,8 +292,7 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
videoUrl: lesson.videoUrl,
|
videoUrl: lesson.videoUrl,
|
||||||
videoType: lesson.videoType,
|
videoType: lesson.videoType,
|
||||||
order: lesson.order,
|
order: lesson.order,
|
||||||
content: lesson.content || '',
|
content: lesson.content || ''
|
||||||
thumbnailUrl: lesson.thumbnailUrl || ''
|
|
||||||
});
|
});
|
||||||
setShowLessonForm(true);
|
setShowLessonForm(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -618,35 +331,34 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
body: JSON.stringify({ questions: activityQuestions })
|
body: JSON.stringify({ questions: activityQuestions })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
showSuccess('Atividade salva com sucesso!');
|
alert('Atividade salva com sucesso!');
|
||||||
setShowActivityModal(false);
|
setShowActivityModal(false);
|
||||||
} else {
|
} else {
|
||||||
showError('Erro ao salvar atividade.');
|
alert('Erro ao salvar atividade.');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error saving activity:', err);
|
console.error('Error saving activity:', err);
|
||||||
showError('Erro ao salvar atividade.');
|
alert('Erro ao salvar atividade.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleDeleteLesson = (lessonId: string) => {
|
const handleDeleteLesson = async (lessonId: string) => {
|
||||||
|
if (!window.confirm('Excluir esta aula?')) return;
|
||||||
if (!selectedCourse) return;
|
if (!selectedCourse) return;
|
||||||
showConfirm(
|
|
||||||
'Excluir esta aula? Esta ação não pode ser desfeita.',
|
try {
|
||||||
async () => {
|
const res = await fetch(`/api/admin/lessons/${lessonId}`, {
|
||||||
try {
|
method: 'DELETE',
|
||||||
const res = await fetch(`/api/admin/lessons/${lessonId}`, {
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
method: 'DELETE',
|
});
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
});
|
if (res.ok) {
|
||||||
if (res.ok) fetchCourseModulesAndLessons(selectedCourse.id);
|
fetchCourseModulesAndLessons(selectedCourse.id);
|
||||||
} catch (err) {
|
}
|
||||||
console.error('Error deleting lesson:', err);
|
} catch (err) {
|
||||||
}
|
console.error('Error deleting lesson:', err);
|
||||||
},
|
}
|
||||||
{ title: 'Excluir Aula', confirmLabel: 'Sim, Excluir', danger: true }
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading && courses.length === 0) {
|
if (isLoading && courses.length === 0) {
|
||||||
|
|
@ -671,7 +383,7 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
</div>
|
</div>
|
||||||
{!selectedCourse && !showCourseForm && (
|
{!selectedCourse && !showCourseForm && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setEditingCourse(null); setCourseForm({ title: '', description: '', thumbnail: '', category: '', isLocked: false, price: 0, certificateMode: 'FULL_COURSE', order: courses.length + 1 }); setShowCourseForm(true); }}
|
onClick={() => { setEditingCourse(null); setCourseForm({ title: '', description: '', thumbnail: '', category: '' }); 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"
|
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" />
|
<Plus className="w-4 h-4" />
|
||||||
|
|
@ -695,99 +407,6 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
</div>
|
</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 --- */}
|
{/* --- RENDER COURSE FORM --- */}
|
||||||
{showCourseForm && (
|
{showCourseForm && (
|
||||||
<div className="glass-card p-6 rounded-2xl space-y-6">
|
<div className="glass-card p-6 rounded-2xl space-y-6">
|
||||||
|
|
@ -830,26 +449,14 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
|
|
||||||
<div className="md:col-span-2 space-y-2">
|
<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>
|
<label className="text-xs text-zinc-400 font-bold uppercase tracking-wider">Imagem de Capa (Thumbnail URL)</label>
|
||||||
<div className="flex gap-2">
|
<input
|
||||||
<input
|
type="url"
|
||||||
type="url"
|
required
|
||||||
required
|
value={courseForm.thumbnail}
|
||||||
value={courseForm.thumbnail}
|
onChange={(e) => setCourseForm({ ...courseForm, thumbnail: e.target.value })}
|
||||||
onChange={(e) => setCourseForm({ ...courseForm, thumbnail: e.target.value })}
|
placeholder="https://images.unsplash.com/... ou link de imagem direta"
|
||||||
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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -876,24 +483,17 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<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="text-xs text-zinc-400 font-bold uppercase tracking-wider mb-2">Status de Bloqueio</label>
|
||||||
<ToggleSwitch
|
<label className="inline-flex items-center space-x-3 cursor-pointer">
|
||||||
checked={courseForm.isLocked}
|
<input
|
||||||
onChange={(v) => setCourseForm({ ...courseForm, isLocked: v })}
|
type="checkbox"
|
||||||
label="Curso Trancado (Requer pagamento ou desbloqueio manual)"
|
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"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-semibold text-zinc-300">Curso Trancado (Requer pagamento ou desbloqueio manual)</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2 space-y-2">
|
<div className="md:col-span-2 space-y-2">
|
||||||
|
|
@ -907,91 +507,6 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="md:col-span-2 flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
@ -1143,29 +658,6 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<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>
|
<label className="text-xs text-zinc-400 font-bold uppercase font-sans">Recursos Extras & Material de Apoio (Opcional)</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|
@ -1201,19 +693,11 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
modules.map((mod, modIdx) => (
|
modules.map((mod, modIdx) => (
|
||||||
<div
|
<div key={mod.id} className="glass-card rounded-xl overflow-hidden">
|
||||||
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 */}
|
{/* 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="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 cursor-grab active:cursor-grabbing">
|
<div className="flex items-center space-x-2">
|
||||||
<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">
|
<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}
|
M{mod.order}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -1267,17 +751,9 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
{mod.lessons.length === 0 ? (
|
{mod.lessons.length === 0 ? (
|
||||||
<div className="p-4 text-xs text-zinc-600 italic text-center">Nenhuma aula cadastrada neste módulo.</div>
|
<div className="p-4 text-xs text-zinc-600 italic text-center">Nenhuma aula cadastrada neste módulo.</div>
|
||||||
) : (
|
) : (
|
||||||
mod.lessons.map((lesson, lessonIdx) => (
|
mod.lessons.map(lesson => (
|
||||||
<div
|
<div key={lesson.id} className="px-6 py-3 flex items-center justify-between text-xs hover:bg-zinc-900/10 transition-colors">
|
||||||
key={lesson.id}
|
<div className="flex items-center space-x-3 min-w-0">
|
||||||
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>
|
<span className="text-zinc-500 font-mono font-bold w-4 text-right">#{lesson.order}</span>
|
||||||
{lesson.videoType === 'youtube' ? (
|
{lesson.videoType === 'youtube' ? (
|
||||||
<Youtube className="w-4 h-4 text-red-500 flex-shrink-0" />
|
<Youtube className="w-4 h-4 text-red-500 flex-shrink-0" />
|
||||||
|
|
@ -1321,25 +797,18 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
) : (
|
) : (
|
||||||
/* --- LIST ALL COURSES IN CATALOGUE --- */
|
/* --- LIST ALL COURSES IN CATALOGUE --- */
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{courses.map((course, courseIdx) => (
|
{courses.map(course => (
|
||||||
<div
|
<div
|
||||||
key={course.id}
|
key={course.id}
|
||||||
className="glass-card rounded-2xl overflow-hidden flex flex-col justify-between"
|
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>
|
<div>
|
||||||
{/* Thumbnail */}
|
{/* Thumbnail */}
|
||||||
<div className="relative w-full aspect-video cursor-grab active:cursor-grabbing">
|
<div className="relative w-full aspect-video">
|
||||||
<img src={course.thumbnail} alt={course.title} className="w-full h-full object-cover" referrerPolicy="no-referrer" />
|
<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">
|
<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}
|
{course.category}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,809 +0,0 @@
|
||||||
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 já 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, é só 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +1,13 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { RefreshCw, Save, Plus, Trash2, Edit, CheckCircle2, AlertTriangle, BookOpen, Layers, DollarSign } from 'lucide-react';
|
import { RefreshCw, Save, Plus, Trash2, Edit, CheckCircle2, AlertTriangle, BookOpen, Layers, DollarSign } from 'lucide-react';
|
||||||
import { Course, Plan } from '../types';
|
import { Course, Plan } from '../types';
|
||||||
import { useModal } from '../contexts/ModalContext';
|
|
||||||
import ToggleSwitch from './ToggleSwitch';
|
|
||||||
|
|
||||||
interface AdminPricingManagerProps {
|
interface AdminPricingManagerProps {
|
||||||
courses: Course[];
|
courses: Course[];
|
||||||
fetchCourses: () => void;
|
fetchCourses: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CoursePricingCardProps {
|
function CoursePricingCard({ course, saveLoading, onSave }: { course: Course, saveLoading: string | null, onSave: (id: string, locked: boolean, price: number) => void }) {
|
||||||
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 [localPrice, setLocalPrice] = useState(course.price || 0);
|
||||||
const [localLocked, setLocalLocked] = useState(course.isLocked || false);
|
const [localLocked, setLocalLocked] = useState(course.isLocked || false);
|
||||||
|
|
||||||
|
|
@ -82,7 +74,6 @@ function CoursePricingCard({ course, saveLoading, onSave }: CoursePricingCardPro
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AdminPricingManager: React.FC<AdminPricingManagerProps> = ({ courses, fetchCourses }) => {
|
export const AdminPricingManager: React.FC<AdminPricingManagerProps> = ({ courses, fetchCourses }) => {
|
||||||
const { confirm: showConfirm, success: showSuccess, error: showError } = useModal();
|
|
||||||
const [plans, setPlans] = useState<Plan[]>([]);
|
const [plans, setPlans] = useState<Plan[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saveLoading, setSaveLoading] = useState<string | null>(null);
|
const [saveLoading, setSaveLoading] = useState<string | null>(null);
|
||||||
|
|
@ -91,7 +82,7 @@ export const AdminPricingManager: React.FC<AdminPricingManagerProps> = ({ course
|
||||||
const [showPlanModal, setShowPlanModal] = useState(false);
|
const [showPlanModal, setShowPlanModal] = useState(false);
|
||||||
const [planForm, setPlanForm] = useState<Partial<Plan>>({ cycle: 'LIFETIME', active: true, includedCourses: [] });
|
const [planForm, setPlanForm] = useState<Partial<Plan>>({ cycle: 'LIFETIME', active: true, includedCourses: [] });
|
||||||
|
|
||||||
const token = localStorage.getItem('admin_token');
|
const token = localStorage.getItem('devflix_admin_token');
|
||||||
|
|
||||||
const fetchPlans = async () => {
|
const fetchPlans = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -158,22 +149,18 @@ export const AdminPricingManager: React.FC<AdminPricingManagerProps> = ({ course
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeletePlan = (planId: string) => {
|
const handleDeletePlan = async (planId: string) => {
|
||||||
showConfirm(
|
if (!window.confirm('Tem certeza que deseja excluir este plano?')) return;
|
||||||
'Tem certeza que deseja excluir este plano? Alunos vinculados não serão afetados imediatamente.',
|
|
||||||
async () => {
|
try {
|
||||||
try {
|
await fetch(`/api/admin/plans/${planId}`, {
|
||||||
await fetch(`/api/admin/plans/${planId}`, {
|
method: 'DELETE',
|
||||||
method: 'DELETE',
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
});
|
||||||
});
|
fetchPlans();
|
||||||
fetchPlans();
|
} catch (err) {
|
||||||
} catch (err) {
|
console.error(err);
|
||||||
console.error(err);
|
}
|
||||||
}
|
|
||||||
},
|
|
||||||
{ title: 'Excluir Plano', confirmLabel: 'Sim, Excluir', danger: true }
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleCourseInPlan = (courseId: string) => {
|
const toggleCourseInPlan = (courseId: string) => {
|
||||||
|
|
@ -206,13 +193,12 @@ export const AdminPricingManager: React.FC<AdminPricingManagerProps> = ({ course
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{courses.map(course => (
|
{courses.map(course => (
|
||||||
<div key={course.id}>
|
<CoursePricingCard
|
||||||
<CoursePricingCard
|
key={course.id}
|
||||||
course={course}
|
course={course}
|
||||||
saveLoading={saveLoading}
|
saveLoading={saveLoading}
|
||||||
onSave={handleUpdateCoursePrice}
|
onSave={handleUpdateCoursePrice}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -375,13 +361,18 @@ export const AdminPricingManager: React.FC<AdminPricingManagerProps> = ({ course
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col justify-center">
|
<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">
|
||||||
<ToggleSwitch
|
<input
|
||||||
|
type="checkbox"
|
||||||
checked={planForm.active}
|
checked={planForm.active}
|
||||||
onChange={v => setPlanForm({...planForm, active: v})}
|
onChange={e => setPlanForm({...planForm, active: e.target.checked})}
|
||||||
label="Plano Ativo (Visível para os alunos comprarem)"
|
className="w-5 h-5 rounded border-zinc-600 text-purple-600 focus:ring-purple-600 focus:ring-offset-zinc-900 bg-zinc-800"
|
||||||
/>
|
/>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Course Selection */}
|
{/* Course Selection */}
|
||||||
|
|
@ -396,34 +387,41 @@ export const AdminPricingManager: React.FC<AdminPricingManagerProps> = ({ course
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 overflow-y-auto space-y-2 flex-grow custom-scrollbar">
|
<div className="p-4 overflow-y-auto space-y-2 flex-grow custom-scrollbar">
|
||||||
<div className={`p-3 rounded-lg border transition-all ${
|
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${
|
||||||
planForm.includedCourses?.length === 0
|
planForm.includedCourses?.length === 0
|
||||||
? 'bg-purple-900/20 border-purple-500/50'
|
? 'bg-purple-900/20 border-purple-500/50'
|
||||||
: 'bg-zinc-900/50 border-white/5'
|
: 'bg-zinc-900/50 border-white/5 hover:border-white/20'
|
||||||
}`}>
|
}`}>
|
||||||
<ToggleSwitch
|
<input
|
||||||
|
type="checkbox"
|
||||||
checked={planForm.includedCourses?.length === 0}
|
checked={planForm.includedCourses?.length === 0}
|
||||||
onChange={(v) => { if(v) setPlanForm({...planForm, includedCourses: []}) }}
|
onChange={() => setPlanForm({...planForm, includedCourses: []})}
|
||||||
label="Acesso Global (All-Access) - Libera todos os cursos, presentes e futuros."
|
className="w-5 h-5 rounded-full border-zinc-600 text-purple-600 focus:ring-purple-600 bg-zinc-800"
|
||||||
/>
|
/>
|
||||||
</div>
|
<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">
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
{courses.map(course => (
|
{courses.map(course => (
|
||||||
<div key={course.id} className={`p-3 rounded-lg border transition-all ${
|
<label key={course.id} className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${
|
||||||
planForm.includedCourses?.includes(course.id)
|
planForm.includedCourses?.includes(course.id)
|
||||||
? 'bg-purple-900/20 border-purple-500/50'
|
? 'bg-purple-900/20 border-purple-500/50'
|
||||||
: 'bg-zinc-900/50 border-white/5'
|
: 'bg-zinc-900/50 border-white/5 hover:border-white/20'
|
||||||
}`}>
|
}`}>
|
||||||
<ToggleSwitch
|
<input
|
||||||
checked={planForm.includedCourses?.includes(course.id) || false}
|
type="checkbox"
|
||||||
|
checked={planForm.includedCourses?.includes(course.id)}
|
||||||
onChange={() => toggleCourseInPlan(course.id)}
|
onChange={() => toggleCourseInPlan(course.id)}
|
||||||
label={course.title}
|
className="w-5 h-5 rounded border-zinc-600 text-purple-600 focus:ring-purple-600 bg-zinc-800"
|
||||||
/>
|
/>
|
||||||
</div>
|
<span className="text-sm font-medium text-zinc-300 truncate">{course.title}</span>
|
||||||
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,288 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -67,9 +67,7 @@ export default function CertificatesTab({ token, studentName }: CertificatesTabP
|
||||||
|
|
||||||
<div className="space-y-16">
|
<div className="space-y-16">
|
||||||
{certificates.map(cert => (
|
{certificates.map(cert => (
|
||||||
<div key={cert.id}>
|
<CertificateView key={cert.id} certificate={cert} studentName={studentName} />
|
||||||
<CertificateView certificate={cert} studentName={studentName} />
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { ArrowLeft, CheckCircle, XCircle, Award } from 'lucide-react';
|
import { ArrowLeft, CheckCircle, XCircle, Award } from 'lucide-react';
|
||||||
import { useModal } from '../contexts/ModalContext';
|
|
||||||
|
|
||||||
interface CourseActivityProps {
|
interface CourseActivityProps {
|
||||||
token: string | null;
|
token: string | null;
|
||||||
|
|
@ -10,7 +9,6 @@ interface CourseActivityProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CourseActivity({ token, moduleId, onBack, onCompleted }: CourseActivityProps) {
|
export default function CourseActivity({ token, moduleId, onBack, onCompleted }: CourseActivityProps) {
|
||||||
const { alert: showAlert, error: showError } = useModal();
|
|
||||||
const [activity, setActivity] = useState<any>(null);
|
const [activity, setActivity] = useState<any>(null);
|
||||||
const [answers, setAnswers] = useState<number[]>([]);
|
const [answers, setAnswers] = useState<number[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
@ -48,7 +46,7 @@ export default function CourseActivity({ token, moduleId, onBack, onCompleted }:
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (answers.includes(-1)) {
|
if (answers.includes(-1)) {
|
||||||
showAlert('Por favor, responda todas as perguntas antes de enviar.');
|
alert('Por favor, responda todas as perguntas.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,7 +68,7 @@ export default function CourseActivity({ token, moduleId, onBack, onCompleted }:
|
||||||
onCompleted(true);
|
onCompleted(true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
showError('Erro ao enviar atividade. Tente novamente.');
|
alert('Erro ao enviar atividade.');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error submitting activity:', err);
|
console.error('Error submitting activity:', err);
|
||||||
|
|
|
||||||
|
|
@ -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' : ''}`}
|
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 */}
|
{/* Thumbnail */}
|
||||||
<div className="relative w-full aspect-[3/4] overflow-hidden">
|
<div className="relative w-full aspect-video overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={course.thumbnail}
|
src={course.thumbnail}
|
||||||
alt={course.title}
|
alt={course.title}
|
||||||
|
|
|
||||||
|
|
@ -1,164 +1,502 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Check, Layers, Loader2 } from 'lucide-react';
|
import { X, Check, CreditCard, QrCode, FileText, Sparkles, Loader2, Play, Layers } from 'lucide-react';
|
||||||
import { Course, Plan } from '../types';
|
import { Course, Plan } from '../types';
|
||||||
import PaymentCheckoutCard from './PaymentCheckoutCard';
|
|
||||||
|
|
||||||
interface CourseUnlockModalProps {
|
interface CourseUnlockModalProps {
|
||||||
course: Course & { price?: number };
|
course: Course & { price?: number };
|
||||||
user: any;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CourseUnlockModal({ course, user, onClose, onSuccess, token }: CourseUnlockModalProps) {
|
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
|
||||||
const [availablePlans, setAvailablePlans] = useState<Plan[]>([]);
|
const [availablePlans, setAvailablePlans] = useState<Plan[]>([]);
|
||||||
|
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null); // null means 'Avulso'
|
||||||
const [loadingPlans, setLoadingPlans] = useState(true);
|
const [loadingPlans, setLoadingPlans] = useState(true);
|
||||||
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null); // null = Avulso
|
|
||||||
|
|
||||||
const coursePrice = course.price || 49.90;
|
const coursePrice = course.price || 49.90;
|
||||||
|
|
||||||
useEffect(() => {
|
const handleCopyPix = () => {
|
||||||
if (!token) return;
|
if (paymentData?.pixData?.payload) {
|
||||||
fetch('/api/plans', { headers: { Authorization: `Bearer ${token}` } })
|
navigator.clipboard.writeText(paymentData.pixData.payload);
|
||||||
.then(r => r.json())
|
setPixCopied(true);
|
||||||
.then((data: Plan[]) => {
|
setTimeout(() => setPixCopied(false), 2000);
|
||||||
// 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);
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoadingPlans(false));
|
|
||||||
}, [token, course.id]);
|
|
||||||
|
|
||||||
const selectedPlanObj = selectedPlanId ? availablePlans.find(p => p.id === selectedPlanId) : null;
|
|
||||||
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 '';
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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));
|
||||||
|
setAvailablePlans(relevant);
|
||||||
|
setLoadingPlans(false);
|
||||||
|
})
|
||||||
|
.catch(() => 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;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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">
|
||||||
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">
|
<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">
|
||||||
|
|
||||||
{/* LEFT: Course info + Plan selection */}
|
{/* Close Button */}
|
||||||
<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]">
|
<button
|
||||||
{/* Course header */}
|
onClick={onClose}
|
||||||
<div className="mb-6">
|
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"
|
||||||
<div className="flex items-center gap-4 mb-4">
|
>
|
||||||
<img
|
<X className="w-4 h-4" />
|
||||||
src={course.thumbnail}
|
</button>
|
||||||
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
|
|
||||||
</span>
|
|
||||||
<h3 className="font-display font-black text-lg text-white leading-tight line-clamp-2">
|
|
||||||
{course.title}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-zinc-400">Escolha a melhor oferta para você.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Option list */}
|
{!unlocked ? (
|
||||||
<div className="space-y-3">
|
<>
|
||||||
{/* Avulso */}
|
{/* Left: Options & Selection */}
|
||||||
<div
|
<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]">
|
||||||
onClick={() => setSelectedPlanId(null)}
|
<div className="mb-6">
|
||||||
className={`relative p-4 rounded-xl border-2 cursor-pointer transition-all ${
|
<div className="flex items-center gap-4 mb-4">
|
||||||
selectedPlanId === null
|
<img src={course.thumbnail} alt={course.title} className="w-20 h-20 object-cover rounded-lg border border-white/10" />
|
||||||
? 'bg-red-950/20 border-[#E50914]'
|
<div>
|
||||||
: 'bg-zinc-900/50 border-white/5 hover:border-white/20'
|
<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
|
||||||
>
|
</span>
|
||||||
{selectedPlanId === null && (
|
<h3 className="font-display font-black text-lg text-white leading-tight line-clamp-2">
|
||||||
<div className="absolute top-3 right-3 text-[#E50914]">
|
{course.title}
|
||||||
<Check className="w-5 h-5" />
|
</h3>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<p className="text-sm text-zinc-400">Escolha a melhor oferta para você acessar este curso e expandir seus conhecimentos.</p>
|
||||||
<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.</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>
|
|
||||||
<span className="text-xs text-zinc-500 font-medium ml-1">taxa única</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Plans/Combos */}
|
<div className="space-y-4">
|
||||||
{loadingPlans ? (
|
{/* Option 1: Avulso */}
|
||||||
<div className="p-8 flex justify-center">
|
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-[#E50914]" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
availablePlans.map(plan => (
|
|
||||||
<div
|
<div
|
||||||
key={plan.id}
|
onClick={() => setSelectedPlanId(null)}
|
||||||
onClick={() => setSelectedPlanId(plan.id)}
|
className={`relative p-4 rounded-xl border-2 cursor-pointer transition-all ${
|
||||||
className={`relative p-4 rounded-xl border-2 cursor-pointer transition-all overflow-hidden ${
|
selectedPlanId === null
|
||||||
selectedPlanId === plan.id
|
? 'bg-red-950/20 border-[#E50914]'
|
||||||
? 'bg-purple-900/20 border-purple-500 shadow-[0_0_20px_rgba(168,85,247,0.15)]'
|
|
||||||
: 'bg-zinc-900/50 border-white/5 hover:border-white/20'
|
: '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 rounded-tr-lg uppercase tracking-wider">
|
{selectedPlanId === null && (
|
||||||
Recomendado
|
<div className="absolute top-3 right-3 text-[#E50914]"><Check className="w-5 h-5" /></div>
|
||||||
</div>
|
|
||||||
{selectedPlanId === plan.id && (
|
|
||||||
<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">
|
<h4 className="font-bold text-white mb-1">Compra Avulsa (Apenas este curso)</h4>
|
||||||
<Layers className="w-4 h-4 text-purple-400" />
|
<p className="text-xs text-zinc-400 mb-3">Pague uma vez e tenha acesso vitalício ao curso selecionado.</p>
|
||||||
{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>
|
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline gap-1">
|
||||||
<span className="text-sm text-zinc-500 font-bold">R$</span>
|
<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-2xl font-black text-white">{coursePrice.toFixed(2)}</span>
|
||||||
<span className="text-xs text-zinc-500 font-medium ml-1">{getCycleLabel(plan.cycle)}</span>
|
<span className="text-xs text-zinc-500 font-medium ml-1">taxa única</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* RIGHT: Unified checkout form via PaymentCheckoutCard */}
|
{/* Option 2+: Plans/Combos */}
|
||||||
<div className="w-full md:w-7/12 p-6 md:p-8 max-h-[85vh] overflow-y-auto custom-scrollbar bg-[#111]">
|
{loadingPlans ? (
|
||||||
<PaymentCheckoutCard
|
<div className="p-8 flex justify-center"><Loader2 className="w-6 h-6 animate-spin text-[#E50914]" /></div>
|
||||||
itemId={currentItem.id}
|
) : availablePlans.map(plan => (
|
||||||
itemType={currentItem.type}
|
<div
|
||||||
title={currentItem.title}
|
key={plan.id}
|
||||||
price={currentItem.price}
|
onClick={() => setSelectedPlanId(plan.id)}
|
||||||
cycle={currentItem.cycle}
|
className={`relative p-4 rounded-xl border-2 cursor-pointer transition-all overflow-hidden ${
|
||||||
description={currentItem.description}
|
selectedPlanId === plan.id
|
||||||
user={user}
|
? 'bg-purple-900/20 border-purple-500 shadow-[0_0_20px_rgba(168,85,247,0.15)]'
|
||||||
token={token}
|
: 'bg-zinc-900/50 border-white/5 hover:border-white/20'
|
||||||
embedded={true}
|
}`}
|
||||||
onClose={onClose}
|
>
|
||||||
onSuccess={onSuccess}
|
<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">
|
||||||
/>
|
Recomendado
|
||||||
</div>
|
</div>
|
||||||
|
{selectedPlanId === plan.id && (
|
||||||
|
<div className="absolute top-3 right-3 mt-3 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>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{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"
|
||||||
|
/>
|
||||||
|
</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 já 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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { ArrowLeft, BookOpen, ChevronRight, ChevronLeft, FileText, CheckCircle2, Circle, MessageSquare, AlertCircle, Award, Download } from 'lucide-react';
|
import { ArrowLeft, BookOpen, ChevronRight, FileText, CheckCircle2, Circle, MessageSquare, AlertCircle, Award } from 'lucide-react';
|
||||||
import VideoPlayer from './VideoPlayer';
|
import VideoPlayer from './VideoPlayer';
|
||||||
import CourseActivity from './CourseActivity';
|
import CourseActivity from './CourseActivity';
|
||||||
|
|
||||||
|
|
@ -34,21 +34,18 @@ interface Course {
|
||||||
description: string;
|
description: string;
|
||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
category: string;
|
category: string;
|
||||||
studyMaterialUrl?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CourseViewProps {
|
interface CourseViewProps {
|
||||||
courseId: string;
|
courseId: string;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
initialLessonId?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CourseView({ courseId, token, onBack, initialLessonId }: CourseViewProps) {
|
export default function CourseView({ courseId, token, onBack }: CourseViewProps) {
|
||||||
const [course, setCourse] = useState<Course | null>(null);
|
const [course, setCourse] = useState<Course | null>(null);
|
||||||
const [modules, setModules] = useState<Module[]>([]);
|
const [modules, setModules] = useState<Module[]>([]);
|
||||||
const [activeLesson, setActiveLesson] = useState<Lesson | null>(null);
|
const [activeLesson, setActiveLesson] = useState<Lesson | null>(null);
|
||||||
const [expandedModules, setExpandedModules] = useState<string[]>([]);
|
|
||||||
const [noteContent, setNoteContent] = useState('');
|
const [noteContent, setNoteContent] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -75,28 +72,13 @@ export default function CourseView({ courseId, token, onBack, initialLessonId }:
|
||||||
setCourse(data.course);
|
setCourse(data.course);
|
||||||
setModules(data.modules);
|
setModules(data.modules);
|
||||||
|
|
||||||
// Default to the selected initial lesson, or the first lesson of the first module
|
// Default to the first lesson of the first module
|
||||||
if (data.modules && data.modules.length > 0) {
|
if (data.modules && data.modules.length > 0) {
|
||||||
let selectedLsn = null;
|
const firstMod = data.modules[0];
|
||||||
if (initialLessonId) {
|
if (firstMod.lessons && firstMod.lessons.length > 0) {
|
||||||
for (const mod of data.modules) {
|
const firstLsn = firstMod.lessons[0];
|
||||||
const found = mod.lessons?.find((l: any) => l.id === initialLessonId);
|
setActiveLesson(firstLsn);
|
||||||
if (found) {
|
setNoteContent(firstLsn.note || '');
|
||||||
selectedLsn = found;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedLsn) {
|
|
||||||
const firstMod = data.modules[0];
|
|
||||||
if (firstMod.lessons && firstMod.lessons.length > 0) {
|
|
||||||
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) {
|
} catch (err: any) {
|
||||||
|
|
@ -106,62 +88,10 @@ export default function CourseView({ courseId, token, onBack, initialLessonId }:
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectLesson = (lesson: Lesson, moduleId?: string) => {
|
const handleSelectLesson = (lesson: Lesson) => {
|
||||||
setActiveLesson(lesson);
|
setActiveLesson(lesson);
|
||||||
setNoteContent(lesson.note || '');
|
setNoteContent(lesson.note || '');
|
||||||
setSaveStatus('idle');
|
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) => {
|
const handleProgressUpdate = async (watchedPercentage: number) => {
|
||||||
|
|
@ -297,13 +227,6 @@ export default function CourseView({ courseId, token, onBack, initialLessonId }:
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Back Header */}
|
{/* Back Header */}
|
||||||
|
|
@ -335,41 +258,8 @@ export default function CourseView({ courseId, token, onBack, initialLessonId }:
|
||||||
lessonId={activeLesson.id}
|
lessonId={activeLesson.id}
|
||||||
onProgressUpdate={handleProgressUpdate}
|
onProgressUpdate={handleProgressUpdate}
|
||||||
initialProgress={activeLesson.progress?.watchedPercentage || 0}
|
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 */}
|
{/* Lesson Description Content */}
|
||||||
{activeLesson.content && (
|
{activeLesson.content && (
|
||||||
<div className="glass-card p-5 rounded-xl space-y-2">
|
<div className="glass-card p-5 rounded-xl space-y-2">
|
||||||
|
|
@ -424,138 +314,93 @@ export default function CourseView({ courseId, token, onBack, initialLessonId }:
|
||||||
|
|
||||||
{/* Right Hand: Interactive Modules Accordion */}
|
{/* Right Hand: Interactive Modules Accordion */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="glass-card p-4 rounded-xl space-y-3">
|
<div className="glass-card p-4 rounded-xl space-y-1">
|
||||||
<div>
|
<h3 className="font-sans font-extrabold text-sm text-white tracking-tight">Conteúdo do Curso</h3>
|
||||||
<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>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{modules.map((mod, modIdx) => {
|
{modules.map((mod, modIdx) => (
|
||||||
const isExpanded = expandedModules.includes(mod.id);
|
<div
|
||||||
const moduleTotal = mod.lessons.length;
|
key={mod.id}
|
||||||
const moduleCompleted = mod.lessons.filter(l => l.progress?.completed).length;
|
className="glass-card rounded-xl overflow-hidden"
|
||||||
const isModuleFullyCompleted = moduleTotal > 0 && moduleTotal === moduleCompleted;
|
>
|
||||||
const moduleProgressPercent = moduleTotal > 0 ? Math.round((moduleCompleted / moduleTotal) * 100) : 0;
|
{/* 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>
|
||||||
|
<h4 className="font-sans font-bold text-xs text-zinc-200 tracking-tight leading-snug line-clamp-1">
|
||||||
|
{mod.title}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-zinc-500 font-bold whitespace-nowrap">
|
||||||
|
{mod.lessons.length} {mod.lessons.length === 1 ? 'aula' : 'aulas'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
return (
|
{/* Lessons list inside module */}
|
||||||
<div
|
<div className="divide-y divide-white/5">
|
||||||
key={mod.id}
|
{mod.lessons.length === 0 ? (
|
||||||
className="glass-card rounded-xl overflow-hidden border border-white/5"
|
<div className="p-3 text-xs text-zinc-600 text-center italic">Nenhuma aula neste módulo.</div>
|
||||||
>
|
) : (
|
||||||
{/* Module Title bar (Clickable) */}
|
mod.lessons.map(lesson => {
|
||||||
<button
|
const isActive = activeLesson?.id === lesson.id;
|
||||||
onClick={() => toggleModule(mod.id)}
|
const isLsnCompleted = lesson.progress?.completed;
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Lessons list inside module */}
|
return (
|
||||||
{isExpanded && (
|
<button
|
||||||
<div className="px-4 pb-3">
|
key={lesson.id}
|
||||||
<div className="ml-2 border-l border-zinc-700 pl-4 py-1 space-y-1">
|
onClick={() => handleSelectLesson(lesson)}
|
||||||
{mod.lessons.length === 0 ? (
|
className={`w-full px-4 py-3 text-left flex items-start space-x-3 transition-colors ${
|
||||||
<div className="py-2 text-xs text-zinc-600 italic">Nenhuma aula neste módulo.</div>
|
isActive
|
||||||
) : (
|
? 'bg-[#E50914]/10 text-white'
|
||||||
mod.lessons.map(lesson => {
|
: 'hover:bg-zinc-800/40 text-zinc-400 hover:text-zinc-200'
|
||||||
const isActive = activeLesson?.id === lesson.id;
|
}`}
|
||||||
const isLsnCompleted = lesson.progress?.completed;
|
>
|
||||||
|
<div className="mt-0.5 flex-shrink-0">
|
||||||
return (
|
{isLsnCompleted ? (
|
||||||
<button
|
<CheckCircle2 className="w-4 h-4 text-emerald-500 fill-emerald-500/10" />
|
||||||
key={lesson.id}
|
) : (
|
||||||
onClick={() => handleSelectLesson(lesson, mod.id)}
|
<Circle className="w-4 h-4 text-zinc-600" />
|
||||||
className="relative w-full py-2.5 text-left flex items-start space-x-3 group"
|
)}
|
||||||
>
|
|
||||||
{/* 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 transition-colors ${
|
|
||||||
isActive ? 'text-[#E50914] font-bold' : 'text-zinc-400 group-hover:text-zinc-200'
|
|
||||||
}`}>
|
|
||||||
{lesson.order}. {lesson.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Module Activity Button (End of Timeline) */}
|
|
||||||
{mod.lessons.length > 0 && (
|
|
||||||
<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={() => 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 flex-shrink-0" />
|
|
||||||
<span className="truncate">Avaliação do Módulo</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
<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>
|
||||||
|
{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 */}
|
||||||
|
{mod.lessons.length > 0 && (
|
||||||
|
<div className="p-3 border-t border-white/5 bg-black/20">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Award className="w-4 h-4 text-white flex-shrink-0" />
|
||||||
|
<span className="truncate">Avaliação do Módulo {modIdx + 1}: {mod.title}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { ErrorInfo, ReactNode } from 'react';
|
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
import { RefreshCw, AlertTriangle } from 'lucide-react';
|
import { RefreshCw, AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -10,7 +10,7 @@ interface State {
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ErrorBoundary extends React.Component<Props, State> {
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
public state: State = {
|
public state: State = {
|
||||||
hasError: false,
|
hasError: false,
|
||||||
error: null
|
error: null
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { ArrowLeft, HelpCircle, MessageSquare, X, Send } from 'lucide-react';
|
import { ArrowLeft, HelpCircle } from 'lucide-react';
|
||||||
|
|
||||||
interface HelpViewProps {
|
interface HelpViewProps {
|
||||||
token: string | null;
|
token: string | null;
|
||||||
|
|
@ -9,10 +9,6 @@ interface HelpViewProps {
|
||||||
export default function HelpView({ token, onBack }: HelpViewProps) {
|
export default function HelpView({ token, onBack }: HelpViewProps) {
|
||||||
const [helpText, setHelpText] = useState<string>('');
|
const [helpText, setHelpText] = useState<string>('');
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchHelpText();
|
fetchHelpText();
|
||||||
|
|
@ -35,33 +31,6 @@ 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 (
|
return (
|
||||||
<div className="space-y-6 max-w-4xl mx-auto">
|
<div className="space-y-6 max-w-4xl mx-auto">
|
||||||
<button
|
<button
|
||||||
|
|
@ -73,18 +42,9 @@ export default function HelpView({ token, onBack }: HelpViewProps) {
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="glass-card rounded-2xl overflow-hidden shadow-2xl border border-white/10 p-8 min-h-[50vh]">
|
<div className="glass-card rounded-2xl overflow-hidden shadow-2xl border border-white/10 p-8 min-h-[50vh]">
|
||||||
<div className="flex items-center justify-between mb-8 border-b border-white/10 pb-4">
|
<div className="flex items-center space-x-3 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]" />
|
||||||
<HelpCircle className="w-8 h-8 text-[#E50914]" />
|
<h2 className="text-2xl font-bold text-white">Central de Ajuda</h2>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|
@ -97,52 +57,6 @@ export default function HelpView({ token, onBack }: HelpViewProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,11 @@ import { Course } from '../types';
|
||||||
interface HeroBannerProps {
|
interface HeroBannerProps {
|
||||||
course: (Course & { progress?: number; totalLessons?: number }) | null;
|
course: (Course & { progress?: number; totalLessons?: number }) | null;
|
||||||
onPlay: (courseId: string) => void;
|
onPlay: (courseId: string) => void;
|
||||||
onDetail: (courseId: string) => void;
|
|
||||||
subscriptionStatus: string;
|
subscriptionStatus: string;
|
||||||
setView: (view: string) => void;
|
setView: (view: string) => void;
|
||||||
customHeroTitle?: string | null;
|
|
||||||
customHeroDescription?: string | null;
|
|
||||||
customHeroImage?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HeroBanner({ course, onPlay, onDetail, subscriptionStatus, setView, customHeroTitle, customHeroDescription, customHeroImage }: HeroBannerProps) {
|
export default function HeroBanner({ course, onPlay, subscriptionStatus, setView }: HeroBannerProps) {
|
||||||
if (!course) {
|
if (!course) {
|
||||||
return (
|
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">
|
<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">
|
||||||
|
|
@ -29,8 +25,8 @@ export default function HeroBanner({ course, onPlay, onDetail, subscriptionStatu
|
||||||
{/* Background Image / Gradient */}
|
{/* Background Image / Gradient */}
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
<img
|
<img
|
||||||
src={customHeroImage || course.thumbnail}
|
src={course.thumbnail}
|
||||||
alt={customHeroTitle || course.title}
|
alt={course.title}
|
||||||
className="w-full h-full object-cover brightness-[0.40] scale-105 transition-all duration-700"
|
className="w-full h-full object-cover brightness-[0.40] scale-105 transition-all duration-700"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
|
|
@ -48,12 +44,12 @@ export default function HeroBanner({ course, onPlay, onDetail, subscriptionStatu
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h1 className="text-3xl md:text-5xl font-display font-black tracking-tight text-white leading-tight">
|
<h1 className="text-3xl md:text-5xl font-display font-black tracking-tight text-white leading-tight">
|
||||||
{customHeroTitle || course.title}
|
{course.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<p className="text-sm md:text-base text-zinc-300 font-normal leading-relaxed line-clamp-3">
|
<p className="text-sm md:text-base text-zinc-300 font-normal leading-relaxed line-clamp-3">
|
||||||
{customHeroDescription || course.description}
|
{course.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Progress Bar (If started) */}
|
{/* Progress Bar (If started) */}
|
||||||
|
|
@ -94,23 +90,13 @@ export default function HeroBanner({ course, onPlay, onDetail, subscriptionStatu
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{course.progress > 0 ? (
|
<button
|
||||||
<button
|
onClick={() => onPlay(course.id)}
|
||||||
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"
|
||||||
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" />
|
||||||
<Info className="w-5 h-5 text-zinc-300" />
|
<span>Ver Detalhes</span>
|
||||||
<span>Continuar Treinamento</span>
|
</button>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,13 @@ interface NavbarProps {
|
||||||
email: string;
|
email: string;
|
||||||
role: 'admin' | 'student';
|
role: 'admin' | 'student';
|
||||||
subscriptionStatus: SubscriptionStatus;
|
subscriptionStatus: SubscriptionStatus;
|
||||||
avatarUrl?: string;
|
|
||||||
} | null;
|
} | null;
|
||||||
activeView: string;
|
activeView: string;
|
||||||
setView: (view: string) => void;
|
setView: (view: string) => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
settings?: { brandName?: string } | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Navbar({ user, activeView, setView, onLogout, settings }: NavbarProps) {
|
export default function Navbar({ user, activeView, setView, onLogout }: NavbarProps) {
|
||||||
const [showNotifications, setShowNotifications] = useState(false);
|
const [showNotifications, setShowNotifications] = useState(false);
|
||||||
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||||
const [notifications, setNotifications] = useState<any[]>([]);
|
const [notifications, setNotifications] = useState<any[]>([]);
|
||||||
|
|
@ -48,7 +46,7 @@ export default function Navbar({ user, activeView, setView, onLogout, settings }
|
||||||
try {
|
try {
|
||||||
// In a real app we would pass the token or it would be in cookies
|
// 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
|
// Assuming server uses some auth, for demo we might just fetch
|
||||||
const token = localStorage.getItem('app_token') || localStorage.getItem('admin_token');
|
const token = localStorage.getItem('token');
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
const res = await fetch('/api/notifications', {
|
const res = await fetch('/api/notifications', {
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
|
@ -64,7 +62,7 @@ export default function Navbar({ user, activeView, setView, onLogout, settings }
|
||||||
|
|
||||||
const markAsRead = async (id: string) => {
|
const markAsRead = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('app_token') || localStorage.getItem('admin_token');
|
const token = localStorage.getItem('token');
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
await fetch(`/api/notifications/${id}/read`, {
|
await fetch(`/api/notifications/${id}/read`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|
@ -100,57 +98,44 @@ export default function Navbar({ user, activeView, setView, onLogout, settings }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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'}`}
|
|
||||||
>
|
|
||||||
Cursos
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setView('subscription')}
|
|
||||||
className={`font-semibold transition-colors hover:text-[#E50914] flex items-center space-x-1.5 whitespace-nowrap flex-shrink-0 ${activeView === 'subscription' ? 'text-white' : 'text-zinc-400'}`}
|
|
||||||
>
|
|
||||||
<DollarSign className="w-4 h-4 text-[#E50914]" />
|
|
||||||
<span>Minha Assinatura</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setView('certificates')}
|
|
||||||
className={`font-semibold transition-colors hover:text-[#E50914] flex items-center space-x-1.5 whitespace-nowrap flex-shrink-0 ${activeView === 'certificates' ? 'text-white' : 'text-zinc-400'}`}
|
|
||||||
>
|
|
||||||
<GraduationCap className="w-4 h-4 text-[#E50914]" />
|
|
||||||
<span>Meus Certificados</span>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<div className="flex items-center space-x-8">
|
||||||
{/* LOGO */}
|
{/* LOGO */}
|
||||||
<div className="flex flex-col">
|
<div
|
||||||
<div
|
onClick={() => setView('home')}
|
||||||
onClick={() => setView('home')}
|
className="flex items-center space-x-2 cursor-pointer select-none group"
|
||||||
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">
|
||||||
<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" />
|
||||||
<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>
|
</div>
|
||||||
{settings?.brandSlogan && (
|
<span className="font-sans font-black text-xl md:text-2xl tracking-tighter text-[#E50914]">
|
||||||
<span className="text-[10px] text-zinc-400 font-medium ml-10 mt-0.5 max-w-[350px] leading-tight hidden md:block">
|
MICROTEC<span className="text-white">FLIX</span>
|
||||||
{settings.brandSlogan}
|
</span>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* NAVIGATION LINKS */}
|
{/* NAVIGATION LINKS */}
|
||||||
<div className="hidden md:flex items-center space-x-3 lg:space-x-6 text-sm flex-shrink-0">
|
<div className="hidden md:flex items-center space-x-3 lg:space-x-6 text-sm flex-shrink-0">
|
||||||
{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'}`}
|
||||||
|
>
|
||||||
|
Cursos
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView('subscription')}
|
||||||
|
className={`font-semibold transition-colors hover:text-[#E50914] flex items-center space-x-1.5 whitespace-nowrap flex-shrink-0 ${activeView === 'subscription' ? 'text-white' : 'text-zinc-400'}`}
|
||||||
|
>
|
||||||
|
<DollarSign className="w-4 h-4 text-[#E50914]" />
|
||||||
|
<span>Minha Assinatura</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView('certificates')}
|
||||||
|
className={`font-semibold transition-colors hover:text-[#E50914] flex items-center space-x-1.5 whitespace-nowrap flex-shrink-0 ${activeView === 'certificates' ? 'text-white' : 'text-zinc-400'}`}
|
||||||
|
>
|
||||||
|
<GraduationCap className="w-4 h-4 text-[#E50914]" />
|
||||||
|
<span>Meus Certificados</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -187,20 +172,22 @@ export default function Navbar({ user, activeView, setView, onLogout, settings }
|
||||||
<h4 className="text-xs font-bold text-white uppercase tracking-wider">Notificações</h4>
|
<h4 className="text-xs font-bold text-white uppercase tracking-wider">Notificações</h4>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-64 overflow-y-auto">
|
<div className="max-h-64 overflow-y-auto">
|
||||||
{notifications.filter(n => !n.read).length === 0 ? (
|
{notifications.length === 0 ? (
|
||||||
<div className="p-4 text-center text-zinc-500 text-xs">Nenhuma notificação no momento.</div>
|
<div className="p-4 text-center text-zinc-500 text-xs">Nenhuma notificação no momento.</div>
|
||||||
) : (
|
) : (
|
||||||
notifications.filter(n => !n.read).map(n => (
|
notifications.map(n => (
|
||||||
<div
|
<div
|
||||||
key={n.id}
|
key={n.id}
|
||||||
className={`p-3 border-b border-white/5 text-xs transition-colors bg-white/5 text-white`}
|
className={`p-3 border-b border-white/5 text-xs transition-colors ${n.read ? 'bg-transparent text-zinc-400' : 'bg-white/5 text-white'}`}
|
||||||
>
|
>
|
||||||
<p>{n.message}</p>
|
<p>{n.message}</p>
|
||||||
<div className="flex items-center justify-between mt-2">
|
<div className="flex items-center justify-between mt-2">
|
||||||
<span className="text-[10px] text-zinc-500">{new Date(n.createdAt).toLocaleDateString('pt-BR')}</span>
|
<span className="text-[10px] text-zinc-500">{new Date(n.createdAt).toLocaleDateString('pt-BR')}</span>
|
||||||
<button onClick={() => markAsRead(n.id)} className="text-[10px] text-[#E50914] hover:text-white flex items-center gap-1">
|
{!n.read && (
|
||||||
<Check className="w-3 h-3" /> Lida
|
<button onClick={() => markAsRead(n.id)} className="text-[10px] text-[#E50914] hover:text-white flex items-center gap-1">
|
||||||
</button>
|
<Check className="w-3 h-3" /> Lida
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|
@ -219,13 +206,9 @@ export default function Navbar({ user, activeView, setView, onLogout, settings }
|
||||||
<div className="relative" ref={profileRef}>
|
<div className="relative" ref={profileRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowProfileMenu(!showProfileMenu)}
|
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 overflow-hidden"
|
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"
|
||||||
>
|
>
|
||||||
{user.avatarUrl ? (
|
<User className="w-5 h-5" />
|
||||||
<img src={user.avatarUrl} alt="Avatar" className="w-full h-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<User className="w-5 h-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showProfileMenu && (
|
{showProfileMenu && (
|
||||||
|
|
@ -242,7 +225,7 @@ export default function Navbar({ user, activeView, setView, onLogout, settings }
|
||||||
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"
|
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" />
|
<GraduationCap className="w-4 h-4" />
|
||||||
<span>Meus Certificados</span>
|
<span>Meu Certificado</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setView('help'); setShowProfileMenu(false); }}
|
onClick={() => { setView('help'); setShowProfileMenu(false); }}
|
||||||
|
|
@ -264,11 +247,6 @@ export default function Navbar({ user, activeView, setView, onLogout, settings }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,592 +0,0 @@
|
||||||
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: 1–2 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;
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import { User as UserIcon, Shield, CreditCard, Mail, Phone, Calendar, ArrowLeft, Camera, Edit2, Save, X, Loader2, Fingerprint } from 'lucide-react';
|
import { User as UserIcon, Shield, CreditCard, Mail, Phone, Calendar, ArrowLeft, Camera, Edit2, Save, X, Loader2 } from 'lucide-react';
|
||||||
import { User } from '../types';
|
import { User } from '../types';
|
||||||
import { useModal } from '../contexts/ModalContext';
|
|
||||||
|
|
||||||
interface ProfileViewProps {
|
interface ProfileViewProps {
|
||||||
user: User;
|
user: User;
|
||||||
|
|
@ -10,30 +9,11 @@ interface ProfileViewProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewProps) {
|
export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewProps) {
|
||||||
const { error: showError, success: showSuccess } = useModal();
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [name, setName] = useState(user.name);
|
const [name, setName] = useState(user.name);
|
||||||
const [whatsapp, setWhatsapp] = useState(user.whatsapp || '');
|
const [whatsapp, setWhatsapp] = useState(user.whatsapp || '');
|
||||||
const [birthDate, setBirthDate] = useState(user.birthDate || '');
|
const [birthDate, setBirthDate] = useState(user.birthDate || '');
|
||||||
const [cpf, setCpf] = useState(user.cpf || '');
|
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 [isSaving, setIsSaving] = useState(false);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
|
|
@ -62,68 +42,29 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('app_token');
|
const token = localStorage.getItem('devflix_token');
|
||||||
const res = await fetch('/api/users/profile', {
|
const res = await fetch('/api/users/profile', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(token && { Authorization: `Bearer ${token}` })
|
...(token && { Authorization: `Bearer ${token}` })
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ name, whatsapp, birthDate, cpf, cep, address, number, complement, reference, neighborhood, city, state })
|
body: JSON.stringify({ name, whatsapp, birthDate, cpf })
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success && onUpdateUser) {
|
if (data.success && onUpdateUser) {
|
||||||
onUpdateUser(data.user);
|
onUpdateUser(data.user);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
} else {
|
} else {
|
||||||
showError(data.error || 'Erro ao atualizar perfil');
|
alert(data.error || 'Erro ao atualizar perfil');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError('Erro de conexão ao salvar perfil');
|
alert('Erro de conexão ao salvar perfil');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
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 handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
@ -133,7 +74,7 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP
|
||||||
formData.append('avatar', file);
|
formData.append('avatar', file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('app_token');
|
const token = localStorage.getItem('devflix_token');
|
||||||
const res = await fetch('/api/users/avatar', {
|
const res = await fetch('/api/users/avatar', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -145,10 +86,10 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP
|
||||||
if (data.success && onUpdateUser) {
|
if (data.success && onUpdateUser) {
|
||||||
onUpdateUser({ ...user, avatarUrl: data.avatarUrl });
|
onUpdateUser({ ...user, avatarUrl: data.avatarUrl });
|
||||||
} else {
|
} else {
|
||||||
showError(data.error || 'Erro ao fazer upload da foto');
|
alert(data.error || 'Erro ao fazer upload da foto');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError('Erro de conexão ao enviar foto');
|
alert('Erro de conexão ao enviar foto');
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -166,22 +107,13 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!isEditing ? (
|
{!isEditing ? (
|
||||||
<div className="flex items-center space-x-3">
|
<button
|
||||||
<button
|
onClick={() => setIsEditing(true)}
|
||||||
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"
|
||||||
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" />
|
||||||
<Fingerprint className="w-4 h-4" />
|
<span>Editar Perfil</span>
|
||||||
<span>Alterar Senha</span>
|
</button>
|
||||||
</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 cursor-pointer"
|
|
||||||
>
|
|
||||||
<Edit2 className="w-4 h-4" />
|
|
||||||
<span>Editar Perfil</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<button
|
<button
|
||||||
|
|
@ -191,16 +123,8 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP
|
||||||
setWhatsapp(user.whatsapp || '');
|
setWhatsapp(user.whatsapp || '');
|
||||||
setBirthDate(user.birthDate || '');
|
setBirthDate(user.birthDate || '');
|
||||||
setCpf(user.cpf || '');
|
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 cursor-pointer"
|
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"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
<span>Cancelar</span>
|
<span>Cancelar</span>
|
||||||
|
|
@ -208,7 +132,7 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isSaving}
|
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 cursor-pointer"
|
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"
|
||||||
>
|
>
|
||||||
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
<span>{isSaving ? 'Salvando...' : 'Salvar Alterações'}</span>
|
<span>{isSaving ? 'Salvando...' : 'Salvar Alterações'}</span>
|
||||||
|
|
@ -361,139 +285,6 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP
|
||||||
</div>
|
</div>
|
||||||
</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' && (
|
{user.role === 'admin' && (
|
||||||
<div className="mt-4 p-4 bg-[#E50914]/10 border border-[#E50914]/30 rounded-xl flex items-start space-x-3">
|
<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" />
|
<Shield className="w-5 h-5 text-[#E50914] mt-0.5" />
|
||||||
|
|
@ -506,77 +297,6 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,14 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
CreditCard, CheckCircle, RefreshCw, AlertTriangle, Calendar, ExternalLink,
|
CreditCard, CheckCircle, RefreshCw, AlertTriangle, Play, Calendar, DollarSign,
|
||||||
ShieldCheck, Layers, Sparkles, Clock3
|
ShieldCheck, ArrowRight, ExternalLink, QrCode
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { SubscriptionStatus, SubscriptionPayment } from '../types';
|
import { SubscriptionStatus, SubscriptionPayment } from '../types';
|
||||||
import { Plan } from '../types';
|
|
||||||
import PaymentCheckoutCard from './PaymentCheckoutCard';
|
|
||||||
|
|
||||||
interface SubscriptionViewProps {
|
interface SubscriptionViewProps {
|
||||||
user: {
|
user: {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
cpf?: string;
|
|
||||||
subscriptionStatus: SubscriptionStatus;
|
subscriptionStatus: SubscriptionStatus;
|
||||||
} | null;
|
} | null;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
|
|
@ -23,21 +20,42 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
|
||||||
const [subStatus, setSubStatus] = useState<SubscriptionStatus | null>(user ? user.subscriptionStatus : null);
|
const [subStatus, setSubStatus] = useState<SubscriptionStatus | null>(user ? user.subscriptionStatus : null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// Plans
|
// Checkout flow state
|
||||||
const [availablePlans, setAvailablePlans] = useState<Plan[]>([]);
|
const [billingType, setBillingType] = useState<'PIX' | 'CREDIT_CARD' | 'BOLETO'>('PIX');
|
||||||
const [loadingPlans, setLoadingPlans] = useState(true);
|
const [cardName, setCardName] = useState('');
|
||||||
const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
|
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);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSubscriptionDetails();
|
fetchSubscriptionDetails();
|
||||||
fetchPlans();
|
if (token) {
|
||||||
|
fetch('/api/settings', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data && data.enableBoleto === false) {
|
||||||
|
setEnableBoleto(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchSubscriptionDetails = async () => {
|
const fetchSubscriptionDetails = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/subscription/status', {
|
const res = await fetch('/api/subscription/status', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
@ -52,20 +70,55 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchPlans = async () => {
|
const handleCheckout = async (e: React.FormEvent) => {
|
||||||
setLoadingPlans(true);
|
e.preventDefault();
|
||||||
try {
|
setIsSubmitting(true);
|
||||||
const res = await fetch('/api/plans', {
|
setCheckoutError(null);
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
setSuccessPayload(null);
|
||||||
});
|
|
||||||
if (res.ok) {
|
const creditCardInfo = billingType === 'CREDIT_CARD' ? {
|
||||||
const data: Plan[] = await res.json();
|
creditCard: {
|
||||||
setAvailablePlans(data);
|
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'
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} : undefined;
|
||||||
console.error('Error fetching plans:', err);
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/subscription/subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
billingType,
|
||||||
|
creditCardInfo
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Erro ao processar assinatura');
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuccessPayload(data);
|
||||||
|
// Wait a moment and fetch updated profile status
|
||||||
|
await fetchSubscriptionDetails();
|
||||||
|
} catch (err: any) {
|
||||||
|
setCheckoutError(err.message || 'Erro de rede. Tente novamente.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingPlans(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -89,15 +142,6 @@ 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) => {
|
const getPaymentStatusTag = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'CONFIRMED':
|
case 'CONFIRMED':
|
||||||
|
|
@ -121,51 +165,27 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasPlans = availablePlans.length > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-4xl mx-auto">
|
<div className="space-y-8 max-w-4xl mx-auto">
|
||||||
{/* Title */}
|
{/* Top Title */}
|
||||||
<div className="border-b border-white/10 pb-4">
|
<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">
|
<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]" />
|
<CreditCard className="w-8 h-8 text-[#E50914]" />
|
||||||
<span>Minha Assinatura & Cobranças</span>
|
<span>Gestão de Assinatura & Cobranças</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-zinc-500 mt-1">
|
<p className="text-xs text-zinc-500 mt-1">Veja seus comprovantes, históricos de faturamento e mude seu plano de estudos.</p>
|
||||||
Veja seus comprovantes, histórico de faturamento e adquira planos ou cursos.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Checkout Modal (when a plan is selected) */}
|
{/* Main Grid: Status summary left, checkout right */}
|
||||||
{selectedPlan && (
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
<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();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={`grid grid-cols-1 ${hasPlans ? 'md:grid-cols-2' : ''} gap-8`}>
|
{/* Left Hand: Current Plan details & invoice history */}
|
||||||
|
|
||||||
{/* LEFT: Account status + Invoice history */}
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
||||||
{/* Account Status Card */}
|
|
||||||
<div className="glass-card p-6 rounded-2xl space-y-5">
|
<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>
|
<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="flex items-center space-x-4">
|
||||||
<div className="w-12 h-12 rounded-full glass-badge flex items-center justify-center">
|
<div className="w-12 h-12 rounded-full glass-badge flex items-center justify-center text-zinc-300">
|
||||||
<ShieldCheck className="w-6 h-6 text-[#E50914]" />
|
<ShieldCheck className="w-6 h-6 text-[#E50914]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
|
|
@ -174,7 +194,7 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-zinc-800 pt-4 space-y-2">
|
<div className="border-t border-zinc-850 pt-4 space-y-2">
|
||||||
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-wider">Status Atual do Acesso</p>
|
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-wider">Status Atual do Acesso</p>
|
||||||
{subStatus && (
|
{subStatus && (
|
||||||
<div className={`inline-block px-3 py-1.5 rounded-lg border text-sm font-extrabold ${getStatusColor(subStatus)}`}>
|
<div className={`inline-block px-3 py-1.5 rounded-lg border text-sm font-extrabold ${getStatusColor(subStatus)}`}>
|
||||||
|
|
@ -184,33 +204,37 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
|
||||||
|
|
||||||
{subStatus === 'TRIAL' && (
|
{subStatus === 'TRIAL' && (
|
||||||
<p className="text-xs text-zinc-400 font-medium pt-1">
|
<p className="text-xs text-zinc-400 font-medium pt-1">
|
||||||
Seu período de teste está ativo. Para continuar com acesso completo, adquira um dos planos abaixo.
|
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.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{subStatus === 'ACTIVE' && (
|
{subStatus === 'ACTIVE' && (
|
||||||
<p className="text-xs text-emerald-500/80 font-bold flex items-center space-x-1 pt-1">
|
<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" />
|
<CheckCircle className="w-4 h-4 fill-emerald-500/10" />
|
||||||
<span>Acesso integral liberado a todos os cursos!</span>
|
<span>Acesso integral liberado a todos os cursos de informática, hardware e servidores!</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{subStatus === 'OVERDUE' && (
|
{subStatus === 'OVERDUE' && (
|
||||||
<p className="text-xs text-red-500/80 font-semibold flex items-center space-x-1 pt-1">
|
<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" />
|
<AlertTriangle className="w-4 h-4 fill-red-500/10" />
|
||||||
<span>Seu acesso foi bloqueado por falta de pagamento. Regularize via um dos planos abaixo.</span>
|
<span>Seu acesso foi bloqueado por falta de pagamento. Regularize via Pix ou Cartão para liberar.</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Invoice History */}
|
{/* Past Payments History */}
|
||||||
<div className="glass-card p-6 rounded-2xl space-y-4">
|
<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>
|
<h3 className="font-display font-bold text-sm text-zinc-400 uppercase tracking-wider">Faturas e Segunda Via</h3>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{payments.length === 0 ? (
|
{payments.length === 0 ? (
|
||||||
<p className="text-xs text-zinc-500 italic">Nenhum pagamento registrado nesta conta.</p>
|
<p className="text-xs text-zinc-500 italic">Nenhum pagamento registrado nesta conta.</p>
|
||||||
) : (
|
) : (
|
||||||
payments.map(pay => (
|
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="space-y-1">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<span className="text-white text-xs font-bold">R$ {pay.value.toFixed(2)}</span>
|
<span className="text-white text-xs font-bold">R$ {pay.value.toFixed(2)}</span>
|
||||||
|
|
@ -221,13 +245,14 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
|
||||||
<span>Vence em: {new Date(pay.dueDate).toLocaleDateString('pt-BR')}</span>
|
<span>Vence em: {new Date(pay.dueDate).toLocaleDateString('pt-BR')}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={pay.invoiceUrl}
|
href={pay.invoiceUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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"
|
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>Detalhes</span>
|
<span>Boleto / Pix / Detalhes</span>
|
||||||
<ExternalLink className="w-3 h-3" />
|
<ExternalLink className="w-3 h-3" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -237,70 +262,177 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RIGHT: Plans (only shown if plans exist) */}
|
{/* Right Hand: Checkout Form (Create Subscription) */}
|
||||||
{hasPlans && (
|
<div className="space-y-6">
|
||||||
<div className="space-y-6">
|
<div className="glass-card p-6 rounded-2xl space-y-6">
|
||||||
<div className="glass-card p-6 rounded-2xl space-y-4">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center space-x-2">
|
<h3 className="font-display font-extrabold text-lg text-white tracking-tight">Assine o Plano Ilimitado</h3>
|
||||||
<Sparkles className="w-5 h-5 text-[#E50914]" />
|
<p className="text-xs text-zinc-500 font-semibold flex items-center space-x-1">
|
||||||
<h3 className="font-display font-bold text-sm text-zinc-400 uppercase tracking-wider">
|
<span>Trilha completa DevFlix por apenas</span>
|
||||||
Planos & Combos Disponíveis
|
<span className="text-[#E50914] font-black text-sm">R$ 49,90/mês</span>
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-zinc-500">
|
|
||||||
Escolha o plano ideal e adquira acesso aos conteúdos da plataforma.
|
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{loadingPlans ? (
|
{/* Billing Types Toggle */}
|
||||||
<div className="flex justify-center py-8">
|
<div className={`grid ${enableBoleto ? 'grid-cols-3' : 'grid-cols-2'} gap-2 p-1 glass-badge rounded-lg`}>
|
||||||
<RefreshCw className="w-6 h-6 animate-spin text-[#E50914]" />
|
<button
|
||||||
</div>
|
type="button"
|
||||||
) : (
|
onClick={() => { setBillingType('PIX'); setCheckoutError(null); }}
|
||||||
<div className="space-y-3">
|
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'}`}
|
||||||
{availablePlans.map(plan => (
|
>
|
||||||
<div
|
PIX
|
||||||
key={plan.id}
|
</button>
|
||||||
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"
|
<button
|
||||||
onClick={() => setSelectedPlan(plan)}
|
type="button"
|
||||||
>
|
onClick={() => { setBillingType('CREDIT_CARD'); setCheckoutError(null); }}
|
||||||
{plan.cycle === 'LIFETIME' && (
|
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'}`}
|
||||||
<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
|
Cartão de Crédito
|
||||||
</div>
|
</button>
|
||||||
)}
|
{enableBoleto && (
|
||||||
|
<button
|
||||||
<div className="flex items-start gap-3">
|
type="button"
|
||||||
<div className="mt-0.5">
|
onClick={() => { setBillingType('BOLETO'); setCheckoutError(null); }}
|
||||||
<Layers className="w-5 h-5 text-[#E50914]" />
|
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'}`}
|
||||||
</div>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
Boleto
|
||||||
<h4 className="font-bold text-white text-sm leading-tight">{plan.title}</h4>
|
</button>
|
||||||
{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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
{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>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useRef, useState, useEffect } from 'react';
|
import React, { useRef, useState, useEffect } from 'react';
|
||||||
import { Play, Pause, RotateCcw, Volume2, Maximize, CheckCircle, ExternalLink, ThumbsUp, ThumbsDown } from 'lucide-react';
|
import { Play, Pause, RotateCcw, Volume2, Maximize, CheckCircle, ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
interface VideoPlayerProps {
|
interface VideoPlayerProps {
|
||||||
videoUrl: string;
|
videoUrl: string;
|
||||||
|
|
@ -7,11 +7,9 @@ interface VideoPlayerProps {
|
||||||
lessonId: string;
|
lessonId: string;
|
||||||
onProgressUpdate: (percentage: number) => void;
|
onProgressUpdate: (percentage: number) => void;
|
||||||
initialProgress?: number;
|
initialProgress?: number;
|
||||||
reaction?: string | null;
|
|
||||||
onReact?: (type: string | null) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VideoPlayer({ videoUrl, videoType, lessonId, onProgressUpdate, initialProgress = 0, reaction, onReact }: VideoPlayerProps) {
|
export default function VideoPlayer({ videoUrl, videoType, lessonId, onProgressUpdate, initialProgress = 0 }: VideoPlayerProps) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
|
@ -223,24 +221,7 @@ export default function VideoPlayer({ videoUrl, videoType, lessonId, onProgressU
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full sm:w-auto flex justify-end items-center gap-2">
|
<div className="w-full sm:w-auto flex justify-end">
|
||||||
{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 ? (
|
{!isCompleted ? (
|
||||||
<button
|
<button
|
||||||
onClick={triggerManualCompletion}
|
onClick={triggerManualCompletion}
|
||||||
|
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
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 já 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, vá 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"
|
|
||||||
>
|
|
||||||
Já 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,203 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -95,58 +95,3 @@ html, body {
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
import { SubscriptionPayment } from '../types';
|
import { SubscriptionPayment } from '../types';
|
||||||
import { prisma } from '../db/prisma';
|
import { loadDb } from '../db/db';
|
||||||
|
|
||||||
export class AsaasService {
|
export class AsaasService {
|
||||||
public static async getApiKey(): Promise<string | null> {
|
public static getApiKey(): string | null {
|
||||||
const db = await prisma.systemSettings.findUnique({ where: { id: 'default' } });
|
const db = loadDb();
|
||||||
return db?.asaasApiKey || process.env.ASAAS_API_KEY || null;
|
return db.settings?.asaasApiKey || process.env.ASAAS_API_KEY || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async getApiUrl(): Promise<string> {
|
private static getApiUrl(): string {
|
||||||
const db = await prisma.systemSettings.findUnique({ where: { id: 'default' } });
|
const db = loadDb();
|
||||||
const env = db?.asaasEnvironment || 'sandbox';
|
const env = db.settings?.asaasEnvironment || 'sandbox';
|
||||||
return env === 'production' ? 'https://api.asaas.com/v3' : 'https://sandbox.asaas.com/api/v3';
|
return env === 'production' ? 'https://api.asaas.com/v3' : 'https://sandbox.asaas.com/api/v3';
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async getHeaders(): Promise<Record<string, string>> {
|
private static getHeaders() {
|
||||||
const key = await this.getApiKey();
|
const key = this.getApiKey();
|
||||||
return {
|
return {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'access_token': key || ''
|
'access_token': key || ''
|
||||||
|
|
@ -25,9 +25,8 @@ export class AsaasService {
|
||||||
* Test the connection to Asaas API
|
* Test the connection to Asaas API
|
||||||
*/
|
*/
|
||||||
public static async testConnection(customApiKey?: string, customEnv?: string): Promise<{ success: boolean; message: string; environment: string }> {
|
public static async testConnection(customApiKey?: string, customEnv?: string): Promise<{ success: boolean; message: string; environment: string }> {
|
||||||
const apiKey = customApiKey || await this.getApiKey();
|
const apiKey = customApiKey || this.getApiKey();
|
||||||
const db = await prisma.systemSettings.findUnique({ where: { id: 'default' } });
|
const env = customEnv || loadDb().settings?.asaasEnvironment || 'sandbox';
|
||||||
const env = customEnv || db?.asaasEnvironment || 'sandbox';
|
|
||||||
const apiUrl = env === 'production' ? 'https://api.asaas.com/v3' : 'https://sandbox.asaas.com/v3';
|
const apiUrl = env === 'production' ? 'https://api.asaas.com/v3' : 'https://sandbox.asaas.com/v3';
|
||||||
|
|
||||||
if (!apiKey || apiKey.trim() === '') {
|
if (!apiKey || apiKey.trim() === '') {
|
||||||
|
|
@ -75,7 +74,7 @@ export class AsaasService {
|
||||||
* Create a customer in Asaas
|
* Create a customer in Asaas
|
||||||
*/
|
*/
|
||||||
public static async createCustomer(name: string, email: string, cpfCnpj?: string): Promise<string> {
|
public static async createCustomer(name: string, email: string, cpfCnpj?: string): Promise<string> {
|
||||||
const apiKey = await this.getApiKey();
|
const apiKey = this.getApiKey();
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
console.warn('ASAAS_API_KEY not configured. Simulating customer creation.');
|
console.warn('ASAAS_API_KEY not configured. Simulating customer creation.');
|
||||||
return `cus_sim_${Math.random().toString(36).substring(2, 9)}`;
|
return `cus_sim_${Math.random().toString(36).substring(2, 9)}`;
|
||||||
|
|
@ -92,9 +91,9 @@ export class AsaasService {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${await this.getApiUrl()}/customers`, {
|
const response = await fetch(`${this.getApiUrl()}/customers`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: await this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -112,39 +111,6 @@ 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
|
* Create a recurring subscription in Asaas
|
||||||
*/
|
*/
|
||||||
|
|
@ -154,9 +120,9 @@ export class AsaasService {
|
||||||
billingType: 'PIX' | 'CREDIT_CARD' | 'BOLETO',
|
billingType: 'PIX' | 'CREDIT_CARD' | 'BOLETO',
|
||||||
creditCardInfo?: any,
|
creditCardInfo?: any,
|
||||||
cycle: 'MONTHLY' | 'YEARLY' = 'MONTHLY',
|
cycle: 'MONTHLY' | 'YEARLY' = 'MONTHLY',
|
||||||
description: string = 'Assinatura Mensal da Plataforma'
|
description: string = 'Assinatura Mensal DevFlix'
|
||||||
): Promise<{ subscriptionId: string; payment: Partial<SubscriptionPayment> }> {
|
): Promise<{ subscriptionId: string; payment: Partial<SubscriptionPayment> }> {
|
||||||
const apiKey = await this.getApiKey();
|
const apiKey = this.getApiKey();
|
||||||
if (!apiKey) {
|
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.');
|
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.');
|
||||||
}
|
}
|
||||||
|
|
@ -176,9 +142,9 @@ export class AsaasService {
|
||||||
payload.creditCardHolderInfo = creditCardInfo.creditCardHolderInfo;
|
payload.creditCardHolderInfo = creditCardInfo.creditCardHolderInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${await this.getApiUrl()}/subscriptions`, {
|
const response = await fetch(`${this.getApiUrl()}/subscriptions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: await this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -226,7 +192,7 @@ export class AsaasService {
|
||||||
* Fetch PIX QR Code for a given payment ID
|
* Fetch PIX QR Code for a given payment ID
|
||||||
*/
|
*/
|
||||||
public static async getPixQrCode(paymentId: string): Promise<{ encodedImage: string, payload: string } | null> {
|
public static async getPixQrCode(paymentId: string): Promise<{ encodedImage: string, payload: string } | null> {
|
||||||
const apiKey = await this.getApiKey();
|
const apiKey = this.getApiKey();
|
||||||
const fallbackPayload = `00020126580014br.gov.bcb.pix0136${paymentId}_microtecflix520400005303986540549.905802BR5915MicrotecFlix6009Sao Paulo62070503***6304`;
|
const fallbackPayload = `00020126580014br.gov.bcb.pix0136${paymentId}_microtecflix520400005303986540549.905802BR5915MicrotecFlix6009Sao Paulo62070503***6304`;
|
||||||
|
|
||||||
if (!apiKey || paymentId.startsWith('pay_sim_') || paymentId.startsWith('pay_fb_')) {
|
if (!apiKey || paymentId.startsWith('pay_sim_') || paymentId.startsWith('pay_fb_')) {
|
||||||
|
|
@ -237,9 +203,9 @@ export class AsaasService {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${await this.getApiUrl()}/payments/${paymentId}/pixQrCode`, {
|
const response = await fetch(`${this.getApiUrl()}/payments/${paymentId}/pixQrCode`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: await this.getHeaders()
|
headers: this.getHeaders()
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -271,7 +237,7 @@ export class AsaasService {
|
||||||
creditCard: any,
|
creditCard: any,
|
||||||
creditCardHolderInfo: any
|
creditCardHolderInfo: any
|
||||||
): Promise<{ creditCardToken: string; creditCardNumber: string; creditCardBrand: string }> {
|
): Promise<{ creditCardToken: string; creditCardNumber: string; creditCardBrand: string }> {
|
||||||
const apiKey = await this.getApiKey();
|
const apiKey = this.getApiKey();
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return {
|
return {
|
||||||
creditCardToken: `tok_sim_${Math.random().toString(36).substring(2, 9)}`,
|
creditCardToken: `tok_sim_${Math.random().toString(36).substring(2, 9)}`,
|
||||||
|
|
@ -281,9 +247,9 @@ export class AsaasService {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${await this.getApiUrl()}/creditCard/tokenize`, {
|
const response = await fetch(`${this.getApiUrl()}/creditCard/tokenize`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: await this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
customer: customerId,
|
customer: customerId,
|
||||||
creditCard,
|
creditCard,
|
||||||
|
|
@ -314,7 +280,7 @@ export class AsaasService {
|
||||||
creditCardInfo?: any,
|
creditCardInfo?: any,
|
||||||
installmentCount?: number
|
installmentCount?: number
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const apiKey = await this.getApiKey();
|
const apiKey = this.getApiKey();
|
||||||
const futureDueDate = new Date(Date.now() + 3 * 24 * 3600 * 1000).toISOString().split('T')[0];
|
const futureDueDate = new Date(Date.now() + 3 * 24 * 3600 * 1000).toISOString().split('T')[0];
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
|
|
@ -344,9 +310,9 @@ export class AsaasService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${await this.getApiUrl()}/payments`, {
|
const response = await fetch(`${this.getApiUrl()}/payments`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: await this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -362,32 +328,4 @@ export class AsaasService {
|
||||||
throw err;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
src/types.ts
31
src/types.ts
|
|
@ -15,18 +15,6 @@ export interface User {
|
||||||
whatsapp?: string;
|
whatsapp?: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
trialEndsAt?: string | null;
|
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;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,22 +26,6 @@ export interface SystemSettings {
|
||||||
asaasWebhookSecret: string;
|
asaasWebhookSecret: string;
|
||||||
helpText: string;
|
helpText: string;
|
||||||
enableBoleto?: boolean;
|
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 {
|
export interface Course {
|
||||||
|
|
@ -66,7 +38,6 @@ export interface Course {
|
||||||
isLocked?: boolean; // Se true, o aluno precisa comprar ou assinar para acessar
|
isLocked?: boolean; // Se true, o aluno precisa comprar ou assinar para acessar
|
||||||
price?: number; // Preço para compra avulsa (vitalícia)
|
price?: number; // Preço para compra avulsa (vitalícia)
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
lessons?: Lesson[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Plan {
|
export interface Plan {
|
||||||
|
|
@ -96,9 +67,7 @@ export interface Lesson {
|
||||||
videoType: 'youtube' | 'direct';
|
videoType: 'youtube' | 'direct';
|
||||||
order: number;
|
order: number;
|
||||||
content: string; // Additional lesson resources/description
|
content: string; // Additional lesson resources/description
|
||||||
thumbnailUrl?: string;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
reaction?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Note {
|
export interface Note {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue