// ============ AcademIA8 · Consola — Campañas · EDITOR (crear/editar) + vista enviada ============
const TZ_LABEL = 'hora de Córdoba (GMT-3)';
const MESES = ['ene','feb','mar','abr','may','jun','jul','ago','sep','oct','nov','dic'];
function fmtCampDateTime(iso){
if(!iso) return '—';
const [d,t] = iso.split('T');
const [y,m,day] = d.split('-');
return `${+day} ${MESES[+m-1]} ${y}, ${t||''} hs`;
}
function tomorrowStr(){ const d = new Date('2026-07-07T00:00:00'); d.setDate(d.getDate()+1); return d.toISOString().slice(0,10); }
// ---------- modal: editar remitente (config global) ----------
function SenderModal({ remitente, onClose, onSave }){
const [nombre, setNombre] = useState(remitente.nombre);
const [email, setEmail] = useState(remitente.email);
return (
Cancelar onSave({nombre:nombre.trim(),email:email.trim()})}>Guardar >}>
setNombre(e.target.value)} placeholder="AcademIA8" />
setEmail(e.target.value)} placeholder="hola@academia8.com" />
);
}
// ---------- modal: confirmar envío ----------
function ConfirmSend({ n, modo, cuando, asunto, remitente, busy, onClose, onConfirm }){
return (
Volver
{busy ? 'Procesando…' : (modo==='ahora'?'Enviar ahora':'Programar envío')}
>}>
{n.toLocaleString('es-AR')}
{n===1?'persona activa recibirá esta campaña':'personas activas recibirán esta campaña'}
{modo==='ahora' ? 'Se envía ahora mismo' : `${fmtCampDateTime(cuando)} · ${TZ_LABEL}`}
Asunto {asunto||'—'}
Remitente {remitente.nombre} · {remitente.email}
);
}
// ---------- preview expandida ----------
function PreviewOverlay({ html, onClose }){
useEffect(()=>{ const h=(e)=>{ if(e.key==='Escape') onClose(); }; window.addEventListener('keydown',h); return ()=>window.removeEventListener('keydown',h); },[]);
return (
{ if(e.target===e.currentTarget) onClose(); }}>
);
}
// =================== EDITOR ===================
function CampaignEditor({ campId, notify, onDone }){
const idRef = useRef(campId==='new' ? null : campId);
const readyRef = useRef(false);
const [loading, setLoading] = useState(true);
const [tags, setTags] = useState([]);
const [config, setConfig] = useState({ remitente:{nombre:'',email:''} });
const [asunto, setAsunto] = useState('');
const [modo, setModo] = useState('todos'); // todos | etiquetas
const [etiquetas, setEtiquetas] = useState([]);
const [html, setHtml] = useState('');
const [conteo, setConteo] = useState({ total:0, excluidos:0 });
const [pruebas, setPruebas] = useState('');
const [pruebaRes, setPruebaRes] = useState(null);
const [pruebaBusy, setPruebaBusy] = useState(false);
const [warnings, setWarnings] = useState([]);
const [sendMode, setSendMode] = useState('ahora'); // ahora | programar
const [fecha, setFecha] = useState(tomorrowStr());
const [hora, setHora] = useState('09:00');
const [confirming, setConfirming] = useState(false);
const [sendBusy, setSendBusy] = useState(false);
const [saveState, setSaveState] = useState('idle');
const [senderModal, setSenderModal] = useState(false);
const [expand, setExpand] = useState(false);
// ---- carga inicial ----
useEffect(()=>{ (async()=>{
try{
const [t, cfg] = await Promise.all([CampAPI.listarEtiquetas(), CampAPI.obtenerConfig()]);
setTags(t); setConfig(cfg);
if (idRef.current){
const c = await CampAPI.obtenerCampania(idRef.current);
if (c){ setAsunto(c.asunto||''); setModo(c.destinatarios.modo); setEtiquetas(c.destinatarios.etiquetas||[]); setHtml(c.html||''); setPruebas((c.pruebasEnviadas||[]).join(', ')); }
}
}catch(e){ notify(e.message||'No se pudo cargar la campaña'); }
setLoading(false);
setTimeout(()=>{ readyRef.current = true; }, 60);
})(); },[]);
// ---- conteo de destinatarios en vivo ----
useEffect(()=>{ if(loading) return; let alive=true; (async()=>{
try{
let r;
if (idRef.current && saveState==='saved'){ r = await CampAPI.previewCampania(idRef.current); }
else { r = await CampAPI.contarDestinatarios(modo, etiquetas); }
if(alive) setConteo(r);
}catch(e){ /* mantené el conteo previo */ }
})(); return ()=>{ alive=false; }; },[modo, etiquetas, loading, saveState]);
// ---- autosave (borrador) ----
// El remitente es global (config/sender), no por campaña: no se manda en el body.
const hasContent = ()=> asunto.trim() || html.trim() || etiquetas.length || modo==='etiquetas';
const buildData = ()=>({ asunto, destinatarios:{ modo, etiquetas }, html });
const persist = async ()=>{
setSaveState('saving');
try{
let c;
if (idRef.current){ c = await CampAPI.actualizarCampania(idRef.current, buildData()); }
else { c = await CampAPI.crearCampania(buildData()); if(c) idRef.current = c.id; }
setWarnings(c && c.warnings ? c.warnings : []);
setSaveState('saved');
return c;
}catch(e){
// "por etiqueta" sin etiqueta todavía: no se puede guardar aún, sin ruido.
if (e && e.code==='no_recipients'){ setSaveState('dirty'); return null; }
setSaveState('dirty');
notify(e.message || 'No se pudo guardar el borrador');
throw e;
}
};
useEffect(()=>{
if (!readyRef.current) return;
if (!idRef.current && !hasContent()) return;
setSaveState('dirty');
const t = setTimeout(()=>{ persist().catch(()=>{}); }, 900);
return ()=>clearTimeout(t);
},[asunto, modo, etiquetas, html]);
const toggleTag = (id)=> setEtiquetas(s=> s.includes(id)?s.filter(x=>x!==id):[...s,id]);
// ---- pasos completos ----
const okDest = conteo.total>0;
const okAsunto = asunto.trim().length>0;
const okHtml = html.trim().length>0;
const okProg = sendMode==='ahora' || (fecha && hora);
const puedeEnviar = okDest && okAsunto && okHtml && okProg;
// ---- prueba ----
const dirsPrueba = pruebas.split(',').map(s=>s.trim()).filter(Boolean);
const enviarPrueba = async ()=>{
if(!dirsPrueba.length){ notify('Ingresá al menos una dirección'); return; }
if(dirsPrueba.length>CampAPI.MAX_PRUEBA){ notify(`El máximo es ${CampAPI.MAX_PRUEBA} direcciones de prueba`); return; }
if(!okHtml){ notify('Primero pegá el contenido HTML'); return; }
setPruebaBusy(true);
try{
if(!idRef.current){ await persist(); if(!idRef.current){ setPruebaBusy(false); notify('Elegí destinatarios antes de probar'); return; } }
// En sandbox de Amazon SES validamos las direcciones contra las verificadas ANTES de llamar.
let toSend = dirsPrueba, localRej = [];
if (config.sandbox){
const verif = (config.verificadas||[]).map(e=>e.toLowerCase());
toSend = dirsPrueba.filter(e=> verif.includes(e.toLowerCase()));
localRej = dirsPrueba.filter(e=> !verif.includes(e.toLowerCase()))
.map(e=>({ email:e, ok:false, error:'No está verificada en Amazon (modo prueba). Verificala antes de enviarle.' }));
}
let apiRes = [];
if (toSend.length){ const res = await CampAPI.enviarPrueba(idRef.current, toSend); apiRes = res.resultados; }
const all = dirsPrueba.map(e=>
apiRes.find(r=>r.email.toLowerCase()===e.toLowerCase())
|| localRej.find(r=>r.email.toLowerCase()===e.toLowerCase())
|| { email:e, ok:false, error:'No se pudo procesar' });
setPruebaRes(all);
const enviadas = all.filter(r=>r.ok).length;
const rech = all.length - enviadas;
if(rech===0) notify(`Prueba enviada a ${enviadas} dirección${enviadas===1?'':'es'}`);
else if(enviadas) notify(`${enviadas} enviada${enviadas===1?'':'s'} · ${rech} rechazada${rech===1?'':'s'}`);
else notify('Ninguna dirección pudo recibir la prueba');
}catch(e){ setPruebaRes(null); notify(e.message||'No se pudo enviar la prueba'); }
setPruebaBusy(false);
};
// ---- guardar remitente global (aplica a TODAS las campañas) ----
const saveSender = async (r)=>{
try{ const out = await CampAPI.guardarRemitente(r); setConfig(c=>({ ...c, remitente: out })); setSenderModal(false); notify('Remitente actualizado'); }
catch(e){ notify(e.message||'No se pudo guardar el remitente'); }
};
// ---- confirmar envío ----
const doSend = async ()=>{
setSendBusy(true);
try{
await persist();
if (!idRef.current){ setSendBusy(false); notify('No se pudo guardar la campaña'); return; }
if (sendMode==='ahora'){
await CampAPI.enviarAhora(idRef.current);
notify('Envío iniciado · la campaña se está enviando');
} else {
await CampAPI.programarCampania(idRef.current, `${fecha}T${hora}`);
notify('Campaña programada');
}
setConfirming(false); setSendBusy(false);
onDone();
}catch(e){ setSendBusy(false); notify(e.message||'No se pudo procesar el envío'); }
};
const saveLabel = saveState==='saving' ? 'Guardando…' : saveState==='saved' ? 'Borrador guardado' : saveState==='dirty' ? 'Sin guardar' : '';
if (loading) return ;
const remitente = config.remitente || { nombre:'Academia 8', email:'cursos@'+CampAPI.DOMINIO_ENVIO };
return (
{/* barra superior del editor */}
Volver a campañas
{asunto.trim() || 'Nueva campaña'}
{saveLabel &&
{saveState==='saved' && }{saveLabel} }
El asunto (paso 2) es el nombre de la campaña y lo que ve quien recibe el correo en su bandeja.
{/* ---------- columna principal ---------- */}
{/* 1 · DESTINATARIOS */}
1
Destinatarios
Elegí a quiénes les llega. Podés combinar varias etiquetas.
{okDest &&
Listo}
setModo('todos')}>
Todos los contactos Toda tu base activa
setModo('etiquetas')}>
Por etiqueta Una o varias etiquetas
{modo==='etiquetas' && (
{tags.map(t=>(
toggleTag(t.id)}>
{etiquetas.includes(t.id) && } {t.nombre}
))}
)}
{conteo.aprox?'≈ ':''}{conteo.total.toLocaleString('es-AR')}
{conteo.total===1?'destinatario activo':'destinatarios activos'}
{conteo.aprox &&
Estimado por etiqueta. El total exacto (sin bajas ni repetidos) se calcula al guardarse el borrador.
}
{conteo.excluidos>0 && (
Se excluyen {conteo.excluidos} contacto{conteo.excluidos===1?'':'s'} por baja o rebote
)}
{/* 2 · ASUNTO + REMITENTE */}
2
Asunto y remitente
El asunto es lo primero que ve la persona en su bandeja.
{okAsunto &&
Listo}
setAsunto(e.target.value)} placeholder="Ej. Arranca tu curso de Finanzas 💸" maxLength={150} />
{remitente.nombre}
{remitente.email}
setSenderModal(true)}>Editar
Las respuestas de los alumnos llegan a la casilla de Academia 8.
{/* 3 · CONTENIDO */}
3
Contenido
Pegá el código HTML que te pasaron. La vista previa se actualiza sola.
{okHtml &&
Listo}
El pie con el link para darse de baja se agrega automáticamente a cada correo. No hace falta incluirlo en el HTML.
{warnings.length>0 && (
{warnings.map((w,i)=>(
{CampAPI._warningText(w)}
))}
)}
Vista previa
{okHtml && setExpand(true)}> Expandir }
{okHtml ? (
) : (
Acá vas a ver cómo queda el correo apenas pegues el HTML.
)}
{/* 4 · PRUEBA */}
4
Envío de prueba
Mandate el correo a vos mismo antes del envío real. Recomendado siempre.
{config.sandbox && (
Modo prueba de Amazon: solo se puede enviar a direcciones verificadas. Las demás se rechazan automáticamente.
)}
{dirsPrueba.length>CampAPI.MAX_PRUEBA && (
Pusiste {dirsPrueba.length} direcciones — el máximo es {CampAPI.MAX_PRUEBA}.
)}
{pruebaRes && (
{pruebaRes.map((r,i)=>(
{r.email}
{r.ok?'Enviada':r.error}
))}
)}
{/* ---------- rail de envío ---------- */}
Borrador
{saveState==='saved'?'Guardado':saveState==='saving'?'Guardando…':''}
{okDest?`${conteo.total.toLocaleString('es-AR')} destinatarios`:'Elegí destinatarios'}
{okAsunto?'Asunto cargado':'Falta el asunto'}
{okHtml?'Contenido cargado':'Falta el contenido'}
setSendMode('ahora')}>Enviar ahora
setSendMode('programar')}>Programar
{sendMode==='programar' && (
<>
setFecha(e.target.value)} />
setHora(e.target.value)} style={{maxWidth:'110px'}} />
Se usa la {TZ_LABEL}
>
)}
setConfirming(true)} style={{width:'100%'}}>
{sendMode==='ahora'?'Enviar ahora':'Programar envío'}
{!puedeEnviar &&
Completá destinatarios, asunto y contenido para habilitar el envío.
}
{confirming &&
!sendBusy&&setConfirming(false)} onConfirm={doSend} />}
{senderModal && setSenderModal(false)} onSave={saveSender} />}
{expand && setExpand(false)} />}
);
}
// =================== VISTA CAMPAÑA ENVIADA / FALLIDA (solo lectura) ===================
function SentCampaignView({ campId, notify, onDone, onDuplicated }){
const [c, setC] = useState(null);
const [tagMap, setTagMap] = useState({});
const [busy, setBusy] = useState(false);
const polledRef = useRef(false);
useEffect(()=>{ (async()=>{
try{
const [camp, tags] = await Promise.all([CampAPI.obtenerCampania(campId), CampAPI.listarEtiquetas()]);
const m={}; tags.forEach(t=>m[t.id]=t.nombre); setTagMap(m); setC(camp);
}catch(e){ notify(e.message||'No se pudo cargar la campaña'); }
})(); },[campId]);
// El fin del envío lo determina el backend: polling suave mientras esté "enviando".
useEffect(()=>{
if (c && c.estado==='enviando' && !polledRef.current){
polledRef.current = true;
let alive = true;
CampAPI.pollCampania(campId, { onTick:(cc)=>{ if(alive) setC(cc); } })
.then(f=>{ if(alive){ setC(f); notify(f.estado==='enviada'?'Campaña enviada':f.estado==='fallida'?'El envío falló':'Envío procesado'); } });
return ()=>{ alive=false; };
}
},[c && c.estado]);
if(!c) return ;
const destTxt = c.destinatarios.modo==='todos' ? 'Todos los contactos' : c.destinatarios.etiquetas.map(id=>tagMap[id]||'—').join(', ');
const fallida = c.estado==='fallida';
const programada = c.estado==='programada';
const kickLbl = programada ? 'Campaña · programada' : c.estado==='enviando' ? 'Campaña · enviando' : 'Campaña · solo lectura';
const fechaLbl = programada ? 'Programada para' : c.estado==='enviada' ? 'Enviada' : c.estado==='fallida' ? 'Intento' : 'Estado';
const fechaVal = programada ? fmtCampDateTime(c.programadaPara) : c.estado==='enviando' ? 'En curso…' : fmtCampDateTime(c.enviadaEn);
const duplicar = async ()=>{ setBusy(true); const nc = await CampAPI.duplicarCampania(c.id); setBusy(false); notify('Campaña duplicada como borrador'); onDuplicated(nc.id); };
const reintentar = async ()=>{ setBusy(true); try{ await CampAPI.enviarAhora(c.id); notify('Reintentando envío…'); polledRef.current=true; const fin = await CampAPI.pollCampania(c.id,{ onTick:setC }); setC(fin); notify(fin.estado==='enviada'?'Campaña enviada':fin.estado==='fallida'?'El envío volvió a fallar':'Envío procesado'); }catch(e){ notify(e.message||'No se pudo reintentar el envío'); } setBusy(false); };
const cancelar = async ()=>{ setBusy(true); await CampAPI.cancelarProgramacion(c.id); setBusy(false); notify('Programación cancelada · vuelve a borrador'); onDone(); };
return (
Volver a campañas
{kickLbl}
{c.nombre}
{CampAPI.ESTADO_CAMPANIA[c.estado]}
{fallida && Reintentar envío }
{programada && Cancelar programación }
Duplicar
Contenido enviado
{c.html ?
:
Esta campaña no tenía contenido HTML.
}
{(fallida || c.fallidos>0) && (
{fallida ? 'El envío falló' : `${c.fallidos} ${c.fallidos===1?'destinatario no recibió':'destinatarios no recibieron'} el correo`}
{fallida
? (c.error || 'El envío falló para todos los destinatarios. Reintentá; el sistema no reenvía a quienes ya recibieron.')
: `El resto de los destinatarios sí lo recibió. El sistema no reenvía a quienes ya recibieron.`}
)}
Remitente
{c.remitente?`${c.remitente.nombre} · ${c.remitente.email}`:'—'}
Alcance
{c.totalDestinatarios.toLocaleString('es-AR')} {programada?'destinatarios':'enviados'}{c.excluidos>0?` · ${c.excluidos} excluidos`:''}
{c.fallidos>0 &&
Con error
{c.fallidos.toLocaleString('es-AR')} {c.fallidos===1?'destinatario':'destinatarios'}
}
{fechaLbl}
{fechaVal}{(programada||c.enviadaEn)?` · ${TZ_LABEL}`:''}
{c.pruebasEnviadas?.length>0 &&
Pruebas
{c.pruebasEnviadas.join(', ')}
}
);
}
Object.assign(window, { CampaignEditor, SentCampaignView, SenderModal, ConfirmSend, fmtCampDateTime });