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>
  );
}