fix(broadcasts): collect header media URL for image/video templates (#298)

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 <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-06-25 17:17:18 +03:00
parent d8d8cf2b2a
commit 33942dc8ac
No known key found for this signature in database
3 changed files with 126 additions and 4 deletions

View File

@ -41,6 +41,7 @@ export default function NewBroadcastPage() {
const [variables, setVariables] = useState< const [variables, setVariables] = useState<
Record<string, { type: 'static' | 'field' | 'custom_field'; value: string }> Record<string, { type: 'static' | 'field' | 'custom_field'; value: string }>
>({}); >({});
const [headerMediaUrl, setHeaderMediaUrl] = useState('');
const [name, setName] = useState(''); const [name, setName] = useState('');
async function handleSend() { async function handleSend() {
@ -58,6 +59,7 @@ export default function NewBroadcastPage() {
excludeTagIds: audience.excludeTagIds, excludeTagIds: audience.excludeTagIds,
}, },
variables, variables,
headerMediaUrl,
}); });
router.push(`/broadcasts/${broadcastId}`); router.push(`/broadcasts/${broadcastId}`);
} catch (err) { } catch (err) {
@ -205,6 +207,8 @@ export default function NewBroadcastPage() {
template={template} template={template}
variables={variables} variables={variables}
onUpdate={setVariables} onUpdate={setVariables}
headerMediaUrl={headerMediaUrl}
onHeaderMediaUrlChange={setHeaderMediaUrl}
onNext={() => setCurrentStep(3)} onNext={() => setCurrentStep(3)}
onBack={() => setCurrentStep(1)} onBack={() => setCurrentStep(1)}
/> />

View File

@ -12,7 +12,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } 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'; type VariableType = 'static' | 'field' | 'custom_field';
@ -25,10 +25,29 @@ interface Step3Props {
template: MessageTemplate; template: MessageTemplate;
variables: Record<string, VariableMapping>; variables: Record<string, VariableMapping>;
onUpdate: (variables: Record<string, VariableMapping>) => void; onUpdate: (variables: Record<string, VariableMapping>) => void;
/** Media URL for an IMAGE/VIDEO/DOCUMENT header, when the template has one. */
headerMediaUrl: string;
onHeaderMediaUrlChange: (url: string) => void;
onNext: () => void; onNext: () => void;
onBack: () => 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 = [ const contactFields = [
{ value: 'name', label: 'Contact Name' }, { value: 'name', label: 'Contact Name' },
{ value: 'phone', label: 'Phone Number' }, { value: 'phone', label: 'Phone Number' },
@ -52,6 +71,8 @@ export function Step3Personalize({
template, template,
variables, variables,
onUpdate, onUpdate,
headerMediaUrl,
onHeaderMediaUrlChange,
onNext, onNext,
onBack, onBack,
}: Step3Props) { }: Step3Props) {
@ -112,6 +133,33 @@ export function Step3Personalize({
return [...new Set(matches)].sort(); return [...new Set(matches)].sort();
}, [template.body_text]); }, [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 * A placeholder is "unmapped" if the user hasn't picked either a
* static value or a field/custom-field source. Blocks Next until * static value or a field/custom-field source. Blocks Next until
@ -194,13 +242,62 @@ export function Step3Personalize({
</p> </p>
</div> </div>
{placeholders.length === 0 ? ( {mediaHeaderType && (
<div className="rounded-xl border border-border bg-card/50 p-4">
<div className="mb-3 flex items-center gap-2">
<ImageIcon className="h-4 w-4 text-primary" />
<p className="text-sm font-medium text-foreground">Header media</p>
<span className="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium uppercase text-primary">
{mediaHeaderType}
</span>
</div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
Media URL
</label>
<Input
type="url"
value={headerMediaUrl}
onChange={(e) => 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"
/>
<p className="mt-1.5 text-xs text-muted-foreground">
Public URL of the {mediaHeaderType} sent as the message header.
Used for every recipient in this broadcast.
</p>
{mediaHeaderType === 'image' &&
headerMediaError === null &&
headerMediaUrl.trim() && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={headerMediaUrl.trim()}
alt="Header preview"
className="mt-3 max-h-40 rounded-lg border border-border object-contain"
/>
)}
{headerMediaError && (
<p className="mt-1.5 text-xs text-amber-300">
{headerMediaError === 'missing'
? 'A media URL is required to send this template.'
: 'Enter a valid http(s) URL.'}
</p>
)}
</div>
)}
{placeholders.length === 0 && !mediaHeaderType ? (
<div className="rounded-xl border border-border bg-card/50 p-6 text-center"> <div className="rounded-xl border border-border bg-card/50 p-6 text-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
This template has no variables to personalize. This template has no variables to personalize.
</p> </p>
</div> </div>
) : ( ) : placeholders.length === 0 ? null : (
<div className="space-y-4"> <div className="space-y-4">
{placeholders.map((placeholder) => { {placeholders.map((placeholder) => {
const key = placeholder.replace(/^\{\{|\}\}$/g, ''); const key = placeholder.replace(/^\{\{|\}\}$/g, '');
@ -351,7 +448,7 @@ export function Step3Personalize({
</Button> </Button>
<Button <Button
onClick={onNext} onClick={onNext}
disabled={unmappedKeys.length > 0} disabled={unmappedKeys.length > 0 || headerMediaError !== null}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
Next Next

View File

@ -39,6 +39,13 @@ interface BroadcastPayload {
template: MessageTemplate; template: MessageTemplate;
audience: AudienceConfig; audience: AudienceConfig;
variables: Record<string, VariableMapping>; variables: Record<string, VariableMapping>;
/**
* Media URL for an IMAGE/VIDEO/DOCUMENT header. Required at send
* time for media-header templates — Meta rejects the send without
* it. Passed through as `messageParams.headerMediaUrl`; the builder
* falls back to the template's stored URL only when this is empty.
*/
headerMediaUrl?: string;
} }
interface UseBroadcastSendingReturn { interface UseBroadcastSendingReturn {
@ -434,6 +441,19 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
let failedCount = 0; let failedCount = 0;
const totalRecipients = recipients.length; const totalRecipients = recipients.length;
// Media-header templates (image/video/document) require a media
// URL on every send. Collected in the personalize step and applied
// to all recipients; falls back to the template's stored URL on the
// server when omitted.
const headerType = payload.template.header_type;
const isMediaHeader =
headerType === 'image' ||
headerType === 'video' ||
headerType === 'document';
const headerMediaUrl = payload.headerMediaUrl?.trim();
const messageParams =
isMediaHeader && headerMediaUrl ? { headerMediaUrl } : undefined;
for (let i = 0; i < recipients.length; i += SEND_BATCH_SIZE) { for (let i = 0; i < recipients.length; i += SEND_BATCH_SIZE) {
const batch = recipients.slice(i, i + SEND_BATCH_SIZE); const batch = recipients.slice(i, i + SEND_BATCH_SIZE);
@ -448,6 +468,7 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
customValueIndex.get(r.contact.id), customValueIndex.get(r.contact.id),
) )
: [], : [],
...(messageParams ? { messageParams } : {}),
})); }));
if (apiRecipients.length === 0) continue; if (apiRecipients.length === 0) continue;