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({
- {placeholders.length === 0 ? (
+ {mediaHeaderType && (
+
+
+
+
Header media
+
+ {mediaHeaderType}
+
+
+
+
onHeaderMediaUrlChange(e.target.value)}
+ placeholder={`https://example.com/header.${
+ mediaHeaderType === 'image'
+ ? 'jpg'
+ : mediaHeaderType === 'video'
+ ? 'mp4'
+ : 'pdf'
+ }`}
+ className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
+ />
+
+ Public URL of the {mediaHeaderType} sent as the message header.
+ Used for every recipient in this broadcast.
+
+ {mediaHeaderType === 'image' &&
+ headerMediaError === null &&
+ headerMediaUrl.trim() && (
+ // eslint-disable-next-line @next/next/no-img-element
+
})
+ )}
+ {headerMediaError && (
+
+ {headerMediaError === 'missing'
+ ? 'A media URL is required to send this template.'
+ : 'Enter a valid http(s) URL.'}
+
+ )}
+
+ )}
+
+ {placeholders.length === 0 && !mediaHeaderType ? (
This template has no variables to personalize.
- ) : (
+ ) : placeholders.length === 0 ? null : (
{placeholders.map((placeholder) => {
const key = placeholder.replace(/^\{\{|\}\}$/g, '');
@@ -351,7 +448,7 @@ export function Step3Personalize({