diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts index 72d7702..aafab04 100644 --- a/src/lib/apiClient.ts +++ b/src/lib/apiClient.ts @@ -27,23 +27,40 @@ import type { PoolProposalPageDto, } from "@/types/api"; -type ApiError = { - error: { - message: string; +export type ApiErrorPayload = { + error?: { + message?: string; + code?: string; [key: string]: unknown; }; }; +export type ApiError = Error & { + data?: ApiErrorPayload; + status?: number; +}; + +export function getApiErrorPayload(error: unknown): ApiErrorPayload | null { + if (!error || typeof error !== "object") return null; + const data = (error as ApiError).data; + if (!data || typeof data !== "object") return null; + return data as ApiErrorPayload; +} + async function readJsonResponse(res: Response): Promise { const contentType = res.headers.get("content-type") ?? ""; const isJson = contentType.toLowerCase().includes("application/json"); const body = isJson ? ((await res.json()) as unknown) : null; if (!res.ok) { + const payload = (body as ApiErrorPayload | null) ?? null; const message = - (body as ApiError | null)?.error?.message ?? + payload?.error?.message ?? (typeof body === "string" ? body : null) ?? `HTTP ${res.status}`; - throw new Error(message); + const error = new Error(message) as ApiError; + if (payload) error.data = payload; + error.status = res.status; + throw error; } return body as T; } @@ -571,6 +588,13 @@ export type ProposalDraftFormPayload = { what: string; why: string; how: string; + proposalType?: + | "basic" + | "fee" + | "monetary" + | "core" + | "administrative" + | "dao-core"; metaGovernance?: { action: "chamber.create" | "chamber.dissolve"; chamberId: string; diff --git a/src/pages/MyGovernance.tsx b/src/pages/MyGovernance.tsx index e3e04ad..652faeb 100644 --- a/src/pages/MyGovernance.tsx +++ b/src/pages/MyGovernance.tsx @@ -28,6 +28,68 @@ type GoverningStatus = | "At risk" | "Losing status"; +type TierProgress = NonNullable; + +type TierKey = "Nominee" | "Ecclesiast" | "Legate" | "Consul" | "Citizen"; + +const proposalRightsByTier: Record = { + Nominee: ["Basic proposals"], + Ecclesiast: ["Basic proposals", "Fee distribution", "Monetary system"], + Legate: [ + "Basic proposals", + "Fee distribution", + "Monetary system", + "Core infrastructure", + ], + Consul: [ + "Basic proposals", + "Fee distribution", + "Monetary system", + "Core infrastructure", + "Administrative", + ], + Citizen: [ + "Basic proposals", + "Fee distribution", + "Monetary system", + "Core infrastructure", + "Administrative", + "DAO core", + ], +}; + +const labelForTier = (tier: TierKey): string => { + return tier; +}; + +const requirementLabel: Record< + | "governorEras" + | "activeEras" + | "acceptedProposals" + | "formationParticipation", + string +> = { + governorEras: "Run a node as a governor (eras)", + activeEras: "Active-governor eras", + acceptedProposals: "Accepted proposals", + formationParticipation: "Formation participation", +}; + +const getRequirementProgress = ( + key: keyof typeof requirementLabel, + metrics: TierProgress["metrics"], + requirements: TierProgress["requirements"], +): { done: number; required: number; percent: number } => { + const required = Number(requirements?.[key] ?? 0); + const done = Number(metrics[key] ?? 0); + if (required <= 0) return { done, required, percent: 100 }; + return { + done, + required, + percent: Math.min(100, Math.round((done / required) * 100)), + }; +}; + const governingStatusForProgress = ( completed: number, required: number, @@ -113,6 +175,30 @@ const MyGovernance: React.FC = () => { return chambers.filter((chamber) => gov.myChamberIds.includes(chamber.id)); }, [gov, chambers]); + const tierProgress = gov?.tier ?? null; + const currentTier = (tierProgress?.tier as TierKey | undefined) ?? "Nominee"; + const nextTier = (tierProgress?.nextTier as TierKey | null) ?? null; + const requirements = tierProgress?.requirements ?? null; + const metrics = tierProgress?.metrics ?? { + governorEras: 0, + activeEras: 0, + acceptedProposals: 0, + formationParticipation: 0, + }; + const requirementKeys = requirements + ? (Object.keys(requirements) as Array) + : []; + const overallPercent = + requirements && requirementKeys.length > 0 + ? Math.round( + requirementKeys.reduce((sum, key) => { + return ( + sum + getRequirementProgress(key, metrics, requirements).percent + ); + }, 0) / requirementKeys.length, + ) + : 100; + return (
@@ -229,7 +315,7 @@ const MyGovernance: React.FC = () => { > Current tier

- Ecclesiast + {labelForTier(currentTier)}

@@ -237,10 +323,14 @@ const MyGovernance: React.FC = () => {
-

68% to Legate

+

+ {nextTier + ? `${overallPercent}% to ${labelForTier(nextTier)}` + : "Max tier reached"} +

{ > Next tier

- Legate + {nextTier ? labelForTier(nextTier) : "—"}

-
- - Requirements progress -
-
-
-

- Run a node for 1 year -

-

1 Y 204 D

-
-
-
-
-

78%

+ {requirements && requirementKeys.length > 0 ? ( +
+ + Requirements progress +
+ {requirementKeys.map((key) => { + const progress = getRequirementProgress( + key, + metrics, + requirements, + ); + return ( +
+
+

+ {requirementLabel[key]} +

+

+ {progress.done} / {progress.required} +

+
+
+
+
+

+ {progress.percent}% +

+
+ ); + })}
+ -
-
-

- Be an active governor for 1 year -

-

1 Y 2 D

-
-
-
-
-

50%

+ + Eligibility checklist +
+ {requirementKeys.map((key) => { + const progress = getRequirementProgress( + key, + metrics, + requirements, + ); + const ok = + progress.required === 0 || + progress.done >= progress.required; + return ( +
+

+ {requirementLabel[key]} +

+ + {ok ? ( + +
+ ); + })}
-
- - + +
+ ) : ( - Eligibility checklist -
- {[ - { - label: "Have your proposal accepted in Vortex", - ok: true, - }, - { - label: "Participate in a project through Formation", - ok: false, - }, - ].map((row) => ( -
-

- {row.label} -

- - {row.ok ? ( - -
- ))} -
+ You have reached the highest available tier.
-
+ )}
{ className="p-4" >

- Proposals available with{" "} - Ecclesiast + Proposals available with {labelForTier(currentTier)}

    -
  • Basic proposals
  • -
  • Fee distribution
  • -
  • Monetary modification
  • -
-
- -

- Proposals available with{" "} - Legate -

-
    -
  • Core infrastructure changes
  • + {proposalRightsByTier[currentTier].map((item) => ( +
  • {item}
  • + ))}
+ {nextTier ? ( + +

+ Proposals unlocked at {labelForTier(nextTier)} +

+
    + {proposalRightsByTier[nextTier].map((item) => ( +
  • {item}
  • + ))} +
+
+ ) : null}
diff --git a/src/pages/proposals/ProposalCreation.tsx b/src/pages/proposals/ProposalCreation.tsx index e0fc5ff..4ffd5b9 100644 --- a/src/pages/proposals/ProposalCreation.tsx +++ b/src/pages/proposals/ProposalCreation.tsx @@ -16,6 +16,7 @@ import { apiProposalDraftDelete, apiProposalDraftSave, apiProposalSubmitToPool, + getApiErrorPayload, } from "@/lib/apiClient"; import type { ChamberDto } from "@/types/api"; import { BudgetStep } from "./proposalCreation/steps/BudgetStep"; @@ -42,6 +43,54 @@ import { } from "./proposalCreation/types"; import { getWizardTemplate } from "./proposalCreation/templates/registry"; +const proposalTypeLabel: Record = { + basic: "Basic", + fee: "Fee distribution", + monetary: "Monetary system", + core: "Core infrastructure", + administrative: "Administrative", + "dao-core": "DAO core", +}; + +const formatProposalType = (value: string): string => + proposalTypeLabel[value] ?? value.replace(/-/g, " "); + +const formatSubmitError = (error: unknown): string => { + const payload = getApiErrorPayload(error); + const details = payload?.error ?? null; + if (!details) return (error as Error).message ?? "Submit failed."; + + const code = typeof details.code === "string" ? details.code : ""; + if (code === "proposal_type_ineligible" || code === "tier_ineligible") { + const requiredTier = + typeof details.requiredTier === "string" + ? details.requiredTier + : "a higher tier"; + const proposalType = + typeof details.proposalType === "string" + ? formatProposalType(details.proposalType) + : "this"; + return `Not eligible for ${proposalType} proposals. Required tier: ${requiredTier}.`; + } + + if (code === "proposal_submit_ineligible") { + const chamberId = + typeof details.chamberId === "string" ? details.chamberId : ""; + if (chamberId === "general") { + return "General chamber proposals require voting rights in any chamber."; + } + if (chamberId) { + return `Only chamber members can submit to ${formatProposalType(chamberId)}.`; + } + } + + if (code === "draft_not_submittable") { + return "Draft is incomplete. Fill required fields before submitting."; + } + + return details.message ?? (error as Error).message ?? "Submit failed."; +}; + const ProposalCreation: React.FC = () => { const auth = useAuth(); const navigate = useNavigate(); @@ -386,7 +435,7 @@ const ProposalCreation: React.FC = () => { clearDraftStorage(); navigate(`/app/proposals/${res.proposalId}/pp`); } catch (error) { - setSubmitError((error as Error).message); + setSubmitError(formatSubmitError(error)); } finally { setSubmitting(false); } diff --git a/src/pages/proposals/ProposalDraft.tsx b/src/pages/proposals/ProposalDraft.tsx index 0e0ba7f..65647b2 100644 --- a/src/pages/proposals/ProposalDraft.tsx +++ b/src/pages/proposals/ProposalDraft.tsx @@ -17,9 +17,61 @@ import { AttachmentList } from "@/components/AttachmentList"; import { TitledSurface } from "@/components/TitledSurface"; import { SIM_AUTH_ENABLED } from "@/lib/featureFlags"; import { useAuth } from "@/app/auth/AuthContext"; -import { apiProposalDraft, apiProposalSubmitToPool } from "@/lib/apiClient"; +import { + apiProposalDraft, + apiProposalSubmitToPool, + getApiErrorPayload, +} from "@/lib/apiClient"; import type { ProposalDraftDetailDto } from "@/types/api"; +const proposalTypeLabel: Record = { + basic: "Basic", + fee: "Fee distribution", + monetary: "Monetary system", + core: "Core infrastructure", + administrative: "Administrative", + "dao-core": "DAO core", +}; + +const formatProposalType = (value: string): string => + proposalTypeLabel[value] ?? value.replace(/-/g, " "); + +const formatSubmitError = (error: unknown): string => { + const payload = getApiErrorPayload(error); + const details = payload?.error ?? null; + if (!details) return (error as Error).message ?? "Submit failed."; + + const code = typeof details.code === "string" ? details.code : ""; + if (code === "proposal_type_ineligible" || code === "tier_ineligible") { + const requiredTier = + typeof details.requiredTier === "string" + ? details.requiredTier + : "a higher tier"; + const proposalType = + typeof details.proposalType === "string" + ? formatProposalType(details.proposalType) + : "this"; + return `Not eligible for ${proposalType} proposals. Required tier: ${requiredTier}.`; + } + + if (code === "proposal_submit_ineligible") { + const chamberId = + typeof details.chamberId === "string" ? details.chamberId : ""; + if (chamberId === "general") { + return "General chamber proposals require voting rights in any chamber."; + } + if (chamberId) { + return `Only chamber members can submit to ${formatProposalType(chamberId)}.`; + } + } + + if (code === "draft_not_submittable") { + return "Draft is incomplete. Fill required fields before submitting."; + } + + return details.message ?? (error as Error).message ?? "Submit failed."; +}; + const ProposalDraft: React.FC = () => { const auth = useAuth(); const { id } = useParams(); @@ -107,7 +159,7 @@ const ProposalDraft: React.FC = () => { const res = await apiProposalSubmitToPool({ draftId: id }); window.location.href = `/app/proposals/${res.proposalId}/pp`; } catch (error) { - setSubmitError((error as Error).message); + setSubmitError(formatSubmitError(error)); } finally { setSubmitting(false); } diff --git a/src/pages/proposals/proposalCreation/steps/EssentialsStep.tsx b/src/pages/proposals/proposalCreation/steps/EssentialsStep.tsx index 2e58abc..47d7a92 100644 --- a/src/pages/proposals/proposalCreation/steps/EssentialsStep.tsx +++ b/src/pages/proposals/proposalCreation/steps/EssentialsStep.tsx @@ -11,6 +11,43 @@ import { type MetaGovernanceDraft = NonNullable; +const PROPOSAL_TYPE_OPTIONS: Array<{ + value: ProposalDraftForm["proposalType"]; + label: string; + helper: string; +}> = [ + { + value: "basic", + label: "Basic", + helper: "Routine proposals that do not change core system parameters.", + }, + { + value: "fee", + label: "Fee distribution", + helper: "Adjust fee/treasury allocation rules.", + }, + { + value: "monetary", + label: "Monetary system", + helper: "Token issuance, emission, or monetary policy changes.", + }, + { + value: "core", + label: "Core infrastructure", + helper: "Protocol and infrastructure-level changes.", + }, + { + value: "administrative", + label: "Administrative", + helper: "Governance operations (e.g., chamber lifecycle).", + }, + { + value: "dao-core", + label: "DAO core", + helper: "Changes to the governance protocol itself.", + }, +]; + export function EssentialsStep(props: { attemptedNext: boolean; chamberOptions: { value: string; label: string }[]; @@ -61,10 +98,18 @@ export function EssentialsStep(props: { return { ...prev, chamberId: "general", + proposalType: "administrative", metaGovernance: nextMeta, }; } - return { ...prev, metaGovernance: undefined }; + return { + ...prev, + proposalType: + prev.proposalType === "administrative" + ? "basic" + : (prev.proposalType ?? "basic"), + metaGovernance: undefined, + }; }); }} > @@ -77,6 +122,34 @@ export function EssentialsStep(props: {

+
+ + +

+ {isSystemProposal + ? "System proposals are administrative by definition." + : PROPOSAL_TYPE_OPTIONS.find( + (option) => option.value === draft.proposalType, + )?.helper} +

+
+
diff --git a/src/pages/proposals/proposalCreation/steps/ReviewStep.tsx b/src/pages/proposals/proposalCreation/steps/ReviewStep.tsx index d6ccc3f..9f18e86 100644 --- a/src/pages/proposals/proposalCreation/steps/ReviewStep.tsx +++ b/src/pages/proposals/proposalCreation/steps/ReviewStep.tsx @@ -7,6 +7,15 @@ import { newId } from "../ids"; import type { ProposalDraftForm } from "../types"; import type { ChamberDto } from "@/types/api"; +const proposalTypeLabel: Record = { + basic: "Basic", + fee: "Fee distribution", + monetary: "Monetary system", + core: "Core infrastructure", + administrative: "Administrative", + "dao-core": "DAO core", +}; + export function ReviewStep(props: { budgetTotal: number; canAct: boolean; @@ -71,6 +80,9 @@ export function ReviewStep(props: { Chamber: {selectedChamber.name}

) : null} +

+ Proposal type: {proposalTypeLabel[draft.proposalType]} +

{mode === "system" ? ( <> diff --git a/src/pages/proposals/proposalCreation/toApiForm.ts b/src/pages/proposals/proposalCreation/toApiForm.ts index f5a75fe..b004513 100644 --- a/src/pages/proposals/proposalCreation/toApiForm.ts +++ b/src/pages/proposals/proposalCreation/toApiForm.ts @@ -13,6 +13,7 @@ export function draftToApiForm( what: draft.what, why: draft.why, how: draft.how, + proposalType: draft.proposalType, ...(draft.metaGovernance ? { metaGovernance: draft.metaGovernance } : {}), timeline: draft.timeline, outputs: draft.outputs, diff --git a/src/pages/proposals/proposalCreation/types.ts b/src/pages/proposals/proposalCreation/types.ts index 29d6aeb..ded8230 100644 --- a/src/pages/proposals/proposalCreation/types.ts +++ b/src/pages/proposals/proposalCreation/types.ts @@ -25,6 +25,13 @@ export type ProposalDraftForm = { what: string; why: string; how: string; + proposalType: + | "basic" + | "fee" + | "monetary" + | "core" + | "administrative" + | "dao-core"; metaGovernance?: { action: "chamber.create" | "chamber.dissolve"; chamberId: string; @@ -48,6 +55,7 @@ export const DEFAULT_DRAFT: ProposalDraftForm = { what: "", why: "", how: "", + proposalType: "basic", metaGovernance: undefined, timeline: [ { id: "ms-1", title: "Milestone 1", timeframe: "2 weeks" }, diff --git a/src/types/api.ts b/src/types/api.ts index ecd20e9..eb8ec01 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -202,9 +202,26 @@ export type MyGovernanceEraActivityDto = { actions: MyGovernanceEraActionDto[]; timeLeft: string; }; +export type TierProgressDto = { + tier: string; + nextTier: string | null; + metrics: { + governorEras: number; + activeEras: number; + acceptedProposals: number; + formationParticipation: number; + }; + requirements: { + governorEras?: number; + activeEras?: number; + acceptedProposals?: number; + formationParticipation?: number; + } | null; +}; export type GetMyGovernanceResponse = { eraActivity: MyGovernanceEraActivityDto; myChamberIds: string[]; + tier?: TierProgressDto; rollup?: { era: number; rolledAt: string;