510 lines
24 KiB
TypeScript
510 lines
24 KiB
TypeScript
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>
|
|
)
|
|
}
|