style: clean up code formatting and remove trailing whitespace

refactor: reorganize imports and fix type imports
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-11 03:08:36 -03:00
1 parent d3704602fd
commit 47c04de6fe
232 files changed
+7140 -4798

No files matched your search

File diff suppressed because it is too large. Load diff
+10 -4
View File
@@ -1,10 +1,17 @@
"use client";
import {
Clock,
Eye,
Heart,
MessageSquare,
MousePointerClick,
TrendingUp,
} from "lucide-react";
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;
@@ -37,13 +44,13 @@ export function AnalyticsDashboard() {
if (user?.userId) {
loadUserListings();
}
}, [user?.userId]);
}, [user?.userId, loadUserListings]);
useEffect(() => {
if (selectedAd) {
loadAnalytics(selectedAd);
}
}, [selectedAd]);
}, [selectedAd, loadAnalytics]);
const loadUserListings = async () => {
setLoading(true);
@@ -266,4 +273,3 @@ export function AnalyticsDashboard() {
</div>
);
}
+8 -11
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { Clock } from "lucide-react";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
interface AuctionCountdownProps {
@@ -30,7 +30,7 @@ export function AuctionCountdown({
const calculateTimeRemaining = () => {
const endTime = new Date(endsAt).getTime();
const now = new Date().getTime();
const now = Date.now();
const total = endTime - now;
if (total <= 0) {
@@ -61,19 +61,19 @@ export function AuctionCountdown({
// Determinar cor baseada no tempo restante
const getColorClass = () => {
const totalMinutes = timeRemaining.hours * 60 + timeRemaining.minutes;
if (timeRemaining.total <= 0) {
return "text-red-600 dark:text-red-400";
}
if (totalMinutes < 5) {
return "text-red-500 dark:text-red-400 font-semibold animate-pulse";
}
if (totalMinutes < 60) {
return "text-yellow-600 dark:text-yellow-400";
}
return "text-green-600 dark:text-green-400";
};
@@ -84,7 +84,7 @@ export function AuctionCountdown({
}
const totalMinutes = timeRemaining.hours * 60 + timeRemaining.minutes;
if (totalMinutes < 5) {
return "Termina em breve";
}
@@ -102,13 +102,10 @@ export function AuctionCountdown({
return (
<div className={cn("flex items-center gap-1.5", className)}>
{showIcon && (
<Clock className={cn("h-3.5 w-3.5", getColorClass())} />
)}
{showIcon && <Clock className={cn("h-3.5 w-3.5", getColorClass())} />}
<span className={cn("text-xs font-medium", getColorClass())}>
{getTimeText()}
</span>
</div>
);
}
+78 -36
View File
@@ -1,8 +1,9 @@
"use client";
import { useState, useEffect } from "react";
import { Separator } from "@/components/ui/separator";
import { Loader2, TrendingUp } from "lucide-react";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -10,7 +11,6 @@ import {
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 {
@@ -20,7 +20,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Coins, DollarSign, Loader2, TrendingUp } from "lucide-react";
import { Separator } from "@/components/ui/separator";
import { useSession } from "@/lib/hooks/use-session";
interface Listing {
@@ -45,20 +45,27 @@ interface BidModalProps {
onSuccess?: () => void;
}
export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalProps) {
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 [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;
@@ -67,41 +74,50 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
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;
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");
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]);
}, [paymentMethod]);
// Atualizar valor do lance quando mudar
useEffect(() => {
if (bidAmount === "" && minBid > 0) {
setBidAmount(minBid.toFixed(2));
}
}, [minBid]);
}, [minBid, bidAmount]);
// Buscar histórico de lances quando abrir modal
useEffect(() => {
if (open && listing) {
fetchBidHistory();
}
}, [open, listing?.listing_id]);
}, [open, listing?.listing_id, fetchBidHistory, listing]);
const fetchBidHistory = async () => {
if (!listing) return;
setLoadingHistory(true);
try {
const res = await fetch(`/api/listings/${listing.listing_id}/bids?limit=5`);
const res = await fetch(
`/api/listings/${listing.listing_id}/bids?limit=5`,
);
if (res.ok) {
const data = await res.json();
if (data.success) {
@@ -117,14 +133,16 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
const handleBid = async () => {
const amount = parseFloat(bidAmount);
if (isNaN(amount) || amount <= 0) {
if (Number.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)})`);
setError(
`Lance mínimo é ${minBid.toFixed(2)} (lance atual: ${currentBid.toFixed(2)} + incremento: ${minIncrement.toFixed(2)})`,
);
return;
}
@@ -198,11 +216,17 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
<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>
<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>
<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>
@@ -211,18 +235,28 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
<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>
<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>
<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>
<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>
@@ -235,7 +269,9 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
{/* 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>
<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;
@@ -244,7 +280,9 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
<div
key={bid.bid_id}
className={`p-2 rounded ${
isCurrentUser ? "bg-primary/10 border border-primary/20" : ""
isCurrentUser
? "bg-primary/10 border border-primary/20"
: ""
} ${isHighest ? "border-l-2 border-l-primary" : ""}`}
>
<div className="flex items-center justify-between">
@@ -343,7 +381,12 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
</Button>
<Button
onClick={handleBid}
disabled={loading || !bidAmount || parseFloat(bidAmount) < minBid || !paymentMethod}
disabled={
loading ||
!bidAmount ||
parseFloat(bidAmount) < minBid ||
!paymentMethod
}
>
{loading ? (
<>
@@ -363,4 +406,3 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
</Dialog>
);
}
+29 -17
View File
@@ -1,6 +1,9 @@
"use client";
import { useState, useEffect } from "react";
import { Package } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -8,12 +11,8 @@ import {
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,
@@ -21,9 +20,10 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { useSession } from "@/lib/hooks/use-session";
import { ItemSearch } from "./ItemSearch";
import { ArrowRight, Package } from "lucide-react";
interface Item {
id: number;
@@ -47,7 +47,11 @@ interface Server {
isActive: boolean;
}
export function CreateAdModal({ open, onOpenChange, onSuccess }: CreateAdModalProps) {
export function CreateAdModal({
open,
onOpenChange,
onSuccess,
}: CreateAdModalProps) {
const { user } = useSession();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -97,7 +101,7 @@ export function CreateAdModal({ open, onOpenChange, onSuccess }: CreateAdModalPr
if (!hasPrice && (!acceptTrade || !tradeItem)) {
setError(
"Informe pelo menos um preço (PIX, TC ou GP) ou configure uma troca por item"
"Informe pelo menos um preço (PIX, TC ou GP) ou configure uma troca por item",
);
setLoading(false);
return;
@@ -130,9 +134,10 @@ export function CreateAdModal({ open, onOpenChange, onSuccess }: CreateAdModalPr
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,
trade_item_quantity:
acceptTrade && tradeItem
? parseInt(formData.trade_item_quantity, 10)
: null,
}),
});
@@ -155,10 +160,14 @@ export function CreateAdModal({ open, onOpenChange, onSuccess }: CreateAdModalPr
auction_ends_at: "Término do leilão",
};
const validationMessages = data.details.map((detail: { field?: string; message?: string }) => {
const fieldLabel = detail.field ? fieldLabels[detail.field] || detail.field : "Campo";
return `${fieldLabel}: ${detail.message ?? "preenchimento obrigatório"}`;
});
const validationMessages = data.details.map(
(detail: { field?: string; message?: string }) => {
const fieldLabel = detail.field
? fieldLabels[detail.field] || detail.field
: "Campo";
return `${fieldLabel}: ${detail.message ?? "preenchimento obrigatório"}`;
},
);
setError(validationMessages.join(" "));
} else {
@@ -199,7 +208,7 @@ export function CreateAdModal({ open, onOpenChange, onSuccess }: CreateAdModalPr
if (open) {
loadServers();
}
}, [open]);
}, [open, loadServers]);
const loadServers = async () => {
setLoadingServers(true);
@@ -295,7 +304,10 @@ export function CreateAdModal({ open, onOpenChange, onSuccess }: CreateAdModalPr
</SelectItem>
) : (
servers.map((server) => (
<SelectItem key={server.serverId} value={server.serverName}>
<SelectItem
key={server.serverId}
value={server.serverName}
>
{server.serverName}
{server.location && ` (${server.location})`}
</SelectItem>
+80 -39
View File
@@ -1,6 +1,20 @@
"use client";
import { useState, useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
CheckCircle2,
Clock,
Loader2,
MessageSquare,
ShoppingBag,
ShoppingCart,
User,
XCircle,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -8,24 +22,10 @@ import {
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 { Textarea } from "@/components/ui/textarea";
import { useSession } from "@/lib/hooks/use-session";
import { useQueryClient } from "@tanstack/react-query";
interface PurchaseIntention {
intention_id: number;
@@ -72,21 +72,30 @@ function getStatusBadge(status: string) {
switch (status) {
case "pending":
return (
<Badge variant="secondary" className="bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">
<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">
<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">
<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>
@@ -96,7 +105,9 @@ function getStatusBadge(status: string) {
}
}
function formatCustomOffer(intention: PurchaseIntention): { value: string; date: string | null } | null {
function formatCustomOffer(
intention: PurchaseIntention,
): { value: string; date: string | null } | null {
if (
intention.custom_offer_amount === null ||
intention.custom_offer_amount === undefined ||
@@ -142,7 +153,10 @@ export function IntentionDetailModal({
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 [listing, setListing] = useState<{
seller_id: number;
listing_status: string;
} | null>(null);
const [showMarkSoldDialog, setShowMarkSoldDialog] = useState(false);
const [markingSold, setMarkingSold] = useState(false);
@@ -161,7 +175,11 @@ export function IntentionDetailModal({
}, [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 canMarkAsSold =
isOwner &&
listing &&
(listing.listing_status === "active" ||
listing.listing_status === "in_escrow");
const canAcceptOrReject = intention.status === "pending";
const customOffer = formatCustomOffer(intention);
@@ -169,7 +187,7 @@ export function IntentionDetailModal({
if (open) {
setSellerResponse(intention.seller_response ?? "");
}
}, [open, intention.intention_id, intention.seller_response]);
}, [open, intention.seller_response]);
const handleAccept = async () => {
setLoading(true);
@@ -185,7 +203,7 @@ export function IntentionDetailModal({
status: "accepted",
seller_response: sellerResponse || undefined,
}),
}
},
);
const data = await res.json();
@@ -217,7 +235,7 @@ export function IntentionDetailModal({
status: "rejected",
seller_response: sellerResponse || undefined,
}),
}
},
);
const data = await res.json();
@@ -229,7 +247,9 @@ export function IntentionDetailModal({
if (onUpdate) onUpdate();
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Erro ao rejeitar intenção");
setError(
err instanceof Error ? err.message : "Erro ao rejeitar intenção",
);
} finally {
setLoading(false);
}
@@ -241,7 +261,7 @@ export function IntentionDetailModal({
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");
@@ -250,13 +270,15 @@ export function IntentionDetailModal({
// 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");
alert(
error instanceof Error ? error.message : "Erro ao marcar como vendido",
);
} finally {
setMarkingSold(false);
}
@@ -271,7 +293,8 @@ export function IntentionDetailModal({
{getStatusBadge(intention.status)}
</DialogTitle>
<DialogDescription>
Detalhes da intenção de compra de {intention.buyer?.username || "usuário"}
Detalhes da intenção de compra de{" "}
{intention.buyer?.username || "usuário"}
</DialogDescription>
</DialogHeader>
@@ -304,7 +327,9 @@ export function IntentionDetailModal({
<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>
<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">
@@ -313,7 +338,9 @@ export function IntentionDetailModal({
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground">Data de Criação</Label>
<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">
@@ -328,9 +355,13 @@ export function IntentionDetailModal({
<Label className="text-xs text-primary uppercase tracking-wide">
Oferta do Comprador
</Label>
<div className="text-base font-semibold text-primary">{customOffer.value}</div>
<div className="text-base font-semibold text-primary">
{customOffer.value}
</div>
{customOffer.date && (
<p className="text-[11px] text-primary/70">Enviada em {customOffer.date}</p>
<p className="text-[11px] text-primary/70">
Enviada em {customOffer.date}
</p>
)}
</div>
)}
@@ -398,7 +429,11 @@ export function IntentionDetailModal({
Marcar como Vendido
</Button>
)}
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading || markingSold}>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading || markingSold}
>
Fechar
</Button>
{canAcceptOrReject && (
@@ -420,7 +455,10 @@ export function IntentionDetailModal({
</>
)}
</Button>
<Button onClick={handleAccept} disabled={loading || markingSold}>
<Button
onClick={handleAccept}
disabled={loading || markingSold}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
@@ -445,14 +483,18 @@ export function IntentionDetailModal({
<DialogHeader>
<DialogTitle>Marcar anúncio como vendido?</DialogTitle>
<DialogDescription>
Ao marcar este anúncio como vendido, as seguintes ações serão executadas:
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>
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">
@@ -480,4 +522,3 @@ export function IntentionDetailModal({
</Dialog>
);
}
+37 -28
View File
@@ -1,12 +1,12 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Input } from "@/components/ui/input";
import { Loader2, Search, X } from "lucide-react";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
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 { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
interface Item {
@@ -38,7 +38,9 @@ export function ItemSearch({
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
const [previewItem, setPreviewItem] = useState<Item | null>(selectedItem || null);
const [previewItem, setPreviewItem] = useState<Item | null>(
selectedItem || null,
);
const searchRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<NodeJS.Timeout | undefined>(undefined);
@@ -58,7 +60,7 @@ export function ItemSearch({
debounceRef.current = setTimeout(async () => {
try {
const res = await fetch(
`/api/items?q=${encodeURIComponent(searchQuery)}&pageSize=10`
`/api/items?q=${encodeURIComponent(searchQuery)}&pageSize=10`,
);
if (res.ok) {
const data = await res.json();
@@ -76,7 +78,10 @@ export function ItemSearch({
// Fechar dropdown ao clicar fora
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(event.target as Node)) {
if (
searchRef.current &&
!searchRef.current.contains(event.target as Node)
) {
setShowDropdown(false);
}
};
@@ -159,7 +164,9 @@ export function ItemSearch({
<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>
<p className="text-xs text-muted-foreground">
{item.category}
</p>
)}
</div>
{item.rarity && (
@@ -200,24 +207,27 @@ export function ItemSearch({
{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>
)}
{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>
@@ -227,4 +237,3 @@ export function ItemSearch({
</div>
);
}
+181 -71
View File
@@ -1,34 +1,32 @@
"use client";
import { useState, useEffect } from "react";
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,
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;
@@ -76,10 +74,11 @@ interface ListingDetailsModalProps {
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);
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"}`;
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);
@@ -92,14 +91,40 @@ function formatTimeAgo(date: Date | string): string {
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" },
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 config =
statusConfig[status as keyof typeof statusConfig] || statusConfig.active;
const Icon = config.icon;
return (
@@ -123,7 +148,7 @@ export function ListingDetailsModal({
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;
@@ -134,13 +159,19 @@ export function ListingDetailsModal({
} else if (listing.image_local_path) {
const normalizedPath = listing.image_local_path.replace(/\\/g, "/");
if (normalizedPath.startsWith("data/")) {
setItemImageUrl(`/api/images?path=${encodeURIComponent(normalizedPath)}`);
setItemImageUrl(
`/api/images?path=${encodeURIComponent(normalizedPath)}`,
);
} else {
const category = listing.item_category || "other";
setItemImageUrl(`/api/images?path=${encodeURIComponent(`data/images/${category}/${normalizedPath}`)}`);
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() });
const params = new URLSearchParams({
item: listing.item_id_unified.toString(),
});
if (listing.item_name) {
params.set("name", listing.item_name);
}
@@ -158,27 +189,52 @@ export function ListingDetailsModal({
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)}` });
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 (!isNaN(price)) {
prices.push({ type: "tibia_coins", label: "Tibia Coins", value: `${price.toLocaleString()} TC` });
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 (!isNaN(price)) {
prices.push({ type: "gold_coins", label: "Gold Coins", value: `${price.toLocaleString()} GP` });
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) {
if (
listing.trade_item_id &&
listing.trade_item_name &&
listing.trade_item_quantity
) {
prices.push({
type: "trade",
label: "Troca",
@@ -220,7 +276,10 @@ export function ListingDetailsModal({
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 canMarkAsSold =
isOwner &&
(listing.listing_status === "active" ||
listing.listing_status === "in_escrow");
const handleMarkAsSold = async () => {
setMarkingSold(true);
@@ -228,7 +287,7 @@ export function ListingDetailsModal({
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");
@@ -237,12 +296,14 @@ export function ListingDetailsModal({
// 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");
alert(
error instanceof Error ? error.message : "Erro ao marcar como vendido",
);
} finally {
setMarkingSold(false);
}
@@ -256,7 +317,9 @@ export function ListingDetailsModal({
Detalhes do Anúncio
{getStatusBadge(listing.listing_status)}
</DialogTitle>
<DialogDescription>Informações completas do seu anúncio</DialogDescription>
<DialogDescription>
Informações completas do seu anúncio
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
@@ -294,22 +357,30 @@ export function ListingDetailsModal({
<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="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 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 className="font-semibold text-xs">
{formatTimeAgo(listing.created_at)}
</div>
</div>
</div>
</div>
@@ -349,7 +420,9 @@ export function ListingDetailsModal({
: "bg-purple-500/10 border-purple-500/20"
}`}
>
<div className="text-xs text-muted-foreground mb-1">{price.label}</div>
<div className="text-xs text-muted-foreground mb-1">
{price.label}
</div>
<div className="font-semibold">{price.value}</div>
</div>
))}
@@ -370,26 +443,44 @@ export function ListingDetailsModal({
<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)}
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="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)}
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>
<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 className="text-center text-muted-foreground py-2">
Sem lances ainda
</div>
)}
</div>
)}
@@ -398,7 +489,9 @@ export function ListingDetailsModal({
{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>
<p className="text-sm text-muted-foreground p-3 bg-muted rounded-lg">
{listing.description}
</p>
</div>
)}
@@ -410,13 +503,19 @@ export function ListingDetailsModal({
</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>
<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>
<span className="ml-2">
{new Date(listing.created_at).toLocaleString("pt-BR")}
</span>
</div>
</div>
@@ -424,9 +523,9 @@ export function ListingDetailsModal({
{(canPause || canActivate || canCancel || canMarkAsSold) && (
<div className="flex gap-2 pt-4 border-t">
{canMarkAsSold && (
<Button
variant="default"
onClick={() => setShowMarkSoldDialog(true)}
<Button
variant="default"
onClick={() => setShowMarkSoldDialog(true)}
className="flex-1 bg-green-600 hover:bg-green-700"
>
<ShoppingBag className="h-4 w-4 mr-2" />
@@ -440,13 +539,21 @@ export function ListingDetailsModal({
</Button>
)}
{canActivate && onActivate && (
<Button variant="default" onClick={onActivate} className="flex-1">
<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">
<Button
variant="destructive"
onClick={onCancel}
className="flex-1"
>
<Trash2 className="h-4 w-4 mr-2" />
Cancelar Anúncio
</Button>
@@ -462,14 +569,18 @@ export function ListingDetailsModal({
<DialogHeader>
<DialogTitle>Marcar anúncio como vendido?</DialogTitle>
<DialogDescription>
Ao marcar este anúncio como vendido, as seguintes ações serão executadas:
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>
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">
@@ -497,4 +608,3 @@ export function ListingDetailsModal({
</Dialog>
);
}
+47 -17
View File
@@ -1,11 +1,17 @@
"use client";
import { useState, useEffect } from "react";
import {
CheckCircle2,
Clock,
MessageSquare,
ShoppingCart,
XCircle,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
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 {
@@ -32,10 +38,11 @@ interface MyAdsIntentionListProps {
}
function formatTimeAgo(date: Date): string {
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 60) return "há alguns segundos";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `há ${minutes} ${minutes === 1 ? "minuto" : "minutos"}`;
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);
@@ -46,21 +53,30 @@ function getStatusBadge(status: string) {
switch (status) {
case "pending":
return (
<Badge variant="secondary" className="bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">
<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">
<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">
<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>
@@ -107,15 +123,21 @@ function formatCustomOffer(intention: PurchaseIntention): string | null {
}
}
export function MyAdsIntentionList({ listingId, sellerId }: MyAdsIntentionListProps) {
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");
const [selectedIntention, setSelectedIntention] =
useState<PurchaseIntention | null>(null);
const [filter, setFilter] = useState<
"all" | "pending" | "accepted" | "rejected"
>("all");
useEffect(() => {
loadIntentions();
}, [listingId, filter]);
}, [loadIntentions]);
const loadIntentions = async () => {
setLoading(true);
@@ -124,7 +146,9 @@ export function MyAdsIntentionList({ listingId, sellerId }: MyAdsIntentionListPr
if (filter !== "all") {
params.set("status", filter);
}
const res = await fetch(`/api/listings/${listingId}/intentions?${params.toString()}`);
const res = await fetch(
`/api/listings/${listingId}/intentions?${params.toString()}`,
);
if (res.ok) {
const data = await res.json();
if (data.success) {
@@ -216,19 +240,26 @@ export function MyAdsIntentionList({ listingId, sellerId }: MyAdsIntentionListPr
</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>
{getPaymentMethodLabel(intention.payment_method)}
</span>
<span>•</span>
<Clock className="h-3 w-3" />
<span>{formatTimeAgo(new Date(intention.created_at))}</span>
</div>
{formatCustomOffer(intention) && (
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline" className="border-primary text-primary">
<Badge
variant="outline"
className="border-primary text-primary"
>
Oferta: {formatCustomOffer(intention)}
</Badge>
{intention.custom_offer_created_at && (
<span className="text-[10px] text-muted-foreground">
{new Date(intention.custom_offer_created_at).toLocaleString("pt-BR")}
{new Date(
intention.custom_offer_created_at,
).toLocaleString("pt-BR")}
</span>
)}
</div>
@@ -261,4 +292,3 @@ export function MyAdsIntentionList({ listingId, sellerId }: MyAdsIntentionListPr
</div>
);
}
+73 -67
View File
@@ -1,39 +1,43 @@
"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,
ChevronDown,
Eye,
Heart,
TrendingUp,
ChevronDown,
Pause,
Play,
ShoppingCart,
Trash2,
TrendingUp,
} from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useMyListings } from "@/lib/hooks/use-listings";
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());
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
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
@@ -145,9 +149,7 @@ export function MyAdsPanel() {
</div>
) : listings.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground">
Nenhum anúncio encontrado
</p>
<p className="text-muted-foreground">Nenhum anúncio encontrado</p>
</div>
) : (
<div className="space-y-4">
@@ -180,21 +182,25 @@ export function MyAdsPanel() {
<span>
{(() => {
if (listing.price_pix) {
const price = typeof listing.price_pix === 'string'
? parseFloat(listing.price_pix)
: 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;
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;
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";
@@ -236,42 +242,43 @@ export function MyAdsPanel() {
</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}
{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" : ""
}`}
/>
</div>
)}
</div>
)}
</Button>
{isExpanded && (
<div className="mt-4">
<MyAdsIntentionList
listingId={listing.listing_id}
sellerId={listing.seller_id}
/>
</div>
)}
</div>
)}
</div>
);
})}
@@ -282,4 +289,3 @@ export function MyAdsPanel() {
</div>
);
}
@@ -1,31 +1,28 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
Clock,
CheckCircle2,
XCircle,
MessageSquare,
ShoppingCart,
MapPin,
Package,
User,
ExternalLink,
AlertCircle,
DollarSign,
CheckCircle2,
Clock,
Coins,
DollarSign,
ExternalLink,
MapPin,
MessageSquare,
Package,
RefreshCw,
ShoppingCart,
User,
XCircle,
} from "lucide-react";
import { useMyPurchaseIntentions, type PurchaseIntention, type PurchaseIntentionStatus } from "@/lib/hooks/use-purchase-intentions";
import { useSession } from "@/lib/hooks/use-session";
import Link from "next/link";
import Image from "next/image";
import { cn } from "@/lib/utils";
import Link from "next/link";
import { useState } from "react";
import { ChatModal } from "@/components/chat/ChatModal";
import { ChangePaymentMethodButton } from "@/components/purchase-intentions/ChangePaymentMethodButton";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
Dialog,
DialogContent,
@@ -33,14 +30,22 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ChatModal } from "@/components/chat/ChatModal";
import { Skeleton } from "@/components/ui/skeleton";
import {
type PurchaseIntention,
type PurchaseIntentionStatus,
useMyPurchaseIntentions,
} from "@/lib/hooks/use-purchase-intentions";
import { useSession } from "@/lib/hooks/use-session";
import { cn } from "@/lib/utils";
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);
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"}`;
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);
@@ -55,35 +60,50 @@ function getStatusBadge(status: PurchaseIntentionStatus) {
switch (status) {
case "pending":
return (
<Badge variant="secondary" className="bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">
<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">
<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">
<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>
);
case "expired":
return (
<Badge variant="secondary" className="bg-gray-500/20 text-gray-700 dark:text-gray-400">
<Badge
variant="secondary"
className="bg-gray-500/20 text-gray-700 dark:text-gray-400"
>
<AlertCircle className="h-3 w-3 mr-1" />
Expirada
</Badge>
);
case "completed":
return (
<Badge variant="secondary" className="bg-blue-500/20 text-blue-700 dark:text-blue-400">
<Badge
variant="secondary"
className="bg-blue-500/20 text-blue-700 dark:text-blue-400"
>
<CheckCircle2 className="h-3 w-3 mr-1" />
Concluída
</Badge>
@@ -110,18 +130,18 @@ function getPaymentMethodLabel(method: string): string {
function formatPrice(price: number | null, method: string): string {
if (price === null) return "N/A";
if (method === "pix") {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(price);
}
if (method === "tibia_coins") {
return `${price.toLocaleString("pt-BR")} TC`;
}
if (method === "gold_coins") {
const gold = price / 1000000;
if (gold >= 1) {
@@ -129,11 +149,14 @@ function formatPrice(price: number | null, method: string): string {
}
return `${price.toLocaleString("pt-BR")} gp`;
}
return price.toLocaleString("pt-BR");
}
function formatCustomOffer(amount: number | null | undefined, currency: string | null | undefined): string | null {
function formatCustomOffer(
amount: number | null | undefined,
currency: string | null | undefined,
): string | null {
if (amount === null || amount === undefined || !currency) {
return null;
}
@@ -171,7 +194,9 @@ function IntentionCard({ intention, locale = "pt" }: IntentionCardProps) {
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-lg">Anúncio não disponível</h3>
<h3 className="font-semibold text-lg">
Anúncio não disponível
</h3>
{getStatusBadge(intention.status)}
</div>
<p className="text-sm text-muted-foreground">
@@ -203,7 +228,9 @@ function IntentionCard({ intention, locale = "pt" }: IntentionCardProps) {
return `/api/images?path=${encodeURIComponent(`data/images/${category}/${normalizedPath}`)}`;
}
if (listing.item_id_unified) {
const params = new URLSearchParams({ item: listing.item_id_unified.toString() });
const params = new URLSearchParams({
item: listing.item_id_unified.toString(),
});
if (listing.item_name) {
params.set("name", listing.item_name);
}
@@ -223,24 +250,27 @@ function IntentionCard({ intention, locale = "pt" }: IntentionCardProps) {
? `${listing.listing_id}_${intention.buyer_id}_${listing.seller_id}`
: null);
const price = listing.price_pix && intention.payment_method === "pix"
? listing.price_pix
: listing.price_tibia_coins && intention.payment_method === "tibia_coins"
? listing.price_tibia_coins
: listing.price_gold_coins && intention.payment_method === "gold_coins"
? listing.price_gold_coins
: null;
const price =
listing.price_pix && intention.payment_method === "pix"
? listing.price_pix
: listing.price_tibia_coins && intention.payment_method === "tibia_coins"
? listing.price_tibia_coins
: listing.price_gold_coins && intention.payment_method === "gold_coins"
? listing.price_gold_coins
: null;
const customOfferLabel = formatCustomOffer(
typeof intention.custom_offer_amount === "number" ? intention.custom_offer_amount : null,
intention.custom_offer_currency ?? null
typeof intention.custom_offer_amount === "number"
? intention.custom_offer_amount
: null,
intention.custom_offer_currency ?? null,
);
return (
<>
<Card className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4 flex-1 min-w-0">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4 flex-1 min-w-0">
<div className="relative w-16 h-16 rounded-lg bg-muted flex items-center justify-center flex-shrink-0 overflow-hidden">
{itemImageUrl ? (
<Image
@@ -254,160 +284,172 @@ function IntentionCard({ intention, locale = "pt" }: IntentionCardProps) {
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-lg truncate">{listing.item_name}</h3>
{getStatusBadge(intention.status)}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-2">
<div className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
<span>{listing.server_name}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-lg truncate">
{listing.item_name}
</h3>
{getStatusBadge(intention.status)}
</div>
<div className="flex items-center gap-1">
<Package className="h-3 w-3" />
<span>Qtd: {listing.quantity}</span>
</div>
<div className="flex items-center gap-1">
<ShoppingCart className="h-3 w-3" />
<span>{getPaymentMethodLabel(intention.payment_method)}</span>
{price !== null && (
<span className="font-medium text-foreground ml-1">
{formatPrice(price, intention.payment_method)}
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-2">
<div className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
<span>{listing.server_name}</span>
</div>
<div className="flex items-center gap-1">
<Package className="h-3 w-3" />
<span>Qtd: {listing.quantity}</span>
</div>
<div className="flex items-center gap-1">
<ShoppingCart className="h-3 w-3" />
<span>
{getPaymentMethodLabel(intention.payment_method)}
</span>
{price !== null && (
<span className="font-medium text-foreground ml-1">
{formatPrice(price, intention.payment_method)}
</span>
)}
</div>
{customOfferLabel && (
<div className="flex items-center gap-1 text-foreground">
<Badge
variant="outline"
className="border-primary text-primary"
>
Sua oferta: {customOfferLabel}
</Badge>
</div>
)}
</div>
{customOfferLabel && (
<div className="flex items-center gap-1 text-foreground">
<Badge variant="outline" className="border-primary text-primary">
Sua oferta: {customOfferLabel}
</Badge>
</div>
)}
</div>
</div>
</div>
</div>
</CardHeader>
</CardHeader>
<CardContent className="space-y-3">
{/* Informações do vendedor */}
<div className="flex items-center gap-2 text-sm">
<User className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Vendedor:</span>
<span className="font-medium">
{listing.seller_character_name || listing.seller_username || "Desconhecido"}
</span>
</div>
<CardContent className="space-y-3">
{/* Informações do vendedor */}
<div className="flex items-center gap-2 text-sm">
<User className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Vendedor:</span>
<span className="font-medium">
{listing.seller_character_name ||
listing.seller_username ||
"Desconhecido"}
</span>
</div>
{/* Mensagem do comprador */}
{intention.buyer_message && (
<div className="bg-muted/50 rounded-md p-3">
<div className="flex items-start gap-2">
<MessageSquare className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground mb-1">Sua mensagem:</p>
<p className="text-sm">{intention.buyer_message}</p>
{/* Mensagem do comprador */}
{intention.buyer_message && (
<div className="bg-muted/50 rounded-md p-3">
<div className="flex items-start gap-2">
<MessageSquare className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground mb-1">
Sua mensagem:
</p>
<p className="text-sm">{intention.buyer_message}</p>
</div>
</div>
</div>
</div>
)}
)}
{/* Resposta do vendedor */}
{intention.seller_response && (
<div
className={cn(
"rounded-md p-3 border",
intention.status === "accepted"
? "bg-green-500/10 border-green-500/20"
: intention.status === "rejected"
? "bg-red-500/10 border-red-500/20"
: "bg-blue-500/10 border-blue-500/20"
{/* Resposta do vendedor */}
{intention.seller_response && (
<div
className={cn(
"rounded-md p-3 border",
intention.status === "accepted"
? "bg-green-500/10 border-green-500/20"
: intention.status === "rejected"
? "bg-red-500/10 border-red-500/20"
: "bg-blue-500/10 border-blue-500/20",
)}
>
<div className="flex items-start gap-2">
<MessageSquare
className={cn(
"h-4 w-4 mt-0.5 flex-shrink-0",
intention.status === "accepted"
? "text-green-600 dark:text-green-400"
: intention.status === "rejected"
? "text-red-600 dark:text-red-400"
: "text-blue-600 dark:text-blue-400",
)}
/>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium mb-1">
{intention.status === "accepted"
? "Resposta do vendedor (Aceita):"
: intention.status === "rejected"
? "Resposta do vendedor (Rejeitada):"
: "Resposta do vendedor:"}
</p>
<p className="text-sm">{intention.seller_response}</p>
</div>
</div>
</div>
)}
{/* Timestamps */}
<div className="flex items-center justify-between text-xs text-muted-foreground pt-2 border-t">
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>Criada {formatTimeAgo(intention.created_at)}</span>
</div>
{intention.updated_at !== intention.created_at && (
<span>Atualizada {formatTimeAgo(intention.updated_at)}</span>
)}
>
<div className="flex items-start gap-2">
<MessageSquare
className={cn(
"h-4 w-4 mt-0.5 flex-shrink-0",
intention.status === "accepted"
? "text-green-600 dark:text-green-400"
: intention.status === "rejected"
? "text-red-600 dark:text-red-400"
: "text-blue-600 dark:text-blue-400"
)}
/>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium mb-1">
{intention.status === "accepted"
? "Resposta do vendedor (Aceita):"
: intention.status === "rejected"
? "Resposta do vendedor (Rejeitada):"
: "Resposta do vendedor:"}
</p>
<p className="text-sm">{intention.seller_response}</p>
</div>
</div>
</div>
)}
{/* Timestamps */}
<div className="flex items-center justify-between text-xs text-muted-foreground pt-2 border-t">
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>Criada {formatTimeAgo(intention.created_at)}</span>
</div>
{intention.updated_at !== intention.created_at && (
<span>Atualizada {formatTimeAgo(intention.updated_at)}</span>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-2 flex-wrap">
{intention.status === "pending" &&
intention.payment_method_change_count < 1 && (
<ChangePaymentMethodButton
intentionId={intention.intention_id}
currentPaymentMethod={intention.payment_method}
availableMethods={[
...(listing.price_pix ? ["pix"] : []),
...(listing.price_tibia_coins ? ["tibia_coins"] : []),
...(listing.price_gold_coins ? ["gold_coins"] : []),
...(listing.trade_item_id ? ["trade"] : []),
]}
canChange={true}
/>
)}
<Button
variant="outline"
size="sm"
onClick={() => setShowListingModal(true)}
className="flex-1"
>
<ExternalLink className="h-4 w-4 mr-2" />
Ver Anúncio
</Button>
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => setShowChatModal(true)}
>
<MessageSquare className="h-4 w-4 mr-2" />
Abrir Chat
</Button>
{intention.status === "accepted" && listing.listing_status === "active" && (
{/* Actions */}
<div className="flex items-center gap-2 pt-2 flex-wrap">
{intention.status === "pending" &&
intention.payment_method_change_count < 1 && (
<ChangePaymentMethodButton
intentionId={intention.intention_id}
currentPaymentMethod={intention.payment_method}
availableMethods={[
...(listing.price_pix ? ["pix"] : []),
...(listing.price_tibia_coins ? ["tibia_coins"] : []),
...(listing.price_gold_coins ? ["gold_coins"] : []),
...(listing.trade_item_id ? ["trade"] : []),
]}
canChange={true}
/>
)}
<Button
variant="outline"
size="sm"
onClick={() => setShowListingModal(true)}
className="flex-1"
>
<ShoppingCart className="h-4 w-4 mr-2" />
Comprar Agora
<ExternalLink className="h-4 w-4 mr-2" />
Ver Anúncio
</Button>
)}
</div>
</CardContent>
</Card>
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => setShowChatModal(true)}
>
<MessageSquare className="h-4 w-4 mr-2" />
Abrir Chat
</Button>
{intention.status === "accepted" &&
listing.listing_status === "active" && (
<Button
size="sm"
onClick={() => setShowListingModal(true)}
className="flex-1"
>
<ShoppingCart className="h-4 w-4 mr-2" />
Comprar Agora
</Button>
)}
</div>
</CardContent>
</Card>
<Dialog open={showListingModal} onOpenChange={setShowListingModal}>
<DialogContent className="max-w-2xl">
<DialogHeader>
@@ -434,30 +476,48 @@ function IntentionCard({ intention, locale = "pt" }: IntentionCardProps) {
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold">{listing.item_name}</h3>
<p className="text-sm text-muted-foreground mt-1">{listing.description ?? "Sem descrição fornecida para este anúncio."}</p>
<p className="text-sm text-muted-foreground mt-1">
{listing.description ??
"Sem descrição fornecida para este anúncio."}
</p>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<MapPin className="h-4 w-4" />
<span>Servidor: <span className="font-medium text-foreground">{listing.server_name}</span></span>
<span>
Servidor:{" "}
<span className="font-medium text-foreground">
{listing.server_name}
</span>
</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Package className="h-4 w-4" />
<span>Quantidade: <span className="font-medium text-foreground">{listing.quantity}</span></span>
<span>
Quantidade:{" "}
<span className="font-medium text-foreground">
{listing.quantity}
</span>
</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<User className="h-4 w-4" />
<span>Vendedor:{" "}
<span>
Vendedor:{" "}
<span className="font-medium text-foreground">
{listing.seller_character_name || listing.seller_username || "Desconhecido"}
{listing.seller_character_name ||
listing.seller_username ||
"Desconhecido"}
</span>
</span>
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">Condições de pagamento</h4>
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Condições de pagamento
</h4>
<div className="flex flex-wrap gap-2">
{listing.price_pix && (
<Badge variant="secondary" className="gap-1">
@@ -477,18 +537,23 @@ function IntentionCard({ intention, locale = "pt" }: IntentionCardProps) {
{formatPrice(listing.price_gold_coins, "gold_coins")}
</Badge>
)}
{listing.trade_item_id && listing.trade_item_name && listing.trade_item_quantity && (
<Badge variant="secondary" className="gap-1">
<RefreshCw className="h-3 w-3" />
{listing.trade_item_quantity}x {listing.trade_item_name}
</Badge>
)}
{listing.trade_item_id &&
listing.trade_item_name &&
listing.trade_item_quantity && (
<Badge variant="secondary" className="gap-1">
<RefreshCw className="h-3 w-3" />
{listing.trade_item_quantity}x {listing.trade_item_name}
</Badge>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" asChild>
<Link href={`/${locale}/market?listing=${listing.listing_id}`} target="_blank">
<Link
href={`/${locale}/market?listing=${listing.listing_id}`}
target="_blank"
>
Abrir no marketplace
</Link>
</Button>
@@ -521,17 +586,25 @@ interface MyPurchaseIntentionsPanelProps {
locale?: string;
}
export function MyPurchaseIntentionsPanel({ locale = "pt" }: MyPurchaseIntentionsPanelProps) {
export function MyPurchaseIntentionsPanel({
locale = "pt",
}: MyPurchaseIntentionsPanelProps) {
const { user } = useSession();
const [filter, setFilter] = useState<PurchaseIntentionStatus | "all">("all");
const { intentions, isLoading } = useMyPurchaseIntentions();
// Contar por status
const pendingCount = intentions.filter((i) => i.status === "pending").length;
const acceptedCount = intentions.filter((i) => i.status === "accepted").length;
const rejectedCount = intentions.filter((i) => i.status === "rejected").length;
const acceptedCount = intentions.filter(
(i) => i.status === "accepted",
).length;
const rejectedCount = intentions.filter(
(i) => i.status === "rejected",
).length;
const expiredCount = intentions.filter((i) => i.status === "expired").length;
const completedCount = intentions.filter((i) => i.status === "completed").length;
const completedCount = intentions.filter(
(i) => i.status === "completed",
).length;
const filteredIntentions =
filter === "all"
? intentions
@@ -540,7 +613,9 @@ export function MyPurchaseIntentionsPanel({ locale = "pt" }: MyPurchaseIntention
if (!user) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">Faça login para ver suas intenções de compra</p>
<p className="text-muted-foreground">
Faça login para ver suas intenções de compra
</p>
</div>
);
}
@@ -614,7 +689,9 @@ export function MyPurchaseIntentionsPanel({ locale = "pt" }: MyPurchaseIntention
) : filteredIntentions.length === 0 ? (
<div className="text-center py-12 border rounded-lg">
<ShoppingCart className="h-12 w-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<p className="text-lg font-medium mb-2">Nenhuma intenção de compra encontrada</p>
<p className="text-lg font-medium mb-2">
Nenhuma intenção de compra encontrada
</p>
<p className="text-sm text-muted-foreground mb-4">
{filter === "all"
? "Quando você manifestar interesse em comprar um item, ele aparecerá aqui."
@@ -627,11 +704,14 @@ export function MyPurchaseIntentionsPanel({ locale = "pt" }: MyPurchaseIntention
) : (
<div className="space-y-4">
{filteredIntentions.map((intention) => (
<IntentionCard key={intention.intention_id} intention={intention} locale={locale} />
<IntentionCard
key={intention.intention_id}
intention={intention}
locale={locale}
/>
))}
</div>
)}
</div>
);
}
+89 -42
View File
@@ -1,6 +1,9 @@
"use client";
import { Coins, DollarSign, Loader2, RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -8,13 +11,10 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
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";
import { Input } from "@/components/ui/input";
interface Listing {
listing_id: number;
@@ -37,7 +37,12 @@ interface PurchaseModalProps {
onSuccess?: () => void;
}
export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: PurchaseModalProps) {
export function PurchaseModal({
open,
onOpenChange,
listing,
onSuccess,
}: PurchaseModalProps) {
const { user } = useSession();
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -53,7 +58,10 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
}, [open]);
useEffect(() => {
if (selectedMethod && !["pix", "tibia_coins", "gold_coins"].includes(selectedMethod)) {
if (
selectedMethod &&
!["pix", "tibia_coins", "gold_coins"].includes(selectedMethod)
) {
setCustomOfferValue("");
}
}, [selectedMethod]);
@@ -61,13 +69,19 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
if (!listing) return null;
// Determinar métodos de pagamento disponíveis
const availableMethods: Array<{ type: string; label: string; value: string; icon: any }> = [];
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)) {
const price =
typeof listing.price_pix === "string"
? parseFloat(listing.price_pix)
: listing.price_pix;
if (!Number.isNaN(price)) {
availableMethods.push({
type: "pix",
label: "PIX",
@@ -78,10 +92,11 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
}
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)) {
const price =
typeof listing.price_tibia_coins === "string"
? parseInt(listing.price_tibia_coins, 10)
: listing.price_tibia_coins;
if (!Number.isNaN(price)) {
availableMethods.push({
type: "tibia_coins",
label: "Tibia Coins",
@@ -92,10 +107,11 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
}
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)) {
const price =
typeof listing.price_gold_coins === "string"
? parseInt(listing.price_gold_coins, 10)
: listing.price_gold_coins;
if (!Number.isNaN(price)) {
availableMethods.push({
type: "gold_coins",
label: "Gold Coins",
@@ -105,7 +121,11 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
}
}
if (listing.trade_item_id && listing.trade_item_name && listing.trade_item_quantity) {
if (
listing.trade_item_id &&
listing.trade_item_name &&
listing.trade_item_quantity
) {
availableMethods.push({
type: "trade",
label: "Troca",
@@ -114,7 +134,10 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
});
}
const formatOffer = (amount: number | string | null, currency: string | null) => {
const formatOffer = (
amount: number | string | null,
currency: string | null,
) => {
if (amount === null || amount === undefined || !currency) {
return null;
}
@@ -136,7 +159,7 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
const highestOfferLabel = formatOffer(
listing.highest_custom_offer_amount ?? null,
listing.highest_custom_offer_currency ?? null
listing.highest_custom_offer_currency ?? null,
);
// Se houver apenas um método, selecionar automaticamente
@@ -167,20 +190,28 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
payment_method: selectedMethod,
};
if (hasCustomOffer && ["pix", "tibia_coins", "gold_coins"].includes(selectedMethod)) {
if (
hasCustomOffer &&
["pix", "tibia_coins", "gold_coins"].includes(selectedMethod)
) {
const offerNumber = Number(customOfferValue);
payload.custom_offer_amount =
selectedMethod === "pix" ? Number(offerNumber.toFixed(2)) : Math.floor(offerNumber);
selectedMethod === "pix"
? Number(offerNumber.toFixed(2))
: Math.floor(offerNumber);
payload.custom_offer_currency = selectedMethod;
}
const response = await fetch(`/api/listings/${listing.listing_id}/purchase`, {
method: "POST",
headers: {
"Content-Type": "application/json",
const response = await fetch(
`/api/listings/${listing.listing_id}/purchase`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
},
body: JSON.stringify(payload),
});
);
const data = await response.json();
@@ -236,10 +267,16 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Valores anunciados pelo vendedor</p>
<p className="text-sm font-medium">
Valores anunciados pelo vendedor
</p>
<div className="flex flex-wrap gap-2">
{availableMethods.map((method) => (
<Badge key={`original-${method.type}`} variant="secondary" className="gap-1">
<Badge
key={`original-${method.type}`}
variant="secondary"
className="gap-1"
>
{method.label}: {method.value}
</Badge>
))}
@@ -287,15 +324,21 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
) : 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={`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>
<span className="font-medium">
{availableMethods[0].label}
</span>
</div>
<p className="text-sm opacity-90">{availableMethods[0].value}</p>
<p className="text-sm opacity-90">
{availableMethods[0].value}
</p>
</div>
</div>
) : (
@@ -308,7 +351,8 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
["pix", "tibia_coins", "gold_coins"].includes(selectedMethod) && (
<div className="space-y-2">
<Label className="text-sm font-medium">
Propor valor personalizado ({selectedMethod === "pix" ? "R$" : "quantidade"})
Propor valor personalizado (
{selectedMethod === "pix" ? "R$" : "quantidade"})
</Label>
<Input
type="number"
@@ -316,11 +360,13 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
step={selectedMethod === "pix" ? "0.01" : "1"}
value={customOfferValue}
onChange={(event) => setCustomOfferValue(event.target.value)}
placeholder={selectedMethod === "pix" ? "Ex.: 199.90" : "Ex.: 1200"}
placeholder={
selectedMethod === "pix" ? "Ex.: 199.90" : "Ex.: 1200"
}
/>
<p className="text-xs text-muted-foreground">
Esse valor será enviado ao vendedor como sua proposta. Ele pode aceitar, negar ou
responder.
Esse valor será enviado ao vendedor como sua proposta. Ele
pode aceitar, negar ou responder.
</p>
</div>
)}
@@ -344,7 +390,9 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
</Button>
<Button
onClick={handlePurchase}
disabled={loading || !selectedMethod || availableMethods.length === 0}
disabled={
loading || !selectedMethod || availableMethods.length === 0
}
>
{loading ? (
<>
@@ -361,4 +409,3 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
</Dialog>
);
}
+6 -11
View File
@@ -1,9 +1,9 @@
"use client";
import { CheckCircle2, User } from "lucide-react";
import { useEffect } from "react";
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 {
@@ -27,14 +27,10 @@ export function SellerInfo({
// Tracking de visualização de vendedor
useEffect(() => {
trackCustomEvent(
"seller_profile_viewed",
listingId,
{
seller_id: sellerId,
seller_name: sellerName,
}
);
trackCustomEvent("seller_profile_viewed", listingId, {
seller_id: sellerId,
seller_name: sellerName,
});
}, [sellerId, sellerName, listingId, trackCustomEvent]);
// Gerar iniciais do nome
@@ -75,4 +71,3 @@ export function SellerInfo({
</div>
);
}