Rocky_Mountain_Vending/app/admin/conversations/page.tsx

518 lines
21 KiB
TypeScript

import Link from "next/link"
import { fetchAction, 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
error?: 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 hydratedDetail =
detail &&
detail.messages.length === 0 &&
detail.conversation.ghlConversationId
? await fetchAction(api.crm.hydrateConversationHistory, {
conversationId: detail.conversation.id,
}).then(async (result) => {
if (result?.imported) {
return await fetchQuery(api.crm.getAdminConversationDetail, {
conversationId: detail.conversation.id,
})
}
return detail
})
: detail
const timeline = hydratedDetail
? [
...hydratedDetail.messages.map((message: any) => ({
id: `message-${message.id}`,
type: "message" as const,
timestamp: message.sentAt || 0,
message,
})),
...hydratedDetail.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]">
{hydratedDetail ? (
<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">
{hydratedDetail.contact?.name ||
hydratedDetail.conversation.title ||
"Conversation"}
</h2>
{hydratedDetail.contact?.secondaryLine ||
hydratedDetail.contact?.email ||
hydratedDetail.contact?.phone ? (
<p className="text-sm text-muted-foreground">
{hydratedDetail.contact?.secondaryLine ||
hydratedDetail.contact?.phone ||
hydratedDetail.contact?.email}
</p>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">
{hydratedDetail.conversation.channel}
</Badge>
<Badge variant="secondary">
{hydratedDetail.conversation.status}
</Badge>
<Badge variant="outline">
{timeline.filter((item) => item.type === "message").length}{" "}
messages
</Badge>
{hydratedDetail.recordings.length ? (
<Badge variant="outline">
{hydratedDetail.recordings.length} recording
{hydratedDetail.recordings.length === 1 ? "" : "s"}
</Badge>
) : null}
</div>
</div>
<div className="text-sm text-muted-foreground">
Last activity:{" "}
{formatTimestamp(hydratedDetail.conversation.lastMessageAt)}
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-3">
<form
action={`/api/admin/conversations/${hydratedDetail.conversation.id}/sync`}
method="post"
>
<Button type="submit" variant="outline" size="sm">
Refresh history
</Button>
</form>
{params.error === "send" ? (
<p className="text-sm text-destructive">
Rocky could not send that message through GHL.
</p>
) : null}
{params.error === "sync" ? (
<p className="text-sm text-destructive">
Rocky could not refresh that conversation from GHL.
</p>
) : null}
</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. Use refresh history to pull the latest
thread from GHL.
</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 className="border-t bg-white px-4 py-4 lg:px-6">
<form
action={`/api/admin/conversations/${hydratedDetail.conversation.id}/messages`}
method="post"
className="space-y-3"
>
<textarea
name="body"
rows={4}
placeholder="Reply to this conversation"
className="border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 min-h-24 w-full rounded-2xl border bg-background px-4 py-3 text-sm shadow-sm outline-none focus-visible:ring-[3px]"
/>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
Sends through GHL and mirrors the reply back into Rocky.
</p>
<Button type="submit">Send message</Button>
</div>
</form>
</div>
</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",
}