Skip to content

Commit 131b112

Browse files
Surface CM economy across profile, governance, and chambers (#15)
* feat(web): surface CM economy across profile, governance, and chambers - add CM DTOs and api client endpoints - render totals + breakdowns on Profile/My Governance - render chamber CM summary, submissions, and awards - expand api client tests for CM endpoints * CI fixes
1 parent 0476a7d commit 131b112

File tree

6 files changed

+496
-5
lines changed

6 files changed

+496
-5
lines changed

src/lib/apiClient.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import type {
66
ChamberThreadDto,
77
ChamberThreadMessageDto,
88
ChamberChatMessageDto,
9+
ChamberCmDto,
10+
CmSummaryDto,
911
CourtCaseDetailDto,
1012
FactionDto,
1113
FormationProposalPageDto,
@@ -188,6 +190,10 @@ export async function apiChamber(id: string): Promise<GetChamberResponse> {
188190
return await apiGet<GetChamberResponse>(`/api/chambers/${id}`);
189191
}
190192

193+
export async function apiChamberCm(id: string): Promise<ChamberCmDto> {
194+
return await apiGet<ChamberCmDto>(`/api/chambers/${id}/cm`);
195+
}
196+
191197
export async function apiChamberThreads(
192198
id: string,
193199
): Promise<{ items: ChamberThreadDto[] }> {
@@ -617,6 +623,14 @@ export async function apiMyGovernance(): Promise<GetMyGovernanceResponse> {
617623
return await apiGet<GetMyGovernanceResponse>("/api/my-governance");
618624
}
619625

626+
export async function apiCmMe(): Promise<CmSummaryDto> {
627+
return await apiGet<CmSummaryDto>("/api/cm/me");
628+
}
629+
630+
export async function apiCmAddress(address: string): Promise<CmSummaryDto> {
631+
return await apiGet<CmSummaryDto>(`/api/cm/${address}`);
632+
}
633+
620634
export async function apiClock(): Promise<GetClockResponse> {
621635
return await apiGet<GetClockResponse>("/api/clock");
622636
}

src/pages/MyGovernance.tsx

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,12 @@ import { StatGrid, makeChamberStats } from "@/components/StatGrid";
1717
import { Surface } from "@/components/Surface";
1818
import { PageHint } from "@/components/PageHint";
1919
import { Kicker } from "@/components/Kicker";
20-
import { apiChambers, apiMyGovernance } from "@/lib/apiClient";
21-
import type { ChamberDto, GetMyGovernanceResponse } from "@/types/api";
20+
import { apiChambers, apiCmMe, apiMyGovernance } from "@/lib/apiClient";
21+
import type {
22+
ChamberDto,
23+
CmSummaryDto,
24+
GetMyGovernanceResponse,
25+
} from "@/types/api";
2226
import { cn } from "@/lib/utils";
2327

2428
type GoverningStatus =
@@ -133,6 +137,7 @@ const governingStatusTermId = (label: GoverningStatus): string => {
133137
const MyGovernance: React.FC = () => {
134138
const [gov, setGov] = useState<GetMyGovernanceResponse | null>(null);
135139
const [chambers, setChambers] = useState<ChamberDto[] | null>(null);
140+
const [cmSummary, setCmSummary] = useState<CmSummaryDto | null>(null);
136141
const [loadError, setLoadError] = useState<string | null>(null);
137142

138143
useEffect(() => {
@@ -143,14 +148,17 @@ const MyGovernance: React.FC = () => {
143148
apiMyGovernance(),
144149
apiChambers(),
145150
]);
151+
const cmRes = await apiCmMe().catch(() => null);
146152
if (!active) return;
147153
setGov(govRes);
148154
setChambers(chambersRes.items);
155+
setCmSummary(cmRes);
149156
setLoadError(null);
150157
} catch (error) {
151158
if (!active) return;
152159
setGov(null);
153160
setChambers(null);
161+
setCmSummary(null);
154162
setLoadError((error as Error).message);
155163
}
156164
})();
@@ -523,6 +531,83 @@ const MyGovernance: React.FC = () => {
523531
</section>
524532
</CardContent>
525533
</Card>
534+
535+
<Card>
536+
<CardHeader className="pb-2">
537+
<CardTitle>CM economy</CardTitle>
538+
</CardHeader>
539+
<CardContent className="space-y-4">
540+
{!cmSummary ? (
541+
<Surface
542+
variant="panelAlt"
543+
radius="2xl"
544+
shadow="tile"
545+
className="px-4 py-3 text-sm text-muted"
546+
>
547+
CM summary unavailable.
548+
</Surface>
549+
) : (
550+
<>
551+
<div className="grid gap-3 sm:grid-cols-3">
552+
{[
553+
{ label: "LCM", value: cmSummary.totals.lcm },
554+
{ label: "MCM", value: cmSummary.totals.mcm },
555+
{ label: "ACM", value: cmSummary.totals.acm },
556+
].map((tile) => (
557+
<Surface
558+
key={tile.label}
559+
variant="panelAlt"
560+
radius="xl"
561+
shadow="tile"
562+
className="px-4 py-3 text-center"
563+
>
564+
<Kicker align="center">{tile.label}</Kicker>
565+
<p className="text-xl font-semibold text-text">
566+
{tile.value.toLocaleString()}
567+
</p>
568+
</Surface>
569+
))}
570+
</div>
571+
<div className="grid gap-3 md:grid-cols-2">
572+
{cmSummary.chambers.length === 0 ? (
573+
<Surface
574+
variant="panelAlt"
575+
radius="xl"
576+
shadow="tile"
577+
className="px-4 py-3 text-sm text-muted"
578+
>
579+
No CM awards yet.
580+
</Surface>
581+
) : (
582+
cmSummary.chambers.map((chamber) => (
583+
<Surface
584+
key={chamber.chamberId}
585+
variant="panelAlt"
586+
radius="xl"
587+
shadow="tile"
588+
className="space-y-2 px-4 py-3"
589+
>
590+
<div className="flex items-center justify-between">
591+
<p className="text-sm font-semibold text-text">
592+
{chamber.chamberTitle}
593+
</p>
594+
<Badge variant="outline">
595+
M × {chamber.multiplier}
596+
</Badge>
597+
</div>
598+
<div className="grid grid-cols-3 gap-2 text-xs text-muted">
599+
<span>LCM {chamber.lcm}</span>
600+
<span>MCM {chamber.mcm}</span>
601+
<span>ACM {chamber.acm}</span>
602+
</div>
603+
</Surface>
604+
))
605+
)}
606+
</div>
607+
</>
608+
)}
609+
</CardContent>
610+
</Card>
526611
</div>
527612
);
528613
};

src/pages/chambers/Chamber.tsx

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type {
2121
ChamberChatPeerDto,
2222
ChamberChatSignalDto,
2323
ChamberChatMessageDto,
24+
ChamberCmDto,
2425
ChamberProposalStageDto,
2526
ChamberThreadDetailDto,
2627
ChamberThreadDto,
@@ -29,6 +30,7 @@ import type {
2930
} from "@/types/api";
3031
import {
3132
apiChamber,
33+
apiChamberCm,
3234
apiChamberChatPresence,
3335
apiChamberChatSignalPoll,
3436
apiChamberChatSignalPost,
@@ -51,6 +53,8 @@ const Chamber: React.FC = () => {
5153

5254
const [data, setData] = useState<GetChamberResponse | null>(null);
5355
const [loadError, setLoadError] = useState<string | null>(null);
56+
const [cmData, setCmData] = useState<ChamberCmDto | null>(null);
57+
const [cmError, setCmError] = useState<string | null>(null);
5458
const [threads, setThreads] = useState<ChamberThreadDto[]>([]);
5559
const [chatLog, setChatLog] = useState<ChamberChatMessageDto[]>([]);
5660
const [threadTitle, setThreadTitle] = useState("");
@@ -95,8 +99,18 @@ const Chamber: React.FC = () => {
9599
apiChamber(id),
96100
apiChambers(),
97101
]);
102+
let nextCm: ChamberCmDto | null = null;
103+
let nextCmError: string | null = null;
104+
try {
105+
nextCm = await apiChamberCm(id);
106+
} catch (error) {
107+
nextCm = null;
108+
nextCmError = (error as Error).message;
109+
}
98110
if (!active) return;
99111
setData(chamberRes);
112+
setCmData(nextCm);
113+
setCmError(nextCmError);
100114
const nextThreads = chamberRes.threads ?? [];
101115
const nextChat = chamberRes.chatLog ?? [];
102116
setThreads(nextThreads);
@@ -111,6 +125,8 @@ const Chamber: React.FC = () => {
111125
} catch (error) {
112126
if (!active) return;
113127
setData(null);
128+
setCmData(null);
129+
setCmError((error as Error).message);
114130
setLoadError((error as Error).message);
115131
}
116132
})();
@@ -638,6 +654,150 @@ const Chamber: React.FC = () => {
638654
</div>
639655
) : null}
640656

657+
{data ? (
658+
<Card>
659+
<CardHeader className="pb-3">
660+
<Kicker>CM economy</Kicker>
661+
<CardTitle>Chamber CM summary</CardTitle>
662+
</CardHeader>
663+
<CardContent className="space-y-4">
664+
{cmData ? (
665+
<>
666+
<div className="grid gap-3 sm:grid-cols-4">
667+
{[
668+
{ label: "LCM", value: cmData.totals.lcm },
669+
{ label: "MCM", value: cmData.totals.mcm },
670+
{ label: "ACM", value: cmData.totals.acm },
671+
{
672+
label: "Avg multiplier",
673+
value:
674+
cmData.avgMultiplier === null
675+
? "—"
676+
: `M × ${cmData.avgMultiplier.toFixed(2)}`,
677+
},
678+
].map((tile) => (
679+
<Surface
680+
key={tile.label}
681+
variant="panelAlt"
682+
radius="xl"
683+
shadow="tile"
684+
className="px-3 py-3 text-center"
685+
>
686+
<Kicker align="center">{tile.label}</Kicker>
687+
<p className="text-base font-semibold text-text">
688+
{typeof tile.value === "number"
689+
? tile.value.toLocaleString()
690+
: tile.value}
691+
</p>
692+
</Surface>
693+
))}
694+
</div>
695+
696+
<div className="grid gap-3 lg:grid-cols-3">
697+
<Surface
698+
variant="panelAlt"
699+
radius="xl"
700+
shadow="tile"
701+
className="px-4 py-3"
702+
>
703+
<Kicker>Top contributors</Kicker>
704+
{cmData.topContributors.length === 0 ? (
705+
<p className="mt-2 text-sm text-muted">
706+
No CM contributions yet.
707+
</p>
708+
) : (
709+
<ul className="mt-2 space-y-2 text-sm text-text">
710+
{cmData.topContributors.slice(0, 5).map((entry) => (
711+
<li
712+
key={entry.address}
713+
className="flex items-center justify-between"
714+
>
715+
<span className="truncate">{entry.address}</span>
716+
<span className="text-xs text-muted">
717+
LCM {entry.lcm} · MCM {entry.mcm} · ACM{" "}
718+
{entry.acm}
719+
</span>
720+
</li>
721+
))}
722+
</ul>
723+
)}
724+
</Surface>
725+
726+
<Surface
727+
variant="panelAlt"
728+
radius="xl"
729+
shadow="tile"
730+
className="px-4 py-3"
731+
>
732+
<Kicker>Multiplier submissions</Kicker>
733+
{cmData.submissions.length === 0 ? (
734+
<p className="mt-2 text-sm text-muted">
735+
No multiplier submissions yet.
736+
</p>
737+
) : (
738+
<ul className="mt-2 space-y-2 text-sm text-text">
739+
{cmData.submissions.slice(0, 5).map((entry) => (
740+
<li
741+
key={`${entry.address}-${entry.submittedAt}`}
742+
className="flex flex-col gap-1"
743+
>
744+
<span className="truncate font-semibold">
745+
{entry.address}
746+
</span>
747+
<span className="text-xs text-muted">
748+
M × {entry.multiplier} ·{" "}
749+
{entry.submittedAt.slice(0, 10)}
750+
</span>
751+
</li>
752+
))}
753+
</ul>
754+
)}
755+
</Surface>
756+
757+
<Surface
758+
variant="panelAlt"
759+
radius="xl"
760+
shadow="tile"
761+
className="px-4 py-3"
762+
>
763+
<Kicker>Recent CM awards</Kicker>
764+
{cmData.history.length === 0 ? (
765+
<p className="mt-2 text-sm text-muted">
766+
No CM awards yet.
767+
</p>
768+
) : (
769+
<ul className="mt-2 space-y-2 text-sm text-text">
770+
{cmData.history.slice(0, 4).map((entry) => (
771+
<li
772+
key={`${entry.proposalId}-${entry.awardedAt}`}
773+
className="flex flex-col gap-1"
774+
>
775+
<span className="font-semibold">{entry.title}</span>
776+
<span className="text-xs text-muted">
777+
M × {entry.multiplier} · LCM {entry.lcm} · MCM{" "}
778+
{entry.mcm}
779+
</span>
780+
</li>
781+
))}
782+
</ul>
783+
)}
784+
</Surface>
785+
</div>
786+
</>
787+
) : (
788+
<Surface
789+
variant="panelAlt"
790+
radius="xl"
791+
borderStyle="dashed"
792+
className="px-4 py-4 text-center text-sm text-muted"
793+
>
794+
{cmError ? `CM summary unavailable: ${cmError}` : "Loading CM…"}
795+
</Surface>
796+
)}
797+
</CardContent>
798+
</Card>
799+
) : null}
800+
641801
<div className="grid gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]">
642802
<Card>
643803
<CardHeader className="flex flex-col gap-4 pb-4">

0 commit comments

Comments
 (0)