- 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.
Como criar seu card de apoio
-
Cards · 00:28
+
Cards de apoio
@@ -708,21 +708,21 @@
Moldura para foto
-
Cards · 00:34
+
Cards de apoio
Baixe seu card
-
Cards · 00:21
+
Cards de apoio
Título do reel
-
Funcionalidade · 00:30
+
Funcionalidade
@@ -763,7 +763,7 @@
Publicado Cards · 00:28
+ >Cards de apoio
Como criar seu card de apoio
- 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.
-
+
@@ -920,7 +921,7 @@
Como criar seu card de apoio
Fora da lista
- 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.
Publicação na biblioteca
- Republicar devolve o reel à lista. Nenhum arquivo será recriado.
+ Republicar devolve o reel à lista e libera novamente o player e os downloads.
+
+
+
+
+
Player indisponível
+
Disponível após republicar.
+
+
Bloqueado
+
+
+
+
+
Downloads indisponíveis
+
Nenhum link de arquivo é exibido.
+
+
Bloqueados
+
+
+
+ Transcrição / roteiro
+
+ [Abertura] Quer mostrar que está com Solla?
+
+
+ O texto permanece disponível porque não é um arquivo de mídia.
+
+
@@ -1000,8 +1059,10 @@
placeholders explícitos; nenhum dado de terceiro foi usado.
- Em aberto no gate: 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.
+ Contrato C193 incorporado: 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.
diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs
index 2534accc..ca48dab6 100644
--- a/scripts/lib/e2e-affected-manifest.mjs
+++ b/scripts/lib/e2e-affected-manifest.mjs
@@ -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
diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx
index f7eac65e..398a0786 100644
--- a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx
@@ -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)
diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/page.tsx
index 92a986ee..d1ea8861 100644
--- a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/page.tsx
@@ -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)
diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/page.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/page.tsx
index 19a37237..d00dceba 100644
--- a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/page.tsx
@@ -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 }),
])
diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx
index 1ff3ff26..780f39c4 100644
--- a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx
@@ -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 }),
])
diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/page.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/page.tsx
index b03abc81..b9340cdd 100644
--- a/src/app/(campaign)/campanha/(app)/comunicacao/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/comunicacao/page.tsx
@@ -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)
}
diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/not-found.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/not-found.tsx
new file mode 100644
index 00000000..38c1a6f1
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/not-found.tsx
@@ -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 (
+
+
+ Reel não encontrado
+
+ O reel pode ter sido removido ou você não tem acesso a ele.
+
+
+
+
+ Voltar à biblioteca de reels
+
+
+
+ )
+}
diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/page.tsx
new file mode 100644
index 00000000..ab1219bc
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/page.tsx
@@ -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 (
+
+
+
+
+
+
+
+ Voltar aos reels
+
+
+
+
+
+
+
+
+ {reel.featureLabel}
+ {reel.publishedAtLabel ? <> · publicado em {reel.publishedAtLabel}> : null}
+
+
+
{reel.title}
+
+
+
+
+
+
+
+
+
+ {reel.canServeMedia ? null : (
+
+
Fora da lista
+
+ 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.
+
+
+ )}
+
+
+
+ {reel.transcript ? (
+
+
+
+ ) : null}
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/publicacao/route.ts b/src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/publicacao/route.ts
new file mode 100644
index 00000000..f6028e54
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/publicacao/route.ts
@@ -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({ status: 'success', reel })
+ },
+)
diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/types.ts b/src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/types.ts
new file mode 100644
index 00000000..c25f40a3
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/types.ts
@@ -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 }
diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/reels/loading.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/reels/loading.tsx
new file mode 100644
index 00000000..6707a0f9
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/comunicacao/reels/loading.tsx
@@ -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 (
+
+
+
+
+
+
+ {Array.from({ length: 4 }, (_, index) => (
+
+ ))}
+
+
+ )
+}
diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/reels/page.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/reels/page.tsx
new file mode 100644
index 00000000..36f41d51
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/comunicacao/reels/page.tsx
@@ -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>
+}
+
+/** 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 (
+
+
+
+ {data.rows.length > 0 ? (
+ <>
+
+
+
Reels publicados
+
+ Abra um reel para assistir e baixar os arquivos.
+
+
+
+ {data.totalDocs} {data.totalDocs === 1 ? 'reel' : 'reels'}
+
+
+
+
+ >
+ ) : (
+
+ )}
+
+ {data.rows.length > 0 ? (
+ buildReelListHref(data.state, page)}
+ />
+ ) : null}
+
+
+
+ )
+}
diff --git a/src/app/(campaign)/campanha/actions/reels.ts b/src/app/(campaign)/campanha/actions/reels.ts
new file mode 100644
index 00000000..240935fc
--- /dev/null
+++ b/src/app/(campaign)/campanha/actions/reels.ts
@@ -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 => {
+ 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 }
+}
diff --git a/src/app/(campaign)/campanha/actions/speech.ts b/src/app/(campaign)/campanha/actions/speech.ts
index 199211ac..dc2f709b 100644
--- a/src/app/(campaign)/campanha/actions/speech.ts
+++ b/src/app/(campaign)/campanha/actions/speech.ts
@@ -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.
// ---------------------------------------------------------------------------
diff --git a/src/components/campaign/reels/ReelDownloadPanel.tsx b/src/components/campaign/reels/ReelDownloadPanel.tsx
new file mode 100644
index 00000000..ece42c84
--- /dev/null
+++ b/src/components/campaign/reels/ReelDownloadPanel.tsx
@@ -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 = {
+ 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 (
+
+
+
+
Downloads
+
+ Arquivos deste reel para finalizar e publicar fora do Teqo.
+
+
+
+
+
+ {primary ? : null}
+
+
+
+ )
+}
+
+const PrimaryDownload = ({ item }: { item: ReelDownloadItemViewModel }) => {
+ if (!item.href) {
+ return (
+
+ {item.label}
+ {item.unavailableLabel}
+
+ )
+ }
+
+ return (
+
+
+ Baixar {item.label.toLowerCase()}
+
+ )
+}
+
+const DownloadRow = ({ item }: { item: ReelDownloadItemViewModel }) => {
+ const Icon = iconByKind[item.kind]
+
+ if (!item.href) {
+ return (
+
+
+
+
{item.label}
+
{item.unavailableLabel}
+
+
Indisponível
+
+ )
+ }
+
+ return (
+
+
+
+
{item.label}
+
{item.description}
+
+ Baixar
+
+ )
+}
diff --git a/src/components/campaign/reels/ReelLibraryCard.tsx b/src/components/campaign/reels/ReelLibraryCard.tsx
new file mode 100644
index 00000000..a2f0011d
--- /dev/null
+++ b/src/components/campaign/reels/ReelLibraryCard.tsx
@@ -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 }) => (
+
+
+
+ {/* 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. */}
+
+
+ {reel.statusLabel}
+
+
+
+
+
{reel.title}
+
{reel.featureLabel}
+ {reel.publishedAtLabel ? (
+
Publicado em {reel.publishedAtLabel}
+ ) : null}
+
+ Abrir reel
+
+
+
+
+)
diff --git a/src/components/campaign/reels/ReelLibraryList.tsx b/src/components/campaign/reels/ReelLibraryList.tsx
new file mode 100644
index 00000000..7ce51a64
--- /dev/null
+++ b/src/components/campaign/reels/ReelLibraryList.tsx
@@ -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[] }) => (
+
+ {rows.map((reel) => (
+
+ ))}
+
+)
diff --git a/src/components/campaign/reels/ReelPlayer.tsx b/src/components/campaign/reels/ReelPlayer.tsx
new file mode 100644
index 00000000..dfd60afb
--- /dev/null
+++ b/src/components/campaign/reels/ReelPlayer.tsx
@@ -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 (
+
+
Player indisponível
+
{reel.mediaBlockedLabel}
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/campaign/reels/ReelPublicationPanel.tsx b/src/components/campaign/reels/ReelPublicationPanel.tsx
new file mode 100644
index 00000000..b69001a3
--- /dev/null
+++ b/src/components/campaign/reels/ReelPublicationPanel.tsx
@@ -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 (
+
+
+ Publicação na biblioteca
+
+
+
+
+
+
+ {status === 'published' ? (
+ <>
+ {UNPUBLISH_WARNING}
+
+
+
+ Despublicar reel
+
+
+
+
+ Despublicar este reel?
+ {UNPUBLISH_WARNING}
+
+
+
+ Cancelar
+
+ void setPublished(false)}
+ >
+ {submitting ? : null}
+ Despublicar
+
+
+
+
+ >
+ ) : null}
+
+ {status === 'unpublished' ? (
+ <>
+ {PUBLISH_WARNING}
+ void setPublished(true)}
+ >
+ {submitting ? : null}
+ Republicar reel
+
+ >
+ ) : null}
+
+ {status === 'draft' ? (
+ <>
+
+ Este reel ainda não foi publicado na biblioteca.
+
+ void setPublished(true)}
+ >
+ {submitting ? : null}
+ Publicar reel
+
+ >
+ ) : null}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ )
+}
diff --git a/src/components/campaign/reels/ReelStatusBadge.tsx b/src/components/campaign/reels/ReelStatusBadge.tsx
new file mode 100644
index 00000000..aa1d1f59
--- /dev/null
+++ b/src/components/campaign/reels/ReelStatusBadge.tsx
@@ -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 }) => (
+
+ {reelStatusLabels[status]}
+
+)
diff --git a/src/components/campaign/reels/ReelTranscriptCard.tsx b/src/components/campaign/reels/ReelTranscriptCard.tsx
new file mode 100644
index 00000000..28a6d09d
--- /dev/null
+++ b/src/components/campaign/reels/ReelTranscriptCard.tsx
@@ -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 (
+
+
+
Transcrição / roteiro
+ void copy(transcript)}
+ >
+ {feedback === 'copied' ? (
+
+ ) : (
+
+ )}
+ {labels[feedback]}
+
+
+
+ {blocks.map((block, index) => (
+
{block}
+ ))}
+
+
+ {liveMessages[feedback]}
+
+
+ )
+}
diff --git a/src/components/campaign/shared/usePublicationToggle.ts b/src/components/campaign/shared/usePublicationToggle.ts
new file mode 100644
index 00000000..e2d55997
--- /dev/null
+++ b/src/components/campaign/shared/usePublicationToggle.ts
@@ -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
+}) => {
+ const router = useRouter()
+ const [submitting, setSubmitting] = useState(false)
+ const [error, setError] = useState(null)
+
+ const setPublished = async (published: boolean): Promise => {
+ setSubmitting(true)
+ setError(null)
+
+ try {
+ const { ok, payload } = await postCampaignJson(
+ 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 }
+}
diff --git a/src/components/campaign/shell/nav.ts b/src/components/campaign/shell/nav.ts
index 8bbaa906..0a51421d 100644
--- a/src/components/campaign/shell/nav.ts
+++ b/src/components/campaign/shell/nav.ts
@@ -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 },
]
/**
diff --git a/src/components/campaign/speech/SpeechCutPublicationPanel.tsx b/src/components/campaign/speech/SpeechCutPublicationPanel.tsx
index f42ecce4..fdb135b8 100644
--- a/src/components/campaign/speech/SpeechCutPublicationPanel.tsx
+++ b/src/components/campaign/speech/SpeechCutPublicationPanel.tsx
@@ -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(null)
-
- const setPublished = async (published: boolean) => {
- setSubmitting(true)
- setError(null)
-
- try {
- const { ok, payload } = await postCampaignJson(
- `/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 (
diff --git a/src/lib/campaignPageChrome.ts b/src/lib/campaignPageChrome.ts
index e74d9279..e066d41c 100644
--- a/src/lib/campaignPageChrome.ts
+++ b/src/lib/campaignPageChrome.ts
@@ -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
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 `/` 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,
diff --git a/src/lib/campaignPaths.ts b/src/lib/campaignPaths.ts
index 748e2cbe..c015def6 100644
--- a/src/lib/campaignPaths.ts
+++ b/src/lib/campaignPaths.ts
@@ -28,6 +28,17 @@ export const CAMPAIGN_COMMUNICATION_ACERVO = '/campanha/comunicacao/acervo' as c
/** C168 — library of cuts made from the acervo (list + `/` detail). */
export const CAMPAIGN_COMMUNICATION_CORTES = '/campanha/comunicacao/acervo/cortes' as const
+/** C194 — private library of reels (list + `/` 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}`
diff --git a/src/lib/copyFeedback.ts b/src/lib/copyFeedback.ts
index 10b6a0ab..7c48ce7b 100644
--- a/src/lib/copyFeedback.ts
+++ b/src/lib/copyFeedback.ts
@@ -6,18 +6,48 @@ const COPY_FEEDBACK_RESET_MS = 2000
export type CopyFeedback = 'idle' | 'copied' | 'error'
-export const copyFeedbackLabels: Record = {
- 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> = {
+ 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 = {
- idle: '',
- copied: 'Link copiado.',
- error: 'Não foi possível copiar o link.',
+const copyFeedbackLiveMessagesBySubject: Record> = {
+ 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 =>
+ copyFeedbackLabelsBySubject[subject]
+
+export const copyFeedbackLiveMessagesFor = (subject: CopySubject): Record =>
+ 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
diff --git a/src/lib/reel.ts b/src/lib/reel.ts
index fefecac3..12bc54d1 100644
--- a/src/lib/reel.ts
+++ b/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
+
+const REEL_DOWNLOAD_KINDS = [
+ 'video',
+ 'video-audio',
+ 'narration',
+ 'captions',
+] as const satisfies readonly ReelDownloadKind[]
+
+const reelDownloadLabels: Record = {
+ 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 = {
+ 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,
+ }
+}
diff --git a/src/lib/schemas/reel.ts b/src/lib/schemas/reel.ts
new file mode 100644
index 00000000..1905fcdd
--- /dev/null
+++ b/src/lib/schemas/reel.ts
@@ -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
diff --git a/src/utilities/campaignPageActor.ts b/src/utilities/campaignPageActor.ts
index 51117043..ddc3b0e6 100644
--- a/src/utilities/campaignPageActor.ts
+++ b/src/utilities/campaignPageActor.ts
@@ -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>>
-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)
diff --git a/src/utilities/reels/reelListUrl.ts b/src/utilities/reels/reelListUrl.ts
new file mode 100644
index 00000000..de3c954c
--- /dev/null
+++ b/src/utilities/reels/reelListUrl.ts
@@ -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(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,
+ })
diff --git a/src/utilities/reels/reelPageData.ts b/src/utilities/reels/reelPageData.ts
new file mode 100644
index 00000000..210e3930
--- /dev/null
+++ b/src/utilities/reels/reelPageData.ts
@@ -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,
+): Promise => {
+ 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 => {
+ 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)
+}
diff --git a/src/utilities/speech/speechPageData.ts b/src/utilities/speech/speechPageData.ts
index bae9d337..972b9d2e 100644
--- a/src/utilities/speech/speechPageData.ts
+++ b/src/utilities/speech/speechPageData.ts
@@ -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
diff --git a/tests/e2e/campaignReel.e2e.spec.ts b/tests/e2e/campaignReel.e2e.spec.ts
new file mode 100644
index 00000000..6791621f
--- /dev/null
+++ b/tests/e2e/campaignReel.e2e.spec.ts
@@ -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,
+ name: string,
+ mimeType: string,
+ buffer: Buffer,
+): Promise => {
+ 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,
+ input: { title: string; status: 'published' | 'unpublished' | 'draft' },
+): Promise => {
+ 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(' {
+ 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',
+ )
+ }
+ })
+})
diff --git a/tests/e2e/fixtures/campaignHttpTest.ts b/tests/e2e/fixtures/campaignHttpTest.ts
index 8a21b563..219a0b43 100644
--- a/tests/e2e/fixtures/campaignHttpTest.ts
+++ b/tests/e2e/fixtures/campaignHttpTest.ts
@@ -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
diff --git a/tests/int/reelLibrary.int.spec.ts b/tests/int/reelLibrary.int.spec.ts
new file mode 100644
index 00000000..8e94290f
--- /dev/null
+++ b/tests/int/reelLibrary.int.spec.ts
@@ -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()
+ 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()
+const createdMediaIds = new Set()
+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 => {
+ 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 => {
+ 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 => {
+ 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,
+ )
+ })
+})
diff --git a/tests/unit/campaignNav.unit.spec.ts b/tests/unit/campaignNav.unit.spec.ts
index d53eefaa..44fab05e 100644
--- a/tests/unit/campaignNav.unit.spec.ts
+++ b/tests/unit/campaignNav.unit.spec.ts
@@ -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',
])
})
diff --git a/tests/unit/campaignPageActorGate.unit.spec.ts b/tests/unit/campaignPageActorGate.unit.spec.ts
index edfd2b1d..cf597b7f 100644
--- a/tests/unit/campaignPageActorGate.unit.spec.ts
+++ b/tests/unit/campaignPageActorGate.unit.spec.ts
@@ -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()
diff --git a/tests/unit/campaignPageChrome.unit.spec.ts b/tests/unit/campaignPageChrome.unit.spec.ts
index 13d4f03d..de044b16 100644
--- a/tests/unit/campaignPageChrome.unit.spec.ts
+++ b/tests/unit/campaignPageChrome.unit.spec.ts
@@ -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',
diff --git a/tests/unit/reel.unit.spec.ts b/tests/unit/reel.unit.spec.ts
index 591afdb9..8533403a 100644
--- a/tests/unit/reel.unit.spec.ts
+++ b/tests/unit/reel.unit.spec.ts
@@ -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,
+ )
+ })
+})