feat: implement market page with QueryProvider and dynamic listings
- wrap root layout with QueryProvider for React Query - replace market UnderConstruction placeholder with MarketClient and real data fetching - implement search, pagination, and sort via search params using getActiveListings - add necessary client components and queries - update dependencies (e.g., prisma, pkg-types)
This commit is contained in:
1 parent
55eebde0ba
commit
ab37b477df
106 files changed
+15261
-301
No files matched your search
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user