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:
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user