"use client"; import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import type { Asset, MarketListing, MarketNegotiation } from "@/payload-types"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { currencyLabel, formatAmount, type CurrencyConfig } from "@/lib/banking/format"; import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount"; import { acceptMarketOffer, cancelMarketOffer, counterMarketOffer, makeMarketOffer, } from "@/app/(frontend)/logistics/market/actions"; import { HandshakeIcon, XIcon } from "lucide-react"; import { toast } from "sonner"; import { PatienceMeter } from "@/components/frontend/market/PatienceMeter"; import { NpcChat } from "@/components/frontend/market/NpcChat"; type OfferOutcome = | { kind: "pending"; negotiation: MarketNegotiation } | { kind: "countered"; negotiation: MarketNegotiation } | { kind: "accepted"; price: number } | { kind: "rejected" } | { kind: "closed" }; /** Keep the dialog visible this long after a deal closes so the user can read the result. */ const MIN_ACCEPT_VISIBLE_MS = 3500; function outcomeOf(negotiation: MarketNegotiation | null): OfferOutcome | null { if (!negotiation) return null; if (negotiation.status === "accepted") { return { kind: "accepted", price: negotiation.acceptedPrice ?? negotiation.amount ?? 0 }; } if (negotiation.status === "rejected") return { kind: "rejected" }; if (negotiation.status === "open") { return negotiation.proposedBy === "seller" ? { kind: "countered", negotiation } : { kind: "pending", negotiation }; } return { kind: "closed" }; } interface MakeOfferDialogProps { listing: MarketListing; currentUserId: number; currencyConfig: CurrencyConfig; } export function MakeOfferDialog({ listing, currentUserId, currencyConfig }: MakeOfferDialogProps) { const router = useRouter(); const [open, setOpen] = useState(false); const [amount, setAmount] = useState(""); const [counter, setCounter] = useState(""); const [qty, setQty] = useState(1); const [negotiation, setNegotiation] = useState(null); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const [loadingThread, setLoadingThread] = useState(false); const [acceptedAt, setAcceptedAt] = useState(null); const [nowTick, setNowTick] = useState(() => Date.now()); const asset = listing.asset as Asset; const assetName = typeof asset === "object" && asset ? asset.name : `Asset #${listing.asset}`; const isVendor = listing.isAutoGenerated; const stock = listing.quantity ?? 1; const outcome = outcomeOf(negotiation); const displayCurrency = currencyLabel(currencyConfig); const fmt = (n: number) => formatAmount(n, currencyConfig); const clampedQty = Math.max(1, Math.min(stock, qty)); const sinceAccept = acceptedAt != null ? nowTick - acceptedAt : 0; const acceptLocked = acceptedAt != null && sinceAccept < MIN_ACCEPT_VISIBLE_MS; const acceptRemaining = acceptLocked ? Math.ceil((MIN_ACCEPT_VISIBLE_MS - sinceAccept) / 1000) : 0; useEffect(() => { if (acceptedAt == null) return; const id = window.setInterval(() => setNowTick(Date.now()), 100); return () => window.clearInterval(id); }, [acceptedAt]); function reset() { setAmount(""); setCounter(""); setQty(1); setNegotiation(null); setBusy(null); setError(null); setAcceptedAt(null); } async function fetchThread(): Promise { try { const res = await fetch( `/api/market-negotiations?limit=1&sort=-updatedAt&where[listing][equals]=${listing.id}&where[buyer][equals]=${currentUserId}`, { cache: "no-store" }, ); if (res.ok) { const data = (await res.json()) as { docs?: MarketNegotiation[] }; if (data.docs && data.docs.length > 0) { return data.docs[0]; } } } catch { // Ignore — the dialog still works for making a fresh offer. } return null; } async function loadThread() { setLoadingThread(true); try { const thread = await fetchThread(); if (thread) setNegotiation(thread); } finally { setLoadingThread(false); } } async function run( action: () => Promise<{ success: boolean; error?: string; data?: unknown }>, key: string, ) { setBusy(key); setError(null); const res = await action(); if (res.success) { if (key === "accept") { // acceptMarketOffer returns the listing, not the negotiation — refetch // the thread to pick up the vendor's final dialogue line. Delay the // refresh so the accepted summary stays on screen (a sold listing // would otherwise unmount the card and close this dialog instantly). const thread = await fetchThread(); if (thread) { setNegotiation(thread); } else { setNegotiation((prev) => prev ? { ...prev, status: "accepted", acceptedPrice: prev.amount ?? 0 } : prev, ); } const agreed = thread?.amount ?? negotiation?.amount ?? 0; const boughtQty = thread?.quantity ?? negotiation?.quantity ?? stock; setAcceptedAt(Date.now()); toast.success("Deal closed", { description: `Sold ${boughtQty}× at ${fmt(agreed)} each, for a total of ${fmt(agreed * boughtQty)}. The items are in your locker.`, }); window.setTimeout(() => router.refresh(), MIN_ACCEPT_VISIBLE_MS); setBusy(null); return; } else { const data = res.data as MarketNegotiation | undefined; if (data && typeof data === "object" && "proposedBy" in data) { setNegotiation(data); if (isVendor && data.status === "accepted") { setAcceptedAt(Date.now()); const dealQty = data.quantity ?? stock; toast.success("Deal closed", { description: `The vendor accepted your offer of ${fmt(data.amount ?? 0)} each for ${dealQty}×, for a total of ${fmt((data.amount ?? 0) * dealQty)}.`, }); window.setTimeout(() => router.refresh(), MIN_ACCEPT_VISIBLE_MS); setBusy(null); return; } } } router.refresh(); } else { setError(res.error ?? "Something went wrong."); } setBusy(null); } function handleOffer() { const value = Number(amount); if (!Number.isFinite(value) || value <= 0) { setError("Enter a valid offer amount."); return; } void run(() => makeMarketOffer(listing.id, value, clampedQty), "offer"); } function handleAccept() { if (!negotiation) return; void run(() => acceptMarketOffer(negotiation.id), "accept"); } function handleCounter() { if (!negotiation) return; const value = Number(counter); if (!Number.isFinite(value) || value <= 0) { setError("Enter a valid counter amount."); return; } void run(() => counterMarketOffer(negotiation.id, value), "counter"); } function handleWithdraw() { if (!negotiation) return; void run(() => cancelMarketOffer(negotiation.id), "withdraw"); } return ( { // After a deal closes, keep the modal on screen for the minimum // display window so the user can read the confirmation and NPC line. if (!o && acceptLocked) return; setOpen(o); if (o) { reset(); void loadThread(); } else { setAcceptedAt(null); } }} > Negotiate: {assetName} {isVendor ? "Haggle with the vendor." : "Send an offer to the seller."} Asking{" "} per unit.
{isVendor && ( )} {loadingThread && !outcome && (

Checking for an existing offer...

)} {!outcome && !loadingThread && ( <>
setAmount(e.target.value)} placeholder="0" />
{stock > 1 && (
setQty(Math.max(1, Math.min(stock, Number(e.target.value) || 1))) } />
)}

{stock}× in stock · total{" "}

{error &&

{error}

} )} {outcome?.kind === "pending" && (

Offer of each for{" "} {negotiation?.quantity ?? stock}× sent. Waiting for the{" "} {isVendor ? "vendor" : "seller"} to respond.

{error &&

{error}

}
)} {outcome?.kind === "countered" && (

The {isVendor ? "vendor" : "seller"} counteroffers{" "} {" "} each for {negotiation?.quantity ?? stock}×, for a total of{" "} .

setCounter(e.target.value)} />
{error &&

{error}

}
)} {outcome?.kind === "accepted" && (
Deal closed

Sold at each for{" "} {negotiation?.quantity ?? stock}×, for a total of{" "} . The items are in your locker.

)} {outcome?.kind === "rejected" && (

The seller declined your offer. The negotiation is closed.

)} {outcome?.kind === "closed" && (

{isVendor && negotiation?.status === "closed" ? "The vendor is done haggling. You can still buy the item at the asking price." : "This negotiation has been closed."}

)}
{isVendor && negotiation && negotiation.status !== "accepted" && ( )} {!outcome && ( )} {outcome?.kind === "pending" && ( )} {outcome?.kind === "countered" && ( <> )} {(outcome?.kind === "accepted" || outcome?.kind === "rejected" || outcome?.kind === "closed") && ( )}
); }