- GameRules currencySymbol switches from text to a media upload - new CurrencyAmount/CurrencySymbol components; render sites across banking, base management, dashboard, market, campaigns and profiles are wired to show the emblem inline with amounts - migration 20260921_140000 (text column out, media fk in); registers the five pending migrations in the migrations index
403 lines
15 KiB
TypeScript
403 lines
15 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, 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 { Label } from "@/components/ui/label";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogTrigger,
|
||
} from "@/components/ui/dialog";
|
||
import { currencyLabel, formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
||
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
|
||
import {
|
||
acceptMarketOffer,
|
||
cancelMarketOffer,
|
||
counterMarketOffer,
|
||
makeMarketOffer,
|
||
} from "@/app/(frontend)/logistics/market/actions";
|
||
import { HandshakeIcon, XIcon } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { PatienceMeter } from "@/components/frontend/market/PatienceMeter";
|
||
import { NpcChat } from "@/components/frontend/market/NpcChat";
|
||
|
||
type OfferOutcome =
|
||
| { kind: "pending"; negotiation: MarketNegotiation }
|
||
| { kind: "countered"; negotiation: MarketNegotiation }
|
||
| { kind: "accepted"; price: number }
|
||
| { kind: "rejected" }
|
||
| { kind: "closed" };
|
||
|
||
/** Keep the dialog visible this long after a deal closes so the user can read the result. */
|
||
const MIN_ACCEPT_VISIBLE_MS = 3500;
|
||
|
||
function outcomeOf(negotiation: MarketNegotiation | null): OfferOutcome | null {
|
||
if (!negotiation) return null;
|
||
if (negotiation.status === "accepted") {
|
||
return { kind: "accepted", price: negotiation.acceptedPrice ?? negotiation.amount ?? 0 };
|
||
}
|
||
if (negotiation.status === "rejected") return { kind: "rejected" };
|
||
if (negotiation.status === "open") {
|
||
return negotiation.proposedBy === "seller"
|
||
? { kind: "countered", negotiation }
|
||
: { kind: "pending", negotiation };
|
||
}
|
||
return { kind: "closed" };
|
||
}
|
||
|
||
interface MakeOfferDialogProps {
|
||
listing: MarketListing;
|
||
currentUserId: number;
|
||
currencyConfig: CurrencyConfig;
|
||
}
|
||
|
||
export function MakeOfferDialog({ listing, currentUserId, currencyConfig }: MakeOfferDialogProps) {
|
||
const router = useRouter();
|
||
const [open, setOpen] = useState(false);
|
||
const [amount, setAmount] = useState("");
|
||
const [counter, setCounter] = useState("");
|
||
const [qty, setQty] = useState(1);
|
||
const [negotiation, setNegotiation] = useState<MarketNegotiation | null>(null);
|
||
const [busy, setBusy] = useState<string | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [loadingThread, setLoadingThread] = useState(false);
|
||
const [acceptedAt, setAcceptedAt] = useState<number | null>(null);
|
||
const [nowTick, setNowTick] = useState(() => Date.now());
|
||
|
||
const asset = listing.asset as Asset;
|
||
const assetName = typeof asset === "object" && asset ? asset.name : `Asset #${listing.asset}`;
|
||
const isVendor = listing.isAutoGenerated;
|
||
const stock = listing.quantity ?? 1;
|
||
const outcome = outcomeOf(negotiation);
|
||
const displayCurrency = currencyLabel(currencyConfig);
|
||
const fmt = (n: number) => formatAmount(n, currencyConfig);
|
||
const clampedQty = Math.max(1, Math.min(stock, qty));
|
||
|
||
const sinceAccept = acceptedAt != null ? nowTick - acceptedAt : 0;
|
||
const acceptLocked = acceptedAt != null && sinceAccept < MIN_ACCEPT_VISIBLE_MS;
|
||
const acceptRemaining = acceptLocked
|
||
? Math.ceil((MIN_ACCEPT_VISIBLE_MS - sinceAccept) / 1000)
|
||
: 0;
|
||
|
||
useEffect(() => {
|
||
if (acceptedAt == null) return;
|
||
const id = window.setInterval(() => setNowTick(Date.now()), 100);
|
||
return () => window.clearInterval(id);
|
||
}, [acceptedAt]);
|
||
|
||
function reset() {
|
||
setAmount("");
|
||
setCounter("");
|
||
setQty(1);
|
||
setNegotiation(null);
|
||
setBusy(null);
|
||
setError(null);
|
||
setAcceptedAt(null);
|
||
}
|
||
|
||
async function fetchThread(): Promise<MarketNegotiation | null> {
|
||
try {
|
||
const res = await fetch(
|
||
`/api/market-negotiations?limit=1&sort=-updatedAt&where[listing][equals]=${listing.id}&where[buyer][equals]=${currentUserId}`,
|
||
{ cache: "no-store" },
|
||
);
|
||
if (res.ok) {
|
||
const data = (await res.json()) as { docs?: MarketNegotiation[] };
|
||
if (data.docs && data.docs.length > 0) {
|
||
return data.docs[0];
|
||
}
|
||
}
|
||
} catch {
|
||
// Ignore — the dialog still works for making a fresh offer.
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function loadThread() {
|
||
setLoadingThread(true);
|
||
try {
|
||
const thread = await fetchThread();
|
||
if (thread) setNegotiation(thread);
|
||
} finally {
|
||
setLoadingThread(false);
|
||
}
|
||
}
|
||
|
||
async function run(
|
||
action: () => Promise<{ success: boolean; error?: string; data?: unknown }>,
|
||
key: string,
|
||
) {
|
||
setBusy(key);
|
||
setError(null);
|
||
const res = await action();
|
||
if (res.success) {
|
||
if (key === "accept") {
|
||
// acceptMarketOffer returns the listing, not the negotiation — refetch
|
||
// the thread to pick up the vendor's final dialogue line. Delay the
|
||
// refresh so the accepted summary stays on screen (a sold listing
|
||
// would otherwise unmount the card and close this dialog instantly).
|
||
const thread = await fetchThread();
|
||
if (thread) {
|
||
setNegotiation(thread);
|
||
} else {
|
||
setNegotiation((prev) =>
|
||
prev ? { ...prev, status: "accepted", acceptedPrice: prev.amount ?? 0 } : prev,
|
||
);
|
||
}
|
||
const agreed = thread?.amount ?? negotiation?.amount ?? 0;
|
||
const boughtQty = thread?.quantity ?? negotiation?.quantity ?? stock;
|
||
setAcceptedAt(Date.now());
|
||
toast.success("Deal closed", {
|
||
description: `Sold ${boughtQty}× at ${fmt(agreed)} each, for a total of ${fmt(agreed * boughtQty)}. The items are in your locker.`,
|
||
});
|
||
window.setTimeout(() => router.refresh(), MIN_ACCEPT_VISIBLE_MS);
|
||
setBusy(null);
|
||
return;
|
||
} else {
|
||
const data = res.data as MarketNegotiation | undefined;
|
||
if (data && typeof data === "object" && "proposedBy" in data) {
|
||
setNegotiation(data);
|
||
if (isVendor && data.status === "accepted") {
|
||
setAcceptedAt(Date.now());
|
||
const dealQty = data.quantity ?? stock;
|
||
toast.success("Deal closed", {
|
||
description: `The vendor accepted your offer of ${fmt(data.amount ?? 0)} each for ${dealQty}×, for a total of ${fmt((data.amount ?? 0) * dealQty)}.`,
|
||
});
|
||
window.setTimeout(() => router.refresh(), MIN_ACCEPT_VISIBLE_MS);
|
||
setBusy(null);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
router.refresh();
|
||
} else {
|
||
setError(res.error ?? "Something went wrong.");
|
||
}
|
||
setBusy(null);
|
||
}
|
||
|
||
function handleOffer() {
|
||
const value = Number(amount);
|
||
if (!Number.isFinite(value) || value <= 0) {
|
||
setError("Enter a valid offer amount.");
|
||
return;
|
||
}
|
||
void run(() => makeMarketOffer(listing.id, value, clampedQty), "offer");
|
||
}
|
||
|
||
function handleAccept() {
|
||
if (!negotiation) return;
|
||
void run(() => acceptMarketOffer(negotiation.id), "accept");
|
||
}
|
||
|
||
function handleCounter() {
|
||
if (!negotiation) return;
|
||
const value = Number(counter);
|
||
if (!Number.isFinite(value) || value <= 0) {
|
||
setError("Enter a valid counter amount.");
|
||
return;
|
||
}
|
||
void run(() => counterMarketOffer(negotiation.id, value), "counter");
|
||
}
|
||
|
||
function handleWithdraw() {
|
||
if (!negotiation) return;
|
||
void run(() => cancelMarketOffer(negotiation.id), "withdraw");
|
||
}
|
||
|
||
return (
|
||
<Dialog
|
||
open={open}
|
||
onOpenChange={(o) => {
|
||
// After a deal closes, keep the modal on screen for the minimum
|
||
// display window so the user can read the confirmation and NPC line.
|
||
if (!o && acceptLocked) return;
|
||
setOpen(o);
|
||
if (o) {
|
||
reset();
|
||
void loadThread();
|
||
} else {
|
||
setAcceptedAt(null);
|
||
}
|
||
}}
|
||
>
|
||
<DialogTrigger asChild>
|
||
<Button size="sm" variant="outline">
|
||
<HandshakeIcon className="size-4" />
|
||
Offer
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Negotiate: {assetName}</DialogTitle>
|
||
<DialogDescription>
|
||
{isVendor ? "Haggle with the vendor." : "Send an offer to the seller."} Asking{" "}
|
||
<span className="font-mono"><CurrencyAmount value={listing.price ?? 0} currency={currencyConfig} /></span> per unit.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="flex flex-col gap-4">
|
||
{isVendor && (
|
||
<NpcChat listing={listing} negotiation={negotiation} currencyLabel={displayCurrency} />
|
||
)}
|
||
|
||
{loadingThread && !outcome && (
|
||
<p className="text-sm text-muted-foreground">Checking for an existing offer...</p>
|
||
)}
|
||
|
||
{!outcome && !loadingThread && (
|
||
<>
|
||
<div className="flex flex-col gap-1.5">
|
||
<Label>Your offer ({displayCurrency} per unit)</Label>
|
||
<Input
|
||
type="number"
|
||
min={0.01}
|
||
step="any"
|
||
value={amount}
|
||
onChange={(e) => setAmount(e.target.value)}
|
||
placeholder="0"
|
||
/>
|
||
</div>
|
||
{stock > 1 && (
|
||
<div className="flex flex-col gap-1.5">
|
||
<Label>Quantity</Label>
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
max={stock}
|
||
step={1}
|
||
value={clampedQty}
|
||
onChange={(e) =>
|
||
setQty(Math.max(1, Math.min(stock, Number(e.target.value) || 1)))
|
||
}
|
||
/>
|
||
</div>
|
||
)}
|
||
<p className="text-xs text-muted-foreground">
|
||
{stock}× in stock · total{" "}
|
||
<span className="font-mono"><CurrencyAmount value={(Number(amount) || 0) * clampedQty} currency={currencyConfig} /></span>
|
||
</p>
|
||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||
</>
|
||
)}
|
||
|
||
{outcome?.kind === "pending" && (
|
||
<div className="flex flex-col gap-2">
|
||
<p className="text-sm text-muted-foreground">
|
||
Offer of <span className="font-mono"><CurrencyAmount value={negotiation?.amount ?? 0} currency={currencyConfig} /></span> each for{" "}
|
||
{negotiation?.quantity ?? stock}× sent. Waiting for the{" "}
|
||
{isVendor ? "vendor" : "seller"} to respond.
|
||
</p>
|
||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||
</div>
|
||
)}
|
||
|
||
{outcome?.kind === "countered" && (
|
||
<div className="flex flex-col gap-3">
|
||
<p className="text-sm">
|
||
The {isVendor ? "vendor" : "seller"} counteroffers{" "}
|
||
<span className="font-mono font-semibold"><CurrencyAmount value={negotiation?.amount ?? 0} currency={currencyConfig} /></span>{" "}
|
||
each for {negotiation?.quantity ?? stock}×, for a total of{" "}
|
||
<span className="font-mono">
|
||
<CurrencyAmount value={(negotiation?.amount ?? 0) * (negotiation?.quantity ?? stock)} currency={currencyConfig} />
|
||
</span>
|
||
.
|
||
</p>
|
||
<div className="flex flex-col gap-1.5">
|
||
<Label>Your counter ({displayCurrency} per unit)</Label>
|
||
<Input
|
||
type="number"
|
||
min={0.01}
|
||
step="any"
|
||
value={counter}
|
||
onChange={(e) => setCounter(e.target.value)}
|
||
/>
|
||
</div>
|
||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||
</div>
|
||
)}
|
||
|
||
{outcome?.kind === "accepted" && (
|
||
<div className="flex flex-col gap-1">
|
||
<Badge variant="outline" className="w-fit">
|
||
Deal closed
|
||
</Badge>
|
||
<p className="text-sm text-muted-foreground">
|
||
Sold at <span className="font-mono"><CurrencyAmount value={outcome.price} currency={currencyConfig} /></span> each for{" "}
|
||
{negotiation?.quantity ?? stock}×, for a total of{" "}
|
||
<span className="font-mono">
|
||
<CurrencyAmount value={outcome.price * (negotiation?.quantity ?? stock)} currency={currencyConfig} />
|
||
</span>
|
||
. The items are in your locker.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{outcome?.kind === "rejected" && (
|
||
<p className="text-sm text-muted-foreground">
|
||
The seller declined your offer. The negotiation is closed.
|
||
</p>
|
||
)}
|
||
|
||
{outcome?.kind === "closed" && (
|
||
<p className="text-sm text-muted-foreground">
|
||
{isVendor && negotiation?.status === "closed"
|
||
? "The vendor is done haggling. You can still buy the item at the asking price."
|
||
: "This negotiation has been closed."}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{isVendor && negotiation && negotiation.status !== "accepted" && (
|
||
<PatienceMeter patience={negotiation.patience ?? 0} />
|
||
)}
|
||
|
||
<DialogFooter className="flex-wrap">
|
||
{!outcome && (
|
||
<Button onClick={handleOffer} disabled={busy !== null || loadingThread}>
|
||
{busy === "offer" ? "Sending..." : "Make Offer"}
|
||
</Button>
|
||
)}
|
||
|
||
{outcome?.kind === "pending" && (
|
||
<Button variant="outline" onClick={handleWithdraw} disabled={busy !== null}>
|
||
<XIcon className="size-4" />
|
||
{busy === "withdraw" ? "..." : "Withdraw"}
|
||
</Button>
|
||
)}
|
||
|
||
{outcome?.kind === "countered" && (
|
||
<>
|
||
<Button onClick={handleAccept} disabled={busy !== null}>
|
||
{busy === "accept" ? "Buying..." : "Accept"}
|
||
</Button>
|
||
<Button variant="outline" onClick={handleCounter} disabled={busy !== null}>
|
||
{busy === "counter" ? "..." : "Counter"}
|
||
</Button>
|
||
<Button variant="ghost" onClick={handleWithdraw} disabled={busy !== null}>
|
||
<XIcon className="size-4" />
|
||
{busy === "withdraw" ? "..." : "Withdraw"}
|
||
</Button>
|
||
</>
|
||
)}
|
||
|
||
{(outcome?.kind === "accepted" ||
|
||
outcome?.kind === "rejected" ||
|
||
outcome?.kind === "closed") && (
|
||
<Button variant="outline" onClick={() => setOpen(false)} disabled={acceptLocked}>
|
||
{acceptLocked ? `Closing in ${acceptRemaining}s…` : "Close"}
|
||
</Button>
|
||
)}
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|