From 07ffa75f67b4a0ac9099bfe2668dd51f3dc8968c Mon Sep 17 00:00:00 2001 From: sarahlolaa <81159694+sarahlolaa@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:15:20 +0000 Subject: [PATCH] feat: implement messaging interface for contracts (#145) - Add ConversationList component with search and unread badges - Add ChatWindow component with auto-scroll, optimistic sends, error/retry - Add MessageBubble component with date dividers and timestamps - Add MessageInput component with Shift+Enter multiline support - Add /api/conversations GET (list) and POST (create/upsert) routes - Add /api/messages GET (list) and POST (send) routes - Add app/dashboard/messages page with responsive mobile/desktop layout - Add Messages nav item to sidebar - Add scripts/012-messaging.sql DB migration for conversations, messages, and conversation_participants tables Closes #145 --- app/api/conversations/route.ts | 184 +++++++++++ app/api/messages/route.ts | 190 +++++++++++ app/dashboard/messages/page.tsx | 124 +++++++ .../dashboard/messaging/chat-window.tsx | 305 ++++++++++++++++++ .../dashboard/messaging/conversation-list.tsx | 192 +++++++++++ components/dashboard/messaging/index.ts | 4 + .../dashboard/messaging/message-bubble.tsx | 116 +++++++ .../dashboard/messaging/message-input.tsx | 96 ++++++ components/dashboard/sidebar.tsx | 6 + scripts/012-messaging.sql | 42 +++ 10 files changed, 1259 insertions(+) create mode 100644 app/api/conversations/route.ts create mode 100644 app/api/messages/route.ts create mode 100644 app/dashboard/messages/page.tsx create mode 100644 components/dashboard/messaging/chat-window.tsx create mode 100644 components/dashboard/messaging/conversation-list.tsx create mode 100644 components/dashboard/messaging/index.ts create mode 100644 components/dashboard/messaging/message-bubble.tsx create mode 100644 components/dashboard/messaging/message-input.tsx create mode 100644 scripts/012-messaging.sql diff --git a/app/api/conversations/route.ts b/app/api/conversations/route.ts new file mode 100644 index 0000000..2cd4c71 --- /dev/null +++ b/app/api/conversations/route.ts @@ -0,0 +1,184 @@ +export const dynamic = "force-dynamic"; + +import { NextRequest, NextResponse } from "next/server"; +import { withAuth, AuthContext, resolveUserIdByWallet } from "@/lib/auth/middleware"; +import { sql } from "@/lib/db"; + +// ─── GET /api/conversations ───────────────────────────────────────────────── + +/** + * Returns all conversations for the authenticated user (as client or freelancer), + * with the other party's info and the last message preview. + */ +export const GET = withAuth(async (_request: NextRequest, auth: AuthContext) => { + try { + const userId = await resolveUserIdByWallet(auth.walletAddress); + if (userId === null) { + return NextResponse.json( + { error: "User not found", code: "USER_NOT_FOUND" }, + { status: 404 } + ); + } + + const conversations = (await sql` + SELECT + c.id, + c.contract_id, + c.last_message, + c.last_message_at, + -- Contract title from jobs table via contracts + COALESCE(j.title, 'Contract #' || c.contract_id) AS contract_title, + -- Other party info + CASE + WHEN c.client_id = ${userId} THEN c.freelancer_id + ELSE c.client_id + END AS other_party_id, + CASE + WHEN c.client_id = ${userId} THEN fu.display_name + ELSE cu.display_name + END AS other_party_name, + CASE + WHEN c.client_id = ${userId} THEN fu.avatar_url + ELSE cu.avatar_url + END AS other_party_avatar, + -- Unread count: messages not sent by this user after their last_read_at + ( + SELECT COUNT(*)::int FROM messages m + WHERE m.conversation_id = c.id + AND m.sender_id != ${userId} + AND ( + cp.last_read_at IS NULL + OR m.created_at > cp.last_read_at + ) + ) AS unread_count + FROM conversations c + JOIN users cu ON cu.id = c.client_id + JOIN users fu ON fu.id = c.freelancer_id + LEFT JOIN contracts ct ON ct.id = c.contract_id + LEFT JOIN jobs j ON j.id = ct.job_id + LEFT JOIN conversation_participants cp + ON cp.conversation_id = c.id AND cp.user_id = ${userId} + WHERE c.client_id = ${userId} OR c.freelancer_id = ${userId} + ORDER BY COALESCE(c.last_message_at, c.created_at) DESC + `) as Array<{ + id: string; + contract_id: string; + contract_title: string; + last_message: string | null; + last_message_at: string | null; + other_party_id: number; + other_party_name: string | null; + other_party_avatar: string | null; + unread_count: number; + }>; + + return NextResponse.json( + { + conversations: conversations.map((c) => ({ + id: c.id, + contract_id: c.contract_id, + contract_title: c.contract_title, + last_message: c.last_message, + last_message_at: c.last_message_at, + other_party_id: String(c.other_party_id), + other_party_name: c.other_party_name ?? "Unknown", + other_party_avatar: c.other_party_avatar, + unread_count: Number(c.unread_count) || 0, + })), + }, + { status: 200, headers: { "Cache-Control": "private, no-store" } } + ); + } catch (err) { + console.error("[GET /api/conversations]", err); + return NextResponse.json( + { error: "Failed to load conversations", code: "CONVERSATIONS_FETCH_FAILED" }, + { status: 500 } + ); + } +}); + +// ─── POST /api/conversations ──────────────────────────────────────────────── + +/** + * Creates or retrieves an existing conversation for a contract. + * Body: { contract_id: string } + * + * A conversation is unique per contract (one per contract). + * Returns the conversation (existing or newly created). + */ +export const POST = withAuth(async (request: NextRequest, auth: AuthContext) => { + try { + const userId = await resolveUserIdByWallet(auth.walletAddress); + if (userId === null) { + return NextResponse.json( + { error: "User not found", code: "USER_NOT_FOUND" }, + { status: 404 } + ); + } + + const body = await request.json(); + const { contract_id } = body ?? {}; + + if (!contract_id || typeof contract_id !== "string") { + return NextResponse.json( + { error: "contract_id is required", code: "MISSING_PARAM" }, + { status: 400 } + ); + } + + // Fetch the contract to verify the user is a participant + const contractRows = (await sql` + SELECT id, client_id, freelancer_id FROM contracts + WHERE id = ${contract_id} + LIMIT 1 + `) as Array<{ id: string; client_id: number; freelancer_id: number | null }>; + + if (contractRows.length === 0) { + return NextResponse.json( + { error: "Contract not found", code: "CONTRACT_NOT_FOUND" }, + { status: 404 } + ); + } + + const contract = contractRows[0]; + const isParticipant = + contract.client_id === userId || contract.freelancer_id === userId; + + if (!isParticipant) { + return NextResponse.json( + { error: "You are not a participant of this contract", code: "FORBIDDEN" }, + { status: 403 } + ); + } + + if (!contract.freelancer_id) { + return NextResponse.json( + { error: "Cannot create a conversation until a freelancer is assigned", code: "NO_FREELANCER" }, + { status: 422 } + ); + } + + // Upsert: find existing conversation or create one + const existing = (await sql` + SELECT id FROM conversations WHERE contract_id = ${contract_id} LIMIT 1 + `) as Array<{ id: string }>; + + if (existing.length > 0) { + return NextResponse.json({ conversation_id: existing[0].id }, { status: 200 }); + } + + const created = (await sql` + INSERT INTO conversations (contract_id, client_id, freelancer_id, created_at) + VALUES (${contract_id}, ${contract.client_id}, ${contract.freelancer_id}, NOW()) + RETURNING id + `) as Array<{ id: string }>; + + return NextResponse.json({ conversation_id: created[0].id }, { status: 201 }); + } catch (err) { + console.error("[POST /api/conversations]", err); + return NextResponse.json( + { error: "Failed to create conversation", code: "CONVERSATION_CREATE_FAILED" }, + { status: 500 } + ); + } +}); diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts new file mode 100644 index 0000000..c4e303e --- /dev/null +++ b/app/api/messages/route.ts @@ -0,0 +1,190 @@ +export const dynamic = "force-dynamic"; + +import { NextRequest, NextResponse } from "next/server"; +import { withAuth, AuthContext, resolveUserIdByWallet } from "@/lib/auth/middleware"; +import { sql } from "@/lib/db"; + +// ─── GET /api/messages?conversation_id= ──────────────────────────────── + +/** + * Returns all messages in a conversation. + * The caller must be a participant of the conversation. + */ +export const GET = withAuth(async (request: NextRequest, auth: AuthContext) => { + try { + const userId = await resolveUserIdByWallet(auth.walletAddress); + if (userId === null) { + return NextResponse.json( + { error: "User not found", code: "USER_NOT_FOUND" }, + { status: 404 } + ); + } + + const conversationId = request.nextUrl.searchParams.get("conversation_id"); + if (!conversationId) { + return NextResponse.json( + { error: "conversation_id is required", code: "MISSING_PARAM" }, + { status: 400 } + ); + } + + // Verify the user is a participant in this conversation + const participation = (await sql` + SELECT id FROM conversations + WHERE id = ${conversationId} + AND (client_id = ${userId} OR freelancer_id = ${userId}) + LIMIT 1 + `) as Array<{ id: string }>; + + if (participation.length === 0) { + return NextResponse.json( + { error: "Conversation not found or access denied", code: "NOT_FOUND" }, + { status: 404 } + ); + } + + const messages = (await sql` + SELECT + m.id, + m.content, + m.sender_id, + m.created_at, + u.display_name AS sender_name, + u.avatar_url AS sender_avatar + FROM messages m + JOIN users u ON u.id = m.sender_id + WHERE m.conversation_id = ${conversationId} + ORDER BY m.created_at ASC + `) as Array<{ + id: string; + content: string; + sender_id: string; + created_at: string; + sender_name: string | null; + sender_avatar: string | null; + }>; + + return NextResponse.json( + { + messages: messages.map((m) => ({ + id: m.id, + content: m.content, + sender_id: String(m.sender_id), + created_at: m.created_at, + sender_name: m.sender_name ?? "Unknown", + sender_avatar: m.sender_avatar, + })), + }, + { status: 200, headers: { "Cache-Control": "private, no-store" } } + ); + } catch (err) { + console.error("[GET /api/messages]", err); + return NextResponse.json( + { error: "Failed to load messages", code: "MESSAGES_FETCH_FAILED" }, + { status: 500 } + ); + } +}); + +// ─── POST /api/messages ──────────────────────────────────────────────────── + +/** + * Sends a new message in a conversation. + * Body: { conversation_id: string; content: string } + */ +export const POST = withAuth(async (request: NextRequest, auth: AuthContext) => { + try { + const userId = await resolveUserIdByWallet(auth.walletAddress); + if (userId === null) { + return NextResponse.json( + { error: "User not found", code: "USER_NOT_FOUND" }, + { status: 404 } + ); + } + + const body = await request.json(); + const { conversation_id, content } = body ?? {}; + + if (!conversation_id || typeof conversation_id !== "string") { + return NextResponse.json( + { error: "conversation_id is required", code: "MISSING_PARAM" }, + { status: 400 } + ); + } + + const trimmed = typeof content === "string" ? content.trim() : ""; + if (!trimmed) { + return NextResponse.json( + { error: "content must not be empty", code: "INVALID_CONTENT" }, + { status: 400 } + ); + } + + if (trimmed.length > 4000) { + return NextResponse.json( + { error: "content exceeds maximum length of 4000 characters", code: "CONTENT_TOO_LONG" }, + { status: 400 } + ); + } + + // Verify participant + const participation = (await sql` + SELECT id FROM conversations + WHERE id = ${conversation_id} + AND (client_id = ${userId} OR freelancer_id = ${userId}) + LIMIT 1 + `) as Array<{ id: string }>; + + if (participation.length === 0) { + return NextResponse.json( + { error: "Conversation not found or access denied", code: "NOT_FOUND" }, + { status: 404 } + ); + } + + // Insert message + const inserted = (await sql` + INSERT INTO messages (conversation_id, sender_id, content, created_at) + VALUES (${conversation_id}, ${userId}, ${trimmed}, NOW()) + RETURNING id, conversation_id, sender_id, content, created_at + `) as Array<{ + id: string; + conversation_id: string; + sender_id: string; + content: string; + created_at: string; + }>; + + // Update last_message_at on conversation + await sql` + UPDATE conversations + SET last_message_at = NOW(), last_message = ${trimmed.slice(0, 200)} + WHERE id = ${conversation_id} + `; + + const user = (await sql` + SELECT display_name, avatar_url FROM users WHERE id = ${userId} LIMIT 1 + `) as Array<{ display_name: string | null; avatar_url: string | null }>; + + const msg = inserted[0]; + return NextResponse.json( + { + message: { + id: msg.id, + content: msg.content, + sender_id: String(msg.sender_id), + created_at: msg.created_at, + sender_name: user[0]?.display_name ?? "Unknown", + sender_avatar: user[0]?.avatar_url ?? null, + }, + }, + { status: 201 } + ); + } catch (err) { + console.error("[POST /api/messages]", err); + return NextResponse.json( + { error: "Failed to send message", code: "MESSAGE_SEND_FAILED" }, + { status: 500 } + ); + } +}); diff --git a/app/dashboard/messages/page.tsx b/app/dashboard/messages/page.tsx new file mode 100644 index 0000000..0ed3af1 --- /dev/null +++ b/app/dashboard/messages/page.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { ConversationList, type Conversation } from "@/components/dashboard/messaging/conversation-list"; +import { ChatWindow } from "@/components/dashboard/messaging/chat-window"; +import { cn } from "@/lib/utils"; + +function getAuthHeaders(): Record { + const token = + typeof window !== "undefined" + ? localStorage.getItem("tc_dev_access_token") + : null; + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +function getCurrentUserId(): string | null { + if (typeof window === "undefined") return null; + // Decode JWT payload to extract sub (user id) without a library + try { + const token = localStorage.getItem("tc_dev_access_token"); + if (!token) return null; + const payload = token.split(".")[1]; + const decoded = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/"))); + // The JWT uses walletAddress as the subject; we resolve user id server-side. + // For message ownership matching we compare sender_id (integer) vs this. + // The API returns sender_id as a string so we use walletAddress for now. + return decoded.sub ?? decoded.walletAddress ?? null; + } catch { + return null; + } +} + +export default function MessagingPage() { + const [conversations, setConversations] = useState([]); + const [loadingConversations, setLoadingConversations] = useState(true); + const [selectedConversation, setSelectedConversation] = useState(null); + const [currentUserId, setCurrentUserId] = useState(null); + // Mobile: show chat panel or list panel + const [mobileView, setMobileView] = useState<"list" | "chat">("list"); + + useEffect(() => { + setCurrentUserId(getCurrentUserId()); + }, []); + + const fetchConversations = useCallback(async () => { + setLoadingConversations(true); + try { + const res = await fetch("/api/conversations", { + headers: getAuthHeaders(), + credentials: "include", + }); + if (!res.ok) return; + const data = await res.json(); + setConversations(data.conversations ?? []); + } finally { + setLoadingConversations(false); + } + }, []); + + useEffect(() => { + fetchConversations(); + }, [fetchConversations]); + + const handleSelectConversation = (conversation: Conversation) => { + setSelectedConversation(conversation); + setMobileView("chat"); + }; + + const handleBack = () => { + setMobileView("list"); + // Refresh conversations to update unread counts + fetchConversations(); + }; + + return ( +
+
+ {/* Page header */} +
+

Messages

+

+ Communicate with your clients and freelancers within contracts. +

+
+ + {/* Chat layout */} +
+
+ {/* Conversation list — hidden on mobile when chat is open */} + + + {/* Chat window — hidden on mobile when list is shown */} +
+ +
+
+
+
+
+ ); +} diff --git a/components/dashboard/messaging/chat-window.tsx b/components/dashboard/messaging/chat-window.tsx new file mode 100644 index 0000000..f2b82e2 --- /dev/null +++ b/components/dashboard/messaging/chat-window.tsx @@ -0,0 +1,305 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { ArrowLeft, Loader2, MessageSquare, AlertCircle, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { MessageBubble, MessageDateDivider, type Message } from "./message-bubble"; +import { MessageInput } from "./message-input"; +import type { Conversation } from "./conversation-list"; +import { cn } from "@/lib/utils"; + +interface ChatWindowProps { + conversation: Conversation | null; + currentUserId: string | null; + onBack?: () => void; + /** Whether this is shown inside the mobile overlay (shows back button) */ + isMobileView?: boolean; +} + +function getAuthHeaders(): Record { + const token = + typeof window !== "undefined" + ? localStorage.getItem("tc_dev_access_token") + : null; + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +function groupMessagesByDate(messages: Message[]): Array<{ date: string; messages: Message[] }> { + const groups: Map = new Map(); + for (const msg of messages) { + const date = new Date(msg.created_at).toDateString(); + if (!groups.has(date)) groups.set(date, []); + groups.get(date)!.push(msg); + } + return Array.from(groups.entries()).map(([date, msgs]) => ({ + date: msgs[0].created_at, + messages: msgs, + })); +} + +export function ChatWindow({ + conversation, + currentUserId, + onBack, + isMobileView = false, +}: ChatWindowProps) { + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [sendError, setSendError] = useState(null); + const bottomRef = useRef(null); + const scrollAreaRef = useRef(null); + + // Scroll to bottom whenever messages change + const scrollToBottom = (behavior: ScrollBehavior = "smooth") => { + bottomRef.current?.scrollIntoView({ behavior }); + }; + + // Load messages for the selected conversation + useEffect(() => { + if (!conversation) { + setMessages([]); + return; + } + + setLoading(true); + setError(null); + + const loadMessages = async () => { + try { + const res = await fetch( + `/api/messages?conversation_id=${conversation.id}`, + { headers: getAuthHeaders(), credentials: "include" } + ); + if (!res.ok) { + setError("Failed to load messages."); + return; + } + const data = await res.json(); + const loaded: Message[] = (data.messages ?? []).map((m: Message) => ({ + ...m, + is_own: m.sender_id === currentUserId, + })); + setMessages(loaded); + } catch { + setError("Could not connect to the server."); + } finally { + setLoading(false); + } + }; + + loadMessages(); + }, [conversation?.id, currentUserId]); + + // Auto-scroll on first load (instant) and on new messages (smooth) + useEffect(() => { + if (messages.length > 0) { + scrollToBottom(loading ? "instant" : "smooth"); + } + }, [messages, loading]); + + const handleSend = async (content: string) => { + if (!conversation || !currentUserId) return; + setSendError(null); + + // Optimistic update + const tempMessage: Message = { + id: `temp-${Date.now()}`, + content, + sender_id: currentUserId, + sender_name: "You", + created_at: new Date().toISOString(), + is_own: true, + }; + setMessages((prev) => [...prev, tempMessage]); + scrollToBottom("smooth"); + + try { + const res = await fetch("/api/messages", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...getAuthHeaders(), + }, + credentials: "include", + body: JSON.stringify({ + conversation_id: conversation.id, + content, + }), + }); + + if (!res.ok) { + // Roll back optimistic message + setMessages((prev) => prev.filter((m) => m.id !== tempMessage.id)); + setSendError("Failed to send message. Please try again."); + return; + } + + const data = await res.json(); + // Replace temp message with real one + setMessages((prev) => + prev.map((m) => + m.id === tempMessage.id + ? { ...data.message, is_own: true } + : m + ) + ); + } catch { + setMessages((prev) => prev.filter((m) => m.id !== tempMessage.id)); + setSendError("Network error. Please try again."); + } + }; + + // Empty state — no conversation selected + if (!conversation) { + return ( +
+
+ +
+
+

Select a conversation

+

+ Choose a conversation from the left to start messaging. +

+
+
+ ); + } + + return ( +
+ {/* Chat Header */} +
+ {(isMobileView || onBack) && ( + + )} + {/* Avatar */} +
+ {conversation.other_party_avatar ? ( + {conversation.other_party_name} + ) : ( +
+ + {conversation.other_party_name + .split(" ") + .map((n) => n[0]) + .slice(0, 2) + .join("") + .toUpperCase()} + +
+ )} +
+
+

+ {conversation.other_party_name} +

+

+ {conversation.contract_title} +

+
+
+ + {/* Messages Area */} +
+ {loading ? ( +
+ + Loading messages… +
+ ) : error ? ( +
+ +

{error}

+ +
+ ) : messages.length === 0 ? ( +
+
+ +
+
+

No messages yet

+

+ Start the conversation by sending a message below. +

+
+
+ ) : ( + <> + {groupMessagesByDate(messages).map((group) => ( +
+ +
+ {group.messages.map((message) => ( + + ))} +
+
+ ))} + + )} +
+
+ + {/* Send error */} + {sendError && ( +
+ + {sendError} +
+ )} + + {/* Message Input */} + +
+ ); +} diff --git a/components/dashboard/messaging/conversation-list.tsx b/components/dashboard/messaging/conversation-list.tsx new file mode 100644 index 0000000..d9dfc64 --- /dev/null +++ b/components/dashboard/messaging/conversation-list.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { MessageSquare, Loader2, Search } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useState } from "react"; +import { Input } from "@/components/ui/input"; + +export interface Conversation { + id: string; + contract_id: string; + contract_title: string; + other_party_name: string; + other_party_avatar?: string | null; + last_message: string | null; + last_message_at: string | null; + unread_count: number; +} + +interface ConversationListProps { + conversations: Conversation[]; + selectedId: string | null; + onSelect: (conversation: Conversation) => void; + loading?: boolean; +} + +function formatLastMessageTime(iso: string | null): string { + if (!iso) return ""; + const date = new Date(iso); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return "just now"; + if (diffMins < 60) return `${diffMins}m`; + if (diffHours < 24) return `${diffHours}h`; + if (diffDays < 7) return `${diffDays}d`; + return date.toLocaleDateString([], { month: "short", day: "numeric" }); +} + +function getInitials(name: string): string { + return name + .split(" ") + .map((n) => n[0]) + .slice(0, 2) + .join("") + .toUpperCase(); +} + +export function ConversationList({ + conversations, + selectedId, + onSelect, + loading = false, +}: ConversationListProps) { + const [query, setQuery] = useState(""); + + const filtered = conversations.filter( + (c) => + c.contract_title.toLowerCase().includes(query.toLowerCase()) || + c.other_party_name.toLowerCase().includes(query.toLowerCase()) + ); + + return ( +
+ {/* Header */} +
+

+ + Messages +

+
+ + setQuery(e.target.value)} + placeholder="Search conversations…" + className="pl-9 h-9 bg-card/50 border-border/40 text-sm" + /> +
+
+ + {/* List */} +
+ {loading ? ( +
+ + Loading conversations… +
+ ) : filtered.length === 0 ? ( +
+
+ +
+

+ {query + ? "No conversations match your search." + : "No conversations yet. Messages linked to your contracts will appear here."} +

+
+ ) : ( +
    + {filtered.map((conversation) => { + const isSelected = conversation.id === selectedId; + const initials = getInitials(conversation.other_party_name); + + return ( +
  • + +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/components/dashboard/messaging/index.ts b/components/dashboard/messaging/index.ts new file mode 100644 index 0000000..a98e503 --- /dev/null +++ b/components/dashboard/messaging/index.ts @@ -0,0 +1,4 @@ +export { MessageBubble, MessageDateDivider, type Message } from "./message-bubble"; +export { MessageInput } from "./message-input"; +export { ConversationList, type Conversation } from "./conversation-list"; +export { ChatWindow } from "./chat-window"; diff --git a/components/dashboard/messaging/message-bubble.tsx b/components/dashboard/messaging/message-bubble.tsx new file mode 100644 index 0000000..2fed44c --- /dev/null +++ b/components/dashboard/messaging/message-bubble.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +export interface Message { + id: string; + content: string; + sender_id: string; + sender_name: string; + sender_avatar?: string | null; + created_at: string; + is_own?: boolean; +} + +interface MessageBubbleProps { + message: Message; +} + +function formatTime(iso: string): string { + const date = new Date(iso); + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +function formatDate(iso: string): string { + const date = new Date(iso); + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + if (date.toDateString() === today.toDateString()) return "Today"; + if (date.toDateString() === yesterday.toDateString()) return "Yesterday"; + return date.toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" }); +} + +export function MessageDateDivider({ date }: { date: string }) { + return ( +
+
+ + {formatDate(date)} + +
+
+ ); +} + +export function MessageBubble({ message }: MessageBubbleProps) { + const isOwn = message.is_own ?? false; + const initials = message.sender_name + .split(" ") + .map((n) => n[0]) + .slice(0, 2) + .join("") + .toUpperCase(); + + return ( +
+ {/* Avatar */} + {!isOwn && ( +
+ {message.sender_avatar ? ( + {message.sender_name} + ) : ( +
+ {initials} +
+ )} +
+ )} + + {/* Bubble */} +
+ {!isOwn && ( + + {message.sender_name} + + )} +
+ {message.content} +
+ + {formatTime(message.created_at)} + +
+ + {/* Own avatar placeholder for alignment */} + {isOwn &&
} +
+ ); +} diff --git a/components/dashboard/messaging/message-input.tsx b/components/dashboard/messaging/message-input.tsx new file mode 100644 index 0000000..adfa75a --- /dev/null +++ b/components/dashboard/messaging/message-input.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useState, useRef, KeyboardEvent } from "react"; +import { Send, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; + +interface MessageInputProps { + onSend: (content: string) => Promise; + disabled?: boolean; + placeholder?: string; +} + +export function MessageInput({ + onSend, + disabled = false, + placeholder = "Type a message…", +}: MessageInputProps) { + const [content, setContent] = useState(""); + const [sending, setSending] = useState(false); + const textareaRef = useRef(null); + + const handleSend = async () => { + const trimmed = content.trim(); + if (!trimmed || sending || disabled) return; + + setSending(true); + try { + await onSend(trimmed); + setContent(""); + // Reset textarea height + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + } + } finally { + setSending(false); + textareaRef.current?.focus(); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + // Cmd/Ctrl+Enter or Enter (without Shift) sends + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const handleInput = () => { + const el = textareaRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, 160)}px`; + }; + + const canSend = content.trim().length > 0 && !sending && !disabled; + + return ( +
+
+