diff --git a/src/lib/api/command.ts b/src/lib/api/command.ts new file mode 100644 index 0000000..8ddec23 --- /dev/null +++ b/src/lib/api/command.ts @@ -0,0 +1,17 @@ +import { apiPost } from "./http"; + +type CommandInput = { + type: string; + payload: Record; + idempotencyKey?: string; +}; + +export async function apiCommand(input: CommandInput): Promise { + return await apiPost( + "/api/command", + input, + input.idempotencyKey + ? { headers: { "idempotency-key": input.idempotencyKey } } + : undefined, + ); +} diff --git a/src/lib/api/factions.ts b/src/lib/api/factions.ts new file mode 100644 index 0000000..2342f1e --- /dev/null +++ b/src/lib/api/factions.ts @@ -0,0 +1,443 @@ +import type { FactionDto, GetFactionsResponse } from "@/types/api"; +import { apiCommand } from "./command"; +import { apiGet } from "./http"; + +export async function apiFactions(): Promise { + return await apiGet("/api/factions"); +} + +export async function apiFaction(id: string): Promise { + return await apiGet(`/api/factions/${id}`); +} + +export async function apiFactionCreate(input: { + name: string; + description: string; + focus?: string; + visibility?: "public" | "private"; + goals?: string[]; + tags?: string[]; + cofounders?: string[]; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "faction.create"; + faction: { id: string; name: string }; +}> { + return await apiCommand({ + type: "faction.create", + payload: { + name: input.name, + description: input.description, + ...(input.focus ? { focus: input.focus } : {}), + ...(input.visibility ? { visibility: input.visibility } : {}), + ...(input.goals && input.goals.length > 0 ? { goals: input.goals } : {}), + ...(input.tags && input.tags.length > 0 ? { tags: input.tags } : {}), + ...(input.cofounders && input.cofounders.length > 0 + ? { cofounders: input.cofounders } + : {}), + }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiFactionUpdate(input: { + factionId: string; + name?: string; + description?: string; + focus?: string; + visibility?: "public" | "private"; + goals?: string[]; + tags?: string[]; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "faction.update"; + factionId: string; + updated: boolean; +}> { + return await apiCommand({ + type: "faction.update", + payload: { + factionId: input.factionId, + ...(input.name ? { name: input.name } : {}), + ...(input.description ? { description: input.description } : {}), + ...(input.focus ? { focus: input.focus } : {}), + ...(input.visibility ? { visibility: input.visibility } : {}), + ...(input.goals ? { goals: input.goals } : {}), + ...(input.tags ? { tags: input.tags } : {}), + }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiFactionDelete(input: { + factionId: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "faction.delete"; + factionId: string; + status: "archived"; +}> { + return await apiCommand({ + type: "faction.delete", + payload: { + factionId: input.factionId, + }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiFactionMemberRoleSet(input: { + factionId: string; + address: string; + role: "founder" | "steward" | "member"; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "faction.member.role.set"; + factionId: string; + member: { address: string; role: "founder" | "steward" | "member" }; +}> { + return await apiCommand({ + type: "faction.member.role.set", + payload: { + factionId: input.factionId, + address: input.address, + role: input.role, + }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiFactionJoin(input: { + factionId: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "faction.join"; + factionId: string; + joined: boolean; + pending: boolean; +}> { + return await apiCommand({ + type: "faction.join", + payload: { + factionId: input.factionId, + }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiFactionJoinRequestApprove(input: { + factionId: string; + address: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "faction.join.request.approve"; + factionId: string; + address: string; + accepted: true; +}> { + return await apiCommand({ + type: "faction.join.request.approve", + payload: { factionId: input.factionId, address: input.address }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiFactionJoinRequestDecline(input: { + factionId: string; + address: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "faction.join.request.decline"; + factionId: string; + address: string; + declined: true; +}> { + return await apiCommand({ + type: "faction.join.request.decline", + payload: { factionId: input.factionId, address: input.address }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiFactionLeave(input: { + factionId: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "faction.leave"; + factionId: string; +}> { + return await apiCommand({ + type: "faction.leave", + payload: { + factionId: input.factionId, + }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiFactionCofounderInviteAccept(input: { + factionId: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "faction.cofounder.invite.accept"; + factionId: string; + accepted: true; +}> { + return await apiCommand({ + type: "faction.cofounder.invite.accept", + payload: { factionId: input.factionId }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiFactionCofounderInviteDecline(input: { + factionId: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "faction.cofounder.invite.decline"; + factionId: string; + declined: true; +}> { + return await apiCommand({ + type: "faction.cofounder.invite.decline", + payload: { factionId: input.factionId }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiFactionCofounderInviteCancel(input: { + factionId: string; + address: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "faction.cofounder.invite.cancel"; + factionId: string; + address: string; + canceled: true; +}> { + return await apiCommand({ + type: "faction.cofounder.invite.cancel", + payload: { factionId: input.factionId, address: input.address }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiFactionChannelCreate(input: { + factionId: string; + title: string; + slug?: string; + writeScope?: "stewards" | "members"; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "faction.channel.create"; + factionId: string; + channel: { id: string; title: string }; +}> { + return await apiCommand({ + type: "faction.channel.create", + payload: { + factionId: input.factionId, + title: input.title, + ...(input.slug ? { slug: input.slug } : {}), + ...(input.writeScope ? { writeScope: input.writeScope } : {}), + }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiFactionChannelLock(input: { + factionId: string; + channelId: string; + isLocked: boolean; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "faction.channel.lock"; + factionId: string; + channel: { id: string; isLocked: boolean }; +}> { + return await apiCommand({ + type: "faction.channel.lock", + payload: { + factionId: input.factionId, + channelId: input.channelId, + isLocked: input.isLocked, + }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiFactionThreadCreate(input: { + factionId: string; + channelId: string; + title: string; + body: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "faction.thread.create"; + factionId: string; + thread: { id: string; title: string }; +}> { + return await apiCommand({ + type: "faction.thread.create", + payload: { + factionId: input.factionId, + channelId: input.channelId, + title: input.title, + body: input.body, + }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiFactionThreadReply(input: { + factionId: string; + threadId: string; + body: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "faction.thread.reply"; + factionId: string; + threadId: string; + messageId: string; +}> { + return await apiCommand({ + type: "faction.thread.reply", + payload: { + factionId: input.factionId, + threadId: input.threadId, + body: input.body, + }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiFactionThreadTransition(input: { + factionId: string; + threadId: string; + status: "open" | "resolved" | "locked"; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "faction.thread.transition"; + factionId: string; + thread: { id: string; status: "open" | "resolved" | "locked" }; +}> { + return await apiCommand({ + type: "faction.thread.transition", + payload: { + factionId: input.factionId, + threadId: input.threadId, + status: input.status, + }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiFactionThreadDelete(input: { + factionId: string; + threadId: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "faction.thread.delete"; + factionId: string; + threadId: string; + deleted: true; +}> { + return await apiCommand({ + type: "faction.thread.delete", + payload: { + factionId: input.factionId, + threadId: input.threadId, + }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiFactionThreadReplyDelete(input: { + factionId: string; + threadId: string; + messageId: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "faction.thread.reply.delete"; + factionId: string; + threadId: string; + messageId: string; + deleted: true; +}> { + return await apiCommand({ + type: "faction.thread.reply.delete", + payload: { + factionId: input.factionId, + threadId: input.threadId, + messageId: input.messageId, + }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiFactionInitiativeCreate(input: { + factionId: string; + title: string; + intent?: string; + checklist?: string[]; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "faction.initiative.create"; + factionId: string; + initiative: { id: string; title: string }; +}> { + return await apiCommand({ + type: "faction.initiative.create", + payload: { + factionId: input.factionId, + title: input.title, + ...(input.intent ? { intent: input.intent } : {}), + ...(input.checklist ? { checklist: input.checklist } : {}), + }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiFactionInitiativeTransition(input: { + factionId: string; + initiativeId: string; + status: "draft" | "active" | "blocked" | "done" | "archived"; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "faction.initiative.transition"; + factionId: string; + initiative: { + id: string; + status: "draft" | "active" | "blocked" | "done" | "archived"; + }; +}> { + return await apiCommand({ + type: "faction.initiative.transition", + payload: { + factionId: input.factionId, + initiativeId: input.initiativeId, + status: input.status, + }, + idempotencyKey: input.idempotencyKey, + }); +} diff --git a/src/lib/api/http.ts b/src/lib/api/http.ts new file mode 100644 index 0000000..e175705 --- /dev/null +++ b/src/lib/api/http.ts @@ -0,0 +1,110 @@ +type ApiClientRuntimeConfig = { + apiBaseUrl?: string; + apiHeaders?: Record; + apiCredentials?: RequestCredentials; +}; + +declare global { + interface Window { + __VORTEX_CONFIG__?: ApiClientRuntimeConfig; + } +} + +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; +} + +const envApiBaseUrl = + import.meta.env.RSBUILD_PUBLIC_API_BASE_URL ?? + import.meta.env.VITE_API_BASE_URL ?? + ""; + +function getRuntimeConfig(): ApiClientRuntimeConfig | undefined { + if (typeof window === "undefined") return undefined; + return window.__VORTEX_CONFIG__; +} + +function getApiBaseUrl(): string { + const runtimeConfig = getRuntimeConfig(); + return runtimeConfig?.apiBaseUrl ?? envApiBaseUrl ?? ""; +} + +function getApiCredentials(): RequestCredentials { + const runtimeConfig = getRuntimeConfig(); + return runtimeConfig?.apiCredentials ?? "include"; +} + +function getApiHeaders(): Record { + const runtimeConfig = getRuntimeConfig(); + return runtimeConfig?.apiHeaders ?? {}; +} + +function resolveApiUrl(path: string): string { + if (/^https?:\/\//i.test(path)) return path; + const apiBaseUrl = getApiBaseUrl(); + if (!apiBaseUrl) return path; + const base = apiBaseUrl.replace(/\/$/, ""); + const suffix = path.startsWith("/") ? path : `/${path}`; + return `${base}${suffix}`; +} + +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) : await res.text(); + if (!res.ok) { + const payload = (body as ApiErrorPayload | null) ?? null; + const rawMessage = + payload?.error?.message ?? + (typeof body === "string" && body.trim() ? body : null) ?? + res.statusText; + const message = `HTTP ${res.status}${rawMessage ? `: ${rawMessage}` : ""}`; + const error = new Error(message) as ApiError; + if (payload) error.data = payload; + error.status = res.status; + throw error; + } + return body as T; +} + +export async function apiGet(path: string): Promise { + const res = await fetch(resolveApiUrl(path), { + credentials: getApiCredentials(), + headers: getApiHeaders(), + }); + return await readJsonResponse(res); +} + +export async function apiPost( + path: string, + body: unknown, + init?: { headers?: HeadersInit }, +): Promise { + const res = await fetch(resolveApiUrl(path), { + method: "POST", + credentials: getApiCredentials(), + headers: { + ...getApiHeaders(), + "content-type": "application/json", + ...(init?.headers ?? {}), + }, + body: JSON.stringify(body), + }); + return await readJsonResponse(res); +} diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts index ee722bd..49f772e 100644 --- a/src/lib/apiClient.ts +++ b/src/lib/apiClient.ts @@ -11,10 +11,8 @@ import type { CitizenVetoProposalPageDto, CmSummaryDto, CourtCaseDetailDto, - FactionDto, FormationProposalPageDto, ProposalFinishedPageDto, - GetFactionsResponse, GetChamberResponse, GetChambersResponse, GetClockResponse, @@ -36,117 +34,17 @@ import type { ProposalStatusDto, PoolProposalPageDto, } from "@/types/api"; - -type ApiClientRuntimeConfig = { - apiBaseUrl?: string; - apiHeaders?: Record; - apiCredentials?: RequestCredentials; -}; - -declare global { - interface Window { - __VORTEX_CONFIG__?: ApiClientRuntimeConfig; - } -} - -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; -} - -const envApiBaseUrl = - import.meta.env.RSBUILD_PUBLIC_API_BASE_URL ?? - import.meta.env.VITE_API_BASE_URL ?? - ""; - -function getRuntimeConfig(): ApiClientRuntimeConfig | undefined { - if (typeof window === "undefined") return undefined; - return window.__VORTEX_CONFIG__; -} - -function getApiBaseUrl(): string { - const runtimeConfig = getRuntimeConfig(); - return runtimeConfig?.apiBaseUrl ?? envApiBaseUrl ?? ""; -} - -function getApiCredentials(): RequestCredentials { - const runtimeConfig = getRuntimeConfig(); - return runtimeConfig?.apiCredentials ?? "include"; -} - -function getApiHeaders(): Record { - const runtimeConfig = getRuntimeConfig(); - return runtimeConfig?.apiHeaders ?? {}; -} - -function resolveApiUrl(path: string): string { - if (/^https?:\/\//i.test(path)) return path; - const apiBaseUrl = getApiBaseUrl(); - if (!apiBaseUrl) return path; - const base = apiBaseUrl.replace(/\/$/, ""); - const suffix = path.startsWith("/") ? path : `/${path}`; - return `${base}${suffix}`; -} - -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) : await res.text(); - if (!res.ok) { - const payload = (body as ApiErrorPayload | null) ?? null; - const rawMessage = - payload?.error?.message ?? - (typeof body === "string" && body.trim() ? body : null) ?? - res.statusText; - const message = `HTTP ${res.status}${rawMessage ? `: ${rawMessage}` : ""}`; - const error = new Error(message) as ApiError; - if (payload) error.data = payload; - error.status = res.status; - throw error; - } - return body as T; -} - -export async function apiGet(path: string): Promise { - const res = await fetch(resolveApiUrl(path), { - credentials: getApiCredentials(), - headers: getApiHeaders(), - }); - return await readJsonResponse(res); -} - -export async function apiPost( - path: string, - body: unknown, - init?: { headers?: HeadersInit }, -): Promise { - const res = await fetch(resolveApiUrl(path), { - method: "POST", - credentials: getApiCredentials(), - headers: { - ...getApiHeaders(), - "content-type": "application/json", - ...(init?.headers ?? {}), - }, - body: JSON.stringify(body), - }); - return await readJsonResponse(res); -} +export * from "@/lib/api/factions"; +export { + apiGet, + apiPost, + getApiErrorPayload, + type ApiError, + type ApiErrorPayload, +} from "@/lib/api/http"; + +import { apiGet, apiPost } from "@/lib/api/http"; +import { apiCommand } from "@/lib/api/command"; export type ApiMeResponse = | { authenticated: false } @@ -321,20 +219,14 @@ export async function apiCitizenVetoVote(input: { choice: CitizenVetoVoteChoice; counts: { veto: number; keep: number }; }> { - return await apiPost( - "/api/command", - { - type: "veto.citizen.vote", - payload: { - proposalId: input.proposalId, - choice: input.choice, - }, - idempotencyKey: input.idempotencyKey, + return await apiCommand({ + type: "veto.citizen.vote", + payload: { + proposalId: input.proposalId, + choice: input.choice, }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); + idempotencyKey: input.idempotencyKey, + }); } export type PoolVoteDirection = "up" | "down"; @@ -350,17 +242,11 @@ export async function apiPoolVote(input: { direction: PoolVoteDirection; counts: { upvotes: number; downvotes: number }; }> { - return await apiPost( - "/api/command", - { - type: "pool.vote", - payload: { proposalId: input.proposalId, direction: input.direction }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); + return await apiCommand({ + type: "pool.vote", + payload: { proposalId: input.proposalId, direction: input.direction }, + idempotencyKey: input.idempotencyKey, + }); } export type ChamberVoteChoice = "yes" | "no" | "abstain"; @@ -379,23 +265,17 @@ export async function apiChamberVote(input: { choice: ChamberVoteChoice; counts: { yes: number; no: number; abstain: number }; }> { - return await apiPost( - "/api/command", - { - type: "chamber.vote", - payload: { - proposalId: input.proposalId, - choice: input.choice, - ...(input.choice === "yes" && typeof input.score === "number" - ? { score: input.score } - : {}), - }, - idempotencyKey: input.idempotencyKey, + return await apiCommand({ + type: "chamber.vote", + payload: { + proposalId: input.proposalId, + choice: input.choice, + ...(input.choice === "yes" && typeof input.score === "number" + ? { score: input.score } + : {}), }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); + idempotencyKey: input.idempotencyKey, + }); } export async function apiChamberMultiplierSubmit(input: { @@ -414,20 +294,14 @@ export async function apiChamberMultiplierSubmit(input: { nextMultiplierTimes10: number; } | null; }> { - return await apiPost( - "/api/command", - { - type: "chamber.multiplier.submit", - payload: { - chamberId: input.chamberId, - multiplierTimes10: input.multiplierTimes10, - }, - idempotencyKey: input.idempotencyKey, + return await apiCommand({ + type: "chamber.multiplier.submit", + payload: { + chamberId: input.chamberId, + multiplierTimes10: input.multiplierTimes10, }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); + idempotencyKey: input.idempotencyKey, + }); } export async function apiChamberThreadCreate(input: { @@ -440,21 +314,15 @@ export async function apiChamberThreadCreate(input: { type: "chamber.thread.create"; thread: ChamberThreadDto; }> { - return await apiPost( - "/api/command", - { - type: "chamber.thread.create", - payload: { - chamberId: input.chamberId, - title: input.title, - body: input.body, - }, - idempotencyKey: input.idempotencyKey, + return await apiCommand({ + type: "chamber.thread.create", + payload: { + chamberId: input.chamberId, + title: input.title, + body: input.body, }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); + idempotencyKey: input.idempotencyKey, + }); } export async function apiChamberThreadReply(input: { @@ -469,21 +337,15 @@ export async function apiChamberThreadReply(input: { message: ChamberThreadMessageDto; replies: number; }> { - return await apiPost( - "/api/command", - { - type: "chamber.thread.reply", - payload: { - chamberId: input.chamberId, - threadId: input.threadId, - body: input.body, - }, - idempotencyKey: input.idempotencyKey, + return await apiCommand({ + type: "chamber.thread.reply", + payload: { + chamberId: input.chamberId, + threadId: input.threadId, + body: input.body, }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); + idempotencyKey: input.idempotencyKey, + }); } export async function apiProposalThreadCreate(input: { @@ -498,22 +360,16 @@ export async function apiProposalThreadCreate(input: { proposalId: string; thread: ProposalThreadDto; }> { - return await apiPost( - "/api/command", - { - type: "proposal.thread.create", - payload: { - proposalId: input.proposalId, - category: input.category ?? "general", - title: input.title, - body: input.body, - }, - idempotencyKey: input.idempotencyKey, + return await apiCommand({ + type: "proposal.thread.create", + payload: { + proposalId: input.proposalId, + category: input.category ?? "general", + title: input.title, + body: input.body, }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); + idempotencyKey: input.idempotencyKey, + }); } export async function apiProposalThreadReply(input: { @@ -529,21 +385,15 @@ export async function apiProposalThreadReply(input: { message: ProposalThreadMessageDto; replies: number; }> { - return await apiPost( - "/api/command", - { - type: "proposal.thread.reply", - payload: { - proposalId: input.proposalId, - threadId: input.threadId, - body: input.body, - }, - idempotencyKey: input.idempotencyKey, + return await apiCommand({ + type: "proposal.thread.reply", + payload: { + proposalId: input.proposalId, + threadId: input.threadId, + body: input.body, }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); + idempotencyKey: input.idempotencyKey, + }); } export async function apiProposalThreadTransition(input: { @@ -557,21 +407,15 @@ export async function apiProposalThreadTransition(input: { proposalId: string; thread: { id: string; status: ProposalThreadDto["status"] }; }> { - return await apiPost( - "/api/command", - { - type: "proposal.thread.transition", - payload: { - proposalId: input.proposalId, - threadId: input.threadId, - status: input.status, - }, - idempotencyKey: input.idempotencyKey, + return await apiCommand({ + type: "proposal.thread.transition", + payload: { + proposalId: input.proposalId, + threadId: input.threadId, + status: input.status, }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); + idempotencyKey: input.idempotencyKey, + }); } export async function apiChamberChatPost(input: { @@ -583,20 +427,14 @@ export async function apiChamberChatPost(input: { type: "chamber.chat.post"; message: ChamberChatMessageDto; }> { - return await apiPost( - "/api/command", - { - type: "chamber.chat.post", - payload: { - chamberId: input.chamberId, - message: input.message, - }, - idempotencyKey: input.idempotencyKey, + return await apiCommand({ + type: "chamber.chat.post", + payload: { + chamberId: input.chamberId, + message: input.message, }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); + idempotencyKey: input.idempotencyKey, + }); } export async function apiFormationJoin(input: { @@ -609,20 +447,14 @@ export async function apiFormationJoin(input: { proposalId: string; teamSlots: { filled: number; total: number }; }> { - return await apiPost( - "/api/command", - { - type: "formation.join", - payload: { - proposalId: input.proposalId, - ...(input.role ? { role: input.role } : {}), - }, - idempotencyKey: input.idempotencyKey, + return await apiCommand({ + type: "formation.join", + payload: { + proposalId: input.proposalId, + ...(input.role ? { role: input.role } : {}), }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); + idempotencyKey: input.idempotencyKey, + }); } export async function apiFormationMilestoneSubmit(input: { @@ -637,21 +469,15 @@ export async function apiFormationMilestoneSubmit(input: { milestoneIndex: number; milestones: { completed: number; total: number }; }> { - return await apiPost( - "/api/command", - { - type: "formation.milestone.submit", - payload: { - proposalId: input.proposalId, - milestoneIndex: input.milestoneIndex, - ...(input.note ? { note: input.note } : {}), - }, - idempotencyKey: input.idempotencyKey, + return await apiCommand({ + type: "formation.milestone.submit", + payload: { + proposalId: input.proposalId, + milestoneIndex: input.milestoneIndex, + ...(input.note ? { note: input.note } : {}), }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); + idempotencyKey: input.idempotencyKey, + }); } export async function apiFormationMilestoneRequestUnlock(input: { @@ -665,20 +491,14 @@ export async function apiFormationMilestoneRequestUnlock(input: { milestoneIndex: number; milestones: { completed: number; total: number }; }> { - return await apiPost( - "/api/command", - { - type: "formation.milestone.requestUnlock", - payload: { - proposalId: input.proposalId, - milestoneIndex: input.milestoneIndex, - }, - idempotencyKey: input.idempotencyKey, + return await apiCommand({ + type: "formation.milestone.requestUnlock", + payload: { + proposalId: input.proposalId, + milestoneIndex: input.milestoneIndex, }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); + idempotencyKey: input.idempotencyKey, + }); } export async function apiFormationMilestoneVote(input: { @@ -704,24 +524,18 @@ export async function apiFormationMilestoneVote(input: { pendingMilestoneIndex: number | null; milestones: { completed: number; total: number }; }> { - return await apiPost( - "/api/command", - { - type: "formation.milestone.vote", - payload: { - proposalId: input.proposalId, - milestoneIndex: input.milestoneIndex, - choice: input.choice, - ...(input.choice === "yes" && typeof input.score === "number" - ? { score: input.score } - : {}), - }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); + return await apiCommand({ + type: "formation.milestone.vote", + payload: { + proposalId: input.proposalId, + milestoneIndex: input.milestoneIndex, + choice: input.choice, + ...(input.choice === "yes" && typeof input.score === "number" + ? { score: input.score } + : {}), + }, + idempotencyKey: input.idempotencyKey, + }); } export async function apiFormationProjectFinish(input: { @@ -734,19 +548,13 @@ export async function apiFormationProjectFinish(input: { projectState: "completed"; milestones: { completed: number; total: number }; }> { - return await apiPost( - "/api/command", - { - type: "formation.project.finish", - payload: { - proposalId: input.proposalId, - }, - idempotencyKey: input.idempotencyKey, + return await apiCommand({ + type: "formation.project.finish", + payload: { + proposalId: input.proposalId, }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); + idempotencyKey: input.idempotencyKey, + }); } export async function apiProposalChamberPage( id: string, @@ -790,17 +598,11 @@ export async function apiReferendumVote(input: { counts: { yes: number; no: number; abstain: number }; systemReset?: boolean; }> { - return await apiPost( - "/api/command", - { - type: "referendum.vote", - payload: { proposalId: input.proposalId, choice: input.choice }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); + return await apiCommand({ + type: "referendum.vote", + payload: { proposalId: input.proposalId, choice: input.choice }, + idempotencyKey: input.idempotencyKey, + }); } export async function apiChamberVetoVote(input: { @@ -816,21 +618,15 @@ export async function apiChamberVetoVote(input: { choice: ChamberVetoVoteChoice; counts: { veto: number; keep: number; abstain: number }; }> { - return await apiPost( - "/api/command", - { - type: "veto.chamber.vote", - payload: { - proposalId: input.proposalId, - chamberId: input.chamberId, - choice: input.choice, - }, - idempotencyKey: input.idempotencyKey, + return await apiCommand({ + type: "veto.chamber.vote", + payload: { + proposalId: input.proposalId, + chamberId: input.chamberId, + choice: input.choice, }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); + idempotencyKey: input.idempotencyKey, + }); } export async function apiProposalFormationPage( @@ -865,17 +661,11 @@ export async function apiCourtReport(input: { reports: number; status: "jury" | "live" | "ended"; }> { - return await apiPost( - "/api/command", - { - type: "court.case.report", - payload: { caseId: input.caseId }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); + return await apiCommand({ + type: "court.case.report", + payload: { caseId: input.caseId }, + idempotencyKey: input.idempotencyKey, + }); } export async function apiCourtVerdict(input: { @@ -890,17 +680,11 @@ export async function apiCourtVerdict(input: { status: "jury" | "live" | "ended"; totals: { guilty: number; notGuilty: number }; }> { - return await apiPost( - "/api/command", - { - type: "court.case.verdict", - payload: { caseId: input.caseId, verdict: input.verdict }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); + return await apiCommand({ + type: "court.case.verdict", + payload: { caseId: input.caseId, verdict: input.verdict }, + idempotencyKey: input.idempotencyKey, + }); } export async function apiHumans(): Promise { @@ -911,568 +695,6 @@ export async function apiHuman(id: string): Promise { return await apiGet(`/api/humans/${id}`); } -export async function apiFactions(): Promise { - return await apiGet("/api/factions"); -} - -export async function apiFaction(id: string): Promise { - return await apiGet(`/api/factions/${id}`); -} - -export async function apiFactionCreate(input: { - name: string; - description: string; - focus?: string; - visibility?: "public" | "private"; - goals?: string[]; - tags?: string[]; - cofounders?: string[]; - idempotencyKey?: string; -}): Promise<{ - ok: true; - type: "faction.create"; - faction: { id: string; name: string }; -}> { - return await apiPost( - "/api/command", - { - type: "faction.create", - payload: { - name: input.name, - description: input.description, - ...(input.focus ? { focus: input.focus } : {}), - ...(input.visibility ? { visibility: input.visibility } : {}), - ...(input.goals && input.goals.length > 0 - ? { goals: input.goals } - : {}), - ...(input.tags && input.tags.length > 0 ? { tags: input.tags } : {}), - ...(input.cofounders && input.cofounders.length > 0 - ? { cofounders: input.cofounders } - : {}), - }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); -} - -export async function apiFactionUpdate(input: { - factionId: string; - name?: string; - description?: string; - focus?: string; - visibility?: "public" | "private"; - goals?: string[]; - tags?: string[]; - idempotencyKey?: string; -}): Promise<{ - ok: true; - type: "faction.update"; - factionId: string; - updated: boolean; -}> { - return await apiPost( - "/api/command", - { - type: "faction.update", - payload: { - factionId: input.factionId, - ...(input.name ? { name: input.name } : {}), - ...(input.description ? { description: input.description } : {}), - ...(input.focus ? { focus: input.focus } : {}), - ...(input.visibility ? { visibility: input.visibility } : {}), - ...(input.goals ? { goals: input.goals } : {}), - ...(input.tags ? { tags: input.tags } : {}), - }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); -} - -export async function apiFactionDelete(input: { - factionId: string; - idempotencyKey?: string; -}): Promise<{ - ok: true; - type: "faction.delete"; - factionId: string; - status: "archived"; -}> { - return await apiPost( - "/api/command", - { - type: "faction.delete", - payload: { - factionId: input.factionId, - }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); -} - -export async function apiFactionMemberRoleSet(input: { - factionId: string; - address: string; - role: "founder" | "steward" | "member"; - idempotencyKey?: string; -}): Promise<{ - ok: true; - type: "faction.member.role.set"; - factionId: string; - member: { address: string; role: "founder" | "steward" | "member" }; -}> { - return await apiPost( - "/api/command", - { - type: "faction.member.role.set", - payload: { - factionId: input.factionId, - address: input.address, - role: input.role, - }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); -} - -export async function apiFactionJoin(input: { - factionId: string; - idempotencyKey?: string; -}): Promise<{ - ok: true; - type: "faction.join"; - factionId: string; - joined: boolean; - pending: boolean; -}> { - return await apiPost( - "/api/command", - { - type: "faction.join", - payload: { - factionId: input.factionId, - }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); -} - -export async function apiFactionJoinRequestApprove(input: { - factionId: string; - address: string; - idempotencyKey?: string; -}): Promise<{ - ok: true; - type: "faction.join.request.approve"; - factionId: string; - address: string; - accepted: true; -}> { - return await apiPost( - "/api/command", - { - type: "faction.join.request.approve", - payload: { factionId: input.factionId, address: input.address }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); -} - -export async function apiFactionJoinRequestDecline(input: { - factionId: string; - address: string; - idempotencyKey?: string; -}): Promise<{ - ok: true; - type: "faction.join.request.decline"; - factionId: string; - address: string; - declined: true; -}> { - return await apiPost( - "/api/command", - { - type: "faction.join.request.decline", - payload: { factionId: input.factionId, address: input.address }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); -} - -export async function apiFactionLeave(input: { - factionId: string; - idempotencyKey?: string; -}): Promise<{ - ok: true; - type: "faction.leave"; - factionId: string; -}> { - return await apiPost( - "/api/command", - { - type: "faction.leave", - payload: { - factionId: input.factionId, - }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); -} - -export async function apiFactionCofounderInviteAccept(input: { - factionId: string; - idempotencyKey?: string; -}): Promise<{ - ok: true; - type: "faction.cofounder.invite.accept"; - factionId: string; - accepted: true; -}> { - return await apiPost( - "/api/command", - { - type: "faction.cofounder.invite.accept", - payload: { factionId: input.factionId }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); -} - -export async function apiFactionCofounderInviteDecline(input: { - factionId: string; - idempotencyKey?: string; -}): Promise<{ - ok: true; - type: "faction.cofounder.invite.decline"; - factionId: string; - declined: true; -}> { - return await apiPost( - "/api/command", - { - type: "faction.cofounder.invite.decline", - payload: { factionId: input.factionId }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); -} - -export async function apiFactionCofounderInviteCancel(input: { - factionId: string; - address: string; - idempotencyKey?: string; -}): Promise<{ - ok: true; - type: "faction.cofounder.invite.cancel"; - factionId: string; - address: string; - canceled: true; -}> { - return await apiPost( - "/api/command", - { - type: "faction.cofounder.invite.cancel", - payload: { factionId: input.factionId, address: input.address }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); -} - -export async function apiFactionChannelCreate(input: { - factionId: string; - title: string; - slug?: string; - writeScope?: "stewards" | "members"; - idempotencyKey?: string; -}): Promise<{ - ok: true; - type: "faction.channel.create"; - factionId: string; - channel: { id: string; title: string }; -}> { - return await apiPost( - "/api/command", - { - type: "faction.channel.create", - payload: { - factionId: input.factionId, - title: input.title, - ...(input.slug ? { slug: input.slug } : {}), - ...(input.writeScope ? { writeScope: input.writeScope } : {}), - }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); -} - -export async function apiFactionChannelLock(input: { - factionId: string; - channelId: string; - isLocked: boolean; - idempotencyKey?: string; -}): Promise<{ - ok: true; - type: "faction.channel.lock"; - factionId: string; - channel: { id: string; isLocked: boolean }; -}> { - return await apiPost( - "/api/command", - { - type: "faction.channel.lock", - payload: { - factionId: input.factionId, - channelId: input.channelId, - isLocked: input.isLocked, - }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); -} - -export async function apiFactionThreadCreate(input: { - factionId: string; - channelId: string; - title: string; - body: string; - idempotencyKey?: string; -}): Promise<{ - ok: true; - type: "faction.thread.create"; - factionId: string; - thread: { id: string; title: string }; -}> { - return await apiPost( - "/api/command", - { - type: "faction.thread.create", - payload: { - factionId: input.factionId, - channelId: input.channelId, - title: input.title, - body: input.body, - }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); -} - -export async function apiFactionThreadReply(input: { - factionId: string; - threadId: string; - body: string; - idempotencyKey?: string; -}): Promise<{ - ok: true; - type: "faction.thread.reply"; - factionId: string; - threadId: string; - messageId: string; -}> { - return await apiPost( - "/api/command", - { - type: "faction.thread.reply", - payload: { - factionId: input.factionId, - threadId: input.threadId, - body: input.body, - }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); -} - -export async function apiFactionThreadTransition(input: { - factionId: string; - threadId: string; - status: "open" | "resolved" | "locked"; - idempotencyKey?: string; -}): Promise<{ - ok: true; - type: "faction.thread.transition"; - factionId: string; - thread: { id: string; status: "open" | "resolved" | "locked" }; -}> { - return await apiPost( - "/api/command", - { - type: "faction.thread.transition", - payload: { - factionId: input.factionId, - threadId: input.threadId, - status: input.status, - }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); -} - -export async function apiFactionThreadDelete(input: { - factionId: string; - threadId: string; - idempotencyKey?: string; -}): Promise<{ - ok: true; - type: "faction.thread.delete"; - factionId: string; - threadId: string; - deleted: true; -}> { - return await apiPost( - "/api/command", - { - type: "faction.thread.delete", - payload: { - factionId: input.factionId, - threadId: input.threadId, - }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); -} - -export async function apiFactionThreadReplyDelete(input: { - factionId: string; - threadId: string; - messageId: string; - idempotencyKey?: string; -}): Promise<{ - ok: true; - type: "faction.thread.reply.delete"; - factionId: string; - threadId: string; - messageId: string; - deleted: true; -}> { - return await apiPost( - "/api/command", - { - type: "faction.thread.reply.delete", - payload: { - factionId: input.factionId, - threadId: input.threadId, - messageId: input.messageId, - }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); -} - -export async function apiFactionInitiativeCreate(input: { - factionId: string; - title: string; - intent?: string; - checklist?: string[]; - idempotencyKey?: string; -}): Promise<{ - ok: true; - type: "faction.initiative.create"; - factionId: string; - initiative: { id: string; title: string }; -}> { - return await apiPost( - "/api/command", - { - type: "faction.initiative.create", - payload: { - factionId: input.factionId, - title: input.title, - ...(input.intent ? { intent: input.intent } : {}), - ...(input.checklist ? { checklist: input.checklist } : {}), - }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); -} - -export async function apiFactionInitiativeTransition(input: { - factionId: string; - initiativeId: string; - status: "draft" | "active" | "blocked" | "done" | "archived"; - idempotencyKey?: string; -}): Promise<{ - ok: true; - type: "faction.initiative.transition"; - factionId: string; - initiative: { - id: string; - status: "draft" | "active" | "blocked" | "done" | "archived"; - }; -}> { - return await apiPost( - "/api/command", - { - type: "faction.initiative.transition", - payload: { - factionId: input.factionId, - initiativeId: input.initiativeId, - status: input.status, - }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); -} - export async function apiFormation(): Promise { return await apiGet("/api/formation"); } @@ -1493,19 +715,13 @@ export async function apiLegitimacyObjectSet(input: { type: "legitimacy.object.set"; legitimacy: GetMyGovernanceResponse["legitimacy"]; }> { - return await apiPost( - "/api/command", - { - type: "legitimacy.object.set", - payload: { - active: input.active, - }, - idempotencyKey: input.idempotencyKey, + return await apiCommand({ + type: "legitimacy.object.set", + payload: { + active: input.active, }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); + idempotencyKey: input.idempotencyKey, + }); } export async function apiDelegationSet(input: { @@ -1520,20 +736,14 @@ export async function apiDelegationSet(input: { delegateeAddress: string; updatedAt: string; }> { - return await apiPost( - "/api/command", - { - type: "delegation.set", - payload: { - chamberId: input.chamberId, - delegateeAddress: input.delegateeAddress, - }, - idempotencyKey: input.idempotencyKey, + return await apiCommand({ + type: "delegation.set", + payload: { + chamberId: input.chamberId, + delegateeAddress: input.delegateeAddress, }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); + idempotencyKey: input.idempotencyKey, + }); } export async function apiDelegationClear(input: { @@ -1546,19 +756,13 @@ export async function apiDelegationClear(input: { delegatorAddress: string; cleared: boolean; }> { - return await apiPost( - "/api/command", - { - type: "delegation.clear", - payload: { - chamberId: input.chamberId, - }, - idempotencyKey: input.idempotencyKey, + return await apiCommand({ + type: "delegation.clear", + payload: { + chamberId: input.chamberId, }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); + idempotencyKey: input.idempotencyKey, + }); } export async function apiCmMe(): Promise { @@ -1639,20 +843,14 @@ export async function apiProposalDraftSave(input: { draftId: string; updatedAt: string; }> { - return await apiPost( - "/api/command", - { - type: "proposal.draft.save", - payload: { - ...(input.draftId ? { draftId: input.draftId } : {}), - form: input.form, - }, - idempotencyKey: input.idempotencyKey, + return await apiCommand({ + type: "proposal.draft.save", + payload: { + ...(input.draftId ? { draftId: input.draftId } : {}), + form: input.form, }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); + idempotencyKey: input.idempotencyKey, + }); } export async function apiProposalDraftDelete(input: { @@ -1664,17 +862,11 @@ export async function apiProposalDraftDelete(input: { draftId: string; deleted: boolean; }> { - return await apiPost( - "/api/command", - { - type: "proposal.draft.delete", - payload: { draftId: input.draftId }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); + return await apiCommand({ + type: "proposal.draft.delete", + payload: { draftId: input.draftId }, + idempotencyKey: input.idempotencyKey, + }); } export async function apiProposalSubmitToPool(input: { @@ -1686,15 +878,9 @@ export async function apiProposalSubmitToPool(input: { draftId: string; proposalId: string; }> { - return await apiPost( - "/api/command", - { - type: "proposal.submitToPool", - payload: { draftId: input.draftId }, - idempotencyKey: input.idempotencyKey, - }, - input.idempotencyKey - ? { headers: { "idempotency-key": input.idempotencyKey } } - : undefined, - ); + return await apiCommand({ + type: "proposal.submitToPool", + payload: { draftId: input.draftId }, + idempotencyKey: input.idempotencyKey, + }); } diff --git a/src/lib/chamberUi.ts b/src/lib/chamberUi.ts new file mode 100644 index 0000000..2dbbc99 --- /dev/null +++ b/src/lib/chamberUi.ts @@ -0,0 +1,15 @@ +type ChamberLabelSource = { + id: string; + name?: string | null; + title?: string | null; +}; + +export function formatChamberLabel( + chamberId: string | null | undefined, + chambers?: ChamberLabelSource[] | null, +): string { + const id = (chamberId ?? "").trim(); + if (!id || id === "general") return "General chamber"; + const match = chambers?.find((item) => item.id === id); + return match?.name?.trim() || match?.title?.trim() || id; +} diff --git a/src/lib/dtoParsers.ts b/src/lib/dtoParsers.ts index 1dd0313..21101fd 100644 --- a/src/lib/dtoParsers.ts +++ b/src/lib/dtoParsers.ts @@ -13,17 +13,20 @@ export function parsePercent(value: string): number { } export function parseRatio(value: string): { a: number; b: number } { - const parts = value - .split("/") - .map((part) => Number.parseInt(part.trim(), 10)); - if (parts.length !== 2) return { a: 0, b: 0 }; - const [a, b] = parts; + const matches = value.match(/\d+/g) ?? []; + const a = Number.parseInt(matches[0] ?? "", 10); + const b = Number.parseInt(matches[1] ?? "", 10); return { a: Number.isFinite(a) ? a : 0, b: Number.isFinite(b) ? b : 0, }; } +export function parseRatioPair(value: string): { left: number; right: number } { + const { a, b } = parseRatio(value); + return { left: a, right: b }; +} + export function getChamberNumericStats(chamber: ChamberDto) { return { governors: parseCommaNumber(chamber.stats.governors), diff --git a/src/lib/factionUi.ts b/src/lib/factionUi.ts new file mode 100644 index 0000000..b617971 --- /dev/null +++ b/src/lib/factionUi.ts @@ -0,0 +1,59 @@ +import { addressesReferToSameIdentity } from "@/lib/addressIdentity"; +import type { FactionDto } from "@/types/api"; + +export type FactionMembership = NonNullable[number]; + +export function findViewerFactionMembership( + memberships: FactionMembership[] | null | undefined, + viewerAddress: string | null | undefined, + options: { activeOnly?: boolean } = {}, +): FactionMembership | null { + if (!viewerAddress) return null; + return ( + (memberships ?? []).find((membership) => { + if (options.activeOnly && !membership.isActive) return false; + return addressesReferToSameIdentity(membership.address, viewerAddress); + }) ?? null + ); +} + +export function hasActiveFactionMembership( + memberships: FactionMembership[] | null | undefined, + viewerAddress: string | null | undefined, +): boolean { + return Boolean( + findViewerFactionMembership(memberships, viewerAddress, { + activeOnly: true, + }), + ); +} + +export function getFactionViewerPermissions( + memberships: FactionMembership[] | null | undefined, + viewerAddress: string | null | undefined, +) { + const viewerMembership = findViewerFactionMembership( + memberships, + viewerAddress, + ); + const viewerRole = viewerMembership?.isActive ? viewerMembership.role : null; + const isFounderAdmin = viewerRole === "founder"; + const canModerateQueues = + viewerRole === "founder" || viewerRole === "steward"; + const canJoin = Boolean(viewerAddress) && !viewerMembership?.isActive; + const canLeave = + Boolean(viewerAddress) && + Boolean(viewerMembership?.isActive) && + viewerRole !== "founder"; + + return { + canJoin, + canLeave, + canManageMembers: isFounderAdmin, + canModerateQueues, + isFounderAdmin, + viewerMembership, + viewerMembershipActive: Boolean(viewerMembership?.isActive), + viewerRole, + }; +} diff --git a/src/lib/feedStageStats.ts b/src/lib/feedStageStats.ts new file mode 100644 index 0000000..ffbc57d --- /dev/null +++ b/src/lib/feedStageStats.ts @@ -0,0 +1,94 @@ +import { parseRatio } from "@/lib/dtoParsers"; +import type { + ChamberProposalPageDto, + FormationProposalPageDto, + PoolProposalPageDto, +} from "@/types/api"; + +export function getFeedPoolStats(poolPage: PoolProposalPageDto) { + const activeGovernors = Math.max(1, poolPage.activeGovernors); + const engaged = poolPage.upvotes + poolPage.downvotes; + const attentionPercent = Math.round((engaged / activeGovernors) * 100); + const attentionNeededPercent = Math.round(poolPage.attentionQuorum * 100); + const upvoteFloorFractionPercent = Math.round( + ((poolPage.thresholdContext?.quorumThreshold?.upvoteFloorFraction ?? 0.1) * + 1000) / + 10, + ); + const upvoteFloorProgressPercent = Math.round( + Math.min( + 1, + poolPage.upvoteFloor > 0 ? poolPage.upvotes / poolPage.upvoteFloor : 0, + ) * upvoteFloorFractionPercent, + ); + const meetsAttention = engaged / activeGovernors >= poolPage.attentionQuorum; + const meetsUpvoteFloor = poolPage.upvotes >= poolPage.upvoteFloor; + const engagedNeeded = Math.ceil(poolPage.attentionQuorum * activeGovernors); + + return { + activeGovernors, + engaged, + attentionPercent, + attentionNeededPercent, + upvoteFloorFractionPercent, + upvoteFloorProgressPercent, + meetsAttention, + meetsUpvoteFloor, + engagedNeeded, + upvoteFloor: poolPage.upvoteFloor, + }; +} + +export function getFeedChamberStats(chamberPage: ChamberProposalPageDto) { + const activeGovernors = Math.max(1, chamberPage.activeGovernors); + const yesTotal = chamberPage.votes.yes; + const noTotal = chamberPage.votes.no; + const abstainTotal = chamberPage.votes.abstain; + const totalVotes = yesTotal + noTotal + abstainTotal; + + const engaged = chamberPage.engagedGovernors; + const quorumNeeded = Math.ceil(activeGovernors * chamberPage.attentionQuorum); + const quorumPercent = Math.round((engaged / activeGovernors) * 100); + const quorumNeededPercent = Math.round(chamberPage.attentionQuorum * 100); + const yesPercentOfQuorum = + engaged > 0 ? Math.round((yesTotal / engaged) * 100) : 0; + + const meetsQuorum = engaged >= quorumNeeded; + const meetsPassing = yesPercentOfQuorum >= 66.6; + + const yesWidth = totalVotes ? (yesTotal / totalVotes) * 100 : 0; + const noWidth = totalVotes ? (noTotal / totalVotes) * 100 : 0; + const abstainWidth = totalVotes ? (abstainTotal / totalVotes) * 100 : 0; + + return { + activeGovernors, + yesTotal, + noTotal, + abstainTotal, + totalVotes, + engaged, + quorumNeeded, + quorumPercent, + quorumNeededPercent, + yesPercentOfQuorum, + meetsQuorum, + meetsPassing, + yesWidth, + noWidth, + abstainWidth, + }; +} + +export function getFeedFormationStats(formationPage: FormationProposalPageDto) { + const progressRaw = Number.parseInt( + formationPage.progress.replace("%", ""), + 10, + ); + const progressValue = Number.isFinite(progressRaw) ? progressRaw : 0; + + return { + progressValue, + team: parseRatio(formationPage.teamSlots), + milestones: parseRatio(formationPage.milestones), + }; +} diff --git a/src/lib/feedUi.ts b/src/lib/feedUi.ts new file mode 100644 index 0000000..fbacc34 --- /dev/null +++ b/src/lib/feedUi.ts @@ -0,0 +1,109 @@ +import { addressesReferToSameIdentity } from "@/lib/addressIdentity"; +import { toTimestampMs } from "@/lib/dateTime"; +import type { FeedItemDto } from "@/types/api"; + +export const normalizeAppHref = (href?: string) => { + if (!href) return undefined; + const appHref = href.startsWith("/app/") ? href : `/app${href}`; + + const factionThreadMatch = appHref.match( + /^\/app\/factions\/([^/]+)\/threads\/([^/]+)$/, + ); + if (factionThreadMatch) { + return `/app/factions/${factionThreadMatch[1]}?thread=${encodeURIComponent(factionThreadMatch[2])}`; + } + + const chamberThreadMatch = appHref.match( + /^\/app\/chambers\/([^/]+)\/threads\/([^/]+)$/, + ); + if (chamberThreadMatch) { + return `/app/chambers/${chamberThreadMatch[1]}?thread=${encodeURIComponent(chamberThreadMatch[2])}`; + } + + return appHref; +}; + +export const courtCaseIdFromHref = (href?: string) => { + if (!href) return null; + const clean = href.startsWith("/app/") ? href.slice("/app".length) : href; + const match = clean.match(/^\/courts\/(.+)$/); + return match?.[1] ?? null; +}; + +export const proposalIdFromHref = (href?: string) => { + if (!href) return null; + const noQuery = href.split("?")[0] ?? href; + const clean = noQuery.startsWith("/app/") + ? noQuery.slice("/app".length) + : noQuery; + const match = clean.match( + /^\/proposals\/([^/]+)\/(pp|chamber|citizen-veto|chamber-veto|referendum|formation|finished)$/, + ); + return match?.[1] ?? null; +}; + +export const factionIdFromHref = (href?: string) => { + if (!href) return null; + const clean = href.startsWith("/app/") ? href.slice("/app".length) : href; + const match = clean.match(/^\/factions\/([^/]+)$/); + return match?.[1] ?? null; +}; + +export const hasFinishedRoute = (href?: string) => + Boolean(href?.includes("/finished")); + +export const feedItemKey = (item: FeedItemDto) => + `${item.id}|${item.stage}|${item.timestamp}|${item.href ?? ""}`; + +export const urgentEntityKey = (item: FeedItemDto) => { + const proposalId = proposalIdFromHref(item.href); + if (proposalId) return `proposal:${proposalId}`; + const caseId = courtCaseIdFromHref(item.href); + if (caseId) return `court:${caseId}`; + if (item.href) return `href:${item.href}`; + return `id:${item.id}`; +}; + +export const isUrgentItemInteractable = ( + item: FeedItemDto, + isGovernorActive: boolean, + viewerAddress?: string, +) => { + if (item.actionable !== true) return false; + if (item.stage === "build") { + return addressesReferToSameIdentity( + viewerAddress, + item.proposerId ?? item.proposer, + ); + } + if ((item.stage === "pool" || item.stage === "vote") && !isGovernorActive) { + if (item.href?.includes("/referendum")) return true; + return false; + } + return true; +}; + +export const toUrgentItems = ( + items: FeedItemDto[], + isGovernorActive: boolean, + viewerAddress?: string, +): FeedItemDto[] => { + const filtered = items.filter((item) => + isUrgentItemInteractable(item, isGovernorActive, viewerAddress), + ); + const deduped = new Map(); + for (const item of filtered) { + const key = urgentEntityKey(item); + const existing = deduped.get(key); + if (!existing) { + deduped.set(key, item); + continue; + } + if ( + toTimestampMs(item.timestamp, -1) > toTimestampMs(existing.timestamp, -1) + ) { + deduped.set(key, item); + } + } + return Array.from(deduped.values()); +}; diff --git a/src/lib/humanNodesUi.ts b/src/lib/humanNodesUi.ts new file mode 100644 index 0000000..e281716 --- /dev/null +++ b/src/lib/humanNodesUi.ts @@ -0,0 +1,196 @@ +import type { + ChamberDto, + FactionDto, + GetMyGovernanceResponse, + HumanNodeDto, + HumanNodeProfileDto, +} from "@/types/api"; +import { shortAddress } from "./profileUi"; + +export type HumanNodesSortBy = "acm-desc" | "acm-asc" | "tier" | "name"; +export type HumanNodesTierFilter = + | "all" + | "nominee" + | "ecclesiast" + | "legate" + | "consul" + | "citizen"; +export type HumanNodesStatusFilter = "all" | "governor" | "human" | "inactive"; +export type HumanNodesCmRange = "all" | "0-50" | "50-200" | "200+"; + +export type HumanNodesFilters = { + cmRange: HumanNodesCmRange; + sortBy: HumanNodesSortBy; + statusFilter: HumanNodesStatusFilter; + tierFilter: HumanNodesTierFilter; +}; + +export const DEFAULT_HUMAN_NODES_FILTERS: HumanNodesFilters = { + sortBy: "acm-desc", + tierFilter: "all", + statusFilter: "all", + cmRange: "all", +}; + +const tierOrder = ["nominee", "ecclesiast", "legate", "consul", "citizen"]; + +export function isLikelyHumanodeAddress(value: string): boolean { + const normalized = value.trim().toLowerCase(); + if (!normalized.startsWith("hm")) return false; + if (normalized.length < 24) return false; + return /^[a-z0-9]+$/.test(normalized); +} + +export function filterHumanNodes(input: { + chambersById: Record; + factionsById: Record; + filters: HumanNodesFilters; + nodes: HumanNodeDto[] | null; + search: string; +}): HumanNodeDto[] { + const { chambersById, factionsById, filters, nodes, search } = input; + const term = search.toLowerCase(); + const { cmRange, sortBy, statusFilter, tierFilter } = filters; + + return [...(nodes ?? [])] + .filter((node) => { + const factionName = + factionsById[node.factionId]?.name?.toLowerCase() ?? ""; + const chamberName = chambersById[node.chamber]?.name?.toLowerCase() ?? ""; + const matchesTerm = + node.name.toLowerCase().includes(term) || + node.role.toLowerCase().includes(term) || + node.tags.some((t) => t.toLowerCase().includes(term)) || + node.chamber.toLowerCase().includes(term) || + chamberName.includes(term) || + factionName.includes(term); + const matchesTier = + tierFilter === "all" ? true : node.tier === tierFilter; + const matchesStatus = + statusFilter === "all" + ? true + : statusFilter === "governor" + ? node.active.governorActive + : statusFilter === "human" + ? node.active.humanNodeActive + : !node.active.governorActive && !node.active.humanNodeActive; + const acmValue = node.cmTotals?.acm ?? node.acm ?? 0; + const matchesRange = + cmRange === "all" + ? true + : cmRange === "0-50" + ? acmValue <= 50 + : cmRange === "50-200" + ? acmValue > 50 && acmValue <= 200 + : acmValue > 200; + return matchesTerm && matchesTier && matchesStatus && matchesRange; + }) + .sort((a, b) => { + const acmA = a.cmTotals?.acm ?? a.acm; + const acmB = b.cmTotals?.acm ?? b.acm; + if (sortBy === "acm-desc") return acmB - acmA; + if (sortBy === "acm-asc") return acmA - acmB; + if (sortBy === "name") return a.name.localeCompare(b.name); + return tierOrder.indexOf(a.tier) - tierOrder.indexOf(b.tier); + }); +} + +export function getHumanNodeHeaderTitle(profile: HumanNodeProfileDto): string { + const normalizedName = profile.name.trim().toLowerCase(); + const isGenericName = [ + "human node profile", + "human node", + "profile", + ].includes(normalizedName); + const isAddressName = normalizedName === profile.id.toLowerCase(); + return isAddressName || isGenericName + ? shortAddress(profile.id) + : profile.name; +} + +export function shouldShowHumanNodeShortBadge( + profile: HumanNodeProfileDto, +): boolean { + const normalizedName = profile.name.trim().toLowerCase(); + const isGenericName = [ + "human node profile", + "human node", + "profile", + ].includes(normalizedName); + const isAddressName = normalizedName === profile.id.toLowerCase(); + return !isAddressName && !isGenericName; +} + +export function getHumanNodeVisibleHeroStats( + heroStats: HumanNodeProfileDto["heroStats"], +): HumanNodeProfileDto["heroStats"] { + return heroStats.filter((stat) => { + const label = stat.label.trim().toUpperCase(); + return !["ACM", "LCM", "MCM", "MM"].includes(label); + }); +} + +export function getHumanNodeCmTotals( + heroStats: HumanNodeProfileDto["heroStats"], +) { + return heroStats.reduce( + (acc, stat) => { + const label = stat.label.trim().toUpperCase(); + const numeric = Number(stat.value.replace(/[^0-9.-]/g, "")) || 0; + if (label === "LCM") acc.lcm = numeric; + if (label === "MCM") acc.mcm = numeric; + if (label === "ACM") acc.acm = numeric; + return acc; + }, + { lcm: 0, mcm: 0, acm: 0 }, + ); +} + +export function getHumanNodeDelegationCards( + delegationChambers: HumanNodeProfileDto["delegation"]["chambers"], + eligibleChamberIds: string[], +): HumanNodeProfileDto["delegation"]["chambers"] { + const byChamber = new Map< + string, + HumanNodeProfileDto["delegation"]["chambers"][number] + >(); + for (const item of delegationChambers) { + byChamber.set(item.chamberId, item); + } + for (const chamberId of eligibleChamberIds) { + if (!byChamber.has(chamberId)) { + byChamber.set(chamberId, { + chamberId, + delegateeAddress: null, + inboundWeight: 0, + inboundDelegators: [], + }); + } + } + return [...byChamber.values()].sort((a, b) => + a.chamberId.localeCompare(b.chamberId), + ); +} + +export function getHumanNodeViewerDelegationByChamber( + viewerGovernance: GetMyGovernanceResponse | null, +): Map { + const out = new Map< + string, + GetMyGovernanceResponse["delegation"]["chambers"][number] + >(); + for (const item of viewerGovernance?.delegation.chambers ?? []) { + out.set(item.chamberId, item); + } + return out; +} + +export function getHumanNodeManageableDelegationChambers( + viewerGovernance: GetMyGovernanceResponse | null, + eligibleChamberIds: string[], +): GetMyGovernanceResponse["delegation"]["chambers"] { + const targetEligible = new Set(eligibleChamberIds); + return (viewerGovernance?.delegation.chambers ?? []) + .filter((item) => targetEligible.has(item.chamberId)) + .sort((a, b) => a.chamberId.localeCompare(b.chamberId)); +} diff --git a/src/lib/myGovernanceUi.ts b/src/lib/myGovernanceUi.ts new file mode 100644 index 0000000..8aff186 --- /dev/null +++ b/src/lib/myGovernanceUi.ts @@ -0,0 +1,127 @@ +import type { GetMyGovernanceResponse } from "@/types/api"; + +export type GoverningStatus = + | "Ahead" + | "Stable" + | "Falling behind" + | "At risk" + | "Losing status"; + +export type TierProgress = NonNullable; + +export type TierKey = + | "Nominee" + | "Ecclesiast" + | "Legate" + | "Consul" + | "Citizen"; + +export 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", + ], +}; + +export const labelForTier = (tier: TierKey): string => { + return tier; +}; + +export 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", +}; + +export 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)), + }; +}; + +export const governingStatusForProgress = ( + completed: number, + required: number, +): { label: GoverningStatus; termId: string } => { + if (required <= 0) { + return { label: "Stable", termId: "governing_status_stable" }; + } + + if (completed >= required + 1) { + return { label: "Ahead", termId: "governing_status_ahead" }; + } + + if (completed >= required) { + return { label: "Stable", termId: "governing_status_stable" }; + } + + const ratio = completed / required; + if (ratio >= 0.75) { + return { + label: "Falling behind", + termId: "governing_status_falling_behind", + }; + } + if (ratio >= 0.55) { + return { label: "At risk", termId: "governing_status_at_risk" }; + } + return { + label: "Losing status", + termId: "governing_status_losing_status", + }; +}; + +export const governingStatusTermId = (label: GoverningStatus): string => { + if (label === "Ahead") return "governing_status_ahead"; + if (label === "Stable") return "governing_status_stable"; + if (label === "Falling behind") return "governing_status_falling_behind"; + if (label === "At risk") return "governing_status_at_risk"; + return "governing_status_losing_status"; +}; + +export const formatDayHourMinute = ( + targetMs: number, + nowMs: number, +): string => { + const deltaMs = Math.max(0, targetMs - nowMs); + const totalMinutes = Math.floor(deltaMs / 60_000); + const days = Math.floor(totalMinutes / (24 * 60)); + const hours = Math.floor((totalMinutes % (24 * 60)) / 60); + const minutes = totalMinutes % 60; + return `${days}d:${String(hours).padStart(2, "0")}h:${String(minutes).padStart(2, "0")}m`; +}; diff --git a/src/lib/proposalDeliberationUi.ts b/src/lib/proposalDeliberationUi.ts new file mode 100644 index 0000000..01838e3 --- /dev/null +++ b/src/lib/proposalDeliberationUi.ts @@ -0,0 +1,122 @@ +import type { + ProposalThreadCategoryDto, + ProposalThreadDetailDto, + ProposalThreadDto, + ProposalThreadListDto, +} from "@/types/api"; + +export type ProposalDiscussionAuthState = { + authenticated: boolean; + eligible: boolean; + enabled: boolean; +}; + +export const proposalThreadCategoryOptions: Array<{ + value: ProposalThreadCategoryDto | "all"; + label: string; +}> = [ + { value: "all", label: "All" }, + { value: "question", label: "Questions" }, + { value: "concern", label: "Concerns" }, + { value: "amendment", label: "Amendments" }, + { value: "support", label: "Support" }, + { value: "execution", label: "Execution" }, + { value: "general", label: "General" }, +]; + +export const proposalThreadCreateCategoryOptions = + proposalThreadCategoryOptions.filter( + (item): item is { value: ProposalThreadCategoryDto; label: string } => + item.value !== "all", + ); + +export const proposalThreadStatusOptions: Array<{ + value: ProposalThreadDto["status"]; + label: string; +}> = [ + { value: "open", label: "Open" }, + { value: "resolved", label: "Resolved" }, + { value: "locked", label: "Locked" }, +]; + +export function proposalThreadCategoryLabel( + value: ProposalThreadCategoryDto, +): string { + return ( + proposalThreadCreateCategoryOptions.find((item) => item.value === value) + ?.label ?? "General" + ); +} + +export function proposalThreadStatusLabel( + value: ProposalThreadDto["status"], +): string { + if (value === "resolved") return "Resolved"; + if (value === "locked") return "Locked"; + return "Open"; +} + +export function canAttemptProposalDiscussionWrite( + auth: ProposalDiscussionAuthState, +): boolean { + if (!auth.enabled) return true; + return auth.authenticated && auth.eligible; +} + +export function restrictProposalThreadListForReadOnly( + threadList: ProposalThreadListDto | null, + writeAllowed: boolean, +): ProposalThreadListDto | null { + if (!threadList || writeAllowed) return threadList; + return { + ...threadList, + permissions: { + ...threadList.permissions, + canCreate: false, + }, + items: threadList.items.map((thread) => ({ + ...thread, + permissions: { + ...thread.permissions, + canReply: false, + canTransition: false, + }, + })), + }; +} + +export function restrictProposalThreadDetailForReadOnly( + activeThread: ProposalThreadDetailDto | null, + writeAllowed: boolean, +): ProposalThreadDetailDto | null { + if (!activeThread || writeAllowed) return activeThread; + return { + ...activeThread, + thread: { + ...activeThread.thread, + permissions: { + ...activeThread.thread.permissions, + canReply: false, + canTransition: false, + }, + }, + messages: activeThread.messages, + }; +} + +export function filterProposalThreadsByCategory( + items: ProposalThreadDto[], + filter: ProposalThreadCategoryDto | "all", +): ProposalThreadDto[] { + if (filter === "all") return items; + return items.filter((thread) => thread.category === filter); +} + +export function getProposalThreadReplyCount( + threadList: ProposalThreadListDto | null, +): number { + return (threadList?.items ?? []).reduce( + (sum, thread) => sum + thread.replies, + 0, + ); +} diff --git a/src/lib/proposalListUi.ts b/src/lib/proposalListUi.ts new file mode 100644 index 0000000..4d3871d --- /dev/null +++ b/src/lib/proposalListUi.ts @@ -0,0 +1,386 @@ +import { proposalFormationSummaryStats } from "@/lib/proposalUi"; +import { toTimestampMs } from "@/lib/dateTime"; +import type { + ChamberProposalPageDto, + ProposalListItemDto, + ProposalStatDto, + PoolProposalPageDto, +} from "@/types/api"; +import type { ProposalStage } from "@/types/stages"; + +export type ProposalListSort = "Newest" | "Oldest" | "Activity" | "Votes"; +export type ProposalListFilters = { + stageFilter: ProposalStage | "any"; + lifecycleFilter: "active" | "all"; + chamberFilter: string; + sortBy: ProposalListSort; +}; +export type ProposalListFilterConfigField = { + key: keyof ProposalListFilters & string; + label: string; + options: { value: string; label: string }[]; +}; + +type PoolStatsInput = Pick< + PoolProposalPageDto, + | "activeGovernors" + | "attentionQuorum" + | "downvotes" + | "thresholdContext" + | "upvoteFloor" + | "upvotes" +>; + +type ChamberStatsInput = Pick< + ChamberProposalPageDto, + "activeGovernors" | "quorumNeeded" | "votes" +>; + +type FormationSummaryInput = Parameters< + typeof proposalFormationSummaryStats +>[0]; + +type PoolKeyStatsPage = PoolStatsInput & FormationSummaryInput; +type ChamberKeyStatsPage = ChamberStatsInput & FormationSummaryInput; +type StatsOnlyPage = { + stats: ProposalStatDto[]; +}; +type FormationKeyStatsPage = StatsOnlyPage & { + budget: string; + timeLeft: string; + teamSlots: string; + milestones: string; +}; + +type ProposalListKeyStatsInput = { + proposal: ProposalListItemDto; + poolPage?: PoolKeyStatsPage | null; + chamberPage?: ChamberKeyStatsPage | null; + citizenVetoPage?: StatsOnlyPage | null; + chamberVetoPage?: StatsOnlyPage | null; + finishedPage?: StatsOnlyPage | null; + formationPage?: FormationKeyStatsPage | null; +}; + +type ProposalPrimaryHrefInput = Pick< + ProposalListItemDto, + "href" | "id" | "stage" | "summaryPill" +>; + +const DELIBERATION_STAT_LABELS = new Set([ + "Deliberation", + "Open concerns", + "Last discussion", +]); + +export const DEFAULT_PROPOSAL_LIST_FILTERS: ProposalListFilters = { + stageFilter: "any", + lifecycleFilter: "active", + chamberFilter: "All chambers", + sortBy: "Newest", +}; + +export const PROPOSAL_STAGE_FILTER_OPTIONS: Array<{ + value: ProposalListFilters["stageFilter"]; + label: string; +}> = [ + { value: "any", label: "Any" }, + { value: "pool", label: "Proposal pool" }, + { value: "vote", label: "Chamber vote" }, + { value: "citizen_veto", label: "Citizen veto" }, + { value: "chamber_veto", label: "Chamber veto" }, + { value: "build", label: "Formation" }, + { value: "passed", label: "Passed" }, + { value: "failed", label: "Ended (failed)" }, +]; + +export const PROPOSAL_LIFECYCLE_FILTER_OPTIONS: Array<{ + value: ProposalListFilters["lifecycleFilter"]; + label: string; +}> = [ + { value: "active", label: "Active only" }, + { value: "all", label: "Include ended" }, +]; + +export const PROPOSAL_SORT_OPTIONS: Array<{ + value: ProposalListSort; + label: string; +}> = [ + { value: "Newest", label: "Newest" }, + { value: "Oldest", label: "Oldest" }, + { value: "Activity", label: "Activity" }, + { value: "Votes", label: "Votes cast" }, +]; + +export function isEndedProposal(proposal: ProposalListItemDto): boolean { + return ( + proposal.stage === "passed" || + proposal.stage === "failed" || + proposal.summaryPill === "Finished" || + proposal.summaryPill === "Failed" + ); +} + +export function hasFinishedRoute(href?: string): boolean { + return Boolean(href?.includes("/finished")); +} + +function proposalMatchesSearchTerm( + proposal: ProposalListItemDto, + term: string, +): boolean { + if (!term) return true; + return ( + proposal.title.toLowerCase().includes(term) || + proposal.summary.toLowerCase().includes(term) || + proposal.meta.toLowerCase().includes(term) || + proposal.keywords.some((keyword) => keyword.toLowerCase().includes(term)) + ); +} + +function compareProposalsBySort( + sortBy: ProposalListSort, + a: ProposalListItemDto, + b: ProposalListItemDto, +): number { + if (sortBy === "Newest") { + return toTimestampMs(b.date, -1) - toTimestampMs(a.date, -1); + } + if (sortBy === "Oldest") { + return toTimestampMs(a.date, -1) - toTimestampMs(b.date, -1); + } + if (sortBy === "Activity") { + return b.activityScore - a.activityScore; + } + if (sortBy === "Votes") { + return b.votes - a.votes; + } + return 0; +} + +export function filterProposalList( + proposals: ProposalListItemDto[], + search: string, + filters: ProposalListFilters, +): ProposalListItemDto[] { + const term = search.trim().toLowerCase(); + return proposals + .filter((proposal) => { + const matchesStage = + filters.stageFilter === "any" + ? true + : proposal.stage === filters.stageFilter; + const matchesLifecycle = + filters.lifecycleFilter === "all" ? true : !isEndedProposal(proposal); + const matchesChamber = + filters.chamberFilter === "All chambers" + ? true + : proposal.chamber === filters.chamberFilter; + return ( + proposalMatchesSearchTerm(proposal, term) && + matchesStage && + matchesLifecycle && + matchesChamber + ); + }) + .sort((a, b) => compareProposalsBySort(filters.sortBy, a, b)); +} + +export function getProposalChamberFilterOptions( + proposals: ProposalListItemDto[], +): Array<{ value: string; label: string }> { + const unique = Array.from( + new Set(proposals.map((proposal) => proposal.chamber)), + ).sort((a, b) => a.localeCompare(b)); + return [ + { value: "All chambers", label: "All chambers" }, + ...unique.map((chamber) => ({ value: chamber, label: chamber })), + ]; +} + +export function getProposalListFilterConfig( + chamberOptions: Array<{ value: string; label: string }>, +): ProposalListFilterConfigField[] { + return [ + { + key: "stageFilter", + label: "Status", + options: PROPOSAL_STAGE_FILTER_OPTIONS, + }, + { + key: "lifecycleFilter", + label: "Lifecycle", + options: PROPOSAL_LIFECYCLE_FILTER_OPTIONS, + }, + { + key: "chamberFilter", + label: "Chamber", + options: chamberOptions, + }, + { + key: "sortBy", + label: "Sort by", + options: PROPOSAL_SORT_OPTIONS, + }, + ]; +} + +export function getPoolProposalListStats(poolPage: PoolStatsInput) { + const activeGovernors = Math.max(1, poolPage.activeGovernors); + const engaged = poolPage.upvotes + poolPage.downvotes; + const attentionPercent = Math.round((engaged / activeGovernors) * 100); + const attentionNeededPercent = Math.round(poolPage.attentionQuorum * 100); + const upvoteFloorFractionPercent = Math.round( + ((poolPage.thresholdContext?.quorumThreshold?.upvoteFloorFraction ?? 0.1) * + 1000) / + 10, + ); + const upvoteFloorProgressPercent = Math.round( + Math.min( + 1, + poolPage.upvoteFloor > 0 ? poolPage.upvotes / poolPage.upvoteFloor : 0, + ) * upvoteFloorFractionPercent, + ); + const engagedNeeded = Math.min( + activeGovernors, + Math.max(1, Math.ceil(poolPage.attentionQuorum * activeGovernors)), + ); + + return { + activeGovernors, + engaged, + attentionPercent, + attentionNeededPercent, + upvoteFloorFractionPercent, + upvoteFloorProgressPercent, + meetsAttention: engaged >= engagedNeeded, + meetsUpvoteFloor: poolPage.upvotes >= poolPage.upvoteFloor, + engagedNeeded, + upvoteFloor: poolPage.upvoteFloor, + }; +} + +export function getChamberProposalListStats(chamberPage: ChamberStatsInput) { + const activeGovernors = Math.max(1, chamberPage.activeGovernors); + const yesTotal = chamberPage.votes.yes; + const noTotal = chamberPage.votes.no; + const abstainTotal = chamberPage.votes.abstain; + const totalVotes = yesTotal + noTotal + abstainTotal; + const engaged = totalVotes; + const quorumNeeded = chamberPage.quorumNeeded; + const quorumPercent = Math.round((engaged / activeGovernors) * 100); + const quorumNeededPercent = Math.round( + (quorumNeeded / activeGovernors) * 100, + ); + const yesPercentOfQuorum = + engaged > 0 ? Math.round((yesTotal / engaged) * 100) : 0; + + return { + activeGovernors, + yesTotal, + noTotal, + abstainTotal, + totalVotes, + engaged, + quorumNeeded, + quorumPercent, + quorumNeededPercent, + yesPercentOfQuorum, + meetsQuorum: engaged >= quorumNeeded, + meetsPassing: yesPercentOfQuorum >= 66.6, + yesWidth: totalVotes ? (yesTotal / totalVotes) * 100 : 0, + noWidth: totalVotes ? (noTotal / totalVotes) * 100 : 0, + abstainWidth: totalVotes ? (abstainTotal / totalVotes) * 100 : 0, + }; +} + +export function getProposalListKeyStats({ + proposal, + poolPage, + chamberPage, + citizenVetoPage, + chamberVetoPage, + finishedPage, + formationPage, +}: ProposalListKeyStatsInput): ProposalStatDto[] { + const baseKeyStats = + proposal.stage === "pool" && poolPage + ? proposalFormationSummaryStats(poolPage, { + milestoneSuffix: "planned", + }) + : proposal.stage === "vote" && chamberPage + ? proposalFormationSummaryStats(chamberPage, { + milestoneSuffix: "planned", + }) + : proposal.stage === "citizen_veto" && citizenVetoPage + ? citizenVetoPage.stats + : proposal.stage === "chamber_veto" && chamberVetoPage + ? chamberVetoPage.stats + : finishedPage + ? finishedPage.stats + : proposal.stage === "build" && formationPage + ? [ + { + label: "Budget ask", + value: formationPage.budget, + }, + { + label: "Time left", + value: formationPage.timeLeft, + }, + { + label: "Team slots", + value: formationPage.teamSlots, + }, + { + label: "Milestones", + value: formationPage.milestones, + }, + ...formationPage.stats, + ] + : proposal.stats; + const deliberationStats = proposal.stats.filter((stat) => + DELIBERATION_STAT_LABELS.has(stat.label), + ); + return [ + ...baseKeyStats, + ...deliberationStats.filter( + (stat) => !baseKeyStats.some((item) => item.label === stat.label), + ), + ]; +} + +export function getProposalListPrimaryHref( + proposal: ProposalPrimaryHrefInput, +): string { + if (proposal.href) return proposal.href; + if (proposal.stage === "pool") return `/app/proposals/${proposal.id}/pp`; + if (proposal.stage === "vote") { + return `/app/proposals/${proposal.id}/chamber`; + } + if (proposal.stage === "citizen_veto") { + return `/app/proposals/${proposal.id}/citizen-veto`; + } + if (proposal.stage === "chamber_veto") { + return `/app/proposals/${proposal.id}/chamber-veto`; + } + if (proposal.stage === "passed") { + return `/app/proposals/${proposal.id}/finished`; + } + if (proposal.stage === "build") { + return proposal.summaryPill === "Finished" + ? `/app/proposals/${proposal.id}/finished` + : `/app/proposals/${proposal.id}/formation`; + } + return `/app/proposals/${proposal.id}/pp`; +} + +export function getProposalListLoadingMessage( + proposal: Pick, +): string | null { + if (hasFinishedRoute(proposal.href)) return "Loading outcome details…"; + if (proposal.stage === "vote") return "Loading chamber vote stats…"; + if (proposal.stage === "citizen_veto") return "Loading citizen veto stats…"; + if (proposal.stage === "chamber_veto") return "Loading chamber veto stats…"; + return null; +} diff --git a/src/lib/proposalSubmitErrors.ts b/src/lib/proposalSubmitErrors.ts index 349bf06..dc92024 100644 --- a/src/lib/proposalSubmitErrors.ts +++ b/src/lib/proposalSubmitErrors.ts @@ -33,3 +33,15 @@ export function formatProposalSubmitError(error: unknown): string { return details.message ?? (error as Error).message ?? "Submit failed."; } + +export function formatProposalActionError( + error: unknown, + fallbackMessage = "Action failed.", +): string { + const payloadMessage = getApiErrorPayload(error)?.error?.message; + if (payloadMessage && payloadMessage.trim().length > 0) { + return payloadMessage; + } + const fallback = error instanceof Error ? error.message : fallbackMessage; + return fallback.replace(/^HTTP\s+\d{3}:\s*/i, "").trim() || fallbackMessage; +} diff --git a/src/lib/proposalUi.ts b/src/lib/proposalUi.ts new file mode 100644 index 0000000..62fca16 --- /dev/null +++ b/src/lib/proposalUi.ts @@ -0,0 +1,198 @@ +import { addressesReferToSameIdentity } from "@/lib/addressIdentity"; +import { parseRatio } from "@/lib/dtoParsers"; +import type { ChamberProposalPageDto } from "@/types/api"; + +type ProposalSummaryStat = { + label: string; + value: string; +}; + +type ProposalFormationSummaryInput = { + formationEligible: boolean; + budget: string; + teamSlots: string; + milestones: string; +}; + +type ProposalFormationSummaryOptions = { + milestoneSuffix?: "milestones planned" | "planned"; +}; + +type ProposalPoolVotingGateInput = { + auth: { + authenticated: boolean; + eligible: boolean; + enabled: boolean; + gateReason?: string; + loading: boolean; + }; + viewerIsProposer: boolean; +}; + +type ProposalOrdinaryVoteGateInput = { + closedReason?: string; + submitting: boolean; + viewerIsProposer: boolean; + votingClosed?: boolean; +}; + +type ProposalChamberPageDerivationInput = { + proposal: ChamberProposalPageDto; + viewerAddress: string | null | undefined; +}; + +export function viewerIsProposalAuthor( + viewerAddress: string | null | undefined, + proposerAddress: string | null | undefined, +): boolean { + return addressesReferToSameIdentity(viewerAddress, proposerAddress); +} + +export function getProposalPoolVotingGate({ + auth, + viewerIsProposer, +}: ProposalPoolVotingGateInput): { allowed: boolean; disabledReason: string } { + const allowed = + !viewerIsProposer && + (!auth.enabled || (auth.authenticated && auth.eligible && !auth.loading)); + const disabledReason = viewerIsProposer + ? "You cannot vote on your own proposal." + : auth.enabled && auth.loading + ? "Checking wallet status…" + : auth.enabled && !auth.authenticated + ? "Connect your wallet to vote." + : (auth.gateReason ?? "Only active human nodes can vote."); + return { allowed, disabledReason }; +} + +export function getProposalOrdinaryVoteGate({ + closedReason = "Ordinary voting is closed.", + submitting, + viewerIsProposer, + votingClosed = false, +}: ProposalOrdinaryVoteGateInput): { + disabled: boolean; + title: string | undefined; +} { + return { + disabled: submitting || votingClosed || viewerIsProposer, + title: viewerIsProposer + ? "You cannot vote on your own proposal." + : votingClosed + ? closedReason + : undefined, + }; +} + +export function proposalFormationSummaryStats( + proposal: ProposalFormationSummaryInput, + options: ProposalFormationSummaryOptions = {}, +): ProposalSummaryStat[] { + if (!proposal.formationEligible) return []; + const { a: filledSlots, b: totalSlots } = parseRatio(proposal.teamSlots); + const openSlots = Math.max(totalSlots - filledSlots, 0); + const milestoneSuffix = options.milestoneSuffix ?? "milestones planned"; + return [ + { label: "Budget ask", value: proposal.budget }, + { + label: "Formation", + value: "Yes", + }, + { + label: "Team slots", + value: `${proposal.teamSlots} (open: ${openSlots})`, + }, + { + label: "Milestones", + value: `${proposal.milestones} ${milestoneSuffix}`, + }, + ]; +} + +export function getProposalChamberPageDerivation({ + proposal, + viewerAddress, +}: ProposalChamberPageDerivationInput) { + const yesTotal = proposal.votes.yes; + const noTotal = proposal.votes.no; + const abstainTotal = proposal.votes.abstain; + const totalVotes = yesTotal + noTotal + abstainTotal; + const engaged = proposal.engagedVoters ?? proposal.engagedGovernors; + const eligibleVoters = Math.max( + 1, + proposal.eligibleVoters ?? proposal.activeGovernors, + ); + const quorumFraction = + proposal.thresholdContext?.quorumThreshold?.quorumFraction ?? + proposal.attentionQuorum ?? + 0.33; + const quorumNeeded = proposal.quorumNeeded; + const quorumPercent = Math.round((engaged / eligibleVoters) * 100); + const quorumNeededPercent = Math.round(quorumFraction * 100); + const yesPercentOfQuorum = + engaged > 0 ? Math.round((yesTotal / engaged) * 100) : 0; + const yesPercentOfTotal = + totalVotes > 0 ? Math.round((yesTotal / totalVotes) * 100) : 0; + const noPercentOfTotal = + totalVotes > 0 ? Math.round((noTotal / totalVotes) * 100) : 0; + const abstainPercentOfTotal = + totalVotes > 0 ? Math.round((abstainTotal / totalVotes) * 100) : 0; + const milestoneVoteIndex = + typeof proposal.milestoneIndex === "number" && proposal.milestoneIndex > 0 + ? proposal.milestoneIndex + : null; + const referendumVote = proposal.voteKind === "referendum"; + const viewerIsProposer = viewerIsProposalAuthor( + viewerAddress, + proposal.proposerId, + ); + const scoreLabel: "CM" | "MM" | null = + proposal.scoreLabel === "MM" || milestoneVoteIndex !== null + ? "MM" + : proposal.scoreLabel === "CM" + ? "CM" + : null; + const chamberTitle = referendumVote + ? `${proposal.title} — Referendum` + : milestoneVoteIndex !== null + ? `${proposal.title} — Milestone vote (M${milestoneVoteIndex})` + : proposal.title; + const viewerVoteLabel = proposal.viewerVote + ? proposal.viewerVote.choice === "yes" + ? `Yes${ + typeof proposal.viewerVote.score === "number" + ? ` (score ${proposal.viewerVote.score})` + : "" + }` + : proposal.viewerVote.choice === "no" + ? "No" + : "Abstain" + : null; + + return { + abstainPercentOfTotal, + abstainTotal, + chamberTitle, + engaged, + eligibleVoters, + formationSummaryStats: proposalFormationSummaryStats(proposal), + milestoneVoteIndex, + noPercentOfTotal, + noTotal, + ordinaryVoteClosed: proposal.ordinaryVoteClosed, + passingNeededPercent: 66.6, + quorumNeeded, + quorumNeededPercent, + quorumPercent, + referendumQuorumRuleLabel: "33.3% + 1", + referendumVote, + scoreLabel, + totalVotes, + vetoWindowOpen: proposal.timeLeft !== "Ended", + viewerIsProposer, + viewerVoteLabel, + yesPercentOfQuorum, + yesPercentOfTotal, + yesTotal, + }; +} diff --git a/src/lib/proposalVetoUi.ts b/src/lib/proposalVetoUi.ts index 47bf124..c6f3ba1 100644 --- a/src/lib/proposalVetoUi.ts +++ b/src/lib/proposalVetoUi.ts @@ -7,3 +7,44 @@ export function calculateCitizenVetoSupportPercent(input: { const vetoVotes = Math.max(0, input.vetoVotes); return Math.round((vetoVotes / eligibleCitizens) * 100); } + +type VetoActionGateInput = { + alreadyRecorded: boolean; + alreadyRecordedReason: string; + ineligibleReason: string; + submitting: boolean; + viewerEligible: boolean; + viewerIsProposer: boolean; + windowOpen: boolean; +}; + +export function getVetoActionGate({ + alreadyRecorded, + alreadyRecordedReason, + ineligibleReason, + submitting, + viewerEligible, + viewerIsProposer, + windowOpen, +}: VetoActionGateInput): { + disabled: boolean; + title: string | undefined; +} { + return { + disabled: + submitting || + !windowOpen || + alreadyRecorded || + !viewerEligible || + viewerIsProposer, + title: viewerIsProposer + ? "You cannot vote on your own proposal." + : alreadyRecorded + ? alreadyRecordedReason + : !windowOpen + ? "Veto window ended." + : !viewerEligible + ? ineligibleReason + : undefined, + }; +} diff --git a/src/pages/MyGovernance.tsx b/src/pages/MyGovernance.tsx index 1a311c5..334fdb6 100644 --- a/src/pages/MyGovernance.tsx +++ b/src/pages/MyGovernance.tsx @@ -1,181 +1,33 @@ -import { Check, X } from "lucide-react"; -import { Link } from "react-router"; import { useEffect, useMemo, useState } from "react"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/components/primitives/card"; -import { AppCard } from "@/components/AppCard"; -import { HintLabel } from "@/components/Hint"; -import { Badge } from "@/components/primitives/badge"; -import { Button } from "@/components/primitives/button"; -import { Input } from "@/components/primitives/input"; -import { AddressInline } from "@/components/AddressInline"; -import { PipelineList } from "@/components/PipelineList"; -import { StatGrid, makeChamberStats } from "@/components/StatGrid"; import { Surface } from "@/components/Surface"; import { PageHint } from "@/components/PageHint"; -import { Kicker } from "@/components/Kicker"; import { - apiChambers, - apiClock, apiDelegationClear, apiDelegationSet, apiLegitimacyObjectSet, - apiCmMe, - apiMyGovernance, } from "@/lib/apiClient"; import { formatLoadError } from "@/lib/errorFormatting"; +import { + formatDayHourMinute, + governingStatusForProgress, + governingStatusTermId, + type GoverningStatus, +} from "@/lib/myGovernanceUi"; import { toTimestampMs } from "@/lib/dateTime"; -import type { - ChamberDto, - CmSummaryDto, - GetClockResponse, - GetMyGovernanceResponse, -} from "@/types/api"; import { cn } from "@/lib/utils"; - -type GoverningStatus = - | "Ahead" - | "Stable" - | "Falling behind" - | "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, -): { label: GoverningStatus; termId: string } => { - if (required <= 0) { - return { label: "Stable", termId: "governing_status_stable" }; - } - - if (completed >= required + 1) { - return { label: "Ahead", termId: "governing_status_ahead" }; - } - - if (completed >= required) { - return { label: "Stable", termId: "governing_status_stable" }; - } - - const ratio = completed / required; - if (ratio >= 0.75) { - return { - label: "Falling behind", - termId: "governing_status_falling_behind", - }; - } - if (ratio >= 0.55) { - return { label: "At risk", termId: "governing_status_at_risk" }; - } - return { - label: "Losing status", - termId: "governing_status_losing_status", - }; -}; - -const governingStatusTermId = (label: GoverningStatus): string => { - if (label === "Ahead") return "governing_status_ahead"; - if (label === "Stable") return "governing_status_stable"; - if (label === "Falling behind") return "governing_status_falling_behind"; - if (label === "At risk") return "governing_status_at_risk"; - return "governing_status_losing_status"; -}; - -const formatDayHourMinute = (targetMs: number, nowMs: number): string => { - const deltaMs = Math.max(0, targetMs - nowMs); - const totalMinutes = Math.floor(deltaMs / 60_000); - const days = Math.floor(totalMinutes / (24 * 60)); - const hours = Math.floor((totalMinutes % (24 * 60)) / 60); - const minutes = totalMinutes % 60; - return `${days}d:${String(hours).padStart(2, "0")}h:${String(minutes).padStart(2, "0")}m`; -}; - -const chamberLabel = ( - chamberId: string, - chambers: ChamberDto[] | null, -): string => { - if (chamberId === "general") return "General chamber"; - const match = chambers?.find((item) => item.id === chamberId); - return match?.name ?? chamberId; -}; +import { MyGovernanceChambersCard } from "./my-governance/components/MyGovernanceChambersCard"; +import { MyGovernanceCmEconomyCard } from "./my-governance/components/MyGovernanceCmEconomyCard"; +import { MyGovernanceDelegationCard } from "./my-governance/components/MyGovernanceDelegationCard"; +import { MyGovernanceLegitimacyCard } from "./my-governance/components/MyGovernanceLegitimacyCard"; +import { MyGovernanceProgressionCard } from "./my-governance/components/MyGovernanceProgressionCard"; +import { MyGovernanceThresholdCard } from "./my-governance/components/MyGovernanceThresholdCard"; +import { MyGovernanceVetoProcessCard } from "./my-governance/components/MyGovernanceVetoProcessCard"; +import { useMyGovernancePageData } from "./my-governance/hooks/useMyGovernancePageData"; const MyGovernance: React.FC = () => { - const [gov, setGov] = useState(null); - const [chambers, setChambers] = useState(null); - const [clock, setClock] = useState(null); - const [cmSummary, setCmSummary] = useState(null); - const [loadError, setLoadError] = useState(null); const [legitimacyPending, setLegitimacyPending] = useState(false); const [legitimacyError, setLegitimacyError] = useState(null); - const [delegationDrafts, setDelegationDrafts] = useState< - Record - >({}); const [delegationPendingByChamber, setDelegationPendingByChamber] = useState< Record >({}); @@ -183,51 +35,22 @@ const MyGovernance: React.FC = () => { Record >({}); const [nowMs, setNowMs] = useState(() => Date.now()); + const { + chambers, + clock, + cmSummary, + delegationDrafts, + gov, + loadError, + refreshGovernance, + updateDelegationDraft, + } = useMyGovernancePageData(); useEffect(() => { const timer = window.setInterval(() => setNowMs(Date.now()), 1000); return () => window.clearInterval(timer); }, []); - useEffect(() => { - let active = true; - (async () => { - try { - const [govRes, chambersRes, clockRes] = await Promise.all([ - apiMyGovernance(), - apiChambers(), - apiClock().catch(() => null), - ]); - const cmRes = await apiCmMe().catch(() => null); - if (!active) return; - setGov(govRes); - setChambers(chambersRes.items); - setClock(clockRes); - setCmSummary(cmRes); - setDelegationDrafts((current) => { - const next = { ...current }; - for (const item of govRes.delegation.chambers) { - if (next[item.chamberId] === undefined) { - next[item.chamberId] = item.delegateeAddress ?? ""; - } - } - return next; - }); - setLoadError(null); - } catch (error) { - if (!active) return; - setGov(null); - setChambers(null); - setClock(null); - setCmSummary(null); - setLoadError((error as Error).message); - } - })(); - return () => { - active = false; - }; - }, []); - const eraActivity = gov?.eraActivity; const timeLeftValue = useMemo(() => { const targetMs = clock?.nextEraAt @@ -275,30 +98,6 @@ const MyGovernance: React.FC = () => { eraActivity?.required ?? 0, ); - 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; - const legitimacy = gov?.legitimacy ?? { percent: 100, objecting: false, @@ -308,25 +107,6 @@ const MyGovernance: React.FC = () => { triggerThresholdPercent: 33.3, }; - const updateDelegationDraft = (chamberId: string, value: string) => { - setDelegationDrafts((current) => ({ - ...current, - [chamberId]: value, - })); - }; - - const refreshGovernance = async () => { - const fresh = await apiMyGovernance(); - setGov(fresh); - setDelegationDrafts((current) => { - const next = { ...current }; - for (const item of fresh.delegation.chambers) { - next[item.chamberId] = item.delegateeAddress ?? ""; - } - return next; - }); - }; - const handleDelegationSet = async (chamberId: string) => { const delegateeAddress = delegationDrafts[chamberId]?.trim() ?? ""; if (!delegateeAddress) { @@ -411,610 +191,37 @@ const MyGovernance: React.FC = () => { return (
- - - - - Governing threshold - - - - - - This tracks opportunities that occurred during the current era, even - if those votes are already closed. - -
- {[ - { label: "Era", value: eraActivity?.era ?? "—" }, - { label: "Time left", value: timeLeftValue }, - ].map((tile) => ( - -

- {tile.label === "Era" ? ( - {tile.label} - ) : ( - tile.label - )} -

-

{tile.value}

-
- ))} -
-
- {[ - { - key: "required", - label: ( - - Era participation - - ), - value: eraActivity - ? `${eraActivity.completed} / ${eraActivity.required} completed this era` - : "—", - }, - { - key: "status", - label: "Status", - value: ( - {status.label} - ), - }, - ].map((tile) => ( - -

{tile.label}

-

{tile.value}

-
- ))} -
-
- {(eraActivity?.actions ?? []).map((act) => ( - - - {act.label} this era - -

- {act.done} / {act.required} -

-
- ))} -
-
-
- - - - Progression dashboard - - -
- - Current tier -

- {labelForTier(currentTier)} -

-
-
- Progress -
-
-
-

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

-
- - Next tier -

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

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

- {requirementLabel[key]} -

-

- {progress.done} / {progress.required} -

-
-
-
-
-

- {progress.percent}% -

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

- {requirementLabel[key]} -

- - {ok ? ( - -
- ); - })} -
-
-
- ) : ( - - You have reached the highest available tier. - - )} -
- -

- Proposals available with {labelForTier(currentTier)} -

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

- Proposals unlocked at {labelForTier(nextTier)} -

-
    - {proposalRightsByTier[nextTier].map((item) => ( -
  • {item}
  • - ))} -
-
- ) : null} -
- - - - - - Veto process - - - - Veto happens on the chamber vote page itself. Citizens and chambers - can cast veto votes during the chamber vote window, and if the - ordinary vote passes, a 24h veto countdown remains open on that same - page. - - - If a veto succeeds, the proposal returns to reconsideration draft - and the proposer can resubmit it directly into chamber vote without - going through proposal pool again. - - - - - - - My chambers - - -
- {myChambers.map((chamber) => ( - {chamber.name} - } - badge={ - - M × {chamber.multiplier} - - } - footer={ -
- -
- } - > - - -
- ))} -
-
-
- - - - CM economy - - - {!cmSummary ? ( - - CM summary unavailable. - - ) : ( - <> -
- {[ - { label: "LCM", value: cmSummary.totals.lcm }, - { label: "MCM", value: cmSummary.totals.mcm }, - { label: "ACM", value: cmSummary.totals.acm }, - ].map((tile) => ( - - {tile.label} -

- {tile.value.toLocaleString()} -

-
- ))} -
-
- {cmSummary.chambers.length === 0 ? ( - - No CM awards yet. - - ) : ( - cmSummary.chambers.map((chamber) => ( - -
-

- {chamber.chamberTitle} -

- - M × {chamber.multiplier} - -
-
- LCM {chamber.lcm} - MCM {chamber.mcm} - ACM {chamber.acm} -
-
- )) - )} -
- - )} -
-
- - - - Delegation - - - {gov?.delegation.chambers.length ? ( -
- {gov.delegation.chambers.map((item) => { - const pending = - delegationPendingByChamber[item.chamberId] ?? false; - const currentValue = - delegationDrafts[item.chamberId] ?? - item.delegateeAddress ?? - ""; - return ( - -
-
- - {chamberLabel(item.chamberId, chambers)} - -

- Current delegate -

- {item.delegateeAddress ? ( - - ) : ( -

No delegate set

- )} -
- - Inbound weight {item.inboundWeight} - -
- -
- - updateDelegationDraft( - item.chamberId, - event.target.value, - ) - } - placeholder="Delegate address" - disabled={pending} - /> -
- - -
-
- - {delegationErrorByChamber[item.chamberId] ? ( -

- {delegationErrorByChamber[item.chamberId]} -

- ) : null} -
- ); - })} -
- ) : ( - - Delegation becomes available once you are participating in chamber - governance. - - )} -
-
- - - - System legitimacy - - -
- {[ - { - label: "Legitimacy", - value: `${legitimacy.percent}%`, - }, - { - label: "Objectors", - value: `${legitimacy.objectingHumanNodes} / ${legitimacy.eligibleHumanNodes}`, - }, - { - label: "Referendum trigger", - value: `< ${legitimacy.triggerThresholdPercent}%`, - }, - ].map((tile) => ( - -

{tile.label}

-

{tile.value}

-
- ))} -
- - -

- Any active human node can object to Vortex legitimacy. Each - objector reduces legitimacy by their equal share of the active - human-node base. -

-
- - - {legitimacy.objecting - ? "You are objecting" - : "You are not objecting"} - - {legitimacy.referendumTriggered ? ( - - Referendum threshold reached - - ) : null} -
- {legitimacyError ? ( -

{legitimacyError}

- ) : null} -
-
-
+ + + + + + + + + + + void handleDelegationClear(chamberId)} + onSetDelegation={(chamberId) => void handleDelegationSet(chamberId)} + onUpdateDelegationDraft={updateDelegationDraft} + /> + +
); }; diff --git a/src/pages/factions/Faction.tsx b/src/pages/factions/Faction.tsx index ffae931..b74b456 100644 --- a/src/pages/factions/Faction.tsx +++ b/src/pages/factions/Faction.tsx @@ -1,23 +1,9 @@ -import { useEffect, useMemo, useState } from "react"; -import { Link, useNavigate, useParams, useSearchParams } from "react-router"; +import { Link, useParams } from "react-router"; -import { Kicker } from "@/components/Kicker"; -import { NoDataYetBar } from "@/components/NoDataYetBar"; import { PageHint } from "@/components/PageHint"; -import { AddressInline } from "@/components/AddressInline"; -import { StatTile } from "@/components/StatTile"; -import { Badge } from "@/components/primitives/badge"; import { Button } from "@/components/primitives/button"; +import { Card, CardContent } from "@/components/primitives/card"; import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/components/primitives/card"; -import { Input } from "@/components/primitives/input"; -import { Select } from "@/components/primitives/select"; -import { - apiFaction, apiFactionChannelCreate, apiFactionChannelLock, apiFactionCofounderInviteCancel, @@ -28,60 +14,36 @@ import { apiFactionLeave, apiFactionMemberRoleSet, apiFactionUpdate, - apiMe, - getApiErrorPayload, } from "@/lib/apiClient"; -import { addressesReferToSameIdentity } from "@/lib/addressIdentity"; -import { formatDateTime } from "@/lib/dateTime"; import { formatLoadError } from "@/lib/errorFormatting"; -import type { FactionDto } from "@/types/api"; +import { getFactionViewerPermissions } from "@/lib/factionUi"; +import { FactionChannelsSection } from "./components/FactionChannelsSection"; +import { FactionEditCard } from "./components/FactionEditCard"; +import { FactionHero } from "./components/FactionHero"; +import { FactionInitiativesSection } from "./components/FactionInitiativesSection"; +import { FactionMembersSection } from "./components/FactionMembersSection"; +import { FactionModerationQueues } from "./components/FactionModerationQueues"; +import { useFactionChannelDraft } from "./hooks/useFactionChannelDraft"; +import { useFactionActionRunner } from "./hooks/useFactionActionRunner"; +import { useFactionEditForm } from "./hooks/useFactionEditForm"; +import { useFactionLegacyThreadRedirect } from "./hooks/useFactionLegacyThreadRedirect"; +import { useFactionPageData } from "./hooks/useFactionPageData"; const Faction: React.FC = () => { const { id } = useParams(); - const [searchParams] = useSearchParams(); - const navigate = useNavigate(); - const [faction, setFaction] = useState(null); - const [loadError, setLoadError] = useState(null); - const [actionError, setActionError] = useState(null); - const [viewerAddress, setViewerAddress] = useState(null); - const [loading, setLoading] = useState(false); - const [mutating, setMutating] = useState(false); - - const [channelTitle, setChannelTitle] = useState(""); - const [channelScope, setChannelScope] = useState<"stewards" | "members">( - "members", - ); - - const [editOpen, setEditOpen] = useState(false); - const [editName, setEditName] = useState(""); - const [editDescription, setEditDescription] = useState(""); - const [editFocus, setEditFocus] = useState(""); - const [editVisibility, setEditVisibility] = useState<"public" | "private">( - "public", - ); - const [editGoalsText, setEditGoalsText] = useState(""); - const [editTagsText, setEditTagsText] = useState(""); - - const loadFaction = async () => { - if (!id) return; - setLoading(true); - try { - const [factionRes, meRes] = await Promise.all([apiFaction(id), apiMe()]); - setFaction(factionRes); - setViewerAddress(meRes.authenticated ? meRes.address : null); - setLoadError(null); - } catch (error) { - setFaction(null); - setViewerAddress(null); - setLoadError((error as Error).message); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - void loadFaction(); - }, [id]); + const { + faction, + loadError, + loading, + reload: reloadFaction, + viewerAddress, + } = useFactionPageData(id); + + const channelDraft = useFactionChannelDraft(); + const editForm = useFactionEditForm(faction); + const { actionError, mutating, runAction } = useFactionActionRunner({ + reload: reloadFaction, + }); const memberships = faction?.memberships ?? []; const channels = faction?.channels ?? []; @@ -91,65 +53,17 @@ const Faction: React.FC = () => { const joinRequests = faction?.joinRequests ?? []; const viewerJoinRequest = faction?.viewerJoinRequest ?? null; - const viewerMembership = useMemo(() => { - if (!viewerAddress) return null; - return memberships.find((membership) => - addressesReferToSameIdentity(membership.address, viewerAddress), - ); - }, [memberships, viewerAddress]); - - const viewerRole = viewerMembership?.isActive ? viewerMembership.role : null; - const isFounderAdmin = viewerRole === "founder"; - const canModerateQueues = - viewerRole === "founder" || viewerRole === "steward"; - const canJoin = !!viewerAddress && !viewerMembership?.isActive; - const canLeave = - !!viewerAddress && !!viewerMembership?.isActive && viewerRole !== "founder"; - const canManageMembers = isFounderAdmin; - - useEffect(() => { - const legacyThreadId = searchParams.get("thread"); - if (!legacyThreadId || !id || threads.length === 0) return; - const thread = threads.find((item) => item.id === legacyThreadId); - if (!thread) return; - navigate( - `/app/factions/${id}/channels/${thread.channelId}/threads/${thread.id}`, - { - replace: true, - }, - ); - }, [id, navigate, searchParams, threads]); - - useEffect(() => { - if (!faction) return; - setEditName(faction.name); - setEditDescription(faction.description); - setEditFocus(faction.focus || "General"); - setEditVisibility(faction.visibility === "private" ? "private" : "public"); - setEditGoalsText((faction.goals ?? []).join("\n")); - setEditTagsText((faction.tags ?? []).join(", ")); - }, [faction]); - - const setCommandError = (error: unknown) => { - const payload = getApiErrorPayload(error); - const message = - payload?.error?.message ?? - (error instanceof Error ? error.message : "Action failed"); - setActionError(message); - }; + const { + canJoin, + canLeave, + canManageMembers, + canModerateQueues, + isFounderAdmin, + viewerMembershipActive, + viewerRole, + } = getFactionViewerPermissions(memberships, viewerAddress); - const runAction = async (fn: () => Promise) => { - setActionError(null); - setMutating(true); - try { - await fn(); - await loadFaction(); - } catch (error) { - setCommandError(error); - } finally { - setMutating(false); - } - }; + useFactionLegacyThreadRedirect(id, threads); if (loading && !faction) { return ( @@ -178,191 +92,59 @@ const Faction: React.FC = () => { return (
- - -
-
- {faction.focus} -

- {faction.name} -

-

- {faction.description} -

-
- -
- {canJoin ? ( - - ) : null} - {canLeave ? ( - - ) : null} - {isFounderAdmin ? ( - - ) : null} -
-
- - {viewerRole === "founder" ? ( -
- - Founder leave disabled until transfer - -
- ) : null} - {viewerJoinRequest?.status === "pending" && - !viewerMembership?.isActive ? ( -
- - Private faction join request pending - -
- ) : null} -
-
- -
- - - + runAction(async () => { + await apiFactionJoin({ factionId: faction.id }); + }) + } + onLeave={() => + runAction(async () => { + await apiFactionLeave({ factionId: faction.id }); + }) + } + viewerJoinRequest={viewerJoinRequest} + viewerMembershipActive={viewerMembershipActive} + viewerRole={viewerRole} + /> + + {isFounderAdmin && editForm.open ? ( + + runAction(async () => { + await apiFactionDelete({ factionId: faction.id }); + }) } - align="center" + onDescriptionChange={editForm.setDescription} + onFocusChange={editForm.setFocus} + onGoalsTextChange={editForm.setGoalsText} + onNameChange={editForm.setName} + onSave={() => + runAction(async () => { + await apiFactionUpdate({ + factionId: faction.id, + ...editForm.payload(), + }); + editForm.close(); + }) + } + onTagsTextChange={editForm.setTagsText} + onVisibilityChange={editForm.setVisibility} + tagsText={editForm.tagsText} + visibility={editForm.visibility} /> -
- - {isFounderAdmin && editOpen ? ( - - - Edit faction - - - setEditName(event.target.value)} - placeholder="Faction name" - /> -