mirror of
https://github.com/pezkuwichain/pezkuwi-apps.git
synced 2026-06-15 16:11:06 +00:00
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:
@@ -0,0 +1 @@
|
||||
# @pezkuwi/app-treasury
|
||||
@@ -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
|
||||
/>
|
||||
<> / </>
|
||||
<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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user