From a96fcdde8401ce3a8a8c38c1213f3a4e134c87ea Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 15:21:38 +0000 Subject: [PATCH] Say why a campaign has no pre-roll instead of showing nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The render card returned null whenever a campaign had no video job, so a campaign deliberately skipped by the rollout and one that is broken looked exactly the same: an absence the advertiser had to interpret. The first person to open a blog-linking campaign reasonably read it as the feature failing. Now it says which, and that editing and saving the campaign queues one — which is true, because the save path queues a render for any campaign regardless of what it links to. Only the backfill was restricted to product ads. The campaign kind is classified on the page from the destination, reusing the same classifier the backfill uses, so the explanation cannot drift from the rule that actually decided. Co-Authored-By: Claude Opus 5 (1M context) --- app/(app)/dashboard/ads/[id]/page.tsx | 3 ++- components/ads/video-render-card.tsx | 35 +++++++++++++++++++++++---- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/app/(app)/dashboard/ads/[id]/page.tsx b/app/(app)/dashboard/ads/[id]/page.tsx index bc86c03..affa2e5 100644 --- a/app/(app)/dashboard/ads/[id]/page.tsx +++ b/app/(app)/dashboard/ads/[id]/page.tsx @@ -6,6 +6,7 @@ import { AdPreview } from "@/components/ads/ad-preview"; import { VideoRenderCard } from "@/components/ads/video-render-card"; import { latestJobForCampaign } from "@/lib/ads/video/jobs"; import { isStreamingFormat } from "@/lib/ads/formats"; +import { classifyCampaign } from "@/lib/ads/video/classify"; import { CampaignActions, RegenerateButton } from "@/components/ads/campaign-actions"; import { CampaignTrend } from "@/components/ads/campaign-trend"; import { BidHistory } from "@/components/ads/bid-history"; @@ -274,7 +275,7 @@ export default async function CampaignDetailPage({
- +
{creatives.length > 0 && ( diff --git a/components/ads/video-render-card.tsx b/components/ads/video-render-card.tsx index 692e545..326153c 100644 --- a/components/ads/video-render-card.tsx +++ b/components/ads/video-render-card.tsx @@ -15,7 +15,14 @@ type Ready = Extract; * deliberately unhurried: an encode takes tens of seconds, and polling faster * would only make the queue look busier than it is. */ -export function VideoRenderCard({ jobId }: { jobId: string | null }) { +export function VideoRenderCard({ + jobId, + campaignKind = "product", +}: { + jobId: string | null; + /** What this campaign advertises. Only used to explain an absent pre-roll. */ + campaignKind?: "product" | "blog" | "social"; +}) { const [status, setStatus] = useState(null); const [error, setError] = useState(null); const timer = useRef | null>(null); @@ -50,10 +57,28 @@ export function VideoRenderCard({ jobId }: { jobId: string | null }) { }; }, [jobId, settled, poll]); - // No job means this campaign predates the video pipeline, or its render was - // never queued. Saying nothing beats showing a broken-looking empty card on - // every older campaign. - if (!jobId) return null; + // No job. Rendering nothing here was worse than it sounds: a campaign that + // was deliberately skipped and one that is broken looked identical, so the + // only signal was an absence the advertiser had to interpret. Say which. + if (!jobId) { + const reason = + campaignKind === "blog" + ? "This campaign links to a blog post, and the initial rollout covered product ads only." + : campaignKind === "social" + ? "This campaign links to a social profile, and the initial rollout covered product ads only." + : "This campaign predates the video pipeline."; + return ( +
+

Streaming pre-roll

+

+ No pre-roll yet. {reason} +

+

+ Editing and saving this campaign queues one. +

+
+ ); + } return (