From 398a91e54a9e7fb78e2d6de3f9c520403b413ba7 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Wed, 19 Aug 2026 17:24:44 -0300 Subject: [PATCH] =?UTF-8?q?feat(S9):=20captura=20de=20novidades=20da=20cam?= =?UTF-8?q?panha=20na=20home=20p=C3=BAblica=20(form=20+=20CTA=20do=20hero)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seção 'Receba as novidades da campanha' acima do rodapé: nome + WhatsApp obrigatórios, e-mail/estado/cidade/comentário opcionais, toggle pré-selecionado 'Quero fazer parte do time' gravado como subscription.campaignLevel (time|esporadico). CTA secundário do hero vira o atalho (#novidades, scroll suave). Contact + Subscription em withPayloadTransaction com consentimento novo fail-closed por chave estável 'campanha-novidades' — nada é gravado sem o texto aprovado no admin. Migration 20260819_192605 (state de contact nullable + coluna campaign_level). Impl plan, changelog, int + e2e. --- docs/CHANGELOG-AGENTS.md | 2 + docs/changelog/2026-08-19-s9.md | 1 + .../captura-novidades-home-campanha-impl.md | 77 + .../plans/escala-dry-pos-s9-acoes-publicas.md | 60 + .../(home)/CampaignNewsletterSection.tsx | 37 + src/app/(frontend)/(home)/page.tsx | 3 + .../actions/submitCampaignNewsletter.ts | 50 + src/app/(frontend)/styles.css | 4 + src/app/(payload)/admin/importMap.js | 2 - src/collections/Contact.ts | 6 +- src/collections/Subscription.ts | 16 + src/components/CampaignHero.tsx | 8 +- src/components/CampaignNewsletterForm.tsx | 181 + src/components/CitySelect.tsx | 11 +- src/components/StateSelect.tsx | 10 +- src/lib/campaignConsentKeys.ts | 10 + src/lib/schemas/campaignNewsletter.ts | 25 + src/lib/schemas/contact.ts | 31 +- ...92605_add_campaign_newsletter_capture.json | 13764 ++++++++++++++++ ..._192605_add_campaign_newsletter_capture.ts | 15 + src/migrations/index.ts | 8 +- src/payload-types.ts | 61 +- src/utilities/campaignConsent.ts | 9 + tests/e2e/campaignNewsletter.e2e.spec.ts | 207 + tests/e2e/frontend.e2e.spec.ts | 7 +- .../int/submitCampaignNewsletter.int.spec.ts | 158 + tests/unit/campaignHero.unit.spec.tsx | 8 +- tests/unit/campaignHome.unit.spec.tsx | 9 +- 28 files changed, 14731 insertions(+), 49 deletions(-) create mode 100644 docs/changelog/2026-08-19-s9.md create mode 100644 docs/plans/captura-novidades-home-campanha-impl.md create mode 100644 docs/plans/escala-dry-pos-s9-acoes-publicas.md create mode 100644 src/app/(frontend)/(home)/CampaignNewsletterSection.tsx create mode 100644 src/app/(frontend)/actions/submitCampaignNewsletter.ts create mode 100644 src/components/CampaignNewsletterForm.tsx create mode 100644 src/lib/schemas/campaignNewsletter.ts create mode 100644 src/migrations/20260819_192605_add_campaign_newsletter_capture.json create mode 100644 src/migrations/20260819_192605_add_campaign_newsletter_capture.ts create mode 100644 tests/e2e/campaignNewsletter.e2e.spec.ts create mode 100644 tests/int/submitCampaignNewsletter.int.spec.ts diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 45e164fc..7af7dbae 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -18,6 +18,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **OPS71-INFRA (2026-08-19):** runner **self-hosted do GitHub instalado no homeserver** (Issue #113) — o job `deploy` do `deploy.yml` (dispatch manual) agora roda localmente no homeserver, sem SSH. Detalhes: `~/actions-runner` v2.336.0 linux-x64 (sha256 conferido contra a API de releases), nome `teqo-1313-runner`, labels `self-hosted` + `homeserver`, registrado com o PAT do OPS71 (registration token TTL 1h gerado e aplicado na sequência). Roda como **systemd user** `actions.runner.fsolla-teqo.teqo-1313-runner.service` (linger já ativo; `svc.sh install` exige root — unit user criado manualmente do template oficial), usuário `fsolla` (docker + `~/stack` envs), workdir `_work`, outbound-only (funciona atrás do Cloudflare tunnel). Verificado `online` na API do GitHub com as labels corretas. Registrado em infra-solla `STATE.md` (seção Teqo: runner + fluxo deploy manual atualizado; linha do repo/CI corrigida para o pós-cutover GitHub). Workspace `~/teqo-deploy` re-aponta para o GitHub no primeiro deploy via `set-url` idempotente do script. Pendência registrada: secret `CURSOR_API_KEY` no GitHub fica dormente (helper `archive-cursor-agent.yml`; a key não existe no ambiente — pool morto OPS65) até o usuário pedir a reativação. +**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`). diff --git a/docs/changelog/2026-08-19-s9.md b/docs/changelog/2026-08-19-s9.md new file mode 100644 index 00000000..3cdba756 --- /dev/null +++ b/docs/changelog/2026-08-19-s9.md @@ -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. diff --git a/docs/plans/captura-novidades-home-campanha-impl.md b/docs/plans/captura-novidades-home-campanha-impl.md new file mode 100644 index 00000000..f2281ad4 --- /dev/null +++ b/docs/plans/captura-novidades-home-campanha-impl.md @@ -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
CTA secundário → #novidades] --> S[CampaignNewsletterSection
id=novidades, após o hero] + S --> F[CampaignNewsletterForm
cliente, react-hook-form+zod] + F -->|submit| A[submitCampaignNewsletter] + A --> C[requireConsentByKey
chave campanha-novidades · fail-closed] + C --> T[Transação: Contact + Subscription] + T -->|ok| X[Sucesso in-place
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 diff --git a/docs/plans/escala-dry-pos-s9-acoes-publicas.md b/docs/plans/escala-dry-pos-s9-acoes-publicas.md new file mode 100644 index 00000000..7473de3b --- /dev/null +++ b/docs/plans/escala-dry-pos-s9-acoes-publicas.md @@ -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). diff --git a/src/app/(frontend)/(home)/CampaignNewsletterSection.tsx b/src/app/(frontend)/(home)/CampaignNewsletterSection.tsx new file mode 100644 index 00000000..20b41a7a --- /dev/null +++ b/src/app/(frontend)/(home)/CampaignNewsletterSection.tsx @@ -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 = () => ( +
+
+
+

+ Apoie e acompanhe +

+

+ Receba as novidades da campanha +

+

+ 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. +

+
+
+ +
+
+
+) diff --git a/src/app/(frontend)/(home)/page.tsx b/src/app/(frontend)/(home)/page.tsx index 278d728c..734a8c45 100644 --- a/src/app/(frontend)/(home)/page.tsx +++ b/src/app/(frontend)/(home)/page.tsx @@ -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() { + + diff --git a/src/app/(frontend)/actions/submitCampaignNewsletter.ts b/src/app/(frontend)/actions/submitCampaignNewsletter.ts new file mode 100644 index 00000000..f6d78dc1 --- /dev/null +++ b/src/app/(frontend)/actions/submitCampaignNewsletter.ts @@ -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 } + }) +} diff --git a/src/app/(frontend)/styles.css b/src/app/(frontend)/styles.css index 1400734d..16b5a783 100644 --- a/src/app/(frontend)/styles.css +++ b/src/app/(frontend)/styles.css @@ -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. diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index 343d05e0..2f6f0ae6 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -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 } diff --git a/src/collections/Contact.ts b/src/collections/Contact.ts index e7632362..2add3a38 100644 --- a/src/collections/Contact.ts +++ b/src/collections/Contact.ts @@ -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', diff --git a/src/collections/Subscription.ts b/src/collections/Subscription.ts index d7ce4106..e85955cd 100644 --- a/src/collections/Subscription.ts +++ b/src/collections/Subscription.ts @@ -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' }, + ], + }, ], } diff --git a/src/components/CampaignHero.tsx b/src/components/CampaignHero.tsx index 09734909..4585a8d5 100644 --- a/src/components/CampaignHero.tsx +++ b/src/components/CampaignHero.tsx @@ -102,14 +102,14 @@ export const CampaignHero = () => (
- Conhecer + Receba - bandeiras + novidades 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({ + resolver: zodResolver(campaignNewsletterSchema), + defaultValues: { + name: '', + phone: '', + email: '', + state: undefined, + city: '', + comment: '', + campaignLevel: 'time', + }, + }) + + const [isSubmitting, startTransition] = useTransition() + + const handleSubmit: SubmitHandler = (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 ( + +
+ + + + + + + + + + + + +
+ + + + + + +
+ +