mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 08:53:11 +00:00
feat(C194): biblioteca de reels na vertical Comunicação (#1153)
Lista publicados-only com capas 9:16, detalhe com player 9:16 e painel de downloads (vídeo sem áudio primário; com áudio, mp3 e .srt quando existem; transcrição em texto) e kill switch reversível: despublicar tira da lista e bloqueia a mídia (C193 decision B), republicar reativa e re-carimba publishedAt. Fiação: gate communicationCatalog (rename do speechCatalog), 3º sub-item da vertical no nav, rule da lista no chrome, paths e loader próprios; sem migration, sem Consent, sem tocar Media.read ou a rota de mídia do C193. Design tier: DEGRADED — crítica final do designer indisponível no tier primário (limite de uso); requer sign-off humano antes de push/PR.
This commit is contained in:
parent
bdaf3c7cbb
commit
f5f412a529
43 changed files with 2156 additions and 95 deletions
1
docs/changelog/2026-09-18-c194.md
Normal file
1
docs/changelog/2026-09-18-c194.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C194 — Biblioteca de reels na vertical Comunicação (2026-09-18):** a assessoria produzia os Reels de tutorial fora do Teqo e não tinha onde se servir do material pronto; o C193 registrou o modelo e os arquivos privados, e o C194 entregou a biblioteca. (1) **Lista** em `/campanha/comunicacao/reels` — grid de capas 9:16 (o card inteiro é o link), só `published`, ordenada por `-publishedAt`, com paginação pelo shell do sistema de listas e vazio honesto ("a produção acontece fora desta tela"); loader próprio `src/utilities/reels/reelPageData.ts` (Local API `overrideAccess:false` + `user`, `select` enxuto) e URL `src/utilities/reels/reelListUrl.ts` (só `page`). (2) **Detalhe** em `/campanha/comunicacao/reels/[id]` — player 9:16 (`video-audio ?? video`, time nativo), painel de downloads (CTA primário "Baixar vídeo sem áudio" com `?download=1`; vídeo com áudio, mp3 e `.srt` como linhas quando existem, com motivo honesto quando não) e transcrição/roteiro em texto com copiar (owner `copyFeedback` parametrizado por assunto) — sem duração (o modelo não tem campo; a decisão visual foi adaptada pelo `designer`, tier primário). (3) **Kill switch** no detalhe: `POST .../reels/[id]/publicacao` via `campaignJsonMutationRoute` + `setReelPublishedForActor` (gate fresco do papel, update único, sem transação, hook do C193 re-carimba `publishedAt`); despublicar tira da lista e bloqueia player/downloads na tela com "Republicar" no lugar (a rota de mídia do C193 responde 404 a `draft`/`unpublished` — decisão B herdada, sem link morto), e a transcrição (texto, não mídia) permanece legível. (4) **Acesso**: só `communicator`/`coordinator`/`candidate` (fail-closed; `advisor`/`leader` redirecionados, rota de mutação com mensagem de domínio própria). (5) **Fiação**: 3º sub-item "Reels" da vertical no `nav.ts` (teste do pin atualizado), rule da lista no `campaignPageChrome.ts` (o detalhe resolve chrome próprio), paths em `campaignPaths.ts`, e o rename mecânico do gate `speechCatalog` → `communicationCatalog` (dono `campaignPageActor.ts`; inclui a referência que o C193 deixou passar em `speechPageData.ts`, que quebrava o typecheck do `main`). (6) **Sem migration, sem Consent, sem `Media.read`/rota do C193 tocados, site público e cards idênticos.** (7) **Testes**: unit de view models (arquivos bloqueados fora de `published`, capa/alt, publicação), nav/chrome/gate; int do loader (lista só publicados e ordenada, matriz de papéis, detalhe em qualquer status, `ReelNotFoundError`) e do kill switch (despublicar não apaga, republicar re-carimba `publishedAt`, negado/inexistente); e2e novo `campaignReel` (HTTP: lista/detalhe/download, anônimo 404, unpublish sem lista e sem mídia, republicar volta, advisor/leader negados) mapeado no manifest. `pnpm gate:fast` verde + e2e local do spec novo e das mesmas superfícies (cortes/acervo) verdes.
|
||||
102
docs/plans/reels-biblioteca-impl.md
Normal file
102
docs/plans/reels-biblioteca-impl.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# Impl: Biblioteca de reels na vertical Comunicação
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-09-18
|
||||
Issue: #1153
|
||||
Intenção: docs/plans/reels-biblioteca.md
|
||||
Appetite restante: herdado (~1–2 dias eng; lista + detalhe + kill switch verificáveis com fixtures)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** `/campanha/comunicacao/reels` lista os reels **publicados** (capa 9:16 + título), o detalhe abre o reel em 9:16 e oferece os arquivos disponíveis (vídeo sem áudio primário, vídeo com áudio, mp3, `.srt`, transcrição) para a assessoria trabalhar fora do site; despublicar tira da lista sem apagar e republicar reativa; acesso só a `communicator`/`coordinator`/`candidate`, fail-closed.
|
||||
- **O que NÃO negociar:** arquivos privados (rota C193 é a única porta; `Media.read` intocado); nada de publicação/edição de vídeo na UI; nenhum reel some por acidente (kill switch reversível); sem busca/filtros no MVP (`-publishedAt`); sem `Consent` novo; sem migração (C193 fechou o modelo).
|
||||
- **O que reavaliar:** a recomendação A da intenção para o link direto do despublicado **conflita com o C193 (decisão B, mergeada)** — vale B: `draft`/`unpublished` não servem mídia; o detalhe interno continua abrindo, mas sem player/downloads. Verifiquei que o regex `/acervo/[^/]+` do chrome (`campaignPageChrome.ts:297`) **não** engole `/reels` (só falta a rule da lista). O conflito C184 (#1106) **não foi mergeado** (commit docs-only) — prosseguir. Os defers do C193 (`reelMediaPath`/`isReelStatus`) têm gatilho explícito neste item.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
L["Lista /campanha/comunicacao/reels"] -->|"loadReelLibraryPageData · status=published · sort -publishedAt"| DB[("reel + reelMedia (depth 1)")]
|
||||
D["Detalhe /campanha/comunicacao/reels/:id"] -->|"loadReelDetailPageData · qualquer status"| DB
|
||||
D --> P["Player 9:16 + painel de downloads"]
|
||||
P -->|"video-audio ?? video · ?download=1"| M["GET .../media/kind — rota C193: só published"]
|
||||
D --> K["ReelPublicationPanel"]
|
||||
K -->|"POST .../publicacao"| A["setReelPublishedForActor: gate fresco + update único"]
|
||||
A --> DB
|
||||
```
|
||||
|
||||
**D1 — Dados da página. Opções: A | B | C — Recomendação: A.** Loader em `src/utilities/reels/reelPageData.ts` (`server-only`, `overrideAccess: false, user`, select enxuto), view models puros no dono do vocabulário `src/lib/reel.ts` (como `lib/speechCut.ts` faz) e URL curta em `src/utilities/reels/reelListUrl.ts` (só `page`, reusando `campaignListUrl`). Rejeitadas: **B** query inline nos `page.tsx` (espalha o where do kill switch e mata o int sem HTTP); **C** top-level `src/utilities/reelPageData.ts` (top-level é pinado por `codebaseConventions.unit.spec.ts`; a subpasta `utilities/reels/` já é o dono criado no C193).
|
||||
|
||||
**D2 — Render da lista. Opções: A | B | C — Recomendação: A.** Grid RSC de capas 9:16, todo o card é um `<Link>` (sem ilha cliente), com os shells de lista (`CampaignPageShell`, `CampaignListPendingBoundary/Results`, `CampaignListEmptyState`, `CampaignListFooter`). Rejeitadas: **B** `CampaignTable` (o sistema de listas é de tabela; o design é capa-led e o job é varrer por capa/título); **C** busca/filtro/sort toggle no MVP (produto recomenda A; ordem fixa `-publishedAt`).
|
||||
|
||||
**D3 — Transporte do kill switch. Opções: A | B | C — Recomendação: A.** `POST .../reels/[id]/publicacao` com `campaignJsonMutationRoute` + `setReelPublishedForActor` em `src/app/(campaign)/campanha/actions/reels.ts` (gate fresco `canReadCommunicationCatalog`, find ator-escopado, **um** `payload.update`); painel cliente com `useState` pending + `postCampaignJson` + `router.refresh()` (precedente `SpeechCutPublicationPanel.tsx:42-65`, regra `campanha-action-feedback`). Rejeitadas: **B** server action + `<form>` (a superfície é botão com AlertDialog, não form; o wrapper JSON é convenção estrutural — `codebaseConventions` recusa `POST` fora dele); **C** REST direto `/api/reel/:id` (pula o gate fresco do ator e a mensagem de domínio; wire divergente).
|
||||
|
||||
**D4 — Detalhe de reel não publicado. Opções: A | B | C — Recomendação: A.** Detalhe abre para `draft`/`unpublished` (leitura permitida), **sem** player/downloads e sem nenhum href de mídia; aviso "fora da lista" com copy adaptada (arquivos guardados, sem acesso até republicar) e painel com "Publicar" (draft) / "Republicar" (unpublished). Rejeitadas: **B** detalhe 404 para não publicado (mataria o kill switch reversível da própria tela, contra o aceite); **C** player mesmo despublicado (contraria o C193 B mergeado — tocar prometeria arquivo que o servidor nega; link morto).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/lib/reel.ts`** (editar): `ReelLibraryItemViewModel`/`ReelDetailViewModel` + `toReelLibraryItemViewModel`/`toReelDetailViewModel` (labels de `reelStatusLabels`/`reelFeatureLabels`, `publishedAtLabel`, disponibilidade por artefato, `coverAlt`); passa a consumir `reelMediaPath` (player/capa/downloads). **Cleanup do defer C193:** se ao merge `isReelStatus` seguir sem call site de produção, apagá-lo + o pino em `tests/unit/reel.unit.spec.ts`.
|
||||
- **`src/lib/campaignPaths.ts`** (editar): `CAMPAIGN_COMMUNICATION_REELS`, `campaignReelDetailHref(id)`, `campaignReelPublicationHref(id)`.
|
||||
- **`src/lib/schemas/reel.ts`** (novo): `reelPublicationRequestSchema` (`reelId: positiveRelationshipId`, `published: z.boolean()`) e `REEL_FORBIDDEN_MESSAGE` ('Você não tem acesso à biblioteca de reels.' — literal próprio do domínio; NÃO reusar o "acervo de falas"), `REEL_NOT_FOUND_MESSAGE`, `REEL_GENERIC_ERROR_MESSAGE`.
|
||||
- **`src/utilities/reels/reelListUrl.ts`** (novo): `parse/serialize/resolve` só de `page` (canônico via `resolveListUrl`), `buildReelListHref`, `REEL_PAGE_SIZE = 24`.
|
||||
- **`src/utilities/reels/reelPageData.ts`** (novo): `loadReelLibraryPageData` (where fixo `{ status: { equals: 'published' } }`, `sort: '-publishedAt'`, depth 1, select título/feature/status/cover/publishedAt) e `loadReelDetailPageData` (qualquer status; `ReelNotFoundError` → `notFound()` na página; depth 1).
|
||||
- **`src/utilities/campaignPageActor.ts`** (editar): rename mecânico do gate `'speechCatalog'` → `'communicationCatalog'` (tipo + doc + condição). **Verificado:** 7 chamadas em 6 páginas (`comunicacao/page.tsx:12`, `acervo/page.tsx:34`, `acervo/[id]/page.tsx:36,52`, `cortes/page.tsx:35`, `cortes/[id]/page.tsx:37,57`) + comentários (`actions/speech.ts:265`, `tests/e2e/fixtures/campaignHttpTest.ts:125`).
|
||||
- **`src/app/(campaign)/campanha/actions/reels.ts`** (novo): `setReelPublishedForActor` — schema parse, `getCampaignActionContext`, gate fresco por role, `payload.find` ator-escopado (não encontrado → `REEL_NOT_FOUND_MESSAGE`), `payload.update({ status: published ? 'published' : 'unpublished' })`. **Sem transação** (uma coleção/um documento; o hook `stampReelPublishedAt` re-carimba `publishedAt` — `Reel.ts:51-59`); mesmo precedente `speech.ts:297-320`.
|
||||
- **Rotas novas** em `src/app/(campaign)/campanha/(app)/comunicacao/reels/`: `page.tsx` (lista; `campaignPageMetadataFromCatalog('reels')`; `requireCampaignPageActor({ gate: 'communicationCatalog' })`), `[id]/page.tsx` (detalhe: chrome próprio, `ReelStatusBadge`, `ReelPlayer`, `ReelDownloadPanel`, `ReelTranscriptCard`, `ReelPublicationPanel`), `[id]/publicacao/route.ts` (`POST` via `campaignJsonMutationRoute` + `safeMessages`), `[id]/types.ts` (`ReelPublicationResponse`).
|
||||
- **`src/components/campaign/reels/`** (novos): `ReelLibraryList.tsx`, `ReelLibraryCard.tsx` (capa via `<img>` apontando à rota C193 — **não** usar `next/image` otimizado: o otimizador busca a fonte server-side sem o cookie `campaign-token` e receberia 404), `ReelStatusBadge.tsx`, `ReelPlayer.tsx` (`<video controls playsInline preload="metadata">`, fonte `video-audio ?? video`, poster cover; barra custom do artefato, se mantida, é decisão do designer no port), `ReelDownloadPanel.tsx` (primário `?download=1`; secundários disponíveis viram `<a>`; indisponíveis, texto desabilitado — cena 06), `ReelTranscriptCard.tsx` (+ botão de copiar reusando o dono `src/lib/copyFeedback.ts`, parametrizado com `subject: 'link' | 'text'`, default preserva os call sites), `ReelPublicationPanel.tsx`.
|
||||
- **`src/components/campaign/shell/nav.ts`** (editar): 3º sub-item `{ title: 'Reels', href: CAMPAIGN_COMMUNICATION_REELS }`; atualizar comentário "dois destinations".
|
||||
- **`src/lib/campaignPageChrome.ts`** (editar): catalog `reels: { title: 'Reels', subtitle: 'Tutoriais prontos para a assessoria baixar e publicar fora do site.' }` + rule exata da lista (antes do regex de `/acervo/[^/]+` por vizinhança; detalhe resolve `null` sem rule nova — regex verificada).
|
||||
- **Migration:** nenhuma. **Access/Consent:** nenhum helper novo; leituras `overrideAccess: false` + `user`; sem `Consent` (mídia de staff, sem PII). A rota de mídia C193 fica intocada.
|
||||
- **UI:** Impeccable **C**. Fonte visual: `docs/plans/reels-biblioteca-ui-design.html`. O `designer` está adaptando em paralelo: (1) cena 07 sem prometer acesso ao arquivo (remove "acessíveis por este link" — C193 B); (2) sem duração (não há campo; o `00:28` sai); (3) kill switch por qualquer um dos 3 papéis. Portar só o artefato adaptado; mudar estrutura visual sem o designer é defeito.
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
N/A — a intenção declara "não vou apresentar dados": inventário operacional (capa/título/status), sem métrica/gráfico. A forma é a lista ordenada por `-publishedAt` com paginação.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Contrato puro + fiação (unit).** ~0.5 dia: VMs e paths; `reelListUrl`; chrome; nav; rename do gate. Unit: `tests/unit/reel.unit.spec.ts` (editar), `reelListUrl.unit.spec.ts` (novo), `campaignPageChrome.unit.spec.ts` (editar: lista 'Reels', detalhe `null`), `campaignNav.unit.spec.ts` (editar: pin exato dos 3 sub-itens em `:100-127`), `campaignPageActorGate.unit.spec.ts` (editar: `communicationCatalog` passa communicator/coordinator/candidate; advisor → `/campanha`, leader → `/campanha/meus-contatos`). Verde: `pnpm test:unit`.
|
||||
2. **Dados + lista + detalhe (port do artefato).** ~0.5 dia: `reelPageData.ts`, páginas e componentes. Int novo `tests/int/reelLibrary.int.spec.ts` (mock `next/cache` + `getCampaignActionContext` como `speechCut.int.spec.ts:22-31`): lista só `published` e ordenada, canonicalização de `page`, matriz de papéis do loader, detalhe em qualquer status, `ReelNotFoundError`. Aceite visual contra o artefato adaptado.
|
||||
3. **Kill switch.** ~0.25 dia: schema, action, route, painel, types; int do action/rota (unpublish some da lista, republish re-carimba `publishedAt` via hook, negado → mensagem de domínio, inexistente → não encontrado). Sem `revalidatePath`: páginas de `/campanha` são dinâmicas e o reel não tem `unstable_cache` (C193 dispensou `revalidateDocumentById`); o painel faz `router.refresh()` como o de cortes.
|
||||
4. **E2E + gates.** ~0.5 dia: `tests/e2e/campaignReel.e2e.spec.ts` espelhando `campaignSpeechCut.e2e.spec.ts:257-355` — lista/detalhe 200 com título e hrefs `?download=1`, GET do download com `Content-Disposition: attachment`, unpublish some da lista + detalhe em estado despublicado com "Republicar", republish volta, advisor/leader redirecionados e rota 400; manifest `scripts/lib/e2e-affected-manifest.mjs` (entry `${CAMPAIGN_APP}/comunicacao`: prefixes `src/components/campaign/reels`, `src/utilities/reels`, `src/lib/reel` e spec `campaignReel`; entry `src/lib/schemas` + `campaignReel`). **Curated intocado:** C194 não toca path `isHighRisk` (sem migração/collection/global/harness) e o PR roda `selected` com o spec novo mapeado; o full fica no verify do deploy. Changelog `docs/changelog/2026-09-18-c194.md`; `pnpm gate:fast`; e2e local (`pnpm test:e2e:affected` + spec novo); `pnpm push`; PR `Closes #1153`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Upload/edição/substituição de mídia ou retry na UI (C195 é o ingest; trocar = substituir via admin).
|
||||
- Busca, filtros, sort toggle, "baixar tudo" em zip; thumbnails/duration derivados (não há campo).
|
||||
- Cache (`unstable_cache`) do reel ou revalidação por hook — C193 dispensou com trigger de revisitação.
|
||||
- Tocar a rota de mídia C193, `Media.read`, `reelMedia`, `Consent` ou migração.
|
||||
- Melhorar chrome/nav além dos 3 pontos; qualquer estrutura visual sem o `designer`.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **C184 (#1106) no mesmo diff de nav/teste.** Não mergeado (docs-only); se mergear antes, rebase — a colisão é só o pin dos sub-itens.
|
||||
- **Rename do gate com superfície ampla.** Mecânico (6 arquivos, só strings de tipo/condição/comentário) + unit novo cobrindo a decisão; manifest da vertical acorda os specs de comunicação.
|
||||
- **Lista vazar não publicado ou detalhe prometer arquivo morto.** Where fixo no loader + int da matriz; VM só expõe URL de artefato quando `status === 'published' && disponível`; a rota C193 já responde 404.
|
||||
- **Kill switch mentir por cache.** Sem cache no caminho; resposta da rota de mídia é `private, no-store`; `router.refresh()` do painel.
|
||||
- **E2E novo não ser selecionado no PR.** Prefixos mapeados + nome pinado por `tests/unit/e2eAffectedManifest.unit.spec.ts:24-32`; execução local explícita antes do push.
|
||||
- **Copy errada no negado.** Literal do domínio ("biblioteca de reels"), não o de acervo de falas.
|
||||
|
||||
## Hard-stops (aprovação humana explícita)
|
||||
|
||||
1. **Schema/migração:** este item não toca o modelo; se algo exigir campo novo, parar e reabrir (C193 fechou).
|
||||
2. **Semântica do arquivo (C193 B):** `draft`/`unpublished` → 404 no serving; se o produto quiser link direto do despublicado, reabrir — não mudar a rota.
|
||||
3. **Design:** só portar o artefato adaptado; mudança de estrutura visual sem o `designer` é defeito (fail-closed).
|
||||
4. **Entrega:** `pnpm push` canônico e PR `Closes #1153`; sem push direto.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto: lista só publicados; detalhe 9:16 + downloads condicionais (primário sem áudio, com áudio, mp3, `.srt`, transcrição); despublicar some e não apaga, republicar reativa; só os 3 papéis (fail-closed); sem publicação em rede; site público/cards idênticos.
|
||||
- [ ] Invariantes AGENTS/engineering-standards: `overrideAccess: false` + `user`; single-collection write sem transação (justificado); sem `revalidatePath` (justificado); sem `Consent`/migração; `Media.read` e rota C193 intocados; "edit the owner, don't twin" (chrome/nav/actor/copyFeedback/paths estendidos); identificadores em inglês, copy pt-BR; sem top-level novo em `utilities/`.
|
||||
- [ ] Testes: unit (VMs, URL, chrome, nav, gate), int (loader + kill switch + despublicado), e2e (`campaignReel`) listados verdes; manifest atualizado; `pnpm gate:fast` e `pnpm push` limpos.
|
||||
|
||||
## Explicitamente fora (skips, descartes e defers deste triage)
|
||||
|
||||
- **Defer (fixtures int):** `tests/int/reelLibrary.int.spec.ts` duplica `createMedia`/`createReel` de `tests/int/reel.int.spec.ts` (2 call sites, shapes distintos — C193 devolve `{reel, video}`+captions, C194 devolve `Reel`+transcript). Gatilho: 3ª suíte/call site de fixtures de reel.
|
||||
- **Defer (prewarm e2e):** as rotas novas de reels ficam fora da lista de prewarm do `tests/e2e/setup.e2e.spec.ts` (as irmãs do acervo/cortes também estão; CI em prod mode compila). Gatilho: flake recorrente de cold-compile em dev nas rotas de reels.
|
||||
- **Defer (copy do vazio):** o artefato `docs/plans/reels-biblioteca-ui-design.html` ainda mostra o jargão "Sem CTA…" que a entrega trocou por copy de usuário; a crítica final do `designer` decide a copy canônica do artefato.
|
||||
- **Descarte:** `ReelPlayer`/`ReelDownloadPanel` recebendo o VM inteiro (precedente `SpeechCutPlayer`); `src/lib/reel.ts` misturando vocabulário e VMs (precedente `speechCut.ts`).
|
||||
|
||||
## Self-score de decision-quality
|
||||
|
||||
**4.5/5.** (1) As quatro decisões caras têm alternativas honestas com rejeição ancorada em file:line; (2) cabe no appetite herdado (~1.75 dia, sem migração, reuso máximo de shells/precedente); (3) rabbit holes nomeados; (4) depth check: loader do dono `utilities/reels/`, action espelhando `speech.ts`, wrapper `campaignJsonMutationRoute`, `copyFeedback` como dono, chrome/nav/paths/actor estendidos; (5) o aceite de produto permanece intacto — inclusive a decisão B do C193 e o kill switch reversível. Perde 0,5 porque o port depende do artefato do designer já adaptado (copy do despublicado, sem duração) — registrado como dependência/gate, não como lacuna.
|
||||
|
|
@ -288,7 +288,7 @@
|
|||
</div>
|
||||
<div class="p-3">
|
||||
<h4 class="text-sm font-medium leading-5">Como criar seu card de apoio</h4>
|
||||
<p class="mt-1.5 text-xs text-muted-foreground">Cards · 00:28</p>
|
||||
<p class="mt-1.5 text-xs text-muted-foreground">Cards de apoio</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Publicado hoje</p>
|
||||
<a
|
||||
href="#detalhe-desktop"
|
||||
|
|
@ -314,7 +314,7 @@
|
|||
<h4 class="text-sm font-medium leading-5">
|
||||
Moldura para foto: passo a passo
|
||||
</h4>
|
||||
<p class="mt-1.5 text-xs text-muted-foreground">Cards · 00:34</p>
|
||||
<p class="mt-1.5 text-xs text-muted-foreground">Cards de apoio</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Publicado em —</p>
|
||||
<a
|
||||
href="#"
|
||||
|
|
@ -338,7 +338,7 @@
|
|||
</div>
|
||||
<div class="p-3">
|
||||
<h4 class="text-sm font-medium leading-5">Baixe seu card no celular</h4>
|
||||
<p class="mt-1.5 text-xs text-muted-foreground">Cards · 00:21</p>
|
||||
<p class="mt-1.5 text-xs text-muted-foreground">Cards de apoio</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Publicado em —</p>
|
||||
<a
|
||||
href="#"
|
||||
|
|
@ -362,7 +362,7 @@
|
|||
</div>
|
||||
<div class="p-3">
|
||||
<h4 class="text-sm font-medium leading-5">Título do reel</h4>
|
||||
<p class="mt-1.5 text-xs text-muted-foreground">Funcionalidade · 00:30</p>
|
||||
<p class="mt-1.5 text-xs text-muted-foreground">Funcionalidade</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Publicado em —</p>
|
||||
<a
|
||||
href="#"
|
||||
|
|
@ -431,7 +431,9 @@
|
|||
<span
|
||||
class="inline-flex h-5 items-center rounded-full bg-primary px-2 text-xs text-white"
|
||||
>Publicado</span
|
||||
><span class="text-xs text-muted-foreground">Cards · 00:28 · publicado hoje</span>
|
||||
><span class="text-xs text-muted-foreground"
|
||||
>Cards de apoio · publicado hoje</span
|
||||
>
|
||||
</div>
|
||||
<h3 class="mt-2 text-lg font-medium">Como criar seu card de apoio</h3>
|
||||
</div>
|
||||
|
|
@ -458,7 +460,9 @@
|
|||
<div class="h-1 flex-1 rounded-full bg-white/35">
|
||||
<div class="h-1 w-0 rounded-full bg-primary"></div>
|
||||
</div>
|
||||
<span class="text-[11px]">00:28</span>
|
||||
<span class="text-[11px]" aria-label="Tempo total carregado pelo vídeo"
|
||||
>—:—</span
|
||||
>
|
||||
</div>
|
||||
<span class="absolute left-3 top-3 rounded bg-black/55 px-2 py-1 text-[10px]"
|
||||
>NEEDS ASSET · MP4</span
|
||||
|
|
@ -606,10 +610,6 @@
|
|||
<dt class="text-xs text-muted-foreground">Funcionalidade</dt>
|
||||
<dd class="mt-0.5 font-medium">Cards de apoio</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">Duração</dt>
|
||||
<dd class="mt-0.5">00:28</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">Publicado em</dt>
|
||||
<dd class="mt-0.5">Hoje</dd>
|
||||
|
|
@ -627,8 +627,8 @@
|
|||
>
|
||||
</div>
|
||||
<p class="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
Despublicar tira este reel da lista, mas preserva o arquivo e o acesso interno
|
||||
por link.
|
||||
Despublicar tira este reel da lista e bloqueia o player e os downloads. Os
|
||||
arquivos voltam a ficar disponíveis quando o reel for republicado.
|
||||
</p>
|
||||
<button
|
||||
class="mt-3 min-h-11 w-full rounded-lg border border-border bg-background px-3 text-sm font-medium"
|
||||
|
|
@ -690,7 +690,7 @@
|
|||
</div>
|
||||
<div class="p-2.5">
|
||||
<h4 class="text-xs font-medium leading-4">Como criar seu card de apoio</h4>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">Cards · 00:28</p>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">Cards de apoio</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="overflow-hidden rounded-xl border border-border">
|
||||
|
|
@ -708,21 +708,21 @@
|
|||
</div>
|
||||
<div class="p-2.5">
|
||||
<h4 class="text-xs font-medium leading-4">Moldura para foto</h4>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">Cards · 00:34</p>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">Cards de apoio</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="overflow-hidden rounded-xl border border-border">
|
||||
<div class="cover-placeholder aspect-[9/16] grayscale"></div>
|
||||
<div class="p-2.5">
|
||||
<h4 class="text-xs font-medium leading-4">Baixe seu card</h4>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">Cards · 00:21</p>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">Cards de apoio</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="overflow-hidden rounded-xl border border-border">
|
||||
<div class="cover-placeholder aspect-[9/16] opacity-80"></div>
|
||||
<div class="p-2.5">
|
||||
<h4 class="text-xs font-medium leading-4">Título do reel</h4>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">Funcionalidade · 00:30</p>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">Funcionalidade</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
|
@ -763,7 +763,7 @@
|
|||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="rounded-full bg-primary px-2 py-0.5 text-xs text-white"
|
||||
>Publicado</span
|
||||
><span class="text-xs text-muted-foreground">Cards · 00:28</span>
|
||||
><span class="text-xs text-muted-foreground">Cards de apoio</span>
|
||||
</div>
|
||||
<h3 class="mt-2 text-lg font-medium">Como criar seu card de apoio</h3>
|
||||
<div
|
||||
|
|
@ -841,7 +841,8 @@
|
|||
>
|
||||
</div>
|
||||
<p class="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
Despublicar tira este reel da lista, sem apagar os arquivos.
|
||||
Despublicar tira este reel da lista e bloqueia o player e os downloads. Os
|
||||
arquivos voltam ao republicar.
|
||||
</p>
|
||||
<button
|
||||
class="mt-3 min-h-11 w-full rounded-lg border border-border bg-background px-3 text-sm font-medium"
|
||||
|
|
@ -877,7 +878,7 @@
|
|||
Sem becos sem saída
|
||||
</h2>
|
||||
</div>
|
||||
<div class="grid gap-5 lg:grid-cols-3">
|
||||
<div class="grid items-start gap-5 lg:grid-cols-3">
|
||||
<article
|
||||
class="overflow-hidden rounded-xl border border-border bg-background frame-shadow"
|
||||
>
|
||||
|
|
@ -920,7 +921,7 @@
|
|||
<div class="p-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded-full bg-primary px-2 py-0.5 text-xs text-white">Publicado</span
|
||||
><span class="text-xs text-muted-foreground">Cards · 00:28</span>
|
||||
><span class="text-xs text-muted-foreground">Cards de apoio</span>
|
||||
</div>
|
||||
<h3 class="mt-2 text-base font-medium">Como criar seu card de apoio</h3>
|
||||
<a
|
||||
|
|
@ -963,20 +964,20 @@
|
|||
<div class="flex items-center gap-2">
|
||||
<span class="rounded-full border border-border px-2 py-0.5 text-xs"
|
||||
>Despublicado</span
|
||||
><span class="text-xs text-muted-foreground">Acesso interno preservado</span>
|
||||
><span class="text-xs text-muted-foreground">Cards de apoio</span>
|
||||
</div>
|
||||
<h3 class="mt-3 text-base font-medium">Como criar seu card de apoio</h3>
|
||||
<div class="mt-4 rounded-lg bg-muted p-3">
|
||||
<p class="text-sm font-medium">Fora da lista</p>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
Este reel não aparece na biblioteca, mas os arquivos continuam guardados e
|
||||
acessíveis por este link interno.
|
||||
Este reel não aparece na biblioteca. O registro e os arquivos continuam guardados,
|
||||
mas nenhuma mídia é servida enquanto estiver despublicado.
|
||||
</p>
|
||||
</div>
|
||||
<section class="mt-4 rounded-xl border border-border p-4">
|
||||
<h4 class="text-xs font-medium text-muted-foreground">Publicação na biblioteca</h4>
|
||||
<p class="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
Republicar devolve o reel à lista. Nenhum arquivo será recriado.
|
||||
Republicar devolve o reel à lista e libera novamente o player e os downloads.
|
||||
</p>
|
||||
<button
|
||||
class="mt-3 min-h-11 w-full rounded-lg bg-primary px-3 text-sm font-semibold text-white"
|
||||
|
|
@ -984,6 +985,64 @@
|
|||
Republicar reel
|
||||
</button>
|
||||
</section>
|
||||
<section class="mt-4 overflow-hidden rounded-xl border border-border">
|
||||
<div class="flex min-h-14 items-center gap-3 bg-muted/35 px-3">
|
||||
<div
|
||||
class="grid size-8 shrink-0 place-items-center rounded-full bg-muted text-muted-foreground"
|
||||
>
|
||||
<svg
|
||||
class="size-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<path d="m10 9 5 3-5 3Z" />
|
||||
<path d="m3 3 18 18" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium">Player indisponível</p>
|
||||
<p class="text-xs text-muted-foreground">Disponível após republicar.</p>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground">Bloqueado</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex min-h-14 items-center gap-3 border-t border-border bg-muted/35 px-3"
|
||||
>
|
||||
<div
|
||||
class="grid size-8 shrink-0 place-items-center rounded-full bg-muted text-muted-foreground"
|
||||
>
|
||||
<svg
|
||||
class="size-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M12 3v12" />
|
||||
<path d="m7 10 5 5 5-5" />
|
||||
<path d="M5 21h14" />
|
||||
<path d="m3 3 18 18" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium">Downloads indisponíveis</p>
|
||||
<p class="text-xs text-muted-foreground">Nenhum link de arquivo é exibido.</p>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground">Bloqueados</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="mt-4 rounded-xl border border-border p-4">
|
||||
<h4 class="text-xs font-medium text-muted-foreground">Transcrição / roteiro</h4>
|
||||
<p class="mt-2 text-sm leading-6">
|
||||
<span class="font-medium">[Abertura]</span> Quer mostrar que está com Solla?
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
O texto permanece disponível porque não é um arquivo de mídia.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
|
@ -1000,8 +1059,10 @@
|
|||
placeholders explícitos; nenhum dado de terceiro foi usado.
|
||||
</p>
|
||||
<p class="mt-1">
|
||||
<strong>Em aberto no gate:</strong> quem opera o kill switch e se o link direto do
|
||||
despublicado permanece acessível. Esta proposta representa a recomendação A/A do plano.
|
||||
<strong>Contrato C193 incorporado:</strong> communicator, coordinator e candidate podem
|
||||
operar o kill switch. Despublicado preserva o registro, mas bloqueia player e downloads;
|
||||
os arquivos voltam ao republicar. O tempo total mostrado pelo player vem do próprio vídeo,
|
||||
não de um campo do catálogo.
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -262,9 +262,14 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
// Zod input schemas surface in the browser through form flows; the
|
||||
// public-site and newsletter specs exercise those flows end to end. C167
|
||||
// adds the cut schemas, whose HTTP contract is the cut spec; C168's library
|
||||
// mutations live in the same `speech` actions file.
|
||||
prefixes: ['src/lib/schemas', 'src/app/(campaign)/campanha/actions/speech.ts'],
|
||||
specs: ['frontend', 'campaignNewsletter', 'campaignSpeechCut'],
|
||||
// mutations live in the same `speech` actions file; C194 adds the reel
|
||||
// schemas and its kill-switch action.
|
||||
prefixes: [
|
||||
'src/lib/schemas',
|
||||
'src/app/(campaign)/campanha/actions/speech.ts',
|
||||
'src/app/(campaign)/campanha/actions/reels.ts',
|
||||
],
|
||||
specs: ['frontend', 'campaignNewsletter', 'campaignSpeechCut', 'campaignReel'],
|
||||
},
|
||||
{
|
||||
// Web Push client — the opt-in toast mounts on the campaign shell, so a
|
||||
|
|
@ -299,18 +304,22 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
},
|
||||
{
|
||||
// C154 — the communication vertical: the acervo search/filters/detail and
|
||||
// the `speechCatalog` role gate. `src/lib/speech*` carries the pure
|
||||
// the `communicationCatalog` role gate. `src/lib/speech*` carries the pure
|
||||
// search/highlight modules the RSC list renders with. C167 adds the cut
|
||||
// routes/player/dialog and the public cut page below.
|
||||
// routes/player/dialog and the public cut page below. C194 adds the reel
|
||||
// library (list/detail/downloads/kill switch) to the same vertical.
|
||||
prefixes: [
|
||||
`${CAMPAIGN_APP}/comunicacao`,
|
||||
'src/components/campaign/speech',
|
||||
'src/components/campaign/reels',
|
||||
'src/utilities/speech',
|
||||
'src/utilities/reels',
|
||||
'src/lib/speech',
|
||||
'src/lib/reel',
|
||||
// The role predicates drive the vertical gate and the assistant surfaces.
|
||||
'src/lib/campaignRoles',
|
||||
],
|
||||
specs: ['campaignSpeechAcervo', 'campaignSpeechCut'],
|
||||
specs: ['campaignSpeechAcervo', 'campaignSpeechCut', 'campaignReel'],
|
||||
},
|
||||
{
|
||||
// C167 — the unlisted public page of a cut: 200 with the stored file and
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export async function generateMetadata({ params }: SpeechDetailPageProps) {
|
|||
if (!speechId) return campaignPageMetadata({ title: 'Fala' })
|
||||
|
||||
const [user, payload] = await Promise.all([
|
||||
requireCampaignPageActor({ gate: 'speechCatalog' }),
|
||||
requireCampaignPageActor({ gate: 'communicationCatalog' }),
|
||||
getPayload({ config }),
|
||||
])
|
||||
|
||||
|
|
@ -49,7 +49,7 @@ export default async function SpeechDetailPage({ params, searchParams }: SpeechD
|
|||
const [{ id }, query, user, payload] = await Promise.all([
|
||||
params,
|
||||
searchParams,
|
||||
requireCampaignPageActor({ gate: 'speechCatalog' }),
|
||||
requireCampaignPageActor({ gate: 'communicationCatalog' }),
|
||||
getPayload({ config }),
|
||||
])
|
||||
const speechId = strictDecimalInteger(id)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export async function generateMetadata({ params }: SpeechCutDetailPageProps) {
|
|||
if (!cutId) return campaignPageMetadata({ title: 'Corte' })
|
||||
|
||||
const [user, payload] = await Promise.all([
|
||||
requireCampaignPageActor({ gate: 'speechCatalog' }),
|
||||
requireCampaignPageActor({ gate: 'communicationCatalog' }),
|
||||
getPayload({ config }),
|
||||
])
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ export async function generateMetadata({ params }: SpeechCutDetailPageProps) {
|
|||
export default async function SpeechCutDetailPage({ params }: SpeechCutDetailPageProps) {
|
||||
const [{ id }, user, payload] = await Promise.all([
|
||||
params,
|
||||
requireCampaignPageActor({ gate: 'speechCatalog' }),
|
||||
requireCampaignPageActor({ gate: 'communicationCatalog' }),
|
||||
getPayload({ config }),
|
||||
])
|
||||
const cutId = strictDecimalInteger(id)
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ type SpeechCutLibraryPageProps = {
|
|||
/** C168 — the cut library: what was already cut, newest first, searchable. */
|
||||
export default async function SpeechCutLibraryPage({ searchParams }: SpeechCutLibraryPageProps) {
|
||||
const [user, payload] = await Promise.all([
|
||||
requireCampaignPageActor({ gate: 'speechCatalog' }),
|
||||
requireCampaignPageActor({ gate: 'communicationCatalog' }),
|
||||
getPayload({ config }),
|
||||
])
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ type SpeechAcervoPageProps = {
|
|||
|
||||
export default async function SpeechAcervoPage({ searchParams }: SpeechAcervoPageProps) {
|
||||
const [user, payload] = await Promise.all([
|
||||
requireCampaignPageActor({ gate: 'speechCatalog' }),
|
||||
requireCampaignPageActor({ gate: 'communicationCatalog' }),
|
||||
getPayload({ config }),
|
||||
])
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,6 @@ import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
|||
* stable destination and future vertical items have a home.
|
||||
*/
|
||||
export default async function CommunicationHomePage() {
|
||||
await requireCampaignPageActor({ gate: 'speechCatalog' })
|
||||
await requireCampaignPageActor({ gate: 'communicationCatalog' })
|
||||
redirect(CAMPAIGN_COMMUNICATION_ACERVO)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
import Link from 'next/link'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/Empty'
|
||||
import { CAMPAIGN_COMMUNICATION_REELS } from '@/lib/campaignPaths'
|
||||
|
||||
export default function ReelNotFoundPage() {
|
||||
return (
|
||||
<Empty className="min-h-72 border">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Reel não encontrado</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
O reel pode ter sido removido ou você não tem acesso a ele.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href={CAMPAIGN_COMMUNICATION_REELS}>Voltar à biblioteca de reels</Link>
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
import config from '@payload-config'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { ReelDownloadPanel } from '@/components/campaign/reels/ReelDownloadPanel'
|
||||
import { ReelPlayer } from '@/components/campaign/reels/ReelPlayer'
|
||||
import { ReelPublicationPanel } from '@/components/campaign/reels/ReelPublicationPanel'
|
||||
import { ReelStatusBadge } from '@/components/campaign/reels/ReelStatusBadge'
|
||||
import { ReelTranscriptCard } from '@/components/campaign/reels/ReelTranscriptCard'
|
||||
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
|
||||
import { CAMPAIGN_COMMUNICATION_REELS } from '@/lib/campaignPaths'
|
||||
import { strictDecimalInteger } from '@/utilities/campaignListUrl'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { loadReelDetailPageData, ReelNotFoundError } from '@/utilities/reels/reelPageData'
|
||||
|
||||
type ReelDetailPageProps = {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: ReelDetailPageProps) {
|
||||
const { id } = await params
|
||||
const reelId = strictDecimalInteger(id)
|
||||
if (!reelId) return campaignPageMetadata({ title: 'Reel' })
|
||||
|
||||
const [user, payload] = await Promise.all([
|
||||
requireCampaignPageActor({ gate: 'communicationCatalog' }),
|
||||
getPayload({ config }),
|
||||
])
|
||||
|
||||
try {
|
||||
const reel = await loadReelDetailPageData(payload, user, reelId)
|
||||
return campaignPageMetadata({ title: reel.title })
|
||||
} catch {
|
||||
return campaignPageMetadata({ title: 'Reel' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* C194 — one reel: the 9:16 player, every artifact the production delivered
|
||||
* and the kill switch. Unpublished reels stay reachable here (so the switch
|
||||
* can be reversed) but the player and the downloads are withheld — the C193
|
||||
* media route serves only `published`.
|
||||
*/
|
||||
export default async function ReelDetailPage({ params }: ReelDetailPageProps) {
|
||||
const [{ id }, user, payload] = await Promise.all([
|
||||
params,
|
||||
requireCampaignPageActor({ gate: 'communicationCatalog' }),
|
||||
getPayload({ config }),
|
||||
])
|
||||
const reelId = strictDecimalInteger(id)
|
||||
if (!reelId) notFound()
|
||||
|
||||
const reel = await loadReelDetailPageData(payload, user, reelId).catch((error) => {
|
||||
if (error instanceof ReelNotFoundError) notFound()
|
||||
throw error
|
||||
})
|
||||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<SetCampaignPageChrome chrome={{ title: reel.title }} />
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button asChild variant="ghost" className="-ml-2 min-h-10 self-start">
|
||||
<Link href={CAMPAIGN_COMMUNICATION_REELS}>
|
||||
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Voltar aos reels
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ReelStatusBadge status={reel.status} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{reel.featureLabel}
|
||||
{reel.publishedAtLabel ? <> · publicado em {reel.publishedAtLabel}</> : null}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="mt-2 text-lg font-medium">{reel.title}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[360px_minmax(0,1fr)_280px]">
|
||||
<div>
|
||||
<ReelPlayer reel={reel} />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
{reel.canServeMedia ? null : (
|
||||
<div className="mb-4 rounded-lg bg-muted p-3">
|
||||
<p className="text-sm font-medium">Fora da lista</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
Este reel não aparece na biblioteca. O registro e os arquivos continuam guardados,
|
||||
mas nenhuma mídia é servida enquanto ele não estiver publicado.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReelDownloadPanel reel={reel} />
|
||||
|
||||
{reel.transcript ? (
|
||||
<div className="mt-4">
|
||||
<ReelTranscriptCard transcript={reel.transcript} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<section className="rounded-xl border border-border p-4">
|
||||
<h2 className="text-xs font-medium text-muted-foreground">Sobre o reel</h2>
|
||||
<dl className="mt-3 space-y-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Funcionalidade</dt>
|
||||
<dd className="mt-0.5 font-medium">{reel.featureLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Publicado em</dt>
|
||||
<dd className="mt-0.5">{reel.publishedAtLabel ?? 'Ainda não publicado'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<ReelPublicationPanel reelId={reel.id} status={reel.status} />
|
||||
|
||||
<p className="rounded-lg bg-muted px-3 py-2 text-xs leading-5 text-muted-foreground">
|
||||
Nada é publicado no Instagram por esta tela.
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
|
||||
import { setReelPublishedForActor } from '@/app/(campaign)/campanha/actions/reels'
|
||||
import {
|
||||
REEL_FORBIDDEN_MESSAGE,
|
||||
REEL_GENERIC_ERROR_MESSAGE,
|
||||
REEL_NOT_FOUND_MESSAGE,
|
||||
reelPublicationRequestSchema,
|
||||
} from '@/lib/schemas/reel'
|
||||
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
|
||||
|
||||
import type { ReelPublicationResponse } from '../types'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const POST = campaignJsonMutationRoute(
|
||||
{
|
||||
bodySchema: reelPublicationRequestSchema,
|
||||
safeMessages: [REEL_FORBIDDEN_MESSAGE, REEL_NOT_FOUND_MESSAGE],
|
||||
genericMessage: REEL_GENERIC_ERROR_MESSAGE,
|
||||
},
|
||||
async (body) => {
|
||||
const reel = await setReelPublishedForActor(body)
|
||||
|
||||
return NextResponse.json<ReelPublicationResponse>({ status: 'success', reel })
|
||||
},
|
||||
)
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import type { ReelStatus } from '@/lib/reel'
|
||||
|
||||
/** C194 — the JSON envelope of the kill-switch route. */
|
||||
export type ReelPublicationResponse =
|
||||
| {
|
||||
status: 'success'
|
||||
reel: {
|
||||
id: number
|
||||
status: ReelStatus
|
||||
}
|
||||
}
|
||||
| { status: 'error'; message: string }
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
/** C194 — grid-shaped skeleton for the reel library and its detail route. */
|
||||
export default function ReelLibraryLoading() {
|
||||
return (
|
||||
<CampaignPageShell aria-label="Carregando biblioteca de reels" aria-busy="true">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-5 w-80 max-w-full" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4 lg:gap-4">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<Skeleton key={index} className="aspect-[9/16] w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
77
src/app/(campaign)/campanha/(app)/comunicacao/reels/page.tsx
Normal file
77
src/app/(campaign)/campanha/(app)/comunicacao/reels/page.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import config from '@payload-config'
|
||||
import { ClapperboardIcon } from 'lucide-react'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { ReelLibraryList } from '@/components/campaign/reels/ReelLibraryList'
|
||||
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
|
||||
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
|
||||
import {
|
||||
CampaignListPendingBoundary,
|
||||
CampaignListResults,
|
||||
} from '@/components/campaign/shared/CampaignListPending'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { buildReelListHref } from '@/utilities/reels/reelListUrl'
|
||||
import { loadReelLibraryPageData } from '@/utilities/reels/reelPageData'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('reels')
|
||||
|
||||
type ReelLibraryPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
||||
/** C194 — the reel library: published tutorials, newest publication first. */
|
||||
export default async function ReelLibraryPage({ searchParams }: ReelLibraryPageProps) {
|
||||
const [user, payload] = await Promise.all([
|
||||
requireCampaignPageActor({ gate: 'communicationCatalog' }),
|
||||
getPayload({ config }),
|
||||
])
|
||||
|
||||
const data = await loadReelLibraryPageData(payload, user, searchParams)
|
||||
if (data.redirectHref) redirect(data.redirectHref)
|
||||
|
||||
return (
|
||||
<CampaignPageShell aria-label="Biblioteca de reels">
|
||||
<CampaignListPendingBoundary>
|
||||
<CampaignListResults>
|
||||
{data.rows.length > 0 ? (
|
||||
<>
|
||||
<div className="mb-6 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Reels publicados</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Abra um reel para assistir e baixar os arquivos.
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{data.totalDocs} {data.totalDocs === 1 ? 'reel' : 'reels'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ReelLibraryList rows={data.rows} />
|
||||
</>
|
||||
) : (
|
||||
<CampaignListEmptyState
|
||||
icon={ClapperboardIcon}
|
||||
title="Nenhum reel produzido ainda"
|
||||
description="A produção dos reels acontece fora do Teqo. Quando um reel for aprovado e publicado na biblioteca, ele aparece aqui para a assessoria."
|
||||
/>
|
||||
)}
|
||||
|
||||
{data.rows.length > 0 ? (
|
||||
<CampaignListFooter
|
||||
totalDocs={data.totalDocs}
|
||||
singular="reel publicado"
|
||||
plural="reels publicados"
|
||||
page={data.state.page}
|
||||
totalPages={data.totalPages}
|
||||
hrefForPage={(page) => buildReelListHref(data.state, page)}
|
||||
/>
|
||||
) : null}
|
||||
</CampaignListResults>
|
||||
</CampaignListPendingBoundary>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
58
src/app/(campaign)/campanha/actions/reels.ts
Normal file
58
src/app/(campaign)/campanha/actions/reels.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
'use server'
|
||||
|
||||
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
|
||||
import type { ReelStatus } from '@/lib/reel'
|
||||
import {
|
||||
REEL_FORBIDDEN_MESSAGE,
|
||||
REEL_NOT_FOUND_MESSAGE,
|
||||
reelPublicationRequestSchema,
|
||||
type ReelPublicationRequest,
|
||||
} from '@/lib/schemas/reel'
|
||||
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
|
||||
|
||||
export type ReelPublicationResult = {
|
||||
id: number
|
||||
status: ReelStatus
|
||||
}
|
||||
|
||||
/**
|
||||
* C194 — the kill switch of the reel library. `unpublished` takes the reel off
|
||||
* the list and withholds its files (the C193 media route serves only
|
||||
* `published`); `published` puts the same row back. The collection's
|
||||
* `beforeChange` stamps `publishedAt` on every transition into `published`, so
|
||||
* the order of the list always reflects the last publication.
|
||||
*
|
||||
* Single-collection, single-document write: no transaction (same precedent as
|
||||
* `setSpeechCutPublishedForActor`). The fresh role gate is repeated here so a
|
||||
* denied actor gets the domain message instead of a silent collection denial.
|
||||
*/
|
||||
export const setReelPublishedForActor = async (
|
||||
input: ReelPublicationRequest,
|
||||
): Promise<ReelPublicationResult> => {
|
||||
const parsed = reelPublicationRequestSchema.parse(input)
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
|
||||
if (!canReadCommunicationCatalog(actor.role)) throw new Error(REEL_FORBIDDEN_MESSAGE)
|
||||
|
||||
const current = await payload.find({
|
||||
collection: 'reel',
|
||||
where: { id: { equals: parsed.reelId } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
if (!current.docs[0]) throw new Error(REEL_NOT_FOUND_MESSAGE)
|
||||
|
||||
const reel = await payload.update({
|
||||
collection: 'reel',
|
||||
id: parsed.reelId,
|
||||
data: { status: parsed.published ? 'published' : 'unpublished' },
|
||||
depth: 0,
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
return { id: reel.id, status: reel.status }
|
||||
}
|
||||
|
|
@ -262,7 +262,7 @@ export const getSpeechCutStatusForActor = async (input: {
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C168 — the cut library: edit the cut's own text and toggle the public link.
|
||||
// Same fresh `speechCatalog` gate as the C167 mutations; the collection access
|
||||
// Same fresh `communicationCatalog` gate as the C167 mutations; the collection access
|
||||
// (canReadSpeech) and the whitelisted zod payload are the field boundary.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
109
src/components/campaign/reels/ReelDownloadPanel.tsx
Normal file
109
src/components/campaign/reels/ReelDownloadPanel.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import {
|
||||
CaptionsIcon,
|
||||
DownloadIcon,
|
||||
FileTextIcon,
|
||||
MusicIcon,
|
||||
SquarePlayIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import type { ReelDetailViewModel, ReelDownloadItemViewModel, ReelDownloadKind } from '@/lib/reel'
|
||||
|
||||
const iconByKind: Record<ReelDownloadKind, LucideIcon> = {
|
||||
video: DownloadIcon,
|
||||
'video-audio': SquarePlayIcon,
|
||||
narration: MusicIcon,
|
||||
captions: CaptionsIcon,
|
||||
}
|
||||
|
||||
/**
|
||||
* C194 — the downloads of one reel. The silent video is the primary CTA; the
|
||||
* optional artifacts are grouped below with the honest reason when they were
|
||||
* never produced. While the reel is unpublished every row states that the
|
||||
* files return on republication (no dead links — C193 decision B).
|
||||
*/
|
||||
export const ReelDownloadPanel = ({ reel }: { reel: ReelDetailViewModel }) => {
|
||||
const primary = reel.downloads.find((item) => item.primary)
|
||||
const secondary = reel.downloads.filter((item) => !item.primary)
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-border p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">Downloads</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
Arquivos deste reel para finalizar e publicar fora do Teqo.
|
||||
</p>
|
||||
</div>
|
||||
<DownloadIcon className="size-5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
{primary ? <PrimaryDownload item={primary} /> : null}
|
||||
|
||||
<div className="mt-3 divide-y divide-border rounded-lg border border-border">
|
||||
{secondary.map((item) => (
|
||||
<DownloadRow key={item.kind} item={item} />
|
||||
))}
|
||||
{reel.transcript ? (
|
||||
<a href="#transcricao" className="flex min-h-12 items-center gap-3 px-3">
|
||||
<FileTextIcon className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">Transcrição / roteiro</p>
|
||||
<p className="text-xs text-muted-foreground">Texto simples</p>
|
||||
</div>
|
||||
<span className="text-xs underline underline-offset-4">Ler</span>
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const PrimaryDownload = ({ item }: { item: ReelDownloadItemViewModel }) => {
|
||||
if (!item.href) {
|
||||
return (
|
||||
<div className="mt-4 flex min-h-12 w-full items-center justify-between gap-3 rounded-lg border border-dashed bg-muted/35 px-4 text-sm">
|
||||
<span className="font-medium">{item.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.unavailableLabel}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={item.href}
|
||||
className="mt-4 flex min-h-12 w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground"
|
||||
>
|
||||
<DownloadIcon className="size-4" aria-hidden="true" />
|
||||
Baixar {item.label.toLowerCase()}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
const DownloadRow = ({ item }: { item: ReelDownloadItemViewModel }) => {
|
||||
const Icon = iconByKind[item.kind]
|
||||
|
||||
if (!item.href) {
|
||||
return (
|
||||
<div className="flex min-h-12 items-center gap-3 bg-muted/35 px-3 opacity-65">
|
||||
<Icon className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{item.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{item.unavailableLabel}</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">Indisponível</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={item.href} className="flex min-h-12 items-center gap-3 px-3">
|
||||
<Icon className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{item.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{item.description}</p>
|
||||
</div>
|
||||
<span className="text-xs underline underline-offset-4">Baixar</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
43
src/components/campaign/reels/ReelLibraryCard.tsx
Normal file
43
src/components/campaign/reels/ReelLibraryCard.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import Link from 'next/link'
|
||||
|
||||
import type { ReelLibraryItemViewModel } from '@/lib/reel'
|
||||
|
||||
/**
|
||||
* C194 — one card of the reel library: the 9:16 cover leads, the title and the
|
||||
* feature follow, and the whole card is the link to the detail (there is no
|
||||
* second action on the card, so the "Abrir reel" affordance is visual).
|
||||
*/
|
||||
export const ReelLibraryCard = ({ reel }: { reel: ReelLibraryItemViewModel }) => (
|
||||
<article className="group overflow-hidden rounded-xl border border-border bg-card">
|
||||
<Link href={reel.detailHref} className="block focus-visible:outline-none">
|
||||
<div className="relative aspect-[9/16] overflow-hidden bg-muted">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- the cover is served by the authenticated campaign route; the Next optimizer has no session cookie. */}
|
||||
<img
|
||||
src={reel.coverUrl}
|
||||
alt={reel.coverAlt}
|
||||
loading="lazy"
|
||||
className="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
<span className="absolute left-3 top-3 rounded-full bg-white/95 px-2 py-1 text-[11px] font-medium text-foreground">
|
||||
{reel.statusLabel}
|
||||
</span>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-3 bottom-3 rounded-lg bg-black/65 p-3 text-white"
|
||||
>
|
||||
<p className="text-sm leading-4 font-semibold">{reel.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<h3 className="text-sm leading-5 font-medium">{reel.title}</h3>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">{reel.featureLabel}</p>
|
||||
{reel.publishedAtLabel ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Publicado em {reel.publishedAtLabel}</p>
|
||||
) : null}
|
||||
<span className="mt-3 inline-flex h-8 items-center text-xs font-medium underline underline-offset-4 group-hover:text-primary">
|
||||
Abrir reel
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</article>
|
||||
)
|
||||
11
src/components/campaign/reels/ReelLibraryList.tsx
Normal file
11
src/components/campaign/reels/ReelLibraryList.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { ReelLibraryCard } from '@/components/campaign/reels/ReelLibraryCard'
|
||||
import type { ReelLibraryItemViewModel } from '@/lib/reel'
|
||||
|
||||
/** C194 — the cover grid: two columns on the phone, four on the desktop. */
|
||||
export const ReelLibraryList = ({ rows }: { rows: readonly ReelLibraryItemViewModel[] }) => (
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4 lg:gap-4">
|
||||
{rows.map((reel) => (
|
||||
<ReelLibraryCard key={reel.id} reel={reel} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
29
src/components/campaign/reels/ReelPlayer.tsx
Normal file
29
src/components/campaign/reels/ReelPlayer.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { ReelDetailViewModel } from '@/lib/reel'
|
||||
|
||||
/**
|
||||
* C194 — the 9:16 player. The source prefers the rough cut with narration
|
||||
* (`video-audio`) and falls back to the silent primary video; while the reel is
|
||||
* unpublished there is no source at all and the player states why (C193 kill
|
||||
* switch withholds every file).
|
||||
*/
|
||||
export const ReelPlayer = ({ reel }: { reel: ReelDetailViewModel }) => {
|
||||
if (!reel.videoSourceUrl) {
|
||||
return (
|
||||
<div className="mx-auto flex aspect-[9/16] w-full max-w-[330px] flex-col items-center justify-center gap-1 rounded-xl border border-dashed bg-muted/40 p-4 text-center">
|
||||
<span className="text-sm font-medium">Player indisponível</span>
|
||||
<p className="text-xs text-muted-foreground">{reel.mediaBlockedLabel}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
poster={reel.videoPosterUrl}
|
||||
src={reel.videoSourceUrl}
|
||||
className="mx-auto aspect-[9/16] w-full max-w-[330px] rounded-xl border bg-black"
|
||||
/>
|
||||
)
|
||||
}
|
||||
131
src/components/campaign/reels/ReelPublicationPanel.tsx
Normal file
131
src/components/campaign/reels/ReelPublicationPanel.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
'use client'
|
||||
|
||||
import { ReelStatusBadge } from '@/components/campaign/reels/ReelStatusBadge'
|
||||
import { usePublicationToggle } from '@/components/campaign/shared/usePublicationToggle'
|
||||
import { Alert, AlertDescription } from '@/components/ui/Alert'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/AlertDialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { campaignReelPublicationHref } from '@/lib/campaignPaths'
|
||||
import type { ReelStatus } from '@/lib/reel'
|
||||
|
||||
type ReelPublicationPanelProps = {
|
||||
reelId: number
|
||||
status: ReelStatus
|
||||
}
|
||||
|
||||
const UNPUBLISH_WARNING =
|
||||
'Despublicar tira este reel da lista e bloqueia o player e os downloads. Os arquivos voltam a ficar disponíveis quando o reel for republicado.'
|
||||
|
||||
const PUBLISH_WARNING =
|
||||
'Republicar devolve o reel à lista e libera novamente o player e os downloads.'
|
||||
|
||||
/**
|
||||
* C194 — the kill switch on the reel detail. Publishing puts the same row back
|
||||
* on the list and reopens the files; unpublishing takes it off the list and
|
||||
* withholds every file (C193 serves only `published`). Confirmation only on
|
||||
* the destructive direction.
|
||||
*/
|
||||
export const ReelPublicationPanel = ({ reelId, status }: ReelPublicationPanelProps) => {
|
||||
const { submitting, error, setPublished } = usePublicationToggle({
|
||||
href: campaignReelPublicationHref(reelId),
|
||||
buildBody: (published) => ({ reelId, published }),
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border p-4">
|
||||
<h2 className="text-xs tracking-wide text-muted-foreground uppercase">
|
||||
Publicação na biblioteca
|
||||
</h2>
|
||||
|
||||
<div className="mt-2">
|
||||
<ReelStatusBadge status={status} />
|
||||
</div>
|
||||
|
||||
{status === 'published' ? (
|
||||
<>
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">{UNPUBLISH_WARNING}</p>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-3 min-h-11 w-full"
|
||||
disabled={submitting}
|
||||
>
|
||||
Despublicar reel
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Despublicar este reel?</AlertDialogTitle>
|
||||
<AlertDialogDescription>{UNPUBLISH_WARNING}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel type="button" className="min-h-11" disabled={submitting}>
|
||||
Cancelar
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11"
|
||||
disabled={submitting}
|
||||
onClick={() => void setPublished(false)}
|
||||
>
|
||||
{submitting ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
|
||||
Despublicar
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{status === 'unpublished' ? (
|
||||
<>
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">{PUBLISH_WARNING}</p>
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-3 min-h-11 w-full"
|
||||
disabled={submitting}
|
||||
onClick={() => void setPublished(true)}
|
||||
>
|
||||
{submitting ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
|
||||
Republicar reel
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{status === 'draft' ? (
|
||||
<>
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
Este reel ainda não foi publicado na biblioteca.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-3 min-h-11 w-full"
|
||||
disabled={submitting}
|
||||
onClick={() => void setPublished(true)}
|
||||
>
|
||||
{submitting ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
|
||||
Publicar reel
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive" className="mt-3 py-2">
|
||||
<AlertDescription className="text-xs">{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
14
src/components/campaign/reels/ReelStatusBadge.tsx
Normal file
14
src/components/campaign/reels/ReelStatusBadge.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { Badge } from '@/components/ui/Badge'
|
||||
import { reelStatusLabels, type ReelStatus } from '@/lib/reel'
|
||||
|
||||
type StatusBadgeVariant = 'default' | 'outline'
|
||||
|
||||
const variantFor = (status: ReelStatus): StatusBadgeVariant =>
|
||||
status === 'published' ? 'default' : 'outline'
|
||||
|
||||
/** C194 — the one status chip of the reel library (list and detail). */
|
||||
export const ReelStatusBadge = ({ status }: { status: ReelStatus }) => (
|
||||
<Badge variant={variantFor(status)} className="font-normal">
|
||||
{reelStatusLabels[status]}
|
||||
</Badge>
|
||||
)
|
||||
54
src/components/campaign/reels/ReelTranscriptCard.tsx
Normal file
54
src/components/campaign/reels/ReelTranscriptCard.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
'use client'
|
||||
|
||||
import { CheckIcon, CopyIcon } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
copyFeedbackLabelsFor,
|
||||
copyFeedbackLiveMessagesFor,
|
||||
useCopyFeedback,
|
||||
} from '@/lib/copyFeedback'
|
||||
|
||||
/**
|
||||
* C194 — the reel's script/narration text. DB content, not a served file: it
|
||||
* stays readable while the reel is unpublished. Each stored line is a block,
|
||||
* the same shape the production writes.
|
||||
*/
|
||||
export const ReelTranscriptCard = ({ transcript }: { transcript: string }) => {
|
||||
const { feedback, copy } = useCopyFeedback()
|
||||
const labels = copyFeedbackLabelsFor('text')
|
||||
const liveMessages = copyFeedbackLiveMessagesFor('text')
|
||||
const blocks = transcript
|
||||
.split(/\n+/)
|
||||
.map((block) => block.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
return (
|
||||
<section id="transcricao" className="scroll-mt-20 rounded-xl border border-border p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold">Transcrição / roteiro</h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-10 px-2 text-xs text-muted-foreground"
|
||||
onClick={() => void copy(transcript)}
|
||||
>
|
||||
{feedback === 'copied' ? (
|
||||
<CheckIcon data-icon="inline-start" aria-hidden="true" />
|
||||
) : (
|
||||
<CopyIcon data-icon="inline-start" aria-hidden="true" />
|
||||
)}
|
||||
{labels[feedback]}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 space-y-3 text-sm leading-6">
|
||||
{blocks.map((block, index) => (
|
||||
<p key={`${index}-${block}`}>{block}</p>
|
||||
))}
|
||||
</div>
|
||||
<span aria-live="polite" className="sr-only">
|
||||
{liveMessages[feedback]}
|
||||
</span>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
54
src/components/campaign/shared/usePublicationToggle.ts
Normal file
54
src/components/campaign/shared/usePublicationToggle.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { postCampaignJson } from '@/lib/campaignJsonRequest'
|
||||
|
||||
const PUBLICATION_ERROR_MESSAGE = 'Não foi possível atualizar a publicação.'
|
||||
|
||||
type PublicationToggleResponse = { status: 'success' } | { status: 'error'; message: string }
|
||||
|
||||
/**
|
||||
* C194 — the one submit/error/refresh contract of a library publish toggle
|
||||
* (cut library, reel library): POST the domain body to the mutation route,
|
||||
* surface the safe domain message or the shared fallback, and refresh the RSC
|
||||
* page on success so the new status renders. The panels keep their own copy,
|
||||
* confirmation dialog and status branches.
|
||||
*/
|
||||
export const usePublicationToggle = ({
|
||||
href,
|
||||
buildBody,
|
||||
}: {
|
||||
href: string
|
||||
buildBody: (published: boolean) => Record<string, unknown>
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const setPublished = async (published: boolean): Promise<void> => {
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { ok, payload } = await postCampaignJson<PublicationToggleResponse>(
|
||||
href,
|
||||
buildBody(published),
|
||||
)
|
||||
|
||||
if (!ok || payload.status !== 'success') {
|
||||
setError(payload.status === 'error' ? payload.message : PUBLICATION_ERROR_MESSAGE)
|
||||
return
|
||||
}
|
||||
|
||||
router.refresh()
|
||||
} catch {
|
||||
setError(PUBLICATION_ERROR_MESSAGE)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return { submitting, error, setPublished }
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ import {
|
|||
CAMPAIGN_COMMUNICATION_ACERVO,
|
||||
CAMPAIGN_COMMUNICATION_CORTES,
|
||||
CAMPAIGN_COMMUNICATION_HOME,
|
||||
CAMPAIGN_COMMUNICATION_REELS,
|
||||
CAMPAIGN_CONTACTS_HOME,
|
||||
CAMPAIGN_UPDATES_HREF,
|
||||
LEADER_CONTACTS_HOME,
|
||||
|
|
@ -54,13 +55,15 @@ export type CampaignNavItem = {
|
|||
}
|
||||
|
||||
/**
|
||||
* C174 — the communication vertical's two destinations, hung under its single
|
||||
* C174 — the communication vertical's destinations, hung under its single
|
||||
* top-level item. Naming the array keeps both consumers (sidebar and drawer)
|
||||
* from spelling the list twice.
|
||||
* from spelling the list twice. C194 appended the reel library after the
|
||||
* acervo and the cut library.
|
||||
*/
|
||||
const communicationSubItems: readonly CampaignNavSubItem[] = [
|
||||
{ title: 'Acervo de falas', href: CAMPAIGN_COMMUNICATION_ACERVO },
|
||||
{ title: 'Biblioteca de cortes', href: CAMPAIGN_COMMUNICATION_CORTES },
|
||||
{ title: 'Reels', href: CAMPAIGN_COMMUNICATION_REELS },
|
||||
]
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
|
||||
import type { SpeechCutPublicationResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/types'
|
||||
import { usePublicationToggle } from '@/components/campaign/shared/usePublicationToggle'
|
||||
import { SpeechCutStatusBadge } from '@/components/campaign/speech/SpeechCutStatusBadge'
|
||||
import { Alert, AlertDescription } from '@/components/ui/Alert'
|
||||
import {
|
||||
|
|
@ -18,7 +15,6 @@ import {
|
|||
} from '@/components/ui/AlertDialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { postCampaignJson } from '@/lib/campaignJsonRequest'
|
||||
import type { SpeechCutStatus } from '@/lib/speechCut'
|
||||
|
||||
type SpeechCutPublicationPanelProps = {
|
||||
|
|
@ -35,34 +31,10 @@ const UNPUBLISH_WARNING =
|
|||
* takes it off the air. A cut without a stored file cannot be published.
|
||||
*/
|
||||
export const SpeechCutPublicationPanel = ({ cutId, status }: SpeechCutPublicationPanelProps) => {
|
||||
const router = useRouter()
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const setPublished = async (published: boolean) => {
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { ok, payload } = await postCampaignJson<SpeechCutPublicationResponse>(
|
||||
`/campanha/comunicacao/acervo/cortes/${cutId}/publicacao`,
|
||||
{ cutId, published },
|
||||
)
|
||||
|
||||
if (!ok || payload.status !== 'success') {
|
||||
setError(
|
||||
payload.status === 'error' ? payload.message : 'Não foi possível atualizar a publicação.',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
router.refresh()
|
||||
} catch {
|
||||
setError('Não foi possível atualizar a publicação.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
const { submitting, error, setPublished } = usePublicationToggle({
|
||||
href: `/campanha/comunicacao/acervo/cortes/${cutId}/publicacao`,
|
||||
buildBody: (published) => ({ cutId, published }),
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border p-4">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Metadata } from 'next'
|
|||
|
||||
import {
|
||||
CAMPAIGN_COMMUNICATION_CORTES,
|
||||
CAMPAIGN_COMMUNICATION_REELS,
|
||||
CAMPAIGN_CONTACTS_HOME,
|
||||
CAMPAIGN_HOME,
|
||||
CAMPAIGN_UPDATES_HREF,
|
||||
|
|
@ -125,6 +126,10 @@ export const campaignPageChromeCatalog = {
|
|||
title: 'Cortes',
|
||||
subtitle: 'O que já foi cortado — reencontre, ajuste o texto e republique.',
|
||||
},
|
||||
reels: {
|
||||
title: 'Reels',
|
||||
subtitle: 'Tutoriais do site prontos para a assessoria baixar e publicar fora do Teqo.',
|
||||
},
|
||||
} as const satisfies Record<string, CampaignPageChrome | null>
|
||||
|
||||
const resolveCatalogEntry = (
|
||||
|
|
@ -293,6 +298,12 @@ const pathRules: PathRule[] = [
|
|||
match: (pathname) => pathname === CAMPAIGN_COMMUNICATION_CORTES,
|
||||
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.cortes),
|
||||
},
|
||||
{
|
||||
// C194 — the reel library list. The `/<id>` detail sets its own title
|
||||
// through `SetCampaignPageChrome`, so it falls through to `null` here.
|
||||
match: (pathname) => pathname === CAMPAIGN_COMMUNICATION_REELS,
|
||||
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.reels),
|
||||
},
|
||||
{
|
||||
match: (pathname) => /^\/campanha\/comunicacao\/acervo\/[^/]+$/.test(pathname),
|
||||
resolve: () => null,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,17 @@ export const CAMPAIGN_COMMUNICATION_ACERVO = '/campanha/comunicacao/acervo' as c
|
|||
/** C168 — library of cuts made from the acervo (list + `/<id>` detail). */
|
||||
export const CAMPAIGN_COMMUNICATION_CORTES = '/campanha/comunicacao/acervo/cortes' as const
|
||||
|
||||
/** C194 — private library of reels (list + `/<id>` detail). */
|
||||
export const CAMPAIGN_COMMUNICATION_REELS = '/campanha/comunicacao/reels' as const
|
||||
|
||||
/** C194 — internal detail of one reel (id is immutable). */
|
||||
export const campaignReelDetailHref = (id: number): string =>
|
||||
`${CAMPAIGN_COMMUNICATION_REELS}/${id}`
|
||||
|
||||
/** C194 — JSON endpoint the detail's kill switch calls to publish/unpublish. */
|
||||
export const campaignReelPublicationHref = (id: number): string =>
|
||||
`${campaignReelDetailHref(id)}/publicacao`
|
||||
|
||||
/** C168/C174 — internal detail of one cut in the library (id is immutable). */
|
||||
export const campaignSpeechCutDetailHref = (id: number): string =>
|
||||
`${CAMPAIGN_COMMUNICATION_CORTES}/${id}`
|
||||
|
|
|
|||
|
|
@ -6,18 +6,48 @@ const COPY_FEEDBACK_RESET_MS = 2000
|
|||
|
||||
export type CopyFeedback = 'idle' | 'copied' | 'error'
|
||||
|
||||
export const copyFeedbackLabels: Record<CopyFeedback, string> = {
|
||||
idle: 'Copiar link',
|
||||
copied: 'Link copiado',
|
||||
error: 'Não foi possível copiar',
|
||||
/**
|
||||
* C194 — what is being copied changes the wording. `link` is the share
|
||||
* controls' default; `text` serves the reel transcript.
|
||||
*/
|
||||
export type CopySubject = 'link' | 'text'
|
||||
|
||||
const copyFeedbackLabelsBySubject: Record<CopySubject, Record<CopyFeedback, string>> = {
|
||||
link: {
|
||||
idle: 'Copiar link',
|
||||
copied: 'Link copiado',
|
||||
error: 'Não foi possível copiar',
|
||||
},
|
||||
text: {
|
||||
idle: 'Copiar texto',
|
||||
copied: 'Texto copiado',
|
||||
error: 'Não foi possível copiar',
|
||||
},
|
||||
}
|
||||
|
||||
export const copyFeedbackLiveMessages: Record<CopyFeedback, string> = {
|
||||
idle: '',
|
||||
copied: 'Link copiado.',
|
||||
error: 'Não foi possível copiar o link.',
|
||||
const copyFeedbackLiveMessagesBySubject: Record<CopySubject, Record<CopyFeedback, string>> = {
|
||||
link: {
|
||||
idle: '',
|
||||
copied: 'Link copiado.',
|
||||
error: 'Não foi possível copiar o link.',
|
||||
},
|
||||
text: {
|
||||
idle: '',
|
||||
copied: 'Texto copiado.',
|
||||
error: 'Não foi possível copiar o texto.',
|
||||
},
|
||||
}
|
||||
|
||||
export const copyFeedbackLabelsFor = (subject: CopySubject): Record<CopyFeedback, string> =>
|
||||
copyFeedbackLabelsBySubject[subject]
|
||||
|
||||
export const copyFeedbackLiveMessagesFor = (subject: CopySubject): Record<CopyFeedback, string> =>
|
||||
copyFeedbackLiveMessagesBySubject[subject]
|
||||
|
||||
/** The link wording the existing share controls import (unchanged). */
|
||||
export const copyFeedbackLabels = copyFeedbackLabelsBySubject.link
|
||||
export const copyFeedbackLiveMessages = copyFeedbackLiveMessagesBySubject.link
|
||||
|
||||
/**
|
||||
* The one copy-to-clipboard feedback contract of the share controls (C166 share
|
||||
* sheet, C167 cut share kit): the label per state, the screen-reader live
|
||||
|
|
|
|||
190
src/lib/reel.ts
190
src/lib/reel.ts
|
|
@ -5,6 +5,9 @@
|
|||
* authenticated media route and the C194 library share this module.
|
||||
*/
|
||||
|
||||
import { campaignReelDetailHref } from '@/lib/campaignPaths'
|
||||
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
|
||||
|
||||
/** Upload collection of the artifacts; also its local disk directory in dev. */
|
||||
export const REEL_MEDIA_SLUG = 'reelMedia'
|
||||
|
||||
|
|
@ -64,3 +67,190 @@ export const reelMediaPath = (reelId: number, kind: ReelMediaKind): string =>
|
|||
|
||||
/** Only a published reel serves its files; `draft`/`unpublished` answer 404. */
|
||||
export const canServeReelMedia = (status: ReelStatus): boolean => status === 'published'
|
||||
|
||||
const isReelFeature = (value: unknown): value is ReelFeature =>
|
||||
REEL_FEATURES.includes(value as ReelFeature)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C194 — the library view models. Same shape as `lib/speechCut.ts`: the pure
|
||||
// builders take the lean record the loader selected and return exactly what
|
||||
// the list/detail render — the artifact URLs only exist when the kill switch
|
||||
// says the file can be served.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ReelMediaRef = number | { alt?: string | null } | null | undefined
|
||||
|
||||
/**
|
||||
* What the view models read. Structurally satisfied by a `Reel` document with
|
||||
* depth 1 (and by the lean `select` of the list loader) — defined here so
|
||||
* `lib/` stays free of `@/payload-types`, same reason `speechCut.ts` has its
|
||||
* own record type.
|
||||
*/
|
||||
export type ReelRecordForView = {
|
||||
id: number
|
||||
title: string
|
||||
feature: string
|
||||
status: string
|
||||
publishedAt?: string | null
|
||||
transcript?: string | null
|
||||
video?: ReelMediaRef
|
||||
videoWithAudio?: ReelMediaRef
|
||||
narrationAudio?: ReelMediaRef
|
||||
captions?: ReelMediaRef
|
||||
cover?: ReelMediaRef
|
||||
}
|
||||
|
||||
/** Every artifact a person can take out of the library (the cover is not one). */
|
||||
export type ReelDownloadKind = Exclude<ReelMediaKind, 'cover'>
|
||||
|
||||
const REEL_DOWNLOAD_KINDS = [
|
||||
'video',
|
||||
'video-audio',
|
||||
'narration',
|
||||
'captions',
|
||||
] as const satisfies readonly ReelDownloadKind[]
|
||||
|
||||
const reelDownloadLabels: Record<ReelDownloadKind, { label: string; description: string }> = {
|
||||
video: { label: 'Vídeo sem áudio', description: 'MP4 · arquivo principal' },
|
||||
'video-audio': { label: 'Vídeo com áudio', description: 'Rascunho · MP4' },
|
||||
narration: { label: 'Narração', description: 'MP3' },
|
||||
captions: { label: 'Legendas', description: 'Arquivo .srt' },
|
||||
}
|
||||
|
||||
/** Shown in place of the link when the artifact was never produced. */
|
||||
export const reelDownloadUnavailableLabels: Record<ReelDownloadKind, string> = {
|
||||
video: 'Ainda não disponível.',
|
||||
'video-audio': 'Ainda não disponível.',
|
||||
narration: 'A narração será adicionada depois.',
|
||||
captions: 'A legenda ainda não foi gerada.',
|
||||
}
|
||||
|
||||
/** Every file is withheld while the reel is unpublished (C193 kill switch). */
|
||||
export const REEL_MEDIA_BLOCKED_LABEL = 'Disponível após republicar.'
|
||||
|
||||
/** A draft was never published; its files appear with the first publication. */
|
||||
export const REEL_MEDIA_DRAFT_LABEL = 'Disponível após publicar.'
|
||||
|
||||
export type ReelLibraryItemViewModel = {
|
||||
id: number
|
||||
title: string
|
||||
featureLabel: string
|
||||
status: ReelStatus
|
||||
statusLabel: string
|
||||
coverUrl: string
|
||||
coverAlt: string
|
||||
publishedAtLabel: string | null
|
||||
detailHref: string
|
||||
}
|
||||
|
||||
export type ReelDownloadItemViewModel = {
|
||||
kind: ReelDownloadKind
|
||||
label: string
|
||||
description: string
|
||||
/** The primary file the design's CTA leads with (always the silent video). */
|
||||
primary: boolean
|
||||
/** Null when the artifact is missing or the kill switch withholds the file. */
|
||||
href: string | null
|
||||
/** What the row says instead of a link when `href` is null. */
|
||||
unavailableLabel: string | null
|
||||
}
|
||||
|
||||
export type ReelDetailViewModel = ReelLibraryItemViewModel & {
|
||||
/** Script/narration text: DB content, not a served file — kept when unpublished. */
|
||||
transcript: string | null
|
||||
/** False when the reel is `draft`/`unpublished` — no player, no download links. */
|
||||
canServeMedia: boolean
|
||||
/** Why the media is withheld while `canServeMedia` is false (draft ≠ unpublished). */
|
||||
mediaBlockedLabel: string
|
||||
/** `video-audio` when the rough cut with narration exists, else the silent video. */
|
||||
videoSourceUrl: string | null
|
||||
videoPosterUrl: string
|
||||
downloads: ReelDownloadItemViewModel[]
|
||||
}
|
||||
|
||||
const normalizeReelStatus = (status: string): ReelStatus =>
|
||||
isReelStatus(status) ? status : 'draft'
|
||||
|
||||
const hasMedia = (value: ReelMediaRef): boolean => value !== null && value !== undefined
|
||||
|
||||
const mediaAlt = (value: ReelMediaRef, fallback: string): string => {
|
||||
if (typeof value !== 'object' || value === null) return fallback
|
||||
const alt = value.alt?.trim()
|
||||
return alt ? alt : fallback
|
||||
}
|
||||
|
||||
const publishedAtLabel = (iso: string | null | undefined): string | null => {
|
||||
if (!iso) return null
|
||||
const instant = new Date(iso)
|
||||
if (Number.isNaN(instant.getTime())) return null
|
||||
return formatBahiaDateTimeLabel(iso)
|
||||
}
|
||||
|
||||
const reelCover = (record: ReelRecordForView): { coverUrl: string; coverAlt: string } => ({
|
||||
coverUrl: reelMediaPath(record.id, 'cover'),
|
||||
coverAlt: mediaAlt(record.cover, record.title),
|
||||
})
|
||||
|
||||
/** One card of the library list (published rows only — the loader filters). */
|
||||
export const toReelLibraryItemViewModel = (record: ReelRecordForView): ReelLibraryItemViewModel => {
|
||||
const status = normalizeReelStatus(record.status)
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
featureLabel: isReelFeature(record.feature)
|
||||
? reelFeatureLabels[record.feature]
|
||||
: record.feature,
|
||||
status,
|
||||
statusLabel: reelStatusLabels[status],
|
||||
...reelCover(record),
|
||||
publishedAtLabel: publishedAtLabel(record.publishedAt),
|
||||
detailHref: campaignReelDetailHref(record.id),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One reel with its artifacts. The media URLs are null unless the reel is
|
||||
* published: the kill switch withholds the player and every download, while
|
||||
* the title/feature/transcript stay readable (C193 decision B).
|
||||
*/
|
||||
export const toReelDetailViewModel = (record: ReelRecordForView): ReelDetailViewModel => {
|
||||
const base = toReelLibraryItemViewModel(record)
|
||||
const canServeMedia = canServeReelMedia(base.status)
|
||||
const transcript = record.transcript?.trim()
|
||||
const mediaBlockedLabel =
|
||||
base.status === 'draft' ? REEL_MEDIA_DRAFT_LABEL : REEL_MEDIA_BLOCKED_LABEL
|
||||
|
||||
const unavailableLabelFor = (kind: ReelDownloadKind): string | null => {
|
||||
if (!canServeMedia) return mediaBlockedLabel
|
||||
return hasMedia(record[reelMediaFieldByKind[kind]]) ? null : reelDownloadUnavailableLabels[kind]
|
||||
}
|
||||
|
||||
const downloads = REEL_DOWNLOAD_KINDS.map((kind): ReelDownloadItemViewModel => {
|
||||
const { label, description } = reelDownloadLabels[kind]
|
||||
|
||||
return {
|
||||
kind,
|
||||
label,
|
||||
description,
|
||||
primary: kind === 'video',
|
||||
href:
|
||||
canServeMedia && hasMedia(record[reelMediaFieldByKind[kind]])
|
||||
? `${reelMediaPath(record.id, kind)}?download=1`
|
||||
: null,
|
||||
unavailableLabel: unavailableLabelFor(kind),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
...base,
|
||||
transcript: transcript ? transcript : null,
|
||||
canServeMedia,
|
||||
mediaBlockedLabel,
|
||||
videoSourceUrl: canServeMedia
|
||||
? reelMediaPath(record.id, hasMedia(record.videoWithAudio) ? 'video-audio' : 'video')
|
||||
: null,
|
||||
videoPosterUrl: base.coverUrl,
|
||||
downloads,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
21
src/lib/schemas/reel.ts
Normal file
21
src/lib/schemas/reel.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { positiveRelationshipId } from '@/lib/schemas/primitives'
|
||||
|
||||
/** The communication vertical refused the actor — the reel library's own literal. */
|
||||
export const REEL_FORBIDDEN_MESSAGE = 'Você não tem acesso à biblioteca de reels.'
|
||||
|
||||
/** The reel does not exist (or the actor cannot read it). */
|
||||
export const REEL_NOT_FOUND_MESSAGE = 'Reel não encontrado.'
|
||||
|
||||
/** Transport/storage failure (or anything unmapped). */
|
||||
export const REEL_GENERIC_ERROR_MESSAGE =
|
||||
'Não foi possível atualizar a publicação. Verifique seu acesso e tente novamente.'
|
||||
|
||||
/** C194 — the kill switch body: which reel and where it should land. */
|
||||
export const reelPublicationRequestSchema = z.object({
|
||||
reelId: positiveRelationshipId,
|
||||
published: z.boolean(),
|
||||
})
|
||||
|
||||
export type ReelPublicationRequest = z.infer<typeof reelPublicationRequestSchema>
|
||||
|
|
@ -14,7 +14,12 @@ import { getCampaignUser, getCampaignUserWithAvatar } from '@/utilities/campaign
|
|||
/** Exactly the non-null actor `getCampaignUser` proves (keeps its `email` refinement). */
|
||||
export type CampaignPageActor = NonNullable<Awaited<ReturnType<typeof getCampaignUser>>>
|
||||
|
||||
export type CampaignPageGate = 'staff' | 'unrestricted' | 'noLeader' | 'writable' | 'speechCatalog'
|
||||
export type CampaignPageGate =
|
||||
| 'staff'
|
||||
| 'unrestricted'
|
||||
| 'noLeader'
|
||||
| 'writable'
|
||||
| 'communicationCatalog'
|
||||
|
||||
export { LEADER_CONTACTS_HOME }
|
||||
|
||||
|
|
@ -33,7 +38,8 @@ export const CAMPAIGN_STAFF_QUADRO_PATH = '/campanha/quadro'
|
|||
* - 'writable' → advisor with Edição `somente_leitura` goes to `/campanha`
|
||||
* (C142 — write destinations must not be offered to a
|
||||
* read-only advisor; the server already rejects the write);
|
||||
* - 'speechCatalog'→ advisor/leader lose the communication vertical (C154);
|
||||
* - 'communicationCatalog' → advisor/leader lose the communication vertical
|
||||
* (C154; C194 generalized the name from `speechCatalog`);
|
||||
* communicator/coordinator/candidate pass, the same
|
||||
* predicate the collection access uses.
|
||||
*
|
||||
|
|
@ -69,9 +75,12 @@ export const requireCampaignPageActor = async (
|
|||
const denyRedirect = redirectTo ?? roleHome[user.role] ?? '/campanha'
|
||||
|
||||
if (gate === 'staff' && !isStaffCampaignRole(user.role)) redirect(denyRedirect)
|
||||
// C154 — the speech catalog vertical: communicator/coordinator/candidate,
|
||||
// the same predicate the collection access uses.
|
||||
if (gate === 'speechCatalog' && !canReadCommunicationCatalog(user.role)) redirect(denyRedirect)
|
||||
// C154/C194 — the communication vertical (acervo, cortes and reels):
|
||||
// communicator/coordinator/candidate, the same predicate the collection
|
||||
// access uses.
|
||||
if (gate === 'communicationCatalog' && !canReadCommunicationCatalog(user.role)) {
|
||||
redirect(denyRedirect)
|
||||
}
|
||||
if (gate === 'unrestricted' && !isUnrestrictedCampaignRole(user.role)) redirect(denyRedirect)
|
||||
if (gate === 'noLeader' && user.role === 'leader') redirect(denyRedirect)
|
||||
if (gate === 'writable' && !isStaffCampaignRole(user.role)) redirect(denyRedirect)
|
||||
|
|
|
|||
60
src/utilities/reels/reelListUrl.ts
Normal file
60
src/utilities/reels/reelListUrl.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* C194 — URL contract of the reel library list (`/campanha/comunicacao/reels`):
|
||||
* pagination only. The MVP has no search or filters (intention: the acervo is
|
||||
* small and the job is scanning covers), so `page` is the whole state.
|
||||
*/
|
||||
import { CAMPAIGN_COMMUNICATION_REELS } from '@/lib/campaignPaths'
|
||||
import {
|
||||
buildListHref,
|
||||
firstValue,
|
||||
resolveListUrl,
|
||||
strictDecimalInteger,
|
||||
type RawSearchParams,
|
||||
} from '@/utilities/campaignListUrl'
|
||||
|
||||
export const reelPageSize = 24
|
||||
|
||||
export type ReelListState = {
|
||||
page: number
|
||||
}
|
||||
|
||||
const reelListParamNames = ['page'] as const
|
||||
|
||||
const reelListParamNameSet = new Set<string>(reelListParamNames)
|
||||
|
||||
export const parseReelListParams = (params: RawSearchParams): ReelListState => ({
|
||||
page: strictDecimalInteger(firstValue(params.page)) ?? 1,
|
||||
})
|
||||
|
||||
const serializeCanonicalReelListSearchParams = (state: ReelListState): URLSearchParams => {
|
||||
const params = new URLSearchParams()
|
||||
if (state.page > 1) params.set('page', String(state.page))
|
||||
return params
|
||||
}
|
||||
|
||||
const buildReelListSearchParams = (state: ReelListState, page = state.page): URLSearchParams =>
|
||||
serializeCanonicalReelListSearchParams(
|
||||
parseReelListParams({
|
||||
page: String(page),
|
||||
}),
|
||||
)
|
||||
|
||||
export const buildReelListHref = (state: ReelListState, page: number): string =>
|
||||
buildListHref(state, buildReelListSearchParams, CAMPAIGN_COMMUNICATION_REELS, page)
|
||||
|
||||
export const resolveReelListUrl = (
|
||||
params: RawSearchParams,
|
||||
totalPages?: number,
|
||||
): {
|
||||
state: ReelListState
|
||||
href: string
|
||||
redirectHref?: string
|
||||
} =>
|
||||
resolveListUrl({
|
||||
params,
|
||||
paramNameSet: reelListParamNameSet,
|
||||
parse: parseReelListParams,
|
||||
buildSearchParams: buildReelListSearchParams,
|
||||
basePath: CAMPAIGN_COMMUNICATION_REELS,
|
||||
totalPages,
|
||||
})
|
||||
120
src/utilities/reels/reelPageData.ts
Normal file
120
src/utilities/reels/reelPageData.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import {
|
||||
toReelDetailViewModel,
|
||||
toReelLibraryItemViewModel,
|
||||
type ReelDetailViewModel,
|
||||
type ReelLibraryItemViewModel,
|
||||
} from '@/lib/reel'
|
||||
import { REEL_NOT_FOUND_MESSAGE } from '@/lib/schemas/reel'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { type RawSearchParams } from '@/utilities/campaignListUrl'
|
||||
import { createEntityNotFoundError } from '@/utilities/entityNotFound'
|
||||
import {
|
||||
parseReelListParams,
|
||||
reelPageSize,
|
||||
resolveReelListUrl,
|
||||
type ReelListState,
|
||||
} from '@/utilities/reels/reelListUrl'
|
||||
|
||||
/**
|
||||
* Lean `select` of the library grid: only the fields the card view model
|
||||
* renders. Payload does not reflect `select` in the result type, so the int
|
||||
* test is the guard.
|
||||
*/
|
||||
const reelListSelect = {
|
||||
title: true,
|
||||
feature: true,
|
||||
status: true,
|
||||
publishedAt: true,
|
||||
cover: true,
|
||||
} as const
|
||||
|
||||
/** Everything the detail view model reads, including the optional artifacts. */
|
||||
const reelDetailSelect = {
|
||||
title: true,
|
||||
feature: true,
|
||||
status: true,
|
||||
publishedAt: true,
|
||||
transcript: true,
|
||||
video: true,
|
||||
videoWithAudio: true,
|
||||
narrationAudio: true,
|
||||
captions: true,
|
||||
cover: true,
|
||||
} as const
|
||||
|
||||
export const ReelNotFoundError = createEntityNotFoundError('Reel', REEL_NOT_FOUND_MESSAGE)
|
||||
|
||||
export type ReelLibraryPageData = {
|
||||
rows: ReelLibraryItemViewModel[]
|
||||
state: ReelListState
|
||||
redirectHref?: string
|
||||
totalDocs: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
/**
|
||||
* C194 — the library list: published reels only (the kill switch takes the row
|
||||
* off the list without deleting it), newest publication first. Access is the
|
||||
* collection's own `canReadReel` (communicator/coordinator/candidate).
|
||||
*/
|
||||
export const loadReelLibraryPageData = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
searchParams: Promise<RawSearchParams> | RawSearchParams,
|
||||
): Promise<ReelLibraryPageData> => {
|
||||
const rawSearchParams = await searchParams
|
||||
const state = parseReelListParams(rawSearchParams)
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'reel',
|
||||
depth: 1,
|
||||
limit: reelPageSize,
|
||||
page: state.page,
|
||||
sort: '-publishedAt',
|
||||
where: { status: { equals: 'published' } },
|
||||
select: reelListSelect,
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
const resolvedUrl = resolveReelListUrl(rawSearchParams, result.totalPages)
|
||||
|
||||
return {
|
||||
rows: result.docs.map((reel) => toReelLibraryItemViewModel(reel)),
|
||||
state: resolvedUrl.state,
|
||||
redirectHref: resolvedUrl.redirectHref,
|
||||
totalDocs: result.totalDocs,
|
||||
totalPages: result.totalPages,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* C194 — one reel of the library, any status: the detail is where the kill
|
||||
* switch is reversed, so an unpublished reel stays reachable while its media
|
||||
* does not (C193 decision B withholds the player and the downloads).
|
||||
*/
|
||||
export const loadReelDetailPageData = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
reelId: number,
|
||||
): Promise<ReelDetailViewModel> => {
|
||||
const result = await payload.find({
|
||||
collection: 'reel',
|
||||
where: { id: { equals: reelId } },
|
||||
depth: 1,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
select: reelDetailSelect,
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
const reel = result.docs[0]
|
||||
if (!reel) throw new ReelNotFoundError()
|
||||
|
||||
return toReelDetailViewModel(reel)
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import 'server-only'
|
|||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { canReadSpeechCatalog } from '@/lib/campaignRoles'
|
||||
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
|
||||
import { measuredVideoLagSeconds, parseYoutubeVideoId } from '@/lib/speechVod'
|
||||
import type { CampaignUser, Speech } from '@/payload-types'
|
||||
import {
|
||||
|
|
@ -184,7 +184,7 @@ export const loadSpeechAcervoPageData = async (
|
|||
const themeRequested = state.mode === 'tema' && Boolean(state.q)
|
||||
let themeTerms: readonly string[] = []
|
||||
let themeUnavailable = false
|
||||
if (themeRequested && canReadSpeechCatalog(user.role)) {
|
||||
if (themeRequested && canReadCommunicationCatalog(user.role)) {
|
||||
const expansion = await expandTheme(state.q ?? '')
|
||||
if (expansion) themeTerms = expansion.terms
|
||||
else themeUnavailable = true
|
||||
|
|
|
|||
205
tests/e2e/campaignReel.e2e.spec.ts
Normal file
205
tests/e2e/campaignReel.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import type { APIRequestContext } from '@playwright/test'
|
||||
import { request as playwrightRequest } from '@playwright/test'
|
||||
|
||||
import { adminHeaders } from '../helpers/adminApi'
|
||||
import { seedTestUser } from '../helpers/seedUser'
|
||||
import {
|
||||
assertCampaignRedirect,
|
||||
assertLeaderRedirect,
|
||||
expect,
|
||||
rendered,
|
||||
test,
|
||||
} from './fixtures/campaignHttpTest.js'
|
||||
|
||||
/**
|
||||
* C194 — the private reel library over real HTTP (browserless): the list and
|
||||
* the detail render for a communication role, the downloads are served by the
|
||||
* C193 authenticated media route, the kill switch takes the reel off the list
|
||||
* and withholds every file (C193 decision B), and advisor/leader are denied
|
||||
* both the page and the mutation route.
|
||||
*
|
||||
* Reels and their media are seeded through the deployed REST API (admin
|
||||
* session) so the server process runs the real hooks; a Local API call from
|
||||
* the runner would throw on `revalidateTag` (same reason as the sibling
|
||||
* speech-cut spec).
|
||||
*/
|
||||
|
||||
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
|
||||
const FAKE_MP4 = Buffer.from('reel-library-mp4')
|
||||
const FAKE_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
)
|
||||
const createdReelIds: number[] = []
|
||||
const createdMediaIds: number[] = []
|
||||
|
||||
const createReelMedia = async (
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>,
|
||||
name: string,
|
||||
mimeType: string,
|
||||
buffer: Buffer,
|
||||
): Promise<number> => {
|
||||
const media = await request.post(`${BASE_URL}/api/reelMedia`, {
|
||||
headers,
|
||||
multipart: {
|
||||
_payload: JSON.stringify({ alt: `Artefato ${name}` }),
|
||||
file: { name, mimeType, buffer },
|
||||
},
|
||||
})
|
||||
expect(media.ok(), await media.text()).toBeTruthy()
|
||||
const mediaId = ((await media.json()) as { doc: { id: number } }).doc.id
|
||||
createdMediaIds.push(mediaId)
|
||||
return mediaId
|
||||
}
|
||||
|
||||
const createReel = async (
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>,
|
||||
input: { title: string; status: 'published' | 'unpublished' | 'draft' },
|
||||
): Promise<number> => {
|
||||
const suffix = randomUUID().slice(0, 8)
|
||||
const video = await createReelMedia(request, headers, `reel-${suffix}.mp4`, 'video/mp4', FAKE_MP4)
|
||||
const cover = await createReelMedia(request, headers, `capa-${suffix}.png`, 'image/png', FAKE_PNG)
|
||||
|
||||
const reel = await request.post(`${BASE_URL}/api/reel`, {
|
||||
headers,
|
||||
data: {
|
||||
title: input.title,
|
||||
feature: 'cards',
|
||||
status: input.status,
|
||||
video,
|
||||
cover,
|
||||
transcript: '[Abertura] Quer mostrar que está com Solla?',
|
||||
},
|
||||
})
|
||||
expect(reel.ok(), await reel.text()).toBeTruthy()
|
||||
const reelId = ((await reel.json()) as { doc: { id: number } }).doc.id
|
||||
createdReelIds.push(reelId)
|
||||
return reelId
|
||||
}
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
const headers = await adminHeaders(request, BASE_URL).catch(() => null)
|
||||
if (!headers) return
|
||||
for (const id of createdReelIds.splice(0)) {
|
||||
await request.delete(`${BASE_URL}/api/reel/${id}`, { headers }).catch(() => undefined)
|
||||
}
|
||||
for (const id of createdMediaIds.splice(0)) {
|
||||
await request.delete(`${BASE_URL}/api/reelMedia/${id}`, { headers }).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('Reel library (C194)', () => {
|
||||
test.beforeAll(async () => {
|
||||
await seedTestUser()
|
||||
})
|
||||
|
||||
test('lists, opens and downloads a published reel; the kill switch empties the list and withholds the files', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
request,
|
||||
}) => {
|
||||
const headers = await adminHeaders(request, BASE_URL)
|
||||
const title = `Reel da biblioteca ${randomUUID().slice(0, 8)}`
|
||||
const reelId = await createReel(request, headers, { status: 'published', title })
|
||||
|
||||
const communicator = await campaign.fixtures.createCampaignUser('communicator')
|
||||
const communicatorRequest = await campaignRequest(communicator, communicator.password)
|
||||
|
||||
const list = await communicatorRequest.get('/campanha/comunicacao/reels')
|
||||
expect(list.status()).toBe(200)
|
||||
expect(rendered(await list.text())).toContain(title)
|
||||
|
||||
const detail = await communicatorRequest.get(`/campanha/comunicacao/reels/${reelId}`)
|
||||
expect(detail.status()).toBe(200)
|
||||
const detailHtml = rendered(await detail.text())
|
||||
expect(detailHtml).toContain(title)
|
||||
expect(detailHtml).toContain(`/campanha/comunicacao/reels/${reelId}/media/video?download=1`)
|
||||
expect(detailHtml).toContain(`/campanha/comunicacao/reels/${reelId}/media/cover`)
|
||||
expect(detailHtml).toContain('Transcrição / roteiro')
|
||||
|
||||
const download = await communicatorRequest.get(
|
||||
`/campanha/comunicacao/reels/${reelId}/media/video?download=1`,
|
||||
)
|
||||
expect(download.status()).toBe(200)
|
||||
expect(download.headers()['content-disposition']).toContain('attachment')
|
||||
|
||||
const anonymous = await playwrightRequest.newContext({ baseURL: campaign.baseURL })
|
||||
try {
|
||||
expect(
|
||||
(await anonymous.get(`/campanha/comunicacao/reels/${reelId}/media/video`)).status(),
|
||||
).toBe(404)
|
||||
} finally {
|
||||
await anonymous.dispose()
|
||||
}
|
||||
|
||||
const unpublish = await communicatorRequest.post(
|
||||
`/campanha/comunicacao/reels/${reelId}/publicacao`,
|
||||
{ data: { reelId, published: false } },
|
||||
)
|
||||
expect(unpublish.status()).toBe(200)
|
||||
expect(((await unpublish.json()) as { reel: { status: string } }).reel.status).toBe(
|
||||
'unpublished',
|
||||
)
|
||||
|
||||
const listAfter = rendered(
|
||||
await (await communicatorRequest.get('/campanha/comunicacao/reels')).text(),
|
||||
)
|
||||
expect(listAfter).not.toContain(title)
|
||||
|
||||
const detailAfter = rendered(
|
||||
await (await communicatorRequest.get(`/campanha/comunicacao/reels/${reelId}`)).text(),
|
||||
)
|
||||
expect(detailAfter).toContain('Republicar reel')
|
||||
expect(detailAfter).toContain('Player indisponível')
|
||||
expect(detailAfter).not.toContain('?download=1')
|
||||
expect(detailAfter).not.toContain('<video')
|
||||
|
||||
const republish = await communicatorRequest.post(
|
||||
`/campanha/comunicacao/reels/${reelId}/publicacao`,
|
||||
{ data: { reelId, published: true } },
|
||||
)
|
||||
expect(republish.status()).toBe(200)
|
||||
expect(((await republish.json()) as { reel: { status: string } }).reel.status).toBe('published')
|
||||
|
||||
const listBack = rendered(
|
||||
await (await communicatorRequest.get('/campanha/comunicacao/reels')).text(),
|
||||
)
|
||||
expect(listBack).toContain(title)
|
||||
})
|
||||
|
||||
test('denies advisor/leader on the library page and on the kill-switch route', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
request,
|
||||
}) => {
|
||||
const headers = await adminHeaders(request, BASE_URL)
|
||||
const reelId = await createReel(request, headers, {
|
||||
status: 'published',
|
||||
title: `Reel restrito ${randomUUID().slice(0, 8)}`,
|
||||
})
|
||||
|
||||
for (const role of ['advisor', 'leader'] as const) {
|
||||
const denied = await campaign.fixtures.createCampaignUser(role)
|
||||
const deniedRequest = await campaignRequest(denied, denied.password)
|
||||
|
||||
if (role === 'leader') {
|
||||
await assertLeaderRedirect(deniedRequest, '/campanha/comunicacao/reels')
|
||||
} else {
|
||||
await assertCampaignRedirect(deniedRequest, '/campanha/comunicacao/reels', '/campanha')
|
||||
}
|
||||
|
||||
const response = await deniedRequest.post(
|
||||
`/campanha/comunicacao/reels/${reelId}/publicacao`,
|
||||
{ data: { reelId, published: false } },
|
||||
)
|
||||
expect(response.status()).toBe(400)
|
||||
expect(((await response.json()) as { message: string }).message).toContain(
|
||||
'não tem acesso à biblioteca de reels',
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -122,7 +122,7 @@ export const rendered = (html: string) => html.replaceAll('<!-- -->', '')
|
|||
|
||||
/**
|
||||
* The route-gate redirect contract (OPS35 → OPS87, 4 call sites; C154
|
||||
* generalized the target): `noLeader`/`staff`/`speechCatalog` gates throw
|
||||
* generalized the target): `noLeader`/`staff`/`communicationCatalog` gates throw
|
||||
* `redirect()` deep in the page, after the layout has started streaming — so
|
||||
* the document usually answers 200 carrying Next's route-redirect meta tag
|
||||
* instead of a 3xx; when the redirect beats the stream it is a real 3xx (dev
|
||||
|
|
|
|||
303
tests/int/reelLibrary.int.spec.ts
Normal file
303
tests/int/reelLibrary.int.spec.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// The reel collection's hooks and the Next runtime: the library loaders do not
|
||||
// cache anything, but the action context and the config import run through the
|
||||
// Next server graph — same neutering as the sibling speech-cut suite.
|
||||
vi.mock('next/cache', () => ({
|
||||
revalidatePath: () => undefined,
|
||||
revalidateTag: () => undefined,
|
||||
unstable_cache: (fn: unknown) => fn,
|
||||
}))
|
||||
|
||||
vi.mock('@/utilities/campaignActionContext', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@/utilities/campaignActionContext')>()
|
||||
return { ...original, getCampaignActionContext: vi.fn() }
|
||||
})
|
||||
|
||||
import { setReelPublishedForActor } from '@/app/(campaign)/campanha/actions/reels'
|
||||
import { REEL_FORBIDDEN_MESSAGE, REEL_NOT_FOUND_MESSAGE } from '@/lib/schemas/reel'
|
||||
import type { CampaignUser, Reel, ReelMedia } from '@/payload-types'
|
||||
import config from '@/payload.config'
|
||||
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
|
||||
import {
|
||||
loadReelDetailPageData,
|
||||
loadReelLibraryPageData,
|
||||
ReelNotFoundError,
|
||||
} from '@/utilities/reels/reelPageData'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
const MP4_BYTES = Buffer.from('fixture-mp4-bytes')
|
||||
const PNG_BYTES = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
)
|
||||
|
||||
let payload: Payload
|
||||
const createdReelIds = new Set<number>()
|
||||
const createdMediaIds = new Set<number>()
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
const mockedGetContext = vi.mocked(getCampaignActionContext)
|
||||
const asActor = (actor: CampaignUser) => mockedGetContext.mockResolvedValue({ payload, actor })
|
||||
|
||||
const createMedia = async (name: string, data: Buffer, mimetype: string): Promise<ReelMedia> => {
|
||||
const media = await payload.create({
|
||||
collection: 'reelMedia',
|
||||
data: { alt: `Artefato ${name}` },
|
||||
file: { data, mimetype, name, size: data.length },
|
||||
overrideAccess: true,
|
||||
})
|
||||
createdMediaIds.add(media.id)
|
||||
return media
|
||||
}
|
||||
|
||||
const createReel = async ({
|
||||
status = 'draft',
|
||||
title = 'Reel de tutorial',
|
||||
withTranscript = false,
|
||||
}: {
|
||||
status?: Reel['status']
|
||||
title?: string
|
||||
withTranscript?: boolean
|
||||
} = {}): Promise<Reel> => {
|
||||
const video = await createMedia(`reel-${createdReelIds.size}.mp4`, MP4_BYTES, 'video/mp4')
|
||||
const cover = await createMedia(`capa-${createdReelIds.size}.png`, PNG_BYTES, 'image/png')
|
||||
|
||||
const reel = await payload.create({
|
||||
collection: 'reel',
|
||||
data: {
|
||||
title,
|
||||
feature: 'cards',
|
||||
status,
|
||||
video: video.id,
|
||||
cover: cover.id,
|
||||
...(withTranscript ? { transcript: ' [Abertura] Olá. ' } : {}),
|
||||
},
|
||||
overrideAccess: true,
|
||||
})
|
||||
createdReelIds.add(reel.id)
|
||||
return reel
|
||||
}
|
||||
|
||||
const setPublishedAt = async (reelId: number, iso: string): Promise<void> => {
|
||||
await payload.update({
|
||||
collection: 'reel',
|
||||
id: reelId,
|
||||
data: { publishedAt: iso },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
|
||||
describe('reel library data (C194)', () => {
|
||||
let communicator: CampaignUser
|
||||
let coordinator: CampaignUser
|
||||
let candidate: CampaignUser
|
||||
let advisor: CampaignUser
|
||||
let leader: CampaignUser
|
||||
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
;[communicator, coordinator, candidate, advisor, leader] = await Promise.all([
|
||||
fixtures.createCampaignUser('communicator'),
|
||||
fixtures.createCampaignUser('coordinator'),
|
||||
fixtures.createCampaignUser('candidate'),
|
||||
fixtures.createCampaignUser('advisor'),
|
||||
fixtures.createCampaignUser('leader'),
|
||||
])
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of createdReelIds) {
|
||||
await payload.delete({ collection: 'reel', id, overrideAccess: true }).catch(() => undefined)
|
||||
}
|
||||
for (const id of createdMediaIds) {
|
||||
await payload
|
||||
.delete({ collection: 'reelMedia', id, overrideAccess: true })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
it('lists only published reels, newest publication first, with the private cover path', async () => {
|
||||
const older = await createReel({ status: 'published', title: 'Reel antigo' })
|
||||
await setPublishedAt(older.id, '2026-09-01T12:00:00.000Z')
|
||||
const newer = await createReel({ status: 'published', title: 'Reel novo' })
|
||||
await setPublishedAt(newer.id, '2026-09-10T12:00:00.000Z')
|
||||
const draft = await createReel({ status: 'draft' })
|
||||
const unpublished = await createReel({ status: 'unpublished' })
|
||||
|
||||
const data = await loadReelLibraryPageData(payload, communicator, {})
|
||||
|
||||
const ids = data.rows.map((row) => row.id)
|
||||
expect(ids).toContain(newer.id)
|
||||
expect(ids).toContain(older.id)
|
||||
expect(ids).not.toContain(draft.id)
|
||||
expect(ids).not.toContain(unpublished.id)
|
||||
expect(ids.indexOf(newer.id)).toBeLessThan(ids.indexOf(older.id))
|
||||
expect(data.totalDocs).toBeGreaterThanOrEqual(2)
|
||||
|
||||
const newest = data.rows.find((row) => row.id === newer.id)
|
||||
expect(newest).toMatchObject({
|
||||
title: 'Reel novo',
|
||||
featureLabel: 'Cards de apoio (#cards)',
|
||||
status: 'published',
|
||||
statusLabel: 'Publicado',
|
||||
coverUrl: `/campanha/comunicacao/reels/${newer.id}/media/cover`,
|
||||
detailHref: `/campanha/comunicacao/reels/${newer.id}`,
|
||||
})
|
||||
})
|
||||
|
||||
it('canonicalizes the URL and redirects an overflowing page', async () => {
|
||||
const data = await loadReelLibraryPageData(payload, coordinator, {
|
||||
page: 'abc',
|
||||
unsupported: 'x',
|
||||
})
|
||||
|
||||
expect(data.state.page).toBe(1)
|
||||
expect(data.redirectHref).toBe('/campanha/comunicacao/reels')
|
||||
})
|
||||
|
||||
it('denies the list to advisors and leaders', async () => {
|
||||
await expect(loadReelLibraryPageData(payload, advisor, {})).rejects.toThrow()
|
||||
await expect(loadReelLibraryPageData(payload, leader, {})).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('loads the detail for any status and fails closed for an unknown reel', async () => {
|
||||
const published = await createReel({
|
||||
status: 'published',
|
||||
title: 'Reel com arquivos',
|
||||
withTranscript: true,
|
||||
})
|
||||
const draft = await createReel({ status: 'draft' })
|
||||
|
||||
const detail = await loadReelDetailPageData(payload, candidate, published.id)
|
||||
expect(detail.canServeMedia).toBe(true)
|
||||
expect(detail.videoSourceUrl).toBe(`/campanha/comunicacao/reels/${published.id}/media/video`)
|
||||
expect(detail.videoPosterUrl).toBe(`/campanha/comunicacao/reels/${published.id}/media/cover`)
|
||||
expect(detail.transcript).toBe('[Abertura] Olá.')
|
||||
expect(detail.downloads[0]).toMatchObject({
|
||||
kind: 'video',
|
||||
primary: true,
|
||||
href: `/campanha/comunicacao/reels/${published.id}/media/video?download=1`,
|
||||
unavailableLabel: null,
|
||||
})
|
||||
expect(detail.downloads[1].href).toBeNull()
|
||||
|
||||
const draftDetail = await loadReelDetailPageData(payload, communicator, draft.id)
|
||||
expect(draftDetail.canServeMedia).toBe(false)
|
||||
expect(draftDetail.videoSourceUrl).toBeNull()
|
||||
expect(draftDetail.downloads.every((item) => item.href === null)).toBe(true)
|
||||
|
||||
await expect(loadReelDetailPageData(payload, communicator, 999_999)).rejects.toBeInstanceOf(
|
||||
ReelNotFoundError,
|
||||
)
|
||||
})
|
||||
|
||||
it('denies the detail to advisors and leaders', async () => {
|
||||
const reel = await createReel({ status: 'published' })
|
||||
|
||||
await expect(loadReelDetailPageData(payload, advisor, reel.id)).rejects.toThrow()
|
||||
await expect(loadReelDetailPageData(payload, leader, reel.id)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reel kill switch (C194)', () => {
|
||||
let communicator: CampaignUser
|
||||
let coordinator: CampaignUser
|
||||
let advisor: CampaignUser
|
||||
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
;[communicator, coordinator, advisor] = await Promise.all([
|
||||
fixtures.createCampaignUser('communicator'),
|
||||
fixtures.createCampaignUser('coordinator'),
|
||||
fixtures.createCampaignUser('advisor'),
|
||||
])
|
||||
})
|
||||
|
||||
it('takes the reel off the list and withholds its media without deleting it', async () => {
|
||||
const reel = await createReel({
|
||||
status: 'published',
|
||||
title: 'Reel do kill switch',
|
||||
withTranscript: true,
|
||||
})
|
||||
asActor(communicator)
|
||||
|
||||
const result = await setReelPublishedForActor({ reelId: reel.id, published: false })
|
||||
expect(result).toEqual({ id: reel.id, status: 'unpublished' })
|
||||
|
||||
const list = await loadReelLibraryPageData(payload, communicator, {})
|
||||
expect(list.rows.map((row) => row.id)).not.toContain(reel.id)
|
||||
|
||||
const detail = await loadReelDetailPageData(payload, communicator, reel.id)
|
||||
expect(detail.canServeMedia).toBe(false)
|
||||
expect(detail.transcript).toBe('[Abertura] Olá.')
|
||||
expect(detail.status).toBe('unpublished')
|
||||
|
||||
const stored = await payload.findByID({
|
||||
collection: 'reel',
|
||||
id: reel.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
const videoId = typeof reel.video === 'number' ? reel.video : reel.video.id
|
||||
expect(stored.video).toBe(videoId)
|
||||
})
|
||||
|
||||
it('republishes the same row and re-stamps the publication', async () => {
|
||||
const reel = await createReel({ status: 'published', title: 'Reel republicado' })
|
||||
asActor(coordinator)
|
||||
|
||||
await setReelPublishedForActor({ reelId: reel.id, published: false })
|
||||
await setPublishedAt(reel.id, '2020-01-01T00:00:00.000Z')
|
||||
|
||||
const result = await setReelPublishedForActor({ reelId: reel.id, published: true })
|
||||
expect(result.status).toBe('published')
|
||||
|
||||
const stored = await payload.findByID({
|
||||
collection: 'reel',
|
||||
id: reel.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(stored.publishedAt).not.toBe('2020-01-01T00:00:00.000Z')
|
||||
expect(new Date(stored.publishedAt ?? 0).getTime()).toBeGreaterThan(
|
||||
new Date('2020-01-01T00:00:00.000Z').getTime(),
|
||||
)
|
||||
|
||||
const list = await loadReelLibraryPageData(payload, coordinator, {})
|
||||
expect(list.rows.map((row) => row.id)).toContain(reel.id)
|
||||
})
|
||||
|
||||
it('refuses the kill switch for a denied role and reports a missing reel', async () => {
|
||||
const reel = await createReel({ status: 'published' })
|
||||
|
||||
asActor(advisor)
|
||||
await expect(setReelPublishedForActor({ reelId: reel.id, published: false })).rejects.toThrow(
|
||||
REEL_FORBIDDEN_MESSAGE,
|
||||
)
|
||||
|
||||
asActor(communicator)
|
||||
await expect(setReelPublishedForActor({ reelId: 999_999, published: false })).rejects.toThrow(
|
||||
REEL_NOT_FOUND_MESSAGE,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -14,6 +14,7 @@ import {
|
|||
CAMPAIGN_COMMUNICATION_ACERVO,
|
||||
CAMPAIGN_COMMUNICATION_CORTES,
|
||||
CAMPAIGN_COMMUNICATION_HOME,
|
||||
CAMPAIGN_COMMUNICATION_REELS,
|
||||
CAMPAIGN_UPDATES_HREF,
|
||||
} from '@/lib/campaignPaths'
|
||||
import { ORGANIZATIONS_LIST_PATH } from '@/lib/campaignQuickActionPaths'
|
||||
|
|
@ -97,15 +98,20 @@ describe('communicator sidebar', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('communication vertical sub-items (C174)', () => {
|
||||
const expectedSubHrefs = [CAMPAIGN_COMMUNICATION_ACERVO, CAMPAIGN_COMMUNICATION_CORTES]
|
||||
describe('communication vertical sub-items (C174, C194)', () => {
|
||||
const expectedSubHrefs = [
|
||||
CAMPAIGN_COMMUNICATION_ACERVO,
|
||||
CAMPAIGN_COMMUNICATION_CORTES,
|
||||
CAMPAIGN_COMMUNICATION_REELS,
|
||||
]
|
||||
|
||||
it('hangs the acervo and the cut library under Comunicação for the communicator', () => {
|
||||
it('hangs the acervo, the cut library and the reels under Comunicação for the communicator', () => {
|
||||
const [item] = getCampaignNav('communicator')
|
||||
expect(item?.subItems?.map((sub) => sub.href)).toEqual(expectedSubHrefs)
|
||||
expect(item?.subItems?.map((sub) => sub.title)).toEqual([
|
||||
'Acervo de falas',
|
||||
'Biblioteca de cortes',
|
||||
'Reels',
|
||||
])
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -25,10 +25,11 @@ vi.mock('@/utilities/access/shared', () => ({
|
|||
},
|
||||
}))
|
||||
|
||||
import { LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
|
||||
type Actor = {
|
||||
role: 'coordinator' | 'candidate' | 'advisor' | 'leader'
|
||||
role: 'coordinator' | 'candidate' | 'communicator' | 'advisor' | 'leader'
|
||||
visibility?: 'carteira' | 'tudo'
|
||||
editing?: 'carteira' | 'tudo' | 'somente_leitura'
|
||||
email: string
|
||||
|
|
@ -82,6 +83,33 @@ describe('requireCampaignPageActor — gate: writable (C142)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('requireCampaignPageActor — gate: communicationCatalog (C154, renamed by C194)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('lets the three communication roles through', async () => {
|
||||
for (const role of ['communicator', 'coordinator', 'candidate'] as const) {
|
||||
mocks.getCampaignUser.mockResolvedValue(actor(role))
|
||||
const user = await requireCampaignPageActor({ gate: 'communicationCatalog' })
|
||||
expect(user.role).toBe(role)
|
||||
expect(mocks.redirect).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('sends the advisor to the campaign home', async () => {
|
||||
mocks.getCampaignUser.mockResolvedValue(actor('advisor'))
|
||||
await requireCampaignPageActor({ gate: 'communicationCatalog' })
|
||||
expect(mocks.redirect).toHaveBeenCalledWith('/campanha')
|
||||
})
|
||||
|
||||
it('sends the leader to their contacts tool', async () => {
|
||||
mocks.getCampaignUser.mockResolvedValue(actor('leader'))
|
||||
await requireCampaignPageActor({ gate: 'communicationCatalog' })
|
||||
expect(mocks.redirect).toHaveBeenCalledWith(LEADER_CONTACTS_HOME)
|
||||
})
|
||||
})
|
||||
|
||||
describe('requireCampaignPageActor — withAvatar option', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
|
|
|||
|
|
@ -69,6 +69,14 @@ describe('campaignPageChrome', () => {
|
|||
expect(resolveCampaignPageChrome('/campanha/comunicacao/acervo/42', 'communicator')).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves the reel library list and leaves the detail title to the page (C194)', () => {
|
||||
expect(resolveCampaignPageChrome('/campanha/comunicacao/reels', 'communicator')).toEqual({
|
||||
title: 'Reels',
|
||||
subtitle: 'Tutoriais do site prontos para a assessoria baixar e publicar fora do Teqo.',
|
||||
})
|
||||
expect(resolveCampaignPageChrome('/campanha/comunicacao/reels/42', 'communicator')).toBeNull()
|
||||
})
|
||||
|
||||
it('separates staff contatos from the leader meus-contatos (C139)', () => {
|
||||
expect(resolveCampaignPageChrome('/campanha/contatos', 'coordinator')).toEqual({
|
||||
title: 'Contatos',
|
||||
|
|
|
|||
|
|
@ -7,12 +7,18 @@ import {
|
|||
isReelMediaKind,
|
||||
isReelStatus,
|
||||
REEL_FEATURES,
|
||||
REEL_MEDIA_BLOCKED_LABEL,
|
||||
REEL_MEDIA_DRAFT_LABEL,
|
||||
REEL_MEDIA_KINDS,
|
||||
REEL_STATUSES,
|
||||
reelDownloadUnavailableLabels,
|
||||
reelFeatureLabels,
|
||||
reelMediaFieldByKind,
|
||||
reelMediaPath,
|
||||
reelStatusLabels,
|
||||
toReelDetailViewModel,
|
||||
toReelLibraryItemViewModel,
|
||||
type ReelRecordForView,
|
||||
} from '@/lib/reel'
|
||||
import {
|
||||
REEL_MEDIA_FALLBACK_MIME_TYPE,
|
||||
|
|
@ -134,3 +140,103 @@ describe('reel media response rules (C193)', () => {
|
|||
expect(headers.get('Content-Type')).toBe('video/mp4')
|
||||
})
|
||||
})
|
||||
|
||||
const baseRecord: ReelRecordForView = {
|
||||
id: 42,
|
||||
title: 'Como criar seu card de apoio',
|
||||
feature: 'cards',
|
||||
status: 'published',
|
||||
publishedAt: '2026-09-18T17:00:00.000Z',
|
||||
transcript: ' [Abertura] Quer mostrar que está com Solla? ',
|
||||
video: { alt: 'Vídeo do tutorial' },
|
||||
cover: { alt: 'Capa do tutorial' },
|
||||
}
|
||||
|
||||
describe('reel view models (C194)', () => {
|
||||
it('builds the library card with the private media paths', () => {
|
||||
const item = toReelLibraryItemViewModel(baseRecord)
|
||||
|
||||
expect(item).toEqual({
|
||||
id: 42,
|
||||
title: 'Como criar seu card de apoio',
|
||||
featureLabel: 'Cards de apoio (#cards)',
|
||||
status: 'published',
|
||||
statusLabel: 'Publicado',
|
||||
coverUrl: '/campanha/comunicacao/reels/42/media/cover',
|
||||
coverAlt: 'Capa do tutorial',
|
||||
publishedAtLabel: '18/09/2026 às 14:00',
|
||||
detailHref: '/campanha/comunicacao/reels/42',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the title when the cover is an id and to the raw feature when unknown', () => {
|
||||
const item = toReelLibraryItemViewModel({
|
||||
...baseRecord,
|
||||
feature: 'wallpapers',
|
||||
cover: 7,
|
||||
})
|
||||
|
||||
expect(item.coverAlt).toBe('Como criar seu card de apoio')
|
||||
expect(item.featureLabel).toBe('wallpapers')
|
||||
})
|
||||
|
||||
it('offers the primary silent video and the existing variants as downloads', () => {
|
||||
const detail = toReelDetailViewModel({
|
||||
...baseRecord,
|
||||
videoWithAudio: { alt: 'Rascunho' },
|
||||
})
|
||||
|
||||
expect(detail.canServeMedia).toBe(true)
|
||||
expect(detail.videoSourceUrl).toBe('/campanha/comunicacao/reels/42/media/video-audio')
|
||||
expect(detail.videoPosterUrl).toBe('/campanha/comunicacao/reels/42/media/cover')
|
||||
expect(detail.transcript).toBe('[Abertura] Quer mostrar que está com Solla?')
|
||||
expect(detail.downloads.map((item) => item.kind)).toEqual([
|
||||
'video',
|
||||
'video-audio',
|
||||
'narration',
|
||||
'captions',
|
||||
])
|
||||
const [video, withAudio, narration, captions] = detail.downloads
|
||||
expect(video).toMatchObject({
|
||||
primary: true,
|
||||
href: '/campanha/comunicacao/reels/42/media/video?download=1',
|
||||
unavailableLabel: null,
|
||||
})
|
||||
expect(withAudio).toMatchObject({
|
||||
href: '/campanha/comunicacao/reels/42/media/video-audio?download=1',
|
||||
unavailableLabel: null,
|
||||
})
|
||||
expect(narration.href).toBeNull()
|
||||
expect(narration.unavailableLabel).toBe(reelDownloadUnavailableLabels.narration)
|
||||
expect(captions.href).toBeNull()
|
||||
expect(captions.unavailableLabel).toBe(reelDownloadUnavailableLabels.captions)
|
||||
})
|
||||
|
||||
it('withholds player and downloads while the kill switch is off (C193 decision B)', () => {
|
||||
const detail = toReelDetailViewModel({
|
||||
...baseRecord,
|
||||
status: 'unpublished',
|
||||
videoWithAudio: { alt: 'Rascunho' },
|
||||
narrationAudio: { alt: 'Narração' },
|
||||
})
|
||||
|
||||
expect(detail.canServeMedia).toBe(false)
|
||||
expect(detail.videoSourceUrl).toBeNull()
|
||||
expect(detail.transcript).toBe('[Abertura] Quer mostrar que está com Solla?')
|
||||
expect(detail.mediaBlockedLabel).toBe(REEL_MEDIA_BLOCKED_LABEL)
|
||||
for (const item of detail.downloads) {
|
||||
expect(item.href).toBeNull()
|
||||
expect(item.unavailableLabel).toBe(REEL_MEDIA_BLOCKED_LABEL)
|
||||
}
|
||||
})
|
||||
|
||||
it('tells a draft the files arrive with the first publication, not a republication', () => {
|
||||
const detail = toReelDetailViewModel({ ...baseRecord, status: 'draft' })
|
||||
|
||||
expect(detail.canServeMedia).toBe(false)
|
||||
expect(detail.mediaBlockedLabel).toBe(REEL_MEDIA_DRAFT_LABEL)
|
||||
expect(detail.downloads.every((item) => item.unavailableLabel === REEL_MEDIA_DRAFT_LABEL)).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue