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:
Evandro Fonseca Junior committed 2025-11-08 11:51:21 -03:00
1 parent d27526e026
commit d8e7d5d7e6
19 files changed
+806 -58

No files matched your search

+109 -8
View File
@@ -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}