// ============ 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 (
CancelaronSave({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(); }}>
Vista previa
Cerrar
); } // =================== 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
Cargando campaña…
; 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}
{modo==='etiquetas' && (
{tags.map(t=>( ))}
)}
{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)}
))}
)}
Código HTML
Vista previa {okHtml && }
{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.
)}
setPruebas(e.target.value)} placeholder="fran@academia8.com, revisor@academia8.com" />
CampAPI.MAX_PRUEBA}>{pruebaBusy?'Enviando…':'Enviar prueba'}
{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'}
{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
Cargando campaña…
; 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.`}
)}
Asunto
{c.asunto||'—'}
Remitente
{c.remitente?`${c.remitente.nombre} · ${c.remitente.email}`:'—'}
Destinatarios
{destTxt}
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 });