urmi-ai/frontend/src/features/whatsapp/CustomerWhatsAppInbox.tsx
2026-10-14 06:04:27 +05:30

540 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 [deliveryAccount, setDeliveryAccount] = useState(account)
const [appSecret, setAppSecret] = useState('')
const [deliveryBusy, setDeliveryBusy] = useState(false)
const line = deliveryAccount.primary_phone?.display_phone || deliveryAccount.name
const canSend = deliveryAccount.messaging_status === 'active' && deliveryAccount.usable
const repliesLive = deliveryAccount.webhooks_status === 'active'
async function saveDelivery(event: FormEvent) {
event.preventDefault()
setDeliveryBusy(true)
setError('')
try {
setDeliveryAccount(await whatsappApi.enableDelivery(account.id, appSecret.trim()))
setAppSecret('')
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Replies could not be turned on')
} finally {
setDeliveryBusy(false)
}
}
async function loadMessages() {
setMessages(await whatsappApi.messages(account.id))
}
useEffect(() => {
Promise.all([
whatsappApi.messages(account.id),
account.status === 'disconnected'
? Promise.resolve({ available: false, templates: [] as WhatsAppTemplateRow[] })
: 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">{deliveryAccount.last_error || 'You can read messages already delivered here. Sending stays off until Meta connects this number.'}</p>}
{canSend && !repliesLive && (
<form className="wa-biz-notice" onSubmit={(event) => void saveDelivery(event)}>
<p>WhatsApp accepted messages from this number, but delivery updates and replies are still going to the Meta app that created the token. Paste that app’s App secret so they arrive here. In Meta: the app → App settings → Basic → App secret.</p>
<label className="explorer-input">
<strong>App secret</strong>
<input type="password" value={appSecret} onChange={(event) => setAppSecret(event.target.value)} autoComplete="off" required />
</label>
<button className="primary-button" type="submit" disabled={deliveryBusy}>Turn on replies</button>
</form>
)}
<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>
)
}