diff --git a/app/dashboard/chats/[id]/page.tsx b/app/dashboard/chats/[id]/page.tsx new file mode 100644 index 0000000..b88555c --- /dev/null +++ b/app/dashboard/chats/[id]/page.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { Metadata } from "next"; +import ChatDetail from "@/components/dashboard/ChatDetail"; + +export const metadata: Metadata = { + title: "Chat Conversation", + description: "View and respond to customer messages", +}; + +export default function ChatDetailPage({ params }: { params: { id: string } }) { + return ( +
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/app/dashboard/chats/new/page.tsx b/app/dashboard/chats/new/page.tsx new file mode 100644 index 0000000..46a0e40 --- /dev/null +++ b/app/dashboard/chats/new/page.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { Metadata } from "next"; +import NewChatForm from "@/components/dashboard/NewChatForm"; + +export const metadata: Metadata = { + title: "Start New Chat", + description: "Begin a new conversation with a customer", +}; + +export default function NewChatPage() { + return ( +
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/app/dashboard/chats/page.tsx b/app/dashboard/chats/page.tsx new file mode 100644 index 0000000..e2fdfe4 --- /dev/null +++ b/app/dashboard/chats/page.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { Metadata } from "next"; +import ChatList from "@/components/dashboard/ChatList"; + +export const metadata: Metadata = { + title: "Customer Chats", + description: "Manage conversations with your customers", +}; + +export default function ChatsPage() { + return ( +
+
+

Customer Chats

+
+ +
+ +
+
+ ); +} \ No newline at end of file diff --git a/components/dashboard/ChatDetail.tsx b/components/dashboard/ChatDetail.tsx new file mode 100644 index 0000000..8fcb60c --- /dev/null +++ b/components/dashboard/ChatDetail.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [message, setMessage] = useState(""); + const [sending, setSending] = useState(false); + const messagesEndRef = useRef(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 ( + + + + + Loading conversation... + + + + + + + ); + } + + if (!chat) { + return ( + + + + + Chat not found + + + +
+

This conversation doesn't exist or you don't have access to it.

+ +
+
+
+ ); + } + + return ( + + + + + Chat with Customer {chat.buyerId.slice(-4)} + + + + + {chat.messages.length === 0 ? ( +
+

No messages yet. Send one to start the conversation.

+
+ ) : ( + chat.messages.map((msg, index) => ( +
+
+
+ {msg.sender === "buyer" && ( + + + {chat.buyerId.slice(0, 2).toUpperCase()} + + + )} + + {formatDistanceToNow(new Date(msg.createdAt), { addSuffix: true })} + +
+

{msg.content}

+ {/* Show attachments if any (future enhancement) */} +
+
+ )) + )} +
+ + +
+
+ setMessage(e.target.value)} + placeholder="Type your message..." + disabled={sending} + className="flex-1" + /> + +
+
+ + ); +} \ No newline at end of file diff --git a/components/dashboard/ChatList.tsx b/components/dashboard/ChatList.tsx new file mode 100644 index 0000000..fa6687f --- /dev/null +++ b/components/dashboard/ChatList.tsx @@ -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; +} + +export default function ChatList() { + const router = useRouter(); + const [chats, setChats] = useState([]); + const [loading, setLoading] = useState(true); + const [unreadCounts, setUnreadCounts] = useState({ totalUnread: 0, chatCounts: {} }); + const [selectedStore, setSelectedStore] = useState(""); + 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) => { + setSelectedStore(e.target.value); + }; + + // Create a new chat + const handleCreateChat = () => { + router.push("/dashboard/chats/new"); + }; + + if (loading) { + return ( + + + + Loading chats... + + + +
+ {[1, 2, 3].map((n) => ( +
+ ))} +
+
+
+ ); + } + + return ( + + + + Customer Chats + + +
+ + +
+
+ + {chats.length === 0 ? ( +
+

No conversations yet

+ +
+ ) : ( +
+ {chats.map((chat) => ( +
handleChatClick(chat._id)} + > +
+ + + {chat.buyerId.slice(0, 2).toUpperCase()} + + +
+

Customer {chat.buyerId.slice(-4)}

+

+ {formatDistanceToNow(new Date(chat.lastUpdated), { addSuffix: true })} +

+
+
+ {unreadCounts.chatCounts[chat._id] > 0 && ( + + {unreadCounts.chatCounts[chat._id]} unread + + )} +
+ ))} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/components/dashboard/ChatNotifications.tsx b/components/dashboard/ChatNotifications.tsx new file mode 100644 index 0000000..46a2874 --- /dev/null +++ b/components/dashboard/ChatNotifications.tsx @@ -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; +} + +export default function ChatNotifications() { + const router = useRouter(); + const [unreadCounts, setUnreadCounts] = useState({ totalUnread: 0, chatCounts: {} }); + const [loading, setLoading] = useState(true); + const [chatMetadata, setChatMetadata] = useState>({}); + + // 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 = {}; + + // 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 ( + + ); + } + + return ( + + + + + +
+

Unread Messages

+
+
+ {Object.entries(unreadCounts.chatCounts).map(([chatId, count]) => ( + handleChatClick(chatId)} + > +
+
+

+ Customer {chatMetadata[chatId]?.buyerId.slice(-4) || 'Unknown'} +

+

+ {count} new {count === 1 ? 'message' : 'messages'} +

+
+ {count} +
+
+ ))} +
+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/components/dashboard/NewChatForm.tsx b/components/dashboard/NewChatForm.tsx new file mode 100644 index 0000000..6d76812 --- /dev/null +++ b/components/dashboard/NewChatForm.tsx @@ -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(""); + + // 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 ( + + + + + Start a New Conversation + + + Start a new conversation with a customer by their Telegram ID + + + + +
+
+ + setBuyerId(e.target.value)} + placeholder="e.g. 123456789" + required + /> +

+ This is the customer's Telegram ID. You can ask them to use the /myid command in your Telegram bot. +

+
+ +
+ + +
+ +
+ +