Files
pagodera/components/marketplace/ListingDetailsModal.tsx
2025-11-11 03:08:36 -03:00

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>
);
}