feat(market): add Minha Intenções tab with panel, hook, and badge

- Introduced "Minhas Intenções" tab visible only to logged-in users.
- Integrated MyPurchaseIntentionsPanel and useMyPurchaseIntentions hook.
- Added tab badge showing count of pending intentions.
- Extended session API to include user’s main character for downstream features.
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-07 17:25:51 -03:00
1 parent f35fee944d
commit 7f42def478
55 files changed
+4906 -95

No files matched your search

+9
View File
@@ -12,6 +12,7 @@ import { ThemeToggle } from "./theme-toggle";
import { LanguageSwitcher } from "./language-switcher";
import { MobileMenu } from "./mobile-menu";
import { NotificationBell } from "./notifications/NotificationBell";
import { MessagesBell } from "./messages/MessagesBell";
import { LoginDialog } from "./auth/login-dialog";
import { RegisterDialog } from "./auth/register-dialog";
import { Button } from "@/components/ui/button";
@@ -97,6 +98,9 @@ export default function Header() {
<ThemeToggle />
<LanguageSwitcher />
{/* Messages */}
{mounted && isAuthenticated && <MessagesBell />}
{/* Notifications */}
{mounted && isAuthenticated && <NotificationBell />}
@@ -123,6 +127,11 @@ export default function Header() {
<p className="text-sm font-medium leading-none">
{user.username}
</p>
{user.characterName && (
<p className="text-xs leading-none text-primary font-medium">
{user.characterName}
</p>
)}
<p className="text-xs leading-none text-muted-foreground">
{user.email}
</p>
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { useState } from "react";
import { Package, MapPin, DollarSign, Coins, User } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import Image from "next/image";
import type { Chat } from "@/lib/hooks/use-chat";
interface ChatHeaderProps {
chat: Chat;
currentUserId: number;
}
export function ChatHeader({ chat, currentUserId }: ChatHeaderProps) {
const listing = chat.listing_cache;
const otherUser =
chat.buyer_id === currentUserId ? chat.seller_cache : chat.buyer_cache;
const [imageError, setImageError] = useState(false);
const formatPrice = (price: number | null | undefined, type: "pix" | "tc" | "gp"): string => {
if (price === null || price === undefined) return "N/A";
if (type === "pix") {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(price);
}
if (type === "tc") {
return `${price.toLocaleString("pt-BR")} TC`;
}
if (type === "gp") {
const gold = price / 1000000;
if (gold >= 1) {
return `${gold.toFixed(2)}kk`;
}
return `${price.toLocaleString("pt-BR")} gp`;
}
return price.toLocaleString("pt-BR");
};
return (
<div className="border-b bg-muted/30 p-4 space-y-3">
{/* Informações do Anúncio */}
<div className="flex items-start gap-3">
{/* Imagem do item */}
<div className="relative w-16 h-16 rounded-lg bg-muted flex items-center justify-center flex-shrink-0 overflow-hidden">
{listing.item_image_url && !imageError ? (
<Image
src={listing.item_image_url}
alt={listing.item_name}
fill
className="object-cover"
onError={() => setImageError(true)}
/>
) : (
<Package className="h-8 w-8 text-muted-foreground" />
)}
</div>
{/* Detalhes do anúncio */}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg truncate">{listing.item_name}</h3>
<div className="flex items-center gap-3 text-sm text-muted-foreground mt-1">
<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: 1</span>
</div>
</div>
{/* Preços */}
<div className="flex items-center gap-2 mt-2 flex-wrap">
{listing.price_pix && (
<Badge variant="secondary" className="gap-1">
<DollarSign className="h-3 w-3" />
{formatPrice(listing.price_pix, "pix")}
</Badge>
)}
{listing.price_tibia_coins && (
<Badge variant="secondary" className="gap-1">
<Coins className="h-3 w-3" />
{formatPrice(listing.price_tibia_coins, "tc")}
</Badge>
)}
{listing.price_gold_coins && (
<Badge variant="secondary" className="gap-1">
<Coins className="h-3 w-3" />
{formatPrice(listing.price_gold_coins, "gp")}
</Badge>
)}
</div>
</div>
</div>
{/* Informações do outro usuário */}
<div className="flex items-center gap-2 pt-2 border-t">
<User className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Conversando com:{" "}
</span>
<span className="text-sm font-medium">
{otherUser.character_name || otherUser.username}
</span>
</div>
</div>
);
}
+147
View File
@@ -0,0 +1,147 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ChatHeader } from "./ChatHeader";
import { MessageList } from "./MessageList";
import { MessageInput } from "./MessageInput";
import { useChat } from "@/lib/hooks/use-chat";
import { useSession } from "@/lib/hooks/use-session";
import { Skeleton } from "@/components/ui/skeleton";
interface ChatModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
listingId?: number | null;
chatId?: string | null;
initialMessage?: string;
}
export function ChatModal({
open,
onOpenChange,
listingId,
chatId: initialChatId,
initialMessage,
}: ChatModalProps) {
const { user } = useSession();
const [chatId, setChatId] = useState<string | null>(initialChatId || null);
const [isCreatingChat, setIsCreatingChat] = useState(false);
const {
chat,
messages,
isLoading,
isSending,
sendMessage,
loadMoreMessages,
hasMore,
otherUserTyping,
} = useChat(chatId);
// Se chatId for fornecido diretamente, usar ele
useEffect(() => {
if (initialChatId) {
setChatId(initialChatId);
return;
}
}, [initialChatId]);
// Criar ou buscar chat quando modal abrir (apenas se não tiver chatId)
useEffect(() => {
if (!open || !user || !listingId || initialChatId) {
if (!initialChatId && !listingId) {
setChatId(null);
}
return;
}
const createOrGetChat = async () => {
setIsCreatingChat(true);
try {
const res = await fetch("/api/chats", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
listing_id: listingId,
buyer_message: initialMessage,
}),
});
if (!res.ok) {
throw new Error("Erro ao criar chat");
}
const data = await res.json();
setChatId(data.chat.chat_id);
// Se houver mensagem inicial, já foi enviada no backend
} catch (error) {
console.error("Erro ao criar/buscar chat:", error);
} finally {
setIsCreatingChat(false);
}
};
createOrGetChat();
}, [open, user, listingId, initialMessage, initialChatId]);
if (!user) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl h-[80vh] flex flex-col p-0 gap-0">
<DialogHeader className="sr-only">
<DialogTitle>Chat com vendedor</DialogTitle>
<DialogDescription>
Conversa sobre o anúncio
</DialogDescription>
</DialogHeader>
{isCreatingChat || (isLoading && !chat) ? (
<div className="flex-1 flex items-center justify-center">
<div className="space-y-4 w-full px-4">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-20 w-full" />
</div>
</div>
) : chat ? (
<>
<ChatHeader chat={chat} currentUserId={user.userId} />
<MessageList
messages={messages}
currentUserId={user.userId}
isLoading={isLoading}
onLoadMore={loadMoreMessages}
hasMore={hasMore}
otherUserTyping={otherUserTyping}
/>
<MessageInput
onSend={sendMessage}
isSending={isSending}
disabled={!chat.is_active}
chatId={chatId}
/>
</>
) : (
<div className="flex-1 flex items-center justify-center text-center text-muted-foreground">
<p>Erro ao carregar chat</p>
</div>
)}
</DialogContent>
</Dialog>
);
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { cn } from "@/lib/utils";
import type { ChatMessage } from "@/lib/hooks/use-chat";
interface MessageBubbleProps {
message: ChatMessage;
isOwn: boolean;
}
function formatTime(date: string): string {
const d = new Date(date);
return d.toLocaleTimeString("pt-BR", {
hour: "2-digit",
minute: "2-digit",
});
}
export function MessageBubble({ message, isOwn }: MessageBubbleProps) {
if (message.message_type === "system") {
return (
<div className="flex justify-center my-2">
<span className="text-xs text-muted-foreground bg-muted px-3 py-1 rounded-full">
{message.content}
</span>
</div>
);
}
return (
<div
className={cn(
"flex w-full mb-2",
isOwn ? "justify-end" : "justify-start"
)}
>
<div
className={cn(
"max-w-[70%] rounded-lg px-4 py-2",
isOwn
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground"
)}
>
<p className="text-sm whitespace-pre-wrap break-words">
{message.content}
</p>
<div
className={cn(
"flex items-center gap-1 mt-1 text-xs",
isOwn ? "text-primary-foreground/70" : "text-muted-foreground"
)}
>
<span>{formatTime(message.created_at)}</span>
{isOwn && message.read_at && (
<span className="opacity-70">✓✓</span>
)}
</div>
</div>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { useState, KeyboardEvent } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Send, Loader2 } from "lucide-react";
import { useChatTyping } from "@/lib/hooks/use-chat";
import { useSocket } from "@/lib/hooks/use-socket";
interface MessageInputProps {
onSend: (content: string) => Promise<void>;
isSending: boolean;
disabled?: boolean;
chatId: string | null;
}
export function MessageInput({
onSend,
isSending,
disabled = false,
chatId,
}: MessageInputProps) {
const [content, setContent] = useState("");
const { socket, isConnected } = useSocket();
const { handleTypingStart, handleTypingStop } = useChatTyping(
socket,
isConnected,
chatId
);
const handleSubmit = async () => {
if (!content.trim() || isSending || disabled) return;
const messageContent = content.trim();
setContent("");
handleTypingStop();
await onSend(messageContent);
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handleChange = (value: string) => {
setContent(value);
if (value.trim().length > 0) {
handleTypingStart();
} else {
handleTypingStop();
}
};
return (
<div className="border-t p-4">
<div className="flex items-end gap-2">
<Textarea
value={content}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Digite sua mensagem... (Enter para enviar, Shift+Enter para nova linha)"
className="min-h-[60px] max-h-[120px] resize-none"
disabled={isSending || disabled}
rows={2}
/>
<Button
onClick={handleSubmit}
disabled={!content.trim() || isSending || disabled}
size="icon"
className="h-[60px] w-[60px] flex-shrink-0"
>
{isSending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Send className="h-5 w-5" />
)}
</Button>
</div>
<p className="text-xs text-muted-foreground mt-2">
Máximo 1000 caracteres
</p>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { useEffect, useRef } from "react";
import { MessageBubble } from "./MessageBubble";
import { Skeleton } from "@/components/ui/skeleton";
import type { ChatMessage } from "@/lib/hooks/use-chat";
interface MessageListProps {
messages: ChatMessage[];
currentUserId: number;
isLoading: boolean;
onLoadMore?: () => void;
hasMore: boolean;
otherUserTyping: boolean;
}
export function MessageList({
messages,
currentUserId,
isLoading,
onLoadMore,
hasMore,
otherUserTyping,
}: MessageListProps) {
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const prevMessagesLengthRef = useRef(0);
// Scroll para última mensagem quando novas mensagens chegarem
useEffect(() => {
if (messages.length > prevMessagesLengthRef.current) {
// Nova mensagem adicionada
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
prevMessagesLengthRef.current = messages.length;
}, [messages.length]);
// Scroll inicial para o final
useEffect(() => {
if (messages.length > 0 && !isLoading) {
messagesEndRef.current?.scrollIntoView({ behavior: "auto" });
}
}, [isLoading]);
// Detectar scroll no topo para carregar mais
const handleScroll = () => {
const container = messagesContainerRef.current;
if (!container || !onLoadMore || !hasMore || isLoading) return;
if (container.scrollTop === 0) {
const previousScrollHeight = container.scrollHeight;
onLoadMore();
// Ajustar scroll após carregar mais mensagens
setTimeout(() => {
const newScrollHeight = container.scrollHeight;
container.scrollTop = newScrollHeight - previousScrollHeight;
}, 100);
}
};
return (
<div
ref={messagesContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto p-4 space-y-1"
>
{isLoading && messages.length === 0 ? (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : messages.length === 0 ? (
<div className="flex items-center justify-center h-full text-center text-muted-foreground">
<div>
<p className="text-sm">Nenhuma mensagem ainda</p>
<p className="text-xs mt-1">Envie a primeira mensagem para começar a conversa</p>
</div>
</div>
) : (
<>
{hasMore && (
<div className="text-center py-2">
<button
onClick={onLoadMore}
className="text-xs text-muted-foreground hover:text-foreground"
disabled={isLoading}
>
{isLoading ? "Carregando..." : "Carregar mensagens anteriores"}
</button>
</div>
)}
{messages.map((message) => (
<MessageBubble
key={message._id}
message={message}
isOwn={message.sender_id === currentUserId}
/>
))}
{otherUserTyping && (
<div className="flex justify-start mb-2">
<div className="bg-muted rounded-lg px-4 py-2">
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce" />
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce delay-75" />
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce delay-150" />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</>
)}
</div>
);
}
+55 -5
View File
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Heart, Clock, Eye, Star, MapPin, DollarSign, Coins, RefreshCw, ShoppingCart, TrendingUp, User } from "lucide-react";
import { Heart, Clock, Eye, Star, MapPin, DollarSign, Coins, RefreshCw, ShoppingCart, TrendingUp, User, MessageCircle } from "lucide-react";
import { useAdTracking, useMarketTelemetry } from "@/lib/hooks/use-telemetry";
import { useSession } from "@/lib/hooks/use-session";
import { useQueryClient } from "@tanstack/react-query";
@@ -15,6 +15,7 @@ import { BidModal } from "./BidModal";
import { SellerInfo } from "./SellerInfo";
import { ListingDetailsModal } from "./ListingDetailsModal";
import { AuctionCountdown } from "./AuctionCountdown";
import { ChatModal } from "@/components/chat/ChatModal";
// Função auxiliar para formatar tempo relativo
function formatTimeAgo(date: Date): string {
@@ -66,6 +67,7 @@ interface Listing {
item_category?: string | null;
// Campo do vendedor
seller_character_name?: string | null;
has_pending_intention_for_current_user?: boolean;
}
interface PriceInfo {
@@ -88,6 +90,7 @@ export function AdCard({ listing, viewMode = "grid" }: AdCardProps) {
const [showPurchaseModal, setShowPurchaseModal] = useState(false);
const [showBidModal, setShowBidModal] = useState(false);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [showChatModal, setShowChatModal] = useState(false);
const invalidateCache = () => {
queryClient.invalidateQueries({ queryKey: ["listings"] });
@@ -261,6 +264,7 @@ export function AdCard({ listing, viewMode = "grid" }: AdCardProps) {
const timeAgo = formatTimeAgo(new Date(listing.created_at));
const availablePrices = getAvailablePrices();
const isSold = listing.listing_status === "sold";
const hasPendingIntention = Boolean(listing.has_pending_intention_for_current_user);
// Função para obter URL da imagem do item
const getItemImageUrl = (): string => {
@@ -662,6 +666,20 @@ export function AdCard({ listing, viewMode = "grid" }: AdCardProps) {
Comprar
</Button>
)}
{user && user.userId !== listing.seller_id && !isSold && (
<Button
variant="outline"
size="sm"
className="transition-all duration-200 hover:scale-105"
onClick={(e) => {
e.stopPropagation();
setShowChatModal(true);
}}
>
<MessageCircle className="h-4 w-4 mr-2" />
Chat
</Button>
)}
</div>
)}
{isSold && (
@@ -932,15 +950,40 @@ export function AdCard({ listing, viewMode = "grid" }: AdCardProps) {
<Button
variant="default"
size="sm"
className={cn(
"flex-1 transition-all duration-200 hover:scale-105",
(isSold || hasPendingIntention) && "opacity-60 cursor-not-allowed hover:scale-100"
)}
onClick={(e) => {
e.stopPropagation();
if (hasPendingIntention) {
return;
}
setShowPurchaseModal(true);
}}
disabled={isSold || hasPendingIntention}
title={
hasPendingIntention
? "Você já enviou uma intenção para este anúncio. Gerencie em Minhas intenções."
: undefined
}
>
<ShoppingCart className="h-4 w-4 mr-2" />
{hasPendingIntention ? "Intenção pendente" : "Comprar"}
</Button>
)}
{user && user.userId !== listing.seller_id && !isSold && (
<Button
variant="outline"
size="sm"
className="flex-1 transition-all duration-200 hover:scale-105"
onClick={(e) => {
e.stopPropagation();
setShowPurchaseModal(true);
setShowChatModal(true);
}}
disabled={isSold}
>
<ShoppingCart className="h-4 w-4 mr-2" />
Comprar
<MessageCircle className="h-4 w-4 mr-2" />
Chat
</Button>
)}
</>
@@ -1021,6 +1064,13 @@ export function AdCard({ listing, viewMode = "grid" }: AdCardProps) {
onActivate={handleActivate}
onCancel={handleCancel}
/>
{user && user.userId !== listing.seller_id && (
<ChatModal
open={showChatModal}
onOpenChange={setShowChatModal}
listingId={listing.listing_id}
/>
)}
</Card>
);
}
@@ -0,0 +1,608 @@
"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,
Coins,
RefreshCw,
} 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 { ChangePaymentMethodButton } from "@/components/purchase-intentions/ChangePaymentMethodButton";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ChatModal } from "@/components/chat/ChatModal";
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: PurchaseIntentionStatus) {
switch (status) {
case "pending":
return (
<Badge variant="secondary" className="bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">
<Clock className="h-3 w-3 mr-1" />
Pendente
</Badge>
);
case "accepted":
return (
<Badge variant="secondary" className="bg-green-500/20 text-green-700 dark:text-green-400">
<CheckCircle2 className="h-3 w-3 mr-1" />
Aceita
</Badge>
);
case "rejected":
return (
<Badge variant="secondary" className="bg-red-500/20 text-red-700 dark:text-red-400">
<XCircle className="h-3 w-3 mr-1" />
Rejeitada
</Badge>
);
case "expired":
return (
<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">
<CheckCircle2 className="h-3 w-3 mr-1" />
Concluída
</Badge>
);
default:
return <Badge variant="secondary">{status}</Badge>;
}
}
function getPaymentMethodLabel(method: string): string {
switch (method) {
case "pix":
return "PIX";
case "tibia_coins":
return "Tibia Coins";
case "gold_coins":
return "Gold Coins";
case "trade":
return "Troca";
default:
return method.toUpperCase();
}
}
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) {
return `${gold.toFixed(2)}kk`;
}
return `${price.toLocaleString("pt-BR")} gp`;
}
return price.toLocaleString("pt-BR");
}
interface IntentionCardProps {
intention: PurchaseIntention;
locale?: string;
}
function IntentionCard({ intention, locale = "pt" }: IntentionCardProps) {
const listing = intention.listing;
const [showListingModal, setShowListingModal] = useState(false);
const [showChatModal, setShowChatModal] = useState(false);
if (!listing) {
return (
<Card className="border-dashed opacity-60">
<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">
<AlertCircle className="h-8 w-8 text-muted-foreground" />
</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>
{getStatusBadge(intention.status)}
</div>
<p className="text-sm text-muted-foreground">
Este anúncio foi removido ou não está mais disponível
</p>
</div>
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-xs text-muted-foreground">
Intenção criada {formatTimeAgo(intention.created_at)}
</div>
</CardContent>
</Card>
);
}
const getItemImageUrl = () => {
if (listing.image_url) {
return listing.image_url;
}
if (listing.image_local_path) {
const normalizedPath = listing.image_local_path.replace(/\\/g, "/");
if (normalizedPath.startsWith("data/")) {
return `/api/images?path=${encodeURIComponent(normalizedPath)}`;
}
const category = listing.item_category || "other";
return `/api/images?path=${encodeURIComponent(`data/images/${category}/${normalizedPath}`)}`;
}
if (listing.item_id_unified) {
const params = new URLSearchParams({ item: listing.item_id_unified.toString() });
if (listing.item_name) {
params.set("name", listing.item_name);
}
return `/api/images?${params.toString()}`;
}
const params = new URLSearchParams({ item: listing.item_id.toString() });
if (listing.item_name) {
params.set("name", listing.item_name);
}
return `/api/images?${params.toString()}`;
};
const itemImageUrl = getItemImageUrl();
const chatId =
intention.chat_id ??
(listing.seller_id != null
? `${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;
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">
<div className="relative w-16 h-16 rounded-lg bg-muted flex items-center justify-center flex-shrink-0 overflow-hidden">
{itemImageUrl ? (
<Image
src={itemImageUrl}
alt={listing.item_name}
fill
className="object-cover"
/>
) : (
<Package className="h-8 w-8 text-muted-foreground" />
)}
</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>
<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>
</div>
</div>
</div>
</div>
</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>
{/* 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>
)}
{/* 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>
{/* 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" && (
<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>
<DialogTitle>{listing.item_name}</DialogTitle>
<DialogDescription>
Detalhes do anúncio selecionado
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 md:grid-cols-[220px,1fr] gap-4">
<div className="relative w-full h-52 rounded-lg bg-muted overflow-hidden flex items-center justify-center">
{itemImageUrl ? (
<Image
src={itemImageUrl}
alt={listing.item_name}
fill
className="object-contain p-4"
/>
) : (
<Package className="h-12 w-12 text-muted-foreground" />
)}
</div>
<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>
</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>
</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>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<User className="h-4 w-4" />
<span>Vendedor:{" "}
<span className="font-medium text-foreground">
{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>
<div className="flex flex-wrap gap-2">
{listing.price_pix && (
<Badge variant="secondary" className="gap-1">
<DollarSign className="h-3 w-3" />
{formatPrice(listing.price_pix, "pix")}
</Badge>
)}
{listing.price_tibia_coins && (
<Badge variant="secondary" className="gap-1">
<Coins className="h-3 w-3" />
{formatPrice(listing.price_tibia_coins, "tibia_coins")}
</Badge>
)}
{listing.price_gold_coins && (
<Badge variant="secondary" className="gap-1">
<Coins className="h-3 w-3" />
{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>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" asChild>
<Link href={`/${locale}/market?listing=${listing.listing_id}`} target="_blank">
Abrir no marketplace
</Link>
</Button>
<Button
variant="default"
onClick={() => setShowChatModal(true)}
>
<MessageSquare className="h-4 w-4 mr-2" />
Continuar conversa
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
{listing && (
<ChatModal
open={showChatModal}
onOpenChange={setShowChatModal}
listingId={listing.listing_id}
chatId={chatId || undefined}
/>
)}
</>
);
}
interface MyPurchaseIntentionsPanelProps {
locale?: string;
}
export function MyPurchaseIntentionsPanel({ locale = "pt" }: MyPurchaseIntentionsPanelProps) {
const { user } = useSession();
const [filter, setFilter] = useState<PurchaseIntentionStatus | "all">("all");
const { intentions, isLoading, refetch } = useMyPurchaseIntentions(
filter === "all" ? undefined : filter
);
// 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 expiredCount = intentions.filter((i) => i.status === "expired").length;
const completedCount = intentions.filter((i) => i.status === "completed").length;
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>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold mb-2">Minhas Intenções de Compra</h2>
<p className="text-muted-foreground">
Acompanhe suas intenções de compra e respostas dos vendedores
</p>
</div>
{/* Filtros */}
<div className="flex items-center gap-2 flex-wrap">
<Button
variant={filter === "all" ? "default" : "outline"}
size="sm"
onClick={() => setFilter("all")}
>
Todas ({intentions.length})
</Button>
<Button
variant={filter === "pending" ? "default" : "outline"}
size="sm"
onClick={() => setFilter("pending")}
>
Pendentes ({pendingCount})
</Button>
<Button
variant={filter === "accepted" ? "default" : "outline"}
size="sm"
onClick={() => setFilter("accepted")}
>
Aceitas ({acceptedCount})
</Button>
<Button
variant={filter === "rejected" ? "default" : "outline"}
size="sm"
onClick={() => setFilter("rejected")}
>
Rejeitadas ({rejectedCount})
</Button>
{expiredCount > 0 && (
<Button
variant={filter === "expired" ? "default" : "outline"}
size="sm"
onClick={() => setFilter("expired")}
>
Expiradas ({expiredCount})
</Button>
)}
{completedCount > 0 && (
<Button
variant={filter === "completed" ? "default" : "outline"}
size="sm"
onClick={() => setFilter("completed")}
>
Concluídas ({completedCount})
</Button>
)}
</div>
{/* Lista de intenções */}
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-48 w-full" />
))}
</div>
) : intentions.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-sm text-muted-foreground mb-4">
{filter === "all"
? "Quando você manifestar interesse em comprar um item, ele aparecerá aqui."
: `Não há intenções com status "${filter}" no momento.`}
</p>
<Button asChild variant="outline">
<Link href={`/${locale}/market`}>Explorar Marketplace</Link>
</Button>
</div>
) : (
<div className="space-y-4">
{intentions.map((intention) => (
<IntentionCard key={intention.intention_id} intention={intention} locale={locale} />
))}
</div>
)}
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Package, Clock } from "lucide-react";
import Image from "next/image";
import type { Chat } from "@/lib/hooks/use-chat";
import { formatTimeAgo } from "@/lib/utils";
interface ChatListItemProps {
chat: Chat;
currentUserId: number;
onChatClick: () => void;
}
export function ChatListItem({
chat,
currentUserId,
onChatClick,
}: ChatListItemProps) {
const otherUser =
chat.buyer_id === currentUserId
? chat.seller_cache
: chat.buyer_cache;
const unreadCount =
chat.buyer_id === currentUserId
? chat.buyer_unread_count
: chat.seller_unread_count;
const listing = chat.listing_cache;
return (
<Card
className="p-4 cursor-pointer hover:bg-accent transition-colors"
onClick={onChatClick}
>
<div className="flex items-start gap-3">
{/* Avatar do outro usuário */}
<Avatar className="h-12 w-12 flex-shrink-0">
<AvatarFallback>
{(otherUser.character_name || otherUser.username)
.charAt(0)
.toUpperCase()}
</AvatarFallback>
</Avatar>
{/* Conteúdo */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<h4 className="font-semibold text-sm truncate">
{otherUser.character_name || otherUser.username}
</h4>
{unreadCount > 0 && (
<Badge variant="destructive" className="h-5 min-w-5 px-1.5 text-xs">
{unreadCount}
</Badge>
)}
</div>
{/* Preview do item */}
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<Package className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{listing.item_name}</span>
</div>
{/* Preview da última mensagem */}
{chat.last_message_preview && (
<p className="text-xs text-muted-foreground truncate mb-1">
{chat.last_message_preview}
</p>
)}
{/* Timestamp */}
{chat.last_message_at && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<span>{formatTimeAgo(new Date(chat.last_message_at))}</span>
</div>
)}
</div>
{/* Imagem do item (opcional) */}
{listing.item_image_url && (
<div className="relative w-12 h-12 rounded-lg bg-muted flex-shrink-0 overflow-hidden">
<Image
src={listing.item_image_url}
alt={listing.item_name}
fill
className="object-cover"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
</div>
)}
</div>
</Card>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useState, useEffect } from "react";
import { MessageCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useSession } from "@/lib/hooks/use-session";
import { MessagesPanel } from "./MessagesPanel";
interface MessagesBellProps {
className?: string;
}
export function MessagesBell({ className }: MessagesBellProps) {
const { user } = useSession();
const [unreadUsersCount, setUnreadUsersCount] = useState(0);
const [isOpen, setIsOpen] = useState(false);
// Buscar contagem de usuários únicos com mensagens não vistas
const fetchUnreadCount = async () => {
if (!user) return;
try {
const res = await fetch("/api/chats/unread-count", {
credentials: "include",
});
if (res.ok) {
const data = await res.json();
if (data.success) {
setUnreadUsersCount(data.count);
}
}
} catch (error) {
console.error("[CHAT-DEBUG] Erro ao buscar contagem de chats:", error);
}
};
// Polling automático a cada 30 segundos
useEffect(() => {
if (!user) return;
fetchUnreadCount();
const interval = setInterval(() => {
fetchUnreadCount();
}, 30000); // 30 segundos
return () => clearInterval(interval);
}, [user]);
// Atualizar contagem quando abrir/fechar
useEffect(() => {
if (isOpen) {
fetchUnreadCount();
}
}, [isOpen]);
// Marcar como visto quando abrir
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
};
if (!user) {
return null;
}
return (
<>
<Button
variant="ghost"
size="icon"
className={`relative ${className}`}
onClick={() => handleOpenChange(true)}
>
<MessageCircle className="h-5 w-5" />
{unreadUsersCount > 0 && (
<Badge
variant="destructive"
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs rounded-full animate-pulse"
>
{unreadUsersCount > 99 ? "99+" : unreadUsersCount}
</Badge>
)}
</Button>
<MessagesPanel
open={isOpen}
onOpenChange={handleOpenChange}
onUnreadCountChange={setUnreadUsersCount}
/>
</>
);
}
+173
View File
@@ -0,0 +1,173 @@
"use client";
import { useState, useEffect } from "react";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { ChatListItem } from "./ChatListItem";
import { Skeleton } from "@/components/ui/skeleton";
import { useSession } from "@/lib/hooks/use-session";
import { ChatModal } from "@/components/chat/ChatModal";
import type { Chat } from "@/lib/hooks/use-chat";
interface MessagesPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onUnreadCountChange: (count: number) => void;
}
export function MessagesPanel({
open,
onOpenChange,
onUnreadCountChange,
}: MessagesPanelProps) {
const { user } = useSession();
const [chats, setChats] = useState<Chat[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [selectedChatId, setSelectedChatId] = useState<string | null>(null);
const [showChatModal, setShowChatModal] = useState(false);
// Buscar chats do usuário
const fetchChats = async () => {
if (!user) return;
setIsLoading(true);
try {
const res = await fetch("/api/chats", {
credentials: "include",
});
if (res.ok) {
const data = await res.json();
if (data.success) {
setChats(data.chats || []);
// Calcular contagem de usuários únicos com não lidas
const uniqueUsersWithUnread = new Set<number>();
data.chats.forEach((chat: Chat) => {
const unreadCount =
chat.buyer_id === user.userId
? chat.buyer_unread_count
: chat.seller_unread_count;
const otherUserId =
chat.buyer_id === user.userId
? chat.seller_id
: chat.buyer_id;
if (unreadCount > 0) {
uniqueUsersWithUnread.add(otherUserId);
}
});
onUnreadCountChange(uniqueUsersWithUnread.size);
}
}
} catch (error) {
console.error("[CHAT-DEBUG] Erro ao buscar chats:", error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (open && user) {
fetchChats();
// Marcar todos como lidos ao abrir
markAllAsRead();
}
}, [open, user]);
const markAllAsRead = async () => {
try {
await fetch("/api/chats/mark-all-read", {
method: "POST",
credentials: "include",
});
// Atualizar estado local
setChats((prev) =>
prev.map((chat) => ({
...chat,
buyer_unread_count:
chat.buyer_id === user?.userId ? 0 : chat.buyer_unread_count,
seller_unread_count:
chat.seller_id === user?.userId ? 0 : chat.seller_unread_count,
}))
);
onUnreadCountChange(0);
} catch (error) {
console.error("[CHAT-DEBUG] Erro ao marcar como lido:", error);
}
};
const handleChatClick = (chatId: string, listingId: number) => {
setSelectedChatId(chatId);
setShowChatModal(true);
onOpenChange(false);
};
if (!user) {
return null;
}
return (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-lg p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b">
<SheetTitle>Mensagens</SheetTitle>
<SheetDescription>
Suas conversas com compradores e vendedores
</SheetDescription>
</SheetHeader>
<div className="mt-4 px-4 overflow-y-auto" style={{ maxHeight: "calc(100vh - 120px)" }}>
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
) : chats.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p>Nenhuma conversa ainda</p>
<p className="text-sm mt-2">
Comece uma conversa a partir de um anúncio
</p>
</div>
) : (
<div className="space-y-2">
{chats.map((chat) => (
<ChatListItem
key={chat.chat_id}
chat={chat}
currentUserId={user.userId}
onChatClick={() => handleChatClick(chat.chat_id, chat.listing_id)}
/>
))}
</div>
)}
</div>
</SheetContent>
</Sheet>
{selectedChatId && (
<ChatModal
open={showChatModal}
onOpenChange={(open) => {
setShowChatModal(open);
if (!open) {
setSelectedChatId(null);
// Recarregar chats quando fechar o modal
fetchChats();
}
}}
listingId={null}
chatId={selectedChatId || undefined}
/>
)}
</>
);
}
@@ -0,0 +1,181 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { RefreshCw, Loader2 } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
interface ChangePaymentMethodButtonProps {
intentionId: number;
currentPaymentMethod: string;
availableMethods: string[];
canChange: boolean;
}
function getPaymentMethodLabel(method: string): string {
switch (method) {
case "pix":
return "PIX";
case "tibia_coins":
return "Tibia Coins";
case "gold_coins":
return "Gold Coins";
case "trade":
return "Troca";
default:
return method.toUpperCase();
}
}
export function ChangePaymentMethodButton({
intentionId,
currentPaymentMethod,
availableMethods,
canChange,
}: ChangePaymentMethodButtonProps) {
const [open, setOpen] = useState(false);
const [selectedMethod, setSelectedMethod] = useState<string>(currentPaymentMethod);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const queryClient = useQueryClient();
// Filtrar métodos disponíveis (excluir o atual)
const availableOptions = availableMethods.filter(
(method) => method !== currentPaymentMethod
);
const handleChange = async () => {
if (selectedMethod === currentPaymentMethod) {
setError("Selecione um método de pagamento diferente do atual");
return;
}
setLoading(true);
setError(null);
try {
const res = await fetch(
`/api/purchase-intentions/${intentionId}/payment-method`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ payment_method: selectedMethod }),
}
);
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.error || "Erro ao trocar método de pagamento");
}
// Invalidar cache para atualizar a lista
queryClient.invalidateQueries({ queryKey: ["my-purchase-intentions"] });
queryClient.invalidateQueries({ queryKey: ["purchase-intentions"] });
queryClient.invalidateQueries({ queryKey: ["listings"] });
setOpen(false);
// Opcional: mostrar toast de sucesso
} catch (err) {
setError(err instanceof Error ? err.message : "Erro ao trocar método de pagamento");
} finally {
setLoading(false);
}
};
if (!canChange || availableOptions.length === 0) {
return null;
}
return (
<>
<Button
variant="outline"
size="sm"
onClick={() => {
setOpen(true);
setSelectedMethod(currentPaymentMethod);
setError(null);
}}
className="gap-2"
>
<RefreshCw className="h-4 w-4" />
Trocar Método
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Trocar Método de Pagamento</DialogTitle>
<DialogDescription>
Você pode trocar o método de pagamento apenas uma vez. Escolha o novo método:
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Método Atual</Label>
<div className="text-sm text-muted-foreground p-2 bg-muted rounded-md">
{getPaymentMethodLabel(currentPaymentMethod)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="new-method">Novo Método de Pagamento</Label>
<Select value={selectedMethod} onValueChange={setSelectedMethod}>
<SelectTrigger id="new-method">
<SelectValue placeholder="Selecione um método" />
</SelectTrigger>
<SelectContent>
{availableOptions.map((method) => (
<SelectItem key={method} value={method}>
{getPaymentMethodLabel(method)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error && (
<div className="bg-destructive/10 text-destructive p-3 rounded-lg text-sm">
{error}
</div>
)}
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
Cancelar
</Button>
<Button onClick={handleChange} disabled={loading || selectedMethod === currentPaymentMethod}>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Trocando...
</>
) : (
"Confirmar Troca"
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}