diff --git a/app/api/listings/[id]/intentions/[intentionId]/route.ts b/app/api/listings/[id]/intentions/[intentionId]/route.ts index c77812b..cf9a059 100644 --- a/app/api/listings/[id]/intentions/[intentionId]/route.ts +++ b/app/api/listings/[id]/intentions/[intentionId]/route.ts @@ -7,6 +7,11 @@ import { updatePurchaseIntentionStatus, } from "@/lib/prisma/queries/purchase-intentions"; import { createNotification } from "@/lib/prisma/queries/notifications"; +import { connectMongoDB } from "@/lib/mongodb/connection"; +import { Chat } from "@/lib/mongodb/models/Chat"; +import { Message } from "@/lib/mongodb/models/Message"; +import { getUserById } from "@/lib/prisma/queries/users"; +import { getMainCharacter } from "@/lib/prisma/queries/characters"; /** * GET /api/listings/[id]/intentions/[intentionId] @@ -88,6 +93,10 @@ export async function PATCH( const body = await request.json(); const { status, seller_response } = body; + const sellerResponse = + typeof seller_response === "string" && seller_response.trim().length > 0 + ? seller_response + : undefined; if (!status || !["accepted", "rejected"].includes(status)) { return NextResponse.json( @@ -134,9 +143,17 @@ export async function PATCH( const updatedIntention = await updatePurchaseIntentionStatus( intentionId, status, - seller_response + sellerResponse ); + await sendSellerResponseToChat({ + listing, + intention, + status, + sellerResponse, + sellerId: authResult.userId, + }); + // Criar notificação para o comprador await createNotification({ user_id: intention.buyer_id, @@ -177,3 +194,94 @@ export async function PATCH( } } +async function sendSellerResponseToChat({ + listing, + intention, + status, + sellerResponse, + sellerId, +}: { + listing: Awaited>; + intention: Exclude>, null>; + status: "accepted" | "rejected"; + sellerResponse?: string; + sellerId: number; +}) { + try { + if (!listing) return; + + const contentBase = sellerResponse?.trim() + ? sellerResponse.trim() + : status === "accepted" + ? "O vendedor aceitou sua intenção de compra." + : "O vendedor rejeitou sua intenção de compra."; + + const content = `[Intenções] ${contentBase}`; + if (!content.trim()) return; + + const chatId = `${listing.listing_id}_${intention.buyer_id}_${listing.seller_id}`; + + await connectMongoDB(); + let chat = await Chat.findOne({ chat_id: chatId }); + + if (!chat) { + const [buyer, seller, buyerCharacter, sellerCharacter] = await Promise.all([ + getUserById(intention.buyer_id), + getUserById(listing.seller_id), + getMainCharacter(intention.buyer_id), + getMainCharacter(listing.seller_id), + ]); + + chat = new Chat({ + chat_id: chatId, + listing_id: listing.listing_id, + buyer_id: intention.buyer_id, + seller_id: listing.seller_id, + buyer_unread_count: 0, + seller_unread_count: 0, + is_active: listing.listing_status === "active", + listing_cache: { + item_name: listing.item_name, + item_image_url: null, + price_pix: listing.price_pix ?? null, + price_tibia_coins: listing.price_tibia_coins ?? null, + price_gold_coins: listing.price_gold_coins ?? null, + server_name: listing.server_name, + listing_status: listing.listing_status, + }, + buyer_cache: { + user_id: buyer?.user_id ?? intention.buyer_id, + username: buyer?.username ?? intention.buyer?.username ?? "Comprador", + character_name: buyerCharacter?.character_name || null, + }, + seller_cache: { + user_id: seller?.user_id ?? listing.seller_id, + username: seller?.username ?? "Vendedor", + character_name: sellerCharacter?.character_name || null, + }, + }); + + await chat.save(); + } + + const message = new Message({ + chat_id: chat.chat_id, + sender_id: sellerId, + content, + message_type: "text", + }); + await message.save(); + + chat.last_message_at = new Date(); + chat.last_message_preview = content.substring(0, 100); + chat.buyer_unread_count = (chat.buyer_unread_count || 0) + 1; + chat.is_active = listing.listing_status === "active"; + await chat.save(); + } catch (error) { + secureLogger.error("❌ Falha ao enviar resposta para o chat", { + error: error instanceof Error ? error.message : "Erro desconhecido", + stack: error instanceof Error ? error.stack : undefined, + }); + } +} + diff --git a/app/api/listings/[id]/intentions/route.ts b/app/api/listings/[id]/intentions/route.ts index d89c05f..7d4918e 100644 --- a/app/api/listings/[id]/intentions/route.ts +++ b/app/api/listings/[id]/intentions/route.ts @@ -34,7 +34,12 @@ export async function POST( } const body = await request.json(); - const { payment_method, buyer_message } = body; + const { + payment_method, + buyer_message, + custom_offer_amount, + custom_offer_currency, + } = body; if (!payment_method || !["pix", "tibia_coins", "gold_coins", "trade"].includes(payment_method)) { return NextResponse.json( @@ -94,6 +99,42 @@ export async function POST( ); } + let normalizedOfferValue: number | null = null; + let normalizedOfferCurrency: string | null = null; + + if (custom_offer_amount !== undefined && custom_offer_amount !== null) { + if (!["pix", "tibia_coins", "gold_coins"].includes(payment_method)) { + return NextResponse.json( + { + success: false, + error: "Ofertas personalizadas só são permitidas para métodos monetários (PIX, Tibia Coins ou Gold Coins).", + }, + { status: 400 } + ); + } + + const numericOffer = Number(custom_offer_amount); + if (Number.isNaN(numericOffer) || numericOffer <= 0) { + return NextResponse.json( + { + success: false, + error: "Valor da oferta personalizada deve ser numérico e maior que zero.", + }, + { status: 400 } + ); + } + + normalizedOfferCurrency = + custom_offer_currency && ["pix", "tibia_coins", "gold_coins"].includes(custom_offer_currency) + ? custom_offer_currency + : payment_method; + + normalizedOfferValue = + normalizedOfferCurrency === "pix" + ? Number(numericOffer.toFixed(2)) + : Math.floor(numericOffer); + } + // Verificar se já existe uma intenção pendente const existingIntention = await getPendingIntentionByBuyerAndListing(listingId, auth.userId); if (existingIntention) { @@ -130,6 +171,8 @@ export async function POST( buyer_id: auth.userId, payment_method: payment_method as any, buyer_message: buyer_message || undefined, + custom_offer_amount: normalizedOfferValue ?? undefined, + custom_offer_currency: normalizedOfferCurrency as any, }); // Criar notificação para o vendedor @@ -137,7 +180,10 @@ export async function POST( user_id: listing.seller_id, notification_type: "purchase", title: "Nova intenção de compra!", - message: `Alguém manifestou interesse em comprar seu item "${listing.item_name}" por ${payment_method.toUpperCase()}`, + message: + normalizedOfferValue && normalizedOfferCurrency + ? `Alguém fez uma oferta de ${normalizedOfferCurrency === "pix" ? `R$ ${normalizedOfferValue.toFixed(2)}` : `${normalizedOfferValue.toLocaleString()} ${normalizedOfferCurrency === "tibia_coins" ? "TC" : "GP"}`} pelo item "${listing.item_name}".` + : `Alguém manifestou interesse em comprar seu item "${listing.item_name}" por ${payment_method.toUpperCase()}`, related_id: listingId, send_whatsapp: false, }); @@ -152,6 +198,8 @@ export async function POST( listing_price_pix: listing.price_pix ? Number(listing.price_pix) : null, listing_price_tc: listing.price_tibia_coins || null, listing_price_gp: listing.price_gold_coins ? Number(listing.price_gold_coins) : null, + custom_offer_amount: normalizedOfferValue, + custom_offer_currency: normalizedOfferCurrency, }, }); diff --git a/app/api/listings/[id]/purchase/route.ts b/app/api/listings/[id]/purchase/route.ts index e708687..36e5fe8 100644 --- a/app/api/listings/[id]/purchase/route.ts +++ b/app/api/listings/[id]/purchase/route.ts @@ -33,7 +33,12 @@ export async function POST( } const body = await request.json(); - const { payment_method, buyer_message } = body; // 'pix' | 'tc' | 'gp' | 'trade' + const { + payment_method, + buyer_message, + custom_offer_amount, + custom_offer_currency, + } = body; if (!payment_method) { return NextResponse.json( @@ -98,6 +103,42 @@ export async function POST( ); } + let normalizedOfferValue: number | null = null; + let normalizedOfferCurrency: string | null = null; + + if (custom_offer_amount !== undefined && custom_offer_amount !== null) { + if (!["pix", "tibia_coins", "gold_coins"].includes(normalizedMethod)) { + return NextResponse.json( + { + success: false, + error: "Ofertas personalizadas só são permitidas para métodos monetários (PIX, Tibia Coins ou Gold Coins).", + }, + { status: 400 } + ); + } + + const numericOffer = Number(custom_offer_amount); + if (Number.isNaN(numericOffer) || numericOffer <= 0) { + return NextResponse.json( + { + success: false, + error: "Valor da oferta personalizada deve ser numérico e maior que zero.", + }, + { status: 400 } + ); + } + + normalizedOfferCurrency = + custom_offer_currency && ["pix", "tibia_coins", "gold_coins"].includes(custom_offer_currency) + ? custom_offer_currency + : normalizedMethod; + + normalizedOfferValue = + normalizedOfferCurrency === "pix" + ? Number(numericOffer.toFixed(2)) + : Math.floor(numericOffer); + } + // Verificar se já existe uma intenção pendente const existingIntention = await getPendingIntentionByBuyerAndListing(listingId, auth.userId); if (existingIntention) { @@ -134,6 +175,8 @@ export async function POST( buyer_id: auth.userId, payment_method: normalizedMethod as any, buyer_message: buyer_message || undefined, + custom_offer_amount: normalizedOfferValue ?? undefined, + custom_offer_currency: normalizedOfferCurrency as any, }); // Criar notificação para o vendedor @@ -141,7 +184,10 @@ export async function POST( user_id: listing.seller_id, notification_type: "purchase", title: "Nova intenção de compra!", - message: `Alguém manifestou interesse em comprar seu item "${listing.item_name}" por ${normalizedMethod.toUpperCase()}`, + message: + normalizedOfferValue && normalizedOfferCurrency + ? `Alguém fez uma oferta de ${normalizedOfferCurrency === "pix" ? `R$ ${normalizedOfferValue.toFixed(2)}` : `${normalizedOfferValue.toLocaleString()} ${normalizedOfferCurrency === "tibia_coins" ? "TC" : "GP"}`} pelo item "${listing.item_name}".` + : `Alguém manifestou interesse em comprar seu item "${listing.item_name}" por ${normalizedMethod.toUpperCase()}.`, related_id: listingId, send_whatsapp: false, }); @@ -156,6 +202,8 @@ export async function POST( listing_price_pix: listing.price_pix ? Number(listing.price_pix) : null, listing_price_tc: listing.price_tibia_coins || null, listing_price_gp: listing.price_gold_coins ? Number(listing.price_gold_coins) : null, + custom_offer_amount: normalizedOfferValue, + custom_offer_currency: normalizedOfferCurrency, }, }); diff --git a/components/admin/AdminSubscriptionTab.tsx b/components/admin/AdminSubscriptionTab.tsx new file mode 100644 index 0000000..8caaba9 --- /dev/null +++ b/components/admin/AdminSubscriptionTab.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { AdminSubscriptionDashboard } from "@/components/admin/subscription-dashboard"; +import { Card, CardContent } from "@/components/ui/card"; + +interface AdminSubscriptionTabProps { + initialTiers: any[]; + adminUser: { + subscription_tier?: { + name: string; + slug: string; + } | null; + } | null; +} + +export function AdminSubscriptionTab({ + initialTiers, + adminUser, +}: AdminSubscriptionTabProps) { + return ( +
+ + +
+

+ Assinaturas & Permissões +

+

+ Configure planos, recursos e vincule usuários às assinaturas + disponíveis. Todas as alterações são aplicadas imediatamente + após salvar. +

+ {adminUser?.subscription_tier && ( +

+ Seu plano atual:{" "} + + {adminUser.subscription_tier.name} ( + {adminUser.subscription_tier.slug}) + +

+ )} +
+
+
+ + +
+ ); +} + diff --git a/components/marketplace/AdCard.tsx b/components/marketplace/AdCard.tsx index 9eaee25..222131e 100644 --- a/components/marketplace/AdCard.tsx +++ b/components/marketplace/AdCard.tsx @@ -68,10 +68,12 @@ interface Listing { // Campo do vendedor seller_character_name?: string | null; has_pending_intention_for_current_user?: boolean; + highest_custom_offer_amount?: number | string | null; + highest_custom_offer_currency?: string | null; } interface PriceInfo { - type: "pix" | "tc" | "gp" | "trade"; + type: "pix" | "tibia_coins" | "gold_coins" | "trade"; label: string; value: string; } @@ -227,7 +229,7 @@ export function AdCard({ listing, viewMode = "grid" }: AdCardProps) { : listing.price_tibia_coins; if (!isNaN(price)) { prices.push({ - type: "tc", + type: "tibia_coins", label: "TC", value: `${price.toLocaleString()} TC`, }); @@ -241,7 +243,7 @@ export function AdCard({ listing, viewMode = "grid" }: AdCardProps) { : listing.price_gold_coins; if (!isNaN(price)) { prices.push({ - type: "gp", + type: "gold_coins", label: "GP", value: `${price.toLocaleString()} GP`, }); @@ -266,6 +268,35 @@ export function AdCard({ listing, viewMode = "grid" }: AdCardProps) { const isSold = listing.listing_status === "sold"; const hasPendingIntention = Boolean(listing.has_pending_intention_for_current_user); + const getHighestOfferLabel = () => { + if ( + listing.highest_custom_offer_amount === null || + listing.highest_custom_offer_amount === undefined || + !listing.highest_custom_offer_currency + ) { + return null; + } + const amount = + typeof listing.highest_custom_offer_amount === "string" + ? Number(listing.highest_custom_offer_amount) + : listing.highest_custom_offer_amount; + if (Number.isNaN(amount)) { + return null; + } + switch (listing.highest_custom_offer_currency) { + case "pix": + return `Maior oferta: R$ ${amount.toFixed(2)}`; + case "tibia_coins": + return `Maior oferta: ${Math.round(amount).toLocaleString()} TC`; + case "gold_coins": + return `Maior oferta: ${Math.round(amount).toLocaleString()} GP`; + default: + return `Maior oferta: ${amount}`; + } + }; + + const highestOfferLabel = getHighestOfferLabel(); + // Função para obter URL da imagem do item const getItemImageUrl = (): string => { // Prioridade 1: image_url (URL externa) se disponível @@ -327,9 +358,9 @@ export function AdCard({ listing, viewMode = "grid" }: AdCardProps) { switch (type) { case "pix": return "bg-green-100 hover:bg-green-200 dark:bg-green-900/30 dark:hover:bg-green-900/50 text-green-700 dark:text-green-300 border-green-300 dark:border-green-700"; - case "tc": + case "tibia_coins": return "bg-blue-100 hover:bg-blue-200 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700"; - case "gp": + case "gold_coins": return "bg-amber-100 hover:bg-amber-200 dark:bg-amber-900/30 dark:hover:bg-amber-900/50 text-amber-700 dark:text-amber-300 border-amber-300 dark:border-amber-700"; case "trade": return "bg-purple-100 hover:bg-purple-200 dark:bg-purple-900/30 dark:hover:bg-purple-900/50 text-purple-700 dark:text-purple-300 border-purple-300 dark:border-purple-700"; @@ -342,8 +373,8 @@ export function AdCard({ listing, viewMode = "grid" }: AdCardProps) { switch (type) { case "pix": return ; - case "tc": - case "gp": + case "tibia_coins": + case "gold_coins": return ; case "trade": return ; @@ -554,7 +585,14 @@ export function AdCard({ listing, viewMode = "grid" }: AdCardProps) { listing.is_featured && "p-2 rounded-lg border-l-4 bg-yellow-500/5 border-yellow-500", "overflow-hidden" )}> - +
+ + {highestOfferLabel && ( +
+ {highestOfferLabel} +
+ )} +
)} @@ -846,7 +884,14 @@ export function AdCard({ listing, viewMode = "grid" }: AdCardProps) { listing.is_featured && "p-2 rounded-lg border-l-4 bg-yellow-500/5 border-yellow-500", "overflow-hidden" )}> - +
+ + {highestOfferLabel && ( +
+ {highestOfferLabel} +
+ )} +
)} diff --git a/components/marketplace/CreateAdModal.tsx b/components/marketplace/CreateAdModal.tsx index 2fab159..8d780c6 100644 --- a/components/marketplace/CreateAdModal.tsx +++ b/components/marketplace/CreateAdModal.tsx @@ -139,7 +139,31 @@ export function CreateAdModal({ open, onOpenChange, onSuccess }: CreateAdModalPr const data = await response.json(); if (!response.ok) { - setError(data.error || "Erro ao criar anúncio"); + if (data?.details?.length) { + const fieldLabels: Record = { + item_id: "Item", + item_id_unified: "Item", + item_name: "Nome do item", + quantity: "Quantidade", + server_name: "Servidor", + price_pix: "Preço PIX", + price_tibia_coins: "Preço Tibia Coins", + price_gold_coins: "Preço Gold Coins", + trade_item_id: "Item de troca", + trade_item_name: "Nome do item de troca", + trade_item_quantity: "Quantidade do item de troca", + 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"}`; + }); + + setError(validationMessages.join(" ")); + } else { + setError(data?.error || "Erro ao criar anúncio"); + } setLoading(false); return; } diff --git a/components/marketplace/IntentionDetailModal.tsx b/components/marketplace/IntentionDetailModal.tsx index 4179a02..633dc92 100644 --- a/components/marketplace/IntentionDetailModal.tsx +++ b/components/marketplace/IntentionDetailModal.tsx @@ -35,6 +35,9 @@ interface PurchaseIntention { buyer_message: string | null; seller_response: string | null; created_at: string; + custom_offer_amount: number | null; + custom_offer_currency: string | null; + custom_offer_created_at: string | null; buyer?: { user_id: number; username: string; @@ -93,6 +96,40 @@ function getStatusBadge(status: string) { } } +function formatCustomOffer(intention: PurchaseIntention): { value: string; date: string | null } | null { + if ( + intention.custom_offer_amount === null || + intention.custom_offer_amount === undefined || + !intention.custom_offer_currency + ) { + return null; + } + + const amount = intention.custom_offer_amount; + let value: string; + + switch (intention.custom_offer_currency) { + case "pix": + value = `R$ ${amount.toFixed(2)}`; + break; + case "tibia_coins": + value = `${Math.round(amount).toLocaleString("pt-BR")} TC`; + break; + case "gold_coins": + value = `${Math.round(amount).toLocaleString("pt-BR")} GP`; + break; + default: + value = amount.toLocaleString("pt-BR"); + } + + return { + value, + date: intention.custom_offer_created_at + ? new Date(intention.custom_offer_created_at).toLocaleString("pt-BR") + : null, + }; +} + export function IntentionDetailModal({ open, onOpenChange, @@ -126,6 +163,13 @@ export function IntentionDetailModal({ const isOwner = user && listing && user.userId === listing.seller_id; const canMarkAsSold = isOwner && listing && (listing.listing_status === "active" || listing.listing_status === "in_escrow"); const canAcceptOrReject = intention.status === "pending"; + const customOffer = formatCustomOffer(intention); + + useEffect(() => { + if (open) { + setSellerResponse(intention.seller_response ?? ""); + } + }, [open, intention.intention_id, intention.seller_response]); const handleAccept = async () => { setLoading(true); @@ -279,6 +323,18 @@ export function IntentionDetailModal({ + {customOffer && ( +
+ +
{customOffer.value}
+ {customOffer.date && ( +

Enviada em {customOffer.date}

+ )} +
+ )} + {/* Mensagem do comprador */} {intention.buyer_message && (
diff --git a/components/marketplace/ListingDetailsModal.tsx b/components/marketplace/ListingDetailsModal.tsx index facbaa6..4684ba1 100644 --- a/components/marketplace/ListingDetailsModal.tsx +++ b/components/marketplace/ListingDetailsModal.tsx @@ -61,6 +61,8 @@ interface Listing { image_url?: string | null; item_category?: string | null; seller_character_name?: string | null; + highest_custom_offer_amount?: number | string | null; + highest_custom_offer_currency?: string | null; } interface ListingDetailsModalProps { @@ -165,14 +167,14 @@ export function ListingDetailsModal({ 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: "tc", label: "Tibia Coins", value: `${price.toLocaleString()} TC` }); + 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: "gp", label: "Gold Coins", value: `${price.toLocaleString()} GP` }); + prices.push({ type: "gold_coins", label: "Gold Coins", value: `${price.toLocaleString()} GP` }); } } @@ -188,6 +190,32 @@ export function ListingDetailsModal({ }; const prices = getAvailablePrices(); + const highestOfferLabel = (() => { + if ( + listing.highest_custom_offer_amount === null || + listing.highest_custom_offer_amount === undefined || + !listing.highest_custom_offer_currency + ) { + return null; + } + const amount = + typeof listing.highest_custom_offer_amount === "string" + ? Number(listing.highest_custom_offer_amount) + : listing.highest_custom_offer_amount; + if (Number.isNaN(amount)) { + return null; + } + switch (listing.highest_custom_offer_currency) { + case "pix": + return `Maior oferta registrada: R$ ${amount.toFixed(2)}`; + case "tibia_coins": + return `Maior oferta registrada: ${Math.round(amount).toLocaleString()} TC`; + case "gold_coins": + return `Maior oferta registrada: ${Math.round(amount).toLocaleString()} GP`; + default: + return `Maior oferta registrada: ${amount}`; + } + })(); // Apenas o dono pode realizar ações const canPause = isOwner && listing.listing_status === "active"; const canActivate = isOwner && listing.listing_status === "cancelled"; @@ -314,9 +342,9 @@ export function ListingDetailsModal({ className={`p-3 rounded-lg border ${ price.type === "pix" ? "bg-green-500/10 border-green-500/20" - : price.type === "tc" + : price.type === "tibia_coins" ? "bg-blue-500/10 border-blue-500/20" - : price.type === "gp" + : price.type === "gold_coins" ? "bg-yellow-500/10 border-yellow-500/20" : "bg-purple-500/10 border-purple-500/20" }`} @@ -326,6 +354,11 @@ export function ListingDetailsModal({
))} + {highestOfferLabel && ( +
+ {highestOfferLabel} +
+ )} {/* Informações de leilão */} diff --git a/components/marketplace/MyAdsIntentionList.tsx b/components/marketplace/MyAdsIntentionList.tsx index d5a27ce..cd8990f 100644 --- a/components/marketplace/MyAdsIntentionList.tsx +++ b/components/marketplace/MyAdsIntentionList.tsx @@ -16,6 +16,9 @@ interface PurchaseIntention { buyer_message: string | null; seller_response: string | null; created_at: string; + custom_offer_amount: number | null; + custom_offer_currency: string | null; + custom_offer_created_at: string | null; buyer?: { user_id: number; username: string; @@ -82,6 +85,28 @@ function getPaymentMethodLabel(method: string): string { } } +function formatCustomOffer(intention: PurchaseIntention): string | null { + if ( + intention.custom_offer_amount === null || + intention.custom_offer_amount === undefined || + !intention.custom_offer_currency + ) { + return null; + } + + const amount = intention.custom_offer_amount; + switch (intention.custom_offer_currency) { + case "pix": + return `R$ ${amount.toFixed(2)}`; + case "tibia_coins": + return `${Math.round(amount).toLocaleString("pt-BR")} TC`; + case "gold_coins": + return `${Math.round(amount).toLocaleString("pt-BR")} GP`; + default: + return amount.toLocaleString("pt-BR"); + } +} + export function MyAdsIntentionList({ listingId, sellerId }: MyAdsIntentionListProps) { const [intentions, setIntentions] = useState([]); const [loading, setLoading] = useState(true); @@ -196,6 +221,18 @@ export function MyAdsIntentionList({ listingId, sellerId }: MyAdsIntentionListPr {formatTimeAgo(new Date(intention.created_at))} + {formatCustomOffer(intention) && ( +
+ + Oferta: {formatCustomOffer(intention)} + + {intention.custom_offer_created_at && ( + + {new Date(intention.custom_offer_created_at).toLocaleString("pt-BR")} + + )} +
+ )} {intention.buyer_message && (

diff --git a/components/marketplace/MyPurchaseIntentionsPanel.tsx b/components/marketplace/MyPurchaseIntentionsPanel.tsx index 52c5cc0..89793ee 100644 --- a/components/marketplace/MyPurchaseIntentionsPanel.tsx +++ b/components/marketplace/MyPurchaseIntentionsPanel.tsx @@ -133,6 +133,23 @@ function formatPrice(price: number | null, method: string): string { return price.toLocaleString("pt-BR"); } +function formatCustomOffer(amount: number | null | undefined, currency: string | null | undefined): string | null { + if (amount === null || amount === undefined || !currency) { + return null; + } + + switch (currency) { + case "pix": + return `R$ ${amount.toFixed(2)}`; + case "tibia_coins": + return `${Math.round(amount).toLocaleString("pt-BR")} TC`; + case "gold_coins": + return `${Math.round(amount).toLocaleString("pt-BR")} GP`; + default: + return amount.toLocaleString("pt-BR"); + } +} + interface IntentionCardProps { intention: PurchaseIntention; locale?: string; @@ -213,6 +230,10 @@ function IntentionCard({ intention, locale = "pt" }: IntentionCardProps) { : 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 + ); return ( <> @@ -256,6 +277,13 @@ function IntentionCard({ intention, locale = "pt" }: IntentionCardProps) { )} + {customOfferLabel && ( +

+ + Sua oferta: {customOfferLabel} + +
+ )} @@ -496,10 +524,7 @@ interface MyPurchaseIntentionsPanelProps { export function MyPurchaseIntentionsPanel({ locale = "pt" }: MyPurchaseIntentionsPanelProps) { const { user } = useSession(); const [filter, setFilter] = useState("all"); - - const { intentions, isLoading, refetch } = useMyPurchaseIntentions( - filter === "all" ? undefined : filter - ); + const { intentions, isLoading } = useMyPurchaseIntentions(); // Contar por status const pendingCount = intentions.filter((i) => i.status === "pending").length; @@ -507,6 +532,10 @@ export function MyPurchaseIntentionsPanel({ locale = "pt" }: MyPurchaseIntention 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 filteredIntentions = + filter === "all" + ? intentions + : intentions.filter((intention) => intention.status === filter); if (!user) { return ( @@ -582,7 +611,7 @@ export function MyPurchaseIntentionsPanel({ locale = "pt" }: MyPurchaseIntention ))} - ) : intentions.length === 0 ? ( + ) : filteredIntentions.length === 0 ? (

Nenhuma intenção de compra encontrada

@@ -597,7 +626,7 @@ export function MyPurchaseIntentionsPanel({ locale = "pt" }: MyPurchaseIntention
) : (
- {intentions.map((intention) => ( + {filteredIntentions.map((intention) => ( ))}
diff --git a/components/marketplace/PurchaseModal.tsx b/components/marketplace/PurchaseModal.tsx index 2d9add2..99dfa1d 100644 --- a/components/marketplace/PurchaseModal.tsx +++ b/components/marketplace/PurchaseModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Dialog, DialogContent, @@ -14,6 +14,7 @@ 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; @@ -25,6 +26,8 @@ interface Listing { trade_item_id?: number | null; trade_item_name?: string | null; trade_item_quantity?: number | null; + highest_custom_offer_amount?: number | string | null; + highest_custom_offer_currency?: string | null; } interface PurchaseModalProps { @@ -39,6 +42,21 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha const [selectedMethod, setSelectedMethod] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [customOfferValue, setCustomOfferValue] = useState(""); + + useEffect(() => { + if (!open) { + setSelectedMethod(null); + setCustomOfferValue(""); + setError(null); + } + }, [open]); + + useEffect(() => { + if (selectedMethod && !["pix", "tibia_coins", "gold_coins"].includes(selectedMethod)) { + setCustomOfferValue(""); + } + }, [selectedMethod]); if (!listing) return null; @@ -65,7 +83,7 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha : listing.price_tibia_coins; if (!isNaN(price)) { availableMethods.push({ - type: "tc", + type: "tibia_coins", label: "Tibia Coins", value: `${price.toLocaleString()} TC`, icon: Coins, @@ -79,7 +97,7 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha : listing.price_gold_coins; if (!isNaN(price)) { availableMethods.push({ - type: "gp", + type: "gold_coins", label: "Gold Coins", value: `${price.toLocaleString()} GP`, icon: Coins, @@ -96,6 +114,31 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha }); } + const formatOffer = (amount: number | string | null, currency: string | null) => { + if (amount === null || amount === undefined || !currency) { + return null; + } + const numeric = typeof amount === "string" ? Number(amount) : amount; + if (Number.isNaN(numeric)) { + return null; + } + switch (currency) { + case "pix": + return `R$ ${numeric.toFixed(2)}`; + case "tibia_coins": + return `${Math.round(numeric).toLocaleString()} TC`; + case "gold_coins": + return `${Math.round(numeric).toLocaleString()} GP`; + default: + return `${numeric}`; + } + }; + + const highestOfferLabel = formatOffer( + listing.highest_custom_offer_amount ?? null, + listing.highest_custom_offer_currency ?? null + ); + // Se houver apenas um método, selecionar automaticamente if (availableMethods.length === 1 && !selectedMethod) { setSelectedMethod(availableMethods[0].type); @@ -107,18 +150,36 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha return; } + const hasCustomOffer = customOfferValue.trim().length > 0; + if (hasCustomOffer) { + const offerNumber = Number(customOfferValue); + if (Number.isNaN(offerNumber) || offerNumber <= 0) { + setError("Informe um valor válido para a oferta personalizada."); + return; + } + } + setLoading(true); setError(null); try { + const payload: Record = { + payment_method: 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); + payload.custom_offer_currency = selectedMethod; + } + const response = await fetch(`/api/listings/${listing.listing_id}/purchase`, { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ - payment_method: selectedMethod, - }), + body: JSON.stringify(payload), }); const data = await response.json(); @@ -132,6 +193,7 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha onSuccess(); } onOpenChange(false); + setCustomOfferValue(""); } catch (err) { setError(err instanceof Error ? err.message : "Erro ao processar compra"); } finally { @@ -143,9 +205,9 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha switch (type) { case "pix": return "bg-green-500 hover:bg-green-600 text-white"; - case "tc": + case "tibia_coins": return "bg-blue-500 hover:bg-blue-600 text-white"; - case "gp": + case "gold_coins": return "bg-yellow-500 hover:bg-yellow-600 text-white"; case "trade": return "bg-purple-500 hover:bg-purple-600 text-white"; @@ -173,6 +235,24 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha

+
+

Valores anunciados pelo vendedor

+
+ {availableMethods.map((method) => ( + + {method.label}: {method.value} + + ))} +
+
+ + {highestOfferLabel && ( +
+ Maior oferta registrada:{" "} + {highestOfferLabel} +
+ )} + {/* Seleção de método de pagamento */} @@ -224,6 +304,27 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha

)} + {selectedMethod && + ["pix", "tibia_coins", "gold_coins"].includes(selectedMethod) && ( +
+ + setCustomOfferValue(event.target.value)} + placeholder={selectedMethod === "pix" ? "Ex.: 199.90" : "Ex.: 1200"} + /> +

+ Esse valor será enviado ao vendedor como sua proposta. Ele pode aceitar, negar ou + responder. +

+
+ )} + {error && (
{error} diff --git a/lib/auth/middleware.ts b/lib/auth/middleware.ts index 0337bb2..d3130f9 100644 --- a/lib/auth/middleware.ts +++ b/lib/auth/middleware.ts @@ -31,6 +31,8 @@ function extractToken(request: NextRequest): string | null { } /** + * Converte payload de token em objeto AuthenticatedUser. + */ function toAuthenticatedUser(payload: { userId: number; username: string; diff --git a/lib/db/migrations/020_purchase_intentions_custom_offer.sql b/lib/db/migrations/020_purchase_intentions_custom_offer.sql new file mode 100644 index 0000000..83e2d64 --- /dev/null +++ b/lib/db/migrations/020_purchase_intentions_custom_offer.sql @@ -0,0 +1,17 @@ +-- Migration 020: Custom offer fields for purchase intentions +-- Criado em: 2025-02-07 + +ALTER TABLE purchase_intentions + ADD COLUMN IF NOT EXISTS custom_offer_amount NUMERIC(12, 2), + ADD COLUMN IF NOT EXISTS custom_offer_currency VARCHAR(20), + ADD COLUMN IF NOT EXISTS custom_offer_created_at TIMESTAMP WITHOUT TIME ZONE; + +-- Atualizar registros existentes para evitar valores inconsistentes +UPDATE purchase_intentions +SET custom_offer_amount = NULL, + custom_offer_currency = NULL, + custom_offer_created_at = NULL +WHERE custom_offer_amount IS NOT NULL + OR custom_offer_currency IS NOT NULL + OR custom_offer_created_at IS NOT NULL; + diff --git a/lib/db/migrations/run-migrations.ts b/lib/db/migrations/run-migrations.ts index e0c3200..0953760 100644 --- a/lib/db/migrations/run-migrations.ts +++ b/lib/db/migrations/run-migrations.ts @@ -1,4 +1,4 @@ -/** +/** * Script de execução de migrations * Executa todas as migrations SQL em ordem */ @@ -39,6 +39,7 @@ const MIGRATIONS = [ "017_add_items_foreign_key.sql", "018_migrate_listings_to_unified_items.sql", "019_subscription_tiers.sql", + "020_purchase_intentions_custom_offer.sql", ]; async function runMigrations() { diff --git a/lib/hooks/use-purchase-intentions.ts b/lib/hooks/use-purchase-intentions.ts index ddf3662..294ad77 100644 --- a/lib/hooks/use-purchase-intentions.ts +++ b/lib/hooks/use-purchase-intentions.ts @@ -43,6 +43,9 @@ export interface PurchaseIntention { buyer_id: number; payment_method: string; payment_method_change_count: number; + custom_offer_amount: number | string | null; + custom_offer_currency: string | null; + custom_offer_created_at: Date | string | null; chat_id: string | null; status: PurchaseIntentionStatus; buyer_message: string | null; @@ -61,17 +64,12 @@ interface PurchaseIntentionsResponse { count: number; } -/** - * Hook para buscar intenções de compra do usuário autenticado - * Atualiza automaticamente a cada 30 segundos - */ -export function useMyPurchaseIntentions(status?: PurchaseIntentionStatus) { +export function useMyPurchaseIntentions() { const queryParams = new URLSearchParams(); - if (status) queryParams.set("status", status); - queryParams.set("limit", "100"); // Limite alto para mostrar todas + queryParams.set("limit", "100"); const { data, isLoading, error, refetch } = useQuery({ - queryKey: ["my-purchase-intentions", status], + queryKey: ["my-purchase-intentions"], queryFn: async () => { const res = await fetch(`/api/purchase-intentions/my?${queryParams.toString()}`); if (!res.ok) { @@ -89,8 +87,16 @@ export function useMyPurchaseIntentions(status?: PurchaseIntentionStatus) { enabled: true, // Sempre habilitado (autenticação é verificada no backend) }); + const normalizedIntentions = (data?.intentions || []).map((intention) => ({ + ...intention, + custom_offer_amount: + intention.custom_offer_amount !== null && intention.custom_offer_amount !== undefined + ? Number(intention.custom_offer_amount) + : null, + })); + return { - intentions: data?.intentions || [], + intentions: normalizedIntentions, count: data?.count || 0, isLoading, error, diff --git a/lib/prisma/queries/items.ts b/lib/prisma/queries/items.ts index 901f05f..9ff0083 100644 --- a/lib/prisma/queries/items.ts +++ b/lib/prisma/queries/items.ts @@ -1,4 +1,4 @@ -/** +/** * Queries de itens usando Prisma * Busca itens da tabela unificada items (PostgreSQL) */ diff --git a/lib/prisma/queries/listings.ts b/lib/prisma/queries/listings.ts index 3bb81c1..e91916c 100644 --- a/lib/prisma/queries/listings.ts +++ b/lib/prisma/queries/listings.ts @@ -5,6 +5,10 @@ import { prisma } from "../app"; import { Prisma } from "@prisma/client"; +import { + getHighestCustomOfferForListing, + getHighestCustomOffersForListings, +} from "./purchase-intentions"; // Re-exportar tipos para compatibilidade export type ListingStatus = "active" | "sold" | "cancelled" | "in_escrow" | "expired"; @@ -42,6 +46,8 @@ export interface Listing { initial_price_tibia_coins?: number | null; initial_price_gold_coins?: number | null; seller_character_name?: string | null; + highest_custom_offer_amount?: number | null; + highest_custom_offer_currency?: string | null; } export interface CreateListingData { @@ -141,6 +147,8 @@ export async function createListing( price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null, initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null, seller_character_name: character?.character_name || null, + highest_custom_offer_amount: null, + highest_custom_offer_currency: null, } as Listing; } @@ -165,6 +173,8 @@ export async function getListingById(listingId: number): Promise }, }); + const highestOffer = await getHighestCustomOfferForListing(listingId); + // Converter todos os campos Decimal e BigInt para números JavaScript return { ...listing, @@ -175,6 +185,8 @@ export async function getListingById(listingId: number): Promise price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null, initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null, seller_character_name: character?.character_name || null, + highest_custom_offer_amount: highestOffer?.amount ?? null, + highest_custom_offer_currency: highestOffer?.currency ?? null, } as Listing; } @@ -264,9 +276,14 @@ export async function getListings( } } + const highestOffers = await getHighestCustomOffersForListings( + listings.map((listing) => listing.listing_id) + ); + // Converter todos os campos Decimal e BigInt para números JavaScript const listingsWithCharacterNames = listings.map((listing) => { const key = `${listing.seller_id}:${listing.server_name}`; + const highestOffer = highestOffers.get(listing.listing_id); return { ...listing, price_pix: listing.price_pix ? Number(listing.price_pix) : null, @@ -276,6 +293,8 @@ export async function getListings( price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null, initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null, seller_character_name: charactersMap.get(key) || null, + highest_custom_offer_amount: highestOffer?.amount ?? null, + highest_custom_offer_currency: highestOffer?.currency ?? null, } as Listing; }); @@ -319,6 +338,8 @@ export async function updateListing( }, }); + const highestOffer = await getHighestCustomOfferForListing(listing.listing_id); + // Converter todos os campos Decimal e BigInt para números JavaScript return { ...listing, @@ -329,6 +350,8 @@ export async function updateListing( price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null, initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null, seller_character_name: character?.character_name || null, + highest_custom_offer_amount: highestOffer?.amount ?? null, + highest_custom_offer_currency: highestOffer?.currency ?? null, } as Listing; } @@ -486,9 +509,14 @@ export async function getActiveListings( } } + const highestOffers = await getHighestCustomOffersForListings( + listings.map((listing) => listing.listing_id) + ); + // Converter todos os campos Decimal e BigInt para números JavaScript const listingsWithCharacterNames = listings.map((listing) => { const key = `${listing.seller_id}:${listing.server_name}`; + const highestOffer = highestOffers.get(listing.listing_id); return { ...listing, price_pix: listing.price_pix ? Number(listing.price_pix) : null, @@ -498,6 +526,8 @@ export async function getActiveListings( price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null, initial_price_gold_coins: listing.initial_price_gold_coins ? Number(listing.initial_price_gold_coins) : null, seller_character_name: charactersMap.get(key) || null, + highest_custom_offer_amount: highestOffer?.amount ?? null, + highest_custom_offer_currency: highestOffer?.currency ?? null, } as Listing; }); @@ -580,6 +610,8 @@ export async function placeBid( price_gold_coins: updatedListing.price_gold_coins ? Number(updatedListing.price_gold_coins) : null, initial_price_gold_coins: updatedListing.initial_price_gold_coins ? Number(updatedListing.initial_price_gold_coins) : null, seller_character_name: character?.character_name || null, + highest_custom_offer_amount: null, + highest_custom_offer_currency: null, } as Listing, previousBidderId: previousBidderId || undefined, }; diff --git a/lib/prisma/queries/purchase-intentions.ts b/lib/prisma/queries/purchase-intentions.ts index 5913b75..39a559c 100644 --- a/lib/prisma/queries/purchase-intentions.ts +++ b/lib/prisma/queries/purchase-intentions.ts @@ -3,6 +3,7 @@ */ import { prisma } from "../app"; +import { Prisma } from "@prisma/client"; export type PurchaseIntentionStatus = "pending" | "accepted" | "rejected" | "expired" | "completed"; export type PaymentMethod = "pix" | "tibia_coins" | "gold_coins" | "trade"; @@ -13,6 +14,8 @@ export interface CreatePurchaseIntentionData { payment_method: PaymentMethod; buyer_message?: string; expires_at?: Date; + custom_offer_amount?: number | null; + custom_offer_currency?: PaymentMethod | null; } export interface PurchaseIntention { @@ -21,6 +24,9 @@ export interface PurchaseIntention { buyer_id: number; payment_method: string; payment_method_change_count: number; + custom_offer_amount: number | null; + custom_offer_currency: string | null; + custom_offer_created_at: Date | null; status: PurchaseIntentionStatus; buyer_message: string | null; seller_response: string | null; @@ -42,13 +48,27 @@ export interface PurchaseIntention { }; } +function serializePurchaseIntention(intention: any): PurchaseIntention { + if (!intention) return intention; + + return { + ...intention, + custom_offer_amount: + intention.custom_offer_amount !== null && intention.custom_offer_amount !== undefined + ? Number(intention.custom_offer_amount) + : null, + custom_offer_currency: intention.custom_offer_currency ?? null, + custom_offer_created_at: intention.custom_offer_created_at ?? null, + }; +} + /** * Cria uma nova intenção de compra */ export async function createPurchaseIntention( data: CreatePurchaseIntentionData ): Promise { - return await prisma.purchaseIntention.create({ + const result = await prisma.purchaseIntention.create({ data: { listing_id: data.listing_id, buyer_id: data.buyer_id, @@ -56,6 +76,15 @@ export async function createPurchaseIntention( buyer_message: data.buyer_message || null, expires_at: data.expires_at || null, status: "pending", + custom_offer_amount: + data.custom_offer_amount !== undefined && data.custom_offer_amount !== null + ? new Prisma.Decimal(data.custom_offer_amount) + : null, + custom_offer_currency: data.custom_offer_currency || null, + custom_offer_created_at: + data.custom_offer_amount !== undefined && data.custom_offer_amount !== null + ? new Date() + : null, }, include: { listing: { @@ -73,7 +102,9 @@ export async function createPurchaseIntention( }, }, }, - }) as PurchaseIntention; + }); + + return serializePurchaseIntention(result); } /** @@ -90,7 +121,7 @@ export async function getPurchaseIntentionsByListing( where.status = status; } - return await prisma.purchaseIntention.findMany({ + const intentions = await prisma.purchaseIntention.findMany({ where, include: { listing: { @@ -110,7 +141,9 @@ export async function getPurchaseIntentionsByListing( }, orderBy: { created_at: "desc" }, take: limit, - }) as PurchaseIntention[]; + }); + + return intentions.map(serializePurchaseIntention); } /** @@ -127,7 +160,7 @@ export async function getPurchaseIntentionsByBuyer( where.status = status; } - return await prisma.purchaseIntention.findMany({ + const intentions = await prisma.purchaseIntention.findMany({ where, include: { listing: { @@ -147,7 +180,9 @@ export async function getPurchaseIntentionsByBuyer( }, orderBy: { created_at: "desc" }, take: limit, - }) as PurchaseIntention[]; + }); + + return intentions.map(serializePurchaseIntention); } /** @@ -156,7 +191,7 @@ export async function getPurchaseIntentionsByBuyer( export async function getPurchaseIntentionById( intentionId: number ): Promise { - return await prisma.purchaseIntention.findUnique({ + const intention = await prisma.purchaseIntention.findUnique({ where: { intention_id: intentionId }, include: { listing: { @@ -174,7 +209,9 @@ export async function getPurchaseIntentionById( }, }, }, - }) as PurchaseIntention | null; + }); + + return serializePurchaseIntention(intention); } /** @@ -200,7 +237,7 @@ export async function updatePurchaseIntentionStatus( updateData.seller_response = sellerResponse; } - return await prisma.purchaseIntention.update({ + const updated = await prisma.purchaseIntention.update({ where: { intention_id: intentionId }, data: updateData, include: { @@ -219,7 +256,9 @@ export async function updatePurchaseIntentionStatus( }, }, }, - }) as PurchaseIntention; + }); + + return serializePurchaseIntention(updated); } /** @@ -259,7 +298,7 @@ export async function getPendingIntentionByBuyerAndListing( listingId: number, buyerId: number ): Promise { - return await prisma.purchaseIntention.findFirst({ + const intention = await prisma.purchaseIntention.findFirst({ where: { listing_id: listingId, buyer_id: buyerId, @@ -284,7 +323,9 @@ export async function getPendingIntentionByBuyerAndListing( orderBy: { created_at: "desc", }, - }) as PurchaseIntention | null; + }); + + return serializePurchaseIntention(intention); } /** @@ -314,7 +355,7 @@ export async function updatePurchaseIntentionPaymentMethod( } // Atualizar - return await prisma.purchaseIntention.update({ + const updated = await prisma.purchaseIntention.update({ where: { intention_id: intentionId }, data: { payment_method: newPaymentMethod, @@ -337,7 +378,9 @@ export async function updatePurchaseIntentionPaymentMethod( }, }, }, - }) as PurchaseIntention; + }); + + return serializePurchaseIntention(updated); } /** @@ -372,3 +415,68 @@ export async function getPendingIntentionListingIdsForBuyer( return Array.from(uniqueIds); } +export async function getHighestCustomOfferForListing( + listingId: number +): Promise<{ amount: number; currency: string } | null> { + const offer = await prisma.purchaseIntention.findFirst({ + where: { + listing_id: listingId, + status: { in: ["pending", "accepted"] }, + custom_offer_amount: { not: null }, + custom_offer_currency: { not: null }, + }, + orderBy: { custom_offer_amount: "desc" }, + select: { + custom_offer_amount: true, + custom_offer_currency: true, + }, + }); + + if (!offer) { + return null; + } + + return { + amount: Number(offer.custom_offer_amount), + currency: offer.custom_offer_currency!, + }; +} + +export async function getHighestCustomOffersForListings( + listingIds: number[] +): Promise> { + if (listingIds.length === 0) { + return new Map(); + } + + const offers = await prisma.purchaseIntention.findMany({ + where: { + listing_id: { in: listingIds }, + status: { in: ["pending", "accepted"] }, + custom_offer_amount: { not: null }, + custom_offer_currency: { not: null }, + }, + select: { + listing_id: true, + custom_offer_amount: true, + custom_offer_currency: true, + }, + orderBy: [ + { listing_id: "asc" }, + { custom_offer_amount: "desc" }, + ], + }); + + const result = new Map(); + for (const offer of offers) { + if (!result.has(offer.listing_id)) { + result.set(offer.listing_id, { + amount: Number(offer.custom_offer_amount), + currency: offer.custom_offer_currency!, + }); + } + } + + return result; +} + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7c9900d..300a21d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -346,6 +346,9 @@ model PurchaseIntention { buyer_id Int payment_method String @db.VarChar(20) payment_method_change_count Int @default(0) + custom_offer_amount Decimal? @db.Decimal(12, 2) + custom_offer_currency String? @db.VarChar(20) + custom_offer_created_at DateTime? status String @default("pending") @db.VarChar(20) buyer_message String? @db.Text seller_response String? @db.Text