feat: initial Pezkuwi Apps rebrand from polkadot-apps

Rebranded terminology:
- Polkadot → Pezkuwi
- Kusama → Dicle
- Westend → Zagros
- Rococo → PezkuwiChain
- Substrate → Bizinikiwi
- parachain → teyrchain

Custom logos with Kurdistan brand colors (#e6007a → #86e62a):
- bizinikiwi-hexagon.svg
- sora-bizinikiwi.svg
- hezscanner.svg
- heztreasury.svg
- pezkuwiscan.svg
- pezkuwistats.svg
- pezkuwiassembly.svg
- pezkuwiholic.svg
This commit is contained in:
2026-01-07 13:05:27 +03:00
commit d21bfb1320
5867 changed files with 329019 additions and 0 deletions
@@ -0,0 +1,83 @@
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Hash, Proposal, ProposalIndex } from '@pezkuwi/types/interfaces';
import React, { useState } from 'react';
import { Button, InputAddress, Modal, TxButton } from '@pezkuwi/react-components';
import { useApi, useCollectiveInstance, useToggle, useWeight } from '@pezkuwi/react-hooks';
import { ProposedAction } from '@pezkuwi/react-params';
import { useTranslation } from '../translate.js';
interface Props {
hasFailed: boolean;
hash: Hash;
idNumber: ProposalIndex;
proposal: Proposal | null;
}
function Close ({ hasFailed, hash, idNumber, proposal }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const [isOpen, toggleOpen] = useToggle();
const [accountId, setAccountId] = useState<string | null>(null);
const { encodedCallLength, weight } = useWeight(proposal);
const modLocation = useCollectiveInstance('council');
// protect against older versions
if (!modLocation) {
return null;
}
return (
<>
{isOpen && (
<Modal
header={t('Close proposal')}
onClose={toggleOpen}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('The proposal that will be affected. Once closed for the current voting round, it would need to be re-submitted to council for a subsequent voting round.')}>
<ProposedAction
idNumber={idNumber}
proposal={proposal}
/>
</Modal.Columns>
<Modal.Columns hint={t('The council account that will apply the close for the current round.')}>
<InputAddress
label={t('close from account')}
onChange={setAccountId}
type='account'
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={accountId}
isDisabled={!hasFailed && !encodedCallLength}
onStart={toggleOpen}
params={
api.tx[modLocation].close.meta.args.length === 4
? hasFailed
? [hash, idNumber, 0, 0]
: [hash, idNumber, weight, encodedCallLength]
: [hash, idNumber]
}
tx={api.tx[modLocation].closeOperational || api.tx[modLocation].close}
/>
</Modal.Actions>
</Modal>
)}
<Button
icon='times'
label={t('Close')}
onClick={toggleOpen}
/>
</>
);
}
export default React.memo(Close);
@@ -0,0 +1,133 @@
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
import type { AccountId } from '@pezkuwi/types/interfaces';
import React, { useMemo } from 'react';
import ProposalCell from '@pezkuwi/app-democracy/Overview/ProposalCell';
import { Icon, LinkExternal, Table } from '@pezkuwi/react-components';
import { useAccounts, useCollectiveInstance, useVotingStatus } from '@pezkuwi/react-hooks';
import { BlockToTime } from '@pezkuwi/react-query';
import { formatNumber } from '@pezkuwi/util';
import Close from './Close.js';
import Voters from './Voters.js';
import Voting from './Voting.js';
interface Props {
className?: string;
isMember: boolean;
members: string[];
motion: DeriveCollectiveProposal;
prime?: AccountId | null;
}
interface VoterState {
hasVoted: boolean;
hasVotedAye: boolean;
hasVotedNay: boolean;
}
function Motion ({ className = '', isMember, members, motion: { hash, proposal, votes }, prime }: Props): React.ReactElement<Props> | null {
const { allAccounts } = useAccounts();
const { hasFailed, isCloseable, isVoteable, remainingBlocks } = useVotingStatus(votes, members.length, 'council');
const modLocation = useCollectiveInstance('council');
const { hasVoted, hasVotedAye } = useMemo(
(): VoterState => {
if (votes) {
const hasVotedAye = allAccounts.some((a) => votes.ayes.some((accountId) => accountId.eq(a)));
const hasVotedNay = allAccounts.some((a) => votes.nays.some((accountId) => accountId.eq(a)));
return {
hasVoted: hasVotedAye || hasVotedNay,
hasVotedAye,
hasVotedNay
};
}
return { hasVoted: false, hasVotedAye: false, hasVotedNay: false };
},
[allAccounts, votes]
);
if (!votes || !modLocation) {
return null;
}
const { ayes, end, index, nays, threshold } = votes;
return (
<tr className={className}>
<Table.Column.Id value={index} />
<ProposalCell
imageHash={hash}
isCollective
proposal={proposal}
/>
<td className='number together'>
{formatNumber(threshold)}
</td>
<td className='number together'>
{remainingBlocks && end && (
<>
<BlockToTime value={remainingBlocks} />
#{formatNumber(end)}
</>
)}
</td>
<td className='expand'>
<Voters
isAye
members={members}
threshold={threshold}
votes={ayes}
/>
<Voters
members={members}
threshold={threshold}
votes={nays}
/>
</td>
<td className='button'>
{isVoteable && !isCloseable && (
<Voting
hash={hash}
idNumber={index}
isDisabled={!isMember}
members={members}
prime={prime}
proposal={proposal}
/>
)}
{isCloseable && (
<Close
hasFailed={hasFailed}
hash={hash}
idNumber={index}
proposal={proposal}
/>
)}
</td>
<td className='badge'>
{isMember && (
<Icon
color={hasVoted ? (hasVotedAye ? 'green' : 'red') : 'gray'}
icon='asterisk'
/>
)}
</td>
<td className='links'>
<LinkExternal
data={index}
hash={hash.toString()}
type='council'
/>
</td>
</tr>
);
}
export default React.memo(Motion);
@@ -0,0 +1,178 @@
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SubmittableExtrinsic } from '@pezkuwi/api/types';
import type { BN } from '@pezkuwi/util';
import type { HexString } from '@pezkuwi/util/types';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { getProposalThreshold } from '@pezkuwi/apps-config';
import { Button, Input, InputAddress, InputNumber, Modal, TxButton } from '@pezkuwi/react-components';
import { useApi, useCollectiveInstance, usePreimage, useToggle } from '@pezkuwi/react-hooks';
import { BN_ZERO, isFunction, isHex } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props {
className?: string;
isMember: boolean;
members: string[];
}
interface HashState {
hash?: HexString | null;
isHashValid: boolean;
}
interface ImageState {
imageLen: BN;
imageLenDefault?: BN;
isImageLenValid: boolean;
}
interface ProposalState {
proposal?: SubmittableExtrinsic<'promise'> | null;
proposalLength: number;
}
function ProposeExternal ({ className = '', isMember, members }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const [isVisible, toggleVisible] = useToggle();
const [accountId, setAcountId] = useState<string | null>(null);
const [{ proposal, proposalLength }, setProposal] = useState<ProposalState>({ proposalLength: 0 });
const [{ hash, isHashValid }, setHash] = useState<HashState>({ isHashValid: false });
const [{ imageLen, imageLenDefault, isImageLenValid }, setImageLen] = useState<ImageState>({ imageLen: BN_ZERO, isImageLenValid: false });
const modLocation = useCollectiveInstance('council');
const preimage = usePreimage(hash);
const threshold = Math.min(members.length, Math.ceil((members.length || 0) * getProposalThreshold(api)));
const isCurrentPreimage = useMemo(
() => isFunction(api.tx.preimage?.notePreimage) && !isFunction(api.tx.democracy?.notePreimage),
[api]
);
const _onChangeHash = useCallback(
(hash?: string): void =>
setHash({ hash: hash as HexString, isHashValid: isHex(hash, 256) }),
[]
);
const _onChangeImageLen = useCallback(
(value?: BN): void => {
value && setImageLen((prev) => ({
imageLen: value,
imageLenDefault: prev.imageLenDefault,
isImageLenValid: !value.isZero()
}));
},
[]
);
useEffect((): void => {
preimage?.proposalLength && setImageLen((prev) => ({
imageLen: prev.imageLen,
imageLenDefault: preimage.proposalLength,
isImageLenValid: prev.isImageLenValid
}));
}, [preimage]);
useEffect((): void => {
if (isHashValid && hash) {
const proposal = isCurrentPreimage
? preimage && api.tx.democracy.externalProposeMajority({
Lookup: {
hash: preimage.proposalHash,
len: preimage.proposalLength || imageLen
}
})
: api.tx.democracy.externalProposeMajority(hash);
if (proposal) {
return setProposal({
proposal,
proposalLength: proposal.encodedLength || 0
});
}
}
setProposal({
proposal: null,
proposalLength: 0
});
}, [api, hash, isCurrentPreimage, isHashValid, imageLen, preimage]);
if (!modLocation) {
return null;
}
return (
<>
<Button
icon='plus'
isDisabled={!isMember}
label={t('Propose external')}
onClick={toggleVisible}
/>
{isVisible && (
<Modal
className={className}
header={t('Propose external (majority)')}
onClose={toggleVisible}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('The council account for the proposal. The selection is filtered by the current members.')}>
<InputAddress
filter={members}
label={t('propose from account')}
onChange={setAcountId}
type='account'
withLabel
/>
</Modal.Columns>
<Modal.Columns hint={t('The hash of the proposal image, either already submitted or valid for the specific call.')}>
<Input
autoFocus
isError={!isHashValid}
label={t('preimage hash')}
onChange={_onChangeHash}
value={hash}
/>
{isCurrentPreimage && (
<InputNumber
defaultValue={imageLenDefault}
isDisabled={!!preimage?.proposalLength && !preimage?.proposalLength.isZero() && isHashValid && isImageLenValid}
isError={!isImageLenValid}
key='inputLength'
label={t('preimage length')}
onChange={_onChangeImageLen}
value={imageLen}
/>
)}
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={accountId}
icon='plus'
isDisabled={!threshold || !members.includes(accountId || '') || !proposal || (isCurrentPreimage && !isImageLenValid)}
label={t('Propose')}
onStart={toggleVisible}
params={
api.tx[modLocation].propose.meta.args.length === 3
? [threshold, proposal, proposalLength]
: [threshold, proposal]
}
tx={api.tx[modLocation].propose}
/>
</Modal.Actions>
</Modal>
)}
</>
);
}
export default React.memo(ProposeExternal);
@@ -0,0 +1,129 @@
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SubmittableExtrinsic } from '@pezkuwi/api/types';
import React, { useCallback, useEffect, useState } from 'react';
import { getProposalThreshold } from '@pezkuwi/apps-config';
import { Button, InputAddress, InputNumber, Modal, TxButton } from '@pezkuwi/react-components';
import { useApi, useCollectiveInstance, useToggle } from '@pezkuwi/react-hooks';
import { Extrinsic } from '@pezkuwi/react-params';
import { BN, BN_ZERO } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props {
isMember: boolean;
members: string[];
}
interface Threshold {
isThresholdValid: boolean;
threshold?: BN;
}
interface ProposalState {
proposal?: SubmittableExtrinsic<'promise'> | null;
proposalLength: number;
}
function Propose ({ isMember, members }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api, apiDefaultTxSudo } = useApi();
const [isOpen, toggleOpen] = useToggle();
const [accountId, setAcountId] = useState<string | null>(null);
const [{ proposal, proposalLength }, setProposal] = useState<ProposalState>({ proposalLength: 0 });
const [{ isThresholdValid, threshold }, setThreshold] = useState<Threshold>({ isThresholdValid: false });
const modLocation = useCollectiveInstance('council');
useEffect((): void => {
members && setThreshold({
isThresholdValid: members.length !== 0,
threshold: new BN(Math.min(members.length, Math.ceil(members.length * getProposalThreshold(api))))
});
}, [api, members]);
const _setMethod = useCallback(
(proposal?: SubmittableExtrinsic<'promise'> | null) => setProposal({
proposal,
proposalLength: proposal?.encodedLength || 0
}),
[]
);
const _setThreshold = useCallback(
(threshold?: BN) => setThreshold({
isThresholdValid: !!threshold?.gtn(0),
threshold
}),
[]
);
if (!modLocation) {
return null;
}
return (
<>
<Button
icon='plus'
isDisabled={!isMember}
label={t('Propose motion')}
onClick={toggleOpen}
/>
{isOpen && (
<Modal
header={t('Propose a council motion')}
onClose={toggleOpen}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('The council account for the proposal. The selection is filtered by the current members.')}>
<InputAddress
filter={members}
label={t('propose from account')}
onChange={setAcountId}
type='account'
withLabel
/>
</Modal.Columns>
<Modal.Columns hint={t('The desired threshold. Here set to a default of 50%+1, as applicable for general proposals.')}>
<InputNumber
className='medium'
isError={!threshold || threshold.eqn(0) || threshold.gtn(members.length)}
label={t('threshold')}
onChange={_setThreshold}
placeholder={t('Positive number between 1 and {{memberCount}}', { replace: { memberCount: members.length } })}
value={threshold || BN_ZERO}
/>
</Modal.Columns>
<Modal.Columns hint={t('The actual proposal to make, based on the selected call and parameters thereof.')}>
<Extrinsic
defaultValue={apiDefaultTxSudo}
label={t('proposal')}
onChange={_setMethod}
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={accountId}
isDisabled={!proposal || !isThresholdValid}
label={t('Propose')}
onStart={toggleOpen}
params={
api.tx[modLocation].propose.meta.args.length === 3
? [threshold, proposal, proposalLength]
: [threshold, proposal]
}
tx={api.tx[modLocation].propose}
/>
</Modal.Actions>
</Modal>
)}
</>
);
}
export default React.memo(Propose);
@@ -0,0 +1,137 @@
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SubmittableExtrinsic } from '@pezkuwi/api/types';
import React, { useEffect, useMemo, useState } from 'react';
import { getSlashProposalThreshold } from '@pezkuwi/apps-config';
import { Button, Dropdown, Input, InputAddress, Modal, TxButton } from '@pezkuwi/react-components';
import { useApi, useAvailableSlashes, useCollectiveInstance, useToggle } from '@pezkuwi/react-hooks';
import { useTranslation } from '../translate.js';
interface Props {
className?: string;
isMember: boolean;
members: string[];
}
interface Option {
text: string;
value: number;
}
interface ProposalState {
proposal?: SubmittableExtrinsic<'promise'> | null;
proposalLength: number;
}
function Slashing ({ className = '', isMember, members }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const slashes = useAvailableSlashes();
const [isVisible, toggleVisible] = useToggle();
const [accountId, setAcountId] = useState<string | null>(null);
const [{ proposal, proposalLength }, setProposal] = useState<ProposalState>({ proposal: null, proposalLength: 0 });
const [selectedEra, setSelectedEra] = useState(0);
const modLocation = useCollectiveInstance('council');
const threshold = Math.ceil((members.length || 0) * getSlashProposalThreshold(api));
const eras = useMemo(
() => (slashes || []).map(([era, slashes]): Option => ({
text: t('era {{era}}, {{count}} slashes', {
replace: {
count: slashes.length,
era: era.toNumber()
}
}),
value: era.toNumber()
})),
[slashes, t]
);
useEffect((): void => {
const actioned = selectedEra && slashes?.find(([era]) => era.eqn(selectedEra));
const proposal = actioned
? api.tx.staking.cancelDeferredSlash(actioned[0], actioned[1].map((_, index) => index))
: null;
setProposal({
proposal,
proposalLength: proposal?.encodedLength || 0
});
}, [api, selectedEra, slashes]);
if (!modLocation || !api.tx.staking) {
return null;
}
return (
<>
<Button
icon='sync'
isDisabled={!isMember || !slashes.length}
label={t('Cancel slashes')}
onClick={toggleVisible}
/>
{isVisible && (
<Modal
className={className}
header={t('Revert pending slashes')}
onClose={toggleVisible}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('The council account for the proposal. The selection is filtered by the current members.')}>
<InputAddress
filter={members}
label={t('propose from account')}
onChange={setAcountId}
type='account'
withLabel
/>
</Modal.Columns>
<Modal.Columns hint={t('The specific eras on which there are unapplied slashes. For each era a separate proposal is to be made.')}>
{eras.length
? (
<Dropdown
defaultValue={eras[0].value}
label={t('the era to cancel for')}
onChange={setSelectedEra}
options={eras}
/>
)
: (
<Input
isDisabled
label={t('the era to cancel for')}
value={t('no unapplied slashes found')}
/>
)
}
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={accountId}
icon='sync'
isDisabled={!threshold || !members.includes(accountId || '') || !proposal}
label={t('Revert')}
onStart={toggleVisible}
params={
api.tx[modLocation].propose.meta.args.length === 3
? [threshold, proposal, proposalLength]
: [threshold, proposal]
}
tx={api.tx[modLocation].propose}
/>
</Modal.Actions>
</Modal>
)}
</>
);
}
export default React.memo(Slashing);
@@ -0,0 +1,63 @@
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountId, MemberCount } from '@pezkuwi/types/interfaces';
import React, { useCallback, useMemo } from 'react';
import { AddressMini, ExpanderScroll } from '@pezkuwi/react-components';
import { useTranslation } from '../translate.js';
interface Props {
isAye?: boolean;
members: string[];
votes: AccountId[];
threshold: MemberCount;
}
function Voters ({ isAye, members, threshold, votes }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const count = useMemo(
(): string => {
const num = threshold.toNumber();
const max = isAye
? num
: members?.length
? (members.length - num) + 1
: 0;
return `${votes.length}${max ? `/${max}` : ''}`;
},
[isAye, members, threshold, votes]
);
const renderVotes = useCallback(
() => votes?.map((address): React.ReactNode => (
<AddressMini
key={address.toString()}
value={address}
withBalance={false}
/>
)),
[votes]
);
if (!count || !votes.length) {
return null;
}
return (
<ExpanderScroll
renderChildren={renderVotes}
summary={
isAye
? t('Aye {{count}}', { replace: { count } })
: t('Nay {{count}}', { replace: { count } })
}
/>
);
}
export default React.memo(Voters);
@@ -0,0 +1,94 @@
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountId, Hash, Proposal, ProposalIndex } from '@pezkuwi/types/interfaces';
import React, { useState } from 'react';
import { Button, MarkWarning, Modal, TxButton, VoteAccount } from '@pezkuwi/react-components';
import { useAccounts, useApi, useCollectiveInstance, useToggle } from '@pezkuwi/react-hooks';
import { ProposedAction } from '@pezkuwi/react-params';
import { useTranslation } from '../translate.js';
interface Props {
hash: Hash;
idNumber: ProposalIndex;
isDisabled: boolean;
members: string[];
prime?: AccountId | null;
proposal: Proposal | null;
}
function Voting ({ hash, idNumber, isDisabled, members, prime, proposal }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const { hasAccounts } = useAccounts();
const [isVotingOpen, toggleVoting] = useToggle();
const [accountId, setAccountId] = useState<string | null>(null);
const modLocation = useCollectiveInstance('council');
if (!hasAccounts || !modLocation) {
return null;
}
const isPrime = prime?.toString() === accountId;
return (
<>
{isVotingOpen && (
<Modal
header={t('Vote on proposal')}
onClose={toggleVoting}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('The proposal that is being voted on. It will pass when the threshold is reached.')}>
<ProposedAction
idNumber={idNumber}
proposal={proposal}
/>
</Modal.Columns>
<Modal.Columns hint={t('The council account for this vote. The selection is filtered by the current members.')}>
<VoteAccount
filter={members}
onChange={setAccountId}
/>
{isPrime && (
<MarkWarning content={t('You are voting with this collective\'s prime account. The vote will be the default outcome in case of any abstentions.')} />
)}
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={accountId}
icon='ban'
isDisabled={isDisabled}
label={t('Vote Nay')}
onStart={toggleVoting}
params={[hash, idNumber, false]}
tx={api.tx[modLocation].vote}
/>
<TxButton
accountId={accountId}
icon='check-to-slot'
isDisabled={isDisabled}
label={t('Vote Aye')}
onStart={toggleVoting}
params={[hash, idNumber, true]}
tx={api.tx[modLocation].vote}
/>
</Modal.Actions>
</Modal>
)}
<Button
icon='check-to-slot'
isDisabled={isDisabled}
label={t('Vote')}
onClick={toggleVoting}
/>
</>
);
}
export default React.memo(Voting);
@@ -0,0 +1,72 @@
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
import type { AccountId } from '@pezkuwi/types/interfaces';
import React, { useRef } from 'react';
import { Button, Table } from '@pezkuwi/react-components';
import { useCollectiveMembers } from '@pezkuwi/react-hooks';
import { useTranslation } from '../translate.js';
import Motion from './Motion.js';
import ProposeExternal from './ProposeExternal.js';
import ProposeMotion from './ProposeMotion.js';
import Slashing from './Slashing.js';
interface Props {
className?: string;
motions?: DeriveCollectiveProposal[];
prime?: AccountId | null;
}
function Proposals ({ className = '', motions, prime }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { isMember, members } = useCollectiveMembers('council');
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
[t('motions'), 'start', 2],
[t('threshold')],
[t('voting end')],
[t('votes'), 'expand'],
[],
[undefined, 'badge'],
[]
]);
return (
<div className={className}>
<Button.Group>
<ProposeMotion
isMember={isMember}
members={members}
/>
<ProposeExternal
isMember={isMember}
members={members}
/>
<Slashing
isMember={isMember}
members={members}
/>
</Button.Group>
<Table
empty={motions && t('No council motions')}
header={headerRef.current}
>
{motions?.map((motion: DeriveCollectiveProposal): React.ReactNode => (
<Motion
isMember={isMember}
key={motion.hash.toHex()}
members={members}
motion={motion}
prime={prime}
/>
))}
</Table>
</div>
);
}
export default React.memo(Proposals);
@@ -0,0 +1,53 @@
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountId, Balance } from '@pezkuwi/types/interfaces';
import React from 'react';
import { AddressSmall, Tag } from '@pezkuwi/react-components';
import { formatNumber } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
import Voters from './Voters.js';
interface Props {
className?: string;
address: AccountId;
balance?: Balance;
hasElections: boolean;
isPrime?: boolean;
voters?: AccountId[];
}
function Candidate ({ address, balance, className = '', hasElections, isPrime, voters }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
return (
<>
<tr className={`${className} isExpanded isFirst ${hasElections ? '' : 'isLast'}`}>
<td className='address all relative'>
<AddressSmall value={address} />
{isPrime && (
<Tag
className='absolute'
color='green'
label={t('prime')}
/>
)}
</td>
<td className='number'>
{voters && formatNumber(voters.length)}
</td>
</tr>
{hasElections && (
<Voters
balance={balance}
voters={voters}
/>
)}
</>
);
}
export default React.memo(Candidate);
@@ -0,0 +1,67 @@
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveElectionsInfo } from '@pezkuwi/api-derive/types';
import type { AccountId } from '@pezkuwi/types/interfaces';
import React, { useRef } from 'react';
import { Table } from '@pezkuwi/react-components';
import { useTranslation } from '../translate.js';
import Candidate from './Candidate.js';
interface Props {
allVotes?: Record<string, AccountId[]>;
className?: string;
electionsInfo?: DeriveElectionsInfo;
hasElections: boolean;
}
function Candidates ({ allVotes = {}, electionsInfo, hasElections }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const headerCandidatesRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
[t('candidates'), 'start', 2]
]);
const headerRunnersRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
[t('runners up'), 'start', 2]
]);
return (
<>
<Table
empty={electionsInfo && t('No runners up found')}
header={headerRunnersRef.current}
isSplit
>
{electionsInfo?.runnersUp.map(([accountId, balance]): React.ReactNode => (
<Candidate
address={accountId}
balance={balance}
hasElections={hasElections}
key={accountId.toString()}
voters={allVotes[accountId.toString()]}
/>
))}
</Table>
<Table
empty={electionsInfo && t('No candidates found')}
header={headerCandidatesRef.current}
isSplit
>
{electionsInfo?.candidates.map((accountId): React.ReactNode => (
<Candidate
address={accountId}
hasElections={false}
key={accountId.toString()}
voters={allVotes[accountId.toString()]}
/>
))}
</Table>
</>
);
}
export default React.memo(Candidates);
@@ -0,0 +1,50 @@
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveElectionsInfo } from '@pezkuwi/api-derive/types';
import type { AccountId } from '@pezkuwi/types/interfaces';
import React, { useRef } from 'react';
import { Table } from '@pezkuwi/react-components';
import { useTranslation } from '../translate.js';
import Candidate from './Candidate.js';
interface Props {
allVotes?: Record<string, AccountId[]>;
className?: string;
electionsInfo?: DeriveElectionsInfo;
hasElections: boolean;
prime?: AccountId | null;
}
function Members ({ allVotes = {}, className = '', electionsInfo, hasElections, prime }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
[t('members'), 'start', 2]
]);
return (
<Table
className={className}
empty={electionsInfo && t('No members found')}
header={headerRef.current}
isSplit
>
{electionsInfo?.members.map(([accountId, balance]): React.ReactNode => (
<Candidate
address={accountId}
balance={balance}
hasElections={hasElections}
isPrime={prime?.eq(accountId)}
key={accountId.toString()}
voters={allVotes[accountId.toString()]}
/>
))}
</Table>
);
}
export default React.memo(Members);
@@ -0,0 +1,77 @@
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { u128 } from '@pezkuwi/types';
import type { ComponentProps as Props } from './types.js';
import React, { useState } from 'react';
import { Button, InputAddress, InputBalance, Modal, TxButton } from '@pezkuwi/react-components';
import { useApi, useModal } from '@pezkuwi/react-hooks';
import { useTranslation } from '../translate.js';
import { useModuleElections } from '../useModuleElections.js';
function SubmitCandidacy ({ electionsInfo }: Props): React.ReactElement<Props> | null {
const { api } = useApi();
const { t } = useTranslation();
const [accountId, setAccountId] = useState<string | null>(null);
const { isOpen, onClose, onOpen } = useModal();
const modLocation = useModuleElections();
if (!modLocation) {
return null;
}
return (
<>
{isOpen && (
<Modal
header={t('Submit your council candidacy')}
onClose={onClose}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('This account will appear in the list of candidates. With enough votes in an election, it will become either a runner-up or a council member.')}>
<InputAddress
label={t('candidate account')}
onChange={setAccountId}
type='account'
/>
</Modal.Columns>
{api.consts[modLocation] && (
<Modal.Columns hint={t('The bond will be reserved for the duration of your candidacy and membership.')}>
<InputBalance
defaultValue={api.consts[modLocation as 'council']?.candidacyBond as u128}
isDisabled
label={t('candidacy bond')}
/>
</Modal.Columns>
)}
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={accountId}
isDisabled={!electionsInfo}
onStart={onClose}
params={
api.tx[modLocation].submitCandidacy.meta.args.length === 1
? [electionsInfo?.candidates.length]
: []
}
tx={api.tx[modLocation].submitCandidacy}
/>
</Modal.Actions>
</Modal>
)}
<Button
icon='plus'
isDisabled={!electionsInfo}
label={t('Submit candidacy')}
onClick={onOpen}
/>
</>
);
}
export default React.memo(SubmitCandidacy);
@@ -0,0 +1,71 @@
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveElectionsInfo } from '@pezkuwi/api-derive/types';
import type { BlockNumber } from '@pezkuwi/types/interfaces';
import type { ComponentProps } from './types.js';
import React from 'react';
import { CardSummary, SummaryBox } from '@pezkuwi/react-components';
import { BN_THREE, BN_TWO, BN_ZERO, formatNumber } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props extends ComponentProps {
bestNumber?: BlockNumber;
className?: string;
electionsInfo?: DeriveElectionsInfo;
hasElections: boolean;
}
function Summary ({ bestNumber, className = '', electionsInfo, hasElections }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
return (
<SummaryBox className={className}>
<section>
<CardSummary label={t('seats')}>
{electionsInfo
? <>{formatNumber(electionsInfo.members.length)}{electionsInfo.desiredSeats && <>&nbsp;/&nbsp;{formatNumber(electionsInfo.desiredSeats)}</>}</>
: <span className='--tmp'>99</span>}
</CardSummary>
{hasElections && (
<>
<CardSummary label={t('runners up')}>
{electionsInfo
? <>{formatNumber(electionsInfo.runnersUp.length)}{electionsInfo.desiredRunnersUp && <>&nbsp;/&nbsp;{formatNumber(electionsInfo.desiredRunnersUp)}</>}</>
: <span className='--tmp'>99 / 99</span>}
</CardSummary>
<CardSummary label={t('candidates')}>
{electionsInfo
? formatNumber(electionsInfo.candidateCount)
: <span className='--tmp'>99</span>}
</CardSummary>
</>
)}
</section>
{electionsInfo?.voteCount && (
<section>
<CardSummary label={t('voting round')}>
#{formatNumber(electionsInfo.voteCount)}
</CardSummary>
</section>
)}
{electionsInfo && bestNumber && electionsInfo.termDuration && electionsInfo.termDuration.gt(BN_ZERO) && (
<section>
<CardSummary
label={t('term progress')}
progress={{
total: (electionsInfo && bestNumber) ? electionsInfo.termDuration : BN_THREE,
value: (electionsInfo && bestNumber) ? bestNumber.mod(electionsInfo.termDuration) : BN_TWO,
withTime: true
}}
/>
</section>
)}
</SummaryBox>
);
}
export default React.memo(Summary);
+154
View File
@@ -0,0 +1,154 @@
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveElectionsInfo } from '@pezkuwi/api-derive/types';
import type { BN } from '@pezkuwi/util';
import React, { useEffect, useMemo, useState } from 'react';
import { Button, InputAddress, InputAddressMulti, InputBalance, Modal, TxButton, VoteValue } from '@pezkuwi/react-components';
import { useApi, useToggle } from '@pezkuwi/react-hooks';
import { BN_ZERO } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
import { useModuleElections } from '../useModuleElections.js';
interface Props {
className?: string;
electionsInfo?: DeriveElectionsInfo;
}
const MAX_VOTES = 16;
function Vote ({ electionsInfo }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const [isVisible, toggleVisible] = useToggle();
const [accountId, setAccountId] = useState<string | null>(null);
const [available, setAvailable] = useState<string[]>([]);
const [defaultVotes, setDefaultVotes] = useState<string[]>([]);
const [votes, setVotes] = useState<string[]>([]);
const [voteValue, setVoteValue] = useState(BN_ZERO);
const modLocation = useModuleElections();
useEffect((): void => {
if (electionsInfo) {
const { candidates, members, runnersUp } = electionsInfo;
setAvailable(
members
.map(([accountId]) => accountId.toString())
.concat(runnersUp.map(([accountId]) => accountId.toString()))
.concat(candidates.map((accountId) => accountId.toString()))
);
}
}, [electionsInfo]);
useEffect((): void => {
accountId && api.derive.council
.votesOf(accountId)
.then(({ votes }): void => {
setDefaultVotes(
votes
.map((a) => a.toString())
.filter((a) => available.includes(a))
);
})
.catch(console.error);
}, [api, accountId, available]);
const bondValue = useMemo(
(): BN | undefined => {
const location = api.consts.elections || api.consts.phragmenElection || api.consts.electionsPhragmen;
return location &&
location.votingBondBase &&
location.votingBondBase.add(location.votingBondFactor.muln(votes.length));
},
[api, votes]
);
if (!modLocation) {
return null;
}
return (
<>
<Button
icon='check-to-slot'
isDisabled={available.length === 0}
label={t('Vote')}
onClick={toggleVisible}
/>
{isVisible && (
<Modal
header={t('Vote for current candidates')}
onClose={toggleVisible}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('The vote will be recorded for the selected account.')}>
<InputAddress
label={t('voting account')}
onChange={setAccountId}
type='account'
/>
</Modal.Columns>
<Modal.Columns hint={t('The value associated with this vote. The amount will be locked (not available for transfer) and used in all subsequent elections.')}>
<VoteValue
accountId={accountId}
onChange={setVoteValue}
/>
</Modal.Columns>
<Modal.Columns
hint={
<>
<p>{t('The votes for the members, runner-ups and candidates. These should be ordered based on your priority.')}</p>
<p>{t('In calculating the election outcome, this prioritized vote ordering will be used to determine the final score for the candidates.')}</p>
</>
}
>
<InputAddressMulti
available={available}
availableLabel={t('council candidates')}
defaultValue={defaultVotes}
maxCount={MAX_VOTES}
onChange={setVotes}
valueLabel={t('my ordered votes')}
/>
</Modal.Columns>
{bondValue && (
<Modal.Columns hint={t('The amount will be reserved for the duration of your vote')}>
<InputBalance
defaultValue={bondValue}
isDisabled
label={t('voting bond')}
/>
</Modal.Columns>
)}
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={accountId}
icon='trash-alt'
isDisabled={!defaultVotes.length}
label={t('Unvote all')}
onStart={toggleVisible}
tx={api.tx[modLocation].removeVoter}
/>
<TxButton
accountId={accountId}
isDisabled={!accountId || votes.length === 0 || voteValue.lten(0)}
label={t('Vote')}
onStart={toggleVisible}
params={[votes, voteValue]}
tx={api.tx[modLocation].vote}
/>
</Modal.Actions>
</Modal>
)}
</>
);
}
export default React.memo(Vote);
@@ -0,0 +1,48 @@
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountId, Balance } from '@pezkuwi/types/interfaces';
import React, { useCallback } from 'react';
import { AddressMini, ExpanderScroll } from '@pezkuwi/react-components';
import { FormatBalance } from '@pezkuwi/react-query';
interface Props {
balance?: Balance;
voters?: AccountId[];
}
function Voters ({ balance, voters }: Props): React.ReactElement<Props> {
const renderVoters = useCallback(
() => voters?.map((who): React.ReactNode =>
<AddressMini
key={who.toString()}
value={who}
withLockedVote
/>
),
[voters]
);
return (
<tr className='isExpanded isLast packedTop'>
<td
className='expand all'
colSpan={2}
>
<ExpanderScroll
renderChildren={renderVoters}
summary={
<FormatBalance
className={balance && voters ? '' : '--tmp'}
value={balance}
/>
}
/>
</td>
</tr>
);
}
export default React.memo(Voters);
@@ -0,0 +1,79 @@
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveCouncilVotes, DeriveElectionsInfo } from '@pezkuwi/api-derive/types';
import type { AccountId } from '@pezkuwi/types/interfaces';
import React from 'react';
import { Button } from '@pezkuwi/react-components';
import { useApi, useBestNumber, useCall } from '@pezkuwi/react-hooks';
import { useModuleElections } from '../useModuleElections.js';
import Candidates from './Candidates.js';
import Members from './Members.js';
import SubmitCandidacy from './SubmitCandidacy.js';
import Summary from './Summary.js';
import Vote from './Vote.js';
interface Props {
className?: string;
prime?: AccountId | null;
}
const transformVotes = {
transform: (entries: DeriveCouncilVotes): Record<string, AccountId[]> =>
entries.reduce<Record<string, AccountId[]>>((result, [voter, { votes }]) => {
votes.forEach((candidate): void => {
const address = candidate.toString();
if (!result[address]) {
result[address] = [];
}
result[address].push(voter);
});
return result;
}, {})
};
function Overview ({ className = '', prime }: Props): React.ReactElement<Props> {
const { api } = useApi();
const bestNumber = useBestNumber();
const electionsInfo = useCall<DeriveElectionsInfo>(api.derive.elections.info);
const allVotes = useCall<Record<string, AccountId[]>>(api.derive.council.votes, undefined, transformVotes);
const modElections = useModuleElections();
const hasElections = !!modElections;
return (
<div className={className}>
<Summary
bestNumber={bestNumber}
electionsInfo={electionsInfo}
hasElections={!!modElections}
/>
{hasElections && (
<Button.Group>
<Vote electionsInfo={electionsInfo} />
<SubmitCandidacy electionsInfo={electionsInfo} />
</Button.Group>
)}
<Members
allVotes={allVotes}
electionsInfo={electionsInfo}
hasElections={hasElections}
prime={prime}
/>
{hasElections && (
<Candidates
allVotes={allVotes}
electionsInfo={electionsInfo}
hasElections={hasElections}
/>
)}
</div>
);
}
export default React.memo(Overview);
@@ -0,0 +1,15 @@
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveElectionsInfo } from '@pezkuwi/api-derive/types';
import type { SetIndex } from '@pezkuwi/types/interfaces';
import type { BN } from '@pezkuwi/util';
export interface ComponentProps {
electionsInfo?: DeriveElectionsInfo;
}
export interface VoterPosition {
setIndex: SetIndex;
globalIndex: BN;
}
+74
View File
@@ -0,0 +1,74 @@
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
import type { AccountId } from '@pezkuwi/types/interfaces';
import React, { useMemo } from 'react';
import { Route, Routes } from 'react-router';
import { useLocation } from 'react-router-dom';
import { Tabs } from '@pezkuwi/react-components';
import { useApi, useCall } from '@pezkuwi/react-hooks';
import Motions from './Motions/index.js';
import Overview from './Overview/index.js';
import { useTranslation } from './translate.js';
import useCounter from './useCounter.js';
export { useCounter };
interface Props {
basePath: string;
className?: string;
}
function CouncilApp ({ basePath, className }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const { pathname } = useLocation();
const numMotions = useCounter();
const prime = useCall<AccountId | null>(api.derive.council.prime);
const motions = useCall<DeriveCollectiveProposal[]>(api.derive.council.proposals);
const items = useMemo(() => [
{
isRoot: true,
name: 'overview',
text: t('Overview')
},
{
count: numMotions,
name: 'motions',
text: t('Motions')
}
], [numMotions, t]);
return (
<main className={className}>
<Tabs
basePath={basePath}
items={items}
/>
<Routes>
<Route path={basePath}>
<Route
element={
<Motions
motions={motions}
prime={prime}
/>
}
path='motions'
/>
</Route>
</Routes>
<Overview
className={[basePath, `${basePath}/candidates`].includes(pathname) ? '' : '--hidden'}
prime={prime}
/>
</main>
);
}
export default React.memo(CouncilApp);
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { useTranslation as useTranslationBase } from 'react-i18next';
export function useTranslation (): { t: (key: string, options?: { replace: Record<string, unknown> }) => string } {
return useTranslationBase('app-council');
}
+20
View File
@@ -0,0 +1,20 @@
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
import { createNamedHook, useAccounts, useApi, useCall } from '@pezkuwi/react-hooks';
const transformCounter = {
transform: (motions: DeriveCollectiveProposal[]) => motions.filter(({ votes }) => !!votes).length
};
function useCounterImpl (): number {
const { hasAccounts } = useAccounts();
const { api, isApiReady } = useApi();
const counter = useCall<number>(isApiReady && hasAccounts && api.derive.council?.proposals, undefined, transformCounter) || 0;
return counter;
}
export default createNamedHook('useCounter', useCounterImpl);
@@ -0,0 +1,23 @@
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { useMemo } from 'react';
import { createNamedHook, useApi } from '@pezkuwi/react-hooks';
function useModuleElectionsImpl (): string | null {
const { api } = useApi();
return useMemo(
() => api.tx.phragmenElection
? 'phragmenElection'
: api.tx.electionsPhragmen
? 'electionsPhragmen'
: api.tx.elections
? 'elections'
: null,
[api]
);
}
export const useModuleElections = createNamedHook('useModuleElections', useModuleElectionsImpl);