meta whatsapp chat
This commit is contained in:
parent
54f75387eb
commit
5ced0d0812
10 changed files with 1015 additions and 25 deletions
510
frontend/src/features/whatsapp/CustomerWhatsAppInbox.tsx
Normal file
510
frontend/src/features/whatsapp/CustomerWhatsAppInbox.tsx
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
import {
|
||||
Check,
|
||||
CheckCheck,
|
||||
ChevronLeft,
|
||||
Image as ImageIcon,
|
||||
MessageCircle,
|
||||
MessageSquareText,
|
||||
MoreHorizontal,
|
||||
Paperclip,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Send,
|
||||
Smile,
|
||||
Star,
|
||||
UserRound,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { API_BASE_URL } from '../../lib/apiBase'
|
||||
import {
|
||||
whatsappApi,
|
||||
type CustomerWhatsAppMessage,
|
||||
type CustomerWhatsAppSend,
|
||||
type WhatsAppAccount,
|
||||
type WhatsAppTemplateRow,
|
||||
} from '../../lib/api'
|
||||
|
||||
type SendMode = 'text' | 'template' | 'media' | 'buttons'
|
||||
type ListFilter = 'all' | 'unread' | 'important'
|
||||
type ProfileTab = 'details' | 'events'
|
||||
|
||||
const REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🙏']
|
||||
|
||||
function formatWaNumber(value: string) {
|
||||
const digits = value.replace(/\D/g, '')
|
||||
if (digits.length === 12 && digits.startsWith('91')) {
|
||||
const local = digits.slice(2)
|
||||
return `+91 ${local.slice(0, 5)} ${local.slice(5)}`
|
||||
}
|
||||
if (digits.length === 10) return `+91 ${digits.slice(0, 5)} ${digits.slice(5)}`
|
||||
return value.startsWith('+') ? value : `+${digits || value}`
|
||||
}
|
||||
|
||||
function formatClock(value: string) {
|
||||
return new Date(value).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function formatChatTime(value: string) {
|
||||
const date = new Date(value)
|
||||
const now = new Date()
|
||||
if (date.toDateString() === now.toDateString()) return formatClock(value)
|
||||
const yesterday = new Date(now)
|
||||
yesterday.setDate(now.getDate() - 1)
|
||||
if (date.toDateString() === yesterday.toDateString()) return 'Yesterday'
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function formatDayLabel(value: string) {
|
||||
const date = new Date(value)
|
||||
const now = new Date()
|
||||
if (date.toDateString() === now.toDateString()) return 'Today'
|
||||
const yesterday = new Date(now)
|
||||
yesterday.setDate(now.getDate() - 1)
|
||||
if (date.toDateString() === yesterday.toDateString()) return 'Yesterday'
|
||||
return date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function formatWhen(value: string) {
|
||||
return new Date(value).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
const parts = name.split(/\s+/).filter(Boolean)
|
||||
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase()
|
||||
return name.replace(/\D/g, '').slice(-2) || 'WA'
|
||||
}
|
||||
|
||||
export function CustomerWhatsAppInbox({ account, onBack }: { account: WhatsAppAccount; onBack: () => void }) {
|
||||
const [messages, setMessages] = useState<CustomerWhatsAppMessage[]>([])
|
||||
const [templates, setTemplates] = useState<WhatsAppTemplateRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedPeer, setSelectedPeer] = useState<string | null>(null)
|
||||
const [startingNew, setStartingNew] = useState(false)
|
||||
const [newPeer, setNewPeer] = useState('')
|
||||
const [sendMode, setSendMode] = useState<SendMode>('text')
|
||||
const [error, setError] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const [textBody, setTextBody] = useState('')
|
||||
const [replyTo, setReplyTo] = useState('')
|
||||
const [replyPreview, setReplyPreview] = useState('')
|
||||
const [templateName, setTemplateName] = useState('')
|
||||
const [templateLanguage, setTemplateLanguage] = useState('en')
|
||||
const [mediaType, setMediaType] = useState<'image' | 'document' | 'audio' | 'video'>('image')
|
||||
const [mediaLink, setMediaLink] = useState('')
|
||||
const [mediaCaption, setMediaCaption] = useState('')
|
||||
const [buttonBody, setButtonBody] = useState('')
|
||||
const [buttonOne, setButtonOne] = useState('')
|
||||
const [buttonTwo, setButtonTwo] = useState('')
|
||||
const [buttonThree, setButtonThree] = useState('')
|
||||
const [threadQuery, setThreadQuery] = useState('')
|
||||
const [listFilter, setListFilter] = useState<ListFilter>('all')
|
||||
const [readPeers, setReadPeers] = useState<Set<string>>(new Set())
|
||||
const [starred, setStarred] = useState<Set<string>>(new Set())
|
||||
const [profileTab, setProfileTab] = useState<ProfileTab>('details')
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const [detailsId, setDetailsId] = useState<string | null>(null)
|
||||
const [reactFor, setReactFor] = useState<string | null>(null)
|
||||
const [emojiOpen, setEmojiOpen] = useState(false)
|
||||
const chatEndRef = useRef<HTMLDivElement | null>(null)
|
||||
const line = account.primary_phone?.display_phone || account.name
|
||||
const canSend = account.messaging_status === 'active' && account.usable
|
||||
|
||||
async function loadMessages() {
|
||||
setMessages(await whatsappApi.messages(account.id))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
whatsappApi.messages(account.id),
|
||||
whatsappApi.templates(account.id).catch(() => ({ available: false, templates: [] as WhatsAppTemplateRow[] })),
|
||||
])
|
||||
.then(([items, templatePayload]) => {
|
||||
setMessages(items)
|
||||
setTemplates(templatePayload.templates.filter((item) => item.status === 'APPROVED' || !item.status))
|
||||
})
|
||||
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load conversations'))
|
||||
.finally(() => setLoading(false))
|
||||
const timer = window.setInterval(() => {
|
||||
loadMessages().catch(() => undefined)
|
||||
}, 8000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [account.id])
|
||||
|
||||
const threads = useMemo(() => {
|
||||
const grouped = new Map<string, CustomerWhatsAppMessage[]>()
|
||||
for (const message of messages) {
|
||||
if (message.message_type === 'status') continue
|
||||
const peer = message.direction === 'inbound' ? message.from : message.to
|
||||
if (!peer) continue
|
||||
const list = grouped.get(peer) ?? []
|
||||
list.push(message)
|
||||
grouped.set(peer, list)
|
||||
}
|
||||
return [...grouped.entries()]
|
||||
.map(([peer, thread]) => {
|
||||
const sorted = [...thread].sort((a, b) => a.created_at.localeCompare(b.created_at))
|
||||
const latest = sorted[sorted.length - 1]
|
||||
const name = sorted.find((item) => item.contact_name)?.contact_name
|
||||
const unread = readPeers.has(peer) ? 0 : sorted.filter((item) => item.direction === 'inbound' && item.status !== 'read').length
|
||||
return {
|
||||
peer,
|
||||
title: name || formatWaNumber(peer),
|
||||
phoneLabel: formatWaNumber(peer),
|
||||
initials: initials(name || peer),
|
||||
messages: sorted,
|
||||
latest,
|
||||
unread,
|
||||
important: starred.has(peer),
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.latest.created_at.localeCompare(a.latest.created_at))
|
||||
}, [messages, readPeers, starred])
|
||||
|
||||
const visibleThreads = threads.filter((thread) => {
|
||||
const query = threadQuery.trim().toLowerCase()
|
||||
if (query && !`${thread.title} ${thread.phoneLabel} ${thread.latest.body || ''}`.toLowerCase().includes(query)) return false
|
||||
if (listFilter === 'unread') return thread.unread > 0
|
||||
if (listFilter === 'important') return thread.important
|
||||
return true
|
||||
})
|
||||
const active = threads.find((thread) => thread.peer === selectedPeer) || null
|
||||
const activeThread = active?.messages ?? []
|
||||
const unreadTotal = threads.reduce((sum, thread) => sum + thread.unread, 0)
|
||||
const importantTotal = threads.filter((thread) => thread.important).length
|
||||
|
||||
useEffect(() => {
|
||||
chatEndRef.current?.scrollIntoView({ block: 'end' })
|
||||
}, [activeThread.length, selectedPeer])
|
||||
|
||||
function openThread(peer: string) {
|
||||
setSelectedPeer(peer)
|
||||
setStartingNew(false)
|
||||
setReadPeers((current) => new Set(current).add(peer))
|
||||
setMoreOpen(false)
|
||||
}
|
||||
|
||||
async function sendReaction(message: CustomerWhatsAppMessage, emoji: string) {
|
||||
if (!message.wa_message_id) return
|
||||
setSending(true)
|
||||
setError('')
|
||||
try {
|
||||
await whatsappApi.sendAccountMessage(account.id, {
|
||||
to: message.from,
|
||||
message_type: 'reaction',
|
||||
reaction_message_id: message.wa_message_id,
|
||||
reaction_emoji: emoji,
|
||||
})
|
||||
setReactFor(null)
|
||||
await loadMessages()
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to send reaction')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
const peer = active?.peer || newPeer
|
||||
if (!peer) {
|
||||
setError('Enter a WhatsApp number')
|
||||
return
|
||||
}
|
||||
setSending(true)
|
||||
setError('')
|
||||
const payload: CustomerWhatsAppSend = { to: peer, message_type: sendMode === 'buttons' ? 'interactive_buttons' : sendMode }
|
||||
if (replyTo) payload.reply_to = replyTo
|
||||
if (sendMode === 'text') payload.text = textBody.trim()
|
||||
if (sendMode === 'template') {
|
||||
payload.template_name = templateName
|
||||
payload.template_language = templateLanguage
|
||||
}
|
||||
if (sendMode === 'media') {
|
||||
payload.message_type = mediaType
|
||||
payload.media_link = mediaLink.trim()
|
||||
payload.media_caption = mediaCaption.trim()
|
||||
}
|
||||
if (sendMode === 'buttons') {
|
||||
payload.interactive_body = buttonBody.trim()
|
||||
payload.interactive_buttons = [buttonOne, buttonTwo, buttonThree]
|
||||
.map((title) => title.trim())
|
||||
.filter(Boolean)
|
||||
.map((title, index) => ({ id: `btn_${index + 1}`, title }))
|
||||
}
|
||||
try {
|
||||
const sent = await whatsappApi.sendAccountMessage(account.id, payload)
|
||||
setSelectedPeer(sent.to)
|
||||
setStartingNew(false)
|
||||
setNewPeer('')
|
||||
setTextBody('')
|
||||
setButtonBody('')
|
||||
setMediaCaption('')
|
||||
setReplyTo('')
|
||||
setReplyPreview('')
|
||||
setSendMode('text')
|
||||
await loadMessages()
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to send message')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
function mediaUrl(mediaId: string) {
|
||||
return `${API_BASE_URL}/api/v1/whatsapp/accounts/${account.id}/media/${mediaId}`
|
||||
}
|
||||
|
||||
if (loading) return <p className="explorer-loading">Loading WhatsApp conversations…</p>
|
||||
|
||||
return (
|
||||
<div className="wa-page wa-customer">
|
||||
<div className="wa-page-title">
|
||||
<div>
|
||||
<button type="button" className="wa-biz-back" onClick={onBack}><ChevronLeft size={16} /> Accounts</button>
|
||||
<h1><MessageCircle size={20} /> {account.business_name || account.name}</h1>
|
||||
<p>Conversations on {formatWaNumber(line)}.</p>
|
||||
</div>
|
||||
<div className="wa-topbar-actions">
|
||||
<span className={canSend ? 'wa-connected' : 'wa-connected pending'}><i />{canSend ? 'Messaging' : 'Messaging unavailable'}</span>
|
||||
<button type="button" className="wa-ghost" onClick={() => void loadMessages()}><RefreshCw size={14} /> Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
{!canSend && <p className="wa-biz-notice">{account.last_error || 'You can read messages already delivered here. Sending stays off until Meta connects this number.'}</p>}
|
||||
<div className="wa-inbox">
|
||||
<aside className="wa-list">
|
||||
<div className="wa-list-head">
|
||||
<h2>Conversations</h2>
|
||||
<button type="button" className="wa-icon-btn" aria-label="New conversation" onClick={() => { setStartingNew(true); setSelectedPeer(null) }}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<label className="wa-search">
|
||||
<Search size={14} />
|
||||
<input value={threadQuery} onChange={(event) => setThreadQuery(event.target.value)} placeholder="Search by name, phone or message…" />
|
||||
</label>
|
||||
<div className="wa-filters">
|
||||
<button type="button" className={listFilter === 'all' ? 'active' : ''} onClick={() => setListFilter('all')}>All <em>{threads.length}</em></button>
|
||||
<button type="button" className={listFilter === 'unread' ? 'active' : ''} onClick={() => setListFilter('unread')}>Unread <em>{unreadTotal}</em></button>
|
||||
<button type="button" className={listFilter === 'important' ? 'active' : ''} onClick={() => setListFilter('important')}>Important <em>{importantTotal}</em></button>
|
||||
</div>
|
||||
<div className="wa-thread-list">
|
||||
{visibleThreads.length === 0 && <p className="wa-empty">No conversations yet.</p>}
|
||||
{visibleThreads.map((thread) => (
|
||||
<button key={thread.peer} type="button" className={`wa-thread ${selectedPeer === thread.peer ? 'active' : ''}`} onClick={() => openThread(thread.peer)}>
|
||||
<span className="wa-avatar wa-avatar-green">{thread.initials}</span>
|
||||
<span className="wa-thread-copy">
|
||||
<strong>{thread.title}</strong>
|
||||
<span>{thread.latest.body || 'Attachment'}</span>
|
||||
</span>
|
||||
<span className="wa-thread-meta">
|
||||
<small>{formatChatTime(thread.latest.created_at)}</small>
|
||||
{thread.unread > 0 && <em>{thread.unread}</em>}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="wa-chat">
|
||||
<div className="wa-chat-head">
|
||||
{active ? (
|
||||
<>
|
||||
<span className="wa-avatar wa-avatar-green">{active.initials}</span>
|
||||
<div className="wa-chat-id">
|
||||
<strong>{active.title}</strong>
|
||||
<small>WhatsApp</small>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="wa-chat-id">
|
||||
<strong>{startingNew ? 'New conversation' : 'Select a conversation'}</strong>
|
||||
<small>{startingNew ? 'Enter a WhatsApp number' : formatWaNumber(line)}</small>
|
||||
</div>
|
||||
)}
|
||||
<div className="wa-chat-tools">
|
||||
<button
|
||||
type="button"
|
||||
className={`wa-tool ${active && starred.has(active.peer) ? 'on' : ''}`}
|
||||
onClick={() => {
|
||||
if (!active) return
|
||||
setStarred((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(active.peer)) next.delete(active.peer)
|
||||
else next.add(active.peer)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Star size={14} /> Star
|
||||
</button>
|
||||
<div className="wa-pop">
|
||||
<button type="button" className="wa-tool" onClick={() => setMoreOpen((value) => !value)}><MoreHorizontal size={14} /> More</button>
|
||||
{moreOpen && active && (
|
||||
<div className="wa-menu">
|
||||
<button type="button" onClick={() => { navigator.clipboard.writeText(active.phoneLabel); setMoreOpen(false) }}>Copy number</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wa-canvas">
|
||||
{!active && !startingNew && <p className="wa-empty">Messages for this WhatsApp number appear here.</p>}
|
||||
{activeThread.map((item, index) => {
|
||||
const previous = activeThread[index - 1]
|
||||
const showDay = !previous || new Date(previous.created_at).toDateString() !== new Date(item.created_at).toDateString()
|
||||
const outbound = item.direction === 'outbound'
|
||||
return (
|
||||
<div key={item.id}>
|
||||
{showDay && <div className="wa-day">{formatDayLabel(item.created_at)}</div>}
|
||||
<div className={`wa-bubble-row ${outbound ? 'out' : 'in'}`}>
|
||||
<div className={`wa-bubble ${outbound ? 'out' : 'in'}`}>
|
||||
{item.media?.id && ['image', 'sticker'].includes(item.message_type) && <img src={mediaUrl(item.media.id)} alt="" className="wa-media" />}
|
||||
{item.media?.id && item.message_type === 'video' && <video src={mediaUrl(item.media.id)} controls className="wa-media" />}
|
||||
{item.media?.id && item.message_type === 'audio' && <audio src={mediaUrl(item.media.id)} controls />}
|
||||
{item.media?.id && item.message_type === 'document' && <a href={mediaUrl(item.media.id)} target="_blank" rel="noreferrer">Open document</a>}
|
||||
{item.message_type === 'template' && <em className="wa-kind">Template</em>}
|
||||
{item.body && <p>{item.body}</p>}
|
||||
<footer>
|
||||
<time>{formatClock(item.created_at)}</time>
|
||||
{outbound && (item.status === 'delivered' || item.status === 'read' ? <CheckCheck size={13} className={item.status === 'read' ? 'read' : ''} /> : <Check size={13} />)}
|
||||
</footer>
|
||||
{detailsId === item.id && (
|
||||
<div className="wa-inspector">
|
||||
<strong>Message details</strong>
|
||||
<span>Status · {item.status}</span>
|
||||
<span>Type · {item.message_type.replace(/_/g, ' ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="wa-hover">
|
||||
{!outbound && <button type="button" onClick={() => { setReplyTo(item.wa_message_id || ''); setReplyPreview(item.body || 'Attachment'); setSendMode('text') }}>Reply</button>}
|
||||
{!outbound && item.wa_message_id && <button type="button" onClick={() => setReactFor(reactFor === item.id ? null : item.id)}>React</button>}
|
||||
<button type="button" onClick={() => setDetailsId(detailsId === item.id ? null : item.id)} aria-label="Message details"><MoreHorizontal size={14} /></button>
|
||||
{reactFor === item.id && (
|
||||
<span className="wa-reacts">
|
||||
{REACTIONS.map((emoji) => <button key={emoji} type="button" onClick={() => void sendReaction(item, emoji)}>{emoji}</button>)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
<form className="wa-composer" onSubmit={(event) => void sendMessage(event)}>
|
||||
<div className="wa-modes">
|
||||
{(['text', 'template', 'media', 'buttons'] as const).map((id) => (
|
||||
<button key={id} type="button" className={sendMode === id ? 'active' : ''} onClick={() => setSendMode(id)}>{id[0].toUpperCase() + id.slice(1)}</button>
|
||||
))}
|
||||
</div>
|
||||
{replyPreview && (
|
||||
<div className="wa-quote">
|
||||
<div><strong>Replying</strong><p>{replyPreview}</p></div>
|
||||
<button type="button" aria-label="Cancel reply" onClick={() => { setReplyPreview(''); setReplyTo('') }}><X size={14} /></button>
|
||||
</div>
|
||||
)}
|
||||
{(startingNew || !active) && <input className="wa-new-peer" value={newPeer} onChange={(event) => setNewPeer(event.target.value)} placeholder="Recipient number, e.g. 919453226814" />}
|
||||
{sendMode === 'template' && (
|
||||
<div className="wa-extra">
|
||||
<select value={templateName} onChange={(event) => setTemplateName(event.target.value)} required>
|
||||
<option value="">Choose a template</option>
|
||||
{templates.map((template) => <option key={`${template.name}-${template.language}`} value={template.name || ''}>{template.name} ({template.language})</option>)}
|
||||
</select>
|
||||
<input value={templateLanguage} onChange={(event) => setTemplateLanguage(event.target.value)} aria-label="Language code" />
|
||||
</div>
|
||||
)}
|
||||
{sendMode === 'media' && (
|
||||
<div className="wa-extra">
|
||||
<select value={mediaType} onChange={(event) => setMediaType(event.target.value as typeof mediaType)}>
|
||||
<option value="image">Image</option>
|
||||
<option value="document">Document</option>
|
||||
<option value="audio">Audio</option>
|
||||
<option value="video">Video</option>
|
||||
</select>
|
||||
<input value={mediaLink} onChange={(event) => setMediaLink(event.target.value)} placeholder="Public file link" required />
|
||||
</div>
|
||||
)}
|
||||
{sendMode === 'buttons' && (
|
||||
<div className="wa-extra">
|
||||
<input value={buttonOne} onChange={(event) => setButtonOne(event.target.value)} placeholder="Button 1" required />
|
||||
<input value={buttonTwo} onChange={(event) => setButtonTwo(event.target.value)} placeholder="Button 2" />
|
||||
<input value={buttonThree} onChange={(event) => setButtonThree(event.target.value)} placeholder="Button 3" />
|
||||
</div>
|
||||
)}
|
||||
<div className="wa-compose-box">
|
||||
<textarea
|
||||
rows={2}
|
||||
value={sendMode === 'media' ? mediaCaption : sendMode === 'buttons' ? buttonBody : textBody}
|
||||
onChange={(event) => {
|
||||
if (sendMode === 'media') setMediaCaption(event.target.value)
|
||||
else if (sendMode === 'buttons') setButtonBody(event.target.value)
|
||||
else setTextBody(event.target.value)
|
||||
}}
|
||||
placeholder={canSend ? 'Type a message…' : 'Sending is unavailable until this number is connected'}
|
||||
required={sendMode !== 'template'}
|
||||
disabled={!canSend}
|
||||
/>
|
||||
</div>
|
||||
<div className="wa-compose-bar">
|
||||
<div className="wa-compose-icons">
|
||||
<button type="button" aria-label="Emoji" onClick={() => setEmojiOpen((value) => !value)}><Smile size={16} /></button>
|
||||
<button type="button" aria-label="Attach file" onClick={() => { setSendMode('media'); setMediaType('document') }}><Paperclip size={16} /></button>
|
||||
<button type="button" aria-label="Image" onClick={() => { setSendMode('media'); setMediaType('image') }}><ImageIcon size={16} /></button>
|
||||
{emojiOpen && (
|
||||
<span className="wa-reacts static">
|
||||
{REACTIONS.map((emoji) => <button key={emoji} type="button" onClick={() => { setTextBody((value) => `${value}${emoji}`); setEmojiOpen(false); setSendMode('text') }}>{emoji}</button>)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="wa-error">{error}</p>}
|
||||
<button className="wa-send" type="submit" disabled={sending || !canSend}><Send size={14} /> {sending ? 'Sending' : 'Send'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside className="wa-profile">
|
||||
<div className="wa-profile-hero">
|
||||
<span className="wa-avatar large wa-avatar-green">{active ? active.initials : <UserRound size={22} />}</span>
|
||||
<div>
|
||||
<strong className="wa-name">{active?.title || 'No contact'}</strong>
|
||||
<small>{active ? 'WhatsApp' : 'Select a conversation'}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wa-tabs">
|
||||
{(['details', 'events'] as const).map((id) => (
|
||||
<button key={id} type="button" className={profileTab === id ? 'active' : ''} onClick={() => setProfileTab(id)}>{id[0].toUpperCase() + id.slice(1)}</button>
|
||||
))}
|
||||
</div>
|
||||
{profileTab === 'details' && active && (
|
||||
<dl className="wa-fields">
|
||||
<div><dt>Phone</dt><dd>{active.phoneLabel}</dd></div>
|
||||
<div><dt>Status</dt><dd>{active.latest.status}</dd></div>
|
||||
<div><dt>Messages</dt><dd>{active.messages.length}</dd></div>
|
||||
<div><dt>First seen</dt><dd>{formatWhen(active.messages[0].created_at)}</dd></div>
|
||||
<div><dt>Last message</dt><dd>{formatWhen(active.latest.created_at)}</dd></div>
|
||||
<div><dt>Source</dt><dd>WhatsApp</dd></div>
|
||||
</dl>
|
||||
)}
|
||||
{profileTab === 'details' && !active && <p className="wa-empty">Open a conversation to see the customer.</p>}
|
||||
{profileTab === 'events' && (
|
||||
<ul className="wa-events">
|
||||
{activeThread.length === 0 && <li><strong>No messages yet</strong></li>}
|
||||
{[...activeThread].reverse().map((item) => (
|
||||
<li key={item.id}><strong>{item.direction === 'inbound' ? 'Customer message' : 'Reply sent'}</strong><span>{formatWhen(item.created_at)}</span></li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="wa-quick">
|
||||
<h3>Quick actions</h3>
|
||||
<button type="button" onClick={() => setSendMode('template')}><MessageSquareText size={14} /> Send template</button>
|
||||
<button type="button" onClick={() => setSendMode('media')}><ImageIcon size={14} /> Send media</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { Check, ChevronLeft, Copy, ExternalLink, Loader2, MessageCircle, MoreHorizontal, Plus, RefreshCw } from 'lucide-react'
|
||||
import { CustomerWhatsAppInbox } from './CustomerWhatsAppInbox'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
whatsappApi,
|
||||
|
|
@ -10,7 +11,7 @@ import {
|
|||
type WhatsAppTemplateRow,
|
||||
} from '../../lib/api'
|
||||
|
||||
type Phase = 'loading' | 'landing' | 'list' | 'provisioning' | 'result' | 'detail'
|
||||
type Phase = 'loading' | 'landing' | 'list' | 'provisioning' | 'result' | 'detail' | 'inbox'
|
||||
type DetailTab = 'overview' | 'numbers' | 'templates' | 'configuration' | 'logs'
|
||||
|
||||
type SignupSession = {
|
||||
|
|
@ -137,6 +138,7 @@ export function WhatsAppSetupPage() {
|
|||
const [developer, setDeveloper] = useState<WhatsAppDeveloperConfig | null>(null)
|
||||
const [logs, setLogs] = useState<WhatsAppAccountLogs | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [pins, setPins] = useState<Record<string, string>>({})
|
||||
const sessionRef = useRef<SignupSession | null>(null)
|
||||
const codeRef = useRef<string | null>(null)
|
||||
const finishingRef = useRef(false)
|
||||
|
|
@ -330,6 +332,20 @@ export function WhatsAppSetupPage() {
|
|||
}
|
||||
}
|
||||
|
||||
async function registerPhone(phoneId: string) {
|
||||
if (!selected) return
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
replaceAccount(await whatsappApi.registerPhone(selected.id, phoneId, pins[phoneId]))
|
||||
setNotice('Urmi asked Meta to register this number for Cloud API.')
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to register this number')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDisconnect() {
|
||||
if (!disconnecting) return
|
||||
setBusy(true)
|
||||
|
|
@ -355,6 +371,16 @@ export function WhatsAppSetupPage() {
|
|||
|
||||
const resultAccount = result?.accounts[0]
|
||||
|
||||
function openInbox(accountId: string) {
|
||||
setSelectedId(accountId)
|
||||
setMenuId(null)
|
||||
setPhase('inbox')
|
||||
}
|
||||
|
||||
if (phase === 'inbox' && selected) {
|
||||
return <CustomerWhatsAppInbox account={selected} onBack={() => setPhase('list')} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wa-biz">
|
||||
{phase === 'loading' && <p className="explorer-loading">Loading WhatsApp Business…</p>}
|
||||
|
|
@ -470,6 +496,11 @@ export function WhatsAppSetupPage() {
|
|||
<StatusDot status={account.status} />
|
||||
</div>
|
||||
<div className="wa-biz-menu">
|
||||
{account.webhooks_status === 'active' && (
|
||||
<button type="button" className="wa-chat-launch" onClick={() => openInbox(account.id)}>
|
||||
<MessageCircle size={15} /> Chat
|
||||
</button>
|
||||
)}
|
||||
<button className="secondary-button" onClick={() => { setSelectedId(account.id); setTab('overview'); setPhase('detail') }}>Open</button>
|
||||
<button className="icon-button" aria-label="Account actions" onClick={() => setMenuId(menuId === account.id ? null : account.id)}>
|
||||
<MoreHorizontal size={16} />
|
||||
|
|
@ -520,6 +551,11 @@ export function WhatsAppSetupPage() {
|
|||
<StatusDot status={selected.status} />
|
||||
</div>
|
||||
<div className="wa-biz-actions">
|
||||
{selected.webhooks_status === 'active' && (
|
||||
<button type="button" className="wa-chat-launch" onClick={() => openInbox(selected.id)}>
|
||||
<MessageCircle size={15} /> Chat
|
||||
</button>
|
||||
)}
|
||||
<button className="secondary-button" onClick={() => void refreshSelected()} disabled={busy}>
|
||||
<RefreshCw size={14} /> Refresh
|
||||
</button>
|
||||
|
|
@ -565,6 +601,7 @@ export function WhatsAppSetupPage() {
|
|||
{tab === 'numbers' && (
|
||||
<section className="wa-biz-card">
|
||||
<h2>Phone numbers</h2>
|
||||
<p className="wa-biz-muted">Messaging stays unavailable until Meta reports this number as connected for Cloud API. Pending means the number is on the account but not registered yet. Quality stays unknown until then.</p>
|
||||
{selected.phones.length === 0 && <p className="wa-biz-muted">Meta has not returned a phone number for this account yet.</p>}
|
||||
<ul className="wa-biz-numbers">
|
||||
{selected.phones.map((phone) => (
|
||||
|
|
@ -572,9 +609,24 @@ export function WhatsAppSetupPage() {
|
|||
<div>
|
||||
<strong>{phone.display_phone || 'Number pending'}</strong>
|
||||
<small>{phone.verified_name || 'Display name pending'}</small>
|
||||
{phone.needs_otp && <small>Complete the SMS or voice code in Meta, then refresh. Log this number out of the WhatsApp app first.</small>}
|
||||
</div>
|
||||
<StatusDot status={phone.status} />
|
||||
<span>Quality: {phone.quality || '—'}</span>
|
||||
{phone.needs_registration && !phone.needs_otp && (
|
||||
<div className="wa-biz-register">
|
||||
<input
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
placeholder="6-digit PIN"
|
||||
value={pins[phone.id] || ''}
|
||||
onChange={(event) => setPins((current) => ({ ...current, [phone.id]: event.target.value.replace(/\D/g, '').slice(0, 6) }))}
|
||||
/>
|
||||
<button className="primary-button" type="button" disabled={busy} onClick={() => void registerPhone(phone.id)}>
|
||||
Register for Cloud API
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -1770,6 +1770,13 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
|
|||
.wa-compose-icons > button { display: grid; place-items: center; width: 30px; height: 30px; border: 0; border-radius: 8px; background: transparent; color: #607089; }
|
||||
.wa-compose-icons > button:hover { background: #f4f7fb; color: #1475ff; }
|
||||
.wa-send { display: inline-flex; align-items: center; gap: 6px; border: 0; border-radius: 10px; padding: 9px 14px; background: #1475ff; color: white; font-size: 13px; font-weight: 700; }
|
||||
.wa-chat-launch { display: inline-flex; align-items: center; gap: 6px; border: 0; border-radius: 999px; padding: 8px 14px; background: #25D366; color: #fff; font-size: 13px; font-weight: 700; box-shadow: 0 6px 14px rgba(37, 211, 102, .28); }
|
||||
.wa-chat-launch:hover { background: #1ebe5d; }
|
||||
.wa-customer .wa-send { background: #25D366; }
|
||||
.wa-customer .wa-send:disabled { background: #b7e7c7; }
|
||||
.wa-customer .wa-bubble.out { background: #d9fdd3; }
|
||||
.wa-customer .wa-filters button.active, .wa-customer .wa-modes button.active { color: #128C7E; }
|
||||
.wa-avatar-green { background: #128C7E; color: white; }
|
||||
.wa-send:disabled { opacity: .55; }
|
||||
.wa-error { margin: 0; color: #b42318; font-size: 12px; }
|
||||
.wa-profile { display: flex; flex-direction: column; border-left: 1px solid #eef2f6; background: #fff; overflow: auto; }
|
||||
|
|
@ -1879,6 +1886,8 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
|
|||
.wa-biz-capabilities li { justify-content: space-between; }
|
||||
.wa-biz-numbers { margin: 8px 0 0; padding: 0; list-style: none; display: grid; gap: 10px; }
|
||||
.wa-biz-numbers li { display: grid; grid-template-columns: 1fr auto auto; gap: 16px; align-items: center; padding: 12px 0; border-top: 1px solid #edf0f4; }
|
||||
.wa-biz-register { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.wa-biz-register input { width: 140px; padding: 10px 12px; border: 1px solid #dfe5ed; border-radius: 8px; font-size: 13px; letter-spacing: .2em; }
|
||||
.wa-biz-table { margin-top: 12px; }
|
||||
.wa-biz-table-head, .wa-biz-table-row { display: grid; grid-template-columns: 1.4fr .8fr .7fr .8fr .6fr; gap: 8px; padding: 10px 0; }
|
||||
.wa-biz-table-head { color: #8a95a7; font-size: 11px; font-weight: 700; text-transform: uppercase; }
|
||||
|
|
|
|||
|
|
@ -1138,6 +1138,8 @@ export type WhatsAppPhoneAsset = {
|
|||
quality: string | null
|
||||
status: string
|
||||
code_verification_status: string | null
|
||||
needs_otp?: boolean
|
||||
needs_registration?: boolean
|
||||
}
|
||||
|
||||
export type WhatsAppAccount = {
|
||||
|
|
@ -1188,6 +1190,36 @@ export type WhatsAppAccountLogs = {
|
|||
events: { id: string; field: string; event_type: string; from_wa_id: string | null; status: string | null; created_at: string }[]
|
||||
}
|
||||
|
||||
export type CustomerWhatsAppMessage = {
|
||||
id: string
|
||||
direction: 'inbound' | 'outbound'
|
||||
wa_message_id: string | null
|
||||
from: string
|
||||
to: string
|
||||
body: string | null
|
||||
message_type: string
|
||||
status: string
|
||||
contact_name?: string | null
|
||||
media?: { id?: string; mime_type?: string; caption?: string; filename?: string } | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type CustomerWhatsAppSend = {
|
||||
to: string
|
||||
message_type?: string
|
||||
text?: string
|
||||
reply_to?: string
|
||||
template_name?: string
|
||||
template_language?: string
|
||||
media_link?: string
|
||||
media_caption?: string
|
||||
media_filename?: string
|
||||
interactive_body?: string
|
||||
interactive_buttons?: { id: string; title: string }[]
|
||||
reaction_message_id?: string
|
||||
reaction_emoji?: string
|
||||
}
|
||||
|
||||
export type WhatsAppWebhookEvent = {
|
||||
id: string
|
||||
waba_id: string | null
|
||||
|
|
@ -1222,6 +1254,18 @@ export const whatsappApi = {
|
|||
jsonRequest<WhatsAppAccount>(`/api/v1/whatsapp/accounts/${accountId}/refresh`, { method: 'POST' }),
|
||||
disconnectAccount: (accountId: string) =>
|
||||
jsonRequest<WhatsAppAccount>(`/api/v1/whatsapp/accounts/${accountId}/disconnect`, { method: 'POST' }),
|
||||
registerPhone: (accountId: string, phoneId: string, pin?: string) =>
|
||||
jsonRequest<WhatsAppAccount>(`/api/v1/whatsapp/accounts/${accountId}/phones/${phoneId}/register`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ pin: pin || null }),
|
||||
}),
|
||||
messages: (accountId: string) =>
|
||||
jsonRequest<CustomerWhatsAppMessage[]>(`/api/v1/whatsapp/accounts/${accountId}/messages`),
|
||||
sendAccountMessage: (accountId: string, payload: CustomerWhatsAppSend) =>
|
||||
jsonRequest<CustomerWhatsAppMessage>(`/api/v1/whatsapp/accounts/${accountId}/messages/send`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
templates: (accountId: string) =>
|
||||
jsonRequest<{ available: boolean; templates: WhatsAppTemplateRow[] }>(`/api/v1/whatsapp/accounts/${accountId}/templates`),
|
||||
logs: (accountId: string) => jsonRequest<WhatsAppAccountLogs>(`/api/v1/whatsapp/accounts/${accountId}/logs`),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue