feat(prematricula): duplicate checks, draft warning, flyer banner uploader in MinIO, clean hours
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 2m9s Details

This commit is contained in:
Sidney 2026-05-28 20:09:41 -03:00
parent c112570065
commit 640604a5df
4 changed files with 182 additions and 34 deletions

View File

@ -3,7 +3,7 @@ import { SchoolData, PreMatriculaCampo, PreMatriculaConfig, PreMatriculaInscrica
import { import {
Plus, Trash2, Save, Eye, EyeOff, Download, GripVertical, Link2, Plus, Trash2, Save, Eye, EyeOff, Download, GripVertical, Link2,
ClipboardPen, Copy, Check, ChevronDown, ChevronRight, Users, RefreshCw, ClipboardPen, Copy, Check, ChevronDown, ChevronRight, Users, RefreshCw,
FileText, X, Settings2, ArrowLeft, ExternalLink, AlertCircle, UserCheck, Edit FileText, X, Settings2, ArrowLeft, ExternalLink, AlertCircle, UserCheck, Edit, Upload
} from 'lucide-react'; } from 'lucide-react';
interface Props { interface Props {
@ -14,7 +14,8 @@ interface Props {
const FIELD_TYPES: Record<string, string> = { const FIELD_TYPES: Record<string, string> = {
text: 'Texto', email: 'Email', phone: 'Telefone', cpf: 'CPF', text: 'Texto', email: 'Email', phone: 'Telefone', cpf: 'CPF',
date: 'Data', select: 'Seleção', textarea: 'Texto Longo', number: 'Número' date: 'Data', select: 'Seleção', textarea: 'Texto Longo', number: 'Número',
file: 'Imagem/Documento'
}; };
const DAY_NAMES = ['Domingo', 'Segunda-feira', 'Terça-feira', 'Quarta-feira', 'Quinta-feira', 'Sexta-feira', 'Sábado']; const DAY_NAMES = ['Domingo', 'Segunda-feira', 'Terça-feira', 'Quarta-feira', 'Quinta-feira', 'Sexta-feira', 'Sábado'];
@ -339,13 +340,9 @@ const PreMatricula: React.FC<Props> = ({ data, onConvert }) => {
{config.turmasPermitidas.map(id => { {config.turmasPermitidas.map(id => {
const turma = turmas.find(t => t.id === id); const turma = turmas.find(t => t.id === id);
if (!turma) return null; if (!turma) return null;
const dayName = (turma.dia_semana !== null && turma.dia_semana !== undefined && DAY_NAMES[parseInt(turma.dia_semana)]) const horarioStr = turma.horario_inicio_padrao && turma.horario_fim_padrao
? DAY_NAMES[parseInt(turma.dia_semana)] ? `${turma.horario_inicio_padrao} às ${turma.horario_fim_padrao}`
: (turma.horario || ''); : 'Horário não definido';
const timeStr = turma.horario_inicio_padrao && turma.horario_fim_padrao
? ` das ${turma.horario_inicio_padrao} às ${turma.horario_fim_padrao}`
: '';
const horarioStr = `${dayName}${timeStr}`.trim() || 'Horário não definido';
return ( return (
<div key={id} className="flex items-center justify-between p-3.5 bg-slate-50 border border-slate-100 rounded-2xl shadow-sm text-xs group hover:border-indigo-100 transition-colors"> <div key={id} className="flex items-center justify-between p-3.5 bg-slate-50 border border-slate-100 rounded-2xl shadow-sm text-xs group hover:border-indigo-100 transition-colors">
<div className="flex-1 min-w-0 mr-3"> <div className="flex-1 min-w-0 mr-3">
@ -391,6 +388,61 @@ const PreMatricula: React.FC<Props> = ({ data, onConvert }) => {
Se nenhuma turma for adicionada, todas as turmas ativas aparecerão para seleção do aluno. Defina o limite de vagas (deixe em branco para ilimitado). Se nenhuma turma for adicionada, todas as turmas ativas aparecerão para seleção do aluno. Defina o limite de vagas (deixe em branco para ilimitado).
</p> </p>
</div> </div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Folder / Panfleto de Promoção (Banner)</label>
{config?.bannerUrl ? (
<div className="relative group border border-slate-200 rounded-xl overflow-hidden shadow-sm mb-3">
<img src={config.bannerUrl} alt="Banner" className="w-full h-32 object-cover" />
<div className="absolute inset-0 bg-slate-900/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<button
type="button"
onClick={() => setConfig(prev => prev ? { ...prev, bannerUrl: '' } : prev)}
className="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white text-[11px] font-bold rounded-lg shadow transition-all active:scale-95"
>
Remover Folder
</button>
</div>
</div>
) : (
<div className="flex items-center justify-center w-full mb-3">
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-slate-200 border-dashed rounded-xl cursor-pointer bg-slate-50 hover:bg-slate-100/70 border-slate-300 transition-all">
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<Upload className="w-8 h-8 text-slate-400 mb-2" />
<p className="text-xs text-slate-500 font-bold">Upload do Folder/Flyer</p>
<p className="text-[9px] text-slate-400 mt-1">PNG, JPG ou WEBP até 10MB</p>
</div>
<input
type="file"
accept="image/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/prematricula/upload', {
method: 'POST',
body: formData
});
if (res.ok) {
const result = await res.json();
setConfig(prev => prev ? { ...prev, bannerUrl: result.url } : prev);
} else {
alert('Falha ao enviar o folder.');
}
} catch (err) {
console.error(err);
alert('Erro de conexão ao enviar.');
}
}}
/>
</label>
</div>
)}
</div>
<div> <div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Mensagem de Sucesso</label> <label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Mensagem de Sucesso</label>
<textarea className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-medium resize-none" rows={2} <textarea className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-medium resize-none" rows={2}
@ -679,7 +731,25 @@ const PreMatricula: React.FC<Props> = ({ data, onConvert }) => {
<td className="p-4"> <td className="p-4">
<p className="font-bold text-slate-800">{insc.nome}</p> <p className="font-bold text-slate-800">{insc.nome}</p>
{insc.respostas && Object.keys(insc.respostas).length > 0 && ( {insc.respostas && Object.keys(insc.respostas).length > 0 && (
<p className="text-[10px] text-slate-400 mt-0.5">{Object.keys(insc.respostas).length} campos extras</p> <div className="mt-2 space-y-1 bg-slate-50 p-2.5 rounded-xl border border-slate-100 text-[11px] max-w-xs">
{Object.entries(insc.respostas).map(([key, value]) => {
const field = campos.find(c => c.id === key);
const label = field ? field.label : key;
const isUrl = typeof value === 'string' && (value.startsWith('http') || value.startsWith('/storage/'));
return (
<div key={key} className="flex flex-col sm:flex-row sm:items-center gap-1">
<span className="font-bold text-slate-500">{label}:</span>
{isUrl ? (
<a href={value} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-700 hover:underline font-extrabold flex items-center gap-1">
📄 Ver Documento
</a>
) : (
<span className="text-slate-800 font-semibold truncate">{String(value)}</span>
)}
</div>
);
})}
</div>
)} )}
</td> </td>
<td className="p-4"> <td className="p-4">

View File

@ -47,7 +47,7 @@ import {
getFrequencias, insertFrequencia, updateFrequencia, deleteFrequencia, getFrequencias, insertFrequencia, updateFrequencia, deleteFrequencia,
getProvas, getQuestoesDaProva, insertProva, updateProva, deleteProva, syncQuestoesProva getProvas, getQuestoesDaProva, insertProva, updateProva, deleteProva, syncQuestoesProva
} from './services/database.js'; } from './services/database.js';
import { uploadLogo as uploadLogoToStorage, uploadCarne as uploadCarneToStorage, uploadReceipt as uploadReceiptToStorage, getMinioStats, s3Client, getBucketObjects, deleteMinioObject } from './services/storage.js'; import { uploadLogo as uploadLogoToStorage, uploadCarne as uploadCarneToStorage, uploadReceipt as uploadReceiptToStorage, getMinioStats, s3Client, getBucketObjects, deleteMinioObject, uploadFile } from './services/storage.js';
import { GetObjectCommand } from '@aws-sdk/client-s3'; import { GetObjectCommand } from '@aws-sdk/client-s3';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@ -163,11 +163,16 @@ async function init(){
const res=await fetch('/api/prematricula/public/'+slug); const res=await fetch('/api/prematricula/public/'+slug);
if(!res.ok){app.innerHTML='<div class="error-page"><h2>Formulário indisponível</h2><p>Este formulário não está publicado ou o link é inválido.</p></div>';return} if(!res.ok){app.innerHTML='<div class="error-page"><h2>Formulário indisponível</h2><p>Este formulário não está publicado ou o link é inválido.</p></div>';return}
const data=await res.json(); const data=await res.json();
if(data.draft){
app.innerHTML='<div class="error-page"><h2>Todas as turmas estão lotadas</h2><p style="margin-top:12px;font-weight:600;color:#64748b">Em breve abriremos novas vagas. Fique atento!</p></div>';
return;
}
const c=data.config; const c=data.config;
document.documentElement.style.setProperty('--primary',c.corPrimaria||'#4f46e5'); document.documentElement.style.setProperty('--primary',c.corPrimaria||'#4f46e5');
let html='<div class="top-bar"></div><div class="header">'; let html='<div class="top-bar"></div><div class="header">';
if(data.escola.logo)html+='<img src="'+data.escola.logo+'" alt="Logo">'; if(data.escola.logo)html+='<img src="'+data.escola.logo+'" alt="Logo">';
html+='<h1>'+c.titulo+'</h1><p>'+c.descricao+'</p></div>'; html+='<h1>'+c.titulo+'</h1><p>'+c.descricao+'</p></div>';
if(c.bannerUrl)html+='<div style="padding:0 32px 24px;text-align:center"><img src="'+c.bannerUrl+'" alt="Banner" style="width:100%;max-height:350px;object-fit:cover;border-radius:16px;box-shadow:0 4px 12px rgba(0,0,0,.05)"></div>';
html+='<form class="form" id="preForm" onsubmit="return submitForm(event)">'; html+='<form class="form" id="preForm" onsubmit="return submitForm(event)">';
// Render fields dynamically in their exact database order // Render fields dynamically in their exact database order
@ -185,8 +190,7 @@ async function init(){
html+='<select name="_turma"'+(f.obrigatorio?' required':'')+'><option value="">Selecione a turma...</option>'; html+='<select name="_turma"'+(f.obrigatorio?' required':'')+'><option value="">Selecione a turma...</option>';
data.turmas.forEach(t=>{ data.turmas.forEach(t=>{
const info=t.horario?' ('+t.horario+')':''; const info=t.horario?' ('+t.horario+')':'';
const vagasInfo=t.vagas!==null?' - '+t.vagasRestantes+' vagas restando':''; const label=t.esgotado?t.nome+info+' [ESGOTADO]':t.nome+info;
const label=t.esgotado?t.nome+info+' [ESGOTADO]':t.nome+info+vagasInfo;
const disabled=t.esgotado?' disabled':''; const disabled=t.esgotado?' disabled':'';
html+='<option value="'+t.id+'" data-nome="'+t.nome+'"'+disabled+'>'+label+'</option>'; html+='<option value="'+t.id+'" data-nome="'+t.nome+'"'+disabled+'>'+label+'</option>';
}); });
@ -209,7 +213,9 @@ async function init(){
} }
} }
if(f.tipo==='select'){ if(f.tipo==='file'){
html+='<input type="file" name="'+f.id+'"'+(f.obrigatorio?' required':'')+' accept="image/*,application/pdf">';
}else if(f.tipo==='select'){
html+='<select name="'+f.id+'"'+(f.obrigatorio?' required':'')+'><option value="">Selecione...</option>'; html+='<select name="'+f.id+'"'+(f.obrigatorio?' required':'')+'><option value="">Selecione...</option>';
(f.opcoes||[]).forEach(o=>{html+='<option value="'+o+'">'+o+'</option>'}); (f.opcoes||[]).forEach(o=>{html+='<option value="'+o+'">'+o+'</option>'});
html+='</select>'; html+='</select>';
@ -255,7 +261,36 @@ async function submitForm(e){
const turmaId=turmaSelect?turmaSelect.value:''; const turmaId=turmaSelect?turmaSelect.value:'';
const turmaNome=turmaSelect?turmaSelect.options[turmaSelect.selectedIndex]?.dataset?.nome||'':''; const turmaNome=turmaSelect?turmaSelect.options[turmaSelect.selectedIndex]?.dataset?.nome||'':'';
const respostas={}; const respostas={};
for(const[k,v]of fd.entries()){if(!k.startsWith('_'))respostas[k]=v}
// Upload file inputs to MinIO first
const fileInputs = form.querySelectorAll('input[type="file"]');
for (const input of fileInputs) {
const file = input.files[0];
if (file) {
btn.textContent = 'Enviando arquivo...';
const fileFd = new FormData();
fileFd.append('file', file);
try {
const uploadRes = await fetch('/api/prematricula/upload', {
method: 'POST',
body: fileFd
});
if (!uploadRes.ok) throw new Error();
const uploadData = await uploadRes.json();
respostas[input.name] = uploadData.url;
} catch(err) {
alert('Falha ao enviar arquivo. Tente novamente.');
btn.disabled=false;btn.textContent='Enviar Pré-Matrícula';
return false;
}
}
}
for(const[k,v]of fd.entries()){
if(!k.startsWith('_') && !(v instanceof File)){
respostas[k]=v;
}
}
try{ try{
const res=await fetch('/api/prematricula/submit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({nome,email,telefone,turmaId,turmaNome,respostas})}); const res=await fetch('/api/prematricula/submit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({nome,email,telefone,turmaId,turmaNome,respostas})});
if(res.ok){ if(res.ok){
@ -2998,7 +3033,8 @@ async function startServer() {
mensagemSucesso: r.mensagem_sucesso, mensagemSucesso: r.mensagem_sucesso,
turmasPermitidas: r.turmas_permitidas || [], turmasPermitidas: r.turmas_permitidas || [],
exibirEmail: r.exibir_email !== false, exibirEmail: r.exibir_email !== false,
exibirTelefone: r.exibir_telefone !== false exibirTelefone: r.exibir_telefone !== false,
bannerUrl: r.banner_url || ''
}}); }});
} catch (e) { console.error(e); res.status(500).json({ error: e.message }); } } catch (e) { console.error(e); res.status(500).json({ error: e.message }); }
}); });
@ -3009,11 +3045,11 @@ async function startServer() {
await pool.query( await pool.query(
`UPDATE prematricula_config SET titulo=$1, descricao=$2, slug=$3, status=$4, `UPDATE prematricula_config SET titulo=$1, descricao=$2, slug=$3, status=$4,
cor_primaria=$5, logo_url=$6, mensagem_sucesso=$7, turmas_permitidas=$8, cor_primaria=$5, logo_url=$6, mensagem_sucesso=$7, turmas_permitidas=$8,
exibir_email=$9, exibir_telefone=$10, updated_at=NOW() WHERE id=1`, exibir_email=$9, exibir_telefone=$10, banner_url=$11, updated_at=NOW() WHERE id=1`,
[ [
c.titulo, c.descricao, c.slug, c.status, c.corPrimaria || '#4f46e5', c.titulo, c.descricao, c.slug, c.status, c.corPrimaria || '#4f46e5',
c.logoUrl || '', c.mensagemSucesso || '', c.turmasPermitidas || [], c.logoUrl || '', c.mensagemSucesso || '', c.turmasPermitidas || [],
c.exibirEmail !== false, c.exibirTelefone !== false c.exibirEmail !== false, c.exibirTelefone !== false, c.bannerUrl || ''
] ]
); );
res.json({ success: true }); res.json({ success: true });
@ -3105,6 +3141,26 @@ async function startServer() {
} catch (e) { console.error(e); res.status(500).json({ error: e.message }); } } catch (e) { console.error(e); res.status(500).json({ error: e.message }); }
}); });
// Upload público de arquivos de pré-matrícula para o MinIO
const prematriculaUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 } // 10MB
});
app.post('/api/prematricula/upload', prematriculaUpload.single('file'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'Nenhum arquivo enviado.' });
}
const fileName = `pre_${Date.now()}_${Math.random().toString(36).substring(7)}_${req.file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_')}`;
const url = await uploadFile('prematriculas', fileName, req.file.buffer, req.file.mimetype);
res.json({ url });
} catch (e) {
console.error('[PreMatricula:Upload] Erro de upload:', e);
res.status(500).json({ error: 'Erro ao fazer upload do arquivo no storage local.' });
}
});
// Submissão pública // Submissão pública
app.post('/api/prematricula/submit', async (req, res) => { app.post('/api/prematricula/submit', async (req, res) => {
try { try {
@ -3114,6 +3170,27 @@ async function startServer() {
const config = (await pool.query('SELECT status FROM prematricula_config WHERE id = 1')).rows[0]; const config = (await pool.query('SELECT status FROM prematricula_config WHERE id = 1')).rows[0];
if (!config || config.status !== 'published') return res.status(403).json({ error: 'Formulário não disponível.' }); if (!config || config.status !== 'published') return res.status(403).json({ error: 'Formulário não disponível.' });
// Impedir duplicidade por e-mail ou telefone
if ((email && email.trim()) || (telefone && telefone.trim())) {
let dupQuery = 'SELECT id FROM prematriculas WHERE 1=0';
const params = [];
if (email && email.trim()) {
dupQuery += ' OR LOWER(email) = LOWER($' + (params.length + 1) + ')';
params.push(email.trim());
}
if (telefone && telefone.trim()) {
const cleanPhone = telefone.replace(/\D/g, '');
if (cleanPhone) {
dupQuery += " OR REGEXP_REPLACE(telefone, '\\D', '', 'g') = $" + (params.length + 1);
params.push(cleanPhone);
}
}
const { rows: dupRows } = await pool.query(dupQuery, params);
if (dupRows.length > 0) {
return res.status(400).json({ error: 'Você já realizou uma pré-matrícula com esses dados de contato.' });
}
}
// Validação rigorosa de vagas // Validação rigorosa de vagas
if (turmaId) { if (turmaId) {
const { rows: tRows } = await pool.query('SELECT vagas_prematricula FROM turmas WHERE id = $1', [turmaId]); const { rows: tRows } = await pool.query('SELECT vagas_prematricula FROM turmas WHERE id = $1', [turmaId]);
@ -3137,16 +3214,19 @@ async function startServer() {
} catch (e) { console.error(e); res.status(500).json({ error: e.message }); } } catch (e) { console.error(e); res.status(500).json({ error: e.message }); }
}); });
// Página pública de pré-matrícula (API que retorna dados do formulário)
// Handler compartilhado para evitar duplicação
async function handlePublicPreMatricula(req, res, slug) { async function handlePublicPreMatricula(req, res, slug) {
try { try {
const { rows: cfgRows } = await pool.query( const { rows: cfgRows } = await pool.query(
'SELECT * FROM prematricula_config WHERE (slug = $1 OR id = 1) AND status = $2 ORDER BY id ASC LIMIT 1', 'SELECT * FROM prematricula_config WHERE (slug = $1 OR id = 1) ORDER BY id ASC LIMIT 1',
[slug, 'published'] [slug]
); );
if (cfgRows.length === 0) return res.status(404).json({ error: 'Formulário não encontrado ou não publicado.' }); if (cfgRows.length === 0) return res.status(404).json({ error: 'Formulário não encontrado.' });
const cfg = cfgRows[0]; const cfg = cfgRows[0];
if (cfg.status === 'draft') {
return res.json({ draft: true });
}
const { rows: camposRows } = await pool.query('SELECT * FROM prematricula_campos WHERE ativo = true ORDER BY ordem ASC'); const { rows: camposRows } = await pool.query('SELECT * FROM prematricula_campos WHERE ativo = true ORDER BY ordem ASC');
// Buscar contagens de pré-matrículas ativas por turma para calcular as vagas restantes // Buscar contagens de pré-matrículas ativas por turma para calcular as vagas restantes
@ -3169,7 +3249,8 @@ async function startServer() {
res.json({ res.json({
config: { config: {
titulo: cfg.titulo, descricao: cfg.descricao, corPrimaria: cfg.cor_primaria, titulo: cfg.titulo, descricao: cfg.descricao, corPrimaria: cfg.cor_primaria,
logoUrl: cfg.logo_url, mensagemSucesso: cfg.mensagem_sucesso logoUrl: cfg.logo_url, mensagemSucesso: cfg.mensagem_sucesso,
bannerUrl: cfg.banner_url
}, },
campos: camposRows.map(r => ({ campos: camposRows.map(r => ({
id: r.id, label: r.label, tipo: r.tipo, placeholder: r.placeholder, id: r.id, label: r.label, tipo: r.tipo, placeholder: r.placeholder,
@ -3179,14 +3260,9 @@ async function startServer() {
const count = prematriculaCounts[t.id] || 0; const count = prematriculaCounts[t.id] || 0;
const vagas = t.vagas_prematricula; const vagas = t.vagas_prematricula;
const esgotado = vagas !== null && count >= vagas; const esgotado = vagas !== null && count >= vagas;
const DAY_NAMES = ['Domingo', 'Segunda-feira', 'Terça-feira', 'Quarta-feira', 'Quinta-feira', 'Sexta-feira', 'Sábado']; const horarioStr = t.horario_inicio_padrao && t.horario_fim_padrao
const dayName = t.dia_semana !== null && t.dia_semana !== undefined && DAY_NAMES[parseInt(t.dia_semana)] ? `${t.horario_inicio_padrao} às ${t.horario_fim_padrao}`
? DAY_NAMES[parseInt(t.dia_semana)]
: (t.horario || '');
const timeStr = t.horario_inicio_padrao && t.horario_fim_padrao
? ` das ${t.horario_inicio_padrao} às ${t.horario_fim_padrao}`
: ''; : '';
const horarioStr = `${dayName}${timeStr}`.trim() || 'Horário não definido';
return { return {
id: t.id, id: t.id,
nome: t.nome, nome: t.nome,
@ -3218,8 +3294,8 @@ async function startServer() {
try { try {
// Verifica se a slug atual corresponde a alguma configuração de pré-matrícula publicada // Verifica se a slug atual corresponde a alguma configuração de pré-matrícula publicada
const { rows } = await pool.query( const { rows } = await pool.query(
'SELECT slug FROM prematricula_config WHERE slug = $1 AND status = $2 LIMIT 1', 'SELECT slug FROM prematricula_config WHERE slug = $1 LIMIT 1',
[slug, 'published'] [slug]
); );
if (rows.length > 0) { if (rows.length > 0) {
// Se bater, serve a página pública renderizando a slug dinâmica! // Se bater, serve a página pública renderizando a slug dinâmica!

View File

@ -194,3 +194,4 @@ ensureBucketExists('logos');
ensureBucketExists('fotos-alunos'); ensureBucketExists('fotos-alunos');
ensureBucketExists('atestados'); ensureBucketExists('atestados');
ensureBucketExists('exames'); ensureBucketExists('exames');
ensureBucketExists('prematriculas');

View File

@ -281,6 +281,7 @@ export interface PreMatriculaConfig {
turmasPermitidas?: string[]; turmasPermitidas?: string[];
exibirEmail?: boolean; exibirEmail?: boolean;
exibirTelefone?: boolean; exibirTelefone?: boolean;
bannerUrl?: string;
} }
export interface PreMatriculaInscricao { export interface PreMatriculaInscricao {