mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Fixes two runtime errors:
1. "Lock 'lock:sb-xxx-auth-token' was released because another request
stole it" — caused by multiple Supabase client instances competing
for the same auth storage lock.
2. "Failed to fetch conversations: {}" — related to #1. When the auth
lock is stolen mid-request, the session becomes temporarily
unavailable and queries fail.
The codebase had 65 call sites invoking createClient() — each creating
a new browser client with its own auth listener. This change makes
createClient() cache and return a single shared instance.
Also improved error logging in conversation-list.tsx to show Supabase
error fields (message/details/hint/code) instead of an empty {}.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
257 lines
8.0 KiB
TypeScript
257 lines
8.0 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
|
import { createClient } from "@/lib/supabase/client";
|
|
import { cn } from "@/lib/utils";
|
|
import type { Conversation, ConversationStatus } from "@/types";
|
|
import { Search, ChevronDown } from "lucide-react";
|
|
import { formatDistanceToNow } from "date-fns";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
|
|
interface ConversationListProps {
|
|
activeConversationId: string | null;
|
|
onSelect: (conversation: Conversation) => void;
|
|
conversations: Conversation[];
|
|
onConversationsLoaded: (conversations: Conversation[]) => void;
|
|
}
|
|
|
|
const STATUS_COLORS: Record<ConversationStatus, string> = {
|
|
open: "bg-emerald-500",
|
|
pending: "bg-amber-500",
|
|
closed: "bg-slate-500",
|
|
};
|
|
|
|
const FILTER_OPTIONS: { label: string; value: ConversationStatus | "all" }[] = [
|
|
{ label: "All", value: "all" },
|
|
{ label: "Open", value: "open" },
|
|
{ label: "Pending", value: "pending" },
|
|
{ label: "Closed", value: "closed" },
|
|
];
|
|
|
|
export function ConversationList({
|
|
activeConversationId,
|
|
onSelect,
|
|
conversations,
|
|
onConversationsLoaded,
|
|
}: ConversationListProps) {
|
|
const [search, setSearch] = useState("");
|
|
const [filter, setFilter] = useState<ConversationStatus | "all">("all");
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const fetchConversations = useCallback(async () => {
|
|
const supabase = createClient();
|
|
const { data, error } = await supabase
|
|
.from("conversations")
|
|
.select("*, contact:contacts(*)")
|
|
.order("last_message_at", { ascending: false });
|
|
|
|
if (error) {
|
|
// Supabase errors have non-enumerable properties — log fields explicitly
|
|
console.error("Failed to fetch conversations:", {
|
|
message: error.message,
|
|
details: error.details,
|
|
hint: error.hint,
|
|
code: error.code,
|
|
});
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
onConversationsLoaded(data ?? []);
|
|
setLoading(false);
|
|
}, [onConversationsLoaded]);
|
|
|
|
useEffect(() => {
|
|
fetchConversations();
|
|
}, [fetchConversations]);
|
|
|
|
const filtered = useMemo(() => {
|
|
let result = conversations;
|
|
|
|
if (filter !== "all") {
|
|
result = result.filter((c) => c.status === filter);
|
|
}
|
|
|
|
if (search.trim()) {
|
|
const q = search.toLowerCase();
|
|
result = result.filter((c) => {
|
|
const name = c.contact?.name?.toLowerCase() ?? "";
|
|
const phone = c.contact?.phone?.toLowerCase() ?? "";
|
|
const lastMsg = c.last_message_text?.toLowerCase() ?? "";
|
|
return name.includes(q) || phone.includes(q) || lastMsg.includes(q);
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}, [conversations, filter, search]);
|
|
|
|
const handleSearchChange = useCallback(
|
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
setSearch(e.target.value);
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleSelect = useCallback(
|
|
(conv: Conversation) => {
|
|
onSelect(conv);
|
|
},
|
|
[onSelect]
|
|
);
|
|
|
|
const activeFilter = FILTER_OPTIONS.find((o) => o.value === filter);
|
|
|
|
return (
|
|
<div className="flex h-full w-80 flex-col border-r border-slate-800 bg-slate-900">
|
|
{/* Search + Filter */}
|
|
<div className="space-y-2 border-b border-slate-800 p-3">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
|
|
<Input
|
|
value={search}
|
|
onChange={handleSearchChange}
|
|
placeholder="Search conversations..."
|
|
className="border-slate-700 bg-slate-800 pl-9 text-sm text-white placeholder-slate-500 focus:border-emerald-500/50"
|
|
/>
|
|
</div>
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger className="inline-flex items-center justify-center h-7 gap-1 px-2 text-xs text-slate-400 hover:text-white rounded-md hover:bg-slate-800">
|
|
{activeFilter?.label ?? "All"}
|
|
<ChevronDown className="h-3 w-3" />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="start"
|
|
className="border-slate-700 bg-slate-800"
|
|
>
|
|
{FILTER_OPTIONS.map((opt) => (
|
|
<DropdownMenuItem
|
|
key={opt.value}
|
|
onClick={() => setFilter(opt.value)}
|
|
className={cn(
|
|
"text-sm",
|
|
filter === opt.value
|
|
? "text-emerald-400"
|
|
: "text-slate-300"
|
|
)}
|
|
>
|
|
{opt.label}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
|
|
{/* Conversation Items */}
|
|
<ScrollArea className="flex-1">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
|
</div>
|
|
) : filtered.length === 0 ? (
|
|
<div className="px-4 py-12 text-center">
|
|
<p className="text-sm text-slate-500">No conversations found</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col">
|
|
{filtered.map((conv) => (
|
|
<ConversationItem
|
|
key={conv.id}
|
|
conversation={conv}
|
|
isActive={conv.id === activeConversationId}
|
|
onSelect={handleSelect}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</ScrollArea>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ConversationItemProps {
|
|
conversation: Conversation;
|
|
isActive: boolean;
|
|
onSelect: (conversation: Conversation) => void;
|
|
}
|
|
|
|
function ConversationItem({
|
|
conversation,
|
|
isActive,
|
|
onSelect,
|
|
}: ConversationItemProps) {
|
|
const contact = conversation.contact;
|
|
const displayName = contact?.name || contact?.phone || "Unknown";
|
|
const initials = displayName.charAt(0).toUpperCase();
|
|
|
|
const handleClick = useCallback(() => {
|
|
onSelect(conversation);
|
|
}, [onSelect, conversation]);
|
|
|
|
const timeAgo = conversation.last_message_at
|
|
? formatDistanceToNow(new Date(conversation.last_message_at), {
|
|
addSuffix: false,
|
|
})
|
|
: "";
|
|
|
|
return (
|
|
<button
|
|
onClick={handleClick}
|
|
className={cn(
|
|
"flex w-full items-start gap-3 px-3 py-3 text-left transition-colors hover:bg-slate-800/50",
|
|
isActive && "border-l-2 border-emerald-500 bg-slate-800/70"
|
|
)}
|
|
>
|
|
{/* Avatar */}
|
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-slate-700 text-sm font-medium text-white">
|
|
{contact?.avatar_url ? (
|
|
<img
|
|
src={contact.avatar_url}
|
|
alt={displayName}
|
|
className="h-10 w-10 rounded-full object-cover"
|
|
/>
|
|
) : (
|
|
initials
|
|
)}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="truncate text-sm font-medium text-white">
|
|
{displayName}
|
|
</span>
|
|
<span className="shrink-0 text-[10px] text-slate-500">{timeAgo}</span>
|
|
</div>
|
|
<div className="mt-0.5 flex items-center justify-between gap-2">
|
|
<p className="truncate text-xs text-slate-400">
|
|
{conversation.last_message_text || "No messages yet"}
|
|
</p>
|
|
<div className="flex shrink-0 items-center gap-1.5">
|
|
{conversation.unread_count > 0 && (
|
|
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-emerald-500 px-1 text-[10px] font-bold text-white">
|
|
{conversation.unread_count}
|
|
</span>
|
|
)}
|
|
<span
|
|
className={cn(
|
|
"h-2 w-2 rounded-full",
|
|
STATUS_COLORS[conversation.status]
|
|
)}
|
|
title={conversation.status}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|