Merge pull request #299 from ArnasDon/fix/broadcast-media-header-url

fix(broadcasts): collect header media URL for image/video templates
This commit is contained in:
Arnas Donauskas 2026-06-25 17:25:20 +03:00 committed by GitHub
commit b72e7a6e62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 126 additions and 4 deletions

View File

@ -41,6 +41,7 @@ export default function NewBroadcastPage() {
const [variables, setVariables] = useState<
Record<string, { type: 'static' | 'field' | 'custom_field'; value: string }>
>({});
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)}
/>

View File

@ -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<string, VariableMapping>;
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;
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({
</p>
</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">
<p className="text-sm text-muted-foreground">
This template has no variables to personalize.
</p>
</div>
) : (
) : placeholders.length === 0 ? null : (
<div className="space-y-4">
{placeholders.map((placeholder) => {
const key = placeholder.replace(/^\{\{|\}\}$/g, '');
@ -351,7 +448,7 @@ export function Step3Personalize({
</Button>
<Button
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"
>
Next

View File

@ -39,6 +39,13 @@ interface BroadcastPayload {
template: MessageTemplate;
audience: AudienceConfig;
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 {
@ -434,6 +441,19 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
let failedCount = 0;
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) {
const batch = recipients.slice(i, i + SEND_BATCH_SIZE);
@ -448,6 +468,7 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
customValueIndex.get(r.contact.id),
)
: [],
...(messageParams ? { messageParams } : {}),
}));
if (apiRecipients.length === 0) continue;