1330 lines
48 KiB
TypeScript
1330 lines
48 KiB
TypeScript
"use client";
|
|
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Clock,
|
|
Coins,
|
|
DollarSign,
|
|
Eye,
|
|
Heart,
|
|
MessageCircle,
|
|
RefreshCw,
|
|
ShoppingCart,
|
|
Star,
|
|
TrendingUp,
|
|
} from "lucide-react";
|
|
import Image from "next/image";
|
|
import { useEffect, useState } from "react";
|
|
import { ChatModal } from "@/components/chat/ChatModal";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardFooter,
|
|
CardHeader,
|
|
} from "@/components/ui/card";
|
|
import { useSession } from "@/lib/hooks/use-session";
|
|
import { useAdTracking, useMarketTelemetry } from "@/lib/hooks/use-telemetry";
|
|
import { cn } from "@/lib/utils";
|
|
import { AuctionCountdown } from "./AuctionCountdown";
|
|
import { BidModal } from "./BidModal";
|
|
import { ListingDetailsModal } from "./ListingDetailsModal";
|
|
import { PurchaseModal } from "./PurchaseModal";
|
|
import { SellerInfo } from "./SellerInfo";
|
|
|
|
// Função auxiliar para formatar tempo relativo
|
|
function formatTimeAgo(date: Date): string {
|
|
const seconds = Math.floor((Date.now() - 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;
|
|
has_pending_intention_for_current_user?: boolean;
|
|
highest_custom_offer_amount?: number | string | null;
|
|
highest_custom_offer_currency?: string | null;
|
|
}
|
|
|
|
interface PriceInfo {
|
|
type: "pix" | "tibia_coins" | "gold_coins" | "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 [showChatModal, setShowChatModal] = 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 (!Number.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 (!Number.isNaN(price)) {
|
|
prices.push({
|
|
type: "tibia_coins",
|
|
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 (!Number.isNaN(price)) {
|
|
prices.push({
|
|
type: "gold_coins",
|
|
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";
|
|
const hasPendingIntention = Boolean(
|
|
listing.has_pending_intention_for_current_user,
|
|
);
|
|
|
|
const getHighestOfferLabel = () => {
|
|
if (
|
|
listing.highest_custom_offer_amount === null ||
|
|
listing.highest_custom_offer_amount === undefined ||
|
|
!listing.highest_custom_offer_currency
|
|
) {
|
|
return null;
|
|
}
|
|
const amount =
|
|
typeof listing.highest_custom_offer_amount === "string"
|
|
? Number(listing.highest_custom_offer_amount)
|
|
: listing.highest_custom_offer_amount;
|
|
if (Number.isNaN(amount)) {
|
|
return null;
|
|
}
|
|
switch (listing.highest_custom_offer_currency) {
|
|
case "pix":
|
|
return `Maior oferta: R$ ${amount.toFixed(2)}`;
|
|
case "tibia_coins":
|
|
return `Maior oferta: ${Math.round(amount).toLocaleString()} TC`;
|
|
case "gold_coins":
|
|
return `Maior oferta: ${Math.round(amount).toLocaleString()} GP`;
|
|
default:
|
|
return `Maior oferta: ${amount}`;
|
|
}
|
|
};
|
|
|
|
const highestOfferLabel = getHighestOfferLabel();
|
|
|
|
// 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();
|
|
|
|
// Função para obter URL da imagem do item de troca
|
|
const getTradeItemImageUrl = (): string | null => {
|
|
if (!listing.trade_item_id || !listing.trade_item_name) {
|
|
return null;
|
|
}
|
|
|
|
// Usar trade_item_id para buscar imagem
|
|
const params = new URLSearchParams({
|
|
item: listing.trade_item_id.toString(),
|
|
});
|
|
params.set("name", listing.trade_item_name);
|
|
return `/api/images?${params.toString()}`;
|
|
};
|
|
|
|
const tradeItemImageUrl = getTradeItemImageUrl();
|
|
|
|
// Componente para exibir badges de preços em quadrados
|
|
const PriceBadges = ({
|
|
prices,
|
|
compact = false,
|
|
tradeItemImageUrl,
|
|
listing,
|
|
}: {
|
|
prices: PriceInfo[];
|
|
compact?: boolean;
|
|
tradeItemImageUrl?: string | null;
|
|
listing?: Listing;
|
|
}) => {
|
|
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-100 hover:bg-green-200 dark:bg-green-900/30 dark:hover:bg-green-900/50 text-green-700 dark:text-green-300 border-green-300 dark:border-green-700";
|
|
case "tibia_coins":
|
|
return "bg-blue-100 hover:bg-blue-200 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700";
|
|
case "gold_coins":
|
|
return "bg-amber-100 hover:bg-amber-200 dark:bg-amber-900/30 dark:hover:bg-amber-900/50 text-amber-700 dark:text-amber-300 border-amber-300 dark:border-amber-700";
|
|
case "trade":
|
|
return "bg-purple-100 hover:bg-purple-200 dark:bg-purple-900/30 dark:hover:bg-purple-900/50 text-purple-700 dark:text-purple-300 border-purple-300 dark:border-purple-700";
|
|
default:
|
|
return "";
|
|
}
|
|
};
|
|
|
|
const getIcon = (type: PriceInfo["type"]) => {
|
|
switch (type) {
|
|
case "pix":
|
|
return <DollarSign className="h-3 w-3" />;
|
|
case "tibia_coins":
|
|
case "gold_coins":
|
|
return <Coins className="h-3 w-3" />;
|
|
case "trade":
|
|
return <RefreshCw className="h-3 w-3" />;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Calcular número de colunas (apenas preços)
|
|
const totalCols = prices.length;
|
|
// Ajustar tamanho mínimo para caber todos os itens
|
|
const colClass = compact ? `flex-1 min-w-0` : `flex-1 min-w-0`;
|
|
|
|
return (
|
|
<div className="flex gap-0 w-full overflow-hidden rounded-lg">
|
|
{/* Quadrados de preços individuais */}
|
|
{prices.map((price, index) => (
|
|
<div
|
|
key={index}
|
|
className={cn(
|
|
colClass,
|
|
"flex flex-col items-center justify-center p-1.5 border-r border-border/50 last:border-r-0 rounded-none",
|
|
index === 0
|
|
? "rounded-l-lg"
|
|
: index === prices.length - 1
|
|
? "rounded-r-lg"
|
|
: "",
|
|
getBadgeClassName(price.type),
|
|
"transition-colors duration-200",
|
|
"overflow-hidden",
|
|
"text-xs",
|
|
)}
|
|
style={{ minWidth: `${100 / totalCols}%` }}
|
|
>
|
|
{price.type === "trade" ? (
|
|
<>
|
|
<div className="flex items-center gap-0.5 mb-1">
|
|
{getIcon(price.type)}
|
|
<span className="text-[10px] font-semibold">Troca</span>
|
|
</div>
|
|
{tradeItemImageUrl && listing && listing.trade_item_name && (
|
|
<div className="relative w-10 h-10 rounded overflow-hidden bg-background/20 dark:bg-background/10 border border-current/20 flex items-center justify-center">
|
|
<Image
|
|
src={tradeItemImageUrl}
|
|
alt={listing.trade_item_name}
|
|
width={40}
|
|
height={40}
|
|
className="object-contain max-w-full max-h-full"
|
|
onError={(e) => {
|
|
(e.target as HTMLImageElement).src =
|
|
"/placeholder-item.png";
|
|
}}
|
|
/>
|
|
{listing.trade_item_quantity && (
|
|
<div className="absolute top-0 right-0 bg-black/70 text-white text-[7px] px-0.5 rounded-bl">
|
|
{listing.trade_item_quantity}x
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center gap-0.5 mb-0.5">
|
|
{getIcon(price.type)}
|
|
<span className="text-[10px] font-semibold leading-tight">
|
|
{price.label}
|
|
</span>
|
|
</div>
|
|
<span
|
|
className={cn(
|
|
"text-center font-bold leading-tight",
|
|
compact ? "text-xs" : "text-sm",
|
|
)}
|
|
>
|
|
{price.value.split(" ")[0]}
|
|
</span>
|
|
{price.value.includes(" ") && (
|
|
<span className="text-[10px] opacity-80 mt-0.5 leading-tight">
|
|
{price.value.split(" ").slice(1).join(" ")}
|
|
</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
if (viewMode === "list") {
|
|
return (
|
|
<Card
|
|
className={cn(
|
|
"hover:shadow-xl transition-all duration-300",
|
|
isSold && "opacity-75 relative",
|
|
listing.is_auction && !isSold && "border-l-4 border-l-primary",
|
|
)}
|
|
>
|
|
{isSold && (
|
|
<div className="absolute inset-0 bg-black/10 rounded-lg z-10 pointer-events-none" />
|
|
)}
|
|
{/* Título */}
|
|
<div className="px-4 pt-0 pb-1.5">
|
|
<h4 className="text-base font-semibold text-foreground">
|
|
Item a venda
|
|
</h4>
|
|
</div>
|
|
<div className="flex gap-3 p-3 pt-1">
|
|
{/* Thumbnails */}
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
{/* Imagem do item principal */}
|
|
<div
|
|
className={cn(
|
|
"relative w-24 h-24 rounded-lg overflow-hidden flex items-center justify-center p-1.5",
|
|
listing.is_featured
|
|
? "bg-background/80 border-2 border-yellow-400/50 shadow-sm"
|
|
: "bg-muted border border-border",
|
|
)}
|
|
>
|
|
<Image
|
|
src={itemImageUrl}
|
|
alt={listing.item_name}
|
|
width={96}
|
|
height={96}
|
|
className="object-contain max-w-full max-h-full"
|
|
onError={(e) => {
|
|
(e.target as HTMLImageElement).src = "/placeholder-item.png";
|
|
}}
|
|
/>
|
|
</div>
|
|
</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-1.5 mb-1.5">
|
|
{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-xs text-muted-foreground">
|
|
<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"
|
|
className="bg-primary/20 text-primary border border-primary/30"
|
|
>
|
|
<TrendingUp className="h-3 w-3 mr-1" />
|
|
Leilão
|
|
</Badge>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleFavorite();
|
|
}}
|
|
disabled={!user}
|
|
className="transition-all duration-200 hover:bg-red-50 dark:hover:bg-red-950/20"
|
|
>
|
|
<Heart
|
|
className={cn(
|
|
"h-4 w-4 transition-all duration-200",
|
|
isFavorited
|
|
? "fill-red-500 text-red-500 scale-110"
|
|
: "hover:scale-110",
|
|
)}
|
|
/>
|
|
<span className="ml-1">{favoritesCount}</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{listing.description && (
|
|
<p className="text-sm text-muted-foreground mt-1.5 line-clamp-2 leading-relaxed">
|
|
{listing.description}
|
|
</p>
|
|
)}
|
|
<div className="flex items-center justify-between mt-2 gap-4">
|
|
<div className="flex-1 min-w-0 space-y-1.5">
|
|
{/* Preços - mostrar lance atual como principal em leilões */}
|
|
{listing.is_auction && listing.current_bid_amount ? (
|
|
<div
|
|
className={cn(
|
|
"space-y-1 p-2 rounded-lg border-l-4",
|
|
"bg-primary/5 border-primary",
|
|
)}
|
|
>
|
|
<div className="text-xs text-muted-foreground">
|
|
Lance Atual
|
|
</div>
|
|
<div className="text-xl font-bold text-primary">
|
|
R${" "}
|
|
{typeof listing.current_bid_amount === "number"
|
|
? listing.current_bid_amount.toFixed(2)
|
|
: parseFloat(
|
|
String(listing.current_bid_amount),
|
|
).toFixed(2)}
|
|
</div>
|
|
{listing.initial_price_pix && (
|
|
<div className="text-xs text-muted-foreground">
|
|
Inicial: R${" "}
|
|
{typeof listing.initial_price_pix === "number"
|
|
? listing.initial_price_pix.toFixed(2)
|
|
: parseFloat(
|
|
String(listing.initial_price_pix),
|
|
).toFixed(2)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div
|
|
className={cn(
|
|
listing.is_featured &&
|
|
"p-2 rounded-lg border-l-4 bg-yellow-500/5 border-yellow-500",
|
|
"overflow-hidden",
|
|
)}
|
|
>
|
|
<div className="space-y-1">
|
|
<PriceBadges
|
|
prices={availablePrices}
|
|
compact
|
|
tradeItemImageUrl={tradeItemImageUrl}
|
|
listing={listing}
|
|
/>
|
|
{highestOfferLabel && (
|
|
<div className="text-xs font-medium text-primary/80">
|
|
{highestOfferLabel}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Informações de lance se for leilão */}
|
|
{listing.is_auction && (
|
|
<div className="bg-gradient-to-r from-primary/5 to-primary/10 border border-primary/20 rounded-lg p-2 space-y-1">
|
|
{listing.current_bid_amount ? (
|
|
<>
|
|
<div className="flex items-center justify-between gap-3">
|
|
{listing.min_bid_increment && (
|
|
<div className="flex flex-col">
|
|
<span className="text-xs text-muted-foreground">
|
|
Incremento Mín
|
|
</span>
|
|
<span className="text-sm font-semibold">
|
|
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 flex-col items-end">
|
|
<span className="text-xs text-muted-foreground mb-0.5">
|
|
Termina em
|
|
</span>
|
|
<AuctionCountdown
|
|
endsAt={listing.auction_ends_at}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center justify-center pt-1 border-t border-primary/10">
|
|
<Badge
|
|
variant="secondary"
|
|
className="bg-primary/20 text-primary hover:bg-primary/30 text-xs"
|
|
>
|
|
<TrendingUp className="h-3 w-3 mr-1" />
|
|
Leilão Ativo
|
|
</Badge>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="space-y-1">
|
|
<div className="text-center">
|
|
<div className="text-xs font-medium text-muted-foreground mb-0.5">
|
|
Sem lances ainda
|
|
</div>
|
|
{listing.initial_price_pix && (
|
|
<div className="text-xs text-muted-foreground">
|
|
Lance inicial: R${" "}
|
|
{typeof listing.initial_price_pix === "number"
|
|
? listing.initial_price_pix.toFixed(2)
|
|
: parseFloat(
|
|
String(listing.initial_price_pix),
|
|
).toFixed(2)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{listing.auction_ends_at && (
|
|
<div className="flex items-center justify-center pt-1 border-t border-primary/10">
|
|
<AuctionCountdown
|
|
endsAt={listing.auction_ends_at}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center justify-center">
|
|
<Badge
|
|
variant="secondary"
|
|
className="bg-primary/20 text-primary hover:bg-primary/30 text-xs"
|
|
>
|
|
<TrendingUp className="h-3 w-3 mr-1" />
|
|
Leilão Ativo
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3 ml-4">
|
|
<div className="flex items-center gap-3 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"
|
|
className={cn(
|
|
"transition-all duration-200",
|
|
listing.auction_ends_at &&
|
|
(() => {
|
|
const endsAt = new Date(
|
|
listing.auction_ends_at,
|
|
).getTime();
|
|
const now = Date.now();
|
|
const timeLeft = endsAt - now;
|
|
const minutesLeft = Math.floor(
|
|
timeLeft / (1000 * 60),
|
|
);
|
|
return minutesLeft < 60 && minutesLeft > 0
|
|
? "bg-red-600 hover:bg-red-700 animate-pulse"
|
|
: "";
|
|
})(),
|
|
)}
|
|
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="transition-all duration-200 hover:scale-105"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setShowPurchaseModal(true);
|
|
}}
|
|
disabled={isSold}
|
|
>
|
|
<ShoppingCart className="h-4 w-4 mr-2" />
|
|
Comprar
|
|
</Button>
|
|
)}
|
|
{user && user.userId !== listing.seller_id && !isSold && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="transition-all duration-200 hover:scale-105"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setShowChatModal(true);
|
|
}}
|
|
>
|
|
<MessageCircle className="h-4 w-4 mr-2" />
|
|
Chat
|
|
</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,
|
|
current_bid_amount: listing.current_bid_amount ?? null,
|
|
min_bid_increment: listing.min_bid_increment ?? null,
|
|
initial_price_pix: listing.initial_price_pix ?? null,
|
|
initial_price_tibia_coins:
|
|
listing.initial_price_tibia_coins ?? null,
|
|
initial_price_gold_coins: listing.initial_price_gold_coins ?? null,
|
|
price_pix:
|
|
typeof listing.price_pix === "string"
|
|
? parseFloat(listing.price_pix)
|
|
: (listing.price_pix ?? null),
|
|
price_tibia_coins:
|
|
typeof listing.price_tibia_coins === "string"
|
|
? parseInt(String(listing.price_tibia_coins), 10)
|
|
: (listing.price_tibia_coins ?? null),
|
|
price_gold_coins:
|
|
typeof listing.price_gold_coins === "string"
|
|
? parseInt(String(listing.price_gold_coins), 10)
|
|
: (listing.price_gold_coins ?? null),
|
|
auction_ends_at: listing.auction_ends_at ?? null,
|
|
}}
|
|
onSuccess={() => {
|
|
invalidateCache();
|
|
}}
|
|
/>
|
|
<ListingDetailsModal
|
|
open={showDetailsModal}
|
|
onOpenChange={setShowDetailsModal}
|
|
listing={listing}
|
|
onPause={handlePause}
|
|
onActivate={handleActivate}
|
|
onCancel={handleCancel}
|
|
/>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// View mode grid (padrão)
|
|
return (
|
|
<Card
|
|
className={cn(
|
|
"hover:shadow-xl transition-all duration-300 flex flex-col",
|
|
isSold && "opacity-75 relative",
|
|
listing.is_auction &&
|
|
!isSold &&
|
|
"border-2 border-primary/20 hover:border-primary/40 relative overflow-hidden",
|
|
)}
|
|
>
|
|
{isSold && (
|
|
<div className="absolute inset-0 bg-black/10 rounded-lg z-10 pointer-events-none" />
|
|
)}
|
|
{listing.is_auction && !isSold && (
|
|
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-primary/50 via-primary to-primary/50" />
|
|
)}
|
|
<CardHeader className="p-0 relative">
|
|
{/* Título */}
|
|
<div className="px-3 pt-0 pb-1">
|
|
<h4 className="text-base font-semibold text-foreground">
|
|
Item a venda
|
|
</h4>
|
|
</div>
|
|
{/* Container de imagens */}
|
|
<div className="relative bg-muted rounded-lg overflow-hidden mx-2 mb-1.5">
|
|
{/* Imagem do item principal */}
|
|
<div className="flex items-center justify-center p-2 h-40">
|
|
<div
|
|
className={cn(
|
|
"relative w-full h-full max-w-[200px] rounded-lg overflow-hidden flex items-center justify-center group",
|
|
listing.is_featured
|
|
? "bg-background/80 border-2 border-yellow-400/50 shadow-sm"
|
|
: "bg-background/50 border border-border",
|
|
)}
|
|
>
|
|
<Image
|
|
src={itemImageUrl}
|
|
alt={listing.item_name}
|
|
width={160}
|
|
height={160}
|
|
className="object-contain transition-transform duration-300 group-hover:scale-105 max-w-full max-h-full"
|
|
onError={(e) => {
|
|
(e.target as HTMLImageElement).src = "/placeholder-item.png";
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Badges de status */}
|
|
{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"
|
|
className="bg-primary/20 text-primary border border-primary/30"
|
|
>
|
|
<TrendingUp className="h-3 w-3 mr-1" />
|
|
Leilão
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent className="p-3 flex-1 flex flex-col gap-2">
|
|
<h3 className="font-semibold text-lg leading-tight line-clamp-2">
|
|
{listing.item_name}
|
|
</h3>
|
|
|
|
<div className="-mt-0.5">
|
|
{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 line-clamp-2 leading-relaxed -mt-0.5">
|
|
{listing.description}
|
|
</p>
|
|
)}
|
|
|
|
<div className="mt-auto space-y-2">
|
|
{/* Preços - mostrar lance atual como principal em leilões */}
|
|
{listing.is_auction && listing.current_bid_amount ? (
|
|
<div
|
|
className={cn(
|
|
"space-y-1.5 p-2 rounded-lg border-l-4",
|
|
"bg-primary/5 border-primary",
|
|
)}
|
|
>
|
|
<div>
|
|
<div className="text-xs text-muted-foreground mb-0.5">
|
|
Lance Atual
|
|
</div>
|
|
<div className="text-2xl font-bold text-primary">
|
|
R${" "}
|
|
{typeof listing.current_bid_amount === "number"
|
|
? listing.current_bid_amount.toFixed(2)
|
|
: parseFloat(String(listing.current_bid_amount)).toFixed(2)}
|
|
</div>
|
|
{listing.initial_price_pix && (
|
|
<div className="text-xs text-muted-foreground mt-0.5">
|
|
Preço inicial: R${" "}
|
|
{typeof listing.initial_price_pix === "number"
|
|
? listing.initial_price_pix.toFixed(2)
|
|
: parseFloat(String(listing.initial_price_pix)).toFixed(
|
|
2,
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className={cn(
|
|
listing.is_featured &&
|
|
"p-2 rounded-lg border-l-4 bg-yellow-500/5 border-yellow-500",
|
|
"overflow-hidden",
|
|
)}
|
|
>
|
|
<div className="space-y-1">
|
|
<PriceBadges
|
|
prices={availablePrices}
|
|
tradeItemImageUrl={tradeItemImageUrl}
|
|
listing={listing}
|
|
/>
|
|
{highestOfferLabel && (
|
|
<div className="text-xs font-medium text-primary/80">
|
|
{highestOfferLabel}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Informações de lance se for leilão */}
|
|
{listing.is_auction && (
|
|
<div className="bg-gradient-to-br from-primary/5 to-primary/10 border border-primary/20 rounded-lg p-2 space-y-1.5">
|
|
{listing.current_bid_amount ? (
|
|
<>
|
|
<div className="flex items-center justify-between">
|
|
{listing.min_bid_increment && (
|
|
<div className="flex flex-col">
|
|
<span className="text-xs text-muted-foreground">
|
|
Incremento Mínimo
|
|
</span>
|
|
<span className="text-sm font-semibold text-foreground">
|
|
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 flex-col items-end">
|
|
<span className="text-xs text-muted-foreground mb-0.5">
|
|
Termina em
|
|
</span>
|
|
<AuctionCountdown endsAt={listing.auction_ends_at} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center justify-center pt-1 border-t border-primary/10">
|
|
<Badge
|
|
variant="secondary"
|
|
className="bg-primary/20 text-primary hover:bg-primary/30"
|
|
>
|
|
<TrendingUp className="h-3 w-3 mr-1.5" />
|
|
Leilão Ativo
|
|
</Badge>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="space-y-1.5">
|
|
<div className="text-center">
|
|
<div className="text-sm font-medium text-muted-foreground mb-0.5">
|
|
Sem lances ainda
|
|
</div>
|
|
{listing.initial_price_pix && (
|
|
<div className="text-xs text-muted-foreground">
|
|
Lance inicial: R${" "}
|
|
{typeof listing.initial_price_pix === "number"
|
|
? listing.initial_price_pix.toFixed(2)
|
|
: parseFloat(
|
|
String(listing.initial_price_pix),
|
|
).toFixed(2)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{listing.auction_ends_at && (
|
|
<div className="flex items-center justify-center pt-2 border-t border-primary/10">
|
|
<AuctionCountdown endsAt={listing.auction_ends_at} />
|
|
</div>
|
|
)}
|
|
<div className="flex items-center justify-center pt-1">
|
|
<Badge
|
|
variant="secondary"
|
|
className="bg-primary/20 text-primary hover:bg-primary/30"
|
|
>
|
|
<TrendingUp className="h-3 w-3 mr-1.5" />
|
|
Leilão Ativo
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between text-xs text-muted-foreground pt-0.5">
|
|
<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="px-3 py-2 pt-0 flex items-center justify-between gap-3">
|
|
{/* 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={cn(
|
|
"flex-1 transition-all duration-200",
|
|
listing.auction_ends_at &&
|
|
(() => {
|
|
const endsAt = new Date(
|
|
listing.auction_ends_at,
|
|
).getTime();
|
|
const now = Date.now();
|
|
const timeLeft = endsAt - now;
|
|
const minutesLeft = Math.floor(timeLeft / (1000 * 60));
|
|
return minutesLeft < 60 && minutesLeft > 0
|
|
? "bg-red-600 hover:bg-red-700 animate-pulse"
|
|
: "";
|
|
})(),
|
|
)}
|
|
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={cn(
|
|
"flex-1 transition-all duration-200 hover:scale-105",
|
|
(isSold || hasPendingIntention) &&
|
|
"opacity-60 cursor-not-allowed hover:scale-100",
|
|
)}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (hasPendingIntention) {
|
|
return;
|
|
}
|
|
setShowPurchaseModal(true);
|
|
}}
|
|
disabled={isSold || hasPendingIntention}
|
|
title={
|
|
hasPendingIntention
|
|
? "Você já enviou uma intenção para este anúncio. Gerencie em Minhas intenções."
|
|
: undefined
|
|
}
|
|
>
|
|
<ShoppingCart className="h-4 w-4 mr-2" />
|
|
{hasPendingIntention ? "Intenção pendente" : "Comprar"}
|
|
</Button>
|
|
)}
|
|
{user && user.userId !== listing.seller_id && !isSold && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="flex-1 transition-all duration-200 hover:scale-105"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setShowChatModal(true);
|
|
}}
|
|
>
|
|
<MessageCircle className="h-4 w-4 mr-2" />
|
|
Chat
|
|
</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 transition-all duration-200 hover:scale-105"
|
|
onClick={handleClick}
|
|
>
|
|
Ver Detalhes
|
|
</Button>
|
|
)}
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleFavorite();
|
|
}}
|
|
disabled={!user}
|
|
className="transition-all duration-200 hover:bg-red-50 dark:hover:bg-red-950/20"
|
|
>
|
|
<Heart
|
|
className={cn(
|
|
"h-4 w-4 transition-all duration-200",
|
|
isFavorited
|
|
? "fill-red-500 text-red-500 scale-110"
|
|
: "hover:scale-110",
|
|
)}
|
|
/>
|
|
<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,
|
|
current_bid_amount: listing.current_bid_amount ?? null,
|
|
min_bid_increment: listing.min_bid_increment ?? null,
|
|
initial_price_pix: listing.initial_price_pix ?? null,
|
|
initial_price_tibia_coins: listing.initial_price_tibia_coins ?? null,
|
|
initial_price_gold_coins: listing.initial_price_gold_coins ?? null,
|
|
price_pix:
|
|
typeof listing.price_pix === "string"
|
|
? parseFloat(listing.price_pix)
|
|
: (listing.price_pix ?? null),
|
|
price_tibia_coins:
|
|
typeof listing.price_tibia_coins === "string"
|
|
? parseInt(String(listing.price_tibia_coins), 10)
|
|
: (listing.price_tibia_coins ?? null),
|
|
price_gold_coins:
|
|
typeof listing.price_gold_coins === "string"
|
|
? parseInt(String(listing.price_gold_coins), 10)
|
|
: (listing.price_gold_coins ?? null),
|
|
auction_ends_at: listing.auction_ends_at ?? null,
|
|
}}
|
|
onSuccess={() => {
|
|
// Recarregar página ou atualizar lista
|
|
window.location.reload();
|
|
}}
|
|
/>
|
|
<ListingDetailsModal
|
|
open={showDetailsModal}
|
|
onOpenChange={setShowDetailsModal}
|
|
listing={listing}
|
|
onPause={handlePause}
|
|
onActivate={handleActivate}
|
|
onCancel={handleCancel}
|
|
/>
|
|
{user && user.userId !== listing.seller_id && (
|
|
<ChatModal
|
|
open={showChatModal}
|
|
onOpenChange={setShowChatModal}
|
|
listingId={listing.listing_id}
|
|
/>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|