feat: implement market page with QueryProvider and dynamic listings

- wrap root layout with QueryProvider for React Query
- replace market UnderConstruction placeholder with MarketClient and real data fetching
- implement search, pagination, and sort via search params using getActiveListings
- add necessary client components and queries
- update dependencies (e.g., prisma, pkg-types)
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-05 01:58:03 -03:00
1 parent 55eebde0ba
commit ab37b477df
106 files changed
+15261 -301

No files matched your search

@@ -0,0 +1,154 @@
"use client";
import { useState, useEffect } from "react";
import { Bell } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { NotificationDropdown } from "./NotificationDropdown";
import { useSession } from "@/lib/hooks/use-session";
interface Notification {
notification_id: number;
notification_type: string;
title: string;
message: string;
is_read: boolean;
related_id: number | null;
created_at: string;
}
interface NotificationBellProps {
userId?: number;
className?: string;
}
export function NotificationBell({ userId, className }: NotificationBellProps) {
const { user } = useSession();
const [unreadCount, setUnreadCount] = useState(0);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
const effectiveUserId = userId || user?.userId;
// Buscar contagem de não lidas
const fetchUnreadCount = async () => {
if (!effectiveUserId) return;
try {
const res = await fetch("/api/notifications/unread-count");
if (res.ok) {
const data = await res.json();
if (data.success) {
setUnreadCount(data.count);
}
}
} catch (error) {
console.error("Erro ao buscar contagem de notificações:", error);
}
};
// Buscar notificações recentes
const fetchNotifications = async () => {
if (!effectiveUserId) return;
setLoading(true);
try {
const res = await fetch("/api/notifications?limit=10");
if (res.ok) {
const data = await res.json();
if (data.success) {
setNotifications(data.notifications || []);
}
}
} catch (error) {
console.error("Erro ao buscar notificações:", error);
} finally {
setLoading(false);
}
};
// Polling automático a cada 30 segundos
useEffect(() => {
if (!effectiveUserId) return;
fetchUnreadCount();
fetchNotifications();
const interval = setInterval(() => {
fetchUnreadCount();
if (isOpen) {
fetchNotifications();
}
}, 30000); // 30 segundos
return () => clearInterval(interval);
}, [effectiveUserId, isOpen]);
// Atualizar quando abrir popover
useEffect(() => {
if (isOpen) {
fetchNotifications();
}
}, [isOpen]);
const handleMarkAsRead = async (notificationId: number) => {
try {
const res = await fetch(`/api/notifications/${notificationId}/read`, {
method: "POST",
});
if (res.ok) {
// Atualizar estado local
setNotifications((prev) =>
prev.map((n) =>
n.notification_id === notificationId ? { ...n, is_read: true } : n
)
);
// Atualizar contagem
setUnreadCount((prev) => Math.max(0, prev - 1));
}
} catch (error) {
console.error("Erro ao marcar notificação como lida:", error);
}
};
if (!effectiveUserId) {
return null;
}
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className={`relative ${className}`}>
<Bell className="h-5 w-5" />
{unreadCount > 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"
>
{unreadCount > 99 ? "99+" : unreadCount}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-80 p-0" align="end">
<NotificationDropdown
notifications={notifications}
onMarkAsRead={handleMarkAsRead}
onViewAll={() => {
setIsOpen(false);
// Navegar para página de notificações (implementar depois)
window.location.href = "/notifications";
}}
/>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,159 @@
"use client";
import { Button } from "@/components/ui/button";
// ScrollArea não disponível, usando div com overflow
import { Separator } from "@/components/ui/separator";
import { Bell, ShoppingCart, TrendingUp, MessageSquare, ExternalLink } from "lucide-react";
import Link from "next/link";
import { useLocale } from "next-intl";
interface Notification {
notification_id: number;
notification_type: string;
title: string;
message: string;
is_read: boolean;
related_id: number | null;
created_at: string;
}
interface NotificationDropdownProps {
notifications: Notification[];
onMarkAsRead: (notificationId: number) => Promise<void>;
onViewAll?: () => void;
}
function formatTimeAgo(date: Date): string {
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
if (seconds < 60) return "há alguns segundos";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `há ${minutes} ${minutes === 1 ? "minuto" : "minutos"}`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `há ${hours} ${hours === 1 ? "hora" : "horas"}`;
const days = Math.floor(hours / 24);
if (days < 30) return `há ${days} ${days === 1 ? "dia" : "dias"}`;
const months = Math.floor(days / 30);
if (months < 12) return `há ${months} ${months === 1 ? "mês" : "meses"}`;
const years = Math.floor(months / 12);
return `há ${years} ${years === 1 ? "ano" : "anos"}`;
}
function getNotificationIcon(type: string) {
switch (type) {
case "purchase":
case "sale":
return <ShoppingCart className="h-4 w-4" />;
case "bid":
return <TrendingUp className="h-4 w-4" />;
case "system":
return <Bell className="h-4 w-4" />;
default:
return <MessageSquare className="h-4 w-4" />;
}
}
function getNotificationColor(type: string) {
switch (type) {
case "purchase":
case "sale":
return "bg-green-500";
case "bid":
return "bg-blue-500";
case "system":
return "bg-yellow-500";
default:
return "bg-gray-500";
}
}
export function NotificationDropdown({
notifications,
onMarkAsRead,
onViewAll,
}: NotificationDropdownProps) {
const locale = useLocale();
const handleNotificationClick = async (notification: Notification) => {
if (!notification.is_read) {
await onMarkAsRead(notification.notification_id);
}
};
if (notifications.length === 0) {
return (
<div className="w-80 p-4 text-center text-sm text-muted-foreground">
Nenhuma notificação
</div>
);
}
return (
<div className="w-80 flex flex-col">
<div className="p-4 border-b">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm">Notificações</h3>
{onViewAll && (
<Button
variant="ghost"
size="sm"
onClick={onViewAll}
className="h-auto p-0 text-xs"
>
Ver todas
</Button>
)}
</div>
</div>
<div className="h-[400px] overflow-y-auto">
<div className="p-2">
{notifications.map((notification, index) => (
<div key={notification.notification_id}>
<button
onClick={() => handleNotificationClick(notification)}
className={`w-full text-left p-3 rounded-lg hover:bg-muted transition-colors ${
!notification.is_read ? "bg-muted/50" : ""
}`}
>
<div className="flex items-start gap-3">
<div
className={`${getNotificationColor(
notification.notification_type
)} rounded-full p-2 text-white flex-shrink-0`}
>
{getNotificationIcon(notification.notification_type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<h4 className="font-medium text-sm">{notification.title}</h4>
{!notification.is_read && (
<div className="h-2 w-2 bg-primary rounded-full flex-shrink-0 mt-1" />
)}
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{notification.message}
</p>
<p className="text-xs text-muted-foreground mt-1">
{formatTimeAgo(new Date(notification.created_at))}
</p>
{notification.related_id && (
<Link
href={`/${locale}/market`}
onClick={(e) => e.stopPropagation()}
className="text-xs text-primary hover:underline mt-1 inline-flex items-center gap-1"
>
Ver anúncio <ExternalLink className="h-3 w-3" />
</Link>
)}
</div>
</div>
</button>
{index < notifications.length - 1 && <Separator className="my-2" />}
</div>
))}
</div>
</div>
</div>
);
}