feat: inicializar repositorio oficial do clube67.com

This commit is contained in:
VPS 4 Builder
2026-05-18 18:32:55 +02:00
commit a7fd18eb55
96 changed files with 17059 additions and 0 deletions
+628
View File
@@ -0,0 +1,628 @@
(function () {
const API = '';
function headers() {
const h = { 'Content-Type': 'application/json' };
const key = document.getElementById('apiKey').value.trim() || localStorage.getItem('rscara_api_key');
if (key) {
h['x-api-key'] = key;
localStorage.setItem('rscara_api_key', key);
}
return h;
}
function show(el, visible) {
el.classList.toggle('hidden', !visible);
}
// Tabs
document.querySelectorAll('.tab').forEach((tab) => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
document.querySelectorAll('.panel').forEach((p) => p.classList.remove('active'));
tab.classList.add('active');
document.getElementById(tab.getAttribute('data-tab')).classList.add('active');
});
});
// Tipo de disparo
const dispatchForms = {
menu: document.getElementById('formMenu'),
buttons: document.getElementById('formButtons'),
interactive: document.getElementById('formInteractive'),
list: document.getElementById('formList'),
poll: document.getElementById('formPoll'),
carousel: document.getElementById('formCarousel'),
};
document.getElementById('dispatchType').addEventListener('change', () => {
const type = document.getElementById('dispatchType').value;
Object.values(dispatchForms).forEach((f) => f && f.classList.add('hidden'));
if (dispatchForms[type]) dispatchForms[type].classList.remove('hidden');
if (type === 'list' && !document.getElementById('listSectionsList').querySelector('.block-section')) addListSection();
if (type === 'carousel' && !document.getElementById('carouselCardsList').querySelector('.block-section')) addCarouselCard();
});
dispatchForms.menu.classList.remove('hidden');
// Instância que estamos conectando (para atualizar QR e status em tempo real)
let connectingInstanceName = null;
// --- Conexões: listar salvas e conectar ao clicar ---
function renderSavedList(saved) {
const ul = document.getElementById('savedList');
if (!saved || saved.length === 0) {
ul.innerHTML = '<li class="text-muted">Nenhuma conexão salva. Conecte uma vez por nome e ela aparecerá aqui.</li>';
return;
}
ul.innerHTML = saved
.map(
(name) =>
`<li class="saved-item-row">
<span class="instance-name">${name}</span>
<div class="saved-item-actions">
<button type="button" class="btn btn-primary btn-connect-saved" data-connect-name="${name}">Conectar</button>
<button type="button" class="btn btn-small btn-danger" data-delete-saved-name="${name}" title="Excluir sessão salva (será necessário novo QR para conectar)">Deletar</button>
</div>
</li>`
)
.join('');
ul.querySelectorAll('[data-connect-name]').forEach((btn) => {
btn.addEventListener('click', () => {
const name = btn.getAttribute('data-connect-name');
document.getElementById('connectInstanceSelect').value = name;
document.getElementById('instanceName').value = name;
connectNewNameRow.style.display = 'none';
doConnect(name);
});
});
ul.querySelectorAll('[data-delete-saved-name]').forEach((btn) => {
btn.addEventListener('click', async () => {
const name = btn.getAttribute('data-delete-saved-name');
if (!name || !confirm(`Excluir a conexão salva "${name}"? Será necessário escanear o QR de novo para conectar.`)) return;
try {
const res = await fetch(`${API}/v1/instances/${encodeURIComponent(name)}/logout`, {
method: 'POST',
headers: headers(),
});
const data = await res.json();
if (data.ok) refreshInstanceList();
} catch (_) {
refreshInstanceList();
}
});
});
}
async function doConnect(name) {
connectingInstanceName = name;
const statusEl = document.getElementById('connectStatus');
const qrContainer = document.getElementById('qrContainer');
const qrImage = document.getElementById('qrImage');
show(statusEl, false);
try {
const res = await fetch(`${API}/v1/instances`, {
method: 'POST',
headers: headers(),
body: JSON.stringify({ instance: name }),
});
const data = await res.json();
if (!res.ok) {
statusEl.textContent = data.error || 'Erro ao conectar';
statusEl.className = 'status error';
show(statusEl, true);
connectingInstanceName = null;
return;
}
if (data.qr) {
qrImage.src = data.qr;
show(qrContainer, true);
statusEl.textContent = 'Escaneie o QR no WhatsApp.';
statusEl.className = 'status success';
} else if (data.status === 'connected') {
show(qrContainer, false);
statusEl.textContent = 'Conectado.';
statusEl.className = 'status success';
connectingInstanceName = null;
} else {
statusEl.textContent = 'Aguardando QR...';
statusEl.className = 'status';
show(qrContainer, false);
}
show(statusEl, true);
refreshInstanceList();
} catch (e) {
statusEl.textContent = e.message || 'Erro de rede';
statusEl.className = 'status error';
show(statusEl, true);
connectingInstanceName = null;
}
}
const connectInstanceSelect = document.getElementById('connectInstanceSelect');
const connectNewNameRow = document.getElementById('connectNewNameRow');
connectInstanceSelect.addEventListener('change', () => {
const isNew = connectInstanceSelect.value === '';
connectNewNameRow.style.display = isNew ? '' : 'none';
});
document.getElementById('btnConnect').addEventListener('click', () => {
const selected = connectInstanceSelect.value;
const name = selected ? selected : (document.getElementById('instanceName').value.trim() || 'main');
doConnect(name);
});
async function fetchQrAndShow(name, qrImage, qrContainer) {
try {
const res = await fetch(`${API}/v1/instances/${encodeURIComponent(name)}/qr`, { headers: headers() });
const data = await res.json();
if (data.qr) {
qrImage.src = data.qr;
show(qrContainer, true);
}
} catch (_) {}
}
function renderInstanceList(list) {
const ul = document.getElementById('instanceList');
if (!list.length) {
ul.innerHTML = '<li>Nenhuma instância ativa.</li>';
return;
}
ul.innerHTML = list
.map(
(i) =>
`<li class="instance-row">
<span class="instance-name">${i.instance}</span>
<span class="badge ${i.status}">${i.status}</span>
<div class="instance-actions">
${i.status === 'qr' ? `<button type="button" class="btn btn-small btn-ghost" data-action="qr" data-name="${i.instance}">Ver QR</button>` : ''}
${i.status === 'connected' ? `<button type="button" class="btn btn-small btn-ghost" data-action="disconnect" data-name="${i.instance}">Desconectar</button>` : ''}
<button type="button" class="btn btn-small btn-ghost" data-action="logout" data-name="${i.instance}" title="Novo QR na próxima conexão">Novo QR</button>
<button type="button" class="btn btn-small btn-danger" data-action="delete" data-name="${i.instance}">Deletar</button>
</div>
</li>`
)
.join('');
ul.querySelectorAll('[data-action]').forEach((btn) => {
btn.addEventListener('click', async () => {
const action = btn.getAttribute('data-action');
const name = btn.getAttribute('data-name');
if (!name) return;
const base = `${API}/v1/instances/${encodeURIComponent(name)}`;
try {
if (action === 'qr') {
const res = await fetch(`${base}/qr`, { headers: headers() });
const data = await res.json();
if (data.qr) {
document.getElementById('qrImage').src = data.qr;
document.getElementById('instanceName').value = name;
show(document.getElementById('qrContainer'), true);
show(document.getElementById('connectStatus'), false);
}
} else if (action === 'disconnect') {
await fetch(`${base}/disconnect`, { method: 'POST', headers: headers() });
refreshInstanceList();
} else if (action === 'logout') {
await fetch(`${base}/logout`, { method: 'POST', headers: headers() });
refreshInstanceList();
} else if (action === 'delete') {
await fetch(base, { method: 'DELETE', headers: headers() });
refreshInstanceList();
}
} catch (_) {}
refreshInstanceList();
});
});
}
function updateConnectSelect(saved) {
const sel = document.getElementById('connectInstanceSelect');
const current = sel.value;
const options = ['— Nova conexão —', ...(saved || [])];
sel.innerHTML = '<option value="">— Nova conexão —</option>' +
(saved || []).map((n) => `<option value="${n}" ${n === current ? 'selected' : ''}>${n}</option>`).join('');
connectNewNameRow.style.display = sel.value === '' ? '' : 'none';
}
async function refreshInstanceList() {
const statusEl = document.getElementById('connectStatus');
const qrContainer = document.getElementById('qrContainer');
const qrImage = document.getElementById('qrImage');
try {
const res = await fetch(`${API}/v1/instances`, { headers: headers() });
const data = await res.json();
if (data.saved) {
renderSavedList(data.saved);
updateConnectSelect(data.saved);
} else {
renderSavedList([]);
updateConnectSelect([]);
}
if (data.instances) {
renderInstanceList(data.instances);
const sel = document.getElementById('dispatchInstance');
const current = sel.value;
const names = [...new Set([...data.instances.map((i) => i.instance), ...(data.saved || [])])];
sel.innerHTML = names.map((n) => `<option value="${n}" ${n === current ? 'selected' : ''}>${n}</option>`).join('');
if (!names.includes(current)) sel.selectedIndex = 0;
// Atualização ativa: se estamos conectando uma instância, atualizar QR e status
if (connectingInstanceName) {
const inst = data.instances.find((i) => i.instance === connectingInstanceName);
if (inst) {
if (inst.status === 'qr') {
try {
const qrRes = await fetch(`${API}/v1/instances/${encodeURIComponent(connectingInstanceName)}/qr`, { headers: headers() });
const qrData = await qrRes.json();
if (qrData.qr) {
qrImage.src = qrData.qr;
show(qrContainer, true);
statusEl.textContent = 'Escaneie o QR no WhatsApp.';
statusEl.className = 'status success';
show(statusEl, true);
}
} catch (_) {}
} else if (inst.status === 'connected') {
show(qrContainer, false);
statusEl.textContent = 'Conectado.';
statusEl.className = 'status success';
show(statusEl, true);
connectingInstanceName = null;
} else if (inst.status === 'disconnected') {
statusEl.textContent = 'Desconectado. Clique em Conectar novamente.';
statusEl.className = 'status error';
show(statusEl, true);
}
}
}
}
} catch (_) {
renderSavedList([]);
renderInstanceList([]);
updateConnectSelect([]);
}
}
document.getElementById('btnRefreshList').addEventListener('click', refreshInstanceList);
refreshInstanceList();
// Polling ativo: atualizar lista, QR e status a cada 2s quando a aba Conexões estiver visível
setInterval(() => {
if (document.getElementById('conexoes').classList.contains('active')) {
refreshInstanceList();
}
}, 2000);
// --- Formulários dinâmicos (add/remove e montagem do payload) ---
function addRow(containerId, html, removeClass) {
const container = document.getElementById(containerId);
if (!container) return;
const div = document.createElement('div');
div.className = removeClass || 'item-row';
div.innerHTML = html + (removeClass ? '' : ' <button type="button" class="btn btn-small btn-ghost btn-remove">Remover</button>');
const removeBtn = div.querySelector('.btn-remove');
if (removeBtn) removeBtn.addEventListener('click', () => div.remove());
container.appendChild(div);
}
function addMenuOption() {
addRow('menuOptionsList', '<input type="text" placeholder="Texto da opção" data-field="opt">');
}
function addButtonRow() {
addRow('buttonsList', '<input type="text" placeholder="ID do botão" data-field="id"><input type="text" placeholder="Texto do botão" data-field="text">');
}
function addInteractiveRow() {
addRow(
'interactiveList',
`<select data-field="type"><option value="url">URL</option><option value="copy">Copiar</option><option value="call">Ligar</option></select>
<input type="text" placeholder="Texto do botão" data-field="text">
<input type="text" placeholder="URL / Código / Telefone" data-field="extra">`
);
}
function addPollOption() {
addRow('pollOptionsList', '<input type="text" placeholder="Opção" data-field="opt">');
}
function addListSection() {
const container = document.getElementById('listSectionsList');
const block = document.createElement('div');
block.className = 'block-section';
block.innerHTML = `
<div class="block-title">Seção</div>
<input type="text" class="section-title" placeholder="Título da seção">
<div class="sub-list section-rows"></div>
<button type="button" class="btn btn-small btn-ghost add-row-in-section">+ Adicionar item</button>
<button type="button" class="btn btn-small btn-danger btn-remove-block">Remover seção</button>
`;
block.querySelector('.add-row-in-section').addEventListener('click', () => {
const row = document.createElement('div');
row.className = 'item-row';
row.innerHTML = `
<input type="text" placeholder="ID" data-field="id">
<input type="text" placeholder="Título" data-field="title">
<input type="text" placeholder="Descrição" data-field="desc">
<button type="button" class="btn btn-small btn-ghost btn-remove">Remover</button>
`;
row.querySelector('.btn-remove').onclick = () => row.remove();
block.querySelector('.section-rows').appendChild(row);
});
block.querySelector('.btn-remove-block').onclick = () => block.remove();
container.appendChild(block);
}
function addCarouselCard() {
const container = document.getElementById('carouselCardsList');
const block = document.createElement('div');
block.className = 'block-section';
block.innerHTML = `
<div class="block-title">Card</div>
<div class="form-row"><input type="text" placeholder="Título" data-field="title"></div>
<div class="form-row"><input type="text" placeholder="Corpo/descrição" data-field="body"></div>
<div class="form-row"><input type="text" placeholder="Rodapé" data-field="footer"></div>
<div class="form-row"><input type="text" placeholder="URL da imagem" data-field="imageUrl"></div>
<div class="sub-list card-buttons"></div>
<button type="button" class="btn btn-small btn-ghost add-card-btn">+ Botão no card</button>
<button type="button" class="btn btn-small btn-danger btn-remove-block">Remover card</button>
`;
block.querySelector('.add-card-btn').addEventListener('click', () => {
const row = document.createElement('div');
row.className = 'item-row';
row.innerHTML = `
<input type="text" placeholder="ID" data-field="id">
<input type="text" placeholder="Texto" data-field="text">
<button type="button" class="btn btn-small btn-ghost btn-remove">Remover</button>
`;
row.querySelector('.btn-remove').onclick = () => row.remove();
block.querySelector('.card-buttons').appendChild(row);
});
block.querySelector('.btn-remove-block').onclick = () => block.remove();
container.appendChild(block);
}
document.querySelectorAll('.add-item').forEach((btn) => {
btn.addEventListener('click', () => {
const forId = btn.getAttribute('data-for');
if (forId === 'menuOptions') addMenuOption();
else if (forId === 'buttons') addButtonRow();
else if (forId === 'interactive') addInteractiveRow();
else if (forId === 'listSections') addListSection();
else if (forId === 'pollOptions') addPollOption();
else if (forId === 'carouselCards') addCarouselCard();
});
});
// Inicializar um item vazio por tipo
addMenuOption();
addButtonRow();
addInteractiveRow();
addPollOption();
// Coletar dados dos formulários e montar payload
function getMenuPayload() {
const options = [];
document.querySelectorAll('#menuOptionsList .item-row input[data-field="opt"]').forEach((inp) => {
const v = inp.value.trim();
if (v) options.push(v);
});
return {
url: '/v1/messages/send_menu',
body: {
instance: document.getElementById('dispatchInstance').value,
to: document.getElementById('dispatchTo').value.trim(),
title: document.getElementById('menuTitle').value.trim() || 'Menu',
text: document.getElementById('menuText').value.trim() || 'Escolha uma opção:',
options: options.length ? options : ['Opção 1'],
footer: document.getElementById('menuFooter').value.trim() || undefined,
},
};
}
function getButtonsPayload() {
const buttons = [];
document.querySelectorAll('#buttonsList .item-row').forEach((row) => {
const id = row.querySelector('[data-field="id"]')?.value?.trim();
const text = row.querySelector('[data-field="text"]')?.value?.trim();
if (id && text) buttons.push({ id, text });
});
return {
url: '/v1/messages/send_buttons_helpers',
body: {
instance: document.getElementById('dispatchInstance').value,
to: document.getElementById('dispatchTo').value.trim(),
text: document.getElementById('buttonsText').value.trim() || 'Escolha:',
footer: document.getElementById('buttonsFooter').value.trim() || undefined,
buttons: buttons.length ? buttons.slice(0, 3) : [{ id: 'btn1', text: 'Opção 1' }],
},
};
}
function getInteractivePayload() {
const buttons = [];
document.querySelectorAll('#interactiveList .item-row').forEach((row) => {
const type = row.querySelector('[data-field="type"]')?.value || 'url';
const text = row.querySelector('[data-field="text"]')?.value?.trim();
const extra = row.querySelector('[data-field="extra"]')?.value?.trim();
if (!text || !extra) return;
const btn = { type, text };
if (type === 'url') btn.url = extra;
else if (type === 'copy') btn.copyCode = extra;
else if (type === 'call') btn.phoneNumber = extra;
buttons.push(btn);
});
return {
url: '/v1/messages/send_interactive_helpers',
body: {
instance: document.getElementById('dispatchInstance').value,
to: document.getElementById('dispatchTo').value.trim(),
text: document.getElementById('interactiveText').value.trim() || 'Confira:',
footer: document.getElementById('interactiveFooter').value.trim() || undefined,
buttons,
},
};
}
function getListPayload() {
const sections = [];
document.querySelectorAll('#listSectionsList .block-section').forEach((block) => {
const title = block.querySelector('.section-title')?.value?.trim() || 'Seção';
const rows = [];
block.querySelectorAll('.section-rows .item-row').forEach((row) => {
const id = row.querySelector('[data-field="id"]')?.value?.trim();
const titleR = row.querySelector('[data-field="title"]')?.value?.trim();
const desc = row.querySelector('[data-field="desc"]')?.value?.trim();
if (id && titleR) rows.push({ id, title: titleR, description: desc || '' });
});
if (rows.length) sections.push({ title, rows });
});
return {
url: '/v1/messages/send_list_helpers',
body: {
instance: document.getElementById('dispatchInstance').value,
to: document.getElementById('dispatchTo').value.trim(),
text: document.getElementById('listText').value.trim() || 'Escolha:',
buttonText: document.getElementById('listButtonText').value.trim() || 'Ver opções',
footer: document.getElementById('listFooter').value.trim() || undefined,
sections: sections.length ? sections : [{ title: 'Opções', rows: [{ id: 'opt1', title: 'Opção 1', description: '' }] }],
},
};
}
function getPollPayload() {
const options = [];
document.querySelectorAll('#pollOptionsList .item-row input[data-field="opt"]').forEach((inp) => {
const v = inp.value.trim();
if (v) options.push(v);
});
return {
url: '/v1/messages/send_poll',
body: {
instance: document.getElementById('dispatchInstance').value,
to: document.getElementById('dispatchTo').value.trim(),
name: document.getElementById('pollName').value.trim() || 'Enquete',
options: options.length >= 2 ? options : ['Sim', 'Não'],
selectableCount: parseInt(document.getElementById('pollSelectable').value, 10) || 1,
},
};
}
function getCarouselPayload() {
const cards = [];
document.querySelectorAll('#carouselCardsList .block-section').forEach((block) => {
const title = block.querySelector('[data-field="title"]')?.value?.trim();
const body = block.querySelector('[data-field="body"]')?.value?.trim();
const footer = block.querySelector('[data-field="footer"]')?.value?.trim();
const imageUrl = block.querySelector('[data-field="imageUrl"]')?.value?.trim();
const buttons = [];
block.querySelectorAll('.card-buttons .item-row').forEach((row) => {
const id = row.querySelector('[data-field="id"]')?.value?.trim();
const text = row.querySelector('[data-field="text"]')?.value?.trim();
if (id && text) buttons.push({ id, text });
});
cards.push({
title: title || '',
body: body || '',
footer: footer || undefined,
imageUrl: imageUrl || undefined,
buttons: buttons.length ? buttons : [{ id: 'btn1', text: 'Ver' }],
});
});
return {
url: '/v1/messages/send_carousel_helpers',
body: {
instance: document.getElementById('dispatchInstance').value,
to: document.getElementById('dispatchTo').value.trim(),
text: document.getElementById('carouselText').value.trim() || undefined,
footer: document.getElementById('carouselFooter').value.trim() || undefined,
cards: cards.length ? cards : [{ title: 'Card', body: '', buttons: [{ id: 'b1', text: 'Botão' }] }],
},
};
}
/**
* Lê destinatários do campo e normaliza: aceita +55, espaços, traços, vírgulas etc.
* Ex: "+55 35 9882-8503," vira "553598828503".
*/
function getRecipients() {
const raw = document.getElementById('dispatchTo').value.trim();
if (!raw) return [];
return raw
.split(/[\r\n,;]+/)
.map((s) => s.replace(/\D/g, ''))
.filter((n) => n.length >= 10);
}
function delayMs(minSec, maxSec) {
const min = Math.max(0, Number(minSec) || 0);
const max = Math.max(min, Number(maxSec) || min);
const sec = min + Math.random() * (max - min);
return Math.round(sec * 1000);
}
document.getElementById('btnSend').addEventListener('click', async () => {
const recipients = getRecipients();
const resultEl = document.getElementById('sendResult');
const btnSend = document.getElementById('btnSend');
if (!recipients.length) {
resultEl.textContent = 'Informe ao menos um número (um por linha, com DDI).';
resultEl.className = 'result error';
show(resultEl, true);
return;
}
const type = document.getElementById('dispatchType').value;
let payload;
switch (type) {
case 'menu': payload = getMenuPayload(); break;
case 'buttons': payload = getButtonsPayload(); break;
case 'interactive': payload = getInteractivePayload(); break;
case 'list': payload = getListPayload(); break;
case 'poll': payload = getPollPayload(); break;
case 'carousel': payload = getCarouselPayload(); break;
default:
resultEl.textContent = 'Tipo não implementado.';
resultEl.className = 'result error';
show(resultEl, true);
return;
}
if (type === 'interactive' && (!payload.body.buttons || payload.body.buttons.length === 0)) {
resultEl.textContent = 'Adicione ao menos um botão CTA.';
resultEl.className = 'result error';
show(resultEl, true);
return;
}
const delayMin = document.getElementById('dispatchDelayMin').value;
const delayMax = document.getElementById('dispatchDelayMax').value;
let sent = 0;
let failed = 0;
btnSend.disabled = true;
show(resultEl, true);
resultEl.className = 'result';
for (let i = 0; i < recipients.length; i++) {
const to = recipients[i];
payload.body.to = to;
resultEl.textContent = `Enviando ${i + 1}/${recipients.length}... (${to})`;
try {
const res = await fetch(`${API}${payload.url}`, {
method: 'POST',
headers: headers(),
body: JSON.stringify(payload.body),
});
const data = await res.json();
if (res.ok) {
sent++;
} else {
failed++;
}
} catch (_) {
failed++;
}
if (i < recipients.length - 1) {
const wait = delayMs(delayMin, delayMax);
resultEl.textContent = `Aguardando ${wait / 1000}s antes do próximo... (${i + 1}/${recipients.length})`;
await new Promise((r) => setTimeout(r, wait));
}
}
resultEl.textContent = `Concluído: ${sent} enviados${failed ? `, ${failed} falhas` : ''}.`;
resultEl.className = failed === 0 ? 'result success' : failed === recipients.length ? 'result error' : 'result';
btnSend.disabled = false;
});
const savedKey = localStorage.getItem('rscara_api_key');
if (savedKey) document.getElementById('apiKey').placeholder = '••••••••';
})();
@@ -0,0 +1,169 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>rsalcara — WhatsApp API</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="app">
<header class="header">
<h1 class="logo">rsalcara</h1>
<p class="tagline">WhatsApp · Múltiplas contas e disparos</p>
<div class="api-key-wrap">
<label for="apiKey">API Key (opcional)</label>
<input type="password" id="apiKey" name="apiKey" placeholder="x-api-key" autocomplete="off">
</div>
</header>
<nav class="tabs">
<button type="button" class="tab active" data-tab="conexoes">Conexões</button>
<button type="button" class="tab" data-tab="disparos">Disparos</button>
</nav>
<main class="main">
<section id="conexoes" class="panel active">
<div class="card">
<h2>Conexões salvas</h2>
<p class="card-hint">Clique em Conectar para abrir a sessão (QR se necessário).</p>
<ul id="savedList" class="saved-list"></ul>
</div>
<div class="card">
<h2>Conectar por nome</h2>
<div class="form-row">
<label for="connectInstanceSelect">Escolha uma conexão</label>
<select id="connectInstanceSelect" name="connectInstanceSelect">
<option value="">— Nova conexão —</option>
</select>
</div>
<div class="form-row" id="connectNewNameRow">
<label for="instanceName">Nome da nova instância</label>
<input type="text" id="instanceName" name="instanceName" value="main" placeholder="ex: main, vendas">
</div>
<button type="button" id="btnConnect" class="btn btn-primary">Criar / Conectar</button>
<div id="qrContainer" class="qr-container hidden">
<p>Escaneie o QR no WhatsApp:</p>
<img id="qrImage" alt="QR Code" class="qr-image">
<p class="qr-hint">Ou abra WhatsApp → Aparelhos conectados → Conectar aparelho</p>
</div>
<div id="connectStatus" class="status hidden"></div>
</div>
<div class="card">
<h2>Instâncias ativas</h2>
<ul id="instanceList" class="instance-list"></ul>
<button type="button" id="btnRefreshList" class="btn btn-ghost">Atualizar lista</button>
</div>
</section>
<section id="disparos" class="panel">
<div class="card form-card">
<div class="form-row">
<label for="dispatchInstance">Instância</label>
<select id="dispatchInstance" name="dispatchInstance"><option value="main">main</option></select>
</div>
<div class="form-row">
<label for="dispatchTo">Destinatários</label>
<textarea id="dispatchTo" name="dispatchTo" rows="4" placeholder="Um número por linha (com DDI)&#10;Ex: 553598828503&#10;5511999999999"></textarea>
<span class="field-hint">Use um número por linha. Para um único número, digite uma linha.</span>
</div>
<div class="form-row mailing-delay">
<label for="dispatchDelayMin">Intervalo entre envios (segundos)</label>
<div class="delay-inputs">
<input type="number" id="dispatchDelayMin" name="dispatchDelayMin" value="2" min="0" max="300" placeholder="Mín"> <span>a</span>
<input type="number" id="dispatchDelayMax" name="dispatchDelayMax" value="5" min="0" max="300" placeholder="Máx">
</div>
<span class="field-hint">Tempo aleatório entre cada envio (evita bloqueio).</span>
</div>
<div class="form-row">
<label for="dispatchType">Tipo de mensagem</label>
<select id="dispatchType" name="dispatchType">
<option value="menu">Menu (texto numerado)</option>
<option value="buttons">Botões (quick reply)</option>
<option value="interactive">Botões CTA (URL / Copiar / Ligar)</option>
<option value="list">Lista dropdown</option>
<option value="poll">Enquete</option>
<option value="carousel">Carrossel</option>
</select>
</div>
<!-- Menu -->
<div id="formMenu" class="dispatch-form">
<div class="form-row"><label>Título</label><input type="text" id="menuTitle" name="menuTitle" placeholder="Menu de Opções"></div>
<div class="form-row"><label>Texto</label><textarea id="menuText" name="menuText" rows="2" placeholder="Escolha uma opção:"></textarea></div>
<div class="form-row">
<label>Opções</label>
<div id="menuOptionsList" class="dynamic-list"></div>
<button type="button" class="btn btn-small btn-ghost add-item" data-for="menuOptions">+ Adicionar opção</button>
</div>
<div class="form-row"><label>Rodapé</label><input type="text" id="menuFooter" name="menuFooter" placeholder="Responda com número"></div>
</div>
<!-- Botões -->
<div id="formButtons" class="dispatch-form hidden">
<div class="form-row"><label>Texto</label><textarea id="buttonsText" name="buttonsText" rows="2" placeholder="Como posso ajudar?"></textarea></div>
<div class="form-row"><label>Rodapé</label><input type="text" id="buttonsFooter" name="buttonsFooter" placeholder="Atendimento 24h"></div>
<div class="form-row">
<label>Botões (máx 3)</label>
<div id="buttonsList" class="dynamic-list"></div>
<button type="button" class="btn btn-small btn-ghost add-item" data-for="buttons">+ Adicionar botão</button>
</div>
</div>
<!-- Interactive CTA -->
<div id="formInteractive" class="dispatch-form hidden">
<div class="form-row"><label>Texto</label><textarea id="interactiveText" name="interactiveText" rows="3" placeholder="Mensagem com CTAs"></textarea></div>
<div class="form-row"><label>Rodapé</label><input type="text" id="interactiveFooter" name="interactiveFooter"></div>
<div class="form-row">
<label>Botões CTA</label>
<div id="interactiveList" class="dynamic-list"></div>
<button type="button" class="btn btn-small btn-ghost add-item" data-for="interactive">+ Adicionar botão</button>
</div>
</div>
<!-- Lista -->
<div id="formList" class="dispatch-form hidden">
<div class="form-row"><label>Texto</label><textarea id="listText" name="listText" rows="2" placeholder="Escolha uma categoria:"></textarea></div>
<div class="form-row"><label>Texto do botão</label><input type="text" id="listButtonText" name="listButtonText" value="Ver Cardápio" placeholder="Ver opções"></div>
<div class="form-row"><label>Rodapé</label><input type="text" id="listFooter" name="listFooter" placeholder="Delivery grátis acima de R$ 50"></div>
<div class="form-row">
<label>Seções</label>
<div id="listSectionsList" class="dynamic-list"></div>
<button type="button" class="btn btn-small btn-ghost add-item" data-for="listSections">+ Adicionar seção</button>
</div>
</div>
<!-- Enquete -->
<div id="formPoll" class="dispatch-form hidden">
<div class="form-row"><label>Pergunta</label><input type="text" id="pollName" name="pollName" placeholder="Qual sua linguagem favorita?"></div>
<div class="form-row"><label>Quantas pode escolher</label><input type="number" id="pollSelectable" name="pollSelectable" value="1" min="1"></div>
<div class="form-row">
<label>Opções</label>
<div id="pollOptionsList" class="dynamic-list"></div>
<button type="button" class="btn btn-small btn-ghost add-item" data-for="pollOptions">+ Adicionar opção</button>
</div>
</div>
<!-- Carrossel -->
<div id="formCarousel" class="dispatch-form hidden">
<div class="form-row"><label>Texto (acima dos cards)</label><input type="text" id="carouselText" name="carouselText" placeholder="Ofertas especiais"></div>
<div class="form-row"><label>Rodapé</label><input type="text" id="carouselFooter" name="carouselFooter"></div>
<div class="form-row">
<label>Cards</label>
<div id="carouselCardsList" class="dynamic-list"></div>
<button type="button" class="btn btn-small btn-ghost add-item" data-for="carouselCards">+ Adicionar card</button>
</div>
</div>
<button type="button" id="btnSend" class="btn btn-primary btn-send">Enviar</button>
<div id="sendResult" class="result hidden"></div>
</div>
</section>
</main>
</div>
<script src="/app.js"></script>
</body>
</html>
@@ -0,0 +1,499 @@
:root {
--bg: #0f0f12;
--bg-card: #18181c;
--bg-input: #222228;
--border: #2d2d35;
--text: #e4e4e7;
--text-muted: #71717a;
--accent: #22c55e;
--accent-hover: #16a34a;
--danger: #ef4444;
--radius: 12px;
--font: 'DM Sans', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: var(--font);
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
.app {
max-width: 720px;
margin: 0 auto;
padding: 1.5rem;
}
.header {
text-align: center;
margin-bottom: 1.5rem;
}
.logo {
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.02em;
margin: 0 0 0.25rem 0;
}
.tagline {
color: var(--text-muted);
font-size: 0.9rem;
margin: 0 0 1rem 0;
}
.api-key-wrap {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 0.35rem;
}
.api-key-wrap label {
font-size: 0.75rem;
color: var(--text-muted);
}
#apiKey {
width: 220px;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-input);
color: var(--text);
font-family: var(--font-mono);
font-size: 0.85rem;
}
#apiKey:focus {
outline: none;
border-color: var(--accent);
}
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.tab {
padding: 0.6rem 1.2rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
color: var(--text);
font-family: var(--font);
font-size: 0.95rem;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.tab:hover {
background: var(--bg-input);
}
.tab.active {
background: var(--accent);
border-color: var(--accent);
color: #0a0a0c;
}
.panel {
display: none;
}
.panel.active {
display: block;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
margin-bottom: 1rem;
}
.card h2 {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.5rem 0;
}
.card-hint {
font-size: 0.85rem;
color: var(--text-muted);
margin: 0 0 0.75rem 0;
}
.saved-list {
list-style: none;
padding: 0;
margin: 0 0 0.75rem 0;
}
.saved-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
}
.saved-list li:last-child { border-bottom: none; }
.saved-list .saved-item-row {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
}
.saved-list .saved-item-actions {
display: flex;
align-items: center;
gap: 0.4rem;
}
.saved-list .btn-connect-saved {
padding: 0.4rem 0.9rem;
font-size: 0.85rem;
}
.dynamic-list {
margin-bottom: 0.5rem;
}
.dynamic-list .item-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.text-muted {
color: var(--text-muted);
font-size: 0.9rem;
}
.dynamic-list .item-row input,
.dynamic-list .item-row select {
flex: 1;
min-width: 100px;
padding: 0.45rem 0.6rem;
font-size: 0.9rem;
}
.dynamic-list .item-row .btn-remove {
flex-shrink: 0;
}
.block-section {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem;
margin-bottom: 0.75rem;
background: var(--bg-input);
}
.block-section .block-title {
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.block-section .sub-list {
margin-top: 0.5rem;
}
.block-section .sub-list .item-row { margin-bottom: 0.35rem; }
.block-section .btn-remove-block { margin-top: 0.5rem; }
.form-row {
margin-bottom: 0.9rem;
}
.form-row label {
display: block;
font-size: 0.8rem;
color: var(--text-muted);
margin-bottom: 0.35rem;
}
.form-row input,
.form-row select,
.form-row textarea {
width: 100%;
padding: 0.55rem 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-input);
color: var(--text);
font-family: var(--font);
font-size: 0.9rem;
}
.form-row textarea {
min-height: 80px;
resize: vertical;
}
.form-row input:focus,
.form-row select:focus,
.form-row textarea:focus {
outline: none;
border-color: var(--accent);
}
.field-hint {
display: block;
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.35rem;
}
.delay-inputs {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.delay-inputs input {
width: 5rem;
}
.delay-inputs span {
color: var(--text-muted);
font-size: 0.9rem;
}
.form-row input[type="number"] {
max-width: 6rem;
}
.btn {
padding: 0.6rem 1.2rem;
border-radius: 8px;
font-family: var(--font);
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
border: none;
transition: background 0.15s, opacity 0.15s;
}
.btn-primary {
background: var(--accent);
color: #0a0a0c;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-ghost {
background: transparent;
color: var(--text-muted);
border: 1px solid var(--border);
}
.btn-ghost:hover {
color: var(--text);
border-color: var(--text-muted);
}
.btn-send {
margin-top: 0.5rem;
}
.btn-small {
padding: 0.35rem 0.6rem;
font-size: 0.8rem;
}
.btn-danger {
background: transparent;
color: #f87171;
border: 1px solid rgba(248, 113, 113, 0.4);
}
.btn-danger:hover {
background: rgba(239, 68, 68, 0.15);
color: #fca5a5;
border-color: var(--danger);
}
.qr-container {
margin-top: 1rem;
text-align: center;
}
.qr-container p {
margin: 0 0 0.5rem 0;
font-size: 0.9rem;
color: var(--text-muted);
}
.qr-image {
max-width: 280px;
width: 100%;
height: auto;
border-radius: 8px;
border: 1px solid var(--border);
}
.qr-hint {
margin-top: 0.75rem !important;
font-size: 0.8rem !important;
}
.status {
margin-top: 0.75rem;
padding: 0.5rem;
border-radius: 8px;
font-size: 0.85rem;
}
.status.error {
background: rgba(239, 68, 68, 0.15);
color: #fca5a5;
}
.status.success {
background: rgba(34, 197, 94, 0.15);
color: #86efac;
}
.hidden {
display: none !important;
}
.instance-list {
list-style: none;
padding: 0;
margin: 0 0 0.75rem 0;
}
.instance-list li {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
}
.instance-list .instance-name {
font-weight: 500;
}
.instance-list .instance-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.instance-list li:last-child {
border-bottom: none;
}
.instance-list .badge {
font-size: 0.7rem;
padding: 0.2rem 0.5rem;
border-radius: 6px;
text-transform: uppercase;
}
.instance-list .badge.connected {
background: rgba(34, 197, 94, 0.2);
color: var(--accent);
}
.instance-list .badge.qr {
background: rgba(234, 179, 8, 0.2);
color: #eab308;
}
.instance-list .badge.disconnected {
background: rgba(239, 68, 68, 0.2);
color: var(--danger);
}
#btnRefreshList {
margin-top: 0.25rem;
}
.dispatch-form {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.dispatch-form.hidden {
display: none;
}
.form-card .form-row:first-of-type {
margin-top: 0;
}
.result {
margin-top: 1rem;
padding: 0.75rem;
border-radius: 8px;
font-size: 0.9rem;
font-family: var(--font-mono);
}
.result.success {
background: rgba(34, 197, 94, 0.15);
color: #86efac;
}
.result.error {
background: rgba(239, 68, 68, 0.15);
color: #fca5a5;
}
/* CTA row: tipo + texto + extra */
#formInteractive .form-row select,
#formInteractive .form-row input {
width: auto;
min-width: 0;
}
#formInteractive .form-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
#formInteractive .form-row label {
margin-bottom: 0;
min-width: 3rem;
}
#formInteractive .form-row input:nth-of-type(2) {
flex: 1;
min-width: 100px;
}
#formInteractive .form-row input:nth-of-type(3) {
flex: 1;
min-width: 120px;
}