mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
feat(S10): pixel do Meta na página pública de campanha (campo admin em SiteSettings + PageView na home + Lead no sucesso da captura)
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:
parent
8c734ba055
commit
e339ceec9f
19 changed files with 14100 additions and 16 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
1
docs/changelog/2026-08-19-s10.md
Normal file
1
docs/changelog/2026-08-19-s10.md
Normal 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).
|
||||
87
docs/plans/pixel-meta-pagina-campanha-impl.md
Normal file
87
docs/plans/pixel-meta-pagina-campanha-impl.md
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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?: (
|
||||
|
|
|
|||
13776
src/migrations/20260819_213947_add_site_settings_facebook_pixel_id.json
Normal file
13776
src/migrations/20260819_213947_add_site_settings_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, 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";`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
15
src/utilities/campaignHomeTracking.ts
Normal file
15
src/utilities/campaignHomeTracking.ts
Normal 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)
|
||||
}
|
||||
91
tests/e2e/campaignHomePixel.e2e.spec.ts
Normal file
91
tests/e2e/campaignHomePixel.e2e.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue