diff --git a/frontend/client/components/Placeholder/index.tsx b/frontend/client/components/Placeholder/index.tsx new file mode 100644 index 00000000..08de8e2f --- /dev/null +++ b/frontend/client/components/Placeholder/index.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import * as Styled from './styled'; + +interface Props { + title?: React.ReactNode; + subtitle?: React.ReactNode; + style?: React.CSSProperties; +} + +const Placeholder: React.SFC = ({ style = {}, title, subtitle }) => ( + + {title && {title}} + {subtitle && {subtitle}} + +); + +export default Placeholder; diff --git a/frontend/client/components/Placeholder/styled.ts b/frontend/client/components/Placeholder/styled.ts new file mode 100644 index 00000000..59c65f8c --- /dev/null +++ b/frontend/client/components/Placeholder/styled.ts @@ -0,0 +1,26 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + border: 2px dashed #d9d9d9; + padding: 3rem; + border-radius: 8px; +`; + +export const Title = styled.h3` + margin-bottom: 0; + color: rgba(0, 0, 0, 0.6); + font-size: 1.6rem; + + & + div { + margin-top: 1rem; + } +`; + +export const Subtitle = styled.div` + color: rgba(0, 0, 0, 0.4); + font-size: 1rem; +`; diff --git a/frontend/client/components/Proposal/Governance/Milestones.tsx b/frontend/client/components/Proposal/Governance/Milestones.tsx index 99de36f6..7aeb47b0 100644 --- a/frontend/client/components/Proposal/Governance/Milestones.tsx +++ b/frontend/client/components/Proposal/Governance/Milestones.tsx @@ -187,30 +187,15 @@ class Milestones extends React.Component { <>
{showVoteProgress && ( -
+ `${p}%`} status="exception" /> -
- voted against payout -
-
+ voted against payout + )}
{content} diff --git a/frontend/client/components/Proposal/Governance/Refunds.tsx b/frontend/client/components/Proposal/Governance/Refunds.tsx index a1da5912..2bd8dc0e 100644 --- a/frontend/client/components/Proposal/Governance/Refunds.tsx +++ b/frontend/client/components/Proposal/Governance/Refunds.tsx @@ -1,56 +1,182 @@ import React from 'react'; -import { Spin, Progress, Button } from 'antd'; +import { connect } from 'react-redux'; +import { Spin, Progress, Button, Alert } from 'antd'; import { ProposalWithCrowdFund } from 'modules/proposals/reducers'; import Web3Container, { Web3RenderProps } from 'lib/Web3Container'; +import { web3Actions } from 'modules/web3'; +import { AppState } from 'store/reducers'; +import * as Styled from './styled'; interface OwnProps { proposal: ProposalWithCrowdFund; } -interface Web3Props { - web3: Web3RenderProps['web3']; +interface StateProps { + isRefundActionPending: AppState['web3']['isRefundActionPending']; + refundActionError: AppState['web3']['refundActionError']; } -type Props = OwnProps & Web3Props; +interface ActionProps { + voteRefund: typeof web3Actions['voteRefund']; + withdrawRefund: typeof web3Actions['withdrawRefund']; +} + +interface Web3Props { + web3: Web3RenderProps['web3']; + account: Web3RenderProps['accounts'][0]; +} + +type Props = OwnProps & StateProps & ActionProps & Web3Props; class GovernanceRefunds extends React.Component { render() { - const fundPct = 32; + const { proposal, account, isRefundActionPending, refundActionError } = this.props; + const { crowdFund } = proposal; + const contributor = crowdFund.contributors.find(c => c.address === account); + const isTrustee = crowdFund.trustees.includes(account); + const hasVotedForRefund = contributor && contributor.refundVote; + const hasRefunded = contributor && contributor.refunded; + const refundPct = Math.floor( + (crowdFund.amountVotingForRefund / crowdFund.target) * 100, + ); + const color = refundPct < 10 ? '#1890ff' : refundPct < 50 ? '#faad14' : '#f5222d'; + + let text; + let button; + if (!isTrustee && contributor) { + if (refundPct < 50) { + text = ` + As a funder of this project, you have the right to vote for a refund. If the + amount of funds contributed by refund voters exceeds half of the project's + total raised funds, all funders will be able to request refunds. + `; + if (hasVotedForRefund) { + button = { + text: 'Undo vote for refund', + type: 'danger', + onClick: () => this.voteRefund(false), + }; + } else { + button = { + text: 'Vote for refund', + type: 'danger', + onClick: () => this.voteRefund(true), + }; + } + } else { + if (hasRefunded) { + return ( + + ); + } else { + text = ( + <> + The majority of funders have voted for a refund. Click below to receive your + refund. + {!crowdFund.isFrozen && ( + + )} + + ); + button = { + text: 'Get your refund', + type: 'primary', + onClick: () => this.withdrawRefund(), + }; + } + } + } else { + if (refundPct < 50) { + text = ` + Funders can vote to request refunds. If the amount of funds contributed by + refund voters exceeds half of the funds contributed, all funders will be able + to request refunds. + `; + } else { + text = ` + The funders of this project have voted for a refund. All funders can request refunds, + and the project will no longer receive any payouts. + `; + } + } return ( -
-
- `${p}%`} - status="exception" + <> +
+ + `${p}%`} /> + voted for a refund + +
+

{text}

+ {button && ( + + )} +
+
+ {refundActionError && ( + -

voted for a refund

-
-
-

- As a funder of this project, you have the right to vote for a refund. If the - amount of funds contributed by refund voters exceeds half of the project's - total raised funds, a refund will be issued to everyone. -

- -
-
+ )} + ); } + + voteRefund = (vote: boolean) => { + this.props.voteRefund(this.props.proposal.crowdFundContract, vote); + }; + + withdrawRefund = () => { + const { proposal, account } = this.props; + this.props.withdrawRefund(proposal.crowdFundContract, account); + }; } +const ConnectedGovernanceRefunds = connect( + state => ({ + isRefundActionPending: state.web3.isRefundActionPending, + refundActionError: state.web3.refundActionError, + }), + { + voteRefund: web3Actions.voteRefund, + withdrawRefund: web3Actions.withdrawRefund, + }, +)(GovernanceRefunds); + export default (props: OwnProps) => ( } - render={({ web3 }: Web3RenderProps) => } + render={({ web3, accounts }: Web3RenderProps) => ( + + )} /> ); diff --git a/frontend/client/components/Proposal/Governance/index.tsx b/frontend/client/components/Proposal/Governance/index.tsx index cf2f60f7..0c34f86b 100644 --- a/frontend/client/components/Proposal/Governance/index.tsx +++ b/frontend/client/components/Proposal/Governance/index.tsx @@ -2,6 +2,7 @@ import React from 'react'; import GovernanceMilestones from './Milestones'; import GovernanceRefunds from './Refunds'; import { ProposalWithCrowdFund } from 'modules/proposals/reducers'; +import Placeholder from 'components/Placeholder'; import * as Styled from './styled'; interface Props { @@ -14,10 +15,14 @@ export default class ProposalGovernance extends React.Component { if (!proposal.crowdFund.isRaiseGoalReached) { return ( -

- Milestone history and voting will be displayed here once the project has been - funded. -

+ ); } diff --git a/frontend/client/components/Proposal/Governance/styled.ts b/frontend/client/components/Proposal/Governance/styled.ts index 3764d4b9..a5c4bbbc 100644 --- a/frontend/client/components/Proposal/Governance/styled.ts +++ b/frontend/client/components/Proposal/Governance/styled.ts @@ -28,3 +28,22 @@ export const GovernanceDivider = styled.div` export const MilestoneActionText = styled.p` font-size: 1rem; `; + +// Shared +export const ProgressContainer = styled<{ stroke?: string }, 'div'>('div')` + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + margin: 0 2rem 0.5rem 0; + + .ant-progress-circle-path { + stroke: ${p => p.stroke || 'inherit'}; + } +`; + +export const ProgressText = styled.div` + white-space: nowrap; + opacity: 0.6; + font-size: 0.75rem; +`; diff --git a/frontend/client/modules/proposals/reducers.ts b/frontend/client/modules/proposals/reducers.ts index be67766f..75197289 100644 --- a/frontend/client/modules/proposals/reducers.ts +++ b/frontend/client/modules/proposals/reducers.ts @@ -16,6 +16,7 @@ export interface Contributor { address: string; contributionAmount: string; refundVote: boolean; + refunded: boolean; proportionalContribution: string; milestoneNoVotes: boolean[]; } @@ -52,8 +53,10 @@ export interface ProposalMilestone extends Milestone { export interface CrowdFund { immediateFirstMilestonePayout: boolean; + balance: number; funded: number; target: number; + amountVotingForRefund: number; beneficiary: string; deadline: number; trustees: string[]; diff --git a/frontend/client/modules/web3/actions.ts b/frontend/client/modules/web3/actions.ts index 6ab7284a..19b75c85 100644 --- a/frontend/client/modules/web3/actions.ts +++ b/frontend/client/modules/web3/actions.ts @@ -301,3 +301,71 @@ export function voteMilestonePayout( } }; } + +export function voteRefund(crowdFundContract: any, vote: boolean) { + return async (dispatch: Dispatch, getState: GetState) => { + dispatch({ type: types.VOTE_REFUND_PENDING }); + const state = getState(); + const account = state.web3.accounts[0]; + + try { + await crowdFundContract.methods + .voteRefund(vote) + .send({ from: account }) + .once('confirmation', async () => { + await sleep(5000); + await dispatch(fetchProposal(crowdFundContract._address)); + dispatch({ type: types.VOTE_REFUND_FULFILLED }); + }); + } catch (err) { + dispatch({ + type: types.VOTE_REFUND_REJECTED, + payload: err.message || err.toString(), + error: true, + }); + } + }; +} + +export function withdrawRefund(crowdFundContract: any, address: string) { + return async (dispatch: Dispatch, getState: GetState) => { + dispatch({ type: types.WITHDRAW_REFUND_PENDING }); + const state = getState(); + const account = state.web3.accounts[0]; + + try { + let isFrozen = await crowdFundContract.methods.frozen().call({ from: account }); + if (!isFrozen) { + await new Promise(resolve => { + crowdFundContract.methods + .refund() + .send({ from: account }) + .once('confirmation', async () => { + await sleep(5000); + isFrozen = await crowdFundContract.methods.frozen().call({ from: account }); + resolve(); + }); + }); + } + + if (!isFrozen) { + throw new Error('Proposal isn’t in a refundable state yet.'); + } + + await crowdFundContract.methods + .withdraw(address) + .send({ from: account }) + .once('confirmation', async () => { + await sleep(5000); + await dispatch(fetchProposal(crowdFundContract._address)); + dispatch({ type: types.WITHDRAW_REFUND_FULFILLED }); + }); + } catch (err) { + dispatch({ + type: types.WITHDRAW_REFUND_REJECTED, + payload: err.message || err.toString(), + error: true, + }); + } + }; +} diff --git a/frontend/client/modules/web3/reducers.ts b/frontend/client/modules/web3/reducers.ts index 0809aa02..8a764fbc 100644 --- a/frontend/client/modules/web3/reducers.ts +++ b/frontend/client/modules/web3/reducers.ts @@ -27,6 +27,9 @@ export interface Web3State { isMilestoneActionPending: boolean; milestoneActionError: null | string; + + isRefundActionPending: boolean; + refundActionError: null | string; } export const INITIAL_STATE: Web3State = { @@ -52,6 +55,9 @@ export const INITIAL_STATE: Web3State = { isMilestoneActionPending: false, milestoneActionError: null, + + isRefundActionPending: false, + refundActionError: null, }; function addContract(state: Web3State, payload: Contract) { @@ -198,6 +204,27 @@ export default (state = INITIAL_STATE, action: any): Web3State => { isMilestoneActionPending: false, }; + case types.VOTE_REFUND_PENDING: + case types.WITHDRAW_REFUND_PENDING: + return { + ...state, + isRefundActionPending: true, + refundActionError: null, + }; + case types.VOTE_REFUND_FULFILLED: + case types.WITHDRAW_REFUND_FULFILLED: + return { + ...state, + isRefundActionPending: false, + }; + case types.VOTE_REFUND_REJECTED: + case types.WITHDRAW_REFUND_REJECTED: + return { + ...state, + refundActionError: payload, + isRefundActionPending: false, + }; + default: return state; } diff --git a/frontend/client/modules/web3/types.ts b/frontend/client/modules/web3/types.ts index be26206e..6c19d71f 100644 --- a/frontend/client/modules/web3/types.ts +++ b/frontend/client/modules/web3/types.ts @@ -35,6 +35,16 @@ enum web3Types { VOTE_AGAINST_MILESTONE_PAYOUT_REJECTED = 'VOTE_AGAINST_MILESTONE_PAYOUT_REJECTED', VOTE_AGAINST_MILESTONE_PAYOUT_PENDING = 'VOTE_AGAINST_MILESTONE_PAYOUT_PENDING', + VOTE_REFUND = 'VOTE_REFUND', + VOTE_REFUND_FULFILLED = 'VOTE_REFUND_FULFILLED', + VOTE_REFUND_REJECTED = 'VOTE_REFUND_REJECTED', + VOTE_REFUND_PENDING = 'VOTE_REFUND_PENDING', + + WITHDRAW_REFUND = 'WITHDRAW_REFUND', + WITHDRAW_REFUND_FULFILLED = 'WITHDRAW_REFUND_FULFILLED', + WITHDRAW_REFUND_REJECTED = 'WITHDRAW_REFUND_REJECTED', + WITHDRAW_REFUND_PENDING = 'WITHDRAW_REFUND_PENDING', + ACCOUNTS = 'ACCOUNTS', ACCOUNTS_FULFILLED = 'ACCOUNTS_FULFILLED', ACCOUNTS_REJECTED = 'ACCOUNTS_REJECTED', diff --git a/frontend/client/web3interact/crowdFund.ts b/frontend/client/web3interact/crowdFund.ts index f410a60d..9a76010b 100644 --- a/frontend/client/web3interact/crowdFund.ts +++ b/frontend/client/web3interact/crowdFund.ts @@ -17,9 +17,11 @@ export async function getCrowdFundState( const isRaiseGoalReached = await crowdFundContract.methods .isRaiseGoalReached() .call({ from: account }); - const funded = isRaiseGoalReached - ? target - : await web3.eth.getBalance(crowdFundContract._address); + const balance = await web3.eth.getBalance(crowdFundContract._address); + const funded = isRaiseGoalReached ? target : balance; + const amountVotingForRefund = isRaiseGoalReached + ? await crowdFundContract.methods.amountVotingForRefund().call({ from: account }) + : '0'; const isFrozen = await crowdFundContract.methods.frozen().call({ from: account }); const trustees = await collectArrayElements( @@ -115,8 +117,13 @@ export async function getCrowdFundState( return { immediateFirstMilestonePayout, + // TODO: Bignumber these 4 + balance: parseFloat(web3.utils.fromWei(String(balance), 'ether')), funded: parseFloat(web3.utils.fromWei(String(funded), 'ether')), target: parseFloat(web3.utils.fromWei(String(target), 'ether')), + amountVotingForRefund: parseFloat( + web3.utils.fromWei(String(amountVotingForRefund), 'ether'), + ), beneficiary, deadline, trustees, diff --git a/frontend/package.json b/frontend/package.json index 6f9230c9..46b0fe8f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,7 @@ "tsc": "tsc", "link-contracts": "cd client/lib && ln -s ../../build/contracts contracts", "ganache": "ganache-cli -b 5", - "truffle": "truffle exec ./bin/init-truffle.js && truffle console" + "truffle": "truffle exec ./bin/init-truffle.js && cd client/lib/contracts && truffle console" }, "husky": { "hooks": {