// Tela: Disparos — campanhas de WhatsApp com templates verificados pela Meta
const dspHeaders = () => { const t = localStorage.getItem('ezza_token'); return { 'Content-Type':'application/json', ...(t?{'Authorization':`Bearer ${t}`}:{}) }; };
const DSP_STATUS = {
APPROVED: { label: 'Aprovado', bg: 'oklch(0.94 0.06 150)', fg: 'oklch(0.35 0.13 155)' },
PENDING: { label: 'Em análise', bg: 'oklch(0.96 0.08 90)', fg: 'oklch(0.45 0.12 80)' },
REJECTED: { label: 'Recusado', bg: 'oklch(0.94 0.06 20)', fg: 'oklch(0.45 0.16 25)' },
};
const DSP_PUBLICOS = [
{ id: 'contatos', label: 'Todos os contatos' },
{ id: 'leads_abertos', label: 'Leads em aberto (não fechados)' },
{ id: 'leads_quentes', label: 'Só leads QUENTES 🔥' },
{ id: 'leads', label: 'Segmentar leads (etapa, temperatura, vendedor)' },
];
// Etapas do funil — mesmos slugs do pipeline
const DSP_ETAPAS = [
{ id: '', label: 'Todas as etapas abertas' },
{ id: 'novo-lead', label: 'Novo lead' },
{ id: 'contato-feito', label: 'Contato feito' },
{ id: 'em-andamento', label: 'Em andamento' },
{ id: 'proposta-enviada', label: 'Proposta enviada' },
{ id: 'fechado', label: 'Fechado' },
];
// Biblioteca de modelos prontos — o cliente escolhe, personaliza e manda pra verificação
const DSP_MODELOS = [
{ id: 'boas_vindas', rotulo: '👋 Boas-vindas', cat: 'UTILITY',
texto: 'Oi {{1}}! Que bom ter você com a gente 😊 Seu cadastro foi confirmado. Qualquer dúvida é só responder essa mensagem que a nossa equipe te ajuda!' },
{ id: 'promocao', rotulo: '🏷️ Promoção', cat: 'MARKETING',
texto: 'Oi {{1}}! Temos uma condição especial essa semana: [DESCREVA A OFERTA AQUI]. Válida até [DATA]. Quer aproveitar? É só responder essa mensagem!' },
{ id: 'reengajamento', rotulo: '🔄 Reengajamento', cat: 'MARKETING',
texto: 'Oi {{1}}, tudo bem? Faz um tempinho que a gente não se fala! Temos novidades que podem te interessar. Quer dar uma olhada? Responde aqui que eu te conto 😊' },
{ id: 'aviso_pedido', rotulo: '📦 Aviso de pedido', cat: 'UTILITY',
texto: 'Oi {{1}}! Passando pra avisar que seu pedido foi atualizado: [STATUS]. Qualquer dúvida é só chamar por aqui!' },
{ id: 'agradecimento', rotulo: '💚 Agradecimento', cat: 'MARKETING',
texto: 'Oi {{1}}! Passando só pra agradecer pela confiança 💚 Se precisar de qualquer coisa, estamos por aqui. Indique a gente pra um amigo e os dois ganham [BENEFÍCIO]!' },
];
function NovoTemplateModal({ onClose, onCreated }) {
const [nome, setNome] = React.useState('');
const [cat, setCat] = React.useState('MARKETING');
const [texto, setTexto] = React.useState('');
const [midia, setMidia] = React.useState(null); // File do anexo (imagem/vídeo)
const [midiaPrev, setMidiaPrev] = React.useState(null); // object URL pra preview
const [busy, setBusy] = React.useState(false);
const [rodape, setRodape] = React.useState('');
const [botoes, setBotoes] = React.useState([]); // [{tipo, texto, url?, telefone?}]
const midiaRef = React.useRef(null);
const addBotao = () => setBotoes(b => [...b, { tipo:'QUICK_REPLY', texto:'' }]);
const removerBotao = (i) => setBotoes(b => b.filter((_, j) => j !== i));
const mudarBotao = (i, patch) => setBotoes(b => b.map((x, j) => j === i ? { ...x, ...patch } : x));
const toast = useToast();
const usarModelo = (m) => { setNome(m.id); setCat(m.cat); setTexto(m.texto); };
const escolherMidia = (e) => {
const f = e.target.files && e.target.files[0]; e.target.value = '';
if (!f) return;
if (f.size > 16*1024*1024) { toast('Anexo muito grande (máx 16 MB)', { kind: 'err' }); return; }
setMidia(f); setMidiaPrev({ url: URL.createObjectURL(f), tipo: f.type.startsWith('video/') ? 'video' : f.type.startsWith('image/') ? 'imagem' : 'arquivo', nome: f.name });
};
const removerMidia = () => { setMidia(null); setMidiaPrev(null); };
// Botao sem texto nao vai pra Meta: ela recusa o template inteiro.
const botoesValidos = () => botoes.filter(b => (b.texto || '').trim());
const enviar = async () => {
if (busy) return;
setBusy(true);
try {
let r;
if (midia) {
// multipart quando há anexo de mídia no cabeçalho
const fd = new FormData();
fd.append('nome', nome); fd.append('categoria', cat); fd.append('texto', texto); fd.append('midia', midia);
fd.append('rodape', rodape); fd.append('botoes', JSON.stringify(botoesValidos()));
const t = localStorage.getItem('ezza_token');
r = await fetch('/api/whatsapp/templates', { method: 'POST', headers: { ...(t?{'Authorization':`Bearer ${t}`}:{}) }, body: fd });
} else {
r = await fetch('/api/whatsapp/templates', { method: 'POST', headers: dspHeaders(), body: JSON.stringify({ nome, categoria: cat, texto, rodape, botoes: botoesValidos() }) });
}
const d = await r.json();
if (d.ok) { toast('Template enviado pra verificação da Meta ✓ (aprovação leva de minutos a 48h)', { duration: 5000 }); onCreated && onCreated(); onClose(); }
else toast(d.error || 'Meta recusou', { kind: 'err', duration: 5000 });
} catch (e) { toast('Erro de conexão', { kind: 'err' }); }
setBusy(false);
};
return (
e.stopPropagation()} style={{width:560}}>
Novo template
Vai pra fila de verificação da Meta antes de poder ser usado
{ICN.x}
Comece por um modelo pronto (opcional)
{DSP_MODELOS.map(m => (
usarModelo(m)} style={{cursor:'default', fontSize:11.5}}>{m.rotulo}
))}
Nome interno (minúsculas, sem espaço)
setNome(e.target.value)} />
Categoria
{[['MARKETING','Marketing (promoções, reengajamento)'],['UTILITY','Utilidade (avisos de pedido, confirmações)']].map(([id,lbl])=>(
setCat(id)} style={{cursor:'default', fontSize:11.5}}>{lbl}
))}
Anexo de mídia no cabeçalho (opcional)
{!midiaPrev ? (
midiaRef.current && midiaRef.current.click()}>
{ICN.upload || '📎'}
Anexar imagem, vídeo ou PDF (vira o cabeçalho do template)
) : (
{midiaPrev.tipo==='imagem'
?
:
{midiaPrev.tipo==='video' ? '🎬' : '📄'}
}
{midiaPrev.nome}
{midiaPrev.tipo} · cabeçalho do template
Remover
)}
Texto da mensagem
{/* RODAPÉ — linha discreta abaixo do texto, teto de 60 pela Meta */}
Rodapé (opcional)
60 ? 'var(--danger)' : 'var(--ink-faint)'}}>
{rodape.length}/60
setRodape(e.target.value)}
placeholder="Ex: Exclusivo para o atacado" />
{/* BOTÕES — é o "Quero adquirir!" que aparece embaixo da mensagem */}
Botões (opcional)
{botoes.length === 0 && (
Aparecem embaixo da mensagem. O cliente toca e já responde, sem digitar.
)}
{botoes.map((bt, i) => (
{/* Tipo em seletor segmentado: sao 3 opcoes fixas, e o
nativo abria aquele menu do navegador que nao aceita estilo. */}
{[['QUICK_REPLY','Resposta'],['URL','Link'],['PHONE_NUMBER','Ligar']].map(([id,rot]) => (
mudarBotao(i, { tipo: id })}>{rot}
))}
removerBotao(i)}>×
mudarBotao(i, { texto: e.target.value })}
placeholder="Texto do botão, ex: Quero adquirir!" />
{bt.tipo === 'URL' && (
mudarBotao(i, { url: e.target.value })}
placeholder="https://..." />
)}
{bt.tipo === 'PHONE_NUMBER' && (
mudarBotao(i, { telefone: e.target.value })}
placeholder="DDD + número" />
)}
))}
{botoes.length < 3 && (
+ Adicionar botão
)}
{/* Prévia do que o cliente vai ver — inclui rodapé e botões */}
{(texto.trim() || botoes.some(b=>b.texto.trim())) && (
Como o cliente vai ver
{texto.replace(/\{\{1\}\}/g, '‹nome›') || '...'}
{rodape.trim() &&
{rodape}
}
{botoes.filter(b=>b.texto.trim()).map((b,i)=>(
{b.texto}
))}
)}
Regras da Meta: a variável {'{{1}}'} não pode ser a primeira nem a última coisa do texto · nada de links encurtados · aprovação leva de minutos a 48h.
Cancelar
{busy?'Enviando…':'Enviar pra verificação'}
);
}
function Disparos() {
const [tpls, setTpls] = React.useState([]);
const [configurado, setConfigurado] = React.useState(true);
const [loading, setLoading] = React.useState(true);
const [showNovo, setShowNovo] = React.useState(false);
const [tplSel, setTplSel] = React.useState('');
const [publico, setPublico] = React.useState('contatos');
const [fEtapa, setFEtapa] = React.useState('');
const [fTemp, setFTemp] = React.useState('');
const [fDono, setFDono] = React.useState('');
const [equipe, setEquipe] = React.useState([]);
const [fTag, setFTag] = React.useState('');
const [tags, setTags] = React.useState([]);
const [progresso, setProgresso] = React.useState(null); // {enviados,total,status}
const [numTeste, setNumTeste] = React.useState('');
const [testando, setTestando] = React.useState(false);
const [apagando, setApagando] = React.useState(''); // nome do template sendo apagado
const [previa, setPrevia] = React.useState(null); // {total, custo, exemplos}
const [disparando, setDisparando] = React.useState(false);
const [resultado, setResultado] = React.useState(null);
const [historico, setHistorico] = React.useState([]);
const toast = useToast();
const carregar = () => {
setLoading(true);
fetch('/api/whatsapp/templates', { headers: dspHeaders() })
.then(r=>r.json())
.then(d=>{ if(d.ok){ setTpls(d.templates||[]); setConfigurado(d.configurado!==false); } else toast(d.error||'Erro ao consultar a Meta',{kind:'err'}); })
.catch(()=>{})
.finally(()=>setLoading(false));
fetch('/api/disparos', { headers: dspHeaders() })
.then(r=>r.json()).then(d=>{ if(d.ok) setHistorico(d.disparos||[]); }).catch(()=>{});
fetch('/api/equipe', { headers: dspHeaders() })
.then(r=>r.json()).then(d=>{ if(d.ok) setEquipe(d.equipe||[]); }).catch(()=>{});
fetch('/api/tags', { headers: dspHeaders() })
.then(r=>r.json()).then(d=>{ if(d.ok) setTags(d.tags||[]); }).catch(()=>{});
};
React.useEffect(() => { carregar(); }, []);
const aprovados = tpls.filter(t => t.status === 'APPROVED');
const tpl = tpls.find(t => t.name === tplSel);
const calcularPrevia = async () => {
setPrevia(null); setResultado(null);
try {
const r = await fetch('/api/whatsapp/disparo', { method:'POST', headers: dspHeaders(),
body: JSON.stringify({ template: tplSel, params: tpl?.params||0, publico, ...filtrosAtivos(), simular: 1 }) });
const d = await r.json();
if (d.ok) setPrevia(d);
else toast(d.error || 'Erro na prévia', { kind: 'err', duration: 4000 });
} catch (e) { toast('Erro de conexão', { kind: 'err' }); }
};
// Só manda os filtros quando o público é "segmentar leads" — senão o backend
// aplicaria filtro em cima de "todos os contatos" sem o usuário perceber.
const filtrosAtivos = () => publico === 'leads'
? { etapa: fEtapa || '', temp: fTemp || '', usuarioId: fDono || '', tagId: fTag || '' }
: (publico === 'contatos' && fTag ? { tagId: fTag } : {});
// Apaga o modelo NA META. Irreversivel: a Meta trava o nome por 30 dias, por
// isso o aviso e explicito e modelo APROVADO ainda exige digitar o nome.
const apagarTemplate = async (t) => {
if (apagando) return;
const aprovado = t.status === 'APPROVED';
const aviso = 'Apagar o modelo "' + t.name + '"?' + '\n\n' +
'Isso APAGA NA META, nao so aqui. Nao tem desfazer.' + '\n' +
'A Meta bloqueia esse nome por 30 DIAS: nao da pra criar outro igual nesse periodo.' +
(aprovado ? '\n\n' + 'Este modelo esta APROVADO e pode estar em uso.' : '');
if (!window.confirm(aviso)) return;
if (aprovado) {
const digitado = window.prompt('Confirme digitando o nome do modelo:' + '\n\n' + t.name);
if (digitado === null) return;
if (digitado.trim() !== t.name) { toast('O nome nao confere. Nada foi apagado.', { kind:'err' }); return; }
}
setApagando(t.name);
try {
const qs = t.id ? ('?id=' + encodeURIComponent(t.id)) : '';
const r = await fetch('/api/whatsapp/templates/' + encodeURIComponent(t.name) + qs,
{ method:'DELETE', headers: dspHeaders() });
const d = await r.json();
if (d.ok) { toast('Modelo "' + t.name + '" apagado'); carregar(); }
else toast(d.error || 'Nao consegui apagar', { kind:'err', duration: 6000 });
} catch (e) { toast('Erro de conexao', { kind:'err' }); }
setApagando('');
};
// Manda o template pra UM numero, sem tocar na base. Todo disparo de verdade
// deveria passar por aqui antes.
const enviarTeste = async () => {
const n = numTeste.replace(/\D/g, '');
if (!tplSel) { toast('Escolha um template primeiro', { kind: 'err' }); return; }
if (n.length < 10) { toast('Informe DDD + número, ex: 11988887777', { kind: 'err' }); return; }
if (testando) return;
setTestando(true);
try {
const r = await fetch('/api/whatsapp/disparo', { method:'POST', headers: dspHeaders(),
body: JSON.stringify({ template: tplSel, params: tpl?.params||0, numeroTeste: n }) });
const d = await r.json();
if (d.ok && d.enviados > 0) toast(`Teste enviado para ${n} ✓ Confira no WhatsApp.`, { duration: 6000 });
else if (d.ok) toast('A Meta recusou: ' + (d.erro_exemplo || 'verifique o template'), { kind: 'err', duration: 7000 });
else toast(d.error || 'Erro no teste', { kind: 'err', duration: 6000 });
carregar();
} catch (e) { toast('Erro de conexão', { kind: 'err' }); }
setTestando(false);
};
const disparar = async () => {
if (disparando || !previa) return;
setDisparando(true); setProgresso(null);
try {
const r = await fetch('/api/whatsapp/disparo', { method:'POST', headers: dspHeaders(),
body: JSON.stringify({ template: tplSel, params: tpl?.params||0, publico, ...filtrosAtivos() }) });
let d = await r.json();
if (!d.ok) { toast(d.error || 'Erro no disparo', { kind: 'err', duration: 5000 }); setDisparando(false); return; }
// O envio vai em lotes: segue chamando até acabar a fila. Se a aba fechar,
// o cron do servidor termina sozinho — nada fica pela metade.
setProgresso({ enviados: d.enviados, total: d.total, status: d.status });
let guarda = 0;
while (d.status !== 'concluido' && d.status !== 'erro' && guarda++ < 40) {
const rc = await fetch(`/api/whatsapp/disparo/${d.disparoId}`, { method:'POST', headers: dspHeaders() });
const dc = await rc.json();
if (!dc.ok) break;
d = { ...d, ...dc };
setProgresso({ enviados: d.enviados, total: d.total, status: d.status });
}
setResultado(d); setPrevia(null); setProgresso(null);
toast(`Disparo concluído: ${d.enviados} enviados${d.falhas?`, ${d.falhas} falhas`:''}`, { duration: 5000 });
carregar();
} catch (e) {
toast('Conexão caiu — o servidor termina o envio sozinho em até 2 min', { kind: 'err', duration: 6000 });
}
setDisparando(false);
};
const fmtD = (iso) => { try { const d=new Date(String(iso).replace(' ','T')); return d.toLocaleDateString('pt-BR')+' '+d.toLocaleTimeString('pt-BR',{hour:'2-digit',minute:'2-digit'}); } catch(e){ return ''; } };
return (
<>
Disparos WhatsApp >} subtitle="Campanhas com templates verificados pela Meta"
right={setShowNovo(true)}>{ICN.plus} Novo template } />
{!configurado && (
⚠️ WhatsApp/WABA ainda não configurados para esta conta. Fale com o suporte da Ezza pra ativar.
)}
{/* ── Novo disparo ── */}
{/* PASSO 1 — o que enviar. Lista de cartoes no lugar do
:
o dropdown nativo nao aceita estilo (aquele azul do navegador) e
escondia o que importa — status e conteudo do modelo. */}
1
O que enviar
Só modelos aprovados pela Meta podem sair
{tpls.length === 0 && (
Nenhum modelo ainda.
)}
{tpls.map(t => {
const st = DSP_STATUS[t.status] || { label: t.status, bg: 'oklch(0.95 0.01 250)', fg: 'var(--ink-muted)' };
const ok = t.status === 'APPROVED';
const sel = tplSel === t.name;
return (
{ setTplSel(t.name); setPrevia(null); setResultado(null); }}>
{t.name}
{(t.body || '').slice(0, 70)}{(t.body || '').length > 70 ? '…' : ''}
{st.label}
);
})}
{/* Prévia em forma de balão: é assim que o cliente vai ver */}
{tpl && (
{tpl.body.replace(/\{\{1\}\}/g, '‹nome do contato›')}
{tpl.rodape &&
{tpl.rodape}
}
{Array.isArray(tpl.botoes) && tpl.botoes.map((bt,i) => (
{bt}
))}
Assim que o contato vai receber
)}
{/* PASSO 2 — para quem */}
2
Para quem
Escolha o grupo e, se quiser, refine
{/* 4 opcoes fixas nao precisam de dropdown: em cartao a pessoa ve
todas de uma vez e entende a diferenca sem abrir nada. */}
{DSP_PUBLICOS.map(pb => (
{ setPublico(pb.id); setPrevia(null); setResultado(null); }}>
{pb.label}
))}
{publico === 'contatos' && tags.length > 0 && (
Filtrar por etiqueta (opcional)
{/* Chips no lugar do
: etiqueta TEM COR, e a cor e o que a
vendedora reconhece de relance no pipeline. Num dropdown nativo
a cor se perde e tudo vira texto igual. */}
{ setFTag(''); setPrevia(null); setResultado(null); }}>
Todos, sem filtrar
{/* Soma leads + contatos: etiqueta colada no pipeline conta como
'lead', e o backend casa pelo telefone. Antes exibia so a contagem
de 'contato', que era sempre 0 e parecia etiqueta vazia. */}
{tags.map(t => {
const n = (t.leads || 0) + (t.contatos || 0);
return (
{ setFTag(String(t.id)); setPrevia(null); setResultado(null); }}>
{t.nome}{n}
);
})}
)}
{/* Filtros finos — só quando o público é "segmentar leads" */}
{publico === 'leads' && (
Refinar o público
Etapa do funil
{setFEtapa(e.target.value); setPrevia(null); setResultado(null);}}>
{DSP_ETAPAS.map(x => {x.label} )}
Temperatura
{setFTemp(e.target.value); setPrevia(null); setResultado(null);}}>
Todas
Frio
Morno
Quente
Etiqueta
{setFTag(e.target.value); setPrevia(null); setResultado(null);}}>
Qualquer etiqueta
{tags.map(t => {t.nome} ({t.leads}) )}
Responsável
{setFDono(e.target.value); setPrevia(null); setResultado(null);}}>
Qualquer vendedor
{equipe.map(m => {m.nome} )}
Clique em Calcular prévia pra ver quantas pessoas entram nesse recorte antes de gastar.
)}
{/* Teste em um número antes de gastar com a base */}
3
Testar antes opcional
Manda pra um número só, sem tocar na base
setNumTeste(e.target.value)}
placeholder="DDD + número, ex: 11988887777" />
{ICN.send || ICN.bolt}
{testando ? 'Enviando…' : 'Enviar teste'}
{!tplSel ? 'Escolha um modelo no passo 1 pra liberar o teste.' : 'Custa R$ 0,33 e nao toca na base.'}
{!previa && !resultado && (
Calcular prévia e custo
)}
{progresso && (
Enviando… {progresso.enviados} de {progresso.total}
{Math.round((progresso.enviados / Math.max(1, progresso.total)) * 100)}%
Pode fechar a aba — o servidor termina o envio sozinho.
)}
{previa && (
Prévia do disparo
Destinatários: {previa.total}
Custo estimado (Meta): R$ {Number(previa.custo).toFixed(2).replace('.',',')} (~R$ 0,33/msg)
{previa.exemplos && Ex.: {previa.exemplos.slice(0,3).join(' · ')} }
setPrevia(null)}>Cancelar
{disparando ? 'Disparando…' : `Disparar para ${previa.total} agora`}
)}
{resultado && (
✅ {resultado.enviados} enviados de {resultado.total} · custo ~R$ {Number(resultado.custo).toFixed(2).replace('.',',')}
{resultado.falhas > 0 && <> ⚠️ {resultado.falhas} falha(s){resultado.erro_exemplo ? ` — ex.: ${resultado.erro_exemplo}` : ''}>}
)}
{/* ── Templates ── */}
{loading &&
Consultando a Meta…
}
{!loading && tpls.length === 0 && (
Nenhum template ainda. Crie o primeiro — a Meta verifica e libera pro disparo.
)}
{tpls.map(t => {
const st = DSP_STATUS[t.status] || { label: t.status, bg: 'oklch(0.95 0.01 250)', fg: 'var(--ink-muted)' };
return (
{t.name}
{/* selo e ação andam juntos na direita — antes o selo ficava
solto no meio quando entrou o terceiro elemento */}
{st.label}
apagarTemplate(t)}>
{ICN.trash}{apagando === t.name ? 'Apagando…' : 'Apagar'}
{/* Texto à ESQUERDA, mídia à DIREITA (pedido do Daniel 06/08).
Antes a imagem ficava full-width em cima e ficava achatada;
na coluna lateral ela aparece inteira e o texto respira. */}
{(() => {
// Cópia local (template criado aqui) ou o exemplo que a própria Meta devolve
const src = t.midia_url || t.header_proxy; // proxy do nosso dominio
const ehVideo = t.midia_tipo === 'video' || t.header_tipo === 'VIDEO';
const temMidia = src && t.header_tipo !== 'DOCUMENT';
const aviso = !temMidia && t.header_tipo && t.header_tipo !== 'TEXT'
? (t.header_tipo === 'DOCUMENT' ? '📄 Tem documento no cabeçalho' : '🖼 Tem mídia no cabeçalho')
: null;
const texto = (
{t.header_texto &&
{t.header_texto}
}
{/* Texto completo — antes cortava em 2 linhas e a vendedora não via a mensagem toda */}
{t.body}
{t.rodape &&
{t.rodape}
}
{Array.isArray(t.botoes) && t.botoes.length > 0 && (
{t.botoes.map((bt,i) => {bt} )}
)}
);
// Sem mídia: o texto ocupa a largura toda, sem coluna vazia do lado.
if (!temMidia && !aviso) return texto;
return (
);
})()}
{t.category === 'MARKETING' ? 'Marketing' : 'Utilidade'} · {t.language}
);
})}
{/* ── Histórico ── */}
{historico.length === 0 &&
Nenhum disparo ainda.
}
{historico.map(h => (
{h.template}
{(DSP_PUBLICOS.find(p=>p.id===h.publico)||{}).label || h.publico}
{h.enviados} /{h.total} enviados{Number(h.falhas)>0?` · ${h.falhas} falhas`:''}
R$ {Number(h.custo_estimado).toFixed(2).replace('.',',')}
{fmtD(h.criado_em)}
))}
{showNovo && setShowNovo(false)} onCreated={carregar} />}
>
);
}
Object.assign(window, { Disparos });