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:
parent
b7cfc609e1
commit
be1106f887
12 changed files with 10633 additions and 21 deletions
|
|
@ -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 < 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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
31
src/components/MetaPixel.tsx
Normal file
31
src/components/MetaPixel.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
|
|
@ -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
38
src/lib/facebookPixel.ts
Normal 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 })
|
||||
}
|
||||
10381
src/migrations/20260721_133531_add_petition_facebook_pixel_id.json
Normal file
10381
src/migrations/20260721_133531_add_petition_facebook_pixel_id.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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";`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
70
tests/unit/facebookPixel.unit.spec.ts
Normal file
70
tests/unit/facebookPixel.unit.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue