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
This commit is contained in:
1 parent
d27526e026
commit
d8e7d5d7e6
19 files changed
+806
-58
No files matched your search
@@ -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<ReturnType<typeof getListingById>>;
|
||||
intention: Exclude<Awaited<ReturnType<typeof getPurchaseIntentionById>>, 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Assinaturas & Permissões
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||
Configure planos, recursos e vincule usuários às assinaturas
|
||||
disponíveis. Todas as alterações são aplicadas imediatamente
|
||||
após salvar.
|
||||
</p>
|
||||
{adminUser?.subscription_tier && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Seu plano atual:{" "}
|
||||
<span className="font-semibold uppercase">
|
||||
{adminUser.subscription_tier.name} (
|
||||
{adminUser.subscription_tier.slug})
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AdminSubscriptionDashboard initialTiers={initialTiers} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <DollarSign className="h-3 w-3" />;
|
||||
case "tc":
|
||||
case "gp":
|
||||
case "tibia_coins":
|
||||
case "gold_coins":
|
||||
return <Coins className="h-3 w-3" />;
|
||||
case "trade":
|
||||
return <RefreshCw className="h-3 w-3" />;
|
||||
@@ -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"
|
||||
)}>
|
||||
<PriceBadges prices={availablePrices} compact tradeItemImageUrl={tradeItemImageUrl} listing={listing} />
|
||||
<div className="space-y-1">
|
||||
<PriceBadges prices={availablePrices} compact tradeItemImageUrl={tradeItemImageUrl} listing={listing} />
|
||||
{highestOfferLabel && (
|
||||
<div className="text-xs font-medium text-primary/80">
|
||||
{highestOfferLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
)}>
|
||||
<PriceBadges prices={availablePrices} tradeItemImageUrl={tradeItemImageUrl} listing={listing} />
|
||||
<div className="space-y-1">
|
||||
<PriceBadges prices={availablePrices} tradeItemImageUrl={tradeItemImageUrl} listing={listing} />
|
||||
{highestOfferLabel && (
|
||||
<div className="text-xs font-medium text-primary/80">
|
||||
{highestOfferLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{customOffer && (
|
||||
<div className="bg-primary/5 border border-primary/20 rounded-lg p-3 space-y-1">
|
||||
<Label className="text-xs text-primary uppercase tracking-wide">
|
||||
Oferta do Comprador
|
||||
</Label>
|
||||
<div className="text-base font-semibold text-primary">{customOffer.value}</div>
|
||||
{customOffer.date && (
|
||||
<p className="text-[11px] text-primary/70">Enviada em {customOffer.date}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mensagem do comprador */}
|
||||
{intention.buyer_message && (
|
||||
<div>
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{highestOfferLabel && (
|
||||
<div className="rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-sm font-medium text-primary">
|
||||
{highestOfferLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Informações de leilão */}
|
||||
|
||||
@@ -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<PurchaseIntention[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -196,6 +221,18 @@ export function MyAdsIntentionList({ listingId, sellerId }: MyAdsIntentionListPr
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{formatTimeAgo(new Date(intention.created_at))}</span>
|
||||
</div>
|
||||
{formatCustomOffer(intention) && (
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="outline" className="border-primary text-primary">
|
||||
Oferta: {formatCustomOffer(intention)}
|
||||
</Badge>
|
||||
{intention.custom_offer_created_at && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{new Date(intention.custom_offer_created_at).toLocaleString("pt-BR")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{intention.buyer_message && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">
|
||||
<MessageSquare className="h-3 w-3 inline mr-1" />
|
||||
|
||||
@@ -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) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{customOfferLabel && (
|
||||
<div className="flex items-center gap-1 text-foreground">
|
||||
<Badge variant="outline" className="border-primary text-primary">
|
||||
Sua oferta: {customOfferLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -496,10 +524,7 @@ interface MyPurchaseIntentionsPanelProps {
|
||||
export function MyPurchaseIntentionsPanel({ locale = "pt" }: MyPurchaseIntentionsPanelProps) {
|
||||
const { user } = useSession();
|
||||
const [filter, setFilter] = useState<PurchaseIntentionStatus | "all">("all");
|
||||
|
||||
const { intentions, isLoading, refetch } = useMyPurchaseIntentions(
|
||||
filter === "all" ? undefined : filter
|
||||
);
|
||||
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
|
||||
<Skeleton key={i} className="h-48 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : intentions.length === 0 ? (
|
||||
) : filteredIntentions.length === 0 ? (
|
||||
<div className="text-center py-12 border rounded-lg">
|
||||
<ShoppingCart className="h-12 w-12 mx-auto mb-4 text-muted-foreground opacity-50" />
|
||||
<p className="text-lg font-medium mb-2">Nenhuma intenção de compra encontrada</p>
|
||||
@@ -597,7 +626,7 @@ export function MyPurchaseIntentionsPanel({ locale = "pt" }: MyPurchaseIntention
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{intentions.map((intention) => (
|
||||
{filteredIntentions.map((intention) => (
|
||||
<IntentionCard key={intention.intention_id} intention={intention} locale={locale} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [customOfferValue, setCustomOfferValue] = useState<string>("");
|
||||
|
||||
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<string, unknown> = {
|
||||
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
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Valores anunciados pelo vendedor</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableMethods.map((method) => (
|
||||
<Badge key={`original-${method.type}`} variant="secondary" className="gap-1">
|
||||
{method.label}: {method.value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{highestOfferLabel && (
|
||||
<div className="rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-sm">
|
||||
Maior oferta registrada:{" "}
|
||||
<span className="font-semibold">{highestOfferLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Seleção de método de pagamento */}
|
||||
@@ -224,6 +304,27 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selectedMethod &&
|
||||
["pix", "tibia_coins", "gold_coins"].includes(selectedMethod) && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
Propor valor personalizado ({selectedMethod === "pix" ? "R$" : "quantidade"})
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step={selectedMethod === "pix" ? "0.01" : "1"}
|
||||
value={customOfferValue}
|
||||
onChange={(event) => setCustomOfferValue(event.target.value)}
|
||||
placeholder={selectedMethod === "pix" ? "Ex.: 199.90" : "Ex.: 1200"}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Esse valor será enviado ao vendedor como sua proposta. Ele pode aceitar, negar ou
|
||||
responder.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-destructive/10 text-destructive p-3 rounded-lg text-sm">
|
||||
{error}
|
||||
|
||||
@@ -31,6 +31,8 @@ function extractToken(request: NextRequest): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Converte payload de token em objeto AuthenticatedUser.
|
||||
*/
|
||||
function toAuthenticatedUser(payload: {
|
||||
userId: number;
|
||||
username: string;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<PurchaseIntentionsResponse>({
|
||||
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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* Queries de itens usando Prisma
|
||||
* Busca itens da tabela unificada items (PostgreSQL)
|
||||
*/
|
||||
|
||||
@@ -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<Listing | null>
|
||||
},
|
||||
});
|
||||
|
||||
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<Listing | null>
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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<PurchaseIntention> {
|
||||
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<PurchaseIntention | null> {
|
||||
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<PurchaseIntention | null> {
|
||||
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<Map<number, { amount: number; currency: string }>> {
|
||||
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<number, { amount: number; currency: string }>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user