feat(S10): pixel do Meta na página pública de campanha (campo admin em SiteSettings + PageView na home + Lead no sucesso da captura)
All checks were successful
CI (PR) / checks (pull_request) Successful in 19m11s
PR Ready + auto-merge / ready-automerge (pull_request) Successful in 19m30s
Promote plan-issue Related on main merge / plan-issue-ready (pull_request) Has been skipped
Main merge flips claimed issues to done + in-prod / issue-done (pull_request) Successful in 8m12s

Campo tracking.facebookPixelId no global SiteSettings (mesma validação/normalização do precedente Petition, regra centralizada em facebookPixel.ts); home (grupo (home)) injeta o base code + PageView quando o ID está configurado (fail-closed) e o CampaignNewsletterForm dispara exatamente um Lead no sucesso do S9. Migration 20260819_213947_add_site_settings_facebook_pixel_id. Closes #94
This commit is contained in:
Francisco Solla 2026-08-19 19:12:29 -03:00
parent 8c734ba055
commit e339ceec9f
19 changed files with 14100 additions and 16 deletions

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**S10 — Pixel do Meta (Facebook) na página pública de campanha (2026-08-19):** o time de campanha paga pode configurar o ID numérico do Pixel (Dataset do Events Manager) no admin — global `SiteSettings` → group "Rastreamento / Ads" → "ID do Pixel do Meta (Facebook)", mesma validação/normalização do precedente dos abaixo-assinados (5–20 dígitos, sem HTML/JS; regras agora vivem num único dono em `facebookPixel.ts`, com o `Petition` refatorado para o helper compartilhado). Com o ID configurado, a home pública de campanha (`/`) carrega o base code + `PageView` do Meta (injeção no layout do grupo `(home)` via `next/script`, escopada só à página de campanha — nada de PageView vazando para notícias/etc.); sem ID, nada é carregado (fail-closed). A conclusão da captura de novidades (S9) dispara exatamente um `Lead` (`content_name` "novidades-da-campanha", `eventID` UUID) no padrão do `PetitionForm` — nunca no page load. Leitura via `getCachedGlobal('site-settings')` (ISR sob a tag `global_site-settings`; a edição do admin já revalida a home). Migração `20260819_213947_add_site_settings_facebook_pixel_id` (colunas `tracking_facebook_pixel_id` + versão, sem backfill). Fora da fatia (gatilhos herdados do plano da petição): CAPI/Advanced Matching, dedup `eventID`, fila/retry de `Lead` e cookie banner/CMP. E2e novo `campaignHomePixel` (script `#meta-pixel-*` presente só com ID configurado, CDN stubada; `mode: serial` por precedente).
**S9 — captura de novidades da campanha na home pública (2026-08-19):** a home de campanha (`jorgesolla1313.com.br`) ganhou a seção "Receba as novidades da campanha" logo acima do rodapé — nome + WhatsApp obrigatórios, e-mail/estado/cidade/comentário opcionais, toggle pré-selecionado "Quero fazer parte do time" (frequência + grupos de WhatsApp vs. comunicações esporádicas) e confirmação in-place com atalho para as bandeiras. O CTA secundário do hero virou o atalho da seção (âncora `#novidades`, scroll suave no container da home; "Conhecer bandeiras" sai, as bandeiras seguem no rodapé e no estado de sucesso). O registro é `Contact` + `Subscription` em transação com consentimento NOVO por chave estável `campanha-novidades` (fail-closed: sem o texto aprovado no admin, o form recusa a captura — nada é gravado); a escolha do toggle sobrevive como `subscription.campaignLevel` (`time|esporadico`, coluna própria na lista do admin). Migração `20260819_192605_add_campaign_newsletter_capture` (DROP NOT NULL em `contact.state` — estado opcional no form, ficha staff via zod segue exigindo — + coluna `campaign_level` com enum). Precedente `submitWhatsapp` preservado; action nova usa `withPayloadTransaction` (o dono do concern de transação, não o hand-roll gêmeo — `submitWhatsapp`/`submitPetitionSignature` ficam como débito de alinhamento). S10 (pixel Meta) dispara `Lead` na costura do sucesso do form. Chave de consentimento pendente de aprovação jurídica antes da ativação.
**S7** — polimento dos cards da seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br), look editorial e direto no lugar do "card de app": cantos retos (**sem `rounded-xl` no card e `rounded-lg` na capa**), capa de **borda a borda** (o `p-3` saiu do contêiner do card e foi para o bloco de texto — título/subtítulo/meta continuam respirando `p-3` das bordas; `mt-auto` da meta e `overflow-hidden` do zoom hover intactos) e, por decisão do humano posterior ao gate, **sem borda (`border --campaign-line`) e sem sombra** (`shadow-sm`/`hover:shadow-md`) — o card branco lê contra a banda do S5 só pela forma. **Chips de badge removidos de todos os cards** (decisão B do gate): nem os tipos de post do site (`POST_TYPE_BADGE_LABELS` deletado de `src/utilities/posts.ts`, junto com o describe no unit test) nem as origens externas YouTube/Instagram (sinalização externa segue por `target="_blank"`/`rel="noopener"`); `badgeLabel` saiu do tipo `CampaignContentCardData`. Superfície única no componente dono (`CampaignContentCard.tsx`) — bento e carrossel herdam; `ContentShareButton` (S4), banda do S5, carrossel e fail-closed da seção intocados. Zero migration/schema/Consent. e2e do describe da seção atualizados para o novo estado: loop de badges → ausência (`toHaveCount(0)` para os 4 tipos + YouTube/Instagram), pins CSS do card (`border-radius`/`border-width`/`box-shadow` + raio da capa) e pin geométrico da capa de borda a borda (boundingBox do card × capa). Verificação: gate:fast verde, unit 2415 + int 791 verdes, e2e frontend 19/19 local, format/knip/cycles limpos. Plano de implementação: `docs/plans/cards-acompanhe-de-perto-polimento-impl.md`.

View file

@ -0,0 +1 @@
**S10 — Pixel do Meta (Facebook) na página pública de campanha (2026-08-19):** o time de campanha paga pode configurar o ID numérico do Pixel (Dataset do Events Manager) no admin — global `SiteSettings` → group "Rastreamento / Ads" → "ID do Pixel do Meta (Facebook)", mesma validação/normalização do precedente dos abaixo-assinados (5–20 dígitos, sem HTML/JS; regras agora vivem num único dono em `facebookPixel.ts`, com o `Petition` refatorado para o helper compartilhado). Com o ID configurado, a home pública de campanha (`/`) carrega o base code + `PageView` do Meta (injeção no layout do grupo `(home)` via `next/script`, escopada só à página de campanha — nada de PageView vazando para notícias/etc.); sem ID, nada é carregado (fail-closed). A conclusão da captura de novidades (S9) dispara exatamente um `Lead` (`content_name` "novidades-da-campanha", `eventID` UUID) no padrão do `PetitionForm` — nunca no page load. Leitura via `getCachedGlobal('site-settings')` (ISR sob a tag `global_site-settings`; a edição do admin já revalida a home). Migração `20260819_213947_add_site_settings_facebook_pixel_id` (colunas `tracking_facebook_pixel_id` + versão, sem backfill). Fora da fatia (gatilhos herdados do plano da petição): CAPI/Advanced Matching, dedup `eventID`, fila/retry de `Lead` e cookie banner/CMP. E2e novo `campaignHomePixel` (script `#meta-pixel-*` presente só com ID configurado, CDN stubada; `mode: serial` por precedente).

View file

@ -0,0 +1,87 @@
# Impl: Pixel do Meta (Facebook) na página pública de campanha
Status: rascunho
Atualizado em: 2026-08-19
Issue: #94
Intenção: docs/plans/pixel-meta-pagina-campanha.md
Appetite restante: herdado (~0,5–1 dia eng)
## Leitura da intenção
- **Outcome:** o time de campanha paga configura no admin um campo opcional "ID do Pixel do Meta" nas configurações do site; a home pública de campanha (`/`) passa a carregar o base code + `PageView` do Meta quando o ID está presente e válido; a conclusão da captura de novidades (S9) dispara exatamente um `Lead`. Sem ID configurado, nada é carregado (fail-closed, zero impacto).
- **O que NÃO negociar:** persistir **só o ID numérico** (5–20 dígitos, sem HTML/JS colado — XSS); `Lead` **somente no sucesso** da captura, nunca no page load; Pixel **só na página pública de campanha** (não vazar PageViews de notícias/etc.); sem env var obrigatória; sem CAPI/Advanced Matching, cookie banner/CMP e dedup `eventID` nesta fatia (herda gatilhos do plano da petição); cache da home revalidado pela edição do admin.
- **O que reavaliar:** a hipótese da intenção dizia "home `src/app/(frontend)/(home)/page.tsx` (injeção do `<MetaPixel />`)" — a injeção pode morar no **layout** do grupo `(home)` (que cobre exatamente a única rota do grupo) em vez da página; e "form de captura do S9" é `CampaignNewsletterForm` (cliente) — a seam do `Lead` é o callback `onSubmit()` já previsto no impl plan do S9. A leitura do global na home é compatível com o caching: `getCachedGlobal('site-settings')` é `unstable_cache` sob a tag `global_site-settings`, já revalidada pelo `afterChange` existente (a home passa a ser ISR nessa tag — mesmo padrão de `SiteHeader`/`socialLinks`).
## Abordagem recomendada
```mermaid
flowchart LR
Admin["SiteSettings global<br/>tracking.facebookPixelId (group Rastreamento/Ads)"]
Home["(home)/layout.tsx<br/>getCampaignHomeMetaPixelId"]
Pixel["MetaPixel client<br/>next/script + fbq init/PageView"]
Section["CampaignNewsletterSection<br/>getCampaignHomeMetaPixelId"]
Form["CampaignNewsletterForm<br/>pixelId prop"]
Action["submitCampaignNewsletter"]
Lead["trackMetaLead<br/>content_name novidades-da-campanha"]
Meta["Meta Events Manager"]
Admin --> Home
Home --> Pixel
Pixel --> Meta
Section --> Form
Form --> Action
Action -->|sucesso--> Lead
Lead --> Meta
```
**Opções consideradas:** A (campo no global `SiteSettings` + injeção no layout do grupo `(home)`) · B (novo global de rastreamento dedicado) · C (injeção no `page.tsx` em vez do layout) · D (validate compartilhado em `facebookPixel.ts` vs. duplicado inline)
**Recomendação:** **A+D** — porque o dono existe com revalidação pronta (`afterChange` → `revalidateGlobal('site-settings')`, apontado pelo plano da petição como destino natural de um Pixel global); o layout do grupo `(home)` cobre **exatamente** a página pública de campanha (única rota do grupo), escopando o Pixel ao aceite e impedindo vazamento; e as regras de ID do Pixel passam a viver num único módulo profundo (`facebookPixel.ts` já é o dono), com o validate do `Petition` migrando para o helper compartilhado (edit owner, sem gêmeo).
**Rejeitadas:**
- **B (global de rastreamento dedicado):** twin do `SiteSettings` — o dono já existe, o plano da petição já o apontava, e um segundo global seria cerimônia sem volatilidade.
- **C (injeção no `page.tsx`):** funcionaria (única rota do grupo), mas o layout é o lugar que declara a superfície "página pública de campanha" (`data-theme="campaign-site"`) — se o grupo ganhar outra rota pública de campanha, o Pixel a cobre por contrato; e `CampaignNewsletterSection` continuaria precisando do pixelId de qualquer forma (2 call sites da mesma leitura).
- **D-duplicado (validate inline copiado do `Petition`):** a repetição do bloco validação/label/description é exatamente o tipo de drift que o precedente da petição adiou com gatilho ("revisitar quando o mesmo ID for colado em ≥3 petições") — o gatilho foi puxado por produto; agora as regras devem ter dono único.
### Componentes / mudanças
- **`src/lib/facebookPixel.ts`** (edit owner): adicionar `validateFacebookPixelId(value: string | null | undefined): true | string` — envolve `normalizeFacebookPixelId` devolvendo a mensagem pt-BR do admin ("Informe somente o ID numérico do Pixel (5 a 20 dígitos), sem HTML ou script.") quando inválido; `true` quando vazio ou válido (campo opcional). Módulo já importado por client — helper puro, sem `server-only`.
- **`src/collections/Petition.ts`** (edit owner): o `validate` inline do campo `tracking.facebookPixelId` passa a chamar `validateFacebookPixelId` (remove a duplicação; comportamento idêntico). `beforeChange` e campo inalterados.
- **`src/globals/SiteSettings.ts`** (edit owner): novo group `tracking` (label "Rastreamento / Ads", mesmo do `Petition`) com o campo `facebookPixelId` — `type: 'text'`, opcional, label "ID do Pixel do Meta (Facebook)", `admin.description` idêntica ao precedente, `validate: validateFacebookPixelId`; novo hook `beforeChange` normalizando `data.tracking.facebookPixelId` (mesmo padrão do `Petition`). `afterChange`/access inalterados (`update: payloadAdminOnly` já cobre).
- **`src/utilities/campaignHomeTracking.ts`** (novo, `server-only`): `getCampaignHomeMetaPixelId(): Promise<string | null>` — `getCachedGlobal('site-settings')()` → `normalizeFacebookPixelId(settings.tracking?.facebookPixelId)`. Esconde a forma do global + normalização; 2 call sites (layout + section); não é pass-through — é a fronteira do tracking da home.
- **`src/app/(frontend)/(home)/layout.tsx`** (edit owner): `const pixelId = await getCampaignHomeMetaPixelId()`; `{pixelId ? <MetaPixel pixelId={pixelId} /> : null}` dentro do container do grupo. Leitura cacheada sob `global_site-settings` — a edição do admin revalida a home (ISR), guardrail da intenção.
- **`src/app/(frontend)/(home)/CampaignNewsletterSection.tsx`** (edit owner): lê `getCampaignHomeMetaPixelId()` e passa `pixelId={pixelId ?? undefined}` ao `CampaignNewsletterCapture`.
- **`src/components/CampaignNewsletterForm.tsx`** (edit owner): `CampaignNewsletterForm` ganha prop opcional `pixelId?: string`; no sucesso de `submitCampaignNewsletter` (após `await`, antes do swap `onSubmit()`), `if (pixelId) trackMetaLead(pixelId, NEWSLETTER_LEAD_CONTENT_NAME, crypto.randomUUID())` — padrão exato do `PetitionForm` (linha 61-63). Constante de módulo `NEWSLETTER_LEAD_CONTENT_NAME = 'novidades-da-campanha'` (valor estável visível no Events Manager, mesmo padrão de `content_name` da petição). `CampaignNewsletterCapture` repassa a prop.
- **Migration:** `pnpm migrate:create add_site_settings_facebook_pixel_id` — colunas `tracking_facebook_pixel_id` (tabela `site_settings`) + `version_tracking_facebook_pixel_id` (tabela `site_settings_versions`); sem backfill, sem dado novo. Inspcionar SQL gerado antes de aplicar.
- **Access / Consent:** nenhum — campo herdado do access do global (`update: payloadAdminOnly`), sem PII nova, sem chave de Consent (mesmo precedente da petição: transparência de cookies fica no `/privacidade`, dependência suave herdada).
- **UI:** Impeccable A — N/A (campo único no admin replicando o precedente aceito + script invisível ao visitante; zero superfície pública nova).
### Dados → forma (não se aplica)
O dado (PageView/Lead) vive no Meta Events Manager; o app só emite o evento. Nenhuma forma de apresentação nova.
## Fases verificáveis
1. **Tracer / schema+server** — migration + `SiteSettings` (group/campo/beforeChange) + `validateFacebookPixelId` em `facebookPixel.ts` + refactor `Petition`; `pnpm migrate` local (teqo e teqo_test) + `pnpm generate:types`; unit do `validateFacebookPixelId` em `tests/unit/facebookPixel.unit.spec.ts`; `pnpm exec tsc --noEmit`.
2. **UI/leitura** — `campaignHomeTracking.ts` + layout com `<MetaPixel>` + section/form com a seam do `Lead`; e2e novo `tests/e2e/campaignHomePixel.e2e.spec.ts` (configura o global via REST admin com ID válido fixo, carrega a home com `?e2e=`, assere o script `#meta-pixel-<id>`, restaura o global para null no cleanup — idempotente, chave própria, paralelo-safe no DB de teste); spec do S9 inalterado (sem pixel configurado, nada renderiza).
3. **Gates** — `pnpm gate:fast`; `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm build` (com `S3_*` dummy e `generate:importmap` por via das dúvidas); changelog `docs/changelog/2026-08-19-s10.md` + `pnpm changelog:build`; push e PR via `pnpm push` (base `main` no Forgejo, `Closes #94`).
## Rabbit holes / Não escopo (engenharia)
- Não mexer em `submitCampaignNewsletter` (action intacta — o `Lead` é puramente client-side, mesmo padrão da petição).
- Não adicionar fila/retry de `Lead` quando `window.fbq` não carregou (race submit rápido vs. `afterInteractive`) — **herda o gatilho** do plano da petição (revisitar quando smoke com Meta Pixel Helper mostrar `Lead` ausente em produção).
- Não usar o ID do Pixel em outras páginas/superfícies do site.
- Não tocar CSP, env vars, `@types` de terceiros para `fbq` (tipagem local já existe).
- Não indexar/unicidade do campo (mesmo ID pode aparecer em petição e home — aceite da intenção).
## Riscos e mitigação
- **Home ISR sob `global_site-settings`:** a primeira render pós-save usa o valor novo (tag busta a cache); o e2e usa o truque do query param (`?e2e=`) para bypass de fetch cache — precedente do spec S9. Nenhuma página fora do grupo `(home)` lê o pixel.
- **Pins de geometria do e2e `frontend`:** `MetaPixel` só renderiza com ID configurado; o spec existente roda sem pixel → DOM inalterado (script + `noscript` `display:none` não deslocam layout). Sem re-pin esperado.
- **Global compartilhado no e2e:** o cleanup restaura `tracking.facebookPixelId` para `null` (update via REST); idempotente por run, DB de teste dedicado por worktree.
- **Concorrência de migration:** rebase em `forgejo/main` já feito; criar a migration após; cadeia validada por `pnpm migrate` + `test:int` no PR.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (campo admin com mesma validação; PageView na home com fail-closed; exatamente um `Lead` no sucesso; cache revalidado)
- [x] Invariantes AGENTS/engineering-standards (sem HTML colado; edit owner sem gêmeos; copy pt-BR/identifiers EN; `unstable_cache`/tag existentes)
- [ ] Testes de domínio previstos: unit `validateFacebookPixelId` (novo helper) + e2e de injeção do base code condicional

View file

@ -5,8 +5,11 @@ import { CampaignNewsletterCapture } from '@/components/CampaignNewsletterForm'
* above the footer (decision 2026-08-19): visitor leaves name + WhatsApp to
* follow the campaign, choosing the engagement level. The hero CTA anchors
* here (`#novidades`, smooth scroll on the campaign-site container).
*
* S10 — passes the site-level Meta pixel to the form so a successful capture
* fires exactly one `Lead` (the seam lives in the client form).
*/
export const CampaignNewsletterSection = () => (
export const CampaignNewsletterSection = ({ pixelId }: { pixelId: string | null }) => (
<section
id="novidades"
aria-labelledby="novidades-title"
@ -30,7 +33,7 @@ export const CampaignNewsletterSection = () => (
</p>
</div>
<div className="mt-8">
<CampaignNewsletterCapture />
<CampaignNewsletterCapture pixelId={pixelId ?? undefined} />
</div>
</div>
</section>

View file

@ -1,9 +1,15 @@
import { MetaPixel } from '@/components/MetaPixel'
import { getCampaignHomeMetaPixelId } from '@/utilities/campaignHomeTracking'
import React from 'react'
import '../styles.css'
export default async function RootLayout(props: { children: React.ReactNode }) {
const { children } = props
// S10 — the Meta base code + PageView for the campaign page. The read is
// cached under `global_site-settings` and fails closed (no ID → nothing).
const pixelId = await getCampaignHomeMetaPixelId()
// A root `(frontend)` trava o documento com `overflow-hidden`, então a home
// é dona do container de scroll interno. `data-theme="campaign-site"` aplica
// a paleta da campanha (vermelho PT + amarelo CTA) aos tokens `--pt-*`.
@ -12,6 +18,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
data-theme="campaign-site"
className="relative h-dvh w-full scroll-smooth overflow-y-auto bg-(--campaign-cream) text-(--campaign-ink) motion-reduce:scroll-auto"
>
{pixelId ? <MetaPixel pixelId={pixelId} /> : null}
{children}
</div>
)

View file

@ -5,6 +5,7 @@ import { CampaignFooter } from '@/components/CampaignFooter'
import { CampaignHero } from '@/components/CampaignHero'
import { CampaignProblemCard } from '@/components/CampaignProblemCard'
import { CampaignStorySection } from '@/components/CampaignStorySection'
import { getCampaignHomeMetaPixelId } from '@/utilities/campaignHomeTracking'
import type { ReactNode } from 'react'
import { CampaignNewsletterSection } from './CampaignNewsletterSection'
@ -110,6 +111,9 @@ const proofItems = [
]
export default async function HomePage() {
// S10 — site-level Meta pixel for the campaign home (cached read, fail-closed).
const pixelId = await getCampaignHomeMetaPixelId()
return (
<>
<main className="w-full bg-white text-black">
@ -231,7 +235,7 @@ export default async function HomePage() {
</section>
<CampaignStorySection />
<CampaignNewsletterSection />
<CampaignNewsletterSection pixelId={pixelId} />
</main>
<CampaignFooter />

View file

@ -1,4 +1,4 @@
import { normalizeFacebookPixelId } from '@/lib/facebookPixel'
import { normalizeFacebookPixelId, validateFacebookPixelId } from '@/lib/facebookPixel'
import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { revalidateDocumentById } from '@/utilities/documents'
import type { CollectionConfig } from 'payload'
@ -119,13 +119,7 @@ export const Petition: CollectionConfig<typeof slug> = {
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
},
validate: validateFacebookPixelId,
},
],
},

View file

@ -10,13 +10,18 @@ import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import { Field, FieldDescription, FieldError, FieldGroup } from '@/components/ui/field'
import { Textarea } from '@/components/ui/textarea'
import { trackMetaLead } from '@/lib/facebookPixel'
import { CampaignNewsletterInput, campaignNewsletterSchema } from '@/lib/schemas/campaignNewsletter'
import { zodResolver } from '@hookform/resolvers/zod'
import { CheckIcon } from 'lucide-react'
import { useState, useTransition } from 'react'
import { FormProvider, SubmitHandler, useForm, useWatch } from 'react-hook-form'
/** S10 — stable `content_name` for the `Lead` fired on a successful capture. */
const NEWSLETTER_LEAD_CONTENT_NAME = 'novidades-da-campanha'
interface CampaignNewsletterFormProps {
pixelId?: string
onSubmit: () => void
}
@ -24,9 +29,9 @@ interface CampaignNewsletterFormProps {
* S9 — public capture of campaign "novidades" on the home: name + WhatsApp
* required, email/state/city/comment optional, engagement-level toggle
* pre-selected ("Quero fazer parte do time"). Success swaps the form for an
* in-place confirmation (the seam where S10 will fire `Lead`).
* in-place confirmation (the seam where S10 fires `Lead`).
*/
const CampaignNewsletterForm = ({ onSubmit }: CampaignNewsletterFormProps) => {
const CampaignNewsletterForm = ({ pixelId, onSubmit }: CampaignNewsletterFormProps) => {
const methods = useForm<CampaignNewsletterInput>({
resolver: zodResolver(campaignNewsletterSchema),
defaultValues: {
@ -46,6 +51,11 @@ const CampaignNewsletterForm = ({ onSubmit }: CampaignNewsletterFormProps) => {
startTransition(async () => {
try {
await submitCampaignNewsletter(input)
// S10 — exactly one `Lead` per successful capture, never on page load
// (petition precedent). `trackMetaLead` no-ops safely without fbq.
if (pixelId) {
trackMetaLead(pixelId, NEWSLETTER_LEAD_CONTENT_NAME, crypto.randomUUID())
}
methods.reset()
onSubmit()
} catch (error) {
@ -170,12 +180,12 @@ const CampaignNewsletterSuccess = () => (
)
/** S9 — wrapper owning the form ↔ success swap on the home section. */
export const CampaignNewsletterCapture = () => {
export const CampaignNewsletterCapture = ({ pixelId }: { pixelId?: string }) => {
const [submitted, setSubmitted] = useState(false)
return submitted ? (
<CampaignNewsletterSuccess />
) : (
<CampaignNewsletterForm onSubmit={() => setSubmitted(true)} />
<CampaignNewsletterForm pixelId={pixelId} onSubmit={() => setSubmitted(true)} />
)
}

View file

@ -1,3 +1,4 @@
import { normalizeFacebookPixelId, validateFacebookPixelId } from '@/lib/facebookPixel'
import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { revalidateGlobal } from '@/utilities/globals'
import type { GlobalConfig } from 'payload'
@ -19,6 +20,14 @@ export const SiteSettings: GlobalConfig = {
update: payloadAdminOnly,
},
hooks: {
beforeChange: [
({ data }) => {
if (data?.tracking?.facebookPixelId != null) {
data.tracking.facebookPixelId = normalizeFacebookPixelId(data.tracking.facebookPixelId)
}
return data
},
],
afterChange: [revalidate],
},
fields: [
@ -82,6 +91,23 @@ export const SiteSettings: GlobalConfig = {
},
],
},
{
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: validateFacebookPixelId,
},
],
},
],
versions: {
drafts: {

View file

@ -12,6 +12,16 @@ export function normalizeFacebookPixelId(value: string | null | undefined): stri
return isValidFacebookPixelId(trimmed) ? trimmed : null
}
// Payload field validation shared by every admin surface that takes a Pixel ID
// (petition + site settings): optional field, digits only when present.
export function validateFacebookPixelId(value: string | null | undefined): true | string {
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
}
declare global {
interface Window {
fbq?: (

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, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "site_settings" ADD COLUMN "tracking_facebook_pixel_id" varchar;
ALTER TABLE "_site_settings_v" ADD COLUMN "version_tracking_facebook_pixel_id" varchar;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "site_settings" DROP COLUMN "tracking_facebook_pixel_id";
ALTER TABLE "_site_settings_v" DROP COLUMN "version_tracking_facebook_pixel_id";`)
}

View file

@ -57,6 +57,7 @@ import * as migration_20260811_234822_add_state_deputy_ballot_name from './20260
import * as migration_20260818_111748_add_social_feed_settings from './20260818_111748_add_social_feed_settings';
import * as migration_20260818_192948_add_instagram_social_feed_fields from './20260818_192948_add_instagram_social_feed_fields';
import * as migration_20260819_192605_add_campaign_newsletter_capture from './20260819_192605_add_campaign_newsletter_capture';
import * as migration_20260819_213947_add_site_settings_facebook_pixel_id from './20260819_213947_add_site_settings_facebook_pixel_id';
export const migrations = [
{
@ -352,6 +353,11 @@ export const migrations = [
{
up: migration_20260819_192605_add_campaign_newsletter_capture.up,
down: migration_20260819_192605_add_campaign_newsletter_capture.down,
name: '20260819_192605_add_campaign_newsletter_capture'
name: '20260819_192605_add_campaign_newsletter_capture',
},
{
up: migration_20260819_213947_add_site_settings_facebook_pixel_id.up,
down: migration_20260819_213947_add_site_settings_facebook_pixel_id.down,
name: '20260819_213947_add_site_settings_facebook_pixel_id'
},
];

View file

@ -2278,6 +2278,12 @@ export interface SiteSetting {
id?: string | null;
}[]
| null;
tracking?: {
/**
* Cole somente o ID numérico do Events Manager (ex.: 123456789012345), não o snippet HTML completo.
*/
facebookPixelId?: string | null;
};
_status?: ('draft' | 'published') | null;
updatedAt?: string | null;
createdAt?: string | null;
@ -2463,6 +2469,11 @@ export interface SiteSettingsSelect<T extends boolean = true> {
enabled?: T;
id?: T;
};
tracking?:
| T
| {
facebookPixelId?: T;
};
_status?: T;
updatedAt?: T;
createdAt?: T;

View file

@ -0,0 +1,15 @@
import 'server-only'
import { normalizeFacebookPixelId } from '@/lib/facebookPixel'
import { getCachedGlobal } from '@/utilities/globalReads'
/**
* S10 — the Meta pixel configured in `SiteSettings.tracking` for the public
* campaign page. Cached under `global_site-settings`, so an admin edit of the
* global already revalidates the home (the `afterChange` hook busts the tag).
* Fail-closed: no configured ID → null → nothing renders on the page.
*/
export const getCampaignHomeMetaPixelId = async (): Promise<string | null> => {
const settings = await getCachedGlobal('site-settings')()
return normalizeFacebookPixelId(settings.tracking?.facebookPixelId)
}

View file

@ -0,0 +1,91 @@
import type { APIRequestContext } from '@playwright/test'
import { seedTestUser, testUser } from '../helpers/seedUser'
import { expect, test } from './fixtures/e2eTest'
/**
* S10 — the Meta pixel on the public campaign page: configured through
* `SiteSettings.tracking.facebookPixelId` (admin REST), the home renders the
* base code (inline `next/script` + `fbq` init) and nothing at all without a
* configured ID (fail-closed). The external `fbevents.js` fetch is stubbed so
* the spec never depends on the Meta CDN; the base code itself is inline.
* Serial mode (precedent `campaignNearestMunicipality`): the two tests share
* one worker — a parallel split would race two `seedTestUser` beforeAlls.
*/
test.describe.configure({ mode: 'serial' })
test.describe('Campaign home Meta pixel', () => {
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
const runSuffix = Date.now()
// Valid 15-digit numeric ID (same class the admin validation accepts).
const PIXEL_ID = '123456789012345'
const adminHeaders = async (request: APIRequestContext): Promise<Record<string, string>> => {
const login = await request.post(`${baseURL}/api/users/login`, {
data: { email: testUser.email, password: testUser.password },
})
expect(login.ok()).toBeTruthy()
const { token } = await login.json()
return { cookie: `payload-token=${token}` }
}
// `_status: 'published'` publishes through the global's draft pipeline, same
// pattern the frontend spec uses for versioned documents.
const setSitePixel = async (
request: APIRequestContext,
headers: Record<string, string>,
pixelId: string | null,
) => {
const response = await request.post(`${baseURL}/api/globals/site-settings`, {
headers,
data: { tracking: { facebookPixelId: pixelId }, _status: 'published' },
})
expect(response.ok()).toBeTruthy()
}
test.beforeAll(async ({ request }) => {
await seedTestUser()
// Self-healing lifecycle (S9 precedent): an aborted run could leave the
// pixel behind and poison the fail-closed assertion — purge first.
const headers = await adminHeaders(request)
await setSitePixel(request, headers, null)
})
test.afterAll(async ({ request }) => {
const headers = await adminHeaders(request).catch(() => undefined)
if (headers) {
await setSitePixel(request, headers, null)
}
})
test('renders nothing without a configured pixel ID', async ({ page }) => {
// Fresh load (unique query param bypasses the ISR fetch cache, same trick
// as the content-section specs); the app ignores unknown params.
await page.goto(`/?e2e=${runSuffix}-none`)
await expect(page.locator('script[id^="meta-pixel-"]')).toHaveCount(0)
})
test('renders the Meta base code when the site settings carry a valid ID', async ({
page,
request,
}) => {
const headers = await adminHeaders(request)
await setSitePixel(request, headers, PIXEL_ID)
// The inline base code always runs; only the external fbevents.js fetch is
// stubbed, so the assertion never depends on the Meta CDN.
await page.route('https://connect.facebook.net/**', (route) =>
route.fulfill({ body: '', contentType: 'application/javascript' }),
)
await page.goto(`/?e2e=${runSuffix}-set`)
const pixelScript = page.locator(`script#meta-pixel-${PIXEL_ID}`)
await expect(pixelScript).toBeAttached()
// The base code defines `window.fbq` synchronously (queue-based no-op
// until fbevents.js processes it) — the seam `trackMetaLead` uses.
await expect
.poll(() => page.evaluate(() => typeof (window as { fbq?: unknown }).fbq === 'function'))
.toBe(true)
})
})

View file

@ -29,6 +29,12 @@ vi.mock('@/components/CampaignNewsletterForm', () => ({
CampaignNewsletterCapture: () => null,
}))
// S10 — the section reads the site-settings global (server-only, Payload DB);
// the unit env has no database, and the pixel behavior is e2e-covered.
vi.mock('@/utilities/campaignHomeTracking', () => ({
getCampaignHomeMetaPixelId: async () => null,
}))
afterEach(cleanup)
describe('Campaign home', () => {

View file

@ -429,6 +429,9 @@ describe('src/utilities top-level is pinned', () => {
'campaignFormFields.ts',
'campaignGeolocation.ts',
'campaignGoals.ts',
// S10 — campaign-home Meta pixel read (site-settings global, fail-closed);
// shared by the home layout (PageView) and the page (Lead prop).
'campaignHomeTracking.ts',
'campaignInvite.ts',
'campaignInviteCreation.ts',
'campaignInviteOrigin.ts',

View file

@ -6,6 +6,7 @@ import {
isValidFacebookPixelId,
normalizeFacebookPixelId,
trackMetaLead,
validateFacebookPixelId,
} from '@/lib/facebookPixel'
describe('facebookPixel', () => {
@ -35,6 +36,24 @@ describe('facebookPixel', () => {
})
})
describe('validateFacebookPixelId', () => {
it('accepts empty values (optional field)', () => {
expect(validateFacebookPixelId('')).toBe(true)
expect(validateFacebookPixelId(null)).toBe(true)
expect(validateFacebookPixelId(undefined)).toBe(true)
})
it('accepts numeric IDs between 5 and 20 digits', () => {
expect(validateFacebookPixelId('123456789012345')).toBe(true)
})
it('rejects non-numeric and out-of-range values with the admin message', () => {
const message = 'Informe somente o ID numérico do Pixel (5 a 20 dígitos), sem HTML ou script.'
expect(validateFacebookPixelId('1234')).toBe(message)
expect(validateFacebookPixelId('<script>alert(1)</script>')).toBe(message)
})
})
describe('trackMetaLead', () => {
it('no-ops when fbq is unavailable', () => {
expect(() => trackMetaLead('123456789012345', 'Petição teste', 'event-1')).not.toThrow()