diff --git a/src/app/api/whatsapp/broadcast/route.ts b/src/app/api/whatsapp/broadcast/route.ts index b987c84..11c1ade 100644 --- a/src/app/api/whatsapp/broadcast/route.ts +++ b/src/app/api/whatsapp/broadcast/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' import { sendTemplateMessage } from '@/lib/whatsapp/meta-api' import { decrypt } from '@/lib/whatsapp/encryption' +import type { SendTimeParams } from '@/lib/whatsapp/template-send-builder' import { sanitizePhoneForMeta, isValidE164, @@ -45,7 +46,15 @@ interface BroadcastResult { */ interface NewRecipient { phone: string + /** Body variable values, one per {{N}}. Legacy field. */ params?: string[] + /** + * Structured per-send values (header text variable, media URL + * override, URL/COPY_CODE button values). When set, takes + * precedence over `params` for the body too — see + * sendTemplateMessage for the merge rules. + */ + messageParams?: SendTimeParams } export async function POST(request: Request) { @@ -125,6 +134,17 @@ export async function POST(request: Request) { const accessToken = decrypt(config.access_token) + // Load the template row once so sendTemplateMessage can build + // header + button components on each iteration. Loading inside + // the loop would N+1 against Supabase for every recipient. + const { data: templateRow } = await supabase + .from('message_templates') + .select('*') + .eq('user_id', user.id) + .eq('name', template_name) + .eq('language', template_language || 'en_US') + .maybeSingle() + const results: BroadcastResult[] = [] let sentCount = 0 let failedCount = 0 @@ -156,6 +176,8 @@ export async function POST(request: Request) { to: variant, templateName: template_name, language: template_language || 'en_US', + template: templateRow ?? undefined, + messageParams: recipient.messageParams, params: recipient.params ?? [], }) sentMessageId = result.messageId diff --git a/src/app/api/whatsapp/send/route.ts b/src/app/api/whatsapp/send/route.ts index 45fb03b..8a8d8c0 100644 --- a/src/app/api/whatsapp/send/route.ts +++ b/src/app/api/whatsapp/send/route.ts @@ -14,6 +14,7 @@ import { rateLimitResponse, RATE_LIMITS, } from '@/lib/rate-limit' +import type { MessageTemplate } from '@/types' export async function POST(request: Request) { try { @@ -45,7 +46,9 @@ export async function POST(request: Request) { content_text, media_url, template_name, + template_language, template_params, + template_message_params, reply_to_message_id, } = body @@ -177,6 +180,24 @@ export async function POST(request: Request) { let waMessageId = '' let workingPhone = sanitizedPhone + // For template sends, load the row so sendTemplateMessage can + // build header + button components from the template definition. + // Match on (user_id, name, language) — same triple the unique + // index enforces — so multi-language templates work correctly. + // Missing template falls through with `templateRow = null` and + // the legacy body-only path runs. + let templateRow: MessageTemplate | null = null + if (message_type === 'template' && template_name) { + const { data } = await supabase + .from('message_templates') + .select('*') + .eq('user_id', user.id) + .eq('name', template_name) + .eq('language', template_language || 'en_US') + .maybeSingle() + templateRow = (data ?? null) as MessageTemplate | null + } + const attempt = async (phone: string): Promise => { if (message_type === 'template') { const result = await sendTemplateMessage({ @@ -184,6 +205,11 @@ export async function POST(request: Request) { accessToken, to: phone, templateName: template_name, + language: template_language || 'en_US', + template: templateRow ?? undefined, + messageParams: template_message_params ?? undefined, + // Legacy body-only fallback — only consulted when + // messageParams.body isn't set. params: template_params || [], contextMessageId, }) diff --git a/src/components/inbox/message-thread.tsx b/src/components/inbox/message-thread.tsx index 5800ced..a454962 100644 --- a/src/components/inbox/message-thread.tsx +++ b/src/components/inbox/message-thread.tsx @@ -493,10 +493,17 @@ export function MessageThread({ }, []); const handleSendTemplate = useCallback( - async (template: MessageTemplate, params: string[]) => { + async ( + template: MessageTemplate, + values: { + body: string[]; + headerText?: string; + buttonParams?: Record; + }, + ) => { if (!conversation) return; - const renderedBody = renderTemplateBody(template.body_text, params); + const renderedBody = renderTemplateBody(template.body_text, values.body); const tempId = `temp-${Date.now()}`; const optimisticMsg: Message = { @@ -519,7 +526,17 @@ export function MessageThread({ conversation_id: conversation.id, message_type: "template", template_name: template.name, - template_params: params, + template_language: template.language, + // Structured params drive the new send-builder path + // (header media + URL button substitution). Body values + // are mirrored under both shapes so the route can fall + // back if the template row isn't found locally. + template_message_params: { + body: values.body, + headerText: values.headerText, + 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 2e3e6dd..ea639e2 100644 --- a/src/components/inbox/template-picker.tsx +++ b/src/components/inbox/template-picker.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { createClient } from "@/lib/supabase/client"; import type { MessageTemplate } from "@/types"; import { Button } from "@/components/ui/button"; @@ -21,23 +21,18 @@ import { LayoutTemplate, Loader2, } from "lucide-react"; +import { extractVariableIndices } from "@/lib/whatsapp/template-validators"; + +export interface TemplateSendValues { + body: string[]; + headerText?: string; + buttonParams?: Record; +} interface TemplatePickerProps { open: boolean; onOpenChange: (open: boolean) => void; - onSelect: (template: MessageTemplate, params: string[]) => void; -} - -// Meta numbers template placeholders from 1 ({{1}}, {{2}}, …) and the -// indices passed to the Graph API must be contiguous starting at 1. -// We sort + dedupe here so a body using only {{2}} still drives a single -// input slot, and so render-order matches send-order. -function extractVariables(body: string): number[] { - const ids = new Set(); - for (const m of body.matchAll(/\{\{(\d+)\}\}/g)) { - ids.add(Number(m[1])); - } - return Array.from(ids).sort((a, b) => a - b); + onSelect: (template: MessageTemplate, values: TemplateSendValues) => void; } function renderBodyPreview(body: string, params: string[]): string { @@ -48,6 +43,36 @@ function renderBodyPreview(body: string, params: string[]): string { }); } +interface UrlButtonSlot { + index: number; + text: string; + 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[]; +} { + const bodyVars = extractVariableIndices(template.body_text); + const headerVarCount = + template.header_type === "text" && template.header_content + ? extractVariableIndices(template.header_content).length + : 0; + 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 }; +} + export function TemplatePicker({ open, onOpenChange, @@ -57,6 +82,8 @@ export function TemplatePicker({ const [loading, setLoading] = useState(true); const [selected, setSelected] = useState(null); const [params, setParams] = useState([]); + const [headerText, setHeaderText] = useState(""); + const [buttonParams, setButtonParams] = useState>({}); useEffect(() => { if (!open) return; @@ -77,9 +104,6 @@ export function TemplatePicker({ return; } - // Only Approved templates are sendable through Meta — anything else - // would 400 on the send route. Hide them rather than letting the - // user pick a template that will be rejected. const { data, error } = await supabase .from("message_templates") .select("*") @@ -102,35 +126,60 @@ export function TemplatePicker({ }; }, [open]); + function resetSelection() { + setSelected(null); + setParams([]); + setHeaderText(""); + setButtonParams({}); + } + function handleOpenChange(next: boolean) { - if (!next) { - setSelected(null); - setParams([]); - } + if (!next) resetSelection(); onOpenChange(next); } function pickTemplate(template: MessageTemplate) { - const vars = extractVariables(template.body_text); - if (vars.length === 0) { - onSelect(template, []); + 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(vars.length).fill("")); + setParams(new Array(slots.bodyVars.length).fill("")); + setHeaderText(""); + setButtonParams({}); } function confirm() { if (!selected) return; - onSelect(selected, params); + const values: TemplateSendValues = { body: params }; + if (headerText.trim()) values.headerText = headerText.trim(); + if (Object.keys(buttonParams).length > 0) { + values.buttonParams = Object.fromEntries( + Object.entries(buttonParams).map(([k, v]) => [Number(k), v.trim()]), + ); + } + onSelect(selected, values); handleOpenChange(false); } - const variables = selected ? extractVariables(selected.body_text) : []; + const slots = useMemo( + () => (selected ? collectVariableSlots(selected) : null), + [selected], + ); const canConfirm = !!selected && - variables.every((_, i) => (params[i] ?? "").trim().length > 0); + !!slots && + slots.bodyVars.every((_, i) => (params[i] ?? "").trim().length > 0) && + (slots.headerVarCount === 0 || headerText.trim().length > 0) && + slots.urlButtonSlots.every( + (s) => (buttonParams[s.index] ?? "").trim().length > 0, + ); return ( @@ -207,9 +256,22 @@ export function TemplatePicker({

)} - {variables.map((v, i) => ( + {slots && slots.headerVarCount > 0 && ( +
+ + setHeaderText(e.target.value)} + placeholder="Value for the header variable" + className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500" + /> +
+ )} + {slots?.bodyVars.map((v, i) => (
- + { @@ -222,6 +284,27 @@ export function TemplatePicker({ />
))} + {slots?.urlButtonSlots.map((slot) => ( +
+ + + setButtonParams((prev) => ({ + ...prev, + [slot.index]: e.target.value, + })) + } + placeholder="URL suffix value" + className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500" + /> +

+ Final URL: {slot.url.replace(/\{\{1\}\}/g, buttonParams[slot.index] || "{{1}}")} +

+
+ ))} )} @@ -230,10 +313,7 @@ export function TemplatePicker({ <>