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 + Header preview + )} + {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({