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>
272 lines
12 KiB
TypeScript
272 lines
12 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* TemplateLibrary — modal de biblioteca de templates de mensagens.
|
|
*
|
|
* Permite:
|
|
* - Navegar templates por tipo
|
|
* - Pesquisar por nome
|
|
* - Selecionar um template para usar (retorna o payload ao pai)
|
|
* - Deletar templates
|
|
* - Ver contagem de uso
|
|
*/
|
|
|
|
import React, { useEffect, useState, useCallback } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
import {
|
|
X, Search, Trash2, Clock, Type, MousePointerClick,
|
|
Link, List, BarChart2, LayoutGrid, BookOpen, CheckCircle2,
|
|
} from 'lucide-react'
|
|
import { templateApi, type MessageTemplate, type TemplateType } from '../services/chatApiService'
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
const TYPE_META: Record<TemplateType, { label: string; icon: React.ReactNode; color: string; bg: string }> = {
|
|
TEXT: { label: 'Texto', icon: <Type className="w-3.5 h-3.5" />, color: '#667781', bg: '#f0f2f5' },
|
|
BUTTONS: { label: 'Botões', icon: <MousePointerClick className="w-3.5 h-3.5" />, color: '#0ea5e9', bg: '#e0f2fe' },
|
|
INTERACTIVE: { label: 'CTA', icon: <Link className="w-3.5 h-3.5" />, color: '#8b5cf6', bg: '#ede9fe' },
|
|
LIST: { label: 'Lista', icon: <List className="w-3.5 h-3.5" />, color: '#f59e0b', bg: '#fef3c7' },
|
|
POLL: { label: 'Enquete', icon: <BarChart2 className="w-3.5 h-3.5" />, color: '#10b981', bg: '#d1fae5' },
|
|
CAROUSEL: { label: 'Carrossel', icon: <LayoutGrid className="w-3.5 h-3.5" />, color: '#ef4444', bg: '#fee2e2' },
|
|
}
|
|
|
|
function TypeBadge({ type }: { type: TemplateType }) {
|
|
const meta = TYPE_META[type]
|
|
return (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-semibold"
|
|
style={{ color: meta.color, backgroundColor: meta.bg }}>
|
|
{meta.icon}
|
|
{meta.label}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function PayloadPreview({ template }: { template: MessageTemplate }) {
|
|
const p = template.payload
|
|
if (template.type === 'TEXT') return <span className="line-clamp-2">{(p as any).text || '—'}</span>
|
|
if (template.type === 'BUTTONS') return <span className="line-clamp-1">{(p as any).text || ''} [{((p as any).nativeButtons ?? []).length} botões]</span>
|
|
if (template.type === 'INTERACTIVE') return <span className="line-clamp-1">{(p as any).text || ''} [{((p as any).nativeButtons ?? []).length} CTAs]</span>
|
|
if (template.type === 'LIST') return <span className="line-clamp-1">{(p as any).text || ''} — {(p as any).nativeList?.buttonText}</span>
|
|
if (template.type === 'POLL') return <span className="line-clamp-1">📊 {(p as any).poll?.name} ({((p as any).poll?.values ?? []).length} opções)</span>
|
|
if (template.type === 'CAROUSEL') return <span className="line-clamp-1">🎠 {((p as any).nativeCarousel?.cards ?? []).length} cards</span>
|
|
return null
|
|
}
|
|
|
|
// ─── Props ────────────────────────────────────────────────────────────────────
|
|
|
|
interface Props {
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
onSelect: (template: MessageTemplate) => void
|
|
}
|
|
|
|
// ─── Component ────────────────────────────────────────────────────────────────
|
|
|
|
const ALL = 'ALL'
|
|
type FilterType = TemplateType | typeof ALL
|
|
|
|
export default function TemplateLibrary({ isOpen, onClose, onSelect }: Props) {
|
|
const [templates, setTemplates] = useState<MessageTemplate[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [search, setSearch] = useState('')
|
|
const [filter, setFilter] = useState<FilterType>(ALL)
|
|
const [deleting, setDeleting] = useState<string | null>(null)
|
|
const [selected, setSelected] = useState<string | null>(null)
|
|
const [mounted, setMounted] = useState(false)
|
|
useEffect(() => { setMounted(true) }, [])
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const data = await templateApi.list({
|
|
...(filter !== ALL ? { type: filter } : {}),
|
|
...(search ? { search } : {}),
|
|
})
|
|
setTemplates(data)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [filter, search])
|
|
|
|
useEffect(() => {
|
|
if (isOpen) load()
|
|
}, [isOpen, load])
|
|
|
|
const handleDelete = async (e: React.MouseEvent, id: string) => {
|
|
e.stopPropagation()
|
|
if (!confirm('Remover este template?')) return
|
|
setDeleting(id)
|
|
try {
|
|
await templateApi.delete(id)
|
|
setTemplates(prev => prev.filter(t => t.id !== id))
|
|
} finally {
|
|
setDeleting(null)
|
|
}
|
|
}
|
|
|
|
const handleSelect = (template: MessageTemplate) => {
|
|
setSelected(template.id)
|
|
setTimeout(() => {
|
|
onSelect(template)
|
|
setSelected(null)
|
|
onClose()
|
|
}, 200)
|
|
}
|
|
|
|
const filterTabs: Array<{ key: FilterType; label: string }> = [
|
|
{ key: ALL, label: 'Todos' },
|
|
...Object.entries(TYPE_META).map(([k, v]) => ({ key: k as TemplateType, label: v.label })),
|
|
]
|
|
|
|
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-center justify-center p-4"
|
|
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
|
>
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 30, scale: 0.97 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ opacity: 0, y: 30, scale: 0.97 }}
|
|
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
|
className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[85vh] 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 className="flex items-center gap-3">
|
|
<div className="w-9 h-9 rounded-xl bg-[#e0f2fe] flex items-center justify-center">
|
|
<BookOpen className="w-5 h-5 text-[#0ea5e9]" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-base font-semibold text-[#111b21]">Biblioteca de Templates</h2>
|
|
<p className="text-xs text-[#667781]">{templates.length} template{templates.length !== 1 ? 's' : ''} salvos</p>
|
|
</div>
|
|
</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>
|
|
|
|
{/* Search */}
|
|
<div className="px-5 py-3 border-b border-gray-100 shrink-0">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-400" />
|
|
<input
|
|
className="w-full pl-9 pr-4 py-2 rounded-xl bg-[#f0f2f5] border-none text-sm focus:outline-none focus:ring-2 focus:ring-[#00a884]/30"
|
|
placeholder="Pesquisar templates..."
|
|
value={search}
|
|
onChange={e => setSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filter tabs */}
|
|
<div className="px-5 py-2 flex gap-1.5 overflow-x-auto shrink-0 border-b border-gray-100 pb-2">
|
|
{filterTabs.map(tab => (
|
|
<button
|
|
key={tab.key}
|
|
onClick={() => setFilter(tab.key)}
|
|
className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all ${
|
|
filter === tab.key
|
|
? 'bg-[#111b21] text-white'
|
|
: 'bg-[#f0f2f5] text-[#667781] hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Template list */}
|
|
<div className="flex-1 overflow-y-auto p-5">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-16">
|
|
<span className="w-6 h-6 border-2 border-gray-200 border-t-[#00a884] rounded-full animate-spin" />
|
|
</div>
|
|
) : templates.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
<BookOpen className="w-12 h-12 text-gray-200 mb-3" />
|
|
<p className="text-sm font-medium text-[#667781]">Nenhum template encontrado</p>
|
|
<p className="text-xs text-gray-400 mt-1">Crie um template no compositor de mensagens</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{templates.map(tmpl => (
|
|
<motion.button
|
|
key={tmpl.id}
|
|
layout
|
|
onClick={() => handleSelect(tmpl)}
|
|
className={`w-full text-left rounded-xl border p-4 transition-all hover:shadow-md active:scale-[0.99] group ${
|
|
selected === tmpl.id
|
|
? 'border-[#00a884] bg-[#e7f8f2]'
|
|
: 'border-gray-200 hover:border-gray-300 bg-white'
|
|
}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1.5">
|
|
<TypeBadge type={tmpl.type} />
|
|
{tmpl.usageCount > 0 && (
|
|
<span className="flex items-center gap-1 text-[10px] text-gray-400">
|
|
<Clock className="w-3 h-3" />
|
|
{tmpl.usageCount}x usado
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-sm font-semibold text-[#111b21] truncate">{tmpl.name}</p>
|
|
{tmpl.description && (
|
|
<p className="text-xs text-[#667781] mt-0.5 line-clamp-1">{tmpl.description}</p>
|
|
)}
|
|
<p className="text-xs text-gray-400 mt-1.5 line-clamp-2">
|
|
<PayloadPreview template={tmpl} />
|
|
</p>
|
|
{tmpl.tags.length > 0 && (
|
|
<div className="flex gap-1 mt-2 flex-wrap">
|
|
{tmpl.tags.map(tag => (
|
|
<span key={tag} className="px-1.5 py-0.5 bg-gray-100 text-[10px] text-gray-500 rounded">
|
|
#{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{selected === tmpl.id ? (
|
|
<CheckCircle2 className="w-5 h-5 text-[#00a884]" />
|
|
) : (
|
|
<>
|
|
<button
|
|
onClick={(e) => handleDelete(e, tmpl.id)}
|
|
disabled={deleting === tmpl.id}
|
|
className="p-1.5 rounded-full opacity-0 group-hover:opacity-100 hover:bg-red-50 text-gray-400 hover:text-red-500 transition-all"
|
|
>
|
|
{deleting === tmpl.id
|
|
? <span className="w-3.5 h-3.5 border border-gray-300 border-t-gray-500 rounded-full animate-spin block" />
|
|
: <Trash2 className="w-3.5 h-3.5" />
|
|
}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>,
|
|
document.body
|
|
)
|
|
}
|