wacrm/src/components/inbox/quick-reply-picker.tsx
Arnas Donauskas 78b42dfba9 feat: WhatsApp interactive messages (reply buttons + lists)
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 <noreply@anthropic.com>
2026-07-08 19:03:15 +03:00

105 lines
3.4 KiB
TypeScript

"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<QuickReply[]>([]);
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("quickReplies")}</DialogTitle>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto">
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : items.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{t("quickRepliesEmpty")}
</p>
) : (
<ul className="flex flex-col gap-1">
{items.map((qr) => (
<li key={qr.id}>
<button
type="button"
onClick={() => onPick(qr)}
className="flex w-full items-start gap-2 rounded-md border border-border bg-muted/40 p-2.5 text-left hover:border-primary/50 hover:bg-muted"
>
{qr.kind === "interactive" ? (
<Zap className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
) : (
<MessageSquare className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">
{qr.title}
</span>
<span className="block truncate text-xs text-muted-foreground">
{qr.kind === "interactive" && qr.interactive_payload
? interactivePayloadPreviewText(qr.interactive_payload)
: qr.content_text}
</span>
</span>
</button>
</li>
))}
</ul>
)}
</div>
</DialogContent>
</Dialog>
);
}