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
View File
View File
+1
View File
@@ -0,0 +1 @@
# @pezkuwi/app-treasury
+27
View File
@@ -0,0 +1,27 @@
{
"bugs": "https://github.com/pezkuwichain/pezkuwi-apps/issues",
"engines": {
"node": ">=18"
},
"homepage": "https://github.com/pezkuwichain/pezkuwi-apps/tree/master/packages/page-treasury#readme",
"license": "Apache-2.0",
"name": "@pezkuwi/app-treasury",
"private": true,
"repository": {
"directory": "packages/page-treasury",
"type": "git",
"url": "https://github.com/pezkuwichain/pezkuwi-apps.git"
},
"sideEffects": false,
"type": "module",
"version": "0.168.2-4-x",
"dependencies": {
"@pezkuwi/react-components": "^0.168.2-4-x",
"@pezkuwi/react-query": "^0.168.2-4-x"
},
"peerDependencies": {
"react": "*",
"react-dom": "*",
"react-is": "*"
}
}
@@ -0,0 +1,108 @@
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SubmittableExtrinsic } from '@pezkuwi/api/types';
import type { ProposalIndex } from '@pezkuwi/types/interfaces';
import React, { useEffect, useRef, useState } from 'react';
import { getTreasuryProposalThreshold } from '@pezkuwi/apps-config';
import { Button, Dropdown, InputAddress, Modal, TxButton } from '@pezkuwi/react-components';
import { useApi, useCollectiveInstance, useToggle } from '@pezkuwi/react-hooks';
import { useTranslation } from '../translate.js';
interface Props {
id: ProposalIndex;
isDisabled: boolean;
members: string[];
}
interface ProposalState {
proposal?: SubmittableExtrinsic<'promise'> | null;
proposalLength: number;
}
function Council ({ id, isDisabled, members }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const [isOpen, toggleOpen] = useToggle();
const [accountId, setAccountId] = useState<string | null>(null);
const [proposalType, setProposalType] = useState('accept');
const [{ proposal, proposalLength }, setProposal] = useState<ProposalState>(() => ({ proposalLength: 0 }));
const modCouncil = useCollectiveInstance('council');
const threshold = Math.ceil((members?.length || 0) * getTreasuryProposalThreshold(api));
const councilTypeOptRef = useRef([
{ text: t('Acceptance proposal to council'), value: 'accept' },
{ text: t('Rejection proposal to council'), value: 'reject' }
]);
useEffect((): void => {
const proposal = proposalType === 'reject'
? api.tx.treasury.rejectProposal(id)
: api.tx.treasury.approveProposal(id);
setProposal({ proposal, proposalLength: proposal.length });
}, [api, id, proposalType]);
if (!modCouncil) {
return null;
}
return (
<>
{isOpen && (
<Modal
header={t('Send to council')}
onClose={toggleOpen}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('The council member that is proposing this, submission equates to an "aye" vote.')}>
<InputAddress
filter={members}
label={t('submit with council account')}
onChange={setAccountId}
type='account'
withLabel
/>
</Modal.Columns>
<Modal.Columns hint={t('Proposal can either be to approve or reject this spend. Once approved, the change is applied by either removing the proposal or scheduling payout.')}>
<Dropdown
label={t('council proposal type')}
onChange={setProposalType}
options={councilTypeOptRef.current}
value={proposalType}
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={accountId}
icon='check'
isDisabled={!accountId || !threshold}
label={t('Send to council')}
onStart={toggleOpen}
params={
api.tx[modCouncil].propose.meta.args.length === 3
? [threshold, proposal, proposalLength]
: [threshold, proposal]
}
tx={api.tx[modCouncil].propose}
/>
</Modal.Actions>
</Modal>
)}
<Button
icon='step-forward'
isDisabled={isDisabled}
label={t('To council')}
onClick={toggleOpen}
/>
</>
);
}
export default React.memo(Council);
@@ -0,0 +1,93 @@
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveTreasuryProposal } from '@pezkuwi/api-derive/types';
import React, { useMemo } from 'react';
import { AddressMini, AddressSmall, Columar, LinkExternal, Table } from '@pezkuwi/react-components';
import { useApi, useToggle } from '@pezkuwi/react-hooks';
import { isFunction } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
import Council from './Council.js';
interface Props {
className?: string;
isMember: boolean;
members: string[];
proposal: DeriveTreasuryProposal;
withSend: boolean;
}
function ProposalDisplay ({ className = '', isMember, members, proposal: { council, id, proposal }, withSend }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [isExpanded, toggleIsExpanded] = useToggle(false);
const hasCouncil = isFunction(api.tx.council?.propose);
const hasProposals = useMemo(
() => !!council
.map(({ votes }) => votes ? votes.index.toNumber() : -1)
.filter((index) => index !== -1)
.length,
[council]
);
return (
<>
<tr className={`${className} isExpanded isFirst ${isExpanded ? '' : 'isLast'}`}>
<Table.Column.Id value={id} />
<td className='address all'>
<AddressSmall value={proposal.beneficiary} />
</td>
<Table.Column.Balance value={proposal.value} />
<td className='address'>
<AddressMini
balance={proposal.bond}
value={proposal.proposer}
withBalance
/>
</td>
<td className={hasProposals ? 'middle' : 'button'}>
{hasCouncil
? hasProposals
? <a href='#/council/motions'>{t('Voting')}</a>
: withSend && (
<Council
id={id}
isDisabled={!isMember}
members={members}
/>
)
: null
}
</td>
<Table.Column.Expand
isExpanded={isExpanded}
toggle={toggleIsExpanded}
/>
</tr>
<tr className={`${className} ${isExpanded ? 'isExpanded isLast' : 'isCollapsed'} packedTop`}>
<td />
<td
className='columar'
colSpan={4}
>
<Columar is100>
<Columar.Column>
<LinkExternal
data={id}
type='treasury'
withTitle
/>
</Columar.Column>
</Columar>
</td>
<td />
</tr>
</>
);
}
export default React.memo(ProposalDisplay);
@@ -0,0 +1,128 @@
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Option, u128 } from '@pezkuwi/types';
import type { Permill } from '@pezkuwi/types/interfaces';
import React, { useMemo, useState } from 'react';
import { Button, InputAddress, InputBalance, MarkWarning, Modal, Static, TxButton } from '@pezkuwi/react-components';
import { useApi, useToggle } from '@pezkuwi/react-hooks';
import { BN, BN_HUNDRED, BN_MILLION } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props {
className?: string;
}
function Propose ({ className }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const [accountId, setAccountId] = useState<string | null>(null);
const [beneficiary, setBeneficiary] = useState<string | null>(null);
const [isOpen, toggleOpen] = useToggle();
const [value, setValue] = useState<BN | undefined>();
const hasValue = value?.gtn(0);
const [bondMin, bondMax, bondPercentage] = useMemo(
() => [
api.consts.treasury.proposalBondMinimum
? (api.consts.treasury.proposalBondMinimum as u128).toString()
: null,
(api.consts.treasury.proposalBondMaximum as Option<u128>)?.isSome
? (api.consts.treasury.proposalBondMaximum as Option<u128>).unwrap().toString()
: null,
api.consts.treasury.proposalBond
? `${(api.consts.treasury.proposalBond as Permill).mul(BN_HUNDRED).div(BN_MILLION).toNumber().toFixed(2)}%`
: `${new BN(0).toNumber().toFixed(2)}%`
],
[api]
);
return (
<>
{isOpen && (
<Modal
className={className}
header={t('Submit treasury proposal')}
onClose={toggleOpen}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('This account will make the proposal and be responsible for the bond.')}>
<InputAddress
label={t('submit with account')}
onChange={setAccountId}
type='account'
withLabel
/>
</Modal.Columns>
<Modal.Columns hint={t('The beneficiary will receive the full amount if the proposal passes.')}>
<InputAddress
label={t('beneficiary')}
onChange={setBeneficiary}
type='allPlus'
/>
</Modal.Columns>
<Modal.Columns
hint={
<>
<p>{t('The value is the amount that is being asked for and that will be allocated to the beneficiary if the proposal is approved.')}</p>
{bondMax
? <p>{t('Of the beneficiary amount, no less than the minimum bond amount and no more than maximum on-chain bond would need to be put up as collateral. This is calculated from {{bondPercentage}} of the requested amount.', { replace: { bondPercentage } })}</p>
: <p>{t('Of the beneficiary amount, no less than the minimum bond amount would need to be put up as collateral. This is calculated from {{bondPercentage}} of the requested amount.', { replace: { bondPercentage } })}</p>
}
</>
}
>
<InputBalance
isError={!hasValue}
label={t('value')}
onChange={setValue}
/>
<Static
label={t('proposal bond')}
>
{bondPercentage}
</Static>
{bondMin && (
<InputBalance
defaultValue={bondMin}
isDisabled
label={t('minimum bond')}
/>
)}
{bondMax && (
<InputBalance
defaultValue={bondMax}
isDisabled
label={t('maximum bond')}
/>
)}
<MarkWarning content={t('Be aware that once submitted the proposal will be put to a vote. If the proposal is rejected due to a lack of info, invalid requirements or non-benefit to the network as a whole, the full bond posted (as describe above) will be lost.')} />
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={accountId}
icon='plus'
isDisabled={!accountId || !hasValue}
label={t('Submit proposal')}
onStart={toggleOpen}
params={[value, beneficiary]}
tx={api.tx.treasury.spendLocal ?? api.tx.treasury.proposeSpend}
/>
</Modal.Actions>
</Modal>
)}
<Button
icon='plus'
label={t('Submit proposal')}
onClick={toggleOpen}
/>
</>
);
}
export default React.memo(Propose);
@@ -0,0 +1,51 @@
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveTreasuryProposal } from '@pezkuwi/api-derive/types';
import React, { useMemo } from 'react';
import { Table } from '@pezkuwi/react-components';
import { useTranslation } from '../translate.js';
import Proposal from './Proposal.js';
interface Props {
className?: string;
isApprovals?: boolean;
isMember: boolean;
members: string[];
proposals?: DeriveTreasuryProposal[];
}
function ProposalsBase ({ className = '', isApprovals, isMember, members, proposals }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const header = useMemo<([React.ReactNode?, string?, number?] | false)[]>(() => [
[isApprovals ? t('Approved') : t('Proposals'), 'start', 2],
[],
[t('proposer'), 'address'],
[],
[]
], [isApprovals, t]);
return (
<Table
className={className}
empty={proposals && (isApprovals ? t('No approved proposals') : t('No pending proposals'))}
header={header}
>
{proposals?.map((proposal): React.ReactNode => (
<Proposal
isMember={isMember}
key={proposal.id.toString()}
members={members}
proposal={proposal}
withSend={!isApprovals}
/>
))}
</Table>
);
}
export default React.memo(ProposalsBase);
@@ -0,0 +1,135 @@
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@pezkuwi/util';
import React, { useMemo } from 'react';
import { CardSummary, SummaryBox } from '@pezkuwi/react-components';
import { useApi, useBestNumber, useCall, useTreasury } from '@pezkuwi/react-hooks';
import { FormatBalance } from '@pezkuwi/react-query';
import { BN_THREE, BN_TWO, BN_ZERO, formatNumber } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props {
approvalCount?: number;
proposalCount?: number;
}
function Summary ({ approvalCount, proposalCount }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const bestNumber = useBestNumber();
const totalProposals = useCall<BN>(api.query.treasury.proposalCount);
const { burn, pendingBounties, pendingProposals, spendPeriod, value } = useTreasury();
const spendable = useMemo(
() => value?.sub(pendingBounties).sub(pendingProposals),
[value, pendingBounties, pendingProposals]
);
const hasSpendable = !!(value && spendable);
return (
<SummaryBox>
<section>
<CardSummary
className='media--1700'
label={t('open')}
>
{proposalCount === undefined
? <span className='--tmp'>99</span>
: formatNumber(proposalCount)}
</CardSummary>
<CardSummary
className='media--1600'
label={t('approved')}
>
{approvalCount === undefined
? <span className='--tmp'>99</span>
: formatNumber(approvalCount)}
</CardSummary>
<CardSummary
className='media--1400'
label={t('total')}
>
{totalProposals === undefined
? <span className='--tmp'>99</span>
: formatNumber(totalProposals)}
</CardSummary>
</section>
<section>
{!pendingProposals.isZero() && (
<CardSummary
className='media--1100'
label={t('approved')}
>
<FormatBalance
value={pendingProposals}
withSi
/>
</CardSummary>
)}
{!pendingBounties.isZero() && (
<CardSummary
className='media--1200'
label={t('bounties')}
>
<FormatBalance
value={pendingBounties}
withSi
/>
</CardSummary>
)}
<CardSummary
className='media--1300'
label={t('next burn')}
>
<FormatBalance
className={burn ? '' : '--tmp'}
value={burn || 1}
withSi
/>
</CardSummary>
</section>
<section>
<CardSummary
label={t('spendable / available')}
progress={{
hideValue: true,
isBlurred: !hasSpendable,
total: hasSpendable ? value : BN_THREE,
value: hasSpendable ? spendable : BN_TWO
}}
>
<span className={hasSpendable ? '' : '--tmp'}>
<FormatBalance
value={spendable || BN_TWO}
withSi
/>
<>&nbsp;/&nbsp;</>
<FormatBalance
value={value || BN_THREE}
withSi
/>
</span>
</CardSummary>
</section>
{bestNumber && spendPeriod.gt(BN_ZERO) && (
<section>
<CardSummary
label={t('spend period')}
progress={{
total: spendPeriod,
value: bestNumber.mod(spendPeriod),
withTime: true
}}
/>
</section>
)}
</SummaryBox>
);
}
export default React.memo(Summary);
@@ -0,0 +1,53 @@
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveTreasuryProposals } from '@pezkuwi/api-derive/types';
import React from 'react';
import { Button } from '@pezkuwi/react-components';
import { useApi, useCall } from '@pezkuwi/react-hooks';
import ProposalCreate from './ProposalCreate.js';
import Proposals from './Proposals.js';
import Summary from './Summary.js';
interface Props {
className?: string;
isMember: boolean;
members: string[];
}
function Overview ({ className, isMember, members }: Props): React.ReactElement<Props> {
const { api } = useApi();
const info = useCall<DeriveTreasuryProposals>(api.derive.treasury.proposals);
return (
<div className={className}>
<Summary
approvalCount={info?.approvals.length}
proposalCount={info?.proposals.length}
/>
{
api.tx.treasury.proposeSpend || !!api.tx.treasury.spendLocal
? <Button.Group>
<ProposalCreate />
</Button.Group>
: <></>
}
<Proposals
isMember={isMember}
members={members}
proposals={info?.proposals}
/>
<Proposals
isApprovals
isMember={isMember}
members={members}
proposals={info?.approvals}
/>
</div>
);
}
export default React.memo(Overview);
+221
View File
@@ -0,0 +1,221 @@
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountId, Balance, BlockNumber, OpenTipTo225 } from '@pezkuwi/types/interfaces';
import type { PalletTipsOpenTip } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { AddressMini, AddressSmall, Checkbox, ExpanderScroll, Icon, LinkExternal, styled, TxButton } from '@pezkuwi/react-components';
import { useAccounts, useApi } from '@pezkuwi/react-hooks';
import { BlockToTime, FormatBalance } from '@pezkuwi/react-query';
import { BN_ZERO, formatNumber } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
import TipEndorse from './TipEndorse.js';
import TipReason from './TipReason.js';
interface Props {
bestNumber?: BlockNumber;
className?: string;
defaultId: string | null;
hash: string;
isMember: boolean;
members: string[];
onSelect: (hash: string, isSelected: boolean, value: BN) => void;
onlyUntipped: boolean;
tip: PalletTipsOpenTip | OpenTipTo225;
}
interface TipState {
closesAt: BlockNumber | null;
deposit: Balance | null;
finder: AccountId | null;
isFinder: boolean;
isTipped: boolean;
isTipper: boolean;
median: BN;
}
function isCurrentTip (tip: PalletTipsOpenTip | OpenTipTo225): tip is PalletTipsOpenTip {
return !!(tip as PalletTipsOpenTip)?.findersFee;
}
function extractTipState (tip: PalletTipsOpenTip | OpenTipTo225, allAccounts: string[]): TipState {
const closesAt = tip.closes.unwrapOr(null);
let finder: AccountId | null = null;
let deposit: Balance | null = null;
if (isCurrentTip(tip)) {
finder = tip.finder;
deposit = tip.deposit;
} else if (tip.finder.isSome) {
const finderInfo = tip.finder.unwrap();
finder = finderInfo[0];
deposit = finderInfo[1];
}
const values = tip.tips.map(([, value]) => value).sort((a, b) => a.cmp(b));
const midIndex = Math.floor(values.length / 2);
const median = values.length
? values.length % 2
? values[midIndex]
: values[midIndex - 1].add(values[midIndex]).divn(2)
: BN_ZERO;
return {
closesAt,
deposit,
finder,
isFinder: !!finder && allAccounts.includes(finder.toString()),
isTipped: !!values.length,
isTipper: tip.tips.some(([address]) => allAccounts.includes(address.toString())),
median
};
}
function Tip ({ bestNumber, className = '', defaultId, hash, isMember, members, onSelect, onlyUntipped, tip }: Props): React.ReactElement<Props> | null {
const { api } = useApi();
const { t } = useTranslation();
const { allAccounts } = useAccounts();
const { closesAt, finder, isFinder, isTipped, isTipper, median } = useMemo(
() => extractTipState(tip, allAccounts),
[allAccounts, tip]
);
const councilId = useMemo(
() => allAccounts.find((accountId) => members.includes(accountId)) || null,
[allAccounts, members]
);
const [isMedianSelected, setMedianTip] = useState(false);
const renderTippers = useCallback(
() => tip.tips.map(([tipper, balance]) => (
<AddressMini
balance={balance}
key={tipper.toString()}
value={tipper}
withBalance
/>
)),
[tip]
);
useEffect((): void => {
onSelect(hash, isMedianSelected, median);
}, [hash, isMedianSelected, median, onSelect]);
useEffect((): void => {
setMedianTip(isMember && !isTipper);
}, [isMember, isTipper]);
if (onlyUntipped && !closesAt && isTipper) {
return null;
}
const { reason, tips, who } = tip;
const recipient = who.toString();
return (
<StyledTr className={className}>
<td className='address'>
<AddressSmall value={who} />
</td>
<td className='address media--1400'>
{finder && (
<AddressMini value={finder} />
)}
</td>
<TipReason hash={reason} />
<td className='expand media--1100'>
{tips.length !== 0 && (
<ExpanderScroll
renderChildren={renderTippers}
summary={
<>
<div>{t('Tippers ({{count}})', { replace: { count: tips.length } })}</div>
<FormatBalance value={median} />
</>
}
/>
)}
</td>
<td className='button together'>
{closesAt
? (bestNumber && closesAt.gt(bestNumber)) && (
<div className='closingTimer'>
<BlockToTime value={closesAt.sub(bestNumber)} />
#{formatNumber(closesAt)}
</div>
)
: finder && isFinder && (
<TxButton
accountId={finder}
className='media--1400'
icon='times'
label={t('Cancel')}
params={[hash]}
tx={(api.tx.tips || api.tx.treasury).retractTip}
/>
)
}
{(!closesAt || !bestNumber || closesAt.gt(bestNumber))
? (
<TipEndorse
defaultId={defaultId}
hash={hash}
isMember={isMember}
isTipped={isTipped}
median={median}
members={members}
recipient={recipient}
/>
)
: (
<TxButton
accountId={councilId}
icon='times'
label={t('Close')}
params={[hash]}
tx={(api.tx.tips || api.tx.treasury).closeTip}
/>
)
}
</td>
<td className='badge media--1700'>
{isMember && (
<Icon
color={isTipper ? 'green' : 'gray'}
icon='asterisk'
/>
)}
</td>
<td>
<Checkbox
isDisabled={!isMember}
onChange={setMedianTip}
value={isMedianSelected}
/>
</td>
<td className='links media--1700'>
<LinkExternal
data={hash}
type='tip'
/>
</td>
</StyledTr>
);
}
const StyledTr = styled.tr`
.closingTimer {
display: inline-block;
padding: 0 0.5rem;
}
`;
export default React.memo(Tip);
@@ -0,0 +1,119 @@
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@pezkuwi/util';
import React, { useMemo, useState } from 'react';
import { Button, Input, InputAddress, InputBalance, Modal, TxButton } from '@pezkuwi/react-components';
import { useApi, useToggle } from '@pezkuwi/react-hooks';
import { BN_ZERO } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props {
members: string[];
}
const MAX_REASON_LEN = 256;
const MIN_REASON_LEN = 5;
function TipCreate ({ members }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const [isOpen, toggleOpen] = useToggle();
const [accountId, setAccountId] = useState<string | null>(null);
const [beneficiary, setBeneficiary] = useState<string | null>(null);
const [reason, setReason] = useState('');
const [value, setValue] = useState<BN | undefined>();
const maxReasonLen = useMemo(
() => Math.min(MAX_REASON_LEN, (
(api.consts.tips || api.consts.treasury)?.maximumReasonLength?.toNumber() ||
MAX_REASON_LEN
)),
[api]
);
const isMember = useMemo(
() => !!accountId && members.includes(accountId),
[accountId, members]
);
const hasValue = !!value && value.gt(BN_ZERO);
const hasReason = !!reason && (reason.length >= MIN_REASON_LEN) && (reason.length <= maxReasonLen);
if (!(api.tx.tips.tipNew || api.tx.treasury.tipNew)) {
return null;
}
return (
<>
<Button
icon='plus'
label={t('Propose tip')}
onClick={toggleOpen}
/>
{isOpen && (
<Modal
header={t('Submit tip request')}
onClose={toggleOpen}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('Use this account to request the tip from. This can be a normal or council account.')}>
<InputAddress
label={t('submit with account')}
onChange={setAccountId}
type='account'
withLabel
/>
</Modal.Columns>
<Modal.Columns hint={t('The beneficiary will received the tip as approved by council members.')}>
<InputAddress
label={t('beneficiary')}
onChange={setBeneficiary}
type='allPlus'
/>
</Modal.Columns>
<Modal.Columns hint={t('A reason (to be stored-on-chain) as to why the recipient deserves a tip payout.')}>
<Input
autoFocus
isError={!hasReason}
label={t('tip reason')}
onChange={setReason}
/>
</Modal.Columns>
{isMember && (
<Modal.Columns hint={t('As a council member, you can suggest an initial value for the tip, each other council member can suggest their own.')}>
<InputBalance
isError={!hasValue}
label={t('tip value')}
onChange={setValue}
/>
</Modal.Columns>
)}
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={accountId}
icon='plus'
isDisabled={!accountId || (isMember && !hasValue) || !hasReason}
label={t('Propose tip')}
onStart={toggleOpen}
params={
isMember
? [reason, beneficiary, value]
: [reason, beneficiary]
}
tx={
isMember
? (api.tx.tips || api.tx.treasury).tipNew
: (api.tx.tips || api.tx.treasury).reportAwesome
}
/>
</Modal.Actions>
</Modal>
)}
</>
);
}
export default React.memo(TipCreate);
@@ -0,0 +1,103 @@
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveBalancesAll } from '@pezkuwi/api-derive/types';
import type { BN } from '@pezkuwi/util';
import React, { useState } from 'react';
import { Button, InputAddress, InputBalance, MarkWarning, Modal, TxButton } from '@pezkuwi/react-components';
import { useApi, useCall, useToggle } from '@pezkuwi/react-hooks';
import { useTranslation } from '../translate.js';
interface Props {
defaultId: string | null;
hash: string;
isMember: boolean;
isTipped: boolean;
median: BN;
members: string[];
recipient: string;
}
const OPT = {
transform: ({ freeBalance, reservedBalance }: DeriveBalancesAll): BN =>
freeBalance.add(reservedBalance)
};
function TipEndorse ({ defaultId, hash, isMember, isTipped, median, members, recipient }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [isOpen, toggleOpen] = useToggle();
const [accountId, setAccountId] = useState<string | null>(defaultId);
const [value, setValue] = useState<BN | undefined>();
const totalBalance = useCall<BN>(api.derive.balances?.all, [recipient], OPT);
const tipTx = (api.tx.tips || api.tx.treasury).tip;
return (
<>
<Button
icon='check'
isDisabled={!isMember}
label={t('Tip')}
onClick={toggleOpen}
/>
<TxButton
accountId={defaultId}
className='media--1600'
icon='fighter-jet'
isDisabled={!isMember || !isTipped}
isIcon
params={[hash, median]}
tx={tipTx}
withoutLink
/>
{isOpen && (
<Modal
header={t('Submit tip endorsement')}
onClose={toggleOpen}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('Your endorsement will be applied for this account.')}>
<InputAddress
filter={members}
label={t('submit with account')}
onChange={setAccountId}
type='account'
withLabel
/>
</Modal.Columns>
<Modal.Columns hint={t('Allocate a suggested tip amount. With enough endorsements, the suggested values are averaged and sent to the beneficiary.')}>
<InputBalance
autoFocus
defaultValue={median}
isZeroable
label={t('value')}
onChange={setValue}
/>
{totalBalance && totalBalance.isZero() && (
<MarkWarning content={t('The recipient account has no balance, ensure the tip is more than the existential deposit to create the account.')} />
)}
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={accountId}
icon='plus'
isDisabled={!accountId}
label={t('Submit tip')}
onStart={toggleOpen}
params={[hash, value]}
tx={tipTx}
/>
</Modal.Actions>
</Modal>
)}
</>
);
}
export default React.memo(TipEndorse);
@@ -0,0 +1,32 @@
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Bytes, Option } from '@pezkuwi/types';
import type { Hash } from '@pezkuwi/types/interfaces';
import React from 'react';
import { useApi, useCall } from '@pezkuwi/react-hooks';
import { hexToString } from '@pezkuwi/util';
interface Props {
hash: Hash;
}
const OPT = {
transform: (optBytes: Option<Bytes>) =>
optBytes.isSome
? hexToString(optBytes.unwrap().toHex())
: null
};
function TipReason ({ hash }: Props): React.ReactElement<Props> {
const { api } = useApi();
const reasonText = useCall<string | null>((api.query.tips || api.query.treasury).reasons, [hash], OPT);
return (
<td className='start all'>{reasonText || hash.toHex()}</td>
);
}
export default React.memo(TipReason);
+117
View File
@@ -0,0 +1,117 @@
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Option } from '@pezkuwi/types';
import type { OpenTipTo225 } from '@pezkuwi/types/interfaces';
import type { PalletTipsOpenTip } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
import React, { useMemo, useRef, useState } from 'react';
import { styled, Table, Toggle } from '@pezkuwi/react-components';
import { useApi, useBestNumber, useCall } from '@pezkuwi/react-hooks';
import { useTranslation } from '../translate.js';
import Tip from './Tip.js';
interface Props {
className?: string;
defaultId: string | null;
hashes?: string[] | null;
isMember: boolean;
members: string[];
onSelectTip: (hash: string, isSelected: boolean, value: BN) => void,
}
type TipType = [string, PalletTipsOpenTip | OpenTipTo225];
const TIP_OPTS = { withParams: true };
function extractTips (tipsWithHashes?: [[string[]], Option<PalletTipsOpenTip>[]], inHashes?: string[] | null): TipType[] | undefined {
if (!tipsWithHashes || !inHashes) {
return undefined;
}
const [[hashes], optTips] = tipsWithHashes;
return optTips
.map((opt, index): [string, PalletTipsOpenTip | null] => [hashes[index], opt.unwrapOr(null)])
.filter((val): val is [string, PalletTipsOpenTip] => inHashes.includes(val[0]) && !!val[1])
.sort((a, b) =>
a[1].closes.isNone
? b[1].closes.isNone
? 0
: -1
: b[1].closes.isSome
? b[1].closes.unwrap().cmp(a[1].closes.unwrap())
: 1
);
}
function Tips ({ className = '', defaultId, hashes, isMember, members, onSelectTip }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [onlyUntipped, setOnlyUntipped] = useState(false);
const bestNumber = useBestNumber();
const tipsWithHashes = useCall<[[string[]], Option<PalletTipsOpenTip>[]]>(hashes && (api.query.tips || api.query.treasury).tips.multi, [hashes], TIP_OPTS);
const tips = useMemo(
() => extractTips(tipsWithHashes, hashes),
[hashes, tipsWithHashes]
);
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
[t('tips'), 'start'],
[t('finder'), 'address media--1400'],
[t('reason'), 'start'],
[undefined, 'media--1100'],
[],
[undefined, 'badge media--1700'],
[],
[undefined, 'media--1700']
]);
return (
<StyledTable
className={className}
empty={tips && t('No open tips')}
filter={isMember && (
<div className='tipsFilter'>
<Toggle
label={t('show only untipped/closing')}
onChange={setOnlyUntipped}
value={onlyUntipped}
/>
</div>
)}
header={headerRef.current}
>
{tips?.map(([hash, tip]): React.ReactNode => (
<Tip
bestNumber={bestNumber}
defaultId={defaultId}
hash={hash}
isMember={isMember}
key={hash}
members={members}
onSelect={onSelectTip}
onlyUntipped={onlyUntipped}
tip={tip}
/>
))}
</StyledTable>
);
}
const StyledTable = styled(Table)`
.tipsFilter {
text-align: right;
.ui--Toggle {
margin-right: 1rem;
margin-top: 0.75rem;
}
}
`;
export default React.memo(Tips);
+80
View File
@@ -0,0 +1,80 @@
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SubmittableExtrinsic } from '@pezkuwi/api/types';
import type { BN } from '@pezkuwi/util';
import React, { useCallback, useMemo, useState } from 'react';
import { Button, TxButton } from '@pezkuwi/react-components';
import { useAccounts, useApi, useTxBatch } from '@pezkuwi/react-hooks';
import { useTranslation } from '../translate.js';
import TipCreate from './TipCreate.js';
import Tips from './Tips.js';
interface Props {
className?: string;
hashes?: string[] | null;
isMember: boolean;
members: string[];
}
interface QuickTipsState {
quickTips: Record<string, BN | null>;
quickTxs: SubmittableExtrinsic<'promise'>[];
}
const DEFAULT_TIPS = { quickTips: {}, quickTxs: [] };
function TipsEntry ({ className, hashes, isMember, members }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { allAccounts } = useAccounts();
const { api } = useApi();
const [{ quickTxs }, setQuickTips] = useState<QuickTipsState>(DEFAULT_TIPS);
const batchTxs = useTxBatch(quickTxs);
const defaultId = useMemo(
() => members.find((memberId) => allAccounts.includes(memberId)) || null,
[allAccounts, members]
);
const _selectTip = useCallback(
(hash: string, isSelected: boolean, value: BN) => setQuickTips(({ quickTips }): QuickTipsState => {
quickTips[hash] = isSelected ? value : null;
return {
quickTips,
quickTxs: Object
.entries(quickTips)
.map(([hash, value]) => value && (api.tx.tips || api.tx.treasury).tip(hash, value))
.filter((value): value is SubmittableExtrinsic<'promise'> => !!value)
};
}),
[api]
);
return (
<div className={className}>
<Button.Group>
<TipCreate members={members} />
<TxButton
accountId={defaultId}
extrinsic={batchTxs}
icon='fighter-jet'
isDisabled={!isMember || !batchTxs}
label={t('Median tip selected')}
/>
</Button.Group>
<Tips
defaultId={defaultId}
hashes={hashes}
isMember={isMember}
members={members}
onSelectTip={_selectTip}
/>
</div>
);
}
export default React.memo(TipsEntry);
+81
View File
@@ -0,0 +1,81 @@
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useMemo } from 'react';
import { Route, Routes } from 'react-router';
import { Tabs } from '@pezkuwi/react-components';
import { useApi, useCollectiveMembers } from '@pezkuwi/react-hooks';
import { isFunction } from '@pezkuwi/util';
import Overview from './Overview/index.js';
import Tips from './Tips/index.js';
import { useTranslation } from './translate.js';
import useTipHashes from './useTipHashes.js';
export { default as useCounter } from './useCounter.js';
interface Props {
basePath: string;
}
interface TabItem {
count?: number;
isRoot?: boolean;
name: string;
text: string;
}
function TreasuryApp ({ basePath }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const { isMember, members } = useCollectiveMembers('council');
const tipHashes = useTipHashes();
const items = useMemo(() => [
{
isRoot: true,
name: 'overview',
text: t('Overview')
},
isFunction((api.query.tips || api.query.treasury)?.tips) && {
count: tipHashes?.length,
name: 'tips',
text: t('Tips')
}
].filter((t: TabItem | false): t is TabItem => !!t), [api, t, tipHashes]);
return (
<main className='treasury--App'>
<Tabs
basePath={basePath}
items={items}
/>
<Routes>
<Route path={basePath}>
<Route
element={
<Tips
hashes={tipHashes}
isMember={isMember}
members={members}
/>
}
path='tips'
/>
<Route
element={
<Overview
isMember={isMember}
members={members}
/>
}
index
/>
</Route>
</Routes>
</main>
);
}
export default React.memo(TreasuryApp);
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2017-2025 @pezkuwi/app-explorer 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-treasury');
}
+21
View File
@@ -0,0 +1,21 @@
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveTreasuryProposals } from '@pezkuwi/api-derive/types';
import { useMemo } from 'react';
import { createNamedHook, useAccounts, useApi, useCall } from '@pezkuwi/react-hooks';
function useCounterImpl (): number {
const { hasAccounts } = useAccounts();
const { api, isApiReady } = useApi();
const proposals = useCall<DeriveTreasuryProposals>(isApiReady && hasAccounts && api.derive.treasury?.proposals);
return useMemo(
() => proposals?.proposals.length || 0,
[proposals]
);
}
export default createNamedHook('useCounter', useCounterImpl);
@@ -0,0 +1,25 @@
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { StorageKey } from '@pezkuwi/types';
import type { Hash } from '@pezkuwi/types/interfaces';
import { createNamedHook, useApi, useEventTrigger, useMapKeys } from '@pezkuwi/react-hooks';
const OPT = {
transform: (keys: StorageKey<[Hash]>[]): string[] =>
keys.map(({ args: [hash] }) => hash.toHex())
};
function useTipHashesImpl (): string[] | undefined {
const { api } = useApi();
const trigger = useEventTrigger([
api.events.tips?.NewTip,
api.events.tips?.TipClosed,
api.events.tips?.TipRetracted
]);
return useMapKeys((api.query.tips || api.query.treasury)?.tips, [], OPT, trigger.blockHash);
}
export default createNamedHook('useTipHashes', useTipHashesImpl);
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": "..",
"outDir": "./build",
"rootDir": "./src"
},
"references": [
{ "path": "../react-components/tsconfig.build.json" }
]
}