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)
187 lines
6.3 KiB
TypeScript
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>
|
|
);
|
|
}
|