Projeto: Suaid Global — suaidglobal.com Componente: Chat Widget de Qualificação de Leads Data: 2026-03-24 Status: Implementado no base.njk com flag
useCustomWidget
| Arquivo | Linhas | Função |
|---|---|---|
src/css/chat-widget.css | 464 | Estilos do widget, variáveis --cw-*, responsivo, mobile |
src/js/chat-widget.js | 748 | Engine do chat: flow, validações, mascaras, syncToGHL, tracking |
src/_includes/partials/chat-widget.njk | 59 | HTML estrutural (toggle + window + input) |
src/chat-widget/chat-widget.html | ~1350 | Demo standalone (CSS+JS+HTML inline, para testes) |
src/chat-widget/suaid-form-proxy-worker.js | ~380 | Cloudflare Worker atualizado com progressive sync |
O src/_includes/layouts/base.njk foi modificado com uma abordagem conservadora usando flag useCustomWidget:
Linha 63-67: CSS condicional no <head>
<link rel="stylesheet" href="/css/chat-widget.css?v=..." ...>
Linha 127-130: JS condicional antes do </body>
<script src="/js/chat-widget.js?v=..." defer></script>
Linha 194-206: HTML condicional + fallback GHL
<div id="cw-overlay" aria-hidden="true"></div>
<button id="cw-toggle" aria-label="Open chat" aria-haspopup="dialog" style="opacity:0;pointer-events:none">
<span id="cw-badge" aria-hidden="true">1</span>
<svg class="icon-chat" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<svg class="icon-close" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
<div id="cw-window" role="dialog" aria-modal="true" aria-label="Chat with Suaid Global" style="opacity:0;pointer-events:none">
<div class="cw-header">
<button class="cw-restart" id="cw-restart-btn" onclick="restartFlow()" aria-label="Restart conversation" title="Restart">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
</button>
<button class="cw-minimize" onclick="toggleChat()" aria-label="Minimize chat">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div class="cw-header-top">
<div class="cw-avatar" aria-hidden="true">
<svg viewBox="0 0 834 834" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="417" cy="417" r="417" fill="#142A43"/>
<text x="417" y="460" text-anchor="middle" fill="#E8611A" font-size="380" font-weight="800" font-family="Inter,sans-serif">S</text>
</svg>
</div>
<div class="cw-header-info">
<h3>Suaid Global</h3>
<div class="cw-status">
<span class="cw-status-dot" aria-hidden="true"></span>
<span>Typically replies in minutes</span>
</div>
</div>
</div>
</div>
<div class="cw-progress" role="progressbar" aria-label="Chat progress" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"><div class="cw-progress-bar" id="cw-progress" style="width:0%"></div></div>
<div class="cw-messages" id="cw-messages" aria-live="polite" aria-relevant="additions"></div>
<div class="cw-input-area" id="cw-input-area">
<div class="cw-input-wrap" id="cw-input-wrap">
<div id="cw-phone-region" style="display:none"></div>
<input type="text" class="cw-input" id="cw-input" placeholder="Type your message..." autocomplete="off" aria-label="Chat message input">
<button class="cw-send-btn" id="cw-send" aria-label="Send message">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
<div class="cw-input-error" id="cw-error" role="alert" aria-live="assertive"></div>
</div>
<div class="cw-powered">Powered by <a href="https://suaidglobal.com" target="_blank" rel="noopener">Suaid Global</a></div>
</div>
useCustomWidgetAtualmente definida apenas em src/support/faq/shipping.njk (frontmatter: useCustomWidget: true). Todas as outras páginas usam o GHL widget padrão.
Isso é intencional — permite testar o widget custom em uma única página antes de ativar globalmente.
O IMPLEMENTATION-PROMPT.md antigo referenciava suaid-form-proxy.suaidglobal.workers.dev, mas o código real em chat-widget.js e chat-widget.html aponta para:
const API_ENDPOINT = 'https://suaid-form-proxy.suaid.workers.dev';
O correto é suaid.workers.dev (mesmo domínio usado pelo tariff-tool.js). Sem conflito — o código está correto.
O useCustomWidget: true está somente em src/support/faq/shipping.njk. Para ativar globalmente existem duas opções:
Opção A — Ativar via data global (recomendado para ir ao ar): Criar src/_data/useCustomWidget.js que retorna true:
module.exports = true;
Isso define useCustomWidget = true globalmente em todas as páginas, ativando o widget custom e desativando o GHL widget em todo o site.
Opção B — Ativar página por página (testing): Adicionar useCustomWidget: true no frontmatter de cada página que deve usar o widget custom. Útil para rollout gradual.
Opção C — Remover a flag e ativar direto no base.njk (all-in): Remover os `` e sempre carregar o widget custom, removendo completamente o GHL widget. Só fazer isso quando o widget estiver 100% validado.
O site suporta dark mode via [data-theme=dark]. O widget CSS usa variáveis --cw-* com valores fixos (tema claro). Se o usuário estiver em dark mode, o widget aparecerá com fundo branco contrastando com o site escuro.
Impacto: Visual inconsistente em dark mode. Solução: Adicionar bloco [data-theme=dark] no chat-widget.css com variáveis adaptadas.
O worker aceita origins de:
suaidglobal.com, www.suaidglobal.comsuaid.co, www.suaid.colocalhost:8080, localhost:3000Se o site for acessado via outro domínio (ex: preview do Cloudflare Pages como xxx.pages.dev), o widget falhará silenciosamente (CORS blocked). Adicionar o domínio de preview se necessário.
Todos os seletores CSS usam prefixo #cw- ou .cw-. Variáveis usam --cw-*. Zero conflito com style.css.
O JS não expõe nenhuma variável global exceto toggleChat e restartFlow (necessárias para onclick no HTML). Não conflita com main.js.
O syncToGHL está implementado em 4 etapas:
syncToGHL(true) marca como completoO worker suporta _contactId e _opportunityId no payload para fazer PUT ao invés de POST em syncs subsequentes.
Adicionar ao final de chat-widget.css:
/* Dark mode support */
[data-theme=dark] #cw-toggle{
box-shadow:0 4px 20px rgba(232,97,26,0.3);
}
[data-theme=dark] #cw-window{
--cw-bg:#1A2332;
--cw-bg-alt:#0F1922;
--cw-border:rgba(255,255,255,0.08);
--cw-text:#F1F5F9;
--cw-text-sec:#94A3B8;
--cw-text-muted:#64748B;
--cw-shadow:0 8px 40px rgba(0,0,0,0.4);
border:1px solid rgba(255,255,255,0.06);
}
[data-theme=dark] .cw-msg.bot .cw-bubble{
background:var(--cw-bg-alt);
color:var(--cw-text);
}
[data-theme=dark] .cw-opt-btn{
background:var(--cw-bg-alt);
color:var(--cw-text);
border-color:rgba(255,255,255,0.1);
}
[data-theme=dark] .cw-opt-btn:hover{
border-color:var(--cw-orange);
}
[data-theme=dark] .cw-input-wrap{
background:var(--cw-bg-alt);
border-color:rgba(255,255,255,0.1);
}
[data-theme=dark] .cw-input{
color:var(--cw-text);
}
[data-theme=dark] .cw-contact-card{
background:var(--cw-bg-alt);
border-color:rgba(255,255,255,0.08);
}
[data-theme=dark] .cw-summary{
background:var(--cw-bg-alt);
border-color:rgba(255,255,255,0.08);
}
[data-theme=dark] .cw-powered{
border-color:rgba(255,255,255,0.04);
color:var(--cw-text-muted);
}
[data-theme=dark] .cw-region-dropdown{
background:var(--cw-bg);
border-color:rgba(255,255,255,0.1);
}
[data-theme=dark] .cw-region-item:hover{
background:rgba(255,255,255,0.05);
}
Atualmente, se syncToGHL() falha, salva em sessionStorage mas nunca tenta reenviar. Adicionar retry automático:
// No chat-widget.js, após a definição de syncToGHL:
// Retry pending leads on page load
(function retryPendingLeads(){
try {
const pending = sessionStorage.getItem('cw_pending_lead');
if(!pending) return;
const payload = JSON.parse(pending);
fetch(API_ENDPOINT, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload),
}).then(r => {
if(r.ok) sessionStorage.removeItem('cw_pending_lead');
}).catch(() => {}); // silent fail, will retry next page
} catch(e){}
})();
O widget usa position: fixed o que funciona em RTL, mas os botões e layout interno devem ser espelhados. Adicionar:
[dir="rtl"] #cw-toggle{right:auto;left:24px}
[dir="rtl"] #cw-window{right:auto;left:24px;transform-origin:bottom left}
[dir="rtl"] .cw-msg.user{flex-direction:row}
[dir="rtl"] .cw-msg.bot{flex-direction:row-reverse}
[dir="rtl"] .cw-header .cw-minimize{right:auto;left:16px}
[dir="rtl"] .cw-header::after{right:auto;left:-40px}
O widget tem seu próprio design system. Para ficar 100% alinhado ao site, considerar:
Radius: Widget usa --cw-radius: 20px, site usa --radius: 16px. Alinhar para 16px.
Sombras: Widget usa 0 8px 40px rgba(0,0,0,0.15), site usa --shadow-md: 0 4px 20px rgba(0,0,0,.06). O widget é mais dramático (intencional para floating element).
Cards: O site usa border-radius: 16px e hover: translateY(-3px) em cards. O widget usa 12px nos option buttons. Alinhar os .cw-opt-btn para border-radius: var(--cw-radius) (~16px) e adicionar hover lift.
Font size: O site base é 16px. O widget messages usam .82rem (~13px). Pode ser levemente pequeno em telas grandes — considerar .85rem.
role="dialog" e aria-modal="true" no #cw-windowaria-live="polite" no #cw-messages para screen readersaria-label nos option buttonsCarregar o widget com delay de 3-5s após page load, e mostrar o badge pulsando. Atualmente aparece imediatamente com o DOM.
// Delay widget appearance
document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
document.getElementById('cw-toggle').classList.add('ready');
}, 3000);
});
#cw-toggle{opacity:0;transform:scale(0.5);pointer-events:none}
#cw-toggle.ready{opacity:1;transform:scale(1);pointer-events:auto;transition:all .5s cubic-bezier(.4,0,.2,1)}
Atualmente só rastreia generate_lead no final. Adicionar eventos em cada step para funnel analysis:
function trackStep(step) {
if(typeof gtag === 'function'){
gtag('event', 'chat_widget_step', {
event_category: 'chat_widget',
step_name: step,
objective: state.data.objective || 'not_set'
});
}
}
Chamar trackStep(state.step) no updateProgress().
A flag já está ativa. Testar o flow completo, verificar console, confirmar no GHL que o contato + oportunidade foram criados.
Criar src/_data/useCustomWidget.js:
module.exports = true;
Isso ativa o widget em TODAS as páginas (exceto /quote/ que já é excluída no base.njk).
No base.njk, remover o bloco else com o script do LeadConnector:
O worker em src/chat-widget/suaid-form-proxy-worker.js contém a lógica de progressive sync (_contactId e _opportunityId). Precisa ser deployed no Cloudflare:
# Via Wrangler CLI
npx wrangler deploy src/chat-widget/suaid-form-proxy-worker.js \
--name suaid-form-proxy \
--compatibility-date 2024-01-01
# Ou copiar manualmente no Cloudflare Dashboard:
# Workers & Pages → suaid-form-proxy → Edit Code → paste → Deploy
IMPORTANTE: O worker atualizado é backward-compatible — funciona tanto com o quote form existente quanto com o chat widget. O quote form NÃO envia _contactId/_opportunityId, então o worker segue o fluxo original (search + create).
LEAD ABRE CHAT
↓
[1] Escolhe objetivo (welcome → objective)
↓
[2] Digita nome (objective → name)
↓
[3] Digita email → VALIDA → syncToGHL() ━━━► Worker ━━━► GHL
↓ ↑ cria Contact ↑ retorna contactId
↓ ↑ cria Opportunity ↑ retorna opportunityId
↓ ↑ salva IDs no state
↓
[4] Digita telefone → VALIDA → syncToGHL() ━━━► Worker ━━━► GHL
↓ ↑ atualiza Contact (PUT com contactId)
↓ ↑ atualiza Opportunity (PUT com oppId)
↓
[5] Descreve necessidade → syncToGHL() ━━━► Worker ━━━► GHL
↓ ↑ atualiza com detalhes
↓
[6] Escolhe método de contato → syncToGHL(true) ━━━► Worker ━━━► GHL
↓ ↑ sync final (isFinal=true)
↓
[7] Mostra summary + thank you + links diretos
Se o lead abandona em qualquer ponto após o step 3, os dados já estão no GHL. Cada sync subsequente ATUALIZA o mesmo contato/oportunidade (não cria duplicatas).
Se a rede falha, os dados são salvos em sessionStorage para retry (melhoria 3.2 para retry automático recomendada).
chat-widget, wants-quote / wants-support / etc., prefers-whatsapp / etc.r9gg8ed9XddqZAiKh2J0)a43e190f-d59c-4b17-9197-a7130ae2e2ae)Se o client-side não conseguir IP/geo (bloqueio de ipapi.co), o worker enriquece com:
CF-Connecting-IP → IP realCF-IPCountry → paísrequest.cf.city, request.cf.region, request.cf.timezone, request.cf.asOrganizationsrc/
├── css/
│ ├── style.css ← NÃO MODIFICADO
│ ├── phosphor.css ← NÃO MODIFICADO
│ └── chat-widget.css ← NOVO (464 linhas)
│
├── js/
│ ├── main.js ← NÃO MODIFICADO
│ └── chat-widget.js ← NOVO (748 linhas)
│
├── _includes/
│ ├── layouts/
│ │ └── base.njk ← MODIFICADO (3 blocos condicionais adicionados)
│ └── partials/
│ └── chat-widget.njk ← NOVO (59 linhas, HTML estrutural)
│
├── chat-widget/
│ ├── chat-widget.html ← Demo standalone (~1350 linhas, para testes offline)
│ ├── suaid-form-proxy-worker.js ← Worker atualizado (progressive sync)
│ └── IMPLEMENTATION-PROMPT.md ← ESTE ARQUIVO
│
├── _data/
│ └── useCustomWidget.js ← CRIAR quando pronto para ativar globalmente
/support/faq/shipping/)[ChatWidget] GHL sync OK em cada step (email, phone, details, contact)npm run build completa sem errosmedia="print" onload="this.media='all'")defer| # | Melhoria | Prioridade | Impacto | Esforço |
|---|---|---|---|---|
| 1 | Dark mode support | 🔴 Alta | Visual quebrado em dark mode | ~30 linhas CSS |
| 2 | Retry queue para leads | 🔴 Alta | Leads perdidos se rede falha | ~15 linhas JS |
| 3 | RTL (Arabic) support | 🟡 Média | Widget invertido em ar | ~10 linhas CSS |
| 4 | Alinhar radius/shadows ao site | 🟡 Média | Consistência visual | ~5 variáveis CSS |
| 5 | Acessibilidade (a11y) | 🟡 Média | Compliance, screen readers | ~20 linhas HTML+JS |
| 6 | Delay de entrada (3s) | 🟢 Baixa | UX mais elegante | ~10 linhas CSS+JS |
| 7 | GA4 funnel events | 🟢 Baixa | Analytics detalhado | ~10 linhas JS |
| 8 | Ativar globalmente | 🟡 Média | Widget em todo o site | 1 arquivo JS de 1 linha |
| 9 | Deploy worker atualizado | 🔴 Alta | Progressive sync funcionar | 1 comando wrangler |