460 lines
18 KiB
TypeScript
460 lines
18 KiB
TypeScript
import Link from "next/link"
|
|
import { fetchQuery } from "convex/nextjs"
|
|
import { MessageSquare, Phone, Search } from "lucide-react"
|
|
import { api } from "@/convex/_generated/api"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card"
|
|
import { Input } from "@/components/ui/input"
|
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
|
|
type PageProps = {
|
|
searchParams: Promise<{
|
|
search?: string
|
|
channel?: "call" | "sms" | "chat" | "unknown"
|
|
status?: "open" | "closed" | "archived"
|
|
conversationId?: string
|
|
page?: string
|
|
}>
|
|
}
|
|
|
|
function formatTimestamp(value?: number) {
|
|
if (!value) {
|
|
return "—"
|
|
}
|
|
|
|
return new Date(value).toLocaleString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
})
|
|
}
|
|
|
|
function formatSidebarTimestamp(value?: number) {
|
|
if (!value) {
|
|
return ""
|
|
}
|
|
|
|
const date = new Date(value)
|
|
const now = new Date()
|
|
const sameDay = date.toDateString() === now.toDateString()
|
|
|
|
return sameDay
|
|
? date.toLocaleTimeString("en-US", {
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
})
|
|
: date.toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
})
|
|
}
|
|
|
|
function formatDuration(value?: number) {
|
|
if (!value) {
|
|
return "—"
|
|
}
|
|
|
|
const totalSeconds = Math.max(0, Math.round(value / 1000))
|
|
const minutes = Math.floor(totalSeconds / 60)
|
|
const seconds = totalSeconds % 60
|
|
return `${minutes}:${String(seconds).padStart(2, "0")}`
|
|
}
|
|
|
|
function getSyncMessage(sync: any) {
|
|
if (!sync.ghlConfigured) {
|
|
return "Connect GHL to load contacts and conversations."
|
|
}
|
|
if (sync.stages.conversations.status === "running") {
|
|
return "Conversations are syncing now."
|
|
}
|
|
if (sync.stages.conversations.error) {
|
|
return "Conversations could not be loaded from GHL yet."
|
|
}
|
|
if (!sync.latestSyncAt) {
|
|
return "No conversations yet."
|
|
}
|
|
return "Browse contacts and conversations in one inbox."
|
|
}
|
|
|
|
function getInitials(value?: string) {
|
|
const text = String(value || "").trim()
|
|
if (!text) {
|
|
return "RM"
|
|
}
|
|
|
|
const parts = text.split(/\s+/).filter(Boolean)
|
|
if (parts.length === 1) {
|
|
return parts[0].slice(0, 2).toUpperCase()
|
|
}
|
|
|
|
return `${parts[0][0] || ""}${parts[1][0] || ""}`.toUpperCase()
|
|
}
|
|
|
|
function buildConversationHref(params: {
|
|
search?: string
|
|
channel?: string
|
|
status?: string
|
|
conversationId?: string
|
|
}) {
|
|
const nextParams = new URLSearchParams()
|
|
if (params.search) {
|
|
nextParams.set("search", params.search)
|
|
}
|
|
if (params.channel) {
|
|
nextParams.set("channel", params.channel)
|
|
}
|
|
if (params.status) {
|
|
nextParams.set("status", params.status)
|
|
}
|
|
if (params.conversationId) {
|
|
nextParams.set("conversationId", params.conversationId)
|
|
}
|
|
|
|
const query = nextParams.toString()
|
|
return query ? `/admin/conversations?${query}` : "/admin/conversations"
|
|
}
|
|
|
|
export default async function AdminConversationsPage({
|
|
searchParams,
|
|
}: PageProps) {
|
|
const params = await searchParams
|
|
const search = params.search?.trim() || undefined
|
|
|
|
const data = await fetchQuery(api.crm.listAdminConversations, {
|
|
search,
|
|
page: 1,
|
|
limit: 100,
|
|
channel: params.channel,
|
|
status: params.status,
|
|
})
|
|
|
|
const selectedConversationId =
|
|
(params.conversationId &&
|
|
data.items.find((item: any) => item.id === params.conversationId)?.id) ||
|
|
data.items[0]?.id
|
|
|
|
const detail = selectedConversationId
|
|
? await fetchQuery(api.crm.getAdminConversationDetail, {
|
|
conversationId: selectedConversationId,
|
|
})
|
|
: null
|
|
|
|
const timeline = detail
|
|
? [
|
|
...detail.messages.map((message: any) => ({
|
|
id: `message-${message.id}`,
|
|
type: "message" as const,
|
|
timestamp: message.sentAt || 0,
|
|
message,
|
|
})),
|
|
...detail.recordings.map((recording: any) => ({
|
|
id: `recording-${recording.id}`,
|
|
type: "recording" as const,
|
|
timestamp: recording.startedAt || recording.endedAt || 0,
|
|
recording,
|
|
})),
|
|
].sort((a, b) => a.timestamp - b.timestamp)
|
|
: []
|
|
|
|
return (
|
|
<div className="container mx-auto px-4 py-8">
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
|
<div>
|
|
<h1 className="text-4xl font-bold tracking-tight text-balance">
|
|
Conversations
|
|
</h1>
|
|
<p className="mt-2 text-muted-foreground">
|
|
Review calls and messages in one inbox.
|
|
</p>
|
|
</div>
|
|
<Link href="/admin">
|
|
<Button variant="outline">Back to Admin</Button>
|
|
</Link>
|
|
</div>
|
|
|
|
<Card className="rounded-[2rem]">
|
|
<CardContent className="flex flex-wrap items-center gap-3 px-6 py-4 text-sm text-muted-foreground">
|
|
<Badge variant="outline">{data.sync.overallStatus}</Badge>
|
|
<span>{getSyncMessage(data.sync)}</span>
|
|
<span>Last sync: {formatTimestamp(data.sync.latestSyncAt || undefined)}</span>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="overflow-hidden rounded-[2rem] p-0">
|
|
<div className="grid min-h-[720px] lg:grid-cols-[360px_minmax(0,1fr)]">
|
|
<div className="border-b bg-white lg:border-b-0 lg:border-r">
|
|
<div className="space-y-4 border-b px-5 py-5">
|
|
<div className="flex items-center gap-2">
|
|
<MessageSquare className="h-5 w-5 text-muted-foreground" />
|
|
<div>
|
|
<h2 className="font-semibold">Conversation Inbox</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
Search and pick a conversation to review.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<form className="space-y-3">
|
|
<div className="relative">
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
name="search"
|
|
defaultValue={search || ""}
|
|
placeholder="Search contacts or messages"
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
|
|
<select
|
|
name="channel"
|
|
defaultValue={params.channel || ""}
|
|
className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
>
|
|
<option value="">All channels</option>
|
|
<option value="call">Call</option>
|
|
<option value="sms">SMS</option>
|
|
<option value="chat">Chat</option>
|
|
<option value="unknown">Unknown</option>
|
|
</select>
|
|
<select
|
|
name="status"
|
|
defaultValue={params.status || ""}
|
|
className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
>
|
|
<option value="">All statuses</option>
|
|
<option value="open">Open</option>
|
|
<option value="closed">Closed</option>
|
|
<option value="archived">Archived</option>
|
|
</select>
|
|
<Button type="submit">Filter</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<ScrollArea className="h-[520px] lg:h-[640px]">
|
|
<div className="divide-y">
|
|
{data.items.length === 0 ? (
|
|
<div className="px-5 py-8 text-sm text-muted-foreground">
|
|
{search || params.channel || params.status
|
|
? "No conversations matched this search."
|
|
: getSyncMessage(data.sync)}
|
|
</div>
|
|
) : (
|
|
data.items.map((conversation: any) => {
|
|
const isSelected = conversation.id === selectedConversationId
|
|
return (
|
|
<Link
|
|
key={conversation.id}
|
|
href={buildConversationHref({
|
|
search,
|
|
channel: params.channel,
|
|
status: params.status,
|
|
conversationId: conversation.id,
|
|
})}
|
|
className={[
|
|
"flex gap-3 px-5 py-4 transition-colors",
|
|
isSelected
|
|
? "bg-primary/5 ring-1 ring-inset ring-primary/20"
|
|
: "hover:bg-muted/40",
|
|
].join(" ")}
|
|
>
|
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
|
|
{getInitials(conversation.displayName)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<p className="truncate font-medium">
|
|
{conversation.displayName}
|
|
</p>
|
|
{conversation.secondaryLine ? (
|
|
<p className="truncate text-xs text-muted-foreground">
|
|
{conversation.secondaryLine}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
<span className="shrink-0 text-xs text-muted-foreground">
|
|
{formatSidebarTimestamp(conversation.lastMessageAt)}
|
|
</span>
|
|
</div>
|
|
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">
|
|
{conversation.lastMessagePreview ||
|
|
"No messages or call notes yet."}
|
|
</p>
|
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
<Badge variant="outline">{conversation.channel}</Badge>
|
|
<Badge variant="secondary">{conversation.status}</Badge>
|
|
{conversation.recordingCount ? (
|
|
<Badge variant="outline">
|
|
{conversation.recordingCount} recording
|
|
{conversation.recordingCount === 1 ? "" : "s"}
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
|
|
<div className="bg-[#faf8f3]">
|
|
{detail ? (
|
|
<div className="flex h-full flex-col">
|
|
<div className="border-b bg-white px-6 py-5">
|
|
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
|
<div className="space-y-2">
|
|
<div>
|
|
<h2 className="text-2xl font-semibold">
|
|
{detail.contact?.name ||
|
|
detail.conversation.title ||
|
|
"Conversation"}
|
|
</h2>
|
|
{detail.contact?.secondaryLine ||
|
|
detail.contact?.email ||
|
|
detail.contact?.phone ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
{detail.contact?.secondaryLine ||
|
|
detail.contact?.phone ||
|
|
detail.contact?.email}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Badge variant="outline">
|
|
{detail.conversation.channel}
|
|
</Badge>
|
|
<Badge variant="secondary">
|
|
{detail.conversation.status}
|
|
</Badge>
|
|
<Badge variant="outline">
|
|
{timeline.filter((item) => item.type === "message").length}{" "}
|
|
messages
|
|
</Badge>
|
|
{detail.recordings.length ? (
|
|
<Badge variant="outline">
|
|
{detail.recordings.length} recording
|
|
{detail.recordings.length === 1 ? "" : "s"}
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
Last activity:{" "}
|
|
{formatTimestamp(detail.conversation.lastMessageAt)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<ScrollArea className="h-[520px] px-4 py-5 lg:h-[640px] lg:px-6">
|
|
<div className="space-y-4 pb-2">
|
|
{timeline.length === 0 ? (
|
|
<div className="rounded-2xl border border-dashed bg-white/70 px-6 py-10 text-center text-sm text-muted-foreground">
|
|
No messages or recordings have been mirrored into this
|
|
conversation yet.
|
|
</div>
|
|
) : (
|
|
timeline.map((item: any) => {
|
|
if (item.type === "recording") {
|
|
const recording = item.recording
|
|
return (
|
|
<div key={item.id} className="max-w-2xl rounded-2xl border bg-white p-4 shadow-sm">
|
|
<div className="flex items-center gap-2 text-sm font-medium">
|
|
<Phone className="h-4 w-4 text-muted-foreground" />
|
|
Call recording
|
|
<Badge variant="outline" className="ml-2">
|
|
{recording.recordingStatus || "recording"}
|
|
</Badge>
|
|
</div>
|
|
<div className="mt-2 flex flex-wrap gap-4 text-xs text-muted-foreground">
|
|
<span>{formatTimestamp(recording.startedAt)}</span>
|
|
<span>Duration: {formatDuration(recording.durationMs)}</span>
|
|
</div>
|
|
{recording.recordingUrl ? (
|
|
<div className="mt-3">
|
|
<a
|
|
href={recording.recordingUrl}
|
|
target="_blank"
|
|
className="text-sm font-medium text-primary hover:underline"
|
|
>
|
|
Open recording
|
|
</a>
|
|
</div>
|
|
) : null}
|
|
{recording.transcriptionText ? (
|
|
<div className="mt-3 rounded-xl border bg-muted/30 p-3 text-sm whitespace-pre-wrap text-foreground/90">
|
|
{recording.transcriptionText}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const message = item.message
|
|
const isOutbound = message.direction === "outbound"
|
|
|
|
return (
|
|
<div
|
|
key={item.id}
|
|
className={`flex ${isOutbound ? "justify-end" : "justify-start"}`}
|
|
>
|
|
<div
|
|
className={[
|
|
"max-w-[85%] rounded-3xl px-4 py-3 shadow-sm",
|
|
isOutbound
|
|
? "bg-primary text-primary-foreground"
|
|
: "border bg-white",
|
|
].join(" ")}
|
|
>
|
|
<div className="mb-2 flex items-center gap-2 text-[11px] uppercase tracking-wide opacity-70">
|
|
<span>{message.channel}</span>
|
|
<span>{message.direction}</span>
|
|
{message.status ? <span>{message.status}</span> : null}
|
|
</div>
|
|
<p className="whitespace-pre-wrap text-sm leading-6">
|
|
{message.body}
|
|
</p>
|
|
<div className="mt-2 text-right text-xs opacity-70">
|
|
{formatTimestamp(message.sentAt)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
) : (
|
|
<div className="flex h-full min-h-[520px] items-center justify-center px-6 py-16">
|
|
<div className="max-w-md text-center">
|
|
<h2 className="text-2xl font-semibold">No conversation selected</h2>
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
Choose a conversation from the left to open the full thread.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export const metadata = {
|
|
title: "Conversations | Admin",
|
|
description: "View Rocky customer conversations",
|
|
}
|