feat(inbox): wire up the template picker dialog

handleOpenTemplates flipped templateModalOpen but nothing rendered when
true, so both composer template buttons were dead. Add a TemplatePicker
dialog that lists Approved templates, prompts for any {{N}} variables,
then sends through the existing /api/whatsapp/send route with
message_type=template. The rendered body is stored as content_text so
the thread bubble and conversation preview show the actual message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-05-18 15:25:45 +03:00
parent 1e6d70a878
commit 2a86781c85
No known key found for this signature in database
2 changed files with 337 additions and 2 deletions

View File

@ -3,7 +3,13 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { createClient } from "@/lib/supabase/client";
import { cn } from "@/lib/utils";
import type { Conversation, Message, Contact, ConversationStatus } from "@/types";
import type {
Conversation,
Message,
Contact,
ConversationStatus,
MessageTemplate,
} from "@/types";
import {
MessageSquare,
ChevronDown,
@ -23,8 +29,16 @@ import {
import { ScrollArea } from "@/components/ui/scroll-area";
import { MessageBubble } from "./message-bubble";
import { MessageComposer } from "./message-composer";
import { TemplatePicker } from "./template-picker";
import { toast } from "sonner";
function renderTemplateBody(body: string, params: string[]): string {
return body.replace(/\{\{(\d+)\}\}/g, (_, raw) => {
const idx = Number(raw) - 1;
return params[idx] ?? `{{${raw}}}`;
});
}
interface MessageThreadProps {
conversation: Conversation | null;
contact: Contact | null;
@ -263,9 +277,61 @@ export function MessageThread({
const handleOpenTemplates = useCallback(() => {
setTemplateModalOpen(true);
// Template modal implementation would go here
}, []);
const handleSendTemplate = useCallback(
async (template: MessageTemplate, params: string[]) => {
if (!conversation) return;
const renderedBody = renderTemplateBody(template.body_text, params);
const tempId = `temp-${Date.now()}`;
const optimisticMsg: Message = {
id: tempId,
conversation_id: conversation.id,
sender_type: "agent",
content_type: "template",
content_text: renderedBody,
template_name: template.name,
status: "sending",
created_at: new Date().toISOString(),
};
onNewMessage(optimisticMsg);
try {
const res = await fetch("/api/whatsapp/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
conversation_id: conversation.id,
message_type: "template",
template_name: template.name,
template_params: params,
content_text: renderedBody,
}),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
const reason = payload?.error || `HTTP ${res.status}`;
console.error("Failed to send template:", reason);
toast.error(`Failed to send template: ${reason}`);
onUpdateMessage(tempId, { status: "failed" });
return;
}
onUpdateMessage(tempId, { status: "sent" });
} catch (err) {
console.error("Failed to send template:", err);
const reason = err instanceof Error ? err.message : "network error";
toast.error(`Failed to send template: ${reason}`);
onUpdateMessage(tempId, { status: "failed" });
}
},
[conversation, onNewMessage, onUpdateMessage],
);
// Empty state
if (!conversation || !contact) {
return (
@ -407,6 +473,12 @@ export function MessageThread({
onSend={handleSend}
onOpenTemplates={handleOpenTemplates}
/>
<TemplatePicker
open={templateModalOpen}
onOpenChange={setTemplateModalOpen}
onSelect={handleSendTemplate}
/>
</div>
);
}

View File

@ -0,0 +1,263 @@
"use client";
import { useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import type { MessageTemplate } from "@/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import {
ArrowLeft,
ChevronRight,
LayoutTemplate,
Loader2,
} from "lucide-react";
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<number>();
for (const m of body.matchAll(/\{\{(\d+)\}\}/g)) {
ids.add(Number(m[1]));
}
return Array.from(ids).sort((a, b) => a - b);
}
function renderBodyPreview(body: string, params: string[]): string {
return body.replace(/\{\{(\d+)\}\}/g, (_, raw) => {
const idx = Number(raw) - 1;
const value = params[idx];
return value && value.trim().length > 0 ? value : `{{${raw}}}`;
});
}
export function TemplatePicker({
open,
onOpenChange,
onSelect,
}: TemplatePickerProps) {
const [templates, setTemplates] = useState<MessageTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<MessageTemplate | null>(null);
const [params, setParams] = useState<string[]>([]);
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);
}
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("*")
.eq("user_id", user.id)
.eq("status", "Approved")
.order("created_at", { ascending: false });
if (cancelled) return;
if (error) {
console.error("Failed to fetch templates:", error);
setTemplates([]);
} else {
setTemplates((data as MessageTemplate[]) ?? []);
}
setLoading(false);
})();
return () => {
cancelled = true;
};
}, [open]);
function handleOpenChange(next: boolean) {
if (!next) {
setSelected(null);
setParams([]);
}
onOpenChange(next);
}
function pickTemplate(template: MessageTemplate) {
const vars = extractVariables(template.body_text);
if (vars.length === 0) {
onSelect(template, []);
handleOpenChange(false);
return;
}
setSelected(template);
setParams(new Array(vars.length).fill(""));
}
function confirm() {
if (!selected) return;
onSelect(selected, params);
handleOpenChange(false);
}
const variables = selected ? extractVariables(selected.body_text) : [];
const canConfirm =
!!selected &&
variables.every((_, i) => (params[i] ?? "").trim().length > 0);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="border-slate-700 bg-slate-900 sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white">
<LayoutTemplate className="h-4 w-4 text-violet-400" />
{selected ? selected.name : "Send template"}
</DialogTitle>
<DialogDescription className="text-slate-400">
{selected
? "Fill in the placeholders to render this template. Meta requires every variable to be set."
: "Pick an approved WhatsApp template to send to this contact."}
</DialogDescription>
</DialogHeader>
{!selected ? (
<div className="max-h-[60vh] space-y-2 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-violet-400" />
</div>
) : templates.length === 0 ? (
<div className="rounded-md border border-slate-800 bg-slate-950/50 p-6 text-center">
<p className="text-sm text-slate-300">No approved templates</p>
<p className="mt-1 text-xs text-slate-500">
Approve a template in Meta WhatsApp Manager, then sync it
from Settings → Templates.
</p>
</div>
) : (
templates.map((t) => (
<button
key={t.id}
type="button"
onClick={() => pickTemplate(t)}
className="w-full rounded-md border border-slate-800 bg-slate-950/50 p-3 text-left transition-colors hover:border-violet-500/40 hover:bg-slate-900"
>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate text-sm font-medium text-white">
{t.name}
</p>
<Badge className="border border-violet-600/30 bg-violet-600/20 text-[10px] text-violet-400">
{t.category}
</Badge>
{t.language && (
<span className="text-[10px] uppercase text-slate-500">
{t.language}
</span>
)}
</div>
<p className="mt-1 line-clamp-2 text-xs text-slate-400">
{t.body_text}
</p>
</div>
<ChevronRight className="h-4 w-4 flex-shrink-0 text-slate-500" />
</div>
</button>
))
)}
</div>
) : (
<div className="space-y-3">
<div className="rounded-md border border-slate-800 bg-slate-950/50 p-3">
<p className="mb-1 text-xs text-slate-400">Preview</p>
<p className="whitespace-pre-wrap text-sm text-slate-200">
{renderBodyPreview(selected.body_text, params)}
</p>
{selected.footer_text && (
<p className="mt-2 text-xs italic text-slate-500">
{selected.footer_text}
</p>
)}
</div>
{variables.map((v, i) => (
<div key={v} className="space-y-1">
<Label className="text-xs text-slate-300">{`Variable {{${v}}}`}</Label>
<Input
value={params[i] ?? ""}
onChange={(e) => {
const next = [...params];
next[i] = e.target.value;
setParams(next);
}}
placeholder={`Value for {{${v}}}`}
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500"
/>
</div>
))}
</div>
)}
<DialogFooter className="gap-2">
{selected ? (
<>
<Button
variant="outline"
onClick={() => {
setSelected(null);
setParams([]);
}}
className="border-slate-700 text-slate-300 hover:bg-slate-800"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<Button
disabled={!canConfirm}
onClick={confirm}
className="bg-violet-600 text-white hover:bg-violet-700 disabled:opacity-50"
>
Send template
</Button>
</>
) : (
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
className="border-slate-700 text-slate-300 hover:bg-slate-800"
>
Cancel
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}