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