1
0
Fork 0
polaris-task-force/src/components/frontend/market/NegotiationPanel.tsx
Z8MB1E 89b8474e23 feat(market): add trading marketplace with listings, haggling, and NPC vendors
Tarkov-style flea market where users list locker items at a fixed price
and buyers pay through the banking system; items transfer directly
into the buyer's locker.

- market-listings + market-negotiations collections and the market page
  with search/rarity filters and a Countdown timer on each card
- service lib (buyListing with partial-quantity + negotiated-price
  support, clean-stock rule, creditLockerQuantity, auto pricing)
- negotiation flow: make/accept/reject/counter/withdraw, strictly-
  increasing buyer offers, NPC vendor auto-resolution
- NPC vendors: game-npcs attribution, patience meter, dialogue, chat
  bubbles, historical threads dialog
- 30d user / 7d auto listing durations, market-tick bin for expiry +
  vendor restock, SSE notify on tick
- market event types + game-npcs/market target collections in the
  event log; sidebar Market entry under Logistics
- partial-quantity purchases (buyListing/buyMarketListing/makeMarketOffer)
  with full int test coverage (25 tests)
2026-08-10 16:05:57 -04:00

291 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { 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 { formatAmount } from "@/lib/banking/format";
import {
acceptMarketOffer,
cancelMarketOffer,
counterMarketOffer,
rejectMarketOffer,
} from "@/app/(frontend)/logistics/market/actions";
import { HandshakeIcon } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { PatienceMeter } from "@/components/frontend/market/PatienceMeter";
import { NegotiationHistoryDialog } from "@/components/frontend/market/NegotiationHistoryDialog";
const STATUS_LABELS: Record<string, string> = {
open: "Open",
accepted: "Accepted",
rejected: "Rejected",
closed: "Closed",
cancelled: "Cancelled",
expired: "Expired",
};
function listingOf(negotiation: MarketNegotiation): MarketListing | null {
return typeof negotiation.listing === "object" && negotiation.listing
? (negotiation.listing as MarketListing)
: null;
}
function assetName(listing: MarketListing | null): string {
if (!listing) return "Unknown item";
const asset = listing.asset as Asset;
return typeof asset === "object" && asset ? asset.name : `Asset #${listing.asset}`;
}
function counterpartyLabel(
negotiation: MarketNegotiation,
role: "buyer" | "seller",
): string {
if (role === "buyer") {
const listing = listingOf(negotiation);
if (listing?.isAutoGenerated) {
const npc = listing.npc as unknown;
if (typeof npc === "object" && npc && "name" in (npc as object)) {
return (npc as { name: string }).name;
}
return "Market vendor";
}
const seller = listing?.seller as unknown;
if (typeof seller === "object" && seller && "username" in (seller as object)) {
return (seller as { username?: string; displayName?: string }).displayName ||
(seller as { username?: string }).username ||
"Seller";
}
return "Seller";
}
const buyer = negotiation.buyer as unknown;
if (typeof buyer === "object" && buyer && "username" in (buyer as object)) {
return (buyer as { username?: string }).username ?? "Buyer";
}
return "Buyer";
}
function NegotiationRow({
negotiation,
role,
currencyLabel,
}: {
negotiation: MarketNegotiation;
role: "buyer" | "seller";
currencyLabel: string | null;
}) {
const router = useRouter();
const [busy, setBusy] = useState<string | null>(null);
const [counterOpen, setCounterOpen] = useState(false);
const [counter, setCounter] = useState("");
const [error, setError] = useState<string | null>(null);
const listing = listingOf(negotiation);
const isOpen = negotiation.status === "open";
const theirProposal =
(role === "buyer" && negotiation.proposedBy === "seller") ||
(role === "seller" && negotiation.proposedBy === "buyer");
const fmt = (n: number) => formatAmount(n, { name: currencyLabel ?? undefined });
async function run(action: () => Promise<{ success: boolean; error?: string }>, key: string) {
setBusy(key);
setError(null);
const res = await action();
if (res.success) {
if (key === "accept") {
const qty = negotiation.quantity ?? listing?.quantity ?? 1;
toast.success("Deal closed", {
description: `Sold ${qty}× at ${fmt(negotiation.amount ?? 0)} each — total ${fmt((negotiation.amount ?? 0) * qty)}.`,
});
}
router.refresh();
} else {
setError(res.error ?? "Something went wrong.");
}
setBusy(null);
}
function handleCounter() {
const value = Number(counter);
if (!Number.isFinite(value) || value <= 0) {
setError("Enter a valid amount.");
return;
}
void run(() => counterMarketOffer(negotiation.id, value), "counter");
}
const showActions = isOpen && theirProposal;
return (
<div className="flex flex-col gap-2 rounded-md border p-3">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">{assetName(listing)}</span>
<span className="text-xs text-muted-foreground">
{counterpartyLabel(negotiation, role)} · {listing?.quantity ?? "?"}× in stock
</span>
</div>
<Badge variant={isOpen ? "default" : "secondary"}>{STATUS_LABELS[negotiation.status] ?? negotiation.status}</Badge>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="font-mono">{fmt(negotiation.amount ?? 0)}</span>
<span className="text-xs text-muted-foreground">
{negotiation.quantity ? `${negotiation.quantity}× · ` : ""}
{theirProposal
? role === "buyer"
? "their counteroffer"
: "buyer's offer"
: "your offer"}
{negotiation.quantity ? (
<>
{" · total "}
<span className="font-mono">
{fmt((negotiation.amount ?? 0) * negotiation.quantity)}
</span>
</>
) : null}
</span>
</div>
{role === "buyer" && listing?.isAutoGenerated && isOpen && (
<PatienceMeter patience={negotiation.patience ?? 0} />
)}
{isOpen && theirProposal && (
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
onClick={() => void run(() => acceptMarketOffer(negotiation.id), "accept")}
disabled={busy !== null}
>
{busy === "accept" ? "Buying..." : "Accept"}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setCounterOpen((v) => !v)}
disabled={busy !== null}
>
Counter
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => void run(() => rejectMarketOffer(negotiation.id), "reject")}
disabled={busy !== null}
>
{busy === "reject" ? "..." : "Reject"}
</Button>
</div>
)}
{isOpen && role === "buyer" && !theirProposal && (
<Button
size="sm"
variant="ghost"
className="w-fit"
onClick={() => void run(() => cancelMarketOffer(negotiation.id), "withdraw")}
disabled={busy !== null}
>
{busy === "withdraw" ? "..." : "Withdraw"}
</Button>
)}
{counterOpen && (
<div className="flex items-center gap-2">
<Input
type="number"
min={0.01}
step="any"
className="w-36"
value={counter}
onChange={(e) => setCounter(e.target.value)}
placeholder="Your counter"
/>
<Button size="sm" onClick={handleCounter} disabled={busy !== null}>
{busy === "counter" ? "..." : "Send"}
</Button>
</div>
)}
{error && <p className="text-sm text-red-400">{error}</p>}
</div>
);
}
interface NegotiationPanelProps {
negotiations: MarketNegotiation[];
currentUserId: number;
currencyLabel: string | null;
}
export function NegotiationPanel({ negotiations, currentUserId, currencyLabel }: NegotiationPanelProps) {
const open = negotiations.filter((n) => n.status === "open");
const closed = negotiations.filter((n) => n.status !== "open");
if (negotiations.length === 0) return null;
return (
<section className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<HandshakeIcon className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Negotiations
</h2>
{open.length > 0 && (
<Badge variant="outline" className="text-xs">
{open.length} open
</Badge>
)}
<div className="ml-auto">
<NegotiationHistoryDialog
negotiations={negotiations}
currentUserId={currentUserId}
currencyLabel={currencyLabel}
/>
</div>
</div>
{open.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{open.map((negotiation) => {
const role: "buyer" | "seller" =
negotiation.buyer &&
(typeof negotiation.buyer === "object"
? negotiation.buyer.id
: negotiation.buyer) === currentUserId
? "buyer"
: "seller";
return (
<NegotiationRow
key={negotiation.id}
negotiation={negotiation}
role={role}
currencyLabel={currencyLabel}
/>
);
})}
</div>
)}
{closed.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{closed.slice(0, 6).map((negotiation) => (
<Badge key={negotiation.id} variant="secondary" className="font-normal">
{assetName(listingOf(negotiation))} · {STATUS_LABELS[negotiation.status] ?? negotiation.status}
</Badge>
))}
{closed.length > 6 && (
<span className={cn("text-xs text-muted-foreground self-center")}>
+{closed.length - 6} more
</span>
)}
</div>
)}
</section>
);
}