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)
291 lines
9.4 KiB
TypeScript
291 lines
9.4 KiB
TypeScript
"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>
|
||
);
|
||
}
|