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 (

Conversations

Review calls and messages in one inbox.

{data.sync.overallStatus} {getSyncMessage(data.sync)} Last sync: {formatTimestamp(data.sync.latestSyncAt || undefined)}

Conversation Inbox

Search and pick a conversation to review.

{data.items.length === 0 ? (
{search || params.channel || params.status ? "No conversations matched this search." : getSyncMessage(data.sync)}
) : ( data.items.map((conversation: any) => { const isSelected = conversation.id === selectedConversationId return (
{getInitials(conversation.displayName)}

{conversation.displayName}

{conversation.secondaryLine ? (

{conversation.secondaryLine}

) : null}
{formatSidebarTimestamp(conversation.lastMessageAt)}

{conversation.lastMessagePreview || "No messages or call notes yet."}

{conversation.channel} {conversation.status} {conversation.recordingCount ? ( {conversation.recordingCount} recording {conversation.recordingCount === 1 ? "" : "s"} ) : null}
) }) )}
{hydratedDetail ? (

{hydratedDetail.contact?.name || hydratedDetail.conversation.title || "Conversation"}

{hydratedDetail.contact?.secondaryLine || hydratedDetail.contact?.email || hydratedDetail.contact?.phone ? (

{hydratedDetail.contact?.secondaryLine || hydratedDetail.contact?.phone || hydratedDetail.contact?.email}

) : null}
{hydratedDetail.conversation.channel} {hydratedDetail.conversation.status} {timeline.filter((item) => item.type === "message").length}{" "} messages {hydratedDetail.recordings.length ? ( {hydratedDetail.recordings.length} recording {hydratedDetail.recordings.length === 1 ? "" : "s"} ) : null}
Last activity:{" "} {formatTimestamp(hydratedDetail.conversation.lastMessageAt)}
{params.error === "send" ? (

Rocky could not send that message through GHL.

) : null} {params.error === "sync" ? (

Rocky could not refresh that conversation from GHL.

) : null}
{timeline.length === 0 ? (
No messages or recordings have been mirrored into this conversation yet. Use refresh history to pull the latest thread from GHL.
) : ( timeline.map((item: any) => { if (item.type === "recording") { const recording = item.recording return (
Call recording {recording.recordingStatus || "recording"}
{formatTimestamp(recording.startedAt)} Duration: {formatDuration(recording.durationMs)}
{recording.recordingUrl ? ( ) : null} {recording.transcriptionText ? (
{recording.transcriptionText}
) : null}
) } const message = item.message const isOutbound = message.direction === "outbound" return (
{message.channel} {message.direction} {message.status ? {message.status} : null}

{message.body}

{formatTimestamp(message.sentAt)}
) }) )}