mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
feat(S21): jingles de Jorge Solla no site público — ouvir e baixar (Closes #1222)
This commit is contained in:
parent
8bb3523408
commit
88f1907dcd
38 changed files with 19230 additions and 93 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -124,6 +124,9 @@ next-env.d.ts
|
|||
*.m4a
|
||||
*.mp3
|
||||
*.webm
|
||||
# S21 — the int/e2e jingle fixture (~5 KB, sine tone): the only audio the repo
|
||||
# commits; every real jingle lives in the media collection.
|
||||
!tests/fixtures/*.mp3
|
||||
|
||||
# opencode secrets (Penpot MCP token, etc.)
|
||||
.opencode/secrets/
|
||||
|
|
|
|||
1
docs/changelog/2026-09-20-s21.md
Normal file
1
docs/changelog/2026-09-20-s21.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**S21 — Jingles de Jorge Solla no site público: ouvir e baixar (2026-09-20):** o mandato ganha uma casa oficial para os jingles — a página pública `/jingles` (tema `campaign-site`, portada classe-a-classe do artefato aprovado `docs/plans/jingles-site-publico-ui-design.html`) lista os jingles publicados em grid de 3 colunas no desktop e coluna única no celular (capa quadrada com gradiente, play amarelo de 56px com sombra 3D, título, estado "Pronto para tocar"/"Em reprodução", trilha de progresso e Tempo/Duração reais, "Baixar MP3" com alvo de 44px e o nome do arquivo), com header próprio (marca completa + selo "Jingles oficiais"), hero "A trilha da nossa caminhada" e o rodapé eleitoral compartilhado. O player é um único client component (`JinglePlayer`): cada card tem seu `<audio preload="none">` — abrir a página **não** baixa áudio, comprovado no e2e —, tocar um pausa os demais (com o catch do `play()` superado preso ao próprio card) e o download usa `href`+`download` da mídia same-origin com nome legível derivado do slug (`jorge-solla-1313-<slug>.<ext>`; `WAV` fica fora do site). Conteúdo gerenciável no CMS: collection nova `jingle` (título, slug único auto-slugificado com validação de não-vazio, capa e áudio pela `media` existente — sem collection paralela —, `order` opcional e `published` como kill switch), escrita por `canManagePublishedContent` e leitura anônima fail-closed por um `publishedOrPanelAccess` novo no dono do access (`access/shared.ts`), que passa a servir também o `ShareLink`. Com zero publicados a página responde 200 com estado honesto (`noindex,nofollow`, ícone de nota cortada e "Voltar ao início"), o selo do header some e o link "Jingles" do rodapé desaparece — `hasPublishedJingles` deriva da mesma lista renderizável e alimenta home, cards e a própria página via prop (o rodapé segue um leaf síncrono; leitura cacheada pela tag `jingles`). Leitura em `unstable_cache` depth 0 com as mídias resolvidas por `getCachedDocumentById`: trocar o **arquivo** de uma capa/áudio no admin atualiza o card sem editar o jingle (revalidação por `revalidateJinglesListing` nos hooks `afterChange`/`afterDelete`), e o segmento estático novo fica reservado contra colisão com share links (`SHARE_LINK_RESERVED_SLUGS`, drift test fail-closed). Migration aditiva `add_jingle` (FKs para `media`). Testes: 15 unit novos (helper/mapper + RTL do player cobrindo play/pausa, exclusividade, rejeição do `play()` e relógio/progresso), 9 int (slug, capa/áudio obrigatórios, access fail-closed para anônimo e `campaignUser` em create/update/delete × editor, ordenação `order`+título, resolução e troca de mídia, revalidação da tag) e e2e `frontendJingles` novo (lista + badge + link do rodapé, zero request de áudio antes do play, um por vez, `suggestedFilename` do download, kill switch 200 vazio ↔ indexável) — pinado no manifesto da área e no conjunto curado OPS86 (a migration torna o PR high-risk), com fixture MP3 mínima commitada em `tests/fixtures/`. Verificação: `pnpm gate:fast` verde (4208 unit), int 892 verdes, e2e local selecionado 41 verdes (família `frontend` + `frontendJingles` + `campaignSpeechCut`), `pnpm build` com `/` e `/jingles` estáticas, e crítica final do `designer` **CERTIFICADO** contra o app renderizado (390/1280, tocando e vazio).
|
||||
192
docs/plans/jingles-site-publico-impl.md
Normal file
192
docs/plans/jingles-site-publico-impl.md
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
# Impl: Jingles de Jorge Solla no site público — ouvir e baixar (S21)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-09-20
|
||||
Issue: #1222
|
||||
Intenção: docs/plans/jingles-site-publico.md
|
||||
Appetite restante: herdado (~1–1,5 dia eng) — sem infra nova, sem dependência de serviço externo; cortes nomeados em "Rabbit holes"
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** uma página pública `/jingles` lista os jingles publicados com capa, título e player que toca ali mesmo (um por vez, sem abrir terceiros) e cada jingle baixa direto o MP3 com nome de arquivo legível — gerenciável no CMS, com kill switch por item e por página, leve no celular e indexável com título/descrição próprios.
|
||||
- **O que NÃO negociar:**
|
||||
- **Kill switch fail-closed:** cada jingle sai do ar por despublicação (checkbox), a página inteira fica vazia/honesta com zero publicados, o link do rodapé some e os arquivos **nunca são apagados**. Despublicado não aparece em lugar nenhum.
|
||||
- **Sem PII/LGPD:** nenhum formulário, consent, Contact ou captura. Consent intocado.
|
||||
- **Um jingle por vez**; **sem autoplay**; abrir a página **não baixa os áudios** (áudio só carrega após o play).
|
||||
- **Um MP3 por jingle** serve para tocar e baixar; **WAV não vai ao site**; nenhum binário grande commitado — áudio/capa entram pela mídia existente (que não ganha collection paralela).
|
||||
- **Home e navegação principal intocadas**; a descoberta é um link condicional no rodapé. Nada de loja/streaming, analytics, share kit, discografia ou páginas por jingle.
|
||||
- **O que reavaliar (hipóteses da "Direção no codebase"):**
|
||||
- "mídia existente" — confirmado, mas com o refinamento de **depth 0 + `getCachedDocumentById`** (senão trocar o arquivo no admin deixa o card velho; ver D2).
|
||||
- "Precedente `/corte/[id]` (player)" — o corte usa `<video controls>` nativo; o artefato dos jingles pede controle próprio (play amarelo, progresso, status), então **não há player para reusar** — nasce um client component novo, pequeno.
|
||||
- "rodapé" — o `CampaignFooter` é compartilhado com home/cards; esta entrega o edita (link condicional, prop `showJingles`; ele segue sync, ver D5). É a **única** mudança que aparece fora de `/jingles` — deliberada e mínima.
|
||||
- "e2e afetado" — a migration classifica todo PR da entrega como high-risk ⇒ CI roda só o conjunto curado; a escolha de entrar no curado é decisão registrada (D7).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
admin[Admin /admin · Jingle] -->|afterChange / afterDelete| bust[revalidateTag 'jingles']
|
||||
page[/jingles RSC · campaign-site] --> list[getCachedPublishedJingles<br/>where published · depth 0 · tag 'jingles']
|
||||
list --> docs[getCachedDocumentById media<br/>tag 'document_media:id']
|
||||
docs --> player[JinglePlayer client<br/>preload none · um por vez]
|
||||
list --> empty[JingleEmptyState<br/>200 honesto · noindex]
|
||||
footer[CampaignFooter sync] -->|showJingles prop| has[hasPublishedJingles]
|
||||
has --> list
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) collection `jingle` nova com checkbox `published` (padrão ShareLink) | B) reusar `post`/`shareLink` existentes | C) drafts/versions via `_status`.
|
||||
|
||||
**Recomendação:** **A** — uma collection própria com campos exatos (título, slug, capa, áudio, ordem, publicado), grupo `Publicações`, leitura anônima fail-closed por `where published: true`, escrita `canManagePublishedContent` e hooks que revalidam a tag `jingles`. É o precedente mais próximo (S19), sem carregar richText/taxonomia/drafts de Post.
|
||||
|
||||
**Rejeitadas:** **B** — `post` traz corpo richText, `type`/categoria, drafts e o controle eleitoral por tag `hidden`, acoplando o jingle a uma taxonomia de notícias e exigindo migration de enum + semântica de visibilidade compartilhada; `shareLink` é OG/destino externo, sem upload próprio de áudio. **C** — `_status`/versions adiciona cerimônia de drafts/preview/agendamento sem uso; o kill switch por `published` é o contrato estabelecido e mantém o mesmo access fail-closed.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
**D1. Modelo — collection `jingle` (nova) com `published` checkbox e `order` opcional.**
|
||||
|
||||
- _Opções:_ A) collection nova (ShareLink) | B) reusar Post/ShareLink | C) `_status` drafts/versions.
|
||||
- _Recomendação:_ A — campos mínimos (`title`, `slug` único auto-slugificado, `coverImage` upload→media required, `audio` upload→media required, `order` number opcional, `published` default `false`); `admin.group: 'Publicações'`; `read: hasPayloadPanelAccess(req.user) ? true : { published: { equals: true } }`; create/update/delete `canManagePublishedContent`; `afterChange`/`afterDelete` → `revalidateJinglesListing()`. Ordenação `['order', 'title']` (sem `order` vai ao fim — `NULLS LAST` no Postgres; divergência barata do `order` required de `SpeechSegment`).
|
||||
- _Rejeitadas:_ B (ver acima); C (sem ganho, mais superfície de query/estado).
|
||||
|
||||
**D2. Leitura/cache — listagem `unstable_cache` depth 0 + mídia por `getCachedDocumentById`.**
|
||||
|
||||
- _Opções:_ A) listing depth 0 (tag `jingles`) + `getCachedDocumentById('media', id)` (tag `document_media:<id>`) | B) listing depth 1 | C) página dinâmica.
|
||||
- _Recomendação:_ A — trocar o **arquivo** de uma mídia no admin busta `document_media:<id>` (`Media.afterChange`), então capa/áudio ficam frescos mesmo sem editar o jingle. O listing guarda só ids.
|
||||
- _Rejeitadas:_ B — um arquivo trocado numa mídia existente não dispara hook do jingle e o card serviria filename/URL velhos até alguém editar o jingle; C — mata ISR/indexação e adiciona DB por request sem necessidade.
|
||||
|
||||
**D3. Nome do download — helper puro derivado do slug.**
|
||||
|
||||
- _Opções:_ A) `jingleDownloadFilename(slug, sourceFilename)` → `jorge-solla-1313-<slug>.<ext>` (ext do filename da mídia em lowercase, fallback `mp3`; slug normalizado com `slugify` e fallback `jingle`) | B) `media.filename` cru | C) nome fixo.
|
||||
- _Recomendação:_ A — o aceite pede nome legível; o design mostra `jorge-solla-1313-axe.mp3` (o slug do Axé já é `axe`); extensão real mantém honestidade.
|
||||
- _Rejeitadas:_ B — filename de upload pode ter acento/espaço/maiúscula; C — todos baixariam o mesmo nome.
|
||||
|
||||
**D4. Estado vazio — 200 honesto + `noindex` só quando vazio.**
|
||||
|
||||
- _Opções:_ A) 200 com estado honesto + "Voltar ao início" + rodapé sem "Jingles"; `robots: noindex,nofollow` quando vazio | B) 404 (padrão `/corte`) | C) 200 sempre indexável.
|
||||
- _Recomendação:_ A (intenção: "acesso direto mostra estado honesto", "não é descoberta"). Só sobrescrever `robots` **quando vazio** — com conteúdo, omitir `robots` para o `index,follow`/`noindex` de staging do layout raiz valer.
|
||||
- _Rejeitadas:_ B — o acesso direto é legítimo (diferente do link não listado de um corte); C — anunciar "nenhum jingle publicado" em busca é pior que não aparecer.
|
||||
|
||||
**D5. Rodapé — leitura cacheada na página + prop explícita no rodapé (sync).**
|
||||
|
||||
- _Opções:_ A) `CampaignFooter` vira async e lê `hasPublishedJingles()` | B) o rodapé segue **sync** recebendo `showJingles` de cada página, com `hasPublishedJingles()` (cacheado, tag `jingles`) resolvido na página | C) footer client com `usePathname`.
|
||||
- _Recomendação:_ **B** — a execução mostrou que o async child quebra o teste-skeleton da home (`tests/unit/campaignHome.unit.spec.tsx`, RTL não renderiza async components) e faria o rodapé — folha compartilhada e estática — depender do runtime do servidor. Com B o rodapé continua um leaf síncrono e a decisão de descoberta fica explícita na página, sempre pelo mesmo dono cacheado (`hasPublishedJingles`); os call sites são 3 linhas (home, cards, jingles).
|
||||
- _Rejeitadas:_ A — verificada em execução: erro `CampaignFooter is an async Client Component` no teste da home e acoplamento desnecessário; C — transformaria o rodapé em client component só para saber a rota (JS desnecessário) e não resolve a leitura.
|
||||
|
||||
**D6. `Media.alt` required para áudio — aceitar e documentar.**
|
||||
|
||||
- _Opções:_ A) comunicação preenche o alt também no áudio; campo do Jingle descreve isso | B) tocar em `Media` (relaxar required por tipo).
|
||||
- _Recomendação:_ A — fricção de 1 campo por jingle (3 hoje); o alt ainda ajuda no admin.
|
||||
- _Rejeitadas:_ B — o alt é contrato compartilhado de todas as mídias; exigiria lógica por MIME que não existe e mexe no owner sem evidência de produto.
|
||||
|
||||
**D7. E2E — spec novo no conjunto curado + pin (precedentes C167/S19).**
|
||||
|
||||
- _Opções:_ A) criar `tests/e2e/frontendJingles.e2e.spec.ts` e adicionar `frontendJingles` a `E2E_CURATED_SPECS` + pin em `e2eAffectedManifest.unit.spec.ts` | B) não adicionar.
|
||||
- _Recomendação:_ A — a migration torna **todo** PR desta entrega high-risk (CI roda só o curado); sem a entrada, o spec que nasce neste PR não roda nele (ficaria só no verify full pós-merge) e PRs high-risk futuros tocando jingles não o acordariam.
|
||||
- _Rejeitadas:_ B — contraria os dois últimos deliveries com migration e deixa o contrato novo sem cobertura no caminho que importa.
|
||||
|
||||
**D8. Leveza — `preload="none"` + controle próprio (não nativo, não metadata).**
|
||||
|
||||
- _Opções:_ A) `<audio preload="none">` oculto por card + controle do artefato | B) `preload="metadata"` | C) `<audio controls>` nativo.
|
||||
- _Recomendação:_ A — aceite literal ("abrir a página não baixa os áudios"); B dispara request de mídia no load; C não reproduz o card do artefato.
|
||||
- _Rejeitadas:_ B e C (acima).
|
||||
|
||||
**D9. Exclusividade — um único client component `JinglePlayer` dono do estado.**
|
||||
|
||||
- _Opções:_ A) um componente client para a lista (estado `activeId` + refs; ao tocar, pausa os demais) | B) card client + contexto | C) `<audio>` global com source trocada.
|
||||
- _Recomendação:_ A — 3 itens, sem contexto, estado onde é usado (engineering-standards).
|
||||
- _Rejeitadas:_ B (cerimônia por <3 call sites); C (complica progresso/duração por card e o seek).
|
||||
|
||||
**D10. Rota e slug — estático `/jingles` + reserva no registro existente.**
|
||||
|
||||
- _Opções:_ A) `src/app/(frontend)/jingles/` + `'jingles'` em `SHARE_LINK_RESERVED_SLUGS` (dono atual da reserva; o drift test é fail-closed) | B) lista de reservados paralela.
|
||||
- _Recomendação:_ A — o Next dá precedência ao segmento estático sobre `[type]`; a reserva impede que um shareLink capture `/jingles`. O slug do **jingle** não é URL (não valida reservados; só alimenta o nome do arquivo, sanitizado no helper).
|
||||
- _Rejeitadas:_ B — twina um registro que o drift test já cobre.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/collections/Jingle.ts`** (novo): collection acima (D1). `admin.description` explica o fluxo ("Suba o MP3 e a capa pela Mídia; desmarcar Publicado tira o jingle do ar sem apagar nada").
|
||||
- **`src/payload.config.ts`**: registrar `Jingle` no array `collections` (mesmo PR da migration).
|
||||
- **Migration:** `pnpm migrate:create add_jingle` (rebase antes) → `src/migrations/<ts>_add_jingle.{ts,json}` + `index.ts`; `pnpm migrate` local; `pnpm generate:types` (`payload-types.ts`). Tabela aditiva, sem backfill; FKs de mídia `ON DELETE set null` (mídia apagada ⇒ item some, fail-closed).
|
||||
- **`src/lib/jingle.ts`** (novo, puro/client-safe): `JingleViewModel` (`id`, `title`, `coverUrl`, `coverAlt`, `audioUrl`, `downloadFilename`), `toJingleViewModel({ jingle, cover, audio })` → `null` quando faltar URL (fallback de alt quando a mídia não tiver), `jingleDownloadFilename` (D3). Sem `@/payload-types` (precedente `lib/recording.ts`, formas estruturais).
|
||||
- **`src/utilities/jingleReads.ts`** (novo; `server-only`; registrar no pin de top-level em `codebaseConventions.unit.spec.ts` com comentário): `getCachedPublishedJingles()` (`unstable_cache`, `where { published: true }`, `sort: ['order','title']`, `depth 0`, `limit 0`, tag `getCollectionListingTag('jingle')` = `jingles`, filtro explícito na query como no S19); `getPublishedJingleItems()` (resolve capa/áudio via `getCachedDocumentById('media', id)` e mapeia com `toJingleViewModel`, descartando itens sem mídia); `hasPublishedJingles()` (rodapé).
|
||||
- **`src/utilities/documents.ts`**: `revalidateJinglesListing = () => revalidateCollectionListing('jingle')` (dono do vocabulário de tags; sem import de config).
|
||||
- **`src/lib/shareLink.ts`**: adicionar `'jingles'` a `SHARE_LINK_RESERVED_SLUGS` + atualizar o literal em `tests/unit/shareLink.unit.spec.ts` (D10).
|
||||
- **`src/app/(frontend)/jingles/layout.tsx`**: `data-theme="campaign-site"` + `h-dvh w-full overflow-y-auto bg-(--campaign-cream) text-(--campaign-ink)` (precedente `(home)/layout.tsx`; o root trava `overflow-hidden`).
|
||||
- **`src/app/(frontend)/jingles/page.tsx`**: RSC estático/ISR; `generateMetadata` (title "Jingles de Jorge Solla · <siteName>", description própria, canonical via `absoluteSitePath`, OG com a capa do primeiro jingle absoluta e fallback do global `metadata`, `robots` só no vazio — e o arquivo chama `resolveSiteMetadata`, exigido pelo guard de convenções); compõe header + hero + lista/`JingleEmptyState` + `CampaignFooter current="jingles"`.
|
||||
- **`src/components/jingles/JinglePageHeader.tsx`** e **`JingleIntro.tsx`** (server, page-local): header cream com `public/campaign-kit/marca-positiva-completa.png` (via `next/image`) + selo "Jingles oficiais"; hero com blobs, eyebrow "A trilha da nossa caminhada", h1, copy e o cartão "Um por vez. Ao tocar outro, o anterior para." (SVG de nota). Não é o `SiteHeader` (vermelho/breadcrumb) e não se generaliza agora — gatilho de revisitação: uma segunda página campaign-site precisar do mesmo header.
|
||||
- **`src/components/jingles/JinglePlayer.tsx`** ('use client'): recebe `readonly JingleViewModel[]`; port do artefato classe-a-classe em Tailwind (`data-state`, `.jingle-card`→utilities, capa quadrada com gradiente, play 56px amarelo com sombra 3D, status "Pronto para tocar"/"Em reprodução" com bolinha, trilha 6px + valor vermelho, "Tempo/Duração" uppercase via `formatSpeechClock` — reuso do dono do relógio —, "Baixar MP3" `min-h-11` com borda vermelha 32%, filename truncado). Um `<audio preload="none">` por card; ao tocar um, pausa os outros; `play().catch` reverte o estado (nunca deixa `aria-pressed` preso); botão com `aria-label` Tocar/Pausar + `aria-pressed`; ícones SVG inline do artefato (play/pause/download); `focus-visible` amarelo; `motion-reduce` desliga transições.
|
||||
- **`src/components/jingles/JingleEmptyState.tsx`** (server): nota cortada, copy "Nenhum jingle publicado por enquanto", "Voltar ao início" (`min-h-11`).
|
||||
- **`src/components/CampaignFooter.tsx`**: segue sync; props `showJingles?: boolean` (default `false`) e `current?: 'jingles'`; item "Jingles" condicional com `aria-current="page"` e destaque bold/branco (D5). Home e cards resolvem `hasPublishedJingles()` (cached) e passam a prop; a página de jingles passa `items.length > 0`.
|
||||
- **Testes:** `tests/unit/jingle.unit.spec.ts` (helper/mapper), `tests/unit/jinglePlayer.unit.spec.tsx` (RTL com mock de `HTMLMediaElement`, precedente `speechDetailPlayer.unit.spec.tsx`: play/pausa/exclusividade/tempos/`preload=none`/href+`download`), `tests/int/jingle.int.spec.ts` (mock de `next/cache` como no S19), `tests/e2e/frontendJingles.e2e.spec.ts`, + atualizações dos pins (`shareLink`, `codebaseConventions`, `e2eAffectedManifest`).
|
||||
- **Fixture e2e/int:** `tests/fixtures/jingle-tone.mp3` (~2–5 KB, MP3 válido gerado uma vez com ffmpeg e commitado) para o áudio; capa segue o padrão do PNG 1×1 base64 do spec do S19. _Rejeitadas:_ base64 gigante no spec (ilegível) e geração em runtime (acopla ffmpeg ao e2e).
|
||||
- **Manifesto/curated:** entries novas em `E2E_AFFECTED_MANIFEST` — jingles (`src/app/(frontend)/jingles`, `src/lib/jingle`, `src/utilities/jingleReads.ts`, `src/utilities/documents.ts`, `src/collections/Jingle.ts`, `src/components/jingles`) → `frontendJingles`; e `src/components/CampaignFooter.tsx` → `['frontend', 'frontendJingles']` (o rodapé renderiza na home e em `/jingles`). `frontendJingles` entra em `E2E_CURATED_SPECS` com comentário S21 + pin do array em `tests/unit/e2eAffectedManifest.unit.spec.ts` (D7).
|
||||
- **Changelog:** `docs/changelog/2026-09-20-s21.md` (uma entrada curta, formato do agregado).
|
||||
- **Operação (fora do código):** os 3 MP3 e as capas são conteúdo — a comunicação sobe no admin (produção) após o merge; a conversão WAV→MP3 é local/ops e nada é commitado. Nenhum seed novo no repo; e2e/int se auto-semeiam e limpam no `afterAll`.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
N/A — a intenção já registra "Vou apresentar dados? Não": é superfície de mídia (áudio + capa), sem dado agregado, sem PII e sem decisão desbloqueada por números. Nada a definir em forma de apresentação; a "forma" aqui é o card/player do artefato aprovado.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer / schema+server (~0.4 dia)** — `Jingle` + registro + `pnpm migrate:create add_jingle` + `pnpm migrate` + `pnpm generate:types`; `revalidateJinglesListing`; `src/lib/jingle.ts`; `jingleReads.ts` + pin de top-level; `'jingles'` reservado + literal do unit. Testes: unit do helper/mapper; int do access fail-closed (anônimo não vê draft, não escreve; `campaignUser` não escreve), filtro publicado, ordenação (`order` asc, sem ordem ao fim, empate por título), `revalidateTag('jingles')` em create/update/delete, resolução das mídias e descarte de item sem mídia. Prova: `pnpm gate:fast` + `pnpm test:int`.
|
||||
2. **UI (~0.4 dia)** — layout/page/header/hero/player/empty + `CampaignFooter`; RTL do player. Verificação visual local em 390/1280 com 2–3 jingles semeados no admin local (converter WAV→MP3 só localmente; capas otimizadas do artefato como arquivos locais de teste). Sem dispatch do designer aqui: o artefato cobre a superfície e é portado classe-a-classe (triggers a/b/d não disparam; ícones vêm do artefato).
|
||||
3. **E2E + manifesto (~0.2 dia)** — fixture MP3; `frontendJingles` (seeding via REST admin com `adminHeaders`/`seedTestUser`, limpeza em `afterAll`; asserções: lista renderizada + rodapé com link + `robots index,follow`; **nenhum request de áudio antes do play**; play/pausa/exclusividade com `audio.paused` real; download dispara com `suggestedFilename()` = `jorge-solla-1313-<slug>.mp3`; kill switch despublica → 200 vazio honesto + rodapé sem "Jingles" + `noindex`, republica → volta); manifest entries + curated + pin.
|
||||
4. **Gates e fechamento (~0.2–0.3 dia)** — `pnpm gate:fast` → `pnpm test:int` → e2e local (loop rápido: `pnpm test:e2e --no-deps -- tests/e2e/frontendJingles.e2e.spec.ts --workers=1`; canônico: `pnpm test:e2e:affected`, que em high-risk roda a suíte **full** local — OPS86) → **crítica final do designer (trigger c)** contra o app renderizado (390/1280 + estados vazio/tocando; `DEGRADED` ⇒ **para antes do `pnpm push`**, comenta a Issue e flipa `blocked` no modo `--auto`) → changelog → `pnpm push` → PR `Closes #1222`.
|
||||
|
||||
## Débitos do simplify (triage do Passo 6)
|
||||
|
||||
Achados dos dois revisores que **não** viraram fix da sessão:
|
||||
|
||||
| ID | Resumo | Origem | Score | Tipo | Destino |
|
||||
| --- | ------------------------------------------------------------------------------------------ | ----------------- | ----- | ------------- | --------------------------------------------------------------- |
|
||||
| S1 | 3ª cópia da resolução de mídia do global `metadata` (corte, shareLinkReads, página) | review estrutural | 2 | defer_trigger | gatilho: 4º consumidor **ou** mexer em uma das superfícies |
|
||||
| S2 | Esqueleto "listing publicado + `unstable_cache` + tag" repetido entre `posts.ts` e jingles | review estrutural | 2 | defer_trigger | gatilho: 3ª collection de listagem pública |
|
||||
| S3 | `src/utilities/documentReads.ts` sem entry no `E2E_AFFECTED_MANIFEST` | review estrutural | 2 | defer_trigger | gatilho: `documentReads` virar risk prefix ou mudar de contrato |
|
||||
| S4 | Copy por breakpoint duplicada em spans no `JingleIntro` (literal do artefato) | review qualidade | 1 | descartar | o artefato define as duas copies; não é débito |
|
||||
|
||||
Já resolvidos na sessão (não reabrir): corrida do `play()` superado (catch por id), `isNotFoundError` extraído para `documentReads`, `publishedOrPanelAccess` no dono do access, `hasPublishedJingles` derivado da lista renderizável, `role="progressbar"`, ref callback estável, `data-jingle-audio` removido, `hasItems`, `h1` no estado vazio, validação do slug, cobertura int de create/update/delete negados, comentário/`async` dos testes e comentário do `.gitignore`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Pré-carregar áudio** (`preload="metadata"`/`"auto"` ou `<link rel=preload>`): viola o aceite de leveza — D8 fixa `preload="none"`.
|
||||
- **Player rico** (ondas, equalizador, visualizador, velocidade): o artefato fixa o controle; nada além de play/pausa/progresso/tempo.
|
||||
- **Upload de WAV / conversão no app / ffprobe de duração**: um MP3 por jingle é decisão de produto; duração vem do browser em runtime.
|
||||
- **Collection paralela de mídia ou mudar `Media`** (mimeTypes, validação de áudio, `alt` opcional): mídia existente, contrato compartilhado intocado (D6).
|
||||
- **Discografia / página por jingle / letras / destaques**: uma página, 3 cards. O slug do jingle não vira URL.
|
||||
- **CDN, Spotify/YouTube embed, "ouvir no…"**: hospedagem e mídia existentes.
|
||||
- **Analytics de play, contador de download, captura de dados/consent**: LGPD N/A; não introduzir consent por engano.
|
||||
- **Share kit social dos jingles**: item futuro se pedido.
|
||||
- **Redesign de home/nav ou seção de jingles na home**: só o link condicional no rodapé.
|
||||
- **Rodapé dinâmico** (`payload.find` cru / `force-dynamic`): quebraria ISR da home — a leitura é cacheada por tag (D5).
|
||||
- **Binários em `public/`** (a pasta vai para a imagem Docker) e seed automático dos 3 jingles no repo.
|
||||
- **Colisão com `[type]`/shareLink**: o segmento estático novo é reservado (D10) — não criar rota `[slug]` paralela.
|
||||
- **Duas fontes de verdade do relógio**: não criar formatador novo; `formatSpeechClock` é o dono (se a crítica de design reprovar o "02:45" vs "2:45", revisitar com gatilho).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Home/cards estáticas e o rodapé:** a flag do rodapé vem de leitura cacheada (tag `jingles`) resolvida na página — nunca `payload.find` cru, que tornaria a home dinâmica; o rodapé em si segue sync (D5, divergência registrada). A tabela `jingles` existe desde a migration (build-after-migrate); falha de DB continua falhando alto.
|
||||
- **Link do rodapé stale após despublicar o último jingle:** o mesmo `afterChange` busta a tag `jingles` que alimenta `hasPublishedJingles`; o e2e de kill switch cobre o flip (com no máximo um request stale, como S19).
|
||||
- **Arquivo/capa trocados no admin sem editar o jingle:** coberto por D2 (`document_media:<id>`); teste int garante que o item usa a URL resolvida na leitura.
|
||||
- **Mídia apagada com jingle publicado:** FK `ON DELETE set null` + descarte de item sem mídia no mapper ⇒ fail-closed (não renderiza player quebrado).
|
||||
- **Fixture MP3 precisa decodificar no Chromium (asserção de `paused`):** fixture válida e pequena; se o headless se mostrar instável, manter as asserções de `aria-pressed`/exclusividade e marcar a de playback com o motivo no spec.
|
||||
- **Fricção do `Media.alt` no áudio:** descrições nos campos do Jingle orientam a comunicação (D6); nenhum código de Media muda.
|
||||
- **Guardas fail-closed que este PR precisa atualizar juntas:** drift de reservados (`shareLink.unit`), pin de top-level (`codebaseConventions.unit`) e pin do curado (`e2eAffectedManifest.unit`) — esquecer qualquer uma quebra a própria CI e o aviso é o teste.
|
||||
- **E2E local high-risk = suíte full (OPS86) consome orçamento:** loop de iteração no spec direto; o `test:e2e:affected` roda uma vez antes do push, com o tempo reservado no appetite.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (lista publicados; play sem sair; um por vez; download MP3 com nome legível; kill switch item+página com rodapé condicional; sem autoplay e sem baixar áudio ao abrir; indexável com title/description próprios; LGPD N/A).
|
||||
- [ ] Invariantes AGENTS/engineering-standards: access explícito e fail-closed; `lib/` pura sem `payload-types`; `utilities` server-bound com `server-only`; top-level pinado; tag vocabulary em `documents.ts`; migration aditiva via `migrate:create` + `push:false`; sem binário grande/`public/`; `pnpm push` (nunca `git push` nu); changelog só a entrada nova.
|
||||
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam: int do fail-closed + hooks de revalidação; unit do helper; RTL do player; e2e do contrato público (play/exclusividade/leveza/download/kill switch).
|
||||
- [ ] Designer (trigger c) certificado antes do push; `Design tier` registrado no PR (ou `DEGRADED` ⇒ parada/sign-off).
|
||||
|
||||
## Self-score decision-quality (gate ≥4)
|
||||
|
||||
1. **Decisões caras com rejeitadas (5/5):** D1–D7 (modelo, cache, nome, vazio, rodapé, Media.alt, curado) têm opções, recomendação e o porquê de cada rejeitada; D8–D10 idem em forma compacta.
|
||||
2. **Cabe no appetite (4/5):** ~1,2 dia entre schema/server, UI e testes; nada de infra nova. O custo não-compressível é o e2e local full (OPS86) + crítica do design no fechamento — por isso todos os rabbit holes foram cortados.
|
||||
3. **Rabbit holes nomeados (5/5):** lista explícita (player rico, WAV, mídia paralela, discografia, CDN, analytics, rodapé dinâmico, binários, colisão de rota…).
|
||||
4. **Depth check (5/5):** reusa ShareLink (template de kill switch), `documentReads`/`documents` (tags), `lib/slug`, `formatSpeechClock`, `CampaignFooter`, `adminHeaders`/`seedTestUser` e o padrão de fixture dos e2e; novos são só os donos que faltam (collection, `lib/jingle`, `jingleReads`, componentes page-local) — 1 top-level novo, pinado.
|
||||
5. **Intenção preservada (5/5):** cada aceite de produto mapeia para um mecanismo do plano; a engenharia não reescreveu outcome (nenhum escopo novo, nenhuma PII/consent).
|
||||
|
||||
**Média: 4,8/5 — aprovado.**
|
||||
|
|
@ -189,6 +189,15 @@ export default defineConfig({
|
|||
dependencies: isProdMode ? [] : ['frontend'],
|
||||
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
|
||||
},
|
||||
{
|
||||
// S21 — the jingles public page (cards, lazy player, download, kill
|
||||
// switch). Owns its jingle rows only, so it serializes behind frontend
|
||||
// in dev (shared dev-server boot) and runs parallel to it in prod.
|
||||
name: 'frontendJingles',
|
||||
testMatch: /frontendJingles\.e2e\.spec\.ts/,
|
||||
dependencies: isProdMode ? [] : ['frontend'],
|
||||
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
|
||||
},
|
||||
{
|
||||
name: 'admin',
|
||||
testMatch: /admin\.e2e\.spec\.ts/,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ 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',
|
||||
]
|
||||
|
||||
/**
|
||||
|
|
@ -89,6 +92,27 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
],
|
||||
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.
|
||||
prefixes: [
|
||||
'src/app/(frontend)/jingles',
|
||||
'src/lib/jingle',
|
||||
'src/utilities/jingleReads.ts',
|
||||
// The tag/revalidation vocabulary owner for the jingles listing.
|
||||
'src/utilities/documents.ts',
|
||||
'src/collections/Jingle.ts',
|
||||
'src/components/jingles',
|
||||
],
|
||||
specs: ['frontendJingles'],
|
||||
},
|
||||
{
|
||||
// S21 — the campaign footer owns the conditional "Jingles" discovery link
|
||||
// (it renders on the home, the cards page and `/jingles`).
|
||||
prefixes: ['src/components/CampaignFooter.tsx'],
|
||||
specs: ['frontend', 'frontendJingles'],
|
||||
},
|
||||
{
|
||||
// S13 — the personalized-cards studio lives in shared cards components and
|
||||
// pure card* modules; a future diff there must wake the frontend spec (the
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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 { hasPublishedJingles } from '@/utilities/jingleReads'
|
||||
|
||||
const intro =
|
||||
'Escolha um dos quatro modelos, personalize com seu nome ou sua foto e baixe para compartilhar.'
|
||||
|
|
@ -23,6 +24,7 @@ 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 = await hasPublishedJingles()
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -57,7 +59,7 @@ export default async function CardsPage({ searchParams }: CardsPageProps) {
|
|||
<CardsStudio initialModelId={initialModelId} fontFamily={brexterBold.style.fontFamily} />
|
||||
</section>
|
||||
</main>
|
||||
<CampaignFooter />
|
||||
<CampaignFooter showJingles={showJingles} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { CampaignHero } from '@/components/CampaignHero'
|
|||
import { CampaignProblemCard } from '@/components/CampaignProblemCard'
|
||||
import { CampaignStorySection } from '@/components/CampaignStorySection'
|
||||
import { getCampaignHomeMetaPixelId } from '@/utilities/campaignHomeTracking'
|
||||
import { hasPublishedJingles } from '@/utilities/jingleReads'
|
||||
import type { ReactNode } from 'react'
|
||||
import { CampaignCardsSection } from './CampaignCardsSection'
|
||||
import { CampaignNewsletterSection } from './CampaignNewsletterSection'
|
||||
|
|
@ -114,6 +115,8 @@ const proofItems = [
|
|||
export default async function HomePage() {
|
||||
// S10 — site-level Meta pixel for the campaign home (cached read, fail-closed).
|
||||
const pixelId = await getCampaignHomeMetaPixelId()
|
||||
// S21 — the footer's "Jingles" discovery link (cached, keeps the home static).
|
||||
const showJingles = await hasPublishedJingles()
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -240,7 +243,7 @@ export default async function HomePage() {
|
|||
<CampaignNewsletterSection pixelId={pixelId ?? undefined} />
|
||||
</main>
|
||||
|
||||
<CampaignFooter />
|
||||
<CampaignFooter showJingles={showJingles} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { formatSpeechClock, formatSpeechDate } from '@/lib/speechClock'
|
|||
import { speechCutPublicPath } from '@/lib/speechCut'
|
||||
import { speechCoverUrl } from '@/lib/speechVod'
|
||||
import type { Media, Speech, SpeechCut } from '@/payload-types'
|
||||
import { getCachedDocumentById } from '@/utilities/documentReads'
|
||||
import { getCachedDocumentById, isNotFoundError } from '@/utilities/documentReads'
|
||||
import { getCachedGlobal } from '@/utilities/globalReads'
|
||||
import { absoluteSitePath, resolveSiteMetadata, toAbsoluteUrl, truncate } from '@/utilities/seo'
|
||||
import type { Metadata } from 'next'
|
||||
|
|
@ -18,15 +18,6 @@ import { notFound } from 'next/navigation'
|
|||
const MAX_DESCRIPTION_LENGTH = 200
|
||||
const CREDIT = 'Fonte: Câmara dos Deputados · CC BY 4.0'
|
||||
|
||||
/**
|
||||
* `findByID` answers 404 for an unknown id; on the production (bundled) server
|
||||
* that error can cross a module boundary where `instanceof APIError` is false,
|
||||
* so the status is the contract — an unknown id is "not found" (renders 404),
|
||||
* anything else keeps failing loudly.
|
||||
*/
|
||||
const isNotFoundError = (error: unknown): boolean =>
|
||||
typeof error === 'object' && error !== null && (error as { status?: unknown }).status === 404
|
||||
|
||||
const parseCutId = (raw: string): number | null => {
|
||||
if (!/^\d+$/.test(raw)) return null
|
||||
const id = Number(raw)
|
||||
|
|
|
|||
17
src/app/(frontend)/jingles/layout.tsx
Normal file
17
src/app/(frontend)/jingles/layout.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
103
src/app/(frontend)/jingles/page.tsx
Normal file
103
src/app/(frontend)/jingles/page.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { CampaignFooter } from '@/components/CampaignFooter'
|
||||
import { JingleEmptyState } from '@/components/jingles/JingleEmptyState'
|
||||
import { JingleIntro } from '@/components/jingles/JingleIntro'
|
||||
import { JinglePageHeader } from '@/components/jingles/JinglePageHeader'
|
||||
import { JinglePlayer } from '@/components/jingles/JinglePlayer'
|
||||
import type { Media } from '@/payload-types'
|
||||
import { getCachedDocumentById } from '@/utilities/documentReads'
|
||||
import { getCachedGlobal } from '@/utilities/globalReads'
|
||||
import { getPublishedJingleItems } from '@/utilities/jingleReads'
|
||||
import {
|
||||
absoluteSitePath,
|
||||
resolveDeploymentOrigin,
|
||||
resolveSiteMetadata,
|
||||
toAbsoluteUrl,
|
||||
} 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 deploymentOrigin = resolveDeploymentOrigin(siteUrl)
|
||||
|
||||
// The media proxy only exists on the deployment origin (the global URL may be
|
||||
// a canonical domain served elsewhere), same rule as the share-link OG image.
|
||||
const coverUrl =
|
||||
items[0] && deploymentOrigin ? toAbsoluteUrl(items[0].coverUrl, deploymentOrigin) : undefined
|
||||
|
||||
let fallbackImage: Media | null = null
|
||||
if (!coverUrl && globalMetadata.image) {
|
||||
fallbackImage =
|
||||
typeof globalMetadata.image === 'number'
|
||||
? await getCachedDocumentById('media', String(globalMetadata.image))()
|
||||
: globalMetadata.image
|
||||
}
|
||||
|
||||
const imageUrl =
|
||||
coverUrl ??
|
||||
(fallbackImage?.url && deploymentOrigin
|
||||
? toAbsoluteUrl(fallbackImage.url, deploymentOrigin)
|
||||
: undefined)
|
||||
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 = await getPublishedJingleItems()
|
||||
const hasItems = items.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<JinglePageHeader withBadge={hasItems} />
|
||||
<main className="w-full bg-white">
|
||||
{hasItems ? (
|
||||
<>
|
||||
<JingleIntro />
|
||||
<JinglePlayer jingles={items} />
|
||||
</>
|
||||
) : (
|
||||
<JingleEmptyState />
|
||||
)}
|
||||
</main>
|
||||
<CampaignFooter showJingles={hasItems} current="jingles" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
112
src/collections/Jingle.ts
Normal file
112
src/collections/Jingle.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { slugify } from '@/lib/slug'
|
||||
import { canManagePublishedContent, publishedOrPanelAccess } from '@/utilities/campaignAccess'
|
||||
import { revalidateJinglesListing } from '@/utilities/documents'
|
||||
import type { CollectionConfig, TextFieldSingleValidation } from 'payload'
|
||||
|
||||
/**
|
||||
* S21 — the public jingles page. A flat, ordered list of pieces (cover + MP3)
|
||||
* served at `/jingles`. `published` is the kill switch (unchecked → invisible
|
||||
* everywhere, file kept); anonymous reads fail closed through the shared
|
||||
* `publishedOrPanelAccess` `where`, the same contract as `ShareLink`.
|
||||
*/
|
||||
const validateSlug: TextFieldSingleValidation = (value) => {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
return 'Use um título com letras ou números — o slug vira o nome do arquivo no download.'
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export const Jingle: CollectionConfig = {
|
||||
slug: 'jingle',
|
||||
labels: {
|
||||
singular: 'Jingle',
|
||||
plural: 'Jingles',
|
||||
},
|
||||
admin: {
|
||||
group: 'Publicações',
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['title', 'order', 'published'],
|
||||
description:
|
||||
'Peças de áudio do site público (/jingles). Suba o MP3 e a capa pela Mídia; desmarcar "Publicado" tira o jingle do ar na hora, sem apagar arquivo.',
|
||||
},
|
||||
access: {
|
||||
read: publishedOrPanelAccess,
|
||||
create: canManagePublishedContent,
|
||||
update: canManagePublishedContent,
|
||||
delete: canManagePublishedContent,
|
||||
},
|
||||
hooks: {
|
||||
afterChange: [revalidateJinglesListing],
|
||||
afterDelete: [revalidateJinglesListing],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
label: 'Título',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Nome curto exibido no card (ex.: Axé).',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
label: 'Slug',
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description:
|
||||
'Gerado automaticamente a partir do título quando vazio. Vira o nome do arquivo no download (jorge-solla-1313-<slug>.mp3).',
|
||||
},
|
||||
hooks: {
|
||||
beforeValidate: [
|
||||
({ value, siblingData }) => {
|
||||
if (value) return slugify(String(value))
|
||||
if (siblingData?.title) return slugify(String(siblingData.title))
|
||||
return value
|
||||
},
|
||||
],
|
||||
},
|
||||
validate: validateSlug,
|
||||
},
|
||||
{
|
||||
name: 'coverImage',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
label: 'Capa',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Imagem quadrada exibida acima do player (JPG/PNG otimizados).',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'audio',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
label: 'Áudio (MP3)',
|
||||
required: true,
|
||||
admin: {
|
||||
description:
|
||||
'O MP3 que toca e baixa. O mesmo arquivo serve para ouvir e baixar; não suba WAV. A Mídia pede um texto alternativo — use algo como "Jingle Axé — áudio".',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'order',
|
||||
type: 'number',
|
||||
label: 'Ordem',
|
||||
admin: {
|
||||
description: 'Menor vem primeiro; vazio vai ao fim. Empate desempata pelo título.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'published',
|
||||
type: 'checkbox',
|
||||
label: 'Publicado',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description: 'Desmarcado, o jingle some do site e do rodapé na hora (kill switch).',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -10,9 +10,9 @@ import {
|
|||
isValidShareLinkSlug,
|
||||
} from '@/lib/shareLink'
|
||||
import { slugify } from '@/lib/slug'
|
||||
import { canManagePublishedContent, hasPayloadPanelAccess } from '@/utilities/campaignAccess'
|
||||
import { canManagePublishedContent, publishedOrPanelAccess } from '@/utilities/campaignAccess'
|
||||
import { revalidateShareLinksListing } from '@/utilities/documents'
|
||||
import type { Access, CollectionConfig, TextFieldSingleValidation } from 'payload'
|
||||
import type { CollectionConfig, TextFieldSingleValidation } from 'payload'
|
||||
|
||||
/**
|
||||
* S19 — share links: `jorgesolla1313.com.br/<slug>` serves our own HTML with
|
||||
|
|
@ -20,9 +20,6 @@ import type { Access, CollectionConfig, TextFieldSingleValidation } from 'payloa
|
|||
* external destination. `published` is the kill switch (unchecked → 404);
|
||||
* anonymous reads fail closed through the `where` constraint.
|
||||
*/
|
||||
const readShareLink: Access = ({ req }) =>
|
||||
hasPayloadPanelAccess(req.user) ? true : { published: { equals: true } }
|
||||
|
||||
const validateSlug: TextFieldSingleValidation = async (value, { req, id }) => {
|
||||
if (typeof value !== 'string' || !value) return SHARE_LINK_SLUG_INVALID_MESSAGE
|
||||
if (!isValidShareLinkSlug(value)) return SHARE_LINK_SLUG_INVALID_MESSAGE
|
||||
|
|
@ -66,7 +63,7 @@ export const ShareLink: CollectionConfig = {
|
|||
'Links curtos com miniatura personalizada para compartilhar no WhatsApp. O endereço é jorgesolla1313.com.br/<slug> e o clique leva direto ao destino.',
|
||||
},
|
||||
access: {
|
||||
read: readShareLink,
|
||||
read: publishedOrPanelAccess,
|
||||
create: canManagePublishedContent,
|
||||
update: canManagePublishedContent,
|
||||
delete: canManagePublishedContent,
|
||||
|
|
|
|||
|
|
@ -1,87 +1,106 @@
|
|||
import Link from 'next/link'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const APOIAR_URL = 'https://apoiar.me/jorgesolla'
|
||||
|
||||
const FOOTER_LINK = cn(
|
||||
'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)',
|
||||
)
|
||||
|
||||
const REDES = [
|
||||
{ label: 'Instagram', href: 'https://instagram.com/depjorgesolla' },
|
||||
{ label: 'YouTube', href: 'https://youtube.com/@JorgeSollaDep' },
|
||||
{ label: 'Facebook', href: 'https://facebook.com/depjorgesolla' },
|
||||
]
|
||||
|
||||
/** Rodapé eleitoral da campanha: identificação, navegação e redes oficiais. */
|
||||
export const CampaignFooter = () => (
|
||||
<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="text-[rgb(255_248_242/75%)] no-underline hover:text-white"
|
||||
>
|
||||
Bandeiras
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href={APOIAR_URL}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="text-[rgb(255_248_242/75%)] no-underline hover:text-white"
|
||||
>
|
||||
Quero apoiar
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/privacidade"
|
||||
className="text-[rgb(255_248_242/75%)] no-underline hover:text-white"
|
||||
>
|
||||
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="text-[rgb(255_248_242/75%)] no-underline hover:text-white"
|
||||
>
|
||||
{rede.label}
|
||||
/**
|
||||
* 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.
|
||||
* `current` marks the page as the current one for assistive tech and the
|
||||
* artefato's highlight.
|
||||
*/
|
||||
export const CampaignFooter = ({
|
||||
showJingles = false,
|
||||
current,
|
||||
}: {
|
||||
showJingles?: boolean
|
||||
current?: 'jingles'
|
||||
} = {}) => {
|
||||
const jinglesLinkClass = cn(FOOTER_LINK, current === 'jingles' && '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>
|
||||
))}
|
||||
</ul>
|
||||
{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>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
42
src/components/jingles/JingleEmptyState.tsx
Normal file
42
src/components/jingles/JingleEmptyState.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
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>
|
||||
)
|
||||
71
src/components/jingles/JingleIntro.tsx
Normal file
71
src/components/jingles/JingleIntro.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* 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.
|
||||
*/
|
||||
const MusicNoteIcon = () => (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4 flex-none text-(--pt-red) sm:h-5 sm:w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const JingleIntro = () => (
|
||||
<section
|
||||
aria-labelledby="jingles-title"
|
||||
className="relative overflow-hidden bg-(--campaign-cream) px-5 pt-9 pb-8 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="sm:hidden">
|
||||
Dê o play para ouvir aqui. Se quiser levar, baixe o MP3.
|
||||
</span>
|
||||
<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 className="mt-5 flex items-center gap-2 text-xs font-bold text-black/60 sm:mt-0 sm:mb-1 sm:gap-3 sm:rounded-xl sm:border sm:border-black/10 sm:bg-white/75 sm:px-4 sm:py-3 sm:text-sm sm:font-normal sm:text-black/65 sm:backdrop-blur">
|
||||
<MusicNoteIcon />
|
||||
<span className="sm:hidden">Um jingle toca por vez.</span>
|
||||
<span className="hidden sm:block">
|
||||
<strong className="text-black">Um por vez.</strong>
|
||||
<br />
|
||||
Ao tocar outro, o anterior para.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
34
src/components/jingles/JinglePageHeader.tsx
Normal file
34
src/components/jingles/JinglePageHeader.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
/**
|
||||
* S21 — the campaign-site bar of the jingles page (artefato: cena 01/03): the
|
||||
* full positive logo linking home and the "Jingles oficiais" badge. The badge
|
||||
* only shows when there is something published (empty state drops it), so this
|
||||
* is page-local — not a generalized header yet.
|
||||
*/
|
||||
export const JinglePageHeader = ({ withBadge }: { withBadge: boolean }) => (
|
||||
<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)"
|
||||
>
|
||||
<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]"
|
||||
/>
|
||||
</Link>
|
||||
{withBadge ? (
|
||||
<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">
|
||||
Jingles oficiais
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
266
src/components/jingles/JinglePlayer.tsx
Normal file
266
src/components/jingles/JinglePlayer.tsx
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
import type { JingleViewModel } from '@/lib/jingle'
|
||||
import { formatSpeechClock } from '@/lib/speechClock'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* S21 — the jingles list (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.
|
||||
*/
|
||||
|
||||
const 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)'
|
||||
|
||||
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',
|
||||
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',
|
||||
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 JinglePlayer = ({ jingles }: { jingles: readonly JingleViewModel[] }) => {
|
||||
const [activeId, setActiveId] = useState<JingleViewModel['id'] | null>(null)
|
||||
const [playback, setPlayback] = useState<Record<number, Playback>>({})
|
||||
const audioRefs = useRef(new Map<number, HTMLAudioElement>())
|
||||
// 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>())
|
||||
|
||||
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) {
|
||||
audio.pause()
|
||||
setActiveId(null)
|
||||
return
|
||||
}
|
||||
|
||||
for (const [otherId, other] of audioRefs.current) {
|
||||
if (otherId !== id) other.pause()
|
||||
}
|
||||
|
||||
setActiveId(id)
|
||||
// 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 (
|
||||
<section aria-label="Jingles publicados" className="bg-white px-4 py-7 sm:px-8 sm:py-14">
|
||||
<div className="mx-auto grid max-w-6xl gap-5 sm:gap-6 md:grid-cols-3">
|
||||
{jingles.map((jingle) => {
|
||||
const playing = activeId === jingle.id
|
||||
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(
|
||||
'overflow-hidden rounded-[14px] border bg-white shadow-[0_12px_34px_rgb(71_19_14/8%)]',
|
||||
playing
|
||||
? 'border-[rgb(162_28_28/36%)] shadow-[0_14px_38px_rgb(162_28_28/14%)]'
|
||||
: 'border-(--campaign-line)',
|
||||
)}
|
||||
>
|
||||
<div className="relative aspect-square overflow-hidden bg-(--campaign-band)">
|
||||
<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-center 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">
|
||||
<h2 className="m-0 truncate border-b-0 pb-0 text-left font-[family-name:var(--font-exo2)] text-2xl leading-none font-black tracking-[-0.02em] md:text-[26px]">
|
||||
{jingle.title}
|
||||
</h2>
|
||||
{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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
76
src/lib/jingle.ts
Normal file
76
src/lib/jingle.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
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}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ export const SHARE_LINK_RESERVED_SLUGS = [
|
|||
'privacidade',
|
||||
'mandato-no-whatsapp',
|
||||
'abaixo-assinado',
|
||||
'jingles',
|
||||
'api',
|
||||
'admin',
|
||||
'noticia',
|
||||
|
|
|
|||
17146
src/migrations/20260920_031320_add_jingle.json
Normal file
17146
src/migrations/20260920_031320_add_jingle.json
Normal file
File diff suppressed because it is too large
Load diff
37
src/migrations/20260920_031320_add_jingle.ts
Normal file
37
src/migrations/20260920_031320_add_jingle.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "jingle" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"title" varchar NOT NULL,
|
||||
"slug" varchar,
|
||||
"cover_image_id" integer NOT NULL,
|
||||
"audio_id" integer NOT NULL,
|
||||
"order" numeric,
|
||||
"published" boolean DEFAULT false,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "jingle_id" integer;
|
||||
ALTER TABLE "jingle" ADD CONSTRAINT "jingle_cover_image_id_media_id_fk" FOREIGN KEY ("cover_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "jingle" ADD CONSTRAINT "jingle_audio_id_media_id_fk" FOREIGN KEY ("audio_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||
CREATE UNIQUE INDEX "jingle_slug_idx" ON "jingle" USING btree ("slug");
|
||||
CREATE INDEX "jingle_cover_image_idx" ON "jingle" USING btree ("cover_image_id");
|
||||
CREATE INDEX "jingle_audio_idx" ON "jingle" USING btree ("audio_id");
|
||||
CREATE INDEX "jingle_updated_at_idx" ON "jingle" USING btree ("updated_at");
|
||||
CREATE INDEX "jingle_created_at_idx" ON "jingle" USING btree ("created_at");
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_jingle_fk" FOREIGN KEY ("jingle_id") REFERENCES "public"."jingle"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "payload_locked_documents_rels_jingle_id_idx" ON "payload_locked_documents_rels" USING btree ("jingle_id");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "jingle" DISABLE ROW LEVEL SECURITY;
|
||||
DROP TABLE "jingle" CASCADE;
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_jingle_fk";
|
||||
|
||||
DROP INDEX "payload_locked_documents_rels_jingle_id_idx";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "jingle_id";`)
|
||||
}
|
||||
|
|
@ -77,6 +77,7 @@ import * as migration_20260919_002605_add_reel_source_hash from './20260919_0026
|
|||
import * as migration_20260919_003440_add_recording from './20260919_003440_add_recording';
|
||||
import * as migration_20260919_032729_add_recording_speakers from './20260919_032729_add_recording_speakers';
|
||||
import * as migration_20260919_155232_add_share_link from './20260919_155232_add_share_link';
|
||||
import * as migration_20260920_031320_add_jingle from './20260920_031320_add_jingle';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -472,6 +473,11 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260919_155232_add_share_link.up,
|
||||
down: migration_20260919_155232_add_share_link.down,
|
||||
name: '20260919_155232_add_share_link'
|
||||
name: '20260919_155232_add_share_link',
|
||||
},
|
||||
{
|
||||
up: migration_20260920_031320_add_jingle.up,
|
||||
down: migration_20260920_031320_add_jingle.down,
|
||||
name: '20260920_031320_add_jingle'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ export interface Config {
|
|||
post: Post;
|
||||
tag: Tag;
|
||||
shareLink: ShareLink;
|
||||
jingle: Jingle;
|
||||
exports: Export;
|
||||
imports: Import;
|
||||
'payload-kv': PayloadKv;
|
||||
|
|
@ -158,6 +159,7 @@ export interface Config {
|
|||
post: PostSelect<false> | PostSelect<true>;
|
||||
tag: TagSelect<false> | TagSelect<true>;
|
||||
shareLink: ShareLinkSelect<false> | ShareLinkSelect<true>;
|
||||
jingle: JingleSelect<false> | JingleSelect<true>;
|
||||
exports: ExportsSelect<false> | ExportsSelect<true>;
|
||||
imports: ImportsSelect<false> | ImportsSelect<true>;
|
||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||
|
|
@ -1559,6 +1561,41 @@ export interface ShareLink {
|
|||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Peças de áudio do site público (/jingles). Suba o MP3 e a capa pela Mídia; desmarcar "Publicado" tira o jingle do ar na hora, sem apagar arquivo.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "jingle".
|
||||
*/
|
||||
export interface Jingle {
|
||||
id: number;
|
||||
/**
|
||||
* Nome curto exibido no card (ex.: Axé).
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Gerado automaticamente a partir do título quando vazio. Vira o nome do arquivo no download (jorge-solla-1313-<slug>.mp3).
|
||||
*/
|
||||
slug?: string | null;
|
||||
/**
|
||||
* Imagem quadrada exibida acima do player (JPG/PNG otimizados).
|
||||
*/
|
||||
coverImage: number | Media;
|
||||
/**
|
||||
* O MP3 que toca e baixa. O mesmo arquivo serve para ouvir e baixar; não suba WAV. A Mídia pede um texto alternativo — use algo como "Jingle Axé — áudio".
|
||||
*/
|
||||
audio: number | Media;
|
||||
/**
|
||||
* Menor vem primeiro; vazio vai ao fim. Empate desempata pelo título.
|
||||
*/
|
||||
order?: number | null;
|
||||
/**
|
||||
* Desmarcado, o jingle some do site e do rodapé na hora (kill switch).
|
||||
*/
|
||||
published?: boolean | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "exports".
|
||||
|
|
@ -1908,6 +1945,10 @@ export interface PayloadLockedDocument {
|
|||
| ({
|
||||
relationTo: 'shareLink';
|
||||
value: number | ShareLink;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'jingle';
|
||||
value: number | Jingle;
|
||||
} | null);
|
||||
globalSlug?: string | null;
|
||||
user:
|
||||
|
|
@ -2789,6 +2830,20 @@ export interface ShareLinkSelect<T extends boolean = true> {
|
|||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "jingle_select".
|
||||
*/
|
||||
export interface JingleSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
slug?: T;
|
||||
coverImage?: T;
|
||||
audio?: T;
|
||||
order?: T;
|
||||
published?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "exports_select".
|
||||
|
|
@ -3301,6 +3356,7 @@ export interface TaskCreateCollectionExport {
|
|||
| 'post'
|
||||
| 'tag'
|
||||
| 'shareLink'
|
||||
| 'jingle'
|
||||
| 'exports'
|
||||
| 'imports';
|
||||
drafts?: ('yes' | 'no') | null;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { ElectionCandidate } from './collections/ElectionCandidate'
|
|||
import { ElectionCandidateVote } from './collections/ElectionCandidateVote'
|
||||
import { ElectionTally } from './collections/ElectionTally'
|
||||
import { GoogleCalendarSync } from './collections/GoogleCalendarSync'
|
||||
import { Jingle } from './collections/Jingle'
|
||||
import { Leadership } from './collections/Leadership'
|
||||
import { Media } from './collections/Media'
|
||||
import { Municipality } from './collections/Municipality'
|
||||
|
|
@ -142,6 +143,7 @@ export default buildConfig({
|
|||
Post,
|
||||
Tag,
|
||||
ShareLink,
|
||||
Jingle,
|
||||
],
|
||||
globals: [SiteSettings, HomePage, Metadata, PrivacyPolicy, CampaignGoals, SocialFeedSettings],
|
||||
editor: lexicalEditor(),
|
||||
|
|
|
|||
|
|
@ -108,6 +108,15 @@ export const payloadAdminOnly: Access = ({ req }) => isPayloadAdmin(req.user)
|
|||
/** Admin or editor — write access to public published content (`post`/`tag`/`media`). */
|
||||
export const canManagePublishedContent: Access = ({ req }) => hasPayloadPanelAccess(req.user)
|
||||
|
||||
/**
|
||||
* Read access for public content with a `published` kill switch (ShareLink,
|
||||
* Jingle): the admin panel sees everything, anonymous reads only the published
|
||||
* rows — the `where` is part of the access result, so a caller can never leak
|
||||
* drafts by forgetting the filter.
|
||||
*/
|
||||
export const publishedOrPanelAccess: Access = ({ req }) =>
|
||||
hasPayloadPanelAccess(req.user) ? true : { published: { equals: true } }
|
||||
|
||||
export const isCampaignUser = (user: CampaignActor): user is CampaignUser =>
|
||||
user?.collection === 'campaignUser'
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export {
|
|||
isCampaignUnrestricted,
|
||||
isPayloadAdmin,
|
||||
payloadAdminOnly,
|
||||
publishedOrPanelAccess,
|
||||
} from '@/utilities/access/shared'
|
||||
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,15 @@ import { getPayload } from 'payload'
|
|||
|
||||
type Collection = keyof Config['collections']
|
||||
|
||||
/**
|
||||
* `findByID` answers 404 for an unknown id; on the production (bundled) server
|
||||
* that error can cross a module boundary where `instanceof APIError` is false,
|
||||
* so the status is the contract — an unknown id is "not found", anything else
|
||||
* keeps failing loudly.
|
||||
*/
|
||||
export const isNotFoundError = (error: unknown): boolean =>
|
||||
typeof error === 'object' && error !== null && (error as { status?: unknown }).status === 404
|
||||
|
||||
/** Bare petition ids (no relationship population) — for `generateStaticParams`. */
|
||||
export const getPetitionIds = async (): Promise<string[]> => {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
|
|
|||
|
|
@ -31,3 +31,5 @@ const revalidateCollectionListing = <Slug extends Collection>(collection: Slug)
|
|||
export const revalidatePostsListing = () => revalidateCollectionListing('post')
|
||||
|
||||
export const revalidateShareLinksListing = () => revalidateCollectionListing('shareLink')
|
||||
|
||||
export const revalidateJinglesListing = () => revalidateCollectionListing('jingle')
|
||||
|
|
|
|||
80
src/utilities/jingleReads.ts
Normal file
80
src/utilities/jingleReads.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
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
|
||||
240
tests/e2e/frontendJingles.e2e.spec.ts
Normal file
240
tests/e2e/frontendJingles.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
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.
|
||||
*/
|
||||
|
||||
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)
|
||||
|
||||
// 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)
|
||||
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()
|
||||
})
|
||||
})
|
||||
BIN
tests/fixtures/jingle-tone.mp3
vendored
Normal file
BIN
tests/fixtures/jingle-tone.mp3
vendored
Normal file
Binary file not shown.
336
tests/int/jingle.int.spec.ts
Normal file
336
tests/int/jingle.int.spec.ts
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
// @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')
|
||||
})
|
||||
})
|
||||
|
|
@ -35,6 +35,13 @@ vi.mock('@/utilities/campaignHomeTracking', () => ({
|
|||
getCampaignHomeMetaPixelId: async () => null,
|
||||
}))
|
||||
|
||||
// S21 — the footer discovery flag reads the jingle listing through the Payload
|
||||
// DB + unstable_cache; the unit env has neither, and the discovery/kill-switch
|
||||
// behavior is e2e-covered (frontendJingles.e2e.spec.ts).
|
||||
vi.mock('@/utilities/jingleReads', () => ({
|
||||
hasPublishedJingles: async () => false,
|
||||
}))
|
||||
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -504,6 +504,10 @@ 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
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@ 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',
|
||||
])
|
||||
const onDisk = new Set(specNamesOnDisk())
|
||||
for (const spec of E2E_CURATED_SPECS) {
|
||||
|
|
|
|||
62
tests/unit/jingle.unit.spec.ts
Normal file
62
tests/unit/jingle.unit.spec.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { jingleDownloadFilename, 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('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')
|
||||
})
|
||||
})
|
||||
173
tests/unit/jinglePlayer.unit.spec.tsx
Normal file
173
tests/unit/jinglePlayer.unit.spec.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
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'
|
||||
|
||||
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()
|
||||
|
||||
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('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)
|
||||
})
|
||||
})
|
||||
|
|
@ -45,6 +45,7 @@ describe('shareLink slug', () => {
|
|||
'cards',
|
||||
'corte',
|
||||
'evento',
|
||||
'jingles',
|
||||
'mandato-no-whatsapp',
|
||||
'noticia',
|
||||
'privacidade',
|
||||
|
|
|
|||
Loading…
Reference in a new issue