mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
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>
105 lines
3.4 KiB
TypeScript
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>
|
|
);
|
|
}
|