Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion rstest.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { defineConfig } from "@rstest/core";
import type { RsbuildPlugin } from "@rsbuild/core";
import { pluginReact } from "@rsbuild/plugin-react";

const rstestServerPlugin = (): RsbuildPlugin => ({
name: "rstest:server-host",
Expand All @@ -21,5 +22,5 @@ export default defineConfig({
testMatch: ["tests/**/*.test.js"],
environment: "node",
browser: { enabled: false },
plugins: [rstestServerPlugin()],
plugins: [pluginReact(), rstestServerPlugin()],
});
2 changes: 1 addition & 1 deletion src/components/Hint.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router";
import {
Card,
Expand Down
2 changes: 1 addition & 1 deletion src/components/TierLabel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import React, { type ReactNode } from "react";

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

Expand Down
27 changes: 27 additions & 0 deletions src/lib/proposalTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,33 @@ export const proposalTypeLabel: Record<string, string> = {
"dao-core": "DAO core",
};

const proposalTypeRequiredTier: Record<string, string> = {
basic: "Nominee",
fee: "Ecclesiast",
monetary: "Ecclesiast",
core: "Legate",
administrative: "Consul",
"dao-core": "Citizen",
};

const tierOrder = ["Nominee", "Ecclesiast", "Legate", "Consul", "Citizen"];

export function formatProposalType(value: string): string {
return proposalTypeLabel[value] ?? value.replace(/-/g, " ");
}

export function requiredTierForProposalType(value: string): string {
return proposalTypeRequiredTier[value] ?? "Nominee";
}

export function isTierEligible(
currentTier: string,
requiredTier: string,
): boolean {
const normalizedCurrent = currentTier.trim();
const normalizedRequired = requiredTier.trim();
const currentIdx = tierOrder.indexOf(normalizedCurrent);
const requiredIdx = tierOrder.indexOf(normalizedRequired);
if (currentIdx < 0 || requiredIdx < 0) return false;
return currentIdx >= requiredIdx;
}
38 changes: 38 additions & 0 deletions src/lib/tierProgress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { TierProgressDto } from "@/types/api";

export type TierRequirementItem = {
key: keyof TierProgressDto["metrics"];
label: string;
done: number;
required: number;
percent: number;
};

const requirementLabel: Record<keyof TierProgressDto["metrics"], string> = {
governorEras: "Governor eras",
activeEras: "Active-governor eras",
acceptedProposals: "Accepted proposals",
formationParticipation: "Formation participation",
};

export function buildTierRequirementItems(
tierProgress?: TierProgressDto | null,
): TierRequirementItem[] {
if (!tierProgress?.requirements) return [];
const metrics = tierProgress.metrics ?? {
governorEras: 0,
activeEras: 0,
acceptedProposals: 0,
formationParticipation: 0,
};
const keys = Object.keys(tierProgress.requirements) as Array<
keyof TierProgressDto["metrics"]
>;
return keys.map((key) => {
const required = Number(tierProgress.requirements?.[key] ?? 0);
const done = Number(metrics[key] ?? 0);
const percent =
required > 0 ? Math.min(100, Math.round((done / required) * 100)) : 100;
return { key, label: requirementLabel[key], done, required, percent };
});
}
63 changes: 63 additions & 0 deletions src/pages/profile/Profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { ToggleGroup } from "@/components/ToggleGroup";
import { apiHuman, apiHumans } from "@/lib/apiClient";
import type { HumanNodeProfileDto, ProofKeyDto } from "@/types/api";
import { useAuth } from "@/app/auth/AuthContext";
import { buildTierRequirementItems } from "@/lib/tierProgress";

const Profile: React.FC = () => {
const auth = useAuth();
Expand Down Expand Up @@ -92,6 +93,9 @@ const Profile: React.FC = () => {
const activeSection =
profile && activeProof ? profile.proofSections[activeProof] : null;

const tierProgress = profile?.tierProgress ?? null;
const requirementItems = buildTierRequirementItems(tierProgress);

return (
<div className="flex flex-col gap-6">
<PageHint pageId="profile" />
Expand Down Expand Up @@ -252,6 +256,65 @@ const Profile: React.FC = () => {
</div>

<div className="flex flex-col gap-4">
{tierProgress ? (
<Card>
<CardHeader className="pb-2">
<CardTitle>Tier progress</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<Surface
variant="panelAlt"
radius="2xl"
shadow="tile"
className="flex h-full flex-col items-center justify-center px-4 py-4 text-center"
>
<Kicker align="center">Current tier</Kicker>
<p className="text-xl font-semibold text-text">
<TierLabel tier={tierProgress.tier} />
</p>
</Surface>
<Surface
variant="panelAlt"
radius="2xl"
shadow="tile"
className="flex h-full flex-col items-center justify-center px-4 py-4 text-center"
>
<Kicker align="center">Next tier</Kicker>
<p className="text-xl font-semibold text-text">
{tierProgress.nextTier ? (
<TierLabel tier={tierProgress.nextTier} />
) : (
"Max tier"
)}
</p>
</Surface>
</div>
{requirementItems.length > 0 ? (
<div className="grid gap-3 text-center sm:grid-cols-2">
{requirementItems.map((item) => (
<div
key={item.key}
className="flex h-24 flex-col items-center justify-between rounded-xl border border-border px-3 py-3"
>
<Kicker align="center">{item.label}</Kicker>
<p className="text-base font-semibold text-text">
{item.done} / {item.required}
</p>
<p className="text-xs text-muted">
{item.percent}% complete
</p>
</div>
))}
</div>
) : (
<p className="text-sm text-muted">
You have reached the highest available tier.
</p>
)}
</CardContent>
</Card>
) : null}
<Card>
<CardHeader className="pb-2">
<CardTitle>Details</CardTitle>
Expand Down
39 changes: 38 additions & 1 deletion src/pages/proposals/ProposalCreation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,18 @@ import { PageHint } from "@/components/PageHint";
import { SIM_AUTH_ENABLED } from "@/lib/featureFlags";
import { useAuth } from "@/app/auth/AuthContext";
import { formatProposalSubmitError } from "@/lib/proposalSubmitErrors";
import {
requiredTierForProposalType,
isTierEligible,
} from "@/lib/proposalTypes";
import {
apiChambers,
apiMyGovernance,
apiProposalDraftDelete,
apiProposalDraftSave,
apiProposalSubmitToPool,
} from "@/lib/apiClient";
import type { ChamberDto } from "@/types/api";
import type { ChamberDto, TierProgressDto } from "@/types/api";
import { BudgetStep } from "./proposalCreation/steps/BudgetStep";
import { EssentialsStep } from "./proposalCreation/steps/EssentialsStep";
import { PlanStep } from "./proposalCreation/steps/PlanStep";
Expand Down Expand Up @@ -63,6 +68,9 @@ const ProposalCreation: React.FC = () => {
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [chambers, setChambers] = useState<ChamberDto[]>([]);
const [tierProgress, setTierProgress] = useState<TierProgressDto | null>(
null,
);

useEffect(() => {
const handle = window.setTimeout(() => {
Expand Down Expand Up @@ -133,6 +141,27 @@ const ProposalCreation: React.FC = () => {
};
}, []);

useEffect(() => {
if (!auth.enabled || !auth.authenticated) {
setTierProgress(null);
return;
}
let active = true;
(async () => {
try {
const res = await apiMyGovernance();
if (!active) return;
setTierProgress(res.tier ?? null);
} catch {
if (!active) return;
setTierProgress(null);
}
})();
return () => {
active = false;
};
}, [auth.authenticated, auth.enabled]);

useEffect(() => {
if (searchParams.get("step") === step) return;
const next = new URLSearchParams(searchParams);
Expand Down Expand Up @@ -230,6 +259,11 @@ const ProposalCreation: React.FC = () => {
const canAct = !SIM_AUTH_ENABLED || (auth.authenticated && auth.eligible);
const submitDisabled = !computed.canSubmit || !canAct;

const requiredTier = requiredTierForProposalType(draft.proposalType);
const currentTier = tierProgress?.tier ?? null;
const tierEligible =
currentTier && isTierEligible(currentTier, requiredTier) ? true : false;

return (
<div className="flex flex-col gap-6">
<PageHint pageId="proposals" />
Expand Down Expand Up @@ -309,6 +343,9 @@ const ProposalCreation: React.FC = () => {
templateId={template.id}
setTemplateId={setTemplateId}
textareaClassName={textareaClassName}
requiredTier={requiredTier}
currentTier={currentTier}
tierEligible={tierEligible}
/>
) : null}

Expand Down
20 changes: 20 additions & 0 deletions src/pages/proposals/proposalCreation/steps/EssentialsStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type React from "react";
import { Input } from "@/components/primitives/input";
import { Label } from "@/components/primitives/label";
import { Select } from "@/components/primitives/select";
import { TierLabel } from "@/components/TierLabel";
import type { ProposalDraftForm } from "../types";
import {
SYSTEM_ACTIONS,
Expand Down Expand Up @@ -56,6 +57,9 @@ export function EssentialsStep(props: {
templateId: "project" | "system";
setTemplateId: (templateId: "project" | "system") => void;
textareaClassName: string;
requiredTier: string;
currentTier: string | null;
tierEligible: boolean;
}) {
const {
attemptedNext,
Expand All @@ -65,6 +69,9 @@ export function EssentialsStep(props: {
templateId,
setTemplateId,
textareaClassName,
requiredTier,
currentTier,
tierEligible,
} = props;

const isSystemProposal = templateId === "system";
Expand Down Expand Up @@ -147,6 +154,19 @@ export function EssentialsStep(props: {
: PROPOSAL_TYPE_OPTIONS.find(
(option) => option.value === draft.proposalType,
)?.helper}
<span className="mt-1 block">
Required tier: <TierLabel tier={requiredTier} />.
{currentTier ? (
<span
className={tierEligible ? "text-muted" : "text-destructive"}
>
{" "}
Your tier: <TierLabel tier={currentTier} />.
</span>
) : (
<span> Connect a wallet to verify eligibility.</span>
)}
</span>
</p>
</div>

Expand Down
1 change: 1 addition & 0 deletions src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,7 @@ export type HumanNodeProfileDto = {
projects: ProjectCardDto[];
activity: HistoryItemDto[];
history: string[];
tierProgress?: TierProgressDto;
};

export type FeedStageDatumDto = {
Expand Down
19 changes: 19 additions & 0 deletions tests/unit/proposal-types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { test, expect } from "@rstest/core";

import {
requiredTierForProposalType,
isTierEligible,
} from "../../src/lib/proposalTypes";

test("requiredTierForProposalType returns expected tiers", () => {
expect(requiredTierForProposalType("basic")).toBe("Nominee");
expect(requiredTierForProposalType("fee")).toBe("Ecclesiast");
expect(requiredTierForProposalType("core")).toBe("Legate");
expect(requiredTierForProposalType("administrative")).toBe("Consul");
expect(requiredTierForProposalType("dao-core")).toBe("Citizen");
});

test("isTierEligible compares tier order correctly", () => {
expect(isTierEligible("Consul", "Legate")).toBe(true);
expect(isTierEligible("Ecclesiast", "Consul")).toBe(false);
});
17 changes: 17 additions & 0 deletions tests/unit/tier-label.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { test, expect } from "@rstest/core";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router";
import { createElement } from "react";

import { TierLabel } from "../../src/components/TierLabel";

test("TierLabel renders tier text", () => {
const html = renderToStaticMarkup(
createElement(
MemoryRouter,
null,
createElement(TierLabel, { tier: "Consul" }),
),
);
expect(html).toContain("Consul");
});
34 changes: 34 additions & 0 deletions tests/unit/tier-progress-ui.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { test, expect } from "@rstest/core";

import { buildTierRequirementItems } from "../../src/lib/tierProgress";
import type { TierProgressDto } from "../../src/types/api";

test("buildTierRequirementItems computes progress percentages", () => {
const progress: TierProgressDto = {
tier: "Ecclesiast",
nextTier: "Legate",
metrics: {
governorEras: 10,
activeEras: 8,
acceptedProposals: 1,
formationParticipation: 0,
},
requirements: {
governorEras: 13,
activeEras: 13,
acceptedProposals: 1,
formationParticipation: 1,
},
};

const items = buildTierRequirementItems(progress);
const active = items.find((item) => item.key === "activeEras");
expect(active?.done).toBe(8);
expect(active?.required).toBe(13);
expect(active?.percent).toBe(62);
});

test("buildTierRequirementItems returns empty when no requirements", () => {
const items = buildTierRequirementItems(null);
expect(items).toEqual([]);
});