Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Governance #155

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
1,372 changes: 1,361 additions & 11 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@
"react-dom": "18.2.0",
"react-helmet-async": "^1.3.0",
"react-hot-toast": "^2.4.1",
"react-markdown": "^9.0.1",
"react-qrcode-logo": "^2.9.0",
"react-router-dom": "^6.4.3",
"react-select": "^5.8.0",
"remark-gfm": "^4.0.0",
"secretjs": "^1.12.4",
"tailwindcss": "^3.4.1",
"vite": "^5.1.7",
Expand Down
4 changes: 4 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import Powertools from 'pages/powertools/Powertools'
import { useSecretNetworkClientStore } from 'store/secretNetworkClient'
import { useUserPreferencesStore } from 'store/UserPreferences'
import { debugModeOverride } from 'utils/commons'
import Governance from 'pages/governance/Governance'
import GovernanceDetail from 'pages/governance/GovernanceDetail'
import Staking from 'pages/staking/Staking'
import Analytics from 'pages/analytics/Analytics'

Expand Down Expand Up @@ -117,6 +119,8 @@ export default function App() {
<Route path="/bridge" element={<Bridge />} />
<Route path="/get-scrt" element={<GetSCRT />} />
<Route path="/staking" element={<Staking />} />
<Route path="/governance" element={<Governance />} />
<Route path="/governance/id/:id" element={<GovernanceDetail />} />
<Route path="/portfolio" element={<Portfolio />} />
<Route path="/send" element={<Send />} />
<Route path="/apps" element={<Apps />} />
Expand Down
14 changes: 14 additions & 0 deletions src/components/Navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
faCreditCard,
faPaperPlane,
faPieChart,
faCheckToSlot,
faSeedling,
faArrowTrendUp,
faHouse
Expand Down Expand Up @@ -159,6 +160,19 @@ export function Navigation({
<span>Staking</span>
</NavLink>
</li>
<li>
<NavLink
to="/governance"
className={({ isActive }) =>
isActive
? 'isActiveNavLink dark:bg-neutral-800 text-black dark:text-white block w-full px-5 py-3 rounded-lg transition-colors font-semibold cursor-default'
: 'isInactiveNavLink text-black dark:text-white dark:hover:bg-neutral-800 block w-full px-5 py-3 rounded-lg transition-colors font-normal'
}
>
<FontAwesomeIcon icon={faCheckToSlot} className="mr-2" />
<span>Governance</span>
</NavLink>
</li>
<li>
<NavLink
to="/portfolio"
Expand Down
97 changes: 97 additions & 0 deletions src/pages/governance/Governance.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import Title from 'components/Title'
import GovernancePreviewItem from './components/GovernancePreviewItem'
import governanceUtils from 'utils/governanceUtils'
import { useEffect, useState } from 'react'
import { ProposalStatus } from 'secretjs'
import { Nullable } from 'types/Nullable'
import { Proposal } from 'secretjs/dist/protobuf/cosmos/gov/v1beta1/gov'
import Button from 'components/UI/Button/Button'

function Governance() {
const [proposals, setProposals] = useState<Nullable<Proposal[]>>(null)
const [visibleProposalsCount, setVisibleProposalsCount] = useState<number>(10)
const [loading, setLoading] = useState<boolean>(false)

useEffect(() => {
const fetchProposals = async () => {
setLoading(true)
const fetchedProposals = await governanceUtils.getProposals()
const filteredProposals =
fetchedProposals?.proposals?.filter((proposal: any) => {
const id = proposal.proposal_id || ''
return !governanceUtils.spamProposalIds.includes(Number(id))
}) || []
setProposals(filteredProposals)
setLoading(false)
}

fetchProposals().catch(console.error)
}, [])

const loadMoreProposals = () => {
if (proposals && visibleProposalsCount < proposals.length) {
setVisibleProposalsCount(visibleProposalsCount + 10)
}
}

const visibleProposals = proposals?.slice(0, visibleProposalsCount)

return (
<div>
<div className="container w-full mx-auto px-4">
{/* Title */}
<Title
title={`Governance`}
tooltip={`Voting on proposals, which are submitted by SCRT holders on the mainnet.`}
className="mb-6"
/>
{/* Content */}
<div className="grid grid-cols-12 gap-4">
{/* Proposals */}
{loading
? // Skeleton Loader
Array.from({ length: 10 }).map((_, index) => (
<div className="col-span-12 sm:col-span-6 lg:col-span-12 xl:col-span-6 animate-pulse" key={index}>
<div className="animate-pulse bg-neutral-300/40 dark:bg-neutral-700/40 rounded col-span-2 mx-auto h-25 w-full rounded"></div>
</div>
))
: visibleProposals?.map((proposal: any, index: number) => {
const id = proposal.proposal_id || ''

const title = proposal?.content?.title || ''
const proposalStatus: ProposalStatus | undefined =
ProposalStatus[proposal.status as keyof typeof ProposalStatus]
const votes = {
yes: Number(proposal.final_tally_result.yes),
abstain: Number(proposal.final_tally_result.abstain),
no: Number(proposal.final_tally_result.no),
noWithVeto: Number(proposal.final_tally_result.no_with_veto)
}
const totalBondedStake = votes.yes + votes.abstain + votes.no + votes.noWithVeto

return (
<div className="col-span-12 sm:col-span-6 lg:col-span-12 xl:col-span-6" key={index}>
<GovernancePreviewItem
id={id}
title={title}
totalBondedStake={totalBondedStake}
votes={votes}
proposalStatus={proposalStatus}
isExpedited={proposal.is_expedited}
/>
</div>
)
})}
</div>
{/* More Button */}
{proposals && visibleProposalsCount < proposals.length && (
<div className="text-center my-4">
<Button onClick={loadMoreProposals}>More</Button>
</div>
)}
</div>
</div>
)
}

export default Governance
203 changes: 203 additions & 0 deletions src/pages/governance/GovernanceDetail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import governanceUtils from 'utils/governanceUtils'
import StatusBadge from './components/StatusBadge'
import { Nullable } from 'types/Nullable'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { formatNumber } from 'utils/commons'
import ExpeditedBadge from './components/ExpeditedBadge'

function GovernanceDetail() {
const navigate = useNavigate()
let { id } = useParams()

// Redirect if the proposal is spam
if (governanceUtils.spamProposalIds.includes(Number(id))) {
navigate('/governance')
}

const [proposal, setProposal] = useState<Nullable<any>>(null)
const [tally, setTally] = useState<Nullable<any>>(null)
const [loading, setLoading] = useState<boolean>(true)

useEffect(() => {
const fetchProposalDetails = async () => {
try {
const [fetchedProposal, fetchedTally] = await Promise.all([
governanceUtils.getProposal(id!),
governanceUtils.getProposalTally(id!)
])

setProposal(fetchedProposal?.proposal || null)
setTally(fetchedTally || null)
setLoading(false)
} catch (error: any) {
console.error(error)
navigate('/governance')
}
}
fetchProposalDetails().catch(console.error)
}, [id, navigate])

function convertUTCToLocalTime(utcDateString: string) {
const date = new Date(utcDateString)

const dateOptions: Intl.DateTimeFormatOptions = {
day: '2-digit',
month: 'short',
year: 'numeric'
}

const timeOptions: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
hour12: false
}

const localDateString = date.toLocaleDateString(undefined, dateOptions)
const localTimeString = date.toLocaleTimeString(undefined, timeOptions)

return `${localDateString} - ${localTimeString}`
}

const [localVotingStartDate, setLocalVotingStartDate] = useState<Nullable<string>>(null)
const [localVotingEndDate, setLocalVotingEndDate] = useState<Nullable<string>>(null)

useEffect(() => {
if (proposal) {
setLocalVotingStartDate(convertUTCToLocalTime(proposal.voting_start_time))
setLocalVotingEndDate(convertUTCToLocalTime(proposal.voting_end_time))
}
}, [proposal])

// Calculate total votes from tally data
const totalVotes = tally
? ['yes', 'abstain', 'no', 'no_with_veto'].reduce((sum, key) => sum + Number(tally[key]), 0)
: 0

// Define colors
const colors: Record<string, string> = {
yes: 'bg-emerald-600 dark:bg-emerald-500',
abstain: 'bg-gray-200 dark:bg-gray-700',
no: 'bg-rose-600 dark:bg-rose-500',
no_with_veto: 'bg-pink-600 dark:bg-pink-500'
}

// Define vote types array
const voteTypes: string[] = ['yes', 'no', 'no_with_veto', 'abstain']

return (
<div>
{loading ? (
<div>
{/* Status Badge Skeleton */}
<div className="h-6 w-32 bg-gray-200 dark:bg-neutral-700 rounded-full animate-pulse mb-4"></div>

{/* Proposal Title Skeleton */}
<div className="h-8 w-3/4 bg-gray-200 dark:bg-neutral-700 rounded animate-pulse mb-6"></div>

{/* Proposal Header Skeleton */}
<div className="bg-white dark:bg-neutral-800 p-4 rounded-xl overflow-hidden mt-4">
<div className="grid grid-cols-2 gap-4 animate-pulse"></div>
</div>

{/* Proposal Description Skeleton */}
<div className="bg-white dark:bg-neutral-800 p-4 rounded-xl overflow-hidden mt-6">
<div className="h-6 w-40 bg-gray-200 dark:bg-neutral-700 rounded mb-4 animate-pulse"></div>
<div className="space-y-4"></div>
</div>

{/* Voting Results Skeleton */}
<div className="bg-white dark:bg-neutral-800 p-4 rounded-xl overflow-hidden mt-6">
<div className="h-6 w-40 bg-gray-200 dark:bg-neutral-700 rounded mb-4 animate-pulse"></div>
<div className="space-y-4">
{voteTypes.map((_, index) => (
<div key={index}>
<div className="flex justify-between mb-1">
<div className="h-4 w-24 bg-gray-200 dark:bg-neutral-700 rounded animate-pulse"></div>
<div className="h-4 w-16 bg-gray-200 dark:bg-neutral-700 rounded animate-pulse"></div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div className="bg-gray-300 h-2.5 rounded-full animate-pulse" style={{ width: '50%' }}></div>
</div>
</div>
))}
</div>
</div>
</div>
) : proposal ? (
<>
<div className="flex items-center justify-between">
{/* Proposal Title */}
{proposal?.proposal_id && proposal?.content?.title && (
<h1 className="text-xl font-bold">{`#${proposal.proposal_id} ${proposal.content.title}`}</h1>
)}
{/* Status Badge */}
<div className="space-x-2">
{' '}
<StatusBadge proposalStatus={governanceUtils.getProposalStatus(proposal.status as unknown as string)} />
{proposal.is_expedited ? <ExpeditedBadge /> : null}{' '}
</div>
</div>

{/* Proposal Header */}
<div className="bg-white dark:bg-neutral-800 p-4 flex flex-col h-full rounded-xl overflow-hidden mt-4">
<div className="grid grid-cols-2 gap-4">
<div className="col-auto">
<div className="font-bold">Voting Start</div>
<div>{localVotingStartDate}</div>
</div>
<div className="col-auto">
<div className="font-bold">Voting End</div>
<div>{localVotingEndDate}</div>
</div>
</div>
</div>

{/* Proposal Description */}
<div className="bg-white dark:bg-neutral-800 p-4 flex flex-col h-full rounded-xl overflow-hidden mt-6">
<h2 className="text-xl font-semibold mb-4">Description</h2>
<div className="prose max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{proposal.content.description}</ReactMarkdown>
</div>
</div>

{/* Voting Results */}
{tally && (
<div className="bg-white dark:bg-neutral-800 p-4 flex flex-col h-full rounded-xl overflow-hidden mt-6">
<h2 className="text-xl font-semibold mb-4">Voting Results</h2>
<div className="flex flex-col gap-4">
{voteTypes.map((voteType) => {
const voteCount = Number(tally[voteType])
const percentage = totalVotes ? (voteCount / totalVotes) * 100 : 0
const label = voteType.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
const displayValue = `${formatNumber(voteCount / 1e6, 2)} SCRT (${percentage.toFixed(2)}%)`

return (
<div key={voteType}>
<div className="flex justify-between mb-1">
<span className="text-base font-medium">{label}</span>
<span className="text-sm font-medium">{displayValue}</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className={`${colors[voteType]} h-2.5 rounded-full`}
style={{ width: `${percentage}%` }}
></div>
</div>
</div>
)
})}
</div>
</div>
)}
</>
) : (
<div className="text-center text-gray-500">Proposal not found.</div>
)}
</div>
)
}

export default GovernanceDetail
17 changes: 17 additions & 0 deletions src/pages/governance/components/ExpeditedBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faClock } from '@fortawesome/free-solid-svg-icons'

function ExpeditedBadge() {
return (
<span
className={
'text-xs font-medium px-3 py-1 rounded border inline-flex gap-2 items-center bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300 border-blue-200 dark:border-blue-900'
}
>
<FontAwesomeIcon icon={faClock} />
Expedited
</span>
)
}

export default ExpeditedBadge
Loading