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

+4
View File
@@ -11,6 +11,7 @@ import { WordRotate } from "@/components/ui/word-rotate";
import { ThemeToggle } from "./theme-toggle";
import { LanguageSwitcher } from "./language-switcher";
import { MobileMenu } from "./mobile-menu";
import { NotificationBell } from "./notifications/NotificationBell";
import { LoginDialog } from "./auth/login-dialog";
import { RegisterDialog } from "./auth/register-dialog";
import { Button } from "@/components/ui/button";
@@ -96,6 +97,9 @@ export default function Header() {
<ThemeToggle />
<LanguageSwitcher />
{/* Notifications */}
{mounted && isAuthenticated && <NotificationBell />}
{/* Auth Section */}
{!mounted ? (
// Placeholder durante SSR para evitar mismatch
+17
View File
@@ -11,6 +11,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
const languages = [
{ code: "pt", name: "Português", flag: "🇧🇷" },
@@ -22,6 +23,12 @@ export function LanguageSwitcher() {
const router = useRouter();
const pathname = usePathname();
const t = useTranslations("header");
const [mounted, setMounted] = useState(false);
// Evitar hydration mismatch com Radix UI
useEffect(() => {
setMounted(true);
}, []);
const handleLanguageChange = (newLocale: string) => {
// Remove the current locale from the pathname
@@ -32,6 +39,16 @@ export function LanguageSwitcher() {
const currentLanguage = languages.find((lang) => lang.code === locale);
// Renderizar placeholder durante SSR para evitar mismatch
if (!mounted) {
return (
<Button variant="outline" size="icon" disabled>
<Globe className="h-[1.2rem] w-[1.2rem]" />
<span className="sr-only">{t("language")}</span>
</Button>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
+799
View File
@@ -0,0 +1,799 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Heart, Clock, Eye, Star, MapPin, DollarSign, Coins, RefreshCw, ShoppingCart, TrendingUp, User } from "lucide-react";
import { useAdTracking, useMarketTelemetry } from "@/lib/hooks/use-telemetry";
import { useSession } from "@/lib/hooks/use-session";
import { useQueryClient } from "@tanstack/react-query";
import Image from "next/image";
import { PurchaseModal } from "./PurchaseModal";
import { BidModal } from "./BidModal";
import { SellerInfo } from "./SellerInfo";
import { ListingDetailsModal } from "./ListingDetailsModal";
// Função auxiliar para formatar tempo relativo
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"}`;
}
interface Listing {
listing_id: number;
seller_id: number;
item_id: number; // ID legado (SQLite) - mantido para compatibilidade
item_name: string;
quantity: number;
server_name: string;
price_pix: number | string | null; // PostgreSQL DECIMAL pode vir como string
price_tibia_coins: number | string | null; // PostgreSQL INTEGER pode vir como string
price_gold_coins: number | string | null; // PostgreSQL BIGINT pode vir como string
is_auction: boolean;
description: string | null;
listing_status: string;
created_at: string;
views_count: number;
favorites_count: number;
is_featured?: boolean;
trade_item_id?: number | null;
trade_item_name?: string | null;
trade_item_quantity?: number | null;
// Campos de leilão
current_bid_amount?: number | null;
min_bid_increment?: number | null;
initial_price_pix?: number | null;
initial_price_tibia_coins?: number | null;
initial_price_gold_coins?: number | null;
auction_ends_at?: Date | null;
// Campos do JOIN com items (unificado)
item_id_unified?: number | null; // ID unificado da tabela items
image_local_path?: string | null;
image_url?: string | null;
item_category?: string | null;
// Campo do vendedor
seller_character_name?: string | null;
}
interface PriceInfo {
type: "pix" | "tc" | "gp" | "trade";
label: string;
value: string;
}
interface AdCardProps {
listing: Listing;
viewMode?: "grid" | "list";
}
export function AdCard({ listing, viewMode = "grid" }: AdCardProps) {
const { user } = useSession();
const queryClient = useQueryClient();
const { trackAdClick, trackFavorite } = useMarketTelemetry();
const [isFavorited, setIsFavorited] = useState(false);
const [favoritesCount, setFavoritesCount] = useState(listing.favorites_count || 0);
const [showPurchaseModal, setShowPurchaseModal] = useState(false);
const [showBidModal, setShowBidModal] = useState(false);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const invalidateCache = () => {
queryClient.invalidateQueries({ queryKey: ["listings"] });
queryClient.invalidateQueries({ queryKey: ["my-listings"] });
};
// Tracking automático de visualização
useAdTracking(listing.listing_id);
// Verificar se está favoritado
useEffect(() => {
if (user?.userId && listing.listing_id) {
fetch(`/api/listings/${listing.listing_id}/favorite`)
.then((res) => {
if (res.ok) {
return res.json();
}
return null;
})
.then((data) => {
if (data?.success) {
setIsFavorited(data.favorited);
}
})
.catch(() => {
// Silenciar erro para não gerar mensagens no console
});
}
}, [user?.userId, listing.listing_id]);
const handleFavorite = async () => {
if (!user) return;
const action = isFavorited ? "unfavorite" : "favorite";
trackFavorite(listing.listing_id, action);
try {
const res = await fetch(`/api/listings/${listing.listing_id}/favorite`, {
method: "POST",
});
if (res.ok) {
const data = await res.json();
setIsFavorited(data.favorited);
setFavoritesCount((prev) => (data.favorited ? prev + 1 : Math.max(0, prev - 1)));
}
} catch (error) {
console.error("Erro ao favoritar:", error);
}
};
const handleClick = (e?: React.MouseEvent) => {
if (e) {
e.stopPropagation();
}
trackAdClick(listing.listing_id);
setShowDetailsModal(true);
};
const handlePause = async () => {
try {
const res = await fetch(`/api/listings/${listing.listing_id}/pause`, {
method: "POST",
});
if (res.ok) {
setShowDetailsModal(false);
invalidateCache();
}
} catch (error) {
console.error("Erro ao pausar anúncio:", error);
}
};
const handleActivate = async () => {
try {
const res = await fetch(`/api/listings/${listing.listing_id}/activate`, {
method: "POST",
});
if (res.ok) {
setShowDetailsModal(false);
invalidateCache();
} else {
const data = await res.json();
alert(data.error || "Erro ao reativar anúncio");
}
} catch (error) {
console.error("Erro ao reativar anúncio:", error);
alert("Erro ao reativar anúncio");
}
};
const handleCancel = async () => {
if (!confirm("Tem certeza que deseja cancelar este anúncio?")) {
return;
}
try {
const res = await fetch(`/api/listings/${listing.listing_id}`, {
method: "DELETE",
});
if (res.ok) {
setShowDetailsModal(false);
invalidateCache();
} else {
const data = await res.json();
alert(data.error || "Erro ao cancelar anúncio");
}
} catch (error) {
console.error("Erro ao cancelar anúncio:", error);
alert("Erro ao cancelar anúncio");
}
};
const getAvailablePrices = (): PriceInfo[] => {
const prices: PriceInfo[] = [];
// PIX
if (listing.price_pix) {
const price = typeof listing.price_pix === 'string'
? parseFloat(listing.price_pix)
: listing.price_pix;
if (!isNaN(price)) {
prices.push({
type: "pix",
label: "PIX",
value: `R$ ${price.toFixed(2)}`,
});
}
}
// Tibia Coins
if (listing.price_tibia_coins) {
const price = typeof listing.price_tibia_coins === 'string'
? parseInt(listing.price_tibia_coins, 10)
: listing.price_tibia_coins;
if (!isNaN(price)) {
prices.push({
type: "tc",
label: "TC",
value: `${price.toLocaleString()} TC`,
});
}
}
// Gold Coins
if (listing.price_gold_coins) {
const price = typeof listing.price_gold_coins === 'string'
? parseInt(listing.price_gold_coins, 10)
: listing.price_gold_coins;
if (!isNaN(price)) {
prices.push({
type: "gp",
label: "GP",
value: `${price.toLocaleString()} GP`,
});
}
}
// Item de Troca
if (listing.trade_item_id && listing.trade_item_name && listing.trade_item_quantity) {
const quantity = listing.trade_item_quantity;
prices.push({
type: "trade",
label: "Troca",
value: `Troca por ${quantity}x ${listing.trade_item_name}`,
});
}
return prices;
};
const timeAgo = formatTimeAgo(new Date(listing.created_at));
const availablePrices = getAvailablePrices();
const isSold = listing.listing_status === "sold";
// Função para obter URL da imagem do item
const getItemImageUrl = (): string => {
// Prioridade 1: image_url (URL externa) se disponível
if (listing.image_url) {
return listing.image_url;
}
// Prioridade 2: image_local_path se disponível
if (listing.image_local_path) {
const normalizedPath = listing.image_local_path.replace(/\\/g, "/");
if (normalizedPath.startsWith("data/")) {
return `/api/images?path=${encodeURIComponent(normalizedPath)}`;
}
// Construir caminho se necessário
const category = listing.item_category || "other";
return `/api/images?path=${encodeURIComponent(`data/images/${category}/${normalizedPath}`)}`;
}
// Prioridade 3: Usar item_id_unified se disponível (com nome para validação)
if (listing.item_id_unified) {
const params = new URLSearchParams({ item: listing.item_id_unified.toString() });
if (listing.item_name) {
params.set("name", listing.item_name);
}
return `/api/images?${params.toString()}`;
}
// Fallback: Usar item_id legado (com nome para validação)
const params = new URLSearchParams({ item: listing.item_id.toString() });
if (listing.item_name) {
params.set("name", listing.item_name);
}
return `/api/images?${params.toString()}`;
};
const itemImageUrl = getItemImageUrl();
// Componente para exibir badges de preços
const PriceBadges = ({ prices, compact = false }: { prices: PriceInfo[]; compact?: boolean }) => {
if (prices.length === 0) {
return <div className="text-sm text-muted-foreground">Preço não informado</div>;
}
const getBadgeClassName = (type: PriceInfo["type"]) => {
switch (type) {
case "pix":
return "bg-green-500 hover:bg-green-600 text-white";
case "tc":
return "bg-blue-500 hover:bg-blue-600 text-white";
case "gp":
return "bg-yellow-500 hover:bg-yellow-600 text-white";
case "trade":
return "bg-purple-500 hover:bg-purple-600 text-white";
default:
return "";
}
};
const getIcon = (type: PriceInfo["type"]) => {
switch (type) {
case "pix":
return <DollarSign className="h-3 w-3" />;
case "tc":
case "gp":
return <Coins className="h-3 w-3" />;
case "trade":
return <RefreshCw className="h-3 w-3" />;
default:
return null;
}
};
if (prices.length === 1 && !compact) {
// Se houver apenas um preço, mostrar em destaque
const price = prices[0];
return (
<div className="flex items-center gap-2">
<div className="text-2xl font-bold">{price.value}</div>
<Badge className={getBadgeClassName(price.type)}>
{getIcon(price.type)}
<span className="ml-1">{price.label}</span>
</Badge>
</div>
);
}
// Múltiplos preços: mostrar badges
if (compact) {
// Layout compacto para modo list
return (
<div className="flex flex-wrap items-center gap-2">
{prices.map((price, index) => (
<Badge key={index} className={getBadgeClassName(price.type)}>
{getIcon(price.type)}
<span className="ml-1">{price.value}</span>
</Badge>
))}
</div>
);
}
// Layout para modo grid
if (prices.length <= 3) {
// Grid 2 colunas para 2-3 preços
return (
<div className="grid grid-cols-2 gap-2">
{prices.map((price, index) => (
<Badge key={index} className={`${getBadgeClassName(price.type)} justify-center`}>
{getIcon(price.type)}
<span className="ml-1 text-xs">{price.value}</span>
</Badge>
))}
</div>
);
}
// Flex wrap para 4+ preços
return (
<div className="flex flex-wrap gap-2">
{prices.map((price, index) => (
<Badge key={index} className={`${getBadgeClassName(price.type)} text-xs`}>
{getIcon(price.type)}
<span className="ml-1">{price.value}</span>
</Badge>
))}
</div>
);
};
if (viewMode === "list") {
return (
<Card
className={`hover:shadow-lg transition-shadow ${isSold ? "opacity-75 relative" : ""}`}
>
{isSold && (
<div className="absolute inset-0 bg-black/10 rounded-lg z-10 pointer-events-none" />
)}
<div className="flex gap-4 p-4">
{/* Thumbnail */}
<div className="relative w-24 h-24 flex-shrink-0 rounded-lg overflow-hidden bg-muted flex items-center justify-center p-2">
<Image
src={itemImageUrl}
alt={listing.item_name}
fill
className="object-contain"
sizes="96px"
onError={(e) => {
(e.target as HTMLImageElement).src = "/placeholder-item.png";
}}
/>
</div>
{/* Conteúdo */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg truncate">{listing.item_name}</h3>
<div className="mt-2 mb-2">
{listing.seller_character_name && (
<SellerInfo
sellerId={listing.seller_id}
sellerName={listing.seller_character_name}
serverName={listing.server_name}
listingId={listing.listing_id}
/>
)}
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>{timeAgo}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{isSold && (
<Badge variant="default" className="bg-red-600 text-white font-semibold">
Vendido
</Badge>
)}
{listing.is_featured && (
<Badge variant="default" className="bg-yellow-500">
<Star className="h-3 w-3 mr-1" />
Destaque
</Badge>
)}
{listing.is_auction && (
<Badge variant="secondary">Leilão</Badge>
)}
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleFavorite();
}}
disabled={!user}
>
<Heart
className={`h-4 w-4 ${isFavorited ? "fill-red-500 text-red-500" : ""}`}
/>
<span className="ml-1">{favoritesCount}</span>
</Button>
</div>
</div>
{listing.description && (
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
{listing.description}
</p>
)}
<div className="flex items-center justify-between mt-3">
<div className="flex-1 min-w-0">
<PriceBadges prices={availablePrices} compact />
{/* Informações de lance se for leilão */}
{listing.is_auction && (
<div className="mt-2 bg-muted p-2 rounded text-xs space-y-1">
{listing.current_bid_amount ? (
<>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Lance Atual:</span>
<span className="font-semibold text-primary">
R$ {typeof listing.current_bid_amount === 'number' ? listing.current_bid_amount.toFixed(2) : parseFloat(String(listing.current_bid_amount)).toFixed(2)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Leilão Ativo</span>
<Badge variant="secondary" className="text-xs">
<TrendingUp className="h-3 w-3 mr-1" />
Lances
</Badge>
</div>
</>
) : (
<div className="text-center text-muted-foreground">
Sem lances ainda
</div>
)}
</div>
)}
</div>
<div className="flex items-center gap-4 ml-4">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Eye className="h-4 w-4" />
<span>{listing.views_count}</span>
</div>
<div className="flex items-center gap-1">
<span>Qtd: {listing.quantity}</span>
</div>
</div>
{/* Botões de ação - desabilitados se vendido */}
{user && user.userId !== listing.seller_id && listing.listing_status === "active" && !isSold && (
<div className="flex items-center gap-2">
{listing.is_auction ? (
<Button
variant="default"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowBidModal(true);
}}
disabled={isSold}
>
<TrendingUp className="h-4 w-4 mr-2" />
Dar Lance
</Button>
) : (
<Button
variant="default"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowPurchaseModal(true);
}}
disabled={isSold}
>
<ShoppingCart className="h-4 w-4 mr-2" />
Comprar
</Button>
)}
</div>
)}
{isSold && (
<div className="text-sm text-muted-foreground italic">
Este item já foi vendido
</div>
)}
</div>
</div>
</div>
</div>
{/* Modais */}
<PurchaseModal
open={showPurchaseModal}
onOpenChange={setShowPurchaseModal}
listing={listing}
onSuccess={() => {
invalidateCache();
}}
/>
<BidModal
open={showBidModal}
onOpenChange={setShowBidModal}
listing={listing}
onSuccess={() => {
invalidateCache();
}}
/>
<ListingDetailsModal
open={showDetailsModal}
onOpenChange={setShowDetailsModal}
listing={listing}
onPause={handlePause}
onActivate={handleActivate}
onCancel={handleCancel}
/>
</Card>
);
}
// View mode grid (padrão)
return (
<Card
className={`hover:shadow-lg transition-shadow flex flex-col ${isSold ? "opacity-75 relative" : ""}`}
>
{isSold && (
<div className="absolute inset-0 bg-black/10 rounded-lg z-10 pointer-events-none" />
)}
<CardHeader className="p-0 relative">
{/* Thumbnail */}
<div className="relative w-full aspect-square rounded-t-lg overflow-hidden bg-muted flex items-center justify-center p-4">
<Image
src={itemImageUrl}
alt={listing.item_name}
fill
className="object-contain"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
onError={(e) => {
(e.target as HTMLImageElement).src = "/placeholder-item.png";
}}
/>
{isSold && (
<div className="absolute top-2 right-2 z-20">
<Badge variant="default" className="bg-red-600 text-white font-semibold">
Vendido
</Badge>
</div>
)}
{listing.is_featured && (
<div className={`absolute ${isSold ? "top-2 left-2" : "top-2 right-2"} z-20`}>
<Badge variant="default" className="bg-yellow-500">
<Star className="h-3 w-3 mr-1" />
Destaque
</Badge>
</div>
)}
{listing.is_auction && !isSold && (
<div className="absolute top-2 left-2 z-20">
<Badge variant="secondary">Leilão</Badge>
</div>
)}
</div>
</CardHeader>
<CardContent className="p-4 flex-1 flex flex-col">
<h3 className="font-semibold text-lg mb-2 line-clamp-2">{listing.item_name}</h3>
<div className="mb-2">
{listing.seller_character_name && (
<SellerInfo
sellerId={listing.seller_id}
sellerName={listing.seller_character_name}
serverName={listing.server_name}
listingId={listing.listing_id}
/>
)}
</div>
{listing.description && (
<p className="text-sm text-muted-foreground mb-3 line-clamp-2">
{listing.description}
</p>
)}
<div className="mt-auto space-y-2">
<PriceBadges prices={availablePrices} />
{/* Informações de lance se for leilão */}
{listing.is_auction && (
<div className="bg-muted p-2 rounded text-xs space-y-1">
{listing.current_bid_amount ? (
<>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Lance Atual:</span>
<span className="font-semibold text-primary">
R$ {typeof listing.current_bid_amount === 'number' ? listing.current_bid_amount.toFixed(2) : parseFloat(String(listing.current_bid_amount)).toFixed(2)}
</span>
</div>
{listing.min_bid_increment && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Incremento Mín:</span>
<span className="font-medium">
R$ {typeof listing.min_bid_increment === 'number' ? listing.min_bid_increment.toFixed(2) : parseFloat(String(listing.min_bid_increment)).toFixed(2)}
</span>
</div>
)}
<div className="flex items-center justify-center pt-1">
<Badge variant="secondary" className="text-xs">
<TrendingUp className="h-3 w-3 mr-1" />
Leilão Ativo
</Badge>
</div>
</>
) : (
<div className="text-center text-muted-foreground py-1">
Sem lances ainda
</div>
)}
</div>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>{timeAgo}</span>
</div>
<div className="flex items-center gap-1">
<Eye className="h-3 w-3" />
<span>{listing.views_count}</span>
</div>
</div>
</div>
</CardContent>
<CardFooter className="p-4 pt-0 flex items-center justify-between gap-2">
{/* Botão de compra/lance - apenas se não for o vendedor, estiver ativo e não vendido */}
{user && user.userId !== listing.seller_id && listing.listing_status === "active" && !isSold && (
<>
{listing.is_auction ? (
<Button
variant="default"
size="sm"
className="flex-1"
onClick={(e) => {
e.stopPropagation();
setShowBidModal(true);
}}
disabled={isSold}
>
<TrendingUp className="h-4 w-4 mr-2" />
Dar Lance
</Button>
) : (
<Button
variant="default"
size="sm"
className="flex-1"
onClick={(e) => {
e.stopPropagation();
setShowPurchaseModal(true);
}}
disabled={isSold}
>
<ShoppingCart className="h-4 w-4 mr-2" />
Comprar
</Button>
)}
</>
)}
{isSold && (
<div className="text-sm text-muted-foreground italic flex-1 text-center">
Este item já foi vendido
</div>
)}
{/* Se não for o vendedor, mostrar botão de detalhes também */}
{(!user || user.userId === listing.seller_id) && (
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={handleClick}
>
Ver Detalhes
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleFavorite();
}}
disabled={!user}
>
<Heart
className={`h-4 w-4 ${isFavorited ? "fill-red-500 text-red-500" : ""}`}
/>
<span className="ml-1">{favoritesCount}</span>
</Button>
</CardFooter>
{/* Modais */}
<PurchaseModal
open={showPurchaseModal}
onOpenChange={setShowPurchaseModal}
listing={listing}
onSuccess={() => {
// Recarregar página ou atualizar lista
window.location.reload();
}}
/>
<BidModal
open={showBidModal}
onOpenChange={setShowBidModal}
listing={listing}
onSuccess={() => {
// Recarregar página ou atualizar lista
window.location.reload();
}}
/>
<ListingDetailsModal
open={showDetailsModal}
onOpenChange={setShowDetailsModal}
listing={listing}
onPause={handlePause}
onActivate={handleActivate}
onCancel={handleCancel}
/>
</Card>
);
}
@@ -0,0 +1,269 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useSession } from "@/lib/hooks/use-session";
import { TrendingUp, Eye, MousePointerClick, Heart, MessageSquare, Clock } from "lucide-react";
interface AdAnalytics {
ad_id: number;
impressions: number;
clicks: number;
ctr: number;
avg_view_duration: number;
bounce_rate: number;
inquiries: number;
favorites: number;
conversion_rate: number;
time_to_sell: number | null;
last_updated: Date;
}
interface Listing {
listing_id: number;
item_name: string;
listing_status: string;
}
export function AnalyticsDashboard() {
const { user } = useSession();
const [listings, setListings] = useState<Listing[]>([]);
const [selectedAd, setSelectedAd] = useState<number | null>(null);
const [analytics, setAnalytics] = useState<AdAnalytics | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (user?.userId) {
loadUserListings();
}
}, [user?.userId]);
useEffect(() => {
if (selectedAd) {
loadAnalytics(selectedAd);
}
}, [selectedAd]);
const loadUserListings = async () => {
setLoading(true);
try {
const res = await fetch("/api/listings/my");
if (res.ok) {
const data = await res.json();
setListings(data.listings || []);
if (data.listings && data.listings.length > 0) {
setSelectedAd(data.listings[0].listing_id);
}
}
} catch (error) {
console.error("Erro ao carregar anúncios:", error);
} finally {
setLoading(false);
}
};
const loadAnalytics = async (adId: number) => {
try {
const res = await fetch(`/api/telemetry/analytics?adId=${adId}`);
if (res.ok) {
const data = await res.json();
setAnalytics(data.analytics);
}
} catch (error) {
console.error("Erro ao carregar analytics:", error);
}
};
if (!user) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">
Faça login para ver suas analytics
</p>
</div>
);
}
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-64" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-32" />
))}
</div>
</div>
);
}
if (listings.length === 0) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">
Você ainda não tem anúncios. Crie um anúncio para ver as analytics.
</p>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Analytics dos Anúncios</h1>
<p className="text-muted-foreground mt-1">
Acompanhe o desempenho dos seus anúncios
</p>
</div>
{/* Seletor de anúncio */}
<div className="space-y-2">
<label className="text-sm font-medium">Selecione um anúncio</label>
<select
className="w-full p-2 border rounded-md"
value={selectedAd || ""}
onChange={(e) => setSelectedAd(parseInt(e.target.value, 10))}
>
{listings.map((listing) => (
<option key={listing.listing_id} value={listing.listing_id}>
{listing.item_name} ({listing.listing_status})
</option>
))}
</select>
</div>
{/* Métricas */}
{analytics && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="h-5 w-5" />
Impressões
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{analytics.impressions}</div>
<p className="text-sm text-muted-foreground mt-1">
Visualizações do anúncio
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MousePointerClick className="h-5 w-5" />
Cliques
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{analytics.clicks}</div>
<p className="text-sm text-muted-foreground mt-1">
CTR: {analytics.ctr.toFixed(2)}%
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Duração Média
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{analytics.avg_view_duration.toFixed(1)}s
</div>
<p className="text-sm text-muted-foreground mt-1">
Tempo médio de visualização
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Bounce Rate
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{analytics.bounce_rate.toFixed(1)}%
</div>
<p className="text-sm text-muted-foreground mt-1">
Usuários que saíram em menos de 3s
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Inquiries
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{analytics.inquiries}</div>
<p className="text-sm text-muted-foreground mt-1">
Mensagens recebidas
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Heart className="h-5 w-5" />
Favoritos
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{analytics.favorites}</div>
<p className="text-sm text-muted-foreground mt-1">
Usuários que favoritaram
</p>
</CardContent>
</Card>
{analytics.conversion_rate > 0 && (
<Card>
<CardHeader>
<CardTitle>Taxa de Conversão</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{analytics.conversion_rate.toFixed(2)}%
</div>
<p className="text-sm text-muted-foreground mt-1">
Taxa de vendas
</p>
</CardContent>
</Card>
)}
{analytics.time_to_sell && (
<Card>
<CardHeader>
<CardTitle>Tempo até Venda</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{Math.round(analytics.time_to_sell)}h
</div>
<p className="text-sm text-muted-foreground mt-1">
Tempo médio até ser vendido
</p>
</CardContent>
</Card>
)}
</div>
)}
</div>
);
}
+366
View File
@@ -0,0 +1,366 @@
"use client";
import { useState, useEffect } from "react";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Coins, DollarSign, Loader2, TrendingUp } from "lucide-react";
import { useSession } from "@/lib/hooks/use-session";
interface Listing {
listing_id: number;
item_name: string;
quantity: number;
current_bid_amount: number | null;
min_bid_increment: number | null;
initial_price_pix: number | null;
initial_price_tibia_coins: number | null;
initial_price_gold_coins: number | null;
price_pix: number | null;
price_tibia_coins: number | null;
price_gold_coins: number | null;
auction_ends_at: Date | null;
}
interface BidModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
listing: Listing | null;
onSuccess?: () => void;
}
export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalProps) {
const { user } = useSession();
const [bidAmount, setBidAmount] = useState<string>("");
const [paymentMethod, setPaymentMethod] = useState<string>("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [bidHistory, setBidHistory] = useState<Array<{
bid_id: number;
bidder_id: number;
bidder_name: string;
bid_amount: number;
bid_type: string | null;
created_at: Date;
}>>([]);
const [loadingHistory, setLoadingHistory] = useState(false);
if (!listing) return null;
// Calcular valores
const currentBid = listing.current_bid_amount || 0;
const minIncrement = listing.min_bid_increment || 10;
const minBid = currentBid + minIncrement;
const initialPrice = listing.initial_price_pix || listing.initial_price_tibia_coins || listing.initial_price_gold_coins || 0;
// Determinar métodos de pagamento disponíveis para lance
const availableMethods: string[] = [];
if (listing.price_pix || listing.initial_price_pix) availableMethods.push("pix");
if (listing.price_tibia_coins || listing.initial_price_tibia_coins) availableMethods.push("tc");
if (listing.price_gold_coins || listing.initial_price_gold_coins) availableMethods.push("gp");
// Selecionar primeiro método disponível por padrão
useEffect(() => {
if (availableMethods.length > 0 && !paymentMethod) {
setPaymentMethod(availableMethods[0]);
}
}, [availableMethods.length]);
// Atualizar valor do lance quando mudar
useEffect(() => {
if (bidAmount === "" && minBid > 0) {
setBidAmount(minBid.toFixed(2));
}
}, [minBid]);
// Buscar histórico de lances quando abrir modal
useEffect(() => {
if (open && listing) {
fetchBidHistory();
}
}, [open, listing?.listing_id]);
const fetchBidHistory = async () => {
if (!listing) return;
setLoadingHistory(true);
try {
const res = await fetch(`/api/listings/${listing.listing_id}/bids?limit=5`);
if (res.ok) {
const data = await res.json();
if (data.success) {
setBidHistory(data.bids || []);
}
}
} catch (error) {
console.error("Erro ao buscar histórico de lances:", error);
} finally {
setLoadingHistory(false);
}
};
const handleBid = async () => {
const amount = parseFloat(bidAmount);
if (isNaN(amount) || amount <= 0) {
setError("Valor do lance inválido");
return;
}
if (amount < minBid) {
setError(`Lance mínimo é ${minBid.toFixed(2)} (lance atual: ${currentBid.toFixed(2)} + incremento: ${minIncrement.toFixed(2)})`);
return;
}
if (!paymentMethod) {
setError("Selecione um método de pagamento");
return;
}
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/listings/${listing.listing_id}/bid`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
amount,
payment_method: paymentMethod,
}),
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || "Erro ao processar lance");
}
// Sucesso - atualizar histórico
await fetchBidHistory();
if (onSuccess) {
onSuccess();
}
onOpenChange(false);
setBidAmount("");
} catch (err) {
setError(err instanceof Error ? err.message : "Erro ao processar lance");
} finally {
setLoading(false);
}
};
const formatPrice = (price: number | null) => {
if (!price) return "N/A";
return `R$ ${price.toFixed(2)}`;
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Dar Lance</DialogTitle>
<DialogDescription>
Informe o valor do seu lance para este leilão
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Resumo do item */}
<div className="bg-muted p-4 rounded-lg">
<h3 className="font-semibold mb-2">{listing.item_name}</h3>
<p className="text-sm text-muted-foreground">
Quantidade: {listing.quantity}
</p>
</div>
<Separator />
{/* Informações do leilão */}
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs text-muted-foreground">Preço Inicial</Label>
<p className="text-lg font-semibold">{formatPrice(initialPrice)}</p>
</div>
<div>
<Label className="text-xs text-muted-foreground">Lance Atual</Label>
<p className="text-lg font-semibold text-primary">
{currentBid > 0 ? formatPrice(currentBid) : "Sem lances"}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs text-muted-foreground">Incremento Mínimo</Label>
<p className="text-sm font-medium">{formatPrice(minIncrement)}</p>
</div>
<div>
<Label className="text-xs text-muted-foreground">Lance Mínimo Necessário</Label>
<p className="text-sm font-semibold text-green-600">{formatPrice(minBid)}</p>
</div>
</div>
{listing.auction_ends_at && (
<div>
<Label className="text-xs text-muted-foreground">Término do Leilão</Label>
<p className="text-sm">
{new Date(listing.auction_ends_at).toLocaleString("pt-BR")}
</p>
</div>
)}
</div>
<Separator />
{/* Histórico de lances */}
{bidHistory.length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Histórico de Lances (Últimos 5)</Label>
<div className="bg-muted rounded-lg p-3 space-y-2 max-h-48 overflow-y-auto">
{bidHistory.map((bid, index) => {
const isCurrentUser = user?.userId === bid.bidder_id;
const isHighest = index === 0;
return (
<div
key={bid.bid_id}
className={`p-2 rounded ${
isCurrentUser ? "bg-primary/10 border border-primary/20" : ""
} ${isHighest ? "border-l-2 border-l-primary" : ""}`}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">
{isCurrentUser ? "Você" : bid.bidder_name}
</span>
{isHighest && (
<Badge variant="secondary" className="text-xs">
Líder
</Badge>
)}
{isCurrentUser && (
<Badge variant="outline" className="text-xs">
Seu lance
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{new Date(bid.created_at).toLocaleString("pt-BR")}
</p>
</div>
<span className="font-semibold text-primary">
R$ {bid.bid_amount.toFixed(2)}
</span>
</div>
</div>
);
})}
</div>
</div>
)}
{loadingHistory && (
<div className="text-center text-sm text-muted-foreground py-4">
Carregando histórico...
</div>
)}
<Separator />
{/* Input do lance */}
<div className="space-y-2">
<Label htmlFor="bid-amount">Valor do Lance (R$)</Label>
<Input
id="bid-amount"
type="number"
step="0.01"
min={minBid}
value={bidAmount}
onChange={(e) => setBidAmount(e.target.value)}
placeholder={minBid.toFixed(2)}
className="text-lg"
/>
<p className="text-xs text-muted-foreground">
Valor mínimo: {formatPrice(minBid)}
</p>
</div>
{/* Seleção de método de pagamento */}
{availableMethods.length > 0 && (
<div className="space-y-2">
<Label htmlFor="payment-method">Método de Pagamento</Label>
<Select value={paymentMethod} onValueChange={setPaymentMethod}>
<SelectTrigger id="payment-method">
<SelectValue placeholder="Selecione o método" />
</SelectTrigger>
<SelectContent>
{availableMethods.map((method) => (
<SelectItem key={method} value={method}>
{method.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{error && (
<div className="bg-destructive/10 text-destructive p-3 rounded-lg text-sm">
{error}
</div>
)}
<Separator />
{/* Botões */}
<div className="flex gap-2 justify-end">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancelar
</Button>
<Button
onClick={handleBid}
disabled={loading || !bidAmount || parseFloat(bidAmount) < minBid || !paymentMethod}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Processando...
</>
) : (
<>
<TrendingUp className="h-4 w-4 mr-2" />
Confirmar Lance
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+450
View File
@@ -0,0 +1,450 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useSession } from "@/lib/hooks/use-session";
import { ItemSearch } from "./ItemSearch";
import { ArrowRight, Package } from "lucide-react";
interface Item {
id: number;
name: string;
category?: string;
image?: string;
[key: string]: any;
}
interface CreateAdModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
}
interface Server {
serverId: number;
serverName: string;
serverType: string;
location: string;
isActive: boolean;
}
export function CreateAdModal({ open, onOpenChange, onSuccess }: CreateAdModalProps) {
const { user } = useSession();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [servers, setServers] = useState<Server[]>([]);
const [loadingServers, setLoadingServers] = useState(false);
const [selectedItem, setSelectedItem] = useState<Item | null>(null);
const [tradeItem, setTradeItem] = useState<Item | null>(null);
const [acceptTrade, setAcceptTrade] = useState(false);
const [formData, setFormData] = useState({
quantity: "1",
server_name: "",
price_pix: "",
price_tibia_coins: "",
price_gold_coins: "",
trade_item_quantity: "1",
description: "",
});
const handleItemSelect = (item: Item | null) => {
setSelectedItem(item);
};
const handleTradeItemSelect = (item: Item | null) => {
setTradeItem(item);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
// Validar item selecionado
if (!selectedItem) {
setError("Selecione um item para vender");
setLoading(false);
return;
}
// Validar pelo menos um método de pagamento (preço ou troca)
const hasPrice =
formData.price_pix ||
formData.price_tibia_coins ||
formData.price_gold_coins;
if (!hasPrice && (!acceptTrade || !tradeItem)) {
setError(
"Informe pelo menos um preço (PIX, TC ou GP) ou configure uma troca por item"
);
setLoading(false);
return;
}
// Validar troca se aceita troca
if (acceptTrade && !tradeItem) {
setError("Selecione o item que deseja receber em troca");
setLoading(false);
return;
}
const response = await fetch("/api/listings", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
item_id: selectedItem.id,
item_name: selectedItem.name,
quantity: parseInt(formData.quantity, 10),
server_name: formData.server_name,
price_pix: formData.price_pix ? parseFloat(formData.price_pix) : null,
price_tibia_coins: formData.price_tibia_coins
? parseInt(formData.price_tibia_coins, 10)
: null,
price_gold_coins: formData.price_gold_coins
? parseInt(formData.price_gold_coins, 10)
: null,
description: formData.description || null,
trade_item_id: acceptTrade && tradeItem ? tradeItem.id : null,
trade_item_name: acceptTrade && tradeItem ? tradeItem.name : null,
trade_item_quantity: acceptTrade && tradeItem
? parseInt(formData.trade_item_quantity, 10)
: null,
}),
});
const data = await response.json();
if (!response.ok) {
setError(data.error || "Erro ao criar anúncio");
setLoading(false);
return;
}
// Limpar formulário
setSelectedItem(null);
setTradeItem(null);
setAcceptTrade(false);
setFormData({
quantity: "1",
server_name: "",
price_pix: "",
price_tibia_coins: "",
price_gold_coins: "",
trade_item_quantity: "1",
description: "",
});
onOpenChange(false);
if (onSuccess) {
onSuccess();
}
} catch (err) {
setError("Erro ao criar anúncio. Tente novamente.");
console.error(err);
} finally {
setLoading(false);
}
};
// Carregar servidores ao abrir o modal
useEffect(() => {
if (open) {
loadServers();
}
}, [open]);
const loadServers = async () => {
setLoadingServers(true);
try {
const res = await fetch("/api/servers");
if (res.ok) {
const data = await res.json();
setServers(data.servers || []);
}
} catch (error) {
console.error("Erro ao carregar servidores:", error);
} finally {
setLoadingServers(false);
}
};
const handleClose = () => {
if (!loading) {
setSelectedItem(null);
setTradeItem(null);
setAcceptTrade(false);
setFormData({
quantity: "1",
server_name: "",
price_pix: "",
price_tibia_coins: "",
price_gold_coins: "",
trade_item_quantity: "1",
description: "",
});
setError(null);
onOpenChange(false);
}
};
if (!user) {
return null;
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Criar Novo Anúncio</DialogTitle>
<DialogDescription>
Preencha os dados do item que deseja vender
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Seleção do item a vender */}
<div className="space-y-2">
<Label htmlFor="item_search">Item a Vender *</Label>
<ItemSearch
onItemSelect={handleItemSelect}
selectedItem={selectedItem}
placeholder="Pesquise o nome do item..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="quantity">Quantidade *</Label>
<Input
id="quantity"
type="number"
min="1"
required
value={formData.quantity}
onChange={(e) =>
setFormData({ ...formData, quantity: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="server_name">Servidor *</Label>
<Select
value={formData.server_name}
onValueChange={(value) =>
setFormData({ ...formData, server_name: value })
}
required
disabled={loadingServers}
>
<SelectTrigger id="server_name">
<SelectValue placeholder="Selecione um servidor" />
</SelectTrigger>
<SelectContent>
{servers.length === 0 && !loadingServers ? (
<SelectItem value="" disabled>
Nenhum servidor disponível
</SelectItem>
) : (
servers.map((server) => (
<SelectItem key={server.serverId} value={server.serverName}>
{server.serverName}
{server.location && ` (${server.location})`}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
</div>
<Separator />
{/* Preços */}
<div className="space-y-2">
<Label>Preços (opcional se aceitar troca)</Label>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="price_pix" className="text-sm">
PIX (R$)
</Label>
<Input
id="price_pix"
type="number"
step="0.01"
min="0"
value={formData.price_pix}
onChange={(e) =>
setFormData({ ...formData, price_pix: e.target.value })
}
placeholder="0.00"
/>
</div>
<div className="space-y-2">
<Label htmlFor="price_tibia_coins" className="text-sm">
Tibia Coins
</Label>
<Input
id="price_tibia_coins"
type="number"
min="0"
value={formData.price_tibia_coins}
onChange={(e) =>
setFormData({
...formData,
price_tibia_coins: e.target.value,
})
}
placeholder="0"
/>
</div>
<div className="space-y-2">
<Label htmlFor="price_gold_coins" className="text-sm">
Gold Coins
</Label>
<Input
id="price_gold_coins"
type="number"
min="0"
value={formData.price_gold_coins}
onChange={(e) =>
setFormData({
...formData,
price_gold_coins: e.target.value,
})
}
placeholder="0"
/>
</div>
</div>
</div>
<Separator />
{/* Troca por item */}
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="accept_trade"
checked={acceptTrade}
onCheckedChange={(checked) => {
setAcceptTrade(checked as boolean);
if (!checked) {
setTradeItem(null);
}
}}
/>
<Label
htmlFor="accept_trade"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer flex items-center gap-2"
>
<Package className="h-4 w-4" />
Aceitar troca por item (além dos valores monetários)
</Label>
</div>
{acceptTrade && (
<div className="space-y-4 pl-6 border-l-2 border-primary/20">
<div className="space-y-2">
<Label>Item Desejado em Troca *</Label>
<ItemSearch
onItemSelect={handleTradeItemSelect}
selectedItem={tradeItem}
placeholder="Pesquise o item que deseja receber..."
/>
</div>
{tradeItem && (
<div className="space-y-2">
<Label htmlFor="trade_item_quantity">
Quantidade do Item de Troca *
</Label>
<Input
id="trade_item_quantity"
type="number"
min="1"
required
value={formData.trade_item_quantity}
onChange={(e) =>
setFormData({
...formData,
trade_item_quantity: e.target.value,
})
}
/>
</div>
)}
</div>
)}
</div>
<Separator />
{/* Descrição */}
<div className="space-y-2">
<Label htmlFor="description">Descrição (opcional)</Label>
<Textarea
id="description"
maxLength={5000}
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder="Descreva o item, condições, etc..."
rows={4}
/>
<p className="text-xs text-muted-foreground">
{formData.description.length}/5000 caracteres
</p>
</div>
{error && (
<div className="p-3 bg-destructive/10 text-destructive rounded-md text-sm">
{error}
</div>
)}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={loading}
>
Cancelar
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Criando..." : "Criar Anúncio"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,427 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
ShoppingCart,
Clock,
CheckCircle2,
XCircle,
MessageSquare,
User,
Loader2,
ShoppingBag,
} from "lucide-react";
import { useSession } from "@/lib/hooks/use-session";
import { useQueryClient } from "@tanstack/react-query";
interface PurchaseIntention {
intention_id: number;
buyer_id: number;
payment_method: string;
status: "pending" | "accepted" | "rejected" | "expired" | "completed";
buyer_message: string | null;
seller_response: string | null;
created_at: string;
buyer?: {
user_id: number;
username: string;
email: string;
};
}
interface IntentionDetailModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
intention: PurchaseIntention;
listingId: number;
onUpdate?: () => void;
}
function getPaymentMethodLabel(method: string): string {
switch (method) {
case "pix":
return "PIX";
case "tibia_coins":
return "Tibia Coins";
case "gold_coins":
return "Gold Coins";
case "trade":
return "Troca";
default:
return method.toUpperCase();
}
}
function getStatusBadge(status: string) {
switch (status) {
case "pending":
return (
<Badge variant="secondary" className="bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">
<Clock className="h-3 w-3 mr-1" />
Pendente
</Badge>
);
case "accepted":
return (
<Badge variant="secondary" className="bg-green-500/20 text-green-700 dark:text-green-400">
<CheckCircle2 className="h-3 w-3 mr-1" />
Aceita
</Badge>
);
case "rejected":
return (
<Badge variant="secondary" className="bg-red-500/20 text-red-700 dark:text-red-400">
<XCircle className="h-3 w-3 mr-1" />
Rejeitada
</Badge>
);
default:
return <Badge variant="secondary">{status}</Badge>;
}
}
export function IntentionDetailModal({
open,
onOpenChange,
intention,
listingId,
onUpdate,
}: IntentionDetailModalProps) {
const { user } = useSession();
const queryClient = useQueryClient();
const [sellerResponse, setSellerResponse] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [listing, setListing] = useState<{ seller_id: number; listing_status: string } | null>(null);
const [showMarkSoldDialog, setShowMarkSoldDialog] = useState(false);
const [markingSold, setMarkingSold] = useState(false);
// Buscar detalhes do listing para verificar se usuário é dono e status
useEffect(() => {
if (open && listingId) {
fetch(`/api/listings/${listingId}`)
.then((res) => res.json())
.then((data) => {
if (data.success && data.listing) {
setListing(data.listing);
}
})
.catch((err) => console.error("Erro ao buscar listing:", err));
}
}, [open, listingId]);
const isOwner = user && listing && user.userId === listing.seller_id;
const canMarkAsSold = isOwner && listing && (listing.listing_status === "active" || listing.listing_status === "in_escrow");
const canAcceptOrReject = intention.status === "pending";
const handleAccept = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(
`/api/listings/${listingId}/intentions/${intention.intention_id}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
status: "accepted",
seller_response: sellerResponse || undefined,
}),
}
);
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.error || "Erro ao aceitar intenção");
}
if (onUpdate) onUpdate();
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Erro ao aceitar intenção");
} finally {
setLoading(false);
}
};
const handleReject = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(
`/api/listings/${listingId}/intentions/${intention.intention_id}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
status: "rejected",
seller_response: sellerResponse || undefined,
}),
}
);
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.error || "Erro ao rejeitar intenção");
}
if (onUpdate) onUpdate();
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Erro ao rejeitar intenção");
} finally {
setLoading(false);
}
};
const handleMarkAsSold = async () => {
setMarkingSold(true);
try {
const res = await fetch(`/api/listings/${listingId}/mark-sold`, {
method: "POST",
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Erro ao marcar como vendido");
}
// Invalidar cache para atualizar a lista
queryClient.invalidateQueries({ queryKey: ["listings"] });
queryClient.invalidateQueries({ queryKey: ["my-listings"] });
if (onUpdate) onUpdate();
setShowMarkSoldDialog(false);
onOpenChange(false);
} catch (error) {
console.error("Erro ao marcar como vendido:", error);
alert(error instanceof Error ? error.message : "Erro ao marcar como vendido");
} finally {
setMarkingSold(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Intenção de Compra
{getStatusBadge(intention.status)}
</DialogTitle>
<DialogDescription>
Detalhes da intenção de compra de {intention.buyer?.username || "usuário"}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Informações do comprador */}
<div className="bg-muted p-4 rounded-lg">
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12">
<AvatarFallback>
{intention.buyer?.username.charAt(0).toUpperCase() || "?"}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<User className="h-4 w-4 text-muted-foreground" />
<span className="font-semibold">
{intention.buyer?.username || "Usuário"}
</span>
</div>
<p className="text-sm text-muted-foreground">
{intention.buyer?.email || "Email não disponível"}
</p>
</div>
</div>
</div>
<Separator />
{/* Informações da intenção */}
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs text-muted-foreground">Método de Pagamento</Label>
<div className="flex items-center gap-2 mt-1">
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
{getPaymentMethodLabel(intention.payment_method)}
</span>
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground">Data de Criação</Label>
<div className="flex items-center gap-2 mt-1">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">
{new Date(intention.created_at).toLocaleString("pt-BR")}
</span>
</div>
</div>
</div>
{/* Mensagem do comprador */}
{intention.buyer_message && (
<div>
<Label className="text-xs text-muted-foreground mb-1 block">
Mensagem do Comprador
</Label>
<div className="bg-muted p-3 rounded-lg">
<div className="flex items-start gap-2">
<MessageSquare className="h-4 w-4 text-muted-foreground mt-0.5" />
<p className="text-sm flex-1">{intention.buyer_message}</p>
</div>
</div>
</div>
)}
{/* Resposta do vendedor */}
{intention.seller_response && (
<div>
<Label className="text-xs text-muted-foreground mb-1 block">
Sua Resposta
</Label>
<div className="bg-muted p-3 rounded-lg">
<p className="text-sm">{intention.seller_response}</p>
</div>
</div>
)}
</div>
{/* Campo de resposta (apenas se pendente) */}
{canAcceptOrReject && (
<div className="space-y-2">
<Label htmlFor="seller-response">Resposta (Opcional)</Label>
<Textarea
id="seller-response"
placeholder="Deixe uma mensagem para o comprador..."
value={sellerResponse}
onChange={(e) => setSellerResponse(e.target.value)}
rows={3}
/>
</div>
)}
{error && (
<div className="bg-destructive/10 text-destructive p-3 rounded-lg text-sm">
{error}
</div>
)}
<Separator />
{/* Botões de ação */}
<div className="flex gap-2 justify-end flex-wrap">
{canMarkAsSold && (
<Button
variant="default"
onClick={() => setShowMarkSoldDialog(true)}
disabled={loading || markingSold}
className="bg-green-600 hover:bg-green-700"
>
<ShoppingBag className="h-4 w-4 mr-2" />
Marcar como Vendido
</Button>
)}
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading || markingSold}>
Fechar
</Button>
{canAcceptOrReject && (
<>
<Button
variant="destructive"
onClick={handleReject}
disabled={loading || markingSold}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Rejeitando...
</>
) : (
<>
<XCircle className="h-4 w-4 mr-2" />
Rejeitar
</>
)}
</Button>
<Button onClick={handleAccept} disabled={loading || markingSold}>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Aceitando...
</>
) : (
<>
<CheckCircle2 className="h-4 w-4 mr-2" />
Aceitar
</>
)}
</Button>
</>
)}
</div>
</div>
</DialogContent>
{/* Dialog de confirmação para marcar como vendido */}
<Dialog open={showMarkSoldDialog} onOpenChange={setShowMarkSoldDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Marcar anúncio como vendido?</DialogTitle>
<DialogDescription>
Ao marcar este anúncio como vendido, as seguintes ações serão executadas:
</DialogDescription>
</DialogHeader>
<div className="space-y-2 pt-2">
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
<li>O anúncio será marcado como "Vendido"</li>
<li>O anúncio continuará visível na lista por mais 1 hora</li>
<li>Após 1 hora, o anúncio sairá automaticamente da lista de anúncios ativos</li>
<li>Nenhuma interação (comprar, dar lance) será permitida</li>
</ul>
<p className="pt-2 font-medium text-foreground">
Tem certeza que deseja marcar este anúncio como vendido?
</p>
</div>
<div className="flex gap-2 justify-end pt-4">
<Button
variant="outline"
onClick={() => setShowMarkSoldDialog(false)}
disabled={markingSold}
>
Cancelar
</Button>
<Button
onClick={handleMarkAsSold}
disabled={markingSold}
className="bg-green-600 hover:bg-green-700"
>
{markingSold ? "Marcando..." : "Sim, marcar como vendido"}
</Button>
</div>
</DialogContent>
</Dialog>
</Dialog>
);
}
+230
View File
@@ -0,0 +1,230 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Search, X, Loader2 } from "lucide-react";
import Image from "next/image";
import { cn } from "@/lib/utils";
interface Item {
id: number;
name: string;
category?: string;
image?: string;
rarity?: string;
level?: number;
weight?: number;
npc_price?: number;
[key: string]: any;
}
interface ItemSearchProps {
onItemSelect: (item: Item) => void;
selectedItem?: Item | null;
placeholder?: string;
className?: string;
}
export function ItemSearch({
onItemSelect,
selectedItem,
placeholder = "Pesquisar item...",
className,
}: ItemSearchProps) {
const [searchQuery, setSearchQuery] = useState("");
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
const [previewItem, setPreviewItem] = useState<Item | null>(selectedItem || null);
const searchRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<NodeJS.Timeout>();
// Buscar itens ao digitar
useEffect(() => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
if (searchQuery.trim().length < 2) {
setItems([]);
setShowDropdown(false);
return;
}
setLoading(true);
debounceRef.current = setTimeout(async () => {
try {
const res = await fetch(
`/api/items?q=${encodeURIComponent(searchQuery)}&pageSize=10`
);
if (res.ok) {
const data = await res.json();
setItems(data.items || []);
setShowDropdown(true);
}
} catch (error) {
console.error("Erro ao buscar itens:", error);
} finally {
setLoading(false);
}
}, 300);
}, [searchQuery]);
// Fechar dropdown ao clicar fora
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(event.target as Node)) {
setShowDropdown(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Atualizar preview quando item selecionado mudar externamente
useEffect(() => {
if (selectedItem) {
setPreviewItem(selectedItem);
setSearchQuery(selectedItem.name);
}
}, [selectedItem]);
const handleItemClick = (item: Item) => {
setPreviewItem(item);
setSearchQuery(item.name);
setShowDropdown(false);
onItemSelect(item);
};
const handleClear = () => {
setSearchQuery("");
setPreviewItem(null);
setItems([]);
setShowDropdown(false);
onItemSelect(null as any);
};
return (
<div ref={searchRef} className={cn("relative", className)}>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onFocus={() => searchQuery.length >= 2 && setShowDropdown(true)}
placeholder={placeholder}
className="pl-9 pr-9"
/>
{searchQuery && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
onClick={handleClear}
>
<X className="h-4 w-4" />
</Button>
)}
{loading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
{/* Dropdown de resultados */}
{showDropdown && items.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-background border rounded-md shadow-lg max-h-60 overflow-y-auto">
{items.map((item) => (
<div
key={item.id}
className="p-2 hover:bg-accent cursor-pointer flex items-center gap-3"
onClick={() => handleItemClick(item)}
>
{item.image && (
<div className="relative w-10 h-10 flex-shrink-0 rounded bg-muted">
<Image
src={item.image}
alt={item.name}
fill
style={{ objectFit: "contain" }}
sizes="40px"
/>
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{item.name}</p>
{item.category && (
<p className="text-xs text-muted-foreground">{item.category}</p>
)}
</div>
{item.rarity && (
<Badge variant="secondary" className="text-xs">
{item.rarity}
</Badge>
)}
</div>
))}
</div>
)}
{/* Preview do item selecionado */}
{previewItem && (
<Card className="mt-3">
<CardContent className="p-4">
<div className="flex gap-4">
{previewItem.image && (
<div className="relative w-20 h-20 flex-shrink-0 rounded-lg bg-muted overflow-hidden">
<Image
src={previewItem.image}
alt={previewItem.name}
fill
style={{ objectFit: "contain" }}
sizes="80px"
/>
</div>
)}
<div className="flex-1">
<h4 className="font-semibold text-lg">{previewItem.name}</h4>
<div className="flex flex-wrap gap-2 mt-2 text-sm text-muted-foreground">
{previewItem.category && (
<span className="flex items-center gap-1">
<span className="font-medium">Categoria:</span>
{previewItem.category}
</span>
)}
{previewItem.rarity && (
<Badge variant="secondary">{previewItem.rarity}</Badge>
)}
{previewItem.level !== undefined && previewItem.level !== null && (
<span className="flex items-center gap-1">
<span className="font-medium">Level:</span>
{previewItem.level}
</span>
)}
{previewItem.weight !== undefined && previewItem.weight !== null && (
<span className="flex items-center gap-1">
<span className="font-medium">Peso:</span>
{previewItem.weight} oz
</span>
)}
{previewItem.npc_price !== undefined && previewItem.npc_price !== null && (
<span className="flex items-center gap-1">
<span className="font-medium">Preço NPC:</span>
{previewItem.npc_price.toLocaleString()} gp
</span>
)}
</div>
</div>
</div>
</CardContent>
</Card>
)}
</div>
);
}
@@ -0,0 +1,467 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Eye,
Heart,
Clock,
MapPin,
DollarSign,
Coins,
TrendingUp,
Star,
Pause,
Trash2,
X,
CheckCircle2,
AlertCircle,
ShoppingBag,
} from "lucide-react";
import Image from "next/image";
import { useSession } from "@/lib/hooks/use-session";
import { useQueryClient } from "@tanstack/react-query";
interface Listing {
listing_id: number;
seller_id: number;
item_id: number;
item_name: string;
quantity: number;
server_name: string;
price_pix: number | string | null;
price_tibia_coins: number | string | null;
price_gold_coins: number | string | null;
is_auction: boolean;
description: string | null;
listing_status: string;
created_at: string;
views_count: number;
favorites_count: number;
is_featured?: boolean;
trade_item_id?: number | null;
trade_item_name?: string | null;
trade_item_quantity?: number | null;
current_bid_amount?: number | null;
min_bid_increment?: number | null;
initial_price_pix?: number | null;
initial_price_tibia_coins?: number | null;
initial_price_gold_coins?: number | null;
auction_ends_at?: Date | null;
item_id_unified?: number | null;
image_local_path?: string | null;
image_url?: string | null;
item_category?: string | null;
seller_character_name?: string | null;
}
interface ListingDetailsModalProps {
listing: Listing;
open: boolean;
onOpenChange: (open: boolean) => void;
onPause?: () => void;
onActivate?: () => void;
onCancel?: () => void;
}
function formatTimeAgo(date: Date | string): string {
const dateObj = typeof date === "string" ? new Date(date) : date;
const seconds = Math.floor((new Date().getTime() - dateObj.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 getStatusBadge(status: string) {
const statusConfig = {
active: { label: "Ativo", variant: "default" as const, icon: CheckCircle2, className: "bg-green-500 hover:bg-green-600" },
sold: { label: "Vendido", variant: "secondary" as const, icon: CheckCircle2, className: "bg-blue-500 hover:bg-blue-600 text-white" },
cancelled: { label: "Cancelado", variant: "destructive" as const, icon: X, className: "bg-red-500 hover:bg-red-600 text-white" },
in_escrow: { label: "Em Escrow", variant: "default" as const, icon: AlertCircle, className: "bg-yellow-500 hover:bg-yellow-600 text-white" },
expired: { label: "Expirado", variant: "secondary" as const, icon: Clock, className: "bg-gray-500 hover:bg-gray-600 text-white" },
};
const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.active;
const Icon = config.icon;
return (
<Badge className={config.className}>
<Icon className="h-3 w-3 mr-1" />
{config.label}
</Badge>
);
}
export function ListingDetailsModal({
listing,
open,
onOpenChange,
onPause,
onActivate,
onCancel,
}: ListingDetailsModalProps) {
const { user } = useSession();
const queryClient = useQueryClient();
const [itemImageUrl, setItemImageUrl] = useState<string>("");
const [showMarkSoldDialog, setShowMarkSoldDialog] = useState(false);
const [markingSold, setMarkingSold] = useState(false);
// Verificar se o usuário é o dono do anúncio
const isOwner = user && user.userId === listing.seller_id;
useEffect(() => {
// Construir URL da imagem
if (listing.image_url) {
setItemImageUrl(listing.image_url);
} else if (listing.image_local_path) {
const normalizedPath = listing.image_local_path.replace(/\\/g, "/");
if (normalizedPath.startsWith("data/")) {
setItemImageUrl(`/api/images?path=${encodeURIComponent(normalizedPath)}`);
} else {
const category = listing.item_category || "other";
setItemImageUrl(`/api/images?path=${encodeURIComponent(`data/images/${category}/${normalizedPath}`)}`);
}
} else if (listing.item_id_unified) {
const params = new URLSearchParams({ item: listing.item_id_unified.toString() });
if (listing.item_name) {
params.set("name", listing.item_name);
}
setItemImageUrl(`/api/images?${params.toString()}`);
} else {
const params = new URLSearchParams({ item: listing.item_id.toString() });
if (listing.item_name) {
params.set("name", listing.item_name);
}
setItemImageUrl(`/api/images?${params.toString()}`);
}
}, [listing]);
const getAvailablePrices = () => {
const prices: Array<{ type: string; label: string; value: string }> = [];
if (listing.price_pix) {
const price = typeof listing.price_pix === "string" ? parseFloat(listing.price_pix) : listing.price_pix;
if (!isNaN(price)) {
prices.push({ type: "pix", label: "PIX", value: `R$ ${price.toFixed(2)}` });
}
}
if (listing.price_tibia_coins) {
const price = typeof listing.price_tibia_coins === "string" ? parseInt(listing.price_tibia_coins, 10) : listing.price_tibia_coins;
if (!isNaN(price)) {
prices.push({ type: "tc", label: "Tibia Coins", value: `${price.toLocaleString()} TC` });
}
}
if (listing.price_gold_coins) {
const price = typeof listing.price_gold_coins === "string" ? parseInt(listing.price_gold_coins, 10) : listing.price_gold_coins;
if (!isNaN(price)) {
prices.push({ type: "gp", label: "Gold Coins", value: `${price.toLocaleString()} GP` });
}
}
if (listing.trade_item_id && listing.trade_item_name && listing.trade_item_quantity) {
prices.push({
type: "trade",
label: "Troca",
value: `${listing.trade_item_quantity}x ${listing.trade_item_name}`,
});
}
return prices;
};
const prices = getAvailablePrices();
// Apenas o dono pode realizar ações
const canPause = isOwner && listing.listing_status === "active";
const canActivate = isOwner && listing.listing_status === "cancelled";
const canCancel = isOwner && listing.listing_status === "active";
const canMarkAsSold = isOwner && (listing.listing_status === "active" || listing.listing_status === "in_escrow");
const handleMarkAsSold = async () => {
setMarkingSold(true);
try {
const res = await fetch(`/api/listings/${listing.listing_id}/mark-sold`, {
method: "POST",
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Erro ao marcar como vendido");
}
// Invalidar cache para atualizar a lista
queryClient.invalidateQueries({ queryKey: ["listings"] });
queryClient.invalidateQueries({ queryKey: ["my-listings"] });
setShowMarkSoldDialog(false);
onOpenChange(false);
} catch (error) {
console.error("Erro ao marcar como vendido:", error);
alert(error instanceof Error ? error.message : "Erro ao marcar como vendido");
} finally {
setMarkingSold(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Detalhes do Anúncio
{getStatusBadge(listing.listing_status)}
</DialogTitle>
<DialogDescription>Informações completas do seu anúncio</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Imagem e informações principais */}
<div className="flex gap-6">
{/* Imagem do item */}
<div className="relative w-48 h-48 flex-shrink-0 rounded-lg overflow-hidden bg-muted flex items-center justify-center p-4 border">
<Image
src={itemImageUrl}
alt={listing.item_name}
fill
className="object-contain"
sizes="192px"
onError={(e) => {
(e.target as HTMLImageElement).src = "/placeholder-item.png";
}}
/>
</div>
{/* Informações do item */}
<div className="flex-1 space-y-4">
<div>
<h3 className="text-2xl font-bold mb-2">{listing.item_name}</h3>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<MapPin className="h-4 w-4" />
<span>Servidor: {listing.server_name}</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
<span>Quantidade: {listing.quantity}</span>
</div>
</div>
{/* Estatísticas */}
<div className="grid grid-cols-3 gap-4">
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-muted-foreground" />
<div>
<div className="text-sm text-muted-foreground">Visualizações</div>
<div className="font-semibold">{listing.views_count}</div>
</div>
</div>
<div className="flex items-center gap-2">
<Heart className="h-4 w-4 text-muted-foreground" />
<div>
<div className="text-sm text-muted-foreground">Favoritos</div>
<div className="font-semibold">{listing.favorites_count || 0}</div>
</div>
</div>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<div>
<div className="text-sm text-muted-foreground">Criado</div>
<div className="font-semibold text-xs">{formatTimeAgo(listing.created_at)}</div>
</div>
</div>
</div>
{/* Badges */}
<div className="flex flex-wrap gap-2">
{listing.is_featured && (
<Badge variant="default" className="bg-yellow-500">
<Star className="h-3 w-3 mr-1" />
Destaque
</Badge>
)}
{listing.is_auction && (
<Badge variant="secondary">
<TrendingUp className="h-3 w-3 mr-1" />
Leilão
</Badge>
)}
</div>
</div>
</div>
{/* Preços */}
<div className="space-y-2">
<h4 className="font-semibold">Preços</h4>
<div className="grid grid-cols-2 gap-3">
{prices.map((price, index) => (
<div
key={index}
className={`p-3 rounded-lg border ${
price.type === "pix"
? "bg-green-500/10 border-green-500/20"
: price.type === "tc"
? "bg-blue-500/10 border-blue-500/20"
: price.type === "gp"
? "bg-yellow-500/10 border-yellow-500/20"
: "bg-purple-500/10 border-purple-500/20"
}`}
>
<div className="text-xs text-muted-foreground mb-1">{price.label}</div>
<div className="font-semibold">{price.value}</div>
</div>
))}
</div>
</div>
{/* Informações de leilão */}
{listing.is_auction && (
<div className="space-y-2 p-4 bg-muted rounded-lg">
<h4 className="font-semibold">Informações do Leilão</h4>
{listing.current_bid_amount ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Lance Atual:</span>
<span className="font-semibold text-lg text-primary">
R$ {typeof listing.current_bid_amount === "number" ? listing.current_bid_amount.toFixed(2) : parseFloat(String(listing.current_bid_amount)).toFixed(2)}
</span>
</div>
{listing.min_bid_increment && (
<div className="flex justify-between">
<span className="text-muted-foreground">Incremento Mínimo:</span>
<span className="font-medium">
R$ {typeof listing.min_bid_increment === "number" ? listing.min_bid_increment.toFixed(2) : parseFloat(String(listing.min_bid_increment)).toFixed(2)}
</span>
</div>
)}
{listing.auction_ends_at && (
<div className="flex justify-between">
<span className="text-muted-foreground">Termina em:</span>
<span className="font-medium">{new Date(listing.auction_ends_at).toLocaleString("pt-BR")}</span>
</div>
)}
</div>
) : (
<div className="text-center text-muted-foreground py-2">Sem lances ainda</div>
)}
</div>
)}
{/* Descrição */}
{listing.description && (
<div className="space-y-2">
<h4 className="font-semibold">Descrição</h4>
<p className="text-sm text-muted-foreground p-3 bg-muted rounded-lg">{listing.description}</p>
</div>
)}
{/* Informações adicionais */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">ID do Anúncio:</span>
<span className="ml-2 font-mono">{listing.listing_id}</span>
</div>
{listing.item_id_unified && (
<div>
<span className="text-muted-foreground">ID do Item (Unificado):</span>
<span className="ml-2 font-mono">{listing.item_id_unified}</span>
</div>
)}
<div>
<span className="text-muted-foreground">Criado em:</span>
<span className="ml-2">{new Date(listing.created_at).toLocaleString("pt-BR")}</span>
</div>
</div>
{/* Ações */}
{(canPause || canActivate || canCancel || canMarkAsSold) && (
<div className="flex gap-2 pt-4 border-t">
{canMarkAsSold && (
<Button
variant="default"
onClick={() => setShowMarkSoldDialog(true)}
className="flex-1 bg-green-600 hover:bg-green-700"
>
<ShoppingBag className="h-4 w-4 mr-2" />
Marcar como Vendido
</Button>
)}
{canPause && (
<Button variant="outline" onClick={onPause} className="flex-1">
<Pause className="h-4 w-4 mr-2" />
Pausar
</Button>
)}
{canActivate && onActivate && (
<Button variant="default" onClick={onActivate} className="flex-1">
<CheckCircle2 className="h-4 w-4 mr-2" />
Reativar Anúncio
</Button>
)}
{canCancel && (
<Button variant="destructive" onClick={onCancel} className="flex-1">
<Trash2 className="h-4 w-4 mr-2" />
Cancelar Anúncio
</Button>
)}
</div>
)}
</div>
</DialogContent>
{/* Dialog de confirmação para marcar como vendido */}
<Dialog open={showMarkSoldDialog} onOpenChange={setShowMarkSoldDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Marcar anúncio como vendido?</DialogTitle>
<DialogDescription>
Ao marcar este anúncio como vendido, as seguintes ações serão executadas:
</DialogDescription>
</DialogHeader>
<div className="space-y-2 pt-2">
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
<li>O anúncio será marcado como "Vendido"</li>
<li>O anúncio continuará visível na lista por mais 1 hora</li>
<li>Após 1 hora, o anúncio sairá automaticamente da lista de anúncios ativos</li>
<li>Nenhuma interação (comprar, dar lance) será permitida</li>
</ul>
<p className="pt-2 font-medium text-foreground">
Tem certeza que deseja marcar este anúncio como vendido?
</p>
</div>
<div className="flex gap-2 justify-end pt-4">
<Button
variant="outline"
onClick={() => setShowMarkSoldDialog(false)}
disabled={markingSold}
>
Cancelar
</Button>
<Button
onClick={handleMarkAsSold}
disabled={markingSold}
className="bg-green-600 hover:bg-green-700"
>
{markingSold ? "Marcando..." : "Sim, marcar como vendido"}
</Button>
</div>
</DialogContent>
</Dialog>
</Dialog>
);
}
@@ -0,0 +1,227 @@
"use client";
import { useState, useEffect } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ShoppingCart, Clock, CheckCircle2, XCircle, MessageSquare } from "lucide-react";
import { IntentionDetailModal } from "./IntentionDetailModal";
interface PurchaseIntention {
intention_id: number;
buyer_id: number;
payment_method: string;
status: "pending" | "accepted" | "rejected" | "expired" | "completed";
buyer_message: string | null;
seller_response: string | null;
created_at: string;
buyer?: {
user_id: number;
username: string;
email: string;
};
}
interface MyAdsIntentionListProps {
listingId: number;
sellerId: number;
}
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);
return `há ${days} ${days === 1 ? "dia" : "dias"}`;
}
function getStatusBadge(status: string) {
switch (status) {
case "pending":
return (
<Badge variant="secondary" className="bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">
<Clock className="h-3 w-3 mr-1" />
Pendente
</Badge>
);
case "accepted":
return (
<Badge variant="secondary" className="bg-green-500/20 text-green-700 dark:text-green-400">
<CheckCircle2 className="h-3 w-3 mr-1" />
Aceita
</Badge>
);
case "rejected":
return (
<Badge variant="secondary" className="bg-red-500/20 text-red-700 dark:text-red-400">
<XCircle className="h-3 w-3 mr-1" />
Rejeitada
</Badge>
);
default:
return <Badge variant="secondary">{status}</Badge>;
}
}
function getPaymentMethodLabel(method: string): string {
switch (method) {
case "pix":
return "PIX";
case "tibia_coins":
return "Tibia Coins";
case "gold_coins":
return "Gold Coins";
case "trade":
return "Troca";
default:
return method.toUpperCase();
}
}
export function MyAdsIntentionList({ listingId, sellerId }: MyAdsIntentionListProps) {
const [intentions, setIntentions] = useState<PurchaseIntention[]>([]);
const [loading, setLoading] = useState(true);
const [selectedIntention, setSelectedIntention] = useState<PurchaseIntention | null>(null);
const [filter, setFilter] = useState<"all" | "pending" | "accepted" | "rejected">("all");
useEffect(() => {
loadIntentions();
}, [listingId, filter]);
const loadIntentions = async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (filter !== "all") {
params.set("status", filter);
}
const res = await fetch(`/api/listings/${listingId}/intentions?${params.toString()}`);
if (res.ok) {
const data = await res.json();
if (data.success) {
setIntentions(data.intentions || []);
}
}
} catch (error) {
console.error("Erro ao carregar intenções:", error);
} finally {
setLoading(false);
}
};
const pendingCount = intentions.filter((i) => i.status === "pending").length;
if (loading) {
return (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
);
}
if (intentions.length === 0) {
return (
<div className="text-center py-8 text-sm text-muted-foreground">
Nenhuma intenção de compra encontrada
</div>
);
}
return (
<div className="space-y-4">
{/* Filtros */}
<div className="flex items-center gap-2 flex-wrap">
<Button
variant={filter === "all" ? "default" : "outline"}
size="sm"
onClick={() => setFilter("all")}
>
Todas ({intentions.length})
</Button>
<Button
variant={filter === "pending" ? "default" : "outline"}
size="sm"
onClick={() => setFilter("pending")}
>
Pendentes ({pendingCount})
</Button>
<Button
variant={filter === "accepted" ? "default" : "outline"}
size="sm"
onClick={() => setFilter("accepted")}
>
Aceitas
</Button>
<Button
variant={filter === "rejected" ? "default" : "outline"}
size="sm"
onClick={() => setFilter("rejected")}
>
Rejeitadas
</Button>
</div>
{/* Lista de intenções */}
<div className="space-y-2">
{intentions.map((intention) => (
<div
key={intention.intention_id}
className="border rounded-lg p-4 hover:bg-muted/50 transition-colors cursor-pointer"
onClick={() => setSelectedIntention(intention)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
<Avatar className="h-10 w-10">
<AvatarFallback>
{intention.buyer?.username.charAt(0).toUpperCase() || "?"}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm">
{intention.buyer?.username || "Usuário"}
</span>
{getStatusBadge(intention.status)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
<ShoppingCart className="h-3 w-3" />
<span>{getPaymentMethodLabel(intention.payment_method)}</span>
<span>•</span>
<Clock className="h-3 w-3" />
<span>{formatTimeAgo(new Date(intention.created_at))}</span>
</div>
{intention.buyer_message && (
<p className="text-xs text-muted-foreground line-clamp-1">
<MessageSquare className="h-3 w-3 inline mr-1" />
{intention.buyer_message}
</p>
)}
</div>
</div>
</div>
</div>
))}
</div>
{/* Modal de detalhes */}
{selectedIntention && (
<IntentionDetailModal
open={!!selectedIntention}
onOpenChange={(open) => {
if (!open) setSelectedIntention(null);
}}
intention={selectedIntention}
listingId={listingId}
onUpdate={loadIntentions}
/>
)}
</div>
);
}
+285
View File
@@ -0,0 +1,285 @@
"use client";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Pause,
Play,
Trash2,
Edit,
Eye,
Heart,
TrendingUp,
ChevronDown,
ShoppingCart,
} from "lucide-react";
import { useSession } from "@/lib/hooks/use-session";
import { useMyListings, type Listing } from "@/lib/hooks/use-listings";
import { AdCard } from "./AdCard";
import { MyAdsIntentionList } from "./MyAdsIntentionList";
export function MyAdsPanel() {
const { user } = useSession();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<string>("active");
const [expandedIntentions, setExpandedIntentions] = useState<Set<number>>(new Set());
// Determinar status baseado na aba ativa
const status =
activeTab === "active" ? "active" :
activeTab === "paused" ? "cancelled" : // Backend usa 'cancelled' para pausados
activeTab === "sold" ? "sold" :
undefined; // "all" busca todos
// Usar React Query para buscar meus anúncios com atualização automática
// O queryKey muda quando status muda, então recarrega automaticamente
const { listings, isLoading: loading, refetch } = useMyListings(status);
const invalidateCache = () => {
queryClient.invalidateQueries({ queryKey: ["my-listings"] });
queryClient.invalidateQueries({ queryKey: ["listings"] });
};
const handlePause = async (listingId: number) => {
try {
const res = await fetch(`/api/listings/${listingId}/pause`, {
method: "POST",
});
if (res.ok) {
invalidateCache();
}
} catch (error) {
console.error("Erro ao pausar anúncio:", error);
}
};
const handleActivate = async (listingId: number) => {
try {
const res = await fetch(`/api/listings/${listingId}/activate`, {
method: "POST",
});
if (res.ok) {
invalidateCache();
}
} catch (error) {
console.error("Erro ao reativar anúncio:", error);
}
};
const handleDelete = async (listingId: number) => {
if (!confirm("Tem certeza que deseja excluir este anúncio?")) return;
try {
const res = await fetch(`/api/listings/${listingId}`, {
method: "DELETE",
});
if (res.ok) {
invalidateCache();
}
} catch (error) {
console.error("Erro ao excluir anúncio:", error);
}
};
const getStatusBadge = (status: string) => {
const variants: Record<string, "default" | "secondary" | "destructive"> = {
active: "default",
sold: "secondary",
cancelled: "destructive",
expired: "destructive",
in_escrow: "secondary",
};
const labels: Record<string, string> = {
active: "Ativo",
sold: "Vendido",
cancelled: "Cancelado",
expired: "Expirado",
in_escrow: "Em Escrow",
};
return (
<Badge variant={variants[status] || "secondary"}>
{labels[status] || status}
</Badge>
);
};
if (!user) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">
Faça login para ver seus anúncios
</p>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold">Meus Anúncios</h2>
<p className="text-muted-foreground mt-1">
Gerencie seus anúncios e acompanhe o desempenho
</p>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="active">Ativos</TabsTrigger>
<TabsTrigger value="paused">Pausados</TabsTrigger>
<TabsTrigger value="sold">Vendidos</TabsTrigger>
<TabsTrigger value="all">Todos</TabsTrigger>
</TabsList>
<TabsContent value={activeTab} className="mt-6">
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-64 w-full" />
))}
</div>
) : listings.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground">
Nenhum anúncio encontrado
</p>
</div>
) : (
<div className="space-y-4">
{listings.map((listing) => {
const isExpanded = expandedIntentions.has(listing.listing_id);
return (
<div
key={listing.listing_id}
className="border rounded-lg p-4 space-y-4"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h3 className="font-semibold text-lg">
{listing.item_name}
</h3>
{getStatusBadge(listing.listing_status)}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Eye className="h-4 w-4" />
<span>{listing.views_count} visualizações</span>
</div>
<div className="flex items-center gap-1">
<Heart className="h-4 w-4" />
<span>{listing.favorites_count} favoritos</span>
</div>
<div className="flex items-center gap-1">
<TrendingUp className="h-4 w-4" />
<span>
{(() => {
if (listing.price_pix) {
const price = typeof listing.price_pix === 'string'
? parseFloat(listing.price_pix)
: listing.price_pix;
return `R$ ${price.toFixed(2)}`;
}
if (listing.price_tibia_coins) {
const price = typeof listing.price_tibia_coins === 'string'
? parseInt(listing.price_tibia_coins, 10)
: listing.price_tibia_coins;
return `${price.toLocaleString()} TC`;
}
if (listing.price_gold_coins) {
const price = typeof listing.price_gold_coins === 'string'
? parseInt(listing.price_gold_coins, 10)
: listing.price_gold_coins;
return `${price.toLocaleString()} GP`;
}
return "Preço não informado";
})()}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{listing.listing_status === "active" && (
<Button
variant="outline"
size="sm"
onClick={() => handlePause(listing.listing_id)}
>
<Pause className="h-4 w-4 mr-1" />
Pausar
</Button>
)}
{listing.listing_status === "cancelled" && (
<Button
variant="outline"
size="sm"
onClick={() => handleActivate(listing.listing_id)}
>
<Play className="h-4 w-4 mr-1" />
Reativar
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(listing.listing_id)}
>
<Trash2 className="h-4 w-4 mr-1" />
Excluir
</Button>
</div>
</div>
{/* Seção de Intenções de Compra */}
{listing.listing_status === "active" && !listing.is_auction && (
<div className="border-t pt-4">
<Button
variant="ghost"
size="sm"
onClick={() => {
const newExpanded = new Set(expandedIntentions);
if (isExpanded) {
newExpanded.delete(listing.listing_id);
} else {
newExpanded.add(listing.listing_id);
}
setExpandedIntentions(newExpanded);
}}
className="w-full justify-between"
>
<div className="flex items-center gap-2">
<ShoppingCart className="h-4 w-4" />
<span>Intenções de Compra</span>
</div>
<ChevronDown
className={`h-4 w-4 transition-transform ${
isExpanded ? "transform rotate-180" : ""
}`}
/>
</Button>
{isExpanded && (
<div className="mt-4">
<MyAdsIntentionList
listingId={listing.listing_id}
sellerId={listing.seller_id}
/>
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}
+263
View File
@@ -0,0 +1,263 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { DollarSign, Coins, RefreshCw, Loader2 } from "lucide-react";
import { useSession } from "@/lib/hooks/use-session";
interface Listing {
listing_id: number;
item_name: string;
quantity: number;
price_pix: number | string | null;
price_tibia_coins: number | string | null;
price_gold_coins: number | string | null;
trade_item_id?: number | null;
trade_item_name?: string | null;
trade_item_quantity?: number | null;
}
interface PurchaseModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
listing: Listing | null;
onSuccess?: () => void;
}
export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: PurchaseModalProps) {
const { user } = useSession();
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!listing) return null;
// Determinar métodos de pagamento disponíveis
const availableMethods: Array<{ type: string; label: string; value: string; icon: any }> = [];
if (listing.price_pix) {
const price = typeof listing.price_pix === 'string'
? parseFloat(listing.price_pix)
: listing.price_pix;
if (!isNaN(price)) {
availableMethods.push({
type: "pix",
label: "PIX",
value: `R$ ${price.toFixed(2)}`,
icon: DollarSign,
});
}
}
if (listing.price_tibia_coins) {
const price = typeof listing.price_tibia_coins === 'string'
? parseInt(listing.price_tibia_coins, 10)
: listing.price_tibia_coins;
if (!isNaN(price)) {
availableMethods.push({
type: "tc",
label: "Tibia Coins",
value: `${price.toLocaleString()} TC`,
icon: Coins,
});
}
}
if (listing.price_gold_coins) {
const price = typeof listing.price_gold_coins === 'string'
? parseInt(listing.price_gold_coins, 10)
: listing.price_gold_coins;
if (!isNaN(price)) {
availableMethods.push({
type: "gp",
label: "Gold Coins",
value: `${price.toLocaleString()} GP`,
icon: Coins,
});
}
}
if (listing.trade_item_id && listing.trade_item_name && listing.trade_item_quantity) {
availableMethods.push({
type: "trade",
label: "Troca",
value: `Troca por ${listing.trade_item_quantity}x ${listing.trade_item_name}`,
icon: RefreshCw,
});
}
// Se houver apenas um método, selecionar automaticamente
if (availableMethods.length === 1 && !selectedMethod) {
setSelectedMethod(availableMethods[0].type);
}
const handlePurchase = async () => {
if (!selectedMethod) {
setError("Selecione um método de pagamento");
return;
}
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/listings/${listing.listing_id}/purchase`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
payment_method: selectedMethod,
}),
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || "Erro ao processar compra");
}
// Sucesso
if (onSuccess) {
onSuccess();
}
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Erro ao processar compra");
} finally {
setLoading(false);
}
};
const getMethodClassName = (type: string) => {
switch (type) {
case "pix":
return "bg-green-500 hover:bg-green-600 text-white";
case "tc":
return "bg-blue-500 hover:bg-blue-600 text-white";
case "gp":
return "bg-yellow-500 hover:bg-yellow-600 text-white";
case "trade":
return "bg-purple-500 hover:bg-purple-600 text-white";
default:
return "";
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Confirmar Compra</DialogTitle>
<DialogDescription>
Escolha o método de pagamento para finalizar a compra
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Resumo do item */}
<div className="bg-muted p-4 rounded-lg">
<h3 className="font-semibold mb-2">{listing.item_name}</h3>
<p className="text-sm text-muted-foreground">
Quantidade: {listing.quantity}
</p>
</div>
<Separator />
{/* Seleção de método de pagamento */}
{availableMethods.length > 1 ? (
<div className="space-y-2">
<Label className="text-sm font-medium">Método de Pagamento</Label>
<div className="grid grid-cols-2 gap-2">
{availableMethods.map((method) => {
const Icon = method.icon;
const isSelected = selectedMethod === method.type;
return (
<button
key={method.type}
type="button"
onClick={() => setSelectedMethod(method.type)}
className={`p-4 rounded-lg border-2 transition-all text-left ${
isSelected
? `${getMethodClassName(method.type)} border-primary`
: "border-muted hover:border-primary/50"
}`}
>
<div className="flex items-center gap-2 mb-1">
<Icon className="h-4 w-4" />
<span className="font-medium">{method.label}</span>
</div>
<p className="text-sm opacity-90">{method.value}</p>
</button>
);
})}
</div>
</div>
) : availableMethods.length === 1 ? (
<div className="space-y-2">
<Label className="text-sm font-medium">Método de Pagamento</Label>
<div className={`p-4 rounded-lg border-2 ${getMethodClassName(availableMethods[0].type)}`}>
<div className="flex items-center gap-2 mb-1">
{(() => {
const Icon = availableMethods[0].icon;
return <Icon className="h-4 w-4" />;
})()}
<span className="font-medium">{availableMethods[0].label}</span>
</div>
<p className="text-sm opacity-90">{availableMethods[0].value}</p>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
Nenhum método de pagamento disponível
</p>
)}
{error && (
<div className="bg-destructive/10 text-destructive p-3 rounded-lg text-sm">
{error}
</div>
)}
<Separator />
{/* Botões */}
<div className="flex gap-2 justify-end">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancelar
</Button>
<Button
onClick={handlePurchase}
disabled={loading || !selectedMethod || availableMethods.length === 0}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Processando...
</>
) : (
"Confirmar Compra"
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { User, CheckCircle2 } from "lucide-react";
import { useEffect } from "react";
import { useMarketTelemetry } from "@/lib/hooks/use-telemetry";
interface SellerInfoProps {
sellerId: number;
sellerName: string;
sellerImage?: string | null;
verified?: boolean;
serverName: string;
listingId: number;
}
export function SellerInfo({
sellerId,
sellerName,
sellerImage,
verified = false,
serverName,
listingId,
}: SellerInfoProps) {
const { trackCustomEvent } = useMarketTelemetry();
// Tracking de visualização de vendedor
useEffect(() => {
trackCustomEvent(
"seller_profile_viewed",
listingId,
{
seller_id: sellerId,
seller_name: sellerName,
}
);
}, [sellerId, sellerName, listingId, trackCustomEvent]);
// Gerar iniciais do nome
const getInitials = (name: string) => {
return name
.split(" ")
.slice(0, 2)
.map((word) => word.charAt(0).toUpperCase())
.join("");
};
return (
<div className="flex items-center gap-2">
<Avatar className="h-10 w-10">
{sellerImage ? (
<img src={sellerImage} alt={sellerName} />
) : (
<AvatarFallback className="bg-muted text-xs">
{getInitials(sellerName)}
</AvatarFallback>
)}
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{sellerName}</span>
{verified && (
<Badge variant="secondary" className="h-4 px-1.5 text-xs">
<CheckCircle2 className="h-3 w-3 mr-1" />
Verificado
</Badge>
)}
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<User className="h-3 w-3" />
<span className="truncate">{serverName}</span>
</div>
</div>
</div>
);
}
+17 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { Menu } from "lucide-react";
import { useState } from "react";
import { useState, useEffect } from "react";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
@@ -16,8 +16,24 @@ interface MobileMenuProps {
export function MobileMenu({ links }: MobileMenuProps) {
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const t = useTranslations("header");
// Evitar hydration mismatch com Radix UI
useEffect(() => {
setMounted(true);
}, []);
// Renderizar placeholder durante SSR para evitar mismatch
if (!mounted) {
return (
<Button variant="outline" size="icon" className="md:hidden" disabled>
<Menu className="h-[1.2rem] w-[1.2rem]" />
<span className="sr-only">{t("menu")}</span>
</Button>
);
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
@@ -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>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
+11 -1
View File
@@ -5,7 +5,17 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const Dialog = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Root>
>(({ ...props }, ref) => (
<DialogPrimitive.Root
ref={ref}
suppressHydrationWarning
{...props}
/>
));
Dialog.displayName = "Dialog";
const DialogTrigger = DialogPrimitive.Trigger;
+7 -1
View File
@@ -9,7 +9,13 @@ import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
return (
<DropdownMenuPrimitive.Root
data-slot="dropdown-menu"
suppressHydrationWarning
{...props}
/>
);
}
function DropdownMenuPortal({
+161
View File
@@ -0,0 +1,161 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+7 -1
View File
@@ -7,7 +7,13 @@ import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
return (
<SheetPrimitive.Root
data-slot="sheet"
suppressHydrationWarning
{...props}
/>
);
}
function SheetTrigger({