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

+115
View File
@@ -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 (
<div className="border-b bg-muted/30 p-4 space-y-3">
{/* Informações do Anúncio */}
<div className="flex items-start gap-3">
{/* Imagem do item */}
<div className="relative w-16 h-16 rounded-lg bg-muted flex items-center justify-center flex-shrink-0 overflow-hidden">
{listing.item_image_url && !imageError ? (
<Image
src={listing.item_image_url}
alt={listing.item_name}
fill
className="object-cover"
onError={() => setImageError(true)}
/>
) : (
<Package className="h-8 w-8 text-muted-foreground" />
)}
</div>
{/* Detalhes do anúncio */}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg truncate">{listing.item_name}</h3>
<div className="flex items-center gap-3 text-sm text-muted-foreground mt-1">
<div className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
<span>{listing.server_name}</span>
</div>
<div className="flex items-center gap-1">
<Package className="h-3 w-3" />
<span>Qtd: 1</span>
</div>
</div>
{/* Preços */}
<div className="flex items-center gap-2 mt-2 flex-wrap">
{listing.price_pix && (
<Badge variant="secondary" className="gap-1">
<DollarSign className="h-3 w-3" />
{formatPrice(listing.price_pix, "pix")}
</Badge>
)}
{listing.price_tibia_coins && (
<Badge variant="secondary" className="gap-1">
<Coins className="h-3 w-3" />
{formatPrice(listing.price_tibia_coins, "tc")}
</Badge>
)}
{listing.price_gold_coins && (
<Badge variant="secondary" className="gap-1">
<Coins className="h-3 w-3" />
{formatPrice(listing.price_gold_coins, "gp")}
</Badge>
)}
</div>
</div>
</div>
{/* Informações do outro usuário */}
<div className="flex items-center gap-2 pt-2 border-t">
<User className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Conversando com:{" "}
</span>
<span className="text-sm font-medium">
{otherUser.character_name || otherUser.username}
</span>
</div>
</div>
);
}
+147
View File
@@ -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<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl h-[80vh] flex flex-col p-0 gap-0">
<DialogHeader className="sr-only">
<DialogTitle>Chat com vendedor</DialogTitle>
<DialogDescription>
Conversa sobre o anúncio
</DialogDescription>
</DialogHeader>
{isCreatingChat || (isLoading && !chat) ? (
<div className="flex-1 flex items-center justify-center">
<div className="space-y-4 w-full px-4">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-20 w-full" />
</div>
</div>
) : chat ? (
<>
<ChatHeader chat={chat} currentUserId={user.userId} />
<MessageList
messages={messages}
currentUserId={user.userId}
isLoading={isLoading}
onLoadMore={loadMoreMessages}
hasMore={hasMore}
otherUserTyping={otherUserTyping}
/>
<MessageInput
onSend={sendMessage}
isSending={isSending}
disabled={!chat.is_active}
chatId={chatId}
/>
</>
) : (
<div className="flex-1 flex items-center justify-center text-center text-muted-foreground">
<p>Erro ao carregar chat</p>
</div>
)}
</DialogContent>
</Dialog>
);
}
+63
View File
@@ -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 (
<div className="flex justify-center my-2">
<span className="text-xs text-muted-foreground bg-muted px-3 py-1 rounded-full">
{message.content}
</span>
</div>
);
}
return (
<div
className={cn(
"flex w-full mb-2",
isOwn ? "justify-end" : "justify-start"
)}
>
<div
className={cn(
"max-w-[70%] rounded-lg px-4 py-2",
isOwn
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground"
)}
>
<p className="text-sm whitespace-pre-wrap break-words">
{message.content}
</p>
<div
className={cn(
"flex items-center gap-1 mt-1 text-xs",
isOwn ? "text-primary-foreground/70" : "text-muted-foreground"
)}
>
<span>{formatTime(message.created_at)}</span>
{isOwn && message.read_at && (
<span className="opacity-70">✓✓</span>
)}
</div>
</div>
</div>
);
}
+88
View File
@@ -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<void>;
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<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handleChange = (value: string) => {
setContent(value);
if (value.trim().length > 0) {
handleTypingStart();
} else {
handleTypingStop();
}
};
return (
<div className="border-t p-4">
<div className="flex items-end gap-2">
<Textarea
value={content}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Digite sua mensagem... (Enter para enviar, Shift+Enter para nova linha)"
className="min-h-[60px] max-h-[120px] resize-none"
disabled={isSending || disabled}
rows={2}
/>
<Button
onClick={handleSubmit}
disabled={!content.trim() || isSending || disabled}
size="icon"
className="h-[60px] w-[60px] flex-shrink-0"
>
{isSending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Send className="h-5 w-5" />
)}
</Button>
</div>
<p className="text-xs text-muted-foreground mt-2">
Máximo 1000 caracteres
</p>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { useEffect, useRef } from "react";
import { MessageBubble } from "./MessageBubble";
import { Skeleton } from "@/components/ui/skeleton";
import type { ChatMessage } from "@/lib/hooks/use-chat";
interface MessageListProps {
messages: ChatMessage[];
currentUserId: number;
isLoading: boolean;
onLoadMore?: () => void;
hasMore: boolean;
otherUserTyping: boolean;
}
export function MessageList({
messages,
currentUserId,
isLoading,
onLoadMore,
hasMore,
otherUserTyping,
}: MessageListProps) {
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const prevMessagesLengthRef = useRef(0);
// Scroll para última mensagem quando novas mensagens chegarem
useEffect(() => {
if (messages.length > prevMessagesLengthRef.current) {
// Nova mensagem adicionada
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
prevMessagesLengthRef.current = messages.length;
}, [messages.length]);
// Scroll inicial para o final
useEffect(() => {
if (messages.length > 0 && !isLoading) {
messagesEndRef.current?.scrollIntoView({ behavior: "auto" });
}
}, [isLoading]);
// Detectar scroll no topo para carregar mais
const handleScroll = () => {
const container = messagesContainerRef.current;
if (!container || !onLoadMore || !hasMore || isLoading) return;
if (container.scrollTop === 0) {
const previousScrollHeight = container.scrollHeight;
onLoadMore();
// Ajustar scroll após carregar mais mensagens
setTimeout(() => {
const newScrollHeight = container.scrollHeight;
container.scrollTop = newScrollHeight - previousScrollHeight;
}, 100);
}
};
return (
<div
ref={messagesContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto p-4 space-y-1"
>
{isLoading && messages.length === 0 ? (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : messages.length === 0 ? (
<div className="flex items-center justify-center h-full text-center text-muted-foreground">
<div>
<p className="text-sm">Nenhuma mensagem ainda</p>
<p className="text-xs mt-1">Envie a primeira mensagem para começar a conversa</p>
</div>
</div>
) : (
<>
{hasMore && (
<div className="text-center py-2">
<button
onClick={onLoadMore}
className="text-xs text-muted-foreground hover:text-foreground"
disabled={isLoading}
>
{isLoading ? "Carregando..." : "Carregar mensagens anteriores"}
</button>
</div>
)}
{messages.map((message) => (
<MessageBubble
key={message._id}
message={message}
isOwn={message.sender_id === currentUserId}
/>
))}
{otherUserTyping && (
<div className="flex justify-start mb-2">
<div className="bg-muted rounded-lg px-4 py-2">
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce" />
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce delay-75" />
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce delay-150" />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</>
)}
</div>
);
}