1
0
Fork 0

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
This commit is contained in:
Jason Fraley 2026-09-21 20:47:52 -04:00
parent 9bbe44d38d
commit 8c10719124
22 changed files with 185 additions and 62 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -37,9 +37,11 @@ export const GameRules: GlobalConfig = {
}, },
{ {
name: "currencySymbol", name: "currencySymbol",
type: "text", type: "upload",
relationTo: "media",
admin: { admin: {
description: 'Optional symbol prefixed to amounts, e.g. "¢" or "$".', description:
"Optional emblem shown inline before amounts. It renders scaled to the surrounding text height (unlike a text symbol).",
}, },
}, },
{ {

View file

@ -11,7 +11,8 @@ import {
ItemTitle, ItemTitle,
} from "@/components/ui/item"; } from "@/components/ui/item";
import { LandmarkIcon, UsersIcon, WalletIcon } from "lucide-react"; import { LandmarkIcon, UsersIcon, WalletIcon } from "lucide-react";
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format"; import type { CurrencyConfig } from "@/lib/banking/format";
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
import { type AccountStatus, accountStatusBase, accountStatusClass } from "@/lib/banking/ui"; import { type AccountStatus, accountStatusBase, accountStatusClass } from "@/lib/banking/ui";
const accountTypeMeta = { const accountTypeMeta = {
@ -85,10 +86,10 @@ export function AccountCard({
</ItemDescription> </ItemDescription>
<div className="mt-1"> <div className="mt-1">
<span className="text-lg font-semibold font-mono"> <span className="text-lg font-semibold font-mono">
{formatAmount( <CurrencyAmount
account.balance ?? 0, value={account.balance ?? 0}
currencyConfig.resourceName ? currencyConfig : currency, currency={currencyConfig.resourceName ? currencyConfig : currency}
)} />
</span> </span>
</div> </div>
</ItemContent> </ItemContent>

View file

@ -4,7 +4,8 @@ import Link from "next/link";
import { ArrowLeftIcon } from "lucide-react"; import { ArrowLeftIcon } from "lucide-react";
import type { BankAccount, LedgerEntry } from "@/payload-types"; import type { BankAccount, LedgerEntry } from "@/payload-types";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format"; import type { CurrencyConfig } from "@/lib/banking/format";
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
import { type AccountStatus, accountStatusBase, accountStatusClass } from "@/lib/banking/ui"; import { type AccountStatus, accountStatusBase, accountStatusClass } from "@/lib/banking/ui";
import { accountTypeInfo } from "@/components/frontend/banking/AccountCard"; import { accountTypeInfo } from "@/components/frontend/banking/AccountCard";
import { BankTransactionDialog } from "@/components/frontend/banking/BankTransactionDialog"; import { BankTransactionDialog } from "@/components/frontend/banking/BankTransactionDialog";
@ -72,10 +73,10 @@ export function AccountDetail({
Balance Balance
</p> </p>
<p className="text-2xl font-semibold font-mono"> <p className="text-2xl font-semibold font-mono">
{formatAmount( <CurrencyAmount
account.balance ?? 0, value={account.balance ?? 0}
currencyConfig.resourceName ? currencyConfig : currency, currency={currencyConfig.resourceName ? currencyConfig : currency}
)} />
</p> </p>
</div> </div>
</div> </div>

View file

@ -8,7 +8,8 @@ import { AccountCard } from "@/components/frontend/banking/AccountCard";
import { CreateAccountDialog } from "@/components/frontend/banking/CreateAccountDialog"; import { CreateAccountDialog } from "@/components/frontend/banking/CreateAccountDialog";
import { WalletHero } from "@/components/frontend/banking/WalletHero"; import { WalletHero } from "@/components/frontend/banking/WalletHero";
import { ActivityFeed } from "@/components/frontend/banking/ActivityFeed"; import { ActivityFeed } from "@/components/frontend/banking/ActivityFeed";
import { currencyLabel, formatAmount, type CurrencyConfig } from "@/lib/banking/format"; import { currencyLabel, type CurrencyConfig } from "@/lib/banking/format";
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext"; import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
interface BankingOverviewProps { interface BankingOverviewProps {
@ -126,7 +127,7 @@ export function BankingOverview({
{isManager && ( {isManager && (
<p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground/70"> <p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground/70">
UNIT FUNDS&nbsp; UNIT FUNDS&nbsp;
{formatAmount(unitFundsTotal, currencyConfig)} <CurrencyAmount value={unitFundsTotal} currency={currencyConfig} />
</p> </p>
)} )}
</div> </div>

View file

@ -0,0 +1,23 @@
import { formatAmount } from "@/lib/banking/format";
import type { CurrencyDisplay } from "@/lib/banking/format";
import { CurrencySymbol } from "./CurrencySymbol";
/**
* Renders the currency emblem inline with the formatted amount. Delegates
* symbol rendering to <CurrencySymbol> and formatting to formatAmount.
* Renders nothing when currency is null/undefined/number (no emblem to show).
*/
export function CurrencyAmount({
value,
currency,
}: {
readonly value: number;
readonly currency?: CurrencyDisplay | null;
}) {
return (
<>
<CurrencySymbol currency={currency} />
{formatAmount(value, currency)}
</>
);
}

View file

@ -0,0 +1,43 @@
import type { CurrencyDisplay } from "@/lib/banking/format";
import { cn } from "@/lib/utils";
import { isCurrencyConfig } from "@/lib/banking/format";
/**
* Renders the currency emblem (an uploaded media image) inline with the text it
* sits beside. The image height is tied to the surrounding font size and capped
* just below the line height, so it scales with the text and never floats larger
* than the numbers next to it. Renders nothing when no emblem is configured.
*/
export function CurrencySymbol({
currency,
className,
ariaHidden,
}: {
readonly currency?: CurrencyDisplay | null;
readonly className?: string;
readonly ariaHidden?: boolean;
}) {
if (!currency || typeof currency !== "object") return null;
const config = isCurrencyConfig(currency) ? currency : null;
const symbolUrl = config?.symbol;
if (!symbolUrl) return null;
const alt =
config?.name?.trim() ||
config?.singular?.trim() ||
config?.plural?.trim() ||
"currency symbol";
return (
<img
src={symbolUrl}
alt={alt}
aria-hidden={ariaHidden}
loading="lazy"
className={cn(
"inline-block h-[0.85em] align-text-bottom max-h-[1.15em] object-contain",
className,
)}
/>
);
}

View file

@ -6,7 +6,8 @@ import Link from "next/link";
import { ChevronRightIcon, WalletIcon } from "lucide-react"; import { ChevronRightIcon, WalletIcon } from "lucide-react";
import type { BankAccount, Resource } from "@/payload-types"; import type { BankAccount, Resource } from "@/payload-types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format"; import type { CurrencyConfig } from "@/lib/banking/format";
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
import { import {
type AccountStatus, type AccountStatus,
accountStatusBase, accountStatusBase,
@ -120,10 +121,10 @@ export function WalletHero({
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<p className="text-4xl md:text-5xl font-bold font-mono tracking-tight"> <p className="text-4xl md:text-5xl font-bold font-mono tracking-tight">
{formatAmount( <CurrencyAmount
account.balance ?? 0, value={account.balance ?? 0}
currencyConfig.resourceName ? currencyConfig : currency, currency={currencyConfig.resourceName ? currencyConfig : currency}
)} />
</p> </p>
<p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground/60"> <p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground/60">
LAST ACTIVITY {lastActivity} LAST ACTIVITY {lastActivity}

View file

@ -1,7 +1,8 @@
"use client"; "use client";
import { BadgeCheckIcon } from "lucide-react"; import { BadgeCheckIcon } from "lucide-react";
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format"; import type { CurrencyConfig } from "@/lib/banking/format";
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { HireStaffDialog } from "@/components/frontend/baseManagement/HireStaffDialog"; import { HireStaffDialog } from "@/components/frontend/baseManagement/HireStaffDialog";
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base"; import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
@ -51,7 +52,7 @@ export function EmploymentCard({
{employment.positionType} · {employment.structureName} {employment.positionType} · {employment.structureName}
</p> </p>
<p className="font-mono text-sm tabular-nums"> <p className="font-mono text-sm tabular-nums">
{formatAmount(employment.salary, currencyConfig)}/tick <CurrencyAmount value={employment.salary} currency={currencyConfig} />/tick
</p> </p>
</div> </div>
) : canManage && hiringEnabled ? ( ) : canManage && hiringEnabled ? (

View file

@ -17,7 +17,8 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { currencyLabel, formatAmount, type CurrencyConfig } from "@/lib/banking/format"; import { currencyLabel, type CurrencyConfig } from "@/lib/banking/format";
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base"; import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
import { HireStaffDialog } from "@/components/frontend/baseManagement/HireStaffDialog"; import { HireStaffDialog } from "@/components/frontend/baseManagement/HireStaffDialog";
@ -239,7 +240,7 @@ export function StaffRoster({
</CardDescription> </CardDescription>
{structure.maintenanceCost > 0 && ( {structure.maintenanceCost > 0 && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Maintenance: {formatAmount(structure.maintenanceCost, currencyConfig)}/tick Maintenance: <CurrencyAmount value={structure.maintenanceCost} currency={currencyConfig} />/tick
</p> </p>
)} )}
</CardHeader> </CardHeader>
@ -262,7 +263,7 @@ export function StaffRoster({
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="shrink-0 font-mono text-sm tabular-nums"> <span className="shrink-0 font-mono text-sm tabular-nums">
{formatAmount(member.salary, currencyConfig)}/tick <CurrencyAmount value={member.salary} currency={currencyConfig} />/tick
</span> </span>
{canManage && ( {canManage && (
<Button <Button
@ -333,12 +334,8 @@ export function StaffRoster({
{crew.requiredHeadcount} required · max {crew.maxHeadcount} {crew.requiredHeadcount} required · max {crew.maxHeadcount}
</p> </p>
<p className="font-mono text-sm tabular-nums text-muted-foreground"> <p className="font-mono text-sm tabular-nums text-muted-foreground">
{formatAmount(crew.unitSalary, currencyConfig)}/tick each ·{" "} <CurrencyAmount value={crew.unitSalary} currency={currencyConfig} />/tick each ·{" "}
{formatAmount( <CurrencyAmount value={crew.unitSalary * crew.headcount} currency={currencyConfig} />/tick total
crew.unitSalary * crew.headcount,
currencyConfig,
)}
/tick total
</p> </p>
</div> </div>
</div> </div>

View file

@ -2,7 +2,8 @@ import Link from "next/link";
import type { MarketListing, MarketNegotiation } from "@/payload-types"; import type { MarketListing, MarketNegotiation } from "@/payload-types";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { ChevronRightIcon, HandshakeIcon } from "lucide-react"; import { ChevronRightIcon, HandshakeIcon } from "lucide-react";
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format"; import type { CurrencyConfig } from "@/lib/banking/format";
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
interface OpenNegotiationsProps { interface OpenNegotiationsProps {
readonly negotiations: MarketNegotiation[]; readonly negotiations: MarketNegotiation[];
@ -36,7 +37,7 @@ export function OpenNegotiations({
// NegotiationPanel on the market page). // NegotiationPanel on the market page).
if (negotiations.length === 0) return null; if (negotiations.length === 0) return null;
const fmt = (n: number) => formatAmount(n, currencyConfig); const fmt = (n: number) => <CurrencyAmount value={n} currency={currencyConfig} />;
return ( return (
<Item variant="outline"> <Item variant="outline">

View file

@ -2,7 +2,8 @@ import Link from "next/link";
import type { BankAccount } from "@/payload-types"; import type { BankAccount } from "@/payload-types";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { WalletIcon } from "lucide-react"; import { WalletIcon } from "lucide-react";
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format"; import type { CurrencyConfig } from "@/lib/banking/format";
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
import { MyWalletCard } from "@/components/frontend/banking/MyWalletCard"; import { MyWalletCard } from "@/components/frontend/banking/MyWalletCard";
interface WalletWidgetProps { interface WalletWidgetProps {
@ -44,7 +45,7 @@ export function WalletWidget({ account, currencyConfig }: WalletWidgetProps) {
</span> </span>
</div> </div>
<span className="shrink-0 font-mono text-xl font-semibold tabular-nums"> <span className="shrink-0 font-mono text-xl font-semibold tabular-nums">
{formatAmount(account.balance ?? 0, currencyConfig)} <CurrencyAmount value={account.balance ?? 0} currency={currencyConfig} />
</span> </span>
</Link> </Link>
</ItemContent> </ItemContent>

View file

@ -43,8 +43,8 @@ const officialStatusLabels: Record<Campaign["status"], string> = {
const statusTextColors: Record<Campaign["status"], string> = { const statusTextColors: Record<Campaign["status"], string> = {
concept: "text-muted-foreground", concept: "text-muted-foreground",
inProgress: "text-blue-400", inProgress: "text-emerald-400",
completed: "text-emerald-400", completed: "text-blue-400",
}; };
export function CampaignCard({ campaign, size = "lg" }: CampaignCardProps) { export function CampaignCard({ campaign, size = "lg" }: CampaignCardProps) {

View file

@ -18,7 +18,8 @@ import {
} from "@/components/ui/item"; } from "@/components/ui/item";
import { getAssetTypeLabel } from "@/lib/locker"; import { getAssetTypeLabel } from "@/lib/locker";
import { getRarityLabel, isNegotiable } from "@/lib/market"; import { getRarityLabel, isNegotiable } from "@/lib/market";
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format"; import type { CurrencyConfig } from "@/lib/banking/format";
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
import { PackageIcon, ShoppingCartIcon, XIcon } from "lucide-react"; import { PackageIcon, ShoppingCartIcon, XIcon } from "lucide-react";
import { buyMarketListing, cancelMarketListing, resetEconomyState } from "@/app/(frontend)/logistics/market/actions"; import { buyMarketListing, cancelMarketListing, resetEconomyState } from "@/app/(frontend)/logistics/market/actions";
import { Countdown } from "@/components/frontend/market/Countdown"; import { Countdown } from "@/components/frontend/market/Countdown";
@ -156,7 +157,7 @@ export function ListingCard({
</ItemDescription> </ItemDescription>
<div className="flex items-center justify-between gap-2 w-full"> <div className="flex items-center justify-between gap-2 w-full">
<span className="text-sm font-semibold font-mono"> <span className="text-sm font-semibold font-mono">
{formatAmount(listing.price ?? 0, currencyConfig)} / unit <CurrencyAmount value={listing.price ?? 0} currency={currencyConfig} /> / unit
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{stock > 1 && ( {stock > 1 && (
@ -164,7 +165,7 @@ export function ListingCard({
{selectedQty}/{stock} ·{" "} {selectedQty}/{stock} ·{" "}
</> </>
)} )}
total {formatAmount(selectedTotal, currencyConfig)} total <CurrencyAmount value={selectedTotal} currency={currencyConfig} />
</span> </span>
</div> </div>
{error && <p className="text-sm text-red-400">{error}</p>} {error && <p className="text-sm text-red-400">{error}</p>}

View file

@ -17,6 +17,7 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { currencyLabel, formatAmount, type CurrencyConfig } from "@/lib/banking/format"; import { currencyLabel, formatAmount, type CurrencyConfig } from "@/lib/banking/format";
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
import { import {
acceptMarketOffer, acceptMarketOffer,
cancelMarketOffer, cancelMarketOffer,
@ -239,7 +240,7 @@ export function MakeOfferDialog({ listing, currentUserId, currencyConfig }: Make
<DialogTitle>Negotiate: {assetName}</DialogTitle> <DialogTitle>Negotiate: {assetName}</DialogTitle>
<DialogDescription> <DialogDescription>
{isVendor ? "Haggle with the vendor." : "Send an offer to the seller."} Asking{" "} {isVendor ? "Haggle with the vendor." : "Send an offer to the seller."} Asking{" "}
<span className="font-mono">{fmt(listing.price ?? 0)}</span> per unit. <span className="font-mono"><CurrencyAmount value={listing.price ?? 0} currency={currencyConfig} /></span> per unit.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -282,7 +283,7 @@ export function MakeOfferDialog({ listing, currentUserId, currencyConfig }: Make
)} )}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{stock}× in stock · total{" "} {stock}× in stock · total{" "}
<span className="font-mono">{fmt((Number(amount) || 0) * clampedQty)}</span> <span className="font-mono"><CurrencyAmount value={(Number(amount) || 0) * clampedQty} currency={currencyConfig} /></span>
</p> </p>
{error && <p className="text-sm text-red-400">{error}</p>} {error && <p className="text-sm text-red-400">{error}</p>}
</> </>
@ -291,7 +292,7 @@ export function MakeOfferDialog({ listing, currentUserId, currencyConfig }: Make
{outcome?.kind === "pending" && ( {outcome?.kind === "pending" && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Offer of <span className="font-mono">{fmt(negotiation?.amount ?? 0)}</span> each for{" "} Offer of <span className="font-mono"><CurrencyAmount value={negotiation?.amount ?? 0} currency={currencyConfig} /></span> each for{" "}
{negotiation?.quantity ?? stock}× sent. Waiting for the{" "} {negotiation?.quantity ?? stock}× sent. Waiting for the{" "}
{isVendor ? "vendor" : "seller"} to respond. {isVendor ? "vendor" : "seller"} to respond.
</p> </p>
@ -303,10 +304,10 @@ export function MakeOfferDialog({ listing, currentUserId, currencyConfig }: Make
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<p className="text-sm"> <p className="text-sm">
The {isVendor ? "vendor" : "seller"} counteroffers{" "} The {isVendor ? "vendor" : "seller"} counteroffers{" "}
<span className="font-mono font-semibold">{fmt(negotiation?.amount ?? 0)}</span>{" "} <span className="font-mono font-semibold"><CurrencyAmount value={negotiation?.amount ?? 0} currency={currencyConfig} /></span>{" "}
each for {negotiation?.quantity ?? stock}×, for a total of{" "} each for {negotiation?.quantity ?? stock}×, for a total of{" "}
<span className="font-mono"> <span className="font-mono">
{fmt((negotiation?.amount ?? 0) * (negotiation?.quantity ?? stock))} <CurrencyAmount value={(negotiation?.amount ?? 0) * (negotiation?.quantity ?? stock)} currency={currencyConfig} />
</span> </span>
. .
</p> </p>
@ -330,10 +331,10 @@ export function MakeOfferDialog({ listing, currentUserId, currencyConfig }: Make
Deal closed Deal closed
</Badge> </Badge>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Sold at <span className="font-mono">{fmt(outcome.price)}</span> each for{" "} Sold at <span className="font-mono"><CurrencyAmount value={outcome.price} currency={currencyConfig} /></span> each for{" "}
{negotiation?.quantity ?? stock}×, for a total of{" "} {negotiation?.quantity ?? stock}×, for a total of{" "}
<span className="font-mono"> <span className="font-mono">
{fmt(outcome.price * (negotiation?.quantity ?? stock))} <CurrencyAmount value={outcome.price * (negotiation?.quantity ?? stock)} currency={currencyConfig} />
</span> </span>
. The items are in your locker. . The items are in your locker.
</p> </p>

View file

@ -14,7 +14,8 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { currencyLabel, formatAmount, type CurrencyConfig } from "@/lib/banking/format"; import { currencyLabel, type CurrencyConfig } from "@/lib/banking/format";
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
import { buildChatBubbles } from "@/lib/market/chatBubbles"; import { buildChatBubbles } from "@/lib/market/chatBubbles";
import { ChatBubbleList } from "@/components/frontend/market/ChatBubbleList"; import { ChatBubbleList } from "@/components/frontend/market/ChatBubbleList";
import { ChevronDownIcon, HistoryIcon } from "lucide-react"; import { ChevronDownIcon, HistoryIcon } from "lucide-react";
@ -79,7 +80,7 @@ function HistoryRow({
}) { }) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const listing = listingOf(negotiation); const listing = listingOf(negotiation);
const fmt = (n: number) => formatAmount(n, currencyConfig); const fmt = (n: number) => <CurrencyAmount value={n} currency={currencyConfig} />;
const buyerLabel = role === "buyer" ? "You" : counterpartyLabel(negotiation, "buyer"); const buyerLabel = role === "buyer" ? "You" : counterpartyLabel(negotiation, "buyer");
const sellerLabel = role === "seller" ? "You" : counterpartyLabel(negotiation, "seller"); const sellerLabel = role === "seller" ? "You" : counterpartyLabel(negotiation, "seller");

View file

@ -7,6 +7,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format"; import { formatAmount, type CurrencyConfig } from "@/lib/banking/format";
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
import { import {
acceptMarketOffer, acceptMarketOffer,
cancelMarketOffer, cancelMarketOffer,
@ -131,7 +132,7 @@ function NegotiationRow({
</div> </div>
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<span className="font-mono">{fmt(negotiation.amount ?? 0)}</span> <span className="font-mono"><CurrencyAmount value={negotiation.amount ?? 0} currency={currencyConfig} /></span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{negotiation.quantity ? `${negotiation.quantity}× · ` : ""} {negotiation.quantity ? `${negotiation.quantity}× · ` : ""}
{theirProposal {theirProposal
@ -143,7 +144,7 @@ function NegotiationRow({
<> <>
{" · total "} {" · total "}
<span className="font-mono"> <span className="font-mono">
{fmt((negotiation.amount ?? 0) * negotiation.quantity)} <CurrencyAmount value={(negotiation.amount ?? 0) * negotiation.quantity} currency={currencyConfig} />
</span> </span>
</> </>
) : null} ) : null}

View file

@ -14,7 +14,7 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { formatAmount } from "@/lib/banking/format"; import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
interface NpcProfileProps { interface NpcProfileProps {
readonly npc: GameNpc; readonly npc: GameNpc;
@ -183,7 +183,7 @@ export function NpcProfile({ npc, listings }: NpcProfileProps) {
</p> </p>
</div> </div>
<span className="shrink-0 text-sm font-semibold tabular-nums"> <span className="shrink-0 text-sm font-semibold tabular-nums">
{formatAmount(listing.price, listing.currency)} <CurrencyAmount value={listing.price} currency={listing.currency} />
</span> </span>
</div> </div>
</Fragment> </Fragment>

View file

@ -14,9 +14,9 @@ export interface CurrencyConfig {
readonly resourceName?: string | null; readonly resourceName?: string | null;
} }
type CurrencyDisplay = CurrencyLike | CurrencyConfig | number | null; export type CurrencyDisplay = CurrencyLike | CurrencyConfig | number | null;
function isCurrencyConfig(currency: CurrencyLike | CurrencyConfig): currency is CurrencyConfig { export function isCurrencyConfig(currency: CurrencyLike | CurrencyConfig): currency is CurrencyConfig {
return ( return (
"symbol" in currency || "symbol" in currency ||
"singular" in currency || "singular" in currency ||
@ -47,12 +47,7 @@ export function currencyLabel(currency?: CurrencyDisplay, amount = 2): string {
export function formatAmount(amount: number, currency?: CurrencyDisplay): string { export function formatAmount(amount: number, currency?: CurrencyDisplay): string {
const n = amount.toLocaleString(); const n = amount.toLocaleString();
const label = currencyLabel(currency, amount); const label = currencyLabel(currency, amount);
const symbol = return label ? `${n} ${label}` : n;
currency && typeof currency === "object" && isCurrencyConfig(currency)
? currency.symbol?.trim()
: undefined;
const formatted = symbol ? `${symbol}${n}` : n;
return label ? `${formatted} ${label}` : formatted;
} }
export function formatDate(date: string | Date): string { export function formatDate(date: string | Date): string {

View file

@ -51,10 +51,15 @@ export async function getMainCurrencyName(payload: PayloadType): Promise<string
export function currencyConfigFromGameRules(rules: GameRule): CurrencyConfig { export function currencyConfigFromGameRules(rules: GameRule): CurrencyConfig {
const resource = typeof rules.mainCurrency === "object" ? rules.mainCurrency : null; const resource = typeof rules.mainCurrency === "object" ? rules.mainCurrency : null;
// currencySymbol is a media id at depth 0 or a hydrated doc at depth >= 1, so guard the object case before reading .url.
const symbolMedia =
typeof rules.currencySymbol === "object" && rules.currencySymbol !== null
? rules.currencySymbol
: null;
return { return {
name: rules.currencyName?.trim() || null, name: rules.currencyName?.trim() || null,
symbol: rules.currencySymbol?.trim() || null, symbol: symbolMedia?.url ?? null,
singular: rules.currencySingular?.trim() || null, singular: rules.currencySingular?.trim() || null,
plural: rules.currencyPlural?.trim() || null, plural: rules.currencyPlural?.trim() || null,
denomination: rules.currencyDenomination?.trim() || null, denomination: rules.currencyDenomination?.trim() || null,

View file

@ -0,0 +1,17 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "game_rules" DROP COLUMN "currency_symbol";
ALTER TABLE "game_rules" ADD COLUMN "currency_symbol_id" integer;
ALTER TABLE "game_rules" ADD CONSTRAINT "game_rules_currency_symbol_id_media_id_fk" FOREIGN KEY ("currency_symbol_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
CREATE INDEX "game_rules_currency_symbol_idx" ON "game_rules" USING btree ("currency_symbol_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "game_rules" DROP CONSTRAINT "game_rules_currency_symbol_id_media_id_fk";
DROP INDEX "game_rules_currency_symbol_idx";
ALTER TABLE "game_rules" DROP COLUMN "currency_symbol_id";
ALTER TABLE "game_rules" ADD COLUMN "currency_symbol" varchar;`)
}

View file

@ -77,6 +77,11 @@ import * as migration_20260918_194842_add_operation_allocation from './20260918_
import * as migration_20260918_222549_add_operation_readiness from './20260918_222549_add_operation_readiness'; import * as migration_20260918_222549_add_operation_readiness from './20260918_222549_add_operation_readiness';
import * as migration_20260920_044743_add_zone_pressure from './20260920_044743_add_zone_pressure'; import * as migration_20260920_044743_add_zone_pressure from './20260920_044743_add_zone_pressure';
import * as migration_20260921_113443_add_supply_boxes from './20260921_113443_add_supply_boxes'; import * as migration_20260921_113443_add_supply_boxes from './20260921_113443_add_supply_boxes';
import * as migration_20260921_120000_add_world_map_flag from './20260921_120000_add_world_map_flag';
import * as migration_20260921_123000_add_map_intel_fields from './20260921_123000_add_map_intel_fields';
import * as migration_20260921_130500_weather_freeform from './20260921_130500_weather_freeform';
import * as migration_20260921_134000_add_node_icons from './20260921_134000_add_node_icons';
import * as migration_20260921_140000_add_currency_symbol_image from './20260921_140000_add_currency_symbol_image';
export const migrations = [ export const migrations = [
{ {
@ -472,6 +477,31 @@ export const migrations = [
{ {
up: migration_20260921_113443_add_supply_boxes.up, up: migration_20260921_113443_add_supply_boxes.up,
down: migration_20260921_113443_add_supply_boxes.down, down: migration_20260921_113443_add_supply_boxes.down,
name: '20260921_113443_add_supply_boxes' name: '20260921_113443_add_supply_boxes',
},
{
up: migration_20260921_120000_add_world_map_flag.up,
down: migration_20260921_120000_add_world_map_flag.down,
name: '20260921_120000_add_world_map_flag',
},
{
up: migration_20260921_123000_add_map_intel_fields.up,
down: migration_20260921_123000_add_map_intel_fields.down,
name: '20260921_123000_add_map_intel_fields',
},
{
up: migration_20260921_130500_weather_freeform.up,
down: migration_20260921_130500_weather_freeform.down,
name: '20260921_130500_weather_freeform',
},
{
up: migration_20260921_134000_add_node_icons.up,
down: migration_20260921_134000_add_node_icons.down,
name: '20260921_134000_add_node_icons',
},
{
up: migration_20260921_140000_add_currency_symbol_image.up,
down: migration_20260921_140000_add_currency_symbol_image.down,
name: '20260921_140000_add_currency_symbol_image',
}, },
]; ];