polyglot-alpha / ui /components /operators /OperatorCard.tsx
licaomeng
deploy: main@8970ffb → HF Spaces (2026-05-27T05:19Z)
88d2f2a
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Cpu, Wallet, Trophy, TrendingUp } from "lucide-react";
import {
shortAddr,
formatReputation,
formatUsd,
formatWinsBids,
} from "@/lib/utils";
import { WinsBidsInfo } from "@/components/reputation/WinsBidsInfo";
import { ClaimFeesButton } from "./ClaimFeesButton";
import { WithdrawStakeButton } from "./WithdrawStakeButton";
/**
* Single operator card for the /operators marketplace listing.
*
* Displays the agent's display name + underlying model, wallet address,
* wins-over-bids ratio (primary signal, W14-D), total builder-fee earnings,
* and the raw on-chain EMA reputation under an "advanced" detail row. The
* `kind` prop distinguishes the 3 in-house reference seeders from external
* marketplace participants (currently 0 of them).
*
* `reputation`, `wins`, `totalBids`, and `totalFees` are sourced live from
* the backend `/leaderboard` endpoint (joined by wallet address). When the
* live value is not yet available — backend still warming up, or the agent
* has not appeared on the leaderboard yet — the field renders as "—"
* rather than a fabricated number. See `ui/app/operators/page.tsx` for
* the wiring.
*
* The on-chain EMA reputation is intentionally relegated to a secondary
* row: the `ReputationRegistry.sol` `_fillSignal` has a known unit-scale
* bug (W14-C) that pins the EMA at its 0.5 floor for realistic fees, so
* leading with the wins/bids count gives operators an unambiguous metric
* until the contract upgrade lands.
*/
export interface OperatorCardData {
name: string;
model: string;
address: string;
reputation?: number;
wins?: number;
totalBids?: number;
totalFees?: number;
kind: "reference" | "external";
}
const UNKNOWN_PLACEHOLDER = "—";
export function OperatorCard({
operator,
showClaimFees = false,
claimMode = "mock",
}: {
operator: OperatorCardData;
/** When true, render an inline "Claim Fees" button for this operator. */
showClaimFees?: boolean;
/** Mock mode is the default — see ClaimFeesButton for semantics. */
claimMode?: "mock" | "live";
}) {
const isReference = operator.kind === "reference";
return (
<Card
className={
isReference
? "border-primary/30 bg-primary/[0.03]"
: "border-emerald-500/30 bg-emerald-500/[0.03]"
}
>
<CardContent className="space-y-3 p-5">
<div className="flex items-start justify-between gap-2">
<div className="space-y-1">
<div className="flex items-center gap-1.5">
<Cpu className="h-3.5 w-3.5 text-primary" aria-hidden />
<h3 className="text-sm font-semibold leading-tight">
{operator.name}
</h3>
</div>
<p className="font-mono text-[10px] text-muted-foreground">
{operator.model}
</p>
</div>
<Badge variant={isReference ? "info" : "success"}>
{isReference ? "Reference Seeder" : "External Operator"}
</Badge>
</div>
<div className="flex items-center gap-1.5 rounded-md border border-border/40 bg-muted/20 px-2.5 py-1.5">
<Wallet className="h-3 w-3 text-muted-foreground" aria-hidden />
<code
className="font-mono text-[10px] text-foreground/85"
title={operator.address}
>
{shortAddr(operator.address)}
</code>
<button
type="button"
onClick={() => {
navigator.clipboard?.writeText(operator.address).catch(() => {});
}}
aria-label={`Copy address ${operator.address}`}
className="ml-auto rounded px-1 text-[10px] text-muted-foreground transition-colors hover:bg-accent/10 hover:text-foreground"
>
copy
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="rounded-md border border-border/40 bg-background/40 p-2">
<p className="flex items-center gap-1 text-[9px] uppercase tracking-wider text-muted-foreground">
<Trophy className="h-2.5 w-2.5" aria-hidden /> Wins / Bids
<WinsBidsInfo
className="ml-auto"
ariaLabel={`Why wins / bids for ${operator.name}?`}
/>
</p>
<p
className="font-mono text-sm font-semibold text-foreground"
data-testid="operator-wins-bids"
title={
typeof operator.wins === "number" &&
typeof operator.totalBids === "number"
? `${operator.wins} wins out of ${operator.totalBids} bids entered`
: undefined
}
>
{typeof operator.wins === "number" &&
typeof operator.totalBids === "number"
? formatWinsBids(operator.wins, operator.totalBids)
: UNKNOWN_PLACEHOLDER}
</p>
</div>
<div className="rounded-md border border-border/40 bg-background/40 p-2">
<p className="flex items-center gap-1 text-[9px] uppercase tracking-wider text-muted-foreground">
<TrendingUp className="h-2.5 w-2.5" aria-hidden /> Fees
</p>
<p className="font-mono text-sm font-semibold text-emerald-300">
{typeof operator.totalFees === "number"
? formatUsd(operator.totalFees)
: UNKNOWN_PLACEHOLDER}
</p>
</div>
</div>
<div
className="flex items-center justify-between gap-2 rounded-md border border-dashed border-border/30 bg-muted/[0.04] px-2 py-1"
title="On-chain EMA reputation — calibrating in next contract upgrade (see ReputationRegistry.sol)"
>
<p className="text-[9px] uppercase tracking-wider text-muted-foreground/80">
On-chain EMA <span className="text-muted-foreground/60">(adv.)</span>
</p>
<p className="font-mono text-[11px] text-muted-foreground">
{typeof operator.reputation === "number"
? formatReputation(operator.reputation, { rawDecimal: true })
: UNKNOWN_PLACEHOLDER}
</p>
</div>
{showClaimFees ? (
<div className="space-y-3 border-t border-border/40 pt-3">
<ClaimFeesButton
address={operator.address}
mode={claimMode}
initialPendingUsdc={
typeof operator.totalFees === "number"
? operator.totalFees
: undefined
}
/>
<WithdrawStakeButton
address={operator.address}
mode={claimMode}
/>
</div>
) : null}
</CardContent>
</Card>
);
}