Spaces:
Running
Running
| 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> | |
| ); | |
| } | |