1
0
Fork 0
polaris-task-force/src/components/frontend/market/MakeOfferDialog.tsx
Z8MB1E 8c10719124 feat(banking): currency emblem image with CurrencyAmount display wrapper
- 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
2026-09-21 20:47:52 -04:00

403 lines
15 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 { 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>
);
}