From 11475840e18e68a9933f9ceb938d538ec11c4d20 Mon Sep 17 00:00:00 2001
From: Arnas Donauskas
Date: Fri, 29 May 2026 16:38:15 +0200
Subject: [PATCH] feat(templates): media headers + URL/copy-code button params
on send
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes the last hole left by PR 3 — until now, sendTemplateMessage
only emitted a body component, so templates with image/video/
document headers or URL-with-{{1}} buttons failed at send time
with Meta's "missing component" 400. After this PR, every
APPROVED template that wacrm can submit is also fully sendable.
New helper
----------
src/lib/whatsapp/template-send-builder.ts builds the per-send
Meta components array from a MessageTemplate row + caller-supplied
SendTimeParams. It's a different beast from template-components.ts
(which builds the SUBMISSION shape) because the send-time payload
has a different schema: header type-specific parameter objects,
button components with sub_type + index, body_text without a 2D
example wrapper, etc.
Auto-fills as much as possible from the template:
* static IMAGE/VIDEO/DOCUMENT headers ride along using the row's
header_media_url (or header_handle when present — preferred)
* URL buttons without {{1}} need no per-send payload
* QUICK_REPLY / PHONE_NUMBER buttons need nothing
* COPY_CODE buttons fall back to the template's example so the
most common "static promo code" case Just Works without UI
Variable-bearing fields require per-send values:
* TEXT header with {{1}} → messageParams.headerText
* Body variables → messageParams.body[]
* URL button with {{1}} → messageParams.buttonParams[i]
* COPY_CODE override → messageParams.buttonParams[i]
19 unit tests assert the wire shape per scenario — header types,
URL button at correct index when QR buttons precede it, example
fallback, and the throw-with-specific-message paths.
sendTemplateMessage extension
-----------------------------
src/lib/whatsapp/meta-api.ts gains optional `template` +
`messageParams` args. When `template` is provided, the full
components array is built via the new helper. Legacy `params:
string[]` (body only) path is preserved as-is — old callers
behave identically. When both shapes are present, structured
messageParams.body wins.
Route + UI wiring
-----------------
POST /api/whatsapp/send and /api/whatsapp/broadcast now load the
template row by (user_id, name, language) and pass it through.
Broadcast loads once per request (not per recipient — avoids N+1).
Both accept `template_message_params` / per-recipient
`messageParams` from the client.
src/components/inbox/template-picker.tsx scans the selected
template for variable-bearing fields and renders an input per
need: header text variable, body variables (existing), URL
button suffixes. Per-button preview shows the final URL with
the substitution so the user sees what the recipient will see.
The callback shape changes to `(template, { body, headerText?,
buttonParams? })`; message-thread.tsx updated accordingly.
Out of scope
------------
Broadcast composer doesn't yet collect per-recipient header /
button params — the legacy body-only flow continues to work for
broadcasts. Templates with header / URL-button variables broadcast
fine if every recipient gets the same value via API call shape;
the UI for varying it per recipient lands in a follow-up.
This is the last PR of the Issue #147 overhaul sequence.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/app/api/whatsapp/broadcast/route.ts | 22 ++
src/app/api/whatsapp/send/route.ts | 26 ++
src/components/inbox/message-thread.tsx | 23 +-
src/components/inbox/template-picker.tsx | 148 +++++++---
src/lib/whatsapp/meta-api.ts | 57 +++-
.../whatsapp/template-send-builder.test.ts | 264 ++++++++++++++++++
src/lib/whatsapp/template-send-builder.ts | 230 +++++++++++++++
7 files changed, 729 insertions(+), 41 deletions(-)
create mode 100644 src/lib/whatsapp/template-send-builder.test.ts
create mode 100644 src/lib/whatsapp/template-send-builder.ts
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 (
)}
- {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({
<>