Spaces:
Sleeping
Sleeping
File size: 6,938 Bytes
88d2f2a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 | 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>
);
}
|