Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link"
|
||||
import { motion } from "framer-motion"
|
||||
import { use } from "react"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { LeadDetailsCard } from "@/components/leads/lead-details-card"
|
||||
import { LeadStatusBadge } from "@/components/leads/lead-status-badge"
|
||||
@@ -19,8 +20,9 @@ import { getLeadById } from "@/data/leads"
|
||||
import { getNotesByLeadId } from "@/data/notes"
|
||||
import { ArrowLeft, Edit, ExternalLink } from "lucide-react"
|
||||
|
||||
export default function LeadDetailsPage({ params }: { params: { id: string } }) {
|
||||
const lead = getLeadById(params.id)
|
||||
export default function LeadDetailsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const lead = getLeadById(id)
|
||||
const leadNotes = lead ? getNotesByLeadId(lead.id) : []
|
||||
|
||||
if (!lead) {
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -68,8 +68,11 @@
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+87
-72
@@ -1,32 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Suspense, useState } from "react"
|
||||
import { useRouter, useSearchParams } 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"
|
||||
import { Eye, EyeOff, Loader2, AlertCircle } from "lucide-react"
|
||||
|
||||
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 [error, setError] = useState("")
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
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")
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Authentication failed.")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const redirect = searchParams.get("redirect") || "/dashboard"
|
||||
router.push(redirect)
|
||||
} catch {
|
||||
setError("Unable to connect to authentication service. Please ensure the database is running.")
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</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 className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<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"
|
||||
/>
|
||||
<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="w-full" disabled={loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{loading ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Left - Brand panel */}
|
||||
@@ -110,69 +185,9 @@ export default function LoginPage() {
|
||||
</p>
|
||||
</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 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>
|
||||
<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"
|
||||
/>
|
||||
<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
|
||||
</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>
|
||||
<Suspense fallback={<div className="text-center text-muted-foreground py-8">Loading...</div>}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
By signing in, you agree to our{" "}
|
||||
|
||||
@@ -42,6 +42,7 @@ interface SidebarProps {
|
||||
export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: SidebarProps) {
|
||||
const pathname = usePathname()
|
||||
const { user } = useUser()
|
||||
if (!user) return null
|
||||
const initials = user.name.split(" ").map((n) => n[0]).join("")
|
||||
|
||||
const sidebarContent = (
|
||||
|
||||
@@ -35,12 +35,13 @@ interface TopbarProps {
|
||||
export function Topbar({ onMenuClick }: TopbarProps) {
|
||||
const router = useRouter()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const { user } = useUser()
|
||||
const { user, logout } = useUser()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
const initials = user.name.split(" ").map((n) => n[0]).join("")
|
||||
if (!user) return null
|
||||
const initials = (user.firstName[0] + user.lastName[0]).toUpperCase()
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-background px-4 lg:px-6">
|
||||
@@ -118,13 +119,13 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
||||
<AvatarImage src={user.avatar} />
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden text-sm font-medium md:inline-block">{user.name}</span>
|
||||
<span className="hidden text-sm font-medium md:inline-block">{user.firstName} {user.lastName}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium">{user.name}</p>
|
||||
<p className="text-sm font-medium">{user.firstName} {user.lastName}</p>
|
||||
<p className="text-xs text-muted-foreground">{user.email}</p>
|
||||
<Badge variant="secondary" className="mt-1 w-fit text-xs capitalize">{user.role}</Badge>
|
||||
</div>
|
||||
@@ -139,7 +140,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive">
|
||||
<DropdownMenuItem className="text-destructive" onClick={logout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -14,7 +14,7 @@ const ScrollArea = React.forwardRef<
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollBar forceMount />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
@@ -28,14 +28,14 @@ const ScrollBar = React.forwardRef<
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
"flex touch-none select-none transition-opacity",
|
||||
orientation === "vertical" && "h-full w-3 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-muted-foreground/30" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
@@ -75,8 +75,6 @@ export const users: User[] = [
|
||||
},
|
||||
]
|
||||
|
||||
export const currentUser: User = users[0]
|
||||
|
||||
export function getUserById(id: string): User | undefined {
|
||||
return users.find((u) => u.id === id)
|
||||
}
|
||||
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
import { SignJWT, jwtVerify } from "jose"
|
||||
import bcrypt from "bcryptjs"
|
||||
import { query } from "@/lib/db"
|
||||
import { cookies } from "next/headers"
|
||||
|
||||
const JWT_SECRET = new TextEncoder().encode(
|
||||
process.env.JWT_SECRET || "fallback-dev-secret-do-not-use-in-production"
|
||||
)
|
||||
|
||||
const SESSION_COOKIE = "session"
|
||||
const MAX_FAILED_ATTEMPTS = 5
|
||||
const LOCKOUT_DURATION_MINUTES = 15
|
||||
|
||||
export interface SessionUser {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
role: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, 12)
|
||||
}
|
||||
|
||||
export async function comparePassword(
|
||||
password: string,
|
||||
hash: string
|
||||
): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash)
|
||||
}
|
||||
|
||||
export async function signToken(payload: { userId: string; role: string }) {
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("24h")
|
||||
.setIssuedAt()
|
||||
.sign(JWT_SECRET)
|
||||
}
|
||||
|
||||
export async function verifyToken(token: string) {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWT_SECRET)
|
||||
return payload as { userId: string; role: string }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserByEmail(email: string) {
|
||||
const result = await query(
|
||||
`SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name,
|
||||
u.is_active, u.is_locked, u.failed_login_attempts, u.locked_until,
|
||||
u.password_change_required,
|
||||
r.name AS role_name
|
||||
FROM users u
|
||||
JOIN user_roles ur ON ur.user_id = u.id
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
WHERE u.email = $1 AND u.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[email.toLowerCase().trim()]
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function getUserByUsername(username: string) {
|
||||
const result = await query(
|
||||
`SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name,
|
||||
u.is_active, u.is_locked, u.failed_login_attempts, u.locked_until,
|
||||
u.password_change_required,
|
||||
r.name AS role_name
|
||||
FROM users u
|
||||
JOIN user_roles ur ON ur.user_id = u.id
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
WHERE u.username = $1 AND u.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[username.toLowerCase().trim()]
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function getUserById(id: string) {
|
||||
const result = await query(
|
||||
`SELECT u.id, u.username, u.email, u.first_name, u.last_name,
|
||||
u.is_active,
|
||||
r.name AS role_name
|
||||
FROM users u
|
||||
JOIN user_roles ur ON ur.user_id = u.id
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
WHERE u.id = $1 AND u.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[id]
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function recordLoginAttempt(
|
||||
userId: string | null,
|
||||
usernameAttempted: string,
|
||||
ipAddress: string,
|
||||
userAgent: string | null,
|
||||
wasSuccessful: boolean,
|
||||
failureReason?: string
|
||||
) {
|
||||
await query(
|
||||
`INSERT INTO login_attempts (user_id, username_attempted, ip_address, user_agent, was_successful, failure_reason)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[userId, usernameAttempted, ipAddress, userAgent, wasSuccessful, failureReason || null]
|
||||
)
|
||||
}
|
||||
|
||||
export async function incrementFailedAttempts(userId: string) {
|
||||
await query(
|
||||
`UPDATE users
|
||||
SET failed_login_attempts = failed_login_attempts + 1,
|
||||
locked_until = CASE
|
||||
WHEN failed_login_attempts + 1 >= $2
|
||||
THEN NOW() + (INTERVAL '1 minute' * $3)
|
||||
ELSE locked_until
|
||||
END
|
||||
WHERE id = $1`,
|
||||
[userId, MAX_FAILED_ATTEMPTS, LOCKOUT_DURATION_MINUTES]
|
||||
)
|
||||
}
|
||||
|
||||
export async function resetFailedAttempts(userId: string) {
|
||||
await query(
|
||||
`UPDATE users
|
||||
SET failed_login_attempts = 0,
|
||||
locked_until = NULL,
|
||||
last_login_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[userId]
|
||||
)
|
||||
}
|
||||
|
||||
export async function isAccountLocked(user: {
|
||||
is_locked: boolean
|
||||
locked_until: Date | null
|
||||
}): Promise<{ locked: boolean; reason?: string }> {
|
||||
if (user.is_locked) {
|
||||
return { locked: true, reason: "Account has been locked by an administrator." }
|
||||
}
|
||||
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
||||
const minutes = Math.ceil(
|
||||
(new Date(user.locked_until).getTime() - Date.now()) / 60000
|
||||
)
|
||||
return {
|
||||
locked: true,
|
||||
reason: `Account is temporarily locked due to too many failed attempts. Try again in ${minutes} minute(s).`,
|
||||
}
|
||||
}
|
||||
return { locked: false }
|
||||
}
|
||||
|
||||
export function mapDbUserToSessionUser(dbUser: Record<string, unknown>): SessionUser {
|
||||
const roleName = dbUser.role_name as string
|
||||
const mappedRole =
|
||||
roleName === "SUPER_ADMIN" || roleName === "ADMIN" ? "admin" : "sales"
|
||||
|
||||
return {
|
||||
id: dbUser.id as string,
|
||||
username: dbUser.username as string,
|
||||
email: dbUser.email as string,
|
||||
firstName: dbUser.first_name as string,
|
||||
lastName: dbUser.last_name as string,
|
||||
role: mappedRole,
|
||||
avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(
|
||||
`${dbUser.first_name}+${dbUser.last_name}`
|
||||
)}&background=1d4ed8&color=fff&size=128`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSession(userId: string, role: string) {
|
||||
const token = await signToken({ userId, role })
|
||||
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.set(SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
})
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
export async function destroySession() {
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.set(SESSION_COOKIE, "", {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getSessionUser(): Promise<SessionUser | null> {
|
||||
try {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get(SESSION_COOKIE)?.value
|
||||
if (!token) return null
|
||||
|
||||
const payload = await verifyToken(token)
|
||||
if (!payload) return null
|
||||
|
||||
const dbUser = await getUserById(payload.userId)
|
||||
if (!dbUser) return null
|
||||
|
||||
return mapDbUserToSessionUser(dbUser)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Pool } from "pg"
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
})
|
||||
|
||||
pool.on("error", (err) => {
|
||||
console.error("Unexpected database pool error:", err)
|
||||
})
|
||||
|
||||
export async function query(text: string, params?: unknown[]) {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
const result = await client.query(text, params)
|
||||
return result
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
export default pool
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import type { NextRequest } from "next/server"
|
||||
import { jwtVerify } from "jose"
|
||||
|
||||
const JWT_SECRET = new TextEncoder().encode(
|
||||
process.env.JWT_SECRET || "fallback-dev-secret-do-not-use-in-production"
|
||||
)
|
||||
|
||||
const publicRoutes = [
|
||||
"/login",
|
||||
"/api/auth/login",
|
||||
"/api/auth/logout",
|
||||
"/_next/static",
|
||||
"/_next/image",
|
||||
"/favicon.ico",
|
||||
"/logo",
|
||||
"/fonts",
|
||||
]
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl
|
||||
|
||||
const isPublic = publicRoutes.some(
|
||||
(route) => pathname === route || pathname.startsWith(route + "/") || pathname.startsWith(route)
|
||||
)
|
||||
|
||||
if (pathname === "/api/auth/me") {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
if (isPublic) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
const sessionCookie = request.cookies.get("session")?.value
|
||||
|
||||
if (!sessionCookie) {
|
||||
const loginUrl = new URL("/login", request.url)
|
||||
loginUrl.searchParams.set("redirect", pathname)
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
try {
|
||||
await jwtVerify(sessionCookie, JWT_SECRET)
|
||||
return NextResponse.next()
|
||||
} catch {
|
||||
const loginUrl = new URL("/login", request.url)
|
||||
loginUrl.searchParams.set("redirect", pathname)
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|logo|fonts).*)"],
|
||||
}
|
||||
@@ -1,23 +1,70 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext, useState, ReactNode } from "react"
|
||||
import { currentUser } from "@/data/users"
|
||||
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import type { User } from "@/types"
|
||||
|
||||
interface UserContextValue {
|
||||
user: User
|
||||
user: User | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
logout: () => Promise<void>
|
||||
updateAvatar: (url: string) => void
|
||||
}
|
||||
|
||||
const UserContext = createContext<UserContextValue | null>(null)
|
||||
|
||||
export function UserProvider({ children }: { children: ReactNode }) {
|
||||
const [avatar, setAvatar] = useState(currentUser.avatar)
|
||||
const router = useRouter()
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const user: User = { ...currentUser, avatar }
|
||||
const fetchUser = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/me")
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const u = data.user
|
||||
setUser({
|
||||
id: u.id,
|
||||
name: `${u.firstName} ${u.lastName}`,
|
||||
email: u.email,
|
||||
role: u.role,
|
||||
active: true,
|
||||
avatar: u.avatar,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
} else {
|
||||
setUser(null)
|
||||
}
|
||||
} catch {
|
||||
setUser(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser()
|
||||
}, [fetchUser])
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await fetch("/api/auth/logout", { method: "POST" })
|
||||
} catch {
|
||||
// Proceed with client-side logout even if API fails
|
||||
}
|
||||
setUser(null)
|
||||
router.push("/login")
|
||||
}, [router])
|
||||
|
||||
const updateAvatar = useCallback((url: string) => {
|
||||
setUser((prev) => (prev ? { ...prev, avatar: url } : prev))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={{ user, updateAvatar: setAvatar }}>
|
||||
<UserContext.Provider value={{ user, loading, error, logout, updateAvatar }}>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user