UI finish
This commit is contained in:
parent
6e295dd4b3
commit
ae72b59878
3 changed files with 107 additions and 578 deletions
|
|
@ -1,19 +1,4 @@
|
|||
import {
|
||||
BarChart3,
|
||||
ChevronRight,
|
||||
Contact,
|
||||
LayoutTemplate,
|
||||
LockKeyhole,
|
||||
Megaphone,
|
||||
MessageSquareText,
|
||||
Network,
|
||||
Phone,
|
||||
ScrollText,
|
||||
Send,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Workflow,
|
||||
} from 'lucide-react'
|
||||
import { ChevronRight, LockKeyhole, MessageSquareText, Network, Phone, ShieldCheck } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import {
|
||||
|
|
@ -405,23 +390,9 @@ 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<boolean | null>(null)
|
||||
const [section, setSection] = useState<'compliance' | 'whatsapp'>('whatsapp')
|
||||
const [focus, setFocus] = useState<'inbox' | 'templates' | 'compose'>('inbox')
|
||||
const [nav, setNav] = useState<string>('conversations')
|
||||
const [section, setSection] = useState<'compliance' | 'whatsapp'>('compliance')
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.me().then(() => setAuthenticated(true)).catch(() => setAuthenticated(false))
|
||||
|
|
@ -432,74 +403,39 @@ 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 <div className="auth-loading">Checking admin session…</div>
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
<header className="admin-topbar">
|
||||
<div><img src="/urmi-logo.png" alt="Urmi" /><strong>Admin portal</strong></div>
|
||||
</header>
|
||||
<main>
|
||||
<AdminLogin onSuccess={() => setAuthenticated(true)} />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wa-shell">
|
||||
<aside className="wa-nav">
|
||||
<div className="wa-nav-brand"><img src="/urmi-logo.png" alt="Urmi" /></div>
|
||||
<nav>
|
||||
{WORKSPACE_NAV.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
<header className="admin-topbar">
|
||||
<div><img src="/urmi-logo.png" alt="Urmi" /><strong>Admin portal</strong></div>
|
||||
{authenticated && (
|
||||
<div className="admin-topbar-actions">
|
||||
<nav className="admin-main-nav">
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={section === 'whatsapp' && nav === item.id ? 'active' : ''}
|
||||
onClick={() => openWorkspace(item.id)}
|
||||
className={section === 'compliance' ? 'active' : ''}
|
||||
onClick={() => setSection('compliance')}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{item.label}
|
||||
Compliance
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
className={section === 'compliance' ? 'active' : ''}
|
||||
onClick={() => { setSection('compliance'); setNav('settings') }}
|
||||
>
|
||||
<Settings size={16} />
|
||||
Settings
|
||||
</button>
|
||||
</nav>
|
||||
<div className="wa-nav-foot">
|
||||
<strong>Business line</strong>
|
||||
<span>WhatsApp connected</span>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="wa-shell-main">
|
||||
{section === 'whatsapp'
|
||||
? <AdminWhatsAppPage onLogout={logout} focus={focus} />
|
||||
: (
|
||||
<>
|
||||
<header className="wa-topbar plain">
|
||||
<strong>Settings</strong>
|
||||
<button className="wa-ghost" type="button" onClick={logout}>Log out</button>
|
||||
</header>
|
||||
<AdminCompliance />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={section === 'whatsapp' ? 'active' : ''}
|
||||
onClick={() => setSection('whatsapp')}
|
||||
>
|
||||
WhatsApp Business
|
||||
</button>
|
||||
</nav>
|
||||
<button className="secondary-button" onClick={logout}>Log out</button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<main>
|
||||
{authenticated
|
||||
? (section === 'compliance' ? <AdminCompliance /> : <AdminWhatsAppPage />)
|
||||
: <AdminLogin onSuccess={() => setAuthenticated(true)} />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,11 @@
|
|||
import {
|
||||
Ban,
|
||||
Bell,
|
||||
Braces,
|
||||
Check,
|
||||
CheckCheck,
|
||||
ChevronDown,
|
||||
Image as ImageIcon,
|
||||
Megaphone,
|
||||
MessageSquareText,
|
||||
MoreHorizontal,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
|
|
@ -18,10 +13,7 @@ import {
|
|||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Smile,
|
||||
Sparkles,
|
||||
Star,
|
||||
StickyNote,
|
||||
UserPlus,
|
||||
UserRound,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
|
@ -38,7 +30,7 @@ import {
|
|||
|
||||
type SendMode = 'text' | 'template' | 'media' | 'buttons'
|
||||
type ListFilter = 'all' | 'unread' | 'important'
|
||||
type ProfileTab = 'details' | 'notes' | 'tags' | 'events'
|
||||
type ProfileTab = 'details' | 'events'
|
||||
|
||||
type UiMessage = AdminWhatsAppMessage & { buttons?: string[] }
|
||||
|
||||
|
|
@ -108,177 +100,6 @@ function formatWhen(value: string) {
|
|||
})
|
||||
}
|
||||
|
||||
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<Omit<ThreadRow, 'latest' | 'unread' | 'important'> & { 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'),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
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'
|
||||
|
|
@ -287,18 +108,12 @@ function eventLabel(message: UiMessage) {
|
|||
return message.direction === 'inbound' ? 'Customer message' : 'Reply sent'
|
||||
}
|
||||
|
||||
export function AdminWhatsAppPage({
|
||||
onLogout,
|
||||
focus = 'inbox',
|
||||
}: {
|
||||
onLogout?: () => void
|
||||
focus?: 'inbox' | 'templates' | 'compose'
|
||||
}) {
|
||||
export function AdminWhatsAppPage() {
|
||||
const [config, setConfig] = useState<AdminWhatsAppConfig | null>(null)
|
||||
const [messages, setMessages] = useState<AdminWhatsAppMessage[]>([])
|
||||
const [templates, setTemplates] = useState<AdminWhatsAppTemplate[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedPeer, setSelectedPeer] = useState<string>('demo:sd')
|
||||
const [selectedPeer, setSelectedPeer] = useState<string | null>(null)
|
||||
const [newPeer, setNewPeer] = useState('')
|
||||
const [startingNew, setStartingNew] = useState(false)
|
||||
const [sendMode, setSendMode] = useState<SendMode>('text')
|
||||
|
|
@ -306,7 +121,6 @@ export function AdminWhatsAppPage({
|
|||
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('')
|
||||
|
|
@ -321,34 +135,20 @@ export function AdminWhatsAppPage({
|
|||
const [mediaLink, setMediaLink] = useState('')
|
||||
const [mediaCaption, setMediaCaption] = useState('')
|
||||
const [mediaFilename, setMediaFilename] = useState('')
|
||||
const [buttonBody, setButtonBody] = useState('Choose an option')
|
||||
const [buttonOne, setButtonOne] = useState('See pricing')
|
||||
const [buttonTwo, setButtonTwo] = useState('Talk to our team')
|
||||
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(['demo:riya', 'demo:delhi']))
|
||||
const [demoExtras, setDemoExtras] = useState<Record<string, UiMessage[]>>({})
|
||||
const [names, setNames] = useState<Record<string, string>>({})
|
||||
const [editingName, setEditingName] = useState(false)
|
||||
const [nameDraft, setNameDraft] = useState('')
|
||||
const [notes, setNotes] = useState<Record<string, string>>({})
|
||||
const [tags, setTags] = useState<Record<string, string[]>>({})
|
||||
const [tagDraft, setTagDraft] = useState('')
|
||||
const [languages, setLanguages] = useState<Record<string, string>>({})
|
||||
const [timezones, setTimezones] = useState<Record<string, string>>({})
|
||||
const [blocked, setBlocked] = useState<Set<string>>(new Set())
|
||||
const [starred, setStarred] = useState<Set<string>>(new Set())
|
||||
const [profileTab, setProfileTab] = useState<ProfileTab>('details')
|
||||
const [assignee, setAssignee] = useState('Unassigned')
|
||||
const [assignOpen, setAssignOpen] = useState(false)
|
||||
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 [actionNote, setActionNote] = useState('')
|
||||
|
||||
const chatEndRef = useRef<HTMLDivElement | null>(null)
|
||||
const replyRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
|
|
@ -379,63 +179,38 @@ export function AdminWhatsAppPage({
|
|||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (focus === 'templates') setSendMode('template')
|
||||
if (focus === 'compose') {
|
||||
setSendMode('text')
|
||||
replyRef.current?.focus()
|
||||
}
|
||||
}, [focus])
|
||||
|
||||
const threads = useMemo(() => {
|
||||
const real = new Map<string, UiMessage[]>()
|
||||
const grouped = new Map<string, UiMessage[]>()
|
||||
for (const message of messages) {
|
||||
const peer = message.direction === 'inbound' ? message.from : message.to
|
||||
if (!peer || message.message_type === 'status') continue
|
||||
const list = real.get(peer) ?? []
|
||||
const list = grouped.get(peer) ?? []
|
||||
list.push(message)
|
||||
real.set(peer, list)
|
||||
grouped.set(peer, list)
|
||||
}
|
||||
|
||||
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] ?? [],
|
||||
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]
|
||||
return {
|
||||
peer,
|
||||
title: formatWaNumber(peer),
|
||||
phoneLabel: formatWaNumber(peer),
|
||||
initials: null,
|
||||
color: AVATAR_COLORS[peer.length % AVATAR_COLORS.length],
|
||||
messages: sorted,
|
||||
latest,
|
||||
unread: !readPeers.has(peer) && latest.direction === 'inbound' ? 1 : 0,
|
||||
important: starred.has(peer),
|
||||
online: Date.now() - new Date(latest.created_at).getTime() < 15 * 60_000,
|
||||
demo: false,
|
||||
source: 'WhatsApp',
|
||||
tags: [],
|
||||
} satisfies ThreadRow
|
||||
})
|
||||
}
|
||||
|
||||
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])
|
||||
.sort((a, b) => b.latest.created_at.localeCompare(a.latest.created_at))
|
||||
}, [messages, readPeers, starred])
|
||||
|
||||
const visibleThreads = useMemo(() => {
|
||||
const query = threadQuery.trim().toLowerCase()
|
||||
|
|
@ -465,11 +240,9 @@ export function AdminWhatsAppPage({
|
|||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -502,29 +275,9 @@ export function AdminWhatsAppPage({
|
|||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -549,62 +302,36 @@ export function AdminWhatsAppPage({
|
|||
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('')
|
||||
try {
|
||||
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 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()
|
||||
setTextBody('')
|
||||
setMediaCaption('')
|
||||
setReplyTo('')
|
||||
|
|
@ -619,30 +346,17 @@ 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 <div className="auth-loading">Loading WhatsApp Business…</div>
|
||||
|
||||
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 (
|
||||
<div className="wa-workspace">
|
||||
<header className="wa-topbar">
|
||||
<button type="button" className="wa-product">
|
||||
<MessageSquareText size={16} />
|
||||
WhatsApp Business
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
<div className="admin-content admin-whatsapp-page wa-page">
|
||||
<div className="wa-page-title">
|
||||
<div>
|
||||
<h1><MessageSquareText size={20} /> WhatsApp Business</h1>
|
||||
<p>Live conversations on {config ? formatWaNumber(config.business_phone) : 'the business line'}.</p>
|
||||
</div>
|
||||
<div className="wa-topbar-actions">
|
||||
<span className={readyToSend ? 'wa-connected' : 'wa-connected pending'}>
|
||||
<i />
|
||||
|
|
@ -654,28 +368,8 @@ export function AdminWhatsAppPage({
|
|||
<button type="button" className="wa-ghost" onClick={() => loadAll()}>
|
||||
<RefreshCw size={14} /> Refresh
|
||||
</button>
|
||||
<button type="button" className="wa-icon-btn" aria-label="Notifications">
|
||||
<Bell size={16} />
|
||||
</button>
|
||||
<div className="wa-account">
|
||||
<button type="button" className="wa-account-btn" onClick={() => setAccountOpen((value) => !value)}>
|
||||
<span>AD</span>
|
||||
<strong>Admin</strong>
|
||||
</button>
|
||||
{accountOpen && (
|
||||
<div className="wa-menu">
|
||||
<button type="button" onClick={onLogout}>Log out</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="wa-page">
|
||||
<div className="wa-page-title">
|
||||
<h1><MessageSquareText size={20} /> WhatsApp Business</h1>
|
||||
<p>Manage conversations, send messages, and grow your business with Urmi.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSetup && (
|
||||
<section className="wa-setup">
|
||||
|
|
@ -703,7 +397,7 @@ export function AdminWhatsAppPage({
|
|||
<div className="wa-list-head">
|
||||
<h2>Conversations</h2>
|
||||
<span className="wa-list-tools">
|
||||
<button type="button" className="wa-icon-btn" aria-label="New conversation" onClick={() => { setStartingNew(true); setSelectedPeer('') }}>
|
||||
<button type="button" className="wa-icon-btn" aria-label="New conversation" onClick={() => { setStartingNew(true); setSelectedPeer(null) }}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
<button type="button" className="wa-icon-btn" aria-label="Filter conversations">
|
||||
|
|
@ -768,18 +462,6 @@ export function AdminWhatsAppPage({
|
|||
</div>
|
||||
)}
|
||||
<div className="wa-chat-tools">
|
||||
<div className="wa-pop">
|
||||
<button type="button" className="wa-tool" onClick={() => { setAssignOpen((value) => !value); setMoreOpen(false) }}>
|
||||
<UserPlus size={14} /> Assign
|
||||
</button>
|
||||
{assignOpen && (
|
||||
<div className="wa-menu">
|
||||
{['Unassigned', 'Admin'].map((option) => (
|
||||
<button key={option} type="button" onClick={() => { setAssignee(option); setAssignOpen(false) }}>{option}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`wa-tool ${active && starred.has(active.peer) ? 'on' : ''}`}
|
||||
|
|
@ -796,21 +478,16 @@ export function AdminWhatsAppPage({
|
|||
<Star size={14} /> Star
|
||||
</button>
|
||||
<div className="wa-pop">
|
||||
<button type="button" className="wa-tool" onClick={() => { setMoreOpen((value) => !value); setAssignOpen(false) }}>
|
||||
<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={() => { setDetailsId(active.latest.id); setMoreOpen(false) }}>Message details</button>
|
||||
<button type="button" onClick={() => { navigator.clipboard.writeText(active.phoneLabel); setMoreOpen(false) }}>Copy number</button>
|
||||
<button type="button" onClick={() => {
|
||||
setBlocked((current) => new Set(current).add(active.peer))
|
||||
setMoreOpen(false)
|
||||
}}>Block contact</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{assignee !== 'Unassigned' && <span className="wa-assigned">{assignee}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -911,7 +588,6 @@ export function AdminWhatsAppPage({
|
|||
{templates.map((template) => (
|
||||
<option key={template.id ?? template.name} value={template.name}>{template.name} ({template.language})</option>
|
||||
))}
|
||||
{templates.length === 0 && <option value="urmi_onboarding">urmi_onboarding (en)</option>}
|
||||
</select>
|
||||
<input value={templateLanguage} onChange={(event) => setTemplateLanguage(event.target.value)} aria-label="Language code" />
|
||||
</div>
|
||||
|
|
@ -924,7 +600,7 @@ export function AdminWhatsAppPage({
|
|||
<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={!active?.demo} />
|
||||
<input value={mediaLink} onChange={(event) => setMediaLink(event.target.value)} placeholder="Public file link" required />
|
||||
{mediaType === 'document' && <input value={mediaFilename} onChange={(event) => setMediaFilename(event.target.value)} placeholder="Filename" />}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -948,16 +624,6 @@ export function AdminWhatsAppPage({
|
|||
placeholder="Type a message…"
|
||||
required={sendMode !== 'template'}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="wa-ai"
|
||||
onClick={() => {
|
||||
setSendMode('text')
|
||||
setTextBody('Happy to help. I can share pricing, the integration steps, or connect you with our team.')
|
||||
}}
|
||||
>
|
||||
<Sparkles size={13} /> AI Assist
|
||||
</button>
|
||||
</div>
|
||||
<div className="wa-compose-bar">
|
||||
<div className="wa-compose-icons">
|
||||
|
|
@ -974,7 +640,7 @@ export function AdminWhatsAppPage({
|
|||
)}
|
||||
</div>
|
||||
{error && <p className="wa-error">{error}</p>}
|
||||
<button className="wa-send" type="submit" disabled={sending || (!readyToSend && !active?.demo) || blocked.has(active?.peer ?? '')}>
|
||||
<button className="wa-send" type="submit" disabled={sending || !readyToSend}>
|
||||
<Send size={14} /> {sending ? 'Sending' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -983,33 +649,15 @@ export function AdminWhatsAppPage({
|
|||
|
||||
<aside className="wa-profile">
|
||||
<div className="wa-profile-hero">
|
||||
{active?.initials ? (
|
||||
<span className="wa-avatar large" style={{ background: active.color }}>{active.initials}</span>
|
||||
) : (
|
||||
<span className="wa-avatar large icon"><UserRound size={22} /></span>
|
||||
)}
|
||||
<span className="wa-avatar large icon"><UserRound size={22} /></span>
|
||||
<div>
|
||||
{editingName ? (
|
||||
<form onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (active && nameDraft.trim()) setNames((current) => ({ ...current, [active.peer]: nameDraft.trim() }))
|
||||
setEditingName(false)
|
||||
}}>
|
||||
<input value={nameDraft} onChange={(event) => setNameDraft(event.target.value)} placeholder="Add name" autoFocus />
|
||||
</form>
|
||||
) : (
|
||||
<button type="button" className="wa-name" onClick={() => { setEditingName(true); setNameDraft(names[active?.peer ?? ''] || '') }}>
|
||||
{active?.title || 'No contact'} <Pencil size={12} />
|
||||
</button>
|
||||
)}
|
||||
<strong className="wa-name">{active?.title || 'No contact'}</strong>
|
||||
<small>{active ? 'WhatsApp' : 'Select a conversation'}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wa-tabs">
|
||||
{([
|
||||
['details', 'Details'],
|
||||
['notes', 'Notes'],
|
||||
['tags', 'Tags'],
|
||||
['events', 'Events'],
|
||||
] as const).map(([id, label]) => (
|
||||
<button key={id} type="button" className={profileTab === id ? 'active' : ''} onClick={() => setProfileTab(id)}>{label}</button>
|
||||
|
|
@ -1018,68 +666,17 @@ export function AdminWhatsAppPage({
|
|||
{profileTab === 'details' && active && (
|
||||
<dl className="wa-fields">
|
||||
<div><dt>Phone</dt><dd>{active.phoneLabel}</dd></div>
|
||||
<div><dt>Status</dt><dd><i className="wa-dot" /> WhatsApp subscriber{blocked.has(active.peer) ? ' · Blocked' : ''}</dd></div>
|
||||
<div>
|
||||
<dt>Language</dt>
|
||||
<dd>
|
||||
<select value={language} onChange={(event) => setLanguages((current) => ({ ...current, [active.peer]: event.target.value }))}>
|
||||
<option>English</option>
|
||||
<option>Hindi</option>
|
||||
</select>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Time zone</dt>
|
||||
<dd>
|
||||
<select value={timezone} onChange={(event) => setTimezones((current) => ({ ...current, [active.peer]: event.target.value }))}>
|
||||
<option>Asia/Kolkata (GMT+5:30)</option>
|
||||
<option>Asia/Dubai (GMT+4)</option>
|
||||
</select>
|
||||
</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>{activeFirst ? formatWhen(activeFirst.created_at) : '—'}</dd></div>
|
||||
<div><dt>Last message</dt><dd>{formatWhen(active.latest.created_at)}</dd></div>
|
||||
<div><dt>Source</dt><dd>{active.source}</dd></div>
|
||||
<div>
|
||||
<dt>Tags</dt>
|
||||
<dd className="wa-tag-line">
|
||||
{(tags[active.peer] ?? active.tags).length === 0 && <span className="muted">None yet</span>}
|
||||
{(tags[active.peer] ?? active.tags).map((tag) => <em key={tag}>{tag}</em>)}
|
||||
</dd>
|
||||
</div>
|
||||
<div><dt>Source</dt><dd>WhatsApp</dd></div>
|
||||
</dl>
|
||||
)}
|
||||
{profileTab === 'notes' && active && (
|
||||
<label className="wa-notes">
|
||||
<StickyNote size={14} />
|
||||
<textarea
|
||||
value={notes[active.peer] ?? ''}
|
||||
onChange={(event) => setNotes((current) => ({ ...current, [active.peer]: event.target.value }))}
|
||||
placeholder="Private note about this customer"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{profileTab === 'tags' && active && (
|
||||
<div className="wa-tag-editor">
|
||||
<div className="wa-tag-line">
|
||||
{(tags[active.peer] ?? active.tags).map((tag) => (
|
||||
<em key={tag}>
|
||||
{tag}
|
||||
<button type="button" aria-label={`Remove ${tag}`} onClick={() => setTags((current) => ({
|
||||
...current,
|
||||
[active.peer]: (current[active.peer] ?? active.tags).filter((item) => item !== tag),
|
||||
}))}><X size={10} /></button>
|
||||
</em>
|
||||
))}
|
||||
</div>
|
||||
<div className="wa-tag-add">
|
||||
<input value={tagDraft} onChange={(event) => setTagDraft(event.target.value)} placeholder="Add a tag" />
|
||||
<button type="button" onClick={addTag}><Plus size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{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>{eventLabel(item)}</strong>
|
||||
|
|
@ -1092,14 +689,9 @@ export function AdminWhatsAppPage({
|
|||
<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>
|
||||
<button type="button" onClick={() => setActionNote('Automation queued for this contact')}><Sparkles size={14} /> Start automation</button>
|
||||
<button type="button" onClick={() => setActionNote('Added to the follow-up campaign')}><Megaphone size={14} /> Add to campaign</button>
|
||||
<button type="button" className="danger" onClick={() => active && setBlocked((current) => new Set(current).add(active.peer))}><Ban size={14} /> Block contact</button>
|
||||
{actionNote && <p>{actionNote}</p>}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1682,6 +1682,7 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
|
|||
.wa-menu button { display: block; width: 100%; border: 0; background: transparent; text-align: left; border-radius: 8px; padding: 8px 10px; color: #24344c; font-size: 12px; }
|
||||
.wa-menu button:hover { background: #f4f7fb; }
|
||||
.wa-page { display: flex; flex-direction: column; gap: 14px; padding: 18px 22px 22px; min-height: 0; flex: 1; }
|
||||
.wa-page-title { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
|
||||
.wa-page-title h1 { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 22px; letter-spacing: -.03em; color: #14233b; }
|
||||
.wa-page-title p { margin: 4px 0 0; color: #7d8ba0; font-size: 13px; }
|
||||
.wa-setup { padding: 16px; background: white; border: 1px solid #e7edf5; border-radius: 14px; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue