3042ddca38
- /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>
720 lines
36 KiB
TypeScript
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
|
|
)
|
|
}
|