1
0
Fork 0
polaris-task-force/src/components/frontend/market/NegotiationPanel.tsx

294 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, type CurrencyConfig } 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,
currencyConfig,
}: {
negotiation: MarketNegotiation;
role: "buyer" | "seller";
currencyConfig: CurrencyConfig;
}) {
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, currencyConfig);
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");
}
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;
currencyConfig: CurrencyConfig;
}
export function NegotiationPanel({
negotiations,
currentUserId,
currencyConfig,
}: 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}
currencyConfig={currencyConfig}
/>
</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}
currencyConfig={currencyConfig}
/>
);
})}
</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>
);
}