feat(market): add Minha Intenções tab with panel, hook, and badge

- Introduced "Minhas Intenções" tab visible only to logged-in users.
- Integrated MyPurchaseIntentionsPanel and useMyPurchaseIntentions hook.
- Added tab badge showing count of pending intentions.
- Extended session API to include user’s main character for downstream features.
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-07 17:25:51 -03:00
1 parent f35fee944d
commit 7f42def478
55 files changed
+4906 -95

No files matched your search

+275
View File
@@ -0,0 +1,275 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSocket } from "./use-socket";
import { useSession } from "./use-session";
import type { Socket } from "socket.io-client";
export interface ChatMessage {
_id: string;
chat_id: string;
sender_id: number;
content: string;
created_at: string;
read_at: string | null;
edited_at: string | null;
message_type: "text" | "system";
metadata?: any;
}
export interface Chat {
chat_id: string;
listing_id: number;
buyer_id: number;
seller_id: number;
created_at: string;
updated_at: string;
last_message_at: string | null;
last_message_preview: string | null;
buyer_unread_count: number;
seller_unread_count: number;
is_active: boolean;
listing_cache: {
item_name: string;
item_image_url?: string;
price_pix?: number;
price_tibia_coins?: number;
price_gold_coins?: number;
server_name: string;
listing_status: string;
};
buyer_cache: {
user_id: number;
username: string;
character_name?: string;
};
seller_cache: {
user_id: number;
username: string;
character_name?: string;
};
}
interface UseChatReturn {
chat: Chat | null;
messages: ChatMessage[];
isLoading: boolean;
isSending: boolean;
error: string | null;
sendMessage: (content: string) => Promise<void>;
loadMoreMessages: () => Promise<void>;
hasMore: boolean;
isTyping: boolean;
otherUserTyping: boolean;
}
/**
* Hook para gerenciar um chat específico
*/
export function useChat(chatId: string | null): UseChatReturn {
const { user } = useSession();
const { socket, isConnected } = useSocket();
const queryClient = useQueryClient();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isSending, setIsSending] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [isTyping, setIsTyping] = useState(false);
const [otherUserTyping, setOtherUserTyping] = useState(false);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Buscar dados do chat
const { data: chatData, isLoading: isLoadingChat } = useQuery<{
success: boolean;
chat: Chat;
}>({
queryKey: ["chat", chatId],
queryFn: async () => {
if (!chatId) return null;
const res = await fetch(`/api/chats/${chatId}`);
if (!res.ok) throw new Error("Erro ao buscar chat");
return res.json();
},
enabled: !!chatId && !!user,
staleTime: 1000 * 30,
});
// Buscar mensagens
const { data: messagesData, isLoading: isLoadingMessages } = useQuery<{
success: boolean;
messages: ChatMessage[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}>({
queryKey: ["chat-messages", chatId, page],
queryFn: async () => {
if (!chatId) return null;
const res = await fetch(`/api/chats/${chatId}/messages?page=${page}&limit=50`);
if (!res.ok) throw new Error("Erro ao buscar mensagens");
return res.json();
},
enabled: !!chatId && !!user,
staleTime: 1000 * 10,
});
// Atualizar mensagens quando dados mudarem
useEffect(() => {
if (messagesData?.messages) {
if (page === 1) {
setMessages(messagesData.messages);
} else {
// Adicionar mensagens antigas no início
setMessages((prev) => [...messagesData.messages, ...prev]);
}
setHasMore(messagesData.pagination.page < messagesData.pagination.totalPages);
}
}, [messagesData, page]);
// Conectar ao Socket.io quando chatId mudar
useEffect(() => {
if (!socket || !isConnected || !chatId) return;
// Entrar no chat
socket.emit("join_chat", { chat_id: chatId });
// Listener para novas mensagens
const handleNewMessage = (data: { message: ChatMessage }) => {
console.log("[CHAT-DEBUG] useChat: new_message recebido", { message: data.message });
// Verificar se a mensagem já existe para evitar duplicação
setMessages((prev) => {
const messageExists = prev.some((msg) => msg._id === data.message._id);
if (messageExists) {
console.log("[CHAT-DEBUG] Mensagem já existe, ignorando duplicação", { messageId: data.message._id });
return prev;
}
return [...prev, data.message];
});
// Invalidar cache
queryClient.invalidateQueries({ queryKey: ["chat-messages", chatId] });
queryClient.invalidateQueries({ queryKey: ["chat", chatId] });
};
// Listener para indicador de digitação
const handleTyping = (data: { user_id: number; username: string; is_typing: boolean }) => {
if (data.user_id !== user?.userId) {
setOtherUserTyping(data.is_typing);
}
};
socket.on("new_message", handleNewMessage);
socket.on("user_typing", handleTyping);
return () => {
socket.off("new_message", handleNewMessage);
socket.off("user_typing", handleTyping);
};
}, [socket, isConnected, chatId, user, queryClient]);
// Função para enviar mensagem
const sendMessage = useCallback(
async (content: string) => {
if (!chatId || !socket || !isConnected || !content.trim()) return;
console.log("[CHAT-DEBUG] sendMessage chamado", { chatId, content, socketConnected: isConnected });
setIsSending(true);
try {
// Enviar via Socket.io
socket.emit("send_message", {
chat_id: chatId,
content: content.trim(),
});
// REMOVIDO: Otimistic update que causava duplicação
// A mensagem será adicionada automaticamente quando o servidor emitir "new_message"
// Parar de digitar
setIsTyping(false);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
socket.emit("typing", { chat_id: chatId, is_typing: false });
} catch (error) {
console.error("[CHAT-DEBUG] Erro ao enviar mensagem:", error);
} finally {
setIsSending(false);
}
},
[chatId, socket, isConnected, user]
);
// Função para carregar mais mensagens
const loadMoreMessages = useCallback(async () => {
if (!hasMore || isLoadingMessages) return;
setPage((prev) => prev + 1);
}, [hasMore, isLoadingMessages]);
return {
chat: chatData?.chat || null,
messages,
isLoading: isLoadingChat || isLoadingMessages,
isSending,
error: null,
sendMessage,
loadMoreMessages,
hasMore,
isTyping,
otherUserTyping,
};
}
// Exportar handlers de digitação separadamente se necessário
export function useChatTyping(
socket: Socket | null,
isConnected: boolean,
chatId: string | null
) {
const [isTyping, setIsTyping] = useState(false);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleTypingStart = useCallback(() => {
if (!socket || !isConnected || !chatId || isTyping) return;
setIsTyping(true);
socket.emit("typing", { chat_id: chatId, is_typing: true });
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
typingTimeoutRef.current = setTimeout(() => {
setIsTyping(false);
socket.emit("typing", { chat_id: chatId, is_typing: false });
}, 3000);
}, [socket, isConnected, chatId, isTyping]);
const handleTypingStop = useCallback(() => {
if (!socket || !isConnected || !chatId || !isTyping) return;
setIsTyping(false);
socket.emit("typing", { chat_id: chatId, is_typing: false });
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
}, [socket, isConnected, chatId, isTyping]);
useEffect(() => {
return () => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
};
}, []);
return { handleTypingStart, handleTypingStop };
}
+1
View File
@@ -24,6 +24,7 @@ export interface Listing {
image_url?: string | null;
item_category?: string | null;
seller_character_name?: string | null;
has_pending_intention_for_current_user?: boolean;
}
interface ListingsResponse {
+113
View File
@@ -0,0 +1,113 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
export type PurchaseIntentionStatus =
| "pending"
| "accepted"
| "rejected"
| "expired"
| "completed";
export interface PurchaseIntentionListing {
listing_id: number;
item_id: number;
item_id_unified: number | null;
item_name: string;
quantity: number;
server_name: string;
seller_id: number;
price_pix: number | null;
price_tibia_coins: number | null;
price_gold_coins: number | null;
listing_status: string;
created_at: Date | string;
description: string | null;
trade_item_id: number | null;
trade_item_name: string | null;
trade_item_quantity: number | null;
views_count: number;
favorites_count: number;
is_featured: boolean;
image_url: string | null;
image_local_path: string | null;
item_category: string | null;
seller_character_name: string | null;
seller_username: string | null;
seller_email: string | null;
}
export interface PurchaseIntention {
intention_id: number;
listing_id: number;
buyer_id: number;
payment_method: string;
payment_method_change_count: number;
chat_id: string | null;
status: PurchaseIntentionStatus;
buyer_message: string | null;
seller_response: string | null;
created_at: Date | string;
updated_at: Date | string;
expires_at: Date | string | null;
accepted_at: Date | string | null;
rejected_at: Date | string | null;
listing: PurchaseIntentionListing | null;
}
interface PurchaseIntentionsResponse {
success: boolean;
intentions: PurchaseIntention[];
count: number;
}
/**
* Hook para buscar intenções de compra do usuário autenticado
* Atualiza automaticamente a cada 30 segundos
*/
export function useMyPurchaseIntentions(status?: PurchaseIntentionStatus) {
const queryParams = new URLSearchParams();
if (status) queryParams.set("status", status);
queryParams.set("limit", "100"); // Limite alto para mostrar todas
const { data, isLoading, error, refetch } = useQuery<PurchaseIntentionsResponse>({
queryKey: ["my-purchase-intentions", status],
queryFn: async () => {
const res = await fetch(`/api/purchase-intentions/my?${queryParams.toString()}`);
if (!res.ok) {
if (res.status === 401) {
throw new Error("Não autenticado");
}
throw new Error("Erro ao buscar intenções de compra");
}
return res.json();
},
staleTime: 1000 * 30, // 30 segundos
refetchInterval: 1000 * 30, // Refetch a cada 30 segundos
refetchOnWindowFocus: true,
refetchOnReconnect: true,
enabled: true, // Sempre habilitado (autenticação é verificada no backend)
});
return {
intentions: data?.intentions || [],
count: data?.count || 0,
isLoading,
error,
refetch,
};
}
/**
* Hook para invalidar cache de purchase intentions
*/
export function useInvalidatePurchaseIntentions() {
const queryClient = useQueryClient();
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ["my-purchase-intentions"] });
};
return invalidate;
}
+1
View File
@@ -6,6 +6,7 @@ export interface SessionUser {
userId: number;
username: string;
email: string;
characterName: string | null;
}
interface SessionResponse {
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { io, Socket } from "socket.io-client";
import { useSession } from "./use-session";
interface UseSocketReturn {
socket: Socket | null;
isConnected: boolean;
error: string | null;
}
/**
* Hook para gerenciar conexão Socket.io
*/
export function useSocket(): UseSocketReturn {
const { user } = useSession();
const [socket, setSocket] = useState<Socket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const socketRef = useRef<Socket | null>(null);
useEffect(() => {
if (!user) {
// Desconectar se usuário não estiver autenticado
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
setSocket(null);
setIsConnected(false);
}
return;
}
// Conectar ao Socket.io
const connectSocket = async () => {
if (socketRef.current?.connected) {
return;
}
try {
const newSocket = io(process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000", {
path: "/api/socket",
transports: ["websocket", "polling"],
withCredentials: true, // Enviar cookies automaticamente
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
reconnectionAttempts: 5,
});
newSocket.on("connect", () => {
setIsConnected(true);
setError(null);
});
newSocket.on("disconnect", () => {
setIsConnected(false);
});
newSocket.on("connect_error", (err) => {
setError(err.message);
setIsConnected(false);
});
socketRef.current = newSocket;
setSocket(newSocket);
} catch (err) {
setError(err instanceof Error ? err.message : "Erro ao conectar");
setIsConnected(false);
}
};
connectSocket();
// Cleanup
return () => {
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
setSocket(null);
setIsConnected(false);
}
};
}, [user]);
return {
socket,
isConnected,
error,
};
}