Skip to content

Commit 0476a7d

Browse files
Surface tier progress + wizard tier hints (#14)
feat(web): surface tier progress + wizard tier hints - add tierProgress DTO + helper - render tier progress in Profile (current/next/requirements) - show required tier hint in proposal wizard - add unit tests for tier helpers + TierLabel; stabilize test JSX runtime
1 parent 46e5f9a commit 0476a7d

File tree

12 files changed

+261
-4
lines changed

12 files changed

+261
-4
lines changed

rstest.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { defineConfig } from "@rstest/core";
22
import type { RsbuildPlugin } from "@rsbuild/core";
3+
import { pluginReact } from "@rsbuild/plugin-react";
34

45
const rstestServerPlugin = (): RsbuildPlugin => ({
56
name: "rstest:server-host",
@@ -21,5 +22,5 @@ export default defineConfig({
2122
testMatch: ["tests/**/*.test.js"],
2223
environment: "node",
2324
browser: { enabled: false },
24-
plugins: [rstestServerPlugin()],
25+
plugins: [pluginReact(), rstestServerPlugin()],
2526
});

src/components/Hint.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useMemo, useRef, useState } from "react";
1+
import React, { useEffect, useMemo, useRef, useState } from "react";
22
import { useNavigate } from "react-router";
33
import {
44
Card,

src/components/TierLabel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ReactNode } from "react";
1+
import React, { type ReactNode } from "react";
22

33
import { HintLabel } from "@/components/Hint";
44

src/lib/proposalTypes.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,33 @@ export const proposalTypeLabel: Record<string, string> = {
77
"dao-core": "DAO core",
88
};
99

10+
const proposalTypeRequiredTier: Record<string, string> = {
11+
basic: "Nominee",
12+
fee: "Ecclesiast",
13+
monetary: "Ecclesiast",
14+
core: "Legate",
15+
administrative: "Consul",
16+
"dao-core": "Citizen",
17+
};
18+
19+
const tierOrder = ["Nominee", "Ecclesiast", "Legate", "Consul", "Citizen"];
20+
1021
export function formatProposalType(value: string): string {
1122
return proposalTypeLabel[value] ?? value.replace(/-/g, " ");
1223
}
24+
25+
export function requiredTierForProposalType(value: string): string {
26+
return proposalTypeRequiredTier[value] ?? "Nominee";
27+
}
28+
29+
export function isTierEligible(
30+
currentTier: string,
31+
requiredTier: string,
32+
): boolean {
33+
const normalizedCurrent = currentTier.trim();
34+
const normalizedRequired = requiredTier.trim();
35+
const currentIdx = tierOrder.indexOf(normalizedCurrent);
36+
const requiredIdx = tierOrder.indexOf(normalizedRequired);
37+
if (currentIdx < 0 || requiredIdx < 0) return false;
38+
return currentIdx >= requiredIdx;
39+
}

src/lib/tierProgress.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { TierProgressDto } from "@/types/api";
2+
3+
export type TierRequirementItem = {
4+
key: keyof TierProgressDto["metrics"];
5+
label: string;
6+
done: number;
7+
required: number;
8+
percent: number;
9+
};
10+
11+
const requirementLabel: Record<keyof TierProgressDto["metrics"], string> = {
12+
governorEras: "Governor eras",
13+
activeEras: "Active-governor eras",
14+
acceptedProposals: "Accepted proposals",
15+
formationParticipation: "Formation participation",
16+
};
17+
18+
export function buildTierRequirementItems(
19+
tierProgress?: TierProgressDto | null,
20+
): TierRequirementItem[] {
21+
if (!tierProgress?.requirements) return [];
22+
const metrics = tierProgress.metrics ?? {
23+
governorEras: 0,
24+
activeEras: 0,
25+
acceptedProposals: 0,
26+
formationParticipation: 0,
27+
};
28+
const keys = Object.keys(tierProgress.requirements) as Array<
29+
keyof TierProgressDto["metrics"]
30+
>;
31+
return keys.map((key) => {
32+
const required = Number(tierProgress.requirements?.[key] ?? 0);
33+
const done = Number(metrics[key] ?? 0);
34+
const percent =
35+
required > 0 ? Math.min(100, Math.round((done / required) * 100)) : 100;
36+
return { key, label: requirementLabel[key], done, required, percent };
37+
});
38+
}

src/pages/profile/Profile.tsx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { ToggleGroup } from "@/components/ToggleGroup";
1717
import { apiHuman, apiHumans } from "@/lib/apiClient";
1818
import type { HumanNodeProfileDto, ProofKeyDto } from "@/types/api";
1919
import { useAuth } from "@/app/auth/AuthContext";
20+
import { buildTierRequirementItems } from "@/lib/tierProgress";
2021

2122
const Profile: React.FC = () => {
2223
const auth = useAuth();
@@ -92,6 +93,9 @@ const Profile: React.FC = () => {
9293
const activeSection =
9394
profile && activeProof ? profile.proofSections[activeProof] : null;
9495

96+
const tierProgress = profile?.tierProgress ?? null;
97+
const requirementItems = buildTierRequirementItems(tierProgress);
98+
9599
return (
96100
<div className="flex flex-col gap-6">
97101
<PageHint pageId="profile" />
@@ -252,6 +256,65 @@ const Profile: React.FC = () => {
252256
</div>
253257

254258
<div className="flex flex-col gap-4">
259+
{tierProgress ? (
260+
<Card>
261+
<CardHeader className="pb-2">
262+
<CardTitle>Tier progress</CardTitle>
263+
</CardHeader>
264+
<CardContent className="space-y-4">
265+
<div className="grid gap-3 sm:grid-cols-2">
266+
<Surface
267+
variant="panelAlt"
268+
radius="2xl"
269+
shadow="tile"
270+
className="flex h-full flex-col items-center justify-center px-4 py-4 text-center"
271+
>
272+
<Kicker align="center">Current tier</Kicker>
273+
<p className="text-xl font-semibold text-text">
274+
<TierLabel tier={tierProgress.tier} />
275+
</p>
276+
</Surface>
277+
<Surface
278+
variant="panelAlt"
279+
radius="2xl"
280+
shadow="tile"
281+
className="flex h-full flex-col items-center justify-center px-4 py-4 text-center"
282+
>
283+
<Kicker align="center">Next tier</Kicker>
284+
<p className="text-xl font-semibold text-text">
285+
{tierProgress.nextTier ? (
286+
<TierLabel tier={tierProgress.nextTier} />
287+
) : (
288+
"Max tier"
289+
)}
290+
</p>
291+
</Surface>
292+
</div>
293+
{requirementItems.length > 0 ? (
294+
<div className="grid gap-3 text-center sm:grid-cols-2">
295+
{requirementItems.map((item) => (
296+
<div
297+
key={item.key}
298+
className="flex h-24 flex-col items-center justify-between rounded-xl border border-border px-3 py-3"
299+
>
300+
<Kicker align="center">{item.label}</Kicker>
301+
<p className="text-base font-semibold text-text">
302+
{item.done} / {item.required}
303+
</p>
304+
<p className="text-xs text-muted">
305+
{item.percent}% complete
306+
</p>
307+
</div>
308+
))}
309+
</div>
310+
) : (
311+
<p className="text-sm text-muted">
312+
You have reached the highest available tier.
313+
</p>
314+
)}
315+
</CardContent>
316+
</Card>
317+
) : null}
255318
<Card>
256319
<CardHeader className="pb-2">
257320
<CardTitle>Details</CardTitle>

src/pages/proposals/ProposalCreation.tsx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,18 @@ import { PageHint } from "@/components/PageHint";
1212
import { SIM_AUTH_ENABLED } from "@/lib/featureFlags";
1313
import { useAuth } from "@/app/auth/AuthContext";
1414
import { formatProposalSubmitError } from "@/lib/proposalSubmitErrors";
15+
import {
16+
requiredTierForProposalType,
17+
isTierEligible,
18+
} from "@/lib/proposalTypes";
1519
import {
1620
apiChambers,
21+
apiMyGovernance,
1722
apiProposalDraftDelete,
1823
apiProposalDraftSave,
1924
apiProposalSubmitToPool,
2025
} from "@/lib/apiClient";
21-
import type { ChamberDto } from "@/types/api";
26+
import type { ChamberDto, TierProgressDto } from "@/types/api";
2227
import { BudgetStep } from "./proposalCreation/steps/BudgetStep";
2328
import { EssentialsStep } from "./proposalCreation/steps/EssentialsStep";
2429
import { PlanStep } from "./proposalCreation/steps/PlanStep";
@@ -63,6 +68,9 @@ const ProposalCreation: React.FC = () => {
6368
const [submitting, setSubmitting] = useState(false);
6469
const [submitError, setSubmitError] = useState<string | null>(null);
6570
const [chambers, setChambers] = useState<ChamberDto[]>([]);
71+
const [tierProgress, setTierProgress] = useState<TierProgressDto | null>(
72+
null,
73+
);
6674

6775
useEffect(() => {
6876
const handle = window.setTimeout(() => {
@@ -133,6 +141,27 @@ const ProposalCreation: React.FC = () => {
133141
};
134142
}, []);
135143

144+
useEffect(() => {
145+
if (!auth.enabled || !auth.authenticated) {
146+
setTierProgress(null);
147+
return;
148+
}
149+
let active = true;
150+
(async () => {
151+
try {
152+
const res = await apiMyGovernance();
153+
if (!active) return;
154+
setTierProgress(res.tier ?? null);
155+
} catch {
156+
if (!active) return;
157+
setTierProgress(null);
158+
}
159+
})();
160+
return () => {
161+
active = false;
162+
};
163+
}, [auth.authenticated, auth.enabled]);
164+
136165
useEffect(() => {
137166
if (searchParams.get("step") === step) return;
138167
const next = new URLSearchParams(searchParams);
@@ -230,6 +259,11 @@ const ProposalCreation: React.FC = () => {
230259
const canAct = !SIM_AUTH_ENABLED || (auth.authenticated && auth.eligible);
231260
const submitDisabled = !computed.canSubmit || !canAct;
232261

262+
const requiredTier = requiredTierForProposalType(draft.proposalType);
263+
const currentTier = tierProgress?.tier ?? null;
264+
const tierEligible =
265+
currentTier && isTierEligible(currentTier, requiredTier) ? true : false;
266+
233267
return (
234268
<div className="flex flex-col gap-6">
235269
<PageHint pageId="proposals" />
@@ -309,6 +343,9 @@ const ProposalCreation: React.FC = () => {
309343
templateId={template.id}
310344
setTemplateId={setTemplateId}
311345
textareaClassName={textareaClassName}
346+
requiredTier={requiredTier}
347+
currentTier={currentTier}
348+
tierEligible={tierEligible}
312349
/>
313350
) : null}
314351

src/pages/proposals/proposalCreation/steps/EssentialsStep.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type React from "react";
22
import { Input } from "@/components/primitives/input";
33
import { Label } from "@/components/primitives/label";
44
import { Select } from "@/components/primitives/select";
5+
import { TierLabel } from "@/components/TierLabel";
56
import type { ProposalDraftForm } from "../types";
67
import {
78
SYSTEM_ACTIONS,
@@ -56,6 +57,9 @@ export function EssentialsStep(props: {
5657
templateId: "project" | "system";
5758
setTemplateId: (templateId: "project" | "system") => void;
5859
textareaClassName: string;
60+
requiredTier: string;
61+
currentTier: string | null;
62+
tierEligible: boolean;
5963
}) {
6064
const {
6165
attemptedNext,
@@ -65,6 +69,9 @@ export function EssentialsStep(props: {
6569
templateId,
6670
setTemplateId,
6771
textareaClassName,
72+
requiredTier,
73+
currentTier,
74+
tierEligible,
6875
} = props;
6976

7077
const isSystemProposal = templateId === "system";
@@ -147,6 +154,19 @@ export function EssentialsStep(props: {
147154
: PROPOSAL_TYPE_OPTIONS.find(
148155
(option) => option.value === draft.proposalType,
149156
)?.helper}
157+
<span className="mt-1 block">
158+
Required tier: <TierLabel tier={requiredTier} />.
159+
{currentTier ? (
160+
<span
161+
className={tierEligible ? "text-muted" : "text-destructive"}
162+
>
163+
{" "}
164+
Your tier: <TierLabel tier={currentTier} />.
165+
</span>
166+
) : (
167+
<span> Connect a wallet to verify eligibility.</span>
168+
)}
169+
</span>
150170
</p>
151171
</div>
152172

src/types/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,7 @@ export type HumanNodeProfileDto = {
485485
projects: ProjectCardDto[];
486486
activity: HistoryItemDto[];
487487
history: string[];
488+
tierProgress?: TierProgressDto;
488489
};
489490

490491
export type FeedStageDatumDto = {

tests/unit/proposal-types.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { test, expect } from "@rstest/core";
2+
3+
import {
4+
requiredTierForProposalType,
5+
isTierEligible,
6+
} from "../../src/lib/proposalTypes";
7+
8+
test("requiredTierForProposalType returns expected tiers", () => {
9+
expect(requiredTierForProposalType("basic")).toBe("Nominee");
10+
expect(requiredTierForProposalType("fee")).toBe("Ecclesiast");
11+
expect(requiredTierForProposalType("core")).toBe("Legate");
12+
expect(requiredTierForProposalType("administrative")).toBe("Consul");
13+
expect(requiredTierForProposalType("dao-core")).toBe("Citizen");
14+
});
15+
16+
test("isTierEligible compares tier order correctly", () => {
17+
expect(isTierEligible("Consul", "Legate")).toBe(true);
18+
expect(isTierEligible("Ecclesiast", "Consul")).toBe(false);
19+
});

0 commit comments

Comments
 (0)