Files
instrucoes_gerais/clube67/newwhats.local/frontend/components/TemplateLibrary.tsx
T

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
)
}