UI finish

This commit is contained in:
debu-2 2026-10-07 12:15:48 +05:30
parent 614cf1e7f5
commit b469a09cde
2 changed files with 552 additions and 231 deletions

View file

@ -1,4 +1,4 @@
import { MessageSquareText, RefreshCw, Settings2 } from 'lucide-react'
import { MessageSquareText, RefreshCw, Search, Send, Settings2, X } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { FormEvent } from 'react'
import { API_BASE_URL } from '../../lib/apiBase'
@ -26,6 +26,42 @@ function mediaUrl(mediaId: string) {
return `${API_BASE_URL}/api/v1/admin/whatsapp/media/${mediaId}`
}
function peerInitials(value: string) {
const digits = value.replace(/\D/g, '')
return digits.slice(-2) || 'WA'
}
function formatChatTime(value: string) {
const date = new Date(value)
const now = new Date()
const sameDay = date.toDateString() === now.toDateString()
if (sameDay) {
return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
}
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' })
}
const SEND_MODES: { id: SendMode; label: string }[] = [
{ id: 'text', label: 'Text' },
{ id: 'template', label: 'Template' },
{ id: 'media', label: 'Media' },
{ id: 'buttons', label: 'Buttons' },
{ id: 'reaction', label: 'Reaction' },
]
export function AdminWhatsAppPage() {
const [config, setConfig] = useState<AdminWhatsAppConfig | null>(null)
const [messages, setMessages] = useState<AdminWhatsAppMessage[]>([])
@ -57,8 +93,10 @@ export function AdminWhatsAppPage() {
const [buttonThree, setButtonThree] = useState('')
const [reactionEmoji, setReactionEmoji] = useState('👍')
const [reactionTarget, setReactionTarget] = useState('')
const [threadQuery, setThreadQuery] = useState('')
const chatEndRef = useRef<HTMLDivElement | null>(null)
const replyRef = useRef<HTMLTextAreaElement | null>(null)
async function loadMessages(peer?: string | null) {
const items = await adminApi.listWhatsAppMessages(300, peer || undefined)
@ -104,12 +142,24 @@ export function AdminWhatsAppPage() {
.sort((a, b) => (b.latest?.created_at ?? '').localeCompare(a.latest?.created_at ?? ''))
}, [messages])
const visibleThreads = useMemo(() => {
const query = threadQuery.trim().toLowerCase()
if (!query) return threads
return threads.filter(({ peer, latest }) => {
const haystack = `${formatWaNumber(peer)} ${peer} ${latest.body ?? ''} ${latest.message_type}`.toLowerCase()
return haystack.includes(query)
})
}, [threads, threadQuery])
const activeThread = useMemo(() => {
if (!selectedPeer) return []
const found = threads.find((item) => item.peer === selectedPeer)
return found?.thread ?? []
}, [threads, selectedPeer])
const activeLatest = activeThread[activeThread.length - 1]
const activeFirst = activeThread[0]
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [activeThread.length, selectedPeer])
@ -271,18 +321,28 @@ export function AdminWhatsAppPage() {
)}
<div className="admin-whatsapp-messenger">
<aside className="panel admin-whatsapp-sidebar">
<div className="panel-heading">
<aside className="admin-whatsapp-sidebar">
<div className="admin-whatsapp-sidebar-head">
<div>
<h2>Conversations</h2>
<p>{threads.length} active threads</p>
<p>{threads.length} active {threads.length === 1 ? 'thread' : 'threads'}</p>
</div>
</div>
<label className="admin-whatsapp-search">
<Search size={14} />
<input
value={threadQuery}
onChange={(event) => setThreadQuery(event.target.value)}
placeholder="Search by name, phone or message…"
/>
</label>
{threads.length === 0 ? (
<p className="admin-whatsapp-empty">No messages yet. Ask someone to message +91 94112 57916 or scan your Meta QR.</p>
<p className="admin-whatsapp-empty">No messages yet. Ask someone to message {config ? formatWaNumber(config.business_phone) : '+91 94112 57916'} or scan your Meta QR.</p>
) : visibleThreads.length === 0 ? (
<p className="admin-whatsapp-empty">No conversations match that search.</p>
) : (
<div className="admin-whatsapp-thread-list compact">
{threads.map(({ peer, latest, thread }) => (
{visibleThreads.map(({ peer, latest, thread }) => (
<button
key={peer}
type="button"
@ -293,195 +353,293 @@ export function AdminWhatsAppPage() {
if (lastInbound) markRead(lastInbound)
}}
>
<strong>{formatWaNumber(peer)}</strong>
<span>{latest.body || `[${latest.message_type}]`}</span>
<small>{new Date(latest.created_at).toLocaleString()}</small>
<span className="admin-whatsapp-avatar">{peerInitials(peer)}</span>
<span className="admin-whatsapp-thread-copy">
<strong>{formatWaNumber(peer)}</strong>
<span>{latest.body || `[${latest.message_type}]`}</span>
</span>
<small>{formatChatTime(latest.created_at)}</small>
</button>
))}
</div>
)}
</aside>
<section className="panel admin-whatsapp-chat">
{!selectedPeer ? (
<div className="admin-whatsapp-chat-empty">
<p>Select a conversation to view messages and reply.</p>
</div>
) : (
<>
<div className="admin-whatsapp-chat-head">
<strong>{formatWaNumber(selectedPeer)}</strong>
<small>{activeThread.length} messages</small>
<section className="admin-whatsapp-chat">
<div className="admin-whatsapp-chat-head">
{selectedPeer ? (
<>
<span className="admin-whatsapp-avatar">{peerInitials(selectedPeer)}</span>
<div>
<strong>{formatWaNumber(selectedPeer)}</strong>
<small>{activeThread.length} messages · {activeLatest ? formatChatTime(activeLatest.created_at) : 'No activity'}</small>
</div>
</>
) : (
<div>
<strong>Select a conversation</strong>
<small>Or start one with a new recipient below.</small>
</div>
<div className="admin-whatsapp-chat-body">
{activeThread.map((item) => (
<div
key={item.id}
className={`admin-whatsapp-bubble ${item.direction === 'inbound' ? 'inbound' : 'outbound'}`}
>
{item.media?.id && ['image', 'sticker'].includes(item.message_type) && (
<img src={mediaUrl(item.media.id)} alt={item.message_type} className="admin-whatsapp-media" />
)}
{item.media?.id && item.message_type === 'video' && (
<video src={mediaUrl(item.media.id)} controls className="admin-whatsapp-media" />
)}
{item.media?.id && item.message_type === 'audio' && (
<audio src={mediaUrl(item.media.id)} controls className="admin-whatsapp-media" />
)}
{item.media?.id && item.message_type === 'document' && (
<a href={mediaUrl(item.media.id)} target="_blank" rel="noreferrer">Download document</a>
)}
<span>{item.body || `[${item.message_type} · ${item.status}]`}</span>
<small>
{new Date(item.created_at).toLocaleTimeString()}
{item.status ? ` · ${item.status}` : ''}
{item.wa_message_id ? ` · ${item.wa_message_id.slice(0, 12)}…` : ''}
</small>
{item.direction === 'inbound' && item.wa_message_id && (
<button
type="button"
className="link-button"
onClick={() => {
setSendMode('reaction')
setReactionTarget(item.wa_message_id || '')
}}
>
React
</button>
)}
)}
</div>
<div className="admin-whatsapp-chat-body">
{!selectedPeer ? (
<div className="admin-whatsapp-chat-empty">
<MessageSquareText size={28} />
<p>Choose a thread on the left to read it and reply from the box below.</p>
</div>
) : (
activeThread.map((item, index) => {
const previous = activeThread[index - 1]
const showDay = !previous || new Date(previous.created_at).toDateString() !== new Date(item.created_at).toDateString()
return (
<div key={item.id} className="admin-whatsapp-message-row">
{showDay && <div className="admin-whatsapp-day">{formatDayLabel(item.created_at)}</div>}
<div className={`admin-whatsapp-bubble ${item.direction === 'inbound' ? 'inbound' : 'outbound'}`}>
{item.media?.id && ['image', 'sticker'].includes(item.message_type) && (
<img src={mediaUrl(item.media.id)} alt={item.message_type} className="admin-whatsapp-media" />
)}
{item.media?.id && item.message_type === 'video' && (
<video src={mediaUrl(item.media.id)} controls className="admin-whatsapp-media" />
)}
{item.media?.id && item.message_type === 'audio' && (
<audio src={mediaUrl(item.media.id)} controls className="admin-whatsapp-media" />
)}
{item.media?.id && item.message_type === 'document' && (
<a href={mediaUrl(item.media.id)} target="_blank" rel="noreferrer">Download document</a>
)}
<span>{item.body || `[${item.message_type} · ${item.status}]`}</span>
<small>
{new Date(item.created_at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
{item.status ? ` · ${item.status}` : ''}
{item.wa_message_id ? ` · ${item.wa_message_id.slice(0, 12)}…` : ''}
</small>
{item.direction === 'inbound' && item.wa_message_id && (
<span className="admin-whatsapp-bubble-actions">
<button
type="button"
className="link-button"
onClick={() => {
setSendMode('text')
setReplyTo(item.wa_message_id || '')
replyRef.current?.focus()
}}
>
Reply
</button>
<button
type="button"
className="link-button"
onClick={() => {
setSendMode('reaction')
setReactionTarget(item.wa_message_id || '')
}}
>
React
</button>
</span>
)}
</div>
</div>
))}
<div ref={chatEndRef} />
)
})
)}
<div ref={chatEndRef} />
</div>
<form className="admin-whatsapp-composer" onSubmit={sendMessage}>
<div className="admin-whatsapp-mode-tabs">
{SEND_MODES.map((mode) => (
<button
key={mode.id}
type="button"
className={sendMode === mode.id ? 'active' : ''}
onClick={() => setSendMode(mode.id)}
>
{mode.label}
</button>
))}
</div>
<div className="admin-whatsapp-form">
{!selectedPeer && (
<label>
<span>New recipient</span>
<input
value={newPeer}
onChange={(e) => setNewPeer(e.target.value)}
placeholder="9411257916 or +91 94112 57916"
/>
</label>
)}
{sendMode === 'text' && (
<>
{replyTo && (
<div className="admin-whatsapp-reply-chip">
<span>Replying to {replyTo.slice(0, 18)}…</span>
<button type="button" onClick={() => setReplyTo('')} aria-label="Clear reply">
<X size={12} />
</button>
</div>
)}
<label>
<span>Message</span>
<textarea
ref={replyRef}
value={textBody}
onChange={(e) => setTextBody(e.target.value)}
rows={3}
placeholder="Type a message…"
required
/>
</label>
<label>
<span>Reply to message ID (optional)</span>
<input value={replyTo} onChange={(e) => setReplyTo(e.target.value)} placeholder="wamid…" />
</label>
</>
)}
{sendMode === 'template' && (
<>
<label>
<span>Approved template</span>
<select value={templateName} onChange={(e) => setTemplateName(e.target.value)} required>
<option value="">Select template</option>
{templates.map((template) => (
<option key={template.id ?? template.name} value={template.name}>
{template.name} ({template.language})
</option>
))}
</select>
</label>
<label>
<span>Language code</span>
<input value={templateLanguage} onChange={(e) => setTemplateLanguage(e.target.value)} />
</label>
<small>Templates work outside the 24-hour customer service window.</small>
</>
)}
{sendMode === 'media' && (
<>
<label>
<span>Media type</span>
<select value={mediaType} onChange={(e) => setMediaType(e.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>
</label>
<label>
<span>Public HTTPS URL</span>
<input value={mediaLink} onChange={(e) => setMediaLink(e.target.value)} placeholder="https://…" required />
</label>
<label>
<span>Caption</span>
<input value={mediaCaption} onChange={(e) => setMediaCaption(e.target.value)} />
</label>
{mediaType === 'document' && (
<label>
<span>Filename</span>
<input value={mediaFilename} onChange={(e) => setMediaFilename(e.target.value)} />
</label>
)}
<label>
<span>Reply to message ID (optional)</span>
<input value={replyTo} onChange={(e) => setReplyTo(e.target.value)} placeholder="wamid…" />
</label>
</>
)}
{sendMode === 'buttons' && (
<>
<label>
<span>Body</span>
<textarea value={buttonBody} onChange={(e) => setButtonBody(e.target.value)} rows={3} required />
</label>
<label><span>Button 1</span><input value={buttonOne} onChange={(e) => setButtonOne(e.target.value)} required /></label>
<label><span>Button 2</span><input value={buttonTwo} onChange={(e) => setButtonTwo(e.target.value)} /></label>
<label><span>Button 3</span><input value={buttonThree} onChange={(e) => setButtonThree(e.target.value)} /></label>
<label>
<span>Reply to message ID (optional)</span>
<input value={replyTo} onChange={(e) => setReplyTo(e.target.value)} placeholder="wamid…" />
</label>
</>
)}
{sendMode === 'reaction' && (
<>
<label>
<span>Message ID</span>
<input value={reactionTarget} onChange={(e) => setReactionTarget(e.target.value)} required />
</label>
<label>
<span>Emoji</span>
<input value={reactionEmoji} onChange={(e) => setReactionEmoji(e.target.value)} required />
</label>
</>
)}
{error && <p className="auth-error">{error}</p>}
<div className="admin-whatsapp-composer-footer">
<small>Within 24h of a customer message: free-form text, media, and buttons. Outside that window, use a template.</small>
<button className="primary-button" type="submit" disabled={sending || !readyToSend || (!selectedPeer && !newPeer.trim())}>
<Send size={14} />
{sending ? 'Sending…' : 'Send'}
</button>
</div>
</>
)}
</div>
</form>
</section>
<section className="panel admin-whatsapp-compose-panel">
<div className="panel-heading">
<div>
<h2>Send</h2>
<p>Supported: text, template, media, buttons, reaction, read receipts.</p>
</div>
<aside className="admin-whatsapp-details">
<div className="admin-whatsapp-details-hero">
<span className="admin-whatsapp-avatar large">{selectedPeer ? peerInitials(selectedPeer) : 'WA'}</span>
<strong>{selectedPeer ? formatWaNumber(selectedPeer) : 'No contact selected'}</strong>
<small>{selectedPeer ? 'WhatsApp conversation' : 'Pick a thread to see details'}</small>
</div>
<div className="admin-whatsapp-mode-tabs">
{(['text', 'template', 'media', 'buttons', 'reaction'] as SendMode[]).map((mode) => (
<button
key={mode}
type="button"
className={sendMode === mode ? 'active' : ''}
onClick={() => setSendMode(mode)}
>
{mode}
<dl className="admin-whatsapp-details-list">
<div>
<dt>Phone</dt>
<dd>{selectedPeer ? formatWaNumber(selectedPeer) : '—'}</dd>
</div>
<div>
<dt>Messages</dt>
<dd>{selectedPeer ? activeThread.length : threads.length}</dd>
</div>
<div>
<dt>First seen</dt>
<dd>{activeFirst ? new Date(activeFirst.created_at).toLocaleString() : '—'}</dd>
</div>
<div>
<dt>Last message</dt>
<dd>{activeLatest ? new Date(activeLatest.created_at).toLocaleString() : '—'}</dd>
</div>
<div>
<dt>Last status</dt>
<dd>{activeLatest?.status || '—'}</dd>
</div>
<div>
<dt>Source</dt>
<dd>{activeLatest ? (activeLatest.direction === 'inbound' ? 'Customer' : 'Business') : '—'}</dd>
</div>
<div>
<dt>Business line</dt>
<dd>{config ? formatWaNumber(config.business_phone) : '+91 94112 57916'}</dd>
</div>
</dl>
<div className="admin-whatsapp-quick">
<h3>Quick actions</h3>
{SEND_MODES.map((mode) => (
<button key={mode.id} type="button" onClick={() => setSendMode(mode.id)}>
{mode.label === 'Text' ? 'Reply with text' : `Send ${mode.label.toLowerCase()}`}
</button>
))}
</div>
<form className="admin-whatsapp-form" onSubmit={sendMessage}>
{!selectedPeer && (
<label>
<span>New recipient</span>
<input
value={newPeer}
onChange={(e) => setNewPeer(e.target.value)}
placeholder="9411257916 or +91 94112 57916"
/>
</label>
)}
{sendMode === 'text' && (
<>
<label>
<span>Message</span>
<textarea value={textBody} onChange={(e) => setTextBody(e.target.value)} rows={5} required />
</label>
<label>
<span>Reply to message ID (optional)</span>
<input value={replyTo} onChange={(e) => setReplyTo(e.target.value)} placeholder="wamid…" />
</label>
</>
)}
{sendMode === 'template' && (
<>
<label>
<span>Approved template</span>
<select value={templateName} onChange={(e) => setTemplateName(e.target.value)} required>
<option value="">Select template</option>
{templates.map((template) => (
<option key={template.id ?? template.name} value={template.name}>
{template.name} ({template.language})
</option>
))}
</select>
</label>
<label>
<span>Language code</span>
<input value={templateLanguage} onChange={(e) => setTemplateLanguage(e.target.value)} />
</label>
<small>Templates work outside the 24-hour customer service window.</small>
</>
)}
{sendMode === 'media' && (
<>
<label>
<span>Media type</span>
<select value={mediaType} onChange={(e) => setMediaType(e.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>
</label>
<label>
<span>Public HTTPS URL</span>
<input value={mediaLink} onChange={(e) => setMediaLink(e.target.value)} placeholder="https://…" required />
</label>
<label>
<span>Caption</span>
<input value={mediaCaption} onChange={(e) => setMediaCaption(e.target.value)} />
</label>
{mediaType === 'document' && (
<label>
<span>Filename</span>
<input value={mediaFilename} onChange={(e) => setMediaFilename(e.target.value)} />
</label>
)}
</>
)}
{sendMode === 'buttons' && (
<>
<label>
<span>Body</span>
<textarea value={buttonBody} onChange={(e) => setButtonBody(e.target.value)} rows={4} required />
</label>
<label><span>Button 1</span><input value={buttonOne} onChange={(e) => setButtonOne(e.target.value)} required /></label>
<label><span>Button 2</span><input value={buttonTwo} onChange={(e) => setButtonTwo(e.target.value)} /></label>
<label><span>Button 3</span><input value={buttonThree} onChange={(e) => setButtonThree(e.target.value)} /></label>
</>
)}
{sendMode === 'reaction' && (
<>
<label>
<span>Message ID</span>
<input value={reactionTarget} onChange={(e) => setReactionTarget(e.target.value)} required />
</label>
<label>
<span>Emoji</span>
<input value={reactionEmoji} onChange={(e) => setReactionEmoji(e.target.value)} required />
</label>
</>
)}
{error && <p className="auth-error">{error}</p>}
<button className="primary-button" type="submit" disabled={sending || !readyToSend || (!selectedPeer && !newPeer.trim())}>
{sending ? 'Sending…' : 'Send'}
</button>
<small>Within 24h of customer message: free-form text/media/buttons. Outside window: use template.</small>
</form>
</section>
</aside>
</div>
</div>
)