Enhance dashboard UI and add order timeline
All checks were successful
Build Frontend / build (push) Successful in 1m12s
All checks were successful
Build Frontend / build (push) Successful in 1m12s
Refactored dashboard pages for improved layout and visual consistency using Card components, motion animations, and updated color schemes. Added an OrderTimeline component to the order details page to visualize order lifecycle. Improved customer management page with better sorting, searching, and a detailed customer dialog. Updated storefront settings page with a modernized layout and clearer sectioning.
This commit is contained in:
@@ -46,41 +46,40 @@ const getFileNameFromUrl = (url: string): string => {
|
||||
// Try to extract filename from the URL path
|
||||
const pathParts = url.split('/');
|
||||
const lastPart = pathParts[pathParts.length - 1];
|
||||
|
||||
|
||||
// Remove query parameters if any
|
||||
const fileNameParts = lastPart.split('?');
|
||||
let fileName = fileNameParts[0];
|
||||
|
||||
|
||||
// If filename is too long or not found, create a generic name
|
||||
if (!fileName || fileName.length > 30) {
|
||||
return 'attachment';
|
||||
}
|
||||
|
||||
// URL decode the filename (handle spaces and special characters)
|
||||
|
||||
try {
|
||||
fileName = decodeURIComponent(fileName);
|
||||
} catch (e) {
|
||||
// If decoding fails, use the original
|
||||
}
|
||||
|
||||
|
||||
return fileName;
|
||||
};
|
||||
|
||||
// Helper function to get file icon based on extension or URL pattern
|
||||
const getFileIcon = (url: string): React.ReactNode => {
|
||||
const fileName = url.toLowerCase();
|
||||
|
||||
|
||||
// Image files
|
||||
if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(fileName) ||
|
||||
url.includes('/photos/') || url.includes('/photo/')) {
|
||||
if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(fileName) ||
|
||||
url.includes('/photos/') || url.includes('/photo/')) {
|
||||
return <ImageIcon className="h-5 w-5" />;
|
||||
}
|
||||
|
||||
|
||||
// Document files
|
||||
if (/\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|rtf|csv)($|\?)/i.test(fileName)) {
|
||||
return <FileText className="h-5 w-5" />;
|
||||
}
|
||||
|
||||
|
||||
// Default file icon
|
||||
return <File className="h-5 w-5" />;
|
||||
};
|
||||
@@ -107,7 +106,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
const { scrollToBottom, scrollToBottomInstant } = useSmoothScrollToBottom();
|
||||
useChromebookKeyboard();
|
||||
const { focusMessageInput, focusNextMessage, focusPreviousMessage } = useChatFocus();
|
||||
|
||||
|
||||
// Scroll to bottom utility functions
|
||||
const scrollToBottomHandler = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
@@ -116,40 +115,40 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const isNearBottom = () => {
|
||||
if (!messagesEndRef.current) return true;
|
||||
|
||||
|
||||
const container = messagesEndRef.current.parentElement;
|
||||
if (!container) return true;
|
||||
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
// Consider "near bottom" if within 100px of the bottom
|
||||
return scrollHeight - (scrollTop + clientHeight) < 100;
|
||||
};
|
||||
|
||||
|
||||
// Initialize audio element
|
||||
useEffect(() => {
|
||||
// Create audio element for notification sound
|
||||
audioRef.current = new Audio('/notification.mp3');
|
||||
|
||||
|
||||
// Fallback if notification.mp3 doesn't exist - use browser API for a simple beep
|
||||
audioRef.current.addEventListener('error', () => {
|
||||
audioRef.current = null;
|
||||
});
|
||||
|
||||
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current = null;
|
||||
}
|
||||
|
||||
|
||||
// Clear any pending timeouts when component unmounts
|
||||
if (markReadTimeoutRef.current) {
|
||||
clearTimeout(markReadTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
// Function to play notification sound
|
||||
const playNotificationSound = () => {
|
||||
if (audioRef.current) {
|
||||
@@ -187,7 +186,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Function to mark messages as read
|
||||
const markMessagesAsRead = async () => {
|
||||
try {
|
||||
@@ -200,7 +199,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
console.error("Error marking messages as read:", error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Loading effect
|
||||
useEffect(() => {
|
||||
if (chatId) {
|
||||
@@ -212,7 +211,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
const getAuthAxios = () => {
|
||||
const authToken = getCookie("Authorization");
|
||||
if (!authToken) return null;
|
||||
|
||||
|
||||
return axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL,
|
||||
headers: {
|
||||
@@ -221,22 +220,22 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// Fetch chat information and messages
|
||||
const fetchChatData = async () => {
|
||||
if (!chatId) return;
|
||||
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
|
||||
// Use clientFetch to load chat data
|
||||
// For now, we're only loading chat data, but this could be extended
|
||||
// to load additional data in parallel (user profiles, order details, etc.)
|
||||
const response = await clientFetch(`/chats/${chatId}`);
|
||||
|
||||
|
||||
setChatData(response);
|
||||
setChat(response); // Set chat data to maintain compatibility
|
||||
|
||||
|
||||
// Set messages with a transition effect
|
||||
// If we already have messages, append new ones to avoid jumpiness
|
||||
if (messages.length > 0) {
|
||||
@@ -244,10 +243,10 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
const newMessages = response.messages.filter(
|
||||
(msg: Message) => !existingMessageIds.has(msg._id)
|
||||
);
|
||||
|
||||
|
||||
if (newMessages.length > 0) {
|
||||
setMessages(prev => [...prev, ...newMessages]);
|
||||
|
||||
|
||||
// Mark all these messages as seen to avoid notification sounds
|
||||
newMessages.forEach((msg: Message) => {
|
||||
seenMessageIdsRef.current.add(msg._id);
|
||||
@@ -255,7 +254,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
} else {
|
||||
// If we need to replace all messages (e.g., first load or refresh)
|
||||
setMessages(Array.isArray(response.messages) ? response.messages : []);
|
||||
|
||||
|
||||
// Mark all messages as seen
|
||||
if (Array.isArray(response.messages)) {
|
||||
response.messages.forEach((msg: Message) => {
|
||||
@@ -267,20 +266,20 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
// Initial load
|
||||
const initialMessages = Array.isArray(response.messages) ? response.messages : [];
|
||||
setMessages(initialMessages);
|
||||
|
||||
|
||||
// Mark all initial messages as seen
|
||||
initialMessages.forEach((msg: Message) => {
|
||||
seenMessageIdsRef.current.add(msg._id);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Scroll to bottom on initial load
|
||||
setTimeout(() => {
|
||||
scrollToBottomHandler();
|
||||
}, 100);
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching chat data:", error);
|
||||
|
||||
|
||||
// Don't redirect on auth errors - let the middleware handle it
|
||||
// Only show error toast for non-auth errors
|
||||
if (error?.message?.includes('401') || error?.message?.includes('403')) {
|
||||
@@ -293,7 +292,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Setup polling for new messages
|
||||
useEffect(() => {
|
||||
// Set up a polling interval to check for new messages
|
||||
@@ -302,60 +301,60 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
pollNewMessages();
|
||||
}
|
||||
}, 3000); // Poll every 3 seconds
|
||||
|
||||
|
||||
return () => {
|
||||
clearInterval(pollInterval);
|
||||
};
|
||||
}, [chatId]);
|
||||
|
||||
|
||||
// Poll for new messages without replacing existing ones
|
||||
const pollNewMessages = async () => {
|
||||
if (!chatId || isPollingRef.current) return;
|
||||
|
||||
|
||||
isPollingRef.current = true;
|
||||
|
||||
|
||||
try {
|
||||
const response = await clientFetch(`/chats/${chatId}`);
|
||||
|
||||
|
||||
// Update chat metadata
|
||||
setChatData(response);
|
||||
setChat(response);
|
||||
|
||||
|
||||
// Check if there are new messages
|
||||
if (Array.isArray(response.messages) && response.messages.length > 0) {
|
||||
// Get existing message IDs to avoid duplicates
|
||||
const existingIds = new Set(messages.map(m => m._id));
|
||||
const newMessages = response.messages.filter((msg: Message) => !existingIds.has(msg._id));
|
||||
|
||||
|
||||
if (newMessages.length > 0) {
|
||||
// Add only new messages to avoid re-rendering all messages
|
||||
setMessages(prev => [...prev, ...newMessages]);
|
||||
|
||||
|
||||
// Play notification sound only for new buyer messages we haven't seen before
|
||||
const unseenBuyerMessages = newMessages.filter((msg: Message) =>
|
||||
const unseenBuyerMessages = newMessages.filter((msg: Message) =>
|
||||
msg.sender === 'buyer' && !seenMessageIdsRef.current.has(msg._id)
|
||||
);
|
||||
|
||||
|
||||
// If we have unseen buyer messages, play sound and mark them as seen
|
||||
if (unseenBuyerMessages.length > 0) {
|
||||
playNotificationSound();
|
||||
|
||||
|
||||
// Add these messages to our seen set
|
||||
unseenBuyerMessages.forEach((msg: Message) => {
|
||||
seenMessageIdsRef.current.add(msg._id);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// If near bottom, scroll to new messages
|
||||
if (isNearBottom()) {
|
||||
setTimeout(scrollToBottom, 50);
|
||||
}
|
||||
|
||||
|
||||
// Set timeout to mark new messages as read
|
||||
if (markReadTimeoutRef.current) {
|
||||
clearTimeout(markReadTimeoutRef.current);
|
||||
}
|
||||
|
||||
|
||||
markReadTimeoutRef.current = setTimeout(() => {
|
||||
markMessagesAsRead();
|
||||
}, 1000);
|
||||
@@ -363,7 +362,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error polling new messages:", error);
|
||||
|
||||
|
||||
// Silently fail on auth errors during polling - don't disrupt the user
|
||||
if (error?.message?.includes('401') || error?.message?.includes('403')) {
|
||||
console.log("Auth error during polling, stopping poll");
|
||||
@@ -411,12 +410,12 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
const focusableElements = document.querySelectorAll(
|
||||
'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
|
||||
|
||||
const currentIndex = Array.from(focusableElements).indexOf(document.activeElement as HTMLElement);
|
||||
const nextIndex = e.shiftKey
|
||||
const nextIndex = e.shiftKey
|
||||
? (currentIndex > 0 ? currentIndex - 1 : focusableElements.length - 1)
|
||||
: (currentIndex < focusableElements.length - 1 ? currentIndex + 1 : 0);
|
||||
|
||||
|
||||
focusableElements[nextIndex]?.focus();
|
||||
}
|
||||
};
|
||||
@@ -425,9 +424,9 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
const sendMessage = async (newMessage: string, file?: File | null) => {
|
||||
// Don't send empty messages
|
||||
if (!newMessage.trim() && !file) return;
|
||||
|
||||
|
||||
if (!chatId || !chatData) return;
|
||||
|
||||
|
||||
// Create a temporary message with a unique temporary ID
|
||||
const tempId = `temp-${Date.now()}`;
|
||||
const tempMessage: Message = {
|
||||
@@ -440,27 +439,27 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
buyerId: chatData.buyerId || '',
|
||||
vendorId: chatData.vendorId || ''
|
||||
};
|
||||
|
||||
|
||||
// Add the temp message ID to seen messages
|
||||
seenMessageIdsRef.current.add(tempId);
|
||||
|
||||
|
||||
// Optimistically add the temp message to the UI
|
||||
setMessages(prev => [...prev, tempMessage]);
|
||||
|
||||
|
||||
// Scroll to bottom to show the new message
|
||||
setTimeout(scrollToBottom, 50);
|
||||
|
||||
|
||||
try {
|
||||
setSending(true);
|
||||
|
||||
|
||||
let response;
|
||||
|
||||
|
||||
if (file) {
|
||||
// Use FormData for file uploads
|
||||
const formData = new FormData();
|
||||
formData.append('content', newMessage);
|
||||
formData.append('attachment', file);
|
||||
|
||||
|
||||
response = await clientFetch(`/chats/${chatId}/message`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
@@ -470,35 +469,35 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
// Use JSON for text-only messages
|
||||
response = await clientFetch(`/chats/${chatId}/message`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
content: newMessage
|
||||
body: JSON.stringify({
|
||||
content: newMessage
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Replace the temporary message with the real one from the server
|
||||
setMessages(prev => prev.map(msg =>
|
||||
setMessages(prev => prev.map(msg =>
|
||||
msg._id === tempId ? response : msg
|
||||
));
|
||||
|
||||
|
||||
// Add the real message ID to seen messages
|
||||
if (response && response._id) {
|
||||
seenMessageIdsRef.current.add(response._id);
|
||||
}
|
||||
|
||||
|
||||
// Update the textarea value to empty
|
||||
setMessage('');
|
||||
|
||||
|
||||
// Clear the file if there was one
|
||||
if (file) {
|
||||
setSelectedImage(null);
|
||||
setSelectedMessageIndex(null);
|
||||
setSelectedAttachmentIndex(null);
|
||||
}
|
||||
|
||||
|
||||
// Update the chat's last message
|
||||
if (chatData) {
|
||||
setChatData({
|
||||
@@ -506,34 +505,34 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
lastUpdated: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error);
|
||||
toast.error('Failed to send message');
|
||||
|
||||
|
||||
// Remove the temporary message if sending failed
|
||||
setMessages(prev => prev.filter(msg => msg._id !== tempId));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleBackClick = () => {
|
||||
router.push("/dashboard/chats");
|
||||
};
|
||||
|
||||
|
||||
// Add function to handle image navigation
|
||||
const handleImageNavigation = (direction: 'prev' | 'next') => {
|
||||
if (!chat || selectedMessageIndex === null || selectedAttachmentIndex === null) return;
|
||||
|
||||
// Get all images from all messages
|
||||
const allImages: { messageIndex: number; attachmentIndex: number; url: string }[] = [];
|
||||
|
||||
|
||||
chat.messages.forEach((msg, msgIndex) => {
|
||||
msg.attachments.forEach((att, attIndex) => {
|
||||
if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(att) ||
|
||||
att.includes('/photos/') ||
|
||||
att.includes('/photo/')) {
|
||||
if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(att) ||
|
||||
att.includes('/photos/') ||
|
||||
att.includes('/photo/')) {
|
||||
allImages.push({ messageIndex: msgIndex, attachmentIndex: attIndex, url: att });
|
||||
}
|
||||
});
|
||||
@@ -542,8 +541,8 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
if (allImages.length === 0) return;
|
||||
|
||||
// Find current image index
|
||||
const currentIndex = allImages.findIndex(img =>
|
||||
img.messageIndex === selectedMessageIndex &&
|
||||
const currentIndex = allImages.findIndex(img =>
|
||||
img.messageIndex === selectedMessageIndex &&
|
||||
img.attachmentIndex === selectedAttachmentIndex
|
||||
);
|
||||
|
||||
@@ -570,7 +569,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
setSelectedMessageIndex(messageIndex);
|
||||
setSelectedAttachmentIndex(attachmentIndex);
|
||||
};
|
||||
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col h-screen w-full relative">
|
||||
@@ -586,7 +585,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (!chat) {
|
||||
return (
|
||||
<div className="flex flex-col h-screen w-full relative">
|
||||
@@ -605,17 +604,17 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen w-full relative">
|
||||
<div className={cn(
|
||||
"border-b bg-card z-10 flex items-center justify-between",
|
||||
isTouchDevice ? "h-20 px-3" : "h-16 px-4"
|
||||
"border-b bg-background/80 backdrop-blur-md z-10 flex items-center justify-between sticky top-0",
|
||||
isTouchDevice ? "h-16 px-4" : "h-16 px-6"
|
||||
)}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleBackClick}
|
||||
className={cn(
|
||||
"transition-all duration-200",
|
||||
@@ -644,15 +643,15 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<BuyerOrderInfo buyerId={chat.buyerId} chatId={chatId} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={cn(
|
||||
isTouchDevice
|
||||
? "flex-1 overflow-y-auto space-y-2 p-3 pb-[calc(112px+env(safe-area-inset-bottom))]"
|
||||
isTouchDevice
|
||||
? "flex-1 overflow-y-auto space-y-2 p-3 pb-[calc(112px+env(safe-area-inset-bottom))]"
|
||||
: "flex-1 overflow-y-auto space-y-2 p-2 pb-[calc(88px+env(safe-area-inset-bottom))]"
|
||||
)}
|
||||
role="log"
|
||||
@@ -681,11 +680,10 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[90%] rounded-lg chat-message",
|
||||
isTouchDevice ? "p-4" : "p-3",
|
||||
"max-w-[85%] rounded-2xl p-4 shadow-sm",
|
||||
msg.sender === "vendor"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted"
|
||||
? "bg-primary text-primary-foreground rounded-tr-none"
|
||||
: "bg-muted text-muted-foreground rounded-tl-none border border-border/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
@@ -705,15 +703,15 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
{msg.attachments && msg.attachments.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{msg.attachments.map((attachment, attachmentIndex) => {
|
||||
const isImage = /\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(attachment) ||
|
||||
attachment.includes('/photos/') ||
|
||||
attachment.includes('/photo/');
|
||||
|
||||
const isImage = /\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(attachment) ||
|
||||
attachment.includes('/photos/') ||
|
||||
attachment.includes('/photo/');
|
||||
|
||||
const fileName = getFileNameFromUrl(attachment);
|
||||
|
||||
|
||||
return isImage ? (
|
||||
<div
|
||||
key={`attachment-${attachmentIndex}`}
|
||||
<div
|
||||
key={`attachment-${attachmentIndex}`}
|
||||
className="rounded-md overflow-hidden bg-background/20 p-1"
|
||||
onClick={() => handleImageClick(attachment, messageIndex, attachmentIndex)}
|
||||
role="button"
|
||||
@@ -731,10 +729,10 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
<ImageIcon className="h-3 w-3 mr-1" aria-hidden="true" />
|
||||
{fileName}
|
||||
</span>
|
||||
<a
|
||||
href={attachment}
|
||||
<a
|
||||
href={attachment}
|
||||
download={fileName}
|
||||
target="_blank"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs opacity-70 hover:opacity-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -773,8 +771,8 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
{fileName}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={attachment}
|
||||
<a
|
||||
href={attachment}
|
||||
download={fileName}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -794,49 +792,38 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
|
||||
<div className={cn(
|
||||
"absolute bottom-0 left-0 right-0 border-t border-border bg-background",
|
||||
isTouchDevice ? "p-3" : "p-4",
|
||||
"pb-[env(safe-area-inset-bottom)]"
|
||||
"absolute bottom-0 left-0 right-0 px-4 pt-10 bg-gradient-to-t from-background via-background/95 to-transparent",
|
||||
"pb-[calc(1.5rem+env(safe-area-inset-bottom))]"
|
||||
)}>
|
||||
<form onSubmit={handleSendMessage} className="flex space-x-2">
|
||||
<Input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Type your message..."
|
||||
disabled={sending}
|
||||
className={cn(
|
||||
"flex-1 text-base transition-all duration-200 form-input",
|
||||
isTouchDevice ? "min-h-[52px] text-lg" : "min-h-[48px]"
|
||||
)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
aria-label="Message input"
|
||||
aria-describedby="message-help"
|
||||
role="textbox"
|
||||
autoComplete="off"
|
||||
spellCheck="true"
|
||||
maxLength={2000}
|
||||
style={{
|
||||
WebkitAppearance: 'none',
|
||||
borderRadius: '0.5rem'
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
<form onSubmit={handleSendMessage} className="flex space-x-2 max-w-4xl mx-auto items-end">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Type your message..."
|
||||
disabled={sending}
|
||||
className={cn(
|
||||
"w-full pl-4 pr-12 py-3 bg-background/50 border-border/50 backdrop-blur-sm shadow-sm focus:ring-primary/20 transition-all duration-200 rounded-full",
|
||||
isTouchDevice ? "min-h-[52px] text-lg" : "min-h-[48px] text-base"
|
||||
)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
aria-label="Message input"
|
||||
role="textbox"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={sending || !message.trim()}
|
||||
aria-label={sending ? "Sending message" : "Send message"}
|
||||
className={cn(
|
||||
"transition-all duration-200 btn-chromebook",
|
||||
isTouchDevice ? "min-h-[52px] min-w-[52px]" : "min-h-[48px] min-w-[48px]"
|
||||
"rounded-full shadow-md transition-all duration-200 bg-primary hover:bg-primary/90 text-primary-foreground",
|
||||
isTouchDevice ? "h-[52px] w-[52px]" : "h-[48px] w-[48px]"
|
||||
)}
|
||||
style={{
|
||||
WebkitAppearance: 'none',
|
||||
touchAction: 'manipulation'
|
||||
}}
|
||||
>
|
||||
{sending ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
{sending ? <RefreshCw className="h-5 w-5 animate-spin" /> : <Send className="h-5 w-5 ml-0.5" />}
|
||||
</Button>
|
||||
</form>
|
||||
<div id="message-help" className="sr-only">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -10,14 +11,15 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
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 {
|
||||
Plus,
|
||||
MessageCircle,
|
||||
Loader2,
|
||||
import {
|
||||
Plus,
|
||||
MessageCircle,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Eye,
|
||||
User,
|
||||
@@ -30,7 +32,8 @@ import {
|
||||
CheckCheck,
|
||||
Search,
|
||||
Volume2,
|
||||
VolumeX
|
||||
VolumeX,
|
||||
MoreHorizontal
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
@@ -78,7 +81,7 @@ export default function ChatTable() {
|
||||
const [totalChats, setTotalChats] = useState(0);
|
||||
const [itemsPerPage, setItemsPerPage] = useState<number>(10);
|
||||
const isManualRefresh = useRef(false);
|
||||
|
||||
|
||||
// Initialize audio element for notifications
|
||||
useEffect(() => {
|
||||
audioRef.current = new Audio('/notification.mp3');
|
||||
@@ -88,7 +91,7 @@ export default function ChatTable() {
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
// Play notification sound
|
||||
const playNotificationSound = () => {
|
||||
if (audioRef.current) {
|
||||
@@ -100,30 +103,30 @@ export default function ChatTable() {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Get vendor ID from JWT token
|
||||
const getVendorIdFromToken = () => {
|
||||
const authToken = getCookie("Authorization") || "";
|
||||
|
||||
|
||||
if (!authToken) {
|
||||
throw new Error("No auth token found");
|
||||
}
|
||||
|
||||
|
||||
const tokenParts = authToken.split(".");
|
||||
if (tokenParts.length !== 3) {
|
||||
throw new Error("Invalid token format");
|
||||
}
|
||||
|
||||
|
||||
const payload = JSON.parse(atob(tokenParts[1]));
|
||||
const vendorId = payload.id;
|
||||
|
||||
|
||||
if (!vendorId) {
|
||||
throw new Error("Vendor ID not found in token");
|
||||
}
|
||||
|
||||
|
||||
return { vendorId, authToken };
|
||||
};
|
||||
|
||||
|
||||
// Fetch chats when component mounts or page/limit changes
|
||||
useEffect(() => {
|
||||
// Skip fetch if this effect was triggered by a manual refresh
|
||||
@@ -131,57 +134,57 @@ export default function ChatTable() {
|
||||
if (!isManualRefresh.current) {
|
||||
fetchChats();
|
||||
}
|
||||
|
||||
|
||||
isManualRefresh.current = false;
|
||||
|
||||
|
||||
// Set up polling for unread messages
|
||||
const interval = setInterval(() => {
|
||||
fetchUnreadCounts();
|
||||
}, 30000); // Check every 30 seconds
|
||||
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [currentPage, itemsPerPage]);
|
||||
|
||||
|
||||
// Handle refresh button click
|
||||
const handleRefresh = () => {
|
||||
isManualRefresh.current = true;
|
||||
setCurrentPage(1);
|
||||
fetchChats();
|
||||
};
|
||||
|
||||
|
||||
// Fetch unread counts
|
||||
const fetchUnreadCounts = async () => {
|
||||
try {
|
||||
// Get the vendor ID from the auth token
|
||||
const { vendorId } = getVendorIdFromToken();
|
||||
|
||||
|
||||
// Fetch unread counts for this vendor using clientFetch
|
||||
const response = await clientFetch(`/chats/vendor/${vendorId}/unread`);
|
||||
|
||||
|
||||
const newUnreadCounts = response;
|
||||
|
||||
|
||||
// Play sound if there are new messages
|
||||
if (newUnreadCounts.totalUnread > unreadCounts.totalUnread) {
|
||||
//playNotificationSound();
|
||||
}
|
||||
|
||||
|
||||
setUnreadCounts(newUnreadCounts);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch unread counts:", error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Fetch chats with pagination
|
||||
const fetchChats = async (page = currentPage, limit = itemsPerPage) => {
|
||||
setLoading(true);
|
||||
|
||||
|
||||
try {
|
||||
// Get the vendor ID from the auth token
|
||||
const { vendorId } = getVendorIdFromToken();
|
||||
|
||||
|
||||
// Use the optimized batch endpoint that fetches chats and unread counts together
|
||||
const batchResponse = await clientFetch(`/chats/vendor/${vendorId}/batch?page=${page}&limit=${limit}`);
|
||||
|
||||
|
||||
// Handle batch response (contains both chats and unread counts)
|
||||
if (Array.isArray(batchResponse)) {
|
||||
// Fallback to old API response format (backward compatibility)
|
||||
@@ -201,15 +204,15 @@ export default function ChatTable() {
|
||||
setTotalPages(batchResponse.totalPages || 1);
|
||||
setCurrentPage(batchResponse.page || 1);
|
||||
setTotalChats(batchResponse.totalChats || 0);
|
||||
|
||||
|
||||
// Handle unread counts from batch response
|
||||
const newUnreadCounts = batchResponse.unreadCounts || { totalUnread: 0, chatCounts: {} };
|
||||
|
||||
|
||||
// Play sound if there are new messages
|
||||
if (newUnreadCounts.totalUnread > unreadCounts.totalUnread) {
|
||||
//playNotificationSound();
|
||||
}
|
||||
|
||||
|
||||
setUnreadCounts(newUnreadCounts);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -220,12 +223,12 @@ export default function ChatTable() {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Navigate to chat detail page
|
||||
const handleChatClick = (chatId: string) => {
|
||||
router.push(`/dashboard/chats/${chatId}`);
|
||||
};
|
||||
|
||||
|
||||
// Create new chat
|
||||
const handleCreateChat = () => {
|
||||
router.push("/dashboard/chats/new");
|
||||
@@ -261,163 +264,213 @@ export default function ChatTable() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
<span className="ml-2">Refresh</span>
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleCreateChat} size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Chat
|
||||
</Button>
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Messages</h2>
|
||||
<p className="text-muted-foreground">Manage your customer conversations</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="h-9"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleCreateChat} size="sm" className="h-9">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">Customer</TableHead>
|
||||
<TableHead>Last Activity</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="h-24 text-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
|
||||
</TableCell>
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[300px] pl-6">Customer</TableHead>
|
||||
<TableHead>Last Activity</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right pr-6">Actions</TableHead>
|
||||
</TableRow>
|
||||
) : chats.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="h-24 text-center">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<MessageCircle className="h-8 w-8 text-muted-foreground mb-2" />
|
||||
<p className="text-muted-foreground">No chats found</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
chats.map((chat) => (
|
||||
<TableRow
|
||||
key={chat._id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleChatClick(chat._id)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Avatar>
|
||||
<AvatarFallback>
|
||||
<User className="h-4t w-4" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{chat.telegramUsername ? `@${chat.telegramUsername}` : 'Customer'}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="h-32 text-center">
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="text-sm text-muted-foreground">Loading conversations...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : chats.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="h-32 text-center">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<MessageCircle className="h-10 w-10 text-muted-foreground/50 mb-3" />
|
||||
<p className="text-muted-foreground font-medium">No chats found</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Start a new conversation to communicate with customers</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
chats.map((chat, index) => (
|
||||
<motion.tr
|
||||
key={chat._id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||
className="group cursor-pointer hover:bg-muted/30 transition-colors border-b border-border/50 last:border-0"
|
||||
onClick={() => handleChatClick(chat._id)}
|
||||
style={{ display: 'table-row' }} // Essential for table layout
|
||||
>
|
||||
<TableCell className="pl-6 py-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="relative">
|
||||
<Avatar className="h-10 w-10 border-2 border-background shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<AvatarFallback className={cn(
|
||||
"font-medium text-xs",
|
||||
unreadCounts.chatCounts[chat._id] > 0 ? "bg-primary/20 text-primary" : "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{chat.buyerId.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{unreadCounts.chatCounts[chat._id] > 0 && (
|
||||
<span className="absolute -top-1 -right-1 h-3 w-3 bg-primary rounded-full ring-2 ring-background animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-sm flex items-center gap-2">
|
||||
{chat.telegramUsername ? (
|
||||
<span className="text-blue-400">@{chat.telegramUsername}</span>
|
||||
) : (
|
||||
<span className="text-foreground">Customer {chat.buyerId.slice(0, 6)}...</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5 font-mono">
|
||||
ID: {chat.buyerId}
|
||||
</div>
|
||||
{chat.orderId && (
|
||||
<div className="text-[10px] text-muted-foreground mt-1 flex items-center gap-1 bg-muted/50 px-1.5 py-0.5 rounded w-fit">
|
||||
<span className="w-1 h-1 rounded-full bg-zinc-400" />
|
||||
Order #{chat.orderId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
ID: {chat.buyerId}
|
||||
</TableCell>
|
||||
<TableCell className="py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
{formatDistanceToNow(new Date(chat.lastUpdated), { addSuffix: true })}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(chat.lastUpdated).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{chat.orderId && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Order #{chat.orderId}
|
||||
</TableCell>
|
||||
<TableCell className="py-4">
|
||||
{unreadCounts.chatCounts[chat._id] > 0 ? (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-primary/10 text-primary text-xs font-medium border border-primary/20 shadow-[0_0_10px_rgba(var(--primary),0.1)]">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
|
||||
</span>
|
||||
{unreadCounts.chatCounts[chat._id]} new message{unreadCounts.chatCounts[chat._id] !== 1 ? 's' : ''}
|
||||
</div>
|
||||
) : (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-muted text-muted-foreground text-xs font-medium border border-border">
|
||||
<CheckCheck className="h-3 w-3" />
|
||||
All caught up
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatDistanceToNow(new Date(chat.lastUpdated), { addSuffix: true })}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{unreadCounts.chatCounts[chat._id] > 0 ? (
|
||||
<Badge variant="destructive" className="ml-1">
|
||||
{unreadCounts.chatCounts[chat._id]} new
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Read</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleChatClick(chat._id);
|
||||
}}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right pr-6 py-4">
|
||||
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleChatClick(chat._id);
|
||||
}}
|
||||
>
|
||||
<ArrowRightCircle className="h-4 w-4" />
|
||||
<span className="sr-only">View</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</motion.tr>
|
||||
))
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pagination controls */}
|
||||
{!loading && chats.length > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {chats.length} of {totalChats} chats
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-muted-foreground">Rows per page:</span>
|
||||
<Select
|
||||
value={itemsPerPage.toString()}
|
||||
onValueChange={handleItemsPerPageChange}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue placeholder={itemsPerPage.toString()} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="20">20</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{
|
||||
!loading && chats.length > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {chats.length} of {totalChats} chats
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={goToPrevPage}
|
||||
disabled={currentPage <= 1 || loading}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-sm">
|
||||
Page {currentPage} of {totalPages}
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-muted-foreground">Rows per page:</span>
|
||||
<Select
|
||||
value={itemsPerPage.toString()}
|
||||
onValueChange={handleItemsPerPageChange}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue placeholder={itemsPerPage.toString()} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="20">20</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={goToPrevPage}
|
||||
disabled={currentPage <= 1 || loading}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-sm">
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={goToNextPage}
|
||||
disabled={currentPage >= totalPages || loading}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={goToNextPage}
|
||||
disabled={currentPage >= totalPages || loading}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,20 @@
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import OrderStats from "./order-stats"
|
||||
import QuickActions from "./quick-actions"
|
||||
import RecentActivity from "./recent-activity"
|
||||
import { getGreeting } from "@/lib/utils/general"
|
||||
import { statsConfig } from "@/config/dashboard"
|
||||
import { getRandomQuote } from "@/config/quotes"
|
||||
import type { OrderStatsData } from "@/lib/types"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { ShoppingCart, RefreshCcw } from "lucide-react"
|
||||
import { ShoppingCart, RefreshCcw, ArrowRight } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { clientFetch } from "@/lib/api"
|
||||
import { motion } from "framer-motion"
|
||||
import Link from "next/link"
|
||||
|
||||
interface ContentProps {
|
||||
username: string
|
||||
@@ -33,146 +37,166 @@ export default function Content({ username, orderStats }: ContentProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Initialize with a random quote from the quotes config
|
||||
|
||||
const [randomQuote, setRandomQuote] = useState(getRandomQuote());
|
||||
|
||||
// Fetch top-selling products data
|
||||
|
||||
const fetchTopProducts = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const data = await clientFetch('/orders/top-products');
|
||||
setTopProducts(data);
|
||||
} catch (err) {
|
||||
console.error("Error fetching top products:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch top products");
|
||||
toast({
|
||||
title: "Error loading top products",
|
||||
description: "Please try refreshing the page",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize greeting and fetch data on component mount
|
||||
useEffect(() => {
|
||||
setGreeting(getGreeting());
|
||||
fetchTopProducts();
|
||||
}, []);
|
||||
|
||||
// Retry fetching top products data
|
||||
const handleRetry = () => {
|
||||
fetchTopProducts();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">
|
||||
{greeting}, {username}!
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 italic text-sm">
|
||||
"{randomQuote.text}" — <span className="font-medium">{randomQuote.author}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-10 pb-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col md:flex-row md:items-end md:justify-between gap-4"
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-foreground">
|
||||
{greeting}, <span className="text-primary">{username}</span>!
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2 max-w-2xl text-lg">
|
||||
"{randomQuote.text}" — <span className="font-medium">{randomQuote.author}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Quick ActionsSection */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Quick Actions</h2>
|
||||
<QuickActions />
|
||||
</section>
|
||||
|
||||
{/* Order Statistics */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 lg:gap-6">
|
||||
{statsConfig.map((stat) => (
|
||||
<OrderStats
|
||||
key={stat.title}
|
||||
title={stat.title}
|
||||
value={orderStats[stat.key as keyof OrderStatsData].toLocaleString()}
|
||||
icon={stat.icon}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Overview</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{statsConfig.map((stat, index) => (
|
||||
<OrderStats
|
||||
key={stat.title}
|
||||
title={stat.title}
|
||||
value={orderStats[stat.key as keyof OrderStatsData]?.toLocaleString() || "0"}
|
||||
icon={stat.icon}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Best Selling Products Section */}
|
||||
<div className="mt-8">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div>
|
||||
<CardTitle>Your Best Selling Products</CardTitle>
|
||||
<CardDescription>Products with the highest sales from your store</CardDescription>
|
||||
</div>
|
||||
{error && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRetry}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<RefreshCcw className="h-3 w-3" />
|
||||
<span>Retry</span>
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
// Loading skeleton
|
||||
<div className="space-y-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-12 w-12 rounded-md" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
|
||||
{/* Recent Activity Section */}
|
||||
<div className="xl:col-span-1">
|
||||
<RecentActivity />
|
||||
</div>
|
||||
|
||||
{/* Best Selling Products Section */}
|
||||
<div className="xl:col-span-2">
|
||||
<Card className="h-full border-border/40 bg-background/50 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div>
|
||||
<CardTitle>Top Performing Listings</CardTitle>
|
||||
<CardDescription>Your products with the highest sales volume</CardDescription>
|
||||
</div>
|
||||
{error && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRetry}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<RefreshCcw className="h-3 w-3" />
|
||||
<span>Retry</span>
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-14 w-14 rounded-xl" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
<div className="ml-auto text-right">
|
||||
<Skeleton className="h-4 w-16 ml-auto" />
|
||||
<Skeleton className="h-4 w-16 ml-auto mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
// Error state
|
||||
<div className="py-8 text-center">
|
||||
<div className="text-muted-foreground mb-4">Failed to load products</div>
|
||||
</div>
|
||||
) : topProducts.length === 0 ? (
|
||||
// Empty state
|
||||
<div className="py-8 text-center">
|
||||
<ShoppingCart className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No products sold yet</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Your best-selling products will appear here after you make some sales.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
// Data view
|
||||
<div className="space-y-4">
|
||||
{topProducts.map((product) => (
|
||||
<div key={product.id} className="flex items-center gap-4 py-2 border-b last:border-0">
|
||||
<div
|
||||
className="h-12 w-12 bg-cover bg-center rounded-md border flex-shrink-0 flex items-center justify-center overflow-hidden"
|
||||
style={{
|
||||
backgroundImage: product.image
|
||||
? `url(/api/products/${product.id}/image)`
|
||||
: 'none'
|
||||
}}
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-12 text-center">
|
||||
<div className="text-muted-foreground mb-4">Failed to load product insights</div>
|
||||
</div>
|
||||
) : topProducts.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<ShoppingCart className="h-12 w-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-lg font-medium mb-1">Begin your sales journey</h3>
|
||||
<p className="text-muted-foreground text-sm max-w-xs mx-auto">
|
||||
Your top performing listings will materialize here as you receive orders.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{topProducts.map((product, index) => (
|
||||
<motion.div
|
||||
key={product.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 + index * 0.05 }}
|
||||
className="flex items-center gap-4 p-3 rounded-xl hover:bg-muted/50 transition-colors group"
|
||||
>
|
||||
{!product.image && (
|
||||
<ShoppingCart className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-grow min-w-0">
|
||||
<h4 className="font-medium truncate">{product.name}</h4>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-medium">{product.count} sold</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div
|
||||
className="h-14 w-14 bg-muted bg-cover bg-center rounded-xl border flex-shrink-0 flex items-center justify-center overflow-hidden group-hover:scale-105 transition-transform"
|
||||
style={{
|
||||
backgroundImage: product.image
|
||||
? `url(/api/products/${product.id}/image)`
|
||||
: 'none'
|
||||
}}
|
||||
>
|
||||
{!product.image && (
|
||||
<ShoppingCart className="h-6 w-6 text-muted-foreground/50" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-grow min-w-0">
|
||||
<h4 className="font-semibold text-lg truncate group-hover:text-primary transition-colors">{product.name}</h4>
|
||||
<div className="flex items-center gap-3 mt-0.5">
|
||||
<span className="text-sm text-muted-foreground font-medium">£{product.price.toFixed(2)}</span>
|
||||
<span className="h-1 w-1 rounded-full bg-muted-foreground/30" />
|
||||
<span className="text-xs text-muted-foreground">ID: {product.id.slice(-6)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xl font-bold">{product.count}</div>
|
||||
<div className="text-xs text-muted-foreground font-medium uppercase tracking-tighter">Units Sold</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
interface OrderStatsProps {
|
||||
title: string
|
||||
value: string
|
||||
icon: LucideIcon
|
||||
index?: number
|
||||
}
|
||||
|
||||
export default function OrderStats({ title, value, icon: Icon }: OrderStatsProps) {
|
||||
export default function OrderStats({ title, value, icon: Icon, index = 0 }: OrderStatsProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
>
|
||||
<Card className="relative overflow-hidden group border-border/40 bg-background/50 backdrop-blur-sm hover:bg-background/80 transition-all duration-300">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium tracking-tight text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
{title}
|
||||
</CardTitle>
|
||||
<div className="p-2 rounded-lg bg-muted group-hover:bg-primary/10 group-hover:text-primary transition-all duration-300">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="text-3xl font-bold tracking-tight">{value}</div>
|
||||
<div className="mt-1 h-1 w-0 bg-primary/20 group-hover:w-full transition-all duration-500 rounded-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
75
components/dashboard/quick-actions.tsx
Normal file
75
components/dashboard/quick-actions.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { motion } from "framer-motion"
|
||||
import {
|
||||
PlusCircle,
|
||||
Package,
|
||||
BarChart3,
|
||||
Settings,
|
||||
MessageSquare,
|
||||
Truck,
|
||||
Tag,
|
||||
Users
|
||||
} from "lucide-react"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
|
||||
const actions = [
|
||||
{
|
||||
title: "Add Product",
|
||||
icon: PlusCircle,
|
||||
href: "/dashboard/products/new",
|
||||
color: "bg-blue-500/10 text-blue-500",
|
||||
description: "Create a new listing"
|
||||
},
|
||||
{
|
||||
title: "Process Orders",
|
||||
icon: Truck,
|
||||
href: "/dashboard/orders?status=paid",
|
||||
color: "bg-emerald-500/10 text-emerald-500",
|
||||
description: "Ship pending orders"
|
||||
},
|
||||
{
|
||||
title: "Analytics",
|
||||
icon: BarChart3,
|
||||
href: "/dashboard/analytics",
|
||||
color: "bg-purple-500/10 text-purple-500",
|
||||
description: "View sales performance"
|
||||
},
|
||||
{
|
||||
title: "Messages",
|
||||
icon: MessageSquare,
|
||||
href: "/dashboard/chats",
|
||||
color: "bg-amber-500/10 text-amber-500",
|
||||
description: "Chat with customers"
|
||||
}
|
||||
]
|
||||
|
||||
export default function QuickActions() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{actions.map((action, index) => (
|
||||
<motion.div
|
||||
key={action.title}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Link href={action.href}>
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer group h-full">
|
||||
<CardContent className="p-6 flex flex-col items-center text-center">
|
||||
<div className={`p-3 rounded-xl ${action.color} mb-4 group-hover:scale-110 transition-transform`}>
|
||||
<action.icon className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg">{action.title}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">{action.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
119
components/dashboard/recent-activity.tsx
Normal file
119
components/dashboard/recent-activity.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { ShoppingBag, CreditCard, Truck, MessageSquare, AlertCircle } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { clientFetch } from "@/lib/api"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import Link from "next/link"
|
||||
|
||||
interface ActivityItem {
|
||||
_id: string;
|
||||
orderId: string;
|
||||
status: string;
|
||||
totalPrice: number;
|
||||
orderDate: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export default function RecentActivity() {
|
||||
const [activities, setActivities] = useState<ActivityItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchRecentOrders() {
|
||||
try {
|
||||
const data = await clientFetch("/orders?limit=5&sortBy=orderDate&sortOrder=desc");
|
||||
setActivities(data.orders || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch recent activity:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchRecentOrders();
|
||||
}, []);
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "paid": return <CreditCard className="h-4 w-4" />;
|
||||
case "shipped": return <Truck className="h-4 w-4" />;
|
||||
case "unpaid": return <ShoppingBag className="h-4 w-4" />;
|
||||
default: return <AlertCircle className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "paid": return "bg-emerald-500/10 text-emerald-500";
|
||||
case "shipped": return "bg-blue-500/10 text-blue-500";
|
||||
case "unpaid": return "bg-amber-500/10 text-amber-500";
|
||||
default: return "bg-gray-500/10 text-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
<CardDescription>Latest updates from your store</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : activities.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
No recent activity
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{activities.map((item, index) => (
|
||||
<motion.div
|
||||
key={item._id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="flex items-start gap-4 relative"
|
||||
>
|
||||
{index !== activities.length - 1 && (
|
||||
<div className="absolute left-[15px] top-8 bottom-[-24px] w-[2px] bg-border/50" />
|
||||
)}
|
||||
<div className={`mt-1 p-2 rounded-full z-10 ${getStatusColor(item.status)}`}>
|
||||
{getStatusIcon(item.status)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href={`/dashboard/orders/${item._id}`} className="font-medium hover:underline">
|
||||
Order #{item.orderId}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(item.orderDate), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.status === "paid" ? "Payment received" :
|
||||
item.status === "shipped" ? "Order marked as shipped" :
|
||||
`Order status: ${item.status}`} for £{item.totalPrice.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user