feat(S9): captura de novidades da campanha na home pública (form + CTA do hero) #104

Merged
fsolla merged 1 commit from S9-captura-de-novidades-da-campanha-na-home-publica-form-cta into main 2026-08-19 20:45:31 +00:00
28 changed files with 14731 additions and 49 deletions

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**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`.
**OPS69** — o admin do Payload abria **em branco em produção** (P0, 2026-08-19): o `importMap` commitado (`src/app/(payload)/admin/importMap.js`) ficou sem a entrada `@payloadcms/storage-s3/client#S3ClientUploadHandler` — o commit OPS52 (`dabc00f4`) removeu o handler do Vercel Blob e **não** adicionou o do S3, porque o `pnpm generate:importmap` rodou **sem** as envs `S3_*`: com o plugin `s3Storage` condicional, sem as envs o importMap sai consistente (dev/test, storage local) e com elas (produção, Garage) o admin precisa do handler e o montava → `getFromImportMap: PayloadComponent not found in importMap` → login nunca renderizava. Fix mínimo (opção A do plano, aprovado no gate): regenerar o importMap com as 4 `S3_*` dummy inline (a mecânica do CLI: `iterateConfig` sem DB; `loadEnvConfig` do `@next/env` não sobrescreve envs já no processo) → diff de **exatamente +2 linhas** (import + entrada do `S3ClientUploadHandler`), nenhum outro drift. Verificação: smoke headless com S3 ativo no dev local — `/admin/login` renderiza (6 inputs, formulário visível, **zero erros de console**) vs. o branco reproduzido no diagnóstico; gates completos verdes (tsc/lint/format/knip/cycles, unit+int 791, build). Sem migration/access/Consent/UI. Docs: linha no AGENTS.md (checklist local: `generate:importmap` precisa das `S3_*` setadas — valores dummy ok — enquanto o plugin for condicional; sem elas o importMap fica órfão, classe OPS69) + item sucessor registrado (guard de CI que regenera com envs dummy e falha o gate se o arquivo commitado divergir — `depends` #77). Verificação pós-deploy: `/admin` abre o login e o log do container não re-emite o `getFromImportMap` para o handler (runbook `docs/ops/teqo-1313-deploy.md`).

View file

@ -0,0 +1 @@
**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.

View file

@ -0,0 +1,77 @@
# Impl: Captura de novidades da campanha na home pública
Status: rascunho
Atualizado em: 2026-08-19
Issue: #93
Intenção: docs/plans/captura-novidades-home-campanha.md
Appetite restante: herdado (~1–1,5 dia eng)
## Leitura da intenção
- **Outcome:** visitante da home deixa nome + WhatsApp (obrigatórios; e-mail, estado, cidade e comentário opcionais) numa seção da própria home, escolhe o nível de engajamento num toggle pré-selecionado ("Quero fazer parte do time"), recebe confirmação in-place; o registro vira Contact + assinatura com consentimento novo fail-closed, e a escolha do toggle fica gravada e visível no admin.
- **O que NÃO negociar:** nada de `supporter` (bloqueio jurídico Onda 0); consentimento NOVO (não `whatsapp-inscricao`), resolvido por chave estável e fail-closed (sem o texto configurado no admin, o form recusa a captura); sem mescla com `/mandato-no-whatsapp`; CTA secundário do hero vira o atalho da captura **sem esconder as bandeiras** (`#bandeiras` continua acessível — link do rodapé já existe); 6 campos do rascunho, nada mais.
- **O que reavaliar:** a hipótese dizia que os campos `NameInput`/`PhoneInput`/`EmailInput`/`StateSelect`/`CitySelect` "já existem e são reutilizáveis" — verdade, mas **`Contact.state` é `NOT NULL` no banco** (migration inicial) e **`StateSelect`/`CitySelect` têm `required` fixo** (usados só pelo `WhatsappForm` hoje). Estado opcional no form exige: `required: false` na collection + migration `DROP NOT NULL`, e um prop `required` nos dois selects (editar o dono, sem gêmeos). A forma do consentimento (um texto vs. dois) fica decidida aqui — ver abordagem.
## Abordagem recomendada
```mermaid
flowchart LR
H[CampaignHero<br/>CTA secundário → #novidades] --> S[CampaignNewsletterSection<br/>id=novidades, após o hero]
S --> F[CampaignNewsletterForm<br/>cliente, react-hook-form+zod]
F -->|submit| A[submitCampaignNewsletter]
A --> C[requireConsentByKey<br/>chave campanha-novidades · fail-closed]
C --> T[Transação: Contact + Subscription]
T -->|ok| X[Sucesso in-place<br/>Ver bandeiras → #bandeiras]
A -->|consent ausente| E[Recusa: nenhum dado gravado]
```
**Opções consideradas:** A (dois consents, escolha = relação de consent) · B (**um consent + campo `campaignLevel` na assinatura**) · C (collection nova de captura)
**Recomendação:** **B** — porque a escolha do toggle sobrevive como **dado explícito** (`subscription.campaignLevel`, coluna própria no admin), o jurídico aprova **um texto** cobrindo os dois níveis (o rabbit-hole "um consentimento só para tudo" da intenção é cortado justamente quando a escolha **sobrevive no dado** — aqui sobrevive), e o padrão repete o precedente `whatsapp-inscricao` (uma chave por fluxo, `requireConsentByKey` fail-closed). O texto em si continua pendente de aprovação jurídica antes da ativação — nenhum dado é capturado sem o(s) documento(s) no admin.
**Rejeitadas:**
- **A (dois consents, `campanha-novidades-time`/`-esporadico`):** juridicamente o dobro de textos a aprovar; a escolha fica implícita numa relação que o admin teria que interpretar pelo label do documento; e se o jurídico aprovar um texto único, a duplicação em dois docs vira truque. Zero schema change é a única vantagem — e B custa só uma coluna nullable.
- **C (collection `campaignNewsletterSignup` nova):** twin do modelo de assinatura; a intenção diz explicitamente "o vínculo reutiliza o modelo de assinatura existente".
### Componentes / mudanças
- **`campaignConsentKeys.ts`** (`src/lib/`): `CAMPAIGN_NEWSLETTER_CONSENT_KEY = 'campanha-novidades'` + `CAMPAIGN_NEWSLETTER_CONSENT_MISSING_MESSAGE` ("Consentimento de novidades da campanha ainda não configurado.").
- **`campaignConsent.ts`** (`src/utilities/`, edit owner): `requireCampaignNewsletterConsent` no padrão das irmãs.
- **`Subscription`** (`src/collections/`): campo novo `campaignLevel` — select `time|esporadico` (labels "Fazer parte do time" / "Comunicações esporádicas", admin label "Nível de engajamento"), **nullable na collection** (linhas do WhatsApp não tocam) e `admin.listColumns` para a coluna aparecer na lista.
- **`Contact`** (`src/collections/`): `state` → `required: false` (a ficha staff via zod `contactCreateSchema` continua exigindo — precedente `phones`: collection permissiva, fluxos com regra própria).
- **Migration** `pnpm migrate:create` (uma: `ALTER TABLE "contact" ALTER COLUMN "state" DROP NOT NULL` + `CREATE TYPE enum_subscription_campaign_level` + `ALTER TABLE "subscription" ADD COLUMN "campaign_level"`); rebase antes de criar; inspecionar o SQL gerado.
- **`schemas/contact.ts`** (edit owner): exportar `contactNameSchema` (hoje const não exportada).
- **`schemas/campaign-newsletter.ts`** (novo, `src/lib/schemas/`): `campaignNewsletterSchema` — `name` (`contactNameSchema`, obrigatório), `phone` (`brazilianMobile`, obrigatório, normaliza p/ 11 dígitos), `email` (`optionalPersistedEmail`), `state` (opcional, `''`→undefined), `city` (opcional, min 3 quando presente), `comment` (`commentSchema.shape.comment`), `campaignLevel` (`z.enum(['time','esporadico']).default('time')` — o pré-selecionado do toggle).
- **`actions/submitCampaignNewsletter.ts`** (novo, `src/app/(frontend)/actions/`): padrão `submitWhatsapp` — parse zod → `requireConsentByKey` fail-closed → transação (`payload.db.beginTransaction`, `req: { transactionID }`): `contact` (name, email?, `phones: [{ value: phone }]`, state?, city?) + `subscription` (`contact`, `consent`, `campaignLevel`, `comment`) → commit; rollback no erro.
- **`StateSelect` / `CitySelect`** (`src/components/`, edit owners): prop `required?: boolean` (default `true`) — o form novo passa `required={false}`; nenhum chamador existente muda.
- **`CampaignHero`** (`src/components/`): CTA secundário `href="#novidades"`, texto **duas linhas** "Receba / novidades" (mesma contagem de caracteres de "Conhecer / bandeiras" — preserva as 6 medições pinadas do teste de primeira dobra, ±2px), `aria-label="Receba novidades da campanha"`. **Scroll animado** (decisão de produto 2026-08-19): a âncora depende do `scroll-behavior: smooth` já existente no container `[data-theme="campaign-site"]` (e2e já pina `smooth`) — verificar se a âncora do CTA do hero participa desse container e manter suave; sem smooth no trecho, adicionar no CSS da home (nunca scroll instantâneo).
- **`CampaignNewsletterSection`** (novo, `src/app/(frontend)/(home)/`): seção server **logo acima do rodapé** (decisão de produto 2026-08-19: depois da seção de bandeiras, antes do `CampaignFooter`), `id="novidades"`, `data-home-section="newsletter"`, banda `--campaign-band` + `border-y` (mesmo padrão da seção contents), eyebrow/título/copy no vocabulário `campaign-section-*`; compõe o form cliente.
- **`CampaignNewsletterForm`** (novo, `src/components/`): cliente, react-hook-form + zodResolver; `NameInput` + `PhoneInput` (obrigatórios), `EmailInput` + `StateSelect`/`CitySelect` (opcionais, placeholders "(opcional)"), `Textarea` comentário, **Checkbox toggle pré-marcado** ("Quero fazer parte do time: receber novidades com frequência, ser adicionado(a) aos grupos de WhatsApp da campanha e participar das ações" + helper "Sem marcar, você recebe apenas comunicações esporádicas…"), botão "QUERO RECEBER NOVIDADES", nota LGPD de rodapé; no sucesso → estado in-place ("Inscrição confirmada" + copy **sem "abaixo"** — as bandeiras ficam acima na home — + botão "Ver bandeiras" → `#bandeiras`). O swap form→sucesso fica no componente cliente — costura pronta para o S10 (pixel) disparar `Lead` aí.
- **UI:** Impeccable B — encaixe fiel ao rascunho aprovado; shape → craft → critique → polish.
### Dados → forma (não se aplica — a captura é o dado; consumo = leitura do admin existente)
## Fases verificáveis
1. **Tracer / schema+server** — migration + collections + keys + schema + action + int tests; `pnpm migrate` local; `pnpm exec tsc --noEmit`.
2. **UI** — selects opcionais, hero CTA, seção + form + sucesso; e2e: atualizar o teste "animates the flags navigation" (CTA secundário → `#novidades`) e medir/re-pinar o teste de primeira dobra se o ±2px falhar; novo e2e do form (cria o Consent via REST admin, submete, assere "Inscrição confirmada", limpa consent+subscription — chave própria, paralelo-safe).
3. **Gates** — `pnpm gate:fast`; `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm build`; changelog `docs/changelog/2026-08-19-s9.md` + `pnpm changelog:build`; push via `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Não mexer em `contactCreateSchema` (ficha staff segue exigindo estado — regra do fluxo, não da collection).
- Não adicionar footer link "Receba novidades" (intenção pede só CTA do hero + seção).
- Não indexar `campaign_level` (sem filtro de admin planejado).
- Não enviar e-mail / double opt-in (gatilho futuro se marketing pedir — fora da intenção).
## Riscos e mitigação
- **Pins de geometria do hero** (`frontend.e2e.spec.ts` — `secondaryAction` x/y/largura ±2px em 6 viewports): texto com mesma contagem de caracteres ("Receba/novidades" ≈ "Conhecer/bandeiras"); se falhar, re-pinar com valores medidos (a mudança é intencional; o teste preserva a detecção de drift).
- **Consent ausente em prod** (bloqueio jurídico pendente): comportamento fail-closed é o aceite — o form recusa a captura com mensagem clara; nenhum dado gravado. Nada no código depende do texto aprovado.
- **Estado opcional vs. admin**: contactos sem `state` agora válidos — a ficha staff continua exigindo (zod), listas do admin existentes não filtram por estado obrigatório.
- **Concorrência de migration**: rebase em `main` antes de `migrate:create`; a cadeia é validada por `pnpm migrate` + `test:int` no PR.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (6 campos, toggle pré-marcado gravado, CTA do hero, consent novo fail-closed, bandeiras acessíveis)
- [x] Invariantes AGENTS/engineering-standards (transação multi-collection com `req.transactionID`; Consent por chave estável; join com `Contact`; copy pt-BR/identifiers EN; edit owner, sem gêmeos)
- [ ] Testes de domínio previstos: int `submitCampaignNewsletter.int.spec.ts` (fail-closed + gravação dos dois níveis + state omitido) e e2e do form → sucesso

View file

@ -0,0 +1,60 @@
# Escala/DRY pós-S9 — integridade e alinhamento das ações públicas
Status: registrado
Atualizado em: 2026-08-19
Issue: a registrar (depends: #93)
Priority: P2
Model: composer-2.5
Appetite: ~0,5–1 dia eng (3 fases pequenas, lock primeiro)
## Fase 1 — Deletar `Consent` em uso falha com erro cru (expensive_lock)
**Sintoma:** deletar um consentimento referenciado por `subscription` (ou
`signature`/`petition`) estoura `23502` no Postgres: a FK é `ON DELETE SET
NULL` mas a coluna `subscription.consent_id` é `NOT NULL` — o gatilho tenta
setar NULL e viola a constraint; no admin o usuário vê um 500 sem explicação.
**Por que existe:** `required: true` no campo `consent` (migration inicial).
O comportamento "não deletar consentimento em uso" é **protetivo** (texto
legal versionado) — o problema é só a superfície: erro cru, sem orientação.
**Decisão de implementação (com produto/jurídico):** mensagem clara no admin
("Consentimento em uso por N assinaturas/inscrições — não é possível
excluir") vs. semântica de arquivamento. Onde: `beforeDelete` hook do
`Consent` (contar referências; lançar `APIError` amigável) ou validação
explícita no delete operation. Descoberto na sessão S9 (também travou os
lifecycles de teste — as purges de `campanha-novidades` precisam deletar
subscriptions antes).
## Fase 2 — `submitWhatsapp`/`submitPetitionSignature` → `withPayloadTransaction`
As duas actions públicas irmãs usam o hand-roll (begin/try/commit/rollback)
que o `submitCampaignNewsletter` (S9) já substituiu pelo dono do concern
(`src/utilities/payloadTransaction.ts`). O helper entrega rollback também
quando o **commit** falha, `AggregateError` quando rollback+erro falham juntos
e o registro after-commit (`onPayloadTransactionCommit`) — a costura pronta
para S10 disparar `Lead` e para notificações pós-write. Sem mudança de
contrato: mesmos creates, mesmo retorno `{ ok: true }`.
## Fase 3 — Consolidar os schemas de estado/cidade em `contact.ts`
O S9 exportou `optionalContactStateSchema` e `contactCityFieldSchema`, mas as
4 fichas do próprio `contact.ts` (`contactSchema`, `contactCreateSchema`,
`contactFieldUpdateSchema`, `contactFullUpdateSchema`) mantêm cópias inline do
predicate `value in CitiesByState` e da chain de cidade (mesma mensagem,
mesmo comportamento). Trocar as cópias pelos exports únicos — DRY de
conhecimento no próprio dono.
## Já resolvido no simplify/critique (não reabrir)
- e2e do path de recusa (consent ausente) — cortado de propósito: a action
fail-closed devolve 500 e o `e2eFailureGuard` trata 5xx como falha; o int
spec cobre com a mensagem exata + nada gravado.
- `contactStateSchema` privado (knip) — o export só teria 1 consumidor.
## Explicitamente fora (defers com gatilho)
- Refatorar o hand-roll das actions irmãs ANTES de S9 existir — já feito na
action nova; o débito é só das irmãs (Fase 2).
- Gatilho: quando S10 precisar do `onPayloadTransactionCommit` para `Lead`, a
Fase 2 vira requisito (não só limpeza).

View file

@ -0,0 +1,37 @@
import { CampaignNewsletterCapture } from '@/components/CampaignNewsletterForm'
/**
* S9 — "novidades" capture section on the campaign home, positioned right
* 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).
*/
export const CampaignNewsletterSection = () => (
<section
id="novidades"
aria-labelledby="novidades-title"
data-home-section="newsletter"
className="scroll-mt-4 border-y border-(--campaign-line) bg-(--campaign-band)"
>
<div className="mx-auto w-full max-w-[1160px] px-5 py-12 sm:px-8 lg:px-10 lg:py-16">
<div className="mx-auto max-w-2xl text-center">
<p className="campaign-section-eyebrow m-0 font-black tracking-[0.1em] text-(--pt-red) uppercase">
Apoie e acompanhe
</p>
<h2
id="novidades-title"
className="campaign-section-title m-0 mt-1 border-0 p-0 font-black tracking-[-0.02em] text-balance"
>
Receba as novidades da campanha
</h2>
<p className="campaign-section-copy m-0 mt-1 text-(--campaign-muted)">
Deixe nome e WhatsApp para acompanhar a campanha de perto — e escolha como quer receber as
novidades: fazendo parte do time ou com comunicações esporádicas.
</p>
</div>
<div className="mt-8">
<CampaignNewsletterCapture />
</div>
</div>
</section>
)

View file

@ -5,6 +5,7 @@ import { CampaignFooter } from '@/components/CampaignFooter'
import { CampaignHero } from '@/components/CampaignHero'
import { CampaignProblemCard } from '@/components/CampaignProblemCard'
import type { ReactNode } from 'react'
import { CampaignNewsletterSection } from './CampaignNewsletterSection'
/**
* S6 — desktop sibling of the mobile carousel: the same cards as a static
@ -227,6 +228,8 @@ export default async function HomePage() {
</div>
</div>
</section>
<CampaignNewsletterSection />
</main>
<CampaignFooter />

View file

@ -0,0 +1,50 @@
'use server'
import config from '@payload-config'
import { getPayload } from 'payload'
import { CampaignNewsletterInput, campaignNewsletterSchema } from '@/lib/schemas/campaignNewsletter'
import { requireCampaignNewsletterConsent } from '@/utilities/campaignConsent'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
/**
* S9 — public home capture: name + WhatsApp (required) with an optional
* email/state/city/comment and the engagement-level toggle. Mirrors
* `submitWhatsapp` (fail-closed consent by stable key): while the admin has
* not created the `campanha-novidades` Consent row, the form refuses to record
* anything. The toggle choice survives as `subscription.campaignLevel` — never
* a `supporter` (jurídico-blocked).
*/
export const submitCampaignNewsletter = async (input: CampaignNewsletterInput) => {
const parsed = campaignNewsletterSchema.parse(input)
const payload = await getPayload({ config })
const consent = await requireCampaignNewsletterConsent(payload)
return withPayloadTransaction(payload, async ({ req }) => {
const { comment, campaignLevel, phone, ...contactFields } = parsed
const contact = await payload.create({
collection: 'contact',
data: {
...contactFields,
// Same shape as the public WhatsApp form: the phones array with the
// captured number as primary (C112).
phones: [{ value: phone }],
},
req,
})
await payload.create({
collection: 'subscription',
data: {
contact: contact.id,
consent: consent.id,
campaignLevel,
comment,
},
req,
})
return { ok: true }
})
}

View file

@ -179,6 +179,10 @@ body,
--campaign-band: #ebe9e9;
--campaign-line: rgb(0 0 0 / 12%);
--campaign-content-inset: clamp(20px, calc(25.36vw - 79.64px), 180px);
/* S9 — the "novidades" capture form sits on the light band, so the field
border must be visible against it (the :root default is a white border
for the dark WhatsApp page). */
--field-border: rgb(0 0 0 / 18%);
}
/* Campaign home geometry is interpolated between the approved Penpot boards.

View file

@ -35,7 +35,6 @@ import { ExportSaveButton as ExportSaveButton_cdf7e044479f899a31f804427d568b36 }
import { ImportPreview as ImportPreview_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
import { ImportSaveButton as ImportSaveButton_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
import { InstagramPostExclusionPicker as InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0 } from '../../../components/admin/InstagramPostExclusionPicker'
import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client'
import { ImportExportProvider as ImportExportProvider_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
@ -77,7 +76,6 @@ export const importMap = {
"@payloadcms/plugin-import-export/rsc#ImportPreview": ImportPreview_cdf7e044479f899a31f804427d568b36,
"@payloadcms/plugin-import-export/rsc#ImportSaveButton": ImportSaveButton_cdf7e044479f899a31f804427d568b36,
"./components/admin/InstagramPostExclusionPicker#InstagramPostExclusionPicker": InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0,
"@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24,
"@payloadcms/plugin-import-export/rsc#ImportExportProvider": ImportExportProvider_cdf7e044479f899a31f804427d568b36,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}

View file

@ -171,7 +171,11 @@ export const Contact: CollectionConfig = {
type: 'select',
label: 'Estado',
options: Object.keys(CitiesByState) as (keyof typeof CitiesByState)[],
required: true,
// S9 — relaxed so the public campaign capture can store a contact
// without a state (optional field in that form). The staff ficha still
// requires it via `contactCreateSchema` — precedent `phones` (optional
// at the collection, zod-required in the flows).
required: false,
},
{
name: 'city',

View file

@ -9,6 +9,7 @@ export const Subscription: CollectionConfig = {
},
admin: {
group: 'Contatos',
defaultColumns: ['contact', 'consent', 'campaignLevel', 'comment'],
},
// Citizen PII. Public opt-in flows write via the Local API without a user
// (overrideAccess defaults to true), so admin-only access does not affect them.
@ -39,5 +40,20 @@ export const Subscription: CollectionConfig = {
label: 'Comentario',
maxLength: 1000,
},
{
// S9 — the campaign home capture records the visitor's engagement
// choice ("Quero fazer parte do time" vs "esporádico") as data, so the
// admin can tell who may join WhatsApp groups. Nullable on purpose: the
// WhatsApp community flow writes subscriptions without it; only the
// campaign newsletter schema sets it (zod-required).
name: 'campaignLevel',
type: 'select',
label: 'Nível de engajamento',
required: false,
options: [
{ label: 'Fazer parte do time', value: 'time' },
{ label: 'Comunicações esporádicas', value: 'esporadico' },
],
},
],
}

View file

@ -102,14 +102,14 @@ export const CampaignHero = () => (
<div className="campaign-hero-actions flex items-stretch justify-start">
<a
href="#bandeiras"
aria-label="Conhecer bandeiras"
href="#novidades"
aria-label="Receba novidades da campanha"
data-cta="secondary"
className="campaign-action-feedback campaign-hero-action campaign-hero-action--secondary inline-flex min-h-11 flex-col items-center justify-center rounded-[10px] border-2 border-white/55 px-3 text-center font-[family-name:var(--font-exo2)] leading-[1.05] font-extrabold text-white no-underline hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-(--pt-red) focus-visible:outline-none"
>
Conhecer
Receba
<small className="campaign-hero-action-detail text-[12px] leading-none font-semibold">
bandeiras
novidades
</small>
</a>
<a

View file

@ -0,0 +1,181 @@
'use client'
import { submitCampaignNewsletter } from '@/app/(frontend)/actions/submitCampaignNewsletter'
import { CitySelect } from '@/components/CitySelect'
import { EmailInput } from '@/components/EmailInput'
import { NameInput } from '@/components/NameInput'
import { PhoneInput } from '@/components/PhoneInput'
import { StateSelect } from '@/components/StateSelect'
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 { 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'
interface CampaignNewsletterFormProps {
onSubmit: () => void
}
/**
* 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`).
*/
const CampaignNewsletterForm = ({ onSubmit }: CampaignNewsletterFormProps) => {
const methods = useForm<CampaignNewsletterInput>({
resolver: zodResolver(campaignNewsletterSchema),
defaultValues: {
name: '',
phone: '',
email: '',
state: undefined,
city: '',
comment: '',
campaignLevel: 'time',
},
})
const [isSubmitting, startTransition] = useTransition()
const handleSubmit: SubmitHandler<CampaignNewsletterInput> = (input) => {
startTransition(async () => {
try {
await submitCampaignNewsletter(input)
methods.reset()
onSubmit()
} catch (error) {
console.log(error)
methods.setError('root', {
message: 'Falha ao enviar. Tente novamente.',
})
}
})
}
const campaignLevel = useWatch({ control: methods.control, name: 'campaignLevel' })
return (
<FormProvider {...methods}>
<form
id="campanha-novidades"
onSubmit={methods.handleSubmit(handleSubmit)}
className="mx-auto max-w-3xl"
>
<FieldGroup>
<Field>
<NameInput placeholder="Nome completo*" />
<FieldError errors={[methods.formState.errors.name]} />
</Field>
<Field>
<PhoneInput placeholder="WhatsApp (com DDD)*" />
<FieldError errors={[methods.formState.errors.phone]} />
</Field>
<Field>
<EmailInput required={false} placeholder="E-mail (opcional)" />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field>
<StateSelect required={false} placeholder="Estado (opcional)" />
</Field>
<Field>
<CitySelect required={false} placeholder="Cidade (opcional)" />
</Field>
</div>
<Field>
<Textarea
id="comment"
{...methods.register('comment')}
placeholder="Comentário (opcional)"
className="min-h-22"
/>
</Field>
<Field>
<div className="rounded-lg border border-(--campaign-line) bg-white p-4">
<div className="flex items-start gap-3">
<Checkbox
id="campaign-level"
checked={campaignLevel === 'time'}
onCheckedChange={(checked) =>
methods.setValue('campaignLevel', checked ? 'time' : 'esporadico')
}
aria-describedby="campaign-level-help"
className="mt-0.5 size-5 rounded border-(--pt-red) text-white data-[state=checked]:border-(--pt-red) data-[state=checked]:bg-(--pt-red)"
/>
<label
htmlFor="campaign-level"
className="cursor-pointer text-sm leading-snug text-(--campaign-ink)"
>
<strong>Quero fazer parte do time:</strong> receber novidades com frequência, ser
adicionado(a) aos grupos de WhatsApp da campanha e participar das ações.
</label>
</div>
<p
id="campaign-level-help"
className="m-0 mt-2 text-xs leading-snug text-(--campaign-muted)"
>
Sem marcar, você recebe apenas comunicações esporádicas da campanha — sem grupos e
sem envios frequentes.
</p>
</div>
</Field>
<Field>
{methods.formState.errors.root?.message ? (
<FieldDescription className="text-destructive">
{methods.formState.errors.root.message}
</FieldDescription>
) : null}
<Button
type="submit"
disabled={isSubmitting}
className="h-12 w-full rounded-lg bg-(--pt-red) text-sm font-extrabold text-white hover:brightness-95"
>
{isSubmitting ? 'ENVIANDO...' : 'QUERO RECEBER NOVIDADES'}
</Button>
</Field>
</FieldGroup>
</form>
<p className="mx-auto mt-4 max-w-xl text-center text-xs leading-relaxed text-(--campaign-muted)">
Ao enviar, você demonstra apoio à campanha e escolhe como quer acompanhar: comunicações
frequentes e grupos de WhatsApp (marcando a opção acima) ou comunicações esporádicas —
conforme a Política de Privacidade (LGPD).
</p>
</FormProvider>
)
}
/** S9 — in-place success shown after the capture is recorded. */
const CampaignNewsletterSuccess = () => (
<div className="mx-auto max-w-3xl rounded-xl border border-(--campaign-line) bg-white px-6 py-12 text-center">
<div className="mx-auto flex size-10 items-center justify-center rounded-full border-2 border-(--pt-red) text-(--pt-red)">
<CheckIcon className="size-5" />
</div>
<h3 className="mt-5 font-[family-name:var(--font-exo2)] text-2xl font-black tracking-[-0.02em] text-(--campaign-ink)">
Inscrição confirmada
</h3>
<p className="mx-auto mt-4 max-w-md text-sm leading-relaxed text-(--campaign-muted)">
Você vai receber as novidades da campanha. Enquanto isso, conheça as bandeiras da campanha.
</p>
<a
href="#bandeiras"
className="mt-6 inline-flex h-11 items-center justify-center rounded-lg border-2 border-(--campaign-line) bg-white px-6 text-sm font-extrabold text-(--pt-red) no-underline transition-colors hover:bg-(--campaign-band) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:outline-none"
>
Ver bandeiras
</a>
</div>
)
/** S9 — wrapper owning the form ↔ success swap on the home section. */
export const CampaignNewsletterCapture = () => {
const [submitted, setSubmitted] = useState(false)
return submitted ? (
<CampaignNewsletterSuccess />
) : (
<CampaignNewsletterForm onSubmit={() => setSubmitted(true)} />
)
}

View file

@ -5,9 +5,16 @@ import { FormCombobox, type ComboboxOption } from './FormCombobox'
interface CitySelectProps {
placeholder?: string
/** S9 — the campaign home capture makes the city optional (the select
* stays disabled until a state is picked). Defaults to true so the WhatsApp
* flow stays as strict as before. */
required?: boolean
}
export const CitySelect = ({ placeholder = 'Selecione uma cidade' }: CitySelectProps) => {
export const CitySelect = ({
placeholder = 'Selecione uma cidade',
required = true,
}: CitySelectProps) => {
const { control, setValue } = useFormContext()
const state: keyof typeof CitiesByState | undefined = useWatch({
control,
@ -36,7 +43,7 @@ export const CitySelect = ({ placeholder = 'Selecione uma cidade' }: CitySelectP
placeholder={placeholder}
minChars={1}
disabled={!options.length}
required
required={required}
emptyMessage="Nenhuma cidade encontrada."
/>
)

View file

@ -3,13 +3,19 @@ import { FormCombobox, type ComboboxOption } from './FormCombobox'
interface StateSelectProps {
placeholder?: string
/** S9 — the campaign home capture makes the state optional. Defaults to
* true so the WhatsApp flow stays as strict as before. */
required?: boolean
}
const stateOptions: ComboboxOption[] = Object.entries(StateNames)
.map(([code, name]) => ({ value: code, label: name, keywords: [code] }))
.sort((a, b) => a.label.localeCompare(b.label, 'pt-BR'))
export const StateSelect = ({ placeholder = 'Selecione um estado' }: StateSelectProps) => {
export const StateSelect = ({
placeholder = 'Selecione um estado',
required = true,
}: StateSelectProps) => {
return (
<FormCombobox
name="state"
@ -17,7 +23,7 @@ export const StateSelect = ({ placeholder = 'Selecione um estado' }: StateSelect
options={stateOptions}
placeholder={placeholder}
minChars={1}
required
required={required}
emptyMessage="Nenhum estado encontrado."
/>
)

View file

@ -28,3 +28,13 @@ export const WHATSAPP_SUBSCRIPTION_CONSENT_KEY = 'whatsapp-inscricao'
export const CAMPAIGN_PUSH_CONSENT_KEY = 'campanha-notificacoes-push'
export const CAMPAIGN_PUSH_CONSENT_MISSING_MESSAGE =
'Consentimento de notificações push ainda não configurado.'
/**
* S9 — campaign home "novidades" capture (name + WhatsApp, engagement level
* toggle). Fail-closed like every public flow: while the admin has not created
* the Consent row with this key (jurídico-approved text pending), the form
* refuses to record anything.
*/
export const CAMPAIGN_NEWSLETTER_CONSENT_KEY = 'campanha-novidades'
export const CAMPAIGN_NEWSLETTER_CONSENT_MISSING_MESSAGE =
'Consentimento de novidades da campanha ainda não configurado.'

View file

@ -0,0 +1,25 @@
import { brazilianMobile, optionalPersistedEmail } from '@/lib/schemas/primitives'
import { z } from 'zod'
import { commentSchema } from './comment'
import { contactCityFieldSchema, contactNameSchema, optionalContactStateSchema } from './contact'
/**
* S9 — the campaign home "novidades" capture. Name + WhatsApp are required
* (the phone feeds the campaign's WhatsApp groups); email, state, city and
* comment are optional. `campaignLevel` carries the pre-selected engagement
* toggle: 'time' (frequent communications + WhatsApp groups + actions) or
* 'esporadico' (occasional communications only) — recorded on the
* subscription so the admin can tell who may join groups.
*/
export const campaignNewsletterSchema = z.object({
name: contactNameSchema,
phone: brazilianMobile,
email: optionalPersistedEmail,
state: optionalContactStateSchema,
city: contactCityFieldSchema,
comment: commentSchema.shape.comment,
campaignLevel: z.enum(['time', 'esporadico']).default('time'),
})
export type CampaignNewsletterInput = z.input<typeof campaignNewsletterSchema>

View file

@ -14,7 +14,11 @@ export type StateKey = keyof typeof CitiesByState
/** Gender enum shared with the leadership collection (same values, C139). */
const contactGenders = personGenders
const contactNameSchema = z
/**
* Full-name rule for people records (required across the public and staff
* flows). Exported so sibling schemas reuse it instead of re-declaring.
*/
export const contactNameSchema = z
.string()
.trim()
.min(2, 'Nome deve ter pelo menos 2 caracteres')
@ -24,6 +28,25 @@ const contactNameSchema = z
'Informe nome e sobrenome. Use apenas letras, no máximo um espaço ou hífen entre termos, e sem espaço no início ou no fim.',
)
/**
* UF-of-the-cities-catalog rule (required). Private: the S9 optional variant
* is the only external consumer; the ficha schemas keep their own inline
* copies (consolidating them is a follow-up).
*/
const contactStateSchema = z.custom<StateKey>(
(value) => typeof value === 'string' && value in CitiesByState,
'Estado inválido',
)
/**
* Optional-state variant: the public combobox clears to '' (FormCombobox
* `onValueChange`), so the union accepts it and collapses ''/undefined.
*/
export const optionalContactStateSchema = z
.union([contactStateSchema, z.literal('')])
.optional()
.transform((value) => (value === '' || value === undefined ? undefined : value))
const contactPostalCodeSchema = z
.string()
.trim()
@ -159,7 +182,11 @@ const contactGenderFieldSchema = z
.transform((value) => (value === '' ? undefined : value))
.optional()
const contactCityFieldSchema = z
/**
* Optional free-text city (''/undefined collapse). Exported so sibling schemas
* reuse the same optional-city rule as the ficha's mobile sheet.
*/
export const contactCityFieldSchema = z
.string()
.trim()
.transform((value) => (value === '' ? undefined : value))

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_subscription_campaign_level" AS ENUM('time', 'esporadico');
ALTER TABLE "contact" ALTER COLUMN "state" DROP NOT NULL;
ALTER TABLE "subscription" ADD COLUMN "campaign_level" "enum_subscription_campaign_level";`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "contact" ALTER COLUMN "state" SET NOT NULL;
ALTER TABLE "subscription" DROP COLUMN "campaign_level";
DROP TYPE "public"."enum_subscription_campaign_level";`)
}

View file

@ -56,6 +56,7 @@ import * as migration_20260811_213648_add_activity_last_mirrored_change_at from
import * as migration_20260811_234822_add_state_deputy_ballot_name from './20260811_234822_add_state_deputy_ballot_name';
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';
export const migrations = [
{
@ -346,6 +347,11 @@ export const migrations = [
{
up: migration_20260818_192948_add_instagram_social_feed_fields.up,
down: migration_20260818_192948_add_instagram_social_feed_fields.down,
name: '20260818_192948_add_instagram_social_feed_fields'
name: '20260818_192948_add_instagram_social_feed_fields',
},
{
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'
},
];

View file

@ -309,34 +309,37 @@ export interface Contact {
}[]
| null;
gender?: ('feminino' | 'masculino' | 'outro' | 'nao_informado') | null;
state:
| 'AC'
| 'AL'
| 'AM'
| 'AP'
| 'BA'
| 'CE'
| 'DF'
| 'ES'
| 'GO'
| 'MA'
| 'MG'
| 'MS'
| 'MT'
| 'PA'
| 'PB'
| 'PE'
| 'PI'
| 'PR'
| 'RJ'
| 'RN'
| 'RO'
| 'RR'
| 'RS'
| 'SC'
| 'SE'
| 'SP'
| 'TO';
state?:
| (
| 'AC'
| 'AL'
| 'AM'
| 'AP'
| 'BA'
| 'CE'
| 'DF'
| 'ES'
| 'GO'
| 'MA'
| 'MG'
| 'MS'
| 'MT'
| 'PA'
| 'PB'
| 'PE'
| 'PI'
| 'PR'
| 'RJ'
| 'RN'
| 'RO'
| 'RR'
| 'RS'
| 'SC'
| 'SE'
| 'SP'
| 'TO'
)
| null;
city?: string | null;
postalCode?: string | null;
updatedAt: string;
@ -1079,6 +1082,7 @@ export interface Subscription {
contact: number | Contact;
consent: number | Consent;
comment?: string | null;
campaignLevel?: ('time' | 'esporadico') | null;
updatedAt: string;
createdAt: string;
}
@ -2093,6 +2097,7 @@ export interface SubscriptionSelect<T extends boolean = true> {
contact?: T;
consent?: T;
comment?: T;
campaignLevel?: T;
updatedAt?: T;
createdAt?: T;
}

View file

@ -4,6 +4,8 @@ import type { Payload } from 'payload'
import {
CAMPAIGN_INVITE_CONSENT_KEY,
CAMPAIGN_NEWSLETTER_CONSENT_KEY,
CAMPAIGN_NEWSLETTER_CONSENT_MISSING_MESSAGE,
CAMPAIGN_PUSH_CONSENT_KEY,
CAMPAIGN_PUSH_CONSENT_MISSING_MESSAGE,
SUPPORTER_REGISTRATION_CONSENT_KEY,
@ -157,3 +159,10 @@ export const requireCampaignPushConsent = (
message = CAMPAIGN_PUSH_CONSENT_MISSING_MESSAGE,
): Promise<ConsentDescriptor> =>
requireConsentByKey(payload, CAMPAIGN_PUSH_CONSENT_KEY, req, message)
export const requireCampaignNewsletterConsent = (
payload: Payload,
req?: ConsentRequest,
message = CAMPAIGN_NEWSLETTER_CONSENT_MISSING_MESSAGE,
): Promise<ConsentDescriptor> =>
requireConsentByKey(payload, CAMPAIGN_NEWSLETTER_CONSENT_KEY, req, message)

View file

@ -0,0 +1,207 @@
import type { APIRequestContext } from '@playwright/test'
import { CAMPAIGN_NEWSLETTER_CONSENT_KEY } from '@/lib/campaignConsentKeys'
import { seedTestUser, testUser } from '../helpers/seedUser'
import { expect, test } from './fixtures/e2eTest'
/**
* S9 — the campaign home "novidades" capture: the hero CTA anchors the
* section, the form records the contact + subscription with the engagement
* level, and the page confirms in place. Runs the write through the deployed
* server (server action + Local API) so the fail-closed consent path and the
* transaction are the production ones.
*/
test.describe('Campaign home novidades capture', () => {
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
const runSuffix = Date.now()
// Unique 11-digit mobile per run (719 + 8 digits) so a crashed previous run
// can never poison the by-phone lookups.
const phoneDigits = String(runSuffix % 100_000_000).padStart(8, '0')
const successPhone = `719${phoneDigits}`
const successName = `E2e Novidades ${runSuffix}`
let consentID: number | undefined
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}` }
}
const createNewsletterConsent = async (
request: APIRequestContext,
headers: Record<string, string>,
) => {
// Self-healing lifecycle (S1 precedent): an aborted run could leave the
// unique-key row behind and poison the next beforeAll — purge first. The
// consent cannot be deleted while a subscription references it
// (`subscription.consent_id` NOT NULL + FK SET NULL), so purge those too.
const existing = await request.get(
`${baseURL}/api/consent?where[key][equals]=${CAMPAIGN_NEWSLETTER_CONSENT_KEY}&limit=10&depth=0`,
{ headers },
)
if (existing.ok()) {
const { docs } = await existing.json()
for (const doc of docs) {
const subscriptions = await request.get(
`${baseURL}/api/subscription?where[consent][equals]=${doc.id}&limit=50&depth=0`,
{ headers },
)
if (subscriptions.ok()) {
const { docs: subscriptionDocs } = await subscriptions.json()
for (const subscription of subscriptionDocs) {
await request.delete(`${baseURL}/api/subscription/${subscription.id}`, { headers })
}
}
await request.delete(`${baseURL}/api/consent/${doc.id}`, { headers })
}
}
const response = await request.post(`${baseURL}/api/consent`, {
headers,
data: {
key: CAMPAIGN_NEWSLETTER_CONSENT_KEY,
text: {
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
text: 'Consentimento de novidades da campanha (e2e).',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
version: 1,
},
],
direction: null,
format: '',
indent: 0,
version: 1,
},
},
},
})
expect(response.ok()).toBeTruthy()
const { doc } = await response.json()
consentID = doc.id
return doc
}
const cleanup = async (request: APIRequestContext, headers: Record<string, string>) => {
for (const phone of [successPhone]) {
const contacts = await request.get(
`${baseURL}/api/contact?where[phones.value][equals]=${phone}&limit=1&depth=0`,
{ headers },
)
if (contacts.ok()) {
const { docs } = await contacts.json()
const contact = docs[0]
if (contact) {
const subscriptions = await request.get(
`${baseURL}/api/subscription?where[contact][equals]=${contact.id}&limit=1&depth=0`,
{ headers },
)
if (subscriptions.ok()) {
const { docs: subscriptionDocs } = await subscriptions.json()
if (subscriptionDocs[0]) {
await request.delete(`${baseURL}/api/subscription/${subscriptionDocs[0].id}`, {
headers,
})
}
}
await request.delete(`${baseURL}/api/contact/${contact.id}`, { headers })
}
}
}
if (consentID !== undefined) {
await request.delete(`${baseURL}/api/consent/${consentID}`, { headers })
}
}
test.beforeAll(async ({ request }) => {
await seedTestUser()
const headers = await adminHeaders(request)
await createNewsletterConsent(request, headers)
})
test.afterAll(async ({ request }) => {
const headers = await adminHeaders(request).catch(() => undefined)
if (headers) {
await cleanup(request, headers)
}
})
test('captures the visitor and confirms in place', async ({ page, request }) => {
// 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}`)
await expect(page).toHaveURL(/\/\?e2e=/)
const section = page.locator('[data-home-section="newsletter"]')
await expect(
section.getByRole('heading', { name: 'Receba as novidades da campanha' }),
).toBeVisible()
// The hero CTA is the shortcut into the section (smooth anchor).
await page.locator('[data-cta="secondary"]').click()
await expect(page).toHaveURL(/#novidades$/)
await expect(section).toBeInViewport()
// The toggle is pre-selected: the default level is "time".
const levelToggle = section.locator('#campaign-level')
await expect(levelToggle).toBeChecked()
await section.getByPlaceholder('Nome completo*').fill(successName)
// Digits are formatted by the input itself; the schema normalizes back.
await section.getByPlaceholder('WhatsApp (com DDD)*').fill(successPhone)
await section.getByRole('button', { name: 'QUERO RECEBER NOVIDADES' }).click()
await expect(section.getByRole('heading', { name: 'Inscrição confirmada' })).toBeVisible()
// The success card keeps the bandeiras reachable (they sit above).
const flagsLink = section.getByRole('link', { name: 'Ver bandeiras' })
await expect(flagsLink).toHaveAttribute('href', '#bandeiras')
// The record is visible to the admin with the level choice.
const headers = await adminHeaders(request)
const contacts = await request.get(
`${baseURL}/api/contact?where[phones.value][equals]=${successPhone}&limit=1&depth=0`,
{ headers },
)
expect(contacts.ok()).toBeTruthy()
const { docs } = await contacts.json()
const contact = docs[0]
expect(contact).toBeDefined()
// NameInput sanitizes/formatts the name on the way in (digits stripped).
expect(contact.name.toLowerCase()).toContain('novidades')
const subscriptions = await request.get(
`${baseURL}/api/subscription?where[contact][equals]=${contact.id}&limit=1&depth=0`,
{ headers },
)
expect(subscriptions.ok()).toBeTruthy()
const { docs: subscriptionDocs } = await subscriptions.json()
expect(subscriptionDocs[0]).toBeDefined()
expect(subscriptionDocs[0].campaignLevel).toBe('time')
expect(
typeof subscriptionDocs[0].consent === 'object'
? subscriptionDocs[0].consent.id
: subscriptionDocs[0].consent,
).toBe(consentID)
})
// NOTE: the fail-closed refusal (consent missing → nothing written) is NOT
// e2e-covered on purpose: the server action rejects with a 500, and the
// suite's e2eFailureGuard treats same-origin 5xx as a failure (the WebAuthn
// refusal precedent is a handled 4xx). The refusal path is pinned by the
// int spec `submitCampaignNewsletter.int.spec.ts` (exact message + no rows).
})

View file

@ -46,7 +46,7 @@ test.describe('Frontend', () => {
)
})
test('animates the flags navigation and gives pointer feedback on campaign buttons', async ({
test('animates the novidades anchor and gives pointer feedback on campaign buttons', async ({
page,
}) => {
await page.goto('/')
@ -55,6 +55,7 @@ test.describe('Frontend', () => {
const secondaryCta = page.locator('[data-cta="secondary"]')
await expect(scrollContainer).toHaveCSS('scroll-behavior', 'smooth')
await expect(secondaryCta).toHaveAttribute('href', '#novidades')
await secondaryCta.hover()
await expect
.poll(() => secondaryCta.evaluate((element) => getComputedStyle(element).transform))
@ -69,8 +70,8 @@ test.describe('Frontend', () => {
await page.mouse.up()
await secondaryCta.click()
await expect(page).toHaveURL(/#bandeiras$/)
await expect(page.locator('#bandeiras')).toBeInViewport()
await expect(page).toHaveURL(/#novidades$/)
await expect(page.locator('#novidades')).toBeInViewport()
})
test('keeps breathing room between the wrapped hero subtitle and CTAs on iPhone SE', async ({

View file

@ -0,0 +1,158 @@
// @vitest-environment node
/**
* S9 — the public campaign "novidades" capture resolves its Consent by the
* stable key `campanha-novidades` and FAILS CLOSED while the keyed document is
* missing; the engagement-level toggle choice survives as
* `subscription.campaignLevel` and a contact may be recorded without a state.
*/
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { submitCampaignNewsletter } from '@/app/(frontend)/actions/submitCampaignNewsletter'
import { CAMPAIGN_NEWSLETTER_CONSENT_KEY } from '@/lib/campaignConsentKeys'
import config from '@/payload.config'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const deleteNewsletterConsentRows = async () => {
// Deleting a consent fails while a subscription references it
// (`subscription.consent_id` is NOT NULL with an `ON DELETE SET NULL` FK),
// so purge referencing subscriptions first — also covers crash leftovers.
const consents = await payload.find({
collection: 'consent',
where: { key: { equals: CAMPAIGN_NEWSLETTER_CONSENT_KEY } },
depth: 0,
limit: 100,
pagination: false,
overrideAccess: true,
})
for (const consent of consents.docs) {
await payload.delete({
collection: 'subscription',
where: { consent: { equals: consent.id } },
depth: 0,
overrideAccess: true,
})
await payload.delete({
collection: 'consent',
id: consent.id,
depth: 0,
overrideAccess: true,
})
}
}
const findSubscriptionByPhone = async (phone: string) => {
const contacts = await payload.find({
collection: 'contact',
where: { 'phones.value': { equals: phone } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
})
const contact = contacts.docs[0]
if (!contact) return undefined
const subscriptions = await payload.find({
collection: 'subscription',
where: { contact: { equals: contact.id } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
})
return { contact, subscription: subscriptions.docs[0] }
}
describe('submitCampaignNewsletter (consent by stable key + level choice)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
// One row per key is what production assumes; deleting before each test
// keeps the "most recent row wins" resolver deterministic without relying on
// created-at precision.
beforeEach(deleteNewsletterConsentRows)
it('fails closed while the keyed Consent document is missing', async () => {
await deleteNewsletterConsentRows()
await expect(
submitCampaignNewsletter({
name: campaignFixtures().personName('Contato'),
phone: campaignFixtures().phone(),
}),
).rejects.toThrow('Consentimento de novidades da campanha ainda não configurado.')
})
it('records contact + subscription with the default "time" level', async () => {
const fixtures = campaignFixtures()
const consent = await fixtures.createConsent({ key: CAMPAIGN_NEWSLETTER_CONSENT_KEY })
const phone = fixtures.phone()
const result = await submitCampaignNewsletter({
name: fixtures.personName('Contato'),
phone,
comment: 'Quero acompanhar a campanha.',
})
expect(result).toEqual({ ok: true })
const { contact, subscription } = (await findSubscriptionByPhone(phone)) ?? {}
expect(contact).toBeDefined()
expect(subscription).toBeDefined()
// depth 0 returns the raw relationship id — the consent link itself.
expect(subscription!.consent).toBe(consent.id)
// The toggle is pre-selected: the default level is recorded as data.
expect(subscription!.campaignLevel).toBe('time')
// The action's rows are not fixture-owned — clean them here so the
// fixture's contact cleanup does not trip on the subscription FK.
await payload.delete({
collection: 'subscription',
id: subscription!.id,
depth: 0,
overrideAccess: true,
})
})
it('records the "esporadico" level and an optional state is not persisted', async () => {
const fixtures = campaignFixtures()
const consent = await fixtures.createConsent({ key: CAMPAIGN_NEWSLETTER_CONSENT_KEY })
const phone = fixtures.phone()
const result = await submitCampaignNewsletter({
name: fixtures.personName('Contato'),
phone,
email: `${fixtures.value('nl')}@example.com`,
city: 'Salvador',
campaignLevel: 'esporadico',
})
expect(result).toEqual({ ok: true })
const { contact, subscription } = (await findSubscriptionByPhone(phone)) ?? {}
expect(contact).toBeDefined()
expect(contact!.state).toBeNull()
expect(contact!.city).toBe('Salvador')
expect(subscription).toBeDefined()
expect(subscription!.campaignLevel).toBe('esporadico')
expect(subscription!.consent).toBe(consent.id)
await payload.delete({
collection: 'subscription',
id: subscription!.id,
depth: 0,
overrideAccess: true,
})
})
})

View file

@ -24,9 +24,11 @@ describe('CampaignHero', () => {
expect(supportLink.getAttribute('href')).toBe('https://apoiar.me/jorgesolla')
expect(supportLink.getAttribute('data-cta')).toBe('primary')
const flagsLink = screen.getByRole('link', { name: /Conhecer bandeiras/i })
expect(flagsLink.getAttribute('href')).toBe('#bandeiras')
expect(flagsLink.getAttribute('data-cta')).toBe('secondary')
// S9 — the secondary CTA is the shortcut to the novidades capture; the
// bandeiras stay reachable through the footer link and the success card.
const novidadesLink = screen.getByRole('link', { name: /Receba novidades da campanha/i })
expect(novidadesLink.getAttribute('href')).toBe('#novidades')
expect(novidadesLink.getAttribute('data-cta')).toBe('secondary')
})
it('expõe as provas e as fotos com nomes acessíveis', () => {

View file

@ -23,10 +23,16 @@ vi.mock('@/components/CampaignContentSection', () => ({
CampaignContentSection: () => null,
}))
// The S9 capture form is a client component (radix comboboxes/checkbox) whose
// interactions are e2e-covered; the unit skeleton only pins the section shell.
vi.mock('@/components/CampaignNewsletterForm', () => ({
CampaignNewsletterCapture: () => null,
}))
afterEach(cleanup)
describe('Campaign home', () => {
it('monta as quatro seções previstas e o rodapé eleitoral', async () => {
it('monta as cinco seções previstas e o rodapé eleitoral', async () => {
render(await HomePage())
expect(screen.getByRole('heading', { level: 1, name: 'MAIS SAÚDE MAIS FUTURO' })).toBeTruthy()
@ -39,6 +45,7 @@ describe('Campaign home', () => {
name: /Junto com o trabalhador e do lado de quem mais precisa, sempre/i,
}),
).toBeTruthy()
expect(screen.getByRole('heading', { name: /Receba as novidades da campanha/i })).toBeTruthy()
expect(screen.getByText(/CNPJ: 68\.430\.467\/0001-05/)).toBeTruthy()
})