From 1ed0265d2c5dfcee086bf6171684a9019806a7b7 Mon Sep 17 00:00:00 2001 From: TaskChain Dev Date: Tue, 28 Jul 2026 07:44:39 +0100 Subject: [PATCH 1/4] feat: add responsive in-app messaging interface for contracts --- app/api/messages/[contractId]/route.ts | 62 +++++++ app/api/messages/route.ts | 16 ++ app/dashboard/contracts/[id]/page.tsx | 16 +- app/dashboard/messages/page.tsx | 37 ++++ components/chat/chat-empty-state.tsx | 68 ++++++++ components/chat/chat-input.tsx | 114 ++++++++++++ components/chat/chat-layout.tsx | 198 +++++++++++++++++++++ components/chat/chat-message-item.tsx | 134 +++++++++++++++ components/chat/chat-message-thread.tsx | 171 ++++++++++++++++++ components/chat/chat-skeleton.tsx | 49 ++++++ components/chat/conversation-list.tsx | 186 ++++++++++++++++++++ components/dashboard/sidebar.tsx | 6 + lib/mock-chat.ts | 219 ++++++++++++++++++++++++ types/chat.ts | 49 ++++++ 14 files changed, 1321 insertions(+), 4 deletions(-) create mode 100644 app/api/messages/[contractId]/route.ts create mode 100644 app/api/messages/route.ts create mode 100644 app/dashboard/messages/page.tsx create mode 100644 components/chat/chat-empty-state.tsx create mode 100644 components/chat/chat-input.tsx create mode 100644 components/chat/chat-layout.tsx create mode 100644 components/chat/chat-message-item.tsx create mode 100644 components/chat/chat-message-thread.tsx create mode 100644 components/chat/chat-skeleton.tsx create mode 100644 components/chat/conversation-list.tsx create mode 100644 lib/mock-chat.ts create mode 100644 types/chat.ts diff --git a/app/api/messages/[contractId]/route.ts b/app/api/messages/[contractId]/route.ts new file mode 100644 index 0000000..641ea33 --- /dev/null +++ b/app/api/messages/[contractId]/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { INITIAL_MESSAGES } from '@/lib/mock-chat'; +import { ChatMessage } from '@/types/chat'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ contractId: string }> } +) { + const { contractId } = await params; + + const messages = INITIAL_MESSAGES[contractId] || []; + return NextResponse.json({ + success: true, + contractId, + messages, + }); +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ contractId: string }> } +) { + try { + const { contractId } = await params; + const body = await request.json(); + const { content, senderId, senderName, senderRole, senderAvatar } = body; + + if (!content || !content.trim()) { + return NextResponse.json( + { success: false, error: 'Message content cannot be empty' }, + { status: 400 } + ); + } + + const newMessage: ChatMessage = { + id: `msg_${contractId}_${Date.now()}`, + contractId, + senderId: senderId || 'client_1', + senderName: senderName || 'Alex Vance', + senderRole: senderRole || 'client', + senderAvatar: senderAvatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&q=80&w=256', + content: content.trim(), + timestamp: new Date().toISOString(), + status: 'sent', + }; + + if (!INITIAL_MESSAGES[contractId]) { + INITIAL_MESSAGES[contractId] = []; + } + INITIAL_MESSAGES[contractId].push(newMessage); + + return NextResponse.json({ + success: true, + message: newMessage, + }); + } catch (error) { + return NextResponse.json( + { success: false, error: 'Failed to process message' }, + { status: 500 } + ); + } +} diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts new file mode 100644 index 0000000..347b6ff --- /dev/null +++ b/app/api/messages/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server'; +import { INITIAL_CONVERSATIONS } from '@/lib/mock-chat'; + +export async function GET() { + try { + return NextResponse.json({ + success: true, + conversations: INITIAL_CONVERSATIONS, + }); + } catch (error) { + return NextResponse.json( + { success: false, error: 'Failed to fetch contract conversations' }, + { status: 500 } + ); + } +} diff --git a/app/dashboard/contracts/[id]/page.tsx b/app/dashboard/contracts/[id]/page.tsx index 42b758f..1944eaa 100644 --- a/app/dashboard/contracts/[id]/page.tsx +++ b/app/dashboard/contracts/[id]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; import Link from "next/link"; -import { ArrowLeft, Loader2, AlertCircle, Star, ShieldCheck, Wallet, User } from "lucide-react"; +import { ArrowLeft, Loader2, AlertCircle, Star, ShieldCheck, Wallet, User, MessageSquare } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Card } from "@/components/ui/card"; @@ -146,9 +146,17 @@ export default function ContractDetailPage() {

Job ID: {contract.job_id}

- - {statusCfg.label} - +
+ + + + + {statusCfg.label} + +
{/* Parties */} diff --git a/app/dashboard/messages/page.tsx b/app/dashboard/messages/page.tsx new file mode 100644 index 0000000..13e2847 --- /dev/null +++ b/app/dashboard/messages/page.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { ChatLayout } from '@/components/chat/chat-layout'; +import { Loader2 } from 'lucide-react'; + +function MessagesContent() { + const searchParams = useSearchParams(); + const contractId = searchParams.get('contractId'); + + return ; +} + +export default function MessagesPage() { + return ( +
+
+

In-App Messaging

+

+ Direct, encrypted communication channel between clients and freelancers for contract milestones. +

+
+ + + + Loading message workspace... +
+ } + > + + + + ); +} diff --git a/components/chat/chat-empty-state.tsx b/components/chat/chat-empty-state.tsx new file mode 100644 index 0000000..4b11ec2 --- /dev/null +++ b/components/chat/chat-empty-state.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { MessageSquare, ShieldCheck, Sparkles, Send } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface ChatEmptyStateProps { + type: 'no-selected' | 'no-messages'; + contractTitle?: string; + onQuickStart?: (message: string) => void; +} + +export function ChatEmptyState({ type, contractTitle, onQuickStart }: ChatEmptyStateProps) { + if (type === 'no-selected') { + return ( +
+
+ +
+

Select a Contract Conversation

+

+ Choose an active contract from the list to message your client or freelancer securely. +

+
+ + All messages are encrypted and logged for escrow milestone verification +
+
+ ); + } + + const quickPrompts = [ + "Hi there! I'm ready to discuss the milestones for this contract.", + "Could you provide an update on the latest task progress?", + "I have reviewed the scope requirements and look forward to working with you.", + ]; + + return ( +
+
+ +
+
+

Start the Conversation

+

+ This is the beginning of your chat for {contractTitle || 'this contract'}. +

+
+ + {onQuickStart && ( +
+

Suggested Greetings

+
+ {quickPrompts.map((prompt, idx) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/components/chat/chat-input.tsx b/components/chat/chat-input.tsx new file mode 100644 index 0000000..b65d6cd --- /dev/null +++ b/components/chat/chat-input.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { useState, useRef, KeyboardEvent } from 'react'; +import { Button } from '@/components/ui/button'; +import { Send, Paperclip, Smile, FileCode2 } from 'lucide-react'; + +interface ChatInputProps { + onSendMessage: (content: string) => void; + disabled?: boolean; +} + +export function ChatInput({ onSendMessage, disabled }: ChatInputProps) { + const [text, setText] = useState(''); + const textareaRef = useRef(null); + + const handleSend = () => { + if (!text.trim() || disabled) return; + onSendMessage(text.trim()); + setText(''); + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const handleTextChange = (e: React.ChangeEvent) => { + setText(e.target.value); + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`; + } + }; + + const insertSnippet = (snippet: string) => { + setText((prev) => (prev ? `${prev} ${snippet}` : snippet)); + if (textareaRef.current) { + textareaRef.current.focus(); + } + }; + + return ( +
+ {/* Quick Actions Row */} +
+ + Quick Snippets: + + + + +
+ + {/* Input Form */} +
+ + +