Enhance dashboard UI and add order timeline
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:
g
2026-01-12 06:53:28 +00:00
parent 7b95589867
commit 211cdc71f9
12 changed files with 1793 additions and 1331 deletions

View File

@@ -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">