From 100242037aede55ce172732210e7b2edf10df238 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 22 Oct 2026 01:34:09 -0300 Subject: [PATCH] =?UTF-8?q?feat(site):=20vira=20o=20site=20p=C3=BAblico=20?= =?UTF-8?q?para=20o=20mandato=20p=C3=B3s-elei=C3=A7=C3=A3o=20(S47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home abre no agradecimento pelos 152.049 votos com board 1+3, trajetória, bandeiras do mandato, galeria de fotos e rodapé institucional; chrome novo (header home/interno, lockup de nome, menu mobile acessível) e /artigos//fotos com título compacto. As superfícies de campanha saem do ar (308 de /conteudos, /jingles e /cards), junto com newsletter, estúdio público de cards e UI da Central/jingles; download de foto e .ics perdem o 1313. Sem migration, sem Consent novo e sem apagar dados do CMS. --- .agents/rules/codebase-map.mdc | 4 +- AGENTS-public.md | 4 +- docs/TECH-DEBT.md | 2 +- docs/changelog/2026-10-22-s47.md | 1 + .../site-pos-eleicao-agradecimento-impl.md | 318 +++ next.config.mjs | 13 + scripts/lib/e2e-affected-manifest.mjs | 127 +- .../(home)/CampaignCardsSection.tsx | 39 - .../(home)/CampaignNewsletterSection.tsx | 40 - src/app/(frontend)/(home)/cards/page.tsx | 75 - src/app/(frontend)/(home)/page.tsx | 277 +-- src/app/(frontend)/(home)/potencial/page.tsx | 15 +- src/app/(frontend)/[type]/page.tsx | 8 +- .../actions/submitCampaignNewsletter.ts | 50 - src/app/(frontend)/artigos/layout.tsx | 18 +- src/app/(frontend)/artigos/page.tsx | 86 +- .../conteudos/(catalog)/loading.tsx | 32 - .../(frontend)/conteudos/(catalog)/page.tsx | 194 -- .../conteudos/[slug]/frame/route.ts | 98 - .../conteudos/[slug]/midia/route.ts | 82 - .../(frontend)/conteudos/[slug]/not-found.tsx | 38 - src/app/(frontend)/conteudos/[slug]/page.tsx | 98 - src/app/(frontend)/conteudos/layout.tsx | 18 - src/app/(frontend)/fonts.ts | 18 - src/app/(frontend)/fotos/[id]/midia/route.ts | 2 +- src/app/(frontend)/fotos/encontre/page.tsx | 14 +- src/app/(frontend)/fotos/page.tsx | 26 +- src/app/(frontend)/jingles/layout.tsx | 17 - src/app/(frontend)/jingles/page.tsx | 91 - src/app/(frontend)/styles.css | 633 +----- src/components/CampaignCarousel.tsx | 208 -- src/components/CampaignContentCard.tsx | 94 +- src/components/CampaignContentCarousel.tsx | 131 -- src/components/CampaignContentSection.tsx | 144 +- src/components/CampaignFlagCard.tsx | 24 - src/components/CampaignFlagsSection.tsx | 86 + src/components/CampaignFooter.tsx | 197 +- src/components/CampaignHashScroll.tsx | 2 +- src/components/CampaignHero.tsx | 214 +- src/components/CampaignInternalPageIntro.tsx | 24 + src/components/CampaignLogoLink.tsx | 81 +- src/components/CampaignMobileMenu.tsx | 186 ++ src/components/CampaignNewsletterForm.tsx | 198 -- src/components/CampaignPageHeader.tsx | 146 +- src/components/CampaignPhotoSection.tsx | 125 ++ src/components/CampaignProblemCard.tsx | 44 - src/components/CampaignStorySection.tsx | 83 +- src/components/ContentShareButton.tsx | 11 +- src/components/PetitionForm.tsx | 2 +- src/components/RefreshRouteOnSave.tsx | 4 +- src/components/cards/CardComposer.tsx | 1225 ----------- src/components/cards/CardModelGallery.tsx | 147 -- src/components/cards/CardModelTile.tsx | 98 - src/components/cards/CardNameTileCanvas.tsx | 58 - src/components/cards/CardPreviewCanvas.tsx | 26 - src/components/cards/CardsStudio.tsx | 102 - src/components/cards/StateDeputySelect.tsx | 219 -- src/components/cards/cardCanvas.ts | 51 +- src/components/cards/cardCopy.ts | 29 - .../cards/cardPhotoHarmonyCanvas.ts | 78 - src/components/cards/useCardCutout.ts | 104 - .../conteudos/ContentCatalogCardItem.tsx | 111 - .../conteudos/ContentPieceActions.tsx | 47 - src/components/conteudos/ContentPieceCard.tsx | 191 -- .../conteudos/ContentPieceCardParts.tsx | 54 - .../conteudos/ContentPieceCatalog.tsx | 103 - .../conteudos/ContentPieceDetail.tsx | 108 - .../conteudos/ContentPieceFilters.tsx | 196 -- src/components/conteudos/ContentPieceHero.tsx | 79 - .../conteudos/ContentPieceHomeBoard.tsx | 316 --- .../conteudos/ContentPieceHomeCard.tsx | 130 -- .../conteudos/ContentPieceHomeFilterRow.tsx | 100 - .../conteudos/ContentPieceHomeSection.tsx | 47 - .../conteudos/ContentPieceMedia.tsx | 333 --- .../conteudos/ContentPiecePageHeader.tsx | 29 - .../conteudos/ContentPieceSearchMode.tsx | 82 - .../conteudos/ContentPieceShareSheet.tsx | 204 -- .../conteudos/ContentPieceStates.tsx | 125 -- .../conteudos/contentPieceClasses.ts | 77 - src/components/fotos/ArchivePhotoHero.tsx | 34 - .../fotos/ArchivePhotoPageHeader.tsx | 40 - src/components/fotos/ArchivePhotoStates.tsx | 4 +- src/components/jingles/JingleCards.tsx | 296 --- src/components/jingles/JingleEmptyState.tsx | 42 - src/components/jingles/JingleHomeSection.tsx | 105 - src/components/jingles/JingleIntro.tsx | 44 - src/components/jingles/JinglePlayer.tsx | 19 - src/components/jingles/RadioPlayer.tsx | 410 ---- src/components/jingles/focusRing.ts | 8 - .../shareLink/ShareLinkAnnouncement.tsx | 12 +- src/lib/archivePhotoPublicCatalog.ts | 4 +- src/lib/calendarEvent.ts | 2 +- src/lib/campaignConsentKeys.ts | 10 - src/lib/cardColinha.ts | 78 - src/lib/cardModels.ts | 6 +- src/lib/cardNameFit.ts | 159 -- src/lib/cardPhotoHarmony.ts | 163 -- src/lib/cardPhotoTransform.ts | 3 - src/lib/cardRender.ts | 390 ---- src/lib/contentPieceCatalog.ts | 10 +- src/lib/contentPieceHomeSelection.ts | 174 -- src/lib/contentPieceShare.ts | 54 - src/lib/jingle.ts | 96 - src/lib/municipalityProximity.ts | 2 +- src/lib/publicSite.ts | 28 + src/lib/radio.ts | 36 - src/lib/schemas/campaignNewsletter.ts | 25 - src/lib/schemas/contact.ts | 18 +- src/utilities/ai/themeSearchGuard.ts | 7 - src/utilities/campaignConsent.ts | 9 - src/utilities/content/contentPieceReads.ts | 14 +- .../content/contentPieceThemeSearch.ts | 127 -- src/utilities/jingleReads.ts | 80 - src/utilities/seo.ts | 2 +- tests/e2e/campaignHomePixel.e2e.spec.ts | 8 +- tests/e2e/campaignNewsletter.e2e.spec.ts | 286 --- tests/e2e/frontend.e2e.spec.ts | 1844 ++--------------- tests/e2e/frontendConteudos.e2e.spec.ts | 1046 ---------- tests/e2e/frontendFotos.e2e.spec.ts | 26 +- tests/e2e/frontendJingles.e2e.spec.ts | 391 ---- tests/e2e/frontendShareLink.e2e.spec.ts | 8 +- tests/int/archivePhotoPublicRead.int.spec.ts | 4 +- tests/int/contentEvents.int.spec.ts | 52 - tests/int/contentPiece.int.spec.ts | 498 +---- tests/int/jingle.int.spec.ts | 336 --- .../int/submitCampaignNewsletter.int.spec.ts | 158 -- .../archivePhotoPublicCatalog.unit.spec.ts | 2 +- tests/unit/calendarEvent.unit.spec.ts | 10 +- tests/unit/campaignCarousel.unit.spec.tsx | 162 -- tests/unit/campaignFooter.unit.spec.tsx | 48 + tests/unit/campaignHero.unit.spec.tsx | 58 +- tests/unit/campaignHome.unit.spec.tsx | 175 +- tests/unit/campaignMobileMenu.unit.spec.tsx | 75 + .../unit/campaignNewsletterLead.unit.spec.tsx | 98 - tests/unit/campaignPageHeader.unit.spec.tsx | 52 + tests/unit/cardColinha.unit.spec.ts | 161 -- tests/unit/cardModelTile.unit.spec.tsx | 53 - tests/unit/cardNameFit.unit.spec.ts | 172 -- tests/unit/cardPhotoHarmony.unit.spec.ts | 255 --- tests/unit/cardRender.unit.spec.ts | 554 ----- tests/unit/codebaseConventions.unit.spec.ts | 4 - .../unit/contentPieceHomeBoard.unit.spec.tsx | 401 ---- .../contentPieceHomeFilterRow.unit.spec.tsx | 54 - .../contentPieceHomeSelection.unit.spec.ts | 336 --- tests/unit/contentPieceMedia.unit.spec.tsx | 254 --- tests/unit/contentPieceShare.unit.spec.ts | 69 - tests/unit/e2eAffectedManifest.unit.spec.ts | 8 - tests/unit/imageDelivery.unit.spec.ts | 2 +- tests/unit/jingle.unit.spec.ts | 87 - tests/unit/jingleHomeSection.unit.spec.tsx | 81 - tests/unit/jinglePlayer.unit.spec.tsx | 261 --- tests/unit/radio.unit.spec.ts | 41 - tests/unit/radioPlayer.unit.spec.tsx | 272 --- tests/unit/siteMetadata.unit.spec.ts | 6 + 154 files changed, 1939 insertions(+), 17891 deletions(-) create mode 100644 docs/changelog/2026-10-22-s47.md create mode 100644 docs/plans/site-pos-eleicao-agradecimento-impl.md delete mode 100644 src/app/(frontend)/(home)/CampaignCardsSection.tsx delete mode 100644 src/app/(frontend)/(home)/CampaignNewsletterSection.tsx delete mode 100644 src/app/(frontend)/(home)/cards/page.tsx delete mode 100644 src/app/(frontend)/actions/submitCampaignNewsletter.ts delete mode 100644 src/app/(frontend)/conteudos/(catalog)/loading.tsx delete mode 100644 src/app/(frontend)/conteudos/(catalog)/page.tsx delete mode 100644 src/app/(frontend)/conteudos/[slug]/frame/route.ts delete mode 100644 src/app/(frontend)/conteudos/[slug]/midia/route.ts delete mode 100644 src/app/(frontend)/conteudos/[slug]/not-found.tsx delete mode 100644 src/app/(frontend)/conteudos/[slug]/page.tsx delete mode 100644 src/app/(frontend)/conteudos/layout.tsx delete mode 100644 src/app/(frontend)/jingles/layout.tsx delete mode 100644 src/app/(frontend)/jingles/page.tsx delete mode 100644 src/components/CampaignCarousel.tsx delete mode 100644 src/components/CampaignContentCarousel.tsx delete mode 100644 src/components/CampaignFlagCard.tsx create mode 100644 src/components/CampaignFlagsSection.tsx create mode 100644 src/components/CampaignInternalPageIntro.tsx create mode 100644 src/components/CampaignMobileMenu.tsx delete mode 100644 src/components/CampaignNewsletterForm.tsx create mode 100644 src/components/CampaignPhotoSection.tsx delete mode 100644 src/components/CampaignProblemCard.tsx delete mode 100644 src/components/cards/CardComposer.tsx delete mode 100644 src/components/cards/CardModelGallery.tsx delete mode 100644 src/components/cards/CardModelTile.tsx delete mode 100644 src/components/cards/CardNameTileCanvas.tsx delete mode 100644 src/components/cards/CardPreviewCanvas.tsx delete mode 100644 src/components/cards/CardsStudio.tsx delete mode 100644 src/components/cards/StateDeputySelect.tsx delete mode 100644 src/components/cards/cardCopy.ts delete mode 100644 src/components/cards/cardPhotoHarmonyCanvas.ts delete mode 100644 src/components/cards/useCardCutout.ts delete mode 100644 src/components/conteudos/ContentCatalogCardItem.tsx delete mode 100644 src/components/conteudos/ContentPieceActions.tsx delete mode 100644 src/components/conteudos/ContentPieceCard.tsx delete mode 100644 src/components/conteudos/ContentPieceCardParts.tsx delete mode 100644 src/components/conteudos/ContentPieceCatalog.tsx delete mode 100644 src/components/conteudos/ContentPieceDetail.tsx delete mode 100644 src/components/conteudos/ContentPieceFilters.tsx delete mode 100644 src/components/conteudos/ContentPieceHero.tsx delete mode 100644 src/components/conteudos/ContentPieceHomeBoard.tsx delete mode 100644 src/components/conteudos/ContentPieceHomeCard.tsx delete mode 100644 src/components/conteudos/ContentPieceHomeFilterRow.tsx delete mode 100644 src/components/conteudos/ContentPieceHomeSection.tsx delete mode 100644 src/components/conteudos/ContentPieceMedia.tsx delete mode 100644 src/components/conteudos/ContentPiecePageHeader.tsx delete mode 100644 src/components/conteudos/ContentPieceSearchMode.tsx delete mode 100644 src/components/conteudos/ContentPieceShareSheet.tsx delete mode 100644 src/components/conteudos/ContentPieceStates.tsx delete mode 100644 src/components/conteudos/contentPieceClasses.ts delete mode 100644 src/components/fotos/ArchivePhotoHero.tsx delete mode 100644 src/components/fotos/ArchivePhotoPageHeader.tsx delete mode 100644 src/components/jingles/JingleCards.tsx delete mode 100644 src/components/jingles/JingleEmptyState.tsx delete mode 100644 src/components/jingles/JingleHomeSection.tsx delete mode 100644 src/components/jingles/JingleIntro.tsx delete mode 100644 src/components/jingles/JinglePlayer.tsx delete mode 100644 src/components/jingles/RadioPlayer.tsx delete mode 100644 src/components/jingles/focusRing.ts delete mode 100644 src/lib/cardColinha.ts delete mode 100644 src/lib/cardNameFit.ts delete mode 100644 src/lib/cardPhotoHarmony.ts delete mode 100644 src/lib/cardRender.ts delete mode 100644 src/lib/contentPieceHomeSelection.ts delete mode 100644 src/lib/contentPieceShare.ts delete mode 100644 src/lib/jingle.ts create mode 100644 src/lib/publicSite.ts delete mode 100644 src/lib/radio.ts delete mode 100644 src/lib/schemas/campaignNewsletter.ts delete mode 100644 src/utilities/content/contentPieceThemeSearch.ts delete mode 100644 src/utilities/jingleReads.ts delete mode 100644 tests/e2e/campaignNewsletter.e2e.spec.ts delete mode 100644 tests/e2e/frontendConteudos.e2e.spec.ts delete mode 100644 tests/e2e/frontendJingles.e2e.spec.ts delete mode 100644 tests/int/jingle.int.spec.ts delete mode 100644 tests/int/submitCampaignNewsletter.int.spec.ts delete mode 100644 tests/unit/campaignCarousel.unit.spec.tsx create mode 100644 tests/unit/campaignFooter.unit.spec.tsx create mode 100644 tests/unit/campaignMobileMenu.unit.spec.tsx delete mode 100644 tests/unit/campaignNewsletterLead.unit.spec.tsx create mode 100644 tests/unit/campaignPageHeader.unit.spec.tsx delete mode 100644 tests/unit/cardColinha.unit.spec.ts delete mode 100644 tests/unit/cardModelTile.unit.spec.tsx delete mode 100644 tests/unit/cardNameFit.unit.spec.ts delete mode 100644 tests/unit/cardPhotoHarmony.unit.spec.ts delete mode 100644 tests/unit/cardRender.unit.spec.ts delete mode 100644 tests/unit/contentPieceHomeBoard.unit.spec.tsx delete mode 100644 tests/unit/contentPieceHomeFilterRow.unit.spec.tsx delete mode 100644 tests/unit/contentPieceHomeSelection.unit.spec.ts delete mode 100644 tests/unit/contentPieceMedia.unit.spec.tsx delete mode 100644 tests/unit/contentPieceShare.unit.spec.ts delete mode 100644 tests/unit/jingle.unit.spec.ts delete mode 100644 tests/unit/jingleHomeSection.unit.spec.tsx delete mode 100644 tests/unit/jinglePlayer.unit.spec.tsx delete mode 100644 tests/unit/radio.unit.spec.ts delete mode 100644 tests/unit/radioPlayer.unit.spec.tsx diff --git a/.agents/rules/codebase-map.mdc b/.agents/rules/codebase-map.mdc index ac01ac56..c7de623e 100644 --- a/.agents/rules/codebase-map.mdc +++ b/.agents/rules/codebase-map.mdc @@ -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//` — 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//` — 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`). diff --git a/AGENTS-public.md b/AGENTS-public.md index e403c650..827b4064 100644 --- a/AGENTS-public.md +++ b/AGENTS-public.md @@ -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=` is the non-canonical overlay and `/fotos/` keeps 404ing (no page per photo). Media goes through the same-origin proxy `/fotos//midia` (`?tamanho=grade` resizes on the fly, `?download=1` names `jorge-solla-1313-foto-.`, `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=` is the non-canonical overlay and `/fotos/` keeps 404ing (no page per photo). Media goes through the same-origin proxy `/fotos//midia` (`?tamanho=grade` resizes on the fly, `?download=1` names `jorge-solla-foto-.`, `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 diff --git a/docs/TECH-DEBT.md b/docs/TECH-DEBT.md index 5ed866f8..a51150c8 100644 --- a/docs/TECH-DEBT.md +++ b/docs/TECH-DEBT.md @@ -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 | diff --git a/docs/changelog/2026-10-22-s47.md b/docs/changelog/2026-10-22-s47.md new file mode 100644 index 00000000..16a8a269 --- /dev/null +++ b/docs/changelog/2026-10-22-s47.md @@ -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-`; 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. diff --git a/docs/plans/site-pos-eleicao-agradecimento-impl.md b/docs/plans/site-pos-eleicao-agradecimento-impl.md new file mode 100644 index 00000000..f0195752 --- /dev/null +++ b/docs/plans/site-pos-eleicao-agradecimento-impl.md @@ -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
1280 + 390] --> SH[CampaignPageHeader
+ CampaignMobileMenu] + SH --> HOME["(home)/page.tsx"] + HOME --> HERO[CampaignHero
Obrigado, Bahia · 152.049] + HOME --> BOARD[CampaignContentSection
1+3 editorial] + HOME --> STORY[CampaignStorySection] + HOME --> FLAGS[CampaignFlagsSection
3×2 hairlines] + HOME --> PHOTOS[CampaignPhotoSection
2 fotos + quote] + HOME --> EXT[Plenária · Selfie
condicionais/fail-closed] + HOME --> FOOT[CampaignFooter
institucional] + BOARD --> POSTS[getVisiblePosts + YouTube + Instagram] + BOARD --> SHARE[ContentShareButton
S4 preservado] + PHOTOS --> ARCH[getApprovedArchivePhotoItems] + SH --> ART["/artigos + /fotos
header + título creme"] + FOOT --> ART + R["/conteudos · /jingles · /cards"] --> RED[next.config.mjs
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 `
    ` 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-`, 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-` | +| `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 ` 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 ``, é 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. diff --git a/next.config.mjs b/next.config.mjs index fb48f7df..83a4e1b3 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -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: [ diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index 71a35b63..a5bd7a98 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.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', diff --git a/src/app/(frontend)/(home)/CampaignCardsSection.tsx b/src/app/(frontend)/(home)/CampaignCardsSection.tsx deleted file mode 100644 index 938bc468..00000000 --- a/src/app/(frontend)/(home)/CampaignCardsSection.tsx +++ /dev/null @@ -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> -) diff --git a/src/app/(frontend)/(home)/CampaignNewsletterSection.tsx b/src/app/(frontend)/(home)/CampaignNewsletterSection.tsx deleted file mode 100644 index ccb8889d..00000000 --- a/src/app/(frontend)/(home)/CampaignNewsletterSection.tsx +++ /dev/null @@ -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> -) diff --git a/src/app/(frontend)/(home)/cards/page.tsx b/src/app/(frontend)/(home)/cards/page.tsx deleted file mode 100644 index 4a489bf3..00000000 --- a/src/app/(frontend)/(home)/cards/page.tsx +++ /dev/null @@ -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} - /> - </> - ) -} diff --git a/src/app/(frontend)/(home)/page.tsx b/src/app/(frontend)/(home)/page.tsx index 5992120e..a1553f03 100644 --- a/src/app/(frontend)/(home)/page.tsx +++ b/src/app/(frontend)/(home)/page.tsx @@ -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 /> </> ) } diff --git a/src/app/(frontend)/(home)/potencial/page.tsx b/src/app/(frontend)/(home)/potencial/page.tsx index 096520f4..aed0493a 100644 --- a/src/app/(frontend)/(home)/potencial/page.tsx +++ b/src/app/(frontend)/(home)/potencial/page.tsx @@ -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 /> </> ) } diff --git a/src/app/(frontend)/[type]/page.tsx b/src/app/(frontend)/[type]/page.tsx index a3e24bf2..be805189 100644 --- a/src/app/(frontend)/[type]/page.tsx +++ b/src/app/(frontend)/[type]/page.tsx @@ -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) diff --git a/src/app/(frontend)/actions/submitCampaignNewsletter.ts b/src/app/(frontend)/actions/submitCampaignNewsletter.ts deleted file mode 100644 index f6d78dc1..00000000 --- a/src/app/(frontend)/actions/submitCampaignNewsletter.ts +++ /dev/null @@ -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 } - }) -} diff --git a/src/app/(frontend)/artigos/layout.tsx b/src/app/(frontend)/artigos/layout.tsx index e02dd015..ac0af7dc 100644 --- a/src/app/(frontend)/artigos/layout.tsx +++ b/src/app/(frontend)/artigos/layout.tsx @@ -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> ) } diff --git a/src/app/(frontend)/artigos/page.tsx b/src/app/(frontend)/artigos/page.tsx index 264628d5..bc6514c2 100644 --- a/src/app/(frontend)/artigos/page.tsx +++ b/src/app/(frontend)/artigos/page.tsx @@ -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"> diff --git a/src/app/(frontend)/conteudos/(catalog)/loading.tsx b/src/app/(frontend)/conteudos/(catalog)/loading.tsx deleted file mode 100644 index c8ec0516..00000000 --- a/src/app/(frontend)/conteudos/(catalog)/loading.tsx +++ /dev/null @@ -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> - ) -} diff --git a/src/app/(frontend)/conteudos/(catalog)/page.tsx b/src/app/(frontend)/conteudos/(catalog)/page.tsx deleted file mode 100644 index c55a9adf..00000000 --- a/src/app/(frontend)/conteudos/(catalog)/page.tsx +++ /dev/null @@ -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" - /> - </> - ) -} diff --git a/src/app/(frontend)/conteudos/[slug]/frame/route.ts b/src/app/(frontend)/conteudos/[slug]/frame/route.ts deleted file mode 100644 index ecbbd4f2..00000000 --- a/src/app/(frontend)/conteudos/[slug]/frame/route.ts +++ /dev/null @@ -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() -} diff --git a/src/app/(frontend)/conteudos/[slug]/midia/route.ts b/src/app/(frontend)/conteudos/[slug]/midia/route.ts deleted file mode 100644 index 97ecc042..00000000 --- a/src/app/(frontend)/conteudos/[slug]/midia/route.ts +++ /dev/null @@ -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 -} diff --git a/src/app/(frontend)/conteudos/[slug]/not-found.tsx b/src/app/(frontend)/conteudos/[slug]/not-found.tsx deleted file mode 100644 index dbf3c770..00000000 --- a/src/app/(frontend)/conteudos/[slug]/not-found.tsx +++ /dev/null @@ -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> - </> - ) -} diff --git a/src/app/(frontend)/conteudos/[slug]/page.tsx b/src/app/(frontend)/conteudos/[slug]/page.tsx deleted file mode 100644 index 1b62a260..00000000 --- a/src/app/(frontend)/conteudos/[slug]/page.tsx +++ /dev/null @@ -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" - /> - </> - ) -} diff --git a/src/app/(frontend)/conteudos/layout.tsx b/src/app/(frontend)/conteudos/layout.tsx deleted file mode 100644 index 1e48e813..00000000 --- a/src/app/(frontend)/conteudos/layout.tsx +++ /dev/null @@ -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> - ) -} diff --git a/src/app/(frontend)/fonts.ts b/src/app/(frontend)/fonts.ts index 0c229ada..65d4d974 100644 --- a/src/app/(frontend)/fonts.ts +++ b/src/app/(frontend)/fonts.ts @@ -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 diff --git a/src/app/(frontend)/fotos/[id]/midia/route.ts b/src/app/(frontend)/fotos/[id]/midia/route.ts index 38a862b9..ae4144a5 100644 --- a/src/app/(frontend)/fotos/[id]/midia/route.ts +++ b/src/app/(frontend)/fotos/[id]/midia/route.ts @@ -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. diff --git a/src/app/(frontend)/fotos/encontre/page.tsx b/src/app/(frontend)/fotos/encontre/page.tsx index 8414873c..a8631dc2 100644 --- a/src/app/(frontend)/fotos/encontre/page.tsx +++ b/src/app/(frontend)/fotos/encontre/page.tsx @@ -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)"> diff --git a/src/app/(frontend)/fotos/page.tsx b/src/app/(frontend)/fotos/page.tsx index a85a3990..a6f0ca81 100644 --- a/src/app/(frontend)/fotos/page.tsx +++ b/src/app/(frontend)/fotos/page.tsx @@ -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} diff --git a/src/app/(frontend)/jingles/layout.tsx b/src/app/(frontend)/jingles/layout.tsx deleted file mode 100644 index 187ab3b6..00000000 --- a/src/app/(frontend)/jingles/layout.tsx +++ /dev/null @@ -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> - ) -} diff --git a/src/app/(frontend)/jingles/page.tsx b/src/app/(frontend)/jingles/page.tsx deleted file mode 100644 index 4db29fec..00000000 --- a/src/app/(frontend)/jingles/page.tsx +++ /dev/null @@ -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" - /> - </> - ) -} diff --git a/src/app/(frontend)/styles.css b/src/app/(frontend)/styles.css index b2a8ca97..fdcda8b6 100644 --- a/src/app/(frontend)/styles.css +++ b/src/app/(frontend)/styles.css @@ -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; diff --git a/src/components/CampaignCarousel.tsx b/src/components/CampaignCarousel.tsx deleted file mode 100644 index 25ae74a4..00000000 --- a/src/components/CampaignCarousel.tsx +++ /dev/null @@ -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> - ) -} diff --git a/src/components/CampaignContentCard.tsx b/src/components/CampaignContentCard.tsx index 4a533827..f39af49d 100644 --- a/src/components/CampaignContentCard.tsx +++ b/src/components/CampaignContentCard.tsx @@ -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> )} diff --git a/src/components/CampaignContentCarousel.tsx b/src/components/CampaignContentCarousel.tsx deleted file mode 100644 index 2420d4d6..00000000 --- a/src/components/CampaignContentCarousel.tsx +++ /dev/null @@ -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> - ) -} diff --git a/src/components/CampaignContentSection.tsx b/src/components/CampaignContentSection.tsx index 2ffb55ab..da4f2e91 100644 --- a/src/components/CampaignContentSection.tsx +++ b/src/components/CampaignContentSection.tsx @@ -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> ) diff --git a/src/components/CampaignFlagCard.tsx b/src/components/CampaignFlagCard.tsx deleted file mode 100644 index 1e7ad306..00000000 --- a/src/components/CampaignFlagCard.tsx +++ /dev/null @@ -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> - ) -} diff --git a/src/components/CampaignFlagsSection.tsx b/src/components/CampaignFlagsSection.tsx new file mode 100644 index 00000000..faf5ca15 --- /dev/null +++ b/src/components/CampaignFlagsSection.tsx @@ -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> +) diff --git a/src/components/CampaignFooter.tsx b/src/components/CampaignFooter.tsx index 07b8f522..16f4dc0f 100644 --- a/src/components/CampaignFooter.tsx +++ b/src/components/CampaignFooter.tsx @@ -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> +) diff --git a/src/components/CampaignHashScroll.tsx b/src/components/CampaignHashScroll.tsx index dfb2c892..831eeb9d 100644 --- a/src/components/CampaignHashScroll.tsx +++ b/src/components/CampaignHashScroll.tsx @@ -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.) diff --git a/src/components/CampaignHero.tsx b/src/components/CampaignHero.tsx index dd85d369..92a1da32 100644 --- a/src/components/CampaignHero.tsx +++ b/src/components/CampaignHero.tsx @@ -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> ) diff --git a/src/components/CampaignInternalPageIntro.tsx b/src/components/CampaignInternalPageIntro.tsx new file mode 100644 index 00000000..602b6667 --- /dev/null +++ b/src/components/CampaignInternalPageIntro.tsx @@ -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> +) diff --git a/src/components/CampaignLogoLink.tsx b/src/components/CampaignLogoLink.tsx index 577b4ead..0cb8a116 100644 --- a/src/components/CampaignLogoLink.tsx +++ b/src/components/CampaignLogoLink.tsx @@ -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> + ) +} diff --git a/src/components/CampaignMobileMenu.tsx b/src/components/CampaignMobileMenu.tsx new file mode 100644 index 00000000..63902648 --- /dev/null +++ b/src/components/CampaignMobileMenu.tsx @@ -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> + ) +} diff --git a/src/components/CampaignNewsletterForm.tsx b/src/components/CampaignNewsletterForm.tsx deleted file mode 100644 index b6fd5f37..00000000 --- a/src/components/CampaignNewsletterForm.tsx +++ /dev/null @@ -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)} /> - ) -} diff --git a/src/components/CampaignPageHeader.tsx b/src/components/CampaignPageHeader.tsx index a586e0c8..69cfc0a8 100644 --- a/src/components/CampaignPageHeader.tsx +++ b/src/components/CampaignPageHeader.tsx @@ -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> + ) +} diff --git a/src/components/CampaignPhotoSection.tsx b/src/components/CampaignPhotoSection.tsx new file mode 100644 index 00000000..84fde3a9 --- /dev/null +++ b/src/components/CampaignPhotoSection.tsx @@ -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> + ) +} diff --git a/src/components/CampaignProblemCard.tsx b/src/components/CampaignProblemCard.tsx deleted file mode 100644 index 3ff602f5..00000000 --- a/src/components/CampaignProblemCard.tsx +++ /dev/null @@ -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> -) diff --git a/src/components/CampaignStorySection.tsx b/src/components/CampaignStorySection.tsx index 581956b2..d128fa23 100644 --- a/src/components/CampaignStorySection.tsx +++ b/src/components/CampaignStorySection.tsx @@ -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> diff --git a/src/components/ContentShareButton.tsx b/src/components/ContentShareButton.tsx index a9fe4b11..f2f75fc8 100644 --- a/src/components/ContentShareButton.tsx +++ b/src/components/ContentShareButton.tsx @@ -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) diff --git a/src/components/PetitionForm.tsx b/src/components/PetitionForm.tsx index bf39f619..0b34d56a 100644 --- a/src/components/PetitionForm.tsx +++ b/src/components/PetitionForm.tsx @@ -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 { diff --git a/src/components/RefreshRouteOnSave.tsx b/src/components/RefreshRouteOnSave.tsx index 8e6ad510..cab0e60a 100644 --- a/src/components/RefreshRouteOnSave.tsx +++ b/src/components/RefreshRouteOnSave.tsx @@ -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. diff --git a/src/components/cards/CardComposer.tsx b/src/components/cards/CardComposer.tsx deleted file mode 100644 index 496a30bd..00000000 --- a/src/components/cards/CardComposer.tsx +++ /dev/null @@ -1,1225 +0,0 @@ -'use client' - -import { - ArrowDownIcon, - ArrowLeftIcon, - ArrowRightIcon, - ArrowUpIcon, - CheckIcon, - MinusIcon, - PlusIcon, -} from 'lucide-react' -import { useEffect, useId, useMemo, useRef, useState, type PointerEvent } from 'react' - -import { - canvasToPngBlob, - cardFileName, - downloadBlob, - ensureCardFont, - loadCardImage, - loadCardPhoto, -} from '@/components/cards/cardCanvas' -import { - CARD_COLINHA_PRIVACY_NOTE, - CARD_HARMONY_HELP, - CARD_HARMONY_LABEL, - CARD_PHOTO_PRIVACY_NOTE, - CARD_PRIVACY_NOTE, -} from '@/components/cards/cardCopy' -import { CardPreviewCanvas } from '@/components/cards/CardPreviewCanvas' -import { StateDeputySelect } from '@/components/cards/StateDeputySelect' -import { useCardCutout } from '@/components/cards/useCardCutout' -import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/ui/Drawer' -import { COLINHA_ESTADUAL_LABEL, COLINHA_FONT_FAMILY } from '@/lib/cardColinha' -import { TEAM_CARD_NAME_SLOT, type CardModel, type CardRect } from '@/lib/cardModels' -import { fitCardName, type CardNameFit } from '@/lib/cardNameFit' -import { - CARD_PHOTO_MAX_ZOOM, - CARD_PHOTO_MIN_ZOOM, - cardPhotoTransformsEqual, - centerCardPhotoTransform, - panCardPhotoTransform, - zoomCardPhotoTransform, - type CardPhotoSize, - type CardPhotoTransform, -} from '@/lib/cardPhotoTransform' -import { - createCardMeasure, - renderColinhaCard, - renderNameCard, - renderPhotoCard, - renderTeamCard, -} from '@/lib/cardRender' -import { sendCardDownloadEvent } from '@/lib/contentEvents' -import { getStateDeputyCard, type StateDeputyCatalogEntry } from '@/lib/stateDeputyCatalog' - -const primaryButtonClassName = - 'inline-flex min-h-11 flex-1 items-center justify-center rounded-lg bg-(--pt-red) px-5 text-sm font-extrabold text-white transition hover:brightness-95 focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50' -const secondaryButtonClassName = - 'inline-flex min-h-11 flex-1 items-center justify-center rounded-lg border-2 border-(--campaign-line) bg-white px-5 text-sm font-extrabold text-(--pt-red) transition-colors hover:bg-(--campaign-band) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:outline-none' -const controlButtonClassName = - 'inline-flex size-11 items-center justify-center rounded-lg border border-(--campaign-line) bg-white text-(--campaign-ink) transition-colors hover:bg-(--campaign-band) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none' - -const NAME_TOO_LONG_MESSAGE = - 'Não foi possível encaixar esse nome no card. Use um nome mais curto, como você é chamado.' - -/** S15 design gate: the team preview width per state (S17 ready: 180px mobile / 240px desktop). */ -type TeamPreviewStage = 'idle' | 'processing' | 'ready' | 'result' -const TEAM_PREVIEW_WIDTH: Record<TeamPreviewStage, string> = { - idle: 'max-w-[10.75rem]', - processing: 'max-w-[9rem]', - ready: 'max-w-[11.25rem] sm:max-w-[15rem]', - result: 'max-w-[11.875rem]', -} - -/** - * S30 — the dobradinha preview gets the gate's larger stage (scene 4: 230px - * desktop; scene 5: 205px mobile) while the S15 tokens above stay untouched. - * `selected` is the chosen-but-photoless state, which the gate draws at the same - * prominence as the result; the pre-selection and the too-long error stay - * compact (scenes 3 and 4-right). - */ -type StateDeputyPreviewStage = TeamPreviewStage | 'selected' -const STATE_DEPUTY_PREVIEW_WIDTH: Record<StateDeputyPreviewStage, string> = { - idle: 'max-w-[10.75rem]', - processing: 'max-w-[9rem]', - ready: 'max-w-[12.8125rem] sm:max-w-[15rem]', - result: 'max-w-[12.8125rem] sm:max-w-[14.375rem]', - selected: 'max-w-[12.8125rem] sm:max-w-[14.375rem]', -} - -/** - * S31/S34 — the colinha stages of the design gate: the delivered art while the - * estadual is unchosen (scene 1: 236px desktop / scene 2: 248px mobile) and the - * scene 3/5 stage after the pick (405px desktop, 300px mobile). - */ -const COLINHA_PREVIEW_WIDTH: Record<'idle' | 'selected', string> = { - idle: 'max-w-[15.5rem] sm:max-w-[14.75rem]', - selected: 'max-w-[18.75rem] sm:max-w-[25.3125rem]', -} - -const TeamNameField = ({ - id, - value, - onChange, - error, - inputClassName, -}: { - id: string - value: string - onChange: (value: string) => void - error: boolean - inputClassName: string -}) => ( - <div> - <label htmlFor={id} className="block text-sm font-bold text-(--campaign-ink)"> - Seu nome - </label> - <input - id={id} - type="text" - value={value} - onChange={(event) => onChange(event.target.value)} - autoComplete="off" - maxLength={60} - placeholder="Como você é chamado" - aria-invalid={error} - className={inputClassName} - /> - <p className="mt-2 text-xs leading-5 text-(--campaign-muted)"> - Vai aparecer em caixa alta depois de “TIME DE”. Use um nome curto e conhecido. - </p> - {error ? ( - <p role="alert" className="mt-2 text-sm leading-5 font-semibold text-(--pt-red)"> - {NAME_TOO_LONG_MESSAGE} - </p> - ) : null} - </div> -) - -/** - * S17 — the tone-harmony switch of the ready state. Native `role="switch"` (the - * design gate's semantics; the studio has no shadcn Switch) with the whole row - * as the target and the label/help always visible. - */ -const TeamHarmonySwitch = ({ enabled, onToggle }: { enabled: boolean; onToggle: () => void }) => { - const helpId = useId() - - return ( - <button - type="button" - role="switch" - aria-checked={enabled} - aria-describedby={helpId} - onClick={onToggle} - className="mt-4 flex min-h-11 w-full items-start gap-3 rounded-lg border border-(--campaign-line) bg-white p-3 text-left focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:outline-none" - > - <span - aria-hidden="true" - className={`relative mt-1.5 inline-block h-[26px] w-11 flex-none rounded-full transition-colors ${ - enabled ? 'bg-(--pt-red)' : 'bg-[#cfcac7]' - }`} - > - <span - className={`absolute top-[3px] size-5 rounded-full bg-white shadow-[0_1px_2px_rgb(0_0_0/25%)] transition-[left] ${ - enabled ? 'left-[21px]' : 'left-[3px]' - }`} - /> - </span> - <span className="min-w-0"> - <span className="block text-sm font-bold text-(--campaign-ink)">{CARD_HARMONY_LABEL}</span> - <span id={helpId} className="mt-0.5 block text-xs leading-5 text-(--campaign-muted)"> - {CARD_HARMONY_HELP} - </span> - </span> - </button> - ) -} - -type CardComposerProps = { - model: CardModel - shell: 'dialog' | 'drawer' - fontFamily: string - onClose: () => void -} - -export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposerProps) => { - const [step, setStep] = useState<'compose' | 'result'>('compose') - const [name, setName] = useState('') - const [nameFit, setNameFit] = useState<CardNameFit | null>(null) - const [photo, setPhoto] = useState<HTMLImageElement | null>(null) - const [photoTransform, setPhotoTransform] = useState<CardPhotoTransform | null>(null) - const [photoError, setPhotoError] = useState<string | null>(null) - const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading') - const [baseImage, setBaseImage] = useState<HTMLImageElement | null>(null) - const [overlayImage, setOverlayImage] = useState<HTMLImageElement | null>(null) - const [previewImage, setPreviewImage] = useState<HTMLImageElement | null>(null) - const [downloadError, setDownloadError] = useState<string | null>(null) - const [isDownloading, setIsDownloading] = useState(false) - /** S17 — LIGADO by default; resets only when the composer unmounts (no persistence). */ - const [harmonyEnabled, setHarmonyEnabled] = useState(true) - /** S30 — the state deputy of the dobradinha model (name/number/art pair). */ - const [selectedDeputySlug, setSelectedDeputySlug] = useState<string | null>(null) - const [deputyImages, setDeputyImages] = useState<{ - slug: string - photos: HTMLImageElement - base: HTMLImageElement - } | null>(null) - const [deputyAssetError, setDeputyAssetError] = useState(false) - /** Bumped when the same deputy is re-picked after a pair load failure. */ - const [deputyReloadToken, setDeputyReloadToken] = useState(0) - - const canvasRef = useRef<HTMLCanvasElement>(null) - const fileInputRef = useRef<HTMLInputElement>(null) - const dragRef = useRef<{ pointerId: number; x: number; y: number } | null>(null) - const nameInputId = useId() - const zoomInputId = useId() - - const photoWindow = model.photoWindow - /** S33 — the model's adjustment policy: the two team models are position-free. */ - const photoPosition = model.photoPosition ?? 'bounded' - const isNameModel = model.kind === 'name' - const isTeamModel = model.kind === 'team' - const isColinhaModel = model.kind === 'colinha' - const isStateDeputyModel = isTeamModel && model.stateDeputyPicker === true - const selectedDeputy = selectedDeputySlug - ? (getStateDeputyCard(selectedDeputySlug) ?? null) - : null - const deputyPairReady = selectedDeputy !== null && deputyImages?.slug === selectedDeputy.slug - // S30 — the tone reference is the selected deputy's group art, frozen when the - // cutout starts (switching the deputy later keeps the processed photo). - const cutout = useCardCutout( - photoWindow, - isStateDeputyModel ? selectedDeputy?.photosSrc : isTeamModel ? model.assetSrc : undefined, - ) - const cutoutState = cutout.state - - const teamProcessing = cutoutState.status === 'processing' ? cutoutState : null - const teamError = cutoutState.status === 'error' ? cutoutState : null - const teamReady = cutoutState.status === 'ready' ? cutoutState : null - - const photoSize = useMemo<CardPhotoSize | null>( - () => (photo ? { width: photo.naturalWidth, height: photo.naturalHeight } : null), - [photo], - ) - const transformSourceSize = useMemo<CardPhotoSize | null>(() => { - if (!isTeamModel) return photoSize - return teamReady ? { width: teamReady.width, height: teamReady.height } : null - }, [isTeamModel, photoSize, teamReady]) - - /** - * The cutout carries the initial framing; local state takes over after the - * first pan/zoom. Deriving (instead of mirroring through an effect) keeps the - * first ready paint composed — no bare-base frame. - */ - const effectiveTransform = isTeamModel - ? (photoTransform ?? teamReady?.transform ?? null) - : photoTransform - - // Assets + font load once per model: typing/panning must not flip the preview - // back into the loading state (flicker) nor re-announce the live region. - useEffect(() => { - let cancelled = false - setLoadState('loading') - setBaseImage(null) - setOverlayImage(null) - setPreviewImage(null) - - const load = async () => { - try { - const fontReady = await ensureCardFont(fontFamily) - if (!fontReady) throw new Error('card-font-unavailable') - // S30 — the dobradinha model loads its art pair per selection (below); - // the static defaults stay unused so no illustrative JULIO art flashes - // before the choice. S34 — the colinha draws the approved art as the - // base and carries no `previewSrc` (the tile falls back to `assetSrc`). - const [image, overlay, preview] = await Promise.all([ - isStateDeputyModel ? Promise.resolve(null) : loadCardImage(model.assetSrc), - !isStateDeputyModel && model.overlaySrc - ? loadCardImage(model.overlaySrc) - : Promise.resolve(null), - model.previewSrc ? loadCardImage(model.previewSrc) : Promise.resolve(null), - ]) - if (cancelled) return - setBaseImage(image) - setOverlayImage(overlay) - setPreviewImage(preview) - setLoadState('ready') - } catch { - if (!cancelled) setLoadState('error') - } - } - - void load() - return () => { - cancelled = true - } - }, [ - isStateDeputyModel, - isColinhaModel, - model.assetSrc, - model.overlaySrc, - model.previewSrc, - fontFamily, - ]) - - /** - * S30 — the selected deputy's pair swaps in place: the previous art stays - * painted until both files decode (no loading state, no flicker) and the name - * and cutout states are untouched by the swap. - */ - useEffect(() => { - if (!isStateDeputyModel || !selectedDeputy) return - - let cancelled = false - setDeputyAssetError(false) - - const load = async () => { - try { - const [photos, base] = await Promise.all([ - loadCardImage(selectedDeputy.photosSrc), - loadCardImage(selectedDeputy.baseSrc), - ]) - if (cancelled) return - setDeputyImages({ slug: selectedDeputy.slug, photos, base }) - } catch { - if (!cancelled) setDeputyAssetError(true) - } - } - - void load() - return () => { - cancelled = true - } - }, [isStateDeputyModel, selectedDeputy, deputyReloadToken]) - - useEffect(() => { - const ctx = canvasRef.current?.getContext('2d') - if (!ctx || loadState !== 'ready') return - - const measure = createCardMeasure(ctx, fontFamily) - - if (isNameModel) { - if (!baseImage) return - ctx.clearRect(0, 0, model.width, model.height) - setNameFit(renderNameCard(ctx, model, { image: baseImage, name, fontFamily, measure })) - return - } - - if (isColinhaModel) { - // S34 — the approved art is the base; only the estadual row is drawn over - // it (or nothing, before a pick). No name, photo or cutout enters this flow. - if (!baseImage) return - ctx.clearRect(0, 0, model.width, model.height) - renderColinhaCard(ctx, model, { - image: baseImage, - deputy: selectedDeputy, - fontFamily: COLINHA_FONT_FAMILY, - measure: createCardMeasure(ctx, COLINHA_FONT_FAMILY, 900), - }) - return - } - - if (isTeamModel) { - // S30 — the dobradinha pair comes from the selected catalog entry: `photos` - // is the FOTOS background (drawn under the cutout) and `base` is the front - // BASE overlay (drawn above it) — the opposite of the S15 masters. - const artBase = isStateDeputyModel ? deputyImages?.photos : baseImage - const artOverlay = isStateDeputyModel ? deputyImages?.base : overlayImage - - if (isStateDeputyModel && !selectedDeputy) { - // Before the choice: the approved example art (no banners), exactly the - // gate's scene 3 placeholder. - ctx.clearRect(0, 0, model.width, model.height) - setNameFit(fitCardName(name, measure, TEAM_CARD_NAME_SLOT)) - if (previewImage) ctx.drawImage(previewImage, 0, 0, model.width, model.height) - return - } - - // Art not decoded yet (first selection or a swap in flight): keep the - // current frame painted instead of clearing to a bare canvas. - if (!artBase || !artOverlay) return - - ctx.clearRect(0, 0, model.width, model.height) - - if (teamReady && effectiveTransform && photoWindow) { - const result = renderTeamCard(ctx, model, { - base: artBase, - overlay: artOverlay, - subject: { - photo: harmonyEnabled && teamReady.harmonized ? teamReady.harmonized : teamReady.canvas, - photoSize: { width: teamReady.width, height: teamReady.height }, - transform: effectiveTransform, - }, - window: photoWindow, - name, - fontFamily, - measure, - }) - setNameFit(result.fit) - if (result.transform && !cardPhotoTransformsEqual(result.transform, effectiveTransform)) { - setPhotoTransform(result.transform) - } - return - } - - if (isStateDeputyModel) { - // Chosen but no photo yet: the art shows as-is — the window stays empty - // until the visitor photo lands (no drawn placeholder). - if (!photoWindow) return - setNameFit( - renderTeamCard(ctx, model, { - base: artBase, - overlay: artOverlay, - subject: null, - window: photoWindow, - name, - fontFamily, - measure, - }).fit, - ) - return - } - - setNameFit(fitCardName(name, measure, TEAM_CARD_NAME_SLOT)) - const idleImage = cutoutState.status === 'idle' && previewImage ? previewImage : artBase - ctx.drawImage(idleImage, 0, 0, model.width, model.height) - return - } - - if (!baseImage) return - ctx.clearRect(0, 0, model.width, model.height) - - if (photo && effectiveTransform && photoWindow && photoSize) { - const clamped = renderPhotoCard(ctx, { - photo, - frame: baseImage, - photoSize, - frameSize: { width: model.width, height: model.height }, - window: photoWindow, - transform: effectiveTransform, - }) - if (!cardPhotoTransformsEqual(clamped, effectiveTransform)) { - setPhotoTransform(clamped) - } - return - } - - if (photoWindow) { - ctx.fillStyle = '#ded8d2' - ctx.fillRect(0, 0, model.width, model.height) - ctx.drawImage(baseImage, 0, 0, model.width, model.height) - } - }, [ - baseImage, - overlayImage, - previewImage, - loadState, - isNameModel, - isTeamModel, - isColinhaModel, - isStateDeputyModel, - selectedDeputy, - deputyImages, - teamReady, - harmonyEnabled, - cutoutState.status, - model, - name, - fontFamily, - photo, - photoSize, - effectiveTransform, - photoWindow, - ]) - - const handlePhotoFile = async (file: File | undefined) => { - if (!file || !photoWindow) return - setPhotoError(null) - - try { - const image = await loadCardPhoto(file) - setPhoto(image) - setPhotoTransform( - centerCardPhotoTransform( - { width: image.naturalWidth, height: image.naturalHeight }, - photoWindow, - ), - ) - } catch { - setPhoto(null) - setPhotoTransform(null) - setPhotoError('Não foi possível usar esta foto. Escolha outra imagem e tente novamente.') - } finally { - if (fileInputRef.current) fileInputRef.current.value = '' - } - } - - const handleTeamPhotoFile = (file: File | undefined) => { - if (!file) return - setPhotoError(null) - setPhotoTransform(null) - void cutout.start(file) - if (fileInputRef.current) fileInputRef.current.value = '' - } - - /** S30 — the swap keeps name and processed photo (only the art pair changes). */ - const handleSelectDeputy = (card: StateDeputyCatalogEntry) => { - // Re-picking the same entry after a pair failure must retry the load. - if (card.slug === selectedDeputySlug) setDeputyReloadToken((token) => token + 1) - setSelectedDeputySlug(card.slug) - } - - /** Applies a pan/zoom update over the derived or user-owned transform. */ - const withTransform = ( - update: ( - transform: CardPhotoTransform, - size: CardPhotoSize, - window: CardRect, - ) => CardPhotoTransform, - ) => { - if (!transformSourceSize || !photoWindow) return - setPhotoTransform((current) => { - const base = current ?? (isTeamModel ? (teamReady?.transform ?? null) : null) - return base ? update(base, transformSourceSize, photoWindow) : base - }) - } - - const handlePointerDown = (event: PointerEvent<HTMLCanvasElement>) => { - if (!transformSourceSize || !photoWindow) return - dragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY } - event.currentTarget.setPointerCapture(event.pointerId) - } - - const handlePointerMove = (event: PointerEvent<HTMLCanvasElement>) => { - const drag = dragRef.current - const canvas = canvasRef.current - if (!drag || drag.pointerId !== event.pointerId || !canvas) return - - const scale = model.width / Math.max(1, canvas.getBoundingClientRect().width) - const dx = (event.clientX - drag.x) * scale - const dy = (event.clientY - drag.y) * scale - dragRef.current = { ...drag, x: event.clientX, y: event.clientY } - withTransform((transform, size, window) => - panCardPhotoTransform(transform, size, window, dx, dy, { position: photoPosition }), - ) - } - - const handlePointerEnd = () => { - dragRef.current = null - } - - const panBy = (dx: number, dy: number) => { - withTransform((transform, size, window) => - panCardPhotoTransform(transform, size, window, dx, dy, { position: photoPosition }), - ) - } - - const zoomTo = (zoom: number) => { - withTransform((transform, size, window) => - zoomCardPhotoTransform(transform, size, window, zoom, undefined, { position: photoPosition }), - ) - } - - const handleDownload = async () => { - const canvas = canvasRef.current - if (!canvas) return - - setIsDownloading(true) - setDownloadError(null) - try { - const blob = await canvasToPngBlob(canvas) - downloadBlob(blob, cardFileName(model.id)) - // S32 — anonymous download count (fire-and-forget, never blocks/throws); - // the state-deputy slug rides along on the models that have a picker. - sendCardDownloadEvent(model.id, selectedDeputy?.slug ?? null) - } catch { - setDownloadError('Não foi possível gerar o arquivo agora. Tente de novo.') - } finally { - setIsDownloading(false) - } - } - - const nameCanAdvance = nameFit?.ok === true - const canAdvance = isColinhaModel - ? selectedDeputy !== null - : isNameModel - ? nameCanAdvance - : isStateDeputyModel - ? selectedDeputy !== null && deputyPairReady && teamReady !== null && nameCanAdvance - : isTeamModel - ? teamReady !== null && nameCanAdvance - : photo !== null - const nameError = - (isNameModel || isTeamModel) && name.trim().length > 0 && nameFit !== null && !nameFit.ok - const adjustable = isStateDeputyModel - ? deputyPairReady && teamReady !== null - : isTeamModel - ? teamReady !== null - : photo !== null - const title = (() => { - if (step === 'result') return 'Seu card está pronto para compartilhar.' - if (isNameModel) return 'Personalize com seu nome' - if (isColinhaModel) { - if (!selectedDeputy) return 'Escolha seu estadual' - return shell === 'dialog' ? 'Pronta para levar com você' : 'Pronta para baixar' - } - if (isStateDeputyModel && !selectedDeputy) return 'Escolha sua dobradinha' - if (!isTeamModel) return 'Enquadre sua foto' - if (teamProcessing) return 'Preparando sua foto' - if (teamError) return 'Vamos tentar outra vez' - if (nameError) return 'Encurte o nome' - if (teamReady) return 'Confira seu card' - if (isStateDeputyModel) return 'Agora coloque seu nome' - return 'Entre para o time' - })() - const photoHeaderDescription = - step === 'compose' && !isNameModel && !isTeamModel && !isColinhaModel - ? 'Arraste para posicionar e use os controles para aproximar ou ajustar.' - : null - const stateDeputyHeaderDescription = - isStateDeputyModel && step === 'compose' && !selectedDeputy - ? 'Escolha seu estadual da dobradinha.' - : null - /** - * S31 — the colinha description lives only in the dialog: the gate's mobile - * drawer (scene 5) carries eyebrow + title only, so the compact preview keeps - * the selector in the first viewport. - */ - const colinhaHeaderDescription = - isColinhaModel && step === 'compose' && shell === 'dialog' - ? selectedDeputy - ? 'Confira o estadual escolhido. As demais escolhas já vêm preenchidas no modelo oficial.' - : 'A linha de deputado estadual será preenchida na hora.' - : null - const headerDescription = - photoHeaderDescription ?? stateDeputyHeaderDescription ?? colinhaHeaderDescription - const teamBodyNotice = - isTeamModel && step === 'compose' && teamReady && !nameError - ? 'O recorte já foi centralizado. Se precisar, arraste a foto ou use os controles.' - : null - const ShellDescription = shell === 'dialog' ? DialogDescription : DrawerDescription - const eyebrow = isColinhaModel - ? 'Minha colinha' - : isTeamModel - ? step === 'result' - ? 'Tudo certo' - : teamError - ? 'Falha no recorte' - : nameError - ? 'Nome muito longo' - : isStateDeputyModel - ? selectedDeputy - ? 'Estadual escolhido' - : 'Time do estadual' - : 'Time de você' - : null - const eyebrowNode = eyebrow ? ( - <p className="text-[10px] font-black tracking-[0.1em] text-(--pt-red) uppercase sm:text-xs"> - {eyebrow} - </p> - ) : null - - // The dialog header takes the available row width so the close button - // sits on the right edge (S35 design gate). The drawer header defaults to - // centered + `shrink-0`, so `text-left!` + `flex-1` port the gate - // (left-aligned, wrapped header) for every model in the shared composer. - const header = - shell === 'dialog' ? ( - <DialogHeader className="min-w-0 flex-1 text-left"> - {eyebrowNode} - <DialogTitle className="text-xl font-black tracking-[-0.01em] text-balance"> - {title} - </DialogTitle> - {headerDescription ? <ShellDescription>{headerDescription}</ShellDescription> : null} - </DialogHeader> - ) : ( - <DrawerHeader className="min-w-0 flex-1 text-left!"> - {eyebrowNode} - <DrawerTitle className="text-lg font-black tracking-[-0.01em] text-balance"> - {title} - </DrawerTitle> - {headerDescription ? <ShellDescription>{headerDescription}</ShellDescription> : null} - </DrawerHeader> - ) - - const teamPreviewStage: TeamPreviewStage = - step === 'result' ? 'result' : teamReady ? 'ready' : teamProcessing ? 'processing' : 'idle' - const stateDeputyPreviewStage: StateDeputyPreviewStage = - teamPreviewStage === 'idle' && selectedDeputy && !nameError ? 'selected' : teamPreviewStage - const previewWidthClassName = isColinhaModel - ? selectedDeputy - ? COLINHA_PREVIEW_WIDTH.selected - : COLINHA_PREVIEW_WIDTH.idle - : isStateDeputyModel - ? STATE_DEPUTY_PREVIEW_WIDTH[stateDeputyPreviewStage] - : isTeamModel - ? TEAM_PREVIEW_WIDTH[teamPreviewStage] - : 'max-w-[22rem]' - /** - * S34 designer critique (c) — the filled desktop state follows the gate's - * scene 3 composition: the full 9:16 preview beside the controls (two-column - * grid, wider dialog), never clipped by the footer. The empty state keeps the - * compact dialog of scene 1. The `data-colinha-filled` attribute below is the - * contract that widens the dialog in `CardsStudio` (`has-[[...]]`). - */ - const colinhaFilledDialog = isColinhaModel && shell === 'dialog' && selectedDeputy !== null - /** - * S34 — caps the colinha preview by the viewport so the whole 9:16 fits - * between the dialog chrome (`max-h-[92dvh]` in `CardsStudio` minus ~224px of - * header/footer); on tall viewports the width reaches the gate's 405px. - */ - const colinhaPreviewMaxHeight = 'max-h-[calc(92dvh-14rem)]' - - const nameInputClassName = `mt-2 h-11 w-full rounded-lg bg-white px-3 text-base text-(--campaign-ink) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none ${ - nameError - ? 'border-2 border-(--pt-red)' - : 'border border-(--field-border) focus-visible:border-(--pt-red)' - }` - - const zoomAndPanControls = effectiveTransform ? ( - <> - <div className="flex items-center gap-3"> - <label htmlFor={zoomInputId} className="text-sm font-bold text-(--campaign-ink)"> - Zoom - </label> - <button - type="button" - aria-label="Diminuir zoom" - onClick={() => zoomTo(effectiveTransform.zoom - 0.1)} - className={controlButtonClassName} - > - <MinusIcon className="size-4" aria-hidden="true" /> - </button> - <input - id={zoomInputId} - type="range" - min={CARD_PHOTO_MIN_ZOOM} - max={CARD_PHOTO_MAX_ZOOM} - step={0.05} - value={effectiveTransform.zoom} - onChange={(event) => zoomTo(Number(event.target.value))} - className="h-11 w-full flex-1 accent-(--pt-red)" - /> - <button - type="button" - aria-label="Aumentar zoom" - onClick={() => zoomTo(effectiveTransform.zoom + 0.1)} - className={controlButtonClassName} - > - <PlusIcon className="size-4" aria-hidden="true" /> - </button> - </div> - - <div className="mt-4"> - <p className="text-sm font-bold text-(--campaign-ink)">Ajuste fino</p> - <div className="mt-2 grid grid-cols-4 gap-2"> - <button - type="button" - aria-label="Mover foto para a esquerda" - onClick={() => panBy(-12, 0)} - className={controlButtonClassName} - > - <ArrowLeftIcon className="size-4" aria-hidden="true" /> - </button> - <button - type="button" - aria-label="Mover foto para cima" - onClick={() => panBy(0, -12)} - className={controlButtonClassName} - > - <ArrowUpIcon className="size-4" aria-hidden="true" /> - </button> - <button - type="button" - aria-label="Mover foto para baixo" - onClick={() => panBy(0, 12)} - className={controlButtonClassName} - > - <ArrowDownIcon className="size-4" aria-hidden="true" /> - </button> - <button - type="button" - aria-label="Mover foto para a direita" - onClick={() => panBy(12, 0)} - className={controlButtonClassName} - > - <ArrowRightIcon className="size-4" aria-hidden="true" /> - </button> - </div> - </div> - </> - ) : null - - // S23 design gate (scene 02): the drawer body carries a scroll tail so the - // team notice leaves the viewport by the end of the scroll. - const bodyScrollClassName = `min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pt-4 ${ - teamBodyNotice && shell === 'drawer' ? 'pb-14' : 'pb-5' - }${colinhaFilledDialog ? ' sm:grid sm:grid-cols-[430px_1fr] sm:items-start sm:gap-8' : ''}` - - return ( - <div - data-colinha-filled={colinhaFilledDialog ? 'true' : undefined} - className="flex min-h-0 flex-1 flex-col bg-(--background) text-(--foreground)" - > - <div className="flex items-start gap-3 px-5 pt-5"> - {header} - <button - type="button" - onClick={onClose} - aria-label="Fechar" - className="inline-flex size-11 shrink-0 items-center justify-center rounded-lg text-xl leading-none text-(--campaign-muted) transition-colors hover:bg-(--campaign-band) hover:text-(--campaign-ink) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none" - > - × - </button> - </div> - - <div className={bodyScrollClassName}> - <p className="sr-only" role="status" aria-live="polite"> - {loadState === 'loading' ? 'Carregando prévia…' : ''} - </p> - - {teamBodyNotice ? ( - <ShellDescription - className={`mx-auto text-sm leading-5 text-(--campaign-muted) ${ - shell === 'dialog' ? 'mb-4 max-w-[420px]' : 'mb-3 max-w-[350px]' - }`} - > - {teamBodyNotice} - </ShellDescription> - ) : null} - - <div - className={`relative mx-auto w-full ${previewWidthClassName} ${adjustable ? 'touch-none' : ''}${ - colinhaFilledDialog ? ' sm:col-start-1 sm:row-start-1' : '' - }`} - tabIndex={adjustable ? 0 : undefined} - role={adjustable ? 'group' : undefined} - aria-label={adjustable ? 'Ajuste da foto' : undefined} - onKeyDown={ - adjustable - ? (event) => { - const step = event.shiftKey ? 48 : 12 - const deltas: Record<string, [number, number]> = { - ArrowLeft: [-step, 0], - ArrowRight: [step, 0], - ArrowUp: [0, -step], - ArrowDown: [0, step], - } - const delta = deltas[event.key] - if (!delta) return - event.preventDefault() - panBy(delta[0], delta[1]) - } - : undefined - } - > - <CardPreviewCanvas - model={model} - canvasRef={canvasRef} - className={`mx-auto block h-auto w-auto max-w-full rounded-lg border border-(--campaign-line) shadow-sm transition-opacity ${ - isColinhaModel ? `${colinhaPreviewMaxHeight} ` : 'max-h-[38dvh] ' - }${loadState === 'loading' ? 'opacity-60' : 'opacity-100'}`} - onPointerDown={adjustable ? handlePointerDown : undefined} - onPointerMove={adjustable ? handlePointerMove : undefined} - onPointerUp={adjustable ? handlePointerEnd : undefined} - onPointerCancel={adjustable ? handlePointerEnd : undefined} - /> - </div> - - {loadState === 'error' ? ( - <p role="alert" className="mt-3 text-sm font-semibold text-(--pt-red)"> - Não foi possível carregar o modelo. Recarregue a página e tente de novo. - </p> - ) : null} - - {step === 'compose' && isStateDeputyModel ? ( - <div> - {!selectedDeputy ? ( - <p className="mt-3 text-center text-[11px] leading-4 text-(--campaign-muted)"> - A silhueta marca o lugar da sua foto. O card final usa a arte do estadual escolhido. - </p> - ) : null} - <StateDeputySelect selected={selectedDeputy} onSelect={handleSelectDeputy} /> - {deputyAssetError ? ( - <p role="alert" className="mt-2 text-sm font-semibold text-(--pt-red)"> - Não foi possível carregar a arte deste estadual. Escolha outro e tente de novo. - </p> - ) : null} - </div> - ) : null} - - {step === 'compose' && isColinhaModel ? ( - <div className={colinhaFilledDialog ? 'sm:col-start-2 sm:row-start-1' : undefined}> - <StateDeputySelect selected={selectedDeputy} onSelect={handleSelectDeputy} /> - {selectedDeputy ? ( - <div className="mt-4 rounded-lg bg-white p-4 ring-1 ring-(--campaign-line)"> - <p className="text-sm font-bold text-(--campaign-ink)">Linha conferida</p> - <p className="mt-1 text-sm text-(--campaign-muted)"> - {COLINHA_ESTADUAL_LABEL} · {selectedDeputy.name.toLocaleUpperCase('pt-BR')} ·{' '} - {selectedDeputy.ballotNumber} - </p> - </div> - ) : ( - <p className="mt-4 rounded-lg bg-(--campaign-cream) p-3 text-xs leading-5 text-(--campaign-muted)"> - <span className="block font-bold text-(--campaign-ink)"> - Escolha um estadual para baixar. - </span> - A linha vazia será preenchida com o nome e os cinco dígitos. - </p> - )} - <p className="mt-3 text-xs leading-5 text-(--campaign-muted)"> - {CARD_COLINHA_PRIVACY_NOTE} - </p> - {downloadError ? ( - <p role="alert" className="mt-2 text-sm font-semibold text-(--pt-red)"> - {downloadError} - </p> - ) : null} - </div> - ) : null} - - {step === 'compose' && isNameModel ? ( - <div className="mt-5"> - <label htmlFor={nameInputId} className="block text-sm font-bold text-(--campaign-ink)"> - Seu nome - </label> - <input - id={nameInputId} - type="text" - value={name} - onChange={(event) => setName(event.target.value)} - autoComplete="off" - maxLength={60} - className="mt-2 h-11 w-full rounded-lg border border-(--field-border) bg-white px-3 text-base text-(--campaign-ink) focus-visible:border-(--pt-red) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none" - /> - <p className="mt-2 text-xs text-(--campaign-muted)"> - Use seu primeiro nome ou o nome pelo qual as pessoas conhecem você. - </p> - {nameError ? ( - <p role="alert" className="mt-2 text-sm font-semibold text-(--pt-red)"> - {NAME_TOO_LONG_MESSAGE} - </p> - ) : null} - <p className="mt-2 text-xs text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p> - </div> - ) : null} - - {step === 'compose' && isTeamModel && !teamProcessing && !teamReady && !teamError ? ( - <div className="mt-5"> - <TeamNameField - id={nameInputId} - value={name} - onChange={setName} - error={nameError} - inputClassName={nameInputClassName} - /> - {isStateDeputyModel && !selectedDeputy ? ( - <p className="mt-4 rounded-lg bg-(--campaign-cream) p-3 text-xs leading-5 text-(--campaign-muted)"> - <span className="block font-bold text-(--campaign-ink)"> - Escolha um estadual para continuar. - </span> - A prévia e o download usam a arte oficial da dobradinha escolhida. - </p> - ) : ( - <div className="mt-5 rounded-lg border border-(--campaign-line) bg-(--campaign-surface) p-4"> - <p className="text-sm font-bold text-(--campaign-ink)">Sua foto de busto</p> - <p className="mt-1 text-sm leading-5 text-(--campaign-muted)"> - Para um recorte melhor, escolha uma foto nítida, de frente e com fundo simples. - </p> - <button - type="button" - onClick={() => fileInputRef.current?.click()} - className="mt-3 inline-flex min-h-11 w-full items-center justify-center rounded-lg border-2 border-(--campaign-line) bg-white px-5 text-sm font-extrabold text-(--pt-red) transition-colors hover:bg-(--campaign-band) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none" - > - Escolher foto - </button> - </div> - )} - <p className="mt-4 text-xs leading-5 text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p> - </div> - ) : null} - - {step === 'compose' && teamProcessing ? ( - <div className="mt-5"> - <div className="flex items-center justify-between gap-3"> - <p - role="status" - aria-live="polite" - className="text-sm font-bold text-(--campaign-ink)" - > - {teamProcessing.phase === 'download' - ? 'Baixando o modelo de recorte…' - : 'Removendo o fundo da foto…'} - </p> - <p aria-hidden="true" className="text-sm font-black text-(--pt-red)"> - {Math.round(teamProcessing.ratio * 100)}% - </p> - </div> - <div - role="progressbar" - aria-label={`Progresso do recorte: ${Math.round(teamProcessing.ratio * 100)}%`} - aria-valuemin={0} - aria-valuemax={100} - aria-valuenow={Math.round(teamProcessing.ratio * 100)} - className="mt-2 h-2 overflow-hidden rounded-full bg-(--campaign-band)" - > - <div - className="h-full rounded-full bg-(--pt-red) transition-all" - style={{ width: `${Math.round(teamProcessing.ratio * 100)}%` }} - /> - </div> - <p className="mt-3 text-sm leading-5 text-(--campaign-muted)"> - Na primeira vez, o modelo precisa ser baixado. Depois, vamos remover o fundo da foto - no seu aparelho. Isso pode levar um pouco. - </p> - <div className="mt-4 flex items-start gap-3 rounded-lg bg-(--campaign-cream) p-3"> - <span - aria-hidden="true" - className="mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded-full bg-(--team-blue) text-white" - > - <CheckIcon className="size-3" aria-hidden="true" /> - </span> - <p className="text-xs leading-5 text-(--campaign-muted)">{CARD_PHOTO_PRIVACY_NOTE}</p> - </div> - </div> - ) : null} - - {step === 'compose' && teamError ? ( - <div className="mt-5"> - <div role="alert" className="rounded-lg border border-(--pt-red) bg-red-50 p-4"> - <p className="text-sm font-black text-(--pt-red)"> - {teamError.reason === 'unsupported' - ? 'Seu navegador está bloqueando o recorte de fundo.' - : 'Não foi possível remover o fundo desta foto.'} - </p> - <p className="mt-1 text-sm leading-5 text-(--campaign-ink)"> - {teamError.reason === 'unsupported' - ? 'O recorte roda no seu aparelho e precisa de WebAssembly e WebGL, que navegadores com proteções avançadas (como IronFox e Tor) desligam por padrão. Ative essas opções para este site nas configurações do navegador — ou abra a página em outro navegador — e toque em “Tentar de novo”.' - : 'Tente de novo ou escolha outra foto.'} - </p> - </div> - {teamError.reason === 'unsupported' ? null : ( - <p className="mt-4 text-sm leading-5 text-(--campaign-muted)"> - Fotos de busto, nítidas e com fundo simples costumam funcionar melhor. - </p> - )} - <p className="mt-3 text-xs leading-5 text-(--campaign-muted)"> - {CARD_PHOTO_PRIVACY_NOTE} - </p> - <div className="mt-5 flex gap-3"> - <button - type="button" - onClick={() => fileInputRef.current?.click()} - className={secondaryButtonClassName} - > - Escolher outra foto - </button> - <button type="button" onClick={cutout.retry} className={primaryButtonClassName}> - Tentar de novo - </button> - </div> - </div> - ) : null} - - {step === 'compose' && teamReady && photoWindow && transformSourceSize ? ( - <div className="mt-5"> - {nameError ? ( - <> - <TeamNameField - id={nameInputId} - value={name} - onChange={setName} - error={nameError} - inputClassName={nameInputClassName} - /> - <p className="mt-4 text-xs leading-5 text-(--campaign-muted)"> - A foto escolhida e o recorte permanecem salvos neste aparelho enquanto você - corrige o nome. - </p> - </> - ) : ( - <> - {zoomAndPanControls} - {teamReady.harmonized ? ( - <TeamHarmonySwitch - enabled={harmonyEnabled} - onToggle={() => setHarmonyEnabled((enabled) => !enabled)} - /> - ) : null} - <button - type="button" - onClick={() => fileInputRef.current?.click()} - className={`${secondaryButtonClassName} mt-3 w-full`} - > - Trocar foto - </button> - <p className="mt-3 text-xs leading-5 text-(--campaign-muted)"> - {CARD_PHOTO_PRIVACY_NOTE} - </p> - </> - )} - </div> - ) : null} - - {step === 'compose' && !isNameModel && !isTeamModel && photoWindow ? ( - <div className="mt-5"> - {photo && effectiveTransform ? ( - zoomAndPanControls - ) : ( - <p className="text-sm text-(--campaign-muted)"> - Escolha uma foto vertical ou horizontal para começar. - </p> - )} - - {photoError ? ( - <div role="alert" className="mt-4 rounded-lg border border-(--pt-red) bg-white p-3"> - <p className="text-sm font-bold text-(--pt-red)">Não foi possível usar esta foto</p> - <p className="mt-1 text-sm text-(--campaign-muted)"> - Escolha outra imagem e tente novamente. - </p> - </div> - ) : null} - - <div className="mt-4 flex gap-2"> - <button - type="button" - onClick={() => fileInputRef.current?.click()} - className={secondaryButtonClassName} - > - {photoError ? 'Escolher outra foto' : photo ? 'Trocar foto' : 'Escolher foto'} - </button> - </div> - - <p className="mt-3 text-xs text-(--campaign-muted)">{CARD_PHOTO_PRIVACY_NOTE}</p> - </div> - ) : null} - - <input - ref={fileInputRef} - type="file" - accept="image/*" - tabIndex={-1} - aria-hidden="true" - className="sr-only" - onChange={(event) => - isTeamModel - ? handleTeamPhotoFile(event.target.files?.[0]) - : void handlePhotoFile(event.target.files?.[0]) - } - /> - - {step === 'result' ? ( - <div className="mt-5"> - {isTeamModel ? ( - <> - {isStateDeputyModel && selectedDeputy ? ( - <p className="mb-3 text-center text-xs font-bold text-(--campaign-muted)"> - {selectedDeputy.name} · {selectedDeputy.ballotNumber} - </p> - ) : null} - <div className="flex items-start gap-3 rounded-lg bg-(--campaign-cream) p-3"> - <span - aria-hidden="true" - className="inline-flex size-6 shrink-0 items-center justify-center rounded-full bg-green-700 text-white" - > - <CheckIcon className="size-3.5" aria-hidden="true" /> - </span> - <p className="text-xs leading-5 text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p> - </div> - </> - ) : ( - <p className="text-sm text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p> - )} - {downloadError ? ( - <p role="alert" className="mt-2 text-sm font-semibold text-(--pt-red)"> - {downloadError} - </p> - ) : null} - </div> - ) : null} - </div> - - <div className="flex shrink-0 gap-3 border-t border-(--campaign-line) px-5 py-4"> - {step === 'compose' ? ( - <> - {isColinhaModel && shell === 'drawer' ? null : ( - <button type="button" onClick={onClose} className={secondaryButtonClassName}> - Cancelar - </button> - )} - <button - type="button" - disabled={!canAdvance || loadState !== 'ready' || (isColinhaModel && isDownloading)} - onClick={isColinhaModel ? () => void handleDownload() : () => setStep('result')} - className={primaryButtonClassName} - > - {isColinhaModel - ? isDownloading - ? 'Gerando…' - : 'Baixar minha colinha' - : teamProcessing - ? 'Processando…' - : 'Criar meu card'} - </button> - </> - ) : ( - <> - <button type="button" onClick={onClose} className={secondaryButtonClassName}> - Criar outro card - </button> - <button - type="button" - disabled={isDownloading || loadState !== 'ready'} - onClick={() => void handleDownload()} - className={primaryButtonClassName} - > - {isDownloading ? 'Gerando…' : 'Baixar meu card'} - </button> - </> - )} - </div> - </div> - ) -} diff --git a/src/components/cards/CardModelGallery.tsx b/src/components/cards/CardModelGallery.tsx deleted file mode 100644 index fc8398cf..00000000 --- a/src/components/cards/CardModelGallery.tsx +++ /dev/null @@ -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> - ) -} diff --git a/src/components/cards/CardModelTile.tsx b/src/components/cards/CardModelTile.tsx deleted file mode 100644 index 9fcb11ea..00000000 --- a/src/components/cards/CardModelTile.tsx +++ /dev/null @@ -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> - ) -} diff --git a/src/components/cards/CardNameTileCanvas.tsx b/src/components/cards/CardNameTileCanvas.tsx deleted file mode 100644 index 8d6f07f7..00000000 --- a/src/components/cards/CardNameTileCanvas.tsx +++ /dev/null @@ -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" - /> - ) -} diff --git a/src/components/cards/CardPreviewCanvas.tsx b/src/components/cards/CardPreviewCanvas.tsx deleted file mode 100644 index ab411dc2..00000000 --- a/src/components/cards/CardPreviewCanvas.tsx +++ /dev/null @@ -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} - /> -) diff --git a/src/components/cards/CardsStudio.tsx b/src/components/cards/CardsStudio.tsx deleted file mode 100644 index e2446e0a..00000000 --- a/src/components/cards/CardsStudio.tsx +++ /dev/null @@ -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> - ) -} diff --git a/src/components/cards/StateDeputySelect.tsx b/src/components/cards/StateDeputySelect.tsx deleted file mode 100644 index 3f9c32da..00000000 --- a/src/components/cards/StateDeputySelect.tsx +++ /dev/null @@ -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> - ) -} diff --git a/src/components/cards/cardCanvas.ts b/src/components/cards/cardCanvas.ts index dfcd621e..686c389b 100644 --- a/src/components/cards/cardCanvas.ts +++ b/src/components/cards/cardCanvas.ts @@ -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` diff --git a/src/components/cards/cardCopy.ts b/src/components/cards/cardCopy.ts deleted file mode 100644 index 6227e10d..00000000 --- a/src/components/cards/cardCopy.ts +++ /dev/null @@ -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' diff --git a/src/components/cards/cardPhotoHarmonyCanvas.ts b/src/components/cards/cardPhotoHarmonyCanvas.ts deleted file mode 100644 index 2eac4f1b..00000000 --- a/src/components/cards/cardPhotoHarmonyCanvas.ts +++ /dev/null @@ -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 - } -} diff --git a/src/components/cards/useCardCutout.ts b/src/components/cards/useCardCutout.ts deleted file mode 100644 index 3e8edc57..00000000 --- a/src/components/cards/useCardCutout.ts +++ /dev/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 } -} diff --git a/src/components/conteudos/ContentCatalogCardItem.tsx b/src/components/conteudos/ContentCatalogCardItem.tsx deleted file mode 100644 index e0f6d42c..00000000 --- a/src/components/conteudos/ContentCatalogCardItem.tsx +++ /dev/null @@ -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> -) diff --git a/src/components/conteudos/ContentPieceActions.tsx b/src/components/conteudos/ContentPieceActions.tsx deleted file mode 100644 index 62f204a4..00000000 --- a/src/components/conteudos/ContentPieceActions.tsx +++ /dev/null @@ -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> -) diff --git a/src/components/conteudos/ContentPieceCard.tsx b/src/components/conteudos/ContentPieceCard.tsx deleted file mode 100644 index e64556fb..00000000 --- a/src/components/conteudos/ContentPieceCard.tsx +++ /dev/null @@ -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> - ) -} diff --git a/src/components/conteudos/ContentPieceCardParts.tsx b/src/components/conteudos/ContentPieceCardParts.tsx deleted file mode 100644 index a4400060..00000000 --- a/src/components/conteudos/ContentPieceCardParts.tsx +++ /dev/null @@ -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 diff --git a/src/components/conteudos/ContentPieceCatalog.tsx b/src/components/conteudos/ContentPieceCatalog.tsx deleted file mode 100644 index 5f23ee85..00000000 --- a/src/components/conteudos/ContentPieceCatalog.tsx +++ /dev/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} - </> - ) -} diff --git a/src/components/conteudos/ContentPieceDetail.tsx b/src/components/conteudos/ContentPieceDetail.tsx deleted file mode 100644 index 610f9442..00000000 --- a/src/components/conteudos/ContentPieceDetail.tsx +++ /dev/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} - </> - ) -} diff --git a/src/components/conteudos/ContentPieceFilters.tsx b/src/components/conteudos/ContentPieceFilters.tsx deleted file mode 100644 index 5bebfad4..00000000 --- a/src/components/conteudos/ContentPieceFilters.tsx +++ /dev/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> - ) -} diff --git a/src/components/conteudos/ContentPieceHero.tsx b/src/components/conteudos/ContentPieceHero.tsx deleted file mode 100644 index d59fc50e..00000000 --- a/src/components/conteudos/ContentPieceHero.tsx +++ /dev/null @@ -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> -) diff --git a/src/components/conteudos/ContentPieceHomeBoard.tsx b/src/components/conteudos/ContentPieceHomeBoard.tsx deleted file mode 100644 index 84cde858..00000000 --- a/src/components/conteudos/ContentPieceHomeBoard.tsx +++ /dev/null @@ -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} - </> - ) -} diff --git a/src/components/conteudos/ContentPieceHomeCard.tsx b/src/components/conteudos/ContentPieceHomeCard.tsx deleted file mode 100644 index bca98450..00000000 --- a/src/components/conteudos/ContentPieceHomeCard.tsx +++ /dev/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> - ) -} diff --git a/src/components/conteudos/ContentPieceHomeFilterRow.tsx b/src/components/conteudos/ContentPieceHomeFilterRow.tsx deleted file mode 100644 index e4323988..00000000 --- a/src/components/conteudos/ContentPieceHomeFilterRow.tsx +++ /dev/null @@ -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> - ) -} diff --git a/src/components/conteudos/ContentPieceHomeSection.tsx b/src/components/conteudos/ContentPieceHomeSection.tsx deleted file mode 100644 index 56aeaf09..00000000 --- a/src/components/conteudos/ContentPieceHomeSection.tsx +++ /dev/null @@ -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> - ) -} diff --git a/src/components/conteudos/ContentPieceMedia.tsx b/src/components/conteudos/ContentPieceMedia.tsx deleted file mode 100644 index 2cccbb04..00000000 --- a/src/components/conteudos/ContentPieceMedia.tsx +++ /dev/null @@ -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> - ) -} diff --git a/src/components/conteudos/ContentPiecePageHeader.tsx b/src/components/conteudos/ContentPiecePageHeader.tsx deleted file mode 100644 index 3f44021a..00000000 --- a/src/components/conteudos/ContentPiecePageHeader.tsx +++ /dev/null @@ -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> -) diff --git a/src/components/conteudos/ContentPieceSearchMode.tsx b/src/components/conteudos/ContentPieceSearchMode.tsx deleted file mode 100644 index 856ac364..00000000 --- a/src/components/conteudos/ContentPieceSearchMode.tsx +++ /dev/null @@ -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> - ) -} diff --git a/src/components/conteudos/ContentPieceShareSheet.tsx b/src/components/conteudos/ContentPieceShareSheet.tsx deleted file mode 100644 index 0fe784f4..00000000 --- a/src/components/conteudos/ContentPieceShareSheet.tsx +++ /dev/null @@ -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, - ) -} diff --git a/src/components/conteudos/ContentPieceStates.tsx b/src/components/conteudos/ContentPieceStates.tsx deleted file mode 100644 index 3e18bf60..00000000 --- a/src/components/conteudos/ContentPieceStates.tsx +++ /dev/null @@ -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> -) diff --git a/src/components/conteudos/contentPieceClasses.ts b/src/components/conteudos/contentPieceClasses.ts deleted file mode 100644 index 7d5b89da..00000000 --- a/src/components/conteudos/contentPieceClasses.ts +++ /dev/null @@ -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, -) diff --git a/src/components/fotos/ArchivePhotoHero.tsx b/src/components/fotos/ArchivePhotoHero.tsx deleted file mode 100644 index 51ed07eb..00000000 --- a/src/components/fotos/ArchivePhotoHero.tsx +++ /dev/null @@ -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> -) diff --git a/src/components/fotos/ArchivePhotoPageHeader.tsx b/src/components/fotos/ArchivePhotoPageHeader.tsx deleted file mode 100644 index cc851aeb..00000000 --- a/src/components/fotos/ArchivePhotoPageHeader.tsx +++ /dev/null @@ -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> -) diff --git a/src/components/fotos/ArchivePhotoStates.tsx b/src/components/fotos/ArchivePhotoStates.tsx index 3def8206..d06080c9 100644 --- a/src/components/fotos/ArchivePhotoStates.tsx +++ b/src/components/fotos/ArchivePhotoStates.tsx @@ -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)"> diff --git a/src/components/jingles/JingleCards.tsx b/src/components/jingles/JingleCards.tsx deleted file mode 100644 index fa969170..00000000 --- a/src/components/jingles/JingleCards.tsx +++ /dev/null @@ -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> - ) -} diff --git a/src/components/jingles/JingleEmptyState.tsx b/src/components/jingles/JingleEmptyState.tsx deleted file mode 100644 index a836c021..00000000 --- a/src/components/jingles/JingleEmptyState.tsx +++ /dev/null @@ -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> -) diff --git a/src/components/jingles/JingleHomeSection.tsx b/src/components/jingles/JingleHomeSection.tsx deleted file mode 100644 index d91508c5..00000000 --- a/src/components/jingles/JingleHomeSection.tsx +++ /dev/null @@ -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> - ) -} diff --git a/src/components/jingles/JingleIntro.tsx b/src/components/jingles/JingleIntro.tsx deleted file mode 100644 index 7999745b..00000000 --- a/src/components/jingles/JingleIntro.tsx +++ /dev/null @@ -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> -) diff --git a/src/components/jingles/JinglePlayer.tsx b/src/components/jingles/JinglePlayer.tsx deleted file mode 100644 index df2a83e4..00000000 --- a/src/components/jingles/JinglePlayer.tsx +++ /dev/null @@ -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> -) diff --git a/src/components/jingles/RadioPlayer.tsx b/src/components/jingles/RadioPlayer.tsx deleted file mode 100644 index 8bda0f6b..00000000 --- a/src/components/jingles/RadioPlayer.tsx +++ /dev/null @@ -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> - ) -} diff --git a/src/components/jingles/focusRing.ts b/src/components/jingles/focusRing.ts deleted file mode 100644 index bfc630f3..00000000 --- a/src/components/jingles/focusRing.ts +++ /dev/null @@ -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)' diff --git a/src/components/shareLink/ShareLinkAnnouncement.tsx b/src/components/shareLink/ShareLinkAnnouncement.tsx index b9f08fdc..549e0f49 100644 --- a/src/components/shareLink/ShareLinkAnnouncement.tsx +++ b/src/components/shareLink/ShareLinkAnnouncement.tsx @@ -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> ) } diff --git a/src/lib/archivePhotoPublicCatalog.ts b/src/lib/archivePhotoPublicCatalog.ts index 3f59ec08..805d1145 100644 --- a/src/lib/archivePhotoPublicCatalog.ts +++ b/src/lib/archivePhotoPublicCatalog.ts @@ -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 diff --git a/src/lib/calendarEvent.ts b/src/lib/calendarEvent.ts index 21452e65..08bb64ea 100644 --- a/src/lib/calendarEvent.ts +++ b/src/lib/calendarEvent.ts @@ -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 diff --git a/src/lib/campaignConsentKeys.ts b/src/lib/campaignConsentKeys.ts index 3b74d3f0..5c0080fc 100644 --- a/src/lib/campaignConsentKeys.ts +++ b/src/lib/campaignConsentKeys.ts @@ -42,13 +42,3 @@ export const CAMPAIGN_PUSH_CONSENT_MISSING_MESSAGE = */ export const FACE_SEARCH_CONSENT_KEY = 'busca-selfie-fotos' export const FACE_INDEX_CONSENT_KEY = 'busca-selfie-indice' - -/** - * S9 — campaign home "novidades" capture (name + WhatsApp, engagement level - * toggle). Fail-closed like every public flow: while the admin has not created - * the Consent row with this key (jurídico-approved text pending), the form - * refuses to record anything. - */ -export const CAMPAIGN_NEWSLETTER_CONSENT_KEY = 'campanha-novidades' -export const CAMPAIGN_NEWSLETTER_CONSENT_MISSING_MESSAGE = - 'Consentimento de novidades da campanha ainda não configurado.' diff --git a/src/lib/cardColinha.ts b/src/lib/cardColinha.ts deleted file mode 100644 index ef68be32..00000000 --- a/src/lib/cardColinha.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * S34 — the drawn overlay of the `Minha colinha` voting slip. The approved art - * (`public/cards/modelo-colinha.jpeg`, the byte-for-byte 900×1600 file the human - * delivered) is the base of the preview and the PNG; the only drawn element is - * the state-deputy row (2nd line of the art) — the burned label wiped by a white - * mask, the label redrawn at the left, the picked name at the right and one - * digit per printed box. The geometry is the approved design gate - * (`docs/plans/cards-colinha-arte-exata-ui-design.html`) measured at the real - * 1080×1920 output (the exact 1.2× of the art); the label size was corrected by - * the designer critique (c) to the art's printed cap height. No DOM and no - * drawing here — the renderer (`renderColinhaCard` in `cardRender.ts`) consumes - * these constants, so the model stays unit-testable with a deterministic fake. - */ - -export type ColinhaRowNameFit = { - readonly fontSize: number - readonly text: string -} - -/** - * S34 — the slip typography of the approved gate: the body face of the art - * (label at weight 400, name at 900) and the digit face (`Arial Black` first). - * The exact heavy face of the art is not shipped in the repo (the gate marks it - * NEEDS ASSET); the port proves the closest face side by side and the gate - * decides composition and geometry. - */ -export const COLINHA_FONT_FAMILY = 'Arial, Helvetica, sans-serif' -export const COLINHA_DIGIT_FONT_FAMILY = 'Arial Black, Arial, Helvetica, sans-serif' - -/** The 2nd line of the art: the office label redrawn over the white mask. */ -export const COLINHA_ESTADUAL_LABEL = 'DEPUTADO ESTADUAL' - -/** - * S34 — pixel geometry at the 1080×1920 output, measured from the approved - * design gate (the gate's percentages resolved against the 1080×1920 stage). - * The mask wipes the burned label of the art and stops at the top of the five - * printed boxes; `copy` is the right-aligned pair band and the name baseline is - * anchored on the cap top of the name. - */ -export const COLINHA_ROW_LAYOUT = { - mask: { x: 264.6, y: 1073.28, width: 619.92, height: 55.68, fill: '#ffffff' }, - copy: { left: 290.52, top: 1084.03, width: 570.24, right: 860.76 }, - // The design gate's 2.15cqw (23.22px) sat below the art's printed labels; - // the designer critique (c) set 26.9px so the cap height matches the 19.2px - // of the fixed rows at weight 400. - office: { fontSize: 26.9, weight: 400, color: '#202020' }, - name: { fontSize: 36.72, weight: 900, color: '#e4102f', letterSpacingEm: -0.04 }, - gap: 16.2, - digit: { - left: 279.72, - step: 74.52, - width: 65.88, - height: 83.52, - top: 1121.28, - fontSize: 45.9, - weight: 900, - color: '#171717', - }, -} as const - -/** - * S34 — the picked name shrinks on one line to fit the white mask band left of - * the label (`maxWidth` already discounts the label and the gap), so the pair - * never leaves the mask and the name is never cut nor wrapped. The gate's own - * CSS keeps the pair right-aligned and lets it use the whole band (the declared - * `copy` box is narrower than a long pair). - */ -export const fitColinhaRowName = ( - text: string, - measure: (text: string, fontSize: number) => { width: number }, - maxWidth: number, -): ColinhaRowNameFit => { - const ideal = COLINHA_ROW_LAYOUT.name.fontSize - const width = measure(text, ideal).width - if (width <= maxWidth || width <= 0) return { fontSize: ideal, text } - - return { fontSize: Math.floor((ideal * maxWidth) / width), text } -} diff --git a/src/lib/cardModels.ts b/src/lib/cardModels.ts index d7142ad8..d23efa66 100644 --- a/src/lib/cardModels.ts +++ b/src/lib/cardModels.ts @@ -180,7 +180,7 @@ export type CardNameBlockSlot = CardNameSlotBase & { } /** Rotated rectangle + background the renderer draws for a banner. */ -export type CardBanner = { +type CardBanner = { centerX: number centerY: number width: number @@ -198,8 +198,6 @@ export type CardNameBannerSlot = CardNameSlotBase & { banner: CardBanner } -export type CardNameSlot = CardNameBlockSlot | CardNameBannerSlot - /** * Name model slot, pixel-measured from the filled master (`FULANO` example): * the `#SOU` line and the visitor's name share the same left border at x=213 @@ -276,8 +274,6 @@ export const TEAM_CARD_NAME_SLOT = { maxLines: 1, } as const satisfies CardNameBannerSlot -export const BRAND_FALLBACK_FONT = 'sans-serif' - export const isCardModelId = (value: unknown): value is CardModelId => typeof value === 'string' && (CARD_MODEL_IDS as readonly string[]).includes(value) diff --git a/src/lib/cardNameFit.ts b/src/lib/cardNameFit.ts deleted file mode 100644 index 5fa7bda6..00000000 --- a/src/lib/cardNameFit.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * S13 — pure fit math for the name card. The visitor's name must never be cut - * silently: it starts at the master's cap height (106px), shrinks and — when a - * single line would drop below the readable minimum — wraps into two lines - * before shrinking further. Past `minCapHeight` the fit fails and the composer - * asks for a shorter name. - * - * Text measurement is injected (`CardMeasureText`) so the module stays free of - * DOM and unit-testable with a deterministic fake. - */ - -import { NAME_CARD_SLOT, type CardNameBlockSlot, type CardNameSlot } from './cardModels' - -export type CardTextMetrics = { - width: number - actualBoundingBoxAscent: number - actualBoundingBoxDescent: number -} - -export type CardMeasureText = (text: string, fontSize: number) => CardTextMetrics - -export type CardNameFit = - | { - ok: true - lines: string[] - fontSize: number - lineHeight: number - capHeight: number - /** - * Measured ink at `fontSize`: the widest line (S16 — the renderer uses it - * to grow the banner only as much as the text needs). - */ - inkWidth: number - } - | { ok: false; reason: 'empty' | 'too-long' } - -const CAP_PROBE = 'X' -const LINE_HEIGHT_RATIO = 1.25 -const MEASURE_PROBE_SIZE = 100 - -export const normalizeCardName = (name: string): string => - name.trim().replace(/\s+/g, ' ').toLocaleUpperCase('pt-BR') - -export const resolveFontSizeForCapHeight = ( - measure: CardMeasureText, - capHeight: number, -): number => { - const probe = measure(CAP_PROBE, MEASURE_PROBE_SIZE) - if (probe.actualBoundingBoxAscent <= 0) return capHeight - - return (capHeight / probe.actualBoundingBoxAscent) * MEASURE_PROBE_SIZE -} - -const widthAt = (measure: CardMeasureText, text: string, fontSize: number): number => - Math.max(0, measure(text, fontSize).width) - -const capAt = (measure: CardMeasureText, fontSize: number): number => - Math.max(0, measure(CAP_PROBE, fontSize).actualBoundingBoxAscent) - -const fitSingleLine = ( - text: string, - measure: CardMeasureText, - slot: CardNameSlot, -): number | null => { - const idealSize = resolveFontSizeForCapHeight(measure, slot.capHeight) - const idealWidth = widthAt(measure, text, idealSize) - if (idealWidth <= slot.maxInkWidth) return idealSize - - const shrunk = Math.floor((idealSize * slot.maxInkWidth) / idealWidth) - if (shrunk <= 0) return null - if (widthAt(measure, text, shrunk) > slot.maxInkWidth) return null - - return shrunk -} - -const fitTwoLines = ( - words: string[], - measure: CardMeasureText, - slot: CardNameBlockSlot, -): { fontSize: number; lines: [string, string] } | null => { - const idealSize = resolveFontSizeForCapHeight(measure, slot.capHeight) - const maxBlockHeight = slot.maxBlockBottom - slot.capTop - let best: { fontSize: number; lines: [string, string] } | null = null - - for (let split = 1; split < words.length; split++) { - const lines: [string, string] = [words.slice(0, split).join(' '), words.slice(split).join(' ')] - const widest = Math.max( - widthAt(measure, lines[0], idealSize), - widthAt(measure, lines[1], idealSize), - ) - if (widest <= 0) continue - - let size = Math.min(idealSize, (idealSize * slot.maxInkWidth) / widest) - const capHeight = capAt(measure, size) - const blockHeight = size * LINE_HEIGHT_RATIO + capHeight - if (blockHeight > maxBlockHeight) { - size *= maxBlockHeight / blockHeight - } - - size = Math.floor(size) - if (size <= 0) continue - - const lineWidths = [widthAt(measure, lines[0], size), widthAt(measure, lines[1], size)] - const sizeCap = capAt(measure, size) - const sizeBlock = size * LINE_HEIGHT_RATIO + sizeCap - if (lineWidths.some((width) => width > slot.maxInkWidth)) continue - if (sizeBlock > maxBlockHeight) continue - if (best === null || size > best.fontSize) best = { fontSize: size, lines } - } - - return best -} - -/** - * Fits `name` to the master slot honoring `maxLines`. The returned geometry is - * what `renderNameCard` draws: `capHeight` is the measured cap at the chosen - * size and `lineHeight` is the baseline-to-baseline distance. - */ -export const fitCardName = ( - name: string, - measure: CardMeasureText, - slot: CardNameSlot = NAME_CARD_SLOT, -): CardNameFit => { - const normalized = normalizeCardName(name) - if (!normalized) return { ok: false, reason: 'empty' } - - const minFontSize = resolveFontSizeForCapHeight(measure, slot.minCapHeight) - const singleSize = fitSingleLine(normalized, measure, slot) - if (singleSize !== null && singleSize >= minFontSize) { - return { - ok: true, - lines: [normalized], - fontSize: singleSize, - lineHeight: singleSize * LINE_HEIGHT_RATIO, - capHeight: capAt(measure, singleSize), - inkWidth: widthAt(measure, normalized, singleSize), - } - } - - const words = normalized.split(' ') - if (slot.align === 'left' && slot.maxLines >= 2 && words.length > 1) { - const twoLines = fitTwoLines(words, measure, slot) - if (twoLines !== null && twoLines.fontSize >= minFontSize) { - return { - ok: true, - lines: twoLines.lines, - fontSize: twoLines.fontSize, - lineHeight: twoLines.fontSize * LINE_HEIGHT_RATIO, - capHeight: capAt(measure, twoLines.fontSize), - inkWidth: Math.max( - widthAt(measure, twoLines.lines[0], twoLines.fontSize), - widthAt(measure, twoLines.lines[1], twoLines.fontSize), - ), - } - } - } - - return { ok: false, reason: 'too-long' } -} diff --git a/src/lib/cardPhotoHarmony.ts b/src/lib/cardPhotoHarmony.ts deleted file mode 100644 index 0e1c8ef0..00000000 --- a/src/lib/cardPhotoHarmony.ts +++ /dev/null @@ -1,163 +0,0 @@ -/** - * S17 — pure tone math for the `Time de você` card: brings the visitor's cutout - * photo closer to the official candidate photos in brightness, contrast and - * saturation. No DOM here (the browser adapter lives in - * `components/cards/cardPhotoHarmonyCanvas`), no hue/white balance and no skin - * retouch — only the three measured channels, clamped to a mild range so the - * result never reads as a preset filter. The alpha channel is always copied - * byte-for-byte: the cutout mask is untouchable. - */ - -import { CARD_CUTOUT_ALPHA_THRESHOLD } from './cardPhotoTransform' - -export type CardPhotoTone = { - /** Rec.709 luma mean (0..1) over the pixels above the alpha threshold. */ - meanLuma: number - /** Population standard deviation of the luma (0..1). */ - sdLuma: number - /** Mean HSV saturation `(max-min)/max` (0..1); 0 when a pixel is black. */ - meanSaturation: number - /** Sampled pixel count; 0 when every pixel is below the alpha threshold. */ - samples: number -} - -export type CardHarmonyAdjustment = { - brightness: number - contrast: number - saturation: number -} - -/** Fixed mild deltas (≤ 12%): the adjustment harmonizes, it does not filter. */ -export const CARD_HARMONY_LIMITS = { - brightness: { min: 0.94, max: 1.08 }, - contrast: { min: 0.94, max: 1.08 }, - saturation: { min: 0.9, max: 1.12 }, -} as const - -/** Inside this band the adjustment is a no-op: the photo is already on tone. */ -export const CARD_HARMONY_IDENTITY_EPSILON = 0.02 - -const IDENTITY_ADJUSTMENT: CardHarmonyAdjustment = Object.freeze({ - brightness: 1, - contrast: 1, - saturation: 1, -}) - -const LUMA_RED = 0.2126 -const LUMA_GREEN = 0.7152 -const LUMA_BLUE = 0.0722 - -const MID_RANGE = 127.5 - -const clamp = (value: number, min: number, max: number): number => - Math.min(Math.max(value, min), max) - -const lumaOf = (red: number, green: number, blue: number): number => - LUMA_RED * red + LUMA_GREEN * green + LUMA_BLUE * blue - -/** - * S17 — measures the tone of an RGBA buffer. Fully transparent pixels are not - * evidence of tone, so they stay out of the sample (same alpha floor as the - * cutout bbox): a blank segmentation measures `samples: 0` instead of black. - */ -export const measureCardPhotoTone = (data: Uint8ClampedArray): CardPhotoTone => { - let samples = 0 - let lumaSum = 0 - let lumaSquaredSum = 0 - let saturationSum = 0 - - for (let index = 0; index < data.length; index += 4) { - if (data[index + 3] <= CARD_CUTOUT_ALPHA_THRESHOLD) continue - - const red = data[index] - const green = data[index + 1] - const blue = data[index + 2] - const luma = lumaOf(red, green, blue) / 255 - const max = Math.max(red, green, blue) - - samples += 1 - lumaSum += luma - lumaSquaredSum += luma * luma - saturationSum += max === 0 ? 0 : (max - Math.min(red, green, blue)) / max - } - - if (samples === 0) return { meanLuma: 0, sdLuma: 0, meanSaturation: 0, samples: 0 } - - const meanLuma = lumaSum / samples - return { - meanLuma, - sdLuma: Math.sqrt(Math.max(0, lumaSquaredSum / samples - meanLuma * meanLuma)), - meanSaturation: saturationSum / samples, - samples, - } -} - -const factorFrom = ( - reference: number, - source: number, - limit: { min: number; max: number }, -): number => { - if (!Number.isFinite(reference) || !Number.isFinite(source) || source <= 0) return 1 - - const factor = reference / source - return Number.isFinite(factor) ? clamp(factor, limit.min, limit.max) : 1 -} - -/** - * S17 — the three factors that bring `source` toward `reference`, each clamped. - * A degenerate source stat (flat tone, zero saturation, empty sample) keeps its - * factor at 1 instead of amplifying noise; an empty reference is identity. - */ -export const computeCardHarmonyAdjustment = ( - source: CardPhotoTone, - reference: CardPhotoTone, -): CardHarmonyAdjustment => { - if (source.samples === 0 || reference.samples === 0) return IDENTITY_ADJUSTMENT - - return { - brightness: factorFrom(reference.meanLuma, source.meanLuma, CARD_HARMONY_LIMITS.brightness), - contrast: factorFrom(reference.sdLuma, source.sdLuma, CARD_HARMONY_LIMITS.contrast), - saturation: factorFrom( - reference.meanSaturation, - source.meanSaturation, - CARD_HARMONY_LIMITS.saturation, - ), - } -} - -export const isCardHarmonyIdentity = (adjustment: CardHarmonyAdjustment): boolean => - Math.abs(adjustment.brightness - 1) <= CARD_HARMONY_IDENTITY_EPSILON && - Math.abs(adjustment.contrast - 1) <= CARD_HARMONY_IDENTITY_EPSILON && - Math.abs(adjustment.saturation - 1) <= CARD_HARMONY_IDENTITY_EPSILON - -/** - * S17 — applies the adjustment in one pass, in a fixed order (brightness → - * contrast → saturation) and with the pixel luma recomputed after the first two - * so saturation pivots on the corrected color. Returns a new buffer; `alpha` - * bytes are copied untouched. - */ -export const applyCardPhotoHarmony = ( - data: Uint8ClampedArray, - adjustment: CardHarmonyAdjustment, -): Uint8ClampedArray => { - const output = new Uint8ClampedArray(data.length) - const { brightness, contrast, saturation } = adjustment - - for (let index = 0; index < data.length; index += 4) { - output[index + 3] = data[index + 3] - - const brightRed = data[index] * brightness - const brightGreen = data[index + 1] * brightness - const brightBlue = data[index + 2] * brightness - const correctedRed = (brightRed - MID_RANGE) * contrast + MID_RANGE - const correctedGreen = (brightGreen - MID_RANGE) * contrast + MID_RANGE - const correctedBlue = (brightBlue - MID_RANGE) * contrast + MID_RANGE - const luma = lumaOf(correctedRed, correctedGreen, correctedBlue) - - output[index] = Math.round(luma + (correctedRed - luma) * saturation) - output[index + 1] = Math.round(luma + (correctedGreen - luma) * saturation) - output[index + 2] = Math.round(luma + (correctedBlue - luma) * saturation) - } - - return output -} diff --git a/src/lib/cardPhotoTransform.ts b/src/lib/cardPhotoTransform.ts index 89ca441f..259a4052 100644 --- a/src/lib/cardPhotoTransform.ts +++ b/src/lib/cardPhotoTransform.ts @@ -59,9 +59,6 @@ export type CardAlphaBbox = { height: number } -export const cardPhotoTransformsEqual = (a: CardPhotoTransform, b: CardPhotoTransform): boolean => - a.zoom === b.zoom && a.offsetX === b.offsetX && a.offsetY === b.offsetY - const clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max) diff --git a/src/lib/cardRender.ts b/src/lib/cardRender.ts deleted file mode 100644 index 68e2033d..00000000 --- a/src/lib/cardRender.ts +++ /dev/null @@ -1,390 +0,0 @@ -/** - * S13 — the single canvas renderer shared by the preview and the PNG export: - * `renderNameCard` draws the master base then the fitted name; `renderPhotoCard` - * draws the visitor photo full-bleed then the master overlay (the overlay is - * authoritative — never redraw bands or contra-forms). The context is a - * structural subset of `CanvasRenderingContext2D` so unit tests drive a fake - * recorder instead of jsdom canvas. - */ - -import { - COLINHA_DIGIT_FONT_FAMILY, - COLINHA_ESTADUAL_LABEL, - COLINHA_ROW_LAYOUT, - fitColinhaRowName, -} from './cardColinha' -import { - BRAND_FALLBACK_FONT, - NAME_CARD_SLOT, - TEAM_CARD_LABEL, - TEAM_CARD_NAME_SLOT, - type CardBanner, - type CardModel, - type CardNameBannerSlot, - type CardNameSlot, - type CardRect, -} from './cardModels' -import { - fitCardName, - resolveFontSizeForCapHeight, - type CardMeasureText, - type CardNameFit, - type CardTextMetrics, -} from './cardNameFit' -import { - cardPhotoDrawRect, - clampCardPhotoTransform, - type CardPhotoSize, - type CardPhotoTransform, -} from './cardPhotoTransform' -import type { StateDeputyCatalogEntry } from './stateDeputyCatalog' - -export type CardDrawContext = { - font: string - letterSpacing: string - globalAlpha: number - fillStyle: string | CanvasGradient | CanvasPattern - textAlign: CanvasTextAlign - textBaseline: CanvasTextBaseline - imageSmoothingEnabled: boolean - imageSmoothingQuality: ImageSmoothingQuality - drawImage(image: CanvasImageSource, dx: number, dy: number, dWidth: number, dHeight: number): void - fillRect(x: number, y: number, width: number, height: number): void - fillText(text: string, x: number, y: number): void - measureText(text: string): CardTextMetrics - save(): void - restore(): void - translate(x: number, y: number): void - rotate(angle: number): void - beginPath(): void - arc(x: number, y: number, radius: number, startAngle: number, endAngle: number): void - ellipse( - x: number, - y: number, - radiusX: number, - radiusY: number, - rotation: number, - startAngle: number, - endAngle: number, - ): void - rect(x: number, y: number, width: number, height: number): void - roundRect(x: number, y: number, width: number, height: number, radii?: number | number[]): void - clip(): void - createLinearGradient(x0: number, y0: number, x1: number, y1: number): CanvasGradient - fill(): void -} - -export const createCardMeasure = ( - ctx: Pick<CardDrawContext, 'font' | 'measureText'>, - fontFamily: string, - weight = 700, -): CardMeasureText => { - return (text, fontSize) => { - ctx.font = `${weight} ${fontSize}px ${fontFamily}, ${BRAND_FALLBACK_FONT}` - const metrics = ctx.measureText(text) - - return { - width: metrics.width, - actualBoundingBoxAscent: metrics.actualBoundingBoxAscent ?? 0, - actualBoundingBoxDescent: metrics.actualBoundingBoxDescent ?? 0, - } - } -} - -export type NameCardRenderArgs = { - image: CanvasImageSource - name: string - fontFamily: string - measure: CardMeasureText - slot?: CardNameSlot -} - -export type CardNameDrawArgs = { - fit: Extract<CardNameFit, { ok: true }> - fontFamily: string - slot?: CardNameSlot -} - -type CardBannerDrawArgs = { - banner: CardBanner - /** S16 — the drawn width: the model width, or the resolved dynamic one. */ - width: number - text: string - fill: string - fontSize: number - capHeight: number - fontFamily: string -} - -/** - * S16 — resolves the name banner width from the measured ink: the model's - * reference width while the ink fits `width - padding` (= 470), then growing - * with the ink so the model's side padding (`maxWidth - maxInkWidth` = 39) is - * preserved, never past the ceiling. Assumes `maxWidth >= width` (fixed - * banners use equal values and resolve to themselves). - */ -export const resolveCardBannerWidth = ( - slot: Pick<CardNameBannerSlot, 'banner' | 'maxInkWidth'>, - inkWidth: number, -): number => { - const { banner } = slot - const padding = banner.maxWidth - slot.maxInkWidth - - return Math.max(banner.width, Math.min(banner.maxWidth, inkWidth + padding)) -} - -/** - * S15 — draws one official top banner: the rotated rectangle plus its centered - * cap-height text. Both team banners (the fixed `TIME DE` label and the fitted - * name) share this so the measured geometry lives once. - */ -const drawCardBanner = (ctx: CardDrawContext, args: CardBannerDrawArgs): void => { - const { banner } = args - const width = args.width - - ctx.save() - ctx.translate(banner.centerX, banner.centerY) - ctx.rotate((banner.rotationDeg * Math.PI) / 180) - ctx.fillStyle = banner.background - ctx.fillRect(-width / 2, -banner.height / 2, width, banner.height) - ctx.font = `700 ${args.fontSize}px ${args.fontFamily}` - ctx.fillStyle = args.fill - ctx.textAlign = 'center' - ctx.textBaseline = 'alphabetic' - ctx.fillText(args.text, 0, args.capHeight / 2) - ctx.restore() -} - -/** - * S14/S15 — draws only the fitted name (no base image) so the home tile preview - * reuses the exact composer geometry. `fit.ok` is the caller's contract: a - * failed fit never draws (names are never cut silently). Left slots draw on the - * master's border; the team slot draws the blue banner + centered name. - */ -export const drawCardName = (ctx: CardDrawContext, args: CardNameDrawArgs): void => { - const slot = args.slot ?? NAME_CARD_SLOT - const { fit } = args - - if (slot.align === 'center') { - drawCardBanner(ctx, { - banner: slot.banner, - width: resolveCardBannerWidth(slot, fit.inkWidth), - text: fit.lines[0], - fill: slot.fill, - fontSize: fit.fontSize, - capHeight: fit.capHeight, - fontFamily: args.fontFamily, - }) - return - } - - ctx.font = `700 ${fit.fontSize}px ${args.fontFamily}` - ctx.fillStyle = slot.fill - ctx.textAlign = 'left' - ctx.textBaseline = 'alphabetic' - - if (fit.lines.length === 1) { - ctx.fillText(fit.lines[0], slot.leftX, slot.capTop + fit.capHeight) - return - } - - const availableHeight = slot.maxBlockBottom - slot.capTop - const blockHeight = fit.lineHeight + fit.capHeight - const blockTop = slot.capTop + Math.max(0, (availableHeight - blockHeight) / 2) - - fit.lines.forEach((line, index) => { - ctx.fillText(line, slot.leftX, blockTop + fit.capHeight + index * fit.lineHeight) - }) -} - -export const renderNameCard = ( - ctx: CardDrawContext, - model: CardModel, - args: NameCardRenderArgs, -): CardNameFit => { - const slot = args.slot ?? NAME_CARD_SLOT - ctx.drawImage(args.image, 0, 0, model.width, model.height) - - const fit = fitCardName(args.name, args.measure, slot) - if (!fit.ok) return fit - - drawCardName(ctx, { fit, fontFamily: args.fontFamily, slot }) - - return fit -} - -export type PhotoCardRenderArgs = { - photo: CanvasImageSource - frame: CanvasImageSource - photoSize: CardPhotoSize - frameSize: CardPhotoSize - window: CardRect - transform: CardPhotoTransform -} - -export const renderPhotoCard = ( - ctx: CardDrawContext, - args: PhotoCardRenderArgs, -): CardPhotoTransform => { - const rect = cardPhotoDrawRect(args.transform, args.photoSize, args.window) - ctx.drawImage(args.photo, rect.x, rect.y, rect.width, rect.height) - ctx.drawImage(args.frame, 0, 0, args.frameSize.width, args.frameSize.height) - - return clampCardPhotoTransform(args.transform, args.photoSize, args.window) -} - -/** S30 — the foreground subject of a team composition. */ -type TeamCardSubject = { - photo: CanvasImageSource - photoSize: CardPhotoSize - transform: CardPhotoTransform -} - -export type TeamCardRenderArgs = { - base: CanvasImageSource - overlay: CanvasImageSource - /** `null` leaves the window empty: the chosen art shows exactly as delivered. */ - subject: TeamCardSubject | null - window: CardRect - name: string - fontFamily: string - measure: CardMeasureText - slot?: CardNameBannerSlot -} - -export type TeamCardRenderResult = { - fit: CardNameFit - /** - * The normalized photo transform (zoom always in range; S33 — the offsets - * follow the model's `photoPosition`, so a `free` model keeps them as sent); - * `null` when there is no photo subject. - */ - transform: CardPhotoTransform | null -} - -/** - * S15/S30 — the team card composition, in the measured order: master base → - * foreground subject (the framed cutout photo; `null` skips it and the window - * stays empty) → master front overlay → `TIME DE` banner → blue name banner - * (only when the name fits; never cut silently). The two banners are drawn by - * the renderer because neither master carries them. - */ -export const renderTeamCard = ( - ctx: CardDrawContext, - model: CardModel, - args: TeamCardRenderArgs, -): TeamCardRenderResult => { - ctx.drawImage(args.base, 0, 0, model.width, model.height) - - let transform: CardPhotoTransform | null = null - if (args.subject) { - // S33 — the drawing and the returned transform follow the same policy read - // from the model: a `free` team model never re-clamps the visitor position - // (that would snap the photo back on the next paint). - const options = { position: model.photoPosition ?? 'bounded' } - const rect = cardPhotoDrawRect( - args.subject.transform, - args.subject.photoSize, - args.window, - options, - ) - ctx.drawImage(args.subject.photo, rect.x, rect.y, rect.width, rect.height) - transform = clampCardPhotoTransform( - args.subject.transform, - args.subject.photoSize, - args.window, - options, - ) - } - - ctx.drawImage(args.overlay, 0, 0, model.width, model.height) - - drawCardBanner(ctx, { - banner: TEAM_CARD_LABEL, - width: TEAM_CARD_LABEL.width, - text: TEAM_CARD_LABEL.text, - fill: TEAM_CARD_LABEL.fill, - fontSize: resolveFontSizeForCapHeight(args.measure, TEAM_CARD_LABEL.capHeight), - capHeight: TEAM_CARD_LABEL.capHeight, - fontFamily: args.fontFamily, - }) - - const slot = args.slot ?? TEAM_CARD_NAME_SLOT - const fit = fitCardName(args.name, args.measure, slot) - if (fit.ok) drawCardName(ctx, { fit, fontFamily: args.fontFamily, slot }) - - return { fit, transform } -} - -export type ColinhaCardRenderArgs = { - /** The approved art (`/cards/modelo-colinha.jpeg`), drawn whole at 1.2×. */ - image: CanvasImageSource - /** The chosen state deputy; `null` leaves the art exactly as delivered. */ - deputy: StateDeputyCatalogEntry | null - fontFamily: string - /** The 900-weight measure the estadual name is fitted and placed with. */ - measure: CardMeasureText -} - -/** - * S34 — the `Minha colinha` slip: the approved art drawn whole (the 900×1600 - * JPEG scaled 1.2× to the 1080×1920 output) and, only when a state deputy is - * picked, the 2nd line: the burned label wiped by the white mask, the label - * redrawn at the left, the name at the right (right-aligned pair) and one digit - * per printed box. Without a pick not a single pixel is added — the art is the - * empty state. - */ -export const renderColinhaCard = ( - ctx: CardDrawContext, - model: CardModel, - args: ColinhaCardRenderArgs, -): void => { - ctx.imageSmoothingEnabled = true - ctx.imageSmoothingQuality = 'high' - ctx.drawImage(args.image, 0, 0, model.width, model.height) - - const { deputy } = args - if (!deputy) return - - const { mask, copy, office, name, gap, digit } = COLINHA_ROW_LAYOUT - const nameText = deputy.name.toLocaleUpperCase('pt-BR') - - // The label keeps its own face/weight; the pair (label + gap + name) is - // right-aligned on `copy.right` and may use the whole white mask band, so the - // name only shrinks when it would leave the mask — the gate's own CSS does - // the same (its `nowrap` pair overflows the declared copy width to the left). - ctx.font = `${office.weight} ${office.fontSize}px ${args.fontFamily}` - const officeWidth = ctx.measureText(COLINHA_ESTADUAL_LABEL).width - const nameFit = fitColinhaRowName(nameText, args.measure, copy.right - mask.x - officeWidth - gap) - const baseline = copy.top + args.measure('X', nameFit.fontSize).actualBoundingBoxAscent - // The label sits just left of the drawn name: measure with the same tracking. - ctx.letterSpacing = `${name.letterSpacingEm * nameFit.fontSize}px` - const nameWidth = args.measure(nameFit.text, nameFit.fontSize).width - ctx.letterSpacing = '0px' - - ctx.fillStyle = mask.fill - ctx.fillRect(mask.x, mask.y, mask.width, mask.height) - - ctx.fillStyle = office.color - ctx.font = `${office.weight} ${office.fontSize}px ${args.fontFamily}` - ctx.textAlign = 'right' - ctx.textBaseline = 'alphabetic' - ctx.letterSpacing = '0px' - ctx.fillText(COLINHA_ESTADUAL_LABEL, copy.right - nameWidth - gap, baseline) - - ctx.fillStyle = name.color - ctx.font = `${name.weight} ${nameFit.fontSize}px ${args.fontFamily}` - ctx.letterSpacing = `${name.letterSpacingEm * nameFit.fontSize}px` - ctx.fillText(nameFit.text, copy.right, baseline) - ctx.letterSpacing = '0px' - - ctx.fillStyle = digit.color - ctx.font = `${digit.weight} ${digit.fontSize}px ${COLINHA_DIGIT_FONT_FAMILY}` - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - const centerY = digit.top + digit.height / 2 - const digits = [...deputy.ballotNumber] - digits.forEach((glyph, index) => { - ctx.fillText(glyph, digit.left + digit.width / 2 + index * digit.step, centerY) - }) -} diff --git a/src/lib/contentPieceCatalog.ts b/src/lib/contentPieceCatalog.ts index 36811e01..5cf4f4a9 100644 --- a/src/lib/contentPieceCatalog.ts +++ b/src/lib/contentPieceCatalog.ts @@ -35,7 +35,7 @@ import { SPEECH_TOPICS, type SpeechTopic } from '@/lib/speechFacets' import { buildHighlightedExcerpt, type SpeechHighlightPart } from '@/lib/speechHighlight' import { normalizeForSearch, uniqueByNormalizedForm } from '@/lib/speechSearch' -export const CONTENT_PIECE_CATALOG_PATH = '/conteudos' +const CONTENT_PIECE_CATALOG_PATH = '/conteudos' export const contentPiecePublicPath = (slug: string): string => `${CONTENT_PIECE_CATALOG_PATH}/${slug}` @@ -52,7 +52,7 @@ export const contentPieceMediaPath = (slug: string): string => export const contentPieceFramePath = (slug: string): string => `${contentPiecePublicPath(slug)}/frame` -export const CONTENT_PIECE_CATALOG_FACETS = [ +const CONTENT_PIECE_CATALOG_FACETS = [ 'tipo', 'cidade', 'regiao', @@ -61,9 +61,9 @@ export const CONTENT_PIECE_CATALOG_FACETS = [ 'lideranca', ] as const -export type ContentPieceCatalogFacet = (typeof CONTENT_PIECE_CATALOG_FACETS)[number] +type ContentPieceCatalogFacet = (typeof CONTENT_PIECE_CATALOG_FACETS)[number] -export const contentPieceCatalogFacetLabels: Record<ContentPieceCatalogFacet, string> = { +const contentPieceCatalogFacetLabels: Record<ContentPieceCatalogFacet, string> = { tipo: 'Tipo', cidade: 'Cidade', regiao: 'Região', @@ -77,7 +77,7 @@ export const contentPieceCatalogFacetLabels: Record<ContentPieceCatalogFacet, st * for related terms; the literal search is the default and is never serialized * (existing deep links stay byte-identical). Only meaningful alongside `q`. */ -export type ContentPieceCatalogMode = 'tema' +type ContentPieceCatalogMode = 'tema' export type ContentPieceCatalogParams = { tipo: ContentPieceType | null diff --git a/src/lib/contentPieceHomeSelection.ts b/src/lib/contentPieceHomeSelection.ts deleted file mode 100644 index 8fe606b8..00000000 --- a/src/lib/contentPieceHomeSelection.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * S39 — pure contract of the home section that advertises the Central de - * Conteúdos: the lean projection of a published piece, the visitor's location - * labels resolved from the committed Bahia meshes and the sample selection - * (município → região → recentes). - * - * No I/O and no `server-only`: the cached read, the client island and the unit - * tests share this module. The visitor's position is only ever resolved in the - * browser — this module never sees an IP and never persists anything. - */ -import type { - MunicipalityGeometryModule, - MunicipalityZoneGeometryModule, - TerritoryGeometryModule, -} from '@/lib/bahiaGeometriesTypes' -import type { ContentPiecePublicItem } from '@/lib/contentPieceCatalog' -import { findContainingFeature, type GeoPoint } from '@/lib/municipalityProximity' -import { slugify } from '@/lib/slug' - -/** - * What the home card actually renders — the full public item carries the - * normalized `searchText` haystack (transcript + description) and the raw - * `description`, which must never ride the home's static HTML to every visitor. - */ -export type ContentPieceHomeItem = Pick< - ContentPiecePublicItem, - | 'id' - | 'slug' - | 'title' - | 'type' - | 'typeLabel' - | 'origin' - | 'originLabel' - | 'isLink' - | 'sourceUrl' - | 'cityLabel' - | 'regionLabel' - | 'excerpt' - | 'durationLabel' - | 'metaLabel' - | 'publicPath' - | 'file' - | 'framePath' -> - -/** Field-by-field on purpose: a new public-item field never leaks by spread. */ -export const toContentPieceHomeItem = (item: ContentPiecePublicItem): ContentPieceHomeItem => ({ - id: item.id, - slug: item.slug, - title: item.title, - type: item.type, - typeLabel: item.typeLabel, - origin: item.origin, - originLabel: item.originLabel, - isLink: item.isLink, - sourceUrl: item.sourceUrl, - cityLabel: item.cityLabel, - regionLabel: item.regionLabel, - excerpt: item.excerpt, - durationLabel: item.durationLabel, - metaLabel: item.metaLabel, - publicPath: item.publicPath, - file: item.file, - framePath: item.framePath, -}) - -/** - * Where the visitor is, in the same vocabulary the catalogue facets use: the - * município name (a Salvador ZE resolves to "Salvador — ZE N", the exact - * municipality record name) and the identity territory name. - */ -export type ContentPieceHomeVisitor = { - municipalityName: string | null - region: string | null -} - -/** - * Point → visitor labels over the committed meshes, in containment order - * zone → município → território: the Salvador ZE mesh is finer than the - * municipal polygon and its feature names are the catalog names, so the zone - * match survives; a point outside the municipal mesh is outside Bahia. - * - * A coarse fix (network/IP guess, `accuracyM > COARSE_ACCURACY_M`) may name a - * neighbouring município with total confidence, so only the region is kept. - */ -export const resolveContentPieceHomeVisitor = ({ - point, - coarse = false, - municipalityGeometry, - zoneGeometry, - territoryGeometry, -}: { - point: GeoPoint - coarse?: boolean - municipalityGeometry: Pick<MunicipalityGeometryModule, 'features'> - zoneGeometry?: Pick<MunicipalityZoneGeometryModule, 'features'> | null - territoryGeometry?: Pick<TerritoryGeometryModule, 'features'> | null -}): ContentPieceHomeVisitor | null => { - const containingZone = zoneGeometry - ? findContainingFeature(zoneGeometry.features, point) - : undefined - const containingMunicipality = findContainingFeature(municipalityGeometry.features, point) - - if (!containingZone && !containingMunicipality) return null - - const municipalityName = coarse - ? null - : (containingZone?.properties.name ?? containingMunicipality?.properties.name ?? null) - const region = territoryGeometry - ? (findContainingFeature(territoryGeometry.features, point)?.properties.name ?? null) - : null - - if (!municipalityName && !region) return null - - return { municipalityName, region } -} - -/** Where a sampled piece came from — the card's provenance tag. */ -export type ContentPieceHomeMatch = 'municipality' | 'region' | 'recent' - -export type ContentPieceHomeSelection = { - item: ContentPieceHomeItem - match: ContentPieceHomeMatch -} - -const normalized = (value: string | null): string => (value ? slugify(value) : '') - -/** - * Up to `limit` pieces for the visitor, in the intention's order: the município - * first, then the region, then the newest published pieces to fill the sample. - * `items` already arrives newest-first (the cached read sorts by `-publishedAt`), - * so every bucket keeps that order and the dedup is by id. - * - * `null` (or an out-of-Bahia) visitor is the recent selection — never an error. - */ -export const selectContentPieceHomeItems = ( - items: readonly ContentPieceHomeItem[], - visitor: ContentPieceHomeVisitor | null, - limit = 3, -): ContentPieceHomeSelection[] => { - const selected: ContentPieceHomeSelection[] = [] - const seen = new Set<number>() - - const take = (item: ContentPieceHomeItem, match: ContentPieceHomeMatch): void => { - if (selected.length >= limit || seen.has(item.id)) return - seen.add(item.id) - selected.push({ item, match }) - } - - if (visitor) { - const municipality = normalized(visitor.municipalityName) - const region = normalized(visitor.region) - - if (municipality) { - for (const item of items) { - if (selected.length >= limit) break - if (normalized(item.cityLabel) === municipality) take(item, 'municipality') - } - } - if (region) { - for (const item of items) { - if (selected.length >= limit) break - if (normalized(item.regionLabel) === region) take(item, 'region') - } - } - } - - for (const item of items) { - if (selected.length >= limit) break - take(item, 'recent') - } - - return selected -} diff --git a/src/lib/contentPieceShare.ts b/src/lib/contentPieceShare.ts deleted file mode 100644 index 406010f3..00000000 --- a/src/lib/contentPieceShare.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * S27 — pure share contract of the Central de Conteúdos: the vote message per - * piece type (the literals of the gate, always editable by the voter) and the - * link each piece shares. The system never sends anything: the sheet opens the - * sender's own WhatsApp with the text prefilled (`wa.me`, no recipient). - * - * The S4 home vocabulary (`src/lib/contentShare.ts`) stays its own owner — a - * different message family with its own pinned contract. - */ -import type { ContentPieceType } from '@/lib/contentPiece' -import { contentPiecePublicPath } from '@/lib/contentPieceCatalog' -import { buildContentShareLink } from '@/lib/contentShare' -import { buildWhatsAppTextShareUrl } from '@/lib/phone' - -/** Link-piece sources share the platform URL; an archived piece shares its page. */ -export type ContentPieceShareSource = { - slug: string - isLink: boolean - sourceUrl: string | null -} - -/** - * Vote message per piece type (gate literals, "assumidas — validar com a - * comunicação"): the voter reviews the text in the sheet before opening - * WhatsApp, so the template is a starting point, never an automatic send. - */ -export const contentPieceVoteMessage = ( - type: ContentPieceType, - title: string, - link: string, -): string => { - if (type === 'video') { - return `Estamos na reta final e o Jorge Solla 1313 precisa do seu voto. Olha esse vídeo: ${title} — ${link}. Peça voto pra Solla 1313 pra quem você conhece.` - } - if (type === 'audio') { - return `Ouça e mande pro grupo: ${title} — ${link}. Peça voto pra Solla 1313.` - } - return `Fiz/achei esse material do Solla 1313: ${title} — ${link}. Peça voto pra Solla 1313 também.` -} - -/** - * The link a piece shares: the canonical platform URL for a link piece (never - * a second copy), the public piece page for an archived one. Absolute against - * the visitor's origin so the message carries a clickable URL (the S4 - * fail-soft resolver is the single owner of that absolutization). - */ -export const contentPieceShareLink = (piece: ContentPieceShareSource, origin: string): string => - piece.isLink && piece.sourceUrl - ? piece.sourceUrl - : buildContentShareLink(contentPiecePublicPath(piece.slug), origin) - -/** `wa.me/?text=` with the prefilled message — no recipient, no automatic send. */ -export const buildContentPieceWhatsAppUrl = (message: string): string => - buildWhatsAppTextShareUrl(message) diff --git a/src/lib/jingle.ts b/src/lib/jingle.ts deleted file mode 100644 index 14d52834..00000000 --- a/src/lib/jingle.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { slugify } from '@/lib/slug' - -/** - * S21 — pure shapes of the public jingles page. The server read resolves the - * cover/audio media and hands the client a serializable view model, so the - * download name and the fail-closed card rules live here (client-safe, no - * Payload imports). - */ - -export type JingleMedia = { - url?: string | null - alt?: string | null - filename?: string | null -} - -export type JingleSource = { - id: number - title: string - slug?: string | null -} - -export type JingleViewModel = { - id: number - title: string - coverUrl: string - coverAlt: string - audioUrl: string - downloadFilename: string -} - -const DOWNLOAD_PREFIX = 'jorge-solla-1313' -const FALLBACK_SLUG = 'jingle' -const FALLBACK_EXTENSION = 'mp3' - -/** - * Legible download name shown under the button and sent in the `download` - * attribute: the stored upload filename can carry accents/spaces, so the base - * comes from the slug and the extension from the actual file (fallback MP3). - */ -export const jingleDownloadFilename = ( - slug: string | null | undefined, - audioFilename?: string | null, -): string => { - const base = slugify(slug ?? '') || FALLBACK_SLUG - const lastDot = audioFilename?.lastIndexOf('.') ?? -1 - const extension = lastDot > 0 ? audioFilename?.slice(lastDot + 1).toLowerCase() : null - const safeExtension = - extension && /^[a-z0-9]{1,5}$/.test(extension) ? extension : FALLBACK_EXTENSION - - return `${DOWNLOAD_PREFIX}-${base}.${safeExtension}` -} - -const FEAT_CREDIT_PATTERN = /\s*\(\s*feat\.\s+([^()]+?)\s*\)\s*$/i - -/** - * S26 — display-only split of the featured artist. Published titles carry the - * guest as a trailing `(feat. …)` segment; the card shows the base on its own - * heading and the credit on a second line. The stored title is never rewritten: - * the full string keeps feeding the aria-labels and the cover alt, and a title - * without the segment falls back to itself with no credit line. - */ -export const splitJingleTitle = (title: string) => { - const match = title.match(FEAT_CREDIT_PATTERN) - if (!match) return { base: title, credit: null } - - const base = title.slice(0, match.index).trim() - const artist = match[1].trim() - if (!base || !artist) return { base: title, credit: null } - - return { base, credit: `feat. ${artist}` } -} - -/** - * A card only exists when both media are readable: an unpopulated/missing - * cover or audio fails closed (the jingle is skipped, never rendered broken). - */ -export const toJingleViewModel = ({ - jingle, - cover, - audio, -}: { - jingle: JingleSource - cover: JingleMedia | null - audio: JingleMedia | null -}): JingleViewModel | null => { - if (!cover?.url || !audio?.url) return null - - return { - id: jingle.id, - title: jingle.title, - coverUrl: cover.url, - coverAlt: cover.alt?.trim() || `Capa do jingle ${jingle.title} de Jorge Solla 1313`, - audioUrl: audio.url, - downloadFilename: jingleDownloadFilename(jingle.slug, audio.filename), - } -} diff --git a/src/lib/municipalityProximity.ts b/src/lib/municipalityProximity.ts index ca535295..cc9c26b0 100644 --- a/src/lib/municipalityProximity.ts +++ b/src/lib/municipalityProximity.ts @@ -190,7 +190,7 @@ export const featureCentroid = (feature: PolygonalFeature): GeoPoint => { * not by this scan's order: the mesh is TopoJSON-derived, so neighbours share * numerically identical arcs and never overlap. */ -export const findContainingFeature = <F extends PolygonalFeature>( +const findContainingFeature = <F extends PolygonalFeature>( features: readonly F[], point: GeoPoint, ): F | undefined => features.find((feature) => featureContainsPoint(feature, point)) diff --git a/src/lib/publicSite.ts b/src/lib/publicSite.ts new file mode 100644 index 00000000..afc8124a --- /dev/null +++ b/src/lib/publicSite.ts @@ -0,0 +1,28 @@ +/** + * S47 — the public-site vocabulary shared by the post-election chrome: the + * canonical paths, the official social profiles and the public focus recipe. + * The home header/footer, the internal headers and the content board render + * from here so the campaign number and the old discovery links never grow back + * in one surface while another is edited. + */ +export const PUBLIC_PATHS = { + home: '/', + artigos: '/artigos', + fotos: '/fotos', + privacidade: '/privacidade', +} as const + +export const PUBLIC_SOCIAL_LINKS = { + instagram: { label: 'Instagram', href: 'https://instagram.com/depjorgesolla' }, + youtube: { label: 'YouTube', href: 'https://youtube.com/@JorgeSollaDep' }, + facebook: { label: 'Facebook', href: 'https://facebook.com/depjorgesolla' }, +} as const + +export const PUBLIC_SOCIAL_LINK_LIST = Object.values(PUBLIC_SOCIAL_LINKS) + +/** + * C243 focus recipe for light public surfaces: 3px yellow outline with the + * dark-blue separator ring. On dark surfaces the yellow edge stays visible. + */ +export const PUBLIC_FOCUS = + 'focus-visible:outline-[3px] focus-visible:outline-offset-4 focus-visible:outline-(--pt-yellow) focus-visible:shadow-[0_0_0_6px_#184e92] focus-visible:outline' diff --git a/src/lib/radio.ts b/src/lib/radio.ts deleted file mode 100644 index 3b2b32bf..00000000 --- a/src/lib/radio.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { buildWhatsAppTextShareUrl } from './phone' - -/** - * S25 — the single owner of the Rádio 1313 contract: the zeno.fm stream the - * player points at, the public page the share exits open, the fixed display - * title (the stream's own `icy-name`) and the literal share message. Pure and - * client-safe, so the player state machine and the unit tests pin the same - * values without React. - */ -export const RADIO_STREAM_URL = 'https://stream.zeno.fm/hys86kx6k16tv' -export const RADIO_PAGE_URL = 'https://zeno.fm/radio/jorge-solla-1313/' -export const RADIO_TITLE = 'Rádio Jorge Solla 1313' -export const RADIO_SHARE_MESSAGE = `Ouça a ${RADIO_TITLE} — ${RADIO_PAGE_URL}` - -/** - * How long `connecting` may last before the player admits the failure. The - * stream answers a 302 with a per-request signed CDN URL (usually under 2s), so - * a connection that accepts and never delivers audio must not spin forever — - * it becomes the honest `error` state with the retry and "Ouvir no Zeno" exits. - */ -export const RADIO_CONNECT_TIMEOUT_MS = 12_000 - -/** - * S25 — one audio at a time in the home sound section. The radio and the - * jingles are sibling client components (the section shell is a server - * component), so the exclusivity is a `window` CustomEvent broadcast — the repo - * precedent (`signature:created`, the speech excerpt request). Each listener - * only ever touches its own media element, which keeps the events inert outside - * the home (`/jingles` has no radio, so `jingle:play` has no consumer there). - */ -export const RADIO_PLAY_EVENT = 'radio:play' -export const JINGLE_PLAY_EVENT = 'jingle:play' - -/** `wa.me` with the literal message and no recipient — the sender's own WhatsApp picks the chat. */ -export const buildRadioShareWhatsAppUrl = (): string => - buildWhatsAppTextShareUrl(RADIO_SHARE_MESSAGE) diff --git a/src/lib/schemas/campaignNewsletter.ts b/src/lib/schemas/campaignNewsletter.ts deleted file mode 100644 index 641a74f1..00000000 --- a/src/lib/schemas/campaignNewsletter.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { brazilianMobile, optionalPersistedEmail } from '@/lib/schemas/primitives' -import { z } from 'zod' - -import { commentSchema } from './comment' -import { contactCityFieldSchema, contactNameSchema, optionalContactStateSchema } from './contact' - -/** - * S9 — the campaign home "novidades" capture. Name + WhatsApp are required - * (the phone feeds the campaign's WhatsApp groups); email, state, city and - * comment are optional. `campaignLevel` carries the pre-selected engagement - * toggle: 'time' (frequent communications + WhatsApp groups + actions) or - * 'esporadico' (occasional communications only) — recorded on the - * subscription so the admin can tell who may join groups. - */ -export const campaignNewsletterSchema = z.object({ - name: contactNameSchema, - phone: brazilianMobile, - email: optionalPersistedEmail, - state: optionalContactStateSchema, - city: contactCityFieldSchema, - comment: commentSchema.shape.comment, - campaignLevel: z.enum(['time', 'esporadico']).default('time'), -}) - -export type CampaignNewsletterInput = z.input<typeof campaignNewsletterSchema> diff --git a/src/lib/schemas/contact.ts b/src/lib/schemas/contact.ts index f34ed595..c458d46e 100644 --- a/src/lib/schemas/contact.ts +++ b/src/lib/schemas/contact.ts @@ -16,9 +16,9 @@ const contactGenders = personGenders /** * Full-name rule for people records (required across the public and staff - * flows). Exported so sibling schemas reuse it instead of re-declaring. + * flows); shared by the sibling schemas in this module. */ -export const contactNameSchema = z +const contactNameSchema = z .string() .trim() .min(2, 'Nome deve ter pelo menos 2 caracteres') @@ -38,15 +38,6 @@ export const contactStateSchema = z.custom<StateKey>( 'Estado inválido', ) -/** - * Optional-state variant: the public combobox clears to '' (FormCombobox - * `onValueChange`), so the union accepts it and collapses ''/undefined. - */ -export const optionalContactStateSchema = z - .union([contactStateSchema, z.literal('')]) - .optional() - .transform((value) => (value === '' || value === undefined ? undefined : value)) - /** * Required free-text city rule (trim + min/max with the same messages the ficha * schemas already used). Exported so the ficha schemas and the optional variant @@ -60,10 +51,9 @@ export const contactCitySchema = z /** * Optional free-text city (''/undefined collapse). Composed over `contactCitySchema` - * so it inherits the same min/max messages, and exported so sibling schemas reuse - * the same optional-city rule as the ficha's mobile sheet. + * so it inherits the same min/max messages; shared by the ficha schemas below. */ -export const contactCityFieldSchema = z +const contactCityFieldSchema = z .union([contactCitySchema, z.literal('')]) .optional() .transform((value) => (value === '' || value === undefined ? undefined : value)) diff --git a/src/utilities/ai/themeSearchGuard.ts b/src/utilities/ai/themeSearchGuard.ts index 6c9d06ee..67429f9c 100644 --- a/src/utilities/ai/themeSearchGuard.ts +++ b/src/utilities/ai/themeSearchGuard.ts @@ -159,10 +159,3 @@ export const createThemeSearchGuard = (options: ThemeSearchGuardOptions = {}): T return { resolve } } - -/** - * The process-wide guard: one in-memory limiter and cache, reset on deploy. The - * cache key is the normalized public query (no PII, no user id); a `null` - * expansion is never stored, so a later request can retry. - */ -export const resolveGuardedThemeExpansion = createThemeSearchGuard().resolve diff --git a/src/utilities/campaignConsent.ts b/src/utilities/campaignConsent.ts index 0cd95ca5..d835b31d 100644 --- a/src/utilities/campaignConsent.ts +++ b/src/utilities/campaignConsent.ts @@ -4,8 +4,6 @@ import type { Payload } from 'payload' import { CAMPAIGN_INVITE_CONSENT_KEY, - CAMPAIGN_NEWSLETTER_CONSENT_KEY, - CAMPAIGN_NEWSLETTER_CONSENT_MISSING_MESSAGE, CAMPAIGN_PUSH_CONSENT_KEY, CAMPAIGN_PUSH_CONSENT_MISSING_MESSAGE, SUPPORTER_REGISTRATION_CONSENT_KEY, @@ -159,10 +157,3 @@ export const requireCampaignPushConsent = ( message = CAMPAIGN_PUSH_CONSENT_MISSING_MESSAGE, ): Promise<ConsentDescriptor> => requireConsentByKey(payload, CAMPAIGN_PUSH_CONSENT_KEY, req, message) - -export const requireCampaignNewsletterConsent = ( - payload: Payload, - req?: ConsentRequest, - message = CAMPAIGN_NEWSLETTER_CONSENT_MISSING_MESSAGE, -): Promise<ConsentDescriptor> => - requireConsentByKey(payload, CAMPAIGN_NEWSLETTER_CONSENT_KEY, req, message) diff --git a/src/utilities/content/contentPieceReads.ts b/src/utilities/content/contentPieceReads.ts index e907c767..ac9ca1c8 100644 --- a/src/utilities/content/contentPieceReads.ts +++ b/src/utilities/content/contentPieceReads.ts @@ -15,7 +15,7 @@ import { getPayload } from 'payload' * access (`canReadContentPiece` is campaign-only), so the anonymous read is the * Local API without `user` — the bypass default — and the gate is the query * plus the `contentPieceIsPublic` predicate: only `publicado` with an archived - * file or the platform link ever leaves here. Same precedent as `jingleReads`. + * file or the platform link ever leaves here. * * The listing is cached under the collection listing tag (`contentPieces`, * busted by the ContentPiece afterChange on every publish/unpublish/edit), and @@ -69,13 +69,13 @@ const getCachedPublishedContentPieces = () => * (`transcript`/`description` included, for the S28 theme provenance). The * caller maps them into view models — with or without theme terms. */ -export const getPublishedContentPieceRecords = async (): Promise<ContentPiecePublicSource[]> => { +const getPublishedContentPieceRecords = async (): Promise<ContentPiecePublicSource[]> => { const { docs } = await getCachedPublishedContentPieces()() return docs } /** Published pieces as serializable public view models, newest first. */ -export const getPublishedContentPieceItems = async (): Promise<ContentPiecePublicItem[]> => +const getPublishedContentPieceItems = async (): Promise<ContentPiecePublicItem[]> => (await getPublishedContentPieceRecords()) .map((piece) => toContentPiecePublicItem(piece)) .filter((item): item is ContentPiecePublicItem => item !== null) @@ -89,11 +89,3 @@ export const getPublishedContentPieceBySlug = async ( slug: string, ): Promise<ContentPiecePublicItem | null> => (await getPublishedContentPieceItems()).find((item) => item.slug === slug) ?? null - -/** - * Discovery flag for the footer link and the header badge: derived from the - * SAME resolved list the page renders, so a piece that cannot render never - * advertises a link to an empty Central. - */ -export const hasPublishedContentPieces = async (): Promise<boolean> => - (await getPublishedContentPieceItems()).length > 0 diff --git a/src/utilities/content/contentPieceThemeSearch.ts b/src/utilities/content/contentPieceThemeSearch.ts deleted file mode 100644 index 7b567b12..00000000 --- a/src/utilities/content/contentPieceThemeSearch.ts +++ /dev/null @@ -1,127 +0,0 @@ -import 'server-only' - -import { - cardCatalogItems, - contentCatalogItems, - contentPieceCatalogActiveFilters, - contentPieceCatalogFacets, - contentPieceThemeTerms, - filterContentPieceCatalogItems, - parseContentPieceCatalogParams, - toContentPiecePublicItem, - type ContentCatalogItem, - type ContentPieceCatalogActiveFilter, - type ContentPieceCatalogFacets, - type ContentPieceCatalogParams, - type ContentPieceCatalogSearchParams, - type ContentPiecePublicItem, -} from '@/lib/contentPieceCatalog' -import { - expandContentPieceSearchTheme, - type ThemeSearchExpansionResolver, -} from '@/utilities/ai/expandSpeechSearchTheme' -import { - resolveGuardedThemeExpansion, - type ThemeSearchHeaders, -} from '@/utilities/ai/themeSearchGuard' -import { getPublishedContentPieceRecords } from '@/utilities/content/contentPieceReads' -import { headers } from 'next/headers' - -/** - * S28 — the public catalogue search, both modes. The literal search is the - * default and the theme mode is an OR over the literal query plus the expanded - * terms, over the SAME cached published records the S27 surface reads. The - * expansion only runs for `mode=tema` with a query, only when the Central has - * something published, and always through the anonymous guard (limit + - * eligibility + cache): the caller renders the discreet notice whenever it - * resolves `null`. - * - * S38 — the same loader appends the six synthetic card items to the board (and - * only there): facets and filter below see one union, so Tipo "Card" and the - * nickname search work without a second pipeline. - */ -export type ContentPieceCatalogSearchData = { - /** - * Public pieces on the Central before filters: zero renders the honest empty - * state. S38 — cards never count here (they only ride along a non-empty - * Central), so the guardrail of the empty state is this number alone. - */ - publishedCount: number - params: ContentPieceCatalogParams - items: ContentCatalogItem[] - facets: ContentPieceCatalogFacets - activeFilters: ContentPieceCatalogActiveFilter[] - /** The actor asked for the theme mode (always with a query): the badges state the provenance. */ - themeMode: boolean - /** The actor asked for `mode=tema` but the mechanism was unavailable: degrade with the notice. */ - themeUnavailable: boolean - /** The expansion actually contributed terms (an empty list is not "applied"). */ - themeApplied: boolean -} - -const toPublicItems = ( - records: Awaited<ReturnType<typeof getPublishedContentPieceRecords>>, - themeTerms: readonly string[] = [], -): ContentPiecePublicItem[] => - records - .map((record) => toContentPiecePublicItem(record, { themeTerms })) - .filter((item): item is ContentPiecePublicItem => item !== null) - -/** The request headers via the dynamic API, imported here so the page stays static-ish. */ -const headersFromRequest = async (): Promise<ThemeSearchHeaders> => (await headers()) as Headers - -export const loadContentPieceCatalogSearch = async ({ - rawSearchParams, - requestHeaders, - expandTheme = expandContentPieceSearchTheme, -}: { - rawSearchParams: ContentPieceCatalogSearchParams - /** - * The request headers the anonymous guard reads. Optional: the page omits it - * and the loader reads `next/headers` ONLY on the theme path — the literal - * catalogue must not opt the render into the dynamic API, which defers the - * whole page behind the `loading.tsx` boundary (streamed into a hidden div) - * and leaves a transient duplicate of the DOM in the production build. - */ - requestHeaders?: ThemeSearchHeaders - expandTheme?: ThemeSearchExpansionResolver -}): Promise<ContentPieceCatalogSearchData> => { - const records = await getPublishedContentPieceRecords() - const params = parseContentPieceCatalogParams(rawSearchParams) - const publishedItems = toPublicItems(records) - - const themeRequested = params.mode === 'tema' - let themeTerms: string[] = [] - let themeUnavailable = false - - // An empty Central has nothing to search: never pay for an expansion there. - if (themeRequested && publishedItems.length > 0) { - const headers = requestHeaders ?? (await headersFromRequest()) - const expansion = await resolveGuardedThemeExpansion({ - q: params.q, - headers, - expandTheme, - }) - if (expansion) themeTerms = contentPieceThemeTerms(params.q, expansion.terms) - else themeUnavailable = true - } - - const publicItems = themeTerms.length > 0 ? toPublicItems(records, themeTerms) : publishedItems - // S38 — the six card models are synthetic items of the same board, appended - // only while the Central has published pieces (the guard lives in the pure - // builder): an empty Central keeps the honest empty state, never a board of - // cards. - const catalogItems = contentCatalogItems(publicItems, publishedItems.length, cardCatalogItems()) - const facets = contentPieceCatalogFacets(catalogItems) - - return { - publishedCount: publishedItems.length, - params, - items: filterContentPieceCatalogItems(catalogItems, params, themeTerms), - facets, - activeFilters: contentPieceCatalogActiveFilters(params, facets), - themeMode: themeRequested, - themeUnavailable, - themeApplied: themeTerms.length > 0, - } -} diff --git a/src/utilities/jingleReads.ts b/src/utilities/jingleReads.ts deleted file mode 100644 index c512d466..00000000 --- a/src/utilities/jingleReads.ts +++ /dev/null @@ -1,80 +0,0 @@ -import 'server-only' - -import type { Jingle, Media } from '@/payload-types' - -import { toJingleViewModel, type JingleViewModel } from '@/lib/jingle' -import { getCachedDocumentById, isNotFoundError } from '@/utilities/documentReads' -import { getCollectionListingTag } from '@/utilities/documents' -import configPromise from '@payload-config' -import { unstable_cache } from 'next/cache' -import { getPayload } from 'payload' - -/** - * S21 — public reads of the jingles page and of the footer discovery link. - * The listing is cached by the `jingles` collection listing tag (busted by - * `revalidateJinglesListing` on Jingle afterChange/afterDelete) and stores - * depth-0 docs; each cover/audio is resolved through `getCachedDocumentById`, - * so replacing the FILE in the media doc (Media.afterChange busts - * `document_media:<id>`) also refreshes the card without editing the jingle. - */ - -const findPublishedJingles = () => - getPayload({ config: configPromise }).then((payload) => - payload.find({ - collection: 'jingle', - where: { published: { equals: true } }, - sort: ['order', 'title'], - depth: 0, - limit: 0, - }), - ) - -const getCachedPublishedJingles = () => - unstable_cache(findPublishedJingles, ['jingles'], { - tags: [getCollectionListingTag('jingle')], - }) - -const mediaIdOf = (value: Jingle['coverImage']): number | null => - typeof value === 'number' ? value : (value?.id ?? null) - -/** - * A media row deleted while still referenced is blocked by the FK; a direct - * write that left the id dangling resolves to "no media" and the card fails - * closed instead of rendering a broken player. Real read failures stay loud. - */ -const readMedia = async (id: number | null): Promise<Media | null> => { - if (id === null) return null - - try { - return await getCachedDocumentById('media', id)() - } catch (error) { - if (isNotFoundError(error)) return null - throw error - } -} - -/** Published jingles as serializable view models, missing media skipped. */ -export const getPublishedJingleItems = async (): Promise<JingleViewModel[]> => { - const { docs } = await getCachedPublishedJingles()() - - const items = await Promise.all( - docs.map(async (jingle) => { - const [cover, audio] = await Promise.all([ - readMedia(mediaIdOf(jingle.coverImage)), - readMedia(mediaIdOf(jingle.audio)), - ]) - - return toJingleViewModel({ jingle, cover, audio }) - }), - ) - - return items.filter((item): item is JingleViewModel => item !== null) -} - -/** - * Discovery flag for the footer link: derived from the SAME resolved list the - * page renders, so a published row that cannot render (dangling media) never - * advertises a link to an empty page. - */ -export const hasPublishedJingles = async (): Promise<boolean> => - (await getPublishedJingleItems()).length > 0 diff --git a/src/utilities/seo.ts b/src/utilities/seo.ts index 8128818e..02750e0c 100644 --- a/src/utilities/seo.ts +++ b/src/utilities/seo.ts @@ -39,7 +39,7 @@ export type SiteMetadataSource = { export const SITE_METADATA_DEFAULTS = { title: 'Jorge Solla', siteName: 'Jorge Solla', - description: 'Notícias, campanhas e a atuação do deputado Jorge Solla pela Bahia.', + description: 'Notícias, o mandato e a atuação do deputado Jorge Solla pela Bahia.', twitterCreator: '@jorgesolla', } as const diff --git a/tests/e2e/campaignHomePixel.e2e.spec.ts b/tests/e2e/campaignHomePixel.e2e.spec.ts index da768970..b9ca3e23 100644 --- a/tests/e2e/campaignHomePixel.e2e.spec.ts +++ b/tests/e2e/campaignHomePixel.e2e.spec.ts @@ -10,11 +10,9 @@ import { expect, test } from './fixtures/e2eTest' * base code (inline `next/script` + `fbq` init) and nothing at all without a * configured ID (fail-closed). The external `fbevents.js` fetch is fulfilled * locally by the shared e2e guard (fixtures/e2eTest.ts), so no spec ever - * depends on the Meta CDN; the base code itself is inline. The `Lead` wiring - * (successful S9 capture → exactly one queued `fbq` Lead) is pinned by the - * unit spec `campaignNewsletterLead.unit.spec.tsx` — a real submission here - * would share the `campanha-novidades` consent key with the parallel - * `campaignNewsletter` spec's purge/create lifecycle. Serial mode (precedent + * depends on the Meta CDN; the base code itself is inline. S47 removed the + * campaign newsletter capture, so the old capture→Lead assertion is gone; the + * pixel stays a page-level PageView. Serial mode (precedent * `campaignNearestMunicipality`): the tests share one worker — a parallel * split would race two `seedTestUser` beforeAlls. */ diff --git a/tests/e2e/campaignNewsletter.e2e.spec.ts b/tests/e2e/campaignNewsletter.e2e.spec.ts deleted file mode 100644 index 1d9e9127..00000000 --- a/tests/e2e/campaignNewsletter.e2e.spec.ts +++ /dev/null @@ -1,286 +0,0 @@ -import type { APIRequestContext } from '@playwright/test' - -import { CAMPAIGN_NEWSLETTER_CONSENT_KEY } from '@/lib/campaignConsentKeys' -import { adminHeaders } from '../helpers/adminApi' -import { seedTestUser } from '../helpers/seedUser' -import { expect, test } from './fixtures/e2eTest' - -/** - * S9 — the campaign home "novidades" capture: the hero CTA anchors the - * section, the form records the contact + subscription with the engagement - * level, and the page confirms in place. Runs the write through the deployed - * server (server action + Local API) so the fail-closed consent path and the - * transaction are the production ones. - * - * The home renders published content pieces; a concurrent curated spec can - * publish one whose still is not on disk yet, and `/conteudos/<slug>/frame` - * answers 404 until the self-heal lands (C226) — the same declaration as - * `frontend.e2e.spec.ts`. - */ -test.use({ expectedRequestFailurePaths: [/\/conteudos\/.+\/frame$/] }) -test.describe('Campaign home novidades capture', () => { - const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000' - const runSuffix = Date.now() - // Unique 11-digit mobile per run (719 + 8 digits) so a crashed previous run - // can never poison the by-phone lookups. - const phoneDigits = String(runSuffix % 100_000_000).padStart(8, '0') - const successPhone = `719${phoneDigits}` - const successName = `E2e Novidades ${runSuffix}` - - let consentID: number | undefined - - const createNewsletterConsent = async ( - request: APIRequestContext, - headers: Record<string, string>, - ) => { - // Self-healing lifecycle (S1 precedent): an aborted run could leave the - // unique-key row behind and poison the next beforeAll — purge first. The - // consent cannot be deleted while a subscription references it - // (`subscription.consent_id` NOT NULL + FK SET NULL), so purge those too. - const existing = await request.get( - `${baseURL}/api/consent?where[key][equals]=${CAMPAIGN_NEWSLETTER_CONSENT_KEY}&limit=10&depth=0`, - { headers }, - ) - if (existing.ok()) { - const { docs } = await existing.json() - for (const doc of docs) { - const subscriptions = await request.get( - `${baseURL}/api/subscription?where[consent][equals]=${doc.id}&limit=50&depth=0`, - { headers }, - ) - if (subscriptions.ok()) { - const { docs: subscriptionDocs } = await subscriptions.json() - for (const subscription of subscriptionDocs) { - await request.delete(`${baseURL}/api/subscription/${subscription.id}`, { headers }) - } - } - await request.delete(`${baseURL}/api/consent/${doc.id}`, { headers }) - } - } - - const response = await request.post(`${baseURL}/api/consent`, { - headers, - data: { - key: CAMPAIGN_NEWSLETTER_CONSENT_KEY, - text: { - root: { - type: 'root', - children: [ - { - type: 'paragraph', - children: [ - { - type: 'text', - text: 'Consentimento de novidades da campanha (e2e).', - version: 1, - }, - ], - direction: null, - format: '', - indent: 0, - version: 1, - }, - ], - direction: null, - format: '', - indent: 0, - version: 1, - }, - }, - }, - }) - expect(response.ok()).toBeTruthy() - const { doc } = await response.json() - consentID = doc.id - return doc - } - - const cleanup = async (request: APIRequestContext, headers: Record<string, string>) => { - for (const phone of [successPhone]) { - const contacts = await request.get( - `${baseURL}/api/contact?where[phones.value][equals]=${phone}&limit=1&depth=0`, - { headers }, - ) - if (contacts.ok()) { - const { docs } = await contacts.json() - const contact = docs[0] - if (contact) { - const subscriptions = await request.get( - `${baseURL}/api/subscription?where[contact][equals]=${contact.id}&limit=1&depth=0`, - { headers }, - ) - if (subscriptions.ok()) { - const { docs: subscriptionDocs } = await subscriptions.json() - if (subscriptionDocs[0]) { - await request.delete(`${baseURL}/api/subscription/${subscriptionDocs[0].id}`, { - headers, - }) - } - } - await request.delete(`${baseURL}/api/contact/${contact.id}`, { headers }) - } - } - } - if (consentID !== undefined) { - await request.delete(`${baseURL}/api/consent/${consentID}`, { headers }) - } - } - - test.beforeAll(async ({ request }) => { - await seedTestUser() - const headers = await adminHeaders(request, baseURL) - await createNewsletterConsent(request, headers) - }) - - test.afterAll(async ({ request }) => { - const headers = await adminHeaders(request, baseURL).catch(() => undefined) - if (headers) { - await cleanup(request, headers) - } - }) - - test('captures the visitor and confirms in place', async ({ page, request }) => { - // Fresh load (unique query param bypasses the ISR fetch cache, same trick - // as the content-section specs); the app ignores unknown params. - await page.goto(`/?e2e=${runSuffix}`) - await expect(page).toHaveURL(/\/\?e2e=/) - - const section = page.locator('[data-home-section="newsletter"]') - await expect( - section.getByRole('heading', { name: 'Receba as novidades da campanha' }), - ).toBeVisible() - - // The hero CTA is the shortcut into the section (smooth anchor). The URL - // legitimately carries the `?e2e=…` cache-buster query (OPS83), so assert - // only the hash — no trailing `$`. - await page.locator('[data-cta="secondary"]').click() - await expect(page).toHaveURL(/#novidades/) - await expect(section).toBeInViewport() - - // The toggle is pre-selected: the default level is "time". - const levelToggle = section.locator('#campaign-level') - await expect(levelToggle).toBeChecked() - - await section.getByPlaceholder('Nome completo*').fill(successName) - // Digits are formatted by the input itself; the schema normalizes back. - await section.getByPlaceholder('WhatsApp (com DDD)*').fill(successPhone) - await section.getByRole('button', { name: 'QUERO RECEBER NOVIDADES' }).click() - - await expect(section.getByRole('heading', { name: 'Inscrição confirmada' })).toBeVisible() - - // The success card keeps the bandeiras reachable (they sit above). - const flagsLink = section.getByRole('link', { name: 'Ver bandeiras' }) - await expect(flagsLink).toHaveAttribute('href', '#bandeiras') - - // The record is visible to the admin with the level choice. - const headers = await adminHeaders(request, baseURL) - const contacts = await request.get( - `${baseURL}/api/contact?where[phones.value][equals]=${successPhone}&limit=1&depth=0`, - { headers }, - ) - expect(contacts.ok()).toBeTruthy() - const { docs } = await contacts.json() - const contact = docs[0] - expect(contact).toBeDefined() - // NameInput sanitizes/formatts the name on the way in (digits stripped). - expect(contact.name.toLowerCase()).toContain('novidades') - - const subscriptions = await request.get( - `${baseURL}/api/subscription?where[contact][equals]=${contact.id}&limit=1&depth=0`, - { headers }, - ) - expect(subscriptions.ok()).toBeTruthy() - const { docs: subscriptionDocs } = await subscriptions.json() - expect(subscriptionDocs[0]).toBeDefined() - expect(subscriptionDocs[0].campaignLevel).toBe('time') - expect( - typeof subscriptionDocs[0].consent === 'object' - ? subscriptionDocs[0].consent.id - : subscriptionDocs[0].consent, - ).toBe(consentID) - }) - // NOTE: the fail-closed refusal (consent missing → nothing written) is NOT - // e2e-covered on purpose: the server action rejects with a 500, and the - // suite's e2eFailureGuard treats same-origin 5xx as a failure (the WebAuthn - // refusal precedent is a handled 4xx). The refusal path is pinned by the - // int spec `submitCampaignNewsletter.int.spec.ts` (exact message + no rows). -}) - -/** - * S12 — the home owns its scroll container (body `overflow-hidden`), so the - * native fragment navigation lands on the section only through - * `CampaignHashScroll`. These tests pin the deep-link contract on mobile - * (where the `h-dvh` reflow + eager hero images used to kick the page back to - * the top) and the direct-load path on desktop (previously uncovered). - */ -test.describe('Campaign home novidades hash scroll', () => { - const scrollSuffix = Date.now() - - test.use({ viewport: { width: 390, height: 844 } }) - - test('direct #novidades deep link lands on the section (mobile)', async ({ page }) => { - await page.goto(`/?e2e=scroll-${scrollSuffix}#novidades`) - const section = page.locator('[data-home-section="newsletter"]') - await expect( - section.getByRole('heading', { name: 'Receba as novidades da campanha' }), - ).toBeVisible() - await expect(section).toBeInViewport() - }) - - test('hero CTA scrolls to the newsletter section (mobile)', async ({ page }) => { - await page.goto(`/?e2e=scroll-${scrollSuffix}`) - await page.locator('[data-cta="secondary"]').click() - // The URL legitimately carries the `?e2e=scroll-…` cache-buster query, - // so assert only the hash (no trailing `$`), which lands after the click. - await expect(page).toHaveURL(/#novidades/) - await expect(page.locator('[data-home-section="newsletter"]')).toBeInViewport() - }) - - test('deep link respects prefers-reduced-motion (mobile)', async ({ page }) => { - await page.emulateMedia({ reducedMotion: 'reduce' }) - await page.goto(`/?e2e=scroll-${scrollSuffix}#novidades`) - await expect(page.locator('[data-home-section="newsletter"]')).toBeInViewport() - }) - - test('hero CTA keeps the hash when an on-mount refresh lands late', async ({ page }) => { - // The Payload live-preview bridge used to call `router.refresh()` on every - // standalone load; when the soft refresh landed after the CTA click, the - // App Router rewrote the canonical URL and dropped `#novidades` (the - // deploy-verify race). Hold the home soft refresh until after the click so - // the race is deterministic: outside the admin preview there must be no - // refresh at all, and the hash survives. - let releaseRefresh: (() => void) | undefined - const refreshGate = new Promise<void>((resolve) => { - releaseRefresh = resolve - }) - - await page.route( - (url) => url.searchParams.has('_rsc'), - async (route) => { - if (new URL(route.request().url()).pathname !== '/') { - await route.continue() - return - } - await refreshGate - await route.continue() - }, - ) - - await page.goto(`/?e2e=refresh-${scrollSuffix}`) - await page.locator('[data-cta="secondary"]').click() - await expect(page).toHaveURL(/#novidades/) - - releaseRefresh?.() - await page.waitForTimeout(1_000) - await expect(page).toHaveURL(/#novidades/) - }) -}) - -test.describe('Campaign home novidades hash scroll — desktop', () => { - const desktopSuffix = Date.now() - - test('direct #novidades deep link lands on the section (desktop)', async ({ page }) => { - await page.goto(`/?e2e=scroll-${desktopSuffix}#novidades`) - await expect(page.locator('[data-home-section="newsletter"]')).toBeInViewport() - }) -}) diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts index 408427b0..ebf23983 100644 --- a/tests/e2e/frontend.e2e.spec.ts +++ b/tests/e2e/frontend.e2e.spec.ts @@ -1,719 +1,83 @@ import type { APIRequestContext, Locator, Page } from '@playwright/test' -import { readFileSync } from 'node:fs' - -import { COLINHA_ROW_LAYOUT } from '@/lib/cardColinha' -import { TEAM_CARD_NAME_BANNER } from '@/lib/cardModels' import { adminHeaders } from '../helpers/adminApi' -import { waitForHomeHTML, waitForHomeSection } from '../helpers/homeIsr' +import { waitForHomeHTML } from '../helpers/homeIsr' import { seedTestUser } from '../helpers/seedUser' import { instagramStubUrlFor, youtubeStubUrlFor } from '../helpers/socialStub' import { expect, test } from './fixtures/e2eTest' -const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) - -// 4×3 opaque PNG — enough to exercise decode/cover/clamp without a binary fixture. -const TEST_PHOTO_PNG = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEklEQVR4nGOQqzjxHxkzEBQAAMHJHF0PRQWkAAAAAElFTkSuQmCC', - 'base64', -) - -const swipeLeft = async ( - page: Page, - box: { x: number; y: number; width: number; height: number }, -) => { - const cdp = await page.context().newCDPSession(page) - const y = Math.round(box.y + box.height / 2) - const startX = Math.round(box.x + box.width * 0.82) - const endX = Math.round(box.x + box.width * 0.12) - - await cdp.send('Input.dispatchTouchEvent', { - type: 'touchStart', - touchPoints: [{ x: startX, y }], - }) - for (let step = 1; step <= 5; step += 1) { - const x = Math.round(startX + ((endX - startX) * step) / 5) - await cdp.send('Input.dispatchTouchEvent', { - type: 'touchMove', - touchPoints: [{ x, y }], - }) - await page.waitForTimeout(25) - } - await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) -} - -/** - * S14 — scans the name band (y[420..540]) of a card canvas: yellow pixel count - * and leftmost x. The base masters have no yellow in this band, so the scan - * sees only the drawn name. - */ -const readCardNameBand = (canvas: Locator) => - canvas.evaluate((element) => { - const node = element as HTMLCanvasElement - const ctx = node.getContext('2d') - if (!ctx) return { yellow: 0, minX: node.width } - - const band = ctx.getImageData(0, 420, node.width, 120).data - let yellow = 0 - let minX = node.width - for (let index = 0; index < band.length; index += 4) { - if (band[index] > 230 && band[index + 1] > 200 && band[index + 2] < 80) { - yellow += 1 - const x = (index / 4) % node.width - if (x < minX) minX = x - } - } - - return { yellow, minX } - }) - -/** S14 — the name starts on the `SOU` left border (x≈213 at 1080), not centered. */ -const expectCardNameLeftAligned = async (canvas: Locator) => { - await expect - .poll(async () => { - const { yellow, minX } = await readCardNameBand(canvas) - return yellow > 1000 && minX >= 205 && minX <= 245 - }) - .toBe(true) -} - -// C226 — the home sample renders the same card as the catalogue, so a video -// piece asks the Central for its still before the play; the door answers 404 -// until the self-heal lands and the browser logs that refusal. Declared here -// because the sample is conditional (the serial frontendConteudos spec may -// publish a video piece mid-run) and this spec must not depend on it. -test.use({ expectedRequestFailurePaths: [/\/conteudos\/.+\/frame$/] }) - test.describe('Frontend', () => { - test('can go on homepage', async ({ page }) => { - await page.goto('/') - - await expect(page).toHaveTitle(/Jorge Solla/) - - const heading = page.getByRole('heading', { - level: 1, - name: 'MAIS SAÚDE MAIS FUTURO', - }) - - await expect(heading).toBeVisible() - await expect(page.locator('[data-cta="primary"]')).toHaveAttribute( - 'href', - 'https://apoiar.me/jorgesolla', - ) - }) - - test('animates the novidades anchor and gives pointer feedback on campaign buttons', async ({ - page, - }) => { - await page.goto('/') - - const scrollContainer = page.locator('[data-theme="campaign-site"]') - const secondaryCta = page.locator('[data-cta="secondary"]') - - await expect(scrollContainer).toHaveCSS('scroll-behavior', 'smooth') - await expect(secondaryCta).toHaveAttribute('href', '#novidades') - await secondaryCta.hover() - await expect - .poll(() => secondaryCta.evaluate((element) => getComputedStyle(element).transform)) - .toContain('1.05') - - const box = await secondaryCta.boundingBox() - if (!box) throw new Error('O CTA secundário não ficou visível para testar o clique.') - await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) - await page.mouse.down() - await expect(secondaryCta).toHaveCSS('opacity', '0.8') - await page.mouse.move(0, 0) - await page.mouse.up() - - await secondaryCta.click() - await expect(page).toHaveURL(/#novidades$/) - await expect(page.locator('#novidades')).toBeInViewport() - }) - - test('keeps breathing room between the wrapped hero subtitle and CTAs on iPhone SE', async ({ - page, - }) => { - await page.setViewportSize({ width: 320, height: 568 }) + test('opens on the thank-you hero and leaves campaign propaganda behind', async ({ page }) => { await page.goto('/') await expect(page).toHaveURL(/\/$/) - const hero = page.locator('[data-home-section="hero"]') - const subtitleBox = await hero.locator('p').first().boundingBox() - const actionsBox = await hero.locator('[data-cta="secondary"]').locator('..').boundingBox() - if (!subtitleBox || !actionsBox) throw new Error('O conteúdo principal do hero não foi medido.') - - expect(actionsBox.y - (subtitleBox.y + subtitleBox.height)).toBeGreaterThanOrEqual(16) - }) - - test('adapts the campaign first fold fluidly across the reference viewport sizes', async ({ - page, - }) => { - await page.goto('/') - await expect(page).toHaveURL(/\/$/) - - const measureFirstFold = async (width: number, height: number) => { - await page.setViewportSize({ width, height }) - - return page.evaluate(() => { - const hero = document.querySelector<HTMLElement>('[data-home-section="hero"]') - const proof = document.querySelector<HTMLElement>('[data-home-section="proof"]') - const scrollContainer = document.querySelector<HTMLElement>('[data-theme="campaign-site"]') - if (!hero || !proof || !scrollContainer) - throw new Error('A primeira dobra não foi montada.') - - const box = (selector: string) => { - const element = document.querySelector<HTMLElement>(selector) - if (!element) throw new Error(`Elemento responsivo ausente: ${selector}`) - const bounds = element.getBoundingClientRect() - return { x: bounds.x, y: bounds.y, width: bounds.width } - } - const heroBounds = hero.getBoundingClientRect() - - return { - heroHeight: heroBounds.height, - firstFoldHeight: heroBounds.height + proof.getBoundingClientRect().height, - horizontalOverflow: scrollContainer.scrollWidth - scrollContainer.clientWidth, - title: box('.campaign-hero-title'), - portrait: box('.campaign-hero-portrait'), - secondaryAction: box('[data-cta="secondary"]'), - } - }) - } - - const penpotBoards = [ - { - width: 393, - height: 852, - heroHeight: 745, - firstFoldHeight: 833, - title: [-5, 462, 392], - portrait: [-6, 53, 422], - secondaryAction: [108, 652, 145], - }, - { - width: 768, - height: 1024, - heroHeight: 901, - firstFoldHeight: 1024, - title: [121, 497, 537], - portrait: [193, 88, 422], - secondaryAction: [214, 774, 158], - }, - { - width: 1024, - height: 900, - heroHeight: 543, - firstFoldHeight: 691, - title: [585, 106, 392], - portrait: [153, 26, 465], - secondaryAction: [637, 349, 158], - }, - { - width: 1280, - height: 800, - heroHeight: 676, - firstFoldHeight: 800, - title: [738, 127, 392], - portrait: [239, 84, 533], - secondaryAction: [790, 370, 158], - }, - { - width: 1366, - height: 768, - heroHeight: 627, - firstFoldHeight: 768, - title: [762, 106, 392], - portrait: [325, 9, 555], - secondaryAction: [814, 349, 158], - }, - { - width: 1920, - height: 1080, - heroHeight: 871, - firstFoldHeight: 1080, - title: [1093, 190, 607], - portrait: [524, 91, 702], - secondaryAction: [1325, 556, 174], - }, - ] - - for (const board of penpotBoards) { - const measured = await measureFirstFold(board.width, board.height) - expect(Math.abs(measured.heroHeight - board.heroHeight)).toBeLessThanOrEqual(2) - expect(Math.abs(measured.firstFoldHeight - board.firstFoldHeight)).toBeLessThanOrEqual(2) - expect(measured.horizontalOverflow).toBeLessThanOrEqual(1) - - for (const key of ['title', 'portrait', 'secondaryAction'] as const) { - const [x, y, width] = board[key] - expect(Math.abs(measured[key].x - x)).toBeLessThanOrEqual(8) - expect(Math.abs(measured[key].y - y)).toBeLessThanOrEqual(2) - expect(Math.abs(measured[key].width - width)).toBeLessThanOrEqual(2) - } - } - - const compact = [ - await measureFirstFold(393, 852), - await measureFirstFold(580, 900), - await measureFirstFold(768, 1024), - ] - expect(compact[1].heroHeight).toBeGreaterThan(compact[0].heroHeight) - expect(compact[1].heroHeight).toBeLessThan(compact[2].heroHeight) - - const standardDesktop = [ - await measureFirstFold(1024, 900), - await measureFirstFold(1152, 900), - await measureFirstFold(1280, 800), - ] - expect(standardDesktop[1].heroHeight).toBeGreaterThan(standardDesktop[0].heroHeight) - expect(standardDesktop[1].heroHeight).toBeLessThan(standardDesktop[2].heroHeight) - - const wideDesktop = [ - await measureFirstFold(1366, 768), - await measureFirstFold(1600, 900), - await measureFirstFold(1920, 1080), - ] - expect(wideDesktop[1].heroHeight).toBeGreaterThan(wideDesktop[0].heroHeight) - expect(wideDesktop[1].heroHeight).toBeLessThan(wideDesktop[2].heroHeight) - - const aspectBoundary = [ - await measureFirstFold(1280, 747), - await measureFirstFold(1280, 746), - await measureFirstFold(1920, 1121), - await measureFirstFold(1920, 1120), - ] - expect(Math.abs(aspectBoundary[0].heroHeight - aspectBoundary[1].heroHeight)).toBeLessThan(2) - expect(Math.abs(aspectBoundary[2].heroHeight - aspectBoundary[3].heroHeight)).toBeLessThan(2) - }) - - test('anchors the desktop portraits to the hero floor without height-dependent scaling', async ({ - page, - }) => { - await page.goto('/') - await expect(page).toHaveURL(/\/$/) - - const measureComposition = async (width: number, height: number) => { - await page.setViewportSize({ width, height }) - - return page.evaluate(() => { - const bounds = (selector: string) => { - const element = document.querySelector<HTMLElement>(selector) - if (!element) throw new Error(`Elemento da composição ausente: ${selector}`) - return element.getBoundingClientRect() - } - const hero = bounds('[data-home-section="hero"]') - const stage = bounds('.campaign-hero-stage') - const portrait = bounds('.campaign-hero-portrait') - const lula = bounds('.campaign-hero-ally--lula') - const wagner = bounds('.campaign-hero-ally--wagner') - const title = bounds('.campaign-hero-title') - - return { - portrait: { left: portrait.left, width: portrait.width }, - portraitBottomGap: hero.bottom - portrait.bottom, - lulaBottomGap: hero.bottom - lula.bottom, - wagnerBottomGap: hero.bottom - wagner.bottom, - portraitTitleGap: title.left - portrait.right, - stageWidth: stage.width, - } - }) - } - - const short = await measureComposition(1340, 70) - const regular = await measureComposition(1340, 700) - - expect(Math.abs(short.portrait.width - regular.portrait.width)).toBeLessThanOrEqual(1) - expect(Math.abs(short.portrait.left - regular.portrait.left)).toBeLessThanOrEqual(1) - - for (const composition of [short, regular]) { - expect(Math.abs(composition.portraitBottomGap)).toBeLessThanOrEqual(1) - expect(Math.abs(composition.lulaBottomGap)).toBeLessThanOrEqual(1) - expect(Math.abs(composition.wagnerBottomGap)).toBeLessThanOrEqual(1) - } - - const ultraWide = await measureComposition(2358, 1030) - expect(Math.abs(ultraWide.portraitBottomGap)).toBeLessThanOrEqual(1) - expect(Math.abs(ultraWide.lulaBottomGap)).toBeLessThanOrEqual(1) - expect(Math.abs(ultraWide.wagnerBottomGap)).toBeLessThanOrEqual(1) - expect(ultraWide.portraitTitleGap).toBeLessThanOrEqual(64) - expect(ultraWide.stageWidth).toBeLessThanOrEqual(1920) - }) - - test('keeps the hero readable with WCAG text spacing overrides', async ({ page }) => { - await page.goto('/') - await expect(page).toHaveURL(/\/$/) - await page.addStyleTag({ - content: ` - [data-home-section="hero"] :is(h1, p, a, small, span, strong) { - line-height: 1.5 !important; - letter-spacing: 0.12em !important; - word-spacing: 0.16em !important; - } - [data-home-section="hero"] p { margin-bottom: 2em !important; } - `, - }) - - for (const viewport of [ - { width: 320, height: 568 }, - { width: 393, height: 852 }, - { width: 768, height: 1024 }, - { width: 1024, height: 597 }, - { width: 1366, height: 768 }, - { width: 1600, height: 600 }, - { width: 1920, height: 840 }, - ]) { - await page.setViewportSize(viewport) - const spacing = await page.evaluate(() => { - const element = (selector: string) => { - const match = document.querySelector<HTMLElement>(selector) - if (!match) throw new Error(`Elemento ausente no teste de espaçamento: ${selector}`) - return match - } - const bounds = (selector: string) => { - return element(selector).getBoundingClientRect() - } - const hero = bounds('[data-home-section="hero"]') - const title = bounds('.campaign-hero-title') - const copy = bounds('.campaign-hero-copy') - const actions = bounds('.campaign-hero-actions') - const accolades = bounds('.campaign-hero-accolades') - const secondaryAction = element('[data-cta="secondary"]') - const primaryAction = element('[data-cta="primary"]') - const scrollContainer = document.querySelector<HTMLElement>('[data-theme="campaign-site"]') - if (!scrollContainer) throw new Error('Container da campanha ausente.') - - const overlapArea = (first: DOMRect, second: DOMRect) => - Math.max(0, Math.min(first.right, second.right) - Math.max(first.left, second.left)) * - Math.max(0, Math.min(first.bottom, second.bottom) - Math.max(first.top, second.top)) - - return { - titleCopyGap: copy.top - title.bottom, - copyActionsGap: actions.top - copy.bottom, - actionsBottomGap: hero.bottom - actions.bottom, - accoladesBottomGap: hero.bottom - accolades.bottom, - actionsAccoladesOverlap: overlapArea(actions, accolades), - primaryActionOverflow: primaryAction.scrollHeight - primaryAction.clientHeight, - secondaryActionOverflow: secondaryAction.scrollHeight - secondaryAction.clientHeight, - horizontalOverflow: scrollContainer.scrollWidth - scrollContainer.clientWidth, - } - }) - - expect(spacing.titleCopyGap).toBeGreaterThanOrEqual(0) - expect(spacing.copyActionsGap).toBeGreaterThanOrEqual(0) - expect(spacing.actionsBottomGap).toBeGreaterThanOrEqual(44) - expect(spacing.accoladesBottomGap).toBeGreaterThanOrEqual(0) - expect(spacing.actionsAccoladesOverlap).toBe(0) - expect(spacing.primaryActionOverflow).toBeLessThanOrEqual(0) - expect(spacing.secondaryActionOverflow).toBeLessThanOrEqual(0) - expect(spacing.horizontalOverflow).toBeLessThanOrEqual(1) - } - }) - - test('shows the campaign message sections as static grids on desktop', async ({ page }) => { - await page.goto('/') - - const problemGrid = page.locator('[data-home-section="problem"] [data-grid="problem"]') - await expect(problemGrid).toBeVisible() - await expect(problemGrid.getByRole('listitem')).toHaveCount(3) - const problemBoxes = await problemGrid - .getByRole('listitem') - .evaluateAll((items) => items.map((item) => item.getBoundingClientRect())) - expect(new Set(problemBoxes.map((box) => Math.round(box.y))).size).toBe(1) - expect(new Set(problemBoxes.map((box) => Math.round(box.x))).size).toBe(3) - expect(problemBoxes.every((box) => box.width > 0 && box.height > 0)).toBe(true) - - const flagsGrid = page.locator('[data-home-section="flags"] [data-grid="flags"]') - await expect(flagsGrid).toBeVisible() - await expect(flagsGrid.getByRole('listitem')).toHaveCount(6) - const flagsBoxes = await flagsGrid - .getByRole('listitem') - .evaluateAll((items) => items.map((item) => item.getBoundingClientRect())) - expect(new Set(flagsBoxes.map((box) => Math.round(box.x))).size).toBe(3) - expect(new Set(flagsBoxes.map((box) => Math.round(box.y))).size).toBe(2) - - // No carousel chrome on desktop: regions and chips are display:none (out - // of the a11y tree) and the hidden carousel never starts its auto-advance - // chain, so no aria-current state exists anywhere in the grids. await expect( - page.getByRole('region', { name: 'Bandeiras que tornam esta eleição decisiva' }), - ).toHaveCount(0) - await expect(page.getByRole('region', { name: 'Bandeiras da campanha' })).toHaveCount(0) - await expect(page.locator('[data-carousel-chips]')).toBeHidden() - await expect(problemGrid.locator('[aria-current]')).toHaveCount(0) - await expect(flagsGrid.locator('[aria-current]')).toHaveCount(0) - - // The grids stay inside their sections with breathing room at every - // desktop width, no card is clipped (no hidden scroll) and nothing - // overflows the page horizontally. Fonts settle first: the flags cards - // are content-height, so a fallback-font measurement could differ from - // the real layout. - await page.evaluate(() => document.fonts.ready) - for (const width of [1024, 1280, 1440, 1920]) { - await page.setViewportSize({ width, height: 900 }) - const spacing = await page.evaluate(() => { - const section = (selector: string) => { - const element = document.querySelector<HTMLElement>(selector) - if (!element) throw new Error(`Seção ausente: ${selector}`) - return element.getBoundingClientRect() - } - const gridBottom = (selector: string) => { - const grid = document.querySelector<HTMLElement>(selector) - if (!grid) throw new Error(`Grade ausente: ${selector}`) - const cards = Array.from(grid.querySelectorAll<HTMLElement>('li')) - return Math.max(...cards.map((card) => card.getBoundingClientRect().bottom)) - } - const gridClipped = (selector: string) => { - const grid = document.querySelector<HTMLElement>(selector) - if (!grid) throw new Error(`Grade ausente: ${selector}`) - return grid.scrollHeight - grid.clientHeight - } - const problem = section('[data-home-section="problem"]') - const flags = section('[data-home-section="flags"]') - const scrollContainer = document.querySelector<HTMLElement>('[data-theme="campaign-site"]') - return { - problemBottomGap: - problem.bottom - gridBottom('[data-home-section="problem"] [data-grid]'), - flagsBottomGap: flags.bottom - gridBottom('[data-home-section="flags"] [data-grid]'), - problemClipped: gridClipped('[data-home-section="problem"] [data-grid]'), - flagsClipped: gridClipped('[data-home-section="flags"] [data-grid]'), - horizontalOverflow: scrollContainer - ? scrollContainer.scrollWidth - scrollContainer.clientWidth - : 0, - } - }) - expect(spacing.problemBottomGap).toBeGreaterThanOrEqual(24) - expect(spacing.flagsBottomGap).toBeGreaterThanOrEqual(24) - expect(spacing.problemClipped).toBeLessThanOrEqual(1) - expect(spacing.flagsClipped).toBeLessThanOrEqual(1) - expect(spacing.horizontalOverflow).toBeLessThanOrEqual(1) - } - }) - - test('shows the story video section after the flags with the YouTube embed', async ({ page }) => { - await page.goto('/') - - const story = page.locator('[data-home-section="story"]') - await expect(story).toBeVisible() - - // Position: after the flags section, before the footer. - const order = await page.evaluate(() => { - const top = (selector: string) => { - const element = document.querySelector<HTMLElement>(selector) - if (!element) throw new Error(`Seção ausente: ${selector}`) - return element.getBoundingClientRect().top - } - return { - flagsTop: top('[data-home-section="flags"]'), - storyTop: top('[data-home-section="story"]'), - footerTop: top('footer'), - } - }) - expect(order.storyTop).toBeGreaterThan(order.flagsTop) - expect(order.footerTop).toBeGreaterThan(order.storyTop) - - // Draft copy (eyebrow + title). - await expect(story.getByText('Nossa história')).toBeVisible() - await expect( - story.getByRole('heading', { name: 'Conheça a trajetória de Jorge Solla' }), + page.getByRole('heading', { level: 1, name: '152.049 votos de confiança' }), ).toBeVisible() - - // The embed: youtube-nocookie (LGPD), native lazy, fullscreen, a11y title. - const embed = story.locator('iframe') - await expect(embed).toBeVisible() - await expect(embed).toHaveAttribute( - 'src', - /youtube-nocookie\.com\/embed\/i_fbclWWC5o.*playsinline=1/, + await expect(page.getByRole('link', { name: 'Acompanhe o mandato' })).toHaveAttribute( + 'href', + '/artigos', ) - await expect(embed).toHaveAttribute('loading', 'lazy') - await expect(embed).toHaveAttribute('allow', /autoplay/) - await expect(embed).toHaveAttribute('allowfullscreen', '') - await expect(embed).toHaveAttribute('title', /trajetória de Jorge Solla/) - // The header CTA scrolls to the player. - await story.getByRole('button', { name: 'Assistir à história' }).click() - await expect(embed).toBeInViewport() + const main = page.locator('main') + await expect(main.getByText('1313')).toHaveCount(0) + await expect(main.getByText(/Quero apoiar|candidato|propaganda eleitoral/i)).toHaveCount(0) - // Desktop scene of the draft: copy column left, frame right, vertically - // centered. - await page.setViewportSize({ width: 1440, height: 900 }) - const desktopLayout = await page.evaluate(() => { - const storySection = document.querySelector<HTMLElement>('[data-home-section="story"]') - if (!storySection) throw new Error('Seção ausente') - const copy = storySection.querySelector<HTMLElement>('div.max-w-md') - const frame = storySection.querySelector<HTMLElement>('iframe')?.parentElement - if (!copy || !frame) throw new Error('Colunas ausentes') - const copyBox = copy.getBoundingClientRect() - const frameBox = frame.getBoundingClientRect() - return { - copyRight: Math.round(copyBox.right), - frameLeft: Math.round(frameBox.left), - centerDelta: Math.round( - copyBox.top + copyBox.height / 2 - (frameBox.top + frameBox.height / 2), - ), - } - }) - expect(desktopLayout.copyRight).toBeLessThan(desktopLayout.frameLeft) - expect(Math.abs(desktopLayout.centerDelta)).toBeLessThanOrEqual(2) + for (const section of ['hero', 'contents', 'story', 'flags', 'photos']) { + await expect(page.locator(`[data-home-section="${section}"]`)).toBeVisible() + } + await expect(page.locator('[data-home-section="proof"]')).toHaveCount(0) + await expect(page.locator('[data-home-section="problem"]')).toHaveCount(0) + await expect(page.locator('[data-home-section="sound"]')).toHaveCount(0) + await expect(page.locator('[data-home-section="cards"]')).toHaveCount(0) + await expect(page.locator('[data-home-section="content-pieces"]')).toHaveCount(0) - // The frame keeps the 9:16 ratio on mobile without horizontal overflow. - await page.setViewportSize({ width: 390, height: 844 }) - const frame = await embed.boundingBox() - expect(frame).not.toBeNull() - expect(frame!.height / frame!.width).toBeCloseTo(16 / 9, 1) - const overflow = await page.evaluate(() => { - const scrollContainer = document.querySelector<HTMLElement>('[data-theme="campaign-site"]') - return scrollContainer ? scrollContainer.scrollWidth - scrollContainer.clientWidth : 0 - }) - expect(overflow).toBeLessThanOrEqual(1) + const footer = page.locator('footer') + await expect(footer.getByRole('link', { name: 'Artigos' })).toHaveAttribute('href', '/artigos') + await expect(footer.getByRole('link', { name: 'Fotos' })).toHaveAttribute('href', '/fotos') + await expect(footer.getByText(/CNPJ|1º turno/)).toHaveCount(0) }) - test('shows the sound section between the story and the cards with the own radio player', async ({ - page, - }) => { - const zenoRequests: string[] = [] - page.on('request', (req) => { - if (req.url().includes('zeno.fm')) zenoRequests.push(req.url()) - }) - + test('desktop nav and mobile menu reach the mandate sections', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }) await page.goto('/') - const sound = page.locator('[data-home-section="sound"]') - await expect(sound).toBeVisible() - await expect(sound.getByText('A trilha da nossa caminhada')).toBeVisible() + const nav = page.getByRole('navigation', { name: 'Navegação principal' }) + await expect(nav.getByRole('link', { name: 'Mandato' })).toHaveAttribute('href', '/artigos') + await expect(nav.getByRole('link', { name: 'Bandeiras' })).toHaveAttribute('href', '#bandeiras') + await expect(nav.getByRole('link', { name: 'Fotos' })).toHaveAttribute('href', '/fotos') - // Position: after the story, before the cards (the conversion block stays - // last). The jingle grid is content-dependent, so this spec never counts - // cards — counts belong to the serial frontendJingles spec. - const tops = await page.evaluate(() => { - const top = (selector: string) => { - const element = document.querySelector<HTMLElement>(selector) - if (!element) throw new Error(`Seção ausente: ${selector}`) - return element.getBoundingClientRect().top - } - return { - story: top('[data-home-section="story"]'), - sound: top('[data-home-section="sound"]'), - cards: top('[data-home-section="cards"]'), - } - }) - expect(tops.sound).toBeGreaterThan(tops.story) - expect(tops.cards).toBeGreaterThan(tops.sound) - - // S25 — the own player replaces the S24 iframe: one lazy `<audio>` and no - // request at all before the visitor's gesture. - const radio = sound.locator('[data-radio]') - await expect(radio).toBeVisible() - await expect(radio.locator('iframe')).toHaveCount(0) - const radioAudio = radio.locator('audio') - await expect(radioAudio).toHaveCount(1) - await expect(radioAudio).toHaveAttribute('preload', 'none') - await expect(radioAudio).toHaveAttribute('src', 'https://stream.zeno.fm/hys86kx6k16tv') - await expect(radio.getByRole('heading', { name: 'Rádio Jorge Solla 1313' })).toBeVisible() - await expect(radio.getByText('Ao vivo')).toBeVisible() - expect(zenoRequests).toHaveLength(0) - - // The play is the only exit to the stream: the request leaves after the - // gesture (the fixture stubs it) and the state says so. - await radio.getByRole('button', { name: 'Ouvir Rádio Jorge Solla 1313' }).click() - await expect.poll(() => zenoRequests.length).toBeGreaterThan(0) - await expect(radio).toHaveAttribute('data-state', 'playing') - await expect(radio.getByText('Em reprodução')).toBeVisible() - - // Desktop 1280: the gate's 56px play hit. - await expect(radio.getByRole('button', { name: 'Pausar Rádio Jorge Solla 1313' })).toHaveCSS( - 'width', - '56px', - ) - - // Mobile 390: the hit narrows to 48px and the section never overflows. - await page.setViewportSize({ width: 390, height: 844 }) - await expect(radio.getByRole('button', { name: 'Pausar Rádio Jorge Solla 1313' })).toHaveCSS( - 'width', - '48px', - ) - const overflow = await page.evaluate(() => { - const scrollContainer = document.querySelector<HTMLElement>('[data-theme="campaign-site"]') - return scrollContainer ? scrollContainer.scrollWidth - scrollContainer.clientWidth : 0 - }) - expect(overflow).toBeLessThanOrEqual(1) - }) - - test('keeps carousel auto-advance and chips on mobile', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }) await page.goto('/') + const trigger = page.getByRole('button', { name: /^(Abrir|Fechar) menu$/ }) + await trigger.click() + await expect(trigger).toHaveAttribute('aria-expanded', 'true') + const mobileNav = page.getByRole('navigation', { name: 'Navegação móvel' }) + await expect(mobileNav).toBeVisible() + await expect(mobileNav.getByRole('link', { name: 'Artigos' })).toHaveAttribute( + 'href', + '/artigos', + ) - await expect(page.locator('[data-home-section="problem"] [data-grid]')).toBeHidden() - await expect(page.locator('[data-home-section="flags"] [data-grid]')).toBeHidden() - - const problemCarousel = page.getByRole('region', { - name: 'Bandeiras que tornam esta eleição decisiva', - }) - const firstProblem = problemCarousel.getByRole('listitem', { name: '1 de 3' }) - const secondProblem = problemCarousel.getByRole('listitem', { name: '2 de 3' }) - - await expect(firstProblem).toHaveAttribute('aria-current', 'true') - await expect(secondProblem).toHaveAttribute('aria-current', 'true', { timeout: 6_000 }) - - const flagCarousel = page.getByRole('region', { name: 'Bandeiras da campanha' }) - const educationChip = flagCarousel.getByRole('button', { name: 'Educação' }) - await educationChip.click() - await expect(educationChip).toHaveAttribute('aria-current', 'true') + await page.keyboard.press('Escape') + await expect(mobileNav).toBeHidden() + await expect(trigger).toHaveAttribute('aria-expanded', 'false') + await expect(trigger).toBeFocused() }) - test('supports a real horizontal touch swipe on mobile', async ({ page }) => { - await page.setViewportSize({ width: 430, height: 900 }) - await page.goto('/') - - const overlayBox = await page.locator('[data-mobile-hero-overlay]').boundingBox() - expect(overlayBox?.x).toBe(0) - expect(overlayBox?.width).toBe(430) - - const mobileSpacing = await page.evaluate(() => { - const rect = (selector: string) => document.querySelector(selector)?.getBoundingClientRect() - const problemSection = rect('[data-home-section="problem"]') - const problemLead = rect('#problem-title + p') - const problemCarousel = rect('[data-home-section="problem"] [data-carousel]') - const flagsSection = rect('[data-home-section="flags"]') - const flagsCarousel = rect('[data-home-section="flags"] [data-carousel]') - - return { - leadGap: - problemLead && problemCarousel ? problemCarousel.top - problemLead.bottom : undefined, - problemBottomGap: - problemSection && problemCarousel - ? problemSection.bottom - problemCarousel.bottom - : undefined, - flagsBottomGap: - flagsSection && flagsCarousel ? flagsSection.bottom - flagsCarousel.bottom : undefined, - } - }) - expect(mobileSpacing.leadGap).toBeLessThan(40) - expect(mobileSpacing.problemBottomGap).toBeGreaterThanOrEqual(40) - expect(mobileSpacing.flagsBottomGap).toBeGreaterThanOrEqual(40) - - const carousel = page.getByRole('region', { name: 'Bandeiras da campanha' }) - await carousel.focus() - const track = carousel.locator('[data-carousel-track]') - await track.scrollIntoViewIfNeeded() - const box = await track.boundingBox() - if (!box) throw new Error('O trilho das bandeiras não ficou visível para o swipe.') - - await swipeLeft(page, box) - - await expect(carousel.getByRole('button', { name: 'Educação' })).toHaveAttribute( - 'aria-current', - 'true', - ) + test('redirects the retired campaign routes with a permanent status', async ({ request }) => { + for (const [from, to] of [ + ['/conteudos', '/artigos'], + ['/jingles', '/'], + ['/cards', '/'], + ]) { + const response = await request.get(from, { maxRedirects: 0 }) + expect(response.status()).toBe(308) + expect(new URL(response.headers().location, 'http://localhost').pathname).toBe(to) + } }) }) -/** - * S1 — content section on the campaign home. Runs serially: the empty-state - * test depends on the suite's default DB (no posts) and must execute before - * the full-state test creates posts. Posts/tags are created through the - * deployed REST API so the server process runs the real `afterChange` cache - * hooks (a Local API call from the runner would throw on `revalidateTag`), - * and cleanup busts the `posts` tag through the revalidate endpoint. - */ test.describe('Campaign home content section', () => { test.describe.configure({ mode: 'serial' }) @@ -769,16 +133,6 @@ test.describe('Campaign home content section', () => { return doc } - // S39/S44 — the ISR poll lives in `tests/helpers/homeIsr.ts` (shared by the - // specs that own home sections); this spec keeps the `contents` specialization. - const waitForHomeSectionState = async ( - request: APIRequestContext, - expected: 'present' | 'absent', - attempts = 12, - ) => { - await waitForHomeSection(request, baseURL, 'contents', expected, attempts) - } - /** * Polls the RENDERED contents section for a card/link count, scoped to * `[data-home-section="contents"]`. The server-HTML polls above converge on @@ -878,40 +232,31 @@ test.describe('Campaign home content section', () => { headers: { 'x-revalidate-secret': revalidateSecret }, }) .catch(() => undefined) - await waitForHomeSectionState(request, 'absent').catch(() => undefined) } - test('hides the content section while no articles are visible', async ({ page, request }) => { - // The dev server persists its `posts` cache to .next-e2e/cache/fetch-cache - // between runs, so an earlier full-state run could leave it populated. - // Busting first makes the empty state deterministic on every rerun. - // OPS83: assert the revalidate actually landed (a swallowed failure made - // the server poll below time out in verify run #15) and converge on the - // rendered DOM with an explicit ISR-regeneration budget — never a blind - // wall-clock bump. + test('shows the honest board empty state while no article is visible', async ({ + page, + request, + }) => { + // The dev server persists its `posts` cache between runs, so an earlier + // full-state run could leave it populated. Busting first makes the empty + // state deterministic on every rerun. const revalidate = await request.post(`${baseURL}/api/revalidate?tag=posts`, { headers: { 'x-revalidate-secret': revalidateSecret }, }) expect(revalidate).toBeOK() - await waitForHomeSectionState(request, 'absent', 30) // Plain URL on purpose: the test also pins the canonical home URL. await page.goto('/') await expect(page).toHaveURL(/\/$/) - await expectSectionCount(page.locator('[data-home-section="contents"]'), 0) - await expect(page.getByText('A caminhada, em tempo real')).toHaveCount(0) - - const proof = await page.locator('[data-home-section="proof"]').boundingBox() - const problem = await page.locator('[data-home-section="problem"]').boundingBox() - expect(proof).not.toBeNull() - expect(problem).not.toBeNull() - expect(problem!.y - (proof!.y + proof!.height)).toBeLessThanOrEqual(1) + const section = page.locator('[data-home-section="contents"]') + await expect(section).toBeVisible() + await expect(section.getByRole('heading', { name: 'Ainda não há publicações' })).toBeVisible() + await expect(page.locator('[data-home-section="proof"]')).toHaveCount(0) + await expect(page.locator('[data-home-section="problem"]')).toHaveCount(0) }) - test('renders the article bento and the one-per-screen mobile carousel', async ({ - page, - request, - }) => { + test('renders the 1+3 editorial board and stacks it on mobile', async ({ page, request }) => { await seedTestUser() const headers = await adminHeaders(request, baseURL) @@ -970,23 +315,19 @@ test.describe('Campaign home content section', () => { try { await page.setViewportSize({ width: 1280, height: 900 }) - // The create hooks revalidated the `posts` tag in the server process; - // wait until the regenerated page actually shows the section. - await waitForHomeSectionState(request, 'present') + await waitForHomeHTML(request, baseURL, ['E2e Artigo em destaque', 'Acompanhe de perto']) await gotoHomeFresh(page) const section = page.locator('[data-home-section="contents"]') await expect(section).toBeVisible() await expect( - section.getByRole('heading', { name: 'A caminhada, em tempo real' }), + section.getByRole('heading', { name: 'O mandato, onde a Bahia acontece' }), ).toBeVisible() await expect(section.getByText('Acompanhe de perto')).toBeVisible() await expect( - section.getByText( - 'Bastidores, caravanas e as lutas do mandato: conteúdo atualizado, direto das redes.', - ), + section.getByText('Notícias, vídeos e registros do trabalho em Brasília e nos municípios.'), ).toBeVisible() - await expect(section.getByRole('link', { name: /Ver artigos/ })).toHaveAttribute( + await expect(section.getByRole('link', { name: 'Todos os artigos' })).toHaveAttribute( 'href', '/artigos', ) @@ -994,10 +335,8 @@ test.describe('Campaign home content section', () => { const bentoCards = section.locator( 'a[href^="/artigo/"], a[href^="/noticia/"], a[href^="/campanha/"], a[href^="/evento/"]', ) - await expect(bentoCards.filter({ visible: true })).toHaveCount(5) - // S7 — cards are badge-free: no type chip (site or external source) - // anywhere in the section. - for (const badge of ['Artigo', 'Notícia', 'Campanha', 'Evento', 'YouTube', 'Instagram']) { + await expect(bentoCards.filter({ visible: true })).toHaveCount(4) + for (const badge of ['Artigo', 'Notícia', 'Campanha', 'Evento']) { await expect(section.getByText(badge, { exact: true })).toHaveCount(0) } await expect(section.getByText('E2e Artigo em destaque').first()).toBeVisible() @@ -1007,34 +346,11 @@ test.describe('Campaign home content section', () => { const featured = section.getByRole('link', { name: /E2e Artigo em destaque/ }) const featuredHref = await featured.getAttribute('href') expect(featuredHref).toBe(`/artigo/${categorySlug}/e2e-artigo-destaque-${runSuffix}`) + await expect(featured.locator('h3')).toHaveText('E2e Artigo em destaque') - const firstCard = bentoCards.first() - await expect(firstCard).toBeVisible() - await expect(firstCard.locator('h3')).toHaveText('E2e Artigo em destaque') - await expect(firstCard.locator('span').last()).toContainText(categoryName) - - // S5 — the section must read as a zone of its own: a banded background - // with a top hairline against the white proof strip, and white cards - // that pop against the band. Exact computed values pin the tokens - // (`--campaign-band` #ebe9e9, `--campaign-line` rgb(0 0 0 / 12%)) so a - // broken or removed token cannot silently pass. - await expect(section).toHaveCSS('background-color', 'rgb(235, 233, 233)') - await expect(section).toHaveCSS('border-top-width', '1px') - await expect(section).toHaveCSS('border-top-color', 'rgba(0, 0, 0, 0.12)') - await expect(firstCard).toHaveCSS('background-color', 'rgb(255, 255, 255)') - - // S7 — flat editorial cards: no radius, border or shadow (the white card - // reads against the band on shape alone), cover edge to edge. - await expect(firstCard).toHaveCSS('border-radius', '0px') - await expect(firstCard).toHaveCSS('border-width', '0px') - await expect(firstCard).toHaveCSS('box-shadow', 'none') - await expect(firstCard.locator('.aspect-video')).toHaveCSS('border-radius', '0px') - // Edge to edge: the cover spans the full card width (no container padding). - const cardBox = await firstCard.boundingBox() - const coverBox = await firstCard.locator('.aspect-video').boundingBox() - expect(coverBox).not.toBeNull() - expect(cardBox).not.toBeNull() - expect(Math.abs(coverBox!.width - cardBox!.width)).toBeLessThanOrEqual(1) + // S47 — the board sits on the cream surface and the featured story + // carries the cover; the three side rows are text-only. + await expect(section).toHaveCSS('background-color', 'rgb(255, 248, 242)') await featured.click() await expect(page).toHaveURL(new RegExp(`${featuredHref}$`)) @@ -1042,32 +358,21 @@ test.describe('Campaign home content section', () => { await page.setViewportSize({ width: 390, height: 844 }) await gotoHomeFresh(page) - const carousel = page.getByRole('region', { name: 'Conteúdos recentes' }) - await expect(carousel).toBeVisible() - await expect(carousel.getByText('E2e Conteúdo oculto')).toHaveCount(0) + const mobileSection = page.locator('[data-home-section="contents"]') + await expect(mobileSection).toBeVisible() + await expect(page.locator('[data-carousel="contents"]')).toHaveCount(0) + for (const title of [ + 'E2e Artigo em destaque', + 'E2e Notícia recente', + 'E2e Campanha em ação', + 'E2e Notícia do interior', + ]) { + await expect(mobileSection.getByText(title).first()).toBeVisible() + } + await expect(mobileSection.getByText('E2e Evento da agenda')).toHaveCount(0) + await expect(mobileSection.getByText('E2e Conteúdo oculto')).toHaveCount(0) - const track = carousel.locator('[data-carousel-track]') - await track.scrollIntoViewIfNeeded() - const trackBox = await track.boundingBox() - if (!trackBox) throw new Error('O trilho do carrossel de conteúdo não ficou visível.') - - const slides = await page.evaluate(() => - Array.from( - document.querySelectorAll('[data-carousel="contents"] [data-carousel-index]'), - ).map((slide) => slide.getBoundingClientRect().width), - ) - expect(slides).toHaveLength(5) - expect(slides.every((width) => Math.abs(width - slides[0]) < 1)).toBe(true) - - await swipeLeft(page, trackBox) - await expect(carousel.getByText('2 de 5 · deslize para ver os próximos')).toBeVisible() - - await carousel.getByRole('button', { name: 'Ir para o conteúdo 4 de 5' }).click() - await expect(carousel.getByText('4 de 5 · deslize para ver os próximos')).toBeVisible() - - // Leave the home before deleting: prod prefetches the card links, and a - // prefetch answered after the deletion would 404 (guard noise). Leaving - // the page aborts in-flight prefetches and stops new ones. + // Leave the home before deleting: prefetches after the deletion would 404. await page.goto('about:blank') await expect(page).toHaveURL(/about:blank/) } finally { @@ -1088,13 +393,6 @@ test.describe('Campaign home content section', () => { headers: { 'x-revalidate-secret': revalidateSecret }, }) .catch(() => undefined) - - // Converge the persisted dev cache back to the empty state so the - // next run starts deterministic (fail-closed on delete, too). - await waitForHomeSectionState(request, 'absent') - await page.setViewportSize({ width: 1280, height: 900 }) - await gotoHomeFresh(page) - await expect(page.locator('[data-home-section="contents"]')).toHaveCount(0) } } }) @@ -1148,27 +446,26 @@ test.describe('Campaign home content section', () => { await expect( section.getByText('há 30 minutos · 12,4 mil visualizações').first(), ).toBeVisible() - // S7 — no external-source chip on YouTube cards anymore. - await expect(section.getByText('YouTube', { exact: true })).toHaveCount(0) + // S47 — the board header links the platform once; cards carry no chip. + await expect(section.getByRole('link', { name: 'YouTube', exact: true })).toHaveAttribute( + 'href', + 'https://youtube.com/@JorgeSollaDep', + ) await expect(section.getByText('E2e Vídeo excluído')).toHaveCount(0) await expect(section.getByRole('link', { name: /E2e Artigo no mix/ }).first()).toBeVisible() await expect(section.getByText('há 2 horas · 8,1 mil visualizações').first()).toBeVisible() await expect(section.getByText('987 visualizações').first()).toBeVisible() - const channelLink = section.getByRole('link', { name: /YouTube →/ }) - await expect(channelLink).toHaveAttribute( - 'href', - 'https://www.youtube.com/channel/UCe2eTestChannel', - ) + const channelLink = section.getByRole('link', { name: 'YouTube', exact: true }) + await expect(channelLink).toHaveAttribute('href', 'https://youtube.com/@JorgeSollaDep') await expect(channelLink).toHaveAttribute('target', '_blank') await page.setViewportSize({ width: 390, height: 844 }) await gotoHomeFresh(page) - const carousel = page.getByRole('region', { name: 'Conteúdos recentes' }) - await expect(carousel).toBeVisible() - await expect(carousel.getByText('1 de 4 · deslize para ver os próximos')).toBeVisible() - await expect(carousel.getByText('E2e Vídeo excluído')).toHaveCount(0) + // S47 — the board stacks on mobile; no carousel track is rendered. + await expect(page.locator('[data-carousel="contents"]')).toHaveCount(0) + await expect(page.getByText('E2e Vídeo excluído')).toHaveCount(0) await page.goto('about:blank') await expect(page).toHaveURL(/about:blank/) @@ -1210,13 +507,13 @@ test.describe('Campaign home content section', () => { }) try { - // The feed is configured, so the "YouTube →" link stays; without a + // The feed is configured, so the "YouTube" link stays; without a // snapshot the cards are silently absent and the article carries the // section (page never breaks, no error shown). await waitForHomeHTML( request, baseURL, - ['E2e Artigo fallback', 'YouTube →'], + ['E2e Artigo fallback', 'YouTube'], ['E2e Vídeo em destaque'], ) await gotoHomeFresh(page) @@ -1225,7 +522,7 @@ test.describe('Campaign home content section', () => { await expect(section.getByRole('link', { name: /E2e Artigo fallback/ })).toBeVisible() await expect(section.getByText('E2e Vídeo em destaque')).toHaveCount(0) await expect(section.getByText('E2e Vídeo de caravana')).toHaveCount(0) - await expect(section.getByRole('link', { name: /YouTube →/ })).toBeVisible() + await expect(section.getByRole('link', { name: 'YouTube', exact: true })).toBeVisible() await page.goto('about:blank') await expect(page).toHaveURL(/about:blank/) @@ -1341,7 +638,7 @@ test.describe('Campaign home content section', () => { request, baseURL, ['E2e Post do muro', 'E2e Artigo no mix IG'], - ['E2e Post de grade', 'YouTube →'], + ['E2e Post de grade'], ) await page.setViewportSize({ width: 1280, height: 900 }) await gotoHomeFresh(page) @@ -1353,16 +650,19 @@ test.describe('Campaign home content section', () => { await expect(featured).toHaveAttribute('href', 'https://www.instagram.com/p/e2e-ig-muro-1/') await expect(featured).toHaveAttribute('target', '_blank') await expect(featured).toHaveAttribute('rel', 'noopener noreferrer') - // S7 — no external-source chip on Instagram cards anymore. - await expect(section.getByText('Instagram', { exact: true })).toHaveCount(0) + // S47 — the board header links the platform once; cards carry no chip. + await expect(section.getByRole('link', { name: 'Instagram', exact: true })).toHaveAttribute( + 'href', + 'https://instagram.com/depjorgesolla', + ) await expect(section.getByText('há 20 minutos').first()).toBeVisible() await expect(section.getByText('E2e Post de grade')).toHaveCount(0) // The captionless grid post shows the fallback title (it enters the // board automatically and the assessoria excludes it item by item). await expect(section.getByText('Publicação no Instagram').first()).toBeVisible() - // Instagram cards render the 1:1 cover of the approved draft. - await expect(featured.locator('.aspect-square').first()).toBeVisible() + // Instagram cards keep the approved cover image. + await expect(featured.locator('img').first()).toBeVisible() // Long captions are clamped with an ellipsis so a single post cannot // stretch the bento row (production captions run into hundreds of @@ -1378,16 +678,15 @@ test.describe('Campaign home content section', () => { ).toBeVisible() await expect(section.getByText('E2e Reel da caravana').first()).toBeVisible() - const profileLink = section.getByRole('link', { name: /Seguir no Instagram/ }) - await expect(profileLink).toHaveAttribute('href', 'https://www.instagram.com/depjorgesolla') + const profileLink = section.getByRole('link', { name: 'Instagram', exact: true }) + await expect(profileLink).toHaveAttribute('href', 'https://instagram.com/depjorgesolla') await expect(profileLink).toHaveAttribute('target', '_blank') await page.setViewportSize({ width: 390, height: 844 }) await gotoHomeFresh(page) - const carousel = page.getByRole('region', { name: 'Conteúdos recentes' }) - await expect(carousel).toBeVisible() - await expect(carousel.getByText('1 de 4 · deslize para ver os próximos')).toBeVisible() - await expect(carousel.getByText('E2e Post de grade')).toHaveCount(0) + // S47 — the board stacks on mobile; no carousel track is rendered. + await expect(page.locator('[data-carousel="contents"]')).toHaveCount(0) + await expect(page.getByText('E2e Post de grade')).toHaveCount(0) await page.goto('about:blank') await expect(page).toHaveURL(/about:blank/) @@ -1435,15 +734,10 @@ test.describe('Campaign home content section', () => { try { // Without a snapshot there is no username to build the profile link - // from, so the IG cards AND the "Seguir no Instagram →" link stay + // from, so the IG cards AND the "Instagram" link stay // silently absent and the article carries the section (page never // breaks, no error shown). - await waitForHomeHTML( - request, - baseURL, - ['E2e Artigo fallback IG'], - ['E2e Post do muro', 'Seguir no Instagram'], - ) + await waitForHomeHTML(request, baseURL, ['E2e Artigo fallback IG'], ['E2e Post do muro']) await gotoHomeFresh(page) const section = page.locator('[data-home-section="contents"]') await expect(section).toBeVisible() @@ -1451,7 +745,7 @@ test.describe('Campaign home content section', () => { // Same rendered-DOM class as the kill-switch test (OPS83 run #15): the // server poll converged but the page served a stale body — poll the DOM. await expectSectionCount(section.getByText('E2e Post do muro'), 0) - await expectSectionCount(section.getByRole('link', { name: /Seguir no Instagram/ }), 0) + await expect(section.getByRole('link', { name: 'Instagram', exact: true })).toBeVisible() await page.goto('about:blank') await expect(page).toHaveURL(/about:blank/) @@ -1505,14 +799,13 @@ test.describe('Campaign home content section', () => { await waitForHomeHTML( request, baseURL, - ['E2e Post do muro', 'E2e Reel da caravana', 'Seguir no Instagram'], + ['E2e Post do muro', 'E2e Reel da caravana', 'Instagram'], ['Publicação no Instagram'], ) await gotoHomeFresh(page) await expect(page.getByRole('link', { name: /E2e Post do muro/ }).first()).toBeVisible() await expect(page.getByText('E2e Reel da caravana').first()).toBeVisible() await expect(page.getByText('Publicação no Instagram')).toHaveCount(0) - await expect(page.getByRole('link', { name: /Seguir no Instagram/ })).toBeVisible() await page.goto('about:blank') await expect(page).toHaveURL(/about:blank/) @@ -1575,7 +868,7 @@ test.describe('Campaign home content section', () => { request, baseURL, ['E2e Artigo kill switch'], - ['E2e Vídeo em destaque', 'YouTube →', 'E2e Post do muro', 'Seguir no Instagram'], + ['E2e Vídeo em destaque', 'E2e Post do muro'], ) // The page can serve a heuristic-cached body that lags the server poll // (OPS83 run #15): navigate fresh AND poll the rendered section DOM — @@ -1585,9 +878,9 @@ test.describe('Campaign home content section', () => { await expect(section).toBeVisible() await expect(section.getByRole('link', { name: /E2e Artigo kill switch/ })).toBeVisible() await expectSectionCount(section.getByText('E2e Vídeo em destaque'), 0) - await expectSectionCount(section.getByRole('link', { name: /YouTube →/ }), 0) + await expect(section.getByRole('link', { name: 'YouTube', exact: true })).toBeVisible() await expectSectionCount(section.getByText('E2e Post do muro'), 0) - await expectSectionCount(section.getByRole('link', { name: /Seguir no Instagram/ }), 0) + await expect(section.getByRole('link', { name: 'Instagram', exact: true })).toBeVisible() await page.goto('about:blank') await expect(page).toHaveURL(/about:blank/) @@ -1647,9 +940,9 @@ test.describe('Campaign home content section', () => { const section = page.locator('[data-home-section="contents"]') await expect(section).toBeVisible() - // Every card of the 1+4 bento (CONTENT_SECTION_LIMIT = 5) carries + // Every card of the 1+3 board (CONTENT_SECTION_LIMIT = 4) carries // exactly one discreet share button. - await expect(section.getByRole('button', { name: 'Compartilhar' })).toHaveCount(5) + await expect(section.getByRole('button', { name: 'Compartilhar' })).toHaveCount(4) const urlBeforeShare = page.url() const waMessage = async (item: Locator) => { const href = await item.getAttribute('href') @@ -1687,7 +980,7 @@ test.describe('Campaign home content section', () => { // Outside click also closes the popover. await articleShare.click() - await section.getByRole('heading', { name: 'A caminhada, em tempo real' }).click() + await section.getByRole('heading', { name: 'O mandato, onde a Bahia acontece' }).click() await expect(page.locator('[data-slot="popover-content"]')).toHaveCount(0) // Video card: the platform URL is shared with the video opener. @@ -1711,28 +1004,25 @@ test.describe('Campaign home content section', () => { ) await page.keyboard.press('Escape') - // Mobile: the carousel exposes only the active card's share button, with - // a ≥44px touch target around the discreet 28px visual, and the portal - // menu stays fully inside the viewport (never clipped by the track). + // Mobile: the board stacks and every card keeps its share control (44px + // target) with the popover fully inside the viewport. await page.setViewportSize({ width: 390, height: 844 }) await gotoHomeFresh(page) - const carousel = page.getByRole('region', { name: 'Conteúdos recentes' }) - await expect(carousel).toBeVisible() - // The active (featured) card is the newest item — the article seeded - // 5 min ago against the stubs' 20-min-oldest — which pins the identity - // of the single exposed share button below. - await expect(carousel.getByRole('heading', { name: articleTitle })).toBeVisible() - const mobileShare = carousel.getByRole('button', { name: 'Compartilhar' }) - await expect(mobileShare).toHaveCount(1) + const mobileSection = page.locator('[data-home-section="contents"]') + await expect(mobileSection).toBeVisible() + await expect(mobileSection.getByRole('heading', { name: articleTitle })).toBeVisible() + await expect(page.locator('[data-carousel="contents"]')).toHaveCount(0) + const mobileShare = mobileSection + .getByText(articleTitle) + .first() + .locator('xpath=../..') + .getByRole('button', { name: 'Compartilhar' }) + await expect(mobileShare).toBeVisible() await mobileShare.scrollIntoViewIfNeeded() const buttonBox = await mobileShare.boundingBox() expect(buttonBox).not.toBeNull() expect(buttonBox!.width).toBeGreaterThanOrEqual(44) expect(buttonBox!.height).toBeGreaterThanOrEqual(44) - const visualBox = await mobileShare.locator('span').first().boundingBox() - expect(visualBox).not.toBeNull() - expect(Math.abs(visualBox!.width - 28)).toBeLessThanOrEqual(1) - expect(Math.abs(visualBox!.height - 28)).toBeLessThanOrEqual(1) await mobileShare.click() const mobileWaItem = page.getByRole('link', { name: 'Compartilhar no WhatsApp' }) @@ -1754,867 +1044,3 @@ test.describe('Campaign home content section', () => { } }) }) - -test.describe('Cards personalizados (S14)', () => { - test('home opens the composer in place right before the newsletter capture', async ({ page }) => { - await page.goto('/') - - const order = await page - .locator('[data-home-section]') - .evaluateAll((sections) => - sections.map((section) => section.getAttribute('data-home-section')), - ) - // S22 — the sound section sits between the story and the cards; the - // conversion block (cards + newsletter) stays last. - expect(order.indexOf('sound')).toBe(order.indexOf('story') + 1) - expect(order.indexOf('cards')).toBe(order.indexOf('sound') + 1) - // S39 — the Central sample is conditional (only while pieces are published, - // and the serial frontendConteudos spec may publish them mid-run), so the - // pin is agnostic: when present it sits between the cards and the newsletter. - expect(order.indexOf('cards')).toBeLessThan(order.indexOf('newsletter')) - const contentPieces = order.indexOf('content-pieces') - if (contentPieces !== -1) { - expect(contentPieces).toBeGreaterThan(order.indexOf('cards')) - expect(contentPieces).toBeLessThan(order.indexOf('newsletter')) - } - - const section = page.locator('section#cards') - await expect( - section.getByRole('heading', { name: 'Mostre que você está com Solla' }), - ).toBeVisible() - // S14/S15/S30/S31 — the CTA is gone: the six models are the only trigger. - await expect(section.getByRole('link', { name: 'Criar meu card' })).toHaveCount(0) - for (const model of [ - 'eu-sou-solla', - 'perfil-quadrado', - 'perfil-retangular', - 'time-de-voce', - 'time-do-estadual', - 'minha-colinha', - ]) { - await expect(section.locator(`[data-card-model-tile="${model}"]`).first()).toBeAttached() - } - // S31 — the `NOVO` badge marks the newest model (design gate scene 1). - await expect(section.locator('[data-card-model-tile="minha-colinha"]').first()).toContainText( - 'NOVO', - ) - - // The tile box takes its height from the in-flow ratio spacer — not from - // `aspect-ratio`, which WebKit drops on relayout (WebKit #265243) and - // collapsed the tiles to their labels. Force the container width change - // first: the same relayout that triggered the production bug is what the - // geometry below must survive. Each visible tile must be as tall as its - // width times the declared ratio (the border accounts for ≤4px). - await page.setViewportSize({ width: 1200, height: 900 }) - for (const model of [ - 'eu-sou-solla', - 'perfil-quadrado', - 'perfil-retangular', - 'time-de-voce', - 'time-do-estadual', - 'minha-colinha', - ]) { - const geometry = await section - .locator(`[data-card-model-tile="${model}"]:visible`) - .first() - .evaluate((element) => { - const spacer = element.querySelector('[data-card-tile-ratio]') - const box = spacer?.parentElement - const rect = box?.getBoundingClientRect() - if (!rect) return null - return { - ratio: Number(spacer!.getAttribute('data-card-tile-ratio')), - width: rect.width, - height: rect.height, - } - }) - expect(geometry).not.toBeNull() - expect(Math.abs(geometry!.height - geometry!.width * geometry!.ratio)).toBeLessThan(4) - } - - // S14 — the name tile reproduces the real result: `SEU NOME` drawn by the - // composer pipeline, left-aligned with the `SOU` border (x≈213 at 1080). - const nameTileCanvas = section.locator('[data-card-model-tile="eu-sou-solla"] canvas').first() - await expectCardNameLeftAligned(nameTileCanvas) - - const trigger = section.getByRole('button', { name: /Moldura quadrada/ }) - await trigger.click() - // No navigation: the same composer island opens over the home. - expect(new URL(page.url()).pathname).toBe('/') - const dialog = page.getByRole('dialog') - await expect(dialog.getByRole('heading', { name: 'Enquadre sua foto' })).toBeVisible() - await page.keyboard.press('Escape') - await expect(page.getByRole('dialog')).toHaveCount(0) - await expect( - page.locator('[data-card-model-tile="perfil-quadrado"]:visible').first(), - ).toBeFocused() - }) - - test('name card fits the master, reaches the result and downloads a real PNG', async ({ - page, - }) => { - await page.goto('/cards') - await expect(page.getByRole('dialog')).toHaveCount(0) - - await page.getByRole('button', { name: 'Card com seu nome' }).click() - const dialog = page.getByRole('dialog') - await expect(dialog.getByRole('heading', { name: 'Personalize com seu nome' })).toBeVisible() - - const primary = dialog.getByRole('button', { name: 'Criar meu card' }) - await expect(primary).toBeDisabled() - await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('João') - await expect(primary).toBeEnabled() - - const canvas = dialog.locator('canvas') - await expect(canvas).toHaveAttribute('width', '1080') - await expect(canvas).toHaveAttribute('height', '1440') - // S14 — the name is drawn on the `SOU` left border (x≈213 at 1080), not - // centered: a centered `João` would start past x=350. - await expectCardNameLeftAligned(canvas) - - await primary.click() - await expect(dialog.getByText('Seu card está pronto para compartilhar.')).toBeVisible() - - const [download, downloadEvent] = await Promise.all([ - page.waitForEvent('download'), - // S32 — the download beacons one anonymous event with the model id; the - // model without a picker never carries a state-deputy slug. - page.waitForRequest( - (request) => request.url().includes('/api/content-events') && request.method() === 'POST', - ), - dialog.getByRole('button', { name: 'Baixar meu card' }).click(), - ]) - expect(download.suggestedFilename()).toBe('card-jorge-solla-eu-sou-solla.png') - expect(downloadEvent.postDataJSON()).toEqual({ - type: 'download', - subjectType: 'card', - cardModelId: 'eu-sou-solla', - }) - const downloadPath = await download.path() - expect(downloadPath).toBeTruthy() - expect(readFileSync(downloadPath!).subarray(0, 8).equals(PNG_SIGNATURE)).toBe(true) - }) - - test('a name that cannot fit fails closed with an actionable message', async ({ page }) => { - await page.goto('/cards?model=eu-sou-solla') - const dialog = page.getByRole('dialog') - await expect(dialog).toBeVisible() - - await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Anticonstitucionalissimamente') - await expect(dialog.getByRole('alert')).toContainText('nome mais curto') - await expect(dialog.getByRole('button', { name: 'Criar meu card' })).toBeDisabled() - }) - - test('photo card decodes the file, keeps the overlay and recovers from a bad image', async ({ - page, - }) => { - await page.goto('/cards?model=perfil-quadrado') - const dialog = page.getByRole('dialog') - const canvas = dialog.locator('canvas') - await expect(canvas).toHaveAttribute('width', '1000') - await expect(canvas).toHaveAttribute('height', '1000') - // S17 — the harmony control is exclusive of the team model. - await expect(dialog.getByRole('switch')).toHaveCount(0) - - await dialog.locator('input[type="file"]').setInputFiles({ - name: 'foto-e2e.png', - mimeType: 'image/png', - buffer: TEST_PHOTO_PNG, - }) - await expect(dialog.getByRole('button', { name: 'Trocar foto' })).toBeVisible() - await expect - .poll(() => - canvas.evaluate((element) => { - const node = element as HTMLCanvasElement - return node.getContext('2d')!.getImageData(5, 5, 1, 1).data[3] - }), - ) - .toBe(255) - - await dialog.locator('input[type="file"]').setInputFiles({ - name: 'nao-e-foto.txt', - mimeType: 'text/plain', - buffer: Buffer.from('not an image'), - }) - await expect(dialog.getByRole('alert')).toContainText('Não foi possível usar esta foto') - await expect(dialog.getByRole('button', { name: 'Escolher outra foto' })).toBeVisible() - }) - - test('mobile opens the composer in the bottom drawer', async ({ page }) => { - await page.setViewportSize({ width: 390, height: 844 }) - await page.goto('/cards?model=eu-sou-solla') - - const drawer = page.locator('[data-slot="drawer-popup"]') - await expect(drawer).toBeVisible() - await expect(drawer.getByRole('heading', { name: 'Personalize com seu nome' })).toBeVisible() - await page.keyboard.press('Escape') - await expect(drawer).toHaveCount(0) - }) -}) - -/** - * S15 — the team model runs its engine in an e2e build with the deterministic - * stub (`NEXT_PUBLIC_CARDS_CUTOUT_STUB=1`): no wasm download, no inference. The - * real engine is exercised manually (craft/UAT); the stub keeps the states - * (progress, ready, error/retry) reproducible. - */ -/** Mirrors the stub's deterministic `slow` download progress (cardCutout.ts). */ -const STUB_CUTOUT_PERCENT = 36 - -const setCutoutStub = (page: Page, mode: 'ok' | 'slow' | 'error') => - page.addInitScript((value: string) => { - ;(window as unknown as { __cardsCutoutStub?: string }).__cardsCutoutStub = value - }, mode) - -const uploadBustPhoto = (dialog: Locator) => - dialog.locator('input[type="file"]').setInputFiles({ - name: 'busto-e2e.png', - mimeType: 'image/png', - buffer: TEST_PHOTO_PNG, - }) - -/** S30/S31 — `jul` matches Juliete and Julio; ↓ moves the active option to Julio. */ -const pickJulioByKeyboard = async (dialog: Locator) => { - const search = dialog.getByRole('combobox', { name: 'Buscar estadual' }) - await search.fill('jul') - await search.press('ArrowDown') - await search.press('Enter') -} - -/** - * S16 — the blue name banner probe: scan only the rows where the rotated - * rectangle spans its full width (|v| ≤ h/2·cos − w/2·sin) and take the widest - * run of solid banner pixels. The banner is the only source of that exact color - * there, so a long name shows a much wider run than the 509 reference (~505 - * pixels) — the fixed-width regression fails the count. - */ -const NAME_BANNER_PROBE = (() => { - const banner = TEAM_CARD_NAME_BANNER - const tilt = (Math.abs(banner.rotationDeg) * Math.PI) / 180 - const fullChordHalfSpan = - (banner.height / 2) * Math.cos(tilt) - (banner.maxWidth / 2) * Math.sin(tilt) - - return { - background: banner.background, - band: { - top: Math.ceil(banner.centerY - fullChordHalfSpan), - bottom: Math.floor(banner.centerY + fullChordHalfSpan), - }, - } -})() - -const widestNameBannerRun = (canvas: Locator) => - canvas.evaluate((element, probe) => { - const node = element as HTMLCanvasElement - const ctx = node.getContext('2d')! - const fill = Number.parseInt(probe.background.slice(1), 16) - const red = (fill >> 16) & 0xff - const green = (fill >> 8) & 0xff - const blue = fill & 0xff - let widest = 0 - - for (let y = probe.band.top; y <= probe.band.bottom; y++) { - const { data } = ctx.getImageData(0, y, node.width, 1) - let matches = 0 - for (let index = 0; index < data.length; index += 4) { - if (data[index] === red && data[index + 1] === green && data[index + 2] === blue) { - matches += 1 - } - } - if (matches > widest) widest = matches - } - - return widest - }, NAME_BANNER_PROBE) - -test.describe('Cards personalizados (S15 — Time de você)', () => { - test('cuts the photo on device, reaches the result and downloads a real PNG', async ({ - page, - }) => { - await setCutoutStub(page, 'slow') - await page.goto('/cards?model=time-de-voce') - - const dialog = page.getByRole('dialog') - await expect(dialog.getByRole('heading', { name: 'Entre para o time' })).toBeVisible() - - const canvas = dialog.locator('canvas') - await expect(canvas).toHaveAttribute('width', '1080') - await expect(canvas).toHaveAttribute('height', '1440') - - const primary = dialog.getByRole('button', { name: 'Criar meu card' }) - await expect(primary).toBeDisabled() - await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Maria Eduarda') - await uploadBustPhoto(dialog) - - // Processing is observable (first run: model download + local cutout). - await expect(dialog.getByText('Baixando o modelo de recorte…')).toBeVisible() - await expect(dialog.getByText(`${STUB_CUTOUT_PERCENT}%`)).toBeVisible() - await expect(dialog.getByRole('progressbar')).toHaveAttribute( - 'aria-valuenow', - String(STUB_CUTOUT_PERCENT), - ) - - // The cutout lands framed: CTA enabled, pan/zoom escape offered, and the - // guidance notice lives in the scrollable body — nothing over the preview. - await expect(dialog.getByRole('heading', { name: 'Confira seu card' })).toBeVisible() - await expect(primary).toBeEnabled() - await expect( - dialog.getByText( - 'O recorte já foi centralizado. Se precisar, arraste a foto ou use os controles.', - ), - ).toBeVisible() - await expect(dialog.getByText('Arraste para ajustar')).toHaveCount(0) - - // S16 — `Maria Eduarda` only fits by widening the blue banner beyond the - // 509 reference (the real Brexter ink goes to ~954 → banner ~993). - await expect.poll(() => widestNameBannerRun(canvas)).toBeGreaterThan(700) - - await primary.click() - await expect(dialog.getByText('Seu card está pronto para compartilhar.')).toBeVisible() - - const [download] = await Promise.all([ - page.waitForEvent('download'), - dialog.getByRole('button', { name: 'Baixar meu card' }).click(), - ]) - expect(download.suggestedFilename()).toBe('card-jorge-solla-time-de-voce.png') - const downloadPath = await download.path() - expect(downloadPath).toBeTruthy() - expect(readFileSync(downloadPath!).subarray(0, 8).equals(PNG_SIGNATURE)).toBe(true) - }) - - test('a failed cutout is recoverable: retry with the same photo reaches the result', async ({ - page, - }) => { - await setCutoutStub(page, 'error') - await page.goto('/cards?model=time-de-voce') - - const dialog = page.getByRole('dialog') - await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Maria') - await uploadBustPhoto(dialog) - - await expect(dialog.getByRole('heading', { name: 'Vamos tentar outra vez' })).toBeVisible() - await expect(dialog.getByRole('alert')).toContainText( - 'Não foi possível remover o fundo desta foto.', - ) - await expect(dialog.getByRole('button', { name: 'Criar meu card' })).toBeDisabled() - - // The retry re-runs the SAME file; no re-upload, no lost state. - await page.evaluate(() => { - ;(window as unknown as { __cardsCutoutStub?: string }).__cardsCutoutStub = 'ok' - }) - await dialog.getByRole('button', { name: 'Tentar de novo' }).click() - await expect(dialog.getByRole('heading', { name: 'Confira seu card' })).toBeVisible() - await expect(dialog.getByRole('button', { name: 'Criar meu card' })).toBeEnabled() - }) - - test('harmonizes the cutout by default and toggles back to the original photo', async ({ - page, - }) => { - await setCutoutStub(page, 'ok') - await page.goto('/cards?model=time-de-voce') - - const dialog = page.getByRole('dialog') - await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Maria') - await uploadBustPhoto(dialog) - await expect(dialog.getByRole('heading', { name: 'Confira seu card' })).toBeVisible() - - const toggle = dialog.getByRole('switch', { name: 'Harmonizar cores' }) - await expect(toggle).toBeVisible() - await expect(toggle).toHaveAttribute('aria-checked', 'true') - await expect( - dialog.getByText( - 'Ajusta o brilho e as cores da sua foto para combinar com as fotos do card.', - ), - ).toBeVisible() - const box = await toggle.boundingBox() - expect(box?.height ?? 0).toBeGreaterThanOrEqual(44) - - const canvas = dialog.locator('canvas') - const readSample = () => - canvas.evaluate((element) => { - const node = element as HTMLCanvasElement - // S15 — the auto-framing covers the slot, so the window center is the - // stub ellipse center. - return [...node.getContext('2d')!.getImageData(582, 727, 1, 1).data] - }) - - // Default ON: the preview pixel is the harmonized variant, not the raw photo. - await expect.poll(readSample).not.toEqual([30, 120, 200, 255]) - const harmonized = await readSample() - - await toggle.click() - await expect(toggle).toHaveAttribute('aria-checked', 'false') - await expect.poll(readSample).toEqual([30, 120, 200, 255]) - - // Re-enabling with the keyboard proves the native switch semantics. - await toggle.focus() - await page.keyboard.press('Space') - await expect(toggle).toHaveAttribute('aria-checked', 'true') - await expect.poll(readSample).toEqual(harmonized) - await expect( - dialog.getByText('Sua foto fica neste aparelho e não é enviada para nós.'), - ).toBeVisible() - }) - - test('a name that cannot fit fails closed and keeps the cutout', async ({ page }) => { - await setCutoutStub(page, 'ok') - await page.goto('/cards?model=time-de-voce') - - const dialog = page.getByRole('dialog') - await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Anticonstitucionalissimamente') - await uploadBustPhoto(dialog) - - await expect(dialog.getByRole('heading', { name: 'Encurte o nome' })).toBeVisible() - await expect(dialog.getByRole('alert')).toContainText('nome mais curto') - // S17 — the harmony control lives in the regular ready state only. - await expect(dialog.getByRole('switch')).toHaveCount(0) - const primary = dialog.getByRole('button', { name: 'Criar meu card' }) - await expect(primary).toBeDisabled() - await expect(dialog.getByText('permanecem salvos neste aparelho')).toBeVisible() - - // The cutout is kept and the name stays editable: fixing it re-enables the card. - await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Maria') - await expect(dialog.getByRole('alert')).toHaveCount(0) - await expect(dialog.getByRole('heading', { name: 'Confira seu card' })).toBeVisible() - await expect(primary).toBeEnabled() - }) - - test('mobile opens the team composer in the bottom drawer', async ({ page }) => { - await page.setViewportSize({ width: 390, height: 844 }) - await page.goto('/cards?model=time-de-voce') - - const drawer = page.locator('[data-slot="drawer-popup"]') - await expect(drawer).toBeVisible() - await expect(drawer.getByRole('heading', { name: 'Entre para o time' })).toBeVisible() - // S23 — the drawer reaches the top of the viewport instead of the old 92dvh - // gap: y=0 and the full viewport height once the open animation settles. - await expect - .poll(async () => { - const box = await drawer.boundingBox() - return box ? `${Math.round(box.y)}x${Math.round(box.height)}` : null - }) - .toBe('0x844') - await page.keyboard.press('Escape') - await expect(drawer).toHaveCount(0) - }) -}) - -/** - * S15 — the auto-framing after the S18 proportional framing was reverted: the - * stub cutout (bbox = whole canvas) must cover the photo window at the cover - * floor (zoom 1), the person filling the slot instead of a small sticker. The - * engine failure paths (WebGL/WASM blocked) are covered by the adapter unit - * specs; the real engine is exercised in craft. - */ -test.describe('Cards personalizados (S15 — enquadramento automático)', () => { - test('covers the photo window at the cover floor and downloads the PNG', async ({ page }) => { - await setCutoutStub(page, 'ok') - await page.goto('/cards?model=time-de-voce') - - const dialog = page.getByRole('dialog') - await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Maria') - await uploadBustPhoto(dialog) - - await expect(dialog.getByRole('heading', { name: 'Confira seu card' })).toBeVisible() - await expect(dialog.getByRole('alert')).toHaveCount(0) - // The S18 proportional framing sat well below the cover floor; the reverted - // auto-framing always covers (zoom 1 for the stub's full-canvas bbox). - expect(Number(await dialog.locator('input[type="range"]').inputValue())).toBe(1) - - // Scale proof: the stub ellipse (rx 180 on a 400px canvas) reaches x=300 in - // card pixels only under the cover framing — the small S18 sticker did not. - // Harmony is ON by default; turn it off to sample the raw fixture. - await dialog.getByRole('switch', { name: 'Harmonizar cores' }).click() - const canvas = dialog.locator('canvas') - await expect - .poll(() => - canvas.evaluate((element) => { - const node = element as HTMLCanvasElement - return [...node.getContext('2d')!.getImageData(300, 727, 1, 1).data] - }), - ) - .toEqual([30, 120, 200, 255]) - - const primary = dialog.getByRole('button', { name: 'Criar meu card' }) - await expect(primary).toBeEnabled() - await primary.click() - - const [download] = await Promise.all([ - page.waitForEvent('download'), - dialog.getByRole('button', { name: 'Baixar meu card' }).click(), - ]) - expect( - readFileSync((await download.path())!) - .subarray(0, 8) - .equals(PNG_SIGNATURE), - ).toBe(true) - }) -}) - -/** - * S30 — the state-deputy model reuses the S15 cutout stub; the new surfaces are - * the picker (search + ↑↓/Enter), the empty photo window after the pick (S40 - * removed the drawn silhouette) and the art swap that must keep the typed name - * and the processed photo. - */ -test.describe('Cards personalizados (S30 — Time do estadual)', () => { - /** - * Window probes at 1080×1440: x=582 is the photo window center (286 + 592/2), - * y=540 a point inside the window top and y=727 the window center where the - * stub cutout lands. - */ - const windowPixel = (canvas: Locator, x: number, y: number) => - canvas.evaluate( - (element, point) => { - const node = element as HTMLCanvasElement - return [...node.getContext('2d')!.getImageData(point.x, point.y, 1, 1).data] - }, - { x, y }, - ) - - test('picks a deputy, keeps the window empty, cuts the photo and downloads the PNG', async ({ - page, - }) => { - await setCutoutStub(page, 'ok') - await page.goto('/cards?model=time-do-estadual') - - const dialog = page.getByRole('dialog') - await expect(dialog.getByRole('heading', { name: 'Escolha sua dobradinha' })).toBeVisible() - await expect(dialog.getByText('Escolha seu estadual da dobradinha.')).toBeVisible() - // Pre-selection: the approved example, the notice and no photo box yet. - await expect(dialog.getByText('A silhueta marca o lugar da sua foto.')).toBeVisible() - await expect(dialog.getByText('Escolha um estadual para continuar.')).toBeVisible() - await expect(dialog.getByRole('button', { name: 'Escolher foto' })).toHaveCount(0) - const primary = dialog.getByRole('button', { name: 'Criar meu card' }) - await expect(primary).toBeDisabled() - - await pickJulioByKeyboard(dialog) - - const trigger = dialog.getByRole('button', { name: /Seu estadual/ }) - await expect(trigger).toContainText('Julio Pinheiro') - await expect(trigger).toContainText('13999') - await expect(dialog.getByRole('button', { name: 'Escolher foto' })).toBeVisible() - - // S40 — no drawn silhouette: the chosen art (julio-fotos) shows through the - // empty photo window until the visitor photo lands. - const canvas = dialog.locator('canvas') - await expect.poll(() => windowPixel(canvas, 582, 540)).toEqual([239, 223, 196, 255]) - - await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Maria') - await expect(primary).toBeDisabled() - await uploadBustPhoto(dialog) - await expect(dialog.getByRole('heading', { name: 'Confira seu card' })).toBeVisible() - await expect(primary).toBeEnabled() - - await primary.click() - await expect(dialog.getByText('Seu card está pronto para compartilhar.')).toBeVisible() - await expect(dialog.getByText('Julio Pinheiro · 13999')).toBeVisible() - - const [download, downloadEvent] = await Promise.all([ - page.waitForEvent('download'), - // S32 — the picker model carries the chosen state-deputy slug. - page.waitForRequest( - (request) => request.url().includes('/api/content-events') && request.method() === 'POST', - ), - dialog.getByRole('button', { name: 'Baixar meu card' }).click(), - ]) - expect(download.suggestedFilename()).toBe('card-jorge-solla-time-do-estadual.png') - expect(downloadEvent.postDataJSON()).toEqual({ - type: 'download', - subjectType: 'card', - cardModelId: 'time-do-estadual', - stateDeputySlug: 'julio', - }) - expect( - readFileSync((await download.path())!) - .subarray(0, 8) - .equals(PNG_SIGNATURE), - ).toBe(true) - }) - - test('switching the deputy keeps the typed name and the processed photo', async ({ page }) => { - await setCutoutStub(page, 'ok') - await page.goto('/cards?model=time-do-estadual') - - const dialog = page.getByRole('dialog') - await pickJulioByKeyboard(dialog) - await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Maria') - await uploadBustPhoto(dialog) - await expect(dialog.getByRole('heading', { name: 'Confira seu card' })).toBeVisible() - - // The cutout landed: harmony OFF samples the stub fixture at the window center. - const canvas = dialog.locator('canvas') - await dialog.getByRole('switch', { name: 'Harmonizar cores' }).click() - await expect.poll(() => windowPixel(canvas, 582, 727)).toEqual([30, 120, 200, 255]) - - // Swap the deputy through the trigger; the new one confirms on it. - await dialog.getByRole('button', { name: /Seu estadual/ }).click() - const search = dialog.getByRole('combobox', { name: 'Buscar estadual' }) - await search.fill('adriana') - await search.press('Enter') - const trigger = dialog.getByRole('button', { name: /Seu estadual/ }) - await expect(trigger).toContainText('Coletivo de Enfermagem') - await expect(trigger).toContainText('13763') - - // Name and cutout survive the swap: still ready, CTA enabled, photo pixel intact. - await expect(dialog.getByRole('heading', { name: 'Confira seu card' })).toBeVisible() - await expect(dialog.getByRole('button', { name: 'Criar meu card' })).toBeEnabled() - await expect.poll(() => windowPixel(canvas, 582, 727)).toEqual([30, 120, 200, 255]) - }) - - test('a name that cannot fit fails closed on the estadual model', async ({ page }) => { - await page.goto('/cards?model=time-do-estadual') - - const dialog = page.getByRole('dialog') - await pickJulioByKeyboard(dialog) - await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Anticonstitucionalissimamente') - - await expect(dialog.getByRole('heading', { name: 'Encurte o nome' })).toBeVisible() - await expect(dialog.getByRole('alert')).toContainText('nome mais curto') - await expect(dialog.getByRole('button', { name: 'Criar meu card' })).toBeDisabled() - }) - - test('the picker shows the empty state when nothing matches', async ({ page }) => { - await page.goto('/cards?model=time-do-estadual') - - const dialog = page.getByRole('dialog') - await dialog.getByRole('combobox', { name: 'Buscar estadual' }).fill('sem-resultado') - - await expect(dialog.getByText('Nenhum estadual encontrado.')).toBeVisible() - }) - - test('mobile gallery exposes the six dots and the six-model hint', async ({ page }) => { - await page.setViewportSize({ width: 390, height: 844 }) - await page.goto('/cards') - - const firstDot = page.getByRole('button', { name: 'Ir para o modelo 1 de 6' }) - const sixthDot = page.getByRole('button', { name: 'Ir para o modelo 6 de 6' }) - await expect(sixthDot).toBeVisible() - await expect(page.getByText('Deslize para ver os seis modelos')).toBeVisible() - await expect(firstDot).toHaveAttribute('aria-current', 'true') - - // Clicking the last dot scrolls the track and keeps the active dot on it - // (the programmatic scroll lock must not bounce the index back mid-animation). - await sixthDot.click() - await expect(sixthDot).toHaveAttribute('aria-current', 'true') - await expect(firstDot).not.toHaveAttribute('aria-current', 'true') - await expect - .poll(() => - page - .locator('ul[aria-label="Modelos de card (mobile)"]') - .evaluate((element) => element.scrollLeft), - ) - .toBeGreaterThan(0) - }) -}) - -test.describe('Cards personalizados (S34 — Minha colinha)', () => { - /** - * Canvas probe at 1080×1920 over the estadual row, derived from the layout the - * renderer draws with: the label band reports the first dark-ink column and - * the first red column (the delivered art burns the label at x 571–856; the - * filled state wipes it and redraws the pair from the mask's left edge), and - * the first printed box interior reports glyph ink. - */ - const colinhaRowProbe = (canvas: Locator) => - canvas.evaluate((element, layout) => { - const ctx = (element as HTMLCanvasElement).getContext('2d')! - const { mask, copy, digit } = layout - const bandX = Math.round(mask.x) - const bandY = Math.round(copy.top) - const bandWidth = Math.round(copy.right) - bandX - const bandHeight = Math.round(mask.y + mask.height) - bandY - const band = ctx.getImageData(bandX, bandY, bandWidth, bandHeight).data - let firstDarkX = -1 - let redFirstX = -1 - for (let x = 0; x < bandWidth && (firstDarkX < 0 || redFirstX < 0); x += 1) { - for (let y = 0; y < bandHeight; y += 1) { - const offset = (y * bandWidth + x) * 4 - const r = band[offset]! - const g = band[offset + 1]! - const b = band[offset + 2]! - if (firstDarkX < 0 && r + g + b < 240) firstDarkX = bandX + x - if (redFirstX < 0 && r > 150 && g < 100 && b < 100) redFirstX = bandX + x - } - } - const boxX = Math.round(digit.left + digit.width / 2 - 17) - const boxY = Math.round(digit.top + digit.height / 2 - 17) - const box = ctx.getImageData(boxX, boxY, 34, 34).data - let boxInk = 0 - for (let i = 0; i < box.length; i += 4) { - if (box[i]! + box[i + 1]! + box[i + 2]! < 240) boxInk += 1 - } - return { firstDarkX, redFirstX, boxInk } - }, COLINHA_ROW_LAYOUT) - - const maskLeft = Math.round(COLINHA_ROW_LAYOUT.mask.x) - const filledRowProbe = async (canvas: Locator) => { - const { firstDarkX, redFirstX, boxInk } = await colinhaRowProbe(canvas) - return firstDarkX >= maskLeft && firstDarkX < 450 && redFirstX >= maskLeft && boxInk > 0 - } - - test('shows the delivered art with the download disabled before the pick', async ({ page }) => { - await page.goto('/cards?model=minha-colinha') - - const dialog = page.getByRole('dialog') - await expect(dialog.getByRole('heading', { name: 'Escolha seu estadual' })).toBeVisible() - await expect( - dialog.getByText('A linha de deputado estadual será preenchida na hora.'), - ).toBeVisible() - await expect(dialog.getByText('Escolha um estadual para baixar.')).toBeVisible() - - const canvas = dialog.locator('canvas') - await expect(canvas).toHaveAttribute('width', '1080') - await expect(canvas).toHaveAttribute('height', '1920') - - // The art as delivered: the burned label ink stays at the right, no red - // name and the printed boxes are empty. - await expect - .poll(async () => { - const { firstDarkX, redFirstX, boxInk } = await colinhaRowProbe(canvas) - return firstDarkX > 500 && redFirstX < 0 && boxInk === 0 - }) - .toBe(true) - - await expect(dialog.getByRole('button', { name: 'Baixar minha colinha' })).toBeDisabled() - }) - - test('picks the estadual, fills the slip row and downloads the 1080×1920 PNG', async ({ - page, - }) => { - await page.goto('/cards?model=minha-colinha') - - const dialog = page.getByRole('dialog') - const canvas = dialog.locator('canvas') - const primary = dialog.getByRole('button', { name: 'Baixar minha colinha' }) - - await pickJulioByKeyboard(dialog) - - await expect(dialog.getByRole('heading', { name: 'Pronta para levar com você' })).toBeVisible() - const trigger = dialog.getByRole('button', { name: /Seu estadual/ }) - await expect(trigger).toContainText('Julio Pinheiro') - await expect(trigger).toContainText('13999') - await expect(dialog.getByText('Linha conferida')).toBeVisible() - await expect(dialog.getByText('DEPUTADO ESTADUAL · JULIO PINHEIRO · 13999')).toBeVisible() - await expect( - dialog.getByText( - 'A escolha é processada neste aparelho. Nenhum nome, foto ou cadastro é solicitado.', - ), - ).toBeVisible() - await expect(primary).toBeEnabled() - - // The picked row: the burned label is wiped and the redrawn pair (dark - // label + red name) starts at the mask's left edge; the first printed box - // carries the `1` glyph ink. - await expect.poll(() => filledRowProbe(canvas)).toBe(true) - - const [download, downloadEvent] = await Promise.all([ - page.waitForEvent('download'), - // S32 — the picker model carries the chosen state-deputy slug. - page.waitForRequest( - (request) => request.url().includes('/api/content-events') && request.method() === 'POST', - ), - primary.click(), - ]) - expect(download.suggestedFilename()).toBe('card-jorge-solla-minha-colinha.png') - expect(downloadEvent.postDataJSON()).toEqual({ - type: 'download', - subjectType: 'card', - cardModelId: 'minha-colinha', - stateDeputySlug: 'julio', - }) - expect( - readFileSync((await download.path())!) - .subarray(0, 8) - .equals(PNG_SIGNATURE), - ).toBe(true) - }) - - test('shrinks the longest catalog name inside the mask', async ({ page }) => { - await page.goto('/cards?model=minha-colinha') - - const dialog = page.getByRole('dialog') - const search = dialog.getByRole('combobox', { name: 'Buscar estadual' }) - await search.fill('arthur') - await search.press('Enter') - - const trigger = dialog.getByRole('button', { name: /Seu estadual/ }) - await expect(trigger).toContainText('Artur Barachisio Lisbôa') - // The shrunk red name still starts inside the mask (never over the art). - await expect.poll(() => filledRowProbe(dialog.locator('canvas'))).toBe(true) - }) - - test('switching the estadual keeps the slip ready for download', async ({ page }) => { - await page.goto('/cards?model=minha-colinha') - - const dialog = page.getByRole('dialog') - await pickJulioByKeyboard(dialog) - - // Swap through the trigger; the new one confirms on it. - await dialog.getByRole('button', { name: /Seu estadual/ }).click() - const search = dialog.getByRole('combobox', { name: 'Buscar estadual' }) - await search.fill('adriana') - await search.press('Enter') - - const trigger = dialog.getByRole('button', { name: /Seu estadual/ }) - await expect(trigger).toContainText('Coletivo de Enfermagem') - await expect(trigger).toContainText('13763') - await expect(dialog.getByRole('button', { name: 'Baixar minha colinha' })).toBeEnabled() - }) -}) - -/** - * S33 — the two team models are position-free (human gate of 2026-09-24): the - * visitor adjustment is not clamped in any axis, so the photo may leave the - * window and reveal the art behind it. The S15 auto-framing stays the starting - * point and the zoom keeps its [1, 4] range. With the stub cutout (400×300, - * full-canvas bbox) the S15 transform lands at offsetX ≈ 197.33, whose old X - * ceiling was 286 — eight Shift+→ steps (+48 each = +384) only pass the window - * with the clamp gone, and the write-back must never snap it back. - */ -test.describe('Cards personalizados (S33 — ajuste livre do time)', () => { - const windowPixel = (canvas: Locator, x: number, y: number) => - canvas.evaluate( - (element, point) => { - const node = element as HTMLCanvasElement - return [...node.getContext('2d')!.getImageData(point.x, point.y, 1, 1).data] - }, - { x, y }, - ) - - test('the keyboard moves the photo past the window and it never snaps back', async ({ page }) => { - await setCutoutStub(page, 'ok') - await page.goto('/cards?model=time-de-voce') - - const dialog = page.getByRole('dialog') - await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Maria') - await uploadBustPhoto(dialog) - await expect(dialog.getByRole('heading', { name: 'Confira seu card' })).toBeVisible() - - // Harmony OFF reads the raw stub fixture: under the S15 framing the photo - // covers x=500, so the probe sees the fixture blue. - await dialog.getByRole('switch', { name: 'Harmonizar cores' }).click() - const canvas = dialog.locator('canvas') - const probe = () => windowPixel(canvas, 500, 727) - await expect.poll(probe).toEqual([30, 120, 200, 255]) - - // Eight Shift+→ steps = +384 card pixels — past the old ceiling (197.33 + - // 88.67): the photo leaves the window and the art behind it shows there. - const group = dialog.getByRole('group', { name: 'Ajuste da foto' }) - await group.focus() - for (let step = 0; step < 8; step += 1) await page.keyboard.press('Shift+ArrowRight') - - // The exact base-art pixel at the probe: stronger than "not the fixture - // blue" (a blank or broken canvas would not pass it). - const baseArtPixel = [246, 246, 248, 255] - await expect.poll(probe).toEqual(baseArtPixel) - - // No snap-back: the render write-back keeps the free position, so the moved - // frame stays painted after the effect settles. - await page.waitForTimeout(250) - expect(await probe()).toEqual(baseArtPixel) - - // The freedom is of the position only: the zoom stays at the cover floor. - expect(Number(await dialog.locator('input[type="range"]').inputValue())).toBe(1) - }) -}) diff --git a/tests/e2e/frontendConteudos.e2e.spec.ts b/tests/e2e/frontendConteudos.e2e.spec.ts deleted file mode 100644 index 3a53ed44..00000000 --- a/tests/e2e/frontendConteudos.e2e.spec.ts +++ /dev/null @@ -1,1046 +0,0 @@ -import { randomUUID } from 'node:crypto' -import { readFileSync } from 'node:fs' -import { join } from 'node:path' - -import type { APIRequestContext, Page } from '@playwright/test' -import sharp from 'sharp' - -import { loadMunicipalityGeometryModule } from '../../src/lib/bahiaGeometries.js' -import { getMunicipalityCatalogEntry } from '../../src/lib/municipalityCatalog.js' -import { slugify } from '../../src/lib/slug.js' -import { adminHeaders } from '../helpers/adminApi' -import { interiorPointOf } from '../helpers/featureBounds' -import { waitForHomeSection } from '../helpers/homeIsr' -import { seedTestUser } from '../helpers/seedUser' -import { expect, test } from './fixtures/e2eTest' - -/** - * S27 — the public Central de Conteúdos over real HTTP: the published pieces - * of C211 list with the five facets, the media is only fetched on the play, - * the share sheet carries the editable vote message with no recipient, the - * download name is legible and the kill switch flips the page, the discovery - * link and the piece page without a deploy. - * - * Pieces and media are seeded through the deployed REST API (admin session) so - * the server process runs the real cache hooks; a Local API call from the - * runner would throw on `revalidateTag` (same reason as the S21 spec). - * - * The tests are serial: the empty-state assertions need a global window with - * zero published pieces, and only this spec owns content piece rows. - */ - -const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000' - -// 1×1 opaque PNG — enough for the photo upload. -const TEST_PNG = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', - 'base64', -) -const TEST_MP4 = Buffer.from('fixture-content-piece-video-bytes') - -type Headers = Record<string, string> - -const createdPieceIds: number[] = [] -const createdMediaIds: number[] = [] -const createdContactIds: number[] = [] -const createdLeadershipIds: number[] = [] - -const uniqueMarker = () => randomUUID().slice(0, 8) - -const mediaFor = (type: 'video' | 'foto' | 'audio' | 'texto') => { - if (type === 'video') return { mimetype: 'video/mp4', buffer: TEST_MP4, extension: 'mp4' } - if (type === 'foto') return { mimetype: 'image/png', buffer: TEST_PNG, extension: 'png' } - if (type === 'audio') - return { - mimetype: 'audio/mpeg', - buffer: readFileSync(join(process.cwd(), 'tests/fixtures/jingle-tone.mp3')), - extension: 'mp3', - } - return { - mimetype: 'text/plain', - buffer: Buffer.from('Peça voto pra Solla 1313.'), - extension: 'txt', - } -} - -const createContentMedia = async ( - request: APIRequestContext, - headers: Headers, - name: string, - mimetype: string, - buffer: Buffer, -): Promise<number> => { - const response = await request.post(`${BASE_URL}/api/contentMedia`, { - headers, - multipart: { - _payload: JSON.stringify({ alt: name }), - file: { name, mimeType: mimetype, buffer }, - }, - }) - expect(response.ok(), await response.text()).toBeTruthy() - const id = ((await response.json()) as { doc: { id: number } }).doc.id - createdMediaIds.push(id) - return id -} - -const createPiece = async ( - request: APIRequestContext, - headers: Headers, - data: { - title: string - type: 'video' | 'foto' | 'texto' | 'audio' | 'card' - withMedia?: boolean - origin?: 'arquivo' | 'instagram' | 'youtube' - sourceUrl?: string - /** S39 — the home sample resolves the município name from this relation. */ - municipality?: number - /** S37 — the campaign leaders and curated figures this piece carries. */ - leaders?: number[] - publicFigures?: string[] - }, -): Promise<{ id: number; slug: string }> => { - const mediaKind = - data.type === 'video' || data.type === 'foto' || data.type === 'audio' ? data.type : 'texto' - const mediaSpec = mediaFor(mediaKind) - const media = - data.withMedia === false - ? null - : await createContentMedia( - request, - headers, - `${data.title.replace(/\s+/g, '-')}.${mediaSpec.extension}`, - mediaSpec.mimetype, - mediaSpec.buffer, - ) - - const response = await request.post(`${BASE_URL}/api/contentPiece`, { - headers, - data: { - title: data.title, - type: data.type, - status: 'publicado', - origin: data.origin ?? 'arquivo', - ...(media ? { media } : {}), - ...(data.sourceUrl ? { sourceUrl: data.sourceUrl } : {}), - ...(data.municipality ? { municipality: data.municipality } : {}), - ...(data.leaders ? { leaders: data.leaders } : {}), - ...(data.publicFigures ? { publicFigures: data.publicFigures } : {}), - }, - }) - expect(response.ok(), await response.text()).toBeTruthy() - const doc = ((await response.json()) as { doc: { id: number; slug: string } }).doc - createdPieceIds.push(doc.id) - return { id: doc.id, slug: doc.slug } -} - -/** - * S37 — a real leadership record (contact + municipality link) for the public - * "Lideranças" facet: the piece references the record, only the name is public. - */ -const createLeadershipWithContact = async ( - request: APIRequestContext, - headers: Headers, - name: string, -): Promise<{ leadershipId: number }> => { - const contactResponse = await request.post(`${BASE_URL}/api/contact`, { - headers, - data: { name }, - }) - expect(contactResponse.ok(), await contactResponse.text()).toBeTruthy() - const contactId = ((await contactResponse.json()) as { doc: { id: number } }).doc.id - createdContactIds.push(contactId) - - const municipalityResponse = await request.get( - `${BASE_URL}/api/municipality?limit=1&depth=0&sort=name`, - { headers }, - ) - expect(municipalityResponse.ok(), await municipalityResponse.text()).toBeTruthy() - const municipalityId = ((await municipalityResponse.json()) as { docs: { id: number }[] }) - .docs[0]!.id - - const leadershipResponse = await request.post(`${BASE_URL}/api/leadership`, { - headers, - data: { contact: contactId, municipalities: [municipalityId] }, - }) - expect(leadershipResponse.ok(), await leadershipResponse.text()).toBeTruthy() - const leadershipId = ((await leadershipResponse.json()) as { doc: { id: number } }).doc.id - createdLeadershipIds.push(leadershipId) - return { leadershipId } -} - -const setPieceStatus = async ( - request: APIRequestContext, - headers: Headers, - id: number, - status: 'rascunho' | 'publicado', -): Promise<void> => { - const response = await request.patch(`${BASE_URL}/api/contentPiece/${id}`, { - headers, - data: { status }, - }) - expect(response.ok(), await response.text()).toBeTruthy() -} - -/** Zero published pieces globally so the empty-state assertions stay deterministic. */ -const unpublishEveryPiece = async (request: APIRequestContext, headers: Headers) => { - const listed = await request.get(`${BASE_URL}/api/contentPiece?limit=0&depth=0`, { headers }) - expect(listed.ok(), await listed.text()).toBeTruthy() - const { docs } = (await listed.json()) as { docs: { id: number; status?: string | null }[] } - for (const doc of docs) { - if (doc.status === 'publicado') await setPieceStatus(request, headers, doc.id, 'rascunho') - } -} - -const mediaRequests = (page: Page) => { - const requests: string[] = [] - page.on('request', (request) => { - if (/\/conteudos\/.+\/midia/.test(request.url())) requests.push(request.url()) - }) - return requests -} - -/** - * C226 — the still is a separate door (`/frame`, a sibling of `/midia`), so the - * S27 pin above keeps meaning "no file before the play" while this one means - * "the image is asked for before the play". - */ -const frameRequests = (page: Page) => { - const requests: string[] = [] - page.on('request', (request) => { - if (/\/conteudos\/.+\/frame/.test(request.url())) requests.push(request.url()) - }) - return requests -} - -/** - * C226 — the still door is stubbed like the other third-party routes: what - * depends on ffmpeg (the extraction) would otherwise decide whether this spec - * sees a rendered frame or the honest neutral slot, differently on a developer - * machine and in CI. The server contract (200 vs 404, the kill switch) is - * pinned where it belongs, in the int spec; here the card's own behavior — - * asking for the still before the play and dropping it after — is the subject. - */ -const stubContentPieceFrames = async (page: Page): Promise<void> => { - // A real 16:9 JPEG (the browser rejects a truncated one, and the card's own - // fallback would then hide a bug this spec is here to catch). - const still = await sharp({ - create: { width: 320, height: 180, channels: 3, background: { r: 24, g: 78, b: 146 } }, - }) - .jpeg() - .toBuffer() - await page.route('**/conteudos/*/frame', (route) => - route.fulfill({ body: still, contentType: 'image/jpeg' }), - ) -} - -/** - * C106/S28 — dynamic pages stream a transient hidden `S:` copy of the shell in - * the production build (the theme path reads `next/headers`, and any heavy - * render can defer behind `loading.tsx`). Wait for the stream to be collected - * before asserting with strict locators. - */ -const waitForSettledPage = async (page: Page) => { - await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0) -} - -test.describe.configure({ mode: 'serial' }) - -// C226 — the still of a video piece is asked for before the play and answers -// 404 until the self-heal lands (the fixtures here are not decodifiable video, -// and the extraction needs ffmpeg). The browser logs that refusal, so the spec -// declares the door: the frame's own 200/404 contract is pinned in the int spec -// and the rendered still in the lazy test below. -test.use({ expectedRequestFailurePaths: [/\/conteudos\/.+\/frame$/] }) - -test.afterAll(async ({ request }) => { - const headers = await adminHeaders(request, BASE_URL).catch(() => null) - if (!headers) return - for (const id of createdPieceIds.splice(0)) { - // C213 — the anonymous events of the piece go with it (the spec owns both). - await request - .delete(`${BASE_URL}/api/contentEvent?where[subjectId][equals]=${id}`, { headers }) - .catch(() => undefined) - await request.delete(`${BASE_URL}/api/contentPiece/${id}`, { headers }).catch(() => undefined) - } - for (const id of createdMediaIds.splice(0)) { - await request.delete(`${BASE_URL}/api/contentMedia/${id}`, { headers }).catch(() => undefined) - } - for (const id of createdLeadershipIds.splice(0)) { - await request.delete(`${BASE_URL}/api/leadership/${id}`, { headers }).catch(() => undefined) - } - for (const id of createdContactIds.splice(0)) { - await request.delete(`${BASE_URL}/api/contact/${id}`, { headers }).catch(() => undefined) - } -}) - -test.describe('Frontend Central de Conteúdos (S27)', () => { - test.beforeAll(async ({ request }) => { - await seedTestUser() - const headers = await adminHeaders(request, BASE_URL) - await unpublishEveryPiece(request, headers) - }) - - test('lists the published pieces, keeps the media lazy and shares the vote', async ({ - page, - request, - }) => { - const headers = await adminHeaders(request, BASE_URL) - const videoTitle = `Fim da escala ${uniqueMarker()}` - const photoTitle = `Solla no SUS ${uniqueMarker()}` - const audioTitle = `Áudio da Central ${uniqueMarker()}` - const video = await createPiece(request, headers, { title: videoTitle, type: 'video' }) - const photo = await createPiece(request, headers, { title: photoTitle, type: 'foto' }) - const audio = await createPiece(request, headers, { title: audioTitle, type: 'audio' }) - - const media = mediaRequests(page) - const frames = frameRequests(page) - await stubContentPieceFrames(page) - - const response = await page.goto('/conteudos') - expect(response?.status()).toBe(200) - await waitForSettledPage(page) - await expect( - page.getByRole('heading', { name: 'Uma mensagem sua pode conquistar mais um voto.' }), - ).toBeVisible() - await expect(page.getByText('Central de Conteúdos').first()).toBeVisible() - await expect(page.locator('article[data-content-piece]')).toHaveCount(3) - // S38 — the six models are items of the same board: the card item and the - // pieces share one grid (no section of their own, no invite tile). - await expect(page.locator('[data-card-model]')).toHaveCount(6) - // S45 — the video shares the vertical board with the models (the photo is a - // compact row below and the audio keeps the wide row): the models are items - // of the board, not a section. - const board = page.locator('[data-card-model]').first().locator('xpath=..') - await expect(board.locator('article[data-content-piece]')).toHaveCount(1) - await expect(board.locator('[data-card-model]')).toHaveCount(6) - await expect(page.locator('[data-card-model="time-de-voce"]')).toHaveAttribute( - 'href', - '/cards?model=time-de-voce', - ) - await expect( - page.locator('[data-card-model="minha-colinha"]').getByText('santinho'), - ).toBeVisible() - - // The video mounts no media element and fetches nothing before the play - // (the photo preview is the only media the catalogue loads on its own). - await expect(page.locator('video')).toHaveCount(0) - await expect(page.locator('audio')).toHaveCount(0) - expect(media.filter((url) => url.includes(video.slug))).toHaveLength(0) - - // C226 — the still is a lightweight image asked for BEFORE the play, and it - // is a different door from the file: a photo piece never asks for one. - const still = page.locator('article[data-content-piece] img[src$="/frame"]') - await expect(still).toHaveCount(1) - await expect - .poll(() => frames.filter((url) => url.includes(video.slug)).length) - .toBeGreaterThan(0) - expect(frames.filter((url) => url.includes(photo.slug))).toHaveLength(0) - // The still really rendered (a card never shows a broken image) and the - // play button is still the point of highest contrast over it. - await expect - .poll(() => still.evaluate((image: HTMLImageElement) => image.naturalWidth)) - .toBeGreaterThan(0) - await expect(page.getByRole('button', { name: `Reproduzir ${videoTitle}` })).toBeVisible() - - // S45 — every piece appears in the form of its type: the Reel keeps the - // 9:16 slot (the still whole, `contain`), the photo keeps the 96×120 (4:5) - // compact thumb, the audio keeps the wide row and the model art keeps 3:4. - const mediaSlot = (slug: string) => - page.locator(`article[data-content-piece="${slug}"] [data-content-piece-media]`) - const videoSlot = await mediaSlot(video.slug).boundingBox() - expect(videoSlot!.height / videoSlot!.width).toBeCloseTo(16 / 9, 2) - await expect(still).toHaveCSS('object-fit', 'contain') - const photoSlot = await mediaSlot(photo.slug).boundingBox() - expect(photoSlot!.width).toBeCloseTo(96, 0) - expect(photoSlot!.height).toBeCloseTo(120, 0) - await expect(mediaSlot(photo.slug).locator('img')).toHaveCSS('object-fit', 'contain') - const audioSlot = await mediaSlot(audio.slug).boundingBox() - expect(audioSlot!.width).toBeCloseTo(190, 0) - expect(audioSlot!.height).toBeGreaterThanOrEqual(156) - const modelSlot = await page - .locator('[data-card-model="time-de-voce"] > span') - .first() - .boundingBox() - expect(modelSlot!.height / modelSlot!.width).toBeCloseTo(4 / 3, 2) - - // Play mounts only the tapped piece and fetches only its media. The still - // leaves the slot: the player takes the same 9:16 box (cena 06). - await page.getByRole('button', { name: `Reproduzir ${videoTitle}` }).click() - await expect(page.locator('video')).toHaveCount(1) - await expect.poll(() => media.filter((url) => url.includes(video.slug)).length).toBe(1) - await expect(page.locator('article[data-content-piece] img[src$="/frame"]')).toHaveCount(0) - - // The download carries the legible name built from the slug. - await expect(page.getByRole('link', { name: `Baixar ${videoTitle}` })).toHaveAttribute( - 'download', - `jorge-solla-1313-${video.slug}.mp4`, - ) - - // The share sheet: editable vote message, wa.me without recipient. - await page.getByRole('button', { name: `Compartilhar ${videoTitle}` }).click() - const sheet = page.getByRole('dialog', { name: 'Compartilhar peça' }) - await expect(sheet).toBeVisible() - const message = sheet.getByLabel('Mensagem para compartilhar') - await expect(message).toHaveValue(new RegExp(videoTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) - const whatsApp = sheet.getByRole('link', { name: 'Abrir no WhatsApp' }) - const href = await whatsApp.getAttribute('href') - expect(href).toContain('https://wa.me/?text=') - expect(new URL(href!).pathname).toBe('/') - expect(decodeURIComponent(href!)).toContain(`/conteudos/${video.slug}`) - await message.fill('Mensagem revisada pela pessoa.') - await expect(whatsApp).toHaveAttribute('href', /Mensagem\+revisada/) - - // "A mídia" falls back to the download of the same file where the device - // cannot share files (headless Linux has no Web Share API). - const [downloadEvent] = await Promise.all([ - page.waitForEvent('download'), - sheet.getByRole('button', { name: 'A mídia' }).click(), - ]) - expect(downloadEvent.suggestedFilename()).toBe(`jorge-solla-1313-${video.slug}.mp4`) - - await sheet.getByRole('button', { name: 'Fechar', exact: true }).click() - await expect(sheet).toHaveCount(0) - - // S45 — the vertical board never overflows the phone (cena 02). - await page.setViewportSize({ width: 390, height: 844 }) - await page.goto('/conteudos') - await waitForSettledPage(page) - await expect(page.locator('article[data-content-piece]')).toHaveCount(3) - const overflow = await page.evaluate(() => { - const container = document.querySelector<HTMLElement>('[data-theme="campaign-site"]') - return container ? container.scrollWidth - container.clientWidth : 0 - }) - expect(overflow).toBeLessThanOrEqual(1) - }) - - test('filters by facet and term and shows the honest no-results state', async ({ - page, - request, - }) => { - const headers = await adminHeaders(request, BASE_URL) - await unpublishEveryPiece(request, headers) - const marker = uniqueMarker() - const videoTitle = `Debate na rádio ${marker}` - const photoTitle = `Card do giro ${marker}` - const video = await createPiece(request, headers, { title: videoTitle, type: 'video' }) - const photo = await createPiece(request, headers, { title: photoTitle, type: 'foto' }) - - await page.goto(`/conteudos?tipo=video`) - await waitForSettledPage(page) - await expect(page.locator('article[data-content-piece]')).toHaveCount(1) - await expect(page.getByRole('link', { name: videoTitle, exact: true })).toBeVisible() - // A filtered board never shows the card models that the facet excludes. - await expect(page.locator('[data-card-model]')).toHaveCount(0) - - await page.goto(`/conteudos?q=${marker}`) - await expect(page.locator('article[data-content-piece]')).toHaveCount(2) - - await page.goto(`/conteudos?tipo=video&q=${marker}`) - await waitForSettledPage(page) - await expect(page.locator('article[data-content-piece]')).toHaveCount(1) - await expect(page.getByRole('link', { name: /Remover filtro Tipo: Vídeo/ })).toBeVisible() - - await page.goto('/conteudos?q=zzz-nao-existe') - await waitForSettledPage(page) - await expect(page.getByText('Busca: “zzz-nao-existe”')).toBeVisible() - const clear = page.getByRole('link', { name: 'Limpar filtros' }) - await expect(clear).toHaveAttribute('href', '/conteudos') - await clear.click() - await expect(page).toHaveURL(/\/conteudos$/) - await expect(page.locator('article[data-content-piece]')).toHaveCount(2) - - // The video card link opens the piece page with the back link. - await page.getByRole('link', { name: videoTitle, exact: true }).click() - await expect(page).toHaveURL(new RegExp(`/conteudos/${video.slug}$`)) - await expect(page.getByRole('heading', { name: videoTitle })).toBeVisible() - await expect(page.getByRole('link', { name: 'Voltar à Central' })).toBeVisible() - - // S45 — the piece page keeps the card's promise: the 9:16 stage in the - // 360px media column (cena 05), never wider than the text. - const stage = await page.locator('[data-content-piece-media]').boundingBox() - expect(stage!.width).toBeCloseTo(360, 0) - expect(stage!.height / stage!.width).toBeCloseTo(16 / 9, 2) - - // The photo keeps the 4:5 stage on its own page. - await page.goto(`/conteudos/${photo.slug}`) - await expect(page.getByRole('heading', { name: photoTitle })).toBeVisible() - const photoStage = await page.locator('[data-content-piece-media]').boundingBox() - expect(photoStage!.width).toBeCloseTo(360, 0) - expect(photoStage!.height / photoStage!.width).toBeCloseTo(5 / 4, 2) - }) - - test('lists the card models by nickname, filters by Tipo Card and routes to the studio (S38)', async ({ - page, - request, - }) => { - const headers = await adminHeaders(request, BASE_URL) - await unpublishEveryPiece(request, headers) - await createPiece(request, headers, { - title: `Peça do catálogo ${uniqueMarker()}`, - type: 'video', - }) - - // The nickname search finds the models that answer to it (no JS: querystring). - await page.goto('/conteudos?q=santinho') - await waitForSettledPage(page) - await expect(page.locator('[data-card-model]')).toHaveCount(2) - await expect(page.locator('[data-card-model="time-de-voce"]')).toBeVisible() - await expect(page.locator('[data-card-model="minha-colinha"]')).toBeVisible() - - await page.goto('/conteudos?q=foto+de+perfil') - await waitForSettledPage(page) - await expect(page.locator('[data-card-model]')).toHaveCount(2) - await expect(page.locator('[data-card-model="perfil-quadrado"]')).toBeVisible() - await expect(page.locator('[data-card-model="perfil-retangular"]')).toBeVisible() - - // Tipo Card brings the six models and the published piece leaves the board. - await page.goto('/conteudos?tipo=card') - await waitForSettledPage(page) - await expect(page.locator('[data-card-model]')).toHaveCount(6) - await expect(page.locator('article[data-content-piece]')).toHaveCount(0) - - // A geographic facet removes the models: they are not territorial. - await page.goto('/conteudos?cidade=salvador') - await waitForSettledPage(page) - await expect(page.locator('[data-card-model]')).toHaveCount(0) - - // The item routes to the studio with the model chosen and counts the opening - // through the anonymous beacon (never a piece event). - await page.goto('/conteudos?tipo=card') - await waitForSettledPage(page) - const openingBeacon = page.waitForRequest((candidate) => { - if (!candidate.url().includes('/api/content-events')) return false - try { - const body = candidate.postDataJSON() as { type?: string; cardModelId?: string } | null - return body?.type === 'abertura' && body?.cardModelId === 'minha-colinha' - } catch { - return false - } - }) - await page.locator('[data-card-model="minha-colinha"]').click() - await expect(page).toHaveURL(/\/cards\?model=minha-colinha$/) - await openingBeacon - await expect(page.getByRole('dialog')).toBeVisible() - }) - - test('offers the theme mode and degrades honestly without the provider key', async ({ - page, - request, - }) => { - const headers = await adminHeaders(request, BASE_URL) - await unpublishEveryPiece(request, headers) - const marker = uniqueMarker() - const title = `Saúde do trabalhador ${marker}` - await createPiece(request, headers, { title, type: 'video' }) - - // The e2e build blanks DEEPSEEK_API_KEY (same as the C192 spec), so the - // theme mode always degrades: the exact results stay and the notice explains. - const response = await page.goto(`/conteudos?q=${marker}&mode=tema`) - expect(response?.status()).toBe(200) - await waitForSettledPage(page) - await expect(page.getByRole('heading', { name: `Resultados para “${marker}”` })).toBeVisible() - await expect(page.getByRole('radio', { name: 'Termo exato' })).toBeChecked() - await expect(page.getByRole('radio', { name: 'Por tema' })).toBeDisabled() - await expect(page.getByText('Busca por tema indisponível agora.')).toBeVisible() - await expect(page.getByRole('link', { name: title, exact: true })).toBeVisible() - await expect(page.getByText('Por que apareceu')).toHaveCount(0) - - // A mode without a query has nothing to expand: no notice, exact is active. - await page.goto('/conteudos?mode=tema') - await waitForSettledPage(page) - await expect(page.getByText('Busca por tema indisponível agora.')).toHaveCount(0) - await expect(page.getByRole('radio', { name: 'Termo exato' })).toBeChecked() - await expect(page.getByRole('radio', { name: 'Por tema' })).toBeEnabled() - - // Changing the mode submits the GET form with the query in force. - await page.goto(`/conteudos?q=${marker}`) - await waitForSettledPage(page) - await page.getByRole('radio', { name: 'Por tema' }).check() - await expect(page).toHaveURL(new RegExp(`q=${marker}&mode=tema$`)) - // The URL commits before the new document renders: wait for the page's own - // content and for the streamed copy to be collected before the strict locator. - await expect(page.getByRole('heading', { name: `Resultados para “${marker}”` })).toHaveCount(1) - await waitForSettledPage(page) - await expect(page.getByText('Busca por tema indisponível agora.')).toBeVisible() - - // Enter on the field searches without JS (the canonical block has no button). - await page.goto('/conteudos') - await waitForSettledPage(page) - const field = page.getByRole('searchbox', { name: 'Buscar peças' }) - await field.fill(marker) - await field.press('Enter') - await expect(page).toHaveURL(new RegExp(`q=${marker}&mode=exato$`)) - await expect(page.getByRole('link', { name: title, exact: true })).toBeVisible() - }) - - test('opens the piece page with OG preview and the same actions', async ({ page, request }) => { - const headers = await adminHeaders(request, BASE_URL) - await unpublishEveryPiece(request, headers) - const photoTitle = `Solla em defesa do SUS ${uniqueMarker()}` - const photo = await createPiece(request, headers, { title: photoTitle, type: 'foto' }) - - const response = await page.goto(`/conteudos/${photo.slug}`) - expect(response?.status()).toBe(200) - await expect(page.getByRole('heading', { name: photoTitle })).toBeVisible() - await expect(page.getByRole('link', { name: `Baixar ${photoTitle}` })).toHaveAttribute( - 'download', - `jorge-solla-1313-${photo.slug}.png`, - ) - await expect(page.locator('meta[property="og:title"]').first()).toHaveAttribute( - 'content', - new RegExp(photoTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), - ) - await expect(page.locator('meta[property="og:image"]').first()).toHaveAttribute( - 'content', - new RegExp(`/conteudos/${photo.slug}/midia$`), - ) - - await page.getByRole('button', { name: `Compartilhar ${photoTitle}` }).click() - const sheet = page.getByRole('dialog', { name: 'Compartilhar peça' }) - await expect(sheet.getByLabel('Mensagem para compartilhar')).toHaveValue( - /Fiz\/achei esse material do Solla 1313/, - ) - }) - - test('shares a link piece by the platform URL with no download action', async ({ - page, - request, - }) => { - const headers = await adminHeaders(request, BASE_URL) - await unpublishEveryPiece(request, headers) - const linkTitle = `Post no Instagram ${uniqueMarker()}` - const shortcode = uniqueMarker().toUpperCase() - const link = await createPiece(request, headers, { - title: linkTitle, - type: 'video', - withMedia: false, - origin: 'instagram', - sourceUrl: `https://www.instagram.com/reel/${shortcode}/`, - }) - - await page.goto('/conteudos') - await waitForSettledPage(page) - await expect(page.locator('article[data-content-piece]')).toHaveCount(1) - await expect(page.getByRole('link', { name: `Baixar ${linkTitle}` })).toHaveCount(0) - - await page.getByRole('button', { name: `Compartilhar ${linkTitle}` }).click() - const sheet = page.getByRole('dialog', { name: 'Compartilhar peça' }) - await expect(sheet.getByRole('button', { name: 'A mídia' })).toHaveCount(0) - const href = await sheet.getByRole('link', { name: 'Abrir no WhatsApp' }).getAttribute('href') - expect(decodeURIComponent(href!)).toContain(`https://www.instagram.com/reel/${shortcode}/`) - - await page.goto(`/conteudos/${link.slug}`) - await expect(page.getByText('A mídia permanece na plataforma original.')).toBeVisible() - await expect( - page.getByText('O compartilhamento usa o link da publicação no Instagram.'), - ).toBeVisible() - }) - - test('counts the anonymous circulation of the piece', async ({ page, request, context }) => { - const headers = await adminHeaders(request, BASE_URL) - await unpublishEveryPiece(request, headers) - const title = `Circulação anônima ${uniqueMarker()}` - const piece = await createPiece(request, headers, { title, type: 'video' }) - - const eventCount = async (type: string): Promise<number> => { - const response = await request.get( - `${BASE_URL}/api/contentEvent?limit=0&depth=0&where[and][0][subjectId][equals]=${piece.id}&where[and][1][type][equals]=${type}`, - { headers }, - ) - expect(response.ok(), await response.text()).toBeTruthy() - return ((await response.json()) as { totalDocs: number }).totalDocs - } - - const beaconFor = (type: string) => - page.waitForRequest((candidate) => { - if (!candidate.url().includes('/api/content-events')) return false - try { - return (candidate.postDataJSON() as { type?: string } | null)?.type === type - } catch { - return false - } - }) - - // Abertura: the piece page mount beacons once, carrying the public slug. - const openBeacon = beaconFor('abertura') - await page.goto(`/conteudos/${piece.slug}`) - expect((await openBeacon).postDataJSON()).toEqual({ type: 'abertura', pieceSlug: piece.slug }) - await expect.poll(() => eventCount('abertura')).toBe(1) - - // Download: the SERVER counts the full file request (the click alone would - // count a cancelled download). - const [download] = await Promise.all([ - page.waitForEvent('download'), - page.getByRole('link', { name: `Baixar ${title}` }).click(), - ]) - expect(download.suggestedFilename()).toBe(`jorge-solla-1313-${piece.slug}.mp4`) - await expect.poll(() => eventCount('download')).toBe(1) - - // WhatsApp and link stay apart, each counted on its own control. - await page.getByRole('button', { name: `Compartilhar ${title}` }).click() - const sheet = page.getByRole('dialog', { name: 'Compartilhar peça' }) - await context.route('https://wa.me/**', (route) => - route.fulfill({ body: '', contentType: 'text/html' }), - ) - const whatsAppBeacon = beaconFor('compartilhar_whatsapp') - await sheet.getByRole('link', { name: 'Abrir no WhatsApp' }).click() - await whatsAppBeacon - - await context.grantPermissions(['clipboard-read', 'clipboard-write']) - const linkBeacon = beaconFor('compartilhar_link') - await sheet.getByRole('button', { name: 'Copiar link' }).click() - await linkBeacon - - await expect.poll(() => eventCount('compartilhar_whatsapp')).toBe(1) - await expect.poll(() => eventCount('compartilhar_link')).toBe(1) - - // The stored event is the subject only: no IP, cookie, user-agent or any - // other visitor identifier — the privacy contract, pinned row by row. - // (`variant` is the S32 state-deputy slug sub-key: a public catalog slug, - // never a visitor identifier.) - const listed = await request.get( - `${BASE_URL}/api/contentEvent?limit=1&depth=0&where[subjectId][equals]=${piece.id}`, - { headers }, - ) - const doc = ((await listed.json()) as { docs: Record<string, unknown>[] }).docs[0]! - expect(Object.keys(doc).sort()).toEqual([ - 'createdAt', - 'id', - 'subjectId', - 'subjectType', - 'type', - 'updatedAt', - 'variant', - ]) - }) - - test('flips the kill switch, the discovery and the piece page without a deploy', async ({ - page, - request, - }) => { - const headers = await adminHeaders(request, BASE_URL) - await unpublishEveryPiece(request, headers) - const title = `Peça do kill switch ${uniqueMarker()}` - const piece = await createPiece(request, headers, { title, type: 'video' }) - - await page.goto('/conteudos') - await waitForSettledPage(page) - await expect(page.getByRole('link', { name: 'Conteúdos' })).toBeVisible() - await expect(page.locator('article[data-content-piece]')).toHaveCount(1) - await expect(page.locator('[data-card-model]')).toHaveCount(6) - - await setPieceStatus(request, headers, piece.id, 'rascunho') - - await page.goto('/conteudos') - await waitForSettledPage(page) - await expect( - page.getByRole('heading', { name: 'As primeiras peças estão a caminho' }), - ).toBeVisible() - await expect(page.locator('article[data-content-piece]')).toHaveCount(0) - // S38 — an empty Central is the honest empty state, never a board of cards. - await expect(page.locator('[data-card-model]')).toHaveCount(0) - await expect(page.getByRole('link', { name: 'Conteúdos' })).toHaveCount(0) - await expect - .poll(async () => - (await (await request.get(`${BASE_URL}/conteudos`)).text()).includes('noindex, nofollow'), - ) - .toBe(true) - - // The raw HTTP status is the 404 contract and the honest copy is in the - // body (the browser is not navigated to the 404 URL: the failure guard - // fails a test on the expected resource error). - const unpublished = await request.get(`/conteudos/${piece.slug}`) - expect(unpublished.status()).toBe(404) - expect(await unpublished.text()).toContain('Esta peça não está disponível') - - // C226 — the still door closes with the file door: an unpublished piece - // stops exposing its derived frame exactly the same way. - expect((await request.get(`/conteudos/${piece.slug}/midia`)).status()).toBe(404) - expect((await request.get(`/conteudos/${piece.slug}/frame`)).status()).toBe(404) - - await setPieceStatus(request, headers, piece.id, 'publicado') - await page.goto('/conteudos') - await waitForSettledPage(page) - await expect(page.locator('article[data-content-piece]')).toHaveCount(1) - await expect(page.getByRole('link', { name: 'Conteúdos' })).toBeVisible() - }) - - test('shows the Central sample on the home and hides it with the kill switch', async ({ - page, - request, - }) => { - const headers = await adminHeaders(request, BASE_URL) - await unpublishEveryPiece(request, headers) - const photoTitle = `Peça da home ${uniqueMarker()}` - const videoTitle = `Vídeo da home ${uniqueMarker()}` - await createPiece(request, headers, { title: photoTitle, type: 'foto' }) - await createPiece(request, headers, { title: videoTitle, type: 'video' }) - - await waitForHomeSection(request, BASE_URL, 'content-pieces', 'present') - - await page.setViewportSize({ width: 390, height: 844 }) - await page.goto(`/?e2e=${Date.now()}`) - const section = page.locator('[data-home-section="content-pieces"]') - await expect(section).toBeVisible() - // S39 — the sample sits between the cards and the newsletter (the home spec - // keeps this pin agnostic because the section is conditional). - const order = await page - .locator('[data-home-section]') - .evaluateAll((sections) => sections.map((s) => s.getAttribute('data-home-section'))) - expect(order.indexOf('cards')).toBeLessThan(order.indexOf('content-pieces')) - expect(order.indexOf('content-pieces')).toBeLessThan(order.indexOf('newsletter')) - await expect(section.getByRole('heading', { name: 'Peça voto pra Solla 1313' })).toBeVisible() - await expect(section.getByRole('link', { name: /Ver todas as peças/ })).toHaveAttribute( - 'href', - '/conteudos', - ) - await expect(section.locator('article[data-content-piece]')).toHaveCount(2) - // Nothing plays on its own: the video mounts no media element before the tap. - await expect(section.locator('video, audio')).toHaveCount(0) - - // S42 — the honest tags open the catalogue already filtered and the others - // stay static; nothing filters the sample locally. - const photoCard = section.locator(`article[data-content-piece]`, { hasText: photoTitle }) - const videoCard = section.locator(`article[data-content-piece]`, { hasText: videoTitle }) - await expect(photoCard.getByRole('link', { name: 'Foto', exact: true })).toHaveAttribute( - 'href', - '/conteudos?tipo=foto', - ) - await expect(videoCard.getByRole('link', { name: 'Vídeo', exact: true })).toHaveAttribute( - 'href', - '/conteudos?tipo=video', - ) - await expect(section.getByText('Mais recente', { exact: true })).toHaveCount(2) - await expect(section.getByRole('link', { name: 'Mais recente' })).toHaveCount(0) - - // S45 — the 124px phone thumb keeps the form of the type: photo 4:5, Reel 9:16. - const photoBox = await photoCard.locator('[data-content-piece-media]').boundingBox() - expect(photoBox!.width).toBeCloseTo(124, 0) - expect(photoBox!.height / photoBox!.width).toBeCloseTo(5 / 4, 2) - const videoBox = await videoCard.locator('[data-content-piece-media]').boundingBox() - expect(videoBox!.height / videoBox!.width).toBeCloseTo(16 / 9, 2) - - // S42 — the card shares through the same S27 sheet, without leaving the home. - await section.getByRole('button', { name: `Compartilhar ${photoTitle}` }).click() - const homeSheet = page.getByRole('dialog', { name: 'Compartilhar peça' }) - await expect(homeSheet).toBeVisible() - await expect(homeSheet.getByLabel('Mensagem para compartilhar')).toHaveValue( - new RegExp(photoTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), - ) - await page.keyboard.press('Escape') - await expect(homeSheet).toHaveCount(0) - await expect(section.getByRole('heading', { name: 'Peça voto pra Solla 1313' })).toBeVisible() - - // S42 — the explore row only carries the facets the sample has (no - // município in these pieces) and its options point at the filtered URL. - const tipoSummary = section.locator('summary').filter({ hasText: 'Tipo' }) - await expect(section.locator('summary')).toHaveCount(1) - await tipoSummary.click() - await expect( - section.locator('details[open]').getByRole('link', { name: 'Vídeo' }), - ).toHaveAttribute('href', '/conteudos?tipo=video') - - // Cena 02 — the sample (with the open menu) never overflows the phone. - const overflow = await page.evaluate(() => { - const container = document.querySelector<HTMLElement>('[data-theme="campaign-site"]') - return container ? container.scrollWidth - container.clientWidth : 0 - }) - expect(overflow).toBeLessThanOrEqual(1) - - await unpublishEveryPiece(request, headers) - await waitForHomeSection(request, BASE_URL, 'content-pieces', 'absent') - await page.goto(`/?e2e=${Date.now()}`) - await expect(page.locator('[data-home-section="content-pieces"]')).toHaveCount(0) - }) - - test('samples the visitor município on the home when the permission is granted', async ({ - page, - request, - context, - }) => { - const headers = await adminHeaders(request, BASE_URL) - await unpublishEveryPiece(request, headers) - - const entry = getMunicipalityCatalogEntry('feira-de-santana') - if (!entry || entry.kind !== 'municipio') throw new Error('Catálogo sem Feira de Santana.') - const geometry = await loadMunicipalityGeometryModule() - const feature = geometry.features.find( - (candidate) => candidate.properties.codarea === entry.ibgeCode, - ) - if (!feature) throw new Error('Malha municipal sem Feira de Santana.') - const point = interiorPointOf(feature) - - const listed = await request.get( - `${BASE_URL}/api/municipality?where[slug][equals]=${entry.slug}&limit=1&depth=0`, - { headers }, - ) - expect(listed.ok(), await listed.text()).toBeTruthy() - const { docs } = (await listed.json()) as { docs: { id: number }[] } - const municipality = docs[0] - if (!municipality) throw new Error('Município não semeado no banco de teste.') - - const title = `Peça de Feira de Santana ${uniqueMarker()}` - await createPiece(request, headers, { title, type: 'foto', municipality: municipality.id }) - await waitForHomeSection(request, BASE_URL, 'content-pieces', 'present') - - await context.grantPermissions(['geolocation']) - await context.setGeolocation({ latitude: point.lat, longitude: point.lng }) - - await page.goto(`/?e2e=${Date.now()}`) - const section = page.locator('[data-home-section="content-pieces"]') - await expect(section.locator('[data-sample-tag="municipality"]')).toBeVisible() - // Exact: the sr-only live region announces "Peças do seu município". - await expect(section.getByText('Do seu município', { exact: true })).toBeVisible() - await expect(section.getByRole('link', { name: title })).toBeVisible() - - // S42 — the territory tags are shortcuts to the catalogue on the same facet. - const card = section.locator('article[data-content-piece]', { hasText: title }) - await expect(card.getByRole('link', { name: 'Do seu município', exact: true })).toHaveAttribute( - 'href', - '/conteudos?cidade=feira-de-santana', - ) - await expect(card.getByRole('link', { name: 'Foto', exact: true })).toHaveAttribute( - 'href', - '/conteudos?tipo=foto', - ) - await section.getByRole('link', { name: /Para seu município/ }).click() - await page.waitForURL(/\/conteudos\?cidade=feira-de-santana$/) - await waitForSettledPage(page) - // The catalogue opens already filtered by the same canonical URL. - await expect(page.getByRole('link', { name: /Remover filtro Cidade/ })).toBeVisible() - await expect(page.locator('article[data-content-piece]')).toHaveCount(1) - await expect(page.getByRole('link', { name: title, exact: true })).toBeVisible() - }) - test('filters by who appears in the piece and removes a name when unpublished (S37)', async ({ - page, - request, - }) => { - const headers = await adminHeaders(request, BASE_URL) - await unpublishEveryPiece(request, headers) - const marker = uniqueMarker() - const leaderName = `Maria Silva ${marker}` - const personSlug = slugify(leaderName) - const title = `Giro com a liderança ${marker}` - const { leadershipId } = await createLeadershipWithContact(request, headers, leaderName) - const piece = await createPiece(request, headers, { - title, - type: 'video', - leaders: [leadershipId], - publicFigures: ['dra elaine'], - }) - - await page.goto('/conteudos') - await waitForSettledPage(page) - await expect(page.locator('article[data-content-piece]')).toHaveCount(1) - // The card's metadata line leads with who appears in the piece. - await expect( - page.locator('article[data-content-piece]').getByText(leaderName, { exact: true }), - ).toBeVisible() - - // Central polimento — the empty facet menus are one exclusive accordion - // with a real centered chevron (artefato - // `central-conteudos-hero-polimento-ui-design.html`): the icon sits on the - // chip's vertical center (never the text glyph `⌄` at the baseline) and the - // next menu opened closes the previous one. - const tipoSummary = page.locator('summary').filter({ hasText: 'Tipo' }).first() - await tipoSummary.click() - await expect(page.locator('details[open]')).toHaveCount(1) - await expect(page.locator('details[open] summary')).toContainText('Tipo') - const chevron = tipoSummary.locator('svg') - await expect(chevron).toBeVisible() - const summaryBox = await tipoSummary.boundingBox() - const chevronBox = await chevron.boundingBox() - if (!summaryBox || !chevronBox) throw new Error('Sem as caixas do chip e do chevron.') - const summaryCenter = summaryBox.y + summaryBox.height / 2 - const chevronCenter = chevronBox.y + chevronBox.height / 2 - expect(Math.abs(chevronCenter - summaryCenter)).toBeLessThanOrEqual(1) - - // The one "Lideranças" facet unions the leader name and the curated figure; - // opening it closes the previous menu (same native group). - await page.locator('summary').filter({ hasText: 'Lideranças' }).first().click() - await expect(page.locator('details[open]')).toHaveCount(1) - await expect(page.locator('details[open] summary')).toContainText('Lideranças') - await expect( - page.getByText('Em peças publicadas').filter({ visible: true }).first(), - ).toBeVisible() - await expect(page.getByRole('link', { name: 'Dra. Elaine', exact: true })).toBeVisible() - await page.getByRole('link', { name: leaderName, exact: true }).click() - await expect(page).toHaveURL(new RegExp(`lideranca=${personSlug}$`)) - await expect(page.locator('article[data-content-piece]')).toHaveCount(1) - await expect( - page - .getByRole('link', { name: /Remover filtro Lideranças/ }) - .filter({ visible: true }) - .first(), - ).toBeVisible() - - // Searching the name finds the piece (the haystack carries it). - await page.goto(`/conteudos?q=${encodeURIComponent(leaderName)}`) - await waitForSettledPage(page) - await expect(page.locator('article[data-content-piece]')).toHaveCount(1) - - // Unpublishing the last piece with the name removes it from the facet. - await setPieceStatus(request, headers, piece.id, 'rascunho') - await page.goto('/conteudos') - await waitForSettledPage(page) - await expect(page.locator('summary').filter({ hasText: 'Lideranças' })).toHaveCount(0) - }) - - /** - * S36 regression (production report 2026-09-25): the negative lockup ships - * with a transparent frame and the header crops it with a fixed window. A - * window with a different aspect ratio than the ink clips the base of - * "SOLLA" (that was the shipped S36: a 188x50 window over a 187.4x67.6 ink, - * 18px lost at the bottom). The real asset is measured with sharp and the - * rendered ink box must fit inside the crop window at both breakpoints. - */ - test('keeps the whole brand ink inside the header crop, both breakpoints (S36)', async ({ - page, - }) => { - const assetPath = join(process.cwd(), 'public/campaign-kit/jorge-solla-negativo.png') - const canvas = await sharp(assetPath).metadata() - const { info: ink } = await sharp(assetPath).trim().toBuffer({ resolveWithObject: true }) - const assetInk = { - canvasWidth: canvas.width!, - left: -ink.trimOffsetLeft!, - top: -ink.trimOffsetTop!, - width: ink.width, - height: ink.height, - } - - const measureAt = async (viewport: { width: number; height: number }) => { - await page.setViewportSize(viewport) - await page.goto('/conteudos') - await waitForSettledPage(page) - const logo = page.locator('header img[alt="Jorge Solla"]') - await expect(logo).toBeVisible() - return logo.evaluate((node, box) => { - const image = node as HTMLImageElement - const crop = image.parentElement!.getBoundingClientRect() - const rendered = image.getBoundingClientRect() - const scale = rendered.width / box.canvasWidth - return { - crop: { top: crop.top, right: crop.right, bottom: crop.bottom, left: crop.left }, - ink: { - top: rendered.top + box.top * scale, - right: rendered.left + (box.left + box.width) * scale, - bottom: rendered.top + (box.top + box.height) * scale, - left: rendered.left + box.left * scale, - }, - } - }, assetInk) - } - - for (const viewport of [ - { width: 1280, height: 900 }, - { width: 390, height: 844 }, - ]) { - const { crop, ink: renderedInk } = await measureAt(viewport) - const at = `@${viewport.width}px` - expect(renderedInk.top, `ink top clipped ${at}`).toBeGreaterThanOrEqual(crop.top - 0.5) - expect(renderedInk.left, `ink left clipped ${at}`).toBeGreaterThanOrEqual(crop.left - 0.5) - expect(renderedInk.bottom, `ink bottom clipped ${at}`).toBeLessThanOrEqual(crop.bottom + 0.5) - expect(renderedInk.right, `ink right clipped ${at}`).toBeLessThanOrEqual(crop.right + 0.5) - } - }) -}) diff --git a/tests/e2e/frontendFotos.e2e.spec.ts b/tests/e2e/frontendFotos.e2e.spec.ts index b3d5f067..e539af24 100644 --- a/tests/e2e/frontendFotos.e2e.spec.ts +++ b/tests/e2e/frontendFotos.e2e.spec.ts @@ -334,6 +334,13 @@ test.describe('Frontend Álbum de Fotos (C233)', () => { expect(response?.status()).toBe(200) await waitForSettledPage(page) + await expect(page.getByRole('heading', { level: 1, name: 'Fotos' })).toBeVisible() + await expect(page.getByText('Memória do mandato')).toBeVisible() + await expect( + page.getByRole('banner').getByRole('link', { name: 'Início — Jorge Solla' }), + ).toBeVisible() + await expect(page.locator('header').getByText('1313')).toHaveCount(0) + const feiraCard = page.locator(`a[data-photo-id="${approvedFeira}"]`) const camacariCard = page.locator(`a[data-photo-id="${approvedCamacari}"]`) @@ -438,7 +445,7 @@ test.describe('Frontend Álbum de Fotos (C233)', () => { expect(download.status()).toBe(200) expect(download.headers()['content-disposition']).toContain('attachment') expect(download.headers()['content-disposition']).toContain( - `jorge-solla-1313-foto-${approvedFeira}.jpg`, + `jorge-solla-foto-${approvedFeira}.jpg`, ) await page.getByRole('link', { name: /Voltar ao álbum/ }).click() @@ -447,6 +454,23 @@ test.describe('Frontend Álbum de Fotos (C233)', () => { await expect(page.getByRole('dialog')).toHaveCount(0) }) + test('carries the approved photos into the home gallery', async ({ page }) => { + await page.goto('/') + await waitForSettledPage(page) + + const section = page.locator('[data-home-section="photos"]') + await expect(section).toBeVisible() + await expect(section.getByRole('heading', { name: 'O mandato em fotos' })).toBeVisible() + const photoLinks = section.locator('a[href*="?foto="]') + await expect(photoLinks).toHaveCount(2) + await expect(photoLinks.first()).toHaveAttribute('href', new RegExp(`^/fotos\\?foto=\\d+$`)) + await expect(section.getByText('Trabalho presente, memória viva.')).toBeVisible() + await expect(section.getByRole('link', { name: 'Ver o álbum' })).toHaveAttribute( + 'href', + '/fotos', + ) + }) + test('pulls a removed photo down immediately, without a deploy', async ({ page, request }) => { await page.goto('/fotos') await waitForSettledPage(page) diff --git a/tests/e2e/frontendJingles.e2e.spec.ts b/tests/e2e/frontendJingles.e2e.spec.ts deleted file mode 100644 index af233758..00000000 --- a/tests/e2e/frontendJingles.e2e.spec.ts +++ /dev/null @@ -1,391 +0,0 @@ -import { randomUUID } from 'node:crypto' -import { readFileSync } from 'node:fs' -import path from 'node:path' - -import type { APIRequestContext } from '@playwright/test' - -import { adminHeaders } from '../helpers/adminApi' -import { seedTestUser } from '../helpers/seedUser' -import { expect, test } from './fixtures/e2eTest' - -/** - * S21 — the public jingles page over real HTTP: the published cards come in - * order, opening the page downloads no audio (`preload="none"`), the player - * plays one at a time and the download carries the legible slug-based name. - * The kill switch (unpublish) flips the honest empty state, the header badge - * and the footer discovery link without a deploy. - * - * Jingles and media are seeded through the deployed REST API (admin session) so - * the server process runs the real cache hooks; a Local API call from the - * runner would throw on `revalidateTag` (same reason as the S19 spec). - * - * The tests are serial: the empty-state assertions need a global window with - * zero published jingles, and only this spec owns jingle rows. - * - * The home renders published content pieces; a concurrent curated spec can - * publish one whose still is not on disk yet, and `/conteudos/<slug>/frame` - * answers 404 until the self-heal lands (C226) — the same declaration as - * `frontend.e2e.spec.ts`. - */ -test.use({ expectedRequestFailurePaths: [/\/conteudos\/.+\/frame$/] }) - -const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000' - -// 1×1 opaque PNG — enough for the cover upload. -const TEST_PNG = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', - 'base64', -) -const TEST_MP3 = readFileSync(path.resolve(process.cwd(), 'tests/fixtures/jingle-tone.mp3')) - -type Headers = Record<string, string> - -const createdJingleIds: number[] = [] -const createdMediaIds: number[] = [] - -const uniqueSlug = () => `jingle-${randomUUID().slice(0, 8)}` - -const createMedia = async ( - request: APIRequestContext, - headers: Headers, - name: string, - mimetype: string, - buffer: Buffer, -): Promise<number> => { - const response = await request.post(`${BASE_URL}/api/media`, { - headers, - // Payload multipart carries the document fields in `_payload` (JSON). - multipart: { - _payload: JSON.stringify({ alt: name }), - file: { name, mimeType: mimetype, buffer }, - }, - }) - expect(response.ok(), await response.text()).toBeTruthy() - const mediaId = ((await response.json()) as { doc: { id: number } }).doc.id - createdMediaIds.push(mediaId) - return mediaId -} - -const createJingle = async ( - request: APIRequestContext, - headers: Headers, - data: { title: string; slug: string; published?: boolean }, -): Promise<number> => { - const coverImage = await createMedia( - request, - headers, - `capa-${data.slug}-${randomUUID().slice(0, 6)}.png`, - 'image/png', - TEST_PNG, - ) - const audio = await createMedia( - request, - headers, - `audio-${data.slug}-${randomUUID().slice(0, 6)}.mp3`, - 'audio/mpeg', - TEST_MP3, - ) - const response = await request.post(`${BASE_URL}/api/jingle`, { - headers, - data: { ...data, coverImage, audio, published: data.published ?? true }, - }) - expect(response.ok(), await response.text()).toBeTruthy() - const id = ((await response.json()) as { doc: { id: number } }).doc.id - createdJingleIds.push(id) - return id -} - -const setJinglePublished = async ( - request: APIRequestContext, - headers: Headers, - id: number, - published: boolean, -): Promise<void> => { - const response = await request.patch(`${BASE_URL}/api/jingle/${id}`, { - headers, - data: { published }, - }) - expect(response.ok(), await response.text()).toBeTruthy() -} - -/** Zero published globally so the empty-state assertions stay deterministic. */ -const unpublishEveryJingle = async (request: APIRequestContext, headers: Headers) => { - const listed = await request.get(`${BASE_URL}/api/jingle?limit=0&depth=0`, { headers }) - expect(listed.ok(), await listed.text()).toBeTruthy() - const { docs } = (await listed.json()) as { docs: { id: number; published?: boolean | null }[] } - for (const doc of docs) { - if (doc.published) await setJinglePublished(request, headers, doc.id, false) - } -} - -const cardStates = (page: import('@playwright/test').Page) => - page - .locator('article[data-jingle]') - .evaluateAll((cards) => cards.map((card) => card.dataset.state)) - -test.describe.configure({ mode: 'serial' }) - -test.afterAll(async ({ request }) => { - const headers = await adminHeaders(request, BASE_URL).catch(() => null) - if (!headers) return - for (const id of createdJingleIds.splice(0)) { - await request.delete(`${BASE_URL}/api/jingle/${id}`, { headers }).catch(() => undefined) - } - for (const id of createdMediaIds.splice(0)) { - await request.delete(`${BASE_URL}/api/media/${id}`, { headers }).catch(() => undefined) - } -}) - -test.describe('Frontend jingles (S21)', () => { - test.beforeAll(async ({ request }) => { - await seedTestUser() - const headers = await adminHeaders(request, BASE_URL) - await unpublishEveryJingle(request, headers) - }) - - test('lists the published jingles, keeps the audio lazy and plays one at a time', async ({ - page, - request, - }) => { - const headers = await adminHeaders(request, BASE_URL) - const firstSlug = uniqueSlug() - const secondSlug = uniqueSlug() - await createJingle(request, headers, { title: 'Axé oficial', slug: firstSlug }) - await createJingle(request, headers, { title: 'Forró oficial', slug: secondSlug }) - - const audioRequests: string[] = [] - page.on('request', (req) => { - if (/\/api\/media\/file\/.*\.mp3/.test(req.url())) audioRequests.push(req.url()) - }) - - const response = await page.goto('/jingles') - expect(response?.status()).toBe(200) - await expect(page.getByRole('heading', { name: 'Jingles de Jorge Solla' })).toBeVisible() - await expect(page.getByText('Jingles oficiais')).toBeVisible() - await expect(page.locator('article[data-jingle]')).toHaveCount(2) - - // S27-FOLLOWUP-DRY — the OG preview uses the first cover through the - // single owner: absolute on the deployment origin, never a relative path. - await expect(page.locator('meta[property="og:image"]')).toHaveAttribute( - 'content', - /^https?:\/\/.+\/api\/media\/file\//, - ) - - // The card never fetches the audio before the play (gate note). - await expect(page.locator('audio[preload="none"]')).toHaveCount(2) - expect(audioRequests).toHaveLength(0) - - const download = page.getByRole('link', { name: 'Baixar Axé oficial em MP3' }) - await expect(download).toHaveAttribute('download', `jorge-solla-1313-${firstSlug}.mp3`) - await expect(page.getByText(`jorge-solla-1313-${firstSlug}.mp3`)).toBeVisible() - - // Play the first: its audio is fetched now, the second one stays lazy. - await page.getByRole('button', { name: 'Tocar jingle Axé oficial' }).click() - await expect(page.locator('audio').first()).toHaveJSProperty('paused', false) - await expect(page.getByText('Em reprodução')).toHaveCount(1) - expect(audioRequests).toHaveLength(1) - - // Starting the second pauses the first (and fetches only its own audio). - await page.getByRole('button', { name: 'Tocar jingle Forró oficial' }).click() - await expect(page.locator('audio').nth(1)).toHaveJSProperty('paused', false) - await expect(page.locator('audio').first()).toHaveJSProperty('paused', true) - expect(await cardStates(page)).toEqual(['stopped', 'playing']) - expect(audioRequests).toHaveLength(2) - - // The download keeps the legible name regardless of the stored filename. - const [downloadEvent] = await Promise.all([ - page.waitForEvent('download'), - page.getByRole('link', { name: 'Baixar Forró oficial em MP3' }).click(), - ]) - expect(downloadEvent.suggestedFilename()).toBe(`jorge-solla-1313-${secondSlug}.mp3`) - }) - - test('flips the kill switch and the discovery without a deploy', async ({ page, request }) => { - const headers = await adminHeaders(request, BASE_URL) - - // With zero published jingles the page is honest and discovery is gone. - await unpublishEveryJingle(request, headers) - await page.goto('/jingles') - await expect(page.getByText('Nenhum jingle publicado por enquanto')).toBeVisible() - await expect(page.locator('article[data-jingle]')).toHaveCount(0) - await expect(page.getByText('Jingles oficiais')).toHaveCount(0) - await expect(page.getByRole('link', { name: 'Jingles' })).toHaveCount(0) - await page.goto('/') - await expect(page.getByRole('link', { name: 'Jingles' })).toHaveCount(0) - // S25 — the home sound section keeps the own radio player and drops the empty grid. - const sound = page.locator('[data-home-section="sound"]') - await expect(sound.getByRole('heading', { name: 'Sintonize com a Rádio 1313' })).toBeVisible() - await expect(sound.locator('[data-radio]')).toBeVisible() - await expect(sound.locator('[data-radio] iframe')).toHaveCount(0) - await expect(sound.locator('[data-radio] audio[preload="none"]')).toHaveCount(1) - await expect(sound.locator('article[data-jingle]')).toHaveCount(0) - await expect(sound.getByRole('link', { name: 'Ver todos os jingles' })).toHaveCount(0) - expect((await request.get(`${BASE_URL}/jingles`)).status()).toBe(200) - await expect - .poll(async () => - (await (await request.get(`${BASE_URL}/jingles`)).text()).includes('noindex, nofollow'), - ) - .toBe(true) - - // Publishing one item turns the page, the badge and the link on. - const id = await createJingle(request, headers, { - title: 'Pagodão oficial', - slug: uniqueSlug(), - }) - await page.goto('/jingles') - await expect(page.getByRole('heading', { name: 'Pagodão oficial' })).toBeVisible() - await expect(page.locator('article[data-jingle]')).toHaveCount(1) - await expect(page.getByRole('link', { name: 'Jingles' })).toBeVisible() - await expect(page.getByText('Jingles oficiais')).toBeVisible() - await expect - .poll(async () => - (await (await request.get(`${BASE_URL}/jingles`)).text()).includes('index, follow'), - ) - .toBe(true) - - // Unpublishing it flips everything back without a deploy. - await setJinglePublished(request, headers, id, false) - await page.goto('/jingles') - await expect(page.getByText('Nenhum jingle publicado por enquanto')).toBeVisible() - await expect(page.locator('article[data-jingle]')).toHaveCount(0) - await expect(page.getByRole('link', { name: 'Jingles' })).toHaveCount(0) - await page.goto('/') - await expect(page.getByRole('link', { name: 'Jingles' })).toHaveCount(0) - - await setJinglePublished(request, headers, id, true) - await page.goto('/jingles') - await expect(page.getByRole('heading', { name: 'Pagodão oficial' })).toBeVisible() - await page.goto('/') - await expect(page.getByRole('link', { name: 'Jingles' })).toBeVisible() - }) - - test('shows the sound section on the home with the own radio and one audio at a time', async ({ - page, - request, - }) => { - const headers = await adminHeaders(request, BASE_URL) - await unpublishEveryJingle(request, headers) - for (const title of ['Axé na home', 'Forró na home', 'Pagodão na home']) { - await createJingle(request, headers, { title, slug: uniqueSlug() }) - } - - const zenoRequests: string[] = [] - const audioRequests: string[] = [] - page.on('request', (req) => { - if (req.url().includes('zeno.fm')) zenoRequests.push(req.url()) - if (/\/api\/media\/file\/.*\.mp3/.test(req.url())) audioRequests.push(req.url()) - }) - - await page.goto(`/?e2e=${Date.now()}`) - const sound = page.locator('[data-home-section="sound"]') - await expect(sound).toBeVisible() - await expect(sound.getByText('A trilha da nossa caminhada')).toBeVisible() - await expect(sound.locator('article[data-jingle]')).toHaveCount(3) - // Three of three: the handoff only exists when more are published. - await expect(sound.getByRole('link', { name: 'Ver todos os jingles' })).toHaveCount(0) - - // S25 — the own player mounts with `preload="none"`: neither the stream nor - // the card audios are requested before a gesture. - const radio = sound.locator('[data-radio]') - await expect(radio.locator('iframe')).toHaveCount(0) - const radioAudio = radio.locator('audio') - await expect(radioAudio).toHaveCount(1) - await expect(radioAudio).toHaveAttribute('preload', 'none') - await expect(radioAudio).toHaveAttribute('src', 'https://stream.zeno.fm/hys86kx6k16tv') - expect(zenoRequests).toHaveLength(0) - expect(audioRequests).toHaveLength(0) - - // Playing the radio is what requests the stream (the fixture stubs it). - await radio.getByRole('button', { name: 'Ouvir Rádio Jorge Solla 1313' }).click() - await expect.poll(() => zenoRequests.length).toBeGreaterThan(0) - await expect(radio).toHaveAttribute('data-state', 'playing') - await expect(radio.getByText('Em reprodução')).toBeVisible() - - // Starting a jingle pauses the radio (one audio at a time). - await sound.getByRole('button', { name: 'Tocar jingle Axé na home' }).click() - await expect(radio).toHaveAttribute('data-state', 'idle') - await expect(radio.getByText('Pronta para tocar')).toBeVisible() - await expect(sound.locator('article[data-jingle][data-state="playing"]')).toHaveCount(1) - - // And playing the radio pauses the jingle back. - await radio.getByRole('button', { name: 'Ouvir Rádio Jorge Solla 1313' }).click() - await expect(radio).toHaveAttribute('data-state', 'playing') - await expect(sound.locator('article[data-jingle][data-state="playing"]')).toHaveCount(0) - - // Mobile 390: the section never overflows horizontally. - await page.setViewportSize({ width: 390, height: 844 }) - const overflow = await page.evaluate(() => { - const scrollContainer = document.querySelector<HTMLElement>('[data-theme="campaign-site"]') - return scrollContainer ? scrollContainer.scrollWidth - scrollContainer.clientWidth : 0 - }) - expect(overflow).toBeLessThanOrEqual(1) - }) - - test('shows three of a larger collection and hands off to /jingles', async ({ - page, - request, - }) => { - const headers = await adminHeaders(request, BASE_URL) - await unpublishEveryJingle(request, headers) - for (let index = 0; index < 4; index += 1) { - await createJingle(request, headers, { title: `Jingle extra ${index}`, slug: uniqueSlug() }) - } - - await page.goto(`/?e2e=${Date.now()}`) - const sound = page.locator('[data-home-section="sound"]') - await expect(sound.locator('article[data-jingle]')).toHaveCount(3) - - const seeAll = sound.getByRole('link', { name: 'Ver todos os jingles' }) - await expect(seeAll).toBeVisible() - await expect(seeAll).toHaveAttribute('href', '/jingles') - }) - - // S26 — the artist credit never disappears: its own line under the base - // title, untruncated, stopped and playing, on /jingles and the home; the - // full title (credit included) keeps feeding the aria-labels. - test('shows the featured artist credit without truncating the title', async ({ - page, - request, - }) => { - const headers = await adminHeaders(request, BASE_URL) - await unpublishEveryJingle(request, headers) - const slug = uniqueSlug() - const fullTitle = 'Jorge Solla 1313 (feat. Felipe Forrozeiro)' - await createJingle(request, headers, { title: fullTitle, slug }) - - await page.goto('/jingles') - const card = page.locator('article[data-jingle]') - await expect(card).toHaveCount(1) - const heading = card.getByRole('heading', { name: 'Jorge Solla 1313', exact: true }) - await expect(heading).toBeVisible() - // Visibility alone does not catch CSS clipping: the heading must wrap - // (never `nowrap`) and its text must fit the box without hidden overflow. - const headingLayout = await heading.evaluate((el) => ({ - whiteSpace: getComputedStyle(el).whiteSpace, - overflows: el.scrollWidth > el.clientWidth + 1, - })) - expect(headingLayout.whiteSpace).not.toBe('nowrap') - expect(headingLayout.overflows).toBe(false) - await expect(card.getByText('feat. Felipe Forrozeiro')).toBeVisible() - await expect(page.getByRole('button', { name: `Tocar jingle ${fullTitle}` })).toBeVisible() - await expect(page.getByRole('link', { name: `Baixar ${fullTitle} em MP3` })).toHaveAttribute( - 'download', - `jorge-solla-1313-${slug}.mp3`, - ) - await expect(page.getByText(`jorge-solla-1313-${slug}.mp3`)).toBeVisible() - - // Playing keeps the credit on the card. - await page.getByRole('button', { name: `Tocar jingle ${fullTitle}` }).click() - await expect(card.getByText('Em reprodução')).toBeVisible() - await expect(card.getByText('feat. Felipe Forrozeiro')).toBeVisible() - - // The home reuses the same card and the same credit. - await page.goto(`/?e2e=${Date.now()}`) - const homeCard = page.locator('[data-home-section="sound"] article[data-jingle]') - await expect(homeCard).toHaveCount(1) - await expect( - homeCard.getByRole('heading', { name: 'Jorge Solla 1313', exact: true }), - ).toBeVisible() - await expect(homeCard.getByText('feat. Felipe Forrozeiro')).toBeVisible() - }) -}) diff --git a/tests/e2e/frontendShareLink.e2e.spec.ts b/tests/e2e/frontendShareLink.e2e.spec.ts index 71da5799..bc89e5b8 100644 --- a/tests/e2e/frontendShareLink.e2e.spec.ts +++ b/tests/e2e/frontendShareLink.e2e.spec.ts @@ -357,7 +357,7 @@ test.describe('Frontend share-link announcement (S29)', () => { const localStart = formatBahiaCalendarDateTime(startsAt) const localEnd = formatBahiaCalendarDateTime(endsAt) - expect(googleParams.get('text')).toBe('Plenária da saúde - Jorge Solla 1313') + expect(googleParams.get('text')).toBe('Plenária da saúde - Jorge Solla') expect(googleParams.get('dates')).toBe(`${localStart}/${localEnd}`) expect(googleParams.get('ctz')).toBe('America/Bahia') expect(googleParams.get('details')).toBe( @@ -373,7 +373,7 @@ test.describe('Frontend share-link announcement (S29)', () => { expect(body).toContain('TZID:America/Bahia') expect(body).toContain(`DTSTART;TZID=America/Bahia:${localStart}`) expect(body).toContain(`DTEND;TZID=America/Bahia:${localEnd}`) - expect(body).toContain('SUMMARY:Plenária da saúde - Jorge Solla 1313') + expect(body).toContain('SUMMARY:Plenária da saúde - Jorge Solla') expect(body).toContain( `DESCRIPTION:Encontro online da campanha.\\n\\nPágina do evento: ${canonicalUrl}`, ) @@ -570,12 +570,12 @@ test.describe('Frontend home Plenária section (S44)', () => { const section = page.locator('[data-home-section="plenaria"]') await expect(section).toBeVisible() - // S44 — immediately after the hero, before the proof strip. + // S44 — immediately after the hero, before the editorial board. const order = await page .locator('[data-home-section]') .evaluateAll((sections) => sections.map((s) => s.getAttribute('data-home-section'))) expect(order.indexOf('plenaria')).toBe(order.indexOf('hero') + 1) - expect(order.indexOf('plenaria')).toBeLessThan(order.indexOf('proof')) + expect(order.indexOf('plenaria')).toBeLessThan(order.indexOf('contents')) // Pré-live: the invitation with the S29 agenda and no entry button. await expect(section.getByRole('heading', { name: 'Plenária da Vitória' })).toBeVisible() diff --git a/tests/int/archivePhotoPublicRead.int.spec.ts b/tests/int/archivePhotoPublicRead.int.spec.ts index 0fd92909..f5942b73 100644 --- a/tests/int/archivePhotoPublicRead.int.spec.ts +++ b/tests/int/archivePhotoPublicRead.int.spec.ts @@ -338,9 +338,7 @@ describe('public photo album reads (C233)', () => { ) expect(download.status).toBe(200) expect(download.headers.get('Content-Disposition')).toContain('attachment') - expect(download.headers.get('Content-Disposition')).toContain( - `jorge-solla-1313-foto-${approved.id}`, - ) + expect(download.headers.get('Content-Disposition')).toContain(`jorge-solla-foto-${approved.id}`) for (const id of [draft.id, 999_999_999]) { const response = await getArchivePhotoMedia( diff --git a/tests/int/contentEvents.int.spec.ts b/tests/int/contentEvents.int.spec.ts index c6643104..eb2a552c 100644 --- a/tests/int/contentEvents.int.spec.ts +++ b/tests/int/contentEvents.int.spec.ts @@ -10,20 +10,6 @@ vi.mock('next/cache', () => ({ unstable_cache: (fn: unknown) => fn, })) -const afterCallbacks: Array<Promise<unknown>> = [] - -vi.mock('next/server', async (importOriginal) => { - const actual = await importOriginal<typeof import('next/server')>() - return { - ...actual, - // The media route keeps the count off the response path; the test runs the - // deferred work explicitly and asserts the row. - after: (callback: () => unknown) => { - afterCallbacks.push(Promise.resolve().then(callback)) - }, - } -}) - const { rateLimitMock } = vi.hoisted(() => ({ rateLimitMock: vi.fn(() => true) })) vi.mock('@/utilities/content/contentEventRateLimit', () => ({ @@ -37,7 +23,6 @@ vi.mock('@/utilities/content/contentEventAggregate', async (importOriginal) => { }) import { POST as postContentEvent } from '@/app/(frontend)/api/content-events/route' -import { GET as getPublicPieceMedia } from '@/app/(frontend)/conteudos/[slug]/midia/route' import { resolveCardDownloadCounts } from '@/lib/cardDownloadCounts' import { contentPieceCirculationFromRows } from '@/lib/contentPieceCirculation' import type { CampaignUser, ContentMedia } from '@/payload-types' @@ -64,10 +49,6 @@ const campaignFixtures = installCampaignFixtures({ }, }) -const flushAfterCallbacks = async (): Promise<void> => { - await Promise.all(afterCallbacks.splice(0)) -} - const eventRowsOf = async (subjectIds: Array<number | string>) => payload.find({ collection: 'contentEvent', @@ -136,17 +117,6 @@ const postEvent = (body: unknown, init: RequestInit = {}): Promise<Response> => }), ) -const callPublicMedia = ( - slug: string, - { download, range }: { download?: boolean; range?: string }, -) => - getPublicPieceMedia( - new Request(`http://localhost/conteudos/${slug}/midia${download ? '?download=1' : ''}`, { - headers: range ? { range } : undefined, - }), - { params: Promise.resolve({ slug }) }, - ) - describe('content events (C213)', () => { let communicator: CampaignUser let leader: CampaignUser @@ -161,7 +131,6 @@ describe('content events (C213)', () => { fixtures.createCampaignUser('communicator'), fixtures.createCampaignUser('leader'), ]) - afterCallbacks.length = 0 // The card counters are global by model id — each test starts from zero. await payload.delete({ collection: 'contentEvent', @@ -297,27 +266,6 @@ describe('content events (C213)', () => { ).resolves.toEqual({ ok: false }) }) - it('counts one full public download and ignores ranges and plain reads', async () => { - const piece = await createPiece({ title: `Download ${Date.now()}` }) - const slug = await slugOf(piece.id) - - const plain = await callPublicMedia(slug, {}) - expect(plain.status).toBe(200) - await flushAfterCallbacks() - - const ranged = await callPublicMedia(slug, { download: true, range: 'bytes=0-3' }) - expect(ranged.status).toBe(206) - await flushAfterCallbacks() - - const download = await callPublicMedia(slug, { download: true }) - expect(download.status).toBe(200) - await flushAfterCallbacks() - - const rows = await eventRowsOf([piece.id]) - expect(rows.docs).toHaveLength(1) - expect(rows.docs[0]).toMatchObject({ type: 'download', subjectId: String(piece.id) }) - }) - it('attaches the circulation counters to the list and the detail loaders', async () => { const published = await createPiece({ title: `Circulação ${Date.now()}` }) const untouched = await createPiece({ title: `Circulação zerada ${Date.now()}` }) diff --git a/tests/int/contentPiece.int.spec.ts b/tests/int/contentPiece.int.spec.ts index 404ce1de..8361f14e 100644 --- a/tests/int/contentPiece.int.spec.ts +++ b/tests/int/contentPiece.int.spec.ts @@ -47,14 +47,7 @@ import { setContentPiecePublishedForActor, updateContentPieceForActor, } from '@/app/(campaign)/campanha/actions/contentPieces' -import { GET as getPublicPieceFrame } from '@/app/(frontend)/conteudos/[slug]/frame/route' -import { GET as getPublicPieceMedia } from '@/app/(frontend)/conteudos/[slug]/midia/route' import type { ContentPieceCuratedField } from '@/lib/contentPiece' -import { - isCardCatalogItem, - type ContentCatalogItem, - type ContentPiecePublicItem, -} from '@/lib/contentPieceCatalog' import { buildContentPieceFrameFfmpegArgs, CONTENT_PIECE_FRAME_MAX_SOURCE_BYTES, @@ -71,7 +64,6 @@ import { CONTENT_PIECE_PROFILE_IMPORT_UNAVAILABLE_MESSAGE, CONTENT_PIECE_RETRY_NOT_FAILED_MESSAGE, } from '@/lib/schemas/contentPiece' -import { slugify } from '@/lib/slug' import type { SpeechTopic } from '@/lib/speechFacets' import { normalizeForSearch } from '@/lib/speechSearch' import type { CampaignUser, ContentMedia, User } from '@/payload-types' @@ -97,13 +89,7 @@ import { createContentPieceFromProfilePost, listContentPieceProfileImportCandidates, } from '@/utilities/content/contentPieceProfileImport' -import { - getPublishedContentPieceBySlug, - getPublishedContentPieceItems, - getPublishedContentPieceRecords, - hasPublishedContentPieces, -} from '@/utilities/content/contentPieceReads' -import { loadContentPieceCatalogSearch } from '@/utilities/content/contentPieceThemeSearch' +import { getPublishedContentPieceBySlug } from '@/utilities/content/contentPieceReads' import { attachContentPieceMedia, receiveContentPieceUpload, @@ -264,22 +250,6 @@ const callFileRoute = async ({ return GET(request, { params: Promise.resolve({ id: String(pieceId) }) }) } -const callPublicPieceMedia = async ({ - slug, - range, - download, -}: { - slug: string - range?: string - download?: boolean -}): Promise<Response> => - getPublicPieceMedia( - new Request(`http://localhost/conteudos/${slug}/midia${download ? '?download=1' : ''}`, { - headers: range ? { range } : undefined, - }), - { params: Promise.resolve({ slug }) }, - ) - const transcribeOk = async () => ({ ok: true as const, segments: [ @@ -338,13 +308,6 @@ const withTempDir = async <T>(run: (tempDir: string) => Promise<T>): Promise<T> } } -/** - * S38 — the loader returns pieces plus the synthetic card items; the piece - * assertions narrow here (the card side has no id/themeMatch by contract). - */ -const pieceRows = (rows: readonly ContentCatalogItem[]): ContentPiecePublicItem[] => - rows.filter((row): row is ContentPiecePublicItem => !isCardCatalogItem(row)) - describe('content pieces (C211)', () => { let communicator: CampaignUser let coordinator: CampaignUser @@ -2346,393 +2309,7 @@ describe('content pieces (C211)', () => { await expect(loadContentPieceListPageData(payload, leader, {})).rejects.toThrow() }) - it('shows only published pieces with file or link in the public read', async () => { - const marker = `pub-${Date.now()}` - const draft = await createPiece({ title: `Rascunho ${marker}` }) - const published = await createPiece({ title: `Publicada ${marker}` }) - const empty = await createPiece({ title: `Vazia ${marker}`, withMedia: false }) - const link = await createPiece({ - title: `Link ${marker}`, - withMedia: false, - origin: 'youtube', - sourceUrl: `https://www.youtube.com/watch?v=PUB${Date.now()}`, - }) - - await payload.update({ - collection: 'contentPiece', - id: published.piece.id, - data: { status: 'publicado' }, - overrideAccess: true, - }) - await payload.update({ - collection: 'contentPiece', - id: empty.piece.id, - data: { status: 'publicado' }, - overrideAccess: true, - }) - await payload.update({ - collection: 'contentPiece', - id: link.piece.id, - data: { status: 'publicado' }, - overrideAccess: true, - }) - - const items = await getPublishedContentPieceItems() - const ids = items.map((item) => item.id) - const publishedItem = items.find((item) => item.id === published.piece.id) - - expect(ids).toContain(published.piece.id) - expect(ids).toContain(link.piece.id) - expect(ids).not.toContain(draft.piece.id) - // Published but with nothing to show (no file, no link) fails closed. - expect(ids).not.toContain(empty.piece.id) - expect(publishedItem?.slug).toMatch(new RegExp(`^publicada-${marker}`)) - expect(await getPublishedContentPieceBySlug(publishedItem!.slug)).toMatchObject({ - id: published.piece.id, - file: { path: `/conteudos/${publishedItem!.slug}/midia` }, - }) - expect(await getPublishedContentPieceBySlug(`nao-existe-${marker}`)).toBeNull() - expect(await hasPublishedContentPieces()).toBe(true) - }) - - it('derives the people facet and the name search from published pieces only (S37)', async () => { - const fixtures = campaignFixtures() - const marker = `facet-${Date.now()}` - const contactName = `Liderança Pública ${marker}` - const contact = await fixtures.createContact({ name: contactName }) - const municipality = await fixtures.getMunicipality() - const leadership = await fixtures.createLeadership({ - contact: contact.id, - municipalities: [municipality.id], - }) - - const published = await createPiece({ title: `Publicada pessoas ${marker}` }) - const draft = await createPiece({ title: `Rascunho pessoas ${marker}` }) - getCampaignUserMock.mockResolvedValue(communicator) - - for (const [id, title] of [ - [published.piece.id, `Publicada pessoas ${marker}`], - [draft.piece.id, `Rascunho pessoas ${marker}`], - ] as const) { - await updateContentPieceForActor({ - contentPieceId: id, - title, - description: null, - type: 'video', - pieceDate: null, - municipalityId: null, - institution: null, - transcript: null, - leaderIds: [leadership.id], - publicFigures: ['Dra. Elaine'], - }) - } - await payload.update({ - collection: 'contentPiece', - id: published.piece.id, - data: { status: 'publicado' }, - overrideAccess: true, - }) - - const personSlug = slugify(contactName) - const records = await getPublishedContentPieceRecords() - const record = records.find((candidate) => candidate.id === published.piece.id) - expect(record?.leaderNames).toEqual([contactName]) - expect(record?.publicFigures).toEqual(['Dra. Elaine']) - // The public select never carries the relation (depth 1 would leak Contact). - expect(record && 'leaders' in record).toBe(false) - - const filtered = await loadContentPieceCatalogSearch({ - rawSearchParams: { lideranca: personSlug }, - }) - expect(pieceRows(filtered.items).map((item) => item.id)).toContain(published.piece.id) - expect(pieceRows(filtered.items).map((item) => item.id)).not.toContain(draft.piece.id) - expect(filtered.facets.lideranca).toContainEqual({ value: personSlug, label: contactName }) - - const byName = await loadContentPieceCatalogSearch({ rawSearchParams: { q: contactName } }) - expect(pieceRows(byName.items).map((item) => item.id)).toContain(published.piece.id) - - // Unpublishing the last piece with the name removes it from the facet. - await payload.update({ - collection: 'contentPiece', - id: published.piece.id, - data: { status: 'rascunho' }, - overrideAccess: true, - }) - const afterUnpublish = await loadContentPieceCatalogSearch({ rawSearchParams: {} }) - expect(afterUnpublish.facets.lideranca).not.toContainEqual({ - value: personSlug, - label: contactName, - }) - }) - - it('serves a published piece file publicly and 404s draft, link and unknown slugs', async () => { - const marker = `media-${Date.now()}` - const draft = await createPiece({ title: `Mídia rascunho ${marker}` }) - const published = await createPiece({ title: `Mídia pública ${marker}` }) - const link = await createPiece({ - title: `Mídia link ${marker}`, - withMedia: false, - origin: 'instagram', - sourceUrl: `https://www.instagram.com/reel/MEDIA${Date.now()}/`, - }) - - await payload.update({ - collection: 'contentPiece', - id: published.piece.id, - data: { status: 'publicado' }, - overrideAccess: true, - }) - await payload.update({ - collection: 'contentPiece', - id: link.piece.id, - data: { status: 'publicado' }, - overrideAccess: true, - }) - - const publishedSlug = ( - await payload.findByID({ - collection: 'contentPiece', - id: published.piece.id, - depth: 0, - overrideAccess: true, - }) - ).slug! - const draftSlug = ( - await payload.findByID({ - collection: 'contentPiece', - id: draft.piece.id, - depth: 0, - overrideAccess: true, - }) - ).slug - const linkSlug = ( - await payload.findByID({ - collection: 'contentPiece', - id: link.piece.id, - depth: 0, - overrideAccess: true, - }) - ).slug! - - const full = await callPublicPieceMedia({ slug: publishedSlug }) - expect(full.status).toBe(200) - expect(Buffer.from(await full.arrayBuffer())).toEqual(VIDEO_BYTES) - - const partial = await callPublicPieceMedia({ slug: publishedSlug, range: 'bytes=0-3' }) - expect(partial.status).toBe(206) - - const download = await callPublicPieceMedia({ slug: publishedSlug, download: true }) - expect(download.headers.get('Content-Disposition')).toContain('attachment') - // The public download carries the legible name, never the stored upload one. - expect(download.headers.get('Content-Disposition')).toContain( - `jorge-solla-1313-${publishedSlug}.mp4`, - ) - - // A draft has no public slug at all; a link piece has no archived file. - expect(draftSlug).toBeNull() - expect((await callPublicPieceMedia({ slug: linkSlug })).status).toBe(404) - expect((await callPublicPieceMedia({ slug: `nao-existe-${marker}` })).status).toBe(404) - - // Unpublishing removes the file from the public door immediately. - await payload.update({ - collection: 'contentPiece', - id: published.piece.id, - data: { status: 'rascunho' }, - overrideAccess: true, - }) - expect((await callPublicPieceMedia({ slug: publishedSlug })).status).toBe(404) - }) - - it('finds a piece by the expanded theme terms with real evidence (S28)', async () => { - const marker = `tema-${Date.now()}` - const term = `atendimento universal ${marker}` - const match = await createPiece({ - title: `Mutirão no Subúrbio ${marker}`, - transcript: `Garantir o ${term} na rede pública`, - status: 'publicado', - }) - - const expansionCalls: string[] = [] - const data = await loadContentPieceCatalogSearch({ - rawSearchParams: { q: 'defesa do SUS', mode: 'tema' }, - requestHeaders: new Headers({ 'sec-fetch-mode': 'navigate' }), - expandTheme: async (theme) => { - expansionCalls.push(theme) - return { terms: [term] } - }, - }) - - expect(expansionCalls).toEqual(['defesa do SUS']) - expect(data.themeUnavailable).toBe(false) - expect(data.themeApplied).toBe(true) - const item = pieceRows(data.items).find((row) => row.id === match.piece.id) - expect(item?.themeMatch?.term).toBe(term) - expect(item?.themeMatch?.evidence?.quoted).toBe(true) - expect(item?.themeMatch?.evidence?.parts.some((part) => part.highlighted)).toBe(true) - }) - - it('degrades to the literal search when the expansion is unavailable (S28)', async () => { - const marker = `literal-tema-${Date.now()}` - const match = await createPiece({ title: `Debate na rádio ${marker}`, status: 'publicado' }) - - const data = await loadContentPieceCatalogSearch({ - rawSearchParams: { q: marker, mode: 'tema' }, - requestHeaders: new Headers({ 'sec-fetch-mode': 'navigate' }), - expandTheme: async () => null, - }) - - expect(data.themeUnavailable).toBe(true) - expect(data.themeApplied).toBe(false) - const item = pieceRows(data.items).find((row) => row.id === match.piece.id) - expect(item).toBeTruthy() - expect(item?.themeMatch).toBeNull() - }) - - it('keeps the literal list without a notice when the expansion adds nothing (S28)', async () => { - const marker = `vazio-tema-${Date.now()}` - const match = await createPiece({ title: `Peça ${marker}`, status: 'publicado' }) - - const data = await loadContentPieceCatalogSearch({ - rawSearchParams: { q: marker, mode: 'tema' }, - requestHeaders: new Headers({ 'sec-fetch-mode': 'navigate' }), - expandTheme: async () => ({ terms: [] }), - }) - - expect(data.themeUnavailable).toBe(false) - expect(data.themeApplied).toBe(false) - expect(pieceRows(data.items).map((row) => row.id)).toEqual([match.piece.id]) - expect(pieceRows(data.items).every((row) => row.themeMatch === null)).toBe(true) - }) - - it('never expands without navigation signals (S28)', async () => { - const marker = `inelegivel-${Date.now()}` - await createPiece({ title: `Peça ${marker}`, status: 'publicado' }) - - let called = false - const data = await loadContentPieceCatalogSearch({ - rawSearchParams: { q: marker, mode: 'tema' }, - requestHeaders: new Headers(), - expandTheme: async () => { - called = true - return { terms: [marker] } - }, - }) - - expect(called).toBe(false) - expect(data.themeUnavailable).toBe(true) - }) - - it('keeps the literal search untouched without the mode (S28)', async () => { - const marker = `exato-${Date.now()}` - const match = await createPiece({ title: `Peça ${marker}`, status: 'publicado' }) - - let called = false - const data = await loadContentPieceCatalogSearch({ - rawSearchParams: { q: marker }, - requestHeaders: new Headers({ 'sec-fetch-mode': 'navigate' }), - expandTheme: async () => { - called = true - return { terms: [marker] } - }, - }) - - expect(called).toBe(false) - expect(data.params.mode).toBeNull() - expect(data.themeUnavailable).toBe(false) - expect(pieceRows(data.items).map((row) => row.id)).toEqual([match.piece.id]) - }) - - it('never surfaces a draft in the theme search (S28)', async () => { - const marker = `rascunho-tema-${Date.now()}` - const term = `texto sobre ${marker}` - const draft = await createPiece({ - title: `Rascunho ${marker}`, - transcript: term, - }) - const published = await createPiece({ - title: `Publicada ${marker}`, - transcript: term, - status: 'publicado', - }) - - const data = await loadContentPieceCatalogSearch({ - rawSearchParams: { q: `assunto ${marker}`, mode: 'tema' }, - requestHeaders: new Headers({ 'sec-fetch-mode': 'navigate' }), - expandTheme: async () => ({ terms: [term] }), - }) - - const ids = pieceRows(data.items).map((row) => row.id) - expect(ids).toContain(published.piece.id) - expect(ids).not.toContain(draft.piece.id) - }) - - it('appends the six card items to a non-empty Central (S38)', async () => { - const marker = `cards-${Date.now()}` - const match = await createPiece({ title: `Peça ${marker}`, status: 'publicado' }) - - const data = await loadContentPieceCatalogSearch({ rawSearchParams: {} }) - - // `publishedCount` still counts pieces only: with no filters the board is - // every published piece plus the six cards — never cards alone. - const pieces = pieceRows(data.items) - expect(data.publishedCount).toBe(pieces.length) - expect(pieces.map((row) => row.id)).toContain(match.piece.id) - const cards = data.items.filter(isCardCatalogItem) - expect(cards.map((card) => card.modelId)).toEqual([ - 'eu-sou-solla', - 'perfil-quadrado', - 'perfil-retangular', - 'time-de-voce', - 'time-do-estadual', - 'minha-colinha', - ]) - expect(cards.every((card) => card.href.startsWith('/cards?model='))).toBe(true) - expect(data.facets.tipo).toContainEqual({ value: 'card', label: 'Card' }) - }) - - it('finds the card models by nickname and keeps them out of the geographic facets (S38)', async () => { - const marker = `cards-busca-${Date.now()}` - await createPiece({ title: `Peça ${marker}`, status: 'publicado' }) - - const santinho = await loadContentPieceCatalogSearch({ rawSearchParams: { q: 'santinho' } }) - expect(santinho.items.filter(isCardCatalogItem).map((card) => card.modelId)).toEqual([ - 'time-de-voce', - 'minha-colinha', - ]) - - const geo = await loadContentPieceCatalogSearch({ - rawSearchParams: { q: 'santinho', cidade: 'salvador' }, - }) - expect(geo.items.filter(isCardCatalogItem)).toEqual([]) - - const byType = await loadContentPieceCatalogSearch({ rawSearchParams: { tipo: 'card' } }) - expect(byType.items.filter(isCardCatalogItem)).toHaveLength(6) - }) - describe('C226 — the video still before the play', () => { - const publish = async (pieceId: number): Promise<string> => { - await payload.update({ - collection: 'contentPiece', - id: pieceId, - data: { status: 'publicado' }, - overrideAccess: true, - }) - return ( - await payload.findByID({ - collection: 'contentPiece', - id: pieceId, - depth: 0, - overrideAccess: true, - }) - ).slug! - } - - const callFrame = (slug: string): Promise<Response> => - getPublicPieceFrame(new Request(`http://localhost/conteudos/${slug}/frame`), { - params: Promise.resolve({ slug }), - }) - it('generates the still once, stores it privately and reuses the row', async () => { const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG) try { @@ -2884,79 +2461,6 @@ describe('content pieces (C211)', () => { } }) - it('serves the still of a published video and 404s everything else', async () => { - const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG) - try { - const marker = `frame-rota-${Date.now()}` - const published = await createPiece({ - title: `Frame pública ${marker}`, - mediaData: FRAME_SOURCE_BYTES, - }) - const photo = await createPiece({ - title: `Frame foto ${marker}`, - type: 'foto', - mediaMimetype: 'image/png', - mediaData: FRAME_SOURCE_BYTES, - }) - const link = await createPiece({ - title: `Frame link ${marker}`, - withMedia: false, - origin: 'instagram', - sourceUrl: `https://www.instagram.com/reel/ROTA${Date.now()}/`, - }) - - const publishedSlug = await publish(published.piece.id) - const photoSlug = await publish(photo.piece.id) - const linkSlug = await publish(link.piece.id) - - const served = await callFrame(publishedSlug) - expect(served.status).toBe(200) - expect(served.headers.get('Content-Type')).toBe('image/jpeg') - // The derived object is as private as the video it comes from. - expect(served.headers.get('Cache-Control')).toBe('private, no-store') - expect(Buffer.from(await served.arrayBuffer())).toEqual(FRAME_SOURCE_BYTES) - - // A photo, a link piece and an unknown slug never reach the generation. - expect((await callFrame(photoSlug)).status).toBe(404) - expect((await callFrame(linkSlug)).status).toBe(404) - expect((await callFrame(`nao-existe-${marker}`)).status).toBe(404) - - // The kill switch closes the frame door with the video door. - await payload.update({ - collection: 'contentPiece', - id: published.piece.id, - data: { status: 'rascunho' }, - overrideAccess: true, - }) - expect((await callFrame(publishedSlug)).status).toBe(404) - } finally { - restoreFfmpeg() - } - }) - - it('declares the frame in the public projection only for a video with a file', async () => { - const video = await createPiece({ - title: `Frame projeção vídeo ${Date.now()}`, - mediaData: FRAME_SOURCE_BYTES, - }) - const photo = await createPiece({ - title: `Frame projeção foto ${Date.now()}`, - type: 'foto', - mediaMimetype: 'image/png', - mediaData: FRAME_SOURCE_BYTES, - }) - await publish(video.piece.id) - await publish(photo.piece.id) - - const byId = async (pieceId: number) => - (await getPublishedContentPieceItems()).find((item) => item.id === pieceId) - - expect((await byId(video.piece.id))?.framePath).toBe( - `/conteudos/${(await byId(video.piece.id))?.slug}/frame`, - ) - expect((await byId(photo.piece.id))?.framePath).toBeNull() - }) - it('leaves the piece ready when the still cannot be extracted (failure isolation)', async () => { const { piece } = await createPiece({ processingStatus: 'processando', diff --git a/tests/int/jingle.int.spec.ts b/tests/int/jingle.int.spec.ts deleted file mode 100644 index cc442c86..00000000 --- a/tests/int/jingle.int.spec.ts +++ /dev/null @@ -1,336 +0,0 @@ -// @vitest-environment node - -/** - * S21 — `jingle`: slug fallback, required cover/audio, the fail-closed - * `published` switch for anonymous reads and campaign users, the ordered - * public listing (order asc, unordered last, title as tiebreak), the media - * resolution used by the cards and the listing tag busted by - * afterChange/afterDelete. - */ -import { randomUUID } from 'node:crypto' -import { readFileSync } from 'node:fs' -import path from 'node:path' - -import type { Payload } from 'payload' -import { getPayload } from 'payload' -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' - -const { revalidateTagMock } = vi.hoisted(() => ({ revalidateTagMock: vi.fn() })) - -// The collection hooks bust the Next cache tag; the loaders reach -// `unstable_cache`. Both need the Next runtime, so mock it — the assertion -// pins the tag name. -vi.mock('next/cache', () => ({ - revalidatePath: () => undefined, - revalidateTag: revalidateTagMock, - unstable_cache: (fn: unknown) => fn, -})) - -import config from '@/payload.config' -import { getPublishedJingleItems, hasPublishedJingles } from '@/utilities/jingleReads' - -const TEST_PNG = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', - 'base64', -) -const TEST_MP3 = readFileSync(path.resolve(process.cwd(), 'tests/fixtures/jingle-tone.mp3')) - -let payload: Payload -const createdJingleIds = new Set<number>() -const createdMediaIds = new Set<number>() -const createdUserIds = new Set<number>() -const createdCampaignUserIds = new Set<number>() - -const run = () => randomUUID().slice(0, 8) - -const createMedia = async (name: string, mimetype: string, data: Buffer) => { - const media = await payload.create({ - collection: 'media', - data: { alt: name }, - file: { data, mimetype, name, size: data.length }, - overrideAccess: true, - }) - createdMediaIds.add(media.id) - return media -} - -type JingleInput = { - title: string - slug?: string - order?: number | null - published?: boolean -} - -const createJingle = async ({ title, slug, order = null, published = false }: JingleInput) => { - const suffix = `${run()}-${slug || 'auto'}` - const cover = await createMedia(`capa-${suffix}.png`, 'image/png', TEST_PNG) - const audio = await createMedia(`audio-${suffix}.mp3`, 'audio/mpeg', TEST_MP3) - const doc = await payload.create({ - collection: 'jingle', - data: { - title, - ...(slug !== undefined ? { slug } : {}), - coverImage: cover.id, - audio: audio.id, - order, - published, - }, - }) - createdJingleIds.add(doc.id) - return { doc, cover, audio } -} - -const findAnonymous = () => - payload.find({ collection: 'jingle', depth: 0, limit: 0, overrideAccess: false }) - -describe('jingle', () => { - beforeAll(async () => { - payload = await getPayload({ config: await config }) - }) - - beforeEach(() => { - revalidateTagMock.mockClear() - }) - - afterAll(async () => { - for (const id of createdJingleIds) { - await payload.delete({ collection: 'jingle', id }).catch(() => undefined) - } - for (const id of createdMediaIds) { - await payload.delete({ collection: 'media', id }).catch(() => undefined) - } - for (const id of createdUserIds) { - await payload.delete({ collection: 'users', id }).catch(() => undefined) - } - for (const id of createdCampaignUserIds) { - await payload.delete({ collection: 'campaignUser', id }).catch(() => undefined) - } - }) - - it('creates unpublished by default and busts the jingles tag', async () => { - const { doc } = await createJingle({ title: 'Axe', slug: 'axe' }) - - expect(doc.published).toBe(false) - expect(revalidateTagMock).toHaveBeenCalledWith('jingles') - }) - - it('generates the slug from the title when empty', async () => { - const { doc } = await createJingle({ title: 'Forró' }) - - expect(doc.slug).toBe('forro') - }) - - it('rejects a title whose slug normalizes to nothing', async () => { - const cover = await createMedia(`capa-slug-vazio-${run()}.png`, 'image/png', TEST_PNG) - const audio = await createMedia(`audio-slug-vazio-${run()}.mp3`, 'audio/mpeg', TEST_MP3) - - try { - const doc = await payload.create({ - collection: 'jingle', - data: { title: '♥♥', coverImage: cover.id, audio: audio.id }, - }) - createdJingleIds.add(doc.id) - throw new Error('expected the create to be rejected') - } catch (error) { - // Payload wraps a field validation in a generic message; the pt-BR - // message travels in `data.errors[].message`. - const entries = - (error as { data?: { errors?: Array<{ message?: string }> } }).data?.errors ?? [] - expect(entries.map((entry) => entry.message ?? '').join(' ')).toContain('letras ou números') - } - }) - - it('requires both the cover and the audio', async () => { - const cover = await createMedia(`capa-obrigatoria-${run()}.png`, 'image/png', TEST_PNG) - const audio = await createMedia(`audio-obrigatorio-${run()}.mp3`, 'audio/mpeg', TEST_MP3) - - await expect( - // @ts-expect-error Testing the runtime rejection of a missing required cover - payload.create({ - collection: 'jingle', - data: { title: 'Sem capa', audio: audio.id, published: true }, - }), - ).rejects.toThrow() - await expect( - // @ts-expect-error Testing the runtime rejection of a missing required audio - payload.create({ - collection: 'jingle', - data: { title: 'Sem áudio', coverImage: cover.id, published: true }, - }), - ).rejects.toThrow() - }) - - it('hides drafts from anonymous reads and from the public listing, exposing them once published', async () => { - const { doc } = await createJingle({ title: 'Rascunho', slug: 'rascunho-s21' }) - - expect((await findAnonymous()).docs.some((row) => row.id === doc.id)).toBe(false) - expect((await getPublishedJingleItems()).some((item) => item.id === doc.id)).toBe(false) - - await payload.update({ collection: 'jingle', id: doc.id, data: { published: true } }) - - expect((await findAnonymous()).docs.some((row) => row.id === doc.id)).toBe(true) - expect((await getPublishedJingleItems()).some((item) => item.id === doc.id)).toBe(true) - expect(await hasPublishedJingles()).toBe(true) - }) - - it('refuses to write as a campaign user and allows an editor', async () => { - const campaignUser = await payload.create({ - collection: 'campaignUser', - data: { - name: 'Coordenação', - email: `jingle-int-${run()}@example.com`, - password: 'password', - role: 'coordinator', - }, - depth: 0, - }) - createdCampaignUserIds.add(campaignUser.id) - const editor = await payload.create({ - collection: 'users', - data: { - email: `jingle-int-editor-${run()}@example.com`, - password: 'password', - roles: ['editor'], - }, - depth: 0, - }) - createdUserIds.add(editor.id) - - const cover = await createMedia(`capa-acesso-${run()}.png`, 'image/png', TEST_PNG) - const audio = await createMedia(`audio-acesso-${run()}.mp3`, 'audio/mpeg', TEST_MP3) - const draft = await payload.create({ - collection: 'jingle', - data: { title: 'Somente admin', coverImage: cover.id, audio: audio.id }, - }) - createdJingleIds.add(draft.id) - - await expect( - payload.create({ - collection: 'jingle', - data: { title: 'Do campaign user', coverImage: cover.id, audio: audio.id }, - user: campaignUser, - overrideAccess: false, - }), - ).rejects.toThrow() - await expect( - payload.create({ - collection: 'jingle', - data: { title: 'Do anônimo', coverImage: cover.id, audio: audio.id }, - overrideAccess: false, - }), - ).rejects.toThrow() - await expect( - payload.update({ - collection: 'jingle', - id: draft.id, - data: { title: 'Editado pelo campaign user' }, - user: campaignUser, - overrideAccess: false, - }), - ).rejects.toThrow() - await expect( - payload.delete({ - collection: 'jingle', - id: draft.id, - user: campaignUser, - overrideAccess: false, - }), - ).rejects.toThrow() - - const editorRead = await payload.find({ - collection: 'jingle', - where: { id: { equals: draft.id } }, - user: editor, - overrideAccess: false, - depth: 0, - }) - expect(editorRead.totalDocs).toBe(1) - - const editorUpdate = await payload.update({ - collection: 'jingle', - id: draft.id, - data: { title: 'Editado pelo editor' }, - user: editor, - overrideAccess: false, - }) - expect(editorUpdate.title).toBe('Editado pelo editor') - - const campaignRead = await payload.find({ - collection: 'jingle', - where: { id: { equals: draft.id } }, - user: campaignUser, - overrideAccess: false, - depth: 0, - }) - expect(campaignRead.totalDocs).toBe(0) - }) - - it('lists published jingles by order, unordered last, title as tiebreak', async () => { - const marker = run() - const second = await createJingle({ - title: `Segundo ${marker}`, - slug: `segundo-${marker}`, - order: 2, - published: true, - }) - const first = await createJingle({ - title: `Primeiro ${marker}`, - slug: `primeiro-${marker}`, - order: 1, - published: true, - }) - const unorderedB = await createJingle({ - title: `Sem ordem B ${marker}`, - slug: `sem-ordem-b-${marker}`, - published: true, - }) - const unorderedA = await createJingle({ - title: `Sem ordem A ${marker}`, - slug: `sem-ordem-a-${marker}`, - published: true, - }) - - const ids = new Set<number | string>([ - first.doc.id, - second.doc.id, - unorderedA.doc.id, - unorderedB.doc.id, - ]) - const listed = (await getPublishedJingleItems()) - .filter((item) => ids.has(item.id)) - .map((item) => item.id) - - expect(listed).toEqual([first.doc.id, second.doc.id, unorderedA.doc.id, unorderedB.doc.id]) - }) - - it('resolves the card media and follows a replaced file', async () => { - const { doc } = await createJingle({ - title: 'Troca de arquivo', - slug: 'troca-de-arquivo', - published: true, - }) - const original = (await getPublishedJingleItems()).find((item) => item.id === doc.id) - expect(original?.coverUrl).toMatch(/^\/api\/media\/file\//) - expect(original?.audioUrl).toMatch(/^\/api\/media\/file\//) - - const replacement = await createMedia(`audio-trocado-${run()}.mp3`, 'audio/mpeg', TEST_MP3) - await payload.update({ collection: 'jingle', id: doc.id, data: { audio: replacement.id } }) - - const updated = (await getPublishedJingleItems()).find((item) => item.id === doc.id) - expect(updated?.audioUrl).toContain(replacement.filename) - expect(updated?.audioUrl).not.toBe(original?.audioUrl) - }) - - it('busts the tag on update and delete', async () => { - const { doc } = await createJingle({ title: 'Ciclo', slug: 'ciclo-s21' }) - - revalidateTagMock.mockClear() - await payload.update({ collection: 'jingle', id: doc.id, data: { published: true } }) - expect(revalidateTagMock).toHaveBeenCalledWith('jingles') - - revalidateTagMock.mockClear() - await payload.delete({ collection: 'jingle', id: doc.id }) - createdJingleIds.delete(doc.id) - expect(revalidateTagMock).toHaveBeenCalledWith('jingles') - }) -}) diff --git a/tests/int/submitCampaignNewsletter.int.spec.ts b/tests/int/submitCampaignNewsletter.int.spec.ts deleted file mode 100644 index 63ec2880..00000000 --- a/tests/int/submitCampaignNewsletter.int.spec.ts +++ /dev/null @@ -1,158 +0,0 @@ -// @vitest-environment node - -/** - * S9 — the public campaign "novidades" capture resolves its Consent by the - * stable key `campanha-novidades` and FAILS CLOSED while the keyed document is - * missing; the engagement-level toggle choice survives as - * `subscription.campaignLevel` and a contact may be recorded without a state. - */ -import type { Payload } from 'payload' -import { getPayload } from 'payload' -import { beforeAll, beforeEach, describe, expect, it } from 'vitest' - -import { submitCampaignNewsletter } from '@/app/(frontend)/actions/submitCampaignNewsletter' -import { CAMPAIGN_NEWSLETTER_CONSENT_KEY } from '@/lib/campaignConsentKeys' -import config from '@/payload.config' - -import { installCampaignFixtures } from '../helpers/campaignFixtures' - -let payload: Payload -const campaignFixtures = installCampaignFixtures({ - getPayload: () => payload, - setPayload: (nextPayload) => { - payload = nextPayload - }, -}) - -const deleteNewsletterConsentRows = async () => { - // Deleting a consent fails while a subscription references it - // (`subscription.consent_id` is NOT NULL with an `ON DELETE SET NULL` FK), - // so purge referencing subscriptions first — also covers crash leftovers. - const consents = await payload.find({ - collection: 'consent', - where: { key: { equals: CAMPAIGN_NEWSLETTER_CONSENT_KEY } }, - depth: 0, - limit: 100, - pagination: false, - overrideAccess: true, - }) - for (const consent of consents.docs) { - await payload.delete({ - collection: 'subscription', - where: { consent: { equals: consent.id } }, - depth: 0, - overrideAccess: true, - }) - await payload.delete({ - collection: 'consent', - id: consent.id, - depth: 0, - overrideAccess: true, - }) - } -} - -const findSubscriptionByPhone = async (phone: string) => { - const contacts = await payload.find({ - collection: 'contact', - where: { 'phones.value': { equals: phone } }, - depth: 0, - limit: 1, - pagination: false, - overrideAccess: true, - }) - const contact = contacts.docs[0] - if (!contact) return undefined - - const subscriptions = await payload.find({ - collection: 'subscription', - where: { contact: { equals: contact.id } }, - depth: 0, - limit: 1, - pagination: false, - overrideAccess: true, - }) - return { contact, subscription: subscriptions.docs[0] } -} - -describe('submitCampaignNewsletter (consent by stable key + level choice)', () => { - beforeAll(async () => { - payload = await getPayload({ config: await config }) - }) - - // One row per key is what production assumes; deleting before each test - // keeps the "most recent row wins" resolver deterministic without relying on - // created-at precision. - beforeEach(deleteNewsletterConsentRows) - - it('fails closed while the keyed Consent document is missing', async () => { - await deleteNewsletterConsentRows() - - await expect( - submitCampaignNewsletter({ - name: campaignFixtures().personName('Contato'), - phone: campaignFixtures().phone(), - }), - ).rejects.toThrow('Consentimento de novidades da campanha ainda não configurado.') - }) - - it('records contact + subscription with the default "time" level', async () => { - const fixtures = campaignFixtures() - const consent = await fixtures.createConsent({ key: CAMPAIGN_NEWSLETTER_CONSENT_KEY }) - const phone = fixtures.phone() - - const result = await submitCampaignNewsletter({ - name: fixtures.personName('Contato'), - phone, - comment: 'Quero acompanhar a campanha.', - }) - expect(result).toEqual({ ok: true }) - - const { contact, subscription } = (await findSubscriptionByPhone(phone)) ?? {} - expect(contact).toBeDefined() - expect(subscription).toBeDefined() - // depth 0 returns the raw relationship id — the consent link itself. - expect(subscription!.consent).toBe(consent.id) - // The toggle is pre-selected: the default level is recorded as data. - expect(subscription!.campaignLevel).toBe('time') - - // The action's rows are not fixture-owned — clean them here so the - // fixture's contact cleanup does not trip on the subscription FK. - await payload.delete({ - collection: 'subscription', - id: subscription!.id, - depth: 0, - overrideAccess: true, - }) - }) - - it('records the "esporadico" level and an optional state is not persisted', async () => { - const fixtures = campaignFixtures() - const consent = await fixtures.createConsent({ key: CAMPAIGN_NEWSLETTER_CONSENT_KEY }) - const phone = fixtures.phone() - - const result = await submitCampaignNewsletter({ - name: fixtures.personName('Contato'), - phone, - email: `${fixtures.value('nl')}@example.com`, - city: 'Salvador', - campaignLevel: 'esporadico', - }) - expect(result).toEqual({ ok: true }) - - const { contact, subscription } = (await findSubscriptionByPhone(phone)) ?? {} - expect(contact).toBeDefined() - expect(contact!.state).toBeNull() - expect(contact!.city).toBe('Salvador') - expect(subscription).toBeDefined() - expect(subscription!.campaignLevel).toBe('esporadico') - expect(subscription!.consent).toBe(consent.id) - - await payload.delete({ - collection: 'subscription', - id: subscription!.id, - depth: 0, - overrideAccess: true, - }) - }) -}) diff --git a/tests/unit/archivePhotoPublicCatalog.unit.spec.ts b/tests/unit/archivePhotoPublicCatalog.unit.spec.ts index eb114097..6b99ffb0 100644 --- a/tests/unit/archivePhotoPublicCatalog.unit.spec.ts +++ b/tests/unit/archivePhotoPublicCatalog.unit.spec.ts @@ -163,7 +163,7 @@ describe('toArchivePhotoPublicItem', () => { expect(photo.thumbnailPath).toBe('/fotos/9/midia?tamanho=grade') expect(photo.mediaPath).toBe('/fotos/9/midia') expect(photo.downloadPath).toBe('/fotos/9/midia?download=1') - expect(photo.downloadFilename).toBe('jorge-solla-1313-foto-9.jpg') + expect(photo.downloadFilename).toBe('jorge-solla-foto-9.jpg') }) it('derives people only from the curated figures (never the text catalog) and dedupes by slug', () => { diff --git a/tests/unit/calendarEvent.unit.spec.ts b/tests/unit/calendarEvent.unit.spec.ts index 3a5a7e82..0a8ad669 100644 --- a/tests/unit/calendarEvent.unit.spec.ts +++ b/tests/unit/calendarEvent.unit.spec.ts @@ -57,7 +57,7 @@ describe('buildGoogleCalendarEventUrl', () => { const parsed = new URL(url as string) expect(`${parsed.origin}${parsed.pathname}`).toBe('https://calendar.google.com/calendar/render') expect(parsed.searchParams.get('action')).toBe('TEMPLATE') - expect(parsed.searchParams.get('text')).toBe('Plenária da saúde - Jorge Solla 1313') + expect(parsed.searchParams.get('text')).toBe('Plenária da saúde - Jorge Solla') expect(parsed.searchParams.get('dates')).toBe('20261003T190000/20261003T210000') expect(parsed.searchParams.get('ctz')).toBe('America/Bahia') expect(parsed.searchParams.get('details')).toBe( @@ -68,12 +68,12 @@ describe('buildGoogleCalendarEventUrl', () => { it('does not duplicate the signature or emit a relative link', () => { const url = buildGoogleCalendarEventUrl({ - title: 'Plenária - Jorge Solla 1313', + title: 'Plenária - Jorge Solla', url: '/plenaria-saude', startsAt: STARTS_AT, }) const parsed = new URL(url as string) - expect(parsed.searchParams.get('text')).toBe('Plenária - Jorge Solla 1313') + expect(parsed.searchParams.get('text')).toBe('Plenária - Jorge Solla') expect(parsed.searchParams.get('details')).toBeNull() }) @@ -128,7 +128,7 @@ describe('buildCalendarEventIcs', () => { expect(body).toContain('TZOFFSETTO:-0300\r\n') expect(body).toContain('DTSTART;TZID=America/Bahia:20261003T190000\r\n') expect(body).toContain('DTEND;TZID=America/Bahia:20261003T210000\r\n') - expect(body).toContain('SUMMARY:Plenária\\, saúde - Jorge Solla 1313\r\n') + expect(body).toContain('SUMMARY:Plenária\\, saúde - Jorge Solla\r\n') expect(body.replace(/\r\n /g, '')).toContain( 'DESCRIPTION:Linha 1\\nLinha 2\\n\\nPágina do evento: https://jorgesolla1313.com.br/plenaria-saude\r\n', ) @@ -146,7 +146,7 @@ describe('buildCalendarEventIcs', () => { expect(body).toContain('DTSTAMP:20261003T220000Z\r\n') expect(body).toContain('DTSTART;TZID=America/Bahia:20261003T190000\r\n') - expect(body).toContain('SUMMARY:Plenária - Jorge Solla 1313\r\n') + expect(body).toContain('SUMMARY:Plenária - Jorge Solla\r\n') expect(body).not.toContain('DESCRIPTION:') expect(body).not.toContain('URL:') expect(body).not.toMatch(/(?:^|\r\n)LOCATION:/) diff --git a/tests/unit/campaignCarousel.unit.spec.tsx b/tests/unit/campaignCarousel.unit.spec.tsx deleted file mode 100644 index 13879354..00000000 --- a/tests/unit/campaignCarousel.unit.spec.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { CampaignCarousel, type CampaignCarouselItem } from '@/components/CampaignCarousel' - -const flagItems: CampaignCarouselItem[] = [ - { - id: 'saude', - label: 'Saúde', - title: 'Defender o SUS e valorizar quem cuida', - body: 'Saúde é direito, não mercadoria.', - }, - { - id: 'educacao', - label: 'Educação', - title: 'Educação em tempo integral', - body: 'O interior não pode ficar para trás.', - }, -] - -const problemItems: CampaignCarouselItem[] = [ - { - id: 'trabalho', - title: 'Pelo fim da escala 6x1!', - body: 'Descanso é saúde pública.', - image: '/fundo.avif', - imageAlt: 'Jorge Solla durante atividade parlamentar', - }, - { - id: 'sus', - title: 'Pra valorizar o SUS', - body: 'O SUS é o sistema de saúde de todo brasileiro.', - image: '/53569851134_02afc18fb4_o.avif', - imageAlt: 'Jorge Solla em agenda de defesa do SUS', - }, -] - -const matchMediaMock = vi.fn() -const scrollToMock = vi.fn() - -beforeEach(() => { - matchMediaMock.mockImplementation((query: string) => ({ - matches: false, - media: query, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - })) - vi.stubGlobal('matchMedia', matchMediaMock) - Object.defineProperty(HTMLElement.prototype, 'scrollTo', { - configurable: true, - value: scrollToMock, - }) -}) - -afterEach(() => { - cleanup() - vi.clearAllMocks() - vi.useRealTimers() - vi.unstubAllGlobals() -}) - -describe('CampaignCarousel', () => { - it('sincroniza o chip ativo quando a pessoa escolhe uma bandeira', () => { - render(<CampaignCarousel ariaLabel="Bandeiras da campanha" items={flagItems} variant="flags" />) - - const healthChip = screen.getByRole('button', { name: 'Saúde' }) - const educationChip = screen.getByRole('button', { name: 'Educação' }) - expect(healthChip.getAttribute('aria-current')).toBe('true') - - fireEvent.click(educationChip) - fireEvent.scroll(screen.getByRole('list')) - - expect(healthChip.getAttribute('aria-current')).toBeNull() - expect(educationChip.getAttribute('aria-current')).toBe('true') - expect(scrollToMock).toHaveBeenCalledTimes(1) - }) - - it('avança automaticamente para o próximo card depois de quatro segundos', () => { - vi.useFakeTimers() - render( - <CampaignCarousel - ariaLabel="Por que essa eleição importa" - items={problemItems} - variant="problem" - />, - ) - - const secondCard = screen.getByRole('listitem', { name: '2 de 2' }) - expect(secondCard.getAttribute('aria-current')).toBeNull() - - act(() => { - vi.advanceTimersByTime(4_000) - }) - - expect(secondCard.getAttribute('aria-current')).toBe('true') - expect(scrollToMock).toHaveBeenCalledTimes(1) - }) - - it('pausa o avanço enquanto o ponteiro está sobre o carrossel', () => { - vi.useFakeTimers() - render( - <CampaignCarousel - ariaLabel="Por que essa eleição importa" - items={problemItems} - variant="problem" - />, - ) - - const carousel = screen.getByRole('region', { name: 'Por que essa eleição importa' }) - fireEvent.mouseEnter(carousel) - act(() => { - vi.advanceTimersByTime(8_000) - }) - - expect(screen.getByRole('listitem', { name: '1 de 2' }).getAttribute('aria-current')).toBe( - 'true', - ) - expect(scrollToMock).not.toHaveBeenCalled() - }) - - it('pausa o avanço quando o próprio carrossel recebe foco', () => { - vi.useFakeTimers() - render( - <CampaignCarousel - ariaLabel="Por que essa eleição importa" - items={problemItems} - variant="problem" - />, - ) - - const carousel = screen.getByRole('region', { name: 'Por que essa eleição importa' }) - fireEvent.focus(carousel) - act(() => { - vi.advanceTimersByTime(8_000) - }) - - expect(scrollToMock).not.toHaveBeenCalled() - }) - - it('não inicia o avanço automático quando a pessoa prefere movimento reduzido', () => { - vi.useFakeTimers() - matchMediaMock.mockImplementation((query: string) => ({ - matches: query.includes('prefers-reduced-motion'), - media: query, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - })) - - render( - <CampaignCarousel - ariaLabel="Por que essa eleição importa" - items={problemItems} - variant="problem" - />, - ) - act(() => { - vi.advanceTimersByTime(8_000) - }) - - expect(scrollToMock).not.toHaveBeenCalled() - }) -}) diff --git a/tests/unit/campaignFooter.unit.spec.tsx b/tests/unit/campaignFooter.unit.spec.tsx new file mode 100644 index 00000000..066f8463 --- /dev/null +++ b/tests/unit/campaignFooter.unit.spec.tsx @@ -0,0 +1,48 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { CampaignFooter } from '@/components/CampaignFooter' + +type MockImageProps = React.ImgHTMLAttributes<HTMLImageElement> & { priority?: boolean } + +vi.mock('next/image', () => ({ + default: ({ alt, priority: _priority, ...props }: MockImageProps) => ( + // eslint-disable-next-line @next/next/no-img-element + <img alt={alt} {...props} /> + ), +})) + +afterEach(cleanup) + +describe('CampaignFooter', () => { + it('é institucional: navegação, redes e assinatura, sem bloco eleitoral', () => { + const { container } = render(<CampaignFooter current="artigos" />) + + expect(screen.getByRole('link', { name: 'Artigos' }).getAttribute('aria-current')).toBe('page') + expect(screen.getByRole('link', { name: 'Fotos' }).getAttribute('href')).toBe('/fotos') + expect(screen.getByRole('link', { name: 'Privacidade' }).getAttribute('href')).toBe( + '/privacidade', + ) + expect(screen.getByRole('link', { name: 'Instagram' }).getAttribute('href')).toBe( + 'https://instagram.com/depjorgesolla', + ) + expect(screen.getByRole('link', { name: 'YouTube' }).getAttribute('href')).toBe( + 'https://youtube.com/@JorgeSollaDep', + ) + expect(screen.getByRole('link', { name: 'Facebook' }).getAttribute('href')).toBe( + 'https://facebook.com/depjorgesolla', + ) + expect(screen.getByText(/Deputado Federal · PT · Bahia/)).toBeTruthy() + expect(screen.getByText('Mais Saúde, Mais Futuro.')).toBeTruthy() + + expect(container.textContent).not.toContain('1313') + expect(container.textContent).not.toMatch(/CNPJ|1º turno|Quero apoiar|propaganda eleitoral/i) + }) + + it('marca Fotos como seção atual quando pedido', () => { + render(<CampaignFooter current="fotos" />) + + expect(screen.getByRole('link', { name: 'Fotos' }).getAttribute('aria-current')).toBe('page') + expect(screen.getByRole('link', { name: 'Artigos' }).getAttribute('aria-current')).toBeNull() + }) +}) diff --git a/tests/unit/campaignHero.unit.spec.tsx b/tests/unit/campaignHero.unit.spec.tsx index dd4f671c..9af0ab36 100644 --- a/tests/unit/campaignHero.unit.spec.tsx +++ b/tests/unit/campaignHero.unit.spec.tsx @@ -15,54 +15,44 @@ vi.mock('next/image', () => ({ afterEach(cleanup) describe('CampaignHero', () => { - it('apresenta a candidatura e mantém um único CTA primário', () => { + it('agradece os 152.049 votos e mantém um único CTA primário', () => { render(<CampaignHero />) - expect(screen.getByRole('heading', { level: 1, name: 'MAIS SAÚDE MAIS FUTURO' })).toBeTruthy() + expect( + screen.getByRole('heading', { level: 1, name: '152.049 votos de confiança' }), + ).toBeTruthy() + expect(screen.getByText('Obrigado, Bahia')).toBeTruthy() + expect( + screen.getByText( + /Obrigado a quem votou, a quem caminhou, a quem compartilhou e a quem acreditou\./, + ), + ).toBeTruthy() - const supportLink = screen.getByRole('link', { name: /Quero apoiar/i }) - expect(supportLink.getAttribute('href')).toBe('https://apoiar.me/jorgesolla') - expect(supportLink.getAttribute('data-cta')).toBe('primary') - - // S9 — the secondary CTA is the shortcut to the novidades capture; the - // bandeiras stay reachable through the footer link and the success card. - const novidadesLink = screen.getByRole('link', { name: /Receba novidades da campanha/i }) - expect(novidadesLink.getAttribute('href')).toBe('#novidades') - expect(novidadesLink.getAttribute('data-cta')).toBe('secondary') + const cta = screen.getByRole('link', { name: 'Acompanhe o mandato' }) + expect(cta.getAttribute('href')).toBe('/artigos') + expect(screen.getAllByRole('link')).toHaveLength(1) }) - it('não percent-encoda os caminhos das imagens', () => { + it('não exibe número eleitoral, pedido de voto nem a candidatura', () => { const { container } = render(<CampaignHero />) - const sources = Array.from(container.querySelectorAll('img')).map((image) => - image.getAttribute('src'), - ) - expect(sources.length).toBeGreaterThan(0) - - for (const source of sources) { - expect(source).not.toContain('%') - } + expect(container.textContent).not.toContain('1313') + expect(container.textContent).not.toMatch(/Quero apoiar|candidato|propaganda eleitoral/i) + expect(screen.queryByRole('link', { name: /Quero apoiar/i })).toBeNull() }) - it('serve masters WebP — AVIF fica fora do otimizador (libheif)', () => { + it('serve o retrato oficial com alt contextual e WebP', () => { const { container } = render(<CampaignHero />) - const sources = Array.from(container.querySelectorAll('img')).map( - (image) => image.getAttribute('src') ?? '', - ) - expect(sources.length).toBeGreaterThan(0) + const portrait = screen.getByAltText('Jorge Solla sorrindo, com blazer azul e camisa do PT') + expect(portrait.getAttribute('src')).toBe('/JOA00162.webp') + const sources = Array.from(container.querySelectorAll('img')) + .map((image) => image.getAttribute('src')) + .filter((source): source is string => source !== null) + expect(sources.length).toBeGreaterThan(0) for (const source of sources) { expect(source.endsWith('.webp'), source).toBe(true) } }) - - it('expõe as provas e as fotos com nomes acessíveis', () => { - render(<CampaignHero />) - - expect(screen.getByLabelText('DIAP entre os 40 melhores da Câmara')).toBeTruthy() - expect(screen.getByLabelText('Mais votado do PT-BA em 2022')).toBeTruthy() - expect(screen.getByRole('img', { name: /Jorge Solla 1313/i })).toBeTruthy() - expect(screen.getByAltText('Jorge Solla, candidato a deputado federal pela Bahia')).toBeTruthy() - }) }) diff --git a/tests/unit/campaignHome.unit.spec.tsx b/tests/unit/campaignHome.unit.spec.tsx index 20811a9d..fbbf2299 100644 --- a/tests/unit/campaignHome.unit.spec.tsx +++ b/tests/unit/campaignHome.unit.spec.tsx @@ -2,19 +2,29 @@ import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import HomePage from '@/app/(frontend)/(home)/page' -import { toContentPiecePublicItem, type ContentPiecePublicItem } from '@/lib/contentPieceCatalog' +import type { ArchivePhotoPublicItem } from '@/lib/archivePhotoPublicCatalog' import type { ShareLinkHomeSectionData } from '@/utilities/shareLinkReads' -const pieceItem = toContentPiecePublicItem({ - id: 1, - slug: 'peca-da-home', - title: 'Peça da home', - type: 'foto', - status: 'publicado', - origin: 'arquivo', - media: { id: 2, filename: 'peca.png', mimeType: 'image/png' }, +const photo = (id: number): ArchivePhotoPublicItem => ({ + id, + alt: `Foto aprovada ${id}`, + title: `Foto aprovada ${id}`, + takenOn: '2026-09-12', + dateLabel: '12 de setembro de 2026', + shortDateLabel: '12 set 2026', + municipalityName: 'Feira de Santana', + municipalitySlug: 'feira-de-santana', + scene: 'plenaria', + sceneLabel: 'Plenária', + people: [], + peopleLabel: null, + metaLabel: '12 set 2026 · Feira de Santana · Plenária', + searchText: `foto aprovada ${id}`, + thumbnailPath: `/fotos/${id}/midia?tamanho=grade`, + mediaPath: `/fotos/${id}/midia`, + downloadPath: `/fotos/${id}/midia?download=1`, + downloadFilename: `jorge-solla-foto-${id}.jpg`, }) -if (!pieceItem) throw new Error('fixture should be public') type MockImageProps = React.ImgHTMLAttributes<HTMLImageElement> & { fill?: boolean @@ -28,55 +38,23 @@ vi.mock('next/image', () => ({ ), })) -// The content section is an async server component that reads posts through -// the Payload DB; the unit env has no database and an async child suspends the -// whole tree under testing-library. Its empty/full behavior is covered by e2e -// (frontend.e2e.spec.ts), so the home skeleton tests render it away. +// The editorial board reads posts/feeds through the Payload DB; the unit env +// has no database and its empty/full behavior is e2e-covered. vi.mock('@/components/CampaignContentSection', () => ({ CampaignContentSection: () => null, })) -// The S9 capture form is a client component (radix comboboxes/checkbox) whose -// interactions are e2e-covered; the unit skeleton only pins the section shell. -vi.mock('@/components/CampaignNewsletterForm', () => ({ - CampaignNewsletterCapture: () => null, -})) - -// S10 — the section reads the site-settings global (server-only, Payload DB); -// the unit env has no database, and the pixel behavior is e2e-covered. -vi.mock('@/utilities/campaignHomeTracking', () => ({ - getCampaignHomeMetaPixelId: async () => null, -})) - -// S21/S22 — the home reads the jingle listing through the Payload DB + -// unstable_cache for the sound section and the footer discovery flag; the unit -// env has neither, and the real listing/kill-switch behavior is e2e-covered -// (frontendJingles.e2e.spec.ts). -vi.mock('@/utilities/jingleReads', () => ({ - getPublishedJingleItems: async () => [], -})) - -// S27/S39 — the same for the Central de Conteúdos listing (cached read that -// feeds the discovery flag and the home sample); the real kill-switch behavior -// is e2e-covered (frontendConteudos.e2e.spec.ts). -const contentPieces = vi.hoisted(() => ({ items: [] as ContentPiecePublicItem[] })) - -vi.mock('@/utilities/content/contentPieceReads', () => ({ - getPublishedContentPieceItems: async () => contentPieces.items, -})) - -// C233/C243 — the footer's "Fotos" discovery flag and the selfie-search section -// read the approved archive photos and the photoAlbum global through the -// Payload DB; the unit env has no database and the real gate is e2e-covered -// (frontendFotosSelfie.e2e.spec.ts). +// C233/C243 — the home reads the approved archive photos and the photoAlbum +// global through the Payload DB; the unit env has no database and the real +// gate is e2e-covered (frontendFotos.e2e.spec.ts). const homePhotoAlbum = vi.hoisted(() => ({ - hasPhotos: false, + photos: [] as ArchivePhotoPublicItem[], selfieSearchEnabled: false, published: true, })) vi.mock('@/utilities/archivePhotos/archivePhotoReads', () => ({ - hasPublishedArchivePhotos: async () => homePhotoAlbum.hasPhotos, + getApprovedArchivePhotoItems: async () => homePhotoAlbum.photos, })) vi.mock('@/utilities/globalReads', () => ({ @@ -97,20 +75,8 @@ vi.mock('@/utilities/shareLinkReads', () => ({ loadShareLinkHomeSection: async () => homeShareLink.section, })) -// S14 — the card section renders the client studio island (next/font local -// face + matchMedia + canvas); its behavior is e2e-covered, so the unit -// skeleton mocks both the face module and the island. -vi.mock('@/app/(frontend)/fonts', () => ({ - brexterBold: { variable: 'font-brexter-mock', style: { fontFamily: 'Brexter' } }, -})) - -vi.mock('@/components/cards/CardsStudio', () => ({ - CardsStudio: () => null, -})) - afterEach(() => { - contentPieces.items = [] - homePhotoAlbum.hasPhotos = false + homePhotoAlbum.photos = [] homePhotoAlbum.selfieSearchEnabled = false homePhotoAlbum.published = true homeShareLink.section = null @@ -118,65 +84,69 @@ afterEach(() => { }) describe('Campaign home', () => { - it('monta as seis seções previstas e o rodapé eleitoral', async () => { - render(await HomePage()) + it('abre no agradecimento e monta a casa do mandato', async () => { + const { container } = render(await HomePage()) - expect(screen.getByRole('heading', { level: 1, name: 'MAIS SAÚDE MAIS FUTURO' })).toBeTruthy() - expect(screen.getByText('3.333')).toBeTruthy() - expect(screen.getByText('1.031')).toBeTruthy() - expect(screen.getByText('3º')).toBeTruthy() - expect(screen.getByRole('heading', { name: /Eleger deputado é coisa séria/i })).toBeTruthy() expect( - screen.getByRole('heading', { - name: /Junto com o trabalhador e do lado de quem mais precisa, sempre/i, - }), + screen.getByRole('heading', { level: 1, name: '152.049 votos de confiança' }), ).toBeTruthy() - // S22 — with zero published jingles the sound section shows only the radio. - expect(screen.getByRole('heading', { name: 'Sintonize com a Rádio 1313' })).toBeTruthy() - expect(screen.getByRole('heading', { name: /Mostre que você está com Solla/i })).toBeTruthy() - expect(screen.getByRole('heading', { name: /Receba as novidades da campanha/i })).toBeTruthy() - expect(screen.getByText(/CNPJ: 68\.430\.467\/0001-05/)).toBeTruthy() + expect(screen.getByRole('heading', { name: 'Saúde pública como escolha de vida' })).toBeTruthy() + expect( + screen.getByRole('heading', { name: 'Compromissos que orientam o trabalho' }), + ).toBeTruthy() + expect(screen.getByRole('heading', { name: 'O mandato em fotos' })).toBeTruthy() + expect(screen.getByRole('heading', { name: 'Navegue' })).toBeTruthy() + + // The campaign sections are gone from the composition. + expect(screen.queryByText('Por que essa eleição importa')).toBeNull() + expect(screen.queryByText('A caminhada, em tempo real')).toBeNull() + expect(screen.queryByText(/Mostre que você está com Solla/i)).toBeNull() + expect(screen.queryByText(/Rádio 1313/i)).toBeNull() + expect(screen.queryByText(/Receba as novidades da campanha/i)).toBeNull() + + expect(container.textContent).not.toContain('1313') + expect(container.textContent).not.toMatch(/CNPJ|1º turno|Quero apoiar/i) }) - it('usa exatamente as seis bandeiras aprovadas', async () => { + it('usa as seis bandeiras do mandato', async () => { render(await HomePage()) - // S6: cada bandeira aparece em duas renderizações irmãs (grade desktop e - // carrossel mobile); o teste garante que as duas seguem a mesma copy. const expectedTitles = [ 'Defender o SUS e valorizar quem cuida', - 'Fim da escala 6×1 e jornada de 40h', - 'Educação em tempo integral e federais no interior', - 'Recomprar a Refinaria de Mataripe', - 'Salário mínimo forte, emprego e moradia', - 'Defesa intransigente da democracia', + 'Mais oportunidades no interior', + 'Crescimento com dignidade', + 'Participação e direitos', + 'A Bahia no centro das decisões', + 'Tempo para viver, renda para cuidar', ] for (const title of expectedTitles) { - expect(screen.getAllByText(title)).toHaveLength(2) + expect(screen.getByRole('heading', { name: title })).toBeTruthy() } }) - it('mostra a seção da Central com peça publicada e a esconde sem nenhuma (fail-closed)', async () => { - contentPieces.items = [pieceItem] - render(await HomePage()) + it('mostra o estado vazio do álbum sem nenhuma foto aprovada', async () => { + const { container } = render(await HomePage()) - expect(screen.getByRole('heading', { name: 'Peça voto pra Solla 1313' })).toBeTruthy() - // The single-piece layout renders one CTA per viewport (split desktop + - // stacked mobile); both hand off to the Central. - const ctas = screen.getAllByRole('link', { name: /Ver todas as peças/ }) - expect(ctas.length).toBeGreaterThan(0) - for (const cta of ctas) expect(cta.getAttribute('href')).toBe('/conteudos') + expect(screen.getByRole('heading', { name: 'O álbum está sendo preparado' })).toBeTruthy() + expect(container.querySelector('[data-home-section="photos"] a[href*="?foto="]')).toBeNull() + }) - cleanup() - contentPieces.items = [] - render(await HomePage()) + it('mostra as duas fotos aprovadas e o ladrilho de assinatura', async () => { + homePhotoAlbum.photos = [photo(10), photo(11)] + const { container } = render(await HomePage()) - expect(screen.queryByRole('heading', { name: 'Peça voto pra Solla 1313' })).toBeNull() - expect(screen.queryByRole('link', { name: /Ver todas as peças/ })).toBeNull() + const section = container.querySelector('[data-home-section="photos"]') + expect(section).not.toBeNull() + const links = section!.querySelectorAll('a[href*="?foto="]') + expect(links).toHaveLength(2) + expect(links[0]!.getAttribute('href')).toBe('/fotos?foto=10') + expect(links[1]!.getAttribute('href')).toBe('/fotos?foto=11') + expect(screen.getByText('Trabalho presente, memória viva.')).toBeTruthy() + expect(screen.getByRole('link', { name: 'Ver o álbum' }).getAttribute('href')).toBe('/fotos') }) it('mostra a seção da busca por selfie só quando a busca está aberta (C243, fail-closed)', async () => { - homePhotoAlbum.hasPhotos = true + homePhotoAlbum.photos = [photo(10)] homePhotoAlbum.selfieSearchEnabled = true render(await HomePage()) @@ -195,7 +165,7 @@ describe('Campaign home', () => { // Flag on but no approved photo is still closed: no dead CTA. homePhotoAlbum.selfieSearchEnabled = true - homePhotoAlbum.hasPhotos = false + homePhotoAlbum.photos = [] render(await HomePage()) expect(screen.queryByRole('heading', { name: 'Encontre você nas fotos' })).toBeNull() }) @@ -222,7 +192,6 @@ describe('Campaign home', () => { render(await HomePage()) expect(screen.getByRole('heading', { name: 'Plenária da Vitória' })).toBeTruthy() - // Pré-live: the S29 agenda is the only action — never an entry button. expect(screen.getByRole('button', { name: 'Adicionar à agenda' })).toBeTruthy() expect( screen.queryByRole('link', { name: /Entrar na plenária|Assistir no YouTube/ }), diff --git a/tests/unit/campaignMobileMenu.unit.spec.tsx b/tests/unit/campaignMobileMenu.unit.spec.tsx new file mode 100644 index 00000000..cc1554d7 --- /dev/null +++ b/tests/unit/campaignMobileMenu.unit.spec.tsx @@ -0,0 +1,75 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { CampaignMobileMenu } from '@/components/CampaignMobileMenu' + +afterEach(cleanup) + +const openMenu = () => { + const trigger = screen.getByRole('button', { name: 'Abrir menu' }) + fireEvent.click(trigger) + return trigger +} + +describe('CampaignMobileMenu', () => { + it('abre no trigger e move o foco para o primeiro link', () => { + render(<CampaignMobileMenu variant="home" />) + + const trigger = openMenu() + expect(trigger.getAttribute('aria-expanded')).toBe('true') + + const nav = screen.getByRole('navigation', { name: 'Navegação móvel' }) + expect(nav).toBeTruthy() + expect(screen.getByRole('link', { name: 'Artigos' }).getAttribute('href')).toBe('/artigos') + expect(screen.getByRole('link', { name: 'Bandeiras' }).getAttribute('href')).toBe('#bandeiras') + expect(screen.getByRole('link', { name: 'Fotos' }).getAttribute('href')).toBe('/fotos') + expect(screen.getByRole('link', { name: 'Início' }).getAttribute('href')).toBe('/') + expect(document.activeElement).toBe(screen.getByRole('link', { name: 'Artigos' })) + }) + + it('usa o hash absoluto da home no menu das páginas internas', () => { + render(<CampaignMobileMenu variant="internal" />) + + openMenu() + expect(screen.getByRole('link', { name: 'Bandeiras' }).getAttribute('href')).toBe('/#bandeiras') + }) + + it('fecha no Escape e devolve o foco ao trigger', () => { + render(<CampaignMobileMenu variant="internal" />) + + const trigger = openMenu() + fireEvent.keyDown(document, { key: 'Escape' }) + + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByRole('navigation', { name: 'Navegação móvel' })).toBeNull() + expect(document.activeElement).toBe(trigger) + }) + + it('fecha no clique fora e no X', () => { + render(<CampaignMobileMenu variant="home" />) + + openMenu() + fireEvent.pointerDown(document.body) + expect(screen.queryByRole('navigation', { name: 'Navegação móvel' })).toBeNull() + + openMenu() + fireEvent.click(screen.getByRole('button', { name: 'Fechar painel' })) + expect(screen.queryByRole('navigation', { name: 'Navegação móvel' })).toBeNull() + }) + + it('fecha ao escolher um link', () => { + render(<CampaignMobileMenu variant="home" />) + + openMenu() + fireEvent.click(screen.getByRole('link', { name: 'Artigos' })) + expect(screen.queryByRole('navigation', { name: 'Navegação móvel' })).toBeNull() + }) + + it('marca a seção atual no menu móvel', () => { + render(<CampaignMobileMenu variant="internal" current="fotos" />) + + openMenu() + expect(screen.getByRole('link', { name: 'Fotos' }).getAttribute('aria-current')).toBe('page') + expect(screen.getByRole('link', { name: 'Artigos' }).getAttribute('aria-current')).toBeNull() + }) +}) diff --git a/tests/unit/campaignNewsletterLead.unit.spec.tsx b/tests/unit/campaignNewsletterLead.unit.spec.tsx deleted file mode 100644 index 318180de..00000000 --- a/tests/unit/campaignNewsletterLead.unit.spec.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' - -import { CampaignNewsletterCapture } from '@/components/CampaignNewsletterForm' - -/** - * S10 — the `Lead` wiring of the S9 capture form: a successful submission - * with a configured pixel fires exactly one `trackMetaLead` (content_name - * `novidades-da-campanha`, UUID eventID); without a pixel, or when the - * submission fails, nothing fires. The e2e covers the base-code render; the - * real submission stays out of e2e on purpose (the `campanha-novidades` - * consent key is shared with the parallel `campaignNewsletter` spec). - */ -const submitCampaignNewsletterMock = vi.fn() -const trackMetaLeadMock = vi.fn() - -vi.mock('@/app/(frontend)/actions/submitCampaignNewsletter', () => ({ - submitCampaignNewsletter: (...args: unknown[]) => submitCampaignNewsletterMock(...args), -})) - -vi.mock('@/lib/facebookPixel', () => ({ - trackMetaLead: (...args: unknown[]) => trackMetaLeadMock(...args), -})) - -const PIXEL_ID = '123456789012345' - -beforeAll(() => { - // Radix comboboxes (StateSelect/CitySelect) need ResizeObserver in jsdom. - vi.stubGlobal( - 'ResizeObserver', - class { - observe() {} - unobserve() {} - disconnect() {} - }, - ) -}) - -const fillAndSubmit = async () => { - // FormInput propagates to react-hook-form through the native `input` event. - fireEvent.input(screen.getByPlaceholderText('Nome completo*'), { - target: { value: 'Maria Teste' }, - }) - fireEvent.input(screen.getByPlaceholderText('WhatsApp (com DDD)*'), { - target: { value: '71987654321' }, - }) - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'QUERO RECEBER NOVIDADES' })) - }) -} - -describe('Campaign newsletter Lead wiring', () => { - beforeEach(() => { - submitCampaignNewsletterMock.mockReset() - trackMetaLeadMock.mockReset() - submitCampaignNewsletterMock.mockResolvedValue(undefined) - }) - - afterEach(() => { - cleanup() - }) - - it('fires exactly one Lead on a successful capture with a configured pixel', async () => { - render(<CampaignNewsletterCapture pixelId={PIXEL_ID} />) - await fillAndSubmit() - - await waitFor(() => { - expect(screen.getByRole('heading', { name: 'Inscrição confirmada' })).toBeTruthy() - }) - expect(trackMetaLeadMock).toHaveBeenCalledTimes(1) - expect(trackMetaLeadMock).toHaveBeenCalledWith( - PIXEL_ID, - 'novidades-da-campanha', - expect.any(String), - ) - }) - - it('never fires a Lead without a configured pixel', async () => { - render(<CampaignNewsletterCapture />) - await fillAndSubmit() - - await waitFor(() => { - expect(screen.getByRole('heading', { name: 'Inscrição confirmada' })).toBeTruthy() - }) - expect(trackMetaLeadMock).not.toHaveBeenCalled() - }) - - it('never fires a Lead when the capture fails', async () => { - submitCampaignNewsletterMock.mockRejectedValue(new Error('boom')) - render(<CampaignNewsletterCapture pixelId={PIXEL_ID} />) - await fillAndSubmit() - - await waitFor(() => { - expect(screen.getByText('Falha ao enviar. Tente novamente.')).toBeTruthy() - }) - expect(trackMetaLeadMock).not.toHaveBeenCalled() - }) -}) diff --git a/tests/unit/campaignPageHeader.unit.spec.tsx b/tests/unit/campaignPageHeader.unit.spec.tsx new file mode 100644 index 00000000..0bf83c76 --- /dev/null +++ b/tests/unit/campaignPageHeader.unit.spec.tsx @@ -0,0 +1,52 @@ +import { cleanup, render, screen, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { CampaignPageHeader } from '@/components/CampaignPageHeader' + +type MockImageProps = React.ImgHTMLAttributes<HTMLImageElement> & { priority?: boolean } + +vi.mock('next/image', () => ({ + default: ({ alt, priority: _priority, ...props }: MockImageProps) => ( + // eslint-disable-next-line @next/next/no-img-element + <img alt={alt} {...props} /> + ), +})) + +afterEach(cleanup) + +describe('CampaignPageHeader', () => { + it('home: lockup negativo, nav Mandato/Bandeiras/Fotos e nenhum 1313', () => { + render(<CampaignPageHeader variant="home" />) + + const nav = screen.getByRole('navigation', { name: 'Navegação principal' }) + expect(within(nav).getByRole('link', { name: 'Mandato' }).getAttribute('href')).toBe('/artigos') + expect(within(nav).getByRole('link', { name: 'Bandeiras' }).getAttribute('href')).toBe( + '#bandeiras', + ) + expect(within(nav).getByRole('link', { name: 'Fotos' }).getAttribute('href')).toBe('/fotos') + expect(screen.getByAltText('Jorge Solla — Deputado Federal').getAttribute('src')).toContain( + 'jorge-solla-negativo.png', + ) + expect(screen.queryByText(/1313/)).toBeNull() + }) + + it('interna: lockup positivo, nav com Início e marca a seção atual', () => { + render(<CampaignPageHeader current="fotos" />) + + const nav = screen.getByRole('navigation', { name: 'Navegação principal' }) + expect(within(nav).getByRole('link', { name: 'Artigos' }).getAttribute('href')).toBe('/artigos') + expect(within(nav).getByRole('link', { name: 'Fotos' }).getAttribute('aria-current')).toBe( + 'page', + ) + expect(within(nav).getByRole('link', { name: 'Início' }).getAttribute('href')).toBe('/') + expect(screen.getByAltText('Jorge Solla — Deputado Federal').getAttribute('src')).toContain( + 'jorge-solla-positivo.png', + ) + }) + + it('mostra o badge de estado quando recebido', () => { + render(<CampaignPageHeader badge="Ao vivo" />) + + expect(screen.getByText('Ao vivo')).toBeTruthy() + }) +}) diff --git a/tests/unit/cardColinha.unit.spec.ts b/tests/unit/cardColinha.unit.spec.ts deleted file mode 100644 index 052caa02..00000000 --- a/tests/unit/cardColinha.unit.spec.ts +++ /dev/null @@ -1,161 +0,0 @@ -// @vitest-environment node - -import { createHash } from 'node:crypto' -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' - -import sharp from 'sharp' -import { describe, expect, it } from 'vitest' - -import { - COLINHA_DIGIT_FONT_FAMILY, - COLINHA_ESTADUAL_LABEL, - COLINHA_FONT_FAMILY, - COLINHA_ROW_LAYOUT, - fitColinhaRowName, -} from '@/lib/cardColinha' -import { stateDeputyCatalog } from '@/lib/stateDeputyCatalog' - -const publicFile = (src: string) => fileURLToPath(new URL(`../../public${src}`, import.meta.url)) - -const APPROVED_TILE_SRC = fileURLToPath( - new URL('../../docs/plans/cards-colinha-ui-design-assets/modelo-colinha.jpeg', import.meta.url), -) - -/** Frozen identity of the approved model art (byte-for-byte copy of the tile). */ -const TILE_SHA256 = '76505492fb243c67313d237e58926afbd4d0847c00ab3bb43c090859cbdf18c8' - -describe('colinha estadual row layout (S34 — measured from the design gate)', () => { - it('resolves the gate percentages against the 1080×1920 output (the 1.2× of the art)', () => { - const { mask, copy, office, name, gap, digit } = COLINHA_ROW_LAYOUT - - expect(mask.fill).toBe('#ffffff') - expect(mask.x).toBeCloseTo(0.245 * 1080, 5) - expect(mask.y).toBeCloseTo(0.559 * 1920, 5) - expect(mask.width).toBeCloseTo(0.574 * 1080, 5) - expect(mask.height).toBeCloseTo(0.029 * 1920, 5) - - expect(copy.left).toBeCloseTo(0.269 * 1080, 5) - expect(copy.top).toBeCloseTo(0.5646 * 1920, 2) - expect(copy.width).toBeCloseTo(0.528 * 1080, 5) - expect(copy.right).toBeCloseTo(copy.left + copy.width, 5) - - // The label is sized to the art's printed cap height (19.2px), not to the - // design gate's 2.15cqw — designer critique (c) adjustment. - expect(office.fontSize).toBeCloseTo(26.9, 5) - expect(name.fontSize).toBeCloseTo(0.034 * 1080, 5) - expect(gap).toBeCloseTo(0.015 * 1080, 5) - - expect(digit.left).toBeCloseTo(0.259 * 1080, 5) - expect(digit.step).toBeCloseTo(0.069 * 1080, 5) - expect(digit.width).toBeCloseTo(0.061 * 1080, 5) - expect(digit.height).toBeCloseTo(0.0435 * 1920, 5) - expect(digit.top).toBeCloseTo(0.584 * 1920, 5) - expect(digit.fontSize).toBeCloseTo(0.0425 * 1080, 5) - }) - - it('pins the mask, the copy band and the row typography of the gate', () => { - expect(COLINHA_ROW_LAYOUT.mask).toEqual({ - x: 264.6, - y: 1073.28, - width: 619.92, - height: 55.68, - fill: '#ffffff', - }) - expect(COLINHA_ROW_LAYOUT.copy).toEqual({ - left: 290.52, - top: 1084.03, - width: 570.24, - right: 860.76, - }) - expect(COLINHA_ROW_LAYOUT.office).toEqual({ - fontSize: 26.9, - weight: 400, - color: '#202020', - }) - expect(COLINHA_ROW_LAYOUT.name).toEqual({ - fontSize: 36.72, - weight: 900, - color: '#e4102f', - letterSpacingEm: -0.04, - }) - expect(COLINHA_ROW_LAYOUT.digit).toEqual({ - left: 279.72, - step: 74.52, - width: 65.88, - height: 83.52, - top: 1121.28, - fontSize: 45.9, - weight: 900, - color: '#171717', - }) - }) - - it('keeps the label literal and the gate font stacks (NEEDS ASSET proof on the PR)', () => { - expect(COLINHA_ESTADUAL_LABEL).toBe('DEPUTADO ESTADUAL') - expect(COLINHA_FONT_FAMILY).toBe('Arial, Helvetica, sans-serif') - expect(COLINHA_DIGIT_FONT_FAMILY).toBe('Arial Black, Arial, Helvetica, sans-serif') - }) -}) - -describe('fitColinhaRowName (S34)', () => { - // Same conservative face as the render unit fake: 0.84em per char, above the - // real Arial/Arial Black uppercase average. - const measure = (text: string, fontSize: number) => ({ width: text.length * fontSize * 0.84 }) - const officeWidth = measure(COLINHA_ESTADUAL_LABEL, COLINHA_ROW_LAYOUT.office.fontSize).width - const pairMaxWidth = COLINHA_ROW_LAYOUT.copy.right - COLINHA_ROW_LAYOUT.mask.x - const nameMaxWidth = pairMaxWidth - officeWidth - COLINHA_ROW_LAYOUT.gap - - it('keeps the ideal size on one line while the name fits the mask band', () => { - expect(fitColinhaRowName('JACÓ', measure, nameMaxWidth)).toEqual({ - fontSize: COLINHA_ROW_LAYOUT.name.fontSize, - text: 'JACÓ', - }) - }) - - it('shrinks the canonical name on one line instead of wrapping or cutting it', () => { - const fit = fitColinhaRowName('JULIO PINHEIRO', measure, nameMaxWidth) - - expect(fit.fontSize).toBeLessThan(COLINHA_ROW_LAYOUT.name.fontSize) - expect(fit.text).toBe('JULIO PINHEIRO') - expect(measure(fit.text, fit.fontSize).width).toBeLessThanOrEqual(nameMaxWidth) - }) - - it('shrinks a long name on one line instead of wrapping or cutting it', () => { - const fit = fitColinhaRowName('ARTUR BARACHISIO LISBÔA', measure, nameMaxWidth) - - expect(fit.fontSize).toBeLessThan(COLINHA_ROW_LAYOUT.name.fontSize) - expect(fit.text).toBe('ARTUR BARACHISIO LISBÔA') - expect(measure(fit.text, fit.fontSize).width).toBeLessThanOrEqual(nameMaxWidth) - }) - - it('keeps the right-aligned pair (label + gap + name) inside the mask for the whole catalog', () => { - for (const deputy of stateDeputyCatalog) { - const fit = fitColinhaRowName(deputy.name.toLocaleUpperCase('pt-BR'), measure, nameMaxWidth) - const pairWidth = officeWidth + COLINHA_ROW_LAYOUT.gap + measure(fit.text, fit.fontSize).width - - expect(fit.fontSize).toBeGreaterThan(0) - expect(fit.fontSize).toBeLessThanOrEqual(COLINHA_ROW_LAYOUT.name.fontSize) - expect(pairWidth).toBeLessThanOrEqual(pairMaxWidth) - // The pair starts at or after the mask's left edge. - expect(COLINHA_ROW_LAYOUT.copy.right - pairWidth).toBeGreaterThanOrEqual( - COLINHA_ROW_LAYOUT.mask.x, - ) - } - }) -}) - -describe('colinha tile asset (S34)', () => { - it('ships the exact approved model art (byte-for-byte, 900×1600)', async () => { - const shipped = readFileSync(publicFile('/cards/modelo-colinha.jpeg')) - const approved = readFileSync(APPROVED_TILE_SRC) - - expect(createHash('sha256').update(shipped).digest('hex')).toBe(TILE_SHA256) - expect(createHash('sha256').update(shipped).digest('hex')).toBe( - createHash('sha256').update(approved).digest('hex'), - ) - - const metadata = await sharp(publicFile('/cards/modelo-colinha.jpeg')).metadata() - expect(`${metadata.width}x${metadata.height}`).toBe('900x1600') - }) -}) diff --git a/tests/unit/cardModelTile.unit.spec.tsx b/tests/unit/cardModelTile.unit.spec.tsx deleted file mode 100644 index a61ed297..00000000 --- a/tests/unit/cardModelTile.unit.spec.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { cleanup, render } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { CardModelTile } from '@/components/cards/CardModelTile' -import { CARD_MODELS } from '@/lib/cardModels' - -type MockImageProps = React.ImgHTMLAttributes<HTMLImageElement> & { - fill?: boolean - priority?: boolean -} - -vi.mock('next/image', () => ({ - default: ({ alt, fill: _fill, priority: _priority, ...props }: MockImageProps) => ( - // eslint-disable-next-line @next/next/no-img-element - <img alt={alt} {...props} /> - ), -})) - -// The name-tile canvas draws through the real font/canvas pipeline (jsdom has -// no canvas); the tile geometry under test does not depend on it. -vi.mock('@/components/cards/CardNameTileCanvas', () => ({ - CardNameTileCanvas: () => null, -})) - -afterEach(cleanup) - -/** - * WebKit #265243: `aspect-ratio` on a flex item loses its height on relayout - * (fixed only in WebKit main, 2026-05). The model tile cannot depend on - * `aspect-ratio` for its box — the ratio must come from an in-flow spacer - * (percentage padding), which every engine resolves against the tile width. - */ -describe('CardModelTile — proporção do tile não usa aspect-ratio', () => { - it.each(CARD_MODELS.map((model) => [model.id, model] as const))( - 'tile %s tira a altura do espaçador de proporção', - (_id, model) => { - const { container } = render(<CardModelTile model={model} fontFamily="Brexter" />) - - const spacer = container.querySelector<HTMLElement>('[data-card-tile-ratio]') - expect(spacer).toBeTruthy() - - const ratio = Number(spacer!.dataset.cardTileRatio) - expect(ratio).toBeCloseTo(model.height / model.width, 6) - // The computed style serializes integer percentages without decimals. - expect(parseFloat(spacer!.style.paddingBottom)).toBeCloseTo(ratio * 100, 3) - - // The tile box (the spacer parent) must not carry the WebKit-fragile - // `aspect-ratio`; nothing inside the tile may either. - expect(spacer!.parentElement!.getAttribute('style') ?? '').not.toContain('aspect-ratio') - expect(container.innerHTML).not.toContain('aspect-ratio') - }, - ) -}) diff --git a/tests/unit/cardNameFit.unit.spec.ts b/tests/unit/cardNameFit.unit.spec.ts deleted file mode 100644 index 4c1a64a3..00000000 --- a/tests/unit/cardNameFit.unit.spec.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { NAME_CARD_SLOT, TEAM_CARD_NAME_SLOT } from '@/lib/cardModels' -import { - fitCardName, - normalizeCardName, - resolveFontSizeForCapHeight, - type CardMeasureText, -} from '@/lib/cardNameFit' - -/** Deterministic fake: 0.84em per char (Brexter-like), 0.72em cap, 0.2em descent. */ -const measure: CardMeasureText = (text, fontSize) => ({ - width: text.length * fontSize * 0.84, - actualBoundingBoxAscent: fontSize * 0.72, - actualBoundingBoxDescent: fontSize * 0.2, -}) - -const inkWidth = (text: string, fontSize: number) => measure(text, fontSize).width -const minFontSize = resolveFontSizeForCapHeight(measure, NAME_CARD_SLOT.minCapHeight) - -describe('normalizeCardName', () => { - it('trims, collapses spaces and uppercases with the pt-BR locale', () => { - expect(normalizeCardName(' joão conceição ')).toBe('JOÃO CONCEIÇÃO') - }) -}) - -describe('resolveFontSizeForCapHeight', () => { - it('calibrates the font size from the measured cap, not the raw cap value', () => { - expect(resolveFontSizeForCapHeight(measure, 106)).toBeCloseTo(106 / 0.72, 5) - }) - - it('falls back to the cap value when the probe has no ascent', () => { - const broken: CardMeasureText = () => ({ - width: 0, - actualBoundingBoxAscent: 0, - actualBoundingBoxDescent: 0, - }) - expect(resolveFontSizeForCapHeight(broken, 106)).toBe(106) - }) -}) - -describe('fitCardName', () => { - it('keeps the master cap height for a short name', () => { - const fit = fitCardName('João', measure) - - expect(fit).toMatchObject({ ok: true, lines: ['JOÃO'] }) - if (fit.ok) { - expect(fit.capHeight).toBeCloseTo(NAME_CARD_SLOT.capHeight, 5) - expect(inkWidth(fit.lines[0], fit.fontSize)).toBeLessThanOrEqual(NAME_CARD_SLOT.maxInkWidth) - expect(fit.inkWidth).toBeCloseTo(inkWidth(fit.lines[0], fit.fontSize), 5) - } - }) - - it('shrinks a long single line to the ink width instead of cutting it', () => { - const fit = fitCardName('Conceição', measure) - - expect(fit).toMatchObject({ ok: true, lines: ['CONCEIÇÃO'] }) - if (fit.ok) { - expect(fit.fontSize).toBeLessThan(resolveFontSizeForCapHeight(measure, 106)) - expect(inkWidth(fit.lines[0], fit.fontSize)).toBeLessThanOrEqual(NAME_CARD_SLOT.maxInkWidth) - expect(fit.fontSize).toBeGreaterThanOrEqual(minFontSize) - } - }) - - it('wraps into two balanced lines when single-line would fall under the minimum', () => { - const fit = fitCardName('Jorge Solla da Bahia', measure) - - expect(fit.ok).toBe(true) - if (fit.ok) { - expect(fit.lines).toEqual(['JORGE SOLLA', 'DA BAHIA']) - expect(fit.fontSize).toBeGreaterThanOrEqual(minFontSize) - for (const line of fit.lines) { - expect(inkWidth(line, fit.fontSize)).toBeLessThanOrEqual(NAME_CARD_SLOT.maxInkWidth) - } - const blockHeight = fit.lineHeight + fit.capHeight - expect(blockHeight).toBeLessThanOrEqual(NAME_CARD_SLOT.maxBlockBottom - NAME_CARD_SLOT.capTop) - } - }) - - it('does not split a single word in the middle', () => { - const fit = fitCardName('Bartolomeucostajunior', measure) - - expect(fit).toEqual({ ok: false, reason: 'too-long' }) - }) - - it('fails closed for an empty name', () => { - expect(fitCardName(' ', measure)).toEqual({ ok: false, reason: 'empty' }) - }) - - it('fails closed when shrinking cannot reach the ink width (adversarial metrics)', () => { - const rigid: CardMeasureText = (_text, fontSize) => ({ - width: 5000, - actualBoundingBoxAscent: fontSize * 0.72, - actualBoundingBoxDescent: fontSize * 0.2, - }) - const rigidCase = fitCardName('João', rigid) - - expect(rigidCase.ok).toBe(false) - if (!rigidCase.ok) expect(rigidCase.reason).toBe('too-long') - }) - - it('fails closed when the shrink factor collapses to zero', () => { - const absurd: CardMeasureText = (_text, fontSize) => ({ - width: 1_000_000_000, - actualBoundingBoxAscent: fontSize * 0.72, - actualBoundingBoxDescent: fontSize * 0.2, - }) - const absurdCase = fitCardName('João', absurd) - - expect(absurdCase.ok).toBe(false) - if (!absurdCase.ok) expect(absurdCase.reason).toBe('too-long') - }) - - it('preserves Portuguese accents in the fitted lines', () => { - const fit = fitCardName('Coração', measure) - - expect(fit.ok).toBe(true) - if (fit.ok) expect(fit.lines[0]).toBe('CORAÇÃO') - }) - - describe('team slot (single line, centered)', () => { - it('keeps the measured cap for a short name and never wraps', () => { - const fit = fitCardName('Ana', measure, TEAM_CARD_NAME_SLOT) - - expect(fit).toMatchObject({ ok: true, lines: ['ANA'] }) - if (fit.ok) { - expect(fit.capHeight).toBeCloseTo(TEAM_CARD_NAME_SLOT.capHeight, 5) - expect(inkWidth(fit.lines[0], fit.fontSize)).toBeLessThanOrEqual( - TEAM_CARD_NAME_SLOT.maxInkWidth, - ) - expect(fit.inkWidth).toBeCloseTo(inkWidth(fit.lines[0], fit.fontSize), 5) - } - }) - - it('shrinks a name that overflows the ink width down to the readable minimum', () => { - const fit = fitCardName('Guilherme', measure, TEAM_CARD_NAME_SLOT) - - expect(fit).toMatchObject({ ok: true, lines: ['GUILHERME'] }) - if (fit.ok) { - expect(fit.capHeight).toBeLessThan(TEAM_CARD_NAME_SLOT.capHeight) - expect(fit.fontSize).toBeGreaterThanOrEqual( - resolveFontSizeForCapHeight(measure, TEAM_CARD_NAME_SLOT.minCapHeight), - ) - expect(inkWidth(fit.lines[0], fit.fontSize)).toBeLessThanOrEqual( - TEAM_CARD_NAME_SLOT.maxInkWidth, - ) - } - }) - - it('fits a long compound name within the new ceiling (S16)', () => { - const fit = fitCardName('Maria da Conceição', measure, TEAM_CARD_NAME_SLOT) - - expect(fit).toMatchObject({ ok: true, lines: ['MARIA DA CONCEIÇÃO'] }) - if (fit.ok) { - expect(fit.capHeight).toBeGreaterThanOrEqual(TEAM_CARD_NAME_SLOT.minCapHeight) - expect(fit.inkWidth).toBeLessThanOrEqual(TEAM_CARD_NAME_SLOT.maxInkWidth) - expect(fit.inkWidth).toBeCloseTo(inkWidth(fit.lines[0], fit.fontSize), 5) - } - }) - - it('fails with too-long instead of wrapping below the readable minimum', () => { - expect(fitCardName('Bartolomeucostajunior', measure, TEAM_CARD_NAME_SLOT)).toEqual({ - ok: false, - reason: 'too-long', - }) - expect(fitCardName('Anticonstitucionalissimamente', measure, TEAM_CARD_NAME_SLOT)).toEqual({ - ok: false, - reason: 'too-long', - }) - }) - }) -}) diff --git a/tests/unit/cardPhotoHarmony.unit.spec.ts b/tests/unit/cardPhotoHarmony.unit.spec.ts deleted file mode 100644 index 9413d521..00000000 --- a/tests/unit/cardPhotoHarmony.unit.spec.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { - CARD_HARMONY_IDENTITY_EPSILON, - CARD_HARMONY_LIMITS, - applyCardPhotoHarmony, - computeCardHarmonyAdjustment, - isCardHarmonyIdentity, - measureCardPhotoTone, - type CardHarmonyAdjustment, - type CardPhotoTone, -} from '@/lib/cardPhotoHarmony' -import { CARD_CUTOUT_ALPHA_THRESHOLD } from '@/lib/cardPhotoTransform' - -type Rgba = readonly [number, number, number, number?] - -const pixelsOf = (colors: ReadonlyArray<Rgba>) => - new Uint8ClampedArray( - colors.flatMap(([red, green, blue, alpha = 255]) => [red, green, blue, alpha]), - ) - -const toneOf = (overrides: Partial<CardPhotoTone> = {}): CardPhotoTone => ({ - meanLuma: 0.5, - sdLuma: 0.2, - meanSaturation: 0.4, - samples: 1, - ...overrides, -}) - -const IDENTITY: CardHarmonyAdjustment = { brightness: 1, contrast: 1, saturation: 1 } - -describe('measureCardPhotoTone', () => { - it('measures Rec.709 luma and HSV saturation of an opaque color', () => { - const tone = measureCardPhotoTone(pixelsOf([[30, 120, 200]])) - - expect(tone.samples).toBe(1) - expect(tone.meanLuma).toBeCloseTo(0.4182, 4) - expect(tone.sdLuma).toBe(0) - expect(tone.meanSaturation).toBeCloseTo(0.85, 5) - }) - - it('ignores pixels at or below the alpha threshold (matches the cutout bbox)', () => { - const tone = measureCardPhotoTone( - pixelsOf([ - [255, 255, 255, 0], - [255, 255, 255, CARD_CUTOUT_ALPHA_THRESHOLD], - [0, 0, 0, CARD_CUTOUT_ALPHA_THRESHOLD + 1], - ]), - ) - - expect(tone.samples).toBe(1) - expect(tone.meanLuma).toBe(0) - }) - - it('reports an empty sample instead of black when nothing is opaque', () => { - const tone = measureCardPhotoTone(pixelsOf([[10, 20, 30, 0]])) - - expect(tone).toEqual({ meanLuma: 0, sdLuma: 0, meanSaturation: 0, samples: 0 }) - }) - - it('measures the population spread of a two-tone sample', () => { - const tone = measureCardPhotoTone( - pixelsOf([ - [0, 0, 0], - [255, 255, 255], - ]), - ) - - expect(tone.samples).toBe(2) - expect(tone.meanLuma).toBeCloseTo(0.5, 5) - expect(tone.sdLuma).toBeCloseTo(0.5, 5) - expect(tone.meanSaturation).toBe(0) - }) -}) - -describe('computeCardHarmonyAdjustment', () => { - it('is identity when the photo already matches the reference tone', () => { - const tone = toneOf() - const adjustment = computeCardHarmonyAdjustment(tone, tone) - - expect(adjustment).toEqual(IDENTITY) - expect(isCardHarmonyIdentity(adjustment)).toBe(true) - }) - - it('raises brightness toward a brighter reference, capped by the fixed limit', () => { - const adjustment = computeCardHarmonyAdjustment( - toneOf({ meanLuma: 0.4 }), - toneOf({ meanLuma: 0.8 }), - ) - - expect(adjustment.brightness).toBe(CARD_HARMONY_LIMITS.brightness.max) - expect(adjustment.contrast).toBe(1) - expect(adjustment.saturation).toBe(1) - }) - - it('moves saturation toward a desaturated reference, floored by the fixed limit', () => { - const adjustment = computeCardHarmonyAdjustment( - toneOf({ meanSaturation: 0.5 }), - toneOf({ meanSaturation: 0.1 }), - ) - - expect(adjustment.saturation).toBe(CARD_HARMONY_LIMITS.saturation.min) - }) - - it('moves contrast toward a flatter reference, floored by the fixed limit', () => { - const adjustment = computeCardHarmonyAdjustment( - toneOf({ sdLuma: 0.3 }), - toneOf({ sdLuma: 0.1 }), - ) - - expect(adjustment.contrast).toBe(CARD_HARMONY_LIMITS.contrast.min) - }) - - it('keeps the factor at 1 for a degenerate source stat instead of amplifying noise', () => { - const adjustment = computeCardHarmonyAdjustment( - toneOf({ sdLuma: 0, meanSaturation: 0 }), - toneOf({ sdLuma: 0.3, meanSaturation: 0.5 }), - ) - - expect(adjustment.contrast).toBe(1) - expect(adjustment.saturation).toBe(1) - }) - - it('is identity when either sample is empty', () => { - expect(computeCardHarmonyAdjustment(toneOf({ samples: 0 }), toneOf())).toEqual(IDENTITY) - expect(computeCardHarmonyAdjustment(toneOf(), toneOf({ samples: 0 }))).toEqual(IDENTITY) - }) - - it('falls back to 1 for non-finite stats', () => { - const adjustment = computeCardHarmonyAdjustment( - toneOf({ meanLuma: Number.NaN }), - toneOf({ meanLuma: Number.NaN, sdLuma: Number.POSITIVE_INFINITY }), - ) - - expect(adjustment).toEqual(IDENTITY) - }) -}) - -describe('isCardHarmonyIdentity', () => { - it('accepts the epsilon band on both sides', () => { - const inside = CARD_HARMONY_IDENTITY_EPSILON - 0.001 - expect( - isCardHarmonyIdentity({ - brightness: 1 + inside, - contrast: 1 - inside, - saturation: 1, - }), - ).toBe(true) - }) - - it('rejects factors outside the epsilon band', () => { - const outside = CARD_HARMONY_IDENTITY_EPSILON + 0.001 - expect(isCardHarmonyIdentity({ ...IDENTITY, brightness: 1 + outside })).toBe(false) - expect(isCardHarmonyIdentity({ ...IDENTITY, saturation: 1 - outside })).toBe(false) - }) -}) - -describe('applyCardPhotoHarmony', () => { - it('copies alpha byte-for-byte and never mutates the input buffer', () => { - const source = pixelsOf([ - [10, 20, 30, 7], - [40, 50, 60, 200], - ]) - const before = Uint8ClampedArray.from(source) - - const result = applyCardPhotoHarmony(source, { - brightness: 1.08, - contrast: 1, - saturation: 1.1, - }) - - expect(source).toEqual(before) - expect(result).not.toBe(source) - expect(result[3]).toBe(7) - expect(result[7]).toBe(200) - }) - - it('returns the same color for an identity adjustment', () => { - const source = pixelsOf([ - [11, 22, 33], - [200, 100, 50], - ]) - - expect([...applyCardPhotoHarmony(source, IDENTITY)]).toEqual([...source]) - }) - - it('multiplies every channel by the brightness factor', () => { - const result = applyCardPhotoHarmony(pixelsOf([[100, 100, 100]]), { - ...IDENTITY, - brightness: 1.08, - }) - - expect([...result]).toEqual([108, 108, 108, 255]) - }) - - it('pushes values away from the mid range with the contrast factor', () => { - const result = applyCardPhotoHarmony( - pixelsOf([ - [0, 0, 0], - [255, 255, 255], - ]), - { ...IDENTITY, contrast: 1.08 }, - ) - - expect([...result]).toEqual([0, 0, 0, 255, 255, 255, 255, 255]) - }) - - it('collapses color into luma when saturation goes to zero', () => { - const result = applyCardPhotoHarmony(pixelsOf([[200, 100, 50]]), { - ...IDENTITY, - saturation: 0, - }) - - expect([...result]).toEqual([118, 118, 118, 255]) - }) - - it('spreads the channels apart when saturation goes up', () => { - const source = pixelsOf([[200, 100, 50]]) - const saturated = applyCardPhotoHarmony(source, { ...IDENTITY, saturation: 1.2 }) - - expect(saturated[0] - saturated[2]).toBeGreaterThan(source[0] - source[2]) - expect(saturated[1]).toBeLessThan(source[1]) - }) - - it('keeps a neutral gray neutral whatever the saturation', () => { - const result = applyCardPhotoHarmony(pixelsOf([[128, 128, 128]]), { - ...IDENTITY, - saturation: 1.12, - }) - - expect(result[0]).toBe(result[1]) - expect(result[1]).toBe(result[2]) - }) - - it('clamps at both ends without wrapping', () => { - const result = applyCardPhotoHarmony( - pixelsOf([ - [250, 250, 250], - [2, 2, 2], - ]), - { ...IDENTITY, brightness: 1.08, contrast: 1.08 }, - ) - - expect([...result].slice(0, 7)).toEqual([255, 255, 255, 255, 0, 0, 0]) - }) - - it('is deterministic across runs', () => { - const source = pixelsOf([[123, 45, 210, 128]]) - const adjustment = { brightness: 1.04, contrast: 0.96, saturation: 1.08 } - - expect([...applyCardPhotoHarmony(source, adjustment)]).toEqual([ - ...applyCardPhotoHarmony(source, adjustment), - ]) - }) -}) diff --git a/tests/unit/cardRender.unit.spec.ts b/tests/unit/cardRender.unit.spec.ts deleted file mode 100644 index 3c4ad8b8..00000000 --- a/tests/unit/cardRender.unit.spec.ts +++ /dev/null @@ -1,554 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { COLINHA_ESTADUAL_LABEL, COLINHA_ROW_LAYOUT } from '@/lib/cardColinha' -import { - NAME_CARD_SLOT, - TEAM_CARD_LABEL, - TEAM_CARD_NAME_BANNER, - TEAM_CARD_NAME_SLOT, - getCardModel, - type CardRect, -} from '@/lib/cardModels' -import { fitCardName } from '@/lib/cardNameFit' -import { - centerCardPhotoTransform, - frameCardPhotoOnBbox, - type CardPhotoSize, -} from '@/lib/cardPhotoTransform' -import { - createCardMeasure, - drawCardName, - renderColinhaCard, - renderNameCard, - renderPhotoCard, - renderTeamCard, - resolveCardBannerWidth, - type CardDrawContext, -} from '@/lib/cardRender' -import { getStateDeputyCard, type StateDeputyCatalogEntry } from '@/lib/stateDeputyCatalog' - -type DrawCall = { image: unknown; dx: number; dy: number; dw: number; dh: number } -type TextCall = { text: string; x: number; y: number } -type TextState = { align: CanvasTextAlign; fill: string; font: string; letterSpacing: string } -type RectCall = { x: number; y: number; width: number; height: number } -type OpCall = { op: string; args: number[] } - -const fontSizeFrom = (font: string): number => { - const match = /(\d+(?:\.\d+)?)px/.exec(font) - return match ? Number(match[1]) : 0 -} - -const createFakeContext = () => { - const drawCalls: DrawCall[] = [] - const textCalls: TextCall[] = [] - const textStates: TextState[] = [] - const rectCalls: RectCall[] = [] - const ops: OpCall[] = [] - - const ctx: CardDrawContext = { - font: '', - letterSpacing: '', - globalAlpha: 1, - fillStyle: '', - textAlign: 'start', - textBaseline: 'alphabetic', - imageSmoothingEnabled: false, - imageSmoothingQuality: 'low', - drawImage: (image, dx, dy, dw, dh) => { - drawCalls.push({ image, dx, dy, dw, dh }) - }, - fillRect: (x, y, width, height) => { - rectCalls.push({ x, y, width, height }) - }, - fillText: (text, x, y) => { - textCalls.push({ text, x, y }) - textStates.push({ - align: ctx.textAlign, - fill: typeof ctx.fillStyle === 'string' ? ctx.fillStyle : '', - font: ctx.font, - letterSpacing: ctx.letterSpacing, - }) - }, - measureText: (text) => { - const size = fontSizeFrom(ctx.font) - return { - width: text.length * size * 0.84, - actualBoundingBoxAscent: size * 0.72, - actualBoundingBoxDescent: size * 0.2, - } - }, - save: () => { - ops.push({ op: 'save', args: [] }) - }, - restore: () => { - ops.push({ op: 'restore', args: [] }) - }, - translate: (x, y) => { - ops.push({ op: 'translate', args: [x, y] }) - }, - rotate: (angle) => { - ops.push({ op: 'rotate', args: [angle] }) - }, - beginPath: () => { - ops.push({ op: 'beginPath', args: [] }) - }, - arc: (x, y, radius, startAngle, endAngle) => { - ops.push({ op: 'arc', args: [x, y, radius, startAngle, endAngle] }) - }, - ellipse: (x, y, radiusX, radiusY, rotation, startAngle, endAngle) => { - ops.push({ op: 'ellipse', args: [x, y, radiusX, radiusY, rotation, startAngle, endAngle] }) - }, - rect: (x, y, width, height) => { - ops.push({ op: 'rect', args: [x, y, width, height] }) - }, - roundRect: (x, y, width, height, radii) => { - ops.push({ - op: 'roundRect', - args: [x, y, width, height, typeof radii === 'number' ? radii : -1], - }) - }, - clip: () => { - ops.push({ op: 'clip', args: [] }) - }, - createLinearGradient: (x0, y0, x1, y1) => { - ops.push({ op: 'createLinearGradient', args: [x0, y0, x1, y1] }) - return { - addColorStop: (offset: number) => { - ops.push({ op: 'addColorStop', args: [offset] }) - }, - } - }, - fill: () => { - ops.push({ op: 'fill', args: [] }) - }, - } - - return { ctx, drawCalls, textCalls, textStates, rectCalls, ops } -} - -const nameModel = getCardModel('eu-sou-solla')! -const squareModel = getCardModel('perfil-quadrado')! -const teamModel = getCardModel('time-de-voce')! - -describe('renderNameCard', () => { - it('draws the master base first and then the name on the left border', () => { - const { ctx, drawCalls, textCalls } = createFakeContext() - const image = { id: 'base' } as unknown as CanvasImageSource - const fit = renderNameCard(ctx, nameModel, { - image, - name: 'João', - fontFamily: 'Brexter', - measure: createCardMeasure(ctx, 'Brexter'), - }) - - expect(fit.ok).toBe(true) - expect(drawCalls).toEqual([{ image, dx: 0, dy: 0, dw: 1080, dh: 1440 }]) - expect(textCalls).toHaveLength(1) - expect(textCalls[0]!.text).toBe('JOÃO') - expect(textCalls[0]!.x).toBe(NAME_CARD_SLOT.leftX) - expect(textCalls[0]!.y).toBeCloseTo(NAME_CARD_SLOT.capTop + NAME_CARD_SLOT.capHeight, 5) - expect(ctx.fillStyle).toBe(NAME_CARD_SLOT.fill) - expect(ctx.textAlign).toBe('left') - }) - - it('wraps long names into two lines with baselines inside the free band', () => { - const { ctx, textCalls } = createFakeContext() - const fit = renderNameCard(ctx, nameModel, { - image: { id: 'base' } as unknown as CanvasImageSource, - name: 'Jorge Solla da Bahia', - fontFamily: 'Brexter', - measure: createCardMeasure(ctx, 'Brexter'), - }) - - expect(fit.ok).toBe(true) - expect(textCalls.map((call) => call.text)).toEqual(['JORGE SOLLA', 'DA BAHIA']) - expect(textCalls[0]!.y).toBeLessThan(textCalls[1]!.y) - expect(textCalls[1]!.y).toBeLessThanOrEqual(NAME_CARD_SLOT.maxBlockBottom) - }) - - it('draws the base but no text when the name cannot fit (never cuts silently)', () => { - const { ctx, drawCalls, textCalls } = createFakeContext() - const fit = renderNameCard(ctx, nameModel, { - image: { id: 'base' } as unknown as CanvasImageSource, - name: 'Bartolomeucostajunior', - fontFamily: 'Brexter', - measure: createCardMeasure(ctx, 'Brexter'), - }) - - expect(fit).toEqual({ ok: false, reason: 'too-long' }) - expect(drawCalls).toHaveLength(1) - expect(textCalls).toHaveLength(0) - }) -}) - -describe('drawCardName', () => { - it('draws only the fitted name (no base) on the left border', () => { - const { ctx, drawCalls, textCalls } = createFakeContext() - const fit = fitCardName('João', createCardMeasure(ctx, 'Brexter')) - expect(fit.ok).toBe(true) - if (!fit.ok) return - - drawCardName(ctx, { fit, fontFamily: 'Brexter' }) - - expect(drawCalls).toHaveLength(0) - expect(textCalls).toEqual([ - { text: 'JOÃO', x: NAME_CARD_SLOT.leftX, y: NAME_CARD_SLOT.capTop + fit.capHeight }, - ]) - expect(ctx.textAlign).toBe('left') - expect(ctx.fillStyle).toBe(NAME_CARD_SLOT.fill) - }) - - it('left-aligns both wrapped lines inside the free band', () => { - const { ctx, textCalls } = createFakeContext() - const fit = fitCardName('Jorge Solla da Bahia', createCardMeasure(ctx, 'Brexter')) - expect(fit.ok).toBe(true) - if (!fit.ok) return - - drawCardName(ctx, { fit, fontFamily: 'Brexter' }) - - expect(textCalls.map((call) => call.text)).toEqual(['JORGE SOLLA', 'DA BAHIA']) - expect(textCalls.every((call) => call.x === NAME_CARD_SLOT.leftX)).toBe(true) - expect(textCalls[0]!.y).toBeLessThan(textCalls[1]!.y) - expect(textCalls[1]!.y).toBeLessThanOrEqual(NAME_CARD_SLOT.maxBlockBottom) - }) -}) - -describe('renderTeamCard', () => { - const window = teamModel.photoWindow! - const photoSize: CardPhotoSize = { width: 400, height: 600 } - const transform = frameCardPhotoOnBbox(photoSize, window, { - x: 80, - y: 40, - width: 240, - height: 420, - })! - - const render = (name: string) => { - const fake = createFakeContext() - const base = { id: 'base' } as unknown as CanvasImageSource - const overlay = { id: 'overlay' } as unknown as CanvasImageSource - const photo = { id: 'photo' } as unknown as CanvasImageSource - const result = renderTeamCard(fake.ctx, teamModel, { - base, - overlay, - subject: { photo, photoSize, transform }, - window, - name, - fontFamily: 'Brexter', - measure: createCardMeasure(fake.ctx, 'Brexter'), - }) - - return { ...fake, result, base, overlay, photo } - } - - it('draws base → framed photo → overlay, then the label and the name banner', () => { - const { drawCalls, textCalls, rectCalls, ops, result, base, overlay, photo } = render('Maria') - - expect(result.fit).toMatchObject({ ok: true, lines: ['MARIA'] }) - expect(drawCalls.map((call) => call.image)).toEqual([base, photo, overlay]) - expect(drawCalls[0]).toEqual({ image: base, dx: 0, dy: 0, dw: 1080, dh: 1440 }) - expect(drawCalls[2]).toEqual({ image: overlay, dx: 0, dy: 0, dw: 1080, dh: 1440 }) - - // Both banners are drawn as rotated rectangles: two save/restore pairs. - expect(rectCalls).toHaveLength(2) - expect(rectCalls[0]).toMatchObject({ - width: TEAM_CARD_LABEL.width, - height: TEAM_CARD_LABEL.height, - }) - // S16 — `MARIA` at the ideal cap (130/0.72) with the 0.84em fake measures - // ≈758.33 of ink; the banner grows by the model's 39px padding: ≈797.33. - expect(rectCalls[1]).toMatchObject({ - y: -TEAM_CARD_NAME_BANNER.height / 2, - height: TEAM_CARD_NAME_BANNER.height, - }) - expect(rectCalls[1]!.width).toBeCloseTo(797.33, 1) - expect(rectCalls[1]!.x).toBeCloseTo(-797.33 / 2, 1) - expect(ops.filter((op) => op.op === 'save')).toHaveLength(2) - expect(ops.filter((op) => op.op === 'restore')).toHaveLength(2) - expect(ops.map((op) => op.op)).toEqual([ - 'save', - 'translate', - 'rotate', - 'restore', - 'save', - 'translate', - 'rotate', - 'restore', - ]) - expect(ops[1]!.args).toEqual([TEAM_CARD_LABEL.centerX, TEAM_CARD_LABEL.centerY]) - expect(ops[5]!.args).toEqual([TEAM_CARD_NAME_BANNER.centerX, TEAM_CARD_NAME_BANNER.centerY]) - - expect(textCalls.map((call) => call.text)).toEqual(['TIME DE', 'MARIA']) - expect(textCalls.every((call) => call.x === 0)).toBe(true) - }) - - it('draws the label but no name banner when the name cannot fit (never cuts silently)', () => { - const { textCalls, rectCalls, result } = render('Bartolomeucostajunior') - - expect(result.fit).toEqual({ ok: false, reason: 'too-long' }) - expect(rectCalls).toHaveLength(1) - expect(textCalls.map((call) => call.text)).toEqual(['TIME DE']) - }) - - it('returns the photo transform with the zoom clamped and the free position kept (S33)', () => { - const { result, photo } = render('Maria') - - expect(result.transform).toEqual(transform) - - // S33 — `time-de-voce` is `free`: the zoom still clamps to [1, 4] but the - // position comes back exactly as sent; a re-clamp here would snap the photo - // back on the next paint (drawing and return share the model's policy). - const fake = createFakeContext() - const outOfBounds = { zoom: 0.5, offsetX: 10_000, offsetY: -10_000 } - const clamped = renderTeamCard(fake.ctx, teamModel, { - base: { id: 'base' } as unknown as CanvasImageSource, - overlay: { id: 'overlay' } as unknown as CanvasImageSource, - subject: { photo, photoSize, transform: outOfBounds }, - window, - name: 'Maria', - fontFamily: 'Brexter', - measure: createCardMeasure(fake.ctx, 'Brexter'), - }) - - expect(clamped.transform).toEqual({ zoom: 1, offsetX: 10_000, offsetY: -10_000 }) - // The drawing follows the same free policy (second drawImage is the photo). - expect(fake.drawCalls[1]).toMatchObject({ dx: 10_000, dy: -10_000 }) - }) - - it('leaves the window empty instead of drawing a subject when no photo is ready (S40)', () => { - const fake = createFakeContext() - const base = { id: 'base' } as unknown as CanvasImageSource - const overlay = { id: 'overlay' } as unknown as CanvasImageSource - const result = renderTeamCard(fake.ctx, teamModel, { - base, - overlay, - subject: null, - window, - name: 'Maria', - fontFamily: 'Brexter', - measure: createCardMeasure(fake.ctx, 'Brexter'), - }) - - expect(result.transform).toBeNull() - expect(result.fit).toMatchObject({ ok: true, lines: ['MARIA'] }) - // Base + overlay only: nothing is drawn inside the photo window. - expect(fake.drawCalls.map((call) => call.image)).toEqual([base, overlay]) - expect( - fake.ops.filter((op) => ['beginPath', 'arc', 'ellipse', 'rect', 'fill'].includes(op.op)), - ).toEqual([]) - }) -}) - -describe('resolveCardBannerWidth (S16)', () => { - const slot = TEAM_CARD_NAME_SLOT - const maxInkWidth = slot.maxInkWidth - - it('pins the design table pairs (ink = width − 39: reference 509 → ceiling 1000)', () => { - const designPairs: [ink: number, width: number][] = [ - [470, 509], - [527, 566], - [807, 846], - [925, 964], - [951, 990], - [952, 991], - [961, 1000], - ] - - for (const [ink, width] of designPairs) { - expect(resolveCardBannerWidth(slot, ink)).toBe(width) - } - }) - - it('keeps the reference width for short names and clamps past the ceiling', () => { - expect(resolveCardBannerWidth(slot, 0)).toBe(slot.banner.width) - expect(resolveCardBannerWidth(slot, 200)).toBe(slot.banner.width) - expect(resolveCardBannerWidth(slot, 2000)).toBe(slot.banner.maxWidth) - }) - - it('never grows a fixed banner (the red TIME DE label)', () => { - const labelSlot = { banner: TEAM_CARD_LABEL, maxInkWidth } - expect(resolveCardBannerWidth(labelSlot, 0)).toBe(TEAM_CARD_LABEL.width) - expect(resolveCardBannerWidth(labelSlot, 5000)).toBe(TEAM_CARD_LABEL.width) - }) -}) - -describe('drawCardName (centered team slot)', () => { - it('draws the blue banner with the centered name through the same banner helper', () => { - const { ctx, textCalls, rectCalls, ops } = createFakeContext() - const fit = fitCardName('Maria', createCardMeasure(ctx, 'Brexter'), TEAM_CARD_NAME_SLOT) - expect(fit.ok).toBe(true) - if (!fit.ok) return - - drawCardName(ctx, { fit, fontFamily: 'Brexter', slot: TEAM_CARD_NAME_SLOT }) - - // S16 — dynamic width resolved from the fitted ink (same ≈797.33 as above). - expect(rectCalls).toHaveLength(1) - expect(rectCalls[0]).toMatchObject({ - y: -TEAM_CARD_NAME_BANNER.height / 2, - height: TEAM_CARD_NAME_BANNER.height, - }) - expect(rectCalls[0]!.width).toBeCloseTo(797.33, 1) - expect(rectCalls[0]!.x).toBeCloseTo(-797.33 / 2, 1) - expect(ops).toEqual([ - { op: 'save', args: [] }, - { op: 'translate', args: [TEAM_CARD_NAME_BANNER.centerX, TEAM_CARD_NAME_BANNER.centerY] }, - { op: 'rotate', args: [(TEAM_CARD_NAME_BANNER.rotationDeg * Math.PI) / 180] }, - { op: 'restore', args: [] }, - ]) - expect(textCalls).toEqual([{ text: 'MARIA', x: 0, y: fit.capHeight / 2 }]) - expect(ctx.fillStyle).toBe('#ffffff') - }) -}) - -describe('renderPhotoCard', () => { - it('draws the photo full-bleed under the official overlay', () => { - const { ctx, drawCalls } = createFakeContext() - const photo = { id: 'photo' } as unknown as CanvasImageSource - const frame = { id: 'frame' } as unknown as CanvasImageSource - const size: CardPhotoSize = { width: 2000, height: 1000 } - const window: CardRect = squareModel.photoWindow! - const transform = centerCardPhotoTransform(size, window) - - const clamped = renderPhotoCard(ctx, { - photo, - frame, - photoSize: size, - frameSize: { width: squareModel.width, height: squareModel.height }, - window, - transform, - }) - - expect(clamped).toEqual(transform) - expect(drawCalls).toHaveLength(2) - expect(drawCalls[0]!.image).toBe(photo) - expect(drawCalls[1]).toEqual({ image: frame, dx: 0, dy: 0, dw: 1000, dh: 1000 }) - expect(drawCalls[0]!.dx).toBeLessThanOrEqual(window.x) - expect(drawCalls[0]!.dy).toBeLessThanOrEqual(window.y) - expect(drawCalls[0]!.dx + drawCalls[0]!.dw).toBeGreaterThanOrEqual(window.x + window.width) - expect(drawCalls[0]!.dy + drawCalls[0]!.dh).toBeGreaterThanOrEqual(window.y + window.height) - }) - - it('still clamps the visitor adjustment on the bounded photo models (S13/S14)', () => { - const { ctx, drawCalls } = createFakeContext() - const photo = { id: 'photo' } as unknown as CanvasImageSource - const frame = { id: 'frame' } as unknown as CanvasImageSource - const size: CardPhotoSize = { width: 2000, height: 1000 } - const window: CardRect = squareModel.photoWindow! - - const clamped = renderPhotoCard(ctx, { - photo, - frame, - photoSize: size, - frameSize: { width: squareModel.width, height: squareModel.height }, - window, - transform: { zoom: 1, offsetX: 9999, offsetY: 9999 }, - }) - - expect(clamped).toEqual({ zoom: 1, offsetX: 0, offsetY: 0 }) - expect(drawCalls[0]).toEqual({ image: photo, dx: 0, dy: 0, dw: 1480, dh: 740 }) - }) -}) - -describe('renderColinhaCard (S34)', () => { - const colinhaModel = getCardModel('minha-colinha')! - const art = { id: 'art' } as unknown as CanvasImageSource - const deputy = getStateDeputyCard('julio')! - - const render = (chosen: StateDeputyCatalogEntry | null) => { - const fake = createFakeContext() - renderColinhaCard(fake.ctx, colinhaModel, { - image: art, - deputy: chosen, - fontFamily: 'Brexter', - measure: createCardMeasure(fake.ctx, 'Brexter', 900), - }) - - return fake - } - - // The fake face is conservative (0.84em per char): JULIO PINHEIRO shrinks - // from 36.72 to floor(36.72 × 195.83 / 431.83) = 16 to fit the mask band. - const nameFontSize = 16 - const baseline = COLINHA_ROW_LAYOUT.copy.top + nameFontSize * 0.72 - const nameWidth = 'JULIO PINHEIRO'.length * nameFontSize * 0.84 - - it('draws the approved art whole at 1080×1920 with high smoothing', () => { - const { ctx, drawCalls, ops } = render(deputy) - - expect(drawCalls).toEqual([{ image: art, dx: 0, dy: 0, dw: 1080, dh: 1920 }]) - expect(ctx.imageSmoothingEnabled).toBe(true) - expect(ctx.imageSmoothingQuality).toBe('high') - // None of the S31 composition survives: no clip/save/gradient/roundRect. - expect(ops).toHaveLength(0) - }) - - it('leaves the art exactly as delivered before a pick (no text, no rect)', () => { - const { drawCalls, rectCalls, textCalls, ops } = render(null) - - expect(drawCalls).toEqual([{ image: art, dx: 0, dy: 0, dw: 1080, dh: 1920 }]) - expect(rectCalls).toHaveLength(0) - expect(textCalls).toHaveLength(0) - expect(ops).toHaveLength(0) - }) - - it('fills the estadual line: mask, label at the left, name at the right and five digits', () => { - const { ctx, rectCalls, textCalls, textStates } = render(deputy) - - expect(rectCalls).toEqual([ - { - x: COLINHA_ROW_LAYOUT.mask.x, - y: COLINHA_ROW_LAYOUT.mask.y, - width: COLINHA_ROW_LAYOUT.mask.width, - height: COLINHA_ROW_LAYOUT.mask.height, - }, - ]) - expect(textCalls.map((call) => call.text)).toEqual([ - COLINHA_ESTADUAL_LABEL, - 'JULIO PINHEIRO', - '1', - '3', - '9', - '9', - '9', - ]) - - const label = textCalls[0]! - expect(label.x).toBeCloseTo( - COLINHA_ROW_LAYOUT.copy.right - nameWidth - COLINHA_ROW_LAYOUT.gap, - 5, - ) - expect(label.y).toBeCloseTo(baseline, 5) - expect(textStates[0]).toMatchObject({ - align: 'right', - fill: COLINHA_ROW_LAYOUT.office.color, - font: `400 ${COLINHA_ROW_LAYOUT.office.fontSize}px Brexter`, - letterSpacing: '0px', - }) - - const name = textCalls[1]! - expect(name.x).toBeCloseTo(COLINHA_ROW_LAYOUT.copy.right, 5) - expect(name.y).toBeCloseTo(baseline, 5) - expect(textStates[1]).toMatchObject({ - align: 'right', - fill: COLINHA_ROW_LAYOUT.name.color, - font: `900 ${nameFontSize}px Brexter`, - letterSpacing: `${COLINHA_ROW_LAYOUT.name.letterSpacingEm * nameFontSize}px`, - }) - - const { digit } = COLINHA_ROW_LAYOUT - const centerY = digit.top + digit.height / 2 - textCalls.slice(2).forEach((call, index) => { - expect(call.x).toBeCloseTo(digit.left + digit.width / 2 + index * digit.step, 5) - expect(call.y).toBeCloseTo(centerY, 5) - expect(textStates[2 + index]).toMatchObject({ - align: 'center', - fill: digit.color, - font: `900 ${digit.fontSize}px Arial Black, Arial, Helvetica, sans-serif`, - }) - }) - - // The tracking set for the name never leaks to the next paint. - expect(ctx.letterSpacing).toBe('0px') - expect(ctx.globalAlpha).toBe(1) - }) -}) diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index 590084b5..e54f9bdf 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -535,10 +535,6 @@ describe('src/utilities top-level is pinned', () => { // 'use server' action module). 'leadershipConflict.ts', 'hookFilledData.ts', - // S21 — cached published-jingles listing + per-media resolution for the - // public `/jingles` page and the footer discovery link (the tag side lives - // in `documents.ts`, the config side here, the sibling of `posts.ts`). - 'jingleReads.ts', 'leaderContactsPageData.ts', 'loadNamesByIds.ts', // OPS52 — media storage env resolution; shared by payload.config.ts diff --git a/tests/unit/contentPieceHomeBoard.unit.spec.tsx b/tests/unit/contentPieceHomeBoard.unit.spec.tsx deleted file mode 100644 index 664d67a8..00000000 --- a/tests/unit/contentPieceHomeBoard.unit.spec.tsx +++ /dev/null @@ -1,401 +0,0 @@ -import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { ContentPieceHomeBoard } from '@/components/conteudos/ContentPieceHomeBoard' -import type { ContentPieceHomeFacets } from '@/components/conteudos/ContentPieceHomeFilterRow' -import { - loadMunicipalityGeometryModule, - loadMunicipalityZoneGeometryModule, - loadTerritoryGeometryModule, -} from '@/lib/bahiaGeometries' -import type { - BahiaFeature, - BahiaMunicipalityFeature, - BahiaTerritoryFeature, - MunicipalityGeometryModule, - MunicipalityZoneGeometryModule, - TerritoryGeometryModule, -} from '@/lib/bahiaGeometriesTypes' -import type { ContentPieceHomeItem } from '@/lib/contentPieceHomeSelection' -import { - readGeolocationPermissionState, - requestCurrentPosition, -} from '@/utilities/campaignGeolocation' - -vi.mock('next/link', () => ({ - default: ({ children, href, ...props }: { children: React.ReactNode; href: string }) => ( - <a href={href} {...props}> - {children} - </a> - ), -})) - -vi.mock('@/lib/bahiaGeometries', () => ({ - loadMunicipalityGeometryModule: vi.fn(), - loadMunicipalityZoneGeometryModule: vi.fn(), - loadTerritoryGeometryModule: vi.fn(), -})) - -vi.mock('@/utilities/campaignGeolocation', () => ({ - COARSE_ACCURACY_M: 10_000, - readGeolocationPermissionState: vi.fn(), - requestCurrentPosition: vi.fn(), -})) - -const readPermission = vi.mocked(readGeolocationPermissionState) -const readPosition = vi.mocked(requestCurrentPosition) -const readMunicipalityGeometry = vi.mocked(loadMunicipalityGeometryModule) -const readZoneGeometry = vi.mocked(loadMunicipalityZoneGeometryModule) -const readTerritoryGeometry = vi.mocked(loadTerritoryGeometryModule) - -const item = (id: number, patch: Partial<ContentPieceHomeItem> = {}): ContentPieceHomeItem => ({ - id, - slug: `peca-${id}`, - title: `Peça ${id}`, - type: 'foto', - typeLabel: 'Foto', - origin: 'arquivo', - originLabel: 'Arquivo', - isLink: false, - sourceUrl: null, - cityLabel: null, - regionLabel: null, - excerpt: null, - durationLabel: null, - metaLabel: 'Tema · Local', - publicPath: `/conteudos/peca-${id}`, - file: { - id: 100 + id, - path: `/conteudos/peca-${id}/midia`, - mimeType: 'image/png', - downloadFilename: `peca-${id}.png`, - }, - framePath: null, - ...patch, -}) - -/** Axis-aligned squares over the committed-mesh contract the resolver reads. */ -const square = <Properties extends Record<string, unknown>>( - properties: Properties, - box: { west: number; south: number; size?: number }, -): BahiaFeature<Properties> => { - const size = box.size ?? 0.4 - return { - type: 'Feature', - properties, - geometry: { - type: 'Polygon', - coordinates: [ - [ - [box.west, box.south], - [box.west + size, box.south], - [box.west + size, box.south + size], - [box.west, box.south + size], - [box.west, box.south], - ], - ], - }, - } -} - -const feira = square( - { codarea: '2910800', name: 'Feira de Santana' }, - { west: -39.2, south: -12.4 }, -) -const portal = square({ code: '20', name: 'Portal do Sertão' }, { west: -40, south: -13, size: 3 }) - -const municipalityModule = ( - features: readonly BahiaMunicipalityFeature[], -): MunicipalityGeometryModule => ({ - topology: { - type: 'Topology', - objects: { municipalities: { type: 'GeometryCollection', geometries: [] } }, - arcs: [], - }, - features, - getMunicipalityFeature: (codarea) => - features.find((feature) => feature.properties.codarea === codarea), -}) - -const zoneModule = (): MunicipalityZoneGeometryModule => ({ - topology: { - type: 'Topology', - objects: { municipalityZones: { type: 'GeometryCollection', geometries: [] } }, - arcs: [], - }, - features: [], -}) - -const territoryModule = (features: readonly BahiaTerritoryFeature[]): TerritoryGeometryModule => ({ - topology: { - type: 'Topology', - objects: { territories: { type: 'GeometryCollection', geometries: [] } }, - arcs: [], - }, - features, -}) - -const feiraPoint = { lat: -12.2, lng: -39 } -const localPiece = item(1, { cityLabel: 'Feira de Santana', regionLabel: 'Portal do Sertão' }) -const otherPiece = item(2, { cityLabel: 'Ilhéus', regionLabel: 'Litoral Sul' }) -const items = [localPiece, otherPiece] - -/** S42 — the server hands the explore row the catalogue vocabulary of the sample. */ -const facets: ContentPieceHomeFacets = { - tipo: [ - { value: 'video', label: 'Vídeo' }, - { value: 'foto', label: 'Foto' }, - ], - cidade: [{ value: 'feira-de-santana', label: 'Feira de Santana' }], - regiao: [{ value: 'portal-do-sertao', label: 'Portal do Sertão' }], -} - -const renderBoard = (boardItems: ContentPieceHomeItem[] = items, boardFacets = facets) => - render(<ContentPieceHomeBoard items={boardItems} facets={boardFacets} />) - -const locateButton = () => screen.queryByRole('button', { name: /Usar minha localização/ }) - -/** The section tag is split by the decorative glyph, so the hook is the attribute. */ -const sampleTag = () => - document.querySelector('[data-sample-tag]')?.getAttribute('data-sample-tag') ?? null - -beforeEach(() => { - vi.clearAllMocks() - readMunicipalityGeometry.mockResolvedValue(municipalityModule([feira])) - readZoneGeometry.mockResolvedValue(zoneModule()) - readTerritoryGeometry.mockResolvedValue(territoryModule([portal])) - readPosition.mockResolvedValue({ ok: true, fix: { ...feiraPoint, accuracyM: 80 } }) -}) - -afterEach(cleanup) - -describe('ContentPieceHomeBoard', () => { - it('renders the recent selection and the affordance while permission is undecided', async () => { - readPermission.mockResolvedValue('prompt') - - renderBoard() - - await waitFor(() => expect(locateButton()).not.toBeNull()) - expect(screen.getByText('Seleção recente')).toBeDefined() - expect(screen.getAllByText('Mais recente')).toHaveLength(items.length) - expect(screen.getByRole('link', { name: /Ver todas as peças/ }).getAttribute('href')).toBe( - '/conteudos', - ) - // No surprise dialog on load and nothing stored anywhere (LGPD contract). - expect(readPosition).not.toHaveBeenCalled() - expect(sessionStorage.length).toBe(0) - expect(localStorage.length).toBe(0) - }) - - it('hides the affordance when the browser already refused the permission', async () => { - readPermission.mockResolvedValue('denied') - - renderBoard() - - await waitFor(() => expect(readPermission).toHaveBeenCalled()) - expect(locateButton()).toBeNull() - expect(screen.getByText('Seleção recente')).toBeDefined() - }) - - it('upgrades to the visitor territory without a dialog when permission is granted', async () => { - readPermission.mockResolvedValue('granted') - - renderBoard() - - await waitFor(() => expect(sampleTag()).toBe('municipality')) - expect(screen.getByText('Do seu município')).toBeDefined() - expect(locateButton()).toBeNull() - expect(readPosition).toHaveBeenCalledTimes(1) - }) - - it('upgrades to the visitor territory when the affordance is tapped', async () => { - readPermission.mockResolvedValue('prompt') - - renderBoard() - - await waitFor(() => expect(locateButton()).not.toBeNull()) - fireEvent.click(locateButton()!) - await waitFor(() => expect(sampleTag()).toBe('municipality')) - expect(locateButton()).toBeNull() - expect(readPosition).toHaveBeenCalledTimes(1) - }) - - it('offers the affordance when the browser cannot tell the permission (Safari)', async () => { - readPermission.mockResolvedValue('unknown') - - renderBoard() - - await waitFor(() => expect(locateButton()).not.toBeNull()) - expect(readPosition).not.toHaveBeenCalled() - }) - - it('mounts the media only after the tap and unmounts it when the play ends', async () => { - readPermission.mockResolvedValue('denied') - const video = item(7, { - title: 'Vídeo da home', - type: 'video', - typeLabel: 'Vídeo', - file: { - id: 107, - path: '/conteudos/peca-7/midia', - mimeType: 'video/mp4', - downloadFilename: 'peca-7.mp4', - }, - }) - - renderBoard([video]) - - await waitFor(() => expect(readPermission).toHaveBeenCalled()) - expect(document.querySelector('video')).toBeNull() - - fireEvent.click(screen.getByRole('button', { name: 'Reproduzir Vídeo da home' })) - const element = document.querySelector('video') - expect(element).not.toBeNull() - - fireEvent.ended(element!) - await waitFor(() => expect(document.querySelector('video')).toBeNull()) - }) - - it('keeps the frame of the type on the sample card (S45)', async () => { - readPermission.mockResolvedValue('denied') - const photo = item(1) - const video = item(2, { - title: 'Vídeo da home', - type: 'video', - typeLabel: 'Vídeo', - file: { - id: 102, - path: '/conteudos/peca-2/midia', - mimeType: 'video/mp4', - downloadFilename: 'peca-2.mp4', - }, - }) - const audio = item(3, { - title: 'Áudio da home', - type: 'audio', - typeLabel: 'Áudio', - file: { - id: 103, - path: '/conteudos/peca-3/midia', - mimeType: 'audio/mpeg', - downloadFilename: 'peca-3.mp3', - }, - }) - - renderBoard([photo, video, audio]) - await waitFor(() => expect(readPermission).toHaveBeenCalled()) - - const slot = (slug: string) => - document.querySelector<HTMLElement>( - `article[data-content-piece="${slug}"] [data-content-piece-media]`, - ) - - // Photo keeps 4:5 and the Reel keeps 9:16; the wide kinds stay short on the - // phone and take the wide block from `sm` up. - expect(slot('peca-1')?.className).toContain('aspect-[4/5]') - expect(slot('peca-2')?.className).toContain('aspect-[9/16]') - expect(slot('peca-3')?.className).toContain('max-sm:h-[156px]') - }) - - it('keeps the recent selection when the position fails, without an error or a retry', async () => { - readPermission.mockResolvedValue('granted') - readPosition.mockResolvedValue({ ok: false, reason: 'timeout' }) - - renderBoard() - - await waitFor(() => expect(readPosition).toHaveBeenCalledTimes(1)) - expect(screen.getByText('Seleção recente')).toBeDefined() - expect(locateButton()).toBeNull() - expect(screen.queryByText(/Não foi possível/)).toBeNull() - }) - - it('keeps the recent selection for a coarse fix (no município claim)', async () => { - readPermission.mockResolvedValue('granted') - readPosition.mockResolvedValue({ ok: true, fix: { ...feiraPoint, accuracyM: 50_000 } }) - - renderBoard() - - // The coarse fix keeps the region sample, never the município claim. - await waitFor(() => expect(screen.getByText('Da sua região')).toBeDefined()) - expect(readPosition).toHaveBeenCalledTimes(1) - expect(screen.getByText('Seleção recente')).toBeDefined() - expect(screen.queryByText('Do seu município')).toBeNull() - }) - - it('does not offer the affordance for a single published piece', async () => { - readPermission.mockResolvedValue('prompt') - - renderBoard([localPiece]) - - await waitFor(() => expect(readPermission).toHaveBeenCalled()) - expect(locateButton()).toBeNull() - expect( - screen.getByText('Uma peça oficial já está pronta para você compartilhar.'), - ).toBeDefined() - // Cena 05 — the single-piece section ends on the CTA, with no footnote. - expect(screen.queryByText(/Nenhum card personalizável/)).toBeNull() - expect(screen.queryByText(/A localização não é guardada/)).toBeNull() - }) - - it('opens the S27 share sheet from the card without leaving the home (S42)', async () => { - readPermission.mockResolvedValue('denied') - - renderBoard() - await waitFor(() => expect(readPermission).toHaveBeenCalled()) - - fireEvent.click(screen.getByRole('button', { name: 'Compartilhar Peça 1' })) - - const sheet = screen.getByRole('dialog', { name: 'Compartilhar peça' }) - expect(sheet).toBeDefined() - const message = within(sheet).getByLabelText( - 'Mensagem para compartilhar', - ) as HTMLTextAreaElement - expect(message.value).toContain('Peça 1') - // The section stays mounted behind the sheet — the visitor never leaves the home. - expect(screen.getByText('Seleção recente')).toBeDefined() - }) - - it('links the honest tags to the catalogue and keeps "Mais recente" static (S42)', async () => { - readPermission.mockResolvedValue('granted') - - renderBoard() - await waitFor(() => expect(sampleTag()).toBe('municipality')) - - const localCard = document.querySelector('article[data-content-piece="peca-1"]') as HTMLElement - const otherCard = document.querySelector('article[data-content-piece="peca-2"]') as HTMLElement - - expect(within(localCard).getByRole('link', { name: 'Foto' }).getAttribute('href')).toBe( - '/conteudos?tipo=foto', - ) - expect( - within(localCard).getByRole('link', { name: 'Do seu município' }).getAttribute('href'), - ).toBe('/conteudos?cidade=feira-de-santana') - expect(within(otherCard).queryByRole('link', { name: 'Mais recente' })).toBeNull() - // The section tag is the shortcut of the visitor's município. - expect(screen.getByRole('link', { name: /Para seu município/ }).getAttribute('href')).toBe( - '/conteudos?cidade=feira-de-santana', - ) - }) - - it('renders the explore row with canonical facet links and never filters the sample (S42)', async () => { - readPermission.mockResolvedValue('denied') - - renderBoard() - await waitFor(() => expect(readPermission).toHaveBeenCalled()) - - // Both breakpoint captions render (one hidden by CSS); the row itself has - // one chip per non-empty facet. - expect(screen.getAllByText(/Explore na Central/)).toHaveLength(2) - - // The board hands the server facets to the row; every option href is the - // row's own contract (pinned in `contentPieceHomeFilterRow.unit.spec.tsx`). - const tipoDetails = screen.getByText('Tipo').closest('details') as HTMLElement - fireEvent.click(within(tipoDetails).getByText('Tipo')) - expect(within(tipoDetails).getByRole('link', { name: 'Vídeo' }).getAttribute('href')).toBe( - '/conteudos?tipo=video', - ) - - // The row is pure navigation: the sample stays intact. - expect(document.querySelectorAll('article[data-content-piece]')).toHaveLength(2) - }) -}) diff --git a/tests/unit/contentPieceHomeFilterRow.unit.spec.tsx b/tests/unit/contentPieceHomeFilterRow.unit.spec.tsx deleted file mode 100644 index eecdee55..00000000 --- a/tests/unit/contentPieceHomeFilterRow.unit.spec.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { fireEvent, render, screen, within } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' - -import { ContentPieceHomeFilterRow } from '@/components/conteudos/ContentPieceHomeFilterRow' - -vi.mock('next/link', () => ({ - default: ({ children, href, ...props }: { children: React.ReactNode; href: string }) => ( - <a href={href} {...props}> - {children} - </a> - ), -})) - -const facets = { - tipo: [ - { value: 'video', label: 'Vídeo' }, - { value: 'foto', label: 'Foto' }, - ], - cidade: [{ value: 'salvador', label: 'Salvador' }], - regiao: [], -} - -describe('ContentPieceHomeFilterRow', () => { - it('renders only the facets with options and links each option to the canonical filtered URL', () => { - render(<ContentPieceHomeFilterRow facets={facets} />) - - expect(screen.getByText('Tipo')).toBeDefined() - expect(screen.getByText('Cidade')).toBeDefined() - // A facet no published piece carries never renders — no empty menu. - expect(screen.queryByText('Região')).toBeNull() - - const tipo = screen.getByText('Tipo').closest('details') as HTMLElement - fireEvent.click(within(tipo).getByText('Tipo')) - expect(within(tipo).getByRole('link', { name: 'Vídeo' }).getAttribute('href')).toBe( - '/conteudos?tipo=video', - ) - - const cidade = screen.getByText('Cidade').closest('details') as HTMLElement - fireEvent.click(within(cidade).getByText('Cidade')) - expect( - within(cidade) - .getByRole('link', { name: /Salvador/ }) - .getAttribute('href'), - ).toBe('/conteudos?cidade=salvador') - }) - - it('renders nothing without any option', () => { - const { container } = render( - <ContentPieceHomeFilterRow facets={{ tipo: [], cidade: [], regiao: [] }} />, - ) - - expect(container.firstChild).toBeNull() - }) -}) diff --git a/tests/unit/contentPieceHomeSelection.unit.spec.ts b/tests/unit/contentPieceHomeSelection.unit.spec.ts deleted file mode 100644 index 510b1f58..00000000 --- a/tests/unit/contentPieceHomeSelection.unit.spec.ts +++ /dev/null @@ -1,336 +0,0 @@ -// @vitest-environment node - -import { describe, expect, it } from 'vitest' - -import type { - BahiaMunicipalityFeature, - BahiaTerritoryFeature, - MunicipalityZoneFeature, -} from '@/lib/bahiaGeometriesTypes' -import { toContentPiecePublicItem, type ContentPiecePublicSource } from '@/lib/contentPieceCatalog' -import { - resolveContentPieceHomeVisitor, - selectContentPieceHomeItems, - toContentPieceHomeItem, - type ContentPieceHomeItem, -} from '@/lib/contentPieceHomeSelection' - -const source = (patch: Partial<ContentPiecePublicSource> = {}): ContentPiecePublicSource => ({ - id: 1, - slug: 'fim-da-escala-6x1', - title: 'Fim da escala 6x1 é saúde', - type: 'video', - status: 'publicado', - origin: 'arquivo', - topics: ['economia-trabalho'], - cityLabel: 'Feira de Santana', - region: 'Portal do Sertão', - description: 'Solla explica a jornada.', - transcript: 'A redução da jornada é saúde.', - durationSeconds: 134, - searchText: 'fim da escala 6x1 e saude feira de santana', - media: { id: 7, filename: 'Fim da Escala.MP4' }, - ...patch, -}) - -const homeItem = (patch: Partial<ContentPiecePublicSource> = {}): ContentPieceHomeItem => { - const item = toContentPiecePublicItem(source(patch)) - if (!item) throw new Error('fixture should be public') - return toContentPieceHomeItem(item) -} - -/** Axis-aligned square in degrees — small enough that planar reasoning holds. */ -const square = <Properties extends Record<string, unknown>>( - properties: Properties, - { west, south, size }: { west: number; south: number; size: number }, -) => ({ - type: 'Feature' as const, - properties, - geometry: { - type: 'Polygon' as const, - coordinates: [ - [ - [west, south], - [west + size, south], - [west + size, south + size], - [west, south + size], - [west, south], - ], - ], - }, -}) - -const municipalitySquare = ( - codarea: string, - name: string, - box: { west: number; south: number; size: number }, -): BahiaMunicipalityFeature => square({ codarea, name }, box) - -const zoneSquare = ( - municipalitySlug: string, - name: string, - ibgeCode: string, - box: { west: number; south: number; size: number }, -): MunicipalityZoneFeature => square({ municipalitySlug, name, ibgeCode }, box) - -const territorySquare = ( - code: string, - name: string, - box: { west: number; south: number; size: number }, -): BahiaTerritoryFeature => square({ code, name }, box) - -describe('content piece home selection', () => { - describe('toContentPieceHomeItem', () => { - it('projects exactly the card fields — the search haystack never rides along', () => { - const item = homeItem() - - expect(Object.keys(item).sort()).toEqual([ - 'cityLabel', - 'durationLabel', - 'excerpt', - 'file', - 'framePath', - 'id', - 'isLink', - 'metaLabel', - 'origin', - 'originLabel', - 'publicPath', - 'regionLabel', - 'slug', - 'sourceUrl', - 'title', - 'type', - 'typeLabel', - ]) - expect('searchText' in item).toBe(false) - expect('description' in item).toBe(false) - expect('topics' in item).toBe(false) - }) - - it('carries the frame of a video piece into the home card (C226)', () => { - // The field-by-field map is what stops a new public field from leaking by - // spread, so the copy is pinned here on purpose. - expect( - homeItem({ - id: 3, - slug: 'peca-video', - type: 'video', - media: { id: 7, filename: 'video.mp4', mimeType: 'video/mp4' }, - }).framePath, - ).toBe('/conteudos/peca-video/frame') - expect( - homeItem({ - id: 4, - slug: 'peca-foto', - type: 'foto', - media: { id: 8, filename: 'foto.png', mimeType: 'image/png' }, - }).framePath, - ).toBeNull() - }) - - it('keeps a link piece with no archived file — the platform handoff', () => { - const item = homeItem({ - id: 9, - slug: 'peca-link', - origin: 'instagram', - media: null, - sourceUrl: 'https://www.instagram.com/p/abc/', - }) - - expect(item.isLink).toBe(true) - expect(item.file).toBeNull() - // S42 — the share sheet reads it from the lean item, so the projection - // must carry the platform URL of a link piece. - expect(item.sourceUrl).toBe('https://www.instagram.com/p/abc/') - }) - }) - - describe('selectContentPieceHomeItems', () => { - const local = homeItem({ id: 1, slug: 'local', cityLabel: 'Feira de Santana' }) - const regional = homeItem({ - id: 2, - slug: 'regional', - cityLabel: 'Serrinha', - region: 'Portal do Sertão', - }) - const other = homeItem({ id: 3, slug: 'outro', cityLabel: 'Ilhéus', region: 'Litoral Sul' }) - const newest = homeItem({ id: 4, slug: 'recente', cityLabel: null, region: null }) - const visitor = { municipalityName: 'Feira de Santana', region: 'Portal do Sertão' } - - it('orders município → região → recentes and annotates every match', () => { - const selection = selectContentPieceHomeItems([other, regional, local], visitor) - - expect(selection.map((entry) => [entry.item.slug, entry.match])).toEqual([ - ['local', 'municipality'], - ['regional', 'region'], - ['outro', 'recent'], - ]) - }) - - it('never repeats a piece that matches more than one bucket', () => { - const both = homeItem({ id: 5, slug: 'both' }) - const selection = selectContentPieceHomeItems([both, local], visitor) - - expect(selection.map((entry) => entry.item.slug)).toEqual(['both', 'local']) - expect(selection[0]?.match).toBe('municipality') - }) - - it('keeps the newest-first order inside each bucket and caps at the limit', () => { - const localNewer = homeItem({ id: 11, slug: 'local-nova' }) - const localOlder = homeItem({ id: 12, slug: 'local-antiga' }) - - const selection = selectContentPieceHomeItems([localNewer, localOlder, regional], visitor, 2) - expect(selection.map((entry) => entry.item.slug)).toEqual(['local-nova', 'local-antiga']) - expect( - selectContentPieceHomeItems([localNewer, localOlder, regional], visitor, 1).map( - (entry) => entry.item.slug, - ), - ).toEqual(['local-nova']) - }) - - it('shows fewer pieces when the catalogue is smaller than the limit', () => { - expect(selectContentPieceHomeItems([local], visitor)).toHaveLength(1) - }) - - it('defaults to three pieces when the catalogue is larger than the sample', () => { - const many = [1, 2, 3, 4, 5].map((id) => homeItem({ id, slug: `peca-${id}` })) - - expect(selectContentPieceHomeItems(many, null)).toHaveLength(3) - }) - - it('falls back to the newest pieces without a visitor or without any label', () => { - const items = [newest, other, local] - - expect(selectContentPieceHomeItems(items, null).map((entry) => entry.item.slug)).toEqual([ - 'recente', - 'outro', - 'local', - ]) - expect( - selectContentPieceHomeItems(items, { municipalityName: null, region: null }).map( - (entry) => entry.match, - ), - ).toEqual(['recent', 'recent', 'recent']) - }) - - it('matches a Salvador ZE by its exact name and degrades to the region without one', () => { - const zonePiece = homeItem({ - id: 6, - slug: 'ze', - cityLabel: 'Salvador — ZE 3', - region: 'Metropolitano de Salvador', - }) - - expect( - selectContentPieceHomeItems([zonePiece], { - municipalityName: 'Salvador — ZE 3', - region: 'Metropolitano de Salvador', - })[0]?.match, - ).toBe('municipality') - expect( - selectContentPieceHomeItems([zonePiece], { - municipalityName: 'Salvador', - region: 'Metropolitano de Salvador', - })[0]?.match, - ).toBe('region') - }) - }) - - describe('resolveContentPieceHomeVisitor', () => { - const feira = municipalitySquare('2910800', 'Feira de Santana', { - west: -39.2, - south: -12.4, - size: 0.4, - }) - const portal = territorySquare('20', 'Portal do Sertão', { - west: -40, - south: -13, - size: 3, - }) - const point = { lat: -12.2, lng: -39 } - - it('resolves the município name and the identity territory', () => { - expect( - resolveContentPieceHomeVisitor({ - point, - municipalityGeometry: { features: [feira] }, - territoryGeometry: { features: [portal] }, - }), - ).toEqual({ municipalityName: 'Feira de Santana', region: 'Portal do Sertão' }) - }) - - it('prefers the Salvador zone name when the zone mesh contains the point', () => { - const salvador = municipalitySquare('2927408', 'Salvador', { - west: -38.6, - south: -13.1, - size: 0.6, - }) - const ze = zoneSquare('salvador-ze-3', 'Salvador — ZE 3', '2927408', { - west: -38.55, - south: -13.05, - size: 0.2, - }) - - expect( - resolveContentPieceHomeVisitor({ - point: { lat: -13, lng: -38.5 }, - municipalityGeometry: { features: [salvador] }, - zoneGeometry: { features: [ze] }, - territoryGeometry: { features: [portal] }, - }), - ).toEqual({ municipalityName: 'Salvador — ZE 3', region: 'Portal do Sertão' }) - }) - - it('falls back to the whole município when no zone contains the point', () => { - const salvador = municipalitySquare('2927408', 'Salvador', { - west: -38.6, - south: -13.1, - size: 0.6, - }) - const ze = zoneSquare('salvador-ze-3', 'Salvador — ZE 3', '2927408', { - west: -38.55, - south: -13.05, - size: 0.05, - }) - - expect( - resolveContentPieceHomeVisitor({ - point: { lat: -12.7, lng: -38.2 }, - municipalityGeometry: { features: [salvador] }, - zoneGeometry: { features: [ze] }, - territoryGeometry: { features: [portal] }, - }), - ).toEqual({ municipalityName: 'Salvador', region: 'Portal do Sertão' }) - }) - - it('keeps only the region on a coarse fix', () => { - expect( - resolveContentPieceHomeVisitor({ - point, - coarse: true, - municipalityGeometry: { features: [feira] }, - territoryGeometry: { features: [portal] }, - }), - ).toEqual({ municipalityName: null, region: 'Portal do Sertão' }) - }) - - it('is null outside the municipal mesh — outside Bahia or without a signal', () => { - expect( - resolveContentPieceHomeVisitor({ - point: { lat: -23.5, lng: -46.6 }, - municipalityGeometry: { features: [feira] }, - territoryGeometry: { features: [portal] }, - }), - ).toBeNull() - expect( - resolveContentPieceHomeVisitor({ - point: { lat: -23.5, lng: -46.6 }, - coarse: true, - municipalityGeometry: { features: [feira] }, - territoryGeometry: { features: [portal] }, - }), - ).toBeNull() - }) - }) -}) diff --git a/tests/unit/contentPieceMedia.unit.spec.tsx b/tests/unit/contentPieceMedia.unit.spec.tsx deleted file mode 100644 index f88f0810..00000000 --- a/tests/unit/contentPieceMedia.unit.spec.tsx +++ /dev/null @@ -1,254 +0,0 @@ -// @vitest-environment jsdom - -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { ContentPieceMedia } from '@/components/conteudos/ContentPieceMedia' -import type { ContentPiecePublicItem } from '@/lib/contentPieceCatalog' - -const item = (patch: Partial<ContentPiecePublicItem> = {}): ContentPiecePublicItem => ({ - id: 7, - slug: 'peca-7', - title: 'Vídeo da peça', - type: 'video', - typeLabel: 'Vídeo', - origin: 'arquivo', - originLabel: 'Arquivo', - isLink: false, - sourceUrl: null, - topics: [], - topicLabels: [], - cityLabel: null, - regionLabel: null, - institution: null, - leaderNames: [], - publicFigures: [], - description: null, - excerpt: null, - durationLabel: '02:14', - pieceDateLabel: null, - metaLabel: 'Tema · Local', - searchText: '', - themeMatch: null, - publicPath: '/conteudos/peca-7', - file: { - id: 107, - path: '/conteudos/peca-7/midia', - mimeType: 'video/mp4', - downloadFilename: 'peca-7.mp4', - }, - framePath: '/conteudos/peca-7/frame', - ...patch, -}) - -const renderSlot = ( - overrides: Partial<ContentPiecePublicItem>, - playing = false, - variant: 'card' | 'thumb' | 'detail' | 'home-thumb' = 'card', -) => - render( - <ContentPieceMedia - item={item(overrides)} - playing={playing} - onToggle={vi.fn()} - onEnded={vi.fn()} - variant={variant} - />, - ) - -/** The play state belongs to the host, so the slot is driven by re-rendering. */ -const playSlot = (view: { rerender: (ui: React.ReactElement) => void }, playing: boolean) => - view.rerender( - <ContentPieceMedia item={item()} playing={playing} onToggle={vi.fn()} onEnded={vi.fn()} />, - ) - -const frameImage = () => - document.querySelector<HTMLImageElement>('img[src="/conteudos/peca-7/frame"]') - -afterEach(cleanup) - -describe('ContentPieceMedia — the video still before the play (C226)', () => { - it('asks for the frame, never for the video file, before the gesture', () => { - renderSlot({}) - - expect(frameImage()).not.toBeNull() - // S45 — the still shows whole in the slot, never cropped. - expect(frameImage()?.className).toContain('object-contain') - expect(document.querySelector('video')).toBeNull() - expect(document.querySelector('audio')).toBeNull() - // S27: the element that would fetch the file only exists after the tap. - expect(document.querySelector('[src="/conteudos/peca-7/midia"]')).toBeNull() - }) - - it('keeps the frame out of the slot once the piece is playing', () => { - renderSlot({}, true) - - expect(frameImage()).toBeNull() - const video = document.querySelector('video') - expect(video).not.toBeNull() - expect(video?.getAttribute('preload')).toBe('none') - expect(video?.getAttribute('src')).toBe('/conteudos/peca-7/midia') - }) - - it('falls back to the neutral slot with the designed marker when there is no frame', () => { - renderSlot({ framePath: null }) - - expect(frameImage()).toBeNull() - expect(screen.getByText('Frame indisponível')).toBeDefined() - // The piece type still reads on the card body tag, not inside the slot. - expect(document.querySelector('video')).toBeNull() - }) - - it('never retries a broken frame: the marker replaces it for the life of the card', () => { - renderSlot({}) - const image = frameImage() - if (!image) throw new Error('the still was not rendered') - - fireEvent.error(image) - expect(frameImage()).toBeNull() - expect(screen.getByText('Frame indisponível')).toBeDefined() - // No broken image is left in the DOM and no second request was made. - expect(document.querySelector('img')).toBeNull() - }) - - it('keeps the still invisible until it decodes (no broken-image glyph ever)', () => { - renderSlot({}) - const image = frameImage() - if (!image) throw new Error('the still was not rendered') - - // Loading: the neutral surface is what the visitor sees — the image never - // paints over it before the browser decodes it. - expect(image.className).toContain('opacity-0') - expect(screen.queryByText('Frame indisponível')).toBeNull() - - fireEvent.load(image) - expect(image.className).not.toContain('opacity-0') - }) - - /** - * The browser fires neither `load` nor `error` again for an image that - * already settled before hydration — the ref is the only thing that can tell - * a decoded still from a failed one, and both outcomes are the fast path. - */ - const withSettledImage = (naturalWidth: number, run: () => void) => { - const complete = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'complete') - Object.defineProperty(HTMLImageElement.prototype, 'complete', { - configurable: true, - get: () => true, - }) - Object.defineProperty(HTMLImageElement.prototype, 'naturalWidth', { - configurable: true, - get: () => naturalWidth, - }) - try { - run() - } finally { - if (complete) { - Object.defineProperty(HTMLImageElement.prototype, 'complete', complete) - } else { - delete (HTMLImageElement.prototype as unknown as Record<string, unknown>).complete - } - delete (HTMLImageElement.prototype as unknown as Record<string, unknown>).naturalWidth - } - } - - it('shows a still that was already decoded before hydration (warm cache)', () => { - withSettledImage(640, () => { - renderSlot({}) - const image = frameImage() - if (!image) throw new Error('the still was not rendered') - expect(image.className).not.toContain('opacity-0') - }) - }) - - it('falls back when the still had already failed before hydration', () => { - withSettledImage(0, () => { - renderSlot({}) - expect(frameImage()).toBeNull() - expect(screen.getByText('Frame indisponível')).toBeDefined() - }) - }) - - it('never asks again after a play/stop cycle (the state outlives the layer)', () => { - const view = renderSlot({}) - const still = frameImage() - if (!still) throw new Error('the still was not rendered') - - fireEvent.error(still) - playSlot(view, true) - playSlot(view, false) - - // The knowledge that this piece has no frame belongs to the card, not to - // the layer that remounts on every play — so the still is never requested - // a second time. - expect(document.querySelector('img')).toBeNull() - expect(screen.getByText('Frame indisponível')).toBeDefined() - }) - - it('keeps an audio piece exactly as it was (no frame, no marker)', () => { - renderSlot({ - type: 'audio', - typeLabel: 'Áudio', - framePath: null, - file: { - id: 107, - path: '/conteudos/peca-7/midia', - mimeType: 'audio/mpeg', - downloadFilename: 'peca-7.mp3', - }, - }) - - expect(document.querySelector('img')).toBeNull() - expect(screen.queryByText('Frame indisponível')).toBeNull() - expect(screen.getByText('Áudio')).toBeDefined() - }) - - it('drops the marker on the 124px home thumb and shrinks the play (CENA 05)', () => { - renderSlot({ framePath: null }, false, 'home-thumb') - - // The neutral surface IS the message there: the marker would crowd the play. - // The compact classes stay in the DOM for the `sm` breakpoint, where that - // card becomes a full block and the full treatment returns. - const marker = screen.getByText('Frame indisponível').closest('span') - expect(marker?.className).toContain('hidden') - expect(marker?.className).toContain('sm:flex') - expect(document.querySelector('button[aria-label^="Reproduzir"]')?.className).toContain( - 'size-11', - ) - expect(document.querySelector('span.rounded.bg-black\\/70')?.className).toContain('text-[10px]') - }) - - it('keeps the full marker on the catalogue card', () => { - renderSlot({ framePath: null }) - - const marker = screen.getByText('Frame indisponível').closest('span') - expect(marker?.className).toContain('flex') - expect(marker?.className).not.toContain('hidden') - }) - - it('keeps the marker and its 28px glyph on the piece page', () => { - renderSlot({ framePath: null }, false, 'detail') - - expect(screen.getByText('Frame indisponível')).toBeDefined() - expect(document.querySelector('span.grid.size-7')).not.toBeNull() - }) - - it('gives a photo piece no frame at all', () => { - renderSlot({ - type: 'foto', - typeLabel: 'Foto', - framePath: null, - file: { - id: 107, - path: '/conteudos/peca-7/midia', - mimeType: 'image/png', - downloadFilename: 'peca-7.png', - }, - }) - - expect(document.querySelectorAll('img')).toHaveLength(1) - expect(document.querySelector('img')?.getAttribute('src')).toBe('/conteudos/peca-7/midia') - // S45 — the photo shows whole in its 4:5 slot, never cropped. - expect(document.querySelector('img')?.className).toContain('object-contain') - }) -}) diff --git a/tests/unit/contentPieceShare.unit.spec.ts b/tests/unit/contentPieceShare.unit.spec.ts deleted file mode 100644 index 0154aedd..00000000 --- a/tests/unit/contentPieceShare.unit.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -// @vitest-environment node - -import { describe, expect, it } from 'vitest' - -import { - buildContentPieceWhatsAppUrl, - contentPieceShareLink, - contentPieceVoteMessage, -} from '@/lib/contentPieceShare' - -const ORIGIN = 'https://jorgesolla1313.com.br' -const LINK = 'https://jorgesolla1313.com.br/conteudos/fim-da-escala-6x1' - -describe('content piece vote message (S27)', () => { - it('uses the video literal with title and link', () => { - expect(contentPieceVoteMessage('video', 'Fim da escala 6×1 é saúde', LINK)).toBe( - `Estamos na reta final e o Jorge Solla 1313 precisa do seu voto. Olha esse vídeo: Fim da escala 6×1 é saúde — ${LINK}. Peça voto pra Solla 1313 pra quem você conhece.`, - ) - }) - - it('uses the audio literal', () => { - expect(contentPieceVoteMessage('audio', 'Mensagem para o grupo', LINK)).toBe( - `Ouça e mande pro grupo: Mensagem para o grupo — ${LINK}. Peça voto pra Solla 1313.`, - ) - }) - - it('uses the material literal for foto, texto and card', () => { - const expected = `Fiz/achei esse material do Solla 1313: Card do giro — ${LINK}. Peça voto pra Solla 1313 também.` - expect(contentPieceVoteMessage('foto', 'Card do giro', LINK)).toBe(expected) - expect(contentPieceVoteMessage('texto', 'Card do giro', LINK)).toBe(expected) - expect(contentPieceVoteMessage('card', 'Card do giro', LINK)).toBe(expected) - }) -}) - -describe('content piece share link (S27)', () => { - it('shares the public page for an archived piece', () => { - expect( - contentPieceShareLink({ slug: 'fim-da-escala-6x1', isLink: false, sourceUrl: null }, ORIGIN), - ).toBe(LINK) - }) - - it('shares the canonical platform URL for a link piece (never a second copy)', () => { - expect( - contentPieceShareLink( - { - slug: 'post-instagram', - isLink: true, - sourceUrl: 'https://www.instagram.com/reel/ABC/', - }, - ORIGIN, - ), - ).toBe('https://www.instagram.com/reel/ABC/') - }) - - it('degrades to the relative path without an origin', () => { - expect(contentPieceShareLink({ slug: 'x', isLink: false, sourceUrl: null }, '')).toBe( - '/conteudos/x', - ) - }) -}) - -describe('content piece WhatsApp url (S27)', () => { - it('prefills the sender text with no recipient and no automatic send', () => { - const url = new URL(buildContentPieceWhatsAppUrl('Peça voto pra Solla 1313')) - expect(url.origin).toBe('https://wa.me') - expect(url.pathname).toBe('/') - expect(url.searchParams.get('text')).toBe('Peça voto pra Solla 1313') - }) -}) diff --git a/tests/unit/e2eAffectedManifest.unit.spec.ts b/tests/unit/e2eAffectedManifest.unit.spec.ts index 2fd2e700..6ab8ba17 100644 --- a/tests/unit/e2eAffectedManifest.unit.spec.ts +++ b/tests/unit/e2eAffectedManifest.unit.spec.ts @@ -58,7 +58,6 @@ describe('E2E_AFFECTED_MANIFEST (OPS5)', () => { 'campaignDemandVisibility', 'campaignAiTranscribe', 'campaignAgendaFeed', - 'campaignNewsletter', // C167 — deliberate: the cut public page/route contract is new and the // migration makes every PR of this delivery high-risk (curated only). 'campaignSpeechCut', @@ -68,13 +67,6 @@ describe('E2E_AFFECTED_MANIFEST (OPS5)', () => { // 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). diff --git a/tests/unit/imageDelivery.unit.spec.ts b/tests/unit/imageDelivery.unit.spec.ts index 51943f85..864952dd 100644 --- a/tests/unit/imageDelivery.unit.spec.ts +++ b/tests/unit/imageDelivery.unit.spec.ts @@ -45,7 +45,7 @@ const collectSourceImageRefs = (pattern: RegExp) => { describe('entrega das imagens do hero (perf do /_next/image)', () => { it('usa masters no tamanho máximo de exibição, sem forçar decode de 3840px', async () => { - expect(localHeroAssets.length).toBeGreaterThanOrEqual(5) + expect(localHeroAssets.length).toBeGreaterThanOrEqual(1) for (const src of localHeroAssets) { const { width, height } = await sharp(publicFile(src)).metadata() diff --git a/tests/unit/jingle.unit.spec.ts b/tests/unit/jingle.unit.spec.ts deleted file mode 100644 index 7aa36002..00000000 --- a/tests/unit/jingle.unit.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { jingleDownloadFilename, splitJingleTitle, toJingleViewModel } from '@/lib/jingle' - -describe('jingleDownloadFilename', () => { - it.each([ - ['axe', 'Jorge Solla 1313 - Axé.mp3', 'jorge-solla-1313-axe.mp3'], - ['forro', 'JORGE SOLLA 1313 - FORRO.MP3', 'jorge-solla-1313-forro.mp3'], - ['Pagodão', 'Jorge Solla 1313 - Pagodão.wav', 'jorge-solla-1313-pagodao.wav'], - ['axe-2026', 'audio.mp3', 'jorge-solla-1313-axe-2026.mp3'], - ])('derives %s → %s', (slug, filename, expected) => { - expect(jingleDownloadFilename(slug, filename)).toBe(expected) - }) - - it('falls back to the generic base when the slug normalizes to nothing', () => { - expect(jingleDownloadFilename('---', 'audio.mp3')).toBe('jorge-solla-1313-jingle.mp3') - expect(jingleDownloadFilename(null, null)).toBe('jorge-solla-1313-jingle.mp3') - }) - - it('falls back to mp3 when the stored filename has no usable extension', () => { - expect(jingleDownloadFilename('axe', 'Jorge Solla 1313 - Axé')).toBe('jorge-solla-1313-axe.mp3') - expect(jingleDownloadFilename('axe', 'audio.')).toBe('jorge-solla-1313-axe.mp3') - expect(jingleDownloadFilename('axe', 'audio')).toBe('jorge-solla-1313-axe.mp3') - }) -}) - -describe('splitJingleTitle', () => { - it.each([ - ['Jorge Solla 1313 (feat. Felipe Forrozeiro)', 'Jorge Solla 1313', 'feat. Felipe Forrozeiro'], - ['Jorge Solla 1313 (feat. Nagib Barroso)', 'Jorge Solla 1313', 'feat. Nagib Barroso'], - ['Jorge Solla 1313 (feat. É O MT)', 'Jorge Solla 1313', 'feat. É O MT'], - ['Jorge Solla 1313 (FEAT. É O MT)', 'Jorge Solla 1313', 'feat. É O MT'], - ['Jorge Solla 1313 ( feat. É O MT )', 'Jorge Solla 1313', 'feat. É O MT'], - ['Jorge Solla 1313 (feat. Felipe Forrozeiro) ', 'Jorge Solla 1313', 'feat. Felipe Forrozeiro'], - ['Jorge Solla 1313 (feat. A) (feat. B)', 'Jorge Solla 1313 (feat. A)', 'feat. B'], - ])('splits %s', (title, base, credit) => { - expect(splitJingleTitle(title)).toEqual({ base, credit }) - }) - - it.each([ - 'Axé', - 'Forró', - 'Jorge Solla 1313 (ao vivo)', - 'Jorge Solla 1313 (feat. )', - '(feat. Felipe Forrozeiro)', - 'Jorge Solla 1313 (feat. X) ao vivo', - ])('keeps %s verbatim without a credit line', (title) => { - expect(splitJingleTitle(title)).toEqual({ base: title, credit: null }) - }) -}) - -describe('toJingleViewModel', () => { - const jingle = { id: 7, title: 'Axé', slug: 'axe' } - - it('fails closed without a readable cover or audio', () => { - expect(toJingleViewModel({ jingle, cover: null, audio: { url: '/a.mp3' } })).toBeNull() - expect(toJingleViewModel({ jingle, cover: { url: '/c.jpg' }, audio: null })).toBeNull() - expect(toJingleViewModel({ jingle, cover: { url: null }, audio: { url: '/a.mp3' } })).toBeNull() - }) - - it('maps the card and derives the download name', () => { - expect( - toJingleViewModel({ - jingle, - cover: { url: '/capa.jpg', alt: 'Capa oficial' }, - audio: { url: '/axe.mp3', filename: 'Jorge Solla 1313 - Axé.mp3' }, - }), - ).toEqual({ - id: 7, - title: 'Axé', - coverUrl: '/capa.jpg', - coverAlt: 'Capa oficial', - audioUrl: '/axe.mp3', - downloadFilename: 'jorge-solla-1313-axe.mp3', - }) - }) - - it('falls back to a readable cover alt', () => { - expect( - toJingleViewModel({ - jingle, - cover: { url: '/capa.jpg', alt: ' ' }, - audio: { url: '/axe.mp3' }, - })?.coverAlt, - ).toBe('Capa do jingle Axé de Jorge Solla 1313') - }) -}) diff --git a/tests/unit/jingleHomeSection.unit.spec.tsx b/tests/unit/jingleHomeSection.unit.spec.tsx deleted file mode 100644 index 73cd61c7..00000000 --- a/tests/unit/jingleHomeSection.unit.spec.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { JingleHomeSection } from '@/components/jingles/JingleHomeSection' -import type { JingleViewModel } from '@/lib/jingle' - -type MockImageProps = React.ImgHTMLAttributes<HTMLImageElement> & { - fill?: boolean - priority?: boolean -} - -vi.mock('next/image', () => ({ - default: ({ alt, fill: _fill, priority: _priority, ...props }: MockImageProps) => ( - // eslint-disable-next-line @next/next/no-img-element - <img alt={alt} {...props} /> - ), -})) - -vi.mock('next/link', () => ({ - default: ({ children, href, ...props }: { children: React.ReactNode; href: string }) => ( - <a href={href} {...props}> - {children} - </a> - ), -})) - -const jingle = (id: number, title: string): JingleViewModel => ({ - id, - title, - coverUrl: `/api/media/file/capa-${id}.jpg`, - coverAlt: `Capa do jingle ${title} de Jorge Solla 1313`, - audioUrl: `/api/media/file/${id}.mp3`, - downloadFilename: `jorge-solla-1313-${id}.mp3`, -}) - -const THREE_JINGLES = [jingle(1, 'Axé'), jingle(2, 'Forró'), jingle(3, 'Pagodão')] as const -const cards = () => document.querySelectorAll('article[data-jingle]') - -afterEach(cleanup) - -describe('JingleHomeSection', () => { - it('renders the radio alone with zero published jingles (fail-closed)', () => { - render(<JingleHomeSection jingles={[]} showAll={false} />) - - expect(document.querySelector('[data-home-section="sound"]')).not.toBeNull() - // S25 — the own player: one lazy audio, no iframe anywhere. - expect(document.querySelector('[data-radio] audio[preload="none"]')).not.toBeNull() - expect(document.querySelector('[data-radio] iframe')).toBeNull() - // Cena 02 (S25): zero jingles keeps the compact, centered player. - expect(document.querySelector('[data-radio]')?.className).toContain('max-w-[880px]') - expect(cards()).toHaveLength(0) - expect(screen.queryByText('Jingles oficiais')).toBeNull() - expect(screen.queryByRole('link', { name: /Ver todos os jingles/ })).toBeNull() - expect(screen.getByRole('heading', { name: 'Sintonize com a Rádio 1313' })).toBeDefined() - }) - - it('renders one or two jingles without inventing content or "Ver todos"', () => { - render(<JingleHomeSection jingles={[jingle(1, 'Axé')]} showAll={false} />) - - expect(cards()).toHaveLength(1) - // Cena 01: with jingles the player takes the full column width. - expect(document.querySelector('[data-radio]')?.className).not.toContain('max-w-[880px]') - expect(screen.queryByRole('link', { name: /Ver todos os jingles/ })).toBeNull() - expect(screen.getByText('Jingles oficiais')).toBeDefined() - }) - - it('renders the three shown jingles without "Ver todos" when the listing fits', () => { - render(<JingleHomeSection jingles={THREE_JINGLES} showAll={false} />) - - expect(cards()).toHaveLength(3) - expect(screen.getByRole('heading', { name: 'Cante, baixe e espalhe' })).toBeDefined() - expect(screen.queryByRole('link', { name: /Ver todos os jingles/ })).toBeNull() - }) - - it('hands off to /jingles only when more than three are published', () => { - render(<JingleHomeSection jingles={THREE_JINGLES} showAll />) - - const link = screen.getByRole('link', { name: /Ver todos os jingles/ }) - expect(link.getAttribute('href')).toBe('/jingles') - }) -}) diff --git a/tests/unit/jinglePlayer.unit.spec.tsx b/tests/unit/jinglePlayer.unit.spec.tsx deleted file mode 100644 index 56083f9b..00000000 --- a/tests/unit/jinglePlayer.unit.spec.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' - -import { JinglePlayer } from '@/components/jingles/JinglePlayer' -import type { JingleViewModel } from '@/lib/jingle' -import { JINGLE_PLAY_EVENT, RADIO_PLAY_EVENT } from '@/lib/radio' - -const JINGLES: readonly JingleViewModel[] = [ - { - id: 1, - title: 'Axé', - coverUrl: '/api/media/file/capa-axe.jpg', - coverAlt: 'Capa do jingle Axé de Jorge Solla 1313', - audioUrl: '/api/media/file/axe.mp3', - downloadFilename: 'jorge-solla-1313-axe.mp3', - }, - { - id: 2, - title: 'Forró', - coverUrl: '/api/media/file/capa-forro.jpg', - coverAlt: 'Capa do jingle Forró de Jorge Solla 1313', - audioUrl: '/api/media/file/forro.mp3', - downloadFilename: 'jorge-solla-1313-forro.mp3', - }, -] - -const playMock = vi.fn(() => Promise.resolve()) -const pauseMock = vi.fn() - -// S26 — own fixture: the specs above count the "Pronto para tocar" texts of -// the shared array, so the credited title stays out of `JINGLES`. -const FEAT_JINGLE: JingleViewModel = { - id: 9, - title: 'Jorge Solla 1313 (feat. Felipe Forrozeiro)', - coverUrl: '/api/media/file/capa-forro.jpg', - coverAlt: 'Capa do jingle Jorge Solla 1313 (feat. Felipe Forrozeiro) de Jorge Solla 1313', - audioUrl: '/api/media/file/forro.mp3', - downloadFilename: 'jorge-solla-1313-forro.mp3', -} - -const cards = () => Array.from(document.querySelectorAll<HTMLElement>('[data-jingle]')) -const cardState = (index: number) => cards()[index]?.getAttribute('data-state') -const playButton = (index: number) => - cards()[index]?.querySelector<HTMLButtonElement>('button[data-play]') as HTMLButtonElement -const audioOf = (index: number) => - cards()[index]?.querySelector<HTMLAudioElement>('audio') as HTMLAudioElement -const progressValue = (title: string) => - document.querySelector<HTMLElement>(`[aria-label="Progresso do jingle ${title}"] > div > div`) - -beforeAll(() => { - // jsdom does not implement media playback; the state machine is the contract. - Object.defineProperty(HTMLMediaElement.prototype, 'play', { - configurable: true, - value: () => playMock(), - }) - Object.defineProperty(HTMLMediaElement.prototype, 'pause', { - configurable: true, - value: () => pauseMock(), - }) - Object.defineProperty(HTMLMediaElement.prototype, 'currentTime', { - configurable: true, - writable: true, - value: 0, - }) -}) - -beforeEach(() => { - playMock.mockClear() - playMock.mockImplementation(() => Promise.resolve()) - pauseMock.mockClear() -}) - -afterEach(() => { - cleanup() -}) - -describe('JinglePlayer', () => { - it('renders the cards with the download named by slug and lazy audio', () => { - render(<JinglePlayer jingles={JINGLES} />) - - const download = screen.getByRole('link', { name: 'Baixar Axé em MP3' }) - expect(download.getAttribute('href')).toBe('/api/media/file/axe.mp3') - expect(download.getAttribute('download')).toBe('jorge-solla-1313-axe.mp3') - expect(screen.getByText('jorge-solla-1313-axe.mp3')).toBeDefined() - expect(screen.getByAltText('Capa do jingle Axé de Jorge Solla 1313')).toBeDefined() - expect(screen.getAllByText('Pronto para tocar')).toHaveLength(2) - expect(screen.queryByText('Em reprodução')).toBeNull() - - for (const audio of Array.from(document.querySelectorAll('audio'))) { - expect(audio.getAttribute('preload')).toBe('none') - } - }) - - it('plays on click and pauses on a second click of the same card', () => { - render(<JinglePlayer jingles={JINGLES} />) - - // The first play also pauses the sibling card (a no-op with it stopped), - // so the mock is cleared before pinning the pause of the card itself. - fireEvent.click(playButton(0)) - - expect(playMock).toHaveBeenCalledTimes(1) - expect(cardState(0)).toBe('playing') - expect(playButton(0).getAttribute('aria-pressed')).toBe('true') - expect(playButton(0).getAttribute('aria-label')).toBe('Pausar jingle Axé') - expect(screen.getByText('Em reprodução')).toBeDefined() - - pauseMock.mockClear() - fireEvent.click(playButton(0)) - - expect(pauseMock).toHaveBeenCalledTimes(1) - expect(cardState(0)).toBe('stopped') - expect(playButton(0).getAttribute('aria-pressed')).toBe('false') - }) - - it('plays one at a time: starting another pauses the previous card', () => { - render(<JinglePlayer jingles={JINGLES} />) - - fireEvent.click(playButton(0)) - pauseMock.mockClear() - fireEvent.click(playButton(1)) - - expect(pauseMock).toHaveBeenCalledTimes(1) - expect(cardState(0)).toBe('stopped') - expect(cardState(1)).toBe('playing') - expect(playMock).toHaveBeenCalledTimes(2) - }) - - it('broadcasts jingle:play when a card starts', () => { - let broadcasts = 0 - const listener = () => { - broadcasts += 1 - } - window.addEventListener(JINGLE_PLAY_EVENT, listener) - render(<JinglePlayer jingles={JINGLES} />) - - fireEvent.click(playButton(0)) - window.removeEventListener(JINGLE_PLAY_EVENT, listener) - - expect(broadcasts).toBe(1) - }) - - it('pauses the active card when the radio starts', () => { - render(<JinglePlayer jingles={JINGLES} />) - - fireEvent.click(playButton(0)) - pauseMock.mockClear() - - act(() => { - window.dispatchEvent(new CustomEvent(RADIO_PLAY_EVENT, { detail: { source: 'radio' } })) - }) - - expect(pauseMock).toHaveBeenCalledTimes(1) - expect(cardState(0)).toBe('stopped') - expect(playButton(0).getAttribute('aria-pressed')).toBe('false') - }) - - it('ignores radio:play with no active card', () => { - render(<JinglePlayer jingles={JINGLES} />) - - act(() => { - window.dispatchEvent(new CustomEvent(RADIO_PLAY_EVENT, { detail: { source: 'radio' } })) - }) - - expect(pauseMock).not.toHaveBeenCalled() - }) - - it('reverts the card when the browser refuses to play', async () => { - playMock.mockImplementationOnce(() => Promise.reject(new Error('blocked'))) - render(<JinglePlayer jingles={JINGLES} />) - - fireEvent.click(playButton(0)) - - await waitFor(() => { - expect(cardState(0)).toBe('stopped') - }) - expect(playButton(0).getAttribute('aria-pressed')).toBe('false') - }) - - it('does not let a superseded play() rejection stop the card now playing', async () => { - // Switching cards aborts the pending play of the previous one: its catch - // must clear only its own id, never the active card. - let rejectFirstPlay: ((error: Error) => void) | undefined - playMock.mockImplementationOnce( - () => - new Promise<void>((_resolve, reject) => { - rejectFirstPlay = reject - }), - ) - render(<JinglePlayer jingles={JINGLES} />) - - fireEvent.click(playButton(0)) - fireEvent.click(playButton(1)) - - await act(async () => { - rejectFirstPlay?.(new Error('The play() request was interrupted')) - }) - - expect(cardState(1)).toBe('playing') - expect(playButton(1).getAttribute('aria-pressed')).toBe('true') - expect(cardState(0)).toBe('stopped') - }) - - it('shows the clock and progress from the media events', () => { - render(<JinglePlayer jingles={JINGLES} />) - - expect(screen.getAllByText(/Tempo —:—/)).toHaveLength(2) - expect(screen.getAllByText(/Duração —:—/)).toHaveLength(2) - - const audio = audioOf(0) - fireEvent.click(playButton(0)) - Object.defineProperty(audio, 'duration', { configurable: true, value: 165 }) - fireEvent.loadedMetadata(audio) - expect(screen.getByText(/Duração 02:45/)).toBeDefined() - - audio.currentTime = 30 - fireEvent.timeUpdate(audio) - expect(screen.getByText(/Tempo 00:30/)).toBeDefined() - expect(parseFloat(progressValue('Axé')?.style.width ?? '')).toBeCloseTo(18.18, 1) - - fireEvent.ended(audio) - expect(cardState(0)).toBe('stopped') - expect(screen.getAllByText(/Tempo —:—/)).toHaveLength(2) - }) -}) - -describe('JinglePlayer — título e crédito do artista (S26)', () => { - it('shows the base title untruncated and the credit on its own line, stopped and playing', () => { - render(<JinglePlayer jingles={[FEAT_JINGLE]} />) - - const heading = screen.getByRole('heading', { level: 2, name: 'Jorge Solla 1313' }) - expect(heading.className).not.toContain('truncate') - expect(heading.className).toContain('wrap-anywhere') - expect(screen.getByText('feat. Felipe Forrozeiro')).toBeDefined() - expect(screen.getByText('Pronto para tocar')).toBeDefined() - - fireEvent.click(playButton(0)) - - expect(cardState(0)).toBe('playing') - expect(screen.getByText('Em reprodução')).toBeDefined() - expect(screen.getByText('feat. Felipe Forrozeiro')).toBeDefined() - }) - - it('keeps the full title in the play, progress and download labels', () => { - render(<JinglePlayer jingles={[FEAT_JINGLE]} />) - - const fullTitle = 'Jorge Solla 1313 (feat. Felipe Forrozeiro)' - expect(playButton(0).getAttribute('aria-label')).toBe(`Tocar jingle ${fullTitle}`) - expect(document.querySelector(`[aria-label="Progresso do jingle ${fullTitle}"]`)).not.toBeNull() - expect(screen.getByRole('link', { name: `Baixar ${fullTitle} em MP3` })).toBeDefined() - }) - - it('reserves no credit line and still drops the truncation without a feat. segment', () => { - render(<JinglePlayer jingles={JINGLES} />) - - expect(screen.queryByText(/^feat\./)).toBeNull() - for (const heading of screen.getAllByRole('heading', { level: 2 })) { - expect(heading.className).not.toContain('truncate') - expect(heading.className).toContain('wrap-anywhere') - } - }) -}) diff --git a/tests/unit/radio.unit.spec.ts b/tests/unit/radio.unit.spec.ts deleted file mode 100644 index 385fbb0d..00000000 --- a/tests/unit/radio.unit.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from 'vitest' - -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' - -describe('radio contract', () => { - it('pins the stream, the public page and the fixed display title', () => { - expect(RADIO_STREAM_URL).toBe('https://stream.zeno.fm/hys86kx6k16tv') - expect(RADIO_PAGE_URL).toBe('https://zeno.fm/radio/jorge-solla-1313/') - expect(RADIO_TITLE).toBe('Rádio Jorge Solla 1313') - }) - - it('builds the literal share message of the gate', () => { - expect(RADIO_SHARE_MESSAGE).toBe( - 'Ouça a Rádio Jorge Solla 1313 — https://zeno.fm/radio/jorge-solla-1313/', - ) - }) - - it('opens the sender WhatsApp with the literal message inside the URL', () => { - const url = buildRadioShareWhatsAppUrl() - - expect(new URL(url).searchParams.get('text')).toBe(RADIO_SHARE_MESSAGE) - }) - - it('arms a finite connect timeout', () => { - expect(RADIO_CONNECT_TIMEOUT_MS).toBe(12_000) - }) - - it('names the exclusivity broadcast events of the sound section', () => { - expect(RADIO_PLAY_EVENT).toBe('radio:play') - expect(JINGLE_PLAY_EVENT).toBe('jingle:play') - }) -}) diff --git a/tests/unit/radioPlayer.unit.spec.tsx b/tests/unit/radioPlayer.unit.spec.tsx deleted file mode 100644 index 0693d97d..00000000 --- a/tests/unit/radioPlayer.unit.spec.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' - -import { RadioPlayer } from '@/components/jingles/RadioPlayer' -import { - JINGLE_PLAY_EVENT, - RADIO_CONNECT_TIMEOUT_MS, - RADIO_PAGE_URL, - RADIO_PLAY_EVENT, - RADIO_SHARE_MESSAGE, - RADIO_STREAM_URL, -} from '@/lib/radio' - -vi.mock('next/image', () => ({ - default: ({ alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => ( - // eslint-disable-next-line @next/next/no-img-element - <img alt={alt} {...props} /> - ), -})) - -const playMock = vi.fn<() => Promise<void>>() -const pauseMock = vi.fn() -const loadMock = vi.fn() - -const player = () => document.querySelector<HTMLElement>('[data-radio]') -const dataState = () => player()?.getAttribute('data-state') -const audioOf = () => document.querySelector<HTMLAudioElement>('[data-radio] audio') -const playButton = () => - document.querySelector<HTMLButtonElement>( - '[data-radio] button[aria-pressed]', - ) as HTMLButtonElement - -const captureEvents = (eventName: string) => { - let count = 0 - const listener = () => { - count += 1 - } - window.addEventListener(eventName, listener) - return { - count: () => count, - stop: () => window.removeEventListener(eventName, listener), - } -} - -beforeAll(() => { - // jsdom does not implement media playback; the state machine is the contract. - Object.defineProperty(HTMLMediaElement.prototype, 'play', { - configurable: true, - value: () => playMock(), - }) - Object.defineProperty(HTMLMediaElement.prototype, 'pause', { - configurable: true, - value: () => pauseMock(), - }) - Object.defineProperty(HTMLMediaElement.prototype, 'load', { - configurable: true, - value: () => loadMock(), - }) -}) - -beforeEach(() => { - playMock.mockReset() - playMock.mockResolvedValue(undefined) - pauseMock.mockReset() - loadMock.mockReset() - // The share popover (radix) measures its content on open. - vi.stubGlobal( - 'ResizeObserver', - class { - observe() {} - unobserve() {} - disconnect() {} - }, - ) -}) - -afterEach(() => { - cleanup() - vi.clearAllMocks() - vi.unstubAllGlobals() - vi.useRealTimers() -}) - -describe('RadioPlayer', () => { - it('renders the own player with lazy audio and no iframe', () => { - render(<RadioPlayer />) - - expect(screen.getByRole('article', { name: 'Player da Rádio Jorge Solla 1313' })).toBeDefined() - expect(dataState()).toBe('idle') - expect(document.querySelector('[data-radio] iframe')).toBeNull() - expect(audioOf()?.getAttribute('preload')).toBe('none') - expect(audioOf()?.getAttribute('src')).toBe(RADIO_STREAM_URL) - expect(screen.getByText('Rádio Jorge Solla 1313')).toBeDefined() - expect(screen.getByText('Ao vivo')).toBeDefined() - expect(screen.getByText('Pronta para tocar')).toBeDefined() - expect(playButton().getAttribute('aria-label')).toBe('Ouvir Rádio Jorge Solla 1313') - expect(playButton().getAttribute('aria-pressed')).toBe('false') - // One trigger per breakpoint (outline inline on md+, link on the bottom - // row) — jsdom applies no CSS, so both mounts are in the tree here. - expect( - screen.getAllByRole('button', { name: 'Compartilhar Rádio Jorge Solla 1313' }), - ).toHaveLength(2) - expect(playMock).not.toHaveBeenCalled() - expect(loadMock).not.toHaveBeenCalled() - }) - - it('connects on the play and reaches playing with the exclusivity broadcast', async () => { - const playEvents = captureEvents(RADIO_PLAY_EVENT) - render(<RadioPlayer />) - - fireEvent.click(playButton()) - - expect(loadMock).toHaveBeenCalledTimes(1) - expect(playMock).toHaveBeenCalledTimes(1) - expect(dataState()).toBe('connecting') - expect(playButton().getAttribute('aria-busy')).toBe('true') - expect(playButton().getAttribute('aria-label')).toBe('Conectando') - expect(screen.getByText('Conectando à rádio…')).toBeDefined() - expect(playEvents.count()).toBe(1) - - await waitFor(() => expect(dataState()).toBe('playing')) - expect(screen.getByText('Em reprodução')).toBeDefined() - expect(playButton().getAttribute('aria-pressed')).toBe('true') - expect(playButton().getAttribute('aria-label')).toBe('Pausar Rádio Jorge Solla 1313') - playEvents.stop() - }) - - it('toggles off and pauses the audio', async () => { - render(<RadioPlayer />) - - fireEvent.click(playButton()) - await waitFor(() => expect(dataState()).toBe('playing')) - - fireEvent.click(playButton()) - - expect(pauseMock).toHaveBeenCalledTimes(1) - expect(dataState()).toBe('idle') - expect(screen.getByText('Pronta para tocar')).toBeDefined() - }) - - it('turns a refused play into the honest error state with the exits', async () => { - playMock.mockRejectedValueOnce(new Error('blocked')) - render(<RadioPlayer />) - - fireEvent.click(playButton()) - - await waitFor(() => expect(dataState()).toBe('error')) - expect(screen.getByText('A rádio não conectou')).toBeDefined() - expect(screen.getByText('Tente de novo ou ouça na página da rádio.')).toBeDefined() - expect(screen.getByRole('button', { name: 'Tentar novamente' })).toBeDefined() - const zeno = screen.getByRole('link', { name: 'Ouvir no Zeno' }) - expect(zeno.getAttribute('href')).toBe(RADIO_PAGE_URL) - expect(zeno.getAttribute('target')).toBe('_blank') - expect(zeno.getAttribute('rel')).toBe('noopener noreferrer') - expect( - screen.getByRole('button', { name: 'Compartilhar Rádio Jorge Solla 1313' }), - ).toBeDefined() - }) - - it('turns a media error event into the error state', () => { - render(<RadioPlayer />) - - fireEvent.click(playButton()) - fireEvent.error(audioOf()!) - - expect(dataState()).toBe('error') - expect(screen.getByText('A rádio não conectou')).toBeDefined() - }) - - it('never spins forever: the connect timeout becomes the error state', () => { - vi.useFakeTimers() - playMock.mockImplementationOnce(() => new Promise<void>(() => {})) - render(<RadioPlayer />) - - fireEvent.click(playButton()) - expect(dataState()).toBe('connecting') - - act(() => { - vi.advanceTimersByTime(RADIO_CONNECT_TIMEOUT_MS) - }) - - expect(pauseMock).toHaveBeenCalledTimes(1) - expect(dataState()).toBe('error') - expect(screen.getByText('A rádio não conectou')).toBeDefined() - }) - - it('retries with a fresh request from the error state', async () => { - playMock.mockRejectedValueOnce(new Error('blocked')) - render(<RadioPlayer />) - - fireEvent.click(playButton()) - await waitFor(() => expect(dataState()).toBe('error')) - - fireEvent.click(screen.getByRole('button', { name: 'Tentar novamente' })) - - expect(loadMock).toHaveBeenCalledTimes(2) - await waitFor(() => expect(dataState()).toBe('playing')) - }) - - it('pauses the radio when a jingle starts', async () => { - render(<RadioPlayer />) - - fireEvent.click(playButton()) - await waitFor(() => expect(dataState()).toBe('playing')) - - act(() => { - window.dispatchEvent( - new CustomEvent(JINGLE_PLAY_EVENT, { detail: { source: 'jingle', id: 7 } }), - ) - }) - - expect(pauseMock).toHaveBeenCalledTimes(1) - expect(dataState()).toBe('idle') - expect(screen.getByText('Pronta para tocar')).toBeDefined() - }) - - it('ignores a superseded play() resolution after the user pauses', async () => { - let resolveFirst: (() => void) | undefined - playMock.mockImplementationOnce( - () => - new Promise<void>((resolve) => { - resolveFirst = resolve - }), - ) - render(<RadioPlayer />) - - fireEvent.click(playButton()) - expect(dataState()).toBe('connecting') - - fireEvent.click(playButton()) - expect(dataState()).toBe('idle') - - await act(async () => { - resolveFirst?.() - }) - - expect(dataState()).toBe('idle') - }) - - it('keeps a single share trigger in the compact variant', () => { - const { container } = render(<RadioPlayer compact />) - - expect(container.querySelector('[data-radio]')?.className).toContain('max-w-[880px]') - expect( - screen.getAllByRole('button', { name: 'Compartilhar Rádio Jorge Solla 1313' }), - ).toHaveLength(1) - expect(container.querySelector('[data-radio] iframe')).toBeNull() - }) - - it('shows the literal message and the two exits in the share popover', async () => { - render(<RadioPlayer />) - - const [trigger] = screen.getAllByRole('button', { - name: 'Compartilhar Rádio Jorge Solla 1313', - }) - fireEvent.click(trigger!) - - expect(await screen.findByText(RADIO_SHARE_MESSAGE)).toBeDefined() - // The portal escapes the home's theme wrapper: the content has to carry - // `data-theme` or the campaign tokens render the CTA invisible. - expect( - document.querySelector('[data-slot="popover-content"]')?.getAttribute('data-theme'), - ).toBe('campaign-site') - const whatsApp = screen.getByRole('link', { name: 'Compartilhar no WhatsApp' }) - expect(whatsApp.getAttribute('href')).toMatch(/^https:\/\/wa\.me\/\?text=/) - expect(new URL(whatsApp.getAttribute('href')!).searchParams.get('text')).toBe( - RADIO_SHARE_MESSAGE, - ) - expect(whatsApp.getAttribute('target')).toBe('_blank') - expect(screen.getByRole('button', { name: 'Copiar link' })).toBeDefined() - }) -}) diff --git a/tests/unit/siteMetadata.unit.spec.ts b/tests/unit/siteMetadata.unit.spec.ts index 20248dcf..3e5f2ccf 100644 --- a/tests/unit/siteMetadata.unit.spec.ts +++ b/tests/unit/siteMetadata.unit.spec.ts @@ -20,6 +20,12 @@ afterEach(() => { }) describe('resolveSiteMetadata', () => { + it('keeps the fallback description on the mandate (no campaign number)', () => { + expect(SITE_METADATA_DEFAULTS.description).not.toContain('1313') + expect(SITE_METADATA_DEFAULTS.description).not.toMatch(/campanha/i) + expect(SITE_METADATA_DEFAULTS.description).toMatch(/mandato/i) + }) + it('returns defaults and null siteUrl when the global is empty and env is unset', () => { delete process.env.NEXT_PUBLIC_SITE_URL