'use client'; /** * RichMessageComposer — modal completo para compor e enviar mensagens ricas. * * Suporta 6 tipos de mensagem WhatsApp via InfiniteAPI/Baileys: * TEXT — texto simples (padrão atual) * BUTTONS — até 3 botões de resposta rápida (nativeButtons reply) * INTERACTIVE — botões CTA: URL / Copiar / Ligar (nativeButtons url/copy/call) * LIST — lista dropdown com seções e itens (nativeList) * POLL — enquete nativa do WhatsApp (poll) * CAROUSEL — cards deslizáveis com imagem e botões (nativeCarousel) * * Props: * isOpen — controla visibilidade * onClose — callback ao fechar * instanceId — instância WhatsApp ativa * jid — JID destino (pré-preenchido quando aberto do chat) * onSent — callback após envio bem-sucedido * initialPayload — pré-preenche o form (ao usar um template) * initialType — tipo inicial selecionado */ import React, { useState, useCallback, useEffect, useId } from 'react' import { createPortal } from 'react-dom' import { motion, AnimatePresence } from 'framer-motion' import { X, Send, Plus, Trash2, ChevronDown, ChevronUp, Type, MousePointerClick, List, BarChart2, LayoutGrid, Link, BookmarkPlus, Calendar, } from 'lucide-react' import { richMessageApi, templateApi, type TemplateType, type RichPayload } from '../services/chatApiService' // ─── Tipos locais ──────────────────────────────────────────────────────────── type MessageType = TemplateType interface BtnItem { id: string; text: string } interface CtaItem { type: 'url' | 'copy' | 'call'; text: string; extra: string } interface ListRow { id: string; title: string; description: string } interface ListSection { title: string; rows: ListRow[] } interface CarouselBtn { id: string; text: string } interface CarouselCard { title: string; body: string; footer: string; imageUrl: string; buttons: CarouselBtn[] } interface FormState { // TEXT text: string // BUTTONS btnText: string btnFooter: string buttons: BtnItem[] // INTERACTIVE (CTA) ctaText: string ctaFooter: string ctaButtons: CtaItem[] // LIST listText: string listButtonText: string listFooter: string listSections: ListSection[] // POLL pollName: string pollSelectable: number pollOptions: string[] // CAROUSEL carouselText: string carouselFooter: string cards: CarouselCard[] } const defaultForm: FormState = { text: '', btnText: '', btnFooter: '', buttons: [{ id: 'btn1', text: '' }], ctaText: '', ctaFooter: '', ctaButtons: [{ type: 'url', text: '', extra: '' }], listText: '', listButtonText: 'Ver opções', listFooter: '', listSections: [{ title: 'Seção 1', rows: [{ id: 'r1', title: '', description: '' }] }], pollName: '', pollSelectable: 1, pollOptions: ['', ''], carouselText: '', carouselFooter: '', cards: [{ title: '', body: '', footer: '', imageUrl: '', buttons: [{ id: 'cb1', text: '' }] }], } // ─── Tipo selector ──────────────────────────────────────────────────────────── const TYPE_TABS: Array<{ type: MessageType; label: string; icon: React.ReactNode; color: string }> = [ { type: 'TEXT', label: 'Texto', icon: , color: '#667781' }, { type: 'BUTTONS', label: 'Botões', icon: , color: '#0ea5e9' }, { type: 'INTERACTIVE', label: 'CTA', icon: , color: '#8b5cf6' }, { type: 'LIST', label: 'Lista', icon: , color: '#f59e0b' }, { type: 'POLL', label: 'Enquete', icon: , color: '#10b981' }, { type: 'CAROUSEL', label: 'Carrossel', icon: , color: '#ef4444' }, ] // ─── Helpers ────────────────────────────────────────────────────────────────── function uid() { return `id_${Date.now()}_${Math.random().toString(36).slice(2, 7)}` } function buildPayload(type: MessageType, form: FormState): RichPayload { switch (type) { case 'TEXT': return { text: form.text } case 'BUTTONS': return { text: form.btnText || undefined, footer: form.btnFooter || undefined, nativeButtons: form.buttons.filter(b => b.id && b.text).map(b => ({ type: 'reply' as const, id: b.id, text: b.text, })), } case 'INTERACTIVE': return { text: form.ctaText || undefined, nativeButtons: form.ctaButtons.filter(b => b.text && b.extra).map(b => { if (b.type === 'url') return { type: 'url' as const, text: b.text, url: b.extra } if (b.type === 'copy') return { type: 'copy' as const, text: b.text, copyText: b.extra } return { type: 'call' as const, text: b.text, phoneNumber: b.extra } }), } case 'LIST': { const sections = form.listSections .map(s => ({ title: s.title, rows: s.rows.filter(r => r.id && r.title) })) .filter(s => s.rows.length > 0) return { text: form.listText || undefined, nativeList: { buttonText: form.listButtonText || 'Ver opções', sections, }, } } case 'POLL': return { poll: { name: form.pollName || 'Enquete', values: form.pollOptions.filter(Boolean), selectableCount: form.pollSelectable, }, } case 'CAROUSEL': { const cards = form.cards.map(c => ({ title: c.title || undefined, body: c.body || undefined, footer: c.footer || undefined, image: c.imageUrl ? { url: c.imageUrl } : undefined, buttons: c.buttons.filter(b => b.id && b.text).map(b => ({ type: 'reply' as const, id: b.id, text: b.text })), })) return { text: form.carouselText || undefined, nativeCarousel: { cards }, } } } } // ─── Sub-forms ──────────────────────────────────────────────────────────────── function TextForm({ form, set }: { form: FormState; set: (p: Partial) => void }) { return ( Mensagem set({ text: e.target.value })} /> ) } function ButtonsForm({ form, set }: { form: FormState; set: (p: Partial) => void }) { const add = () => { if (form.buttons.length >= 3) return set({ buttons: [...form.buttons, { id: uid(), text: '' }] }) } const remove = (i: number) => set({ buttons: form.buttons.filter((_, idx) => idx !== i) }) const update = (i: number, field: keyof BtnItem, val: string) => { const btns = [...form.buttons]; btns[i] = { ...btns[i], [field]: val }; set({ buttons: btns }) } return ( Texto set({ btnText: e.target.value })} /> Rodapé set({ btnFooter: e.target.value })} /> Botões (máx. 3) {form.buttons.map((btn, i) => ( update(i, 'id', e.target.value)} /> update(i, 'text', e.target.value)} /> remove(i)} disabled={form.buttons.length <= 1} className="p-2 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 disabled:opacity-30 transition-colors"> ))} {form.buttons.length < 3 && ( Adicionar botão )} ) } function InteractiveForm({ form, set }: { form: FormState; set: (p: Partial) => void }) { const add = () => { if (form.ctaButtons.length >= 3) return set({ ctaButtons: [...form.ctaButtons, { type: 'url', text: '', extra: '' }] }) } const remove = (i: number) => set({ ctaButtons: form.ctaButtons.filter((_, idx) => idx !== i) }) const update = (i: number, field: keyof CtaItem, val: string) => { const btns = [...form.ctaButtons]; btns[i] = { ...btns[i], [field]: val as any }; set({ ctaButtons: btns }) } const placeholders: Record = { url: 'https://...', copy: 'Código do cupom', call: '5511999999999' } const labels: Record = { url: 'URL', copy: 'Código a copiar', call: 'Número de telefone' } return ( Texto set({ ctaText: e.target.value })} /> Botões CTA (máx. 3) {form.ctaButtons.map((btn, i) => ( update(i, 'type', e.target.value)} className="rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]/30"> 🌐 URL 📋 Copiar 📞 Ligar update(i, 'text', e.target.value)} /> remove(i)} disabled={form.ctaButtons.length <= 1} className="p-1.5 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 disabled:opacity-30 transition-colors"> update(i, 'extra', e.target.value)} /> {labels[btn.type]} ))} {form.ctaButtons.length < 3 && ( Adicionar botão CTA )} ) } function ListForm({ form, set }: { form: FormState; set: (p: Partial) => void }) { const addSection = () => { set({ listSections: [...form.listSections, { title: `Seção ${form.listSections.length + 1}`, rows: [{ id: uid(), title: '', description: '' }] }] }) } const removeSection = (si: number) => set({ listSections: form.listSections.filter((_, i) => i !== si) }) const updateSection = (si: number, title: string) => { const ss = [...form.listSections]; ss[si] = { ...ss[si], title }; set({ listSections: ss }) } const addRow = (si: number) => { const ss = [...form.listSections] ss[si] = { ...ss[si], rows: [...ss[si].rows, { id: uid(), title: '', description: '' }] } set({ listSections: ss }) } const removeRow = (si: number, ri: number) => { const ss = [...form.listSections] ss[si] = { ...ss[si], rows: ss[si].rows.filter((_, i) => i !== ri) } set({ listSections: ss }) } const updateRow = (si: number, ri: number, field: keyof ListRow, val: string) => { const ss = [...form.listSections] const rows = [...ss[si].rows]; rows[ri] = { ...rows[ri], [field]: val } ss[si] = { ...ss[si], rows }; set({ listSections: ss }) } return ( Texto set({ listText: e.target.value })} /> Texto do Botão set({ listButtonText: e.target.value })} /> {form.listSections.map((sec, si) => ( updateSection(si, e.target.value)} /> removeSection(si)} disabled={form.listSections.length <= 1} className="p-1 rounded text-gray-400 hover:text-red-500 disabled:opacity-30"> {sec.rows.map((row, ri) => ( updateRow(si, ri, 'id', e.target.value)} /> updateRow(si, ri, 'title', e.target.value)} /> updateRow(si, ri, 'description', e.target.value)} /> removeRow(si, ri)} disabled={sec.rows.length <= 1} className="p-1 rounded text-gray-400 hover:text-red-500 disabled:opacity-30"> ))} addRow(si)} className="flex items-center gap-1 text-xs text-amber-600 hover:underline font-medium"> Adicionar item ))} Adicionar seção ) } function PollForm({ form, set }: { form: FormState; set: (p: Partial) => void }) { const addOption = () => set({ pollOptions: [...form.pollOptions, ''] }) const removeOption = (i: number) => { if (form.pollOptions.length <= 2) return set({ pollOptions: form.pollOptions.filter((_, idx) => idx !== i) }) } const updateOption = (i: number, val: string) => { const opts = [...form.pollOptions]; opts[i] = val; set({ pollOptions: opts }) } return ( Pergunta set({ pollName: e.target.value })} /> Máx. seleções set({ pollSelectable: parseInt(e.target.value) || 1 })} /> Opções (mín. 2) {form.pollOptions.map((opt, i) => ( updateOption(i, e.target.value)} /> removeOption(i)} disabled={form.pollOptions.length <= 2} className="p-2 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 disabled:opacity-30 transition-colors"> ))} {form.pollOptions.length < 12 && ( Adicionar opção )} ) } function CarouselForm({ form, set }: { form: FormState; set: (p: Partial) => void }) { const addCard = () => { if (form.cards.length >= 10) return set({ cards: [...form.cards, { title: '', body: '', footer: '', imageUrl: '', buttons: [{ id: uid(), text: '' }] }] }) } const removeCard = (ci: number) => set({ cards: form.cards.filter((_, i) => i !== ci) }) const updateCard = (ci: number, field: keyof CarouselCard, val: any) => { const cards = [...form.cards]; cards[ci] = { ...cards[ci], [field]: val }; set({ cards }) } const addCardBtn = (ci: number) => { const cards = [...form.cards]; cards[ci].buttons.push({ id: uid(), text: '' }); set({ cards }) } const removeCardBtn = (ci: number, bi: number) => { const cards = [...form.cards]; cards[ci].buttons = cards[ci].buttons.filter((_, i) => i !== bi); set({ cards }) } const updateCardBtn = (ci: number, bi: number, field: keyof CarouselBtn, val: string) => { const cards = [...form.cards]; cards[ci].buttons[bi] = { ...cards[ci].buttons[bi], [field]: val }; set({ cards }) } const [expanded, setExpanded] = useState([0]) const toggle = (i: number) => setExpanded(prev => prev.includes(i) ? prev.filter(x => x !== i) : [...prev, i]) return ( Texto acima set({ carouselText: e.target.value })} /> Rodapé set({ carouselFooter: e.target.value })} /> {form.cards.map((card, ci) => ( toggle(ci)} className="w-full flex items-center justify-between px-3 py-2.5 text-sm font-medium text-[#111b21] hover:bg-gray-100 transition-colors"> Card {ci + 1}{card.title ? ` — ${card.title}` : ''} {form.cards.length > 1 && ( { e.stopPropagation(); removeCard(ci) }} className="p-1 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors"> )} {expanded.includes(ci) ? : } {expanded.includes(ci) && ( updateCard(ci, 'title', e.target.value)} /> updateCard(ci, 'footer', e.target.value)} /> updateCard(ci, 'body', e.target.value)} /> updateCard(ci, 'imageUrl', e.target.value)} /> Botões do card {card.buttons.map((btn, bi) => ( updateCardBtn(ci, bi, 'id', e.target.value)} /> updateCardBtn(ci, bi, 'text', e.target.value)} /> removeCardBtn(ci, bi)} disabled={card.buttons.length <= 1} className="p-1 rounded text-gray-400 hover:text-red-500 disabled:opacity-30"> ))} addCardBtn(ci)} className="flex items-center gap-1 text-[11px] text-red-500 hover:underline font-medium"> Botão no card )} ))} {form.cards.length < 10 && ( Adicionar card )} ) } // ─── Modal principal ────────────────────────────────────────────────────────── interface Props { isOpen: boolean onClose: () => void instanceId: string | null jid: string onSent?: () => void onSaveTemplate?: (type: TemplateType, payload: Record, name?: string) => void onSchedule?: (type: TemplateType, payload: Record) => void initialPayload?: Record initialType?: TemplateType } export default function RichMessageComposer({ isOpen, onClose, instanceId, jid, onSent, onSaveTemplate, onSchedule, initialPayload, initialType = 'TEXT', }: Props) { const [type, setType] = useState(initialType) const [form, setFormState] = useState(defaultForm) const [sending, setSending] = useState(false) const [error, setError] = useState(null) const [saving, setSaving] = useState(false) const [savedName, setSavedName] = useState('') const [showSaveName, setShowSaveName] = useState(false) const set = useCallback((partial: Partial) => { setFormState(prev => ({ ...prev, ...partial })) }, []) const handleSend = async () => { if (!instanceId || !jid) return // Validações de frontend antes de enviar if (type === 'CAROUSEL' && form.cards.length < 2) { setError('Carrossel requer no mínimo 2 cards') return } if (type === 'CAROUSEL' && form.cards.some(c => c.buttons.filter(b => b.id && b.text).length === 0)) { setError('Cada card do carrossel precisa ter pelo menos 1 botão') return } setError(null) setSending(true) try { const payload = buildPayload(type, form) await richMessageApi.send(instanceId, jid, payload) onSent?.() onClose() } catch (e: any) { setError(e?.response?.data?.error ?? e?.message ?? 'Erro ao enviar') } finally { setSending(false) } } const handleSave = async () => { if (!savedName.trim()) { setShowSaveName(true); return } setSaving(true) try { const payload = buildPayload(type, form) as Record await templateApi.create({ name: savedName.trim(), type, payload, tags: [] }) setShowSaveName(false) setSavedName('') } finally { setSaving(false) } } const handleSchedule = () => { const payload = buildPayload(type, form) as Record onSchedule?.(type, payload) } const activeTab = TYPE_TABS.find(t => t.type === type)! const [mounted, setMounted] = useState(false) useEffect(() => { setMounted(true) }, []) if (!mounted) return null return createPortal( {isOpen && ( { if (e.target === e.currentTarget) onClose() }} > {/* Header */} Mensagem Rica Compose um tipo avançado de mensagem WhatsApp {/* Type tabs */} {TYPE_TABS.map(tab => ( setType(tab.type)} className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-[11px] font-semibold transition-all ${ type === tab.type ? 'bg-white shadow-sm text-[#111b21]' : 'text-[#667781] hover:text-[#111b21]' }`} style={type === tab.type ? { color: tab.color } : {}} > {tab.icon} {tab.label} ))} {/* Form area */} {type === 'TEXT' && } {type === 'BUTTONS' && } {type === 'INTERACTIVE' && } {type === 'LIST' && } {type === 'POLL' && } {type === 'CAROUSEL' && } {/* Save name input */} {showSaveName && ( setSavedName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') setShowSaveName(false) }} /> {saving ? '...' : 'Salvar'} setShowSaveName(false)} className="p-2 rounded-full hover:bg-gray-200 text-gray-400"> )} {/* Error */} {error && ( {error} )} {/* Footer actions */} setShowSaveName(!showSaveName)} title="Salvar como template" className="flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-medium text-[#667781] hover:bg-gray-100 transition-colors" > Salvar template {onSchedule && ( Agendar )} Cancelar {sending ? ( ) : ( )} Enviar )} , document.body ) }
Compose um tipo avançado de mensagem WhatsApp