From d8e7d5d7e6f4e3e25221104d3acec5e693f62e6c Mon Sep 17 00:00:00 2001
From: Evandro Fonseca Junior
Date: Sat, 8 Nov 2025 11:51:21 -0300
Subject: [PATCH] feat: enhance purchase intentions with chat integration and
offer tracking
- Added MongoDB chat/message integration for seller responses
- Improved seller response validation and default messaging
- Integrated automatic chat creation/update when responding to intentions
- Added highest custom offer fields to listing queries and models
- Enhanced listing data with top offer information for buyers
---
.../[id]/intentions/[intentionId]/route.ts | 110 +++++++++++++-
app/api/listings/[id]/intentions/route.ts | 52 ++++++-
app/api/listings/[id]/purchase/route.ts | 52 ++++++-
components/admin/AdminSubscriptionTab.tsx | 50 +++++++
components/marketplace/AdCard.tsx | 63 ++++++--
components/marketplace/CreateAdModal.tsx | 26 +++-
.../marketplace/IntentionDetailModal.tsx | 56 ++++++++
.../marketplace/ListingDetailsModal.tsx | 41 +++++-
components/marketplace/MyAdsIntentionList.tsx | 37 +++++
.../marketplace/MyPurchaseIntentionsPanel.tsx | 41 +++++-
components/marketplace/PurchaseModal.tsx | 117 +++++++++++++--
lib/auth/middleware.ts | 2 +
.../020_purchase_intentions_custom_offer.sql | 17 +++
lib/db/migrations/run-migrations.ts | 3 +-
lib/hooks/use-purchase-intentions.ts | 24 ++--
lib/prisma/queries/items.ts | 2 +-
lib/prisma/queries/listings.ts | 32 +++++
lib/prisma/queries/purchase-intentions.ts | 136 ++++++++++++++++--
prisma/schema.prisma | 3 +
19 files changed, 806 insertions(+), 58 deletions(-)
create mode 100644 components/admin/AdminSubscriptionTab.tsx
create mode 100644 lib/db/migrations/020_purchase_intentions_custom_offer.sql
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