1
0
Fork 0
polaris-task-force/src/components/frontend/market/NegotiationHistoryDialog.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

187 lines
6.3 KiB
TypeScript

"use client";
import { useState } from "react";
import type { Asset, MarketListing, MarketNegotiation } from "@/payload-types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { formatAmount } from "@/lib/banking/format";
import { buildChatBubbles } from "@/lib/market/chatBubbles";
import { ChatBubbleList } from "@/components/frontend/market/ChatBubbleList";
import { HistoryIcon, ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
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 === "seller") {
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 HistoryRow({
negotiation,
role,
currencyLabel,
}: {
negotiation: MarketNegotiation;
role: "buyer" | "seller";
currencyLabel: string | null;
}) {
const [expanded, setExpanded] = useState(false);
const listing = listingOf(negotiation);
const fmt = (n: number) => formatAmount(n, { name: currencyLabel ?? undefined });
const buyerLabel = role === "buyer" ? "You" : counterpartyLabel(negotiation, "buyer");
const sellerLabel = role === "seller" ? "You" : counterpartyLabel(negotiation, "seller");
const counterparty = role === "buyer" ? sellerLabel : buyerLabel;
const date = new Date(negotiation.updatedAt).toLocaleDateString();
const isOpen = negotiation.status === "open";
return (
<Collapsible open={expanded} onOpenChange={setExpanded} className="rounded-md border">
<CollapsibleTrigger className="flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-medium">{assetName(listing)}</span>
<span className="text-xs text-muted-foreground">
{counterparty} · {date}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="font-mono text-sm">{fmt(negotiation.amount ?? 0)}</span>
<Badge variant={isOpen ? "default" : "secondary"}>
{STATUS_LABELS[negotiation.status] ?? negotiation.status}
</Badge>
<ChevronDownIcon
className={cn(
"size-4 text-muted-foreground transition-transform",
expanded && "rotate-180",
)}
/>
</div>
</CollapsibleTrigger>
{listing && (
<CollapsibleContent className="border-t">
<ChatBubbleList
bubbles={buildChatBubbles(negotiation, listing, currencyLabel)}
buyerLabel={buyerLabel}
sellerLabel={sellerLabel}
maxHeight="max-h-64"
/>
</CollapsibleContent>
)}
</Collapsible>
);
}
interface NegotiationHistoryDialogProps {
negotiations: MarketNegotiation[];
currentUserId: number;
currencyLabel: string | null;
}
export function NegotiationHistoryDialog({
negotiations,
currentUserId,
currencyLabel,
}: NegotiationHistoryDialogProps) {
const [open, setOpen] = useState(false);
const sorted = [...negotiations].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<HistoryIcon className="size-4" />
History
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Negotiation history</DialogTitle>
<DialogDescription>
Your offers, counters and vendor conversations. Tap a row to expand the chat.
</DialogDescription>
</DialogHeader>
<div className="flex max-h-[60vh] flex-col gap-2 overflow-y-auto pr-1">
{sorted.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No negotiations yet.</p>
) : (
sorted.map((negotiation) => {
const buyerId =
typeof negotiation.buyer === "object" && negotiation.buyer
? negotiation.buyer.id
: (negotiation.buyer as number);
const role: "buyer" | "seller" = buyerId === currentUserId ? "buyer" : "seller";
return (
<HistoryRow
key={negotiation.id}
negotiation={negotiation}
role={role}
currencyLabel={currencyLabel}
/>
);
})
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}