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