diff --git a/src/app/api/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts index 7687637e7b..70523360d7 100644 --- a/src/app/api/whatsapp/webhook/route.ts +++ b/src/app/api/whatsapp/webhook/route.ts @@ -57,6 +57,7 @@ interface WhatsAppMessage { button_reply?: { id: string; title: string } list_reply?: { id: string; title: string; description?: string } } + button?: { payload?: string; text?: string } /** Present when the customer swipe-replies to one of our messages. */ context?: { id: string } } @@ -653,7 +654,9 @@ async function processMessage( ? message.type : message.type === 'sticker' ? 'image' // stickers are images - : 'text' // reaction, unknown → text fallback + : message.type === 'button' + ? 'interactive' // quick replies are interactive button replies + : 'text' // reaction, unknown → text fallback // Determine whether this is the contact's very first inbound message // BEFORE we insert, so the count is accurate. Covers the case where @@ -963,6 +966,17 @@ async function parseMessageContent( return { ...empty, contentText: '[Interactive reply]' } } + case 'button': { + if (message.button) { + return { + ...empty, + contentText: message.button.text || null, + interactiveReplyId: message.button.payload || null, + } + } + return empty + } + default: return { ...empty, diff --git a/src/components/contacts/contact-detail-view.tsx b/src/components/contacts/contact-detail-view.tsx index 4a0c3fb4e0..189a2fcbfc 100644 --- a/src/components/contacts/contact-detail-view.tsx +++ b/src/components/contacts/contact-detail-view.tsx @@ -330,6 +330,11 @@ export function ContactDetailView({ ) { if (!contactId) return; setSendingTemplate(true); + const renderedBody = template.body_text.replace(/\{\{(\d+)\}\}/g, (_, raw) => { + const idx = Number(raw) - 1; + return values.body[idx] || `{{${raw}}}`; + }); + try { const res = await fetch('/api/whatsapp/send', { method: 'POST', @@ -344,9 +349,11 @@ export function ContactDetailView({ template_message_params: { body: values.body, headerText: values.headerText, + headerMediaUrl: values.headerMediaUrl, buttonParams: values.buttonParams, }, template_params: values.body, + content_text: renderedBody, }), }); diff --git a/src/components/inbox/template-picker.tsx b/src/components/inbox/template-picker.tsx index 4715763b05..aa46e771ce 100644 --- a/src/components/inbox/template-picker.tsx +++ b/src/components/inbox/template-picker.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { createClient } from "@/lib/supabase/client"; import type { MessageTemplate } from "@/types"; import { Button } from "@/components/ui/button"; @@ -10,7 +10,6 @@ import { Dialog, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; @@ -18,15 +17,25 @@ import { Badge } from "@/components/ui/badge"; import { ArrowLeft, ChevronRight, + FileText, + Image as ImageIcon, LayoutTemplate, Loader2, + Paperclip, + Upload, + Video, + X, } from "lucide-react"; import { extractVariableIndices } from "@/lib/whatsapp/template-validators"; +import { toast } from "sonner"; import { useTranslations } from "next-intl"; +// ─── Public interface ──────────────────────────────────────────────────────── + export interface TemplateSendValues { body: string[]; headerText?: string; + headerMediaUrl?: string; buttonParams?: Record; } @@ -36,6 +45,8 @@ interface TemplatePickerProps { onSelect: (template: MessageTemplate, values: TemplateSendValues) => void; } +// ─── Helpers ───────────────────────────────────────────────────────────────── + function renderBodyPreview(body: string, params: string[]): string { return body.replace(/\{\{(\d+)\}\}/g, (_, raw) => { const idx = Number(raw) - 1; @@ -50,60 +61,77 @@ interface UrlButtonSlot { url: string; } -/** - * Templates may need values for: body variables, a text-header - * variable, and per-URL-button suffixes. Collect them all so the - * send-message path doesn't 400 on missing parameters. - */ function collectVariableSlots(template: MessageTemplate): { bodyVars: number[]; headerVarCount: number; urlButtonSlots: UrlButtonSlot[]; + hasMediaHeader: boolean; } { const bodyVars = extractVariableIndices(template.body_text); const headerVarCount = template.header_type === "text" && template.header_content ? extractVariableIndices(template.header_content).length : 0; + const hasMediaHeader = ["image", "video", "document"].includes( + template.header_type ?? "" + ); const urlButtonSlots: UrlButtonSlot[] = []; (template.buttons ?? []).forEach((b, i) => { if (b.type === "URL" && extractVariableIndices(b.url).length > 0) { urlButtonSlots.push({ index: i, text: b.text, url: b.url }); } }); - return { bodyVars, headerVarCount, urlButtonSlots }; + return { bodyVars, headerVarCount, urlButtonSlots, hasMediaHeader }; } +type MediaSourceType = "static" | "upload"; +type ActiveTab = "map" | "media" | "advance"; + +// ─── Main component ─────────────────────────────────────────────────────────── + export function TemplatePicker({ open, onOpenChange, onSelect, }: TemplatePickerProps) { const t = useTranslations("Inbox.templatePicker"); + const supabase = createClient(); + const fileInputRef = useRef(null); + // Template list const [templates, setTemplates] = useState([]); const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + + // Selection const [selected, setSelected] = useState(null); - const [params, setParams] = useState([]); - const [headerText, setHeaderText] = useState(""); + + // Parameter values + const [bodyParams, setBodyParams] = useState([]); + const [headerText, setHeaderText] = useState(""); const [buttonParams, setButtonParams] = useState>({}); + // Media + const [mediaSourceType, setMediaSourceType] = useState("static"); + const [mediaLink, setMediaLink] = useState(""); + const [mediaUploadUrl, setMediaUploadUrl] = useState(""); + const [mediaFileName, setMediaFileName] = useState(""); + const [mediaUploading, setMediaUploading] = useState(false); + + // Tabs + const [activeTab, setActiveTab] = useState("map"); + + // ── Load templates on open ────────────────────────────────────────────────── useEffect(() => { if (!open) return; - let cancelled = false; (async () => { setLoading(true); - const supabase = createClient(); const { data: { user }, } = await supabase.auth.getUser(); - if (!user) { - if (!cancelled) { - setTemplates([]); - setLoading(false); - } + if (!cancelled) { setTemplates([]); setLoading(false); } return; } @@ -116,7 +144,6 @@ export function TemplatePicker({ .select("*") .eq("status", "APPROVED") .order("created_at", { ascending: false }); - if (cancelled) return; if (error) { console.error("Failed to fetch templates:", error); @@ -126,223 +153,718 @@ export function TemplatePicker({ } setLoading(false); })(); + return () => { cancelled = true; }; + }, [open]); // eslint-disable-line react-hooks/exhaustive-deps - return () => { - cancelled = true; - }; - }, [open]); - - function resetSelection() { + // ── Reset helpers ────────────────────────────────────────────────────────── + function resetAll() { setSelected(null); - setParams([]); + setBodyParams([]); setHeaderText(""); setButtonParams({}); + setMediaSourceType("static"); + setMediaLink(""); + setMediaUploadUrl(""); + setMediaFileName(""); + setActiveTab("map"); + setSearch(""); } function handleOpenChange(next: boolean) { - if (!next) resetSelection(); + if (!next) resetAll(); onOpenChange(next); } + // ── Pick a template from the list ────────────────────────────────────────── function pickTemplate(template: MessageTemplate) { const slots = collectVariableSlots(template); - const noInputsNeeded = - slots.bodyVars.length === 0 && - slots.headerVarCount === 0 && - slots.urlButtonSlots.length === 0; - if (noInputsNeeded) { - onSelect(template, { body: [] }); - handleOpenChange(false); - return; - } setSelected(template); - setParams(new Array(slots.bodyVars.length).fill("")); + setBodyParams(new Array(slots.bodyVars.length).fill("")); setHeaderText(""); setButtonParams({}); + setMediaSourceType("static"); + setMediaLink(""); + setMediaUploadUrl(""); + setMediaFileName(""); + setActiveTab("map"); + } + + // ── Media upload ──────────────────────────────────────────────────────────── + async function handleMediaUpload(file: File) { + const headerType = selected?.header_type ?? "image"; + if (headerType === "image") { + if (!["image/jpeg", "image/jpg", "image/png"].includes(file.type)) { + toast.error("Unsupported image type. Use PNG, JPG, or JPEG."); + return; + } + if (file.size > 5 * 1024 * 1024) { toast.error("Image is too large. Maximum 5 MB."); return; } + } else if (headerType === "video") { + if (file.type !== "video/mp4") { toast.error("Unsupported video type. Use MP4."); return; } + if (file.size > 10 * 1024 * 1024) { toast.error("Video is too large. Maximum 10 MB."); return; } + } else if (headerType === "document") { + if (file.type !== "application/pdf") { toast.error("Unsupported document type. Use PDF."); return; } + if (file.size > 10 * 1024 * 1024) { toast.error("Document is too large. Maximum 10 MB."); return; } + } + + setMediaUploading(true); + try { + const { data: { user }, error: userErr } = await supabase.auth.getUser(); + if (userErr || !user) throw new Error("Not signed in."); + const { data: profile, error: profileErr } = await supabase + .from("profiles").select("account_id").eq("user_id", user.id).maybeSingle(); + if (profileErr || !profile?.account_id) throw new Error("Could not resolve your account."); + + const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin"; + const safeBase = file.name.replace(/\.[^.]+$/, "").replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 40) || "file"; + const path = `account-${profile.account_id}/${Date.now()}-${safeBase}.${ext}`; + + const { error: upErr } = await supabase.storage + .from("flow-media") + .upload(path, file, { cacheControl: "3600", upsert: false, contentType: file.type }); + if (upErr) throw new Error(upErr.message); + + const { data: { publicUrl } } = supabase.storage.from("flow-media").getPublicUrl(path); + setMediaUploadUrl(publicUrl); + setMediaFileName(file.name); + toast.success("File uploaded successfully."); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Upload failed."); + } finally { + setMediaUploading(false); + } } + // ── Confirm send ──────────────────────────────────────────────────────────── function confirm() { if (!selected) return; - const values: TemplateSendValues = { body: params }; + const values: TemplateSendValues = { body: bodyParams }; if (headerText.trim()) values.headerText = headerText.trim(); + + // Resolve media URL + const resolvedMediaUrl = + mediaSourceType === "upload" ? mediaUploadUrl : mediaLink; + if (resolvedMediaUrl?.trim()) values.headerMediaUrl = resolvedMediaUrl.trim(); + if (Object.keys(buttonParams).length > 0) { values.buttonParams = Object.fromEntries( - Object.entries(buttonParams).map(([k, v]) => [Number(k), v.trim()]), + Object.entries(buttonParams).map(([k, v]) => [Number(k), v.trim()]) ); } onSelect(selected, values); handleOpenChange(false); } + // ── Derived state ─────────────────────────────────────────────────────────── const slots = useMemo( () => (selected ? collectVariableSlots(selected) : null), - [selected], + [selected] + ); + + const hasMediaHeader = !!(selected && slots?.hasMediaHeader); + const hasButtons = !!(selected?.buttons && selected.buttons.length > 0); + + // A media template can still be sent if the template row already has header_media_url / header_handle + const templateHasDefaultMedia = !!( + selected?.header_media_url || + (selected?.header_handle && selected.header_handle.startsWith("http")) || + (selected?.header_handle && /^\d+$/.test(selected.header_handle)) ); + const mediaProvided = + !hasMediaHeader || + templateHasDefaultMedia || + (mediaSourceType === "upload" && !!mediaUploadUrl) || + (mediaSourceType === "static" && !!mediaLink.trim()); + const canConfirm = !!selected && !!slots && - slots.bodyVars.every((_, i) => (params[i] ?? "").trim().length > 0) && + slots.bodyVars.every((_, i) => (bodyParams[i] ?? "").trim().length > 0) && (slots.headerVarCount === 0 || headerText.trim().length > 0) && - slots.urlButtonSlots.every( - (s) => (buttonParams[s.index] ?? "").trim().length > 0, - ); + slots.urlButtonSlots.every((s) => (buttonParams[s.index] ?? "").trim().length > 0) && + mediaProvided; + // Live preview helpers + const previewBody = selected + ? renderBodyPreview(selected.body_text, bodyParams) + : ""; + + const previewHeader = selected?.header_type === "text" && selected.header_content + ? selected.header_content.replace(/\{\{1\}\}/g, headerText || "{{1}}") + : ""; + + // Determine preview image for image header + const previewImageUrl = useMemo(() => { + if (selected?.header_type !== "image") return ""; + if (mediaSourceType === "upload" && mediaUploadUrl) return mediaUploadUrl; + if (mediaSourceType === "static" && mediaLink) return mediaLink; + return selected.header_media_url ?? ""; + }, [selected, mediaSourceType, mediaUploadUrl, mediaLink]); + + const filteredTemplates = useMemo( + () => + templates.filter( + (t) => + !search || + t.name.toLowerCase().includes(search.toLowerCase()) || + t.body_text.toLowerCase().includes(search.toLowerCase()) + ), + [templates, search] + ); + + // ── Render ────────────────────────────────────────────────────────────────── return ( - - - - - {selected ? selected.name : t("sendTemplate")} - - + + {/* Header */} + + {selected && ( + + )} +
+ + + {selected ? selected.name : t("sendTemplate")} + + {selected && ( + + {selected.category} + + )} +
+ {selected ? t("fillPlaceholders") : t("pickTemplate")}
- {!selected ? ( -
- {loading ? ( -
- + {/* Body */} +
+ {/* ── Step 1: Template list ── */} + {!selected ? ( +
+
+ setSearch(e.target.value)} + placeholder="Search templates…" + className="h-8 text-sm" + />
- ) : templates.length === 0 ? ( -
-

{t("noApprovedTemplates")}

-

- {t("noApprovedTemplatesHint")} -

+
+ {loading ? ( +
+ +
+ ) : filteredTemplates.length === 0 ? ( +
+

+ {search ? "No templates match your search" : t("noApprovedTemplates")} +

+ {!search && ( +

+ {t("noApprovedTemplatesHint")} +

+ )} +
+ ) : ( + filteredTemplates.map((tpl) => ( + + )) + )}
- ) : ( - templates.map((t) => ( - +
+
+ ) : ( + /* ── Step 2: Two-column config ── */ +
+ {/* Left column: Live preview */} +
+
+ + Live Preview + +
+
+ {/* WhatsApp-style chat bubble */} +
+ {/* Chat header bar */} +
+
+ WA +
+
+
Business
+
WhatsApp
+
+
+ {/* Message bubble */} +
+
+ {/* Media header preview */} + {selected.header_type === "image" && ( +
+ {previewImageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + Header + ) : ( +
+ + No image set +
+ )} +
+ )} + {selected.header_type === "video" && ( +
+ {(mediaSourceType === "upload" && mediaUploadUrl) || (mediaSourceType === "static" && mediaLink) ? ( +
+
+ ) : ( +
+
+ )} +
+ )} + {selected.header_type === "document" && ( +
+ + + {mediaFileName || + ((mediaSourceType === "static" && mediaLink) ? "Document ready" : + templateHasDefaultMedia ? "Default document" : "No document set")} + +
+ )} + {/* Text header */} + {selected.header_type === "text" && previewHeader && ( +
+ {previewHeader} +
)} + {/* Body text */} +
+ {previewBody || Body text preview} +
+ {/* Footer */} + {selected.footer_text && ( +
+ {selected.footer_text} +
+ )} + {/* Timestamp */} +
+ {new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} +
-

- {t.body_text} -

+ {/* Buttons */} + {hasButtons && ( +
+ {selected.buttons!.map((btn, idx) => ( +
+ {btn.text} +
+ ))} +
+ )}
-
- - )) - )} -
- ) : ( -
-
-

{t("preview")}

-

- {renderBodyPreview(selected.body_text, params)} -

- {selected.footer_text && ( -

- {selected.footer_text} -

- )} -
- {slots && slots.headerVarCount > 0 && ( -
- - setHeaderText(e.target.value)} - placeholder={t("headerValuePlaceholder")} - className="border-border bg-muted text-foreground placeholder:text-muted-foreground" - /> -
- )} - {slots?.bodyVars.map((v, i) => ( -
- - { - const next = [...params]; - next[i] = e.target.value; - setParams(next); - }} - placeholder={t("bodyValuePlaceholder", { val: `{{${v}}}` })} - className="border-border bg-muted text-foreground placeholder:text-muted-foreground" - /> + {/* Template meta */} +
+
+ {selected.name} + · + {selected.language} +
+
+
- ))} - {slots?.urlButtonSlots.map((slot) => ( -
- - - setButtonParams((prev) => ({ - ...prev, - [slot.index]: e.target.value, - })) - } - placeholder={t("urlSuffixValuePlaceholder")} - className="border-border bg-muted text-foreground placeholder:text-muted-foreground" - /> -

- {t("finalUrl", { url: slot.url.replace(/\{\{1\}\}/g, buttonParams[slot.index] || "{{1}}") })} -

+ + {/* Right column: Input tabs */} +
+ {/* Tab nav */} +
+ setActiveTab("map")}> + Map Variables + + hasMediaHeader && setActiveTab("media")} + title={!hasMediaHeader ? "This template has no media header" : "Configure media"} + > + Media {hasMediaHeader && ( + + {mediaProvided ? "✓" : "!"} + + )} + + hasButtons && setActiveTab("advance")} + title={!hasButtons ? "This template has no buttons" : "Configure button params"} + > + Buttons {hasButtons && ( + ({selected!.buttons!.length}) + )} + +
+ + {/* Tab content */} +
+ {/* ── Map Tab ── */} + {activeTab === "map" && ( +
+ {slots && slots.headerVarCount === 0 && slots.bodyVars.length === 0 && ( +
+

This template has no body variables.

+

+ {hasMediaHeader + ? "Switch to the Media tab to set a header image/video/document." + : "You can send it directly."} +

+
+ )} + + {/* Header text var */} + {slots && slots.headerVarCount > 0 && ( +
+ + setHeaderText(e.target.value)} + placeholder={t("headerValuePlaceholder") || "Value for the header variable"} + className="" + /> +
+ )} + + {/* Body vars */} + {slots?.bodyVars.map((v, i) => ( +
+ + { + const next = [...bodyParams]; + next[i] = e.target.value; + setBodyParams(next); + }} + placeholder={t("bodyValuePlaceholder", { val: `{{${v}}}` }) || `Value for {{${v}}}`} + className="" + /> +
+ ))} +
+ )} + + {/* ── Media Tab ── */} + {activeTab === "media" && hasMediaHeader && ( +
+

+ {templateHasDefaultMedia + ? "This template already has a default media file. You can optionally override it with a different file for this send." + : "This template requires a media file. Please provide one below."} +

+ + {/* Source type */} +
+ +
+ {(["static", "upload"] as const).map((type) => ( + + ))} +
+
+ + {/* Static URL */} + {mediaSourceType === "static" && ( +
+ + setMediaLink(e.target.value)} + placeholder={ + selected.header_type === "video" + ? "https://example.com/video.mp4" + : selected.header_type === "document" + ? "https://example.com/document.pdf" + : "https://example.com/image.jpg" + } + className="" + /> +

+ Must be a publicly accessible HTTPS URL. +

+
+ )} + + {/* Upload */} + {mediaSourceType === "upload" && ( +
+ + {mediaUploadUrl ? ( +
+ + + {mediaFileName || mediaUploadUrl} + + +
+ ) : ( + + )} + { + const f = e.target.files?.[0]; + if (f) void handleMediaUpload(f); + e.target.value = ""; + }} + /> +

+ {selected.header_type === "video" + ? "Allowed: .mp4 · Max 10 MB" + : selected.header_type === "document" + ? "Allowed: .pdf · Max 10 MB" + : "Allowed: .jpeg, .jpg, .png · Max 5 MB"} +

+
+ )} +
+ )} + + {/* ── Advance / Buttons Tab ── */} + {activeTab === "advance" && hasButtons && ( +
+

+ URL buttons with a {"{{1}}"} variable + require a suffix value. Quick-reply and phone buttons don't need input. +

+ {selected!.buttons!.map((btn, idx) => { + const needsParam = + btn.type === "URL" && + extractVariableIndices(btn.url).length > 0; + return ( +
+
+ + {`Button ${idx + 1}: "${btn.text}"`} + + + {btn.type} + +
+ {needsParam ? ( + <> +

+ Base URL: {btn.url} +

+ + setButtonParams((prev) => ({ + ...prev, + [idx]: e.target.value, + })) + } + placeholder={t("urlSuffixValuePlaceholder") || "URL suffix value for {{1}}"} + className="text-xs h-8" + /> + {buttonParams[idx] && ( +

+ Final: {btn.url.replace(/\{\{1\}\}/g, buttonParams[idx])} +

+ )} + + ) : ( +

+ No input required for this button type. +

+ )} +
+ ); + })} +
+ )} +
+ + {/* Footer */} +
+
+ {!canConfirm && selected && ( + + {slots?.bodyVars.some((_, i) => !(bodyParams[i] ?? "").trim()) + ? "Fill in all body variables to send." + : slots!.headerVarCount > 0 && !headerText.trim() + ? "Header variable required." + : !mediaProvided + ? "Media file required — go to the Media tab." + : slots!.urlButtonSlots.some((s) => !(buttonParams[s.index] ?? "").trim()) + ? "Button URL suffix required — go to the Buttons tab." + : ""} + + )} +
+ +
- ))} -
- )} - - - {selected ? ( - <> - - - - ) : ( - +
)} - +
); } + +// ─── Small helpers ──────────────────────────────────────────────────────────── + +function TabButton({ + active, + disabled, + onClick, + children, + title, +}: { + active: boolean; + disabled?: boolean; + onClick: () => void; + children: React.ReactNode; + title?: string; +}) { + return ( + + ); +} diff --git a/src/lib/whatsapp/send-message.ts b/src/lib/whatsapp/send-message.ts index 5b5ca32dd0..b4075e425d 100644 --- a/src/lib/whatsapp/send-message.ts +++ b/src/lib/whatsapp/send-message.ts @@ -455,7 +455,7 @@ export async function sendMessageToConversation( sender_type: 'agent', content_type: messageType, content_text: interactiveBody ?? contentText ?? null, - media_url: mediaUrl || null, + media_url: mediaUrl || ((templateMessageParams as Record)?.headerMediaUrl as string | undefined) || null, template_name: templateName || null, interactive_payload: messageType === 'interactive' ? interactivePayload : null, diff --git a/src/lib/whatsapp/template-header-handle.test.ts b/src/lib/whatsapp/template-header-handle.test.ts index f73af6dd34..2d20630e0e 100644 --- a/src/lib/whatsapp/template-header-handle.test.ts +++ b/src/lib/whatsapp/template-header-handle.test.ts @@ -53,10 +53,6 @@ describe('ensureImageHeaderHandle', () => { expect(p.header_handle).toBe('existing'); }); - it('throws an actionable error when META_APP_ID is unset', async () => { - const p = payload(); - await expect(ensureImageHeaderHandle(p, 'tok')).rejects.toThrow(/META_APP_ID/); - }); it('derives + sets header_handle from a valid image URL', async () => { vi.stubEnv('META_APP_ID', 'app-1'); diff --git a/src/lib/whatsapp/template-send-builder.ts b/src/lib/whatsapp/template-send-builder.ts index 8e72237f67..808341be51 100644 --- a/src/lib/whatsapp/template-send-builder.ts +++ b/src/lib/whatsapp/template-send-builder.ts @@ -63,16 +63,16 @@ export type MetaSendComponent = type MetaSendParameter = | { type: 'text'; text: string } - | { type: 'image'; image: { link?: string; id?: string } } - | { type: 'video'; video: { link?: string; id?: string } } - | { type: 'document'; document: { link?: string; id?: string } } + | { type: 'image'; image: { link?: string; id?: string | number } } + | { type: 'video'; video: { link?: string; id?: string | number } } + | { type: 'document'; document: { link?: string; id?: string | number } } | { type: 'coupon_code'; coupon_code: string } | { type: 'payload'; payload: string }; function buildHeaderComponent( template: MessageTemplate, params: SendTimeParams, -): MetaSendComponent | null { + ): MetaSendComponent | null { const headerType = template.header_type; if (!headerType) return null; @@ -103,14 +103,26 @@ function buildHeaderComponent( // sample (`example.header_handle`); it is NOT a reusable send-time // media id, and passing it as `{ id }` makes Meta reject the send. Only // an explicit `headerMediaId` (a real /media upload id) is honored. - const link = params.headerMediaUrl ?? template.header_media_url; - const id = params.headerMediaId; - if (!link && !id) { - throw new Error( - `${headerType} header requires a media link or id at send time — set header_media_url on the template or pass headerMediaUrl/headerMediaId.`, - ); + let mediaPayload: { link?: string; id?: string | number } = {}; + + if (params.headerMediaId) { + const id = params.headerMediaId; + if (typeof id === 'string' && /^\d+$/.test(id)) { + mediaPayload = { id: parseInt(id, 10) }; + } else { + mediaPayload = { id }; + } + } else { + const link = params.headerMediaUrl ?? template.header_media_url; + if (link) { + mediaPayload = { link }; + } else { + throw new Error( + `${headerType} header requires a media link or id at send time — set header_media_url on the template or pass headerMediaUrl/headerMediaId.`, + ); + } } - const mediaPayload: { link?: string; id?: string } = id ? { id } : { link }; + return { type: 'header', parameters: [