Add Meta Pixel tracking to public petition pages.

Let marketing configure a numeric facebookPixelId per petition in Payload, fire PageView on load and Lead after a successful signature, and mark the roadmap item delivered with deferred follow-ups documented.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-21 10:51:15 -03:00
parent b7cfc609e1
commit be1106f887
12 changed files with 10633 additions and 21 deletions

View file

@ -1,12 +1,14 @@
# Pixel do Meta (Facebook) nos abaixo-assinados
Status: rascunho
Status: entregue 2026-07-21
Atualizado em: 2026-07-21
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Site público)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Site público — entregue)
Impeccable: B — encaixe no admin `Petition` + script na página `/abaixo-assinado/[id]` e no sucesso do `PetitionForm`
Appetite: ~0,5–1 dia eng; migration + 1 campo admin + componente de script + disparo `Lead` no sucesso
Responsável: —
_Revisão 2026-07-21: implementação + `/simplify` + `capture-review-debts` — status entregue; unicidade travada; gatilho S10 (fila/retry de `Lead`) em Adiado; débitos de triage registrados em Explicitamente fora._
## Design (Impeccable)
Âncoras: `PRODUCT.md` / `DESIGN.md` (register `brand` no site público; tema `data-theme='petition'` na página). Admin Payload usa labels pt-BR do grupo `Abaixo-assinados` — sem redesign de paleta.
@ -22,9 +24,9 @@ Brief compacto:
## Contexto
A página pública `/abaixo-assinado/[id]` (`src/app/(frontend)/abaixo-assinado/[id]/page.tsx`) renderiza o documento `petition` e o formulário cliente `PetitionForm` (`src/components/PetitionForm.tsx`). Assinatura bem-sucedida chama `submitPetitionSignature` e abre `PetitionSuccessDialog` — hoje **sem** telemetria de ads.
A página pública `/abaixo-assinado/[id]` renderiza o documento `petition` e o formulário cliente `PetitionForm`. Assinatura bem-sucedida chama `submitPetitionSignature` e abre `PetitionSuccessDialog`. Com Pixel configurado: `MetaPixel` dispara `PageView` no load; `trackMetaLead` dispara `Lead` após assinatura ok.
A collection `Petition` (`src/collections/Petition.ts`, grupo admin `Abaixo-assinados`) tem `title`/`subtitle`/`body`/`enabled` e o grupo `form` (título, subtítulo, `consent`). Não há campo de tracking.
A collection `Petition` (`src/collections/Petition.ts`) expõe o grupo `tracking.facebookPixelId` (admin “Rastreamento / Ads”) além do grupo `form`.
O time de marketing digital pediu (2026-07-21) um campo no formulário do abaixo-assinado no Payload para “adicionar o snippet do Pixel do Facebook” e acompanhar assinaturas no Events Manager / otimizar campanhas.
@ -52,17 +54,14 @@ Fontes: [Meta Pixel Get Started](https://developers.facebook.com/docs/meta-pixel
- **Pixel por petição (campo no documento `petition`), não global do site.** Campanhas de ads costumam ser por abaixo-assinado/URL; evita Pixel único vazar PageViews de notícias/home. **Rejeitado:** só env `NEXT_PUBLIC_FACEBOOK_PIXEL_ID` site-wide; Global `SiteSettings` único (pode ser fill-in futuro se marketing unificar Dataset).
- **v1 = browser Pixel only, com os dois eventos:** `PageView` no load da página da petição **e** `Lead` no sucesso do form (confirmado com produto 2026-07-21). Padrão Meta: retargeting de visitantes + conversão de assinatura. Transparência de cookies/Meta fica no polish de `/privacidade` (dependência suave). **Rejeitado nesta fatia:** só `Lead` sem `PageView`; Conversions API; Advanced Matching com e-mail/telefone no browser/servidor; eventos `CompleteRegistration`/`Contact` além de `Lead`; cookie banner / CMP.
- **`Lead` só após `submitPetitionSignature` ok** (mesmo caminho que abre o diálogo de sucesso). **Rejeitado:** `Lead` no `onSubmit` antes da resposta; `Lead` no page load; thank-you page dedicada (não existe hoje — o sucesso é modal same-page).
- **Mesmo Dataset em N petições permitido.** O mesmo `facebookPixelId` pode aparecer em várias petições; `content_name` no `Lead` carrega o título da petição para diferenciar no Events Manager. **Rejeitado:** uniqueness forçada no admin.
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`facebookPixelId`, `MetaPixel`, `trackMetaLead`); labels admin e copy em pt-BR (“ID do Pixel do Meta (Facebook)”).
## Questões em aberto
- **Um Pixel por petição vs. mesmo Dataset em várias petições?** **Opções:** A) permitir o mesmo ID em N petições (só conteúdo do `Lead` diferencia) | B) forçar uniqueness. **Recomendação:** A — Dataset único da campanha é o caso comum; `content_name` carrega `petition.id` / título.
## Abordagem proposta
```mermaid
flowchart LR
Admin["Payload admin<br/>petition.facebookPixelId"]
Admin["Payload admin<br/>tracking.facebookPixelId"]
Page["abaixo-assinado/[id]<br/>Server Component"]
Pixel["MetaPixel client<br/>next/script + fbq init/PageView"]
Form["PetitionForm"]
@ -86,8 +85,16 @@ Componentes:
- **`trackMetaLead`** (mesmo módulo lib/utilities ou export do componente): `fbq('track', 'Lead', { content_name }, { eventID? })` se `window.fbq` existir; no-op seguro se Pixel não carregou / adblock.
- **`PetitionForm`**: após sucesso de `submitPetitionSignature`, chamar `trackMetaLead` com `content_name` = título ou id da petição (e `eventID` = UUID gerado no cliente **só se** quisermos preparar CAPI depois — nesta fatia opcional; preferir gerar UUID e passar já, custo zero, facilita fatia futura).
- **Página** `abaixo-assinado/[id]/page.tsx`: passar `petition.facebookPixelId` para `<MetaPixel />` quando presente; incluir o campo no `select`/cache se a leitura de documento filtrar campos (hoje `getCachedDocumentById` carrega o doc — confirmar após `generate:types`).
- **Migration**: `pnpm migrate:create add_petition_facebook_pixel_id` — coluna nullable em `petition` (+ drafts/versions se Payload espelhar). Sem backfill.
- **Testes:** unit no validador de ID; int opcional na collection validate se o repo já testa Petition; E2E não obrigatório (terceiro externo) — smoke manual com [Meta Pixel Helper](https://developers.facebook.com/docs/meta-pixel/support/pixel-helper).
- **Migration** `20260721_133531_add_petition_facebook_pixel_id` — colunas `tracking_facebook_pixel_id` / `version_tracking_facebook_pixel_id`; sem backfill.
- **Testes:** `tests/unit/facebookPixel.unit.spec.ts` (validador + `trackMetaLead`); smoke manual com [Meta Pixel Helper](https://developers.facebook.com/docs/meta-pixel/support/pixel-helper).
## Já resolvido no simplify/critique (não reabrir)
- Normalização única na página (`normalizeFacebookPixelId`) + prop `facebookPixelId` para `PetitionForm` (sem re-normalizar no form).
- `validate` admin e `beforeChange` em `Petition` reutilizam `normalizeFacebookPixelId`.
- Validação duplicada removida de `MetaPixel` (prop pré-validada).
- Tipos one-off removidos (`TrackMetaLeadInput`, `MetaPixelProps`); `trackMetaLead` com assinatura posicional.
- Blockquote da página da petição suavizado (Impeccable side-tab); `impeccable detect` limpo na superfície.
## Dependências
@ -113,10 +120,19 @@ Componentes:
## Adiado com gatilho
- **Fila/retry de `Lead` quando `window.fbq` ainda não carregou** (race submit rápido vs. `afterInteractive`). Revisitar quando smoke com [Meta Pixel Helper](https://developers.facebook.com/docs/meta-pixel/support/pixel-helper) mostrar conversão `Lead` ausente após assinatura bem-sucedida em produção.
- **Conversions API + dedup com `event_id` compartilhado.** Revisitar quando: marketing reportar perda material de atribuição (iOS/adblock) **ou** Event Match Quality &lt; 6 no Dataset em uso com volume real de ads.
- **Pixel / Dataset global em `SiteSettings`.** Revisitar quando: o mesmo ID for colado em ≥3 petições e o admin reclamar de repetição.
- **Cookie banner / CMP.** Revisitar quando: jurídico exigir bloqueio pré-consent de cookies de ads (não só transparência).
## Explicitamente fora (triage capture-review-debts 2026-07-21)
- **Re-validação em `trackMetaLead`** — defesa em profundidade intencional (custo zero).
- **Flatten `tracking` → campo top-level** — grupo reservado para futuros campos de ads.
- **`eslint` `@next/next/no-img-element` no `<noscript>` 1×1** — padrão Meta; `next/image` inadequado.
- **Int test de `Petition.validate`** — opcional no appetite original; sem evidência de regressão.
- **Mencionar cookies/Meta em `/privacidade`** — absorvido no item existente do roadmap (Site público / O0+); não duplicar plano.
## Referências
- `docs/roadmap.md` (Site público)

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-21 (B10 click-nav; B11 escala % mapa; A9→B9; B8; Fill-in filtros; B7; Pixel Meta)
Atualizado em: 2026-07-21 (Pixel Meta entregue; B10 click-nav; B11 escala % mapa; A9→B9; B8; Fill-in filtros; B7)
Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
@ -81,7 +81,7 @@ flowchart TD
C2prod --> C5["C5 GOTV (validar)"]
```
Paralelizáveis sem seta dura de entrada (além das tracejadas a partir de R2): **B7**, **B10**, **B11**, fill-ins da lista, Pixel Meta (site público). **A9** bloqueia **B9** (seta dura).
Paralelizáveis sem seta dura de entrada (além das tracejadas a partir de R2): **B7**, **B10**, **B11**, fill-ins da lista. **A9** bloqueia **B9** (seta dura).
### Sequência por janela
@ -97,12 +97,13 @@ Paralelizáveis sem seta dura de entrada (além das tracejadas a partir de R2):
**Não cortáveis:** Onda 0 (jurídico/Consent); R1–R2 (sem eles a vertical não reflete a operação real); C2 dados reais; assimetria declarado×estimado (relação de campo); **A9** (sem total esperado da Praça, lista/mapa mentem onde não há liderança — schema do modelo operacional).
**Cortes seguros** (se o prazo apertar, nesta ordem): R4 mapa comparativo (manter tabela comparativa); painel de detalhe por zona no mapa; R3 organizações (manter demandas); resultado de plano com mídia (manter texto); Eleitorado/IBGE na Praça; D2 push (manter sino); A6; B6; B7 (mapa ainda mostra escopo de access sem filtro de UI); **B11** (mapa continua em escala absoluta); **B10** (mapa legível sem hover); **B9** (com A9, edição do total fica só no `/editar`); **B8** (F2 polígonos; manter F1 bairros na Praça se já entregue — mapa continua agregado no município); Pixel Meta nos abaixo-assinados (ads do site público; vertical `/campanha` independente); débitos/fill-ins.
**Cortes seguros** (se o prazo apertar, nesta ordem): R4 mapa comparativo (manter tabela comparativa); painel de detalhe por zona no mapa; R3 organizações (manter demandas); resultado de plano com mídia (manter texto); Eleitorado/IBGE na Praça; D2 push (manter sino); A6; B6; B7 (mapa ainda mostra escopo de access sem filtro de UI); **B11** (mapa continua em escala absoluta); **B10** (mapa legível sem hover); **B9** (com A9, edição do total fica só no `/editar`); **B8** (F2 polígonos; manter F1 bairros na Praça se já entregue — mapa continua agregado no município); débitos/fill-ins.
## Já entregue (resumo)
- **Era Núcleos (2026-07-15 → 2026-07-20)** — MVP + Ciclo 2 (auth `campaignUser`, território A1/A2, baseline TSE A3/A4, overview B1, share C1, PWA D1, geometrias B2, Leaflet B3), C2 apoiadores (eng.), C3 agenda, C6–C11 escala, E1+E3 metas/estratégia, E2 série TSE 2014/2018/2022, A5 conversão/classificação/alavancagem/mobilização, A7 F1–F2, A8 perfis IBGE, fill-ins (reset senha/perfil, visitados recentes, Field Desk polish). Infra e padrões (locks, transações, consent por chave, shells, mapa, dados eleitorais) **são reaproveitados pela remodelagem**; as superfícies e o modelo de Núcleo são substituídos.
- **Plataforma** — local Postgres + guards, migrations baselined, posts/tags do site público com cache `posts`, Onda 0 textos provisórios + `/privacidade`.
- **Site público (2026-07-21)** — **Pixel do Meta nos abaixo-assinados** (`tracking.facebookPixelId` no admin `petition`, `PageView`/`Lead` na página pública via `MetaPixel` + `trackMetaLead`; migration `20260721_133531_add_petition_facebook_pixel_id`) — [plano](plans/pixel-meta-abaixo-assinado.md).
## Supersedidos pela remodelagem (2026-07-20)
@ -148,12 +149,11 @@ Paralelizáveis sem seta dura de entrada (além das tracejadas a partir de R2):
## Site público
**Já entregue:** `post`/`tag`, listagens/artigos, seed, cache `posts`; `/privacidade` (texto provisório Onda 0).
**Já entregue:** `post`/`tag`, listagens/artigos, seed, cache `posts`; `/privacidade` (texto provisório Onda 0); **Pixel do Meta nos abaixo-assinados** ([plano](plans/pixel-meta-abaixo-assinado.md)).
**Próximos:**
- **Pixel do Meta (Facebook) nos abaixo-assinados** — campo `facebookPixelId` no admin `petition` + `PageView`/`Lead` na página pública (não colar snippet HTML) · appetite ~0,5–1 dia · paralelizável · [plano](plans/pixel-meta-abaixo-assinado.md)
- Textos finais de privacidade + polish O0+ (revalidate globals, DRY Lexical) — mencionar cookies/Meta quando o Pixel estiver em uso _(suave com o item acima)_
- Textos finais de privacidade + polish O0+ (revalidate globals, DRY Lexical) — mencionar cookies/Meta com Pixel em uso _(suave; ver roadmap Site público)_
- `Pages` institucionais (bio, mandato, propostas) + hero/copy editáveis
- Agenda/multimídia via links oficiais; CTA Doar → QueroApoiar
- Migrar Consent dos fluxos públicos para chave estável

View file

@ -1,6 +1,8 @@
import { PetitionForm } from '@/components/PetitionForm'
import { MetaPixel } from '@/components/MetaPixel'
import { SignatureCounter } from '@/components/SignatureCounter'
import { getSignatureCount } from '@/app/(frontend)/actions/getSignatureCount'
import { normalizeFacebookPixelId } from '@/lib/facebookPixel'
import { getCachedDocumentById, getDocuments } from '@/utilities/documents'
import { getCachedGlobal } from '@/utilities/globals'
import { extractFirstImageFromLexical } from '@/utilities/extractFirstImageFromLexical'
@ -164,12 +166,14 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
: ''
const signatureCount = await getSignatureCount(petition.id)
const facebookPixelId = normalizeFacebookPixelId(petition.tracking?.facebookPixelId)
return (
<main
data-theme="petition"
className="h-screen w-screen overflow-y-auto bg-background text-foreground"
>
{facebookPixelId ? <MetaPixel pixelId={facebookPixelId} /> : null}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
@ -215,7 +219,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
className="mx-auto w-full max-w-3xl px-4 py-14 sm:px-6 lg:px-8 lg:py-16"
>
<article
className="space-y-6 text-lg leading-8 text-foreground/90 [&_a]:font-medium [&_a]:text-primary [&_a]:underline-offset-4 [&_a:hover]:underline [&_blockquote]:border-l-4 [&_blockquote]:border-primary/40 [&_blockquote]:pl-4 [&_h1]:text-3xl [&_h1]:font-bold [&_h2]:border-none [&_h2]:text-2xl [&_h2]:font-semibold [&_h2]:text-primary [&_h3]:text-xl [&_h3]:font-semibold [&_li]:ml-5 [&_li]:list-disc [&_p]:text-lg [&_strong]:font-semibold [&_strong]:text-foreground"
className="space-y-6 text-lg leading-8 text-foreground/90 [&_a]:font-medium [&_a]:text-primary [&_a]:underline-offset-4 [&_a:hover]:underline [&_blockquote]:border-l [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground [&_h1]:text-3xl [&_h1]:font-bold [&_h2]:border-none [&_h2]:text-2xl [&_h2]:font-semibold [&_h2]:text-primary [&_h3]:text-xl [&_h3]:font-semibold [&_li]:ml-5 [&_li]:list-disc [&_p]:text-lg [&_strong]:font-semibold [&_strong]:text-foreground"
dangerouslySetInnerHTML={{ __html: convertLexicalToHTML({ data: petition.body }) }}
/>
</section>
@ -241,7 +245,12 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
/>
</div>
<div className="rounded-xl border border-border bg-card p-5 text-card-foreground shadow-[0_24px_60px_rgb(122_25_18/0.12)] sm:p-7">
<PetitionForm id="formulario" petition={petition} consentHTML={consentHTML} />
<PetitionForm
id="formulario"
petition={petition}
consentHTML={consentHTML}
facebookPixelId={facebookPixelId ?? undefined}
/>
</div>
</div>
</section>

View file

@ -1,3 +1,4 @@
import { normalizeFacebookPixelId } from '@/lib/facebookPixel'
import { revalidateDocumentById } from '@/utilities/documents'
import type { CollectionConfig } from 'payload'
@ -26,6 +27,14 @@ export const Petition: CollectionConfig<typeof slug> = {
read: () => true,
},
hooks: {
beforeChange: [
({ data }) => {
if (data?.tracking?.facebookPixelId != null) {
data.tracking.facebookPixelId = normalizeFacebookPixelId(data.tracking.facebookPixelId)
}
return data
},
],
afterChange: [
({ doc, previousDoc, operation }) => {
// Admin create view creates the initial draft during render.
@ -92,5 +101,28 @@ export const Petition: CollectionConfig<typeof slug> = {
},
],
},
{
name: 'tracking',
type: 'group',
label: 'Rastreamento / Ads',
fields: [
{
name: 'facebookPixelId',
type: 'text',
label: 'ID do Pixel do Meta (Facebook)',
admin: {
description:
'Cole somente o ID numérico do Events Manager (ex.: 123456789012345), não o snippet HTML completo.',
},
validate: (value: string | null | undefined) => {
if (!value) return true
if (!normalizeFacebookPixelId(String(value))) {
return 'Informe somente o ID numérico do Pixel (5 a 20 dígitos), sem HTML ou script.'
}
return true
},
},
],
},
],
}

View file

@ -0,0 +1,31 @@
'use client'
import Script from 'next/script'
export const MetaPixel = ({ pixelId }: { pixelId: string }) => (
<>
<Script id={`meta-pixel-${pixelId}`} strategy="afterInteractive">
{`
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', '${pixelId}');
fbq('track', 'PageView');
`}
</Script>
<noscript>
<img
alt=""
height="1"
width="1"
style={{ display: 'none' }}
src={`https://www.facebook.com/tr?id=${pixelId}&ev=PageView&noscript=1`}
/>
</noscript>
</>
)

View file

@ -15,6 +15,7 @@ import { useState, useTransition } from 'react'
import { Textarea } from './ui/textarea'
import { Button } from './ui/button'
import { petitionFormSchema, type PetitionFormInput } from '@/lib/schemas/petition-form'
import { trackMetaLead } from '@/lib/facebookPixel'
import { submitPetitionSignature } from '@/app/(frontend)/actions/submitPetitionSignature'
import { NameInput } from './NameInput'
import { EmailInput } from './EmailInput'
@ -32,9 +33,10 @@ interface PetitionFormProps {
id: string
petition: Petition
consentHTML: string
facebookPixelId?: string
}
export const PetitionForm = ({ id, petition, consentHTML }: PetitionFormProps) => {
export const PetitionForm = ({ id, petition, consentHTML, facebookPixelId }: PetitionFormProps) => {
const methods = useForm<PetitionFormInput>({
resolver: zodResolver(petitionFormSchema),
defaultValues: {
@ -63,6 +65,9 @@ export const PetitionForm = ({ id, petition, consentHTML }: PetitionFormProps) =
petitionId: petition.id,
consentId,
})
if (facebookPixelId) {
trackMetaLead(facebookPixelId, petition.title, crypto.randomUUID())
}
methods.reset()
setSignatureNumber(result.signatureNumber)
const detail: SignatureCreatedDetail = {

38
src/lib/facebookPixel.ts Normal file
View file

@ -0,0 +1,38 @@
const FACEBOOK_PIXEL_ID_PATTERN = /^\d{5,20}$/
export function isValidFacebookPixelId(value: string | null | undefined): boolean {
if (value == null || value === '') return false
return FACEBOOK_PIXEL_ID_PATTERN.test(value)
}
export function normalizeFacebookPixelId(value: string | null | undefined): string | null {
if (value == null) return null
const trimmed = value.trim()
if (!trimmed) return null
return isValidFacebookPixelId(trimmed) ? trimmed : null
}
declare global {
interface Window {
fbq?: (
command: 'track',
eventName: string,
params?: Record<string, string>,
options?: { eventID: string },
) => void
}
}
export function trackMetaLead(
pixelId: string,
contentName: string,
eventID: string,
): void {
if (typeof window === 'undefined') return
if (!isValidFacebookPixelId(pixelId)) return
const fbq = window.fbq
if (!fbq) return
fbq('track', 'Lead', { content_name: contentName }, { eventID })
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "petition" ADD COLUMN "tracking_facebook_pixel_id" varchar;
ALTER TABLE "_petition_v" ADD COLUMN "version_tracking_facebook_pixel_id" varchar;`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "petition" DROP COLUMN "tracking_facebook_pixel_id";
ALTER TABLE "_petition_v" DROP COLUMN "version_tracking_facebook_pixel_id";`)
}

View file

@ -16,6 +16,7 @@ import * as migration_20260719_054706_add_privacy_policy_global from './20260719
import * as migration_20260719_054707_seed_onda0_consent_and_privacy from './20260719_054707_seed_onda0_consent_and_privacy';
import * as migration_20260719_061302_add_campaign_user_avatar from './20260719_061302_add_campaign_user_avatar';
import * as migration_20260721_020109_remodel_plazas from './20260721_020109_remodel_plazas';
import * as migration_20260721_133531_add_petition_facebook_pixel_id from './20260721_133531_add_petition_facebook_pixel_id';
export const migrations = [
{
@ -106,6 +107,11 @@ export const migrations = [
{
up: migration_20260721_020109_remodel_plazas.up,
down: migration_20260721_020109_remodel_plazas.down,
name: '20260721_020109_remodel_plazas'
name: '20260721_020109_remodel_plazas',
},
{
up: migration_20260721_133531_add_petition_facebook_pixel_id.up,
down: migration_20260721_133531_add_petition_facebook_pixel_id.down,
name: '20260721_133531_add_petition_facebook_pixel_id'
},
];

View file

@ -799,6 +799,12 @@ export interface Petition {
subtitle?: string | null;
consent: number | Consent;
};
tracking?: {
/**
* Cole somente o ID numérico do Events Manager (ex.: 123456789012345), não o snippet HTML completo.
*/
facebookPixelId?: string | null;
};
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
@ -1543,6 +1549,11 @@ export interface PetitionSelect<T extends boolean = true> {
subtitle?: T;
consent?: T;
};
tracking?:
| T
| {
facebookPixelId?: T;
};
updatedAt?: T;
createdAt?: T;
_status?: T;

View file

@ -0,0 +1,70 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest'
import {
isValidFacebookPixelId,
normalizeFacebookPixelId,
trackMetaLead,
} from '@/lib/facebookPixel'
describe('facebookPixel', () => {
describe('isValidFacebookPixelId', () => {
it('accepts numeric IDs between 5 and 20 digits', () => {
expect(isValidFacebookPixelId('12345')).toBe(true)
expect(isValidFacebookPixelId('123456789012345')).toBe(true)
})
it('rejects empty, non-numeric, and out-of-range values', () => {
expect(isValidFacebookPixelId('')).toBe(false)
expect(isValidFacebookPixelId(null)).toBe(false)
expect(isValidFacebookPixelId(undefined)).toBe(false)
expect(isValidFacebookPixelId('1234')).toBe(false)
expect(isValidFacebookPixelId('123456789012345678901')).toBe(false)
expect(isValidFacebookPixelId('<script>alert(1)</script>')).toBe(false)
expect(isValidFacebookPixelId('fbq("init")')).toBe(false)
})
})
describe('normalizeFacebookPixelId', () => {
it('trims whitespace and returns null for invalid values', () => {
expect(normalizeFacebookPixelId(' 123456789012345 ')).toBe('123456789012345')
expect(normalizeFacebookPixelId('')).toBe(null)
expect(normalizeFacebookPixelId(' ')).toBe(null)
expect(normalizeFacebookPixelId('abc')).toBe(null)
})
})
describe('trackMetaLead', () => {
it('no-ops when fbq is unavailable', () => {
expect(() => trackMetaLead('123456789012345', 'Petição teste', 'event-1')).not.toThrow()
})
it('fires Lead with content_name and eventID when fbq exists', () => {
const fbq = vi.fn()
vi.stubGlobal('window', { fbq })
trackMetaLead('123456789012345', 'Petição teste', 'event-1')
expect(fbq).toHaveBeenCalledWith(
'track',
'Lead',
{ content_name: 'Petição teste' },
{ eventID: 'event-1' },
)
vi.unstubAllGlobals()
})
it('no-ops for invalid pixel IDs even when fbq exists', () => {
const fbq = vi.fn()
vi.stubGlobal('window', { fbq })
trackMetaLead('not-a-pixel', 'Petição teste', 'event-1')
expect(fbq).not.toHaveBeenCalled()
vi.unstubAllGlobals()
})
})
})