Files
clube67_newwhats.local/clube67/newwhats.local/frontend/components/ContactPickerModal.tsx
T
VPS 4 Deploy Agent 2f8c04a0a7
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
chore(ops): restore all source files in newwhats.clube67.com
2026-05-18 03:28:29 +02:00

491 lines
22 KiB
TypeScript

'use client'
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
Search, X, Users, Check, ChevronDown,
Loader2, UserCheck, BookUser,
} from 'lucide-react'
import { contactApi, type BackendContact } from '../services/chatApiService'
import { formatPhone, getAvatarProxyUrl } from '../utils/inboxUtils'
// ─── Helpers ──────────────────────────────────────────────────────────────────
function resolveDisplayName(c: BackendContact): string {
return (
c.name ??
c.verifiedName ??
c.notify ??
formatPhone(c.phone ?? c.jid.split('@')[0], c.jid)
)
}
function getInitials(name: string): string {
const parts = name.trim().split(/\s+/)
if (parts.length >= 2) return (parts[0][0]! + parts[1]![0]).toUpperCase()
return name.slice(0, 2).toUpperCase()
}
function avatarColor(jid: string): string {
let h = 0
for (let i = 0; i < jid.length; i++) h = jid.charCodeAt(i) + ((h << 5) - h)
return `hsl(${Math.abs(h % 360)}, 58%, 42%)`
}
// ─── Types ────────────────────────────────────────────────────────────────────
interface Props {
open: boolean
instanceId: string | null
onConfirm: (phones: string[]) => void
onClose: () => void
}
/**
* URL do avatar via proxy local (evita expiração do CDN do WhatsApp).
*
* Só retorna URL se o backend já confirmou que existe avatar (c.avatarUrl != null).
* Se for null, evitamos disparar uma requisição que o proxy só pode responder
* com 204 — economiza até 60 round-trips por página de contatos sem foto.
*/
function resolveAvatarUrl(c: BackendContact, instanceId: string | null): string | null {
if (!instanceId) return null
// @lid e @g.us não suportam profilePictureUrl — proxy retornaria 204
if (c.jid.endsWith('@lid') || c.jid.endsWith('@g.us')) return null
// Banco já sabe que não tem foto — pula o proxy direto pro fallback de iniciais
if (!c.avatarUrl) return null
return getAvatarProxyUrl({ instance_name: instanceId, remote_jid: c.jid })
}
const PAGE_SIZE = 60
// ─── Avatar de linha (sem hack de DOM no onError) ─────────────────────────────
interface RowAvatarProps {
url: string | null
name: string
jid: string
}
/**
* Avatar de uma linha da lista. Renderiza <img> quando há URL e ela carrega;
* cai pro fallback de iniciais coloridas em qualquer outro caso.
*
* Substitui o `onError` antigo que mexia em `nextSibling` direto no DOM —
* isso quebrava em re-renders do React e às vezes escondia a inicial em
* vez do <img>. Aqui o estado de erro vive no React.
*/
function RowAvatar({ url, name, jid }: RowAvatarProps) {
const [failed, setFailed] = useState(false)
// Reseta o estado de falha quando a URL muda (ex: re-fetch da lista)
useEffect(() => { setFailed(false) }, [url])
if (url && !failed) {
return (
<img
src={url}
alt=""
loading="lazy"
decoding="async"
className="w-9 h-9 rounded-full object-cover flex-shrink-0"
onError={() => setFailed(true)}
/>
)
}
return (
<div
className="w-9 h-9 rounded-full flex-shrink-0 flex items-center justify-center text-white text-xs font-bold"
style={{ backgroundColor: avatarColor(jid) }}
>
{getInitials(name)}
</div>
)
}
// ─── Component ────────────────────────────────────────────────────────────────
export default function ContactPickerModal({ open, instanceId, onConfirm, onClose }: Props) {
const [search, setSearch] = useState('')
const [contacts, setContacts] = useState<BackendContact[]>([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [offset, setOffset] = useState(0)
const [total, setTotal] = useState(0)
const [selected, setSelected] = useState<Set<string>>(new Set())
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const searchRef = useRef<HTMLInputElement>(null)
// Marca um run do effect de busca pra ser ignorado. Usado quando o effect
// de open chama setSearch('') — esse setSearch dispara o effect de busca,
// que agenda um refetch em 280ms; mas o open já fez fetchContacts imediato,
// então o refetch é redundante e cria race com cliques em "carregar mais".
const skipNextDebounceRef = useRef(false)
// Contador de geração — qualquer fetch só aplica seu resultado se ainda for
// o mais recente. Defende contra cenários onde uma busca/clear/load-more
// dispara um fetch antes da resposta anterior chegar.
const fetchGenRef = useRef(0)
// ── Fetch ──────────────────────────────────────────────────────────────────
const fetchContacts = useCallback(async (q: string, off: number, replace = false) => {
if (!instanceId) return
const gen = ++fetchGenRef.current
setLoading(true)
try {
const res = await contactApi.list({
instanceId,
search: q.trim() || undefined,
blocked: false,
limit: PAGE_SIZE,
offset: off,
})
// Resposta obsoleta — outro fetch já foi disparado depois deste. Ignora
// pra não sobrescrever o estado correto com dados antigos.
if (gen !== fetchGenRef.current) return
// O `offset` é o SKIP da query SQL no backend, então tem que avançar
// pelo tamanho CRU da página devolvida — não pelo filtrado. Senão, ao
// descartar @g.us/@lid client-side, a próxima página começa "para trás"
// do que já mostramos e o backend devolve linhas que já estão na tela.
const raw = res.contacts ?? []
const rows = raw.filter((c: BackendContact) =>
!c.jid.endsWith('@g.us') && !c.jid.endsWith('@lid')
)
const total = res.total ?? raw.length
const nextOffset = off + raw.length
setTotal(total)
setContacts(prev => {
const merged = replace ? rows : [...prev, ...rows]
// Dedupe por jid — defesa contra fetches concorrentes (debounce de
// busca x effect de open) que podem reaplicar rows já presentes.
const seen = new Set<string>()
const unique: BackendContact[] = []
for (const c of merged) {
if (seen.has(c.jid)) continue
seen.add(c.jid)
unique.push(c)
}
return unique
})
setOffset(nextOffset)
setHasMore(nextOffset < total)
} catch {
// silent
} finally {
// Só limpa o loading se este fetch ainda for o mais recente — senão
// o spinner some enquanto o request "vencedor" continua em voo.
if (gen === fetchGenRef.current) setLoading(false)
}
}, [instanceId])
// ── Open/reset ─────────────────────────────────────────────────────────────
useEffect(() => {
if (!open) return
// Se a busca tiver conteúdo de uma sessão anterior, o setSearch('') abaixo
// vai disparar o effect debounced. Marca esse run pra ser ignorado — o
// fetchContacts imediato logo abaixo já cobre o caso, e deixar o debounce
// rodar cria race com cliques em "carregar mais" nos primeiros 280ms.
if (search !== '') skipNextDebounceRef.current = true
setSearch('')
setSelected(new Set())
setContacts([])
setOffset(0)
setHasMore(false)
fetchContacts('', 0, true)
setTimeout(() => searchRef.current?.focus(), 100)
}, [open, instanceId])
// ── Debounced search ───────────────────────────────────────────────────────
useEffect(() => {
if (!open) return
if (skipNextDebounceRef.current) {
skipNextDebounceRef.current = false
return
}
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
// sem setOffset(0) — fetchContacts já atualiza o offset com base na
// resposta, e setá-lo aqui no meio do voo capturava o valor errado no
// onClick do botão "carregar mais" durante a janela de race.
fetchContacts(search, 0, true)
}, 280)
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
// fetchContacts incluído nos deps para evitar stale closure quando instanceId muda
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [search, fetchContacts])
// ── Keyboard close ─────────────────────────────────────────────────────────
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
if (open) document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [open, onClose])
// ── Helpers ────────────────────────────────────────────────────────────────
const getPhone = (c: BackendContact) => c.phone ?? c.jid.split('@')[0]
const toggle = useCallback((c: BackendContact) => {
const phone = getPhone(c)
setSelected(prev => {
const next = new Set(prev)
next.has(phone) ? next.delete(phone) : next.add(phone)
return next
})
}, [])
const allVisible = contacts.every(c => selected.has(getPhone(c))) && contacts.length > 0
const toggleAll = () => {
if (allVisible) {
setSelected(prev => {
const next = new Set(prev)
contacts.forEach(c => next.delete(getPhone(c)))
return next
})
} else {
setSelected(prev => {
const next = new Set(prev)
contacts.forEach(c => next.add(getPhone(c)))
return next
})
}
}
const handleConfirm = () => {
onConfirm(Array.from(selected))
onClose()
}
// ─────────────────────────────────────────────────────────────────────────────
return (
<AnimatePresence>
{open && (
<>
{/* Backdrop */}
<motion.div
key="backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
className="fixed inset-0 z-50"
style={{ backgroundColor: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(6px)' }}
onClick={onClose}
/>
{/* Panel */}
<motion.div
key="panel"
initial={{ opacity: 0, scale: 0.96, y: 24 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 24 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none"
>
<div
className="w-full max-w-xl bg-[#0d1017] border border-white/[0.08] rounded-2xl shadow-2xl flex flex-col pointer-events-auto overflow-hidden"
style={{ maxHeight: '88vh' }}
onClick={e => e.stopPropagation()}
>
{/* ── Header ──────────────────────────────────────────────────── */}
<div className="flex items-center gap-3 px-5 pt-5 pb-4 border-b border-white/[0.06]">
<div className="w-9 h-9 rounded-xl bg-brand-500/15 flex items-center justify-center flex-shrink-0">
<BookUser className="w-4 h-4 text-brand-400" />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-white font-semibold text-sm">Selecionar da Agenda</h2>
<p className="text-slate-500 text-xs mt-0.5 truncate">
{total > 0 ? `${total} contatos sincronizados` : 'Contatos do WhatsApp'}
</p>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-white/[0.06] text-slate-500 hover:text-white transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* ── Search ──────────────────────────────────────────────────── */}
<div className="px-5 py-3 border-b border-white/[0.04] space-y-2.5">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
<input
ref={searchRef}
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Buscar por nome ou número…"
className="w-full bg-white/[0.05] border border-white/[0.08] rounded-xl pl-9 pr-10 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-brand-500/40 focus:ring-1 focus:ring-brand-500/25 transition-all"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 rounded-md hover:bg-white/10 text-slate-500 hover:text-white transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Toolbar */}
<div className="flex items-center justify-between">
<button
onClick={toggleAll}
disabled={contacts.length === 0}
className="flex items-center gap-2 text-xs text-slate-400 hover:text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center transition-all flex-shrink-0 ${
allVisible
? 'bg-brand-500 border-brand-500'
: 'border-white/25'
}`}>
{allVisible && <Check className="w-2.5 h-2.5 text-white" strokeWidth={3} />}
</div>
{allVisible ? 'Desmarcar página' : 'Selecionar página'}
</button>
<div className="flex items-center gap-3 text-xs text-slate-500">
{selected.size > 0 && (
<button
onClick={() => setSelected(new Set())}
className="text-red-400/70 hover:text-red-400 transition-colors"
>
Limpar seleção
</button>
)}
<span>{contacts.length} exibidos</span>
</div>
</div>
</div>
{/* ── List ────────────────────────────────────────────────────── */}
<div className="flex-1 overflow-y-auto min-h-0">
{loading && contacts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 gap-3">
<Loader2 className="w-6 h-6 text-brand-400 animate-spin" />
<p className="text-slate-500 text-xs">Carregando contatos</p>
</div>
) : contacts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center px-8">
<Users className="w-10 h-10 text-slate-700" />
<p className="text-slate-400 text-sm font-medium">Nenhum contato encontrado</p>
<p className="text-slate-600 text-xs">
{search
? 'Tente outro nome ou número.'
: 'Os contatos são sincronizados automaticamente quando o WhatsApp está conectado.'}
</p>
</div>
) : (
<>
{contacts.map((c) => {
const phone = getPhone(c)
const name = resolveDisplayName(c)
const sel = selected.has(phone)
const avatarUrl = resolveAvatarUrl(c, instanceId)
return (
<button
key={c.jid}
type="button"
onClick={() => toggle(c)}
className={`w-full flex items-center gap-3 px-5 py-2.5 text-left transition-colors border-b border-white/[0.03] last:border-0 ${
sel
? 'bg-brand-500/[0.08] hover:bg-brand-500/[0.12]'
: 'hover:bg-white/[0.04]'
}`}
>
{/* Checkbox */}
<div className={`w-5 h-5 rounded-md border-2 flex items-center justify-center flex-shrink-0 transition-all ${
sel ? 'bg-brand-500 border-brand-500' : 'border-white/20'
}`}>
{sel && <Check className="w-3 h-3 text-white" strokeWidth={3} />}
</div>
{/* Avatar — proxy local quando o banco indica que existe foto */}
<RowAvatar url={avatarUrl} name={name} jid={c.jid} />
{/* Info */}
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium truncate transition-colors ${sel ? 'text-brand-300' : 'text-white'}`}>
{name}
</p>
<p className="text-slate-500 text-xs truncate">
{formatPhone(phone, c.jid)}
</p>
</div>
{/* Selected indicator */}
{sel && (
<UserCheck className="w-4 h-4 text-brand-400 flex-shrink-0" />
)}
</button>
)
})}
{/* Load more */}
{hasMore && (
<div className="flex justify-center py-4">
<button
onClick={() => fetchContacts(search, offset)}
disabled={loading}
className="flex items-center gap-1.5 text-xs text-brand-400 hover:text-brand-300 transition-colors disabled:opacity-50"
>
{loading
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
: <ChevronDown className="w-3.5 h-3.5" />
}
Carregar mais contatos
</button>
</div>
)}
</>
)}
</div>
{/* ── Footer ──────────────────────────────────────────────────── */}
<div className="flex items-center justify-between gap-4 px-5 py-4 border-t border-white/[0.06]">
{selected.size > 0 ? (
<p className="text-sm text-white">
<span className="font-bold text-brand-400">{selected.size}</span>
<span className="text-slate-400">
{' '}{selected.size === 1 ? 'contato selecionado' : 'contatos selecionados'}
</span>
</p>
) : (
<p className="text-xs text-slate-600">Toque em um contato para selecioná-lo</p>
)}
<div className="flex items-center gap-2">
<button
onClick={onClose}
className="px-4 py-2 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/[0.05] transition-colors"
>
Cancelar
</button>
<button
onClick={handleConfirm}
disabled={selected.size === 0}
className="inline-flex items-center gap-1.5 px-5 py-2 rounded-xl text-sm font-semibold bg-brand-500 hover:bg-brand-600 active:scale-[0.98] text-white transition-all disabled:opacity-35 disabled:cursor-not-allowed"
>
<Users className="w-3.5 h-3.5" />
Adicionar{selected.size > 0 ? ` ${selected.size}` : ''}
</button>
</div>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
)
}