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

+101
View File
@@ -0,0 +1,101 @@
"use client";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Package, Clock } from "lucide-react";
import Image from "next/image";
import type { Chat } from "@/lib/hooks/use-chat";
import { formatTimeAgo } from "@/lib/utils";
interface ChatListItemProps {
chat: Chat;
currentUserId: number;
onChatClick: () => void;
}
export function ChatListItem({
chat,
currentUserId,
onChatClick,
}: ChatListItemProps) {
const otherUser =
chat.buyer_id === currentUserId
? chat.seller_cache
: chat.buyer_cache;
const unreadCount =
chat.buyer_id === currentUserId
? chat.buyer_unread_count
: chat.seller_unread_count;
const listing = chat.listing_cache;
return (
<Card
className="p-4 cursor-pointer hover:bg-accent transition-colors"
onClick={onChatClick}
>
<div className="flex items-start gap-3">
{/* Avatar do outro usuário */}
<Avatar className="h-12 w-12 flex-shrink-0">
<AvatarFallback>
{(otherUser.character_name || otherUser.username)
.charAt(0)
.toUpperCase()}
</AvatarFallback>
</Avatar>
{/* Conteúdo */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<h4 className="font-semibold text-sm truncate">
{otherUser.character_name || otherUser.username}
</h4>
{unreadCount > 0 && (
<Badge variant="destructive" className="h-5 min-w-5 px-1.5 text-xs">
{unreadCount}
</Badge>
)}
</div>
{/* Preview do item */}
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<Package className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{listing.item_name}</span>
</div>
{/* Preview da última mensagem */}
{chat.last_message_preview && (
<p className="text-xs text-muted-foreground truncate mb-1">
{chat.last_message_preview}
</p>
)}
{/* Timestamp */}
{chat.last_message_at && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<span>{formatTimeAgo(new Date(chat.last_message_at))}</span>
</div>
)}
</div>
{/* Imagem do item (opcional) */}
{listing.item_image_url && (
<div className="relative w-12 h-12 rounded-lg bg-muted flex-shrink-0 overflow-hidden">
<Image
src={listing.item_image_url}
alt={listing.item_name}
fill
className="object-cover"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
</div>
)}
</div>
</Card>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useState, useEffect } from "react";
import { MessageCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useSession } from "@/lib/hooks/use-session";
import { MessagesPanel } from "./MessagesPanel";
interface MessagesBellProps {
className?: string;
}
export function MessagesBell({ className }: MessagesBellProps) {
const { user } = useSession();
const [unreadUsersCount, setUnreadUsersCount] = useState(0);
const [isOpen, setIsOpen] = useState(false);
// Buscar contagem de usuários únicos com mensagens não vistas
const fetchUnreadCount = async () => {
if (!user) return;
try {
const res = await fetch("/api/chats/unread-count", {
credentials: "include",
});
if (res.ok) {
const data = await res.json();
if (data.success) {
setUnreadUsersCount(data.count);
}
}
} catch (error) {
console.error("[CHAT-DEBUG] Erro ao buscar contagem de chats:", error);
}
};
// Polling automático a cada 30 segundos
useEffect(() => {
if (!user) return;
fetchUnreadCount();
const interval = setInterval(() => {
fetchUnreadCount();
}, 30000); // 30 segundos
return () => clearInterval(interval);
}, [user]);
// Atualizar contagem quando abrir/fechar
useEffect(() => {
if (isOpen) {
fetchUnreadCount();
}
}, [isOpen]);
// Marcar como visto quando abrir
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
};
if (!user) {
return null;
}
return (
<>
<Button
variant="ghost"
size="icon"
className={`relative ${className}`}
onClick={() => handleOpenChange(true)}
>
<MessageCircle className="h-5 w-5" />
{unreadUsersCount > 0 && (
<Badge
variant="destructive"
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs rounded-full animate-pulse"
>
{unreadUsersCount > 99 ? "99+" : unreadUsersCount}
</Badge>
)}
</Button>
<MessagesPanel
open={isOpen}
onOpenChange={handleOpenChange}
onUnreadCountChange={setUnreadUsersCount}
/>
</>
);
}
+173
View File
@@ -0,0 +1,173 @@
"use client";
import { useState, useEffect } from "react";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { ChatListItem } from "./ChatListItem";
import { Skeleton } from "@/components/ui/skeleton";
import { useSession } from "@/lib/hooks/use-session";
import { ChatModal } from "@/components/chat/ChatModal";
import type { Chat } from "@/lib/hooks/use-chat";
interface MessagesPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onUnreadCountChange: (count: number) => void;
}
export function MessagesPanel({
open,
onOpenChange,
onUnreadCountChange,
}: MessagesPanelProps) {
const { user } = useSession();
const [chats, setChats] = useState<Chat[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [selectedChatId, setSelectedChatId] = useState<string | null>(null);
const [showChatModal, setShowChatModal] = useState(false);
// Buscar chats do usuário
const fetchChats = async () => {
if (!user) return;
setIsLoading(true);
try {
const res = await fetch("/api/chats", {
credentials: "include",
});
if (res.ok) {
const data = await res.json();
if (data.success) {
setChats(data.chats || []);
// Calcular contagem de usuários únicos com não lidas
const uniqueUsersWithUnread = new Set<number>();
data.chats.forEach((chat: Chat) => {
const unreadCount =
chat.buyer_id === user.userId
? chat.buyer_unread_count
: chat.seller_unread_count;
const otherUserId =
chat.buyer_id === user.userId
? chat.seller_id
: chat.buyer_id;
if (unreadCount > 0) {
uniqueUsersWithUnread.add(otherUserId);
}
});
onUnreadCountChange(uniqueUsersWithUnread.size);
}
}
} catch (error) {
console.error("[CHAT-DEBUG] Erro ao buscar chats:", error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (open && user) {
fetchChats();
// Marcar todos como lidos ao abrir
markAllAsRead();
}
}, [open, user]);
const markAllAsRead = async () => {
try {
await fetch("/api/chats/mark-all-read", {
method: "POST",
credentials: "include",
});
// Atualizar estado local
setChats((prev) =>
prev.map((chat) => ({
...chat,
buyer_unread_count:
chat.buyer_id === user?.userId ? 0 : chat.buyer_unread_count,
seller_unread_count:
chat.seller_id === user?.userId ? 0 : chat.seller_unread_count,
}))
);
onUnreadCountChange(0);
} catch (error) {
console.error("[CHAT-DEBUG] Erro ao marcar como lido:", error);
}
};
const handleChatClick = (chatId: string, listingId: number) => {
setSelectedChatId(chatId);
setShowChatModal(true);
onOpenChange(false);
};
if (!user) {
return null;
}
return (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-lg p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b">
<SheetTitle>Mensagens</SheetTitle>
<SheetDescription>
Suas conversas com compradores e vendedores
</SheetDescription>
</SheetHeader>
<div className="mt-4 px-4 overflow-y-auto" style={{ maxHeight: "calc(100vh - 120px)" }}>
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
) : chats.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p>Nenhuma conversa ainda</p>
<p className="text-sm mt-2">
Comece uma conversa a partir de um anúncio
</p>
</div>
) : (
<div className="space-y-2">
{chats.map((chat) => (
<ChatListItem
key={chat.chat_id}
chat={chat}
currentUserId={user.userId}
onChatClick={() => handleChatClick(chat.chat_id, chat.listing_id)}
/>
))}
</div>
)}
</div>
</SheetContent>
</Sheet>
{selectedChatId && (
<ChatModal
open={showChatModal}
onOpenChange={(open) => {
setShowChatModal(open);
if (!open) {
setSelectedChatId(null);
// Recarregar chats quando fechar o modal
fetchChats();
}
}}
listingId={null}
chatId={selectedChatId || undefined}
/>
)}
</>
);
}