611 lines
20 KiB
TypeScript
611 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
AlertCircle,
|
|
CheckCircle2,
|
|
Clock,
|
|
Eye,
|
|
Heart,
|
|
MapPin,
|
|
Pause,
|
|
ShoppingBag,
|
|
Star,
|
|
Trash2,
|
|
TrendingUp,
|
|
X,
|
|
} from "lucide-react";
|
|
import Image from "next/image";
|
|
import { useEffect, useState } from "react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { useSession } from "@/lib/hooks/use-session";
|
|
|
|
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;
|
|
highest_custom_offer_amount?: number | string | null;
|
|
highest_custom_offer_currency?: 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((Date.now() - 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 (!Number.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 (!Number.isNaN(price)) {
|
|
prices.push({
|
|
type: "tibia_coins",
|
|
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 (!Number.isNaN(price)) {
|
|
prices.push({
|
|
type: "gold_coins",
|
|
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();
|
|
const highestOfferLabel = (() => {
|
|
if (
|
|
listing.highest_custom_offer_amount === null ||
|
|
listing.highest_custom_offer_amount === undefined ||
|
|
!listing.highest_custom_offer_currency
|
|
) {
|
|
return null;
|
|
}
|
|
const amount =
|
|
typeof listing.highest_custom_offer_amount === "string"
|
|
? Number(listing.highest_custom_offer_amount)
|
|
: listing.highest_custom_offer_amount;
|
|
if (Number.isNaN(amount)) {
|
|
return null;
|
|
}
|
|
switch (listing.highest_custom_offer_currency) {
|
|
case "pix":
|
|
return `Maior oferta registrada: R$ ${amount.toFixed(2)}`;
|
|
case "tibia_coins":
|
|
return `Maior oferta registrada: ${Math.round(amount).toLocaleString()} TC`;
|
|
case "gold_coins":
|
|
return `Maior oferta registrada: ${Math.round(amount).toLocaleString()} GP`;
|
|
default:
|
|
return `Maior oferta registrada: ${amount}`;
|
|
}
|
|
})();
|
|
// 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 === "tibia_coins"
|
|
? "bg-blue-500/10 border-blue-500/20"
|
|
: price.type === "gold_coins"
|
|
? "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>
|
|
{highestOfferLabel && (
|
|
<div className="rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-sm font-medium text-primary">
|
|
{highestOfferLabel}
|
|
</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>
|
|
);
|
|
}
|