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">
|
||||
|
||||
Reference in New Issue
Block a user