mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(S22): jingles e rádio na home — a seção de som (Closes #1228)
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions
This commit is contained in:
parent
33a8f53e20
commit
d86b3aa0a1
15 changed files with 1990 additions and 289 deletions
1
docs/changelog/2026-09-20-s22.md
Normal file
1
docs/changelog/2026-09-20-s22.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**S22 — Jingles e rádio na home: a seção de som (2026-09-20):** o som do mandato chega à vitrine — a home pública ganha, entre "Nossa história" e "Faça parte", a seção "A trilha da nossa caminhada" (portada classe-a-classe do artefato aprovado `docs/plans/jingles-radio-homepage-ui-design.html`, Cenas 01–07) com a **Rádio Jorge Solla 1313** e até **3 jingles publicados** do S21, sem rota nova e sem tocar `/jingles` nem o rodapé. A rádio é um `RadioFacade` click-to-load: no estado `facade` não existe iframe, script ou request de terceiro — só o botão "Ouvir a rádio" e o link "Abrir no Zeno" —; o clique monta o widget oficial do zeno.fm e o card passa por `loading` (`role="status"` + `aria-busy` + spinner, com o foco do teclado movido para a região) até `loaded` no `onLoad`, com o **mesmo footprint reservado nos três estados** (330px mobile / 312px desktop, medido 522=522 e 314=314 no app real) — zero CLS, e o iframe nunca remonta (sem refetch). Os jingles reusam o core do S21, **extraído behavior-preserving** de `JinglePlayer` para `JingleCards` (exclusividade, `preload="none"`, download pelo slug e contrato `article[data-jingle]`/`data-state` intactos, com `headingLevel` para o outline da home e o anel de foco único em `focusRing.ts`): grade `md:grid-cols-3`, até 3 exibidos e "Ver todos os jingles" → `/jingles` **somente** quando há mais de 3; com zero publicados a grade e o link somem e a rádio permanece (fail-closed, mesmo kill switch do S21) na variante compacta da Cena 07 (`max-w-[880px]`, coluna de 240px, copy própria). A home segue estática: uma leitura `getPublishedJingleItems()` (cache tag `jingles`) alimenta a seção e o flag do rodapé (`slice(0,3)` em memória; nenhum `payload.find` novo). e2e: `frontendJingles` ganhou a seção da home (facade inerte com zero request `zeno.fm` **e** zero request de MP3 antes do gesto, widget de teste via `page.route`, `data-radio-state` até `loaded`, 4 publicados → 3 cards + handoff, kill switch → só rádio) e o `frontend` pinou a ordem (`sound = story+1`, `cards = sound+1`) e o overflow 390; unit: `radioFacade` (facade sem iframe → clique monta → `onLoad` não remonta) e `jingleHomeSection` (0/1/3/>3, "Ver todos", contrato da grade). Sem migration (nenhum schema; leitura pública sem PII/Consent). Crítica final do `designer` **CERTIFICADO** (tier `openai/gpt-5.6-sol`) contra o app renderizado em 390/1280 nos estados facade, carregando, carregado e zero — após uma primeira passada com 6 ajustes (escala do título, facade mobile da Cena 02, arte/wave, CLS, variante zero, header mobile), todos aplicados. `pnpm gate:fast` verde (4224 unit), e2e local selecionado verde (`frontendJingles` 4/4, `frontend` 2/2), simplify com 2 revisores (11 achados: 9 resolvidos no lugar, 2 descartados com respaldo; nenhum débito novo).
|
||||
140
docs/plans/jingles-radio-homepage-impl.md
Normal file
140
docs/plans/jingles-radio-homepage-impl.md
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
# Impl: Jingles e rádio na home — a seção de som (S22)
|
||||
|
||||
Status: aprovado — modo --auto
|
||||
Atualizado em: 2026-09-20
|
||||
Issue: #1228
|
||||
Intenção: docs/plans/jingles-radio-homepage.md
|
||||
Appetite restante: herdado (~1 dia eng; uma seção nova na home, sem rota nova) — sem ajuste de escopo; cortes em Rabbit holes.
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** a home passa a ter uma seção de som curta com a Rádio Jorge Solla 1313 (player do zeno só após o clique) e até 3 jingles publicados (capa, play um por vez, download MP3), linkando `/jingles` quando houver mais; zero jingles publicados deixa a rádio e esconde a grade (fail-closed); nada de terceiro, áudio ou PII antes do gesto do visitante.
|
||||
- **O que NÃO negociar:** `/jingles` continua canônica, indexável e no rodapé (não mudar player nem página do S21); facade click-to-load da rádio (zero request de terceiro antes do clique; link externo como alternativa); até 3 jingles + "Ver todos" só quando houver mais; posição entre `CampaignStorySection` e `CampaignCardsSection`; fonte única é a collection `jingle` via `getPublishedJingleItems()` (sem collection/mídia nova); home estática (nada de `payload.find` cru); kill switch do S21 preservado; sem formulário/consent/PII.
|
||||
- **O que reavaliar (hipóteses do plano de intenção):** (a) o `JinglePlayer` do S21 é reusável como está? — não: ele hardcoda o shell de página inteira (`JinglePlayer.tsx:134-135,261`) e a grade; a hipótese correta é extrair o core; (b) o fixture `e2eTest.ts` "aborta requests externos"? — não em geral: ele só roteia `youtube-nocookie` (`:108`) e `facebook.net` (`:114`); o iframe do zeno exige `page.route` próprio no spec, senão vira rede real/erro de console no guard; (c) o e2e da home mede overflow? — sim (`frontend.e2e.spec.ts:553-557`), mas não da seção nova: ela ganha asserção própria.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
RSC["(home)/page.tsx (RSC)<br/>getPublishedJingleItems() · cache tag jingles"] --> SEC["JingleHomeSection (server)<br/>data-home-section='sound'"]
|
||||
RSC -- "slice(0,3)" --> GRID["JingleCards (client, extraído)<br/>article[data-jingle] · data-state · preload=none"]
|
||||
RSC -- ">>> 3" --> ALL["Link 'Ver todos os jingles' → /jingles"]
|
||||
SEC --> RADIO["RadioFacade (client)<br/>data-radio-state: facade → loading → loaded"]
|
||||
RADIO -- "clique" --> IFRAME["iframe zeno.fm/player/jorge-solla-1313/"]
|
||||
S21["JinglePlayer (shell S21) · /jingles"] --> GRID
|
||||
```
|
||||
|
||||
**Opções consideradas:** A | B | C | D (por decisão, abaixo)
|
||||
**Recomendação:** extrair o core do player (`JingleCards`) e montar a seção da home com ele + uma facade de rádio sob `src/components/jingles/`; leitura única do listing cacheado na page.
|
||||
**Rejeitadas:** props de shell no `JinglePlayer` (arrisca `/jingles` sem ganho), grade reimplementada na home (duplica a máquina de estado), iframe direto da rádio (viola o aceite), spec e2e novo (segundo dono das linhas de jingle + entrada de manifest).
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
**D1 — Reuso do player (caro: fronteira de módulo)**
|
||||
Opções: A) props de shell/compacto no `JinglePlayer`; B) extrair `JingleCards` (core client) e deixar `JinglePlayer` como shell do S21; C) montar a grade na home sem reusar.
|
||||
Recomendação: B — uma única dona da exclusividade (`activeId`/`playback`/refs em `JinglePlayer.tsx:83-131`), `/jingles` visual e contratualmente intacta (seletores pinados `article[data-jingle]`, `data-state`, `button[data-play]`, `audio[preload="none"]`, `download={downloadFilename}`) e nenhum branching novo no componente que o e2e `frontendJingles` pina.
|
||||
Alternativas rejeitadas: A porque move o branching para o shell pinado e arrisca regressão visual em `/jingles`; C porque duplica a exclusividade, o catch do `play()` abortado e o progresso — drift garantido contra `jinglePlayer.unit.spec.tsx`.
|
||||
|
||||
**D2 — Facade da rádio (caro: componente client + contrato de terceiro)**
|
||||
Opções: A) `RadioFacade` client em `src/components/jingles/`, sem sandbox, estados `facade/loading/loaded`, iframe oficial só no clique; B) iframe direto (como o YouTube da story); C) só link externo; D) facade com `sandbox` restritivo.
|
||||
Recomendação: A — entrega o "embed tocável" pedido sem expor GA (`G-2T527NZWVM`) e redes de anúncio da zeno em todo pageview; fica sob o prefixo `src/components/jingles` (manifest `frontendJingles`, sem entrada nova); iframe com `src` do widget, `title` em pt-BR, `allow="autoplay"`, altura reservada; clique monta, `onLoad` promove `loading → loaded`; `aria-busy` no botão de carga; link "Abrir no Zeno" `target="_blank" rel="noopener noreferrer"` sempre visível na facade.
|
||||
Alternativas rejeitadas: B porque carrega third-party/ads em toda abertura da home e viola o aceite; C porque não entrega o player pedido; D porque sandbox sem contrato do widget pode quebrar o player sem ganho de isolamento (o vendor já vê o que veria) — reavaliar apenas se a zeno documentar requisitos.
|
||||
Lacuna de design: o artefato só tem `facade`/`loaded` (a cena 03 é "loaded", sem pending). Fase 0 estende o artefato com a cena de loading antes de qualquer markup.
|
||||
|
||||
**D3 — Leitura na home e derivação do rodapé (caro: cache/estático)**
|
||||
Opções: A) uma chamada `getPublishedJingleItems()` na page e derivar `showJingles` e a seleção; B) manter `hasPublishedJingles()` + chamada extra ao listing; C) mover o slice para dentro de `JingleHomeSection`.
|
||||
Recomendação: A — o listing cacheado (`unstable_cache` tag `jingles`, `jingleReads.ts:32-35`) já é a fonte do rodapé e o padrão existente é o dono da lista derivar o flag (`/jingles/page.tsx:84-100`, comentário em `CampaignFooter.tsx:21`); uma leitura só evita divergir "tem jingles" de "tem grade"; `slice(0,3)` em memória (limit 0 devolve todos; volume trivial).
|
||||
Alternativas rejeitadas: B porque `hasPublishedJingles` vira pass-through de `length > 0` quando a lista já está em mãos (depth check); C porque esconde a regra de produto no componente e tira do teste da page a seleção.
|
||||
`hasPublishedJingles` continua usado por `(home)/cards/page.tsx:27` — não fica órfão (knip ok). O mock de `tests/unit/campaignHome.unit.spec.tsx:41-43` passa a exportar `getPublishedJingleItems`.
|
||||
|
||||
**D4 — Posição e pino de ordem (caro: pin de e2e)**
|
||||
Opções: A) `data-home-section="sound"` entre `story` e `cards` + atualizar o pino; B) renderizar sem `data-home-section` para não tocar o teste.
|
||||
Recomendação: A — o pino (`frontend.e2e.spec.ts:1700-1706`) existe para proteger a ordem do bloco de conversão; a seção nova é parte da home e deve aparecer no contrato. Atualizar para `sound === story + 1` e `cards === sound + 1` (e manter `cards === newsletter - 1`).
|
||||
Alternativa rejeitada: B porque deixa a seção invisível ao teste de ordem e qualquer reordenação futura passa silenciosa.
|
||||
|
||||
**D5 — Estratégia de testes (caro: superfície de spec/manifest)**
|
||||
Opções: A) estender `frontendJingles.e2e.spec.ts` (serial, dono das linhas de jingle) + update do pino/overflow em `frontend.e2e.spec.ts`; B) criar `frontendJinglesHome.e2e.spec.ts`; C) só unit.
|
||||
Recomendação: A — o spec serial controla publicação/kill switch e já visita a home (`frontendJingles.e2e.spec.ts:200-201,231-232`), então grade/facade/zero/overflow-com-conteúdo cabem nele; o dono da home mantém ordem e overflow independente-de-conteúdo. Os prefixos atuais do manifest (`src/components/jingles` → `frontendJingles`, `:99-108`; `src/app/(frontend)` → `frontend`, `:77`) já cobrem todos os arquivos tocados — sem mudança no manifest.
|
||||
Alternativas rejeitadas: B porque exige entrada nova no manifest e cria segundo dono das linhas de jingle (serialização/limpeza duplicadas) sem cobertura extra; C porque não prova o facade sem request de terceiro nem o estado real da home.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/app/(frontend)/(home)/page.tsx`** — troca o import de `hasPublishedJingles` (`:9`) por `getPublishedJingleItems`; `const jingles = await getPublishedJingleItems()`; `const homeJingles = jingles.slice(0, 3)`; `showJingles={jingles.length > 0}`; render `<JingleHomeSection jingles={homeJingles} showAll={jingles.length > homeJingles.length} />` entre `<CampaignStorySection/>` (`:241`) e `<CampaignCardsSection/>` (`:242`).
|
||||
- **`src/components/jingles/JingleCards.tsx`** (novo, `'use client'`) — core extraído de `JinglePlayer.tsx:83-259`: estado de exclusividade, progresso e o grid (`max-w-6xl`, `gap-5 sm:gap-6 md:grid-cols-3`), com todo o markup/contrato dos cards (capa `next/image fill` sizes 33vw, título, `data-play`, `data-state`, progressbar, download com `downloadFilename` e `<audio preload="none">`). Props `{ jingles: readonly JingleViewModel[] }`; sem props de shell.
|
||||
- **`src/components/jingles/JinglePlayer.tsx`** — fica só com o shell do S21 (`section[aria-label="Jingles publicados"]` + nota "Sem autoplay…", `:134,261`) renderizando `<JingleCards jingles={jingles} />`; sem `'use client'` (não guarda mais estado). Markup/contrato visual inalterados; `/jingles/page.tsx` intocado.
|
||||
- **`src/components/jingles/JingleHomeSection.tsx`** (novo, server) — `section[data-home-section="sound"][aria-labelledby="sound-title"]` com o eyebrow "A trilha da nossa caminhada", h2 "Cante, baixe e espalhe" e a copy do design; `<RadioFacade />`; sub-bloco "Jingles oficiais / Dê o play. O próximo ritmo é seu." + "Um jingle toca por vez." com `<JingleCards jingles={jingles} />` **somente se** `jingles.length > 0`; link "Ver todos os jingles" → `/jingles` somente se `showAll`. Sem `JingleEmptyState` na home (o vazio da home é a rádio sozinha, sem estado de página).
|
||||
- **`src/components/jingles/RadioFacade.tsx`** (novo, `'use client'`) — card `[data-radio][data-radio-state]`; facade com logo (`numero-negativo.png`), `button[data-load-radio]` "Ouvir a rádio", link "Abrir no Zeno" e nota "O player do zeno.fm só é carregado depois do seu clique."; clique monta o iframe oficial e marca `loading`; `onLoad` → `loaded`; altura reservada para CLS zero; copy pós-carregamento "Player fornecido por zeno.fm. Ao usar, você acessa um serviço externo."
|
||||
- **`src/app/(frontend)/styles.css`** — regras novas no fim, padrão `campaign-radio-*` (arte/gradiente do card, wave decorativa `aria-hidden`, `motion-reduce`), reusando `campaign-section-eyebrow/title/copy` (`:371-389`) e os tokens `[data-theme='campaign-site']`; layout em utilitários Tailwind como os cards do S21. Nada de CSS existente alterado.
|
||||
- **`docs/plans/jingles-radio-homepage-ui-design.html`** — estender (fase 0) com a cena da rádio em `loading` e a nota/cena de 1–2 jingles (grade sem "Ver todos").
|
||||
- **Migration:** nenhuma (nenhum schema; sem `generate:types`).
|
||||
- **Access / Consent:** N/A — leitura pública, sem formulário, sem PII, sem chave de Consent.
|
||||
- **UI:** Impeccable C — nova seção em página existente; shells a reusar: `campaign-section-*`, card do S21, tokens `campaign-site`, linguagem do `CampaignStorySection` para a section band; processo shape (fase 0) → craft (fase 3) → critique → polish sobre o artefato aprovado no gate.
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
- **N/A** — a intenção declara "Vou apresentar dados? Não"; superfície de mídia (áudio + capa), sem agregação, sem PII e sem pergunta de data-presentation a responder.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Fase 0 — design gate (~0,5 h):** estender `docs/plans/jingles-radio-homepage-ui-design.html` com o estado `loading` da rádio (pós-clique, altura reservada, `aria-busy`, affordance de carregando) e a nota de 1–2 jingles; sem markup antes disso. Checkpoint: artefato commitado e aprovado.
|
||||
2. **Fase 1 — tracer behavior-preserving (~2 h):** criar `JingleCards`, emagrecer `JinglePlayer`; rodar `pnpm test:unit -- jinglePlayer` e e2e `frontendJingles` (fluxo `/jingles`) — verdes sem nenhuma mudança de `/jingles`. Checkpoint: extração sem regressão.
|
||||
3. **Fase 2 — facade da rádio (~2 h):** `RadioFacade` + unit novo (sem iframe no idle; iframe e `data-radio-state` após clique; `onLoad` → loaded; link externo `noopener noreferrer`). Checkpoint: `pnpm test:unit` verde.
|
||||
4. **Fase 3 — seção na home (~2,5 h):** `JingleHomeSection`, `styles.css`, page.tsx (leitura única/slice/showAll/rodapé derivado), update de `tests/unit/campaignHome.unit.spec.tsx` + `tests/unit/jingleHomeSection.unit.spec.tsx` (novo) e pino de ordem no `frontend.e2e.spec.ts`. Checkpoint: home renderiza 0 / 1–3 / 4+.
|
||||
5. **Fase 4 — e2e da home (~1,5 h):** estender `frontendJingles.e2e.spec.ts` (facade sem request zeno antes do clique com `page.route('https://zeno.fm/**')`, iframe após clique, >3 → 3 cards + "Ver todos" → `/jingles`, kill switch zero → só rádio, sem request de MP3 antes do play, overflow 390 com 3 cards) e a asserção de overflow da seção no `frontend.e2e.spec.ts`; nav da home por `/?e2e=<ts>` (padrão `gotoHomeFresh`, ISR + cache heurístico). Rodar `pnpm test:e2e -- frontendJingles frontend`.
|
||||
6. **Gates (~0,5 h):** `pnpm gate:fast`; registro em `docs/changelog/2026-09-20-s22.md`; push via `pnpm push` (CI roda o restante).
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- "Melhorar o player" durante a extração: o core mantém o S21 como está (sem velocidade, playlist, waveform, scrub).
|
||||
- Player de rádio próprio ou stream direto em `<audio>` (CORS/mixed content sem aviso): fica o iframe oficial sob clique + link externo.
|
||||
- Analytics de play/download, contagem de ouvintes, status "no ar", share kit, letras/transcrição/páginas por jingle.
|
||||
- Sandbox do iframe, CSP para zeno, consent de terceiro: a mitigação é a facade; nada além disso.
|
||||
- Novo schema/collection/mídia e migration; publicação dos 3 jingles no admin é dependência de ops do S21 (o código do S22 não publica conteúdo — sem isso a home mostra só a rádio).
|
||||
- Embed da rádio em nav/rodapé/outras páginas e redesign do shell/home.
|
||||
- Novo spec e2e ou entrada nova no manifest (prefixos atuais cobrem); abstrair uma "media section" genérica com <3 call sites (DRY <3).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Regressão do S21 na extração:** fase 1 é movimentação pura de estado/markup; rodar `jinglePlayer.unit.spec.tsx` (pina um-por-vez, `preload="none"`, download) e e2e `frontendJingles` de `/jingles` antes/depois; nenhum seletor ou `data-*` muda.
|
||||
- **Terceiro antes do clique (aceite central):** unit pina "sem iframe no idle"; e2e coleta requests `zeno.fm` e exige zero antes do clique; `page.route('https://zeno.fm/**', fulfill vazio)` no spec antes do clique evita rede real/GA no CI (o fixture `e2eTest` não intercepta zeno e o guard de console trata erro externo como falha).
|
||||
- **Home deixa de ser estática ou ganha read cru:** só `getPublishedJingleItems()` (cache tag `jingles`); nenhum `payload.find` novo; slice em memória; rodapé derivado do mesmo listing (padrão de `/jingles`).
|
||||
- **Pino de ordem quebra:** atualização deliberada e registrada (D4); o teste passa a pinar `sound` entre `story` e `cards`.
|
||||
- **Overflow mobile:** medir em 390 nos dois specs (com 3 cards no `frontendJingles`, rádio-only no `frontend`); `min-w-0`/`truncate` já presentes nos cards, arte do rádio com largura fluida e logo limitado.
|
||||
- **Corrida de conteúdo no `frontend.e2e.spec.ts`** (roda em paralelo com o spec que mexe nas linhas de jingle): na home do spec `frontend`, assertar só o que independe de publicação (seção, ordem, facade, overflow), nunca contagem de cards; contagens ficam no `frontendJingles` (serial, dono das linhas).
|
||||
- **CLS/estado de loading não desenhado:** fase 0 estende o artefato (altura reservada + `aria-busy`) antes do markup.
|
||||
- **1–2 jingles:** regra `length > 3` cobre; nota/cena na fase 0.
|
||||
- **A11y:** `title` no iframe, `aria-busy` no botão de carga, nota de serviço externo, wave decorativa `aria-hidden` com `motion-reduce`.
|
||||
- **knip/ciclos/import map:** `JingleCards`/`RadioFacade` no mesmo diretório sem ciclo; `hasPublishedJingles` segue usado por `(home)/cards`; sem campos novos, sem import map novo.
|
||||
|
||||
## Simplify + débitos (2026-09-20)
|
||||
|
||||
Dois revisores (estrutural + qualidade) sobre o diff: 11 achados, 9 resolvidos no
|
||||
lugar (grid mobile do card sem `col-start-2` condicional, arte sem faixa branca
|
||||
com `md:h-auto`, CLS facade↔loaded zerado com footprint reservado 330/312 medido,
|
||||
classe/hooks sem consumidor removidos, anel de foco em `focusRing.ts`, heading
|
||||
level dos cards na home, foco do teclado movido para a região de status, mock
|
||||
morto, testes redundantes) e 2 descartados com respaldo:
|
||||
|
||||
- **Asserções da seção de som em dois specs.** O `frontend` cobre ordem/estrutura
|
||||
independentes de publicação (roda em paralelo ao spec serial que flipa
|
||||
jingles); o `frontendJingles` cobre conteúdo/clique. Decisão D5; DRY <3.
|
||||
- **`aria-busy` persistente no `role="status"`.** Contrato do artefato (Cena 04)
|
||||
pinado no unit; o loading é transitório e o foco vai para a região.
|
||||
|
||||
Nenhum débito virou Issue nova (0 `expensive_lock` score ≥4).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto: seção na home com jingles (capa/play um por vez/download), rádio sob clique sem terceiro antes, kill switch individual + zero-jingles→só rádio, `/jingles` canônica intacta + "Ver todos" quando houver mais, home estática sem áudio/iframe no load, mobile/LGPD.
|
||||
- [ ] Invariantes AGENTS/engineering-standards: sem collection/Consent/migration; `unstable_cache` preservado; `push:false` intocado; nenhuma URL pública nova ou alterada; `/jingles` e rodapé intocados.
|
||||
- [ ] Testes de domínio previstos: unit (facade idle→loading→loaded sem iframe no idle; home 0/1–3/4+ com slice e "Ver todos"; player do S21 preservado) e e2e (home no `frontendJingles`; ordem/overflow no `frontend`); int N/A (sem write path).
|
||||
|
||||
## Self-score decision-quality
|
||||
|
||||
1. Decisões caras com rejeitadas: **sim** — D1 (A/C), D2 (B/C/D), D3 (B/C), D4 (B), D5 (B/C).
|
||||
2. Abordagem dentro do appetite: **sim** — uma seção, sem rota/schema; fases somam ~1 dia e a fase 1 é behavior-preserving.
|
||||
3. Rabbit holes nomeados: **sim** — player, stream direto, analytics/"no ar", sandbox/CSP, share kit, spec novo, publicação ops, DRY <3.
|
||||
4. Depth check: **sim** — extrai o core em vez de duplicar; reusa `jingleReads`, `campaign-section-*`, tokens `campaign-site` e o vocabulário de teste `data-home-section`/`data-radio`.
|
||||
5. Outcome preservado: **sim** — nenhuma decisão de engenharia reescreveu o produto (facade, até 3 + ver todos, fail-closed, `/jingles` canônica).
|
||||
|
||||
**Self-score: 5/5** — gate ≥4 atendido.
|
||||
|
|
@ -117,6 +117,20 @@
|
|||
width: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--pt-yellow);
|
||||
animation: radio-wave-pulse 1.15s ease-in-out infinite alternate;
|
||||
transform-origin: center;
|
||||
}
|
||||
.radio-wave span:nth-child(2n) {
|
||||
animation-delay: -420ms;
|
||||
}
|
||||
.radio-wave span:nth-child(3n) {
|
||||
animation-delay: -760ms;
|
||||
}
|
||||
@keyframes radio-wave-pulse {
|
||||
to {
|
||||
transform: scaleY(0.55);
|
||||
opacity: 0.72;
|
||||
}
|
||||
}
|
||||
.primary-control {
|
||||
display: inline-flex;
|
||||
|
|
@ -302,13 +316,48 @@
|
|||
border-radius: 12px;
|
||||
background: repeating-linear-gradient(-45deg, #fff, #fff 12px, #fff8f2 12px, #fff8f2 24px);
|
||||
}
|
||||
/* O mesmo painel reserva o maior footprint nos três estados: facade → loading → loaded. */
|
||||
.radio-state-panel {
|
||||
min-height: 260px;
|
||||
}
|
||||
.radio-widget-slot {
|
||||
height: 150px;
|
||||
min-height: 150px;
|
||||
}
|
||||
.radio-loading-spinner {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 3px solid rgb(162 28 28 / 18%);
|
||||
border-top-color: var(--pt-red);
|
||||
border-radius: 999px;
|
||||
animation: radio-spinner 850ms linear infinite;
|
||||
}
|
||||
@keyframes radio-spinner {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
[data-radio-state='loading'] .radio-facade,
|
||||
[data-radio-state='loaded'] .radio-facade {
|
||||
display: none;
|
||||
}
|
||||
[data-radio-state='facade'] .radio-loaded {
|
||||
[data-radio-state='facade'] .radio-loading,
|
||||
[data-radio-state='facade'] .radio-loaded,
|
||||
[data-radio-state='loading'] .radio-loaded,
|
||||
[data-radio-state='loaded'] .radio-loading {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.radio-state-panel {
|
||||
min-height: 300px;
|
||||
}
|
||||
.radio-widget-slot {
|
||||
height: 168px;
|
||||
min-height: 168px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
|
|
@ -322,6 +371,10 @@
|
|||
.play-control:hover {
|
||||
transform: none;
|
||||
}
|
||||
.radio-wave span,
|
||||
.radio-loading-spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
|
@ -361,7 +414,7 @@
|
|||
Cena 01 · Desktop 1280
|
||||
</p>
|
||||
<h2 id="scene-desktop" class="font-display mt-1 text-xl font-black text-[#211b19]">
|
||||
Facade da rádio + três jingles · no ritmo real da home
|
||||
Facade da rádio + três de um acervo maior · no ritmo real da home
|
||||
</h2>
|
||||
</div>
|
||||
<p class="m-0 max-w-lg text-right text-xs leading-5 text-[#6c615e]">
|
||||
|
|
@ -481,7 +534,7 @@
|
|||
><span class="h-3"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="radio-facade flex flex-col justify-center p-9">
|
||||
<div class="radio-facade radio-state-panel flex flex-col justify-center p-9">
|
||||
<p
|
||||
class="font-display m-0 text-xs font-black tracking-[0.1em] text-[#a21c1c] uppercase"
|
||||
>
|
||||
|
|
@ -533,9 +586,40 @@
|
|||
>O player do zeno.fm só é carregado depois do seu clique.
|
||||
</p>
|
||||
</div>
|
||||
<div class="radio-loaded col-start-2 flex flex-col justify-center p-8">
|
||||
<div
|
||||
class="radio-loading radio-state-panel col-start-2 flex flex-col justify-center p-8"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-5">
|
||||
<div>
|
||||
<p
|
||||
class="font-display text-xs font-black tracking-[0.1em] text-[#a21c1c] uppercase"
|
||||
>
|
||||
Sintonize com a gente
|
||||
</p>
|
||||
<h3 class="font-display mt-1 text-[28px] leading-tight font-black">
|
||||
Rádio Jorge Solla 1313
|
||||
</h3>
|
||||
<p class="mt-3 text-sm font-bold">Carregando o player da rádio…</p>
|
||||
<p class="mt-1 text-xs text-[#6c615e]">Isso pode levar alguns segundos.</p>
|
||||
</div>
|
||||
<div class="radio-loading-spinner mt-2 flex-none" aria-hidden="true"></div>
|
||||
</div>
|
||||
<a
|
||||
href="https://zeno.fm/radio/jorge-solla-1313/"
|
||||
class="secondary-link safe-focus mt-4 w-fit text-sm"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>Abrir no Zeno</a
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="radio-loaded radio-state-panel col-start-2 flex flex-col justify-center p-8"
|
||||
>
|
||||
<div
|
||||
class="iframe-placeholder flex items-center justify-center p-5 text-center"
|
||||
class="iframe-placeholder radio-widget-slot flex items-center justify-center p-5 text-center"
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
|
|
@ -817,7 +901,7 @@
|
|||
Cena 02 · Mobile 390
|
||||
</p>
|
||||
<h2 id="scene-mobile" class="font-display mt-1 text-xl font-black text-[#211b19]">
|
||||
Uma coluna · facade antes de qualquer terceiro
|
||||
Uma coluna · três exibidos de um acervo maior
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
|
|
@ -893,7 +977,7 @@
|
|||
><span class="h-6"></span><span class="h-4"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="radio-facade p-5">
|
||||
<div class="radio-facade radio-state-panel p-5">
|
||||
<h3 class="font-display text-2xl leading-tight font-black tracking-[-0.02em]">
|
||||
Rádio Jorge Solla 1313
|
||||
</h3>
|
||||
|
|
@ -931,9 +1015,33 @@
|
|||
O player do zeno.fm só carrega depois do clique.
|
||||
</p>
|
||||
</div>
|
||||
<div class="radio-loaded p-4">
|
||||
<div
|
||||
class="radio-loading radio-state-panel flex flex-col justify-center p-5"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h3 class="font-display text-2xl leading-tight font-black">
|
||||
Rádio Jorge Solla 1313
|
||||
</h3>
|
||||
<p class="mt-3 text-sm font-bold">Carregando o player da rádio…</p>
|
||||
<p class="mt-1 text-xs text-[#6c615e]">Isso pode levar alguns segundos.</p>
|
||||
</div>
|
||||
<div class="radio-loading-spinner mt-1 flex-none" aria-hidden="true"></div>
|
||||
</div>
|
||||
<a
|
||||
href="https://zeno.fm/radio/jorge-solla-1313/"
|
||||
class="secondary-link safe-focus mt-3 w-fit text-sm"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>Abrir no Zeno</a
|
||||
>
|
||||
</div>
|
||||
<div class="radio-loaded radio-state-panel p-4">
|
||||
<div
|
||||
class="iframe-placeholder flex min-h-[150px] items-center justify-center p-4 text-center"
|
||||
class="iframe-placeholder radio-widget-slot flex items-center justify-center p-4 text-center"
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
|
|
@ -1211,7 +1319,7 @@
|
|||
><span class="h-7"></span><span class="h-5"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="radio-loaded flex flex-col justify-center p-5 sm:p-8">
|
||||
<div class="radio-loaded radio-state-panel flex flex-col justify-center p-5 sm:p-8">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 class="font-display text-2xl font-black tracking-[-0.02em]">
|
||||
Rádio Jorge Solla 1313
|
||||
|
|
@ -1235,7 +1343,9 @@
|
|||
/></svg
|
||||
></a>
|
||||
</div>
|
||||
<div class="iframe-placeholder flex items-center justify-center p-5 text-center">
|
||||
<div
|
||||
class="iframe-placeholder radio-widget-slot flex items-center justify-center p-5 text-center"
|
||||
>
|
||||
<div>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
|
|
@ -1266,11 +1376,250 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CENA 04: ESTADO DE INTERAÇÃO HERDADO DO S21 -->
|
||||
<!-- CENA 04: RADIO CARREGANDO, MOBILE-FIRST + DESKTOP -->
|
||||
<section id="radio-loading" aria-labelledby="scene-loading" class="mb-16">
|
||||
<div class="mb-4 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<p
|
||||
class="font-display m-0 text-xs font-black tracking-[0.12em] text-[#a21c1c] uppercase"
|
||||
>
|
||||
Cena 04 · Rádio carregando · 390 primeiro
|
||||
</p>
|
||||
<h2 id="scene-loading" class="font-display mt-1 text-xl font-black text-[#211b19]">
|
||||
Presença intacta enquanto o serviço externo responde
|
||||
</h2>
|
||||
</div>
|
||||
<p class="m-0 max-w-xl text-xs leading-5 text-[#6c615e]">
|
||||
O clique monta o iframe; até o <code>onLoad</code>, o mesmo footprint fica reservado e a
|
||||
região anuncia “Carregando o player da rádio”.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid items-start gap-6 xl:grid-cols-[390px_1fr]">
|
||||
<div
|
||||
class="stage-shadow mx-auto w-[390px] max-w-full overflow-hidden rounded-[28px] border-[6px] border-[#2a2523] bg-[#fff8f2] p-4"
|
||||
>
|
||||
<p
|
||||
class="font-display mb-3 text-[10px] font-black tracking-[0.12em] text-[#a21c1c] uppercase"
|
||||
>
|
||||
Mobile · 390px
|
||||
</p>
|
||||
<article class="radio-card" data-radio data-radio-state="loading">
|
||||
<div class="radio-art flex h-[190px] flex-col justify-between p-5 text-white">
|
||||
<div class="flex items-center justify-between">
|
||||
<span
|
||||
class="font-display rounded-full border border-white/30 bg-white/10 px-3 py-1 text-[10px] font-black tracking-[0.1em] uppercase"
|
||||
>Rádio online</span
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="h-6 w-6 text-[#ffe607]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9M8.5 15.5a5 5 0 0 1 0-7M19.1 4.9c3.9 3.9 3.9 10.3 0 14.2M15.5 8.5a5 5 0 0 1 0 7"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<img
|
||||
src="../../public/campaign-kit/numero-negativo.png"
|
||||
alt="Jorge Solla 1313 — Mais Saúde, Mais Futuro"
|
||||
class="w-[155px] self-center object-contain"
|
||||
/>
|
||||
<div
|
||||
class="radio-wave flex h-6 items-center justify-center gap-1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="h-2"></span><span class="h-5"></span><span class="h-3"></span
|
||||
><span class="h-6"></span><span class="h-4"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="radio-loading radio-state-panel flex flex-col justify-center p-5"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p
|
||||
class="font-display text-[10px] font-black tracking-[0.1em] text-[#a21c1c] uppercase"
|
||||
>
|
||||
Sintonize com a gente
|
||||
</p>
|
||||
<h3
|
||||
class="font-display mt-1 text-2xl leading-tight font-black tracking-[-0.02em]"
|
||||
>
|
||||
Rádio Jorge Solla 1313
|
||||
</h3>
|
||||
</div>
|
||||
<div class="radio-loading-spinner mt-1 flex-none" aria-hidden="true"></div>
|
||||
</div>
|
||||
<p class="mt-4 text-sm font-bold text-[#211b19]">Carregando o player da rádio…</p>
|
||||
<p class="mt-1 text-xs leading-5 text-[#6c615e]">
|
||||
Isso pode levar alguns segundos.
|
||||
</p>
|
||||
<a
|
||||
href="https://zeno.fm/radio/jorge-solla-1313/"
|
||||
class="secondary-link safe-focus mt-3 w-fit text-sm"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>Abrir no Zeno
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 min-h-11 w-full rounded-[10px] bg-[#a21c1c]/10 px-4 text-sm font-bold text-[#7d4848]"
|
||||
aria-busy="true"
|
||||
disabled
|
||||
>
|
||||
Carregando…
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="stage-shadow overflow-hidden rounded-[18px] border border-black/10 bg-[#fff8f2] p-6 lg:p-8"
|
||||
>
|
||||
<p
|
||||
class="font-display mb-4 text-[10px] font-black tracking-[0.12em] text-[#a21c1c] uppercase"
|
||||
>
|
||||
Desktop · painel reservado em 300px
|
||||
</p>
|
||||
<article
|
||||
class="radio-card grid md:grid-cols-[250px_1fr]"
|
||||
data-radio
|
||||
data-radio-state="loading"
|
||||
>
|
||||
<div class="radio-art flex min-h-[300px] flex-col justify-between p-6 text-white">
|
||||
<div class="flex items-center justify-between">
|
||||
<span
|
||||
class="font-display rounded-full border border-white/30 bg-white/10 px-3 py-1 text-[10px] font-black tracking-[0.1em] uppercase"
|
||||
>Rádio online</span
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="h-7 w-7 text-[#ffe607]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9M8.5 15.5a5 5 0 0 1 0-7M19.1 4.9c3.9 3.9 3.9 10.3 0 14.2M15.5 8.5a5 5 0 0 1 0 7"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<img
|
||||
src="../../public/campaign-kit/numero-negativo.png"
|
||||
alt="Jorge Solla 1313 — Mais Saúde, Mais Futuro"
|
||||
class="w-[175px] self-center object-contain"
|
||||
/>
|
||||
<div
|
||||
class="radio-wave flex h-7 items-center justify-center gap-1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="h-3"></span><span class="h-6"></span><span class="h-4"></span
|
||||
><span class="h-7"></span><span class="h-5"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="radio-loading radio-state-panel flex flex-col justify-center p-7"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-5">
|
||||
<div>
|
||||
<p
|
||||
class="font-display text-xs font-black tracking-[0.1em] text-[#a21c1c] uppercase"
|
||||
>
|
||||
Sintonize com a gente
|
||||
</p>
|
||||
<h3
|
||||
class="font-display mt-1 text-[28px] leading-tight font-black tracking-[-0.025em]"
|
||||
>
|
||||
Rádio Jorge Solla 1313
|
||||
</h3>
|
||||
<p class="mt-3 text-sm font-bold text-[#211b19]">
|
||||
Carregando o player da rádio…
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-5 text-[#6c615e]">
|
||||
Isso pode levar alguns segundos.
|
||||
</p>
|
||||
</div>
|
||||
<div class="radio-loading-spinner mt-2 flex-none" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="mt-5 flex flex-wrap items-center gap-5">
|
||||
<button
|
||||
type="button"
|
||||
class="min-h-11 rounded-[10px] bg-[#a21c1c]/10 px-5 text-sm font-bold text-[#7d4848]"
|
||||
aria-busy="true"
|
||||
disabled
|
||||
>
|
||||
Carregando…
|
||||
</button>
|
||||
<a
|
||||
href="https://zeno.fm/radio/jorge-solla-1313/"
|
||||
class="secondary-link safe-focus text-sm"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>Abrir no Zeno
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<p class="mt-4 text-xs leading-5 text-[#6c615e]">
|
||||
Player fornecido por zeno.fm. Ao usar, você acessa um serviço externo.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-4 rounded-xl border border-[#a21c1c]/20 bg-white px-4 py-3 text-xs leading-5 text-[#4f4642]"
|
||||
>
|
||||
<strong class="text-black">Contrato visual:</strong>
|
||||
<code>.radio-state-panel</code> existe nos três estados e reserva 260px no mobile / 300px
|
||||
no desktop; o slot do widget reserva 150px / 168px. No estado <code>loading</code>, o
|
||||
iframe já foi montado pelo gesto, mas o design mostra só o affordance até
|
||||
<code>onLoad</code>. Antes do clique, não existe iframe, request ou script de zeno.fm.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CENA 05: ESTADO DE INTERAÇÃO HERDADO DO S21 -->
|
||||
<section aria-labelledby="scene-playing" class="mb-16">
|
||||
<div class="mb-4">
|
||||
<p class="font-display m-0 text-xs font-black tracking-[0.12em] text-[#a21c1c] uppercase">
|
||||
Cena 04 · Tocando + download em foco
|
||||
Cena 05 · Tocando + download em foco
|
||||
</p>
|
||||
<h2 id="scene-playing" class="font-display mt-1 text-xl font-black text-[#211b19]">
|
||||
O play amarelo continua dominante; download é secundário e acessível
|
||||
|
|
@ -1344,11 +1693,383 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CENA 05: ZERO JINGLES -->
|
||||
<!-- CENA 06: UM OU DOIS JINGLES -->
|
||||
<section id="one-two-jingles" aria-labelledby="scene-one-two" class="mb-16">
|
||||
<div class="mb-4 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<p
|
||||
class="font-display m-0 text-xs font-black tracking-[0.12em] text-[#a21c1c] uppercase"
|
||||
>
|
||||
Cena 06 · Um ou dois jingles
|
||||
</p>
|
||||
<h2 id="scene-one-two" class="font-display mt-1 text-xl font-black text-[#211b19]">
|
||||
A grade não inventa conteúdo nem exibe “Ver todos”
|
||||
</h2>
|
||||
</div>
|
||||
<p class="m-0 max-w-xl text-xs leading-5 text-[#6c615e]">
|
||||
À esquerda, 390px com um publicado. À direita, desktop com dois. O card da rádio
|
||||
permanece acima nos dois casos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid items-start gap-6 xl:grid-cols-[390px_1fr]">
|
||||
<div
|
||||
class="stage-shadow mx-auto w-[390px] max-w-full overflow-hidden rounded-[28px] border-[6px] border-[#2a2523] bg-[#fff8f2]"
|
||||
>
|
||||
<section aria-label="Seção de som com um jingle" class="overflow-hidden px-4 py-9">
|
||||
<p
|
||||
class="campaign-section-eyebrow text-center font-black tracking-[0.1em] text-[#a21c1c] uppercase"
|
||||
>
|
||||
A trilha da nossa caminhada
|
||||
</p>
|
||||
<h3
|
||||
class="font-display mt-1 text-center text-[30px] leading-none font-black tracking-[-0.03em]"
|
||||
>
|
||||
Cante, baixe e espalhe
|
||||
</h3>
|
||||
|
||||
<article class="radio-card mt-6" data-radio data-radio-state="facade">
|
||||
<div class="radio-art flex h-[150px] flex-col justify-between p-4 text-white">
|
||||
<div class="flex items-center justify-between">
|
||||
<span
|
||||
class="font-display rounded-full border border-white/30 bg-white/10 px-2.5 py-1 text-[9px] font-black tracking-[0.1em] uppercase"
|
||||
>Rádio online</span
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="h-5 w-5 text-[#ffe607]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9M8.5 15.5a5 5 0 0 1 0-7M19.1 4.9c3.9 3.9 3.9 10.3 0 14.2M15.5 8.5a5 5 0 0 1 0 7"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<img
|
||||
src="../../public/campaign-kit/numero-negativo.png"
|
||||
alt="Jorge Solla 1313 — Mais Saúde, Mais Futuro"
|
||||
class="w-[132px] self-center object-contain"
|
||||
/>
|
||||
<div
|
||||
class="radio-wave flex h-5 items-center justify-center gap-1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="h-2"></span><span class="h-4"></span><span class="h-3"></span
|
||||
><span class="h-5"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="radio-facade radio-state-panel flex flex-col justify-center p-5">
|
||||
<h4 class="font-display text-xl font-black">Rádio Jorge Solla 1313</h4>
|
||||
<p class="mt-2 text-sm leading-5 text-[#6c615e]">
|
||||
Carregue o player oficial só quando quiser ouvir.
|
||||
</p>
|
||||
<button type="button" class="primary-control safe-focus mt-4 w-full">
|
||||
<svg viewBox="0 0 24 24" class="h-5 w-5 fill-current" aria-hidden="true">
|
||||
<path d="m8 5 11 7-11 7z" />
|
||||
</svg>
|
||||
Ouvir a rádio
|
||||
</button>
|
||||
<a
|
||||
href="https://zeno.fm/radio/jorge-solla-1313/"
|
||||
class="secondary-link safe-focus mt-2 justify-center"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>Abrir no Zeno</a
|
||||
>
|
||||
<p class="mt-2 text-center text-[11px] leading-4 text-black/50">
|
||||
O player só carrega depois do clique.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="mt-9">
|
||||
<p
|
||||
class="campaign-section-eyebrow font-black tracking-[0.1em] text-[#a21c1c] uppercase"
|
||||
>
|
||||
Jingles oficiais
|
||||
</p>
|
||||
<h4 class="font-display mt-1 text-2xl font-black">Dê o play</h4>
|
||||
<p class="mt-2 text-sm text-[#6c615e]">Um jingle toca por vez.</p>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<article class="jingle-card" data-jingle data-state="stopped">
|
||||
<!-- No port: next/image com fill; este wrapper relative + aspect-square é o contrato. -->
|
||||
<div class="cover-wrap">
|
||||
<img
|
||||
src="jingles-site-publico-ui-design-assets/capa-axe.jpg"
|
||||
alt="Capa do jingle Axé de Jorge Solla 1313"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
class="play-control safe-focus"
|
||||
data-play
|
||||
aria-label="Tocar jingle Axé"
|
||||
aria-pressed="false"
|
||||
>
|
||||
<svg class="play-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m8 5 11 7-11 7z" />
|
||||
</svg>
|
||||
<svg class="pause-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8 5h3v14H8zM14 5h3v14h-3z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h4 class="font-display truncate text-2xl leading-none font-black">Axé</h4>
|
||||
<p class="stopped-only mt-2 text-sm text-[#6c615e]">Pronto para tocar</p>
|
||||
<p
|
||||
class="playing-only mt-2 items-center gap-2 text-sm font-bold text-[#a21c1c]"
|
||||
>
|
||||
<span class="h-2 w-2 rounded-full bg-[#a21c1c]"></span>Em reprodução
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div class="progress-track"><div class="progress-value"></div></div>
|
||||
<div
|
||||
class="mt-2 flex justify-between text-[10px] font-bold tracking-wide text-black/50 uppercase"
|
||||
>
|
||||
<span>Tempo —:—</span><span>Duração —:—</span>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="#"
|
||||
class="download-control safe-focus mt-5 w-full"
|
||||
aria-label="Baixar Axé em MP3"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 3v12" />
|
||||
<path d="m7 10 5 5 5-5" />
|
||||
<path d="M5 21h14" />
|
||||
</svg>
|
||||
Baixar MP3
|
||||
</a>
|
||||
<p class="mt-3 truncate text-center text-[11px] text-black/45">
|
||||
jorge-solla-1313-axe.mp3
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<p
|
||||
class="mt-5 text-center text-[11px] font-bold tracking-wide text-[#6c615e] uppercase"
|
||||
>
|
||||
1 publicado · sem link “Ver todos”
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="stage-shadow overflow-hidden rounded-[18px] border border-black/10 bg-[#fff8f2] px-7 py-10"
|
||||
>
|
||||
<section aria-label="Seção de som com dois jingles">
|
||||
<div class="mx-auto max-w-xl text-center">
|
||||
<p
|
||||
class="campaign-section-eyebrow font-black tracking-[0.1em] text-[#a21c1c] uppercase"
|
||||
>
|
||||
A trilha da nossa caminhada
|
||||
</p>
|
||||
<h3 class="campaign-section-title mt-2 font-black tracking-[-0.03em]">
|
||||
Cante, baixe e espalhe
|
||||
</h3>
|
||||
</div>
|
||||
<article
|
||||
class="radio-card mt-7 grid md:grid-cols-[190px_1fr]"
|
||||
data-radio
|
||||
data-radio-state="facade"
|
||||
>
|
||||
<div class="radio-art flex min-h-[220px] flex-col justify-between p-5 text-white">
|
||||
<span
|
||||
class="font-display w-fit rounded-full border border-white/30 bg-white/10 px-2.5 py-1 text-[9px] font-black tracking-[0.1em] uppercase"
|
||||
>Rádio online</span
|
||||
>
|
||||
<img
|
||||
src="../../public/campaign-kit/numero-negativo.png"
|
||||
alt="Jorge Solla 1313 — Mais Saúde, Mais Futuro"
|
||||
class="w-[145px] self-center object-contain"
|
||||
/>
|
||||
<div
|
||||
class="radio-wave flex h-5 items-center justify-center gap-1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="h-2"></span><span class="h-4"></span><span class="h-3"></span
|
||||
><span class="h-5"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="radio-facade radio-state-panel flex flex-col justify-center p-6">
|
||||
<h4 class="font-display text-2xl font-black">Rádio Jorge Solla 1313</h4>
|
||||
<p class="mt-2 text-sm text-[#6c615e]">Clique para carregar o player oficial.</p>
|
||||
<div class="mt-4 flex flex-wrap items-center gap-5">
|
||||
<button type="button" class="primary-control safe-focus">
|
||||
<svg viewBox="0 0 24 24" class="h-5 w-5 fill-current" aria-hidden="true">
|
||||
<path d="m8 5 11 7-11 7z" />
|
||||
</svg>
|
||||
Ouvir a rádio
|
||||
</button>
|
||||
<a
|
||||
href="https://zeno.fm/radio/jorge-solla-1313/"
|
||||
class="secondary-link safe-focus"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>Abrir no Zeno</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="mt-9 flex items-end justify-between gap-5">
|
||||
<div>
|
||||
<p
|
||||
class="campaign-section-eyebrow font-black tracking-[0.1em] text-[#a21c1c] uppercase"
|
||||
>
|
||||
Jingles oficiais
|
||||
</p>
|
||||
<h4 class="font-display mt-1 text-2xl font-black">
|
||||
Dê o play. O próximo ritmo é seu.
|
||||
</h4>
|
||||
</div>
|
||||
<span class="text-sm text-[#6c615e]">Um jingle toca por vez.</span>
|
||||
</div>
|
||||
<div class="mt-5 grid gap-6 md:grid-cols-3" data-player-group>
|
||||
<article class="jingle-card" data-jingle data-state="stopped">
|
||||
<div class="cover-wrap">
|
||||
<img
|
||||
src="jingles-site-publico-ui-design-assets/capa-axe.jpg"
|
||||
alt="Capa do jingle Axé de Jorge Solla 1313"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="play-control safe-focus"
|
||||
data-play
|
||||
aria-label="Tocar jingle Axé"
|
||||
aria-pressed="false"
|
||||
>
|
||||
<svg class="play-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m8 5 11 7-11 7z" /></svg
|
||||
><svg class="pause-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8 5h3v14H8zM14 5h3v14h-3z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h4 class="font-display truncate text-2xl leading-none font-black">Axé</h4>
|
||||
<p class="stopped-only mt-2 text-xs text-[#6c615e]">Pronto para tocar</p>
|
||||
<p
|
||||
class="playing-only mt-2 items-center gap-2 text-xs font-bold text-[#a21c1c]"
|
||||
>
|
||||
Em reprodução
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div class="progress-track"><div class="progress-value"></div></div>
|
||||
<div
|
||||
class="mt-2 flex justify-between text-[10px] font-bold text-black/50 uppercase"
|
||||
>
|
||||
<span>—:—</span><span>—:—</span>
|
||||
</div>
|
||||
</div>
|
||||
<a href="#" class="download-control safe-focus mt-5 w-full"
|
||||
><svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 3v12" />
|
||||
<path d="m7 10 5 5 5-5" />
|
||||
<path d="M5 21h14" /></svg
|
||||
>Baixar MP3</a
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
<article class="jingle-card" data-jingle data-state="stopped">
|
||||
<div class="cover-wrap">
|
||||
<img
|
||||
src="jingles-site-publico-ui-design-assets/capa-forro.jpg"
|
||||
alt="Capa do jingle Forró de Jorge Solla 1313"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="play-control safe-focus"
|
||||
data-play
|
||||
aria-label="Tocar jingle Forró"
|
||||
aria-pressed="false"
|
||||
>
|
||||
<svg class="play-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m8 5 11 7-11 7z" /></svg
|
||||
><svg class="pause-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8 5h3v14H8zM14 5h3v14h-3z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h4 class="font-display truncate text-2xl leading-none font-black">
|
||||
Forró
|
||||
</h4>
|
||||
<p class="stopped-only mt-2 text-xs text-[#6c615e]">Pronto para tocar</p>
|
||||
<p
|
||||
class="playing-only mt-2 items-center gap-2 text-xs font-bold text-[#a21c1c]"
|
||||
>
|
||||
Em reprodução
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div class="progress-track"><div class="progress-value"></div></div>
|
||||
<div
|
||||
class="mt-2 flex justify-between text-[10px] font-bold text-black/50 uppercase"
|
||||
>
|
||||
<span>—:—</span><span>—:—</span>
|
||||
</div>
|
||||
</div>
|
||||
<a href="#" class="download-control safe-focus mt-5 w-full"
|
||||
><svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 3v12" />
|
||||
<path d="m7 10 5 5 5-5" />
|
||||
<path d="M5 21h14" /></svg
|
||||
>Baixar MP3</a
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<p
|
||||
class="mt-6 text-center text-[11px] font-bold tracking-wide text-[#6c615e] uppercase"
|
||||
>
|
||||
2 publicados · terceira coluna vazia · sem link “Ver todos”
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CENA 07: ZERO JINGLES -->
|
||||
<section aria-labelledby="scene-empty" class="mb-12">
|
||||
<div class="mb-4">
|
||||
<p class="font-display m-0 text-xs font-black tracking-[0.12em] text-[#a21c1c] uppercase">
|
||||
Cena 05 · Zero jingles publicados
|
||||
Cena 07 · Zero jingles publicados
|
||||
</p>
|
||||
<h2 id="scene-empty" class="font-display mt-1 text-xl font-black text-[#211b19]">
|
||||
Só a rádio permanece · sem grade e sem “Ver todos”
|
||||
|
|
@ -1408,7 +2129,7 @@
|
|||
><span class="h-6"></span><span class="h-4"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="radio-facade flex flex-col justify-center p-6 sm:p-8">
|
||||
<div class="radio-facade radio-state-panel flex flex-col justify-center p-6 sm:p-8">
|
||||
<h4 class="font-display text-2xl font-black tracking-[-0.02em]">
|
||||
Rádio Jorge Solla 1313
|
||||
</h4>
|
||||
|
|
@ -1429,8 +2150,32 @@
|
|||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="radio-loaded p-5 sm:p-7">
|
||||
<div class="iframe-placeholder flex items-center justify-center p-4 text-center">
|
||||
<div
|
||||
class="radio-loading radio-state-panel flex flex-col justify-center p-6 sm:p-8"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h4 class="font-display text-2xl font-black">Rádio Jorge Solla 1313</h4>
|
||||
<p class="mt-3 text-sm font-bold">Carregando o player da rádio…</p>
|
||||
<p class="mt-1 text-xs text-[#6c615e]">Isso pode levar alguns segundos.</p>
|
||||
</div>
|
||||
<div class="radio-loading-spinner mt-1 flex-none" aria-hidden="true"></div>
|
||||
</div>
|
||||
<a
|
||||
href="https://zeno.fm/radio/jorge-solla-1313/"
|
||||
class="secondary-link safe-focus mt-4 w-fit text-sm"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>Abrir no Zeno</a
|
||||
>
|
||||
</div>
|
||||
<div class="radio-loaded radio-state-panel p-5 sm:p-7">
|
||||
<div
|
||||
class="iframe-placeholder radio-widget-slot flex items-center justify-center p-4 text-center"
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
class="font-display text-xs font-black tracking-[0.12em] text-[#a21c1c] uppercase"
|
||||
|
|
@ -1450,6 +2195,14 @@
|
|||
<h2 class="font-display text-base font-black text-black">Notas do gate</h2>
|
||||
<ul class="mt-3 grid list-disc gap-2 pl-5 leading-6 sm:grid-cols-2 sm:gap-x-10">
|
||||
<li>Facade não contém iframe: zeno.fm só entra após “Ouvir a rádio”.</li>
|
||||
<li>
|
||||
Pós-clique: <code>loading</code> mantém logo, título e “Abrir no Zeno”; região e botão
|
||||
usam <code>aria-busy="true"</code> até o <code>onLoad</code>.
|
||||
</li>
|
||||
<li>
|
||||
<code>.radio-state-panel</code> reserva 260px no mobile e 300px no desktop; slot do
|
||||
widget, 150px / 168px — sem salto entre facade, loading e loaded.
|
||||
</li>
|
||||
<li>“Rádio online” identifica o canal sem afirmar status “ao vivo” não verificado.</li>
|
||||
<li>Cards, play de 56px, download de 44px, foco e progresso preservam o S21.</li>
|
||||
<li>
|
||||
|
|
@ -1458,6 +2211,23 @@
|
|||
<li>
|
||||
Com zero publicados, nenhum card ou link de jingles é renderizado; a rádio permanece.
|
||||
</li>
|
||||
<li>
|
||||
Com 1–2 publicados, a grade continua <code>md:grid-cols-3</code>, ocupa só as primeiras
|
||||
colunas e não renderiza “Ver todos”; com 3 também não renderiza — o link exige >3.
|
||||
</li>
|
||||
<li>
|
||||
Capas portam para <code>next/image fill</code> no wrapper
|
||||
<code>relative aspect-square</code>; mobile 390 nunca cria largura mínima nem overflow
|
||||
horizontal.
|
||||
</li>
|
||||
<li>
|
||||
Reusar tokens <code>[data-theme='campaign-site']</code>, shells
|
||||
<code>campaign-section-eyebrow/title/copy</code> e o card do S21 classe a classe.
|
||||
</li>
|
||||
<li>
|
||||
A wave é <code>aria-hidden</code>; <code>prefers-reduced-motion</code> remove sua
|
||||
animação e a do spinner sem remover o affordance visual.
|
||||
</li>
|
||||
<li>Sem autoplay, captação de dados ou request de terceiro antes da ação explícita.</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
|
@ -1468,7 +2238,10 @@
|
|||
document.querySelectorAll('[data-load-radio]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const card = button.closest('[data-radio]')
|
||||
card.dataset.radioState = 'loaded'
|
||||
// O artefato para em loading para deixá-lo inspecionável; a Cena 03 mostra o onLoad.
|
||||
// Nenhum iframe/src é criado aqui: é somente apresentação, sem request de terceiro.
|
||||
button.setAttribute('aria-busy', 'true')
|
||||
card.dataset.radioState = 'loading'
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ import { CampaignFooter } from '@/components/CampaignFooter'
|
|||
import { CampaignHero } from '@/components/CampaignHero'
|
||||
import { CampaignProblemCard } from '@/components/CampaignProblemCard'
|
||||
import { CampaignStorySection } from '@/components/CampaignStorySection'
|
||||
import { JingleHomeSection } from '@/components/jingles/JingleHomeSection'
|
||||
import { getCampaignHomeMetaPixelId } from '@/utilities/campaignHomeTracking'
|
||||
import { hasPublishedJingles } from '@/utilities/jingleReads'
|
||||
import { getPublishedJingleItems } from '@/utilities/jingleReads'
|
||||
import type { ReactNode } from 'react'
|
||||
import { CampaignCardsSection } from './CampaignCardsSection'
|
||||
import { CampaignNewsletterSection } from './CampaignNewsletterSection'
|
||||
|
|
@ -115,8 +116,10 @@ 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()
|
||||
// S21/S22 — one cached listing (`unstable_cache` tag `jingles`) serves the
|
||||
// home sound section and the footer's discovery flag, keeping the home static.
|
||||
const jingles = await getPublishedJingleItems()
|
||||
const homeJingles = jingles.slice(0, 3)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -239,11 +242,12 @@ export default async function HomePage() {
|
|||
</section>
|
||||
|
||||
<CampaignStorySection />
|
||||
<JingleHomeSection jingles={homeJingles} showAll={jingles.length > homeJingles.length} />
|
||||
<CampaignCardsSection />
|
||||
<CampaignNewsletterSection pixelId={pixelId ?? undefined} />
|
||||
</main>
|
||||
|
||||
<CampaignFooter showJingles={showJingles} />
|
||||
<CampaignFooter showJingles={jingles.length > 0} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1154,3 +1154,92 @@ body,
|
|||
border: 1px solid rgb(0 0 0 / 20%);
|
||||
}
|
||||
}
|
||||
|
||||
/* S22 — home "A trilha da nossa caminhada": the Rádio 1313 card art (port
|
||||
classe-a-classe de docs/plans/jingles-radio-homepage-ui-design.html). The
|
||||
frame, panels and states live as Tailwind utilities in RadioFacade; only the
|
||||
painted art, the decorative wave and the loading spinner are CSS here. */
|
||||
.campaign-sound-title {
|
||||
/* The artefact scales this section's title above the neighbours' (Cenas
|
||||
01/02: 40px desktop, 34px mobile) without touching the shared token. */
|
||||
font-size: clamp(34px, calc(1.7vw + 18px), 40px);
|
||||
}
|
||||
|
||||
.campaign-radio-art {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 18% 20%, rgb(255 230 7 / 100%) 0 8%, transparent 8.4%),
|
||||
radial-gradient(circle at 85% 75%, rgb(24 78 146 / 90%) 0 14%, transparent 14.5%),
|
||||
linear-gradient(140deg, #c52626 0%, #8d1414 58%, #47130e 100%);
|
||||
}
|
||||
|
||||
.campaign-radio-art::before,
|
||||
.campaign-radio-art::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
border: 1px solid rgb(255 255 255 / 28%);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.campaign-radio-art::before {
|
||||
width: 190px;
|
||||
height: 190px;
|
||||
right: -56px;
|
||||
top: -74px;
|
||||
}
|
||||
|
||||
.campaign-radio-art::after {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
right: -86px;
|
||||
top: -104px;
|
||||
}
|
||||
|
||||
.campaign-radio-wave span {
|
||||
display: block;
|
||||
width: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--pt-yellow);
|
||||
animation: campaign-radio-wave-pulse 1.15s ease-in-out infinite alternate;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.campaign-radio-wave span:nth-child(2n) {
|
||||
animation-delay: -420ms;
|
||||
}
|
||||
|
||||
.campaign-radio-wave span:nth-child(3n) {
|
||||
animation-delay: -760ms;
|
||||
}
|
||||
|
||||
@keyframes campaign-radio-wave-pulse {
|
||||
to {
|
||||
transform: scaleY(0.55);
|
||||
opacity: 0.72;
|
||||
}
|
||||
}
|
||||
|
||||
.campaign-radio-spinner {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 3px solid rgb(162 28 28 / 18%);
|
||||
border-top-color: var(--pt-red);
|
||||
border-radius: 999px;
|
||||
animation: campaign-radio-spin 850ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes campaign-radio-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.campaign-radio-wave span,
|
||||
.campaign-radio-spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
267
src/components/jingles/JingleCards.tsx
Normal file
267
src/components/jingles/JingleCards.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
'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'
|
||||
|
||||
import { JINGLE_FOCUS_RING } from './focusRing'
|
||||
|
||||
/**
|
||||
* S21 — the jingles card grid (artefato: cenas 01/02). One client component
|
||||
* owns the exclusivity state: every card renders its own `<audio
|
||||
* preload="none">` (no request before the play, per the gate note) and starting
|
||||
* one pauses the others. The play control reverts on a failed `play()` so
|
||||
* `aria-pressed` never gets stuck; progress/time come from the media events.
|
||||
* S22 reuses this core in the home sound section, so the
|
||||
* `article[data-jingle]`/`data-state`/download contract stays single-owned and
|
||||
* the heading level follows the host page outline.
|
||||
*/
|
||||
|
||||
const PLAY_CONTROL = cn(
|
||||
'inline-flex h-14 w-14 flex-none items-center justify-center rounded-full bg-(--pt-yellow) text-(--pt-yellow-ink)',
|
||||
'shadow-[0_6px_0_#cfb900,0_10px_22px_rgb(0_0_0/15%)] transition-[transform,box-shadow,filter] duration-150 ease-out',
|
||||
'hover:-translate-y-0.5 hover:brightness-[0.98] hover:shadow-[0_8px_0_#cfb900,0_12px_24px_rgb(0_0_0/18%)]',
|
||||
'active:translate-y-[3px] active:shadow-[0_3px_0_#cfb900]',
|
||||
'motion-reduce:transition-none motion-reduce:hover:translate-y-0',
|
||||
JINGLE_FOCUS_RING,
|
||||
)
|
||||
|
||||
const DOWNLOAD_CONTROL = cn(
|
||||
'mt-5 inline-flex min-h-11 w-full items-center justify-center gap-2 rounded-[10px] border-[1.5px] border-[rgb(162_28_28/32%)] bg-transparent',
|
||||
'font-[family-name:var(--font-exo2)] font-extrabold text-(--pt-red) no-underline',
|
||||
'transition-[background-color,border-color,transform] duration-150 ease-out hover:border-(--pt-red) hover:bg-[rgb(162_28_28/7%)] active:scale-[0.99]',
|
||||
'motion-reduce:transition-none',
|
||||
JINGLE_FOCUS_RING,
|
||||
)
|
||||
|
||||
const PlayIcon = () => (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-[25px] w-[25px] fill-current stroke-current"
|
||||
>
|
||||
<path d="m8 5 11 7-11 7z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const PauseIcon = () => (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-[25px] w-[25px] fill-current stroke-current"
|
||||
>
|
||||
<path d="M8 5h3v14H8zM14 5h3v14h-3z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const DownloadIcon = () => (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-[18px] w-[18px]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M12 3v12" />
|
||||
<path d="m7 10 5 5 5-5" />
|
||||
<path d="M5 21h14" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
type Playback = {
|
||||
currentSeconds: number
|
||||
durationSeconds: number | null
|
||||
}
|
||||
|
||||
const EMPTY_PLAYBACK: Playback = { currentSeconds: 0, durationSeconds: null }
|
||||
|
||||
const EMPTY_TIME = '—:—'
|
||||
|
||||
export const JingleCards = ({
|
||||
jingles,
|
||||
headingLevel = 'h2',
|
||||
}: {
|
||||
jingles: readonly JingleViewModel[]
|
||||
headingLevel?: 'h2' | 'h3' | 'h4'
|
||||
}) => {
|
||||
const Heading = headingLevel
|
||||
const [activeId, setActiveId] = useState<JingleViewModel['id'] | null>(null)
|
||||
const [playback, setPlayback] = useState<Record<number, Playback>>({})
|
||||
const audioRefs = useRef(new Map<number, HTMLAudioElement>())
|
||||
// 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 (
|
||||
<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">
|
||||
<Heading 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}
|
||||
</Heading>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
105
src/components/jingles/JingleHomeSection.tsx
Normal file
105
src/components/jingles/JingleHomeSection.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import Link from 'next/link'
|
||||
|
||||
import type { JingleViewModel } from '@/lib/jingle'
|
||||
|
||||
import { JINGLE_FOCUS_RING } from './focusRing'
|
||||
import { JingleCards } from './JingleCards'
|
||||
import { RadioFacade } from './RadioFacade'
|
||||
|
||||
type JingleHomeSectionProps = {
|
||||
jingles: readonly JingleViewModel[]
|
||||
showAll: boolean
|
||||
}
|
||||
|
||||
const SEE_ALL_CONTROL = `inline-flex min-h-11 items-center gap-1.5 rounded-md px-2 font-bold text-(--pt-red) underline-offset-4 hover:underline ${JINGLE_FOCUS_RING}`
|
||||
|
||||
/**
|
||||
* S22 — the home sound section (artefato: cenas 01/02/06/07): the Rádio 1313
|
||||
* facade (click-to-load) followed by up to three published jingles and the
|
||||
* "Ver todos" handoff to `/jingles` when more exist. With zero published
|
||||
* jingles the grid and the handoff are gone and only the radio stays
|
||||
* (fail-closed, same kill switch as S21); the page passes the already-cached
|
||||
* `getPublishedJingleItems()` listing in, so the section never re-reads.
|
||||
*/
|
||||
export const JingleHomeSection = ({ jingles, showAll }: JingleHomeSectionProps) => {
|
||||
const hasJingles = jingles.length > 0
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="sound-title"
|
||||
data-home-section="sound"
|
||||
className="relative overflow-hidden border-b border-(--campaign-line) bg-(--campaign-cream)"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -top-28 -right-20 h-64 w-64 rounded-full bg-(--pt-yellow)/45"
|
||||
/>
|
||||
<div className="relative mx-auto w-full max-w-[1160px] px-5 py-12 sm:px-8 lg:px-10 lg:py-16">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<p className="campaign-section-eyebrow m-0 font-black tracking-[0.1em] text-(--pt-red) uppercase">
|
||||
A trilha da nossa caminhada
|
||||
</p>
|
||||
<h2
|
||||
id="sound-title"
|
||||
className="campaign-section-title campaign-sound-title m-0 mt-2 border-0 p-0 font-black tracking-[-0.03em] text-balance"
|
||||
>
|
||||
{hasJingles ? (
|
||||
<>
|
||||
Cante, baixe
|
||||
<br className="md:hidden" /> e espalhe
|
||||
</>
|
||||
) : (
|
||||
'Sintonize com a Rádio 1313'
|
||||
)}
|
||||
</h2>
|
||||
<p className="campaign-section-copy mx-auto mt-3 max-w-xl text-(--campaign-muted)">
|
||||
{hasJingles
|
||||
? 'Ouça a Rádio Jorge Solla 1313 e os jingles oficiais da campanha. Dê o play e baixe o seu preferido para levar com você.'
|
||||
: 'Ainda não há jingles publicados por aqui. Enquanto isso, ouça a Rádio Jorge Solla 1313.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RadioFacade compact={!hasJingles} />
|
||||
|
||||
{hasJingles ? (
|
||||
<>
|
||||
<div className="mt-10 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6 lg:mt-12">
|
||||
<div>
|
||||
<p className="campaign-section-eyebrow m-0 font-black tracking-[0.1em] text-(--pt-red) uppercase">
|
||||
Jingles oficiais
|
||||
</p>
|
||||
<h3 className="m-0 mt-1 font-[family-name:var(--font-exo2)] text-2xl font-black tracking-[-0.02em]">
|
||||
<span className="md:hidden">Dê o play</span>
|
||||
<span className="hidden md:inline">Dê o play. O próximo ritmo é seu.</span>
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-sm text-(--campaign-muted)">Um jingle toca por vez.</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<JingleCards jingles={jingles} headingLevel="h4" />
|
||||
</div>
|
||||
|
||||
{showAll ? (
|
||||
<div className="mt-8 text-center">
|
||||
<Link href="/jingles" className={SEE_ALL_CONTROL}>
|
||||
Ver todos os jingles
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,266 +1,19 @@
|
|||
'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'
|
||||
|
||||
import { JingleCards } from './JingleCards'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* S21 — the jingles page shell (artefato: cenas 01/02): the player core
|
||||
* (`JingleCards`) owns the exclusivity state, the lazy audio and the download
|
||||
* name; this shell keeps the published band and the lazy-audio note. S22
|
||||
* reuses the same core in the home sound section.
|
||||
*/
|
||||
export const JinglePlayer = ({ jingles }: { jingles: readonly JingleViewModel[] }) => (
|
||||
<section aria-label="Jingles publicados" className="bg-white px-4 py-7 sm:px-8 sm:py-14">
|
||||
<JingleCards jingles={jingles} />
|
||||
|
||||
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,
|
||||
<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>
|
||||
)
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
285
src/components/jingles/RadioFacade.tsx
Normal file
285
src/components/jingles/RadioFacade.tsx
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { JINGLE_FOCUS_RING } from './focusRing'
|
||||
|
||||
/**
|
||||
* S22 — the Rádio Jorge Solla 1313 card with a click-to-load facade
|
||||
* (artefato: cenas 01/02/03/04/07): the official zeno.fm widget is only mounted
|
||||
* after "Ouvir a rádio", so no third-party request, script or ad loads on the
|
||||
* home pageview. While the iframe answers, the reaction is `loading`
|
||||
* (`role="status"` + `aria-busy`), promoting to `loaded` on `onLoad`; the three
|
||||
* states share one reserved footprint, so nothing jumps (CLS). The external
|
||||
* link stays available in every state. `compact` is the Cena 07 variant: the
|
||||
* zero-jingles home narrows the card and drops the facade's intro and note.
|
||||
*/
|
||||
|
||||
const RADIO_PAGE_URL = 'https://zeno.fm/radio/jorge-solla-1313/'
|
||||
const RADIO_PLAYER_URL = 'https://zeno.fm/player/jorge-solla-1313/'
|
||||
|
||||
type RadioState = 'facade' | 'loading' | 'loaded'
|
||||
|
||||
const ExternalLink = ({ className }: { className?: string }) => (
|
||||
<a
|
||||
href={RADIO_PAGE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
'inline-flex min-h-11 items-center gap-1.5 rounded-md font-bold text-(--pt-red) underline-offset-4 hover:underline',
|
||||
JINGLE_FOCUS_RING,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
Abrir no Zeno
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
</svg>
|
||||
</a>
|
||||
)
|
||||
|
||||
const ShieldIcon = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className={cn('h-4 w-4 flex-none', className)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const RadioArt = ({ compact }: { compact?: boolean }) => (
|
||||
<div
|
||||
className={cn(
|
||||
'campaign-radio-art flex h-[190px] flex-col justify-between p-5 text-white md:h-auto md:p-6',
|
||||
compact ? 'sm:min-h-[220px]' : 'md:min-h-[270px] lg:p-7',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="rounded-full border border-white/30 bg-white/10 px-3 py-1 font-[family-name:var(--font-exo2)] text-[10px] font-black tracking-[0.1em] uppercase md:text-[11px]">
|
||||
Rádio online
|
||||
</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-6 w-6 text-(--pt-yellow) md:h-7 md:w-7"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9M8.5 15.5a5 5 0 0 1 0-7M19.1 4.9c3.9 3.9 3.9 10.3 0 14.2M15.5 8.5a5 5 0 0 1 0 7" />
|
||||
<circle cx="12" cy="12" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<Image
|
||||
src="/campaign-kit/numero-negativo.png"
|
||||
alt="Jorge Solla 1313 — Mais Saúde, Mais Futuro"
|
||||
width={1037}
|
||||
height={595}
|
||||
className={cn(
|
||||
'h-auto self-center object-contain',
|
||||
compact ? 'w-[155px] sm:w-[165px]' : 'w-[155px] md:w-[190px]',
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="campaign-radio-wave flex h-6 items-center justify-center gap-1.5 md:h-8"
|
||||
>
|
||||
<span className="h-2 md:h-3" />
|
||||
<span className="h-5 md:h-6" />
|
||||
<span className="h-3 md:h-4" />
|
||||
<span className="h-6 md:h-8" />
|
||||
<span className="h-4 md:h-5" />
|
||||
<span className="hidden h-7 md:block" />
|
||||
<span className="hidden h-3 md:block" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const RadioPanel = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||
<div
|
||||
className={cn(
|
||||
// The loaded state's real footprint (header + 150/168px widget + note)
|
||||
// is the tallest of the three, so it is the reserved height for facade,
|
||||
// loading and loaded alike — no vertical jump (Cena 04's contract).
|
||||
'flex min-h-[330px] flex-col justify-center p-5 md:min-h-[312px] md:p-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
const FacadePlayButton = ({ onRequestPlayer }: { onRequestPlayer: () => void }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRequestPlayer}
|
||||
className={cn(
|
||||
'inline-flex min-h-12 w-full items-center justify-center gap-2.5 rounded-[10px] bg-(--pt-red) px-5 font-[family-name:var(--font-exo2)] font-black text-white md:w-auto',
|
||||
'shadow-[0_5px_0_#751111,0_10px_22px_rgb(71_19_14/18%)] transition-[transform,box-shadow,filter] duration-150 ease-out',
|
||||
'hover:-translate-y-0.5 hover:brightness-105 hover:shadow-[0_7px_0_#751111,0_13px_25px_rgb(71_19_14/22%)]',
|
||||
'active:translate-y-[3px] active:shadow-[0_2px_0_#751111]',
|
||||
'motion-reduce:transition-none motion-reduce:hover:translate-y-0',
|
||||
JINGLE_FOCUS_RING,
|
||||
)}
|
||||
>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-5 w-5 fill-current">
|
||||
<path d="m8 5 11 7-11 7z" />
|
||||
</svg>
|
||||
Ouvir a rádio
|
||||
</button>
|
||||
)
|
||||
|
||||
const FacadePanel = ({
|
||||
compact,
|
||||
onRequestPlayer,
|
||||
}: {
|
||||
compact?: boolean
|
||||
onRequestPlayer: () => void
|
||||
}) => (
|
||||
<RadioPanel>
|
||||
{compact ? (
|
||||
<>
|
||||
<h3 className="m-0 font-[family-name:var(--font-exo2)] text-2xl font-black tracking-[-0.02em]">
|
||||
Rádio Jorge Solla 1313
|
||||
</h3>
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-(--campaign-muted)">
|
||||
O player oficial só é carregado quando você pedir.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-col gap-2 md:flex-row md:flex-wrap md:items-center md:gap-x-5">
|
||||
<FacadePlayButton onRequestPlayer={onRequestPlayer} />
|
||||
<ExternalLink className="justify-center md:justify-start" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="m-0 hidden font-[family-name:var(--font-exo2)] text-xs font-black tracking-[0.1em] text-(--pt-red) uppercase md:block">
|
||||
Sintonize com a gente
|
||||
</p>
|
||||
<h3 className="m-0 mt-2 font-[family-name:var(--font-exo2)] text-2xl leading-tight font-black tracking-[-0.025em] md:text-[31px]">
|
||||
Rádio Jorge Solla 1313
|
||||
</h3>
|
||||
<p className="mt-3 max-w-xl text-[15px] leading-6 text-(--campaign-muted)">
|
||||
<span className="md:hidden">Carregue o player oficial só quando quiser ouvir.</span>
|
||||
<span className="hidden md:inline">
|
||||
Clique para carregar o player oficial e ouvir a programação da rádio.
|
||||
</span>
|
||||
</p>
|
||||
<div className="mt-5 flex flex-col gap-2 md:mt-6 md:flex-row md:flex-wrap md:items-center md:gap-x-6">
|
||||
<FacadePlayButton onRequestPlayer={onRequestPlayer} />
|
||||
<ExternalLink className="justify-center md:justify-start" />
|
||||
</div>
|
||||
<p className="mt-3 flex flex-col items-center gap-1 text-center text-xs leading-5 text-black/55 md:flex-row md:items-start md:gap-2 md:text-left">
|
||||
<ShieldIcon className="hidden md:block" />
|
||||
<span className="md:hidden">O player do zeno.fm só carrega depois do clique.</span>
|
||||
<span className="hidden md:inline">
|
||||
O player do zeno.fm só é carregado depois do seu clique.
|
||||
</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</RadioPanel>
|
||||
)
|
||||
|
||||
const LoadingPanel = ({ panelRef }: { panelRef?: React.Ref<HTMLDivElement> }) => (
|
||||
<RadioPanel ref={panelRef} tabIndex={-1} role="status" aria-live="polite" aria-busy="true">
|
||||
<div className="flex items-start justify-between gap-5">
|
||||
<div>
|
||||
<p className="m-0 font-[family-name:var(--font-exo2)] text-xs font-black tracking-[0.1em] text-(--pt-red) uppercase">
|
||||
Sintonize com a gente
|
||||
</p>
|
||||
<h3 className="m-0 mt-1 font-[family-name:var(--font-exo2)] text-2xl leading-tight font-black tracking-[-0.02em] md:text-[28px]">
|
||||
Rádio Jorge Solla 1313
|
||||
</h3>
|
||||
<p className="mt-3 text-sm font-bold">Carregando o player da rádio…</p>
|
||||
<p className="mt-1 text-xs text-(--campaign-muted)">Isso pode levar alguns segundos.</p>
|
||||
</div>
|
||||
<div className="campaign-radio-spinner mt-2 flex-none" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap items-center gap-x-5 gap-y-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
aria-busy="true"
|
||||
className="min-h-11 cursor-default rounded-[10px] bg-[rgb(162_28_28/10%)] px-5 text-sm font-bold text-[#7d4848]"
|
||||
>
|
||||
Carregando…
|
||||
</button>
|
||||
<ExternalLink className="text-sm" />
|
||||
</div>
|
||||
</RadioPanel>
|
||||
)
|
||||
|
||||
/**
|
||||
* Mounted at the click (with the iframe) but kept `hidden` until `onLoad`:
|
||||
* `display:none` does not stop the frame from loading, so the same element
|
||||
* survives the `loading → loaded` promotion and the widget never refetches.
|
||||
*/
|
||||
const LoadedPanel = ({ hidden, onLoaded }: { hidden: boolean; onLoaded: () => void }) => (
|
||||
<div className={cn(hidden && 'hidden')}>
|
||||
<RadioPanel>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="m-0 font-[family-name:var(--font-exo2)] text-2xl font-black tracking-[-0.02em]">
|
||||
Rádio Jorge Solla 1313
|
||||
</h3>
|
||||
<ExternalLink className="text-sm" />
|
||||
</div>
|
||||
<iframe
|
||||
src={RADIO_PLAYER_URL}
|
||||
title="Player da Rádio Jorge Solla 1313 no zeno.fm"
|
||||
allow="autoplay"
|
||||
onLoad={onLoaded}
|
||||
className="h-[150px] w-full rounded-xl border-0 bg-(--campaign-band) md:h-[168px]"
|
||||
/>
|
||||
<p className="mt-2 mb-0 text-xs leading-5 text-(--campaign-muted)">
|
||||
Player fornecido por zeno.fm. Ao usar, você acessa um serviço externo.
|
||||
</p>
|
||||
</RadioPanel>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const RadioFacade = ({ compact = false }: { compact?: boolean }) => {
|
||||
const [state, setState] = useState<RadioState>('facade')
|
||||
const statusRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// The facade's button unmounts at the click; move the keyboard focus to the
|
||||
// status region so the journey does not fall back to the document body.
|
||||
useEffect(() => {
|
||||
if (state === 'loading') statusRef.current?.focus()
|
||||
}, [state])
|
||||
|
||||
return (
|
||||
<article
|
||||
data-radio
|
||||
data-radio-state={state}
|
||||
className={cn(
|
||||
'grid overflow-hidden rounded-[18px] border border-[rgb(71_19_14/18%)] bg-white shadow-[0_16px_44px_rgb(71_19_14/10%)]',
|
||||
compact
|
||||
? 'mx-auto mt-8 max-w-[880px] sm:grid-cols-[240px_1fr]'
|
||||
: 'mt-9 md:grid-cols-[270px_1fr]',
|
||||
)}
|
||||
>
|
||||
<RadioArt compact={compact} />
|
||||
{state === 'facade' ? (
|
||||
<FacadePanel compact={compact} onRequestPlayer={() => setState('loading')} />
|
||||
) : (
|
||||
<>
|
||||
{state === 'loading' ? <LoadingPanel panelRef={statusRef} /> : null}
|
||||
<LoadedPanel hidden={state === 'loading'} onLoaded={() => setState('loaded')} />
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
8
src/components/jingles/focusRing.ts
Normal file
8
src/components/jingles/focusRing.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* S21/S22 — the shared focus-visible ring of the jingles surfaces (play,
|
||||
* download, radio CTA and the "Ver todos" handoff): yellow outline over the
|
||||
* dark red shadow, the same policy the campaign sections apply via
|
||||
* `safe-focus` in the design artefact.
|
||||
*/
|
||||
export const JINGLE_FOCUS_RING =
|
||||
'focus-visible:shadow-[0_0_0_2px_var(--pt-red-dark)] focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-yellow)'
|
||||
|
|
@ -557,6 +557,52 @@ test.describe('Frontend', () => {
|
|||
expect(overflow).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('shows the sound section between the story and the cards with the inert radio facade', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/')
|
||||
|
||||
const sound = page.locator('[data-home-section="sound"]')
|
||||
await expect(sound).toBeVisible()
|
||||
await expect(sound.getByText('A trilha da nossa caminhada')).toBeVisible()
|
||||
|
||||
// Position: after the story, before the cards (the conversion block stays
|
||||
// last). The jingle grid is content-dependent, so this spec never counts
|
||||
// cards — counts belong to the serial frontendJingles spec.
|
||||
const tops = await page.evaluate(() => {
|
||||
const top = (selector: string) => {
|
||||
const element = document.querySelector<HTMLElement>(selector)
|
||||
if (!element) throw new Error(`Seção ausente: ${selector}`)
|
||||
return element.getBoundingClientRect().top
|
||||
}
|
||||
return {
|
||||
story: top('[data-home-section="story"]'),
|
||||
sound: top('[data-home-section="sound"]'),
|
||||
cards: top('[data-home-section="cards"]'),
|
||||
}
|
||||
})
|
||||
expect(tops.sound).toBeGreaterThan(tops.story)
|
||||
expect(tops.cards).toBeGreaterThan(tops.sound)
|
||||
|
||||
// S22 — the official player only mounts after the gesture; until then the
|
||||
// facade holds no iframe and the external link is the alternative.
|
||||
await expect(sound.locator('[data-radio][data-radio-state="facade"]')).toBeVisible()
|
||||
await expect(sound.locator('iframe')).toHaveCount(0)
|
||||
await expect(sound.getByRole('button', { name: 'Ouvir a rádio' })).toBeVisible()
|
||||
await expect(sound.getByRole('link', { name: 'Abrir no Zeno' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://zeno.fm/radio/jorge-solla-1313/',
|
||||
)
|
||||
|
||||
// Mobile 390: the section never overflows horizontally.
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
const overflow = await page.evaluate(() => {
|
||||
const scrollContainer = document.querySelector<HTMLElement>('[data-theme="campaign-site"]')
|
||||
return scrollContainer ? scrollContainer.scrollWidth - scrollContainer.clientWidth : 0
|
||||
})
|
||||
expect(overflow).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('keeps carousel auto-advance and chips on mobile', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto('/')
|
||||
|
|
@ -1702,7 +1748,10 @@ test.describe('Cards personalizados (S14)', () => {
|
|||
.evaluateAll((sections) =>
|
||||
sections.map((section) => section.getAttribute('data-home-section')),
|
||||
)
|
||||
expect(order.indexOf('cards')).toBe(order.indexOf('story') + 1)
|
||||
// S22 — the sound section sits between the story and the cards; the
|
||||
// conversion block (cards + newsletter) stays last.
|
||||
expect(order.indexOf('sound')).toBe(order.indexOf('story') + 1)
|
||||
expect(order.indexOf('cards')).toBe(order.indexOf('sound') + 1)
|
||||
expect(order.indexOf('cards')).toBe(order.indexOf('newsletter') - 1)
|
||||
|
||||
const section = page.locator('section#cards')
|
||||
|
|
|
|||
|
|
@ -199,6 +199,12 @@ test.describe('Frontend jingles (S21)', () => {
|
|||
await expect(page.getByRole('link', { name: 'Jingles' })).toHaveCount(0)
|
||||
await page.goto('/')
|
||||
await expect(page.getByRole('link', { name: 'Jingles' })).toHaveCount(0)
|
||||
// S22 — the home sound section keeps the radio and drops the empty grid.
|
||||
const sound = page.locator('[data-home-section="sound"]')
|
||||
await expect(sound.getByRole('heading', { name: 'Sintonize com a Rádio 1313' })).toBeVisible()
|
||||
await expect(sound.locator('[data-radio][data-radio-state="facade"]')).toBeVisible()
|
||||
await expect(sound.locator('article[data-jingle]')).toHaveCount(0)
|
||||
await expect(sound.getByRole('link', { name: 'Ver todos os jingles' })).toHaveCount(0)
|
||||
expect((await request.get(`${BASE_URL}/jingles`)).status()).toBe(200)
|
||||
await expect
|
||||
.poll(async () =>
|
||||
|
|
@ -237,4 +243,81 @@ test.describe('Frontend jingles (S21)', () => {
|
|||
await page.goto('/')
|
||||
await expect(page.getByRole('link', { name: 'Jingles' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows the sound section on the home and loads the radio only on click', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const headers = await adminHeaders(request, BASE_URL)
|
||||
await unpublishEveryJingle(request, headers)
|
||||
for (const title of ['Axé na home', 'Forró na home', 'Pagodão na home']) {
|
||||
await createJingle(request, headers, { title, slug: uniqueSlug() })
|
||||
}
|
||||
|
||||
const zenoRequests: string[] = []
|
||||
const audioRequests: string[] = []
|
||||
page.on('request', (req) => {
|
||||
if (req.url().includes('zeno.fm')) zenoRequests.push(req.url())
|
||||
if (/\/api\/media\/file\/.*\.mp3/.test(req.url())) audioRequests.push(req.url())
|
||||
})
|
||||
// Hermetic e2e: the official widget comes from the local double, never the
|
||||
// real network (the fixture guard fails on external console errors).
|
||||
await page.route('https://zeno.fm/**', (route) =>
|
||||
route.fulfill({ body: '<!doctype html><title>zeno stub</title>', contentType: 'text/html' }),
|
||||
)
|
||||
|
||||
await page.goto(`/?e2e=${Date.now()}`)
|
||||
const sound = page.locator('[data-home-section="sound"]')
|
||||
await expect(sound).toBeVisible()
|
||||
await expect(sound.getByText('A trilha da nossa caminhada')).toBeVisible()
|
||||
await expect(sound.locator('article[data-jingle]')).toHaveCount(3)
|
||||
// Three of three: the handoff only exists when more are published.
|
||||
await expect(sound.getByRole('link', { name: 'Ver todos os jingles' })).toHaveCount(0)
|
||||
|
||||
// The facade is inert: no iframe, no zeno request and no audio fetch
|
||||
// before the gesture (`preload="none"` on every card).
|
||||
await expect(sound.locator('[data-radio][data-radio-state="facade"]')).toBeVisible()
|
||||
await expect(sound.locator('iframe')).toHaveCount(0)
|
||||
expect(zenoRequests).toHaveLength(0)
|
||||
expect(audioRequests).toHaveLength(0)
|
||||
await expect(sound.getByRole('link', { name: 'Abrir no Zeno' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://zeno.fm/radio/jorge-solla-1313/',
|
||||
)
|
||||
|
||||
// The click mounts the official player (loading → loaded on onLoad).
|
||||
await sound.getByRole('button', { name: 'Ouvir a rádio' }).click()
|
||||
await expect(sound.locator('[data-radio]')).toHaveAttribute('data-radio-state', 'loaded')
|
||||
const frame = sound.locator('iframe')
|
||||
await expect(frame).toHaveCount(1)
|
||||
await expect(frame).toHaveAttribute('src', 'https://zeno.fm/player/jorge-solla-1313/')
|
||||
expect(zenoRequests.length).toBeGreaterThan(0)
|
||||
|
||||
// Mobile 390: the section never overflows horizontally.
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
const overflow = await page.evaluate(() => {
|
||||
const scrollContainer = document.querySelector<HTMLElement>('[data-theme="campaign-site"]')
|
||||
return scrollContainer ? scrollContainer.scrollWidth - scrollContainer.clientWidth : 0
|
||||
})
|
||||
expect(overflow).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('shows three of a larger collection and hands off to /jingles', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const headers = await adminHeaders(request, BASE_URL)
|
||||
await unpublishEveryJingle(request, headers)
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
await createJingle(request, headers, { title: `Jingle extra ${index}`, slug: uniqueSlug() })
|
||||
}
|
||||
|
||||
await page.goto(`/?e2e=${Date.now()}`)
|
||||
const sound = page.locator('[data-home-section="sound"]')
|
||||
await expect(sound.locator('article[data-jingle]')).toHaveCount(3)
|
||||
|
||||
const seeAll = sound.getByRole('link', { name: 'Ver todos os jingles' })
|
||||
await expect(seeAll).toBeVisible()
|
||||
await expect(seeAll).toHaveAttribute('href', '/jingles')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -35,11 +35,12 @@ 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).
|
||||
// S21/S22 — the home reads the jingle listing through the Payload DB +
|
||||
// unstable_cache for the sound section and the footer discovery flag; the unit
|
||||
// env has neither, and the real listing/kill-switch behavior is e2e-covered
|
||||
// (frontendJingles.e2e.spec.ts).
|
||||
vi.mock('@/utilities/jingleReads', () => ({
|
||||
hasPublishedJingles: async () => false,
|
||||
getPublishedJingleItems: async () => [],
|
||||
}))
|
||||
|
||||
// S14 — the card section renders the client studio island (next/font local
|
||||
|
|
@ -56,7 +57,7 @@ vi.mock('@/components/cards/CardsStudio', () => ({
|
|||
afterEach(cleanup)
|
||||
|
||||
describe('Campaign home', () => {
|
||||
it('monta as cinco seções previstas e o rodapé eleitoral', async () => {
|
||||
it('monta as seis seções previstas e o rodapé eleitoral', async () => {
|
||||
render(await HomePage())
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'MAIS SAÚDE MAIS FUTURO' })).toBeTruthy()
|
||||
|
|
@ -69,6 +70,8 @@ describe('Campaign home', () => {
|
|||
name: /Junto com o trabalhador e do lado de quem mais precisa, sempre/i,
|
||||
}),
|
||||
).toBeTruthy()
|
||||
// S22 — with zero published jingles the sound section shows only the radio.
|
||||
expect(screen.getByRole('heading', { name: 'Sintonize com a Rádio 1313' })).toBeTruthy()
|
||||
expect(screen.getByRole('heading', { name: /Mostre que você está com Solla/i })).toBeTruthy()
|
||||
expect(screen.getByRole('heading', { name: /Receba as novidades da campanha/i })).toBeTruthy()
|
||||
expect(screen.getByText(/CNPJ: 68\.430\.467\/0001-05/)).toBeTruthy()
|
||||
|
|
|
|||
75
tests/unit/jingleHomeSection.unit.spec.tsx
Normal file
75
tests/unit/jingleHomeSection.unit.spec.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { JingleHomeSection } from '@/components/jingles/JingleHomeSection'
|
||||
import type { JingleViewModel } from '@/lib/jingle'
|
||||
|
||||
type MockImageProps = React.ImgHTMLAttributes<HTMLImageElement> & {
|
||||
fill?: boolean
|
||||
priority?: boolean
|
||||
}
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({ alt, fill: _fill, priority: _priority, ...props }: MockImageProps) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img alt={alt} {...props} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href, ...props }: { children: React.ReactNode; href: string }) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}))
|
||||
|
||||
const jingle = (id: number, title: string): JingleViewModel => ({
|
||||
id,
|
||||
title,
|
||||
coverUrl: `/api/media/file/capa-${id}.jpg`,
|
||||
coverAlt: `Capa do jingle ${title} de Jorge Solla 1313`,
|
||||
audioUrl: `/api/media/file/${id}.mp3`,
|
||||
downloadFilename: `jorge-solla-1313-${id}.mp3`,
|
||||
})
|
||||
|
||||
const THREE_JINGLES = [jingle(1, 'Axé'), jingle(2, 'Forró'), jingle(3, 'Pagodão')] as const
|
||||
const cards = () => document.querySelectorAll('article[data-jingle]')
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('JingleHomeSection', () => {
|
||||
it('renders the radio alone with zero published jingles (fail-closed)', () => {
|
||||
render(<JingleHomeSection jingles={[]} showAll={false} />)
|
||||
|
||||
expect(document.querySelector('[data-home-section="sound"]')).not.toBeNull()
|
||||
expect(document.querySelector('[data-radio][data-radio-state="facade"]')).not.toBeNull()
|
||||
expect(cards()).toHaveLength(0)
|
||||
expect(screen.queryByText('Jingles oficiais')).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: /Ver todos os jingles/ })).toBeNull()
|
||||
expect(screen.getByRole('heading', { name: 'Sintonize com a Rádio 1313' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders one or two jingles without inventing content or "Ver todos"', () => {
|
||||
render(<JingleHomeSection jingles={[jingle(1, 'Axé')]} showAll={false} />)
|
||||
|
||||
expect(cards()).toHaveLength(1)
|
||||
expect(screen.queryByRole('link', { name: /Ver todos os jingles/ })).toBeNull()
|
||||
expect(screen.getByText('Jingles oficiais')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders the three shown jingles without "Ver todos" when the listing fits', () => {
|
||||
render(<JingleHomeSection jingles={THREE_JINGLES} showAll={false} />)
|
||||
|
||||
expect(cards()).toHaveLength(3)
|
||||
expect(screen.getByRole('heading', { name: 'Cante, baixe e espalhe' })).toBeDefined()
|
||||
expect(screen.queryByRole('link', { name: /Ver todos os jingles/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('hands off to /jingles only when more than three are published', () => {
|
||||
render(<JingleHomeSection jingles={THREE_JINGLES} showAll />)
|
||||
|
||||
const link = screen.getByRole('link', { name: /Ver todos os jingles/ })
|
||||
expect(link.getAttribute('href')).toBe('/jingles')
|
||||
})
|
||||
})
|
||||
66
tests/unit/radioFacade.unit.spec.tsx
Normal file
66
tests/unit/radioFacade.unit.spec.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { RadioFacade } from '@/components/jingles/RadioFacade'
|
||||
|
||||
type MockImageProps = React.ImgHTMLAttributes<HTMLImageElement> & {
|
||||
fill?: boolean
|
||||
priority?: boolean
|
||||
}
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({ alt, fill: _fill, priority: _priority, ...props }: MockImageProps) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img alt={alt} {...props} />
|
||||
),
|
||||
}))
|
||||
|
||||
const card = () => document.querySelector<HTMLElement>('[data-radio]')
|
||||
const radioState = () => card()?.getAttribute('data-radio-state')
|
||||
const frame = () => document.querySelector<HTMLIFrameElement>('iframe')
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('RadioFacade', () => {
|
||||
it('starts at the facade with no iframe, script or third-party request', () => {
|
||||
render(<RadioFacade />)
|
||||
|
||||
expect(radioState()).toBe('facade')
|
||||
expect(frame()).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Ouvir a rádio' })).toBeDefined()
|
||||
expect(
|
||||
screen.getByText(/O player do zeno\.fm só é carregado depois do seu clique/),
|
||||
).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps the external link as the always-available alternative', () => {
|
||||
render(<RadioFacade />)
|
||||
|
||||
const link = screen.getByRole('link', { name: /Abrir no Zeno/ })
|
||||
expect(link.getAttribute('href')).toBe('https://zeno.fm/radio/jorge-solla-1313/')
|
||||
expect(link.getAttribute('target')).toBe('_blank')
|
||||
expect(link.getAttribute('rel')).toBe('noopener noreferrer')
|
||||
})
|
||||
|
||||
it('mounts the official player only on click and promotes to loaded on onLoad', () => {
|
||||
render(<RadioFacade />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Ouvir a rádio' }))
|
||||
|
||||
expect(radioState()).toBe('loading')
|
||||
const mountedFrame = frame()
|
||||
expect(mountedFrame).not.toBeNull()
|
||||
expect(mountedFrame?.getAttribute('src')).toBe('https://zeno.fm/player/jorge-solla-1313/')
|
||||
expect(mountedFrame?.getAttribute('title')).toContain('Rádio Jorge Solla 1313')
|
||||
expect(screen.getByRole('status').getAttribute('aria-busy')).toBe('true')
|
||||
expect(screen.getByRole('button', { name: 'Carregando…' }).hasAttribute('disabled')).toBe(true)
|
||||
|
||||
fireEvent.load(mountedFrame as HTMLIFrameElement)
|
||||
|
||||
expect(radioState()).toBe('loaded')
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
// The very same frame survives loading → loaded: the widget never refetches.
|
||||
expect(frame()).toBe(mountedFrame)
|
||||
expect(screen.getByText(/Player fornecido por zeno\.fm/)).toBeDefined()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue