diff --git a/app/[locale]/market/MarketClient.tsx b/app/[locale]/market/MarketClient.tsx index 4b68d01..5f09d27 100644 --- a/app/[locale]/market/MarketClient.tsx +++ b/app/[locale]/market/MarketClient.tsx @@ -19,8 +19,11 @@ import { useMarketTelemetry } from "@/lib/hooks/use-telemetry"; import { AdCard } from "@/components/marketplace/AdCard"; import { CreateAdModal } from "@/components/marketplace/CreateAdModal"; import { MyAdsPanel } from "@/components/marketplace/MyAdsPanel"; +import { MyPurchaseIntentionsPanel } from "@/components/marketplace/MyPurchaseIntentionsPanel"; import { useSession } from "@/lib/hooks/use-session"; import { useListings, useInvalidateListings } from "@/lib/hooks/use-listings"; +import { useMyPurchaseIntentions } from "@/lib/hooks/use-purchase-intentions"; +import { useLocale } from "next-intl"; interface Listing { listing_id: number; @@ -53,6 +56,7 @@ export default function MarketClient({ initial }: { initial: InitialData }) { const { user } = useSession(); const { trackSearch, trackFilter, trackPageView } = useMarketTelemetry(); const invalidateListings = useInvalidateListings(); + const locale = useLocale(); const [page, setPage] = useState(initial.page || 1); const [pageSize] = useState(initial.pageSize || 20); @@ -152,11 +156,14 @@ export default function MarketClient({ initial }: { initial: InitialData }) { )} - {/* Tabs para Explorar e Meus Anúncios */} + {/* Tabs para Explorar, Meus Anúncios e Minhas Intenções */} Explorar {user && Meus Anúncios} + {user && ( + + )} {/* Aba: Explorar Anúncios */} @@ -399,8 +406,35 @@ export default function MarketClient({ initial }: { initial: InitialData }) { )} + + {/* Aba: Minhas Intenções */} + {user && ( + + + + )} ); } +// Componente para o trigger da tab com badge de contador +function MyPurchaseIntentionsTabTrigger() { + const { intentions } = useMyPurchaseIntentions(); + const pendingCount = intentions.filter((i) => i.status === "pending").length; + + return ( + + Minhas Intenções + {pendingCount > 0 && ( + + {pendingCount} + + )} + + ); +} + diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 6ca47d1..1cb4af3 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -3,6 +3,7 @@ import { cookies } from "next/headers"; import { secureLogger } from "@/lib/secure-logger"; import { getSession, refreshSession, deleteSession } from "@/lib/redis/session"; import { verifyAccessToken } from "@/lib/auth/jwt"; +import { getMainCharacter } from "@/lib/prisma/queries/characters"; /** * GET /api/auth/session @@ -65,6 +66,9 @@ export async function GET(request: NextRequest) { // Atualizar TTL da sessão await refreshSession(sessionId); + // Buscar personagem principal do usuário + const mainCharacter = await getMainCharacter(session.userId); + return NextResponse.json( { success: true, @@ -73,6 +77,7 @@ export async function GET(request: NextRequest) { userId: session.userId, username: session.username, email: session.email, + characterName: mainCharacter?.character_name || null, }, }, { status: 200 } diff --git a/app/api/chats/[chatId]/messages/route.ts b/app/api/chats/[chatId]/messages/route.ts new file mode 100644 index 0000000..1f3a938 --- /dev/null +++ b/app/api/chats/[chatId]/messages/route.ts @@ -0,0 +1,265 @@ +/** + * GET /api/chats/[chatId]/messages - Busca mensagens do chat + * POST /api/chats/[chatId]/messages - Envia mensagem (fallback se Socket.io não estiver disponível) + */ + +import { NextRequest, NextResponse } from "next/server"; +import { requireAuth } from "@/lib/auth/middleware"; +import { connectMongoDB } from "@/lib/mongodb/connection"; +import { Chat } from "@/lib/mongodb/models/Chat"; +import { Message } from "@/lib/mongodb/models/Message"; +import { sendMessageSchema, getMessagesSchema } from "@/lib/validation/chat-schemas"; +import { secureLogger } from "@/lib/secure-logger"; + +/** + * GET /api/chats/[chatId]/messages + * Busca mensagens do chat com paginação + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ chatId: string }> } +) { + try { + const authResult = await requireAuth(request); + if (authResult instanceof NextResponse) return authResult; + const auth = authResult; + + const { chatId } = await params; + const { searchParams } = new URL(request.url); + + const queryValidation = getMessagesSchema.safeParse({ + page: searchParams.get("page"), + limit: searchParams.get("limit"), + }); + + if (!queryValidation.success) { + return NextResponse.json( + { + success: false, + error: "Parâmetros inválidos", + details: queryValidation.error.issues, + }, + { status: 400 } + ); + } + + const { page, limit } = queryValidation.data; + + await connectMongoDB(); + + // Verificar se chat existe e usuário tem acesso + const chat = await Chat.findOne({ chat_id: chatId }); + + if (!chat) { + return NextResponse.json( + { success: false, error: "Chat não encontrado" }, + { status: 404 } + ); + } + + if (chat.buyer_id !== auth.userId && chat.seller_id !== auth.userId) { + return NextResponse.json( + { success: false, error: "Você não tem permissão para acessar este chat" }, + { status: 403 } + ); + } + + // Buscar mensagens (não deletadas) + const skip = (page - 1) * limit; + const messages = await Message.find({ + chat_id: chatId, + deleted_at: null, + }) + .sort({ created_at: -1 }) + .skip(skip) + .limit(limit); + + const total = await Message.countDocuments({ + chat_id: chatId, + deleted_at: null, + }); + + // Marcar mensagens como lidas + await Message.updateMany( + { + chat_id: chatId, + sender_id: { $ne: auth.userId }, + read_at: null, + }, + { + $set: { read_at: new Date() }, + } + ); + + // Atualizar contador de não lidas + if (chat.buyer_id === auth.userId) { + chat.buyer_unread_count = 0; + } else { + chat.seller_unread_count = 0; + } + await chat.save(); + + // Serializar mensagens (ordem reversa para mostrar mais antigas primeiro) + const serializedMessages = messages + .reverse() + .map((msg) => ({ + _id: msg._id.toString(), + chat_id: msg.chat_id, + sender_id: msg.sender_id, + content: msg.content, + created_at: msg.created_at.toISOString(), + read_at: msg.read_at?.toISOString() || null, + edited_at: msg.edited_at?.toISOString() || null, + message_type: msg.message_type, + metadata: msg.metadata || null, + })); + + return NextResponse.json( + { + success: true, + messages: serializedMessages, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }, + { status: 200 } + ); + } catch (error) { + secureLogger.error("❌ Erro ao buscar mensagens", { + error: error instanceof Error ? error.message : "Erro desconhecido", + stack: error instanceof Error ? error.stack : undefined, + }); + + return NextResponse.json( + { success: false, error: "Erro ao buscar mensagens" }, + { status: 500 } + ); + } +} + +/** + * POST /api/chats/[chatId]/messages + * Envia mensagem (fallback se Socket.io não estiver disponível) + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ chatId: string }> } +) { + try { + const authResult = await requireAuth(request); + if (authResult instanceof NextResponse) return authResult; + const auth = authResult; + + const { chatId } = await params; + const body = await request.json(); + const validationResult = sendMessageSchema.safeParse(body); + + if (!validationResult.success) { + return NextResponse.json( + { + success: false, + error: "Dados inválidos", + details: validationResult.error.issues, + }, + { status: 400 } + ); + } + + const { content } = validationResult.data; + + await connectMongoDB(); + + // Verificar se chat existe e usuário tem acesso + const chat = await Chat.findOne({ chat_id: chatId }); + + if (!chat) { + return NextResponse.json( + { success: false, error: "Chat não encontrado" }, + { status: 404 } + ); + } + + if (chat.buyer_id !== auth.userId && chat.seller_id !== auth.userId) { + return NextResponse.json( + { success: false, error: "Você não tem permissão para enviar mensagens neste chat" }, + { status: 403 } + ); + } + + // Criar mensagem + const message = new Message({ + chat_id: chatId, + sender_id: auth.userId, + content, + message_type: "text", + }); + + await message.save(); + + // Atualizar chat + chat.last_message_at = new Date(); + chat.last_message_preview = content.substring(0, 100); + + if (chat.buyer_id === auth.userId) { + chat.seller_unread_count += 1; + } else { + chat.buyer_unread_count += 1; + } + + await chat.save(); + + // Tentar enviar via Socket.io se disponível + try { + const { getSocketIO } = await import("@/lib/socket/server"); + const io = getSocketIO(); + if (io) { + io.to(`chat:${chatId}`).emit("new_message", { + message: { + _id: message._id.toString(), + chat_id: message.chat_id, + sender_id: message.sender_id, + content: message.content, + created_at: message.created_at.toISOString(), + read_at: message.read_at?.toISOString() || null, + message_type: message.message_type, + }, + }); + } + } catch (socketError) { + // Socket.io não disponível, mensagem já foi salva + secureLogger.warn("Socket.io não disponível, mensagem salva via REST", { + chatId, + }); + } + + return NextResponse.json( + { + success: true, + message: { + _id: message._id.toString(), + chat_id: message.chat_id, + sender_id: message.sender_id, + content: message.content, + created_at: message.created_at.toISOString(), + read_at: message.read_at?.toISOString() || null, + message_type: message.message_type, + }, + }, + { status: 201 } + ); + } catch (error) { + secureLogger.error("❌ Erro ao enviar mensagem", { + error: error instanceof Error ? error.message : "Erro desconhecido", + stack: error instanceof Error ? error.stack : undefined, + }); + + return NextResponse.json( + { success: false, error: "Erro ao enviar mensagem" }, + { status: 500 } + ); + } +} + diff --git a/app/api/chats/[chatId]/route.ts b/app/api/chats/[chatId]/route.ts new file mode 100644 index 0000000..782135c --- /dev/null +++ b/app/api/chats/[chatId]/route.ts @@ -0,0 +1,76 @@ +/** + * GET /api/chats/[chatId] - Busca detalhes de um chat específico + */ + +import { NextRequest, NextResponse } from "next/server"; +import { requireAuth } from "@/lib/auth/middleware"; +import { connectMongoDB } from "@/lib/mongodb/connection"; +import { Chat } from "@/lib/mongodb/models/Chat"; +import { secureLogger } from "@/lib/secure-logger"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ chatId: string }> } +) { + try { + const authResult = await requireAuth(request); + if (authResult instanceof NextResponse) return authResult; + const auth = authResult; + + const { chatId } = await params; + + await connectMongoDB(); + + // Buscar chat + const chat = await Chat.findOne({ chat_id: chatId }); + + if (!chat) { + return NextResponse.json( + { success: false, error: "Chat não encontrado" }, + { status: 404 } + ); + } + + // Verificar se o usuário pertence ao chat + if (chat.buyer_id !== auth.userId && chat.seller_id !== auth.userId) { + return NextResponse.json( + { success: false, error: "Você não tem permissão para acessar este chat" }, + { status: 403 } + ); + } + + return NextResponse.json( + { + success: true, + chat: { + chat_id: chat.chat_id, + listing_id: chat.listing_id, + buyer_id: chat.buyer_id, + seller_id: chat.seller_id, + created_at: chat.created_at.toISOString(), + updated_at: chat.updated_at.toISOString(), + last_message_at: chat.last_message_at?.toISOString() || null, + last_message_preview: chat.last_message_preview, + buyer_unread_count: chat.buyer_unread_count, + seller_unread_count: chat.seller_unread_count, + is_active: chat.is_active, + listing_cache: chat.listing_cache, + buyer_cache: chat.buyer_cache, + seller_cache: chat.seller_cache, + }, + }, + { status: 200 } + ); + } catch (error) { + secureLogger.error("❌ Erro ao buscar chat", { + error: error instanceof Error ? error.message : "Erro desconhecido", + stack: error instanceof Error ? error.stack : undefined, + }); + + return NextResponse.json( + { success: false, error: "Erro ao buscar chat" }, + { status: 500 } + ); + } +} + diff --git a/app/api/chats/mark-all-read/route.ts b/app/api/chats/mark-all-read/route.ts new file mode 100644 index 0000000..96043b8 --- /dev/null +++ b/app/api/chats/mark-all-read/route.ts @@ -0,0 +1,72 @@ +/** + * POST /api/chats/mark-all-read + * Marca todas as mensagens de todos os chats do usuário como lidas + */ +import { NextRequest, NextResponse } from "next/server"; +import { requireAuth } from "@/lib/auth/middleware"; +import { connectMongoDB } from "@/lib/mongodb/connection"; +import { Chat } from "@/lib/mongodb/models/Chat"; +import { Message } from "@/lib/mongodb/models/Message"; +import { secureLogger } from "@/lib/secure-logger"; + +export async function POST(request: NextRequest) { + try { + const authResult = await requireAuth(request); + if (authResult instanceof NextResponse) return authResult; + const auth = authResult; + + await connectMongoDB(); + + // Buscar todos os chats do usuário + const chats = await Chat.find({ + $or: [{ buyer_id: auth.userId }, { seller_id: auth.userId }], + is_active: true, + }); + + // Para cada chat, marcar mensagens não lidas como lidas + const updatePromises = chats.map(async (chat) => { + const chatId = chat.chat_id; + + // Marcar mensagens como lidas + await Message.updateMany( + { + chat_id: chatId, + sender_id: { $ne: auth.userId }, + read_at: null, + }, + { + $set: { read_at: new Date() }, + } + ); + + // Zerar contador de não lidas + if (chat.buyer_id === auth.userId) { + chat.buyer_unread_count = 0; + } else { + chat.seller_unread_count = 0; + } + + return chat.save(); + }); + + await Promise.all(updatePromises); + + return NextResponse.json( + { + success: true, + message: "Todos os chats marcados como lidos", + }, + { status: 200 } + ); + } catch (error) { + secureLogger.error("❌ Erro ao marcar chats como lidos", { + error: error instanceof Error ? error.message : "Erro desconhecido", + stack: error instanceof Error ? error.stack : undefined, + }); + + return NextResponse.json( + { success: false, error: "Erro ao marcar como lido" }, + { status: 500 } + ); + } +} diff --git a/app/api/chats/route.ts b/app/api/chats/route.ts new file mode 100644 index 0000000..b86505d --- /dev/null +++ b/app/api/chats/route.ts @@ -0,0 +1,265 @@ +/** + * GET /api/chats - Lista chats do usuário + * POST /api/chats - Cria novo chat + */ + +import { NextRequest, NextResponse } from "next/server"; +import { requireAuth } from "@/lib/auth/middleware"; +import { connectMongoDB } from "@/lib/mongodb/connection"; +import { Chat } from "@/lib/mongodb/models/Chat"; +import { createChatSchema } from "@/lib/validation/chat-schemas"; +import { getListingById } from "@/lib/prisma/queries/listings"; +import { getMainCharacter } from "@/lib/prisma/queries/characters"; +import { getUserById } from "@/lib/prisma/queries/users"; +import { secureLogger } from "@/lib/secure-logger"; + +/** + * GET /api/chats + * Lista todos os chats do usuário autenticado + */ +export async function GET(request: NextRequest) { + try { + const authResult = await requireAuth(request); + if (authResult instanceof NextResponse) return authResult; + const auth = authResult; + + await connectMongoDB(); + + // Buscar todos os chats do usuário (como comprador ou vendedor) + const chats = await Chat.find({ + $or: [{ buyer_id: auth.userId }, { seller_id: auth.userId }], + is_active: true, + }) + .sort({ updated_at: -1 }) + .limit(100); + + // Serializar datas + const serializedChats = chats.map((chat) => ({ + chat_id: chat.chat_id, + listing_id: chat.listing_id, + buyer_id: chat.buyer_id, + seller_id: chat.seller_id, + created_at: chat.created_at.toISOString(), + updated_at: chat.updated_at.toISOString(), + last_message_at: chat.last_message_at?.toISOString() || null, + last_message_preview: chat.last_message_preview, + buyer_unread_count: chat.buyer_unread_count, + seller_unread_count: chat.seller_unread_count, + is_active: chat.is_active, + listing_cache: chat.listing_cache, + buyer_cache: chat.buyer_cache, + seller_cache: chat.seller_cache, + })); + + return NextResponse.json( + { + success: true, + chats: serializedChats, + count: serializedChats.length, + }, + { status: 200 } + ); + } catch (error) { + secureLogger.error("❌ Erro ao buscar chats", { + error: error instanceof Error ? error.message : "Erro desconhecido", + stack: error instanceof Error ? error.stack : undefined, + }); + + return NextResponse.json( + { success: false, error: "Erro ao buscar chats" }, + { status: 500 } + ); + } +} + +/** + * POST /api/chats + * Cria um novo chat entre comprador e vendedor + */ +export async function POST(request: NextRequest) { + try { + const authResult = await requireAuth(request); + if (authResult instanceof NextResponse) return authResult; + const auth = authResult; + + const body = await request.json(); + const validationResult = createChatSchema.safeParse(body); + + if (!validationResult.success) { + return NextResponse.json( + { + success: false, + error: "Dados inválidos", + details: validationResult.error.issues, + }, + { status: 400 } + ); + } + + const { listing_id, buyer_message } = validationResult.data; + + await connectMongoDB(); + + // Buscar anúncio + const listing = await getListingById(listing_id); + if (!listing) { + return NextResponse.json( + { success: false, error: "Anúncio não encontrado" }, + { status: 404 } + ); + } + + // Verificar se não é o próprio vendedor + if (listing.seller_id === auth.userId) { + return NextResponse.json( + { success: false, error: "Você não pode criar um chat com você mesmo" }, + { status: 400 } + ); + } + + // Verificar se anúncio está ativo + if (listing.listing_status !== "active") { + return NextResponse.json( + { success: false, error: "Anúncio não está disponível para chat" }, + { status: 400 } + ); + } + + // Gerar chat_id único + const chat_id = `${listing_id}_${auth.userId}_${listing.seller_id}`; + + // Verificar se chat já existe + let chat = await Chat.findOne({ chat_id }); + + if (chat) { + // Chat já existe, retornar + return NextResponse.json( + { + success: true, + chat: { + chat_id: chat.chat_id, + listing_id: chat.listing_id, + buyer_id: chat.buyer_id, + seller_id: chat.seller_id, + created_at: chat.created_at.toISOString(), + updated_at: chat.updated_at.toISOString(), + last_message_at: chat.last_message_at?.toISOString() || null, + last_message_preview: chat.last_message_preview, + buyer_unread_count: chat.buyer_unread_count, + seller_unread_count: chat.seller_unread_count, + is_active: chat.is_active, + listing_cache: chat.listing_cache, + buyer_cache: chat.buyer_cache, + seller_cache: chat.seller_cache, + }, + already_exists: true, + }, + { status: 200 } + ); + } + + // Buscar dados do comprador + const buyer = await getUserById(auth.userId); + if (!buyer) { + return NextResponse.json( + { success: false, error: "Usuário não encontrado" }, + { status: 404 } + ); + } + + const buyerCharacter = await getMainCharacter(auth.userId); + + // Buscar dados do vendedor + const seller = await getUserById(listing.seller_id); + if (!seller) { + return NextResponse.json( + { success: false, error: "Vendedor não encontrado" }, + { status: 404 } + ); + } + + const sellerCharacter = await getMainCharacter(listing.seller_id); + + // Criar chat + chat = new Chat({ + chat_id, + listing_id, + buyer_id: auth.userId, + seller_id: listing.seller_id, + listing_cache: { + item_name: listing.item_name, + item_image_url: null, // Imagem será obtida via item_id se necessário + price_pix: listing.price_pix ? Number(listing.price_pix) : null, + price_tibia_coins: listing.price_tibia_coins || null, + price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null, + server_name: listing.server_name, + listing_status: listing.listing_status, + }, + buyer_cache: { + user_id: buyer.user_id, + username: buyer.username, + character_name: buyerCharacter?.character_name || null, + }, + seller_cache: { + user_id: seller.user_id, + username: seller.username, + character_name: sellerCharacter?.character_name || null, + }, + }); + + await chat.save(); + + // Se houver mensagem inicial, criar + if (buyer_message && buyer_message.trim().length > 0) { + const { Message } = await import("@/lib/mongodb/models/Message"); + const message = new Message({ + chat_id, + sender_id: auth.userId, + content: buyer_message.trim(), + message_type: "text", + }); + await message.save(); + + // Atualizar chat + chat.last_message_at = new Date(); + chat.last_message_preview = buyer_message.substring(0, 100); + chat.seller_unread_count = 1; + await chat.save(); + } + + return NextResponse.json( + { + success: true, + chat: { + chat_id: chat.chat_id, + listing_id: chat.listing_id, + buyer_id: chat.buyer_id, + seller_id: chat.seller_id, + created_at: chat.created_at.toISOString(), + updated_at: chat.updated_at.toISOString(), + last_message_at: chat.last_message_at?.toISOString() || null, + last_message_preview: chat.last_message_preview, + buyer_unread_count: chat.buyer_unread_count, + seller_unread_count: chat.seller_unread_count, + is_active: chat.is_active, + listing_cache: chat.listing_cache, + buyer_cache: chat.buyer_cache, + seller_cache: chat.seller_cache, + }, + already_exists: false, + }, + { status: 201 } + ); + } catch (error) { + secureLogger.error("❌ Erro ao criar chat", { + error: error instanceof Error ? error.message : "Erro desconhecido", + stack: error instanceof Error ? error.stack : undefined, + }); + + return NextResponse.json( + { success: false, error: "Erro ao criar chat" }, + { status: 500 } + ); + } +} + diff --git a/app/api/chats/unread-count/route.ts b/app/api/chats/unread-count/route.ts new file mode 100644 index 0000000..c929c46 --- /dev/null +++ b/app/api/chats/unread-count/route.ts @@ -0,0 +1,70 @@ +/** + * GET /api/chats/unread-count + * Retorna contagem de usuários únicos com mensagens não vistas + */ +import { NextRequest, NextResponse } from "next/server"; +import { requireAuth } from "@/lib/auth/middleware"; +import { connectMongoDB } from "@/lib/mongodb/connection"; +import { Chat } from "@/lib/mongodb/models/Chat"; +import { secureLogger } from "@/lib/secure-logger"; + +export async function GET(request: NextRequest) { + try { + const authResult = await requireAuth(request); + if (authResult instanceof NextResponse) return authResult; + const auth = authResult; + + await connectMongoDB(); + + // Buscar todos os chats do usuário + const chats = await Chat.find({ + $or: [{ buyer_id: auth.userId }, { seller_id: auth.userId }], + is_active: true, + }); + + // Calcular contagem de usuários únicos com mensagens não vistas + const uniqueUsersWithUnread = new Set(); + + chats.forEach((chat) => { + let unreadCount = 0; + let otherUserId: number; + + if (chat.buyer_id === auth.userId) { + unreadCount = chat.buyer_unread_count; + otherUserId = chat.seller_id; + } else { + unreadCount = chat.seller_unread_count; + otherUserId = chat.buyer_id; + } + + // Se há mensagens não vistas, adicionar o outro usuário ao Set + if (unreadCount > 0) { + uniqueUsersWithUnread.add(otherUserId); + } + }); + + return NextResponse.json( + { + success: true, + count: uniqueUsersWithUnread.size, + totalUnreadMessages: chats.reduce((total, chat) => { + if (chat.buyer_id === auth.userId) { + return total + chat.buyer_unread_count; + } + return total + chat.seller_unread_count; + }, 0), + }, + { status: 200 } + ); + } catch (error) { + secureLogger.error("❌ Erro ao buscar contagem de chats não lidos", { + error: error instanceof Error ? error.message : "Erro desconhecido", + stack: error instanceof Error ? error.stack : undefined, + }); + + return NextResponse.json( + { success: false, error: "Erro ao buscar contagem" }, + { status: 500 } + ); + } +} diff --git a/app/api/listings/[id]/intentions/route.ts b/app/api/listings/[id]/intentions/route.ts index b767ca4..d89c05f 100644 --- a/app/api/listings/[id]/intentions/route.ts +++ b/app/api/listings/[id]/intentions/route.ts @@ -6,6 +6,7 @@ import { createPurchaseIntention, getPurchaseIntentionsByListing, hasPendingIntention, + getPendingIntentionByBuyerAndListing, } from "@/lib/prisma/queries/purchase-intentions"; import { createNotification } from "@/lib/prisma/queries/notifications"; import { trackEvent } from "@/lib/prisma/queries/telemetry"; @@ -94,12 +95,33 @@ export async function POST( } // Verificar se já existe uma intenção pendente - const alreadyHasPending = await hasPendingIntention(listingId, auth.userId); - if (alreadyHasPending) { - return NextResponse.json( - { success: false, error: "Você já possui uma intenção de compra pendente para este anúncio" }, - { status: 400 } - ); + const existingIntention = await getPendingIntentionByBuyerAndListing(listingId, auth.userId); + if (existingIntention) { + // Se já existe e ainda pode trocar o método + if (existingIntention.payment_method_change_count < 1) { + // Permite atualizar o método via endpoint específico + return NextResponse.json( + { + success: false, + error: "Você já possui uma intenção de compra pendente para este anúncio", + existing_intention_id: existingIntention.intention_id, + can_change_payment_method: true, + payment_method_change_count: existingIntention.payment_method_change_count, + message: "Use o endpoint PATCH /api/purchase-intentions/[intentionId]/payment-method para trocar o método de pagamento", + }, + { status: 400 } + ); + } else { + // Já atingiu o limite de trocas + return NextResponse.json( + { + success: false, + error: "Você já possui uma intenção de compra pendente para este anúncio e atingiu o limite de trocas de método de pagamento", + existing_intention_id: existingIntention.intention_id, + }, + { status: 400 } + ); + } } // Criar intenção de compra diff --git a/app/api/listings/[id]/purchase/route.ts b/app/api/listings/[id]/purchase/route.ts index 9eff3b8..e708687 100644 --- a/app/api/listings/[id]/purchase/route.ts +++ b/app/api/listings/[id]/purchase/route.ts @@ -6,6 +6,7 @@ import { createNotification } from "@/lib/prisma/queries/notifications"; import { createPurchaseIntention, hasPendingIntention, + getPendingIntentionByBuyerAndListing, } from "@/lib/prisma/queries/purchase-intentions"; import { trackEvent } from "@/lib/prisma/queries/telemetry"; @@ -98,12 +99,33 @@ export async function POST( } // Verificar se já existe uma intenção pendente - const alreadyHasPending = await hasPendingIntention(listingId, auth.userId); - if (alreadyHasPending) { - return NextResponse.json( - { success: false, error: "Você já possui uma intenção de compra pendente para este anúncio" }, - { status: 400 } - ); + const existingIntention = await getPendingIntentionByBuyerAndListing(listingId, auth.userId); + if (existingIntention) { + // Se já existe e ainda pode trocar o método + if (existingIntention.payment_method_change_count < 1) { + // Permite atualizar o método via endpoint específico + return NextResponse.json( + { + success: false, + error: "Você já possui uma intenção de compra pendente para este anúncio", + existing_intention_id: existingIntention.intention_id, + can_change_payment_method: true, + payment_method_change_count: existingIntention.payment_method_change_count, + message: "Use o endpoint PATCH /api/purchase-intentions/[intentionId]/payment-method para trocar o método de pagamento", + }, + { status: 400 } + ); + } else { + // Já atingiu o limite de trocas + return NextResponse.json( + { + success: false, + error: "Você já possui uma intenção de compra pendente para este anúncio e atingiu o limite de trocas de método de pagamento", + existing_intention_id: existingIntention.intention_id, + }, + { status: 400 } + ); + } } // Criar intenção de compra ao invés de mudar status diretamente diff --git a/app/api/listings/[id]/route.ts b/app/api/listings/[id]/route.ts index 0ebb2fc..cfb480a 100644 --- a/app/api/listings/[id]/route.ts +++ b/app/api/listings/[id]/route.ts @@ -6,6 +6,7 @@ import { updateListing, deleteListing, } from "@/lib/prisma/queries/listings"; +import { hasPendingIntention } from "@/lib/prisma/queries/purchase-intentions"; import { updateListingSchema } from "@/lib/validation-schemas"; /** @@ -17,6 +18,7 @@ export async function GET( { params }: { params: Promise<{ id: string }> } ) { try { + const auth = await optionalAuth(request); const { id } = await params; const listingId = parseInt(id, 10); @@ -42,10 +44,18 @@ export async function GET( ); } + const hasPendingForUser = + auth && auth.userId + ? await hasPendingIntention(listingId, auth.userId) + : false; + return NextResponse.json( { success: true, - listing, + listing: { + ...listing, + has_pending_intention_for_current_user: hasPendingForUser, + }, }, { status: 200 } ); diff --git a/app/api/listings/route.ts b/app/api/listings/route.ts index 9cf4778..b5817b0 100644 --- a/app/api/listings/route.ts +++ b/app/api/listings/route.ts @@ -13,6 +13,7 @@ import { getActiveListings, } from "@/lib/prisma/queries/listings"; import { isServerActive } from "@/lib/prisma/queries/servers"; +import { getPendingIntentionListingIdsForBuyer } from "@/lib/prisma/queries/purchase-intentions"; /** * GET /api/listings @@ -20,6 +21,7 @@ import { isServerActive } from "@/lib/prisma/queries/servers"; */ export async function GET(request: NextRequest) { try { + const auth = await optionalAuth(request); const { searchParams } = new URL(request.url); const filters = { @@ -65,10 +67,26 @@ export async function GET(request: NextRequest) { ? await getListings(filters) : await getActiveListings(filters); + let listingsResponse = listingsData.listings; + + if (auth && listingsResponse.length > 0) { + const listingIds = listingsResponse.map((listing) => listing.listing_id); + const pendingListingIds = await getPendingIntentionListingIdsForBuyer( + auth.userId, + listingIds + ); + const pendingSet = new Set(pendingListingIds); + + listingsResponse = listingsResponse.map((listing) => ({ + ...listing, + has_pending_intention_for_current_user: pendingSet.has(listing.listing_id), + })); + } + return NextResponse.json( { success: true, - listings: listingsData.listings, + listings: listingsResponse, pagination: { page: filters.page, pageSize: filters.pageSize, diff --git a/app/api/purchase-intentions/[intentionId]/payment-method/route.ts b/app/api/purchase-intentions/[intentionId]/payment-method/route.ts new file mode 100644 index 0000000..09ce62c --- /dev/null +++ b/app/api/purchase-intentions/[intentionId]/payment-method/route.ts @@ -0,0 +1,175 @@ +/** + * PATCH /api/purchase-intentions/[intentionId]/payment-method + * Permite ao comprador trocar o método de pagamento (máximo 1 vez) + */ +import { NextRequest, NextResponse } from "next/server"; +import { requireAuth } from "@/lib/auth/middleware"; +import { + getPurchaseIntentionById, + updatePurchaseIntentionPaymentMethod, +} from "@/lib/prisma/queries/purchase-intentions"; +import { getListingById } from "@/lib/prisma/queries/listings"; +import { createSystemMessageInChat } from "@/lib/chat/system-messages"; +import { secureLogger } from "@/lib/secure-logger"; + +function getPaymentMethodLabel(method: string): string { + switch (method) { + case "pix": + return "PIX"; + case "tibia_coins": + return "Tibia Coins"; + case "gold_coins": + return "Gold Coins"; + case "trade": + return "Troca"; + default: + return method.toUpperCase(); + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ intentionId: string }> } +) { + try { + const authResult = await requireAuth(request); + if (authResult instanceof NextResponse) return authResult; + const auth = authResult; + + const { intentionId: intentionIdParam } = await params; + const intentionId = parseInt(intentionIdParam, 10); + + if (isNaN(intentionId)) { + return NextResponse.json( + { success: false, error: "ID de intenção inválido" }, + { status: 400 } + ); + } + + const body = await request.json(); + const { payment_method } = body; + + if (!payment_method || !["pix", "tibia_coins", "gold_coins", "trade"].includes(payment_method)) { + return NextResponse.json( + { success: false, error: "Método de pagamento inválido" }, + { status: 400 } + ); + } + + // Buscar intenção + const intention = await getPurchaseIntentionById(intentionId); + if (!intention) { + return NextResponse.json( + { success: false, error: "Intenção não encontrada" }, + { status: 404 } + ); + } + + // Verificar se o usuário é o comprador + if (intention.buyer_id !== auth.userId) { + return NextResponse.json( + { success: false, error: "Apenas o comprador pode trocar o método de pagamento" }, + { status: 403 } + ); + } + + // Verificar se a intenção está pendente + if (intention.status !== "pending") { + return NextResponse.json( + { success: false, error: "Apenas intenções pendentes podem ter o método de pagamento alterado" }, + { status: 400 } + ); + } + + // Verificar se já atingiu o limite de trocas + if (intention.payment_method_change_count >= 1) { + return NextResponse.json( + { success: false, error: "Você já atingiu o limite de trocas de método de pagamento (1 vez)" }, + { status: 400 } + ); + } + + // Verificar se o novo método é diferente + if (intention.payment_method === payment_method) { + return NextResponse.json( + { success: false, error: "O novo método de pagamento deve ser diferente do atual" }, + { status: 400 } + ); + } + + // Buscar listing para validar método disponível + const listing = await getListingById(intention.listing_id); + if (!listing) { + return NextResponse.json( + { success: false, error: "Anúncio não encontrado" }, + { status: 404 } + ); + } + + // Validar método de pagamento disponível + const availableMethods: string[] = []; + if (listing.price_pix) availableMethods.push("pix"); + if (listing.price_tibia_coins) availableMethods.push("tibia_coins"); + if (listing.price_gold_coins) availableMethods.push("gold_coins"); + if (listing.trade_item_id) availableMethods.push("trade"); + + if (!availableMethods.includes(payment_method)) { + return NextResponse.json( + { + success: false, + error: `Método de pagamento "${payment_method}" não está disponível para este anúncio`, + available_methods: availableMethods, + }, + { status: 400 } + ); + } + + // Atualizar método de pagamento + const oldPaymentMethod = intention.payment_method; + const updatedIntention = await updatePurchaseIntentionPaymentMethod( + intentionId, + payment_method as any + ); + + // Criar mensagem do sistema no chat + await createSystemMessageInChat({ + listingId: intention.listing_id, + buyerId: intention.buyer_id, + sellerId: listing.seller_id, + message: `O comprador alterou o método de pagamento de ${getPaymentMethodLabel(oldPaymentMethod)} para ${getPaymentMethodLabel(payment_method)}`, + metadata: { + action: "payment_method_changed", + old_payment_method: oldPaymentMethod, + new_payment_method: payment_method, + intention_id: intentionId, + }, + }); + + secureLogger.info("✅ Método de pagamento atualizado", { + userId: auth.userId, + intentionId, + oldPaymentMethod, + newPaymentMethod: payment_method, + }); + + return NextResponse.json( + { + success: true, + intention: updatedIntention, + message: "Método de pagamento atualizado com sucesso. O vendedor foi notificado.", + }, + { status: 200 } + ); + } catch (error) { + secureLogger.error("❌ Erro ao atualizar método de pagamento", { + error: error instanceof Error ? error.message : "Erro desconhecido", + stack: error instanceof Error ? error.stack : undefined, + }); + + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : "Erro ao atualizar método de pagamento" }, + { status: 500 } + ); + } +} + diff --git a/app/api/purchase-intentions/my/route.ts b/app/api/purchase-intentions/my/route.ts new file mode 100644 index 0000000..c2aaf74 --- /dev/null +++ b/app/api/purchase-intentions/my/route.ts @@ -0,0 +1,202 @@ +import { NextRequest, NextResponse } from "next/server"; +import { secureLogger } from "@/lib/secure-logger"; +import { requireAuth } from "@/lib/auth/middleware"; +import { getPurchaseIntentionsByBuyer } from "@/lib/prisma/queries/purchase-intentions"; +import { prisma } from "@/lib/prisma/app"; +import { trackEvent } from "@/lib/prisma/queries/telemetry"; + +/** + * GET /api/purchase-intentions/my + * Lista todas as intenções de compra do usuário autenticado + */ +export async function GET(request: NextRequest) { + try { + const authResult = await requireAuth(request); + if (authResult instanceof NextResponse) return authResult; + const auth = authResult; + + // Buscar parâmetros de filtro + const { searchParams } = new URL(request.url); + const status = searchParams.get("status") as + | "pending" + | "accepted" + | "rejected" + | "expired" + | "completed" + | null; + const limit = parseInt(searchParams.get("limit") || "100", 10); + + // Buscar intenções do comprador + const intentions = await getPurchaseIntentionsByBuyer(auth.userId, { + status: status || undefined, + limit, + }); + + // Buscar todos os listings de uma vez (otimização) + const listingIds = [...new Set(intentions.map((i) => i.listing_id))]; + const listings = await prisma.listing.findMany({ + where: { listing_id: { in: listingIds } }, + select: { + listing_id: true, + item_id: true, + item_id_unified: true, + item_name: true, + quantity: true, + server_name: true, + seller_id: true, + price_pix: true, + price_tibia_coins: true, + price_gold_coins: true, + listing_status: true, + created_at: true, + description: true, + trade_item_id: true, + trade_item_name: true, + trade_item_quantity: true, + views_count: true, + favorites_count: true, + is_featured: true, + item: { + select: { + image_url: true, + image_local_path: true, + category: true, + }, + }, + seller: { + select: { + user_id: true, + username: true, + email: true, + }, + }, + }, + }); + + // Criar mapa de listings para acesso rápido + const listingsMap = new Map(listings.map((l) => [l.listing_id, l])); + + // Buscar character names dos vendedores em batch + const sellerServerPairs = [ + ...new Set( + listings.map((l) => `${l.seller_id}:${l.server_name}`) + ), + ]; + + const characterMap = new Map(); + await Promise.all( + sellerServerPairs.map(async (pair) => { + const [sellerId, serverName] = pair.split(":"); + const character = await prisma.userCharacter.findFirst({ + where: { + user_id: parseInt(sellerId, 10), + server_name: serverName, + is_main: true, + }, + select: { + character_name: true, + }, + }); + characterMap.set(pair, character?.character_name || null); + }) + ); + + // Serializar datas para string + const serializeDate = (date: Date | null): string | null => { + return date ? new Date(date).toISOString() : null; + }; + + // Expandir dados do listing para cada intenção + const intentionsWithListingDetails = intentions.map((intention) => { + const listing = listingsMap.get(intention.listing_id); + const characterKey = listing + ? `${listing.seller_id}:${listing.server_name}` + : null; + const sellerCharacterName = characterKey + ? characterMap.get(characterKey) || null + : null; + + const chatId = + listing?.seller_id != null + ? `${intention.listing_id}_${intention.buyer_id}_${listing.seller_id}` + : null; + + return { + ...intention, + created_at: serializeDate(intention.created_at as Date) || intention.created_at, + updated_at: serializeDate(intention.updated_at as Date) || intention.updated_at, + expires_at: serializeDate(intention.expires_at as Date | null), + accepted_at: serializeDate(intention.accepted_at as Date | null), + rejected_at: serializeDate(intention.rejected_at as Date | null), + chat_id: chatId, + listing: listing + ? { + listing_id: listing.listing_id, + item_id: listing.item_id, + item_id_unified: listing.item_id_unified, + item_name: listing.item_name, + quantity: listing.quantity, + server_name: listing.server_name, + seller_id: listing.seller_id, + price_pix: listing.price_pix ? Number(listing.price_pix) : null, + price_tibia_coins: + listing.price_tibia_coins !== null && listing.price_tibia_coins !== undefined + ? Number(listing.price_tibia_coins) + : null, + price_gold_coins: listing.price_gold_coins + ? Number(listing.price_gold_coins) + : null, + listing_status: listing.listing_status, + created_at: + listing.created_at instanceof Date + ? listing.created_at.toISOString() + : listing.created_at, + description: listing.description, + trade_item_id: listing.trade_item_id, + trade_item_name: listing.trade_item_name, + trade_item_quantity: listing.trade_item_quantity, + views_count: listing.views_count ?? 0, + favorites_count: listing.favorites_count ?? 0, + is_featured: listing.is_featured ?? false, + image_url: listing.item?.image_url || null, + image_local_path: listing.item?.image_local_path || null, + item_category: listing.item?.category || null, + seller_character_name: sellerCharacterName, + seller_username: listing.seller?.username || null, + seller_email: listing.seller?.email || null, + } + : null, + }; + }); + + // Registrar telemetria + await trackEvent({ + event_type: "purchase_intention_viewed", + user_id: auth.userId, + metadata: { + intentions_count: intentionsWithListingDetails.length, + filter_status: status || "all", + }, + }); + + return NextResponse.json( + { + success: true, + intentions: intentionsWithListingDetails, + count: intentionsWithListingDetails.length, + }, + { status: 200 } + ); + } catch (error) { + secureLogger.error("❌ Erro ao buscar intenções de compra do usuário", { + error: error instanceof Error ? error.message : "Erro desconhecido", + stack: error instanceof Error ? error.stack : undefined, + }); + + return NextResponse.json( + { success: false, error: "Erro ao buscar intenções de compra" }, + { status: 500 } + ); + } +} + diff --git a/app/api/socket/route.ts b/app/api/socket/route.ts new file mode 100644 index 0000000..69c3ad5 --- /dev/null +++ b/app/api/socket/route.ts @@ -0,0 +1,16 @@ +/** + * API Route para Socket.io + * Next.js 15 não suporta servidor HTTP customizado diretamente + * Esta rota será usada para health check + * O Socket.io será inicializado via server.ts customizado + */ + +import { NextResponse } from "next/server"; + +export async function GET() { + return NextResponse.json({ + status: "ok", + message: "Socket.io está ativo", + }); +} + diff --git a/components/Header.tsx b/components/Header.tsx index 5b7f95a..05c9e26 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -12,6 +12,7 @@ import { ThemeToggle } from "./theme-toggle"; import { LanguageSwitcher } from "./language-switcher"; import { MobileMenu } from "./mobile-menu"; import { NotificationBell } from "./notifications/NotificationBell"; +import { MessagesBell } from "./messages/MessagesBell"; import { LoginDialog } from "./auth/login-dialog"; import { RegisterDialog } from "./auth/register-dialog"; import { Button } from "@/components/ui/button"; @@ -97,6 +98,9 @@ export default function Header() { + {/* Messages */} + {mounted && isAuthenticated && } + {/* Notifications */} {mounted && isAuthenticated && } @@ -123,6 +127,11 @@ export default function Header() {

{user.username}

+ {user.characterName && ( +

+ {user.characterName} +

+ )}

{user.email}

diff --git a/components/chat/ChatHeader.tsx b/components/chat/ChatHeader.tsx new file mode 100644 index 0000000..3f79ebd --- /dev/null +++ b/components/chat/ChatHeader.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useState } from "react"; +import { Package, MapPin, DollarSign, Coins, User } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import Image from "next/image"; +import type { Chat } from "@/lib/hooks/use-chat"; + +interface ChatHeaderProps { + chat: Chat; + currentUserId: number; +} + +export function ChatHeader({ chat, currentUserId }: ChatHeaderProps) { + const listing = chat.listing_cache; + const otherUser = + chat.buyer_id === currentUserId ? chat.seller_cache : chat.buyer_cache; + const [imageError, setImageError] = useState(false); + + const formatPrice = (price: number | null | undefined, type: "pix" | "tc" | "gp"): string => { + if (price === null || price === undefined) return "N/A"; + + if (type === "pix") { + return new Intl.NumberFormat("pt-BR", { + style: "currency", + currency: "BRL", + }).format(price); + } + + if (type === "tc") { + return `${price.toLocaleString("pt-BR")} TC`; + } + + if (type === "gp") { + const gold = price / 1000000; + if (gold >= 1) { + return `${gold.toFixed(2)}kk`; + } + return `${price.toLocaleString("pt-BR")} gp`; + } + + return price.toLocaleString("pt-BR"); + }; + + return ( +
+ {/* Informações do Anúncio */} +
+ {/* Imagem do item */} +
+ {listing.item_image_url && !imageError ? ( + {listing.item_name} setImageError(true)} + /> + ) : ( + + )} +
+ + {/* Detalhes do anúncio */} +
+

{listing.item_name}

+
+
+ + {listing.server_name} +
+
+ + Qtd: 1 +
+
+ + {/* Preços */} +
+ {listing.price_pix && ( + + + {formatPrice(listing.price_pix, "pix")} + + )} + {listing.price_tibia_coins && ( + + + {formatPrice(listing.price_tibia_coins, "tc")} + + )} + {listing.price_gold_coins && ( + + + {formatPrice(listing.price_gold_coins, "gp")} + + )} +
+
+
+ + {/* Informações do outro usuário */} +
+ + + Conversando com:{" "} + + + {otherUser.character_name || otherUser.username} + +
+
+ ); +} + diff --git a/components/chat/ChatModal.tsx b/components/chat/ChatModal.tsx new file mode 100644 index 0000000..b01ad58 --- /dev/null +++ b/components/chat/ChatModal.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ChatHeader } from "./ChatHeader"; +import { MessageList } from "./MessageList"; +import { MessageInput } from "./MessageInput"; +import { useChat } from "@/lib/hooks/use-chat"; +import { useSession } from "@/lib/hooks/use-session"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface ChatModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + listingId?: number | null; + chatId?: string | null; + initialMessage?: string; +} + +export function ChatModal({ + open, + onOpenChange, + listingId, + chatId: initialChatId, + initialMessage, +}: ChatModalProps) { + const { user } = useSession(); + const [chatId, setChatId] = useState(initialChatId || null); + const [isCreatingChat, setIsCreatingChat] = useState(false); + + const { + chat, + messages, + isLoading, + isSending, + sendMessage, + loadMoreMessages, + hasMore, + otherUserTyping, + } = useChat(chatId); + + // Se chatId for fornecido diretamente, usar ele + useEffect(() => { + if (initialChatId) { + setChatId(initialChatId); + return; + } + }, [initialChatId]); + + // Criar ou buscar chat quando modal abrir (apenas se não tiver chatId) + useEffect(() => { + if (!open || !user || !listingId || initialChatId) { + if (!initialChatId && !listingId) { + setChatId(null); + } + return; + } + + const createOrGetChat = async () => { + setIsCreatingChat(true); + try { + const res = await fetch("/api/chats", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ + listing_id: listingId, + buyer_message: initialMessage, + }), + }); + + if (!res.ok) { + throw new Error("Erro ao criar chat"); + } + + const data = await res.json(); + setChatId(data.chat.chat_id); + + // Se houver mensagem inicial, já foi enviada no backend + } catch (error) { + console.error("Erro ao criar/buscar chat:", error); + } finally { + setIsCreatingChat(false); + } + }; + + createOrGetChat(); + }, [open, user, listingId, initialMessage, initialChatId]); + + if (!user) { + return null; + } + + return ( + + + + Chat com vendedor + + Conversa sobre o anúncio + + + + {isCreatingChat || (isLoading && !chat) ? ( +
+
+ + + +
+
+ ) : chat ? ( + <> + + + + + ) : ( +
+

Erro ao carregar chat

+
+ )} +
+
+ ); +} + diff --git a/components/chat/MessageBubble.tsx b/components/chat/MessageBubble.tsx new file mode 100644 index 0000000..0f0375e --- /dev/null +++ b/components/chat/MessageBubble.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import type { ChatMessage } from "@/lib/hooks/use-chat"; + +interface MessageBubbleProps { + message: ChatMessage; + isOwn: boolean; +} + +function formatTime(date: string): string { + const d = new Date(date); + return d.toLocaleTimeString("pt-BR", { + hour: "2-digit", + minute: "2-digit", + }); +} + +export function MessageBubble({ message, isOwn }: MessageBubbleProps) { + if (message.message_type === "system") { + return ( +
+ + {message.content} + +
+ ); + } + + return ( +
+
+

+ {message.content} +

+
+ {formatTime(message.created_at)} + {isOwn && message.read_at && ( + ✓✓ + )} +
+
+
+ ); +} + diff --git a/components/chat/MessageInput.tsx b/components/chat/MessageInput.tsx new file mode 100644 index 0000000..7456cff --- /dev/null +++ b/components/chat/MessageInput.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useState, KeyboardEvent } from "react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Send, Loader2 } from "lucide-react"; +import { useChatTyping } from "@/lib/hooks/use-chat"; +import { useSocket } from "@/lib/hooks/use-socket"; + +interface MessageInputProps { + onSend: (content: string) => Promise; + isSending: boolean; + disabled?: boolean; + chatId: string | null; +} + +export function MessageInput({ + onSend, + isSending, + disabled = false, + chatId, +}: MessageInputProps) { + const [content, setContent] = useState(""); + const { socket, isConnected } = useSocket(); + const { handleTypingStart, handleTypingStop } = useChatTyping( + socket, + isConnected, + chatId + ); + + const handleSubmit = async () => { + if (!content.trim() || isSending || disabled) return; + + const messageContent = content.trim(); + setContent(""); + handleTypingStop(); + await onSend(messageContent); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }; + + const handleChange = (value: string) => { + setContent(value); + + if (value.trim().length > 0) { + handleTypingStart(); + } else { + handleTypingStop(); + } + }; + + return ( +
+
+