feat: add system monitor component and API for performance metrics
feat: implement conversations API with message retrieval and posting feat: add avatar URL handling for users and update user role definitions feat: create chat system tables in the database with initial seed data fix: update user roles from "sales_user" to "sales" for consistency chore: add middleware for system API route access fixed fuckups on john's side, added a benchmark Made Graphs actualy load from database and realtime data, graphs will update and percentages will be mathematically made, and made realtime added chatcart edits, made graphs glow. added in some little small home feeling fuctions added in a slide show, in login with qoutes from creators because i want to leave a print on it saying we made this shit, we built it brick for brick login page added qoutes some random, and made them shuffle from left to right one dissapears other come in adding shape to the textbox for the qoutes Fixed my chat fuckups when it comes to chats
This commit is contained in:
+262
-145
@@ -14,7 +14,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { conversations as conversationsData } from "@/data/chats"
|
||||
import {
|
||||
Search, Send, Phone, Video, MoreHorizontal, Paperclip,
|
||||
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
|
||||
@@ -94,7 +93,7 @@ function VoiceMessagePlayer({ src, initialDuration }: { src: string; initialDura
|
||||
export default function ChatsPage() {
|
||||
const { theme } = useTheme()
|
||||
const { user } = useUser()
|
||||
const [activeChat, setActiveChat] = useState(conversationsData[0]?.id ?? null)
|
||||
const [activeChat, setActiveChat] = useState<string | null>(null)
|
||||
const [messageInput, setMessageInput] = useState("")
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||
const [attachments, setAttachments] = useState<File[]>([])
|
||||
@@ -103,7 +102,7 @@ export default function ChatsPage() {
|
||||
const [reportDialogOpen, setReportDialogOpen] = useState(false)
|
||||
const [reportReason, setReportReason] = useState("")
|
||||
const [forwardDialogOpen, setForwardDialogOpen] = useState(false)
|
||||
const [forwardMessage, setForwardMessage] = useState<typeof conversationsData[0]["messages"][0] | null>(null)
|
||||
const [forwardMessage, setForwardMessage] = useState<any>(null)
|
||||
const [forwardSearch, setForwardSearch] = useState("")
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
@@ -112,18 +111,17 @@ export default function ChatsPage() {
|
||||
const recordingDurationRef = useRef(0)
|
||||
const [voiceMessages, setVoiceMessages] = useState<Map<string, { url: string; duration: number }>>(new Map())
|
||||
const [messageAttachments, setMessageAttachments] = useState<Map<string, { name: string; type: string; url: string }[]>>(new Map())
|
||||
const [replyingTo, setReplyingTo] = useState<typeof conversationsData[0]["messages"][0] | null>(null)
|
||||
const [editingMessage, setEditingMessage] = useState<typeof conversationsData[0]["messages"][0] | null>(null)
|
||||
const [replyingTo, setReplyingTo] = useState<any>(null)
|
||||
const [editingMessage, setEditingMessage] = useState<any>(null)
|
||||
const [replyMap, setReplyMap] = useState<Map<string, { repliedToSender: string; repliedToContent: string }>>(new Map())
|
||||
const [messageStatus, setMessageStatus] = useState<Map<string, "sent" | "delivered" | "read">>(() => {
|
||||
const map = new Map<string, "sent" | "delivered" | "read">()
|
||||
conversationsData.forEach((conv) =>
|
||||
conv.messages.forEach((msg) => {
|
||||
if (msg.senderId === "user1") map.set(msg.id, "read")
|
||||
})
|
||||
)
|
||||
return map
|
||||
})
|
||||
// message status tracking removed — now using msg.read from API
|
||||
const [conversations, setConversations] = useState<any[]>([])
|
||||
const [conversationMessages, setConversationMessages] = useState<Map<string, any[]>>(new Map())
|
||||
const [loadingChats, setLoadingChats] = useState(true)
|
||||
const [searchResults, setSearchResults] = useState<any[]>([])
|
||||
const [searchingUsers, setSearchingUsers] = useState(false)
|
||||
const [unreadMap, setUnreadMap] = useState<Map<string, number>>(new Map())
|
||||
const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const emojiPickerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -141,15 +139,79 @@ export default function ChatsPage() {
|
||||
ta.style.height = `${ta.scrollHeight}px`
|
||||
}, [])
|
||||
|
||||
const [conversations, setConversations] = useState(conversationsData)
|
||||
if (!user) return null
|
||||
|
||||
const messages = conversationMessages.get(activeChat || "") || []
|
||||
const conversation = conversations.find((c) => c.id === activeChat)
|
||||
const otherParticipant = (conv: typeof conversationsData[0]) =>
|
||||
conv.participants.find((p) => p.id !== "user1") ?? conv.participants[0]
|
||||
const otherParticipant = (conv: any) => conv.otherUser
|
||||
const filteredConversations = searchQuery.trim()
|
||||
? conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
: conversations
|
||||
|
||||
// Fetch conversations from API
|
||||
useEffect(() => {
|
||||
const fetchConvs = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/conversations")
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const convs = data.conversations || []
|
||||
setConversations(convs)
|
||||
setUnreadMap(new Map(convs.map((c: any) => [c.id, c.unread])))
|
||||
if (convs.length > 0) setActiveChat(convs[0].id)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingChats(false)
|
||||
}
|
||||
}
|
||||
fetchConvs()
|
||||
}, [])
|
||||
|
||||
// Fetch messages when active chat changes, and mark as read
|
||||
useEffect(() => {
|
||||
if (!activeChat) return
|
||||
const fetchMsgs = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/conversations/${activeChat}/messages`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setConversationMessages((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(activeChat, data.messages || [])
|
||||
return next
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// Mark conversation as read
|
||||
fetch(`/api/conversations/${activeChat}/read`, { method: "POST" }).catch(() => {})
|
||||
setUnreadMap((prev) => { const m = new Map(prev); m.set(activeChat, 0); return m })
|
||||
}
|
||||
fetchMsgs()
|
||||
}, [activeChat])
|
||||
|
||||
// Search users when search query changes
|
||||
useEffect(() => {
|
||||
const q = searchQuery.trim()
|
||||
if (!q) { setSearchResults([]); return }
|
||||
const timer = setTimeout(async () => {
|
||||
setSearchingUsers(true)
|
||||
try {
|
||||
const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const existingIds = new Set(conversations.map((c) => otherParticipant(c).id))
|
||||
setSearchResults(data.users.filter((u: any) => !existingIds.has(u.id)))
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setSearchingUsers(false)
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchQuery, conversations])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (emojiPickerRef.current && !emojiPickerRef.current.contains(e.target as Node)) {
|
||||
@@ -202,79 +264,77 @@ export default function ChatsPage() {
|
||||
setAttachments((prev) => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const addMessageToChat = (content: string, voice?: { url: string; duration: number }, replyTo?: { senderName: string; content: string }, attachments?: { name: string; type: string; url: string }[]) => {
|
||||
const id = crypto.randomUUID()
|
||||
const newMessage = {
|
||||
id,
|
||||
conversationId: activeChat!,
|
||||
senderId: "user1",
|
||||
senderName: "Sarah Chen",
|
||||
senderAvatar: "SC",
|
||||
content,
|
||||
timestamp: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||
}
|
||||
setConversations((prev) =>
|
||||
prev.map((conv) =>
|
||||
conv.id === activeChat
|
||||
? {
|
||||
...conv,
|
||||
messages: [...conv.messages, newMessage],
|
||||
lastMessage: voice ? "🎤 Voice note" : (replyTo ? `↪ ${content}` : content),
|
||||
lastMessageTime: "Just now",
|
||||
unread: 0,
|
||||
}
|
||||
: conv
|
||||
)
|
||||
)
|
||||
setMessageStatus((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(id, "sent")
|
||||
return next
|
||||
})
|
||||
setTimeout(() => {
|
||||
setMessageStatus((prev) => {
|
||||
const addMessageToChat = async (content: string, voice?: { url: string; duration: number }, replyTo?: { senderName: string; content: string }, fileAttachments?: { name: string; type: string; url: string }[]) => {
|
||||
if (!activeChat || !user) return
|
||||
const payload = voice ? "[Voice message]" : content
|
||||
try {
|
||||
const res = await fetch(`/api/conversations/${activeChat}/messages`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: payload }),
|
||||
})
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
const msg = data.message
|
||||
setConversationMessages((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (next.get(id) === "sent") next.set(id, "delivered")
|
||||
const existing = next.get(activeChat) || []
|
||||
next.set(activeChat, [...existing, msg])
|
||||
return next
|
||||
})
|
||||
}, 1000)
|
||||
if (voice) {
|
||||
setVoiceMessages((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(id, voice)
|
||||
return next
|
||||
})
|
||||
}
|
||||
if (replyTo) {
|
||||
setReplyMap((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(id, { repliedToSender: replyTo.senderName, repliedToContent: replyTo.content })
|
||||
return next
|
||||
})
|
||||
}
|
||||
if (attachments && attachments.length > 0) {
|
||||
setMessageAttachments((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(id, attachments)
|
||||
return next
|
||||
setConversations((prev) => {
|
||||
const updated = prev.map((conv) =>
|
||||
conv.id === activeChat
|
||||
? { ...conv, lastMessage: content, lastMessageTime: "Just now" }
|
||||
: conv
|
||||
)
|
||||
const idx = updated.findIndex((c) => c.id === activeChat)
|
||||
if (idx > 0) {
|
||||
const [item] = updated.splice(idx, 1)
|
||||
updated.unshift(item)
|
||||
}
|
||||
return updated
|
||||
})
|
||||
setUnreadMap((prev) => { const m = new Map(prev); m.set(activeChat, 0); return m })
|
||||
await fetch(`/api/conversations/${activeChat}/read`, { method: "POST" })
|
||||
if (voice) {
|
||||
setVoiceMessages((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(msg.id, voice)
|
||||
return next
|
||||
})
|
||||
}
|
||||
if (replyTo) {
|
||||
setReplyMap((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(msg.id, { repliedToSender: replyTo.senderName, repliedToContent: replyTo.content })
|
||||
return next
|
||||
})
|
||||
}
|
||||
if (fileAttachments && fileAttachments.length > 0) {
|
||||
setMessageAttachments((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(msg.id, fileAttachments)
|
||||
return next
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to send message")
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = (e: React.FormEvent) => {
|
||||
const handleSend = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!messageInput.trim() && attachments.length === 0) return
|
||||
if (editingMessage) {
|
||||
setConversations((prev) =>
|
||||
prev.map((conv) => ({
|
||||
...conv,
|
||||
messages: conv.messages.map((m) =>
|
||||
m.id === editingMessage.id ? { ...m, content: messageInput.trim() } : m
|
||||
),
|
||||
lastMessage: conv.id === activeChat && conv.messages.some((m) => m.id === editingMessage.id)
|
||||
? messageInput.trim() : conv.lastMessage,
|
||||
}))
|
||||
)
|
||||
setConversationMessages((prev) => {
|
||||
const next = new Map(prev)
|
||||
const msgs = (next.get(activeChat || "") || []).map((m) =>
|
||||
m.id === editingMessage.id ? { ...m, content: messageInput.trim() } : m
|
||||
)
|
||||
next.set(activeChat || "", msgs)
|
||||
return next
|
||||
})
|
||||
setEditingMessage(null)
|
||||
setMessageInput("")
|
||||
setTimeout(() => autoResizeTextarea(), 0)
|
||||
@@ -284,11 +344,11 @@ export default function ChatsPage() {
|
||||
? attachments.map((f) => ({ name: f.name, type: f.type, url: URL.createObjectURL(f) }))
|
||||
: undefined
|
||||
if (replyingTo) {
|
||||
const replySender = replyingTo.senderId === "user1" ? user.name : otherParticipant(conversation!).name
|
||||
addMessageToChat(messageInput.trim(), undefined, { senderName: replySender, content: replyingTo.content }, fileAttachments)
|
||||
const replySender = replyingTo.senderId === user.id ? user.name : otherParticipant(conversation!).name
|
||||
await addMessageToChat(messageInput.trim(), undefined, { senderName: replySender, content: replyingTo.content }, fileAttachments)
|
||||
setReplyingTo(null)
|
||||
} else {
|
||||
addMessageToChat(messageInput.trim(), undefined, undefined, fileAttachments)
|
||||
await addMessageToChat(messageInput.trim(), undefined, undefined, fileAttachments)
|
||||
}
|
||||
setMessageInput("")
|
||||
setTimeout(() => autoResizeTextarea(), 0)
|
||||
@@ -296,12 +356,18 @@ export default function ChatsPage() {
|
||||
}
|
||||
|
||||
const handleDeleteMessage = (messageId: string) => {
|
||||
setConversationMessages((prev) => {
|
||||
const next = new Map(prev)
|
||||
const msgs = (next.get(activeChat || "") || []).filter((m) => m.id !== messageId)
|
||||
next.set(activeChat || "", msgs)
|
||||
return next
|
||||
})
|
||||
setConversations((prev) =>
|
||||
prev.map((conv) => ({
|
||||
...conv,
|
||||
messages: conv.messages.filter((m) => m.id !== messageId),
|
||||
lastMessage: conv.messages.filter((m) => m.id !== messageId).at(-1)?.content ?? conv.lastMessage,
|
||||
}))
|
||||
prev.map((conv) =>
|
||||
conv.id === activeChat
|
||||
? { ...conv, lastMessage: conversationMessages.get(activeChat || "")?.filter((m) => m.id !== messageId).at(-1)?.content ?? conv.lastMessage }
|
||||
: conv
|
||||
)
|
||||
)
|
||||
toast.success("Message deleted")
|
||||
}
|
||||
@@ -405,16 +471,30 @@ export default function ChatsPage() {
|
||||
const person = otherParticipant(conv)
|
||||
const isActive = conv.id === activeChat
|
||||
return (
|
||||
<button
|
||||
key={conv.id}
|
||||
onClick={() => setActiveChat(conv.id)}
|
||||
<button
|
||||
key={conv.id}
|
||||
onClick={() => {
|
||||
setActiveChat(conv.id)
|
||||
setUnreadMap((prev) => { const m = new Map(prev); m.set(conv.id, 0); return m })
|
||||
setConversations((prev) => {
|
||||
const idx = prev.findIndex((c) => c.id === conv.id)
|
||||
if (idx > 0) {
|
||||
const u = [...prev]
|
||||
const [item] = u.splice(idx, 1)
|
||||
u.unshift(item)
|
||||
return u
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}}
|
||||
className={cn(
|
||||
"w-full flex items-start gap-3 p-4 text-left transition-colors hover:bg-muted/50",
|
||||
"w-full flex items-start gap-3 p-4 text-left transition-colors hover:bg-muted/50 relative",
|
||||
isActive && "bg-muted"
|
||||
)}
|
||||
>
|
||||
<Avatar className="h-10 w-10 shrink-0">
|
||||
<AvatarFallback>{person.avatar}</AvatarFallback>
|
||||
<AvatarImage src={person.avatar} />
|
||||
<AvatarFallback>{person.name?.charAt(0) || "?"}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -423,15 +503,55 @@ export default function ChatsPage() {
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">{conv.lastMessage}</p>
|
||||
</div>
|
||||
{conv.unread > 0 && (
|
||||
<span className="shrink-0 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-[10px] font-medium text-primary-foreground">
|
||||
{conv.unread}
|
||||
</span>
|
||||
{!isActive && (unreadMap.get(conv.id) || 0) > 0 && (
|
||||
<div className="shrink-0 h-3 w-3 rounded-full bg-red-500 animate-pulse shadow-[0_0_10px_#ef4444] self-center" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{filteredConversations.length === 0 && searchQuery && (
|
||||
{searchResults.length > 0 && (
|
||||
<>
|
||||
{searchQuery && <div className="px-4 py-2 text-xs font-medium text-muted-foreground">New conversation</div>}
|
||||
{searchResults.map((person: any) => (
|
||||
<button
|
||||
key={person.id}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch("/api/conversations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: person.id }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const conv = data.conversation || conversations.find((c) => c.id === data.conversationId)
|
||||
if (conv) {
|
||||
setConversations((prev) => [conv, ...prev])
|
||||
setUnreadMap((prev) => { const m = new Map(prev); m.set(conv.id, 0); return m })
|
||||
setActiveChat(conv.id)
|
||||
setSearchQuery("")
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
className="w-full flex items-center gap-3 p-4 text-left transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<div className="relative">
|
||||
<Avatar className="h-10 w-10 shrink-0">
|
||||
<AvatarImage src={person.avatar} />
|
||||
<AvatarFallback>{person.name?.charAt(0) || "?"}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="absolute -bottom-0.5 -right-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground font-bold">+</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate block">{person.name}</span>
|
||||
<span className="text-xs text-muted-foreground truncate block">{person.email}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{filteredConversations.length === 0 && searchResults.length === 0 && searchQuery && !searchingUsers && (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">No conversations found</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
@@ -451,8 +571,9 @@ export default function ChatsPage() {
|
||||
{/* Chat header */}
|
||||
<div className="flex items-center justify-between gap-4 px-6 h-16 border-b shrink-0">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Avatar className="h-9 w-9 shrink-0">
|
||||
<AvatarFallback>{otherParticipant(conversation).avatar}</AvatarFallback>
|
||||
<Avatar className="h-9 w-9 shrink-0 cursor-pointer" onClick={() => setPreviewAvatarUrl(otherParticipant(conversation).avatar)}>
|
||||
<AvatarImage src={otherParticipant(conversation).avatar} />
|
||||
<AvatarFallback>{otherParticipant(conversation).name?.charAt(0) || "?"}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{otherParticipant(conversation).name}</p>
|
||||
@@ -493,15 +614,15 @@ export default function ChatsPage() {
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-6" style={{ scrollbarWidth: "thin", scrollbarColor: "hsl(var(--muted-foreground) / 0.3) transparent" }}>
|
||||
<div className="space-y-4 min-h-0">
|
||||
{conversation.messages.map((msg) => {
|
||||
const isMe = msg.senderId === "user1"
|
||||
{messages.map((msg) => {
|
||||
const isMe = msg.senderId === user?.id
|
||||
const voiceUrl = voiceMessages.get(msg.id)
|
||||
return (
|
||||
<div key={msg.id} className={cn("group flex gap-3", isMe && "flex-row-reverse")}>
|
||||
<Avatar className="h-8 w-8 mt-0.5 shrink-0">
|
||||
{isMe ? <AvatarImage src={user.avatar} /> : null}
|
||||
<Avatar className="h-8 w-8 mt-0.5 shrink-0 cursor-pointer" onClick={() => setPreviewAvatarUrl(msg.senderAvatar)}>
|
||||
<AvatarImage src={msg.senderAvatar} />
|
||||
<AvatarFallback className={cn("text-xs", isMe && "bg-primary text-primary-foreground")}>
|
||||
{msg.senderAvatar}
|
||||
{msg.senderName?.charAt(0) || "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className={cn("max-w-[70%]", isMe && "items-end flex flex-col")}>
|
||||
@@ -608,12 +729,9 @@ export default function ChatsPage() {
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground mt-1.5 px-1">
|
||||
{msg.timestamp}
|
||||
{isMe && (
|
||||
(() => {
|
||||
const status = messageStatus.get(msg.id)
|
||||
if (status === "read") return <CheckCheck className="h-3 w-3 text-blue-400" />
|
||||
if (status === "delivered") return <CheckCheck className="h-3 w-3 text-muted-foreground/60" />
|
||||
return <Check className="h-3 w-3 text-muted-foreground/60" />
|
||||
})()
|
||||
msg.read
|
||||
? <CheckCheck className="h-3 w-3 text-blue-400" />
|
||||
: <Check className="h-3 w-3 text-muted-foreground/60" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -649,7 +767,7 @@ export default function ChatsPage() {
|
||||
<div className="flex items-center gap-2 rounded-lg border-l-4 border-primary bg-muted/50 px-3 py-2 text-sm">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-primary">
|
||||
Replying to {replyingTo.senderId === "user1" ? "yourself" : otherParticipant(conversation).name}
|
||||
Replying to {replyingTo.senderId === user?.id ? "yourself" : otherParticipant(conversation).name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{replyingTo.content}</p>
|
||||
</div>
|
||||
@@ -690,7 +808,7 @@ export default function ChatsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<textarea ref={textareaRef} value={messageInput} onChange={(e) => { setMessageInput(e.target.value); autoResizeTextarea() }} onPaste={(e) => { const items = Array.from(e.clipboardData.items).filter((item) => item.type.startsWith("image/")); if (items.length > 0) { e.preventDefault(); const files = items.map((item) => item.getAsFile()).filter((f): f is File => f !== null); setAttachments((prev) => [...prev, ...files]) } }} placeholder={editingMessage ? "Edit message..." : "Type a message..."} className="w-full resize-none overflow-hidden rounded-md border border-input bg-transparent px-3 py-1.5 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 min-h-[36px] max-h-[200px] pr-16" style={{ height: "auto" }} rows={1} />
|
||||
<textarea ref={textareaRef} value={messageInput} onChange={(e) => { setMessageInput(e.target.value); autoResizeTextarea() }} onPaste={(e) => { if (!e.clipboardData) return; const items = Array.from(e.clipboardData.items).filter((item) => item.type.startsWith("image/")); if (items.length > 0) { e.preventDefault(); const files = items.map((item) => item.getAsFile()).filter((f): f is File => f !== null); setAttachments((prev) => [...prev, ...files]) } }} placeholder={editingMessage ? "Edit message..." : "Type a message..."} className="w-full resize-none overflow-hidden rounded-md border border-input bg-transparent px-3 py-1.5 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 min-h-[36px] max-h-[200px] pr-16" style={{ height: "auto" }} rows={1} />
|
||||
<div className="absolute right-0 top-0 bottom-0 flex items-center pr-1" ref={emojiPickerRef}>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setShowEmojiPicker(!showEmojiPicker)}>
|
||||
<Smile className="h-4 w-4 text-muted-foreground" />
|
||||
@@ -740,6 +858,19 @@ export default function ChatsPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Avatar preview dialog */}
|
||||
<Dialog open={!!previewAvatarUrl} onOpenChange={(o) => { if (!o) setPreviewAvatarUrl(null) }}>
|
||||
<DialogContent className="sm:max-w-sm p-0 overflow-hidden bg-transparent border-0 shadow-none">
|
||||
{previewAvatarUrl && (
|
||||
<img
|
||||
src={previewAvatarUrl}
|
||||
alt="Profile picture"
|
||||
className="w-full h-auto max-h-[80vh] object-contain rounded-2xl"
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Forward dialog */}
|
||||
<Dialog open={forwardDialogOpen} onOpenChange={setForwardDialogOpen}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
@@ -754,8 +885,6 @@ export default function ChatsPage() {
|
||||
<ScrollArea className="max-h-64">
|
||||
<div className="space-y-0.5">
|
||||
{conversations
|
||||
.slice()
|
||||
.sort((a, b) => b.messages.length - a.messages.length)
|
||||
.filter((conv) => {
|
||||
const person = otherParticipant(conv)
|
||||
return person.name.toLowerCase().includes(forwardSearch.toLowerCase())
|
||||
@@ -766,45 +895,33 @@ export default function ChatsPage() {
|
||||
<button
|
||||
key={conv.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
const msg = forwardMessage
|
||||
if (!msg) return
|
||||
if (!msg || !user) return
|
||||
const newContent = `📨 Forwarded: ${msg.content}`
|
||||
setConversations((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === conv.id
|
||||
? {
|
||||
...c,
|
||||
messages: [
|
||||
...c.messages,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
conversationId: conv.id,
|
||||
senderId: "user1",
|
||||
senderName: "Sarah Chen",
|
||||
senderAvatar: "SC",
|
||||
content: newContent,
|
||||
timestamp: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||
},
|
||||
],
|
||||
lastMessage: newContent,
|
||||
lastMessageTime: "Just now",
|
||||
}
|
||||
: c
|
||||
)
|
||||
)
|
||||
setForwardDialogOpen(false)
|
||||
setForwardSearch("")
|
||||
toast.success("Message forwarded")
|
||||
try {
|
||||
const res = await fetch(`/api/conversations/${conv.id}/messages`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: newContent }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setForwardDialogOpen(false)
|
||||
setForwardSearch("")
|
||||
toast.success("Message forwarded")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to forward message")
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center gap-3 rounded-lg p-2.5 text-left transition-colors hover:bg-muted"
|
||||
>
|
||||
<Avatar className="h-9 w-9 shrink-0">
|
||||
<AvatarFallback>{person.avatar}</AvatarFallback>
|
||||
<AvatarFallback>{person.name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{person.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{person.role} · {conv.messages.length} messages</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{person.email || ""}</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { StatCard } from "@/components/dashboard/stat-card"
|
||||
import { StatCardSkeleton } from "@/components/dashboard/stat-card-skeleton"
|
||||
import { RecentLeadsTable } from "@/components/dashboard/recent-leads-table"
|
||||
import { LeadStatusChart } from "@/components/dashboard/lead-status-chart"
|
||||
import { LeadsPerMonthChart } from "@/components/dashboard/leads-per-month-chart"
|
||||
import { getDashboardStats } from "@/data/dashboard"
|
||||
import {
|
||||
Users,
|
||||
UserPlus,
|
||||
@@ -29,19 +28,36 @@ import { DashboardStats } from "@/types"
|
||||
export default function DashboardPage() {
|
||||
const [period, setPeriod] = useState("6months")
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const pollingRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
async function fetchStats(p: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/dashboard?period=${p}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setStats(data)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setStats(getDashboardStats(period))
|
||||
fetchStats(period)
|
||||
pollingRef.current = setInterval(() => fetchStats(period), 30000)
|
||||
return () => {
|
||||
if (pollingRef.current) clearInterval(pollingRef.current)
|
||||
}
|
||||
}, [period])
|
||||
|
||||
const statCards = stats
|
||||
? [
|
||||
{ title: "Total Leads", value: stats.totalLeads, icon: Users, variant: "blue" as const, description: stats.periodLabel },
|
||||
{ title: "Open Leads", value: stats.openLeads, icon: UserPlus, variant: "blue" as const, description: "Needs attention" },
|
||||
{ title: "Contacted", value: stats.contactedLeads, icon: PhoneCall, variant: "amber" as const, description: "In conversation" },
|
||||
{ title: "Pending", value: stats.pendingLeads, icon: Clock, variant: "purple" as const, description: "Awaiting decision" },
|
||||
{ title: "Closed", value: stats.closedLeads, icon: CheckCircle2, variant: "emerald" as const, description: "Won" },
|
||||
{ title: "Conversion Rate", value: `${stats.conversionRate}%`, icon: TrendingUp, variant: "emerald" as const, description: `${stats.closedLeads} of ${stats.totalLeads} leads` },
|
||||
{ title: "Total Leads", value: stats.totalLeads, icon: Users, description: stats.periodLabel, trend: stats.trends.totalLeads, sparklineField: "total" as const },
|
||||
{ title: "Open Leads", value: stats.openLeads, icon: UserPlus, description: "Needs attention", trend: stats.trends.openLeads, sparklineField: "open" as const },
|
||||
{ title: "Contacted", value: stats.contactedLeads, icon: PhoneCall, description: "In conversation", trend: stats.trends.contactedLeads, sparklineField: "contacted" as const },
|
||||
{ title: "Pending", value: stats.pendingLeads, icon: Clock, description: "Awaiting decision", trend: stats.trends.pendingLeads, sparklineField: "pending" as const },
|
||||
{ title: "Closed", value: stats.closedLeads, icon: CheckCircle2, description: "Won", trend: stats.trends.closedLeads, sparklineField: "closed" as const },
|
||||
{ title: "Conversion Rate", value: `${stats.conversionRate}%`, icon: TrendingUp, description: `${stats.closedLeads} of ${stats.totalLeads} leads`, trend: stats.trends.conversionRate, sparklineField: "closed" as const, conversionRate: stats.conversionRate },
|
||||
]
|
||||
: []
|
||||
|
||||
@@ -67,7 +83,7 @@ export default function DashboardPage() {
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{stats
|
||||
? statCards.map((card, i) => (
|
||||
<StatCard key={card.title} {...card} index={i} />
|
||||
<StatCard key={card.title} {...card} index={i} monthlyBreakdown={stats.monthlyBreakdown} />
|
||||
))
|
||||
: Array.from({ length: 6 }).map((_, i) => <StatCardSkeleton key={i} />)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import Link from "next/link"
|
||||
import { motion } from "framer-motion"
|
||||
import { use } from "react"
|
||||
@@ -16,14 +17,39 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { getLeadById } from "@/data/leads"
|
||||
import { getNotesByLeadId } from "@/data/notes"
|
||||
import { ArrowLeft, Edit, ExternalLink } from "lucide-react"
|
||||
import { Lead, Note } from "@/types"
|
||||
|
||||
export default function LeadDetailsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const lead = getLeadById(id)
|
||||
const leadNotes = lead ? getNotesByLeadId(lead.id) : []
|
||||
const [lead, setLead] = useState<Lead | null>(null)
|
||||
const [leadNotes, setLeadNotes] = useState<Note[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [leadRes, notesRes] = await Promise.all([
|
||||
fetch(`/api/leads/${id}`),
|
||||
fetch(`/api/leads/${id}/notes`),
|
||||
])
|
||||
if (leadRes.ok) setLead(await leadRes.json())
|
||||
if (notesRes.ok) setLeadNotes(await notesRes.json())
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
fetchData()
|
||||
}, [id])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[60vh]">
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!lead) {
|
||||
return (
|
||||
@@ -61,71 +87,45 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
|
||||
}
|
||||
description={lead.contactName}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Select defaultValue={lead.status}>
|
||||
<SelectTrigger className="h-9 w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="open">Open</SelectItem>
|
||||
<SelectItem value="contacted">Contacted</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
<SelectItem value="ignored">Ignored</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button className="gap-2">
|
||||
<Edit className="h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
<Select
|
||||
value={lead.status}
|
||||
onValueChange={(v) => {
|
||||
setLead((prev) => prev ? { ...prev, status: v as Lead["status"] } : prev)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="open">Open</SelectItem>
|
||||
<SelectItem value="contacted">Contacted</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
<SelectItem value="ignored">Ignored</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm">
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<LeadDetailsCard lead={lead} />
|
||||
<div className="space-y-6">
|
||||
<NoteForm leadId={lead.id} />
|
||||
<NoteTimeline notes={leadNotes} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<LeadDetailsCard lead={lead} />
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
>
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="border-b px-6 py-4">
|
||||
<h3 className="font-semibold">Activity</h3>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{[
|
||||
{ action: "Lead created", date: lead.createdAt, user: lead.assignedUser?.name || "System" },
|
||||
...leadNotes.slice(0, 3).map((n) => ({
|
||||
action: "Note added",
|
||||
date: n.createdAt,
|
||||
user: n.authorName,
|
||||
})),
|
||||
].map((activity, i) => (
|
||||
<div key={i} className="px-6 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-primary/60 shrink-0" />
|
||||
<p className="text-sm">{activity.action}</p>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground pl-4">
|
||||
by {activity.user} · {new Date(activity.date).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<NoteForm leadId={lead.id} />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
@@ -133,20 +133,54 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.2 }}
|
||||
>
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="border-b px-6 py-4">
|
||||
<h3 className="font-semibold">Quick Actions</h3>
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
<Button variant="outline" className="w-full justify-start" size="sm">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Send Email
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start" size="sm">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Call Lead
|
||||
</Button>
|
||||
<NoteTimeline notes={leadNotes} />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.15 }}
|
||||
className="rounded-lg border bg-card p-6"
|
||||
>
|
||||
<h3 className="font-semibold mb-4">Activity</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
<div>
|
||||
<p className="font-medium">Lead Created</p>
|
||||
<p className="text-xs text-muted-foreground">{new Date(lead.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
{leadNotes.slice(0, 3).map((note) => (
|
||||
<div key={note.id} className="flex items-start gap-3 text-sm">
|
||||
<div className="h-2 w-2 rounded-full bg-muted-foreground mt-1.5" />
|
||||
<div>
|
||||
<p className="font-medium">{note.authorName}</p>
|
||||
<p className="text-xs text-muted-foreground">{note.note.slice(0, 60)}...</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.25 }}
|
||||
className="rounded-lg border bg-card p-6"
|
||||
>
|
||||
<h3 className="font-semibold mb-4">Quick Actions</h3>
|
||||
<div className="space-y-2">
|
||||
<Button variant="outline" className="w-full justify-start" size="sm">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Send Email
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start" size="sm">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Call Lead
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -1,42 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { LeadsTable } from "@/components/leads/leads-table"
|
||||
import { LeadsTableToolbar } from "@/components/leads/leads-table-toolbar"
|
||||
import { leads } from "@/data/leads"
|
||||
import { filterLeadsByPeriod } from "@/lib/date-utils"
|
||||
import { Lead } from "@/types"
|
||||
|
||||
export default function LeadsPage() {
|
||||
const router = useRouter()
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState("all")
|
||||
const [periodFilter, setPeriodFilter] = useState("all")
|
||||
const [leadsData, setLeadsData] = useState<Lead[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const filteredLeads = useMemo(() => {
|
||||
let result = leads
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams()
|
||||
if (search) params.set("search", search)
|
||||
if (statusFilter !== "all") params.set("status", statusFilter)
|
||||
if (periodFilter !== "all") params.set("period", periodFilter)
|
||||
|
||||
if (periodFilter && periodFilter !== "all") {
|
||||
result = filterLeadsByPeriod(result, periodFilter)
|
||||
}
|
||||
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
result = result.filter(
|
||||
(l) =>
|
||||
l.companyName.toLowerCase().includes(q) ||
|
||||
l.contactName.toLowerCase().includes(q) ||
|
||||
l.email.toLowerCase().includes(q) ||
|
||||
l.phone.includes(q)
|
||||
)
|
||||
}
|
||||
|
||||
if (statusFilter && statusFilter !== "all") {
|
||||
result = result.filter((l) => l.status === statusFilter)
|
||||
}
|
||||
|
||||
return result
|
||||
setLoading(true)
|
||||
fetch(`/api/leads?${params.toString()}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setLeadsData(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => setLoading(false))
|
||||
}, [search, statusFilter, periodFilter])
|
||||
|
||||
return (
|
||||
@@ -58,7 +50,7 @@ export default function LeadsPage() {
|
||||
onCreateClick={() => router.push("/leads/new")}
|
||||
/>
|
||||
</div>
|
||||
<LeadsTable data={filteredLeads} />
|
||||
<LeadsTable data={leadsData} loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function ProfilePage() {
|
||||
if (!user) return null
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const validTypes = ["image/png", "image/jpeg"]
|
||||
@@ -22,9 +22,27 @@ export default function ProfilePage() {
|
||||
toast.error("Only PNG and JPEG files are allowed")
|
||||
return
|
||||
}
|
||||
const url = URL.createObjectURL(file)
|
||||
updateAvatar(url)
|
||||
toast.success("Avatar updated")
|
||||
const reader = new FileReader()
|
||||
reader.onload = async () => {
|
||||
const dataUrl = reader.result as string
|
||||
try {
|
||||
const res = await fetch("/api/users/avatar", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ avatar: dataUrl }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
updateAvatar(data.avatar)
|
||||
toast.success("Avatar updated")
|
||||
} else {
|
||||
toast.error("Failed to save avatar")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to save avatar")
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,16 +1,55 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { useState } from "react";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
import { UsersTable } from "@/components/users/users-table";
|
||||
import { UserFormDialog } from "@/components/users/user-form-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { users } from "@/data/users";
|
||||
import { Plus, Users as UsersIcon } from "lucide-react";
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { UsersTable } from "@/components/users/users-table"
|
||||
import { UserFormDialog } from "@/components/users/user-form-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useUser } from "@/providers/user-provider"
|
||||
import { Plus, Users as UsersIcon, Search } from "lucide-react"
|
||||
import type { User } from "@/types"
|
||||
|
||||
export default function UsersPage() {
|
||||
const { user } = useUser();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const { user } = useUser()
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/users")
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setUsers(data.users || [])
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
}, [fetchUsers])
|
||||
|
||||
const activeUsers = users.filter((u) => u.active !== false)
|
||||
const admins = users.filter((u) => u.role === "admin")
|
||||
|
||||
const stats = [
|
||||
{ label: "Total Users", value: users.length, color: "bg-blue-500/10", textColor: "text-blue-500" },
|
||||
{ label: "Active", value: activeUsers.length, color: "bg-green-500/10", textColor: "text-green-500" },
|
||||
{ label: "Admins", value: admins.length, color: "bg-purple-500/10", textColor: "text-purple-500" },
|
||||
{ label: "Sales", value: users.filter((u) => u.role === "sales").length, color: "bg-orange-500/10", textColor: "text-orange-500" },
|
||||
]
|
||||
|
||||
const filtered = users.filter((u) => {
|
||||
if (!search) return true
|
||||
const q = search.toLowerCase()
|
||||
return u.name?.toLowerCase().includes(q) || u.email?.toLowerCase().includes(q)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -53,7 +92,7 @@ export default function UsersPage() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<UsersTable data={users} />
|
||||
<UsersTable data={filtered} loading={loading} onUserDeleted={fetchUsers} />
|
||||
</div>
|
||||
|
||||
<UserFormDialog
|
||||
@@ -62,5 +101,5 @@ export default function UsersPage() {
|
||||
onUserCreated={fetchUsers}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user