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,60 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
import '@pezkuwi/react-components/i18n';
import { render } from '@testing-library/react';
import React, { Suspense } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from 'styled-components';
import { ApiCtxRoot } from '@pezkuwi/react-api';
import { lightTheme } from '@pezkuwi/react-components';
import { createApi } from '@pezkuwi/test-support/api';
import { aliceSigner, MemoryStore } from '@pezkuwi/test-support/keyring';
import { WaitForApi } from '@pezkuwi/test-support/react';
import { execute } from '@pezkuwi/test-support/transaction';
import { BN } from '@pezkuwi/util';
import BountiesApp from './index.js';
const BIZINIKIWI_PORT = Number.parseInt(process.env.TEST_BIZINIKIWI_PORT || '30333');
const renderBounties = () => {
const memoryStore = new MemoryStore();
return render(
<Suspense fallback='...'>
<MemoryRouter>
<ThemeProvider theme={lightTheme}>
<ApiCtxRoot
apiUrl={`ws://127.0.0.1:${BIZINIKIWI_PORT}`}
isElectron={false}
store={memoryStore}
>
<WaitForApi>
<div>
<BountiesApp basePath='/bounties' />
</div>
</WaitForApi>
</ApiCtxRoot>
</ThemeProvider>
</MemoryRouter>
</Suspense>
);
};
// eslint-disable-next-line jest/no-disabled-tests
describe.skip('--SLOW--: Bounties', () => {
it('list shows an existing bounty', async () => {
const api = await createApi();
await execute(api.tx.bounties.proposeBounty(new BN(500_000_000_000_000), 'a short bounty title'), aliceSigner());
const { findByText } = renderBounties();
expect(await findByText('a short bounty title', {}, { timeout: 20_000 })).toBeTruthy();
});
});
+518
View File
@@ -0,0 +1,518 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
/* eslint-disable jest/expect-expect */
import type { ApiPromise } from '@pezkuwi/api';
import type { SubmittableExtrinsic } from '@pezkuwi/api/types';
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
import type { BountyIndex } from '@pezkuwi/types/interfaces';
import type { PalletBountiesBounty, PalletBountiesBountyStatus } from '@pezkuwi/types/lookup';
import { fireEvent } from '@testing-library/react';
import i18next from '@pezkuwi/react-components/i18n';
import { createAugmentedApi } from '@pezkuwi/test-support/api';
import { balanceOf } from '@pezkuwi/test-support/creation/balance';
import { BountyFactory } from '@pezkuwi/test-support/creation/bounties';
import { proposalFactory } from '@pezkuwi/test-support/creation/treasury';
import { mockHooks } from '@pezkuwi/test-support/hooks';
import { alice, bob, MemoryStore } from '@pezkuwi/test-support/keyring';
import { keyring } from '@pezkuwi/ui-keyring';
import { BN } from '@pezkuwi/util';
import { defaultBountyUpdatePeriod, mockBountyHooks } from '../test/hooks/defaults.js';
import { BountiesPage } from '../test/pages/bountiesPage.js';
import { BLOCKS_PERCENTAGE_LEFT_TO_SHOW_WARNING } from './BountyNextActionInfo/BountyActionMessage.js';
jest.mock('@pezkuwi/react-hooks/useTreasury', () => ({
useTreasury: () => mockHooks.treasury
}));
jest.mock('@pezkuwi/react-hooks/useCollectiveInstance', () => ({
useCollectiveInstance: () => 'council'
}));
jest.mock('@pezkuwi/react-hooks/useCollectiveMembers', () => ({
useCollectiveMembers: () => mockHooks.members
}));
jest.mock('@pezkuwi/react-hooks/useBlockTime', () => ({
useBlockTime: () => mockHooks.blockTime
}));
jest.mock('./hooks/useBalance', () => ({
useBalance: () => mockBountyHooks.balance
}));
jest.mock('./hooks/useBounties', () => ({
useBounties: () => mockBountyHooks.bountyApi
}));
let aProposal: (extrinsic: SubmittableExtrinsic<'promise'>, ayes?: string[], nays?: string[]) => DeriveCollectiveProposal;
let augmentedApi: ApiPromise;
let aBounty: ({ status, value }?: Partial<PalletBountiesBounty>) => PalletBountiesBounty;
let aBountyIndex: (index?: number) => BountyIndex;
let bountyStatusWith: ({ curator, status, updateDue }: { curator?: string, status?: string, updateDue?: number}) => PalletBountiesBountyStatus;
let bountyWith: ({ status, value }: { status?: string, value?: number }) => PalletBountiesBounty;
describe('Bounties', () => {
let bountiesPage: BountiesPage;
beforeAll(async () => {
await i18next.changeLanguage('en');
keyring.loadAll({ isDevelopment: true, store: new MemoryStore() });
augmentedApi = createAugmentedApi();
({ aBounty, aBountyIndex, bountyStatusWith, bountyWith } = new BountyFactory(augmentedApi));
({ aProposal } = proposalFactory(augmentedApi));
});
beforeEach(() => {
bountiesPage = new BountiesPage(augmentedApi);
});
describe('list', () => {
it('shows message when no bounties', async () => {
bountiesPage.renderMany();
await bountiesPage.expectText('No open bounties');
});
it('renders a bounty', async () => {
bountiesPage.renderOne(aBounty(), [], 'dicle comic book');
await bountiesPage.expectText('dicle comic book');
bountiesPage.expectTextAbsent('No open bounties');
});
it('renders bounties in order from newest to oldest', async () => {
bountiesPage.renderMany({
bounties: [
{ bounty: aBounty(), description: 'bounty 2', index: aBountyIndex(2), proposals: [] },
{ bounty: aBounty(), description: 'bounty 1', index: aBountyIndex(1), proposals: [] },
{ bounty: aBounty(), description: 'bounty 3', index: aBountyIndex(3), proposals: [] }
]
});
expect(await bountiesPage.findAllDescriptions()).toEqual(['bounty 3', 'bounty 2', 'bounty 1']);
});
});
describe('bounty in a list', () => {
describe('has extended status', () => {
it('when voting on proposed curator', async () => {
const bounty = bountyWith({ status: 'Funded' });
const proposals = [aProposal(augmentedApi.tx.bounties.proposeCurator(0, '5EYCAe5ijiYfyeZ2JJCGq56LmPyNRAKzpG4QkoQkkQNB5e6Z', 1))];
bountiesPage.renderOne(bounty, proposals);
await bountiesPage.expectVotingDescription('Curator proposal under voting');
});
it('when voting on bounty approval', async () => {
const bounty = bountyWith({ status: 'Proposed' });
const proposals = [aProposal(augmentedApi.tx.bounties.approveBounty(0))];
bountiesPage.renderOne(bounty, proposals);
await bountiesPage.expectVotingDescription('Bounty approval under voting');
});
it('when simultaneous close and approve motions exist, show approved', async () => {
const bounty = bountyWith({ status: 'Proposed' });
const proposals = [
aProposal(augmentedApi.tx.bounties.closeBounty(0)),
aProposal(augmentedApi.tx.bounties.approveBounty(0))
];
bountiesPage.renderOne(bounty, proposals);
await bountiesPage.expectVotingDescription('Bounty approval under voting');
});
it('when voting on close bounty', async () => {
const bounty = bountyWith({ status: 'Active' });
const proposals = [aProposal(augmentedApi.tx.bounties.closeBounty(0))];
bountiesPage.renderOne(bounty, proposals);
await bountiesPage.expectVotingDescription('Bounty rejection under voting');
});
it('when voting on unassign curator', async () => {
const bounty = bountyWith({ status: 'Active' });
const proposals = [aProposal(augmentedApi.tx.bounties.unassignCurator(0))];
bountiesPage.renderOne(bounty, proposals);
await bountiesPage.expectVotingDescription('Curator slash under voting');
});
it('when a motion exists that would fail on execution, show nothing', async () => {
const bounty = bountyWith({ status: 'Active' });
const proposals = [aProposal(augmentedApi.tx.bounties.approveBounty(0))];
const { findByTestId } = bountiesPage.renderOne(bounty, proposals);
await expect(findByTestId('voting-description')).rejects.toThrow();
});
});
describe('has extended description for Curator', () => {
it('when propose curator motion is voted and bounty is in Funded state', async () => {
const bounty = bountyWith({ status: 'Funded' });
const proposals = [aProposal(augmentedApi.tx.bounties.proposeCurator(0, alice, 1))];
bountiesPage.renderOne(bounty, proposals);
await bountiesPage.expectText('Proposed Curator');
});
it('when bounty is in Funded status, but there is no motion, show nothing', async () => {
const bounty = bountyWith({ status: 'Funded' });
bountiesPage.renderOne(bounty);
await bountiesPage.rendered();
bountiesPage.expectTextAbsent('Proposed Curator');
});
it('when status is different, show nothing', async () => {
const bounty = bountyWith({ status: 'CuratorProposed' });
bountiesPage.renderOne(bounty);
await bountiesPage.rendered();
bountiesPage.expectTextAbsent('Proposed Curator');
});
});
describe('has Beneficiary description', () => {
it('in PendingPayout status', async () => {
const bounty = bountyWith({ status: 'PendingPayout' });
const proposals = [aProposal(augmentedApi.tx.bounties.awardBounty(0, '5EYCAe5ijiYfyeZ2JJCGq56LmPyNRAKzpG4QkoQkkQNB5e6Z'))];
bountiesPage.renderOne(bounty, proposals);
await bountiesPage.expectText('Beneficiary');
});
it('not in other status', async () => {
const bounty = bountyWith({ status: 'Active' });
bountiesPage.renderOne(bounty);
await bountiesPage.rendered();
bountiesPage.expectTextAbsent('Beneficiary');
});
});
describe('has voting summary', () => {
it('is displayed when voting', async () => {
const bounty = bountyWith({ status: 'Proposed' });
const proposals = [aProposal(augmentedApi.tx.bounties.approveBounty(0), [alice, bob], [])];
const { findByTestId } = bountiesPage.renderOne(bounty, proposals);
expect((await findByTestId('voting-summary')).textContent).toEqual('Aye 2/4Nay 0/0Voting');
});
it('is not displayed when not voting', async () => {
const bounty = bountyWith({ status: 'Proposed' });
const proposals: DeriveCollectiveProposal[] = [];
const { findByTestId } = bountiesPage.renderOne(bounty, proposals);
await expect(findByTestId('voting-summary')).rejects.toThrow();
});
});
describe('has voters', () => {
it('aye and nay', async () => {
const bounty = bountyWith({ status: 'Proposed' });
const proposals = [aProposal(augmentedApi.tx.bounties.approveBounty(0))];
const { findAllByTestId } = bountiesPage.renderOne(bounty, proposals);
const ayeVoters = await findAllByTestId((testId) => testId.startsWith('voters_ayes'));
const nayVoters = await findAllByTestId((testId) => testId.startsWith('voters_nays'));
expect(ayeVoters).toHaveLength(1);
expect(nayVoters).toHaveLength(1);
expect(ayeVoters[0].getAttribute('data-testid')).toContain(alice);
expect(nayVoters[0].getAttribute('data-testid')).toContain(bob);
});
it('multiple ayes and no nay', async () => {
const bounty = bountyWith({ status: 'Proposed' });
const proposals = [aProposal(augmentedApi.tx.bounties.approveBounty(0), [alice, bob], [])];
const { findAllByTestId } = bountiesPage.renderOne(bounty, proposals);
const ayeVoters = await findAllByTestId((testId) => testId.startsWith('voters_ayes'));
expect(ayeVoters).toHaveLength(2);
await expect(findAllByTestId((testId) => testId.startsWith('voters_nays'))).rejects.toThrow();
expect(ayeVoters[0].getAttribute('data-testid')).toContain(alice);
expect(ayeVoters[1].getAttribute('data-testid')).toContain(bob);
});
it('no voters when no voting', async () => {
const bounty = bountyWith({ status: 'Proposed' });
const proposals: DeriveCollectiveProposal[] = [];
const { findAllByTestId } = bountiesPage.renderOne(bounty, proposals);
await expect(findAllByTestId((testId) => testId.startsWith('voters_ayes'))).rejects.toThrow();
await expect(findAllByTestId((testId) => testId.startsWith('voters_nays'))).rejects.toThrow();
});
});
});
describe('create bounty modal', () => {
it('validates bounty length', async () => {
bountiesPage.renderMany({ maximumReasonLength: 5 });
await bountiesPage.openAddBounty();
await bountiesPage.enterBountyTitle('longer than 5');
await bountiesPage.expectText('Title too long');
});
it('validates balance is enough for bond', async () => {
bountiesPage.renderMany(
{ bountyDepositBase: new BN(10), dataDepositPerByte: new BN(1) },
{ balance: 10 }
);
await bountiesPage.openAddBounty();
bountiesPage.expectTextAbsent('Account does not have enough funds.');
await bountiesPage.enterBountyTitle('add bytes');
await bountiesPage.expectText('Account does not have enough funds.');
});
});
describe('propose curator modal', () => {
beforeEach(async () => {
bountiesPage.renderOne(bountyWith({ status: 'Funded', value: 5 }));
await bountiesPage.openProposeCurator();
});
it('shows an error if fee is greater than bounty value', async () => {
await bountiesPage.enterCuratorsFee('6');
await bountiesPage.expectText("Curator's fee can't be higher than bounty value.");
});
it('disables Assign Curator button if validation fails', async () => {
await bountiesPage.enterCuratorsFee('6');
expect(await bountiesPage.assignCuratorButton()).toHaveClass('isDisabled');
});
it('queues propose extrinsic on submit', async () => {
await bountiesPage.enterCuratorsFee('0');
bountiesPage.enterProposingAccount(alice);
bountiesPage.enterProposedCurator(alice);
fireEvent.click(await bountiesPage.assignCuratorButton());
bountiesPage.expectExtrinsicQueued({ accountId: alice, extrinsic: 'mockProposeExtrinsic' });
});
});
describe('close bounty modal', () => {
it('creates closeBounty proposal', async () => {
bountiesPage.renderOne(bountyWith({ status: 'Funded' }));
await bountiesPage.openCloseBounty();
bountiesPage.enterProposingAccount(alice);
await bountiesPage.clickButton('Close Bounty');
bountiesPage.expectExtrinsicQueued({ accountId: alice, extrinsic: 'mockProposeExtrinsic' });
expect(mockBountyHooks.bountyApi.closeBounty).toHaveBeenCalledWith(aBountyIndex(0));
});
it('is not available when close bounty motion already exists', async () => {
const bounty = bountyWith({ status: 'Funded' });
const proposals = [aProposal(augmentedApi.tx.bounties.closeBounty(0))];
const { findByTestId } = bountiesPage.renderOne(bounty, proposals);
await expect(findByTestId('extra-actions')).rejects.toThrow();
});
});
describe('Reject curator modal', () => {
it('creates extrinsic', async () => {
const bounty = aBounty({ status: bountyStatusWith({ curator: bob, status: 'CuratorProposed' }) });
bountiesPage.renderOne(bounty);
await bountiesPage.openRejectCuratorRole();
await bountiesPage.clickButton('Reject');
bountiesPage.expectExtrinsicQueued({ accountId: bob });
});
it('shows options for all roles', async () => {
const bounty = aBounty({ status: bountyStatusWith({ curator: bob, status: 'Active' }) });
bountiesPage.renderOne(bounty);
await bountiesPage.openExtraActions();
await bountiesPage.expectText('Give up');
await bountiesPage.expectText('Slash curator (Council)');
});
});
describe('Accept curator modal', () => {
it('creates extrinsic', async () => {
const bounty = aBounty({
fee: balanceOf(20),
status: bountyStatusWith({ curator: bob, status: 'CuratorProposed' })
});
bountiesPage.renderOne(bounty);
await bountiesPage.openAcceptCuratorRole();
expect(await bountiesPage.findCuratorsFee()).toEqual('20.0000');
expect(await bountiesPage.findCuratorsDeposit()).toEqual('10.0000');
await bountiesPage.clickButton('Accept Curator Role');
bountiesPage.expectExtrinsicQueued({ accountId: bob });
expect(mockBountyHooks.bountyApi.acceptCurator).toHaveBeenCalledWith(aBountyIndex(0));
});
});
describe('extend bounty expiry action modal', () => {
it('queues extend bounty expiry extrinsic on submit', async () => {
const bounty = aBounty({ status: bountyStatusWith({ curator: alice }) });
bountiesPage.renderOne(bounty);
await bountiesPage.openExtendExpiry();
await bountiesPage.enterExpiryRemark('The bounty extend expiry remark');
await bountiesPage.clickButton('Accept');
bountiesPage.expectExtrinsicQueued({ accountId: alice, extrinsic: 'mockExtendExtrinsic' });
expect(mockBountyHooks.bountyApi.extendBountyExpiry).toHaveBeenCalledWith(aBountyIndex(0), 'The bounty extend expiry remark');
});
});
describe('give up curator modal', () => {
it('gives up on the Curator role of an Active bounty', async () => {
const bounty = aBounty({ status: bountyStatusWith({ curator: alice }) });
bountiesPage.renderOne(bounty);
await bountiesPage.openGiveUpCuratorsRole();
bountiesPage.enterProposingAccount(alice);
await bountiesPage.clickButton('Give up');
bountiesPage.expectExtrinsicQueued({ accountId: alice, extrinsic: 'mockUnassignExtrinsic' });
expect(mockBountyHooks.bountyApi.unassignCurator).toHaveBeenCalledWith(aBountyIndex(0));
});
});
describe('slash curator modal', () => {
it('creates a motion when slashing a PendingPayout bounty', async () => {
bountiesPage.renderOne(bountyWith({ status: 'PendingPayout' }));
await bountiesPage.openSlashCuratorByCouncil();
bountiesPage.enterProposingAccount(alice);
await bountiesPage.clickButton('Approve');
bountiesPage.expectExtrinsicQueued({ accountId: alice, extrinsic: 'mockProposeExtrinsic' });
});
});
describe('award beneficiary action modal', () => {
it('awards the beneficiary', async () => {
const bounty = aBounty({ status: bountyStatusWith({ curator: alice }) });
bountiesPage.renderOne(bounty);
await bountiesPage.openAwardBeneficiary();
bountiesPage.enterBeneficiary(bob);
await bountiesPage.clickButton('Approve');
bountiesPage.expectExtrinsicQueued({ accountId: alice, extrinsic: 'mockAwardExtrinsic' });
expect(mockBountyHooks.bountyApi.awardBounty).toHaveBeenCalledWith(aBountyIndex(0), bob);
});
});
describe('Show', () => {
it('warning when update time is close', async () => {
const bounty = aBounty({ status: bountyStatusWith(
{
curator: alice,
status: 'Active',
updateDue: defaultBountyUpdatePeriod.muln(BLOCKS_PERCENTAGE_LEFT_TO_SHOW_WARNING).divn(100).toNumber() - 1
}) });
bountiesPage.renderOne(bounty);
await bountiesPage.expectText('Close deadline');
});
it('warning when update time is overdue', async () => {
const bounty = aBounty({ status: bountyStatusWith(
{
curator: alice,
status: 'Active',
updateDue: mockBountyHooks.bountyApi.bestNumber?.toNumber()
}) });
bountiesPage.renderOne(bounty);
await bountiesPage.expectText('Update overdue');
});
it('info when waiting for bounty funding', async () => {
const bounty = bountyWith({ status: 'Approved' });
bountiesPage.renderOne(bounty);
await bountiesPage.expectText('Waiting for Bounty Funding');
});
it('info when waiting for curator acceptance', async () => {
const bounty = bountyWith({ status: 'CuratorProposed' });
bountiesPage.renderOne(bounty);
await bountiesPage.expectText('Waiting for Curator\'s acceptance');
});
it('info when bounty is claimable', async () => {
const bounty = bountyWith({ status: 'PendingPayout' });
bountiesPage.renderOne(bounty);
await bountiesPage.expectText('Waiting for implementer to claim');
});
it('no warning or info when requirements are not met', async () => {
const bounty = aBounty({ status: bountyStatusWith({
curator: alice,
status: 'Active',
updateDue: defaultBountyUpdatePeriod.muln(BLOCKS_PERCENTAGE_LEFT_TO_SHOW_WARNING).divn(100).toNumber() + 1
}) });
bountiesPage.renderOne(bounty);
await bountiesPage.rendered();
bountiesPage.expectTextAbsent('Close deadline');
bountiesPage.expectTextAbsent('Update overdue');
bountiesPage.expectTextAbsent('Waiting for Bounty Funding');
bountiesPage.expectTextAbsent("Waiting for Curator's acceptance");
bountiesPage.expectTextAbsent('Waiting for implementer to claim');
});
});
});
+85
View File
@@ -0,0 +1,85 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useMemo, useRef } from 'react';
import { Button, styled, Table } from '@pezkuwi/react-components';
import { useBounties } from './hooks/index.js';
import Bounty from './Bounty.js';
import BountyCreate from './BountyCreate.js';
import Summary from './Summary.js';
import { useTranslation } from './translate.js';
interface Props {
className?: string;
}
function Bounties ({ className }: Props): React.ReactElement {
const { t } = useTranslation();
const info = useBounties();
const sorted = useMemo(
() => info?.bounties && [...info.bounties].sort((a, b) => b.index.cmp(a.index)),
[info]
);
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
[t('bounties'), 'start', 3],
[t('value')],
[t('curator'), 'start'],
[t('next action'), 'start', 3]
]);
const bestNumber = info.bestNumber;
return (
<StyledDiv className={className}>
<Summary info={info} />
<Button.Group>
<BountyCreate />
</Button.Group>
<Table
className='bounties-table-wrapper'
empty={sorted && t('No open bounties')}
header={headerRef.current}
>
{sorted && bestNumber && sorted.map(({ bounty, description, index, proposals }) => (
<Bounty
bestNumber={bestNumber}
bounty={bounty}
description={description}
index={index}
key={index.toNumber()}
proposals={proposals}
/>
))}
</Table>
</StyledDiv>
);
}
const StyledDiv = styled.div`
.bounties-table-wrapper table {
tr {
td, &:not(.filter) th {
&:last-child {
padding-right: 1.14rem;
}
}
}
}
.ui--IdentityIcon {
margin-right: 0.42rem;
}
.via-identity .name {
font-size: var(--font-size-base);
line-height: 1.7rem;
text-transform: initial;
filter: initial;
opacity: 1;
}
`;
export default React.memo(Bounties);
+270
View File
@@ -0,0 +1,270 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
import type { BountyIndex } from '@pezkuwi/types/interfaces';
import type { PalletBountiesBounty } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
import React, { useMemo } from 'react';
import { AddressSmall, Columar, ExpandButton, LinkExternal, styled, Table } from '@pezkuwi/react-components';
import { useToggle } from '@pezkuwi/react-hooks';
import { FormatBalance } from '@pezkuwi/react-query';
import { BountyActions } from './BountyActions/index.js';
import BountyExtraActions from './BountyExtraActions/index.js';
import BountyInfos from './BountyInfos/index.js';
import BountyActionMessage from './BountyNextActionInfo/BountyActionMessage.js';
import { getProposalToDisplay } from './helpers/extendedStatuses.js';
import { useBountyStatus } from './hooks/index.js';
import BountyStatusView from './BountyStatusView.js';
import Curator from './Curator.js';
import DueBlocks from './DueBlocks.js';
import { useTranslation } from './translate.js';
import VotersColumn from './VotersColumn.js';
interface Props {
bestNumber: BN;
bounty: PalletBountiesBounty;
className?: string;
description: string;
index: BountyIndex;
proposals?: DeriveCollectiveProposal[];
}
function Bounty ({ bestNumber, bounty, className = '', description, index, proposals }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [isExpanded, toggleExpanded] = useToggle(false);
const { bond, curatorDeposit, fee, proposer, status, value } = bounty;
const { beneficiary, bountyStatus, curator, unlockAt, updateDue } = useBountyStatus(status);
const blocksUntilUpdate = useMemo(() => updateDue?.sub(bestNumber), [bestNumber, updateDue]);
const blocksUntilPayout = useMemo(() => unlockAt?.sub(bestNumber), [bestNumber, unlockAt]);
const curatorToRender = useMemo(() => {
if (curator) {
return { curator, isFromProposal: false };
}
const proposalToDisplay = proposals && getProposalToDisplay(proposals, status);
return (proposalToDisplay?.proposal?.method === 'proposeCurator')
? { curator: proposalToDisplay.proposal.args[1], isFromProposal: true }
: null;
}, [curator, proposals, status]);
return (
<>
<StyledTr className={`${className} isExpanded isFirst ${isExpanded ? '' : 'isLast'}`}>
<Table.Column.Id value={index} />
<td
className='description-column'
data-testid='description'
>
<div title={description}>
{description}
</div>
</td>
<td>
<BountyStatusView bountyStatus={bountyStatus} />
</td>
<Table.Column.Balance value={value} />
<td>
{curatorToRender && (
<Curator
curator={curatorToRender.curator}
isFromProposal={curatorToRender.isFromProposal}
/>
)}
</td>
<td>
{blocksUntilPayout && unlockAt && (
<DueBlocks
dueBlocks={blocksUntilPayout}
endBlock={unlockAt}
label={t('payout')}
/>
)}
{blocksUntilUpdate && updateDue && (
<DueBlocks
dueBlocks={blocksUntilUpdate}
endBlock={updateDue}
label={t('update')}
/>
)}
<BountyActionMessage
bestNumber={bestNumber}
blocksUntilUpdate={blocksUntilUpdate}
status={status}
/>
<BountyActions
bestNumber={bestNumber}
description={description}
fee={fee}
index={index}
proposals={proposals}
status={status}
value={value}
/>
</td>
<td>
<BountyInfos
beneficiary={beneficiary}
proposals={proposals}
status={status}
/>
</td>
<td className='actions'>
<div>
<BountyExtraActions
bestNumber={bestNumber}
description={description}
index={index}
proposals={proposals}
status={status}
/>
<ExpandButton
expanded={isExpanded}
onClick={toggleExpanded}
/>
</div>
</td>
</StyledTr>
<StyledTr className={`${className} ${isExpanded ? 'isExpanded isLast' : 'isCollapsed'}`}>
<td />
<td
className='columar'
colSpan={3}
>
<Columar>
<Columar.Column>
<LinkExternal
data={index}
type='bounty'
withTitle
/>
</Columar.Column>
<Columar.Column>
<div className='column'>
<h5>{t('Proposer')}</h5>
<AddressSmall value={proposer} />
</div>
<div className='column'>
<h5>{t('Bond')}</h5>
<div className='inline-balance'><FormatBalance value={bond} /></div>
</div>
{curator && (
<div className='column'>
<h5>{t("Curator's fee")}</h5>
<div className='inline-balance'>{<FormatBalance value={fee} />}</div>
</div>
)}
<div className='column'>
{curator && !curatorDeposit.isZero() && (
<>
<h5>{t("Curator's deposit")}</h5>
<div className='inline-balance'>
<FormatBalance value={curatorDeposit} />
</div>
</>
)}
</div>
</Columar.Column>
</Columar>
</td>
<td />
<td />
<td>
{proposals && (
<div className='votes-table'>
<VotersColumn
option={'ayes'}
proposals={proposals}
status={status}
/>
<VotersColumn
option={'nays'}
proposals={proposals}
status={status}
/>
</div>
)}
</td>
<td />
</StyledTr>
</>
);
}
const StyledTr = styled.tr`
.description-column {
max-width: 200px;
div {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
& .links {
display: inline-flex;
}
& .inline-balance {
width: 50%;
font-size: var(--font-size-base);
line-height: normal;
}
.column {
align-items: center;
display: flex;
padding: 0 0 0.5rem;
h5 {
text-align: right;
padding: 0 1.7rem 0 0;
width: 50%;
}
}
& .td-info-action-row {
padding-right: 0;
}
.td-row {
display: flex;
justify-content: space-between;
align-items: center;
& :only-child {
margin-left: auto;
}
}
.bounty-action-row {
display: flex;
justify-content: flex-end;
align-items: center;
& > * + * {
margin-left: 0.6rem;
}
}
.block-to-time {
font-size: var(--font-size-tiny);
line-height: 1.5rem;
color: var(--color-label);
}
& .votes-table {
display: flex;
justify-content: space-between;
}
`;
export default React.memo(Bounty);
@@ -0,0 +1,79 @@
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountId, BountyIndex } from '@pezkuwi/types/interfaces';
import React, { useMemo, useState } from 'react';
import { Button, InputAddress, Modal, TxButton } from '@pezkuwi/react-components';
import { useAccounts, useToggle } from '@pezkuwi/react-hooks';
import { truncateTitle } from '../helpers/index.js';
import { useBounties } from '../hooks/index.js';
import { useTranslation } from '../translate.js';
interface Props {
curatorId: AccountId;
description: string;
index: BountyIndex;
}
function AwardBounty ({ curatorId, description, index }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { awardBounty } = useBounties();
const { allAccounts } = useAccounts();
const [isOpen, toggleOpen] = useToggle();
const [beneficiaryId, setBeneficiaryId] = useState<string | null>(null);
const isCurator = useMemo(() => allAccounts.includes(curatorId.toString()), [allAccounts, curatorId]);
return isCurator
? (
<>
<Button
icon='award'
isDisabled={false}
label={t('Reward implementer')}
onClick={toggleOpen}
/>
{isOpen && (
<Modal
header={`${t('award bounty')} - "${truncateTitle(description, 30)}"`}
onClose={toggleOpen}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('The Curator account that will be used to send this transaction. Any applicable fees will be paid by this account.')}>
<InputAddress
defaultValue={curatorId}
isDisabled={true}
label={t('award with account')}
type='account'
withLabel
/>
</Modal.Columns>
<Modal.Columns hint={t("Reward the bounty to an implementer's account. The implementer will be able to claim the funds after a delay period.")}>
<InputAddress
label={t('implementer account')}
onChange={setBeneficiaryId}
withLabel
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={curatorId}
icon='check'
label={t('Approve')}
onStart={toggleOpen}
params={[index, beneficiaryId]}
tx={awardBounty}
/>
</Modal.Actions>
</Modal>
)}
</>
)
: null;
}
export default React.memo(AwardBounty);
@@ -0,0 +1,89 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountId, BountyIndex } from '@pezkuwi/types/interfaces';
import type { BN } from '@pezkuwi/util';
import React, { useMemo } from 'react';
import { Button, InputAddress, InputBalance, Modal, TxButton } from '@pezkuwi/react-components';
import { useToggle } from '@pezkuwi/react-hooks';
import { permillOf, truncateTitle } from '../helpers/index.js';
import { useBounties, useUserRole } from '../hooks/index.js';
import { useTranslation } from '../translate.js';
interface Props {
curatorId: AccountId;
description: string;
fee: BN;
index: BountyIndex;
}
function BountyAcceptCurator ({ curatorId, description, fee, index }: Props) {
const { t } = useTranslation();
const { acceptCurator } = useBounties();
const { isCurator } = useUserRole(curatorId);
const { bountyCuratorDeposit } = useBounties();
const [isOpen, toggleOpen] = useToggle();
const deposit = useMemo(() => permillOf(fee, bountyCuratorDeposit), [fee, bountyCuratorDeposit]);
return isCurator
? (
<>
<Button
icon='check'
isDisabled={false}
label={t('Accept')}
onClick={toggleOpen}
/>
{isOpen && (
<Modal
header={`${t('accept curator role')} - "${truncateTitle(description, 30)}"`}
onClose={toggleOpen}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('Only the account proposed as curator by the council can create the assign curator transaction')}>
<InputAddress
isDisabled
label={t('curator account')}
type='account'
value={curatorId.toString()}
withLabel
/>
</Modal.Columns>
<Modal.Columns hint={t("This amount will be sent to your account after bounty is rewarded and you claim curator's fee.")}>
<InputBalance
defaultValue={fee.toString()}
isDisabled
label={t("curator's fee")}
/>
</Modal.Columns>
<Modal.Columns hint={t('This amount will be reserved from your account and returned after bounty claim is confirmed or if you give up, unless you are slashed earlier.')}>
<InputBalance
defaultValue={deposit.toString()}
isDisabled
label={t("curator's deposit")}
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={curatorId}
icon='check'
label={t('Accept Curator Role')}
onStart={toggleOpen}
params={[index]}
tx={acceptCurator}
/>
</Modal.Actions>
</Modal>
)}
</>
)
: null;
}
export default React.memo(BountyAcceptCurator);
@@ -0,0 +1,45 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountId, BountyIndex } from '@pezkuwi/types/interfaces';
import type { BN } from '@pezkuwi/util';
import React, { useMemo } from 'react';
import { TxButton } from '@pezkuwi/react-components';
import { useAccounts } from '@pezkuwi/react-hooks';
import { isClaimable } from '../helpers/index.js';
import { useBounties } from '../hooks/index.js';
import { useTranslation } from '../translate.js';
interface Props {
beneficiaryId: AccountId;
index: BountyIndex;
payoutDue: BN;
}
function BountyClaimAction ({ beneficiaryId, index, payoutDue }: Props) {
const { t } = useTranslation();
const { claimBounty } = useBounties();
const { allAccounts } = useAccounts();
const isBountyClaimable = useMemo(
() => isClaimable(allAccounts, beneficiaryId, payoutDue),
[allAccounts, beneficiaryId, payoutDue]
);
return isBountyClaimable
? (
<TxButton
accountId={beneficiaryId}
icon='plus'
label={t('Claim')}
params={[index]}
tx={claimBounty}
/>
)
: null;
}
export default React.memo(BountyClaimAction);
@@ -0,0 +1,105 @@
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
import type { BountyIndex } from '@pezkuwi/types/interfaces';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { getTreasuryProposalThreshold } from '@pezkuwi/apps-config';
import { Button, InputAddress, Modal, TxButton } from '@pezkuwi/react-components';
import { useApi, useCollectiveInstance, useCollectiveMembers, useToggle } from '@pezkuwi/react-hooks';
import { BN } from '@pezkuwi/util';
import { truncateTitle } from '../helpers/index.js';
import { useBounties } from '../hooks/index.js';
import { useTranslation } from '../translate.js';
interface Props {
description: string;
index: BountyIndex;
proposals?: DeriveCollectiveProposal[];
}
const BOUNTY_METHODS = ['approveBounty', 'closeBounty'];
function BountyInitiateVoting ({ description, index, proposals }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const { isMember, members } = useCollectiveMembers('council');
const councilMod = useCollectiveInstance('council');
const { approveBounty, closeBounty } = useBounties();
const [isOpen, toggleOpen] = useToggle();
const [accountId, setAccountId] = useState<string | null>(null);
const [threshold, setThreshold] = useState<BN>();
useEffect((): void => {
members && setThreshold(
new BN(Math.ceil(members.length * getTreasuryProposalThreshold(api)))
);
}, [api, members]);
const approveBountyProposal = useRef(approveBounty(index));
const closeBountyProposal = useRef(closeBounty(index));
const isVotingInitiated = useMemo(
() => proposals?.filter(({ proposal }) =>
proposal && BOUNTY_METHODS.includes(proposal.method)
).length !== 0,
[proposals]
);
return isMember && !isVotingInitiated && councilMod
? (
<>
<Button
icon='step-forward'
isDisabled={false}
label={t('Initiate voting')}
onClick={toggleOpen}
/>
{isOpen && (
<Modal
header={`${t('Initiate voting')} - "${truncateTitle(description, 30)}"`}
onClose={toggleOpen}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('The council member that will create a motion, submission equates to an "aye" vote for chosen option.')}>
<InputAddress
filter={members}
label={t('vote with account')}
onChange={setAccountId}
type='account'
withLabel
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={accountId}
icon='check'
isDisabled={false}
label={t('Approve')}
onStart={toggleOpen}
params={[threshold, approveBountyProposal.current, approveBountyProposal.current.length]}
tx={api.tx[councilMod].propose}
/>
<TxButton
accountId={accountId}
icon='ban'
isDisabled={false}
label={t('Reject')}
onStart={toggleOpen}
params={[threshold, closeBountyProposal.current, closeBountyProposal.current.length]}
tx={api.tx[councilMod].propose}
/>
</Modal.Actions>
</Modal>
)}
</>
)
: null;
}
export default React.memo(BountyInitiateVoting);
@@ -0,0 +1,126 @@
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
import type { Balance, BountyIndex } from '@pezkuwi/types/interfaces';
import React, { useEffect, useMemo, useState } from 'react';
import { getTreasuryProposalThreshold } from '@pezkuwi/apps-config';
import { Button, InputAddress, InputBalance, MarkError, Modal, TxButton } from '@pezkuwi/react-components';
import { useApi, useCollectiveInstance, useCollectiveMembers, useToggle } from '@pezkuwi/react-hooks';
import { BN } from '@pezkuwi/util';
import { truncateTitle } from '../helpers/index.js';
import { useBounties } from '../hooks/index.js';
import { useTranslation } from '../translate.js';
interface Props {
description: string
index: BountyIndex;
proposals?: DeriveCollectiveProposal[];
value: Balance;
}
const BOUNTY_METHODS = ['proposeCurator'];
function ProposeCuratorAction ({ description, index, proposals, value }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const { isMember, members } = useCollectiveMembers('council');
const councilMod = useCollectiveInstance('council');
const { proposeCurator } = useBounties();
const [isOpen, toggleOpen] = useToggle();
const [accountId, setAccountId] = useState<string | null>(null);
const [curatorId, setCuratorId] = useState<string | null>(null);
const [threshold, setThreshold] = useState<BN>();
const [fee, setFee] = useState<BN | null>();
const [isFeeValid, setIsFeeValid] = useState(false);
useEffect((): void => {
members && setThreshold(
new BN(Math.ceil(members.length * getTreasuryProposalThreshold(api)))
);
}, [api, members]);
const proposeCuratorProposal = useMemo(
() => curatorId && proposeCurator(index, curatorId, fee),
[curatorId, fee, index, proposeCurator]
);
const isVotingInitiated = useMemo(
() => proposals?.filter(({ proposal }) =>
proposal && BOUNTY_METHODS.includes(proposal.method)
).length !== 0,
[proposals]
);
useEffect(() => {
setIsFeeValid(!!fee && !!value?.gt(fee));
}, [value, fee]);
return isMember && !isVotingInitiated && councilMod
? (
<>
<Button
icon='step-forward'
isDisabled={false}
label={t('Propose curator')}
onClick={toggleOpen}
/>
{isOpen && (
<Modal
header={`${t('Propose curator')} - "${truncateTitle(description, 30)}"`}
onClose={toggleOpen}
size='large'
testId='propose-curator-modal'
>
<Modal.Content>
<Modal.Columns hint={t('The council member that will create the motion.')}>
<InputAddress
filter={members}
label={t('proposing account')}
onChange={setAccountId}
type='account'
withLabel
/>
</Modal.Columns>
<Modal.Columns hint={t('Choose a curator whose background and expertise is such that they are capable of determining when the task is complete.')}>
<InputAddress
label={t('select curator')}
onChange={setCuratorId}
withLabel
/>
</Modal.Columns>
<Modal.Columns hint={t('Part of the bounty value that will go to the Curator as a reward for their work')}>
<InputBalance
isError={!isFeeValid}
isZeroable
label={t("curator's fee")}
onChange={setFee}
value={fee}
/>
{!isFeeValid && (
<MarkError content={t("Curator's fee can't be higher than bounty value.")} />
)}
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={accountId}
icon='check'
isDisabled={!isFeeValid}
label={t('Propose curator')}
onStart={toggleOpen}
params={[threshold, proposeCuratorProposal, proposeCuratorProposal?.length]}
tx={api.tx[councilMod].propose}
/>
</Modal.Actions>
</Modal>
)}
</>
)
: null;
}
export default React.memo(ProposeCuratorAction);
@@ -0,0 +1,73 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
import type { Balance, BountyIndex } from '@pezkuwi/types/interfaces';
import type { PalletBountiesBountyStatus } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
import React, { useMemo } from 'react';
import { useBountyStatus } from '../hooks/index.js';
import AwardBounty from './AwardBounty.js';
import BountyAcceptCurator from './BountyAcceptCurator.js';
import BountyClaimAction from './BountyClaimAction.js';
import BountyInitiateVoting from './BountyInitiateVoting.js';
import ProposeCuratorAction from './ProposeCuratorAction.js';
interface Props {
bestNumber: BN;
description: string;
fee?: BN;
index: BountyIndex;
proposals?: DeriveCollectiveProposal[];
status: PalletBountiesBountyStatus;
value: Balance;
}
export function BountyActions ({ bestNumber, description, fee, index, proposals, status, value }: Props): React.ReactElement<Props> {
const { beneficiary, curator, unlockAt } = useBountyStatus(status);
const blocksUntilPayout = useMemo(() => unlockAt?.sub(bestNumber), [bestNumber, unlockAt]);
return (
<>
{status.isProposed &&
<BountyInitiateVoting
description={description}
index={index}
proposals={proposals}
/>
}
{status.isFunded &&
<ProposeCuratorAction
description={description}
index={index}
proposals={proposals}
value={value}
/>
}
{status.isCuratorProposed && curator && fee &&
<BountyAcceptCurator
curatorId={curator}
description={description}
fee={fee}
index={index}
/>
}
{status.isPendingPayout && beneficiary && blocksUntilPayout &&
<BountyClaimAction
beneficiaryId={beneficiary}
index={index}
payoutDue={blocksUntilPayout}
/>
}
{status.isActive && curator &&
<AwardBounty
curatorId={curator}
description={description}
index={index}
/>
}
</>
);
}
+130
View File
@@ -0,0 +1,130 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@pezkuwi/util';
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Input, InputAddress, InputBalance, MarkError, Modal, TxButton } from '@pezkuwi/react-components';
import { useToggle } from '@pezkuwi/react-hooks';
import { BN_ZERO } from '@pezkuwi/util';
import { calculateBountyBond, countUtf8Bytes } from './helpers/index.js';
import { useBalance, useBounties } from './hooks/index.js';
import { useTranslation } from './translate.js';
const MIN_TITLE_LEN = 1;
const TITLE_DEFAULT_VALUE = '';
const BOUNTY_DEFAULT_VALUE = BN_ZERO;
function BountyCreate () {
const { t } = useTranslation();
const { bountyDepositBase, bountyValueMinimum, dataDepositPerByte, maximumReasonLength, proposeBounty } = useBounties();
const [accountId, setAccountId] = useState<string | null>(null);
const balance = useBalance(accountId);
const [title, setTitle] = useState('');
const [bond, setBond] = useState(bountyDepositBase);
const [value, setValue] = useState<BN | undefined>(BOUNTY_DEFAULT_VALUE);
const [isOpen, toggleIsOpen] = useToggle();
const [isTitleValid, setIsTitleValid] = useState(false);
const [isValueValid, setIsValueValid] = useState(false);
const [hasFunds, setHasFunds] = useState(false);
useEffect(() => {
setIsTitleValid(title?.length >= MIN_TITLE_LEN && countUtf8Bytes(title) <= maximumReasonLength);
}, [maximumReasonLength, title]);
useEffect(() => {
setIsValueValid(!!value?.gte(bountyValueMinimum));
}, [bountyValueMinimum, value]);
useEffect(() => {
setHasFunds(!!balance?.gte(bond));
}, [balance, bond]);
const isValid = hasFunds && isTitleValid && isValueValid;
const onTitleChange = useCallback((value: string) => {
setTitle(value);
setBond(calculateBountyBond(value, bountyDepositBase, dataDepositPerByte));
}, [bountyDepositBase, dataDepositPerByte]);
return (
<>
<Button
icon='plus'
isDisabled={false}
label={t('Add Bounty')}
onClick={toggleIsOpen}
/>
{isOpen && (
<Modal
className='ui--AddBountyModal'
header={t('Add Bounty')}
onClose={toggleIsOpen}
>
<Modal.Content>
<Modal.Columns hint={t('Description of the Bounty (to be stored on-chain)')}>
<Input
autoFocus
defaultValue={TITLE_DEFAULT_VALUE}
isError={!isTitleValid}
label={t('bounty title')}
onChange={onTitleChange}
value={title}
/>
{!isTitleValid && (title !== TITLE_DEFAULT_VALUE) && (
<MarkError content={t('Title too long')} />
)}
</Modal.Columns>
<Modal.Columns hint={t('How much should be paid out for completed Bounty. Upon funding, the amount will be reserved in treasury.')}>
<InputBalance
isError={!isValueValid}
isZeroable
label={t('bounty requested allocation')}
onChange={setValue}
value={value}
/>
{!isValueValid && !value?.eq(BOUNTY_DEFAULT_VALUE) && (
<MarkError content={t('Allocation value is smaller than the minimum bounty value.')} />
)}
</Modal.Columns>
<Modal.Columns hint={t('Proposer bond depends on bounty title length.')}>
<InputBalance
defaultValue={bond.toString()}
isDisabled
label={t('bounty bond')}
/>
</Modal.Columns>
<Modal.Columns hint={t('This account will propose the bounty. Bond amount will be reserved on its balance.')}>
<InputAddress
isError={!hasFunds}
label={t('submit with account')}
onChange={setAccountId}
type='account'
withLabel
/>
{!hasFunds && (
<MarkError content={t('Account does not have enough funds.')} />
)}
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={accountId}
icon='plus'
isDisabled={!accountId || !isValid}
label={t('Add Bounty')}
onStart={toggleIsOpen}
params={[value, title]}
tx={proposeBounty}
/>
</Modal.Actions>
</Modal>
)}
</>
);
}
export default React.memo(BountyCreate);
@@ -0,0 +1,56 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountId, BountyIndex } from '@pezkuwi/types/interfaces';
import React from 'react';
import { InputAddress, Modal, TxButton } from '@pezkuwi/react-components';
import { truncateTitle } from '../helpers/index.js';
import { useBounties } from '../hooks/index.js';
import { useTranslation } from '../translate.js';
interface Props {
curatorId: AccountId;
description: string;
index: BountyIndex;
toggleOpen: () => void;
}
function BountyRejectCurator ({ curatorId, description, index, toggleOpen }: Props) {
const { t } = useTranslation();
const { unassignCurator } = useBounties();
return (
<Modal
header={`${t('reject curator')} - "${truncateTitle(description, 30)}"`}
onClose={toggleOpen}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('Only the account proposed as curator by the council can create the unassign curator transaction ')}>
<InputAddress
isDisabled
label={t('curator account')}
type='account'
value={curatorId.toString()}
withLabel
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={curatorId}
icon='times'
label={t('Reject')}
onStart={toggleOpen}
params={[index]}
tx={unassignCurator}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(BountyRejectCurator);
@@ -0,0 +1,76 @@
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BountyIndex } from '@pezkuwi/types/interfaces';
import React, { useEffect, useRef, useState } from 'react';
import { getTreasuryProposalThreshold } from '@pezkuwi/apps-config';
import { InputAddress, Modal, TxButton } from '@pezkuwi/react-components';
import { useApi, useCollectiveInstance, useCollectiveMembers } from '@pezkuwi/react-hooks';
import { BN } from '@pezkuwi/util';
import { truncateTitle } from '../helpers/index.js';
import { useBounties } from '../hooks/index.js';
import { useTranslation } from '../translate.js';
interface Props {
description: string;
index: BountyIndex;
toggleOpen: () => void;
}
function CloseBounty ({ description, index, toggleOpen }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const { members } = useCollectiveMembers('council');
const councilMod = useCollectiveInstance('council');
const { closeBounty } = useBounties();
const [accountId, setAccountId] = useState<string | null>(null);
const [threshold, setThreshold] = useState<BN>();
useEffect((): void => {
members && setThreshold(
new BN(Math.ceil(members.length * getTreasuryProposalThreshold(api)))
);
}, [api, members]);
const closeBountyProposal = useRef(closeBounty(index));
if (!councilMod) {
return null;
}
return (
<Modal
header={`${t('close bounty')} - "${truncateTitle(description, 30)}"`}
onClose={toggleOpen}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('The council member that will create the close bounty proposal, submission equates to an "aye" vote.')}>
<InputAddress
filter={members}
label={t('propose with account')}
onChange={setAccountId}
type='account'
withLabel
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={accountId}
icon='ban'
isDisabled={false}
label={t('Close Bounty')}
onStart={toggleOpen}
params={[threshold, closeBountyProposal.current, closeBountyProposal.current.length]}
tx={api.tx[councilMod].propose}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(CloseBounty);
@@ -0,0 +1,86 @@
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountId, BountyIndex } from '@pezkuwi/types/interfaces';
import React, { useCallback, useMemo, useState } from 'react';
import { Input, InputAddress, Modal, TxButton } from '@pezkuwi/react-components';
import { useBlockTime } from '@pezkuwi/react-hooks';
import { increaseDateByBlocks } from '../helpers/increaseDateByBlocks.js';
import { truncateTitle } from '../helpers/index.js';
import { useBounties } from '../hooks/index.js';
import { useTranslation } from '../translate.js';
interface Props {
curatorId: AccountId;
description: string
index: BountyIndex;
toggleOpen: () => void;
}
function ExtendBountyExpiryAction ({ curatorId, description, index, toggleOpen }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { bountyUpdatePeriod, extendBountyExpiry } = useBounties();
const [remark, setRemark] = useState('');
const [blockTime, timeAsText] = useBlockTime(bountyUpdatePeriod);
const onRemarkChange = useCallback((value: string) => {
setRemark(value);
}, []);
const expiryDate = useMemo(() => bountyUpdatePeriod && increaseDateByBlocks(bountyUpdatePeriod, blockTime), [bountyUpdatePeriod, blockTime]);
return (
<>
<Modal
header={`${t('extend expiry')} - "${truncateTitle(description, 30)}"`}
onClose={toggleOpen}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('Only curator can extend the bounty time.')}>
<InputAddress
isDisabled
label={t('curator account')}
type='account'
value={curatorId.toString()}
withLabel
/>
</Modal.Columns>
{expiryDate &&
<Modal.Columns hint={t(`Bounty expiry time will be set to ${timeAsText} from now.`)}>
<Input
isDisabled
label={t('new expiry date and time')}
value={`${expiryDate.toLocaleDateString()} ${expiryDate.toLocaleTimeString()}`}
/>
</Modal.Columns>
}
<Modal.Columns hint={t("The note that will be added to the transaction. It won't be stored on chain")}>
<Input
autoFocus
defaultValue={''}
label={t('bounty remark')}
onChange={onRemarkChange}
value={remark}
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={curatorId}
icon='check'
label={t('Accept')}
onStart={toggleOpen}
params={[index, remark]}
tx={extendBountyExpiry}
/>
</Modal.Actions>
</Modal>
</>
);
}
export default React.memo(ExtendBountyExpiryAction);
@@ -0,0 +1,56 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountId, BountyIndex } from '@pezkuwi/types/interfaces';
import React from 'react';
import { InputAddress, Modal, TxButton } from '@pezkuwi/react-components';
import { truncateTitle } from '../helpers/index.js';
import { useBounties } from '../hooks/index.js';
import { useTranslation } from '../translate.js';
interface Props {
curatorId: AccountId;
description: string;
index: BountyIndex;
toggleOpen: () => void;
}
function BountyGiveUpCurator ({ curatorId, description, index, toggleOpen }: Props) {
const { t } = useTranslation();
const { unassignCurator } = useBounties();
return (
<Modal
header={`${t("give up curator's role")} - "${truncateTitle(description, 30)}"`}
onClose={toggleOpen}
size='large'
>
<Modal.Content>
<Modal.Columns hint={t('You are giving up your curator role, the bounty will return to the Funded state. You will get your deposit back.')}>
<InputAddress
isDisabled
label={t('curator account')}
type='account'
value={curatorId.toString()}
withLabel
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={curatorId}
icon='check'
label={t('Give up')}
onStart={toggleOpen}
params={[index]}
tx={unassignCurator}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(BountyGiveUpCurator);
@@ -0,0 +1,130 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SubmittableExtrinsicFunction } from '@pezkuwi/api/types';
import type { AccountId, BountyIndex } from '@pezkuwi/types/interfaces';
import type { ValidUnassignCuratorAction } from '../types.js';
import React, { useEffect, useMemo, useState } from 'react';
import { getTreasuryProposalThreshold } from '@pezkuwi/apps-config';
import { InputAddress, Modal, TxButton } from '@pezkuwi/react-components';
import { useAccounts, useApi, useCollectiveInstance, useCollectiveMembers } from '@pezkuwi/react-hooks';
import { BN } from '@pezkuwi/util';
import { truncateTitle } from '../helpers/index.js';
import { useBounties } from '../hooks/index.js';
import { useTranslation } from '../translate.js';
interface Props {
action: ValidUnassignCuratorAction;
curatorId: AccountId;
description: string;
index: BountyIndex;
toggleOpen: () => void;
}
interface ActionProperties {
filter: string[];
header: string;
params: unknown[] | (() => unknown[]) | undefined;
proposingAccountTip: string;
tip: string;
title: string;
tx: null | SubmittableExtrinsicFunction<'promise'>;
}
function SlashCurator ({ action, curatorId, description, index, toggleOpen }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const { members } = useCollectiveMembers('council');
const councilMod = useCollectiveInstance('council');
const { unassignCurator } = useBounties();
const [accountId, setAccountId] = useState<string | null>(null);
const [threshold, setThreshold] = useState<BN>();
const { allAccounts } = useAccounts();
useEffect((): void => {
members && setThreshold(
new BN(Math.ceil(members.length * getTreasuryProposalThreshold(api)))
);
}, [api, members]);
const unassignCuratorProposal = useMemo(() => unassignCurator(index), [index, unassignCurator]);
const actionProperties = useMemo<Record<ValidUnassignCuratorAction, ActionProperties>>(() => ({
SlashCuratorAction: {
filter: allAccounts,
header: t('This action will Slash the Curator.'),
params: [index],
proposingAccountTip: t('The account that will create the transaction.'),
tip: t("Curator's deposit will be slashed and curator will be unassigned. Bounty will return to the Funded state."),
title: t('Slash curator'),
tx: unassignCurator
},
SlashCuratorMotion: {
filter: members,
header: t('This action will create a Council motion to slash the Curator.'),
params: [threshold, unassignCuratorProposal, unassignCuratorProposal?.length],
proposingAccountTip: t('The council member that will create the motion, submission equates to an "aye" vote.'),
tip: t("If the motion is approved, Curator's deposit will be slashed and Curator will be unassigned. Bounty will return to the Funded state."),
title: t('Slash curator'),
tx: councilMod && api.tx[councilMod].propose
},
UnassignCurator: {
filter: members,
header: t('This action will create a Council motion to unassign the Curator.'),
params: [threshold, unassignCuratorProposal, unassignCuratorProposal?.length],
proposingAccountTip: t('The council member that will create the motion, submission equates to an "aye" vote.'),
tip: t('If the motion is approved, the current Curator will be unassigned and the Bounty will return to the Funded state.'),
title: t('Unassign curator'),
tx: councilMod && api.tx[councilMod].propose
}
}), [t, index, unassignCurator, api, allAccounts, councilMod, members, threshold, unassignCuratorProposal]);
const { filter, params, proposingAccountTip, tip, title, tx } = actionProperties[action];
if (!tx) {
return null;
}
return (
<Modal
header={`${title} - "${truncateTitle(description, 30)}"`}
onClose={toggleOpen}
size='large'
>
<Modal.Content>
<Modal.Columns hint={proposingAccountTip}>
<InputAddress
filter={filter}
label={t('proposing account')}
onChange={setAccountId}
type='account'
withLabel
/>
</Modal.Columns>
<Modal.Columns hint={tip}>
<InputAddress
defaultValue={curatorId}
isDisabled
label={t('current curator')}
withLabel
/>
</Modal.Columns>
</Modal.Content>
<Modal.Actions>
<TxButton
accountId={accountId}
icon='check'
label='Approve'
onStart={toggleOpen}
params={params}
tx={tx}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(SlashCurator);
@@ -0,0 +1,174 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
import type { BountyIndex } from '@pezkuwi/types/interfaces';
import type { PalletBountiesBountyStatus } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
import type { ValidUnassignCuratorAction } from '../types.js';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { Menu, Popup } from '@pezkuwi/react-components';
import { useCollectiveMembers, useToggle } from '@pezkuwi/react-hooks';
import { determineUnassignCuratorAction } from '../helpers/index.js';
import { useBountyStatus, useUserRole } from '../hooks/index.js';
import { useTranslation } from '../translate.js';
import BountyRejectCurator from './BountyRejectCurator.js';
import CloseBounty from './CloseBounty.js';
import ExtendBountyExpiryAction from './ExtendBountyExpiryAction.js';
import GiveUp from './GiveUp.js';
import SlashCurator from './SlashCurator.js';
interface Props {
bestNumber: BN;
className?: string;
description: string;
index: BountyIndex;
proposals?: DeriveCollectiveProposal[];
status: PalletBountiesBountyStatus;
}
function Index ({ bestNumber, className, description, index, proposals, status }: Props): React.ReactElement<Props> | null {
const [isCloseBountyOpen, toggleCloseBounty] = useToggle();
const [isRejectCuratorOpen, toggleRejectCurator] = useToggle();
const [isSlashCuratorOpen, toggleSlashCurator] = useToggle();
const [isExtendExpiryOpen, toggleExtendExpiry] = useToggle();
const [isGiveUpCuratorOpen, toggleGiveUpCurator] = useToggle();
const [selectedAction, setSlashAction] = useState<ValidUnassignCuratorAction>();
const { t } = useTranslation();
const { isMember } = useCollectiveMembers('council');
const { curator, updateDue } = useBountyStatus(status);
const { isCurator, roles } = useUserRole(curator);
const blocksUntilUpdate = useMemo(() => updateDue?.sub(bestNumber), [bestNumber, updateDue]);
const availableSlashActions = determineUnassignCuratorAction(roles, status, blocksUntilUpdate);
const slashCuratorActionNames = useRef<Record<ValidUnassignCuratorAction, string>>({
SlashCuratorAction: t('Slash curator'),
SlashCuratorMotion: t('Slash curator (Council)'),
UnassignCurator: t('Unassign curator')
});
const existingCloseBountyProposal = useMemo(
() => proposals?.find(({ proposal }) =>
proposal && proposal.method === 'closeBounty'
),
[proposals]
);
const existingUnassignCuratorProposal = useMemo(
() => proposals?.find(({ proposal }) =>
proposal && proposal.method === 'unassignCurator'
),
[proposals]
);
const showCloseBounty = (status.isFunded || status.isActive || status.isCuratorProposed) && isMember && !existingCloseBountyProposal;
const showRejectCurator = status.isCuratorProposed && isCurator;
const showGiveUpCurator = status.isActive && isCurator;
const showExtendExpiry = status.isActive && isCurator;
const showSlashCurator = (status.isCuratorProposed || status.isActive || status.isPendingPayout) && !existingUnassignCuratorProposal && availableSlashActions.length !== 0;
const hasNoItems = !(showCloseBounty || showRejectCurator || showExtendExpiry || showSlashCurator || showGiveUpCurator);
const slashCurator = useCallback(
(actionName: ValidUnassignCuratorAction) =>
(): void => {
setSlashAction(actionName);
toggleSlashCurator();
},
[toggleSlashCurator]
);
return !hasNoItems
? (
<div className={className}>
{isCloseBountyOpen &&
<CloseBounty
description={description}
index={index}
toggleOpen={toggleCloseBounty}
/>
}
{isRejectCuratorOpen && curator &&
<BountyRejectCurator
curatorId={curator}
description={description}
index={index}
toggleOpen={toggleRejectCurator}
/>
}
{isExtendExpiryOpen && curator &&
<ExtendBountyExpiryAction
curatorId={curator}
description={description}
index={index}
toggleOpen={toggleExtendExpiry}
/>
}
{isGiveUpCuratorOpen && curator &&
<GiveUp
curatorId={curator}
description={description}
index={index}
toggleOpen={toggleGiveUpCurator}
/>
}
{isSlashCuratorOpen && curator && selectedAction &&
<SlashCurator
action={selectedAction}
curatorId={curator}
description={description}
index={index}
toggleOpen={toggleSlashCurator}
/>
}
<Popup
value={
<Menu className='settings-menu'>
{showCloseBounty && (
<Menu.Item
key='closeBounty'
label={t('Close')}
onClick={toggleCloseBounty}
/>
)}
{showRejectCurator && (
<Menu.Item
key='rejectCurator'
label={t('Reject curator')}
onClick={toggleRejectCurator}
/>
)}
{showExtendExpiry && (
<Menu.Item
key='extendExpiry'
label={t('Extend expiry')}
onClick={toggleExtendExpiry}
/>
)}
{showGiveUpCurator && (
<Menu.Item
key='giveUpCurator'
label={t('Give up')}
onClick={toggleGiveUpCurator}
/>
)}
{showSlashCurator && availableSlashActions.map((actionName) => (
<Menu.Item
key={actionName}
label={slashCuratorActionNames.current[actionName]}
onClick={slashCurator(actionName)}
/>
))}
</Menu>
}
/>
</div>
)
: null;
}
export default React.memo(Index);
@@ -0,0 +1,47 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
import type { PalletBountiesBountyStatus } from '@pezkuwi/types/lookup';
import React, { useRef } from 'react';
import { LabelHelp, styled } from '@pezkuwi/react-components';
import { proposalNameToDisplay } from '../helpers/extendedStatuses.js';
import { useTranslation } from '../translate.js';
interface Props {
className?: string;
proposal: DeriveCollectiveProposal;
status: PalletBountiesBountyStatus;
}
function VotingDescriptionInfo ({ className, proposal, status }: Props): React.ReactElement<Props> {
const bestProposalName = proposalNameToDisplay(proposal, status);
const { t } = useTranslation();
const votingDescriptions = useRef<Record<string, string>>({
approveBounty: t('Bounty approval under voting'),
closeBounty: t('Bounty rejection under voting'),
proposeCurator: t('Curator proposal under voting'),
slashCurator: t('Curator slash under voting'),
unassignCurator: t('Unassign curator under voting')
});
return (
<StyledDiv
className={className}
data-testid='voting-description'
>
{bestProposalName && votingDescriptions.current[bestProposalName] &&
<LabelHelp help={votingDescriptions.current[bestProposalName]} />
}
</StyledDiv>
);
}
const StyledDiv = styled.div`
margin-left: 0.2rem;
`;
export default React.memo(VotingDescriptionInfo);
@@ -0,0 +1,33 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { styled } from '@pezkuwi/react-components';
import { useTranslation } from '../translate.js';
interface Props {
className?: string;
}
function VotingLink ({ className }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
return (
<StyledA
className={className}
href='#/council/motions'
>
{t('Voting')}
</StyledA>
);
}
const StyledA = styled.a`
line-height: 0.85rem;
font-size: var(--font-size-tiny);
text-decoration: underline;
`;
export default React.memo(VotingLink);
@@ -0,0 +1,72 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
import type { PalletBountiesBountyStatus } from '@pezkuwi/types/lookup';
import React, { useMemo } from 'react';
import { styled } from '@pezkuwi/react-components';
import { useCollectiveMembers } from '@pezkuwi/react-hooks';
import { useTranslation } from '../translate.js';
import VotingDescriptionInfo from './VotingDescriptionInfo.js';
import VotingLink from './VotingLink.js';
interface Props {
className?: string;
proposal: DeriveCollectiveProposal;
status: PalletBountiesBountyStatus;
}
function VotingSummary ({ className, proposal, status }: Props): React.ReactElement<Props> {
const { members } = useCollectiveMembers('council');
const { t } = useTranslation();
const ayes = useMemo(() => proposal?.votes?.ayes?.length, [proposal]);
const nays = useMemo(() => proposal?.votes?.nays?.length, [proposal]);
const threshold = useMemo(() => proposal?.votes?.threshold.toNumber(), [proposal]);
const nayThreshold = useMemo(() => members?.length && threshold ? (members.length - threshold + 1) : 0, [members, threshold]);
return (
<>
{proposal && (
<StyledDiv
className={className}
data-testid='voting-summary'
>
<div className='voting-summary-text'><span>{t('Aye')}</span> <b>{ayes}/{threshold}</b></div>
<div className='voting-summary-text'><span>{t('Nay')}</span> <b>{nays}/{nayThreshold}</b></div>
<div className='link-info'>
<VotingLink />
<VotingDescriptionInfo
proposal={proposal}
status={status}
/>
</div>
</StyledDiv>
)}
</>
);
}
const StyledDiv = styled.div`
.voting-summary-text {
font-size: var(--font-size-small);
line-height: 1.5rem;
color: var(--color-label);
span {
min-width: 0.5rem;
margin-right: 0.5rem;
}
}
.link-info {
display: flex;
justify-content: space-between;
align-items: center;
line-height: 1.5rem;
}
`;
export default React.memo(VotingSummary);
@@ -0,0 +1,46 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
import type { AccountId } from '@pezkuwi/types/interfaces';
import type { PalletBountiesBountyStatus } from '@pezkuwi/types/lookup';
import React, { useMemo } from 'react';
import { AddressSmall } from '@pezkuwi/react-components';
import Description from '../Description.js';
import { getProposalToDisplay } from '../helpers/extendedStatuses.js';
import { useTranslation } from '../translate.js';
import VotingSummary from './VotingSummary.js';
interface Props {
beneficiary?: AccountId;
proposals?: DeriveCollectiveProposal[];
status: PalletBountiesBountyStatus;
}
function BountyInfos ({ beneficiary, proposals, status }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const proposalToDisplay = useMemo(() => proposals && getProposalToDisplay(proposals, status), [proposals, status]);
return (
<>
{proposalToDisplay &&
<VotingSummary
proposal={proposalToDisplay}
status={status}
/>
}
{beneficiary && (
<div>
<AddressSmall value={beneficiary} />
<Description description={t('Beneficiary')} />
</div>
)}
</>
);
}
export default React.memo(BountyInfos);
@@ -0,0 +1,69 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletBountiesBountyStatus } from '@pezkuwi/types/lookup';
import React, { useMemo } from 'react';
import { BN, BN_HUNDRED, BN_ZERO } from '@pezkuwi/util';
import { useBounties, useBountyStatus } from '../hooks/index.js';
import { useTranslation } from '../translate.js';
import BountyInfo from './BountyInfo.js';
interface Props {
bestNumber: BN;
blocksUntilUpdate?: BN;
status: PalletBountiesBountyStatus;
}
export const BLOCKS_PERCENTAGE_LEFT_TO_SHOW_WARNING = 10;
const BLOCKS_LEFT_TO_SHOW_WARNING = new BN('10000');
function BountyActionMessage ({ bestNumber, blocksUntilUpdate, status }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { unlockAt } = useBountyStatus(status);
const { bountyUpdatePeriod } = useBounties();
const blocksUntilPayout = useMemo(() => unlockAt?.sub(bestNumber), [bestNumber, unlockAt]);
const blocksPercentageLeftToShowWarning = bountyUpdatePeriod?.muln(BLOCKS_PERCENTAGE_LEFT_TO_SHOW_WARNING).div(BN_HUNDRED);
const blocksToShowWarning = blocksPercentageLeftToShowWarning ?? BLOCKS_LEFT_TO_SHOW_WARNING;
return (
<div>
{blocksUntilUpdate?.lte(BN_ZERO) && (
<BountyInfo
description={t('Update overdue')}
type='warning'
/>
)}
{blocksUntilUpdate?.lt(blocksToShowWarning) && blocksUntilUpdate?.gt(BN_ZERO) && (
<BountyInfo
description={t('Close deadline')}
type='warning'
/>
)}
{status.isApproved && (
<BountyInfo
description={t('Waiting for Bounty Funding')}
type='info'
/>
)}
{status.isCuratorProposed && (
<BountyInfo
description={t("Waiting for Curator's acceptance")}
type='info'
/>
)}
{blocksUntilPayout?.lt(BN_ZERO) &&
<BountyInfo
description={t('Waiting for implementer to claim')}
type='info'
/>
}
</div>
);
}
export default React.memo(BountyActionMessage);
@@ -0,0 +1,52 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { Icon, styled } from '@pezkuwi/react-components';
interface Props {
className?: string;
description: string;
type?: 'info' | 'warning';
}
function BountyInfo ({ className = '', description, type = 'info' }: Props): React.ReactElement<Props> {
return (
<StyledDiv className={className}>
{type === 'warning' && (
<div className='info-icon'>
<Icon icon={'exclamation-triangle'} />
</div>
)}
<div className='description'>
{description}
</div>
</StyledDiv>
);
}
const StyledDiv = styled.div`
display: flex;
align-items: center;
font-size: var(--font-size-small);
line-height: 1.5rem;
.info-icon {
margin-right: 0.2rem;
svg {
color: var(--color-bounty-info);
}
}
.description {
font-weight: var(--font-weight-normal);
var(font-size: var(--font-size-tiny);)
line-height: 0.864rem;
color: var(--color-label);
word-wrap: break-word;
}
`;
export default React.memo(BountyInfo);
@@ -0,0 +1,33 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { StatusName } from './types.js';
import React from 'react';
import { styled } from '@pezkuwi/react-components';
import { insertSpaceBeforeCapitalLetter } from './helpers/index.js';
interface Props {
bountyStatus: StatusName;
className?: string;
}
function BountyStatusView ({ bountyStatus, className = '' }: Props): React.ReactElement<Props> {
return (
<StyledDiv
className={className}
data-testid={'bountyStatus'}
>
{insertSpaceBeforeCapitalLetter(bountyStatus)}
</StyledDiv>
);
}
const StyledDiv = styled.div`
display: flex;
align-items: center;
`;
export default React.memo(BountyStatusView);
+29
View File
@@ -0,0 +1,29 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Codec } from '@pezkuwi/types/types';
import React from 'react';
import { AddressSmall } from '@pezkuwi/react-components';
import Description from './Description.js';
import { useTranslation } from './translate.js';
interface Props {
curator: Codec;
isFromProposal: boolean;
}
function Curator ({ curator, isFromProposal }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
return (
<div>
<AddressSmall value={curator.toString()} />
{isFromProposal && <Description description={t('Proposed Curator')} />}
</div>
);
}
export default React.memo(Curator);
@@ -0,0 +1,32 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { styled } from '@pezkuwi/react-components';
interface Props {
className?: string;
dataTestId?: string;
description: string;
}
function Description ({ className = '', dataTestId = '', description }: Props): React.ReactElement<Props> {
return (
<StyledDiv
className={className}
data-testid={dataTestId}
>
{description}
</StyledDiv>
);
}
const StyledDiv = styled.div`
margin-top: 0.28rem;
font-size: var(--font-size-tiny);
line-height: 0.85rem;
color: var(--color-label);
`;
export default React.memo(Description);
+32
View File
@@ -0,0 +1,32 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@pezkuwi/util';
import React from 'react';
import { BlockToTime } from '@pezkuwi/react-query';
import { formatNumber } from '@pezkuwi/util';
interface Props {
dueBlocks: BN;
endBlock: BN;
label: string;
}
function DueBlocks ({ dueBlocks, endBlock, label }: Props): React.ReactElement<Props> {
return (
<>
{dueBlocks.gtn(0) && (
<>
<BlockToTime value={dueBlocks}>
&nbsp;({label})
</BlockToTime>
#{formatNumber(endBlock)}
</>
)}
</>
);
}
export default React.memo(DueBlocks);
+72
View File
@@ -0,0 +1,72 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BountyApi } from './hooks/useBounties.js';
import React, { useMemo } from 'react';
import { CardSummary, SummaryBox } from '@pezkuwi/react-components';
import { useTreasury } from '@pezkuwi/react-hooks';
import { FormatBalance } from '@pezkuwi/react-query';
import { BN, formatNumber } from '@pezkuwi/util';
import { useTranslation } from './translate.js';
interface Props {
className?: string;
info: BountyApi;
}
function Summary ({ className = '', info: { bestNumber, bounties, bountyCount, childCount } }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { spendPeriod } = useTreasury();
const totalValue = useMemo(
() => (bounties || []).reduce((total, { bounty: { value } }) => total.iadd(value), new BN(0)),
[bounties]
);
return (
<SummaryBox className={`${className} ui--BountySummary`}>
<section>
{bounties && (
<CardSummary label={t('active')}>
{formatNumber(bounties.length)}
</CardSummary>
)}
{bountyCount && bounties && (
<CardSummary label={t('past')}>
{formatNumber(bountyCount.subn(bounties.length))}
</CardSummary>
)}
{childCount && (
<CardSummary label={t('children')}>
{formatNumber(childCount)}
</CardSummary>
)}
</section>
<section>
<CardSummary label={t('active total')}>
<FormatBalance
value={totalValue}
withSi
/>
</CardSummary>
</section>
<section>
{bestNumber && !spendPeriod.isZero() && (
<CardSummary
label={t('funding period')}
progress={{
total: spendPeriod,
value: bestNumber.mod(spendPeriod),
withTime: true
}}
/>
)}
</section>
</SummaryBox>
);
}
export default React.memo(Summary);
@@ -0,0 +1,84 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { IconName } from '@fortawesome/fontawesome-svg-core';
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
import type { PalletBountiesBountyStatus } from '@pezkuwi/types/lookup';
import React, { useMemo } from 'react';
import { AddressSmall, Icon, styled } from '@pezkuwi/react-components';
import { getProposalToDisplay } from './helpers/extendedStatuses.js';
import { useTranslation } from './translate.js';
interface Props {
className?: string;
option: 'ayes' | 'nays';
proposals: DeriveCollectiveProposal[];
status: PalletBountiesBountyStatus;
}
const icons: Record<string, IconName> = {
ayes: 'check',
nays: 'times'
} as const;
function VotersColumn ({ className, option, proposals, status }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const proposal = useMemo(() => getProposalToDisplay(proposals, status), [proposals, status]);
const votes = useMemo(() => option === 'ayes' ? proposal?.votes?.ayes : proposal?.votes?.nays, [proposal, option]);
const voters = useMemo(() => votes?.map((accountId) =>
<div
className='voter'
data-testid={`voters_${option}_${accountId.toString()}`}
key={accountId.toString()}
>
<AddressSmall value={accountId} />
</div>),
[option, votes]);
return (
<>
{proposal &&
<StyledDiv className={className}>
<div className='vote-numbers'>
<span className='vote-numbers-icon'><Icon icon={icons[option]} /></span>
<span className='vote-numbers-label'>
{option === 'ayes' && t('Aye: {{count}}', { replace: { count: votes ? votes.length : 0 } })}
{option === 'nays' && t('Nay: {{count}}', { replace: { count: votes ? votes.length : 0 } })}
</span>
</div>
{voters}
</StyledDiv>
}
</>
);
}
const StyledDiv = styled.div`
width: 50%;
.vote-numbers {
display: flex;
align-items: center;
margin-bottom: 0.85rem;
}
.vote-numbers-icon svg {
max-width: 10px;
color: var(--color-label);
}
.vote-numbers-label {
margin-left: 0.75rem;
font-weight: var(--font-weight-bold);
font-size: var(--font-size-tiny);
line-height: 0.85rem;
text-transform: uppercase;
color: var(--color-label);
}
`;
export default React.memo(VotersColumn);
@@ -0,0 +1,27 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
import { TypeRegistry } from '@pezkuwi/types/create';
import { BN } from '@pezkuwi/util';
import { calculateBountyBond } from './calculateBountyBond.js';
describe('Calculate bounty bond', () => {
it('sums deposit base and deposit for each byte of description', () => {
const registry = new TypeRegistry();
const depositBase = registry.createType('BalanceOf', new BN(166666666666));
const depositPerByte = registry.createType('BalanceOf', new BN(1666666666));
expect(calculateBountyBond('Dicle network UI Bounty', depositBase, depositPerByte)).toEqual(new BN(206666666650));
});
it('handles utf-8 chars', () => {
const registry = new TypeRegistry();
const depositBase = registry.createType('BalanceOf', new BN(100));
const depositPerByte = registry.createType('BalanceOf', new BN(10));
expect(calculateBountyBond('óy😅€', depositBase, depositPerByte)).toEqual(new BN(200));
});
});
@@ -0,0 +1,12 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@pezkuwi/util';
export function calculateBountyBond (description: string, depositBase: BN, depositPerByte: BN): BN {
return depositBase.add(depositPerByte.muln(countUtf8Bytes(description)));
}
export function countUtf8Bytes (str: string): number {
return new Blob([str]).size;
}
@@ -0,0 +1,63 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
import { createAugmentedApi } from '@pezkuwi/test-support/api';
import { BountyFactory } from '@pezkuwi/test-support/creation/bounties';
import { BN } from '@pezkuwi/util';
import { determineUnassignCuratorAction } from './determineUnassignCuratorAction.js';
describe('adjust slash curator component for', () => {
const augmentedApi = createAugmentedApi();
const { aBountyStatus } = new BountyFactory(augmentedApi);
it('Member in Active state', () => {
const displayAs = determineUnassignCuratorAction(['Member'], aBountyStatus('Active'));
expect(displayAs).toEqual(['SlashCuratorMotion']);
});
it('Member in CuratorProposed state', () => {
const displayAs = determineUnassignCuratorAction(['Member'], aBountyStatus('CuratorProposed'));
expect(displayAs).toEqual(['UnassignCurator']);
});
it('Member in PendingPayout state', () => {
const displayAs = determineUnassignCuratorAction(['Member'], aBountyStatus('PendingPayout'));
expect(displayAs).toEqual(['SlashCuratorMotion']);
});
it('User in Active state with update due blocks remaining', () => {
const displayAs = determineUnassignCuratorAction(['User'], aBountyStatus('Active'), new BN('1'));
expect(displayAs).toEqual([]);
});
it('User in Active state with no updated state', () => {
const displayAs = determineUnassignCuratorAction(['User'], aBountyStatus('Active'), new BN('-1'));
expect(displayAs).toEqual(['SlashCuratorAction']);
});
it('Member and User in Active state with no updated state', () => {
const displayAs = determineUnassignCuratorAction(['User', 'Member'], aBountyStatus('Active'), new BN('-1'));
expect(displayAs).toEqual(expect.arrayContaining(['SlashCuratorAction', 'SlashCuratorMotion']));
});
it('Curator in Active state', () => {
const displayAs = determineUnassignCuratorAction(['Curator'], aBountyStatus('PendingPayout'));
expect(displayAs).toEqual([]);
});
it('User in Active state', () => {
const displayAs = determineUnassignCuratorAction(['User'], aBountyStatus('PendingPayout'));
expect(displayAs).toEqual([]);
});
});
@@ -0,0 +1,32 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletBountiesBountyStatus } from '@pezkuwi/types/lookup';
import type { BN } from '@pezkuwi/util';
import type { UserRole, ValidUnassignCuratorAction } from '../types.js';
import { BN_ZERO } from '@pezkuwi/util';
export function determineUnassignCuratorAction (roles: UserRole[], status: PalletBountiesBountyStatus, blocksUntilUpdate?: BN): ValidUnassignCuratorAction[] {
const actions: ValidUnassignCuratorAction[] = [];
if (status.isCuratorProposed && roles.includes('Member')) {
actions.push('UnassignCurator');
}
if (status.isActive) {
if (roles.includes('Member')) {
actions.push('SlashCuratorMotion');
}
if (roles.includes('User') && blocksUntilUpdate && blocksUntilUpdate.lt(BN_ZERO)) {
actions.push('SlashCuratorAction');
}
}
if (status.isPendingPayout && roles.includes('Member')) {
actions.push('SlashCuratorMotion');
}
return actions;
}
@@ -0,0 +1,43 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
import type { PalletBountiesBountyStatus } from '@pezkuwi/types/lookup';
import type { BountyVotingStatuses, StatusName } from '../types.js';
const validProposalNames: BountyVotingStatuses = {
Active: ['closeBounty', 'unassignCurator'],
Approved: [],
CuratorProposed: ['closeBounty', 'unassignCurator'],
Funded: ['proposeCurator', 'closeBounty'],
PendingPayout: ['unassignCurator'],
Proposed: ['approveBounty', 'closeBounty']
};
function validMethods (status: PalletBountiesBountyStatus): string[] {
return validProposalNames[status.type as StatusName];
}
function getProposalByMethod (bountyProposals: DeriveCollectiveProposal[], method: string | undefined): DeriveCollectiveProposal | undefined {
return bountyProposals.find(({ proposal }) => proposal && proposal.method === method);
}
function bestValidProposalName (bountyProposals: DeriveCollectiveProposal[], status: PalletBountiesBountyStatus): string | undefined {
const methods = bountyProposals.map(({ proposal }) => proposal?.method);
return validMethods(status).find((method) => methods.includes(method));
}
export function proposalNameToDisplay (bountyProposal: DeriveCollectiveProposal, status: PalletBountiesBountyStatus): string | undefined {
if (bountyProposal.proposal && bountyProposal.proposal.method !== 'unassignCurator') {
return bountyProposal.proposal.method;
}
return status.isCuratorProposed ? 'unassignCurator' : 'slashCurator';
}
export function getProposalToDisplay (bountyProposals: DeriveCollectiveProposal[], status: PalletBountiesBountyStatus): DeriveCollectiveProposal | null {
const method = bestValidProposalName(bountyProposals, status);
return getProposalByMethod(bountyProposals, method) ?? null;
}
@@ -0,0 +1,40 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
import { TypeRegistry } from '@pezkuwi/types/create';
import { getBountyStatus } from './getBountyStatus.js';
describe('get bounty status', () => {
let registry: TypeRegistry;
beforeEach(() => {
registry = new TypeRegistry();
});
it('for CuratorProposed state it has curator defined', () => {
const bountyStatus = getBountyStatus(registry.createType('PalletBountiesBountyStatus', 'CuratorProposed'));
expect(bountyStatus.bountyStatus).toEqual('CuratorProposed');
expect(bountyStatus.curator).toBeDefined();
});
it('for Active state it has curator and update due defined', () => {
const bountyStatus = getBountyStatus(registry.createType('PalletBountiesBountyStatus', 'Active'));
expect(bountyStatus.bountyStatus).toEqual('Active');
expect(bountyStatus.curator).toBeDefined();
expect(bountyStatus.updateDue).toBeDefined();
});
it('for PendingPayout state it has curator, beneficiary and unlock_at defined', () => {
const bountyStatus = getBountyStatus(registry.createType('PalletBountiesBountyStatus', 'PendingPayout'));
expect(bountyStatus.bountyStatus).toEqual('PendingPayout');
expect(bountyStatus.curator).toBeDefined();
expect(bountyStatus.beneficiary).toBeDefined();
expect(bountyStatus.unlockAt).toBeDefined();
});
});
@@ -0,0 +1,45 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletBountiesBountyStatus } from '@pezkuwi/types/lookup';
import type { BountyStatusType, StatusName } from '../types.js';
export const getBountyStatus = (status: PalletBountiesBountyStatus): BountyStatusType => {
const statusAsString = status.type as StatusName;
let result: BountyStatusType = {
beneficiary: undefined,
bountyStatus: statusAsString,
curator: undefined,
unlockAt: undefined,
updateDue: undefined
};
if (status.isCuratorProposed) {
result = {
...result,
bountyStatus: 'CuratorProposed',
curator: status.asCuratorProposed.curator
};
}
if (status.isActive) {
result = {
...result,
curator: status.asActive.curator,
updateDue: status.asActive.updateDue
};
}
if (status.isPendingPayout) {
result = {
...result,
beneficiary: status.asPendingPayout.beneficiary,
bountyStatus: 'PendingPayout',
curator: status.asPendingPayout.curator,
unlockAt: status.asPendingPayout.unlockAt
};
}
return result;
};
@@ -0,0 +1,8 @@
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@pezkuwi/util';
export function increaseDateByBlocks (blocks: BN, blockTime: number): Date {
return new Date(Date.now() + blocks.muln(blockTime).toNumber());
}
@@ -0,0 +1,9 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
export * from './calculateBountyBond.js';
export * from './determineUnassignCuratorAction.js';
export * from './getBountyStatus.js';
export * from './isClaimable.js';
export * from './permillOf.js';
export * from './stringHelpers.js';
@@ -0,0 +1,43 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
import { TypeRegistry } from '@pezkuwi/types/create';
import { BN } from '@pezkuwi/util';
import { isClaimable } from './isClaimable.js';
describe('Is claimable', () => {
const registry = new TypeRegistry();
const accountAddress = '5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM';
const beneficiaryId = registry.createType('AccountId', accountAddress);
it('returns false, when payout due is greater than 0', () => {
const accounts = [accountAddress];
const payoutDue = new BN('1');
expect(isClaimable(accounts, beneficiaryId, payoutDue)).toBe(false);
});
it('returns false, when payout due is equal 0', () => {
const accounts = [accountAddress];
const payoutDue = new BN('0');
expect(isClaimable(accounts, beneficiaryId, payoutDue)).toBe(false);
});
it('returns true, when payout due is lesser than 0 and beneficiary is among accounts', () => {
const accounts = [accountAddress];
const payoutDue = new BN('-1');
expect(isClaimable(accounts, beneficiaryId, payoutDue)).toBe(true);
});
it('returns false, when beneficiary is not among accounts', () => {
const accounts = ['This_is_not_the_treasury_address_27Tt8tkntv6Q7JVPhFsTB'];
const payoutDue = new BN('-1');
expect(isClaimable(accounts, beneficiaryId, payoutDue)).toBe(false);
});
});
@@ -0,0 +1,9 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountId } from '@pezkuwi/types/interfaces';
import type { BN } from '@pezkuwi/util';
export function isClaimable (accounts: string[], beneficiary: AccountId, payoutDue: BN): boolean {
return payoutDue.ltn(0) && accounts.includes(beneficiary.toString());
}
@@ -0,0 +1,10 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@pezkuwi/util';
import { BN_MILLION } from '@pezkuwi/util';
export function permillOf (value: BN, perMill: BN): BN {
return value.mul(perMill).div(BN_MILLION);
}
@@ -0,0 +1,13 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
export function insertSpaceBeforeCapitalLetter (str: string): string {
return str.replace(/([a-z])([A-Z])/g, '$1 $2');
}
export function truncateTitle (str: string, maxLength: number): string {
return (str.length > maxLength)
// ellipsis
? (str.substring(0, maxLength - 1) + String.fromCharCode(8230))
: str;
}
@@ -0,0 +1,18 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
import { truncateTitle } from './stringHelpers.js';
describe('Truncate title', () => {
it('does not truncate short title', () => {
expect(truncateTitle('a short one', 30)).toEqual('a short one');
});
it('truncates a long title', () => {
const ellipsis = String.fromCharCode(8230);
expect(truncateTitle('A long title that should got truncated', 30)).toEqual(`A long title that should got ${ellipsis}`);
});
});
@@ -0,0 +1,7 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
export * from './useBalance.js';
export * from './useBounties.js';
export * from './useBountyStatus.js';
export * from './useUserRole.js';
@@ -0,0 +1,16 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveBalancesAll } from '@pezkuwi/api-derive/types';
import type { Balance } from '@pezkuwi/types/interfaces';
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
function useBalanceImpl (accountId: string | null): Balance | undefined {
const { api } = useApi();
const balancesAll = useCall<DeriveBalancesAll>(api.derive.balances?.all, [accountId]);
return balancesAll?.transferable || balancesAll?.availableBalance;
}
export const useBalance = createNamedHook('useBalance', useBalanceImpl);
@@ -0,0 +1,98 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@pezkuwi/api';
import type { SubmittableExtrinsicFunction } from '@pezkuwi/api/types';
import type { DeriveBounties } from '@pezkuwi/api-derive/types';
import type { Codec } from '@pezkuwi/types/types';
import type { BN } from '@pezkuwi/util';
import { useMemo } from 'react';
import { createNamedHook, useApi, useBestNumber, useCall } from '@pezkuwi/react-hooks';
import { BN_ZERO } from '@pezkuwi/util';
interface BountyApiTxs {
acceptCurator: SubmittableExtrinsicFunction<'promise'>;
approveBounty: SubmittableExtrinsicFunction<'promise'>;
awardBounty: SubmittableExtrinsicFunction<'promise'>;
claimBounty: SubmittableExtrinsicFunction<'promise'>;
closeBounty: SubmittableExtrinsicFunction<'promise'>;
extendBountyExpiry: SubmittableExtrinsicFunction<'promise'>;
proposeBounty: SubmittableExtrinsicFunction<'promise'>;
proposeCurator: SubmittableExtrinsicFunction<'promise'>;
unassignCurator: SubmittableExtrinsicFunction<'promise'>;
}
interface BountyApiConstants {
bountyCuratorDeposit: BN;
bountyDepositBase: BN;
bountyUpdatePeriod?: BN;
bountyValueMinimum: BN;
dataDepositPerByte: BN;
maximumReasonLength: number;
}
interface BountyApiStatics extends BountyApiConstants, BountyApiTxs {
// nothing additional
}
export interface BountyApi extends BountyApiStatics {
bestNumber?: BN;
bounties?: DeriveBounties;
bountyCount?: BN;
childCount?: BN;
}
function getStatics (api: ApiPromise): BountyApiStatics {
const constsBase = api.consts.bounties || api.consts.treasury;
const txBase = api.tx.bounties || api.tx.treasury;
return {
// constants
bountyCuratorDeposit: (constsBase.bountyCuratorDeposit as (BN & Codec)) || BN_ZERO,
bountyDepositBase: constsBase.bountyDepositBase,
bountyUpdatePeriod: constsBase.bountyUpdatePeriod,
bountyValueMinimum: constsBase.bountyValueMinimum,
dataDepositPerByte: constsBase.dataDepositPerByte,
maximumReasonLength: constsBase.maximumReasonLength.toNumber(),
// extrinsics
// eslint-disable-next-line sort-keys
acceptCurator: txBase.acceptCurator,
approveBounty: txBase.approveBounty,
awardBounty: txBase.awardBounty,
claimBounty: txBase.claimBounty,
closeBounty: txBase.closeBounty,
extendBountyExpiry: txBase.extendBountyExpiry,
proposeBounty: txBase.proposeBounty,
proposeCurator: txBase.proposeCurator,
unassignCurator: txBase.unassignCurator
};
}
function useBountiesImpl (): BountyApi {
const { api } = useApi();
const bounties = useCall<DeriveBounties>(api.derive.bounties.bounties);
const bountyCount = useCall<BN>((api.query.bounties || api.query.treasury).bountyCount);
const childCount = useCall<BN>(api.query.childBounties?.childBountyCount);
const bestNumber = useBestNumber();
const statics = useMemo(
() => getStatics(api),
[api]
);
return useMemo(
(): BountyApi => ({
...statics,
bestNumber,
bounties,
bountyCount,
childCount
}),
[bestNumber, bounties, bountyCount, childCount, statics]
);
}
export const useBounties = createNamedHook('useBounties', useBountiesImpl);
@@ -0,0 +1,19 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletBountiesBountyStatus } from '@pezkuwi/types/lookup';
import type { BountyStatusType } from '../types.js';
import { useCallback } from 'react';
import { createNamedHook } from '@pezkuwi/react-hooks';
import { getBountyStatus } from '../helpers/index.js';
function useBountyStatusImpl (status: PalletBountiesBountyStatus): BountyStatusType {
const updateStatus = useCallback(() => getBountyStatus(status), [status]);
return updateStatus();
}
export const useBountyStatus = createNamedHook('useBountyStatus', useBountyStatusImpl);
@@ -0,0 +1,37 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountId } from '@pezkuwi/types/interfaces';
import type { UserRole } from '../types.js';
import { useMemo } from 'react';
import { createNamedHook, useAccounts, useCollectiveMembers } from '@pezkuwi/react-hooks';
export interface UserRolesInfo { roles: UserRole[], isCurator: boolean }
function useUserRoleImpl (curatorId?: AccountId): UserRolesInfo {
const { allAccounts, hasAccounts } = useAccounts();
const { isMember } = useCollectiveMembers('council');
return useMemo((): UserRolesInfo => {
const isCurator = !!curatorId && allAccounts.includes(curatorId.toString());
const roles: UserRole[] = [];
if (isCurator) {
roles.push('Curator');
}
if (isMember) {
roles.push('Member');
}
if (hasAccounts) {
roles.push('User');
}
return { isCurator, roles };
}, [allAccounts, curatorId, hasAccounts, isMember]);
}
export const useUserRole = createNamedHook('useUserRole', useUserRoleImpl);
+40
View File
@@ -0,0 +1,40 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useRef } from 'react';
import { Tabs } from '@pezkuwi/react-components';
import Bounties from './Bounties.js';
import { useTranslation } from './translate.js';
export { default as useCounter } from './useCounter.js';
interface Props {
basePath: string;
className?: string;
}
function BountiesApp ({ basePath, className = '' }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const itemsRef = useRef([
{
isRoot: true,
name: 'index',
text: t('Overview')
}
]);
return (
<main className={`${className} bounties--App`}>
<Tabs
basePath={basePath}
items={itemsRef.current}
/>
<Bounties />
</main>
);
}
export default React.memo(BountiesApp);
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2017-2025 @pezkuwi/app-bounties 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-bounties');
}
+20
View File
@@ -0,0 +1,20 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountId, BlockNumber } from '@pezkuwi/types/interfaces/runtime';
export interface BountyStatusType {
beneficiary: AccountId | undefined;
bountyStatus: StatusName;
curator: AccountId | undefined;
unlockAt: BlockNumber | undefined;
updateDue: BlockNumber | undefined;
}
export type HelpMessages = Record<StatusName, string>;
export type StatusName = 'Active' | 'Approved' | 'CuratorProposed' | 'Funded' | 'PendingPayout' | 'Proposed';
export type BountyVotingStatuses = { [status in StatusName]: string[] };
export type ValidUnassignCuratorAction = 'UnassignCurator' | 'SlashCuratorMotion' | 'SlashCuratorAction';
export type UnassignCuratorAction = ValidUnassignCuratorAction | 'None';
export type UserRole = 'User' | 'Member' | 'Curator' | 'None';
+20
View File
@@ -0,0 +1,20 @@
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveBounties } from '@pezkuwi/api-derive/types';
import { useMemo } from 'react';
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
function useCounterImpl (): number {
const { api, isApiReady } = useApi();
const bounties = useCall<DeriveBounties>(isApiReady && api.derive.bounties?.bounties);
return useMemo(
() => bounties?.length || 0,
[bounties]
);
}
export default createNamedHook('useCounter', useCounterImpl);