From 78b42dfba97e8ceef9b4315c423d3583ec62592f Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Wed, 8 Jul 2026 19:02:20 +0300 Subject: [PATCH] feat: WhatsApp interactive messages (reply buttons + lists) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add full support for WhatsApp Cloud API interactive messages across the inbox, automations, and a new quick-replies feature. Extends the interactive plumbing that previously existed only for the Flows engine. Sending - send-message core handles `interactive` (reply buttons ≤3, list ≤10 rows) via the existing Meta senders; validated server-side before send. - Inbox composer gains a "+" menu → builder dialog with live WhatsApp-style preview + Meta char limits (button 20, list row 24, body 1024). - Automation "Send Buttons" / "Send List" steps in the builder. - Public v1 /messages accepts type=interactive. Storage / round-trip - migration 035: messages.interactive_payload JSONB + quick_replies table. - Outbound interactive persists the structured payload (composer, automations, and Flows) so the thread re-renders the buttons/rows. Receiving - Bubble renders outbound interactive via the shared preview; inbound taps (button_reply/list_reply) keep the "chosen option" affordance. Automations - New `interactive_reply` trigger matches on tapped button/list IDs (exact), enabling chained multi-step menus. Quick replies - New account-scoped quick_replies (text or interactive snippets), managed in Settings and insertable from the composer; "Save as quick reply" in the builder. Shared core in src/lib/whatsapp/interactive.ts + src/components/interactive/. Includes unit tests for the validator, send-param validation, the interactive_reply trigger match, and the new automation step/trigger validation. typecheck + lint clean; full suite green. Co-Authored-By: Claude Opus 4.8 --- messages/en.json | 20 +- src/app/(dashboard)/settings/page.tsx | 2 + src/app/api/quick-replies/[id]/route.ts | 103 ++++ src/app/api/quick-replies/route.ts | 80 +++ src/app/api/v1/messages/route.ts | 10 + src/app/api/whatsapp/send/route.ts | 3 + src/app/api/whatsapp/webhook/route.ts | 11 + .../automations/automation-builder.tsx | 94 ++++ src/components/inbox/message-bubble.tsx | 43 +- src/components/inbox/message-composer.tsx | 177 +++++++ src/components/inbox/message-thread.tsx | 55 ++ src/components/inbox/quick-reply-picker.tsx | 104 ++++ .../interactive/interactive-builder.tsx | 468 ++++++++++++++++++ .../interactive/interactive-preview.tsx | 75 +++ .../settings/quick-replies-manager.tsx | 265 ++++++++++ src/components/settings/settings-sections.ts | 3 + src/lib/automations/engine.test.ts | 44 +- src/lib/automations/engine.ts | 65 ++- src/lib/automations/meta-send.ts | 48 ++ src/lib/automations/trigger-meta.ts | 4 + src/lib/automations/validate.test.ts | 46 ++ src/lib/automations/validate.ts | 24 + src/lib/flows/meta-send.ts | 24 +- src/lib/whatsapp/interactive.test.ts | 142 ++++++ src/lib/whatsapp/interactive.ts | 239 +++++++++ src/lib/whatsapp/meta-api.ts | 8 + src/lib/whatsapp/send-message.test.ts | 42 ++ src/lib/whatsapp/send-message.ts | 77 ++- src/types/index.ts | 62 ++- .../migrations/035_interactive_messages.sql | 61 +++ 30 files changed, 2365 insertions(+), 34 deletions(-) create mode 100644 src/app/api/quick-replies/[id]/route.ts create mode 100644 src/app/api/quick-replies/route.ts create mode 100644 src/components/inbox/quick-reply-picker.tsx create mode 100644 src/components/interactive/interactive-builder.tsx create mode 100644 src/components/interactive/interactive-preview.tsx create mode 100644 src/components/settings/quick-replies-manager.tsx create mode 100644 src/lib/whatsapp/interactive.test.ts create mode 100644 src/lib/whatsapp/interactive.ts create mode 100644 supabase/migrations/035_interactive_messages.sql diff --git a/messages/en.json b/messages/en.json index 1e7b98f..6817727 100644 --- a/messages/en.json +++ b/messages/en.json @@ -193,7 +193,16 @@ "sendTemplate": "Send template", "draftWithAI": "Draft a reply with AI", "addCaption": "Add a caption…", - "removeAttachment": "Remove attachment" + "removeAttachment": "Remove attachment", + "moreActions": "More", + "interactiveMessage": "Interactive message", + "quickReplies": "Quick replies", + "quickRepliesEmpty": "No quick replies yet. Add some in Settings → Quick replies.", + "saveAsQuickReply": "Save as quick reply", + "send": "Send", + "quickReplyNamePrompt": "Name this quick reply:", + "quickReplySaved": "Saved as a quick reply.", + "quickReplySaveError": "Couldn't save the quick reply." }, "bubble": { "photo": "Photo", @@ -814,6 +823,8 @@ "builder": { "steps": { "send_message": "Send Message", + "send_buttons": "Send Buttons", + "send_list": "Send List", "send_template": "Send Template", "add_tag": "Add Tag", "remove_tag": "Remove Tag", @@ -838,6 +849,10 @@ "label": "Keyword Match", "hint": "Message contains specific keyword(s)" }, + "interactive_reply": { + "label": "Button / List Reply", + "hint": "Customer taps a button or list row whose id matches — chain menus across automations" + }, "new_contact_created": { "label": "New Contact Created", "hint": "When a contact is auto-created from an incoming message" @@ -859,6 +874,9 @@ "triggerType": "Trigger type", "keywords": "Keywords", "keywordsHint": "Comma-separated list (e.g. hello, help, buy)", + "replyIds": "Reply IDs", + "replyIdsHint": "yes, no, more_info", + "replyIdsHelp": "Comma-separated button/list-row ids to match, exact. Use the same ids you set on a Send Buttons / Send List step.", "schedule": "Schedule", "scheduleHint": "Cron expression (e.g. 0 9 * * 1-5)", "active": "Active", diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 2954666..97d192b 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -13,6 +13,7 @@ import { SecurityPanel } from '@/components/settings/security-panel'; import { AppearancePanel } from '@/components/settings/appearance-panel'; import { WhatsAppConfig } from '@/components/settings/whatsapp-config'; import { TemplateManager } from '@/components/settings/template-manager'; +import { QuickRepliesManager } from '@/components/settings/quick-replies-manager'; import { FieldsAndTagsPanel } from '@/components/settings/fields-and-tags-panel'; import { DealsSettings } from '@/components/settings/deals-settings'; import { MembersTab } from '@/components/settings/members-tab'; @@ -59,6 +60,7 @@ export default function SettingsPage() { appearance: , whatsapp: , templates: , + 'quick-replies': , fields: , deals: , members: , diff --git a/src/app/api/quick-replies/[id]/route.ts b/src/app/api/quick-replies/[id]/route.ts new file mode 100644 index 0000000..d29258f --- /dev/null +++ b/src/app/api/quick-replies/[id]/route.ts @@ -0,0 +1,103 @@ +import { NextResponse } from 'next/server' +import { requireRole, toErrorResponse } from '@/lib/auth/account' +import { supabaseAdmin } from '@/lib/automations/admin-client' +import { validateInteractivePayload } from '@/lib/whatsapp/interactive' + +// Update / delete a single quick reply. Quick replies are account- +// shared, so every mutation is scoped by `account_id` (the service-role +// client bypasses the agent-gated RLS, so both the role check and the +// account scope are enforced here). + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + let ctx + try { + ctx = await requireRole('agent') + } catch (err) { + return toErrorResponse(err) + } + + const body = await request.json().catch(() => null) + if (!body) return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) + + const update: Record = {} + if (typeof body.title === 'string') { + const title = body.title.trim() + if (!title) return NextResponse.json({ error: 'title cannot be empty' }, { status: 400 }) + update.title = title + } + + // When `kind` is supplied (e.g. the editor flips Text ↔ Interactive), it + // drives which content column is authoritative and the other is cleared — + // otherwise a switched row keeps a stale payload the picker mis-routes on. + if ('kind' in body) { + if (body.kind !== 'text' && body.kind !== 'interactive') { + return NextResponse.json({ error: 'kind must be "text" or "interactive"' }, { status: 400 }) + } + update.kind = body.kind + if (body.kind === 'interactive') { + const result = validateInteractivePayload(body.interactive_payload) + if (!result.ok) return NextResponse.json({ error: result.error }, { status: 400 }) + update.interactive_payload = body.interactive_payload + update.content_text = null + } else { + const text = typeof body.content_text === 'string' ? body.content_text : '' + if (!text.trim()) { + return NextResponse.json( + { error: 'content_text is required for text quick replies' }, + { status: 400 }, + ) + } + update.content_text = text + update.interactive_payload = null + } + } else { + // No kind change — allow partial edits of whichever field the row uses. + if ('content_text' in body) update.content_text = body.content_text ?? null + if ('interactive_payload' in body) { + if (body.interactive_payload != null) { + const result = validateInteractivePayload(body.interactive_payload) + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: 400 }) + } + } + update.interactive_payload = body.interactive_payload ?? null + } + } + + if (Object.keys(update).length === 0) { + return NextResponse.json({ ok: true }) + } + + const { error } = await supabaseAdmin() + .from('quick_replies') + .update(update) + .eq('id', id) + .eq('account_id', ctx.accountId) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + return NextResponse.json({ ok: true }) +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + let ctx + try { + ctx = await requireRole('agent') + } catch (err) { + return toErrorResponse(err) + } + + const { error } = await supabaseAdmin() + .from('quick_replies') + .delete() + .eq('id', id) + .eq('account_id', ctx.accountId) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + return NextResponse.json({ ok: true }) +} diff --git a/src/app/api/quick-replies/route.ts b/src/app/api/quick-replies/route.ts new file mode 100644 index 0000000..1e7b697 --- /dev/null +++ b/src/app/api/quick-replies/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from 'next/server' +import { getCurrentAccount, requireRole, toErrorResponse } from '@/lib/auth/account' +import { supabaseAdmin } from '@/lib/automations/admin-client' +import { validateInteractivePayload } from '@/lib/whatsapp/interactive' + +// Quick replies — reusable snippets (plain text or a saved interactive +// message) shared across the account. GET lists; POST creates. Mirrors +// the automations route: RLS-scoped read via the user client, service- +// role write after an explicit role check. + +export async function GET() { + try { + const { supabase } = await getCurrentAccount() + // RLS (quick_replies_select) scopes to the caller's account. + const { data, error } = await supabase + .from('quick_replies') + .select('*') + .order('created_at', { ascending: false }) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + return NextResponse.json({ quick_replies: data ?? [] }) + } catch (err) { + return toErrorResponse(err) + } +} + +export async function POST(request: Request) { + let ctx + try { + ctx = await requireRole('agent') + } catch (err) { + return toErrorResponse(err) + } + + const body = await request.json().catch(() => null) + if (!body) return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) + + const title = typeof body.title === 'string' ? body.title.trim() : '' + const kind = body.kind === 'interactive' ? 'interactive' : 'text' + if (!title) { + return NextResponse.json({ error: 'title is required' }, { status: 400 }) + } + + let content_text: string | null = null + let interactive_payload: unknown = null + + if (kind === 'interactive') { + const result = validateInteractivePayload(body.interactive_payload) + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: 400 }) + } + interactive_payload = body.interactive_payload + } else { + const text = typeof body.content_text === 'string' ? body.content_text : '' + if (!text.trim()) { + return NextResponse.json( + { error: 'content_text is required for text quick replies' }, + { status: 400 }, + ) + } + content_text = text + } + + const { data, error } = await supabaseAdmin() + .from('quick_replies') + .insert({ + account_id: ctx.accountId, + user_id: ctx.userId, + title, + kind, + content_text, + interactive_payload, + }) + .select() + .single() + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + return NextResponse.json({ quick_reply: data }, { status: 201 }) +} diff --git a/src/app/api/v1/messages/route.ts b/src/app/api/v1/messages/route.ts index f10937c..380d45f 100644 --- a/src/app/api/v1/messages/route.ts +++ b/src/app/api/v1/messages/route.ts @@ -39,6 +39,7 @@ import { validateSendMessageParams, SendMessageError, } from '@/lib/whatsapp/send-message'; +import type { InteractiveMessagePayload } from '@/lib/whatsapp/interactive'; export async function POST(request: Request) { try { @@ -79,11 +80,19 @@ export async function POST(request: Request) { // Validate the message shape BEFORE resolveConversationByPhone // finds-or-creates a contact + conversation, so a bad payload 400s // without leaving an orphan contact/conversation behind. + // Validated by `validateSendMessageParams` below; the cast just bridges + // the untyped JSON body to the send-core param type. + const interactivePayload = + body.interactive_payload && typeof body.interactive_payload === 'object' + ? (body.interactive_payload as InteractiveMessagePayload) + : null; + validateSendMessageParams({ messageType: type, contentText: typeof body.text === 'string' ? body.text : null, mediaUrl: typeof body.media_url === 'string' ? body.media_url : null, templateName: typeof template?.name === 'string' ? template.name : null, + interactivePayload, }); // Find-or-create the conversation for this phone, then send. Both @@ -110,6 +119,7 @@ export async function POST(request: Request) { typeof template?.language === 'string' ? template.language : null, templateParams, templateMessageParams, + interactivePayload, replyToMessageId: typeof body.reply_to_message_id === 'string' ? body.reply_to_message_id diff --git a/src/app/api/whatsapp/send/route.ts b/src/app/api/whatsapp/send/route.ts index cd9eac2..085add6 100644 --- a/src/app/api/whatsapp/send/route.ts +++ b/src/app/api/whatsapp/send/route.ts @@ -75,6 +75,7 @@ export async function POST(request: Request) { template_language, template_params, template_message_params, + interactive_payload, reply_to_message_id, } = body @@ -97,6 +98,7 @@ export async function POST(request: Request) { contentText: content_text, mediaUrl: media_url, templateName: template_name, + interactivePayload: interactive_payload, }) } catch (err) { if (err instanceof SendMessageError) { @@ -180,6 +182,7 @@ export async function POST(request: Request) { templateLanguage: template_language, templateParams: template_params, templateMessageParams: template_message_params, + interactivePayload: interactive_payload, replyToMessageId: reply_to_message_id, }) diff --git a/src/app/api/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts index 3e6de50..d1c1472 100644 --- a/src/app/api/whatsapp/webhook/route.ts +++ b/src/app/api/whatsapp/webhook/route.ts @@ -759,11 +759,19 @@ async function processMessage( | 'first_inbound_message' | 'new_message_received' | 'keyword_match' + | 'interactive_reply' )[] = [] // Content-level triggers are suppressed when a flow consumed the // message — see the comment block above. if (!flowConsumed) { automationTriggers.push('new_message_received', 'keyword_match') + // Interactive tap → fire the interactive_reply trigger too (only + // meaningful when a button/list reply actually arrived). Enables + // automation-only chained menus; when a Flow owns the menu it will + // have consumed the reply and this is skipped. + if (interactiveReplyId) { + automationTriggers.push('interactive_reply') + } } // new_contact_created fires only when the webhook just auto-created the // contact row. first_inbound_message fires whenever this is the contact's @@ -781,6 +789,9 @@ async function processMessage( context: { message_text: inboundText, conversation_id: conversation.id, + // Only set on interactive taps; drives the interactive_reply + // trigger's exact-id match. + interactive_reply_id: interactiveReplyId ?? undefined, }, }).catch((err) => console.error('[automations] dispatch failed:', err)) } diff --git a/src/components/automations/automation-builder.tsx b/src/components/automations/automation-builder.tsx index d887b35..9c4b8e0 100644 --- a/src/components/automations/automation-builder.tsx +++ b/src/components/automations/automation-builder.tsx @@ -31,6 +31,8 @@ import { Loader2, ArrowDown, ArrowUp, + MousePointerClick, + List, } from "lucide-react" import { Button } from "@/components/ui/button" @@ -48,10 +50,17 @@ import type { AutomationStepType, AutomationTriggerType, CustomField, + InteractiveMessagePayload, KeywordMatchTriggerConfig, MessageTemplate, Tag as TagRecord, } from "@/types" +import { + InteractiveBuilder, + blankButtonsPayload, + blankListPayload, +} from "@/components/interactive/interactive-builder" +import { interactivePayloadPreviewText } from "@/lib/whatsapp/interactive" import { createClient } from "@/lib/supabase/client" import { cn } from "@/lib/utils" @@ -90,6 +99,8 @@ interface StepMeta { const STEP_META: Record = { send_message: { label: "send_message", icon: MessageSquare, border: "border-l-primary" }, + send_buttons: { label: "send_buttons", icon: MousePointerClick, border: "border-l-primary" }, + send_list: { label: "send_list", icon: List, border: "border-l-primary" }, send_template: { label: "send_template", icon: FileText, border: "border-l-primary" }, add_tag: { label: "add_tag", icon: Tag, border: "border-l-primary" }, remove_tag: { label: "remove_tag", icon: TagIcon, border: "border-l-primary" }, @@ -104,6 +115,8 @@ const STEP_META: Record = { const ADDABLE_STEPS: AutomationStepType[] = [ "send_message", + "send_buttons", + "send_list", "send_template", "add_tag", "remove_tag", @@ -120,6 +133,7 @@ const TRIGGER_OPTIONS: { value: AutomationTriggerType }[] = [ { value: "new_message_received" }, { value: "first_inbound_message" }, { value: "keyword_match" }, + { value: "interactive_reply" }, { value: "new_contact_created" }, { value: "conversation_assigned" }, { value: "tag_added" }, @@ -135,10 +149,26 @@ function cid(): string { ) } +// The send_buttons / send_list step_config IS an InteractiveMessagePayload, +// but step_config is typed generically as Record. These two +// helpers hold the single unavoidable structural cast in one place so a +// payload-shape change has one seam to update instead of four scattered +// `as unknown as` sites. +function toStepConfig(p: InteractiveMessagePayload): Record { + return p as unknown as Record +} +function asInteractive(cfg: Record): InteractiveMessagePayload { + return cfg as unknown as InteractiveMessagePayload +} + function blankConfig(type: AutomationStepType): Record { switch (type) { case "send_message": return { text: "" } + case "send_buttons": + return toStepConfig(blankButtonsPayload()) + case "send_list": + return toStepConfig(blankListPayload()) case "send_template": return { template_name: "", language: "en_US" } case "add_tag": @@ -815,6 +845,9 @@ function TriggerCard({ t={t} /> )} + {type === "interactive_reply" && ( + + )} {type === "tag_added" && (
); } diff --git a/src/components/inbox/message-thread.tsx b/src/components/inbox/message-thread.tsx index 78c3376..6ff3c6a 100644 --- a/src/components/inbox/message-thread.tsx +++ b/src/components/inbox/message-thread.tsx @@ -15,6 +15,7 @@ import type { ConversationStatus, MessageTemplate, Profile, + InteractiveMessagePayload, } from "@/types"; import { MessageSquare, @@ -567,6 +568,59 @@ export function MessageThread({ [conversation, onNewMessage, onUpdateMessage], ); + const handleSendInteractive = useCallback( + async (payload: InteractiveMessagePayload, replyToId?: string) => { + if (!conversation) return; + + const tempId = `temp-${Date.now()}`; + // Optimistic bubble — renders the buttons/list immediately via the + // interactive_payload, same as the persisted row will. + const optimisticMsg: Message = { + id: tempId, + conversation_id: conversation.id, + sender_type: "agent", + content_type: "interactive", + content_text: payload.body, + interactive_payload: payload, + status: "sending", + created_at: new Date().toISOString(), + reply_to_message_id: replyToId, + }; + 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: "interactive", + interactive_payload: payload, + reply_to_message_id: replyToId, + }), + }); + + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + const reason = data?.error || `HTTP ${res.status}`; + console.error("Failed to send interactive message:", reason); + toast.error(`Failed to send: ${reason}`); + onUpdateMessage(tempId, { status: "failed" }); + return; + } + + onUpdateMessage(tempId, { status: "sent" }); + } catch (err) { + console.error("Failed to send interactive message:", err); + const reason = err instanceof Error ? err.message : "network error"; + toast.error(`Failed to send: ${reason}`); + onUpdateMessage(tempId, { status: "failed" }); + } + }, + [conversation, onNewMessage, onUpdateMessage], + ); + const handleStatusChange = useCallback( async (status: ConversationStatus) => { if (!conversation) return; @@ -1101,6 +1155,7 @@ export function MessageThread({ sessionExpired={sessionInfo.expired} onSend={handleSend} onSendMedia={handleSendMedia} + onSendInteractive={handleSendInteractive} onOpenTemplates={handleOpenTemplates} replyTo={replyTo} onClearReply={() => setReplyTo(null)} diff --git a/src/components/inbox/quick-reply-picker.tsx b/src/components/inbox/quick-reply-picker.tsx new file mode 100644 index 0000000..4998581 --- /dev/null +++ b/src/components/inbox/quick-reply-picker.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Loader2, MessageSquare, Zap } from "lucide-react"; +import { useTranslations } from "next-intl"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import type { QuickReply } from "@/types"; +import { interactivePayloadPreviewText } from "@/lib/whatsapp/interactive"; + +interface QuickReplyPickerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onPick: (qr: QuickReply) => void; +} + +/** + * Lists the account's saved quick replies for insertion into the + * composer. Text snippets fill the textarea; interactive snippets open + * the builder pre-filled (handled by the caller's `onPick`). + */ +export function QuickReplyPicker({ + open, + onOpenChange, + onPick, +}: QuickReplyPickerProps) { + const t = useTranslations("Inbox.composer"); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!open) return; + let cancelled = false; + setLoading(true); + void (async () => { + try { + const res = await fetch("/api/quick-replies", { cache: "no-store" }); + const data = await res.json().catch(() => ({})); + if (!cancelled && res.ok) { + setItems((data.quick_replies as QuickReply[]) ?? []); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [open]); + + return ( + + + + {t("quickReplies")} + +
+ {loading ? ( +
+ +
+ ) : items.length === 0 ? ( +

+ {t("quickRepliesEmpty")} +

+ ) : ( +
    + {items.map((qr) => ( +
  • + +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/src/components/interactive/interactive-builder.tsx b/src/components/interactive/interactive-builder.tsx new file mode 100644 index 0000000..4282bdf --- /dev/null +++ b/src/components/interactive/interactive-builder.tsx @@ -0,0 +1,468 @@ +"use client"; + +import { useState } from "react"; +import { Plus, Trash2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import { slugify } from "@/components/flows/shared"; +import { INTERACTIVE_LIMITS } from "@/lib/whatsapp/meta-api"; +import { + validateInteractivePayload, + type InteractiveButtonsPayload, + type InteractiveListPayload, + type InteractiveMessagePayload, +} from "@/lib/whatsapp/interactive"; +import { InteractivePreview } from "./interactive-preview"; + +// ------------------------------------------------------------ +// Blank payload factories — used to seed a fresh builder and to +// switch kind without losing the shared body/header/footer. +// ------------------------------------------------------------ + +/** + * Generate an id that doesn't collide with any already in use. A plain + * count-based id (`btn_${length+1}`) regenerates an existing id after a + * middle item is removed, which then trips the duplicate-id validator and + * silently blocks sending. Increment past any taken id instead. + */ +function nextId(existing: string[], prefix: string): string { + const taken = new Set(existing); + let n = existing.length + 1; + while (taken.has(`${prefix}${n}`)) n++; + return `${prefix}${n}`; +} + +export function blankButtonsPayload(): InteractiveButtonsPayload { + return { + kind: "buttons", + body: "", + buttons: [{ id: "btn_1", title: "" }], + }; +} + +export function blankListPayload(): InteractiveListPayload { + return { + kind: "list", + body: "", + button_label: "Menu", + sections: [{ title: "", rows: [{ id: "row_1", title: "" }] }], + }; +} + +interface InteractiveBuilderProps { + value: InteractiveMessagePayload; + onChange: (payload: InteractiveMessagePayload) => void; + /** Show the live WhatsApp-style preview beside the form. Default true. */ + showPreview?: boolean; +} + +/** + * Controlled builder for a WhatsApp interactive message (reply buttons + * or list). Enforces Meta's char limits inline (maxLength + counters) + * and surfaces a single validation error via `validateInteractivePayload` + * — the same check the server runs before sending. Shared by the inbox + * composer, the automation Send node, and the quick-replies manager. + */ +export function InteractiveBuilder({ + value, + onChange, + showPreview = true, +}: InteractiveBuilderProps) { + const [advanced, setAdvanced] = useState(false); + const validation = validateInteractivePayload(value); + + const setField = (patch: Partial) => + onChange({ ...value, ...patch } as InteractiveMessagePayload); + + const switchKind = (kind: "buttons" | "list") => { + if (kind === value.kind) return; + const shared = { body: value.body, header: value.header, footer: value.footer }; + onChange( + kind === "buttons" + ? { ...blankButtonsPayload(), ...shared } + : { ...blankListPayload(), ...shared }, + ); + }; + + return ( +
+
+ {/* Kind toggle */} +
+ switchKind("buttons")} + /> + switchKind("list")} + /> +
+ + +