mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
Compare commits
4 commits
8fa6432b7b
...
93bda37c29
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93bda37c29 | ||
|
|
5635e6522c | ||
|
|
5be50b182c | ||
|
|
5786bc57dc |
165 changed files with 2528 additions and 17899 deletions
|
|
@ -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`).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
1
docs/changelog/2026-10-22-s47.md
Normal file
1
docs/changelog/2026-10-22-s47.md
Normal 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.
|
||||
1
docs/changelog/2026-10-22-s48-peticao-pl-5594.md
Normal file
1
docs/changelog/2026-10-22-s48-peticao-pl-5594.md
Normal 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`.
|
||||
126
docs/plans/abaixo-assinado-pl-5594-2026-impl.md
Normal file
126
docs/plans/abaixo-assinado-pl-5594-2026-impl.md
Normal 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.
|
||||
56
docs/plans/json-ld-escape-peticao-impl.md
Normal file
56
docs/plans/json-ld-escape-peticao-impl.md
Normal 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.
|
||||
318
docs/plans/site-pos-eleicao-agradecimento-impl.md
Normal file
318
docs/plans/site-pos-eleicao-agradecimento-impl.md
Normal 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.
|
||||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
93
scripts/lib/petitionPl5594Content.mjs
Normal file
93
scripts/lib/petitionPl5594Content.mjs
Normal 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')
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
203
scripts/seed-petition-pl-5594.mjs
Normal file
203
scripts/seed-petition-pl-5594.mjs
Normal 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))
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
86
src/components/CampaignFlagsSection.tsx
Normal file
86
src/components/CampaignFlagsSection.tsx
Normal 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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
24
src/components/CampaignInternalPageIntro.tsx
Normal file
24
src/components/CampaignInternalPageIntro.tsx
Normal 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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
186
src/components/CampaignMobileMenu.tsx
Normal file
186
src/components/CampaignMobileMenu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)} />
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
125
src/components/CampaignPhotoSection.tsx
Normal file
125
src/components/CampaignPhotoSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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)">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)'
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue