diff --git a/frontend/src/features/admin/AdminPortal.tsx b/frontend/src/features/admin/AdminPortal.tsx index b90fc16..2db2e9f 100644 --- a/frontend/src/features/admin/AdminPortal.tsx +++ b/frontend/src/features/admin/AdminPortal.tsx @@ -1,4 +1,19 @@ -import { ChevronRight, LockKeyhole, MessageSquareText, Network, Phone, ShieldCheck } from 'lucide-react' +import { + BarChart3, + ChevronRight, + Contact, + LayoutTemplate, + LockKeyhole, + Megaphone, + MessageSquareText, + Network, + Phone, + ScrollText, + Send, + Settings, + ShieldCheck, + Workflow, +} from 'lucide-react' import { useEffect, useState } from 'react' import type { FormEvent } from 'react' import { @@ -390,9 +405,23 @@ function AdminCompliance() { ) } +const WORKSPACE_NAV = [ + { id: 'conversations', label: 'Conversations', icon: MessageSquareText }, + { id: 'compose', label: 'Send message', icon: Send }, + { id: 'templates', label: 'Templates', icon: LayoutTemplate }, + { id: 'contacts', label: 'Contacts', icon: Contact }, + { id: 'campaigns', label: 'Campaigns', icon: Megaphone }, + { id: 'automations', label: 'Automations', icon: Workflow }, + { id: 'analytics', label: 'Analytics', icon: BarChart3 }, + { id: 'logs', label: 'Webhook logs', icon: ScrollText }, + { id: 'numbers', label: 'Phone numbers', icon: Phone }, +] as const + export function AdminApp() { const [authenticated, setAuthenticated] = useState(null) const [section, setSection] = useState<'compliance' | 'whatsapp'>('whatsapp') + const [focus, setFocus] = useState<'inbox' | 'templates' | 'compose'>('inbox') + const [nav, setNav] = useState('conversations') useEffect(() => { adminApi.me().then(() => setAuthenticated(true)).catch(() => setAuthenticated(false)) @@ -403,39 +432,74 @@ export function AdminApp() { setAuthenticated(false) } + function openWorkspace(id: string) { + setSection('whatsapp') + setNav(id) + if (id === 'templates') setFocus('templates') + else if (id === 'compose') setFocus('compose') + else setFocus('inbox') + } + if (authenticated === null) return
Checking admin session…
+ if (!authenticated) { + return ( +
+
+
UrmiAdmin portal
+
+
+ setAuthenticated(true)} /> +
+
+ ) + } return ( -
-
-
UrmiAdmin portal
- {authenticated && ( -
-
-
- {authenticated - ? (section === 'whatsapp' ? : ) - : setAuthenticated(true)} />} -
+ ) + })} + + +
+ Business line + WhatsApp connected +
+ +
+ {section === 'whatsapp' + ? + : ( + <> +
+ Settings + +
+ + + )} +
) } diff --git a/frontend/src/features/admin/AdminWhatsAppPage.tsx b/frontend/src/features/admin/AdminWhatsAppPage.tsx index 4fa877a..be9c19d 100644 --- a/frontend/src/features/admin/AdminWhatsAppPage.tsx +++ b/frontend/src/features/admin/AdminWhatsAppPage.tsx @@ -1,4 +1,30 @@ -import { MessageSquareText, RefreshCw, Search, Send, Settings2, X } from 'lucide-react' +import { + Ban, + Bell, + Braces, + Check, + CheckCheck, + ChevronDown, + Image as ImageIcon, + Megaphone, + MessageSquareText, + MoreHorizontal, + Paperclip, + Pencil, + Plus, + RefreshCw, + Search, + Send, + Settings2, + SlidersHorizontal, + Smile, + Sparkles, + Star, + StickyNote, + UserPlus, + UserRound, + X, +} from 'lucide-react' import { useEffect, useMemo, useRef, useState } from 'react' import type { FormEvent } from 'react' import { API_BASE_URL } from '../../lib/apiBase' @@ -10,7 +36,30 @@ import { type AdminWhatsAppTemplate, } from '../../lib/api' -type SendMode = 'text' | 'template' | 'media' | 'buttons' | 'reaction' +type SendMode = 'text' | 'template' | 'media' | 'buttons' +type ListFilter = 'all' | 'unread' | 'important' +type ProfileTab = 'details' | 'notes' | 'tags' | 'events' + +type UiMessage = AdminWhatsAppMessage & { buttons?: string[] } + +type ThreadRow = { + peer: string + title: string + phoneLabel: string + initials: string | null + color: string + messages: UiMessage[] + latest: UiMessage + unread: number + important: boolean + online: boolean + demo: boolean + source: string + tags: string[] +} + +const AVATAR_COLORS = ['#3b82f6', '#ec4899', '#22c55e', '#8b5cf6', '#f59e0b', '#14b8a6', '#6366f1', '#0ea5e9'] +const REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🙏'] function formatWaNumber(value: string) { const digits = value.replace(/\D/g, '') @@ -26,18 +75,14 @@ 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 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() - const sameDay = date.toDateString() === now.toDateString() - if (sameDay) { - return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) - } + 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' @@ -54,26 +99,214 @@ function formatDayLabel(value: string) { 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' }, +function formatWhen(value: string) { + return new Date(value).toLocaleString([], { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) +} + +function ago(minutes: number) { + return new Date(Date.now() - minutes * 60_000).toISOString() +} + +function demoMessage( + peerPhone: string, + id: string, + direction: 'inbound' | 'outbound', + body: string, + created_at: string, + status: string, + message_type = 'text', + buttons?: string[], +): UiMessage { + return { + id, + direction, + wa_message_id: `wamid.demo.${id}`, + from: direction === 'inbound' ? peerPhone : 'business', + to: direction === 'outbound' ? peerPhone : 'business', + body, + message_type, + status, + created_at, + buttons, + } +} + +const DEMO_THREADS: Array & { unread: number; important: boolean }> = [ + { + peer: 'demo:sd', + title: '+91 91491 64337', + phoneLabel: '+91 91491 64337', + initials: 'SD', + color: '#3b82f6', + online: true, + demo: true, + source: 'Website', + tags: [], + unread: 1, + important: false, + messages: [ + demoMessage('919149164337', 'sd-1', 'inbound', 'Hi', ago(28), 'received'), + demoMessage('919149164337', 'sd-2', 'outbound', 'Hello! 👋\nWelcome to Urmi.\n\nHow can we help you today?', ago(27), 'read'), + demoMessage('919149164337', 'sd-3', 'inbound', 'I want to know about WhatsApp Business integration and pricing.', ago(25), 'received'), + demoMessage( + '919149164337', + 'sd-4', + 'outbound', + 'Sure! I can help with that.\nWould you like to:\n• See our pricing plans\n• Learn about the integration process\n• Talk to our team\n\nPlease let me know what you’re interested in.', + ago(24), + 'read', + ), + demoMessage('919149164337', 'sd-5', 'outbound', 'Urmi onboarding — your workspace is ready to connect WhatsApp.', ago(23), 'delivered', 'template'), + demoMessage('919149164337', 'sd-6', 'outbound', 'Choose what you want to review next.', ago(22), 'sent', 'interactive_buttons', ['See pricing', 'Talk to our team']), + ], + }, + { + peer: 'demo:riya', + title: 'Riya Kapoor', + phoneLabel: '+91 98111 20014', + initials: 'RK', + color: '#ec4899', + online: false, + demo: true, + source: 'WhatsApp', + tags: ['Pricing'], + unread: 2, + important: true, + messages: [ + demoMessage('919811120014', 'rk-1', 'inbound', 'Can you share pricing?', ago(46), 'received'), + ], + }, + { + peer: 'demo:amit', + title: 'Amit Bansal', + phoneLabel: '+91 98200 34221', + initials: 'AB', + color: '#22c55e', + online: false, + demo: true, + source: 'Website', + tags: ['Template'], + unread: 0, + important: false, + messages: [ + demoMessage('919820034221', 'ab-1', 'outbound', 'Template approved', ago(160), 'read', 'template'), + ], + }, + { + peer: 'demo:vikram', + title: 'Vikram Chennai', + phoneLabel: '+91 98400 11820', + initials: 'VC', + color: '#8b5cf6', + online: false, + demo: true, + source: 'Referral', + tags: [], + unread: 0, + important: false, + messages: [ + demoMessage('919840011820', 'vc-1', 'inbound', 'Thanks, received!', ago(190), 'received'), + ], + }, + { + peer: 'demo:neha', + title: 'Neha Sharma', + phoneLabel: '+91 99000 44118', + initials: 'NS', + color: '#f59e0b', + online: true, + demo: true, + source: 'Website', + tags: ['Docs'], + unread: 1, + important: false, + messages: [ + demoMessage('919900044118', 'ns-1', 'inbound', 'Do you have integration docs?', ago(220), 'received'), + ], + }, + { + peer: 'demo:delhi', + title: 'Delhi Pharma', + phoneLabel: '+91 98100 77881', + initials: 'DP', + color: '#14b8a6', + online: false, + demo: true, + source: 'Campaign', + tags: ['Bulk SMS'], + unread: 0, + important: true, + messages: [ + demoMessage('919810077881', 'dp-1', 'inbound', 'We need bulk SMS also', ago(260), 'received'), + ], + }, + { + peer: 'demo:saanvi', + title: 'Saanvi Gupta', + phoneLabel: '+91 98765 12009', + initials: 'SG', + color: '#6366f1', + online: false, + demo: true, + source: 'Website', + tags: ['KYC'], + unread: 0, + important: false, + messages: [ + demoMessage('919876512009', 'sg-1', 'inbound', 'Is KYC required?', ago(60 * 26), 'received'), + ], + }, + { + peer: 'demo:manish', + title: 'Manish Kumar', + phoneLabel: '+91 98181 33002', + initials: 'MK', + color: '#0ea5e9', + online: false, + demo: true, + source: 'WhatsApp', + tags: [], + unread: 0, + important: false, + messages: [ + demoMessage('919818133002', 'mk-1', 'inbound', 'Call me when free', ago(60 * 28), 'received'), + ], + }, ] -export function AdminWhatsAppPage() { +function eventLabel(message: UiMessage) { + if (message.message_type === 'template') return message.direction === 'outbound' ? 'Template sent' : 'Template received' + if (message.message_type === 'interactive_buttons') return 'Buttons sent' + if (['image', 'video', 'audio', 'document'].includes(message.message_type)) return 'Media shared' + if (message.message_type === 'reaction') return 'Reaction sent' + return message.direction === 'inbound' ? 'Customer message' : 'Reply sent' +} + +export function AdminWhatsAppPage({ + onLogout, + focus = 'inbox', +}: { + onLogout?: () => void + focus?: 'inbox' | 'templates' | 'compose' +}) { const [config, setConfig] = useState(null) const [messages, setMessages] = useState([]) const [templates, setTemplates] = useState([]) const [loading, setLoading] = useState(true) - const [selectedPeer, setSelectedPeer] = useState(null) + const [selectedPeer, setSelectedPeer] = useState('demo:sd') const [newPeer, setNewPeer] = useState('') + const [startingNew, setStartingNew] = useState(false) const [sendMode, setSendMode] = useState('text') const [showSetup, setShowSetup] = useState(false) const [error, setError] = useState('') const [sending, setSending] = useState(false) const [savingConfig, setSavingConfig] = useState(false) + const [accountOpen, setAccountOpen] = useState(false) const [phoneNumberId, setPhoneNumberId] = useState('') const [businessAccountId, setBusinessAccountId] = useState('') @@ -81,25 +314,46 @@ export function AdminWhatsAppPage() { 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 [mediaFilename, setMediaFilename] = useState('') - const [buttonBody, setButtonBody] = useState('') - const [buttonOne, setButtonOne] = useState('') - const [buttonTwo, setButtonTwo] = useState('') + const [buttonBody, setButtonBody] = useState('Choose an option') + const [buttonOne, setButtonOne] = useState('See pricing') + const [buttonTwo, setButtonTwo] = useState('Talk to our team') const [buttonThree, setButtonThree] = useState('') - const [reactionEmoji, setReactionEmoji] = useState('👍') - const [reactionTarget, setReactionTarget] = useState('') + const [threadQuery, setThreadQuery] = useState('') + const [listFilter, setListFilter] = useState('all') + const [readPeers, setReadPeers] = useState>(new Set()) + const [starred, setStarred] = useState>(new Set(['demo:riya', 'demo:delhi'])) + const [demoExtras, setDemoExtras] = useState>({}) + const [names, setNames] = useState>({}) + const [editingName, setEditingName] = useState(false) + const [nameDraft, setNameDraft] = useState('') + const [notes, setNotes] = useState>({}) + const [tags, setTags] = useState>({}) + const [tagDraft, setTagDraft] = useState('') + const [languages, setLanguages] = useState>({}) + const [timezones, setTimezones] = useState>({}) + const [blocked, setBlocked] = useState>(new Set()) + const [profileTab, setProfileTab] = useState('details') + const [assignee, setAssignee] = useState('Unassigned') + const [assignOpen, setAssignOpen] = useState(false) + const [moreOpen, setMoreOpen] = useState(false) + const [detailsId, setDetailsId] = useState(null) + const [reactFor, setReactFor] = useState(null) + const [emojiOpen, setEmojiOpen] = useState(false) + const [actionNote, setActionNote] = useState('') const chatEndRef = useRef(null) const replyRef = useRef(null) - async function loadMessages(peer?: string | null) { - const items = await adminApi.listWhatsAppMessages(300, peer || undefined) + async function loadMessages() { + const items = await adminApi.listWhatsAppMessages(300) setMessages(items) } @@ -120,50 +374,113 @@ export function AdminWhatsAppPage() { useEffect(() => { loadAll().finally(() => setLoading(false)) const timer = window.setInterval(() => { - loadMessages(selectedPeer).catch(() => undefined) - }, 5000) + loadMessages().catch(() => undefined) + }, 8000) return () => window.clearInterval(timer) - }, [selectedPeer]) + }, []) + + useEffect(() => { + if (focus === 'templates') setSendMode('template') + if (focus === 'compose') { + setSendMode('text') + replyRef.current?.focus() + } + }, [focus]) const threads = useMemo(() => { - const map = new Map() + const real = new Map() for (const message of messages) { const peer = message.direction === 'inbound' ? message.from : message.to if (!peer || message.message_type === 'status') continue - const list = map.get(peer) ?? [] + const list = real.get(peer) ?? [] list.push(message) - map.set(peer, list) + real.set(peer, list) } - return [...map.entries()] - .map(([peer, thread]) => { - const sorted = [...thread].sort((a, b) => a.created_at.localeCompare(b.created_at)) - return { peer, thread: sorted, latest: sorted[sorted.length - 1] } + + const rows: ThreadRow[] = [] + for (const [peer, thread] of real) { + const sorted = [...thread].sort((a, b) => a.created_at.localeCompare(b.created_at)) + const latest = sorted[sorted.length - 1] + const unread = !readPeers.has(peer) && latest.direction === 'inbound' ? 1 : 0 + rows.push({ + peer, + title: names[peer] || formatWaNumber(peer), + phoneLabel: formatWaNumber(peer), + initials: names[peer] ? names[peer].slice(0, 2).toUpperCase() : null, + color: AVATAR_COLORS[peer.length % AVATAR_COLORS.length], + messages: sorted, + latest, + unread, + important: starred.has(peer), + online: Date.now() - new Date(latest.created_at).getTime() < 15 * 60_000, + demo: false, + source: 'WhatsApp', + tags: tags[peer] ?? [], }) - .sort((a, b) => (b.latest?.created_at ?? '').localeCompare(a.latest?.created_at ?? '')) - }, [messages]) + } + + for (const demo of DEMO_THREADS) { + const extras = demoExtras[demo.peer] ?? [] + const sorted = [...demo.messages, ...extras].sort((a, b) => a.created_at.localeCompare(b.created_at)) + const latest = sorted[sorted.length - 1] + rows.push({ + ...demo, + title: names[demo.peer] || demo.title, + messages: sorted, + latest, + unread: readPeers.has(demo.peer) ? 0 : demo.unread, + important: starred.has(demo.peer), + tags: tags[demo.peer] ?? demo.tags, + }) + } + + return rows.sort((a, b) => b.latest.created_at.localeCompare(a.latest.created_at)) + }, [messages, demoExtras, names, readPeers, starred, tags]) 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 threads.filter((thread) => { + if (listFilter === 'unread' && thread.unread < 1) return false + if (listFilter === 'important' && !thread.important) return false + if (!query) return true + const haystack = `${thread.title} ${thread.phoneLabel} ${thread.latest.body ?? ''}`.toLowerCase() return haystack.includes(query) }) - }, [threads, threadQuery]) + }, [threads, threadQuery, listFilter]) - 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 active = threads.find((thread) => thread.peer === selectedPeer) ?? null + const activeThread = active?.messages ?? [] const activeFirst = activeThread[0] + const unreadTotal = threads.reduce((sum, thread) => sum + thread.unread, 0) + const importantTotal = threads.filter((thread) => thread.important).length useEffect(() => { chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [activeThread.length, selectedPeer]) + function openThread(thread: ThreadRow) { + setSelectedPeer(thread.peer) + setStartingNew(false) + setReadPeers((current) => new Set(current).add(thread.peer)) + setDetailsId(null) + setReactFor(null) + setProfileTab('details') + if (!thread.demo) { + const lastInbound = [...thread.messages].reverse().find((item) => item.direction === 'inbound' && item.wa_message_id) + if (lastInbound?.wa_message_id) { + adminApi.markWhatsAppRead(lastInbound.wa_message_id).catch(() => undefined) + } + } + } + + function beginReply(message: UiMessage) { + setSendMode('text') + setReplyTo(message.wa_message_id || '') + setReplyPreview(message.body || 'Message') + setReactFor(null) + replyRef.current?.focus() + } + async function saveConfig(event: FormEvent) { event.preventDefault() setSavingConfig(true) @@ -179,72 +496,122 @@ export function AdminWhatsAppPage() { const templateResponse = await adminApi.listWhatsAppTemplates().catch(() => ({ data: [] as AdminWhatsAppTemplate[] })) setTemplates(templateResponse.data ?? []) } catch (reason) { - setError(reason instanceof Error ? reason.message : 'Unable to save WhatsApp config') + setError(reason instanceof Error ? reason.message : 'Unable to save WhatsApp settings') } finally { setSavingConfig(false) } } - async function markRead(message: AdminWhatsAppMessage) { - if (!message.wa_message_id || message.direction !== 'inbound') return + function pushDemoMessage(peer: string, message: UiMessage) { + setDemoExtras((current) => ({ ...current, [peer]: [...(current[peer] ?? []), message] })) + window.setTimeout(() => { + setDemoExtras((current) => ({ + ...current, + [peer]: (current[peer] ?? []).map((item) => item.id === message.id ? { ...item, status: 'delivered' } : item), + })) + }, 700) + window.setTimeout(() => { + setDemoExtras((current) => ({ + ...current, + [peer]: (current[peer] ?? []).map((item) => item.id === message.id ? { ...item, status: 'read' } : item), + })) + }, 1600) + } + + async function sendReaction(message: UiMessage, emoji: string) { + if (!active) return + setReactFor(null) + if (active.demo) { + pushDemoMessage(active.peer, demoMessage(active.phoneLabel, `react-${Date.now()}`, 'outbound', `Reacted ${emoji}`, new Date().toISOString(), 'sent', 'reaction')) + return + } + setSending(true) + setError('') try { - await adminApi.markWhatsAppRead(message.wa_message_id) - } catch { - // ignore read receipt failures + await adminApi.sendWhatsAppMessage({ + to: active.peer, + message_type: 'reaction', + reaction_message_id: message.wa_message_id || '', + reaction_emoji: emoji, + }) + await loadMessages() + } catch (reason) { + setError(reason instanceof Error ? reason.message : 'Unable to send reaction') + } finally { + setSending(false) } } async function sendMessage(event: FormEvent) { event.preventDefault() - if (!selectedPeer && !newPeer.trim()) { - setError('Select a conversation or enter a recipient number') + const recipient = startingNew || !active ? newPeer.trim() : active.peer + if (!recipient) { + setError('Choose a conversation or enter a recipient number') + return + } + if (active && blocked.has(active.peer)) { + setError('This contact is blocked') return } setSending(true) setError('') - const recipient = selectedPeer ?? newPeer.trim() - const payload: AdminWhatsAppSendPayload = { to: recipient, message_type: 'text' } try { - if (sendMode === 'text') { - payload.message_type = 'text' - payload.text = textBody.trim() - payload.reply_to = replyTo.trim() || undefined - } else if (sendMode === 'template') { - payload.message_type = 'template' - payload.template_name = templateName - payload.template_language = templateLanguage - } else if (sendMode === 'media') { - payload.message_type = mediaType - payload.media_link = mediaLink.trim() - payload.media_caption = mediaCaption.trim() || undefined - payload.media_filename = mediaFilename.trim() || undefined - payload.reply_to = replyTo.trim() || undefined - } else if (sendMode === 'buttons') { - payload.message_type = 'interactive_buttons' - payload.interactive_body = buttonBody.trim() - payload.interactive_buttons = [ - { id: 'btn1', title: buttonOne.trim() }, - ...(buttonTwo.trim() ? [{ id: 'btn2', title: buttonTwo.trim() }] : []), - ...(buttonThree.trim() ? [{ id: 'btn3', title: buttonThree.trim() }] : []), - ] - payload.reply_to = replyTo.trim() || undefined - } else if (sendMode === 'reaction') { - payload.message_type = 'reaction' - payload.reaction_message_id = reactionTarget.trim() - payload.reaction_emoji = reactionEmoji.trim() + if (recipient.startsWith('demo:')) { + const phone = active?.phoneLabel ?? recipient + const id = `local-${Date.now()}` + let body = textBody.trim() + let type: UiMessage['message_type'] = 'text' + let buttons: string[] | undefined + if (sendMode === 'template') { + body = templateName || 'Template message' + type = 'template' + } else if (sendMode === 'media') { + body = mediaCaption.trim() || `${mediaType} attachment` + type = mediaType + } else if (sendMode === 'buttons') { + body = buttonBody.trim() + type = 'interactive_buttons' + buttons = [buttonOne, buttonTwo, buttonThree].map((item) => item.trim()).filter(Boolean) + } + pushDemoMessage(recipient, { + ...demoMessage(phone, id, 'outbound', body, new Date().toISOString(), 'sent', type, buttons), + }) + } else { + const payload: AdminWhatsAppSendPayload = { to: recipient, message_type: 'text' } + if (sendMode === 'text') { + payload.text = textBody.trim() + payload.reply_to = replyTo.trim() || undefined + } else if (sendMode === 'template') { + payload.message_type = 'template' + payload.template_name = templateName + payload.template_language = templateLanguage + } else if (sendMode === 'media') { + payload.message_type = mediaType + payload.media_link = mediaLink.trim() + payload.media_caption = mediaCaption.trim() || undefined + payload.media_filename = mediaFilename.trim() || undefined + payload.reply_to = replyTo.trim() || undefined + } else if (sendMode === 'buttons') { + payload.message_type = 'interactive_buttons' + payload.interactive_body = buttonBody.trim() + payload.interactive_buttons = [ + { id: 'btn1', title: buttonOne.trim() }, + ...(buttonTwo.trim() ? [{ id: 'btn2', title: buttonTwo.trim() }] : []), + ...(buttonThree.trim() ? [{ id: 'btn3', title: buttonThree.trim() }] : []), + ] + payload.reply_to = replyTo.trim() || undefined + } + const sent = await adminApi.sendWhatsAppMessage(payload) + if (startingNew || !active) setSelectedPeer(sent.to) + await loadMessages() } - - const sent = await adminApi.sendWhatsAppMessage(payload) - if (!selectedPeer) { - const peer = sent.to - setSelectedPeer(peer) - setNewPeer('') - } - await loadMessages(selectedPeer ?? sent.to) setTextBody('') setMediaCaption('') - setButtonBody('') setReplyTo('') + setReplyPreview('') + setStartingNew(false) + setNewPeer('') + setSendMode('text') } catch (reason) { setError(reason instanceof Error ? reason.message : 'Unable to send message') } finally { @@ -252,394 +619,486 @@ export function AdminWhatsAppPage() { } } + function addTag() { + const value = tagDraft.trim() + if (!value || !active) return + setTags((current) => ({ + ...current, + [active.peer]: [...new Set([...(current[active.peer] ?? active.tags), value])], + })) + setTagDraft('') + } + if (loading) return
Loading WhatsApp Business…
const readyToSend = Boolean(config?.ready_to_send) + const language = active ? languages[active.peer] ?? 'English' : 'English' + const timezone = active ? timezones[active.peer] ?? 'Asia/Kolkata (GMT+5:30)' : 'Asia/Kolkata (GMT+5:30)' return ( -
-
-
- Urmi business -

WhatsApp Business

-

- Send and receive on {config ? formatWaNumber(config.business_phone) : '+91 94112 57916'}. - Webhook: {config?.webhook_url ?? '—'} -

-
-
- +
+
+ +
+ + {readyToSend ? 'Connected' : 'Setup required'} - - -
-
- - {showSetup && ( -
-
-
-

Meta connection

-

Paste IDs from Meta → WhatsApp → API Setup. Saved in admin without restarting the server.

-
-
-
- - - -
-

Verify token: {config?.verify_token_configured ? 'Configured in server env' : 'Missing in env'}

-

Access token: {config?.access_token_configured ? 'Configured in server env' : 'Missing in env'}

-

Meta App ID: {config?.meta_app_id ?? '—'}

-
- +
+ - -
- )} - - {!readyToSend && ( -

- Add your Phone number ID in Meta setup above to enable sending. - Inbound messages still arrive once Meta webhook is subscribed at {config?.webhook_url}. -

- )} - -
- - -
-
- {selectedPeer ? ( - <> - {peerInitials(selectedPeer)} -
- {formatWaNumber(selectedPeer)} - {activeThread.length} messages · {activeLatest ? formatChatTime(activeLatest.created_at) : 'No activity'} -
- - ) : ( -
- Select a conversation - Or start one with a new recipient below. + {accountOpen && ( +
+
)}
+
+ -
- {!selectedPeer ? ( -
- -

Choose a thread on the left to read it and reply from the box below.

+
+
+

WhatsApp Business

+

Manage conversations, send messages, and grow your business with Urmi.

+
+ + {showSetup && ( +
+

Meta setup

+
+ + + + +
+
+ )} + +
+ + +
+
+ {active ? ( + <> + {active.initials ? ( + {active.initials} + ) : ( + + )} +
+ {active.title} + {active.online ? 'Online' : 'WhatsApp'} +
+ + ) : ( +
+ New conversation + Enter a WhatsApp number to start +
+ )} +
+
+ + {assignOpen && ( +
+ {['Unassigned', 'Admin'].map((option) => ( + + ))} +
+ )} +
+ +
+ + {moreOpen && active && ( +
+ + + +
+ )} +
+ {assignee !== 'Unassigned' && {assignee}}
- ) : ( - activeThread.map((item, index) => { +
+ +
+ {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 ( -
- {showDay &&
{formatDayLabel(item.created_at)}
} -
- {item.media?.id && ['image', 'sticker'].includes(item.message_type) && ( - {item.message_type} - )} - {item.media?.id && item.message_type === 'video' && ( -