Updated Login

This commit is contained in:
2026-06-18 16:12:10 +02:00
40 changed files with 2337 additions and 713 deletions
+519 -99
View File
@@ -17,7 +17,8 @@ import {
import { conversations as conversationsData } from "@/data/chats"
import {
Search, Send, Phone, Video, MoreHorizontal, Paperclip,
Smile, Flag, Ban, Trash2, Image, File, X,
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
CornerDownRight, Forward, Pencil, Download,
} from "lucide-react"
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
@@ -31,6 +32,65 @@ import { toast } from "sonner"
import data from "@emoji-mart/data"
import Picker from "@emoji-mart/react"
function VoiceMessagePlayer({ src, initialDuration }: { src: string; initialDuration: number }) {
const [playing, setPlaying] = useState(false)
const [current, setCurrent] = useState(0)
const [duration, setDuration] = useState(initialDuration)
const audioRef = useRef<HTMLAudioElement>(null)
const animRef = useRef<number>(0)
useEffect(() => {
const audio = audioRef.current
if (!audio) return
const onLoaded = () => {
if (isFinite(audio.duration)) setDuration(audio.duration)
}
const onEnded = () => { setPlaying(false); setCurrent(0) }
audio.addEventListener("loadedmetadata", onLoaded)
audio.addEventListener("ended", onEnded)
return () => {
audio.removeEventListener("loadedmetadata", onLoaded)
audio.removeEventListener("ended", onEnded)
}
}, [src])
useEffect(() => {
if (!playing) { cancelAnimationFrame(animRef.current); return }
const tick = () => {
if (audioRef.current) setCurrent(audioRef.current.currentTime)
animRef.current = requestAnimationFrame(tick)
}
animRef.current = requestAnimationFrame(tick)
return () => cancelAnimationFrame(animRef.current)
}, [playing])
const toggle = () => {
if (!audioRef.current) return
if (playing) { audioRef.current.pause(); setPlaying(false) }
else { audioRef.current.play(); setPlaying(true) }
}
const displayDuration = isFinite(duration) && duration > 0 ? duration : initialDuration
const pct = displayDuration > 0 ? (current / displayDuration) * 100 : 0
const fmt = (s: number) => {
const secs = isFinite(s) ? Math.max(0, Math.floor(s)) : 0
return `${Math.floor(secs / 60)}:${(secs % 60).toString().padStart(2, "0")}`
}
return (
<div className="flex items-center gap-2 min-w-[180px]">
<audio ref={audioRef} src={src} preload="metadata" />
<button type="button" onClick={toggle} className="h-8 w-8 shrink-0 rounded-full flex items-center justify-center hover:bg-black/10 transition-colors">
{playing ? <Pause className="h-4 w-4 fill-current" /> : <Play className="h-4 w-4 ml-0.5 fill-current" />}
</button>
<div className="flex-1 h-1.5 rounded-full bg-white/30 relative overflow-hidden">
<div className="h-full rounded-full bg-white transition-all duration-100" style={{ width: `${pct}%` }} />
</div>
<span className="text-[11px] tabular-nums opacity-70 w-8 text-right">{fmt(displayDuration)}</span>
</div>
)
}
export default function ChatsPage() {
const { theme } = useTheme()
const { user } = useUser()
@@ -42,14 +102,53 @@ export default function ChatsPage() {
const [isResizing, setIsResizing] = useState(false)
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 [forwardSearch, setForwardSearch] = useState("")
const [searchQuery, setSearchQuery] = useState("")
const [isRecording, setIsRecording] = useState(false)
const [isPaused, setIsPaused] = useState(false)
const [recordingDuration, setRecordingDuration] = useState(0)
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 [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
})
const fileInputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const emojiPickerRef = useRef<HTMLDivElement>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const recordingChunksRef = useRef<Blob[]>([])
const resizeStartRef = useRef({ x: 0, width: 0 })
const isOnlyEmoji = (text: string) => /^(\p{Extended_Pictographic}\s*)+$/u.test(text.trim())
const autoResizeTextarea = useCallback(() => {
const ta = textareaRef.current
if (!ta) return
ta.style.height = "auto"
ta.style.height = `${ta.scrollHeight}px`
}, [])
const [conversations, setConversations] = useState(conversationsData)
if (!user) return null
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 filteredConversations = searchQuery.trim()
? conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase()))
: conversations
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
@@ -83,9 +182,14 @@ export default function ChatsPage() {
}
}, [isResizing])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [conversations])
const handleEmojiSelect = (emoji: { native: string }) => {
setMessageInput((prev) => prev + emoji.native)
setShowEmojiPicker(false)
setTimeout(() => autoResizeTextarea(), 0)
}
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -98,16 +202,15 @@ export default function ChatsPage() {
setAttachments((prev) => prev.filter((_, i) => i !== index))
}
const handleSend = (e: React.FormEvent) => {
e.preventDefault()
if (!messageInput.trim() && attachments.length === 0) return
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: crypto.randomUUID(),
id,
conversationId: activeChat!,
senderId: "user1",
senderName: "Sarah Chen",
senderAvatar: "SC",
content: messageInput.trim(),
content,
timestamp: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
}
setConversations((prev) =>
@@ -116,17 +219,165 @@ export default function ChatsPage() {
? {
...conv,
messages: [...conv.messages, newMessage],
lastMessage: newMessage.content,
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 next = new Map(prev)
if (next.get(id) === "sent") next.set(id, "delivered")
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
})
}
}
const handleSend = (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,
}))
)
setEditingMessage(null)
setMessageInput("")
setTimeout(() => autoResizeTextarea(), 0)
return
}
const fileAttachments = attachments.length > 0
? 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)
setReplyingTo(null)
} else {
addMessageToChat(messageInput.trim(), undefined, undefined, fileAttachments)
}
setMessageInput("")
setTimeout(() => autoResizeTextarea(), 0)
setAttachments([])
}
const handleDeleteMessage = (messageId: string) => {
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,
}))
)
toast.success("Message deleted")
}
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const recorder = new MediaRecorder(stream, { mimeType: "audio/webm" })
recordingChunksRef.current = []
recorder.ondataavailable = (e) => {
if (e.data.size > 0) recordingChunksRef.current.push(e.data)
}
recorder.onstop = () => {
const blob = new Blob(recordingChunksRef.current, { type: "audio/webm" })
const url = URL.createObjectURL(blob)
addMessageToChat("", { url, duration: recordingDurationRef.current })
stream.getTracks().forEach((t) => t.stop())
setRecordingDuration(0)
}
mediaRecorderRef.current = recorder
recorder.start()
setIsRecording(true)
setIsPaused(false)
} catch {
toast.error("Microphone access denied")
}
}
const togglePauseRecording = () => {
const recorder = mediaRecorderRef.current
if (!recorder || recorder.state === "inactive") return
if (recorder.state === "recording") {
recorder.pause()
setIsPaused(true)
} else {
recorder.resume()
setIsPaused(false)
}
}
const stopRecording = () => {
mediaRecorderRef.current?.stop()
setIsRecording(false)
setIsPaused(false)
}
const discardRecording = () => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.ondataavailable = null
mediaRecorderRef.current.onstop = null
mediaRecorderRef.current.stop()
mediaRecorderRef.current.stream?.getTracks().forEach((t) => t.stop())
}
setIsRecording(false)
setIsPaused(false)
setRecordingDuration(0)
recordingDurationRef.current = 0
}
useEffect(() => {
if (!isRecording) return
recordingDurationRef.current = 0
setRecordingDuration(0)
const interval = setInterval(() => {
recordingDurationRef.current += 1
setRecordingDuration(recordingDurationRef.current)
}, 1000)
return () => clearInterval(interval)
}, [isRecording])
const formatDuration = (secs: number) => {
const m = Math.floor(secs / 60)
const s = secs % 60
return `${m}:${s.toString().padStart(2, "0")}`
}
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + " B"
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"
@@ -146,11 +397,11 @@ export default function ChatsPage() {
<h2 className="text-lg font-semibold">Chats</h2>
<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 conversations..." className="h-9 pl-9" />
<Input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Search conversations..." className="h-9 pl-9" />
</div>
</div>
<ScrollArea className="flex-1">
{conversations.map((conv) => {
{filteredConversations.map((conv) => {
const person = otherParticipant(conv)
const isActive = conv.id === activeChat
return (
@@ -180,6 +431,9 @@ export default function ChatsPage() {
</button>
)
})}
{filteredConversations.length === 0 && searchQuery && (
<div className="p-4 text-center text-sm text-muted-foreground">No conversations found</div>
)}
</ScrollArea>
</div>
@@ -206,16 +460,10 @@ export default function ChatsPage() {
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost" size="icon" className="h-8 w-8"
onClick={() => toast.info("Voice calling coming soon")}
>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Voice calling coming soon")}>
<Phone className="h-4 w-4" />
</Button>
<Button
variant="ghost" size="icon" className="h-8 w-8"
onClick={() => toast.info("Video calling coming soon")}
>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Video calling coming soon")}>
<Video className="h-4 w-4" />
</Button>
<DropdownMenu>
@@ -234,10 +482,7 @@ export default function ChatsPage() {
<Ban className="mr-2 h-4 w-4" /> Block
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => toast.info("Conversation deleted")}
>
<DropdownMenuItem className="text-destructive" onClick={() => toast.info("Conversation deleted")}>
<Trash2 className="mr-2 h-4 w-4" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
@@ -246,12 +491,13 @@ export default function ChatsPage() {
</div>
{/* Messages */}
<ScrollArea className="flex-1 p-6">
<div className="space-y-4">
<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"
const voiceUrl = voiceMessages.get(msg.id)
return (
<div key={msg.id} className={cn("flex gap-3", isMe && "flex-row-reverse")}>
<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}
<AvatarFallback className={cn("text-xs", isMe && "bg-primary text-primary-foreground")}>
@@ -261,92 +507,208 @@ export default function ChatsPage() {
<div className={cn("max-w-[70%]", isMe && "items-end flex flex-col")}>
<div
className={cn(
"rounded-2xl px-4 py-2.5 text-sm",
"rounded-2xl px-5 py-3 text-base relative",
isMe ? "bg-primary text-primary-foreground rounded-tr-sm" : "bg-muted rounded-tl-sm"
)}
>
{msg.content}
{!isMe && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className="absolute -right-8 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6 flex items-center justify-center rounded-full hover:bg-sky-400/20">
<MoreHorizontal className="h-3.5 w-3.5 text-sky-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top" className="w-32">
<DropdownMenuItem onClick={() => setReplyingTo(msg)}>
<CornerDownRight className="mr-2 h-4 w-4" /> Reply
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { setForwardMessage(msg); setForwardDialogOpen(true) }}>
<Forward className="mr-2 h-4 w-4" /> Forward
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{isMe && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className="absolute -left-8 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6 flex items-center justify-center rounded-full hover:bg-sky-400/20">
<MoreHorizontal className="h-3.5 w-3.5 text-sky-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top" className="w-32">
<DropdownMenuItem onClick={() => { setEditingMessage(msg); setMessageInput(msg.content) }}>
<Pencil className="mr-2 h-4 w-4" /> Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setReplyingTo(msg)}>
<CornerDownRight className="mr-2 h-4 w-4" /> Reply
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { setForwardMessage(msg); setForwardDialogOpen(true) }}>
<Forward className="mr-2 h-4 w-4" /> Forward
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive" onClick={() => handleDeleteMessage(msg.id)}>
<Trash2 className="mr-2 h-4 w-4" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{(() => {
const replyInfo = replyMap.get(msg.id)
return (
<>
{replyInfo && (
<div className={cn("mb-1.5 pl-2 border-l-2 overflow-hidden bg-black/70 rounded-sm text-white", isMe ? "border-white/40" : "border-primary/40")}>
<p className="text-[11px] font-medium truncate">{replyInfo.repliedToSender}</p>
<p className="text-[11px] text-white/60 truncate">{replyInfo.repliedToContent}</p>
</div>
)}
{voiceUrl ? (
<div>
<VoiceMessagePlayer src={voiceUrl.url} initialDuration={voiceUrl.duration} />
{msg.content && (
<p className="mt-1 text-sm text-black">{msg.content}</p>
)}
</div>
) : (
<>
{(() => {
const files = messageAttachments.get(msg.id)
return files && files.length > 0 ? (
<div className="space-y-1.5 mb-1.5">
{files.map((file, i) =>
file.type.startsWith("image/") ? (
<div key={i} className="relative group">
<img src={file.url} alt={file.name} className="max-w-full rounded-lg max-h-60 object-cover cursor-pointer" onClick={() => window.open(file.url, "_blank")} />
<a href={file.url} download={file.name} className="absolute top-2 right-2 h-7 w-7 rounded-full bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<Download className="h-3.5 w-3.5 text-white" />
</a>
</div>
) : (
<a key={i} href={file.url} download={file.name} className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2 text-sm hover:bg-muted/80 transition-colors">
<FileIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 truncate">{file.name}</span>
<Download className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</a>
)
)}
</div>
) : null
})()}
{isOnlyEmoji(msg.content) ? (
<span className="text-4xl leading-none">{msg.content.trim()}</span>
) : (
msg.content
)}
</>
)}
</>
)
})()}
</div>
<span className="text-[10px] text-muted-foreground mt-1 px-1">{msg.timestamp}</span>
<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" />
})()
)}
</span>
</div>
</div>
)
})}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
</div>
{/* Input */}
<div className="p-4 border-t shrink-0 space-y-2">
{attachments.length > 0 && (
<div className="flex flex-wrap gap-2">
{attachments.map((file, i) => (
<div
key={i}
className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-1.5 text-sm max-w-[200px]"
>
<div key={i} className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-1.5 text-sm max-w-[200px]">
{isImageFile(file) ? (
<Image className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<File className="h-4 w-4 shrink-0 text-muted-foreground" />
<FileIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="truncate">{file.name}</span>
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(file.size)}</span>
<Button
variant="ghost" size="icon" className="h-5 w-5 -mr-1 shrink-0"
onClick={() => removeAttachment(i)}
>
<Button variant="ghost" size="icon" className="h-5 w-5 -mr-1 shrink-0" onClick={() => removeAttachment(i)}>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{/* Reply bar */}
{replyingTo && (
<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}
</p>
<p className="text-xs text-muted-foreground truncate">{replyingTo.content}</p>
</div>
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={() => setReplyingTo(null)}>
<X className="h-3 w-3" />
</Button>
</div>
)}
{editingMessage && (
<div className="flex items-center gap-2 rounded-lg bg-muted/50 px-3 py-2 text-sm">
<Pencil className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground">Editing message</span>
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0 ml-auto" onClick={() => { setEditingMessage(null); setMessageInput(""); setTimeout(() => autoResizeTextarea(), 0) }}>
<X className="h-3 w-3" />
</Button>
</div>
)}
<form onSubmit={handleSend} className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt"
className="hidden"
onChange={handleFileSelect}
/>
<Button
type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0"
onClick={() => fileInputRef.current?.click()}
>
<input ref={fileInputRef} type="file" multiple accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt" className="hidden" onChange={handleFileSelect} />
<Button type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0" onClick={() => fileInputRef.current?.click()}>
<Paperclip className="h-4 w-4 text-muted-foreground" />
</Button>
<div className="relative flex-1">
<Input
value={messageInput}
onChange={(e) => setMessageInput(e.target.value)}
placeholder="Type a message..."
className="h-9 w-full pr-9"
/>
<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" />
</Button>
{showEmojiPicker && (
<div className="absolute bottom-full right-0 mb-2 z-50">
<Picker
data={data}
onEmojiSelect={handleEmojiSelect}
theme={theme === "dark" ? "dark" : "light"}
previewPosition="none"
skinTonePosition="none"
set="native"
maxFrequentRows={2}
/>
{isRecording ? (
<div className="flex h-9 items-center gap-1 rounded-md border bg-muted/30 px-2">
<span className={cn("h-2 w-2 rounded-full", isPaused ? "bg-muted-foreground" : "bg-destructive animate-pulse")} />
<span className="text-sm tabular-nums font-medium text-muted-foreground">{formatDuration(recordingDuration)}</span>
<div className="flex-1" />
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-muted-foreground hover:text-muted-foreground" onClick={togglePauseRecording}>
{isPaused ? <Play className="h-3.5 w-3.5 fill-current ml-0.5" /> : <Pause className="h-3.5 w-3.5 fill-current" />}
</Button>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-destructive hover:text-destructive" onClick={discardRecording}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-destructive hover:text-destructive" onClick={stopRecording}>
<Square className="h-3.5 w-3.5 fill-current" />
</Button>
</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} />
<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" />
</Button>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={startRecording}>
<Mic className="h-4 w-4 text-muted-foreground" />
</Button>
{showEmojiPicker && (
<div className="absolute bottom-full right-0 mb-2 z-50">
<Picker data={data} onEmojiSelect={handleEmojiSelect} theme={theme === "dark" ? "dark" : "light"} previewPosition="none" skinTonePosition="none" set="native" emojiSize={36} maxFrequentRows={2} />
</div>
)}
</div>
)}
</div>
</>
)}
</div>
<Button type="submit" size="icon" className="h-9 w-9 shrink-0" disabled={!messageInput.trim() && attachments.length === 0}>
<Send className="h-4 w-4" />
{editingMessage ? <Pencil className="h-4 w-4" /> : <Send className="h-4 w-4" />}
</Button>
</form>
</div>
@@ -365,37 +727,95 @@ export default function ChatsPage() {
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Report {conversation ? otherParticipant(conversation).name : "User"}</DialogTitle>
<DialogDescription>
Let us know why you're reporting this conversation.
</DialogDescription>
<DialogDescription>Let us know why you're reporting this conversation.</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<Label htmlFor="reason">Reason</Label>
<Textarea
id="reason"
placeholder="Describe the issue..."
value={reportReason}
onChange={(e) => setReportReason(e.target.value)}
rows={4}
/>
<Textarea id="reason" placeholder="Describe the issue..." value={reportReason} onChange={(e) => setReportReason(e.target.value)} rows={4} />
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button
onClick={() => {
toast.success("Report submitted")
setReportReason("")
setReportDialogOpen(false)
}}
disabled={!reportReason.trim()}
>
Submit Report
</Button>
<DialogClose asChild><Button variant="outline">Cancel</Button></DialogClose>
<Button onClick={() => { toast.success("Report submitted"); setReportReason(""); setReportDialogOpen(false) }} disabled={!reportReason.trim()}>Submit Report</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Forward dialog */}
<Dialog open={forwardDialogOpen} onOpenChange={setForwardDialogOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Forward message</DialogTitle>
<DialogDescription>Select who to forward this message to.</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input value={forwardSearch} onChange={(e) => setForwardSearch(e.target.value)} placeholder="Search contacts..." className="h-9 pl-9" />
</div>
<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())
})
.map((conv) => {
const person = otherParticipant(conv)
return (
<button
key={conv.id}
type="button"
onClick={() => {
const msg = forwardMessage
if (!msg) 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")
}}
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>
</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>
</div>
</button>
)
})}
{forwardSearch && conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(forwardSearch.toLowerCase())).length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">No contacts found</p>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
</div>
)
}
+32 -17
View File
@@ -1,12 +1,13 @@
"use client"
import { useState } from "react"
import { useState, useEffect } 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 { dashboardStats } from "@/data/dashboard"
import { getDashboardStats } from "@/data/dashboard"
import {
Users,
UserPlus,
@@ -23,19 +24,26 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { DashboardStats } from "@/types"
export default function DashboardPage() {
const stats = dashboardStats
const [period, setPeriod] = useState("6months")
const [stats, setStats] = useState<DashboardStats | null>(null)
const statCards = [
{ title: "Total Leads", value: stats.totalLeads, icon: Users, variant: "blue" as const, description: "All time" },
{ 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` },
]
useEffect(() => {
setStats(getDashboardStats(period))
}, [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` },
]
: []
return (
<div className="space-y-6">
@@ -54,18 +62,25 @@ export default function DashboardPage() {
</Select>
</PageHeader>
<p className="text-xs font-semibold tracking-widest uppercase text-muted-foreground">Pipeline Overview</p>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{statCards.map((card, i) => (
<StatCard key={card.title} {...card} index={i} />
))}
{stats
? statCards.map((card, i) => (
<StatCard key={card.title} {...card} index={i} />
))
: Array.from({ length: 6 }).map((_, i) => <StatCardSkeleton key={i} />)
}
</div>
<p className="text-xs font-semibold tracking-widest uppercase text-muted-foreground">Analytics</p>
<div className="grid gap-6 lg:grid-cols-2">
<LeadStatusChart data={stats.statusDistribution} />
<LeadsPerMonthChart data={stats.leadsPerMonth} />
<LeadStatusChart data={stats?.statusDistribution ?? []} />
<LeadsPerMonthChart data={stats?.leadsPerMonth ?? []} />
</div>
<RecentLeadsTable leads={stats.recentLeads} />
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
</div>
)
}
+19 -2
View File
@@ -1,7 +1,24 @@
"use client"
import { AppShell } from "@/components/layout/app-shell"
import { UserProvider } from "@/providers/user-provider"
import { UserProvider, useUser } from "@/providers/user-provider"
import { Loader2 } from "lucide-react"
function DashboardContent({ children }: { children: React.ReactNode }) {
const { user, loading } = useUser()
if (loading) {
return (
<div className="flex h-screen items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
if (!user) return null
return <AppShell>{children}</AppShell>
}
export default function DashboardLayout({
children,
@@ -10,7 +27,7 @@ export default function DashboardLayout({
}) {
return (
<UserProvider>
<AppShell>{children}</AppShell>
<DashboardContent>{children}</DashboardContent>
</UserProvider>
)
}
+271
View File
@@ -0,0 +1,271 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Card, CardContent } from "@/components/ui/card"
import { ArrowLeft } from "lucide-react"
import { Lead, LeadStatus } from "@/types"
import { leads } from "@/data/leads"
import { users } from "@/data/users"
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
const leadFormSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
contactName: z.string().min(1, "Contact name is required"),
email: z.string().email("Invalid email address"),
phone: z.string().optional(),
source: z.string().optional(),
description: z.string().optional(),
status: z.string(),
assignedUserId: z.string().optional(),
})
type LeadFormValues = z.infer<typeof leadFormSchema>
export default function CreateLeadPage() {
const router = useRouter()
const [saving, setSaving] = useState(false)
const form = useForm<LeadFormValues>({
resolver: zodResolver(leadFormSchema),
defaultValues: {
companyName: "",
contactName: "",
email: "",
phone: "",
source: "",
description: "",
status: "open",
assignedUserId: "none",
},
})
function onSubmit(values: LeadFormValues) {
setSaving(true)
const now = new Date().toISOString()
const assignedUserId: string | null = values.assignedUserId === "none" ? null : (values.assignedUserId ?? null)
const assignedUser = assignedUserId ? users.find((u) => u.id === assignedUserId) ?? null : null
const newLead: Lead = {
id: `lead-${String(leads.length + 1).padStart(3, "0")}`,
companyName: values.companyName,
contactName: values.contactName,
email: values.email,
phone: values.phone ?? "",
source: values.source ?? "",
description: values.description ?? "",
status: values.status as LeadStatus,
assignedUserId,
assignedUser,
createdAt: now,
updatedAt: now,
}
leads.unshift(newLead)
router.push("/leads")
}
return (
<div className="space-y-6">
<Link
href="/leads"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Back to leads
</Link>
<div>
<h1 className="text-2xl font-bold tracking-tight">Create New Lead</h1>
<p className="mt-1 text-sm text-muted-foreground">Fill in the details to add a new lead.</p>
</div>
<Card>
<CardContent className="pt-6">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="companyName"
render={({ field }) => (
<FormItem>
<FormLabel>Company Name</FormLabel>
<FormControl>
<Input placeholder="Acme Corp" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contactName"
render={({ field }) => (
<FormItem>
<FormLabel>Contact Name</FormLabel>
<FormControl>
<Input placeholder="John Doe" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="john@acme.com" type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Phone</FormLabel>
<FormControl>
<Input placeholder="(555) 123-4567" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="source"
render={({ field }) => (
<FormItem>
<FormLabel>Source</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select source" />
</SelectTrigger>
</FormControl>
<SelectContent>
{LEAD_SOURCES.map((source) => (
<SelectItem key={source} value={source}>
{source}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(LEAD_STATUSES).map(([key, { label }]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Brief description of the lead and their requirements..."
className="min-h-[100px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="assignedUserId"
render={({ field }) => (
<FormItem>
<FormLabel>Assign To</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select user" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">Unassigned</SelectItem>
{users.filter((u) => u.active).map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.name} ({user.role})
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex items-center gap-3 pt-2">
<Button type="submit" disabled={saving}>
{saving ? "Saving..." : "Create Lead"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.push("/leads")}
>
Cancel
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
)
}
+12 -6
View File
@@ -1,20 +1,26 @@
"use client"
import { useState, useMemo } 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 { LeadFormDialog } from "@/components/leads/lead-form-dialog"
import { leads } from "@/data/leads"
import { filterLeadsByPeriod } from "@/lib/date-utils"
export default function LeadsPage() {
const router = useRouter()
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const [createOpen, setCreateOpen] = useState(false)
const [periodFilter, setPeriodFilter] = useState("all")
const filteredLeads = useMemo(() => {
let result = leads
if (periodFilter && periodFilter !== "all") {
result = filterLeadsByPeriod(result, periodFilter)
}
if (search) {
const q = search.toLowerCase()
result = result.filter(
@@ -31,7 +37,7 @@ export default function LeadsPage() {
}
return result
}, [search, statusFilter])
}, [search, statusFilter, periodFilter])
return (
<div className="space-y-4">
@@ -47,13 +53,13 @@ export default function LeadsPage() {
onSearchChange={setSearch}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onCreateClick={() => setCreateOpen(true)}
periodFilter={periodFilter}
onPeriodFilterChange={setPeriodFilter}
onCreateClick={() => router.push("/leads/new")}
/>
</div>
<LeadsTable data={filteredLeads} />
</div>
<LeadFormDialog open={createOpen} onOpenChange={setCreateOpen} />
</div>
)
}
+1
View File
@@ -11,6 +11,7 @@ import { toast } from "sonner"
export default function ProfilePage() {
const { user, updateAvatar } = useUser()
if (!user) return null
const fileInputRef = useRef<HTMLInputElement>(null)
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+58 -51
View File
@@ -1,22 +1,54 @@
"use client"
import { useState } from "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 { users } from "@/data/users"
import { Plus, Users as UsersIcon } from "lucide-react"
import { Input } from "@/components/ui/input"
import { Plus, Search, Users as UsersIcon } from "lucide-react"
import { User } from "@/types"
import { toast } from "sonner"
export default function UsersPage() {
const [createOpen, setCreateOpen] = useState(false)
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState("")
const fetchUsers = useCallback(async () => {
try {
const res = await fetch("/api/users")
const data = await res.json()
if (data.users) setUsers(data.users)
} catch {
toast.error("Failed to load users")
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchUsers()
}, [fetchUsers])
const filteredUsers = searchQuery.trim()
? users.filter((u) =>
u.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
u.email.toLowerCase().includes(searchQuery.toLowerCase())
)
: users
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" },
]
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
@@ -24,57 +56,32 @@ export default function UsersPage() {
</PageHeader>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-6">
<div 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 bg-blue-500/10">
<UsersIcon className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-2xl font-bold">{users.length}</p>
<p className="text-xs text-muted-foreground">Total Users</p>
{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}`}>
<UsersIcon className={`h-5 w-5 ${s.textColor}`} />
</div>
<div>
<p className="text-2xl font-bold">{s.value}</p>
<p className="text-xs text-muted-foreground">{s.label}</p>
</div>
</div>
</div>
</div>
<div 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 bg-emerald-500/10">
<UsersIcon className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<p className="text-2xl font-bold">{users.filter((u) => u.active).length}</p>
<p className="text-xs text-muted-foreground">Active</p>
</div>
</div>
</div>
<div 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 bg-purple-500/10">
<UsersIcon className="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<p className="text-2xl font-bold">{users.filter((u) => u.role === "admin").length}</p>
<p className="text-xs text-muted-foreground">Admins</p>
</div>
</div>
</div>
<div 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 bg-amber-500/10">
<UsersIcon className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<p className="text-2xl font-bold">{users.filter((u) => u.role === "sales").length}</p>
<p className="text-xs text-muted-foreground">Sales</p>
</div>
</div>
</div>
))}
</div>
<div className="rounded-lg border bg-card">
<UsersTable data={users} />
<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>
<UserFormDialog open={createOpen} onOpenChange={setCreateOpen} />
<UserFormDialog open={createOpen} onOpenChange={setCreateOpen} onUserCreated={fetchUsers} />
</div>
)
}
+120
View File
@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from "next/server"
import {
comparePassword,
getUserByEmail,
getUserByUsername,
mapDbUserToSessionUser,
recordLoginAttempt,
incrementFailedAttempts,
resetFailedAttempts,
isAccountLocked,
createSession,
} from "@/lib/auth"
export async function POST(request: NextRequest) {
try {
const { email, username, password } = await request.json()
const credential = email || username
if (!credential || !password) {
return NextResponse.json(
{ error: "Email/Username and password are required." },
{ status: 400 }
)
}
if (credential.trim().length === 0 || password.trim().length === 0) {
return NextResponse.json(
{ error: "Credentials cannot be empty." },
{ status: 400 }
)
}
const ipAddress =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"127.0.0.1"
const userAgent = request.headers.get("user-agent") || null
// Try to find user by email first, then by username
let dbUser =
email || credential.includes("@")
? await getUserByEmail(credential)
: null
if (!dbUser) {
dbUser = await getUserByUsername(credential)
}
if (!dbUser) {
await recordLoginAttempt(
null,
credential,
ipAddress,
userAgent,
false,
"User not found"
)
return NextResponse.json(
{ error: "Invalid email/username or password." },
{ status: 401 }
)
}
const lockStatus = await isAccountLocked(dbUser)
if (lockStatus.locked) {
await recordLoginAttempt(
dbUser.id,
credential,
ipAddress,
userAgent,
false,
lockStatus.reason
)
return NextResponse.json(
{ error: lockStatus.reason },
{ status: 423 }
)
}
const valid = await comparePassword(password, dbUser.password_hash)
if (!valid) {
await incrementFailedAttempts(dbUser.id)
await recordLoginAttempt(
dbUser.id,
credential,
ipAddress,
userAgent,
false,
"Invalid password"
)
return NextResponse.json(
{ error: "Invalid email/username or password." },
{ status: 401 }
)
}
await resetFailedAttempts(dbUser.id)
await recordLoginAttempt(
dbUser.id,
credential,
ipAddress,
userAgent,
true
)
await createSession(dbUser.id, dbUser.role_name)
const user = mapDbUserToSessionUser(dbUser)
return NextResponse.json({ user }, { status: 200 })
} catch (error) {
console.error("Login error:", error)
return NextResponse.json(
{ error: "Authentication service unavailable. Please ensure PostgreSQL is running and configured." },
{ status: 503 }
)
}
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server"
import { destroySession } from "@/lib/auth"
export async function POST() {
try {
await destroySession()
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
console.error("Logout error:", error)
return NextResponse.json(
{ error: "Logout failed." },
{ status: 500 }
)
}
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) {
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
}
return NextResponse.json({ user }, { status: 200 })
} catch (error) {
console.error("Auth me error:", error)
return NextResponse.json(
{ error: "Authentication service unavailable." },
{ status: 503 }
)
}
}
+24
View File
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { getSessionUser } from "@/lib/auth"
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const sessionUser = await getSessionUser()
if (!sessionUser) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
}
const { id } = await params
await query(
`UPDATE users SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL`,
[id]
)
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
console.error("Error deleting user:", error)
return NextResponse.json({ error: "Failed to delete user" }, { status: 500 })
}
}
+78
View File
@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { hashPassword, getSessionUser } from "@/lib/auth"
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,
r.name AS role
FROM users u
JOIN user_roles ur ON ur.user_id = u.id
JOIN roles r ON r.id = ur.role_id
WHERE u.deleted_at IS NULL
ORDER BY u.created_at DESC`
)
const users = result.rows.map((row: any) => ({
id: row.id,
name: `${row.first_name} ${row.last_name}`,
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`,
createdAt: row.created_at,
}))
return NextResponse.json({ users }, { status: 200 })
} catch (error) {
console.error("Error fetching users:", error)
return NextResponse.json({ error: "Failed to fetch users" }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const sessionUser = await getSessionUser()
if (!sessionUser) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
}
const { name, email, password, role, active } = await request.json()
if (!name || !email || !password || !role) {
return NextResponse.json({ error: "Name, email, password, and role are required" }, { status: 400 })
}
const nameParts = name.trim().split(/\s+/)
const firstName = nameParts[0]
const lastName = nameParts.slice(1).join(" ") || firstName
const username = email.split("@")[0]
const passwordHash = await hashPassword(password)
const result = await query(
`INSERT INTO users (username, email, password_hash, first_name, last_name, is_active, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
[username.toLowerCase(), email.toLowerCase(), passwordHash, firstName, lastName, active, sessionUser.id]
)
const roleId = (
await query(`SELECT id FROM roles WHERE LOWER(name) = LOWER($1)`, [role])
).rows[0]?.id
if (roleId) {
await query(
`INSERT INTO user_roles (user_id, role_id, assigned_by)
VALUES ($1, $2, $3)`,
[result.rows[0].id, roleId, sessionUser.id]
)
}
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
} catch (error: any) {
console.error("Error creating user:", error)
if (error?.constraint === "uq_users_username" || error?.constraint === "uq_users_email") {
return NextResponse.json({ error: "A user with this email or username already exists" }, { status: 409 })
}
return NextResponse.json({ error: error?.message || "Failed to create user" }, { status: 500 })
}
}
+7 -6
View File
@@ -63,11 +63,12 @@
--sidebar-ring: 224.3 76.3% 48%;
}
* {
@apply border-border;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
body {
@apply bg-background text-foreground;
font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
}
}
+71 -240
View File
@@ -1,191 +1,31 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { useState } 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"
import { Checkbox } from "@/components/ui/checkbox"
import { COMPANY_NAME } from "@/lib/constants"
import { Eye, EyeOff, Loader2 } from "lucide-react"
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)" },
]
export default function LoginPage() {
function LoginForm() {
const router = useRouter()
const [email, setEmail] = useState("admin@coastalit.com")
const searchParams = useSearchParams()
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)
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()
setError("")
setLoading(true)
await new Promise((resolve) => setTimeout(resolve, 1200))
router.push("/dashboard")
}
@@ -503,78 +343,69 @@ export default function LoginPage() {
Sign in to your account to continue
</p>
<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>
<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>
<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 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>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="login-checkbox"
<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"
/>
<span className="text-[13px] checkbox-text">
Remember me
</span>
</label>
<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>
<button type="submit" className="auth-btn" disabled={loading}>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{loading ? "Signing in..." : "Sign in"}
</button>
</form>
<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
</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>
<p className="text-center text-[11px] mt-4 footer-text">
By signing in, you agree to our{" "}
+1 -1
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation"
export default function RootPage() {
redirect("/login")
redirect("/dashboard")
}