mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
d8d8cf2b2a
commit
33942dc8ac
@ -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)}
|
||||
/>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user