This commit is contained in:
NotII
2025-03-03 20:24:26 +00:00
parent de6ec3bfaf
commit f5c7994bf7
11 changed files with 827 additions and 110 deletions

View File

@@ -0,0 +1,204 @@
import React, { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import { formatDistanceToNow } from "date-fns";
import axios from "axios";
import { toast } from "sonner";
import { ArrowLeft, Send, RefreshCw } from "lucide-react";
interface Message {
_id: string;
sender: "buyer" | "vendor";
content: string;
attachments: string[];
read: boolean;
createdAt: string;
buyerId: string;
vendorId: string;
}
interface Chat {
_id: string;
buyerId: string;
vendorId: string;
storeId: string;
messages: Message[];
lastUpdated: string;
orderId?: string;
}
export default function ChatDetail({ chatId }: { chatId: string }) {
const router = useRouter();
const [chat, setChat] = useState<Chat | null>(null);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState("");
const [sending, setSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Fetch chat data
const fetchChat = async () => {
try {
const response = await axios.get(`/api/chats/${chatId}`);
setChat(response.data);
setLoading(false);
} catch (error) {
console.error("Error fetching chat:", error);
toast.error("Failed to load conversation");
setLoading(false);
}
};
useEffect(() => {
fetchChat();
// Poll for updates every 10 seconds
const intervalId = setInterval(fetchChat, 10000);
return () => clearInterval(intervalId);
}, [chatId]);
// Scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [chat?.messages]);
// Send a message
const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim()) return;
setSending(true);
try {
await axios.post(`/api/chats/${chatId}/message`, {
content: message
});
setMessage("");
await fetchChat(); // Refresh chat after sending
} catch (error) {
console.error("Error sending message:", error);
toast.error("Failed to send message");
} finally {
setSending(false);
}
};
const handleBackClick = () => {
router.push("/dashboard/chats");
};
if (loading) {
return (
<Card className="w-full h-[80vh] flex flex-col">
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Button variant="ghost" size="icon" onClick={handleBackClick}>
<ArrowLeft className="h-5 w-5" />
</Button>
<span>Loading conversation...</span>
</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex items-center justify-center">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</CardContent>
</Card>
);
}
if (!chat) {
return (
<Card className="w-full h-[80vh] flex flex-col">
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Button variant="ghost" size="icon" onClick={handleBackClick}>
<ArrowLeft className="h-5 w-5" />
</Button>
<span>Chat not found</span>
</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground mb-4">This conversation doesn't exist or you don't have access to it.</p>
<Button onClick={handleBackClick}>Back to Chats</Button>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="w-full h-[80vh] flex flex-col">
<CardHeader className="border-b">
<CardTitle className="flex items-center space-x-2">
<Button variant="ghost" size="icon" onClick={handleBackClick}>
<ArrowLeft className="h-5 w-5" />
</Button>
<span>Chat with Customer {chat.buyerId.slice(-4)}</span>
</CardTitle>
</CardHeader>
<CardContent className="flex-1 overflow-y-auto p-4 space-y-4">
{chat.messages.length === 0 ? (
<div className="h-full flex items-center justify-center">
<p className="text-muted-foreground">No messages yet. Send one to start the conversation.</p>
</div>
) : (
chat.messages.map((msg, index) => (
<div
key={msg._id || index}
className={cn(
"flex",
msg.sender === "vendor" ? "justify-end" : "justify-start"
)}
>
<div
className={cn(
"max-w-[80%] rounded-lg p-3",
msg.sender === "vendor"
? "bg-primary text-primary-foreground"
: "bg-muted"
)}
>
<div className="flex items-center space-x-2 mb-1">
{msg.sender === "buyer" && (
<Avatar className="h-6 w-6">
<AvatarFallback className="text-xs">
{chat.buyerId.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
)}
<span className="text-xs opacity-70">
{formatDistanceToNow(new Date(msg.createdAt), { addSuffix: true })}
</span>
</div>
<p className="whitespace-pre-wrap break-words">{msg.content}</p>
{/* Show attachments if any (future enhancement) */}
</div>
</div>
))
)}
<div ref={messagesEndRef} />
</CardContent>
<div className="p-4 border-t">
<form onSubmit={handleSendMessage} className="flex space-x-2">
<Input
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..."
disabled={sending}
className="flex-1"
/>
<Button type="submit" disabled={sending || !message.trim()}>
{sending ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
</form>
</div>
</Card>
);
}

View File

@@ -0,0 +1,204 @@
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { formatDistanceToNow } from "date-fns";
import axios from "axios";
import { toast } from "sonner";
interface Chat {
_id: string;
buyerId: string;
vendorId: string;
storeId: string;
lastUpdated: string;
orderId?: string;
}
interface UnreadCounts {
totalUnread: number;
chatCounts: Record<string, number>;
}
export default function ChatList() {
const router = useRouter();
const [chats, setChats] = useState<Chat[]>([]);
const [loading, setLoading] = useState(true);
const [unreadCounts, setUnreadCounts] = useState<UnreadCounts>({ totalUnread: 0, chatCounts: {} });
const [selectedStore, setSelectedStore] = useState<string>("");
const [vendorStores, setVendorStores] = useState<{ _id: string, name: string }[]>([]);
// Fetch vendor ID and stores
useEffect(() => {
const fetchVendorData = async () => {
try {
// Get vendor info from session storage or context
const vendorId = sessionStorage.getItem("vendorId");
if (!vendorId) {
toast.error("You need to be logged in to view chats");
router.push("/login");
return;
}
// Fetch vendor's stores
const storesResponse = await axios.get(`/api/stores/vendor/${vendorId}`);
setVendorStores(storesResponse.data);
if (storesResponse.data.length > 0) {
setSelectedStore(storesResponse.data[0]._id);
}
} catch (error) {
console.error("Error fetching vendor data:", error);
toast.error("Failed to load vendor data");
}
};
fetchVendorData();
}, [router]);
// Fetch chats and unread counts when store is selected
useEffect(() => {
const fetchChats = async () => {
if (!selectedStore) return;
setLoading(true);
try {
const vendorId = sessionStorage.getItem("vendorId");
// Fetch chats
const chatsResponse = await axios.get(`/api/chats/vendor/${vendorId}`);
// Filter chats by selected store
const filteredChats = chatsResponse.data.filter(
(chat: Chat) => chat.storeId === selectedStore
);
setChats(filteredChats);
// Fetch unread counts
const unreadResponse = await axios.get(`/api/chats/vendor/${vendorId}/unread`);
setUnreadCounts(unreadResponse.data);
} catch (error) {
console.error("Error fetching chats:", error);
toast.error("Failed to load chats");
} finally {
setLoading(false);
}
};
fetchChats();
// Set up polling for updates every 30 seconds
const intervalId = setInterval(fetchChats, 30000);
return () => clearInterval(intervalId);
}, [selectedStore]);
// Handle chat selection
const handleChatClick = (chatId: string) => {
router.push(`/dashboard/chats/${chatId}`);
};
// Handle store change
const handleStoreChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setSelectedStore(e.target.value);
};
// Create a new chat
const handleCreateChat = () => {
router.push("/dashboard/chats/new");
};
if (loading) {
return (
<Card className="w-full">
<CardHeader>
<CardTitle className="flex justify-between items-center">
<span>Loading chats...</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="animate-pulse space-y-4">
{[1, 2, 3].map((n) => (
<div key={n} className="h-16 bg-muted rounded-md"></div>
))}
</div>
</CardContent>
</Card>
);
}
return (
<Card className="w-full">
<CardHeader>
<CardTitle className="flex justify-between items-center">
<span>Customer Chats</span>
<Button onClick={handleCreateChat}>New Chat</Button>
</CardTitle>
<div className="flex items-center space-x-2">
<label htmlFor="store-select" className="text-sm font-medium">
Store:
</label>
<select
id="store-select"
value={selectedStore}
onChange={handleStoreChange}
className="rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{vendorStores.map((store) => (
<option key={store._id} value={store._id}>
{store.name}
</option>
))}
</select>
</div>
</CardHeader>
<CardContent>
{chats.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p>No conversations yet</p>
<Button
variant="outline"
className="mt-4"
onClick={handleCreateChat}
>
Start a new conversation
</Button>
</div>
) : (
<div className="space-y-4">
{chats.map((chat) => (
<div
key={chat._id}
className="flex items-center justify-between p-4 rounded-lg border cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => handleChatClick(chat._id)}
>
<div className="flex items-center space-x-4">
<Avatar>
<AvatarFallback>
{chat.buyerId.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<h4 className="font-medium">Customer {chat.buyerId.slice(-4)}</h4>
<p className="text-sm text-muted-foreground">
{formatDistanceToNow(new Date(chat.lastUpdated), { addSuffix: true })}
</p>
</div>
</div>
{unreadCounts.chatCounts[chat._id] > 0 && (
<Badge variant="destructive">
{unreadCounts.chatCounts[chat._id]} unread
</Badge>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,138 @@
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Bell } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import axios from "axios";
interface UnreadCounts {
totalUnread: number;
chatCounts: Record<string, number>;
}
export default function ChatNotifications() {
const router = useRouter();
const [unreadCounts, setUnreadCounts] = useState<UnreadCounts>({ totalUnread: 0, chatCounts: {} });
const [loading, setLoading] = useState(true);
const [chatMetadata, setChatMetadata] = useState<Record<string, { buyerId: string }>>({});
// Fetch unread counts
useEffect(() => {
const fetchUnreadCounts = async () => {
try {
const vendorId = sessionStorage.getItem("vendorId");
if (!vendorId) return;
const response = await axios.get(`/api/chats/vendor/${vendorId}/unread`);
setUnreadCounts(response.data);
// If there are unread messages, fetch chat metadata
if (response.data.totalUnread > 0) {
const chatIds = Object.keys(response.data.chatCounts);
if (chatIds.length > 0) {
// Create a simplified metadata object with just needed info
const metadata: Record<string, { buyerId: string }> = {};
// Fetch each chat to get buyer IDs
await Promise.all(
chatIds.map(async (chatId) => {
try {
const chatResponse = await axios.get(`/api/chats/${chatId}`);
metadata[chatId] = {
buyerId: chatResponse.data.buyerId,
};
} catch (error) {
console.error(`Error fetching chat ${chatId}:`, error);
}
})
);
setChatMetadata(metadata);
}
}
} catch (error) {
console.error("Error fetching unread counts:", error);
} finally {
setLoading(false);
}
};
fetchUnreadCounts();
// Set polling interval (every 30 seconds)
const intervalId = setInterval(fetchUnreadCounts, 30000);
return () => clearInterval(intervalId);
}, []);
const handleChatClick = (chatId: string) => {
router.push(`/dashboard/chats/${chatId}`);
};
if (loading || unreadCounts.totalUnread === 0) {
return (
<Button variant="ghost" size="icon" className="relative" disabled={loading}>
<Bell className="h-5 w-5" />
</Button>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
<Badge
variant="destructive"
className="absolute -top-1 -right-1 px-1.5 py-0.5 text-xs"
>
{unreadCounts.totalUnread}
</Badge>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
<div className="p-2 border-b">
<h3 className="font-medium">Unread Messages</h3>
</div>
<div className="max-h-80 overflow-y-auto">
{Object.entries(unreadCounts.chatCounts).map(([chatId, count]) => (
<DropdownMenuItem
key={chatId}
className="p-3 cursor-pointer"
onClick={() => handleChatClick(chatId)}
>
<div className="flex items-center justify-between w-full">
<div>
<p className="font-medium">
Customer {chatMetadata[chatId]?.buyerId.slice(-4) || 'Unknown'}
</p>
<p className="text-sm text-muted-foreground">
{count} new {count === 1 ? 'message' : 'messages'}
</p>
</div>
<Badge variant="destructive">{count}</Badge>
</div>
</DropdownMenuItem>
))}
</div>
<div className="p-2 border-t">
<Button
variant="outline"
className="w-full"
onClick={() => router.push('/dashboard/chats')}
>
View All Chats
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,175 @@
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { ArrowLeft, Send, RefreshCw } from "lucide-react";
import axios from "axios";
import { toast } from "sonner";
export default function NewChatForm() {
const router = useRouter();
const [buyerId, setBuyerId] = useState("");
const [initialMessage, setInitialMessage] = useState("");
const [loading, setLoading] = useState(false);
const [vendorStores, setVendorStores] = useState<{ _id: string, name: string }[]>([]);
const [selectedStore, setSelectedStore] = useState<string>("");
// Fetch vendor stores
useEffect(() => {
const fetchVendorStores = async () => {
try {
const vendorId = sessionStorage.getItem("vendorId");
if (!vendorId) {
toast.error("You need to be logged in");
router.push("/login");
return;
}
const response = await axios.get(`/api/stores/vendor/${vendorId}`);
setVendorStores(response.data);
if (response.data.length > 0) {
setSelectedStore(response.data[0]._id);
}
} catch (error) {
console.error("Error fetching stores:", error);
toast.error("Failed to load stores");
}
};
fetchVendorStores();
}, [router]);
const handleBackClick = () => {
router.push("/dashboard/chats");
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!buyerId || !selectedStore) {
toast.error("Please fill all required fields");
return;
}
setLoading(true);
try {
const response = await axios.post("/api/chats/create", {
buyerId,
storeId: selectedStore,
initialMessage
});
if (response.data.chatId) {
toast.success("Chat created successfully!");
router.push(`/dashboard/chats/${response.data.chatId}`);
} else if (response.data.error === "Chat already exists") {
toast.info("Chat already exists, redirecting...");
router.push(`/dashboard/chats/${response.data.chatId}`);
}
} catch (error: any) {
console.error("Error creating chat:", error);
if (error.response?.status === 409) {
// Chat already exists
toast.info("Chat already exists, redirecting...");
router.push(`/dashboard/chats/${error.response.data.chatId}`);
} else {
toast.error("Failed to create chat");
}
} finally {
setLoading(false);
}
};
return (
<Card className="w-full">
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Button variant="ghost" size="icon" onClick={handleBackClick}>
<ArrowLeft className="h-5 w-5" />
</Button>
<span>Start a New Conversation</span>
</CardTitle>
<CardDescription>
Start a new conversation with a customer by their Telegram ID
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="buyerId">Customer Telegram ID</Label>
<Input
id="buyerId"
value={buyerId}
onChange={(e) => setBuyerId(e.target.value)}
placeholder="e.g. 123456789"
required
/>
<p className="text-sm text-muted-foreground">
This is the customer's Telegram ID. You can ask them to use the /myid command in your Telegram bot.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="store">Store</Label>
<select
id="store"
value={selectedStore}
onChange={(e) => setSelectedStore(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2"
required
>
<option value="" disabled>Select a store</option>
{vendorStores.map((store) => (
<option key={store._id} value={store._id}>
{store.name}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="initialMessage">Initial Message (Optional)</Label>
<Textarea
id="initialMessage"
value={initialMessage}
onChange={(e) => setInitialMessage(e.target.value)}
placeholder="Hello! How can I help you today?"
rows={4}
/>
</div>
<div className="flex justify-end space-x-2">
<Button
type="button"
variant="outline"
onClick={handleBackClick}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading || !buyerId || !selectedStore}>
{loading ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
<>
<Send className="mr-2 h-4 w-4" />
Start Conversation
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}