mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
589 lines
20 KiB
TypeScript
589 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
|
import { createClient } from "@/lib/supabase/client";
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import { cn } from "@/lib/utils";
|
|
import type {
|
|
Conversation,
|
|
Message,
|
|
Contact,
|
|
ConversationStatus,
|
|
MessageTemplate,
|
|
Profile,
|
|
} from "@/types";
|
|
import {
|
|
MessageSquare,
|
|
ChevronDown,
|
|
UserPlus,
|
|
Check,
|
|
Clock,
|
|
ArrowLeft,
|
|
} from "lucide-react";
|
|
import { format, isToday, isYesterday, differenceInHours } from "date-fns";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { MessageBubble } from "./message-bubble";
|
|
import { MessageComposer } from "./message-composer";
|
|
import { TemplatePicker } from "./template-picker";
|
|
import { toast } from "sonner";
|
|
|
|
function renderTemplateBody(body: string, params: string[]): string {
|
|
return body.replace(/\{\{(\d+)\}\}/g, (_, raw) => {
|
|
const idx = Number(raw) - 1;
|
|
return params[idx] ?? `{{${raw}}}`;
|
|
});
|
|
}
|
|
|
|
interface MessageThreadProps {
|
|
conversation: Conversation | null;
|
|
contact: Contact | null;
|
|
messages: Message[];
|
|
onMessagesLoaded: (messages: Message[]) => void;
|
|
onNewMessage: (message: Message) => void;
|
|
onUpdateMessage: (id: string, updates: Partial<Message>) => void;
|
|
onStatusChange: (conversationId: string, status: ConversationStatus) => void;
|
|
onAssignChange: (
|
|
conversationId: string,
|
|
assignedAgentId: string | null,
|
|
) => void;
|
|
/**
|
|
* On mobile, the thread is shown full-screen with the conversation list
|
|
* hidden. This callback lets the page deselect the active conversation
|
|
* and reveal the list again. Rendered as a back-arrow in the header on
|
|
* mobile only.
|
|
*/
|
|
onBack?: () => void;
|
|
}
|
|
|
|
function formatDateSeparator(dateStr: string): string {
|
|
const date = new Date(dateStr);
|
|
if (isToday(date)) return "Today";
|
|
if (isYesterday(date)) return "Yesterday";
|
|
return format(date, "MMMM d, yyyy");
|
|
}
|
|
|
|
function groupMessagesByDate(messages: Message[]) {
|
|
const groups: { date: string; messages: Message[] }[] = [];
|
|
let currentDate = "";
|
|
|
|
for (const msg of messages) {
|
|
const day = format(new Date(msg.created_at), "yyyy-MM-dd");
|
|
if (day !== currentDate) {
|
|
currentDate = day;
|
|
groups.push({ date: msg.created_at, messages: [msg] });
|
|
} else {
|
|
groups[groups.length - 1].messages.push(msg);
|
|
}
|
|
}
|
|
|
|
return groups;
|
|
}
|
|
|
|
const STATUS_OPTIONS: { label: string; value: ConversationStatus; color: string }[] = [
|
|
{ label: "Open", value: "open", color: "text-violet-400" },
|
|
{ label: "Pending", value: "pending", color: "text-amber-400" },
|
|
{ label: "Closed", value: "closed", color: "text-slate-400" },
|
|
];
|
|
|
|
export function MessageThread({
|
|
conversation,
|
|
contact,
|
|
messages,
|
|
onMessagesLoaded,
|
|
onNewMessage,
|
|
onUpdateMessage,
|
|
onStatusChange,
|
|
onAssignChange,
|
|
onBack,
|
|
}: MessageThreadProps) {
|
|
const { user } = useAuth();
|
|
const [loading, setLoading] = useState(false);
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const [templateModalOpen, setTemplateModalOpen] = useState(false);
|
|
const [profiles, setProfiles] = useState<Profile[]>([]);
|
|
|
|
// Profiles are bounded by RLS to rows the current user is allowed to
|
|
// see — today that's just the current user, but the dropdown keeps the
|
|
// shape ready for shared-team workspaces without a refactor.
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const supabase = createClient();
|
|
supabase
|
|
.from("profiles")
|
|
.select("*")
|
|
.order("full_name")
|
|
.then(({ data, error }) => {
|
|
if (cancelled) return;
|
|
if (error) {
|
|
console.error("Failed to fetch profiles:", error);
|
|
return;
|
|
}
|
|
setProfiles((data as Profile[]) ?? []);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
// 24-hour session timer
|
|
const sessionInfo = useMemo(() => {
|
|
if (!messages.length) return { expired: false, remaining: "" };
|
|
|
|
// Find last customer message
|
|
const lastCustomerMsg = [...messages]
|
|
.reverse()
|
|
.find((m) => m.sender_type === "customer");
|
|
|
|
if (!lastCustomerMsg) return { expired: true, remaining: "No customer messages" };
|
|
|
|
const hoursSince = differenceInHours(new Date(), new Date(lastCustomerMsg.created_at));
|
|
const expired = hoursSince >= 24;
|
|
|
|
if (expired) {
|
|
return { expired: true, remaining: "Expired" };
|
|
}
|
|
|
|
const hoursLeft = 24 - hoursSince;
|
|
const remaining =
|
|
hoursLeft >= 1
|
|
? `${Math.floor(hoursLeft)}h remaining`
|
|
: `${Math.floor(hoursLeft * 60)}m remaining`;
|
|
|
|
return { expired, remaining };
|
|
}, [messages]);
|
|
|
|
// Store latest callback in a ref so fetchMessages doesn't need to
|
|
// depend on `onMessagesLoaded` — otherwise parent re-renders cause
|
|
// fetchMessages to change → useEffect re-fires → refetch → realtime
|
|
// UPDATE on conversations.unread_count → parent re-renders → LOOP.
|
|
// The ref is written inside an effect so the mutation doesn't happen
|
|
// during render (React 19 refs rule); consumers only read `.current`
|
|
// inside the async fetch completion, which runs after the render.
|
|
const onMessagesLoadedRef = useRef(onMessagesLoaded);
|
|
useEffect(() => {
|
|
onMessagesLoadedRef.current = onMessagesLoaded;
|
|
});
|
|
|
|
const conversationId = conversation?.id;
|
|
const hasUnread = (conversation?.unread_count ?? 0) > 0;
|
|
|
|
// Fetch messages whenever the selected conversation changes. Kept
|
|
// separate from the unread-reset effect so that incoming messages
|
|
// arriving while the thread is open don't trigger a full refetch —
|
|
// they only flip hasUnread, which only the reset effect listens to.
|
|
useEffect(() => {
|
|
if (!conversationId) return;
|
|
|
|
const supabase = createClient();
|
|
let cancelled = false;
|
|
|
|
(async () => {
|
|
setLoading(true);
|
|
|
|
const { data, error } = await supabase
|
|
.from("messages")
|
|
.select("*")
|
|
.eq("conversation_id", conversationId)
|
|
.order("created_at", { ascending: true });
|
|
|
|
if (cancelled) return;
|
|
|
|
if (error) {
|
|
console.error("Failed to fetch messages:", error);
|
|
} else {
|
|
onMessagesLoadedRef.current(data ?? []);
|
|
}
|
|
|
|
if (!cancelled) setLoading(false);
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [conversationId]);
|
|
|
|
// Reset the server-side unread_count to 0 whenever an unread count
|
|
// surfaces on the active conversation — covers both (a) opening a
|
|
// conversation that had unread messages and (b) new messages arriving
|
|
// while the user is already viewing the thread (webhook server-bumps
|
|
// unread_count to N+1; the realtime UPDATE propagates it into the
|
|
// client, which re-runs this effect and flips it back to 0).
|
|
//
|
|
// Guarding on hasUnread prevents the eq-update loop: once unread_count
|
|
// is 0 the condition is false, so no further UPDATE is issued.
|
|
useEffect(() => {
|
|
if (!conversationId || !hasUnread) return;
|
|
const supabase = createClient();
|
|
supabase
|
|
.from("conversations")
|
|
.update({ unread_count: 0 })
|
|
.eq("id", conversationId)
|
|
.then(({ error }) => {
|
|
if (error) console.error("Failed to reset unread_count:", error);
|
|
});
|
|
}, [conversationId, hasUnread]);
|
|
|
|
// Auto-scroll to bottom on new messages
|
|
useEffect(() => {
|
|
if (scrollRef.current) {
|
|
const el = scrollRef.current;
|
|
el.scrollTop = el.scrollHeight;
|
|
}
|
|
}, [messages]);
|
|
|
|
const handleSend = useCallback(
|
|
async (text: string) => {
|
|
if (!conversation) return;
|
|
|
|
const tempId = `temp-${Date.now()}`;
|
|
|
|
// Optimistic update — shows the message immediately with "sending" status
|
|
const optimisticMsg: Message = {
|
|
id: tempId,
|
|
conversation_id: conversation.id,
|
|
sender_type: "agent",
|
|
content_type: "text",
|
|
content_text: text,
|
|
status: "sending",
|
|
created_at: new Date().toISOString(),
|
|
};
|
|
onNewMessage(optimisticMsg);
|
|
|
|
try {
|
|
const res = await fetch("/api/whatsapp/send", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
conversation_id: conversation.id,
|
|
message_type: "text",
|
|
content_text: text,
|
|
}),
|
|
});
|
|
|
|
const payload = await res.json().catch(() => ({}));
|
|
|
|
if (!res.ok) {
|
|
const reason = payload?.error || `HTTP ${res.status}`;
|
|
console.error("Failed to send message:", reason);
|
|
toast.error(`Failed to send: ${reason}`);
|
|
// Mark the optimistic bubble as failed so the user sees what happened
|
|
onUpdateMessage(tempId, { status: "failed" });
|
|
return;
|
|
}
|
|
|
|
// Success — the realtime INSERT event will replace the temp bubble
|
|
// with the real DB row. If realtime hasn't arrived yet, at least
|
|
// flip status to 'sent' so the UI stops showing "sending".
|
|
onUpdateMessage(tempId, { status: "sent" });
|
|
} catch (err) {
|
|
console.error("Failed to send message:", err);
|
|
const reason = err instanceof Error ? err.message : "network error";
|
|
toast.error(`Failed to send: ${reason}`);
|
|
onUpdateMessage(tempId, { status: "failed" });
|
|
}
|
|
},
|
|
[conversation, onNewMessage, onUpdateMessage]
|
|
);
|
|
|
|
const handleStatusChange = useCallback(
|
|
async (status: ConversationStatus) => {
|
|
if (!conversation) return;
|
|
|
|
const supabase = createClient();
|
|
await supabase
|
|
.from("conversations")
|
|
.update({ status })
|
|
.eq("id", conversation.id);
|
|
|
|
onStatusChange(conversation.id, status);
|
|
},
|
|
[conversation, onStatusChange]
|
|
);
|
|
|
|
const handleOpenTemplates = useCallback(() => {
|
|
setTemplateModalOpen(true);
|
|
}, []);
|
|
|
|
const handleSendTemplate = useCallback(
|
|
async (template: MessageTemplate, params: string[]) => {
|
|
if (!conversation) return;
|
|
|
|
const renderedBody = renderTemplateBody(template.body_text, params);
|
|
const tempId = `temp-${Date.now()}`;
|
|
|
|
const optimisticMsg: Message = {
|
|
id: tempId,
|
|
conversation_id: conversation.id,
|
|
sender_type: "agent",
|
|
content_type: "template",
|
|
content_text: renderedBody,
|
|
template_name: template.name,
|
|
status: "sending",
|
|
created_at: new Date().toISOString(),
|
|
};
|
|
onNewMessage(optimisticMsg);
|
|
|
|
try {
|
|
const res = await fetch("/api/whatsapp/send", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
conversation_id: conversation.id,
|
|
message_type: "template",
|
|
template_name: template.name,
|
|
template_params: params,
|
|
content_text: renderedBody,
|
|
}),
|
|
});
|
|
|
|
const payload = await res.json().catch(() => ({}));
|
|
|
|
if (!res.ok) {
|
|
const reason = payload?.error || `HTTP ${res.status}`;
|
|
console.error("Failed to send template:", reason);
|
|
toast.error(`Failed to send template: ${reason}`);
|
|
onUpdateMessage(tempId, { status: "failed" });
|
|
return;
|
|
}
|
|
|
|
onUpdateMessage(tempId, { status: "sent" });
|
|
} catch (err) {
|
|
console.error("Failed to send template:", err);
|
|
const reason = err instanceof Error ? err.message : "network error";
|
|
toast.error(`Failed to send template: ${reason}`);
|
|
onUpdateMessage(tempId, { status: "failed" });
|
|
}
|
|
},
|
|
[conversation, onNewMessage, onUpdateMessage],
|
|
);
|
|
|
|
const handleAssignChange = useCallback(
|
|
async (agentId: string | null) => {
|
|
if (!conversation) return;
|
|
|
|
const supabase = createClient();
|
|
const { error } = await supabase
|
|
.from("conversations")
|
|
.update({ assigned_agent_id: agentId })
|
|
.eq("id", conversation.id);
|
|
|
|
if (error) {
|
|
console.error("Failed to update assignment:", error);
|
|
toast.error("Failed to update assignment");
|
|
return;
|
|
}
|
|
|
|
onAssignChange(conversation.id, agentId);
|
|
},
|
|
[conversation, onAssignChange],
|
|
);
|
|
|
|
// Empty state
|
|
if (!conversation || !contact) {
|
|
return (
|
|
<div className="flex flex-1 flex-col items-center justify-center bg-slate-950">
|
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-800">
|
|
<MessageSquare className="h-8 w-8 text-slate-600" />
|
|
</div>
|
|
<h3 className="mt-4 text-sm font-medium text-slate-400">
|
|
Select a conversation
|
|
</h3>
|
|
<p className="mt-1 text-xs text-slate-600">
|
|
Choose a conversation from the left to start messaging
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const displayName = contact.name || contact.phone;
|
|
const messageGroups = groupMessagesByDate(messages);
|
|
const currentStatus = STATUS_OPTIONS.find(
|
|
(s) => s.value === conversation.status
|
|
);
|
|
const assignedAgentId = conversation.assigned_agent_id ?? null;
|
|
const currentAssignee = profiles.find((p) => p.user_id === assignedAgentId);
|
|
const assignLabel = assignedAgentId
|
|
? (currentAssignee?.full_name ?? "Assigned")
|
|
: "Assign";
|
|
|
|
return (
|
|
<div className="flex flex-1 flex-col bg-slate-950">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between gap-2 border-b border-slate-800 bg-slate-900 px-3 py-3 sm:px-4">
|
|
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
|
|
{/* Back-to-list button — mobile only. Hidden on lg+ where the
|
|
conversation list is always visible next to the thread. */}
|
|
{onBack && (
|
|
<button
|
|
type="button"
|
|
onClick={onBack}
|
|
aria-label="Back to conversations"
|
|
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md text-slate-300 hover:bg-slate-800 hover:text-white lg:hidden"
|
|
>
|
|
<ArrowLeft className="h-5 w-5" />
|
|
</button>
|
|
)}
|
|
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-slate-700 text-sm font-medium text-white">
|
|
{displayName.charAt(0).toUpperCase()}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h2 className="truncate text-sm font-semibold text-white">{displayName}</h2>
|
|
<p className="truncate text-xs text-slate-400">{contact.phone}</p>
|
|
</div>
|
|
{/* Session timer badge — hidden on the narrowest phones so
|
|
the name + back arrow keep their room. */}
|
|
<Badge
|
|
variant="outline"
|
|
className={cn(
|
|
"ml-1 hidden gap-1 border-slate-700 text-[10px] sm:inline-flex sm:ml-2",
|
|
sessionInfo.expired ? "text-red-400" : "text-violet-400"
|
|
)}
|
|
>
|
|
<Clock className="h-3 w-3" />
|
|
{sessionInfo.remaining}
|
|
</Badge>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{/* Status dropdown */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger className={cn(
|
|
"inline-flex items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-slate-800",
|
|
currentStatus?.color ?? "text-slate-400"
|
|
)}>
|
|
{currentStatus?.label ?? "Status"}
|
|
<ChevronDown className="h-3 w-3" />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="end"
|
|
className="border-slate-700 bg-slate-800"
|
|
>
|
|
{STATUS_OPTIONS.map((opt) => (
|
|
<DropdownMenuItem
|
|
key={opt.value}
|
|
onClick={() => handleStatusChange(opt.value)}
|
|
className={cn("text-sm", opt.color)}
|
|
>
|
|
{opt.label}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* Assign dropdown */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
className={cn(
|
|
"inline-flex items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-slate-800",
|
|
assignedAgentId ? "text-violet-400" : "text-slate-400"
|
|
)}
|
|
>
|
|
<UserPlus className="h-3 w-3" />
|
|
<span className="hidden sm:inline">{assignLabel}</span>
|
|
<ChevronDown className="h-3 w-3" />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="end"
|
|
className="border-slate-700 bg-slate-800"
|
|
>
|
|
{profiles.length === 0 ? (
|
|
<DropdownMenuItem disabled className="text-sm text-slate-500">
|
|
No teammates available
|
|
</DropdownMenuItem>
|
|
) : (
|
|
profiles.map((p) => {
|
|
const isSelected = p.user_id === assignedAgentId;
|
|
return (
|
|
<DropdownMenuItem
|
|
key={p.id}
|
|
onClick={() => handleAssignChange(p.user_id)}
|
|
className={cn(
|
|
"text-sm",
|
|
isSelected ? "text-violet-400" : "text-slate-300"
|
|
)}
|
|
>
|
|
<span className="flex-1">
|
|
{p.full_name}
|
|
{p.user_id === user?.id ? " (me)" : ""}
|
|
</span>
|
|
{isSelected && <Check className="ml-2 h-3 w-3" />}
|
|
</DropdownMenuItem>
|
|
);
|
|
})
|
|
)}
|
|
{assignedAgentId && (
|
|
<>
|
|
<DropdownMenuSeparator className="bg-slate-700" />
|
|
<DropdownMenuItem
|
|
onClick={() => handleAssignChange(null)}
|
|
className="text-sm text-slate-400"
|
|
>
|
|
Unassign
|
|
</DropdownMenuItem>
|
|
</>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Messages Area */}
|
|
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-violet-500 border-t-transparent" />
|
|
</div>
|
|
) : messages.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12">
|
|
<p className="text-sm text-slate-500">No messages yet</p>
|
|
<p className="text-xs text-slate-600">
|
|
Send a template to start the conversation
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{messageGroups.map((group) => (
|
|
<div key={group.date}>
|
|
{/* Date separator */}
|
|
<div className="mb-4 flex items-center justify-center">
|
|
<span className="rounded-full bg-slate-800 px-3 py-1 text-[10px] font-medium text-slate-400">
|
|
{formatDateSeparator(group.date)}
|
|
</span>
|
|
</div>
|
|
{/* Messages */}
|
|
<div className="space-y-2">
|
|
{group.messages.map((msg) => (
|
|
<MessageBubble key={msg.id} message={msg} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Composer */}
|
|
<MessageComposer
|
|
conversationId={conversation.id}
|
|
sessionExpired={sessionInfo.expired}
|
|
onSend={handleSend}
|
|
onOpenTemplates={handleOpenTemplates}
|
|
/>
|
|
|
|
<TemplatePicker
|
|
open={templateModalOpen}
|
|
onOpenChange={setTemplateModalOpen}
|
|
onSelect={handleSendTemplate}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|