Files
pagodera/components/marketplace/ListingDetailsModal.tsx
T
Evandro Fonseca Junior d8e7d5d7e6 feat: enhance purchase intentions with chat integration and offer tracking
- Added MongoDB chat/message integration for seller responses
- Improved seller response validation and default messaging
- Integrated automatic chat creation/update when responding to intentions
- Added highest custom offer fields to listing queries and models
- Enhanced listing data with top offer information for buyers
2025-11-08 11:51:21 -03:00

501 lines
19 KiB
TypeScript

"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;
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((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: "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 (!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>
);
}