This commit is contained in:
2026-06-19 09:53:52 +02:00
44 changed files with 2705 additions and 1114 deletions
+262 -145
View File
@@ -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>
)
+26 -10
View File
@@ -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} />)
}
+4 -1
View File
@@ -2,6 +2,7 @@
import { AppShell } from "@/components/layout/app-shell"
import { UserProvider, useUser } from "@/providers/user-provider"
import { NotificationProvider } from "@/providers/notification-provider"
import { Loader2 } from "lucide-react"
function DashboardContent({ children }: { children: React.ReactNode }) {
@@ -27,7 +28,9 @@ export default function DashboardLayout({
}) {
return (
<UserProvider>
<DashboardContent>{children}</DashboardContent>
<NotificationProvider>
<DashboardContent>{children}</DashboardContent>
</NotificationProvider>
</UserProvider>
)
}
+107 -73
View File
@@ -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} &middot; {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>
+8
View File
@@ -29,6 +29,7 @@ import { ArrowLeft } from "lucide-react"
import { Lead, LeadStatus } from "@/types"
import { leads } from "@/data/leads"
import { users } from "@/data/users"
import { useNotifications } from "@/providers/notification-provider"
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
const leadFormSchema = z.object({
@@ -46,6 +47,7 @@ type LeadFormValues = z.infer<typeof leadFormSchema>
export default function CreateLeadPage() {
const router = useRouter()
const { addNotification } = useNotifications()
const [saving, setSaving] = useState(false)
const form = useForm<LeadFormValues>({
@@ -84,6 +86,12 @@ export default function CreateLeadPage() {
}
leads.unshift(newLead)
addNotification(
"lead_created",
"New Lead Created",
`${newLead.companyName}${newLead.contactName}`,
`/leads/${newLead.id}`
)
router.push("/leads")
}
+18 -26
View File
@@ -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>
)
+22 -4
View File
@@ -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 (
+47 -29
View File
@@ -6,23 +6,26 @@ 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 { Plus, Search, Users as UsersIcon } from "lucide-react"
import { User } from "@/types"
import { toast } from "sonner"
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 [createOpen, setCreateOpen] = useState(false)
const { user } = useUser()
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState("")
const [createOpen, setCreateOpen] = useState(false)
const [search, setSearch] = useState("")
const fetchUsers = useCallback(async () => {
try {
const res = await fetch("/api/users")
const data = await res.json()
if (data.users) setUsers(data.users)
if (res.ok) {
const data = await res.json()
setUsers(data.users || [])
}
} catch {
toast.error("Failed to load users")
// ignore
} finally {
setLoading(false)
}
@@ -32,23 +35,28 @@ export default function UsersPage() {
fetchUsers()
}, [fetchUsers])
const filteredUsers = searchQuery.trim()
? users.filter((u) =>
u.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
u.email.toLowerCase().includes(searchQuery.toLowerCase())
)
: users
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-600 dark:text-blue-400" },
{ label: "Active", value: users.filter((u) => u.active).length, color: "bg-emerald-500/10", textColor: "text-emerald-600 dark:text-emerald-400" },
{ label: "Admins", value: users.filter((u) => u.role === "admin" || u.role === "super_admin").length, color: "bg-purple-500/10", textColor: "text-purple-600 dark:text-purple-400" },
{ label: "Sales", value: users.filter((u) => u.role === "sales_user").length, color: "bg-amber-500/10", textColor: "text-amber-600 dark:text-amber-400" },
{ 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">
<PageHeader title="Users" description="Manage team members and their roles">
<PageHeader
title="Users"
description="Manage team members and their roles"
>
<Button className="gap-2" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
Add User
@@ -59,7 +67,9 @@ export default function UsersPage() {
{stats.map((s) => (
<div key={s.label} className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${s.color}`}>
<div
className={`flex h-10 w-10 items-center justify-center rounded-lg ${s.color}`}
>
<UsersIcon className={`h-5 w-5 ${s.textColor}`} />
</div>
<div>
@@ -71,17 +81,25 @@ export default function UsersPage() {
))}
</div>
<div className="rounded-lg border bg-card">
<div className="flex items-center gap-2 p-4 pb-0">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Search users by name or email..." className="h-9 pl-9" />
</div>
</div>
<UsersTable data={filteredUsers} loading={loading} onUserDeleted={fetchUsers} />
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by name or email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-9 pl-9"
/>
</div>
<UserFormDialog open={createOpen} onOpenChange={setCreateOpen} onUserCreated={fetchUsers} />
<div className="rounded-lg border bg-card">
<UsersTable data={filtered} loading={loading} onUserDeleted={fetchUsers} />
</div>
<UserFormDialog
open={createOpen}
onOpenChange={setCreateOpen}
onUserCreated={fetchUsers}
/>
</div>
)
}
@@ -0,0 +1,115 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const [msgResult, otherReadResult] = await Promise.all([
query(
`SELECT m.id, m.sender_id, m.content, m.created_at, m.updated_at, m.deleted_at,
u.first_name || ' ' || u.last_name AS sender_name,
u.email AS sender_email,
u.avatar_url AS sender_avatar_url
FROM messages m
JOIN users u ON u.id = m.sender_id
WHERE m.conversation_id = $1 AND m.deleted_at IS NULL
ORDER BY m.created_at ASC`,
[id],
),
query(
`SELECT last_read_at FROM conversation_participants
WHERE conversation_id = $1 AND user_id != $2`,
[id, user.id],
),
])
const otherLastReadAt = otherReadResult.rows[0]?.last_read_at
? new Date(otherReadResult.rows[0].last_read_at).getTime()
: 0
const messages = msgResult.rows.map((row: any) => ({
id: row.id,
conversationId: id,
senderId: row.sender_id,
senderName: row.sender_name,
senderAvatar: row.sender_avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(row.sender_name)}&background=1d4ed8&color=fff&size=128`,
content: row.content,
timestamp: formatTime(new Date(row.created_at)),
createdAt: row.created_at,
read: row.sender_id === user.id
? new Date(row.created_at).getTime() <= otherLastReadAt
: true,
}))
return NextResponse.json({ messages })
} catch (error) {
console.error("Messages error:", error)
return NextResponse.json({ error: "Failed to load messages" }, { status: 500 })
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const { content } = await request.json()
if (!content?.trim()) {
return NextResponse.json({ error: "Message content is required" }, { status: 400 })
}
const result = await query(
`INSERT INTO messages (conversation_id, sender_id, content)
VALUES ($1, $2, $3)
RETURNING id, created_at`,
[id, user.id, content.trim()],
)
await query(
`UPDATE conversations SET updated_at = NOW() WHERE id = $1`,
[id],
)
const msg = result.rows[0]
return NextResponse.json({
message: {
id: msg.id,
conversationId: id,
senderId: user.id,
senderName: `${user.firstName} ${user.lastName}`,
senderAvatar: user.avatar,
content: content.trim(),
timestamp: formatTime(new Date(msg.created_at)),
createdAt: msg.created_at,
read: false,
},
})
} catch (error) {
console.error("Send message error:", error)
return NextResponse.json({ error: "Failed to send message" }, { status: 500 })
}
}
function formatTime(date: Date): string {
const now = new Date()
const isToday = date.toDateString() === now.toDateString()
if (isToday) {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
return date.toLocaleDateString([], { month: "short", day: "numeric" }) + " " +
date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
await query(
`UPDATE conversation_participants
SET last_read_at = NOW()
WHERE conversation_id = $1 AND user_id = $2`,
[id, user.id],
)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Mark read error:", error)
return NextResponse.json({ error: "Failed to mark as read" }, { status: 500 })
}
}
+129
View File
@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const result = await query(
`SELECT
c.id,
c.updated_at,
cp_me.last_read_at,
u.id AS other_user_id,
u.first_name || ' ' || u.last_name AS other_user_name,
u.email AS other_user_email,
u.avatar_url AS other_user_avatar_url,
(SELECT content FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message,
(SELECT created_at FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message_time,
(SELECT count(*) FROM messages WHERE conversation_id = c.id AND sender_id != $1 AND created_at > COALESCE(cp_me.last_read_at, '1970-01-01')) AS unread
FROM conversations c
JOIN conversation_participants cp_me ON cp_me.conversation_id = c.id AND cp_me.user_id = $1
JOIN conversation_participants cp ON cp.conversation_id = c.id
JOIN users u ON u.id = cp.user_id AND u.id != $1
WHERE c.id IN (
SELECT conversation_id FROM conversation_participants WHERE user_id = $1
)
ORDER BY c.updated_at DESC`,
[user.id],
)
const conversations = result.rows.map((row: any) => ({
id: row.id,
updatedAt: row.updated_at,
otherUser: {
id: row.other_user_id,
name: row.other_user_name,
email: row.other_user_email,
avatar: row.other_user_avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(row.other_user_name)}&background=1d4ed8&color=fff&size=128`,
},
lastMessage: row.last_message || "",
lastMessageTime: row.last_message_time ? timeAgo(new Date(row.last_message_time)) : "",
unread: parseInt(row.unread) || 0,
}))
return NextResponse.json({ conversations })
} catch (error) {
console.error("Conversations error:", error)
return NextResponse.json({ error: "Failed to load conversations" }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { userId } = await request.json()
if (!userId) {
return NextResponse.json({ error: "userId is required" }, { status: 400 })
}
if (userId === user.id) {
return NextResponse.json({ error: "Cannot start a conversation with yourself" }, { status: 400 })
}
const existing = await query(
`SELECT c.id FROM conversations c
JOIN conversation_participants cp1 ON cp1.conversation_id = c.id AND cp1.user_id = $1
JOIN conversation_participants cp2 ON cp2.conversation_id = c.id AND cp2.user_id = $2
LIMIT 1`,
[user.id, userId],
)
if (existing.rows.length > 0) {
return NextResponse.json({ conversationId: existing.rows[0].id })
}
const convResult = await query(
`INSERT INTO conversations DEFAULT VALUES RETURNING id`,
)
const conversationId = convResult.rows[0].id
await query(
`INSERT INTO conversation_participants (conversation_id, user_id, last_read_at) VALUES ($1, $2, NOW()), ($1, $3, NOW())`,
[conversationId, user.id, userId],
)
const otherUser = await query(
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
FROM users WHERE id = $1`,
[userId],
)
const other = otherUser.rows[0]
return NextResponse.json({
conversation: {
id: conversationId,
updatedAt: new Date().toISOString(),
otherUser: {
id: other.id,
name: other.name,
email: other.email,
avatar: other.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(other.name)}&background=1d4ed8&color=fff&size=128`,
},
lastMessage: "",
lastMessageTime: "",
unread: 0,
},
})
} catch (error) {
console.error("Create conversation error:", error)
return NextResponse.json({ error: "Failed to create conversation" }, { status: 500 })
}
}
function timeAgo(date: Date): string {
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
if (seconds < 60) return "now"
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 7) return `${days}d ago`
return date.toLocaleDateString()
}
+207
View File
@@ -0,0 +1,207 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
function getPeriodDateRange(period: string): { start: Date; end: Date } {
const end = new Date()
let start: Date
switch (period) {
case "7days":
start = new Date(end); start.setDate(start.getDate() - 7); break
case "30days":
start = new Date(end); start.setDate(start.getDate() - 30); break
case "12months":
start = new Date(end); start.setFullYear(start.getFullYear() - 12); break
case "6months":
default:
start = new Date(end); start.setMonth(start.getMonth() - 6); break
}
return { start, end }
}
function getPreviousPeriodRange(period: string, currentStart: Date): { start: Date; end: Date } {
const end = new Date(currentStart)
const diff = end.getTime() - currentStart.getTime()
const start = new Date(end.getTime() - diff)
return { start, end }
}
const periodLabels: Record<string, string> = {
"7days": "Last 7 days",
"30days": "Last 30 days",
"6months": "Last 6 months",
"12months": "Last 12 months",
}
function stageToStatus(name: string): string {
switch (name) {
case "New": return "open"
case "Contacted": return "contacted"
case "Qualified":
case "Interested":
case "Demo Scheduled":
case "Negotiation": return "pending"
case "Closed Won": return "closed"
case "Closed Lost": return "ignored"
default: return "open"
}
}
async function fetchLeadsInRange(start: Date, end: Date) {
const result = await query(
`SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone,
l.notes, l.assigned_to, l.score,
ls.name AS stage_name,
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
FROM leads l
JOIN lead_stages ls ON ls.id = l.stage_id
LEFT JOIN users u ON u.id = l.assigned_to
WHERE l.deleted_at IS NULL
AND l.created_at >= $1 AND l.created_at <= $2
ORDER BY l.created_at DESC`,
[start.toISOString(), end.toISOString()]
)
return result.rows.map((r: any) => ({
...r,
status: stageToStatus(r.stage_name),
}))
}
function countStatuses(leads: any[]) {
const counts = { open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
leads.forEach((l: any) => {
const s = l.status as keyof typeof counts
if (s in counts) counts[s]++
})
return counts
}
function buildMonthlyBreakdown(leads: any[], period: string) {
const { start, end } = getPeriodDateRange(period)
const result: { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }[] = []
const current = new Date(start)
const isMonthly = period === "6months" || period === "12months"
while (current <= end) {
const label = isMonthly
? current.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
: current.toLocaleDateString("en-US", { month: "short", day: "numeric" })
const ps = new Date(current)
const pe = isMonthly
? new Date(current.getFullYear(), current.getMonth() + 1, 0, 23, 59, 59)
: (() => { const d = new Date(current); d.setHours(23, 59, 59, 999); return d })()
const inPeriod = leads.filter((l: any) => {
const d = new Date(l.created_at)
return d >= ps && d <= pe
})
const counts = countStatuses(inPeriod)
result.push({ label, total: inPeriod.length, ...counts })
if (isMonthly) current.setMonth(current.getMonth() + 1)
else current.setDate(current.getDate() + 1)
}
return result
}
function computeTrend(current: number, previous: number): { pct: number; up: boolean } {
if (previous === 0) return { pct: current > 0 ? 100 : 0, up: current > 0 }
const pct = Math.round(((current - previous) / previous) * 100)
return { pct: Math.abs(pct), up: pct >= 0 }
}
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { searchParams } = new URL(request.url)
const period = searchParams.get("period") || "6months"
const yearParam = searchParams.get("year")
let start: Date, end: Date, prevRange: { start: Date; end: Date }
if (yearParam) {
const y = parseInt(yearParam)
start = new Date(y, 0, 1)
end = new Date(y, 11, 31, 23, 59, 59)
prevRange = { start: new Date(y - 1, 0, 1), end: new Date(y - 1, 11, 31, 23, 59, 59) }
} else {
const r = getPeriodDateRange(period)
start = r.start; end = r.end
prevRange = getPreviousPeriodRange(period, start)
}
const [currentLeads, prevLeads] = await Promise.all([
fetchLeadsInRange(start, end),
fetchLeadsInRange(prevRange.start, prevRange.end),
])
const currentCounts = countStatuses(currentLeads)
const prevCounts = countStatuses(prevLeads)
const totalLeads = currentLeads.length
const closedLeads = currentCounts.closed
const conversionRate = totalLeads > 0 ? Math.round((closedLeads / totalLeads) * 100) : 0
const mappedLeads = currentLeads.map((r: any) => ({
id: r.id,
companyName: r.company_name || "",
contactName: r.contact_name,
email: r.email || "",
phone: r.phone || "",
source: "",
description: r.notes || "",
status: r.status,
assignedUserId: r.assigned_to,
assignedUser: r.assigned_to ? {
id: r.user_id,
name: `${r.first_name} ${r.last_name}`,
email: r.user_email,
avatar: r.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(`${r.first_name} ${r.last_name}`)}&background=1d4ed8&color=fff&size=128`,
} : null,
createdAt: r.created_at,
updatedAt: r.updated_at,
}))
const monthlyBreakdown = buildMonthlyBreakdown(currentLeads, period)
const trends = {
totalLeads: computeTrend(currentCounts.open + currentCounts.contacted + currentCounts.pending + currentCounts.closed + currentCounts.ignored,
prevCounts.open + prevCounts.contacted + prevCounts.pending + prevCounts.closed + prevCounts.ignored),
openLeads: computeTrend(currentCounts.open, prevCounts.open),
contactedLeads: computeTrend(currentCounts.contacted, prevCounts.contacted),
pendingLeads: computeTrend(currentCounts.pending, prevCounts.pending),
closedLeads: computeTrend(currentCounts.closed, prevCounts.closed),
conversionRate: computeTrend(conversionRate,
prevLeads.length > 0 ? Math.round((prevCounts.closed / prevLeads.length) * 100) : 0),
}
const stats = {
totalLeads,
openLeads: currentCounts.open,
contactedLeads: currentCounts.contacted,
pendingLeads: currentCounts.pending,
closedLeads,
ignoredLeads: currentCounts.ignored,
conversionRate,
monthlyBreakdown,
leadsPerMonth: monthlyBreakdown.map((m: any) => ({ label: m.label, leads: m.total, closed: m.closed })),
trends,
recentLeads: mappedLeads.slice(0, 10),
statusDistribution: [
{ name: "Open", value: currentCounts.open, color: "#3b82f6" },
{ name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" },
{ name: "Pending", value: currentCounts.pending, color: "#8b5cf6" },
{ name: "Closed", value: currentCounts.closed, color: "#10b981" },
{ name: "Ignored", value: currentCounts.ignored, color: "#6B7280" },
],
periodLabel: periodLabels[period] ?? "Selected period",
}
return NextResponse.json(stats)
} catch (error) {
console.error("Dashboard API error:", error)
return NextResponse.json({ error: "Failed to load dashboard stats" }, { status: 500 })
}
}
+62
View File
@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const { content } = await request.json()
if (!content?.trim()) {
return NextResponse.json({ error: "Content is required" }, { status: 400 })
}
await query(
`INSERT INTO customer_notes (customer_id, author_id, content)
VALUES ($1, $2, $3)`,
[id, user.id, content.trim()]
)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Create note error:", error)
return NextResponse.json({ error: "Failed to create note" }, { status: 500 })
}
}
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const result = await query(
`SELECT cn.id, cn.created_at, cn.updated_at, cn.content,
u.id AS user_id, u.first_name, u.last_name, u.avatar_url
FROM customer_notes cn
JOIN users u ON u.id = cn.author_id
WHERE cn.customer_id = $1 AND cn.deleted_at IS NULL
ORDER BY cn.created_at DESC`,
[id]
)
const notes = result.rows.map((r: any) => ({
id: r.id,
leadId: id,
userId: r.user_id,
authorName: `${r.first_name} ${r.last_name}`,
authorAvatar: r.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(`${r.first_name} ${r.last_name}`)}&background=1d4ed8&color=fff&size=128`,
note: r.content,
createdAt: r.created_at,
updatedAt: r.updated_at,
}))
return NextResponse.json(notes)
} catch (error) {
console.error("Lead notes API error:", error)
return NextResponse.json({ error: "Failed to load notes" }, { status: 500 })
}
}
+68
View File
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
function stageToStatus(name: string): string {
switch (name) {
case "New": return "open"
case "Contacted": return "contacted"
case "Qualified":
case "Interested":
case "Demo Scheduled":
case "Negotiation": return "pending"
case "Closed Won": return "closed"
case "Closed Lost": return "ignored"
default: return "open"
}
}
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const result = await query(
`SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
ls.name AS stage_name,
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
FROM leads l
JOIN lead_stages ls ON ls.id = l.stage_id
LEFT JOIN users u ON u.id = l.assigned_to
WHERE l.id = $1 AND l.deleted_at IS NULL`,
[id]
)
if (result.rows.length === 0) {
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
}
const r = result.rows[0]
const lead = {
id: r.id,
companyName: r.company_name || "",
contactName: r.contact_name,
email: r.email || "",
phone: r.phone || "",
source: "",
description: r.notes || "",
status: stageToStatus(r.stage_name),
assignedUserId: r.assigned_to,
assignedUser: r.assigned_to ? {
id: r.user_id,
name: `${r.first_name} ${r.last_name}`,
email: r.user_email,
avatar: r.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(`${r.first_name} ${r.last_name}`)}&background=1d4ed8&color=fff&size=128`,
} : null,
createdAt: r.created_at,
updatedAt: r.updated_at,
}
return NextResponse.json(lead)
} catch (error) {
console.error("Lead detail API error:", error)
return NextResponse.json({ error: "Failed to load lead" }, { status: 500 })
}
}
+105
View File
@@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
function stageToStatus(name: string): string {
switch (name) {
case "New": return "open"
case "Contacted": return "contacted"
case "Qualified":
case "Interested":
case "Demo Scheduled":
case "Negotiation": return "pending"
case "Closed Won": return "closed"
case "Closed Lost": return "ignored"
default: return "open"
}
}
function getPeriodDateRange(period: string): { start: Date; end: Date } | null {
if (period === "all") return null
const end = new Date()
let start: Date
switch (period) {
case "7days": start = new Date(end); start.setDate(start.getDate() - 7); break
case "30days": start = new Date(end); start.setDate(start.getDate() - 30); break
case "12months": start = new Date(end); start.setFullYear(start.getFullYear() - 12); break
case "6months": default: start = new Date(end); start.setMonth(start.getMonth() - 6); break
}
return { start, end }
}
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { searchParams } = new URL(request.url)
const search = searchParams.get("search") || ""
const status = searchParams.get("status") || "all"
const period = searchParams.get("period") || "all"
let sql = `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
ls.name AS stage_name,
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
FROM leads l
JOIN lead_stages ls ON ls.id = l.stage_id
LEFT JOIN users u ON u.id = l.assigned_to
WHERE l.deleted_at IS NULL`
const params: any[] = []
let paramIdx = 1
if (period !== "all") {
const range = getPeriodDateRange(period)
if (range) {
sql += ` AND l.created_at >= $${paramIdx} AND l.created_at <= $${paramIdx + 1}`
params.push(range.start.toISOString(), range.end.toISOString())
paramIdx += 2
}
}
if (search) {
sql += ` AND (l.contact_name ILIKE $${paramIdx} OR l.company_name ILIKE $${paramIdx} OR l.email ILIKE $${paramIdx} OR l.phone ILIKE $${paramIdx})`
params.push(`%${search}%`)
paramIdx++
}
sql += ` ORDER BY l.created_at DESC`
const result = await query(sql, params)
let leads = result.rows.map((r: any) => {
const s = stageToStatus(r.stage_name)
return {
id: r.id,
companyName: r.company_name || "",
contactName: r.contact_name,
email: r.email || "",
phone: r.phone || "",
source: "",
description: r.notes || "",
status: s,
assignedUserId: r.assigned_to,
assignedUser: r.assigned_to ? {
id: r.user_id,
name: `${r.first_name} ${r.last_name}`,
email: r.user_email,
avatar: r.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(`${r.first_name} ${r.last_name}`)}&background=1d4ed8&color=fff&size=128`,
} : null,
createdAt: r.created_at,
updatedAt: r.updated_at,
}
})
if (status !== "all") {
leads = leads.filter((l: any) => l.status === status)
}
return NextResponse.json(leads)
} catch (error) {
console.error("Leads API error:", error)
return NextResponse.json({ error: "Failed to load leads" }, { status: 500 })
}
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server"
import os from "os"
let prevCpu = process.cpuUsage()
let prevTime = Date.now()
export async function GET() {
const now = Date.now()
const elapsed = now - prevTime
const currentCpu = process.cpuUsage()
const user = currentCpu.user - prevCpu.user
const sys = currentCpu.system - prevCpu.system
const totalUs = user + sys
// CPU time (ms) / wall time (ms) * 100 = % of one core
const cpuPct = elapsed > 0 ? Math.round((totalUs / 1000) / elapsed * 100 * 10) / 10 : 0
prevCpu = currentCpu
prevTime = now
const mem = process.memoryUsage()
return NextResponse.json({
rssMB: Math.round(mem.rss / 1024 / 1024),
cpuPct,
cores: os.cpus().length,
})
}
+25
View File
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { avatar } = await request.json()
if (!avatar || typeof avatar !== "string") {
return NextResponse.json({ error: "Invalid avatar data" }, { status: 400 })
}
await query(
`UPDATE users SET avatar_url = $1 WHERE id = $2`,
[avatar, user.id],
)
return NextResponse.json({ avatar })
} catch (error) {
console.error("Avatar upload error:", error)
return NextResponse.json({ error: "Failed to update avatar" }, { status: 500 })
}
}
+2 -2
View File
@@ -6,7 +6,7 @@ export async function GET() {
try {
const result = await query(
`SELECT u.id, u.username, u.email, u.first_name, u.last_name,
u.is_active AS active, u.created_at,
u.is_active AS active, u.created_at, u.avatar_url,
r.name AS role
FROM users u
JOIN user_roles ur ON ur.user_id = u.id
@@ -20,7 +20,7 @@ export async function GET() {
email: row.email,
role: row.role.toLowerCase(),
active: row.active,
avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(`${row.first_name}+${row.last_name}`)}&background=1d4ed8&color=fff&size=128`,
avatar: row.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(`${row.first_name}+${row.last_name}`)}&background=1d4ed8&color=fff&size=128`,
createdAt: row.created_at,
}))
return NextResponse.json({ users }, { status: 200 })
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET(request: NextRequest) {
try {
const currentUser = await getSessionUser()
if (!currentUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const q = request.nextUrl.searchParams.get("q") || ""
if (!q.trim()) {
return NextResponse.json({ users: [] })
}
const result = await query(
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
FROM users
WHERE deleted_at IS NULL
AND id != $1
AND (LOWER(first_name || ' ' || last_name) LIKE LOWER($2)
OR LOWER(email) LIKE LOWER($2))
ORDER BY first_name ASC
LIMIT 10`,
[currentUser.id, `%${q}%`],
)
const users = result.rows.map((row: any) => ({
id: row.id,
name: row.name,
email: row.email,
avatar: row.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(row.name)}&background=1d4ed8&color=fff&size=128`,
}))
return NextResponse.json({ users })
} catch (error) {
console.error("User search error:", error)
return NextResponse.json({ error: "Search failed" }, { status: 500 })
}
}
+252
View File
@@ -63,6 +63,7 @@
--sidebar-ring: 224.3 76.3% 48%;
}
@layer base {
* {
@apply border-border;
}
@@ -72,3 +73,254 @@
font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
}
}
/* Login page custom styles */
.left-panel {
background: #0a0a0f;
position: relative;
overflow: hidden;
}
.right-panel {
background: #111118;
width: 420px;
flex: none;
position: relative;
}
.panel-divider {
width: 1px;
background: rgba(180, 192, 210, 0.1);
flex: none;
}
.growth-word {
position: relative;
color: #1BB0CE;
}
.growth-word::after {
content: "";
position: absolute;
bottom: -2px;
left: 0;
width: 100%;
height: 2px;
background: #1BB0CE;
animation: pulseUnderline 2.5s ease-in-out infinite;
}
@keyframes pulseUnderline {
0%, 100% { background-color: #1BB0CE; }
50% { background-color: rgba(180, 192, 210, 0.6); }
}
.stats-row {
display: flex;
justify-content: center;
margin-top: 32px;
gap: 0;
}
.stat {
flex: 1;
max-width: 120px;
text-align: center;
position: relative;
padding-top: 12px;
}
.stat::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 2px;
background: linear-gradient(90deg, #1BB0CE, rgba(180,192,210,1), #1BB0CE);
background-size: 200% 100%;
animation: statSweep 2.5s ease-in-out infinite;
}
.stat:nth-child(2)::before {
animation-delay: 0.6s;
}
.stat:nth-child(3)::before {
animation-delay: 1.2s;
}
@keyframes statSweep {
0% { background-position: 0% 0; }
100% { background-position: -200% 0; }
}
.stat-number {
font-size: 24px;
font-weight: 700;
color: #1BB0CE;
line-height: 1.2;
}
.stat-label {
font-size: 11px;
color: rgba(232,232,239,0.3);
margin-top: 4px;
letter-spacing: 0.3px;
}
.testimonial {
border-left: 2px solid rgba(27,176,206,0.25);
background: rgba(27,176,206,0.06);
padding: 10px 14px;
}
.stars-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
}
.wave-canvas {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 200px;
z-index: 0;
pointer-events: none;
background: #0a0a0f;
}
.accent-line {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 2px;
background: linear-gradient(90deg, transparent, #1BB0CE, rgba(232,232,239,0.15), transparent);
animation: pulseAccent 3s ease-in-out infinite;
}
@keyframes pulseAccent {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
.input-sheen {
position: relative;
overflow: hidden;
border-radius: 4px;
}
.input-sheen::before {
content: "";
position: absolute;
top: -10%;
left: -100%;
width: 60%;
height: 120%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.08), transparent);
transform: rotate(-15deg);
animation: sheenFloat 3.2s ease-in-out infinite;
pointer-events: none;
z-index: 2;
}
.input-sheen-delayed::before {
animation-delay: 1s;
}
.input-sheen:focus-within::before {
animation: none;
opacity: 0;
}
@keyframes sheenFloat {
0% { left: -100%; }
100% { left: 200%; }
}
.login-input {
width: 100%;
height: 44px;
background: linear-gradient(135deg, #0d0d15, #151520, #0d0d15);
background-size: 200% 200%;
animation: bgShift 6s ease-in-out infinite;
border: 1.5px solid rgba(180,192,210,0.15);
border-radius: 4px;
font-size: 13px;
color: #e8e8ef;
padding: 0 12px;
position: relative;
z-index: 1;
transition: border-color 0.2s ease, background 0.2s ease;
box-sizing: border-box;
}
.login-input::placeholder {
color: rgba(232,232,239,0.2);
}
.login-input:focus {
border-color: #1BB0CE;
outline: none;
background: #111118;
animation: none;
}
@keyframes bgShift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.password-toggle {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: rgba(232,232,239,0.3);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
z-index: 3;
}
.password-toggle:hover {
color: rgba(232,232,239,0.5);
}
.login-checkbox {
accent-color: #1BB0CE;
width: 16px;
height: 16px;
cursor: pointer;
}
.auth-btn {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
background: #1BB0CE;
color: #ffffff;
font-size: 14px;
font-weight: 700;
padding: 13px;
border-radius: 4px;
border: none;
cursor: pointer;
position: relative;
overflow: hidden;
transition: background 0.2s ease;
}
.auth-btn:hover {
background: #17a0bc;
}
.auth-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.auth-btn::before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 60%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
animation: btnSweep 2.2s ease-in-out infinite;
pointer-events: none;
}
@keyframes btnSweep {
0% { left: -100%; }
100% { left: 200%; }
}
.login-label {
font-size: 12px;
font-weight: 600;
color: rgba(232,232,239,0.4);
display: block;
margin-bottom: 6px;
}
.body-text { color: rgba(232,232,239,0.5); }
.subheading-text { color: rgba(232,232,239,0.38); }
.checkbox-text { color: rgba(232,232,239,0.38); }
.footer-text { color: rgba(232,232,239,0.2); }
+7 -2
View File
@@ -1,4 +1,4 @@
import type { Metadata } from "next"
import type { Metadata, Viewport } from "next"
import { Inter } from "next/font/google"
import { ThemeProvider } from "@/providers/theme-provider"
import { Toaster } from "@/components/ui/sonner"
@@ -9,8 +9,13 @@ const inter = Inter({
subsets: ["latin"],
})
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
}
export const metadata: Metadata = {
title: "Coast IT - CRM",
title: "CRM",
description: "Customer Relationship Management System",
icons: {
icon: "/logo/CompanyMiniLogo.png",
+7
View File
@@ -0,0 +1,7 @@
export default function LoginLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}
+120 -491
View File
@@ -2,7 +2,7 @@
import { useState, useEffect, useRef } from "react"
import { useRouter } from "next/navigation"
import { motion } from "framer-motion"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -10,444 +10,40 @@ import { Checkbox } from "@/components/ui/checkbox"
import { COMPANY_NAME } from "@/lib/constants"
import { Eye, EyeOff, Loader2 } from "lucide-react"
export default function LoginForm() {
function LoginForm() {
const router = useRouter()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false)
const [remember, setRemember] = useState(false)
const [counts, setCounts] = useState({ leads: 0, conversion: 0, users: 0 })
const counterStarted = useRef(false)
const canvasRef = useRef<HTMLCanvasElement>(null)
const starsCanvasRightRef = useRef<HTMLCanvasElement>(null)
const waves = [
{ a: 16, f: 0.011, s: 0.018, y: 0.38, fill: "rgba(27,176,206,0.18)" },
{ a: 20, f: 0.008, s: 0.013, y: 0.52, fill: "rgba(27,176,206,0.25)" },
{ a: 12, f: 0.015, s: 0.025, y: 0.28, fill: "rgba(180,192,210,0.06)" },
{ a: 26, f: 0.006, s: 0.010, y: 0.65, fill: "rgba(180,192,210,0.15)" },
{ a: 10, f: 0.019, s: 0.030, y: 0.20, fill: "rgba(27,176,206,0.10)" },
]
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext("2d")
if (!ctx) return
let animId: number
let time = 0
const resize = () => {
const parent = canvas.parentElement!
const rect = parent.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
canvas.width = rect.width * dpr
canvas.height = 200 * dpr
canvas.style.width = rect.width + "px"
canvas.style.height = "200px"
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0)
}
resize()
window.addEventListener("resize", resize)
const draw = () => {
const w = canvas.width / (window.devicePixelRatio || 1)
const h = 200
ctx!.clearRect(0, 0, w, h)
for (const wave of waves) {
ctx!.beginPath()
ctx!.moveTo(0, h)
for (let x = 0; x <= w; x++) {
const y = wave.y * h + Math.sin(x * wave.f + time * wave.s) * wave.a
ctx!.lineTo(x, y)
}
ctx!.lineTo(w, h)
ctx!.closePath()
ctx!.fillStyle = wave.fill
ctx!.fill()
}
time += 1
animId = requestAnimationFrame(draw)
}
animId = requestAnimationFrame(draw)
return () => {
cancelAnimationFrame(animId)
window.removeEventListener("resize", resize)
}
}, [])
useEffect(() => {
const canvases = [starsCanvasRightRef.current].filter(Boolean) as HTMLCanvasElement[]
if (canvases.length === 0) return
const stars = Array.from({ length: 312 }, () => ({
x: Math.random(),
y: Math.random(),
r: 0.2 + Math.random() * 0.6,
baseOpacity: 0.12 + Math.random() * 0.43,
speed: 0.003 + Math.random() * 0.015,
phase: Math.random() * Math.PI * 2,
driftX: (Math.random() - 0.5) * 0.00003,
driftY: (Math.random() - 0.5) * 0.00003,
}))
let animId: number
let time = 0
const resize = () => {
for (const c of canvases) {
const dpr = window.devicePixelRatio || 1
const rect = c.getBoundingClientRect()
c.width = rect.width * dpr
c.height = rect.height * dpr
const ctx = c.getContext("2d")
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
}
}
resize()
const ros = canvases.map((c) => {
const ro = new ResizeObserver(() => resize())
ro.observe(c.parentElement!)
return ro
})
window.addEventListener("resize", resize)
const draw = () => {
time += 1
for (const c of canvases) {
const dpr = window.devicePixelRatio || 1
const w = c.width / dpr
const h = c.height / dpr
const ctx = c.getContext("2d")
if (!ctx) continue
ctx.clearRect(0, 0, w, h)
for (const star of stars) {
const x = ((star.x + time * star.driftX) % 1) * w
const y = ((star.y + time * star.driftY) % 1) * h
const opacity = star.baseOpacity * (0.5 + 0.5 * Math.sin(time * star.speed + star.phase))
ctx.beginPath()
ctx.arc(x, y, star.r, 0, Math.PI * 2)
ctx.fillStyle = `rgba(255,255,255,${opacity})`
ctx.fill()
if (star.r > 0.5) {
ctx.beginPath()
ctx.arc(x, y, star.r * 1.8, 0, Math.PI * 2)
ctx.fillStyle = `rgba(255,255,255,${opacity * 0.06})`
ctx.fill()
}
}
}
animId = requestAnimationFrame(draw)
}
animId = requestAnimationFrame(draw)
return () => {
cancelAnimationFrame(animId)
ros.forEach((ro) => ro.disconnect())
window.removeEventListener("resize", resize)
}
}, [])
useEffect(() => {
if (counterStarted.current) return
counterStarted.current = true
const targets = { leads: 1247, conversion: 40, users: 83 }
const steps = 150
const delayTimer = setTimeout(() => {
let step = 0
const interval = setInterval(() => {
step++
if (step >= steps) {
clearInterval(interval)
setCounts(targets)
return
}
setCounts({
leads: Math.min(Math.floor((targets.leads / steps) * step), targets.leads),
conversion: Math.min(Math.floor((targets.conversion / steps) * step), targets.conversion),
users: Math.min(Math.floor((targets.users / steps) * step), targets.users),
})
}, 15)
}, 400)
return () => clearTimeout(delayTimer)
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
await new Promise((resolve) => setTimeout(resolve, 1200))
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
})
router.push("/dashboard")
if (res.ok) {
router.push("/dashboard")
} else {
const data = await res.json().catch(() => ({}))
setError(data.error || "Invalid email or password.")
}
} catch {
setError("Connection error. Please try again.")
} finally {
setLoading(false)
}
}
return (
<>
<style>{`
.left-panel {
background: #0a0a0f;
position: relative;
overflow: hidden;
}
.right-panel {
background: #111118;
width: 420px;
flex: none;
position: relative;
}
.panel-divider {
width: 1px;
background: rgba(180, 192, 210, 0.1);
flex: none;
}
.growth-word {
position: relative;
color: #1BB0CE;
}
.growth-word::after {
content: "";
position: absolute;
bottom: -2px;
left: 0;
width: 100%;
height: 2px;
background: #1BB0CE;
animation: pulseUnderline 2.5s ease-in-out infinite;
}
@keyframes pulseUnderline {
0%, 100% { background-color: #1BB0CE; }
50% { background-color: rgba(180, 192, 210, 0.6); }
}
.stats-row {
display: flex;
justify-content: center;
margin-top: 32px;
gap: 0;
}
.stat {
flex: 1;
max-width: 120px;
text-align: center;
position: relative;
padding-top: 12px;
}
.stat::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 2px;
background: linear-gradient(90deg, #1BB0CE, rgba(180,192,210,1), #1BB0CE);
background-size: 200% 100%;
animation: statSweep 2.5s ease-in-out infinite;
}
.stat:nth-child(2)::before {
animation-delay: 0.6s;
}
.stat:nth-child(3)::before {
animation-delay: 1.2s;
}
@keyframes statSweep {
0% { background-position: 0% 0; }
100% { background-position: -200% 0; }
}
.stat-number {
font-size: 24px;
font-weight: 700;
color: #1BB0CE;
line-height: 1.2;
}
.stat-label {
font-size: 11px;
color: rgba(232,232,239,0.3);
margin-top: 4px;
letter-spacing: 0.3px;
}
.testimonial {
border-left: 2px solid rgba(27,176,206,0.25);
background: rgba(27,176,206,0.06);
padding: 10px 14px;
}
.stars-canvas {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
}
.wave-canvas {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 200px;
z-index: 0;
pointer-events: none;
background: #0a0a0f;
}
.accent-line {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 2px;
background: linear-gradient(90deg, transparent, #1BB0CE, rgba(232,232,239,0.15), transparent);
animation: pulseAccent 3s ease-in-out infinite;
}
@keyframes pulseAccent {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
.input-sheen {
position: relative;
overflow: hidden;
border-radius: 4px;
}
.input-sheen::before {
content: "";
position: absolute;
top: -10%;
left: -100%;
width: 60%;
height: 120%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.08), transparent);
transform: rotate(-15deg);
animation: sheenFloat 3.2s ease-in-out infinite;
pointer-events: none;
z-index: 2;
}
.input-sheen-delayed::before {
animation-delay: 1s;
}
.input-sheen:focus-within::before {
animation: none;
opacity: 0;
}
@keyframes sheenFloat {
0% { left: -100%; }
100% { left: 200%; }
}
.login-input {
width: 100%;
height: 44px;
background: linear-gradient(135deg, #0d0d15, #151520, #0d0d15);
background-size: 200% 200%;
animation: bgShift 6s ease-in-out infinite;
border: 1.5px solid rgba(180,192,210,0.15);
border-radius: 4px;
font-size: 13px;
color: #e8e8ef;
padding: 0 12px;
position: relative;
z-index: 1;
transition: border-color 0.2s ease, background 0.2s ease;
box-sizing: border-box;
}
.login-input::placeholder {
color: rgba(232,232,239,0.2);
}
.login-input:focus {
border-color: #1BB0CE;
outline: none;
background: #111118;
animation: none;
}
@keyframes bgShift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.password-toggle {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: rgba(232,232,239,0.3);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
z-index: 3;
}
.password-toggle:hover {
color: rgba(232,232,239,0.5);
}
.login-checkbox {
accent-color: #1BB0CE;
width: 16px;
height: 16px;
cursor: pointer;
}
.auth-btn {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
background: #1BB0CE;
color: #ffffff;
font-size: 14px;
font-weight: 700;
padding: 13px;
border-radius: 4px;
border: none;
cursor: pointer;
position: relative;
overflow: hidden;
transition: background 0.2s ease;
}
.auth-btn:hover {
background: #17a0bc;
}
.auth-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.auth-btn::before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 60%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
animation: btnSweep 2.2s ease-in-out infinite;
pointer-events: none;
}
@keyframes btnSweep {
0% { left: -100%; }
100% { left: 200%; }
}
.login-label {
font-size: 12px;
font-weight: 600;
color: rgba(232,232,239,0.4);
display: block;
margin-bottom: 6px;
}
.body-text { color: rgba(232,232,239,0.5); }
.subheading-text { color: rgba(232,232,239,0.38); }
.checkbox-text { color: rgba(232,232,239,0.38); }
.footer-text { color: rgba(232,232,239,0.2); }
`}</style>
<div className="flex min-h-screen bg-[#0a0a0f]">
<div className="flex min-h-screen bg-[#0a0a0f]">
<div className="left-panel flex flex-1 flex-col p-12">
<div className="relative z-10">
<img
@@ -484,13 +80,32 @@ export default function LoginForm() {
</div>
</div>
<div className="relative z-10 testimonial">
<p className="text-[12px] italic" style={{ color: "#0a0a0f" }}>
&ldquo;This CRM transformed how we manage our sales pipeline. We&apos;ve seen a 40% increase in lead conversion.&rdquo;
</p>
<p className="text-[11px] mt-1" style={{ color: "#0a0a0f" }}>
Marcus Johnson, Sales Lead at Coast IT
</p>
<div className="relative z-10 testimonial" style={{ background: "rgba(255,255,255,0.7)", padding: "16px 20px 16px 16px", clipPath: "url(#testimonialClip)" }}>
<svg width="0" height="0" style={{ position: "absolute" }}>
<defs>
<clipPath id="testimonialClip" clipPathUnits="objectBoundingBox">
<path d="M0,0 L0.92,0 C0.96,0 1,0.03 1,0.08 C1,0.14 0.97,0.22 0.9,0.3 C0.85,0.36 0.83,0.42 0.83,0.5 C0.83,0.58 0.85,0.64 0.9,0.7 C0.97,0.78 1,0.86 1,0.92 C1,0.97 0.96,1 0.92,1 L0,1 Z" />
</clipPath>
</defs>
</svg>
<div className="transition-opacity duration-500" key={testimonialIndex}>
<p className="text-sm font-semibold leading-relaxed" style={{ color: "#0a0a0f" }}>
&ldquo;{testimonials[testimonialIndex].text}&rdquo;
</p>
<p className="text-xs font-bold mt-2" style={{ color: "#0a0a0f" }}>
{testimonials[testimonialIndex].author}
</p>
</div>
<div className="flex items-center justify-center gap-1.5 mt-3">
{testimonials.map((_, i) => (
<button
key={i}
type="button"
onClick={() => setTestimonialIndex(i)}
className={`h-1.5 rounded-full transition-all duration-300 ${i === testimonialIndex ? "w-5 bg-[#1BB0CE]" : "w-1.5 bg-[#1BB0CE]/30"}`}
/>
))}
</div>
</div>
<canvas ref={canvasRef} className="wave-canvas" />
@@ -510,69 +125,84 @@ export default function LoginForm() {
Sign in to your account to continue
</p>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="name@company.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<button
type="button"
className="text-xs text-primary hover:underline"
>
Forgot password?
</button>
{error && (
<div className="mb-4 rounded bg-red-500/10 px-4 py-2 text-sm text-red-400">
{error}
</div>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="email" className="login-label">
Email
</label>
<div className="input-sheen">
<input
id="email"
type="email"
placeholder="name@company.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
className="login-input"
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-[6px]">
<label htmlFor="password" className="login-label" style={{ marginBottom: 0 }}>
Password
</label>
<button type="button" className="text-[12px] text-[#1BB0CE] hover:underline">
Forgot password?
</button>
</div>
<div className="input-sheen input-sheen-delayed">
<div className="relative">
<input
id="password"
type={showPassword ? "text" : "password"}
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
className="login-input"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="password-toggle"
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="login-checkbox"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="remember"
checked={remember}
onCheckedChange={(checked) => setRemember(checked as boolean)}
/>
<label
htmlFor="remember"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Remember me
<span className="text-[13px] checkbox-text">
Remember me
</span>
</label>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{loading ? "Signing in..." : "Sign in"}
</Button>
</form>
<button type="submit" className="auth-btn" disabled={loading}>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{loading ? "Signing in..." : "Sign in"}
</button>
</form>
<p className="text-center text-[11px] mt-4 footer-text">
By signing in, you agree to our{" "}
@@ -587,6 +217,5 @@ export default function LoginForm() {
</div>
</div>
</div>
</>
)
}