diff --git a/.env.production.example b/.env.production.example index 165d640..3ac1567 100644 --- a/.env.production.example +++ b/.env.production.example @@ -7,6 +7,10 @@ # Docker Hub (usado pelo docker-compose.prod.yml) DOCKERHUB_USERNAME=seu-usuario-dockerhub +# ── Domínios ────────────────────────────────────────────── +DOMAIN=valida-ai.online +DOMAIN_ALT=valida-ai.site + # ── Banco de Dados ──────────────────────────────────────── DB_USER=valida_user DB_PASSWORD=TROQUE_POR_SENHA_FORTE @@ -30,7 +34,7 @@ JWT_EXPIRES_IN=7d # ── CORS (URL pública do frontend) ──────────────────────── # Exemplo: http://ec2-XX-XX-XX-XX.compute-1.amazonaws.com # Ou seu domínio: https://app.validaai.com.br -CORS_ORIGIN=http://SEU_IP_OU_DOMINIO +CORS_ORIGIN=https://SEU_DOMINIO,https://SEU_DOMINIO_ALT # ── URL do frontend (usada no QR Code dos certificados) ── FRONTEND_URL=http://valida-ai.online diff --git a/backend/src/aplicativo.ts b/backend/src/aplicativo.ts index 4758af4..83def3a 100644 --- a/backend/src/aplicativo.ts +++ b/backend/src/aplicativo.ts @@ -17,6 +17,13 @@ import { registrarHandlers } from './eventos/registrar'; const aplicativo: Express = express(); +// Rotas internas (antes do CORS — acesso apenas via rede interna) +aplicativo.use('/', rotasVerificacao); +aplicativo.get('/metrics', async (_req, res) => { + res.set('Content-Type', registro.contentType); + res.end(await registro.metrics()); +}); + // Middlewares de segurança aplicativo.use(helmet()); @@ -53,14 +60,7 @@ aplicativo.use((req, _res, next) => { next(); }); -// Métricas para o Prometheus -aplicativo.get('/metrics', async (_req, res) => { - res.set('Content-Type', registro.contentType); - res.end(await registro.metrics()); -}); - // Rotas -aplicativo.use('/', rotasVerificacao); aplicativo.use('/api/publica', rotasPublica); aplicativo.use('/api/auth', rotasAuth); aplicativo.use('/api/usuarios', rotasUsuarios); @@ -79,7 +79,7 @@ aplicativo.use((_req, res) => { }); // Manipulador de erros -aplicativo.use((err: unknown, _req: express.Request, res: express.Response) => { +aplicativo.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { const mensagem = err instanceof Error ? err.message : 'Erro Interno do Servidor'; const status = (err as { status?: number }).status || 500; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 154bc38..097c7db 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -8,6 +8,7 @@ services: - "443:443" environment: DOMAIN: ${DOMAIN} + DOMAIN_ALT: ${DOMAIN_ALT} volumes: - ./infra/nginx/default.conf.template:/etc/nginx/templates/default.conf.template:ro - ./certbot/conf:/etc/letsencrypt:ro diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 86233b0..e036d64 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -7,7 +7,7 @@ import { PERFIL_LABEL, PERFIL_COR, iniciais } from '../utils/perfil'; import { SunIcon, MoonIcon } from './icons'; const linkCls = ({ isActive }: { isActive: boolean }) => - `px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${ + `px-4 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${ isActive ? 'bg-[#618C7C]/15 text-[#7AAA9A] border border-[#618C7C]/25' : 'text-white/55 border border-transparent hover:text-white hover:bg-white/5' @@ -20,66 +20,48 @@ export function Layout() { const [menuAberto, setMenuAberto] = useState(false); - const eEstudante = usuario?.perfil === 'estudante'; - const eCoordenadorPuro = usuario?.perfil === 'coordenador'; - const eCoordenador = eCoordenadorPuro || usuario?.perfil === 'admin'; + const perfil = usuario?.perfil; function handleLogout() { logout(); navigate('/login'); } - const navLinks = (mobile = false) => ( - <> - setMenuAberto(false) : undefined}> - Início - - setMenuAberto(false) : undefined}> - Documentos - - {eEstudante && ( - setMenuAberto(false) : undefined}> - Certificados - - )} - {eCoordenador && ( - setMenuAberto(false) : undefined}> - Fila de Análise - - )} - {eCoordenadorPuro && ( - setMenuAberto(false) : undefined}> - Alunos - - )} - setMenuAberto(false) : undefined}> - Perfil + const allLinks: { to: string; label: string; perfis?: string[] }[] = [ + { to: '/dashboard', label: 'Início' }, + { to: '/documentos', label: 'Documentos' }, + { to: '/certificados', label: 'Certificados', perfis: ['estudante'] }, + { to: '/documentos?status=pendente', label: 'Fila de Análise', perfis: ['coordenador', 'admin'] }, + { to: '/alunos', label: 'Alunos', perfis: ['coordenador'] }, + { to: '/perfil', label: 'Perfil' }, + { to: '/usuarios', label: 'Usuários', perfis: ['admin'] }, + { to: '/instituicoes', label: 'Instituições', perfis: ['admin'] }, + { to: '/cursos', label: 'Cursos', perfis: ['admin'] }, + ]; + + const visibleLinks = allLinks.filter((l) => !l.perfis || (perfil && l.perfis.includes(perfil))); + + const navLinks = (mobile = false) => + visibleLinks.map((l) => ( + setMenuAberto(false) : undefined} + > + {l.label} - {usuario?.perfil === 'admin' && ( - <> - setMenuAberto(false) : undefined}> - Usuários - - setMenuAberto(false) : undefined}> - Instituições - - setMenuAberto(false) : undefined}> - Cursos - - - )} - - ); + )); return (
{/* Header */}
-
+
{/* Logo */} - -
- + +
+
@@ -90,25 +72,25 @@ export function Layout() { {/* Ações do header */} -
+
{/* Botão tema */}
{usuario?.nome ? iniciais(usuario.nome) : '?'} @@ -125,7 +107,7 @@ export function Layout() { @@ -133,7 +115,7 @@ export function Layout() { + +
+ {NAV_ITEMS.map(({ id, label }) => ( + + ))} +
+ +
+ + Entrar + + + Criar conta + + +
+
+ + {menuOpen && ( +
+
+ {NAV_ITEMS.map(({ id, label }) => ( + + ))} + + Criar conta + +
+ )} + - - Entrar - -
+ {/* ── Hero ── */} +
+ + - {/* Hero */} -
+
- - Captação e validação de documentos universitários + {/* Badge animado */} + + + + + + Plataforma de validação acadêmica -

- Centralize e valide os certificados acadêmicos da sua instituição +

+ Centralize e valide os{' '} + + certificados acadêmicos + + + + {' '} + da sua instituição

-

- O ValidaAI conecta estudantes e coordenadores em um único lugar: estudantes enviam - certificados de cursos, eventos e atividades complementares, e coordenadores - validam cada documento com segurança e rastreabilidade. +

+ O ValidaAI conecta estudantes e coordenadores em um único lugar: envie certificados + de cursos, eventos e atividades complementares, e tenha cada documento + validado com segurança e rastreabilidade.

-
+
- Criar conta de estudante + Começar agora + + + - + Já tenho uma conta
- {/* Recursos */} -
- {recursos.map((recurso) => ( + {/* Stats com ícones */} +
+ {[ + { value: '100%', label: 'Digital', icon: 'M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25' }, + { value: 'QR Code', label: 'Verificável', icon: 'M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z' }, + { value: '24/7', label: 'Disponível', icon: 'M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z' }, + ].map((stat) => ( +
+ +
{stat.value}
+
{stat.label}
+
+ ))} +
+
+ + {/* Scroll indicator */} + +
+ + + + {/* ── Como Funciona ── */} +
+
+ + +
+ {/* Linha conectora vertical (mobile) / horizontal (desktop) */} +
+ +
+ {etapas.map((etapa, i) => ( +
+
+ {etapa.numero} +
+
+

{etapa.titulo}

+

{etapa.descricao}

+
+
+ ))} +
+
+
+
+ + + + {/* ── Funcionalidades ── */} +
+ +
+ + +
+ {funcionalidades.map((item, i) => (
-
- - {recurso.icone} - + {/* Número decorativo */} + + {String(i + 1).padStart(2, '0')} + +
+
-

{recurso.titulo}

-

{recurso.descricao}

+

{item.titulo}

+

{item.descricao}

))}
- +
+
+ + + + {/* ── Benefícios ── */} +
+
+ + +
+ {beneficios.map((grupo, i) => ( +
+ {/* Top accent bar */} +
+ +
+
+ +
+

{grupo.titulo}

+
+
    + {grupo.items.map((item) => ( +
  • + + + + {item} +
  • + ))} +
+
+ ))} +
+
+
+ + + + {/* ── Segurança ── */} +
+ {/* Shield background decorativo */} + + + + +
+ + +
+ {segurancaItems.map((item, i) => ( +
+
+ +
+

{item.titulo}

+

{item.descricao}

+
+ ))} +
+
+
+ + + + {/* ── Contato ── */} +
+
+ + +
+
+ + + +
+ +

+ O ValidaAI é um projeto acadêmico desenvolvido como + Trabalho de Conclusão de Curso (TCC). Para dúvidas, sugestões ou interesse em + colaborar, entre em contato pelo GitHub: +

+ + + + + + github.com/icrcode + +
+
+
+ + {/* ── Footer ── */} +
+
+
+
+ + + +
+ + ValidaAI + +
+ +
+ {NAV_ITEMS.map(({ id, label }) => ( + + ))} +
-
- Validação inteligente de documentos acadêmicos -
-
+

© {new Date().getFullYear()} ValidaAI

+
+ ); } diff --git a/frontend/src/test/App.test.tsx b/frontend/src/test/App.test.tsx index 85d8e17..fb1195d 100644 --- a/frontend/src/test/App.test.tsx +++ b/frontend/src/test/App.test.tsx @@ -40,7 +40,7 @@ describe('App', () => { it('exibe convite para criar conta na tela de apresentação quando não autenticado', () => { render(); - const link = screen.queryByRole('link', { name: /criar conta de estudante/i }); + const link = screen.queryByRole('link', { name: /começar agora/i }); expect(link).toBeInTheDocument(); }); }); diff --git a/frontend/src/test/pages/Apresentacao.test.tsx b/frontend/src/test/pages/Apresentacao.test.tsx new file mode 100644 index 0000000..be7c6ce --- /dev/null +++ b/frontend/src/test/pages/Apresentacao.test.tsx @@ -0,0 +1,224 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, fireEvent } from '@testing-library/react'; +import { renderWithProviders } from '../helpers/renderWithProviders'; +import { Apresentacao } from '../../pages/Apresentacao'; + +vi.mock('../../services/api', () => ({ + default: { + get: vi.fn().mockResolvedValue({ data: [] }), + post: vi.fn().mockResolvedValue({ data: {} }), + interceptors: { + request: { use: vi.fn() }, + response: { use: vi.fn() }, + }, + }, +})); + +beforeEach(() => { + localStorage.clear(); +}); + +function renderPage() { + return renderWithProviders(); +} + +describe('Apresentacao', () => { + it('renderiza sem erros', () => { + const { container } = renderPage(); + expect(container).toBeTruthy(); + }); + + it('exibe o logo ValidaAI', () => { + renderPage(); + const logos = screen.getAllByText(/ValidaAI/i); + expect(logos.length).toBeGreaterThan(0); + }); + + it('exibe o título principal do hero', () => { + renderPage(); + expect(screen.getByText(/centralize e valide os/i)).toBeInTheDocument(); + expect(screen.getByText(/certificados acadêmicos/i)).toBeInTheDocument(); + }); + + it('exibe o badge de plataforma', () => { + renderPage(); + expect(screen.getByText(/plataforma de validação acadêmica/i)).toBeInTheDocument(); + }); + + it('exibe o CTA "Começar agora"', () => { + renderPage(); + const links = screen.getAllByRole('link', { name: /começar agora/i }); + expect(links.length).toBeGreaterThan(0); + }); + + it('exibe o link "Já tenho uma conta"', () => { + renderPage(); + const links = screen.getAllByRole('link', { name: /já tenho uma conta/i }); + expect(links.length).toBeGreaterThan(0); + }); + + it('exibe os stats do hero', () => { + renderPage(); + expect(screen.getByText('100%')).toBeInTheDocument(); + expect(screen.getByText('QR Code')).toBeInTheDocument(); + expect(screen.getByText('24/7')).toBeInTheDocument(); + }); + + it('exibe os labels dos stats', () => { + renderPage(); + expect(screen.getByText('Digital')).toBeInTheDocument(); + expect(screen.getByText('Verificável')).toBeInTheDocument(); + expect(screen.getByText('Disponível')).toBeInTheDocument(); + }); + + // Navbar + it('exibe os itens de navegação no desktop', () => { + renderPage(); + expect(screen.getAllByText('Início').length).toBeGreaterThan(0); + expect(screen.getAllByText('Funcionalidades').length).toBeGreaterThan(0); + expect(screen.getAllByText('Benefícios').length).toBeGreaterThan(0); + expect(screen.getAllByText('Segurança').length).toBeGreaterThan(0); + expect(screen.getAllByText('Contato').length).toBeGreaterThan(0); + }); + + it('exibe os botões Entrar e Criar conta na navbar', () => { + renderPage(); + expect(screen.getAllByText('Entrar')[0]).toBeInTheDocument(); + expect(screen.getAllByText('Criar conta')[0]).toBeInTheDocument(); + }); + + it('abre o menu mobile ao clicar no hamburger', () => { + renderPage(); + const hamburgers = screen.getAllByRole('button'); + const hamburger = hamburgers.find((b) => b.querySelector('path[d*="3.75 6.75"]')); + expect(hamburger).toBeTruthy(); + fireEvent.click(hamburger!); + // Após abrir, o botão muda para o X (close icon) + const closeBtn = screen.getAllByRole('button').find((b) => b.querySelector('path[d*="M6 18"]')); + expect(closeBtn).toBeTruthy(); + }); + + // Seção "Como funciona" + it('exibe as 4 etapas do fluxo', () => { + renderPage(); + expect(screen.getByText('Cadastre-se')).toBeInTheDocument(); + expect(screen.getByText('Envie')).toBeInTheDocument(); + expect(screen.getByText('Validação')).toBeInTheDocument(); + expect(screen.getByText('Compartilhe')).toBeInTheDocument(); + }); + + it('exibe os números das etapas', () => { + renderPage(); + expect(screen.getAllByText('01').length).toBeGreaterThan(0); + expect(screen.getAllByText('02').length).toBeGreaterThan(0); + expect(screen.getAllByText('03').length).toBeGreaterThan(0); + expect(screen.getAllByText('04').length).toBeGreaterThan(0); + }); + + // Seção Funcionalidades + it('exibe os cards de funcionalidades', () => { + renderPage(); + expect(screen.getByText('Envio de Certificados')).toBeInTheDocument(); + expect(screen.getByText('Validação por Coordenadores')).toBeInTheDocument(); + expect(screen.getByText('Verificação Pública')).toBeInTheDocument(); + expect(screen.getByText('Multi-Instituição')).toBeInTheDocument(); + expect(screen.getByText('Dashboard Inteligente')).toBeInTheDocument(); + expect(screen.getByText('Geração de Certificados')).toBeInTheDocument(); + }); + + // Seção Benefícios + it('exibe os três perfis de benefícios', () => { + renderPage(); + expect(screen.getByText('Para Estudantes')).toBeInTheDocument(); + expect(screen.getByText('Para Coordenadores')).toBeInTheDocument(); + expect(screen.getByText('Para Instituições')).toBeInTheDocument(); + }); + + it('exibe os itens de benefícios dos estudantes', () => { + renderPage(); + expect(screen.getByText(/envie certificados de qualquer lugar/i)).toBeInTheDocument(); + expect(screen.getByText(/acompanhe o status/i)).toBeInTheDocument(); + }); + + // Seção Segurança + it('exibe os cards de segurança', () => { + renderPage(); + expect(screen.getByText('Criptografia TLS 1.2/1.3')).toBeInTheDocument(); + expect(screen.getByText('Autenticação Segura')).toBeInTheDocument(); + expect(screen.getByText('Controle de Acesso')).toBeInTheDocument(); + expect(screen.getByText('QR Code Verificável')).toBeInTheDocument(); + expect(screen.getByText('Headers de Segurança')).toBeInTheDocument(); + expect(screen.getByText('Armazenamento S3')).toBeInTheDocument(); + }); + + // Seção Contato + it('exibe a seção de contato com menção ao TCC', () => { + renderPage(); + const tccTexts = screen.getAllByText(/trabalho de conclusão de curso/i); + expect(tccTexts.length).toBeGreaterThan(0); + }); + + it('exibe o link para o GitHub', () => { + renderPage(); + const ghLink = screen.getByRole('link', { name: /github\.com\/icrcode/i }); + expect(ghLink).toBeInTheDocument(); + expect(ghLink).toHaveAttribute('href', 'https://github.com/icrcode'); + expect(ghLink).toHaveAttribute('target', '_blank'); + }); + + // Footer + it('exibe o footer com copyright', () => { + renderPage(); + const year = new Date().getFullYear().toString(); + expect(screen.getByText(new RegExp(year))).toBeInTheDocument(); + }); + + it('exibe links de navegação no footer', () => { + renderPage(); + // Footer duplica os nav items + const inicioButtons = screen.getAllByText('Início'); + expect(inicioButtons.length).toBeGreaterThanOrEqual(2); + }); + + // Seções heading + it('exibe os headings das seções', () => { + renderPage(); + expect(screen.getByText('Tudo que você precisa em um só lugar')).toBeInTheDocument(); + expect(screen.getByText('Vantagens para cada perfil')).toBeInTheDocument(); + expect(screen.getByText('Seus dados estão protegidos')).toBeInTheDocument(); + expect(screen.getAllByText('Entre em contato').length).toBeGreaterThan(0); + }); + + // Redirect + it('redireciona para /dashboard quando autenticado', () => { + renderWithProviders(, { + token: 'tok-test', + usuario: { + id: 'u-1', nome: 'Teste', email: 'teste@test.com', perfil: 'estudante', + matricula: '2021001', cpf: null, endereco: null, curso_id: 'c-1', + instituicao_id: 'i-1', instituicao_nome: 'Universidade Teste', ativo: true, + }, + }); + expect(screen.queryByText(/plataforma de validação acadêmica/i)).not.toBeInTheDocument(); + }); + + // Elementos gráficos + it('renderiza o grid pattern SVG', () => { + const { container } = renderPage(); + const patterns = container.querySelectorAll('pattern#grid'); + expect(patterns.length).toBeGreaterThan(0); + }); + + it('renderiza as formas flutuantes', () => { + const { container } = renderPage(); + const floats = container.querySelectorAll('.landing-float-slow, .landing-float-med, .landing-float-fast'); + expect(floats.length).toBeGreaterThan(0); + }); + + it('renderiza os dividers entre seções', () => { + const { container } = renderPage(); + // Dividers have the gradient line + dot pattern + const dots = container.querySelectorAll('.rounded-full.bg-\\[\\#618C7C\\]\\/25'); + expect(dots.length).toBeGreaterThan(0); + }); +}); diff --git a/infra/nginx/default.conf.template b/infra/nginx/default.conf.template index 43bbda5..8e568c8 100644 --- a/infra/nginx/default.conf.template +++ b/infra/nginx/default.conf.template @@ -1,7 +1,7 @@ # ── HTTP: challenge do Certbot + redirect para HTTPS ────────────── server { listen 80; - server_name ${DOMAIN} www.${DOMAIN}; + server_name ${DOMAIN} www.${DOMAIN} ${DOMAIN_ALT} www.${DOMAIN_ALT}; location /.well-known/acme-challenge/ { root /var/www/certbot; @@ -15,7 +15,7 @@ server { # ── HTTPS ────────────────────────────────────────────────────────── server { listen 443 ssl; - server_name ${DOMAIN} www.${DOMAIN}; + server_name ${DOMAIN} www.${DOMAIN} ${DOMAIN_ALT} www.${DOMAIN_ALT}; ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem;