From 33942dc8acdd496a5d90d54ded3d003f70c473d8 Mon Sep 17 00:00:00 2001
From: Arnas Donauskas
Date: Thu, 25 Jun 2026 17:17:18 +0300
Subject: [PATCH] fix(broadcasts): collect header media URL for image/video
templates (#298)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Templates with an IMAGE/VIDEO/DOCUMENT header could not be broadcast:
the wizard never offered a place to provide the header media URL, so
the send either shipped the template's stale stored URL or — when none
was stored — threw "image header requires a media link or id at send
time", failing the whole broadcast. The send path already supported
`messageParams.headerMediaUrl`; only the UI/hook never collected it.
- step3-personalize: show a required "Header media" URL field (with
http(s) validation and an image preview) when the selected template
has a media header; seed it from the template's stored sample URL and
block Next until a valid URL is provided.
- new-broadcast page: thread headerMediaUrl through wizard state.
- use-broadcast-sending: pass it as messageParams.headerMediaUrl to the
broadcast API for media-header templates.
Co-Authored-By: Claude Opus 4.8
---
src/app/(dashboard)/broadcasts/new/page.tsx | 4 +
.../broadcasts/step3-personalize.tsx | 105 +++++++++++++++++-
src/hooks/use-broadcast-sending.ts | 21 ++++
3 files changed, 126 insertions(+), 4 deletions(-)
diff --git a/src/app/(dashboard)/broadcasts/new/page.tsx b/src/app/(dashboard)/broadcasts/new/page.tsx
index bcbbb57..2eba6a8 100644
--- a/src/app/(dashboard)/broadcasts/new/page.tsx
+++ b/src/app/(dashboard)/broadcasts/new/page.tsx
@@ -41,6 +41,7 @@ export default function NewBroadcastPage() {
const [variables, setVariables] = useState<
Record
>({});
+ const [headerMediaUrl, setHeaderMediaUrl] = useState('');
const [name, setName] = useState('');
async function handleSend() {
@@ -58,6 +59,7 @@ export default function NewBroadcastPage() {
excludeTagIds: audience.excludeTagIds,
},
variables,
+ headerMediaUrl,
});
router.push(`/broadcasts/${broadcastId}`);
} catch (err) {
@@ -205,6 +207,8 @@ export default function NewBroadcastPage() {
template={template}
variables={variables}
onUpdate={setVariables}
+ headerMediaUrl={headerMediaUrl}
+ onHeaderMediaUrlChange={setHeaderMediaUrl}
onNext={() => setCurrentStep(3)}
onBack={() => setCurrentStep(1)}
/>
diff --git a/src/components/broadcasts/step3-personalize.tsx b/src/components/broadcasts/step3-personalize.tsx
index 1fce6ec..8bbeddb 100644
--- a/src/components/broadcasts/step3-personalize.tsx
+++ b/src/components/broadcasts/step3-personalize.tsx
@@ -12,7 +12,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
-import { ArrowLeft, ArrowRight, Eye, Loader2 } from 'lucide-react';
+import { ArrowLeft, ArrowRight, Eye, ImageIcon, Loader2 } from 'lucide-react';
type VariableType = 'static' | 'field' | 'custom_field';
@@ -25,10 +25,29 @@ interface Step3Props {
template: MessageTemplate;
variables: Record;
onUpdate: (variables: Record) => void;
+ /** Media URL for an IMAGE/VIDEO/DOCUMENT header, when the template has one. */
+ headerMediaUrl: string;
+ onHeaderMediaUrlChange: (url: string) => void;
onNext: () => void;
onBack: () => void;
}
+const MEDIA_HEADER_TYPES = ['image', 'video', 'document'] as const;
+type MediaHeaderType = (typeof MEDIA_HEADER_TYPES)[number];
+
+function isMediaHeaderType(value: unknown): value is MediaHeaderType {
+ return MEDIA_HEADER_TYPES.includes(value as MediaHeaderType);
+}
+
+function isValidHttpUrl(value: string): boolean {
+ try {
+ const u = new URL(value);
+ return u.protocol === 'http:' || u.protocol === 'https:';
+ } catch {
+ return false;
+ }
+}
+
const contactFields = [
{ value: 'name', label: 'Contact Name' },
{ value: 'phone', label: 'Phone Number' },
@@ -52,6 +71,8 @@ export function Step3Personalize({
template,
variables,
onUpdate,
+ headerMediaUrl,
+ onHeaderMediaUrlChange,
onNext,
onBack,
}: Step3Props) {
@@ -112,6 +133,33 @@ export function Step3Personalize({
return [...new Set(matches)].sort();
}, [template.body_text]);
+ // Templates with an IMAGE/VIDEO/DOCUMENT header need a media URL at
+ // send time — Meta requires the media component on every delivery and
+ // rejects the broadcast without it. The field is hidden for text-only
+ // headers.
+ const mediaHeaderType = isMediaHeaderType(template.header_type)
+ ? template.header_type
+ : null;
+
+ // Seed the field with the template's stored sample URL the first time
+ // we land on a media-header template, so the common "reuse the
+ // approved media" case needs no typing. Only seeds when empty to avoid
+ // clobbering a URL the user already edited.
+ useEffect(() => {
+ if (mediaHeaderType && !headerMediaUrl && template.header_media_url) {
+ onHeaderMediaUrlChange(template.header_media_url);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [mediaHeaderType, template.header_media_url]);
+
+ const headerMediaError = useMemo<'missing' | 'invalid' | null>(() => {
+ if (!mediaHeaderType) return null;
+ const value = headerMediaUrl.trim();
+ if (!value) return 'missing';
+ if (!isValidHttpUrl(value)) return 'invalid';
+ return null;
+ }, [mediaHeaderType, headerMediaUrl]);
+
/**
* A placeholder is "unmapped" if the user hasn't picked either a
* static value or a field/custom-field source. Blocks Next until
@@ -194,13 +242,62 @@ export function Step3Personalize({