cdp-edge 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +367 -0
  2. package/bin/cdp-edge.js +61 -0
  3. package/contracts/api-versions.json +368 -0
  4. package/dist/commands/analyze.js +52 -0
  5. package/dist/commands/infra.js +54 -0
  6. package/dist/commands/install.js +168 -0
  7. package/dist/commands/server.js +174 -0
  8. package/dist/commands/setup.js +123 -0
  9. package/dist/commands/validate.js +84 -0
  10. package/dist/index.js +12 -0
  11. package/docs/CI-CD-SETUP.md +217 -0
  12. package/docs/PixelBuilder-Documentacao-Completa (2).docx +0 -0
  13. package/docs/events-reference.md +359 -0
  14. package/docs/installation.md +155 -0
  15. package/docs/quick-start.md +185 -0
  16. package/docs/sdk-reference.md +371 -0
  17. package/docs/whatsapp-ctwa.md +209 -0
  18. package/extracted-skill/tracking-events-generator/INDEX.md +94 -0
  19. package/extracted-skill/tracking-events-generator/INSTALACAO-CDPEDGE.md +58 -0
  20. package/extracted-skill/tracking-events-generator/INTEGRACAO-COMPLETA.md +594 -0
  21. package/extracted-skill/tracking-events-generator/MELHORIAS-IMPLEMENTADAS.md +412 -0
  22. package/extracted-skill/tracking-events-generator/Premium-Tracking-Intelligence-Resumo.md +333 -0
  23. package/extracted-skill/tracking-events-generator/SKILL.md +257 -0
  24. package/extracted-skill/tracking-events-generator/advanced-matching.js +364 -0
  25. package/extracted-skill/tracking-events-generator/agents/ab-testing-agent.md +54 -0
  26. package/extracted-skill/tracking-events-generator/agents/attribution-agent.md +1304 -0
  27. package/extracted-skill/tracking-events-generator/agents/bing-agent.md +76 -0
  28. package/extracted-skill/tracking-events-generator/agents/browser-tracking.md +264 -0
  29. package/extracted-skill/tracking-events-generator/agents/code-guardian-agent.md +149 -0
  30. package/extracted-skill/tracking-events-generator/agents/compliance-agent.md +2077 -0
  31. package/extracted-skill/tracking-events-generator/agents/crm-integration-agent.md +1419 -0
  32. package/extracted-skill/tracking-events-generator/agents/dashboard-agent.md +456 -0
  33. package/extracted-skill/tracking-events-generator/agents/database-agent.md +667 -0
  34. package/extracted-skill/tracking-events-generator/agents/debug-agent.md +1455 -0
  35. package/extracted-skill/tracking-events-generator/agents/domain-setup-agent.md +224 -0
  36. package/extracted-skill/tracking-events-generator/agents/email-agent.md +61 -0
  37. package/extracted-skill/tracking-events-generator/agents/fingerprint-agent.md +52 -0
  38. package/extracted-skill/tracking-events-generator/agents/google-agent.md +109 -0
  39. package/extracted-skill/tracking-events-generator/agents/intelligence-agent.md +365 -0
  40. package/extracted-skill/tracking-events-generator/agents/intelligence-scheduling.md +643 -0
  41. package/extracted-skill/tracking-events-generator/agents/linkedin-agent.md +62 -0
  42. package/extracted-skill/tracking-events-generator/agents/localization-agent.md +55 -0
  43. package/extracted-skill/tracking-events-generator/agents/ltv-predictor-agent.md +59 -0
  44. package/extracted-skill/tracking-events-generator/agents/master-feedback-loop.md +900 -0
  45. package/extracted-skill/tracking-events-generator/agents/master-orchestrator.md +1922 -0
  46. package/extracted-skill/tracking-events-generator/agents/memory-agent.json +109 -0
  47. package/extracted-skill/tracking-events-generator/agents/memory-agent.md +703 -0
  48. package/extracted-skill/tracking-events-generator/agents/meta-agent.md +110 -0
  49. package/extracted-skill/tracking-events-generator/agents/page-analyzer.md +255 -0
  50. package/extracted-skill/tracking-events-generator/agents/performance-agent.md +1157 -0
  51. package/extracted-skill/tracking-events-generator/agents/performance-optimization-agent.md +1432 -0
  52. package/extracted-skill/tracking-events-generator/agents/pinterest-agent.md +310 -0
  53. package/extracted-skill/tracking-events-generator/agents/premium-tracking-intelligence-agent.md +849 -0
  54. package/extracted-skill/tracking-events-generator/agents/r2-setup-agent.md +250 -0
  55. package/extracted-skill/tracking-events-generator/agents/reddit-agent.md +313 -0
  56. package/extracted-skill/tracking-events-generator/agents/security-enterprise-agent.md +1752 -0
  57. package/extracted-skill/tracking-events-generator/agents/server-tracking.md +1188 -0
  58. package/extracted-skill/tracking-events-generator/agents/spotify-agent.md +383 -0
  59. package/extracted-skill/tracking-events-generator/agents/tiktok-agent.md +111 -0
  60. package/extracted-skill/tracking-events-generator/agents/tracking-plan-agent.md +364 -0
  61. package/extracted-skill/tracking-events-generator/agents/validator-agent.md +267 -0
  62. package/extracted-skill/tracking-events-generator/agents/webhook-agent.md +69 -0
  63. package/extracted-skill/tracking-events-generator/agents/whatsapp-agent.md +76 -0
  64. package/extracted-skill/tracking-events-generator/agents/whatsapp-ctwa-setup-agent.md +699 -0
  65. package/extracted-skill/tracking-events-generator/agents/youtube-agent.md +422 -0
  66. package/extracted-skill/tracking-events-generator/anti-blocking.js +285 -0
  67. package/extracted-skill/tracking-events-generator/cdpTrack.js +641 -0
  68. package/extracted-skill/tracking-events-generator/contracts/api-versions.json +368 -0
  69. package/extracted-skill/tracking-events-generator/docs/guia-cloudflare-iniciante.md +107 -0
  70. package/extracted-skill/tracking-events-generator/engagement-scoring.js +226 -0
  71. package/extracted-skill/tracking-events-generator/evals/evals.json +235 -0
  72. package/extracted-skill/tracking-events-generator/integration-test.js +497 -0
  73. package/extracted-skill/tracking-events-generator/knowledge-base.md +2894 -0
  74. package/extracted-skill/tracking-events-generator/micro-events.js +992 -0
  75. package/extracted-skill/tracking-events-generator/models/captura-de-lead.md +78 -0
  76. package/extracted-skill/tracking-events-generator/models/captura-lead-evento-externo.md +99 -0
  77. package/extracted-skill/tracking-events-generator/models/checkout-proprio.md +111 -0
  78. package/extracted-skill/tracking-events-generator/models/multi-step-checkout.md +672 -0
  79. package/extracted-skill/tracking-events-generator/models/pagina-obrigado.md +55 -0
  80. package/extracted-skill/tracking-events-generator/models/pinterest/conversions-api-template.js +144 -0
  81. package/extracted-skill/tracking-events-generator/models/pinterest/event-mappings.json +48 -0
  82. package/extracted-skill/tracking-events-generator/models/pinterest/tag-template.js +28 -0
  83. package/extracted-skill/tracking-events-generator/models/quiz-funnel.md +68 -0
  84. package/extracted-skill/tracking-events-generator/models/reddit/conversions-api-template.js +205 -0
  85. package/extracted-skill/tracking-events-generator/models/reddit/event-mappings.json +56 -0
  86. package/extracted-skill/tracking-events-generator/models/reddit/pixel-template.js +19 -0
  87. package/extracted-skill/tracking-events-generator/models/scenarios/behavior-engine.js +425 -0
  88. package/extracted-skill/tracking-events-generator/models/scenarios/real-estate-logic.md +50 -0
  89. package/extracted-skill/tracking-events-generator/models/scenarios/sales-page-logic.md +50 -0
  90. package/extracted-skill/tracking-events-generator/models/trafego-direto.md +582 -0
  91. package/extracted-skill/tracking-events-generator/models/webinar-registration.md +63 -0
  92. package/extracted-skill/tracking-events-generator/tracking.config.js +46 -0
  93. package/extracted-skill/tracking-events-generator/walkthrough.md +26 -0
  94. package/package.json +75 -0
  95. package/server-edge-tracker/INSTALAR.md +328 -0
  96. package/server-edge-tracker/migrate-new-db.sql +137 -0
  97. package/server-edge-tracker/migrate-v2.sql +16 -0
  98. package/server-edge-tracker/migrate-v3.sql +6 -0
  99. package/server-edge-tracker/migrate-v4.sql +18 -0
  100. package/server-edge-tracker/migrate-v5.sql +17 -0
  101. package/server-edge-tracker/migrate-v6.sql +24 -0
  102. package/server-edge-tracker/migrate.sql +111 -0
  103. package/server-edge-tracker/schema.sql +265 -0
  104. package/server-edge-tracker/worker.js +2574 -0
  105. package/server-edge-tracker/wrangler.toml +85 -0
  106. package/templates/afiliado-sem-landing.md +312 -0
  107. package/templates/captura-de-lead.md +78 -0
  108. package/templates/captura-lead-evento-externo.md +99 -0
  109. package/templates/checkout-proprio.md +111 -0
  110. package/templates/install/.claude/commands/cdp.md +1 -0
  111. package/templates/install/CLAUDE.md +65 -0
  112. package/templates/linkedin/tag-template.js +46 -0
  113. package/templates/multi-step-checkout.md +673 -0
  114. package/templates/pagina-obrigado.md +55 -0
  115. package/templates/pinterest/conversions-api-template.js +144 -0
  116. package/templates/pinterest/event-mappings.json +48 -0
  117. package/templates/pinterest/tag-template.js +28 -0
  118. package/templates/quiz-funnel.md +68 -0
  119. package/templates/reddit/conversions-api-template.js +205 -0
  120. package/templates/reddit/event-mappings.json +56 -0
  121. package/templates/reddit/pixel-template.js +46 -0
  122. package/templates/scenarios/behavior-engine.js +402 -0
  123. package/templates/scenarios/real-estate-logic.md +50 -0
  124. package/templates/scenarios/sales-page-logic.md +50 -0
  125. package/templates/spotify/pixel-template.js +46 -0
  126. package/templates/trafego-direto.md +582 -0
  127. package/templates/vsl-page.md +292 -0
  128. package/templates/webinar-registration.md +63 -0
@@ -0,0 +1,2894 @@
1
+ # Knowledge Base: CDP Edge (Quantum Tier)
2
+
3
+ Esta é a fonte técnica oficial para o CDP Edge. Toda implementação deve seguir os padrões de infraestrutura Cloudflare Native (Workers + D1) para garantir máxima atribuição e performance.
4
+
5
+ ---
6
+
7
+ ## 🏗️ 1. ARQUITETURA DE DADOS (CLOUDFLARE NATIVE)
8
+
9
+ ### Camadas do Sistema:
10
+ 1. **Browser (Coleta)**: O SDK `cdpTrack.js` captura eventos de interação, dados de identidade e **micro-comportamentos**.
11
+ 2. **Worker (Processamento)**: O Cloudflare Worker recebe os dados, normaliza, executa o hashing WebCrypto e persiste no banco D1.
12
+ 3. **D1 Database (Persistência)**: Fonte de verdade para o Identity Graph e log de eventos completo (Low-level).
13
+ 4. **APIs (Despacho)**: Envio seletivo e assíncrono para Meta, TikTok e GA4 via `ctx.waitUntil`.
14
+
15
+ ### Estratégia: D1 vs. Plataformas (EMQ Optimization)
16
+ Para manter o Meta Pixel limpo e focado em conversão, aplicamos a regra de **Filtragem de Intent**:
17
+
18
+ * **D1 (Tudo)**: Salva 100% dos eventos (cliques x/y, rage clicks, scrolls, heartbeats). É sua ferramenta de auditoria e BI.
19
+ * **Plataformas (Meta/Google)**: Recebe apenas eventos de **Alta Intenção** (Milestones):
20
+ * `Retention_Pulse` (Ex: usuário ativo por +60s).
21
+ * `Rage_Click` (Exclusão técnica).
22
+ * `VSL_25/50/75/100` (Funis de vídeo).
23
+ * Eventos Standard (`Lead`, `Purchase`, `AddToCart`).
24
+
25
+ ---
26
+
27
+ ## 🛠️ 2. PADRÕES TÉCNICOS
28
+
29
+ ### 2.1 Identity Graph (Lead Lock)
30
+ A identidade do usuário é o pilar da atribuição. Ela vincula identificadores anônimos a dados reais (PII) de forma segura.
31
+ - **Campos**: `email_hash`, `phone_hash`, `fbp`, `fbc`, `ttp`, `ttclid`, `ip`, `ua`.
32
+ - **Lógica**: Quando um Webhook de venda chega, o sistema busca no D1 pelo e-mail/telefone para recuperar os cookies de browser originais, garantindo Match Quality máximo.
33
+
34
+ ### 2.2 Deduplicação (Event ID)
35
+ - Todo evento disparado no browser gera um `event_id` único.
36
+ - Esse mesmo ID deve ser enviado na chamada de servidor (CAPI) para que as plataformas ignorem duplicatas.
37
+
38
+ ---
39
+
40
+ ## 🏗️ 3. HUMAN-BEHAVIOR ENGINE (MICRO-EVENTOS)
41
+
42
+ Protocolos de captura para análise de CRO e saúde técnica.
43
+
44
+ ### 3.1 Rage Click Detector
45
+ Detecta quando o usuário clica repetidamente (>3 cliques em 500ms) em uma área não interativa.
46
+ * **Ação**: Disparar `rage_click`.
47
+ * **Uso**: Identificar bugs de UI ou frustração do usuário.
48
+
49
+ ### 3.2 Visibility Heartbeat (Tab Focus)
50
+ Detecta se o usuário mudou de aba (`document.visibilityState === 'hidden'`).
51
+ * **Ação**: Disparar `tab_visibility_change` com status `hidden` ou `visible`.
52
+ * **Uso**: Medir retenção real em VSLs onde o áudio continua mas o vídeo não é visto.
53
+
54
+ ### 3.3 Click Heatmap (D1 Only)
55
+ Captura as coordenadas relativas do clique no documento.
56
+ * **Schema D1**: `x_pos` (float), `y_pos` (float), `element_id` (string), `element_class` (string).
57
+ * **Uso**: Reconstrução de mapas de calor sem ferramentas externas.
58
+
59
+ ### 3.4 Retention Pulse
60
+ Disparado em intervalos fixos (30s, 60s, 120s) para medir o tempo de atenção ativa.
61
+ * **Ação**: Disparar `pulse_heartbeat` com o atributo `duration`.
62
+ * **Uso**: Treinar a IA da Meta com usuários de "Alto Tempo de Atenção".
63
+
64
+ ---
65
+
66
+ ## 🛠️ 4. GERADOR DE TRACKING (SDK UNIFICADO)
67
+
68
+ **Gerado uma vez. Nunca editar.** Despacha cada evento para todas as plataformas ativas automaticamente.
69
+
70
+ ```js
71
+ // src/tracking/tracking.js
72
+ // ============================================================
73
+ // CDPEDGE — Utilitário multi-plataforma unificado
74
+ // Suporta: Meta · GA4 · Google Ads · TikTok · Pinterest · Bing · Reddit
75
+ // NÃO edite — edite apenas tracking.config.js
76
+ // ============================================================
77
+ import CONFIG from './tracking.config.js';
78
+
79
+ // ── Guards — segurança em SSR e SDK não carregado ──
80
+ const isBrowser = typeof window !== 'undefined';
81
+ const has = (fn) => isBrowser && typeof window[fn] === 'function';
82
+
83
+ // ── Captura de parâmetros de URL no carregamento da página ──
84
+ // Cada plataforma injeta seu próprio Click ID na URL quando o usuário clica em um anúncio.
85
+ // Guardamos todos em memória para enviar ao servidor e garantir atribuição correta.
86
+ const _urlParams = isBrowser ? new URLSearchParams(window.location.search) : new URLSearchParams();
87
+
88
+ // Meta
89
+ const _ctwaClid = _urlParams.get('ctwa_clid') || ''; // Click-to-WhatsApp Click ID
90
+ const _fbclid = _urlParams.get('fbclid') || ''; // Meta Ads click ID → gera cookie _fbc
91
+
92
+ // Google Ads (três variantes por tipo de match/privacy)
93
+ const _gclid = _urlParams.get('gclid') || ''; // Google Ads standard click ID → gera cookie _gcl_aw
94
+ const _wbraid = _urlParams.get('wbraid') || ''; // Google Ads (iOS, web-to-app, privacy preserving)
95
+ const _gbraid = _urlParams.get('gbraid') || ''; // Google Ads (app campaigns, privacy preserving)
96
+
97
+ // TikTok
98
+ const _ttclid = _urlParams.get('ttclid') || ''; // TikTok Ads click ID → complementa cookie _ttp
99
+
100
+ // UTMs — rastreamento interno de origem de tráfego (independente da atribuição das plataformas)
101
+ const _utms = {
102
+ utm_source: _urlParams.get('utm_source') || '',
103
+ utm_medium: _urlParams.get('utm_medium') || '',
104
+ utm_campaign: _urlParams.get('utm_campaign') || '',
105
+ utm_content: _urlParams.get('utm_content') || '',
106
+ utm_term: _urlParams.get('utm_term') || '',
107
+ };
108
+
109
+ // ── User ID persistente (first-party cookie) ──────────────────────────────────
110
+ // Identifica o mesmo usuário entre sessões diferentes, mesmo sem login.
111
+ // Resolve o problema de cookies de terceiros bloqueados por ad-blockers.
112
+ const _getUserId = () => {
113
+ if (!isBrowser) return '';
114
+ const KEY = '_cdp_uid';
115
+ let uid = document.cookie.match(new RegExp(`${KEY}=([^;]+)`))?.[1];
116
+ if (!uid) {
117
+ uid = `${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
118
+ // Cookie first-party de 365 dias — não bloqueado por navegadores
119
+ document.cookie = `${KEY}=${uid}; max-age=${60*60*24*365}; path=/; SameSite=Lax`;
120
+ }
121
+ return uid;
122
+ };
123
+ const _userId = isBrowser ? _getUserId() : '';
124
+
125
+ /** getUTMs() — retorna os UTMs capturados na chegada do usuário
126
+ * Útil para: salvar no CRM, incluir no payload do servidor, rastreamento interno.
127
+ * Atribuição própria independente da janela das plataformas (último clique registrado). */
128
+ export const getUTMs = () => ({ ..._utms });
129
+
130
+ /** getUserId() — retorna o User ID persistente gerado como cookie first-party.
131
+ * Identifica o mesmo usuário entre sessões. Não é bloqueado por ad-blockers.
132
+ * Identificador de usuário persistente nativo. */
133
+ export const getUserId = () => _userId;
134
+
135
+ /**
136
+ * passCheckoutParams(options?) — adiciona UTMs + User ID nos links de checkout externo.
137
+ *
138
+ * Problema: quando o usuário clica em um link de Hotmart, Kiwify, Eduzz etc., os UTMs e
139
+ * o user ID são perdidos — a plataforma não sabe de onde veio o comprador.
140
+ *
141
+ * Solução: interceptar os links de checkout e adicionar os parâmetros de rastreamento
142
+ * na URL antes do clique, para que a plataforma receba e registre a origem.
143
+ *
144
+ * Plataformas suportadas e seus parâmetros:
145
+ * hotmart → xcod (user ID) + sck (UTMs pipe-separados: src|med|camp|con|term)
146
+ * kiwify → src (utm_source)
147
+ * eduzz → src (utm_source)
148
+ * monetizze → src (utm_source)
149
+ * cartpanda → utm_* passthrough direto
150
+ * ticto → utm_* + user_id (_cdp_uid) — capturado em wh.tracking e wh.url_params
151
+ * custom → qualquer domínio via opção `domains`
152
+ *
153
+ * Uso:
154
+ * passCheckoutParams() // detecta automaticamente
155
+ * passCheckoutParams({ platforms: ['hotmart'] }) // só Hotmart
156
+ * passCheckoutParams({ domains: ['meusite.com/checkout'] }) // domínio customizado
157
+ */
158
+ export const passCheckoutParams = (options = {}) => {
159
+ if (!isBrowser) return;
160
+
161
+ const {
162
+ platforms = ['hotmart', 'kiwify', 'eduzz', 'monetizze', 'cartpanda', 'ticto'],
163
+ domains = [], // domínios extras além das plataformas padrão
164
+ extra = {}, // parâmetros extras a adicionar em todos os links
165
+ } = options;
166
+
167
+ const utms = getUTMs();
168
+ const userId = _userId;
169
+
170
+ // Mapa de domínios por plataforma
171
+ const PLATFORM_DOMAINS = {
172
+ hotmart: ['hotmart.com', 'pay.hotmart.com', 'payment.hotmart.com'],
173
+ kiwify: ['kiwify.com.br', 'checkout.kiwify.com.br'],
174
+ eduzz: ['eduzz.com', 'sun.eduzz.com'],
175
+ monetizze: ['monetizze.com.br'],
176
+ cartpanda: ['cartpanda.com', 'pay.cartpanda.com'],
177
+ ticto: ['ticto.app', 'pay.ticto.app', 'checkout.ticto.app'],
178
+ };
179
+
180
+ // Constrói o `sck` do Hotmart: utm_source|utm_medium|utm_campaign|utm_content|utm_term
181
+ const buildSck = () =>
182
+ [utms.utm_source, utms.utm_medium, utms.utm_campaign, utms.utm_content, utms.utm_term]
183
+ .map(v => v || 'direto')
184
+ .join('|');
185
+
186
+ // Parâmetros por plataforma
187
+ const getParamsForUrl = (href) => {
188
+ const params = { ...extra };
189
+ const url = href.toLowerCase();
190
+
191
+ const isMatch = (list) => list.some(d => url.includes(d));
192
+
193
+ if (platforms.includes('hotmart') && isMatch(PLATFORM_DOMAINS.hotmart)) {
194
+ if (userId) params.xcod = userId;
195
+ if (utms.utm_source) params.sck = buildSck();
196
+ } else if (platforms.includes('kiwify') && isMatch(PLATFORM_DOMAINS.kiwify)) {
197
+ if (utms.utm_source) params.src = utms.utm_source;
198
+ if (utms.utm_medium) params.utm_medium = utms.utm_medium;
199
+ if (utms.utm_campaign) params.utm_campaign = utms.utm_campaign;
200
+ } else if (
201
+ (platforms.includes('eduzz') && isMatch(PLATFORM_DOMAINS.eduzz)) ||
202
+ (platforms.includes('monetizze') && isMatch(PLATFORM_DOMAINS.monetizze))
203
+ ) {
204
+ if (utms.utm_source) params.src = utms.utm_source;
205
+ } else if (platforms.includes('cartpanda') && isMatch(PLATFORM_DOMAINS.cartpanda)) {
206
+ // CartPanda recebe utm_* diretamente
207
+ Object.entries(utms).forEach(([k, v]) => { if (v) params[k] = v; });
208
+ } else if (platforms.includes('ticto') && isMatch(PLATFORM_DOMAINS.ticto)) {
209
+ Object.entries(utms).forEach(([k, v]) => { if (v) params[k] = v; });
210
+ if (userId) params.user_id = userId;
211
+ } else {
212
+ // Domínios customizados: repassa utm_* e userId
213
+ const allDomains = Object.values(PLATFORM_DOMAINS).flat().concat(domains);
214
+ if (!isMatch(allDomains)) return null; // não é checkout conhecido
215
+ Object.entries(utms).forEach(([k, v]) => { if (v) params[k] = v; });
216
+ if (userId) params.user_id = userId;
217
+ }
218
+
219
+ return Object.keys(params).length ? params : null;
220
+ };
221
+
222
+ const applyParams = (link) => {
223
+ if (!link.href || link.href.startsWith('javascript')) return;
224
+ const p = getParamsForUrl(link.href);
225
+ if (!p) return;
226
+
227
+ try {
228
+ const url = new URL(link.href);
229
+ Object.entries(p).forEach(([k, v]) => url.searchParams.set(k, v));
230
+ link.href = url.toString();
231
+ } catch { /* URL inválida — ignora */ }
232
+ };
233
+
234
+ // Aplica em todos os links existentes
235
+ document.querySelectorAll('a[href]').forEach(applyParams);
236
+
237
+ // Aplica em links adicionados dinamicamente (ex: botões de checkout lazy loaded)
238
+ new MutationObserver((mutations) => {
239
+ mutations.forEach(m => m.addedNodes.forEach(node => {
240
+ if (node.nodeType !== 1) return;
241
+ if (node.tagName === 'A') applyParams(node);
242
+ node.querySelectorAll?.('a[href]').forEach(applyParams);
243
+ }));
244
+ }).observe(document.body, { childList: true, subtree: true });
245
+ };
246
+
247
+ // ── Utilitários de formulário ─────────────────────────────────────────────────
248
+ // Normalizam dados ANTES de enviar às plataformas, alinhados com as variáveis
249
+
250
+
251
+ /** normalizePhone(tel) — normaliza telefone para o formato exigido pelas plataformas.
252
+ * Remove tudo que não é número. Adiciona DDI Brasil 55 se necessário.
253
+ * Meta exige somente dígitos com DDI — ex: '5511999998888'
254
+ * Retorna undefined se vazio (evita enviar campo em branco às APIs). */
255
+ export const normalizePhone = (tel = '') => {
256
+ let t = String(tel).replace(/\D/g, '');
257
+ if (!t) return undefined;
258
+ if (!t.startsWith('55') || t.length <= 11) t = '55' + t;
259
+ return t;
260
+ };
261
+
262
+ /** splitName(fullName) — divide nome completo em firstName e lastName.
263
+ * firstName = todos os nomes menos o último (alinha com campo Meta fn).
264
+ * lastName = somente o último nome (alinha com campo Meta ln).
265
+ * Ex: 'João da Silva' → { firstName: 'João da', lastName: 'Silva' }
266
+ * Ex: 'João' → { firstName: 'João', lastName: undefined } */
267
+ export const splitName = (fullName = '') => {
268
+ const parts = String(fullName).trim().split(/\s+/).filter(Boolean);
269
+ if (!parts.length) return { firstName: undefined, lastName: undefined };
270
+ if (parts.length === 1) return { firstName: parts[0], lastName: undefined };
271
+ return { firstName: parts.slice(0, -1).join(' '), lastName: parts.at(-1) };
272
+ };
273
+
274
+ /** isValidEmail(email) — valida formato de email antes de enviar.
275
+ * Evita poluir os dados das plataformas com emails inválidos.
276
+ * Validação de Email nativa do CDP Edge.
277
+ * Retorna true se válido, false caso contrário. */
278
+ export const isValidEmail = (email = '') =>
279
+ /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(email).toLowerCase().trim());
280
+
281
+ /** readField(selector) — lê valor de campo de formulário por id, name ou seletor CSS.
282
+ * ex: readField('email') → busca id="email" ou name="email"
283
+ * ex: readField('#meu-email') ou readField('input[name="telefone"]') */
284
+ export const readField = (selector = '') => {
285
+ if (!isBrowser) return '';
286
+ const el = /^[#.[]/.test(selector)
287
+ ? document.querySelector(selector)
288
+ : document.getElementById(selector) || document.querySelector(`[name="${selector}"]`);
289
+ return el?.value?.trim() || '';
290
+ };
291
+
292
+ /** normalizeCity(city) — normaliza cidade para o campo `ct` do Meta Advanced Matching.
293
+ * Regra Meta: lowercase, sem acentos, sem espaços, sem caracteres especiais.
294
+ * ex: 'São Paulo' → 'saopaulo' | 'Belo Horizonte' → 'belohorizonte'
295
+ * DEVE ser aplicada ANTES do SHA256 no lado servidor (CAPI). */
296
+ export const normalizeCity = (city = '') => {
297
+ if (!city) return undefined;
298
+ return String(city)
299
+ .normalize('NFD') // decompõe acentos (e.g. ã → a + ~)
300
+ .replace(/[\u0300-\u036f]/g, '') // remove diacríticos
301
+ .toLowerCase()
302
+ .replace(/[^a-z0-9]/g, ''); // remove tudo que não é letra ou número
303
+ };
304
+
305
+ // ── Utilitários de DataLayer / Items ─────────────────────────────────────────
306
+ // Transformam qualquer estrutura de produto para o formato GA4 items (a base de tudo).
307
+ // O GA4 items é a "língua universal" — de lá convertemos para Meta, TikTok, etc.
308
+
309
+ /**
310
+ * fixItems(malFormatted) — converte items MAL FORMATADOS para o padrão GA4.
311
+ * Recebe objeto ou array com qualquer estrutura de campo e mapeia para GA4.
312
+ * Uso: quando o dataLayer do site/plataforma chega com nomes diferentes dos esperados.
313
+ *
314
+ * Parâmetro fieldMap: { ga4Campo: 'nomeCampoOrigem' }
315
+ * Ex: fixItems(dlItems, { item_id:'id', item_name:'nome', price:'valor', quantity:'qtd' })
316
+ *
317
+ * Se não passar fieldMap, tenta detecção automática de campos comuns.
318
+ */
319
+ export const fixItems = (malFormatted, fieldMap = {}) => {
320
+ if (!malFormatted) return [];
321
+ const arr = Array.isArray(malFormatted) ? malFormatted : [malFormatted];
322
+ const fm = {
323
+ item_id: fieldMap.item_id || 'id',
324
+ item_name: fieldMap.item_name || 'name' ,
325
+ price: fieldMap.price || 'price',
326
+ quantity: fieldMap.quantity || 'quantity',
327
+ currency: fieldMap.currency || 'currency',
328
+ item_brand: fieldMap.item_brand || 'brand',
329
+ item_category: fieldMap.item_category || 'category',
330
+ item_variant: fieldMap.item_variant || 'variant',
331
+ };
332
+ return arr.map((item, index) => ({
333
+ item_id: item[fm.item_id] || item.sku || item.product_id || '',
334
+ item_name: item[fm.item_name] || item.title || item.nome || item.produto || '',
335
+ price: parseFloat(String(item[fm.price] || 0).replace(',', '.')),
336
+ quantity: parseInt(item[fm.quantity] || 1, 10),
337
+ index,
338
+ ...(item[fm.currency] && { currency: item[fm.currency] }),
339
+ ...(item[fm.item_brand] && { item_brand: item[fm.item_brand] }),
340
+ ...(item[fm.item_category]&& { item_category: item[fm.item_category] }),
341
+ ...(item[fm.item_variant] && { item_variant: item[fm.item_variant] }),
342
+ // campos opcionais GA4 — passados direto se presentes
343
+ ...(item.item_category2 && { item_category2: item.item_category2 }),
344
+ ...(item.item_category3 && { item_category3: item.item_category3 }),
345
+ ...(item.affiliation && { affiliation: item.affiliation }),
346
+ ...(item.coupon && { coupon: item.coupon }),
347
+ ...(item.discount && { discount: parseFloat(item.discount) }),
348
+ ...(item.item_list_id && { item_list_id: item.item_list_id }),
349
+ ...(item.item_list_name && { item_list_name: item.item_list_name }),
350
+ ...(item.item_variant && { item_variant: item.item_variant }),
351
+ ...(item.location_id && { location_id: item.location_id }),
352
+ ...(item.promotion_id && { promotion_id: item.promotion_id }),
353
+ ...(item.promotion_name && { promotion_name: item.promotion_name }),
354
+ }));
355
+ };
356
+
357
+ /**
358
+ * buildItems(params) — monta array GA4 a partir de parâmetros SOLTOS.
359
+ * Uso: quando o produto chega em campos separados (product_id, product_name, etc.)
360
+ * em vez de um array items.
361
+ *
362
+ * Ex: buildItems({ item_id: 'SKU123', item_name: 'Produto', price: 99.9 })
363
+ * → [{ item_id: 'SKU123', item_name: 'Produto', price: 99.9, quantity: 1 }]
364
+ */
365
+ export const buildItems = (params = {}) => [{
366
+ item_id: params.item_id || params.product_id || params.id || '',
367
+ item_name: params.item_name || params.product_name || params.name || params.nome || '',
368
+ price: parseFloat(params.price || params.valor || 0),
369
+ quantity: parseInt(params.quantity || params.quantidade || 1, 10),
370
+ currency: params.currency || 'BRL',
371
+ ...(params.item_brand && { item_brand: params.item_brand }),
372
+ ...(params.item_category && { item_category: params.item_category }),
373
+ ...(params.item_variant && { item_variant: params.item_variant }),
374
+ ...(params.item_category2&& { item_category2:params.item_category2 }),
375
+ ...(params.discount && { discount: parseFloat(params.discount) }),
376
+ ...(params.coupon && { coupon: params.coupon }),
377
+ ...(params.affiliation && { affiliation: params.affiliation }),
378
+ }];
379
+
380
+ /**
381
+ * ga4ItemsToMeta(items) — converte GA4 items para formato de content_ids + contents do Meta.
382
+ * Usado internamente pelo trackPurchase / trackAddToCart quando recebem _ga4Items.
383
+ * Converte dados para o formato Schema Standard da Meta.
384
+ */
385
+ export const ga4ItemsToMeta = (items = []) => ({
386
+ content_ids: items.map(i => String(i.item_id)),
387
+ contents: items.map(i => ({
388
+ id: String(i.item_id),
389
+ quantity: i.quantity || 1,
390
+ item_price: i.price || 0,
391
+ title: i.item_name || undefined,
392
+ category: i.item_category || undefined,
393
+ brand: i.item_brand || undefined,
394
+ })),
395
+ num_items: items.reduce((s, i) => s + (i.quantity || 1), 0),
396
+ value: items.reduce((s, i) => s + (i.price || 0) * (i.quantity || 1), 0),
397
+ });
398
+
399
+ /**
400
+ * ga4ItemsToTikTok(items) — converte GA4 items para formato de contents do TikTok Pixel.
401
+ * Converte dados para o formato Schema Standard do TikTok.
402
+ */
403
+ export const ga4ItemsToTikTok = (items = []) => ({
404
+ contents: items.map(i => ({
405
+ content_id: String(i.item_id),
406
+ content_name: i.item_name || undefined,
407
+ content_category: i.item_category || undefined,
408
+ brand: i.item_brand || undefined,
409
+ price: i.price || 0,
410
+ quantity: i.quantity || 1,
411
+ })),
412
+ value: items.reduce((s, i) => s + (i.price || 0) * (i.quantity || 1), 0),
413
+ });
414
+
415
+ // ── Dispatchers por plataforma ──
416
+ const meta = (type, name, p = {}) => has('fbq') && window.fbq(type, name, p);
417
+ const tiktok = (name, p = {}) => has('ttq') && window.ttq.track(name, p);
418
+ const pinterest = (name, p = {}) => has('pintrk') && window.pintrk('track', name, p);
419
+ const bing = (name, p = {}) => isBrowser && window.uetq?.push({ ea: name, ...p });
420
+ const reddit = (name, p = {}) => has('rdt') && window.rdt('track', name, p);
421
+ const gtag = (...a) => has('gtag') && window.gtag(...a);
422
+
423
+ // ── Verificação de credencial ──
424
+ const active = (key) => !!CONFIG[key];
425
+
426
+ // ── Dispatcher GA4 ──
427
+ const ga4Event = (name, params = {}) => gtag('event', name, params);
428
+ const ga4Ecommerce = (name, data) => gtag('event', name, { ecommerce: data });
429
+
430
+ // ── Conversão Google Ads (com Enhanced Conversions / Conversões Otimizadas) ──
431
+ const gadsConversion = (label, value = 0, currency = 'BRL', txId = '', email = '', phone = '') => {
432
+ if (!label || !active('googleAdsId')) return;
433
+ gtag('event', 'conversion', {
434
+ send_to: `${CONFIG.googleAdsId}/${label}`,
435
+ value, currency,
436
+ ...(txId && { transaction_id: txId }),
437
+ // Enhanced Conversions (Conversões Otimizadas) — atribuição a nível de usuário
438
+ ...(email && { email_address: email }),
439
+ ...(phone && { phone_number: phone }),
440
+ });
441
+ };
442
+
443
+ // ============================================================
444
+ // API PÚBLICA
445
+ // ============================================================
446
+
447
+ /** PageView — chame em cada página via useEffect(()=>{}, []) */
448
+ export const trackPageView = (title = document.title, url = window.location.pathname) => {
449
+ if (active('metaPixelId')) meta('track', 'PageView');
450
+ if (active('tiktokPixelId')) tiktok('PageView'); // ttq.page() automático
451
+ if (active('pinterestTagId')) pinterest('pagevisit');
452
+ if (active('redditPixelId')) reddit('PageVisit');
453
+ if (isBrowser && window.uetq) window.uetq.push({ ea: 'pageLoad' }); // Bing
454
+ ga4Event('page_view', { page_title: title, page_location: url });
455
+ };
456
+
457
+ /** ViewContent — ao carregar a página do produto/imóvel/serviço (NO useEffect, não no scroll) */
458
+ export const trackViewContent = (params = {}) => {
459
+ const { _ga4Items = [], _tiktok = {}, ...metaParams } = params;
460
+
461
+ if (active('metaPixelId')) meta('track', 'ViewContent', metaParams);
462
+ if (active('tiktokPixelId')) tiktok('ViewContent', {
463
+ content_id: (_tiktok.content_id || metaParams.content_ids?.[0] || ''),
464
+ content_name: metaParams.content_name || '',
465
+ content_type: metaParams.content_type || 'product',
466
+ value: metaParams.value || 0,
467
+ currency: metaParams.currency || 'BRL',
468
+ ..._tiktok
469
+ });
470
+ if (active('pinterestTagId')) pinterest('viewcategory', {
471
+ product_id: metaParams.content_ids?.[0] || '',
472
+ product_name: metaParams.content_name || '',
473
+ value: metaParams.value || 0,
474
+ currency: metaParams.currency || 'BRL',
475
+ });
476
+ if (active('redditPixelId')) reddit('ViewContent', { value: metaParams.value, currency: metaParams.currency });
477
+ if (isBrowser && window.uetq) window.uetq.push({ ea: 'view_content' });
478
+
479
+ ga4Ecommerce('view_item', {
480
+ currency: metaParams.currency || 'BRL',
481
+ value: metaParams.value || 0,
482
+ items: _ga4Items
483
+ });
484
+ };
485
+
486
+ // ── Geolocalização nativa (cidade do lead via OpenStreetMap — gratuito) ──
487
+ const getCidade = () => new Promise((resolve) => {
488
+ if (!isBrowser || !navigator.geolocation) return resolve(null);
489
+ navigator.geolocation.getCurrentPosition(
490
+ async ({ coords: { latitude, longitude } }) => {
491
+ try {
492
+ const r = await fetch(
493
+ `https://nominatim.openstreetmap.org/reverse?lat=${latitude}&lon=${longitude}&format=json`,
494
+ { headers: { 'Accept-Language': 'pt-BR' } }
495
+ );
496
+ const d = await r.json();
497
+ resolve(d.address?.city || d.address?.town || d.address?.municipality || null);
498
+ } catch { resolve(null); }
499
+ },
500
+ () => resolve(null), // usuário negou permissão → ignora silenciosamente
501
+ { timeout: 5000 }
502
+ );
503
+ });
504
+
505
+ /** Lead — envio de formulário (CONVERSÃO PRINCIPAL) — captura cidade automaticamente */
506
+ export const trackLead = async (params = {}) => {
507
+ const eventId = genId(); // ID único — deduplicação browser ↔ servidor
508
+ const cidade = await getCidade(); // tenta capturar cidade
509
+ const base = { value: 0, currency: 'BRL', ...params, ...(cidade && { city: cidade }) };
510
+
511
+ // Meta: eventID no 3º argumento → deduplicação com CAPI
512
+ if (active('metaPixelId')) meta('track', 'Lead', base, { eventID: eventId });
513
+ if (active('tiktokPixelId')) {
514
+ // ttq.identify(): Advanced Matching — vincula eventos ao usuário (cross-session)
515
+ // Chamar ANTES do track para que o evento já carregue o match key
516
+ if ((base.email || base.phone) && has('ttq')) {
517
+ window.ttq.identify({
518
+ email: base.email?.toLowerCase().trim() || '',
519
+ phone_number: base.phone ? `+${String(base.phone).replace(/\D/g, '')}` : '',
520
+ external_id: _userId || '',
521
+ });
522
+ }
523
+ tiktok('Lead', { content_name: base.content_name || '', value: base.value, currency: base.currency });
524
+ }
525
+ if (active('pinterestTagId')) pinterest('lead', { lead_type: base.content_category || 'formulario', value: base.value, currency: base.currency });
526
+ if (active('redditPixelId')) reddit('Lead', { value: base.value, currency: base.currency });
527
+ if (isBrowser && window.uetq) window.uetq.push({ ea: 'submit_form', ev: base.value || 0, ec: base.currency || 'BRL' });
528
+
529
+ // event_id incluído para deduplicação browser ↔ servidor
530
+ ga4Event('generate_lead', { method: 'formulario', currency: base.currency, value: base.value, event_id: eventId, ...(cidade && { city: cidade }) });
531
+ gadsConversion(CONFIG.googleAdsConversions?.lead, base.value, base.currency, '', base.email, base.phone);
532
+ };
533
+
534
+ /** Contact — clique em WhatsApp / telefone — captura cidade e ctwa_clid automaticamente */
535
+ export const trackContact = async (method = 'whatsapp') => {
536
+ const eventId = genId();
537
+ const cidade = await getCidade();
538
+ const extra = cidade ? { city: cidade } : {};
539
+
540
+ // Meta: passa eventID para deduplicação com CAPI
541
+ if (active('metaPixelId')) has('fbq') && window.fbq('track', 'Contact', extra, { eventID: eventId });
542
+ if (active('tiktokPixelId')) tiktok('Contact', extra);
543
+ if (active('pinterestTagId')) pinterest('lead', { lead_type: method });
544
+ if (active('redditPixelId')) reddit('Lead', extra);
545
+ if (isBrowser && window.uetq) window.uetq.push({ ea: 'contact', el: method });
546
+
547
+ // event_id incluído para deduplicação Meta
548
+ ga4Event('generate_lead', { method, currency: 'BRL', value: 0, event_id: eventId, ...extra });
549
+ gadsConversion(CONFIG.googleAdsConversions?.whatsapp);
550
+
551
+ // Server-side: envia ctwa_clid se veio de anúncio Click-to-WhatsApp
552
+ sendServerEvent('Contact', eventId, {
553
+ city: cidade || '',
554
+ method,
555
+ // ctwaClid é incluído automaticamente via getCookies() dentro de sendServerEvent
556
+ });
557
+ };
558
+
559
+ /** Schedule — agendamento */
560
+ export const trackSchedule = () => {
561
+ if (active('metaPixelId')) meta('track', 'Schedule');
562
+ if (active('tiktokPixelId')) tiktok('Schedule');
563
+ if (active('pinterestTagId')) pinterest('lead', { lead_type: 'agendamento' });
564
+ if (active('redditPixelId')) reddit('Lead');
565
+ if (isBrowser && window.uetq) window.uetq.push({ ea: 'schedule' });
566
+
567
+ ga4Event('generate_lead', { method: 'agendamento', currency: 'BRL', value: 0 });
568
+ gadsConversion(CONFIG.googleAdsConversions?.schedule);
569
+ };
570
+
571
+ /** InitiateCheckout — clique em "Comprar" */
572
+ export const trackInitiateCheckout = (params = {}) => {
573
+ const { _ga4Items = [], ...p } = params;
574
+ const base = { currency: 'BRL', value: 0, ...p };
575
+
576
+ if (active('metaPixelId')) meta('track', 'InitiateCheckout', base);
577
+ if (active('tiktokPixelId')) tiktok('InitiateCheckout', { value: base.value, currency: base.currency, content_id: base.content_ids?.[0] });
578
+ if (active('pinterestTagId')) pinterest('checkout', { value: base.value, currency: base.currency, order_quantity: 1 });
579
+ if (active('redditPixelId')) reddit('AddToCart', { value: base.value, currency: base.currency });
580
+ if (isBrowser && window.uetq) window.uetq.push({ ea: 'initiate_checkout', ev: base.value, ec: base.currency });
581
+
582
+ ga4Ecommerce('begin_checkout', { currency: base.currency, value: base.value, items: _ga4Items });
583
+ };
584
+
585
+ /** AddToCart — adicionar ao carrinho */
586
+ export const trackAddToCart = (params = {}) => {
587
+ const { _ga4Items = [], ...p } = params;
588
+ const base = { currency: 'BRL', value: 0, ...p };
589
+ // Converte GA4 items → formato Meta/TikTok automaticamente (Universal Conversion)
590
+ const metaP = _ga4Items.length ? { ...base, ...ga4ItemsToMeta(_ga4Items) } : base;
591
+ const ttkP = _ga4Items.length ? ga4ItemsToTikTok(_ga4Items) : { value: base.value, currency: base.currency };
592
+
593
+ if (active('metaPixelId')) meta('track', 'AddToCart', metaP);
594
+ if (active('tiktokPixelId')) tiktok('AddToCart', { ...ttkP, currency: base.currency, content_id: metaP.content_ids?.[0] });
595
+ if (active('pinterestTagId')) pinterest('addtocart', { value: metaP.value, currency: base.currency, product_id: metaP.content_ids?.[0] });
596
+ if (active('redditPixelId')) reddit('AddToCart', { value: metaP.value, currency: base.currency });
597
+ if (isBrowser && window.uetq) window.uetq.push({ ea: 'add_to_cart', ev: metaP.value, ec: base.currency });
598
+
599
+ ga4Ecommerce('add_to_cart', { currency: base.currency, value: metaP.value, items: _ga4Items });
600
+ };
601
+
602
+ /** Purchase — compra concluída (value + currency obrigatórios) */
603
+ export const trackPurchase = (params = {}) => {
604
+ const { _ga4Items = [], ...p } = params;
605
+ if (!p.value || !p.currency) console.warn('[CDP Edge] trackPurchase requer value e currency');
606
+ const txId = p.order_id || `T_${Date.now()}`;
607
+ // Converte GA4 items → formato Meta/TikTok automaticamente (Universal Conversion)
608
+ const metaP = _ga4Items.length ? { ...p, ...ga4ItemsToMeta(_ga4Items) } : p;
609
+ const ttkP = _ga4Items.length ? ga4ItemsToTikTok(_ga4Items) : { value: p.value };
610
+
611
+ if (active('metaPixelId')) meta('track', 'Purchase', { currency: p.currency || 'BRL', ...metaP });
612
+ if (active('tiktokPixelId')) {
613
+ // identify() antes do track: Advanced Matching vincula compra ao usuário
614
+ if ((p.email || p.phone) && has('ttq')) {
615
+ window.ttq.identify({
616
+ email: p.email?.toLowerCase().trim() || '',
617
+ phone_number: p.phone ? `+${String(p.phone).replace(/\D/g, '')}` : '',
618
+ external_id: _userId || '',
619
+ });
620
+ }
621
+ tiktok('CompletePayment', { ...ttkP, currency: p.currency || 'BRL', order_id: txId });
622
+ }
623
+ if (active('pinterestTagId')) pinterest('checkout', { value: metaP.value, currency: p.currency || 'BRL', order_id: txId, order_quantity: metaP.num_items || 1 });
624
+ if (active('redditPixelId')) reddit('Purchase', { value: metaP.value, currency: p.currency || 'BRL' });
625
+ if (isBrowser && window.uetq) window.uetq.push({ ea: 'purchase', ev: p.value, ec: p.currency || 'BRL', gc: txId });
626
+
627
+ // event_id = txId incluído para deduplicação Meta
628
+ ga4Ecommerce('purchase', { transaction_id: txId, event_id: txId, currency: p.currency || 'BRL', value: p.value, items: _ga4Items });
629
+ gadsConversion(CONFIG.googleAdsConversions?.purchase, p.value, p.currency || 'BRL', txId, p.email, p.phone);
630
+ };
631
+
632
+ /** CompleteRegistration — cadastro concluído */
633
+ export const trackSignUp = (method = 'formulario') => {
634
+ if (active('metaPixelId')) meta('track', 'CompleteRegistration', { status: 'completed' });
635
+ if (active('tiktokPixelId')) tiktok('CompleteRegistration');
636
+ if (active('pinterestTagId')) pinterest('signup', { lead_type: method });
637
+ if (active('redditPixelId')) reddit('SignUp');
638
+ if (isBrowser && window.uetq) window.uetq.push({ ea: 'sign_up' });
639
+
640
+ ga4Event('sign_up', { method });
641
+ gadsConversion(CONFIG.googleAdsConversions?.signup);
642
+ };
643
+
644
+ // ============================================================
645
+ // EVENTOS COMPLETOS — E-COMMERCE E FUNIL AVANÇADO
646
+ // ============================================================
647
+
648
+ /** Search — busca no site */
649
+ export const trackSearch = (query = '') => {
650
+ if (active('metaPixelId')) meta('track', 'Search', { search_string: query });
651
+ if (active('tiktokPixelId')) tiktok('Search', { query });
652
+ if (active('redditPixelId')) reddit('Search');
653
+ if (isBrowser && window.uetq) window.uetq.push({ ea: 'search', el: query });
654
+ ga4Event('search', { search_term: query });
655
+ };
656
+
657
+ /** AddToWishlist — adicionar à lista de desejos / favoritos */
658
+ export const trackAddToWishlist = (params = {}) => {
659
+ const { _ga4Items = [], ...p } = params;
660
+ const base = { currency: 'BRL', value: 0, ...p };
661
+
662
+ if (active('metaPixelId')) meta('track', 'AddToWishlist', base);
663
+ if (active('tiktokPixelId')) tiktok('AddToWishlist', { content_id: base.content_ids?.[0], value: base.value, currency: base.currency });
664
+ if (active('redditPixelId')) reddit('AddToWishlist');
665
+ if (isBrowser && window.uetq) window.uetq.push({ ea: 'add_to_wishlist', ev: base.value, ec: base.currency });
666
+ ga4Ecommerce('add_to_wishlist', { currency: base.currency, value: base.value, items: _ga4Items });
667
+ };
668
+
669
+ /** RemoveFromCart — remover item do carrinho */
670
+ export const trackRemoveFromCart = (params = {}) => {
671
+ const { _ga4Items = [], ...p } = params;
672
+ const base = { currency: 'BRL', value: 0, ...p };
673
+
674
+ if (isBrowser && window.uetq) window.uetq.push({ ea: 'remove_from_cart', ev: base.value, ec: base.currency });
675
+ ga4Ecommerce('remove_from_cart', { currency: base.currency, value: base.value, items: _ga4Items });
676
+ // Meta e TikTok não têm RemoveFromCart padrão — usar trackCustom se necessário
677
+ };
678
+
679
+ /** ViewCart — visualizar o carrinho */
680
+ export const trackViewCart = (params = {}) => {
681
+ const { _ga4Items = [], ...p } = params;
682
+ const base = { currency: 'BRL', value: 0, ...p };
683
+
684
+ if (isBrowser && window.uetq) window.uetq.push({ ea: 'view_cart' });
685
+ ga4Ecommerce('view_cart', { currency: base.currency, value: base.value, items: _ga4Items });
686
+ };
687
+
688
+ /** ViewItemList — listagem de produtos (categoria, busca, vitrine) */
689
+ export const trackViewItemList = (params = {}) => {
690
+ // aceita item_list_id/item_list_name (GA4 spec) ou list_id/list_name (compat)
691
+ const {
692
+ _ga4Items = [], items = _ga4Items,
693
+ item_list_id = params.list_id || '',
694
+ item_list_name = params.list_name || '',
695
+ } = params;
696
+
697
+ if (isBrowser && window.uetq) window.uetq.push({ ea: 'view_item_list' });
698
+ ga4Ecommerce('view_item_list', { item_list_name, item_list_id, items });
699
+ };
700
+
701
+ /** SelectItem — clicou em produto na lista */
702
+ export const trackSelectItem = (params = {}) => {
703
+ const {
704
+ _ga4Items = [], items = _ga4Items,
705
+ item_list_id = params.list_id || '',
706
+ item_list_name = params.list_name || '',
707
+ } = params;
708
+
709
+ if (isBrowser && window.uetq) window.uetq.push({ ea: 'select_item' });
710
+ ga4Ecommerce('select_item', { item_list_name, item_list_id, items });
711
+ };
712
+
713
+ /** AddPaymentInfo — informou dados de pagamento */
714
+ export const trackAddPaymentInfo = (params = {}) => {
715
+ const { _ga4Items = [], payment_type = '', ...p } = params;
716
+ const base = { currency: 'BRL', value: 0, ...p };
717
+
718
+ if (active('metaPixelId')) meta('track', 'AddPaymentInfo', base);
719
+ if (active('tiktokPixelId')) tiktok('AddPaymentInfo', { value: base.value, currency: base.currency });
720
+ if (isBrowser && window.uetq) window.uetq.push({ ea: 'add_payment_info', ev: base.value, ec: base.currency });
721
+ ga4Ecommerce('add_payment_info', { currency: base.currency, value: base.value, payment_type, items: _ga4Items });
722
+ };
723
+
724
+ /** AddShippingInfo — informou dados de entrega */
725
+ export const trackAddShippingInfo = (params = {}) => {
726
+ const { _ga4Items = [], shipping_tier = '', ...p } = params;
727
+ const base = { currency: 'BRL', value: 0, ...p };
728
+
729
+ if (isBrowser && window.uetq) window.uetq.push({ ea: 'add_shipping_info' });
730
+ ga4Ecommerce('add_shipping_info', { currency: base.currency, value: base.value, shipping_tier, items: _ga4Items });
731
+ };
732
+
733
+ /** ViewPromotion — visualizou banner/promoção */
734
+ export const trackViewPromotion = (params = {}) => {
735
+ const { _ga4Items = [], promotion_id = '', promotion_name = '', creative_name = '', creative_slot = '' } = params;
736
+
737
+ if (isBrowser && window.uetq) window.uetq.push({ ea: 'view_promotion' });
738
+ ga4Ecommerce('view_promotion', { promotion_id, promotion_name, creative_name, creative_slot, items: _ga4Items });
739
+ };
740
+
741
+ /** SelectPromotion — clicou em banner/promoção */
742
+ export const trackSelectPromotion = (params = {}) => {
743
+ const { _ga4Items = [], promotion_id = '', promotion_name = '', creative_name = '', creative_slot = '' } = params;
744
+
745
+ if (isBrowser && window.uetq) window.uetq.push({ ea: 'select_promotion' });
746
+ ga4Ecommerce('select_promotion', { promotion_id, promotion_name, creative_name, creative_slot, items: _ga4Items });
747
+ };
748
+
749
+ /** Subscribe — assinou newsletter ou serviço recorrente */
750
+ export const trackSubscribe = (params = {}) => {
751
+ const base = { value: 0, currency: 'BRL', predicted_ltv: 0, ...params };
752
+
753
+ if (active('metaPixelId')) meta('track', 'Subscribe', base);
754
+ if (active('tiktokPixelId')) tiktok('Subscribe', { value: base.value, currency: base.currency });
755
+ if (active('redditPixelId')) reddit('Lead');
756
+ if (isBrowser && window.uetq) window.uetq.push({ ea: 'subscribe', ev: base.value, ec: base.currency });
757
+ ga4Event('generate_lead', { method: 'subscribe', currency: base.currency, value: base.value });
758
+ gadsConversion(CONFIG.googleAdsConversions?.signup);
759
+ };
760
+
761
+ /** StartTrial — iniciou período de teste gratuito
762
+ * TikTok: usa evento padrão 'StartTrial' (não 'Subscribe') */
763
+ export const trackStartTrial = (params = {}) => {
764
+ const base = { value: 0, currency: 'BRL', predicted_ltv: 0, ...params };
765
+
766
+ if (active('metaPixelId')) meta('track', 'StartTrial', base);
767
+ if (active('tiktokPixelId')) tiktok('StartTrial', { value: base.value, currency: base.currency });
768
+ if (isBrowser && window.uetq) window.uetq.push({ ea: 'start_trial' });
769
+ ga4Event('generate_lead', { method: 'trial', currency: base.currency, value: base.value });
770
+ };
771
+
772
+ /** FindLocation — buscou localização física da empresa */
773
+ export const trackFindLocation = () => {
774
+ if (active('metaPixelId')) meta('track', 'FindLocation');
775
+ if (active('tiktokPixelId')) tiktok('FindLocation');
776
+ if (isBrowser && window.uetq) window.uetq.push({ ea: 'find_location' });
777
+ ga4Event('find_location');
778
+ };
779
+
780
+ /** Donate — fez uma doação */
781
+ export const trackDonate = (params = {}) => {
782
+ const base = { value: 0, currency: 'BRL', ...params };
783
+
784
+ if (active('metaPixelId')) meta('track', 'Donate', base);
785
+ if (active('redditPixelId')) reddit('Purchase', { value: base.value, currency: base.currency });
786
+ if (isBrowser && window.uetq) window.uetq.push({ ea: 'donate', ev: base.value, ec: base.currency });
787
+ ga4Event('generate_lead', { method: 'donate', currency: base.currency, value: base.value });
788
+ };
789
+
790
+ /** SubmitApplication — enviou candidatura, inscrição ou formulário longo */
791
+ export const trackSubmitApplication = (params = {}) => {
792
+ const base = { value: 0, currency: 'BRL', ...params };
793
+
794
+ if (active('metaPixelId')) meta('track', 'SubmitApplication', base);
795
+ if (active('tiktokPixelId')) tiktok('SubmitForm', { value: base.value, currency: base.currency });
796
+ if (active('redditPixelId')) reddit('Lead');
797
+ if (isBrowser && window.uetq) window.uetq.push({ ea: 'submit_application' });
798
+ ga4Event('generate_lead', { method: 'application', currency: base.currency, value: base.value });
799
+ gadsConversion(CONFIG.googleAdsConversions?.lead);
800
+ };
801
+
802
+ /** Download — download de arquivo (PDF, app, material) */
803
+ export const trackDownload = (params = {}) => {
804
+ const base = { file_name: '', file_extension: '', link_url: '', ...params };
805
+
806
+ if (active('tiktokPixelId')) tiktok('Download', { content_name: base.file_name });
807
+ if (isBrowser && window.uetq) window.uetq.push({ ea: 'file_download', el: base.file_name });
808
+ ga4Event('file_download', { file_name: base.file_name, file_extension: base.file_extension, link_url: base.link_url });
809
+ };
810
+
811
+ /** CustomizeProduct — personalizou produto (cor, tamanho, gravação, etc.) */
812
+ export const trackCustomizeProduct = (params = {}) => {
813
+ if (active('metaPixelId')) meta('track', 'CustomizeProduct', params);
814
+ if (active('tiktokPixelId')) tiktok('CustomizeProduct', { content_name: params.content_name || '' });
815
+ ga4Event('customize_product', params);
816
+ };
817
+
818
+ // ============================================================
819
+ // EVENTO GENÉRICO CUSTOMIZADO
820
+ // ============================================================
821
+
822
+ /** Evento customizado — qualquer nome de evento não coberto acima */
823
+ export const trackCustom = (metaEventName, ga4EventName, params = {}) => {
824
+ if (active('metaPixelId')) window.fbq?.('trackCustom', metaEventName, params);
825
+ if (active('tiktokPixelId')) window.ttq?.track(metaEventName, params);
826
+ ga4Event(ga4EventName, params);
827
+ };
828
+
829
+ /** Scroll depth tracker — utilitário para medir profundidade de scroll */
830
+ export const scrollTracker = (threshold = 50, callback) => {
831
+ let fired = false;
832
+ return () => {
833
+ if (fired) return;
834
+ const pct = (window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100;
835
+ if (pct >= threshold) { fired = true; callback(); }
836
+ };
837
+ };
838
+ ```
839
+
840
+ ---
841
+
842
+ ## PASSO 4 — Eventos por página e tipo de negócio
843
+
844
+ ### Regra de ouro por plataforma (NUNCA violar)
845
+
846
+ | Plataforma | Regra crítica |
847
+ |---|---|
848
+ | **Meta** | `ViewContent` sempre no load da página — NUNCA em scroll. `Lead`/`Purchase` apenas quando ação é confirmada (onSubmit, não onClick) |
849
+ | **GA4** | Sempre `{ecommerce: null}` antes de qualquer evento de e-commerce |
850
+ | **TikTok** | `CompletePayment` = Purchase. `SubmitForm` = Lead. Nunca usar eventos Meta como nomes |
851
+ | **Google Ads** | Conversão só no evento real de conversão (Lead confirmado, Purchase confirmado). Não usar em PageView |
852
+ | **Geral** | `trackPageView()` em TODA rota via `useEffect(()=>{}, [])`. Em SPA nunca confiar no carregamento automático |
853
+
854
+ ---
855
+
856
+ ### 📄 TIPO 1 — Landing Page / Página de Captura (Infoproduto, Serviço, Imóvel)
857
+
858
+ **Funil:** Visita → Interesse → Lead (formulário ou WhatsApp)
859
+
860
+ ```jsx
861
+ // Eventos no LOAD (automáticos)
862
+ // ✅ PageView → todas as plataformas
863
+ // ✅ ViewContent → todas as plataformas (sinaliza o que o usuário está vendo)
864
+
865
+ // Eventos na INTERAÇÃO (nunca automáticos)
866
+ // ✅ Lead → no onSubmit do formulário (NÃO no onClick do botão)
867
+ // ✅ Contact → no clique no botão de WhatsApp/telefone
868
+ // ✅ Schedule → no clique em "Agendar"
869
+ // ✅ trackCustom('FormStart') → no onFocus do primeiro campo (micro-conversão)
870
+ // ❌ NUNCA: ViewContent no scroll, Lead no onClick, PageView duplicado
871
+
872
+ import { useEffect } from 'react';
873
+ import {
874
+ trackPageView, trackViewContent, trackLead,
875
+ trackContact, trackSchedule, trackCustom, scrollTracker,
876
+ readField, normalizePhone, splitName, isValidEmail, normalizeCity, // utilitários de formulário
877
+ } from '../tracking/tracking';
878
+
879
+ // ── Dados do conteúdo da página ────────────────────────────
880
+ const CONTEUDO = {
881
+ content_ids: ['ID_PRODUTO'],
882
+ content_type: 'product', // 'home_listing' para imóveis
883
+ content_name: 'Nome do Produto / Serviço',
884
+ value: 0, // 0 se não tiver preço fixo; preço real se tiver
885
+ currency: 'BRL',
886
+ _ga4Items: [{ item_id: 'ID_PRODUTO', item_name: 'Nome do Produto', price: 0, quantity: 1 }],
887
+ };
888
+
889
+ export function LandingPage() {
890
+
891
+ // ── LOAD: PageView + ViewContent (obrigatórios em toda landing page) ──
892
+ useEffect(() => {
893
+ trackPageView();
894
+ trackViewContent(CONTEUDO);
895
+ }, []);
896
+
897
+ // ── SCROLL: micro-conversão de interesse (opcional, não substitui ViewContent) ──
898
+ useEffect(() => {
899
+ return scrollTracker([75], (pct) =>
900
+ trackCustom('ScrollEngagement', { percent_scrolled: pct }));
901
+ }, []);
902
+
903
+ // ── INTERAÇÕES ──────────────────────────────────────────
904
+ const onWhatsApp = async () => {
905
+ await trackContact('whatsapp'); // Meta: Contact | GA4: generate_lead | TikTok: Contact
906
+ window.open('https://wa.me/55SEUNUMERO', '_blank');
907
+ };
908
+
909
+ const onFormFocus = () =>
910
+ trackCustom('FormStart', { form_name: 'captacao_lead' }); // sinaliza interesse em preencher
911
+
912
+ const onSubmit = async (e) => {
913
+ e.preventDefault();
914
+
915
+ // ── Leitura e normalização dos campos ──────────────────────────────
916
+ // readField() busca por id="X" ou name="X" — adaptar ao HTML real do site
917
+ const emailRaw = readField('email'); // id="email" ou name="email"
918
+ const nomeRaw = readField('nome'); // id="nome" ou name="nome"
919
+ const telRaw = readField('telefone'); // id="telefone" ou name="telefone"
920
+
921
+ // Validar email antes de disparar
922
+ if (!isValidEmail(emailRaw)) return; // ← não dispara se email inválido
923
+
924
+ // Separar nome em firstName / lastName (alinha com campos fn/ln do Meta CAPI)
925
+ const { firstName, lastName } = splitName(nomeRaw);
926
+
927
+ // Normalizar telefone para formato Meta: somente dígitos + DDI
928
+ const phone = normalizePhone(telRaw); // ex: '5511999998888'
929
+
930
+ await trackLead({ // Meta: Lead | GA4: generate_lead | TikTok: SubmitForm
931
+ email: emailRaw, // → SHA256 no servidor (Meta Advanced Matching / Google Enhanced Conversions)
932
+ phone, // → já normalizado; SHA256 no servidor
933
+ firstName, // → SHA256 no servidor (campo fn no Meta CAPI)
934
+ lastName, // → SHA256 no servidor (campo ln no Meta CAPI)
935
+ content_name: CONTEUDO.content_name,
936
+ });
937
+ // redirecionar ou mostrar mensagem de sucesso
938
+ };
939
+
940
+ const onSchedule = () =>
941
+ trackSchedule(); // Meta: Schedule | GA4: generate_lead | TikTok: Schedule
942
+
943
+ return (
944
+ <div>
945
+ <button onClick={onWhatsApp}>Falar no WhatsApp</button>
946
+ {/* ── Campos com id E name ── */}
947
+ <form onSubmit={onSubmit}>
948
+ <input id="nome" name="nome" placeholder="Seu nome completo" required />
949
+ <input id="email" name="email" type="email" placeholder="Seu e-mail" required onFocus={onFormFocus} />
950
+ <input id="telefone" name="telefone" type="tel" placeholder="Seu telefone" />
951
+ <button type="submit">Quero saber mais</button>
952
+ </form>
953
+ <button onClick={onSchedule}>Agendar visita</button>
954
+ </div>
955
+ );
956
+ }
957
+
958
+ ```
959
+
960
+ ---
961
+
962
+ ### 🏠 TIPO 2 — Página de Imóvel (Real Estate)
963
+
964
+ **Diferença:** `content_type: 'home_listing'` obrigatório para Meta Dynamic Ads imobiliário.
965
+
966
+ ```jsx
967
+ const IMOVEL = {
968
+ content_ids: ['APT-SBC-001'],
969
+ content_type: 'home_listing', // ← obrigatório para catálogo Meta imobiliário
970
+ content_name: 'Apartamento 3 Quartos - São Bernardo',
971
+ city: 'São Bernardo do Campo',
972
+ region: 'SP',
973
+ country: 'BR',
974
+ neighborhood: 'Bairro Nobre',
975
+ value: 850000,
976
+ currency: 'BRL',
977
+ property_size: 260,
978
+ num_baths: 2,
979
+ num_beds: 3,
980
+ parking_spaces: 3,
981
+ _ga4Items: [{
982
+ item_id: 'APT-SBC-001', item_name: 'Apartamento 3 Quartos',
983
+ item_category: 'Residencial', item_category2: 'São Bernardo do Campo',
984
+ price: 850000, quantity: 1
985
+ }],
986
+ };
987
+
988
+ export function PaginaImovel() {
989
+ useEffect(() => {
990
+ trackPageView();
991
+ trackViewContent(IMOVEL); // content_type: 'home_listing' → Dynamic Ads imobiliário
992
+ }, []);
993
+
994
+ // Scroll 75%: sinaliza leitura profunda (interesse qualificado)
995
+ useEffect(() => {
996
+ return scrollTracker([75], (pct) =>
997
+ trackCustom('ImovelLido', { content_name: IMOVEL.content_name, scroll_depth: pct }));
998
+ }, []);
999
+
1000
+ const onWhatsApp = async () => { await trackContact('whatsapp'); window.open('https://wa.me/55...', '_blank'); };
1001
+ const onSubmit = async (e, d) => { e.preventDefault(); await trackLead({ ...d, content_name: IMOVEL.content_name }); };
1002
+ const onSchedule = () => trackSchedule();
1003
+ }
1004
+ ```
1005
+
1006
+ ---
1007
+
1008
+ ### 🛒 TIPO 3 — E-commerce: Página de Listagem de Produtos
1009
+
1010
+ **Funil:** Listagem → Produto → Carrinho → Checkout → Confirmação
1011
+
1012
+ ```jsx
1013
+ // ── Listagem (Category / Collection page) ──────────────────
1014
+ // ✅ PageView no load
1015
+ // ✅ ViewItemList no load (GA4 e Bing — lista de produtos visíveis)
1016
+ // ✅ SelectItem no clique em qualquer produto
1017
+ // ✅ ViewPromotion se houver banner de promoção visível no load
1018
+ // ✅ SelectPromotion no clique no banner
1019
+
1020
+ import {
1021
+ trackPageView, trackViewItemList, trackSelectItem,
1022
+ trackViewPromotion, trackSelectPromotion, trackSearch
1023
+ } from '../tracking/tracking';
1024
+
1025
+ export function ListagemProdutos({ produtos, categoria }) {
1026
+ useEffect(() => {
1027
+ trackPageView();
1028
+ trackViewItemList({
1029
+ item_list_id: categoria.id,
1030
+ item_list_name: categoria.nome,
1031
+ items: produtos.map((p, i) => ({
1032
+ item_id: p.id, item_name: p.nome,
1033
+ item_category: categoria.nome, price: p.preco,
1034
+ index: i, quantity: 1,
1035
+ })),
1036
+ });
1037
+ // Se tiver banner de promoção visível na página:
1038
+ trackViewPromotion({ promotion_id: 'banner_topo', promotion_name: 'Oferta da Semana' });
1039
+ }, []);
1040
+
1041
+ const onClicarProduto = (produto, index) => {
1042
+ trackSelectItem({
1043
+ item_list_id: categoria.id,
1044
+ item_list_name: categoria.nome,
1045
+ items: [{ item_id: produto.id, item_name: produto.nome, price: produto.preco, index }],
1046
+ });
1047
+ // navegar para página do produto
1048
+ };
1049
+
1050
+ const onClicarBanner = () =>
1051
+ trackSelectPromotion({ promotion_id: 'banner_topo', promotion_name: 'Oferta da Semana' });
1052
+
1053
+ const onPesquisar = (termo) =>
1054
+ trackSearch(termo); // Meta: Search | GA4: search | TikTok: Search
1055
+
1056
+ return (/* jsx */);
1057
+ }
1058
+ ```
1059
+
1060
+ ---
1061
+
1062
+ ### 🛍️ TIPO 4 — E-commerce: Página de Produto
1063
+
1064
+ ```jsx
1065
+ // ✅ PageView + ViewContent no load (obrigatório)
1066
+ // ✅ AddToCart no clique em "Adicionar ao carrinho"
1067
+ // ✅ AddToWishlist no clique em "Favoritar"
1068
+ // ✅ CustomizeProduct se houver seleção de variante (cor, tamanho)
1069
+ // ❌ NUNCA: AddToCart automático no load
1070
+
1071
+ import {
1072
+ trackPageView, trackViewContent, trackAddToCart,
1073
+ trackAddToWishlist, trackCustomizeProduct
1074
+ } from '../tracking/tracking';
1075
+
1076
+ export function PaginaProduto({ produto }) {
1077
+ useEffect(() => {
1078
+ trackPageView();
1079
+ trackViewContent({
1080
+ content_ids: [produto.id],
1081
+ content_type: 'product',
1082
+ content_name: produto.nome,
1083
+ value: produto.preco,
1084
+ currency: 'BRL',
1085
+ _ga4Items: [{ item_id: produto.id, item_name: produto.nome, price: produto.preco, quantity: 1 }],
1086
+ });
1087
+ }, []);
1088
+
1089
+ const onAddToCart = (quantidade = 1) => {
1090
+ trackAddToCart({
1091
+ content_ids: [produto.id],
1092
+ content_name: produto.nome,
1093
+ value: produto.preco * quantidade,
1094
+ currency: 'BRL',
1095
+ num_items: quantidade,
1096
+ _ga4Items: [{ item_id: produto.id, item_name: produto.nome, price: produto.preco, quantity: quantidade }],
1097
+ });
1098
+ };
1099
+
1100
+ const onFavoritar = () =>
1101
+ trackAddToWishlist({
1102
+ content_ids: [produto.id],
1103
+ content_name: produto.nome,
1104
+ value: produto.preco,
1105
+ currency: 'BRL',
1106
+ });
1107
+
1108
+ const onSelecionarVariante = (variante) =>
1109
+ trackCustomizeProduct({ content_name: produto.nome, variante });
1110
+
1111
+ return (/* jsx */);
1112
+ }
1113
+ ```
1114
+
1115
+ ---
1116
+
1117
+ ### 🛒 TIPO 5 — E-commerce: Página do Carrinho
1118
+
1119
+ ```jsx
1120
+ // ✅ PageView + ViewCart no load
1121
+ // ✅ RemoveFromCart ao remover item
1122
+ // ✅ InitiateCheckout no clique em "Finalizar compra"
1123
+ // ❌ NUNCA: Purchase aqui — apenas na confirmação
1124
+
1125
+ import {
1126
+ trackPageView, trackViewCart, trackRemoveFromCart, trackInitiateCheckout
1127
+ } from '../tracking/tracking';
1128
+
1129
+ export function Carrinho({ itens, total }) {
1130
+ useEffect(() => {
1131
+ trackPageView();
1132
+ trackViewCart({
1133
+ value: total,
1134
+ currency: 'BRL',
1135
+ _ga4Items: itens.map(i => ({ item_id: i.id, item_name: i.nome, price: i.preco, quantity: i.qtd })),
1136
+ });
1137
+ }, []);
1138
+
1139
+ const onRemoverItem = (item) =>
1140
+ trackRemoveFromCart({
1141
+ content_name: item.nome,
1142
+ value: item.preco * item.qtd,
1143
+ currency: 'BRL',
1144
+ _ga4Items: [{ item_id: item.id, item_name: item.nome, price: item.preco, quantity: item.qtd }],
1145
+ });
1146
+
1147
+ const onFinalizarCompra = () => {
1148
+ trackInitiateCheckout({
1149
+ value: total,
1150
+ currency: 'BRL',
1151
+ num_items: itens.length,
1152
+ content_ids: itens.map(i => i.id),
1153
+ _ga4Items: itens.map(i => ({ item_id: i.id, item_name: i.nome, price: i.preco, quantity: i.qtd })),
1154
+ });
1155
+ // navegar para checkout
1156
+ };
1157
+
1158
+ return (/* jsx */);
1159
+ }
1160
+ ```
1161
+
1162
+ ---
1163
+
1164
+ ### 💳 TIPO 6 — E-commerce: Página de Checkout
1165
+
1166
+ ```jsx
1167
+ // ✅ PageView no load
1168
+ // ✅ AddShippingInfo ao confirmar endereço de entrega
1169
+ // ✅ AddPaymentInfo ao informar dados de pagamento
1170
+ // ❌ NUNCA: Purchase aqui — apenas na confirmação do pedido
1171
+
1172
+ import {
1173
+ trackPageView, trackAddShippingInfo, trackAddPaymentInfo
1174
+ } from '../tracking/tracking';
1175
+
1176
+ export function Checkout({ itens, total }) {
1177
+ useEffect(() => { trackPageView(); }, []);
1178
+
1179
+ const onConfirmarEndereco = () =>
1180
+ trackAddShippingInfo({
1181
+ value: total,
1182
+ currency: 'BRL',
1183
+ _ga4Items: itens.map(i => ({ item_id: i.id, item_name: i.nome, price: i.preco, quantity: i.qtd })),
1184
+ });
1185
+
1186
+ const onInformarPagamento = (metodoPagamento) =>
1187
+ trackAddPaymentInfo({
1188
+ value: total,
1189
+ currency: 'BRL',
1190
+ payment_type: metodoPagamento, // 'credit_card', 'pix', 'boleto'
1191
+ _ga4Items: itens.map(i => ({ item_id: i.id, item_name: i.nome, price: i.preco, quantity: i.qtd })),
1192
+ });
1193
+
1194
+ return (/* jsx */);
1195
+ }
1196
+ ```
1197
+
1198
+ ---
1199
+
1200
+ ### ✅ TIPO 7 — E-commerce: Página de Confirmação / Obrigado (Compra)
1201
+
1202
+ ```jsx
1203
+ // ✅ PageView + Purchase no load — UMA VEZ (usar order_id único para deduplicação)
1204
+ // ✅ Purchase com value, currency e order_id obrigatórios
1205
+ // ❌ NUNCA: Purchase em qualquer outra página além desta
1206
+ // ❌ NUNCA: sem order_id (causa duplicação nos relatórios)
1207
+
1208
+ import { useEffect, useRef } from 'react';
1209
+ import { trackPageView, trackPurchase } from '../tracking/tracking';
1210
+
1211
+ export function PaginaObrigado({ pedido }) {
1212
+ const fired = useRef(false); // proteção contra double-fire no StrictMode
1213
+
1214
+ useEffect(() => {
1215
+ if (fired.current) return;
1216
+ fired.current = true;
1217
+
1218
+ trackPageView();
1219
+ trackPurchase({
1220
+ order_id: pedido.id, // OBRIGATÓRIO — evita duplicatas browser + servidor
1221
+ value: pedido.total, // OBRIGATÓRIO
1222
+ currency: 'BRL', // OBRIGATÓRIO
1223
+ content_ids: pedido.itens.map(i => i.id),
1224
+ email: pedido.email, // Enhanced Matching Meta + Google Enhanced Conversions
1225
+ phone: pedido.telefone,
1226
+ firstName: pedido.nome,
1227
+ _ga4Items: pedido.itens.map(i => ({
1228
+ item_id: i.id, item_name: i.nome, price: i.preco, quantity: i.qtd
1229
+ })),
1230
+ });
1231
+ }, []);
1232
+
1233
+ return (/* jsx */);
1234
+ }
1235
+ ```
1236
+
1237
+ ---
1238
+
1239
+ ### 🎓 TIPO 8 — Infoproduto / SaaS: Página de Obrigado (Lead)
1240
+
1241
+ ```jsx
1242
+ // Quando o lead é capturado em outra página e confirmado aqui
1243
+ // ✅ PageView + Lead no load (se não foi disparado no formulário anterior)
1244
+ // ✅ SignUp se for cadastro de conta
1245
+
1246
+ import { useEffect, useRef } from 'react';
1247
+ import { trackPageView, trackLead, trackSignUp } from '../tracking/tracking';
1248
+
1249
+ export function ObrigadoLead({ dados }) {
1250
+ const fired = useRef(false);
1251
+
1252
+ useEffect(() => {
1253
+ if (fired.current) return;
1254
+ fired.current = true;
1255
+
1256
+ trackPageView();
1257
+ // Disparar Lead AQUI apenas se não foi disparado no formulário anterior
1258
+ // Se o formulário já chamou trackLead(), NÃO chamar novamente aqui
1259
+ trackLead({
1260
+ email: dados.email,
1261
+ phone: dados.phone,
1262
+ firstName: dados.nome,
1263
+ content_name: 'Lead Capturado',
1264
+ value: 0,
1265
+ currency: 'BRL',
1266
+ });
1267
+ }, []);
1268
+
1269
+ return (/* jsx */);
1270
+ }
1271
+ ```
1272
+
1273
+ ---
1274
+
1275
+ ### 📧 TIPO 9 — Página de Assinatura / Newsletter / SaaS Trial
1276
+
1277
+ ```jsx
1278
+ // ✅ PageView + ViewContent no load
1279
+ // ✅ Subscribe no onSubmit do formulário de assinatura
1280
+ // ✅ StartTrial no início de período de teste
1281
+ // ✅ SignUp na criação de conta
1282
+
1283
+ import {
1284
+ trackPageView, trackViewContent, trackSubscribe, trackStartTrial, trackSignUp
1285
+ } from '../tracking/tracking';
1286
+
1287
+ export function PaginaAssinatura({ plano }) {
1288
+ useEffect(() => {
1289
+ trackPageView();
1290
+ trackViewContent({ content_name: plano.nome, value: plano.preco, currency: 'BRL' });
1291
+ }, []);
1292
+
1293
+ const onAssinar = async (e, dados) => {
1294
+ e.preventDefault();
1295
+ await trackSubscribe({ value: plano.preco, currency: 'BRL', email: dados.email });
1296
+ };
1297
+
1298
+ const onIniciarTrial = async (e, dados) => {
1299
+ e.preventDefault();
1300
+ await trackStartTrial({ value: 0, currency: 'BRL', email: dados.email });
1301
+ };
1302
+
1303
+ const onCriarConta = (metodo = 'email') => trackSignUp(metodo);
1304
+
1305
+ return (/* jsx */);
1306
+ }
1307
+ ```
1308
+
1309
+ ---
1310
+
1311
+ ### 📰 TIPO 10 — Blog / Conteúdo / Artigo
1312
+
1313
+ ```jsx
1314
+ // ✅ PageView no load
1315
+ // ✅ ViewContent no load (o artigo é o conteúdo)
1316
+ // ✅ ScrollTracker para medir engajamento com o conteúdo
1317
+ // ✅ Download se houver material para baixar
1318
+ // ✅ Lead se houver formulário de newsletter inline
1319
+
1320
+ import {
1321
+ trackPageView, trackViewContent, trackDownload, trackLead, scrollTracker
1322
+ } from '../tracking/tracking';
1323
+
1324
+ export function PaginaArtigo({ artigo }) {
1325
+ useEffect(() => {
1326
+ trackPageView();
1327
+ trackViewContent({
1328
+ content_ids: [artigo.slug],
1329
+ content_type: 'article',
1330
+ content_name: artigo.titulo,
1331
+ });
1332
+ }, []);
1333
+
1334
+ useEffect(() => {
1335
+ // Medir profundidade de leitura — útil para remarketing de leitores engajados
1336
+ return scrollTracker([25, 50, 75, 90], (pct) =>
1337
+ trackCustom('LeituraArtigo', { artigo: artigo.slug, scroll_depth: pct }));
1338
+ }, []);
1339
+
1340
+ const onDownload = (arquivo) =>
1341
+ trackDownload({ content_name: arquivo.nome, content_ids: [arquivo.id] });
1342
+
1343
+ const onNewsletterSubmit = async (e, email) => {
1344
+ e.preventDefault();
1345
+ await trackLead({ email, content_name: 'Newsletter', value: 0, currency: 'BRL' });
1346
+ };
1347
+
1348
+ return (/* jsx */);
1349
+ }
1350
+ ```
1351
+
1352
+ ---
1353
+
1354
+ ### 📍 TIPO 11 — Página de Localização / Franquia / Loja Física
1355
+
1356
+ ```jsx
1357
+ // ✅ PageView + ViewContent no load
1358
+ // ✅ FindLocation no clique em "Ver no mapa" ou "Como chegar"
1359
+ // ✅ Contact no clique em WhatsApp/telefone da unidade
1360
+
1361
+ import {
1362
+ trackPageView, trackViewContent, trackFindLocation, trackContact
1363
+ } from '../tracking/tracking';
1364
+
1365
+ export function PaginaUnidade({ unidade }) {
1366
+ useEffect(() => {
1367
+ trackPageView();
1368
+ trackViewContent({ content_name: unidade.nome, content_type: 'local' });
1369
+ }, []);
1370
+
1371
+ const onVerMapa = () => trackFindLocation();
1372
+ const onWhatsApp = async () => { await trackContact('whatsapp'); window.open(unidade.whatsapp); };
1373
+
1374
+ return (/* jsx */);
1375
+ }
1376
+ ```
1377
+
1378
+ ---
1379
+
1380
+ ### 🔁 SEQUÊNCIA CORRETA DO FUNIL DE E-COMMERCE (visão geral)
1381
+
1382
+ ```
1383
+ Listagem → PageView + ViewItemList
1384
+ ↓ clique
1385
+ Produto → PageView + ViewContent
1386
+ ↓ ação
1387
+ Carrinho → PageView + ViewCart + AddToCart (vem do clique anterior)
1388
+ ↓ ação
1389
+ Checkout → PageView + InitiateCheckout + AddShippingInfo + AddPaymentInfo
1390
+ ↓ confirmação
1391
+ Obrigado → PageView + Purchase (com order_id único)
1392
+ ```
1393
+
1394
+ ### 🔁 SEQUÊNCIA CORRETA DO FUNIL DE LEAD (visão geral)
1395
+
1396
+ ```
1397
+ Landing Page → PageView + ViewContent
1398
+ ↓ interação
1399
+ scrollTracker (micro-conversão de interesse)
1400
+ onFocus formulário → trackCustom('FormStart')
1401
+ ↓ submit
1402
+ onSubmit → trackLead() OU trackContact()
1403
+ ↓ redirect
1404
+ Obrigado → PageView (+ Lead apenas se não foi disparado no formulário)
1405
+ ```
1406
+
1407
+ ---
1408
+
1409
+ ## PASSO 5 — Mapeamento de eventos por plataforma
1410
+
1411
+ ### 📄 Eventos de Navegação
1412
+
1413
+ | Função CDP Edge | Meta | GA4 | TikTok | Pinterest | Bing | Reddit |
1414
+ |---|---|---|---|---|---|---|
1415
+ | `trackPageView()` | PageView | page_view | PageView | pagevisit | pageLoad | PageVisit |
1416
+ | `trackViewContent(params)` | ViewContent | view_item | ViewContent | viewcategory | view_content | ViewContent |
1417
+ | `trackSearch(query)` | Search | search | Search | — | search | Search |
1418
+ | `trackFindLocation()` | FindLocation | find_location | FindLocation | — | find_location | — |
1419
+
1420
+ ### 🛒 Eventos de E-commerce
1421
+
1422
+ | Função CDP Edge | Meta | GA4 | TikTok | Pinterest | Bing | Reddit |
1423
+ |---|---|---|---|---|---|---|
1424
+ | `trackAddToCart(params)` | AddToCart | add_to_cart | AddToCart | addtocart | add_to_cart | AddToCart |
1425
+ | `trackAddToWishlist(params)` | AddToWishlist | add_to_wishlist | AddToWishlist | — | add_to_wishlist | — |
1426
+ | `trackRemoveFromCart(params)` | — | remove_from_cart | — | — | remove_from_cart | — |
1427
+ | `trackViewCart(params)` | — | view_cart | — | — | view_cart | — |
1428
+ | `trackViewItemList(params)` | — | view_item_list | — | — | view_item_list | — |
1429
+ | `trackSelectItem(params)` | — | select_item | — | — | select_item | — |
1430
+ | `trackInitiateCheckout(params)` | InitiateCheckout | begin_checkout | InitiateCheckout | checkout | initiate_checkout | AddToCart |
1431
+ | `trackAddPaymentInfo(params)` | AddPaymentInfo | add_payment_info | AddPaymentInfo | — | add_payment_info | — |
1432
+ | `trackAddShippingInfo(params)` | — | add_shipping_info | — | — | add_shipping_info | — |
1433
+ | `trackPurchase(params)` | Purchase | purchase | CompletePayment *(+ ttq.identify())* | checkout | purchase | Purchase |
1434
+ | `trackCustomizeProduct(params)` | CustomizeProduct | customize_product | CustomizeProduct | — | — | — |
1435
+
1436
+ ### 🏷️ Eventos de Promoção
1437
+
1438
+ | Função CDP Edge | Meta | GA4 | TikTok | Pinterest | Bing | Reddit |
1439
+ |---|---|---|---|---|---|---|
1440
+ | `trackViewPromotion(params)` | — | view_promotion | — | — | view_promotion | — |
1441
+ | `trackSelectPromotion(params)` | — | select_promotion | — | — | select_promotion | — |
1442
+
1443
+ ### 🎯 Eventos de Conversão / Lead
1444
+
1445
+ | Função CDP Edge | Meta | GA4 | TikTok | Pinterest | Bing | Reddit |
1446
+ |---|---|---|---|---|---|---|
1447
+ | `trackLead(params)` | Lead | generate_lead | Lead *(+ ttq.identify())* | lead | submit_form | Lead |
1448
+ | `trackContact(params)` | Contact | generate_lead | Contact | lead | contact | Lead |
1449
+ | `trackSchedule(params)` | Schedule | generate_lead | Schedule | lead | schedule | Lead |
1450
+ | `trackSignUp(params)` | CompleteRegistration | sign_up | CompleteRegistration | signup | sign_up | SignUp |
1451
+ | `trackSubscribe(params)` | Subscribe | — | Subscribe | — | subscribe | Lead |
1452
+ | `trackStartTrial(params)` | StartTrial | — | StartTrial | — | start_trial | — |
1453
+ | `trackSubmitApplication(params)` | SubmitApplication | — | SubmitForm | — | submit_application | Lead |
1454
+ | `trackDonate(params)` | Donate | — | — | — | donate | Purchase |
1455
+ | `trackDownload(params)` | — | file_download | Download | — | download | — |
1456
+
1457
+ ### ⚙️ Utilitários
1458
+
1459
+ | Função CDP Edge | Descrição |
1460
+ |---|---|
1461
+ | `trackCustom(name, params)` | Evento personalizado em todas as plataformas ativas |
1462
+ | `scrollTracker(thresholds, cb)` | Dispara callback em % de scroll (ex: 25%, 50%, 75%, 90%) |
1463
+ | `getCityFromGeolocation()` | Captura cidade do usuário via GPS + OpenStreetMap (gratuito) |
1464
+ | `sendServerEvent(name, params)` | Envia evento direto ao servidor (fire-and-forget, não bloqueia) |
1465
+ | `getUTMs()` | Retorna `{ utm_source, utm_medium, utm_campaign, utm_content, utm_term }` da URL — para rastreamento interno e CRM (independente da atribuição das plataformas) |
1466
+ | `getUserId()` | Retorna User ID first-party persistente (cookie 365 dias) — identifica o mesmo usuário entre sessões sem depender de cookies de terceiros |
1467
+ | `passCheckoutParams(options?)` | Injeta UTMs + User ID nos links de checkout externo (Hotmart, Kiwify, Eduzz, Monetizze, CartPanda). Hotmart: `xcod` + `sck` (UTMs pipe-separados). Kiwify/Eduzz: `src`. Usa MutationObserver para links carregados dinamicamente. Chamar no `useEffect` do layout ou no `<body onload>` |
1468
+ | `normalizePhone(tel)` | Remove caracteres não-numéricos e adiciona DDI Brasil `55`. Formato exigido pela Meta: `'5511999998888'`. Retorna `undefined` se vazio |
1469
+ | `normalizeCity(city)` | Normaliza cidade para campo `ct` do Meta Advanced Matching: lowercase, remove acentos e caracteres especiais. Ex: `'São Paulo'` → `'saopaulo'`. Deve ser aplicada ANTES do SHA256 no servidor (CAPI) |
1470
+ | `splitName(fullName)` | Divide nome completo em `{ firstName, lastName }` — alinha com campos `fn`/`ln` do Meta CAPI e Enhanced Conversions do Google |
1471
+ | `isValidEmail(email)` | Valida formato de email — retorna `true/false`. Usar como guarda antes de `trackLead()` |
1472
+ | `readField(selector)` | Lê valor de campo de formulário por `id`, `name` ou seletor CSS. Usa fallback automático entre os dois |
1473
+ | `fixItems(raw, fieldMap?)` | Converte items MAL FORMATADOS para o padrão GA4. Recebe objeto ou array com qualquer estrutura de campo |
1474
+ | `buildItems(params)` | Monta array GA4 a partir de parâmetros SOLTOS (product_id, product_name, price, etc.) |
1475
+ | `ga4ItemsToMeta(items)` | Converte GA4 items → `{ content_ids, contents, num_items, value }` do Meta Pixel (Universal Conversion). Cada `contents[i]` inclui `id`, `quantity`, `item_price`, `title`, `category`, `brand` — formato completo exigido pelo Meta para e-commerce |
1476
+ | `ga4ItemsToTikTok(items)` | Converte GA4 items → `{ contents, value }` do TikTok Pixel (Universal Conversion) |
1477
+
1478
+ ### 📋 Parâmetros comuns
1479
+
1480
+ | Parâmetro | Tipo | Usado em |
1481
+ |---|---|---|
1482
+ | `value` | number | Purchase, Lead, Subscribe, Donate, etc. |
1483
+ | `currency` | string | Purchase, AddToCart, etc. (ex: `'BRL'`) |
1484
+ | `content_ids` | string[] | ViewContent, AddToCart, Purchase |
1485
+ | `content_name` | string | ViewContent, AddToCart, etc. |
1486
+ | `content_type` | string | ViewContent (`'product'`, `'home_listing'`, etc.) |
1487
+ | `order_id` | string | Purchase (único por pedido — evita duplicatas) |
1488
+ | `email` | string | Lead, Purchase (será hashed SHA256 no server-side) |
1489
+ | `phone` | string | Lead, Purchase (será hashed SHA256 no server-side) |
1490
+ | `firstName` | string | Lead, Purchase (será hashed SHA256 no server-side) |
1491
+ | `items` | object[] | Eventos GA4 de e-commerce (name, id, price, quantity) |
1492
+ | `search_term` | string | trackSearch |
1493
+ | `promotion_id` | string | trackViewPromotion, trackSelectPromotion |
1494
+ | `item_list_id` | string | trackViewItemList, trackSelectItem |
1495
+
1496
+ ---
1497
+
1498
+ ## CHECKLIST ANTI-ERROS
1499
+
1500
+ - [ ] `ViewContent` no **carregamento** da página — nunca no scroll
1501
+ - [ ] Botão de CTA/orçamento → `trackLead()` ou `trackContact()`, **nunca** AddToWishlist
1502
+ - [ ] `trackPageView()` em **toda** página via `useEffect(()=>{}, [])`
1503
+ - [ ] `send_page_view: false` no gtag (SPA) — `true` / ausente em HTML puro
1504
+ - [ ] `value` + `currency` obrigatórios no `trackPurchase()`
1505
+ - [ ] `order_id` único em Purchase (evita duplicata)
1506
+ - [ ] Formulários com `trackLead()` no `onSubmit` (não no onClick do botão)
1507
+ - [ ] Credenciais null no config → plataforma ignorada sem erros
1508
+ - [ ] GA4 com tag ativa além do ID (erro clássico: ID configurado como variável mas sem tag)
1509
+ - [ ] `trackLead()` e `trackContact()` são **async** — usar `await` ou não bloquear a UX
1510
+ - [ ] Geolocalização é opcional para o usuário — se negar, o evento dispara normalmente sem `city`
1511
+ - [ ] Nunca usar geolocalização como **condição de disparo** — sempre disparar o evento, capturar cidade como dado extra
1512
+ - [ ] No Modo A (Aplicação Direta): nunca sobrescrever arquivos inteiros — usar `Edit` para inserir blocos cirurgicamente
1513
+ - [ ] UTMs são capturados automaticamente na chegada — para rastreamento interno usar `getUTMs()` e incluir no payload enviado ao servidor
1514
+ - [ ] UTMs **NÃO** são enviados para as plataformas de anúncio diretamente — servem para rastreamento interno (D1, CRM) independente da janela de atribuição das plataformas
1515
+ - [ ] Página SPA: UTMs capturados no primeiro carregamento são preservados mesmo após navegação entre rotas (ficam em `_utms` na memória)
1516
+ - [ ] `gclid`, `wbraid`, `gbraid` capturados da URL → atribuição a nível de anúncio do Google Ads
1517
+ - [ ] `ttclid` capturado da URL → atribuição a nível de anúncio do TikTok Ads
1518
+ - [ ] Enhanced Conversions (Google Ads): passar `email` e `phone` no `trackLead()` e `trackPurchase()` para ativar atribuição a nível de usuário
1519
+ - [ ] `allow_enhanced_conversions: true` na config do `gtag('config', 'AW-...')` — obrigatório para Enhanced Conversions funcionar
1520
+ - [ ] User ID first-party (`getUserId()`) — incluir em payloads de servidor para identificação cross-session
1521
+
1522
+ ---
1523
+
1524
+ ## PASSO 6 — MODO SERVER-SIDE (quando solicitado)
1525
+
1526
+ > **Quando usar:** o usuário pede tracking server-side, CAPI, Measurement Protocol, "evitar bloqueio de adblocker", ou quer o endpoint `/api/tracking`.
1527
+
1528
+ ### Por que server-side existe
1529
+
1530
+ Adblockers e o ITP do Safari bloqueiam ou limitam pixels no browser. O server-side envia o mesmo evento direto das APIs das plataformas, sem depender do navegador. As plataformas deduplicam pelo `event_id` — o mesmo ID enviado pelo browser e pelo servidor.
1531
+
1532
+ ### Arquitetura com server-side
1533
+
1534
+ ```
1535
+ Browser (tracking.js)
1536
+ ├── dispara evento client-side (Meta Pixel, GA4, TikTok...)
1537
+ ├── gera event_id único
1538
+ ├── lê cookies: _fbp, _fbc, _ttp, _ga
1539
+ ├── captura UTMs da URL (utm_source, utm_medium, utm_campaign, utm_content, utm_term)
1540
+ └── POST /api/tracking → { event_name, event_id, cookies, utms, user_data, page_url }
1541
+
1542
+ Servidor Node.js (/api/tracking)
1543
+ ├── recebe payload do browser
1544
+ ├── adiciona: IP real, User-Agent do header
1545
+ ├── SHA256 hash: email, phone, nome, cidade, estado, CEP, país
1546
+ └── envia simultaneamente para:
1547
+ ├── Meta CAPI (graph.facebook.com)
1548
+ ├── GA4 MP (apenas purchase/refund)
1549
+ └── TikTok Events API
1550
+ ```
1551
+
1552
+ ---
1553
+
1554
+ ### PASSO 6.1 — Atualizar `tracking.config.js` com credenciais server-side
1555
+
1556
+ ```js
1557
+ const TRACKING_CONFIG = {
1558
+ // ... credenciais existentes ...
1559
+
1560
+ // ── SERVER-SIDE (backend) ──────────────────────────────
1561
+ server: {
1562
+ metaCapi: {
1563
+ accessToken: null, // Meta CAPI Access Token (gerado no Events Manager)
1564
+ testEventCode: null, // ex: 'TEST12345' — só durante testes
1565
+ },
1566
+ ga4: {
1567
+ apiSecret: null, // GA4 Measurement Protocol API Secret
1568
+ // measurement_id já usa ga4Id acima
1569
+ },
1570
+ tiktok: {
1571
+ accessToken: null, // TikTok Events API Access Token
1572
+ testEventCode: null,
1573
+ },
1574
+ },
1575
+ };
1576
+ ```
1577
+
1578
+ ---
1579
+
1580
+ ### PASSO 6.2 — Atualizar `tracking.js` para coletar cookies e enviar ao servidor
1581
+
1582
+ Adicionar ao `tracking.js` a função `sendServerEvent` que coleta os cookies do browser e faz POST ao endpoint:
1583
+
1584
+ ```js
1585
+ // ── Captura cookies do browser para repassar ao servidor ──
1586
+ // Cookies gerados pelas plataformas (identificam usuário e anúncio de origem)
1587
+ const getCookies = () => ({
1588
+ // Meta
1589
+ fbp: document.cookie.match(/_fbp=([^;]+)/)?.[1] || '', // ID do browser pelo Meta Pixel
1590
+ fbc: document.cookie.match(/_fbc=([^;]+)/)?.[1] || '', // Click ID Meta (gerado do fbclid)
1591
+ // TikTok
1592
+ ttp: document.cookie.match(/_ttp=([^;]+)/)?.[1] || '', // ID do browser pelo TikTok Pixel
1593
+ // Google Analytics
1594
+ ga: document.cookie.match(/_ga=([^;]+)/)?.[1] || '', // Raw _ga cookie
1595
+ // GA4 sessão — necessário para GA4 Measurement Protocol em webhooks externos (PASSO 2.23)
1596
+ // _ga_PROPERTY: "GS1.1.{session_id}.{session_num}.1.{last}.0.0.0"
1597
+ gaClient: (document.cookie.match(/_ga=([^;]+)/)?.[1] || '').replace(/^GA\d+\.\d+\./, ''), // client_id limpo
1598
+ gaSessionId: (document.cookie.match(/_ga_[^=]+=([^;]+)/)?.[1] || '').replace(/^GS\d+\.\d+\./, '').split('.')[0] || '',
1599
+ gaSessionNum: (document.cookie.match(/_ga_[^=]+=([^;]+)/)?.[1] || '').replace(/^GS\d+\.\d+\./, '').split('.')[1] || '',
1600
+ // Google Ads
1601
+ gcl_aw: document.cookie.match(/_gcl_aw=([^;]+)/)?.[1] || '', // Click ID Google Ads (do gclid)
1602
+
1603
+ // Click IDs da URL (capturados no carregamento — não ficam em cookies por padrão)
1604
+ ctwaClid: _ctwaClid, // Meta Click-to-WhatsApp
1605
+ gclid: _gclid, // Google Ads standard
1606
+ wbraid: _wbraid, // Google Ads (iOS / privacy preserving)
1607
+ gbraid: _gbraid, // Google Ads (app campaigns)
1608
+ ttclid: _ttclid, // TikTok Ads
1609
+
1610
+ // User ID first-party (persistente entre sessões, não bloqueado por ad-blockers)
1611
+ userId: _userId,
1612
+ });
1613
+
1614
+ // ── Envio server-side (fire and forget — não bloqueia UX) ──
1615
+ const sendServerEvent = (eventName, eventId, payload = {}) => {
1616
+ if (!isBrowser) return;
1617
+ // Não usar await — deixar rodar em paralelo com o event client-side
1618
+ fetch('/api/tracking', {
1619
+ method: 'POST',
1620
+ headers: { 'Content-Type': 'application/json' },
1621
+ body: JSON.stringify({
1622
+ event_name: eventName,
1623
+ event_id: eventId,
1624
+ page_url: window.location.href,
1625
+ referrer: document.referrer,
1626
+ cookies: getCookies(),
1627
+ utms: _utms, // UTMs para rastreamento interno (atribuição própria)
1628
+ ...payload,
1629
+ }),
1630
+ }).catch(() => {}); // silencia erros de rede
1631
+ };
1632
+
1633
+ // ── Gerador de event_id único (compartilhado browser ↔ servidor) ──
1634
+ const genId = () => `${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
1635
+ ```
1636
+
1637
+ Atualizar `trackLead`, `trackPurchase` e `trackContact` para gerar `event_id` e chamar `sendServerEvent` em paralelo:
1638
+
1639
+ ```js
1640
+ export const trackLead = async (params = {}) => {
1641
+ const eventId = genId();
1642
+ const cidade = await getCidade();
1643
+ const base = { value: 0, currency: 'BRL', ...params, ...(cidade && { city: cidade }) };
1644
+
1645
+ // Client-side (existente)
1646
+ if (active('metaPixelId')) window.fbq('track', 'Lead', base, { eventID: eventId });
1647
+ // ... demais plataformas ...
1648
+
1649
+ // Server-side (paralelo)
1650
+ sendServerEvent('Lead', eventId, {
1651
+ value: base.value, currency: base.currency,
1652
+ content_name: base.content_name || '',
1653
+ city: cidade || '',
1654
+ // email/phone: se disponíveis no formulário, passar via params
1655
+ email: params.email || '',
1656
+ phone: params.phone || '',
1657
+ });
1658
+ };
1659
+ ```
1660
+
1661
+ > **Regra:** `eventID` deve ser passado como 3º argumento do `fbq('track', ...)` para deduplicação Meta. TikTok usa `event_id` nas `properties`.
1662
+
1663
+ ---
1664
+
1665
+ ### PASSO 6.3 — Endpoint `/api/tracking` (Node.js / Next.js API Route)
1666
+
1667
+ ```js
1668
+ // Node.js Express: src/api/tracking.js
1669
+ // Next.js App Router: app/api/tracking/route.js
1670
+ // Next.js Pages Router: pages/api/tracking.js
1671
+
1672
+ import crypto from 'crypto';
1673
+ import CONFIG from '../tracking/tracking.config.js'; // ajustar path
1674
+
1675
+ // SHA256 com lowercase e trim — padrão exigido pela Meta e TikTok
1676
+ const sha256 = (val) => val
1677
+ ? crypto.createHash('sha256').update(String(val).toLowerCase().trim()).digest('hex')
1678
+ : '';
1679
+
1680
+ // IP real — checka proxies e load balancers
1681
+ const getRealIp = (req) =>
1682
+ req.headers['x-forwarded-for']?.split(',')[0].trim() ||
1683
+ req.headers['x-real-ip'] ||
1684
+ req.socket?.remoteAddress ||
1685
+ req.ip || '';
1686
+
1687
+ // ── HANDLER PRINCIPAL ──
1688
+ export async function POST(req) { // Next.js App Router
1689
+ // export default async function handler(req, res) { // Express / Pages Router
1690
+
1691
+ const body = await req.json(); // Next.js App Router
1692
+ // const body = req.body; // Express / Pages Router
1693
+
1694
+ const {
1695
+ event_name, event_id, page_url, referrer,
1696
+ cookies = {},
1697
+ utms = {}, // UTMs para rastreamento interno (atribuição própria)
1698
+ email = '', phone = '', firstName = '', lastName = '',
1699
+ city = '', state = '', zipCode = '', country = 'br',
1700
+ value = 0, currency = 'BRL', content_name = '',
1701
+ content_ids = [], content_type = 'product',
1702
+ ga_client_id = '', // extraído do cookie _ga no browser
1703
+ method = '',
1704
+ } = body;
1705
+
1706
+ // Click IDs — identificam o anúncio que originou o evento (atribuição a nível de anúncio)
1707
+ const ctwaClid = cookies.ctwaClid || ''; // Meta Click-to-WhatsApp
1708
+ const gclid = cookies.gclid || ''; // Google Ads standard click ID → Enhanced Conversions
1709
+ const wbraid = cookies.wbraid || ''; // Google Ads (iOS / privacy preserving)
1710
+ const gbraid = cookies.gbraid || ''; // Google Ads (app campaigns)
1711
+ const ttclid = cookies.ttclid || ''; // TikTok Ads click ID
1712
+ const userId = cookies.userId || ''; // User ID first-party (cross-session)
1713
+
1714
+ // UTMs extraídos — disponíveis para salvar em CRM/banco com cada evento
1715
+ // Exemplo: salvar no D1 → { event_name, utm_source, utm_campaign, email, value, page_url }
1716
+ const { utm_source = '', utm_medium = '', utm_campaign = '', utm_content = '', utm_term = '' } = utms;
1717
+
1718
+ const userAgent = req.headers['user-agent'] || '';
1719
+ const ip = getRealIp(req);
1720
+ const eventTime = Math.floor(Date.now() / 1000);
1721
+
1722
+ const results = await Promise.allSettled([
1723
+ sendMetaCapi({ event_name, event_id, page_url, ip, userAgent,
1724
+ cookies, email, phone, firstName, lastName,
1725
+ city, state, zipCode, country,
1726
+ value, currency, content_name, content_ids, content_type,
1727
+ ctwaClid, userId, eventTime }),
1728
+ sendGA4Mp({ event_name, event_id, page_url,
1729
+ value, currency, content_name, ga_client_id, eventTime }),
1730
+ sendTiktokApi({ event_name, event_id, page_url, ip, userAgent,
1731
+ cookies, email, phone, userId, value, currency, content_name, eventTime }),
1732
+ ]);
1733
+
1734
+ // Next.js App Router
1735
+ return Response.json({ ok: true });
1736
+ // Express: res.json({ ok: true });
1737
+ }
1738
+
1739
+ // ══════════════════════════════════════════════════════════
1740
+ // META CONVERSIONS API
1741
+ // ══════════════════════════════════════════════════════════
1742
+ async function sendMetaCapi(p) {
1743
+ if (!CONFIG.metaPixelId || !CONFIG.server?.metaCapi?.accessToken) return;
1744
+
1745
+ // user_data — todos os campos que existirem com SHA256
1746
+ // REGRA META: normalizar ANTES de hashear. Ordem importa:
1747
+ // email → lowercase + trim → sha256
1748
+ // phone → apenas dígitos (DDI incluído, ex: 5511999998888) → sha256
1749
+ // fn / ln → lowercase + trim → sha256
1750
+ // ct → lowercase, sem acentos, sem espaços, sem caracteres especiais → sha256
1751
+ // ex: 'São Paulo' → 'saopaulo' → sha256('saopaulo')
1752
+ // st → código 2 letras lowercase (ex: 'sp', 'rj') → sha256
1753
+ // zp → apenas dígitos (sem hífen) → sha256
1754
+ // country → código ISO 2 letras lowercase (ex: 'br') → sha256
1755
+ // fbp / fbc / ctwa_clid / external_id → NÃO hashear
1756
+ // external_id → sha256 do User ID first-party (_cdp_uid)
1757
+
1758
+ // normaliza cidade: lowercase, remove acentos, remove tudo que não é a-z0-9
1759
+ const normalizeCity = (city = '') =>
1760
+ String(city).normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]/g, '');
1761
+
1762
+ const user_data = {
1763
+ client_ip_address: p.ip, // obrigatório quando action_source=website
1764
+ client_user_agent: p.userAgent, // obrigatório quando action_source=website
1765
+ ...(p.cookies.fbp && { fbp: p.cookies.fbp }), // cookie _fbp — não hashear
1766
+ ...(p.cookies.fbc && { fbc: p.cookies.fbc }), // cookie _fbc — não hashear
1767
+ // ctwa_clid: Click ID de anúncio Click-to-WhatsApp — não hashear, enviar raw
1768
+ // Permite que a Meta atribua o evento ao anúncio CTWA específico
1769
+ ...(p.ctwaClid && { ctwa_clid: p.ctwaClid }),
1770
+ // external_id: User ID first-party — sha256 do _cdp_uid cookie
1771
+ // Permite deduplicação cross-dispositivo e atribuição cross-session
1772
+ ...(p.userId && { external_id: sha256(p.userId) }),
1773
+ ...(p.email && { em: sha256(p.email.toLowerCase().trim()) }),
1774
+ ...(p.phone && { ph: sha256(p.phone.replace(/\D/g, '')) }),
1775
+ ...(p.firstName && { fn: sha256(p.firstName.toLowerCase().trim()) }),
1776
+ ...(p.lastName && { ln: sha256(p.lastName.toLowerCase().trim()) }),
1777
+ ...(p.city && { ct: sha256(normalizeCity(p.city)) }),
1778
+ ...(p.state && { st: sha256(p.state.toLowerCase().trim()) }),
1779
+ ...(p.zipCode && { zp: sha256(p.zipCode.replace(/\D/g, '')) }),
1780
+ ...(p.country && { country: sha256(p.country.toLowerCase().trim()) }),
1781
+ };
1782
+
1783
+ const custom_data = {
1784
+ value: p.value,
1785
+ currency: p.currency,
1786
+ content_name: p.content_name,
1787
+ content_ids: p.content_ids,
1788
+ content_type: p.content_type,
1789
+ };
1790
+
1791
+ const serverEvent = {
1792
+ event_name: p.event_name,
1793
+ event_time: p.eventTime,
1794
+ event_id: p.event_id, // deduplicação com browser
1795
+ action_source: 'website', // obrigatório
1796
+ event_source_url: p.page_url, // obrigatório quando action_source=website
1797
+ user_data,
1798
+ custom_data,
1799
+ };
1800
+
1801
+ const url = `https://graph.facebook.com/v22.0/${CONFIG.metaPixelId}/events`;
1802
+ const params = new URLSearchParams({ access_token: CONFIG.server.metaCapi.accessToken });
1803
+ if (CONFIG.server.metaCapi.testEventCode) {
1804
+ serverEvent.test_event_code = CONFIG.server.metaCapi.testEventCode;
1805
+ }
1806
+
1807
+ await fetch(`${url}?${params}`, {
1808
+ method: 'POST',
1809
+ headers: { 'Content-Type': 'application/json' },
1810
+ body: JSON.stringify({ data: [serverEvent] }),
1811
+ });
1812
+ }
1813
+
1814
+ // ══════════════════════════════════════════════════════════
1815
+ // GA4 MEASUREMENT PROTOCOL
1816
+ // Nota: GA4 MP só é confiável para Purchase e eventos com transaction_id.
1817
+ // Para outros eventos, o GA4 coleta via gtag no browser é suficiente.
1818
+ // ══════════════════════════════════════════════════════════
1819
+ async function sendGA4Mp(p) {
1820
+ if (!CONFIG.ga4Id || !CONFIG.server?.ga4?.apiSecret) return;
1821
+ // GA4 MP só para purchase (padrão PYS e recomendação Google)
1822
+ if (p.event_name !== 'purchase') return;
1823
+
1824
+ // client_id vem do cookie _ga: GA1.1.XXXXXXXXXX.YYYYYYYYYY → pegar as partes
1825
+ const clientId = p.ga_client_id
1826
+ ? p.ga_client_id.replace(/^GA\d+\.\d+\./, '') // remove prefixo GA1.1.
1827
+ : `${Date.now()}.${Math.floor(Math.random()*1e9)}`;
1828
+
1829
+ const url = `https://www.google-analytics.com/mp/collect` +
1830
+ `?measurement_id=${CONFIG.ga4Id}&api_secret=${CONFIG.server.ga4.apiSecret}`;
1831
+
1832
+ await fetch(url, {
1833
+ method: 'POST',
1834
+ headers: { 'Content-Type': 'application/json' },
1835
+ body: JSON.stringify({
1836
+ client_id: clientId,
1837
+ events: [{
1838
+ name: 'purchase',
1839
+ params: {
1840
+ transaction_id: p.event_id,
1841
+ value: p.value,
1842
+ currency: p.currency,
1843
+ engagement_time_msec: 100,
1844
+ }
1845
+ }]
1846
+ }),
1847
+ });
1848
+ }
1849
+
1850
+ // ══════════════════════════════════════════════════════════
1851
+ // TIKTOK EVENTS API
1852
+ // ══════════════════════════════════════════════════════════
1853
+ async function sendTiktokApi(p) {
1854
+ if (!CONFIG.tiktokPixelId || !CONFIG.server?.tiktok?.accessToken) return;
1855
+
1856
+ // Mapeamento de nomes Meta → TikTok
1857
+ const eventMap = {
1858
+ Lead: 'SubmitForm', Purchase: 'CompletePayment',
1859
+ ViewContent: 'ViewContent', Contact: 'Contact',
1860
+ Schedule: 'Schedule', InitiateCheckout: 'InitiateCheckout',
1861
+ AddToCart: 'AddToCart', CompleteRegistration: 'CompleteRegistration',
1862
+ PageView: 'PageView',
1863
+ };
1864
+
1865
+ const tiktokEvent = {
1866
+ event: eventMap[p.event_name] || p.event_name,
1867
+ event_id: p.event_id, // deduplicação com browser
1868
+ timestamp: new Date().toISOString(), // obrigatório ISO 8601
1869
+ context: {
1870
+ ip: p.ip,
1871
+ user_agent: p.userAgent,
1872
+ page: {
1873
+ url: p.page_url,
1874
+ referrer: p.referrer || '',
1875
+ },
1876
+ user: {
1877
+ // SHA256 para TikTok
1878
+ // phone: normalizar (apenas dígitos, sem +) → sha256
1879
+ // email: lowercase + trim → sha256
1880
+ ...(p.email && { email: sha256(p.email.toLowerCase().trim()) }),
1881
+ ...(p.phone && { phone_number: sha256(p.phone.replace(/\D/g, '')) }),
1882
+ // external_id: User ID first-party (_cdp_uid) → sha256 — Advanced Matching cross-session
1883
+ ...(p.userId && { external_id: sha256(p.userId) }),
1884
+ // cookies — enviar raw, sem hash
1885
+ ...(p.cookies.ttp && { ttp: p.cookies.ttp }), // _ttp cookie → 'ttp' no payload
1886
+ ...(p.cookies.ttclid && { ttclid: p.cookies.ttclid }), // TikTok click ID
1887
+ },
1888
+ },
1889
+ properties: {
1890
+ value: p.value,
1891
+ currency: p.currency,
1892
+ content_name: p.content_name,
1893
+ content_type: 'product',
1894
+ },
1895
+ };
1896
+
1897
+ if (CONFIG.server.tiktok.testEventCode) {
1898
+ tiktokEvent.test_event_code = CONFIG.server.tiktok.testEventCode;
1899
+ }
1900
+
1901
+ await fetch('https://business-api.tiktok.com/open_api/v1.3/event/track/', {
1902
+ method: 'POST',
1903
+ headers: {
1904
+ 'Content-Type': 'application/json',
1905
+ 'Access-Token': CONFIG.server.tiktok.accessToken,
1906
+ },
1907
+ body: JSON.stringify({
1908
+ pixel_code: CONFIG.tiktokPixelId,
1909
+ event: tiktokEvent,
1910
+ }),
1911
+ });
1912
+ }
1913
+
1914
+ ---
1915
+
1916
+ ### PASSO 6.4 — Campos SHA256 obrigatórios (padrão Meta e TikTok)
1917
+
1918
+ | Campo | Antes de hashear |
1919
+ |---|---|
1920
+ | `email` | lowercase + trim |
1921
+ | `phone` | só dígitos (remover `+`, `-`, `(`, `)`, espaços) |
1922
+ | `firstName` | lowercase + trim |
1923
+ | `lastName` | lowercase + trim |
1924
+ | `city` | lowercase + sem acentos + sem espaços + sem caracteres especiais (`normalizeCity()`) — ex: `'São Paulo'` → `'saopaulo'` |
1925
+ | `state` | lowercase + trim (código ISO do estado, ex: `sp`) |
1926
+ | `zipCode` | só dígitos |
1927
+ | `country` | código ISO 2 letras lowercase (ex: `br`) |
1928
+ | `_ttp` (TikTok) | **NÃO hashear** — enviar raw |
1929
+ | `_fbp`, `_fbc` | **NÃO hashear** — enviar raw |
1930
+ | `ctwa_clid` | **NÃO hashear** — enviar raw (lido da URL `?ctwa_clid=xxx`) |
1931
+
1932
+ ---
1933
+
1934
+ ### PASSO 6.5 — Como testar server-side
1935
+
1936
+ **Meta:**
1937
+ 1. Adicionar `test_event_code` nas credenciais (`CONFIG.server.metaCapi.testEventCode`)
1938
+ 2. Acessar Meta Events Manager → Testar Eventos → filtrar pelo test code
1939
+
1940
+ **TikTok:**
1941
+ 1. Adicionar `test_event_code` nas credenciais TikTok
1942
+ 2. Acessar TikTok Events Manager → Testar Eventos
1943
+
1944
+ **GA4 Measurement Protocol:**
1945
+ Trocar URL de coleta para:
1946
+ `https://www.google-analytics.com/debug/mp/collect?measurement_id=...&api_secret=...`
1947
+ Retorna JSON com `validationMessages` indicando erros.
1948
+
1949
+ ---
1950
+
1951
+ ### Checklist server-side
1952
+
1953
+ - [ ] `ctwa_clid` capturado de `URLSearchParams` no carregamento da página e enviado no POST ao endpoint — **não hashear, não colocar no cookie**
1954
+ - [ ] `ctwa_clid` enviado em `user_data.ctwa_clid` na Meta CAPI — permite atribuição a anúncios Click-to-WhatsApp
1955
+ - [ ] `event_id` é o MESMO no browser e no servidor para deduplicação correta
1956
+ - [ ] `action_source: 'website'` + `event_source_url` + `client_ip_address` + `client_user_agent` — todos obrigatórios na Meta CAPI
1957
+ - [ ] Cookies `_fbp` e `_fbc` coletados no browser e repassados no POST
1958
+ - [ ] Cookie `_ttp` coletado e repassado para TikTok (sem hash)
1959
+ - [ ] `_ga` cookie extraído e `client_id` parseado corretamente para GA4 MP
1960
+ - [ ] SHA256 aplicado em todos os campos de user_data — Meta e TikTok
1961
+ - [ ] Telefone normalizado (só dígitos) antes do SHA256
1962
+ - [ ] Email em lowercase antes do SHA256
1963
+ - [ ] `sendServerEvent` no browser é fire-and-forget (sem await que bloqueie UX)
1964
+ - [ ] Em produção: remover `testEventCode` das configurações
1965
+
1966
+ ---
1967
+
1968
+ ---
1969
+
1970
+ ### Arquitetura de Servidor (Cloudflare Workers)
1971
+
1972
+ O CDP Edge opera exclusivamente sobre a infraestrutura **Cloudflare Workers (Quantum Tier)**.
1973
+
1974
+ O CDP Edge recomenda o uso de **Cloudflare Workers (Quantum Tier)** como endpoint de rastreamento principal. Ele oferece latência global de ~10ms, banco de dados D1 nativo e custo zero para a maioria dos funis ($0 para 100k req/dia).
1975
+
1976
+ | Recurso | Cloudflare Workers | Benefício Quantum Tier |
1977
+ |---|---|---|
1978
+ | Custo | Grátis / $5 pago | Zero barreira de entrada |
1979
+ | Banco de Dados | D1 SQLite ✅ | Persistência de leads e match |
1980
+ | SHA256 | WebCrypto Native | Segurança Enterprise |
1981
+ | Resiliência | Edge Global | Escala infinita |
1982
+
1983
+ ---
1984
+
1985
+ ## PASSO 6.6 — Cloudflare Workers + D1 (Full Core Tracking)
1986
+
1987
+ > **Quando usar:** O padrão ouro do CDP Edge. Roda o endpoint `/api/tracking` no Cloudflare Workers e salva todos os eventos e perfis no banco D1 para atribuição precisa.
1988
+
1989
+ ### Vantagens da Infraestrutura Cloudflare Native
1990
+
1991
+ | Recurso | Workers (free) |
1992
+ |---|---|
1993
+ | Custo | Grátis até 100k req/dia |
1994
+ | SHA256 | WebCrypto API (nativa) |
1995
+ | IP do usuário | Header `CF-Connecting-IP` (automático) |
1996
+ | Banco de dados | D1 SQLite (grátis até 5M reads/dia) |
1997
+ | Deploy | `wrangler deploy` (30s) |
1998
+ | Manutenção | Zero |
1999
+ | SSL | Automático |
2000
+
2001
+ > ⚠️ **Diferença crítica de código:** Workers usa **WebCrypto API** (assíncrona) para SHA256 em vez do módulo `crypto` do Node. Tudo mais é quase idêntico — `fetch()` é nativo, `Request`/`Response` são padrão Web API.
2002
+
2003
+ ---
2004
+
2005
+ ### 1. Estrutura do projeto Workers
2006
+
2007
+ ```
2008
+ cdp-edge-worker/
2009
+ ├── src/
2010
+ │ └── worker.js ← endpoint principal
2011
+ ├── wrangler.toml ← configuração Cloudflare
2012
+ └── schema.sql ← schema do banco D1
2013
+ ```
2014
+
2015
+ ---
2016
+
2017
+ ### 2. `wrangler.toml` — configuração completa
2018
+
2019
+ ```toml
2020
+ name = "cdp-edge-tracking"
2021
+ main = "src/worker.js"
2022
+ compatibility_date = "2024-01-01"
2023
+
2024
+ # ── D1 Database ──────────────────────────────────────────────
2025
+ [[d1_databases]]
2026
+ binding = "DB"
2027
+ database_name = "cdp-edge"
2028
+ database_id = "COLE_AQUI_O_ID_DO_D1" # gerado após: wrangler d1 create cdp-edge
2029
+
2030
+ # ── Variáveis públicas (não sensíveis) ───────────────────────
2031
+ [vars]
2032
+ META_PIXEL_ID = "SEU_META_PIXEL_ID"
2033
+ GA4_ID = "G-XXXXXXXXXX"
2034
+ TIKTOK_PIXEL_ID = "SEU_TIKTOK_PIXEL_ID"
2035
+
2036
+ # Secrets (nunca colocar aqui — usar: wrangler secret put NOME):
2037
+ # META_ACCESS_TOKEN
2038
+ # GA4_API_SECRET
2039
+ # TIKTOK_ACCESS_TOKEN
2040
+ ```
2041
+
2042
+ **Criar D1 e adicionar ID ao wrangler.toml:**
2043
+ ```bash
2044
+ wrangler d1 create cdp-edge
2045
+ # Saída: database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
2046
+ # Copiar esse ID para o wrangler.toml
2047
+ ```
2048
+
2049
+ wrangler secret put META_ACCESS_TOKEN
2050
+ wrangler secret put TIKTOK_ACCESS_TOKEN
2051
+ wrangler secret put GA4_API_SECRET
2052
+
2053
+ ---
2054
+
2055
+ ### 3. `schema.sql` — banco D1 para leads
2056
+
2057
+ ```sql
2058
+ CREATE TABLE IF NOT EXISTS leads (
2059
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2060
+ event_id TEXT NOT NULL,
2061
+ event_name TEXT NOT NULL,
2062
+ email TEXT DEFAULT '',
2063
+ phone TEXT DEFAULT '',
2064
+ first_name TEXT DEFAULT '',
2065
+ last_name TEXT DEFAULT '',
2066
+ city TEXT DEFAULT '',
2067
+ value REAL DEFAULT 0,
2068
+ currency TEXT DEFAULT 'BRL',
2069
+ utm_source TEXT DEFAULT '',
2070
+ utm_medium TEXT DEFAULT '',
2071
+ utm_campaign TEXT DEFAULT '',
2072
+ utm_content TEXT DEFAULT '',
2073
+ utm_term TEXT DEFAULT '',
2074
+ page_url TEXT DEFAULT '',
2075
+ user_id TEXT DEFAULT '',
2076
+ ip TEXT DEFAULT '',
2077
+ created_at TEXT NOT NULL
2078
+ );
2079
+
2080
+ CREATE INDEX IF NOT EXISTS idx_leads_email ON leads(email);
2081
+ CREATE INDEX IF NOT EXISTS idx_leads_created_at ON leads(created_at);
2082
+ CREATE INDEX IF NOT EXISTS idx_leads_event_name ON leads(event_name);
2083
+
2084
+ -- ── Perfis de usuário (enriquecimento de webhooks) ──────────
2085
+ -- Salva cookies do browser quando o usuário está no site.
2086
+ -- Permite recuperar fbp/fbc/gclid ao receber webhooks externos (Hotmart, Kiwify etc.)
2087
+ CREATE TABLE IF NOT EXISTS user_profiles (
2088
+ email TEXT PRIMARY KEY,
2089
+ fbp TEXT DEFAULT '',
2090
+ fbc TEXT DEFAULT '',
2091
+ ttp TEXT DEFAULT '',
2092
+ gclid TEXT DEFAULT '',
2093
+ user_id TEXT DEFAULT '',
2094
+ city TEXT DEFAULT '',
2095
+ state TEXT DEFAULT '',
2096
+ country TEXT DEFAULT 'br',
2097
+ user_agent TEXT DEFAULT '',
2098
+ ga_client TEXT DEFAULT '',
2099
+ updated_at TEXT NOT NULL
2100
+ );
2101
+
2102
+ CREATE INDEX IF NOT EXISTS idx_profiles_user_id ON user_profiles(user_id);
2103
+ ```
2104
+
2105
+ **Criar as tabelas:**
2106
+ ```bash
2107
+ wrangler d1 execute cdp-edge --file=schema.sql
2108
+ ```
2109
+
2110
+ ---
2111
+
2112
+ ### 4. `src/worker.js` — endpoint completo para Cloudflare Workers
2113
+
2114
+ ```js
2115
+ // ============================================================
2116
+ // Recebe eventos do browser → Meta CAPI + TikTok Events API + D1 Database
2117
+ // ============================================================
2118
+
2119
+ // SHA256 com WebCrypto API (nativa no Workers — não usar node:crypto)
2120
+ const sha256 = async (val) => {
2121
+ if (!val) return '';
2122
+ const data = new TextEncoder().encode(String(val).toLowerCase().trim());
2123
+ const buf = await crypto.subtle.digest('SHA-256', data);
2124
+ return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
2125
+ };
2126
+
2127
+ // SHA256 SEM lowercase (para campos que já chegam normalizados)
2128
+ const sha256Raw = async (val) => {
2129
+ if (!val) return '';
2130
+ const data = new TextEncoder().encode(String(val));
2131
+ const buf = await crypto.subtle.digest('SHA-256', data);
2132
+ return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
2133
+ };
2134
+
2135
+ // Normaliza cidade: 'São Paulo' → 'saopaulo'
2136
+ const normalizeCity = (city = '') =>
2137
+ String(city).normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]/g, '');
2138
+
2139
+ // IP real — Cloudflare injeta CF-Connecting-IP automaticamente
2140
+ const getRealIp = (request) =>
2141
+ request.headers.get('CF-Connecting-IP') ||
2142
+ request.headers.get('X-Forwarded-For')?.split(',')[0].trim() || '';
2143
+
2144
+ // ── HANDLER PRINCIPAL ────────────────────────────────────────
2145
+ export default {
2146
+ async fetch(request, env, ctx) {
2147
+ // CORS para o site poder chamar este Worker
2148
+ if (request.method === 'OPTIONS') {
2149
+ return new Response(null, {
2150
+ headers: {
2151
+ 'Access-Control-Allow-Origin': '*',
2152
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
2153
+ 'Access-Control-Allow-Headers': 'Content-Type',
2154
+ },
2155
+ });
2156
+ }
2157
+
2158
+ if (request.method !== 'POST') {
2159
+ return new Response('Method Not Allowed', { status: 405 });
2160
+ }
2161
+
2162
+ const body = await request.json().catch(() => ({}));
2163
+
2164
+ const {
2165
+ event_name, event_id, page_url, referrer,
2166
+ cookies = {},
2167
+ utms = {},
2168
+ email = '', phone = '', firstName = '', lastName = '',
2169
+ city = '', state = '', zipCode = '', country = 'br',
2170
+ value = 0, currency = 'BRL', content_name = '',
2171
+ content_ids = [], content_type = 'product',
2172
+ method = '',
2173
+ } = body;
2174
+
2175
+ const ip = getRealIp(request);
2176
+ const userAgent = request.headers.get('User-Agent') || '';
2177
+ const eventTime = Math.floor(Date.now() / 1000);
2178
+
2179
+ const { utm_source = '', utm_medium = '', utm_campaign = '',
2180
+ utm_content = '', utm_term = '' } = utms;
2181
+
2182
+ // ── Geo: browser tem prioridade (GPS real via getCidade())
2183
+ // request.cf.country disponível grátis em todos os planos
2184
+ // request.cf.city/region só no plano Business ($200/mês) — não usar no free/paid
2185
+ const geoCountry = country || request.cf?.country?.toLowerCase() || 'br';
2186
+ // Fallback por IP somente se browser não enviou cidade (usuário negou geolocalização)
2187
+ let geoCity = city;
2188
+ let geoState = state;
2189
+ if (!geoCity && ip && ip !== '127.0.0.1') {
2190
+ try {
2191
+ const geoRes = await fetch(`https://ipapi.co/${ip}/json/`, { headers: { 'User-Agent': 'CDP Edge/1.0' } });
2192
+ const geoData = await geoRes.json();
2193
+ geoCity = geoData.city || '';
2194
+ geoState = geoData.region_code?.toLowerCase() || '';
2195
+ } catch { /* silencia falha no lookup */ }
2196
+ }
2197
+
2198
+ // Disparar tudo em paralelo (não bloquear a resposta)
2199
+ const tasks = [
2200
+ sendMetaCapi({ event_name, event_id, page_url, referrer,
2201
+ ip, userAgent, cookies, email, phone, firstName, lastName,
2202
+ city: geoCity, state: geoState, zipCode, country: geoCountry,
2203
+ value, currency, content_name, content_ids, content_type,
2204
+ ctwaClid: cookies.ctwaClid || '',
2205
+ userId: cookies.userId || '',
2206
+ eventTime }, env),
2207
+
2208
+ sendTiktokApi({ event_name, event_id, page_url, referrer,
2209
+ ip, userAgent, cookies, email, phone,
2210
+ userId: cookies.userId || '',
2211
+ value, currency, content_name, eventTime }, env),
2212
+
2213
+ sendGA4Mp({ event_name, event_id, page_url,
2214
+ value, currency, content_name,
2215
+ ga_client_id: cookies.gaClient || '',
2216
+ ga_session_id: cookies.gaSessionId || '',
2217
+ ga_session_num:cookies.gaSessionNum || '',
2218
+ eventTime }, env),
2219
+ ];
2220
+
2221
+ // D1: salvar lead (fire-and-forget — não bloqueia resposta)
2222
+ if (env.DB && event_name) {
2223
+ tasks.push(
2224
+ env.DB.prepare(
2225
+ `INSERT INTO leads
2226
+ (event_id, event_name, email, phone, first_name, last_name, city,
2227
+ value, currency, utm_source, utm_medium, utm_campaign,
2228
+ utm_content, utm_term, page_url, user_id, ip, created_at)
2229
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
2230
+ ).bind(
2231
+ event_id, event_name, email, phone, firstName, lastName, city,
2232
+ value, currency, utm_source, utm_medium, utm_campaign,
2233
+ utm_content, utm_term, page_url,
2234
+ cookies.userId || '', ip, new Date().toISOString()
2235
+ ).run().catch(() => {})
2236
+ );
2237
+ }
2238
+
2239
+ // D1: salvar/enriquecer perfil do usuário (UPSERT preservando campos não-vazios)
2240
+ // Garante que fbp/fbc/ga_session salvos aqui podem ser recuperados em webhooks futuros (Hotmart etc.)
2241
+ if (env.DB && email) {
2242
+ tasks.push(
2243
+ env.DB.prepare(`
2244
+ INSERT INTO user_profiles
2245
+ (email, fbp, fbc, ttp, gclid, user_id, city, state, country, user_agent,
2246
+ ga_client, ga_session_id, ga_session_num, page_location, timestamp_ms, updated_at)
2247
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
2248
+ ON CONFLICT(email) DO UPDATE SET
2249
+ fbp = CASE WHEN excluded.fbp != '' THEN excluded.fbp ELSE fbp END,
2250
+ fbc = CASE WHEN excluded.fbc != '' THEN excluded.fbc ELSE fbc END,
2251
+ ttp = CASE WHEN excluded.ttp != '' THEN excluded.ttp ELSE ttp END,
2252
+ gclid = CASE WHEN excluded.gclid != '' THEN excluded.gclid ELSE gclid END,
2253
+ user_id = CASE WHEN excluded.user_id != '' THEN excluded.user_id ELSE user_id END,
2254
+ city = CASE WHEN excluded.city != '' THEN excluded.city ELSE city END,
2255
+ state = CASE WHEN excluded.state != '' THEN excluded.state ELSE state END,
2256
+ ga_client = CASE WHEN excluded.ga_client != '' THEN excluded.ga_client ELSE ga_client END,
2257
+ ga_session_id = CASE WHEN excluded.ga_session_id != '' THEN excluded.ga_session_id ELSE ga_session_id END,
2258
+ ga_session_num= CASE WHEN excluded.ga_session_num!= '' THEN excluded.ga_session_num ELSE ga_session_num END,
2259
+ page_location = excluded.page_location,
2260
+ timestamp_ms = excluded.timestamp_ms,
2261
+ updated_at = excluded.updated_at
2262
+ `).bind(
2263
+ email,
2264
+ cookies.fbp || '', cookies.fbc || '', cookies.ttp || '',
2265
+ cookies.gclid || '', cookies.userId || '',
2266
+ geoCity || '', geoState || '', geoCountry || 'br',
2267
+ userAgent,
2268
+ cookies.gaClient || '',
2269
+ cookies.gaSessionId || '',
2270
+ cookies.gaSessionNum || '',
2271
+ page_url || '',
2272
+ String(Date.now()),
2273
+ new Date().toISOString()
2274
+ ).run().catch(() => {})
2275
+ );
2276
+ }
2277
+
2278
+ // ctx.waitUntil: permite que as tasks terminem após a resposta ser enviada
2279
+ ctx.waitUntil(Promise.allSettled(tasks));
2280
+
2281
+ return Response.json({ ok: true }, {
2282
+ headers: { 'Access-Control-Allow-Origin': '*' },
2283
+ });
2284
+ },
2285
+ };
2286
+
2287
+ // ══════════════════════════════════════════════════════════
2288
+ // META CONVERSIONS API
2289
+ // ══════════════════════════════════════════════════════════
2290
+ async function sendMetaCapi(p, env) {
2291
+ if (!env.META_PIXEL_ID || !env.META_ACCESS_TOKEN) return;
2292
+
2293
+ const user_data = {
2294
+ client_ip_address: p.ip,
2295
+ client_user_agent: p.userAgent,
2296
+ ...(p.cookies.fbp && { fbp: p.cookies.fbp }),
2297
+ ...(p.cookies.fbc && { fbc: p.cookies.fbc }),
2298
+ ...(p.ctwaClid && { ctwa_clid: p.ctwaClid }),
2299
+ ...(p.userId && { external_id: await sha256Raw(p.userId) }),
2300
+ ...(p.email && { em: await sha256(p.email) }),
2301
+ ...(p.phone && { ph: await sha256Raw(p.phone.replace(/\D/g, '')) }),
2302
+ ...(p.firstName && { fn: await sha256(p.firstName) }),
2303
+ ...(p.lastName && { ln: await sha256(p.lastName) }),
2304
+ ...(p.city && { ct: await sha256Raw(normalizeCity(p.city)) }),
2305
+ ...(p.state && { st: await sha256(p.state) }),
2306
+ ...(p.zipCode && { zp: await sha256Raw(p.zipCode.replace(/\D/g, '')) }),
2307
+ ...(p.country && { country: await sha256(p.country) }),
2308
+ };
2309
+
2310
+ const serverEvent = {
2311
+ event_name: p.event_name,
2312
+ event_time: p.eventTime,
2313
+ event_id: p.event_id,
2314
+ action_source: 'website',
2315
+ event_source_url: p.page_url,
2316
+ user_data,
2317
+ custom_data: {
2318
+ value: p.value,
2319
+ currency: p.currency,
2320
+ content_name: p.content_name,
2321
+ content_ids: p.content_ids,
2322
+ content_type: p.content_type,
2323
+ },
2324
+ };
2325
+
2326
+ if (env.META_TEST_EVENT_CODE) serverEvent.test_event_code = env.META_TEST_EVENT_CODE;
2327
+
2328
+ await fetch(
2329
+ `https://graph.facebook.com/v22.0/${env.META_PIXEL_ID}/events?access_token=${env.META_ACCESS_TOKEN}`,
2330
+ {
2331
+ method: 'POST',
2332
+ headers: { 'Content-Type': 'application/json' },
2333
+ body: JSON.stringify({ data: [serverEvent] }),
2334
+ }
2335
+ );
2336
+ }
2337
+
2338
+ // ══════════════════════════════════════════════════════════
2339
+ // GA4 MEASUREMENT PROTOCOL (somente purchase)
2340
+ // ══════════════════════════════════════════════════════════
2341
+ async function sendGA4Mp(p, env) {
2342
+ if (!env.GA4_ID || !env.GA4_API_SECRET) return;
2343
+ if (p.event_name !== 'purchase') return;
2344
+
2345
+ // client_id vem do cookie _ga (sem prefixo GA1.1.)
2346
+ const clientId = p.ga_client_id
2347
+ ? p.ga_client_id.replace(/^GA\d+\.\d+\./, '')
2348
+ : `${Date.now()}.${Math.floor(Math.random() * 1e9)}`;
2349
+
2350
+ // session_id e session_number vêm do cookie _ga_PROPERTY (sem prefixo GS1.1.)
2351
+ const sessionId = p.ga_session_id || '';
2352
+ const sessionNum = p.ga_session_num || '1';
2353
+
2354
+ const payload = {
2355
+ client_id: clientId,
2356
+ // timestamp_micros: vincula o evento à sessão correta (obrigatório para MP)
2357
+ ...(p.eventTime && { timestamp_micros: String(p.eventTime * 1_000_000) }),
2358
+ events: [{
2359
+ name: 'purchase',
2360
+ params: {
2361
+ transaction_id: p.event_id,
2362
+ value: p.value,
2363
+ currency: p.currency,
2364
+ engagement_time_msec: 100,
2365
+ // session_id e session_number conectam ao relatório de GA4 correto
2366
+ ...(sessionId && { session_id: sessionId }),
2367
+ ...(sessionNum && { session_number: Number(sessionNum) }),
2368
+ },
2369
+ }],
2370
+ };
2371
+
2372
+ await fetch(
2373
+ `https://www.google-analytics.com/mp/collect?measurement_id=${env.GA4_ID}&api_secret=${env.GA4_API_SECRET}`,
2374
+ {
2375
+ method: 'POST',
2376
+ headers: { 'Content-Type': 'application/json' },
2377
+ body: JSON.stringify(payload),
2378
+ }
2379
+ );
2380
+ }
2381
+
2382
+ // ══════════════════════════════════════════════════════════
2383
+ // TIKTOK EVENTS API
2384
+ // ══════════════════════════════════════════════════════════
2385
+ async function sendTiktokApi(p, env) {
2386
+ if (!env.TIKTOK_PIXEL_ID || !env.TIKTOK_ACCESS_TOKEN) return;
2387
+
2388
+ const eventMap = {
2389
+ Lead: 'SubmitForm', Purchase: 'CompletePayment',
2390
+ ViewContent: 'ViewContent', Contact: 'Contact',
2391
+ Schedule: 'Schedule', InitiateCheckout: 'InitiateCheckout',
2392
+ AddToCart: 'AddToCart', CompleteRegistration: 'CompleteRegistration',
2393
+ PageView: 'PageView',
2394
+ };
2395
+
2396
+ const tiktokEvent = {
2397
+ event: eventMap[p.event_name] || p.event_name,
2398
+ event_id: p.event_id,
2399
+ timestamp: new Date().toISOString(),
2400
+ context: {
2401
+ ip: p.ip,
2402
+ user_agent: p.userAgent,
2403
+ page: { url: p.page_url, referrer: p.referrer || '' },
2404
+ user: {
2405
+ ...(p.email && { email: await sha256(p.email) }),
2406
+ ...(p.phone && { phone_number: await sha256Raw(p.phone.replace(/\D/g, '')) }),
2407
+ ...(p.userId && { external_id: await sha256Raw(p.userId) }),
2408
+ ...(p.cookies.ttp && { ttp: p.cookies.ttp }),
2409
+ ...(p.cookies.ttclid && { ttclid: p.cookies.ttclid }),
2410
+ },
2411
+ },
2412
+ properties: {
2413
+ value: p.value,
2414
+ currency: p.currency,
2415
+ content_name: p.content_name,
2416
+ content_type: 'product',
2417
+ },
2418
+ };
2419
+
2420
+ if (env.TIKTOK_TEST_EVENT_CODE) tiktokEvent.test_event_code = env.TIKTOK_TEST_EVENT_CODE;
2421
+
2422
+ await fetch('https://business-api.tiktok.com/open_api/v1.3/event/track/', {
2423
+ method: 'POST',
2424
+ headers: {
2425
+ 'Content-Type': 'application/json',
2426
+ 'Access-Token': env.TIKTOK_ACCESS_TOKEN,
2427
+ },
2428
+ body: JSON.stringify({
2429
+ pixel_code: env.TIKTOK_PIXEL_ID,
2430
+ event: tiktokEvent,
2431
+ }),
2432
+ });
2433
+ }
2434
+ ```
2435
+
2436
+ ---
2437
+
2438
+
2439
+ ---
2440
+
2441
+ ### 6. Deploy do Worker
2442
+
2443
+ ```bash
2444
+ # Instalar Wrangler (CLI Cloudflare)
2445
+ npm install -g wrangler
2446
+
2447
+ # Login (abre browser)
2448
+ wrangler login
2449
+
2450
+ # Criar banco D1
2451
+ wrangler d1 create cdp-edge
2452
+ # Copiar o database_id gerado → colar em wrangler.toml
2453
+
2454
+ # Criar tabelas
2455
+ wrangler d1 execute cdp-edge --file=schema.sql
2456
+
2457
+ # Adicionar secrets
2458
+ wrangler secret put META_ACCESS_TOKEN
2459
+ wrangler secret put TIKTOK_ACCESS_TOKEN
2460
+ wrangler secret put GA4_API_SECRET
2461
+
2462
+ # Deploy
2463
+ wrangler deploy
2464
+
2465
+ # URL gerada: https://cdp-edge-tracking.SEU_USUARIO.workers.dev
2466
+ ```
2467
+
2468
+ > Após o deploy, o Worker fica disponível em `https://cdp-edge-tracking.SEU_USUARIO.workers.dev`. Para usar `/api/tracking` no site, criar um **Custom Domain** nas configurações do Worker ou usar a URL completa.
2469
+
2470
+ ---
2471
+
2472
+ ### 7. Adaptar `sendServerEvent` no browser para apontar ao Worker
2473
+
2474
+ Se o site e o Worker estão em domínios diferentes, mudar a URL no `tracking.js`:
2475
+
2476
+ ```js
2477
+ // Em vez de '/api/tracking' (mesmo domínio — Next.js / Vite server)
2478
+ // usar a URL do Worker:
2479
+ const TRACKING_ENDPOINT = 'https://cdp-edge-tracking.SEU_USUARIO.workers.dev';
2480
+
2481
+ const sendServerEvent = (eventName, eventId, payload = {}) => {
2482
+ if (!isBrowser) return;
2483
+ fetch(TRACKING_ENDPOINT, {
2484
+ method: 'POST',
2485
+ headers: { 'Content-Type': 'application/json' },
2486
+ body: JSON.stringify({
2487
+ event_name: eventName,
2488
+ event_id: eventId,
2489
+ page_url: window.location.href,
2490
+ referrer: document.referrer,
2491
+ cookies: getCookies(),
2492
+ utms: _utms,
2493
+ ...payload,
2494
+ }),
2495
+ }).catch(() => {});
2496
+ };
2497
+ ```
2498
+
2499
+ > Se o site rodar em **Cloudflare Pages**, usar `functions/api/tracking.js` — o código do Worker é idêntico, só muda o handler para `export async function onRequestPost({ request, env, waitUntil })`.
2500
+
2501
+ ---
2502
+
2503
+ ### 8. Checklist deploy Cloudflare Workers
2504
+
2505
+ ```
2506
+ ☐ wrangler.toml criado com name, main, d1_databases
2507
+ ☐ D1 criado: wrangler d1 create cdp-edge → ID copiado para wrangler.toml
2508
+ ☐ Schema criado: wrangler d1 execute cdp-edge --file=schema.sql
2509
+ ☐ Secrets adicionados via wrangler secret put
2510
+ ☐ wrangler deploy executado com sucesso
2511
+ ☐ URL do Worker testada com curl ou Postman
2512
+ ☐ Meta Events Manager → Testar Eventos → evento chegando
2513
+ ☐ TikTok Events Manager → evento chegando
2514
+ ☐ Em produção: remover META_TEST_EVENT_CODE e TIKTOK_TEST_EVENT_CODE
2515
+ ```
2516
+
2517
+ ---
2518
+
2519
+ ## PASSO 7 — CTWA SEM LANDING PAGE (Cloudflare Worker + WhatsApp Business API)
2520
+
2521
+ > **Quando usar:** usuário roda anúncios Click-to-WhatsApp que vão direto para o WhatsApp, sem passar por nenhuma página do site. Não existe pixel no browser — o rastreamento é 100% server-side via webhook.
2522
+
2523
+ ### Arquitetura
2524
+
2525
+ ```
2526
+ Anúncio Meta (Instagram / Facebook)
2527
+ ↓ clique em "Enviar Mensagem"
2528
+ WhatsApp abre com conversa iniciada
2529
+ ↓ usuário manda primeira mensagem
2530
+ Meta Cloud API (webhook oficial)
2531
+ ↓ POST para Cloudflare Worker
2532
+ Worker (edge, ~5ms)
2533
+ ├── Meta CAPI (action_source: 'business_messaging')
2534
+ └── Cloudflare D1 (Identity Graph)
2535
+ ```
2536
+
2537
+ ### Diferença crítica de action_source
2538
+
2539
+ | Cenário | action_source | ctwa_clid vem de |
2540
+ |---|---|---|
2541
+ | Botão WhatsApp no site | `website` | `?ctwa_clid=` na URL (JavaScript) |
2542
+ | CTWA → site → WhatsApp | `website` | `?ctwa_clid=` na URL (JavaScript) |
2543
+ | CTWA → WhatsApp direto | `business_messaging` | `message.referral.ctwa_clid` (webhook) |
2544
+
2545
+ ### PASSO 7.1 — Cloudflare Worker completo
2546
+
2547
+ ```js
2548
+ // worker.js — Cloudflare Worker
2549
+ // Deploy: wrangler deploy
2550
+ // Variáveis de ambiente: META_PIXEL_ID, META_CAPI_TOKEN, VERIFY_TOKEN, SHEET_WEBHOOK_URL
2551
+
2552
+ const sha256 = async (val) => {
2553
+ const data = new TextEncoder().encode(String(val).toLowerCase().trim());
2554
+ const hash = await crypto.subtle.digest('SHA-256', data);
2555
+ return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2,'0')).join('');
2556
+ };
2557
+
2558
+ export default {
2559
+ async fetch(request, env) {
2560
+
2561
+ // ── Verificação do webhook (GET — Meta envia uma vez ao configurar) ──
2562
+ if (request.method === 'GET') {
2563
+ const url = new URL(request.url);
2564
+ const mode = url.searchParams.get('hub.mode');
2565
+ const token = url.searchParams.get('hub.verify_token');
2566
+ const challenge = url.searchParams.get('hub.challenge');
2567
+
2568
+ if (mode === 'subscribe' && token === env.VERIFY_TOKEN) {
2569
+ return new Response(challenge, { status: 200 });
2570
+ }
2571
+ return new Response('Forbidden', { status: 403 });
2572
+ }
2573
+
2574
+ // ── Receber mensagem do WhatsApp (POST) ──
2575
+ if (request.method === 'POST') {
2576
+ const body = await request.json();
2577
+ const change = body.entry?.[0]?.changes?.[0]?.value;
2578
+ const message = change?.messages?.[0];
2579
+
2580
+ // Responde 200 imediatamente — Meta exige resposta em < 20s
2581
+ if (!message || message.type === 'status') {
2582
+ return new Response('ok', { status: 200 });
2583
+ }
2584
+
2585
+ // ── Extrair dados da conversa ──
2586
+ const phone = message.from; // ex: '5511999999999'
2587
+ const name = change?.contacts?.[0]?.profile?.name || '';
2588
+ const ctwaClid = message.referral?.ctwa_clid || ''; // só existe se veio de anúncio CTWA
2589
+ const adId = message.referral?.source_id || '';
2590
+ const adUrl = message.referral?.source_url || '';
2591
+ const timestamp = Math.floor(Date.now() / 1000);
2592
+ const eventId = `wa_${phone}_${timestamp}`;
2593
+
2594
+ const lead = {
2595
+ phone,
2596
+ name,
2597
+ ctwa_clid: ctwaClid,
2598
+ ad_id: adId,
2599
+ ad_url: adUrl,
2600
+ event_id: eventId,
2601
+ timestamp: new Date().toISOString(),
2602
+ source: ctwaClid ? 'ctwa_ad' : 'organico',
2603
+ };
2604
+
2605
+ // Rodar em paralelo sem bloquear a resposta ao WhatsApp
2606
+ // (usar waitUntil para não cancelar após o return)
2607
+ const ctx = { waitUntil: (p) => p }; // fallback se não tiver ExecutionContext
2608
+ await Promise.allSettled([
2609
+ sendMetaCapi(lead, env, await sha256(phone.replace(/\D/g, ''))),
2610
+ ]);
2611
+
2612
+ return new Response('ok', { status: 200 });
2613
+ }
2614
+
2615
+ return new Response('Method Not Allowed', { status: 405 });
2616
+ }
2617
+ };
2618
+
2619
+ // ── META CAPI (action_source: business_messaging) ─────────
2620
+ async function sendMetaCapi(lead, env, hashedPhone) {
2621
+ if (!env.META_PIXEL_ID || !env.META_CAPI_TOKEN) return;
2622
+
2623
+ const user_data = {
2624
+ ph: hashedPhone,
2625
+ ...(lead.ctwa_clid && { ctwa_clid: lead.ctwa_clid }), // não hashear
2626
+ };
2627
+
2628
+ await fetch(
2629
+ `https://graph.facebook.com/v22.0/${env.META_PIXEL_ID}/events?access_token=${env.META_CAPI_TOKEN}`,
2630
+ {
2631
+ method: 'POST',
2632
+ headers: { 'Content-Type': 'application/json' },
2633
+ body: JSON.stringify({
2634
+ data: [{
2635
+ event_name: 'Contact',
2636
+ event_time: Math.floor(Date.now() / 1000),
2637
+ event_id: lead.event_id,
2638
+ action_source: 'business_messaging', // ← não é 'website'
2639
+ messaging_channel: 'whatsapp', // ← obrigatório
2640
+ user_data,
2641
+ }]
2642
+ }),
2643
+ }
2644
+ );
2645
+ }
2646
+ ```
2647
+
2648
+ ### PASSO 7.3 — Variáveis de ambiente no Cloudflare
2649
+
2650
+ No painel: Workers & Pages → seu Worker → Settings → Variables and Secrets
2651
+
2652
+ ```
2653
+ META_PIXEL_ID = 1234567890123456
2654
+ META_CAPI_TOKEN = seu_token_capi_do_events_manager
2655
+ VERIFY_TOKEN = qualquer_palavra_secreta (ex: cdp-edge2025)
2656
+ SHEET_WEBHOOK_URL = https://script.google.com/macros/s/SEU_ID/exec
2657
+ ```
2658
+
2659
+ ### PASSO 7.4 — Conectar o Worker ao WhatsApp Business API
2660
+
2661
+ **Meta Cloud API (gratuita):**
2662
+ 1. [developers.facebook.com](https://developers.facebook.com) → App → WhatsApp → Configuração
2663
+ 2. Webhook URL: `https://seu-worker.workers.dev`
2664
+ 3. Verify Token: mesmo valor de `VERIFY_TOKEN`
2665
+ 4. Assinar campo: `messages`
2666
+
2667
+ **Meta Cloud API (única opção suportada):**
2668
+ developers.facebook.com → App → WhatsApp → Configuração → Webhook URL: `https://seu-worker.workers.dev` → Assinar campo: `messages`
2669
+
2670
+ ### Checklist CTWA sem landing page
2671
+
2672
+ - [ ] Worker responde `200 OK` imediatamente — Meta cancela webhook se demorar mais de 20s
2673
+ - [ ] Verificar se `message.referral` existe antes de ler `ctwa_clid` — nem toda mensagem vem de anúncio
2674
+ - [ ] `action_source: 'business_messaging'` + `messaging_channel: 'whatsapp'` — obrigatórios juntos
2675
+ - [ ] `ctwa_clid` enviado raw em `user_data.ctwa_clid` — não hashear
2676
+ - [ ] Telefone normalizado (só dígitos) antes do SHA256
2677
+ - [ ] `event_id` único por mensagem para evitar duplicatas (ex: `wa_{phone}_{timestamp}`)
2678
+ - [ ] Eventos chegando no Meta Events Manager
2679
+ - [ ] `VERIFY_TOKEN` configurado no Worker antes de cadastrar o webhook na Meta
2680
+ - [ ] Testar com Meta Events Manager → Testar Eventos usando `testEventCode`
2681
+
2682
+ ---
2683
+
2684
+ ## PASSO 8.4 — Pixel de Mensagens Meta (WhatsApp)
2685
+
2686
+ #### 1. Criar App no Meta Developers
2687
+
2688
+ 1. Acessar https://developers.facebook.com
2689
+ 2. Login (se pedir conta: conectar com telefone; se der erro: adicionar cartão como método de pagamento)
2690
+ 3. Meus Apps → Criar App:
2691
+
2692
+ | Campo | Valor |
2693
+ |---|---|
2694
+ | Nome | qualquer nome descritivo |
2695
+ | Caso de uso | Outro |
2696
+ | Tipo | Business (Empresa) |
2697
+ | Portfólio | selecionar o BM que contém a conta de anúncios |
2698
+
2699
+ #### 2. Configurar API de Marketing
2700
+
2701
+ API DE MARKETING → Configurar → Ferramentas → Obter token de acesso
2702
+
2703
+ Permissões obrigatórias:
2704
+ - `ads_management`
2705
+ - `ads_read`
2706
+ - `read_insights`
2707
+
2708
+ Gerar token → **salvar imediatamente** (não é exibido novamente)
2709
+
2710
+ #### 3. Criar Pixel de Mensagens
2711
+
2712
+ No Gerenciador de Eventos:
2713
+ 1. Criar pixel de mensagens
2714
+ 2. Vincular a: uma Página do Facebook **ou** um perfil do Instagram
2715
+ 3. Se não aparecer páginas → desvincular a página de pixels antigos antes de tentar
2716
+
2717
+ #### 4. Criar Usuário do Sistema (BM)
2718
+
2719
+ URL: https://business.facebook.com/latest/settings/system_users
2720
+
2721
+ 1. Usuários → Usuários do sistema → Adicionar
2722
+ 2. Definir nome, System user role = `admin`
2723
+ 3. Atribuir todos os ativos
2724
+ 4. Gerar token com expiração **nunca** → Copiar token
2725
+
2726
+ #### 5. Salvar token no Cloudflare Secrets
2727
+
2728
+ Use o Wrangler para persistir o token de forma segura no ambiente do Worker:
2729
+
2730
+ ```bash
2731
+ wrangler secret put META_MESSAGING_TOKEN
2732
+ # Cole o token gerado no passo anterior
2733
+ ```
2734
+
2735
+ ---
2736
+
2737
+ ### PASSO 8.6 — Arquitetura Cloudflare Native (Quantum Tier) 🛡️⚓🚀
2738
+
2739
+ ```
2740
+ ┌─────────────────────────────────────┐
2741
+ │ CLOUDFLARE ECOSYSTEM │
2742
+ │ Tudo num único Domínio Raiz │
2743
+ └──────────────┬──────────────────────┘
2744
+
2745
+ ┌──────────────▼──────────────────────┐
2746
+ │ Cloudflare Edge │
2747
+ │ (Workers + D1 + R2 + Queues) │
2748
+ │ │
2749
+ │ ┌──────────┐ ┌──────────┐ ┌─────┐ │
2750
+ │ │ Payments │ │ Messaging│ │ CRM │ │
2751
+ │ │ (Ticto) │ │ (Resend) │ │ (D1)│ │
2752
+ │ └─────┬────┘ └─────┬────┘ └──┬──┘ │
2753
+ │ │ │ │ │
2754
+ │ ┌─────▼─────────────▼──────────▼──┐ │
2755
+ │ │ Identity Graph (D1) │ │
2756
+ │ │ (FBP / FBC / GCLID / UTMs) │ │
2757
+ │ └─────────────────────────────────┘ │
2758
+ └─────────────────────────────────────┘
2759
+
2760
+ ┌───────────────┼──────────────────┐
2761
+ │ │ │
2762
+ ┌──────────▼──┐ ┌────────▼────────┐ ┌─────▼──────────┐
2763
+ │ WhatsApp │ │ Meta CAPI │ │ A/B Edge │
2764
+ │ (Cloud API) │ │ v22.0 │ │ Routing │
2765
+ └─────────────┘ └─────────────────┘ └────────────────┘
2766
+ ```
2767
+
2768
+ **Fluxo de Dados Autônomo:**
2769
+ 1. **Inbound**: O Worker intercepta Webhooks (Ticto) ou Cliques no Site.
2770
+ 2. **Process**: IA Preditiva (LTV) e Fingerprinting cruzam dados no D1.
2771
+ 3. **Outbound**: Disparos simultâneos via Queues para Meta CAPI, Google Ads, TikTok e Mensageria (WhatsApp/Resend).
2772
+ 4. **Resgate**: UTMs perdidas são resgatadas via Identity Graph persistente no domínio.
2773
+
2774
+ ---
2775
+
2776
+ ### PASSO 8.7 — Excelência em Infraestrutura (Quantum Tier)
2777
+
2778
+ A arquitetura CDP Edge Quantum Tier é otimizada para máxima performance com redundância global, operando exclusivamente na borda (Edge).
2779
+
2780
+ ---
2781
+
2782
+ **Cloudflare DNS:**
2783
+ - [ ] Nameservers apontados para Cloudflare
2784
+ - [ ] Proxy ☁️ ativado para o domínio principal (Segurança & Performance)
2785
+ - [ ] Registros CNAME configurados para os subdomínios de tracking
2786
+ - [ ] SSL configurado como "Full (Strict)"
2787
+
2788
+ ---
2789
+
2790
+ ## PASSO 2.21 — Webhooks Externos: Ticto (v2.0)
2791
+
2792
+ > Use esta seção para orientar o usuário na configuração da Ticto como gateway principal de conversões offline e ROI.
2793
+
2794
+ ### Configuração na Ticto
2795
+ 1. Acesse **Tictools > Webhooks** no menu lateral esquerdo.
2796
+ 2. Clique em **+ Nova Pasta** (opcional para organização).
2797
+ 3. Clique em **+ Criar Webhook**.
2798
+ 4. **Endpoint URL:** `https://seu-worker.workers.dev/api/wh/ticto`
2799
+ 5. **Versão:** Selecione **2.0** (Recomendado).
2800
+ 6. **Formato:** Selecione **JSON**.
2801
+ 7. **Eventos Críticos (Mapeamento CDP Edge):**
2802
+ - `15. Venda Realizada` -> `Purchase`
2803
+ - `1. Abandono de Carrinho` -> `AddToCart` / CRM
2804
+ - `4. Pix Gerado` / `2. Boleto Impresso` -> `InitiateCheckout`
2805
+ 8. **Token:** Copie o Token gerado e configure no Worker via `wrangler secret put TICTO_TOKEN`.
2806
+
2807
+ ### Validação Automática
2808
+ A Ticto enviará um POST de teste imediatamente. O Worker **DEVE** retornar status `200 OK` para que a integração seja salva com sucesso.
2809
+
2810
+ ### Payload de Referência (v2.0)
2811
+ ```json
2812
+ {
2813
+ "event_id": 15,
2814
+ "event_name": "Venda Realizada",
2815
+ "customer": {
2816
+ "email": "cliente@email.com",
2817
+ "phone": "5511999999999"
2818
+ },
2819
+ "transaction": {
2820
+ "total_amount": 197.00,
2821
+ "currency": "BRL"
2822
+ }
2823
+ }
2824
+ ```
2825
+
2826
+ ---
2827
+
2828
+ ## PASSO 2.22 — E-mail Transacional: Resend API
2829
+
2830
+ > Use esta seção para configurar o envio de e-mails automáticos (Venda, Abandono, Bônus) diretamente do Cloudflare Worker.
2831
+
2832
+ ### Configuração no Resend
2833
+ 1. Acesse **Resend > Domains** e adicione seu domínio.
2834
+ 2. No Cloudflare, adicione os registros **MX, TXT (SPF/DKIM)** que o Resend fornecerá.
2835
+ 3. Gere uma **API Key** e salve via `wrangler secret put RESEND_API_KEY`.
2836
+ 4. Envie e-mails via `POST https://api.resend.com/emails`.
2837
+
2838
+ ---
2839
+
2840
+ ## PASSO 2.23 — Spotify Ads: Pixel + Conversions API (v1)
2841
+
2842
+ > O Spotify Ads é a peça final do Quantum Tier. Permite rastrear conversões originadas de áudio e display no Spotify.
2843
+
2844
+ ### Especificações Técnicas
2845
+ - **Endpoint**: `https://advertising-api.spotify.com/conversion/v1/accounts/{ACCOUNT_ID}/events`
2846
+ - **Autenticação**: Bearer Token (`SPOTIFY_ACCESS_TOKEN`)
2847
+ - **Deduplicação**: `event_id` obrigatório entre Browser e Server
2848
+ - **PII Hashing**: SHA-256 obrigatório para E-mail e Telefone (Advanced Matching)
2849
+
2850
+ ### Eventos Principais
2851
+ | Evento | Conversão | Descrição |
2852
+ |---|---|---|
2853
+ | `ViewContent` | Milestone | Visualização de página de produto/serviço |
2854
+ | `AddToCart` | Intenção | Adicionado ao carrinho |
2855
+ | `Purchase` | Conversão | Compra realizada via Spotify Ads |
2856
+ | `Lead` | Conversão | Captação de lead via áudio/display |
2857
+
2858
+ ### Implementação de Borda (Worker)
2859
+ Sempre use `ctx.waitUntil` para despachar o evento para o Spotify sem atrasar a resposta ao usuário.
2860
+
2861
+ ---
2862
+
2863
+ ### 2.3 Segurança e Hashing (WebCrypto API)
2864
+ O hashing de PII (e-mail, telefone, nome) deve ser feito obrigatoriamente no servidor usando a API nativa do Cloudflare Workers:
2865
+ ```javascript
2866
+ async function sha256(data) {
2867
+ const msgBuffer = new TextEncoder().encode(data.toLowerCase().trim());
2868
+ const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
2869
+ return Array.from(new Uint8Array(hashBuffer))
2870
+ .map(b => b.toString(16).padStart(2, '0')).join('');
2871
+ }
2872
+ ```
2873
+
2874
+ ---
2875
+
2876
+ ## 📋 3. MAPEAMENTO DE APIs
2877
+
2878
+ | Plataforma | Versão | Endpoint Principal |
2879
+ |---|---|---|
2880
+ | **Meta (CAPI)** | v22.0 | `https://graph.facebook.com/v22.0/{PIXEL_ID}/events` |
2881
+ | **TikTok (Events)** | v1.3 | `https://business-api.tiktok.com/open_api/v1.3/event/track/` |
2882
+ | **Google (GA4)** | MP | `https://www.google-analytics.com/mp/collect` |
2883
+ | **Spotify (Ads)** | v1 | `https://advertising-api.spotify.com/conversion/v1/accounts/{ACC_ID}/events` |
2884
+
2885
+ ---
2886
+
2887
+ ## 🔄 4. PROCESSO DE CONFIGURAÇÃO
2888
+
2889
+ 1. **Setup Worker**: Criar Worker no Cloudflare e vincular banco D1.
2890
+ 2. **Schema D1**: Executar `schema.sql` para criar as tabelas de identidade e logs.
2891
+ 3. **Wrangler Config**: Configurar `wrangler.toml` com os bindings e secrets (tokens de API).
2892
+ 4. **SDK Site**: Inserir `cdpTrack.js` e `tracking.config.js` no site.
2893
+ 5. **Event Mapping**: Configurar gatilhos de clique e formulário no site.
2894
+ 6. **Webhooks**: Configurar o endpoint de webhook na plataforma de vendas (Ticto/Hotmart).