diff --git a/src/components/inbox/message-thread.tsx b/src/components/inbox/message-thread.tsx index c3b8671..85044c7 100644 --- a/src/components/inbox/message-thread.tsx +++ b/src/components/inbox/message-thread.tsx @@ -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} /> + + ); } diff --git a/src/components/inbox/template-picker.tsx b/src/components/inbox/template-picker.tsx new file mode 100644 index 0000000..5093ab0 --- /dev/null +++ b/src/components/inbox/template-picker.tsx @@ -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(); + 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([]); + const [loading, setLoading] = useState(true); + const [selected, setSelected] = useState(null); + const [params, setParams] = useState([]); + + 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 ( + + + + + + {selected ? selected.name : "Send template"} + + + {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."} + + + + {!selected ? ( +
+ {loading ? ( +
+ +
+ ) : templates.length === 0 ? ( +
+

No approved templates

+

+ Approve a template in Meta WhatsApp Manager, then sync it + from Settings → Templates. +

+
+ ) : ( + templates.map((t) => ( + + )) + )} +
+ ) : ( +
+
+

Preview

+

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

+ {selected.footer_text && ( +

+ {selected.footer_text} +

+ )} +
+ {variables.map((v, i) => ( +
+ + { + 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" + /> +
+ ))} +
+ )} + + + {selected ? ( + <> + + + + ) : ( + + )} + +
+
+ ); +}