Compare commits

...

4 commits

Author SHA1 Message Date
Francisco Solla
93bda37c29 test(e2e): alinha o acervo à Central pública retirada (S47)
Some checks failed
Deploy / preflight (push) Has been cancelled
Deploy / verify (push) Has been cancelled
Deploy / deploy staging (teqo-staging) (push) Has been cancelled
Deploy / requeue when main advanced (push) Has been cancelled
Deploy / deploy production (teqo-1313) (push) Has been cancelled
Os testes de exclusão da Central deixam de esperar o antigo /conteudos/<slug> (200/404) e passam a pinar o redirect permanente para /artigos, mantendo o contrato do arquivo privado no /campanha.
2026-10-22 02:16:08 -03:00
Francisco Solla
5635e6522c feat(site): vira o site público para o mandato pós-eleição (S47)
Home abre no agradecimento pelos 152.049 votos com board 1+3, trajetória, bandeiras do mandato, galeria de fotos e rodapé institucional; chrome novo (header home/interno, lockup de nome, menu mobile acessível) e /artigos//fotos com título compacto.

As superfícies de campanha saem do ar (308 de /conteudos, /jingles e /cards), junto com newsletter, estúdio público de cards e UI da Central/jingles; download de foto e .ics perdem o 1313. Sem migration, sem Consent novo e sem apagar dados do CMS.
2026-10-22 02:16:08 -03:00
Francisco Solla
5be50b182c docs(plans): registra o follow-up do JSON-LD da petição
Débito da S48 (#1443): a página de petição serializa o JSON-LD sem
escapar '<', enquanto o artigo já escapa. A Issue #1447 fecha o escopo
com um helper único em src/utilities/seo.ts usado nas duas páginas.
2026-10-22 00:51:20 -03:00
Francisco Solla
5786bc57dc feat(petition): publica a petição do PL 5594/2026
A ferramenta de abaixo-assinado já existia, mas a petição do PL não tinha
caminho reproduzível fora do dump legado: produção só carregava as três
petições antigas editadas no admin. O conteúdo agora é versionado em
scripts/lib/petitionPl5594Content.mjs e provisionado por
pnpm seed:petition-pl-5594 — dry-run por padrão, create-if-missing por id,
Consent whatsapp-inscricao resolvido por chave (fail-closed) e guards de
escrita (PETITION_SEED_CONFIRM + TEQO_ENV casando o banco exato), sem
sobrescrever edição do admin.

O corpo traz os fatos do inteiro teor (autorização no registro, 5
exercícios de IR, sigilos bancário/fiscal/telefônico/de conexão, exclusão
do conteúdo das comunicações, auditoria, LGPD e divulgação pela Justiça
Eleitoral), a motivação de 18-19/10 sem acusar crime e o pedido sem
prometer aprovação. A copy hardcoded do bloco do formulário deixa de falar
em 'campanha'/'luta dos trabalhadores' e vira genérica.

Prova: unit full 475 arquivos/5238 testes verdes, lint/typecheck/format
limpos, seed aplicado e reaplicado em DB temporário (criação + no-op) e
smoke real no navegador (página 200, consent, assinatura nº 1). Sem
migration, collection, Consent novo ou mudança no fluxo de assinatura.
2026-10-22 00:51:20 -03:00
165 changed files with 2528 additions and 17899 deletions

View file

@ -20,9 +20,9 @@ Conceitos e decisões: `docs/ARCHITECTURE.md`. Operação (setup/seeds/deploy/se
- `src/utilities/access/` — RBAC por domínio; `campaignAccess.ts` é o barrel de re-export; predicados de role client-safe em `src/lib/campaignRoles.ts`.
- `src/utilities/<domínio>/` — subpastas de domínio (P3-J, gatilho D1): `municipality/` (incl. `campaignMunicipalityScope`, `goalCoverage`), `supporter/`, `territory/` (incl. `loadTerritoryOverview`), `leadership/`, `visit/`, `webauthn/` (`campaignWebAuthn*`). Imports diretos, **sem barrels**. O top-level de `src/utilities/` é **pinado** (`codebaseConventions.unit.spec.ts`): módulo novo top-level falha o build até ser registrado — a 3º módulo novo de um domínio no mês, o domínio ganha subpasta. Helpers puros que estavam na camada errada moram em `lib/` (`campaignTime`, `relationship`).
- `src/components/campaign/<domínio>/` — municipality, activity, supporter, leadership, advisor, demand, organization, stateDeputy, votePledge, invite, map, dashboard, auth, shell (sidebar/nav/PWA), shared (sistema de listas + widgets genéricos).
- `src/components/cards/` — estúdio público de cards personalizados (S13): `CardsStudio` (seleção + shell dialog/drawer), `CardComposer`, `CardModelGallery`/`CardModelTile`, `CardPreviewCanvas`, adapters em `cardCanvas.ts`/`cardCutout.ts`/`cardPhotoHarmonyCanvas.ts` (S17). Geometria/catálogo/tom puros em `src/lib/card{Models,NameFit,PhotoTransform,Render,Harmony}.ts`.
- `src/components/cards/` — os adapters de browser que sobraram do estúdio de cards (S47 removeu a UI pública): `cardCanvas.ts` (load/PNG/download) e `cardCutout.ts`, reusados pela busca por selfie e pelo story do `/potencial`. O catálogo/tons puros ficam em `src/lib/cardModels.ts`/`cardPhotoTransform.ts`; o dashboard interno lê `src/lib/cardDownloadCounts.ts`.
- `src/components/ui/` — kit shadcn da casa (sem peças mortas; `shadcn add` re-adiciona).
- `src/lib/schemas/` — zod de entrada; `src/lib/` também guarda catálogos estáticos e o artefato TSE (`electionAggregates/`), além dos módulos puros do estúdio de cards públicos (`cardModels`, `cardNameFit`, `cardPhotoTransform`, `cardRender`, `cardPhotoHarmony`) e do roster estático das 53 dobradinhas (`stateDeputyCatalog`, S30 — snapshot em `tests/fixtures/state-deputy-catalog.snapshot.json`; artes derivadas por `scripts/build-state-deputy-card-assets.mjs`), além da geometria da linha do estadual da colinha (`cardColinha`, S34 — a arte aprovada é a base e só a linha `DEPUTADO ESTADUAL` é desenhada por cima, com `fitColinhaRowName`).
- `src/lib/schemas/` — zod de entrada; `src/lib/` também guarda catálogos estáticos e o artefato TSE (`electionAggregates/`), além dos módulos puros que restaram do estúdio de cards (`cardModels`, `cardPhotoTransform`) e do roster estático das 53 dobradinhas (`stateDeputyCatalog`, S30 — snapshot em `tests/fixtures/state-deputy-catalog.snapshot.json`; artes derivadas por `scripts/build-state-deputy-card-assets.mjs`).
- `src/migrations/` — história congelada; nunca editar retroativamente.
- `tests/` — `unit` primeiro (lógica pura, DATABASE_URL inválida), depois `int` para fronteiras Payload/DB (Payload real em `teqo_test`, fixtures em `tests/helpers/campaignFixtures.ts`), e `e2e` só com benefício real por fluxo que as camadas inferiores não cobrem (Playwright smoke; dono da definição: skill `test-driven-development`).

View file

@ -2,9 +2,9 @@
## Photo album (`/fotos`) — C233
The public album over the private archive (`archivePhoto`, C231/C232): **only `publicationStatus: 'approved'` photos** are read (`getApprovedArchivePhotoItems`, `src/utilities/archivePhotos/archivePhotoReads.ts`), cached under the collection listing tag `archivePhotos` and busted by the collection's `afterChange`/`afterDelete` — an `approved→removed` edit pulls the photo and its media down **without a deploy**. The route canonicalizes to `/fotos`; the single-value facets are `data` (day, `YYYY-MM-DD`), `municipio` (catalog slug), `atividade` (an `ARCHIVE_PHOTO_SCENES` value), `pessoa` (slug of a curated `faceFigure`, recognized by the facial layer — C244; the text `catalog.people` no longer governs it) plus `q`/`pagina`; `?foto=<id>` is the non-canonical overlay and `/fotos/<id>` keeps 404ing (no page per photo). Media goes through the same-origin proxy `/fotos/<id>/midia` (`?tamanho=grade` resizes on the fly, `?download=1` names `jorge-solla-1313-foto-<id>.<ext>`, `X-Robots-Tag: noindex`); the municipality name/slug are snapshotted on the row (`municipalityName`/`municipalitySlug`) because the `municipality` collection is campaign-only.
The public album over the private archive (`archivePhoto`, C231/C232): **only `publicationStatus: 'approved'` photos** are read (`getApprovedArchivePhotoItems`, `src/utilities/archivePhotos/archivePhotoReads.ts`), cached under the collection listing tag `archivePhotos` and busted by the collection's `afterChange`/`afterDelete` — an `approved→removed` edit pulls the photo and its media down **without a deploy**. The route canonicalizes to `/fotos`; the single-value facets are `data` (day, `YYYY-MM-DD`), `municipio` (catalog slug), `atividade` (an `ARCHIVE_PHOTO_SCENES` value), `pessoa` (slug of a curated `faceFigure`, recognized by the facial layer — C244; the text `catalog.people` no longer governs it) plus `q`/`pagina`; `?foto=<id>` is the non-canonical overlay and `/fotos/<id>` keeps 404ing (no page per photo). Media goes through the same-origin proxy `/fotos/<id>/midia` (`?tamanho=grade` resizes on the fly, `?download=1` names `jorge-solla-foto-<id>.<ext>`, `X-Robots-Tag: noindex`); the municipality name/slug are snapshotted on the row (`municipalityName`/`municipalitySlug`) because the `municipality` collection is campaign-only.
The album global `photoAlbum` holds the two product knobs: `published` (kill switch — **only `false` closes**; an absent row means the schema default `true`) and `removalChannelUrl` (the visible "é você nesta foto? peça a remoção" channel, never invented in code). Publishing the album or approving a photo **requires a valid channel** (fail-closed guard in the global and in the collection); the album has no `loading.tsx` on purpose — a Suspense boundary would turn the closed album's `notFound()` into a `200`. The "Fotos" discovery link in `CampaignFooter` appears while there are approved photos (and always on `/fotos` itself).
The album global `photoAlbum` holds the two product knobs: `published` (kill switch — **only `false` closes**; an absent row means the schema default `true`) and `removalChannelUrl` (the visible "é você nesta foto? peça a remoção" channel, never invented in code). Publishing the album or approving a photo **requires a valid channel** (fail-closed guard in the global and in the collection); the album has no `loading.tsx` on purpose — a Suspense boundary would turn the closed album's `notFound()` into a `200`. The "Fotos" link in `CampaignFooter` is permanent (the album renders its own honest empty state), and the home gallery (`CampaignPhotoSection`) reads the same approved list.
## Selfie search (`/fotos/encontre`) — C234/C242

View file

@ -81,7 +81,7 @@ Ledger of known engineering debt. Items large enough to be scheduled work become
| Dead modules survive knip because test imports count as usage: `projectionSheetParse.ts` (173 LOC, 7 exports, 0 production importers) and `searchOnlyListOmnibox.ts` (98 LOC, 7 exports); dead scripts survive via the knip entry list covering every top-level script: `github-pr.mjs`, `measure-e2e-family.mjs`, `regenerate-municipality-catalog-snapshot.mjs` (0 referencers) | `src/lib/projectionSheetParse.ts`, `src/lib/searchOnlyListOmnibox.ts`, `scripts/` top-level | dead code + tooling dodge (knip entry list) | low | S | P2 | open — Pass 6 (P6-G: delete the 5; knip config so spec-only modules surface — class 4) | P6-G |
| CLI skeleton guard dodged twice: 4 local function die() (`check-plans-only-pr-closes.mjs:12`, `check-changelog-append-only.mjs:29`, `build-changelog.mjs:35`, `check-docs-conflict-markers.mjs:19`) + ~20 raw `process.exit(1)` — scriptCliConventions catches neither | `scripts/` top-level | guard dodge + duplication (P3-H class) | low | S | P2 | open — Pass 6 (P6-H: extend guard to ban local die() and process.exit; migrate 4 files to `dieWithLabel`) | P6-H |
| `taskActions.ts:20-27` hand-rolls try/catch and passes raw `error.message` to the UI (no safelist); `resultFormActions.ts:17` / `updateFormActions.ts:16-20` safelist bare literals duplicating the ACTIVITY_RESULT_STAFF_MESSAGE / ACTIVITY_UPDATE constants — the B32+ class, both directions | `atividades/[slug]/taskActions.ts`, `resultFormActions.ts`, `updateFormActions.ts` | correctness (message-only) + B32+ contract | medium — raw Payload/DB errors can reach the toast; literal drift silently collapses refusals | S | P2 | open — Pass 6 (P6-I: wrap in `runCampaignFormAction` + import constants; guard 2/3 sweeps error.message passthrough and literal safelists) | P6-I |
| Public form actions create a new `contact` row per submission with no phone/email dedup (`submitWhatsapp.ts:30`, `submitPetitionSignature.ts:40`, `submitCampaignNewsletter.ts:26`) — campaign actions dedup via `findOrCreateContactByPhone`; repeat submissions multiply PII rows and inflate the public signature counter | `src/app/(frontend)/actions/` — the 3 submit actions | LGPD hygiene + counter integrity | medium | M | P2 | open — Pass 6 (P6-J: dedupe before create; guard 5 int pin — same input twice → 1 contact) | P6-J |
| Public form actions create a new `contact` row per submission with no phone/email dedup (`submitWhatsapp.ts:30`, `submitPetitionSignature.ts:40`) — campaign actions dedup via `findOrCreateContactByPhone`; repeat submissions multiply PII rows and inflate the public signature counter | `src/app/(frontend)/actions/` — the 2 submit actions | LGPD hygiene + counter integrity | medium | M | P2 | open — Pass 6 (P6-J: dedupe before create; guard 5 int pin — same input twice → 1 contact) | P6-J |
| `findPublishedPosts` has no `select` — every listing/article page ships full published docs incl. richText bodies; article pages read 3 full-doc sets per render | `src/utilities/posts.ts:102-114`; `[type]/[category]/[slug]/page.tsx:35,72,142` | over-fetch (engineering-standards: view models with selected fields) | medium — per-render DB payload + cache size | M | P2 | open — Pass 6 (P6-K: listing projection + related-posts from the same selected query; guard 3) | P6-K |
| server-only sweep is blind to `next/navigation` value imports (`campaignFormActionError.ts:1` — isomorphic today, invisible either way) and to Payload-as-param modules (`personDelete.ts:9`) | `tests/unit/codebaseConventions.unit.spec.ts` server-only sweep; `src/utilities/campaignFormActionError.ts:1` | guard dodge (P3-K class) | low — a future server-bound next/navigation module passes green | S | P2 | open — Pass 6 (P6-M: widen regex to next/navigation + allowlist current clean importers) | P6-M |
| D1 trigger has no teeth: 12 top-level activity modules + the googleCalendar trio + 3 top-level omniboxes coexist because the pin is a manual allowlist; `person/` (1 file) vs `people/` (9 files) split-brain | `src/utilities/` top-level | organization + guard dodge (P3-J D1) | low (path churn) | M | P2 | open — Pass 6 (P6-N/P6-O: prefix-count spec — N top-level files sharing a domain prefix fail; fold personContactWriteScope.ts into people/) | P6-N |

View file

@ -0,0 +1 @@
**Site público pós-eleição: o mandato no lugar da propaganda (2026-10-22):** a home deixou de ser peça de campanha e abriu no agradecimento pelos **152.049 votos** (hero redesenhado, sem pedido de voto, número eleitoral ou "Quero apoiar"), com board editorial **1+3** (posts + feeds, agora com estado vazio explícito), trajetória, **seis bandeiras do mandato**, galeria de fotos aprovadas + estado vazio e rodapé institucional; o chrome ganhou header próprio (variantes vermelha da home e creme interna), lockup de nome positivo/negativo e um **menu mobile acessível** (Escape, clique fora, foco de ida/volta). `/artigos`, `/fotos` e `/fotos/encontre` passaram ao header/rodapé institucional e a um título compacto (artigos e fotos com canonical/OG próprios); `/potencial` e o anúncio de share link usam o mesmo shell sem depender de flags de jingles/conteúdos. As superfícies de campanha saíram do ar com **redirects 308** (`/conteudos` → `/artigos`, `/jingles` e `/cards` → `/`), a captura de newsletter e sua chave `campanha-novidades` foram removidas (a linha `Consent` histórica e as subscriptions ficam), o estúdio público de cards e a UI pública da Central/jingles/rádio foram apagados, e o download de foto virou `jorge-solla-foto-<id>`; o sufixo "1313" saiu também do `.ics`/Google Agenda. Sem migration, sem Consent novo e sem apagar posts/fotos do CMS. Metadados do global `metadata` e `SiteSettings.headerTitle` são dados de produção e ficam como **nota operacional** para edição no admin. Testes: unit (5.063), int (1.089) e e2e afetado (frontend, fotos, selfie, share link e potencial) verdes; design certificado pelo `designer` (`openai/gpt-5.6-sol`, primário) contra o gate S47.

View file

@ -0,0 +1 @@
**S48 — Abaixo-assinado do PL 5594/2026 (transparência dos presidenciáveis) (2026-10-22):** a petição `pl-5594-2026` passa a existir de forma reproduzível: o conteúdo (título, subtítulo, 5 blocos do corpo, links oficiais da Câmara e o pedido) é versionado em `scripts/lib/petitionPl5594Content.mjs` e provisionado por `pnpm seed:petition-pl-5594`, um seed dry-run por padrão que resolve o Consent existente `whatsapp-inscricao` **por chave** (fail-closed) e cria a petição `enabled`/`published` apenas quando o id não existe — nunca sobrescreve edição feita no admin. `--apply` em alvo não-local/produção exige `PETITION_SEED_CONFIRM=1` e `TEQO_ENV` casando o banco exato (`teqo_staging`/`teqo_1313`; dev local comprovadamente local segue a política do `assertWriteConfirm`), e o runbook de produção é `TEQO_ENV=production PETITION_SEED_CONFIRM=1 pnpm seed:petition-pl-5594 --apply` seguido de restart/redeploy (o seed roda fora do runtime Next e não busta `document_petition:<id>`). O corpo cobre o que o PL propõe (autorização no registro, 5 exercícios de IR, dados bancários/fiscais/telefônicos/de conexão), por que importa (debate de 18/10 e protocolo em 19/10), o que o PL **não** faz (conteúdo das comunicações, acesso livre, finalidade estranha, dados de terceiros, LGPD), o pedido e o status oficial em 21/10 — sem acusar crime nem prometer aprovação. A copy hardcoded do bloco do formulário da página de petição trocou "nesta campanha"/"luta dos trabalhadores" pelos literais genéricos "Sua assinatura fortalece esta mobilização." / "Os dados serão usados apenas para registrar sua participação e a comunicação de conteúdos relacionados." (vale para as 3 petições existentes; autorizado no plano de intenção). Sem migration, sem collection/field novo, sem Consent novo; `Contact`+`Signature`+`Subscription` e a transação existentes não mudaram. Testes: unit novo de invariantes do conteúdo (identidade, 5 blocos, fatos oficiais, links, guardrail editorial) + pin da copy na página; smoke real em navegador (página 200, consent renderizado, assinatura nº 1 e compartilhamento). Sem e2e novo (a suíte não tem e2e de petição e o fluxo não mudou). Dívida registrada em #1447 (fora de escopo): o JSON-LD da petição serializa sem escapar `<`, diferente do artigo — conteúdo é admin-only, mas o escape deve entrar numa próxima passada. Impl plan: `docs/plans/abaixo-assinado-pl-5594-2026-impl.md`.

View file

@ -0,0 +1,126 @@
# Impl: Abaixo-assinado do PL 5594/2026 — transparência dos presidenciáveis (S48)
Status: aprovado
Atualizado em: 2026-10-21
Issue: #1443
Intenção: docs/plans/abaixo-assinado-pl-5594-2026.md
Appetite restante: herdado — ~0,5 dia eng, sem corte
## Leitura da intenção
- **Outcome:** a petição `pl-5594-2026` existe `enabled`, com título/subtítulo/corpo/consent, página 200 em `/abaixo-assinado/pl-5594-2026`, assinável e compartilhável, com contador e SEO atual.
- **O que NÃO negociar:** sem cadastro paralelo a `Contact`; sem acusar crime nem prometer aprovação; sem Consent novo (reusar `whatsapp-inscricao`); sem migration/collection nova; `id` é a URL pública e não muda; sem PII em log.
- **O que reavaliar:** a hipótese "conteúdo criado no admin" não é repetível/auditável — vira seed versionado; "sem código novo" vira seed + copy + testes (sem schema).
## Abordagem recomendada
```mermaid
flowchart LR
A[petitionPl5594Content.mjs] --> B[seed-petition-pl-5594.mjs]
B -->|getConsentByKey| C[(Consent whatsapp-inscricao)]
B -->|create-if-missing| D[(petition pl-5594-2026)]
D --> E[/abaixo-assinado/pl-5594-2026/]
E --> F[getCachedDocumentById + SignatureCounter + PetitionForm]
G[copy literals na page] --> E
```
**Opções consideradas:** A) criar a petição só no admin; B) migration de dados; C) seed idempotente versionado + ajuste de copy + testes unitários.
**Recomendação:** C — o conteúdo nasce no repo, é revisável/testável, reproduzível em worktree/staging/produção e não toca schema.
**Rejeitadas:** A porque não é repetível/auditável e some do repo; B porque a intenção proíbe migration e conteúdo editorial não pertence a migration.
### Componentes / mudanças
- **`scripts/lib/petitionPl5594Content.mjs`**: literais (id, título, subtítulo, `form.title` "Assine o abaixo-assinado"/`form.subtitle` "Preencha seus dados para apoiar o PL 5594/2026.", consent key), 5 blocos HTML e links oficiais; fonte única para seed e testes.
- **`scripts/seed-petition-pl-5594.mjs`**: dry-run por padrão; `--apply`; create-if-missing por `id`; consent via `getConsentByKey` (fail-closed); HTML→Lexical com `editorConfigFactory.default` + JSDOM (padrão de `seed-posts.mjs`).
- **`package.json`**: `"seed:petition-pl-5594": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-petition-pl-5594.mjs"`.
- **`src/app/(frontend)/abaixo-assinado/[id]/page.tsx:204-213`**: só copy (D2); canonical/OG/JSON-LD existentes, sem SEO novo.
- **Testes**: novo `tests/unit/petitionPl5594Content.unit.spec.ts`; ajuste de `tests/unit/petition-page-layout.unit.spec.ts`.
- **Changelog:** `docs/changelog/2026-10-22-s48-peticao-pl-5594.md` (copy global + passo de seed em produção).
- **Migration:** nenhuma; nenhuma collection/field/access alterado.
- **Access / Consent:** access inalterado; Consent existente `whatsapp-inscricao` resolvido por key (`overrideAccess` intencional), nunca por id hardcoded; ausência → seed aborta.
- **UI:** Impeccable A — nenhuma UI nova; reusa page, `PetitionForm` e `SignatureCounter`; sem shape/craft/critique.
### Dados → forma
- Contador absoluto já existente (`SignatureCounter` + `getSignatureCount`); sem percentual, ranking ou meta. Nenhuma forma nova.
## Decisões de engenharia
**D1 — Provisionamento do conteúdo.**
Opções: A) criar só no admin; B) migration de dados; C) seed dedicado versionado, dry-run e create-if-missing.
Recomendação: C — idempotente por `id`, auditável, testável em worktree/staging; dry-run default, `--apply`, guards `PETITION_SEED_CONFIRM=1` + `assertEnvironmentDatabaseTarget`, npm script `seed:petition-pl-5594`; se existir, reporta `_status`/`enabled` e não sobrescreve (admin vira dono).
Rejeitadas: A porque não é repetível nem revisável no repo; B porque viola a intenção e mistura editorial com schema; upsert/sobrescrita porque apagaria ajuste feito no admin.
**D2 — Copy do bloco do formulário.**
Opções: A) novos campos no schema; B) editar as 3 strings hardcoded; C) manter.
Recomendação: B — headline "Sua assinatura fortalece esta mobilização."; parágrafo "Os dados serão usados para registrar sua participação e a comunicação de conteúdos relacionados."; eyebrow "Participe da mobilização" intacto. Afeta as 3 petições existentes, autorizado na intenção; registrar no changelog.
Rejeitadas: A porque exige migration/field para copy e não há demanda de admin; C porque "campanha"/"trabalhadores" contradizem os literais de produto.
**D3 — Corpo em 5 blocos.**
Opções: A) Lexical JSON à mão; B) HTML versionado convertido por `convertHTMLToLexical`; C) texto corrido.
Recomendação: B — HTML legível/revisável, reusa o padrão de `seed-posts.mjs`; h2 nos 5 blocos (o que é; por que agora; o que o PL não faz; o pedido; links oficiais), listas e links para ficha/inteiro teor.
Rejeitadas: A porque é verboso e frágil de revisar; C porque perde h2/listas/links exigidos.
Fatos: ementa oficial (transparência patrimonial, fiscal e financeira; altera a Lei 9.504/1997); autorização expressa no registro (Pres/VP); 5 exercícios fiscais anteriores de IR; dados bancários/financeiros; registros telefônicos e de conexão/acesso sem o conteúdo das comunicações; acesso exclusivo por autoridades competentes (fiscalização eleitoral, verificação patrimonial, apuração de indícios concretos); auditoria (responsável/finalidade/data/categorias); confidencialidade e vedação de finalidade estranha; conteúdo só com ordem judicial; LGPD (Art. 7); Justiça Eleitoral divulga o autorizado resguardando terceiros (Art. 3 §2º); complementação com contraditório (Art. 5); vigência após adaptação (Art. 8).
Guardrails: motivação 18/10 cita o desafio de Lula a Flávio Bolsonaro e o protocolo de Solla em 19/10 (sem tramitação posterior em 21/10), sem afirmar crime/ilícito/recusa não comprovada; pedido de tramitação/aprovação sem prometer resultado; `form.title`/`form.subtitle` são copy do seed (a intenção não os fixa).
**D4 — Testes.**
Opções: A) unit do conteúdo + pin da copy; B) acrescentar int; C) e2e novo.
Recomendação: A — invariantes puros (id/título/subtítulo/consent key/5 blocos/fatos/links; ausência de "crime", "ilícito", "corrupção", "desvio", "será aprovado") + pin da copy com whitespace normalizado (JSX quebra linha); fluxo de assinatura intocado.
Rejeitadas: B porque o write path/transação não muda e o int existente já cobre; C porque não há interação nova, a suíte não tem e2e de petição e o custo/flakiness não se paga.
**D5 — Aplicação em produção e cache.**
Opções: A) deploy aplica o seed; B) passo manual pós-merge documentado; C) não aplicar.
Recomendação: B — no checkout do homeserver (deps de dev, como nos demais seeds), com o env do stack carregado: `TEQO_ENV=production PETITION_SEED_CONFIRM=1 pnpm seed:petition-pl-5594 --apply`; registrar no changelog/PR.
Rejeitadas: A porque deploy não deve publicar conteúdo editorial e o guard exige intenção explícita; C porque o aceite exige a página em produção.
Limitação: o seed roda fora do processo do Next e não busta `document_petition:<id>`; `findByID` lança 404 (não há entrada negativa no `unstable_cache`), mas uma visita anterior ao slug pode ter cacheado a resposta 404 na rota. Após o seed, reiniciar/redeployar o container (ou salvar a petição uma vez no admin) antes de divulgar; sem endpoint novo.
**D6 — Escopo negativo.**
Opções: A) incluir agora; B) cortar e registrar.
Recomendação: B — e2e novo, CTA home/footer (S47), OG nova, consent novo e conserto do escape do JSON-LD ficam fora; a dívida do JSON-LD (`page.tsx:150-153`) vai para o changelog/issue.
Rejeitadas: A porque estoura o appetite e mistura concerns.
## Fases verificáveis
1. **Fase 1 — conteúdo + seed (tracer, ~0,25 dia):** content module + seed + npm script; dry-run no worktree resolve consent e mostra o plano; `--apply` cria `pl-5594-2026` `enabled`/`published`; segunda execução é no-op.
2. **Fase 2 — copy (UI, ~0,05 dia):** editar as 3 strings; nenhum componente novo; conferir `PetitionForm` data-driven.
3. **Fase 3 — testes (~0,1 dia):** unit novo e pin da copy; rodar os dois specs.
4. **Fase 4 — verificação local (~0,05 dia):** com o DB do worktree, `GET /abaixo-assinado/pl-5594-2026` → 200; conferir título, 5 h2, links oficiais, contador e consent; uma assinatura manual de fumaça com dados fictícios (sem teste commitado). Sem e2e novo: o fluxo não mudou e a suíte não tem precedente de petição.
5. **Fase 5 — gates e entrega (~0,05 dia):** `pnpm gate:fast`, changelog, PR com o passo de produção; `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Não editar schema/collection/Consent; não criar migration; não "consertar" o SEO existente nem o escape do JSON-LD neste PR.
- Não transformar o seed em upsert, importador genérico de petições ou runner de deploy.
- Não criar e2e/listing/meta de assinaturas/ranking; não adicionar imagem OG ou CTA na home (S47).
- Não duplicar leitura de petição: reusar `getCachedDocumentById`, `getSignatureCount`, `getConsentByKey`.
## Débitos da sessão (triage)
- **Registrado:** escape de `<` no JSON-LD da petição — Issue #1447 (`S48-FOLLOWUP-JSONLD`, plano `docs/plans/json-ld-escape-peticao-impl.md`).
- **Deferido com gatilho:** int test do write path do seed (HTML→Lexical + `_status`/`enabled`/`form.consent`) — reabrir na próxima edição de conteúdo/seed ou upgrade do Payload no `create`; hoje os invariantes do conteúdo estão em unit e o create foi provado em DB temporário.
- **Descartado:** rename do npm script (`seed:face-*` é precedente) e reparo gramatical de literal fixado pela intenção.
## Riscos e mitigação
- **`id` é URL pública e PK:** definido uma vez no seed; create-if-missing nunca altera; revisão de conteúdo não mexe no id.
- **Consent ausente/errado:** resolução por key fail-closed; sem id hardcoded; dry-run mostra o alvo antes de escrever.
- **Seed rodado no ambiente errado:** `assertWriteConfirm` (`PETITION_SEED_CONFIRM`) + `assertEnvironmentDatabaseTarget` (`TEQO_ENV` casando `teqo_1313`/`teqo_staging`); write só com `--apply`.
- **Cache negativo do 404:** documentado em D5; operador reinicia/redeploya ou salva no admin após o seed.
- **Copy afeta 3 petições:** mudança intencional e autorizada; changelog registra; rollback é reverter o commit.
- **Divergência editorial pós-seed:** o seed não sobrescreve; o admin é dono; testes pinam o conteúdo versionado, não o DB.
## Aceite de engenharia
- [ ] Petição `pl-5594-2026` criada `enabled`/`published`, consent `whatsapp-inscricao` resolvido por key, sem migration/collection nova.
- [ ] Corpo com 5 blocos e fatos oficiais; sem acusação de crime e sem promessa de aprovação.
- [ ] Copy nova na page; eyebrow intacto; `PetitionForm` continua data-driven.
- [ ] Seed dry-run por padrão, create-if-missing, guards e npm script; segunda execução no-op.
- [ ] Testes de domínio: unit novo/atualizado verde; sem int porque o write path não muda; `pnpm gate:fast` limpo.
- [ ] Verificação local: página 200, corpo/consent/contador renderizados, assinatura de fumaça OK.
- [ ] Changelog + passo de seed em produção documentados no PR.
- [ ] Invariantes AGENTS/engineering-standards: sem PII em log; access/LGPD inalterados; sem migration.
- [ ] Aceite de produto da intenção ainda coberto.
## Self-score decision-quality
5/5 — todas as decisões caras têm rejeitadas (D1–D6); appetite herdado sem corte; rabbit holes nomeados; depth check reusa `getConsentByKey`, `getCachedDocumentById`, `getSignatureCount`, `seed-loader` e o padrão de `seed-posts`; o outcome de produto permanece intacto.

View file

@ -0,0 +1,56 @@
# Impl: S48-FOLLOWUP — escape de `<` no JSON-LD da página de petição
Status: aprovado
Atualizado em: 2026-10-22
Issue: S48-FOLLOWUP-JSONLD
Intenção: débito registrado na entrega da Issue #1443 (`docs/plans/abaixo-assinado-pl-5594-2026-impl.md`, D6)
Appetite: ~1h eng — correção pontual de paridade de segurança, sem mudança de shape
## Leitura do débito
- **Outcome:** o JSON-LD das páginas públicas nunca é quebrado por um valor de conteúdo que contenha `</script>`; a página de petição passa a ter a mesma proteção que a página de artigo.
- **O que NÃO negociar:** o shape do JSON-LD (`Article`), a URL canônica e o HTML renderizado não mudam; conteúdo continua admin-only; sem schema/migration.
- **O que reavaliar:** o artigo repete a serialização inline — a correção deve extrair o dono do concern em vez de criar uma segunda cópia.
## Abordagem recomendada
**Opções:** A) repetir `JSON.stringify(jsonLd).replace(/</g, '\\u003c')` inline na petição; B) extrair `serializeJsonLd(value)` em `src/utilities/seo.ts` e usar nas duas páginas; C) sanitizador/escaping de HTML completo.
**Recomendação:** B — `src/utilities/seo.ts` já é o dono do SEO; uma função pura (string JSON com `<` escapado para `\u003c`) elimina a divergência entre artigo e petição e é unit-testável. Aplicar nas duas páginas (`[type]/[category]/[slug]/page.tsx` e `abaixo-assinado/[id]/page.tsx`).
**Rejeitadas:** A porque twina a lógica e deixa a próxima página pública repetir o erro; C porque `JSON.stringify` já cuida de aspas/backslashes e o vetor real é o fechamento de `</script>` — um sanitizador amplo seria cerimônia sem volatilidade.
### Componentes / mudanças
- **`serializeJsonLd`** (`src/utilities/seo.ts`): `JSON.stringify(value).replace(/</g, '\\u003c')`, com comentário do vetor `</script>`.
- **Duas páginas públicas**: trocar o `JSON.stringify` inline pelo helper; nenhuma mudança de shape.
- **Teste**: unit do helper (`</script>` vira `\u003c/script>`, strings normais intactas, JSON continua parseável) e pin de que as duas páginas usam o helper (source-level, como o pin da copy da petição).
- **Migration:** nenhuma.
- **UI:** Impeccable A — sem UI nova; nenhum trigger do `designer`.
## Fases verificáveis
1. **Helper + adoção** — extrair e usar nas duas páginas.
2. **Testes** — unit do helper + pin das páginas; `pnpm test:unit`.
3. **Gates** — `pnpm gate:fast`; entrega com `pnpm push`.
## Rabbit holes / Não escopo
- Não criar sanitizador genérico, não escapar `>`/`&` por reflexo, não mudar o `@type`/campos do JSON-LD.
- Não tocar em canonical/OG/metadata; não criar rota/endpoint.
## Riscos e mitigação
- **Quebrar o JSON-LD** (escape errado): o unit test faz `JSON.parse` do resultado e compara o objeto; a página continua renderizando o script.
- **Divergência futura**: o helper único + pin source-level impede uma terceira cópia inline.
## Aceite de engenharia
- [ ] `serializeJsonLd` é o único caminho de serialização JSON-LD nas páginas públicas.
- [ ] `</script>` em título/subtítulo não fecha o script; JSON permanece válido.
- [ ] Sem schema/migration/URL nova; unit verde e `pnpm gate:fast` limpo.
- [ ] Invariantes AGENTS/engineering-standards preservados (sem PII em log, access inalterado).
## Self-score decision-quality
5/5 — decisão cara (onde mora a serialização) tem rejeitadas; appetite de 1h; rabbit holes nomeados; depth check reusa o dono `src/utilities/seo.ts` em vez de twinar; o outcome original da S48 não é reescrito.

View file

@ -0,0 +1,318 @@
# Impl: Site público pós-eleição — retirar a propaganda e agradecer os 152.049 votos (S47)
Status: aprovado (modo --auto)
Atualizado em: 2026-10-21
Issue: #1442
Intenção: docs/plans/site-pos-eleicao-agradecimento.md
Design UI: docs/plans/site-pos-eleicao-agradecimento-ui-design.html
Appetite restante: herdado (~1–2 dias eng; corte explícito: sem redesenho das páginas internas além do título/shell, sem migration, sem asset novo)
Self-score decision-quality: 5/5 — as 8 decisões caras têm opções e rejeitadas, o escopo cabe no appetite, os rabbit holes estão nomeados, os shells existentes são reusados e o aceite de produto permanece o norte.
## Leitura da intenção
- **Outcome:** o site público deixa de ser peça de campanha — nenhuma superfície pública exibe 1313 como número eleitoral, pedido de voto, “candidato”, “propaganda eleitoral” ou CNPJ de campanha; a home abre com “Obrigado, Bahia — 152.049 votos de confiança” e segue como casa institucional do mandato (notícias, trajetória, bandeiras, fotos e selfie), com rodapé institucional.
- **O que NÃO negociar:**
- O número **152.049** é o literal TSE (totalização de 05/10/2026), não percentual, ranking ou dashboard.
- O logo visível é o lockup de nome (`jorge-solla-positivo/negativo`), nunca `marca-*-completa`/`numero-*`.
- Sem migration, sem Consent novo, sem PII nova, sem apagar dados do CMS.
- `/fotos`, `/artigos`, `/privacidade` e o acervo de fala continuam; `/potencial` e a Plenária ficam até 25/10 e saem depois (decisão de produto já assumida).
- O domínio/canonical atual continua até OPS131; nada de `/campanha`.
- O design hi-fi aprovado é o alvo do port.
- **O que reavaliar (hipóteses da intenção que o código desmente):**
- O board atual é 1+4 com carrossel mobile e feeds; o design pede 1+3 editorial e **sem carrossel obrigatório**.
- O `/artigos` atual usa hero em vermelho com imagem do global `home`; o design pede título compacto em creme — a imagem passa a ser OG, não hero.
- O `/fotos` atual tem header vermelho e `ArchivePhotoHero` de campanha; o design pede header positivo em creme e título compacto.
- O `CampaignFooter` hoje é condicional (Jingles/Conteúdos/Fotos) e eleitoral; vira institucional com Fotos sempre visível.
- O vídeo da trajetória é um Short 9:16 real; o 16:10 do gate é composição, não mídia existente.
## Abordagem recomendada
```mermaid
flowchart LR
D[Design S47<br/>1280 + 390] --> SH[CampaignPageHeader<br/>+ CampaignMobileMenu]
SH --> HOME["(home)/page.tsx"]
HOME --> HERO[CampaignHero<br/>Obrigado, Bahia · 152.049]
HOME --> BOARD[CampaignContentSection<br/>1+3 editorial]
HOME --> STORY[CampaignStorySection]
HOME --> FLAGS[CampaignFlagsSection<br/>3×2 hairlines]
HOME --> PHOTOS[CampaignPhotoSection<br/>2 fotos + quote]
HOME --> EXT[Plenária · Selfie<br/>condicionais/fail-closed]
HOME --> FOOT[CampaignFooter<br/>institucional]
BOARD --> POSTS[getVisiblePosts + YouTube + Instagram]
BOARD --> SHARE[ContentShareButton<br/>S4 preservado]
PHOTOS --> ARCH[getApprovedArchivePhotoItems]
SH --> ART["/artigos + /fotos<br/>header + título creme"]
FOOT --> ART
R["/conteudos · /jingles · /cards"] --> RED[next.config.mjs<br/>308]
RED --> ART
RED --> HOME
```
**Opções consideradas:** A) editar os donos existentes (header/footer/hero/board/story) e portar o design na home + shell, com redirects das rotas de campanha; B) criar um “site do mandato” paralelo (novo shell/novas rotas); C) manter as páginas de campanha publicadas, só escondendo-as do menu.
**Recomendação:** **A** — é o único caminho que cabe em ~1,5 dia: reusa `CampaignHero`, `CampaignContentSection`, `CampaignStorySection`, `CampaignFooter`, `CampaignPageHeader`, `CampaignLogoLink`, `ShareLinkHomeSection` e `SelfieSearchHomeSection`, troca somente a composição/copy e remove as superfícies de campanha. Evita collection, migration e um segundo design system.
**Rejeitadas:** **B** porque duplica shell, cria uma segunda navegação e estoura o appetite (o site institucional completo é PUB2); **C** porque mantém as rotas acessíveis/indexáveis e o código de campanha vivo, falhando o aceite “saem do ar”.
### Ordem final da home (alvo do port)
| # | Seção | Dono no código | Conteúdo/observação |
| --- | ---------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Header | `CampaignPageHeader` (`variant="home"`) | Lockup negativo sobre vermelho; nav desktop **Mandato · Bandeiras · Fotos**; menu mobile acessível |
| 2 | Hero de agradecimento | `CampaignHero` (reescrito) | Eyebrow **Obrigado, Bahia**; h1 **152.049 / votos de confiança**; copy do design; CTA **Acompanhe o mandato** → `/artigos`; identidade; retrato `JOA00162.webp` |
| 3 | Plenária (condicional) | `ShareLinkHomeSection` | Mantida **logo após o hero** até 25/10; ausente por CMS/kill switch; não é CTA concorrente |
| 4 | Board editorial | `CampaignContentSection` (reescrito) | **1+3** em creme com hairlines; 4 itens (posts + YouTube + Instagram); sem carrossel mobile; estado vazio “Ainda não há publicações” |
| 5 | Trajetória | `CampaignStorySection` | **Nossa história / Saúde pública como escolha de vida**; Short 9:16 existente; CTA “Assistir à trajetória” |
| 6 | Bandeiras | `CampaignFlagsSection` (novo) | **Bandeiras do mandato / Compromissos que orientam o trabalho**; 3×2 desktop, 1×6 mobile, hairlines, sem cards flutuantes |
| 7 | Fotos | `CampaignPhotoSection` (novo) | **Perto da Bahia / O mandato em fotos**; 2 fotos aprovadas + tile “Trabalho presente, memória viva.”; link `/fotos`; estado vazio “O álbum está sendo preparado” |
| 8 | Selfie (condicional) | `SelfieSearchHomeSection` | Mantida após as fotos; só com álbum publicado + flag + foto aprovada (C243) |
| 9 | Rodapé | `CampaignFooter` (reescrito) | Institucional: lockup, cargo/partido/estado, Navegue (Artigos · Fotos · Privacidade), Redes, linha legal; sem CTA/CNPJ/turno |
O design mostra 1–2–4–5–6–7–9; as seções 3 e 8 são exceções vivas do produto, condicionais e fail-closed, e não alteram a dobra.
### Contrato visual (resumo do gate)
- **Tokens/fontes:** `--pt-red #a21c1c`, `--pt-red-dark #47130e`, `--pt-yellow #ffe607`, `--campaign-cream #fff8f2`, `--campaign-band #ebe9e9`, `--campaign-line rgb(0 0 0 / 12%)`, `--campaign-muted #6c615e`; Exo 2 (display) e Arimo (texto) já carregadas em `fonts.ts`/`styles.css`. Nenhuma fonte/paleta nova.
- **Geometria:** conteúdo máximo **1152px**; header desktop **88px**, mobile **74px**; hero desktop mínimo **668px**, mobile **760px**; um único CTA primário na dobra; board **7/12 + 5/12** no desktop e coluna única no mobile; bandeiras 3×2; fotos 530px no desktop; cantos 8–12px; sem sombra na página real.
- **Estados:** board vazio “Ainda não há publicações”; álbum vazio “O álbum está sendo preparado”; nunca buraco silencioso.
- **A11y:** foco C243 (3px amarelo + separador azul-escuro `#184e92`) em superfícies claras; amarelo visível no escuro; menu mobile abre/fecha no trigger, fecha no X/link/fora/Escape, foca o primeiro link ao abrir e devolve o foco ao trigger; alt contextual; `prefers-reduced-motion` remove transições.
- **Proibido na superfície:** 1313, pedido de voto, “candidato”, “propaganda eleitoral”, CNPJ eleitoral, data/turno, “Quero apoiar”, newsletter/captação, Central, cards eleitorais, jingles/rádio, `marca-*-completa`, `numero-*`, ranking/percentual/dashboard.
### Componentes / mudanças
- **`CampaignPageHeader`** (`src/components/CampaignPageHeader.tsx`): dono único do topo público; ganha `variant: 'home' | 'internal'`, `current?: 'artigos' | 'fotos'` e mantém `badge?` (share link). Home = fundo vermelho/lockup negativo/nav branca; interno = creme/lockup positivo/nav vermelha. Renderiza o logo no servidor e delega só o disclosure mobile ao cliente.
- **`CampaignMobileMenu`** (`src/components/CampaignMobileMenu.tsx`, novo, client): disclosure acessível do menu mobile; sem dialog/trap (não é modal), com `aria-expanded`, `aria-controls`, `hidden`, foco inicial/retorno e fechamento por Escape/clique fora/link.
- **`CampaignLogoLink`** (`src/components/CampaignLogoLink.tsx`): continua dono do crop do lockup; ganha `tone: 'negative' | 'positive'` e `priority?`; corrige `aria-label` para “Início — Jorge Solla” e alt “Jorge Solla — Deputado Federal”; positiva usa janela 160×58 desktop/128×46 mobile (img 209px; offsets −24,8/−32,6).
- **`CampaignFooter`** (`src/components/CampaignFooter.tsx`): vira o rodapé institucional; perde `showJingles/showConteudos/showFotos`; `current?: 'artigos' | 'fotos'`; Fotos sempre visível (a página do álbum tem estado vazio honesto); sem `Quero apoiar`, CNPJ, propaganda ou turno.
- **`CampaignHero`** (`src/components/CampaignHero.tsx`): mesmo dono, conteúdo novo; constante local `THANK_YOU_VOTE_TOTAL = '152.049'`; h1 com `aria-label` completo; CTA único `/artigos`; retrato e composição do gate; nenhum “Quero apoiar”/1313.
- **`CampaignContentSection`** (`src/components/CampaignContentSection.tsx`): mantém o pipeline de posts + YouTube + Instagram e o share (S2/S3/S4); reescreve o layout para 1+3, remove o carrossel mobile e passa a renderizar o estado vazio em vez de `null`; limite 4.
- **`CampaignContentCard`** (`src/components/CampaignContentCard.tsx`): ganha `variant: 'featured' | 'compact'`; compact é texto (sem capa) com kicker vermelho, título e “Ler notícia/abrir”; o share continua como ação secundária acessível.
- **`CampaignStorySection`** (`src/components/CampaignStorySection.tsx`): copy do design; mantém o iframe `youtube-nocookie` 9:16 existente.
- **`CampaignFlagsSection`** (`src/components/CampaignFlagsSection.tsx`, novo): seis bandeiras do design em `<ol>` com hairlines; substitui `CampaignFlagCard`/`CampaignCarousel` e a copy eleitoral.
- **`CampaignPhotoSection`** (`src/components/CampaignPhotoSection.tsx`, novo): recebe `ArchivePhotoPublicItem[]`; 2 fotos + quote tile; empty state; usa `thumbnailPath` (proxy privado) e alt curado.
- **`(home)/page.tsx`**: nova composição; uma leitura de `getApprovedArchivePhotoItems()` serve fotos + gate da selfie (sem o matching facial, que só o álbum precisa); mantém Plenária/selfie condicionais; remove proof/problem/cards/sound/content-pieces/newsletter.
- **`publicSite`** (`src/lib/publicSite.ts`, novo): caminhos, links sociais oficiais e receita de foco compartilhados por header/footer/board; evita strings duplicadas.
- **Migration:** **nenhuma**. Nenhuma collection/global/field é alterado.
- **Access / Consent:** nenhum novo. Remove-se o caminho da newsletter (action/schema/helper/chave `campanha-novidades`); a linha `Consent` e as subscriptions existentes **não** são apagadas.
- **UI:** Impeccable **C**; shape→craft→critique→polish com o `designer`; shells existentes; sem novo design system.
### Arquivos por área
**Shell e páginas internas**
| Arquivo | Ação | Detalhe |
| ---------------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/lib/publicSite.ts` | criar | `PUBLIC_PATHS`, `PUBLIC_SOCIAL_LINKS`, `PUBLIC_FOCUS`; usado por header/footer/board |
| `src/components/CampaignLogoLink.tsx` | editar | `tone`, `priority`, crop positivo/negativo, alt/aria sem 1313 |
| `src/components/CampaignPageHeader.tsx` | reescrever | variantes home/internal, nav, current, badge; sem 1313 no alt/aria |
| `src/components/CampaignMobileMenu.tsx` | criar (client) | disclosure a11y completo |
| `src/components/CampaignFooter.tsx` | reescrever | institucional, sem flags, `current`, Fotos sempre |
| `src/app/(frontend)/artigos/layout.tsx` | editar | `data-theme="campaign-site"`, `CampaignPageHeader current="artigos"`, `CampaignFooter current="artigos"`; sai `SiteHeader`/`Footer` |
| `src/app/(frontend)/artigos/page.tsx` | editar | título creme “Mandato em movimento / Artigos e notícias”; `generateMetadata` com canonical `/artigos` e OG de `home.image`; manter grade de últimas notícias; remover hero/imagem/CTA |
| `src/app/(frontend)/fotos/page.tsx` | editar | header compartilhado; título creme “Memória do mandato / Fotos”; remover `ArchivePhotoHero`, flags de rodapé e `hasPublishedJingles`/`hasPublishedContentPieces`; manter busca/filtros/grade/lightbox/removal |
| `src/app/(frontend)/fotos/encontre/page.tsx` | editar | header compartilhado; h1 “Encontre você nas fotos” + descrição; remover flags/`hasPublishedContentPieces`; título sem 1313 |
| `src/app/(frontend)/fotos/[id]/midia/route.ts` | editar comentário | nome legível vem de `downloadFilename`; atualizar o literal do comentário |
| `src/lib/archivePhotoPublicCatalog.ts` | editar | `DOWNLOAD_PREFIX = 'jorge-solla-foto'` |
| `src/components/fotos/ArchivePhotoPageHeader.tsx` | deletar | substituído pelo header compartilhado |
| `src/components/fotos/ArchivePhotoHero.tsx` | deletar | substituído pelo título creme |
| `src/app/(frontend)/(home)/potencial/page.tsx` | editar | header/footer novos; remover flags de descoberta; manter estúdio até 25/10 |
| `src/components/shareLink/ShareLinkAnnouncement.tsx` | editar | remover props `showJingles/showFotos`; header/footer novos |
| `src/app/(frontend)/[type]/page.tsx` | editar | remover reads de jingles/fotos e props do anúncio |
| `src/utilities/seo.ts` | editar | `SITE_METADATA_DEFAULTS.description` → “Notícias, o mandato e a atuação do deputado Jorge Solla pela Bahia.” |
| `next.config.mjs` | editar | `async redirects()` com 308 |
**Home**
| Arquivo | Ação | Detalhe |
| --------------------------------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `src/app/(frontend)/(home)/page.tsx` | reescrever composição | header, hero, plenária, board, story, flags, photos, selfie, footer; sem proof/problem/cards/sound/content-pieces/newsletter |
| `src/components/CampaignHero.tsx` | reescrever | hero de agradecimento; literal 152.049; CTA `/artigos`; retrato |
| `src/components/CampaignContentSection.tsx` | reescrever | 1+3, 4 itens, empty state, links fixos YouTube/Instagram, feeds/share preservados |
| `src/components/CampaignContentCard.tsx` | editar | variante compacta sem capa; share secundário |
| `src/components/CampaignContentCarousel.tsx` | deletar | design sem carrossel obrigatório |
| `src/components/CampaignStorySection.tsx` | editar copy | “Saúde pública como escolha de vida” / “Assistir à trajetória” |
| `src/components/CampaignFlagsSection.tsx` | criar | seis bandeiras do design; `id="bandeiras"` |
| `src/components/CampaignPhotoSection.tsx` | criar | 2 fotos + quote; empty state |
| `src/components/fotos/SelfieSearchHomeSection.tsx` | manter | posição após fotos; gate C243 intacto |
| `src/components/shareLink/ShareLinkHomeSection.tsx` | manter | posição após hero; S44 intacto |
| `src/app/(frontend)/(home)/layout.tsx` | manter | scroll container + MetaPixel PageView; atualizar comentário |
| `src/app/(frontend)/styles.css` | limpar | remover blocos mortos de hero/proof/problem/flags/sound/cards/newsletter; manter tokens e `campaign-section-*` compartilhados |
| `src/components/CampaignHashScroll.tsx` | comentário | `#novidades` → `#bandeiras` |
| `src/components/RefreshRouteOnSave.tsx` | comentário | referência ao anchor antigo |
**Rotas, newsletter e órfãos**
| Arquivo | Ação | Detalhe |
| -------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/app/(frontend)/conteudos/**` | deletar | redirect 308 para `/artigos` |
| `src/app/(frontend)/jingles/**` | deletar | redirect 308 para `/` |
| `src/app/(frontend)/(home)/cards/**` | deletar | redirect 308 para `/` |
| `src/app/(frontend)/(home)/CampaignCardsSection.tsx` | deletar | seção de cards eleitorais |
| `src/components/CampaignCarousel.tsx`, `CampaignProblemCard.tsx`, `CampaignFlagCard.tsx` | deletar | sem consumidores após a home nova |
| `src/components/conteudos/**` | deletar | UI pública da Central; CMS/`/campanha` ficam |
| `src/components/jingles/**`, `src/lib/radio.ts`, `src/lib/jingle.ts`, `src/utilities/jingleReads.ts` | deletar | UI/reads públicos de jingles/rádio; collection `Jingle` fica |
| `src/lib/contentPieceHomeSelection.ts`, `src/lib/contentPieceShare.ts`, `src/utilities/content/contentPieceThemeSearch.ts` | deletar se órfãos | rodar `git grep -w` antes; `contentPieceReads` mantém só o slug read usado por `api/content-events` |
| `src/components/cards/**` (UI do estúdio) | deletar somente a UI pública | **manter** `cardCanvas.ts`/`cardCutout.ts` (selfie e story canvas) e `lib/cardModels`/`cardDownloadCounts`/`contentEvents` (dashboard interno) |
| `src/components/CampaignNewsletterForm.tsx`, `src/app/(frontend)/(home)/CampaignNewsletterSection.tsx` | deletar | captura de campanha |
| `src/app/(frontend)/actions/submitCampaignNewsletter.ts`, `src/lib/schemas/campaignNewsletter.ts` | deletar | action/schema exclusivos |
| `src/lib/campaignConsentKeys.ts`, `src/utilities/campaignConsent.ts` | editar | remover chave/helper `campanha-novidades`; demais Consent intactos |
| `src/app/(frontend)/api/content-events/route.ts` | manter | eventos históricos; sem UI nova |
| `src/collections/**`, `src/globals/**`, `src/app/(campaign)/**` | não tocar | sem migration, sem PII, sem deleção de CMS |
**Testes, scripts e docs**
| Arquivo | Ação | Detalhe |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tests/unit/campaignHome.unit.spec.tsx` | reescrever | hero/head/board/flags/photos/footer; sem jingles/content pieces/cards/newsletter/pixel da page |
| `tests/unit/campaignHero.unit.spec.tsx` | reescrever | 152.049, CTA `/artigos`, sem 1313/candidato/Quero apoiar |
| `tests/unit/campaignPageHeader.unit.spec.tsx`, `campaignMobileMenu.unit.spec.tsx`, `campaignFooter.unit.spec.tsx` | criar | nav/current, menu (Escape/fora/foco), rodapé sem 1313/CNPJ |
| `tests/unit/campaignPhotoSection.unit.spec.tsx` (opcional) | criar | 0/1/2 fotos + quote + empty |
| `tests/unit/campaignCarousel.unit.spec.tsx`, `jingleHomeSection`, `jinglePlayer`, `radioPlayer`, `radio`, `jingle`, `campaignNewsletterLead`, `contentPieceHome*` | deletar | superfícies removidas |
| `tests/unit/siteMetadata.unit.spec.ts` | editar | default sem “campanha”/1313 |
| `tests/e2e/frontend.e2e.spec.ts` | reescrever o describe da home | hero/menu/board/flags/photos/footer/empty states/redirects; manter describes S1/S2/S3/S4 do board; apagar blocos de cards/newsletter/sound/carrossel |
| `tests/e2e/frontendFotos.e2e.spec.ts` | editar | header positivo + crop S36, nome `jorge-solla-foto-<id>`, seção de fotos da home, sem 1313 |
| `tests/e2e/frontendShareLink.e2e.spec.ts` | editar | ordem `hero → plenaria → contents`; footer/header novos; fixtures sem 1313 |
| `tests/e2e/frontendJingles.e2e.spec.ts`, `frontendConteudos.e2e.spec.ts`, `campaignNewsletter.e2e.spec.ts` | deletar | páginas/superfícies removidas |
| `scripts/lib/e2e-affected-manifest.mjs` | editar | remover specs/chaves curadas; mapear componentes do shell para `frontend`/`frontendFotos`/`frontendShareLink`; manter o never-zero |
| `tests/unit/e2eAffectedManifest.unit.spec.ts` | editar | pin da lista curada sem newsletter/jingles/conteudos |
| `tests/int/submitCampaignNewsletter.int.spec.ts` | deletar | action removida |
| `tests/int/jingle.int.spec.ts` | deletar/atualizar | leitura pública some; collection fica |
| `tests/int/contentPiece.int.spec.ts` | atualizar | collection/CMS fica; reads públicos removidos |
| `AGENTS-public.md` | editar | footer sempre Fotos; download `jorge-solla-foto-<id>` |
| `docs/changelog/2026-10-21-s47.md` | criar | entrada única da entrega |
| `docs/TECH-DEBT.md` | editar | remover referência ao `submitCampaignNewsletter.ts` (P6-J) |
| `DESIGN.md` §7 | conferir | a direção S47 já está registrada; não reescrever |
## Decisões de engenharia
**D1 — Destino de `/conteudos`, `/jingles` e `/cards`.**
Opções: A) `redirects()` no `next.config.mjs` com 308 (`/conteudos` + `/conteudos/:path*` → `/artigos`; `/jingles` + `/jingles/:path*` → `/`; `/cards` → `/`) e apagar as rotas; B) manter arquivos finos com `permanentRedirect`; C) apagar e responder 404; D) manter publicadas fora do menu.
**Recomendação: A** — preserva links/SEO de URLs que já circularam, tira o código de campanha da árvore de render e não exige um layout/rota viva só para redirecionar. Query strings (`/cards?model=...`) são preservadas pelo redirect.
**Rejeitadas:** B mantém módulos/layouts de campanha vivos e o redirect só ocorre depois do render; C perde tráfego legítimo e indexa 404; D viola “saem do ar/do menu”. `/potencial` e `/plenaria-vitoria` **não** entram nessa decisão (ficam até 25/10).
**D2 — `CampaignFooter`/`CampaignPageHeader` compartilhados.**
Opções: A) evoluir os donos atuais para o shell institucional e atualizar todos os call sites; B) criar `MandateFooter`/`MandateHeader` novos e deixar os antigos para `/potencial`/share link; C) duplicar por página.
**Recomendação: A** — “edite o dono, não crie gêmeo”; `/fotos`, `/artigos`, `/potencial` e o anúncio de share link passam a usar o mesmo shell, e o comportamento eleitoral sai de todos de uma vez. Mantêm-se os nomes históricos `CampaignFooter`/`CampaignPageHeader` (renomear é cosmético e fica fora do appetite).
**Rejeitadas:** B cria dois shells com o mesmo contrato e mantém o código eleitoral vivo; C gera drift de nav/foco/alt em cada página. A variante visual mora em `variant`, não em arquivo novo.
**D3 — Newsletter (UI + action/Consent/teste).**
Opções: A) remover a seção, o form, a action, o schema, a chave/helper `campanha-novidades` e os testes; manter a linha `Consent` e as subscriptions existentes no CMS; B) manter o form com copy de mandato e novo Consent; C) manter o form desabilitado.
**Recomendação: A** — o consentimento existente foi dado para “novidades da campanha”; reaproveitá-lo para o mandato muda a finalidade sem revisão jurídica, e criar um novo Consent está fora do escopo (sem PII/Consent novo). A remoção também elimina o `Lead` de campanha; o Meta Pixel de PageView do layout permanece (tracking de site, não captura).
**Rejeitadas:** B exige novo texto/key e revisão LGPD; C deixa um formulário morto e falha o aceite. Consequências: `campaignNewsletter.e2e`, `campaignNewsletterLead.unit`, `submitCampaignNewsletter.int` saem; a lista curada do e2e é atualizada de propósito.
**D4 — Escopo da remoção de órfãos vs knip.**
Opções: A) apagar no mesmo PR toda a UI/helpers públicos que ficaram sem consumidor, mantendo os módulos compartilhados com `/fotos/encontre`, `/potencial` e `/campanha`; rodar `knip` e podar exports; B) deixar órfãos e afrouxar o knip; C) apagar as árvores inteiras de `components/cards` e `components/conteudos`.
**Recomendação: A** — knip é gate de CI. `components/cards/cardCanvas.ts` e `cardCutout.ts` alimentam a busca por selfie; `sectionStoryCanvas` usa `downloadBlob`; `lib/cardModels`/`cardDownloadCounts`/`contentEvents` alimentam o dashboard interno; `utilities/content/*` atende a Central do `/campanha`. Só morre a UI pública do funil de cards/Central/jingles.
**Rejeitadas:** B viola “dead code dies”; C quebra a selfie e o estúdio do `/potencial`. Regra de execução: `git grep -w <symbol>` antes de cada remoção e `pnpm knip` nu (sem `--fix` cego) no checkpoint.
**D5 — Board: feeds+share ou 1+3 estrito do design.**
Opções: A) manter o pipeline (posts + YouTube + Instagram) e o share S4, portando a composição 1+3 e removendo o carrossel; B) 4 posts visíveis apenas, sem feeds/share; C) manter o bento 1+4 atual.
**Recomendação: A** — o board continua fresco e preserva contratos S2/S3/S4; o 1+3/hairlines é a camada visual. O share fica como ação secundária (não é CTA de campanha) e o mobile empilha, como o design pede.
**Rejeitadas:** B descarta feeds e quebra testes/contratos sem pedido de produto; C ignora o design e mantém o carrossel que ele removeu. Gatilho de revisão: se o share poluir o board editorial, desligá-lo apenas na home (o card de artigo continua compartilhável).
**D6 — Selfie e Plenária fora do design.**
Opções: A) manter ambas condicionais e fail-closed (Plenária após o hero até 25/10; selfie após as fotos); B) remover as duas agora; C) manter selfie e remover Plenária.
**Recomendação: A** — a intenção manda manter; a Plenária é 2º turno até 25/10 e tem kill switch por CMS; a selfie é feature do acervo (C243) com gate próprio. Nenhuma compete com o CTA único do hero.
**Rejeitadas:** B corta produto vivo antes do 2º turno e uma feature de fotos que não pertence a esta Issue; C corta uma superfície de 2º turno ainda no ar. As duas continuam com `data-home-section` e testes próprios; a saída delas é operacional (despublicar) e não código deste PR.
**D7 — Metadados quando o global é CMS.**
Opções: A) o código corrige fallbacks e literais de página (home default, `/artigos`, `/fotos`, `/fotos/encontre`) e uma nota operacional manda editar o global `metadata` + `SiteSettings.headerTitle` no admin; sem migration e sem sobrescrever CMS; B) migration para reescrever o global; C) override incondicional no código.
**Recomendação: A** — o global é dado editorial de produção; migration sobrescreveria copy humana e viola “sem migration”; override quebra a autoridade do CMS. O canonical continua `metadata.URL` até OPS131.
**Rejeitadas:** B e C pelos motivos acima. A verificação pós-deploy é obrigatória: `curl` em `/`, `/artigos`, `/fotos` e uma página `[type]`; se “1313” aparecer no `<title>`, é CMS e o operador corrige no admin.
**D8 — Foto download sem 1313.**
Opções: A) trocar `DOWNLOAD_PREFIX` para `jorge-solla-foto` e atualizar o comentário/teste; B) manter `jorge-solla-1313-foto` por “contrato”; C) criar alias para o nome antigo.
**Recomendação: A** — o nome é `Content-Disposition`/atributo `download`, não rota nem canonical; nenhum link externo depende dele; o caminho `/fotos/<id>/midia` permanece. O aceite proíbe 1313 na superfície pública, inclusive no arquivo baixado.
**Rejeitadas:** B mantém o número eleitoral visível; C não faz sentido para um filename de resposta. Atualizar `AGENTS-public.md` e o teste e2e que pinam o nome.
### Decisões complementares (baratas, com gatilho)
- **Vídeo da trajetória:** manter o Short 9:16 real dentro do layout editorial; o frame 16:10 do gate é composição, não mídia. **Gatilho:** se chegar um corte horizontal oficial, portar o poster 16:10 e o play nesse PR.
- **Título interno:** portar o bloco compacto creme de `/artigos` (“Mandato em movimento / Artigos e notícias”) e `/fotos` (“Memória do mandato / Fotos”); o corpo (grade de notícias/busca do álbum) fica. `[type]`, `/corte`, `/privacidade` e `/mandato-no-whatsapp` não são redesenhados.
- **`HomePage` global:** a imagem deixa de ser hero e passa a ser a OG de `/artigos`; o global continua usado e não é apagado. **Gatilho:** se o editor quiser aposentar o global, é uma Issue de CMS separada.
- **Nomes `Campaign*`:** mantidos por histórico; renomear só quando houver um terceiro shell público.
- **Plenária/`potencial`:** saída após 25/10 é despublicar o share link e remover a rota `/potencial` numa entrega futura; não neste PR.
## Dados → forma
- **Forma escolhida:** um único número absoluto, **152.049**, no hero, com algarismos tabulares e `aria-label="152.049 votos de confiança"`. O número é memória de confiança, não placar.
- **Por quê:** é o único dado que a intenção quer apresentar; o contrato do design permite que ele domine a dobra, mas proíbe ranking, percentual estadual, série ou dashboard.
- **Rejeitadas:** percentual/ranking (convidam comparação e não são o produto), contador animado (ruído/motion, pouco sóbrio), campo no CMS (seria migration e o dado é um fato único). O literal vive numa constante local de `CampaignHero` com a fonte TSE no comentário; **não** acoplar ao artefato do 2º turno.
## Fases verificáveis
1. **Shell compartilhado — ~4h.**
`publicSite`, `CampaignLogoLink`, `CampaignPageHeader`, `CampaignMobileMenu`, `CampaignFooter`; wire em `/artigos`, `/fotos`, `/fotos/encontre`, `/potencial` e `ShareLinkAnnouncement`; apagar `ArchivePhotoPageHeader`/`ArchivePhotoHero`; título/metadata de `/artigos` e `/fotos`.
**Checkpoint:** unit de header/menu/footer/logo; `pnpm typecheck`; inspeção 390/1280 do header positivo/negativo e do foco por teclado.
2. **Home — ~5h.**
`CampaignHero`, `CampaignContentSection` (1+3 + empty), `CampaignContentCard` compacto, `CampaignStorySection` copy, `CampaignFlagsSection`, `CampaignPhotoSection`, `(home)/page.tsx`; manter Plenária/selfie; remover proof/problem/cards/sound/content-pieces/newsletter da composição.
**Checkpoint:** unit `campaignHome`/`campaignHero`/`campaignPhotoSection`; home renderiza 0/1/2 fotos e board vazio; sem overflow em 320/390/1280; ordem `hero → plenaria? → contents → story → flags → photos → selfie? → footer`.
3. **Rotas, newsletter e órfãos — ~3h.**
Redirects 308; apagar `/conteudos`, `/jingles`, `/cards`; apagar UI pública de Central/jingles/cards/newsletter; podar exports e testes; atualizar manifest/curadoria.
**Checkpoint:** `pnpm knip`, `pnpm check:cycles`, `pnpm test:unit -- e2eAffectedManifest`, `pnpm test:int`; redirects testados com `maxRedirects: 0` (308 + location).
4. **Copy, SEO e docs — ~2h.**
Grep de 1313/“candidato”/“propaganda”/CNPJ; download filename; títulos `/fotos`/`encontre`; `SITE_METADATA_DEFAULTS`; canonical `/artigos`; `AGENTS-public.md`; changelog; tech-debt.
**Checkpoint:** `pnpm gate:fast`; `pnpm format:check`; e2e de home/fotos/artigos sem 1313 no header/hero/footer/título.
5. **Verificação e PR — ~2h.**
`pnpm gate:ci` (mesma cascata do PR, inclui build/e2e selecionado); rodar o conjunto afetado de e2e; QA visual 390/1280 contra o gate; crítica do `designer`; revisão de teclado (menu, Escape, foco, links do rodapé); abrir o PR via `pnpm push`.
**Checkpoint:** PR ready com a descrição do aceite, evidências de 390/1280 e a nota operacional dos metadados.
## Rabbit holes / Não escopo (engenharia)
- Redesenhar `[type]`, `/corte`, `/privacidade`, `/mandato-no-whatsapp` ou o abaixo-assinado (S48).
- Criar marca nova, paleta/fonte nova ou “site institucional completo” (PUB2).
- Apagar posts, fotos, jingles, peças, cards ou Consents do CMS; “limpar” o banco.
- Migration para metadados, Consent ou qualquer schema; `push` contra DB remoto.
- Mexer em `/campanha`, nas roles, nos dashboards internos ou na Central interna.
- Reintroduzir newsletter/captação com copy de mandato.
- Ranking/percentual/dashboard de votos no hero.
- Novo player, novo carrossel, novo card ou novo asset de vídeo/foto.
- Refatorar o share link/Plenária além do header/footer.
- Remover `/potencial` ou a Plenária antes de 25/10.
- Lint global proibindo “1313” (falsos positivos em domínio/assets); a verificação é grep classificado + testes de superfície.
- Mapa de redirect para todo o acervo antigo; só as três rotas de campanha.
## Riscos e mitigação
| Risco | Prob. | Impacto | Mitigação |
| ------------------------------------------------------------------------------ | ----- | ------- | --------------------------------------------------------------------------------------------------------------------------- |
| Header/footer compartilhados quebrarem `/potencial` ou o anúncio do share link | Média | Alto | Atualizar todos os call sites no mesmo PR; `typecheck`; e2e `frontendShareLink`/`frontendFotos`; manter `badge` e `current` |
| Remoção de órfãos quebrar selfie/`potencial` (canvas/cutout compartilhados) | Média | Alto | `git grep -w` antes de apagar; manter `cardCanvas`/`cardCutout` e `lib/cardModels`/`cardDownloadCounts`; knip nu |
| Global `metadata`/`SiteSettings.headerTitle` continuarem com 1313 em produção | Alta | Alto | Nota operacional com campos exatos; edição no admin antes do anúncio; `curl` pós-deploy; sem migration/override |
| Board/photos vazios ou feed fora do ar deixarem buraco | Média | Médio | Empty states do design; feeds fail-soft já existentes; teste de board vazio e álbum vazio |
| Menu mobile com foco/Escape errado | Média | Médio | Unit do menu + e2e de teclado; foco inicial/retorno; `aria-expanded`/`aria-controls`; `hidden` |
| Redirect/canonical errado ou URL antiga indexada | Baixa | Médio | 308 testado; canonical `/artigos`/`/fotos`; `metadata.URL` intacto; sem sitemap |
| Lista curada/manifest com spec inexistente derrubar o CI | Alta | Médio | Atualizar `E2E_CURATED_SPECS`, `E2E_AFFECTED_MANIFEST` e o pin unit no mesmo commit |
| Crop do lockup positivo cortar a tinta | Média | Médio | Teste com `sharp` no `/fotos` 1280/390; janela 160×58/128×46; ajustar offsets se preciso |
| Port divergir do design (story 9:16, share no board) | Média | Baixo | Desvios documentados; crítica do designer; gatilhos de revisão |
| 152.049 corrigido pelo TSE | Baixa | Baixo | Constante única no hero; troca de uma linha |
## Nota operacional (sem migration)
Executar no admin de produção **antes/junto do anúncio**; o código não sobrescreve CMS:
- [ ] **Configurações → Metadados:** revisar `title`, `description`, `keywords`, `openGraph.description` e `twitter.description`; remover “1313”, “candidato”, “campanha eleitoral”, “1º turno”, “propaganda”.
- [ ] **Configurações → Site:** `headerTitle` = “Jorge Solla”; conferir `socialLinks` (Instagram/YouTube/Facebook oficiais).
- [ ] **Links de compartilhamento:** revisar títulos/descrições publicados (incluindo `plenaria-vitoria`); despublicar a Plenária após 25/10.
- [ ] **`metadata.URL`:** manter o canonical atual até o cutover OPS131; não alterar neste PR.
- [ ] **`HomePage.image`:** permanece como OG de `/artigos`; nenhuma edição obrigatória.
- [ ] **Consent `campanha-novidades` e subscriptions históricas:** não apagar; não há novo Consent. Se o jurídico quiser purgar, é processo LGPD separado.
- [ ] **Pós-deploy:** `curl` em `/`, `/artigos`, `/fotos` e uma página `[type]`; se “1313” aparecer em título/descrição, é dado do CMS e o operador corrige no admin.
- [ ] **Após 25/10:** despublicar o share link da Plenária e abrir a remoção de `/potencial` em entrega própria.
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: home de agradecimento com 152.049; identidade “Jorge Solla — Deputado Federal · PT · Bahia”; sem 1313/pedido de voto/candidato/propaganda/CNPJ em header, hero, board, story, flags, photos, footer, `/artigos` e `/fotos`; seções de campanha removidas; rodapé institucional; `/fotos`, `/artigos`, `/privacidade` e acervo de fala intactos; `/potencial`/Plenária até 25/10.
- [ ] Invariantes: **sem migration**, **sem Consent/PII novo**, **sem apagar CMS**, **sem `/campanha`**; `isPostVisible`/posts intocados; canonical atual preservado; `CampaignFooter`/`CampaignPageHeader` editados no dono, sem gêmeo.
- [ ] Design portado: ordem 1–9, 1152px, hero 88+668/74+760, board 1+3, flags 3×2 hairlines, fotos 530px, rodapé 3 colunas, um CTA primário, empty states com a copy do gate.
- [ ] A11y: menu mobile (abre/fecha/X/link/fora/Escape/foco), foco C243, alvos ≥44px, alt do lockup “Jorge Solla — Deputado Federal”, headings h1→h2→h3, `prefers-reduced-motion`, share acessível.
- [ ] SEO: títulos/descrições de `/artigos`, `/fotos`, `/fotos/encontre` e default sem 1313; canonical `/artigos`/`/fotos`; redirects 308 testados; nota operacional do global entregue.
- [ ] Gates: `pnpm gate:fast`, `pnpm knip`, `pnpm check:cycles`, `pnpm format:check`, `pnpm test:int`, `pnpm gate:ci` e e2e afetado (`frontend`, `frontendFotos`, `frontendShareLink`, `campaignHomePixel`) verdes.
- [ ] Manifest/curadoria: sem spec inexistente; `frontendJingles`/`frontendConteudos`/`campaignNewsletter` removidos da lista congelada de propósito; componentes do shell mapeados para os specs certos.
- [ ] Órfãos: knip sem arquivo/export pendente; `styles.css` sem blocos mortos; `HomePage` global, collections e dados históricos preservados.
- [ ] Docs: `AGENTS-public.md` atualizado (Fotos sempre, filename), `docs/changelog/2026-10-21-s47.md`, tech-debt sem referência à action removida; `DESIGN.md` §7 conferido.
- [ ] Visual: 390/1280 contra o gate; sem overflow em 320/390/1280; crítica final do `designer` registrada no PR.

View file

@ -6,6 +6,19 @@ const allowedImageHost = process.env.NEXT_PUBLIC_SITE_URL || 'https://jorgesolla
const nextConfig = {
distDir: process.env.NEXT_DIST_DIR || '.next',
...(process.env.NEXT_OUTPUT_STANDALONE === '1' ? { output: 'standalone' } : {}),
// S47 — the campaign surfaces left the air after the election. The pages are
// gone from the tree; the permanent redirects keep the links that already
// circulated (and any index entry) pointing at the mandate instead of a 404.
async redirects() {
return [
{ source: '/conteudos', destination: '/artigos', permanent: true },
{ source: '/conteudos/:path*', destination: '/artigos', permanent: true },
{ source: '/jingles', destination: '/', permanent: true },
{ source: '/jingles/:path*', destination: '/', permanent: true },
{ source: '/cards', destination: '/', permanent: true },
{ source: '/cards/:path*', destination: '/', permanent: true },
]
},
images: {
minimumCacheTTL: 14400,
remotePatterns: [

View file

@ -31,6 +31,7 @@
"db:seed:posts": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-posts.mjs",
"db:seed:tse": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-tse-results.mjs",
"db:seed:consent": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-consent.mjs",
"seed:petition-pl-5594": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-petition-pl-5594.mjs",
"db:seed:projecao": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/import-projecao.mjs",
"media:recover": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/recover-media.mjs",
"reels:ingest": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/reels-ingest.mjs",

View file

@ -28,7 +28,6 @@ export const E2E_CURATED_SPECS = [
'campaignDemandVisibility',
'campaignAiTranscribe',
'campaignAgendaFeed',
'campaignNewsletter',
// C167 — deliberate: the cut public page/route gates are the only e2e
// covering the new URL contract, and a migration always classifies the PR
// as high-risk (curated only).
@ -40,13 +39,6 @@ export const E2E_CURATED_SPECS = [
// S19 — deliberate: the share-link OG/redirect contract is new and the
// migration makes every PR of this delivery high-risk (curated only).
'frontendShareLink',
// S21 — deliberate: the jingles page/player/download contract is new and the
// migration makes every PR of this delivery high-risk (curated only).
'frontendJingles',
// S27 — deliberate: the Central catalogue/piece page, the public media route
// and the vote share sheet are new contracts over the C211 collection
// (curated only).
'frontendConteudos',
// C219 — deliberate: the recordings acervo gained its own search/facet
// contract and the migration makes every PR of this delivery high-risk
// (curated only).
@ -97,9 +89,9 @@ export const E2E_AFFECTED_MANIFEST = [
// The Payload live-preview bridge mounts on every `(frontend)` page and
// owns when a soft refresh may run; a diff there (or in the layout that
// mounts it) can silently drop the in-page anchors, so it wakes the hero
// owner (`frontend`) and the novidades anchor spec (`campaignNewsletter`).
// owner (`frontend`).
prefixes: ['src/components/RefreshRouteOnSave.tsx', 'src/app/(frontend)/layout.tsx'],
specs: ['frontend', 'campaignNewsletter'],
specs: ['frontend'],
},
{
// S46 — the public `/potencial` surface: the section query endpoints over
@ -145,10 +137,32 @@ export const E2E_AFFECTED_MANIFEST = [
specs: ['frontendShareLink'],
},
{
// S29 — the campaign-site header extracted from `/jingles` is shared by the
// announcement page; a diff there must wake both public specs.
prefixes: ['src/components/CampaignPageHeader.tsx'],
specs: ['frontendShareLink', 'frontendJingles'],
// S47 — the public chrome (header, mobile menu, name lockup and the shared
// path/social vocabulary) is rendered by the home, `/artigos`, `/fotos`
// and the announcement page; a diff there wakes every public spec.
prefixes: [
'src/components/CampaignPageHeader.tsx',
'src/components/CampaignMobileMenu.tsx',
'src/components/CampaignLogoLink.tsx',
'src/components/CampaignInternalPageIntro.tsx',
'src/lib/publicSite.ts',
],
specs: ['frontend', 'frontendShareLink', 'frontendFotos'],
},
{
// S47 — the mandate home sections (hero, board/card, story, flags and the
// photo gallery) are owned by the `frontend` spec; the photo gallery also
// feeds the album contract pinned by `frontendFotos`.
prefixes: [
'src/components/CampaignHero.tsx',
'src/components/CampaignContentSection.tsx',
'src/components/CampaignContentCard.tsx',
'src/components/ContentShareButton.tsx',
'src/components/CampaignStorySection.tsx',
'src/components/CampaignFlagsSection.tsx',
'src/components/CampaignPhotoSection.tsx',
],
specs: ['frontend', 'frontendFotos'],
},
{
// S29 — the iCal primitives extracted from the agenda feed also render the
@ -168,71 +182,6 @@ export const E2E_AFFECTED_MANIFEST = [
prefixes: ['src/lib/campaignTime.ts'],
specs: ['frontendShareLink'],
},
{
// S21 — the public jingles page: ordered published cards, the lazy
// in-page player (one at a time), the slug-based download name and the
// fail-closed publish contract of the collection.
// S25 — the same spec owns the home sound section, now with the own radio
// player (`lib/radio` holds the stream/share contract).
prefixes: [
'src/app/(frontend)/jingles',
'src/lib/jingle',
'src/lib/radio',
'src/utilities/jingleReads.ts',
// S27-FOLLOWUP-DRY — the owner of the page's og:image fallback.
'src/utilities/ogImageReads.ts',
// The tag/revalidation vocabulary owner for the jingles listing.
'src/utilities/documents.ts',
'src/collections/Jingle.ts',
'src/components/jingles',
],
// S29 — `hasPublishedJingles` also decides the footer of the announcement
// page, so the share-link spec wakes on the same owners.
specs: ['frontendJingles', 'frontendShareLink'],
},
{
// S27 — the public Central de Conteúdos: the facet/term catalogue, the
// on-demand play, the private-media public route, the vote share sheet and
// the kill-switch contract over the C211 listing tag. C213 adds the
// anonymous beacon endpoint, its vocabulary and the event collection the
// public page (and the public download) write to. S38 adds the card models
// as catalogue items (the copy/aliases live in the model catalog) and the
// card-opening variant of the beacon schema. S39 adds the home sample,
// wired by the home page and the pure selection module (the section
// components live under `components/conteudos`).
prefixes: [
'src/app/(frontend)/conteudos',
'src/app/(frontend)/(home)',
'src/app/(frontend)/api/content-events',
'src/components/conteudos',
'src/lib/contentPieceCatalog',
// C226 — the still of a video piece: the public route, the generation job
// and these pure rules. (`src/lib/contentPiece` below is a campaign prefix
// that also matches this file, so the public entry names it explicitly.)
'src/lib/contentPieceFrame',
'src/utilities/content/contentPieceFrameJob.ts',
'src/lib/contentPieceHomeSelection.ts',
'src/lib/contentPieceShare',
// S37 — the catalog behind the public "Lideranças" names.
'src/lib/publicFigureCatalog.ts',
'src/lib/contentEvents.ts',
'src/lib/cardModels.ts',
'src/lib/schemas/contentEvent.ts',
'src/utilities/content/contentPieceReads.ts',
// S27-FOLLOWUP-DRY — the owner of the page's og:image fallback.
'src/utilities/ogImageReads.ts',
// S28 — the theme-mode loader of the same public surface.
'src/utilities/content/contentPieceThemeSearch.ts',
// The tag/revalidation vocabulary owner for the content pieces listing.
'src/utilities/documents.ts',
'src/collections/ContentPiece.ts',
'src/collections/ContentMedia.ts',
'src/collections/ContentEvent.ts',
// The private-media response rules the public route reuses.
'src/lib/privateMedia',
],
specs: ['frontendConteudos'],
},
{
// C233 — the public photo album: the `/fotos` catalogue, the overlay, the
// public media route (thumbnail/download) and the removal channel over the
@ -285,22 +234,13 @@ export const E2E_AFFECTED_MANIFEST = [
// C234 — the bounded streaming body reader extracted from the beacon: both
// anonymous JSON routes consume it, so a diff there wakes both specs.
prefixes: ['src/utilities/boundedRequestBody.ts'],
specs: ['frontendConteudos', 'frontendFotosSelfie'],
specs: ['frontendFotosSelfie'],
},
{
// S21 — the campaign footer owns the conditional "Jingles" discovery link
// (it renders on the home, the cards page, `/jingles` and the S29
// announcement page).
// S27 — and the conditional "Conteúdos" link.
// C233 — and the conditional "Fotos" link.
// S47 — the institutional footer is shared by the home, `/artigos`,
// `/fotos` and the announcement page.
prefixes: ['src/components/CampaignFooter.tsx'],
specs: [
'frontend',
'frontendJingles',
'frontendConteudos',
'frontendShareLink',
'frontendFotos',
],
specs: ['frontend', 'frontendShareLink', 'frontendFotos'],
},
{
// S13/S30 — the personalized-cards studio lives in shared cards components
@ -508,7 +448,7 @@ export const E2E_AFFECTED_MANIFEST = [
// S28 — the same owner now generalizes the expansion for the public
// Central, so a diff there must wake the public spec too.
prefixes: ['src/utilities/ai'],
specs: ['campaignAiTranscribe', 'campaignSpeechAcervo', 'frontendConteudos'],
specs: ['campaignAiTranscribe', 'campaignSpeechAcervo'],
},
{
// Zod input schemas surface in the browser through form flows; the
@ -523,7 +463,6 @@ export const E2E_AFFECTED_MANIFEST = [
],
specs: [
'frontend',
'campaignNewsletter',
'campaignSpeechCut',
'campaignReel',
'frontendFotosSelfie',

View file

@ -0,0 +1,93 @@
/**
* S48 — the PL 5594/2026 petition (transparency of presidential candidates).
*
* The petition is editorial content, but production only carries the three
* legacy petitions restored from the old platform: there is no admin-free path
* to reproduce it in a worktree, staging or a fresh database. This module
* versions the content as the single source the seed writes and the unit
* invariants check (`tests/unit/petitionPl5594Content.unit.spec.ts`).
*
* Facts come from the official bill text (Câmara, codteor=3184912) and the
* product plan `docs/plans/abaixo-assinado-pl-5594-2026.md`. Guardrails: the
* motivation mentions the 18/10/2026 debate without accusing anyone of a
* crime, and the request asks for support without promising approval.
*
* The body is HTML (readable/reviewable) and the seed converts it to Lexical
* with the project editor config, the same round-trip as `seed-posts.mjs`.
*/
import { WHATSAPP_SUBSCRIPTION_CONSENT_KEY } from '../../src/lib/campaignConsentKeys.ts'
export const PETITION_ID = 'pl-5594-2026'
export const PETITION_TITLE = 'Quem quer ser presidente deve abrir os sigilos'
export const PETITION_SUBTITLE =
'Apoie o PL 5594/2026: candidatos à Presidência e à Vice-Presidência devem autorizar o acesso aos sigilos bancário, fiscal, telefônico e de internet.'
export const PETITION_FORM_TITLE = 'Assine o abaixo-assinado'
export const PETITION_FORM_SUBTITLE = 'Preencha seus dados para apoiar o PL 5594/2026.'
/**
* The existing petition Consent (`form.consent` of the three production
* petitions). Resolved by stable key — never by a hardcoded id; the seed is
* fail-closed when the key is missing.
*/
export const PETITION_CONSENT_KEY = WHATSAPP_SUBSCRIPTION_CONSENT_KEY
export const PETITION_BODY_SECTIONS = [
{
heading: 'O que o PL 5594/2026 propõe',
html: [
'<p>O projeto de lei do deputado Jorge Solla (PT/BA), apresentado em 19/10/2026, torna a transparência uma condição do registro da candidatura à Presidência e à Vice-Presidência da República. Quem disputa o cargo passa a apresentar, no pedido de registro, uma declaração expressa, individual, específica e informada de autorização de acesso a:</p>',
'<ul>',
'<li>informações bancárias e financeiras — contas, aplicações, investimentos, operações de crédito, empréstimos, financiamentos, participações societárias, ativos e passivos;</li>',
'<li>declarações de Imposto de Renda, rendimentos, patrimônio declarado, ganhos de capital e obrigações tributárias dos <strong>cinco exercícios fiscais anteriores</strong> ao pedido de registro;</li>',
'<li>registros telefônicos — origem, destino, data, horário, duração e faturamento —, <strong>excluído o conteúdo das comunicações</strong>;</li>',
'<li>registros de conexão e de acesso a aplicações de internet — data, horário, endereço IP e demais metadados legalmente disponíveis —, <strong>excluído o conteúdo de mensagens, arquivos, correspondências eletrônicas e comunicações privadas</strong>.</li>',
'</ul>',
'<p>A ementa oficial resume: o PL dispõe sobre a transparência patrimonial, fiscal e financeira dos candidatos à Presidência da República, estabelece mecanismos de autorização de acesso a informações protegidas por sigilo e altera a Lei nº 9.504, de 30 de setembro de 1997.</p>',
].join('\n'),
},
{
heading: 'Por que isso importa agora',
html: [
'<p>Em 18/10/2026, durante o debate eleitoral, Lula desafiou Flávio Bolsonaro a abrir seus sigilos. No dia seguinte, Solla protocolou o PL 5594/2026 na Câmara dos Deputados. O projeto não trata de um caso isolado: cria uma regra geral de transparência para quem disputa a Presidência.</p>',
'<p>Hoje o eleitor recebe declarações de bens, mas não tem instrumentos equivalentes para verificar a coerência entre patrimônio, rendimentos e movimentação financeira dos candidatos. O PL busca fechar essa lacuna com critérios objetivos e fiscalização por órgãos competentes.</p>',
].join('\n'),
},
{
heading: 'O que o PL não faz',
html: [
'<ul>',
'<li><strong>Não abre o conteúdo das conversas.</strong> Telefonia e internet entram apenas como registros e metadados; o conteúdo de comunicações telefônicas e telemáticas permanece sujeito às garantias constitucionais e só pode ser acessado com ordem judicial quando exigida.</li>',
'<li><strong>Não permite acesso livre.</strong> O acesso fica restrito a autoridades e órgãos públicos legalmente competentes, para fiscalização eleitoral, verificação patrimonial e apuração de indícios concretos de ilícitos, respeitadas as competências e garantias legais.</li>',
'<li><strong>Não autoriza uso para outra finalidade.</strong> Toda consulta é registrada em sistema de auditoria com identificação do responsável, da finalidade, da data e das categorias de informações consultadas; as informações ficam protegidas por dever de confidencialidade.</li>',
'<li><strong>Não expõe dados de terceiros.</strong> A Justiça Eleitoral divulga ao público apenas o que a lei autoriza, resguardando dados pessoais de terceiros, credenciais de acesso e números completos de contas.</li>',
'<li><strong>Não substitui a LGPD.</strong> O tratamento das informações observa a legislação de proteção de dados pessoais e as medidas técnicas e administrativas para prevenir acessos não autorizados, vazamentos e uso indevido.</li>',
'</ul>',
].join('\n'),
},
{
heading: 'O pedido',
html: [
'<p>Assine para pedir que a Câmara dos Deputados dê tramitação e aprove o PL 5594/2026. A transparência não substitui a apuração de responsabilidades nem presume irregularidade: ela dá ao eleitor e aos órgãos de fiscalização os meios para verificar.</p>',
'<p>Quem quer governar o país deve aceitar o mesmo padrão de transparência que cobra dos outros. Assine e compartilhe.</p>',
].join('\n'),
},
{
heading: 'Texto oficial e tramitação',
html: [
'<p>Leia a íntegra e acompanhe a tramitação na Câmara dos Deputados:</p>',
'<ul>',
'<li><a href="https://www.camara.leg.br/proposicoesWeb/fichadetramitacao?idProposicao=2648344">Ficha de tramitação do PL 5594/2026</a></li>',
'<li><a href="https://www.camara.leg.br/proposicoesWeb/prop_mostrarintegra?codteor=3184912">Inteiro teor do PL 5594/2026</a></li>',
'</ul>',
'<p>Status em 21/10/2026: o projeto foi apresentado à Mesa da Câmara dos Deputados; não havia tramitação posterior registrada nesta data.</p>',
].join('\n'),
},
]
export const PETITION_BODY_HTML = PETITION_BODY_SECTIONS.map(
(section) => `<h2>${section.heading}</h2>\n${section.html}`,
).join('\n')

View file

@ -102,6 +102,7 @@ export const SCRIPTS_SPEC_PINNED = [
'scripts/lib/mediaBinaries.mjs',
'scripts/lib/mediaEndpoint.mjs',
'scripts/lib/opsGlobal.mjs',
'scripts/lib/petitionPl5594Content.mjs',
'scripts/lib/plansOnlyClosesGuard.mjs',
'scripts/lib/playwright-e2e-args.mjs',
'scripts/lib/portalTransparenciaEmendas.mjs',

View file

@ -0,0 +1,203 @@
/**
* S48 — provisions the PL 5594/2026 petition (`pl-5594-2026`).
*
* The petition is editorial content: production historically only had the three
* petitions restored from the old platform (edited in the admin), so a fresh
* database has no reproducible path to the new one. This seed writes the content
* versioned in `scripts/lib/petitionPl5594Content.mjs`.
*
* Contract:
* - dry-run (default): resolves the target/Consent and reports the plan; no write
* - `--apply`: creates the petition only when the id is missing. An existing row
* is never overwritten — after the first write the admin owns the content.
* - the Consent is the existing `whatsapp-inscricao` document, resolved by
* stable key (`getConsentByKey`), fail-closed when absent.
* - `--apply` outside a provably local dev database requires
* `PETITION_SEED_CONFIRM=1` and a `TEQO_ENV` matching the exact database
* (staging → `teqo_staging`, production → `teqo_1313`).
*
* After a production seed the page may still serve a cached 404 until the server
* restarts/redeploys (or the petition is saved once in the admin): the seed runs
* outside the Next runtime and cannot bust `document_petition:<id>`.
*
* Usage:
* pnpm seed:petition-pl-5594
* pnpm seed:petition-pl-5594 --apply
* TEQO_ENV=production PETITION_SEED_CONFIRM=1 pnpm seed:petition-pl-5594 --apply
*/
import { convertHTMLToLexical, editorConfigFactory } from '@payloadcms/richtext-lexical'
import { JSDOM } from 'jsdom'
import { getPayload } from 'payload'
import {
PETITION_BODY_HTML,
PETITION_BODY_SECTIONS,
PETITION_CONSENT_KEY,
PETITION_FORM_SUBTITLE,
PETITION_FORM_TITLE,
PETITION_ID,
PETITION_SUBTITLE,
PETITION_TITLE,
} from './lib/petitionPl5594Content.mjs'
import {
assertEnvironmentDatabaseTarget,
assertWriteConfirm,
databaseName,
databaseTarget,
dieWithLabel,
loadCliEnv,
requiresWriteConfirm,
TEQO_ENV_DATABASE_BY_ENV,
} from './lib/cli.mjs'
loadCliEnv()
const die = dieWithLabel('seed:petition-pl-5594')
const WRITE_CONFIRM_FLAG = 'PETITION_SEED_CONFIRM'
const HELP = `
Uso: pnpm seed:petition-pl-5594 [--apply]
Cria a petição "${PETITION_ID}" (abaixo-assinado do PL 5594/2026), ativa e
publicada, com o Consent existente "${PETITION_CONSENT_KEY}" resolvido por chave.
Modos:
(sem flag) plano/dry-run — alvo, Consent e estado atual; sem escrita
--apply cria a petição se o id não existir (nunca sobrescreve)
Ambiente:
DATABASE_URL alvo (obrigatório em todos os modos)
--apply em alvo não-local/produção exige ${WRITE_CONFIRM_FLAG}=1 e TEQO_ENV
casando o banco exato (staging=teqo_staging | production=teqo_1313).
`
/**
* `draft: true` is what makes the lookup honest: a draft-only row with the same
* id (the admin create view generates one during render) would otherwise be
* invisible and the create would die on a duplicate key instead of no-op.
*/
const findPetition = async (payload, id) => {
const result = await payload.find({
collection: 'petition',
where: { id: { equals: id } },
draft: true,
limit: 1,
depth: 0,
pagination: false,
})
return result.docs[0] ?? null
}
async function main() {
const argv = process.argv.slice(2)
if (argv.includes('--help') || argv.includes('-h')) {
console.log(HELP)
process.exit(0)
}
const unknown = argv.find((arg) => arg !== '--apply')
if (unknown)
die(`argumento desconhecido: ${unknown} — uso: pnpm seed:petition-pl-5594 [--apply].`)
const apply = argv.includes('--apply')
if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.')
console.log(
`[seed:petition-pl-5594] alvo: ${databaseTarget()} | modo: ${apply ? 'apply' : 'plan'}`,
)
if (apply) {
assertWriteConfirm({
label: 'seed:petition-pl-5594',
flag: WRITE_CONFIRM_FLAG,
command: 'pnpm seed:petition-pl-5594 --apply',
})
// A worktree dev database is provably local and follows the write-confirm
// policy (no flag). But the homeserver proxy rewrites the host to localhost,
// so a local host carrying a declared target name (teqo_1313/teqo_staging)
// must still declare the exact environment — the name is the discriminator.
const targetName = databaseName(process.env.DATABASE_URL)
const declaredTargets = Object.values(TEQO_ENV_DATABASE_BY_ENV)
if (requiresWriteConfirm() || declaredTargets.includes(targetName)) {
assertEnvironmentDatabaseTarget()
}
}
const config = (await import('../src/payload.config.ts')).default
const payload = await getPayload({ config })
const existing = await findPetition(payload, PETITION_ID)
if (existing) {
console.log(
`[seed:petition-pl-5594] ${PETITION_ID}: existe — enabled=${Boolean(existing.enabled)} ` +
`_status=${existing._status ?? '(sem drafts)'}; não sobrescreve (o admin é dono).`,
)
if (!existing.enabled || existing._status !== 'published') {
console.log(
'[seed:petition-pl-5594] atenção: a petição existe mas não está ativa/publicada; ' +
'ajuste no admin (o seed nunca sobrescreve).',
)
}
process.exit(0)
}
const { getConsentByKey } = await import('../src/utilities/campaignConsent.ts')
const consent = await getConsentByKey(payload, PETITION_CONSENT_KEY)
if (!consent) {
die(
`Consent "${PETITION_CONSENT_KEY}" ausente — a petição não pode ser criada sem ele ` +
'(fail-closed). Rode `pnpm db:seed:minimal` no ambiente local ou confira o Consent ' +
'de produção antes de aplicar.',
)
}
console.log(
`[seed:petition-pl-5594] Consent "${PETITION_CONSENT_KEY}": #${consent.id} (resolvido por chave)`,
)
console.log(
`[seed:petition-pl-5594] ${PETITION_ID}: ausente | ${PETITION_BODY_SECTIONS.length} bloco(s) de corpo | ` +
`título "${PETITION_TITLE}"`,
)
if (!apply) {
console.log('\n[seed:petition-pl-5594] dry-run — use --apply para criar a petição.')
process.exit(0)
}
const editorConfig = await editorConfigFactory.default({ config: payload.config })
const body = convertHTMLToLexical({ editorConfig, html: PETITION_BODY_HTML, JSDOM })
const created = await payload.create({
collection: 'petition',
data: {
id: PETITION_ID,
title: PETITION_TITLE,
subtitle: PETITION_SUBTITLE,
enabled: true,
body,
form: {
title: PETITION_FORM_TITLE,
subtitle: PETITION_FORM_SUBTITLE,
consent: consent.id,
},
_status: 'published',
},
depth: 0,
// Operator CLI: the create must write the petition even though the
// collection's create access is admin-only (same intent as the seeds).
overrideAccess: true,
})
console.log(
`[seed:petition-pl-5594] ${PETITION_ID}: criada (#${created.id}) ` +
`enabled=${Boolean(created.enabled)} _status=${created._status ?? '(sem drafts)'}.`,
)
console.log(
'[seed:petition-pl-5594] Em produção, reinicie/redeploye o container (ou salve a petição uma vez ' +
'no admin) antes de divulgar: o seed não busta o cache document_petition da rota.',
)
process.exit(0)
}
main().catch((error) => {
die(error?.message || String(error))
})

View file

@ -1,39 +0,0 @@
import { brexterBold } from '@/app/(frontend)/fonts'
import { CardsStudio } from '@/components/cards/CardsStudio'
/**
* S14/S30/S31 — the home invitation to the card funnel: short copy + the six
* model tiles, positioned right before the newsletter capture (which stays the last
* conversion block). Tapping a tile opens the SAME composer island used by
* `/cards` right here (dialog on desktop, drawer on mobile) — no navigation and
* no separate CTA. `brexterBold.variable` includes the display face in the page
* so the name-tile canvas (family passed as a prop) can load it.
*/
export const CampaignCardsSection = () => (
<section
id="cards"
aria-labelledby="cards-title"
data-home-section="cards"
className={`border-b border-(--campaign-line) bg-(--campaign-cream) ${brexterBold.variable}`}
>
<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">
Faça parte
</p>
<h2
id="cards-title"
className="campaign-section-title m-0 mt-1 border-0 p-0 font-black tracking-[-0.02em] text-balance"
>
Mostre que você está com Solla
</h2>
<p className="campaign-section-copy m-0 mt-1 text-(--campaign-muted)">
Escolha um modelo, coloque seu nome ou sua foto e baixe seu card. Compartilhe com sua
gente e fortaleça nossa caminhada.
</p>
</div>
<CardsStudio fontFamily={brexterBold.style.fontFamily} />
</div>
</section>
)

View file

@ -1,40 +0,0 @@
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).
*
* 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 = ({ pixelId }: { pixelId?: string }) => (
<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 pixelId={pixelId} />
</div>
</div>
</section>
)

View file

@ -1,75 +0,0 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { brexterBold } from '@/app/(frontend)/fonts'
import { CampaignFooter } from '@/components/CampaignFooter'
import { CARD_PRIVACY_NOTE } from '@/components/cards/cardCopy'
import { CardsStudio } from '@/components/cards/CardsStudio'
import { isCardModelId } from '@/lib/cardModels'
import { hasPublishedArchivePhotos } from '@/utilities/archivePhotos/archivePhotoReads'
import { hasPublishedContentPieces } from '@/utilities/content/contentPieceReads'
import { hasPublishedJingles } from '@/utilities/jingleReads'
const intro =
'Escolha um dos seis modelos, personalize com seu nome ou sua foto e baixe para compartilhar.'
export const metadata: Metadata = {
title: 'Crie seu card de apoio | Jorge Solla',
description: intro,
}
type CardsPageProps = {
searchParams: Promise<{ model?: string | string[] }>
}
export default async function CardsPage({ searchParams }: CardsPageProps) {
const params = await searchParams
const rawModel = Array.isArray(params.model) ? params.model[0] : params.model
const initialModelId = isCardModelId(rawModel) ? rawModel : undefined
const [showJingles, showConteudos, showFotos] = await Promise.all([
hasPublishedJingles(),
hasPublishedContentPieces(),
hasPublishedArchivePhotos(),
])
return (
<>
<main className={`w-full bg-white text-black ${brexterBold.variable}`}>
<div className="mx-auto w-full max-w-[1160px] px-5 pt-6 sm:px-8 lg:px-10">
<Link
href="/"
className="inline-flex min-h-11 items-center text-sm font-bold text-(--pt-red) underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none"
>
← Voltar para a campanha
</Link>
</div>
<section
aria-labelledby="cards-page-title"
className="mx-auto w-full max-w-[1160px] px-5 pt-6 pb-12 sm:px-8 lg:px-10 lg:pt-10 lg:pb-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">
Faça parte
</p>
<h1
id="cards-page-title"
className="campaign-section-title m-0 mt-1 border-0 p-0 font-black tracking-[-0.02em] text-balance"
>
Crie seu card de apoio
</h1>
<p className="campaign-section-copy m-0 mt-1 text-(--campaign-muted)">{intro}</p>
<p className="mt-2 text-xs text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p>
</div>
<CardsStudio initialModelId={initialModelId} fontFamily={brexterBold.style.fontFamily} />
</section>
</main>
<CampaignFooter
showJingles={showJingles}
showConteudos={showConteudos}
showFotos={showFotos}
/>
</>
)
}

View file

@ -1,287 +1,58 @@
import { CampaignCarousel, type CampaignCarouselItem } from '@/components/CampaignCarousel'
import { CampaignContentSection } from '@/components/CampaignContentSection'
import { CampaignFlagCard } from '@/components/CampaignFlagCard'
import { CampaignFlagsSection } from '@/components/CampaignFlagsSection'
import { CampaignFooter } from '@/components/CampaignFooter'
import { CampaignHero } from '@/components/CampaignHero'
import { CampaignProblemCard } from '@/components/CampaignProblemCard'
import { CampaignPageHeader } from '@/components/CampaignPageHeader'
import { CampaignPhotoSection } from '@/components/CampaignPhotoSection'
import { CampaignStorySection } from '@/components/CampaignStorySection'
import { ContentPieceHomeSection } from '@/components/conteudos/ContentPieceHomeSection'
import { SelfieSearchHomeSection } from '@/components/fotos/SelfieSearchHomeSection'
import { JingleHomeSection } from '@/components/jingles/JingleHomeSection'
import { ShareLinkHomeSection } from '@/components/shareLink/ShareLinkHomeSection'
import { SHARE_LINK_HOME_SECTION_SLUG } from '@/lib/shareLinkHomeSection'
import { hasPublishedArchivePhotos } from '@/utilities/archivePhotos/archivePhotoReads'
import { getCampaignHomeMetaPixelId } from '@/utilities/campaignHomeTracking'
import { getPublishedContentPieceItems } from '@/utilities/content/contentPieceReads'
import { getApprovedArchivePhotoItems } from '@/utilities/archivePhotos/archivePhotoReads'
import { getCachedGlobal } from '@/utilities/globalReads'
import { getPublishedJingleItems } from '@/utilities/jingleReads'
import { loadShareLinkHomeSection } from '@/utilities/shareLinkReads'
import type { ReactNode } from 'react'
import { CampaignCardsSection } from './CampaignCardsSection'
import { CampaignNewsletterSection } from './CampaignNewsletterSection'
/**
* S6 — desktop sibling of the mobile carousel: the same cards as a static
* grid (3 columns), visible only from the `lg` breakpoint the carousel
* geometry used to interpolate at. Kept here with the sections it serves.
* S47 — the post-election home (design artifact
* `site-pos-eleicao-agradecimento-ui-design.html`, scenes 01/02): the thank-you
* hero, the editorial board, the trajectory, the mandate flags, the photos and
* the institutional footer. The campaign sections (proof, problem, cards,
* jingles/radio, Central de Conteúdos and the newsletter capture) are gone from
* the composition; the Plenária (2nd round, until 25/10) and the selfie search
* stay as conditional, fail-closed sections.
*/
const CampaignCardGrid = ({
gridKey,
ariaLabel,
gapClassName,
children,
}: {
gridKey: 'problem' | 'flags'
ariaLabel: string
gapClassName: string
children: ReactNode
}) => (
<div className="hidden px-(--campaign-content-inset) lg:block">
<ol
data-grid={gridKey}
aria-label={ariaLabel}
className={`m-0 grid list-none grid-cols-3 p-0 ${gapClassName}`}
>
{children}
</ol>
</div>
)
const problemItems: CampaignCarouselItem[] = [
{
id: 'escala-6x1',
title: 'Pelo fim da escala 6x1!',
body: 'Jornadas longas aumentam o risco de AVC e infarto. Descanso é saúde pública. A bancada de Lula derrotou a direita na Câmara: o fim da 6×1 avançou. Mas a briga continua no Senado, onde a direita tenta barrar o avanço. No dia 4 de outubro, o 1313 é o seu sim ao descanso.',
image: '/fundo.webp',
imageAlt: 'Jorge Solla defendendo direitos dos trabalhadores na Câmara dos Deputados',
},
{
id: 'sus',
title: 'Pra valorizar o SUS',
body: 'O SUS é o sistema de saúde de todo brasileiro. Com Lula, a saúde voltou a receber recursos: Nova PAC de R$ 30,5 bilhões e Mais Médicos retomado. Mas o teto de gastos da direita ainda trava o orçamento. O 1313 é seu voto em defesa do SUS no Congresso!',
image: '/53569851134_02afc18fb4_o.webp',
imageAlt: 'Jorge Solla em agenda pública de defesa do SUS',
imageFrame: {
width: 1024,
height: 682,
className: 'absolute top-0 left-[-21.97%] h-auto w-[136.62%] max-w-none',
},
},
{
id: 'mataripe',
title: 'Pra defender os baianos',
body: 'A Refinaria de Mataripe foi vendida no governo Bolsonaro e segue nas mãos de estrangeiros. Com Lula e Jerônimo, a Bahia voltou a andar pra frente; mas recomprar Mataripe exige bancada forte no Congresso. Votar 1313 é devolver à Bahia o que é seu.',
image: '/52396285023_561ffc0ff6_o.webp',
imageAlt: 'Vista da Bahia durante mobilização popular',
},
]
const flagItems: CampaignCarouselItem[] = [
{
id: 'saude',
label: 'Saúde',
title: 'Defender o SUS e valorizar quem cuida',
body: 'Saúde é direito, não mercadoria. Fim do subfinanciamento, piso da enfermagem e dos agentes pagos, concursos públicos, vacinação e ciência.',
},
{
id: 'educacao',
label: 'Educação',
title: 'Educação em tempo integral e federais no interior',
body: 'IFs, campus federal, escola de tempo integral: o interior não pode ficar para trás no conhecimento.',
},
{
id: 'renda',
label: 'Renda',
title: 'Salário mínimo forte, emprego e moradia',
body: 'Valorização do mínimo, Bolsa Família, Minha Casa Minha Vida e agricultura familiar como motor do interior.',
},
{
id: 'democracia',
label: 'Democracia',
title: 'Defesa intransigente da democracia',
body: 'Participação popular, instituições fortes e combate à desinformação. Eleger o parlamento é coisa séria.',
},
{
id: 'bahia',
label: 'Bahia',
title: 'Recomprar a Refinaria de Mataripe',
body: 'Recomprar a refinaria é defender a Bahia e a soberania do Brasil. Preço justo de combustível é política de saúde e de renda.',
},
{
id: 'trabalho',
label: 'Trabalho',
title: 'Fim da escala 6×1 e jornada de 40h',
body: 'Sem redução de salário. Jornada longa é questão de saúde pública: quem trabalha demais adoece. O SUS paga a conta.',
},
]
const proofItems = [
{ value: '3.333', label: 'proposições apresentadas' },
{ value: '1.031', label: 'discursos em Plenário' },
{ value: '3º', label: 'mandato de deputado federal' },
]
export default async function HomePage() {
// S10 — site-level Meta pixel (cached read, fail-closed).
// S21/S22 — one cached listing (`unstable_cache` tag `jingles`) serves the
// home sound section and the footer's discovery flag, keeping the home static.
// S27/S39 — one cached listing (tag `contentPieces`) feeds both the discovery
// flag of the footer and the home sample; zero pieces hides both (fail-closed).
// C233 — the same contract for the "Fotos" discovery flag (tag `archivePhotos`).
// S44 — the Plenária section reads one published share link under the
// `shareLinks` tag; unpublished or past the event window, the section is
// absent from the HTML (fail-closed, kill switch without a deploy).
const [pixelId, jingles, contentPieces, showFotos, album, plenaria] = await Promise.all([
getCampaignHomeMetaPixelId(),
getPublishedJingleItems(),
getPublishedContentPieceItems(),
hasPublishedArchivePhotos(),
// S44 — one published share link under the `shareLinks` tag; unpublished or
// past the event window, the Plenária is absent from the HTML (fail-closed).
// C233/C243 — one approved-photo read feeds the home gallery and the selfie
// discovery gate (album published + flag + at least one approved photo).
const [album, photos, plenaria] = await Promise.all([
getCachedGlobal('photoAlbum')(),
getApprovedArchivePhotoItems(),
loadShareLinkHomeSection(SHARE_LINK_HOME_SECTION_SLUG),
])
const homeJingles = jingles.slice(0, 3)
// C243 — the discovery section only exists while the selfie search is open
// (album published + own flag + approved photos); closed leaves no dead CTA.
const showSelfieSearch =
album.published !== false && album.selfieSearchEnabled === true && showFotos
album.published !== false && album.selfieSearchEnabled === true && photos.length > 0
return (
<>
<main className="w-full bg-white text-black">
<CampaignPageHeader variant="home" />
<main className="w-full bg-(--campaign-cream) text-(--campaign-ink)">
<CampaignHero />
{plenaria ? (
<ShareLinkHomeSection view={plenaria.view} initialLive={plenaria.initialLive} />
) : null}
<section
aria-label="Experiência e atuação parlamentar"
data-home-section="proof"
className="campaign-proof flex justify-center bg-white"
>
<ul className="campaign-proof-list m-0 grid h-full w-[calc(100%_-_22px)] max-w-[712px] list-none grid-cols-3 p-0">
{proofItems.map((item) => (
<li key={item.value} className="m-0 text-center">
<strong className="campaign-proof-value block font-[family-name:var(--font-exo2)] leading-none font-black text-(--pt-red)">
{item.value}
</strong>
<span className="campaign-proof-label mt-1 block font-medium text-[#242124]">
{item.label}
</span>
</li>
))}
</ul>
</section>
<CampaignContentSection />
<section
aria-labelledby="problem-title"
data-home-section="problem"
className="campaign-problem relative bg-(--pt-red-dark) text-white"
>
<div className="relative h-full w-full">
<p className="campaign-section-eyebrow campaign-problem-eyebrow absolute m-0 font-[family-name:var(--font-exo2)] font-black tracking-[0.1em] text-(--pt-yellow) uppercase">
Por que essa eleição importa
</p>
<h2
id="problem-title"
aria-label="Eleger deputado é coisa séria. O Congresso tem nas mãos vidas reais e o SUS"
className="campaign-section-title campaign-problem-title absolute m-0 border-0 p-0 font-black tracking-[-0.02em] text-balance"
>
<span className="block">Eleger deputado é coisa séria</span>
<span className="block text-(--pt-yellow)">
O Congresso tem nas mãos vidas reais e o SUS
</span>
</h2>
<p className="campaign-section-copy campaign-problem-copy absolute m-0 font-[family-name:var(--font-exo2)] leading-[1.2] text-white/85">
É no Congresso que se define o orçamento do SUS, o piso da enfermagem, a recompra de
Mataripe e o fim da escala 6×1.{' '}
<strong className="font-extrabold text-white">
Reeleger Lula não basta: é preciso eleger uma bancada aliada.
</strong>
</p>
<div className="campaign-problem-carousel absolute right-0 left-0">
<CampaignCardGrid
gridKey="problem"
ariaLabel="Bandeiras que tornam esta eleição decisiva"
gapClassName="gap-[21px]"
>
{problemItems.map((item) => (
<li key={item.id} className="m-0 h-[437px]">
<CampaignProblemCard item={item} />
</li>
))}
</CampaignCardGrid>
<div className="lg:hidden">
<CampaignCarousel
ariaLabel="Bandeiras que tornam esta eleição decisiva"
items={problemItems}
variant="problem"
/>
</div>
</div>
</div>
</section>
<section
id="bandeiras"
aria-labelledby="flags-title"
data-home-section="flags"
className="campaign-flags relative scroll-mt-4 bg-(--campaign-band) text-black"
>
<div className="relative h-full w-full">
<p className="campaign-section-eyebrow campaign-flags-eyebrow absolute m-0 font-[family-name:var(--font-exo2)] font-black tracking-[0.1em] text-(--pt-red) uppercase">
Nossa caminhada
</p>
<h2
id="flags-title"
aria-label="Junto com o trabalhador e do lado de quem mais precisa, sempre."
className="campaign-section-title campaign-flags-title absolute m-0 border-0 p-0 font-black tracking-[-0.02em] text-balance"
>
<span className="block">Junto com o trabalhador e</span>
<span className="block text-(--pt-red)">do lado de quem mais precisa, sempre.</span>
</h2>
<p className="campaign-section-copy campaign-flags-copy absolute m-0 font-sans leading-[1.2] text-black/80">
Não é promessa de palanque, é a experiência de quem já fez na gestão do SUS, do
Ministério da Saúde e da saúde da Bahia.
</p>
<div className="campaign-flags-carousel absolute right-0 left-0">
<CampaignCardGrid
gridKey="flags"
ariaLabel="Bandeiras da campanha"
gapClassName="gap-[14px]"
>
{flagItems.map((item) => (
<li key={item.id} className="m-0">
<CampaignFlagCard item={item} size="spacious" />
</li>
))}
</CampaignCardGrid>
<div className="lg:hidden">
<CampaignCarousel
ariaLabel="Bandeiras da campanha"
items={flagItems}
variant="flags"
/>
</div>
</div>
</div>
</section>
<CampaignStorySection />
<JingleHomeSection jingles={homeJingles} showAll={jingles.length > homeJingles.length} />
<CampaignCardsSection />
{contentPieces.length > 0 ? <ContentPieceHomeSection items={contentPieces} /> : null}
<CampaignFlagsSection />
<CampaignPhotoSection items={photos} />
{showSelfieSearch ? <SelfieSearchHomeSection /> : null}
<CampaignNewsletterSection pixelId={pixelId ?? undefined} />
</main>
<CampaignFooter
showJingles={jingles.length > 0}
showConteudos={contentPieces.length > 0}
showFotos={showFotos}
/>
<CampaignFooter />
</>
)
}

View file

@ -10,10 +10,7 @@ import {
POTENTIAL_INTRO,
POTENTIAL_PATH,
} from '@/components/potencial/sectionPotentialCopy'
import { hasPublishedArchivePhotos } from '@/utilities/archivePhotos/archivePhotoReads'
import { hasPublishedContentPieces } from '@/utilities/content/contentPieceReads'
import { getCachedGlobal } from '@/utilities/globalReads'
import { hasPublishedJingles } from '@/utilities/jingleReads'
import { resolveOgImage } from '@/utilities/ogImageReads'
import { absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo'
@ -56,12 +53,6 @@ export async function generateMetadata(): Promise<Metadata> {
}
export default async function PotencialPage() {
const [showJingles, showConteudos, showFotos] = await Promise.all([
hasPublishedJingles(),
hasPublishedContentPieces(),
hasPublishedArchivePhotos(),
])
return (
<>
<CampaignPageHeader />
@ -79,11 +70,7 @@ export default async function PotencialPage() {
<main className="w-full bg-white text-black">
<SectionPotentialStudio fontFamily={campaignTextFont.style.fontFamily} />
</main>
<CampaignFooter
showJingles={showJingles}
showConteudos={showConteudos}
showFotos={showFotos}
/>
<CampaignFooter />
</>
)
}

View file

@ -12,9 +12,7 @@ import {
} from '@/lib/shareLink'
import { buildShareLinkAnnouncementView } from '@/lib/shareLinkAnnouncement'
import type { ShareLink } from '@/payload-types'
import { hasPublishedArchivePhotos } from '@/utilities/archivePhotos/archivePhotoReads'
import { getCachedGlobal } from '@/utilities/globalReads'
import { hasPublishedJingles } from '@/utilities/jingleReads'
import { POST_TYPE_LABELS, getVisiblePosts, isPostType } from '@/utilities/posts'
import { absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo'
import {
@ -141,17 +139,13 @@ export default async function Page({ params }: { params: Promise<RouteParams> })
// deployment-origin URL is only for the OG card in `resolveShareLinkMetadata`.
const image = typeof link.image === 'object' ? link.image : null
const canonicalUrl = await resolveShareLinkCanonicalUrl(link.slug)
const [showJingles, showFotos] = await Promise.all([
hasPublishedJingles(),
hasPublishedArchivePhotos(),
])
const view = buildShareLinkAnnouncementView({
link,
imageUrl: image?.url ?? null,
canonicalUrl: canonicalUrl ?? null,
})
return <ShareLinkAnnouncement view={view} showJingles={showJingles} showFotos={showFotos} />
return <ShareLinkAnnouncement view={view} />
}
const posts = (await getVisiblePosts()).filter((post) => post.type === type)

View file

@ -204,12 +204,11 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
Participe da mobilização
</span>
<h2 className="border-none text-left text-3xl font-bold leading-tight text-foreground sm:text-4xl">
Sua assinatura fortalece a luta dos trabalhadores.
Sua assinatura fortalece esta mobilização.
</h2>
<p className="text-base leading-7 text-muted-foreground">
Preencha o formulário para somar apoio público à proposta. Os dados serão usados
apenas para registrar sua participação nesta campanha, e comunicação de conteúdos
relacionados.
apenas para registrar sua participação e a comunicação de conteúdos relacionados.
</p>
<SignatureCounter
petitionId={petition.id}

View file

@ -1,50 +0,0 @@
'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

@ -1,5 +1,5 @@
import { Footer } from '@/components/Footer'
import { SiteHeader } from '@/components/SiteHeader'
import { CampaignFooter } from '@/components/CampaignFooter'
import { CampaignPageHeader } from '@/components/CampaignPageHeader'
import React from 'react'
import '../styles.css'
@ -7,13 +7,17 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
const { children } = props
// A root `(frontend)` trava o documento com `overflow-hidden`, então a rota
// é dona do container de scroll interno. Header sólido (não overlay) porque
// /artigos é uma subpágina, não o hero da home.
// é dona do container de scroll interno. S47 — o chrome é o institucional
// (cabeçalho/rodapé compartilhados em creme), não mais o header editorial
// com a imagem de campanha.
return (
<div className="relative h-dvh w-full overflow-y-auto">
<SiteHeader variant="solid" />
<div
data-theme="campaign-site"
className="relative h-dvh w-full overflow-y-auto bg-(--campaign-cream) text-(--campaign-ink)"
>
<CampaignPageHeader current="artigos" />
{children}
<Footer />
<CampaignFooter current="artigos" />
</div>
)
}

View file

@ -1,57 +1,67 @@
import { CampaignInternalPageIntro } from '@/components/CampaignInternalPageIntro'
import { PostCard } from '@/components/PostCard'
import { Media } from '@/payload-types'
import { getCachedDocumentById } from '@/utilities/documentReads'
import { getCachedGlobal } from '@/utilities/globalReads'
import { resolveOgImage } from '@/utilities/ogImageReads'
import { getTypePath, getVisiblePosts } from '@/utilities/posts'
import Image from 'next/image'
import { absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo'
import type { Metadata } from 'next'
import Link from 'next/link'
const LATEST_NEWS_LIMIT = 6
export default async function ArtigosPage() {
const home = await getCachedGlobal('home', 2)()
const ARTIGOS_DESCRIPTION = 'Notícias, artigos e a atuação do deputado Jorge Solla pela Bahia.'
let heroImage: Media | null = null
if (typeof home.image === 'number') {
heroImage = await getCachedDocumentById('media', `${home.image}`)()
} else {
heroImage = home.image ?? null
export async function generateMetadata(): Promise<Metadata> {
const [home, globalMetadata] = await Promise.all([
getCachedGlobal('home', 2)(),
getCachedGlobal('metadata')(),
])
const { siteUrl, siteName, twitterCreator } = resolveSiteMetadata(globalMetadata)
const canonicalUrl = absoluteSitePath(siteUrl, '/artigos')
const { url: imageUrl, media } = await resolveOgImage(home.image)
const images = imageUrl
? [
{
url: imageUrl,
width: media?.width ?? undefined,
height: media?.height ?? undefined,
alt: media?.alt ?? 'Jorge Solla',
},
]
: []
const title = `Artigos e notícias | ${siteName}`
return {
title,
description: ARTIGOS_DESCRIPTION,
...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}),
openGraph: {
type: 'website',
locale: 'pt-BR',
...(canonicalUrl ? { url: canonicalUrl } : {}),
siteName,
title,
description: ARTIGOS_DESCRIPTION,
images,
},
twitter: {
card: 'summary_large_image',
title,
description: ARTIGOS_DESCRIPTION,
creator: twitterCreator,
images: images.map((image) => image.url),
},
}
}
export default async function ArtigosPage() {
const latestNews = (await getVisiblePosts())
.filter((post) => post.type === 'noticia')
.slice(0, LATEST_NEWS_LIMIT)
return (
<main className="w-full">
<section className="relative flex min-h-[68vh] w-full flex-col items-center justify-center gap-8 px-4 pt-24 pb-16 text-center text-(--site-header-foreground) sm:px-6 sm:pt-28 lg:px-8">
<div className="flex max-w-4xl flex-col items-center gap-5">
<h1 className="text-4xl font-extrabold leading-tight tracking-tight text-balance sm:text-5xl lg:text-6xl">
O mandato do tamanho da Bahia
</h1>
<p className="max-w-2xl text-lg leading-relaxed text-(--site-header-muted) sm:text-xl">
Notícias, campanhas e a atuação do deputado Jorge Solla por uma Bahia mais justa.
</p>
<Link
href={getTypePath('noticia')}
className="inline-flex items-center justify-center rounded-md bg-white/15 px-5 py-2.5 text-sm font-semibold text-(--site-header-foreground) ring-1 ring-white/25 backdrop-blur transition-colors hover:bg-white/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
>
Ver notícias
</Link>
</div>
{heroImage?.url ? (
<div className="w-full max-w-3xl overflow-hidden rounded-2xl shadow-2xl ring-1 ring-white/15">
<Image
className="h-auto w-full object-cover"
src={heroImage.url}
alt={heroImage.alt}
width={heroImage.width ?? 1200}
height={heroImage.height ?? 675}
priority
/>
</div>
) : null}
</section>
<CampaignInternalPageIntro eyebrow="Mandato em movimento" title="Artigos e notícias" />
<section data-theme="editorial" className="w-full bg-background text-foreground">
<div className="mx-auto w-full max-w-5xl px-4 py-14 sm:px-6 lg:px-8 lg:py-16">

View file

@ -1,32 +0,0 @@
/**
* S27 — the loading state of the Central (artefato: cena 05): the skeleton
* shape of the board, so a slow connection sees the page it is about to get.
* S45 — the vertical board shape (4 columns from `lg`; the dominant 9:16 video
* slot, an approximation of the mixed board) with the media edge-to-edge like
* the real card.
*/
export default function ConteudosLoading() {
return (
<div className="mx-auto w-full max-w-6xl px-5 py-10 sm:px-8">
<div className="h-44 animate-pulse rounded-xl bg-(--campaign-band) motion-reduce:animate-none" />
<div className="mt-9 grid items-start gap-5 sm:grid-cols-2 lg:grid-cols-4">
{[0, 1, 2, 3].map((index) => (
<div
key={index}
className="overflow-hidden rounded-[14px] border border-(--campaign-line) bg-white"
>
<div className="aspect-[9/16] animate-pulse bg-(--campaign-band) motion-reduce:animate-none" />
<div className="p-4">
<div className="h-5 w-3/4 animate-pulse rounded bg-(--campaign-band) motion-reduce:animate-none" />
<div className="mt-3 h-4 w-full animate-pulse rounded bg-(--campaign-band) motion-reduce:animate-none" />
<div className="mt-4 grid grid-cols-2 gap-2">
<div className="h-11 animate-pulse rounded bg-(--campaign-band) motion-reduce:animate-none" />
<div className="h-11 animate-pulse rounded bg-(--campaign-band) motion-reduce:animate-none" />
</div>
</div>
</div>
))}
</div>
</div>
)
}

View file

@ -1,194 +0,0 @@
import { CampaignFooter } from '@/components/CampaignFooter'
import { ContentPieceCatalog } from '@/components/conteudos/ContentPieceCatalog'
import {
ContentPieceActiveFilterChip,
ContentPieceFilters,
} from '@/components/conteudos/ContentPieceFilters'
import { ContentPieceHero } from '@/components/conteudos/ContentPieceHero'
import { ContentPiecePageHeader } from '@/components/conteudos/ContentPiecePageHeader'
import {
ContentPieceEmptyState,
ContentPieceNoResults,
ContentPieceThemeFallbackNotice,
ContentPieceThemeNoResults,
} from '@/components/conteudos/ContentPieceStates'
import { CONTENT_PIECE_ACTIVE_CHIP } from '@/components/conteudos/contentPieceClasses'
import {
CONTENT_PIECE_CATALOG_PATH,
buildContentPieceCatalogHref,
contentPieceMediaKind,
type ContentPieceCatalogSearchParams,
type ContentPiecePublicItem,
} from '@/lib/contentPieceCatalog'
import { hasPublishedArchivePhotos } from '@/utilities/archivePhotos/archivePhotoReads'
import { getPublishedContentPieceItems } from '@/utilities/content/contentPieceReads'
import { loadContentPieceCatalogSearch } from '@/utilities/content/contentPieceThemeSearch'
import { getCachedGlobal } from '@/utilities/globalReads'
import { hasPublishedJingles } from '@/utilities/jingleReads'
import { resolveOgImage } from '@/utilities/ogImageReads'
import { absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo'
import type { Metadata } from 'next'
const CATALOG_TITLE = 'Central de Conteúdos — Peça voto pra Solla 1313'
const CATALOG_DESCRIPTION =
'Escolha uma peça oficial de Jorge Solla 1313, baixe ou compartilhe no WhatsApp com a mensagem de voto pronta. Sem cadastro e direto no celular.'
const isImagePiece = (item: ContentPiecePublicItem): boolean =>
contentPieceMediaKind(item) === 'image'
/**
* S27/S28 — the public Central: the published pieces (C211) with the five
* facets, the term search (literal or by theme) and the share of the vote from
* the card itself. Indexable with its own title while there is content; zero
* published renders the honest empty state and hides discovery (header badge
* and footer link), never a 404. The filtered URLs canonicalize to `/conteudos`.
*/
export async function generateMetadata(): Promise<Metadata> {
const [items, globalMetadata] = await Promise.all([
getPublishedContentPieceItems(),
getCachedGlobal('metadata')(),
])
const { siteUrl, siteName, twitterCreator } = resolveSiteMetadata(globalMetadata)
const canonicalUrl = absoluteSitePath(siteUrl, CONTENT_PIECE_CATALOG_PATH)
const firstImage = items.find(isImagePiece)
const { url: imageUrl } = await resolveOgImage(firstImage?.file?.path)
const title = `${CATALOG_TITLE} | ${siteName}`
const images = imageUrl ? [{ url: imageUrl, alt: CATALOG_TITLE }] : []
return {
title,
description: CATALOG_DESCRIPTION,
...(items.length === 0 ? { robots: { index: false, follow: false } } : {}),
...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}),
openGraph: {
type: 'website',
locale: 'pt-BR',
...(canonicalUrl ? { url: canonicalUrl } : {}),
siteName,
title,
description: CATALOG_DESCRIPTION,
images,
},
twitter: {
card: 'summary_large_image',
title,
description: CATALOG_DESCRIPTION,
creator: twitterCreator,
images: images.map((image) => image.url),
},
}
}
export default async function ConteudosPage({
searchParams,
}: {
searchParams: Promise<ContentPieceCatalogSearchParams>
}) {
const [rawSearchParams, showJingles, showFotos] = await Promise.all([
searchParams,
hasPublishedJingles(),
hasPublishedArchivePhotos(),
])
const {
publishedCount,
params,
items,
facets,
activeFilters,
themeMode,
themeUnavailable,
themeApplied,
} = await loadContentPieceCatalogSearch({ rawSearchParams })
if (publishedCount === 0) {
return (
<>
<ContentPiecePageHeader />
<main className="w-full bg-white">
<ContentPieceEmptyState />
</main>
<CampaignFooter showJingles={showJingles} showFotos={showFotos} />
</>
)
}
// S37 (design scene 03): the people filter leads the results with its own
// heading and the active chip at the side (desktop).
const liderancaFilter = activeFilters.find((filter) => filter.facet === 'lideranca')
return (
<>
<ContentPiecePageHeader />
<main className="w-full bg-white">
<ContentPieceHero />
<section className="mx-auto w-full max-w-6xl px-5 py-8 sm:px-8 sm:py-10">
{themeUnavailable ? (
<div className="mb-4">
<ContentPieceThemeFallbackNotice retryHref={buildContentPieceCatalogHref(params)} />
</div>
) : null}
<ContentPieceFilters
params={params}
facets={facets}
activeFilters={activeFilters}
themeUnavailable={themeUnavailable}
/>
<div className="mt-8 border-t border-(--campaign-line) pt-6 sm:mt-9 sm:pt-7">
<div className="flex flex-wrap items-end justify-between gap-2">
<div>
<h2 className="border-0 pb-0 font-[family-name:var(--font-exo2)] text-xl font-black tracking-[-0.02em] sm:text-2xl">
{liderancaFilter
? `Peças com ${liderancaFilter.value}`
: themeMode
? `Resultados para “${params.q}”`
: 'Escolha, compartilhe e peça voto'}
</h2>
<p className="mt-1 text-sm text-(--campaign-muted)">
{liderancaFilter
? 'O filtro combina com cidade, tema e os demais recortes.'
: 'O play só carrega a mídia quando você pedir.'}
</p>
</div>
{liderancaFilter ? (
<ContentPieceActiveFilterChip
filter={liderancaFilter}
className={`${CONTENT_PIECE_ACTIVE_CHIP} max-sm:hidden`}
/>
) : themeMode ? (
themeApplied ? (
<span className="text-sm text-(--campaign-muted)">Por tema ativo</span>
) : null
) : (
<span className="hidden text-sm text-(--campaign-muted) sm:inline">
Abra o título para ver detalhes
</span>
)}
</div>
<div className="mt-5">
{items.length > 0 ? (
<ContentPieceCatalog items={items} themeMode={themeMode} />
) : themeMode && !themeUnavailable ? (
<ContentPieceThemeNoResults params={params} />
) : (
<ContentPieceNoResults activeFilters={activeFilters} />
)}
</div>
</div>
</section>
</main>
<CampaignFooter
showJingles={showJingles}
showConteudos
showFotos={showFotos}
current="conteudos"
/>
</>
)
}

View file

@ -1,98 +0,0 @@
import config from '@payload-config'
import { after, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import { CONTENT_MEDIA_SLUG } from '@/lib/contentPiece'
import { CONTENT_PIECE_FRAME_WAIT_MS } from '@/lib/contentPieceFrame'
import { PRIVATE_MEDIA_CACHE_CONTROL } from '@/lib/privateMedia'
import {
ensureContentPieceFrame,
findContentPieceFrameMedia,
} from '@/utilities/content/contentPieceFrameJob'
import { getPublishedContentPieceBySlug } from '@/utilities/content/contentPieceReads'
import {
buildPrivateMediaResponse,
resolvePrivateMediaStaticDir,
} from '@/utilities/privateMedia/privateMediaResponse'
export const dynamic = 'force-dynamic'
/**
* C226 — the still of a published video piece. Sibling of `/midia` with the very
* same gate: the cached public read (published + archived file), so a draft, an
* unpublished piece, an unknown slug, a link piece and a non-video piece all
* answer the same silent 404. The frame therefore stops being visible exactly
* when the video does — the kill switch survives by construction, because the
* derived file lives in the PRIVATE `contentMedia`, never in the public `media`.
*
* On a miss the still is generated and awaited for a short budget; the work
* keeps running after the response, so the archive published before this slice
* heals one piece per view without any backfill. Nothing here records a
* circulation event: seeing a frame is not opening or downloading the piece.
*/
const notFound = (): NextResponse =>
new NextResponse(null, {
status: 404,
headers: { 'Cache-Control': PRIVATE_MEDIA_CACHE_CONTROL },
})
/**
* Resolves the promise, or null when the budget runs out first. A deliberate
* twin of the C182 poster route's helper: the acervo route is behind the
* campaign gate and must not be edited by this slice, and 15 lines of promise
* racing is not worth a shared module for two call sites. Revisit if a third
* door ever needs the same race.
*/
const settledWithin = async <Value>(
promise: Promise<Value>,
budgetMs: number,
): Promise<Value | null> => {
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
promise,
new Promise<null>((resolve) => {
timer = setTimeout(() => resolve(null), budgetMs)
}),
])
} finally {
if (timer) clearTimeout(timer)
}
}
export const GET = async (
_request: Request,
{ params }: { params: Promise<{ slug: string }> },
): Promise<Response> => {
const { slug } = await params
const piece = await getPublishedContentPieceBySlug(slug)
// `framePath` is the eligibility decision (a video with an archived file),
// so a photo, an audio or a link piece never reaches the generation.
if (!piece?.framePath) return notFound()
const payload = await getPayload({ config })
const generation = ensureContentPieceFrame(payload, piece.id)
// The response below may answer the neutral slot before the still is ready;
// `after` keeps the process alive until the generation settles (never a 524).
after(async () => {
await generation
})
const generated = await settledWithin(generation, CONTENT_PIECE_FRAME_WAIT_MS)
// The probe after the wait closes the race with a generation that committed
// its row while this request was waiting (the `Promise.race` resolves the
// moment the budget is out, not the moment the row lands).
const media = generated ?? (await findContentPieceFrameMedia(payload, piece.id).catch(() => null))
if (!media?.filename) return notFound()
// Intentional admin bypass: the public gate is the cached published read
// above; the media row only supplies the stored object to stream.
const response = await buildPrivateMediaResponse({
media,
staticDir: resolvePrivateMediaStaticDir(payload, CONTENT_MEDIA_SLUG),
rangeHeader: null,
download: false,
})
return response.status === 200 ? response : notFound()
}

View file

@ -1,82 +0,0 @@
import config from '@payload-config'
import { after, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import { CONTENT_MEDIA_SLUG } from '@/lib/contentPiece'
import { contentPieceDownloadFilename } from '@/lib/contentPieceCatalog'
import { PRIVATE_MEDIA_CACHE_CONTROL } from '@/lib/privateMedia'
import { recordContentEvent } from '@/utilities/content/contentEventWrite'
import { getPublishedContentPieceBySlug } from '@/utilities/content/contentPieceReads'
import {
buildPrivateMediaResponse,
resolvePrivateMediaStaticDir,
} from '@/utilities/privateMedia/privateMediaResponse'
export const dynamic = 'force-dynamic'
/**
* S27 — the only public door to a published piece's archived file. The gate is
* the cached public read (published + file/link predicate), so a draft, an
* unpublished piece, an unknown slug and a link piece without an attachment
* all answer the same silent 404 — the route never leaks which pieces exist.
* The object itself stays in the private bucket; this reuses the C193/C199
* streaming helper (range, disposition, private headers), never a second copy.
*/
const notFound = (): NextResponse =>
new NextResponse(null, {
status: 404,
headers: { 'Cache-Control': PRIVATE_MEDIA_CACHE_CONTROL },
})
export const GET = async (
request: Request,
{ params }: { params: Promise<{ slug: string }> },
): Promise<Response> => {
const { slug } = await params
const piece = await getPublishedContentPieceBySlug(slug)
if (!piece?.file) return notFound()
const payload = await getPayload({ config })
// Intentional admin bypass: the public gate is the cached published read
// above; the media row only supplies the fresh filename/mimeType to stream.
const media = await payload
.findByID({
collection: CONTENT_MEDIA_SLUG,
id: piece.file.id,
depth: 0,
overrideAccess: true,
})
.catch(() => null)
if (!media?.filename) return notFound()
const download = new URL(request.url).searchParams.get('download') === '1'
const rangeHeader = request.headers.get('range')
const response = await buildPrivateMediaResponse({
media,
staticDir: resolvePrivateMediaStaticDir(payload, CONTENT_MEDIA_SLUG),
rangeHeader,
download,
// The voter downloads `jorge-solla-1313-<slug>.<ext>`, never the internal
// upload name; the object key stays `media.filename`.
dispositionFilename: contentPieceDownloadFilename(slug, media.filename),
})
// C213 — one download served is one anonymous counter, and only a FULL
// `?download=1` request counts: a plain media GET is play/preview, and a
// ranged request is a resumed transfer (counting each part would multiply one
// download). `after` keeps the write off the response path — a lost count is
// acceptable, a slower download is not.
if (download && !rangeHeader && response.status === 200) {
const pieceId = piece.id
after(async () => {
await recordContentEvent({
type: 'download',
subjectType: 'peca',
subjectId: String(pieceId),
})
})
}
return response
}

View file

@ -1,38 +0,0 @@
import { XCircleIcon } from 'lucide-react'
import Link from 'next/link'
import { ContentPiecePageHeader } from '@/components/conteudos/ContentPiecePageHeader'
import { CONTENT_PIECE_PRIMARY_BUTTON } from '@/components/conteudos/contentPieceClasses'
import { CONTENT_PIECE_CATALOG_PATH } from '@/lib/contentPieceCatalog'
/**
* S27 — the same friendly screen for an unpublished piece and an unknown slug
* (artefato: cena 05): the link is gone and the page does not reveal whether
* the piece existed.
*/
export default function ContentPieceNotFound() {
return (
<>
<ContentPiecePageHeader backHref={CONTENT_PIECE_CATALOG_PATH} />
<main className="flex min-h-[60vh] w-full items-center justify-center px-5 py-16 sm:px-8">
<div className="max-w-lg text-center">
<span className="mx-auto flex size-12 items-center justify-center rounded-full bg-(--campaign-band) text-(--campaign-muted)">
<XCircleIcon className="size-6" aria-hidden="true" />
</span>
<h1 className="mt-5 font-[family-name:var(--font-exo2)] text-2xl font-black tracking-[-0.02em] sm:text-3xl">
Esta peça não está disponível
</h1>
<p className="mx-auto mt-3 max-w-[48ch] text-base leading-7 text-(--campaign-muted)">
Ela pode ter sido retirada. Encontre outro material oficial e continue pedindo voto.
</p>
<Link
href={CONTENT_PIECE_CATALOG_PATH}
className={`${CONTENT_PIECE_PRIMARY_BUTTON} mt-6`}
>
Ver outras peças
</Link>
</div>
</main>
</>
)
}

View file

@ -1,98 +0,0 @@
import { CampaignFooter } from '@/components/CampaignFooter'
import { ContentPieceDetail } from '@/components/conteudos/ContentPieceDetail'
import { ContentPiecePageHeader } from '@/components/conteudos/ContentPiecePageHeader'
import { CONTENT_PIECE_CATALOG_PATH } from '@/lib/contentPieceCatalog'
import { hasPublishedArchivePhotos } from '@/utilities/archivePhotos/archivePhotoReads'
import { getPublishedContentPieceBySlug } from '@/utilities/content/contentPieceReads'
import { getCachedGlobal } from '@/utilities/globalReads'
import { hasPublishedJingles } from '@/utilities/jingleReads'
import { resolveOgImage } from '@/utilities/ogImageReads'
import { absoluteSitePath, resolveSiteMetadata, truncate } from '@/utilities/seo'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
const MAX_DESCRIPTION_LENGTH = 200
const fallbackDescription = (title: string): string =>
`Peça oficial de Jorge Solla 1313: ${title}. Baixe ou compartilhe e peça voto pra Solla 1313.`
/**
* S27 — the public page of one piece: the link opens with title and preview on
* WhatsApp, the voter can play, download and share right there. An unpublished
* piece, a draft and an unknown slug resolve to the same honest not-found
* screen — the kill switch never reveals whether the piece existed.
*/
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params
const item = await getPublishedContentPieceBySlug(slug)
if (!item) {
return { title: 'Esta peça não está disponível', robots: { index: false, follow: false } }
}
const globalMetadata = await getCachedGlobal('metadata')()
const { siteUrl, siteName, twitterCreator } = resolveSiteMetadata(globalMetadata)
const canonicalUrl = absoluteSitePath(siteUrl, item.publicPath)
const ownImage = item.file?.mimeType?.startsWith('image/') ? item.file.path : null
const { url: imageUrl } = await resolveOgImage(ownImage)
const title = `${item.title} | ${siteName}`
const description = truncate(
item.description ?? fallbackDescription(item.title),
MAX_DESCRIPTION_LENGTH,
)
const images = imageUrl ? [{ url: imageUrl, alt: item.title }] : []
return {
title,
description,
...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}),
openGraph: {
type: item.type === 'video' ? 'video.other' : 'article',
locale: 'pt-BR',
...(canonicalUrl ? { url: canonicalUrl } : {}),
siteName,
title,
description,
images,
},
twitter: {
card: 'summary_large_image',
title,
description,
creator: twitterCreator,
images: images.map((image) => image.url),
},
}
}
export default async function ContentPiecePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const [item, showJingles, showFotos] = await Promise.all([
getPublishedContentPieceBySlug(slug),
hasPublishedJingles(),
hasPublishedArchivePhotos(),
])
if (!item) notFound()
return (
<>
<ContentPiecePageHeader backHref={CONTENT_PIECE_CATALOG_PATH} />
<main className="w-full bg-white">
<div className="mx-auto w-full max-w-5xl px-5 py-8 sm:px-8 sm:py-12">
<ContentPieceDetail item={item} />
</div>
</main>
<CampaignFooter
showJingles={showJingles}
showConteudos
showFotos={showFotos}
current="conteudos"
/>
</>
)
}

View file

@ -1,18 +0,0 @@
import React from 'react'
/**
* S27 — the Central de Conteúdos shell. The root `(frontend)` layout locks the
* document with `overflow-hidden`, so this segment owns the scroll container;
* `campaign-site` applies the campaign palette, same contract as the jingles
* page.
*/
export default function ConteudosLayout({ children }: { children: React.ReactNode }) {
return (
<div
data-theme="campaign-site"
className="h-dvh w-full overflow-y-auto bg-(--campaign-cream) text-(--campaign-ink)"
>
{children}
</div>
)
}

View file

@ -1,22 +1,4 @@
import { Arimo, Exo_2 } from 'next/font/google'
import localFont from 'next/font/local'
/**
* S13 — Brexter 700, the display face of the official card masters. Web use is
* licensed for the campaign (regular DEMO face is deliberately excluded). The
* font is only consumed by the card canvas, so the family string travels to the
* client and `ensureCardFont` awaits `document.fonts.load` before measuring.
*/
export const brexterBold = localFont({
src: './fonts/Brexter-Bold.ttf',
weight: '700',
style: 'normal',
display: 'swap',
variable: '--font-brexter',
// Canvas-only face: preloading it on every page would be waste (the studio
// loads it on demand via `document.fonts.load`).
preload: false,
})
/**
* Campanha 2026 — Exo 2 display (títulos/CTAs) e Arimo (texto), as duas faces

View file

@ -24,7 +24,7 @@ export const dynamic = 'force-dynamic'
* public read (`archivePhotoIsPublic`), so a draft, a removed photo, an unknown
* id and a delete all answer the same silent 404 — the route never leaks which
* photos exist. `?tamanho=grade` serves the grade thumbnail, `?download=1`
* forces the legible `jorge-solla-1313-foto-<id>.<ext>` name; the object itself
* forces the legible `jorge-solla-foto-<id>.<ext>` name; the object itself
* stays in the private bucket (the C193/C199 streaming helper is the single
* owner of that I/O). `X-Robots-Tag` keeps the derivative out of the index: the
* canonical surface is the album, never the file.

View file

@ -5,8 +5,9 @@ import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
import { ConsentText } from '@/components/campaign/shared/ConsentText'
import { CampaignInternalPageIntro } from '@/components/CampaignInternalPageIntro'
import { CampaignPageHeader } from '@/components/CampaignPageHeader'
import { ARCHIVE_PHOTO_FOCUS } from '@/components/fotos/archivePhotoClasses'
import { ArchivePhotoPageHeader } from '@/components/fotos/ArchivePhotoPageHeader'
import { SelfieSearchClosed } from '@/components/fotos/SelfieSearchClosed'
import { SelfieSearchFlow } from '@/components/fotos/SelfieSearchFlow'
import { isArchivePhotoRemovalChannelUrl } from '@/lib/archivePhotoCatalog'
@ -16,13 +17,12 @@ import {
} from '@/lib/archivePhotoPublicCatalog'
import { FACE_INDEX_CONSENT_KEY, FACE_SEARCH_CONSENT_KEY } from '@/lib/campaignConsentKeys'
import { getConsentByKey } from '@/utilities/campaignConsent'
import { hasPublishedContentPieces } from '@/utilities/content/contentPieceReads'
import { getCachedGlobal } from '@/utilities/globalReads'
import { absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo'
export const dynamic = 'force-dynamic'
const ENTRY_TITLE = 'Encontre você nas fotos — Jorge Solla 1313'
const ENTRY_TITLE = 'Encontre você nas fotos'
const ENTRY_DESCRIPTION =
'Busque por uma selfie sua as fotos públicas aprovadas em que você aparece. A imagem não sai do seu dispositivo.'
@ -72,10 +72,7 @@ export async function generateMetadata(): Promise<Metadata> {
}
export default async function FotosEncontrePage() {
const [album, showConteudos] = await Promise.all([
getCachedPhotoAlbum(),
hasPublishedContentPieces(),
])
const album = await getCachedPhotoAlbum()
if (album.published === false || album.selfieSearchEnabled !== true) notFound()
const payload = await getPayload({ config })
@ -89,7 +86,8 @@ export default async function FotosEncontrePage() {
return (
<>
<ArchivePhotoPageHeader showConteudos={showConteudos} />
<CampaignPageHeader current="fotos" />
<CampaignInternalPageIntro eyebrow="Memória do mandato" title="Encontre você nas fotos" />
<main className="w-full">
<div className="mx-auto w-full max-w-6xl px-5 pt-5 sm:px-8 sm:pt-7">
<nav className="text-sm text-(--campaign-muted)">

View file

@ -1,9 +1,9 @@
import { CampaignFooter } from '@/components/CampaignFooter'
import { CampaignInternalPageIntro } from '@/components/CampaignInternalPageIntro'
import { CampaignPageHeader } from '@/components/CampaignPageHeader'
import { ArchivePhotoFilters } from '@/components/fotos/ArchivePhotoFilters'
import { ArchivePhotoGrid } from '@/components/fotos/ArchivePhotoGrid'
import { ArchivePhotoHero } from '@/components/fotos/ArchivePhotoHero'
import { ArchivePhotoLightbox } from '@/components/fotos/ArchivePhotoLightbox'
import { ArchivePhotoPageHeader } from '@/components/fotos/ArchivePhotoPageHeader'
import { ArchivePhotoRemovalBand } from '@/components/fotos/ArchivePhotoRemovalBand'
import {
ArchivePhotoEmptyState,
@ -26,14 +26,12 @@ import {
getApprovedArchivePhotoAlbumItems,
hasPublishedArchivePhotos,
} from '@/utilities/archivePhotos/archivePhotoReads'
import { hasPublishedContentPieces } from '@/utilities/content/contentPieceReads'
import { getCachedGlobal } from '@/utilities/globalReads'
import { hasPublishedJingles } from '@/utilities/jingleReads'
import { absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
const ALBUM_TITLE = 'Fotos do mandato — Jorge Solla 1313'
const ALBUM_TITLE = 'Fotos do mandato'
const ALBUM_DESCRIPTION =
'Procure as fotos do mandato por data, município, atividade ou pessoa pública — só registros aprovados, com o contexto de cada momento.'
@ -91,12 +89,7 @@ export default async function FotosPage({
}: {
searchParams: Promise<ArchivePhotoAlbumSearchParams>
}) {
const [rawSearchParams, album, showJingles, showConteudos] = await Promise.all([
searchParams,
getCachedPhotoAlbum(),
hasPublishedJingles(),
hasPublishedContentPieces(),
])
const [rawSearchParams, album] = await Promise.all([searchParams, getCachedPhotoAlbum()])
if (album.published === false) notFound()
@ -119,8 +112,8 @@ export default async function FotosPage({
return (
<>
<ArchivePhotoPageHeader showConteudos={showConteudos} />
<ArchivePhotoHero />
<CampaignPageHeader current="fotos" />
<CampaignInternalPageIntro eyebrow="Memória do mandato" title="Fotos" />
<main className="w-full bg-white">
<div className="mx-auto w-full max-w-6xl px-5 py-8 sm:px-8">
{isEmptyAlbum ? (
@ -166,12 +159,7 @@ export default async function FotosPage({
{removalChannelUrl ? <ArchivePhotoRemovalBand href={removalChannelUrl} /> : null}
</main>
<CampaignFooter
showJingles={showJingles}
showConteudos={showConteudos}
showFotos
current="fotos"
/>
<CampaignFooter current="fotos" />
{selected ? (
<ArchivePhotoLightbox
item={selected}

View file

@ -1,17 +0,0 @@
import React from 'react'
/**
* S21 — the public jingles page shell. The root `(frontend)` layout locks the
* document with `overflow-hidden`, so this segment owns the scroll container;
* `campaign-site` applies the campaign palette, same contract as the home.
*/
export default function JinglesLayout({ children }: { children: React.ReactNode }) {
return (
<div
data-theme="campaign-site"
className="h-dvh w-full overflow-y-auto bg-(--campaign-cream) text-(--campaign-ink)"
>
{children}
</div>
)
}

View file

@ -1,91 +0,0 @@
import { CampaignFooter } from '@/components/CampaignFooter'
import { CampaignPageHeader } from '@/components/CampaignPageHeader'
import { JingleEmptyState } from '@/components/jingles/JingleEmptyState'
import { JingleIntro } from '@/components/jingles/JingleIntro'
import { JinglePlayer } from '@/components/jingles/JinglePlayer'
import { hasPublishedArchivePhotos } from '@/utilities/archivePhotos/archivePhotoReads'
import { hasPublishedContentPieces } from '@/utilities/content/contentPieceReads'
import { getCachedGlobal } from '@/utilities/globalReads'
import { getPublishedJingleItems } from '@/utilities/jingleReads'
import { resolveOgImage } from '@/utilities/ogImageReads'
import { absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo'
import type { Metadata } from 'next'
const JINGLES_PATH = '/jingles'
const JINGLES_TITLE = 'Jingles de Jorge Solla'
const JINGLES_DESCRIPTION =
'Ouça aqui mesmo e baixe os jingles oficiais de Jorge Solla 1313. Grátis, sem cadastro e direto no celular.'
/**
* S21 — the public jingles page: the published pieces with cover, in-page
* player and MP3 download. Indexable with its own title/description while
* there is content; zero published renders the honest empty state and hides
* discovery (footer link + header badge), never a 404.
*/
export async function generateMetadata(): Promise<Metadata> {
const [items, globalMetadata] = await Promise.all([
getPublishedJingleItems(),
getCachedGlobal('metadata')(),
])
const { siteUrl, siteName, twitterCreator } = resolveSiteMetadata(globalMetadata)
const canonicalUrl = absoluteSitePath(siteUrl, JINGLES_PATH)
const { url: imageUrl } = await resolveOgImage(items[0]?.coverUrl)
const title = `${JINGLES_TITLE} | ${siteName}`
const images = imageUrl ? [{ url: imageUrl, alt: items[0]?.coverAlt ?? JINGLES_TITLE }] : []
return {
title,
description: JINGLES_DESCRIPTION,
...(items.length === 0 ? { robots: { index: false, follow: false } } : {}),
...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}),
openGraph: {
type: 'website',
locale: 'pt-BR',
...(canonicalUrl ? { url: canonicalUrl } : {}),
siteName,
title,
description: JINGLES_DESCRIPTION,
images,
},
twitter: {
card: 'summary_large_image',
title,
description: JINGLES_DESCRIPTION,
creator: twitterCreator,
images: images.map((image) => image.url),
},
}
}
export default async function JinglesPage() {
const [items, showConteudos, showFotos] = await Promise.all([
getPublishedJingleItems(),
hasPublishedContentPieces(),
hasPublishedArchivePhotos(),
])
const hasItems = items.length > 0
return (
<>
<CampaignPageHeader badge={hasItems ? 'Jingles oficiais' : undefined} />
<main className="w-full bg-white">
{hasItems ? (
<>
<JingleIntro />
<JinglePlayer jingles={items} />
</>
) : (
<JingleEmptyState />
)}
</main>
<CampaignFooter
showJingles={hasItems}
showConteudos={showConteudos}
showFotos={showFotos}
current="jingles"
/>
</>
)
}

View file

@ -184,197 +184,34 @@ body,
--campaign-surface: #f9faff;
--campaign-band: #ebe9e9;
--campaign-line: rgb(0 0 0 / 12%);
--campaign-content-inset: clamp(20px, calc(25.36vw - 79.64px), 180px);
/* S15 — literal colors of the "Time de você" card banners, measured from the
reference card. The canvas renderer keeps the same literals in `src/lib`. */
--team-red: #e50e2f;
--team-yellow: #ffec01;
--team-blue: #0061a5;
/* 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). */
/* Public campaign-site forms sit 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.
Copy stays in normal flow so browser text-spacing preferences can expand the
hero; only the photographic composition is positioned absolutely. */
.campaign-hero {
--campaign-hero-height: max(
clamp(745px, calc(933.42px - 47.95vw), 780px),
clamp(745px, calc(41.6vw + 581.51px), 901px)
);
min-height: var(--campaign-hero-height);
height: auto;
/* S47 — the post-election hero field shared by the home header and hero
(design scene 01/02): the PT red gradient with the warm yellow glow. */
.campaign-hero-field {
background:
radial-gradient(circle at 82% 36%, rgb(255 230 7 / 18%), transparent 24rem),
linear-gradient(112deg, #8f1717 0%, #a21c1c 58%, #7d1714 100%);
}
.campaign-hero-stage {
max-width: 1920px;
min-height: var(--campaign-hero-height);
height: auto;
/* The subtle paper grain of the approved hero (same SVG noise as the gate). */
.campaign-noise {
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.22'/%3E%3C/svg%3E");
}
/* Penpot uses the wide official lockup on phones/tablets. Its PNG canvas is
square and mostly transparent, so the element follows the board geometry. */
.campaign-home-logo {
--campaign-logo-size: clamp(356px, calc(38.93vw + 203px), 502px);
top: clamp(-166px, calc(-11.47vw - 77.92px), -123px);
left: clamp(0px, calc(33.6vw - 113.05px), 145px);
width: var(--campaign-logo-size);
height: var(--campaign-logo-size);
background-image: url('/Prancheta%206%403x.png');
background-repeat: no-repeat;
background-position: center;
background-size: contain;
/* S47 — the muted grade of the editorial/album photography. */
.photo-tone {
filter: saturate(0.86) contrast(1.04);
}
.campaign-hero-portrait {
top: clamp(53px, calc(9.33vw + 16.32px), 88px);
left: calc(50% - 211px + clamp(8.5px, calc(3.07vw - 3.55px), 20px));
width: 422px;
height: auto;
aspect-ratio: 422 / 469;
}
.campaign-hero-portrait-image {
top: -3.63%;
left: 0.24%;
width: 106.4%;
}
.campaign-hero-ally {
z-index: 0;
width: 177px;
height: auto;
}
.campaign-hero-ally--rui {
top: clamp(144px, calc(9.33vw + 107.32px), 179px);
left: calc(50% - 218.5px + clamp(0px, calc(3.07vw - 12.05px), 11.5px));
}
.campaign-hero-ally--wagner {
top: clamp(148px, calc(9.33vw + 111.32px), 183px);
left: calc(50% - 265.5px + clamp(0px, calc(3.07vw - 12.05px), 11.5px));
}
.campaign-hero-ally--jeronimo {
top: clamp(140px, calc(9.33vw + 103.32px), 175px);
left: calc(50% + 27.5px + clamp(0px, calc(3.07vw - 12.05px), 11.5px));
}
.campaign-hero-ally--lula {
top: clamp(179px, calc(9.33vw + 142.32px), 214px);
left: calc(50% + 65.5px + clamp(0px, calc(3.07vw - 12.05px), 11.5px));
z-index: 2;
width: 216px;
}
.campaign-hero-overlay {
top: clamp(300px, calc(9.33vw + 263.32px), 335px);
left: 50%;
width: 100vw;
height: 245px;
transform: translateX(-50%);
}
.campaign-hero-content {
min-height: var(--campaign-hero-height);
padding-top: clamp(462px, calc(9.33vw + 425.32px), 497px);
padding-bottom: clamp(14px, calc(28.34px - 1.87vw), 21px);
}
.campaign-hero-title {
width: min(100%, clamp(392px, calc(38.67vw + 240.04px), 537px));
margin-right: auto;
margin-left: auto;
text-align: center;
font-size: clamp(32px, calc(4.27vw + 15.23px), 48px);
}
.campaign-hero-copy {
width: min(calc(100% - 40px), clamp(327px, calc(24.8vw + 229.54px), 420px));
margin-top: clamp(13px, calc(4.59vw - 4.73px), 30.5px);
margin-left: clamp(20px, calc(31.2vw - 98.62px), 141px);
font-size: clamp(14px, calc(1.07vw + 9.81px), 18px);
}
.campaign-hero-actions {
width: max-content;
margin-top: clamp(16px, calc(6.85vw - 5.92px), 31px);
margin-right: clamp(18px, calc(52.27vw - 187.43px), 214px);
margin-left: auto;
gap: clamp(10px, calc(3.73vw - 4.67px), 24px);
}
.campaign-hero-action {
min-height: clamp(44px, calc(4.27vw + 27.23px), 60px);
height: auto;
font-size: clamp(17px, calc(0.27vw + 15.95px), 18px);
}
.campaign-hero-action--secondary {
width: clamp(145px, calc(3.47vw + 131.38px), 158px);
}
.campaign-hero-action--primary {
width: clamp(112px, calc(12.27vw + 63.79px), 158px);
}
.campaign-hero-action-detail,
.campaign-hero-action-long {
display: none;
}
.campaign-hero-accolades {
width: max-content;
margin-top: clamp(16px, calc(6.13vw - 8.09px), 39px);
margin-right: clamp(18px, calc(5.07vw - 1.91px), 37px);
margin-left: auto;
align-items: flex-end;
gap: 2px;
text-align: right;
font-size: clamp(10px, calc(0.53vw + 7.9px), 12px);
line-height: 1.2;
}
.campaign-hero-accolade-secondary {
display: none;
}
.campaign-proof {
height: clamp(88px, calc(9.33vw + 51.32px), 123px);
}
.campaign-proof-list {
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: center;
gap: 23px;
}
.campaign-proof-value {
font-size: clamp(24px, calc(3.2vw + 11.42px), 36px);
}
.campaign-proof-label {
font-size: clamp(10px, calc(2.13vw + 1.62px), 18px);
line-height: 1.15;
}
.campaign-problem {
height: clamp(659px, calc(774.22px - 11.25vw), 730px);
}
.campaign-flags {
height: clamp(368px, calc(419.93px - 5.08vw), 400px);
}
.campaign-section-eyebrow,
.campaign-section-title,
.campaign-section-copy {
left: var(--campaign-content-inset);
}
.campaign-section-eyebrow {
font-size: clamp(10px, calc(0.32vw + 8.75px), 12px);
line-height: 1.25;
@ -389,446 +226,6 @@ body,
font-size: clamp(14px, calc(0.32vw + 12.75px), 16px);
}
.campaign-problem-eyebrow {
top: 35px;
}
.campaign-problem-title {
top: clamp(59px, calc(1.11vw + 54.64px), 66px);
width: min(
calc(100% - var(--campaign-content-inset) - var(--campaign-content-inset)),
clamp(331px, calc(45.17vw + 153.49px), 616px)
);
}
.campaign-problem-copy {
top: clamp(127px, calc(157.83px - 3.01vw), 146px);
width: min(
calc(100% - var(--campaign-content-inset) - 20px),
clamp(327px, calc(61.65vw + 84.72px), 716px)
);
}
.campaign-problem-carousel {
top: clamp(191px, calc(278.63px - 8.56vw), 245px);
}
.campaign-flags-eyebrow {
top: clamp(33px, calc(39.49px - 0.63vw), 37px);
}
.campaign-flags-title {
top: clamp(62px, calc(68.49px - 0.63vw), 66px);
width: min(
calc(100% - var(--campaign-content-inset) - var(--campaign-content-inset)),
clamp(331px, calc(52.77vw + 123.6px), 664px)
);
}
.campaign-flags-copy {
top: clamp(130px, calc(160.83px - 3.01vw), 149px);
width: min(
calc(100% - var(--campaign-content-inset) - 20px),
clamp(327px, calc(63.87vw + 76px), 730px)
);
}
.campaign-flags-carousel {
top: clamp(215px, calc(285px - 17.81vw), 228px);
}
[data-theme='campaign-site'] [data-carousel='problem'] [data-carousel-track] {
gap: clamp(10px, calc(1.74vw + 3.16px), 21px);
}
[data-theme='campaign-site'] [data-carousel='flags'] [data-carousel-track] {
gap: clamp(10px, calc(0.63vw + 7.51px), 14px);
}
/* The content board carousel is not full-bleed: it lives inside the section
gutter, so the shared track inset (--campaign-carousel-inset) must not
re-pad it or its slides stop filling the viewport. */
[data-theme='campaign-site'] [data-carousel='contents'] [data-carousel-track] {
padding-left: 0;
padding-right: 0;
scroll-padding-left: 0;
}
[data-theme='campaign-site'] [data-carousel-chips] {
gap: clamp(6px, calc(0.16vw + 5.37px), 7px);
}
@media (min-width: 24.5625rem) {
.campaign-flags-carousel {
top: clamp(184px, calc(234.31px - 4.91vw), 215px);
}
}
@media (min-width: 40rem) {
.campaign-hero-action-detail,
.campaign-hero-action-long {
display: block;
}
.campaign-hero-action-short {
display: none;
}
}
/* Desktop grid recalibration (S6): the flags section grows so both grid rows
of content-height cards fit with breathing room. Fluid clamp between the
measured anchors: ~520px at 1024px (narrow columns, 3-line card bodies) →
~418px from ~1385px up (columns saturate at the content max width).
`overflow: hidden` keeps text-zoom growth (≥125%) inside the band instead
of painting the cards over the footer. */
@media (min-width: 64rem) {
.campaign-flags {
height: clamp(418px, calc(807.7px - 28.13vw), 520px);
overflow: hidden;
}
[data-theme='campaign-site'] {
--campaign-desktop-portrait-width: clamp(465px, calc(26.45vw + 194.15px), 702px);
--campaign-desktop-portrait-height: clamp(517px, calc(29.41vw + 215.84px), 781px);
--campaign-desktop-hero-height: max(
var(--campaign-desktop-portrait-height),
clamp(477px, min(clamp(543px, 52.8vw, 1014px), calc(-16.79vw + 108.01dvh + 26.8px)), 871px)
);
--campaign-proof-min-height: clamp(124px, calc(244px - 9.38vw), 148px);
--campaign-proof-max-height: clamp(148px, calc(6.81vw + 78.3px), 209px);
}
.campaign-hero {
--campaign-hero-height: var(--campaign-desktop-hero-height);
}
.campaign-home-logo {
top: clamp(55px, calc(15.23vw - 101px), 94px);
left: clamp(47px, calc(6.64vw - 21px), 64px);
width: clamp(197px, calc(23.44vw - 43px), 257px);
height: clamp(150px, calc(17.97vw - 34px), 196px);
transform: none;
background-image: url('/Prancheta%203%403x.png');
background-position: clamp(-56.75px, calc(-5.37vw + 12px), -43px)
clamp(-87.55px, calc(-8.03vw + 15.2px), -67px);
background-size: 143.65% 189.3%;
}
.campaign-hero-portrait {
top: auto;
bottom: 0;
left: clamp(153px, calc(33.59vw - 191px), 239px);
width: var(--campaign-desktop-portrait-width);
aspect-ratio: 465 / 517;
}
.campaign-hero-portrait-image {
top: -3.67%;
left: 0.25%;
width: 106.45%;
}
.campaign-hero-ally {
top: auto;
bottom: var(--campaign-ally-bottom, 0px);
width: clamp(177px, calc(6.14vw + 93.16px), 211px);
}
.campaign-hero-ally--rui {
left: clamp(-27px, calc(37.89vw - 415px), 70px);
--campaign-ally-bottom: clamp(67px, calc(102px - 2.73vw), 74px);
}
.campaign-hero-ally--wagner {
left: clamp(-75px, calc(37.89vw - 463px), 22px);
--campaign-ally-bottom: 0px;
z-index: 1;
}
.campaign-hero-ally--jeronimo {
left: clamp(63px, calc(31.25vw - 257px), 143px);
--campaign-ally-bottom: clamp(49px, calc(1.56vw + 33px), 53px);
z-index: 1;
}
.campaign-hero-ally--lula {
left: clamp(-8px, calc(37.89vw - 396px), 89px);
width: clamp(238px, calc(8.3vw + 124.6px), 284px);
--campaign-ally-bottom: 0px;
}
.campaign-hero-overlay {
display: none;
}
.campaign-hero-content {
--campaign-hero-content-right: clamp(47px, calc(40.23vw - 365px), 150px);
padding-top: clamp(106px, calc(8.2vw + 22px), 127px);
padding-bottom: 11px;
}
.campaign-hero-title {
width: 392px;
margin-right: var(--campaign-hero-content-right);
margin-left: auto;
text-align: right;
font-size: 36px;
line-height: 1.2;
}
.campaign-hero-copy {
width: 418px;
margin-top: 19.6px;
margin-right: var(--campaign-hero-content-right);
margin-left: auto;
text-align: right;
font-size: 18px;
}
.campaign-hero-actions {
margin-top: 29px;
margin-right: var(--campaign-hero-content-right);
margin-left: auto;
gap: 24px;
}
.campaign-hero-action {
width: 158px;
min-height: 60px;
height: auto;
font-size: 18px;
}
.campaign-hero-action-detail,
.campaign-hero-action-long,
.campaign-hero-accolade-secondary {
display: block;
}
.campaign-hero-action-short {
display: none;
}
.campaign-hero-accolades {
margin-top: clamp(64px, calc(169px - 8.2vw), 85px);
margin-right: 0;
margin-left: 559px;
align-items: flex-start;
gap: 8px;
text-align: left;
font-size: 12px;
line-height: 15px;
}
.campaign-proof {
height: clamp(
var(--campaign-proof-min-height),
calc(100dvh - var(--campaign-desktop-hero-height)),
var(--campaign-proof-max-height)
);
}
.campaign-proof-list {
grid-template-columns: 147px 178px 183px;
align-items: start;
justify-content: space-between;
gap: 0;
padding-top: clamp(17px, calc(47px - 2.34vw), 23px);
}
.campaign-proof-value {
font-size: 36px;
line-height: 1.1;
}
.campaign-proof-label {
font-size: 18px;
line-height: 1.25;
}
}
@media (min-width: 80rem) {
.campaign-hero-portrait {
left: clamp(239px, calc(100vw - 1041px), 325px);
}
.campaign-home-logo {
top: clamp(45px, calc(823.3px - 56.98vw), 94px);
left: clamp(64px, calc(8.14vw - 40.19px), 71px);
width: clamp(257px, calc(43.02vw - 293.7px), 294px);
height: clamp(196px, calc(32.56vw - 220.74px), 224px);
background-position: clamp(-64.92px, calc(64.85px - 9.5vw), -56.75px)
clamp(-100.05px, calc(98.5px - 14.54vw), -87.55px);
}
.campaign-hero-ally--rui {
left: clamp(70px, calc(83.72vw - 1001.63px), 142px);
--campaign-ally-bottom: clamp(64px, calc(111.65px - 3.49vw), 67px);
}
.campaign-hero-ally--wagner {
left: clamp(22px, calc(87.21vw - 1094.28px), 97px);
--campaign-ally-bottom: 0px;
}
.campaign-hero-ally--jeronimo {
left: clamp(143px, calc(93.02vw - 1047.7px), 223px);
--campaign-ally-bottom: clamp(49px, calc(112.53px - 4.65vw), 53px);
}
.campaign-hero-ally--lula {
left: clamp(89px, calc(87.21vw - 1027.28px), 164px);
--campaign-ally-bottom: 0px;
}
.campaign-hero-content {
--campaign-hero-content-right: clamp(150px, calc(72.09vw - 772.8px), 212px);
padding-top: clamp(106px, calc(439.56px - 24.42vw), 127px);
}
.campaign-hero-accolades {
margin-top: clamp(64px, calc(122.09vw - 1498.8px), 169px);
margin-left: clamp(559px, calc(186.05vw - 1822.4px), 719px);
}
.campaign-proof-list {
padding-top: clamp(17px, calc(19.77vw - 236.02px), 34px);
}
}
@media (min-width: 85.375rem) {
.campaign-hero-portrait {
left: clamp(325px, calc(35.92vw - 165.7px), 524px);
}
.campaign-home-logo {
top: clamp(45px, calc(7.4vw - 56.1px), 86px);
left: clamp(71px, calc(22.38vw - 234.8px), 195px);
width: clamp(294px, calc(16.43vw + 69.6px), 385px);
height: clamp(224px, calc(12.64vw + 51.4px), 294px);
background-position: clamp(-85px, calc(-3.63vw - 15.35px), -64.92px)
clamp(-131px, calc(-5.6vw - 23.6px), -100.05px);
}
.campaign-hero-ally--rui {
left: clamp(142px, calc(30.51vw - 274.7px), 311px);
--campaign-ally-bottom: clamp(64px, calc(2.17vw + 34.4px), 76px);
}
.campaign-hero-ally--wagner {
left: clamp(97px, calc(28.88vw - 297.5px), 257px);
--campaign-ally-bottom: 0px;
}
.campaign-hero-ally--jeronimo {
left: clamp(223px, calc(35.56vw - 262.8px), 420px);
--campaign-ally-bottom: clamp(49px, calc(1.44vw + 29.27px), 57px);
}
.campaign-hero-ally--lula {
left: clamp(164px, calc(32.31vw - 277.4px), 343px);
--campaign-ally-bottom: 0px;
}
.campaign-hero-content {
--campaign-hero-content-right: clamp(212px, calc(1.44vw + 192.27px), 220px);
padding-top: clamp(106px, calc(15.16vw - 101.1px), 190px);
padding-bottom: clamp(7px, calc(24.32px - 0.9vw), 12px);
}
.campaign-hero-title {
width: clamp(392px, calc(38.81vw - 138.1px), 607px);
font-size: clamp(36px, calc(2.17vw + 6.41px), 48px);
}
.campaign-hero-copy {
width: clamp(418px, calc(26.71vw + 53.05px), 566px);
margin-top: clamp(19.6px, calc(3.83vw - 32.68px), 40.8px);
font-size: clamp(18px, calc(1.08vw + 3.2px), 24px);
}
.campaign-hero-actions {
margin-top: clamp(29px, calc(6.68vw - 62.23px), 66px);
gap: clamp(24px, calc(0.36vw + 19.07px), 26px);
}
.campaign-hero-action {
width: clamp(158px, calc(2.89vw + 118.55px), 174px);
min-height: clamp(60px, calc(1.08vw + 45.2px), 66px);
height: auto;
font-size: clamp(18px, calc(0.36vw + 13.07px), 20px);
}
.campaign-hero-accolades {
margin-top: clamp(169px, calc(5.87vw + 88.76px), 201.5px);
margin-left: clamp(719px, calc(73.83vw - 289.5px), 1128px);
font-size: clamp(11px, calc(0.36vw + 7.07px), 14px);
line-height: 1.2;
}
.campaign-proof-list {
padding-top: clamp(30px, calc(1.08vw + 19.2px), 40px);
}
}
@media (prefers-reduced-motion: reduce) {
[data-theme='campaign-site'] [data-carousel-track] {
scroll-behavior: auto;
}
}
/* Full-bleed carousel viewport with content-aligned first cards. The scroller
reaches both browser edges; only its initial item follows the page gutter. */
[data-theme='campaign-site'] [data-carousel] {
--campaign-carousel-inset: var(--campaign-content-inset);
}
[data-theme='campaign-site'] [data-carousel-track],
[data-theme='campaign-site'] [data-carousel-chips] {
padding-left: var(--campaign-carousel-inset);
padding-right: var(--campaign-carousel-inset);
}
[data-theme='campaign-site'] [data-carousel-track] {
scroll-padding-left: var(--campaign-carousel-inset);
}
.campaign-action-feedback {
transition:
transform 180ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 120ms ease-out,
background-color 180ms ease-out,
filter 180ms ease-out;
}
@media (hover: hover) and (pointer: fine) {
.campaign-action-feedback:hover {
transform: scale(1.05);
}
}
.campaign-action-feedback:active {
opacity: 0.8;
transform: scale(0.99);
}
@media (prefers-reduced-motion: reduce) {
.campaign-action-feedback {
transition: none;
}
.campaign-action-feedback:hover,
.campaign-action-feedback:active {
transform: none;
}
}
@media (min-width: 64rem) {
[data-theme='campaign-site'] {
--campaign-content-inset: max(180px, calc((100vw - 1152px) / 2 + 32px));
}
}
[data-theme='petition'] {
--background: #fff8f2;
--foreground: #2c2522;

View file

@ -1,208 +0,0 @@
'use client'
import { CampaignFlagCard } from '@/components/CampaignFlagCard'
import { CampaignProblemCard } from '@/components/CampaignProblemCard'
import { useCallback, useEffect, useRef, useState } from 'react'
const AUTO_ADVANCE_MS = 4_000
const PROGRAMMATIC_SCROLL_LOCK_MS = 700
export type CampaignCarouselItem = {
id: string
label?: string
title: string
body: string
image?: string
imageAlt?: string
imageFrame?: {
width: number
height: number
className: string
}
}
type CampaignCarouselProps = {
ariaLabel: string
items: CampaignCarouselItem[]
variant: 'problem' | 'flags'
}
export const CampaignCarousel = ({ ariaLabel, items, variant }: CampaignCarouselProps) => {
const trackRef = useRef<HTMLOListElement>(null)
const programmaticScrollTimerRef = useRef<number | null>(null)
const isProgrammaticScrollRef = useRef(false)
const [activeIndex, setActiveIndex] = useState(0)
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const [isFocused, setIsFocused] = useState(false)
const [isTouching, setIsTouching] = useState(false)
const temporarilyPaused = isHovered || isFocused || isTouching
const releaseProgrammaticScroll = useCallback(() => {
isProgrammaticScrollRef.current = false
if (programmaticScrollTimerRef.current !== null) {
window.clearTimeout(programmaticScrollTimerRef.current)
programmaticScrollTimerRef.current = null
}
}, [])
useEffect(() => releaseProgrammaticScroll, [releaseProgrammaticScroll])
useEffect(() => {
if (typeof window.matchMedia !== 'function') return
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
const applyPreference = () => setPrefersReducedMotion(media.matches)
applyPreference()
media.addEventListener('change', applyPreference)
return () => media.removeEventListener('change', applyPreference)
}, [])
const scrollToItem = useCallback(
(index: number) => {
const track = trackRef.current
const target = track?.querySelector<HTMLElement>(`[data-carousel-index="${index}"]`)
if (!track || !target) return
const startInset = Number.parseFloat(window.getComputedStyle(track).paddingLeft) || 0
releaseProgrammaticScroll()
isProgrammaticScrollRef.current = true
setActiveIndex(index)
track.scrollTo({
left: target.offsetLeft - track.offsetLeft - startInset,
behavior: prefersReducedMotion ? 'auto' : 'smooth',
})
programmaticScrollTimerRef.current = window.setTimeout(
releaseProgrammaticScroll,
PROGRAMMATIC_SCROLL_LOCK_MS,
)
},
[prefersReducedMotion, releaseProgrammaticScroll],
)
useEffect(() => {
if (prefersReducedMotion || temporarilyPaused || items.length < 2) return
// S6: on desktop the carousel lives inside an `lg:hidden` wrapper — its
// auto-advance chain must not keep re-rendering a display:none subtree.
if (trackRef.current && window.getComputedStyle(trackRef.current).display === 'none') return
const timer = window.setTimeout(() => {
scrollToItem((activeIndex + 1) % items.length)
}, AUTO_ADVANCE_MS)
return () => window.clearTimeout(timer)
}, [activeIndex, items.length, prefersReducedMotion, scrollToItem, temporarilyPaused])
const syncActiveItem = useCallback(() => {
const track = trackRef.current
if (!track || isProgrammaticScrollRef.current) return
const cards = Array.from(track.querySelectorAll<HTMLElement>('[data-carousel-index]'))
const startInset = Number.parseFloat(window.getComputedStyle(track).paddingLeft) || 0
let nearestIndex = activeIndex
let nearestDistance = Number.POSITIVE_INFINITY
cards.forEach((card, index) => {
const distance = Math.abs(card.offsetLeft - track.offsetLeft - track.scrollLeft - startInset)
if (distance < nearestDistance) {
nearestDistance = distance
nearestIndex = index
}
})
if (nearestIndex !== activeIndex) setActiveIndex(nearestIndex)
}, [activeIndex])
const pauseOnTouch = () => {
releaseProgrammaticScroll()
setIsTouching(true)
}
const resumeAfterTouch = () => setIsTouching(false)
return (
<div
role="region"
tabIndex={0}
aria-label={ariaLabel}
aria-roledescription="carrossel"
data-carousel={variant}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onPointerDown={releaseProgrammaticScroll}
onWheel={releaseProgrammaticScroll}
onFocusCapture={() => setIsFocused(true)}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setIsFocused(false)
}}
onTouchStart={pauseOnTouch}
onTouchEnd={resumeAfterTouch}
onTouchCancel={resumeAfterTouch}
className="relative focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-current"
>
{variant === 'flags' ? (
<div className="relative h-6">
<div
aria-label="Escolher bandeira"
data-carousel-chips
className="scrollbar-hide absolute -top-2.5 right-0 left-0 flex h-11 items-center gap-1.5 overflow-x-auto pr-2"
>
{items.map((item, index) => {
const isActive = index === activeIndex
return (
<button
key={item.id}
type="button"
aria-current={isActive ? 'true' : undefined}
onClick={() => scrollToItem(index)}
className="campaign-action-feedback inline-flex min-h-11 shrink-0 items-center justify-center rounded-full font-[family-name:var(--font-arimo)] text-xs font-bold uppercase focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:outline-none"
>
<span
className={`inline-flex h-6 items-center rounded-full px-3 transition-colors ${
isActive
? 'bg-(--pt-red) text-white'
: 'bg-[#dbdce0] text-[#66676b] hover:bg-[#cfd0d4]'
}`}
>
{item.label}
</span>
</button>
)
})}
</div>
</div>
) : null}
<ol
ref={trackRef}
aria-live={temporarilyPaused ? 'polite' : 'off'}
onScroll={syncActiveItem}
data-carousel-track
className={`scrollbar-hide m-0 flex list-none snap-x snap-mandatory overflow-x-auto scroll-smooth p-0 ${
variant === 'problem' ? 'gap-2.5' : 'mt-5 gap-2.5'
}`}
>
{items.map((item, index) => (
<li
key={item.id}
data-carousel-index={index}
aria-label={`${index + 1} de ${items.length}`}
aria-current={index === activeIndex ? 'true' : undefined}
className={`m-0 shrink-0 snap-start ${
variant === 'problem'
? 'h-[437px] w-[calc(100vw_-_38px)] max-w-[355px]'
: 'h-[100px] w-[calc(100vw_-_66px)] max-w-[327px]'
}`}
>
{variant === 'problem' ? (
<CampaignProblemCard item={item} />
) : (
<CampaignFlagCard item={item} />
)}
</li>
))}
</ol>
</div>
)
}

View file

@ -1,5 +1,6 @@
import { ContentShareButton } from '@/components/ContentShareButton'
import type { ContentShareKind } from '@/lib/contentShare'
import { PUBLIC_FOCUS } from '@/lib/publicSite'
import Image from 'next/image'
import Link from 'next/link'
@ -13,73 +14,88 @@ export type CampaignContentCardData = {
coverUrl?: string
coverAlt?: string
subtitle?: string
/** Cover ratio: 16:9 for articles/videos, 1:1 for Instagram posts. */
coverAspect?: 'video' | 'square'
/** External target (e.g. a YouTube video): opens in a new tab with noopener. */
external?: boolean
}
type CampaignContentCardProps = {
card: CampaignContentCardData
featured?: boolean
/** S47 — the editorial board is 1 featured + 3 compact text rows. */
variant?: 'featured' | 'compact'
}
const cardClassName =
'flex h-full flex-col bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--pt-red)'
const ACTION_LABELS: Record<ContentShareKind, string> = {
article: 'Ler notícia',
video: 'Assistir no YouTube',
instagram: 'Ver no Instagram',
event: 'Ver',
}
const CARD_LINK = `block rounded-sm text-(--campaign-ink) no-underline ${PUBLIC_FOCUS}`
/**
* Presentational content card for the campaign home content bento/carousel —
* one spelling for every source (article, YouTube, Instagram in S3). Internal
* targets use `Link`; external ones open on the platform in a new tab with
* `noopener` (no inline player, per the approved wireframe). The card body is
* one anchor; the S4 share control lives as an absolutely positioned sibling
* (a button inside the anchor would be invalid interactive nesting), on a
* wrapper that also owns the hover `group` so the cover zoom keeps working.
* S47 — one item of the editorial home board (design scene 01/02): the
* `featured` variant carries the cover of the lead story; the `compact`
* variant is the text-only row of the side column. Both keep the S4 share
* control as a sibling of the anchor (never nested inside it) and derive the
* action label from the content source.
*/
export const CampaignContentCard = ({ card, featured = false }: CampaignContentCardProps) => {
const inner = (
export const CampaignContentCard = ({ card, variant = 'compact' }: CampaignContentCardProps) => {
const featured = variant === 'featured'
const actionLabel = ACTION_LABELS[card.shareKind]
const inner = featured ? (
<>
<div
className={`relative w-full shrink-0 overflow-hidden bg-(--campaign-band) ${
card.coverAspect === 'square' ? 'aspect-square' : 'aspect-video'
}`}
>
<div className="h-[220px] w-full overflow-hidden bg-[#d7d0cb] lg:h-[338px]">
{card.coverUrl ? (
<Image
src={card.coverUrl}
alt={card.coverAlt ?? card.title}
fill
sizes={featured ? '(min-width: 768px) 44vw, 92vw' : '(min-width: 768px) 22vw, 92vw'}
className="object-cover transition-transform duration-300 group-hover:scale-[1.03] motion-reduce:transition-none motion-reduce:group-hover:scale-100"
width={720}
height={405}
sizes="(min-width: 1024px) 650px, 100vw"
className="h-full w-full object-cover transition-transform duration-500 ease-out group-hover:scale-[1.025] motion-reduce:transition-none motion-reduce:group-hover:scale-100"
/>
) : null}
</div>
<div className="flex flex-1 flex-col gap-1 p-3">
<h3
className={`line-clamp-3 leading-snug font-bold text-(--campaign-ink) group-hover:underline ${
featured ? 'text-base' : 'text-sm'
}`}
>
{card.title}
</h3>
{featured && card.subtitle ? (
<p className="line-clamp-2 text-xs leading-relaxed text-(--campaign-muted)">
{card.subtitle}
</p>
) : null}
<span className="mt-auto text-xs text-(--campaign-muted)">{card.meta}</span>
</div>
<p className="mt-4 text-xs font-bold text-(--pt-red) lg:mt-5">{card.meta}</p>
<h3 className="mt-2 max-w-[690px] font-[family-name:var(--font-exo2)] text-[25px] leading-[1.12] font-extrabold tracking-[-0.02em] text-balance lg:text-[31px] lg:leading-[1.1] lg:tracking-[-0.025em]">
{card.title}
</h3>
{card.subtitle ? (
<p className="mt-2 line-clamp-2 text-sm leading-6 text-(--campaign-muted)">
{card.subtitle}
</p>
) : null}
<span className="mt-4 inline-flex items-center gap-2 text-sm font-bold text-(--pt-red)">
{actionLabel} <span aria-hidden="true">→</span>
</span>
</>
) : (
<>
<p className="text-xs font-bold text-(--pt-red)">{card.meta}</p>
<h3 className="mt-3 line-clamp-3 font-[family-name:var(--font-exo2)] text-xl leading-[1.18] font-extrabold tracking-[-0.02em] text-balance lg:text-[23px] lg:leading-[1.16]">
{card.title}
</h3>
<p className="mt-4 text-sm font-bold text-(--pt-red)">
{actionLabel} <span aria-hidden="true">→</span>
</p>
</>
)
return (
<div className="group relative h-full">
{card.external ? (
<a href={card.href} target="_blank" rel="noopener noreferrer" className={cardClassName}>
<a
href={card.href}
target="_blank"
rel="noopener noreferrer"
className={`${CARD_LINK} ${featured ? '' : 'py-6 pr-12 lg:py-7'}`}
>
{inner}
</a>
) : (
<Link href={card.href} className={cardClassName}>
<Link href={card.href} className={`${CARD_LINK} ${featured ? '' : 'py-6 pr-12 lg:py-7'}`}>
{inner}
</Link>
)}

View file

@ -1,131 +0,0 @@
'use client'
import { CampaignContentCard, type CampaignContentCardData } from '@/components/CampaignContentCard'
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react'
const PROGRAMMATIC_SCROLL_LOCK_MS = 700
type CampaignContentCarouselProps = {
ariaLabel: string
items: CampaignContentCardData[]
}
/**
* Mobile-only carousel for the campaign home content section: one card per
* screen with snap scrolling, page dots and a "N de M" readout. Deliberately
* not a variant of `CampaignCarousel` — that module is pinned to the Penpot
* auto-advancing geometry of the problem/flags sections.
*/
export const CampaignContentCarousel = ({ ariaLabel, items }: CampaignContentCarouselProps) => {
const trackRef = useRef<HTMLOListElement>(null)
const isProgrammaticScrollRef = useRef(false)
const [activeIndex, setActiveIndex] = useState(0)
const releaseProgrammaticScroll = useCallback(() => {
isProgrammaticScrollRef.current = false
}, [])
useEffect(() => releaseProgrammaticScroll, [releaseProgrammaticScroll])
const scrollToItem = useCallback(
(index: number) => {
const track = trackRef.current
const target = track?.querySelector<HTMLElement>(`[data-carousel-index="${index}"]`)
if (!track || !target) return
isProgrammaticScrollRef.current = true
setActiveIndex(index)
track.scrollTo({ left: target.offsetLeft - track.offsetLeft })
window.setTimeout(releaseProgrammaticScroll, PROGRAMMATIC_SCROLL_LOCK_MS)
},
[releaseProgrammaticScroll],
)
const handleKeyDown = (event: KeyboardEvent<HTMLOListElement>) => {
if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return
event.preventDefault()
const next = activeIndex + (event.key === 'ArrowRight' ? 1 : -1)
scrollToItem(Math.max(0, Math.min(items.length - 1, next)))
}
const syncActiveItem = useCallback(() => {
const track = trackRef.current
if (!track || isProgrammaticScrollRef.current) return
const cards = Array.from(track.querySelectorAll<HTMLElement>('[data-carousel-index]'))
let nearestIndex = activeIndex
let nearestDistance = Number.POSITIVE_INFINITY
cards.forEach((card, index) => {
const distance = Math.abs(card.offsetLeft - track.offsetLeft - track.scrollLeft)
if (distance < nearestDistance) {
nearestDistance = distance
nearestIndex = index
}
})
if (nearestIndex !== activeIndex) setActiveIndex(nearestIndex)
}, [activeIndex])
return (
<div
role="region"
aria-label={ariaLabel}
aria-roledescription="carrossel"
data-carousel="contents"
onPointerDown={releaseProgrammaticScroll}
onFocusCapture={releaseProgrammaticScroll}
className="relative focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-current"
>
<ol
ref={trackRef}
onScroll={syncActiveItem}
onKeyDown={handleKeyDown}
tabIndex={items.length > 1 ? 0 : undefined}
data-carousel-track
className="scrollbar-hide m-0 flex list-none snap-x snap-mandatory overflow-x-auto scroll-smooth p-0 motion-reduce:scroll-auto focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-current"
>
{items.map((item, index) => (
<li
key={item.id}
data-carousel-index={index}
aria-label={`${index + 1} de ${items.length}`}
aria-current={index === activeIndex ? 'true' : undefined}
aria-hidden={index !== activeIndex || undefined}
inert={index !== activeIndex || undefined}
className="m-0 w-full shrink-0 snap-start"
>
<CampaignContentCard card={item} />
</li>
))}
</ol>
{items.length > 1 ? (
<>
<div className="mt-3 flex items-center justify-center gap-2">
{items.map((item, index) => (
<button
key={item.id}
type="button"
aria-label={`Ir para o conteúdo ${index + 1} de ${items.length}`}
aria-current={index === activeIndex ? 'true' : undefined}
onClick={() => scrollToItem(index)}
className={`h-2 w-2 rounded-full transition-colors focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none ${
index === activeIndex
? 'bg-(--pt-red)'
: 'bg-(--campaign-muted) hover:bg-[#4f4744]'
}`}
/>
))}
</div>
<p
aria-live="polite"
className="mt-2 text-center text-xs font-semibold text-(--campaign-muted)"
>
{activeIndex + 1} de {items.length} · deslize para ver os próximos
</p>
</>
) : null}
</div>
)
}

View file

@ -1,5 +1,6 @@
import { CampaignContentCard, type CampaignContentCardData } from '@/components/CampaignContentCard'
import { CampaignContentCarousel } from '@/components/CampaignContentCarousel'
import { PUBLIC_FOCUS, PUBLIC_PATHS, PUBLIC_SOCIAL_LINKS } from '@/lib/publicSite'
import { cn } from '@/lib/utils'
import type { Post } from '@/payload-types'
import {
formatRelativePostDate,
@ -16,10 +17,10 @@ import {
} from '@/utilities/socialFeed/youtubeFeed'
import Link from 'next/link'
const CONTENT_SECTION_LIMIT = 5
const CONTENT_SECTION_LIMIT = 4
const sectionHeaderLinkClassName =
'text-sm font-bold text-(--pt-red) underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none'
'rounded-sm text-sm font-bold text-(--pt-red) underline decoration-(--pt-red) underline-offset-[5px] hover:text-(--pt-red-dark)'
type DatedCard = {
card: CampaignContentCardData
@ -81,7 +82,6 @@ const toInstagramCardData = (post: InstagramPost): DatedCard => ({
title: post.caption ?? 'Publicação no Instagram',
shareKind: 'instagram' as const,
meta: formatRelativePostDate(post.timestamp) ?? '',
coverAspect: 'square',
...(post.thumbnailUrl
? { coverUrl: post.thumbnailUrl, coverAlt: post.caption ?? 'Publicação no Instagram' }
: {}),
@ -90,14 +90,41 @@ const toInstagramCardData = (post: InstagramPost): DatedCard => ({
date: dateTimeOf(post.timestamp),
})
const youtubeHref = PUBLIC_SOCIAL_LINKS.youtube.href
const instagramHref = PUBLIC_SOCIAL_LINKS.instagram.href
const BoardLinks = () => (
<>
<Link href={PUBLIC_PATHS.artigos} className={cn(sectionHeaderLinkClassName, PUBLIC_FOCUS)}>
Todos os artigos
</Link>
<a
href={youtubeHref}
target="_blank"
rel="noopener noreferrer"
className={cn(sectionHeaderLinkClassName, PUBLIC_FOCUS)}
>
YouTube
</a>
<a
href={instagramHref}
target="_blank"
rel="noopener noreferrer"
className={cn(sectionHeaderLinkClassName, PUBLIC_FOCUS)}
>
Instagram
</a>
</>
)
/**
* Campaign home content board: the 5 most recent items across the visible
* posts (S1), the eligible YouTube feed (S2) and the eligible Instagram feed
* (S3), newest first, as a 1+4 bento on desktop and a one-per-screen carousel
* on mobile. Hides entirely when nothing is visible (`isPostVisible`
* fail-closed — hidden electoral tags never leak) and never breaks when a
* feed API is down (snapshot or no cards). With the feeds unconfigured it
* degrades to the S1 articles-only behavior.
* S47 — the editorial home board (design scene 01/02): the 4 most recent items
* across the visible posts (S1), the eligible YouTube feed (S2) and the
* eligible Instagram feed (S3), newest first, as 1 featured story + 3 compact
* rows. The previous mobile carousel is gone — the side column simply stacks.
* With nothing visible the section renders the approved empty state instead of
* leaving a silent hole (`isPostVisible` fail-closed — hidden electoral tags
* never leak), and a feed outage degrades to the articles-only board.
*/
export const CampaignContentSection = async () => {
const [visiblePosts, youtubeFeed, instagramFeed] = await Promise.all([
@ -116,71 +143,78 @@ export const CampaignContentSection = async () => {
.slice(0, CONTENT_SECTION_LIMIT)
.map(({ card }) => card)
if (!cards.length) return null
const [featured, ...rest] = cards
return (
<section
aria-labelledby="contents-title"
data-home-section="contents"
className="border-y border-(--campaign-line) bg-(--campaign-band)"
className="bg-(--campaign-cream)"
>
<div className="mx-auto w-full max-w-[1160px] px-5 py-12 sm:px-8 lg:px-10 lg:py-16">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="mx-auto w-full max-w-[1280px] px-5 py-16 sm:px-8 lg:px-16 lg:py-24">
<div className="flex flex-wrap items-end justify-between gap-6">
<div>
<p className="campaign-section-eyebrow m-0 font-black tracking-[0.1em] text-(--pt-red) uppercase">
<p className="m-0 font-[family-name:var(--font-exo2)] text-sm font-extrabold text-(--pt-red)">
Acompanhe de perto
</p>
<h2
id="contents-title"
className="campaign-section-title m-0 mt-1 border-0 p-0 font-black tracking-[-0.02em] text-balance"
className="mt-2 max-w-2xl border-0 p-0 font-[family-name:var(--font-exo2)] text-[34px] leading-[1.03] font-black tracking-[-0.04em] text-balance lg:text-[44px] lg:leading-[1.02] lg:tracking-[-0.045em]"
>
A caminhada, em tempo real
O mandato, onde a Bahia acontece
</h2>
<p className="campaign-section-copy m-0 mt-1 text-(--campaign-muted)">
Bastidores, caravanas e as lutas do mandato: conteúdo atualizado, direto das redes.
<p className="mt-4 max-w-xl text-[15px] leading-6 text-(--campaign-muted) lg:text-base lg:leading-7">
Notícias, vídeos e registros do trabalho em Brasília e nos municípios.
</p>
</div>
<div className="flex flex-wrap gap-4">
<Link href="/artigos" className={sectionHeaderLinkClassName}>
Ver artigos →
</Link>
{instagramFeed?.username ? (
<a
href={`https://www.instagram.com/${instagramFeed.username}`}
target="_blank"
rel="noopener noreferrer"
className={sectionHeaderLinkClassName}
>
Seguir no Instagram →
</a>
) : null}
{youtubeFeed ? (
<a
href={`https://www.youtube.com/channel/${youtubeFeed.channelId}`}
target="_blank"
rel="noopener noreferrer"
className={sectionHeaderLinkClassName}
>
YouTube →
</a>
) : null}
<div className="mb-1 hidden flex-wrap gap-x-6 gap-y-3 text-sm font-bold text-(--pt-red) lg:flex">
<BoardLinks />
</div>
</div>
<div className="mt-6 hidden grid-cols-4 gap-4 md:grid">
<div className="col-span-2 row-span-2">
<CampaignContentCard card={featured} featured />
{featured ? (
<div className="mt-8 grid border-y border-(--campaign-line) lg:mt-10 lg:grid-cols-12">
<article className="border-b border-(--campaign-line) py-7 lg:col-span-7 lg:border-r lg:border-b-0 lg:pr-8">
<CampaignContentCard card={featured} variant="featured" />
</article>
<div className="divide-y divide-(--campaign-line) lg:col-span-5 lg:pl-8">
{rest.map((card) => (
<article key={card.id}>
<CampaignContentCard card={card} variant="compact" />
</article>
))}
</div>
</div>
{rest.map((card) => (
<CampaignContentCard key={card.id} card={card} />
))}
</div>
) : (
<div className="mt-8 flex min-h-[290px] flex-col items-center justify-center border-y border-(--campaign-line) px-8 text-center lg:mt-10">
<svg
aria-hidden="true"
width="34"
height="34"
viewBox="0 0 24 24"
fill="none"
stroke="var(--pt-red)"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2Z" />
</svg>
<h3 className="mt-5 font-[family-name:var(--font-exo2)] text-2xl font-extrabold">
Ainda não há publicações
</h3>
<p className="mt-3 max-w-sm text-sm leading-6 text-(--campaign-muted)">
As notícias do mandato aparecerão aqui assim que forem publicadas.
</p>
</div>
)}
<div className="mt-5 md:hidden">
<CampaignContentCarousel ariaLabel="Conteúdos recentes" items={cards} />
</div>
{featured ? (
<div className="mt-6 flex flex-wrap gap-x-6 gap-y-3 text-sm font-bold text-(--pt-red) lg:hidden">
<BoardLinks />
</div>
) : null}
</div>
</section>
)

View file

@ -1,24 +0,0 @@
type CampaignFlagCardItem = {
id: string
title: string
body: string
}
type CampaignFlagCardProps = {
item: CampaignFlagCardItem
size?: 'compact' | 'spacious'
}
export const CampaignFlagCard = ({ item, size = 'compact' }: CampaignFlagCardProps) => {
const padding = size === 'spacious' ? 'p-4' : 'px-3.5 py-2.5'
return (
<article
className={`h-full rounded-[10px] bg-(--campaign-surface) ${padding} font-[family-name:var(--font-arimo)] text-black`}
>
<h3 className="m-0 border-0 p-0 text-[14px] leading-[1.12] font-bold tracking-normal">
{item.title}
</h3>
<p className="m-0 mt-1 text-[12px] leading-[1.18] text-[#6f6f73]">{item.body}</p>
</article>
)
}

View file

@ -0,0 +1,86 @@
/**
* S47 — "Bandeiras do mandato" (design scene 01/02): the six positions of the
* new mandate in an editorial hairline grid — 3×2 on desktop, one column on
* mobile. The electoral copy of the old campaign cards is gone; these are the
* commitments that orient the work, not a vote request.
*/
const FLAGS = [
{
id: 'saude',
label: 'Saúde',
title: 'Defender o SUS e valorizar quem cuida',
body: 'Financiamento público, piso da enfermagem, vacinação, ciência e atendimento digno.',
},
{
id: 'educacao',
label: 'Educação',
title: 'Mais oportunidades no interior',
body: 'Institutos federais, universidades e escola pública de qualidade em toda a Bahia.',
},
{
id: 'renda',
label: 'Renda',
title: 'Crescimento com dignidade',
body: 'Salário mínimo valorizado, moradia, agricultura familiar e comida na mesa.',
},
{
id: 'democracia',
label: 'Democracia',
title: 'Participação e direitos',
body: 'Instituições fortes, liberdade, respeito e combate permanente à desinformação.',
},
{
id: 'bahia',
label: 'Bahia',
title: 'A Bahia no centro das decisões',
body: 'Investimento federal e presença do mandato nos municípios e territórios.',
},
{
id: 'trabalho',
label: 'Trabalho',
title: 'Tempo para viver, renda para cuidar',
body: 'Jornada justa, direitos protegidos e trabalho que não adoece.',
},
] as const
export const CampaignFlagsSection = () => (
<section
id="bandeiras"
aria-labelledby="flags-title"
data-home-section="flags"
className="scroll-mt-4 bg-(--campaign-band)"
>
<div className="mx-auto w-full max-w-[1280px] px-5 py-16 sm:px-8 lg:px-16 lg:py-24">
<div className="grid gap-6 lg:grid-cols-12 lg:gap-8">
<div className="lg:col-span-7">
<p className="m-0 font-[family-name:var(--font-exo2)] text-sm font-extrabold text-(--pt-red)">
Bandeiras do mandato
</p>
<h2
id="flags-title"
className="mt-3 border-0 p-0 font-[family-name:var(--font-exo2)] text-[34px] leading-[1.03] font-black tracking-[-0.04em] text-balance lg:text-[46px] lg:leading-[1.02] lg:tracking-[-0.045em]"
>
Compromissos que orientam o trabalho
</h2>
</div>
<p className="m-0 self-end text-[15px] leading-6 text-(--campaign-muted) lg:col-span-5 lg:text-base lg:leading-7">
Posições construídas em décadas de vida pública, agora renovadas para o quarto mandato.
</p>
</div>
<ol className="mt-8 grid list-none border-t border-l border-black/15 p-0 lg:mt-12 lg:grid-cols-3">
{FLAGS.map((flag) => (
<li key={flag.id} className="min-h-[210px] border-r border-b border-black/15 p-6 lg:p-7">
<span className="font-[family-name:var(--font-exo2)] text-xs font-extrabold text-(--pt-red)">
{flag.label}
</span>
<h3 className="mt-3 font-[family-name:var(--font-exo2)] text-[22px] font-extrabold tracking-[-0.02em] text-balance lg:mt-5 lg:text-2xl">
{flag.title}
</h3>
<p className="mt-2 text-sm leading-6 text-(--campaign-muted) lg:mt-3">{flag.body}</p>
</li>
))}
</ol>
</div>
</section>
)

View file

@ -1,137 +1,86 @@
import Link from 'next/link'
import { CampaignLogoLink } from '@/components/CampaignLogoLink'
import { PUBLIC_FOCUS, PUBLIC_PATHS, PUBLIC_SOCIAL_LINK_LIST } from '@/lib/publicSite'
import { cn } from '@/lib/utils'
const APOIAR_URL = 'https://apoiar.me/jorgesolla'
type CampaignFooterProps = {
current?: 'artigos' | 'fotos'
}
const FOOTER_LINK = cn(
// C233 (design critique) — every footer link is a 44px-tall target on mobile.
'inline-flex min-h-11 items-center text-[rgb(255_248_242/75%)] no-underline hover:text-white',
'focus-visible:rounded-[2px] focus-visible:text-white focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-yellow)',
// C233 — every footer link is a 44px-tall target on mobile.
'inline-flex min-h-11 items-center rounded-sm text-[rgb(255_248_242/75%)] no-underline hover:text-white',
PUBLIC_FOCUS,
)
const REDES = [
{ label: 'Instagram', href: 'https://instagram.com/depjorgesolla' },
{ label: 'YouTube', href: 'https://youtube.com/@JorgeSollaDep' },
{ label: 'Facebook', href: 'https://facebook.com/depjorgesolla' },
]
const NAV_ITEMS = [
{ label: 'Artigos', href: PUBLIC_PATHS.artigos, key: 'artigos' },
{ label: 'Fotos', href: PUBLIC_PATHS.fotos, key: 'fotos' },
{ label: 'Privacidade', href: PUBLIC_PATHS.privacidade },
] as const
/**
* Rodapé eleitoral da campanha: identificação, navegação e redes oficiais.
* S21 — the "Jingles" discovery link only appears while something is published;
* the caller resolves the cached flag (`hasPublishedJingles`, or the page's own
* list) and passes it, so this stays a synchronous presentational leaf.
* S27 — the same contract for the "Conteúdos" link (`hasPublishedContentPieces`).
* C233 — the same contract for the "Fotos" link (`hasPublishedArchivePhotos`).
* `current` marks the page as the current one for assistive tech and the
* artefato's highlight.
* S47 — the institutional footer (design scene 03): the name lockup, the
* mandate identity and the two link columns. The electoral block is gone —
* no "Quero apoiar", campaign number, CNPJ or turn date — and "Fotos" is a
* permanent destination (the album has an honest empty state of its own).
* `current` marks the active section for assistive tech and the highlight.
*/
export const CampaignFooter = ({
showJingles = false,
showConteudos = false,
showFotos = false,
current,
}: {
showJingles?: boolean
showConteudos?: boolean
showFotos?: boolean
current?: 'jingles' | 'conteudos' | 'fotos'
} = {}) => {
const jinglesLinkClass = cn(FOOTER_LINK, current === 'jingles' && 'font-bold text-white')
const conteudosLinkClass = cn(FOOTER_LINK, current === 'conteudos' && 'font-bold text-white')
const fotosLinkClass = cn(FOOTER_LINK, current === 'fotos' && 'font-bold text-white')
return (
<footer className="bg-[#180a09] text-[rgb(255_248_242/75%)]">
<div className="mx-auto grid w-full max-w-6xl gap-6 px-4 py-10 text-sm sm:px-6 md:grid-cols-[2fr_1fr_1fr] lg:px-8">
<div>
<h4 className="mb-2 font-[family-name:var(--font-exo2)] text-base font-extrabold text-white">
Jorge Solla 1313
</h4>
<p className="m-0 text-[rgb(255_248_242/75%)]">
Deputado Federal · PT · Bahia
<br />
Mais saúde mais futuro.
</p>
</div>
<div>
<h4 className="mb-2 font-[family-name:var(--font-exo2)] text-sm font-bold text-white">
Navegue
</h4>
<ul className="m-0 grid list-none gap-1.5 p-0">
<li>
<a href="#bandeiras" className={FOOTER_LINK}>
Bandeiras
</a>
</li>
{showFotos ? (
<li>
<Link
href="/fotos"
aria-current={current === 'fotos' ? 'page' : undefined}
className={fotosLinkClass}
>
Fotos
</Link>
</li>
) : null}
{showConteudos ? (
<li>
<Link
href="/conteudos"
aria-current={current === 'conteudos' ? 'page' : undefined}
className={conteudosLinkClass}
>
Conteúdos
</Link>
</li>
) : null}
{showJingles ? (
<li>
<Link
href="/jingles"
aria-current={current === 'jingles' ? 'page' : undefined}
className={jinglesLinkClass}
>
Jingles
</Link>
</li>
) : null}
<li>
<a href={APOIAR_URL} target="_blank" rel="noopener" className={FOOTER_LINK}>
Quero apoiar
</a>
</li>
<li>
<Link href="/privacidade" className={FOOTER_LINK}>
Privacidade
</Link>
</li>
</ul>
</div>
<div>
<h4 className="mb-2 font-[family-name:var(--font-exo2)] text-sm font-bold text-white">
Redes
</h4>
<ul className="m-0 grid list-none gap-1.5 p-0">
{REDES.map((rede) => (
<li key={rede.label}>
<a href={rede.href} target="_blank" rel="noopener" className={FOOTER_LINK}>
{rede.label}
</a>
</li>
))}
</ul>
</div>
</div>
<div className="border-t border-white/10">
<p className="mx-auto my-0 w-full max-w-6xl px-4 py-5 text-xs leading-relaxed text-[rgb(255_248_242/65%)] sm:px-6 lg:px-8">
Jorge José Santos Pereira Solla · Candidato a Deputado Federal · Nº 1313 · Federação
Brasil da Esperança (PT/PCdoB/PV) · CNPJ: 68.430.467/0001-05 · Propaganda eleitoral
gratuita. Site de campanha — conteúdo sob responsabilidade do candidato. Eleições 2026 ·
1º turno: 04/10/2026.
export const CampaignFooter = ({ current }: CampaignFooterProps = {}) => (
<footer className="bg-[#180a09] text-[#fff8f2]">
<div className="mx-auto grid w-full max-w-[1152px] gap-10 px-5 py-12 sm:px-8 md:grid-cols-[1.7fr_.8fr_.8fr] md:gap-12 lg:py-14">
<div>
<CampaignLogoLink tone="negative" priority={false} className={PUBLIC_FOCUS} />
<p className="mt-5 text-sm leading-6 text-white/68">
Deputado Federal · PT · Bahia
<br />
<strong className="font-semibold text-white">Mais Saúde, Mais Futuro.</strong>
</p>
</div>
</footer>
)
}
<div>
<h2 className="border-0 p-0 font-[family-name:var(--font-exo2)] text-sm font-extrabold">
Navegue
</h2>
<ul className="mt-4 ml-0 grid list-none gap-1 p-0 text-sm text-white/70">
{NAV_ITEMS.map((item) => {
const isCurrent = 'key' in item && item.key === current
return (
<li key={item.label}>
<Link
href={item.href}
aria-current={isCurrent ? 'page' : undefined}
className={cn(FOOTER_LINK, isCurrent && 'font-bold text-white')}
>
{item.label}
</Link>
</li>
)
})}
</ul>
</div>
<div>
<h2 className="border-0 p-0 font-[family-name:var(--font-exo2)] text-sm font-extrabold">
Redes
</h2>
<ul className="mt-4 ml-0 grid list-none gap-1 p-0 text-sm text-white/70">
{PUBLIC_SOCIAL_LINK_LIST.map((social) => (
<li key={social.label}>
<a href={social.href} target="_blank" rel="noopener" className={FOOTER_LINK}>
{social.label}
</a>
</li>
))}
</ul>
</div>
</div>
<div className="border-t border-white/10">
<p className="mx-auto my-0 w-full max-w-[1152px] px-5 py-5 text-xs leading-5 text-white/55 sm:px-8">
Jorge José Santos Pereira Solla · Deputado Federal · Partido dos Trabalhadores · Bahia
</p>
</div>
</footer>
)

View file

@ -10,7 +10,7 @@ const SCROLL_KEYS = new Set(['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', ' '])
* S12 — the campaign home owns its scroll container (`h-dvh overflow-y-auto`
* on the (home) layout; the body is `overflow-hidden`), so the browser's
* native fragment navigation — which scrolls the document — never lands on a
* hash target. On a direct `/#novidades` load the mobile layout settles only
* hash target. On a direct `/#bandeiras` load the mobile layout settles only
* after the `h-dvh` reflow and the hero's eager images, kicking the page back
* to the top. (`[type]`/`artigos` share the same scroll-container model; if
* they ever gain in-page anchors they inherit this mechanism.)

View file

@ -1,147 +1,107 @@
import Image from 'next/image'
import Link from 'next/link'
const SUPPORT_URL = 'https://apoiar.me/jorgesolla'
import { PUBLIC_FOCUS, PUBLIC_PATHS } from '@/lib/publicSite'
const allyImages = [
{
id: 'rui',
src: '/RUI - 2-2.webp',
width: 177,
height: 265,
sizes: '(min-width: 1920px) 211px, (min-width: 1366px) calc(6.14vw + 93px), 177px',
},
{
id: 'wagner',
src: '/WAGNER - 2-9 final.webp',
width: 177,
height: 265,
sizes: '(min-width: 1920px) 211px, (min-width: 1366px) calc(6.14vw + 93px), 177px',
},
{
id: 'jeronimo',
src: '/Jeronimo.webp',
width: 177,
height: 265,
sizes: '(min-width: 1920px) 211px, (min-width: 1366px) calc(6.14vw + 93px), 177px',
},
{
id: 'lula',
src: '/Lula.webp',
width: 216,
height: 213,
sizes:
'(min-width: 1920px) 284px, (min-width: 1366px) calc(8.3vw + 124.6px), (min-width: 1024px) 238px, 216px',
},
]
/**
* S47 — the post-election hero (design scene 01/02): "Obrigado, Bahia" over the
* TSE literal 152.049. The number is the only data on the fold — no ranking,
* percentage or counter — and the single primary CTA hands the visitor to the
* mandate (`/artigos`). The campaign request ("Quero apoiar", the number, the
* running-mate portraits) is intentionally gone from this surface.
*/
const THANK_YOU_VOTE_TOTAL = '152.049'
const ArrowIcon = () => (
<svg
aria-hidden="true"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12h14" />
<path d="m13 6 6 6-6 6" />
</svg>
)
export const CampaignHero = () => (
<section
aria-labelledby="campaign-title"
data-home-section="hero"
className="campaign-hero relative overflow-hidden bg-(--pt-red) text-white"
className="campaign-hero-field relative isolate overflow-hidden text-white"
>
<div className="campaign-hero-stage relative mx-auto h-full w-full">
<div
role="img"
aria-label="Jorge Solla 1313 — candidato a deputado federal"
className="campaign-home-logo absolute z-30"
/>
<div
aria-hidden="true"
className="campaign-noise pointer-events-none absolute inset-0 opacity-[0.055]"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute -right-32 bottom-[-120px] size-[390px] rounded-full border-[56px] border-(--pt-yellow) lg:-right-20 lg:-bottom-40 lg:size-[520px] lg:border-[74px] lg:opacity-90"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute right-[430px] bottom-0 hidden h-px w-56 bg-white/25 lg:block"
/>
<div
role="img"
aria-label="Jorge Solla com Lula, Jerônimo Rodrigues, Jaques Wagner e Rui Costa"
className="absolute inset-0 z-10"
>
{allyImages.map((image) => (
<Image
key={image.src}
src={image.src}
alt=""
width={image.width}
height={image.height}
sizes={image.sizes}
loading="eager"
className={`campaign-hero-ally campaign-hero-ally--${image.id} absolute max-w-none object-contain`}
/>
))}
</div>
<div className="campaign-hero-portrait absolute z-20 overflow-hidden">
<Image
src="/JOA00162.webp"
alt="Jorge Solla, candidato a deputado federal pela Bahia"
width={495}
height={742}
sizes="(min-width: 1920px) 747px, (min-width: 1024px) calc(28.16vw + 206.7px), 449px"
priority
className="campaign-hero-portrait-image absolute h-auto max-w-none object-contain"
/>
</div>
<div
data-mobile-hero-overlay
className="campaign-hero-overlay absolute z-20 bg-[linear-gradient(to_bottom,transparent_0%,rgba(162,28,28,0.5)_38%,#a21c1c_78%)]"
/>
<div className="campaign-hero-content relative z-30">
<h1
id="campaign-title"
aria-label="MAIS SAÚDE MAIS FUTURO"
className="campaign-hero-title m-0 border-0 p-0 font-[family-name:var(--font-exo2)] leading-[1.12] tracking-[-0.02em] text-balance uppercase"
>
<span className="block font-medium">Mais saúde</span>
<span className="block font-black">Mais futuro</span>
</h1>
<p className="campaign-hero-copy m-0 text-left font-sans leading-[1.2] uppercase">
Médico sanitarista.{' '}
<strong className="font-extrabold text-(--pt-yellow)">Criou o SAMU 192</strong> e o{' '}
<strong className="font-extrabold">Brasil Sorridente</strong>. Já comandou a saúde em
Vitória da Conquista, na Bahia e no Brasil — e leva o mandato aos 417 municípios, da saúde
à educação.
<div className="relative mx-auto grid min-h-[760px] w-full max-w-[1152px] grid-cols-1 px-5 pt-10 pb-16 lg:min-h-[668px] lg:grid-cols-[1.08fr_.92fr] lg:px-8 lg:pt-0 lg:pb-0">
<div className="relative z-10 flex flex-col justify-start pt-0 lg:justify-center lg:pt-2 lg:pb-16">
<p className="m-0 font-[family-name:var(--font-exo2)] text-[13px] font-extrabold tracking-[0.13em] text-(--pt-yellow) uppercase lg:text-[15px] lg:tracking-[0.14em]">
Obrigado, Bahia
</p>
<div className="campaign-hero-actions flex items-stretch justify-start">
<a
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"
<h1
id="campaign-title"
aria-label="152.049 votos de confiança"
className="mt-4 max-w-[690px] border-0 p-0 text-left font-[family-name:var(--font-exo2)] text-[55px] leading-[0.92] font-black tracking-[-0.06em] text-balance lg:mt-5 lg:text-[76px] lg:leading-[0.91]"
>
<span className="block tabular-nums">{THANK_YOU_VOTE_TOTAL}</span>
<span className="mt-1 block text-[36px] leading-[1.02] tracking-[-0.045em] lg:text-[57px] lg:leading-[1.03]">
votos de confiança
</span>
</h1>
<p className="mt-5 max-w-[590px] text-[17px] leading-[1.45] text-white/87 lg:mt-7 lg:text-[19px] lg:leading-[1.5]">
Obrigado a quem votou, a quem caminhou, a quem compartilhou e a quem acreditou. A eleição
terminou; o trabalho continua.
</p>
<div className="mt-6 lg:mt-8">
<Link
href={PUBLIC_PATHS.artigos}
className={`inline-flex min-h-[50px] items-center gap-3 rounded-[10px] bg-(--pt-yellow) px-5 font-[family-name:var(--font-exo2)] text-[15px] font-extrabold text-[#261f00] no-underline transition-[transform,background-color] duration-200 hover:-translate-y-0.5 hover:bg-[#fff02e] active:translate-y-px motion-reduce:transition-none motion-reduce:hover:translate-y-0 lg:min-h-12 ${PUBLIC_FOCUS}`}
>
Receba
<small className="campaign-hero-action-detail text-[12px] leading-none font-semibold">
novidades
</small>
</a>
<a
href={SUPPORT_URL}
aria-label="Quero apoiar — é rápido, 30 segundos"
target="_blank"
rel="noopener"
data-cta="primary"
className="campaign-action-feedback campaign-hero-action campaign-hero-action--primary inline-flex min-h-11 flex-col items-center justify-center rounded-[10px] bg-(--pt-yellow) px-2 text-center font-[family-name:var(--font-exo2)] leading-[1.05] font-black text-(--pt-yellow-ink) no-underline hover:brightness-95 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-(--pt-red) focus-visible:outline-none"
>
<span className="campaign-hero-action-short">Apoiar</span>
<span className="campaign-hero-action-long">Quero apoiar</span>
<small className="campaign-hero-action-detail text-[12px] leading-none font-semibold">
é rápido, 30 segundos
</small>
</a>
Acompanhe o mandato
<ArrowIcon />
</Link>
</div>
<div className="campaign-hero-accolades flex flex-col font-sans text-white/80">
<span aria-label="DIAP entre os 40 melhores da Câmara">
<strong className="font-extrabold text-white">DIAP</strong> entre os 40 melhores da
Câmara
</span>
<span
aria-label="Mais votado do PT-BA em 2022"
className="campaign-hero-accolade-secondary"
>
<strong className="font-extrabold text-white">Mais votado</strong> do PT-BA em 2022
</span>
<p className="mt-9 hidden text-sm font-semibold text-white/65 lg:block">
Jorge Solla · Deputado Federal · PT · Bahia
</p>
</div>
<div className="relative z-0 min-h-[360px] lg:min-h-0 lg:self-stretch">
<div className="pointer-events-none absolute right-[-18px] bottom-[-38px] h-[370px] w-[278px] lg:pointer-events-auto lg:right-0 lg:bottom-0 lg:h-[620px] lg:w-[505px] lg:overflow-hidden">
<Image
src="/JOA00162.webp"
alt="Jorge Solla sorrindo, com blazer azul e camisa do PT"
width={495}
height={742}
sizes="(min-width: 1024px) 505px, 278px"
priority
className="absolute right-0 bottom-0 h-[370px] w-auto max-w-none object-contain lg:right-[-32px] lg:bottom-[-35px] lg:h-[650px]"
/>
</div>
</div>
<p className="absolute bottom-5 left-5 z-10 text-[11px] font-semibold text-white/68 [text-shadow:0_1px_3px_rgb(0_0_0/55%)] lg:hidden">
Deputado Federal · PT · Bahia
</p>
</div>
</section>
)

View file

@ -0,0 +1,24 @@
/**
* S47 — the compact cream intro shared by the internal public pages
* (`/artigos`, `/fotos`, `/fotos/encontre`): eyebrow + h1 with the approved
* scene-05 geometry (full-width bar, 40px gutter at 1280). The page body stays
* its own owner; only the title band is shared.
*/
export const CampaignInternalPageIntro = ({
eyebrow,
title,
}: {
eyebrow: string
title: string
}) => (
<div className="border-b border-(--campaign-line) bg-(--campaign-cream)">
<div className="mx-auto w-full max-w-[1280px] px-5 py-10 sm:px-10 sm:py-12">
<p className="m-0 font-[family-name:var(--font-exo2)] text-sm font-extrabold text-(--pt-red)">
{eyebrow}
</p>
<h1 className="mt-2 border-0 p-0 text-left font-[family-name:var(--font-exo2)] text-[34px] font-black tracking-[-0.04em] lg:text-4xl">
{title}
</h1>
</div>
</div>
)

View file

@ -2,31 +2,58 @@ import Image from 'next/image'
import Link from 'next/link'
/**
* S36/C233 — the negative campaign lockup as a home link, owner of the crop
* contract shared by the public page-local headers: the official PNG ships with
* a large transparent frame (canvas 1037x595, visible ink 790x285: top 162,
* bottom 148, left 123, right 124), so the header crops the frame with a fixed
* window instead of scaling the whole canvas. The window must have the SAME
* aspect ratio as the ink (the frame does not); a mismatched window clips the
* base of "SOLLA" (production report 2026-09-25). Window = ink box at the
* /jingles reference weight: mobile 128x46 (img 167px -> ink 127.2x45.9, left
* -19.8 / top -26.1) and desktop 144x52 (img 189px -> ink 144x51.9, left -22.4
* / top -29.5). To recompute after a kit PNG change: `sharp(asset).trim()`
* gives the ink box (size + trimOffset); imgWidth = inkHeight x 1037/285 and
* the offsets are the trim offsets x imgWidth/1037. The e2e spec asserts the
* fitted ink.
* S36/C233 — the name-only campaign lockup as a home link, owner of the crop
* contract shared by the public chrome (home/internal headers and the footer):
* the official PNG ships with a large transparent frame (canvas 1037x595,
* visible ink 790x285: top 162, bottom 148, left 123, right 124), so the header
* crops the frame with a fixed window instead of scaling the whole canvas. The
* window must have the SAME aspect ratio as the ink (the frame does not); a
* mismatched window clips the base of "SOLLA" (production report 2026-09-25).
* Window = ink box at the /jingles reference weight: mobile 128x46 (img 167px
* -> ink 127.2x45.9, left -19.8 / top -26.1), desktop 144x52 for the negative
* lockup (img 189px) and 160x58 for the positive one (img 209px). To recompute
* after a kit PNG change: `sharp(asset).trim()` gives the ink box (size +
* trimOffset); imgWidth = inkHeight x 1037/285 and the offsets are the trim
* offsets x imgWidth/1037. The e2e spec asserts the fitted ink.
*
* S47 — the positive lockup is the internal-page mark; the negative one stays
* on the red home/announcement chrome and the footer. Neither reads the
* `marca-*-completa` assets, so the electoral number never reaches the surface.
*/
export const CampaignLogoLink = ({ className }: { className?: string }) => (
<Link href="/" aria-label="Início — Jorge Solla 1313" className={className}>
<span className="relative block h-[46px] w-[128px] shrink-0 overflow-hidden sm:h-[52px] sm:w-[144px]">
<Image
src="/campaign-kit/jorge-solla-negativo.png"
alt="Jorge Solla"
width={1037}
height={595}
priority
className="absolute top-[-26.1px] left-[-19.8px] h-auto w-[167px] max-w-none sm:top-[-29.5px] sm:left-[-22.4px] sm:w-[189px]"
/>
</span>
</Link>
)
type CampaignLogoLinkProps = {
className?: string
tone?: 'negative' | 'positive'
priority?: boolean
}
const NEGATIVE_SRC = '/campaign-kit/jorge-solla-negativo.png'
const POSITIVE_SRC = '/campaign-kit/jorge-solla-positivo.png'
export const CampaignLogoLink = ({
className,
tone = 'negative',
priority = true,
}: CampaignLogoLinkProps) => {
const isPositive = tone === 'positive'
const windowClassName = isPositive
? 'h-[46px] w-[128px] sm:h-[58px] sm:w-[160px]'
: 'h-[46px] w-[128px] sm:h-[52px] sm:w-[144px]'
const imageClassName = isPositive
? 'absolute top-[-26.1px] left-[-19.8px] h-auto w-[167px] max-w-none sm:top-[-32.6px] sm:left-[-24.8px] sm:w-[209px]'
: 'absolute top-[-26.1px] left-[-19.8px] h-auto w-[167px] max-w-none sm:top-[-29.5px] sm:left-[-22.4px] sm:w-[189px]'
return (
<Link href="/" aria-label="Início — Jorge Solla" className={className}>
<span className={`relative block shrink-0 overflow-hidden ${windowClassName}`}>
<Image
src={isPositive ? POSITIVE_SRC : NEGATIVE_SRC}
alt="Jorge Solla — Deputado Federal"
width={1037}
height={595}
priority={priority}
className={imageClassName}
/>
</span>
</Link>
)
}

View file

@ -0,0 +1,186 @@
'use client'
import Link from 'next/link'
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import { PUBLIC_FOCUS, PUBLIC_PATHS } from '@/lib/publicSite'
import { cn } from '@/lib/utils'
type CampaignMobileMenuProps = {
variant: 'home' | 'internal'
current?: 'artigos' | 'fotos'
}
const iconClassName = 'size-[22px]'
const HamburguerIcon = () => (
<svg
aria-hidden="true"
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className={iconClassName}
>
<path d="M4 7h16M4 12h16M4 17h16" />
</svg>
)
const CloseIcon = () => (
<svg
aria-hidden="true"
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className={iconClassName}
>
<path d="m6 6 12 12M18 6 6 18" />
</svg>
)
/**
* S47 — the public mobile menu (design scene 01/02/05): a real disclosure, not
* a modal. The trigger toggles the panel, closes on X, link, outside click and
* Escape, moves focus to the first link when it opens and returns it to the
* trigger when it closes. Desktop keeps the inline nav, so this island mounts
* only below `lg`.
*/
export const CampaignMobileMenu = ({ variant, current }: CampaignMobileMenuProps) => {
const [open, setOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const panelId = useId()
const isHome = variant === 'home'
const menuLinks = [
{ label: 'Artigos', href: PUBLIC_PATHS.artigos, key: 'artigos' as const },
{ label: 'Bandeiras', href: isHome ? '#bandeiras' : '/#bandeiras' },
{ label: 'Fotos', href: PUBLIC_PATHS.fotos, key: 'fotos' as const },
{ label: 'Início', href: PUBLIC_PATHS.home },
]
const close = useCallback((moveFocus = true) => {
setOpen(false)
if (moveFocus) triggerRef.current?.focus()
}, [])
useEffect(() => {
if (!open) return
const firstLink = panelRef.current?.querySelector<HTMLElement>('[data-menu-link]')
firstLink?.focus()
const handlePointerDown = (event: PointerEvent) => {
if (containerRef.current?.contains(event.target as Node)) return
close(false)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
close()
}
}
document.addEventListener('pointerdown', handlePointerDown)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('pointerdown', handlePointerDown)
document.removeEventListener('keydown', handleKeyDown)
}
}, [open, close])
const triggerClassName = cn(
'flex size-11 items-center justify-center rounded-[8px]',
isHome
? 'border border-white/30 text-white'
: 'border border-(--campaign-line) text-(--pt-red)',
PUBLIC_FOCUS,
'lg:hidden',
)
const panelClassName = cn(
'absolute inset-x-0 top-full z-50 border-b px-5 py-5 shadow-[0_18px_36px_rgb(71_19_14/18%)]',
isHome
? 'border-(--campaign-line) bg-(--campaign-cream) text-[#211b19]'
: 'border-(--campaign-line) bg-white text-[#211b19]',
'lg:hidden',
)
const linkClassName = cn(
'flex min-h-12 items-center rounded-sm border-b border-(--campaign-line) font-[family-name:var(--font-exo2)] text-base font-bold',
PUBLIC_FOCUS,
)
return (
<div ref={containerRef} className="lg:hidden">
<button
ref={triggerRef}
type="button"
aria-label={open ? 'Fechar menu' : 'Abrir menu'}
aria-expanded={open}
aria-controls={panelId}
onClick={() => (open ? close() : setOpen(true))}
className={triggerClassName}
>
{open ? <CloseIcon /> : <HamburguerIcon />}
</button>
<div ref={panelRef} id={panelId} hidden={!open} className={panelClassName}>
<div className="flex items-center justify-between border-b border-(--campaign-line) pb-3">
<p className="m-0 font-[family-name:var(--font-exo2)] text-sm font-extrabold text-(--pt-red)">
Menu
</p>
<button
type="button"
aria-label="Fechar painel"
onClick={() => close()}
className={cn(
'flex size-11 items-center justify-center rounded-[8px] border border-(--campaign-line) text-(--pt-red)',
PUBLIC_FOCUS,
)}
>
<CloseIcon />
</button>
</div>
<nav aria-label="Navegação móvel" className="mt-2 grid">
{menuLinks.map((link) => {
const isCurrent = 'key' in link && link.key === current
const className = cn(
linkClassName,
isCurrent && 'text-(--pt-red)',
link.label === 'Início' && 'border-b-0',
)
return link.href.startsWith('/') ? (
<Link
key={link.label}
data-menu-link
href={link.href}
aria-current={isCurrent ? 'page' : undefined}
onClick={() => close()}
className={className}
>
{link.label}
</Link>
) : (
<a
key={link.label}
data-menu-link
href={link.href}
onClick={() => close()}
className={className}
>
{link.label}
</a>
)
})}
</nav>
</div>
</div>
)
}

View file

@ -1,198 +0,0 @@
'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 { 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
}
/**
* 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 fires `Lead`).
*/
const CampaignNewsletterForm = ({ pixelId, 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)
// S10 — exactly one `Lead` per successful capture, never on page load
// (petition precedent). The inner guard keeps a tracking hiccup (e.g.
// `crypto.randomUUID` on a non-secure context) from turning an
// already-committed capture into the error UI; `trackMetaLead`
// no-ops safely without fbq.
if (pixelId) {
try {
trackMetaLead(pixelId, NEWSLETTER_LEAD_CONTENT_NAME, crypto.randomUUID())
} catch {
// tracking is invisible to the visitor
}
}
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 = ({ pixelId }: { pixelId?: string }) => {
const [submitted, setSubmitted] = useState(false)
return submitted ? (
<CampaignNewsletterSuccess />
) : (
<CampaignNewsletterForm pixelId={pixelId} onSubmit={() => setSubmitted(true)} />
)
}

View file

@ -1,36 +1,124 @@
import Image from 'next/image'
import Link from 'next/link'
import type { ReactNode } from 'react'
import { CampaignLogoLink } from '@/components/CampaignLogoLink'
import { CampaignMobileMenu } from '@/components/CampaignMobileMenu'
import { PUBLIC_FOCUS, PUBLIC_PATHS } from '@/lib/publicSite'
import { cn } from '@/lib/utils'
/**
* S21/S29 — the campaign-site bar of the public pages that live outside the
* home chrome (`/jingles`, the share-link announcement): the full positive logo
* linking home plus an optional status badge. Extracted from the jingles header
* so both pages share one chrome; the badge is desktop-only by design (mobile
* keeps the logo alone).
* S21/S29 — the public-site bar. S47 split it into the two approved variants:
*
* - `home`: the red hero field with the negative lockup and the white nav
* (`Mandato · Bandeiras · Fotos`), sitting directly above the thank-you hero;
* - `internal` (default): the cream bar with the positive lockup and the red
* nav (`Artigos · Fotos · Início`) used by `/artigos`, `/fotos`,
* `/fotos/encontre`, `/potencial` and the share-link announcement.
*
* The badge stays desktop-only by design (the announcement page's "Ao vivo"
* state). The mobile disclosure is its own client island; the header itself
* stays a server component so the lockup is rendered on the server.
*/
export const CampaignPageHeader = ({ badge }: { badge?: ReactNode }) => (
<header className="border-b border-black/10 bg-(--campaign-cream)">
<div className="mx-auto flex max-w-6xl items-center justify-between px-5 py-3 sm:h-[92px] sm:px-8 sm:py-0">
<Link
href="/"
aria-label="Início — Jorge Solla 1313"
className="rounded-md focus-visible:shadow-[0_0_0_2px_var(--pt-red-dark)] focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-yellow)"
type CampaignPageHeaderProps = {
variant?: 'home' | 'internal'
current?: 'artigos' | 'fotos'
badge?: ReactNode
}
const NAV_LINK = cn('rounded-sm text-sm font-bold', PUBLIC_FOCUS)
export const CampaignPageHeader = ({
variant = 'internal',
current,
badge,
}: CampaignPageHeaderProps) => {
const isHome = variant === 'home'
const navLinkClassName = (key?: 'artigos' | 'fotos') =>
cn(
NAV_LINK,
key !== undefined && key === current
? 'text-(--pt-red) underline decoration-(--pt-red) decoration-2 underline-offset-8'
: 'text-(--campaign-ink) hover:text-(--pt-red)',
)
return (
<header
className={cn(
'relative z-20 border-b',
isHome
? 'campaign-hero-field border-white/15 text-white'
: 'border-(--campaign-line) bg-(--campaign-cream)',
)}
>
<div
className={cn(
isHome
? 'mx-auto flex max-w-[1152px] items-center justify-between gap-4 px-5'
: 'mx-auto flex max-w-[1280px] items-center justify-between gap-4 px-5',
isHome ? 'h-[74px] lg:h-[88px] lg:px-8' : 'h-[74px] lg:h-[92px] lg:px-10',
)}
>
<Image
src="/campaign-kit/marca-positiva-completa.png"
alt="Jorge Solla 1313 — Mais Saúde, Mais Futuro"
width={1037}
height={595}
priority
className="h-[66px] w-auto object-contain sm:h-[74px]"
<CampaignLogoLink
tone={isHome ? 'negative' : 'positive'}
className={cn('shrink-0', PUBLIC_FOCUS)}
/>
</Link>
{badge ? (
<span className="hidden rounded-full bg-(--pt-red) px-4 py-2 font-[family-name:var(--font-exo2)] text-xs font-black tracking-[0.08em] text-white uppercase sm:inline-block">
{badge}
</span>
) : null}
</div>
</header>
)
<div className="flex items-center gap-4">
{badge ? (
<span className="hidden rounded-full bg-(--pt-red) px-4 py-2 font-[family-name:var(--font-exo2)] text-xs font-black tracking-[0.08em] text-white uppercase lg:inline-block">
{badge}
</span>
) : null}
<nav aria-label="Navegação principal" className="hidden items-center gap-8 lg:flex">
{isHome ? (
<>
<Link
href={PUBLIC_PATHS.artigos}
className={cn(
NAV_LINK,
'text-white underline decoration-(--pt-yellow) decoration-2 underline-offset-8',
)}
>
Mandato
</Link>
<a href="#bandeiras" className={cn(NAV_LINK, 'text-white/80 hover:text-white')}>
Bandeiras
</a>
<Link
href={PUBLIC_PATHS.fotos}
className={cn(NAV_LINK, 'text-white/80 hover:text-white')}
>
Fotos
</Link>
</>
) : (
<>
<Link
href={PUBLIC_PATHS.artigos}
aria-current={current === 'artigos' ? 'page' : undefined}
className={navLinkClassName('artigos')}
>
Artigos
</Link>
<Link
href={PUBLIC_PATHS.fotos}
aria-current={current === 'fotos' ? 'page' : undefined}
className={navLinkClassName('fotos')}
>
Fotos
</Link>
<Link href={PUBLIC_PATHS.home} className={navLinkClassName()}>
Início
</Link>
</>
)}
</nav>
<CampaignMobileMenu variant={variant} current={current} />
</div>
</div>
</header>
)
}

View file

@ -0,0 +1,125 @@
import Link from 'next/link'
import {
buildArchivePhotoAlbumHref,
type ArchivePhotoPublicItem,
} from '@/lib/archivePhotoPublicCatalog'
import { PUBLIC_FOCUS, PUBLIC_PATHS } from '@/lib/publicSite'
import { cn } from '@/lib/utils'
const PHOTO_LINK = cn('group block overflow-hidden rounded-[9px] lg:rounded-[10px]', PUBLIC_FOCUS)
const PHOTO_IMAGE =
'photo-tone h-full w-full object-cover transition-transform duration-500 ease-out group-hover:scale-[1.025] motion-reduce:transition-none motion-reduce:group-hover:scale-100'
const AlbumPhoto = ({ item, className }: { item: ArchivePhotoPublicItem; className: string }) => (
<Link href={buildArchivePhotoAlbumHref({ foto: item.id })} className={cn(PHOTO_LINK, className)}>
{/* eslint-disable-next-line @next/next/no-img-element -- private proxy path with on-demand headers */}
<img
src={item.thumbnailPath}
alt={item.alt}
loading="lazy"
decoding="async"
className={PHOTO_IMAGE}
/>
</Link>
)
const EmptyAlbum = () => (
<div className="mt-7 flex min-h-[290px] flex-col items-center justify-center rounded-[10px] border border-white/15 px-8 text-center lg:mt-10">
<svg
aria-hidden="true"
width="36"
height="36"
viewBox="0 0 24 24"
fill="none"
stroke="var(--pt-yellow)"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21" />
</svg>
<h3 className="mt-5 font-[family-name:var(--font-exo2)] text-2xl font-extrabold">
O álbum está sendo preparado
</h3>
<p className="mt-3 max-w-sm text-sm leading-6 text-white/68">
Novos registros do trabalho na Bahia serão publicados em breve.
</p>
</div>
)
/**
* S47 — "Perto da Bahia" (design scene 01/02): two approved album photos plus
* the red quote tile, all linking into the album's overlay (`?foto=<id>`). The
* section reads the same approved list as the album, so it can never show a
* photo the album would not; with no approved photo it renders the approved
* empty state instead of disappearing.
*/
export const CampaignPhotoSection = ({ items }: { items: ArchivePhotoPublicItem[] }) => {
const [first, second] = items
return (
<section
aria-labelledby="photos-title"
data-home-section="photos"
className="bg-[#24100e] text-white"
>
<div className="mx-auto w-full max-w-[1280px] px-5 py-16 sm:px-8 lg:px-16 lg:py-24">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="m-0 font-[family-name:var(--font-exo2)] text-sm font-extrabold text-(--pt-yellow)">
Perto da Bahia
</p>
<h2
id="photos-title"
className="mt-2 border-0 p-0 font-[family-name:var(--font-exo2)] text-[34px] leading-none font-black tracking-[-0.04em] lg:mt-3 lg:text-[46px] lg:tracking-[-0.045em]"
>
O mandato em fotos
</h2>
</div>
<Link
href={PUBLIC_PATHS.fotos}
className={cn(
'text-sm font-bold text-white underline decoration-white underline-offset-[5px] hover:text-white/85',
PUBLIC_FOCUS,
)}
>
Ver o álbum
</Link>
</div>
{first ? (
<div className="mt-7 grid grid-cols-2 gap-2 lg:mt-10 lg:h-[530px] lg:grid-cols-12 lg:grid-rows-2 lg:gap-3">
<AlbumPhoto
item={first}
className="col-span-2 h-[300px] lg:col-span-7 lg:row-span-2 lg:h-auto"
/>
{second ? (
<AlbumPhoto item={second} className="h-[170px] lg:col-span-5 lg:h-auto" />
) : null}
<div
className={cn(
'flex items-end rounded-[9px] bg-(--pt-red) p-5 lg:rounded-[10px] lg:p-8',
second
? 'h-[170px] lg:col-span-5 lg:h-auto'
: 'col-span-2 h-[170px] lg:col-span-5 lg:row-span-2 lg:h-auto',
)}
>
<p className="max-w-[255px] font-[family-name:var(--font-exo2)] text-[19px] leading-[1.13] font-extrabold lg:text-[27px] lg:leading-[1.12]">
Trabalho presente, memória viva.
</p>
<span aria-hidden="true" className="ml-auto text-(--pt-yellow)">
↗
</span>
</div>
</div>
) : (
<EmptyAlbum />
)}
</div>
</section>
)
}

View file

@ -1,44 +0,0 @@
import Image from 'next/image'
export type CampaignProblemCardItem = {
id: string
title: string
body: string
image?: string
imageAlt?: string
imageFrame?: {
width: number
height: number
className: string
}
}
export const CampaignProblemCard = ({ item }: { item: CampaignProblemCardItem }) => (
<article className="relative h-full overflow-hidden rounded-[10px] bg-[#2a0b08] font-[family-name:var(--font-arimo)] text-white">
{item.image && item.imageFrame ? (
<Image
src={item.image}
alt={item.imageAlt ?? ''}
width={item.imageFrame.width}
height={item.imageFrame.height}
sizes="(max-width: 393px) 137vw, 485px"
className={item.imageFrame.className}
/>
) : item.image ? (
<Image
src={item.image}
alt={item.imageAlt ?? ''}
fill
sizes="355px"
className="object-cover object-center"
/>
) : null}
<div className="absolute inset-0 bg-[linear-gradient(to_top,#2a0b08_0%,rgba(42,11,8,0.96)_23%,rgba(42,11,8,0.68)_34%,transparent_62%)]" />
<div className="absolute right-3.5 bottom-3.5 left-3.5">
<h3 className="m-0 border-0 p-0 text-[15px] leading-tight font-bold tracking-normal">
{item.title}
</h3>
<p className="m-0 mt-1 text-[12px] leading-[1.16] text-white/90">{item.body}</p>
</div>
</article>
)

View file

@ -2,12 +2,15 @@
import { useRef } from 'react'
import { PUBLIC_FOCUS } from '@/lib/publicSite'
/**
* S8 — campaign home "Nossa história" section: the story video of Jorge
* Solla's trajectory embedded from the campaign's YouTube channel (the video
* lives there as a Short; every embed play counts as a channel view).
* S8/S47 — the "Nossa história" section of the mandate home: the story video
* of Jorge Solla's trajectory embedded from the official YouTube channel (the
* video lives there as a Short; every embed play counts as a channel view).
* `youtube-nocookie` keeps the embed LGPD-friendly; `loading="lazy"` defers
* the fetch until the frame nears the viewport.
* the fetch until the frame nears the viewport. S47 brought the editorial copy
* and the two-column composition of the approved design.
*/
export const CampaignStorySection = () => {
const frameRef = useRef<HTMLDivElement>(null)
@ -22,44 +25,42 @@ export const CampaignStorySection = () => {
data-home-section="story"
className="border-y border-(--campaign-line) bg-white"
>
<div className="mx-auto w-full max-w-[1160px] px-5 py-12 sm:px-8 lg:px-10 lg:py-16">
<div className="flex flex-col items-start gap-8 lg:flex-row lg:items-center lg:justify-between lg:gap-12">
<div className="max-w-md">
<p className="campaign-section-eyebrow m-0 font-black tracking-[0.1em] text-(--pt-red) uppercase">
Nossa história
</p>
<h2
id="story-title"
className="campaign-section-title m-0 mt-1 border-0 p-0 font-black tracking-[-0.02em] text-balance"
>
Conheça a trajetória de Jorge Solla
</h2>
<p className="campaign-section-copy m-0 mt-2 text-(--campaign-muted)">
A história de quem já fez saúde pública na Bahia e no Brasil, em um minuto e meio: da
gestão no SUS ao mandato na Câmara.
</p>
<button
type="button"
onClick={handleCtaPlay}
className="mt-6 text-sm font-bold text-(--pt-red) underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none"
>
Assistir à história
</button>
</div>
<div
ref={frameRef}
className="relative aspect-[9/16] w-full max-w-[420px] shrink-0 self-center overflow-hidden rounded-2xl bg-(--campaign-band) lg:max-w-[300px] lg:self-auto"
<div className="mx-auto grid w-full max-w-[1280px] items-center gap-10 px-5 py-16 sm:px-8 lg:grid-cols-[0.83fr_1.17fr] lg:gap-20 lg:px-16 lg:py-24">
<div>
<p className="m-0 font-[family-name:var(--font-exo2)] text-sm font-extrabold text-(--pt-red)">
Nossa história
</p>
<h2
id="story-title"
className="mt-3 border-0 p-0 font-[family-name:var(--font-exo2)] text-[34px] leading-[1.03] font-black tracking-[-0.04em] text-balance lg:text-[46px] lg:leading-[1.02] lg:tracking-[-0.045em]"
>
<iframe
src="https://www.youtube-nocookie.com/embed/i_fbclWWC5o?playsinline=1&rel=0"
title="Vídeo: Conheça a trajetória de Jorge Solla"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; compute-pressure"
allowFullScreen
className="absolute inset-0 h-full w-full border-0"
/>
</div>
Saúde pública como escolha de vida
</h2>
<p className="mt-4 text-[15px] leading-6 text-(--campaign-muted) lg:mt-6 lg:text-[17px] lg:leading-7">
Da gestão do SUS ao Congresso Nacional, uma trajetória feita ao lado de quem defende
saúde, democracia e direitos para todos.
</p>
<button
type="button"
onClick={handleCtaPlay}
className={`mt-5 rounded-sm text-sm font-bold text-(--pt-red) underline decoration-(--pt-red) underline-offset-[5px] hover:text-(--pt-red-dark) lg:mt-7 ${PUBLIC_FOCUS}`}
>
Assistir à trajetória
</button>
</div>
<div
ref={frameRef}
className="relative aspect-[9/16] w-full max-w-[360px] justify-self-center overflow-hidden rounded-xl bg-(--pt-red-dark) lg:max-w-[380px] lg:justify-self-end"
>
<iframe
src="https://www.youtube-nocookie.com/embed/i_fbclWWC5o?playsinline=1&rel=0"
title="Vídeo: Saúde pública como escolha de vida — a trajetória de Jorge Solla"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; compute-pressure"
allowFullScreen
className="absolute inset-0 h-full w-full border-0"
/>
</div>
</div>
</section>

View file

@ -34,12 +34,11 @@ type ContentShareButtonProps = {
/**
* Discreet per-card WhatsApp share control for the home content section (S4):
* a small round button in the card corner opening a two-item popover — share
* on WhatsApp with a pre-configured message (`wa.me` of the sender's own
* WhatsApp, new tab with `noopener`) or copy the absolute link. It lives as a
* sibling of the card anchor (never inside it — interactive inside an anchor
* is invalid HTML), and the Popover portals its content so the mobile
* carousel's `overflow-x-auto` track never clips it.
* a small round button opening a two-item popover — share on WhatsApp with a
* pre-configured message (`wa.me` of the sender's own WhatsApp, new tab with
* `noopener`) or copy the absolute link. It lives as a sibling of the card
* anchor (never inside it — interactive inside an anchor is invalid HTML), and
* the Popover portals its content so no overflow track clips it.
*/
export const ContentShareButton = ({ kind, title, href }: ContentShareButtonProps) => {
const [open, setOpen] = useState(false)

View file

@ -68,7 +68,7 @@ export const PetitionForm = ({ id, petition, consentHTML, facebookPixelId }: Pet
})
if (facebookPixelId) {
// #766 — tracking must never fail an already-committed signature
// (same inner guard as CampaignNewsletterForm S10).
// (same inner guard as the public campaign forms).
try {
trackMetaLead(facebookPixelId, petition.title, crypto.randomUUID())
} catch {

View file

@ -8,8 +8,8 @@ import React, { useEffect, useState } from 'react'
* opens (an iframe in the edit view, or the popup variant). The package fires
* `router.refresh()` as soon as it mounts to pick up the saved data, and the
* App Router answers a refresh by rewriting the canonical URL — which drops
* any `#hash` applied between the click and the refresh (the hero CTA →
* `#novidades` deploy-verify race). A standalone public load must not pay that
* any `#hash` applied between the click and the refresh (the section nav →
* `#bandeiras` deploy-verify race). A standalone public load must not pay that
* refresh, so the bridge mounts only when there is a preview parent
* (`window.parent` for the iframe, `window.opener` for the popup); the render
* stays `null` on every other page view.

File diff suppressed because it is too large Load diff

View file

@ -1,147 +0,0 @@
'use client'
import { useRef, useState } from 'react'
/** While a dot click animates the track, the scroll listener must not fight it. */
const PROGRAMMATIC_SCROLL_LOCK_MS = 700
import { CardModelTile } from '@/components/cards/CardModelTile'
import { CARD_MODELS, type CardModel, type CardModelId } from '@/lib/cardModels'
type CardModelGalleryProps = {
ariaLabel: string
fontFamily: string
selectedId?: CardModelId | null
onSelect: (id: CardModelId) => void
}
const tileClassName =
'group flex h-full w-full flex-col rounded-xl text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:ring-offset-(--campaign-cream)'
/**
* S14/S15/S30/S31 — the six models side by side on desktop (a single row) and a
* one-item-with-peek snap track on mobile, as buttons for the studio island:
* selecting a tile opens the single composer in place (home or `/cards`). Both
* tracks come from the same catalog (S13); the mobile track carries the six
* page dots of the design gate (scene 2), following the scroll position.
*/
export const CardModelGallery = ({
ariaLabel,
fontFamily,
selectedId = null,
onSelect,
}: CardModelGalleryProps) => {
const trackRef = useRef<HTMLUListElement>(null)
const isProgrammaticScrollRef = useRef(false)
const [activeIndex, setActiveIndex] = useState(0)
const syncActiveItem = () => {
const track = trackRef.current
if (!track || isProgrammaticScrollRef.current) return
const items = Array.from(track.querySelectorAll<HTMLLIElement>('li'))
const center = track.scrollLeft + track.clientWidth / 2
let nearestIndex = activeIndex
let nearestDistance = Number.POSITIVE_INFINITY
items.forEach((item, index) => {
const distance = Math.abs(item.offsetLeft - track.offsetLeft + item.offsetWidth / 2 - center)
if (distance < nearestDistance) {
nearestDistance = distance
nearestIndex = index
}
})
if (nearestIndex !== activeIndex) setActiveIndex(nearestIndex)
}
const scrollToModel = (index: number) => {
const track = trackRef.current
const target = track?.querySelector<HTMLElement>(`li:nth-child(${index + 1})`)
if (!track || !target) return
isProgrammaticScrollRef.current = true
setActiveIndex(index)
track.scrollTo({ left: target.offsetLeft - track.offsetLeft, behavior: 'smooth' })
window.setTimeout(() => {
isProgrammaticScrollRef.current = false
}, PROGRAMMATIC_SCROLL_LOCK_MS)
}
const renderItem = (model: CardModel) => {
const selected = selectedId === model.id
return (
<button
type="button"
aria-pressed={selected}
aria-label={model.label}
data-card-model-tile={model.id}
onClick={() => onSelect(model.id)}
className={tileClassName}
>
<CardModelTile model={model} fontFamily={fontFamily} selected={selected} />
<span
className={`mt-auto block pt-3 text-center text-sm font-bold sm:text-base ${
model.badge && !selected ? 'text-(--pt-red)' : 'text-(--campaign-ink)'
}`}
>
{model.label}
</span>
</button>
)
}
return (
<div className="mt-8">
<ul
aria-label={`${ariaLabel} (desktop)`}
className="m-0 hidden list-none grid-cols-6 gap-5 p-0 sm:gap-6 lg:grid lg:gap-4"
>
{CARD_MODELS.map((model) => (
<li key={model.id} className="m-0">
{renderItem(model)}
</li>
))}
</ul>
<ul
ref={trackRef}
aria-label={`${ariaLabel} (mobile)`}
onScroll={syncActiveItem}
className="scrollbar-hide m-0 flex list-none snap-x snap-mandatory gap-4 overflow-x-auto scroll-px-5 p-0 pb-2 lg:hidden"
>
{CARD_MODELS.map((model) => (
<li key={model.id} className="m-0 flex w-[78%] shrink-0 snap-start sm:w-[58%]">
{renderItem(model)}
</li>
))}
</ul>
<p className="mt-1 text-center text-xs font-semibold text-(--campaign-muted) lg:hidden">
Deslize para ver os seis modelos
</p>
{/* The visual dot of the gate sits inside a 24px target (WCAG 2.5.8 AA). */}
<div className="mt-1 flex items-center justify-center lg:hidden">
{CARD_MODELS.map((model, index) => (
<button
key={model.id}
type="button"
aria-label={`Ir para o modelo ${index + 1} de ${CARD_MODELS.length}`}
aria-current={index === activeIndex ? 'true' : undefined}
onClick={() => scrollToModel(index)}
className="inline-flex size-6 items-center justify-center rounded-full focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none"
>
<span
aria-hidden="true"
className={`block rounded-full transition-colors ${
index === activeIndex ? 'h-1.5 w-6 bg-(--pt-red)' : 'size-1.5 bg-(--campaign-muted)'
}`}
/>
</button>
))}
</div>
</div>
)
}

View file

@ -1,98 +0,0 @@
import { CheckIcon, UserRoundIcon } from 'lucide-react'
import Image from 'next/image'
import { CardNameTileCanvas } from '@/components/cards/CardNameTileCanvas'
import type { CardModel } from '@/lib/cardModels'
import { cn } from '@/lib/utils'
/**
* S13/S15/S31/S34 — artwork preview of one card model, shared by the home
* section and the `/cards` gallery. The name tile reuses the empty master with a
* `SEU NOME` preview drawn by the real composer pipeline (S14); the photo tiles
* show the official transparent overlay above a neutral photo slot; the team
* tiles show `previewSrc` (the filled example) and the colinha tile shows the
* approved art itself (`assetSrc`, S34), with the `NOVO` badge.
*
* The box ratio comes from an in-flow spacer, never from `aspect-ratio` — see
* the comment on the spacer for the WebKit bug that collapsed the tiles.
*/
export const CardModelTile = ({
model,
fontFamily,
selected = false,
}: {
model: CardModel
fontFamily: string
selected?: boolean
}) => {
const ratio = model.height / model.width
return (
<span
className={cn(
'relative block overflow-hidden rounded-xl border bg-(--campaign-band) transition duration-200',
selected
? 'border-(--pt-red) ring-2 ring-(--pt-red)'
: model.badge
? // S30 — the gate highlights the newest model even unselected.
'border-2 border-(--pt-red) shadow-lg group-hover:-translate-y-0.5'
: 'border-(--campaign-line) group-hover:-translate-y-0.5 group-hover:shadow-lg',
)}
>
{/*
The tile height comes from this in-flow ratio spacer — a percentage
padding resolved against the tile width — and NOT from `aspect-ratio`:
WebKit loses the aspect-ratio height of a flex item on relayout
(https://bugs.webkit.org/show_bug.cgi?id=265243, fixed only in WebKit
main 2026-05), which collapsed the tiles to their labels on Orion/Safari.
The spacer is engine-proof and keeps the master's exact ratio.
*/}
<span
aria-hidden="true"
data-card-tile-ratio={ratio}
className="block w-full"
style={{ paddingBottom: `${(ratio * 100).toFixed(4)}%` }}
/>
{model.kind === 'photo' ? (
<>
<span
aria-hidden="true"
className="absolute inset-0 bg-[linear-gradient(160deg,#ded8d2,#f7f3ef)]"
/>
{model.photoWindow ? (
<span
aria-hidden="true"
className="absolute inset-x-0 top-0 flex items-center justify-center"
style={{
height: `${((model.photoWindow.y + model.photoWindow.height) / model.height) * 100}%`,
}}
>
<UserRoundIcon className="h-[30%] w-auto text-(--campaign-muted) opacity-45" />
</span>
) : null}
</>
) : null}
<Image
src={model.previewSrc ?? model.assetSrc}
alt=""
fill
sizes="(min-width: 1024px) 167px, 78vw"
className="object-cover"
/>
{model.kind === 'name' ? <CardNameTileCanvas model={model} fontFamily={fontFamily} /> : null}
{selected ? (
<span className="absolute top-3 right-3 inline-flex size-7 items-center justify-center rounded-full bg-(--pt-red) text-white shadow-md">
<CheckIcon className="size-4" aria-hidden="true" />
</span>
) : model.badge ? (
<span className="absolute top-3 right-3 rounded-full bg-white/95 px-2.5 py-1 text-[10px] font-black text-(--team-blue) shadow">
{model.badge}
</span>
) : null}
</span>
)
}

View file

@ -1,58 +0,0 @@
'use client'
import { useEffect, useRef } from 'react'
import { ensureCardFont } from '@/components/cards/cardCanvas'
import { CARD_NAME_TILE_PLACEHOLDER } from '@/components/cards/cardCopy'
import type { CardModel } from '@/lib/cardModels'
import { fitCardName } from '@/lib/cardNameFit'
import { createCardMeasure, drawCardName } from '@/lib/cardRender'
/**
* S14 — the name-model tile preview: a transparent canvas over the base image
* that draws `SEU NOME` with the exact composer pipeline (same fit, same slot,
* same left border), so the tile cannot drift from the generated card. Draws
* only after the real font loads — measuring with the fallback would lie.
*/
export const CardNameTileCanvas = ({
model,
fontFamily,
}: {
model: CardModel
fontFamily: string
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
let cancelled = false
const draw = async () => {
const fontReady = await ensureCardFont(fontFamily)
if (!fontReady || cancelled) return
const ctx = canvasRef.current?.getContext('2d')
if (!ctx) return
const fit = fitCardName(CARD_NAME_TILE_PLACEHOLDER, createCardMeasure(ctx, fontFamily))
if (!fit.ok) return
ctx.clearRect(0, 0, model.width, model.height)
drawCardName(ctx, { fit, fontFamily })
}
void draw()
return () => {
cancelled = true
}
}, [model.width, model.height, fontFamily])
return (
<canvas
ref={canvasRef}
width={model.width}
height={model.height}
aria-hidden="true"
className="absolute inset-0 h-full w-full"
/>
)
}

View file

@ -1,26 +0,0 @@
import type { HTMLAttributes, RefObject } from 'react'
/**
* S13 — the canvas element of the card studio. Presentational only: the
* composer owns the ref, the draw effect and the pointer/keyboard controls.
*/
export const CardPreviewCanvas = ({
model,
canvasRef,
className,
...props
}: {
model: { width: number; height: number; label: string }
canvasRef: RefObject<HTMLCanvasElement | null>
className?: string
} & HTMLAttributes<HTMLCanvasElement>) => (
<canvas
ref={canvasRef}
width={model.width}
height={model.height}
role="img"
aria-label={`Prévia do card: ${model.label}`}
className={className}
{...props}
/>
)

View file

@ -1,102 +0,0 @@
'use client'
import { useState } from 'react'
import { CardComposer } from '@/components/cards/CardComposer'
import { CardModelGallery } from '@/components/cards/CardModelGallery'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Drawer, DrawerContent } from '@/components/ui/Drawer'
import { useIsMobileMeasured } from '@/hooks/use-mobile'
import { getCardModel, type CardModelId } from '@/lib/cardModels'
/**
* S13 — the card studio island: owns which model is selected and whether the
* composer is open, renders the catalog and mounts the single editor in a
* centered dialog (desktop) or a bottom drawer (mobile). Closing returns to the
* catalog with the selection kept; the URL is only read, never rewritten.
*
* S14 — the home section renders this same island (no second editor): tiles are
* buttons everywhere and opening the composer never navigates.
*/
export const CardsStudio = ({
initialModelId,
fontFamily,
}: {
initialModelId?: CardModelId
fontFamily: string
}) => {
const initialModel = initialModelId ? getCardModel(initialModelId) : undefined
const [selectedId, setSelectedId] = useState<CardModelId | null>(initialModel?.id ?? null)
const [open, setOpen] = useState(Boolean(initialModel))
const { isMobile, measured } = useIsMobileMeasured()
const selectedModel = selectedId ? getCardModel(selectedId) : undefined
const handleSelect = (id: CardModelId) => {
setSelectedId(id)
setOpen(true)
}
// Radix restores focus to the element that opened the dialog; send it back to
// the catalog tile of the selected model (visible list only) so home and
// `/cards` share the same focus return.
const focusSelectedTile = () => {
if (!selectedId) return
const tiles = document.querySelectorAll<HTMLElement>(`[data-card-model-tile="${selectedId}"]`)
const visibleTile = Array.from(tiles).find((tile) => tile.getClientRects().length > 0)
visibleTile?.focus()
}
return (
<div>
<CardModelGallery
ariaLabel="Modelos de card"
fontFamily={fontFamily}
selectedId={selectedId}
onSelect={handleSelect}
/>
{selectedModel && measured ? (
isMobile ? (
<Drawer open={open} onOpenChange={setOpen} swipeDirection="down" showSwipeHandle>
<DrawerContent
data-theme="campaign-site"
className="h-dvh max-h-dvh bg-background pt-[max(0px,env(safe-area-inset-top))] text-foreground"
>
<CardComposer
model={selectedModel}
shell="drawer"
fontFamily={fontFamily}
onClose={() => setOpen(false)}
/>
</DrawerContent>
</Drawer>
) : (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
data-theme="campaign-site"
showCloseButton={false}
onCloseAutoFocus={(event) => {
event.preventDefault()
focusSelectedTile()
}}
// `flex flex-col` (over the kit's `grid`) constrains the composer
// body so its scrollable area shrinks and the CTA footer stays
// pinned — required by the longer state-deputy selector (S30).
// S34: the filled colinha state widens the dialog for the gate's
// scene 3 two-column composition (the empty state stays compact);
// `data-colinha-filled` is set by the composer (see CardComposer).
className="flex max-h-[92dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-lg sm:p-0 has-[[data-colinha-filled]]:sm:max-w-[60rem]"
>
<CardComposer
model={selectedModel}
shell="dialog"
fontFamily={fontFamily}
onClose={() => setOpen(false)}
/>
</DialogContent>
</Dialog>
)
) : null}
</div>
)
}

View file

@ -1,219 +0,0 @@
'use client'
import { CheckIcon, ChevronDownIcon, SearchIcon } from 'lucide-react'
import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react'
import {
filterStateDeputyCards,
stateDeputyCatalog,
type StateDeputyCatalogEntry,
} from '@/lib/stateDeputyCatalog'
/**
* S30 — the state-deputy picker of the `time-do-estadual` composer, ported from
* the design gate (scene 3): a full-width trigger that shows the chosen name +
* number ("Trocar") or the placeholder, and an inline panel with search, hint,
* scrollable listbox and whole-row selection with a visible check. Keyboard:
* ↑ ↓ move the active option, Enter selects, Escape closes and returns focus to
* the trigger. The panel opens by default while nothing is chosen (the gate's
* initial state).
*
* Third inline-listbox copy in the repo (the campaign omnibox and the relation
* chip cell came first) — the Base UI `Combobox` was rejected because the gate
* is an inline panel, not a floating popup. Revisit trigger: a fourth consumer,
* or an a11y fix landing in one of the three.
*/
export const StateDeputySelect = ({
selected,
onSelect,
}: {
selected: StateDeputyCatalogEntry | null
onSelect: (card: StateDeputyCatalogEntry) => void
}) => {
const [open, setOpen] = useState(selected === null)
const [query, setQuery] = useState('')
const [activeIndex, setActiveIndex] = useState(0)
const labelId = useId()
const valueId = useId()
const searchId = useId()
const listboxId = `${searchId}-listbox`
const optionId = (index: number) => `${searchId}-option-${index}`
const triggerRef = useRef<HTMLButtonElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
/** Only a user-initiated open focuses the search (the gate's default-open panel must not steal the dialog focus). */
const focusSearchOnOpenRef = useRef(false)
const results = filterStateDeputyCards(query)
useEffect(() => {
if (open && focusSearchOnOpenRef.current) {
focusSearchOnOpenRef.current = false
searchRef.current?.focus()
}
}, [open])
useEffect(() => {
if (!open || activeIndex < 0) return
const option = listRef.current?.querySelector<HTMLElement>(
`#${CSS.escape(`${searchId}-option-${activeIndex}`)}`,
)
option?.scrollIntoView({ block: 'nearest' })
}, [activeIndex, open, searchId])
const close = (focusTrigger = false) => {
setOpen(false)
setQuery('')
setActiveIndex(0)
if (focusTrigger) triggerRef.current?.focus()
}
const choose = (card: StateDeputyCatalogEntry) => {
onSelect(card)
close(true)
}
const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
setActiveIndex((index) => Math.max(0, Math.min(index + 1, results.length - 1)))
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
setActiveIndex((index) => Math.max(index - 1, 0))
return
}
if (event.key === 'Enter') {
event.preventDefault()
const card = results[activeIndex]
if (card) choose(card)
return
}
if (event.key === 'Escape') {
event.preventDefault()
close(true)
}
}
return (
<div>
<p id={labelId} className="mt-4 block text-sm font-bold text-(--campaign-ink)">
Seu estadual
</p>
<button
ref={triggerRef}
type="button"
aria-labelledby={`${labelId} ${valueId}`}
aria-expanded={open}
aria-controls={open ? listboxId : undefined}
onClick={() => {
if (open) {
close()
return
}
focusSearchOnOpenRef.current = true
setOpen(true)
}}
className={`mt-2 flex min-h-11 w-full items-center rounded-lg border border-(--field-border) bg-white px-3 text-left text-sm focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:outline-none ${
selected ? 'font-bold text-(--campaign-ink)' : 'text-(--campaign-muted)'
}`}
>
<span id={valueId} className="min-w-0 truncate">
{selected ? selected.name : 'Escolha um nome'}
</span>
<span className="ml-auto flex shrink-0 items-center gap-1 pl-2 text-xs font-normal text-(--campaign-muted)">
{selected ? `${selected.ballotNumber} · Trocar` : null}
<ChevronDownIcon className="size-4" aria-hidden="true" />
</span>
</button>
{open ? (
<div className="mt-2 overflow-hidden rounded-xl border border-(--campaign-line) bg-white shadow-xl">
<div className="border-b border-(--campaign-line) p-3">
<div className="flex min-h-11 items-center gap-2 rounded-lg border border-(--field-border) px-3">
<SearchIcon className="size-4 shrink-0 text-(--campaign-muted)" aria-hidden="true" />
<label htmlFor={searchId} className="sr-only">
Buscar estadual
</label>
<input
ref={searchRef}
id={searchId}
type="text"
role="combobox"
aria-expanded="true"
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={results[activeIndex] ? optionId(activeIndex) : undefined}
value={query}
autoComplete="off"
placeholder="Buscar por nome"
onChange={(event) => {
setQuery(event.target.value)
setActiveIndex(0)
listRef.current?.scrollTo({ top: 0 })
}}
onKeyDown={handleSearchKeyDown}
className="min-w-0 flex-1 bg-transparent text-sm text-(--campaign-ink) outline-none placeholder:text-(--campaign-muted)"
/>
</div>
<p className="mt-2 text-[11px] text-(--campaign-muted)">
{stateDeputyCatalog.length} estaduais · use ↑ ↓ e Enter para escolher
</p>
</div>
<div
ref={listRef}
id={listboxId}
role="listbox"
aria-label="Estaduais da dobradinha"
className={`max-h-52 overflow-y-auto overscroll-contain text-sm ${
results.length === 0 ? 'p-0' : 'p-1.5'
}`}
>
{results.map((card, index) => {
const isSelected = selected?.slug === card.slug
const isActive = index === activeIndex
return (
<button
key={card.slug}
id={optionId(index)}
type="button"
role="option"
tabIndex={-1}
aria-selected={isSelected}
onClick={() => choose(card)}
onMouseEnter={() => setActiveIndex(index)}
className={`flex min-h-11 w-full items-center rounded-md px-3 text-left focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none ${
isSelected
? 'bg-red-50 font-bold text-(--pt-red)'
: isActive
? 'bg-stone-100 text-(--campaign-ink)'
: 'text-(--campaign-ink) hover:bg-stone-100'
}`}
>
<span className="min-w-0 truncate">{card.name}</span>
{isSelected ? (
<span className="ml-auto flex shrink-0 items-center gap-1 pl-2 text-xs font-normal">
{card.ballotNumber}
<CheckIcon className="size-3.5" aria-hidden="true" />
</span>
) : null}
</button>
)
})}
</div>
{results.length === 0 ? (
<p className="border-t border-(--campaign-line) px-3 py-2 text-sm text-(--campaign-muted)">
Nenhum estadual encontrado.
</p>
) : null}
</div>
) : null}
</div>
)
}

View file

@ -1,31 +1,11 @@
/**
* S13 — browser adapters for the card studio. Kept out of `src/lib` because
* they touch DOM APIs; the pure geometry stays in `cardRender`/`cardNameFit`/
* `cardPhotoTransform`. Imports of the model assets are cached so reopening a
* composer never re-downloads the master.
* S13/S47 — browser adapters kept out of `src/lib` because they touch DOM
* APIs. The public card studio is gone (S47); the remaining consumers are the
* selfie search (`loadCardPhoto`/`supportsWebgl`) and the `/potencial` story
* canvas (`canvasToPngBlob`/`downloadBlob`). The pure geometry lives in
* `cardPhotoTransform`.
*/
const CARD_IMAGE_CACHE = new Map<string, Promise<HTMLImageElement>>()
export const loadCardImage = (src: string): Promise<HTMLImageElement> => {
const cached = CARD_IMAGE_CACHE.get(src)
if (cached) return cached
const promise = new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image()
image.decoding = 'async'
image.onload = () => resolve(image)
image.onerror = () => {
CARD_IMAGE_CACHE.delete(src)
reject(new Error(`card-image-load-failed:${src}`))
}
image.src = src
})
CARD_IMAGE_CACHE.set(src, promise)
return promise
}
export const loadCardPhoto = (file: File): Promise<HTMLImageElement> =>
new Promise<HTMLImageElement>((resolve, reject) => {
const url = URL.createObjectURL(file)
@ -42,25 +22,6 @@ export const loadCardPhoto = (file: File): Promise<HTMLImageElement> =>
image.src = url
})
/**
* Canvas does not wait for CSS fonts: the family must be loaded before
* measuring. `next/font` hands a family list (primary + generated fallback),
* and `document.fonts.check` fails closed on the unloaded fallback — so only
* the primary family is awaited here.
*/
export const ensureCardFont = async (fontFamily: string): Promise<boolean> => {
if (typeof document === 'undefined' || !document.fonts) return false
const primaryFamily = fontFamily.split(',')[0]?.trim() || fontFamily
try {
const faces = await document.fonts.load(`700 100px ${primaryFamily}`)
return faces.length > 0
} catch {
return false
}
}
export const canvasToPngBlob = (canvas: HTMLCanvasElement): Promise<Blob> =>
new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
@ -77,5 +38,3 @@ export const downloadBlob = (blob: Blob, filename: string) => {
link.click()
URL.revokeObjectURL(url)
}
export const cardFileName = (modelId: string): string => `card-jorge-solla-${modelId}.png`

View file

@ -1,29 +0,0 @@
/**
* S13 — shared card-studio copy. The privacy notes are literals of the intent
* and must stay identical between the `/cards` page and the composer.
*/
export const CARD_PRIVACY_NOTE =
'Seu nome e sua foto são processados apenas no seu aparelho e não são enviados para nós.'
export const CARD_PHOTO_PRIVACY_NOTE = 'Sua foto fica neste aparelho e não é enviada para nós.'
/**
* S31 — the colinha has no typed name and no photo: the slip is composed from
* the official art plus the deputy choice, all on the device (literal of the
* design gate, scene 3).
*/
export const CARD_COLINHA_PRIVACY_NOTE =
'A escolha é processada neste aparelho. Nenhum nome, foto ou cadastro é solicitado.'
/**
* S17 — the tone-harmony control of the team model. Literals of the intent and
* the design gate; keep both identical between the plan and the composer.
*/
export const CARD_HARMONY_LABEL = 'Harmonizar cores'
export const CARD_HARMONY_HELP =
'Ajusta o brilho e as cores da sua foto para combinar com as fotos do card.'
/**
* S14 — example name of the name-model tile. Drawn by the real composer
* pipeline over the base image (same fit and left border as the generated card).
*/
export const CARD_NAME_TILE_PLACEHOLDER = 'SEU NOME'

View file

@ -1,78 +0,0 @@
/**
* S17 — browser adapter for the on-device tone harmony of the `Time de você`
* cutout. Reads the reference tone from the official master (already cached by
* `loadCardImage`) and the photo's tone through an offscreen canvas — the photo
* still never leaves the device. The pixel math is pure (`lib/cardPhotoHarmony`);
* this module only touches canvas/DOM and never mutates the cutout canvas, so
* toggling the control can always bring the original back.
*/
import { loadCardImage } from '@/components/cards/cardCanvas'
import {
applyCardPhotoHarmony,
computeCardHarmonyAdjustment,
isCardHarmonyIdentity,
measureCardPhotoTone,
type CardPhotoTone,
} from '@/lib/cardPhotoHarmony'
const REFERENCE_TONE_CACHE = new WeakMap<HTMLImageElement, CardPhotoTone>()
const measureImageTone = (image: HTMLImageElement): CardPhotoTone | null => {
const cached = REFERENCE_TONE_CACHE.get(image)
if (cached) return cached
const canvas = document.createElement('canvas')
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
const ctx = canvas.getContext('2d', { willReadFrequently: true })
if (!ctx) return null
ctx.drawImage(image, 0, 0)
const tone = measureCardPhotoTone(ctx.getImageData(0, 0, canvas.width, canvas.height).data)
REFERENCE_TONE_CACHE.set(image, tone)
return tone
}
/**
* Returns the harmonized variant of `cutout` (same size, same alpha) or `null`
* when the reference cannot be read. A photo already on the reference tone
* returns the `cutout` itself: the identity band means "nothing to adjust".
*/
export const harmonizeCardCutout = async (
cutout: HTMLCanvasElement,
referenceSrc: string,
): Promise<HTMLCanvasElement | null> => {
try {
const referenceTone = measureImageTone(await loadCardImage(referenceSrc))
if (!referenceTone) return null
const ctx = cutout.getContext('2d')
if (!ctx) return null
const source = ctx.getImageData(0, 0, cutout.width, cutout.height)
const adjustment = computeCardHarmonyAdjustment(
measureCardPhotoTone(source.data),
referenceTone,
)
if (isCardHarmonyIdentity(adjustment)) return cutout
const harmonized = applyCardPhotoHarmony(source.data, adjustment)
const canvas = document.createElement('canvas')
canvas.width = cutout.width
canvas.height = cutout.height
const outputCtx = canvas.getContext('2d')
if (!outputCtx) return null
const image = outputCtx.createImageData(cutout.width, cutout.height)
image.data.set(harmonized)
outputCtx.putImageData(image, 0, 0)
return canvas
} catch {
// Fail closed: the composer hides the control instead of showing a switch
// with no effect. The pixel math is pure and covered by unit tests.
return null
}
}

View file

@ -1,104 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { removeCardPhotoBackground } from '@/components/cards/cardCutout'
import { harmonizeCardCutout } from '@/components/cards/cardPhotoHarmonyCanvas'
import type { CardRect } from '@/lib/cardModels'
import { frameCardPhotoOnBbox, type CardPhotoTransform } from '@/lib/cardPhotoTransform'
export type CardCutoutState =
| { status: 'idle' }
| { status: 'processing'; phase: 'download' | 'process'; ratio: number }
| {
status: 'ready'
canvas: HTMLCanvasElement
/**
* S17 — tone-harmonized variant of `canvas` (same size/alpha). Equals
* `canvas` when the photo is already on tone; `null` when the reference
* could not be read (the composer then hides the control).
*/
harmonized: HTMLCanvasElement | null
width: number
height: number
/** Initial framing: cutout top on the slot top, centered (S15). */
transform: CardPhotoTransform
}
| { status: 'error'; reason: 'engine' | 'empty' | 'unsupported' }
/**
* S15 — owns the cutout lifecycle for the team model: engine import, progress,
* degenerate framing (fail-closed), retry with the same file and run-id guards
* so a stale inference or an unmount never writes state. The engine itself is a
* page-lifetime singleton in the adapter (re-creating the wasm session per open
* would cost seconds); each result's masks/bitmap are released there.
*
* S17 — the ready state also carries the harmonized variant, computed once
* before the first ready paint (default ON must not flash the raw photo).
*/
export const useCardCutout = (photoWindow: CardRect | undefined, harmonyReferenceSrc?: string) => {
const [state, setState] = useState<CardCutoutState>({ status: 'idle' })
const fileRef = useRef<File | null>(null)
const runRef = useRef(0)
useEffect(
() => () => {
runRef.current += 1
},
[],
)
const start = useCallback(
async (file: File) => {
runRef.current += 1
const run = runRef.current
fileRef.current = file
setState({ status: 'processing', phase: 'download', ratio: 0 })
const result = await removeCardPhotoBackground(file, (progress) => {
if (runRef.current !== run) return
setState({ status: 'processing', phase: progress.phase, ratio: progress.ratio })
})
if (runRef.current !== run) return
if (!result.ok) {
setState({ status: 'error', reason: result.reason })
return
}
const transform = photoWindow
? frameCardPhotoOnBbox(
{ width: result.width, height: result.height },
photoWindow,
result.bbox,
)
: null
if (!transform) {
setState({ status: 'error', reason: 'empty' })
return
}
const harmonized = harmonyReferenceSrc
? await harmonizeCardCutout(result.canvas, harmonyReferenceSrc)
: null
if (runRef.current !== run) return
setState({
status: 'ready',
canvas: result.canvas,
harmonized,
width: result.width,
height: result.height,
transform,
})
},
[photoWindow, harmonyReferenceSrc],
)
const retry = useCallback(() => {
const file = fileRef.current
if (file) void start(file)
}, [start])
return { state, start, retry }
}

View file

@ -1,111 +0,0 @@
import Image from 'next/image'
import Link from 'next/link'
import { sendCardOpeningEvent } from '@/lib/contentEvents'
import { contentPieceTypeLabels } from '@/lib/contentPiece'
import { CONTENT_PIECE_ASPECT_CLASS, type CardCatalogItem } from '@/lib/contentPieceCatalog'
import { cn } from '@/lib/utils'
import {
CONTENT_PIECE_CARD,
CONTENT_PIECE_FOCUS,
CONTENT_PIECE_OUTLINE_BUTTON,
CONTENT_PIECE_TAG,
} from './contentPieceClasses'
/**
* S38 — the art of a model item (artefato: cenas 01–03): the model's real file
* whole in the 3:4 vertical slot (S45 — the art is never cropped), or the
* neutral dashed placeholder of the photo models, whose master is only the
* frame with a transparent photo window. The `NOVO` badge of the newest model
* sits on the art.
*/
const CardItemArt = ({ item }: { item: CardCatalogItem }) => (
<span
className={cn(
'relative grid place-items-center overflow-hidden',
CONTENT_PIECE_ASPECT_CLASS.model,
item.art.kind === 'placeholder'
? 'bg-[linear-gradient(145deg,#eee9e5,#fff)]'
: 'bg-[linear-gradient(145deg,#184e92,#0876ac)]',
)}
>
{item.art.kind === 'placeholder' ? (
<span
aria-hidden="true"
className={cn(
'relative grid place-items-center border-2 border-dashed border-[#a21c1c]/40 text-3xl text-[#a21c1c]',
item.art.shape === 'square' ? 'size-20 rounded-full' : 'h-24 w-16 rounded-lg',
)}
>
◎
</span>
) : (
<Image
src={item.art.src}
alt=""
fill
sizes="(min-width: 1024px) 260px, (min-width: 640px) 45vw, 112px"
className="object-contain"
/>
)}
{item.badge ? (
<span className="absolute top-2 right-2 hidden rounded-full bg-white px-2 py-1 text-[9px] font-black text-[#184e92] sm:block">
{item.badge}
</span>
) : null}
</span>
)
/**
* S38 — one card model as an item of the board (artefato: cenas 01–05). The
* whole item is a single link to the studio with the model chosen — a bigger
* tap target on the phone and no second selection contract. It is NOT a piece:
* no download, no share, no detail page; the item is an invite to `/cards`.
* The click feeds the anonymous opening event of the model (C213/S32
* mechanism), never a piece event.
*/
export const ContentCatalogCardItem = ({ item }: { item: CardCatalogItem }) => (
<Link
href={item.href}
data-card-model={item.modelId}
onClick={() => sendCardOpeningEvent(item.modelId)}
className={cn(
CONTENT_PIECE_CARD,
'grid grid-cols-[112px_minmax(0,1fr)] text-black no-underline sm:block',
item.badge ? 'border-2 border-[#e4102f]' : null,
CONTENT_PIECE_FOCUS,
)}
>
<CardItemArt item={item} />
<div className="p-4">
<span className={CONTENT_PIECE_TAG}>
{contentPieceTypeLabels.card}
{/* Artefato (cena 02): no celular o selo vive na tag, não sobre a arte. */}
{item.badge ? <span className="sm:hidden"> · {item.badge}</span> : null}
</span>
<h3 className="mt-2 font-[family-name:var(--font-exo2)] text-sm font-extrabold sm:text-base">
{item.title}
</h3>
<p className="mt-1 text-xs text-(--campaign-muted)">{item.description}</p>
{item.aliases.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{item.aliases.map((alias) => (
<span
key={alias}
className="inline-flex rounded-full border border-black/10 bg-white px-[7px] py-[3px] text-[10px] font-bold text-(--campaign-muted)"
>
{alias}
</span>
))}
</div>
) : null}
<span className={cn(CONTENT_PIECE_OUTLINE_BUTTON, 'mt-4 hidden w-full sm:inline-flex')}>
Escolher este modelo →
</span>
<span className="mt-3 block text-xs font-bold text-(--pt-red) sm:hidden">Escolher →</span>
</div>
</Link>
)

View file

@ -1,47 +0,0 @@
import type { ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
import { cn } from '@/lib/utils'
import { CONTENT_PIECE_OUTLINE_BUTTON, CONTENT_PIECE_PRIMARY_BUTTON } from './contentPieceClasses'
/**
* S27 — the two per-piece actions, single-owned (artefato: cenas 01/04/07): the
* download is the SAME public file with the legible name (a link piece has no
* file and renders no download), and share always opens the vote sheet.
*/
export const ContentPieceDownloadLink = ({
item,
className,
}: {
item: ContentPiecePublicItem
className?: string
}) =>
item.file ? (
<a
href={`${item.file.path}?download=1`}
download={item.file.downloadFilename}
aria-label={`Baixar ${item.title}`}
className={cn(CONTENT_PIECE_OUTLINE_BUTTON, className)}
>
Baixar
</a>
) : null
export const ContentPieceShareButton = <Item extends { title: string }>({
item,
onShare,
className,
}: {
item: Item
onShare: (item: Item) => void
className?: string
}) => (
<button
type="button"
onClick={() => onShare(item)}
aria-label={`Compartilhar ${item.title}`}
className={cn(CONTENT_PIECE_PRIMARY_BUTTON, className)}
>
Compartilhar
</button>
)

View file

@ -1,191 +0,0 @@
import Link from 'next/link'
import {
CONTENT_PIECE_ASPECT_CLASS,
contentPieceBoardBucket,
contentPieceMediaKind,
type ContentPiecePublicItem,
} from '@/lib/contentPieceCatalog'
import { cn } from '@/lib/utils'
import { ContentPieceDownloadLink, ContentPieceShareButton } from './ContentPieceActions'
import { ContentPieceCardMeta, ContentPieceCardTitle } from './ContentPieceCardParts'
import { ContentPieceMedia } from './ContentPieceMedia'
import {
CONTENT_PIECE_CARD,
CONTENT_PIECE_EXACT_TAG,
CONTENT_PIECE_TAG,
} from './contentPieceClasses'
type ContentPieceCardProps = {
item: ContentPiecePublicItem
playing: boolean
onToggle: () => void
onEnded: () => void
onShare: (item: ContentPiecePublicItem) => void
/** S28 — the board is in the theme mode: the badge states the provenance. */
themeMode: boolean
}
/**
* S28 — the badge of a result. In the literal search it stays the editorial
* tag; in the theme mode it states where the result came from: "✦ Tema" when an
* expanded term surfaced the piece, "Termo exato" when only the query did
* (never an invented theme).
*/
const CardTag = ({ item, themeMode }: { item: ContentPiecePublicItem; themeMode: boolean }) => {
if (themeMode) {
return item.themeMatch ? (
<span className={CONTENT_PIECE_TAG}>✦ Tema</span>
) : (
<span className={CONTENT_PIECE_EXACT_TAG}>Termo exato</span>
)
}
return (
<span className={CONTENT_PIECE_TAG}>{item.isLink ? item.originLabel : item.typeLabel}</span>
)
}
const EVIDENCE_MARK = 'rounded-[3px] bg-[#fff09a] px-0.5 font-extrabold text-black'
/**
* S28 — "Por que apareceu" (artefato: cenas 05/06): the real passage that
* carries the expanded term, quoted and highlighted. When the match came from
* another part of the haystack the block falls back to a real passage of the
* piece — no quotes, no highlight, with the origin label — and never invents a
* match or a score.
*/
const CardThemeEvidence = ({ item }: { item: ContentPiecePublicItem }) => {
const evidence = item.themeMatch?.evidence
if (!evidence || evidence.parts.length === 0) return null
const originLabel =
evidence.source === 'description' ? 'Descrição da peça' : 'Transcrição da peça'
return (
<div className="mt-3 rounded-lg bg-[#f5f5f4] p-3">
<div className="flex items-baseline justify-between gap-3">
<b className="text-xs">Por que apareceu</b>
{evidence.quoted ? null : (
<span className="text-[11px] font-bold text-(--campaign-muted)">{originLabel}</span>
)}
</div>
<p className="mt-1 text-sm leading-6">
{evidence.quoted ? `“${evidence.truncatedStart ? '… ' : ''}` : null}
{evidence.parts.map((part, index) =>
part.highlighted ? (
<mark key={index} className={EVIDENCE_MARK}>
{part.text}
</mark>
) : (
<span key={index}>{part.text}</span>
),
)}
{evidence.quoted ? `${evidence.truncatedEnd ? ' …' : ''}”` : null}
</p>
</div>
)
}
/**
* S27/S28 — one catalogue card (artefato: cenas 01/02/05–07): video takes the
* full card with the 9:16 frame on top, audio/link take the wide row (the
* media column at the left, no framing to preserve), and a photo/text is the
* compact horizontal row. Actions work from the card itself — download never
* needs the piece page.
*/
export const ContentPieceCard = ({
item,
playing,
onToggle,
onEnded,
onShare,
themeMode,
}: ContentPieceCardProps) => {
const bucket = contentPieceBoardBucket(contentPieceMediaKind(item))
if (bucket === 'compact') {
return (
<article
data-content-piece={item.slug}
data-state={playing ? 'playing' : 'stopped'}
className={cn(CONTENT_PIECE_CARD, 'flex items-center gap-4 p-3')}
>
{/* S45 — a fixed 96×120 (4:5) box: the compact row is a flex child, and
`aspect-ratio` on a flex item is the WebKit #265243 trap; the fixed
box keeps the whole photo, never a crop. */}
<ContentPieceMedia
item={item}
playing={playing}
onToggle={onToggle}
onEnded={onEnded}
variant="thumb"
className="h-[120px] w-24 shrink-0 rounded-lg"
/>
<div className="min-w-0 flex-1">
<CardTag item={item} themeMode={themeMode} />
<ContentPieceCardTitle item={item} className="text-sm" />
<ContentPieceCardMeta item={item} />
<CardThemeEvidence item={item} />
<div className="mt-3 flex flex-wrap gap-2">
<ContentPieceDownloadLink item={item} className="min-h-11 px-3" />
<ContentPieceShareButton item={item} onShare={onShare} className="min-h-11 px-3" />
</div>
</div>
</article>
)
}
const body = (
<div className="min-w-0 p-4">
<CardTag item={item} themeMode={themeMode} />
<ContentPieceCardTitle item={item} className="text-base" />
<ContentPieceCardMeta item={item} />
<CardThemeEvidence item={item} />
<div className={cn('mt-4 grid gap-2', item.file ? 'grid-cols-2' : 'grid-cols-1')}>
<ContentPieceDownloadLink item={item} />
<ContentPieceShareButton item={item} onShare={onShare} />
</div>
<Link
href={item.publicPath}
className="mt-3 block rounded-sm text-center text-xs font-bold text-(--pt-red) underline-offset-4 hover:underline focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-yellow) focus-visible:shadow-[0_0_0_6px_rgb(24_78_146/72%)] sm:hidden"
>
Ver detalhes da peça →
</Link>
</div>
)
if (bucket === 'wide') {
return (
<article
data-content-piece={item.slug}
data-state={playing ? 'playing' : 'stopped'}
className={cn(
CONTENT_PIECE_CARD,
'grid min-h-[156px] grid-cols-[112px_minmax(0,1fr)] sm:min-h-[170px] sm:grid-cols-[190px_minmax(0,1fr)]',
)}
>
<ContentPieceMedia item={item} playing={playing} onToggle={onToggle} onEnded={onEnded} />
{body}
</article>
)
}
return (
<article
data-content-piece={item.slug}
data-state={playing ? 'playing' : 'stopped'}
className={CONTENT_PIECE_CARD}
>
<ContentPieceMedia
item={item}
playing={playing}
onToggle={onToggle}
onEnded={onEnded}
className={CONTENT_PIECE_ASPECT_CLASS.video}
/>
{body}
</article>
)
}

View file

@ -1,54 +0,0 @@
import { ChevronRight } from 'lucide-react'
import Link from 'next/link'
import type { ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
import { cn } from '@/lib/utils'
import { CONTENT_PIECE_FOCUS } from './contentPieceClasses'
/**
* S27/S39 — the title and the metadata line shared by the catalogue card and
* the home sample card. Both take the structural subset they read, so the
* leaner home projection renders through the same markup (one owner for the
* link, the focus ring and the meta contract).
*/
/** S42 — the chevron of a filtrable tag (home card and section tag). */
export const ContentPieceTagChevron = () => (
<ChevronRight aria-hidden="true" strokeWidth={2.5} className="size-3 shrink-0" />
)
type ContentPieceCardTitleItem = Pick<ContentPiecePublicItem, 'title' | 'publicPath'>
export const ContentPieceCardTitle = ({
item,
className,
}: {
item: ContentPieceCardTitleItem
className?: string
}) => (
<h3 className={cn('mt-2 font-[family-name:var(--font-exo2)] font-extrabold', className)}>
<Link
href={item.publicPath}
className={cn(
'rounded-sm text-black underline-offset-4 hover:underline',
CONTENT_PIECE_FOCUS,
)}
>
{item.title}
</Link>
</h3>
)
type ContentPieceCardMetaItem = Pick<ContentPiecePublicItem, 'metaLabel'>
export const ContentPieceCardMeta = ({
item,
className,
}: {
item: ContentPieceCardMetaItem
className?: string
}) =>
item.metaLabel ? (
<p className={cn('mt-1 text-xs text-(--campaign-muted)', className)}>{item.metaLabel}</p>
) : null

View file

@ -1,103 +0,0 @@
'use client'
import { useState } from 'react'
import {
contentPieceBoardBucket,
contentPieceMediaKind,
interleaveCatalogItems,
isCardCatalogItem,
type CardCatalogItem,
type ContentCatalogItem,
type ContentPiecePublicItem,
} from '@/lib/contentPieceCatalog'
import { ContentCatalogCardItem } from './ContentCatalogCardItem'
import { ContentPieceCard } from './ContentPieceCard'
import { ContentPieceShareSheet } from './ContentPieceShareSheet'
/**
* S27 — the catalogue board (artefato: cena 01): the vertical board (video and
* the S38 models) runs four columns from `lg`, audio/link keep a wide two-column
* grid (no framing to preserve) and photo/text keep the compact scan row. One
* piece plays at a time (switching unmounts the previous element) and the share
* sheet is owned here, so every card opens the same vote message.
*
* S38 — the six card models are items of the same board: they take the vertical
* board bucket and the whole item is one link to the studio (`/cards?model=<id>`);
* the old single invite tile is gone and no section groups them.
*/
export const ContentPieceCatalog = ({
items,
themeMode,
}: {
items: readonly ContentCatalogItem[]
/** S28 — the board is in the theme mode: the pieces state their provenance. */
themeMode: boolean
}) => {
const [playingId, setPlayingId] = useState<number | null>(null)
const [sharingItem, setSharingItem] = useState<ContentPiecePublicItem | null>(null)
const videoPieces: ContentPiecePublicItem[] = []
const widePieces: ContentPiecePublicItem[] = []
const compactItems: ContentPiecePublicItem[] = []
const cards: CardCatalogItem[] = []
for (const item of items) {
if (isCardCatalogItem(item)) {
cards.push(item)
continue
}
const bucket = contentPieceBoardBucket(contentPieceMediaKind(item))
if (bucket === 'vertical') videoPieces.push(item)
else if (bucket === 'wide') widePieces.push(item)
else compactItems.push(item)
}
// S38 — the models alternate with the video pieces (never a block of their
// own); the wide and compact rows keep the S27 board below.
const verticalItems = interleaveCatalogItems(videoPieces, cards)
const cardProps = (item: ContentPiecePublicItem) => ({
item,
playing: playingId === item.id,
onToggle: () => setPlayingId((current) => (current === item.id ? null : item.id)),
onEnded: () => setPlayingId((current) => (current === item.id ? null : current)),
onShare: setSharingItem,
themeMode,
})
return (
<>
{verticalItems.length > 0 ? (
<div className="grid items-start gap-5 sm:grid-cols-2 lg:grid-cols-4">
{verticalItems.map((item) =>
isCardCatalogItem(item) ? (
<ContentCatalogCardItem key={`card:${item.modelId}`} item={item} />
) : (
<ContentPieceCard key={item.id} {...cardProps(item)} />
),
)}
</div>
) : null}
{widePieces.length > 0 ? (
<div className="mt-6 grid items-start gap-5 lg:grid-cols-2">
{widePieces.map((item) => (
<ContentPieceCard key={item.id} {...cardProps(item)} />
))}
</div>
) : null}
{compactItems.length > 0 ? (
<div className="mt-6 grid items-start gap-5 sm:grid-cols-2">
{compactItems.map((item) => (
<ContentPieceCard key={item.id} {...cardProps(item)} />
))}
</div>
) : null}
{sharingItem ? (
<ContentPieceShareSheet item={sharingItem} onClose={() => setSharingItem(null)} />
) : null}
</>
)
}

View file

@ -1,108 +0,0 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { sendContentPieceEvent } from '@/lib/contentEvents'
import {
contentPieceAspectClass,
contentPieceMediaKind,
type ContentPiecePublicItem,
} from '@/lib/contentPieceCatalog'
import { cn } from '@/lib/utils'
import { ContentPieceDownloadLink, ContentPieceShareButton } from './ContentPieceActions'
import { ContentPieceMedia } from './ContentPieceMedia'
import { ContentPieceShareSheet } from './ContentPieceShareSheet'
import { CONTENT_PIECE_TAG } from './contentPieceClasses'
type Fact = { label: string; value: string }
const originOrDurationFact = (item: ContentPiecePublicItem): Fact | null => {
if (item.isLink) return { label: 'Origem', value: item.originLabel }
if (item.durationLabel) return { label: 'Duração', value: item.durationLabel }
return null
}
/**
* S27 — the piece page body (artefato: cenas 04/07): the asset on the left
* (play on demand, photo preview, platform placeholder for a link piece), the
* title/description/facts and the same two actions of the card. The share
* sheet is the same component, so the message is identical everywhere.
*/
export const ContentPieceDetail = ({ item }: { item: ContentPiecePublicItem }) => {
const [playing, setPlaying] = useState(false)
const [sharing, setSharing] = useState(false)
// C213 — one open is one anonymous counter. The ref keeps the dev
// StrictMode's double effect from counting twice, and a client-side return
// to the page mounts a fresh component (a real second open).
const trackedOpen = useRef(false)
useEffect(() => {
if (trackedOpen.current) return
trackedOpen.current = true
sendContentPieceEvent('abertura', item.slug)
}, [item.slug])
const facts = [
item.topicLabels.length > 0 ? { label: 'Tema', value: item.topicLabels.join(', ') } : null,
item.cityLabel || item.regionLabel
? { label: 'Local', value: item.cityLabel ?? item.regionLabel ?? '' }
: null,
originOrDurationFact(item),
item.pieceDateLabel ? { label: 'Data', value: item.pieceDateLabel } : null,
].filter((fact): fact is Fact => fact !== null)
return (
<>
{/* S45 — the piece page keeps the card's promise: the 360px media column
(artefato cena 05) and the frame of the type — Reel 9:16, photo 4:5,
wide kinds 16:9 — with the media whole, never cropped. */}
<div className="grid items-start gap-8 lg:grid-cols-[360px_minmax(0,1fr)] lg:gap-10">
<ContentPieceMedia
item={item}
playing={playing}
onToggle={() => setPlaying((current) => !current)}
onEnded={() => setPlaying(false)}
variant="detail"
className={contentPieceAspectClass(contentPieceMediaKind(item)) ?? 'aspect-video'}
/>
<div>
<span className={CONTENT_PIECE_TAG}>
{item.isLink ? item.originLabel : item.typeLabel}
</span>
<h1 className="mt-3 text-left font-[family-name:var(--font-exo2)] text-3xl leading-[1.05] font-black tracking-[-0.02em] text-balance">
{item.title}
</h1>
{item.description ? (
<p className="mt-3 text-sm leading-6 text-(--campaign-muted)">{item.description}</p>
) : null}
{facts.length > 0 ? (
<dl className="mt-5 border-y border-(--campaign-line) py-4 text-sm">
{facts.map((fact) => (
<div key={fact.label} className="flex justify-between gap-4 py-1">
<dt className="text-(--campaign-muted)">{fact.label}</dt>
<dd className="text-right font-medium">{fact.value}</dd>
</div>
))}
</dl>
) : null}
<div className={cn('mt-5 grid gap-2', item.file ? 'grid-cols-2' : 'grid-cols-1')}>
<ContentPieceDownloadLink item={item} />
<ContentPieceShareButton item={item} onShare={() => setSharing(true)} />
</div>
{item.isLink ? (
<p className="mt-3 text-center text-xs leading-5 text-(--campaign-muted)">
O compartilhamento usa o link da publicação no {item.originLabel}.
</p>
) : null}
</div>
</div>
{sharing ? <ContentPieceShareSheet item={item} onClose={() => setSharing(false)} /> : null}
</>
)
}

View file

@ -1,196 +0,0 @@
import { ChevronDown } from 'lucide-react'
import Link from 'next/link'
import {
CONTENT_PIECE_CATALOG_FACETS,
CONTENT_PIECE_CATALOG_PATH,
buildContentPieceCatalogHref,
contentPieceCatalogFacetLabels,
type ContentPieceCatalogActiveFilter,
type ContentPieceCatalogFacet,
type ContentPieceCatalogFacets,
type ContentPieceCatalogParams,
} from '@/lib/contentPieceCatalog'
import { ContentPieceSearchMode } from './ContentPieceSearchMode'
import {
CONTENT_PIECE_ACTIVE_CHIP,
CONTENT_PIECE_CHIP,
CONTENT_PIECE_FOCUS,
} from './contentPieceClasses'
const FIELD_CLASS =
'min-h-11 w-full rounded-[10px] border border-black/18 bg-white py-2.5 pr-10 pl-3 text-[15px] text-black placeholder:text-(--campaign-muted) focus-visible:border-(--pt-red) focus-visible:outline-[3px] focus-visible:outline-offset-[2px] focus-visible:outline-(--pt-red) [&::-webkit-search-cancel-button]:hidden'
const DROPDOWN_LINK =
'flex min-h-10 items-center rounded-lg px-3 text-sm font-medium text-black hover:bg-(--campaign-band) focus-visible:outline-[3px] focus-visible:outline-offset-[2px] focus-visible:outline-(--pt-red)'
/**
* S27 — the facet menu chevron (artefato: cena 03): a real icon centered by
* `inline-flex + items-center`, never the text glyph `⌄` (whose ink sits at the
* bottom of the line box and reads as a subscript). It flips on the open menu.
*/
const FACET_CHEVRON = (
<ChevronDown
aria-hidden="true"
strokeWidth={2.5}
className="size-3.5 shrink-0 transition-transform group-open:rotate-180 motion-reduce:transition-none"
/>
)
/**
* S37 — the active-filter chip is shared with the results header (design scene
* 03): on desktop the `Lideranças` chip lives next to the contextual heading,
* on mobile it stays in the facet row.
*/
export const ContentPieceActiveFilterChip = ({
filter,
className,
prefetch,
}: {
filter: ContentPieceCatalogActiveFilter
className?: string
prefetch?: boolean
}) => (
<Link
href={filter.removeHref}
prefetch={prefetch}
aria-label={`Remover filtro ${filter.label}: ${filter.value}`}
className={className ?? CONTENT_PIECE_ACTIVE_CHIP}
>
{filter.label} · {filter.value}
<span aria-hidden="true" className="text-base leading-none">
×
</span>
</Link>
)
/**
* S28 — the catalogue filters (artefato: cenas 07–09): a GET form (works
* without JS, the URL is the state) with the search term, the mode segmented
* control and one chip per facet — a `<details>` menu when empty, an active
* chip with its removal link when set. There is no submit button: Enter on the
* field searches and changing the mode submits. Facets that no published piece
* carries never render; in the theme mode the links keep `prefetch={false}` so
* a prefetched (never expanded) payload does not poison the client router
* cache.
*/
export const ContentPieceFilters = ({
params,
facets,
activeFilters,
themeUnavailable,
}: {
params: ContentPieceCatalogParams
facets: ContentPieceCatalogFacets
activeFilters: readonly ContentPieceCatalogActiveFilter[]
themeUnavailable: boolean
}) => {
const themeMode = params.mode === 'tema'
const termFilter = activeFilters.find((filter) => filter.facet === 'q')
const facetFilters = activeFilters.filter(
(filter): filter is typeof filter & { facet: ContentPieceCatalogFacet } => filter.facet !== 'q',
)
return (
<form action={CONTENT_PIECE_CATALOG_PATH} method="get">
{CONTENT_PIECE_CATALOG_FACETS.map((facet) =>
params[facet] ? (
<input key={facet} type="hidden" name={facet} value={params[facet] ?? ''} />
) : null,
)}
<div className="grid gap-3 rounded-xl bg-(--campaign-cream) p-4 sm:grid-cols-[1fr_300px]">
<div className="relative self-end">
<input
type="search"
name="q"
defaultValue={params.q}
placeholder="Encontre uma peça por assunto, cidade ou pessoa…"
aria-label="Buscar peças"
className={FIELD_CLASS}
/>
{termFilter ? (
<Link
href={termFilter.removeHref}
aria-label="Limpar busca"
className={`absolute top-1/2 right-2 grid size-8 -translate-y-1/2 place-items-center rounded-full text-lg leading-none text-(--campaign-muted) hover:text-black ${CONTENT_PIECE_FOCUS}`}
>
×
</Link>
) : null}
</div>
<ContentPieceSearchMode mode={params.mode} themeUnavailable={themeUnavailable} />
<div className="flex flex-wrap items-center gap-2 sm:col-span-2">
{facetFilters.map((filter) => (
<ContentPieceActiveFilterChip
key={filter.facet}
filter={filter}
prefetch={themeMode ? false : undefined}
// S37 — on desktop the Lideranças chip moves next to the results
// heading (design scene 03); mobile keeps it in the row.
className={
filter.facet === 'lideranca'
? `${CONTENT_PIECE_ACTIVE_CHIP} sm:hidden`
: CONTENT_PIECE_ACTIVE_CHIP
}
/>
))}
{CONTENT_PIECE_CATALOG_FACETS.map((facet) =>
params[facet] || facets[facet].length === 0 ? null : (
// S27 — one shared `name` makes the empty facet menus an
// exclusive accordion: opening a filter closes the previous one
// (native `<details>`, no JS).
<details
key={facet}
name="content-facet"
className="group relative max-sm:open:w-full"
>
<summary
className={`${CONTENT_PIECE_CHIP} cursor-pointer list-none group-open:border-[#184e92]/35 group-open:text-[#184e92] [&::-webkit-details-marker]:hidden`}
>
{contentPieceCatalogFacetLabels[facet]}
{FACET_CHEVRON}
</summary>
<ul
className={`absolute z-20 mt-2 max-h-72 list-none overflow-y-auto rounded-xl border border-(--campaign-line) bg-white p-1.5 shadow-[0_14px_34px_rgb(0_0_0/16%)] max-sm:static max-sm:mt-2 max-sm:w-full max-sm:min-w-0 max-sm:p-2 ${
facet === 'lideranca' ? 'sm:w-64' : 'min-w-48'
}`}
>
{facet === 'lideranca' ? (
// S37 (design scene 03): the facet derives from published
// pieces alone — the caption states the rule; the mobile
// panel names the facet it belongs to.
<>
<li className="px-2.5 py-1 text-[10px] font-black tracking-wide text-(--campaign-muted) uppercase sm:hidden">
Lideranças · Em peças publicadas
</li>
<li className="hidden px-2.5 py-1 text-[10px] font-black tracking-wide text-(--campaign-muted) uppercase sm:block">
Em peças publicadas
</li>
</>
) : null}
{facets[facet].map((option) => (
<li key={option.value}>
<Link
href={buildContentPieceCatalogHref({ ...params, [facet]: option.value })}
prefetch={themeMode ? false : undefined}
className={`${DROPDOWN_LINK}${
facet === 'lideranca' ? ' sm:px-3 sm:py-2' : ''
}`}
>
{option.label}
</Link>
</li>
))}
</ul>
</details>
),
)}
</div>
</div>
</form>
)
}

View file

@ -1,79 +0,0 @@
import Image from 'next/image'
/**
* S27 — the Central hero (artefato: `central-conteudos-hero-polimento-ui-design.html`
* cenas 01/02): the campaign gradient band, the "Peça voto pra Solla 1313"
* eyebrow, the 1313 mark and the pattern texture of the kit. The copy asks for
* the vote — one goal for the whole screen. The 1313 mark no longer disappears
* on mobile (it anchors the bottom-right corner there).
*/
export const ContentPieceHero = () => (
<section className="relative isolate overflow-hidden bg-[radial-gradient(circle_at_70%_5%,rgb(255_230_7/14%),transparent_25%),linear-gradient(116deg,#e4102f_0%,#c10b2b_49%,#6b2758_70%,#184e92_100%)] text-white">
<div
aria-hidden="true"
className="absolute inset-y-0 right-0 -z-10 hidden w-[48%] bg-[url('/campaign-kit/pattern-shapes.png')] bg-[length:100%_auto] bg-[position:center_5%] bg-no-repeat opacity-[0.13] sm:block"
/>
<div
aria-hidden="true"
className="absolute inset-y-0 right-0 -z-10 hidden w-[43%] bg-[linear-gradient(90deg,transparent,rgb(11_38_78/42%))] sm:block"
/>
<div
aria-hidden="true"
className="absolute -right-20 bottom-0 -z-10 h-[210px] w-[330px] bg-[url('/campaign-kit/pattern-shapes.png')] bg-[length:100%_auto] bg-[position:center_top] bg-no-repeat opacity-[0.11] sm:hidden"
/>
<div
aria-hidden="true"
className="absolute inset-x-0 bottom-0 -z-10 h-28 bg-[linear-gradient(0deg,rgb(10_43_94/72%),transparent)] sm:hidden"
/>
<div aria-hidden="true" className="absolute inset-x-0 bottom-0 h-1 bg-(--pt-yellow)" />
<div className="relative mx-auto min-h-[318px] w-full max-w-6xl px-5 py-7 sm:grid sm:min-h-[316px] sm:grid-cols-[1fr_330px] sm:items-center sm:gap-14 sm:px-8 sm:py-10">
<div>
<p className="m-0 text-xs font-black tracking-[0.1em] text-(--pt-yellow) uppercase sm:text-sm">
Peça voto pra Solla 1313
</p>
<h1 className="mt-3 max-w-2xl text-left font-[family-name:var(--font-exo2)] text-[34px] leading-[0.98] font-black tracking-[-0.025em] text-balance sm:text-[42px] sm:leading-[1.02]">
<span className="sm:hidden">Mande uma peça. Peça mais um voto.</span>
<span className="hidden sm:inline">Uma mensagem sua pode conquistar mais um voto.</span>
</h1>
<p className="mt-3 max-w-[335px] text-sm leading-5 text-white/90 sm:mt-4 sm:max-w-xl sm:text-base sm:leading-6">
<span className="sm:hidden">Escolha alguém, compartilhe e peça o voto no 1313.</span>
<span className="hidden sm:inline">
Escolha uma peça, mande para alguém que você conhece e peça o voto no 1313.
</span>
</p>
<div className="mt-6 hidden items-center gap-2 text-xs font-bold text-white/75 sm:flex">
<span aria-hidden="true" className="h-px w-8 bg-(--pt-yellow)" />
<span>Peças oficiais para baixar e compartilhar</span>
</div>
</div>
<div className="absolute right-5 bottom-5 flex items-end gap-3 sm:hidden">
<span aria-hidden="true" className="mb-2 h-px w-10 bg-white/45" />
<Image
src="/campaign-kit/numero-negativo.png"
alt="1313 — Mais Saúde, Mais Futuro"
width={1037}
height={595}
priority
className="h-auto w-[154px] drop-shadow-[0_8px_18px_rgb(7_20_51/28%)]"
/>
</div>
<div className="relative hidden h-48 items-center justify-center sm:flex">
<div
aria-hidden="true"
className="absolute inset-0 rotate-[-3deg] rounded-[28px] border border-white/12 bg-[#0e3978]/42 shadow-[0_22px_45px_rgb(9_22_56/22%)]"
/>
<Image
src="/campaign-kit/numero-negativo.png"
alt="1313 — Mais Saúde, Mais Futuro"
width={1037}
height={595}
priority
className="relative h-auto w-[272px] drop-shadow-[0_10px_22px_rgb(7_20_51/30%)]"
/>
</div>
</div>
</section>
)

View file

@ -1,316 +0,0 @@
'use client'
import Link from 'next/link'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
loadMunicipalityGeometryModule,
loadMunicipalityZoneGeometryModule,
loadTerritoryGeometryModule,
} from '@/lib/bahiaGeometries'
import { buildContentPieceCatalogHref } from '@/lib/contentPieceCatalog'
import {
resolveContentPieceHomeVisitor,
selectContentPieceHomeItems,
type ContentPieceHomeItem,
type ContentPieceHomeVisitor,
} from '@/lib/contentPieceHomeSelection'
import { slugify } from '@/lib/slug'
import { cn } from '@/lib/utils'
import {
COARSE_ACCURACY_M,
readGeolocationPermissionState,
requestCurrentPosition,
type GeolocationPermissionState,
} from '@/utilities/campaignGeolocation'
import { ContentPieceTagChevron } from './ContentPieceCardParts'
import { ContentPieceHomeCard } from './ContentPieceHomeCard'
import { ContentPieceHomeFilterRow, type ContentPieceHomeFacets } from './ContentPieceHomeFilterRow'
import { ContentPieceShareSheet } from './ContentPieceShareSheet'
import {
CONTENT_PIECE_FOCUS,
CONTENT_PIECE_HOME_TAG,
CONTENT_PIECE_LOCAL_TAG_LINK,
CONTENT_PIECE_PRIMARY_BUTTON,
} from './contentPieceClasses'
/**
* S39 — the Central sample board (artefato: cenas 01–05). The server renders
* the recent selection; this island only upgrades it in silence when the
* visitor's location is already available: permission `granted` resolves
* without a dialog on mount, anything else needs the explicit "Usar minha
* localização" affordance. Nothing is persisted, logged or sent anywhere — the
* fix is matched against the committed meshes in the browser and dies here.
*
* No auto-prompt (unlike the staff B14 card): a surprise dialog on the home
* burns trust, and the recent selection is already useful without it.
*
* S42 — the board also owns the one share sheet of the section (same S27
* mechanism as the catalogue) and the explore row that opens `/conteudos`
* already filtered; the row never filters the sample.
*/
const COPY = {
local: {
desktop: 'Comece pelo material do seu território e mande para quem você conhece.',
mobile: 'Material do seu território para compartilhar.',
},
recent: {
desktop: 'Veja as peças mais recentes e escolha uma para compartilhar.',
mobile: 'Veja as peças mais recentes.',
},
single: {
desktop: 'Uma peça oficial já está pronta para você compartilhar.',
mobile: 'Uma peça oficial já está pronta.',
},
} as const
const LOCATE_BUTTON = cn(
'inline-flex min-h-9 items-center gap-1.5 rounded-lg px-2 text-xs font-extrabold text-(--pt-red) underline underline-offset-4',
CONTENT_PIECE_FOCUS,
)
export const ContentPieceHomeBoard = ({
items,
facets,
}: {
items: readonly ContentPieceHomeItem[]
facets: ContentPieceHomeFacets
}) => {
const [permission, setPermission] = useState<GeolocationPermissionState | null>(null)
const [visitor, setVisitor] = useState<ContentPieceHomeVisitor | null>(null)
const [locating, setLocating] = useState(false)
const [attempted, setAttempted] = useState(false)
const [playingId, setPlayingId] = useState<number | null>(null)
const [sharingItem, setSharingItem] = useState<ContentPieceHomeItem | null>(null)
const autoStartedRef = useRef(false)
const locate = useCallback(async () => {
setLocating(true)
setAttempted(true)
/*
* The geometry chunks are shared with the staff map, so they are usually
* cached; the dynamic imports can still fail on a weak connection, and the
* loader memoizes the rejection — without the catch the section would keep
* the recent selection silently, which is exactly the honest fallback.
*/
const loaded = await Promise.all([
requestCurrentPosition(),
loadMunicipalityGeometryModule(),
]).catch(() => null)
if (!loaded || !loaded[0].ok) {
setLocating(false)
return
}
const [result, municipalityGeometry] = loaded
const [zoneGeometry, territoryGeometry] = await Promise.all([
loadMunicipalityZoneGeometryModule().catch(() => null),
loadTerritoryGeometryModule().catch(() => null),
])
setVisitor(
resolveContentPieceHomeVisitor({
point: result.fix,
coarse: result.fix.accuracyM > COARSE_ACCURACY_M,
municipalityGeometry,
zoneGeometry,
territoryGeometry,
}),
)
setLocating(false)
}, [])
useEffect(() => {
/*
* One automatic attempt per mount (the B14 guard): a re-render must never
* fire a second position request, and Strict Mode re-runs this effect on
* the same instance — cancelling the first run while the ref blocks the
* second would leave the board idle forever.
*/
if (autoStartedRef.current) return
autoStartedRef.current = true
const start = async () => {
const state = await readGeolocationPermissionState()
setPermission(state)
if (state === 'granted') void locate()
}
void start()
}, [locate])
const selection = selectContentPieceHomeItems(items, visitor)
const hasMunicipalityMatch = selection.some((entry) => entry.match === 'municipality')
const isSingle = selection.length === 1
const showLocateButton =
(permission === 'prompt' || permission === 'unknown') &&
visitor === null &&
selection.length > 1 &&
// The button stays mounted (disabled) while the fix is in flight, so the
// tap does not drop the keyboard focus; after the attempt it is gone.
(!attempted || locating)
const copy = isSingle ? COPY.single : hasMunicipalityMatch ? COPY.local : COPY.recent
const municipalityHref = visitor?.municipalityName
? buildContentPieceCatalogHref({ cidade: slugify(visitor.municipalityName) })
: null
return (
<>
<p aria-live="polite" className="sr-only">
{locating
? 'Localizando…'
: visitor !== null
? hasMunicipalityMatch
? 'Peças do seu município'
: 'Peças recentes'
: ''}
</p>
<div
className={cn(
'relative',
isSingle && 'lg:grid lg:grid-cols-[1fr_360px] lg:items-center lg:gap-10',
)}
>
<div
className={cn(isSingle ? 'text-center lg:text-left' : 'mx-auto max-w-2xl text-center')}
>
<p className="campaign-section-eyebrow m-0 font-black tracking-[0.1em] text-(--pt-red) uppercase">
Central de Conteúdos
</p>
<h2
id="content-pieces-title"
className="campaign-section-title campaign-content-pieces-title m-0 mt-2 border-0 p-0 font-black tracking-[-0.02em] text-balance"
>
Peça voto pra
<br className="sm:hidden" /> Solla 1313
</h2>
<p className="campaign-section-copy mx-auto mt-3 max-w-xl text-(--campaign-muted) lg:mx-0">
<span className="sm:hidden">{copy.mobile}</span>
<span className="hidden sm:inline">{copy.desktop}</span>
</p>
<div
className={cn(
'mt-3 flex flex-wrap items-center gap-2',
isSingle ? 'justify-center lg:justify-start' : 'justify-center',
)}
>
{hasMunicipalityMatch && municipalityHref ? (
// S42 — the sampled territory tag is itself a shortcut to the
// catalogue filtered by the visitor's município (artefato cena 01).
<Link
href={municipalityHref}
data-sample-tag="municipality"
className={CONTENT_PIECE_LOCAL_TAG_LINK}
>
<span aria-hidden="true">⌖</span> Para seu município
<ContentPieceTagChevron />
</Link>
) : (
<span data-sample-tag="recent" className={CONTENT_PIECE_HOME_TAG}>
Seleção recente
</span>
)}
{hasMunicipalityMatch ? (
<span className="hidden text-[11px] text-(--campaign-muted) sm:inline">
Localização usada só nesta visita
</span>
) : null}
</div>
<ContentPieceHomeFilterRow
facets={facets}
className={cn(
'mt-5 w-full',
isSingle ? 'mx-auto max-w-md lg:mx-0' : 'mx-auto max-w-xl sm:mt-6',
)}
/>
{isSingle ? (
<Link
href="/conteudos"
className={cn(CONTENT_PIECE_PRIMARY_BUTTON, 'mt-6 hidden lg:inline-flex')}
>
Ver todas as peças →
</Link>
) : null}
</div>
<div
className={cn(
'mt-6',
isSingle
? 'lg:mt-0'
: 'grid items-start gap-4 sm:grid-cols-2 sm:gap-6 lg:mt-9 lg:grid-cols-3',
)}
>
{selection.map(({ item, match }) => (
<ContentPieceHomeCard
key={item.id}
item={item}
match={match}
playing={playingId === item.id}
onToggle={() => setPlayingId((current) => (current === item.id ? null : item.id))}
onEnded={() => setPlayingId((current) => (current === item.id ? null : current))}
onShare={setSharingItem}
/>
))}
</div>
{isSingle ? (
<Link
href="/conteudos"
className={cn(CONTENT_PIECE_PRIMARY_BUTTON, 'mt-7 w-full lg:hidden')}
>
Ver todas as peças →
</Link>
) : (
<div className="mt-9 text-center">
<Link href="/conteudos" className={CONTENT_PIECE_PRIMARY_BUTTON}>
Ver todas as peças →
</Link>
</div>
)}
</div>
{/* Cena 05 — with a single published piece the section ends on the CTA. */}
{isSingle ? null : (
<div className="relative mt-6 text-center">
{showLocateButton ? (
<>
<button
type="button"
onClick={() => void locate()}
disabled={locating}
className={LOCATE_BUTTON}
>
<span aria-hidden="true">⌖</span> Usar minha localização
</button>
<p className="mt-1 text-[11px] leading-4 text-(--campaign-muted)">
<span className="sm:hidden">Opcional. Sem prompt no carregamento.</span>
<span className="hidden sm:inline">
Opcional e secundário. A seção já funciona sem isso.
</span>
</p>
</>
) : (
<p className="text-[11px] leading-4 text-(--campaign-muted)">
<span className="sm:hidden">
A localização não é guardada. Cards personalizáveis ficam na seção anterior.
</span>
<span className="hidden sm:inline">
Nenhum card personalizável entra nesta amostra.
</span>
</p>
)}
</div>
)}
{sharingItem ? (
<ContentPieceShareSheet item={sharingItem} onClose={() => setSharingItem(null)} />
) : null}
</>
)
}

View file

@ -1,130 +0,0 @@
import Link from 'next/link'
import {
buildContentPieceCatalogHref,
contentPieceAspectClass,
contentPieceMediaKind,
} from '@/lib/contentPieceCatalog'
import type { ContentPieceHomeItem, ContentPieceHomeMatch } from '@/lib/contentPieceHomeSelection'
import { slugify } from '@/lib/slug'
import { cn } from '@/lib/utils'
import { ContentPieceShareButton } from './ContentPieceActions'
import {
ContentPieceCardMeta,
ContentPieceCardTitle,
ContentPieceTagChevron,
} from './ContentPieceCardParts'
import { ContentPieceMedia } from './ContentPieceMedia'
import {
CONTENT_PIECE_CARD,
CONTENT_PIECE_HOME_LOCAL_TAG,
CONTENT_PIECE_HOME_TAG,
CONTENT_PIECE_LOCAL_TAG_LINK,
CONTENT_PIECE_PIECE_LINK,
CONTENT_PIECE_TAG_LINK,
} from './contentPieceClasses'
const MATCH_LABELS: Record<ContentPieceHomeMatch, string> = {
municipality: 'Do seu município',
region: 'Da sua região',
recent: 'Mais recente',
}
/**
* S42 — the canonical facet of a tag, or null when the tag has no honest one
* (origin and "Mais recente" stay static). The match only exists with its
* label, so the null guard is a contract, never a rendered case.
*/
const matchHref = (item: ContentPieceHomeItem, match: ContentPieceHomeMatch): string | null => {
if (match === 'municipality' && item.cityLabel) {
return buildContentPieceCatalogHref({ cidade: slugify(item.cityLabel) })
}
if (match === 'region' && item.regionLabel) {
return buildContentPieceCatalogHref({ regiao: slugify(item.regionLabel) })
}
return null
}
/**
* S39/S42 — the home sample card (artefato: cenas 01/02/05): a lighter sibling
* of the catalogue card. Horizontal on the phone (`[124px | 1fr]`, cena 02) and
* vertical from `sm` on; the asset is the shared `ContentPieceMedia`, so
* video/audio only mount the media after the tap. S42 gives it the direct
* shortcut: `Compartilhar` opens the section's own share sheet (the S27 one)
* and the honest tags open `/conteudos` already filtered.
*/
export const ContentPieceHomeCard = ({
item,
match,
playing,
onToggle,
onEnded,
onShare,
}: {
item: ContentPieceHomeItem
match: ContentPieceHomeMatch
playing: boolean
onToggle: () => void
onEnded: () => void
onShare: (item: ContentPieceHomeItem) => void
}) => {
const typeHref = item.isLink ? null : buildContentPieceCatalogHref({ tipo: item.type })
const matchFacetHref = matchHref(item, match)
// S45 — the thumb keeps the form of the type (photo 4:5, Reel 9:16); the
// wide kinds (audio/link/text) keep a short fixed column on the phone and the
// wide block from `sm` up, where the card becomes a full block.
const aspectClass = contentPieceAspectClass(contentPieceMediaKind(item))
return (
<article
data-content-piece={item.slug}
className={cn(CONTENT_PIECE_CARD, 'grid grid-cols-[124px_1fr] sm:block')}
>
<ContentPieceMedia
item={item}
playing={playing}
onToggle={onToggle}
onEnded={onEnded}
// C226 — below `sm` this card's media slot is the 124px thumb of the
// design's cena 05 (the compact play/duration, no marker); from `sm` up
// the card becomes a full block and the media slot goes back to the
// catalogue treatment.
variant="home-thumb"
className={cn('self-start', aspectClass ?? 'max-sm:h-[156px] sm:aspect-video')}
/>
<div className="min-w-0 p-4">
<div className="flex flex-wrap gap-1.5">
{typeHref ? (
<Link href={typeHref} className={CONTENT_PIECE_TAG_LINK}>
{item.typeLabel}
<ContentPieceTagChevron />
</Link>
) : (
<span className={CONTENT_PIECE_HOME_TAG}>
{item.isLink ? item.originLabel : item.typeLabel}
</span>
)}
{matchFacetHref ? (
<Link href={matchFacetHref} className={CONTENT_PIECE_LOCAL_TAG_LINK}>
<span className="truncate">{MATCH_LABELS[match]}</span>
<ContentPieceTagChevron />
</Link>
) : (
<span
className={match === 'recent' ? CONTENT_PIECE_HOME_TAG : CONTENT_PIECE_HOME_LOCAL_TAG}
>
{MATCH_LABELS[match]}
</span>
)}
</div>
<ContentPieceCardTitle item={item} className="text-sm sm:text-base" />
<ContentPieceCardMeta item={item} className="text-[11px] sm:text-xs" />
<ContentPieceShareButton item={item} onShare={onShare} className="mt-3 w-full sm:mt-4" />
<Link href={item.publicPath} className={CONTENT_PIECE_PIECE_LINK}>
Ver esta peça →
</Link>
</div>
</article>
)
}

View file

@ -1,100 +0,0 @@
import { ChevronDown } from 'lucide-react'
import Link from 'next/link'
import { isContentPieceType } from '@/lib/contentPiece'
import {
buildContentPieceCatalogHref,
contentPieceCatalogFacetLabels,
type ContentPieceCatalogFacets,
} from '@/lib/contentPieceCatalog'
import { cn } from '@/lib/utils'
import { CONTENT_PIECE_FOCUS } from './contentPieceClasses'
/**
* S42 — the facets of the home section's explore row (artefato: cenas 01/04/06).
* The server derives them from the published pieces with the catalogue owner
* (`contentPieceCatalogFacets`), so the vocabulary is the same and a facet with
* no option never renders. The row is pure navigation: no local state, nothing
* filters the sample.
*/
const HOME_FACETS = ['tipo', 'cidade', 'regiao'] as const
type ContentPieceHomeFacet = (typeof HOME_FACETS)[number]
export type ContentPieceHomeFacets = Pick<ContentPieceCatalogFacets, ContentPieceHomeFacet>
const CHIP = cn(
'flex min-h-10 w-full cursor-pointer list-none items-center justify-between gap-1.5 rounded-full border border-(--campaign-line) bg-white px-3 py-[7px] text-xs font-extrabold text-[#171000]',
'transition-[border-color,background-color,color] duration-150 ease-out hover:border-[rgb(24_78_146/35%)] hover:bg-[#eef4fb] hover:text-[#184e92] group-open:border-[rgb(24_78_146/35%)] group-open:bg-[#eef4fb] group-open:text-[#184e92] motion-reduce:transition-none [&::-webkit-details-marker]:hidden',
CONTENT_PIECE_FOCUS,
)
const MENU =
'absolute top-[calc(100%+8px)] left-0 z-20 max-h-72 w-[min(220px,calc(100vw-72px))] list-none overflow-y-auto rounded-xl border border-(--campaign-line) bg-white p-1.5 shadow-[0_16px_40px_rgb(39_25_22/16%)]'
const OPTION = cn(
'flex min-h-10 items-center justify-between gap-2 rounded-lg px-2.5 py-2 text-xs font-bold text-[#171000] no-underline hover:bg-[#eef4fb] hover:text-[#184e92]',
CONTENT_PIECE_FOCUS,
)
/** The canonical href of one option — each facet maps to its own URL parameter. */
const optionHref = (facet: ContentPieceHomeFacet, value: string): string => {
if (facet === 'tipo') {
return buildContentPieceCatalogHref({ tipo: isContentPieceType(value) ? value : null })
}
if (facet === 'cidade') return buildContentPieceCatalogHref({ cidade: value })
return buildContentPieceCatalogHref({ regiao: value })
}
export const ContentPieceHomeFilterRow = ({
facets,
className,
}: {
facets: ContentPieceHomeFacets
className?: string
}) => {
const available = HOME_FACETS.filter((facet) => facets[facet].length > 0)
if (available.length === 0) return null
return (
<div className={className}>
<p className="mb-2 text-[11px] font-bold text-(--campaign-muted)">
<span className="sm:hidden">Explore na Central · abre já filtrada</span>
<span className="hidden sm:inline">Explore na Central · abre o catálogo já filtrado</span>
</p>
<div className="grid grid-cols-3 gap-2">
{available.map((facet, index) => (
<details key={facet} name="content-piece-home-facet" className="group relative min-w-0">
<summary className={CHIP}>
<span className="truncate">{contentPieceCatalogFacetLabels[facet]}</span>
<ChevronDown
aria-hidden="true"
strokeWidth={2.5}
className="size-3.5 shrink-0 transition-transform group-open:rotate-180 motion-reduce:transition-none"
/>
</summary>
<ul
className={cn(
MENU,
// Artefato: the middle chip centers its menu, the last one
// anchors right so no menu bleeds past the section.
index === 1 && available.length > 2 && 'left-1/2 -translate-x-1/2',
index > 0 && index === available.length - 1 && 'right-0 left-auto',
)}
>
{facets[facet].map((option) => (
<li key={option.value}>
<Link href={optionHref(facet, option.value)} className={OPTION}>
<span className="truncate">{option.label}</span>
<span aria-hidden="true">→</span>
</Link>
</li>
))}
</ul>
</details>
))}
</div>
</div>
)
}

View file

@ -1,47 +0,0 @@
import { contentPieceCatalogFacets, type ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
import { toContentPieceHomeItem } from '@/lib/contentPieceHomeSelection'
import { ContentPieceHomeBoard } from './ContentPieceHomeBoard'
/**
* S39 — the home section advertising the Central de Conteúdos (artefato: cenas
* 01–06). The page resolves the cached listing once and only renders this when
* something is published (fail-closed, same kill switch as the footer link);
* the lean projection keeps the search haystack out of the static HTML.
*
* S42 — the explore row's facets are derived here, before the projection, from
* the same catalogue owner the catalogue page uses (the lean item keeps only
* what the card renders).
*/
export const ContentPieceHomeSection = ({
items,
}: {
items: readonly ContentPiecePublicItem[]
}) => {
const facets = contentPieceCatalogFacets(items)
return (
<section
aria-labelledby="content-pieces-title"
data-home-section="content-pieces"
className="relative overflow-hidden border-b border-(--campaign-line) bg-white"
>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-24 -right-20 size-64 rounded-full bg-(--pt-yellow)/35"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -bottom-28 -left-24 size-64 rounded-full bg-[#e4102f]/8"
/>
<div className="relative mx-auto w-full max-w-[1160px] px-5 py-10 sm:px-8 lg:px-10 lg:py-16">
<ContentPieceHomeBoard
items={items.map(toContentPieceHomeItem)}
// Only the row's three facets cross to the client island; the other
// option lists (tema/instituição/liderança) stay on the server.
facets={{ tipo: facets.tipo, cidade: facets.cidade, regiao: facets.regiao }}
/>
</div>
</section>
)
}

View file

@ -1,333 +0,0 @@
import { contentPieceMediaKind, type ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
import { cn } from '@/lib/utils'
import { useCallback, useState } from 'react'
/**
* The media states read this subset of the public item, so the S39 home
* projection (leaner, no search haystack) renders through the same component.
*/
type ContentPieceMediaItem = Pick<
ContentPiecePublicItem,
| 'isLink'
| 'file'
| 'framePath'
| 'type'
| 'origin'
| 'originLabel'
| 'title'
| 'excerpt'
| 'durationLabel'
| 'typeLabel'
>
/**
* S27 — the piece asset (artefato: cenas 01/02/03/07): a photo previews, a
* text shows its opening, a video/audio only mounts the media element after
* the voter taps play (`preload="none"`, nothing is fetched before the
* gesture) and a link piece shows where it lives. The owner of the play state
* is the host (catalogue keeps one at a time; the piece page keeps its own).
*/
const PlayIcon = () => (
<svg aria-hidden="true" viewBox="0 0 24 24" className="size-6 fill-current stroke-current">
<path d="m8 5 11 7-11 7z" />
</svg>
)
const PlayButton = ({
label,
onToggle,
compact = false,
}: {
label: string
onToggle: () => void
/** C226 — the 124px home thumb (artefato cena 05) has room for a 44px play only. */
compact?: boolean
}) => (
<button
type="button"
onClick={onToggle}
aria-label={label}
className={cn(
'absolute grid place-items-center rounded-full bg-(--pt-yellow) text-(--pt-yellow-ink) shadow-[0_6px_0_#cfb900,0_10px_22px_rgb(0_0_0/15%)] transition-[transform,box-shadow] duration-150 ease-out hover:-translate-y-0.5 active:translate-y-[3px] active:shadow-[0_3px_0_#cfb900] motion-reduce:transition-none',
// C243 — public light-surface focus: the yellow signal plus the dark blue
// outer separator, so the ring separates from the yellow button itself.
'focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-yellow) focus-visible:shadow-[0_0_0_6px_rgb(24_78_146/72%)]',
// S45 — the approved play (artefato cena 07): 56px on the card, 44px on
// the compact thumb (44 → 56 from `sm`, where that card becomes a block).
compact ? 'size-11 sm:size-14' : 'size-14',
)}
>
<PlayIcon />
</button>
)
const DurationBadge = ({ label, compact = false }: { label: string | null; compact?: boolean }) =>
label ? (
<span
className={cn(
'absolute rounded bg-black/70 text-white',
compact
? 'right-1.5 bottom-1.5 px-1.5 py-0.5 text-[10px] sm:right-3 sm:bottom-3 sm:px-2 sm:py-1 sm:text-xs'
: 'right-3 bottom-3 px-2 py-1 text-xs',
)}
>
{label}
</span>
) : null
/**
* C226 — the neutral marker of a piece with no still (artefato cena 04). The
* piece type keeps reading on the card body tag, so nothing is lost.
*/
/**
* C226 — the honest marker of a piece with no still (artefato cenas 04/05). The
* `compact` form is the 124px home thumb, where the neutral surface itself is
* the message and the marker would crowd the play; from `sm` up, where that
* card becomes a full block, the complete marker returns (cena 05).
*/
const FrameUnavailableMarker = ({
detail = false,
compact = false,
}: {
detail?: boolean
compact?: boolean
}) => (
<span
className={cn(
'absolute top-3 left-3 z-10 items-center gap-2 text-[10px] font-black tracking-[0.1em] text-(--campaign-muted) uppercase',
compact ? 'hidden sm:flex' : 'flex',
)}
>
<span
aria-hidden="true"
className={cn(
'grid place-items-center rounded-full border border-black/10 bg-white',
detail ? 'size-7' : 'size-6',
)}
>
×
</span>
Frame indisponível
</span>
)
export const ContentPieceMedia = ({
item,
playing,
onToggle,
onEnded,
variant = 'card',
className,
}: {
item: ContentPieceMediaItem
playing: boolean
onToggle: () => void
onEnded: () => void
variant?: 'card' | 'thumb' | 'detail' | 'home-thumb'
className?: string
}) => {
const kind = contentPieceMediaKind(item)
const base = cn('relative overflow-hidden bg-(--campaign-band)', className)
// C226 — the still lives in the slot the play state shares, so the knowledge
// that this piece has no frame is held by the host, not by the layer that
// remounts on every play: `failed` is terminal for the card, never retried (a
// broken image would be a lie and the retry would hammer the route).
const [frameState, setFrameState] = useState<'loading' | 'ready' | 'failed'>('loading')
// A still whose load settled BEFORE React attached the handlers (a warm
// cache, an SSR page, a 404) fires NEITHER `load` nor `error` again — the
// ref is the only place that can still tell a decoded image from a failed
// one, and without it the fastest paths are exactly the broken ones.
const settleLoadedFrame = useCallback((node: HTMLImageElement | null) => {
if (!node?.complete) return
setFrameState(node.naturalWidth > 0 ? 'ready' : 'failed')
}, [])
if (kind === null) {
return (
<div
data-content-piece-media=""
className={cn(
base,
'grid place-items-center bg-[linear-gradient(145deg,#fff4f5,#eef4fb)]',
variant === 'detail' && 'rounded-2xl border border-[#184e92]/15',
)}
>
<span
aria-hidden="true"
className={cn(
'absolute -right-10 -bottom-16 size-48 rounded-full border-[28px] border-[#e4102f]/10',
item.origin === 'youtube' &&
'right-auto bottom-auto -top-16 -left-10 border-[#184e92]/10',
)}
/>
<div className="relative px-3 text-center">
<p className="text-[10px] font-black tracking-[0.18em] text-(--campaign-muted) uppercase">
Peça no {item.originLabel}
</p>
<p
className={cn(
'mt-2 font-[family-name:var(--font-exo2)] font-black',
item.origin === 'instagram' ? 'text-[#e4102f]' : 'text-[#184e92]',
// S45 — the wide row of the catalogue and the home thumb carry a
// narrow media column on the phone; from `sm` the full scale returns.
variant === 'detail' ? 'text-4xl' : 'text-base sm:text-2xl',
)}
>
{item.originLabel}
</p>
<p className="mt-1 text-xs text-(--campaign-muted)">
{variant === 'detail'
? 'A mídia permanece na plataforma original.'
: 'Conteúdo hospedado na plataforma'}
</p>
</div>
</div>
)
}
if (kind === 'image') {
return (
<div
data-content-piece-media=""
className={cn(base, variant === 'detail' && 'rounded-2xl border border-black/10')}
>
{/* eslint-disable-next-line @next/next/no-img-element -- private proxy path with on-demand headers */}
<img
src={item.file?.path}
alt={item.title}
loading="lazy"
decoding="async"
className="h-full w-full object-contain"
/>
</div>
)
}
if (kind === 'text') {
return (
<div
data-content-piece-media=""
className={cn(
base,
'grid place-items-center bg-(--campaign-cream) px-3 text-center',
variant === 'detail' && 'rounded-2xl border border-black/10',
)}
>
<p
className={cn(
'font-[family-name:var(--font-exo2)] font-black text-(--pt-red-dark)',
variant === 'detail' ? 'text-xl leading-8 sm:text-2xl' : 'text-sm leading-5',
)}
>
“{item.excerpt ?? item.title}”
</p>
</div>
)
}
if (kind === 'video' || kind === 'audio') {
if (playing) {
return (
<div
data-content-piece-media=""
className={cn(
base,
'bg-[#180a09]',
variant === 'detail' && 'rounded-2xl border border-black/10',
)}
>
{kind === 'video' ? (
<video
controls
autoPlay
playsInline
preload="none"
src={item.file?.path}
onEnded={onEnded}
className="h-full w-full object-contain"
/>
) : (
<div className="grid h-full w-full place-items-center p-4">
<audio
controls
autoPlay
preload="none"
src={item.file?.path}
onEnded={onEnded}
className="w-full"
/>
</div>
)}
</div>
)
}
return (
<div
data-content-piece-media=""
className={cn(
base,
'grid place-items-center bg-[linear-gradient(135deg,#e5e2df,#f7f5f3)]',
variant === 'detail' && 'rounded-2xl border border-black/10',
)}
>
{kind === 'video' && item.framePath && frameState !== 'failed' && (
// eslint-disable-next-line @next/next/no-img-element -- private proxy path with on-demand headers
<img
src={item.framePath}
// Decorative on purpose: the play button already names the slot
// ("Reproduzir <título>") and the title sits right below it.
alt=""
loading="lazy"
decoding="async"
ref={settleLoadedFrame}
onLoad={() => setFrameState('ready')}
onError={() => setFrameState('failed')}
// Invisible until it decodes: a failed image would otherwise paint
// the browser's broken-image glyph over the slot for a moment, and
// the design's honest state is the neutral surface, never a glyph.
className={cn(
'absolute inset-0 h-full w-full object-contain transition-opacity duration-200 motion-reduce:transition-none',
frameState !== 'ready' && 'opacity-0',
)}
/>
)}
{/* A piece whose projection declares no frame, and one whose still the
route could not produce: the same honest slot, no broken image. */}
{kind === 'video' && (!item.framePath || frameState === 'failed') && (
<FrameUnavailableMarker
detail={variant === 'detail'}
compact={variant === 'home-thumb'}
/>
)}
{kind === 'audio' && (
<span className="absolute top-3 left-3 text-[10px] font-black tracking-[0.14em] text-(--campaign-muted) uppercase">
{item.typeLabel}
</span>
)}
<PlayButton
label={`Reproduzir ${item.title}`}
onToggle={onToggle}
compact={variant === 'home-thumb'}
/>
<DurationBadge label={item.durationLabel} compact={variant === 'home-thumb'} />
</div>
)
}
return (
<div
data-content-piece-media=""
className={cn(
base,
'grid place-items-center bg-[linear-gradient(135deg,#e5e2df,#f7f5f3)] px-3 text-center',
variant === 'detail' && 'rounded-2xl border border-black/10',
)}
>
<p className="text-xs font-bold text-(--campaign-muted)">
{item.file ? 'Baixe a peça para ver o arquivo.' : 'Sem arquivo para pré-visualizar.'}
</p>
</div>
)
}

View file

@ -1,29 +0,0 @@
import Link from 'next/link'
import { CampaignLogoLink } from '@/components/CampaignLogoLink'
import { CONTENT_PIECE_FOCUS } from './contentPieceClasses'
/**
* S27 — the Central de Conteúdos bar (artefato: cenas 01/04/06/07/08): the
* negative logo over the campaign red and, on a piece page, the way back to the
* catalogue. Page-local, like the jingles header — not a site shell. The lockup
* crop contract lives in `CampaignLogoLink`, shared with the C233 album header.
*/
export const ContentPiecePageHeader = ({ backHref }: { backHref?: string }) => (
<header className="bg-[#ae1603]">
<div className="mx-auto flex h-14 w-full max-w-6xl items-center justify-between gap-3 px-5 sm:h-16 sm:px-8">
<CampaignLogoLink className={CONTENT_PIECE_FOCUS} />
{backHref ? (
<Link
href={backHref}
className={`rounded-sm text-sm font-bold text-white underline-offset-4 hover:underline ${CONTENT_PIECE_FOCUS}`}
>
Voltar à Central
</Link>
) : (
<span className="text-sm font-bold text-white">Central de Conteúdos</span>
)}
</div>
</header>
)

View file

@ -1,82 +0,0 @@
'use client'
import type { ChangeEvent } from 'react'
import type { ContentPieceCatalogMode } from '@/lib/contentPieceCatalog'
import { cn } from '@/lib/utils'
const MODE_OPTION = cn(
'relative flex min-h-[42px] items-center justify-center rounded-[7px] px-2.5 text-[13px] font-extrabold text-[#3f3937] transition-colors',
'has-[:checked]:bg-white has-[:checked]:text-[#184e92] has-[:checked]:shadow-[0_1px_4px_rgb(0_0_0/12%)]',
'has-[:focus-visible]:outline-[3px] has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-(--pt-red)',
'motion-reduce:transition-none',
)
const MODE_OPTION_ENABLED = 'cursor-pointer hover:bg-white/58 hover:text-[#184e92]'
const MODE_OPTION_DISABLED = 'cursor-not-allowed text-[#8b6c1a] opacity-[0.64]'
/**
* The radio itself covers the whole option (invisible, on top): the entire
* segment is the hit target and the native keyboard group keeps working.
*/
const MODE_INPUT = 'absolute inset-0 size-full opacity-0'
const MODE_INPUT_ENABLED = 'cursor-pointer'
const MODE_INPUT_DISABLED = 'cursor-not-allowed'
/**
* S28 — the search mode of the Central (artefato: cenas 07–09): a single radio
* group inside the GET form, "Termo exato" as the default. With JS, changing
* the mode submits the form (one tap); without JS, Enter on the search field
* submits it with the checked option. When the mechanism is unavailable the
* theme option is disabled with the ⚠ mark — the notice above explains — and
* the exact option stays active.
*/
export const ContentPieceSearchMode = ({
mode,
themeUnavailable,
}: {
mode: ContentPieceCatalogMode | null
themeUnavailable: boolean
}) => {
const themeChecked = mode === 'tema' && !themeUnavailable
const submitOnChange = (event: ChangeEvent<HTMLInputElement>) => {
event.currentTarget.form?.requestSubmit()
}
return (
<fieldset className="min-w-0">
<legend className="mb-1 text-xs font-bold text-(--campaign-muted)">Buscar por:</legend>
<div className="grid grid-cols-2 rounded-[10px] bg-[#ebe9e9] p-1">
<label className={cn(MODE_OPTION, MODE_OPTION_ENABLED)}>
<input
type="radio"
name="mode"
value="exato"
defaultChecked={!themeChecked}
onChange={submitOnChange}
className={cn(MODE_INPUT, MODE_INPUT_ENABLED)}
/>
<span>Termo exato</span>
</label>
<label
className={cn(MODE_OPTION, themeUnavailable ? MODE_OPTION_DISABLED : MODE_OPTION_ENABLED)}
>
<input
type="radio"
name="mode"
value="tema"
defaultChecked={themeChecked}
disabled={themeUnavailable}
onChange={submitOnChange}
className={cn(MODE_INPUT, themeUnavailable ? MODE_INPUT_DISABLED : MODE_INPUT_ENABLED)}
/>
<span>{themeUnavailable ? '⚠ Por tema' : '✦ Por tema'}</span>
</label>
</div>
</fieldset>
)
}

View file

@ -1,204 +0,0 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { CopyLinkButton } from '@/components/CopyLinkButton'
import { WhatsAppIcon } from '@/components/socialIcons'
import { sendContentPieceEvent } from '@/lib/contentEvents'
import type { ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
import {
buildContentPieceWhatsAppUrl,
contentPieceShareLink,
contentPieceVoteMessage,
} from '@/lib/contentPieceShare'
import { cn } from '@/lib/utils'
import {
CONTENT_PIECE_FOCUS,
CONTENT_PIECE_OUTLINE_BUTTON,
CONTENT_PIECE_PRIMARY_BUTTON,
} from './contentPieceClasses'
/**
* S42 — what the sheet actually reads: the catalogue passes the full public
* item and the home section passes its lean projection (the same structural
* precedent as `ContentPieceMedia`), so the one share mechanism serves both
* surfaces without the home carrying the search haystack.
*/
type ContentPieceShareSheetItem = Pick<
ContentPiecePublicItem,
'slug' | 'title' | 'type' | 'file' | 'isLink' | 'sourceUrl'
>
/**
* S27 — the share sheet (artefato: cenas 03/08): the vote message prefilled and
* EDITABLE, then `wa.me` of the sender's own WhatsApp — no recipient, nothing
* sent by the system. "A mídia" opens the native share sheet with the file
* where the device supports it and downloads the same file where it does not
* (never pretending the file was attached). The sheet portals to the body so
* no card overflow can clip it.
*/
export const ContentPieceShareSheet = ({
item,
onClose,
}: {
item: ContentPieceShareSheetItem
onClose: () => void
}) => {
const [mounted, setMounted] = useState(false)
const [preparing, setPreparing] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const shareLink = contentPieceShareLink(item, window.location.origin)
const [message, setMessage] = useState(() =>
contentPieceVoteMessage(item.type, item.title, shareLink),
)
useEffect(() => {
setMounted(true)
// Focus moves into the dialog on open and back to the trigger on close.
const previousFocus = document.activeElement as HTMLElement | null
textareaRef.current?.focus()
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
previousFocus?.focus?.()
}
}, [onClose])
const shareMedia = async () => {
const mediaFile = item.file
if (!mediaFile || preparing) return
setPreparing(true)
const path = mediaFile.path
try {
const response = await fetch(path)
if (!response.ok) throw new Error('fetch failed')
const blob = await response.blob()
const file = new File([blob], mediaFile.downloadFilename, {
type: blob.type || 'application/octet-stream',
})
if (typeof navigator.canShare === 'function' && navigator.canShare({ files: [file] })) {
try {
await navigator.share({ files: [file], title: item.title })
return
} catch (error) {
// A cancelled share sheet is not a failure: never download instead.
if (error instanceof DOMException && error.name === 'AbortError') return
}
}
} catch {
// The honest fallback below is the download of the same file.
} finally {
setPreparing(false)
}
const anchor = document.createElement('a')
anchor.href = `${path}?download=1`
anchor.download = mediaFile.downloadFilename
anchor.click()
}
if (!mounted) return null
return createPortal(
// The portal lands outside the `campaign-site` layout, so the sheet carries
// its own theme: without it every campaign token resolves to nothing (the
// default `:root` foreground is near-white) and the panel fades away.
<div data-theme="campaign-site" className="fixed inset-0 z-50 text-(--campaign-ink)">
<button
type="button"
tabIndex={-1}
aria-label="Fechar compartilhamento"
onClick={onClose}
className="absolute inset-0 h-full w-full cursor-default bg-black/12"
/>
<aside
role="dialog"
aria-modal="true"
aria-label="Compartilhar peça"
className={cn(
'absolute inset-x-0 bottom-0 rounded-t-2xl border border-black/12 bg-white p-4 shadow-[0_-12px_35px_rgb(0_0_0/12%)]',
'sm:inset-x-auto sm:top-1/2 sm:bottom-auto sm:left-1/2 sm:w-[430px] sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-2xl sm:p-5 sm:shadow-[0_20px_55px_rgb(0_0_0/22%)]',
)}
>
<div
aria-hidden="true"
className="mx-auto mb-3 h-1 w-10 rounded-full bg-stone-300 sm:hidden"
/>
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="border-0 pb-0 font-[family-name:var(--font-exo2)] text-xl font-black">
Peça esse voto no WhatsApp
</h2>
<p className="mt-1 text-xs text-(--campaign-muted)">
A mensagem é sua: revise antes de abrir.
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Fechar"
className={cn(
'grid size-8 shrink-0 place-items-center rounded-full border border-black/10 text-lg text-(--campaign-muted) hover:text-black',
CONTENT_PIECE_FOCUS,
)}
>
×
</button>
</div>
<label className="sr-only" htmlFor="content-piece-share-message">
Mensagem para compartilhar
</label>
<textarea
id="content-piece-share-message"
ref={textareaRef}
value={message}
onChange={(event) => setMessage(event.target.value)}
className="mt-4 min-h-32 w-full resize-none rounded-[10px] border border-black/18 bg-white p-3 text-sm leading-5 text-black focus-visible:border-(--pt-red) focus-visible:outline-[3px] focus-visible:outline-offset-[2px] focus-visible:outline-(--pt-red)"
/>
<a
href={buildContentPieceWhatsAppUrl(message)}
target="_blank"
rel="noopener noreferrer"
onClick={() => sendContentPieceEvent('compartilhar_whatsapp', item.slug)}
className={`${CONTENT_PIECE_PRIMARY_BUTTON} mt-3 w-full`}
>
<WhatsAppIcon className="size-4" />
Abrir no WhatsApp
</a>
<div className={cn('mt-3 grid gap-2', item.file ? 'grid-cols-2' : 'grid-cols-1')}>
{item.file ? (
<button
type="button"
onClick={() => void shareMedia()}
disabled={preparing}
className={cn(CONTENT_PIECE_OUTLINE_BUTTON, 'disabled:opacity-70')}
>
{preparing ? 'Preparando…' : 'A mídia'}
</button>
) : null}
<CopyLinkButton
url={shareLink}
className={CONTENT_PIECE_OUTLINE_BUTTON}
onCopied={() => sendContentPieceEvent('compartilhar_link', item.slug)}
/>
</div>
<p className="mt-3 text-center text-[11px] leading-4 text-(--campaign-muted)">
Sem destinatário e sem envio automático.
{item.file ? ' Se o aparelho não compartilhar o arquivo, ele será baixado.' : ''}
</p>
</aside>
</div>,
document.body,
)
}

View file

@ -1,125 +0,0 @@
import { SearchIcon } from 'lucide-react'
import Link from 'next/link'
import {
CONTENT_PIECE_CATALOG_PATH,
buildContentPieceCatalogHref,
type ContentPieceCatalogActiveFilter,
type ContentPieceCatalogParams,
} from '@/lib/contentPieceCatalog'
import { CONTENT_PIECE_OUTLINE_BUTTON, CONTENT_PIECE_PRIMARY_BUTTON } from './contentPieceClasses'
/**
* S27 — the honest states of the Central (artefato: cena 05/06). Zero
* published pieces keeps the page 200 with no discovery; an active filter that
* matches nothing shows the query, the filters in force and the one way out.
*/
export const ContentPieceEmptyState = () => (
<section className="bg-(--campaign-cream) px-5 py-16 sm:px-8 sm:py-20">
<div className="mx-auto max-w-lg rounded-xl bg-white p-8 text-center shadow-[0_22px_60px_rgb(39_25_22/13%)]">
<h1 className="border-0 pb-0 font-[family-name:var(--font-exo2)] text-xl font-black">
As primeiras peças estão a caminho
</h1>
<p className="mt-2 text-sm leading-6 text-(--campaign-muted)">
Enquanto isso, faça seu card e peça voto pra Solla 1313.
</p>
<Link href="/cards" className={`${CONTENT_PIECE_PRIMARY_BUTTON} mt-5`}>
Fazer meu card
</Link>
</div>
</section>
)
export const ContentPieceNoResults = ({
activeFilters,
}: {
activeFilters: readonly ContentPieceCatalogActiveFilter[]
}) => (
<section className="rounded-2xl border border-[#184e92]/15 bg-[#f9faff] px-5 py-10 text-center sm:px-8 sm:py-14">
<div
aria-hidden="true"
className="mx-auto grid size-11 place-items-center rounded-full border border-[#184e92]/20 bg-white text-[#184e92] sm:size-12"
>
<SearchIcon className="size-5 sm:size-6" />
</div>
<h2 className="mt-4 border-0 pb-0 font-[family-name:var(--font-exo2)] text-xl font-black sm:text-2xl">
Nenhuma peça com esses filtros
</h2>
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-(--campaign-muted)">
Tente outro termo ou limpe os filtros para ver todas as peças.
</p>
{activeFilters.length > 0 ? (
<div className="mt-4 flex flex-wrap justify-center gap-1.5 text-[11px] text-(--campaign-muted) sm:text-xs">
{activeFilters.map((filter) => (
<span
key={filter.facet}
className="rounded-full border border-black/10 bg-white px-2.5 py-1 sm:px-3 sm:py-1.5"
>
{filter.facet === 'q' ? `Busca: “${filter.value}”` : filter.value}
</span>
))}
</div>
) : null}
<Link
href={CONTENT_PIECE_CATALOG_PATH}
className={`${CONTENT_PIECE_PRIMARY_BUTTON} mt-6 sm:min-w-40`}
>
Limpar filtros
</Link>
</section>
)
/**
* S28 — the honest empty of the theme mode (artefato: cena 03): no weak result
* fills the board, and the three ways out are the reformulation, the exact
* search and the clear filters.
*/
export const ContentPieceThemeNoResults = ({ params }: { params: ContentPieceCatalogParams }) => (
<section className="rounded-2xl border border-[#184e92]/15 bg-[#f9faff] px-5 py-10 text-center sm:px-8 sm:py-14">
<h2 className="border-0 pb-0 font-[family-name:var(--font-exo2)] text-xl font-black sm:text-2xl">
Nenhuma peça combina bem com esse tema
</h2>
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-(--campaign-muted)">
Não mostramos um resultado fraco só para preencher a tela.
</p>
<div className="mt-6 flex flex-wrap justify-center gap-2">
<Link
href={buildContentPieceCatalogHref({ ...params, q: '', mode: null })}
className={`${CONTENT_PIECE_PRIMARY_BUTTON} sm:min-w-40`}
>
Reformular busca
</Link>
<Link
href={buildContentPieceCatalogHref({ ...params, mode: null })}
className={CONTENT_PIECE_OUTLINE_BUTTON}
>
Usar termo exato
</Link>
<Link href={CONTENT_PIECE_CATALOG_PATH} className={CONTENT_PIECE_OUTLINE_BUTTON}>
Limpar filtros
</Link>
</div>
</section>
)
/**
* S28 — the degradation notice (artefato: cenas 04/09): the exact search keeps
* working and the retry lives here (the canonical block has no submit button).
* The link never prefetches: a prefetched payload is never expanded and would
* poison the client router cache.
*/
export const ContentPieceThemeFallbackNotice = ({ retryHref }: { retryHref: string }) => (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm leading-5 text-amber-900">
<b>Busca por tema indisponível agora.</b>
<br />A busca exata continua funcionando.{' '}
<Link
href={retryHref}
prefetch={false}
className="font-bold text-amber-950 underline underline-offset-2 focus-visible:outline-[3px] focus-visible:outline-offset-[2px] focus-visible:outline-(--pt-red)"
>
Tentar por tema novamente
</Link>
</div>
)

View file

@ -1,77 +0,0 @@
import { cn } from '@/lib/utils'
/** Shared class contracts of the Central de Conteúdos (artefato S27, cenas 01–08). */
export const CONTENT_PIECE_FOCUS =
'focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-red)'
export const CONTENT_PIECE_CHIP = cn(
'inline-flex min-h-9 items-center gap-1.5 rounded-full border border-(--campaign-line) bg-white px-3 text-xs font-bold text-black',
CONTENT_PIECE_FOCUS,
)
export const CONTENT_PIECE_ACTIVE_CHIP = cn(
'inline-flex min-h-9 items-center gap-1.5 rounded-full border border-[#184e92]/35 bg-[#eef4fb] px-3 text-xs font-bold text-[#184e92]',
CONTENT_PIECE_FOCUS,
)
export const CONTENT_PIECE_TAG =
'inline-flex rounded-full bg-[#eef4fb] px-2 py-1 text-[11px] font-extrabold text-[#184e92]'
/** S39 — the provenance tag of a piece sampled from the visitor's territory. */
const CONTENT_PIECE_LOCAL_TAG = cn(CONTENT_PIECE_TAG, 'bg-[#fff3c4] text-[#6b5100]')
/** S28 — the neutral badge of a theme-mode result that only matched the query. */
export const CONTENT_PIECE_EXACT_TAG =
'inline-flex rounded-full border border-black/12 bg-white px-2 py-1 text-[11px] font-extrabold text-[#514945]'
export const CONTENT_PIECE_PRIMARY_BUTTON = cn(
'inline-flex min-h-11 items-center justify-center gap-2 rounded-[10px] bg-(--pt-yellow) px-4 font-[family-name:var(--font-exo2)] text-sm font-extrabold text-(--pt-yellow-ink) no-underline',
'shadow-[0_4px_0_#cfb900] transition-[transform,box-shadow] duration-150 ease-out hover:-translate-y-0.5 active:translate-y-[2px] active:shadow-[0_2px_0_#cfb900] motion-reduce:transition-none',
CONTENT_PIECE_FOCUS,
)
export const CONTENT_PIECE_OUTLINE_BUTTON = cn(
'inline-flex min-h-11 items-center justify-center gap-2 rounded-[10px] border-[1.5px] border-[rgb(162_28_28/28%)] bg-white px-4 font-[family-name:var(--font-exo2)] text-sm font-extrabold text-(--pt-red) no-underline',
'transition-colors duration-150 ease-out hover:border-(--pt-red) hover:bg-[rgb(162_28_28/7%)] motion-reduce:transition-none',
CONTENT_PIECE_FOCUS,
)
export const CONTENT_PIECE_CARD = cn(
'overflow-hidden rounded-[14px] border border-(--campaign-line) bg-white shadow-[0_8px_24px_rgb(71_19_14/7%)]',
)
/**
* S42 — the home section's tags (artefato: cenas 01–06): 40px targets on
* touch and 36px from `sm` up, in the static (filled) and link (outline +
* chevron) variants. The catalogue and the piece page keep the S27 tags
* (`CONTENT_PIECE_TAG`/`CONTENT_PIECE_LOCAL_TAG`) untouched.
*/
export const CONTENT_PIECE_HOME_TAG = cn(
CONTENT_PIECE_TAG,
'min-h-10 items-center px-2.5 py-1.5 sm:min-h-9',
)
export const CONTENT_PIECE_HOME_LOCAL_TAG = cn(
CONTENT_PIECE_LOCAL_TAG,
'min-h-10 items-center px-2.5 py-1.5 sm:min-h-9',
)
export const CONTENT_PIECE_TAG_LINK = cn(
CONTENT_PIECE_TAG,
'min-h-10 max-w-full items-center gap-1 border border-[rgb(24_78_146/30%)] bg-[#f8fbff] px-2.5 py-1.5 no-underline sm:min-h-9',
'transition-[border-color,background-color,transform] duration-150 ease-out hover:-translate-y-px hover:border-current hover:bg-[#eef4fb] hover:underline hover:underline-offset-[3px] motion-reduce:transition-none',
CONTENT_PIECE_FOCUS,
)
/** S42 — the local (territory) variant of the filtrable tag. */
export const CONTENT_PIECE_LOCAL_TAG_LINK = cn(
CONTENT_PIECE_TAG_LINK,
'border-[rgb(107_81_0/28%)] bg-[#fff8dc] text-[#6b5100] hover:bg-[#fff3c4]',
)
/** S42 — the card's `Ver esta peça →` under the share button (artefato `.piece-link`). */
export const CONTENT_PIECE_PIECE_LINK = cn(
'mt-2 flex min-h-11 w-full items-center justify-center rounded-sm text-xs font-extrabold text-(--pt-red) underline-offset-4 hover:underline sm:min-h-9',
CONTENT_PIECE_FOCUS,
)

View file

@ -1,34 +0,0 @@
/**
* C233 — the album hero (artefato: cenas 01/02): the campaign gradient band
* with the kit texture, no CTA — the search right below is the primary action
* and duplicating it would not add a destination (the selfie CTA belonged to
* C234, out of this slice). Copy closes on the two levels the album offers:
* the agenda/município/pessoa search and the real context of each record.
*/
export const ArchivePhotoHero = () => (
<section className="relative isolate overflow-hidden border-b-4 border-(--pt-yellow) bg-[radial-gradient(circle_at_78%_0%,rgb(255_230_7/13%),transparent_28%),linear-gradient(112deg,#e4102f_0%,#b8102a_58%,#184e92_100%)] text-white">
<div
aria-hidden="true"
className="absolute inset-y-0 right-0 -z-10 w-[38%] bg-[url('/campaign-kit/pattern-shapes.png')] bg-[length:100%_auto] bg-[position:center_5%] bg-no-repeat opacity-[0.1]"
/>
<div className="relative mx-auto flex min-h-[196px] w-full max-w-6xl items-center px-5 py-8 sm:min-h-[230px] sm:px-8 sm:py-10">
<div className="max-w-2xl">
<p className="m-0 text-xs font-black tracking-[0.12em] text-(--pt-yellow) uppercase">
<span className="sm:hidden">Nossa caminhada</span>
<span className="hidden sm:inline">Memória da nossa caminhada</span>
</p>
<h1 className="mt-3 font-[family-name:var(--font-exo2)] text-[30px] leading-[0.98] font-black tracking-[-0.03em] text-balance sm:text-[42px] sm:leading-[1]">
<span className="sm:hidden">Encontre um momento.</span>
<span className="hidden sm:inline">Fotos que contam onde a gente esteve.</span>
</h1>
<p className="mt-3 max-w-xl text-sm leading-5 text-white/90 sm:mt-4 sm:text-base sm:leading-6">
<span className="sm:hidden">Busque pela agenda, pelo lugar ou por quem apareceu.</span>
<span className="hidden sm:inline">
Procure uma agenda, um município ou uma pessoa pública e abra cada registro com data e
contexto.
</span>
</p>
</div>
</div>
</section>
)

View file

@ -1,40 +0,0 @@
import Link from 'next/link'
import { CampaignLogoLink } from '@/components/CampaignLogoLink'
import { ARCHIVE_PHOTO_FOCUS } from './archivePhotoClasses'
const NAV_LINK = `rounded-sm text-sm font-bold text-white underline-offset-4 hover:underline ${ARCHIVE_PHOTO_FOCUS}`
/**
* C233 — the album bar (artefato: cena 01): the negative logo over the campaign
* red (crop contract in `CampaignLogoLink`, single owner S36) and the site nav
* on desktop — "Fotos" carries the yellow underline of the current page;
* mobile keeps the section name alone. "Conteúdos" only appears while the
* Central has something published, the same discovery rule as the footer.
*/
export const ArchivePhotoPageHeader = ({ showConteudos }: { showConteudos: boolean }) => (
<header className="bg-[#ae1603]">
<div className="mx-auto flex h-14 w-full max-w-6xl items-center justify-between gap-3 px-5 sm:h-16 sm:px-8">
<CampaignLogoLink className={ARCHIVE_PHOTO_FOCUS} />
<nav aria-label="Navegação principal" className="hidden items-center gap-6 sm:flex">
<Link href="/" className={NAV_LINK}>
Início
</Link>
<Link
href="/fotos"
aria-current="page"
className="border-b-2 border-(--pt-yellow) py-2 text-sm font-bold text-white"
>
Fotos
</Link>
{showConteudos ? (
<Link href="/conteudos" className={NAV_LINK}>
Conteúdos
</Link>
) : null}
</nav>
<span className="text-sm font-bold text-white sm:hidden">Fotos</span>
</div>
</header>
)

View file

@ -19,7 +19,7 @@ export const ArchivePhotoEmptyState = () => (
<div className="mx-auto grid size-12 place-items-center rounded-full border border-(--campaign-line) bg-white text-(--campaign-muted)">
<ImageIcon aria-hidden="true" className="size-6" strokeWidth={2} />
</div>
<h2 className="mt-4 font-[family-name:var(--font-exo2)] text-2xl font-black">
<h2 className="mt-4 border-0 pb-0 font-[family-name:var(--font-exo2)] text-2xl font-black">
O álbum ainda está sendo preparado
</h2>
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-(--campaign-muted)">
@ -50,7 +50,7 @@ export const ArchivePhotoNoResults = ({
>
<SearchIcon className="size-5 sm:size-6" strokeWidth={2} />
</div>
<h2 className="mt-4 font-[family-name:var(--font-exo2)] text-xl font-black sm:text-2xl">
<h2 className="mt-4 border-0 pb-0 font-[family-name:var(--font-exo2)] text-xl font-black sm:text-2xl">
Nada encontrado com esses filtros
</h2>
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-(--campaign-muted)">

View file

@ -1,296 +0,0 @@
'use client'
import Image from 'next/image'
import { useCallback, useEffect, useRef, useState } from 'react'
import { splitJingleTitle, type JingleViewModel } from '@/lib/jingle'
import { JINGLE_PLAY_EVENT, RADIO_PLAY_EVENT } from '@/lib/radio'
import { formatSpeechClock } from '@/lib/speechClock'
import { cn } from '@/lib/utils'
import { JINGLE_FOCUS_RING } from './focusRing'
/**
* S21 — the jingles card grid (artefato: cenas 01/02). One client component
* owns the exclusivity state: every card renders its own `<audio
* preload="none">` (no request before the play, per the gate note) and starting
* one pauses the others. The play control reverts on a failed `play()` so
* `aria-pressed` never gets stuck; progress/time come from the media events.
* S22 reuses this core in the home sound section, so the
* `article[data-jingle]`/`data-state`/download contract stays single-owned and
* the heading level follows the host page outline.
*/
const PLAY_CONTROL = cn(
'inline-flex h-14 w-14 flex-none items-center justify-center rounded-full bg-(--pt-yellow) text-(--pt-yellow-ink)',
'shadow-[0_6px_0_#cfb900,0_10px_22px_rgb(0_0_0/15%)] transition-[transform,box-shadow,filter] duration-150 ease-out',
'hover:-translate-y-0.5 hover:brightness-[0.98] hover:shadow-[0_8px_0_#cfb900,0_12px_24px_rgb(0_0_0/18%)]',
'active:translate-y-[3px] active:shadow-[0_3px_0_#cfb900]',
'motion-reduce:transition-none motion-reduce:hover:translate-y-0',
JINGLE_FOCUS_RING,
)
const DOWNLOAD_CONTROL = cn(
'mt-5 inline-flex min-h-11 w-full items-center justify-center gap-2 rounded-[10px] border-[1.5px] border-[rgb(162_28_28/32%)] bg-transparent',
'font-[family-name:var(--font-exo2)] font-extrabold text-(--pt-red) no-underline',
'transition-[background-color,border-color,transform] duration-150 ease-out hover:border-(--pt-red) hover:bg-[rgb(162_28_28/7%)] active:scale-[0.99]',
'motion-reduce:transition-none',
JINGLE_FOCUS_RING,
)
const PlayIcon = () => (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-[25px] w-[25px] fill-current stroke-current"
>
<path d="m8 5 11 7-11 7z" />
</svg>
)
const PauseIcon = () => (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-[25px] w-[25px] fill-current stroke-current"
>
<path d="M8 5h3v14H8zM14 5h3v14h-3z" />
</svg>
)
const DownloadIcon = () => (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-[18px] w-[18px]"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path d="M12 3v12" />
<path d="m7 10 5 5 5-5" />
<path d="M5 21h14" />
</svg>
)
type Playback = {
currentSeconds: number
durationSeconds: number | null
}
const EMPTY_PLAYBACK: Playback = { currentSeconds: 0, durationSeconds: null }
const EMPTY_TIME = '—:—'
export const JingleCards = ({
jingles,
headingLevel = 'h2',
}: {
jingles: readonly JingleViewModel[]
headingLevel?: 'h2' | 'h3' | 'h4'
}) => {
const Heading = headingLevel
const [activeId, setActiveId] = useState<JingleViewModel['id'] | null>(null)
const [playback, setPlayback] = useState<Record<number, Playback>>({})
const audioRefs = useRef(new Map<number, HTMLAudioElement>())
// S25 — the radio:play listener reads the live active id without
// re-subscribing on every card change.
const activeIdRef = useRef<JingleViewModel['id'] | null>(null)
// One stable ref callback per id: an inline arrow would be detached and
// reattached on every render (each `timeupdate` re-renders the list).
const audioRefCallbacks = useRef(new Map<number, (node: HTMLAudioElement | null) => void>())
useEffect(() => {
activeIdRef.current = activeId
}, [activeId])
// S25 — one audio at a time with the home radio: a `radio:play` pauses only
// this component's active card. Inert on `/jingles` (no radio broadcasts
// there), so the exclusivity never leaks into the page's own behaviour.
const stopActiveCard = useCallback(() => {
const current = activeIdRef.current
if (current === null) return
audioRefs.current.get(current)?.pause()
setActiveId(null)
}, [])
useEffect(() => {
window.addEventListener(RADIO_PLAY_EVENT, stopActiveCard)
return () => window.removeEventListener(RADIO_PLAY_EVENT, stopActiveCard)
}, [stopActiveCard])
const audioRef = (id: number) => {
const cached = audioRefCallbacks.current.get(id)
if (cached) return cached
const callback = (node: HTMLAudioElement | null) => {
if (node) {
audioRefs.current.set(id, node)
return
}
audioRefs.current.delete(id)
}
audioRefCallbacks.current.set(id, callback)
return callback
}
const updatePlayback = (id: number, patch: Partial<Playback>) => {
setPlayback((current) => ({
...current,
[id]: { ...(current[id] ?? EMPTY_PLAYBACK), ...patch },
}))
}
const toggle = (id: number) => {
const audio = audioRefs.current.get(id)
if (!audio) return
if (activeId === id) {
stopActiveCard()
return
}
for (const [otherId, other] of audioRefs.current) {
if (otherId !== id) other.pause()
}
setActiveId(id)
window.dispatchEvent(new CustomEvent(JINGLE_PLAY_EVENT))
// A pending play() of another card rejects when we pause it; the catch
// must only clear the state of THIS card, never the one now playing.
void audio.play().catch(() => setActiveId((current) => (current === id ? null : current)))
}
return (
<div className="mx-auto flex max-w-6xl snap-x snap-mandatory gap-4 overflow-x-auto pb-4 scrollbar-hide md:grid md:grid-cols-3 md:snap-none md:gap-5 lg:gap-6">
{jingles.map((jingle) => {
const playing = activeId === jingle.id
const { base, credit } = splitJingleTitle(jingle.title)
const state = playback[jingle.id] ?? EMPTY_PLAYBACK
const percent =
state.durationSeconds && state.durationSeconds > 0
? Math.min(100, (state.currentSeconds / state.durationSeconds) * 100)
: 0
return (
<article
key={jingle.id}
data-jingle
data-state={playing ? 'playing' : 'stopped'}
className={cn(
'w-[280px] flex-none snap-start overflow-hidden rounded-[14px] border bg-white shadow-[0_12px_34px_rgb(71_19_14/8%)] md:w-auto md:flex-none',
playing
? 'border-[rgb(162_28_28/36%)] shadow-[0_14px_38px_rgb(162_28_28/14%)]'
: 'border-(--campaign-line)',
)}
>
<div className="relative h-40 overflow-hidden bg-(--campaign-band) md:h-48">
<Image
src={jingle.coverUrl}
alt={jingle.coverAlt}
fill
sizes="(min-width: 768px) 33vw, 100vw"
className="object-cover"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-[58%] bottom-0 bg-[linear-gradient(to_bottom,transparent,rgb(24_10_9/42%))]"
/>
</div>
<div className="p-4 md:p-5">
<div className="flex items-start gap-4">
<button
type="button"
data-play
className={PLAY_CONTROL}
aria-label={`${playing ? 'Pausar' : 'Tocar'} jingle ${jingle.title}`}
aria-pressed={playing}
onClick={() => toggle(jingle.id)}
>
{playing ? <PauseIcon /> : <PlayIcon />}
</button>
<div className="min-w-0 flex-1">
<Heading className="m-0 border-b-0 pb-0 text-left font-[family-name:var(--font-exo2)] text-2xl leading-[1.02] font-black tracking-[-0.02em] wrap-anywhere">
{base}
</Heading>
{credit ? (
<p className="mt-1.5 text-[13px] leading-[1.25] font-bold text-(--campaign-muted)">
{credit}
</p>
) : null}
{playing ? (
<p className="mt-2 flex items-center gap-2 text-sm font-bold text-(--pt-red)">
<span className="h-2 w-2 rounded-full bg-(--pt-red)" />
Em reprodução
</p>
) : (
<p className="mt-2 text-sm text-(--campaign-muted)">Pronto para tocar</p>
)}
</div>
</div>
<div
className="mt-5"
role="progressbar"
aria-label={`Progresso do jingle ${jingle.title}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(percent)}
>
<div className="h-1.5 overflow-hidden rounded-full bg-[#dedbd8]">
<div
className="h-full rounded-[inherit] bg-(--pt-red)"
style={{ width: `${percent}%` }}
/>
</div>
<div className="mt-2 flex justify-between text-[10px] font-bold tracking-wide text-black/50 uppercase md:text-[11px]">
<span>
Tempo{' '}
{state.currentSeconds > 0
? formatSpeechClock(state.currentSeconds)
: EMPTY_TIME}
</span>
<span>
Duração{' '}
{state.durationSeconds ? formatSpeechClock(state.durationSeconds) : EMPTY_TIME}
</span>
</div>
</div>
<a
href={jingle.audioUrl}
download={jingle.downloadFilename}
className={DOWNLOAD_CONTROL}
aria-label={`Baixar ${jingle.title} em MP3`}
>
<DownloadIcon />
Baixar MP3
</a>
<p className="mt-3 truncate text-center text-[11px] text-black/45">
{jingle.downloadFilename}
</p>
</div>
<audio
ref={audioRef(jingle.id)}
src={jingle.audioUrl}
preload="none"
onTimeUpdate={(event) =>
updatePlayback(jingle.id, { currentSeconds: event.currentTarget.currentTime })
}
onLoadedMetadata={(event) =>
updatePlayback(jingle.id, { durationSeconds: event.currentTarget.duration })
}
onEnded={() => {
updatePlayback(jingle.id, { currentSeconds: 0 })
setActiveId((current) => (current === jingle.id ? null : current))
}}
/>
</article>
)
})}
</div>
)
}

View file

@ -1,42 +0,0 @@
import Link from 'next/link'
/**
* S21 — honest access state with zero published jingles (artefato: cena 03):
* the page still answers 200, discovery is gone (header badge and footer link)
* and the copy never promises releases.
*/
export const JingleEmptyState = () => (
<section className="bg-(--campaign-cream) px-6 py-20 text-center">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-(--campaign-band) text-(--pt-red)">
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-9 w-9"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
>
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
<path d="M3 3l18 18" />
</svg>
</div>
<p className="mt-7 font-[family-name:var(--font-exo2)] text-xs font-black tracking-[0.14em] text-(--pt-red) uppercase">
Jingles
</p>
<h1 className="mx-auto mt-2 max-w-2xl text-center font-[family-name:var(--font-exo2)] text-4xl font-black tracking-[-0.035em] text-wrap">
Nenhum jingle publicado por enquanto
</h1>
<p className="mx-auto mt-4 max-w-xl text-base leading-7 text-(--campaign-muted)">
Quando houver jingles disponíveis, eles aparecerão nesta página para ouvir e baixar.
</p>
<p className="mt-8 text-sm text-black/55">Você pode voltar ao site de Jorge Solla.</p>
<Link
href="/"
className="mt-3 inline-flex min-h-11 items-center rounded-[10px] border-2 border-(--pt-red) px-5 font-[family-name:var(--font-exo2)] font-extrabold text-(--pt-red) no-underline hover:bg-(--pt-red) hover:text-white focus-visible:shadow-[0_0_0_2px_var(--pt-red-dark)] focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-yellow)"
>
Voltar ao início
</Link>
</section>
)

View file

@ -1,105 +0,0 @@
import Link from 'next/link'
import type { JingleViewModel } from '@/lib/jingle'
import { JINGLE_FOCUS_RING } from './focusRing'
import { JingleCards } from './JingleCards'
import { RadioPlayer } from './RadioPlayer'
type JingleHomeSectionProps = {
jingles: readonly JingleViewModel[]
showAll: boolean
}
const SEE_ALL_CONTROL = `inline-flex min-h-11 items-center gap-1.5 rounded-md px-2 font-bold text-(--pt-red) underline-offset-4 hover:underline ${JINGLE_FOCUS_RING}`
/**
* S22/S25 — the home sound section (artefato: cenas 01/02/06/07 do S22; o
* player da rádio é o do S25, cenas 01–05): the Rádio 1313 player (próprio,
* `preload="none"`, desde o S25) followed by up to three published jingles and
* the "Ver todos" handoff to `/jingles` when more exist. With zero published
* jingles the grid and the handoff are gone and only the radio stays
* (fail-closed, same kill switch as S21); the page passes the already-cached
* `getPublishedJingleItems()` listing in, so the section never re-reads.
*/
export const JingleHomeSection = ({ jingles, showAll }: JingleHomeSectionProps) => {
const hasJingles = jingles.length > 0
return (
<section
aria-labelledby="sound-title"
data-home-section="sound"
className="relative overflow-hidden border-b border-(--campaign-line) bg-(--campaign-cream)"
>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-28 -right-20 h-64 w-64 rounded-full bg-(--pt-yellow)/45"
/>
<div className="relative mx-auto w-full max-w-[1160px] px-5 py-8 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">
A trilha da nossa caminhada
</p>
<h2
id="sound-title"
className="campaign-section-title campaign-sound-title m-0 mt-2 border-0 p-0 font-black tracking-[-0.03em] text-balance"
>
{hasJingles ? (
<>
Cante, baixe
<br className="md:hidden" /> e espalhe
</>
) : (
'Sintonize com a Rádio 1313'
)}
</h2>
<p className="campaign-section-copy mx-auto mt-3 max-w-xl text-(--campaign-muted)">
{hasJingles
? 'Ouça a Rádio Jorge Solla 1313 e os jingles oficiais da campanha. Dê o play e baixe o seu preferido para levar com você.'
: 'Ainda não há jingles publicados por aqui. Enquanto isso, ouça a Rádio Jorge Solla 1313.'}
</p>
</div>
<RadioPlayer compact={!hasJingles} />
{hasJingles ? (
<>
<div className="mt-10 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6 lg:mt-12">
<div>
<p className="campaign-section-eyebrow m-0 font-black tracking-[0.1em] text-(--pt-red) uppercase">
Jingles oficiais
</p>
<h3 className="m-0 mt-1 font-[family-name:var(--font-exo2)] text-2xl font-black tracking-[-0.02em]">
<span className="md:hidden">Dê o play</span>
<span className="hidden md:inline">Dê o play. O próximo ritmo é seu.</span>
</h3>
</div>
</div>
<div className="mt-6">
<JingleCards jingles={jingles} headingLevel="h4" />
</div>
{showAll ? (
<div className="mt-8 text-center">
<Link href="/jingles" className={SEE_ALL_CONTROL}>
Ver todos os jingles
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path d="m9 18 6-6-6-6" />
</svg>
</Link>
</div>
) : null}
</>
) : null}
</div>
</section>
)
}

View file

@ -1,44 +0,0 @@
/**
* S21 — the jingles hero (artefato: cena 01 desktop / cena 02 mobile): cream
* band with the decorative blobs, the eyebrow and the "um por vez" reassurance.
* Copy differs per breakpoint in the artefato, so each state keeps its literal.
*/
export const JingleIntro = () => (
<section
aria-labelledby="jingles-title"
className="relative overflow-hidden bg-(--campaign-cream) px-5 pt-9 pb-6 sm:px-8 sm:pt-16 sm:pb-12"
>
<div
aria-hidden="true"
className="absolute -top-14 -right-14 h-40 w-40 rounded-full bg-[#ffe607]/60 sm:-top-32 sm:-right-24 sm:h-72 sm:w-72 sm:bg-[#ffe607]/55"
/>
<div
aria-hidden="true"
className="absolute top-16 -left-28 hidden h-60 w-60 rounded-full bg-[#184e92]/8 sm:block"
/>
<div className="relative mx-auto max-w-6xl">
<p className="m-0 font-[family-name:var(--font-exo2)] text-[10px] font-black tracking-[0.14em] text-(--pt-red) uppercase sm:text-xs">
A trilha da nossa caminhada
</p>
<div className="mt-2 sm:mt-3 sm:grid sm:grid-cols-[1fr_auto] sm:items-end sm:gap-10">
<div>
<h1
id="jingles-title"
className="m-0 max-w-2xl text-left font-[family-name:var(--font-exo2)] text-[34px] leading-[1] font-black tracking-[-0.04em] text-black sm:text-[52px] sm:leading-[0.98]"
>
Jingles de
<br className="sm:hidden" /> Jorge Solla
</h1>
<p className="mt-4 max-w-xl text-[15px] leading-6 text-black/70 sm:mt-5 sm:text-[17px] sm:leading-7">
<span className="hidden sm:inline">
Escolha um jingle, dê o play e escute aqui mesmo. Para levar com você, baixe o MP3.
</span>
</p>
</div>
</div>
</div>
</section>
)

View file

@ -1,19 +0,0 @@
import type { JingleViewModel } from '@/lib/jingle'
import { JingleCards } from './JingleCards'
/**
* S21 — the jingles page shell (artefato: cenas 01/02): the player core
* (`JingleCards`) owns the exclusivity state, the lazy audio and the download
* name; this shell keeps the published band and the lazy-audio note. S22
* reuses the same core in the home sound section.
*/
export const JinglePlayer = ({ jingles }: { jingles: readonly JingleViewModel[] }) => (
<section aria-label="Jingles publicados" className="bg-white px-4 py-7 sm:px-8 sm:py-14">
<JingleCards jingles={jingles} />
<p className="m-0 mt-5 px-3 text-center text-xs leading-5 text-(--campaign-muted) md:hidden">
Sem autoplay. O áudio só é carregado depois do play.
</p>
</section>
)

View file

@ -1,410 +0,0 @@
'use client'
import { CheckIcon, CopyIcon, InfoIcon } from 'lucide-react'
import Image from 'next/image'
import { useCallback, useEffect, useRef, useState } from 'react'
import { WhatsAppIcon } from '@/components/socialIcons'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { copyFeedbackLabels, copyFeedbackLiveMessages, useCopyFeedback } from '@/lib/copyFeedback'
import {
buildRadioShareWhatsAppUrl,
JINGLE_PLAY_EVENT,
RADIO_CONNECT_TIMEOUT_MS,
RADIO_PAGE_URL,
RADIO_PLAY_EVENT,
RADIO_SHARE_MESSAGE,
RADIO_STREAM_URL,
RADIO_TITLE,
} from '@/lib/radio'
import { cn } from '@/lib/utils'
import { JINGLE_FOCUS_RING } from './focusRing'
type RadioStatus = 'idle' | 'connecting' | 'playing' | 'error'
const PLAY_CONTROL = cn(
'inline-flex size-12 flex-none items-center justify-center rounded-full bg-(--pt-yellow) text-(--pt-yellow-ink) md:size-14',
'shadow-[0_6px_0_#cfb900,0_10px_22px_rgb(0_0_0/15%)] transition-[transform,box-shadow,filter,opacity] duration-150 ease-out',
'hover:-translate-y-0.5 hover:brightness-[0.98] hover:shadow-[0_8px_0_#cfb900,0_12px_24px_rgb(0_0_0/18%)]',
'active:translate-y-[3px] active:shadow-[0_3px_0_#cfb900]',
'motion-reduce:transition-none motion-reduce:hover:translate-y-0',
JINGLE_FOCUS_RING,
)
const SHARE_OUTLINE = cn(
'inline-flex min-h-11 items-center justify-center gap-2 rounded-[10px] border-[1.5px] border-[rgb(162_28_28/32%)] bg-transparent px-3.5',
'font-[family-name:var(--font-exo2)] text-sm font-extrabold text-(--pt-red) no-underline',
'transition-colors duration-150 ease-out hover:border-(--pt-red) hover:bg-[rgb(162_28_28/7%)] motion-reduce:transition-none',
JINGLE_FOCUS_RING,
)
const SHARE_LINK = cn(
'inline-flex min-h-11 items-center gap-2 rounded-lg px-2 text-sm font-bold text-(--pt-red)',
JINGLE_FOCUS_RING,
)
const SHARE_PRIMARY = cn(
'inline-flex min-h-11 items-center justify-center gap-2 rounded-lg bg-(--pt-red) px-4 text-sm font-black text-white no-underline',
'font-[family-name:var(--font-exo2)] transition-[filter] duration-150 ease-out hover:brightness-110 motion-reduce:transition-none',
JINGLE_FOCUS_RING,
)
const RETRY_CONTROL = cn(
'inline-flex min-h-11 items-center rounded-lg bg-(--pt-red) px-4 text-sm font-black text-white',
'font-[family-name:var(--font-exo2)] transition-[filter] duration-150 ease-out hover:brightness-110 motion-reduce:transition-none',
JINGLE_FOCUS_RING,
)
const ZENO_LINK = cn(
'inline-flex min-h-11 items-center rounded-lg px-2 text-sm font-bold text-(--pt-red) underline underline-offset-4',
JINGLE_FOCUS_RING,
)
const PlayIcon = () => (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-5 w-5 fill-current stroke-current md:h-[25px] md:w-[25px]"
>
<path d="m8 5 11 7-11 7z" />
</svg>
)
const PauseIcon = () => (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-5 w-5 fill-current stroke-current md:h-[25px] md:w-[25px]"
>
<path d="M8 5h3v14H8zM14 5h3v14h-3z" />
</svg>
)
const ShareIcon = ({ className }: { className: string }) => (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className={className}
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<path d="m8.6 10.5 6.8-4M8.6 13.5l6.8 4" />
</svg>
)
/**
* S25 (Cena 05) — the radio share control: one discreet trigger with the two
* approved exits (the literal message into the sender's own WhatsApp, or the
* message copied to the clipboard with the shared feedback). The copy writes
* the literal, link included — what circulates is the message, matching the
* gate's box. Private to this file: the radio owns its share surface.
*/
const RadioShareControl = ({ variant }: { variant: 'outline' | 'link' }) => {
const [open, setOpen] = useState(false)
const { feedback, copy } = useCopyFeedback()
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={variant === 'outline' ? SHARE_OUTLINE : SHARE_LINK}
aria-label={`Compartilhar ${RADIO_TITLE}`}
>
<ShareIcon className={variant === 'outline' ? 'size-[18px]' : 'size-4'} />
Compartilhar
</button>
</PopoverTrigger>
<PopoverContent
align="end"
// The portal lives under `document.body`, outside the home's
// `[data-theme='campaign-site']` wrapper: without the attribute the
// campaign tokens (--pt-red, --campaign-band, …) are undefined and the
// primary WhatsApp CTA renders invisible (same fix as CardsStudio).
data-theme="campaign-site"
className="w-80 rounded-xl border border-(--campaign-line) bg-white p-4 shadow-xl"
>
<p className="m-0 font-[family-name:var(--font-exo2)] text-base font-black">
Compartilhar a rádio
</p>
<p className="mt-2 rounded-lg bg-(--campaign-band) p-3 text-xs leading-5 break-words text-(--campaign-muted)">
{RADIO_SHARE_MESSAGE}
</p>
<div className="mt-3 grid gap-2">
<a
href={buildRadioShareWhatsAppUrl()}
target="_blank"
rel="noopener noreferrer"
className={SHARE_PRIMARY}
onClick={() => setOpen(false)}
>
<WhatsAppIcon className="size-4" />
Compartilhar no WhatsApp
</a>
<button
type="button"
className={cn(SHARE_OUTLINE, 'w-full')}
onClick={() => void copy(RADIO_SHARE_MESSAGE)}
>
{feedback === 'copied' ? (
<CheckIcon className="size-4" aria-hidden="true" />
) : (
<CopyIcon className="size-4" aria-hidden="true" />
)}
{copyFeedbackLabels[feedback]}
</button>
</div>
<span aria-live="polite" className="sr-only">
{copyFeedbackLiveMessages[feedback]}
</span>
</PopoverContent>
</Popover>
)
}
/**
* S25 — the home radio player (artefato: cenas 01–05). The zeno.fm iframe of
* S24 is gone: this client component owns a bare `<audio preload="none">`
* pointed at the stream, so no request to the stream (or any third party) leaves
* before the visitor's play. The state machine (`idle → connecting → playing |
* error`) turns a refused `play()` or a connection that never delivers audio
* into the honest error state with retry and "Ouvir no Zeno" — never an
* infinite spinner. `compact` is the zero-jingles variant (centered, share on
* the bottom row); with jingles the section grid follows the gate's Cena 01 on
* `md+` and the Cena 02 row on mobile. Exclusivity with the jingles is the
* `radio:play`/`radio:pause`/`jingle:play` broadcast from `lib/radio`.
*/
export const RadioPlayer = ({ compact = false }: { compact?: boolean }) => {
const [status, setStatus] = useState<RadioStatus>('idle')
const audioRef = useRef<HTMLAudioElement>(null)
// Invalidates the pending play()/timeout of a superseded attempt: pausing or
// retrying during `connecting` must not let the old attempt win later.
const attemptRef = useRef(0)
// Event handlers read the live status without re-subscribing window listeners.
const statusRef = useRef<RadioStatus>('idle')
useEffect(() => {
statusRef.current = status
}, [status])
const stopPlayback = useCallback((next: 'idle' | 'error') => {
attemptRef.current += 1
audioRef.current?.pause()
setStatus(next)
}, [])
const start = useCallback(() => {
const audio = audioRef.current
if (!audio) return
// The jingle stops on the gesture itself, even if the stream then fails.
window.dispatchEvent(new CustomEvent(RADIO_PLAY_EVENT))
const attempt = (attemptRef.current += 1)
setStatus('connecting')
// The stream answers 302 with a per-request signed URL: a retry needs a new
// request, and `load()` is what discards the failed one.
audio.load()
void audio
.play()
.then(() => {
if (attemptRef.current === attempt) setStatus('playing')
})
.catch(() => {
if (attemptRef.current === attempt) stopPlayback('error')
})
}, [stopPlayback])
const toggle = useCallback(() => {
if (statusRef.current === 'playing' || statusRef.current === 'connecting') {
stopPlayback('idle')
return
}
start()
}, [start, stopPlayback])
// Never an infinite spinner: a stream that accepts the connection and never
// delivers audio becomes the honest error state after the timeout.
useEffect(() => {
if (status !== 'connecting') return
const timeout = setTimeout(() => stopPlayback('error'), RADIO_CONNECT_TIMEOUT_MS)
return () => clearTimeout(timeout)
}, [status, stopPlayback])
// One audio at a time: a jingle started anywhere pauses the radio.
useEffect(() => {
const onJinglePlay = () => {
if (statusRef.current !== 'playing' && statusRef.current !== 'connecting') return
stopPlayback('idle')
}
window.addEventListener(JINGLE_PLAY_EVENT, onJinglePlay)
return () => window.removeEventListener(JINGLE_PLAY_EVENT, onJinglePlay)
}, [stopPlayback])
return (
<article
data-radio
data-state={status}
aria-label={`Player da ${RADIO_TITLE}`}
className={cn(
'overflow-hidden rounded-[14px] border border-(--campaign-line) bg-white shadow-[0_10px_28px_rgb(71_19_14/7%)]',
compact ? 'mx-auto mt-8 max-w-[880px] p-3' : 'mt-9 p-3 md:p-4',
)}
>
{status === 'error' ? (
<div>
<div className="flex items-start gap-3">
<span
aria-hidden="true"
className="grid size-10 flex-none place-items-center rounded-full bg-[rgb(162_28_28/10%)] text-(--pt-red)"
>
<InfoIcon className="size-5" />
</span>
<div>
<h3 className="m-0 border-0 p-0 font-[family-name:var(--font-exo2)] text-lg font-black">
A rádio não conectou
</h3>
<p className="m-0 mt-1 text-sm leading-5 text-(--campaign-muted)">
Tente de novo ou ouça na página da rádio.
</p>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button type="button" onClick={start} className={RETRY_CONTROL}>
Tentar novamente
</button>
<a
href={RADIO_PAGE_URL}
target="_blank"
rel="noopener noreferrer"
className={ZENO_LINK}
>
Ouvir no Zeno
</a>
<div className="ml-auto">
<RadioShareControl variant="outline" />
</div>
</div>
</div>
) : (
<>
<div
className={cn(
'flex items-center gap-3',
compact ? undefined : 'md:grid md:grid-cols-[112px_1fr_auto] md:gap-5',
)}
>
<div className="relative grid size-[76px] flex-none place-items-center overflow-hidden rounded-[10px] md:size-28">
<span
aria-hidden="true"
className="absolute inset-[13%] rounded-full border border-[rgb(0_0_0/8%)] bg-white shadow-[0_7px_18px_rgb(71_19_14/10%)]"
/>
<Image
src="/campaign-kit/radio/radio-jorge-solla-1313-logo.png"
alt="Logo da Rádio Jorge Solla 1313"
width={112}
height={112}
className="relative z-[1] aspect-square w-[74%] rounded-full object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<span className="inline-flex items-center gap-2 text-[10px] font-black tracking-[0.08em] text-(--pt-red) uppercase md:rounded-full md:bg-[rgb(162_28_28/8%)] md:px-3 md:py-1 md:text-[11px]">
<span
aria-hidden="true"
className="size-2 rounded-full bg-(--pt-red) shadow-[0_0_0_4px_rgb(162_28_28/11%)]"
/>
Ao vivo
</span>
<span className="hidden text-xs text-(--campaign-muted) md:inline">
Rádio online
</span>
</div>
<h3 className="m-0 mt-1 border-0 p-0 font-[family-name:var(--font-exo2)] text-lg leading-tight font-black tracking-[-0.025em] md:mt-3 md:text-[28px] md:leading-none">
{RADIO_TITLE}
</h3>
{status === 'connecting' ? (
<p
role="status"
className="m-0 mt-1 flex items-center gap-2 text-xs font-bold md:mt-2 md:text-sm"
>
<span
aria-hidden="true"
className="size-5 animate-spin rounded-full border-2 border-[rgb(162_28_28/18%)] border-t-(--pt-red) [animation-duration:0.8s] motion-reduce:animate-none"
/>
Conectando à rádio…
</p>
) : status === 'playing' ? (
<p className="m-0 mt-1 flex items-center gap-2 text-xs font-bold text-(--pt-red) md:mt-2 md:text-sm">
<span aria-hidden="true" className="size-2 rounded-full bg-(--pt-red)" />
Em reprodução
</p>
) : (
<p className="m-0 mt-1 text-xs text-(--campaign-muted) md:mt-2 md:text-sm">
Pronta para tocar
</p>
)}
</div>
<div className="flex items-center gap-4 md:pr-2">
{compact ? null : (
<span className="hidden md:inline-flex">
<RadioShareControl variant="outline" />
</span>
)}
<button
type="button"
className={cn(PLAY_CONTROL, status === 'connecting' && 'opacity-60')}
aria-label={
status === 'connecting'
? 'Conectando'
: status === 'playing'
? `Pausar ${RADIO_TITLE}`
: `Ouvir ${RADIO_TITLE}`
}
aria-pressed={status === 'playing'}
aria-busy={status === 'connecting'}
onClick={toggle}
>
{status === 'playing' ? <PauseIcon /> : <PlayIcon />}
</button>
</div>
</div>
<div
className={cn(
'mt-3 flex justify-end border-t border-(--campaign-line) pt-2',
compact ? undefined : 'md:hidden',
)}
>
<RadioShareControl variant="link" />
</div>
</>
)}
{/* A live stream never ends: `ended` is intentionally ignored (a stub
short media file must not flip the player back to idle). */}
<audio
ref={audioRef}
src={RADIO_STREAM_URL}
preload="none"
onPlaying={() => {
if (statusRef.current === 'connecting' || statusRef.current === 'playing') {
setStatus('playing')
}
}}
onError={() => {
if (statusRef.current === 'connecting' || statusRef.current === 'playing') {
stopPlayback('error')
}
}}
/>
</article>
)
}

View file

@ -1,8 +0,0 @@
/**
* S21/S22 — the shared focus-visible ring of the jingles surfaces (play,
* download and the "Ver todos" handoff): yellow outline over the dark red
* shadow, the same policy the campaign sections apply via `safe-focus` in the
* design artefact.
*/
export const JINGLE_FOCUS_RING =
'focus-visible:shadow-[0_0_0_2px_var(--pt-red-dark)] focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-yellow)'

View file

@ -32,15 +32,7 @@ const PRIMARY_ACTION_DISABLED = cn(
* the page open. The link itself keeps redirecting after a reload; this page
* only exists while nothing is on air.
*/
export const ShareLinkAnnouncement = ({
view,
showJingles = false,
showFotos = false,
}: {
view: ShareLinkAnnouncementView
showJingles?: boolean
showFotos?: boolean
}) => {
export const ShareLinkAnnouncement = ({ view }: { view: ShareLinkAnnouncementView }) => {
const live = useShareLinkLiveTarget(view.slug)
const isLive = live !== null
const hasEventDetails = Boolean(view.eventLabel || view.location)
@ -197,7 +189,7 @@ export const ShareLinkAnnouncement = ({
</article>
</main>
<CampaignFooter showJingles={showJingles} showFotos={showFotos} />
<CampaignFooter />
</div>
)
}

View file

@ -260,10 +260,10 @@ const archivePhotoPeopleLabel = (people: readonly string[]): string | null => {
return `${names.slice(0, -1).join(', ')} e ${names[names.length - 1]}`
}
const DOWNLOAD_PREFIX = 'jorge-solla-1313-foto'
const DOWNLOAD_PREFIX = 'jorge-solla-foto'
const FALLBACK_EXTENSION = 'jpg'
/** Legible download name (`jorge-solla-1313-foto-<id>.<ext>`), never the stored key. */
/** Legible download name (`jorge-solla-foto-<id>.<ext>`), never the stored key. */
const archivePhotoDownloadFilename = (id: number, filename?: string | null): string => {
const lastDot = filename?.lastIndexOf('.') ?? -1
const extension = lastDot > 0 ? filename?.slice(lastDot + 1).toLowerCase() : null

View file

@ -9,7 +9,7 @@ import { normalizeAbsoluteHttpUrl } from '@/lib/shareLink'
*/
export const DEFAULT_EVENT_DURATION_MS = 2 * 60 * 60 * 1000
const EVENT_TITLE_SUFFIX = ' - Jorge Solla 1313'
const EVENT_TITLE_SUFFIX = ' - Jorge Solla'
export type CalendarEventInput = {
title: string

Some files were not shown because too many files have changed in this diff Show more