Files
scoreodonto.com/frontend/views/newwhats/components/RichMessageComposer.tsx
T
VPS 4 Builder 3042ddca38 feat(newwhats): frontend do plugin (Inbox/Sessions/Secretária) em tela cheia
- /wa-inbox, /wa-sessions e /wa-secretaria rodam sem a sidebar do scoreodonto
  (isStandaloneView); cada tela tem seu "Voltar" (Secretária ganhou botão na ThinNav).
- SessionsView: banner "Você já possui acesso ao WhatsApp" quando há sessão ativa.
- avatares: usa a URL do CDN (contactAvatarUrl/instance.avatar) direto; Avatar
  exibe sem depender de version; self-heal no onError re-busca a foto atual.
- deps: framer-motion, immer. Dev override com HMR (docker-compose.dev.yml).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 20:54:24 +02:00

720 lines
36 KiB
TypeScript

'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: <Type className="w-4 h-4" />, color: '#667781' },
{ type: 'BUTTONS', label: 'Botões', icon: <MousePointerClick className="w-4 h-4" />, color: '#0ea5e9' },
{ type: 'INTERACTIVE', label: 'CTA', icon: <Link className="w-4 h-4" />, color: '#8b5cf6' },
{ type: 'LIST', label: 'Lista', icon: <List className="w-4 h-4" />, color: '#f59e0b' },
{ type: 'POLL', label: 'Enquete', icon: <BarChart2 className="w-4 h-4" />, color: '#10b981' },
{ type: 'CAROUSEL', label: 'Carrossel', icon: <LayoutGrid className="w-4 h-4" />, 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<FormState>) => void }) {
return (
<div className="space-y-3">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Mensagem</label>
<textarea
rows={5}
className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#00a884]/30 resize-none"
placeholder="Digite o texto da mensagem..."
value={form.text}
onChange={e => set({ text: e.target.value })}
/>
</div>
)
}
function ButtonsForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => 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 (
<div className="space-y-4">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Texto</label>
<textarea rows={2} className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#0ea5e9]/30 resize-none"
placeholder="Corpo da mensagem" value={form.btnText} onChange={e => set({ btnText: e.target.value })} />
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Rodapé</label>
<input className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#0ea5e9]/30"
placeholder="Texto de rodapé (opcional)" value={form.btnFooter} onChange={e => set({ btnFooter: e.target.value })} />
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Botões (máx. 3)</label>
{form.buttons.map((btn, i) => (
<div key={btn.id} className="flex gap-2">
<input className="flex-1 rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#0ea5e9]/30"
placeholder="ID do botão" value={btn.id} onChange={e => update(i, 'id', e.target.value)} />
<input className="flex-[2] rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#0ea5e9]/30"
placeholder="Texto visível" value={btn.text} onChange={e => update(i, 'text', e.target.value)} />
<button onClick={() => 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">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
{form.buttons.length < 3 && (
<button onClick={add} className="flex items-center gap-1 text-xs text-[#0ea5e9] hover:underline font-medium">
<Plus className="w-3.5 h-3.5" /> Adicionar botão
</button>
)}
</div>
</div>
)
}
function InteractiveForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => 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<string, string> = { url: 'https://...', copy: 'Código do cupom', call: '5511999999999' }
const labels: Record<string, string> = { url: 'URL', copy: 'Código a copiar', call: 'Número de telefone' }
return (
<div className="space-y-4">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Texto</label>
<textarea rows={2} className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]/30 resize-none"
placeholder="Corpo da mensagem" value={form.ctaText} onChange={e => set({ ctaText: e.target.value })} />
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Botões CTA (máx. 3)</label>
{form.ctaButtons.map((btn, i) => (
<div key={i} className="rounded-xl border border-gray-200 bg-[#f8f9fa] p-3 space-y-2">
<div className="flex gap-2 items-start">
<select value={btn.type} onChange={e => 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">
<option value="url">🌐 URL</option>
<option value="copy">📋 Copiar</option>
<option value="call">📞 Ligar</option>
</select>
<input className="flex-1 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"
placeholder="Texto do botão" value={btn.text} onChange={e => update(i, 'text', e.target.value)} />
<button onClick={() => 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">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<input className="w-full 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"
placeholder={placeholders[btn.type]} value={btn.extra} onChange={e => update(i, 'extra', e.target.value)} />
<span className="text-[10px] text-gray-400">{labels[btn.type]}</span>
</div>
))}
{form.ctaButtons.length < 3 && (
<button onClick={add} className="flex items-center gap-1 text-xs text-[#8b5cf6] hover:underline font-medium">
<Plus className="w-3.5 h-3.5" /> Adicionar botão CTA
</button>
)}
</div>
</div>
)
}
function ListForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => 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 (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Texto</label>
<textarea rows={2} className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none resize-none"
placeholder="Corpo da mensagem" value={form.listText} onChange={e => set({ listText: e.target.value })} />
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Texto do Botão</label>
<input className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none"
placeholder="Ver opções" value={form.listButtonText} onChange={e => set({ listButtonText: e.target.value })} />
</div>
</div>
<div className="space-y-3">
{form.listSections.map((sec, si) => (
<div key={si} className="rounded-xl border border-amber-100 bg-amber-50/50 p-3 space-y-2">
<div className="flex items-center gap-2">
<input className="flex-1 rounded-lg border border-amber-200 bg-white px-2 py-1.5 text-xs font-medium focus:outline-none"
placeholder="Título da seção" value={sec.title} onChange={e => updateSection(si, e.target.value)} />
<button onClick={() => removeSection(si)} disabled={form.listSections.length <= 1}
className="p-1 rounded text-gray-400 hover:text-red-500 disabled:opacity-30">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
{sec.rows.map((row, ri) => (
<div key={ri} className="flex gap-2">
<input className="w-20 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
placeholder="ID" value={row.id} onChange={e => updateRow(si, ri, 'id', e.target.value)} />
<input className="flex-1 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
placeholder="Título" value={row.title} onChange={e => updateRow(si, ri, 'title', e.target.value)} />
<input className="flex-[2] rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
placeholder="Descrição" value={row.description} onChange={e => updateRow(si, ri, 'description', e.target.value)} />
<button onClick={() => removeRow(si, ri)} disabled={sec.rows.length <= 1}
className="p-1 rounded text-gray-400 hover:text-red-500 disabled:opacity-30">
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
<button onClick={() => addRow(si)} className="flex items-center gap-1 text-xs text-amber-600 hover:underline font-medium">
<Plus className="w-3 h-3" /> Adicionar item
</button>
</div>
))}
<button onClick={addSection} className="flex items-center gap-1 text-xs text-amber-600 hover:underline font-medium">
<Plus className="w-3.5 h-3.5" /> Adicionar seção
</button>
</div>
</div>
)
}
function PollForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => 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 (
<div className="space-y-4">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Pergunta</label>
<input className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#10b981]/30"
placeholder="Qual a sua preferência?" value={form.pollName} onChange={e => set({ pollName: e.target.value })} />
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Máx. seleções</label>
<input type="number" min={1} max={form.pollOptions.filter(Boolean).length || 1}
className="w-24 rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#10b981]/30"
value={form.pollSelectable} onChange={e => set({ pollSelectable: parseInt(e.target.value) || 1 })} />
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Opções (mín. 2)</label>
{form.pollOptions.map((opt, i) => (
<div key={i} className="flex gap-2">
<input className="flex-1 rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#10b981]/30"
placeholder={`Opção ${i + 1}`} value={opt} onChange={e => updateOption(i, e.target.value)} />
<button onClick={() => 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">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
{form.pollOptions.length < 12 && (
<button onClick={addOption} className="flex items-center gap-1 text-xs text-[#10b981] hover:underline font-medium">
<Plus className="w-3.5 h-3.5" /> Adicionar opção
</button>
)}
</div>
</div>
)
}
function CarouselForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => 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<number[]>([0])
const toggle = (i: number) => setExpanded(prev => prev.includes(i) ? prev.filter(x => x !== i) : [...prev, i])
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Texto acima</label>
<input className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none"
placeholder="Ofertas especiais" value={form.carouselText} onChange={e => set({ carouselText: e.target.value })} />
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Rodapé</label>
<input className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none"
placeholder="Rodapé geral" value={form.carouselFooter} onChange={e => set({ carouselFooter: e.target.value })} />
</div>
</div>
<div className="space-y-2 max-h-[320px] overflow-y-auto pr-1">
{form.cards.map((card, ci) => (
<div key={ci} className="rounded-xl border border-gray-200 bg-[#f8f9fa] overflow-hidden">
<button onClick={() => 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">
<span>Card {ci + 1}{card.title ? `${card.title}` : ''}</span>
<div className="flex items-center gap-2">
{form.cards.length > 1 && (
<button onClick={(e) => { e.stopPropagation(); removeCard(ci) }}
className="p-1 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{expanded.includes(ci) ? <ChevronUp className="w-4 h-4 text-gray-400" /> : <ChevronDown className="w-4 h-4 text-gray-400" />}
</div>
</button>
{expanded.includes(ci) && (
<div className="px-3 pb-3 space-y-2 border-t border-gray-100 pt-2">
<div className="grid grid-cols-2 gap-2">
<input className="rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
placeholder="Título" value={card.title} onChange={e => updateCard(ci, 'title', e.target.value)} />
<input className="rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
placeholder="Rodapé" value={card.footer} onChange={e => updateCard(ci, 'footer', e.target.value)} />
</div>
<textarea rows={2} className="w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none resize-none"
placeholder="Descrição/corpo" value={card.body} onChange={e => updateCard(ci, 'body', e.target.value)} />
<input className="w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
placeholder="URL da imagem (opcional)" value={card.imageUrl} onChange={e => updateCard(ci, 'imageUrl', e.target.value)} />
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-gray-400 uppercase">Botões do card</span>
{card.buttons.map((btn, bi) => (
<div key={bi} className="flex gap-1.5">
<input className="w-20 rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] focus:outline-none"
placeholder="ID" value={btn.id} onChange={e => updateCardBtn(ci, bi, 'id', e.target.value)} />
<input className="flex-1 rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] focus:outline-none"
placeholder="Texto" value={btn.text} onChange={e => updateCardBtn(ci, bi, 'text', e.target.value)} />
<button onClick={() => removeCardBtn(ci, bi)} disabled={card.buttons.length <= 1}
className="p-1 rounded text-gray-400 hover:text-red-500 disabled:opacity-30">
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
<button onClick={() => addCardBtn(ci)} className="flex items-center gap-1 text-[11px] text-red-500 hover:underline font-medium">
<Plus className="w-3 h-3" /> Botão no card
</button>
</div>
</div>
)}
</div>
))}
</div>
{form.cards.length < 10 && (
<button onClick={addCard} className="flex items-center gap-1 text-xs text-[#ef4444] hover:underline font-medium">
<Plus className="w-3.5 h-3.5" /> Adicionar card
</button>
)}
</div>
)
}
// ─── Modal principal ──────────────────────────────────────────────────────────
interface Props {
isOpen: boolean
onClose: () => void
instanceId: string | null
jid: string
onSent?: () => void
onSaveTemplate?: (type: TemplateType, payload: Record<string, unknown>, name?: string) => void
onSchedule?: (type: TemplateType, payload: Record<string, unknown>) => void
initialPayload?: Record<string, unknown>
initialType?: TemplateType
}
export default function RichMessageComposer({
isOpen, onClose, instanceId, jid, onSent, onSaveTemplate, onSchedule,
initialPayload, initialType = 'TEXT',
}: Props) {
const [type, setType] = useState<MessageType>(initialType)
const [form, setFormState] = useState<FormState>(defaultForm)
const [sending, setSending] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [savedName, setSavedName] = useState('')
const [showSaveName, setShowSaveName] = useState(false)
const set = useCallback((partial: Partial<FormState>) => {
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<string, unknown>
await templateApi.create({ name: savedName.trim(), type, payload, tags: [] })
setShowSaveName(false)
setSavedName('')
} finally {
setSaving(false)
}
}
const handleSchedule = () => {
const payload = buildPayload(type, form) as Record<string, unknown>
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(
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-end sm:items-center justify-center p-4"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<motion.div
initial={{ opacity: 0, y: 40, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 40, scale: 0.97 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="bg-white rounded-2xl shadow-2xl w-full max-w-xl max-h-[92vh] flex flex-col overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 shrink-0">
<div>
<h2 className="text-base font-semibold text-[#111b21]">Mensagem Rica</h2>
<p className="text-xs text-[#667781] mt-0.5">Compose um tipo avançado de mensagem WhatsApp</p>
</div>
<button onClick={onClose} className="p-2 rounded-full hover:bg-gray-100 text-gray-400 transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Type tabs */}
<div className="px-5 pt-3 pb-2 shrink-0">
<div className="flex gap-1 bg-[#f0f2f5] rounded-xl p-1">
{TYPE_TABS.map(tab => (
<button
key={tab.type}
onClick={() => 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}
<span className="hidden sm:block">{tab.label}</span>
</button>
))}
</div>
</div>
{/* Form area */}
<div className="flex-1 overflow-y-auto px-5 py-3">
{type === 'TEXT' && <TextForm form={form} set={set} />}
{type === 'BUTTONS' && <ButtonsForm form={form} set={set} />}
{type === 'INTERACTIVE' && <InteractiveForm form={form} set={set} />}
{type === 'LIST' && <ListForm form={form} set={set} />}
{type === 'POLL' && <PollForm form={form} set={set} />}
{type === 'CAROUSEL' && <CarouselForm form={form} set={set} />}
</div>
{/* Save name input */}
<AnimatePresence>
{showSaveName && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="px-5 py-2 border-t border-gray-100 bg-[#f8f9fa] overflow-hidden shrink-0"
>
<div className="flex gap-2 items-center">
<input
autoFocus
className="flex-1 rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#00a884]/30"
placeholder="Nome do template..."
value={savedName}
onChange={e => setSavedName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') setShowSaveName(false) }}
/>
<button onClick={handleSave} disabled={!savedName.trim() || saving}
className="px-3 py-2 bg-[#00a884] text-white rounded-xl text-sm font-medium disabled:opacity-50 hover:bg-[#008f72] transition-colors">
{saving ? '...' : 'Salvar'}
</button>
<button onClick={() => setShowSaveName(false)} className="p-2 rounded-full hover:bg-gray-200 text-gray-400">
<X className="w-4 h-4" />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Error */}
{error && (
<div className="mx-5 mb-2 px-3 py-2 bg-red-50 border border-red-200 rounded-xl text-xs text-red-600">
{error}
</div>
)}
{/* Footer actions */}
<div className="px-5 py-3 border-t border-gray-100 flex items-center justify-between shrink-0">
<div className="flex items-center gap-1">
<button
onClick={() => 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"
>
<BookmarkPlus className="w-4 h-4" />
<span className="hidden sm:block">Salvar template</span>
</button>
{onSchedule && (
<button
onClick={handleSchedule}
title="Agendar envio"
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"
>
<Calendar className="w-4 h-4" />
<span className="hidden sm:block">Agendar</span>
</button>
)}
</div>
<div className="flex items-center gap-2">
<button onClick={onClose}
className="px-4 py-2 rounded-xl text-sm font-medium text-[#667781] hover:bg-gray-100 transition-colors">
Cancelar
</button>
<button
onClick={handleSend}
disabled={sending || !instanceId || !jid}
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold text-white disabled:opacity-50 transition-all active:scale-95"
style={{ backgroundColor: activeTab.color }}
>
{sending ? (
<span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
Enviar
</button>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body
)
}